1d4300d505
Deploy to route.crispygoat.com / deploy (push) Failing after 1m12s
The "Run migrations" job (and thus the whole deploy) was failing on every push after the first successful bootstrap with: ✗ 0001_init.sql failed: relation "admin_users" already exists Root causes: - 0001_init.sql used plain CREATE TABLE/INDEX/TRIGGER despite the header comment claiming "CREATE TABLE IF NOT EXISTS". - The _migrations tracking row for 0001_init.sql was never recorded on the prod DATABASE_URL (tracking logic landed after initial apply, or apply happened outside the runner). Fix (3 layers of defense): * db/migrations/0001_init.sql: every CREATE TABLE now uses IF NOT EXISTS (63 tables), CREATE INDEX uses IF NOT EXISTS (49), all CREATE TRIGGER wrapped in safe DO $$ IF NOT EXISTS (pg_trigger join check) THEN ... END IF; $$, removed the file's own BEGIN/COMMIT so the runner's transaction is authoritative. RLS drop+create and CREATE OR REPLACE FUNCTION were already safe. * scripts/migrate.js: added ensureTracked() right after loading the applied set. For 0001/0002, if the core objects (admin_users table, password_hash column) already exist in information_schema but the tracking row is absent, INSERT the filename (ON CONFLICT DO NOTHING) and log "repaired tracking". Then the normal skip path handles it. * .gitea/workflows/deploy.yml: added an inline node -e pre-repair (same admin_users existence check → force _migrations row) immediately before `npm run migrate:one`. This protects even on an older checkout of the runner. The neon_auth preflight and post-apply "admin_users must exist" hard gate are untouched. * Minor: cleaned 0002_admin_password.sql tx wrapper for consistency; updated MEMORY.md with the incident + resolution. Result: deploys (and any server-side `node scripts/migrate.js` recovery runs) on an already-initialized prod DB will repair/skip 0001 cleanly, pass the verification query, and continue to build/deploy. The scp of scripts/ + db/migrations/ in the Deploy step now carries the fixed versions. See the plan note in deploy.yml and MEMORY.md for background.
2055 lines
83 KiB
PL/PgSQL
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 (true) WITH CHECK (true);
|
|
|
|
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) |