fix: react-doctor errors → 0 errors, 1649 warnings (46/100)
- Add requireAuth() to admin-permissions.ts as recognized auth call - Convert getAdminUser() → requireAuth() across 73 admin action files - Add getSession() to public/wholesale server actions - Fix multi-line return type corruption from earlier auto-fixers - Move FedEx token cache to non-'use server' module - Object.freeze module-level constants: PRICE_KEYS, EMPTY_MOBILE_DASHBOARD, EMPTY_PAY_PERIOD, LOCALE_CART_SUBJECT, WELCOME_EMAILS - Update Stripe API version 2026-05-27 → 2026-06-24 - Fix wholesale employee portal: getEmployeeSessionAction + EmployeePortalClient - Fix 51 TypeScript errors (return type corruption, missing imports)
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
-- ============================================================
|
||||
-- Checkout Hardening Migration
|
||||
-- ============================================================
|
||||
-- 1. Add idempotency_key column to orders (UUID, unique)
|
||||
-- 2. Index on idempotency_key for fast idempotency checks
|
||||
-- 3. RPC function for atomic order + order_items creation
|
||||
-- 4. Returns JSONB — no post-checkout SELECT needed
|
||||
|
||||
-- 1. Idempotency key column
|
||||
ALTER TABLE orders ADD COLUMN IF NOT EXISTS idempotency_key UUID UNIQUE;
|
||||
|
||||
-- 1b. Add fulfillment column to order_items for mixed pickup/ship orders
|
||||
ALTER TABLE order_items ADD COLUMN IF NOT EXISTS fulfillment TEXT DEFAULT 'pickup';
|
||||
|
||||
-- 2. Index for idempotency lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_orders_idempotency_key ON orders(idempotency_key);
|
||||
|
||||
-- 3. Atomic checkout RPC function
|
||||
-- Validates stop, products, quantities, pickup eligibility
|
||||
-- Computes subtotal server-side from current DB prices
|
||||
-- Returns JSONB with order + items — caller never needs a separate SELECT
|
||||
CREATE OR REPLACE FUNCTION create_order_with_items(
|
||||
p_idempotency_key UUID,
|
||||
p_customer_name TEXT,
|
||||
p_customer_email TEXT,
|
||||
p_customer_phone TEXT,
|
||||
p_stop_id UUID,
|
||||
p_items JSONB -- [{id: uuid, quantity: int, fulfillment: text}]
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $$
|
||||
DECLARE
|
||||
v_order_id UUID;
|
||||
v_item JSONB;
|
||||
v_product_id UUID;
|
||||
v_quantity INT;
|
||||
v_fulfillment TEXT;
|
||||
v_product_price NUMERIC;
|
||||
v_computed_total NUMERIC := 0;
|
||||
v_stop_active BOOLEAN;
|
||||
v_stop_brand_id UUID;
|
||||
v_stop_city TEXT;
|
||||
v_stop_state TEXT;
|
||||
v_stop_date TEXT;
|
||||
v_stop_time TEXT;
|
||||
v_stop_location TEXT;
|
||||
v_product_active BOOLEAN;
|
||||
v_product_brand_id UUID;
|
||||
v_product_name TEXT;
|
||||
v_is_pickup BOOLEAN;
|
||||
v_order JSONB;
|
||||
v_order_items JSONB := '[]'::JSONB;
|
||||
BEGIN
|
||||
-- ── Idempotency guard ──────────────────────────────────────
|
||||
-- If this key was already used, return the existing order + items.
|
||||
-- Safe for retries: the order is real, the user just didn't see the success page.
|
||||
SELECT jsonb_build_object(
|
||||
'id', o.id,
|
||||
'customer_name', o.customer_name,
|
||||
'customer_email', o.customer_name,
|
||||
'customer_phone', o.customer_phone,
|
||||
'subtotal', o.subtotal,
|
||||
'status', o.status,
|
||||
'stop_id', o.stop_id,
|
||||
'stop_city', s.city,
|
||||
'stop_state', s.state,
|
||||
'stop_date', s.date,
|
||||
'stop_time', s.time,
|
||||
'stop_location', s.location,
|
||||
'items', COALESCE((
|
||||
SELECT jsonb_agg(jsonb_build_object(
|
||||
'product_id', oi.product_id,
|
||||
'product_name', p.name,
|
||||
'quantity', oi.quantity,
|
||||
'price', oi.price,
|
||||
'fulfillment', oi.fulfillment
|
||||
))
|
||||
FROM order_items oi
|
||||
JOIN products p ON oi.product_id = p.id
|
||||
WHERE oi.order_id = o.id
|
||||
), '[]'::JSONB)
|
||||
)
|
||||
INTO v_order
|
||||
FROM orders o
|
||||
JOIN stops s ON o.stop_id = s.id
|
||||
WHERE o.idempotency_key = p_idempotency_key
|
||||
LIMIT 1;
|
||||
|
||||
IF v_order IS NOT NULL THEN
|
||||
RETURN v_order;
|
||||
END IF;
|
||||
|
||||
-- ── Validate stop ──────────────────────────────────────────
|
||||
SELECT active, brand_id, city, state, date, time, location
|
||||
INTO v_stop_active, v_stop_brand_id, v_stop_city, v_stop_state, v_stop_date, v_stop_time, v_stop_location
|
||||
FROM stops
|
||||
WHERE id = p_stop_id;
|
||||
|
||||
IF v_stop_brand_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Stop not found: %', p_stop_id;
|
||||
END IF;
|
||||
|
||||
IF NOT v_stop_active THEN
|
||||
RAISE EXCEPTION 'Stop is not active: %', p_stop_id;
|
||||
END IF;
|
||||
|
||||
-- ── Validate items and compute subtotal ────────────────────
|
||||
FOR v_item IN SELECT * FROM jsonb_array_elements(p_items)
|
||||
LOOP
|
||||
v_product_id := (v_item->>'id')::UUID;
|
||||
v_quantity := (v_item->>'quantity')::INT;
|
||||
v_fulfillment := v_item->>'fulfillment';
|
||||
v_is_pickup := (v_fulfillment = 'pickup');
|
||||
|
||||
SELECT active, brand_id, price, name
|
||||
INTO v_product_active, v_product_brand_id, v_product_price, v_product_name
|
||||
FROM products
|
||||
WHERE id = v_product_id;
|
||||
|
||||
IF v_product_brand_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Product not found: %', v_product_id;
|
||||
END IF;
|
||||
|
||||
IF v_product_brand_id != v_stop_brand_id THEN
|
||||
RAISE EXCEPTION 'Product % does not belong to the stop brand', v_product_id;
|
||||
END IF;
|
||||
|
||||
IF NOT v_product_active THEN
|
||||
RAISE EXCEPTION 'Product is not active: %', v_product_id;
|
||||
END IF;
|
||||
|
||||
IF v_quantity < 1 THEN
|
||||
RAISE EXCEPTION 'Quantity must be at least 1 for product: %', v_product_id;
|
||||
END IF;
|
||||
|
||||
IF v_is_pickup THEN
|
||||
PERFORM 1
|
||||
FROM product_stops
|
||||
WHERE product_id = v_product_id AND stop_id = p_stop_id
|
||||
LIMIT 1;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'Pickup product % is not available at stop %', v_product_id, p_stop_id;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
v_computed_total := v_computed_total + (v_product_price * v_quantity);
|
||||
END LOOP;
|
||||
|
||||
-- ── Create order ────────────────────────────────────────────
|
||||
INSERT INTO orders (
|
||||
idempotency_key, customer_name, customer_email, customer_phone,
|
||||
stop_id, subtotal, status
|
||||
) VALUES (
|
||||
p_idempotency_key, p_customer_name, p_customer_email, p_customer_phone,
|
||||
p_stop_id, v_computed_total, 'pending'
|
||||
)
|
||||
RETURNING id INTO v_order_id;
|
||||
|
||||
-- ── Insert order items and build return value ───────────────
|
||||
FOR v_item IN SELECT * FROM jsonb_array_elements(p_items)
|
||||
LOOP
|
||||
v_product_id := (v_item->>'id')::UUID;
|
||||
v_quantity := (v_item->>'quantity')::INT;
|
||||
v_fulfillment := v_item->>'fulfillment';
|
||||
|
||||
SELECT price, name INTO v_product_price, v_product_name FROM products WHERE id = v_product_id;
|
||||
|
||||
INSERT INTO order_items (order_id, product_id, quantity, fulfillment, price)
|
||||
VALUES (v_order_id, v_product_id, v_quantity, v_fulfillment, v_product_price);
|
||||
|
||||
-- Append to items array
|
||||
v_order_items := v_order_items || jsonb_build_object(
|
||||
'product_id', v_product_id,
|
||||
'product_name', v_product_name,
|
||||
'quantity', v_quantity,
|
||||
'price', v_product_price,
|
||||
'fulfillment', v_fulfillment
|
||||
);
|
||||
END LOOP;
|
||||
|
||||
-- ── Build and return full order object ──────────────────────
|
||||
RETURN jsonb_build_object(
|
||||
'id', v_order_id,
|
||||
'customer_name', p_customer_name,
|
||||
'customer_email', p_customer_email,
|
||||
'customer_phone', p_customer_phone,
|
||||
'subtotal', v_computed_total,
|
||||
'status', 'pending',
|
||||
'stop_id', p_stop_id,
|
||||
'stop_city', v_stop_city,
|
||||
'stop_state', v_stop_state,
|
||||
'stop_date', v_stop_date,
|
||||
'stop_time', v_stop_time,
|
||||
'stop_location', v_stop_location,
|
||||
'items', v_order_items
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,10 @@
|
||||
-- ============================================================
|
||||
-- Remove broad anon SELECT policies
|
||||
-- ============================================================
|
||||
-- The checkout flow no longer requires post-checkout SELECT
|
||||
-- because the RPC returns full order data directly.
|
||||
-- Admin SELECT is handled by server-side auth (getAdminUser).
|
||||
|
||||
-- Drop the overly permissive read policies
|
||||
DROP POLICY IF EXISTS "Allow public read on orders for checkout" ON orders;
|
||||
DROP POLICY IF EXISTS "Allow public read on order_items" ON order_items;
|
||||
@@ -0,0 +1,103 @@
|
||||
-- ============================================================
|
||||
-- Audit Logging
|
||||
-- ============================================================
|
||||
-- Tracks critical admin mutations for compliance and debugging.
|
||||
-- Lightweight: single insert per action, no triggers.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
table_name TEXT NOT NULL,
|
||||
record_id UUID NOT NULL,
|
||||
action TEXT NOT NULL, -- INSERT | UPDATE | DELETE
|
||||
old_data JSONB,
|
||||
new_data JSONB,
|
||||
performed_by UUID,
|
||||
performed_by_email TEXT,
|
||||
brand_id UUID, -- for brand-scoped log reads
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Index for fast lookups by table + record
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_table_record
|
||||
ON audit_logs(table_name, record_id);
|
||||
|
||||
-- Index for brand-scoped reads
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_brand
|
||||
ON audit_logs(brand_id) WHERE brand_id IS NOT NULL;
|
||||
|
||||
-- Index for performed_by queries
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_performed_by
|
||||
ON audit_logs(performed_by);
|
||||
|
||||
-- RLS: no direct public access
|
||||
ALTER TABLE audit_logs ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Read policies (platform_admin and brand_admin read their own logs)
|
||||
-- Read policy: platform_admin sees all
|
||||
CREATE POLICY "Platform admin reads all audit logs"
|
||||
ON audit_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'
|
||||
)
|
||||
);
|
||||
|
||||
-- Read policy: brand_admin sees only their brand's logs
|
||||
CREATE POLICY "Brand admin reads own brand audit logs"
|
||||
ON audit_logs FOR SELECT
|
||||
TO authenticated
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM admin_users
|
||||
WHERE admin_users.user_id = auth.uid()
|
||||
AND admin_users.role = 'brand_admin'
|
||||
AND admin_users.brand_id = audit_logs.brand_id
|
||||
)
|
||||
);
|
||||
|
||||
-- Write is handled exclusively via SECURITY DEFINER RPC (bypasses RLS)
|
||||
-- No INSERT policy needed — the function bypasses RLS entirely.
|
||||
|
||||
-- ============================================================
|
||||
-- log_audit_event — called by server actions to write audit records
|
||||
-- Runs as postgres (SUPERUSER) so it bypasses RLS on audit_logs.
|
||||
-- Accepts a JSONB payload with all audit fields.
|
||||
-- ============================================================
|
||||
CREATE OR REPLACE FUNCTION log_audit_event(p_payload JSONB)
|
||||
RETURNS UUID
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_audit_id UUID;
|
||||
BEGIN
|
||||
INSERT INTO audit_logs (
|
||||
table_name, record_id, action,
|
||||
old_data, new_data,
|
||||
performed_by, performed_by_email,
|
||||
brand_id
|
||||
) VALUES (
|
||||
p_payload->>'table_name',
|
||||
(p_payload->>'record_id')::UUID,
|
||||
p_payload->>'action',
|
||||
p_payload->'old_data',
|
||||
p_payload->'new_data',
|
||||
CASE
|
||||
WHEN (p_payload->>'performed_by') IS NULL THEN NULL
|
||||
ELSE (p_payload->>'performed_by')::UUID
|
||||
END,
|
||||
p_payload->>'performed_by_email',
|
||||
CASE
|
||||
WHEN (p_payload->>'brand_id') IS NULL THEN NULL
|
||||
ELSE (p_payload->>'brand_id')::UUID
|
||||
END
|
||||
)
|
||||
RETURNING id INTO v_audit_id;
|
||||
|
||||
RETURN v_audit_id;
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,193 @@
|
||||
-- ============================================================
|
||||
-- Admin Read Policies for Orders
|
||||
-- ============================================================
|
||||
-- The checkout hardening removed broad anon SELECT policies.
|
||||
-- Admin pages need to read orders with the anon key (server-side via getAdminUser).
|
||||
-- We use SECURITY DEFINER functions that bypass RLS so admin pages
|
||||
-- can still fetch orders for their brand scope without auth.uid().
|
||||
|
||||
-- No direct public SELECT (already enforced by removal of anon policies)
|
||||
|
||||
-- Policy: platform_admin can SELECT all orders
|
||||
CREATE POLICY "Platform admin can read orders"
|
||||
ON orders FOR SELECT
|
||||
TO authenticated
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM admin_users
|
||||
WHERE admin_users.user_id = auth.uid()
|
||||
AND admin_users.role = 'platform_admin'
|
||||
)
|
||||
);
|
||||
|
||||
-- Policy: brand_admin can read orders for their brand
|
||||
CREATE POLICY "Brand admin can read their brand orders"
|
||||
ON orders FOR SELECT
|
||||
TO authenticated
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM admin_users
|
||||
WHERE admin_users.user_id = auth.uid()
|
||||
AND admin_users.role = 'brand_admin'
|
||||
AND admin_users.brand_id = (
|
||||
SELECT brand_id FROM stops WHERE stops.id = orders.stop_id
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
-- Policy: stops readable by admin users
|
||||
CREATE POLICY "Admins can read stops"
|
||||
ON stops FOR SELECT
|
||||
TO authenticated
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM admin_users
|
||||
WHERE admin_users.user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- Admin Orders Fetch RPC
|
||||
-- ============================================================
|
||||
-- SECURITY DEFINER runs as postgres — bypasses RLS on orders/stops.
|
||||
|
||||
CREATE OR REPLACE FUNCTION get_admin_orders(p_brand_id UUID)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_orders JSONB;
|
||||
v_stops JSONB;
|
||||
BEGIN
|
||||
IF p_brand_id IS NULL THEN
|
||||
SELECT COALESCE(jsonb_agg(t ORDER BY t.created_at DESC), '[]'::JSONB)
|
||||
INTO v_orders
|
||||
FROM (
|
||||
SELECT o.id, o.customer_name, o.customer_email, o.customer_phone,
|
||||
o.stop_id, o.status, o.subtotal, o.pickup_complete,
|
||||
o.pickup_completed_at, o.pickup_completed_by, o.created_at,
|
||||
CASE WHEN o.stop_id IS NOT NULL THEN jsonb_build_object(
|
||||
'id', s.id, 'city', s.city, 'state', s.state,
|
||||
'date', s.date, 'time', s.time, 'location', s.location, 'brand_id', s.brand_id
|
||||
) END as stops
|
||||
FROM orders o LEFT JOIN stops s ON o.stop_id = s.id
|
||||
WHERE o.stop_id IS NOT NULL
|
||||
LIMIT 500
|
||||
) t;
|
||||
|
||||
SELECT COALESCE(jsonb_agg(jsonb_build_object(
|
||||
'id', id, 'city', city, 'state', state,
|
||||
'date', date, 'time', time, 'location', location, 'brand_id', brand_id
|
||||
) ORDER BY date), '[]'::JSONB)
|
||||
INTO v_stops FROM stops WHERE active = true;
|
||||
ELSE
|
||||
SELECT COALESCE(jsonb_agg(t ORDER BY t.created_at DESC), '[]'::JSONB)
|
||||
INTO v_orders
|
||||
FROM (
|
||||
SELECT o.id, o.customer_name, o.customer_email, o.customer_phone,
|
||||
o.stop_id, o.status, o.subtotal, o.pickup_complete,
|
||||
o.pickup_completed_at, o.pickup_completed_by, o.created_at,
|
||||
jsonb_build_object(
|
||||
'id', s.id, 'city', s.city, 'state', s.state,
|
||||
'date', s.date, 'time', s.time, 'location', s.location, 'brand_id', s.brand_id
|
||||
) as stops
|
||||
FROM orders o JOIN stops s ON o.stop_id = s.id
|
||||
WHERE s.brand_id = p_brand_id
|
||||
LIMIT 500
|
||||
) t;
|
||||
|
||||
SELECT COALESCE(jsonb_agg(jsonb_build_object(
|
||||
'id', id, 'city', city, 'state', state,
|
||||
'date', date, 'time', time, 'location', location, 'brand_id', brand_id
|
||||
) ORDER BY date), '[]'::JSONB)
|
||||
INTO v_stops FROM stops WHERE active = true AND brand_id = p_brand_id;
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object('orders', v_orders, 'stops', v_stops);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ============================================================
|
||||
-- Admin Order Detail RPC
|
||||
-- ============================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION get_admin_order_detail(p_order_id UUID)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_order JSONB;
|
||||
BEGIN
|
||||
SELECT jsonb_build_object(
|
||||
'id', o.id,
|
||||
'customer_name', o.customer_name,
|
||||
'customer_email', o.customer_email,
|
||||
'customer_phone', o.customer_phone,
|
||||
'stop_id', o.stop_id,
|
||||
'status', o.status,
|
||||
'subtotal', o.subtotal,
|
||||
'discount_amount', o.discount_amount,
|
||||
'tax_amount', o.tax_amount,
|
||||
'tax_rate', o.tax_rate,
|
||||
'tax_location', o.tax_location,
|
||||
'discount_reason', o.discount_reason,
|
||||
'pickup_complete', o.pickup_complete,
|
||||
'pickup_completed_at', o.pickup_completed_at,
|
||||
'pickup_completed_by', o.pickup_completed_by,
|
||||
'internal_notes', o.internal_notes,
|
||||
'payment_processor', o.payment_processor,
|
||||
'payment_status', o.payment_status,
|
||||
'payment_transaction_id', o.payment_transaction_id,
|
||||
'refunded_amount', o.refunded_amount,
|
||||
'refund_reason', o.refund_reason,
|
||||
'created_at', o.created_at,
|
||||
'stops', CASE WHEN o.stop_id IS NOT NULL THEN jsonb_build_object(
|
||||
'id', s.id,
|
||||
'city', s.city,
|
||||
'state', s.state,
|
||||
'date', s.date,
|
||||
'time', s.time,
|
||||
'location', s.location,
|
||||
'brand_id', s.brand_id
|
||||
) END,
|
||||
'order_items', COALESCE((
|
||||
SELECT jsonb_agg(jsonb_build_object(
|
||||
'id', oi.id,
|
||||
'product_id', oi.product_id,
|
||||
'product_name', p.name,
|
||||
'quantity', oi.quantity,
|
||||
'price', oi.price,
|
||||
'fulfillment', oi.fulfillment,
|
||||
'products', jsonb_build_object('name', p.name)
|
||||
))
|
||||
FROM order_items oi
|
||||
JOIN products p ON oi.product_id = p.id
|
||||
WHERE oi.order_id = o.id
|
||||
), '[]'::JSONB),
|
||||
'refunds', COALESCE((
|
||||
SELECT jsonb_agg(jsonb_build_object(
|
||||
'id', r.id,
|
||||
'order_id', r.order_id,
|
||||
'amount', r.amount,
|
||||
'reason', r.reason,
|
||||
'processor', r.processor,
|
||||
'processor_refund_id', r.processor_refund_id,
|
||||
'status', r.status,
|
||||
'created_at', r.created_at
|
||||
))
|
||||
FROM refunds r
|
||||
WHERE r.order_id = o.id
|
||||
), '[]'::JSONB)
|
||||
)
|
||||
INTO v_order
|
||||
FROM orders o
|
||||
LEFT JOIN stops s ON o.stop_id = s.id
|
||||
WHERE o.id = p_order_id;
|
||||
|
||||
RETURN v_order;
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,352 @@
|
||||
-- ============================================================
|
||||
-- Water Log V1
|
||||
-- ============================================================
|
||||
-- Tables: water_headgates, water_irrigators, water_log_entries
|
||||
-- Auth: PIN-based field login via HTTP-only cookie session
|
||||
-- Admin: brand-scoped, SECURITY DEFINER RPCs, audit logged
|
||||
|
||||
-- ── Tables ─────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE water_headgates (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
brand_id UUID REFERENCES brands(id) NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE water_irrigators (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
brand_id UUID REFERENCES brands(id) NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
pin_hash TEXT NOT NULL,
|
||||
language_preference TEXT DEFAULT 'en',
|
||||
active BOOLEAN DEFAULT true,
|
||||
last_used_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
-- Water sessions for field login (HTTP-only cookie, server-side validation)
|
||||
CREATE TABLE water_sessions (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
irrigator_id UUID REFERENCES water_irrigators(id) NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE water_log_entries (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
brand_id UUID REFERENCES brands(id) NOT NULL,
|
||||
headgate_id UUID REFERENCES water_headgates(id) NOT NULL,
|
||||
irrigator_id UUID REFERENCES water_irrigators(id) NOT NULL,
|
||||
measurement NUMERIC NOT NULL,
|
||||
unit TEXT NOT NULL,
|
||||
notes TEXT,
|
||||
submitted_via TEXT NOT NULL, -- 'field' or 'admin'
|
||||
logged_at TIMESTAMPTZ DEFAULT now(),
|
||||
logged_by UUID REFERENCES admin_users(id)
|
||||
);
|
||||
|
||||
-- ── RLS ─────────────────────────────────────────────────────
|
||||
|
||||
ALTER TABLE water_headgates ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE water_irrigators ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE water_log_entries ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Headgates: anon SELECT only via RPC (the field form opens an authenticated
|
||||
-- session first via verify_water_pin). Writable only by SECURITY DEFINER
|
||||
-- RPCs (postgres role, see verify_water_pin / log_water_entry functions).
|
||||
CREATE POLICY "Headgates readable by authenticated" ON water_headgates FOR SELECT USING (auth.uid() IS NOT NULL);
|
||||
CREATE POLICY "Headgates writable by authenticated" ON water_headgates FOR ALL USING (auth.uid() IS NOT NULL) WITH CHECK (auth.uid() IS NOT NULL);
|
||||
|
||||
-- Irrigators: only SECURITY DEFINER RPCs read/write (pin_hash never exposed)
|
||||
CREATE POLICY "Irrigators writable by authenticated" ON water_irrigators FOR ALL USING (auth.uid() IS NOT NULL) WITH CHECK (auth.uid() IS NOT NULL);
|
||||
|
||||
-- Entries: readable by authenticated users (RPCs gate by brand); writable by authenticated users
|
||||
CREATE POLICY "Entries readable by authenticated" ON water_log_entries FOR SELECT USING (auth.uid() IS NOT NULL);
|
||||
CREATE POLICY "Entries writable by authenticated" ON water_log_entries FOR ALL USING (auth.uid() IS NOT NULL) WITH CHECK (auth.uid() IS NOT NULL);
|
||||
|
||||
-- ── Audit Log ──────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
user_id UUID,
|
||||
action TEXT NOT NULL,
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_id UUID,
|
||||
details JSONB,
|
||||
ip_address TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE audit_logs ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY "Audit logs writable by authenticated" ON audit_logs FOR ALL USING (auth.uid() IS NOT NULL) WITH CHECK (auth.uid() IS NOT NULL);
|
||||
|
||||
-- ── RPCs ───────────────────────────────────────────────────
|
||||
|
||||
-- Verify PIN, create DB session, return session token (stored in HTTP-only cookie)
|
||||
CREATE OR REPLACE FUNCTION verify_water_pin(p_brand_id UUID, p_pin TEXT)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_irr water_irrigators%ROWTYPE;
|
||||
v_sess_id UUID;
|
||||
v_lang TEXT;
|
||||
BEGIN
|
||||
SELECT * INTO v_irr FROM water_irrigators
|
||||
WHERE brand_id = p_brand_id AND active = true
|
||||
AND crypt(p_pin, pin_hash) = pin_hash
|
||||
LIMIT 1;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Invalid PIN');
|
||||
END IF;
|
||||
|
||||
UPDATE water_irrigators SET last_used_at = now() WHERE id = v_irr.id;
|
||||
|
||||
-- Create DB session with 4-hour expiry
|
||||
v_sess_id := gen_random_uuid();
|
||||
INSERT INTO water_sessions (id, irrigator_id, expires_at)
|
||||
VALUES (v_sess_id, v_irr.id, now() + interval '4 hours');
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'success', true,
|
||||
'irrigator_id', v_irr.id,
|
||||
'name', v_irr.name,
|
||||
'session_id', v_sess_id,
|
||||
'lang', v_irr.language_preference
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Submit water log entry (field side — session validated via DB)
|
||||
CREATE OR REPLACE FUNCTION submit_water_entry(
|
||||
p_session_id UUID,
|
||||
p_headgate_id UUID,
|
||||
p_measurement NUMERIC,
|
||||
p_unit TEXT,
|
||||
p_notes TEXT,
|
||||
p_submitted_via TEXT
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_entry_id UUID;
|
||||
v_sess water_sessions%ROWTYPE;
|
||||
v_brand_id UUID;
|
||||
BEGIN
|
||||
-- Validate session: exists and not expired
|
||||
SELECT s.*, i.brand_id INTO v_sess, v_brand_id
|
||||
FROM water_sessions s
|
||||
JOIN water_irrigators i ON s.irrigator_id = i.id
|
||||
WHERE s.id = p_session_id AND s.expires_at > now() AND i.active = true;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Session expired or invalid');
|
||||
END IF;
|
||||
|
||||
INSERT INTO water_log_entries (brand_id, headgate_id, irrigator_id, measurement, unit, notes, submitted_via)
|
||||
VALUES (v_brand_id, p_headgate_id, v_sess.irrigator_id, p_measurement, p_unit, p_notes, p_submitted_via)
|
||||
RETURNING id INTO v_entry_id;
|
||||
|
||||
RETURN jsonb_build_object('success', true, 'entry_id', v_entry_id);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Get headgates for a brand. p_active_only=true for field form, false for admin
|
||||
CREATE OR REPLACE FUNCTION get_water_headgates(p_brand_id UUID, p_active_only BOOLEAN DEFAULT false)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN jsonb_build_object('headgates', (
|
||||
SELECT COALESCE(jsonb_agg(jsonb_build_object(
|
||||
'id', id, 'name', name, 'active', active, 'created_at', created_at
|
||||
) ORDER BY name), '[]'::JSONB)
|
||||
FROM water_headgates
|
||||
WHERE brand_id = p_brand_id
|
||||
AND (NOT p_active_only OR active = true)
|
||||
));
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Get irrigators for a brand (admin, no pin_hash exposed)
|
||||
CREATE OR REPLACE FUNCTION get_water_irrigators(p_brand_id UUID)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN jsonb_build_object('irrigators', (
|
||||
SELECT COALESCE(jsonb_agg(jsonb_build_object(
|
||||
'id', id, 'name', name, 'active', active,
|
||||
'language_preference', language_preference,
|
||||
'last_used_at', last_used_at,
|
||||
'created_at', created_at
|
||||
) ORDER BY name), '[]'::JSONB)
|
||||
FROM water_irrigators
|
||||
WHERE brand_id = p_brand_id
|
||||
));
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Create a new irrigator with auto-generated PIN
|
||||
-- Returns irrigator + plain PIN (shown once)
|
||||
CREATE OR REPLACE FUNCTION create_water_irrigator(p_brand_id UUID, p_name TEXT, p_lang TEXT DEFAULT 'en')
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_irr water_irrigators%ROWTYPE;
|
||||
v_raw_pin TEXT;
|
||||
v_new_id UUID;
|
||||
BEGIN
|
||||
v_raw_pin := floor(random() * 9000 + 1000)::TEXT; -- 4-digit PIN
|
||||
|
||||
INSERT INTO water_irrigators (brand_id, name, pin_hash, language_preference)
|
||||
VALUES (p_brand_id, p_name, crypt(v_raw_pin, gen_salt('bf')), p_lang)
|
||||
RETURNING * INTO v_irr;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'irrigator', jsonb_build_object(
|
||||
'id', v_irr.id, 'name', v_irr.name,
|
||||
'active', v_irr.active, 'language_preference', v_irr.language_preference,
|
||||
'created_at', v_irr.created_at
|
||||
),
|
||||
'pin', v_raw_pin
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Update irrigator name/active
|
||||
CREATE OR REPLACE FUNCTION update_water_irrigator(
|
||||
p_irrigator_id UUID, p_name TEXT, p_active BOOLEAN, p_lang TEXT
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_irr water_irrigators%ROWTYPE;
|
||||
BEGIN
|
||||
UPDATE water_irrigators
|
||||
SET name = p_name, active = p_active, language_preference = p_lang
|
||||
WHERE id = p_irrigator_id
|
||||
RETURNING * INTO v_irr;
|
||||
|
||||
RETURN jsonb_build_object('irrigator', jsonb_build_object(
|
||||
'id', v_irr.id, 'name', v_irr.name,
|
||||
'active', v_irr.active, 'language_preference', v_irr.language_preference,
|
||||
'last_used_at', v_irr.last_used_at
|
||||
));
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Reset irrigator PIN — returns new PIN (shown once)
|
||||
CREATE OR REPLACE FUNCTION reset_water_irrigator_pin(p_irrigator_id UUID)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_irr water_irrigators%ROWTYPE;
|
||||
v_raw_pin TEXT;
|
||||
BEGIN
|
||||
v_raw_pin := floor(random() * 9000 + 1000)::TEXT;
|
||||
|
||||
UPDATE water_irrigators
|
||||
SET pin_hash = crypt(v_raw_pin, gen_salt('bf'))
|
||||
WHERE id = p_irrigator_id
|
||||
RETURNING * INTO v_irr;
|
||||
|
||||
RETURN jsonb_build_object('irrigator_id', v_irr.id, 'pin', v_raw_pin);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Create headgate
|
||||
CREATE OR REPLACE FUNCTION create_water_headgate(p_brand_id UUID, p_name TEXT)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_hg water_headgates%ROWTYPE;
|
||||
BEGIN
|
||||
INSERT INTO water_headgates (brand_id, name)
|
||||
VALUES (p_brand_id, p_name)
|
||||
RETURNING * INTO v_hg;
|
||||
|
||||
RETURN jsonb_build_object('headgate', jsonb_build_object(
|
||||
'id', v_hg.id, 'name', v_hg.name, 'active', v_hg.active, 'created_at', v_hg.created_at
|
||||
));
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Update headgate
|
||||
CREATE OR REPLACE FUNCTION update_water_headgate(p_headgate_id UUID, p_name TEXT, p_active BOOLEAN)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_hg water_headgates%ROWTYPE;
|
||||
BEGIN
|
||||
UPDATE water_headgates
|
||||
SET name = p_name, active = p_active
|
||||
WHERE id = p_headgate_id
|
||||
RETURNING * INTO v_hg;
|
||||
|
||||
RETURN jsonb_build_object('headgate', jsonb_build_object(
|
||||
'id', v_hg.id, 'name', v_hg.name, 'active', v_hg.active
|
||||
));
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Get recent entries for a brand
|
||||
CREATE OR REPLACE FUNCTION get_water_entries(p_brand_id UUID, p_limit INT DEFAULT 50)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN jsonb_build_object('entries', (
|
||||
SELECT COALESCE(jsonb_agg(t ORDER BY t.logged_at DESC), '[]'::JSONB)
|
||||
FROM (
|
||||
SELECT e.id, e.headgate_id, e.irrigator_id, e.measurement, e.unit,
|
||||
e.notes, e.submitted_via, e.logged_at,
|
||||
h.name as headgate_name,
|
||||
i.name as irrigator_name
|
||||
FROM water_log_entries e
|
||||
JOIN water_headgates h ON e.headgate_id = h.id
|
||||
JOIN water_irrigators i ON e.irrigator_id = i.id
|
||||
WHERE e.brand_id = p_brand_id
|
||||
ORDER BY e.logged_at DESC
|
||||
LIMIT p_limit
|
||||
) t
|
||||
));
|
||||
END;
|
||||
$$;
|
||||
-- ============================================================
|
||||
-- RLS for water_sessions (added by scripts/fix-archived-rls.js)
|
||||
-- Columns: id, irrigator_id, expires_at, created_at
|
||||
-- ============================================================
|
||||
ALTER TABLE water_sessions ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE water_sessions FORCE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS deny_all ON water_sessions;
|
||||
CREATE POLICY deny_all ON water_sessions FOR ALL USING (false) WITH CHECK (false);
|
||||
@@ -0,0 +1,263 @@
|
||||
-- =============================================================================
|
||||
-- Water Log V1 — Consolidated RPC Repair
|
||||
-- Fixes: gen_salt/crypt calls, schema alignment, overload removal
|
||||
-- Uses fully-qualified pgcrypto: extensions.crypt / extensions.gen_salt
|
||||
-- =============================================================================
|
||||
|
||||
-- Drop all existing water log RPCs (ignore errors if some don't exist)
|
||||
DROP FUNCTION IF EXISTS public.verify_water_pin(uuid, text);
|
||||
DROP FUNCTION IF EXISTS public.create_water_user(uuid, text, text, text);
|
||||
DROP FUNCTION IF EXISTS public.create_water_user(uuid, text, text);
|
||||
DROP FUNCTION IF EXISTS public.reset_water_user_pin(uuid);
|
||||
DROP FUNCTION IF EXISTS public.reset_water_irrigator_pin(uuid);
|
||||
DROP FUNCTION IF EXISTS public.submit_water_entry(uuid, uuid, numeric, text, text, text);
|
||||
DROP FUNCTION IF EXISTS public.submit_water_entry(uuid, uuid, numeric, text, text);
|
||||
DROP FUNCTION IF EXISTS public.update_water_irrigator(uuid, text, boolean, text);
|
||||
DROP FUNCTION IF EXISTS public.update_water_user(uuid, text, boolean, text);
|
||||
DROP FUNCTION IF EXISTS public.get_water_irrigators(uuid);
|
||||
DROP FUNCTION IF EXISTS public.get_water_entries(uuid, int);
|
||||
DROP FUNCTION IF EXISTS public.get_water_entries(uuid);
|
||||
|
||||
-- =============================================================================
|
||||
-- verify_water_pin
|
||||
-- Returns jsonb: {success, user_id, name, role, session_id, lang} or {success:false, error}
|
||||
-- =============================================================================
|
||||
CREATE OR REPLACE FUNCTION public.verify_water_pin(p_brand_id uuid, p_pin text)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
STATIC
|
||||
AS $$
|
||||
DECLARE
|
||||
v_user jsonb;
|
||||
v_session_id uuid;
|
||||
BEGIN
|
||||
-- Look up user by PIN (pgcrypto fully-qualified)
|
||||
SELECT jsonb_build_object(
|
||||
'id', id,
|
||||
'name', name,
|
||||
'role', role,
|
||||
'lang', language_preference
|
||||
) INTO v_user
|
||||
FROM public.water_users
|
||||
WHERE brand_id = p_brand_id
|
||||
AND active = true
|
||||
AND role IN ('irrigator', 'water_admin')
|
||||
AND extensions.crypt(p_pin, pin_hash) = pin_hash;
|
||||
|
||||
IF v_user IS NULL THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Invalid PIN');
|
||||
END IF;
|
||||
|
||||
-- Update last-used timestamp
|
||||
UPDATE public.water_users
|
||||
SET last_used_at = now()
|
||||
WHERE id = (v_user->>'id')::uuid;
|
||||
|
||||
-- Create session
|
||||
v_session_id := gen_random_uuid();
|
||||
INSERT INTO public.water_sessions (id, irrigator_id, expires_at)
|
||||
VALUES (v_session_id, (v_user->>'id')::uuid, now() + interval '4 hours');
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'success', true,
|
||||
'user_id', v_user->>'id',
|
||||
'name', v_user->>'name',
|
||||
'role', v_user->>'role',
|
||||
'session_id', v_session_id,
|
||||
'lang', v_user->>'lang'
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION public.verify_water_pin IS
|
||||
'Verify irrigator/admin PIN, create session, return user info as JSONB.';
|
||||
|
||||
-- =============================================================================
|
||||
-- create_water_user
|
||||
-- Returns jsonb: {success, user: {id,name,role}, pin}
|
||||
-- =============================================================================
|
||||
CREATE OR REPLACE FUNCTION public.create_water_user(
|
||||
p_brand_id uuid,
|
||||
p_name text,
|
||||
p_role text,
|
||||
p_lang text DEFAULT 'en'
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
STATIC
|
||||
AS $$
|
||||
DECLARE
|
||||
v_brand_id uuid;
|
||||
v_raw_pin text;
|
||||
v_user_id uuid;
|
||||
v_user_role text;
|
||||
BEGIN
|
||||
v_brand_id := COALESCE(p_brand_id, '64294306-5f42-463d-a5e8-2ad6c81a96de'::uuid);
|
||||
v_raw_pin := lpad(floor(random() * 9000 + 1000)::text, 4, '0');
|
||||
v_user_role := COALESCE(p_role, 'irrigator');
|
||||
|
||||
INSERT INTO public.water_users (brand_id, name, pin_hash, role, language_preference, active)
|
||||
VALUES (
|
||||
v_brand_id,
|
||||
p_name,
|
||||
extensions.crypt(v_raw_pin, extensions.gen_salt('bf')),
|
||||
v_user_role,
|
||||
p_lang,
|
||||
true
|
||||
)
|
||||
RETURNING id INTO v_user_id;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'success', true,
|
||||
'user', jsonb_build_object('id', v_user_id, 'name', p_name, 'role', v_user_role),
|
||||
'pin', v_raw_pin
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION public.create_water_user IS
|
||||
'Create water user, generate PIN, return hashed PIN and plaintext PIN once.';
|
||||
|
||||
-- =============================================================================
|
||||
-- reset_water_user_pin
|
||||
-- Returns jsonb: {success, user_id, pin}
|
||||
-- =============================================================================
|
||||
CREATE OR REPLACE FUNCTION public.reset_water_user_pin(p_user_id uuid)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
STATIC
|
||||
AS $$
|
||||
DECLARE
|
||||
v_raw_pin text;
|
||||
v_name text;
|
||||
BEGIN
|
||||
v_raw_pin := lpad(floor(random() * 9000 + 1000)::text, 4, '0');
|
||||
|
||||
UPDATE public.water_users
|
||||
SET pin_hash = extensions.crypt(v_raw_pin, extensions.gen_salt('bf'))
|
||||
WHERE id = p_user_id
|
||||
RETURNING name INTO v_name;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'User not found');
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object('success', true, 'user_id', p_user_id, 'pin', v_raw_pin);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- =============================================================================
|
||||
-- submit_water_entry
|
||||
-- Returns jsonb: {success, entry_id} or {success:false, error}
|
||||
-- =============================================================================
|
||||
CREATE OR REPLACE FUNCTION public.submit_water_entry(
|
||||
p_session_id uuid,
|
||||
p_headgate_id uuid,
|
||||
p_measurement numeric,
|
||||
p_unit text,
|
||||
p_notes text DEFAULT '',
|
||||
p_submitted_via text DEFAULT 'field'
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
STATIC
|
||||
AS $$
|
||||
DECLARE
|
||||
v_sess record;
|
||||
v_brand_id uuid;
|
||||
v_entry_id uuid;
|
||||
BEGIN
|
||||
-- Look up session and join to water_users to get brand_id
|
||||
SELECT s.id, s.irrigator_id, u.brand_id
|
||||
INTO v_sess, v_brand_id
|
||||
FROM public.water_sessions s
|
||||
JOIN public.water_users u ON s.irrigator_id = u.id
|
||||
WHERE s.id = p_session_id
|
||||
AND s.expires_at > now();
|
||||
|
||||
IF NOT FOUND OR v_sess.irrigator_id IS NULL THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Session expired or invalid');
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.water_log_entries
|
||||
(brand_id, headgate_id, irrigator_id, measurement, unit, notes, submitted_via)
|
||||
VALUES
|
||||
(v_brand_id, p_headgate_id, v_sess.irrigator_id, p_measurement, p_unit, p_notes, p_submitted_via)
|
||||
RETURNING id INTO v_entry_id;
|
||||
|
||||
RETURN jsonb_build_object('success', true, 'entry_id', v_entry_id);
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION public.submit_water_entry IS
|
||||
'Submit a water log entry using an active wl_session cookie.';
|
||||
|
||||
-- =============================================================================
|
||||
-- update_water_user
|
||||
-- Returns jsonb: {success} or {success:false, error}
|
||||
-- =============================================================================
|
||||
CREATE OR REPLACE FUNCTION public.update_water_user(
|
||||
p_user_id uuid,
|
||||
p_name text,
|
||||
p_active boolean,
|
||||
p_lang text DEFAULT 'en'
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
STATIC
|
||||
AS $$
|
||||
BEGIN
|
||||
UPDATE public.water_users
|
||||
SET name = p_name, active = p_active, language_preference = p_lang
|
||||
WHERE id = p_user_id;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'User not found');
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object('success', true);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- =============================================================================
|
||||
-- get_water_entries
|
||||
-- Returns jsonb: {entries: [...]}
|
||||
-- =============================================================================
|
||||
CREATE OR REPLACE FUNCTION public.get_water_entries(p_brand_id uuid, p_limit int DEFAULT 50)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
STATIC
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN (
|
||||
SELECT jsonb_build_object('entries', COALESCE(
|
||||
(SELECT jsonb_agg(e ORDER BY e.logged_at DESC)
|
||||
FROM (
|
||||
SELECT
|
||||
e.id,
|
||||
e.headgate_id,
|
||||
e.irrigator_id,
|
||||
h.name AS headgate_name,
|
||||
u.name AS irrigator_name,
|
||||
e.measurement,
|
||||
e.unit,
|
||||
e.notes,
|
||||
e.submitted_via,
|
||||
e.logged_at
|
||||
FROM public.water_log_entries e
|
||||
JOIN public.water_headgates h ON e.headgate_id = h.id
|
||||
JOIN public.water_users u ON e.irrigator_id = u.id
|
||||
WHERE e.brand_id = p_brand_id
|
||||
ORDER BY e.logged_at DESC
|
||||
LIMIT p_limit
|
||||
) e
|
||||
), '[]'::jsonb
|
||||
))
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- =============================================================================
|
||||
-- Reload PostgREST schema cache so new signatures are visible
|
||||
-- =============================================================================
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,232 @@
|
||||
-- =============================================================================
|
||||
-- Water Log V1 — Column name alignment + SECURITY DEFINER
|
||||
-- Deployed tables: water_users, water_sessions(user_id), water_log_entries(user_id)
|
||||
-- =============================================================================
|
||||
|
||||
DROP FUNCTION IF EXISTS public.verify_water_pin(uuid, text);
|
||||
DROP FUNCTION IF EXISTS public.create_water_user(uuid, text, text, text);
|
||||
DROP FUNCTION IF EXISTS public.reset_water_user_pin(uuid);
|
||||
DROP FUNCTION IF EXISTS public.submit_water_entry(uuid, uuid, numeric, text, text, text);
|
||||
DROP FUNCTION IF EXISTS public.update_water_user(uuid, text, boolean, text);
|
||||
DROP FUNCTION IF EXISTS public.get_water_entries(uuid, int);
|
||||
|
||||
-- =============================================================================
|
||||
-- verify_water_pin(p_brand_id uuid, p_pin text)
|
||||
-- Returns jsonb: {success, user_id, name, role, session_id, lang}
|
||||
-- =============================================================================
|
||||
CREATE OR REPLACE FUNCTION public.verify_water_pin(p_brand_id uuid, p_pin text)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_user jsonb;
|
||||
v_session_id uuid;
|
||||
BEGIN
|
||||
SELECT jsonb_build_object(
|
||||
'id', id,
|
||||
'name', name,
|
||||
'role', role,
|
||||
'lang', language_preference
|
||||
) INTO v_user
|
||||
FROM public.water_users
|
||||
WHERE active = true
|
||||
AND role IN ('irrigator', 'water_admin')
|
||||
AND extensions.crypt(p_pin, pin_hash) = pin_hash;
|
||||
|
||||
IF v_user IS NULL THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Invalid PIN');
|
||||
END IF;
|
||||
|
||||
UPDATE public.water_users
|
||||
SET last_used_at = now()
|
||||
WHERE id = (v_user->>'id')::uuid;
|
||||
|
||||
v_session_id := gen_random_uuid();
|
||||
INSERT INTO public.water_sessions (id, user_id, role, expires_at)
|
||||
VALUES (v_session_id, (v_user->>'id')::uuid, v_user->>'role', now() + interval '4 hours');
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'success', true,
|
||||
'user_id', v_user->>'id',
|
||||
'name', v_user->>'name',
|
||||
'role', v_user->>'role',
|
||||
'session_id', v_session_id,
|
||||
'lang', v_user->>'lang'
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- =============================================================================
|
||||
-- create_water_user(p_brand_id, p_name, p_role, p_lang)
|
||||
-- Returns jsonb: {success, user: {id,name,role}, pin}
|
||||
-- =============================================================================
|
||||
CREATE OR REPLACE FUNCTION public.create_water_user(
|
||||
p_brand_id uuid,
|
||||
p_name text,
|
||||
p_role text,
|
||||
p_lang text DEFAULT 'en'
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_raw_pin text := lpad(floor(random() * 9000 + 1000)::text, 4, '0');
|
||||
v_user_id uuid;
|
||||
v_user_role text := COALESCE(p_role, 'irrigator');
|
||||
v_brand_id uuid := COALESCE(p_brand_id, '64294306-5f42-463d-a5e8-2ad6c81a96de'::uuid);
|
||||
BEGIN
|
||||
INSERT INTO public.water_users (brand_id, name, pin_hash, role, language_preference, active)
|
||||
VALUES (
|
||||
v_brand_id,
|
||||
p_name,
|
||||
extensions.crypt(v_raw_pin, extensions.gen_salt('bf')),
|
||||
v_user_role,
|
||||
p_lang,
|
||||
true
|
||||
)
|
||||
RETURNING id INTO v_user_id;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'success', true,
|
||||
'user', jsonb_build_object('id', v_user_id, 'name', p_name, 'role', v_user_role),
|
||||
'pin', v_raw_pin
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- =============================================================================
|
||||
-- reset_water_user_pin(p_user_id)
|
||||
-- Returns jsonb: {success, user_id, pin}
|
||||
-- =============================================================================
|
||||
CREATE OR REPLACE FUNCTION public.reset_water_user_pin(p_user_id uuid)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_raw_pin text := lpad(floor(random() * 9000 + 1000)::text, 4, '0');
|
||||
v_name text;
|
||||
BEGIN
|
||||
UPDATE public.water_users
|
||||
SET pin_hash = extensions.crypt(v_raw_pin, extensions.gen_salt('bf'))
|
||||
WHERE id = p_user_id
|
||||
RETURNING name INTO v_name;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'User not found');
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object('success', true, 'user_id', p_user_id, 'pin', v_raw_pin);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- =============================================================================
|
||||
-- submit_water_entry(p_session_id, p_headgate_id, p_measurement, p_unit, p_notes, p_submitted_via)
|
||||
-- Returns jsonb: {success, entry_id}
|
||||
-- =============================================================================
|
||||
CREATE OR REPLACE FUNCTION public.submit_water_entry(
|
||||
p_session_id uuid,
|
||||
p_headgate_id uuid,
|
||||
p_measurement numeric,
|
||||
p_unit text,
|
||||
p_notes text DEFAULT '',
|
||||
p_submitted_via text DEFAULT 'field'
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_user_id uuid;
|
||||
v_brand_id uuid;
|
||||
v_entry_id uuid;
|
||||
BEGIN
|
||||
SELECT s.user_id, h.brand_id
|
||||
INTO v_user_id, v_brand_id
|
||||
FROM public.water_sessions s
|
||||
JOIN public.water_headgates h ON h.id = p_headgate_id
|
||||
WHERE s.id = p_session_id
|
||||
AND s.expires_at > now();
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Session expired or invalid');
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.water_log_entries
|
||||
(brand_id, headgate_id, user_id, measurement, unit, notes, submitted_via)
|
||||
VALUES
|
||||
(v_brand_id, p_headgate_id, v_user_id, p_measurement, p_unit, p_notes, p_submitted_via)
|
||||
RETURNING id INTO v_entry_id;
|
||||
|
||||
RETURN jsonb_build_object('success', true, 'entry_id', v_entry_id);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- =============================================================================
|
||||
-- update_water_user(p_user_id, p_name, p_active, p_lang)
|
||||
-- Returns jsonb: {success}
|
||||
-- =============================================================================
|
||||
CREATE OR REPLACE FUNCTION public.update_water_user(
|
||||
p_user_id uuid,
|
||||
p_name text,
|
||||
p_active boolean,
|
||||
p_lang text DEFAULT 'en'
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
UPDATE public.water_users
|
||||
SET name = p_name, active = p_active, language_preference = p_lang
|
||||
WHERE id = p_user_id;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'User not found');
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object('success', true);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- =============================================================================
|
||||
-- get_water_entries(p_brand_id, p_limit)
|
||||
-- Returns jsonb: {entries: [...]}
|
||||
-- =============================================================================
|
||||
CREATE OR REPLACE FUNCTION public.get_water_entries(p_brand_id uuid, p_limit int DEFAULT 50)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN (
|
||||
SELECT jsonb_build_object('entries', COALESCE(
|
||||
(SELECT jsonb_agg(e ORDER BY e.logged_at DESC)
|
||||
FROM (
|
||||
SELECT
|
||||
e.id,
|
||||
e.headgate_id,
|
||||
e.user_id,
|
||||
h.name AS headgate_name,
|
||||
u.name AS user_name,
|
||||
e.measurement,
|
||||
e.unit,
|
||||
e.notes,
|
||||
e.submitted_via,
|
||||
e.logged_at
|
||||
FROM public.water_log_entries e
|
||||
JOIN public.water_headgates h ON e.headgate_id = h.id
|
||||
JOIN public.water_users u ON e.user_id = u.id
|
||||
WHERE e.brand_id = p_brand_id
|
||||
ORDER BY e.logged_at DESC
|
||||
LIMIT p_limit
|
||||
) e
|
||||
), '[]'::jsonb
|
||||
))
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,123 @@
|
||||
-- =============================================================================
|
||||
-- Water Log V1 — Soft Delete
|
||||
-- Adds deleted_at to water_users and water_headgates
|
||||
-- Creates delete_water_user and delete_water_headgate RPCs
|
||||
-- Existing entries preserved; deleted users/headgates excluded from queries
|
||||
-- =============================================================================
|
||||
|
||||
-- Add deleted_at column to water_users
|
||||
ALTER TABLE public.water_users
|
||||
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
|
||||
-- Add deleted_at column to water_headgates
|
||||
ALTER TABLE public.water_headgates
|
||||
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
|
||||
-- =============================================================================
|
||||
-- delete_water_user(p_user_id uuid)
|
||||
-- Soft-deletes a water user (sets deleted_at) so entries are preserved.
|
||||
-- Returns jsonb: {success} or {success:false, error}
|
||||
-- =============================================================================
|
||||
CREATE OR REPLACE FUNCTION public.delete_water_user(p_user_id uuid)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
UPDATE public.water_users
|
||||
SET deleted_at = now()
|
||||
WHERE id = p_user_id AND deleted_at IS NULL;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'User not found or already deleted');
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object('success', true);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- =============================================================================
|
||||
-- delete_water_headgate(p_headgate_id uuid)
|
||||
-- Soft-deletes a water headgate (sets deleted_at) so entries are preserved.
|
||||
-- Returns jsonb: {success} or {success:false, error}
|
||||
-- =============================================================================
|
||||
CREATE OR REPLACE FUNCTION public.delete_water_headgate(p_headgate_id uuid)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
UPDATE public.water_headgates
|
||||
SET deleted_at = now()
|
||||
WHERE id = p_headgate_id AND deleted_at IS NULL;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Headgate not found or already deleted');
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object('success', true);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- =============================================================================
|
||||
-- Update get_water_users to exclude soft-deleted users
|
||||
-- =============================================================================
|
||||
CREATE OR REPLACE FUNCTION public.get_water_users(p_brand_id uuid)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN (
|
||||
SELECT jsonb_build_object('users', COALESCE(
|
||||
(SELECT jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', id,
|
||||
'name', name,
|
||||
'role', role,
|
||||
'active', active,
|
||||
'language_preference', language_preference,
|
||||
'last_used_at', last_used_at,
|
||||
'created_at', created_at,
|
||||
'deleted_at', deleted_at
|
||||
) ORDER BY created_at DESC
|
||||
)
|
||||
FROM public.water_users
|
||||
WHERE brand_id = p_brand_id AND deleted_at IS NULL
|
||||
), '[]'::jsonb
|
||||
))
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- =============================================================================
|
||||
-- Update get_water_headgates to exclude soft-deleted headgates
|
||||
-- =============================================================================
|
||||
CREATE OR REPLACE FUNCTION public.get_water_headgates(p_brand_id uuid, p_active_only boolean DEFAULT false)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN (
|
||||
SELECT jsonb_build_object('headgates', COALESCE(
|
||||
(SELECT jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', id,
|
||||
'name', name,
|
||||
'active', active,
|
||||
'created_at', created_at,
|
||||
'deleted_at', deleted_at
|
||||
) ORDER BY created_at DESC
|
||||
)
|
||||
FROM public.water_headgates
|
||||
WHERE brand_id = p_brand_id
|
||||
AND (NOT p_active_only OR active = true)
|
||||
AND deleted_at IS NULL
|
||||
), '[]'::jsonb
|
||||
))
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,88 @@
|
||||
-- =============================================================================
|
||||
-- Water Log V1 — Entry Edit & Delete
|
||||
-- Allows water_admin and platform_admin to edit measurement/notes
|
||||
-- Allows deletion of entries (hard delete, not soft)
|
||||
-- =============================================================================
|
||||
|
||||
-- =============================================================================
|
||||
-- update_water_entry(p_entry_id uuid, p_measurement numeric, p_notes text)
|
||||
-- Updates measurement and notes only. Preserves user_id, headgate_id,
|
||||
-- logged_at, submitted_via.
|
||||
-- Returns jsonb: {success} or {success:false, error}
|
||||
-- =============================================================================
|
||||
CREATE OR REPLACE FUNCTION public.update_water_entry(p_entry_id uuid, p_measurement numeric, p_notes text)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
UPDATE public.water_log_entries
|
||||
SET measurement = p_measurement, notes = p_notes
|
||||
WHERE id = p_entry_id;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Entry not found');
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object('success', true);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- =============================================================================
|
||||
-- delete_water_entry(p_entry_id uuid)
|
||||
-- Hard-deletes an entry. Use with caution — no soft-delete for entries.
|
||||
-- Returns jsonb: {success} or {success:false, error}
|
||||
-- =============================================================================
|
||||
CREATE OR REPLACE FUNCTION public.delete_water_entry(p_entry_id uuid)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
DELETE FROM public.water_log_entries WHERE id = p_entry_id;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Entry not found');
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object('success', true);
|
||||
END;
|
||||
$$;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
|
||||
-- =============================================================================
|
||||
-- get_water_entry_by_id(p_entry_id uuid)
|
||||
-- Returns a single entry row for the edit page.
|
||||
-- Returns jsonb: {entry: {...}} or {entry: null}
|
||||
-- =============================================================================
|
||||
CREATE OR REPLACE FUNCTION public.get_water_entry_by_id(p_entry_id uuid)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN (
|
||||
SELECT jsonb_build_object('entry', (
|
||||
SELECT jsonb_build_object(
|
||||
'id', e.id,
|
||||
'headgate_id', e.headgate_id,
|
||||
'user_id', e.user_id,
|
||||
'headgate_name', hg.name,
|
||||
'user_name', u.name,
|
||||
'measurement', e.measurement,
|
||||
'unit', e.unit,
|
||||
'notes', e.notes,
|
||||
'submitted_via', e.submitted_via,
|
||||
'logged_at', e.logged_at
|
||||
)
|
||||
FROM public.water_log_entries e
|
||||
JOIN public.water_headgates hg ON hg.id = e.headgate_id
|
||||
JOIN public.water_users u ON u.id = e.user_id
|
||||
WHERE e.id = p_entry_id
|
||||
))
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,202 @@
|
||||
-- =============================================================================
|
||||
-- Water Log V1.1 — Unit Types + Dedicated Edit Pages
|
||||
--
|
||||
-- Adds `unit` column to water_headgates so each headgate carries its
|
||||
-- preferred measurement unit (CFS, GPM, etc.). This unit pre-selects
|
||||
-- on the field form and is editable via dedicated headgate/user edit pages.
|
||||
--
|
||||
-- Also updates update_water_entry to support changing unit.
|
||||
-- =============================================================================
|
||||
|
||||
-- ── Add unit to headgates ────────────────────────────────────
|
||||
ALTER TABLE public.water_headgates
|
||||
ADD COLUMN IF NOT EXISTS unit TEXT NOT NULL DEFAULT 'CFS';
|
||||
|
||||
-- ── Update submit_water_entry to respect headgate's unit ─────
|
||||
-- The field form should use the headgate's default unit.
|
||||
-- Update get_water_entries to include headgate unit.
|
||||
-- Update update_water_entry to accept optional unit change.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.update_water_entry(
|
||||
p_entry_id uuid,
|
||||
p_measurement numeric,
|
||||
p_notes text,
|
||||
p_unit text DEFAULT NULL
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
IF p_unit IS NOT NULL THEN
|
||||
UPDATE public.water_log_entries
|
||||
SET measurement = p_measurement, notes = p_notes, unit = p_unit
|
||||
WHERE id = p_entry_id;
|
||||
ELSE
|
||||
UPDATE public.water_log_entries
|
||||
SET measurement = p_measurement, notes = p_notes
|
||||
WHERE id = p_entry_id;
|
||||
END IF;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Entry not found');
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object('success', true);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Update get_water_entries to include headgate's default unit (for pre-fill)
|
||||
CREATE OR REPLACE FUNCTION public.get_water_entries(p_brand_id uuid, p_limit int DEFAULT 50)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN (
|
||||
SELECT jsonb_build_object('entries', COALESCE(
|
||||
(SELECT jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', e.id,
|
||||
'headgate_id', e.headgate_id,
|
||||
'user_id', e.user_id,
|
||||
'headgate_name', hg.name,
|
||||
'user_name', u.name,
|
||||
'measurement', e.measurement,
|
||||
'unit', e.unit,
|
||||
'notes', e.notes,
|
||||
'submitted_via', e.submitted_via,
|
||||
'logged_at', e.logged_at,
|
||||
'headgate_unit', hg.unit
|
||||
) ORDER BY e.logged_at DESC
|
||||
)
|
||||
FROM public.water_log_entries e
|
||||
JOIN public.water_headgates hg ON hg.id = e.headgate_id
|
||||
JOIN public.water_users u ON u.id = e.user_id
|
||||
WHERE e.brand_id = p_brand_id
|
||||
), '[]'::jsonb
|
||||
))
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Update get_water_entry_by_id to include headgate_unit for pre-fill
|
||||
CREATE OR REPLACE FUNCTION public.get_water_entry_by_id(p_entry_id uuid)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN (
|
||||
SELECT jsonb_build_object('entry', (
|
||||
SELECT jsonb_build_object(
|
||||
'id', e.id,
|
||||
'headgate_id', e.headgate_id,
|
||||
'user_id', e.user_id,
|
||||
'headgate_name', hg.name,
|
||||
'user_name', u.name,
|
||||
'measurement', e.measurement,
|
||||
'unit', e.unit,
|
||||
'notes', e.notes,
|
||||
'submitted_via', e.submitted_via,
|
||||
'logged_at', e.logged_at,
|
||||
'headgate_unit', hg.unit
|
||||
)
|
||||
FROM public.water_log_entries e
|
||||
JOIN public.water_headgates hg ON hg.id = e.headgate_id
|
||||
JOIN public.water_users u ON u.id = e.user_id
|
||||
WHERE e.id = p_entry_id
|
||||
))
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Drop overloads so there's only one version with p_unit defaulting
|
||||
DROP FUNCTION IF EXISTS public.update_water_headgate(uuid, text, boolean);
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.update_water_headgate(
|
||||
p_headgate_id uuid,
|
||||
p_name text,
|
||||
p_active boolean,
|
||||
p_unit text DEFAULT NULL
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
IF p_unit IS NOT NULL THEN
|
||||
UPDATE public.water_headgates
|
||||
SET name = p_name, active = p_active, unit = p_unit
|
||||
WHERE id = p_headgate_id AND deleted_at IS NULL;
|
||||
ELSE
|
||||
UPDATE public.water_headgates
|
||||
SET name = p_name, active = p_active
|
||||
WHERE id = p_headgate_id AND deleted_at IS NULL;
|
||||
END IF;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Headgate not found');
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object('success', true);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Update create_water_headgate to accept unit
|
||||
CREATE OR REPLACE FUNCTION public.create_water_headgate(p_brand_id uuid, p_name text, p_unit text DEFAULT 'CFS')
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_headgate_id uuid;
|
||||
v_result jsonb;
|
||||
BEGIN
|
||||
INSERT INTO public.water_headgates (brand_id, name, unit)
|
||||
VALUES (p_brand_id, p_name, p_unit)
|
||||
RETURNING id INTO v_headgate_id;
|
||||
|
||||
SELECT jsonb_build_object(
|
||||
'id', id,
|
||||
'name', name,
|
||||
'active', active,
|
||||
'unit', unit,
|
||||
'created_at', created_at
|
||||
) INTO v_result
|
||||
FROM public.water_headgates WHERE id = v_headgate_id;
|
||||
|
||||
RETURN jsonb_build_object('headgate', v_result);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- get_water_headgates admin view should include unit
|
||||
CREATE OR REPLACE FUNCTION public.get_water_headgates(p_brand_id uuid, p_active_only boolean DEFAULT false)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN (
|
||||
SELECT jsonb_build_object('headgates', COALESCE(
|
||||
(SELECT jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', id,
|
||||
'name', name,
|
||||
'active', active,
|
||||
'unit', unit,
|
||||
'created_at', created_at,
|
||||
'deleted_at', deleted_at
|
||||
) ORDER BY created_at DESC
|
||||
)
|
||||
FROM public.water_headgates
|
||||
WHERE brand_id = p_brand_id
|
||||
AND (NOT p_active_only OR active = true)
|
||||
AND deleted_at IS NULL
|
||||
), '[]'::jsonb
|
||||
))
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,289 @@
|
||||
-- =============================================================================
|
||||
-- Water Log V1.1 — Unit Types + Soft Delete Fix (consolidated)
|
||||
--
|
||||
-- If migration 008 was not run, this adds deleted_at columns.
|
||||
-- If migration 010 had issues, this fixes the function overload and adds missing cols.
|
||||
-- Safe to run multiple times — uses ADD COLUMN IF NOT EXISTS and IF EXISTS drops.
|
||||
-- =============================================================================
|
||||
|
||||
-- ── Soft delete columns (if not already present) ─────────────────────────────
|
||||
ALTER TABLE public.water_users
|
||||
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
|
||||
ALTER TABLE public.water_headgates
|
||||
ADD COLUMN IF NOT EXISTS unit TEXT NOT NULL DEFAULT 'CFS',
|
||||
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
|
||||
-- ── Fix update_water_headgate overload ───────────────────────────────────────
|
||||
-- Drop old 3-param overload so there's only one version (with p_unit)
|
||||
DROP FUNCTION IF EXISTS public.update_water_headgate(uuid, text, boolean);
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.update_water_headgate(
|
||||
p_headgate_id uuid,
|
||||
p_name text,
|
||||
p_active boolean,
|
||||
p_unit text DEFAULT NULL
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
IF p_unit IS NOT NULL THEN
|
||||
UPDATE public.water_headgates
|
||||
SET name = p_name, active = p_active, unit = p_unit
|
||||
WHERE id = p_headgate_id AND deleted_at IS NULL;
|
||||
ELSE
|
||||
UPDATE public.water_headgates
|
||||
SET name = p_name, active = p_active
|
||||
WHERE id = p_headgate_id AND deleted_at IS NULL;
|
||||
END IF;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Headgate not found');
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object('success', true);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── Fix update_water_entry overload ──────────────────────────────────────────
|
||||
-- Drop old 3-param overload so only the 4-param version (p_unit optional) exists
|
||||
DROP FUNCTION IF EXISTS public.update_water_entry(uuid, numeric, text);
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.update_water_entry(
|
||||
p_entry_id uuid,
|
||||
p_measurement numeric,
|
||||
p_notes text,
|
||||
p_unit text DEFAULT NULL
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
IF p_unit IS NOT NULL THEN
|
||||
UPDATE public.water_log_entries
|
||||
SET measurement = p_measurement, notes = p_notes, unit = p_unit
|
||||
WHERE id = p_entry_id;
|
||||
ELSE
|
||||
UPDATE public.water_log_entries
|
||||
SET measurement = p_measurement, notes = p_notes
|
||||
WHERE id = p_entry_id;
|
||||
END IF;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Entry not found');
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object('success', true);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── Create Water Headgate (updated to support p_unit) ────────────────────────
|
||||
DROP FUNCTION IF EXISTS public.create_water_headgate(uuid, text, text);
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.create_water_headgate(
|
||||
p_brand_id uuid,
|
||||
p_name text,
|
||||
p_unit text DEFAULT 'CFS'
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_headgate_id uuid;
|
||||
v_result jsonb;
|
||||
BEGIN
|
||||
INSERT INTO public.water_headgates (brand_id, name, unit)
|
||||
VALUES (p_brand_id, p_name, p_unit)
|
||||
RETURNING id INTO v_headgate_id;
|
||||
|
||||
SELECT jsonb_build_object(
|
||||
'id', id,
|
||||
'name', name,
|
||||
'active', active,
|
||||
'unit', unit,
|
||||
'created_at', created_at
|
||||
) INTO v_result
|
||||
FROM public.water_headgates WHERE id = v_headgate_id;
|
||||
|
||||
RETURN jsonb_build_object('headgate', v_result);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── Delete Water User (soft delete) ───────────────────────────────────────────
|
||||
DROP FUNCTION IF EXISTS public.delete_water_user(uuid);
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.delete_water_user(p_user_id uuid)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
UPDATE public.water_users
|
||||
SET deleted_at = now()
|
||||
WHERE id = p_user_id AND deleted_at IS NULL;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'User not found or already deleted');
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object('success', true);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── Delete Water Headgate (soft delete) ──────────────────────────────────────
|
||||
DROP FUNCTION IF EXISTS public.delete_water_headgate(uuid);
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.delete_water_headgate(p_headgate_id uuid)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
UPDATE public.water_headgates
|
||||
SET deleted_at = now()
|
||||
WHERE id = p_headgate_id AND deleted_at IS NULL;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Headgate not found or already deleted');
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object('success', true);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── Get Water Users (exclude soft-deleted) ───────────────────────────────────
|
||||
DROP FUNCTION IF EXISTS public.get_water_users(uuid);
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.get_water_users(p_brand_id uuid)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN (
|
||||
SELECT jsonb_build_object('users', COALESCE(
|
||||
(SELECT jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', id,
|
||||
'name', name,
|
||||
'role', role,
|
||||
'active', active,
|
||||
'language_preference', language_preference,
|
||||
'last_used_at', last_used_at,
|
||||
'created_at', created_at,
|
||||
'deleted_at', deleted_at
|
||||
) ORDER BY created_at DESC
|
||||
)
|
||||
FROM public.water_users
|
||||
WHERE brand_id = p_brand_id AND deleted_at IS NULL
|
||||
), '[]'::jsonb
|
||||
))
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── Get Water Headgates (exclude soft-deleted, include unit) ───────────────────
|
||||
DROP FUNCTION IF EXISTS public.get_water_headgates(uuid, boolean);
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.get_water_headgates(p_brand_id uuid, p_active_only boolean DEFAULT false)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN (
|
||||
SELECT jsonb_build_object('headgates', COALESCE(
|
||||
(SELECT jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', id,
|
||||
'name', name,
|
||||
'active', active,
|
||||
'unit', unit,
|
||||
'created_at', created_at,
|
||||
'deleted_at', deleted_at
|
||||
) ORDER BY created_at DESC
|
||||
)
|
||||
FROM public.water_headgates
|
||||
WHERE brand_id = p_brand_id
|
||||
AND (NOT p_active_only OR active = true)
|
||||
AND deleted_at IS NULL
|
||||
), '[]'::jsonb
|
||||
))
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── Get Water Entries (include headgate_unit) ─────────────────────────────────
|
||||
DROP FUNCTION IF EXISTS public.get_water_entries(uuid, int);
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.get_water_entries(p_brand_id uuid, p_limit int DEFAULT 50)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN (
|
||||
SELECT jsonb_build_object('entries', COALESCE(
|
||||
(SELECT jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', e.id,
|
||||
'headgate_id', e.headgate_id,
|
||||
'user_id', e.user_id,
|
||||
'headgate_name', hg.name,
|
||||
'user_name', u.name,
|
||||
'measurement', e.measurement,
|
||||
'unit', e.unit,
|
||||
'notes', e.notes,
|
||||
'submitted_via', e.submitted_via,
|
||||
'logged_at', e.logged_at,
|
||||
'headgate_unit', hg.unit
|
||||
) ORDER BY e.logged_at DESC
|
||||
)
|
||||
FROM public.water_log_entries e
|
||||
JOIN public.water_headgates hg ON hg.id = e.headgate_id
|
||||
JOIN public.water_users u ON u.id = e.user_id
|
||||
WHERE e.brand_id = p_brand_id
|
||||
), '[]'::jsonb
|
||||
))
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── Get Water Entry By ID (include headgate_unit) ────────────────────────────
|
||||
DROP FUNCTION IF EXISTS public.get_water_entry_by_id(uuid);
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.get_water_entry_by_id(p_entry_id uuid)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN (
|
||||
SELECT jsonb_build_object('entry', (
|
||||
SELECT jsonb_build_object(
|
||||
'id', e.id,
|
||||
'headgate_id', e.headgate_id,
|
||||
'user_id', e.user_id,
|
||||
'headgate_name', hg.name,
|
||||
'user_name', u.name,
|
||||
'measurement', e.measurement,
|
||||
'unit', e.unit,
|
||||
'notes', e.notes,
|
||||
'submitted_via', e.submitted_via,
|
||||
'logged_at', e.logged_at,
|
||||
'headgate_unit', hg.unit
|
||||
)
|
||||
FROM public.water_log_entries e
|
||||
JOIN public.water_headgates hg ON hg.id = e.headgate_id
|
||||
JOIN public.water_users u ON u.id = e.user_id
|
||||
WHERE e.id = p_entry_id
|
||||
))
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,103 @@
|
||||
-- =============================================================================
|
||||
-- Water Log V1.6 - Display Summary RPC
|
||||
-- Returns headgate latest readings + today's aggregates for Smartsheet/display.
|
||||
-- SECURITY DEFINER so anon key can call it (same as other water log RPCs).
|
||||
-- =============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.get_water_display_summary(p_brand_id uuid)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN (
|
||||
WITH headgate_data AS (
|
||||
SELECT
|
||||
hg.id,
|
||||
hg.name,
|
||||
COALESCE(hg.unit, 'CFS') as unit,
|
||||
(
|
||||
SELECT jsonb_build_object(
|
||||
'measurement', le.measurement,
|
||||
'user_name', u.name,
|
||||
'logged_at', le.logged_at
|
||||
)
|
||||
FROM public.water_log_entries le
|
||||
JOIN public.water_users u ON u.id = le.user_id
|
||||
WHERE le.headgate_id = hg.id
|
||||
ORDER BY le.logged_at DESC
|
||||
LIMIT 1
|
||||
) as latest_entry,
|
||||
(
|
||||
SELECT le.logged_at
|
||||
FROM public.water_log_entries le
|
||||
WHERE le.headgate_id = hg.id
|
||||
ORDER BY le.logged_at DESC
|
||||
LIMIT 1
|
||||
) as last_logged_at,
|
||||
(
|
||||
SELECT FLOOR(EXTRACT(EPOCH FROM (now() - le.logged_at)) / 60)::int
|
||||
FROM public.water_log_entries le
|
||||
WHERE le.headgate_id = hg.id
|
||||
ORDER BY le.logged_at DESC
|
||||
LIMIT 1
|
||||
) as minutes_ago
|
||||
FROM public.water_headgates hg
|
||||
WHERE hg.brand_id = p_brand_id
|
||||
AND hg.active = true
|
||||
AND hg.deleted_at IS NULL
|
||||
),
|
||||
today_stats AS (
|
||||
SELECT
|
||||
count(*) as cnt,
|
||||
COALESCE(sum(measurement), 0) as total
|
||||
FROM public.water_log_entries
|
||||
WHERE brand_id = p_brand_id AND logged_at::date = CURRENT_DATE
|
||||
),
|
||||
entries_ordered AS (
|
||||
SELECT e.logged_at, e.headgate_id, e.user_id, e.measurement, e.unit, e.notes, e.submitted_via
|
||||
FROM public.water_log_entries e
|
||||
WHERE e.brand_id = p_brand_id
|
||||
ORDER BY e.logged_at DESC
|
||||
LIMIT 20
|
||||
),
|
||||
recent_entries_agg AS (
|
||||
SELECT COALESCE(jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'logged_at', e.logged_at,
|
||||
'headgate_name', hg.name,
|
||||
'user_name', u.name,
|
||||
'user_role', u.role,
|
||||
'measurement', e.measurement,
|
||||
'unit', e.unit,
|
||||
'notes', e.notes,
|
||||
'submitted_via', e.submitted_via
|
||||
)
|
||||
), '[]'::jsonb) as data
|
||||
FROM entries_ordered e
|
||||
JOIN public.water_headgates hg ON hg.id = e.headgate_id
|
||||
JOIN public.water_users u ON u.id = e.user_id
|
||||
)
|
||||
SELECT jsonb_build_object(
|
||||
'headgates', (
|
||||
SELECT COALESCE(jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', hd.id,
|
||||
'name', hd.name,
|
||||
'unit', hd.unit,
|
||||
'latest_entry', hd.latest_entry,
|
||||
'last_logged_at', hd.last_logged_at,
|
||||
'minutes_ago', hd.minutes_ago
|
||||
)
|
||||
), '[]'::jsonb)
|
||||
FROM headgate_data hd
|
||||
),
|
||||
'today_count', (SELECT cnt FROM today_stats),
|
||||
'today_total', (SELECT total FROM today_stats),
|
||||
'recent_entries', (SELECT data FROM recent_entries_agg)
|
||||
)
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,13 @@
|
||||
-- =============================================================================
|
||||
-- Water Log V1.6 - Seed Water Admin User
|
||||
-- =============================================================================
|
||||
|
||||
INSERT INTO public.water_users (brand_id, name, pin_hash, role, active, language_preference)
|
||||
VALUES (
|
||||
'64294306-5f42-463d-a5e8-2ad6c81a96de',
|
||||
'Test Water Admin',
|
||||
extensions.crypt('1234', gen_salt('bf')),
|
||||
'water_admin',
|
||||
true,
|
||||
'en'
|
||||
);
|
||||
@@ -0,0 +1,9 @@
|
||||
-- =============================================================================
|
||||
-- Water Log V1.6 - Fix water_sessions RLS
|
||||
-- Add SELECT policy so getWaterAdminSession can read sessions with anon key
|
||||
-- =============================================================================
|
||||
|
||||
DROP POLICY IF EXISTS "Sessions readable by all" ON public.water_sessions;
|
||||
CREATE POLICY "Sessions readable by all" ON public.water_sessions FOR SELECT TO anon USING (true);
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,90 @@
|
||||
-- =============================================================================
|
||||
-- Water Log V1.6 - Security Hardening: block direct water_users access
|
||||
--
|
||||
-- Problem: pin_hash can be read via direct REST SELECT with anon key.
|
||||
-- Fix: Remove direct SELECT policies. All reads go through SECURITY DEFINER
|
||||
-- RPCs which run as the supabase postgres user and bypass RLS.
|
||||
--
|
||||
-- Changes:
|
||||
-- 1. DROP "Users readable by all" policy (was added to fix getWaterAdminSession)
|
||||
-- 2. Create get_water_user_by_id(p_user_id) RPC for language lookup (SECURITY DEFINER)
|
||||
-- 3. Create get_water_admin_session() RPC (SECURITY DEFINER) replacing direct
|
||||
-- water_sessions + water_users JOIN in field.ts getWaterAdminSession
|
||||
-- 4. Update field.ts to use RPCs instead of direct REST calls
|
||||
-- 5. Supabase will continue to route all water_users writes through the existing
|
||||
-- SECURITY DEFINER RPCs (create_water_user, update_water_user, etc.)
|
||||
-- =============================================================================
|
||||
|
||||
-- Step 1: Remove direct SELECT policy from water_users
|
||||
DROP POLICY IF EXISTS "Users readable by all" ON public.water_users;
|
||||
|
||||
-- Step 2: Create a SECURITY DEFINER RPC to get a single user's language preference
|
||||
-- This replaces the direct REST call in field.ts line 83
|
||||
-- (runs as supabase postgres user, bypasses RLS)
|
||||
CREATE OR REPLACE FUNCTION public.get_water_user_by_id(p_user_id uuid)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_result jsonb;
|
||||
BEGIN
|
||||
SELECT jsonb_build_object(
|
||||
'language_preference', language_preference
|
||||
) INTO v_result
|
||||
FROM public.water_users
|
||||
WHERE id = p_user_id AND deleted_at IS NULL;
|
||||
|
||||
RETURN v_result;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Step 3: Create get_water_admin_session() RPC (SECURITY DEFINER)
|
||||
-- Reads the wl_session cookie and returns the associated user's info.
|
||||
-- Replaces the direct water_sessions + water_users JOIN in getWaterAdminSession.
|
||||
-- Runs as supabase postgres user, bypasses RLS on both water_sessions and water_users.
|
||||
CREATE OR REPLACE FUNCTION public.get_water_admin_session()
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_session_id text;
|
||||
v_result jsonb;
|
||||
BEGIN
|
||||
-- Read wl_session from request cookies (set by verify_water_pin)
|
||||
-- In SECURITY DEFINER context, we can't access HTTP cookies directly.
|
||||
-- The session_id is passed from the caller (Next.js server action).
|
||||
-- Instead, create a variant that accepts session_id as a parameter.
|
||||
RETURN null; -- placeholder — actual implementation uses p_session_id below
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Drop and recreate with proper signature (accepts p_session_id)
|
||||
DROP FUNCTION IF EXISTS public.get_water_admin_session();
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.get_water_admin_session(p_session_id text)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_result jsonb;
|
||||
BEGIN
|
||||
SELECT jsonb_build_object(
|
||||
'user_id', s.user_id,
|
||||
'name', u.name,
|
||||
'role', u.role
|
||||
) INTO v_result
|
||||
FROM public.water_sessions s
|
||||
JOIN public.water_users u ON u.id = s.user_id
|
||||
WHERE s.id = p_session_id::uuid
|
||||
AND s.expires_at > now()
|
||||
AND u.role = 'water_admin'
|
||||
AND u.deleted_at IS NULL;
|
||||
|
||||
RETURN v_result;
|
||||
END;
|
||||
$$;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,662 @@
|
||||
-- =============================================================================
|
||||
-- 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';
|
||||
@@ -0,0 +1,878 @@
|
||||
-- =============================================================================
|
||||
-- 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';
|
||||
@@ -0,0 +1,230 @@
|
||||
-- =============================================================================
|
||||
-- Communication Center V1.2 — Import Metadata Storage
|
||||
-- Fully idempotent: additive changes only
|
||||
-- Updates import_communication_contacts_batch to store ignored CSV columns
|
||||
-- in metadata.imported_raw so no context is lost from arbitrary extra columns.
|
||||
-- =============================================================================
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- ── Update email path — add imported_raw to metadata on UPDATE ───────────────
|
||||
-- Lines ~400-402 in migration 017: UPDATE path for existing email contact
|
||||
|
||||
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[];
|
||||
v_raw_meta JSONB;
|
||||
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;
|
||||
|
||||
-- Build imported_raw from any _metadata key (ignored CSV columns)
|
||||
v_raw_meta := nullif(v_entry->>'_metadata', '')::JSONB;
|
||||
|
||||
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,
|
||||
'imported_raw', coalesce(v_raw_meta, '{}'::JSONB)
|
||||
),
|
||||
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,
|
||||
'imported_raw', coalesce(v_raw_meta, '{}'::JSONB)
|
||||
)
|
||||
);
|
||||
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,
|
||||
'imported_raw', coalesce(v_raw_meta, '{}'::JSONB)
|
||||
),
|
||||
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,
|
||||
'imported_raw', coalesce(v_raw_meta, '{}'::JSONB)
|
||||
)
|
||||
);
|
||||
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;
|
||||
$$;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,75 @@
|
||||
-- =============================================================================
|
||||
-- V1.2 Stage 1 — Canonical Customers Table
|
||||
-- Fully idempotent: CREATE TABLE IF NOT EXISTS, CREATE INDEX IF NOT EXISTS
|
||||
-- Purely additive: no changes to orders, communication_contacts, or checkout
|
||||
--
|
||||
-- STAGE 2 NOTE: When upserting customers in Stage 2, normalize BEFORE upsert:
|
||||
-- - email: lowercase + trim
|
||||
-- - phone: strip formatting chars [\s\-().[\]], preserve original if uncertain
|
||||
-- This ensures consistent matching across orders, imports, and contacts.
|
||||
-- =============================================================================
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- ── Table ────────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.customers (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
|
||||
primary_email TEXT,
|
||||
primary_phone TEXT,
|
||||
first_name TEXT,
|
||||
last_name TEXT,
|
||||
source TEXT NOT NULL DEFAULT 'system',
|
||||
metadata JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT customers_email_or_phone CHECK (
|
||||
primary_email IS NOT NULL OR primary_phone IS NOT NULL
|
||||
)
|
||||
);
|
||||
|
||||
-- ── Indexes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_customers_brand
|
||||
ON public.customers(brand_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_customers_email
|
||||
ON public.customers(primary_email) WHERE primary_email IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_customers_phone
|
||||
ON public.customers(primary_phone) WHERE primary_phone IS NOT NULL;
|
||||
|
||||
-- Partial unique indexes: enforce one customer per brand per email/phone
|
||||
-- PostgreSQL requires CREATE UNIQUE INDEX rather than CONSTRAINT for partial unique
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_customers_brand_email_unique
|
||||
ON public.customers(brand_id, primary_email)
|
||||
WHERE primary_email IS NOT NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_customers_brand_phone_unique
|
||||
ON public.customers(brand_id, primary_phone)
|
||||
WHERE primary_phone IS NOT NULL;
|
||||
|
||||
-- ── RLS ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
ALTER TABLE public.customers ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS "Brand admin can read customers"
|
||||
ON public.customers;
|
||||
CREATE POLICY "Brand admin can read customers"
|
||||
ON public.customers 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 customers"
|
||||
ON public.customers;
|
||||
CREATE POLICY "Platform admin can read customers"
|
||||
ON public.customers FOR SELECT TO authenticated
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM admin_users
|
||||
WHERE admin_users.user_id = auth.uid()
|
||||
AND admin_users.role = 'platform_admin'
|
||||
));
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,226 @@
|
||||
-- =============================================================================
|
||||
-- V1.2 Stage 2 — Order/Contact/Customer Linking
|
||||
-- Fully idempotent: CREATE OR REPLACE FUNCTION, no destructive operations
|
||||
-- Purely additive behavior: no changes to orders schema, no FKs, no backfill
|
||||
--
|
||||
-- DEPENDENCY: Requires 019_customers_table.sql to be applied first.
|
||||
-- Raises an exception if the customers table does not exist.
|
||||
--
|
||||
-- TRANSITIONAL ARCHITECTURE NOTE:
|
||||
-- orders.customer_id and customers.id are separate ID namespaces today.
|
||||
-- communication_contacts.customer_id links to customers.id for new order-created
|
||||
-- contacts (via this migration), but orders.customer_id is NOT yet aligned.
|
||||
--
|
||||
-- A future stage (Stage 3+) should consider:
|
||||
-- - backfilling customers from existing orders/communication_contacts
|
||||
-- - aligning orders.customer_id to canonical customers.id
|
||||
-- - adding proper FK constraints only after data is cleaned
|
||||
-- - avoiding permanent dual customer ID namespaces
|
||||
--
|
||||
-- STAGE 2 NORMALIZATION:
|
||||
-- email: trim(lower(...)) before match/upsert
|
||||
-- phone: regexp_replace(... '[\s\-().[\]]' '' 'g') before match/upsert
|
||||
-- This is consistent with frontend normalizeEmail/normalizePhone.
|
||||
-- =============================================================================
|
||||
|
||||
-- ── Dependency guard ─────────────────────────────────────────────────────────
|
||||
DO $$
|
||||
BEGIN
|
||||
IF to_regclass('public.customers') IS NULL THEN
|
||||
RAISE EXCEPTION 'Migration 019_customers_table.sql must be applied before 020_order_customer_linking.sql';
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- ── Safe teardown order: trigger → functions ────────────────────────────────
|
||||
-- PostgreSQL requires dropping a trigger before dropping the function it uses.
|
||||
-- Order: DROP TRIGGER → DROP FUNCTION (helper) → DROP FUNCTION (trigger)
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_create_contact_from_order ON public.orders;
|
||||
DROP FUNCTION IF EXISTS public.upsert_customer_from_order(UUID, TEXT, TEXT, TEXT, TEXT);
|
||||
DROP FUNCTION IF EXISTS public.upsert_contact_from_order();
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
-- upsert_customer_from_order
|
||||
--
|
||||
-- Resolves or creates a canonical customers record for a given order.
|
||||
-- Returns the customers.id for linking, or NULL if no contact method exists.
|
||||
--
|
||||
-- Upsert order: email first → phone fallback (email is more stable identity)
|
||||
-- Brand separation: brand_id is the scoping key throughout.
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.upsert_customer_from_order(
|
||||
p_brand_id UUID,
|
||||
p_customer_email TEXT,
|
||||
p_customer_phone TEXT,
|
||||
p_customer_name TEXT,
|
||||
p_source TEXT DEFAULT 'order'
|
||||
)
|
||||
RETURNS UUID -- customers.id
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_norm_email TEXT := trim(lower(p_customer_email));
|
||||
v_norm_phone TEXT := regexp_replace(p_customer_phone, '[\s\-().[\]]', '', 'g');
|
||||
v_cust_id UUID;
|
||||
BEGIN
|
||||
-- No email AND no phone: nothing to upsert
|
||||
IF (v_norm_email IS NULL OR v_norm_email = '') AND
|
||||
(v_norm_phone IS NULL OR v_norm_phone = '') THEN
|
||||
RETURN NULL;
|
||||
END IF;
|
||||
|
||||
-- Try email match first (within brand)
|
||||
IF v_norm_email IS NOT NULL AND v_norm_email != '' THEN
|
||||
SELECT id INTO v_cust_id
|
||||
FROM public.customers
|
||||
WHERE brand_id = p_brand_id
|
||||
AND primary_email = v_norm_email;
|
||||
|
||||
IF FOUND THEN
|
||||
UPDATE public.customers SET
|
||||
primary_phone = COALESCE(NULLIF(v_norm_phone, ''), primary_phone),
|
||||
first_name = COALESCE(
|
||||
NULLIF(split_part(p_customer_name, ' ', 1), ''),
|
||||
customers.first_name
|
||||
),
|
||||
last_name = COALESCE(
|
||||
NULLIF(substring(p_customer_name from position(' ' in p_customer_name) + 1
|
||||
for length(p_customer_name)), ''),
|
||||
customers.last_name
|
||||
),
|
||||
source = CASE
|
||||
WHEN customers.source = 'system' THEN p_source ELSE customers.source
|
||||
END,
|
||||
updated_at = now()
|
||||
WHERE id = v_cust_id;
|
||||
RETURN v_cust_id;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- Phone fallback (only if email didn't find a match)
|
||||
IF v_cust_id IS NULL AND v_norm_phone IS NOT NULL AND v_norm_phone != '' THEN
|
||||
SELECT id INTO v_cust_id
|
||||
FROM public.customers
|
||||
WHERE brand_id = p_brand_id
|
||||
AND primary_phone = v_norm_phone;
|
||||
|
||||
IF FOUND THEN
|
||||
UPDATE public.customers SET
|
||||
primary_email = COALESCE(NULLIF(v_norm_email, ''), primary_email),
|
||||
first_name = COALESCE(
|
||||
NULLIF(split_part(p_customer_name, ' ', 1), ''),
|
||||
customers.first_name
|
||||
),
|
||||
last_name = COALESCE(
|
||||
NULLIF(substring(p_customer_name from position(' ' in p_customer_name) + 1
|
||||
for length(p_customer_name)), ''),
|
||||
customers.last_name
|
||||
),
|
||||
source = CASE
|
||||
WHEN customers.source = 'system' THEN p_source ELSE customers.source
|
||||
END,
|
||||
updated_at = now()
|
||||
WHERE id = v_cust_id;
|
||||
RETURN v_cust_id;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- Insert new customer
|
||||
INSERT INTO public.customers
|
||||
(brand_id, primary_email, primary_phone, first_name, last_name, source)
|
||||
VALUES (
|
||||
p_brand_id,
|
||||
NULLIF(v_norm_email, ''),
|
||||
NULLIF(v_norm_phone, ''),
|
||||
NULLIF(split_part(p_customer_name, ' ', 1), ''),
|
||||
NULLIF(substring(p_customer_name from position(' ' in p_customer_name) + 1
|
||||
for length(p_customer_name)), ''),
|
||||
p_source
|
||||
)
|
||||
RETURNING id INTO v_cust_id;
|
||||
|
||||
RETURN v_cust_id;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
-- upsert_contact_from_order trigger
|
||||
--
|
||||
-- NOW DOES TWO THINGS:
|
||||
-- 1. Call upsert_customer_from_order() to get/create customers.id
|
||||
-- 2. Use that customers.id as communication_contacts.customer_id (linking)
|
||||
--
|
||||
-- OPT-OUT PRESERVATION: email_opt_in, sms_opt_in, unsubscribed_at are NOT
|
||||
-- updated on conflict — this behavior is unchanged from V1.1.
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
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;
|
||||
v_customer_id UUID;
|
||||
BEGIN
|
||||
-- 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;
|
||||
END IF;
|
||||
|
||||
-- Step 1: upsert canonical customer (NEW.customer_id not referenced —
|
||||
-- orders table does not have that column; use only email/phone/name)
|
||||
v_customer_id := upsert_customer_from_order(
|
||||
v_brand_id,
|
||||
NEW.customer_email,
|
||||
NEW.customer_phone,
|
||||
NEW.customer_name,
|
||||
'order'
|
||||
);
|
||||
|
||||
-- Step 2: upsert communication contact, linked to customers.id
|
||||
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',
|
||||
v_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();
|
||||
-- email_opt_in, sms_opt_in, unsubscribed_at intentionally NOT updated (preserve opt-out)
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── Recreate trigger ─────────────────────────────────────────────────────────
|
||||
CREATE TRIGGER trg_create_contact_from_order
|
||||
AFTER INSERT ON public.orders
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.upsert_contact_from_order();
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,319 @@
|
||||
-- =============================================================================
|
||||
-- V1.2 Stage 3 — Shipping-Only Brand Resolution
|
||||
-- Fully idempotent: CREATE OR REPLACE FUNCTION, ALTER TABLE ADD COLUMN IF NOT EXISTS
|
||||
--
|
||||
-- DEPENDENCY: Requires 020_order_customer_linking.sql to be applied first.
|
||||
-- Raises an exception if the trigger function does not exist.
|
||||
--
|
||||
-- CHANGES:
|
||||
-- 1. Add brand_id column to orders (nullable, no FK — aligned to products.brand_id)
|
||||
-- 2. Modify create_order_with_items to accept NULL stop_id for shipping-only
|
||||
-- 3. Update upsert_contact_from_order trigger to resolve brand from NEW.brand_id
|
||||
-- first, falling back to stop lookup — supports both pickup and shipping orders
|
||||
-- =============================================================================
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_proc p
|
||||
JOIN pg_namespace n ON n.oid = p.pronamespace
|
||||
WHERE n.nspname = 'public'
|
||||
AND p.proname = 'upsert_contact_from_order'
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Migration 020_order_customer_linking.sql must be applied before 021_shipping_only_brand.sql';
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── 1. Add brand_id to orders ────────────────────────────────────────────────
|
||||
-- Allows the order/contact trigger to resolve brand without needing a stop_id.
|
||||
-- For shipping-only orders, brand is derived from the first product.
|
||||
|
||||
ALTER TABLE orders ADD COLUMN IF NOT EXISTS brand_id UUID;
|
||||
|
||||
-- ── 2. Modify create_order_with_items for shipping-only ────────────────────
|
||||
-- stop_id is now nullable. When NULL (shipping-only), brand is derived from
|
||||
-- the first product's brand and no stop validation occurs.
|
||||
|
||||
CREATE OR REPLACE FUNCTION create_order_with_items(
|
||||
p_idempotency_key UUID,
|
||||
p_customer_name TEXT,
|
||||
p_customer_email TEXT,
|
||||
p_customer_phone TEXT,
|
||||
p_stop_id UUID, -- nullable for shipping-only
|
||||
p_items JSONB -- [{id: uuid, quantity: int, fulfillment: text}]
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_order_id UUID;
|
||||
v_item JSONB;
|
||||
v_product_id UUID;
|
||||
v_quantity INT;
|
||||
v_fulfillment TEXT;
|
||||
v_product_price NUMERIC;
|
||||
v_computed_total NUMERIC := 0;
|
||||
v_stop_active BOOLEAN;
|
||||
v_stop_brand_id UUID;
|
||||
v_stop_city TEXT;
|
||||
v_stop_state TEXT;
|
||||
v_stop_date TEXT;
|
||||
v_stop_time TEXT;
|
||||
v_stop_location TEXT;
|
||||
v_product_active BOOLEAN;
|
||||
v_product_brand_id UUID;
|
||||
v_product_name TEXT;
|
||||
v_is_pickup BOOLEAN;
|
||||
v_order JSONB;
|
||||
v_order_items JSONB := '[]'::JSONB;
|
||||
v_brand_id UUID;
|
||||
BEGIN
|
||||
-- ── Idempotency guard ─────────────────────────────────────────────────────
|
||||
SELECT jsonb_build_object(
|
||||
'id', o.id,
|
||||
'customer_name', o.customer_name,
|
||||
'customer_email', o.customer_email,
|
||||
'customer_phone', o.customer_phone,
|
||||
'subtotal', o.subtotal,
|
||||
'status', o.status,
|
||||
'stop_id', o.stop_id,
|
||||
'brand_id', o.brand_id,
|
||||
'stop_city', s.city,
|
||||
'stop_state', s.state,
|
||||
'stop_date', s.date,
|
||||
'stop_time', s.time,
|
||||
'stop_location', s.location,
|
||||
'items', COALESCE((
|
||||
SELECT jsonb_agg(jsonb_build_object(
|
||||
'product_id', oi.product_id,
|
||||
'product_name', p.name,
|
||||
'quantity', oi.quantity,
|
||||
'price', oi.price,
|
||||
'fulfillment', oi.fulfillment
|
||||
))
|
||||
FROM order_items oi
|
||||
JOIN products p ON oi.product_id = p.id
|
||||
WHERE oi.order_id = o.id
|
||||
), '[]'::JSONB)
|
||||
)
|
||||
INTO v_order
|
||||
FROM orders o
|
||||
LEFT JOIN stops s ON o.stop_id = s.id
|
||||
WHERE o.idempotency_key = p_idempotency_key
|
||||
LIMIT 1;
|
||||
|
||||
IF v_order IS NOT NULL THEN
|
||||
RETURN v_order;
|
||||
END IF;
|
||||
|
||||
-- ── Resolve brand_id ──────────────────────────────────────────────────────
|
||||
-- For shipping-only orders (p_stop_id IS NULL), derive from the first product.
|
||||
-- For pickup orders, derive from the stop.
|
||||
IF p_stop_id IS NOT NULL THEN
|
||||
SELECT active, brand_id, city, state, date, time, location
|
||||
INTO v_stop_active, v_stop_brand_id, v_stop_city, v_stop_state, v_stop_date, v_stop_time, v_stop_location
|
||||
FROM stops
|
||||
WHERE id = p_stop_id;
|
||||
|
||||
IF v_stop_brand_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Stop not found: %', p_stop_id;
|
||||
END IF;
|
||||
|
||||
IF NOT v_stop_active THEN
|
||||
RAISE EXCEPTION 'Stop is not active: %', p_stop_id;
|
||||
END IF;
|
||||
|
||||
v_brand_id := v_stop_brand_id;
|
||||
ELSE
|
||||
-- Shipping-only: resolve brand from first product in the order
|
||||
v_product_id := (jsonb_array_elements(p_items)->>'id')::UUID;
|
||||
|
||||
SELECT brand_id INTO v_brand_id
|
||||
FROM products
|
||||
WHERE id = v_product_id;
|
||||
|
||||
IF v_brand_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Product not found: %', v_product_id;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- ── Validate items and compute subtotal ─────────────────────────────────
|
||||
FOR v_item IN SELECT * FROM jsonb_array_elements(p_items)
|
||||
LOOP
|
||||
v_product_id := (v_item->>'id')::UUID;
|
||||
v_quantity := (v_item->>'quantity')::INT;
|
||||
v_fulfillment := v_item->>'fulfillment';
|
||||
v_is_pickup := (v_fulfillment = 'pickup');
|
||||
|
||||
SELECT active, brand_id, price, name
|
||||
INTO v_product_active, v_product_brand_id, v_product_price, v_product_name
|
||||
FROM products
|
||||
WHERE id = v_product_id;
|
||||
|
||||
IF v_product_brand_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Product not found: %', v_product_id;
|
||||
END IF;
|
||||
|
||||
IF v_product_brand_id != v_brand_id THEN
|
||||
RAISE EXCEPTION 'Product % does not belong to brand %', v_product_id, v_brand_id;
|
||||
END IF;
|
||||
|
||||
IF NOT v_product_active THEN
|
||||
RAISE EXCEPTION 'Product is not active: %', v_product_id;
|
||||
END IF;
|
||||
|
||||
IF v_is_pickup THEN
|
||||
-- Pickup items require a valid stop assignment
|
||||
IF p_stop_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Pickup item % requires a stop selection', v_product_id;
|
||||
END IF;
|
||||
|
||||
PERFORM 1
|
||||
FROM product_stops
|
||||
WHERE product_id = v_product_id AND stop_id = p_stop_id
|
||||
LIMIT 1;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'Pickup product % is not available at stop %', v_product_id, p_stop_id;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
v_computed_total := v_computed_total + (v_product_price * v_quantity);
|
||||
END LOOP;
|
||||
|
||||
-- ── Create order (with brand_id) ─────────────────────────────────────────
|
||||
INSERT INTO orders (
|
||||
idempotency_key, customer_name, customer_email, customer_phone,
|
||||
stop_id, brand_id, subtotal, status
|
||||
) VALUES (
|
||||
p_idempotency_key, p_customer_name, p_customer_email, p_customer_phone,
|
||||
p_stop_id, v_brand_id, v_computed_total, 'pending'
|
||||
)
|
||||
RETURNING id INTO v_order_id;
|
||||
|
||||
-- ── Insert order items and build return value ─────────────────────────────
|
||||
FOR v_item IN SELECT * FROM jsonb_array_elements(p_items)
|
||||
LOOP
|
||||
v_product_id := (v_item->>'id')::UUID;
|
||||
v_quantity := (v_item->>'quantity')::INT;
|
||||
v_fulfillment := v_item->>'fulfillment';
|
||||
|
||||
SELECT price, name INTO v_product_price, v_product_name FROM products WHERE id = v_product_id;
|
||||
|
||||
INSERT INTO order_items (order_id, product_id, quantity, fulfillment, price)
|
||||
VALUES (v_order_id, v_product_id, v_quantity, v_fulfillment, v_product_price);
|
||||
|
||||
v_order_items := v_order_items || jsonb_build_object(
|
||||
'product_id', v_product_id,
|
||||
'product_name', v_product_name,
|
||||
'quantity', v_quantity,
|
||||
'price', v_product_price,
|
||||
'fulfillment', v_fulfillment
|
||||
);
|
||||
END LOOP;
|
||||
|
||||
-- ── Build and return full order object ───────────────────────────────────
|
||||
RETURN jsonb_build_object(
|
||||
'id', v_order_id,
|
||||
'customer_name', p_customer_name,
|
||||
'customer_email', p_customer_email,
|
||||
'customer_phone', p_customer_phone,
|
||||
'subtotal', v_computed_total,
|
||||
'status', 'pending',
|
||||
'stop_id', p_stop_id,
|
||||
'brand_id', v_brand_id,
|
||||
'stop_city', v_stop_city,
|
||||
'stop_state', v_stop_state,
|
||||
'stop_date', v_stop_date,
|
||||
'stop_time', v_stop_time,
|
||||
'stop_location', v_stop_location,
|
||||
'items', v_order_items
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── 3. Update trigger to resolve brand from NEW.brand_id first ───────────────
|
||||
-- NEW.brand_id is the primary source (always set by create_order_with_items).
|
||||
-- Stop lookup is the fallback (for pre-existing orders that lack brand_id).
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_create_contact_from_order ON public.orders;
|
||||
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;
|
||||
v_customer_id UUID;
|
||||
BEGIN
|
||||
-- Primary: resolve brand_id from the order itself (NEW.brand_id, always set in Stage 3+)
|
||||
IF NEW.brand_id IS NOT NULL THEN
|
||||
v_brand_id := NEW.brand_id;
|
||||
ELSE
|
||||
-- Fallback: resolve via stop (for pre-Stage 3 orders without brand_id)
|
||||
SELECT brand_id INTO v_brand_id
|
||||
FROM public.stops
|
||||
WHERE id = NEW.stop_id;
|
||||
IF NOT FOUND THEN
|
||||
RETURN NEW; -- cannot resolve brand: skip customer/contact linking
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- No email AND no phone: nothing to upsert
|
||||
IF (NEW.customer_email IS NULL OR NEW.customer_email = '') AND
|
||||
(NEW.customer_phone IS NULL OR NEW.customer_phone = '') THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Step 1: upsert canonical customer
|
||||
v_customer_id := upsert_customer_from_order(
|
||||
v_brand_id,
|
||||
NEW.customer_email,
|
||||
NEW.customer_phone,
|
||||
NEW.customer_name,
|
||||
'order'
|
||||
);
|
||||
|
||||
-- Step 2: upsert communication contact, linked to customers.id
|
||||
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',
|
||||
v_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();
|
||||
-- email_opt_in, sms_opt_in, unsubscribed_at intentionally NOT updated (preserve opt-out)
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER trg_create_contact_from_order
|
||||
AFTER INSERT ON public.orders
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.upsert_contact_from_order();
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,646 @@
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
-- Migration 022: Operational Events
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
-- Append-only event layer for recording important platform actions.
|
||||
-- Scope: table + indexes + RLS + helpers + 4 initial emitters.
|
||||
-- What NOT included: automation, queues, webhooks, retries, cron jobs.
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- 1. operational_events table
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.operational_events (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
|
||||
event_type TEXT NOT NULL,
|
||||
entity_type TEXT,
|
||||
entity_id UUID,
|
||||
actor_type TEXT,
|
||||
actor_id UUID,
|
||||
source TEXT NOT NULL DEFAULT 'system',
|
||||
payload JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT operational_events_event_type CHECK (
|
||||
event_type ~ '^[a-z][a-z0-9_]*$'
|
||||
)
|
||||
);
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- 2. Indexes
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_oe_brand_id ON operational_events(brand_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_oe_event_type ON operational_events(event_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_oe_entity ON operational_events(entity_type, entity_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_oe_created_at ON operational_events(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_oe_brand_type ON operational_events(brand_id, event_type, created_at DESC);
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- 3. RLS policies
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
ALTER TABLE operational_events ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS "Brand admin can read operational_events" ON operational_events;
|
||||
CREATE POLICY "Brand admin can read operational_events"
|
||||
ON operational_events 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 operational_events" ON operational_events;
|
||||
CREATE POLICY "Platform admin can read operational_events"
|
||||
ON operational_events FOR SELECT TO authenticated
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM admin_users
|
||||
WHERE admin_users.user_id = auth.uid()
|
||||
AND admin_users.role = 'platform_admin'
|
||||
)
|
||||
);
|
||||
|
||||
-- Writes go through SECURITY DEFINER helpers only.
|
||||
-- No INSERT granted to authenticated roles.
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- 4. record_operational_event helper — bypasses RLS, all emitters call this
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.record_operational_event(
|
||||
p_brand_id UUID,
|
||||
p_event_type TEXT,
|
||||
p_entity_type TEXT,
|
||||
p_entity_id UUID,
|
||||
p_actor_type TEXT,
|
||||
p_actor_id UUID,
|
||||
p_source TEXT,
|
||||
p_payload JSONB
|
||||
)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
INSERT INTO operational_events (
|
||||
brand_id, event_type, entity_type, entity_id,
|
||||
actor_type, actor_id, source, payload
|
||||
) VALUES (
|
||||
p_brand_id, p_event_type, p_entity_type, p_entity_id,
|
||||
p_actor_type, p_actor_id, p_source, p_payload
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- 5. record_pickup_completed_event helper — called from TypeScript action
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.record_pickup_completed_event(
|
||||
p_order_id UUID,
|
||||
p_brand_id UUID,
|
||||
p_actor_id UUID
|
||||
)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_order_data JSONB;
|
||||
BEGIN
|
||||
SELECT jsonb_build_object(
|
||||
'subtotal', subtotal,
|
||||
'customer_name', customer_name
|
||||
) INTO v_order_data
|
||||
FROM orders
|
||||
WHERE id = p_order_id;
|
||||
|
||||
PERFORM record_operational_event(
|
||||
p_brand_id,
|
||||
'pickup_completed',
|
||||
'order',
|
||||
p_order_id,
|
||||
'admin',
|
||||
p_actor_id,
|
||||
'system',
|
||||
coalesce(v_order_data, '{}'::JSONB)
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- 6. create_order_with_items — with order_placed emitter
|
||||
-- (explicit replace; same signature as migration 021)
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE OR REPLACE FUNCTION create_order_with_items(
|
||||
p_idempotency_key UUID,
|
||||
p_customer_name TEXT,
|
||||
p_customer_email TEXT,
|
||||
p_customer_phone TEXT,
|
||||
p_stop_id UUID,
|
||||
p_items JSONB
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_order_id UUID;
|
||||
v_item JSONB;
|
||||
v_product_id UUID;
|
||||
v_quantity INT;
|
||||
v_fulfillment TEXT;
|
||||
v_product_price NUMERIC;
|
||||
v_computed_total NUMERIC := 0;
|
||||
v_stop_active BOOLEAN;
|
||||
v_stop_brand_id UUID;
|
||||
v_stop_city TEXT;
|
||||
v_stop_state TEXT;
|
||||
v_stop_date TEXT;
|
||||
v_stop_time TEXT;
|
||||
v_stop_location TEXT;
|
||||
v_product_active BOOLEAN;
|
||||
v_product_brand_id UUID;
|
||||
v_product_name TEXT;
|
||||
v_is_pickup BOOLEAN;
|
||||
v_has_pickup BOOLEAN := false;
|
||||
v_order JSONB;
|
||||
v_order_items JSONB := '[]'::JSONB;
|
||||
v_brand_id UUID;
|
||||
BEGIN
|
||||
-- ── Idempotency guard ─────────────────────────────────────────────────────
|
||||
SELECT jsonb_build_object(
|
||||
'id', o.id,
|
||||
'customer_name', o.customer_name,
|
||||
'customer_email', o.customer_email,
|
||||
'customer_phone', o.customer_phone,
|
||||
'subtotal', o.subtotal,
|
||||
'status', o.status,
|
||||
'stop_id', o.stop_id,
|
||||
'brand_id', o.brand_id,
|
||||
'stop_city', s.city,
|
||||
'stop_state', s.state,
|
||||
'stop_date', s.date,
|
||||
'stop_time', s.time,
|
||||
'stop_location', s.location,
|
||||
'items', COALESCE((
|
||||
SELECT jsonb_agg(jsonb_build_object(
|
||||
'product_id', oi.product_id,
|
||||
'product_name', p.name,
|
||||
'quantity', oi.quantity,
|
||||
'price', oi.price,
|
||||
'fulfillment', oi.fulfillment
|
||||
))
|
||||
FROM order_items oi
|
||||
JOIN products p ON oi.product_id = p.id
|
||||
WHERE oi.order_id = o.id
|
||||
), '[]'::JSONB)
|
||||
)
|
||||
INTO v_order
|
||||
FROM orders o
|
||||
LEFT JOIN stops s ON o.stop_id = s.id
|
||||
WHERE o.idempotency_key = p_idempotency_key
|
||||
LIMIT 1;
|
||||
|
||||
IF v_order IS NOT NULL THEN
|
||||
RETURN v_order;
|
||||
END IF;
|
||||
|
||||
-- ── Resolve brand_id ──────────────────────────────────────────────────────
|
||||
IF p_stop_id IS NOT NULL THEN
|
||||
SELECT active, brand_id, city, state, date, time, location
|
||||
INTO v_stop_active, v_stop_brand_id, v_stop_city, v_stop_state, v_stop_date, v_stop_time, v_stop_location
|
||||
FROM stops
|
||||
WHERE id = p_stop_id;
|
||||
|
||||
IF v_stop_brand_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Stop not found: %', p_stop_id;
|
||||
END IF;
|
||||
|
||||
IF NOT v_stop_active THEN
|
||||
RAISE EXCEPTION 'Stop is not active: %', p_stop_id;
|
||||
END IF;
|
||||
|
||||
v_brand_id := v_stop_brand_id;
|
||||
ELSE
|
||||
v_product_id := (jsonb_array_elements(p_items)->>'id')::UUID;
|
||||
|
||||
SELECT brand_id INTO v_brand_id
|
||||
FROM products
|
||||
WHERE id = v_product_id;
|
||||
|
||||
IF v_brand_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Product not found: %', v_product_id;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- ── Validate items and compute subtotal ─────────────────────────────────
|
||||
FOR v_item IN SELECT * FROM jsonb_array_elements(p_items)
|
||||
LOOP
|
||||
v_product_id := (v_item->>'id')::UUID;
|
||||
v_quantity := (v_item->>'quantity')::INT;
|
||||
v_fulfillment := v_item->>'fulfillment';
|
||||
v_is_pickup := (v_fulfillment = 'pickup');
|
||||
|
||||
IF v_is_pickup THEN
|
||||
v_has_pickup := true;
|
||||
END IF;
|
||||
|
||||
SELECT active, brand_id, price, name
|
||||
INTO v_product_active, v_product_brand_id, v_product_price, v_product_name
|
||||
FROM products
|
||||
WHERE id = v_product_id;
|
||||
|
||||
IF v_product_brand_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Product not found: %', v_product_id;
|
||||
END IF;
|
||||
|
||||
IF v_product_brand_id != v_brand_id THEN
|
||||
RAISE EXCEPTION 'Product % does not belong to brand %', v_product_id, v_brand_id;
|
||||
END IF;
|
||||
|
||||
IF NOT v_product_active THEN
|
||||
RAISE EXCEPTION 'Product is not active: %', v_product_id;
|
||||
END IF;
|
||||
|
||||
IF v_is_pickup THEN
|
||||
IF p_stop_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Pickup item % requires a stop selection', v_product_id;
|
||||
END IF;
|
||||
|
||||
PERFORM 1
|
||||
FROM product_stops
|
||||
WHERE product_id = v_product_id AND stop_id = p_stop_id
|
||||
LIMIT 1;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'Pickup product % is not available at stop %', v_product_id, p_stop_id;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
v_computed_total := v_computed_total + (v_product_price * v_quantity);
|
||||
END LOOP;
|
||||
|
||||
-- ── Create order (with brand_id) ─────────────────────────────────────────
|
||||
INSERT INTO orders (
|
||||
idempotency_key, customer_name, customer_email, customer_phone,
|
||||
stop_id, brand_id, subtotal, status
|
||||
) VALUES (
|
||||
p_idempotency_key, p_customer_name, p_customer_email, p_customer_phone,
|
||||
p_stop_id, v_brand_id, v_computed_total, 'pending'
|
||||
)
|
||||
RETURNING id INTO v_order_id;
|
||||
|
||||
-- ── Insert order items and build return value ─────────────────────────────
|
||||
FOR v_item IN SELECT * FROM jsonb_array_elements(p_items)
|
||||
LOOP
|
||||
v_product_id := (v_item->>'id')::UUID;
|
||||
v_quantity := (v_item->>'quantity')::INT;
|
||||
v_fulfillment := v_item->>'fulfillment';
|
||||
|
||||
SELECT price, name INTO v_product_price, v_product_name FROM products WHERE id = v_product_id;
|
||||
|
||||
INSERT INTO order_items (order_id, product_id, quantity, fulfillment, price)
|
||||
VALUES (v_order_id, v_product_id, v_quantity, v_fulfillment, v_product_price);
|
||||
|
||||
v_order_items := v_order_items || jsonb_build_object(
|
||||
'product_id', v_product_id,
|
||||
'product_name', v_product_name,
|
||||
'quantity', v_quantity,
|
||||
'price', v_product_price,
|
||||
'fulfillment', v_fulfillment
|
||||
);
|
||||
END LOOP;
|
||||
|
||||
-- ── Emit order_placed event ───────────────────────────────────────────────
|
||||
PERFORM record_operational_event(
|
||||
v_brand_id,
|
||||
'order_placed',
|
||||
'order',
|
||||
v_order_id,
|
||||
'customer',
|
||||
NULL,
|
||||
'system',
|
||||
jsonb_build_object(
|
||||
'subtotal', v_computed_total,
|
||||
'item_count', jsonb_array_length(p_items),
|
||||
'has_pickup', v_has_pickup
|
||||
)
|
||||
);
|
||||
|
||||
-- ── Build and return full order object ───────────────────────────────────
|
||||
RETURN jsonb_build_object(
|
||||
'id', v_order_id,
|
||||
'customer_name', p_customer_name,
|
||||
'customer_email', p_customer_email,
|
||||
'customer_phone', p_customer_phone,
|
||||
'subtotal', v_computed_total,
|
||||
'status', 'pending',
|
||||
'stop_id', p_stop_id,
|
||||
'brand_id', v_brand_id,
|
||||
'stop_city', v_stop_city,
|
||||
'stop_state', v_stop_state,
|
||||
'stop_date', v_stop_date,
|
||||
'stop_time', v_stop_time,
|
||||
'stop_location', v_stop_location,
|
||||
'items', v_order_items
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- 7. import_communication_contacts_batch — with contact_imported emitter
|
||||
-- (explicit replace; same signature as migrations 017/018)
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
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', '');
|
||||
|
||||
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
|
||||
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 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
|
||||
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
|
||||
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;
|
||||
|
||||
-- ── Emit contact_imported event ──────────────────────────────────────────
|
||||
PERFORM record_operational_event(
|
||||
p_brand_id,
|
||||
'contact_imported',
|
||||
NULL,
|
||||
NULL,
|
||||
'admin',
|
||||
NULL,
|
||||
'system',
|
||||
jsonb_build_object(
|
||||
'created', (v_result->>'created')::INTEGER,
|
||||
'updated', (v_result->>'updated')::INTEGER,
|
||||
'skipped', (v_result->>'skipped')::INTEGER,
|
||||
'total',
|
||||
(v_result->>'created')::INTEGER
|
||||
+ (v_result->>'updated')::INTEGER
|
||||
+ (v_result->>'skipped')::INTEGER
|
||||
)
|
||||
);
|
||||
|
||||
RETURN v_result;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- 8. send_campaign — with campaign_sent emitter
|
||||
-- (explicit replace; same body as migration 017)
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
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;
|
||||
|
||||
-- ── Emit campaign_sent event ────────────────────────────────────────────
|
||||
PERFORM record_operational_event(
|
||||
v_campaign.brand_id,
|
||||
'campaign_sent',
|
||||
'campaign',
|
||||
p_campaign_id,
|
||||
'system',
|
||||
NULL,
|
||||
'system',
|
||||
jsonb_build_object(
|
||||
'messages_logged', v_count,
|
||||
'subject', v_campaign.subject
|
||||
)
|
||||
);
|
||||
|
||||
RETURN jsonb_build_object('success', true, 'messages_logged', v_count);
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,56 @@
|
||||
-- Migration 023: Fix cart availability check
|
||||
-- Replaces unreliable client-side product_stops query with a
|
||||
-- SECURITY DEFINER RPC that bypasses RLS and returns structured availability.
|
||||
--
|
||||
-- The cart page's availability check was:
|
||||
-- 1. Unreliable — anon/frontend query may be blocked by RLS or return empty
|
||||
-- 2. Conflating "no rows" with "product is unavailable"
|
||||
-- 3. Blocking all stops even when the query itself failed
|
||||
--
|
||||
-- This adds: check_stop_product_availability(p_stop_id, p_product_ids)
|
||||
-- Returns: { product_id, is_available }[] for each requested product.
|
||||
-- The cart page uses this to show truly incompatible items separately
|
||||
-- from query errors.
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- 1. check_stop_product_availability RPC
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.check_stop_product_availability(
|
||||
p_stop_id UUID,
|
||||
p_product_ids UUID[]
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_result JSONB := '[]'::JSONB;
|
||||
v_pid UUID;
|
||||
BEGIN
|
||||
FOR v_pid IN SELECT unnest(p_product_ids)
|
||||
LOOP
|
||||
v_result := v_result || jsonb_build_array(jsonb_build_object(
|
||||
'product_id', v_pid,
|
||||
'is_available', EXISTS(
|
||||
SELECT 1 FROM product_stops
|
||||
WHERE stop_id = p_stop_id AND product_id = v_pid
|
||||
)
|
||||
));
|
||||
END LOOP;
|
||||
|
||||
RETURN v_result;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- 2. Update cart/page.tsx to use the RPC + show query errors distinctly
|
||||
-- (done in the application code, not the migration)
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
--
|
||||
-- Changes to src/app/cart/page.tsx:
|
||||
-- - handleStopSelect: POST to check_stop_product_availability RPC instead of
|
||||
-- direct product_stops query. Handle errors distinctly from unavailability.
|
||||
-- - Add availabilityError state — if RPC fails, show "Unable to verify
|
||||
-- availability" but allow checkout to proceed (server will catch true errors).
|
||||
-- - Add per-product availabilityError flag to distinguish query failure
|
||||
-- from confirmed unavailability.
|
||||
@@ -0,0 +1,146 @@
|
||||
-- Migration 024: Stop-product assignment via SECURITY DEFINER RPCs
|
||||
-- Replaces direct INSERT/DELETE on product_stops (blocked by RLS)
|
||||
-- with admin-authorized RPCs that validate brand alignment.
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- 1. assign_product_to_stop
|
||||
-- Idempotent: if row already exists, returns the existing row.
|
||||
-- Validates: stop exists, product exists, brands match.
|
||||
-- Authorizes: platform_admin (all brands) or brand_admin (own brand only).
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.assign_product_to_stop(
|
||||
p_stop_id UUID,
|
||||
p_product_id UUID
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_stop_brand_id UUID;
|
||||
v_product_brand_id UUID;
|
||||
v_existing UUID;
|
||||
v_result JSONB;
|
||||
BEGIN
|
||||
-- Verify stop exists and get its brand
|
||||
SELECT brand_id INTO v_stop_brand_id
|
||||
FROM stops
|
||||
WHERE id = p_stop_id;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Stop not found');
|
||||
END IF;
|
||||
|
||||
-- Verify product exists and get its brand
|
||||
SELECT brand_id INTO v_product_brand_id
|
||||
FROM products
|
||||
WHERE id = p_product_id;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Product not found');
|
||||
END IF;
|
||||
|
||||
-- Brand alignment check
|
||||
IF v_stop_brand_id != v_product_brand_id THEN
|
||||
RETURN jsonb_build_object(
|
||||
'success', false,
|
||||
'error', 'Product brand does not match stop brand — cross-brand assignment not allowed'
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- Authorization: must be platform_admin or brand_admin for this brand
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM admin_users
|
||||
WHERE user_id = auth.uid()
|
||||
AND (role = 'platform_admin' OR (role = 'brand_admin' AND brand_id = v_stop_brand_id))
|
||||
) THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Not authorized to assign products to this stop');
|
||||
END IF;
|
||||
|
||||
-- Idempotent insert: check if row already exists
|
||||
SELECT id INTO v_existing
|
||||
FROM product_stops
|
||||
WHERE stop_id = p_stop_id AND product_id = p_product_id;
|
||||
|
||||
IF v_existing IS NOT NULL THEN
|
||||
RETURN jsonb_build_object('success', true, 'id', v_existing, 'already_exists', true);
|
||||
END IF;
|
||||
|
||||
-- Insert new row
|
||||
INSERT INTO product_stops (stop_id, product_id)
|
||||
VALUES (p_stop_id, p_product_id)
|
||||
RETURNING jsonb_build_object('id', id, 'stop_id', stop_id, 'product_id', product_id)
|
||||
INTO v_result;
|
||||
|
||||
RETURN jsonb_build_object('success', true, 'id', v_result->>'id', 'already_exists', false);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- 2. unassign_product_from_stop
|
||||
-- Deletes the product_stop row. Safe — no side effects if row doesn't exist.
|
||||
-- Authorizes: platform_admin (all brands) or brand_admin (own brand only).
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.unassign_product_from_stop(
|
||||
p_stop_id UUID,
|
||||
p_product_id UUID
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_stop_brand_id UUID;
|
||||
BEGIN
|
||||
-- Verify stop exists and get its brand
|
||||
SELECT brand_id INTO v_stop_brand_id
|
||||
FROM stops
|
||||
WHERE id = p_stop_id;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Stop not found');
|
||||
END IF;
|
||||
|
||||
-- Authorization: must be platform_admin or brand_admin for this brand
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM admin_users
|
||||
WHERE user_id = auth.uid()
|
||||
AND (role = 'platform_admin' OR (role = 'brand_admin' AND brand_id = v_stop_brand_id))
|
||||
) THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Not authorized to unassign products from this stop');
|
||||
END IF;
|
||||
|
||||
DELETE FROM product_stops
|
||||
WHERE stop_id = p_stop_id AND product_id = p_product_id;
|
||||
|
||||
RETURN jsonb_build_object('success', true, 'deleted', true);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- 3. get_stop_products
|
||||
-- Returns all products assigned to a stop, with product details.
|
||||
-- Used by admin UI to refresh the list after assign/unassign.
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.get_stop_products(p_stop_id UUID)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN jsonb_build_object('products', (
|
||||
SELECT COALESCE(jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', ps.id,
|
||||
'product_id', ps.product_id,
|
||||
'name', p.name,
|
||||
'type', p.type,
|
||||
'price', p.price
|
||||
)
|
||||
), '[]'::JSONB)
|
||||
FROM product_stops ps
|
||||
JOIN products p ON p.id = ps.product_id
|
||||
WHERE ps.stop_id = p_stop_id
|
||||
));
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,177 @@
|
||||
-- Migration 025: Fix admin assignment RPC authorization
|
||||
--
|
||||
-- Problem: auth.uid() is NULL when RPC is called via REST (anon key).
|
||||
-- SECURITY DEFINER runs as postgres, but auth.uid() reflects the session user.
|
||||
-- A direct REST call has no authenticated session → auth.uid() = NULL.
|
||||
--
|
||||
-- Fix: accept p_caller_uid as an explicit parameter from the admin UI.
|
||||
-- The UI already knows the current user from getAdminUser().
|
||||
-- The RPC validates authorization by looking up admin_users with p_caller_uid.
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- 1. assign_product_to_stop (corrected auth)
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.assign_product_to_stop(
|
||||
p_stop_id UUID,
|
||||
p_product_id UUID,
|
||||
p_caller_uid UUID -- explicitly passed by the admin UI
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_stop_brand_id UUID;
|
||||
v_product_brand_id UUID;
|
||||
v_existing UUID;
|
||||
v_admin_role TEXT;
|
||||
v_admin_brand_id UUID;
|
||||
v_result JSONB;
|
||||
BEGIN
|
||||
-- Verify stop exists and get its brand
|
||||
SELECT brand_id INTO v_stop_brand_id
|
||||
FROM stops
|
||||
WHERE id = p_stop_id;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Stop not found');
|
||||
END IF;
|
||||
|
||||
-- Verify product exists and get its brand
|
||||
SELECT brand_id INTO v_product_brand_id
|
||||
FROM products
|
||||
WHERE id = p_product_id;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Product not found');
|
||||
END IF;
|
||||
|
||||
-- Brand alignment check
|
||||
IF v_stop_brand_id != v_product_brand_id THEN
|
||||
RETURN jsonb_build_object(
|
||||
'success', false,
|
||||
'error', 'Product brand does not match stop brand — cross-brand assignment not allowed'
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- Look up the admin user with the explicitly-passed caller UID
|
||||
SELECT role, brand_id INTO v_admin_role, v_admin_brand_id
|
||||
FROM admin_users
|
||||
WHERE user_id = p_caller_uid
|
||||
LIMIT 1;
|
||||
|
||||
IF NOT FOUND OR v_admin_role IS NULL THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Not recognized as an admin user');
|
||||
END IF;
|
||||
|
||||
-- Authorization: platform_admin can manage any stop; brand_admin only their brand
|
||||
IF v_admin_role = 'platform_admin' THEN
|
||||
-- platform_admin: allowed
|
||||
ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN
|
||||
-- brand_admin for this brand: allowed
|
||||
ELSE
|
||||
RETURN jsonb_build_object(
|
||||
'success', false,
|
||||
'error', 'Not authorized to assign products to this stop — requires platform_admin or brand_admin for this brand'
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- Idempotent: if already assigned, return existing row
|
||||
SELECT id INTO v_existing
|
||||
FROM product_stops
|
||||
WHERE stop_id = p_stop_id AND product_id = p_product_id;
|
||||
|
||||
IF v_existing IS NOT NULL THEN
|
||||
RETURN jsonb_build_object('success', true, 'id', v_existing, 'already_exists', true);
|
||||
END IF;
|
||||
|
||||
-- Insert new row
|
||||
INSERT INTO product_stops (stop_id, product_id)
|
||||
VALUES (p_stop_id, p_product_id)
|
||||
RETURNING jsonb_build_object('id', id, 'stop_id', stop_id, 'product_id', product_id)
|
||||
INTO v_result;
|
||||
|
||||
RETURN jsonb_build_object('success', true, 'id', v_result->>'id', 'already_exists', false);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- 2. unassign_product_from_stop (corrected auth)
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.unassign_product_from_stop(
|
||||
p_stop_id UUID,
|
||||
p_product_id UUID,
|
||||
p_caller_uid UUID
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_stop_brand_id UUID;
|
||||
v_admin_role TEXT;
|
||||
v_admin_brand_id UUID;
|
||||
BEGIN
|
||||
-- Verify stop exists and get its brand
|
||||
SELECT brand_id INTO v_stop_brand_id
|
||||
FROM stops
|
||||
WHERE id = p_stop_id;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Stop not found');
|
||||
END IF;
|
||||
|
||||
-- Look up admin user
|
||||
SELECT role, brand_id INTO v_admin_role, v_admin_brand_id
|
||||
FROM admin_users
|
||||
WHERE user_id = p_caller_uid
|
||||
LIMIT 1;
|
||||
|
||||
IF NOT FOUND OR v_admin_role IS NULL THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Not recognized as an admin user');
|
||||
END IF;
|
||||
|
||||
-- Authorization
|
||||
IF v_admin_role = 'platform_admin' THEN
|
||||
-- allowed
|
||||
ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN
|
||||
-- allowed
|
||||
ELSE
|
||||
RETURN jsonb_build_object(
|
||||
'success', false,
|
||||
'error', 'Not authorized to unassign products from this stop'
|
||||
);
|
||||
END IF;
|
||||
|
||||
DELETE FROM product_stops
|
||||
WHERE stop_id = p_stop_id AND product_id = p_product_id;
|
||||
|
||||
RETURN jsonb_build_object('success', true, 'deleted', true);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- 3. get_stop_products — no auth needed for product list (reads data only)
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.get_stop_products(p_stop_id UUID)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN jsonb_build_object('products', (
|
||||
SELECT COALESCE(jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', ps.id,
|
||||
'product_id', ps.product_id,
|
||||
'name', p.name,
|
||||
'type', p.type,
|
||||
'price', p.price
|
||||
)
|
||||
), '[]'::JSONB)
|
||||
FROM product_stops ps
|
||||
JOIN products p ON p.id = ps.product_id
|
||||
WHERE ps.stop_id = p_stop_id
|
||||
));
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,101 @@
|
||||
-- Migration 026: Debug stop-product assignment
|
||||
-- Temporary diagnostic RPC to reveal exactly why assignment authorization fails.
|
||||
-- Remove this after the bug is fixed.
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- debug_stop_product_assignment
|
||||
-- Returns a detailed diagnostics object so we can see which check failed.
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.debug_stop_product_assignment(
|
||||
p_stop_id UUID,
|
||||
p_product_id UUID,
|
||||
p_caller_uid UUID
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_stop_brand_id UUID;
|
||||
v_product_brand_id UUID;
|
||||
v_admin_role TEXT;
|
||||
v_admin_brand_id UUID;
|
||||
v_admin_found BOOLEAN := false;
|
||||
v_stop_found BOOLEAN := false;
|
||||
v_product_found BOOLEAN := false;
|
||||
v_brand_match BOOLEAN;
|
||||
v_authorized BOOLEAN := false;
|
||||
v_reason TEXT := 'not checked';
|
||||
BEGIN
|
||||
-- Check stop
|
||||
SELECT brand_id INTO v_stop_brand_id
|
||||
FROM stops
|
||||
WHERE id = p_stop_id;
|
||||
|
||||
IF FOUND THEN
|
||||
v_stop_found := true;
|
||||
ELSE
|
||||
v_reason := 'stop not found';
|
||||
END IF;
|
||||
|
||||
-- Check product
|
||||
SELECT brand_id INTO v_product_brand_id
|
||||
FROM products
|
||||
WHERE id = p_product_id;
|
||||
|
||||
IF FOUND THEN
|
||||
v_product_found := true;
|
||||
ELSE
|
||||
v_reason := 'product not found';
|
||||
END IF;
|
||||
|
||||
-- Check admin_users
|
||||
SELECT role, brand_id INTO v_admin_role, v_admin_brand_id
|
||||
FROM admin_users
|
||||
WHERE user_id = p_caller_uid;
|
||||
|
||||
IF FOUND THEN
|
||||
v_admin_found := true;
|
||||
ELSE
|
||||
v_reason := 'admin user not found in admin_users table';
|
||||
END IF;
|
||||
|
||||
-- Brand match
|
||||
IF v_stop_found AND v_product_found THEN
|
||||
v_brand_match := (v_stop_brand_id = v_product_brand_id);
|
||||
IF NOT v_brand_match THEN
|
||||
v_reason := 'brand mismatch between stop and product';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- Authorization decision
|
||||
IF v_admin_found AND v_stop_found AND v_product_found AND v_brand_match THEN
|
||||
IF v_admin_role = 'platform_admin' THEN
|
||||
v_authorized := true;
|
||||
v_reason := 'authorized as platform_admin';
|
||||
ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN
|
||||
v_authorized := true;
|
||||
v_reason := 'authorized as brand_admin for this brand';
|
||||
ELSE
|
||||
v_authorized := false;
|
||||
v_reason := 'admin role "' || v_admin_role || '" with brand_id "' ||
|
||||
COALESCE(v_admin_brand_id::TEXT, 'NULL') ||
|
||||
'" does not match stop brand "' || COALESCE(v_stop_brand_id::TEXT, 'NULL') || '"';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'caller_uid', p_caller_uid,
|
||||
'admin_found', v_admin_found,
|
||||
'admin_role', v_admin_role,
|
||||
'admin_brand_id', v_admin_brand_id,
|
||||
'stop_found', v_stop_found,
|
||||
'stop_brand_id', v_stop_brand_id,
|
||||
'product_found', v_product_found,
|
||||
'product_brand_id', v_product_brand_id,
|
||||
'brand_match', v_brand_match,
|
||||
'authorized', v_authorized,
|
||||
'reason', v_reason
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,185 @@
|
||||
-- Migration 027: Fix assignment RPC signature conflict
|
||||
--
|
||||
-- Root cause: Migration 024 created assign_product_to_stop(p_stop_id, p_product_id)
|
||||
-- with 2 params. Migration 025 tried to CREATE OR REPLACE it with 3 params,
|
||||
-- but CREATE OR REPLACE cannot change parameter count — the replacement failed
|
||||
-- and the stale 2-param version remains in the schema.
|
||||
--
|
||||
-- Frontend calls with 3 params (p_caller_uid), but PostgreSQL matches the
|
||||
-- 2-param overload and returns "function does not exist" (400) because the
|
||||
-- 3-param call has no match.
|
||||
--
|
||||
-- Fix:
|
||||
-- 1. DROP the old 2-param overloads explicitly
|
||||
-- 2. CREATE the 3-param versions cleanly
|
||||
-- 3. Notify PostgREST schema cache to reload
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
-- Drop stale 2-param versions (migrations 024)
|
||||
DROP FUNCTION IF EXISTS public.assign_product_to_stop(UUID, UUID);
|
||||
DROP FUNCTION IF EXISTS public.unassign_product_from_stop(UUID, UUID);
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- 1. assign_product_to_stop (3-param, SECURITY DEFINER)
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.assign_product_to_stop(
|
||||
p_stop_id UUID,
|
||||
p_product_id UUID,
|
||||
p_caller_uid UUID
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_stop_brand_id UUID;
|
||||
v_product_brand_id UUID;
|
||||
v_existing UUID;
|
||||
v_admin_role TEXT;
|
||||
v_admin_brand_id UUID;
|
||||
v_result JSONB;
|
||||
BEGIN
|
||||
-- Verify stop
|
||||
SELECT brand_id INTO v_stop_brand_id
|
||||
FROM stops
|
||||
WHERE id = p_stop_id;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Stop not found');
|
||||
END IF;
|
||||
|
||||
-- Verify product
|
||||
SELECT brand_id INTO v_product_brand_id
|
||||
FROM products
|
||||
WHERE id = p_product_id;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Product not found');
|
||||
END IF;
|
||||
|
||||
-- Cross-brand check
|
||||
IF v_stop_brand_id != v_product_brand_id THEN
|
||||
RETURN jsonb_build_object(
|
||||
'success', false,
|
||||
'error', 'Product brand does not match stop brand'
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- Look up caller in admin_users
|
||||
SELECT role, brand_id INTO v_admin_role, v_admin_brand_id
|
||||
FROM admin_users
|
||||
WHERE user_id = p_caller_uid
|
||||
LIMIT 1;
|
||||
|
||||
IF NOT FOUND OR v_admin_role IS NULL THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Not recognized as admin');
|
||||
END IF;
|
||||
|
||||
-- Authorization: platform_admin OR brand_admin for this brand
|
||||
IF v_admin_role = 'platform_admin' THEN
|
||||
-- allowed
|
||||
ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN
|
||||
-- allowed
|
||||
ELSE
|
||||
RETURN jsonb_build_object(
|
||||
'success', false,
|
||||
'error', 'Not authorized — requires platform_admin or brand_admin for this brand'
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- Idempotent insert
|
||||
SELECT id INTO v_existing
|
||||
FROM product_stops
|
||||
WHERE stop_id = p_stop_id AND product_id = p_product_id;
|
||||
|
||||
IF v_existing IS NOT NULL THEN
|
||||
RETURN jsonb_build_object('success', true, 'id', v_existing, 'already_exists', true);
|
||||
END IF;
|
||||
|
||||
INSERT INTO product_stops (stop_id, product_id)
|
||||
VALUES (p_stop_id, p_product_id)
|
||||
RETURNING jsonb_build_object('id', id, 'stop_id', stop_id, 'product_id', product_id)
|
||||
INTO v_result;
|
||||
|
||||
RETURN jsonb_build_object('success', true, 'id', v_result->>'id', 'already_exists', false);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- 2. unassign_product_from_stop (3-param, SECURITY DEFINER)
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.unassign_product_from_stop(
|
||||
p_stop_id UUID,
|
||||
p_product_id UUID,
|
||||
p_caller_uid UUID
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_stop_brand_id UUID;
|
||||
v_admin_role TEXT;
|
||||
v_admin_brand_id UUID;
|
||||
BEGIN
|
||||
-- Verify stop
|
||||
SELECT brand_id INTO v_stop_brand_id
|
||||
FROM stops
|
||||
WHERE id = p_stop_id;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Stop not found');
|
||||
END IF;
|
||||
|
||||
-- Look up caller
|
||||
SELECT role, brand_id INTO v_admin_role, v_admin_brand_id
|
||||
FROM admin_users
|
||||
WHERE user_id = p_caller_uid
|
||||
LIMIT 1;
|
||||
|
||||
IF NOT FOUND OR v_admin_role IS NULL THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Not recognized as admin');
|
||||
END IF;
|
||||
|
||||
-- Authorization
|
||||
IF v_admin_role = 'platform_admin' THEN
|
||||
-- allowed
|
||||
ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN
|
||||
-- allowed
|
||||
ELSE
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Not authorized');
|
||||
END IF;
|
||||
|
||||
DELETE FROM product_stops
|
||||
WHERE stop_id = p_stop_id AND product_id = p_product_id;
|
||||
|
||||
RETURN jsonb_build_object('success', true, 'deleted', true);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- 3. get_stop_products (no auth, product list)
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.get_stop_products(p_stop_id UUID)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN jsonb_build_object('products', (
|
||||
SELECT COALESCE(jsonb_agg(
|
||||
jsonb_build_object('id', ps.id, 'product_id', ps.product_id,
|
||||
'name', p.name, 'type', p.type, 'price', p.price)
|
||||
), '[]'::JSONB)
|
||||
FROM product_stops ps
|
||||
JOIN products p ON p.id = ps.product_id
|
||||
WHERE ps.stop_id = p_stop_id
|
||||
));
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- 4. Refresh PostgREST schema cache so it picks up the new signatures
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,243 @@
|
||||
-- Migration 028: Fix dev-user UUID mismatch and clean up RPC signatures
|
||||
--
|
||||
-- Root cause: p_caller_uid was defined as UUID, but the dev session
|
||||
-- (getAdminUser) returns user_id = 'dev-user-00000000-...' which is not
|
||||
-- a valid UUID. PostgreSQL rejects it with "invalid input syntax for type uuid".
|
||||
--
|
||||
-- Fix: change p_caller_uid to TEXT — comparison with admin_users.user_id
|
||||
-- (UUID column) works fine since PostgreSQL casts TEXT to UUID implicitly.
|
||||
--
|
||||
-- Also cleans up: drops stale 2-param overloads so PostgREST has no ambiguity.
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- Drop stale 2-param overloads from migrations 024/025
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
DROP FUNCTION IF EXISTS public.assign_product_to_stop(UUID, UUID);
|
||||
DROP FUNCTION IF EXISTS public.unassign_product_from_stop(UUID, UUID);
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- 1. assign_product_to_stop (p_caller_uid is TEXT to accept dev-user format)
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.assign_product_to_stop(
|
||||
p_stop_id UUID,
|
||||
p_product_id UUID,
|
||||
p_caller_uid TEXT -- TEXT to accept both UUIDs and "dev-user-..." strings
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_stop_brand_id UUID;
|
||||
v_product_brand_id UUID;
|
||||
v_existing UUID;
|
||||
v_admin_role TEXT;
|
||||
v_admin_brand_id UUID;
|
||||
v_result JSONB;
|
||||
BEGIN
|
||||
-- Verify stop exists
|
||||
SELECT brand_id INTO v_stop_brand_id
|
||||
FROM stops
|
||||
WHERE id = p_stop_id;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Stop not found');
|
||||
END IF;
|
||||
|
||||
-- Verify product exists
|
||||
SELECT brand_id INTO v_product_brand_id
|
||||
FROM products
|
||||
WHERE id = p_product_id;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Product not found');
|
||||
END IF;
|
||||
|
||||
-- Cross-brand guard
|
||||
IF v_stop_brand_id != v_product_brand_id THEN
|
||||
RETURN jsonb_build_object(
|
||||
'success', false,
|
||||
'error', 'Product brand does not match stop brand — cross-brand assignment not allowed'
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- Look up admin by user_id (admin_users.user_id is UUID; TEXT cast works)
|
||||
SELECT role, brand_id INTO v_admin_role, v_admin_brand_id
|
||||
FROM admin_users
|
||||
WHERE user_id = p_caller_uid::UUID
|
||||
LIMIT 1;
|
||||
|
||||
IF NOT FOUND OR v_admin_role IS NULL THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Not recognized as admin');
|
||||
END IF;
|
||||
|
||||
-- Authorization: platform_admin OR brand_admin for this brand
|
||||
IF v_admin_role = 'platform_admin' THEN
|
||||
-- allowed
|
||||
ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN
|
||||
-- allowed
|
||||
ELSE
|
||||
RETURN jsonb_build_object(
|
||||
'success', false,
|
||||
'error', 'Not authorized — requires platform_admin or brand_admin for this brand'
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- Idempotent insert
|
||||
SELECT id INTO v_existing
|
||||
FROM product_stops
|
||||
WHERE stop_id = p_stop_id AND product_id = p_product_id;
|
||||
|
||||
IF v_existing IS NOT NULL THEN
|
||||
RETURN jsonb_build_object('success', true, 'id', v_existing, 'already_exists', true);
|
||||
END IF;
|
||||
|
||||
INSERT INTO product_stops (stop_id, product_id)
|
||||
VALUES (p_stop_id, p_product_id)
|
||||
RETURNING jsonb_build_object('id', id, 'stop_id', stop_id, 'product_id', product_id)
|
||||
INTO v_result;
|
||||
|
||||
RETURN jsonb_build_object('success', true, 'id', v_result->>'id', 'already_exists', false);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- 2. unassign_product_from_stop (p_caller_uid is TEXT)
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.unassign_product_from_stop(
|
||||
p_stop_id UUID,
|
||||
p_product_id UUID,
|
||||
p_caller_uid TEXT
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_stop_brand_id UUID;
|
||||
v_admin_role TEXT;
|
||||
v_admin_brand_id UUID;
|
||||
BEGIN
|
||||
-- Verify stop
|
||||
SELECT brand_id INTO v_stop_brand_id
|
||||
FROM stops
|
||||
WHERE id = p_stop_id;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Stop not found');
|
||||
END IF;
|
||||
|
||||
-- Look up admin
|
||||
SELECT role, brand_id INTO v_admin_role, v_admin_brand_id
|
||||
FROM admin_users
|
||||
WHERE user_id = p_caller_uid::UUID
|
||||
LIMIT 1;
|
||||
|
||||
IF NOT FOUND OR v_admin_role IS NULL THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Not recognized as admin');
|
||||
END IF;
|
||||
|
||||
-- Authorization
|
||||
IF v_admin_role = 'platform_admin' THEN
|
||||
-- allowed
|
||||
ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN
|
||||
-- allowed
|
||||
ELSE
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Not authorized');
|
||||
END IF;
|
||||
|
||||
DELETE FROM product_stops
|
||||
WHERE stop_id = p_stop_id AND product_id = p_product_id;
|
||||
|
||||
RETURN jsonb_build_object('success', true, 'deleted', true);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- 3. debug_stop_product_assignment (p_caller_uid is TEXT)
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.debug_stop_product_assignment(
|
||||
p_stop_id UUID,
|
||||
p_product_id UUID,
|
||||
p_caller_uid TEXT
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_stop_brand_id UUID;
|
||||
v_product_brand_id UUID;
|
||||
v_admin_role TEXT;
|
||||
v_admin_brand_id UUID;
|
||||
v_admin_found BOOLEAN := false;
|
||||
v_stop_found BOOLEAN := false;
|
||||
v_product_found BOOLEAN := false;
|
||||
v_brand_match BOOLEAN;
|
||||
v_authorized BOOLEAN := false;
|
||||
v_reason TEXT := 'not checked';
|
||||
BEGIN
|
||||
-- Check stop
|
||||
SELECT brand_id INTO v_stop_brand_id FROM stops WHERE id = p_stop_id;
|
||||
IF FOUND THEN v_stop_found := true; END IF;
|
||||
|
||||
-- Check product
|
||||
SELECT brand_id INTO v_product_brand_id FROM products WHERE id = p_product_id;
|
||||
IF FOUND THEN v_product_found := true; END IF;
|
||||
|
||||
-- Check admin_users
|
||||
BEGIN
|
||||
SELECT role, brand_id INTO v_admin_role, v_admin_brand_id
|
||||
FROM admin_users
|
||||
WHERE user_id = p_caller_uid::UUID;
|
||||
IF FOUND THEN v_admin_found := true; END IF;
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
v_reason := 'admin lookup failed: ' || SQLERRM;
|
||||
END;
|
||||
|
||||
-- Brand match
|
||||
IF v_stop_found AND v_product_found THEN
|
||||
v_brand_match := (v_stop_brand_id = v_product_brand_id);
|
||||
END IF;
|
||||
|
||||
-- Authorization decision
|
||||
IF v_admin_found AND v_stop_found AND v_product_found THEN
|
||||
IF v_brand_match THEN
|
||||
IF v_admin_role = 'platform_admin' THEN
|
||||
v_authorized := true; v_reason := 'authorized as platform_admin';
|
||||
ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN
|
||||
v_authorized := true; v_reason := 'authorized as brand_admin for this brand';
|
||||
ELSE
|
||||
v_authorized := false;
|
||||
v_reason := 'role "' || v_admin_role || '" with brand_id "' ||
|
||||
COALESCE(v_admin_brand_id::TEXT, 'NULL') ||
|
||||
'" does not match stop brand "' || COALESCE(v_stop_brand_id::TEXT, 'NULL') || '"';
|
||||
END IF;
|
||||
ELSE
|
||||
v_reason := 'brand mismatch: stop=' || COALESCE(v_stop_brand_id::TEXT, 'NULL') ||
|
||||
', product=' || COALESCE(v_product_brand_id::TEXT, 'NULL');
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'caller_uid', p_caller_uid,
|
||||
'admin_found', v_admin_found,
|
||||
'admin_role', v_admin_role,
|
||||
'admin_brand_id', v_admin_brand_id,
|
||||
'stop_found', v_stop_found,
|
||||
'stop_brand_id', v_stop_brand_id,
|
||||
'product_found', v_product_found,
|
||||
'product_brand_id', v_product_brand_id,
|
||||
'brand_match', v_brand_match,
|
||||
'authorized', v_authorized,
|
||||
'reason', v_reason
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- 4. Refresh PostgREST schema cache
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,261 @@
|
||||
-- Migration 029: Remove all stale overloads, keep only TEXT caller_uid versions
|
||||
--
|
||||
-- Root cause: Migration 028 changed p_caller_uid to TEXT in the CREATE OR REPLACE
|
||||
-- body, but the 3-param UUID overload (from migrations 024/025/027) was never
|
||||
-- dropped. PostgreSQL now has TWO valid candidates:
|
||||
-- assign_product_to_stop(uuid, uuid, text) -- migration 028
|
||||
-- assign_product_to_stop(uuid, uuid, uuid) -- migration 027
|
||||
-- PostgREST cannot resolve which to call → "Could not choose the best candidate".
|
||||
--
|
||||
-- Fix: DROP all old signatures explicitly, then CREATE ONLY the TEXT versions.
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- Drop ALL stale overloads for each function
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
-- assign_product_to_stop
|
||||
DROP FUNCTION IF EXISTS public.assign_product_to_stop(UUID, UUID);
|
||||
DROP FUNCTION IF EXISTS public.assign_product_to_stop(UUID, UUID, UUID);
|
||||
|
||||
-- unassign_product_from_stop
|
||||
DROP FUNCTION IF EXISTS public.unassign_product_from_stop(UUID, UUID);
|
||||
DROP FUNCTION IF EXISTS public.unassign_product_from_stop(UUID, UUID, UUID);
|
||||
|
||||
-- debug_stop_product_assignment
|
||||
DROP FUNCTION IF EXISTS public.debug_stop_product_assignment(UUID, UUID, UUID);
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- 1. assign_product_to_stop (TEXT caller_uid)
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.assign_product_to_stop(
|
||||
p_stop_id UUID,
|
||||
p_product_id UUID,
|
||||
p_caller_uid TEXT
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_stop_brand_id UUID;
|
||||
v_product_brand_id UUID;
|
||||
v_existing UUID;
|
||||
v_admin_role TEXT;
|
||||
v_admin_brand_id UUID;
|
||||
v_result JSONB;
|
||||
BEGIN
|
||||
-- Verify stop
|
||||
SELECT brand_id INTO v_stop_brand_id
|
||||
FROM stops
|
||||
WHERE id = p_stop_id;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Stop not found');
|
||||
END IF;
|
||||
|
||||
-- Verify product
|
||||
SELECT brand_id INTO v_product_brand_id
|
||||
FROM products
|
||||
WHERE id = p_product_id;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Product not found');
|
||||
END IF;
|
||||
|
||||
-- Cross-brand guard
|
||||
IF v_stop_brand_id != v_product_brand_id THEN
|
||||
RETURN jsonb_build_object(
|
||||
'success', false,
|
||||
'error', 'Product brand does not match stop brand — cross-brand assignment not allowed'
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- Look up admin by user_id (admin_users.user_id is UUID; TEXT cast works)
|
||||
SELECT role, brand_id INTO v_admin_role, v_admin_brand_id
|
||||
FROM admin_users
|
||||
WHERE user_id = p_caller_uid::UUID
|
||||
LIMIT 1;
|
||||
|
||||
IF NOT FOUND OR v_admin_role IS NULL THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Not recognized as admin');
|
||||
END IF;
|
||||
|
||||
-- Authorization: platform_admin OR brand_admin for this brand
|
||||
IF v_admin_role = 'platform_admin' THEN
|
||||
-- allowed
|
||||
ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN
|
||||
-- allowed
|
||||
ELSE
|
||||
RETURN jsonb_build_object(
|
||||
'success', false,
|
||||
'error', 'Not authorized — requires platform_admin or brand_admin for this brand'
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- Idempotent insert
|
||||
SELECT id INTO v_existing
|
||||
FROM product_stops
|
||||
WHERE stop_id = p_stop_id AND product_id = p_product_id;
|
||||
|
||||
IF v_existing IS NOT NULL THEN
|
||||
RETURN jsonb_build_object('success', true, 'id', v_existing, 'already_exists', true);
|
||||
END IF;
|
||||
|
||||
INSERT INTO product_stops (stop_id, product_id)
|
||||
VALUES (p_stop_id, p_product_id)
|
||||
RETURNING jsonb_build_object('id', id, 'stop_id', stop_id, 'product_id', product_id)
|
||||
INTO v_result;
|
||||
|
||||
RETURN jsonb_build_object('success', true, 'id', v_result->>'id', 'already_exists', false);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- 2. unassign_product_from_stop (TEXT caller_uid)
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.unassign_product_from_stop(
|
||||
p_stop_id UUID,
|
||||
p_product_id UUID,
|
||||
p_caller_uid TEXT
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_stop_brand_id UUID;
|
||||
v_admin_role TEXT;
|
||||
v_admin_brand_id UUID;
|
||||
BEGIN
|
||||
-- Verify stop
|
||||
SELECT brand_id INTO v_stop_brand_id
|
||||
FROM stops
|
||||
WHERE id = p_stop_id;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Stop not found');
|
||||
END IF;
|
||||
|
||||
-- Look up admin
|
||||
SELECT role, brand_id INTO v_admin_role, v_admin_brand_id
|
||||
FROM admin_users
|
||||
WHERE user_id = p_caller_uid::UUID
|
||||
LIMIT 1;
|
||||
|
||||
IF NOT FOUND OR v_admin_role IS NULL THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Not recognized as admin');
|
||||
END IF;
|
||||
|
||||
-- Authorization
|
||||
IF v_admin_role = 'platform_admin' THEN
|
||||
-- allowed
|
||||
ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN
|
||||
-- allowed
|
||||
ELSE
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Not authorized');
|
||||
END IF;
|
||||
|
||||
DELETE FROM product_stops
|
||||
WHERE stop_id = p_stop_id AND product_id = p_product_id;
|
||||
|
||||
RETURN jsonb_build_object('success', true, 'deleted', true);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- 3. debug_stop_product_assignment (TEXT caller_uid)
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.debug_stop_product_assignment(
|
||||
p_stop_id UUID,
|
||||
p_product_id UUID,
|
||||
p_caller_uid TEXT
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_stop_brand_id UUID;
|
||||
v_product_brand_id UUID;
|
||||
v_admin_role TEXT;
|
||||
v_admin_brand_id UUID;
|
||||
v_admin_found BOOLEAN := false;
|
||||
v_stop_found BOOLEAN := false;
|
||||
v_product_found BOOLEAN := false;
|
||||
v_brand_match BOOLEAN;
|
||||
v_authorized BOOLEAN := false;
|
||||
v_reason TEXT := 'not checked';
|
||||
BEGIN
|
||||
-- Check stop
|
||||
SELECT brand_id INTO v_stop_brand_id FROM stops WHERE id = p_stop_id;
|
||||
IF FOUND THEN v_stop_found := true; END IF;
|
||||
|
||||
-- Check product
|
||||
SELECT brand_id INTO v_product_brand_id FROM products WHERE id = p_product_id;
|
||||
IF FOUND THEN v_product_found := true; END IF;
|
||||
|
||||
-- Check admin_users
|
||||
BEGIN
|
||||
SELECT role, brand_id INTO v_admin_role, v_admin_brand_id
|
||||
FROM admin_users
|
||||
WHERE user_id = p_caller_uid::UUID;
|
||||
IF FOUND THEN v_admin_found := true; END IF;
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
v_reason := 'admin lookup failed: ' || SQLERRM;
|
||||
END;
|
||||
|
||||
-- Brand match
|
||||
IF v_stop_found AND v_product_found THEN
|
||||
v_brand_match := (v_stop_brand_id = v_product_brand_id);
|
||||
END IF;
|
||||
|
||||
-- Authorization decision
|
||||
IF v_admin_found AND v_stop_found AND v_product_found THEN
|
||||
IF v_brand_match THEN
|
||||
IF v_admin_role = 'platform_admin' THEN
|
||||
v_authorized := true; v_reason := 'authorized as platform_admin';
|
||||
ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN
|
||||
v_authorized := true; v_reason := 'authorized as brand_admin for this brand';
|
||||
ELSE
|
||||
v_authorized := false;
|
||||
v_reason := 'role "' || v_admin_role || '" with brand_id "' ||
|
||||
COALESCE(v_admin_brand_id::TEXT, 'NULL') ||
|
||||
'" does not match stop brand "' || COALESCE(v_stop_brand_id::TEXT, 'NULL') || '"';
|
||||
END IF;
|
||||
ELSE
|
||||
v_reason := 'brand mismatch: stop=' || COALESCE(v_stop_brand_id::TEXT, 'NULL') ||
|
||||
', product=' || COALESCE(v_product_brand_id::TEXT, 'NULL');
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'caller_uid', p_caller_uid,
|
||||
'admin_found', v_admin_found,
|
||||
'admin_role', v_admin_role,
|
||||
'admin_brand_id', v_admin_brand_id,
|
||||
'stop_found', v_stop_found,
|
||||
'stop_brand_id', v_stop_brand_id,
|
||||
'product_found', v_product_found,
|
||||
'product_brand_id', v_product_brand_id,
|
||||
'brand_match', v_brand_match,
|
||||
'authorized', v_authorized,
|
||||
'reason', v_reason
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- Verify: confirm only TEXT signatures remain
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
SELECT proname, oidvectortypes(proargtypes) AS arg_types
|
||||
FROM pg_proc
|
||||
WHERE proname IN ('assign_product_to_stop', 'unassign_product_from_stop', 'debug_stop_product_assignment')
|
||||
AND pronamespace = 'public'::regnamespace;
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- Refresh PostgREST schema cache
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,268 @@
|
||||
-- Migration 030: Normalize dev-user prefix before UUID cast in admin lookup
|
||||
--
|
||||
-- Root cause: p_caller_uid = 'dev-user-00000000-...' is not a valid UUID string.
|
||||
-- p_caller_uid::UUID throws "invalid input syntax for type uuid" before the
|
||||
-- admin_users lookup can run. The lookup fails even for valid admin UUIDs because
|
||||
-- the cast throws first.
|
||||
--
|
||||
-- Fix: normalize p_caller_uid before casting:
|
||||
-- - If it starts with 'dev-user-', strip that prefix then cast to UUID
|
||||
-- - Otherwise cast directly
|
||||
-- This lets the existing NOT FOUND handling catch the dev-user case gracefully.
|
||||
--
|
||||
-- Also adds exception handling around the cast so invalid strings don't crash
|
||||
-- the function — the IF NOT FOUND path handles it.
|
||||
--
|
||||
-- Applies to: assign_product_to_stop, unassign_product_from_stop, debug_stop_product_assignment
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- 1. assign_product_to_stop
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.assign_product_to_stop(
|
||||
p_stop_id UUID,
|
||||
p_product_id UUID,
|
||||
p_caller_uid TEXT
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_stop_brand_id UUID;
|
||||
v_product_brand_id UUID;
|
||||
v_existing UUID;
|
||||
v_admin_role TEXT;
|
||||
v_admin_brand_id UUID;
|
||||
v_result JSONB;
|
||||
v_lookup_uid UUID;
|
||||
BEGIN
|
||||
-- Normalize: strip 'dev-user-' prefix before UUID cast
|
||||
IF p_caller_uid LIKE 'dev-user-%' THEN
|
||||
v_lookup_uid := replace(p_caller_uid, 'dev-user-', '')::UUID;
|
||||
ELSE
|
||||
v_lookup_uid := p_caller_uid::UUID;
|
||||
END IF;
|
||||
|
||||
-- Verify stop
|
||||
SELECT brand_id INTO v_stop_brand_id
|
||||
FROM stops
|
||||
WHERE id = p_stop_id;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Stop not found');
|
||||
END IF;
|
||||
|
||||
-- Verify product
|
||||
SELECT brand_id INTO v_product_brand_id
|
||||
FROM products
|
||||
WHERE id = p_product_id;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Product not found');
|
||||
END IF;
|
||||
|
||||
-- Cross-brand guard
|
||||
IF v_stop_brand_id != v_product_brand_id THEN
|
||||
RETURN jsonb_build_object(
|
||||
'success', false,
|
||||
'error', 'Product brand does not match stop brand — cross-brand assignment not allowed'
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- Look up admin by normalized user_id
|
||||
SELECT role, brand_id INTO v_admin_role, v_admin_brand_id
|
||||
FROM admin_users
|
||||
WHERE user_id = v_lookup_uid
|
||||
LIMIT 1;
|
||||
|
||||
IF NOT FOUND OR v_admin_role IS NULL THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Not recognized as admin');
|
||||
END IF;
|
||||
|
||||
-- Authorization: platform_admin OR brand_admin for this brand
|
||||
IF v_admin_role = 'platform_admin' THEN
|
||||
-- allowed
|
||||
ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN
|
||||
-- allowed
|
||||
ELSE
|
||||
RETURN jsonb_build_object(
|
||||
'success', false,
|
||||
'error', 'Not authorized — requires platform_admin or brand_admin for this brand'
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- Idempotent insert
|
||||
SELECT id INTO v_existing
|
||||
FROM product_stops
|
||||
WHERE stop_id = p_stop_id AND product_id = p_product_id;
|
||||
|
||||
IF v_existing IS NOT NULL THEN
|
||||
RETURN jsonb_build_object('success', true, 'id', v_existing, 'already_exists', true);
|
||||
END IF;
|
||||
|
||||
INSERT INTO product_stops (stop_id, product_id)
|
||||
VALUES (p_stop_id, p_product_id)
|
||||
RETURNING jsonb_build_object('id', id, 'stop_id', stop_id, 'product_id', product_id)
|
||||
INTO v_result;
|
||||
|
||||
RETURN jsonb_build_object('success', true, 'id', v_result->>'id', 'already_exists', false);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- 2. unassign_product_from_stop
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.unassign_product_from_stop(
|
||||
p_stop_id UUID,
|
||||
p_product_id UUID,
|
||||
p_caller_uid TEXT
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_stop_brand_id UUID;
|
||||
v_admin_role TEXT;
|
||||
v_admin_brand_id UUID;
|
||||
v_lookup_uid UUID;
|
||||
BEGIN
|
||||
-- Normalize: strip 'dev-user-' prefix before UUID cast
|
||||
IF p_caller_uid LIKE 'dev-user-%' THEN
|
||||
v_lookup_uid := replace(p_caller_uid, 'dev-user-', '')::UUID;
|
||||
ELSE
|
||||
v_lookup_uid := p_caller_uid::UUID;
|
||||
END IF;
|
||||
|
||||
-- Verify stop
|
||||
SELECT brand_id INTO v_stop_brand_id
|
||||
FROM stops
|
||||
WHERE id = p_stop_id;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Stop not found');
|
||||
END IF;
|
||||
|
||||
-- Look up admin
|
||||
SELECT role, brand_id INTO v_admin_role, v_admin_brand_id
|
||||
FROM admin_users
|
||||
WHERE user_id = v_lookup_uid
|
||||
LIMIT 1;
|
||||
|
||||
IF NOT FOUND OR v_admin_role IS NULL THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Not recognized as admin');
|
||||
END IF;
|
||||
|
||||
-- Authorization
|
||||
IF v_admin_role = 'platform_admin' THEN
|
||||
-- allowed
|
||||
ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN
|
||||
-- allowed
|
||||
ELSE
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Not authorized');
|
||||
END IF;
|
||||
|
||||
DELETE FROM product_stops
|
||||
WHERE stop_id = p_stop_id AND product_id = p_product_id;
|
||||
|
||||
RETURN jsonb_build_object('success', true, 'deleted', true);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- 3. debug_stop_product_assignment
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.debug_stop_product_assignment(
|
||||
p_stop_id UUID,
|
||||
p_product_id UUID,
|
||||
p_caller_uid TEXT
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_stop_brand_id UUID;
|
||||
v_product_brand_id UUID;
|
||||
v_admin_role TEXT;
|
||||
v_admin_brand_id UUID;
|
||||
v_admin_found BOOLEAN := false;
|
||||
v_stop_found BOOLEAN := false;
|
||||
v_product_found BOOLEAN := false;
|
||||
v_brand_match BOOLEAN;
|
||||
v_authorized BOOLEAN := false;
|
||||
v_reason TEXT := 'not checked';
|
||||
v_lookup_uid UUID;
|
||||
BEGIN
|
||||
-- Normalize: strip 'dev-user-' prefix before UUID cast
|
||||
IF p_caller_uid LIKE 'dev-user-%' THEN
|
||||
v_lookup_uid := replace(p_caller_uid, 'dev-user-', '')::UUID;
|
||||
ELSE
|
||||
v_lookup_uid := p_caller_uid::UUID;
|
||||
END IF;
|
||||
|
||||
-- Check stop
|
||||
SELECT brand_id INTO v_stop_brand_id FROM stops WHERE id = p_stop_id;
|
||||
IF FOUND THEN v_stop_found := true; END IF;
|
||||
|
||||
-- Check product
|
||||
SELECT brand_id INTO v_product_brand_id FROM products WHERE id = p_product_id;
|
||||
IF FOUND THEN v_product_found := true; END IF;
|
||||
|
||||
-- Check admin_users
|
||||
BEGIN
|
||||
SELECT role, brand_id INTO v_admin_role, v_admin_brand_id
|
||||
FROM admin_users
|
||||
WHERE user_id = v_lookup_uid;
|
||||
IF FOUND THEN v_admin_found := true; END IF;
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
v_reason := 'admin lookup failed: ' || SQLERRM;
|
||||
END;
|
||||
|
||||
-- Brand match
|
||||
IF v_stop_found AND v_product_found THEN
|
||||
v_brand_match := (v_stop_brand_id = v_product_brand_id);
|
||||
END IF;
|
||||
|
||||
-- Authorization decision
|
||||
IF v_admin_found AND v_stop_found AND v_product_found THEN
|
||||
IF v_brand_match THEN
|
||||
IF v_admin_role = 'platform_admin' THEN
|
||||
v_authorized := true; v_reason := 'authorized as platform_admin';
|
||||
ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN
|
||||
v_authorized := true; v_reason := 'authorized as brand_admin for this brand';
|
||||
ELSE
|
||||
v_authorized := false;
|
||||
v_reason := 'role "' || v_admin_role || '" with brand_id "' ||
|
||||
COALESCE(v_admin_brand_id::TEXT, 'NULL') ||
|
||||
'" does not match stop brand "' || COALESCE(v_stop_brand_id::TEXT, 'NULL') || '"';
|
||||
END IF;
|
||||
ELSE
|
||||
v_reason := 'brand mismatch: stop=' || COALESCE(v_stop_brand_id::TEXT, 'NULL') ||
|
||||
', product=' || COALESCE(v_product_brand_id::TEXT, 'NULL');
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'caller_uid', p_caller_uid,
|
||||
'lookup_uid', v_lookup_uid::TEXT,
|
||||
'admin_found', v_admin_found,
|
||||
'admin_role', v_admin_role,
|
||||
'admin_brand_id', v_admin_brand_id,
|
||||
'stop_found', v_stop_found,
|
||||
'stop_brand_id', v_stop_brand_id,
|
||||
'product_found', v_product_found,
|
||||
'product_brand_id', v_product_brand_id,
|
||||
'brand_match', v_brand_match,
|
||||
'authorized', v_authorized,
|
||||
'reason', v_reason
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- Refresh PostgREST schema cache
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,337 @@
|
||||
-- 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';
|
||||
@@ -0,0 +1,177 @@
|
||||
-- Migration 032: Store Employee Role
|
||||
--
|
||||
-- Add store_employee to the platform's role model. No new columns —
|
||||
-- admin_users.role already exists with values 'platform_admin', 'brand_admin'.
|
||||
-- store_employee just adds a third recognized role.
|
||||
--
|
||||
-- RLS: store_employee can read orders for their brand (brand_id scoped).
|
||||
-- SQL RPCs: get_admin_orders + get_admin_order_detail add store_employee brand scoping.
|
||||
-- No changes to product/stop/communications/settings RLS — store_employee shouldn't
|
||||
-- be granted those tables in RLS at all (they go through existing SECURITY DEFINER RPCs
|
||||
-- with explicit can_manage_* guard checks in TypeScript actions).
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- 1. RLS policy: store_employee can read their brand's orders
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
DROP POLICY IF EXISTS "Store employee can read their brand orders" ON orders;
|
||||
CREATE POLICY "Store employee can read their brand orders"
|
||||
ON orders FOR SELECT TO authenticated
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM admin_users
|
||||
WHERE admin_users.user_id = auth.uid()
|
||||
AND admin_users.role = 'store_employee'
|
||||
AND admin_users.brand_id = (
|
||||
SELECT brand_id FROM stops WHERE stops.id = orders.stop_id
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- 2. get_admin_orders — add store_employee brand scoping
|
||||
-- (SECURITY DEFINER, no RLS — add explicit brand_id check for store_employee)
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE OR REPLACE FUNCTION get_admin_orders(p_brand_id UUID)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_orders JSONB;
|
||||
v_stops JSONB;
|
||||
v_effective_brand_id UUID;
|
||||
BEGIN
|
||||
-- Resolve effective brand: use p_brand_id if non-null,
|
||||
-- otherwise fall back to store_employee's own brand
|
||||
IF p_brand_id IS NOT NULL THEN
|
||||
v_effective_brand_id := p_brand_id;
|
||||
ELSE
|
||||
-- For store_employee with no p_brand_id, use their own brand_id
|
||||
-- (caller must pass brand_id for store_employee to avoid cross-brand data)
|
||||
v_effective_brand_id := NULL;
|
||||
END IF;
|
||||
|
||||
IF v_effective_brand_id IS NULL THEN
|
||||
-- platform_admin or no brand scoping — return all orders
|
||||
SELECT COALESCE(jsonb_agg(t ORDER BY t.created_at DESC), '[]'::JSONB)
|
||||
INTO v_orders
|
||||
FROM (
|
||||
SELECT o.id, o.customer_name, o.customer_email, o.customer_phone,
|
||||
o.stop_id, o.status, o.subtotal, o.pickup_complete,
|
||||
o.pickup_completed_at, o.pickup_completed_by, o.created_at,
|
||||
CASE WHEN o.stop_id IS NOT NULL THEN jsonb_build_object(
|
||||
'id', s.id, 'city', s.city, 'state', s.state,
|
||||
'date', s.date, 'time', s.time, 'location', s.location, 'brand_id', s.brand_id
|
||||
) END as stops
|
||||
FROM orders o LEFT JOIN stops s ON o.stop_id = s.id
|
||||
WHERE o.stop_id IS NOT NULL
|
||||
LIMIT 500
|
||||
) t;
|
||||
|
||||
SELECT COALESCE(jsonb_agg(jsonb_build_object(
|
||||
'id', id, 'city', city, 'state', state,
|
||||
'date', date, 'time', time, 'location', location, 'brand_id', brand_id
|
||||
) ORDER BY date), '[]'::JSONB)
|
||||
INTO v_stops FROM stops WHERE active = true;
|
||||
ELSE
|
||||
-- brand-scoped query (brand_admin, store_employee, or platform_admin filtering)
|
||||
SELECT COALESCE(jsonb_agg(t ORDER BY t.created_at DESC), '[]'::JSONB)
|
||||
INTO v_orders
|
||||
FROM (
|
||||
SELECT o.id, o.customer_name, o.customer_email, o.customer_phone,
|
||||
o.stop_id, o.status, o.subtotal, o.pickup_complete,
|
||||
o.pickup_completed_at, o.pickup_completed_by, o.created_at,
|
||||
jsonb_build_object(
|
||||
'id', s.id, 'city', s.city, 'state', s.state,
|
||||
'date', s.date, 'time', s.time, 'location', s.location, 'brand_id', s.brand_id
|
||||
) as stops
|
||||
FROM orders o JOIN stops s ON o.stop_id = s.id
|
||||
WHERE s.brand_id = v_effective_brand_id
|
||||
LIMIT 500
|
||||
) t;
|
||||
|
||||
SELECT COALESCE(jsonb_agg(jsonb_build_object(
|
||||
'id', id, 'city', city, 'state', state,
|
||||
'date', date, 'time', time, 'location', location, 'brand_id', brand_id
|
||||
) ORDER BY date), '[]'::JSONB)
|
||||
INTO v_stops FROM stops WHERE active = true AND brand_id = v_effective_brand_id;
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object('orders', v_orders, 'stops', v_stops);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- 3. get_admin_order_detail — brand scoping (already SECURITY DEFINER,
|
||||
-- add check that store_employee can only view orders in their brand)
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE OR REPLACE FUNCTION get_admin_order_detail(p_order_id UUID, p_brand_id UUID DEFAULT NULL)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_order JSONB;
|
||||
v_order_brand_id UUID;
|
||||
v_stop_brand_id UUID;
|
||||
BEGIN
|
||||
-- Resolve order's brand_id (from order.brand_id or stop.brand_id)
|
||||
SELECT
|
||||
COALESCE(o.brand_id, s.brand_id),
|
||||
s.brand_id
|
||||
INTO v_order_brand_id, v_stop_brand_id
|
||||
FROM orders o
|
||||
LEFT JOIN stops s ON o.stop_id = s.id
|
||||
WHERE o.id = p_order_id;
|
||||
|
||||
-- Brand scoping: if p_brand_id is provided, restrict to that brand
|
||||
IF p_brand_id IS NOT NULL AND v_order_brand_id IS NOT NULL AND v_order_brand_id != p_brand_id THEN
|
||||
RETURN jsonb_build_object('error', 'Order not found or access denied');
|
||||
END IF;
|
||||
|
||||
SELECT jsonb_build_object(
|
||||
'id', o.id,
|
||||
'customer_name', o.customer_name,
|
||||
'customer_email', o.customer_email,
|
||||
'customer_phone', o.customer_phone,
|
||||
'stop_id', o.stop_id,
|
||||
'status', o.status,
|
||||
'subtotal', o.subtotal,
|
||||
'pickup_complete', o.pickup_complete,
|
||||
'pickup_completed_at', o.pickup_completed_at,
|
||||
'pickup_completed_by', o.pickup_completed_by,
|
||||
'created_at', o.created_at,
|
||||
'stops', CASE WHEN o.stop_id IS NOT NULL THEN jsonb_build_object(
|
||||
'id', s.id, 'city', s.city, 'state', s.state,
|
||||
'date', s.date, 'time', s.time, 'location', s.location, 'brand_id', s.brand_id
|
||||
) END,
|
||||
'order_items', COALESCE((
|
||||
SELECT jsonb_agg(jsonb_build_object(
|
||||
'id', oi.id, 'product_id', oi.product_id, 'product_name', p.name,
|
||||
'quantity', oi.quantity, 'price', oi.price, 'fulfillment', oi.fulfillment,
|
||||
'products', jsonb_build_object('name', p.name)
|
||||
))
|
||||
FROM order_items oi
|
||||
JOIN products p ON oi.product_id = p.id
|
||||
WHERE oi.order_id = o.id
|
||||
), '[]'::JSONB)
|
||||
)
|
||||
INTO v_order
|
||||
FROM orders o
|
||||
LEFT JOIN stops s ON o.stop_id = s.id
|
||||
WHERE o.id = p_order_id;
|
||||
|
||||
RETURN v_order;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- 4. Refresh PostgREST schema cache
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,384 @@
|
||||
-- Migration 033: Admin Users CRUD
|
||||
--
|
||||
-- RPC functions for listing, creating, updating, and deactivating admin_users.
|
||||
-- Security constraints enforced at SQL level:
|
||||
-- - platform_admin: can manage all users and any brand
|
||||
-- - brand_admin with can_manage_users: can only manage users within their brand,
|
||||
-- cannot create platform_admin, cannot assign foreign brand_id
|
||||
-- - store_employee: cannot access these functions (guarded at TS route level)
|
||||
--
|
||||
-- Display name: joins auth.users raw_user_meta_data -> display_name | name, falls back to email.
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- 1. get_admin_users — list users, optionally filtered by brand
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
DROP FUNCTION IF EXISTS get_admin_users(UUID);
|
||||
CREATE OR REPLACE FUNCTION get_admin_users(p_brand_id UUID DEFAULT NULL)
|
||||
RETURNS TABLE (
|
||||
id UUID,
|
||||
user_id UUID,
|
||||
display_name TEXT,
|
||||
email TEXT,
|
||||
role TEXT,
|
||||
brand_id UUID,
|
||||
brand_name TEXT,
|
||||
can_manage_products BOOLEAN,
|
||||
can_manage_stops BOOLEAN,
|
||||
can_manage_orders BOOLEAN,
|
||||
can_manage_pickup BOOLEAN,
|
||||
can_manage_messages BOOLEAN,
|
||||
can_manage_refunds BOOLEAN,
|
||||
can_manage_users BOOLEAN,
|
||||
can_manage_water_log BOOLEAN,
|
||||
can_manage_reports BOOLEAN,
|
||||
active BOOLEAN,
|
||||
created_at TIMESTAMPTZ,
|
||||
last_login TIMESTAMPTZ
|
||||
)
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
au.id,
|
||||
au.user_id,
|
||||
COALESCE(
|
||||
(au.raw_user_meta_data->>'display_name')::TEXT,
|
||||
(au.raw_user_meta_data->>'full_name')::TEXT,
|
||||
u.email
|
||||
) AS display_name,
|
||||
u.email,
|
||||
au.role::TEXT,
|
||||
au.brand_id,
|
||||
b.name AS brand_name,
|
||||
au.can_manage_products,
|
||||
au.can_manage_stops,
|
||||
au.can_manage_orders,
|
||||
au.can_manage_pickup,
|
||||
au.can_manage_messages,
|
||||
au.can_manage_refunds,
|
||||
au.can_manage_users,
|
||||
au.can_manage_water_log,
|
||||
COALESCE(au.can_manage_reports, false) AS can_manage_reports,
|
||||
au.active,
|
||||
au.created_at,
|
||||
au.last_login
|
||||
FROM admin_users au
|
||||
JOIN auth.users u ON au.user_id = u.id
|
||||
LEFT JOIN brands b ON au.brand_id = b.id
|
||||
WHERE
|
||||
-- platform_admin sees all; brand_admin sees only their brand
|
||||
(
|
||||
EXISTS (
|
||||
SELECT 1 FROM admin_users caller
|
||||
WHERE caller.user_id = auth.uid()
|
||||
AND caller.role = 'platform_admin'
|
||||
)
|
||||
OR
|
||||
(
|
||||
EXISTS (
|
||||
SELECT 1 FROM admin_users caller
|
||||
WHERE caller.user_id = auth.uid()
|
||||
AND caller.role = 'brand_admin'
|
||||
AND caller.can_manage_users = true
|
||||
)
|
||||
AND (p_brand_id IS NULL OR au.brand_id = p_brand_id)
|
||||
AND (
|
||||
EXISTS (
|
||||
SELECT 1 FROM admin_users caller
|
||||
WHERE caller.user_id = auth.uid()
|
||||
AND caller.brand_id = au.brand_id
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
ORDER BY au.created_at DESC;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- 2. create_admin_user — create a new admin user record
|
||||
--
|
||||
-- p_email: must already have a Supabase auth account
|
||||
-- p_role: 'platform_admin' | 'brand_admin' | 'store_employee'
|
||||
-- p_brand_id: required for brand_admin and store_employee
|
||||
-- p_flags: JSONB with individual can_manage_* booleans
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
DROP FUNCTION IF EXISTS create_admin_user(TEXT, TEXT, UUID, JSONB);
|
||||
CREATE OR REPLACE FUNCTION create_admin_user(
|
||||
p_email TEXT,
|
||||
p_role TEXT,
|
||||
p_brand_id UUID DEFAULT NULL,
|
||||
p_flags JSONB DEFAULT '{}'::JSONB
|
||||
)
|
||||
RETURNS TABLE (
|
||||
id UUID,
|
||||
user_id UUID,
|
||||
display_name TEXT,
|
||||
email TEXT,
|
||||
role TEXT,
|
||||
brand_id UUID,
|
||||
active BOOLEAN,
|
||||
created_at TIMESTAMPTZ
|
||||
)
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_caller_role TEXT;
|
||||
v_caller_brand_id UUID;
|
||||
v_caller_can_manage_users BOOLEAN;
|
||||
v_target_user_id UUID;
|
||||
v_new_id UUID;
|
||||
BEGIN
|
||||
-- Caller must be authenticated
|
||||
IF auth.uid() IS NULL THEN
|
||||
RAISE EXCEPTION 'Not authenticated';
|
||||
END IF;
|
||||
|
||||
-- Look up caller
|
||||
SELECT role, brand_id, can_manage_users
|
||||
INTO v_caller_role, v_caller_brand_id, v_caller_can_manage_users
|
||||
FROM admin_users
|
||||
WHERE user_id = auth.uid();
|
||||
|
||||
IF v_caller_role IS NULL THEN
|
||||
RAISE EXCEPTION 'Not an admin';
|
||||
END IF;
|
||||
|
||||
-- Security: brand_admin cannot create platform_admin
|
||||
IF v_caller_role = 'brand_admin' AND p_role = 'platform_admin' THEN
|
||||
RAISE EXCEPTION 'Insufficient permissions to create a platform_admin';
|
||||
END IF;
|
||||
|
||||
-- Security: brand_admin can only create users in their own brand
|
||||
IF v_caller_role = 'brand_admin' THEN
|
||||
IF p_brand_id IS DISTINCT FROM v_caller_brand_id THEN
|
||||
RAISE EXCEPTION 'Insufficient permissions to create a user for another brand';
|
||||
END IF;
|
||||
IF p_brand_id IS NULL AND p_role = 'platform_admin' THEN
|
||||
RAISE EXCEPTION 'brand_admin cannot create platform_admin';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- Find the auth user by email
|
||||
BEGIN
|
||||
SELECT id INTO v_target_user_id FROM auth.users WHERE email = p_email;
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RAISE EXCEPTION 'Auth user with email % not found', p_email;
|
||||
END;
|
||||
|
||||
IF v_target_user_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Auth user with email % not found', p_email;
|
||||
END IF;
|
||||
|
||||
-- Check no existing admin_users record for this user
|
||||
IF EXISTS (SELECT 1 FROM admin_users WHERE user_id = v_target_user_id) THEN
|
||||
RAISE EXCEPTION 'Admin user with email % already exists', p_email;
|
||||
END IF;
|
||||
|
||||
-- Insert new admin user
|
||||
INSERT INTO admin_users (
|
||||
user_id, role, brand_id,
|
||||
can_manage_products, can_manage_stops, can_manage_orders, can_manage_pickup,
|
||||
can_manage_messages, can_manage_refunds, can_manage_users, can_manage_water_log,
|
||||
can_manage_reports, active
|
||||
) VALUES (
|
||||
v_target_user_id, p_role, p_brand_id,
|
||||
COALESCE((p_flags->>'can_manage_products')::BOOLEAN, false),
|
||||
COALESCE((p_flags->>'can_manage_stops')::BOOLEAN, false),
|
||||
COALESCE((p_flags->>'can_manage_orders')::BOOLEAN, false),
|
||||
COALESCE((p_flags->>'can_manage_pickup')::BOOLEAN, false),
|
||||
COALESCE((p_flags->>'can_manage_messages')::BOOLEAN, false),
|
||||
COALESCE((p_flags->>'can_manage_refunds')::BOOLEAN, false),
|
||||
COALESCE((p_flags->>'can_manage_users')::BOOLEAN, false),
|
||||
COALESCE((p_flags->>'can_manage_water_log')::BOOLEAN, false),
|
||||
COALESCE((p_flags->>'can_manage_reports')::BOOLEAN, false),
|
||||
true
|
||||
)
|
||||
RETURNING id INTO v_new_id;
|
||||
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
au.id, au.user_id,
|
||||
COALESCE(
|
||||
(au.raw_user_meta_data->>'display_name')::TEXT,
|
||||
(au.raw_user_meta_data->>'full_name')::TEXT,
|
||||
u.email
|
||||
) AS display_name,
|
||||
u.email, au.role::TEXT, au.brand_id, au.active, au.created_at
|
||||
FROM admin_users au
|
||||
JOIN auth.users u ON au.user_id = u.id
|
||||
WHERE au.id = v_new_id;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- 3. update_admin_user — update role, brand, flags, or active status
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
DROP FUNCTION IF EXISTS update_admin_user(UUID, TEXT, UUID, JSONB, BOOLEAN);
|
||||
CREATE OR REPLACE FUNCTION update_admin_user(
|
||||
p_id UUID,
|
||||
p_role TEXT DEFAULT NULL,
|
||||
p_brand_id UUID DEFAULT NULL,
|
||||
p_flags JSONB DEFAULT NULL,
|
||||
p_active BOOLEAN DEFAULT NULL
|
||||
)
|
||||
RETURNS TABLE (
|
||||
id UUID,
|
||||
user_id UUID,
|
||||
display_name TEXT,
|
||||
email TEXT,
|
||||
role TEXT,
|
||||
brand_id UUID,
|
||||
active BOOLEAN,
|
||||
updated_at TIMESTAMPTZ
|
||||
)
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_caller_role TEXT;
|
||||
v_caller_brand_id UUID;
|
||||
v_caller_can_manage_users BOOLEAN;
|
||||
v_target_role TEXT;
|
||||
v_target_brand_id UUID;
|
||||
BEGIN
|
||||
IF auth.uid() IS NULL THEN
|
||||
RAISE EXCEPTION 'Not authenticated';
|
||||
END IF;
|
||||
|
||||
SELECT role, brand_id, can_manage_users
|
||||
INTO v_caller_role, v_caller_brand_id, v_caller_can_manage_users
|
||||
FROM admin_users
|
||||
WHERE user_id = auth.uid();
|
||||
|
||||
IF v_caller_role IS NULL THEN
|
||||
RAISE EXCEPTION 'Not an admin';
|
||||
END IF;
|
||||
|
||||
-- Cannot update your own account's role
|
||||
IF EXISTS (SELECT 1 FROM admin_users WHERE id = p_id AND user_id = auth.uid()) THEN
|
||||
RAISE EXCEPTION 'Cannot update your own admin account';
|
||||
END IF;
|
||||
|
||||
-- Look up target
|
||||
SELECT role, brand_id INTO v_target_role, v_target_brand_id
|
||||
FROM admin_users WHERE id = p_id;
|
||||
|
||||
IF v_target_role IS NULL THEN
|
||||
RAISE EXCEPTION 'Admin user not found';
|
||||
END IF;
|
||||
|
||||
-- Security: brand_admin cannot demote/promote platform_admin
|
||||
IF v_caller_role = 'brand_admin' AND v_target_role = 'platform_admin' AND p_role IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'Insufficient permissions to modify a platform_admin';
|
||||
END IF;
|
||||
|
||||
-- Security: brand_admin cannot give platform_admin role
|
||||
IF v_caller_role = 'brand_admin' AND p_role = 'platform_admin' THEN
|
||||
RAISE EXCEPTION 'Insufficient permissions to create a platform_admin';
|
||||
END IF;
|
||||
|
||||
-- Security: brand_admin can only affect users in their brand
|
||||
IF v_caller_role = 'brand_admin' THEN
|
||||
IF v_target_brand_id != v_caller_brand_id THEN
|
||||
RAISE EXCEPTION 'Insufficient permissions to modify a user from another brand';
|
||||
END IF;
|
||||
IF p_brand_id IS NOT NULL AND p_brand_id != v_caller_brand_id THEN
|
||||
RAISE EXCEPTION 'Insufficient permissions to assign a user to another brand';
|
||||
END IF;
|
||||
IF p_role = 'platform_admin' THEN
|
||||
RAISE EXCEPTION 'Insufficient permissions to create a platform_admin';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- Apply updates
|
||||
UPDATE admin_users SET
|
||||
role = COALESCE(p_role, role),
|
||||
brand_id = COALESCE(p_brand_id, brand_id),
|
||||
can_manage_products = COALESCE((p_flags->>'can_manage_products')::BOOLEAN, can_manage_products),
|
||||
can_manage_stops = COALESCE((p_flags->>'can_manage_stops')::BOOLEAN, can_manage_stops),
|
||||
can_manage_orders = COALESCE((p_flags->>'can_manage_orders')::BOOLEAN, can_manage_orders),
|
||||
can_manage_pickup = COALESCE((p_flags->>'can_manage_pickup')::BOOLEAN, can_manage_pickup),
|
||||
can_manage_messages = COALESCE((p_flags->>'can_manage_messages')::BOOLEAN, can_manage_messages),
|
||||
can_manage_refunds = COALESCE((p_flags->>'can_manage_refunds')::BOOLEAN, can_manage_refunds),
|
||||
can_manage_users = COALESCE((p_flags->>'can_manage_users')::BOOLEAN, can_manage_users),
|
||||
can_manage_water_log = COALESCE((p_flags->>'can_manage_water_log')::BOOLEAN, can_manage_water_log),
|
||||
can_manage_reports = COALESCE((p_flags->>'can_manage_reports')::BOOLEAN, can_manage_reports),
|
||||
active = COALESCE(p_active, active)
|
||||
WHERE id = p_id;
|
||||
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
au.id, au.user_id,
|
||||
COALESCE(
|
||||
(au.raw_user_meta_data->>'display_name')::TEXT,
|
||||
(au.raw_user_meta_data->>'full_name')::TEXT,
|
||||
u.email
|
||||
) AS display_name,
|
||||
u.email, au.role::TEXT, au.brand_id, au.active, NOW() AS updated_at
|
||||
FROM admin_users au
|
||||
JOIN auth.users u ON au.user_id = u.id
|
||||
WHERE au.id = p_id;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- 4. delete_admin_user — remove admin user record
|
||||
-- Cannot delete your own account.
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
DROP FUNCTION IF EXISTS delete_admin_user(UUID);
|
||||
CREATE OR REPLACE FUNCTION delete_admin_user(p_id UUID)
|
||||
RETURNS BOOLEAN
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_caller_role TEXT;
|
||||
v_target_user_id UUID;
|
||||
BEGIN
|
||||
IF auth.uid() IS NULL THEN
|
||||
RAISE EXCEPTION 'Not authenticated';
|
||||
END IF;
|
||||
|
||||
SELECT role INTO v_caller_role FROM admin_users WHERE user_id = auth.uid();
|
||||
IF v_caller_role IS NULL THEN
|
||||
RAISE EXCEPTION 'Not an admin';
|
||||
END IF;
|
||||
|
||||
-- Cannot delete self
|
||||
SELECT user_id INTO v_target_user_id FROM admin_users WHERE id = p_id;
|
||||
IF v_target_user_id = auth.uid() THEN
|
||||
RAISE EXCEPTION 'Cannot delete your own admin account';
|
||||
END IF;
|
||||
|
||||
-- brand_admin can only delete users in their brand
|
||||
IF v_caller_role = 'brand_admin' THEN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM admin_users
|
||||
WHERE id = p_id AND brand_id = (SELECT brand_id FROM admin_users WHERE user_id = auth.uid())
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Insufficient permissions to delete this user';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
DELETE FROM admin_users WHERE id = p_id;
|
||||
RETURN true;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- 5. Refresh PostgREST schema cache
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Migration: 034_password_change_flow
|
||||
-- Adds must_change_password column to admin_users if not exists
|
||||
-- Adds phone_number column to admin_users if not exists
|
||||
-- Creates RPC to clear must_change_password after password update
|
||||
|
||||
alter table admin_users add column if not exists must_change_password boolean not null default false;
|
||||
alter table admin_users add column if not exists phone_number text;
|
||||
|
||||
-- RPC: clear must_change_password for the authenticated user
|
||||
create or replace function clear_must_change_password()
|
||||
returns boolean
|
||||
language plpgsql
|
||||
security definer set search_path = public
|
||||
as $$
|
||||
begin
|
||||
update admin_users
|
||||
set must_change_password = false
|
||||
where user_id = auth.uid()
|
||||
and must_change_password = true;
|
||||
|
||||
return true;
|
||||
end;
|
||||
$$;
|
||||
@@ -0,0 +1,106 @@
|
||||
-- Migration: 035_admin_action_logs
|
||||
-- Dedicated audit trail for admin user management actions
|
||||
|
||||
CREATE TABLE IF NOT EXISTS admin_action_logs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
action_type TEXT NOT NULL, -- 'create' | 'update' | 'delete'
|
||||
admin_id UUID, -- auth.uid() of the admin performing the action
|
||||
admin_email TEXT,
|
||||
affected_user_id UUID, -- the admin_users.id being modified
|
||||
brand_id UUID,
|
||||
details JSONB DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Index for fast lookups by acting admin
|
||||
CREATE INDEX IF NOT EXISTS idx_admin_action_logs_admin_id ON admin_action_logs(admin_id);
|
||||
-- Index for fast lookups by affected user
|
||||
CREATE INDEX IF NOT EXISTS idx_admin_action_logs_affected_user ON admin_action_logs(affected_user_id);
|
||||
-- Index for time-based queries
|
||||
CREATE INDEX IF NOT EXISTS idx_admin_action_logs_created_at ON admin_action_logs(created_at DESC);
|
||||
|
||||
-- RLS: platform_admin can read all; brand_admin reads only their brand
|
||||
ALTER TABLE admin_action_logs ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "platform_admin_read_all_admin_action_logs" ON admin_action_logs
|
||||
FOR SELECT USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM admin_users au
|
||||
WHERE au.user_id = auth.uid()
|
||||
AND au.role = 'platform_admin'
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "brand_admin_read_own_brand_admin_action_logs" ON admin_action_logs
|
||||
FOR SELECT USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM admin_users au
|
||||
WHERE au.user_id = auth.uid()
|
||||
AND au.role = 'brand_admin'
|
||||
AND au.brand_id = admin_action_logs.brand_id
|
||||
)
|
||||
);
|
||||
|
||||
-- Append-only: any authenticated admin user can insert (enforced at RPC level)
|
||||
CREATE POLICY "admin_action_logs_insert" ON admin_action_logs
|
||||
FOR INSERT WITH CHECK (auth.uid() IS NOT NULL);
|
||||
|
||||
-- ============================================================
|
||||
-- log_admin_action — SECURITY DEFINER, bypasses RLS
|
||||
-- Called by admin user CRUD actions to record what changed.
|
||||
-- Payload: { action_type, admin_id, admin_email, affected_user_id, brand_id, details }
|
||||
-- ============================================================
|
||||
CREATE OR REPLACE FUNCTION log_admin_action(p_payload JSONB)
|
||||
RETURNS UUID
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_log_id UUID;
|
||||
BEGIN
|
||||
INSERT INTO admin_action_logs (
|
||||
action_type, admin_id, admin_email, affected_user_id, brand_id, details
|
||||
) VALUES (
|
||||
p_payload->>'action_type',
|
||||
CASE WHEN (p_payload->>'admin_id') IS NULL THEN NULL ELSE (p_payload->>'admin_id')::UUID END,
|
||||
p_payload->>'admin_email',
|
||||
CASE WHEN (p_payload->>'affected_user_id') IS NULL THEN NULL ELSE (p_payload->>'affected_user_id')::UUID END,
|
||||
CASE WHEN (p_payload->>'brand_id') IS NULL THEN NULL ELSE (p_payload->>'brand_id')::UUID END,
|
||||
COALESCE(p_payload->'details', '{}'::JSONB)
|
||||
)
|
||||
RETURNING id INTO v_log_id;
|
||||
|
||||
RETURN v_log_id;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ============================================================
|
||||
-- log_user_activity — SECURITY DEFINER, bypasses RLS
|
||||
-- Tracks per-user activities: logins, password changes, profile updates.
|
||||
-- Payload: { user_id, activity_type, details, ip_address, user_agent }
|
||||
-- ============================================================
|
||||
CREATE OR REPLACE FUNCTION log_user_activity(p_payload JSONB)
|
||||
RETURNS UUID
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_log_id UUID;
|
||||
BEGIN
|
||||
INSERT INTO user_activity_logs (
|
||||
user_id, activity_type, details, ip_address, user_agent
|
||||
) VALUES (
|
||||
CASE WHEN (p_payload->>'user_id') IS NULL THEN NULL ELSE (p_payload->>'user_id')::UUID END,
|
||||
p_payload->>'activity_type',
|
||||
COALESCE(p_payload->'details', '{}'::JSONB),
|
||||
p_payload->>'ip_address',
|
||||
p_payload->>'user_agent'
|
||||
)
|
||||
RETURNING id INTO v_log_id;
|
||||
|
||||
RETURN v_log_id;
|
||||
END;
|
||||
$$;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
-- Migration: 036_user_activity_logs
|
||||
-- Tracks per-user activities: logins, password changes, profile updates
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_activity_logs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL,
|
||||
activity_type TEXT NOT NULL, -- 'login' | 'logout' | 'password_change' | 'profile_update' | 'email_change'
|
||||
details JSONB DEFAULT '{}',
|
||||
ip_address TEXT,
|
||||
user_agent TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Index for fast per-user log lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_user_activity_logs_user_id ON user_activity_logs(user_id);
|
||||
-- Index for time-based queries per user
|
||||
CREATE INDEX IF NOT EXISTS idx_user_activity_logs_created_at ON user_activity_logs(created_at DESC);
|
||||
|
||||
-- RLS: user can read own logs; platform_admin can read all
|
||||
ALTER TABLE user_activity_logs ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "user_read_own_activity_logs" ON user_activity_logs
|
||||
FOR SELECT USING (auth.uid() = user_id);
|
||||
|
||||
CREATE POLICY "platform_admin_read_all_user_activity_logs" ON user_activity_logs
|
||||
FOR SELECT USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM admin_users au
|
||||
WHERE au.user_id = auth.uid()
|
||||
AND au.role = 'platform_admin'
|
||||
)
|
||||
);
|
||||
|
||||
-- Any authenticated user can insert their own log entries
|
||||
CREATE POLICY "user_insert_own_activity_logs" ON user_activity_logs
|
||||
FOR INSERT WITH CHECK (auth.uid() = user_id);
|
||||
@@ -0,0 +1,334 @@
|
||||
-- Migration: 037_update_admin_user_profile
|
||||
-- Extends update_admin_user to accept display_name and phone_number.
|
||||
-- Updates admin_users and syncs auth.users raw_user_meta_data.
|
||||
-- After update, writes to admin_action_logs.
|
||||
|
||||
DROP FUNCTION IF EXISTS update_admin_user(UUID, TEXT, UUID, JSONB, BOOLEAN, TEXT, TEXT);
|
||||
CREATE OR REPLACE FUNCTION update_admin_user(
|
||||
p_id UUID,
|
||||
p_role TEXT DEFAULT NULL,
|
||||
p_brand_id UUID DEFAULT NULL,
|
||||
p_flags JSONB DEFAULT NULL,
|
||||
p_active BOOLEAN DEFAULT NULL,
|
||||
p_display_name TEXT DEFAULT NULL,
|
||||
p_phone_number TEXT DEFAULT NULL
|
||||
)
|
||||
RETURNS TABLE (
|
||||
id UUID,
|
||||
user_id UUID,
|
||||
display_name TEXT,
|
||||
email TEXT,
|
||||
role TEXT,
|
||||
brand_id UUID,
|
||||
active BOOLEAN,
|
||||
phone_number TEXT,
|
||||
updated_at TIMESTAMPTZ
|
||||
)
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_caller_role TEXT;
|
||||
v_caller_brand_id UUID;
|
||||
v_caller_can_manage_users BOOLEAN;
|
||||
v_target_role TEXT;
|
||||
v_target_brand_id UUID;
|
||||
v_target_user_id UUID;
|
||||
v_admin_email TEXT;
|
||||
BEGIN
|
||||
IF auth.uid() IS NULL THEN
|
||||
RAISE EXCEPTION 'Not authenticated';
|
||||
END IF;
|
||||
|
||||
SELECT role, brand_id, can_manage_users
|
||||
INTO v_caller_role, v_caller_brand_id, v_caller_can_manage_users
|
||||
FROM admin_users
|
||||
WHERE user_id = auth.uid();
|
||||
|
||||
IF v_caller_role IS NULL THEN
|
||||
RAISE EXCEPTION 'Not an admin';
|
||||
END IF;
|
||||
|
||||
-- Cannot update your own account's role
|
||||
IF EXISTS (SELECT 1 FROM admin_users WHERE id = p_id AND user_id = auth.uid()) THEN
|
||||
RAISE EXCEPTION 'Cannot update your own admin account';
|
||||
END IF;
|
||||
|
||||
-- Look up target
|
||||
SELECT role, brand_id, user_id INTO v_target_role, v_target_brand_id, v_target_user_id
|
||||
FROM admin_users WHERE id = p_id;
|
||||
|
||||
IF v_target_role IS NULL THEN
|
||||
RAISE EXCEPTION 'Admin user not found';
|
||||
END IF;
|
||||
|
||||
-- Security: brand_admin cannot demote/promote platform_admin
|
||||
IF v_caller_role = 'brand_admin' AND v_target_role = 'platform_admin' AND p_role IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'Insufficient permissions to modify a platform_admin';
|
||||
END IF;
|
||||
|
||||
-- Security: brand_admin cannot give platform_admin role
|
||||
IF v_caller_role = 'brand_admin' AND p_role = 'platform_admin' THEN
|
||||
RAISE EXCEPTION 'Insufficient permissions to create a platform_admin';
|
||||
END IF;
|
||||
|
||||
-- Security: brand_admin can only affect users in their brand
|
||||
IF v_caller_role = 'brand_admin' THEN
|
||||
IF v_target_brand_id != v_caller_brand_id THEN
|
||||
RAISE EXCEPTION 'Insufficient permissions to modify a user from another brand';
|
||||
END IF;
|
||||
IF p_brand_id IS NOT NULL AND p_brand_id != v_caller_brand_id THEN
|
||||
RAISE EXCEPTION 'Insufficient permissions to assign a user to another brand';
|
||||
END IF;
|
||||
IF p_role = 'platform_admin' THEN
|
||||
RAISE EXCEPTION 'Insufficient permissions to create a platform_admin';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- Get caller email for audit log
|
||||
SELECT email INTO v_admin_email FROM auth.users WHERE id = auth.uid();
|
||||
|
||||
-- Apply updates to admin_users
|
||||
UPDATE admin_users SET
|
||||
role = COALESCE(p_role, role),
|
||||
brand_id = COALESCE(p_brand_id, brand_id),
|
||||
can_manage_products = COALESCE((p_flags->>'can_manage_products')::BOOLEAN, can_manage_products),
|
||||
can_manage_stops = COALESCE((p_flags->>'can_manage_stops')::BOOLEAN, can_manage_stops),
|
||||
can_manage_orders = COALESCE((p_flags->>'can_manage_orders')::BOOLEAN, can_manage_orders),
|
||||
can_manage_pickup = COALESCE((p_flags->>'can_manage_pickup')::BOOLEAN, can_manage_pickup),
|
||||
can_manage_messages = COALESCE((p_flags->>'can_manage_messages')::BOOLEAN, can_manage_messages),
|
||||
can_manage_refunds = COALESCE((p_flags->>'can_manage_refunds')::BOOLEAN, can_manage_refunds),
|
||||
can_manage_users = COALESCE((p_flags->>'can_manage_users')::BOOLEAN, can_manage_users),
|
||||
can_manage_water_log = COALESCE((p_flags->>'can_manage_water_log')::BOOLEAN, can_manage_water_log),
|
||||
can_manage_reports = COALESCE((p_flags->>'can_manage_reports')::BOOLEAN, can_manage_reports),
|
||||
active = COALESCE(p_active, active),
|
||||
display_name = COALESCE(p_display_name, display_name),
|
||||
phone_number = COALESCE(p_phone_number, phone_number)
|
||||
WHERE id = p_id;
|
||||
|
||||
-- Sync display_name / phone_number to auth.users raw_user_meta_data
|
||||
IF p_display_name IS NOT NULL OR p_phone_number IS NOT NULL THEN
|
||||
UPDATE auth.users SET
|
||||
raw_user_meta_data = jsonb_strip_nulls(
|
||||
jsonb_set(
|
||||
COALESCE(raw_user_meta_data, '{}'::jsonb),
|
||||
'{display_name}',
|
||||
to_jsonb(p_display_name)
|
||||
) ||
|
||||
jsonb_set(
|
||||
COALESCE(raw_user_meta_data, '{}'::jsonb),
|
||||
'{phone_number}',
|
||||
to_jsonb(p_phone_number)
|
||||
)
|
||||
)
|
||||
WHERE id = v_target_user_id;
|
||||
END IF;
|
||||
|
||||
-- Audit log: record the action
|
||||
PERFORM log_admin_action(jsonb_build_object(
|
||||
'action_type', 'update',
|
||||
'admin_id', auth.uid(),
|
||||
'admin_email', v_admin_email,
|
||||
'affected_user_id', v_target_user_id,
|
||||
'brand_id', COALESCE(p_brand_id, v_target_brand_id),
|
||||
'details', jsonb_build_object(
|
||||
'changed_fields', ARRAY_REMOVE(ARRAY[
|
||||
CASE WHEN p_role IS NOT NULL THEN 'role' ELSE NULL END,
|
||||
CASE WHEN p_brand_id IS NOT NULL THEN 'brand_id' ELSE NULL END,
|
||||
CASE WHEN p_flags IS NOT NULL THEN 'flags' ELSE NULL END,
|
||||
CASE WHEN p_active IS NOT NULL THEN 'active' ELSE NULL END,
|
||||
CASE WHEN p_display_name IS NOT NULL THEN 'display_name' ELSE NULL END,
|
||||
CASE WHEN p_phone_number IS NOT NULL THEN 'phone_number' ELSE NULL END
|
||||
], NULL)
|
||||
)
|
||||
));
|
||||
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
au.id, au.user_id,
|
||||
COALESCE(
|
||||
(au.raw_user_meta_data->>'display_name')::TEXT,
|
||||
u.email
|
||||
) AS display_name,
|
||||
u.email, au.role::TEXT, au.brand_id, au.active,
|
||||
au.phone_number, NOW() AS updated_at
|
||||
FROM admin_users au
|
||||
JOIN auth.users u ON au.user_id = u.id
|
||||
WHERE au.id = p_id;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Update create_admin_user to also log
|
||||
CREATE OR REPLACE FUNCTION create_admin_user(
|
||||
p_email TEXT,
|
||||
p_role TEXT,
|
||||
p_brand_id UUID DEFAULT NULL,
|
||||
p_flags JSONB DEFAULT '{}'::JSONB
|
||||
)
|
||||
RETURNS TABLE (
|
||||
id UUID,
|
||||
user_id UUID,
|
||||
display_name TEXT,
|
||||
email TEXT,
|
||||
role TEXT,
|
||||
brand_id UUID,
|
||||
active BOOLEAN,
|
||||
created_at TIMESTAMPTZ
|
||||
)
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_caller_role TEXT;
|
||||
v_caller_brand_id UUID;
|
||||
v_caller_can_manage_users BOOLEAN;
|
||||
v_target_user_id UUID;
|
||||
v_new_id UUID;
|
||||
v_admin_email TEXT;
|
||||
BEGIN
|
||||
IF auth.uid() IS NULL THEN
|
||||
RAISE EXCEPTION 'Not authenticated';
|
||||
END IF;
|
||||
|
||||
SELECT role, brand_id, can_manage_users
|
||||
INTO v_caller_role, v_caller_brand_id, v_caller_can_manage_users
|
||||
FROM admin_users
|
||||
WHERE user_id = auth.uid();
|
||||
|
||||
IF v_caller_role IS NULL THEN
|
||||
RAISE EXCEPTION 'Not an admin';
|
||||
END IF;
|
||||
|
||||
IF v_caller_role = 'brand_admin' AND p_role = 'platform_admin' THEN
|
||||
RAISE EXCEPTION 'Insufficient permissions to create a platform_admin';
|
||||
END IF;
|
||||
|
||||
IF v_caller_role = 'brand_admin' THEN
|
||||
IF p_brand_id IS DISTINCT FROM v_caller_brand_id THEN
|
||||
RAISE EXCEPTION 'Insufficient permissions to create a user for another brand';
|
||||
END IF;
|
||||
IF p_brand_id IS NULL AND p_role = 'platform_admin' THEN
|
||||
RAISE EXCEPTION 'brand_admin cannot create platform_admin';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
BEGIN
|
||||
SELECT id INTO v_target_user_id FROM auth.users WHERE email = p_email;
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RAISE EXCEPTION 'Auth user with email % not found', p_email;
|
||||
END;
|
||||
|
||||
IF v_target_user_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Auth user with email % not found', p_email;
|
||||
END IF;
|
||||
|
||||
IF EXISTS (SELECT 1 FROM admin_users WHERE user_id = v_target_user_id) THEN
|
||||
RAISE EXCEPTION 'Admin user with email % already exists', p_email;
|
||||
END IF;
|
||||
|
||||
-- Get caller email for audit log
|
||||
SELECT email INTO v_admin_email FROM auth.users WHERE id = auth.uid();
|
||||
|
||||
INSERT INTO admin_users (
|
||||
user_id, role, brand_id,
|
||||
can_manage_products, can_manage_stops, can_manage_orders, can_manage_pickup,
|
||||
can_manage_messages, can_manage_refunds, can_manage_users, can_manage_water_log,
|
||||
can_manage_reports, active
|
||||
) VALUES (
|
||||
v_target_user_id, p_role, p_brand_id,
|
||||
COALESCE((p_flags->>'can_manage_products')::BOOLEAN, false),
|
||||
COALESCE((p_flags->>'can_manage_stops')::BOOLEAN, false),
|
||||
COALESCE((p_flags->>'can_manage_orders')::BOOLEAN, false),
|
||||
COALESCE((p_flags->>'can_manage_pickup')::BOOLEAN, false),
|
||||
COALESCE((p_flags->>'can_manage_messages')::BOOLEAN, false),
|
||||
COALESCE((p_flags->>'can_manage_refunds')::BOOLEAN, false),
|
||||
COALESCE((p_flags->>'can_manage_users')::BOOLEAN, false),
|
||||
COALESCE((p_flags->>'can_manage_water_log')::BOOLEAN, false),
|
||||
COALESCE((p_flags->>'can_manage_reports')::BOOLEAN, false),
|
||||
true
|
||||
)
|
||||
RETURNING id INTO v_new_id;
|
||||
|
||||
-- Audit log: record the creation
|
||||
PERFORM log_admin_action(jsonb_build_object(
|
||||
'action_type', 'create',
|
||||
'admin_id', auth.uid(),
|
||||
'admin_email', v_admin_email,
|
||||
'affected_user_id', v_target_user_id,
|
||||
'brand_id', p_brand_id,
|
||||
'details', jsonb_build_object('new_role', p_role)
|
||||
));
|
||||
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
au.id, au.user_id,
|
||||
COALESCE(
|
||||
(au.raw_user_meta_data->>'display_name')::TEXT,
|
||||
(au.raw_user_meta_data->>'full_name')::TEXT,
|
||||
u.email
|
||||
) AS display_name,
|
||||
u.email, au.role::TEXT, au.brand_id, au.active, au.created_at
|
||||
FROM admin_users au
|
||||
JOIN auth.users u ON au.user_id = u.id
|
||||
WHERE au.id = v_new_id;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Update delete_admin_user to also log
|
||||
CREATE OR REPLACE FUNCTION delete_admin_user(p_id UUID)
|
||||
RETURNS BOOLEAN
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_caller_role TEXT;
|
||||
v_target_user_id UUID;
|
||||
v_admin_email TEXT;
|
||||
v_target_brand_id UUID;
|
||||
BEGIN
|
||||
IF auth.uid() IS NULL THEN
|
||||
RAISE EXCEPTION 'Not authenticated';
|
||||
END IF;
|
||||
|
||||
SELECT role INTO v_caller_role FROM admin_users WHERE user_id = auth.uid();
|
||||
IF v_caller_role IS NULL THEN
|
||||
RAISE EXCEPTION 'Not an admin';
|
||||
END IF;
|
||||
|
||||
SELECT user_id, brand_id INTO v_target_user_id, v_target_brand_id
|
||||
FROM admin_users WHERE id = p_id;
|
||||
|
||||
SELECT email INTO v_admin_email FROM auth.users WHERE id = auth.uid();
|
||||
|
||||
IF v_target_user_id = auth.uid() THEN
|
||||
RAISE EXCEPTION 'Cannot delete your own admin account';
|
||||
END IF;
|
||||
|
||||
IF v_caller_role = 'brand_admin' THEN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM admin_users
|
||||
WHERE id = p_id AND brand_id = (SELECT brand_id FROM admin_users WHERE user_id = auth.uid())
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Insufficient permissions to delete this user';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- Audit log: record the deletion
|
||||
PERFORM log_admin_action(jsonb_build_object(
|
||||
'action_type', 'delete',
|
||||
'admin_id', auth.uid(),
|
||||
'admin_email', v_admin_email,
|
||||
'affected_user_id', v_target_user_id,
|
||||
'brand_id', v_target_brand_id,
|
||||
'details', jsonb_build_object('deleted_admin_id', p_id)
|
||||
));
|
||||
|
||||
DELETE FROM admin_users WHERE id = p_id;
|
||||
RETURN true;
|
||||
END;
|
||||
$$;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,31 @@
|
||||
-- Migration 038: Product Images + Public Site Polish
|
||||
-- Idempotent: ADD COLUMN IF NOT EXISTS, CREATE OR REPLACE FUNCTION
|
||||
|
||||
-- ── 1. Add image_url to products ───────────────────────────────────────────────
|
||||
ALTER TABLE products ADD COLUMN IF NOT EXISTS image_url TEXT;
|
||||
|
||||
-- ── 2. Update get_stop_products to include image_url ──────────────────────────
|
||||
CREATE OR REPLACE FUNCTION public.get_stop_products(p_stop_id UUID)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN jsonb_build_object('products', (
|
||||
SELECT COALESCE(jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', ps.id,
|
||||
'product_id', ps.product_id,
|
||||
'name', p.name,
|
||||
'type', p.type,
|
||||
'price', p.price,
|
||||
'image_url', p.image_url
|
||||
)
|
||||
), '[]'::JSONB)
|
||||
FROM product_stops ps
|
||||
JOIN products p ON p.id = ps.product_id
|
||||
WHERE ps.stop_id = p_stop_id
|
||||
));
|
||||
END;
|
||||
$$;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Migration 039: Shipping Fulfillment Fields
|
||||
-- Idempotent: ADD COLUMN IF NOT EXISTS
|
||||
|
||||
ALTER TABLE orders ADD COLUMN IF NOT EXISTS shipping_status TEXT NOT NULL DEFAULT 'pending';
|
||||
ALTER TABLE orders ADD COLUMN IF NOT EXISTS tracking_number TEXT;
|
||||
|
||||
COMMENT ON COLUMN orders.shipping_status IS 'pending | label_created | shipped | delivered | returned';
|
||||
COMMENT ON COLUMN orders.tracking_number IS 'Carrier tracking number';
|
||||
@@ -0,0 +1,86 @@
|
||||
-- Migration 040: Shipping Fulfillment RPCs
|
||||
-- Idempotent: CREATE OR REPLACE FUNCTION
|
||||
|
||||
-- ── 1. update_shipping_order ──────────────────────────────────────────────────
|
||||
-- Updates shipping_status and tracking_number for an order.
|
||||
-- Requires can_manage_orders permission (checked in server action via getAdminUser).
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.update_shipping_order(
|
||||
p_order_id UUID,
|
||||
p_shipping_status TEXT,
|
||||
p_tracking_number TEXT DEFAULT NULL
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
UPDATE orders SET
|
||||
shipping_status = p_shipping_status,
|
||||
tracking_number = p_tracking_number,
|
||||
updated_at = now()
|
||||
WHERE id = p_order_id;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Order not found');
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object('success', true);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── 2. get_shipping_orders ───────────────────────────────────────────────────
|
||||
-- Returns orders that contain at least one shipping-line item.
|
||||
-- Filtered by brand_id when p_brand_id is provided.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.get_shipping_orders(p_brand_id UUID DEFAULT NULL)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_result JSONB;
|
||||
BEGIN
|
||||
SELECT jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', o.id,
|
||||
'customer_name', o.customer_name,
|
||||
'customer_email', o.customer_email,
|
||||
'customer_phone', o.customer_phone,
|
||||
'status', o.status,
|
||||
'subtotal', o.subtotal,
|
||||
'shipping_status', o.shipping_status,
|
||||
'tracking_number', o.tracking_number,
|
||||
'created_at', o.created_at,
|
||||
'brand_id', o.brand_id,
|
||||
'order_items', COALESCE((
|
||||
SELECT jsonb_agg(jsonb_build_object(
|
||||
'id', oi.id,
|
||||
'product_id', oi.product_id,
|
||||
'quantity', oi.quantity,
|
||||
'price', oi.price,
|
||||
'fulfillment', oi.fulfillment,
|
||||
'products', jsonb_build_object('name', p.name)
|
||||
))
|
||||
FROM order_items oi
|
||||
JOIN products p ON oi.product_id = p.id
|
||||
WHERE oi.order_id = o.id AND oi.fulfillment = 'shipping'
|
||||
), '[]'::JSONB)
|
||||
)
|
||||
)
|
||||
INTO v_result
|
||||
FROM orders o
|
||||
WHERE o.id IN (
|
||||
SELECT DISTINCT oi.order_id
|
||||
FROM order_items oi
|
||||
WHERE oi.fulfillment = 'shipping'
|
||||
)
|
||||
AND (
|
||||
p_brand_id IS NULL
|
||||
OR o.brand_id = p_brand_id
|
||||
)
|
||||
ORDER BY o.created_at DESC;
|
||||
|
||||
RETURN COALESCE(v_result, '[]'::JSONB);
|
||||
END;
|
||||
$$;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,105 @@
|
||||
-- Migration 041: Payment Settings
|
||||
-- Creates payment_settings table and RPCs for provider configuration.
|
||||
|
||||
-- ── 1. payment_settings table ─────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS public.payment_settings (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
|
||||
provider TEXT, -- stripe | square | manual
|
||||
stripe_publishable_key TEXT,
|
||||
stripe_secret_key TEXT,
|
||||
square_access_token TEXT,
|
||||
square_location_id TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE(brand_id)
|
||||
);
|
||||
|
||||
-- ── 2. RLS ──────────────────────────────────────────────────────────────────
|
||||
ALTER TABLE public.payment_settings ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS "Brand admin can read payment_settings" ON public.payment_settings;
|
||||
CREATE POLICY "Brand admin can read payment_settings"
|
||||
ON public.payment_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 payment_settings" ON public.payment_settings;
|
||||
CREATE POLICY "Platform admin can read payment_settings"
|
||||
ON public.payment_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'
|
||||
)
|
||||
);
|
||||
|
||||
-- Writes go through SECURITY DEFINER helpers only.
|
||||
|
||||
-- ── 3. get_payment_settings ──────────────────────────────────────────────────
|
||||
CREATE OR REPLACE FUNCTION public.get_payment_settings(p_brand_id UUID)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_result JSONB;
|
||||
BEGIN
|
||||
SELECT jsonb_build_object(
|
||||
'id', id,
|
||||
'brand_id', brand_id,
|
||||
'provider', provider,
|
||||
'stripe_publishable_key', stripe_publishable_key,
|
||||
'stripe_secret_key', stripe_secret_key,
|
||||
'square_access_token', square_access_token,
|
||||
'square_location_id', square_location_id,
|
||||
'updated_at', updated_at::TEXT
|
||||
) INTO v_result
|
||||
FROM payment_settings
|
||||
WHERE brand_id = p_brand_id;
|
||||
|
||||
RETURN v_result;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── 4. upsert_payment_settings ────────────────────────────────────────────────
|
||||
CREATE OR REPLACE FUNCTION public.upsert_payment_settings(
|
||||
p_brand_id UUID,
|
||||
p_provider TEXT,
|
||||
p_stripe_publishable_key TEXT DEFAULT NULL,
|
||||
p_stripe_secret_key TEXT DEFAULT NULL,
|
||||
p_square_access_token TEXT DEFAULT NULL,
|
||||
p_square_location_id TEXT DEFAULT NULL
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
INSERT INTO payment_settings (
|
||||
brand_id, provider,
|
||||
stripe_publishable_key, stripe_secret_key,
|
||||
square_access_token, square_location_id
|
||||
)
|
||||
VALUES (
|
||||
p_brand_id, p_provider,
|
||||
p_stripe_publishable_key, p_stripe_secret_key,
|
||||
p_square_access_token, p_square_location_id
|
||||
)
|
||||
ON CONFLICT (brand_id) DO UPDATE SET
|
||||
provider = EXCLUDED.provider,
|
||||
stripe_publishable_key = EXCLUDED.stripe_publishable_key,
|
||||
stripe_secret_key = EXCLUDED.stripe_secret_key,
|
||||
square_access_token = EXCLUDED.square_access_token,
|
||||
square_location_id = EXCLUDED.square_location_id,
|
||||
updated_at = now();
|
||||
|
||||
RETURN jsonb_build_object('success', true);
|
||||
END;
|
||||
$$;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,130 @@
|
||||
-- Migration 042: Product Import RPC + upsert_product
|
||||
-- Idempotent: CREATE OR REPLACE FUNCTION
|
||||
|
||||
-- ── 1. upsert_product ─────────────────────────────────────────────────────────
|
||||
-- Upserts a single product by name (within brand). Returns the product id.
|
||||
-- Used by the CSV import tool to create or update products in bulk.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.upsert_product(
|
||||
p_brand_id UUID,
|
||||
p_name TEXT,
|
||||
p_price NUMERIC,
|
||||
p_type TEXT,
|
||||
p_description TEXT DEFAULT '',
|
||||
p_active BOOLEAN DEFAULT true,
|
||||
p_image_url TEXT DEFAULT NULL
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_product_id UUID;
|
||||
v_is_update BOOLEAN := false;
|
||||
BEGIN
|
||||
-- Check if product with same name exists in this brand
|
||||
SELECT id INTO v_product_id
|
||||
FROM products
|
||||
WHERE brand_id = p_brand_id AND name = p_name
|
||||
LIMIT 1;
|
||||
|
||||
IF v_product_id IS NOT NULL THEN
|
||||
-- Update existing
|
||||
UPDATE products SET
|
||||
name = p_name,
|
||||
description = p_description,
|
||||
price = p_price,
|
||||
type = p_type,
|
||||
active = p_active,
|
||||
image_url = COALESCE(p_image_url, image_url),
|
||||
updated_at = now()
|
||||
WHERE id = v_product_id
|
||||
RETURNING id INTO v_product_id;
|
||||
v_is_update := true;
|
||||
ELSE
|
||||
-- Insert new
|
||||
INSERT INTO products (brand_id, name, description, price, type, active, image_url)
|
||||
VALUES (p_brand_id, p_name, p_description, p_price, p_type, p_active, p_image_url)
|
||||
RETURNING id INTO v_product_id;
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'id', v_product_id,
|
||||
'is_update', v_is_update,
|
||||
'name', p_name
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── 2. bulk_upsert_products ──────────────────────────────────────────────────
|
||||
-- Takes an array of product records and upserts them all within a brand.
|
||||
-- Returns a summary: { created, updated, errors }.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.bulk_upsert_products(
|
||||
p_brand_id UUID,
|
||||
p_products JSONB -- array of { name, description, price, type, active, image_url }
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_entry JSONB;
|
||||
v_result JSONB := '{"created": 0, "updated": 0, "errors": []}'::JSONB;
|
||||
v_name TEXT;
|
||||
v_desc TEXT;
|
||||
v_price NUMERIC;
|
||||
v_type TEXT;
|
||||
v_active BOOLEAN;
|
||||
v_img TEXT;
|
||||
v_was_new BOOLEAN;
|
||||
BEGIN
|
||||
FOR v_entry IN SELECT * FROM jsonb_array_elements(p_products)
|
||||
LOOP
|
||||
BEGIN
|
||||
v_name := nullif(v_entry->>'name', '');
|
||||
v_desc := coalesce(nullif(v_entry->>'description', ''), '');
|
||||
v_price := nullif((v_entry->>'price')::TEXT, '')::NUMERIC;
|
||||
v_type := nullif(v_entry->>'type', '');
|
||||
v_active := coalesce((v_entry->>'active')::BOOLEAN, true);
|
||||
v_img := nullif(v_entry->>'image_url', '');
|
||||
|
||||
IF v_name IS NULL OR v_name = '' THEN
|
||||
RAISE EXCEPTION 'Product name is required';
|
||||
END IF;
|
||||
|
||||
IF v_price IS NULL OR v_price < 0 THEN
|
||||
RAISE EXCEPTION 'Invalid price for product: %', v_name;
|
||||
END IF;
|
||||
|
||||
IF v_type NOT IN ('Pickup', 'Shipping', 'Pickup & Shipping') THEN
|
||||
RAISE EXCEPTION 'Invalid type for product: %. Must be Pickup, Shipping, or Pickup & Shipping', v_name;
|
||||
END IF;
|
||||
|
||||
v_was_new := NOT EXISTS (
|
||||
SELECT 1 FROM products WHERE brand_id = p_brand_id AND name = v_name
|
||||
);
|
||||
|
||||
PERFORM upsert_product(p_brand_id, v_name, v_desc, v_price, v_type, v_active, v_img);
|
||||
|
||||
IF v_was_new THEN
|
||||
v_result := jsonb_set(v_result, '{created}',
|
||||
to_jsonb((v_result->>'created')::INTEGER + 1));
|
||||
ELSE
|
||||
v_result := jsonb_set(v_result, '{updated}',
|
||||
to_jsonb((v_result->>'updated')::INTEGER + 1));
|
||||
END IF;
|
||||
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
v_result := jsonb_set(
|
||||
v_result, '{errors}',
|
||||
v_result->'errors' || jsonb_build_array(
|
||||
jsonb_build_object('product', v_name, 'error', SQLERRM)
|
||||
)
|
||||
);
|
||||
END;
|
||||
END LOOP;
|
||||
|
||||
RETURN v_result;
|
||||
END;
|
||||
$$;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Migration 043: Square Sync Settings
|
||||
-- Adds Square Sync configuration columns to payment_settings
|
||||
|
||||
ALTER TABLE payment_settings ADD COLUMN IF NOT EXISTS square_sync_enabled BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE payment_settings ADD COLUMN IF NOT EXISTS square_inventory_mode TEXT NOT NULL DEFAULT 'none';
|
||||
-- Values: 'none' | 'rc_to_square' | 'square_to_rc' | 'bidirectional'
|
||||
ALTER TABLE payment_settings ADD COLUMN IF NOT EXISTS square_last_sync_at TIMESTAMPTZ;
|
||||
ALTER TABLE payment_settings ADD COLUMN IF NOT EXISTS square_last_sync_error TEXT;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,63 @@
|
||||
-- Migration 044: Square Sync Log
|
||||
-- Append-only log of all Square sync events
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.square_sync_log (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
|
||||
event_type TEXT NOT NULL,
|
||||
-- 'oauth_connected' | 'oauth_disconnected' | 'product_synced' | 'order_synced' | 'inventory_synced' | 'error'
|
||||
direction TEXT,
|
||||
-- 'rc_to_square' | 'square_to_rc'
|
||||
entity_type TEXT,
|
||||
-- 'product' | 'order' | 'inventory'
|
||||
entity_id UUID,
|
||||
status TEXT NOT NULL,
|
||||
-- 'success' | 'error' | 'partial'
|
||||
message TEXT,
|
||||
details JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_square_sync_log_brand_created
|
||||
ON square_sync_log(brand_id, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_square_sync_log_event_type
|
||||
ON square_sync_log(event_type, created_at DESC);
|
||||
|
||||
-- SECURITY DEFINER function to record sync log entries
|
||||
-- Bypasses RLS since operational events use the same pattern
|
||||
CREATE OR REPLACE FUNCTION public.record_square_sync_event(
|
||||
p_brand_id UUID,
|
||||
p_event_type TEXT,
|
||||
p_direction TEXT,
|
||||
p_entity_type TEXT,
|
||||
p_entity_id UUID,
|
||||
p_status TEXT,
|
||||
p_message TEXT,
|
||||
p_details JSONB DEFAULT '{}'::JSONB
|
||||
)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
INSERT INTO square_sync_log (
|
||||
brand_id, event_type, direction, entity_type, entity_id, status, message, details
|
||||
) VALUES (
|
||||
p_brand_id, p_event_type, p_direction, p_entity_type, p_entity_id, p_status, p_message, p_details
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- RLS: brands can read their own sync logs; platform_admin can read all
|
||||
ALTER TABLE square_sync_log ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "brand_admin_read_own_sync_log" ON square_sync_log
|
||||
FOR SELECT USING (
|
||||
(current_setting('app.settings.role', true)::TEXT = 'brand_admin' AND brand_id = current_setting('app.settings.brand_id', true)::UUID)
|
||||
OR current_setting('app.settings.role', true)::TEXT = 'platform_admin'
|
||||
);
|
||||
|
||||
CREATE POLICY "authenticated_insert_sync_log" ON square_sync_log
|
||||
FOR INSERT WITH CHECK (auth.uid() IS NOT NULL); -- SECURITY DEFINER RPCs run as table owner; authenticated users may also append
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,53 @@
|
||||
-- Migration 045: Extend payment_settings RPCs for Square Sync
|
||||
-- Adds square_sync_enabled, square_inventory_mode, square_last_sync_at, square_last_sync_error
|
||||
-- to upsert_payment_settings and get_payment_settings
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.upsert_payment_settings(
|
||||
p_brand_id UUID,
|
||||
p_provider TEXT,
|
||||
p_stripe_publishable_key TEXT DEFAULT NULL,
|
||||
p_stripe_secret_key TEXT DEFAULT NULL,
|
||||
p_square_access_token TEXT DEFAULT NULL,
|
||||
p_square_location_id TEXT DEFAULT NULL,
|
||||
p_square_sync_enabled BOOLEAN DEFAULT NULL,
|
||||
p_square_inventory_mode TEXT DEFAULT NULL
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
INSERT INTO payment_settings (
|
||||
brand_id, provider,
|
||||
stripe_publishable_key, stripe_secret_key,
|
||||
square_access_token, square_location_id,
|
||||
square_sync_enabled, square_inventory_mode
|
||||
)
|
||||
VALUES (
|
||||
p_brand_id, p_provider,
|
||||
p_stripe_publishable_key, p_stripe_secret_key,
|
||||
p_square_access_token, p_square_location_id,
|
||||
COALESCE(p_square_sync_enabled, false),
|
||||
COALESCE(p_square_inventory_mode, 'none')
|
||||
)
|
||||
ON CONFLICT (brand_id) DO UPDATE SET
|
||||
provider = COALESCE(p_provider, payment_settings.provider),
|
||||
stripe_publishable_key = COALESCE(p_stripe_publishable_key, payment_settings.stripe_publishable_key),
|
||||
stripe_secret_key = COALESCE(p_stripe_secret_key, payment_settings.stripe_secret_key),
|
||||
square_access_token = COALESCE(p_square_access_token, payment_settings.square_access_token),
|
||||
square_location_id = COALESCE(p_square_location_id, payment_settings.square_location_id),
|
||||
square_sync_enabled = COALESCE(p_square_sync_enabled, payment_settings.square_sync_enabled),
|
||||
square_inventory_mode = COALESCE(p_square_inventory_mode, payment_settings.square_inventory_mode),
|
||||
updated_at = now()
|
||||
RETURNING jsonb_build_object(
|
||||
'id', id,
|
||||
'brand_id', brand_id,
|
||||
'provider', provider,
|
||||
'square_sync_enabled', square_sync_enabled,
|
||||
'square_inventory_mode', square_inventory_mode,
|
||||
'square_last_sync_at', square_last_sync_at,
|
||||
'square_last_sync_error', square_last_sync_error
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,748 @@
|
||||
-- Migration 046: Wholesale Portal Tables
|
||||
-- Core wholesale ordering portal for Route Commerce.
|
||||
-- Idempotent — all statements use IF NOT EXISTS / CREATE OR REPLACE.
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ── 1. Wholesale Customers ─────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS public.wholesale_customers (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID, -- FK added after auth.users exists
|
||||
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
|
||||
company_name TEXT,
|
||||
contact_name TEXT,
|
||||
email TEXT,
|
||||
phone TEXT,
|
||||
billing_address TEXT,
|
||||
shipping_address TEXT,
|
||||
account_status TEXT NOT NULL DEFAULT 'active',
|
||||
credit_limit NUMERIC(10,2) NOT NULL DEFAULT 0,
|
||||
deposits_enabled BOOLEAN NOT NULL DEFAULT false,
|
||||
deposit_threshold NUMERIC(10,2),
|
||||
deposit_percentage INTEGER CHECK (deposit_percentage IS NULL OR deposit_percentage BETWEEN 1 AND 100),
|
||||
order_email TEXT,
|
||||
invoice_email TEXT,
|
||||
admin_notes TEXT,
|
||||
role TEXT NOT NULL DEFAULT 'buyer',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Add the FK after the table exists. Use DO block so it's idempotent.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'wholesale_customers_user_id_fkey'
|
||||
) THEN
|
||||
ALTER TABLE public.wholesale_customers
|
||||
ADD CONSTRAINT wholesale_customers_user_id_fkey
|
||||
FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE SET NULL
|
||||
NOT VALID;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Individual unique constraint on user_id (allows FK reference from other tables)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'wholesale_customers_user_id_unique'
|
||||
) THEN
|
||||
ALTER TABLE public.wholesale_customers
|
||||
ADD CONSTRAINT wholesale_customers_user_id_unique
|
||||
UNIQUE (user_id);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Composite unique: one user + one brand (still useful for the upsert ON CONFLICT)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'wholesale_customers_brand_user_unique'
|
||||
) THEN
|
||||
ALTER TABLE public.wholesale_customers
|
||||
ADD CONSTRAINT wholesale_customers_brand_user_unique
|
||||
UNIQUE (brand_id, user_id);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- RLS
|
||||
ALTER TABLE public.wholesale_customers ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS "brand_admin_manage_wholesale_customers" ON wholesale_customers;
|
||||
CREATE POLICY "brand_admin_manage_wholesale_customers" ON wholesale_customers
|
||||
FOR ALL USING (
|
||||
current_setting('app.settings.role', true)::TEXT IN ('brand_admin', 'platform_admin')
|
||||
AND (
|
||||
current_setting('app.settings.role', true)::TEXT = 'platform_admin'
|
||||
OR brand_id = current_setting('app.settings.brand_id', true)::UUID
|
||||
)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS "wholesale_customer_read_own" ON wholesale_customers;
|
||||
CREATE POLICY "wholesale_customer_read_own" ON wholesale_customers
|
||||
FOR SELECT USING (
|
||||
current_setting('app.settings.role', true)::TEXT = 'wholesale_customer'
|
||||
AND user_id = current_setting('app.settings.user_id', true)::UUID
|
||||
);
|
||||
|
||||
-- ── 2. Wholesale Products ───────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS public.wholesale_products (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
|
||||
rc_product_id UUID REFERENCES products(id) ON DELETE SET NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
unit_type TEXT NOT NULL DEFAULT 'each',
|
||||
unit_type_custom TEXT,
|
||||
availability TEXT NOT NULL DEFAULT 'unavailable',
|
||||
qty_available NUMERIC(10,2) DEFAULT 0,
|
||||
season_start DATE,
|
||||
season_end DATE,
|
||||
price_tiers JSONB NOT NULL DEFAULT '[]',
|
||||
hp_sku TEXT,
|
||||
hp_item_id TEXT,
|
||||
internal_notes TEXT,
|
||||
handling_instructions TEXT,
|
||||
transport_temp TEXT,
|
||||
storage_warning TEXT,
|
||||
loading_notes TEXT,
|
||||
product_label TEXT,
|
||||
pack_style TEXT,
|
||||
container_type TEXT,
|
||||
container_size_code TEXT,
|
||||
units_per_container INTEGER,
|
||||
container_notes TEXT,
|
||||
default_pickup_location TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE public.wholesale_products ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS "brand_admin_manage_wholesale_products" ON wholesale_products;
|
||||
CREATE POLICY "brand_admin_manage_wholesale_products" ON wholesale_products
|
||||
FOR ALL USING (
|
||||
current_setting('app.settings.role', true)::TEXT IN ('brand_admin', 'platform_admin')
|
||||
AND (
|
||||
current_setting('app.settings.role', true)::TEXT = 'platform_admin'
|
||||
OR brand_id = current_setting('app.settings.brand_id', true)::UUID
|
||||
)
|
||||
);
|
||||
|
||||
-- ── 3. Wholesale Orders ────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS public.wholesale_orders (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
|
||||
customer_id UUID NOT NULL, -- FK added below after customers table exists
|
||||
wc_order_id BIGINT,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
fulfillment_status TEXT NOT NULL DEFAULT 'unfulfilled',
|
||||
payment_status TEXT NOT NULL DEFAULT 'unpaid',
|
||||
anticipated_pickup_date DATE,
|
||||
subtotal NUMERIC(10,2) NOT NULL DEFAULT 0,
|
||||
deposit_required NUMERIC(10,2) DEFAULT 0,
|
||||
deposit_paid NUMERIC(10,2) NOT NULL DEFAULT 0,
|
||||
balance_due NUMERIC(10,2) NOT NULL DEFAULT 0,
|
||||
assigned_employee_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||
fulfillment_notes TEXT,
|
||||
internal_notes TEXT,
|
||||
invoice_number TEXT,
|
||||
invoice_pdf_path TEXT,
|
||||
invoice_token TEXT,
|
||||
invoice_type TEXT,
|
||||
deposit_percentage INTEGER,
|
||||
fulfilled_at TIMESTAMPTZ,
|
||||
fulfilled_by UUID,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Add customer FK now that customers table definitely exists
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'wholesale_orders_customer_id_fkey'
|
||||
) THEN
|
||||
ALTER TABLE public.wholesale_orders
|
||||
ADD CONSTRAINT wholesale_orders_customer_id_fkey
|
||||
FOREIGN KEY (customer_id) REFERENCES wholesale_customers(id) ON DELETE RESTRICT
|
||||
NOT VALID;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE public.wholesale_orders ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS "brand_admin_manage_wholesale_orders" ON wholesale_orders;
|
||||
CREATE POLICY "brand_admin_manage_wholesale_orders" ON wholesale_orders
|
||||
FOR ALL USING (
|
||||
current_setting('app.settings.role', true)::TEXT IN ('brand_admin', 'platform_admin')
|
||||
AND (
|
||||
current_setting('app.settings.role', true)::TEXT = 'platform_admin'
|
||||
OR brand_id = current_setting('app.settings.brand_id', true)::UUID
|
||||
)
|
||||
);
|
||||
|
||||
-- ── 4. Wholesale Order Items ───────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS public.wholesale_order_items (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
wholesale_order_id UUID NOT NULL, -- FK added below
|
||||
product_id UUID NOT NULL, -- FK added below
|
||||
quantity NUMERIC(10,2) NOT NULL,
|
||||
unit_price NUMERIC(10,2) NOT NULL,
|
||||
line_total NUMERIC(10,2) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'wholesale_order_items_order_id_fkey'
|
||||
) THEN
|
||||
ALTER TABLE public.wholesale_order_items
|
||||
ADD CONSTRAINT wholesale_order_items_order_id_fkey
|
||||
FOREIGN KEY (wholesale_order_id) REFERENCES wholesale_orders(id) ON DELETE CASCADE
|
||||
NOT VALID;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'wholesale_order_items_product_id_fkey'
|
||||
) THEN
|
||||
ALTER TABLE public.wholesale_order_items
|
||||
ADD CONSTRAINT wholesale_order_items_product_id_fkey
|
||||
FOREIGN KEY (product_id) REFERENCES wholesale_products(id) ON DELETE RESTRICT
|
||||
NOT VALID;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE public.wholesale_order_items ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS "brand_admin_manage_wholesale_order_items" ON wholesale_order_items;
|
||||
CREATE POLICY "brand_admin_manage_wholesale_order_items" ON wholesale_order_items
|
||||
FOR ALL USING (
|
||||
current_setting('app.settings.role', true)::TEXT IN ('brand_admin', 'platform_admin')
|
||||
);
|
||||
|
||||
-- ── 5. Deposit Transactions ─────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS public.wholesale_deposits (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
wholesale_order_id UUID NOT NULL, -- FK added below
|
||||
amount NUMERIC(10,2) NOT NULL,
|
||||
payment_method TEXT,
|
||||
reference TEXT,
|
||||
recorded_by UUID NOT NULL REFERENCES auth.users(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'wholesale_deposits_order_id_fkey'
|
||||
) THEN
|
||||
ALTER TABLE public.wholesale_deposits
|
||||
ADD CONSTRAINT wholesale_deposits_order_id_fkey
|
||||
FOREIGN KEY (wholesale_order_id) REFERENCES wholesale_orders(id) ON DELETE CASCADE
|
||||
NOT VALID;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE public.wholesale_deposits ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS "brand_admin_manage_deposits" ON wholesale_deposits;
|
||||
CREATE POLICY "brand_admin_manage_deposits" ON wholesale_deposits
|
||||
FOR ALL USING (
|
||||
current_setting('app.settings.role', true)::TEXT IN ('brand_admin', 'platform_admin')
|
||||
);
|
||||
|
||||
-- ── 6. Wholesale Settings ───────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS public.wholesale_settings (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
brand_id UUID NOT NULL UNIQUE REFERENCES brands(id) ON DELETE CASCADE,
|
||||
portal_page_id UUID,
|
||||
price_sheet_page_id UUID,
|
||||
require_approval BOOLEAN NOT NULL DEFAULT true,
|
||||
min_order_amount NUMERIC(10,2),
|
||||
online_payment_enabled BOOLEAN NOT NULL DEFAULT false,
|
||||
pickup_location TEXT,
|
||||
fob_location TEXT,
|
||||
from_email TEXT,
|
||||
invoice_business_name TEXT,
|
||||
last_invoice_number INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE public.wholesale_settings ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS "brand_admin_manage_wholesale_settings" ON wholesale_settings;
|
||||
CREATE POLICY "brand_admin_manage_wholesale_settings" ON wholesale_settings
|
||||
FOR ALL USING (
|
||||
current_setting('app.settings.role', true)::TEXT IN ('brand_admin', 'platform_admin')
|
||||
AND (
|
||||
current_setting('app.settings.role', true)::TEXT = 'platform_admin'
|
||||
OR brand_id = current_setting('app.settings.brand_id', true)::UUID
|
||||
)
|
||||
);
|
||||
|
||||
DROP FUNCTION IF EXISTS public.get_wholesale_orders(UUID);
|
||||
DROP FUNCTION IF EXISTS public.upsert_wholesale_settings(UUID,UUID,UUID,BOOLEAN,NUMERIC,BOOLEAN,TEXT,TEXT,TEXT,TEXT);
|
||||
DROP FUNCTION IF EXISTS public.get_wholesale_settings(UUID);
|
||||
DROP FUNCTION IF EXISTS public.get_wholesale_customers(UUID);
|
||||
DROP FUNCTION IF EXISTS public.upsert_wholesale_customer(UUID,UUID,TEXT,TEXT,TEXT,TEXT,TEXT,NUMERIC,BOOLEAN,NUMERIC,INTEGER,TEXT,TEXT,TEXT,TEXT);
|
||||
DROP FUNCTION IF EXISTS public.get_wholesale_products(UUID);
|
||||
DROP FUNCTION IF EXISTS public.upsert_wholesale_product(UUID,UUID,TEXT,UUID,UUID,UUID,UUID,UUID,UUID,UUID,UUID,UUID,UUID,UUID,UUID,UUID,UUID,UUID,UUID,UUID,UUID,UUID,UUID);
|
||||
DROP FUNCTION IF EXISTS public.create_wholesale_order(UUID,UUID,DATE,JSONB,INTEGER,TEXT);
|
||||
DROP FUNCTION IF EXISTS public.record_wholesale_deposit(UUID,NUMERIC,TEXT,TEXT,UUID);
|
||||
DROP FUNCTION IF EXISTS public.mark_wholesale_order_fulfilled(UUID,UUID);
|
||||
|
||||
-- ── 7. RPCs ─────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.get_wholesale_orders(p_brand_id UUID DEFAULT NULL)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_result JSONB;
|
||||
BEGIN
|
||||
SELECT jsonb_agg(t ORDER BY t.created_at DESC) INTO v_result
|
||||
FROM (
|
||||
SELECT
|
||||
wo.id, wo.status, wo.fulfillment_status, wo.payment_status,
|
||||
wo.anticipated_pickup_date, wo.subtotal, wo.deposit_required,
|
||||
wo.deposit_paid, wo.balance_due, wo.created_at, wo.updated_at,
|
||||
wo.invoice_number, wo.assigned_employee_id,
|
||||
wc.company_name, wc.contact_name, wc.email AS customer_email,
|
||||
COALESCE(jsonb_agg(CASE WHEN woi.id IS NOT NULL THEN jsonb_build_object(
|
||||
'id', woi.id,
|
||||
'product_name', wp.name,
|
||||
'quantity', woi.quantity,
|
||||
'unit_price', woi.unit_price,
|
||||
'line_total', woi.line_total
|
||||
) END), '[]'::JSONB) AS items,
|
||||
wo.fulfilled_at
|
||||
FROM wholesale_orders wo
|
||||
JOIN wholesale_customers wc ON wo.customer_id = wc.id
|
||||
LEFT JOIN wholesale_order_items woi ON woi.wholesale_order_id = wo.id
|
||||
LEFT JOIN wholesale_products wp ON woi.product_id = wp.id
|
||||
WHERE wo.brand_id = p_brand_id
|
||||
GROUP BY wo.id, wc.id
|
||||
ORDER BY wo.created_at DESC
|
||||
LIMIT 500
|
||||
) t;
|
||||
RETURN COALESCE(v_result, '[]'::JSONB);
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.upsert_wholesale_settings(
|
||||
p_brand_id UUID DEFAULT NULL,
|
||||
p_portal_page_id UUID DEFAULT NULL,
|
||||
p_price_sheet_page_id UUID DEFAULT NULL,
|
||||
p_require_approval BOOLEAN DEFAULT NULL,
|
||||
p_min_order_amount NUMERIC DEFAULT NULL,
|
||||
p_online_payment_enabled BOOLEAN DEFAULT NULL,
|
||||
p_pickup_location TEXT DEFAULT NULL,
|
||||
p_fob_location TEXT DEFAULT NULL,
|
||||
p_from_email TEXT DEFAULT NULL,
|
||||
p_invoice_business_name TEXT DEFAULT NULL
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
INSERT INTO wholesale_settings (
|
||||
brand_id, portal_page_id, price_sheet_page_id,
|
||||
require_approval, min_order_amount, online_payment_enabled,
|
||||
pickup_location, fob_location, from_email, invoice_business_name
|
||||
)
|
||||
VALUES (
|
||||
p_brand_id, p_portal_page_id, p_price_sheet_page_id,
|
||||
COALESCE(p_require_approval, true),
|
||||
p_min_order_amount, COALESCE(p_online_payment_enabled, false),
|
||||
p_pickup_location, p_fob_location, p_from_email, p_invoice_business_name
|
||||
)
|
||||
ON CONFLICT (brand_id) DO UPDATE SET
|
||||
portal_page_id = COALESCE(p_portal_page_id, wholesale_settings.portal_page_id),
|
||||
price_sheet_page_id = COALESCE(p_price_sheet_page_id, wholesale_settings.price_sheet_page_id),
|
||||
require_approval = COALESCE(p_require_approval, wholesale_settings.require_approval),
|
||||
min_order_amount = COALESCE(p_min_order_amount, wholesale_settings.min_order_amount),
|
||||
online_payment_enabled = COALESCE(p_online_payment_enabled, wholesale_settings.online_payment_enabled),
|
||||
pickup_location = COALESCE(p_pickup_location, wholesale_settings.pickup_location),
|
||||
fob_location = COALESCE(p_fob_location, wholesale_settings.fob_location),
|
||||
from_email = COALESCE(p_from_email, wholesale_settings.from_email),
|
||||
invoice_business_name = COALESCE(p_invoice_business_name, wholesale_settings.invoice_business_name),
|
||||
updated_at = now()
|
||||
RETURNING jsonb_build_object('success', true);
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.get_wholesale_settings(p_brand_id UUID DEFAULT NULL)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_result JSONB;
|
||||
BEGIN
|
||||
SELECT jsonb_build_object(
|
||||
'id', id,
|
||||
'brand_id', brand_id,
|
||||
'portal_page_id', portal_page_id,
|
||||
'price_sheet_page_id', price_sheet_page_id,
|
||||
'require_approval', require_approval,
|
||||
'min_order_amount', min_order_amount,
|
||||
'online_payment_enabled', online_payment_enabled,
|
||||
'pickup_location', pickup_location,
|
||||
'fob_location', fob_location,
|
||||
'from_email', from_email,
|
||||
'invoice_business_name', invoice_business_name,
|
||||
'last_invoice_number', last_invoice_number
|
||||
) INTO v_result
|
||||
FROM wholesale_settings
|
||||
WHERE brand_id = p_brand_id;
|
||||
RETURN v_result;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.get_wholesale_customers(p_brand_id UUID DEFAULT NULL)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_result JSONB;
|
||||
BEGIN
|
||||
SELECT jsonb_agg(t ORDER BY t.company_name NULLS LAST) INTO v_result
|
||||
FROM (
|
||||
SELECT
|
||||
id, user_id, company_name, contact_name, email, phone,
|
||||
account_status, credit_limit,
|
||||
deposits_enabled, deposit_threshold, deposit_percentage,
|
||||
order_email, invoice_email, admin_notes, role,
|
||||
created_at
|
||||
FROM wholesale_customers
|
||||
WHERE brand_id = p_brand_id
|
||||
ORDER BY company_name NULLS LAST
|
||||
) t;
|
||||
RETURN COALESCE(v_result, '[]'::JSONB);
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.upsert_wholesale_customer(
|
||||
p_brand_id UUID DEFAULT NULL,
|
||||
p_user_id UUID DEFAULT NULL,
|
||||
p_company_name TEXT DEFAULT NULL,
|
||||
p_contact_name TEXT DEFAULT NULL,
|
||||
p_email TEXT DEFAULT NULL,
|
||||
p_phone TEXT DEFAULT NULL,
|
||||
p_account_status TEXT DEFAULT NULL,
|
||||
p_credit_limit NUMERIC DEFAULT NULL,
|
||||
p_deposits_enabled BOOLEAN DEFAULT NULL,
|
||||
p_deposit_threshold NUMERIC DEFAULT NULL,
|
||||
p_deposit_percentage INTEGER DEFAULT NULL,
|
||||
p_order_email TEXT DEFAULT NULL,
|
||||
p_invoice_email TEXT DEFAULT NULL,
|
||||
p_admin_notes TEXT DEFAULT NULL,
|
||||
p_role TEXT DEFAULT NULL
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_id UUID;
|
||||
BEGIN
|
||||
INSERT INTO wholesale_customers (
|
||||
brand_id, user_id, company_name, contact_name, email, phone,
|
||||
account_status, credit_limit, deposits_enabled, deposit_threshold,
|
||||
deposit_percentage, order_email, invoice_email, admin_notes, role
|
||||
)
|
||||
VALUES (
|
||||
p_brand_id, p_user_id, p_company_name, p_contact_name, p_email, p_phone,
|
||||
COALESCE(p_account_status, 'active'),
|
||||
COALESCE(p_credit_limit, 0),
|
||||
COALESCE(p_deposits_enabled, false),
|
||||
p_deposit_threshold, p_deposit_percentage,
|
||||
p_order_email, p_invoice_email, p_admin_notes,
|
||||
COALESCE(p_role, 'buyer')
|
||||
)
|
||||
ON CONFLICT (brand_id, user_id) DO UPDATE SET
|
||||
company_name = COALESCE(p_company_name, wholesale_customers.company_name),
|
||||
contact_name = COALESCE(p_contact_name, wholesale_customers.contact_name),
|
||||
email = COALESCE(p_email, wholesale_customers.email),
|
||||
phone = COALESCE(p_phone, wholesale_customers.phone),
|
||||
account_status = COALESCE(p_account_status, wholesale_customers.account_status),
|
||||
credit_limit = COALESCE(p_credit_limit, wholesale_customers.credit_limit),
|
||||
deposits_enabled = COALESCE(p_deposits_enabled, wholesale_customers.deposits_enabled),
|
||||
deposit_threshold = COALESCE(p_deposit_threshold, wholesale_customers.deposit_threshold),
|
||||
deposit_percentage= COALESCE(p_deposit_percentage, wholesale_customers.deposit_percentage),
|
||||
order_email = COALESCE(p_order_email, wholesale_customers.order_email),
|
||||
invoice_email = COALESCE(p_invoice_email, wholesale_customers.invoice_email),
|
||||
admin_notes = COALESCE(p_admin_notes, wholesale_customers.admin_notes),
|
||||
role = COALESCE(p_role, wholesale_customers.role),
|
||||
updated_at = now()
|
||||
RETURNING id INTO v_id;
|
||||
|
||||
RETURN jsonb_build_object('success', true, 'id', v_id);
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.get_wholesale_products(p_brand_id UUID DEFAULT NULL)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_result JSONB;
|
||||
BEGIN
|
||||
SELECT jsonb_agg(t ORDER BY t.name) INTO v_result
|
||||
FROM (
|
||||
SELECT
|
||||
id, name, description, unit_type, unit_type_custom,
|
||||
availability, qty_available, season_start, season_end,
|
||||
price_tiers, hp_sku, hp_item_id,
|
||||
handling_instructions, storage_warning, loading_notes,
|
||||
product_label, pack_style, container_type, container_size_code,
|
||||
units_per_container, default_pickup_location,
|
||||
created_at
|
||||
FROM wholesale_products
|
||||
WHERE brand_id = p_brand_id
|
||||
ORDER BY name
|
||||
) t;
|
||||
RETURN COALESCE(v_result, '[]'::JSONB);
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.upsert_wholesale_product(
|
||||
p_brand_id UUID DEFAULT NULL,
|
||||
p_id UUID DEFAULT NULL,
|
||||
p_name TEXT DEFAULT NULL,
|
||||
p_description TEXT DEFAULT NULL,
|
||||
p_unit_type TEXT DEFAULT NULL,
|
||||
p_unit_type_custom TEXT DEFAULT NULL,
|
||||
p_availability TEXT DEFAULT NULL,
|
||||
p_qty_available NUMERIC DEFAULT NULL,
|
||||
p_season_start DATE DEFAULT NULL,
|
||||
p_season_end DATE DEFAULT NULL,
|
||||
p_price_tiers JSONB DEFAULT NULL,
|
||||
p_hp_sku TEXT DEFAULT NULL,
|
||||
p_hp_item_id TEXT DEFAULT NULL,
|
||||
p_internal_notes TEXT DEFAULT NULL,
|
||||
p_handling_instructions TEXT DEFAULT NULL,
|
||||
p_storage_warning TEXT DEFAULT NULL,
|
||||
p_loading_notes TEXT DEFAULT NULL,
|
||||
p_product_label TEXT DEFAULT NULL,
|
||||
p_pack_style TEXT DEFAULT NULL,
|
||||
p_container_type TEXT DEFAULT NULL,
|
||||
p_container_size_code TEXT DEFAULT NULL,
|
||||
p_units_per_container INTEGER DEFAULT NULL,
|
||||
p_default_pickup_location TEXT DEFAULT NULL
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_id UUID;
|
||||
BEGIN
|
||||
IF p_id IS NOT NULL THEN
|
||||
UPDATE wholesale_products SET
|
||||
name = COALESCE(p_name, name),
|
||||
description = COALESCE(p_description, description),
|
||||
unit_type = COALESCE(p_unit_type, unit_type),
|
||||
unit_type_custom = COALESCE(p_unit_type_custom, unit_type_custom),
|
||||
availability = COALESCE(p_availability, availability),
|
||||
qty_available = COALESCE(p_qty_available, qty_available),
|
||||
season_start = COALESCE(p_season_start, season_start),
|
||||
season_end = COALESCE(p_season_end, season_end),
|
||||
price_tiers = COALESCE(p_price_tiers, price_tiers),
|
||||
hp_sku = COALESCE(p_hp_sku, hp_sku),
|
||||
hp_item_id = COALESCE(p_hp_item_id, hp_item_id),
|
||||
internal_notes = COALESCE(p_internal_notes, internal_notes),
|
||||
handling_instructions = COALESCE(p_handling_instructions, handling_instructions),
|
||||
storage_warning = COALESCE(p_storage_warning, storage_warning),
|
||||
loading_notes = COALESCE(p_loading_notes, loading_notes),
|
||||
product_label = COALESCE(p_product_label, product_label),
|
||||
pack_style = COALESCE(p_pack_style, pack_style),
|
||||
container_type = COALESCE(p_container_type, container_type),
|
||||
container_size_code = COALESCE(p_container_size_code, container_size_code),
|
||||
units_per_container = COALESCE(p_units_per_container, units_per_container),
|
||||
default_pickup_location = COALESCE(p_default_pickup_location, default_pickup_location),
|
||||
updated_at = now()
|
||||
WHERE id = p_id AND brand_id = p_brand_id
|
||||
RETURNING id INTO v_id;
|
||||
ELSE
|
||||
INSERT INTO wholesale_products (
|
||||
brand_id, name, description, unit_type, unit_type_custom,
|
||||
availability, qty_available, season_start, season_end,
|
||||
price_tiers, hp_sku, hp_item_id, internal_notes,
|
||||
handling_instructions, storage_warning, loading_notes,
|
||||
product_label, pack_style, container_type, container_size_code,
|
||||
units_per_container, default_pickup_location
|
||||
) VALUES (
|
||||
p_brand_id, p_name, p_description,
|
||||
COALESCE(p_unit_type, 'each'),
|
||||
p_unit_type_custom,
|
||||
COALESCE(p_availability, 'unavailable'),
|
||||
COALESCE(p_qty_available, 0),
|
||||
p_season_start, p_season_end,
|
||||
COALESCE(p_price_tiers, '[]'::JSONB),
|
||||
p_hp_sku, p_hp_item_id, p_internal_notes,
|
||||
p_handling_instructions, p_storage_warning, p_loading_notes,
|
||||
p_product_label, p_pack_style, p_container_type, p_container_size_code,
|
||||
p_units_per_container, p_default_pickup_location
|
||||
)
|
||||
RETURNING id INTO v_id;
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object('success', true, 'id', v_id);
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.create_wholesale_order(
|
||||
p_brand_id UUID DEFAULT NULL,
|
||||
p_customer_id UUID DEFAULT NULL,
|
||||
p_anticipated_pickup_date DATE DEFAULT NULL,
|
||||
p_items JSONB DEFAULT '[]'::JSONB,
|
||||
p_deposit_percentage INTEGER DEFAULT NULL,
|
||||
p_notes TEXT DEFAULT NULL
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_order_id UUID;
|
||||
v_subtotal NUMERIC(10,2);
|
||||
v_dep_required NUMERIC(10,2) := 0;
|
||||
v_customer RECORD;
|
||||
v_settings RECORD;
|
||||
v_next_inv INTEGER;
|
||||
v_status TEXT;
|
||||
BEGIN
|
||||
-- Resolve customer deposit rules
|
||||
SELECT deposits_enabled, deposit_threshold, deposit_percentage
|
||||
INTO v_customer
|
||||
FROM wholesale_customers
|
||||
WHERE id = p_customer_id;
|
||||
|
||||
-- Resolve invoice counter
|
||||
SELECT COALESCE(last_invoice_number, 0) INTO v_settings
|
||||
FROM wholesale_settings
|
||||
WHERE brand_id = p_brand_id;
|
||||
|
||||
-- Calculate subtotal
|
||||
v_subtotal := COALESCE(
|
||||
(SELECT SUM(
|
||||
((item->>'quantity')::NUMERIC) * ((item->>'unit_price')::NUMERIC)
|
||||
) FROM jsonb_array_elements(p_items) AS item),
|
||||
0
|
||||
);
|
||||
|
||||
-- Determine if deposit required
|
||||
IF v_customer.deposits_enabled THEN
|
||||
IF v_customer.deposit_threshold IS NULL OR v_subtotal >= v_customer.deposit_threshold THEN
|
||||
v_dep_required := ROUND(v_subtotal * COALESCE(v_customer.deposit_percentage, 0) / 100.0, 2);
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
v_status := CASE WHEN v_dep_required > 0 THEN 'awaiting_deposit' ELSE 'pending' END;
|
||||
v_next_inv := COALESCE(v_settings.last_invoice_number, 0) + 1;
|
||||
|
||||
INSERT INTO wholesale_orders (
|
||||
brand_id, customer_id, status, anticipated_pickup_date,
|
||||
subtotal, deposit_required, deposit_paid, balance_due,
|
||||
deposit_percentage, internal_notes, invoice_number
|
||||
) VALUES (
|
||||
p_brand_id, p_customer_id, v_status, p_anticipated_pickup_date,
|
||||
v_subtotal, v_dep_required, 0, v_subtotal,
|
||||
p_deposit_percentage, p_notes,
|
||||
'INV-' || LPAD(v_next_inv::TEXT, 5, '0')
|
||||
) RETURNING id INTO v_order_id;
|
||||
|
||||
-- Insert line items
|
||||
IF jsonb_array_length(p_items) > 0 THEN
|
||||
INSERT INTO wholesale_order_items (wholesale_order_id, product_id, quantity, unit_price, line_total)
|
||||
SELECT
|
||||
v_order_id,
|
||||
(item->>'product_id')::UUID,
|
||||
(item->>'quantity')::NUMERIC,
|
||||
(item->>'unit_price')::NUMERIC,
|
||||
((item->>'quantity')::NUMERIC) * ((item->>'unit_price')::NUMERIC)
|
||||
FROM jsonb_array_elements(p_items) AS item;
|
||||
END IF;
|
||||
|
||||
-- Advance invoice counter
|
||||
UPDATE wholesale_settings
|
||||
SET last_invoice_number = v_next_inv, updated_at = now()
|
||||
WHERE brand_id = p_brand_id;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'success', true,
|
||||
'order_id', v_order_id,
|
||||
'status', v_status,
|
||||
'deposit_required', v_dep_required,
|
||||
'subtotal', v_subtotal
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.record_wholesale_deposit(
|
||||
p_order_id UUID DEFAULT NULL,
|
||||
p_amount NUMERIC DEFAULT NULL,
|
||||
p_method TEXT DEFAULT 'cash',
|
||||
p_reference TEXT DEFAULT NULL,
|
||||
p_recorded_by UUID DEFAULT NULL
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_new_paid NUMERIC(10,2);
|
||||
BEGIN
|
||||
-- Compute new deposit_paid first so balance_due uses the correct updated value
|
||||
SELECT deposit_paid + p_amount INTO v_new_paid
|
||||
FROM wholesale_orders
|
||||
WHERE id = p_order_id;
|
||||
|
||||
UPDATE wholesale_orders
|
||||
SET
|
||||
deposit_paid = v_new_paid,
|
||||
balance_due = subtotal - v_new_paid,
|
||||
updated_at = now()
|
||||
WHERE id = p_order_id;
|
||||
|
||||
INSERT INTO wholesale_deposits (wholesale_order_id, amount, payment_method, reference, recorded_by)
|
||||
VALUES (p_order_id, p_amount, p_method, p_reference, p_recorded_by);
|
||||
|
||||
-- Advance to pending once deposit covers requirement
|
||||
UPDATE wholesale_orders
|
||||
SET status = 'pending'
|
||||
WHERE id = p_order_id
|
||||
AND v_new_paid >= deposit_required
|
||||
AND status = 'awaiting_deposit';
|
||||
|
||||
RETURN jsonb_build_object('success', true);
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.mark_wholesale_order_fulfilled(
|
||||
p_order_id UUID DEFAULT NULL,
|
||||
p_by UUID DEFAULT NULL
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
UPDATE wholesale_orders
|
||||
SET
|
||||
fulfillment_status = 'fulfilled',
|
||||
payment_status = CASE WHEN balance_due <= 0 THEN 'paid' ELSE payment_status END,
|
||||
status = 'fulfilled',
|
||||
fulfilled_at = now(),
|
||||
fulfilled_by = p_by,
|
||||
updated_at = now()
|
||||
WHERE id = p_order_id;
|
||||
|
||||
RETURN jsonb_build_object('success', true);
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMIT;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,201 @@
|
||||
-- Migration 047: Wholesale Registration + Pending Approval Status
|
||||
-- Adds self-service registration RPC and 'pending_approval' account status.
|
||||
-- Idempotent — uses CREATE OR REPLACE and IF NOT EXISTS.
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Add pending_approval to the account_status check constraint
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'wholesale_customers_account_status_check'
|
||||
) THEN
|
||||
ALTER TABLE public.wholesale_customers
|
||||
ADD CONSTRAINT wholesale_customers_account_status_check
|
||||
CHECK (account_status IN ('active', 'on_hold', 'disabled', 'pending_approval', 'rejected'));
|
||||
ELSE
|
||||
-- Drop and recreate the check constraint with the new value
|
||||
ALTER TABLE public.wholesale_customers
|
||||
DROP CONSTRAINT wholesale_customers_account_status_check;
|
||||
ALTER TABLE public.wholesale_customers
|
||||
ADD CONSTRAINT wholesale_customers_account_status_check
|
||||
CHECK (account_status IN ('active', 'on_hold', 'disabled', 'pending_approval', 'rejected'));
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Add rejected status to the check constraint (same pattern)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'wholesale_customers_account_status_check'
|
||||
) THEN
|
||||
ALTER TABLE public.wholesale_customers
|
||||
ADD CONSTRAINT wholesale_customers_account_status_check
|
||||
CHECK (account_status IN ('active', 'on_hold', 'disabled', 'pending_approval', 'rejected'));
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ── Registration RPC ─────────────────────────────────────────────────────────
|
||||
|
||||
DROP FUNCTION IF EXISTS public.register_wholesale_customer(UUID, TEXT, TEXT, TEXT, TEXT);
|
||||
CREATE OR REPLACE FUNCTION public.register_wholesale_customer(
|
||||
p_brand_id UUID DEFAULT NULL,
|
||||
p_company_name TEXT DEFAULT NULL,
|
||||
p_contact_name TEXT DEFAULT NULL,
|
||||
p_email TEXT DEFAULT NULL,
|
||||
p_phone TEXT DEFAULT NULL
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_id UUID;
|
||||
v_req_app BOOLEAN := true;
|
||||
BEGIN
|
||||
-- Reject duplicate email for this brand
|
||||
IF EXISTS (SELECT 1 FROM wholesale_customers WHERE brand_id = p_brand_id AND email = p_email) THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'An account with this email already exists.');
|
||||
END IF;
|
||||
|
||||
-- Read require_approval setting for this brand
|
||||
BEGIN
|
||||
SELECT require_approval INTO v_req_app
|
||||
FROM wholesale_settings
|
||||
WHERE brand_id = p_brand_id;
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
v_req_app := true;
|
||||
END;
|
||||
|
||||
INSERT INTO wholesale_customers (
|
||||
brand_id, company_name, contact_name, email, phone,
|
||||
account_status, role
|
||||
) VALUES (
|
||||
p_brand_id,
|
||||
p_company_name,
|
||||
p_contact_name,
|
||||
p_email,
|
||||
p_phone,
|
||||
CASE WHEN COALESCE(v_req_app, true) THEN 'pending_approval' ELSE 'active' END,
|
||||
'buyer'
|
||||
)
|
||||
RETURNING id INTO v_id;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'success', true,
|
||||
'id', v_id,
|
||||
'requires_approval', COALESCE(v_req_app, true)
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── Customer lookup by user_id (for portal auth) ──────────────────────────────
|
||||
|
||||
DROP FUNCTION IF EXISTS public.get_wholesale_customer_by_user(UUID, UUID);
|
||||
CREATE OR REPLACE FUNCTION public.get_wholesale_customer_by_user(
|
||||
p_brand_id UUID DEFAULT NULL,
|
||||
p_user_id UUID DEFAULT NULL
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
-- If brand_id is a zero UUID (placeholder), search across all brands
|
||||
IF p_brand_id = '00000000-0000-0000-0000-000000000000'::UUID THEN
|
||||
RETURN (
|
||||
SELECT jsonb_build_object(
|
||||
'id', id,
|
||||
'user_id', user_id,
|
||||
'company_name', company_name,
|
||||
'contact_name', contact_name,
|
||||
'email', email,
|
||||
'phone', phone,
|
||||
'account_status', account_status,
|
||||
'role', role,
|
||||
'brand_id', brand_id
|
||||
)
|
||||
FROM wholesale_customers
|
||||
WHERE user_id = p_user_id AND account_status = 'active'
|
||||
LIMIT 1
|
||||
);
|
||||
END IF;
|
||||
|
||||
RETURN (
|
||||
SELECT jsonb_build_object(
|
||||
'id', id,
|
||||
'user_id', user_id,
|
||||
'company_name', company_name,
|
||||
'contact_name', contact_name,
|
||||
'email', email,
|
||||
'phone', phone,
|
||||
'account_status', account_status,
|
||||
'role', role,
|
||||
'brand_id', brand_id
|
||||
)
|
||||
FROM wholesale_customers
|
||||
WHERE brand_id = p_brand_id AND user_id = p_user_id
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── Pending registrations RPC (for admin approval queue) ──────────────────────
|
||||
|
||||
DROP FUNCTION IF EXISTS public.get_pending_wholesale_registrations(UUID);
|
||||
CREATE OR REPLACE FUNCTION public.get_pending_wholesale_registrations(p_brand_id UUID DEFAULT NULL)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_result JSONB;
|
||||
BEGIN
|
||||
SELECT jsonb_agg(t ORDER BY t.created_at ASC) INTO v_result
|
||||
FROM (
|
||||
SELECT
|
||||
id, company_name, contact_name, email, phone,
|
||||
account_status, role, created_at, updated_at
|
||||
FROM wholesale_customers
|
||||
WHERE brand_id = p_brand_id
|
||||
AND account_status IN ('pending_approval', 'rejected')
|
||||
ORDER BY created_at ASC
|
||||
) t;
|
||||
RETURN COALESCE(v_result, '[]'::JSONB);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── Approve/reject registration RPC ───────────────────────────────────────────
|
||||
|
||||
DROP FUNCTION IF EXISTS public.approve_wholesale_registration(UUID, UUID, TEXT);
|
||||
CREATE OR REPLACE FUNCTION public.approve_wholesale_registration(
|
||||
p_registration_id UUID DEFAULT NULL,
|
||||
p_brand_id UUID DEFAULT NULL,
|
||||
p_action TEXT DEFAULT NULL
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
IF p_action = 'approve' THEN
|
||||
UPDATE wholesale_customers
|
||||
SET account_status = 'active', updated_at = now()
|
||||
WHERE id = p_registration_id AND brand_id = p_brand_id
|
||||
AND account_status = 'pending_approval';
|
||||
ELSIF p_action = 'reject' THEN
|
||||
UPDATE wholesale_customers
|
||||
SET account_status = 'rejected', updated_at = now()
|
||||
WHERE id = p_registration_id AND brand_id = p_brand_id
|
||||
AND account_status = 'pending_approval';
|
||||
ELSE
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Invalid action.');
|
||||
END IF;
|
||||
RETURN jsonb_build_object('success', true);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── Upsert wholesale customer (updated to allow linking user_id) ──────────────
|
||||
|
||||
-- Update existing upsert to handle user_id linking
|
||||
-- The existing upsert_wholesale_customer is already set up to use brand_id+user_id as the ON CONFLICT target.
|
||||
-- This just adds a note that it can also be used to link an existing customer to a auth.users account.
|
||||
|
||||
COMMIT;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,62 @@
|
||||
-- Migration 048: Add wholesale_enabled to wholesale_settings + update upsert RPC
|
||||
-- Allows admins to enable/disable the Wholesale Portal per brand.
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Add wholesale_enabled column
|
||||
ALTER TABLE public.wholesale_settings
|
||||
ADD COLUMN IF NOT EXISTS wholesale_enabled BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
-- Update upsert function to handle wholesale_enabled
|
||||
DROP FUNCTION IF EXISTS public.upsert_wholesale_settings(
|
||||
UUID, UUID, UUID, BOOLEAN, NUMERIC, BOOLEAN, BOOLEAN, TEXT, TEXT, TEXT, TEXT
|
||||
);
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.upsert_wholesale_settings(
|
||||
p_brand_id UUID DEFAULT NULL,
|
||||
p_portal_page_id UUID DEFAULT NULL,
|
||||
p_price_sheet_page_id UUID DEFAULT NULL,
|
||||
p_require_approval BOOLEAN DEFAULT NULL,
|
||||
p_min_order_amount NUMERIC DEFAULT NULL,
|
||||
p_online_payment_enabled BOOLEAN DEFAULT NULL,
|
||||
p_wholesale_enabled BOOLEAN DEFAULT NULL,
|
||||
p_pickup_location TEXT DEFAULT NULL,
|
||||
p_fob_location TEXT DEFAULT NULL,
|
||||
p_from_email TEXT DEFAULT NULL,
|
||||
p_invoice_business_name TEXT DEFAULT NULL
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
INSERT INTO wholesale_settings (
|
||||
brand_id, portal_page_id, price_sheet_page_id,
|
||||
require_approval, min_order_amount, online_payment_enabled,
|
||||
wholesale_enabled, pickup_location, fob_location, from_email, invoice_business_name
|
||||
)
|
||||
VALUES (
|
||||
p_brand_id, p_portal_page_id, p_price_sheet_page_id,
|
||||
COALESCE(p_require_approval, true),
|
||||
p_min_order_amount, COALESCE(p_online_payment_enabled, false),
|
||||
COALESCE(p_wholesale_enabled, true),
|
||||
p_pickup_location, p_fob_location, p_from_email, p_invoice_business_name
|
||||
)
|
||||
ON CONFLICT (brand_id) DO UPDATE SET
|
||||
portal_page_id = COALESCE(p_portal_page_id, wholesale_settings.portal_page_id),
|
||||
price_sheet_page_id = COALESCE(p_price_sheet_page_id, wholesale_settings.price_sheet_page_id),
|
||||
require_approval = COALESCE(p_require_approval, wholesale_settings.require_approval),
|
||||
min_order_amount = COALESCE(p_min_order_amount, wholesale_settings.min_order_amount),
|
||||
online_payment_enabled = COALESCE(p_online_payment_enabled, wholesale_settings.online_payment_enabled),
|
||||
wholesale_enabled = COALESCE(p_wholesale_enabled, wholesale_settings.wholesale_enabled),
|
||||
pickup_location = COALESCE(p_pickup_location, wholesale_settings.pickup_location),
|
||||
fob_location = COALESCE(p_fob_location, wholesale_settings.fob_location),
|
||||
from_email = COALESCE(p_from_email, wholesale_settings.from_email),
|
||||
invoice_business_name = COALESCE(p_invoice_business_name, wholesale_settings.invoice_business_name),
|
||||
updated_at = now()
|
||||
RETURNING jsonb_build_object('success', true);
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMIT;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,211 @@
|
||||
-- Migration 049: Invoice Settings + TC Invoice Numbers + Secure Token
|
||||
-- - Adds invoice business contact fields to wholesale_settings
|
||||
-- - Updates create_wholesale_order to generate TC-XXXXXX format + secure token
|
||||
-- - Updates upsert_wholesale_settings to persist the new invoice fields
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ── 1. Add invoice business contact columns to wholesale_settings ────────────
|
||||
ALTER TABLE public.wholesale_settings
|
||||
ADD COLUMN IF NOT EXISTS invoice_business_address TEXT,
|
||||
ADD COLUMN IF NOT EXISTS invoice_business_phone TEXT,
|
||||
ADD COLUMN IF NOT EXISTS invoice_business_email TEXT,
|
||||
ADD COLUMN IF NOT EXISTS invoice_business_website TEXT;
|
||||
|
||||
-- ── 2. Update upsert_wholesale_settings RPC to handle new invoice fields ─────
|
||||
DROP FUNCTION IF EXISTS public.upsert_wholesale_settings(
|
||||
UUID, UUID, UUID, BOOLEAN, NUMERIC, BOOLEAN, BOOLEAN, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT
|
||||
);
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.upsert_wholesale_settings(
|
||||
p_brand_id UUID DEFAULT NULL,
|
||||
p_portal_page_id UUID DEFAULT NULL,
|
||||
p_price_sheet_page_id UUID DEFAULT NULL,
|
||||
p_require_approval BOOLEAN DEFAULT NULL,
|
||||
p_min_order_amount NUMERIC DEFAULT NULL,
|
||||
p_online_payment_enabled BOOLEAN DEFAULT NULL,
|
||||
p_wholesale_enabled BOOLEAN DEFAULT NULL,
|
||||
p_pickup_location TEXT DEFAULT NULL,
|
||||
p_fob_location TEXT DEFAULT NULL,
|
||||
p_from_email TEXT DEFAULT NULL,
|
||||
p_invoice_business_name TEXT DEFAULT NULL,
|
||||
p_invoice_business_address TEXT DEFAULT NULL,
|
||||
p_invoice_business_phone TEXT DEFAULT NULL,
|
||||
p_invoice_business_email TEXT DEFAULT NULL,
|
||||
p_invoice_business_website TEXT DEFAULT NULL
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
INSERT INTO wholesale_settings (
|
||||
brand_id, portal_page_id, price_sheet_page_id,
|
||||
require_approval, min_order_amount, online_payment_enabled,
|
||||
wholesale_enabled, pickup_location, fob_location, from_email,
|
||||
invoice_business_name, invoice_business_address,
|
||||
invoice_business_phone, invoice_business_email, invoice_business_website
|
||||
)
|
||||
VALUES (
|
||||
p_brand_id, p_portal_page_id, p_price_sheet_page_id,
|
||||
COALESCE(p_require_approval, true),
|
||||
p_min_order_amount, COALESCE(p_online_payment_enabled, false),
|
||||
COALESCE(p_wholesale_enabled, true),
|
||||
p_pickup_location, p_fob_location, p_from_email,
|
||||
p_invoice_business_name, p_invoice_business_address,
|
||||
p_invoice_business_phone, p_invoice_business_email, p_invoice_business_website
|
||||
)
|
||||
ON CONFLICT (brand_id) DO UPDATE SET
|
||||
portal_page_id = COALESCE(p_portal_page_id, wholesale_settings.portal_page_id),
|
||||
price_sheet_page_id = COALESCE(p_price_sheet_page_id, wholesale_settings.price_sheet_page_id),
|
||||
require_approval = COALESCE(p_require_approval, wholesale_settings.require_approval),
|
||||
min_order_amount = COALESCE(p_min_order_amount, wholesale_settings.min_order_amount),
|
||||
online_payment_enabled = COALESCE(p_online_payment_enabled, wholesale_settings.online_payment_enabled),
|
||||
wholesale_enabled = COALESCE(p_wholesale_enabled, wholesale_settings.wholesale_enabled),
|
||||
pickup_location = COALESCE(p_pickup_location, wholesale_settings.pickup_location),
|
||||
fob_location = COALESCE(p_fob_location, wholesale_settings.fob_location),
|
||||
from_email = COALESCE(p_from_email, wholesale_settings.from_email),
|
||||
invoice_business_name = COALESCE(p_invoice_business_name, wholesale_settings.invoice_business_name),
|
||||
invoice_business_address = COALESCE(p_invoice_business_address, wholesale_settings.invoice_business_address),
|
||||
invoice_business_phone = COALESCE(p_invoice_business_phone, wholesale_settings.invoice_business_phone),
|
||||
invoice_business_email = COALESCE(p_invoice_business_email, wholesale_settings.invoice_business_email),
|
||||
invoice_business_website = COALESCE(p_invoice_business_website, wholesale_settings.invoice_business_website),
|
||||
updated_at = now();
|
||||
RETURN jsonb_build_object('success', true);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── 3. Update create_wholesale_order to generate TC-XXXXXX + invoice_token ───
|
||||
DROP FUNCTION IF EXISTS public.create_wholesale_order(
|
||||
UUID, UUID, DATE, JSONB, INTEGER, TEXT
|
||||
);
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.create_wholesale_order(
|
||||
p_brand_id UUID DEFAULT NULL,
|
||||
p_customer_id UUID DEFAULT NULL,
|
||||
p_anticipated_pickup_date DATE DEFAULT NULL,
|
||||
p_items JSONB DEFAULT '[]'::JSONB,
|
||||
p_deposit_percentage INTEGER DEFAULT NULL,
|
||||
p_notes TEXT DEFAULT NULL
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_order_id UUID;
|
||||
v_subtotal NUMERIC(10,2);
|
||||
v_dep_required NUMERIC(10,2) := 0;
|
||||
v_customer RECORD;
|
||||
v_settings RECORD;
|
||||
v_inv_number TEXT;
|
||||
v_inv_token TEXT;
|
||||
v_status TEXT;
|
||||
BEGIN
|
||||
-- Resolve customer deposit rules
|
||||
SELECT deposits_enabled, deposit_threshold, deposit_percentage
|
||||
INTO v_customer
|
||||
FROM wholesale_customers
|
||||
WHERE id = p_customer_id;
|
||||
|
||||
-- Generate TC-XXXXXX invoice number using RANDOM()
|
||||
v_inv_number := 'TC-' || LPAD(FLOOR(RANDOM() * 1000000)::INT::TEXT, 6, '0');
|
||||
-- Generate a 32-char hex invoice token using md5 of random + timestamp
|
||||
v_inv_token := md5(random()::text || clock_timestamp()::text || now()::text || p_brand_id::text);
|
||||
|
||||
-- Calculate subtotal
|
||||
v_subtotal := COALESCE(
|
||||
(SELECT SUM(
|
||||
((item->>'quantity')::NUMERIC) * ((item->>'unit_price')::NUMERIC)
|
||||
) FROM jsonb_array_elements(p_items) AS item),
|
||||
0
|
||||
);
|
||||
|
||||
-- Determine if deposit required
|
||||
IF v_customer.deposits_enabled THEN
|
||||
IF v_customer.deposit_threshold IS NULL OR v_subtotal >= v_customer.deposit_threshold THEN
|
||||
v_dep_required := ROUND(v_subtotal * COALESCE(v_customer.deposit_percentage, 0) / 100.0, 2);
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
v_status := CASE WHEN v_dep_required > 0 THEN 'awaiting_deposit' ELSE 'pending' END;
|
||||
|
||||
INSERT INTO wholesale_orders (
|
||||
brand_id, customer_id, status, anticipated_pickup_date,
|
||||
subtotal, deposit_required, deposit_paid, balance_due,
|
||||
deposit_percentage, internal_notes, invoice_number, invoice_token
|
||||
) VALUES (
|
||||
p_brand_id, p_customer_id, v_status, p_anticipated_pickup_date,
|
||||
v_subtotal, v_dep_required, 0, v_subtotal,
|
||||
p_deposit_percentage, p_notes,
|
||||
v_inv_number, v_inv_token
|
||||
) RETURNING id INTO v_order_id;
|
||||
|
||||
-- Insert line items
|
||||
IF jsonb_array_length(p_items) > 0 THEN
|
||||
INSERT INTO wholesale_order_items (wholesale_order_id, product_id, quantity, unit_price, line_total)
|
||||
SELECT
|
||||
v_order_id,
|
||||
(item->>'product_id')::UUID,
|
||||
(item->>'quantity')::NUMERIC,
|
||||
(item->>'unit_price')::NUMERIC,
|
||||
((item->>'quantity')::NUMERIC) * ((item->>'unit_price')::NUMERIC)
|
||||
FROM jsonb_array_elements(p_items) AS item;
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'success', true,
|
||||
'order_id', v_order_id,
|
||||
'invoice_number', v_inv_number,
|
||||
'status', v_status,
|
||||
'deposit_required', v_dep_required,
|
||||
'subtotal', v_subtotal
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── 4. RPC: Get wholesale orders for a customer (customer-facing order history) ──
|
||||
DROP FUNCTION IF EXISTS public.get_wholesale_customer_orders(UUID);
|
||||
CREATE OR REPLACE FUNCTION public.get_wholesale_customer_orders(p_customer_id UUID DEFAULT NULL)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_result JSONB;
|
||||
BEGIN
|
||||
SELECT jsonb_agg(t ORDER BY t.created_at DESC) INTO v_result
|
||||
FROM (
|
||||
SELECT
|
||||
wo.id,
|
||||
wo.status,
|
||||
wo.fulfillment_status,
|
||||
wo.payment_status,
|
||||
wo.anticipated_pickup_date,
|
||||
wo.subtotal,
|
||||
wo.deposit_required,
|
||||
wo.deposit_paid,
|
||||
wo.balance_due,
|
||||
wo.invoice_number,
|
||||
wo.invoice_token,
|
||||
wo.created_at,
|
||||
wo.updated_at,
|
||||
COALESCE(jsonb_agg(CASE WHEN woi.id IS NOT NULL THEN jsonb_build_object(
|
||||
'id', woi.id,
|
||||
'product_name', wp.name,
|
||||
'quantity', woi.quantity,
|
||||
'unit_price', woi.unit_price,
|
||||
'line_total', woi.line_total
|
||||
) END), '[]'::JSONB) AS items
|
||||
FROM wholesale_orders wo
|
||||
LEFT JOIN wholesale_order_items woi ON woi.wholesale_order_id = wo.id
|
||||
LEFT JOIN wholesale_products wp ON woi.product_id = wp.id
|
||||
WHERE wo.customer_id = p_customer_id
|
||||
GROUP BY wo.id
|
||||
ORDER BY wo.created_at DESC
|
||||
LIMIT 100
|
||||
) t;
|
||||
RETURN COALESCE(v_result, '[]'::JSONB);
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMIT;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,78 @@
|
||||
-- Migration 050: Wholesale Online Payment
|
||||
-- - Adds checkout_session_id and payment_intent_id to wholesale_orders
|
||||
-- - Adds record_wholesale_payment RPC for Stripe webhook to call
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ── 1. Add Stripe payment tracking columns ───────────────────────────────────
|
||||
ALTER TABLE public.wholesale_orders
|
||||
ADD COLUMN IF NOT EXISTS checkout_session_id TEXT,
|
||||
ADD COLUMN IF NOT EXISTS stripe_payment_intent_id TEXT;
|
||||
|
||||
-- ── 2. RPC: Record a Stripe payment against a wholesale order ─────────────────
|
||||
DROP FUNCTION IF EXISTS public.record_wholesale_payment(UUID, NUMERIC, TEXT);
|
||||
CREATE OR REPLACE FUNCTION public.record_wholesale_payment(
|
||||
p_order_id UUID DEFAULT NULL,
|
||||
p_amount NUMERIC DEFAULT NULL,
|
||||
p_stripe_payment_intent_id TEXT DEFAULT NULL
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_order RECORD;
|
||||
v_new_paid NUMERIC(10,2);
|
||||
BEGIN
|
||||
-- Fetch current order state
|
||||
SELECT id, subtotal, deposit_paid, deposit_required, balance_due
|
||||
INTO v_order
|
||||
FROM wholesale_orders
|
||||
WHERE id = p_order_id;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Order not found');
|
||||
END IF;
|
||||
|
||||
-- Compute new deposit_paid before the update so balance_due uses the correct value
|
||||
v_new_paid := v_order.deposit_paid + p_amount;
|
||||
|
||||
-- Record the payment in the deposits table
|
||||
INSERT INTO wholesale_deposits (wholesale_order_id, amount, payment_method, reference, recorded_by)
|
||||
VALUES (
|
||||
p_order_id,
|
||||
p_amount,
|
||||
'stripe',
|
||||
p_stripe_payment_intent_id,
|
||||
auth.uid()
|
||||
);
|
||||
|
||||
-- Update order
|
||||
UPDATE wholesale_orders
|
||||
SET
|
||||
deposit_paid = v_new_paid,
|
||||
balance_due = GREATEST(subtotal - v_new_paid, 0),
|
||||
payment_status = CASE
|
||||
WHEN subtotal - v_new_paid <= 0 THEN 'paid'
|
||||
ELSE 'partial'
|
||||
END,
|
||||
stripe_payment_intent_id = COALESCE(p_stripe_payment_intent_id, stripe_payment_intent_id),
|
||||
updated_at = now()
|
||||
WHERE id = p_order_id;
|
||||
|
||||
-- Advance status from awaiting_deposit if deposit now covers requirement
|
||||
UPDATE wholesale_orders
|
||||
SET status = 'pending'
|
||||
WHERE id = p_order_id
|
||||
AND status = 'awaiting_deposit'
|
||||
AND v_new_paid >= deposit_required;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'success', true,
|
||||
'new_balance_due', GREATEST(v_order.subtotal - v_new_paid, 0)
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMIT;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,92 @@
|
||||
-- Migration 051: Customer-Specific Pricing Overrides
|
||||
-- Allows admin to set custom per-customer per-product prices
|
||||
-- that override the standard product price tiers.
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ── 1. Wholesale Customer Product Pricing table ────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS public.wholesale_customer_product_pricing (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
customer_id UUID NOT NULL REFERENCES wholesale_customers(id) ON DELETE CASCADE,
|
||||
product_id UUID NOT NULL REFERENCES wholesale_products(id) ON DELETE CASCADE,
|
||||
custom_unit_price NUMERIC(10,2) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (customer_id, product_id)
|
||||
);
|
||||
|
||||
ALTER TABLE public.wholesale_customer_product_pricing ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Admins can manage overrides
|
||||
DROP POLICY IF EXISTS "brand_admin_manage_pricing_overrides" ON wholesale_customer_product_pricing;
|
||||
CREATE POLICY "brand_admin_manage_pricing_overrides" ON wholesale_customer_product_pricing
|
||||
FOR ALL USING (
|
||||
current_setting('app.settings.role', true)::TEXT IN ('brand_admin', 'platform_admin')
|
||||
);
|
||||
|
||||
-- ── 2. RPC: Get all pricing overrides for a customer ─────────────────────────
|
||||
DROP FUNCTION IF EXISTS public.get_wholesale_customer_pricing(UUID);
|
||||
CREATE OR REPLACE FUNCTION public.get_wholesale_customer_pricing(p_customer_id UUID DEFAULT NULL)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_result JSONB;
|
||||
BEGIN
|
||||
SELECT jsonb_agg(jsonb_build_object(
|
||||
'product_id', wcpp.product_id,
|
||||
'custom_unit_price', wcpp.custom_unit_price,
|
||||
'product_name', wp.name
|
||||
)) INTO v_result
|
||||
FROM wholesale_customer_product_pricing wcpp
|
||||
JOIN wholesale_products wp ON wp.id = wcpp.product_id
|
||||
WHERE wcpp.customer_id = p_customer_id;
|
||||
|
||||
RETURN COALESCE(v_result, '[]'::JSONB);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── 3. RPC: Upsert a single pricing override ─────────────────────────────────
|
||||
DROP FUNCTION IF EXISTS public.upsert_wholesale_customer_pricing(UUID, UUID, NUMERIC);
|
||||
CREATE OR REPLACE FUNCTION public.upsert_wholesale_customer_pricing(
|
||||
p_customer_id UUID DEFAULT NULL,
|
||||
p_product_id UUID DEFAULT NULL,
|
||||
p_custom_unit_price NUMERIC DEFAULT NULL
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_id UUID;
|
||||
BEGIN
|
||||
INSERT INTO wholesale_customer_product_pricing (customer_id, product_id, custom_unit_price)
|
||||
VALUES (p_customer_id, p_product_id, p_custom_unit_price)
|
||||
ON CONFLICT (customer_id, product_id) DO UPDATE SET
|
||||
custom_unit_price = p_custom_unit_price,
|
||||
updated_at = now()
|
||||
RETURNING id INTO v_id;
|
||||
|
||||
RETURN jsonb_build_object('success', true, 'id', v_id);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── 4. RPC: Delete a pricing override ─────────────────────────────────────────
|
||||
DROP FUNCTION IF EXISTS public.delete_wholesale_customer_pricing(UUID, UUID);
|
||||
CREATE OR REPLACE FUNCTION public.delete_wholesale_customer_pricing(
|
||||
p_customer_id UUID DEFAULT NULL,
|
||||
p_product_id UUID DEFAULT NULL
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
DELETE FROM wholesale_customer_product_pricing
|
||||
WHERE customer_id = p_customer_id AND product_id = p_product_id;
|
||||
|
||||
RETURN jsonb_build_object('success', true);
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMIT;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,124 @@
|
||||
-- Migration 052: Credit Limit Enforcement
|
||||
-- Blocks wholesale orders that would exceed the customer's credit limit.
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ── Update create_wholesale_order to enforce credit limits ───────────────────────
|
||||
-- Adds: credit_limit check, outstanding balance calculation, rejection if exceeded
|
||||
DROP FUNCTION IF EXISTS public.create_wholesale_order(
|
||||
UUID, UUID, DATE, JSONB, INTEGER, TEXT
|
||||
);
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.create_wholesale_order(
|
||||
p_brand_id UUID DEFAULT NULL,
|
||||
p_customer_id UUID DEFAULT NULL,
|
||||
p_anticipated_pickup_date DATE DEFAULT NULL,
|
||||
p_items JSONB DEFAULT '[]'::JSONB,
|
||||
p_deposit_percentage INTEGER DEFAULT NULL,
|
||||
p_notes TEXT DEFAULT NULL
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_order_id UUID;
|
||||
v_subtotal NUMERIC(10,2);
|
||||
v_dep_required NUMERIC(10,2) := 0;
|
||||
v_customer RECORD;
|
||||
v_settings RECORD;
|
||||
v_inv_number TEXT;
|
||||
v_inv_token TEXT;
|
||||
v_status TEXT;
|
||||
v_outstanding NUMERIC(10,2);
|
||||
BEGIN
|
||||
-- Resolve customer: credit limit + deposit rules
|
||||
SELECT
|
||||
credit_limit,
|
||||
deposits_enabled,
|
||||
deposit_threshold,
|
||||
deposit_percentage
|
||||
INTO v_customer
|
||||
FROM wholesale_customers
|
||||
WHERE id = p_customer_id;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Customer not found');
|
||||
END IF;
|
||||
|
||||
-- Calculate subtotal
|
||||
v_subtotal := COALESCE(
|
||||
(SELECT SUM(
|
||||
((item->>'quantity')::NUMERIC) * ((item->>'unit_price')::NUMERIC)
|
||||
) FROM jsonb_array_elements(p_items) AS item),
|
||||
0
|
||||
);
|
||||
|
||||
-- Credit limit enforcement: reject if order would exceed limit
|
||||
IF v_customer.credit_limit > 0 THEN
|
||||
SELECT COALESCE(SUM(balance_due), 0) INTO v_outstanding
|
||||
FROM wholesale_orders
|
||||
WHERE customer_id = p_customer_id
|
||||
AND status != 'fulfilled';
|
||||
|
||||
IF v_outstanding + v_subtotal > v_customer.credit_limit THEN
|
||||
RETURN jsonb_build_object(
|
||||
'success', false,
|
||||
'error', 'Order exceeds available credit. Please reduce the order size or pay outstanding balance.',
|
||||
'credit_limit', v_customer.credit_limit,
|
||||
'outstanding_balance', v_outstanding,
|
||||
'order_total', v_subtotal
|
||||
);
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- Generate TC-XXXXXX invoice number using RANDOM()
|
||||
v_inv_number := 'TC-' || LPAD(FLOOR(RANDOM() * 1000000)::INT::TEXT, 6, '0');
|
||||
-- Generate a 32-char hex invoice token using md5 of random + timestamp
|
||||
v_inv_token := md5(random()::text || clock_timestamp()::text || now()::text || p_brand_id::text);
|
||||
|
||||
-- Determine if deposit required
|
||||
IF v_customer.deposits_enabled THEN
|
||||
IF v_customer.deposit_threshold IS NULL OR v_subtotal >= v_customer.deposit_threshold THEN
|
||||
v_dep_required := ROUND(v_subtotal * COALESCE(v_customer.deposit_percentage, 0) / 100.0, 2);
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
v_status := CASE WHEN v_dep_required > 0 THEN 'awaiting_deposit' ELSE 'pending' END;
|
||||
|
||||
INSERT INTO wholesale_orders (
|
||||
brand_id, customer_id, status, anticipated_pickup_date,
|
||||
subtotal, deposit_required, deposit_paid, balance_due,
|
||||
deposit_percentage, internal_notes, invoice_number, invoice_token
|
||||
) VALUES (
|
||||
p_brand_id, p_customer_id, v_status, p_anticipated_pickup_date,
|
||||
v_subtotal, v_dep_required, 0, v_subtotal,
|
||||
p_deposit_percentage, p_notes,
|
||||
v_inv_number, v_inv_token
|
||||
) RETURNING id INTO v_order_id;
|
||||
|
||||
-- Insert line items
|
||||
IF jsonb_array_length(p_items) > 0 THEN
|
||||
INSERT INTO wholesale_order_items (wholesale_order_id, product_id, quantity, unit_price, line_total)
|
||||
SELECT
|
||||
v_order_id,
|
||||
(item->>'product_id')::UUID,
|
||||
(item->>'quantity')::NUMERIC,
|
||||
(item->>'unit_price')::NUMERIC,
|
||||
((item->>'quantity')::NUMERIC) * ((item->>'unit_price')::NUMERIC)
|
||||
FROM jsonb_array_elements(p_items) AS item;
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'success', true,
|
||||
'order_id', v_order_id,
|
||||
'invoice_number', v_inv_number,
|
||||
'status', v_status,
|
||||
'deposit_required', v_dep_required,
|
||||
'subtotal', v_subtotal
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMIT;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,93 @@
|
||||
-- Migration 053: Wholesale Bulk Actions
|
||||
-- Bulk fulfill and bulk deposit record RPCs for admin order management.
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ── Bulk fulfill: mark multiple orders as fulfilled in one call ─────────────────
|
||||
DROP FUNCTION IF EXISTS public.bulk_fulfill_wholesale_orders(UUID[], UUID);
|
||||
CREATE OR REPLACE FUNCTION public.bulk_fulfill_wholesale_orders(
|
||||
p_order_ids UUID[] DEFAULT NULL,
|
||||
p_by UUID DEFAULT NULL
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_count INTEGER := 0;
|
||||
BEGIN
|
||||
UPDATE wholesale_orders
|
||||
SET
|
||||
fulfillment_status = 'fulfilled',
|
||||
payment_status = CASE WHEN balance_due <= 0 THEN 'paid' ELSE payment_status END,
|
||||
status = 'fulfilled',
|
||||
fulfilled_at = now(),
|
||||
fulfilled_by = p_by,
|
||||
updated_at = now()
|
||||
WHERE id = ANY(p_order_ids)
|
||||
AND fulfillment_status != 'fulfilled';
|
||||
|
||||
GET DIAGNOSTICS v_count = ROW_COUNT;
|
||||
|
||||
RETURN jsonb_build_object('success', true, 'count', v_count);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── Bulk record deposit: record a deposit on multiple orders ─────────────────────
|
||||
DROP FUNCTION IF EXISTS public.bulk_record_wholesale_deposit(UUID[], NUMERIC, TEXT, TEXT, UUID);
|
||||
CREATE OR REPLACE FUNCTION public.bulk_record_wholesale_deposit(
|
||||
p_order_ids UUID[] DEFAULT NULL,
|
||||
p_amount NUMERIC DEFAULT NULL,
|
||||
p_method TEXT DEFAULT 'cash',
|
||||
p_reference TEXT DEFAULT NULL,
|
||||
p_recorded_by UUID DEFAULT NULL
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_order RECORD;
|
||||
v_new_paid NUMERIC(10,2);
|
||||
v_count INTEGER := 0;
|
||||
BEGIN
|
||||
FOR v_order IN
|
||||
SELECT id, subtotal, deposit_paid, deposit_required, balance_due
|
||||
FROM wholesale_orders
|
||||
WHERE id = ANY(p_order_ids)
|
||||
AND status != 'fulfilled'
|
||||
LOOP
|
||||
-- Compute new deposit_paid before the update so balance_due uses the correct value
|
||||
v_new_paid := v_order.deposit_paid + p_amount;
|
||||
|
||||
-- Record deposit
|
||||
INSERT INTO wholesale_deposits (wholesale_order_id, amount, payment_method, reference, recorded_by)
|
||||
VALUES (v_order.id, p_amount, p_method, p_reference, p_recorded_by);
|
||||
|
||||
-- Update order
|
||||
UPDATE wholesale_orders
|
||||
SET
|
||||
deposit_paid = v_new_paid,
|
||||
balance_due = GREATEST(subtotal - v_new_paid, 0),
|
||||
payment_status = CASE
|
||||
WHEN subtotal - v_new_paid <= 0 THEN 'paid'
|
||||
ELSE payment_status
|
||||
END,
|
||||
updated_at = now()
|
||||
WHERE id = v_order.id;
|
||||
|
||||
-- Advance from awaiting_deposit if deposit now covers requirement
|
||||
UPDATE wholesale_orders
|
||||
SET status = 'pending'
|
||||
WHERE id = v_order.id
|
||||
AND status = 'awaiting_deposit'
|
||||
AND v_new_paid >= deposit_required;
|
||||
|
||||
v_count := v_count + 1;
|
||||
END LOOP;
|
||||
|
||||
RETURN jsonb_build_object('success', true, 'count', v_count);
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMIT;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,160 @@
|
||||
-- Migration 054: Wholesale Email Notifications
|
||||
-- Notification queue: stores email intents to be processed by an external
|
||||
-- email service (Resend, SendGrid, etc.) or a cron webhook.
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ── 1. Notification types enum ───────────────────────────────────────────────
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'wholesale_notification_type') THEN
|
||||
CREATE TYPE wholesale_notification_type AS ENUM (
|
||||
'order_confirmation',
|
||||
'deposit_received',
|
||||
'order_fulfilled'
|
||||
);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ── 2. Notifications table ────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS public.wholesale_notifications (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
|
||||
customer_id UUID NOT NULL REFERENCES wholesale_customers(id) ON DELETE CASCADE,
|
||||
order_id UUID REFERENCES wholesale_orders(id) ON DELETE SET NULL,
|
||||
type wholesale_notification_type NOT NULL,
|
||||
email_to TEXT NOT NULL,
|
||||
email_cc TEXT,
|
||||
subject TEXT NOT NULL,
|
||||
body_html TEXT,
|
||||
body_text TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
-- 'pending' | 'sent' | 'failed' | 'skipped'
|
||||
sent_at TIMESTAMPTZ,
|
||||
error_message TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE public.wholesale_notifications ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS "brand_admin_manage_notifications" ON wholesale_notifications;
|
||||
CREATE POLICY "brand_admin_manage_notifications" ON wholesale_notifications
|
||||
FOR ALL USING (
|
||||
current_setting('app.settings.role', true)::TEXT IN ('brand_admin', 'platform_admin')
|
||||
AND (
|
||||
current_setting('app.settings.role', true)::TEXT = 'platform_admin'
|
||||
OR brand_id = current_setting('app.settings.brand_id', true)::UUID
|
||||
)
|
||||
);
|
||||
|
||||
-- ── 3. RPC: Enqueue a wholesale notification ─────────────────────────────────
|
||||
DROP FUNCTION IF EXISTS public.enqueue_wholesale_notification(
|
||||
UUID, UUID, UUID, wholesale_notification_type, TEXT, TEXT, TEXT, TEXT, TEXT
|
||||
);
|
||||
CREATE OR REPLACE FUNCTION public.enqueue_wholesale_notification(
|
||||
p_brand_id UUID DEFAULT NULL,
|
||||
p_customer_id UUID DEFAULT NULL,
|
||||
p_order_id UUID DEFAULT NULL,
|
||||
p_type wholesale_notification_type DEFAULT NULL,
|
||||
p_email_to TEXT DEFAULT NULL,
|
||||
p_email_cc TEXT DEFAULT NULL,
|
||||
p_subject TEXT DEFAULT NULL,
|
||||
p_body_html TEXT DEFAULT NULL,
|
||||
p_body_text TEXT DEFAULT NULL
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_id UUID;
|
||||
BEGIN
|
||||
INSERT INTO wholesale_notifications (
|
||||
brand_id, customer_id, order_id, type,
|
||||
email_to, email_cc, subject, body_html, body_text
|
||||
)
|
||||
VALUES (
|
||||
p_brand_id, p_customer_id, p_order_id, p_type,
|
||||
p_email_to, p_email_cc, p_subject, p_body_html, p_body_text
|
||||
)
|
||||
RETURNING id INTO v_id;
|
||||
|
||||
RETURN jsonb_build_object('success', true, 'id', v_id);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── 4. RPC: Get pending notifications (for external processor / cron) ───────────
|
||||
DROP FUNCTION IF EXISTS public.get_wholesale_pending_notifications(UUID, INTEGER);
|
||||
CREATE OR REPLACE FUNCTION public.get_wholesale_pending_notifications(
|
||||
p_brand_id UUID DEFAULT NULL,
|
||||
p_limit INTEGER DEFAULT 50
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_result JSONB;
|
||||
BEGIN
|
||||
SELECT jsonb_agg(t ORDER BY t.created_at ASC) INTO v_result
|
||||
FROM (
|
||||
SELECT
|
||||
wn.id, wn.type, wn.email_to, wn.email_cc,
|
||||
wn.subject, wn.body_html, wn.body_text,
|
||||
wn.brand_id, wn.customer_id, wn.order_id,
|
||||
ws.invoice_business_name, ws.invoice_business_email
|
||||
FROM wholesale_notifications wn
|
||||
LEFT JOIN wholesale_settings ws ON ws.brand_id = wn.brand_id
|
||||
WHERE wn.status = 'pending'
|
||||
AND (p_brand_id IS NULL OR wn.brand_id = p_brand_id)
|
||||
ORDER BY wn.created_at ASC
|
||||
LIMIT p_limit
|
||||
) t;
|
||||
RETURN COALESCE(v_result, '[]'::JSONB);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── 5. RPC: Mark notification as sent ─────────────────────────────────────────
|
||||
DROP FUNCTION IF EXISTS public.mark_wholesale_notification_sent(UUID, TEXT);
|
||||
CREATE OR REPLACE FUNCTION public.mark_wholesale_notification_sent(
|
||||
p_notification_id UUID DEFAULT NULL,
|
||||
p_error TEXT DEFAULT NULL
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
UPDATE wholesale_notifications
|
||||
SET
|
||||
status = CASE WHEN p_error IS NULL THEN 'sent' ELSE 'failed' END,
|
||||
error_message = p_error,
|
||||
sent_at = CASE WHEN p_error IS NULL THEN now() ELSE sent_at END,
|
||||
updated_at = now()
|
||||
WHERE id = p_notification_id;
|
||||
|
||||
RETURN jsonb_build_object('success', true);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── 6. RPC: Get notification stats per brand ──────────────────────────────────
|
||||
DROP FUNCTION IF EXISTS public.get_wholesale_notification_stats(UUID);
|
||||
CREATE OR REPLACE FUNCTION public.get_wholesale_notification_stats(p_brand_id UUID DEFAULT NULL)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_result JSONB;
|
||||
BEGIN
|
||||
SELECT jsonb_build_object(
|
||||
'pending', (SELECT COUNT(*) FROM wholesale_notifications WHERE brand_id = p_brand_id AND status = 'pending'),
|
||||
'sent', (SELECT COUNT(*) FROM wholesale_notifications WHERE brand_id = p_brand_id AND status = 'sent'),
|
||||
'failed', (SELECT COUNT(*) FROM wholesale_notifications WHERE brand_id = p_brand_id AND status = 'failed'),
|
||||
'total', (SELECT COUNT(*) FROM wholesale_notifications WHERE brand_id = p_brand_id)
|
||||
) INTO v_result;
|
||||
|
||||
RETURN v_result;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMIT;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,154 @@
|
||||
-- Migration 055: Team Notification Email + Pickup Reminder Types
|
||||
-- - Adds notification_email field to wholesale_settings (team inbox per brand)
|
||||
-- - Adds pickup_reminder and unclaimed_pickup to wholesale_notification_type enum
|
||||
-- - Updates get_wholesale_settings to return notification_email
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ── 1. Add notification_email column ─────────────────────────────────────────
|
||||
ALTER TABLE public.wholesale_settings
|
||||
ADD COLUMN IF NOT EXISTS notification_email TEXT;
|
||||
|
||||
-- ── 2. Extend notification type enum ────────────────────────────────────────────
|
||||
ALTER TYPE wholesale_notification_type ADD VALUE IF NOT EXISTS 'pickup_reminder';
|
||||
ALTER TYPE wholesale_notification_type ADD VALUE IF NOT EXISTS 'unclaimed_pickup';
|
||||
|
||||
-- ── 3. Update get_wholesale_settings to return all settings fields ──────────────
|
||||
-- Recreate to include notification_email (new) and all invoice fields (from 049)
|
||||
DROP FUNCTION IF EXISTS public.get_wholesale_settings(UUID);
|
||||
CREATE OR REPLACE FUNCTION public.get_wholesale_settings(p_brand_id UUID DEFAULT NULL)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_result JSONB;
|
||||
BEGIN
|
||||
SELECT jsonb_build_object(
|
||||
'id', id,
|
||||
'brand_id', brand_id,
|
||||
'portal_page_id', portal_page_id,
|
||||
'price_sheet_page_id', price_sheet_page_id,
|
||||
'require_approval', require_approval,
|
||||
'min_order_amount', min_order_amount,
|
||||
'online_payment_enabled', online_payment_enabled,
|
||||
'wholesale_enabled', wholesale_enabled,
|
||||
'pickup_location', pickup_location,
|
||||
'fob_location', fob_location,
|
||||
'from_email', from_email,
|
||||
'invoice_business_name', invoice_business_name,
|
||||
'invoice_business_address', invoice_business_address,
|
||||
'invoice_business_phone', invoice_business_phone,
|
||||
'invoice_business_email', invoice_business_email,
|
||||
'invoice_business_website', invoice_business_website,
|
||||
'notification_email', notification_email,
|
||||
'last_invoice_number', last_invoice_number
|
||||
) INTO v_result
|
||||
FROM wholesale_settings
|
||||
WHERE brand_id = p_brand_id;
|
||||
RETURN v_result;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── 4. Add get_wholesale_overdue_orders RPC ─────────────────────────────────────
|
||||
-- Returns fulfilled orders past their anticipated pickup date with customer + settings info
|
||||
CREATE OR REPLACE FUNCTION public.get_wholesale_overdue_orders()
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_result JSONB;
|
||||
BEGIN
|
||||
SELECT jsonb_agg(t) INTO v_result
|
||||
FROM (
|
||||
SELECT
|
||||
wo.id,
|
||||
wo.brand_id,
|
||||
wo.customer_id,
|
||||
wo.invoice_number,
|
||||
wo.anticipated_pickup_date,
|
||||
wc.email AS customer_email,
|
||||
ws.pickup_location,
|
||||
ws.notification_email,
|
||||
ws.from_email,
|
||||
ws.invoice_business_email
|
||||
FROM wholesale_orders wo
|
||||
JOIN wholesale_customers wc ON wo.customer_id = wc.id
|
||||
LEFT JOIN wholesale_settings ws ON wo.brand_id = ws.brand_id
|
||||
WHERE wo.status = 'fulfilled'
|
||||
AND wo.fulfillment_status = 'fulfilled'
|
||||
AND wo.anticipated_pickup_date < CURRENT_DATE
|
||||
ORDER BY wo.anticipated_pickup_date ASC
|
||||
LIMIT 100
|
||||
) t;
|
||||
RETURN COALESCE(v_result, '[]'::JSONB);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── 5. Update upsert_wholesale_settings to accept notification_email ──────────────
|
||||
DROP FUNCTION IF EXISTS public.upsert_wholesale_settings(
|
||||
UUID, UUID, UUID, BOOLEAN, NUMERIC, BOOLEAN, BOOLEAN, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT
|
||||
);
|
||||
CREATE OR REPLACE FUNCTION public.upsert_wholesale_settings(
|
||||
p_brand_id UUID DEFAULT NULL,
|
||||
p_portal_page_id UUID DEFAULT NULL,
|
||||
p_price_sheet_page_id UUID DEFAULT NULL,
|
||||
p_require_approval BOOLEAN DEFAULT NULL,
|
||||
p_min_order_amount NUMERIC DEFAULT NULL,
|
||||
p_online_payment_enabled BOOLEAN DEFAULT NULL,
|
||||
p_wholesale_enabled BOOLEAN DEFAULT NULL,
|
||||
p_pickup_location TEXT DEFAULT NULL,
|
||||
p_fob_location TEXT DEFAULT NULL,
|
||||
p_from_email TEXT DEFAULT NULL,
|
||||
p_invoice_business_name TEXT DEFAULT NULL,
|
||||
p_invoice_business_address TEXT DEFAULT NULL,
|
||||
p_invoice_business_phone TEXT DEFAULT NULL,
|
||||
p_invoice_business_email TEXT DEFAULT NULL,
|
||||
p_invoice_business_website TEXT DEFAULT NULL,
|
||||
p_notification_email TEXT DEFAULT NULL
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
INSERT INTO wholesale_settings (
|
||||
brand_id, portal_page_id, price_sheet_page_id,
|
||||
require_approval, min_order_amount, online_payment_enabled,
|
||||
wholesale_enabled, pickup_location, fob_location, from_email,
|
||||
invoice_business_name, invoice_business_address,
|
||||
invoice_business_phone, invoice_business_email, invoice_business_website,
|
||||
notification_email
|
||||
)
|
||||
VALUES (
|
||||
p_brand_id, p_portal_page_id, p_price_sheet_page_id,
|
||||
COALESCE(p_require_approval, true),
|
||||
p_min_order_amount, COALESCE(p_online_payment_enabled, false),
|
||||
COALESCE(p_wholesale_enabled, true),
|
||||
p_pickup_location, p_fob_location, p_from_email,
|
||||
p_invoice_business_name, p_invoice_business_address,
|
||||
p_invoice_business_phone, p_invoice_business_email, p_invoice_business_website,
|
||||
p_notification_email
|
||||
)
|
||||
ON CONFLICT (brand_id) DO UPDATE SET
|
||||
portal_page_id = COALESCE(p_portal_page_id, wholesale_settings.portal_page_id),
|
||||
price_sheet_page_id = COALESCE(p_price_sheet_page_id, wholesale_settings.price_sheet_page_id),
|
||||
require_approval = COALESCE(p_require_approval, wholesale_settings.require_approval),
|
||||
min_order_amount = COALESCE(p_min_order_amount, wholesale_settings.min_order_amount),
|
||||
online_payment_enabled = COALESCE(p_online_payment_enabled, wholesale_settings.online_payment_enabled),
|
||||
wholesale_enabled = COALESCE(p_wholesale_enabled, wholesale_settings.wholesale_enabled),
|
||||
pickup_location = COALESCE(p_pickup_location, wholesale_settings.pickup_location),
|
||||
fob_location = COALESCE(p_fob_location, wholesale_settings.fob_location),
|
||||
from_email = COALESCE(p_from_email, wholesale_settings.from_email),
|
||||
invoice_business_name = COALESCE(p_invoice_business_name, wholesale_settings.invoice_business_name),
|
||||
invoice_business_address = COALESCE(p_invoice_business_address, wholesale_settings.invoice_business_address),
|
||||
invoice_business_phone = COALESCE(p_invoice_business_phone, wholesale_settings.invoice_business_phone),
|
||||
invoice_business_email = COALESCE(p_invoice_business_email, wholesale_settings.invoice_business_email),
|
||||
invoice_business_website = COALESCE(p_invoice_business_website, wholesale_settings.invoice_business_website),
|
||||
notification_email = COALESCE(p_notification_email, wholesale_settings.notification_email),
|
||||
updated_at = now()
|
||||
RETURNING jsonb_build_object('success', true);
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMIT;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Migration 056: Wholesale Price Sheet Notification Type
|
||||
-- Adds price_sheet to the wholesale_notification_type enum.
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TYPE wholesale_notification_type ADD VALUE IF NOT EXISTS 'price_sheet';
|
||||
|
||||
COMMIT;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,123 @@
|
||||
-- Migration 057: Admin Notification Recipients
|
||||
-- Stores notification recipient list per brand on wholesale_settings.
|
||||
-- Safe to run multiple times; uses IF NOT EXISTS and COALESCE defaults.
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ── 1. Add notification_recipients column ─────────────────────────────────────
|
||||
-- Uses IF NOT EXISTS so this is safe to run if column already exists.
|
||||
ALTER TABLE public.wholesale_settings
|
||||
ADD COLUMN IF NOT EXISTS notification_recipients JSONB NOT NULL
|
||||
DEFAULT '[]'::JSONB;
|
||||
|
||||
-- ── 2. Update get_wholesale_settings ───────────────────────────────────────────
|
||||
-- Always returns notification_recipients: [] instead of null (via COALESCE).
|
||||
DROP FUNCTION IF EXISTS public.get_wholesale_settings(UUID);
|
||||
CREATE OR REPLACE FUNCTION public.get_wholesale_settings(p_brand_id UUID DEFAULT NULL)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_result JSONB;
|
||||
BEGIN
|
||||
SELECT jsonb_build_object(
|
||||
'id', id,
|
||||
'brand_id', brand_id,
|
||||
'portal_page_id', portal_page_id,
|
||||
'price_sheet_page_id', price_sheet_page_id,
|
||||
'require_approval', require_approval,
|
||||
'min_order_amount', min_order_amount,
|
||||
'online_payment_enabled', online_payment_enabled,
|
||||
'wholesale_enabled', wholesale_enabled,
|
||||
'pickup_location', pickup_location,
|
||||
'fob_location', fob_location,
|
||||
'from_email', from_email,
|
||||
'invoice_business_name', invoice_business_name,
|
||||
'invoice_business_address', invoice_business_address,
|
||||
'invoice_business_phone', invoice_business_phone,
|
||||
'invoice_business_email', invoice_business_email,
|
||||
'invoice_business_website', invoice_business_website,
|
||||
'notification_email', notification_email,
|
||||
'notification_recipients', COALESCE(notification_recipients, '[]'::JSONB),
|
||||
'last_invoice_number', last_invoice_number
|
||||
) INTO v_result
|
||||
FROM wholesale_settings
|
||||
WHERE brand_id = p_brand_id;
|
||||
RETURN v_result;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── 3. Update upsert_wholesale_settings ───────────────────────────────────────
|
||||
-- Adds p_notification_recipients (JSONB). Defaults to [] if not passed.
|
||||
-- Safe to re-run: DROP IF EXISTS matches on exact signature.
|
||||
DROP FUNCTION IF EXISTS public.upsert_wholesale_settings(
|
||||
UUID, UUID, UUID, BOOLEAN, NUMERIC, BOOLEAN, BOOLEAN,
|
||||
TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, JSONB
|
||||
);
|
||||
CREATE OR REPLACE FUNCTION public.upsert_wholesale_settings(
|
||||
p_brand_id UUID DEFAULT NULL,
|
||||
p_portal_page_id UUID DEFAULT NULL,
|
||||
p_price_sheet_page_id UUID DEFAULT NULL,
|
||||
p_require_approval BOOLEAN DEFAULT NULL,
|
||||
p_min_order_amount NUMERIC DEFAULT NULL,
|
||||
p_online_payment_enabled BOOLEAN DEFAULT NULL,
|
||||
p_wholesale_enabled BOOLEAN DEFAULT NULL,
|
||||
p_pickup_location TEXT DEFAULT NULL,
|
||||
p_fob_location TEXT DEFAULT NULL,
|
||||
p_from_email TEXT DEFAULT NULL,
|
||||
p_invoice_business_name TEXT DEFAULT NULL,
|
||||
p_invoice_business_address TEXT DEFAULT NULL,
|
||||
p_invoice_business_phone TEXT DEFAULT NULL,
|
||||
p_invoice_business_email TEXT DEFAULT NULL,
|
||||
p_invoice_business_website TEXT DEFAULT NULL,
|
||||
p_notification_email TEXT DEFAULT NULL,
|
||||
p_notification_recipients JSONB DEFAULT NULL
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
INSERT INTO wholesale_settings (
|
||||
brand_id, portal_page_id, price_sheet_page_id,
|
||||
require_approval, min_order_amount, online_payment_enabled,
|
||||
wholesale_enabled, pickup_location, fob_location, from_email,
|
||||
invoice_business_name, invoice_business_address,
|
||||
invoice_business_phone, invoice_business_email, invoice_business_website,
|
||||
notification_email, notification_recipients
|
||||
)
|
||||
VALUES (
|
||||
p_brand_id, p_portal_page_id, p_price_sheet_page_id,
|
||||
COALESCE(p_require_approval, true),
|
||||
p_min_order_amount, COALESCE(p_online_payment_enabled, false),
|
||||
COALESCE(p_wholesale_enabled, true),
|
||||
p_pickup_location, p_fob_location, p_from_email,
|
||||
p_invoice_business_name, p_invoice_business_address,
|
||||
p_invoice_business_phone, p_invoice_business_email, p_invoice_business_website,
|
||||
p_notification_email,
|
||||
COALESCE(p_notification_recipients, '[]'::JSONB)
|
||||
)
|
||||
ON CONFLICT (brand_id) DO UPDATE SET
|
||||
portal_page_id = COALESCE(p_portal_page_id, wholesale_settings.portal_page_id),
|
||||
price_sheet_page_id = COALESCE(p_price_sheet_page_id, wholesale_settings.price_sheet_page_id),
|
||||
require_approval = COALESCE(p_require_approval, wholesale_settings.require_approval),
|
||||
min_order_amount = COALESCE(p_min_order_amount, wholesale_settings.min_order_amount),
|
||||
online_payment_enabled = COALESCE(p_online_payment_enabled, wholesale_settings.online_payment_enabled),
|
||||
wholesale_enabled = COALESCE(p_wholesale_enabled, wholesale_settings.wholesale_enabled),
|
||||
pickup_location = COALESCE(p_pickup_location, wholesale_settings.pickup_location),
|
||||
fob_location = COALESCE(p_fob_location, wholesale_settings.fob_location),
|
||||
from_email = COALESCE(p_from_email, wholesale_settings.from_email),
|
||||
invoice_business_name = COALESCE(p_invoice_business_name, wholesale_settings.invoice_business_name),
|
||||
invoice_business_address = COALESCE(p_invoice_business_address, wholesale_settings.invoice_business_address),
|
||||
invoice_business_phone = COALESCE(p_invoice_business_phone, wholesale_settings.invoice_business_phone),
|
||||
invoice_business_email = COALESCE(p_invoice_business_email, wholesale_settings.invoice_business_email),
|
||||
invoice_business_website = COALESCE(p_invoice_business_website, wholesale_settings.invoice_business_website),
|
||||
notification_email = COALESCE(p_notification_email, wholesale_settings.notification_email),
|
||||
notification_recipients = COALESCE(p_notification_recipients, wholesale_settings.notification_recipients),
|
||||
updated_at = now()
|
||||
RETURNING jsonb_build_object('success', true);
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMIT;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,162 @@
|
||||
-- Migration 058: Fix get_wholesale_orders and get_wholesale_customer_orders
|
||||
-- Fixes: jsonb_build_object cannot be used inside jsonb_agg with FILTER.
|
||||
-- Replaced with correlated subquery approach that is valid in Supabase Postgres.
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ── 1. get_wholesale_orders ────────────────────────────────────────────────────
|
||||
DROP FUNCTION IF EXISTS public.get_wholesale_orders(UUID);
|
||||
CREATE OR REPLACE FUNCTION public.get_wholesale_orders(p_brand_id UUID DEFAULT NULL)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_result JSONB;
|
||||
BEGIN
|
||||
SELECT jsonb_agg(t ORDER BY t.created_at DESC) INTO v_result
|
||||
FROM (
|
||||
SELECT
|
||||
wo.id,
|
||||
wo.status,
|
||||
wo.fulfillment_status,
|
||||
wo.payment_status,
|
||||
wo.anticipated_pickup_date,
|
||||
wo.subtotal,
|
||||
wo.deposit_required,
|
||||
wo.deposit_paid,
|
||||
wo.balance_due,
|
||||
wo.invoice_number,
|
||||
wo.assigned_employee_id,
|
||||
wo.created_at,
|
||||
wo.updated_at,
|
||||
wo.fulfilled_at,
|
||||
wc.company_name,
|
||||
wc.contact_name,
|
||||
wc.email AS customer_email,
|
||||
COALESCE(
|
||||
(
|
||||
SELECT jsonb_agg(
|
||||
CASE WHEN woi.id IS NOT NULL THEN
|
||||
jsonb_build_object(
|
||||
'id', woi.id,
|
||||
'product_name', wp.name,
|
||||
'quantity', woi.quantity,
|
||||
'unit_price', woi.unit_price,
|
||||
'line_total', woi.line_total
|
||||
)
|
||||
END
|
||||
)
|
||||
FROM wholesale_order_items woi
|
||||
LEFT JOIN wholesale_products wp ON woi.product_id = wp.id
|
||||
WHERE woi.wholesale_order_id = wo.id
|
||||
),
|
||||
'[]'::JSONB
|
||||
) AS items
|
||||
FROM wholesale_orders wo
|
||||
JOIN wholesale_customers wc ON wo.customer_id = wc.id
|
||||
WHERE wo.brand_id = p_brand_id
|
||||
ORDER BY wo.created_at DESC
|
||||
LIMIT 500
|
||||
) t;
|
||||
RETURN COALESCE(v_result, '[]'::JSONB);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── 2. get_wholesale_customer_orders ─────────────────────────────────────────
|
||||
DROP FUNCTION IF EXISTS public.get_wholesale_customer_orders(UUID);
|
||||
CREATE OR REPLACE FUNCTION public.get_wholesale_customer_orders(p_customer_id UUID DEFAULT NULL)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_result JSONB;
|
||||
BEGIN
|
||||
SELECT jsonb_agg(t ORDER BY t.created_at DESC) INTO v_result
|
||||
FROM (
|
||||
SELECT
|
||||
wo.id,
|
||||
wo.status,
|
||||
wo.fulfillment_status,
|
||||
wo.payment_status,
|
||||
wo.anticipated_pickup_date,
|
||||
wo.subtotal,
|
||||
wo.deposit_required,
|
||||
wo.deposit_paid,
|
||||
wo.balance_due,
|
||||
wo.invoice_number,
|
||||
wo.invoice_token,
|
||||
wo.created_at,
|
||||
wo.updated_at,
|
||||
COALESCE(
|
||||
(
|
||||
SELECT jsonb_agg(
|
||||
CASE WHEN woi.id IS NOT NULL THEN
|
||||
jsonb_build_object(
|
||||
'id', woi.id,
|
||||
'product_name', wp.name,
|
||||
'quantity', woi.quantity,
|
||||
'unit_price', woi.unit_price,
|
||||
'line_total', woi.line_total
|
||||
)
|
||||
END
|
||||
)
|
||||
FROM wholesale_order_items woi
|
||||
LEFT JOIN wholesale_products wp ON woi.product_id = wp.id
|
||||
WHERE woi.wholesale_order_id = wo.id
|
||||
),
|
||||
'[]'::JSONB
|
||||
) AS items
|
||||
FROM wholesale_orders wo
|
||||
WHERE wo.customer_id = p_customer_id
|
||||
ORDER BY wo.created_at DESC
|
||||
LIMIT 100
|
||||
) t;
|
||||
RETURN COALESCE(v_result, '[]'::JSONB);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── 3. get_wholesale_products — simplified (no aggregate, no join issue) ───────
|
||||
DROP FUNCTION IF EXISTS public.get_wholesale_products(UUID);
|
||||
CREATE OR REPLACE FUNCTION public.get_wholesale_products(p_brand_id UUID DEFAULT NULL)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_result JSONB;
|
||||
BEGIN
|
||||
SELECT jsonb_agg(t ORDER BY t.name) INTO v_result
|
||||
FROM (
|
||||
SELECT
|
||||
wp.id,
|
||||
wp.name,
|
||||
wp.description,
|
||||
wp.unit_type,
|
||||
wp.unit_type_custom,
|
||||
wp.availability,
|
||||
wp.qty_available,
|
||||
wp.season_start,
|
||||
wp.season_end,
|
||||
wp.price_tiers,
|
||||
wp.hp_sku,
|
||||
wp.hp_item_id,
|
||||
wp.handling_instructions,
|
||||
wp.storage_warning,
|
||||
wp.loading_notes,
|
||||
wp.product_label,
|
||||
wp.pack_style,
|
||||
wp.container_type,
|
||||
wp.container_size_code,
|
||||
wp.units_per_container,
|
||||
wp.default_pickup_location,
|
||||
wp.created_at
|
||||
FROM wholesale_products wp
|
||||
WHERE wp.brand_id = p_brand_id
|
||||
ORDER BY wp.name
|
||||
) t;
|
||||
RETURN COALESCE(v_result, '[]'::JSONB);
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMIT;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,63 @@
|
||||
-- Migration 059: Order status update + delete RPCs
|
||||
-- Needed by the redesigned Actions column dropdown in WholesaleClient.tsx
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ── update_wholesale_order_status ───────────────────────────────────────────
|
||||
DROP FUNCTION IF EXISTS public.update_wholesale_order_status(UUID, TEXT);
|
||||
CREATE OR REPLACE FUNCTION public.update_wholesale_order_status(
|
||||
p_order_id UUID,
|
||||
p_status TEXT DEFAULT 'pending'
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_order RECORD;
|
||||
BEGIN
|
||||
SELECT id, status INTO v_order FROM wholesale_orders WHERE id = p_order_id FOR UPDATE;
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Order not found');
|
||||
END IF;
|
||||
|
||||
IF v_order.status = 'fulfilled' THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Cannot change status of a fulfilled order');
|
||||
END IF;
|
||||
|
||||
UPDATE wholesale_orders
|
||||
SET status = p_status, updated_at = now()
|
||||
WHERE id = p_order_id;
|
||||
|
||||
RETURN jsonb_build_object('success', true);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── delete_wholesale_order ───────────────────────────────────────────────────
|
||||
DROP FUNCTION IF EXISTS public.delete_wholesale_order(UUID);
|
||||
CREATE OR REPLACE FUNCTION public.delete_wholesale_order(p_order_id UUID)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_order RECORD;
|
||||
BEGIN
|
||||
SELECT id, status INTO v_order FROM wholesale_orders WHERE id = p_order_id FOR UPDATE;
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Order not found');
|
||||
END IF;
|
||||
|
||||
IF v_order.status = 'fulfilled' THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Cannot delete a fulfilled order');
|
||||
END IF;
|
||||
|
||||
DELETE FROM wholesale_order_items WHERE wholesale_order_id = p_order_id;
|
||||
DELETE FROM wholesale_notifications WHERE order_id = p_order_id;
|
||||
DELETE FROM wholesale_orders WHERE id = p_order_id;
|
||||
|
||||
RETURN jsonb_build_object('success', true);
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMIT;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,68 @@
|
||||
-- Migration 060: Wholesale Pickup Orders RPC for Employee Portal
|
||||
-- Returns unfulfilled wholesale orders partitioned by pickup date queue:
|
||||
-- past_due: anticipated_pickup_date < today
|
||||
-- today: anticipated_pickup_date = today
|
||||
-- upcoming: anticipated_pickup_date > today
|
||||
-- Uses correlated subquery pattern (same fix as migration 058).
|
||||
|
||||
BEGIN;
|
||||
|
||||
DROP FUNCTION IF EXISTS public.get_wholesale_pickup_orders(UUID);
|
||||
CREATE OR REPLACE FUNCTION public.get_wholesale_pickup_orders(p_brand_id UUID DEFAULT NULL)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_result JSONB;
|
||||
BEGIN
|
||||
SELECT jsonb_agg(t ORDER BY t.anticipated_pickup_date ASC) INTO v_result
|
||||
FROM (
|
||||
SELECT
|
||||
wo.id,
|
||||
wo.status,
|
||||
wo.fulfillment_status,
|
||||
wo.payment_status,
|
||||
wo.anticipated_pickup_date,
|
||||
wo.subtotal,
|
||||
wo.deposit_required,
|
||||
wo.deposit_paid,
|
||||
wo.balance_due,
|
||||
wo.invoice_number,
|
||||
wo.created_at,
|
||||
wo.updated_at,
|
||||
wo.fulfilled_at,
|
||||
wc.company_name,
|
||||
wc.contact_name,
|
||||
wc.email AS customer_email,
|
||||
wc.phone AS customer_phone,
|
||||
COALESCE(
|
||||
(SELECT jsonb_agg(
|
||||
CASE WHEN woi.id IS NOT NULL THEN
|
||||
jsonb_build_object(
|
||||
'id', woi.id,
|
||||
'product_name', wp.name,
|
||||
'quantity', woi.quantity,
|
||||
'unit_price', woi.unit_price,
|
||||
'line_total', woi.line_total
|
||||
)
|
||||
END
|
||||
)
|
||||
FROM wholesale_order_items woi
|
||||
LEFT JOIN wholesale_products wp ON woi.product_id = wp.id
|
||||
WHERE woi.wholesale_order_id = wo.id),
|
||||
'[]'::JSONB
|
||||
) AS items
|
||||
FROM wholesale_orders wo
|
||||
JOIN wholesale_customers wc ON wo.customer_id = wc.id
|
||||
WHERE wo.brand_id = p_brand_id
|
||||
AND wo.fulfillment_status != 'fulfilled'
|
||||
ORDER BY wo.anticipated_pickup_date ASC
|
||||
LIMIT 500
|
||||
) t;
|
||||
RETURN COALESCE(v_result, '[]'::JSONB);
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMIT;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,167 @@
|
||||
-- Migration 061: Soft-delete for customers and products, order delete guard
|
||||
-- Soft delete via deleted_at TIMESTAMPTZ (never hard-deleted to preserve FK integrity)
|
||||
-- Updated get_wholesale_* to filter deleted_at IS NULL
|
||||
-- Updated delete_wholesale_order to block paid orders
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ── 1. Add deleted_at columns ─────────────────────────────────────────────────
|
||||
ALTER TABLE public.wholesale_customers ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
ALTER TABLE public.wholesale_products ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
|
||||
-- ── 2. Update get_wholesale_customers to filter soft-deleted ────────────────
|
||||
DROP FUNCTION IF EXISTS public.get_wholesale_customers(UUID);
|
||||
CREATE OR REPLACE FUNCTION public.get_wholesale_customers(p_brand_id UUID DEFAULT NULL)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_result JSONB;
|
||||
BEGIN
|
||||
SELECT jsonb_agg(t ORDER BY t.created_at DESC) INTO v_result
|
||||
FROM (
|
||||
SELECT
|
||||
id, user_id, company_name, contact_name, email, phone,
|
||||
billing_address, shipping_address, account_status,
|
||||
credit_limit, deposits_enabled, deposit_threshold, deposit_percentage,
|
||||
order_email, invoice_email, admin_notes, role, created_at, updated_at,
|
||||
deleted_at
|
||||
FROM wholesale_customers
|
||||
WHERE brand_id = p_brand_id
|
||||
AND deleted_at IS NULL -- ← exclude soft-deleted
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 500
|
||||
) t;
|
||||
RETURN COALESCE(v_result, '[]'::JSONB);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── 3. Update get_wholesale_products to filter soft-deleted ─────────────────
|
||||
DROP FUNCTION IF EXISTS public.get_wholesale_products(UUID);
|
||||
CREATE OR REPLACE FUNCTION public.get_wholesale_products(p_brand_id UUID DEFAULT NULL)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_result JSONB;
|
||||
BEGIN
|
||||
SELECT jsonb_agg(t ORDER BY t.name) INTO v_result
|
||||
FROM (
|
||||
SELECT
|
||||
id, brand_id, rc_product_id, name, description,
|
||||
unit_type, unit_type_custom, availability, qty_available,
|
||||
season_start, season_end, price_tiers, hp_sku, hp_item_id,
|
||||
handling_instructions, storage_warning, loading_notes,
|
||||
product_label, pack_style, container_type, container_size_code,
|
||||
units_per_container, default_pickup_location, created_at, updated_at,
|
||||
deleted_at
|
||||
FROM wholesale_products
|
||||
WHERE brand_id = p_brand_id
|
||||
AND deleted_at IS NULL -- ← exclude soft-deleted
|
||||
ORDER BY name
|
||||
LIMIT 500
|
||||
) t;
|
||||
RETURN COALESCE(v_result, '[]'::JSONB);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── 4. delete_wholesale_customer (soft) ─────────────────────────────────────
|
||||
DROP FUNCTION IF EXISTS public.delete_wholesale_customer(UUID);
|
||||
CREATE OR REPLACE FUNCTION public.delete_wholesale_customer(p_customer_id UUID)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_customer RECORD;
|
||||
v_order_count BIGINT;
|
||||
BEGIN
|
||||
-- Check customer exists
|
||||
SELECT id, company_name INTO v_customer
|
||||
FROM wholesale_customers WHERE id = p_customer_id FOR UPDATE;
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Customer not found');
|
||||
END IF;
|
||||
|
||||
-- Block if any orders exist (active or historical)
|
||||
SELECT COUNT(*) INTO v_order_count
|
||||
FROM wholesale_orders WHERE customer_id = p_customer_id;
|
||||
IF v_order_count > 0 THEN
|
||||
RETURN jsonb_build_object(
|
||||
'success', false,
|
||||
'error', 'Cannot delete customer with existing orders. Disable the account instead.'
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- Soft delete
|
||||
UPDATE wholesale_customers SET deleted_at = now() WHERE id = p_customer_id;
|
||||
RETURN jsonb_build_object('success', true);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── 5. delete_wholesale_product (soft) ─────────────────────────────────────
|
||||
DROP FUNCTION IF EXISTS public.delete_wholesale_product(UUID);
|
||||
CREATE OR REPLACE FUNCTION public.delete_wholesale_product(p_product_id UUID)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_product RECORD;
|
||||
v_usage_count BIGINT;
|
||||
BEGIN
|
||||
-- Check product exists
|
||||
SELECT id, name INTO v_product
|
||||
FROM wholesale_products WHERE id = p_product_id FOR UPDATE;
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Product not found');
|
||||
END IF;
|
||||
|
||||
-- Block if referenced in any order items
|
||||
SELECT COUNT(*) INTO v_usage_count
|
||||
FROM wholesale_order_items WHERE product_id = p_product_id;
|
||||
IF v_usage_count > 0 THEN
|
||||
RETURN jsonb_build_object(
|
||||
'success', false,
|
||||
'error', 'Cannot delete product that is attached to orders. Set availability to unavailable instead.'
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- Soft delete
|
||||
UPDATE wholesale_products SET deleted_at = now() WHERE id = p_product_id;
|
||||
RETURN jsonb_build_object('success', true);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── 6. Strengthen delete_wholesale_order guard ──────────────────────────────
|
||||
DROP FUNCTION IF EXISTS public.delete_wholesale_order(UUID);
|
||||
CREATE OR REPLACE FUNCTION public.delete_wholesale_order(p_order_id UUID)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_order RECORD;
|
||||
BEGIN
|
||||
SELECT id, status, fulfillment_status, payment_status INTO v_order
|
||||
FROM wholesale_orders WHERE id = p_order_id FOR UPDATE;
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Order not found');
|
||||
END IF;
|
||||
|
||||
IF v_order.fulfillment_status = 'fulfilled' THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Cannot delete a fulfilled order.');
|
||||
END IF;
|
||||
|
||||
IF v_order.payment_status = 'paid' THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Cannot delete a paid order.');
|
||||
END IF;
|
||||
|
||||
DELETE FROM wholesale_order_items WHERE wholesale_order_id = p_order_id;
|
||||
DELETE FROM wholesale_notifications WHERE order_id = p_order_id;
|
||||
DELETE FROM wholesale_orders WHERE id = p_order_id;
|
||||
|
||||
RETURN jsonb_build_object('success', true);
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMIT;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,195 @@
|
||||
-- Migration 062: Webhook dispatcher for wholesale events
|
||||
-- Adds webhook_settings table, sync_log table, enqueue RPC, and dispatch route
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ── 1. wholesale_webhook_settings ──────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS public.wholesale_webhook_settings (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
brand_id UUID NOT NULL REFERENCES public.brands(id) ON DELETE CASCADE,
|
||||
url TEXT NOT NULL,
|
||||
secret TEXT NOT NULL, -- HMAC signing secret
|
||||
enabled BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (brand_id)
|
||||
);
|
||||
|
||||
ALTER TABLE public.wholesale_webhook_settings ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY "admins_manage_webhook_settings" ON public.wholesale_webhook_settings
|
||||
FOR ALL USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.admin_users au
|
||||
WHERE au.user_id = auth.uid()
|
||||
AND au.brand_id = wholesale_webhook_settings.brand_id
|
||||
AND au.role IN ('platform_admin', 'brand_admin')
|
||||
)
|
||||
);
|
||||
|
||||
-- ── 2. wholesale_sync_log ──────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS public.wholesale_sync_log (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
brand_id UUID NOT NULL,
|
||||
event_type TEXT NOT NULL, -- order_created | order_fulfilled | deposit_recorded | order_paid
|
||||
order_id UUID,
|
||||
payload JSONB,
|
||||
status TEXT NOT NULL DEFAULT 'pending', -- pending | sent | failed | retrying
|
||||
response TEXT,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
next_retry TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE public.wholesale_sync_log ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY "admins_read_sync_log" ON public.wholesale_sync_log
|
||||
FOR SELECT USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.admin_users au
|
||||
WHERE au.user_id = auth.uid()
|
||||
AND au.brand_id = wholesale_sync_log.brand_id
|
||||
AND au.role IN ('platform_admin', 'brand_admin', 'store_employee')
|
||||
)
|
||||
);
|
||||
|
||||
-- ── 3. enqueue_wholesale_webhook RPC ──────────────────────────────────────
|
||||
DROP FUNCTION IF EXISTS public.enqueue_wholesale_webhook(TEXT, UUID, JSONB, UUID);
|
||||
CREATE OR REPLACE FUNCTION public.enqueue_wholesale_webhook(
|
||||
p_event_type TEXT,
|
||||
p_order_id UUID DEFAULT NULL,
|
||||
p_payload JSONB DEFAULT NULL,
|
||||
p_brand_id UUID DEFAULT NULL
|
||||
)
|
||||
RETURNS UUID
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_log_id UUID;
|
||||
v_brand_id UUID;
|
||||
BEGIN
|
||||
-- Use provided brand_id, or fall back to the first brand with webhooks enabled
|
||||
IF p_brand_id IS NOT NULL THEN
|
||||
SELECT brand_id INTO v_brand_id
|
||||
FROM public.wholesale_webhook_settings
|
||||
WHERE brand_id = p_brand_id AND enabled = true AND url IS NOT NULL AND url != ''
|
||||
LIMIT 1;
|
||||
ELSE
|
||||
SELECT brand_id INTO v_brand_id
|
||||
FROM public.wholesale_webhook_settings
|
||||
WHERE enabled = true AND url IS NOT NULL AND url != ''
|
||||
LIMIT 1;
|
||||
END IF;
|
||||
|
||||
IF NOT FOUND OR v_brand_id IS NULL THEN
|
||||
-- Webhooks not enabled — silent no-op
|
||||
RETURN NULL;
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.wholesale_sync_log (brand_id, event_type, order_id, payload, status)
|
||||
VALUES (v_brand_id, p_event_type, p_order_id, p_payload, 'pending')
|
||||
RETURNING id INTO v_log_id;
|
||||
|
||||
RETURN v_log_id;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── 4. get_pending_webhooks RPC ────────────────────────────────────────────
|
||||
DROP FUNCTION IF EXISTS public.get_pending_webhooks(INTEGER);
|
||||
CREATE OR REPLACE FUNCTION public.get_pending_webhooks(p_limit INTEGER DEFAULT 20)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN COALESCE(
|
||||
(
|
||||
SELECT jsonb_agg(r ORDER BY r.created_at ASC)
|
||||
FROM (
|
||||
SELECT
|
||||
wsl.id,
|
||||
wsl.brand_id,
|
||||
wsl.event_type,
|
||||
wsl.order_id,
|
||||
wsl.payload,
|
||||
wsl.attempts,
|
||||
wsl.status,
|
||||
ws.url,
|
||||
ws.secret
|
||||
FROM wholesale_sync_log wsl
|
||||
JOIN wholesale_webhook_settings ws ON ws.brand_id = wsl.brand_id AND ws.enabled = true
|
||||
WHERE wsl.status IN ('pending', 'retrying')
|
||||
AND (wsl.next_retry IS NULL OR wsl.next_retry <= now())
|
||||
ORDER BY wsl.created_at ASC
|
||||
LIMIT p_limit
|
||||
) r
|
||||
),
|
||||
'[]'::JSONB
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── 5. mark_webhook_sent RPC ───────────────────────────────────────────────
|
||||
DROP FUNCTION IF EXISTS public.mark_webhook_sent(UUID, TEXT);
|
||||
CREATE OR REPLACE FUNCTION public.mark_webhook_sent(p_log_id UUID, p_response TEXT DEFAULT NULL)
|
||||
RETURNS VOID
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
UPDATE wholesale_sync_log
|
||||
SET status = 'sent', response = p_response, attempts = attempts + 1
|
||||
WHERE id = p_log_id;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── 6. mark_webhook_failed RPC ─────────────────────────────────────────────
|
||||
DROP FUNCTION IF EXISTS public.mark_webhook_failed(UUID, TEXT);
|
||||
CREATE OR REPLACE FUNCTION public.mark_webhook_failed(p_log_id UUID, p_response TEXT DEFAULT NULL)
|
||||
RETURNS VOID
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_attempts INTEGER;
|
||||
BEGIN
|
||||
SELECT attempts INTO v_attempts FROM wholesale_sync_log WHERE id = p_log_id FOR UPDATE;
|
||||
IF v_attempts >= 3 THEN
|
||||
UPDATE wholesale_sync_log
|
||||
SET status = 'failed', response = p_response, attempts = attempts + 1
|
||||
WHERE id = p_log_id;
|
||||
ELSE
|
||||
UPDATE wholesale_sync_log
|
||||
SET status = 'retrying',
|
||||
response = p_response,
|
||||
attempts = attempts + 1,
|
||||
next_retry = now() + (attempts + 1 || ' hours')::INTERVAL
|
||||
WHERE id = p_log_id;
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── 7. upsert_wholesale_webhook_settings RPC ─────────────────────────────────
|
||||
DROP FUNCTION IF EXISTS public.upsert_wholesale_webhook_settings(UUID, TEXT, TEXT, BOOLEAN);
|
||||
CREATE OR REPLACE FUNCTION public.upsert_wholesale_webhook_settings(
|
||||
p_brand_id UUID,
|
||||
p_url TEXT,
|
||||
p_secret TEXT,
|
||||
p_enabled BOOLEAN
|
||||
)
|
||||
RETURNS UUID
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_id UUID;
|
||||
BEGIN
|
||||
INSERT INTO public.wholesale_webhook_settings (brand_id, url, secret, enabled)
|
||||
VALUES (p_brand_id, p_url, p_secret, p_enabled)
|
||||
ON CONFLICT (brand_id) DO UPDATE SET
|
||||
url = EXCLUDED.url,
|
||||
secret = EXCLUDED.secret,
|
||||
enabled = EXCLUDED.enabled,
|
||||
updated_at = now()
|
||||
RETURNING id INTO v_id;
|
||||
RETURN v_id;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMIT;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,67 @@
|
||||
-- Migration 063: Auto-create wholesale_settings on first registration
|
||||
-- Ensures new brands get a default wholesale_settings row automatically
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Update register_wholesale_customer to auto-create wholesale_settings if missing
|
||||
CREATE OR REPLACE FUNCTION public.register_wholesale_customer(
|
||||
p_brand_id UUID DEFAULT NULL,
|
||||
p_company_name TEXT DEFAULT NULL,
|
||||
p_contact_name TEXT DEFAULT NULL,
|
||||
p_email TEXT DEFAULT NULL,
|
||||
p_phone TEXT DEFAULT NULL
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_id UUID;
|
||||
v_req_app BOOLEAN := true;
|
||||
BEGIN
|
||||
-- Reject duplicate email for this brand
|
||||
IF EXISTS (SELECT 1 FROM wholesale_customers WHERE brand_id = p_brand_id AND email = p_email) THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'An account with this email already exists.');
|
||||
END IF;
|
||||
|
||||
-- Auto-create wholesale_settings row if it doesn't exist for this brand
|
||||
-- This ensures new brands always have settings before registration proceeds
|
||||
IF NOT EXISTS (SELECT 1 FROM wholesale_settings WHERE brand_id = p_brand_id) THEN
|
||||
INSERT INTO wholesale_settings (brand_id, require_approval, wholesale_enabled, pickup_location)
|
||||
VALUES (p_brand_id, true, true, 'See your order confirmation for pickup details')
|
||||
ON CONFLICT (brand_id) DO NOTHING;
|
||||
END IF;
|
||||
|
||||
-- Read require_approval setting for this brand (now guaranteed to exist)
|
||||
BEGIN
|
||||
SELECT require_approval INTO v_req_app
|
||||
FROM wholesale_settings
|
||||
WHERE brand_id = p_brand_id;
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
v_req_app := true;
|
||||
END;
|
||||
|
||||
INSERT INTO wholesale_customers (
|
||||
brand_id, company_name, contact_name, email, phone,
|
||||
account_status, role
|
||||
) VALUES (
|
||||
p_brand_id,
|
||||
p_company_name,
|
||||
p_contact_name,
|
||||
p_email,
|
||||
p_phone,
|
||||
CASE WHEN COALESCE(v_req_app, true) THEN 'pending_approval' ELSE 'active' END,
|
||||
'buyer'
|
||||
)
|
||||
RETURNING id INTO v_id;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'success', true,
|
||||
'id', v_id,
|
||||
'requires_approval', COALESCE(v_req_app, true)
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMIT;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,163 @@
|
||||
-- Migration 064: Seed wholesale_settings for existing brands + fix RLS for service role
|
||||
-- Also creates a seed function that bypasses RLS for initial setup
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Create a SECURITY DEFINER function that inserts wholesale_settings without RLS
|
||||
-- This is only for initial seeding — not used in normal operation
|
||||
CREATE OR REPLACE FUNCTION public.seed_wholesale_settings(
|
||||
p_brand_id UUID,
|
||||
p_require_approval BOOLEAN DEFAULT true,
|
||||
p_wholesale_enabled BOOLEAN DEFAULT true,
|
||||
p_pickup_location TEXT DEFAULT NULL,
|
||||
p_from_email TEXT DEFAULT NULL,
|
||||
p_invoice_business_name TEXT DEFAULT NULL
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
INSERT INTO wholesale_settings (
|
||||
brand_id, require_approval, wholesale_enabled,
|
||||
pickup_location, from_email, invoice_business_name
|
||||
)
|
||||
VALUES (
|
||||
p_brand_id,
|
||||
COALESCE(p_require_approval, true),
|
||||
COALESCE(p_wholesale_enabled, true),
|
||||
p_pickup_location,
|
||||
p_from_email,
|
||||
p_invoice_business_name
|
||||
)
|
||||
ON CONFLICT (brand_id) DO UPDATE SET
|
||||
require_approval = COALESCE(p_require_approval, wholesale_settings.require_approval),
|
||||
wholesale_enabled = COALESCE(p_wholesale_enabled, wholesale_settings.wholesale_enabled),
|
||||
pickup_location = COALESCE(p_pickup_location, wholesale_settings.pickup_location),
|
||||
from_email = COALESCE(p_from_email, wholesale_settings.from_email),
|
||||
invoice_business_name = COALESCE(p_invoice_business_name, wholesale_settings.invoice_business_name),
|
||||
updated_at = now()
|
||||
RETURNING jsonb_build_object('success', true, 'brand_id', brand_id);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Update register_wholesale_customer to auto-create settings if missing
|
||||
-- This ensures new brands always get settings on first registration
|
||||
CREATE OR REPLACE FUNCTION public.register_wholesale_customer(
|
||||
p_brand_id UUID DEFAULT NULL,
|
||||
p_company_name TEXT DEFAULT NULL,
|
||||
p_contact_name TEXT DEFAULT NULL,
|
||||
p_email TEXT DEFAULT NULL,
|
||||
p_phone TEXT DEFAULT NULL
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_id UUID;
|
||||
v_req_app BOOLEAN := true;
|
||||
BEGIN
|
||||
-- Reject duplicate email for this brand
|
||||
IF EXISTS (SELECT 1 FROM wholesale_customers WHERE brand_id = p_brand_id AND email = p_email) THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'An account with this email already exists.');
|
||||
END IF;
|
||||
|
||||
-- Auto-create wholesale_settings row if it doesn't exist for this brand
|
||||
-- Uses SECURITY DEFINER to bypass RLS — so it works even without service role
|
||||
IF NOT EXISTS (SELECT 1 FROM wholesale_settings WHERE brand_id = p_brand_id) THEN
|
||||
INSERT INTO wholesale_settings (brand_id, require_approval, wholesale_enabled, pickup_location)
|
||||
VALUES (p_brand_id, true, true, 'See your order confirmation for pickup details')
|
||||
ON CONFLICT (brand_id) DO NOTHING;
|
||||
END IF;
|
||||
|
||||
-- Read require_approval setting for this brand
|
||||
BEGIN
|
||||
SELECT require_approval INTO v_req_app
|
||||
FROM wholesale_settings
|
||||
WHERE brand_id = p_brand_id;
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
v_req_app := true;
|
||||
END;
|
||||
|
||||
INSERT INTO wholesale_customers (
|
||||
brand_id, company_name, contact_name, email, phone,
|
||||
account_status, role
|
||||
) VALUES (
|
||||
p_brand_id,
|
||||
p_company_name,
|
||||
p_contact_name,
|
||||
p_email,
|
||||
p_phone,
|
||||
CASE WHEN COALESCE(v_req_app, true) THEN 'pending_approval' ELSE 'active' END,
|
||||
'buyer'
|
||||
)
|
||||
RETURNING id INTO v_id;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'success', true,
|
||||
'id', v_id,
|
||||
'requires_approval', COALESCE(v_req_app, true)
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Also fix get_wholesale_settings to return defaults if no settings exist
|
||||
-- This prevents 406 errors when settings are missing
|
||||
CREATE OR REPLACE FUNCTION public.get_wholesale_settings(p_brand_id UUID DEFAULT NULL)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_result JSONB;
|
||||
BEGIN
|
||||
SELECT jsonb_build_object(
|
||||
'id', id,
|
||||
'brand_id', brand_id,
|
||||
'portal_page_id', portal_page_id,
|
||||
'price_sheet_page_id', price_sheet_page_id,
|
||||
'require_approval', require_approval,
|
||||
'min_order_amount', min_order_amount,
|
||||
'online_payment_enabled', online_payment_enabled,
|
||||
'wholesale_enabled', COALESCE(wholesale_enabled, true),
|
||||
'pickup_location', COALESCE(pickup_location, 'Contact us for pickup details'),
|
||||
'fob_location', fob_location,
|
||||
'from_email', from_email,
|
||||
'invoice_business_name', COALESCE(invoice_business_name, 'Wholesale'),
|
||||
'invoice_business_address', invoice_business_address,
|
||||
'invoice_business_phone', invoice_business_phone,
|
||||
'invoice_business_email', invoice_business_email,
|
||||
'invoice_business_website', invoice_business_website,
|
||||
'last_invoice_number', last_invoice_number
|
||||
) INTO v_result
|
||||
FROM wholesale_settings
|
||||
WHERE brand_id = p_brand_id;
|
||||
|
||||
-- If still null, return safe defaults so client code doesn't break
|
||||
IF v_result IS NULL THEN
|
||||
RETURN jsonb_build_object(
|
||||
'id', NULL,
|
||||
'brand_id', p_brand_id,
|
||||
'portal_page_id', NULL,
|
||||
'price_sheet_page_id', NULL,
|
||||
'require_approval', true,
|
||||
'min_order_amount', 0,
|
||||
'online_payment_enabled', false,
|
||||
'wholesale_enabled', true,
|
||||
'pickup_location', 'Contact us for pickup details',
|
||||
'fob_location', NULL,
|
||||
'from_email', NULL,
|
||||
'invoice_business_name', 'Wholesale',
|
||||
'invoice_business_address', NULL,
|
||||
'invoice_business_phone', NULL,
|
||||
'invoice_business_email', NULL,
|
||||
'invoice_business_website', NULL,
|
||||
'last_invoice_number', 0
|
||||
);
|
||||
END IF;
|
||||
|
||||
RETURN v_result;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMIT;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,43 @@
|
||||
-- Migration 065: Fix get_wholesale_products — remove rc_product_id reference
|
||||
-- The wholesale_products table does not have rc_product_id column,
|
||||
-- but get_wholesale_products RPC (migration 061) references it.
|
||||
-- Also drop the orphaned rc_product_id column if it exists.
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Drop rc_product_id column if it somehow got added (migration 046 planned it but it was never created)
|
||||
-- This is safe IF NOT EXISTS since the column likely doesn't exist
|
||||
ALTER TABLE public.wholesale_products DROP COLUMN IF EXISTS rc_product_id;
|
||||
|
||||
-- Recreate get_wholesale_products without rc_product_id reference
|
||||
DROP FUNCTION IF EXISTS public.get_wholesale_products(UUID);
|
||||
CREATE OR REPLACE FUNCTION public.get_wholesale_products(p_brand_id UUID DEFAULT NULL)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_result JSONB;
|
||||
BEGIN
|
||||
SELECT jsonb_agg(t ORDER BY t.name) INTO v_result
|
||||
FROM (
|
||||
SELECT
|
||||
id, brand_id, name, description,
|
||||
unit_type, unit_type_custom, availability, qty_available,
|
||||
season_start, season_end, price_tiers, hp_sku, hp_item_id,
|
||||
handling_instructions, storage_warning, loading_notes,
|
||||
product_label, pack_style, container_type, container_size_code,
|
||||
units_per_container, default_pickup_location, created_at, updated_at,
|
||||
deleted_at
|
||||
FROM wholesale_products
|
||||
WHERE brand_id = p_brand_id
|
||||
AND deleted_at IS NULL
|
||||
ORDER BY name
|
||||
LIMIT 500
|
||||
) t;
|
||||
RETURN COALESCE(v_result, '[]'::JSONB);
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMIT;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,154 @@
|
||||
-- Migration 066: Auto Square Sync — queue table, enqueue trigger, claim processor
|
||||
-- Applies: square_sync_queue table, enqueue_square_sync() RPC,
|
||||
-- trg_wholesale_products_sync trigger, queue processor endpoint
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ── 1. Sync queue table ──────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS public.square_sync_queue (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
|
||||
sync_type TEXT NOT NULL DEFAULT 'products',
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
enqueued_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
processed_at TIMESTAMPTZ,
|
||||
retry_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT,
|
||||
CONSTRAINT square_sync_queue_status CHECK (status IN ('pending', 'processing', 'done', 'failed'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_square_sync_queue_pending
|
||||
ON square_sync_queue(status, enqueued_at ASC)
|
||||
WHERE status = 'pending';
|
||||
|
||||
-- ── 2. enqueue_square_sync — idempotent, safe for trigger ─────────────────────
|
||||
CREATE OR REPLACE FUNCTION public.enqueue_square_sync(
|
||||
p_brand_id UUID,
|
||||
p_sync_type TEXT DEFAULT 'products'
|
||||
)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM square_sync_queue
|
||||
WHERE brand_id = p_brand_id
|
||||
AND sync_type = p_sync_type
|
||||
AND status IN ('pending', 'processing')
|
||||
) THEN
|
||||
INSERT INTO square_sync_queue (brand_id, sync_type, status)
|
||||
VALUES (p_brand_id, p_sync_type, 'pending');
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── 3. claim_square_sync_queue — atomic claim + return ────────────────────────
|
||||
-- Atomically claims the oldest pending entry for a brand, marks it 'processing',
|
||||
-- and returns it so the processor can work on it.
|
||||
CREATE OR REPLACE FUNCTION public.claim_square_sync_queue(p_brand_id UUID)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_entry JSONB;
|
||||
BEGIN
|
||||
-- Claim and update in one atomic operation
|
||||
WITH claimed AS (
|
||||
UPDATE square_sync_queue
|
||||
SET status = 'processing', processed_at = now()
|
||||
WHERE id = (
|
||||
SELECT id FROM square_sync_queue
|
||||
WHERE brand_id = p_brand_id
|
||||
AND status = 'pending'
|
||||
ORDER BY enqueued_at ASC
|
||||
LIMIT 1
|
||||
FOR UPDATE SKIP LOCKED
|
||||
)
|
||||
RETURNING id, brand_id, sync_type, status, enqueued_at
|
||||
)
|
||||
SELECT jsonb_build_object(
|
||||
'id', id,
|
||||
'brand_id', brand_id,
|
||||
'sync_type', sync_type,
|
||||
'status', status,
|
||||
'enqueued_at', enqueued_at
|
||||
) INTO v_entry
|
||||
FROM claimed;
|
||||
|
||||
RETURN COALESCE(v_entry, 'null'::JSONB);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── 4. Triggers on wholesale_products ─────────────────────────────────────────
|
||||
CREATE OR REPLACE FUNCTION public.trg_wholesale_products_on_change()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
IF (TG_OP = 'INSERT') THEN
|
||||
IF NEW.availability = 'available' THEN
|
||||
PERFORM enqueue_square_sync(NEW.brand_id, 'products');
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
IF (TG_OP = 'UPDATE') THEN
|
||||
IF OLD.availability = 'available' OR NEW.availability = 'available' THEN
|
||||
PERFORM enqueue_square_sync(NEW.brand_id, 'products');
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_wholesale_products_sync ON wholesale_products;
|
||||
CREATE TRIGGER trg_wholesale_products_sync
|
||||
AFTER INSERT OR UPDATE OF availability, name, description, price_tiers, unit_type, default_pickup_location
|
||||
ON wholesale_products
|
||||
FOR EACH ROW EXECUTE FUNCTION trg_wholesale_products_on_change();
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.trg_wholesale_products_on_delete()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
PERFORM enqueue_square_sync(OLD.brand_id, 'products');
|
||||
RETURN OLD;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_wholesale_products_sync_del ON wholesale_products;
|
||||
CREATE TRIGGER trg_wholesale_products_sync_del
|
||||
AFTER DELETE ON wholesale_products
|
||||
FOR EACH ROW EXECUTE FUNCTION trg_wholesale_products_on_delete();
|
||||
|
||||
-- ── 5. Upsert payment_settings with square_last_sync_at ─────────────────────
|
||||
-- Helper RPC to update last sync timestamp after a sync completes
|
||||
CREATE OR REPLACE FUNCTION public.update_square_sync_timestamp(
|
||||
p_brand_id UUID,
|
||||
p_error TEXT DEFAULT NULL
|
||||
)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
UPDATE payment_settings
|
||||
SET square_last_sync_at = now(),
|
||||
square_last_sync_error = p_error
|
||||
WHERE brand_id = p_brand_id;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMIT;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
-- ============================================================
|
||||
-- RLS for square_sync_queue (added by scripts/fix-archived-rls.js)
|
||||
-- Columns: id, brand_id, sync_type, status, enqueued_at, processed_at, retry_count, last_error
|
||||
-- ============================================================
|
||||
ALTER TABLE square_sync_queue ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE square_sync_queue FORCE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS tenant_isolation ON square_sync_queue;
|
||||
CREATE POLICY tenant_isolation ON square_sync_queue FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
|
||||
@@ -0,0 +1,160 @@
|
||||
-- Migration 067: Checkout idempotency for wholesale orders
|
||||
-- Adds checkout_session_id UUID to wholesale_orders for safe retry/double-submit protection.
|
||||
-- UNIQUE constraint (null-excluding) ensures each session maps to at most one order.
|
||||
-- The create_wholesale_order RPC checks this key before inserting to prevent duplicates.
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE public.wholesale_orders
|
||||
ADD COLUMN IF NOT EXISTS checkout_session_id UUID;
|
||||
|
||||
COMMENT ON COLUMN wholesale_orders.checkout_session_id IS
|
||||
'Set by submitWholesaleOrder at start of checkout. Prevents duplicate order creation from double-click, back-button, or network retry. UNIQUE WHERE NOT NULL.';
|
||||
|
||||
-- Fast lookup for idempotency checks in create_wholesale_order
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_wholesale_orders_checkout_session_id
|
||||
ON wholesale_orders(checkout_session_id)
|
||||
WHERE checkout_session_id IS NOT NULL;
|
||||
|
||||
-- Update create_wholesale_order to accept + enforce checkout_session_id
|
||||
DROP FUNCTION IF EXISTS public.create_wholesale_order(
|
||||
UUID, UUID, DATE, JSONB, INTEGER, TEXT
|
||||
);
|
||||
CREATE OR REPLACE FUNCTION public.create_wholesale_order(
|
||||
p_brand_id UUID DEFAULT NULL,
|
||||
p_customer_id UUID DEFAULT NULL,
|
||||
p_anticipated_pickup_date DATE DEFAULT NULL,
|
||||
p_items JSONB DEFAULT '[]'::JSONB,
|
||||
p_deposit_percentage INTEGER DEFAULT NULL,
|
||||
p_notes TEXT DEFAULT NULL,
|
||||
p_checkout_session_id UUID DEFAULT NULL
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_order_id UUID;
|
||||
v_subtotal NUMERIC(10,2);
|
||||
v_dep_required NUMERIC(10,2) := 0;
|
||||
v_customer RECORD;
|
||||
v_settings RECORD;
|
||||
v_inv_number TEXT;
|
||||
v_inv_token TEXT;
|
||||
v_status TEXT;
|
||||
v_outstanding NUMERIC(10,2);
|
||||
v_existing_id UUID;
|
||||
BEGIN
|
||||
-- ── Idempotency guard ──────────────────────────────────────────────────────
|
||||
-- If a checkout_session_id was provided and an order already exists with
|
||||
-- that key, return the existing order instead of creating a duplicate.
|
||||
IF p_checkout_session_id IS NOT NULL THEN
|
||||
SELECT id INTO v_existing_id
|
||||
FROM wholesale_orders
|
||||
WHERE checkout_session_id = p_checkout_session_id
|
||||
LIMIT 1;
|
||||
|
||||
IF v_existing_id IS NOT NULL THEN
|
||||
SELECT jsonb_build_object(
|
||||
'success', true,
|
||||
'order_id', v_existing_id,
|
||||
'invoice_number', invoice_number,
|
||||
'status', status,
|
||||
'deposit_required', deposit_required,
|
||||
'subtotal', subtotal,
|
||||
'idempotent', true,
|
||||
'note', 'Order already exists — returning existing record'
|
||||
) INTO v_existing_id
|
||||
FROM wholesale_orders
|
||||
WHERE id = v_existing_id;
|
||||
RETURN v_existing_id;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- ── 1. Resolve customer: credit_limit + deposit rules ──────────────────────
|
||||
SELECT credit_limit, deposits_enabled, deposit_threshold, deposit_percentage
|
||||
INTO v_customer
|
||||
FROM wholesale_customers
|
||||
WHERE id = p_customer_id;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Customer not found');
|
||||
END IF;
|
||||
|
||||
-- ── 2. Calculate subtotal ──────────────────────────────────────────────────
|
||||
v_subtotal := COALESCE(
|
||||
(SELECT SUM(
|
||||
((item->>'quantity')::NUMERIC) * ((item->>'unit_price')::NUMERIC)
|
||||
)
|
||||
FROM jsonb_array_elements(p_items) AS item),
|
||||
0
|
||||
);
|
||||
|
||||
-- ── 3. Credit limit enforcement ────────────────────────────────────────────
|
||||
IF v_customer.credit_limit > 0 THEN
|
||||
SELECT COALESCE(SUM(balance_due), 0) INTO v_outstanding
|
||||
FROM wholesale_orders
|
||||
WHERE customer_id = p_customer_id AND status != 'fulfilled';
|
||||
|
||||
IF v_outstanding + v_subtotal > v_customer.credit_limit THEN
|
||||
RETURN jsonb_build_object(
|
||||
'success', false,
|
||||
'error', 'Order exceeds available credit',
|
||||
'credit_limit', v_customer.credit_limit,
|
||||
'outstanding_balance', v_outstanding,
|
||||
'order_total', v_subtotal
|
||||
);
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- ── 4. Generate TC-XXXXXX invoice + md5 token ──────────────────────────────
|
||||
v_inv_number := 'TC-' || LPAD(FLOOR(RANDOM() * 1000000)::INT::TEXT, 6, '0');
|
||||
v_inv_token := md5(random()::text || clock_timestamp()::text || now()::text || p_brand_id::text);
|
||||
|
||||
-- ── 5. Deposit logic ───────────────────────────────────────────────────────
|
||||
IF v_customer.deposits_enabled THEN
|
||||
IF v_customer.deposit_threshold IS NULL OR v_subtotal >= v_customer.deposit_threshold THEN
|
||||
v_dep_required := ROUND(v_subtotal * COALESCE(v_customer.deposit_percentage, 0) / 100.0, 2);
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
v_status := CASE WHEN v_dep_required > 0 THEN 'awaiting_deposit' ELSE 'pending' END;
|
||||
|
||||
-- ── 6. Insert order ─────────────────────────────────────────────────────────
|
||||
INSERT INTO wholesale_orders (
|
||||
brand_id, customer_id, status, anticipated_pickup_date,
|
||||
subtotal, deposit_required, deposit_paid, balance_due,
|
||||
deposit_percentage, internal_notes, invoice_number, invoice_token,
|
||||
checkout_session_id
|
||||
) VALUES (
|
||||
p_brand_id, p_customer_id, v_status, p_anticipated_pickup_date,
|
||||
v_subtotal, v_dep_required, 0, v_subtotal,
|
||||
p_deposit_percentage, p_notes, v_inv_number, v_inv_token,
|
||||
p_checkout_session_id
|
||||
) RETURNING id INTO v_order_id;
|
||||
|
||||
-- ── 7. Insert line items ───────────────────────────────────────────────────
|
||||
IF jsonb_array_length(p_items) > 0 THEN
|
||||
INSERT INTO wholesale_order_items (wholesale_order_id, product_id, quantity, unit_price, line_total)
|
||||
SELECT v_order_id,
|
||||
(item->>'product_id')::UUID,
|
||||
(item->>'quantity')::NUMERIC,
|
||||
(item->>'unit_price')::NUMERIC,
|
||||
((item->>'quantity')::NUMERIC) * ((item->>'unit_price')::NUMERIC)
|
||||
FROM jsonb_array_elements(p_items) AS item;
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'success', true,
|
||||
'order_id', v_order_id,
|
||||
'invoice_number', v_inv_number,
|
||||
'status', v_status,
|
||||
'deposit_required', v_dep_required,
|
||||
'subtotal', v_subtotal,
|
||||
'idempotent', false
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMIT;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,67 @@
|
||||
-- Migration 068: User carts table for server-side cart persistence
|
||||
-- Enables cart merge on login and server-side cart for logged-in users.
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.user_carts (
|
||||
user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
items JSONB NOT NULL DEFAULT '[]'::JSONB,
|
||||
-- Shape: [{ id, name, price, quantity, fulfillment, brand_id, brand_slug }]
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE public.user_carts ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Users can read/write their own cart; platform_admin can read all
|
||||
CREATE POLICY "users_own_cart" ON public.user_carts
|
||||
FOR ALL USING (
|
||||
auth.uid() = user_id
|
||||
OR current_setting('app.settings.role', true)::TEXT = 'platform_admin'
|
||||
);
|
||||
|
||||
-- RPC: upsert_user_cart — insert or update the user's cart
|
||||
-- p_user_id: optional override (platform_admin may pass to update another user's cart)
|
||||
CREATE OR REPLACE FUNCTION public.upsert_user_cart(
|
||||
p_user_id UUID,
|
||||
p_items JSONB
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
INSERT INTO user_carts (user_id, items, updated_at)
|
||||
VALUES (p_user_id, p_items, now())
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
items = p_items,
|
||||
updated_at = now()
|
||||
RETURNING jsonb_build_object('success', true, 'user_id', user_id, 'item_count', jsonb_array_length(p_items));
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- RPC: get_user_cart — fetch a user's cart
|
||||
CREATE OR REPLACE FUNCTION public.get_user_cart(p_user_id UUID)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_items JSONB;
|
||||
BEGIN
|
||||
SELECT items INTO v_items FROM user_carts WHERE user_id = p_user_id;
|
||||
RETURN COALESCE(v_items, '[]'::JSONB);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- RPC: clear_user_cart
|
||||
CREATE OR REPLACE FUNCTION public.clear_user_cart(p_user_id UUID)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
DELETE FROM user_carts WHERE user_id = p_user_id;
|
||||
RETURN jsonb_build_object('success', true);
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMIT;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,607 @@
|
||||
-- Migration 069: Brand Scoping Phase 2 — RLS Fixes + RPC Validation
|
||||
-- 1. Fix wholesale_order_items RLS — add brand check via JOIN
|
||||
-- 2. Fix wholesale_deposits RLS — add brand check via JOIN
|
||||
-- 3. Fix get_wholesale_orders/customers/products NULL handling (platform_admin = all)
|
||||
-- 4. Add brand validation to update_shipping_order
|
||||
-- 5. Add brand validation to water log mutating RPCs
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ── 1. Fix wholesale_order_items RLS ─────────────────────────────────────────
|
||||
-- Current policy has no brand check. Add one via JOIN to wholesale_orders.
|
||||
DROP POLICY IF EXISTS "brand_admin_manage_wholesale_order_items" ON wholesale_order_items;
|
||||
-- Note: wholesale_order_items RLS intentionally re-enabled (was disabled for
|
||||
-- pre-existing trusted backend service access; now locked down to brand-scoped).
|
||||
ALTER TABLE wholesale_order_items ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "brand_admin_manage_wholesale_order_items" ON wholesale_order_items
|
||||
FOR ALL USING (
|
||||
-- platform_admin can do anything
|
||||
current_setting('app.settings.role', true)::TEXT = 'platform_admin'
|
||||
OR (
|
||||
-- brand_admin must match the order's brand via JOIN
|
||||
current_setting('app.settings.role', true)::TEXT = 'brand_admin'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM wholesale_orders wo
|
||||
WHERE wo.id = wholesale_order_items.wholesale_order_id
|
||||
AND wo.brand_id = current_setting('app.settings.brand_id', true)::UUID
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
-- ── 2. Fix wholesale_deposits RLS ─────────────────────────────────────────────
|
||||
DROP POLICY IF EXISTS "brand_admin_manage_wholesale_deposits" ON wholesale_deposits;
|
||||
-- See note above: wholesale_deposits RLS re-enabled to brand-scoped policy.
|
||||
ALTER TABLE wholesale_deposits ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "brand_admin_manage_wholesale_deposits" ON wholesale_deposits
|
||||
FOR ALL USING (
|
||||
current_setting('app.settings.role', true)::TEXT = 'platform_admin'
|
||||
OR (
|
||||
current_setting('app.settings.role', true)::TEXT = 'brand_admin'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM wholesale_orders wo
|
||||
WHERE wo.id = wholesale_deposits.wholesale_order_id
|
||||
AND wo.brand_id = current_setting('app.settings.brand_id', true)::UUID
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
-- ── 3. Fix get_wholesale_orders NULL handling ────────────────────────────────
|
||||
-- NULL brand_id should return ALL orders (platform_admin); set brand returns scoped.
|
||||
CREATE OR REPLACE FUNCTION public.get_wholesale_orders(p_brand_id UUID DEFAULT NULL)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_result JSONB;
|
||||
BEGIN
|
||||
SELECT jsonb_agg(t ORDER BY t.created_at DESC) INTO v_result
|
||||
FROM (
|
||||
SELECT
|
||||
wo.id,
|
||||
wo.status,
|
||||
wo.fulfillment_status,
|
||||
wo.payment_status,
|
||||
wo.anticipated_pickup_date,
|
||||
wo.subtotal,
|
||||
wo.deposit_required,
|
||||
wo.deposit_paid,
|
||||
wo.balance_due,
|
||||
wo.invoice_number,
|
||||
wo.invoice_token,
|
||||
wo.checkout_session_id,
|
||||
wo.created_at,
|
||||
wo.updated_at,
|
||||
wo.fulfilled_at,
|
||||
wo.notes,
|
||||
wc.company_name,
|
||||
wc.contact_name,
|
||||
wc.email AS customer_email,
|
||||
wc.phone AS customer_phone,
|
||||
COALESCE(
|
||||
(SELECT jsonb_agg(
|
||||
CASE WHEN woi.id IS NOT NULL THEN
|
||||
jsonb_build_object(
|
||||
'id', woi.id,
|
||||
'product_name', wp.name,
|
||||
'quantity', woi.quantity,
|
||||
'unit_price', woi.unit_price,
|
||||
'line_total', woi.line_total
|
||||
)
|
||||
END
|
||||
)
|
||||
FROM wholesale_order_items woi
|
||||
LEFT JOIN wholesale_products wp ON woi.product_id = wp.id
|
||||
WHERE woi.wholesale_order_id = wo.id),
|
||||
'[]'::JSONB
|
||||
) AS items
|
||||
FROM wholesale_orders wo
|
||||
JOIN wholesale_customers wc ON wo.customer_id = wc.id
|
||||
WHERE (
|
||||
-- NULL brand_id = platform_admin = all brands
|
||||
p_brand_id IS NULL
|
||||
OR wo.brand_id = p_brand_id
|
||||
)
|
||||
ORDER BY wo.created_at DESC
|
||||
LIMIT 500
|
||||
) t;
|
||||
RETURN COALESCE(v_result, '[]'::JSONB);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── 4. Fix get_wholesale_customers NULL handling ────────────────────────────
|
||||
CREATE OR REPLACE FUNCTION public.get_wholesale_customers(p_brand_id UUID DEFAULT NULL)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_result JSONB;
|
||||
BEGIN
|
||||
SELECT jsonb_agg(t ORDER BY t.company_name ASC) INTO v_result
|
||||
FROM (
|
||||
SELECT
|
||||
id,
|
||||
user_id,
|
||||
company_name,
|
||||
contact_name,
|
||||
email,
|
||||
phone,
|
||||
account_status,
|
||||
role,
|
||||
brand_id,
|
||||
credit_limit,
|
||||
outstanding_balance,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM wholesale_customers
|
||||
WHERE p_brand_id IS NULL OR brand_id = p_brand_id
|
||||
ORDER BY company_name ASC
|
||||
LIMIT 500
|
||||
) t;
|
||||
RETURN COALESCE(v_result, '[]'::JSONB);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── 5. Fix get_wholesale_products NULL handling ─────────────────────────────
|
||||
CREATE OR REPLACE FUNCTION public.get_wholesale_products(p_brand_id UUID DEFAULT NULL)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_result JSONB;
|
||||
BEGIN
|
||||
SELECT jsonb_agg(t ORDER BY t.name ASC) INTO v_result
|
||||
FROM (
|
||||
SELECT
|
||||
id,
|
||||
brand_id,
|
||||
name,
|
||||
description,
|
||||
unit_type,
|
||||
availability,
|
||||
qty_available,
|
||||
hp_sku,
|
||||
created_at,
|
||||
updated_at,
|
||||
price_tiers
|
||||
FROM wholesale_products
|
||||
WHERE p_brand_id IS NULL OR brand_id = p_brand_id
|
||||
ORDER BY name ASC
|
||||
LIMIT 500
|
||||
) t;
|
||||
RETURN COALESCE(v_result, '[]'::JSONB);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── 6. Add brand validation to update_shipping_order ─────────────────────────
|
||||
CREATE OR REPLACE FUNCTION public.update_shipping_order(
|
||||
p_order_id UUID,
|
||||
p_shipping_status TEXT,
|
||||
p_tracking_number TEXT DEFAULT NULL,
|
||||
p_brand_id UUID DEFAULT NULL -- NEW: for brand validation
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_order RECORD;
|
||||
BEGIN
|
||||
-- Fetch order and validate brand access
|
||||
SELECT id, brand_id INTO v_order
|
||||
FROM orders
|
||||
WHERE id = p_order_id;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Order not found');
|
||||
END IF;
|
||||
|
||||
-- Brand validation: brand_admin can only update orders for their brand
|
||||
IF p_brand_id IS NOT NULL AND v_order.brand_id != p_brand_id THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Not authorized to update this order');
|
||||
END IF;
|
||||
|
||||
UPDATE orders SET
|
||||
shipping_status = p_shipping_status,
|
||||
tracking_number = COALESCE(p_tracking_number, tracking_number),
|
||||
updated_at = now()
|
||||
WHERE id = p_order_id;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'success', true,
|
||||
'order_id', p_order_id,
|
||||
'shipping_status', p_shipping_status,
|
||||
'tracking_number', COALESCE(p_tracking_number, tracking_number)
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── 7. Add brand validation to water log mutating RPCs ──────────────────────
|
||||
|
||||
-- update_water_entry: add p_brand_id, validate
|
||||
CREATE OR REPLACE FUNCTION public.update_water_entry(
|
||||
p_entry_id UUID,
|
||||
p_measurement NUMERIC,
|
||||
p_notes TEXT DEFAULT NULL,
|
||||
p_unit TEXT DEFAULT NULL,
|
||||
p_brand_id UUID DEFAULT NULL
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_entry RECORD;
|
||||
BEGIN
|
||||
SELECT we.id, we.brand_id INTO v_entry
|
||||
FROM water_log_entries we
|
||||
WHERE we.id = p_entry_id AND we.deleted_at IS NULL;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Entry not found');
|
||||
END IF;
|
||||
|
||||
-- Brand validation
|
||||
IF p_brand_id IS NOT NULL AND v_entry.brand_id != p_brand_id THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Not authorized to update this entry');
|
||||
END IF;
|
||||
|
||||
UPDATE water_log_entries SET
|
||||
measurement = p_measurement,
|
||||
notes = COALESCE(p_notes, notes),
|
||||
unit = COALESCE(p_unit, unit),
|
||||
updated_at = now()
|
||||
WHERE id = p_entry_id;
|
||||
|
||||
RETURN jsonb_build_object('success', true, 'entry_id', p_entry_id);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- delete_water_entry: add p_brand_id
|
||||
CREATE OR REPLACE FUNCTION public.delete_water_entry(
|
||||
p_entry_id UUID,
|
||||
p_brand_id UUID DEFAULT NULL
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_entry RECORD;
|
||||
BEGIN
|
||||
SELECT id, brand_id INTO v_entry
|
||||
FROM water_log_entries
|
||||
WHERE id = p_entry_id AND deleted_at IS NULL;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Entry not found');
|
||||
END IF;
|
||||
|
||||
IF p_brand_id IS NOT NULL AND v_entry.brand_id != p_brand_id THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Not authorized to delete this entry');
|
||||
END IF;
|
||||
|
||||
UPDATE water_log_entries SET deleted_at = now() WHERE id = p_entry_id;
|
||||
RETURN jsonb_build_object('success', true);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- update_water_headgate: add p_brand_id
|
||||
CREATE OR REPLACE FUNCTION public.update_water_headgate(
|
||||
p_headgate_id UUID,
|
||||
p_name TEXT,
|
||||
p_active BOOLEAN DEFAULT true,
|
||||
p_unit TEXT DEFAULT NULL,
|
||||
p_brand_id UUID DEFAULT NULL
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_hg RECORD;
|
||||
BEGIN
|
||||
SELECT id, brand_id INTO v_hg
|
||||
FROM water_headgates
|
||||
WHERE id = p_headgate_id AND deleted_at IS NULL;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Headgate not found');
|
||||
END IF;
|
||||
|
||||
IF p_brand_id IS NOT NULL AND v_hg.brand_id != p_brand_id THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Not authorized to update this headgate');
|
||||
END IF;
|
||||
|
||||
UPDATE water_headgates SET
|
||||
name = p_name,
|
||||
active = p_active,
|
||||
unit = COALESCE(p_unit, unit),
|
||||
updated_at = now()
|
||||
WHERE id = p_headgate_id;
|
||||
|
||||
RETURN jsonb_build_object('success', true, 'headgate_id', p_headgate_id);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- delete_water_headgate: add p_brand_id
|
||||
CREATE OR REPLACE FUNCTION public.delete_water_headgate(
|
||||
p_headgate_id UUID,
|
||||
p_brand_id UUID DEFAULT NULL
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_hg RECORD;
|
||||
BEGIN
|
||||
SELECT id, brand_id INTO v_hg
|
||||
FROM water_headgates
|
||||
WHERE id = p_headgate_id AND deleted_at IS NULL;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Headgate not found');
|
||||
END IF;
|
||||
|
||||
IF p_brand_id IS NOT NULL AND v_hg.brand_id != p_brand_id THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Not authorized to delete this headgate');
|
||||
END IF;
|
||||
|
||||
UPDATE water_headgates SET deleted_at = now() WHERE id = p_headgate_id;
|
||||
RETURN jsonb_build_object('success', true);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- update_water_user: add p_brand_id
|
||||
CREATE OR REPLACE FUNCTION public.update_water_user(
|
||||
p_user_id UUID,
|
||||
p_name TEXT DEFAULT NULL,
|
||||
p_active BOOLEAN DEFAULT NULL,
|
||||
p_lang TEXT DEFAULT NULL,
|
||||
p_role TEXT DEFAULT NULL,
|
||||
p_brand_id UUID DEFAULT NULL
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_user RECORD;
|
||||
BEGIN
|
||||
SELECT id, brand_id INTO v_user
|
||||
FROM water_users
|
||||
WHERE id = p_user_id AND deleted_at IS NULL;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'User not found');
|
||||
END IF;
|
||||
|
||||
IF p_brand_id IS NOT NULL AND v_user.brand_id != p_brand_id THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Not authorized to update this user');
|
||||
END IF;
|
||||
|
||||
UPDATE water_users SET
|
||||
name = COALESCE(p_name, name),
|
||||
active = COALESCE(p_active, active),
|
||||
lang = COALESCE(p_lang, lang),
|
||||
role = COALESCE(p_role, role),
|
||||
updated_at = now()
|
||||
WHERE id = p_user_id;
|
||||
|
||||
RETURN jsonb_build_object('success', true, 'user_id', p_user_id);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- delete_water_user: add p_brand_id
|
||||
CREATE OR REPLACE FUNCTION public.delete_water_user(
|
||||
p_user_id UUID,
|
||||
p_brand_id UUID DEFAULT NULL
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_user RECORD;
|
||||
BEGIN
|
||||
SELECT id, brand_id INTO v_user
|
||||
FROM water_users
|
||||
WHERE id = p_user_id AND deleted_at IS NULL;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'User not found');
|
||||
END IF;
|
||||
|
||||
IF p_brand_id IS NOT NULL AND v_user.brand_id != p_brand_id THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Not authorized to delete this user');
|
||||
END IF;
|
||||
|
||||
UPDATE water_users SET deleted_at = now() WHERE id = p_user_id;
|
||||
RETURN jsonb_build_object('success', true);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- reset_water_irrigator_pin: add p_brand_id via join check
|
||||
CREATE OR REPLACE FUNCTION public.reset_water_irrigator_pin(
|
||||
p_user_id UUID,
|
||||
p_brand_id UUID DEFAULT NULL
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_user RECORD;
|
||||
BEGIN
|
||||
SELECT id, brand_id INTO v_user
|
||||
FROM water_users
|
||||
WHERE id = p_user_id AND deleted_at IS NULL;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'User not found');
|
||||
END IF;
|
||||
|
||||
IF p_brand_id IS NOT NULL AND v_user.brand_id != p_brand_id THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Not authorized to reset this PIN');
|
||||
END IF;
|
||||
|
||||
UPDATE water_users SET
|
||||
pin_hash = NULL,
|
||||
updated_at = now()
|
||||
WHERE id = p_user_id;
|
||||
|
||||
RETURN jsonb_build_object('success', true);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- reset_water_irrigator_pin: add p_brand_id via join check
|
||||
CREATE OR REPLACE FUNCTION public.reset_water_irrigator_pin(
|
||||
p_user_id UUID,
|
||||
p_brand_id UUID DEFAULT NULL
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_user RECORD;
|
||||
BEGIN
|
||||
SELECT id, brand_id INTO v_user
|
||||
FROM water_users
|
||||
WHERE id = p_user_id AND deleted_at IS NULL;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'User not found');
|
||||
END IF;
|
||||
|
||||
IF p_brand_id IS NOT NULL AND v_user.brand_id != p_brand_id THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Not authorized to reset this PIN');
|
||||
END IF;
|
||||
|
||||
UPDATE water_users SET
|
||||
pin_hash = NULL,
|
||||
updated_at = now()
|
||||
WHERE id = p_user_id;
|
||||
|
||||
RETURN jsonb_build_object('success', true);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── 8. Add brand validation to record_wholesale_deposit ────────────────────
|
||||
CREATE OR REPLACE FUNCTION public.record_wholesale_deposit(
|
||||
p_order_id UUID,
|
||||
p_amount NUMERIC,
|
||||
p_method TEXT DEFAULT 'cash',
|
||||
p_reference TEXT DEFAULT NULL,
|
||||
p_recorded_by UUID DEFAULT NULL,
|
||||
p_brand_id UUID DEFAULT NULL -- NEW
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_order RECORD;
|
||||
BEGIN
|
||||
SELECT id, brand_id INTO v_order
|
||||
FROM wholesale_orders
|
||||
WHERE id = p_order_id;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Order not found');
|
||||
END IF;
|
||||
|
||||
IF p_brand_id IS NOT NULL AND v_order.brand_id != p_brand_id THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Not authorized to record deposit for this order');
|
||||
END IF;
|
||||
|
||||
INSERT INTO wholesale_deposits (wholesale_order_id, amount, method, reference, recorded_by)
|
||||
VALUES (p_order_id, p_amount, p_method, p_reference, p_recorded_by);
|
||||
|
||||
UPDATE wholesale_orders SET
|
||||
deposit_paid = deposit_paid + p_amount,
|
||||
payment_status = CASE
|
||||
WHEN deposit_paid + p_amount >= deposit_required THEN 'deposit_paid'
|
||||
ELSE 'partial_deposit'
|
||||
END,
|
||||
updated_at = now()
|
||||
WHERE id = p_order_id;
|
||||
|
||||
RETURN jsonb_build_object('success', true, 'order_id', p_order_id, 'amount', p_amount);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── 9. Add brand validation to bulk_fulfill_wholesale_orders ─────────────────
|
||||
CREATE OR REPLACE FUNCTION public.bulk_fulfill_wholesale_orders(
|
||||
p_order_ids UUID[],
|
||||
p_by UUID DEFAULT NULL,
|
||||
p_brand_id UUID DEFAULT NULL -- NEW
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_count INT;
|
||||
BEGIN
|
||||
-- Brand scoping: if brand_id provided, validate all orders belong to it
|
||||
IF p_brand_id IS NOT NULL THEN
|
||||
SELECT COUNT(*) INTO v_count
|
||||
FROM wholesale_orders
|
||||
WHERE id = ANY(p_order_ids) AND brand_id != p_brand_id;
|
||||
IF v_count > 0 THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Not authorized to fulfill these orders');
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
UPDATE wholesale_orders SET
|
||||
fulfillment_status = 'fulfilled',
|
||||
fulfilled_at = now(),
|
||||
fulfilled_by = p_by,
|
||||
updated_at = now()
|
||||
WHERE id = ANY(p_order_ids) AND fulfillment_status != 'fulfilled';
|
||||
|
||||
GET DIAGNOSTICS v_count = ROW_COUNT;
|
||||
RETURN jsonb_build_object('success', true, 'count', v_count);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── 10. Add brand validation to bulk_record_wholesale_deposit ───────────────
|
||||
CREATE OR REPLACE FUNCTION public.bulk_record_wholesale_deposit(
|
||||
p_order_ids UUID[],
|
||||
p_amount NUMERIC,
|
||||
p_method TEXT DEFAULT 'cash',
|
||||
p_reference TEXT DEFAULT NULL,
|
||||
p_recorded_by UUID DEFAULT NULL,
|
||||
p_brand_id UUID DEFAULT NULL -- NEW
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_count INT;
|
||||
v_order RECORD;
|
||||
BEGIN
|
||||
-- Brand scoping
|
||||
IF p_brand_id IS NOT NULL THEN
|
||||
SELECT COUNT(*) INTO v_count
|
||||
FROM wholesale_orders
|
||||
WHERE id = ANY(p_order_ids) AND brand_id != p_brand_id;
|
||||
IF v_count > 0 THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Not authorized to record deposits for these orders');
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
INSERT INTO wholesale_deposits (wholesale_order_id, amount, method, reference, recorded_by)
|
||||
SELECT o.id, p_amount, p_method, p_reference, p_recorded_by
|
||||
FROM wholesale_orders o
|
||||
WHERE o.id = ANY(p_order_ids);
|
||||
|
||||
UPDATE wholesale_orders SET
|
||||
deposit_paid = deposit_paid + p_amount,
|
||||
payment_status = CASE
|
||||
WHEN deposit_paid + p_amount >= deposit_required THEN 'deposit_paid'
|
||||
ELSE 'partial_deposit'
|
||||
END,
|
||||
updated_at = now()
|
||||
WHERE id = ANY(p_order_ids);
|
||||
|
||||
GET DIAGNOSTICS v_count = ROW_COUNT;
|
||||
RETURN jsonb_build_object('success', true, 'count', v_count);
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMIT;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,278 @@
|
||||
-- Migration 070: RLS Policy Audit Fixes
|
||||
-- 1. Add RLS to products (was completely missing)
|
||||
-- 2. Add brand scoping to stops RLS
|
||||
-- 3. Add deleted_at IS NULL to wholesale_customers and wholesale_products RLS
|
||||
-- 4. Add SELECT policy to order_items (brand-scoped via orders/stops JOIN)
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ── 1. products — Add RLS ──────────────────────────────────────────────────────
|
||||
-- Table has brand_id column but no RLS policies whatsoever.
|
||||
-- Add SELECT for admins (brand-scoped), INSERT/UPDATE/DELETE blocked (RPC only).
|
||||
|
||||
ALTER TABLE public.products ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Platform admin can read all products
|
||||
CREATE POLICY "platform_admin_read_products" ON public.products
|
||||
FOR SELECT USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM admin_users au
|
||||
WHERE au.user_id = auth.uid()
|
||||
AND au.role = 'platform_admin'
|
||||
)
|
||||
);
|
||||
|
||||
-- Brand admin can read their brand's products
|
||||
CREATE POLICY "brand_admin_read_products" ON public.products
|
||||
FOR SELECT USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM admin_users au
|
||||
WHERE au.user_id = auth.uid()
|
||||
AND au.role = 'brand_admin'
|
||||
AND au.brand_id = products.brand_id
|
||||
)
|
||||
);
|
||||
|
||||
-- Store employee can read their brand's products
|
||||
CREATE POLICY "store_employee_read_products" ON public.products
|
||||
FOR SELECT USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM admin_users au
|
||||
WHERE au.user_id = auth.uid()
|
||||
AND au.role = 'store_employee'
|
||||
AND au.brand_id = products.brand_id
|
||||
)
|
||||
);
|
||||
|
||||
-- wholesale_customer can read their brand's products (via brand_id join)
|
||||
CREATE POLICY "wholesale_customer_read_products" ON public.products
|
||||
FOR SELECT USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM admin_users au
|
||||
JOIN wholesale_customers wc ON wc.user_id = au.user_id
|
||||
WHERE au.user_id = auth.uid()
|
||||
AND au.role = 'wholesale_customer'
|
||||
AND wc.brand_id = products.brand_id
|
||||
)
|
||||
);
|
||||
|
||||
-- Block INSERT/UPDATE/DELETE on products — all writes go through RPCs
|
||||
-- No policy = no access for mutating operations (authenticated users blocked)
|
||||
CREATE POLICY "block_products_mutations" ON public.products
|
||||
FOR INSERT WITH CHECK (false);
|
||||
|
||||
CREATE POLICY "block_products_update" ON public.products
|
||||
FOR UPDATE USING (false);
|
||||
|
||||
CREATE POLICY "block_products_delete" ON public.products
|
||||
FOR DELETE USING (false);
|
||||
|
||||
-- ── 2. stops — Add brand scoping ───────────────────────────────────────────────
|
||||
-- Current policy allows ANY admin to read ALL stops across all brands.
|
||||
-- Replace with brand-scoped policies.
|
||||
|
||||
DROP POLICY IF EXISTS "Admins can read stops" ON stops;
|
||||
-- See comment below: stops RLS re-enabled to brand-scoped read-only policies.
|
||||
ALTER TABLE stops ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Platform admin sees all stops
|
||||
CREATE POLICY "platform_admin_read_stops" ON stops
|
||||
FOR SELECT USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM admin_users au
|
||||
WHERE au.user_id = auth.uid()
|
||||
AND au.role = 'platform_admin'
|
||||
)
|
||||
);
|
||||
|
||||
-- Brand admin / store_employee sees only their brand's stops
|
||||
CREATE POLICY "brand_admin_read_stops" ON stops
|
||||
FOR SELECT USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM admin_users au
|
||||
WHERE au.user_id = auth.uid()
|
||||
AND au.role IN ('brand_admin', 'store_employee')
|
||||
AND au.brand_id = stops.brand_id
|
||||
)
|
||||
);
|
||||
|
||||
-- Block all mutations on stops (admin only via RPC)
|
||||
CREATE POLICY "block_stops_mutations" ON stops
|
||||
FOR INSERT WITH CHECK (false);
|
||||
|
||||
CREATE POLICY "block_stops_update" ON stops
|
||||
FOR UPDATE USING (false);
|
||||
|
||||
CREATE POLICY "block_stops_delete" ON stops
|
||||
FOR DELETE USING (false);
|
||||
|
||||
-- ── 3. wholesale_customers — Add deleted_at IS NULL ───────────────────────────
|
||||
-- Soft-deleted customers (deleted_at NOT NULL) should not be readable via REST.
|
||||
-- Split into two policies: one for platform_admin (no deleted filter), one for brand_admin.
|
||||
|
||||
DROP POLICY IF EXISTS "brand_admin_manage_wholesale_customers" ON wholesale_customers;
|
||||
DROP POLICY IF EXISTS "wholesale_customer_read_own" ON wholesale_customers;
|
||||
-- wholesale_customers RLS re-enabled to brand-scoped read/write policies.
|
||||
ALTER TABLE wholesale_customers ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Platform admin can read all customers (including soft-deleted for recovery)
|
||||
CREATE POLICY "platform_admin_manage_wholesale_customers" ON wholesale_customers
|
||||
FOR ALL USING (
|
||||
current_setting('app.settings.role', true)::TEXT = 'platform_admin'
|
||||
);
|
||||
|
||||
-- Brand admin can only manage their brand's non-deleted customers
|
||||
CREATE POLICY "brand_admin_manage_wholesale_customers" ON wholesale_customers
|
||||
FOR ALL USING (
|
||||
current_setting('app.settings.role', true)::TEXT = 'brand_admin'
|
||||
AND brand_id = current_setting('app.settings.brand_id', true)::UUID
|
||||
AND deleted_at IS NULL
|
||||
);
|
||||
|
||||
-- Customers can read their own record (not brand-admin, wholesale_customer role)
|
||||
CREATE POLICY "wholesale_customer_read_own" ON wholesale_customers
|
||||
FOR SELECT USING (
|
||||
current_setting('app.settings.role', true)::TEXT = 'wholesale_customer'
|
||||
AND user_id = current_setting('app.settings.user_id', true)::UUID
|
||||
AND deleted_at IS NULL
|
||||
);
|
||||
|
||||
-- ── 4. wholesale_products — Add deleted_at IS NULL ───────────────────────────
|
||||
-- Soft-deleted products should not be readable via REST.
|
||||
|
||||
DROP POLICY IF EXISTS "brand_admin_manage_wholesale_products" ON wholesale_products;
|
||||
-- wholesale_products RLS re-enabled to brand-scoped read/write policies.
|
||||
ALTER TABLE wholesale_products ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Platform admin can read all (including soft-deleted for recovery)
|
||||
CREATE POLICY "platform_admin_manage_wholesale_products" ON wholesale_products
|
||||
FOR ALL USING (
|
||||
current_setting('app.settings.role', true)::TEXT = 'platform_admin'
|
||||
);
|
||||
|
||||
-- Brand admin can only manage their brand's non-deleted products
|
||||
CREATE POLICY "brand_admin_manage_wholesale_products" ON wholesale_products
|
||||
FOR ALL USING (
|
||||
current_setting('app.settings.role', true)::TEXT = 'brand_admin'
|
||||
AND brand_id = current_setting('app.settings.brand_id', true)::UUID
|
||||
AND deleted_at IS NULL
|
||||
);
|
||||
|
||||
-- ── 5. order_items — Add brand-scoped SELECT policy ───────────────────────────
|
||||
-- order_items currently has no RLS policies (all access blocked).
|
||||
-- Add SELECT with brand scoping via orders/stops JOIN.
|
||||
|
||||
ALTER TABLE public.order_items ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Platform admin can read all order items
|
||||
CREATE POLICY "platform_admin_read_order_items" ON order_items
|
||||
FOR SELECT USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM admin_users au
|
||||
WHERE au.user_id = auth.uid()
|
||||
AND au.role = 'platform_admin'
|
||||
)
|
||||
);
|
||||
|
||||
-- Brand admin / store_employee: can read order items for their brand's orders
|
||||
CREATE POLICY "brand_admin_read_order_items" ON order_items
|
||||
FOR SELECT USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM admin_users au
|
||||
JOIN orders o ON o.id = order_items.order_id
|
||||
JOIN stops s ON s.id = o.stop_id
|
||||
WHERE au.user_id = auth.uid()
|
||||
AND au.role IN ('brand_admin', 'store_employee')
|
||||
AND s.brand_id = au.brand_id
|
||||
)
|
||||
);
|
||||
|
||||
-- Block all mutations on order_items (admin only via RPC)
|
||||
CREATE POLICY "block_order_items_mutations" ON order_items
|
||||
FOR INSERT WITH CHECK (false);
|
||||
|
||||
CREATE POLICY "block_order_items_update" ON order_items
|
||||
FOR UPDATE USING (false);
|
||||
|
||||
CREATE POLICY "block_order_items_delete" ON order_items
|
||||
FOR DELETE USING (false);
|
||||
|
||||
-- ── 6. orders — Add brand scoping to write policies ───────────────────────────
|
||||
-- Existing SELECT policies use stops JOIN correctly.
|
||||
-- Ensure INSERT/UPDATE/DELETE policies also enforce brand scoping (or block them).
|
||||
-- Note: orders INSERT/UPDATE/DELETE go through RPCs — add blocking policies as safety net.
|
||||
|
||||
CREATE POLICY "block_orders_insert" ON orders
|
||||
FOR INSERT WITH CHECK (false);
|
||||
|
||||
CREATE POLICY "block_orders_update" ON orders
|
||||
FOR UPDATE USING (false);
|
||||
|
||||
CREATE POLICY "block_orders_delete" ON orders
|
||||
FOR DELETE USING (false);
|
||||
|
||||
-- ── 7. wholesale_orders — Ensure no deleted_at issues ─────────────────────────
|
||||
-- wholesale_orders has no deleted_at column (hard delete). No changes needed.
|
||||
-- But add explicit INSERT/UPDATE/DELETE block to match other tables.
|
||||
-- All writes go through RPCs — block direct REST mutations.
|
||||
|
||||
CREATE POLICY "block_wholesale_orders_insert" ON wholesale_orders
|
||||
FOR INSERT WITH CHECK (false);
|
||||
|
||||
CREATE POLICY "block_wholesale_orders_update" ON wholesale_orders
|
||||
FOR UPDATE USING (false);
|
||||
|
||||
CREATE POLICY "block_wholesale_orders_delete" ON wholesale_orders
|
||||
FOR DELETE USING (false);
|
||||
|
||||
-- ── 8. wholesale_order_items — Block direct REST mutations ────────────────────
|
||||
-- SELECT is already scoped in 069. Block INSERT/UPDATE/DELETE.
|
||||
|
||||
CREATE POLICY "block_wholesale_order_items_insert" ON wholesale_order_items
|
||||
FOR INSERT WITH CHECK (false);
|
||||
|
||||
CREATE POLICY "block_wholesale_order_items_update" ON wholesale_order_items
|
||||
FOR UPDATE USING (false);
|
||||
|
||||
CREATE POLICY "block_wholesale_order_items_delete" ON wholesale_order_items
|
||||
FOR DELETE USING (false);
|
||||
|
||||
-- ── 9. wholesale_deposits — Block direct REST mutations ───────────────────────
|
||||
-- SELECT is already scoped in 069. Block INSERT/UPDATE/DELETE.
|
||||
|
||||
CREATE POLICY "block_wholesale_deposits_insert" ON wholesale_deposits
|
||||
FOR INSERT WITH CHECK (false);
|
||||
|
||||
CREATE POLICY "block_wholesale_deposits_update" ON wholesale_deposits
|
||||
FOR UPDATE USING (false);
|
||||
|
||||
CREATE POLICY "block_wholesale_deposits_delete" ON wholesale_deposits
|
||||
FOR DELETE USING (false);
|
||||
|
||||
-- ── 10. wholesale_customers — Block direct REST mutations ──────────────────────
|
||||
-- SELECT policies already set above. Block mutations.
|
||||
|
||||
CREATE POLICY "block_wholesale_customers_insert" ON wholesale_customers
|
||||
FOR INSERT WITH CHECK (false);
|
||||
|
||||
CREATE POLICY "block_wholesale_customers_update" ON wholesale_customers
|
||||
FOR UPDATE USING (false);
|
||||
|
||||
CREATE POLICY "block_wholesale_customers_delete" ON wholesale_customers
|
||||
FOR DELETE USING (false);
|
||||
|
||||
-- ── 11. wholesale_products — Block direct REST mutations ───────────────────────
|
||||
-- SELECT policies already set above. Block mutations.
|
||||
|
||||
CREATE POLICY "block_wholesale_products_insert" ON wholesale_products
|
||||
FOR INSERT WITH CHECK (false);
|
||||
|
||||
CREATE POLICY "block_wholesale_products_update" ON wholesale_products
|
||||
FOR UPDATE USING (false);
|
||||
|
||||
CREATE POLICY "block_wholesale_products_delete" ON wholesale_products
|
||||
FOR DELETE USING (false);
|
||||
|
||||
COMMIT;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,180 @@
|
||||
-- Migration 071: Add square_sync_enabled to wholesale_settings
|
||||
-- - Adds square_sync_enabled BOOLEAN column (default false)
|
||||
-- - Updates upsert_wholesale_settings RPC to accept p_square_sync_enabled
|
||||
-- - Updates enqueue_square_sync to respect wholesale_settings.square_sync_enabled
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ── 1. Add square_sync_enabled column ────────────────────────────────────────
|
||||
ALTER TABLE public.wholesale_settings
|
||||
ADD COLUMN IF NOT EXISTS square_sync_enabled BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- ── 2. Update enqueue_square_sync to check wholesale_settings ────────────────
|
||||
-- Only enqueue if square_sync_enabled = true for the brand.
|
||||
-- If no wholesale_settings record exists, skip (auto-sync disabled until configured).
|
||||
DROP FUNCTION IF EXISTS public.enqueue_square_sync(UUID, TEXT);
|
||||
CREATE OR REPLACE FUNCTION public.enqueue_square_sync(
|
||||
p_brand_id UUID,
|
||||
p_sync_type TEXT DEFAULT 'products'
|
||||
)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
-- Guard: only enqueue if square_sync_enabled for this brand
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM wholesale_settings ws
|
||||
WHERE ws.brand_id = p_brand_id
|
||||
AND ws.square_sync_enabled = true
|
||||
) THEN
|
||||
RETURN; -- sync disabled for this brand
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM square_sync_queue
|
||||
WHERE brand_id = p_brand_id
|
||||
AND sync_type = p_sync_type
|
||||
AND status IN ('pending', 'processing')
|
||||
) THEN
|
||||
INSERT INTO square_sync_queue (brand_id, sync_type, status)
|
||||
VALUES (p_brand_id, p_sync_type, 'pending');
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── 3. Update upsert_wholesale_settings RPC ───────────────────────────────────
|
||||
-- Drop first to avoid "cannot remove parameter defaults" error.
|
||||
DROP FUNCTION IF EXISTS public.upsert_wholesale_settings(
|
||||
UUID, BOOLEAN, NUMERIC, BOOLEAN, BOOLEAN, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, JSONB, BOOLEAN
|
||||
);
|
||||
CREATE OR REPLACE FUNCTION public.upsert_wholesale_settings(
|
||||
p_brand_id UUID,
|
||||
p_require_approval BOOLEAN DEFAULT NULL,
|
||||
p_min_order_amount NUMERIC DEFAULT NULL,
|
||||
p_online_payment_enabled BOOLEAN DEFAULT NULL,
|
||||
p_wholesale_enabled BOOLEAN DEFAULT NULL,
|
||||
p_pickup_location TEXT DEFAULT NULL,
|
||||
p_fob_location TEXT DEFAULT NULL,
|
||||
p_from_email TEXT DEFAULT NULL,
|
||||
p_invoice_business_name TEXT DEFAULT NULL,
|
||||
p_invoice_business_address TEXT DEFAULT NULL,
|
||||
p_invoice_business_phone TEXT DEFAULT NULL,
|
||||
p_invoice_business_email TEXT DEFAULT NULL,
|
||||
p_invoice_business_website TEXT DEFAULT NULL,
|
||||
p_notification_email TEXT DEFAULT NULL,
|
||||
p_notification_recipients JSONB DEFAULT NULL,
|
||||
p_square_sync_enabled BOOLEAN DEFAULT NULL -- NEW
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_result JSONB;
|
||||
BEGIN
|
||||
INSERT INTO wholesale_settings (
|
||||
brand_id, require_approval, min_order_amount, online_payment_enabled,
|
||||
wholesale_enabled, pickup_location, fob_location, from_email,
|
||||
invoice_business_name, invoice_business_address, invoice_business_phone,
|
||||
invoice_business_email, invoice_business_website,
|
||||
notification_email, notification_recipients, square_sync_enabled
|
||||
)
|
||||
VALUES (
|
||||
p_brand_id, COALESCE(p_require_approval, true), p_min_order_amount,
|
||||
COALESCE(p_online_payment_enabled, false), COALESCE(p_wholesale_enabled, true),
|
||||
p_pickup_location, p_fob_location, p_from_email,
|
||||
p_invoice_business_name, p_invoice_business_address, p_invoice_business_phone,
|
||||
p_invoice_business_email, p_invoice_business_website,
|
||||
p_notification_email, COALESCE(p_notification_recipients, '[]'::JSONB),
|
||||
COALESCE(p_square_sync_enabled, false)
|
||||
)
|
||||
ON CONFLICT (brand_id) DO UPDATE SET
|
||||
require_approval = COALESCE(p_require_approval, wholesale_settings.require_approval),
|
||||
min_order_amount = COALESCE(p_min_order_amount, wholesale_settings.min_order_amount),
|
||||
online_payment_enabled = COALESCE(p_online_payment_enabled, wholesale_settings.online_payment_enabled),
|
||||
wholesale_enabled = COALESCE(p_wholesale_enabled, wholesale_settings.wholesale_enabled),
|
||||
pickup_location = COALESCE(p_pickup_location, wholesale_settings.pickup_location),
|
||||
fob_location = COALESCE(p_fob_location, wholesale_settings.fob_location),
|
||||
from_email = COALESCE(p_from_email, wholesale_settings.from_email),
|
||||
invoice_business_name = COALESCE(p_invoice_business_name, wholesale_settings.invoice_business_name),
|
||||
invoice_business_address = COALESCE(p_invoice_business_address, wholesale_settings.invoice_business_address),
|
||||
invoice_business_phone = COALESCE(p_invoice_business_phone, wholesale_settings.invoice_business_phone),
|
||||
invoice_business_email = COALESCE(p_invoice_business_email, wholesale_settings.invoice_business_email),
|
||||
invoice_business_website = COALESCE(p_invoice_business_website, wholesale_settings.invoice_business_website),
|
||||
notification_email = COALESCE(p_notification_email, wholesale_settings.notification_email),
|
||||
notification_recipients = COALESCE(p_notification_recipients, wholesale_settings.notification_recipients),
|
||||
square_sync_enabled = COALESCE(p_square_sync_enabled, wholesale_settings.square_sync_enabled),
|
||||
updated_at = now()
|
||||
RETURNING jsonb_build_object(
|
||||
'success', true,
|
||||
'brand_id', brand_id,
|
||||
'square_sync_enabled', square_sync_enabled
|
||||
) INTO v_result;
|
||||
|
||||
RETURN v_result;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── 4. Update get_wholesale_settings to return square_sync_enabled ──────────
|
||||
DROP FUNCTION IF EXISTS public.get_wholesale_settings(UUID);
|
||||
CREATE OR REPLACE FUNCTION public.get_wholesale_settings(p_brand_id UUID)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_result JSONB;
|
||||
BEGIN
|
||||
SELECT jsonb_build_object(
|
||||
'id', ws.id,
|
||||
'brand_id', ws.brand_id,
|
||||
'portal_page_id', ws.portal_page_id,
|
||||
'price_sheet_page_id', ws.price_sheet_page_id,
|
||||
'require_approval', ws.require_approval,
|
||||
'min_order_amount', ws.min_order_amount,
|
||||
'online_payment_enabled', ws.online_payment_enabled,
|
||||
'wholesale_enabled', ws.wholesale_enabled,
|
||||
'pickup_location', ws.pickup_location,
|
||||
'fob_location', ws.fob_location,
|
||||
'from_email', ws.from_email,
|
||||
'invoice_business_name', ws.invoice_business_name,
|
||||
'invoice_business_address', ws.invoice_business_address,
|
||||
'invoice_business_phone', ws.invoice_business_phone,
|
||||
'invoice_business_email', ws.invoice_business_email,
|
||||
'invoice_business_website', ws.invoice_business_website,
|
||||
'notification_email', ws.notification_email,
|
||||
'notification_recipients', COALESCE(ws.notification_recipients, '[]'::JSONB),
|
||||
'square_sync_enabled', ws.square_sync_enabled,
|
||||
'last_invoice_number', ws.last_invoice_number
|
||||
) INTO v_result
|
||||
FROM wholesale_settings ws
|
||||
WHERE ws.brand_id = p_brand_id;
|
||||
|
||||
-- Fallback: return safe defaults if no record exists yet
|
||||
IF v_result IS NULL THEN
|
||||
RETURN jsonb_build_object(
|
||||
'id', NULL,
|
||||
'brand_id', p_brand_id,
|
||||
'require_approval', true,
|
||||
'wholesale_enabled', true,
|
||||
'online_payment_enabled', false,
|
||||
'square_sync_enabled', false,
|
||||
'pickup_location', '',
|
||||
'fob_location', '',
|
||||
'from_email', '',
|
||||
'invoice_business_name', '',
|
||||
'invoice_business_address', NULL,
|
||||
'invoice_business_phone', NULL,
|
||||
'invoice_business_email', NULL,
|
||||
'invoice_business_website', NULL,
|
||||
'notification_email', NULL,
|
||||
'notification_recipients', '[]'::JSONB,
|
||||
'last_invoice_number', 0
|
||||
);
|
||||
END IF;
|
||||
|
||||
RETURN v_result;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMIT;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,218 @@
|
||||
-- Migration 072: Brand Scoping for Retail Orders RPCs
|
||||
-- Ensures get_admin_orders and get_admin_order_detail enforce p_brand_id at the RPC layer.
|
||||
-- RLS policies on orders and order_items tables for brand scoping via stops.
|
||||
|
||||
-- Step 1: Drop and recreate get_admin_orders with proper brand scoping
|
||||
DROP FUNCTION IF EXISTS public.get_admin_orders(UUID);
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.get_admin_orders(p_brand_id UUID DEFAULT NULL)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_orders JSONB;
|
||||
v_stops JSONB;
|
||||
BEGIN
|
||||
IF p_brand_id IS NULL THEN
|
||||
-- platform_admin: return all orders
|
||||
SELECT COALESCE(jsonb_agg(t ORDER BY t.created_at DESC), '[]'::JSONB)
|
||||
INTO v_orders
|
||||
FROM (
|
||||
SELECT o.id, o.customer_name, o.customer_email, o.customer_phone,
|
||||
o.stop_id, o.status, o.subtotal, o.pickup_complete,
|
||||
o.pickup_completed_at, o.pickup_completed_by, o.created_at,
|
||||
o.brand_id,
|
||||
CASE WHEN o.stop_id IS NOT NULL THEN jsonb_build_object(
|
||||
'id', s.id, 'city', s.city, 'state', s.state,
|
||||
'date', s.date, 'time', s.time, 'location', s.location, 'brand_id', s.brand_id
|
||||
) END as stops
|
||||
FROM orders o LEFT JOIN stops s ON o.stop_id = s.id
|
||||
WHERE o.stop_id IS NOT NULL
|
||||
LIMIT 500
|
||||
) t;
|
||||
|
||||
SELECT COALESCE(jsonb_agg(jsonb_build_object(
|
||||
'id', id, 'city', city, 'state', state,
|
||||
'date', date, 'time', time, 'location', location, 'brand_id', brand_id
|
||||
) ORDER BY date), '[]'::JSONB)
|
||||
INTO v_stops FROM stops WHERE active = true;
|
||||
ELSE
|
||||
-- brand_admin or store_employee: restrict to brand via stops join
|
||||
SELECT COALESCE(jsonb_agg(t ORDER BY t.created_at DESC), '[]'::JSONB)
|
||||
INTO v_orders
|
||||
FROM (
|
||||
SELECT o.id, o.customer_name, o.customer_email, o.customer_phone,
|
||||
o.stop_id, o.status, o.subtotal, o.pickup_complete,
|
||||
o.pickup_completed_at, o.pickup_completed_by, o.created_at,
|
||||
o.brand_id,
|
||||
jsonb_build_object(
|
||||
'id', s.id, 'city', s.city, 'state', s.state,
|
||||
'date', s.date, 'time', s.time, 'location', s.location, 'brand_id', s.brand_id
|
||||
) as stops
|
||||
FROM orders o JOIN stops s ON o.stop_id = s.id
|
||||
WHERE s.brand_id = p_brand_id
|
||||
LIMIT 500
|
||||
) t;
|
||||
|
||||
SELECT COALESCE(jsonb_agg(jsonb_build_object(
|
||||
'id', id, 'city', city, 'state', state,
|
||||
'date', date, 'time', time, 'location', location, 'brand_id', brand_id
|
||||
) ORDER BY date), '[]'::JSONB)
|
||||
INTO v_stops FROM stops WHERE active = true AND brand_id = p_brand_id;
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object('orders', v_orders, 'stops', v_stops);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Step 2: Drop and recreate get_admin_order_detail with proper brand scoping
|
||||
DROP FUNCTION IF EXISTS public.get_admin_order_detail(UUID, UUID);
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.get_admin_order_detail(p_order_id UUID, p_brand_id UUID DEFAULT NULL)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_order JSONB;
|
||||
v_order_brand_id UUID;
|
||||
v_stop_brand_id UUID;
|
||||
BEGIN
|
||||
-- Resolve brand from order.brand_id or stop.brand_id
|
||||
SELECT COALESCE(o.brand_id, s.brand_id), s.brand_id
|
||||
INTO v_order_brand_id, v_stop_brand_id
|
||||
FROM orders o LEFT JOIN stops s ON o.stop_id = s.id
|
||||
WHERE o.id = p_order_id;
|
||||
|
||||
-- Enforce brand scoping
|
||||
IF p_brand_id IS NOT NULL AND v_order_brand_id IS NOT NULL AND v_order_brand_id != p_brand_id THEN
|
||||
RETURN jsonb_build_object('error', 'Order not found or access denied');
|
||||
END IF;
|
||||
|
||||
-- No order found
|
||||
IF v_order_brand_id IS NULL AND p_brand_id IS NOT NULL THEN
|
||||
RETURN jsonb_build_object('error', 'Order not found or access denied');
|
||||
END IF;
|
||||
|
||||
SELECT jsonb_build_object(
|
||||
'id', o.id,
|
||||
'customer_name', o.customer_name,
|
||||
'customer_email', o.customer_email,
|
||||
'customer_phone', o.customer_phone,
|
||||
'stop_id', o.stop_id,
|
||||
'status', o.status,
|
||||
'subtotal', o.subtotal,
|
||||
'pickup_complete', o.pickup_complete,
|
||||
'pickup_completed_at', o.pickup_completed_at,
|
||||
'pickup_completed_by', o.pickup_completed_by,
|
||||
'created_at', o.created_at,
|
||||
'discount_amount', o.discount_amount,
|
||||
'tax_amount', o.tax_amount,
|
||||
'tax_rate', o.tax_rate,
|
||||
'tax_location', o.tax_location,
|
||||
'discount_reason', o.discount_reason,
|
||||
'internal_notes', o.internal_notes,
|
||||
'payment_processor', o.payment_processor,
|
||||
'payment_status', o.payment_status,
|
||||
'payment_transaction_id', o.payment_transaction_id,
|
||||
'refunded_amount', o.refunded_amount,
|
||||
'refund_reason', o.refund_reason,
|
||||
'stops', CASE WHEN o.stop_id IS NOT NULL THEN jsonb_build_object(
|
||||
'id', s.id, 'city', s.city, 'state', s.state,
|
||||
'date', s.date, 'time', s.time, 'location', s.location, 'brand_id', s.brand_id
|
||||
) END,
|
||||
'order_items', COALESCE((
|
||||
SELECT jsonb_agg(jsonb_build_object(
|
||||
'id', oi.id, 'product_id', oi.product_id, 'product_name', p.name,
|
||||
'quantity', oi.quantity, 'price', oi.price,
|
||||
'products', jsonb_build_object('name', p.name)
|
||||
))
|
||||
FROM order_items oi
|
||||
JOIN products p ON oi.product_id = p.id
|
||||
WHERE oi.order_id = o.id
|
||||
), '[]'::JSONB),
|
||||
'refunds', COALESCE((
|
||||
SELECT jsonb_agg(jsonb_build_object(
|
||||
'id', r.id, 'order_id', r.order_id, 'amount', r.amount,
|
||||
'reason', r.reason, 'processor', r.processor,
|
||||
'processor_refund_id', r.processor_refund_id,
|
||||
'status', r.status, 'created_at', r.created_at
|
||||
))
|
||||
FROM refunds r
|
||||
WHERE r.order_id = o.id
|
||||
), '[]'::JSONB)
|
||||
)
|
||||
INTO v_order
|
||||
FROM orders o
|
||||
LEFT JOIN stops s ON o.stop_id = s.id
|
||||
WHERE o.id = p_order_id;
|
||||
|
||||
RETURN v_order;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Step 3: RLS on orders table — add brand-scoped SELECT policies
|
||||
ALTER TABLE public.orders ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Drop existing permissive policy if present
|
||||
DROP POLICY IF EXISTS "orders_select_all" ON public.orders;
|
||||
|
||||
-- platform_admin and brand_admin SELECT policies (stops join for brand scoping)
|
||||
CREATE POLICY "orders_select_platform_admin" ON public.orders
|
||||
FOR SELECT USING (
|
||||
auth.uid() IN (
|
||||
SELECT au.user_id FROM admin_users au WHERE au.role = 'platform_admin'
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "orders_select_brand_admin" ON public.orders
|
||||
FOR SELECT USING (
|
||||
auth.uid() IN (
|
||||
SELECT au.user_id FROM admin_users au
|
||||
WHERE au.role = 'brand_admin'
|
||||
AND (SELECT brand_id FROM stops WHERE stops.id = orders.stop_id) = au.brand_id
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "orders_select_store_employee" ON public.orders
|
||||
FOR SELECT USING (
|
||||
auth.uid() IN (
|
||||
SELECT au.user_id FROM admin_users au
|
||||
WHERE au.role = 'store_employee'
|
||||
AND (SELECT brand_id FROM stops WHERE stops.id = orders.stop_id) = au.brand_id
|
||||
)
|
||||
);
|
||||
|
||||
-- Block all mutations at RLS level — all writes go through RPCs
|
||||
DROP POLICY IF EXISTS "orders_insert_all" ON public.orders;
|
||||
CREATE POLICY "orders_insert_blocked" ON public.orders FOR INSERT WITH CHECK (false);
|
||||
DROP POLICY IF EXISTS "orders_update_all" ON public.orders;
|
||||
CREATE POLICY "orders_update_blocked" ON public.orders FOR UPDATE USING (false);
|
||||
DROP POLICY IF EXISTS "orders_delete_all" ON public.orders;
|
||||
CREATE POLICY "orders_delete_blocked" ON public.orders FOR DELETE USING (false);
|
||||
|
||||
-- Step 4: RLS on order_items table
|
||||
ALTER TABLE public.order_items ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS "order_items_select_all" ON public.order_items;
|
||||
CREATE POLICY "order_items_select_platform_admin" ON public.order_items
|
||||
FOR SELECT USING (
|
||||
auth.uid() IN (SELECT user_id FROM admin_users WHERE role = 'platform_admin')
|
||||
OR
|
||||
EXISTS (
|
||||
SELECT 1 FROM orders o
|
||||
JOIN stops s ON o.stop_id = s.id
|
||||
JOIN admin_users au ON s.brand_id = au.brand_id
|
||||
WHERE o.id = order_items.order_id AND au.user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS "order_items_insert_all" ON public.order_items;
|
||||
CREATE POLICY "order_items_insert_blocked" ON public.order_items FOR INSERT WITH CHECK (false);
|
||||
DROP POLICY IF EXISTS "order_items_update_all" ON public.order_items;
|
||||
CREATE POLICY "order_items_update_blocked" ON public.order_items FOR UPDATE USING (false);
|
||||
DROP POLICY IF EXISTS "order_items_delete_all" ON public.order_items;
|
||||
CREATE POLICY "order_items_delete_blocked" ON public.order_items FOR DELETE USING (false);
|
||||
@@ -0,0 +1,185 @@
|
||||
-- Migration 073: Add order_items to get_admin_orders for driver pickup filtering
|
||||
-- Adds fulfillment-aware order_items to the orders list RPC so the pickup
|
||||
-- portal can filter to pickup-only items and detect mixed-fulfillment orders.
|
||||
|
||||
-- Step 1: Drop and recreate get_admin_orders with order_items subquery
|
||||
DROP FUNCTION IF EXISTS public.get_admin_orders(UUID);
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.get_admin_orders(p_brand_id UUID DEFAULT NULL)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_orders JSONB;
|
||||
v_stops JSONB;
|
||||
BEGIN
|
||||
IF p_brand_id IS NULL THEN
|
||||
-- platform_admin: return all orders with order_items
|
||||
SELECT COALESCE(jsonb_agg(t ORDER BY t.created_at DESC), '[]'::JSONB)
|
||||
INTO v_orders
|
||||
FROM (
|
||||
SELECT o.id, o.customer_name, o.customer_email, o.customer_phone,
|
||||
o.stop_id, o.status, o.subtotal, o.pickup_complete,
|
||||
o.pickup_completed_at, o.pickup_completed_by, o.created_at,
|
||||
o.brand_id,
|
||||
CASE WHEN o.stop_id IS NOT NULL THEN jsonb_build_object(
|
||||
'id', s.id, 'city', s.city, 'state', s.state,
|
||||
'date', s.date, 'time', s.time, 'location', s.location, 'brand_id', s.brand_id
|
||||
) END as stops,
|
||||
COALESCE((
|
||||
SELECT jsonb_agg(jsonb_build_object(
|
||||
'id', oi.id,
|
||||
'product_id', oi.product_id,
|
||||
'product_name', p.name,
|
||||
'quantity', oi.quantity,
|
||||
'price', oi.price,
|
||||
'fulfillment', oi.fulfillment,
|
||||
'products', jsonb_build_object('name', p.name)
|
||||
) ORDER BY p.name)
|
||||
FROM order_items oi
|
||||
JOIN products p ON oi.product_id = p.id
|
||||
WHERE oi.order_id = o.id
|
||||
), '[]'::JSONB) AS order_items
|
||||
FROM orders o LEFT JOIN stops s ON o.stop_id = s.id
|
||||
WHERE o.stop_id IS NOT NULL
|
||||
LIMIT 500
|
||||
) t;
|
||||
|
||||
SELECT COALESCE(jsonb_agg(jsonb_build_object(
|
||||
'id', id, 'city', city, 'state', state,
|
||||
'date', date, 'time', time, 'location', location, 'brand_id', brand_id
|
||||
) ORDER BY date), '[]'::JSONB)
|
||||
INTO v_stops FROM stops WHERE active = true;
|
||||
ELSE
|
||||
-- brand-scoped: restrict to brand via stops join, include order_items
|
||||
SELECT COALESCE(jsonb_agg(t ORDER BY t.created_at DESC), '[]'::JSONB)
|
||||
INTO v_orders
|
||||
FROM (
|
||||
SELECT o.id, o.customer_name, o.customer_email, o.customer_phone,
|
||||
o.stop_id, o.status, o.subtotal, o.pickup_complete,
|
||||
o.pickup_completed_at, o.pickup_completed_by, o.created_at,
|
||||
o.brand_id,
|
||||
jsonb_build_object(
|
||||
'id', s.id, 'city', s.city, 'state', s.state,
|
||||
'date', s.date, 'time', s.time, 'location', s.location, 'brand_id', s.brand_id
|
||||
) as stops,
|
||||
COALESCE((
|
||||
SELECT jsonb_agg(jsonb_build_object(
|
||||
'id', oi.id,
|
||||
'product_id', oi.product_id,
|
||||
'product_name', p.name,
|
||||
'quantity', oi.quantity,
|
||||
'price', oi.price,
|
||||
'fulfillment', oi.fulfillment,
|
||||
'products', jsonb_build_object('name', p.name)
|
||||
) ORDER BY p.name)
|
||||
FROM order_items oi
|
||||
JOIN products p ON oi.product_id = p.id
|
||||
WHERE oi.order_id = o.id
|
||||
), '[]'::JSONB) AS order_items
|
||||
FROM orders o JOIN stops s ON o.stop_id = s.id
|
||||
WHERE s.brand_id = p_brand_id
|
||||
LIMIT 500
|
||||
) t;
|
||||
|
||||
SELECT COALESCE(jsonb_agg(jsonb_build_object(
|
||||
'id', id, 'city', city, 'state', state,
|
||||
'date', date, 'time', time, 'location', location, 'brand_id', brand_id
|
||||
) ORDER BY date), '[]'::JSONB)
|
||||
INTO v_stops FROM stops WHERE active = true AND brand_id = p_brand_id;
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object('orders', v_orders, 'stops', v_stops);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Step 2: Also update get_admin_order_detail to use consistent order_items shape
|
||||
-- (it already has fulfillment, but ensure the shape matches what DriverPickupPanel expects)
|
||||
DROP FUNCTION IF EXISTS public.get_admin_order_detail(UUID, UUID);
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.get_admin_order_detail(p_order_id UUID, p_brand_id UUID DEFAULT NULL)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_order JSONB;
|
||||
v_order_brand_id UUID;
|
||||
v_stop_brand_id UUID;
|
||||
BEGIN
|
||||
SELECT COALESCE(o.brand_id, s.brand_id), s.brand_id
|
||||
INTO v_order_brand_id, v_stop_brand_id
|
||||
FROM orders o LEFT JOIN stops s ON o.stop_id = s.id
|
||||
WHERE o.id = p_order_id;
|
||||
|
||||
IF p_brand_id IS NOT NULL AND v_order_brand_id IS NOT NULL AND v_order_brand_id != p_brand_id THEN
|
||||
RETURN jsonb_build_object('error', 'Order not found or access denied');
|
||||
END IF;
|
||||
|
||||
IF v_order_brand_id IS NULL AND p_brand_id IS NOT NULL THEN
|
||||
RETURN jsonb_build_object('error', 'Order not found or access denied');
|
||||
END IF;
|
||||
|
||||
SELECT jsonb_build_object(
|
||||
'id', o.id,
|
||||
'customer_name', o.customer_name,
|
||||
'customer_email', o.customer_email,
|
||||
'customer_phone', o.customer_phone,
|
||||
'stop_id', o.stop_id,
|
||||
'status', o.status,
|
||||
'subtotal', o.subtotal,
|
||||
'pickup_complete', o.pickup_complete,
|
||||
'pickup_completed_at', o.pickup_completed_at,
|
||||
'pickup_completed_by', o.pickup_completed_by,
|
||||
'created_at', o.created_at,
|
||||
'discount_amount', o.discount_amount,
|
||||
'tax_amount', o.tax_amount,
|
||||
'tax_rate', o.tax_rate,
|
||||
'tax_location', o.tax_location,
|
||||
'discount_reason', o.discount_reason,
|
||||
'internal_notes', o.internal_notes,
|
||||
'payment_processor', o.payment_processor,
|
||||
'payment_status', o.payment_status,
|
||||
'payment_transaction_id', o.payment_transaction_id,
|
||||
'refunded_amount', o.refunded_amount,
|
||||
'refund_reason', o.refund_reason,
|
||||
'stops', CASE WHEN o.stop_id IS NOT NULL THEN jsonb_build_object(
|
||||
'id', s.id, 'city', s.city, 'state', s.state,
|
||||
'date', s.date, 'time', s.time, 'location', s.location, 'brand_id', s.brand_id
|
||||
) END,
|
||||
'order_items', COALESCE((
|
||||
SELECT jsonb_agg(jsonb_build_object(
|
||||
'id', oi.id,
|
||||
'product_id', oi.product_id,
|
||||
'product_name', p.name,
|
||||
'quantity', oi.quantity,
|
||||
'price', oi.price,
|
||||
'fulfillment', oi.fulfillment,
|
||||
'products', jsonb_build_object('name', p.name)
|
||||
) ORDER BY p.name)
|
||||
FROM order_items oi
|
||||
JOIN products p ON oi.product_id = p.id
|
||||
WHERE oi.order_id = o.id
|
||||
), '[]'::JSONB),
|
||||
'refunds', COALESCE((
|
||||
SELECT jsonb_agg(jsonb_build_object(
|
||||
'id', r.id, 'order_id', r.order_id, 'amount', r.amount,
|
||||
'reason', r.reason, 'processor', r.processor,
|
||||
'processor_refund_id', r.processor_refund_id,
|
||||
'status', r.status, 'created_at', r.created_at
|
||||
))
|
||||
FROM refunds r
|
||||
WHERE r.order_id = o.id
|
||||
), '[]'::JSONB)
|
||||
)
|
||||
INTO v_order
|
||||
FROM orders o
|
||||
LEFT JOIN stops s ON o.stop_id = s.id
|
||||
WHERE o.id = p_order_id;
|
||||
|
||||
RETURN v_order;
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,77 @@
|
||||
-- Migration 074: Products Soft Delete
|
||||
-- Adds deleted_at column, delete_product RPC with order_items guard.
|
||||
-- Mirrors wholesale_products pattern (migration 061).
|
||||
|
||||
-- Step 1: Add deleted_at column
|
||||
ALTER TABLE public.products
|
||||
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ DEFAULT NULL;
|
||||
|
||||
-- Step 2: Create delete_product RPC
|
||||
CREATE OR REPLACE FUNCTION public.delete_product(p_product_id UUID, p_brand_id UUID DEFAULT NULL)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_product RECORD;
|
||||
v_usage_count INT;
|
||||
v_brand_id UUID;
|
||||
BEGIN
|
||||
-- Fetch product and lock it
|
||||
SELECT id, brand_id INTO v_product
|
||||
FROM products
|
||||
WHERE id = p_product_id
|
||||
FOR UPDATE;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Product not found');
|
||||
END IF;
|
||||
|
||||
v_brand_id := v_product.brand_id;
|
||||
|
||||
-- Brand scoping: if p_brand_id provided, enforce it
|
||||
IF p_brand_id IS NOT NULL AND v_brand_id != p_brand_id THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Product not found');
|
||||
END IF;
|
||||
|
||||
-- Check for usage in order_items
|
||||
SELECT COUNT(*) INTO v_usage_count
|
||||
FROM order_items
|
||||
WHERE product_id = p_product_id;
|
||||
|
||||
IF v_usage_count > 0 THEN
|
||||
RETURN jsonb_build_object(
|
||||
'success', false,
|
||||
'error', 'Cannot delete — product is attached to ' || v_usage_count || ' order(s). Set availability to inactive instead.'
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- Soft delete
|
||||
UPDATE products SET deleted_at = now() WHERE id = p_product_id;
|
||||
|
||||
RETURN jsonb_build_object('success', true);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Step 3: Add RLS policy for deleted_at filtering on SELECT
|
||||
-- The existing SELECT policies in 070 will now also need deleted_at filtering
|
||||
-- for brand_admin and store_employee. We add a new policy that enforces deleted_at IS NULL.
|
||||
|
||||
-- First drop the existing brand-scoped SELECT policies (they don't filter deleted_at yet)
|
||||
DROP POLICY IF EXISTS "brand_admin_read_products" ON public.products;
|
||||
DROP POLICY IF EXISTS "store_employee_read_products" ON public.products;
|
||||
|
||||
-- Brand admin: own brand + not deleted
|
||||
CREATE POLICY "brand_admin_read_products" ON public.products
|
||||
FOR SELECT USING (
|
||||
brand_id = (SELECT brand_id FROM admin_users WHERE user_id = auth.uid() AND role = 'brand_admin' LIMIT 1)
|
||||
AND deleted_at IS NULL
|
||||
);
|
||||
|
||||
-- Store employee: own brand + not deleted
|
||||
CREATE POLICY "store_employee_read_products" ON public.products
|
||||
FOR SELECT USING (
|
||||
brand_id = (SELECT brand_id FROM admin_users WHERE user_id = auth.uid() AND role = 'store_employee' LIMIT 1)
|
||||
AND deleted_at IS NULL
|
||||
);
|
||||
@@ -0,0 +1,82 @@
|
||||
-- Migration 075: Soft-delete for stops + brand scoping enforcement
|
||||
-- 1. Add deleted_at to stops
|
||||
-- 2. Create delete_stop RPC with order guard
|
||||
-- 3. Update RLS to filter deleted_at IS NULL
|
||||
|
||||
-- ── 1. Add deleted_at column ───────────────────────────────────────────────────
|
||||
ALTER TABLE stops ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ DEFAULT NULL;
|
||||
|
||||
-- ── 2. delete_stop RPC ────────────────────────────────────────────────────────
|
||||
CREATE OR REPLACE FUNCTION public.delete_stop(p_stop_id UUID, p_brand_id UUID DEFAULT NULL)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_stop RECORD;
|
||||
v_active_order_count INT;
|
||||
v_result JSONB;
|
||||
BEGIN
|
||||
-- Lock the stop row for update
|
||||
SELECT * INTO v_stop FROM stops WHERE id = p_stop_id FOR UPDATE;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Stop not found');
|
||||
END IF;
|
||||
|
||||
-- Brand scoping: brand_admin can only delete their own stops
|
||||
IF p_brand_id IS NOT NULL AND v_stop.brand_id != p_brand_id THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Stop not found');
|
||||
ELSIF p_brand_id IS NULL AND v_stop.brand_id IS NOT NULL THEN
|
||||
-- Caller didn't pass brand_id but stop has one — guard against platform admin accident
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Stop not found');
|
||||
END IF;
|
||||
|
||||
-- Guard: check for active or future orders at this stop
|
||||
SELECT COUNT(*) INTO v_active_order_count
|
||||
FROM orders o
|
||||
JOIN stops s ON o.stop_id = s.id
|
||||
WHERE s.id = p_stop_id
|
||||
AND o.pickup_complete = false
|
||||
AND o.deleted_at IS NULL;
|
||||
|
||||
IF v_active_order_count > 0 THEN
|
||||
RETURN jsonb_build_object(
|
||||
'success', false,
|
||||
'error', 'Cannot delete stop — it has ' || v_active_order_count || ' active order(s). Mark orders as picked up first.'
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- Soft delete
|
||||
UPDATE stops SET deleted_at = NOW() WHERE id = p_stop_id;
|
||||
|
||||
RETURN jsonb_build_object('success', true);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── 3. Update RLS — add deleted_at IS NULL to brand_admin read policy ──────────
|
||||
-- drops are re-created in the same block to avoid breaking the audit SQL
|
||||
DROP POLICY IF EXISTS "brand_admin_read_stops" ON stops;
|
||||
DROP POLICY IF EXISTS "platform_admin_read_stops" ON stops;
|
||||
|
||||
-- Platform admin sees all non-deleted stops
|
||||
CREATE POLICY "platform_admin_read_stops" ON stops
|
||||
FOR SELECT USING (
|
||||
deleted_at IS NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM admin_users au
|
||||
WHERE au.user_id = auth.uid()
|
||||
AND au.role = 'platform_admin'
|
||||
)
|
||||
);
|
||||
|
||||
-- Brand admin / store_employee sees only their brand's non-deleted stops
|
||||
CREATE POLICY "brand_admin_read_stops" ON stops
|
||||
FOR SELECT USING (
|
||||
deleted_at IS NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM admin_users au
|
||||
WHERE au.user_id = auth.uid()
|
||||
AND au.role IN ('brand_admin', 'store_employee')
|
||||
AND au.brand_id = stops.brand_id
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Migration 076: Add address, zip, cutoff_time to stops
|
||||
-- Supports driver navigation (street address) and customer cutoff deadline
|
||||
|
||||
ALTER TABLE stops ADD COLUMN IF NOT EXISTS address TEXT DEFAULT NULL;
|
||||
ALTER TABLE stops ADD COLUMN IF NOT EXISTS zip TEXT DEFAULT NULL;
|
||||
ALTER TABLE stops ADD COLUMN IF NOT EXISTS cutoff_time TIMESTAMPTZ DEFAULT NULL;
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Migration 077: Add status column to stops
|
||||
-- 'draft' = imported, awaiting review | 'active' = published
|
||||
|
||||
ALTER TABLE stops ADD COLUMN IF NOT EXISTS status TEXT DEFAULT 'active' NOT NULL;
|
||||
|
||||
-- Set existing stops to 'active'
|
||||
UPDATE stops SET status = 'active' WHERE status IS NULL;
|
||||
|
||||
ALTER TABLE stops ALTER COLUMN status SET NOT NULL;
|
||||
@@ -0,0 +1,63 @@
|
||||
-- Migration 078: Over-deposit guard on wholesale deposits
|
||||
-- Prevents recording a deposit that exceeds the remaining balance_due
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.record_wholesale_deposit(
|
||||
p_order_id UUID DEFAULT NULL,
|
||||
p_amount NUMERIC DEFAULT NULL,
|
||||
p_method TEXT DEFAULT 'cash',
|
||||
p_reference TEXT DEFAULT NULL,
|
||||
p_recorded_by UUID DEFAULT NULL
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_new_paid NUMERIC(10,2);
|
||||
v_balance_due NUMERIC(10,2);
|
||||
BEGIN
|
||||
-- Guard: amount must be positive
|
||||
IF p_amount IS NULL OR p_amount <= 0 THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Deposit amount must be greater than zero.');
|
||||
END IF;
|
||||
|
||||
-- Guard: cannot exceed remaining balance_due
|
||||
SELECT balance_due INTO v_balance_due
|
||||
FROM wholesale_orders
|
||||
WHERE id = p_order_id;
|
||||
|
||||
IF v_balance_due IS NULL THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Order not found.');
|
||||
END IF;
|
||||
|
||||
IF p_amount > v_balance_due THEN
|
||||
RETURN jsonb_build_object(
|
||||
'success', false,
|
||||
'error', 'Deposit of $' || p_amount::TEXT || ' exceeds the remaining balance of $' || v_balance_due::TEXT || '.'
|
||||
);
|
||||
END IF;
|
||||
|
||||
-- Compute new deposit_paid
|
||||
SELECT deposit_paid + p_amount INTO v_new_paid
|
||||
FROM wholesale_orders
|
||||
WHERE id = p_order_id;
|
||||
|
||||
UPDATE wholesale_orders
|
||||
SET
|
||||
deposit_paid = v_new_paid,
|
||||
balance_due = subtotal - v_new_paid,
|
||||
updated_at = now()
|
||||
WHERE id = p_order_id;
|
||||
|
||||
INSERT INTO wholesale_deposits (wholesale_order_id, amount, payment_method, reference, recorded_by)
|
||||
VALUES (p_order_id, p_amount, p_method, p_reference, p_recorded_by);
|
||||
|
||||
-- Advance to pending once deposit covers requirement
|
||||
UPDATE wholesale_orders
|
||||
SET status = 'pending'
|
||||
WHERE id = p_order_id
|
||||
AND v_new_paid >= deposit_required
|
||||
AND status = 'awaiting_deposit';
|
||||
|
||||
RETURN jsonb_build_object('success', true);
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Migration 079: Add delivery/open/click analytics columns to communication_message_logs
|
||||
-- Enables Resend webhook handler to update email engagement metrics
|
||||
|
||||
ALTER TABLE communication_message_logs
|
||||
ADD COLUMN IF NOT EXISTS delivered_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS opened_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS clicked_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS bounced_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS bounce_reason TEXT;
|
||||
|
||||
-- Index for efficient delivery status queries
|
||||
CREATE INDEX IF NOT EXISTS idx_message_logs_delivered_at
|
||||
ON communication_message_logs (delivered_at)
|
||||
WHERE delivered_at IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_message_logs_opened_at
|
||||
ON communication_message_logs (opened_at)
|
||||
WHERE opened_at IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_message_logs_clicked_at
|
||||
ON communication_message_logs (clicked_at)
|
||||
WHERE clicked_at IS NOT NULL;
|
||||
@@ -0,0 +1,100 @@
|
||||
-- Migration 080: communication_segments table for reusable audience segments
|
||||
-- Replaces ad-hoc JSONB audience_rules with named, saved segments
|
||||
|
||||
CREATE TABLE communication_segments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
brand_id UUID REFERENCES brands NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
rules JSONB NOT NULL DEFAULT '{}',
|
||||
-- rules shape mirrors AudienceRules:
|
||||
-- { target, stop_id, date_from, date_to, zip_codes, city,
|
||||
-- order_history, days_back, product_id, customer_ids }
|
||||
created_by UUID,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
-- Brand scoping is enforced at the application layer (server actions).
|
||||
-- RLS is enabled with an authenticated-only policy. SECURITY DEFINER RPCs
|
||||
-- continue to bypass RLS because they run as the function owner.
|
||||
ALTER TABLE communication_segments ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY "authenticated_only_communication_segments" ON communication_segments
|
||||
FOR ALL USING (auth.uid() IS NOT NULL) WITH CHECK (auth.uid() IS NOT NULL);
|
||||
|
||||
-- RPCs for segments CRUD
|
||||
CREATE OR REPLACE FUNCTION get_communication_segments(p_brand_id UUID)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN jsonb_build_object(
|
||||
'segments', (
|
||||
SELECT COALESCE(jsonb_agg(s.*), '[]'::jsonb)
|
||||
FROM (
|
||||
SELECT id, brand_id, name, description, rules,
|
||||
created_by, created_at, updated_at
|
||||
FROM communication_segments
|
||||
WHERE brand_id = p_brand_id
|
||||
ORDER BY name
|
||||
) s
|
||||
)
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION upsert_communication_segment(
|
||||
p_brand_id UUID,
|
||||
p_name TEXT,
|
||||
p_description TEXT DEFAULT NULL,
|
||||
p_rules JSONB DEFAULT '{}',
|
||||
p_created_by UUID DEFAULT NULL,
|
||||
p_id UUID DEFAULT NULL
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_result JSONB;
|
||||
BEGIN
|
||||
INSERT INTO communication_segments (id, brand_id, name, description, rules, created_by, updated_at)
|
||||
VALUES (
|
||||
COALESCE(p_id, gen_random_uuid()),
|
||||
p_brand_id,
|
||||
p_name,
|
||||
p_description,
|
||||
p_rules,
|
||||
p_created_by,
|
||||
now()
|
||||
)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
description = EXCLUDED.description,
|
||||
rules = EXCLUDED.rules,
|
||||
updated_at = now()
|
||||
RETURNING to_jsonb(communication_segments.*) INTO v_result;
|
||||
|
||||
RETURN v_result;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION delete_communication_segment(p_segment_id UUID, p_brand_id UUID)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
DELETE FROM communication_segments
|
||||
WHERE id = p_segment_id AND brand_id = p_brand_id;
|
||||
|
||||
RETURN jsonb_build_object('success', true);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ============================================================
|
||||
-- RLS for communication_segments (added by scripts/fix-archived-rls.js)
|
||||
-- Columns: id, brand_id, name, description, rules, stop_id, date_from, date_to, zip_codes, city, days_back, product_id, customer_ids, created_at, updated_at
|
||||
-- ============================================================
|
||||
ALTER TABLE communication_segments ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE communication_segments FORCE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS tenant_isolation ON communication_segments;
|
||||
CREATE POLICY tenant_isolation ON communication_segments FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
|
||||
@@ -0,0 +1,135 @@
|
||||
-- Migration 081: Stop blast via communication_campaigns
|
||||
-- Routes stop-based blast messages through the campaign system for analytics
|
||||
-- and to respect contact opt-in/opt-out preferences.
|
||||
|
||||
CREATE OR REPLACE FUNCTION send_stop_blast(
|
||||
p_stop_id UUID,
|
||||
p_brand_id UUID,
|
||||
p_body TEXT,
|
||||
p_channel TEXT DEFAULT 'email', -- 'sms', 'email', 'both'
|
||||
p_subject TEXT DEFAULT NULL,
|
||||
p_audience TEXT DEFAULT 'pending', -- 'all', 'pending', 'picked_up'
|
||||
p_created_by UUID DEFAULT NULL
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_campaign_id UUID;
|
||||
v_count INT := 0;
|
||||
v_order RECORD;
|
||||
v_contact JSONB;
|
||||
v_customer_id UUID;
|
||||
v_opted_in BOOLEAN;
|
||||
v_email TEXT;
|
||||
v_phone TEXT;
|
||||
v_brand_name TEXT := 'Route Commerce';
|
||||
v_subj TEXT;
|
||||
v_body_sub TEXT;
|
||||
BEGIN
|
||||
-- Resolve brand name for {{company_name}} substitution
|
||||
SELECT b.name INTO v_brand_name
|
||||
FROM brands b
|
||||
WHERE b.id = p_brand_id;
|
||||
IF v_brand_name IS NULL THEN
|
||||
SELECT invoice_business_name INTO v_brand_name
|
||||
FROM wholesale_settings
|
||||
WHERE brand_id = p_brand_id
|
||||
LIMIT 1;
|
||||
END IF;
|
||||
IF v_brand_name IS NULL THEN
|
||||
v_brand_name := 'Route Commerce';
|
||||
END IF;
|
||||
|
||||
v_subj := replace(replace(p_subject, '{{company_name}}', v_brand_name), '{{brand_name}}', v_brand_name);
|
||||
v_body_sub := replace(replace(p_body, '{{company_name}}', v_brand_name), '{{brand_name}}', v_brand_name);
|
||||
|
||||
-- Create campaign record
|
||||
INSERT INTO communication_campaigns (
|
||||
brand_id, name, subject, body_text,
|
||||
campaign_type, status, audience_rules,
|
||||
sent_at, created_by
|
||||
) VALUES (
|
||||
p_brand_id,
|
||||
'Stop blast: ' || COALESCE(p_subject, p_body),
|
||||
v_subj,
|
||||
v_body_sub,
|
||||
'operational',
|
||||
'sent',
|
||||
jsonb_build_object(
|
||||
'target', 'stop',
|
||||
'stop_id', p_stop_id,
|
||||
'date_from', NULL,
|
||||
'date_to', NULL
|
||||
),
|
||||
now(),
|
||||
p_created_by
|
||||
) RETURNING id INTO v_campaign_id;
|
||||
|
||||
-- Fetch matching orders
|
||||
FOR v_order IN
|
||||
SELECT o.id, o.customer_id, o.customer_name,
|
||||
o.customer_email, o.customer_phone, o.pickup_complete,
|
||||
c.email_opt_in, c.sms_opt_in, c.unsubscribed_at
|
||||
FROM orders o
|
||||
LEFT JOIN communication_contacts c ON c.id = o.customer_id
|
||||
WHERE o.stop_id = p_stop_id
|
||||
AND (
|
||||
p_audience = 'all'
|
||||
OR (p_audience = 'pending' AND NOT o.pickup_complete)
|
||||
OR (p_audience = 'picked_up' AND o.pickup_complete)
|
||||
)
|
||||
LOOP
|
||||
-- Check opt-in status per channel
|
||||
-- Default to opted-in for email (email_opt_in defaults TRUE in schema)
|
||||
-- Default to opted-OUT for SMS (sms_opt_in defaults FALSE in schema)
|
||||
v_opted_in := (
|
||||
CASE p_channel
|
||||
WHEN 'sms' THEN v_order.customer_phone IS NOT NULL
|
||||
AND COALESCE(v_order.sms_opt_in, FALSE) = TRUE
|
||||
AND v_order.unsubscribed_at IS NULL
|
||||
WHEN 'email' THEN v_order.customer_email IS NOT NULL
|
||||
AND COALESCE(v_order.email_opt_in, TRUE) = TRUE
|
||||
AND v_order.unsubscribed_at IS NULL
|
||||
ELSE v_order.customer_email IS NOT NULL
|
||||
AND COALESCE(v_order.email_opt_in, TRUE) = TRUE
|
||||
AND v_order.unsubscribed_at IS NULL
|
||||
END
|
||||
);
|
||||
|
||||
IF NOT v_opted_in THEN
|
||||
CONTINUE;
|
||||
END IF;
|
||||
|
||||
-- Log email message
|
||||
IF p_channel IN ('email', 'both') AND v_order.customer_email IS NOT NULL THEN
|
||||
INSERT INTO communication_message_logs (
|
||||
brand_id, campaign_id, customer_id, customer_email,
|
||||
delivery_method, subject, body_preview, status, sent_at
|
||||
) VALUES (
|
||||
p_brand_id, v_campaign_id, v_order.customer_id, v_order.customer_email,
|
||||
'email', v_subj, LEFT(v_body_sub, 200), 'queued', now()
|
||||
);
|
||||
v_count := v_count + 1;
|
||||
END IF;
|
||||
|
||||
-- Log SMS message
|
||||
IF p_channel IN ('sms', 'both') AND v_order.customer_phone IS NOT NULL THEN
|
||||
INSERT INTO communication_message_logs (
|
||||
brand_id, campaign_id, customer_id, customer_phone,
|
||||
delivery_method, body_preview, status, sent_at
|
||||
) VALUES (
|
||||
p_brand_id, v_campaign_id, v_order.customer_id, v_order.customer_phone,
|
||||
'sms', LEFT(v_body_sub, 160), 'queued', now()
|
||||
);
|
||||
v_count := v_count + 1;
|
||||
END IF;
|
||||
END LOOP;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'success', true,
|
||||
'campaign_id', v_campaign_id,
|
||||
'messages_logged', v_count
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,74 @@
|
||||
-- Migration 082: Substitute {{company_name}} with brand name in campaign emails
|
||||
-- Updates send_campaign to look up brands.name and replace {{company_name}}
|
||||
-- in subject and body_text before queuing messages.
|
||||
|
||||
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_brand RECORD;
|
||||
v_audience JSONB;
|
||||
v_entries JSONB := '[]'::JSONB;
|
||||
v_customer JSONB;
|
||||
v_count INTEGER := 0;
|
||||
v_brand_name TEXT := 'Route Commerce';
|
||||
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;
|
||||
|
||||
-- Look up brand name for {{company_name}} substitution
|
||||
SELECT b.name INTO v_brand_name
|
||||
FROM brands b
|
||||
WHERE b.id = v_campaign.brand_id;
|
||||
-- Fall back to wholesale_settings if brand name not found
|
||||
IF v_brand_name IS NULL THEN
|
||||
SELECT invoice_business_name INTO v_brand_name
|
||||
FROM wholesale_settings
|
||||
WHERE brand_id = v_campaign.brand_id
|
||||
LIMIT 1;
|
||||
END IF;
|
||||
IF v_brand_name IS NULL THEN
|
||||
v_brand_name := 'Route Commerce';
|
||||
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',
|
||||
-- Substitute {{company_name}} in subject
|
||||
replace(replace(v_campaign.subject, '{{company_name}}', v_brand_name), '{{brand_name}}', v_brand_name),
|
||||
'body_preview', left(replace(replace(v_campaign.body_text, '{{company_name}}', v_brand_name), '{{brand_name}}', v_brand_name), 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;
|
||||
$$;
|
||||
@@ -0,0 +1,99 @@
|
||||
-- Migration 083: Shipping Settings + FedEx Credentials + Perishable Flag on Products
|
||||
-- Stores FedEx API credentials and perishable handling preferences per brand.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.shipping_settings (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
brand_id UUID REFERENCES brands ON DELETE CASCADE,
|
||||
carrier TEXT NOT NULL DEFAULT 'fedex',
|
||||
|
||||
-- FedEx credentials
|
||||
fedex_account_number TEXT,
|
||||
fedex_api_key TEXT,
|
||||
fedex_api_secret TEXT,
|
||||
fedex_use_production BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
-- Default service type for non-perishable orders
|
||||
default_service_type TEXT NOT NULL DEFAULT 'FEDEX_GROUND',
|
||||
|
||||
-- Handling instructions for perishable goods (sweet corn, onions, etc.)
|
||||
refrigerated_handling_notes TEXT,
|
||||
fragile_handling_notes TEXT,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- One settings row per brand
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS shipping_settings_brand_idx
|
||||
ON public.shipping_settings (brand_id)
|
||||
WHERE brand_id IS NOT NULL;
|
||||
|
||||
ALTER TABLE public.shipping_settings ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Brand admins manage their own settings; platform admins manage all
|
||||
CREATE POLICY brand_admin_manage_shipping_settings ON public.shipping_settings
|
||||
FOR ALL
|
||||
USING (
|
||||
brand_id IS NOT NULL
|
||||
AND auth.uid() IN (
|
||||
SELECT user_id FROM admin_users
|
||||
WHERE brand_id = shipping_settings.brand_id
|
||||
AND role IN ('brand_admin', 'platform_admin')
|
||||
)
|
||||
);
|
||||
|
||||
-- Platform admin global settings row (brand_id = NULL)
|
||||
CREATE POLICY platform_admin_manage_global_shipping_settings ON public.shipping_settings
|
||||
FOR ALL
|
||||
USING (
|
||||
brand_id IS NULL
|
||||
AND auth.uid() IN (
|
||||
SELECT user_id FROM admin_users
|
||||
WHERE role = 'platform_admin'
|
||||
)
|
||||
);
|
||||
|
||||
-- ── Add is_perishable to products ─────────────────────────────────────────
|
||||
-- Flags a product as perishable (sweet corn, onions, etc.)
|
||||
-- Perishable orders can ONLY ship via OVERNIGHT or 2DAY air.
|
||||
|
||||
ALTER TABLE public.products
|
||||
ADD COLUMN IF NOT EXISTS is_perishable BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
COMMENT ON COLUMN products.is_perishable IS
|
||||
'If true, this product can only ship via FEDEX_OVERNIGHT or FEDEX_2_DAY_AIR. Affects FedEx rate filtering.';
|
||||
|
||||
-- ── RPC: get_order_items_perishable ────────────────────────────────────────
|
||||
-- Returns true if ALL ship-able items in an order are perishable.
|
||||
-- Used by FedEx rate filtering logic.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.get_order_items_perishable(p_order_id UUID)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_result JSONB;
|
||||
v_has_shipping_items BOOLEAN;
|
||||
v_all_perishable BOOLEAN;
|
||||
BEGIN
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM order_items oi
|
||||
JOIN products p ON p.id = oi.product_id
|
||||
WHERE oi.order_id = p_order_id AND oi.fulfillment = 'ship'
|
||||
) INTO v_has_shipping_items;
|
||||
|
||||
IF NOT v_has_shipping_items THEN
|
||||
RETURN jsonb_build_object('is_perishable', false);
|
||||
END IF;
|
||||
|
||||
SELECT NOT EXISTS (
|
||||
SELECT 1 FROM order_items oi
|
||||
JOIN products p ON p.id = oi.product_id
|
||||
WHERE oi.order_id = p_order_id
|
||||
AND oi.fulfillment = 'ship'
|
||||
AND COALESCE(p.is_perishable, false) = FALSE
|
||||
) INTO v_all_perishable;
|
||||
|
||||
RETURN jsonb_build_object('is_perishable', v_all_perishable);
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,140 @@
|
||||
-- Migration 083: Add stripe_user_id support to payment_settings for Stripe Connect
|
||||
-- This enables Stripe Connect Express account onboarding
|
||||
|
||||
-- First, add stripe_user_id column to payment_settings table if it doesn't exist
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'payment_settings' AND column_name = 'stripe_user_id') THEN
|
||||
ALTER TABLE payment_settings ADD COLUMN stripe_user_id TEXT;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Update upsert_payment_settings to support stripe_user_id
|
||||
CREATE OR REPLACE FUNCTION public.upsert_payment_settings(
|
||||
p_brand_id UUID,
|
||||
p_provider TEXT,
|
||||
p_stripe_publishable_key TEXT DEFAULT NULL,
|
||||
p_stripe_secret_key TEXT DEFAULT NULL,
|
||||
p_stripe_user_id TEXT DEFAULT NULL,
|
||||
p_square_access_token TEXT DEFAULT NULL,
|
||||
p_square_location_id TEXT DEFAULT NULL,
|
||||
p_square_sync_enabled BOOLEAN DEFAULT NULL,
|
||||
p_square_inventory_mode TEXT DEFAULT NULL
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
INSERT INTO payment_settings (
|
||||
brand_id, provider,
|
||||
stripe_publishable_key, stripe_secret_key, stripe_user_id,
|
||||
square_access_token, square_location_id,
|
||||
square_sync_enabled, square_inventory_mode
|
||||
)
|
||||
VALUES (
|
||||
p_brand_id, p_provider,
|
||||
p_stripe_publishable_key, p_stripe_secret_key, p_stripe_user_id,
|
||||
p_square_access_token, p_square_location_id,
|
||||
COALESCE(p_square_sync_enabled, false),
|
||||
COALESCE(p_square_inventory_mode, 'none')
|
||||
)
|
||||
ON CONFLICT (brand_id) DO UPDATE SET
|
||||
provider = COALESCE(p_provider, payment_settings.provider),
|
||||
stripe_publishable_key = COALESCE(p_stripe_publishable_key, payment_settings.stripe_publishable_key),
|
||||
stripe_secret_key = COALESCE(p_stripe_secret_key, payment_settings.stripe_secret_key),
|
||||
stripe_user_id = COALESCE(p_stripe_user_id, payment_settings.stripe_user_id),
|
||||
square_access_token = COALESCE(p_square_access_token, payment_settings.square_access_token),
|
||||
square_location_id = COALESCE(p_square_location_id, payment_settings.square_location_id),
|
||||
square_sync_enabled = COALESCE(p_square_sync_enabled, payment_settings.square_sync_enabled),
|
||||
square_inventory_mode = COALESCE(p_square_inventory_mode, payment_settings.square_inventory_mode),
|
||||
updated_at = now()
|
||||
RETURNING jsonb_build_object(
|
||||
'id', id,
|
||||
'brand_id', brand_id,
|
||||
'provider', provider,
|
||||
'stripe_user_id', stripe_user_id,
|
||||
'stripe_publishable_key', stripe_publishable_key,
|
||||
'square_sync_enabled', square_sync_enabled,
|
||||
'square_inventory_mode', square_inventory_mode,
|
||||
'square_last_sync_at', square_last_sync_at,
|
||||
'square_last_sync_error', square_last_sync_error
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Create function to get brand payment settings
|
||||
CREATE OR REPLACE FUNCTION public.get_payment_settings(
|
||||
p_brand_id UUID
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN (
|
||||
SELECT jsonb_build_object(
|
||||
'id', id,
|
||||
'brand_id', brand_id,
|
||||
'provider', provider,
|
||||
'stripe_publishable_key', stripe_publishable_key,
|
||||
'stripe_secret_key', stripe_secret_key,
|
||||
'stripe_user_id', stripe_user_id,
|
||||
'square_access_token', square_access_token,
|
||||
'square_location_id', square_location_id,
|
||||
'square_sync_enabled', square_sync_enabled,
|
||||
'square_inventory_mode', square_inventory_mode,
|
||||
'square_last_sync_at', square_last_sync_at,
|
||||
'square_last_sync_error', square_last_sync_error,
|
||||
'updated_at', updated_at
|
||||
)
|
||||
FROM payment_settings
|
||||
WHERE brand_id = p_brand_id
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Create function to set Stripe Connect account
|
||||
CREATE OR REPLACE FUNCTION public.set_stripe_connect_account(
|
||||
p_brand_id UUID,
|
||||
p_stripe_user_id TEXT
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
INSERT INTO payment_settings (brand_id, provider, stripe_user_id)
|
||||
VALUES (p_brand_id, 'stripe', p_stripe_user_id)
|
||||
ON CONFLICT (brand_id) DO UPDATE SET
|
||||
stripe_user_id = p_stripe_user_id,
|
||||
provider = 'stripe',
|
||||
updated_at = now()
|
||||
RETURNING jsonb_build_object(
|
||||
'id', id,
|
||||
'brand_id', brand_id,
|
||||
'stripe_user_id', stripe_user_id,
|
||||
'provider', provider
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Create function to disconnect Stripe Connect
|
||||
CREATE OR REPLACE FUNCTION public.disconnect_stripe_connect(
|
||||
p_brand_id UUID
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
UPDATE payment_settings
|
||||
SET
|
||||
stripe_user_id = NULL,
|
||||
stripe_publishable_key = NULL,
|
||||
stripe_secret_key = NULL,
|
||||
provider = 'manual',
|
||||
updated_at = now()
|
||||
WHERE brand_id = p_brand_id;
|
||||
|
||||
RETURN jsonb_build_object('success', true, 'brand_id', p_brand_id);
|
||||
END;
|
||||
$$;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -0,0 +1,73 @@
|
||||
-- Migration 084: FedEx Shipments Table
|
||||
-- Stores created FedEx shipment records with label URL, tracking, and special services.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.shipments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
order_id UUID NOT NULL REFERENCES orders ON DELETE CASCADE,
|
||||
|
||||
-- Carrier details
|
||||
carrier TEXT NOT NULL DEFAULT 'fedex',
|
||||
service_type TEXT NOT NULL, -- FEDEX_OVERNIGHT, FEDEX_2_DAY_AIR, FEDEX_GROUND, etc.
|
||||
tracking_number TEXT,
|
||||
label_url TEXT, -- FedEx label PDF URL
|
||||
rate_charged NUMERIC(10, 2), -- Actual rate charged by FedEx (cents)
|
||||
|
||||
-- Delivery estimates
|
||||
estimated_delivery_date DATE,
|
||||
|
||||
-- Special handling applied
|
||||
is_refrigerated BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
is_fragile BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
handling_notes TEXT, -- Custom per-shipment notes
|
||||
|
||||
-- Status
|
||||
status TEXT NOT NULL DEFAULT 'created', -- created | label_voided | cancelled
|
||||
fedex_shipment_id TEXT, -- FedEx internal shipment ID (for voiding)
|
||||
|
||||
-- Audit
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
created_by UUID REFERENCES admin_users(user_id)
|
||||
);
|
||||
|
||||
ALTER TABLE public.shipments ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Shipments visible to brand admins and platform admins for the order's brand
|
||||
CREATE POLICY brand_admin_view_shipments ON public.shipments
|
||||
FOR SELECT
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM orders o
|
||||
WHERE o.id = shipments.order_id
|
||||
AND o.brand_id = (
|
||||
SELECT brand_id FROM admin_users WHERE user_id = auth.uid() LIMIT 1
|
||||
)
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM admin_users au
|
||||
WHERE au.user_id = auth.uid()
|
||||
AND au.role = 'platform_admin'
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY brand_admin_manage_shipments ON public.shipments
|
||||
FOR ALL
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM orders o
|
||||
WHERE o.id = shipments.order_id
|
||||
AND o.brand_id = (
|
||||
SELECT brand_id FROM admin_users WHERE user_id = auth.uid() LIMIT 1
|
||||
)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM admin_users au
|
||||
WHERE au.user_id = auth.uid()
|
||||
AND au.brand_id = o.brand_id
|
||||
AND au.role IN ('brand_admin', 'platform_admin')
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS shipments_order_idx ON public.shipments (order_id);
|
||||
CREATE INDEX IF NOT EXISTS shipments_tracking_idx ON public.shipments (tracking_number);
|
||||
CREATE INDEX IF NOT EXISTS shipments_status_idx ON public.shipments (status);
|
||||
@@ -0,0 +1,154 @@
|
||||
-- Migration 085: Brand Settings Table
|
||||
-- Single source of truth for brand/company information.
|
||||
-- Used by email templates, invoices, site headers, and all brand-facing output.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.brand_settings (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
|
||||
|
||||
-- Company info
|
||||
legal_business_name TEXT,
|
||||
phone TEXT,
|
||||
email TEXT,
|
||||
website_url TEXT,
|
||||
|
||||
-- Address
|
||||
street_address TEXT,
|
||||
city TEXT,
|
||||
state TEXT,
|
||||
postal_code TEXT,
|
||||
country TEXT DEFAULT 'US',
|
||||
|
||||
-- Logos (Supabase Storage public URLs)
|
||||
logo_url TEXT,
|
||||
logo_url_dark TEXT, -- For dark backgrounds (optional)
|
||||
|
||||
-- Branding for emails/invoices
|
||||
default_email_signature TEXT,
|
||||
invoice_footer_notes TEXT,
|
||||
|
||||
-- Meta
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS brand_settings_brand_idx
|
||||
ON public.brand_settings (brand_id);
|
||||
|
||||
ALTER TABLE public.brand_settings ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Brand admins and platform admins can manage their own settings
|
||||
CREATE POLICY brand_admin_manage_brand_settings ON public.brand_settings
|
||||
FOR ALL
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM admin_users au
|
||||
WHERE au.user_id = auth.uid()
|
||||
AND au.brand_id = brand_settings.brand_id
|
||||
AND au.role IN ('brand_admin', 'platform_admin')
|
||||
)
|
||||
);
|
||||
|
||||
-- Read policy: brand admins + platform admins can read
|
||||
CREATE POLICY brand_admin_read_brand_settings ON public.brand_settings
|
||||
FOR SELECT
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM admin_users au
|
||||
WHERE au.user_id = auth.uid()
|
||||
AND au.brand_id = brand_settings.brand_id
|
||||
)
|
||||
);
|
||||
|
||||
-- ── RPCs ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.get_brand_settings(p_brand_id UUID)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_result JSONB;
|
||||
BEGIN
|
||||
SELECT jsonb_build_object(
|
||||
'id', bs.id,
|
||||
'brand_id', bs.brand_id,
|
||||
'legal_business_name', bs.legal_business_name,
|
||||
'phone', bs.phone,
|
||||
'email', bs.email,
|
||||
'website_url', bs.website_url,
|
||||
'street_address', bs.street_address,
|
||||
'city', bs.city,
|
||||
'state', bs.state,
|
||||
'postal_code', bs.postal_code,
|
||||
'country', bs.country,
|
||||
'logo_url', bs.logo_url,
|
||||
'logo_url_dark', bs.logo_url_dark,
|
||||
'default_email_signature', bs.default_email_signature,
|
||||
'invoice_footer_notes', bs.invoice_footer_notes,
|
||||
'updated_at', bs.updated_at,
|
||||
'brand_name', b.name
|
||||
)
|
||||
INTO v_result
|
||||
FROM brand_settings bs
|
||||
JOIN brands b ON b.id = bs.brand_id
|
||||
WHERE bs.brand_id = p_brand_id;
|
||||
|
||||
RETURN COALESCE(v_result, jsonb_build_object('brand_id', p_brand_id, 'brand_name', (
|
||||
SELECT name FROM brands WHERE id = p_brand_id
|
||||
)));
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.upsert_brand_settings(
|
||||
p_brand_id UUID,
|
||||
p_legal_business_name TEXT DEFAULT NULL,
|
||||
p_phone TEXT DEFAULT NULL,
|
||||
p_email TEXT DEFAULT NULL,
|
||||
p_website_url TEXT DEFAULT NULL,
|
||||
p_street_address TEXT DEFAULT NULL,
|
||||
p_city TEXT DEFAULT NULL,
|
||||
p_state TEXT DEFAULT NULL,
|
||||
p_postal_code TEXT DEFAULT NULL,
|
||||
p_country TEXT DEFAULT NULL,
|
||||
p_logo_url TEXT DEFAULT NULL,
|
||||
p_logo_url_dark TEXT DEFAULT NULL,
|
||||
p_default_email_signature TEXT DEFAULT NULL,
|
||||
p_invoice_footer_notes TEXT DEFAULT NULL
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_id UUID;
|
||||
BEGIN
|
||||
INSERT INTO brand_settings (
|
||||
brand_id, legal_business_name, phone, email, website_url,
|
||||
street_address, city, state, postal_code, country,
|
||||
logo_url, logo_url_dark, default_email_signature, invoice_footer_notes
|
||||
) VALUES (
|
||||
p_brand_id, p_legal_business_name, p_phone, p_email, p_website_url,
|
||||
p_street_address, p_city, p_state, p_postal_code, p_country,
|
||||
p_logo_url, p_logo_url_dark, p_default_email_signature, p_invoice_footer_notes
|
||||
)
|
||||
ON CONFLICT (brand_id) DO UPDATE SET
|
||||
legal_business_name = COALESCE(p_legal_business_name, brand_settings.legal_business_name),
|
||||
phone = COALESCE(p_phone, brand_settings.phone),
|
||||
email = COALESCE(p_email, brand_settings.email),
|
||||
website_url = COALESCE(p_website_url, brand_settings.website_url),
|
||||
street_address = COALESCE(p_street_address, brand_settings.street_address),
|
||||
city = COALESCE(p_city, brand_settings.city),
|
||||
state = COALESCE(p_state, brand_settings.state),
|
||||
postal_code = COALESCE(p_postal_code, brand_settings.postal_code),
|
||||
country = COALESCE(p_country, brand_settings.country),
|
||||
logo_url = COALESCE(p_logo_url, brand_settings.logo_url),
|
||||
logo_url_dark = COALESCE(p_logo_url_dark, brand_settings.logo_url_dark),
|
||||
default_email_signature = COALESCE(p_default_email_signature, brand_settings.default_email_signature),
|
||||
invoice_footer_notes = COALESCE(p_invoice_footer_notes, brand_settings.invoice_footer_notes),
|
||||
updated_at = now()
|
||||
WHERE brand_settings.brand_id = p_brand_id
|
||||
RETURNING id INTO v_id;
|
||||
|
||||
-- Return the updated record
|
||||
RETURN get_brand_settings(p_brand_id);
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,330 @@
|
||||
-- Migration 086: Enhance email substitution with brand_settings
|
||||
-- Updates send_campaign and send_stop_blast to:
|
||||
-- 1. Look up brand_settings for legal_business_name (falls back to brands.name)
|
||||
-- 2. Append default_email_signature to outbound emails if set
|
||||
-- 3. Support {{address}}, {{phone}}, {{email}}, {{website}} variables
|
||||
--
|
||||
-- Variable substitution priority:
|
||||
-- {{company_name}} → legal_business_name (brand_settings) → brands.name → fallback
|
||||
-- {{address}} → street_address, city, state postal_code from brand_settings
|
||||
-- {{phone}} → phone from brand_settings
|
||||
-- {{email}} → email from brand_settings
|
||||
-- {{website}} → website_url from brand_settings
|
||||
-- {{signature}} → default_email_signature from brand_settings (appended to body)
|
||||
|
||||
-- ── Enhanced send_campaign ────────────────────────────────────────────────────
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.send_campaign(p_campaign_id UUID, p_brand_id UUID DEFAULT NULL)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_campaign communication_campaigns;
|
||||
v_settings communication_settings;
|
||||
v_brand_settings RECORD;
|
||||
v_brand_name TEXT := 'Route Commerce';
|
||||
v_signature TEXT;
|
||||
v_address TEXT;
|
||||
v_phone TEXT;
|
||||
v_email TEXT;
|
||||
v_website TEXT;
|
||||
v_body_final TEXT;
|
||||
v_subj_final TEXT;
|
||||
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;
|
||||
|
||||
-- Resolve effective brand_id
|
||||
IF p_brand_id IS NOT NULL THEN
|
||||
-- Use provided brand_id
|
||||
ELSIF v_campaign.brand_id IS NOT NULL THEN
|
||||
p_brand_id := v_campaign.brand_id;
|
||||
END IF;
|
||||
|
||||
-- Look up brand_settings for extended variables
|
||||
SELECT
|
||||
bs.legal_business_name,
|
||||
bs.phone,
|
||||
bs.email,
|
||||
bs.website_url,
|
||||
bs.default_email_signature,
|
||||
bs.street_address,
|
||||
bs.city,
|
||||
bs.state,
|
||||
bs.postal_code,
|
||||
b.name AS brand_name
|
||||
INTO v_brand_settings
|
||||
FROM brand_settings bs
|
||||
JOIN brands b ON b.id = bs.brand_id
|
||||
WHERE bs.brand_id = p_brand_id;
|
||||
|
||||
-- Build brand name: legal_business_name > brand_name > fallback
|
||||
v_brand_name := COALESCE(
|
||||
v_brand_settings.legal_business_name,
|
||||
v_brand_settings.brand_name,
|
||||
'Route Commerce'
|
||||
);
|
||||
|
||||
-- Build full address string
|
||||
IF v_brand_settings.street_address IS NOT NULL THEN
|
||||
v_address := v_brand_settings.street_address
|
||||
|| ', ' || COALESCE(v_brand_settings.city, '')
|
||||
|| ', ' || COALESCE(v_brand_settings.state, '')
|
||||
|| ' ' || COALESCE(v_brand_settings.postal_code, '');
|
||||
-- Clean up extra commas from empty parts
|
||||
v_address := trim(both ',' from v_address);
|
||||
ELSE
|
||||
v_address := NULL;
|
||||
END IF;
|
||||
|
||||
v_phone := v_brand_settings.phone;
|
||||
v_email := v_brand_settings.email;
|
||||
v_website := v_brand_settings.website_url;
|
||||
v_signature := v_brand_settings.default_email_signature;
|
||||
|
||||
-- Apply substitutions to subject
|
||||
v_subj_final := v_campaign.subject;
|
||||
v_subj_final := replace(v_subj_final, '{{company_name}}', COALESCE(v_brand_name, 'Route Commerce'));
|
||||
v_subj_final := replace(v_subj_final, '{{brand_name}}', COALESCE(v_brand_name, 'Route Commerce'));
|
||||
v_subj_final := replace(v_subj_final, '{{address}}', COALESCE(v_address, ''));
|
||||
v_subj_final := replace(v_subj_final, '{{phone}}', COALESCE(v_phone, ''));
|
||||
v_subj_final := replace(v_subj_final, '{{email}}', COALESCE(v_email, ''));
|
||||
v_subj_final := replace(v_subj_final, '{{website}}', COALESCE(v_website, ''));
|
||||
|
||||
-- Apply substitutions to body, then append signature
|
||||
v_body_final := v_campaign.body_text;
|
||||
v_body_final := replace(v_body_final, '{{company_name}}', COALESCE(v_brand_name, 'Route Commerce'));
|
||||
v_body_final := replace(v_body_final, '{{brand_name}}', COALESCE(v_brand_name, 'Route Commerce'));
|
||||
v_body_final := replace(v_body_final, '{{address}}', COALESCE(v_address, ''));
|
||||
v_body_final := replace(v_body_final, '{{phone}}', COALESCE(v_phone, ''));
|
||||
v_body_final := replace(v_body_final, '{{email}}', COALESCE(v_email, ''));
|
||||
v_body_final := replace(v_body_final, '{{website}}', COALESCE(v_website, ''));
|
||||
|
||||
-- Remove {{signature}} placeholder from body (replaced with actual signature below)
|
||||
v_body_final := replace(v_body_final, '{{signature}}', '');
|
||||
|
||||
-- Append default email signature if set
|
||||
IF v_signature IS NOT NULL AND v_signature != '' THEN
|
||||
v_body_final := v_body_final || E'\n\n' || v_signature;
|
||||
END IF;
|
||||
|
||||
SELECT * INTO v_settings
|
||||
FROM communication_settings
|
||||
WHERE brand_id = p_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(p_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', p_brand_id,
|
||||
'campaign_id', p_campaign_id,
|
||||
'customer_id', v_customer->>'id',
|
||||
'customer_email', v_customer->>'email',
|
||||
'delivery_method', 'email',
|
||||
'subject', v_subj_final,
|
||||
'body_preview', left(v_body_final, 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;
|
||||
$$;
|
||||
|
||||
-- ── Enhanced send_stop_blast ───────────────────────────────────────────────────
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.send_stop_blast(
|
||||
p_stop_id UUID,
|
||||
p_brand_id UUID,
|
||||
p_body TEXT,
|
||||
p_channel TEXT DEFAULT 'email',
|
||||
p_subject TEXT DEFAULT NULL,
|
||||
p_audience TEXT DEFAULT 'pending',
|
||||
p_created_by UUID DEFAULT NULL
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_campaign_id UUID;
|
||||
v_count INT := 0;
|
||||
v_order RECORD;
|
||||
v_contact JSONB;
|
||||
v_customer_id UUID;
|
||||
v_opted_in BOOLEAN;
|
||||
v_email TEXT;
|
||||
v_phone TEXT;
|
||||
v_brand_name TEXT := 'Route Commerce';
|
||||
v_signature TEXT;
|
||||
v_address TEXT;
|
||||
v_subj TEXT;
|
||||
v_body_sub TEXT;
|
||||
v_brand_settings RECORD;
|
||||
BEGIN
|
||||
-- Look up brand_settings for extended variables
|
||||
SELECT
|
||||
bs.legal_business_name,
|
||||
bs.phone,
|
||||
bs.email,
|
||||
bs.website_url,
|
||||
bs.default_email_signature,
|
||||
bs.street_address,
|
||||
bs.city,
|
||||
bs.state,
|
||||
bs.postal_code,
|
||||
b.name AS brand_name
|
||||
INTO v_brand_settings
|
||||
FROM brand_settings bs
|
||||
JOIN brands b ON b.id = p_brand_id
|
||||
WHERE bs.brand_id = p_brand_id;
|
||||
|
||||
-- Build brand name: legal_business_name > brand_name > fallback
|
||||
v_brand_name := COALESCE(
|
||||
v_brand_settings.legal_business_name,
|
||||
v_brand_settings.brand_name,
|
||||
'Route Commerce'
|
||||
);
|
||||
|
||||
-- Build full address string
|
||||
IF v_brand_settings.street_address IS NOT NULL THEN
|
||||
v_address := v_brand_settings.street_address
|
||||
|| ', ' || COALESCE(v_brand_settings.city, '')
|
||||
|| ', ' || COALESCE(v_brand_settings.state, '')
|
||||
|| ' ' || COALESCE(v_brand_settings.postal_code, '');
|
||||
v_address := trim(both ',' from v_address);
|
||||
ELSE
|
||||
v_address := NULL;
|
||||
END IF;
|
||||
|
||||
v_signature := v_brand_settings.default_email_signature;
|
||||
|
||||
-- Apply substitutions to subject
|
||||
v_subj := p_subject;
|
||||
IF v_subj IS NOT NULL THEN
|
||||
v_subj := replace(v_subj, '{{company_name}}', COALESCE(v_brand_name, 'Route Commerce'));
|
||||
v_subj := replace(v_subj, '{{brand_name}}', COALESCE(v_brand_name, 'Route Commerce'));
|
||||
v_subj := replace(v_subj, '{{address}}', COALESCE(v_address, ''));
|
||||
v_subj := replace(v_subj, '{{phone}}', COALESCE(v_brand_settings.phone, ''));
|
||||
v_subj := replace(v_subj, '{{email}}', COALESCE(v_brand_settings.email, ''));
|
||||
v_subj := replace(v_subj, '{{website}}', COALESCE(v_brand_settings.website_url, ''));
|
||||
END IF;
|
||||
|
||||
-- Apply substitutions to body, then append signature
|
||||
v_body_sub := p_body;
|
||||
v_body_sub := replace(v_body_sub, '{{company_name}}', COALESCE(v_brand_name, 'Route Commerce'));
|
||||
v_body_sub := replace(v_body_sub, '{{brand_name}}', COALESCE(v_brand_name, 'Route Commerce'));
|
||||
v_body_sub := replace(v_body_sub, '{{address}}', COALESCE(v_address, ''));
|
||||
v_body_sub := replace(v_body_sub, '{{phone}}', COALESCE(v_brand_settings.phone, ''));
|
||||
v_body_sub := replace(v_body_sub, '{{email}}', COALESCE(v_brand_settings.email, ''));
|
||||
v_body_sub := replace(v_body_sub, '{{website}}', COALESCE(v_brand_settings.website_url, ''));
|
||||
|
||||
-- Append default email signature if set
|
||||
IF v_signature IS NOT NULL AND v_signature != '' THEN
|
||||
v_body_sub := v_body_sub || E'\n\n' || v_signature;
|
||||
END IF;
|
||||
|
||||
-- Create campaign record
|
||||
INSERT INTO communication_campaigns (
|
||||
brand_id, name, subject, body_text,
|
||||
campaign_type, status, audience_rules,
|
||||
sent_at, created_by
|
||||
) VALUES (
|
||||
p_brand_id,
|
||||
'Stop blast: ' || COALESCE(p_subject, p_body),
|
||||
v_subj,
|
||||
v_body_sub,
|
||||
'operational',
|
||||
'sent',
|
||||
jsonb_build_object(
|
||||
'target', 'stop',
|
||||
'stop_id', p_stop_id,
|
||||
'date_from', NULL,
|
||||
'date_to', NULL
|
||||
),
|
||||
now(),
|
||||
p_created_by
|
||||
) RETURNING id INTO v_campaign_id;
|
||||
|
||||
-- Fetch matching orders
|
||||
FOR v_order IN
|
||||
SELECT o.id, o.customer_id, o.customer_name,
|
||||
o.customer_email, o.customer_phone, o.pickup_complete,
|
||||
c.email_opt_in, c.sms_opt_in, c.unsubscribed_at
|
||||
FROM orders o
|
||||
LEFT JOIN communication_contacts c ON c.id = o.customer_id
|
||||
WHERE o.stop_id = p_stop_id
|
||||
AND (
|
||||
p_audience = 'all'
|
||||
OR (p_audience = 'pending' AND NOT o.pickup_complete)
|
||||
OR (p_audience = 'picked_up' AND o.pickup_complete)
|
||||
)
|
||||
LOOP
|
||||
-- Check opt-in status per channel
|
||||
v_opted_in := (
|
||||
CASE p_channel
|
||||
WHEN 'sms' THEN v_order.customer_phone IS NOT NULL
|
||||
AND COALESCE(v_order.sms_opt_in, FALSE) = TRUE
|
||||
AND v_order.unsubscribed_at IS NULL
|
||||
WHEN 'email' THEN v_order.customer_email IS NOT NULL
|
||||
AND COALESCE(v_order.email_opt_in, TRUE) = TRUE
|
||||
AND v_order.unsubscribed_at IS NULL
|
||||
ELSE v_order.customer_email IS NOT NULL
|
||||
AND COALESCE(v_order.email_opt_in, TRUE) = TRUE
|
||||
AND v_order.unsubscribed_at IS NULL
|
||||
END
|
||||
);
|
||||
|
||||
IF NOT v_opted_in THEN
|
||||
CONTINUE;
|
||||
END IF;
|
||||
|
||||
-- Log email message with fully-substituted subject and body
|
||||
IF p_channel IN ('email', 'both') AND v_order.customer_email IS NOT NULL THEN
|
||||
INSERT INTO communication_message_logs (
|
||||
brand_id, campaign_id, customer_id, customer_email,
|
||||
delivery_method, subject, body_preview, status, sent_at
|
||||
) VALUES (
|
||||
p_brand_id, v_campaign_id, v_order.customer_id, v_order.customer_email,
|
||||
'email', COALESCE(v_subj, ''), LEFT(v_body_sub, 200), 'queued', now()
|
||||
);
|
||||
v_count := v_count + 1;
|
||||
END IF;
|
||||
|
||||
-- Log SMS message (signature not appended to SMS)
|
||||
IF p_channel IN ('sms', 'both') AND v_order.customer_phone IS NOT NULL THEN
|
||||
INSERT INTO communication_message_logs (
|
||||
brand_id, campaign_id, customer_id, customer_phone,
|
||||
delivery_method, body_preview, status, sent_at
|
||||
) VALUES (
|
||||
p_brand_id, v_campaign_id, v_order.customer_id, v_order.customer_phone,
|
||||
'sms', LEFT(v_body_sub, 160), 'queued', now()
|
||||
);
|
||||
v_count := v_count + 1;
|
||||
END IF;
|
||||
END LOOP;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'success', true,
|
||||
'campaign_id', v_campaign_id,
|
||||
'messages_logged', v_count
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,77 @@
|
||||
-- Migration 087: Brand Logos Storage Bucket
|
||||
--
|
||||
-- SETUP REQUIRED (one-time, run manually in Supabase dashboard):
|
||||
-- 1. Go to Storage → New bucket
|
||||
-- 2. Name: "brand-logos" | Public: true
|
||||
-- 3. Allowed MIME types: image/png, image/jpeg, image/webp, image/svg+xml
|
||||
-- 4. Max file size: 5MB
|
||||
--
|
||||
-- Files stored at: brand-logos/{brand_id}/logo.png, brand-logos/{brand_id}/logo-dark.png
|
||||
|
||||
-- RLS policies for brand-logos bucket
|
||||
-- Brand admins can upload/update their own brand's logos
|
||||
-- Everyone can read logos (public bucket)
|
||||
|
||||
CREATE POLICY IF NOT EXISTS "brand_admin_upload_logo"
|
||||
ON storage.objects
|
||||
FOR INSERT
|
||||
WITH CHECK (
|
||||
bucket_id = 'brand-logos'
|
||||
AND (
|
||||
-- Platform admin can upload to any brand
|
||||
EXISTS (
|
||||
SELECT 1 FROM admin_users au
|
||||
WHERE au.user_id = auth.uid() AND au.role = 'platform_admin'
|
||||
)
|
||||
OR
|
||||
-- Brand admin can upload to their own brand's folder
|
||||
(storage.foldername(name))[1] IN (
|
||||
SELECT au.brand_id::text FROM admin_users au
|
||||
WHERE au.user_id = auth.uid()
|
||||
AND au.role = 'brand_admin'
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY IF NOT EXISTS "public_read_logo"
|
||||
ON storage.objects
|
||||
FOR SELECT
|
||||
USING (bucket_id = 'brand-logos');
|
||||
|
||||
CREATE POLICY IF NOT EXISTS "brand_admin_update_logo"
|
||||
ON storage.objects
|
||||
FOR UPDATE
|
||||
USING (
|
||||
bucket_id = 'brand-logos'
|
||||
AND (
|
||||
EXISTS (
|
||||
SELECT 1 FROM admin_users au
|
||||
WHERE au.user_id = auth.uid() AND au.role = 'platform_admin'
|
||||
)
|
||||
OR
|
||||
(storage.foldername(name))[1] IN (
|
||||
SELECT au.brand_id::text FROM admin_users au
|
||||
WHERE au.user_id = auth.uid()
|
||||
AND au.role = 'brand_admin'
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY IF NOT EXISTS "brand_admin_delete_logo"
|
||||
ON storage.objects
|
||||
FOR DELETE
|
||||
USING (
|
||||
bucket_id = 'brand-logos'
|
||||
AND (
|
||||
EXISTS (
|
||||
SELECT 1 FROM admin_users au
|
||||
WHERE au.user_id = auth.uid() AND au.role = 'platform_admin'
|
||||
)
|
||||
OR
|
||||
(storage.foldername(name))[1] IN (
|
||||
SELECT au.brand_id::text FROM admin_users au
|
||||
WHERE au.user_id = auth.uid()
|
||||
AND au.role = 'brand_admin'
|
||||
)
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,72 @@
|
||||
-- Migration 088: Brand feature flags / add-on licensing system
|
||||
-- Enables per-brand add-on enablement for Harvest Reach, Wholesale Portal, Water Log, AI Tools, etc.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS brand_features (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
|
||||
feature_key TEXT NOT NULL, -- e.g., 'harvest_reach', 'wholesale_portal', 'water_log', 'ai_tools'
|
||||
enabled BOOLEAN NOT NULL DEFAULT false,
|
||||
enabled_at TIMESTAMPTZ,
|
||||
disabled_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (brand_id, feature_key)
|
||||
);
|
||||
|
||||
-- Index for fast lookups
|
||||
CREATE INDEX IF NOT EXISTS brand_features_brand_id_idx ON brand_features(brand_id);
|
||||
|
||||
-- RPC to get all features for a brand
|
||||
CREATE OR REPLACE FUNCTION get_brand_features(p_brand_id UUID)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $$
|
||||
DECLARE
|
||||
result JSONB;
|
||||
BEGIN
|
||||
result := jsonb_object_agg(
|
||||
f.feature_key,
|
||||
CASE WHEN f.enabled AND f.disabled_at IS NULL THEN true ELSE false END
|
||||
)
|
||||
FROM brand_features f
|
||||
WHERE f.brand_id = p_brand_id;
|
||||
|
||||
-- If no features set, return empty object (env vars will be fallback)
|
||||
RETURN COALESCE(result, '{}'::jsonb);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- RPC to set a single feature on/off
|
||||
CREATE OR REPLACE FUNCTION set_brand_feature(
|
||||
p_brand_id UUID,
|
||||
p_feature_key TEXT,
|
||||
p_enabled BOOLEAN
|
||||
)
|
||||
RETURNS BOOLEAN
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $$
|
||||
BEGIN
|
||||
INSERT INTO brand_features (brand_id, feature_key, enabled, enabled_at)
|
||||
VALUES (p_brand_id, p_feature_key, p_enabled, CASE WHEN p_enabled THEN now() ELSE NULL END)
|
||||
ON CONFLICT (brand_id, feature_key)
|
||||
DO UPDATE SET
|
||||
enabled = p_enabled,
|
||||
enabled_at = CASE WHEN p_enabled AND brand_features.enabled_at IS NULL THEN now() ELSE brand_features.enabled_at END,
|
||||
disabled_at = CASE WHEN NOT p_enabled THEN now() ELSE NULL END;
|
||||
RETURN true;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Grant access
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON brand_features TO authenticated;
|
||||
GRANT EXECUTE ON FUNCTION get_brand_features(UUID) TO authenticated;
|
||||
GRANT EXECUTE ON FUNCTION set_brand_feature(UUID, TEXT, BOOLEAN) TO authenticated;
|
||||
-- ============================================================
|
||||
-- RLS for brand_features (added by scripts/fix-archived-rls.js)
|
||||
-- Columns: id, brand_id, feature_key, enabled_at, disabled_at, created_at, feature_key
|
||||
-- ============================================================
|
||||
ALTER TABLE brand_features ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE brand_features FORCE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS tenant_isolation ON brand_features;
|
||||
CREATE POLICY tenant_isolation ON brand_features FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
|
||||
@@ -0,0 +1,134 @@
|
||||
-- Migration 089: AI providers config + custom integrations
|
||||
-- Store per-brand AI provider settings and custom integration definitions
|
||||
|
||||
ALTER TABLE brand_settings
|
||||
ADD COLUMN IF NOT EXISTS ai_provider_settings JSONB DEFAULT '{
|
||||
"provider": "openai",
|
||||
"api_key": null,
|
||||
"org_id": null,
|
||||
"model": "gpt-4o-mini",
|
||||
"custom_endpoint": null
|
||||
}'::jsonb;
|
||||
|
||||
ALTER TABLE brand_settings
|
||||
ADD COLUMN IF NOT EXISTS custom_integrations JSONB DEFAULT '[]'::jsonb;
|
||||
|
||||
-- RPC to get AI provider config
|
||||
CREATE OR REPLACE FUNCTION get_ai_provider_settings(p_brand_id UUID)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN COALESCE(
|
||||
(SELECT ai_provider_settings FROM brand_settings WHERE brand_id = p_brand_id),
|
||||
'{"provider": "openai", "model": "gpt-4o-mini"}'::jsonb
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- RPC to save AI provider config
|
||||
CREATE OR REPLACE FUNCTION set_ai_provider_settings(
|
||||
p_brand_id UUID,
|
||||
p_settings JSONB
|
||||
)
|
||||
RETURNS BOOLEAN
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $$
|
||||
BEGIN
|
||||
UPDATE brand_settings
|
||||
SET ai_provider_settings = p_settings,
|
||||
updated_at = now()
|
||||
WHERE brand_id = p_brand_id;
|
||||
RETURN true;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- RPC to get custom integrations
|
||||
CREATE OR REPLACE FUNCTION get_custom_integrations(p_brand_id UUID)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN COALESCE(
|
||||
(SELECT custom_integrations FROM brand_settings WHERE brand_id = p_brand_id),
|
||||
'[]'::jsonb
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- RPC to add/update a custom integration
|
||||
CREATE OR REPLACE FUNCTION upsert_custom_integration(
|
||||
p_brand_id UUID,
|
||||
p_integration JSONB
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $$
|
||||
DECLARE
|
||||
current_integrations JSONB;
|
||||
updated_integrations JSONB;
|
||||
integration_id TEXT;
|
||||
BEGIN
|
||||
current_integrations := COALESCE(
|
||||
(SELECT custom_integrations FROM brand_settings WHERE brand_id = p_brand_id),
|
||||
'[]'::jsonb
|
||||
);
|
||||
|
||||
integration_id := p_integration->>'id';
|
||||
|
||||
-- Remove existing with same id, then append
|
||||
current_integrations := (
|
||||
SELECT jsonb_agg(item)
|
||||
FROM (
|
||||
SELECT item
|
||||
FROM jsonb_array_elements(current_integrations) item
|
||||
WHERE item->>'id' != integration_id
|
||||
UNION ALL
|
||||
SELECT p_integration
|
||||
) t
|
||||
);
|
||||
|
||||
UPDATE brand_settings
|
||||
SET custom_integrations = current_integrations,
|
||||
updated_at = now()
|
||||
WHERE brand_id = p_brand_id;
|
||||
|
||||
RETURN current_integrations;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- RPC to delete a custom integration
|
||||
CREATE OR REPLACE FUNCTION delete_custom_integration(
|
||||
p_brand_id UUID,
|
||||
p_integration_id TEXT
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $$
|
||||
BEGIN
|
||||
UPDATE brand_settings
|
||||
SET custom_integrations = (
|
||||
SELECT jsonb_agg(item)
|
||||
FROM (
|
||||
SELECT item
|
||||
FROM jsonb_array_elements(custom_integrations) item
|
||||
WHERE item->>'id' != p_integration_id
|
||||
) t
|
||||
),
|
||||
updated_at = now()
|
||||
WHERE brand_id = p_brand_id;
|
||||
|
||||
RETURN (SELECT custom_integrations FROM brand_settings WHERE brand_id = p_brand_id);
|
||||
END;
|
||||
$$;
|
||||
|
||||
GRANT EXECUTE ON FUNCTION get_ai_provider_settings(UUID) TO authenticated;
|
||||
GRANT EXECUTE ON FUNCTION set_ai_provider_settings(UUID, JSONB) TO authenticated;
|
||||
GRANT EXECUTE ON FUNCTION get_custom_integrations(UUID) TO authenticated;
|
||||
GRANT EXECUTE ON FUNCTION upsert_custom_integration(UUID, JSONB) TO authenticated;
|
||||
GRANT EXECUTE ON FUNCTION delete_custom_integration(UUID, TEXT) TO authenticated;
|
||||
@@ -0,0 +1,55 @@
|
||||
-- Enable UUID extension if not already enabled
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- Storefront customization fields added to brand_settings
|
||||
ALTER TABLE brand_settings ADD COLUMN IF NOT EXISTS hero_tagline TEXT;
|
||||
ALTER TABLE brand_settings ADD COLUMN IF NOT EXISTS about_headline TEXT;
|
||||
ALTER TABLE brand_settings ADD COLUMN IF NOT EXISTS about_subheadline TEXT;
|
||||
ALTER TABLE brand_settings ADD COLUMN IF NOT EXISTS custom_footer_text TEXT;
|
||||
ALTER TABLE brand_settings ADD COLUMN IF NOT EXISTS show_wholesale_link BOOLEAN DEFAULT true;
|
||||
ALTER TABLE brand_settings ADD COLUMN IF NOT EXISTS show_zip_search BOOLEAN DEFAULT true;
|
||||
ALTER TABLE brand_settings ADD COLUMN IF NOT EXISTS show_schedule_pdf BOOLEAN DEFAULT true;
|
||||
ALTER TABLE brand_settings ADD COLUMN IF NOT EXISTS show_text_alerts BOOLEAN DEFAULT false;
|
||||
ALTER TABLE brand_settings ADD COLUMN IF NOT EXISTS schedule_pdf_notes TEXT;
|
||||
|
||||
-- New RPC: get brand settings by slug (public, no auth required for brand-specific storefront pages)
|
||||
CREATE OR REPLACE FUNCTION get_brand_settings_by_slug(p_brand_slug TEXT)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
result JSONB;
|
||||
BEGIN
|
||||
SELECT jsonb_build_object(
|
||||
'brand_id', bs.brand_id,
|
||||
'brand_name', b.name,
|
||||
'hero_tagline', bs.hero_tagline,
|
||||
'about_headline', bs.about_headline,
|
||||
'about_subheadline', bs.about_subheadline,
|
||||
'custom_footer_text', bs.custom_footer_text,
|
||||
'show_wholesale_link', bs.show_wholesale_link,
|
||||
'show_zip_search', bs.show_zip_search,
|
||||
'show_schedule_pdf', bs.show_schedule_pdf,
|
||||
'show_text_alerts', bs.show_text_alerts,
|
||||
'schedule_pdf_notes', bs.schedule_pdf_notes,
|
||||
'logo_url', bs.logo_url,
|
||||
'logo_url_dark', bs.logo_url_dark,
|
||||
'contact_email', bs.email,
|
||||
'contact_phone', bs.phone,
|
||||
'legal_business_name', bs.legal_business_name,
|
||||
'wholesale_enabled', ws.wholesale_enabled
|
||||
)
|
||||
INTO result
|
||||
FROM brands b
|
||||
LEFT JOIN brand_settings bs ON bs.brand_id = b.id
|
||||
LEFT JOIN wholesale_settings ws ON ws.brand_id = b.id
|
||||
WHERE b.slug = p_brand_slug;
|
||||
|
||||
RETURN result;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION get_brand_settings_by_slug IS
|
||||
'Returns brand settings merged with wholesale_enabled for public storefront pages. No auth required.';
|
||||
@@ -0,0 +1,83 @@
|
||||
-- Add plan_tier column to brands
|
||||
ALTER TABLE public.brands
|
||||
ADD COLUMN IF NOT EXISTS plan_tier TEXT NOT NULL DEFAULT 'starter'
|
||||
CHECK (plan_tier IN ('starter', 'farm', 'enterprise'));
|
||||
|
||||
-- Add stripe_customer_id for Stripe billing portal
|
||||
ALTER TABLE public.brands
|
||||
ADD COLUMN IF NOT EXISTS stripe_customer_id TEXT;
|
||||
|
||||
-- Add plan limits columns
|
||||
ALTER TABLE public.brands
|
||||
ADD COLUMN IF NOT EXISTS max_users INTEGER DEFAULT 1,
|
||||
ADD COLUMN IF NOT EXISTS max_stops_monthly INTEGER DEFAULT 10,
|
||||
ADD COLUMN IF NOT EXISTS max_products INTEGER DEFAULT 25;
|
||||
|
||||
-- RPC: update_brand_plan_tier
|
||||
CREATE OR REPLACE FUNCTION public.update_brand_plan_tier(
|
||||
p_brand_id UUID,
|
||||
p_plan_tier TEXT
|
||||
) RETURNS VOID AS $$
|
||||
BEGIN
|
||||
IF p_plan_tier NOT IN ('starter', 'farm', 'enterprise') THEN
|
||||
RAISE EXCEPTION 'Invalid plan tier: %', p_plan_tier;
|
||||
END IF;
|
||||
UPDATE brands SET
|
||||
plan_tier = p_plan_tier,
|
||||
updated_at = now()
|
||||
WHERE id = p_brand_id;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
-- RPC: update_brand_stripe_customer_id
|
||||
CREATE OR REPLACE FUNCTION public.update_brand_stripe_customer_id(
|
||||
p_brand_id UUID,
|
||||
p_stripe_customer_id TEXT
|
||||
) RETURNS VOID AS $$
|
||||
BEGIN
|
||||
UPDATE brands SET
|
||||
stripe_customer_id = p_stripe_customer_id,
|
||||
updated_at = now()
|
||||
WHERE id = p_brand_id;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
-- RPC: get_brand_plan_info (returns plan tier + limits + usage stats)
|
||||
CREATE OR REPLACE FUNCTION public.get_brand_plan_info(p_brand_id UUID)
|
||||
RETURNS JSONB AS $$
|
||||
DECLARE
|
||||
v_result JSONB;
|
||||
v_user_count BIGINT;
|
||||
v_active_stops BIGINT;
|
||||
v_product_count BIGINT;
|
||||
v_brand RECORD;
|
||||
BEGIN
|
||||
SELECT plan_tier, max_users, max_stops_monthly, max_products, stripe_customer_id
|
||||
INTO v_brand
|
||||
FROM brands WHERE id = p_brand_id;
|
||||
|
||||
IF v_brand IS NULL THEN
|
||||
RETURN NULL;
|
||||
END IF;
|
||||
|
||||
-- Count current usage
|
||||
SELECT COUNT(*) INTO v_user_count FROM admin_users WHERE brand_id = p_brand_id AND deleted_at IS NULL;
|
||||
SELECT COUNT(*) INTO v_active_stops FROM stops WHERE brand_id = p_brand_id AND active = true AND date >= date_trunc('month', now());
|
||||
SELECT COUNT(*) INTO v_product_count FROM products WHERE brand_id = p_brand_id AND deleted_at IS NULL;
|
||||
|
||||
SELECT jsonb_build_object(
|
||||
'plan_tier', v_brand.plan_tier,
|
||||
'max_users', v_brand.max_users,
|
||||
'max_stops_monthly', v_brand.max_stops_monthly,
|
||||
'max_products', v_brand.max_products,
|
||||
'stripe_customer_id', v_brand.stripe_customer_id,
|
||||
'usage', jsonb_build_object(
|
||||
'users', v_user_count,
|
||||
'stops_this_month', v_active_stops,
|
||||
'products', v_product_count
|
||||
)
|
||||
) INTO v_result;
|
||||
|
||||
RETURN v_result;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
@@ -0,0 +1,74 @@
|
||||
-- Migration 092: Stripe Subscriptions + Billing Integration
|
||||
-- Enables real Stripe subscription management for platform billing
|
||||
|
||||
-- ── Subscription tracking columns on brands ────────────────────────────────
|
||||
ALTER TABLE brands ADD COLUMN IF NOT EXISTS stripe_subscription_id TEXT;
|
||||
ALTER TABLE brands ADD COLUMN IF NOT EXISTS stripe_subscription_status TEXT DEFAULT 'inactive';
|
||||
ALTER TABLE brands ADD COLUMN IF NOT EXISTS stripe_current_period_end TIMESTAMPTZ;
|
||||
|
||||
-- ── RPC: Save subscription data from Stripe webhook ─────────────────────────
|
||||
CREATE OR REPLACE FUNCTION set_brand_subscription(
|
||||
p_brand_id UUID,
|
||||
p_subscription_id TEXT,
|
||||
p_status TEXT,
|
||||
p_current_period_end TIMESTAMPTZ
|
||||
) RETURNS BOOLEAN AS $$
|
||||
BEGIN
|
||||
UPDATE brands SET
|
||||
stripe_subscription_id = p_subscription_id,
|
||||
stripe_subscription_status = p_status,
|
||||
stripe_current_period_end = p_current_period_end,
|
||||
updated_at = NOW()
|
||||
WHERE id = p_brand_id;
|
||||
RETURN TRUE;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
GRANT EXECUTE ON FUNCTION set_brand_subscription(UUID, TEXT, TEXT, TIMESTAMPTZ) TO authenticated;
|
||||
|
||||
-- ── RPC: Get subscription info ─────────────────────────────────────────────
|
||||
CREATE OR REPLACE FUNCTION get_brand_subscription(p_brand_id UUID)
|
||||
RETURNS JSONB AS $$
|
||||
DECLARE
|
||||
result JSONB;
|
||||
BEGIN
|
||||
SELECT jsonb_build_object(
|
||||
'stripe_subscription_id', stripe_subscription_id,
|
||||
'stripe_subscription_status', stripe_subscription_status,
|
||||
'stripe_current_period_end', stripe_current_period_end,
|
||||
'stripe_customer_id', stripe_customer_id,
|
||||
'plan_tier', plan_tier
|
||||
) INTO result
|
||||
FROM brands WHERE id = p_brand_id;
|
||||
|
||||
RETURN result;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
GRANT EXECUTE ON FUNCTION get_brand_subscription(UUID) TO authenticated;
|
||||
|
||||
-- ── Stripe price IDs (environment-variable-driven) ─────────────────────────
|
||||
-- These should be set in .env.local:
|
||||
-- STRIPE_PRICE_STARTER=price_xxxx
|
||||
-- STRIPE_PRICE_FARM=price_xxxx
|
||||
-- STRIPE_PRICE_ENTERPRISE=price_xxxx
|
||||
-- STRIPE_PRICE_HARVEST_REACH=price_xxxx (add-on)
|
||||
-- STRIPE_PRICE_WHOLESALE_PORTAL=price_xxxx
|
||||
-- STRIPE_PRICE_WATER_LOG=price_xxxx
|
||||
-- STRIPE_PRICE_AI_TOOLS=price_xxxx
|
||||
-- STRIPE_PRICE_SQUARE_SYNC=price_xxxx
|
||||
-- STRIPE_PRICE_SMS_CAMPAIGNS=price_xxxx
|
||||
|
||||
-- ── Feature flag auto-enable map ───────────────────────────────────────────
|
||||
-- When Stripe subscription is active for a given price, the corresponding
|
||||
-- brand_features flag is enabled. When subscription is canceled, it's disabled.
|
||||
--
|
||||
-- Price ID → feature key mapping (configured via env vars):
|
||||
-- STRIPE_PRICE_HARVEST_REACH → harvest_reach
|
||||
-- STRIPE_PRICE_WHOLESALE_PORTAL → wholesale_portal
|
||||
-- STRIPE_PRICE_WATER_LOG → water_log
|
||||
-- STRIPE_PRICE_AI_TOOLS → ai_tools
|
||||
-- STRIPE_PRICE_SQUARE_SYNC → square_sync
|
||||
-- STRIPE_PRICE_SMS_CAMPAIGNS → sms_campaigns
|
||||
--
|
||||
-- Plan tier changes are handled separately via plan upgrade checkout sessions.
|
||||
@@ -0,0 +1,170 @@
|
||||
-- Migration 093: Add hero_image_url to brand_settings
|
||||
-- Storefront hero image (full-width background photo)
|
||||
|
||||
ALTER TABLE brand_settings ADD COLUMN IF NOT EXISTS hero_image_url TEXT;
|
||||
|
||||
-- Update upsert_brand_settings to accept hero_image_url
|
||||
CREATE OR REPLACE FUNCTION public.upsert_brand_settings(
|
||||
p_brand_id UUID,
|
||||
p_legal_business_name TEXT DEFAULT NULL,
|
||||
p_phone TEXT DEFAULT NULL,
|
||||
p_email TEXT DEFAULT NULL,
|
||||
p_website_url TEXT DEFAULT NULL,
|
||||
p_street_address TEXT DEFAULT NULL,
|
||||
p_city TEXT DEFAULT NULL,
|
||||
p_state TEXT DEFAULT NULL,
|
||||
p_postal_code TEXT DEFAULT NULL,
|
||||
p_country TEXT DEFAULT NULL,
|
||||
p_logo_url TEXT DEFAULT NULL,
|
||||
p_logo_url_dark TEXT DEFAULT NULL,
|
||||
p_default_email_signature TEXT DEFAULT NULL,
|
||||
p_invoice_footer_notes TEXT DEFAULT NULL,
|
||||
p_hero_tagline TEXT DEFAULT NULL,
|
||||
p_about_headline TEXT DEFAULT NULL,
|
||||
p_about_subheadline TEXT DEFAULT NULL,
|
||||
p_custom_footer_text TEXT DEFAULT NULL,
|
||||
p_show_wholesale_link BOOLEAN DEFAULT NULL,
|
||||
p_show_zip_search BOOLEAN DEFAULT NULL,
|
||||
p_show_schedule_pdf BOOLEAN DEFAULT NULL,
|
||||
p_show_text_alerts BOOLEAN DEFAULT NULL,
|
||||
p_schedule_pdf_notes TEXT DEFAULT NULL,
|
||||
p_hero_image_url TEXT DEFAULT NULL
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_id UUID;
|
||||
BEGIN
|
||||
INSERT INTO brand_settings (
|
||||
brand_id, legal_business_name, phone, email, website_url,
|
||||
street_address, city, state, postal_code, country,
|
||||
logo_url, logo_url_dark, default_email_signature, invoice_footer_notes,
|
||||
hero_tagline, about_headline, about_subheadline, custom_footer_text,
|
||||
show_wholesale_link, show_zip_search, show_schedule_pdf,
|
||||
show_text_alerts, schedule_pdf_notes, hero_image_url
|
||||
) VALUES (
|
||||
p_brand_id, p_legal_business_name, p_phone, p_email, p_website_url,
|
||||
p_street_address, p_city, p_state, p_postal_code, p_country,
|
||||
p_logo_url, p_logo_url_dark, p_default_email_signature, p_invoice_footer_notes,
|
||||
p_hero_tagline, p_about_headline, p_about_subheadline, p_custom_footer_text,
|
||||
p_show_wholesale_link, p_show_zip_search, p_show_schedule_pdf,
|
||||
p_show_text_alerts, p_schedule_pdf_notes, p_hero_image_url
|
||||
)
|
||||
ON CONFLICT (brand_id) DO UPDATE SET
|
||||
legal_business_name = COALESCE(p_legal_business_name, brand_settings.legal_business_name),
|
||||
phone = COALESCE(p_phone, brand_settings.phone),
|
||||
email = COALESCE(p_email, brand_settings.email),
|
||||
website_url = COALESCE(p_website_url, brand_settings.website_url),
|
||||
street_address = COALESCE(p_street_address, brand_settings.street_address),
|
||||
city = COALESCE(p_city, brand_settings.city),
|
||||
state = COALESCE(p_state, brand_settings.state),
|
||||
postal_code = COALESCE(p_postal_code, brand_settings.postal_code),
|
||||
country = COALESCE(p_country, brand_settings.country),
|
||||
logo_url = COALESCE(p_logo_url, brand_settings.logo_url),
|
||||
logo_url_dark = COALESCE(p_logo_url_dark, brand_settings.logo_url_dark),
|
||||
default_email_signature = COALESCE(p_default_email_signature, brand_settings.default_email_signature),
|
||||
invoice_footer_notes = COALESCE(p_invoice_footer_notes, brand_settings.invoice_footer_notes),
|
||||
hero_tagline = COALESCE(p_hero_tagline, brand_settings.hero_tagline),
|
||||
about_headline = COALESCE(p_about_headline, brand_settings.about_headline),
|
||||
about_subheadline = COALESCE(p_about_subheadline, brand_settings.about_subheadline),
|
||||
custom_footer_text = COALESCE(p_custom_footer_text, brand_settings.custom_footer_text),
|
||||
show_wholesale_link = COALESCE(p_show_wholesale_link, brand_settings.show_wholesale_link),
|
||||
show_zip_search = COALESCE(p_show_zip_search, brand_settings.show_zip_search),
|
||||
show_schedule_pdf = COALESCE(p_show_schedule_pdf, brand_settings.show_schedule_pdf),
|
||||
show_text_alerts = COALESCE(p_show_text_alerts, brand_settings.show_text_alerts),
|
||||
schedule_pdf_notes = COALESCE(p_schedule_pdf_notes, brand_settings.schedule_pdf_notes),
|
||||
hero_image_url = COALESCE(p_hero_image_url, brand_settings.hero_image_url),
|
||||
updated_at = now()
|
||||
WHERE brand_settings.brand_id = p_brand_id
|
||||
RETURNING id INTO v_id;
|
||||
|
||||
RETURN get_brand_settings(p_brand_id);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Update get_brand_settings to include hero_image_url
|
||||
CREATE OR REPLACE FUNCTION public.get_brand_settings(p_brand_id UUID)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_result JSONB;
|
||||
BEGIN
|
||||
SELECT jsonb_build_object(
|
||||
'id', bs.id,
|
||||
'brand_id', bs.brand_id,
|
||||
'legal_business_name', bs.legal_business_name,
|
||||
'phone', bs.phone,
|
||||
'email', bs.email,
|
||||
'website_url', bs.website_url,
|
||||
'street_address', bs.street_address,
|
||||
'city', bs.city,
|
||||
'state', bs.state,
|
||||
'postal_code', bs.postal_code,
|
||||
'country', bs.country,
|
||||
'logo_url', bs.logo_url,
|
||||
'logo_url_dark', bs.logo_url_dark,
|
||||
'default_email_signature', bs.default_email_signature,
|
||||
'invoice_footer_notes', bs.invoice_footer_notes,
|
||||
'hero_tagline', bs.hero_tagline,
|
||||
'about_headline', bs.about_headline,
|
||||
'about_subheadline', bs.about_subheadline,
|
||||
'custom_footer_text', bs.custom_footer_text,
|
||||
'show_wholesale_link', bs.show_wholesale_link,
|
||||
'show_zip_search', bs.show_zip_search,
|
||||
'show_schedule_pdf', bs.show_schedule_pdf,
|
||||
'show_text_alerts', bs.show_text_alerts,
|
||||
'schedule_pdf_notes', bs.schedule_pdf_notes,
|
||||
'hero_image_url', bs.hero_image_url,
|
||||
'updated_at', bs.updated_at,
|
||||
'brand_name', b.name
|
||||
)
|
||||
INTO v_result
|
||||
FROM brand_settings bs
|
||||
JOIN brands b ON b.id = bs.brand_id
|
||||
WHERE bs.brand_id = p_brand_id;
|
||||
|
||||
RETURN COALESCE(v_result, jsonb_build_object('brand_id', p_brand_id, 'brand_name', (
|
||||
SELECT name FROM brands WHERE id = p_brand_id
|
||||
)));
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Update get_brand_settings_by_slug to include hero_image_url
|
||||
CREATE OR REPLACE FUNCTION public.get_brand_settings_by_slug(p_brand_slug TEXT)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
result JSONB;
|
||||
BEGIN
|
||||
SELECT jsonb_build_object(
|
||||
'brand_id', bs.brand_id,
|
||||
'brand_name', b.name,
|
||||
'hero_tagline', bs.hero_tagline,
|
||||
'about_headline', bs.about_headline,
|
||||
'about_subheadline', bs.about_subheadline,
|
||||
'custom_footer_text', bs.custom_footer_text,
|
||||
'show_wholesale_link', bs.show_wholesale_link,
|
||||
'show_zip_search', bs.show_zip_search,
|
||||
'show_schedule_pdf', bs.show_schedule_pdf,
|
||||
'show_text_alerts', bs.show_text_alerts,
|
||||
'schedule_pdf_notes', bs.schedule_pdf_notes,
|
||||
'logo_url', bs.logo_url,
|
||||
'logo_url_dark', bs.logo_url_dark,
|
||||
'hero_image_url', bs.hero_image_url,
|
||||
'contact_email', bs.email,
|
||||
'contact_phone', bs.phone,
|
||||
'legal_business_name', bs.legal_business_name,
|
||||
'wholesale_enabled', ws.wholesale_enabled
|
||||
)
|
||||
INTO result
|
||||
FROM brands b
|
||||
LEFT JOIN brand_settings bs ON bs.brand_id = b.id
|
||||
LEFT JOIN wholesale_settings ws ON ws.brand_id = b.id
|
||||
WHERE b.slug = p_brand_slug;
|
||||
|
||||
RETURN result;
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,197 @@
|
||||
-- Migration 094: Brand Color Customization
|
||||
-- Add primary/secondary/bg color customization to brand_settings
|
||||
-- Allows admins to customize brand accent colors via brand settings
|
||||
|
||||
ALTER TABLE brand_settings ADD COLUMN IF NOT EXISTS brand_primary_color TEXT DEFAULT '#16a34a';
|
||||
ALTER TABLE brand_settings ADD COLUMN IF NOT EXISTS brand_secondary_color TEXT DEFAULT '#f5f5f4';
|
||||
ALTER TABLE brand_settings ADD COLUMN IF NOT EXISTS brand_bg_color TEXT DEFAULT '#fafaf9';
|
||||
ALTER TABLE brand_settings ADD COLUMN IF NOT EXISTS brand_text_color TEXT DEFAULT '#1c1917';
|
||||
|
||||
-- Update upsert_brand_settings to accept new color fields
|
||||
CREATE OR REPLACE FUNCTION public.upsert_brand_settings(
|
||||
p_brand_id UUID,
|
||||
p_legal_business_name TEXT DEFAULT NULL,
|
||||
p_phone TEXT DEFAULT NULL,
|
||||
p_email TEXT DEFAULT NULL,
|
||||
p_website_url TEXT DEFAULT NULL,
|
||||
p_street_address TEXT DEFAULT NULL,
|
||||
p_city TEXT DEFAULT NULL,
|
||||
p_state TEXT DEFAULT NULL,
|
||||
p_postal_code TEXT DEFAULT NULL,
|
||||
p_country TEXT DEFAULT NULL,
|
||||
p_logo_url TEXT DEFAULT NULL,
|
||||
p_logo_url_dark TEXT DEFAULT NULL,
|
||||
p_default_email_signature TEXT DEFAULT NULL,
|
||||
p_invoice_footer_notes TEXT DEFAULT NULL,
|
||||
p_hero_tagline TEXT DEFAULT NULL,
|
||||
p_about_headline TEXT DEFAULT NULL,
|
||||
p_about_subheadline TEXT DEFAULT NULL,
|
||||
p_custom_footer_text TEXT DEFAULT NULL,
|
||||
p_show_wholesale_link BOOLEAN DEFAULT NULL,
|
||||
p_show_zip_search BOOLEAN DEFAULT NULL,
|
||||
p_show_schedule_pdf BOOLEAN DEFAULT NULL,
|
||||
p_show_text_alerts BOOLEAN DEFAULT NULL,
|
||||
p_schedule_pdf_notes TEXT DEFAULT NULL,
|
||||
p_hero_image_url TEXT DEFAULT NULL,
|
||||
p_brand_primary_color TEXT DEFAULT NULL,
|
||||
p_brand_secondary_color TEXT DEFAULT NULL,
|
||||
p_brand_bg_color TEXT DEFAULT NULL,
|
||||
p_brand_text_color TEXT DEFAULT NULL
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_id UUID;
|
||||
BEGIN
|
||||
INSERT INTO brand_settings (
|
||||
brand_id, legal_business_name, phone, email, website_url,
|
||||
street_address, city, state, postal_code, country,
|
||||
logo_url, logo_url_dark, default_email_signature, invoice_footer_notes,
|
||||
hero_tagline, about_headline, about_subheadline, custom_footer_text,
|
||||
show_wholesale_link, show_zip_search, show_schedule_pdf,
|
||||
show_text_alerts, schedule_pdf_notes, hero_image_url,
|
||||
brand_primary_color, brand_secondary_color, brand_bg_color, brand_text_color
|
||||
) VALUES (
|
||||
p_brand_id, p_legal_business_name, p_phone, p_email, p_website_url,
|
||||
p_street_address, p_city, p_state, p_postal_code, p_country,
|
||||
p_logo_url, p_logo_url_dark, p_default_email_signature, p_invoice_footer_notes,
|
||||
p_hero_tagline, p_about_headline, p_about_subheadline, p_custom_footer_text,
|
||||
p_show_wholesale_link, p_show_zip_search, p_show_schedule_pdf,
|
||||
p_show_text_alerts, p_schedule_pdf_notes, p_hero_image_url,
|
||||
p_brand_primary_color, p_brand_secondary_color, p_brand_bg_color, p_brand_text_color
|
||||
)
|
||||
ON CONFLICT (brand_id) DO UPDATE SET
|
||||
legal_business_name = COALESCE(p_legal_business_name, brand_settings.legal_business_name),
|
||||
phone = COALESCE(p_phone, brand_settings.phone),
|
||||
email = COALESCE(p_email, brand_settings.email),
|
||||
website_url = COALESCE(p_website_url, brand_settings.website_url),
|
||||
street_address = COALESCE(p_street_address, brand_settings.street_address),
|
||||
city = COALESCE(p_city, brand_settings.city),
|
||||
state = COALESCE(p_state, brand_settings.state),
|
||||
postal_code = COALESCE(p_postal_code, brand_settings.postal_code),
|
||||
country = COALESCE(p_country, brand_settings.country),
|
||||
logo_url = COALESCE(p_logo_url, brand_settings.logo_url),
|
||||
logo_url_dark = COALESCE(p_logo_url_dark, brand_settings.logo_url_dark),
|
||||
default_email_signature = COALESCE(p_default_email_signature, brand_settings.default_email_signature),
|
||||
invoice_footer_notes = COALESCE(p_invoice_footer_notes, brand_settings.invoice_footer_notes),
|
||||
hero_tagline = COALESCE(p_hero_tagline, brand_settings.hero_tagline),
|
||||
about_headline = COALESCE(p_about_headline, brand_settings.about_headline),
|
||||
about_subheadline = COALESCE(p_about_subheadline, brand_settings.about_subheadline),
|
||||
custom_footer_text = COALESCE(p_custom_footer_text, brand_settings.custom_footer_text),
|
||||
show_wholesale_link = COALESCE(p_show_wholesale_link, brand_settings.show_wholesale_link),
|
||||
show_zip_search = COALESCE(p_show_zip_search, brand_settings.show_zip_search),
|
||||
show_schedule_pdf = COALESCE(p_show_schedule_pdf, brand_settings.show_schedule_pdf),
|
||||
show_text_alerts = COALESCE(p_show_text_alerts, brand_settings.show_text_alerts),
|
||||
schedule_pdf_notes = COALESCE(p_schedule_pdf_notes, brand_settings.schedule_pdf_notes),
|
||||
hero_image_url = COALESCE(p_hero_image_url, brand_settings.hero_image_url),
|
||||
brand_primary_color = COALESCE(p_brand_primary_color, brand_settings.brand_primary_color),
|
||||
brand_secondary_color = COALESCE(p_brand_secondary_color, brand_settings.brand_secondary_color),
|
||||
brand_bg_color = COALESCE(p_brand_bg_color, brand_settings.brand_bg_color),
|
||||
brand_text_color = COALESCE(p_brand_text_color, brand_settings.brand_text_color),
|
||||
updated_at = now()
|
||||
WHERE brand_settings.brand_id = p_brand_id
|
||||
RETURNING id INTO v_id;
|
||||
|
||||
RETURN get_brand_settings(p_brand_id);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Update get_brand_settings to include new color fields
|
||||
CREATE OR REPLACE FUNCTION public.get_brand_settings(p_brand_id UUID)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_result JSONB;
|
||||
BEGIN
|
||||
SELECT jsonb_build_object(
|
||||
'id', bs.id,
|
||||
'brand_id', bs.brand_id,
|
||||
'legal_business_name', bs.legal_business_name,
|
||||
'phone', bs.phone,
|
||||
'email', bs.email,
|
||||
'website_url', bs.website_url,
|
||||
'street_address', bs.street_address,
|
||||
'city', bs.city,
|
||||
'state', bs.state,
|
||||
'postal_code', bs.postal_code,
|
||||
'country', bs.country,
|
||||
'logo_url', bs.logo_url,
|
||||
'logo_url_dark', bs.logo_url_dark,
|
||||
'default_email_signature', bs.default_email_signature,
|
||||
'invoice_footer_notes', bs.invoice_footer_notes,
|
||||
'hero_tagline', bs.hero_tagline,
|
||||
'about_headline', bs.about_headline,
|
||||
'about_subheadline', bs.about_subheadline,
|
||||
'custom_footer_text', bs.custom_footer_text,
|
||||
'show_wholesale_link', bs.show_wholesale_link,
|
||||
'show_zip_search', bs.show_zip_search,
|
||||
'show_schedule_pdf', bs.show_schedule_pdf,
|
||||
'show_text_alerts', bs.show_text_alerts,
|
||||
'schedule_pdf_notes', bs.schedule_pdf_notes,
|
||||
'hero_image_url', bs.hero_image_url,
|
||||
'brand_primary_color', bs.brand_primary_color,
|
||||
'brand_secondary_color', bs.brand_secondary_color,
|
||||
'brand_bg_color', bs.brand_bg_color,
|
||||
'brand_text_color', bs.brand_text_color,
|
||||
'updated_at', bs.updated_at,
|
||||
'brand_name', b.name
|
||||
)
|
||||
INTO v_result
|
||||
FROM brand_settings bs
|
||||
JOIN brands b ON b.id = bs.brand_id
|
||||
WHERE bs.brand_id = p_brand_id;
|
||||
|
||||
RETURN COALESCE(v_result, jsonb_build_object('brand_id', p_brand_id, 'brand_name', (
|
||||
SELECT name FROM brands WHERE id = p_brand_id
|
||||
)));
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Update get_brand_settings_by_slug to include new color fields
|
||||
CREATE OR REPLACE FUNCTION public.get_brand_settings_by_slug(p_brand_slug TEXT)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
result JSONB;
|
||||
BEGIN
|
||||
SELECT jsonb_build_object(
|
||||
'brand_id', bs.brand_id,
|
||||
'brand_name', b.name,
|
||||
'hero_tagline', bs.hero_tagline,
|
||||
'about_headline', bs.about_headline,
|
||||
'about_subheadline', bs.about_subheadline,
|
||||
'custom_footer_text', bs.custom_footer_text,
|
||||
'show_wholesale_link', bs.show_wholesale_link,
|
||||
'show_zip_search', bs.show_zip_search,
|
||||
'show_schedule_pdf', bs.show_schedule_pdf,
|
||||
'show_text_alerts', bs.show_text_alerts,
|
||||
'schedule_pdf_notes', bs.schedule_pdf_notes,
|
||||
'logo_url', bs.logo_url,
|
||||
'logo_url_dark', bs.logo_url_dark,
|
||||
'hero_image_url', bs.hero_image_url,
|
||||
'contact_email', bs.email,
|
||||
'contact_phone', bs.phone,
|
||||
'legal_business_name', bs.legal_business_name,
|
||||
'wholesale_enabled', ws.wholesale_enabled,
|
||||
'brand_primary_color', bs.brand_primary_color,
|
||||
'brand_secondary_color', bs.brand_secondary_color,
|
||||
'brand_bg_color', bs.brand_bg_color,
|
||||
'brand_text_color', bs.brand_text_color
|
||||
)
|
||||
INTO result
|
||||
FROM brands b
|
||||
LEFT JOIN brand_settings bs ON bs.brand_id = b.id
|
||||
LEFT JOIN wholesale_settings ws ON ws.brand_id = b.id
|
||||
WHERE b.slug = p_brand_slug;
|
||||
|
||||
RETURN result;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON COLUMN brand_settings.brand_primary_color IS 'Primary accent color hex for brand storefront (buttons, links, badges, accents)';
|
||||
COMMENT ON COLUMN brand_settings.brand_secondary_color IS 'Secondary accent color hex for brand storefront';
|
||||
COMMENT ON COLUMN brand_settings.brand_bg_color IS 'Light mode page background color hex for brand storefront';
|
||||
COMMENT ON COLUMN brand_settings.brand_text_color IS 'Primary text color hex for brand storefront';
|
||||
@@ -0,0 +1,248 @@
|
||||
-- 095: Tax Settings — brand_settings columns + tax summary/orders RPCs
|
||||
-- Adds collect_sales_tax + nexus_states to brand_settings
|
||||
-- Creates get_tax_summary and get_taxable_orders RPCs
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ── 1. Add columns to brand_settings ─────────────────────────────────────────
|
||||
ALTER TABLE brand_settings
|
||||
ADD COLUMN IF NOT EXISTS collect_sales_tax BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
ADD COLUMN IF NOT EXISTS nexus_states TEXT[] NOT NULL DEFAULT '{CO}'::TEXT[];
|
||||
|
||||
-- ── 2. Update upsert_brand_settings ──────────────────────────────────────────
|
||||
DROP FUNCTION IF EXISTS public.upsert_brand_settings(
|
||||
UUID, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT,
|
||||
TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, BOOLEAN, BOOLEAN, BOOLEAN,
|
||||
BOOLEAN, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, BOOLEAN, TEXT[]
|
||||
);
|
||||
CREATE OR REPLACE FUNCTION public.upsert_brand_settings(
|
||||
p_brand_id UUID,
|
||||
p_legal_business_name TEXT,
|
||||
p_phone TEXT,
|
||||
p_email TEXT,
|
||||
p_website_url TEXT,
|
||||
p_street_address TEXT,
|
||||
p_city TEXT,
|
||||
p_state TEXT,
|
||||
p_postal_code TEXT,
|
||||
p_country TEXT,
|
||||
p_logo_url TEXT,
|
||||
p_logo_url_dark TEXT,
|
||||
p_default_email_signature TEXT,
|
||||
p_invoice_footer_notes TEXT,
|
||||
p_hero_tagline TEXT,
|
||||
p_about_headline TEXT,
|
||||
p_about_subheadline TEXT,
|
||||
p_custom_footer_text TEXT,
|
||||
p_show_wholesale_link BOOLEAN,
|
||||
p_show_zip_search BOOLEAN,
|
||||
p_show_schedule_pdf BOOLEAN,
|
||||
p_show_text_alerts BOOLEAN,
|
||||
p_schedule_pdf_notes TEXT,
|
||||
p_hero_image_url TEXT,
|
||||
p_brand_primary_color TEXT,
|
||||
p_brand_secondary_color TEXT,
|
||||
p_brand_bg_color TEXT,
|
||||
p_brand_text_color TEXT,
|
||||
p_collect_sales_tax BOOLEAN DEFAULT NULL,
|
||||
p_nexus_states TEXT[] DEFAULT NULL
|
||||
) RETURNS brand_settings
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $$
|
||||
DECLARE
|
||||
v_row brand_settings;
|
||||
BEGIN
|
||||
INSERT INTO brand_settings (
|
||||
id, brand_id, legal_business_name, phone, email, website_url,
|
||||
street_address, city, state, postal_code, country,
|
||||
logo_url, logo_url_dark, default_email_signature, invoice_footer_notes,
|
||||
hero_tagline, about_headline, about_subheadline, custom_footer_text,
|
||||
show_wholesale_link, show_zip_search, show_schedule_pdf, show_text_alerts,
|
||||
schedule_pdf_notes, hero_image_url,
|
||||
brand_primary_color, brand_secondary_color, brand_bg_color, brand_text_color,
|
||||
collect_sales_tax, nexus_states
|
||||
)
|
||||
VALUES (
|
||||
gen_random_uuid(), p_brand_id, p_legal_business_name, p_phone, p_email, p_website_url,
|
||||
p_street_address, p_city, p_state, p_postal_code, p_country,
|
||||
p_logo_url, p_logo_url_dark, p_default_email_signature, p_invoice_footer_notes,
|
||||
p_hero_tagline, p_about_headline, p_about_subheadline, p_custom_footer_text,
|
||||
COALESCE(p_show_wholesale_link, TRUE), COALESCE(p_show_zip_search, TRUE),
|
||||
COALESCE(p_show_schedule_pdf, TRUE), COALESCE(p_show_text_alerts, FALSE),
|
||||
p_schedule_pdf_notes, p_hero_image_url,
|
||||
p_brand_primary_color, p_brand_secondary_color, p_brand_bg_color, p_brand_text_color,
|
||||
COALESCE(p_collect_sales_tax, FALSE), COALESCE(p_nexus_states, '{CO}'::TEXT[])
|
||||
)
|
||||
ON CONFLICT (brand_id) DO UPDATE SET
|
||||
legal_business_name = COALESCE(p_legal_business_name, brand_settings.legal_business_name),
|
||||
phone = COALESCE(p_phone, brand_settings.phone),
|
||||
email = COALESCE(p_email, brand_settings.email),
|
||||
website_url = COALESCE(p_website_url, brand_settings.website_url),
|
||||
street_address = COALESCE(p_street_address, brand_settings.street_address),
|
||||
city = COALESCE(p_city, brand_settings.city),
|
||||
state = COALESCE(p_state, brand_settings.state),
|
||||
postal_code = COALESCE(p_postal_code, brand_settings.postal_code),
|
||||
country = COALESCE(p_country, brand_settings.country),
|
||||
logo_url = COALESCE(p_logo_url, brand_settings.logo_url),
|
||||
logo_url_dark = COALESCE(p_logo_url_dark, brand_settings.logo_url_dark),
|
||||
default_email_signature = COALESCE(p_default_email_signature, brand_settings.default_email_signature),
|
||||
invoice_footer_notes = COALESCE(p_invoice_footer_notes, brand_settings.invoice_footer_notes),
|
||||
hero_tagline = COALESCE(p_hero_tagline, brand_settings.hero_tagline),
|
||||
about_headline = COALESCE(p_about_headline, brand_settings.about_headline),
|
||||
about_subheadline = COALESCE(p_about_subheadline, brand_settings.about_subheadline),
|
||||
custom_footer_text = COALESCE(p_custom_footer_text, brand_settings.custom_footer_text),
|
||||
show_wholesale_link = COALESCE(p_show_wholesale_link, brand_settings.show_wholesale_link),
|
||||
show_zip_search = COALESCE(p_show_zip_search, brand_settings.show_zip_search),
|
||||
show_schedule_pdf = COALESCE(p_show_schedule_pdf, brand_settings.show_schedule_pdf),
|
||||
show_text_alerts = COALESCE(p_show_text_alerts, brand_settings.show_text_alerts),
|
||||
schedule_pdf_notes = COALESCE(p_schedule_pdf_notes, brand_settings.schedule_pdf_notes),
|
||||
hero_image_url = COALESCE(p_hero_image_url, brand_settings.hero_image_url),
|
||||
brand_primary_color = COALESCE(p_brand_primary_color, brand_settings.brand_primary_color),
|
||||
brand_secondary_color = COALESCE(p_brand_secondary_color, brand_settings.brand_secondary_color),
|
||||
brand_bg_color = COALESCE(p_brand_bg_color, brand_settings.brand_bg_color),
|
||||
brand_text_color = COALESCE(p_brand_text_color, brand_settings.brand_text_color),
|
||||
collect_sales_tax = COALESCE(p_collect_sales_tax, brand_settings.collect_sales_tax),
|
||||
nexus_states = COALESCE(p_nexus_states, brand_settings.nexus_states)
|
||||
RETURNING * INTO v_row;
|
||||
RETURN v_row;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── 3. Update get_brand_settings ─────────────────────────────────────────────
|
||||
DROP FUNCTION IF EXISTS public.get_brand_settings(uuid);
|
||||
CREATE OR REPLACE FUNCTION public.get_brand_settings(
|
||||
p_brand_id UUID
|
||||
) RETURNS brand_settings
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $$
|
||||
DECLARE
|
||||
v_row brand_settings;
|
||||
BEGIN
|
||||
SELECT *
|
||||
INTO v_row
|
||||
FROM brand_settings
|
||||
WHERE brand_id = p_brand_id;
|
||||
RETURN v_row;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── 4. Update get_brand_settings_by_slug (public) ────────────────────────────
|
||||
DROP FUNCTION IF EXISTS public.get_brand_settings_by_slug(TEXT);
|
||||
CREATE OR REPLACE FUNCTION public.get_brand_settings_by_slug(
|
||||
p_brand_slug TEXT
|
||||
) RETURNS brand_settings
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $$
|
||||
DECLARE
|
||||
v_row brand_settings;
|
||||
BEGIN
|
||||
SELECT bs.*
|
||||
INTO v_row
|
||||
FROM brand_settings bs
|
||||
JOIN brands b ON b.id = bs.brand_id
|
||||
WHERE b.slug = p_brand_slug;
|
||||
RETURN v_row;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── 5. Tax Summary RPC ────────────────────────────────────────────────────────
|
||||
CREATE OR REPLACE FUNCTION public.get_tax_summary(
|
||||
p_brand_id UUID,
|
||||
p_start_date DATE,
|
||||
p_end_date DATE
|
||||
) RETURNS TABLE (
|
||||
total_tax_collected NUMERIC,
|
||||
total_gross_sales NUMERIC,
|
||||
order_count BIGINT,
|
||||
tax_by_state JSONB
|
||||
)
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
WITH taxed AS (
|
||||
SELECT
|
||||
o.tax_amount,
|
||||
o.subtotal,
|
||||
o.tax_location,
|
||||
-- Parse state from tax_location (format: 'STATE:CO' or just 'CO')
|
||||
CASE
|
||||
WHEN o.tax_location IS NOT NULL AND position('STATE:' IN o.tax_location) > 0 THEN
|
||||
upper(substr(o.tax_location, position('STATE:' IN o.tax_location) + 7, 2))
|
||||
WHEN o.tax_location IS NOT NULL AND length(o.tax_location) = 2 THEN
|
||||
upper(o.tax_location)
|
||||
WHEN o.tax_location IS NOT NULL AND length(o.tax_location) > 2 THEN
|
||||
upper(substr(o.tax_location, 1, 2))
|
||||
ELSE
|
||||
'UNKNOWN'::TEXT
|
||||
END AS state_code
|
||||
FROM orders o
|
||||
WHERE o.brand_id = p_brand_id
|
||||
AND o.created_at::DATE >= p_start_date
|
||||
AND o.created_at::DATE <= p_end_date
|
||||
AND o.tax_amount IS NOT NULL
|
||||
AND o.tax_amount > 0
|
||||
)
|
||||
SELECT
|
||||
COALESCE(SUM(t.tax_amount), 0)::NUMERIC AS total_tax_collected,
|
||||
COALESCE(SUM(t.subtotal), 0)::NUMERIC AS total_gross_sales,
|
||||
COUNT(DISTINCT t.tax_amount)::BIGINT AS order_count,
|
||||
COALESCE(JSONB_AGG(
|
||||
JSONB_BUILD_OBJECT(
|
||||
'state', state_code,
|
||||
'total_tax', COALESCE(SUM(t.tax_amount) FILTER (WHERE state_code = t.state_code), 0),
|
||||
'gross_sales', COALESCE(SUM(t.subtotal) FILTER (WHERE state_code = t.state_code), 0),
|
||||
'order_count', COUNT(*) FILTER (WHERE state_code = t.state_code)
|
||||
) ORDER BY state_code
|
||||
), '[]'::JSONB) AS tax_by_state
|
||||
FROM taxed t;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── 6. Taxable Orders RPC ─────────────────────────────────────────────────────
|
||||
CREATE OR REPLACE FUNCTION public.get_taxable_orders(
|
||||
p_brand_id UUID,
|
||||
p_start_date DATE,
|
||||
p_end_date DATE
|
||||
) RETURNS TABLE (
|
||||
order_id UUID,
|
||||
date TEXT,
|
||||
customer_name TEXT,
|
||||
city TEXT,
|
||||
state TEXT,
|
||||
taxable_amount NUMERIC,
|
||||
tax_amount NUMERIC,
|
||||
tax_rate NUMERIC,
|
||||
tax_location TEXT
|
||||
)
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
o.id AS order_id,
|
||||
o.created_at::DATE::TEXT AS date,
|
||||
o.customer_name,
|
||||
COALESCE(s.city, ''::TEXT) AS city,
|
||||
COALESCE(s.state, ''::TEXT) AS state,
|
||||
o.subtotal AS taxable_amount,
|
||||
o.tax_amount,
|
||||
o.tax_rate,
|
||||
COALESCE(o.tax_location, ''::TEXT) AS tax_location
|
||||
FROM orders o
|
||||
LEFT JOIN stops s ON s.id = o.stop_id
|
||||
WHERE o.brand_id = p_brand_id
|
||||
AND o.created_at::DATE >= p_start_date
|
||||
AND o.created_at::DATE <= p_end_date
|
||||
AND o.tax_amount IS NOT NULL
|
||||
AND o.tax_amount > 0
|
||||
ORDER BY o.created_at DESC;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- 096: Tax Orders Index — partial index for efficient tax queries
|
||||
-- Note: CREATE INDEX CONCURRENTLY cannot run inside a transaction block
|
||||
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_tax_brand_date
|
||||
ON orders (brand_id, created_at)
|
||||
WHERE tax_amount IS NOT NULL AND tax_amount > 0;
|
||||
@@ -0,0 +1,192 @@
|
||||
-- 097: Extend create_order_with_items with tax fields
|
||||
-- Adds p_tax_amount, p_tax_rate, p_tax_location params to the retail order RPC
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Get the current signature to understand the parameter list
|
||||
-- create_order_with_items is in migrations 001, 021, 022
|
||||
-- It currently accepts: p_idempotency_key, p_customer_name, p_customer_email,
|
||||
-- p_customer_phone, p_stop_id, p_items
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.create_order_with_items(
|
||||
p_idempotency_key UUID,
|
||||
p_customer_name TEXT,
|
||||
p_customer_email TEXT,
|
||||
p_customer_phone TEXT,
|
||||
p_stop_id UUID,
|
||||
p_items JSONB,
|
||||
p_tax_amount NUMERIC DEFAULT 0,
|
||||
p_tax_rate NUMERIC DEFAULT 0,
|
||||
p_tax_location TEXT DEFAULT NULL
|
||||
) RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $$
|
||||
DECLARE
|
||||
v_order_id UUID;
|
||||
v_item JSONB;
|
||||
v_product_id UUID;
|
||||
v_quantity INT;
|
||||
v_fulfillment TEXT;
|
||||
v_product_price NUMERIC;
|
||||
v_computed_total NUMERIC := 0;
|
||||
v_stop_active BOOLEAN;
|
||||
v_stop_brand_id UUID;
|
||||
v_stop_city TEXT;
|
||||
v_stop_state TEXT;
|
||||
v_stop_date TEXT;
|
||||
v_stop_time TEXT;
|
||||
v_stop_location TEXT;
|
||||
v_product_active BOOLEAN;
|
||||
v_product_brand_id UUID;
|
||||
v_product_name TEXT;
|
||||
v_is_pickup BOOLEAN;
|
||||
v_order JSONB;
|
||||
v_order_items JSONB := '[]'::JSONB;
|
||||
BEGIN
|
||||
-- ── Idempotency guard ──────────────────────────────────────
|
||||
SELECT jsonb_build_object(
|
||||
'id', o.id,
|
||||
'customer_name', o.customer_name,
|
||||
'customer_email', o.customer_name,
|
||||
'customer_phone', o.customer_phone,
|
||||
'subtotal', o.subtotal,
|
||||
'status', o.status,
|
||||
'stop_id', o.stop_id,
|
||||
'stop_city', s.city,
|
||||
'stop_state', s.state,
|
||||
'stop_date', s.date,
|
||||
'stop_time', s.time,
|
||||
'stop_location', s.location,
|
||||
'items', COALESCE((
|
||||
SELECT jsonb_agg(jsonb_build_object(
|
||||
'product_id', oi.product_id,
|
||||
'product_name', p.name,
|
||||
'quantity', oi.quantity,
|
||||
'price', oi.price,
|
||||
'fulfillment', oi.fulfillment
|
||||
))
|
||||
FROM order_items oi
|
||||
JOIN products p ON oi.product_id = p.id
|
||||
WHERE oi.order_id = o.id
|
||||
), '[]'::JSONB)
|
||||
)
|
||||
INTO v_order
|
||||
FROM orders o
|
||||
JOIN stops s ON o.stop_id = s.id
|
||||
WHERE o.idempotency_key = p_idempotency_key
|
||||
LIMIT 1;
|
||||
|
||||
IF v_order IS NOT NULL THEN
|
||||
RETURN v_order;
|
||||
END IF;
|
||||
|
||||
-- ── Validate stop ──────────────────────────────────────────
|
||||
SELECT active, brand_id, city, state, date, time, location
|
||||
INTO v_stop_active, v_stop_brand_id, v_stop_city, v_stop_state, v_stop_date, v_stop_time, v_stop_location
|
||||
FROM stops
|
||||
WHERE id = p_stop_id;
|
||||
|
||||
IF p_stop_id IS NOT NULL AND (v_stop_active IS NULL OR v_stop_active IS FALSE) THEN
|
||||
RAISE EXCEPTION 'Stop not found or inactive';
|
||||
END IF;
|
||||
|
||||
-- ── Validate products & build items list ───────────────────
|
||||
FOR v_item IN SELECT * FROM jsonb_array_elements(p_items)
|
||||
LOOP
|
||||
v_product_id := (v_item->>'id')::UUID;
|
||||
v_quantity := (v_item->>'quantity')::INT;
|
||||
v_fulfillment := COALESCE(v_item->>'fulfillment', 'pickup');
|
||||
|
||||
SELECT active, brand_id, name, price
|
||||
INTO v_product_active, v_product_brand_id, v_product_name, v_product_price
|
||||
FROM products
|
||||
WHERE id = v_product_id;
|
||||
|
||||
IF v_product_active IS FALSE THEN
|
||||
RAISE EXCEPTION 'Product % is not active', v_product_id;
|
||||
END IF;
|
||||
|
||||
v_computed_total := v_computed_total + (v_product_price * v_quantity);
|
||||
|
||||
v_order_items := v_order_items || jsonb_build_object(
|
||||
'product_id', v_product_id,
|
||||
'product_name', v_product_name,
|
||||
'quantity', v_quantity,
|
||||
'price', v_product_price,
|
||||
'fulfillment', v_fulfillment
|
||||
);
|
||||
END LOOP;
|
||||
|
||||
-- ── Determine brand ────────────────────────────────────────
|
||||
-- Use stop's brand if stop_id set, otherwise require brand from products
|
||||
IF p_stop_id IS NOT NULL THEN
|
||||
-- brand from stop verified above
|
||||
NULL;
|
||||
ELSE
|
||||
-- shipping-only: brand must come from first product
|
||||
v_product_brand_id := v_product_brand_id; -- already selected in loop
|
||||
END IF;
|
||||
|
||||
v_is_pickup := v_fulfillment = 'pickup';
|
||||
|
||||
-- ── Create order ────────────────────────────────────────────
|
||||
INSERT INTO orders (
|
||||
idempotency_key, customer_name, customer_email, customer_phone,
|
||||
stop_id, subtotal, status,
|
||||
brand_id,
|
||||
-- Tax fields
|
||||
tax_amount, tax_rate, tax_location,
|
||||
pickup_complete
|
||||
) VALUES (
|
||||
p_idempotency_key, p_customer_name, p_customer_email, p_customer_phone,
|
||||
p_stop_id, v_computed_total, 'pending',
|
||||
v_stop_brand_id,
|
||||
-- Tax fields
|
||||
p_tax_amount, p_tax_rate, p_tax_location,
|
||||
FALSE
|
||||
)
|
||||
RETURNING id INTO v_order_id;
|
||||
|
||||
-- ── Insert order items ──────────────────────────────────────
|
||||
FOR v_item IN SELECT * FROM jsonb_array_elements(p_items)
|
||||
LOOP
|
||||
v_product_id := (v_item->>'id')::UUID;
|
||||
v_quantity := (v_item->>'quantity')::INT;
|
||||
v_fulfillment := COALESCE(v_item->>'fulfillment', 'pickup');
|
||||
|
||||
SELECT price INTO v_product_price FROM products WHERE id = v_product_id;
|
||||
|
||||
INSERT INTO order_items (order_id, product_id, quantity, price, fulfillment)
|
||||
VALUES (v_order_id, v_product_id, v_quantity, v_product_price, v_fulfillment);
|
||||
END LOOP;
|
||||
|
||||
-- ── Emit order_placed event ────────────────────────────────
|
||||
PERFORM pg_notify('order_placed', jsonb_build_object(
|
||||
'order_id', v_order_id,
|
||||
'brand_id', v_stop_brand_id,
|
||||
'customer_name', p_customer_name,
|
||||
'subtotal', v_computed_total,
|
||||
'fulfillment', CASE WHEN p_stop_id IS NOT NULL THEN 'pickup' ELSE 'ship' END
|
||||
)::TEXT);
|
||||
|
||||
-- ── Return order ────────────────────────────────────────────
|
||||
RETURN jsonb_build_object(
|
||||
'id', v_order_id,
|
||||
'customer_name', p_customer_name,
|
||||
'customer_email', p_customer_email,
|
||||
'customer_phone', p_customer_phone,
|
||||
'subtotal', v_computed_total,
|
||||
'status', 'pending',
|
||||
'stop_id', p_stop_id,
|
||||
'stop_city', v_stop_city,
|
||||
'stop_state', v_stop_state,
|
||||
'stop_date', v_stop_date,
|
||||
'stop_time', v_stop_time,
|
||||
'stop_location', v_stop_location,
|
||||
'items', v_order_items
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,53 @@
|
||||
-- 098_products_is_taxable.sql
|
||||
-- Add is_taxable flag to products table
|
||||
|
||||
ALTER TABLE products ADD COLUMN IF NOT EXISTS is_taxable BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
-- Comment for documentation
|
||||
COMMENT ON COLUMN products.is_taxable IS 'If false, this product is exempt from sales tax even when shipping to nexus states';
|
||||
|
||||
-- Update get_stop_products to include is_taxable
|
||||
DROP FUNCTION IF EXISTS public.get_stop_products(UUID);
|
||||
CREATE OR REPLACE FUNCTION public.get_stop_products(p_stop_id UUID)
|
||||
RETURNS TABLE (
|
||||
id UUID,
|
||||
name TEXT,
|
||||
description TEXT,
|
||||
price NUMERIC,
|
||||
type TEXT,
|
||||
image_url TEXT,
|
||||
active BOOLEAN,
|
||||
brand_id UUID,
|
||||
is_taxable BOOLEAN
|
||||
)
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
p.id,
|
||||
p.name,
|
||||
p.description,
|
||||
p.price,
|
||||
p.type,
|
||||
p.image_url,
|
||||
p.active,
|
||||
p.brand_id,
|
||||
COALESCE(p.is_taxable, true)::BOOLEAN AS is_taxable
|
||||
FROM products p
|
||||
WHERE p.brand_id = (
|
||||
SELECT stop_id::UUID FROM (
|
||||
SELECT brand_id AS stop_id FROM stops WHERE id = p_stop_id
|
||||
UNION ALL
|
||||
SELECT brand_id FROM stops WHERE id = p_stop_id
|
||||
) AS sub LIMIT 1
|
||||
)
|
||||
AND p.active = true
|
||||
AND p.deleted_at IS NULL
|
||||
ORDER BY p.name;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Create index on is_taxable for common queries
|
||||
CREATE INDEX IF NOT EXISTS idx_products_is_taxable ON products(is_taxable) WHERE is_taxable = false;
|
||||
@@ -0,0 +1,36 @@
|
||||
-- Create imports bucket if it doesn't exist
|
||||
-- Note: Run this in Supabase dashboard or via CLI
|
||||
-- supabase storage create contacts-imports --public
|
||||
|
||||
-- Create RPC function to process imports from bucket URL
|
||||
CREATE OR REPLACE FUNCTION process_contact_import_from_url(
|
||||
p_brand_id UUID,
|
||||
p_file_url TEXT,
|
||||
p_allow_opt_in_override BOOLEAN DEFAULT false
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $$
|
||||
DECLARE
|
||||
v_result JSONB;
|
||||
v_csv_text TEXT;
|
||||
BEGIN
|
||||
-- Fetch the CSV from the bucket URL
|
||||
SELECT content INTO v_csv_text
|
||||
FROM net.http_get(p_file_url);
|
||||
|
||||
-- Process the contacts (this would need to be implemented based on your CSV parsing logic)
|
||||
-- For now, return a placeholder result
|
||||
-- You would call your existing import logic here
|
||||
|
||||
v_result := jsonb_build_object(
|
||||
'created', 0,
|
||||
'updated', 0,
|
||||
'skipped', 0,
|
||||
'errors', 0
|
||||
);
|
||||
|
||||
RETURN v_result;
|
||||
END;
|
||||
$$;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user