Files
route-commerce/db/migrations/0001_init.sql
T
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

2055 lines
83 KiB
PL/PgSQL

-- 0001_init.sql
--
-- Route Commerce — Complete Schema
-- Neon Postgres (Neon Auth) + Application Schema
--
-- Fresh start: drops stub tables, creates full Route Commerce schema.
-- Neon Auth tables (users, accounts, sessions, verifications) are preserved.
--
-- Conventions:
-- - Status enums as TEXT + CHECK (no PG ENUM type)
-- - TIMESTAMPTZ everywhere for timestamps
-- - gen_random_uuid() for all UUID PKs
-- - CREATE OR REPLACE for helpers; CREATE TABLE IF NOT EXISTS + guarded triggers for re-runnability
-- - All business tables are brand-scoped (brand_id FK → brands.id)
-- - SECURITY DEFINER RPCs for all data access
-- - RLS on tenant-scoped tables; bypass via is_platform_admin() GUC
--
-- Brand scoping: every brand-scoped table has brand_id and an RLS policy
-- that compares it to current_setting('app.current_brand_id'). The
-- application MUST set this GUC (transaction-local) before any query.
-- Platform admin sets app.platform_admin='true' to bypass.
--
-- ============================================================================
-- BEGIN; (transaction controlled by migrate runner)
-- ============================================================================
-- 0. Extensions
-- ============================================================================
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- ============================================================================
-- 1. Brands (Tenants)
-- Drop stub, recreate with full schema.
-- ============================================================================
DROP TABLE IF EXISTS items CASCADE;
DROP TABLE IF EXISTS addons CASCADE;
DROP TABLE IF EXISTS subscriptions CASCADE;
DROP TABLE IF EXISTS plans CASCADE;
DROP TABLE IF EXISTS products CASCADE;
DROP TABLE IF EXISTS brands CASCADE;
CREATE TABLE IF NOT EXISTS brands (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
plan_tier TEXT NOT NULL DEFAULT 'starter'
CHECK (plan_tier IN ('starter', 'farm', 'enterprise')),
max_users INTEGER NOT NULL DEFAULT 2,
max_products INTEGER NOT NULL DEFAULT 25,
max_stops_monthly INTEGER NOT NULL DEFAULT 10,
stripe_customer_id TEXT,
stripe_subscription_id TEXT,
stripe_subscription_status TEXT
CHECK (stripe_subscription_status IN ('trialing', 'active', 'past_due', 'canceled', 'incomplete', 'incomplete_expired')),
stripe_current_period_end TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE OR REPLACE FUNCTION set_updated_at() RETURNS TRIGGER AS $$
BEGIN NEW.updated_at = now(); RETURN NEW; END;
$$ LANGUAGE plpgsql;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_trigger t
JOIN pg_class c ON t.tgrelid = c.oid
JOIN pg_namespace n ON c.relnamespace = n.oid
WHERE t.tgname = 'brands_updated_at' AND n.nspname = current_schema()
) THEN
CREATE TRIGGER brands_updated_at BEFORE UPDATE ON brands
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
END IF;
END $$;
-- ============================================================================
-- 2. Admin Users + Multi-brand
-- Links to Neon Auth users table (neon_neon_auth.user) via email.
-- ============================================================================
CREATE TABLE IF NOT EXISTS admin_users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES neon_auth.user(id) ON DELETE CASCADE,
email TEXT NOT NULL,
name TEXT,
role TEXT NOT NULL DEFAULT 'brand_admin'
CHECK (role IN ('platform_admin', 'brand_admin', 'store_employee')),
can_manage_orders BOOLEAN NOT NULL DEFAULT true,
can_manage_products BOOLEAN NOT NULL DEFAULT true,
can_manage_stops BOOLEAN NOT NULL DEFAULT true,
can_manage_customers BOOLEAN NOT NULL DEFAULT true,
can_manage_wholesale BOOLEAN NOT NULL DEFAULT false,
can_manage_billing BOOLEAN NOT NULL DEFAULT false,
can_manage_settings BOOLEAN NOT NULL DEFAULT false,
can_manage_water_log BOOLEAN NOT NULL DEFAULT false,
can_manage_time_tracking BOOLEAN NOT NULL DEFAULT false,
can_manage_route_trace BOOLEAN NOT NULL DEFAULT false,
can_manage_reports BOOLEAN NOT NULL DEFAULT true,
can_manage_communications BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (user_id),
UNIQUE (email)
);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_trigger t
JOIN pg_class c ON t.tgrelid = c.oid
JOIN pg_namespace n ON c.relnamespace = n.oid
WHERE t.tgname = 'admin_users_updated_at' AND n.nspname = current_schema()
) THEN
CREATE TRIGGER admin_users_updated_at BEFORE UPDATE ON admin_users
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
END IF;
END $$;
CREATE TABLE IF NOT EXISTS admin_user_brands (
admin_user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
added_at TIMESTAMPTZ NOT NULL DEFAULT now(),
added_by UUID REFERENCES admin_users(id),
PRIMARY KEY (admin_user_id, brand_id)
);
-- ============================================================================
-- 3. Products
-- ============================================================================
CREATE TABLE IF NOT EXISTS products (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
sku TEXT,
type TEXT NOT NULL DEFAULT 'standard'
CHECK (type IN ('standard', 'wholesale', 'both')),
price_cents INTEGER NOT NULL CHECK (price_cents >= 0),
inventory INTEGER NOT NULL DEFAULT 0,
unit TEXT,
active BOOLEAN NOT NULL DEFAULT true,
is_taxable BOOLEAN NOT NULL DEFAULT false,
pickup_type TEXT NOT NULL DEFAULT 'all'
CHECK (pickup_type IN ('pickup', 'ship', 'all')),
image_url TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_trigger t
JOIN pg_class c ON t.tgrelid = c.oid
JOIN pg_namespace n ON c.relnamespace = n.oid
WHERE t.tgname = 'products_updated_at' AND n.nspname = current_schema()
) THEN
CREATE TRIGGER products_updated_at BEFORE UPDATE ON products
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
END IF;
END $$;
CREATE INDEX IF NOT EXISTS products_brand_idx ON products (brand_id);
CREATE INDEX IF NOT EXISTS products_active_idx ON products (brand_id, active);
-- ============================================================================
-- 4. Product Images
-- ============================================================================
CREATE TABLE IF NOT EXISTS product_images (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE,
storage_key TEXT NOT NULL,
position INTEGER NOT NULL DEFAULT 0,
alt_text TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS product_images_product_idx ON product_images (product_id);
-- ============================================================================
-- 5. Stops / Locations
-- ============================================================================
CREATE TABLE IF NOT EXISTS stops (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
name TEXT NOT NULL,
location TEXT NOT NULL,
address TEXT,
city TEXT,
state TEXT,
zip TEXT,
date DATE NOT NULL,
"time" TEXT,
cutoff_date DATE,
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'paused', 'closed')),
is_public BOOLEAN NOT NULL DEFAULT true,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_trigger t
JOIN pg_class c ON t.tgrelid = c.oid
JOIN pg_namespace n ON c.relnamespace = n.oid
WHERE t.tgname = 'stops_updated_at' AND n.nspname = current_schema()
) THEN
CREATE TRIGGER stops_updated_at BEFORE UPDATE ON stops
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
END IF;
END $$;
CREATE INDEX IF NOT EXISTS stops_brand_idx ON stops (brand_id);
CREATE INDEX IF NOT EXISTS stops_status_idx ON stops (brand_id, status);
CREATE INDEX IF NOT EXISTS stops_date_idx ON stops (brand_id, date);
CREATE TABLE IF NOT EXISTS locations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
name TEXT NOT NULL,
address TEXT,
city TEXT,
state TEXT,
zip TEXT,
phone TEXT,
contact_name TEXT,
contact_email TEXT,
notes TEXT,
active BOOLEAN NOT NULL DEFAULT true,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_trigger t
JOIN pg_class c ON t.tgrelid = c.oid
JOIN pg_namespace n ON c.relnamespace = n.oid
WHERE t.tgname = 'locations_updated_at' AND n.nspname = current_schema()
) THEN
CREATE TRIGGER locations_updated_at BEFORE UPDATE ON locations
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
END IF;
END $$;
-- ============================================================================
-- 5. Customers
-- ============================================================================
CREATE TABLE IF NOT EXISTS customers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
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)
);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_trigger t
JOIN pg_class c ON t.tgrelid = c.oid
JOIN pg_namespace n ON c.relnamespace = n.oid
WHERE t.tgname = 'customers_updated_at' AND n.nspname = current_schema()
) THEN
CREATE TRIGGER customers_updated_at BEFORE UPDATE ON customers
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
END IF;
END $$;
CREATE INDEX IF NOT EXISTS customers_brand_idx ON customers (brand_id);
-- ============================================================================
-- 6. Orders
-- ============================================================================
CREATE TABLE IF NOT EXISTS orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
customer_id UUID REFERENCES customers(id) ON DELETE SET NULL,
stop_id UUID REFERENCES stops(id) ON DELETE SET NULL,
total_cents INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'confirmed', 'fulfilled', 'canceled')),
fulfillment TEXT NOT NULL
CHECK (fulfillment IN ('pickup', 'ship', 'mixed')),
customer_address TEXT,
customer_city TEXT,
customer_state TEXT,
customer_zip TEXT,
idempotency_key TEXT,
notes TEXT,
placed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_trigger t
JOIN pg_class c ON t.tgrelid = c.oid
JOIN pg_namespace n ON c.relnamespace = n.oid
WHERE t.tgname = 'orders_updated_at' AND n.nspname = current_schema()
) THEN
CREATE TRIGGER orders_updated_at BEFORE UPDATE ON orders
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
END IF;
END $$;
CREATE INDEX IF NOT EXISTS orders_brand_idx ON orders (brand_id);
CREATE INDEX IF NOT EXISTS orders_status_idx ON orders (brand_id, status);
CREATE INDEX IF NOT EXISTS orders_stop_idx ON orders (stop_id);
CREATE INDEX IF NOT EXISTS orders_customer_idx ON orders (customer_id);
CREATE TABLE IF NOT EXISTS order_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
order_id UUID NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
product_id UUID REFERENCES products(id) ON DELETE SET NULL,
quantity INTEGER NOT NULL CHECK (quantity > 0),
price_cents INTEGER NOT NULL CHECK (price_cents >= 0),
fulfillment TEXT NOT NULL CHECK (fulfillment IN ('pickup', 'ship')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS order_items_order_idx ON order_items (order_id);
-- ============================================================================
-- 7. Brand Settings + Features
-- ============================================================================
CREATE TABLE IF NOT EXISTS brand_settings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
brand_id UUID NOT NULL UNIQUE REFERENCES brands(id) ON DELETE CASCADE,
legal_business_name TEXT,
phone TEXT,
email TEXT,
website_url TEXT,
street_address TEXT,
city TEXT,
state TEXT,
postal_code TEXT,
country TEXT DEFAULT 'US',
logo_url TEXT,
logo_url_dark TEXT,
hero_image_url TEXT,
tagline TEXT,
about_html TEXT,
primary_color TEXT DEFAULT '#0F766E',
default_email_signature TEXT,
invoice_footer_notes TEXT,
from_email TEXT,
from_name TEXT,
reply_to_email TEXT,
custom_footer_text TEXT,
feature_flags JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_trigger t
JOIN pg_class c ON t.tgrelid = c.oid
JOIN pg_namespace n ON c.relnamespace = n.oid
WHERE t.tgname = 'brand_settings_updated_at' AND n.nspname = current_schema()
) THEN
CREATE TRIGGER brand_settings_updated_at BEFORE UPDATE ON brand_settings
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
END IF;
END $$;
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,
enabled BOOLEAN NOT NULL DEFAULT false,
enabled_at TIMESTAMPTZ,
disabled_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (brand_id, feature_key)
);
-- ============================================================================
-- 8. Billing — Plans + Add-ons
-- ============================================================================
CREATE TABLE IF NOT EXISTS plans (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code TEXT NOT NULL UNIQUE
CHECK (code IN ('starter', 'farm', 'enterprise')),
name TEXT NOT NULL,
monthly_price_cents INTEGER NOT NULL CHECK (monthly_price_cents >= 0),
max_users INTEGER NOT NULL,
max_products INTEGER NOT NULL,
max_stops_monthly INTEGER NOT NULL,
features JSONB NOT NULL DEFAULT '[]',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS add_ons (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code TEXT NOT NULL UNIQUE
CHECK (code IN (
'wholesale_portal', 'harvest_reach', 'ai_tools',
'water_log', 'square_sync', 'sms_campaigns'
)),
name TEXT NOT NULL,
monthly_price_cents INTEGER NOT NULL CHECK (monthly_price_cents >= 0),
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS brand_add_ons (
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
add_on_id UUID NOT NULL REFERENCES add_ons(id) ON DELETE CASCADE,
stripe_subscription_id TEXT,
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'canceled')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (brand_id, add_on_id)
);
-- Seed plans
INSERT INTO plans (code, name, monthly_price_cents, max_users, max_products, max_stops_monthly, features) VALUES
('starter', 'Starter', 4900, 1, 25, 10, '["products", "stops", "orders", "pickup"]'),
('farm', 'Farm', 14900, 5, -1, -1, '["products", "stops", "orders", "pickup", "ship", "wholesale_portal", "harvest_reach"]'),
('enterprise', 'Enterprise', 39900, -1, -1, -1, '["products", "stops", "orders", "pickup", "ship", "wholesale_portal", "harvest_reach", "ai_tools", "sms_campaigns", "square_sync", "water_log"]');
-- Seed add-ons
INSERT INTO add_ons (code, name, monthly_price_cents, description) VALUES
('wholesale_portal', 'Wholesale Portal', 9900, 'B2B self-service portal for wholesale customers'),
('harvest_reach', 'Harvest Reach', 7900, 'Email/SMS campaigns, automation, stop-blast'),
('ai_tools', 'AI Intelligence Pack', 5900, 'AI-powered campaign writer, pricing advisor, forecasting'),
('water_log', 'Water Log', 3900, 'Irrigation and water usage tracking'),
('square_sync', 'Square Sync', 3900, 'Bidirectional Square POS inventory sync'),
('sms_campaigns', 'SMS Campaigns', 2900, 'SMS campaigns via Twilio');
-- ============================================================================
-- 9. Wholesale Portal
-- ============================================================================
CREATE TABLE IF NOT EXISTS wholesale_settings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
brand_id UUID NOT NULL UNIQUE REFERENCES brands(id) ON DELETE CASCADE,
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()
);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_trigger t
JOIN pg_class c ON t.tgrelid = c.oid
JOIN pg_namespace n ON c.relnamespace = n.oid
WHERE t.tgname = 'wholesale_settings_updated_at' AND n.nspname = current_schema()
) THEN
CREATE TRIGGER wholesale_settings_updated_at BEFORE UPDATE ON wholesale_settings
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
END IF;
END $$;
CREATE TABLE IF NOT EXISTS wholesale_customers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES neon_auth.user(id) ON DELETE SET NULL,
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'
CHECK (account_status IN ('active', 'suspended', 'inactive')),
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'
CHECK (role IN ('buyer', 'admin')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_trigger t
JOIN pg_class c ON t.tgrelid = c.oid
JOIN pg_namespace n ON c.relnamespace = n.oid
WHERE t.tgname = 'wholesale_customers_updated_at' AND n.nspname = current_schema()
) THEN
CREATE TRIGGER wholesale_customers_updated_at BEFORE UPDATE ON wholesale_customers
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
END IF;
END $$;
CREATE INDEX IF NOT EXISTS wholesale_customers_brand_idx ON wholesale_customers (brand_id);
CREATE TABLE IF NOT EXISTS wholesale_products (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
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',
availability TEXT NOT NULL DEFAULT 'unavailable'
CHECK (availability IN ('available', 'unavailable', 'limited', 'seasonal')),
qty_available NUMERIC(10,2) DEFAULT 0,
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()
);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_trigger t
JOIN pg_class c ON t.tgrelid = c.oid
JOIN pg_namespace n ON c.relnamespace = n.oid
WHERE t.tgname = 'wholesale_products_updated_at' AND n.nspname = current_schema()
) THEN
CREATE TRIGGER wholesale_products_updated_at BEFORE UPDATE ON wholesale_products
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
END IF;
END $$;
CREATE INDEX IF NOT EXISTS wholesale_products_brand_idx ON wholesale_products (brand_id);
CREATE TABLE IF NOT EXISTS wholesale_orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
customer_id UUID NOT NULL REFERENCES wholesale_customers(id) ON DELETE RESTRICT,
wc_order_id BIGINT,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'confirmed', 'in_production', 'ready', 'fulfilled', 'canceled')),
fulfillment_status TEXT NOT NULL DEFAULT 'unfulfilled'
CHECK (fulfillment_status IN ('unfulfilled', 'partial', 'fulfilled')),
payment_status TEXT NOT NULL DEFAULT 'unpaid'
CHECK (payment_status IN ('unpaid', 'deposit_paid', 'paid', 'refunded')),
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 neon_auth.user(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()
);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_trigger t
JOIN pg_class c ON t.tgrelid = c.oid
JOIN pg_namespace n ON c.relnamespace = n.oid
WHERE t.tgname = 'wholesale_orders_updated_at' AND n.nspname = current_schema()
) THEN
CREATE TRIGGER wholesale_orders_updated_at BEFORE UPDATE ON wholesale_orders
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
END IF;
END $$;
CREATE INDEX IF NOT EXISTS wholesale_orders_brand_idx ON wholesale_orders (brand_id);
CREATE INDEX IF NOT EXISTS wholesale_orders_customer_idx ON wholesale_orders (customer_id);
CREATE INDEX IF NOT EXISTS wholesale_orders_status_idx ON wholesale_orders (brand_id, status);
CREATE TABLE IF NOT EXISTS wholesale_order_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
wholesale_order_id UUID NOT NULL REFERENCES wholesale_orders(id) ON DELETE CASCADE,
product_id UUID NOT NULL REFERENCES wholesale_products(id) ON DELETE RESTRICT,
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()
);
CREATE INDEX IF NOT EXISTS wholesale_order_items_order_idx ON wholesale_order_items (wholesale_order_id);
CREATE TABLE IF NOT EXISTS wholesale_deposits (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
wholesale_order_id UUID NOT NULL REFERENCES wholesale_orders(id) ON DELETE CASCADE,
amount NUMERIC(10,2) NOT NULL,
payment_method TEXT CHECK (payment_method IN ('stripe', 'square', 'check', 'cash', 'other')),
reference TEXT,
recorded_by UUID NOT NULL REFERENCES neon_auth.user(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS wholesale_notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
customer_id UUID NOT NULL REFERENCES wholesale_customers(id) ON DELETE CASCADE,
notification_type TEXT NOT NULL
CHECK (notification_type IN ('order_confirmed', 'order_ready', 'pickup_reminder', 'payment_reminder', 'invoice')),
channel TEXT NOT NULL CHECK (channel IN ('email', 'sms')),
recipient TEXT NOT NULL,
subject TEXT,
body TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'sent', 'failed')),
sent_at TIMESTAMPTZ,
error_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS wholesale_notifications_brand_idx ON wholesale_notifications (brand_id);
CREATE TABLE IF NOT EXISTS wholesale_customer_product_pricing (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
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_price_cents INTEGER NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (customer_id, product_id)
);
CREATE TABLE IF NOT EXISTS wholesale_webhook_settings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
brand_id UUID NOT NULL UNIQUE REFERENCES brands(id) ON DELETE CASCADE,
url TEXT NOT NULL,
secret TEXT NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_trigger t
JOIN pg_class c ON t.tgrelid = c.oid
JOIN pg_namespace n ON c.relnamespace = n.oid
WHERE t.tgname = 'wholesale_webhook_settings_updated_at' AND n.nspname = current_schema()
) THEN
CREATE TRIGGER wholesale_webhook_settings_updated_at BEFORE UPDATE ON wholesale_webhook_settings
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
END IF;
END $$;
CREATE TABLE IF NOT EXISTS wholesale_sync_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
event_type TEXT NOT NULL,
order_id UUID,
payload JSONB,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'sent', 'failed')),
response TEXT,
attempts INTEGER NOT NULL DEFAULT 0,
next_retry TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS wholesale_sync_log_brand_idx ON wholesale_sync_log (brand_id);
-- ============================================================================
-- 10. Water Log
-- ============================================================================
CREATE TABLE IF NOT EXISTS water_headgates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
name TEXT NOT NULL,
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS water_irrigators (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
name TEXT NOT NULL,
pin_hash TEXT NOT NULL,
language_preference TEXT NOT NULL DEFAULT 'en',
active BOOLEAN NOT NULL DEFAULT true,
last_used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS water_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
irrigator_id UUID NOT NULL REFERENCES water_irrigators(id) ON DELETE CASCADE,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS water_log_entries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
headgate_id UUID NOT NULL REFERENCES water_headgates(id) ON DELETE CASCADE,
irrigator_id UUID NOT NULL REFERENCES water_irrigators(id) ON DELETE CASCADE,
measurement NUMERIC NOT NULL,
unit TEXT NOT NULL,
notes TEXT,
submitted_via TEXT NOT NULL DEFAULT 'app',
logged_at TIMESTAMPTZ NOT NULL DEFAULT now(),
logged_by UUID REFERENCES admin_users(id)
);
CREATE INDEX IF NOT EXISTS water_log_entries_brand_idx ON water_log_entries (brand_id);
CREATE INDEX IF NOT EXISTS water_log_entries_headgate_idx ON water_log_entries (headgate_id);
CREATE TABLE IF NOT EXISTS water_alert_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
alert_type TEXT NOT NULL,
headgate_id UUID REFERENCES water_headgates(id) ON DELETE SET NULL,
message TEXT NOT NULL,
sent_to TEXT,
sent_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- ============================================================================
-- 11. Communications (Harvest Reach)
-- ============================================================================
CREATE TABLE IF NOT EXISTS communication_settings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
brand_id UUID NOT NULL UNIQUE 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()
);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_trigger t
JOIN pg_class c ON t.tgrelid = c.oid
JOIN pg_namespace n ON c.relnamespace = n.oid
WHERE t.tgname = 'communication_settings_updated_at' AND n.nspname = current_schema()
) THEN
CREATE TRIGGER communication_settings_updated_at BEFORE UPDATE ON communication_settings
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
END IF;
END $$;
CREATE TABLE IF NOT EXISTS communication_templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
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()
);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_trigger t
JOIN pg_class c ON t.tgrelid = c.oid
JOIN pg_namespace n ON c.relnamespace = n.oid
WHERE t.tgname = 'communication_templates_updated_at' AND n.nspname = current_schema()
) THEN
CREATE TRIGGER communication_templates_updated_at BEFORE UPDATE ON communication_templates
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
END IF;
END $$;
CREATE INDEX IF NOT EXISTS communication_templates_brand_idx ON communication_templates (brand_id);
CREATE TABLE IF NOT EXISTS communication_segments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
rules JSONB NOT NULL DEFAULT '{}',
created_by UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS communication_segments_brand_idx ON communication_segments (brand_id);
CREATE TABLE IF NOT EXISTS communication_campaigns (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
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'
CHECK (status IN ('draft', 'scheduled', 'sending', 'sent', 'canceled')),
audience_rules JSONB NOT NULL DEFAULT '{}',
brand_name TEXT,
scheduled_at TIMESTAMPTZ,
sent_at TIMESTAMPTZ,
recipient_count INTEGER NOT NULL DEFAULT 0,
created_by UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_trigger t
JOIN pg_class c ON t.tgrelid = c.oid
JOIN pg_namespace n ON c.relnamespace = n.oid
WHERE t.tgname = 'communication_campaigns_updated_at' AND n.nspname = current_schema()
) THEN
CREATE TRIGGER communication_campaigns_updated_at BEFORE UPDATE ON communication_campaigns
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
END IF;
END $$;
CREATE INDEX IF NOT EXISTS communication_campaigns_brand_idx ON communication_campaigns (brand_id);
CREATE INDEX IF NOT EXISTS communication_campaigns_status_idx ON communication_campaigns (brand_id, status);
CREATE TABLE IF NOT EXISTS communication_contacts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
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,
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)
);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_trigger t
JOIN pg_class c ON t.tgrelid = c.oid
JOIN pg_namespace n ON c.relnamespace = n.oid
WHERE t.tgname = 'communication_contacts_updated_at' AND n.nspname = current_schema()
) THEN
CREATE TRIGGER communication_contacts_updated_at BEFORE UPDATE ON communication_contacts
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
END IF;
END $$;
CREATE INDEX IF NOT EXISTS communication_contacts_brand_idx ON communication_contacts (brand_id);
CREATE INDEX IF NOT EXISTS communication_contacts_email_idx ON communication_contacts (brand_id, email);
CREATE TABLE IF NOT EXISTS communication_message_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
campaign_id UUID REFERENCES communication_campaigns(id) ON DELETE SET NULL,
contact_id UUID REFERENCES communication_contacts(id) ON DELETE SET NULL,
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 INDEX IF NOT EXISTS communication_message_logs_brand_idx ON communication_message_logs (brand_id);
CREATE INDEX IF NOT EXISTS communication_message_logs_campaign_idx ON communication_message_logs (campaign_id);
CREATE TABLE IF NOT EXISTS customer_communication_preferences (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
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)
);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_trigger t
JOIN pg_class c ON t.tgrelid = c.oid
JOIN pg_namespace n ON c.relnamespace = n.oid
WHERE t.tgname = 'customer_communication_preferences_updated_at' AND n.nspname = current_schema()
) THEN
CREATE TRIGGER customer_communication_preferences_updated_at BEFORE UPDATE ON customer_communication_preferences
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
END IF;
END $$;
-- Email automation sequences
CREATE TABLE IF NOT EXISTS abandoned_cart_recovery (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
customer_id UUID REFERENCES wholesale_customers(id) ON DELETE SET NULL,
contact_email TEXT NOT NULL,
contact_name TEXT,
cart_snapshot JSONB NOT NULL,
brand_name TEXT,
locale TEXT DEFAULT 'en',
sequence_step INTEGER DEFAULT 0,
last_email_sent_at TIMESTAMPTZ,
next_email_at TIMESTAMPTZ,
status TEXT DEFAULT 'active'
CHECK (status IN ('active', 'recovered', 'expired', 'manually_closed')),
recovered_order_id UUID,
recovered_at TIMESTAMPTZ,
expired_at TIMESTAMPTZ,
manually_closed_at TIMESTAMPTZ,
manually_closed_by UUID REFERENCES admin_users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_trigger t
JOIN pg_class c ON t.tgrelid = c.oid
JOIN pg_namespace n ON c.relnamespace = n.oid
WHERE t.tgname = 'abandoned_cart_recovery_updated_at' AND n.nspname = current_schema()
) THEN
CREATE TRIGGER abandoned_cart_recovery_updated_at BEFORE UPDATE ON abandoned_cart_recovery
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
END IF;
END $$;
CREATE TABLE IF NOT EXISTS welcome_email_sequence (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
contact_id UUID REFERENCES communication_contacts(id) ON DELETE CASCADE,
contact_email TEXT NOT NULL,
contact_name TEXT,
brand_name TEXT,
locale TEXT DEFAULT 'en',
sequence_step INTEGER DEFAULT 0,
last_email_sent_at TIMESTAMPTZ,
next_email_at TIMESTAMPTZ,
status TEXT DEFAULT 'active'
CHECK (status IN ('active', 'completed', 'unsubscribed', 'bounced')),
completed_at TIMESTAMPTZ,
unsubscribed_at TIMESTAMPTZ,
bounced_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_trigger t
JOIN pg_class c ON t.tgrelid = c.oid
JOIN pg_namespace n ON c.relnamespace = n.oid
WHERE t.tgname = 'welcome_email_sequence_updated_at' AND n.nspname = current_schema()
) THEN
CREATE TRIGGER welcome_email_sequence_updated_at BEFORE UPDATE ON welcome_email_sequence
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
END IF;
END $$;
-- ============================================================================
-- 12. Shipping + Payments
-- ============================================================================
CREATE TABLE IF NOT EXISTS shipping_settings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
brand_id UUID NOT NULL UNIQUE REFERENCES brands(id) ON DELETE CASCADE,
carrier TEXT NOT NULL DEFAULT 'fedex',
fedex_account_number TEXT,
fedex_api_key TEXT,
fedex_api_secret TEXT,
fedex_use_production BOOLEAN NOT NULL DEFAULT false,
default_service_type TEXT NOT NULL DEFAULT 'FEDEX_GROUND',
refrigerated_handling_notes TEXT,
fragile_handling_notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_trigger t
JOIN pg_class c ON t.tgrelid = c.oid
JOIN pg_namespace n ON c.relnamespace = n.oid
WHERE t.tgname = 'shipping_settings_updated_at' AND n.nspname = current_schema()
) THEN
CREATE TRIGGER shipping_settings_updated_at BEFORE UPDATE ON shipping_settings
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
END IF;
END $$;
CREATE TABLE IF NOT EXISTS shipments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
order_id UUID NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
carrier TEXT NOT NULL DEFAULT 'fedex',
service_type TEXT NOT NULL,
tracking_number TEXT,
label_url TEXT,
rate_charged NUMERIC(10,2),
estimated_delivery_date DATE,
is_refrigerated BOOLEAN NOT NULL DEFAULT false,
is_fragile BOOLEAN NOT NULL DEFAULT false,
handling_notes TEXT,
status TEXT NOT NULL DEFAULT 'created'
CHECK (status IN ('created', 'label_printed', 'picked_up', 'in_transit', 'delivered', 'exception')),
fedex_shipment_id TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by UUID REFERENCES admin_users(id)
);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_trigger t
JOIN pg_class c ON t.tgrelid = c.oid
JOIN pg_namespace n ON c.relnamespace = n.oid
WHERE t.tgname = 'shipments_updated_at' AND n.nspname = current_schema()
) THEN
CREATE TRIGGER shipments_updated_at BEFORE UPDATE ON shipments
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
END IF;
END $$;
CREATE INDEX IF NOT EXISTS shipments_order_idx ON shipments (order_id);
CREATE TABLE IF NOT EXISTS payment_settings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
brand_id UUID NOT NULL UNIQUE REFERENCES brands(id) ON DELETE CASCADE,
provider TEXT,
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()
);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_trigger t
JOIN pg_class c ON t.tgrelid = c.oid
JOIN pg_namespace n ON c.relnamespace = n.oid
WHERE t.tgname = 'payment_settings_updated_at' AND n.nspname = current_schema()
) THEN
CREATE TRIGGER payment_settings_updated_at BEFORE UPDATE ON payment_settings
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
END IF;
END $$;
-- ============================================================================
-- 13. Square Sync
-- ============================================================================
CREATE TABLE IF NOT EXISTS square_sync_queue (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
sync_type TEXT NOT NULL DEFAULT 'products'
CHECK (sync_type IN ('products', 'inventory', 'orders')),
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'processing', 'done', 'failed')),
enqueued_at TIMESTAMPTZ NOT NULL DEFAULT now(),
processed_at TIMESTAMPTZ,
retry_count INTEGER NOT NULL DEFAULT 0,
last_error TEXT
);
CREATE INDEX IF NOT EXISTS square_sync_queue_brand_idx ON square_sync_queue (brand_id, status);
CREATE TABLE IF NOT EXISTS square_sync_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
sync_type TEXT NOT NULL,
direction TEXT NOT NULL CHECK (direction IN ('import', 'export')),
item_id TEXT,
status TEXT NOT NULL,
message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS square_sync_log_brand_idx ON square_sync_log (brand_id);
-- ============================================================================
-- 14. Time Tracking
-- ============================================================================
CREATE TABLE IF NOT EXISTS time_tracking_settings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
brand_id UUID NOT NULL UNIQUE REFERENCES brands(id) ON DELETE CASCADE,
pay_period_start_day INTEGER NOT NULL DEFAULT 0,
pay_period_length_days INTEGER NOT NULL DEFAULT 7,
daily_overtime_threshold NUMERIC(5,2) NOT NULL DEFAULT 8.0,
weekly_overtime_threshold NUMERIC(5,2) NOT NULL DEFAULT 40.0,
overtime_multiplier NUMERIC(3,2) NOT NULL DEFAULT 1.50,
overtime_notifications BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_trigger t
JOIN pg_class c ON t.tgrelid = c.oid
JOIN pg_namespace n ON c.relnamespace = n.oid
WHERE t.tgname = 'time_tracking_settings_updated_at' AND n.nspname = current_schema()
) THEN
CREATE TRIGGER time_tracking_settings_updated_at BEFORE UPDATE ON time_tracking_settings
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
END IF;
END $$;
CREATE TABLE IF NOT EXISTS time_tracking_workers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
name TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'worker'
CHECK (role IN ('worker', 'time_admin')),
lang TEXT NOT NULL DEFAULT 'en',
pin TEXT NOT NULL,
active BOOLEAN NOT NULL DEFAULT true,
last_used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS time_tracking_workers_brand_idx ON time_tracking_workers (brand_id);
CREATE TABLE IF NOT EXISTS time_tracking_tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
name TEXT NOT NULL,
name_es TEXT,
unit TEXT NOT NULL DEFAULT 'hours'
CHECK (unit IN ('hours', 'pieces', 'units')),
sort_order INTEGER NOT NULL DEFAULT 0,
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS time_tracking_tasks_brand_idx ON time_tracking_tasks (brand_id);
CREATE TABLE IF NOT EXISTS time_tracking_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
worker_id UUID NOT NULL REFERENCES time_tracking_workers(id) ON DELETE CASCADE,
task_id UUID REFERENCES time_tracking_tasks(id) ON DELETE SET NULL,
task_name TEXT NOT NULL,
clock_in TIMESTAMPTZ NOT NULL,
clock_out TIMESTAMPTZ,
lunch_break_minutes INTEGER NOT NULL DEFAULT 0,
notes TEXT,
submitted_via TEXT NOT NULL DEFAULT 'manual'
CHECK (submitted_via IN ('manual', 'field', 'import')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS time_tracking_logs_brand_idx ON time_tracking_logs (brand_id);
CREATE INDEX IF NOT EXISTS time_tracking_logs_worker_idx ON time_tracking_logs (worker_id);
CREATE INDEX IF NOT EXISTS time_tracking_logs_clock_in_idx ON time_tracking_logs (clock_in);
CREATE TABLE IF NOT EXISTS time_tracking_notification_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
worker_id UUID REFERENCES time_tracking_workers(id) ON DELETE SET NULL,
notification_type TEXT NOT NULL,
recipient TEXT NOT NULL,
subject TEXT,
body TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'sent', 'failed')),
sent_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- ============================================================================
-- 15. Operational + Audit
-- ============================================================================
CREATE TABLE IF NOT EXISTS operational_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
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()
);
CREATE INDEX IF NOT EXISTS operational_events_brand_idx ON operational_events (brand_id);
CREATE INDEX IF NOT EXISTS operational_events_type_idx ON operational_events (brand_id, event_type);
CREATE TABLE IF NOT EXISTS audit_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID,
brand_id UUID REFERENCES brands(id) ON DELETE SET NULL,
action TEXT NOT NULL,
entity_type TEXT,
entity_id UUID,
details JSONB,
ip_address TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS audit_logs_brand_idx ON audit_logs (brand_id);
CREATE INDEX IF NOT EXISTS audit_logs_user_idx ON audit_logs (user_id);
CREATE INDEX IF NOT EXISTS audit_logs_created_idx ON audit_logs (created_at DESC);
CREATE TABLE IF NOT EXISTS admin_action_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
admin_user_id UUID NOT NULL REFERENCES admin_users(id),
brand_id UUID REFERENCES brands(id) ON DELETE SET NULL,
action TEXT NOT NULL,
target_type TEXT,
target_id UUID,
metadata JSONB,
ip_address TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS admin_action_logs_admin_idx ON admin_action_logs (admin_user_id);
CREATE INDEX IF NOT EXISTS admin_action_logs_created_idx ON admin_action_logs (created_at DESC);
-- ============================================================================
-- 16. Support Tables
-- ============================================================================
CREATE TABLE IF NOT EXISTS user_carts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
customer_id UUID NOT NULL REFERENCES wholesale_customers(id) ON DELETE CASCADE,
items JSONB NOT NULL DEFAULT '[]',
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_trigger t
JOIN pg_class c ON t.tgrelid = c.oid
JOIN pg_namespace n ON c.relnamespace = n.oid
WHERE t.tgname = 'user_carts_updated_at' AND n.nspname = current_schema()
) THEN
CREATE TRIGGER user_carts_updated_at BEFORE UPDATE ON user_carts
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
END IF;
END $$;
CREATE UNIQUE INDEX IF NOT EXISTS user_carts_customer_idx ON user_carts (brand_id, customer_id);
CREATE TABLE IF NOT EXISTS referral_codes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
brand_id UUID REFERENCES brands(id) ON DELETE CASCADE,
referrer_user_id UUID NOT NULL,
referral_code VARCHAR(50) UNIQUE NOT NULL,
referrer_email VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ DEFAULT now() + INTERVAL '1 year',
max_uses INTEGER DEFAULT 1,
current_uses INTEGER DEFAULT 0,
is_active BOOLEAN DEFAULT true,
reward_type VARCHAR(50) DEFAULT 'percentage',
reward_value DECIMAL(10,2) DEFAULT 20.00,
metadata JSONB DEFAULT '{}'
);
CREATE TABLE IF NOT EXISTS referral_redemptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
referral_code_id UUID REFERENCES referral_codes(id) ON DELETE CASCADE,
brand_id UUID REFERENCES brands(id) ON DELETE CASCADE,
referred_user_id UUID NOT NULL,
referred_email VARCHAR(255) NOT NULL,
redeemed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
reward_type VARCHAR(50),
reward_value DECIMAL(10,2),
is_credit_applied BOOLEAN DEFAULT false,
signup_plan VARCHAR(50),
signup_value DECIMAL(10,2),
metadata JSONB DEFAULT '{}'
);
CREATE TABLE IF NOT EXISTS changelogs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
brand_id UUID REFERENCES brands(id) ON DELETE CASCADE,
version VARCHAR(50) NOT NULL,
title VARCHAR(255) NOT NULL,
description TEXT,
content JSONB NOT NULL DEFAULT '[]',
released_at TIMESTAMPTZ NOT NULL DEFAULT now(),
is_published BOOLEAN DEFAULT false,
feature_type VARCHAR(50) DEFAULT 'general',
metadata JSONB DEFAULT '{}',
UNIQUE (brand_id, version)
);
CREATE TABLE IF NOT EXISTS changelog_reads (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
changelog_id UUID REFERENCES changelogs(id) ON DELETE CASCADE,
read_at TIMESTAMPTZ NOT NULL DEFAULT now(),
dismissed BOOLEAN DEFAULT false,
UNIQUE (user_id, changelog_id)
);
CREATE TABLE IF NOT EXISTS onboarding_progress (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
brand_id UUID REFERENCES brands(id) ON DELETE CASCADE,
user_id UUID NOT NULL,
current_step VARCHAR(50) NOT NULL,
completed_steps JSONB DEFAULT '[]',
skipped_steps JSONB DEFAULT '[]',
data JSONB DEFAULT '{}',
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
completed_at TIMESTAMPTZ,
is_completed BOOLEAN DEFAULT false,
metadata JSONB DEFAULT '{}',
UNIQUE (brand_id, user_id)
);
CREATE TABLE IF NOT EXISTS api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
brand_id UUID REFERENCES brands(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
key_hash VARCHAR(255) NOT NULL,
key_prefix VARCHAR(20),
permissions JSONB DEFAULT '["read"]',
rate_limit INTEGER DEFAULT 1000,
last_used_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by UUID NOT NULL
);
CREATE INDEX IF NOT EXISTS api_keys_brand_idx ON api_keys (brand_id);
CREATE TABLE IF NOT EXISTS notification_preferences (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
brand_id UUID REFERENCES brands(id) ON DELETE CASCADE,
email_orders BOOLEAN DEFAULT true,
email_marketing BOOLEAN DEFAULT false,
email_reports BOOLEAN DEFAULT true,
email_billing BOOLEAN DEFAULT true,
sms_orders BOOLEAN DEFAULT false,
sms_marketing BOOLEAN DEFAULT false,
push_orders BOOLEAN DEFAULT true,
push_marketing BOOLEAN DEFAULT false,
quiet_hours_start TIME,
quiet_hours_end TIME,
timezone VARCHAR(50) DEFAULT 'America/New_York',
metadata JSONB DEFAULT '{}',
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (user_id, brand_id)
);
-- ============================================================================
-- 17. Files + Audit Log
-- ============================================================================
CREATE TABLE IF NOT EXISTS files (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
storage_key TEXT NOT NULL UNIQUE,
mime_type TEXT NOT NULL,
size_bytes INTEGER NOT NULL,
purpose TEXT,
uploaded_by UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS files_brand_idx ON files (brand_id);
CREATE TABLE IF NOT EXISTS audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
brand_id UUID REFERENCES brands(id) ON DELETE CASCADE,
user_id UUID,
action TEXT NOT NULL,
target_type TEXT,
target_id UUID,
payload JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS audit_log_brand_idx ON audit_log (brand_id, created_at);
-- ============================================================================
-- 18. GUC Helpers + RLS
-- ============================================================================
CREATE OR REPLACE FUNCTION current_brand_id() RETURNS UUID
LANGUAGE sql STABLE AS $$
SELECT NULLIF(current_setting('app.current_brand_id', true), '')::UUID;
$$;
CREATE OR REPLACE FUNCTION is_platform_admin() RETURNS BOOLEAN
LANGUAGE sql STABLE AS $$
SELECT COALESCE(NULLIF(current_setting('app.platform_admin', true), ''), 'false') = 'true';
$$;
-- Enable RLS on brand-scoped tables
ALTER TABLE products ENABLE ROW LEVEL SECURITY;
ALTER TABLE stops ENABLE ROW LEVEL SECURITY;
ALTER TABLE locations ENABLE ROW LEVEL SECURITY;
ALTER TABLE customers ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE order_items ENABLE ROW LEVEL SECURITY;
ALTER TABLE brand_settings ENABLE ROW LEVEL SECURITY;
ALTER TABLE brand_features ENABLE ROW LEVEL SECURITY;
ALTER TABLE wholesale_settings ENABLE ROW LEVEL SECURITY;
ALTER TABLE wholesale_customers ENABLE ROW LEVEL SECURITY;
ALTER TABLE wholesale_products ENABLE ROW LEVEL SECURITY;
ALTER TABLE wholesale_orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE wholesale_order_items ENABLE ROW LEVEL SECURITY;
ALTER TABLE wholesale_deposits ENABLE ROW LEVEL SECURITY;
ALTER TABLE wholesale_notifications ENABLE ROW LEVEL SECURITY;
ALTER TABLE wholesale_customer_product_pricing ENABLE ROW LEVEL SECURITY;
ALTER TABLE wholesale_webhook_settings ENABLE ROW LEVEL SECURITY;
ALTER TABLE wholesale_sync_log ENABLE ROW LEVEL SECURITY;
ALTER TABLE water_headgates ENABLE ROW LEVEL SECURITY;
ALTER TABLE water_irrigators ENABLE ROW LEVEL SECURITY;
ALTER TABLE water_sessions ENABLE ROW LEVEL SECURITY;
ALTER TABLE water_log_entries ENABLE ROW LEVEL SECURITY;
ALTER TABLE water_alert_log ENABLE ROW LEVEL SECURITY;
ALTER TABLE communication_settings ENABLE ROW LEVEL SECURITY;
ALTER TABLE communication_templates ENABLE ROW LEVEL SECURITY;
ALTER TABLE communication_segments ENABLE ROW LEVEL SECURITY;
ALTER TABLE communication_campaigns ENABLE ROW LEVEL SECURITY;
ALTER TABLE communication_contacts ENABLE ROW LEVEL SECURITY;
ALTER TABLE communication_message_logs ENABLE ROW LEVEL SECURITY;
ALTER TABLE customer_communication_preferences ENABLE ROW LEVEL SECURITY;
ALTER TABLE abandoned_cart_recovery ENABLE ROW LEVEL SECURITY;
ALTER TABLE welcome_email_sequence ENABLE ROW LEVEL SECURITY;
ALTER TABLE shipping_settings ENABLE ROW LEVEL SECURITY;
ALTER TABLE shipments ENABLE ROW LEVEL SECURITY;
ALTER TABLE payment_settings ENABLE ROW LEVEL SECURITY;
ALTER TABLE square_sync_queue ENABLE ROW LEVEL SECURITY;
ALTER TABLE square_sync_log ENABLE ROW LEVEL SECURITY;
ALTER TABLE time_tracking_settings ENABLE ROW LEVEL SECURITY;
ALTER TABLE time_tracking_workers ENABLE ROW LEVEL SECURITY;
ALTER TABLE time_tracking_tasks ENABLE ROW LEVEL SECURITY;
ALTER TABLE time_tracking_logs ENABLE ROW LEVEL SECURITY;
ALTER TABLE time_tracking_notification_log ENABLE ROW LEVEL SECURITY;
ALTER TABLE operational_events ENABLE ROW LEVEL SECURITY;
ALTER TABLE audit_logs ENABLE ROW LEVEL SECURITY;
ALTER TABLE admin_action_logs ENABLE ROW LEVEL SECURITY;
ALTER TABLE user_carts ENABLE ROW LEVEL SECURITY;
ALTER TABLE referral_codes ENABLE ROW LEVEL SECURITY;
ALTER TABLE referral_redemptions ENABLE ROW LEVEL SECURITY;
ALTER TABLE changelogs ENABLE ROW LEVEL SECURITY;
ALTER TABLE changelog_reads ENABLE ROW LEVEL SECURITY;
ALTER TABLE onboarding_progress ENABLE ROW LEVEL SECURITY;
ALTER TABLE api_keys ENABLE ROW LEVEL SECURITY;
ALTER TABLE notification_preferences ENABLE ROW LEVEL SECURITY;
ALTER TABLE files ENABLE ROW LEVEL SECURITY;
ALTER TABLE audit_log ENABLE ROW LEVEL SECURITY;
-- Force RLS (app connects as table owner in dev; belt-and-suspenders)
ALTER TABLE products FORCE ROW LEVEL SECURITY;
ALTER TABLE stops FORCE ROW LEVEL SECURITY;
ALTER TABLE locations FORCE ROW LEVEL SECURITY;
ALTER TABLE customers FORCE ROW LEVEL SECURITY;
ALTER TABLE orders FORCE ROW LEVEL SECURITY;
ALTER TABLE order_items FORCE ROW LEVEL SECURITY;
ALTER TABLE brand_settings FORCE ROW LEVEL SECURITY;
ALTER TABLE brand_features FORCE ROW LEVEL SECURITY;
ALTER TABLE wholesale_settings FORCE ROW LEVEL SECURITY;
ALTER TABLE wholesale_customers FORCE ROW LEVEL SECURITY;
ALTER TABLE wholesale_products FORCE ROW LEVEL SECURITY;
ALTER TABLE wholesale_orders FORCE ROW LEVEL SECURITY;
ALTER TABLE wholesale_order_items FORCE ROW LEVEL SECURITY;
ALTER TABLE wholesale_deposits FORCE ROW LEVEL SECURITY;
ALTER TABLE wholesale_notifications FORCE ROW LEVEL SECURITY;
ALTER TABLE wholesale_customer_product_pricing FORCE ROW LEVEL SECURITY;
ALTER TABLE wholesale_webhook_settings FORCE ROW LEVEL SECURITY;
ALTER TABLE wholesale_sync_log FORCE ROW LEVEL SECURITY;
ALTER TABLE water_headgates FORCE ROW LEVEL SECURITY;
ALTER TABLE water_irrigators FORCE ROW LEVEL SECURITY;
ALTER TABLE water_sessions FORCE ROW LEVEL SECURITY;
ALTER TABLE water_log_entries FORCE ROW LEVEL SECURITY;
ALTER TABLE water_alert_log FORCE ROW LEVEL SECURITY;
ALTER TABLE communication_settings FORCE ROW LEVEL SECURITY;
ALTER TABLE communication_templates FORCE ROW LEVEL SECURITY;
ALTER TABLE communication_segments FORCE ROW LEVEL SECURITY;
ALTER TABLE communication_campaigns FORCE ROW LEVEL SECURITY;
ALTER TABLE communication_contacts FORCE ROW LEVEL SECURITY;
ALTER TABLE communication_message_logs FORCE ROW LEVEL SECURITY;
ALTER TABLE customer_communication_preferences FORCE ROW LEVEL SECURITY;
ALTER TABLE abandoned_cart_recovery FORCE ROW LEVEL SECURITY;
ALTER TABLE welcome_email_sequence FORCE ROW LEVEL SECURITY;
ALTER TABLE shipping_settings FORCE ROW LEVEL SECURITY;
ALTER TABLE shipments FORCE ROW LEVEL SECURITY;
ALTER TABLE payment_settings FORCE ROW LEVEL SECURITY;
ALTER TABLE square_sync_queue FORCE ROW LEVEL SECURITY;
ALTER TABLE square_sync_log FORCE ROW LEVEL SECURITY;
ALTER TABLE time_tracking_settings FORCE ROW LEVEL SECURITY;
ALTER TABLE time_tracking_workers FORCE ROW LEVEL SECURITY;
ALTER TABLE time_tracking_tasks FORCE ROW LEVEL SECURITY;
ALTER TABLE time_tracking_logs FORCE ROW LEVEL SECURITY;
ALTER TABLE time_tracking_notification_log FORCE ROW LEVEL SECURITY;
ALTER TABLE operational_events FORCE ROW LEVEL SECURITY;
ALTER TABLE audit_logs FORCE ROW LEVEL SECURITY;
ALTER TABLE admin_action_logs FORCE ROW LEVEL SECURITY;
ALTER TABLE user_carts FORCE ROW LEVEL SECURITY;
ALTER TABLE referral_codes FORCE ROW LEVEL SECURITY;
ALTER TABLE referral_redemptions FORCE ROW LEVEL SECURITY;
ALTER TABLE changelogs FORCE ROW LEVEL SECURITY;
ALTER TABLE changelog_reads FORCE ROW LEVEL SECURITY;
ALTER TABLE onboarding_progress FORCE ROW LEVEL SECURITY;
ALTER TABLE api_keys FORCE ROW LEVEL SECURITY;
ALTER TABLE notification_preferences FORCE ROW LEVEL SECURITY;
ALTER TABLE files FORCE ROW LEVEL SECURITY;
ALTER TABLE audit_log FORCE ROW LEVEL SECURITY;
-- RLS policies: brand match OR platform admin
-- Macro: FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin())
-- products
DROP POLICY IF EXISTS tenant_isolation ON products;
CREATE POLICY tenant_isolation ON products FOR ALL
USING (brand_id = current_brand_id() OR is_platform_admin())
WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
-- stops
DROP POLICY IF EXISTS tenant_isolation ON stops;
CREATE POLICY tenant_isolation ON stops FOR ALL
USING (brand_id = current_brand_id() OR is_platform_admin())
WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
-- locations
DROP POLICY IF EXISTS tenant_isolation ON locations;
CREATE POLICY tenant_isolation ON locations FOR ALL
USING (brand_id = current_brand_id() OR is_platform_admin())
WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
-- customers
DROP POLICY IF EXISTS tenant_isolation ON customers;
CREATE POLICY tenant_isolation ON customers FOR ALL
USING (brand_id = current_brand_id() OR is_platform_admin())
WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
-- orders
DROP POLICY IF EXISTS tenant_isolation ON orders;
CREATE POLICY tenant_isolation ON orders FOR ALL
USING (brand_id = current_brand_id() OR is_platform_admin())
WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
-- order_items (via orders)
DROP POLICY IF EXISTS tenant_isolation ON order_items;
CREATE POLICY tenant_isolation ON order_items FOR ALL
USING (EXISTS (SELECT 1 FROM orders o WHERE o.id = order_items.order_id AND (o.brand_id = current_brand_id() OR is_platform_admin())))
WITH CHECK (EXISTS (SELECT 1 FROM orders o WHERE o.id = order_items.order_id AND (o.brand_id = current_brand_id() OR is_platform_admin())));
-- brand_settings
DROP POLICY IF EXISTS tenant_isolation ON brand_settings;
CREATE POLICY tenant_isolation ON brand_settings FOR ALL
USING (brand_id = current_brand_id() OR is_platform_admin())
WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
-- brand_features
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());
-- wholesale_* tables
DROP POLICY IF EXISTS tenant_isolation ON wholesale_settings;
CREATE POLICY tenant_isolation ON wholesale_settings FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
DROP POLICY IF EXISTS tenant_isolation ON wholesale_customers;
CREATE POLICY tenant_isolation ON wholesale_customers FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
DROP POLICY IF EXISTS tenant_isolation ON wholesale_products;
CREATE POLICY tenant_isolation ON wholesale_products FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
DROP POLICY IF EXISTS tenant_isolation ON wholesale_orders;
CREATE POLICY tenant_isolation ON wholesale_orders FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
DROP POLICY IF EXISTS tenant_isolation ON wholesale_order_items;
CREATE POLICY tenant_isolation ON wholesale_order_items FOR ALL USING (EXISTS (SELECT 1 FROM wholesale_orders wo WHERE wo.id = wholesale_order_items.wholesale_order_id AND (wo.brand_id = current_brand_id() OR is_platform_admin()))) WITH CHECK (EXISTS (SELECT 1 FROM wholesale_orders wo WHERE wo.id = wholesale_order_items.wholesale_order_id AND (wo.brand_id = current_brand_id() OR is_platform_admin())));
DROP POLICY IF EXISTS tenant_isolation ON wholesale_deposits;
CREATE POLICY tenant_isolation ON wholesale_deposits FOR ALL USING (EXISTS (SELECT 1 FROM wholesale_orders wo WHERE wo.id = wholesale_deposits.wholesale_order_id AND (wo.brand_id = current_brand_id() OR is_platform_admin()))) WITH CHECK (EXISTS (SELECT 1 FROM wholesale_orders wo WHERE wo.id = wholesale_deposits.wholesale_order_id AND (wo.brand_id = current_brand_id() OR is_platform_admin())));
DROP POLICY IF EXISTS tenant_isolation ON wholesale_notifications;
CREATE POLICY tenant_isolation ON wholesale_notifications FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
DROP POLICY IF EXISTS tenant_isolation ON wholesale_customer_product_pricing;
CREATE POLICY tenant_isolation ON wholesale_customer_product_pricing FOR ALL USING (EXISTS (SELECT 1 FROM wholesale_customers wc WHERE wc.id = wholesale_customer_product_pricing.customer_id AND (wc.brand_id = current_brand_id() OR is_platform_admin()))) WITH CHECK (EXISTS (SELECT 1 FROM wholesale_customers wc WHERE wc.id = wholesale_customer_product_pricing.customer_id AND (wc.brand_id = current_brand_id() OR is_platform_admin())));
DROP POLICY IF EXISTS tenant_isolation ON wholesale_webhook_settings;
CREATE POLICY tenant_isolation ON wholesale_webhook_settings FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
DROP POLICY IF EXISTS tenant_isolation ON wholesale_sync_log;
CREATE POLICY tenant_isolation ON wholesale_sync_log FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
-- water_log
DROP POLICY IF EXISTS tenant_isolation ON water_headgates;
CREATE POLICY tenant_isolation ON water_headgates FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
DROP POLICY IF EXISTS tenant_isolation ON water_irrigators;
CREATE POLICY tenant_isolation ON water_irrigators FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
DROP POLICY IF EXISTS tenant_isolation ON water_sessions;
CREATE POLICY tenant_isolation ON water_sessions FOR ALL USING (EXISTS (SELECT 1 FROM water_irrigators wi WHERE wi.id = water_sessions.irrigator_id AND (wi.brand_id = current_brand_id() OR is_platform_admin()))) WITH CHECK (EXISTS (SELECT 1 FROM water_irrigators wi WHERE wi.id = water_sessions.irrigator_id AND (wi.brand_id = current_brand_id() OR is_platform_admin())));
DROP POLICY IF EXISTS tenant_isolation ON water_log_entries;
CREATE POLICY tenant_isolation ON water_log_entries FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
DROP POLICY IF EXISTS tenant_isolation ON water_alert_log;
CREATE POLICY tenant_isolation ON water_alert_log FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
-- communications
DROP POLICY IF EXISTS tenant_isolation ON communication_settings;
CREATE POLICY tenant_isolation ON communication_settings FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
DROP POLICY IF EXISTS tenant_isolation ON communication_templates;
CREATE POLICY tenant_isolation ON communication_templates FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
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());
DROP POLICY IF EXISTS tenant_isolation ON communication_campaigns;
CREATE POLICY tenant_isolation ON communication_campaigns FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
DROP POLICY IF EXISTS tenant_isolation ON communication_contacts;
CREATE POLICY tenant_isolation ON communication_contacts FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
DROP POLICY IF EXISTS tenant_isolation ON communication_message_logs;
CREATE POLICY tenant_isolation ON communication_message_logs FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
DROP POLICY IF EXISTS tenant_isolation ON customer_communication_preferences;
CREATE POLICY tenant_isolation ON customer_communication_preferences FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
DROP POLICY IF EXISTS tenant_isolation ON abandoned_cart_recovery;
CREATE POLICY tenant_isolation ON abandoned_cart_recovery FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
DROP POLICY IF EXISTS tenant_isolation ON welcome_email_sequence;
CREATE POLICY tenant_isolation ON welcome_email_sequence FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
-- shipping
DROP POLICY IF EXISTS tenant_isolation ON shipping_settings;
CREATE POLICY tenant_isolation ON shipping_settings FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
DROP POLICY IF EXISTS tenant_isolation ON shipments;
CREATE POLICY tenant_isolation ON shipments FOR ALL USING (EXISTS (SELECT 1 FROM orders o WHERE o.id = shipments.order_id AND (o.brand_id = current_brand_id() OR is_platform_admin()))) WITH CHECK (EXISTS (SELECT 1 FROM orders o WHERE o.id = shipments.order_id AND (o.brand_id = current_brand_id() OR is_platform_admin())));
DROP POLICY IF EXISTS tenant_isolation ON payment_settings;
CREATE POLICY tenant_isolation ON payment_settings FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
-- square
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());
DROP POLICY IF EXISTS tenant_isolation ON square_sync_log;
CREATE POLICY tenant_isolation ON square_sync_log FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
-- time tracking
DROP POLICY IF EXISTS tenant_isolation ON time_tracking_settings;
CREATE POLICY tenant_isolation ON time_tracking_settings FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
DROP POLICY IF EXISTS tenant_isolation ON time_tracking_workers;
CREATE POLICY tenant_isolation ON time_tracking_workers FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
DROP POLICY IF EXISTS tenant_isolation ON time_tracking_tasks;
CREATE POLICY tenant_isolation ON time_tracking_tasks FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
DROP POLICY IF EXISTS tenant_isolation ON time_tracking_logs;
CREATE POLICY tenant_isolation ON time_tracking_logs FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
DROP POLICY IF EXISTS tenant_isolation ON time_tracking_notification_log;
CREATE POLICY tenant_isolation ON time_tracking_notification_log FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
-- audit
DROP POLICY IF EXISTS tenant_isolation ON operational_events;
CREATE POLICY tenant_isolation ON operational_events FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
DROP POLICY IF EXISTS tenant_isolation ON audit_logs;
CREATE POLICY tenant_isolation ON audit_logs FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin() OR brand_id IS NULL) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin() OR brand_id IS NULL);
DROP POLICY IF EXISTS tenant_isolation ON admin_action_logs;
CREATE POLICY tenant_isolation ON admin_action_logs FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
-- support
DROP POLICY IF EXISTS tenant_isolation ON user_carts;
CREATE POLICY tenant_isolation ON user_carts FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
DROP POLICY IF EXISTS tenant_isolation ON referral_codes;
CREATE POLICY tenant_isolation ON referral_codes FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin() OR brand_id IS NULL) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin() OR brand_id IS NULL);
DROP POLICY IF EXISTS tenant_isolation ON referral_redemptions;
CREATE POLICY tenant_isolation ON referral_redemptions FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin() OR brand_id IS NULL) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin() OR brand_id IS NULL);
DROP POLICY IF EXISTS tenant_isolation ON changelogs;
CREATE POLICY tenant_isolation ON changelogs FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin() OR brand_id IS NULL) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin() OR brand_id IS NULL);
DROP POLICY IF EXISTS tenant_isolation ON changelog_reads;
CREATE POLICY tenant_isolation ON changelog_reads FOR ALL USING (user_id = auth.uid() OR is_platform_admin()) WITH CHECK (user_id = auth.uid() OR is_platform_admin());
DROP POLICY IF EXISTS tenant_isolation ON onboarding_progress;
CREATE POLICY tenant_isolation ON onboarding_progress FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin() OR brand_id IS NULL) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin() OR brand_id IS NULL);
DROP POLICY IF EXISTS tenant_isolation ON api_keys;
CREATE POLICY tenant_isolation ON api_keys FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin() OR brand_id IS NULL) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin() OR brand_id IS NULL);
DROP POLICY IF EXISTS tenant_isolation ON notification_preferences;
CREATE POLICY tenant_isolation ON notification_preferences FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin() OR brand_id IS NULL) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin() OR brand_id IS NULL);
DROP POLICY IF EXISTS tenant_isolation ON files;
CREATE POLICY tenant_isolation ON files FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin() OR brand_id IS NULL) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin() OR brand_id IS NULL);
DROP POLICY IF EXISTS tenant_isolation ON audit_log;
CREATE POLICY tenant_isolation ON audit_log FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin() OR brand_id IS NULL) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin() OR brand_id IS NULL);
-- ============================================================================
-- 19. Key RPC Functions
-- SECURITY DEFINER so they always run with table-owner privileges.
-- ============================================================================
-- set_brand_feature: enable/disable a feature flag
CREATE OR REPLACE FUNCTION set_brand_feature(p_brand_id UUID, p_feature_key TEXT, p_enabled BOOLEAN)
RETURNS VOID LANGUAGE plpgsql SECURITY DEFINER AS $$
BEGIN
INSERT INTO brand_features (brand_id, feature_key, enabled, enabled_at, disabled_at)
VALUES (p_brand_id, p_feature_key, p_enabled,
CASE WHEN p_enabled THEN now() ELSE NULL END,
CASE WHEN NOT 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 THEN now() ELSE brand_features.enabled_at END,
disabled_at = CASE WHEN NOT p_enabled THEN now() ELSE brand_features.disabled_at END;
END;
$$;
-- set_brand_subscription: save Stripe subscription info
CREATE OR REPLACE FUNCTION set_brand_subscription(
p_brand_id UUID,
p_stripe_subscription_id TEXT,
p_status TEXT,
p_current_period_end TIMESTAMPTZ
) RETURNS VOID LANGUAGE plpgsql SECURITY DEFINER AS $$
BEGIN
UPDATE brands SET
stripe_subscription_id = p_stripe_subscription_id,
stripe_subscription_status = p_status,
stripe_current_period_end = p_current_period_end,
updated_at = now()
WHERE id = p_brand_id;
END;
$$;
-- update_brand_plan_tier: update plan tier + limits
CREATE OR REPLACE FUNCTION update_brand_plan_tier(
p_brand_id UUID,
p_plan_tier TEXT,
p_max_users INTEGER,
p_max_products INTEGER,
p_max_stops_monthly INTEGER
) RETURNS VOID LANGUAGE plpgsql SECURITY DEFINER AS $$
BEGIN
UPDATE brands SET
plan_tier = p_plan_tier,
max_users = p_max_users,
max_products = p_max_products,
max_stops_monthly = p_max_stops_monthly,
updated_at = now()
WHERE id = p_brand_id;
END;
$$;
-- enqueue_notification: add a notification to the queue
CREATE OR REPLACE FUNCTION enqueue_notification(
p_brand_id UUID,
p_customer_id UUID,
p_notification_type TEXT,
p_channel TEXT,
p_recipient TEXT,
p_subject TEXT,
p_body TEXT
) RETURNS UUID LANGUAGE plpgsql SECURITY DEFINER AS $$
DECLARE
v_id UUID;
BEGIN
INSERT INTO wholesale_notifications
(brand_id, customer_id, notification_type, channel, recipient, subject, body)
VALUES
(p_brand_id, p_customer_id, p_notification_type, p_channel, p_recipient, p_subject, p_body)
RETURNING id INTO v_id;
RETURN v_id;
END;
$$;
-- upsert_admin_user_for_neon_auth: provision admin_users from Neon Auth user
CREATE OR REPLACE FUNCTION upsert_admin_user_for_neon_auth(p_user_id UUID)
RETURNS VOID LANGUAGE plpgsql SECURITY DEFINER AS $$
DECLARE
v_email TEXT;
v_name TEXT;
BEGIN
SELECT u.email, u.name INTO v_email, v_name
FROM neon_auth.user u WHERE u.id = p_user_id;
IF v_email IS NULL THEN RETURN; END IF;
INSERT INTO admin_users (user_id, email, name, role,
can_manage_orders, can_manage_products, can_manage_stops,
can_manage_customers, can_manage_wholesale, can_manage_billing,
can_manage_settings, can_manage_water_log, can_manage_time_tracking,
can_manage_route_trace, can_manage_reports, can_manage_communications)
VALUES (p_user_id, v_email, v_name, 'platform_admin',
true, true, true, true, true, true, true, true, true, true, true, true)
ON CONFLICT (user_id) DO NOTHING;
END;
$$;
-- get_brand_plan_info: compute current usage vs plan limits
CREATE OR REPLACE FUNCTION get_brand_plan_info(p_brand_id UUID)
RETURNS TABLE (
plan_tier TEXT,
max_users INTEGER,
max_products INTEGER,
max_stops_monthly INTEGER,
current_users INTEGER,
current_products INTEGER,
current_stops_this_month INTEGER
) LANGUAGE plpgsql SECURITY DEFINER AS $$
BEGIN
RETURN QUERY
SELECT
b.plan_tier,
b.max_users,
b.max_products,
b.max_stops_monthly,
COALESCE((SELECT COUNT(*) FROM admin_user_brands aub WHERE aub.brand_id = p_brand_id), 0)::INTEGER,
COALESCE((SELECT COUNT(*) FROM products p WHERE p.brand_id = p_brand_id AND p.active = true), 0)::INTEGER,
COALESCE((SELECT COUNT(*) FROM stops s WHERE s.brand_id = p_brand_id AND s.date >= date_trunc('month', now())), 0)::INTEGER
FROM brands b WHERE b.id = p_brand_id;
END;
$$;
-- get_admin_users: list admin users for a brand
CREATE OR REPLACE FUNCTION get_admin_users(p_brand_id UUID DEFAULT NULL)
RETURNS TABLE (
id UUID,
user_id UUID,
email TEXT,
name TEXT,
role TEXT,
can_manage_orders BOOLEAN,
can_manage_products BOOLEAN,
can_manage_stops BOOLEAN,
can_manage_customers BOOLEAN,
can_manage_wholesale BOOLEAN,
can_manage_billing BOOLEAN,
can_manage_settings BOOLEAN,
can_manage_water_log BOOLEAN,
can_manage_time_tracking BOOLEAN,
can_manage_route_trace BOOLEAN,
can_manage_reports BOOLEAN,
can_manage_communications BOOLEAN,
created_at TIMESTAMPTZ
) LANGUAGE plpgsql SECURITY DEFINER AS $$
BEGIN
IF p_brand_id IS NULL THEN
RETURN QUERY SELECT * FROM admin_users au ORDER BY au.created_at;
ELSE
RETURN QUERY
SELECT au.* FROM admin_users au
JOIN admin_user_brands aub ON aub.admin_user_id = au.id
WHERE aub.brand_id = p_brand_id
ORDER BY au.created_at;
END IF;
END;
$$;
-- create_order_with_items: transactional order creation
CREATE OR REPLACE FUNCTION create_order_with_items(
p_brand_id UUID,
p_customer_id UUID,
p_stop_id UUID,
p_fulfillment TEXT,
p_items JSONB,
p_customer_address TEXT DEFAULT NULL,
p_customer_city TEXT DEFAULT NULL,
p_customer_state TEXT DEFAULT NULL,
p_customer_zip TEXT DEFAULT NULL,
p_notes TEXT DEFAULT NULL
) RETURNS UUID LANGUAGE plpgsql SECURITY DEFINER AS $$
DECLARE
v_order_id UUID;
v_item JSONB;
v_total INTEGER := 0;
BEGIN
INSERT INTO orders (brand_id, customer_id, stop_id, fulfillment, customer_address,
customer_city, customer_state, customer_zip, notes)
VALUES (p_brand_id, p_customer_id, p_stop_id, p_fulfillment, p_customer_address,
p_customer_city, p_customer_state, p_customer_zip, p_notes)
RETURNING id INTO v_order_id;
FOR v_item IN SELECT * FROM jsonb_array_elements(p_items)
LOOP
INSERT INTO order_items (order_id, product_id, quantity, price_cents, fulfillment)
VALUES (
v_order_id,
(v_item->>'product_id')::UUID,
(v_item->>'quantity')::INTEGER,
(v_item->>'price_cents')::INTEGER,
v_item->>'fulfillment'
);
v_total := v_total + ((v_item->>'quantity')::INTEGER * (v_item->>'price_cents')::INTEGER);
END LOOP;
UPDATE orders SET total_cents = v_total WHERE id = v_order_id;
RETURN v_order_id;
END;
$$;
-- get_public_stops_for_brand: public stops for a brand storefront
CREATE OR REPLACE FUNCTION get_public_stops_for_brand(p_brand_slug TEXT)
RETURNS TABLE (
id UUID,
name TEXT,
location TEXT,
address TEXT,
city TEXT,
state TEXT,
zip TEXT,
date DATE,
"time" TEXT,
cutoff_date DATE,
status TEXT,
notes TEXT
) LANGUAGE plpgsql SECURITY DEFINER AS $$
BEGIN
RETURN QUERY
SELECT s.id, s.name, s.location, s.address, s.city, s.state, s.zip,
s.date, s."time", s.cutoff_date, s.status, s.notes
FROM stops s
JOIN brands b ON b.id = s.brand_id
WHERE b.slug = p_brand_slug
AND s.is_public = true
AND s.status = 'active'
AND s.date >= CURRENT_DATE
ORDER BY s.date ASC, s."time" ASC;
END;
$$;
-- create_admin_user: create a new admin user
CREATE OR REPLACE FUNCTION create_admin_user(
p_user_id UUID,
p_email TEXT,
p_name TEXT,
p_role TEXT DEFAULT 'brand_admin'
) RETURNS UUID LANGUAGE plpgsql SECURITY DEFINER AS $$
DECLARE
v_id UUID;
BEGIN
INSERT INTO admin_users (user_id, email, name, role)
VALUES (p_user_id, p_email, p_name, p_role)
RETURNING id INTO v_id;
RETURN v_id;
END;
$$;
-- delete_admin_user: remove an admin user
CREATE OR REPLACE FUNCTION delete_admin_user(p_id UUID)
RETURNS VOID LANGUAGE plpgsql SECURITY DEFINER AS $$
BEGIN
DELETE FROM admin_users WHERE id = p_id;
END;
$$;
-- log_audit: insert an audit log entry
CREATE OR REPLACE FUNCTION log_audit(
p_user_id UUID,
p_brand_id UUID,
p_action TEXT,
p_entity_type TEXT DEFAULT NULL,
p_entity_id UUID DEFAULT NULL,
p_details JSONB DEFAULT NULL,
p_ip_address TEXT DEFAULT NULL
) RETURNS VOID LANGUAGE plpgsql SECURITY DEFINER AS $$
BEGIN
INSERT INTO audit_logs (user_id, brand_id, action, entity_type, entity_id, details, ip_address)
VALUES (p_user_id, p_brand_id, p_action, p_entity_type, p_entity_id, p_details, p_ip_address);
END;
$$;
-- COMMIT; (transaction controlled by migrate runner)