Files
Nora 0ac4beaaa8 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)
2026-06-25 23:49:37 -06:00

177 lines
7.9 KiB
PL/PgSQL

-- 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';