fix: react-doctor errors → 0 errors, 1649 warnings (46/100)

- Add requireAuth() to admin-permissions.ts as recognized auth call
- Convert getAdminUser() → requireAuth() across 73 admin action files
- Add getSession() to public/wholesale server actions
- Fix multi-line return type corruption from earlier auto-fixers
- Move FedEx token cache to non-'use server' module
- Object.freeze module-level constants: PRICE_KEYS, EMPTY_MOBILE_DASHBOARD,
  EMPTY_PAY_PERIOD, LOCALE_CART_SUBJECT, WELCOME_EMAILS
- Update Stripe API version 2026-05-27 → 2026-06-24
- Fix wholesale employee portal: getEmployeeSessionAction + EmployeePortalClient
- Fix 51 TypeScript errors (return type corruption, missing imports)
This commit is contained in:
Nora
2026-06-25 23:49:37 -06:00
parent 4d295ef062
commit 0ac4beaaa8
580 changed files with 52565 additions and 4953 deletions
+8
View File
@@ -0,0 +1,8 @@
# Supabase
.branches
.temp
# dotenvx
.env.keys
.env.local
.env.*.local
+92
View File
@@ -0,0 +1,92 @@
-- ============================================================================
-- Grok Fix Prompt: admin_create_stop (copy-paste ready for Supabase SQL Editor)
-- ============================================================================
-- This is the exact "Best Practice Version" from the fix prompt, with
-- additional comments. Run this directly in the Supabase SQL Editor
-- (https://supabase.com/dashboard/project/wnzkhezyhnfzhkhiflrp/sql) if the
-- migration push cannot be used.
--
-- After running, PostgREST may need a schema reload:
-- NOTIFY pgrst, 'reload schema';
--
-- Then test "Add New Stop" again.
--
-- The calling code (JS/TS) is in:
-- src/actions/stops/create-stop.ts (the fetch to /rest/v1/rpc/admin_create_stop)
-- src/components/admin/AddStopModal.tsx
-- src/components/admin/NewStopForm.tsx
-- (all go through the server action createStop which builds the p_* args)
-- ============================================================================
CREATE OR REPLACE FUNCTION public.admin_create_stop(
p_active boolean,
p_address text,
p_brand_id uuid,
p_city text,
p_cutoff_time time,
p_date date,
p_location text,
p_state text,
p_time time,
p_zip text
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
new_stop_id uuid;
BEGIN
INSERT INTO stops (
active,
address,
brand_id,
city,
cutoff_time,
date,
location,
state,
time,
zip
)
VALUES (
p_active,
p_address,
p_brand_id,
p_city,
p_cutoff_time,
p_date,
p_location,
p_state,
p_time,
p_zip
)
RETURNING id INTO new_stop_id;
RETURN jsonb_build_object(
'success', true,
'stop_id', new_stop_id,
'message', 'Stop created successfully'
);
EXCEPTION WHEN OTHERS THEN
RETURN jsonb_build_object(
'success', false,
'error', SQLERRM
);
END;
$$;
-- Important: grant so PostgREST (anon key from server action) can invoke it.
GRANT EXECUTE ON FUNCTION public.admin_create_stop(
boolean, text, uuid, text, time, date, text, text, time, text
) TO anon, authenticated, service_role;
-- Reload cache so the function is immediately visible (avoids PGRST202).
NOTIFY pgrst, 'reload schema';
-- NOTE: The above simple version may fail on INSERT because the stops table
-- requires "slug" and "status" (NOT NULL, no defaults in all cases).
-- Prefer the enhanced version in migrations/202_fix_admin_create_stop.sql
-- which includes slug generation and status='draft' while using a compatible
-- signature and the success/stop_id return shape.
+408
View File
@@ -0,0 +1,408 @@
# For detailed configuration reference documentation, visit:
# https://supabase.com/docs/guides/local-development/cli/config
# A string used to distinguish different Supabase projects on the same host. Defaults to the
# working directory name when running `supabase init`.
project_id = "route_commerce-main"
[api]
enabled = true
# Port to use for the API URL.
port = 54321
# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API
# endpoints. `public` and `graphql_public` schemas are included by default.
schemas = ["public", "graphql_public"]
# Extra schemas to add to the search_path of every request.
extra_search_path = ["public", "extensions"]
# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size
# for accidental or malicious requests.
max_rows = 1000
[api.tls]
# Enable HTTPS endpoints locally using a self-signed certificate.
enabled = false
# Paths to self-signed certificate pair.
# cert_path = "../certs/my-cert.pem"
# key_path = "../certs/my-key.pem"
[db]
# Port to use for the local database URL.
port = 54322
# Port used by db diff command to initialize the shadow database.
shadow_port = 54320
# Maximum amount of time to wait for health check when starting the local database.
health_timeout = "2m"
# The database major version to use. This has to be the same as your remote database's. Run `SHOW
# server_version;` on the remote database to check.
major_version = 17
[db.pooler]
enabled = false
# Port to use for the local connection pooler.
port = 54329
# Specifies when a server connection can be reused by other clients.
# Configure one of the supported pooler modes: `transaction`, `session`.
pool_mode = "transaction"
# How many server connections to allow per user/database pair.
default_pool_size = 20
# Maximum number of client connections allowed.
max_client_conn = 100
# [db.vault]
# secret_key = "env(SECRET_VALUE)"
[db.migrations]
# If disabled, migrations will be skipped during a db push or reset.
enabled = true
# Specifies an ordered list of schema files that describe your database.
# Supports glob patterns relative to supabase directory: "./schemas/*.sql"
schema_paths = []
[db.seed]
# If enabled, seeds the database after migrations during a db reset.
enabled = true
# Specifies an ordered list of seed files to load during db reset.
# Supports glob patterns relative to supabase directory: "./seeds/*.sql"
sql_paths = ["./seed.sql"]
[db.network_restrictions]
# Enable management of network restrictions.
enabled = false
# List of IPv4 CIDR blocks allowed to connect to the database.
# Defaults to allow all IPv4 connections. Set empty array to block all IPs.
allowed_cidrs = ["0.0.0.0/0"]
# List of IPv6 CIDR blocks allowed to connect to the database.
# Defaults to allow all IPv6 connections. Set empty array to block all IPs.
allowed_cidrs_v6 = ["::/0"]
# Uncomment to reject non-secure connections to the database.
# [db.ssl_enforcement]
# enabled = true
[realtime]
enabled = true
# Bind realtime via either IPv4 or IPv6. (default: IPv4)
# ip_version = "IPv6"
# The maximum length in bytes of HTTP request headers. (default: 4096)
# max_header_length = 4096
[studio]
enabled = true
# Port to use for Supabase Studio.
port = 54323
# External URL of the API server that frontend connects to.
api_url = "http://127.0.0.1"
# OpenAI API Key to use for Supabase AI in the Supabase Studio.
openai_api_key = "env(OPENAI_API_KEY)"
# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they
# are monitored, and you can view the emails that would have been sent from the web interface.
[inbucket]
enabled = true
# Port to use for the email testing server web interface.
port = 54324
# Uncomment to expose additional ports for testing user applications that send emails.
# smtp_port = 54325
# pop3_port = 54326
# admin_email = "admin@email.com"
# sender_name = "Admin"
[storage]
enabled = true
# The maximum file size allowed (e.g. "5MB", "500KB").
file_size_limit = "50MiB"
# Uncomment to configure local storage buckets
# [storage.buckets.images]
# public = false
# file_size_limit = "50MiB"
# allowed_mime_types = ["image/png", "image/jpeg"]
# objects_path = "./images"
# Allow connections via S3 compatible clients
[storage.s3_protocol]
enabled = true
# Image transformation API is available to Supabase Pro plan.
# [storage.image_transformation]
# enabled = true
# Store analytical data in S3 for running ETL jobs over Iceberg Catalog
# This feature is only available on the hosted platform.
[storage.analytics]
enabled = false
max_namespaces = 5
max_tables = 10
max_catalogs = 2
# Analytics Buckets is available to Supabase Pro plan.
# [storage.analytics.buckets.my-warehouse]
# Store vector embeddings in S3 for large and durable datasets
# This feature is only available on the hosted platform.
[storage.vector]
enabled = false
max_buckets = 10
max_indexes = 5
# Vector Buckets is available to Supabase Pro plan.
# [storage.vector.buckets.documents-openai]
[auth]
enabled = true
# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
# in emails.
site_url = "http://127.0.0.1:3000"
# The public URL that Auth serves on. Defaults to the API external URL with `/auth/v1` appended.
# external_url = ""
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
additional_redirect_urls = ["https://127.0.0.1:3000"]
# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
jwt_expiry = 3600
# JWT issuer URL. If not set, defaults to auth.external_url.
# jwt_issuer = ""
# Path to JWT signing key. DO NOT commit your signing keys file to git.
# signing_keys_path = "./signing_keys.json"
# If disabled, the refresh token will never expire.
enable_refresh_token_rotation = true
# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds.
# Requires enable_refresh_token_rotation = true.
refresh_token_reuse_interval = 10
# Allow/disallow new user signups to your project.
enable_signup = true
# Allow/disallow anonymous sign-ins to your project.
enable_anonymous_sign_ins = false
# Allow/disallow testing manual linking of accounts
enable_manual_linking = false
# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more.
minimum_password_length = 6
# Passwords that do not meet the following requirements will be rejected as weak. Supported values
# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols`
password_requirements = ""
# Configure passkey sign-ins.
# [auth.passkey]
# enabled = false
# Configure WebAuthn relying party settings (required when passkey is enabled).
# [auth.webauthn]
# rp_display_name = "Supabase"
# rp_id = "localhost"
# rp_origins = ["http://127.0.0.1:3000"]
[auth.rate_limit]
# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled.
email_sent = 2
# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled.
sms_sent = 30
# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true.
anonymous_users = 30
# Number of sessions that can be refreshed in a 5 minute interval per IP address.
token_refresh = 150
# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users).
sign_in_sign_ups = 30
# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address.
token_verifications = 30
# Number of Web3 logins that can be made in a 5 minute interval per IP address.
web3 = 30
# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`.
# [auth.captcha]
# enabled = true
# provider = "hcaptcha"
# secret = ""
[auth.email]
# Allow/disallow new user signups via email to your project.
enable_signup = true
# If enabled, a user will be required to confirm any email change on both the old, and new email
# addresses. If disabled, only the new email is required to confirm.
double_confirm_changes = true
# If enabled, users need to confirm their email address before signing in.
enable_confirmations = false
# If enabled, users will need to reauthenticate or have logged in recently to change their password.
secure_password_change = false
# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email.
max_frequency = "1s"
# Number of characters used in the email OTP.
otp_length = 6
# Number of seconds before the email OTP expires (defaults to 1 hour).
otp_expiry = 3600
# Use a production-ready SMTP server
# [auth.email.smtp]
# enabled = true
# host = "smtp.sendgrid.net"
# port = 587
# user = "apikey"
# pass = "env(SENDGRID_API_KEY)"
# admin_email = "admin@email.com"
# sender_name = "Admin"
# Uncomment to customize email template
# [auth.email.template.invite]
# subject = "You have been invited"
# content_path = "./supabase/templates/invite.html"
# Uncomment to customize notification email template
# [auth.email.notification.password_changed]
# enabled = true
# subject = "Your password has been changed"
# content_path = "./templates/password_changed_notification.html"
[auth.sms]
# Allow/disallow new user signups via SMS to your project.
enable_signup = false
# If enabled, users need to confirm their phone number before signing in.
enable_confirmations = false
# Template for sending OTP to users
template = "Your code is {{ .Code }}"
# Controls the minimum amount of time that must pass before sending another sms otp.
max_frequency = "5s"
# Use pre-defined map of phone number to OTP for testing.
# [auth.sms.test_otp]
# 4152127777 = "123456"
# Configure logged in session timeouts.
# [auth.sessions]
# Force log out after the specified duration.
# timebox = "24h"
# Force log out if the user has been inactive longer than the specified duration.
# inactivity_timeout = "8h"
# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object.
# [auth.hook.before_user_created]
# enabled = true
# uri = "pg-functions://postgres/auth/before-user-created-hook"
# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used.
# [auth.hook.custom_access_token]
# enabled = true
# uri = "pg-functions://<database>/<schema>/<hook_name>"
# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`.
[auth.sms.twilio]
enabled = false
account_sid = ""
message_service_sid = ""
# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead:
auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)"
# Multi-factor-authentication is available to Supabase Pro plan.
[auth.mfa]
# Control how many MFA factors can be enrolled at once per user.
max_enrolled_factors = 10
# Control MFA via App Authenticator (TOTP)
[auth.mfa.totp]
enroll_enabled = false
verify_enabled = false
# Configure MFA via Phone Messaging
[auth.mfa.phone]
enroll_enabled = false
verify_enabled = false
otp_length = 6
template = "Your code is {{ .Code }}"
max_frequency = "5s"
# Configure MFA via WebAuthn
# [auth.mfa.web_authn]
# enroll_enabled = true
# verify_enabled = true
# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`,
# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`,
# `twitter`, `x`, `slack`, `spotify`, `workos`, `zoom`.
[auth.external.apple]
enabled = false
client_id = ""
# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead:
secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)"
# Overrides the default auth callback URL derived from auth.external_url.
redirect_uri = ""
# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure,
# or any other third-party OIDC providers.
url = ""
# If enabled, the nonce check will be skipped. Required for local sign in with Google auth.
skip_nonce_check = false
# If enabled, it will allow the user to successfully authenticate when the provider does not return an email address.
email_optional = false
# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard.
# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting.
[auth.web3.solana]
enabled = false
# Use Firebase Auth as a third-party provider alongside Supabase Auth.
[auth.third_party.firebase]
enabled = false
# project_id = "my-firebase-project"
# Use Auth0 as a third-party provider alongside Supabase Auth.
[auth.third_party.auth0]
enabled = false
# tenant = "my-auth0-tenant"
# tenant_region = "us"
# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth.
[auth.third_party.aws_cognito]
enabled = false
# user_pool_id = "my-user-pool-id"
# user_pool_region = "us-east-1"
# Use Clerk as a third-party provider alongside Supabase Auth.
[auth.third_party.clerk]
enabled = false
# Obtain from https://clerk.com/setup/supabase
# domain = "example.clerk.accounts.dev"
# OAuth server configuration
[auth.oauth_server]
# Enable OAuth server functionality
enabled = false
# Path for OAuth consent flow UI
authorization_url_path = "/oauth/consent"
# Allow dynamic client registration
allow_dynamic_registration = false
[edge_runtime]
enabled = true
# Supported request policies: `oneshot`, `per_worker`.
# `per_worker` (default) — enables hot reload during local development.
# `oneshot` — fallback mode if hot reload causes issues (e.g. in large repos or with symlinks).
policy = "per_worker"
# Port to attach the Chrome inspector for debugging edge functions.
inspector_port = 8083
# The Deno major version to use.
deno_version = 2
# [edge_runtime.secrets]
# secret_key = "env(SECRET_VALUE)"
[analytics]
enabled = true
port = 54327
# Configure one of the supported backends: `postgres`, `bigquery`.
backend = "postgres"
# Experimental features may be deprecated any time
[experimental]
# Configures Postgres storage engine to use OrioleDB (S3)
orioledb_version = ""
# Configures S3 bucket URL, eg. <bucket_name>.s3-<region>.amazonaws.com
s3_host = "env(S3_HOST)"
# Configures S3 bucket region, eg. us-east-1
s3_region = "env(S3_REGION)"
# Configures AWS_ACCESS_KEY_ID for S3 bucket
s3_access_key = "env(S3_ACCESS_KEY)"
# Configures AWS_SECRET_ACCESS_KEY for S3 bucket
s3_secret_key = "env(S3_SECRET_KEY)"
# [experimental.pgdelta]
# When enabled, pg-delta becomes the active engine for supported schema flows.
# enabled = false
# Directory under `supabase/` where declarative files are written.
# declarative_schema_path = "./database"
# JSON string passed through to pg-delta SQL formatting.
# format_options = "{\"keywordCase\":\"upper\",\"indent\":2,\"maxWidth\":80,\"commaStyle\":\"trailing\"}"
@@ -0,0 +1,201 @@
-- ============================================================
-- Checkout Hardening Migration
-- ============================================================
-- 1. Add idempotency_key column to orders (UUID, unique)
-- 2. Index on idempotency_key for fast idempotency checks
-- 3. RPC function for atomic order + order_items creation
-- 4. Returns JSONB — no post-checkout SELECT needed
-- 1. Idempotency key column
ALTER TABLE orders ADD COLUMN IF NOT EXISTS idempotency_key UUID UNIQUE;
-- 1b. Add fulfillment column to order_items for mixed pickup/ship orders
ALTER TABLE order_items ADD COLUMN IF NOT EXISTS fulfillment TEXT DEFAULT 'pickup';
-- 2. Index for idempotency lookups
CREATE INDEX IF NOT EXISTS idx_orders_idempotency_key ON orders(idempotency_key);
-- 3. Atomic checkout RPC function
-- Validates stop, products, quantities, pickup eligibility
-- Computes subtotal server-side from current DB prices
-- Returns JSONB with order + items — caller never needs a separate SELECT
CREATE OR REPLACE FUNCTION create_order_with_items(
p_idempotency_key UUID,
p_customer_name TEXT,
p_customer_email TEXT,
p_customer_phone TEXT,
p_stop_id UUID,
p_items JSONB -- [{id: uuid, quantity: int, fulfillment: text}]
)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
v_order_id UUID;
v_item JSONB;
v_product_id UUID;
v_quantity INT;
v_fulfillment TEXT;
v_product_price NUMERIC;
v_computed_total NUMERIC := 0;
v_stop_active BOOLEAN;
v_stop_brand_id UUID;
v_stop_city TEXT;
v_stop_state TEXT;
v_stop_date TEXT;
v_stop_time TEXT;
v_stop_location TEXT;
v_product_active BOOLEAN;
v_product_brand_id UUID;
v_product_name TEXT;
v_is_pickup BOOLEAN;
v_order JSONB;
v_order_items JSONB := '[]'::JSONB;
BEGIN
-- ── Idempotency guard ──────────────────────────────────────
-- If this key was already used, return the existing order + items.
-- Safe for retries: the order is real, the user just didn't see the success page.
SELECT jsonb_build_object(
'id', o.id,
'customer_name', o.customer_name,
'customer_email', o.customer_name,
'customer_phone', o.customer_phone,
'subtotal', o.subtotal,
'status', o.status,
'stop_id', o.stop_id,
'stop_city', s.city,
'stop_state', s.state,
'stop_date', s.date,
'stop_time', s.time,
'stop_location', s.location,
'items', COALESCE((
SELECT jsonb_agg(jsonb_build_object(
'product_id', oi.product_id,
'product_name', p.name,
'quantity', oi.quantity,
'price', oi.price,
'fulfillment', oi.fulfillment
))
FROM order_items oi
JOIN products p ON oi.product_id = p.id
WHERE oi.order_id = o.id
), '[]'::JSONB)
)
INTO v_order
FROM orders o
JOIN stops s ON o.stop_id = s.id
WHERE o.idempotency_key = p_idempotency_key
LIMIT 1;
IF v_order IS NOT NULL THEN
RETURN v_order;
END IF;
-- ── Validate stop ──────────────────────────────────────────
SELECT active, brand_id, city, state, date, time, location
INTO v_stop_active, v_stop_brand_id, v_stop_city, v_stop_state, v_stop_date, v_stop_time, v_stop_location
FROM stops
WHERE id = p_stop_id;
IF v_stop_brand_id IS NULL THEN
RAISE EXCEPTION 'Stop not found: %', p_stop_id;
END IF;
IF NOT v_stop_active THEN
RAISE EXCEPTION 'Stop is not active: %', p_stop_id;
END IF;
-- ── Validate items and compute subtotal ────────────────────
FOR v_item IN SELECT * FROM jsonb_array_elements(p_items)
LOOP
v_product_id := (v_item->>'id')::UUID;
v_quantity := (v_item->>'quantity')::INT;
v_fulfillment := v_item->>'fulfillment';
v_is_pickup := (v_fulfillment = 'pickup');
SELECT active, brand_id, price, name
INTO v_product_active, v_product_brand_id, v_product_price, v_product_name
FROM products
WHERE id = v_product_id;
IF v_product_brand_id IS NULL THEN
RAISE EXCEPTION 'Product not found: %', v_product_id;
END IF;
IF v_product_brand_id != v_stop_brand_id THEN
RAISE EXCEPTION 'Product % does not belong to the stop brand', v_product_id;
END IF;
IF NOT v_product_active THEN
RAISE EXCEPTION 'Product is not active: %', v_product_id;
END IF;
IF v_quantity < 1 THEN
RAISE EXCEPTION 'Quantity must be at least 1 for product: %', v_product_id;
END IF;
IF v_is_pickup THEN
PERFORM 1
FROM product_stops
WHERE product_id = v_product_id AND stop_id = p_stop_id
LIMIT 1;
IF NOT FOUND THEN
RAISE EXCEPTION 'Pickup product % is not available at stop %', v_product_id, p_stop_id;
END IF;
END IF;
v_computed_total := v_computed_total + (v_product_price * v_quantity);
END LOOP;
-- ── Create order ────────────────────────────────────────────
INSERT INTO orders (
idempotency_key, customer_name, customer_email, customer_phone,
stop_id, subtotal, status
) VALUES (
p_idempotency_key, p_customer_name, p_customer_email, p_customer_phone,
p_stop_id, v_computed_total, 'pending'
)
RETURNING id INTO v_order_id;
-- ── Insert order items and build return value ───────────────
FOR v_item IN SELECT * FROM jsonb_array_elements(p_items)
LOOP
v_product_id := (v_item->>'id')::UUID;
v_quantity := (v_item->>'quantity')::INT;
v_fulfillment := v_item->>'fulfillment';
SELECT price, name INTO v_product_price, v_product_name FROM products WHERE id = v_product_id;
INSERT INTO order_items (order_id, product_id, quantity, fulfillment, price)
VALUES (v_order_id, v_product_id, v_quantity, v_fulfillment, v_product_price);
-- Append to items array
v_order_items := v_order_items || jsonb_build_object(
'product_id', v_product_id,
'product_name', v_product_name,
'quantity', v_quantity,
'price', v_product_price,
'fulfillment', v_fulfillment
);
END LOOP;
-- ── Build and return full order object ──────────────────────
RETURN jsonb_build_object(
'id', v_order_id,
'customer_name', p_customer_name,
'customer_email', p_customer_email,
'customer_phone', p_customer_phone,
'subtotal', v_computed_total,
'status', 'pending',
'stop_id', p_stop_id,
'stop_city', v_stop_city,
'stop_state', v_stop_state,
'stop_date', v_stop_date,
'stop_time', v_stop_time,
'stop_location', v_stop_location,
'items', v_order_items
);
END;
$$;
@@ -0,0 +1,10 @@
-- ============================================================
-- Remove broad anon SELECT policies
-- ============================================================
-- The checkout flow no longer requires post-checkout SELECT
-- because the RPC returns full order data directly.
-- Admin SELECT is handled by server-side auth (getAdminUser).
-- Drop the overly permissive read policies
DROP POLICY IF EXISTS "Allow public read on orders for checkout" ON orders;
DROP POLICY IF EXISTS "Allow public read on order_items" ON order_items;
@@ -0,0 +1,103 @@
-- ============================================================
-- Audit Logging
-- ============================================================
-- Tracks critical admin mutations for compliance and debugging.
-- Lightweight: single insert per action, no triggers.
CREATE TABLE IF NOT EXISTS audit_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
table_name TEXT NOT NULL,
record_id UUID NOT NULL,
action TEXT NOT NULL, -- INSERT | UPDATE | DELETE
old_data JSONB,
new_data JSONB,
performed_by UUID,
performed_by_email TEXT,
brand_id UUID, -- for brand-scoped log reads
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Index for fast lookups by table + record
CREATE INDEX IF NOT EXISTS idx_audit_logs_table_record
ON audit_logs(table_name, record_id);
-- Index for brand-scoped reads
CREATE INDEX IF NOT EXISTS idx_audit_logs_brand
ON audit_logs(brand_id) WHERE brand_id IS NOT NULL;
-- Index for performed_by queries
CREATE INDEX IF NOT EXISTS idx_audit_logs_performed_by
ON audit_logs(performed_by);
-- RLS: no direct public access
ALTER TABLE audit_logs ENABLE ROW LEVEL SECURITY;
-- Read policies (platform_admin and brand_admin read their own logs)
-- Read policy: platform_admin sees all
CREATE POLICY "Platform admin reads all audit logs"
ON audit_logs FOR SELECT
TO authenticated
USING (
EXISTS (
SELECT 1 FROM admin_users
WHERE admin_users.user_id = auth.uid()
AND admin_users.role = 'platform_admin'
)
);
-- Read policy: brand_admin sees only their brand's logs
CREATE POLICY "Brand admin reads own brand audit logs"
ON audit_logs FOR SELECT
TO authenticated
USING (
EXISTS (
SELECT 1 FROM admin_users
WHERE admin_users.user_id = auth.uid()
AND admin_users.role = 'brand_admin'
AND admin_users.brand_id = audit_logs.brand_id
)
);
-- Write is handled exclusively via SECURITY DEFINER RPC (bypasses RLS)
-- No INSERT policy needed — the function bypasses RLS entirely.
-- ============================================================
-- log_audit_event — called by server actions to write audit records
-- Runs as postgres (SUPERUSER) so it bypasses RLS on audit_logs.
-- Accepts a JSONB payload with all audit fields.
-- ============================================================
CREATE OR REPLACE FUNCTION log_audit_event(p_payload JSONB)
RETURNS UUID
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_audit_id UUID;
BEGIN
INSERT INTO audit_logs (
table_name, record_id, action,
old_data, new_data,
performed_by, performed_by_email,
brand_id
) VALUES (
p_payload->>'table_name',
(p_payload->>'record_id')::UUID,
p_payload->>'action',
p_payload->'old_data',
p_payload->'new_data',
CASE
WHEN (p_payload->>'performed_by') IS NULL THEN NULL
ELSE (p_payload->>'performed_by')::UUID
END,
p_payload->>'performed_by_email',
CASE
WHEN (p_payload->>'brand_id') IS NULL THEN NULL
ELSE (p_payload->>'brand_id')::UUID
END
)
RETURNING id INTO v_audit_id;
RETURN v_audit_id;
END;
$$;
@@ -0,0 +1,193 @@
-- ============================================================
-- Admin Read Policies for Orders
-- ============================================================
-- The checkout hardening removed broad anon SELECT policies.
-- Admin pages need to read orders with the anon key (server-side via getAdminUser).
-- We use SECURITY DEFINER functions that bypass RLS so admin pages
-- can still fetch orders for their brand scope without auth.uid().
-- No direct public SELECT (already enforced by removal of anon policies)
-- Policy: platform_admin can SELECT all orders
CREATE POLICY "Platform admin can read orders"
ON orders FOR SELECT
TO authenticated
USING (
EXISTS (
SELECT 1 FROM admin_users
WHERE admin_users.user_id = auth.uid()
AND admin_users.role = 'platform_admin'
)
);
-- Policy: brand_admin can read orders for their brand
CREATE POLICY "Brand admin can read their brand orders"
ON orders FOR SELECT
TO authenticated
USING (
EXISTS (
SELECT 1 FROM admin_users
WHERE admin_users.user_id = auth.uid()
AND admin_users.role = 'brand_admin'
AND admin_users.brand_id = (
SELECT brand_id FROM stops WHERE stops.id = orders.stop_id
)
)
);
-- Policy: stops readable by admin users
CREATE POLICY "Admins can read stops"
ON stops FOR SELECT
TO authenticated
USING (
EXISTS (
SELECT 1 FROM admin_users
WHERE admin_users.user_id = auth.uid()
)
);
-- ============================================================
-- Admin Orders Fetch RPC
-- ============================================================
-- SECURITY DEFINER runs as postgres — bypasses RLS on orders/stops.
CREATE OR REPLACE FUNCTION get_admin_orders(p_brand_id UUID)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_orders JSONB;
v_stops JSONB;
BEGIN
IF p_brand_id IS NULL THEN
SELECT COALESCE(jsonb_agg(t ORDER BY t.created_at DESC), '[]'::JSONB)
INTO v_orders
FROM (
SELECT o.id, o.customer_name, o.customer_email, o.customer_phone,
o.stop_id, o.status, o.subtotal, o.pickup_complete,
o.pickup_completed_at, o.pickup_completed_by, o.created_at,
CASE WHEN o.stop_id IS NOT NULL THEN jsonb_build_object(
'id', s.id, 'city', s.city, 'state', s.state,
'date', s.date, 'time', s.time, 'location', s.location, 'brand_id', s.brand_id
) END as stops
FROM orders o LEFT JOIN stops s ON o.stop_id = s.id
WHERE o.stop_id IS NOT NULL
LIMIT 500
) t;
SELECT COALESCE(jsonb_agg(jsonb_build_object(
'id', id, 'city', city, 'state', state,
'date', date, 'time', time, 'location', location, 'brand_id', brand_id
) ORDER BY date), '[]'::JSONB)
INTO v_stops FROM stops WHERE active = true;
ELSE
SELECT COALESCE(jsonb_agg(t ORDER BY t.created_at DESC), '[]'::JSONB)
INTO v_orders
FROM (
SELECT o.id, o.customer_name, o.customer_email, o.customer_phone,
o.stop_id, o.status, o.subtotal, o.pickup_complete,
o.pickup_completed_at, o.pickup_completed_by, o.created_at,
jsonb_build_object(
'id', s.id, 'city', s.city, 'state', s.state,
'date', s.date, 'time', s.time, 'location', s.location, 'brand_id', s.brand_id
) as stops
FROM orders o JOIN stops s ON o.stop_id = s.id
WHERE s.brand_id = p_brand_id
LIMIT 500
) t;
SELECT COALESCE(jsonb_agg(jsonb_build_object(
'id', id, 'city', city, 'state', state,
'date', date, 'time', time, 'location', location, 'brand_id', brand_id
) ORDER BY date), '[]'::JSONB)
INTO v_stops FROM stops WHERE active = true AND brand_id = p_brand_id;
END IF;
RETURN jsonb_build_object('orders', v_orders, 'stops', v_stops);
END;
$$;
-- ============================================================
-- Admin Order Detail RPC
-- ============================================================
CREATE OR REPLACE FUNCTION get_admin_order_detail(p_order_id UUID)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_order JSONB;
BEGIN
SELECT jsonb_build_object(
'id', o.id,
'customer_name', o.customer_name,
'customer_email', o.customer_email,
'customer_phone', o.customer_phone,
'stop_id', o.stop_id,
'status', o.status,
'subtotal', o.subtotal,
'discount_amount', o.discount_amount,
'tax_amount', o.tax_amount,
'tax_rate', o.tax_rate,
'tax_location', o.tax_location,
'discount_reason', o.discount_reason,
'pickup_complete', o.pickup_complete,
'pickup_completed_at', o.pickup_completed_at,
'pickup_completed_by', o.pickup_completed_by,
'internal_notes', o.internal_notes,
'payment_processor', o.payment_processor,
'payment_status', o.payment_status,
'payment_transaction_id', o.payment_transaction_id,
'refunded_amount', o.refunded_amount,
'refund_reason', o.refund_reason,
'created_at', o.created_at,
'stops', CASE WHEN o.stop_id IS NOT NULL THEN jsonb_build_object(
'id', s.id,
'city', s.city,
'state', s.state,
'date', s.date,
'time', s.time,
'location', s.location,
'brand_id', s.brand_id
) END,
'order_items', COALESCE((
SELECT jsonb_agg(jsonb_build_object(
'id', oi.id,
'product_id', oi.product_id,
'product_name', p.name,
'quantity', oi.quantity,
'price', oi.price,
'fulfillment', oi.fulfillment,
'products', jsonb_build_object('name', p.name)
))
FROM order_items oi
JOIN products p ON oi.product_id = p.id
WHERE oi.order_id = o.id
), '[]'::JSONB),
'refunds', COALESCE((
SELECT jsonb_agg(jsonb_build_object(
'id', r.id,
'order_id', r.order_id,
'amount', r.amount,
'reason', r.reason,
'processor', r.processor,
'processor_refund_id', r.processor_refund_id,
'status', r.status,
'created_at', r.created_at
))
FROM refunds r
WHERE r.order_id = o.id
), '[]'::JSONB)
)
INTO v_order
FROM orders o
LEFT JOIN stops s ON o.stop_id = s.id
WHERE o.id = p_order_id;
RETURN v_order;
END;
$$;
@@ -0,0 +1,352 @@
-- ============================================================
-- Water Log V1
-- ============================================================
-- Tables: water_headgates, water_irrigators, water_log_entries
-- Auth: PIN-based field login via HTTP-only cookie session
-- Admin: brand-scoped, SECURITY DEFINER RPCs, audit logged
-- ── Tables ─────────────────────────────────────────────────
CREATE TABLE water_headgates (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
brand_id UUID REFERENCES brands(id) NOT NULL,
name TEXT NOT NULL,
active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE water_irrigators (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
brand_id UUID REFERENCES brands(id) NOT NULL,
name TEXT NOT NULL,
pin_hash TEXT NOT NULL,
language_preference TEXT DEFAULT 'en',
active BOOLEAN DEFAULT true,
last_used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now()
);
-- Water sessions for field login (HTTP-only cookie, server-side validation)
CREATE TABLE water_sessions (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
irrigator_id UUID REFERENCES water_irrigators(id) NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE water_log_entries (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
brand_id UUID REFERENCES brands(id) NOT NULL,
headgate_id UUID REFERENCES water_headgates(id) NOT NULL,
irrigator_id UUID REFERENCES water_irrigators(id) NOT NULL,
measurement NUMERIC NOT NULL,
unit TEXT NOT NULL,
notes TEXT,
submitted_via TEXT NOT NULL, -- 'field' or 'admin'
logged_at TIMESTAMPTZ DEFAULT now(),
logged_by UUID REFERENCES admin_users(id)
);
-- ── RLS ─────────────────────────────────────────────────────
ALTER TABLE water_headgates ENABLE ROW LEVEL SECURITY;
ALTER TABLE water_irrigators ENABLE ROW LEVEL SECURITY;
ALTER TABLE water_log_entries ENABLE ROW LEVEL SECURITY;
-- Headgates: anon SELECT only via RPC (the field form opens an authenticated
-- session first via verify_water_pin). Writable only by SECURITY DEFINER
-- RPCs (postgres role, see verify_water_pin / log_water_entry functions).
CREATE POLICY "Headgates readable by authenticated" ON water_headgates FOR SELECT USING (auth.uid() IS NOT NULL);
CREATE POLICY "Headgates writable by authenticated" ON water_headgates FOR ALL USING (auth.uid() IS NOT NULL) WITH CHECK (auth.uid() IS NOT NULL);
-- Irrigators: only SECURITY DEFINER RPCs read/write (pin_hash never exposed)
CREATE POLICY "Irrigators writable by authenticated" ON water_irrigators FOR ALL USING (auth.uid() IS NOT NULL) WITH CHECK (auth.uid() IS NOT NULL);
-- Entries: readable by authenticated users (RPCs gate by brand); writable by authenticated users
CREATE POLICY "Entries readable by authenticated" ON water_log_entries FOR SELECT USING (auth.uid() IS NOT NULL);
CREATE POLICY "Entries writable by authenticated" ON water_log_entries FOR ALL USING (auth.uid() IS NOT NULL) WITH CHECK (auth.uid() IS NOT NULL);
-- ── Audit Log ──────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS audit_logs (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
user_id UUID,
action TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id UUID,
details JSONB,
ip_address TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
ALTER TABLE audit_logs ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Audit logs writable by authenticated" ON audit_logs FOR ALL USING (auth.uid() IS NOT NULL) WITH CHECK (auth.uid() IS NOT NULL);
-- ── RPCs ───────────────────────────────────────────────────
-- Verify PIN, create DB session, return session token (stored in HTTP-only cookie)
CREATE OR REPLACE FUNCTION verify_water_pin(p_brand_id UUID, p_pin TEXT)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_irr water_irrigators%ROWTYPE;
v_sess_id UUID;
v_lang TEXT;
BEGIN
SELECT * INTO v_irr FROM water_irrigators
WHERE brand_id = p_brand_id AND active = true
AND crypt(p_pin, pin_hash) = pin_hash
LIMIT 1;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Invalid PIN');
END IF;
UPDATE water_irrigators SET last_used_at = now() WHERE id = v_irr.id;
-- Create DB session with 4-hour expiry
v_sess_id := gen_random_uuid();
INSERT INTO water_sessions (id, irrigator_id, expires_at)
VALUES (v_sess_id, v_irr.id, now() + interval '4 hours');
RETURN jsonb_build_object(
'success', true,
'irrigator_id', v_irr.id,
'name', v_irr.name,
'session_id', v_sess_id,
'lang', v_irr.language_preference
);
END;
$$;
-- Submit water log entry (field side — session validated via DB)
CREATE OR REPLACE FUNCTION submit_water_entry(
p_session_id UUID,
p_headgate_id UUID,
p_measurement NUMERIC,
p_unit TEXT,
p_notes TEXT,
p_submitted_via TEXT
)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_entry_id UUID;
v_sess water_sessions%ROWTYPE;
v_brand_id UUID;
BEGIN
-- Validate session: exists and not expired
SELECT s.*, i.brand_id INTO v_sess, v_brand_id
FROM water_sessions s
JOIN water_irrigators i ON s.irrigator_id = i.id
WHERE s.id = p_session_id AND s.expires_at > now() AND i.active = true;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Session expired or invalid');
END IF;
INSERT INTO water_log_entries (brand_id, headgate_id, irrigator_id, measurement, unit, notes, submitted_via)
VALUES (v_brand_id, p_headgate_id, v_sess.irrigator_id, p_measurement, p_unit, p_notes, p_submitted_via)
RETURNING id INTO v_entry_id;
RETURN jsonb_build_object('success', true, 'entry_id', v_entry_id);
END;
$$;
-- Get headgates for a brand. p_active_only=true for field form, false for admin
CREATE OR REPLACE FUNCTION get_water_headgates(p_brand_id UUID, p_active_only BOOLEAN DEFAULT false)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
RETURN jsonb_build_object('headgates', (
SELECT COALESCE(jsonb_agg(jsonb_build_object(
'id', id, 'name', name, 'active', active, 'created_at', created_at
) ORDER BY name), '[]'::JSONB)
FROM water_headgates
WHERE brand_id = p_brand_id
AND (NOT p_active_only OR active = true)
));
END;
$$;
-- Get irrigators for a brand (admin, no pin_hash exposed)
CREATE OR REPLACE FUNCTION get_water_irrigators(p_brand_id UUID)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
RETURN jsonb_build_object('irrigators', (
SELECT COALESCE(jsonb_agg(jsonb_build_object(
'id', id, 'name', name, 'active', active,
'language_preference', language_preference,
'last_used_at', last_used_at,
'created_at', created_at
) ORDER BY name), '[]'::JSONB)
FROM water_irrigators
WHERE brand_id = p_brand_id
));
END;
$$;
-- Create a new irrigator with auto-generated PIN
-- Returns irrigator + plain PIN (shown once)
CREATE OR REPLACE FUNCTION create_water_irrigator(p_brand_id UUID, p_name TEXT, p_lang TEXT DEFAULT 'en')
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_irr water_irrigators%ROWTYPE;
v_raw_pin TEXT;
v_new_id UUID;
BEGIN
v_raw_pin := floor(random() * 9000 + 1000)::TEXT; -- 4-digit PIN
INSERT INTO water_irrigators (brand_id, name, pin_hash, language_preference)
VALUES (p_brand_id, p_name, crypt(v_raw_pin, gen_salt('bf')), p_lang)
RETURNING * INTO v_irr;
RETURN jsonb_build_object(
'irrigator', jsonb_build_object(
'id', v_irr.id, 'name', v_irr.name,
'active', v_irr.active, 'language_preference', v_irr.language_preference,
'created_at', v_irr.created_at
),
'pin', v_raw_pin
);
END;
$$;
-- Update irrigator name/active
CREATE OR REPLACE FUNCTION update_water_irrigator(
p_irrigator_id UUID, p_name TEXT, p_active BOOLEAN, p_lang TEXT
)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_irr water_irrigators%ROWTYPE;
BEGIN
UPDATE water_irrigators
SET name = p_name, active = p_active, language_preference = p_lang
WHERE id = p_irrigator_id
RETURNING * INTO v_irr;
RETURN jsonb_build_object('irrigator', jsonb_build_object(
'id', v_irr.id, 'name', v_irr.name,
'active', v_irr.active, 'language_preference', v_irr.language_preference,
'last_used_at', v_irr.last_used_at
));
END;
$$;
-- Reset irrigator PIN — returns new PIN (shown once)
CREATE OR REPLACE FUNCTION reset_water_irrigator_pin(p_irrigator_id UUID)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_irr water_irrigators%ROWTYPE;
v_raw_pin TEXT;
BEGIN
v_raw_pin := floor(random() * 9000 + 1000)::TEXT;
UPDATE water_irrigators
SET pin_hash = crypt(v_raw_pin, gen_salt('bf'))
WHERE id = p_irrigator_id
RETURNING * INTO v_irr;
RETURN jsonb_build_object('irrigator_id', v_irr.id, 'pin', v_raw_pin);
END;
$$;
-- Create headgate
CREATE OR REPLACE FUNCTION create_water_headgate(p_brand_id UUID, p_name TEXT)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_hg water_headgates%ROWTYPE;
BEGIN
INSERT INTO water_headgates (brand_id, name)
VALUES (p_brand_id, p_name)
RETURNING * INTO v_hg;
RETURN jsonb_build_object('headgate', jsonb_build_object(
'id', v_hg.id, 'name', v_hg.name, 'active', v_hg.active, 'created_at', v_hg.created_at
));
END;
$$;
-- Update headgate
CREATE OR REPLACE FUNCTION update_water_headgate(p_headgate_id UUID, p_name TEXT, p_active BOOLEAN)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_hg water_headgates%ROWTYPE;
BEGIN
UPDATE water_headgates
SET name = p_name, active = p_active
WHERE id = p_headgate_id
RETURNING * INTO v_hg;
RETURN jsonb_build_object('headgate', jsonb_build_object(
'id', v_hg.id, 'name', v_hg.name, 'active', v_hg.active
));
END;
$$;
-- Get recent entries for a brand
CREATE OR REPLACE FUNCTION get_water_entries(p_brand_id UUID, p_limit INT DEFAULT 50)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
RETURN jsonb_build_object('entries', (
SELECT COALESCE(jsonb_agg(t ORDER BY t.logged_at DESC), '[]'::JSONB)
FROM (
SELECT e.id, e.headgate_id, e.irrigator_id, e.measurement, e.unit,
e.notes, e.submitted_via, e.logged_at,
h.name as headgate_name,
i.name as irrigator_name
FROM water_log_entries e
JOIN water_headgates h ON e.headgate_id = h.id
JOIN water_irrigators i ON e.irrigator_id = i.id
WHERE e.brand_id = p_brand_id
ORDER BY e.logged_at DESC
LIMIT p_limit
) t
));
END;
$$;
-- ============================================================
-- RLS for water_sessions (added by scripts/fix-archived-rls.js)
-- Columns: id, irrigator_id, expires_at, created_at
-- ============================================================
ALTER TABLE water_sessions ENABLE ROW LEVEL SECURITY;
ALTER TABLE water_sessions FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS deny_all ON water_sessions;
CREATE POLICY deny_all ON water_sessions FOR ALL USING (false) WITH CHECK (false);
@@ -0,0 +1,263 @@
-- =============================================================================
-- Water Log V1 — Consolidated RPC Repair
-- Fixes: gen_salt/crypt calls, schema alignment, overload removal
-- Uses fully-qualified pgcrypto: extensions.crypt / extensions.gen_salt
-- =============================================================================
-- Drop all existing water log RPCs (ignore errors if some don't exist)
DROP FUNCTION IF EXISTS public.verify_water_pin(uuid, text);
DROP FUNCTION IF EXISTS public.create_water_user(uuid, text, text, text);
DROP FUNCTION IF EXISTS public.create_water_user(uuid, text, text);
DROP FUNCTION IF EXISTS public.reset_water_user_pin(uuid);
DROP FUNCTION IF EXISTS public.reset_water_irrigator_pin(uuid);
DROP FUNCTION IF EXISTS public.submit_water_entry(uuid, uuid, numeric, text, text, text);
DROP FUNCTION IF EXISTS public.submit_water_entry(uuid, uuid, numeric, text, text);
DROP FUNCTION IF EXISTS public.update_water_irrigator(uuid, text, boolean, text);
DROP FUNCTION IF EXISTS public.update_water_user(uuid, text, boolean, text);
DROP FUNCTION IF EXISTS public.get_water_irrigators(uuid);
DROP FUNCTION IF EXISTS public.get_water_entries(uuid, int);
DROP FUNCTION IF EXISTS public.get_water_entries(uuid);
-- =============================================================================
-- verify_water_pin
-- Returns jsonb: {success, user_id, name, role, session_id, lang} or {success:false, error}
-- =============================================================================
CREATE OR REPLACE FUNCTION public.verify_water_pin(p_brand_id uuid, p_pin text)
RETURNS jsonb
LANGUAGE plpgsql
STATIC
AS $$
DECLARE
v_user jsonb;
v_session_id uuid;
BEGIN
-- Look up user by PIN (pgcrypto fully-qualified)
SELECT jsonb_build_object(
'id', id,
'name', name,
'role', role,
'lang', language_preference
) INTO v_user
FROM public.water_users
WHERE brand_id = p_brand_id
AND active = true
AND role IN ('irrigator', 'water_admin')
AND extensions.crypt(p_pin, pin_hash) = pin_hash;
IF v_user IS NULL THEN
RETURN jsonb_build_object('success', false, 'error', 'Invalid PIN');
END IF;
-- Update last-used timestamp
UPDATE public.water_users
SET last_used_at = now()
WHERE id = (v_user->>'id')::uuid;
-- Create session
v_session_id := gen_random_uuid();
INSERT INTO public.water_sessions (id, irrigator_id, expires_at)
VALUES (v_session_id, (v_user->>'id')::uuid, now() + interval '4 hours');
RETURN jsonb_build_object(
'success', true,
'user_id', v_user->>'id',
'name', v_user->>'name',
'role', v_user->>'role',
'session_id', v_session_id,
'lang', v_user->>'lang'
);
END;
$$;
COMMENT ON FUNCTION public.verify_water_pin IS
'Verify irrigator/admin PIN, create session, return user info as JSONB.';
-- =============================================================================
-- create_water_user
-- Returns jsonb: {success, user: {id,name,role}, pin}
-- =============================================================================
CREATE OR REPLACE FUNCTION public.create_water_user(
p_brand_id uuid,
p_name text,
p_role text,
p_lang text DEFAULT 'en'
)
RETURNS jsonb
LANGUAGE plpgsql
STATIC
AS $$
DECLARE
v_brand_id uuid;
v_raw_pin text;
v_user_id uuid;
v_user_role text;
BEGIN
v_brand_id := COALESCE(p_brand_id, '64294306-5f42-463d-a5e8-2ad6c81a96de'::uuid);
v_raw_pin := lpad(floor(random() * 9000 + 1000)::text, 4, '0');
v_user_role := COALESCE(p_role, 'irrigator');
INSERT INTO public.water_users (brand_id, name, pin_hash, role, language_preference, active)
VALUES (
v_brand_id,
p_name,
extensions.crypt(v_raw_pin, extensions.gen_salt('bf')),
v_user_role,
p_lang,
true
)
RETURNING id INTO v_user_id;
RETURN jsonb_build_object(
'success', true,
'user', jsonb_build_object('id', v_user_id, 'name', p_name, 'role', v_user_role),
'pin', v_raw_pin
);
END;
$$;
COMMENT ON FUNCTION public.create_water_user IS
'Create water user, generate PIN, return hashed PIN and plaintext PIN once.';
-- =============================================================================
-- reset_water_user_pin
-- Returns jsonb: {success, user_id, pin}
-- =============================================================================
CREATE OR REPLACE FUNCTION public.reset_water_user_pin(p_user_id uuid)
RETURNS jsonb
LANGUAGE plpgsql
STATIC
AS $$
DECLARE
v_raw_pin text;
v_name text;
BEGIN
v_raw_pin := lpad(floor(random() * 9000 + 1000)::text, 4, '0');
UPDATE public.water_users
SET pin_hash = extensions.crypt(v_raw_pin, extensions.gen_salt('bf'))
WHERE id = p_user_id
RETURNING name INTO v_name;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'User not found');
END IF;
RETURN jsonb_build_object('success', true, 'user_id', p_user_id, 'pin', v_raw_pin);
END;
$$;
-- =============================================================================
-- submit_water_entry
-- Returns jsonb: {success, entry_id} or {success:false, error}
-- =============================================================================
CREATE OR REPLACE FUNCTION public.submit_water_entry(
p_session_id uuid,
p_headgate_id uuid,
p_measurement numeric,
p_unit text,
p_notes text DEFAULT '',
p_submitted_via text DEFAULT 'field'
)
RETURNS jsonb
LANGUAGE plpgsql
STATIC
AS $$
DECLARE
v_sess record;
v_brand_id uuid;
v_entry_id uuid;
BEGIN
-- Look up session and join to water_users to get brand_id
SELECT s.id, s.irrigator_id, u.brand_id
INTO v_sess, v_brand_id
FROM public.water_sessions s
JOIN public.water_users u ON s.irrigator_id = u.id
WHERE s.id = p_session_id
AND s.expires_at > now();
IF NOT FOUND OR v_sess.irrigator_id IS NULL THEN
RETURN jsonb_build_object('success', false, 'error', 'Session expired or invalid');
END IF;
INSERT INTO public.water_log_entries
(brand_id, headgate_id, irrigator_id, measurement, unit, notes, submitted_via)
VALUES
(v_brand_id, p_headgate_id, v_sess.irrigator_id, p_measurement, p_unit, p_notes, p_submitted_via)
RETURNING id INTO v_entry_id;
RETURN jsonb_build_object('success', true, 'entry_id', v_entry_id);
END;
$$;
COMMENT ON FUNCTION public.submit_water_entry IS
'Submit a water log entry using an active wl_session cookie.';
-- =============================================================================
-- update_water_user
-- Returns jsonb: {success} or {success:false, error}
-- =============================================================================
CREATE OR REPLACE FUNCTION public.update_water_user(
p_user_id uuid,
p_name text,
p_active boolean,
p_lang text DEFAULT 'en'
)
RETURNS jsonb
LANGUAGE plpgsql
STATIC
AS $$
BEGIN
UPDATE public.water_users
SET name = p_name, active = p_active, language_preference = p_lang
WHERE id = p_user_id;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'User not found');
END IF;
RETURN jsonb_build_object('success', true);
END;
$$;
-- =============================================================================
-- get_water_entries
-- Returns jsonb: {entries: [...]}
-- =============================================================================
CREATE OR REPLACE FUNCTION public.get_water_entries(p_brand_id uuid, p_limit int DEFAULT 50)
RETURNS jsonb
LANGUAGE plpgsql
STATIC
AS $$
BEGIN
RETURN (
SELECT jsonb_build_object('entries', COALESCE(
(SELECT jsonb_agg(e ORDER BY e.logged_at DESC)
FROM (
SELECT
e.id,
e.headgate_id,
e.irrigator_id,
h.name AS headgate_name,
u.name AS irrigator_name,
e.measurement,
e.unit,
e.notes,
e.submitted_via,
e.logged_at
FROM public.water_log_entries e
JOIN public.water_headgates h ON e.headgate_id = h.id
JOIN public.water_users u ON e.irrigator_id = u.id
WHERE e.brand_id = p_brand_id
ORDER BY e.logged_at DESC
LIMIT p_limit
) e
), '[]'::jsonb
))
);
END;
$$;
-- =============================================================================
-- Reload PostgREST schema cache so new signatures are visible
-- =============================================================================
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,232 @@
-- =============================================================================
-- Water Log V1 — Column name alignment + SECURITY DEFINER
-- Deployed tables: water_users, water_sessions(user_id), water_log_entries(user_id)
-- =============================================================================
DROP FUNCTION IF EXISTS public.verify_water_pin(uuid, text);
DROP FUNCTION IF EXISTS public.create_water_user(uuid, text, text, text);
DROP FUNCTION IF EXISTS public.reset_water_user_pin(uuid);
DROP FUNCTION IF EXISTS public.submit_water_entry(uuid, uuid, numeric, text, text, text);
DROP FUNCTION IF EXISTS public.update_water_user(uuid, text, boolean, text);
DROP FUNCTION IF EXISTS public.get_water_entries(uuid, int);
-- =============================================================================
-- verify_water_pin(p_brand_id uuid, p_pin text)
-- Returns jsonb: {success, user_id, name, role, session_id, lang}
-- =============================================================================
CREATE OR REPLACE FUNCTION public.verify_water_pin(p_brand_id uuid, p_pin text)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_user jsonb;
v_session_id uuid;
BEGIN
SELECT jsonb_build_object(
'id', id,
'name', name,
'role', role,
'lang', language_preference
) INTO v_user
FROM public.water_users
WHERE active = true
AND role IN ('irrigator', 'water_admin')
AND extensions.crypt(p_pin, pin_hash) = pin_hash;
IF v_user IS NULL THEN
RETURN jsonb_build_object('success', false, 'error', 'Invalid PIN');
END IF;
UPDATE public.water_users
SET last_used_at = now()
WHERE id = (v_user->>'id')::uuid;
v_session_id := gen_random_uuid();
INSERT INTO public.water_sessions (id, user_id, role, expires_at)
VALUES (v_session_id, (v_user->>'id')::uuid, v_user->>'role', now() + interval '4 hours');
RETURN jsonb_build_object(
'success', true,
'user_id', v_user->>'id',
'name', v_user->>'name',
'role', v_user->>'role',
'session_id', v_session_id,
'lang', v_user->>'lang'
);
END;
$$;
-- =============================================================================
-- create_water_user(p_brand_id, p_name, p_role, p_lang)
-- Returns jsonb: {success, user: {id,name,role}, pin}
-- =============================================================================
CREATE OR REPLACE FUNCTION public.create_water_user(
p_brand_id uuid,
p_name text,
p_role text,
p_lang text DEFAULT 'en'
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_raw_pin text := lpad(floor(random() * 9000 + 1000)::text, 4, '0');
v_user_id uuid;
v_user_role text := COALESCE(p_role, 'irrigator');
v_brand_id uuid := COALESCE(p_brand_id, '64294306-5f42-463d-a5e8-2ad6c81a96de'::uuid);
BEGIN
INSERT INTO public.water_users (brand_id, name, pin_hash, role, language_preference, active)
VALUES (
v_brand_id,
p_name,
extensions.crypt(v_raw_pin, extensions.gen_salt('bf')),
v_user_role,
p_lang,
true
)
RETURNING id INTO v_user_id;
RETURN jsonb_build_object(
'success', true,
'user', jsonb_build_object('id', v_user_id, 'name', p_name, 'role', v_user_role),
'pin', v_raw_pin
);
END;
$$;
-- =============================================================================
-- reset_water_user_pin(p_user_id)
-- Returns jsonb: {success, user_id, pin}
-- =============================================================================
CREATE OR REPLACE FUNCTION public.reset_water_user_pin(p_user_id uuid)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_raw_pin text := lpad(floor(random() * 9000 + 1000)::text, 4, '0');
v_name text;
BEGIN
UPDATE public.water_users
SET pin_hash = extensions.crypt(v_raw_pin, extensions.gen_salt('bf'))
WHERE id = p_user_id
RETURNING name INTO v_name;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'User not found');
END IF;
RETURN jsonb_build_object('success', true, 'user_id', p_user_id, 'pin', v_raw_pin);
END;
$$;
-- =============================================================================
-- submit_water_entry(p_session_id, p_headgate_id, p_measurement, p_unit, p_notes, p_submitted_via)
-- Returns jsonb: {success, entry_id}
-- =============================================================================
CREATE OR REPLACE FUNCTION public.submit_water_entry(
p_session_id uuid,
p_headgate_id uuid,
p_measurement numeric,
p_unit text,
p_notes text DEFAULT '',
p_submitted_via text DEFAULT 'field'
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_user_id uuid;
v_brand_id uuid;
v_entry_id uuid;
BEGIN
SELECT s.user_id, h.brand_id
INTO v_user_id, v_brand_id
FROM public.water_sessions s
JOIN public.water_headgates h ON h.id = p_headgate_id
WHERE s.id = p_session_id
AND s.expires_at > now();
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Session expired or invalid');
END IF;
INSERT INTO public.water_log_entries
(brand_id, headgate_id, user_id, measurement, unit, notes, submitted_via)
VALUES
(v_brand_id, p_headgate_id, v_user_id, p_measurement, p_unit, p_notes, p_submitted_via)
RETURNING id INTO v_entry_id;
RETURN jsonb_build_object('success', true, 'entry_id', v_entry_id);
END;
$$;
-- =============================================================================
-- update_water_user(p_user_id, p_name, p_active, p_lang)
-- Returns jsonb: {success}
-- =============================================================================
CREATE OR REPLACE FUNCTION public.update_water_user(
p_user_id uuid,
p_name text,
p_active boolean,
p_lang text DEFAULT 'en'
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
BEGIN
UPDATE public.water_users
SET name = p_name, active = p_active, language_preference = p_lang
WHERE id = p_user_id;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'User not found');
END IF;
RETURN jsonb_build_object('success', true);
END;
$$;
-- =============================================================================
-- get_water_entries(p_brand_id, p_limit)
-- Returns jsonb: {entries: [...]}
-- =============================================================================
CREATE OR REPLACE FUNCTION public.get_water_entries(p_brand_id uuid, p_limit int DEFAULT 50)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
BEGIN
RETURN (
SELECT jsonb_build_object('entries', COALESCE(
(SELECT jsonb_agg(e ORDER BY e.logged_at DESC)
FROM (
SELECT
e.id,
e.headgate_id,
e.user_id,
h.name AS headgate_name,
u.name AS user_name,
e.measurement,
e.unit,
e.notes,
e.submitted_via,
e.logged_at
FROM public.water_log_entries e
JOIN public.water_headgates h ON e.headgate_id = h.id
JOIN public.water_users u ON e.user_id = u.id
WHERE e.brand_id = p_brand_id
ORDER BY e.logged_at DESC
LIMIT p_limit
) e
), '[]'::jsonb
))
);
END;
$$;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,123 @@
-- =============================================================================
-- Water Log V1 — Soft Delete
-- Adds deleted_at to water_users and water_headgates
-- Creates delete_water_user and delete_water_headgate RPCs
-- Existing entries preserved; deleted users/headgates excluded from queries
-- =============================================================================
-- Add deleted_at column to water_users
ALTER TABLE public.water_users
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
-- Add deleted_at column to water_headgates
ALTER TABLE public.water_headgates
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
-- =============================================================================
-- delete_water_user(p_user_id uuid)
-- Soft-deletes a water user (sets deleted_at) so entries are preserved.
-- Returns jsonb: {success} or {success:false, error}
-- =============================================================================
CREATE OR REPLACE FUNCTION public.delete_water_user(p_user_id uuid)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
BEGIN
UPDATE public.water_users
SET deleted_at = now()
WHERE id = p_user_id AND deleted_at IS NULL;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'User not found or already deleted');
END IF;
RETURN jsonb_build_object('success', true);
END;
$$;
-- =============================================================================
-- delete_water_headgate(p_headgate_id uuid)
-- Soft-deletes a water headgate (sets deleted_at) so entries are preserved.
-- Returns jsonb: {success} or {success:false, error}
-- =============================================================================
CREATE OR REPLACE FUNCTION public.delete_water_headgate(p_headgate_id uuid)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
BEGIN
UPDATE public.water_headgates
SET deleted_at = now()
WHERE id = p_headgate_id AND deleted_at IS NULL;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Headgate not found or already deleted');
END IF;
RETURN jsonb_build_object('success', true);
END;
$$;
-- =============================================================================
-- Update get_water_users to exclude soft-deleted users
-- =============================================================================
CREATE OR REPLACE FUNCTION public.get_water_users(p_brand_id uuid)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
BEGIN
RETURN (
SELECT jsonb_build_object('users', COALESCE(
(SELECT jsonb_agg(
jsonb_build_object(
'id', id,
'name', name,
'role', role,
'active', active,
'language_preference', language_preference,
'last_used_at', last_used_at,
'created_at', created_at,
'deleted_at', deleted_at
) ORDER BY created_at DESC
)
FROM public.water_users
WHERE brand_id = p_brand_id AND deleted_at IS NULL
), '[]'::jsonb
))
);
END;
$$;
-- =============================================================================
-- Update get_water_headgates to exclude soft-deleted headgates
-- =============================================================================
CREATE OR REPLACE FUNCTION public.get_water_headgates(p_brand_id uuid, p_active_only boolean DEFAULT false)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
BEGIN
RETURN (
SELECT jsonb_build_object('headgates', COALESCE(
(SELECT jsonb_agg(
jsonb_build_object(
'id', id,
'name', name,
'active', active,
'created_at', created_at,
'deleted_at', deleted_at
) ORDER BY created_at DESC
)
FROM public.water_headgates
WHERE brand_id = p_brand_id
AND (NOT p_active_only OR active = true)
AND deleted_at IS NULL
), '[]'::jsonb
))
);
END;
$$;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,88 @@
-- =============================================================================
-- Water Log V1 — Entry Edit & Delete
-- Allows water_admin and platform_admin to edit measurement/notes
-- Allows deletion of entries (hard delete, not soft)
-- =============================================================================
-- =============================================================================
-- update_water_entry(p_entry_id uuid, p_measurement numeric, p_notes text)
-- Updates measurement and notes only. Preserves user_id, headgate_id,
-- logged_at, submitted_via.
-- Returns jsonb: {success} or {success:false, error}
-- =============================================================================
CREATE OR REPLACE FUNCTION public.update_water_entry(p_entry_id uuid, p_measurement numeric, p_notes text)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
BEGIN
UPDATE public.water_log_entries
SET measurement = p_measurement, notes = p_notes
WHERE id = p_entry_id;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Entry not found');
END IF;
RETURN jsonb_build_object('success', true);
END;
$$;
-- =============================================================================
-- delete_water_entry(p_entry_id uuid)
-- Hard-deletes an entry. Use with caution — no soft-delete for entries.
-- Returns jsonb: {success} or {success:false, error}
-- =============================================================================
CREATE OR REPLACE FUNCTION public.delete_water_entry(p_entry_id uuid)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
BEGIN
DELETE FROM public.water_log_entries WHERE id = p_entry_id;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Entry not found');
END IF;
RETURN jsonb_build_object('success', true);
END;
$$;
NOTIFY pgrst, 'reload schema';
-- =============================================================================
-- get_water_entry_by_id(p_entry_id uuid)
-- Returns a single entry row for the edit page.
-- Returns jsonb: {entry: {...}} or {entry: null}
-- =============================================================================
CREATE OR REPLACE FUNCTION public.get_water_entry_by_id(p_entry_id uuid)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
BEGIN
RETURN (
SELECT jsonb_build_object('entry', (
SELECT jsonb_build_object(
'id', e.id,
'headgate_id', e.headgate_id,
'user_id', e.user_id,
'headgate_name', hg.name,
'user_name', u.name,
'measurement', e.measurement,
'unit', e.unit,
'notes', e.notes,
'submitted_via', e.submitted_via,
'logged_at', e.logged_at
)
FROM public.water_log_entries e
JOIN public.water_headgates hg ON hg.id = e.headgate_id
JOIN public.water_users u ON u.id = e.user_id
WHERE e.id = p_entry_id
))
);
END;
$$;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,202 @@
-- =============================================================================
-- Water Log V1.1 — Unit Types + Dedicated Edit Pages
--
-- Adds `unit` column to water_headgates so each headgate carries its
-- preferred measurement unit (CFS, GPM, etc.). This unit pre-selects
-- on the field form and is editable via dedicated headgate/user edit pages.
--
-- Also updates update_water_entry to support changing unit.
-- =============================================================================
-- ── Add unit to headgates ────────────────────────────────────
ALTER TABLE public.water_headgates
ADD COLUMN IF NOT EXISTS unit TEXT NOT NULL DEFAULT 'CFS';
-- ── Update submit_water_entry to respect headgate's unit ─────
-- The field form should use the headgate's default unit.
-- Update get_water_entries to include headgate unit.
-- Update update_water_entry to accept optional unit change.
CREATE OR REPLACE FUNCTION public.update_water_entry(
p_entry_id uuid,
p_measurement numeric,
p_notes text,
p_unit text DEFAULT NULL
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
BEGIN
IF p_unit IS NOT NULL THEN
UPDATE public.water_log_entries
SET measurement = p_measurement, notes = p_notes, unit = p_unit
WHERE id = p_entry_id;
ELSE
UPDATE public.water_log_entries
SET measurement = p_measurement, notes = p_notes
WHERE id = p_entry_id;
END IF;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Entry not found');
END IF;
RETURN jsonb_build_object('success', true);
END;
$$;
-- Update get_water_entries to include headgate's default unit (for pre-fill)
CREATE OR REPLACE FUNCTION public.get_water_entries(p_brand_id uuid, p_limit int DEFAULT 50)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
BEGIN
RETURN (
SELECT jsonb_build_object('entries', COALESCE(
(SELECT jsonb_agg(
jsonb_build_object(
'id', e.id,
'headgate_id', e.headgate_id,
'user_id', e.user_id,
'headgate_name', hg.name,
'user_name', u.name,
'measurement', e.measurement,
'unit', e.unit,
'notes', e.notes,
'submitted_via', e.submitted_via,
'logged_at', e.logged_at,
'headgate_unit', hg.unit
) ORDER BY e.logged_at DESC
)
FROM public.water_log_entries e
JOIN public.water_headgates hg ON hg.id = e.headgate_id
JOIN public.water_users u ON u.id = e.user_id
WHERE e.brand_id = p_brand_id
), '[]'::jsonb
))
);
END;
$$;
-- Update get_water_entry_by_id to include headgate_unit for pre-fill
CREATE OR REPLACE FUNCTION public.get_water_entry_by_id(p_entry_id uuid)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
BEGIN
RETURN (
SELECT jsonb_build_object('entry', (
SELECT jsonb_build_object(
'id', e.id,
'headgate_id', e.headgate_id,
'user_id', e.user_id,
'headgate_name', hg.name,
'user_name', u.name,
'measurement', e.measurement,
'unit', e.unit,
'notes', e.notes,
'submitted_via', e.submitted_via,
'logged_at', e.logged_at,
'headgate_unit', hg.unit
)
FROM public.water_log_entries e
JOIN public.water_headgates hg ON hg.id = e.headgate_id
JOIN public.water_users u ON u.id = e.user_id
WHERE e.id = p_entry_id
))
);
END;
$$;
-- Drop overloads so there's only one version with p_unit defaulting
DROP FUNCTION IF EXISTS public.update_water_headgate(uuid, text, boolean);
CREATE OR REPLACE FUNCTION public.update_water_headgate(
p_headgate_id uuid,
p_name text,
p_active boolean,
p_unit text DEFAULT NULL
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
BEGIN
IF p_unit IS NOT NULL THEN
UPDATE public.water_headgates
SET name = p_name, active = p_active, unit = p_unit
WHERE id = p_headgate_id AND deleted_at IS NULL;
ELSE
UPDATE public.water_headgates
SET name = p_name, active = p_active
WHERE id = p_headgate_id AND deleted_at IS NULL;
END IF;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Headgate not found');
END IF;
RETURN jsonb_build_object('success', true);
END;
$$;
-- Update create_water_headgate to accept unit
CREATE OR REPLACE FUNCTION public.create_water_headgate(p_brand_id uuid, p_name text, p_unit text DEFAULT 'CFS')
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_headgate_id uuid;
v_result jsonb;
BEGIN
INSERT INTO public.water_headgates (brand_id, name, unit)
VALUES (p_brand_id, p_name, p_unit)
RETURNING id INTO v_headgate_id;
SELECT jsonb_build_object(
'id', id,
'name', name,
'active', active,
'unit', unit,
'created_at', created_at
) INTO v_result
FROM public.water_headgates WHERE id = v_headgate_id;
RETURN jsonb_build_object('headgate', v_result);
END;
$$;
-- get_water_headgates admin view should include unit
CREATE OR REPLACE FUNCTION public.get_water_headgates(p_brand_id uuid, p_active_only boolean DEFAULT false)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
BEGIN
RETURN (
SELECT jsonb_build_object('headgates', COALESCE(
(SELECT jsonb_agg(
jsonb_build_object(
'id', id,
'name', name,
'active', active,
'unit', unit,
'created_at', created_at,
'deleted_at', deleted_at
) ORDER BY created_at DESC
)
FROM public.water_headgates
WHERE brand_id = p_brand_id
AND (NOT p_active_only OR active = true)
AND deleted_at IS NULL
), '[]'::jsonb
))
);
END;
$$;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,289 @@
-- =============================================================================
-- Water Log V1.1 — Unit Types + Soft Delete Fix (consolidated)
--
-- If migration 008 was not run, this adds deleted_at columns.
-- If migration 010 had issues, this fixes the function overload and adds missing cols.
-- Safe to run multiple times — uses ADD COLUMN IF NOT EXISTS and IF EXISTS drops.
-- =============================================================================
-- ── Soft delete columns (if not already present) ─────────────────────────────
ALTER TABLE public.water_users
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
ALTER TABLE public.water_headgates
ADD COLUMN IF NOT EXISTS unit TEXT NOT NULL DEFAULT 'CFS',
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
-- ── Fix update_water_headgate overload ───────────────────────────────────────
-- Drop old 3-param overload so there's only one version (with p_unit)
DROP FUNCTION IF EXISTS public.update_water_headgate(uuid, text, boolean);
CREATE OR REPLACE FUNCTION public.update_water_headgate(
p_headgate_id uuid,
p_name text,
p_active boolean,
p_unit text DEFAULT NULL
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
BEGIN
IF p_unit IS NOT NULL THEN
UPDATE public.water_headgates
SET name = p_name, active = p_active, unit = p_unit
WHERE id = p_headgate_id AND deleted_at IS NULL;
ELSE
UPDATE public.water_headgates
SET name = p_name, active = p_active
WHERE id = p_headgate_id AND deleted_at IS NULL;
END IF;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Headgate not found');
END IF;
RETURN jsonb_build_object('success', true);
END;
$$;
-- ── Fix update_water_entry overload ──────────────────────────────────────────
-- Drop old 3-param overload so only the 4-param version (p_unit optional) exists
DROP FUNCTION IF EXISTS public.update_water_entry(uuid, numeric, text);
CREATE OR REPLACE FUNCTION public.update_water_entry(
p_entry_id uuid,
p_measurement numeric,
p_notes text,
p_unit text DEFAULT NULL
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
BEGIN
IF p_unit IS NOT NULL THEN
UPDATE public.water_log_entries
SET measurement = p_measurement, notes = p_notes, unit = p_unit
WHERE id = p_entry_id;
ELSE
UPDATE public.water_log_entries
SET measurement = p_measurement, notes = p_notes
WHERE id = p_entry_id;
END IF;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Entry not found');
END IF;
RETURN jsonb_build_object('success', true);
END;
$$;
-- ── Create Water Headgate (updated to support p_unit) ────────────────────────
DROP FUNCTION IF EXISTS public.create_water_headgate(uuid, text, text);
CREATE OR REPLACE FUNCTION public.create_water_headgate(
p_brand_id uuid,
p_name text,
p_unit text DEFAULT 'CFS'
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_headgate_id uuid;
v_result jsonb;
BEGIN
INSERT INTO public.water_headgates (brand_id, name, unit)
VALUES (p_brand_id, p_name, p_unit)
RETURNING id INTO v_headgate_id;
SELECT jsonb_build_object(
'id', id,
'name', name,
'active', active,
'unit', unit,
'created_at', created_at
) INTO v_result
FROM public.water_headgates WHERE id = v_headgate_id;
RETURN jsonb_build_object('headgate', v_result);
END;
$$;
-- ── Delete Water User (soft delete) ───────────────────────────────────────────
DROP FUNCTION IF EXISTS public.delete_water_user(uuid);
CREATE OR REPLACE FUNCTION public.delete_water_user(p_user_id uuid)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
BEGIN
UPDATE public.water_users
SET deleted_at = now()
WHERE id = p_user_id AND deleted_at IS NULL;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'User not found or already deleted');
END IF;
RETURN jsonb_build_object('success', true);
END;
$$;
-- ── Delete Water Headgate (soft delete) ──────────────────────────────────────
DROP FUNCTION IF EXISTS public.delete_water_headgate(uuid);
CREATE OR REPLACE FUNCTION public.delete_water_headgate(p_headgate_id uuid)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
BEGIN
UPDATE public.water_headgates
SET deleted_at = now()
WHERE id = p_headgate_id AND deleted_at IS NULL;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Headgate not found or already deleted');
END IF;
RETURN jsonb_build_object('success', true);
END;
$$;
-- ── Get Water Users (exclude soft-deleted) ───────────────────────────────────
DROP FUNCTION IF EXISTS public.get_water_users(uuid);
CREATE OR REPLACE FUNCTION public.get_water_users(p_brand_id uuid)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
BEGIN
RETURN (
SELECT jsonb_build_object('users', COALESCE(
(SELECT jsonb_agg(
jsonb_build_object(
'id', id,
'name', name,
'role', role,
'active', active,
'language_preference', language_preference,
'last_used_at', last_used_at,
'created_at', created_at,
'deleted_at', deleted_at
) ORDER BY created_at DESC
)
FROM public.water_users
WHERE brand_id = p_brand_id AND deleted_at IS NULL
), '[]'::jsonb
))
);
END;
$$;
-- ── Get Water Headgates (exclude soft-deleted, include unit) ───────────────────
DROP FUNCTION IF EXISTS public.get_water_headgates(uuid, boolean);
CREATE OR REPLACE FUNCTION public.get_water_headgates(p_brand_id uuid, p_active_only boolean DEFAULT false)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
BEGIN
RETURN (
SELECT jsonb_build_object('headgates', COALESCE(
(SELECT jsonb_agg(
jsonb_build_object(
'id', id,
'name', name,
'active', active,
'unit', unit,
'created_at', created_at,
'deleted_at', deleted_at
) ORDER BY created_at DESC
)
FROM public.water_headgates
WHERE brand_id = p_brand_id
AND (NOT p_active_only OR active = true)
AND deleted_at IS NULL
), '[]'::jsonb
))
);
END;
$$;
-- ── Get Water Entries (include headgate_unit) ─────────────────────────────────
DROP FUNCTION IF EXISTS public.get_water_entries(uuid, int);
CREATE OR REPLACE FUNCTION public.get_water_entries(p_brand_id uuid, p_limit int DEFAULT 50)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
BEGIN
RETURN (
SELECT jsonb_build_object('entries', COALESCE(
(SELECT jsonb_agg(
jsonb_build_object(
'id', e.id,
'headgate_id', e.headgate_id,
'user_id', e.user_id,
'headgate_name', hg.name,
'user_name', u.name,
'measurement', e.measurement,
'unit', e.unit,
'notes', e.notes,
'submitted_via', e.submitted_via,
'logged_at', e.logged_at,
'headgate_unit', hg.unit
) ORDER BY e.logged_at DESC
)
FROM public.water_log_entries e
JOIN public.water_headgates hg ON hg.id = e.headgate_id
JOIN public.water_users u ON u.id = e.user_id
WHERE e.brand_id = p_brand_id
), '[]'::jsonb
))
);
END;
$$;
-- ── Get Water Entry By ID (include headgate_unit) ────────────────────────────
DROP FUNCTION IF EXISTS public.get_water_entry_by_id(uuid);
CREATE OR REPLACE FUNCTION public.get_water_entry_by_id(p_entry_id uuid)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
BEGIN
RETURN (
SELECT jsonb_build_object('entry', (
SELECT jsonb_build_object(
'id', e.id,
'headgate_id', e.headgate_id,
'user_id', e.user_id,
'headgate_name', hg.name,
'user_name', u.name,
'measurement', e.measurement,
'unit', e.unit,
'notes', e.notes,
'submitted_via', e.submitted_via,
'logged_at', e.logged_at,
'headgate_unit', hg.unit
)
FROM public.water_log_entries e
JOIN public.water_headgates hg ON hg.id = e.headgate_id
JOIN public.water_users u ON u.id = e.user_id
WHERE e.id = p_entry_id
))
);
END;
$$;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,103 @@
-- =============================================================================
-- Water Log V1.6 - Display Summary RPC
-- Returns headgate latest readings + today's aggregates for Smartsheet/display.
-- SECURITY DEFINER so anon key can call it (same as other water log RPCs).
-- =============================================================================
CREATE OR REPLACE FUNCTION public.get_water_display_summary(p_brand_id uuid)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
BEGIN
RETURN (
WITH headgate_data AS (
SELECT
hg.id,
hg.name,
COALESCE(hg.unit, 'CFS') as unit,
(
SELECT jsonb_build_object(
'measurement', le.measurement,
'user_name', u.name,
'logged_at', le.logged_at
)
FROM public.water_log_entries le
JOIN public.water_users u ON u.id = le.user_id
WHERE le.headgate_id = hg.id
ORDER BY le.logged_at DESC
LIMIT 1
) as latest_entry,
(
SELECT le.logged_at
FROM public.water_log_entries le
WHERE le.headgate_id = hg.id
ORDER BY le.logged_at DESC
LIMIT 1
) as last_logged_at,
(
SELECT FLOOR(EXTRACT(EPOCH FROM (now() - le.logged_at)) / 60)::int
FROM public.water_log_entries le
WHERE le.headgate_id = hg.id
ORDER BY le.logged_at DESC
LIMIT 1
) as minutes_ago
FROM public.water_headgates hg
WHERE hg.brand_id = p_brand_id
AND hg.active = true
AND hg.deleted_at IS NULL
),
today_stats AS (
SELECT
count(*) as cnt,
COALESCE(sum(measurement), 0) as total
FROM public.water_log_entries
WHERE brand_id = p_brand_id AND logged_at::date = CURRENT_DATE
),
entries_ordered AS (
SELECT e.logged_at, e.headgate_id, e.user_id, e.measurement, e.unit, e.notes, e.submitted_via
FROM public.water_log_entries e
WHERE e.brand_id = p_brand_id
ORDER BY e.logged_at DESC
LIMIT 20
),
recent_entries_agg AS (
SELECT COALESCE(jsonb_agg(
jsonb_build_object(
'logged_at', e.logged_at,
'headgate_name', hg.name,
'user_name', u.name,
'user_role', u.role,
'measurement', e.measurement,
'unit', e.unit,
'notes', e.notes,
'submitted_via', e.submitted_via
)
), '[]'::jsonb) as data
FROM entries_ordered e
JOIN public.water_headgates hg ON hg.id = e.headgate_id
JOIN public.water_users u ON u.id = e.user_id
)
SELECT jsonb_build_object(
'headgates', (
SELECT COALESCE(jsonb_agg(
jsonb_build_object(
'id', hd.id,
'name', hd.name,
'unit', hd.unit,
'latest_entry', hd.latest_entry,
'last_logged_at', hd.last_logged_at,
'minutes_ago', hd.minutes_ago
)
), '[]'::jsonb)
FROM headgate_data hd
),
'today_count', (SELECT cnt FROM today_stats),
'today_total', (SELECT total FROM today_stats),
'recent_entries', (SELECT data FROM recent_entries_agg)
)
);
END;
$$;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,13 @@
-- =============================================================================
-- Water Log V1.6 - Seed Water Admin User
-- =============================================================================
INSERT INTO public.water_users (brand_id, name, pin_hash, role, active, language_preference)
VALUES (
'64294306-5f42-463d-a5e8-2ad6c81a96de',
'Test Water Admin',
extensions.crypt('1234', gen_salt('bf')),
'water_admin',
true,
'en'
);
@@ -0,0 +1,9 @@
-- =============================================================================
-- Water Log V1.6 - Fix water_sessions RLS
-- Add SELECT policy so getWaterAdminSession can read sessions with anon key
-- =============================================================================
DROP POLICY IF EXISTS "Sessions readable by all" ON public.water_sessions;
CREATE POLICY "Sessions readable by all" ON public.water_sessions FOR SELECT TO anon USING (true);
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,90 @@
-- =============================================================================
-- Water Log V1.6 - Security Hardening: block direct water_users access
--
-- Problem: pin_hash can be read via direct REST SELECT with anon key.
-- Fix: Remove direct SELECT policies. All reads go through SECURITY DEFINER
-- RPCs which run as the supabase postgres user and bypass RLS.
--
-- Changes:
-- 1. DROP "Users readable by all" policy (was added to fix getWaterAdminSession)
-- 2. Create get_water_user_by_id(p_user_id) RPC for language lookup (SECURITY DEFINER)
-- 3. Create get_water_admin_session() RPC (SECURITY DEFINER) replacing direct
-- water_sessions + water_users JOIN in field.ts getWaterAdminSession
-- 4. Update field.ts to use RPCs instead of direct REST calls
-- 5. Supabase will continue to route all water_users writes through the existing
-- SECURITY DEFINER RPCs (create_water_user, update_water_user, etc.)
-- =============================================================================
-- Step 1: Remove direct SELECT policy from water_users
DROP POLICY IF EXISTS "Users readable by all" ON public.water_users;
-- Step 2: Create a SECURITY DEFINER RPC to get a single user's language preference
-- This replaces the direct REST call in field.ts line 83
-- (runs as supabase postgres user, bypasses RLS)
CREATE OR REPLACE FUNCTION public.get_water_user_by_id(p_user_id uuid)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_result jsonb;
BEGIN
SELECT jsonb_build_object(
'language_preference', language_preference
) INTO v_result
FROM public.water_users
WHERE id = p_user_id AND deleted_at IS NULL;
RETURN v_result;
END;
$$;
-- Step 3: Create get_water_admin_session() RPC (SECURITY DEFINER)
-- Reads the wl_session cookie and returns the associated user's info.
-- Replaces the direct water_sessions + water_users JOIN in getWaterAdminSession.
-- Runs as supabase postgres user, bypasses RLS on both water_sessions and water_users.
CREATE OR REPLACE FUNCTION public.get_water_admin_session()
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_session_id text;
v_result jsonb;
BEGIN
-- Read wl_session from request cookies (set by verify_water_pin)
-- In SECURITY DEFINER context, we can't access HTTP cookies directly.
-- The session_id is passed from the caller (Next.js server action).
-- Instead, create a variant that accepts session_id as a parameter.
RETURN null; -- placeholder — actual implementation uses p_session_id below
END;
$$;
-- Drop and recreate with proper signature (accepts p_session_id)
DROP FUNCTION IF EXISTS public.get_water_admin_session();
CREATE OR REPLACE FUNCTION public.get_water_admin_session(p_session_id text)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_result jsonb;
BEGIN
SELECT jsonb_build_object(
'user_id', s.user_id,
'name', u.name,
'role', u.role
) INTO v_result
FROM public.water_sessions s
JOIN public.water_users u ON u.id = s.user_id
WHERE s.id = p_session_id::uuid
AND s.expires_at > now()
AND u.role = 'water_admin'
AND u.deleted_at IS NULL;
RETURN v_result;
END;
$$;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,662 @@
-- =============================================================================
-- Communication Center V1 — Core Schema
-- Fully idempotent: safe to re-run after partial failure or interrupted deploy
--
-- Cross-brand platform module: supports Tuxedo Corn, Indian River Direct, and
-- future brands via brand_id separation and RLS.
--
-- Design principles:
-- - Operational messaging first (stop notifications, pickup reminders, etc.)
-- - Audience rules owned by campaigns (no separate reusable audience table)
-- - Environment-based provider config (Resend API key in env, not in DB)
-- - Event-driven future-ready (event_type / event_id on message_logs)
-- - No live email/SMS sending until explicitly enabled after testing
-- - Customer data lives in orders table — no separate customers table
-- =============================================================================
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- ── Tables (CREATE TABLE IF NOT EXISTS is already idempotent) ────────────
CREATE TABLE IF NOT EXISTS public.communication_settings (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
default_sender_email TEXT,
default_sender_name TEXT,
reply_to_email TEXT,
email_provider TEXT NOT NULL DEFAULT 'resend',
email_footer_html TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(brand_id)
);
CREATE TABLE IF NOT EXISTS public.communication_templates (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
name TEXT NOT NULL,
subject TEXT NOT NULL,
body_text TEXT NOT NULL DEFAULT '',
body_html TEXT,
template_type TEXT NOT NULL,
campaign_type TEXT,
created_by UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS public.communication_campaigns (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
name TEXT NOT NULL,
subject TEXT,
body_text TEXT,
body_html TEXT,
template_id UUID REFERENCES communication_templates(id) ON DELETE SET NULL,
campaign_type TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft',
audience_rules JSONB NOT NULL DEFAULT '{}',
scheduled_at TIMESTAMPTZ,
sent_at TIMESTAMPTZ,
created_by UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS public.communication_message_logs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
campaign_id UUID REFERENCES communication_campaigns(id) ON DELETE SET NULL,
customer_id UUID,
customer_email TEXT,
delivery_method TEXT NOT NULL,
subject TEXT,
body_preview TEXT,
status TEXT NOT NULL,
sent_at TIMESTAMPTZ,
error_message TEXT,
event_type TEXT,
event_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS public.customer_communication_preferences (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID NOT NULL,
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
email_opt_in BOOLEAN NOT NULL DEFAULT true,
sms_opt_in BOOLEAN NOT NULL DEFAULT false,
email_opt_in_at TIMESTAMPTZ,
sms_opt_in_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(customer_id, brand_id)
);
-- ── Indexes (CREATE INDEX IF NOT EXISTS is already idempotent) ────────────
CREATE INDEX IF NOT EXISTS idx_communication_settings_brand
ON public.communication_settings(brand_id);
CREATE INDEX IF NOT EXISTS idx_communication_templates_brand
ON public.communication_templates(brand_id);
CREATE INDEX IF NOT EXISTS idx_communication_campaigns_brand_status
ON public.communication_campaigns(brand_id, status);
CREATE INDEX IF NOT EXISTS idx_communication_campaigns_brand_type
ON public.communication_campaigns(brand_id, campaign_type);
CREATE INDEX IF NOT EXISTS idx_communication_message_logs_brand_sent
ON public.communication_message_logs(brand_id, sent_at DESC);
CREATE INDEX IF NOT EXISTS idx_communication_message_logs_campaign
ON public.communication_message_logs(campaign_id);
CREATE INDEX IF NOT EXISTS idx_communication_message_logs_customer
ON public.communication_message_logs(customer_id);
CREATE INDEX IF NOT EXISTS idx_communication_message_logs_event
ON public.communication_message_logs(event_type, event_id) WHERE event_type IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_customer_comm_prefs_customer_brand
ON public.customer_communication_preferences(customer_id, brand_id);
-- ── RLS — idempotent (ALTER TABLE ENABLE is safe to re-run) ────────────────
ALTER TABLE public.communication_settings ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.communication_templates ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.communication_campaigns ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.communication_message_logs ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.customer_communication_preferences ENABLE ROW LEVEL SECURITY;
-- ── Policies (DROP IF EXISTS before each CREATE makes them idempotent) ────
DROP POLICY IF EXISTS "Platform admin can read communication_settings"
ON public.communication_settings;
DROP POLICY IF EXISTS "Brand admin can read own brand communication_settings"
ON public.communication_settings;
CREATE POLICY "Platform admin can read communication_settings"
ON public.communication_settings FOR SELECT TO authenticated
USING (EXISTS (
SELECT 1 FROM admin_users
WHERE admin_users.user_id = auth.uid()
AND admin_users.role = 'platform_admin'
));
CREATE POLICY "Brand admin can read own brand communication_settings"
ON public.communication_settings FOR SELECT TO authenticated
USING (brand_id IN (
SELECT brand_id FROM admin_users
WHERE admin_users.user_id = auth.uid()
AND admin_users.role = 'brand_admin'
));
DROP POLICY IF EXISTS "Platform admin can read communication_templates"
ON public.communication_templates;
DROP POLICY IF EXISTS "Brand admin can read own brand communication_templates"
ON public.communication_templates;
CREATE POLICY "Platform admin can read communication_templates"
ON public.communication_templates FOR SELECT TO authenticated
USING (EXISTS (
SELECT 1 FROM admin_users
WHERE admin_users.user_id = auth.uid()
AND admin_users.role = 'platform_admin'
));
CREATE POLICY "Brand admin can read own brand communication_templates"
ON public.communication_templates FOR SELECT TO authenticated
USING (brand_id IN (
SELECT brand_id FROM admin_users
WHERE admin_users.user_id = auth.uid()
AND admin_users.role = 'brand_admin'
));
DROP POLICY IF EXISTS "Platform admin can read communication_campaigns"
ON public.communication_campaigns;
DROP POLICY IF EXISTS "Brand admin can read own brand communication_campaigns"
ON public.communication_campaigns;
CREATE POLICY "Platform admin can read communication_campaigns"
ON public.communication_campaigns FOR SELECT TO authenticated
USING (EXISTS (
SELECT 1 FROM admin_users
WHERE admin_users.user_id = auth.uid()
AND admin_users.role = 'platform_admin'
));
CREATE POLICY "Brand admin can read own brand communication_campaigns"
ON public.communication_campaigns FOR SELECT TO authenticated
USING (brand_id IN (
SELECT brand_id FROM admin_users
WHERE admin_users.user_id = auth.uid()
AND admin_users.role = 'brand_admin'
));
DROP POLICY IF EXISTS "Platform admin can read communication_message_logs"
ON public.communication_message_logs;
DROP POLICY IF EXISTS "Brand admin can read own brand communication_message_logs"
ON public.communication_message_logs;
CREATE POLICY "Platform admin can read communication_message_logs"
ON public.communication_message_logs FOR SELECT TO authenticated
USING (EXISTS (
SELECT 1 FROM admin_users
WHERE admin_users.user_id = auth.uid()
AND admin_users.role = 'platform_admin'
));
CREATE POLICY "Brand admin can read own brand communication_message_logs"
ON public.communication_message_logs FOR SELECT TO authenticated
USING (brand_id IN (
SELECT brand_id FROM admin_users
WHERE admin_users.user_id = auth.uid()
AND admin_users.role = 'brand_admin'
));
DROP POLICY IF EXISTS "Anyone can read customer_communication_preferences"
ON public.customer_communication_preferences;
DROP POLICY IF EXISTS "Platform admin can read customer_communication_preferences"
ON public.customer_communication_preferences;
CREATE POLICY "Anyone can read customer_communication_preferences"
ON public.customer_communication_preferences FOR SELECT TO anon USING (true);
CREATE POLICY "Platform admin can read customer_communication_preferences"
ON public.customer_communication_preferences FOR SELECT TO authenticated
USING (EXISTS (
SELECT 1 FROM admin_users
WHERE admin_users.user_id = auth.uid()
AND admin_users.role = 'platform_admin'
));
-- ── SECURITY DEFINER RPCs ─────────────────────────────────────────────────
DROP FUNCTION IF EXISTS public.upsert_communication_settings(UUID, TEXT, TEXT, TEXT, TEXT, TEXT);
CREATE OR REPLACE FUNCTION public.upsert_communication_settings(
p_brand_id UUID,
p_sender_email TEXT,
p_sender_name TEXT,
p_reply_to_email TEXT,
p_provider TEXT,
p_footer_html TEXT
)
RETURNS communication_settings
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE v_result communication_settings;
BEGIN
INSERT INTO public.communication_settings
(brand_id, default_sender_email, default_sender_name, reply_to_email, email_provider, email_footer_html)
VALUES (p_brand_id, p_sender_email, p_sender_name, p_reply_to_email, p_provider, p_footer_html)
ON CONFLICT (brand_id) DO UPDATE SET
default_sender_email = EXCLUDED.default_sender_email,
default_sender_name = EXCLUDED.default_sender_name,
reply_to_email = EXCLUDED.reply_to_email,
email_provider = EXCLUDED.email_provider,
email_footer_html = EXCLUDED.email_footer_html,
updated_at = now()
RETURNING * INTO v_result;
RETURN v_result;
END;
$$;
DROP FUNCTION IF EXISTS public.upsert_communication_template(UUID, UUID, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, UUID);
CREATE OR REPLACE FUNCTION public.upsert_communication_template(
p_id UUID,
p_brand_id UUID,
p_name TEXT,
p_subject TEXT,
p_body_text TEXT,
p_body_html TEXT,
p_template_type TEXT,
p_campaign_type TEXT,
p_created_by UUID
)
RETURNS communication_templates
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_result communication_templates;
v_id UUID;
BEGIN
v_id := coalesce(p_id, uuid_generate_v4());
INSERT INTO public.communication_templates
(id, brand_id, name, subject, body_text, body_html, template_type, campaign_type, created_by)
VALUES (v_id, p_brand_id, p_name, p_subject, p_body_text, p_body_html, p_template_type, p_campaign_type, p_created_by)
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
subject = EXCLUDED.subject,
body_text = EXCLUDED.body_text,
body_html = EXCLUDED.body_html,
template_type = EXCLUDED.template_type,
campaign_type = EXCLUDED.campaign_type,
updated_at = now()
RETURNING * INTO v_result;
RETURN v_result;
END;
$$;
DROP FUNCTION IF EXISTS public.upsert_communication_campaign(UUID, UUID, TEXT, TEXT, TEXT, TEXT, UUID, TEXT, TEXT, JSONB, TIMESTAMPTZ, UUID);
CREATE OR REPLACE FUNCTION public.upsert_communication_campaign(
p_id UUID,
p_brand_id UUID,
p_name TEXT,
p_subject TEXT,
p_body_text TEXT,
p_body_html TEXT,
p_template_id UUID,
p_campaign_type TEXT,
p_status TEXT,
p_audience_rules JSONB,
p_scheduled_at TIMESTAMPTZ,
p_created_by UUID
)
RETURNS communication_campaigns
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_result communication_campaigns;
v_id UUID;
BEGIN
v_id := coalesce(p_id, uuid_generate_v4());
INSERT INTO public.communication_campaigns
(id, brand_id, name, subject, body_text, body_html, template_id, campaign_type, status, audience_rules, scheduled_at, created_by)
VALUES (v_id, p_brand_id, p_name, p_subject, p_body_text, p_body_html, p_template_id, p_campaign_type, p_status, p_audience_rules, p_scheduled_at, p_created_by)
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
subject = EXCLUDED.subject,
body_text = EXCLUDED.body_text,
body_html = EXCLUDED.body_html,
template_id = EXCLUDED.template_id,
campaign_type = EXCLUDED.campaign_type,
status = EXCLUDED.status,
audience_rules = EXCLUDED.audience_rules,
scheduled_at = EXCLUDED.scheduled_at,
updated_at = now()
WHERE communication_campaigns.brand_id = p_brand_id
RETURNING * INTO v_result;
RETURN v_result;
END;
$$;
DROP FUNCTION IF EXISTS public.log_communication_messages(JSONB);
CREATE OR REPLACE FUNCTION public.log_communication_messages(p_entries JSONB)
RETURNS integer
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_entry JSONB;
v_inserted_count integer := 0;
BEGIN
FOR v_entry IN SELECT * FROM jsonb_array_elements(p_entries)
LOOP
INSERT INTO public.communication_message_logs
(brand_id, campaign_id, customer_id, customer_email, delivery_method,
subject, body_preview, status, sent_at, error_message)
VALUES (
(v_entry->>'brand_id')::UUID,
nullif(v_entry->>'campaign_id', '')::UUID,
nullif(v_entry->>'customer_id', '')::UUID,
v_entry->>'customer_email',
v_entry->>'delivery_method',
v_entry->>'subject',
left(v_entry->>'body_preview', 500),
coalesce(v_entry->>'status', 'queued'),
now(),
v_entry->>'error_message'
);
v_inserted_count := v_inserted_count + 1;
END LOOP;
RETURN v_inserted_count;
END;
$$;
DROP FUNCTION IF EXISTS public.opt_out_customer(UUID, UUID, TEXT);
CREATE OR REPLACE FUNCTION public.opt_out_customer(
p_customer_id UUID,
p_brand_id UUID,
p_method TEXT
)
RETURNS customer_communication_preferences
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE v_result customer_communication_preferences;
BEGIN
INSERT INTO public.customer_communication_preferences
(customer_id, brand_id, email_opt_in, sms_opt_in, email_opt_in_at, sms_opt_in_at)
VALUES (
p_customer_id, p_brand_id,
CASE WHEN p_method = 'email' THEN false ELSE true END,
CASE WHEN p_method = 'sms' THEN false ELSE true END,
CASE WHEN p_method = 'email' THEN now() ELSE NULL END,
CASE WHEN p_method = 'sms' THEN now() ELSE NULL END
)
ON CONFLICT (customer_id, brand_id) DO UPDATE SET
email_opt_in = CASE WHEN p_method = 'email' THEN false ELSE customer_communication_preferences.email_opt_in END,
sms_opt_in = CASE WHEN p_method = 'sms' THEN false ELSE customer_communication_preferences.sms_opt_in END,
email_opt_in_at = CASE WHEN p_method = 'email' THEN now() ELSE email_opt_in_at END,
sms_opt_in_at = CASE WHEN p_method = 'sms' THEN now() ELSE sms_opt_in_at END,
updated_at = now()
RETURNING * INTO v_result;
RETURN v_result;
END;
$$;
DROP FUNCTION IF EXISTS public.preview_campaign_audience(UUID, JSONB);
CREATE OR REPLACE FUNCTION public.preview_campaign_audience(
p_brand_id UUID,
p_audience_rules JSONB
)
RETURNS jsonb
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_target TEXT;
v_stop_id UUID;
v_date_from TIMESTAMPTZ;
v_date_to TIMESTAMPTZ;
v_city TEXT;
v_order_hist TEXT;
v_days_back INTEGER;
v_product_id UUID;
v_count INTEGER;
v_customers JSONB;
BEGIN
v_target := p_audience_rules->>'target';
-- Stop-based targeting
IF v_target = 'stop' THEN
v_stop_id := nullif(p_audience_rules->>'stop_id', '')::UUID;
v_date_from := nullif(p_audience_rules->>'date_from', '')::TIMESTAMPTZ;
v_date_to := nullif(p_audience_rules->>'date_to', '')::TIMESTAMPTZ;
SELECT COUNT(DISTINCT o.customer_id),
jsonb_agg(DISTINCT jsonb_build_object('id', o.customer_id, 'email', o.customer_email, 'name', o.customer_name) ORDER BY o.customer_name)
INTO v_count, v_customers
FROM orders o
JOIN stops s ON s.id = o.stop_id
LEFT JOIN customer_communication_preferences cp ON cp.customer_id = o.customer_id AND cp.brand_id = p_brand_id
WHERE s.brand_id = p_brand_id AND o.status NOT IN ('canceled')
AND (v_stop_id IS NULL OR o.stop_id = v_stop_id)
AND (v_date_from IS NULL OR o.created_at >= v_date_from)
AND (v_date_to IS NULL OR o.created_at <= v_date_to)
AND (cp.email_opt_in IS NULL OR cp.email_opt_in = true);
-- ZIP/city targeting (customer ZIP not in orders — city filter only)
ELSIF v_target = 'zip_code' THEN
v_city := p_audience_rules->>'city';
SELECT COUNT(DISTINCT o.customer_id),
jsonb_agg(DISTINCT jsonb_build_object('id', o.customer_id, 'email', o.customer_email, 'name', o.customer_name) ORDER BY o.customer_name)
INTO v_count, v_customers
FROM orders o
JOIN stops s ON s.id = o.stop_id
LEFT JOIN customer_communication_preferences cp ON cp.customer_id = o.customer_id AND cp.brand_id = p_brand_id
WHERE s.brand_id = p_brand_id AND o.status NOT IN ('canceled')
AND (cp.email_opt_in IS NULL OR cp.email_opt_in = true)
AND (v_city IS NULL OR true); -- city filter placeholder; ZIP requires customer_zip column in orders
-- Customer history targeting
ELSIF v_target = 'customer_history' THEN
v_order_hist := p_audience_rules->>'order_history';
v_days_back := (p_audience_rules->>'days_back')::INTEGER;
IF v_order_hist = 'first_order' THEN
WITH first_orders AS (
SELECT o.customer_id, MIN(o.created_at)
FROM orders o JOIN stops s ON s.id = o.stop_id
WHERE s.brand_id = p_brand_id AND o.status NOT IN ('canceled')
AND (v_days_back IS NULL OR o.created_at >= now() - (v_days_back || ' days')::INTERVAL)
GROUP BY o.customer_id HAVING COUNT(*) = 1
)
SELECT COUNT(*),
jsonb_agg(DISTINCT jsonb_build_object('id', fo.customer_id, 'email', o.customer_email, 'name', o.customer_name) ORDER BY o.customer_name)
INTO v_count, v_customers
FROM first_orders fo
JOIN orders o ON o.customer_id = fo.customer_id
LEFT JOIN customer_communication_preferences cp ON cp.customer_id = fo.customer_id AND cp.brand_id = p_brand_id
WHERE (cp.email_opt_in IS NULL OR cp.email_opt_in = true);
ELSIF v_order_hist = 'repeat' THEN
WITH repeat_customer_ids AS (
SELECT o.customer_id
FROM orders o JOIN stops s ON s.id = o.stop_id
WHERE s.brand_id = p_brand_id AND o.status NOT IN ('canceled')
AND (v_days_back IS NULL OR o.created_at >= now() - (v_days_back || ' days')::INTERVAL)
GROUP BY o.customer_id HAVING COUNT(*) > 1
)
SELECT COUNT(*),
jsonb_agg(DISTINCT jsonb_build_object('id', rc.customer_id, 'email', o.customer_email, 'name', o.customer_name) ORDER BY o.customer_name)
INTO v_count, v_customers
FROM repeat_customer_ids rc
JOIN orders o ON o.customer_id = rc.customer_id
LEFT JOIN customer_communication_preferences cp ON cp.customer_id = rc.customer_id AND cp.brand_id = p_brand_id
WHERE (cp.email_opt_in IS NULL OR cp.email_opt_in = true);
ELSE
SELECT COUNT(DISTINCT o.customer_id),
jsonb_agg(DISTINCT jsonb_build_object('id', o.customer_id, 'email', o.customer_email, 'name', o.customer_name) ORDER BY o.customer_name)
INTO v_count, v_customers
FROM orders o
JOIN stops s ON s.id = o.stop_id
LEFT JOIN customer_communication_preferences cp ON cp.customer_id = o.customer_id AND cp.brand_id = p_brand_id
WHERE s.brand_id = p_brand_id AND o.status NOT IN ('canceled')
AND (v_days_back IS NULL OR o.created_at >= now() - (v_days_back || ' days')::INTERVAL)
AND (cp.email_opt_in IS NULL OR cp.email_opt_in = true);
END IF;
-- Product-based targeting
ELSIF v_target = 'product' THEN
v_product_id := nullif(p_audience_rules->>'product_id', '')::UUID;
SELECT COUNT(DISTINCT o.customer_id),
jsonb_agg(DISTINCT jsonb_build_object('id', o.customer_id, 'email', o.customer_email, 'name', o.customer_name) ORDER BY o.customer_name)
INTO v_count, v_customers
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
JOIN stops s ON s.id = o.stop_id
LEFT JOIN customer_communication_preferences cp ON cp.customer_id = o.customer_id AND cp.brand_id = p_brand_id
WHERE s.brand_id = p_brand_id AND o.status NOT IN ('canceled')
AND (v_product_id IS NULL OR oi.product_id = v_product_id)
AND (cp.email_opt_in IS NULL OR cp.email_opt_in = true);
-- Explicit customer ID list
ELSIF v_target = 'customer_ids' THEN
SELECT COUNT(*),
jsonb_agg(jsonb_build_object('id', o.customer_id, 'email', o.customer_email, 'name', o.customer_name) ORDER BY o.customer_name)
INTO v_count, v_customers
FROM orders o
LEFT JOIN customer_communication_preferences cp ON cp.customer_id = o.customer_id AND cp.brand_id = p_brand_id
WHERE o.customer_id IN (
SELECT jsonb_array_elements_text(p_audience_rules->'customer_ids')::UUID
)
AND (cp.email_opt_in IS NULL OR cp.email_opt_in = true);
ELSE
-- all_customers or unknown
SELECT COUNT(DISTINCT o.customer_id),
jsonb_agg(DISTINCT jsonb_build_object('id', o.customer_id, 'email', o.customer_email, 'name', o.customer_name) ORDER BY o.customer_name)
INTO v_count, v_customers
FROM orders o
JOIN stops s ON s.id = o.stop_id
LEFT JOIN customer_communication_preferences cp ON cp.customer_id = o.customer_id AND cp.brand_id = p_brand_id
WHERE s.brand_id = p_brand_id AND o.status NOT IN ('canceled')
AND (cp.email_opt_in IS NULL OR cp.email_opt_in = true);
END IF;
-- Cap sample at 20
SELECT jsonb_agg(x ORDER BY x->>'name') INTO v_customers
FROM (SELECT * FROM jsonb_array_elements(coalesce(v_customers, '[]'::jsonb)) LIMIT 20) t(x);
RETURN jsonb_build_object('count', v_count, 'sample_customers', v_customers);
END;
$$;
DROP FUNCTION IF EXISTS public.send_campaign(UUID);
CREATE OR REPLACE FUNCTION public.send_campaign(p_campaign_id UUID)
RETURNS jsonb
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_campaign communication_campaigns;
v_settings communication_settings;
v_audience JSONB;
v_entries JSONB := '[]'::JSONB;
v_customer JSONB;
v_count INTEGER := 0;
BEGIN
SELECT * INTO v_campaign FROM communication_campaigns WHERE id = p_campaign_id;
IF NOT FOUND THEN RETURN jsonb_build_object('success', false, 'error', 'Campaign not found'); END IF;
SELECT * INTO v_settings FROM communication_settings WHERE brand_id = v_campaign.brand_id;
IF NOT FOUND THEN RETURN jsonb_build_object('success', false, 'error', 'No communication settings for brand'); END IF;
v_audience := preview_campaign_audience(v_campaign.brand_id, v_campaign.audience_rules);
FOR v_customer IN SELECT * FROM jsonb_array_elements(coalesce(v_audience->'sample_customers', '[]'::jsonb))
LOOP
v_entries := v_entries || jsonb_build_array(jsonb_build_object(
'brand_id', v_campaign.brand_id, 'campaign_id', p_campaign_id,
'customer_id', v_customer->>'id', 'customer_email', v_customer->>'email',
'delivery_method', 'email', 'subject', v_campaign.subject,
'body_preview', left(v_campaign.body_text, 500), 'status', 'queued'
));
v_count := v_count + 1;
END LOOP;
PERFORM log_communication_messages(v_entries);
UPDATE communication_campaigns SET status = 'sent', sent_at = now(), updated_at = now()
WHERE id = p_campaign_id;
RETURN jsonb_build_object('success', true, 'messages_logged', v_count);
END;
$$;
DROP FUNCTION IF EXISTS public.get_communication_campaigns(UUID);
CREATE OR REPLACE FUNCTION public.get_communication_campaigns(p_brand_id UUID)
RETURNS JSONB LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE v_campaigns JSONB;
BEGIN
IF p_brand_id IS NULL THEN
SELECT COALESCE(jsonb_agg(c ORDER BY c.created_at DESC), '[]'::JSONB) INTO v_campaigns FROM communication_campaigns c;
ELSE
SELECT COALESCE(jsonb_agg(c ORDER BY c.created_at DESC), '[]'::JSONB) INTO v_campaigns
FROM communication_campaigns c WHERE c.brand_id = p_brand_id;
END IF;
RETURN jsonb_build_object('campaigns', v_campaigns);
END;
$$;
DROP FUNCTION IF EXISTS public.get_communication_campaign_by_id(UUID);
CREATE OR REPLACE FUNCTION public.get_communication_campaign_by_id(p_campaign_id UUID)
RETURNS JSONB LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE v_campaign communication_campaigns%ROWTYPE;
BEGIN
SELECT * INTO v_campaign FROM communication_campaigns WHERE id = p_campaign_id;
IF NOT FOUND THEN RETURN jsonb_build_object('campaign', NULL); END IF;
RETURN jsonb_build_object('campaign', row_to_json(v_campaign));
END;
$$;
DROP FUNCTION IF EXISTS public.get_communication_templates(UUID);
CREATE OR REPLACE FUNCTION public.get_communication_templates(p_brand_id UUID)
RETURNS JSONB LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE v_templates JSONB;
BEGIN
IF p_brand_id IS NULL THEN
SELECT COALESCE(jsonb_agg(t ORDER BY t.name), '[]'::JSONB) INTO v_templates FROM communication_templates t;
ELSE
SELECT COALESCE(jsonb_agg(t ORDER BY t.name), '[]'::JSONB) INTO v_templates
FROM communication_templates t WHERE t.brand_id = p_brand_id;
END IF;
RETURN jsonb_build_object('templates', v_templates);
END;
$$;
DROP FUNCTION IF EXISTS public.get_communication_settings(UUID);
CREATE OR REPLACE FUNCTION public.get_communication_settings(p_brand_id UUID)
RETURNS JSONB LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE v_settings communication_settings%ROWTYPE;
BEGIN
SELECT * INTO v_settings FROM communication_settings WHERE brand_id = p_brand_id;
IF NOT FOUND THEN RETURN jsonb_build_object('settings', NULL); END IF;
RETURN jsonb_build_object('settings', row_to_json(v_settings));
END;
$$;
DROP FUNCTION IF EXISTS public.get_message_logs(UUID, UUID, TEXT, INTEGER);
CREATE OR REPLACE FUNCTION public.get_message_logs(
p_brand_id UUID,
p_campaign_id UUID DEFAULT NULL,
p_status TEXT DEFAULT NULL,
p_limit INTEGER DEFAULT 100
)
RETURNS JSONB LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE v_logs JSONB;
BEGIN
SELECT COALESCE(jsonb_agg(l ORDER BY l.created_at DESC), '[]'::JSONB) INTO v_logs
FROM (
SELECT l.* FROM communication_message_logs l
WHERE (p_brand_id IS NULL OR l.brand_id = p_brand_id)
AND (p_campaign_id IS NULL OR l.campaign_id = p_campaign_id)
AND (p_status IS NULL OR l.status = p_status)
ORDER BY l.created_at DESC LIMIT p_limit
) l;
RETURN jsonb_build_object('logs', v_logs);
END;
$$;
DROP FUNCTION IF EXISTS public.delete_communication_campaign(UUID);
CREATE OR REPLACE FUNCTION public.delete_communication_campaign(p_campaign_id UUID)
RETURNS boolean LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
UPDATE communication_campaigns SET status = 'canceled', updated_at = now()
WHERE id = p_campaign_id;
RETURN FOUND;
END;
$$;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,878 @@
-- =============================================================================
-- Communication Center V1.1 — Contacts + Import Foundation
-- Fully idempotent: safe to re-run after partial failure
-- Additive over V1 (migration 016):
-- - Adds communication_contacts table
-- - Adds DB trigger to auto-create contacts from orders
-- - Adds new RPCs for contact CRUD, import, and opt-out
-- - Rewrites preview_campaign_audience to use communication_contacts as primary
-- - Updates send_campaign to resolve through communication_contacts
-- - Updates opt_out_customer to also set unsubscribed_at on contacts
-- =============================================================================
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- ── Table ────────────────────────────────────────────────────────────────────
-- Uses CREATE TABLE IF NOT EXISTS so this is safe to re-run — no data loss.
CREATE TABLE IF NOT EXISTS public.communication_contacts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
email TEXT,
phone TEXT,
first_name TEXT,
last_name TEXT,
full_name TEXT,
source TEXT NOT NULL, -- 'order' | 'import' | 'manual' | 'admin'
external_id TEXT,
customer_id UUID,
email_opt_in BOOLEAN NOT NULL DEFAULT true,
sms_opt_in BOOLEAN NOT NULL DEFAULT false,
email_opt_in_at TIMESTAMPTZ,
sms_opt_in_at TIMESTAMPTZ,
unsubscribed_at TIMESTAMPTZ,
tags TEXT[] DEFAULT '{}',
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT chk_has_email_or_phone CHECK (email IS NOT NULL OR phone IS NOT NULL)
);
-- ── Indexes ─────────────────────────────────────────────────────────────────
CREATE UNIQUE INDEX IF NOT EXISTS idx_contacts_brand_email_unique
ON public.communication_contacts(brand_id, email)
WHERE email IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_contacts_brand_phone_unique
ON public.communication_contacts(brand_id, phone)
WHERE phone IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_communication_contacts_brand
ON public.communication_contacts(brand_id);
CREATE INDEX IF NOT EXISTS idx_communication_contacts_customer
ON public.communication_contacts(customer_id)
WHERE customer_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_communication_contacts_unsubscribed
ON public.communication_contacts(brand_id, unsubscribed_at)
WHERE unsubscribed_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_communication_contacts_source
ON public.communication_contacts(brand_id, source);
-- ── RLS ─────────────────────────────────────────────────────────────────────
ALTER TABLE public.communication_contacts ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "Platform admin can read communication_contacts"
ON public.communication_contacts;
CREATE POLICY "Platform admin can read communication_contacts"
ON public.communication_contacts FOR SELECT TO authenticated
USING (EXISTS (
SELECT 1 FROM admin_users
WHERE admin_users.user_id = auth.uid()
AND admin_users.role = 'platform_admin'
));
DROP POLICY IF EXISTS "Brand admin can read own brand communication_contacts"
ON public.communication_contacts;
CREATE POLICY "Brand admin can read own brand communication_contacts"
ON public.communication_contacts FOR SELECT TO authenticated
USING (brand_id IN (
SELECT brand_id FROM admin_users
WHERE admin_users.user_id = auth.uid()
AND admin_users.role = 'brand_admin'
));
-- ── SECURITY DEFINER RPCs ────────────────────────────────────────────────────
-- 1. Trigger function for auto-creating contact from order
-- PostgreSQL trigger functions MUST use RETURNS TRIGGER and access NEW/OLD directly.
DROP FUNCTION IF EXISTS public.upsert_contact_from_order();
CREATE OR REPLACE FUNCTION public.upsert_contact_from_order()
RETURNS TRIGGER
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_brand_id UUID;
BEGIN
-- Skip if no email (can't upsert without a key)
IF NEW.customer_email IS NULL OR NEW.customer_email = '' THEN
RETURN NEW;
END IF;
-- Resolve brand_id from the stop
SELECT brand_id INTO v_brand_id FROM public.stops WHERE id = NEW.stop_id;
IF NOT FOUND THEN
RETURN NEW; -- stop not found; skip silently
END IF;
INSERT INTO public.communication_contacts
(brand_id, email, phone, full_name, source, customer_id, email_opt_in, metadata)
VALUES (
v_brand_id,
NEW.customer_email,
NEW.customer_phone,
NEW.customer_name,
'order',
NEW.customer_id,
true,
jsonb_build_object(
'last_order_id', NEW.id,
'last_order_at', now()::TEXT,
'stop_id', NEW.stop_id::TEXT
)
)
ON CONFLICT (brand_id, email) WHERE email IS NOT NULL DO UPDATE SET
full_name = COALESCE(EXCLUDED.full_name, communication_contacts.full_name),
phone = COALESCE(EXCLUDED.phone, communication_contacts.phone),
customer_id = COALESCE(EXCLUDED.customer_id, communication_contacts.customer_id),
metadata = jsonb_build_object(
'last_order_id', communication_contacts.metadata->>'last_order_id',
'last_order_at', communication_contacts.metadata->>'last_order_at',
'stop_id', NEW.stop_id::TEXT
),
updated_at = now();
-- NOTE: email_opt_in, unsubscribed_at, sms_opt_in are intentionally NOT
-- updated here — we preserve the existing opt-out status.
RETURN NEW;
END;
$$;
-- 2. Trigger on orders INSERT
-- Trigger functions receive NEW implicitly; no arguments after func name.
DROP TRIGGER IF EXISTS trg_create_contact_from_order ON public.orders;
CREATE TRIGGER trg_create_contact_from_order
AFTER INSERT ON public.orders
FOR EACH ROW
EXECUTE FUNCTION public.upsert_contact_from_order();
-- 3. upsert_communication_contact (general upsert by email or phone)
DROP FUNCTION IF EXISTS public.upsert_communication_contact(
UUID, UUID, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, BOOLEAN, BOOLEAN, TEXT[], JSONB
);
CREATE OR REPLACE FUNCTION public.upsert_communication_contact(
p_id UUID,
p_brand_id UUID,
p_email TEXT,
p_phone TEXT,
p_first_name TEXT,
p_last_name TEXT,
p_full_name TEXT,
p_source TEXT,
p_external_id TEXT,
p_customer_id UUID,
p_email_opt_in BOOLEAN,
p_sms_opt_in BOOLEAN,
p_tags TEXT[],
p_metadata JSONB
)
RETURNS communication_contacts
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_result communication_contacts;
v_id UUID;
BEGIN
v_id := coalesce(p_id, uuid_generate_v4());
IF p_email IS NOT NULL AND p_email != '' THEN
INSERT INTO public.communication_contacts
(id, brand_id, email, phone, first_name, last_name, full_name, source,
external_id, customer_id, email_opt_in, sms_opt_in, tags, metadata,
email_opt_in_at, sms_opt_in_at)
VALUES
(v_id, p_brand_id, p_email, p_phone, p_first_name, p_last_name,
p_full_name, p_source, p_external_id, p_customer_id,
coalesce(p_email_opt_in, true), coalesce(p_sms_opt_in, false),
coalesce(p_tags, '{}'), coalesce(p_metadata, '{}'),
CASE WHEN p_email_opt_in = true THEN now() ELSE NULL END,
CASE WHEN p_sms_opt_in = true THEN now() ELSE NULL END)
ON CONFLICT (brand_id, email) WHERE email IS NOT NULL DO UPDATE SET
phone = COALESCE(EXCLUDED.phone, communication_contacts.phone),
first_name = COALESCE(EXCLUDED.first_name, communication_contacts.first_name),
last_name = COALESCE(EXCLUDED.last_name, communication_contacts.last_name),
full_name = COALESCE(EXCLUDED.full_name, communication_contacts.full_name),
source = COALESCE(EXCLUDED.source, communication_contacts.source),
external_id = COALESCE(EXCLUDED.external_id, communication_contacts.external_id),
customer_id = COALESCE(EXCLUDED.customer_id, communication_contacts.customer_id),
email_opt_in = CASE
WHEN communication_contacts.unsubscribed_at IS NOT NULL
THEN communication_contacts.email_opt_in
ELSE COALESCE(EXCLUDED.email_opt_in, communication_contacts.email_opt_in)
END,
sms_opt_in = CASE
WHEN communication_contacts.unsubscribed_at IS NOT NULL
THEN communication_contacts.sms_opt_in
ELSE COALESCE(EXCLUDED.sms_opt_in, communication_contacts.sms_opt_in)
END,
tags = COALESCE(EXCLUDED.tags, communication_contacts.tags),
metadata = communication_contacts.metadata || COALESCE(EXCLUDED.metadata, '{}'),
updated_at = now()
RETURNING * INTO v_result;
ELSIF p_phone IS NOT NULL AND p_phone != '' THEN
INSERT INTO public.communication_contacts
(id, brand_id, email, phone, first_name, last_name, full_name, source,
external_id, customer_id, email_opt_in, sms_opt_in, tags, metadata,
email_opt_in_at, sms_opt_in_at)
VALUES
(v_id, p_brand_id, NULL, p_phone, p_first_name, p_last_name,
p_full_name, p_source, p_external_id, p_customer_id,
coalesce(p_email_opt_in, true), coalesce(p_sms_opt_in, false),
coalesce(p_tags, '{}'), coalesce(p_metadata, '{}'),
CASE WHEN p_email_opt_in = true THEN now() ELSE NULL END,
CASE WHEN p_sms_opt_in = true THEN now() ELSE NULL END)
ON CONFLICT (brand_id, phone) WHERE phone IS NOT NULL DO UPDATE SET
email = COALESCE(EXCLUDED.email, communication_contacts.email),
first_name = COALESCE(EXCLUDED.first_name, communication_contacts.first_name),
last_name = COALESCE(EXCLUDED.last_name, communication_contacts.last_name),
full_name = COALESCE(EXCLUDED.full_name, communication_contacts.full_name),
source = COALESCE(EXCLUDED.source, communication_contacts.source),
external_id = COALESCE(EXCLUDED.external_id, communication_contacts.external_id),
customer_id = COALESCE(EXCLUDED.customer_id, communication_contacts.customer_id),
email_opt_in = CASE
WHEN communication_contacts.unsubscribed_at IS NOT NULL
THEN communication_contacts.email_opt_in
ELSE COALESCE(EXCLUDED.email_opt_in, communication_contacts.email_opt_in)
END,
sms_opt_in = CASE
WHEN communication_contacts.unsubscribed_at IS NOT NULL
THEN communication_contacts.sms_opt_in
ELSE COALESCE(EXCLUDED.sms_opt_in, communication_contacts.sms_opt_in)
END,
tags = COALESCE(EXCLUDED.tags, communication_contacts.tags),
metadata = communication_contacts.metadata || COALESCE(EXCLUDED.metadata, '{}'),
updated_at = now()
RETURNING * INTO v_result;
ELSE
RETURN NULL;
END IF;
RETURN v_result;
END;
$$;
-- 4. get_communication_contacts (paginated list with search)
DROP FUNCTION IF EXISTS public.get_communication_contacts(UUID, TEXT, TEXT, INTEGER, INTEGER);
CREATE OR REPLACE FUNCTION public.get_communication_contacts(
p_brand_id UUID,
p_search TEXT DEFAULT NULL,
p_source TEXT DEFAULT NULL,
p_limit INTEGER DEFAULT 100,
p_offset INTEGER DEFAULT 0
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_contacts JSONB;
v_total INTEGER;
BEGIN
SELECT COUNT(*) INTO v_total
FROM public.communication_contacts c
WHERE (p_brand_id IS NULL OR c.brand_id = p_brand_id)
AND (p_source IS NULL OR c.source = p_source)
AND (
p_search IS NULL
OR c.email ILIKE '%' || p_search || '%'
OR c.full_name ILIKE '%' || p_search || '%'
OR c.phone ILIKE '%' || p_search || '%'
);
SELECT COALESCE(jsonb_agg(c ORDER BY c.created_at DESC), '[]'::JSONB) INTO v_contacts
FROM (
SELECT c.*
FROM public.communication_contacts c
WHERE (p_brand_id IS NULL OR c.brand_id = p_brand_id)
AND (p_source IS NULL OR c.source = p_source)
AND (
p_search IS NULL
OR c.email ILIKE '%' || p_search || '%'
OR c.full_name ILIKE '%' || p_search || '%'
OR c.phone ILIKE '%' || p_search || '%'
)
ORDER BY c.created_at DESC
LIMIT p_limit
OFFSET p_offset
) c;
RETURN jsonb_build_object(
'contacts', v_contacts,
'total', v_total,
'limit', p_limit,
'offset', p_offset
);
END;
$$;
-- 5. import_communication_contacts_batch (bulk CSV upsert)
-- Returns: { created: n, updated: n, skipped: n, errors: [...] }
-- tags are stored as TEXT[]; CSV string "tag1;tag2" is split with string_to_array.
DROP FUNCTION IF EXISTS public.import_communication_contacts_batch(UUID, JSONB, BOOLEAN);
CREATE OR REPLACE FUNCTION public.import_communication_contacts_batch(
p_brand_id UUID,
p_contacts JSONB,
p_allow_opt_in_override BOOLEAN DEFAULT false
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_entry JSONB;
v_result JSONB := '{"created": 0, "updated": 0, "skipped": 0, "errors": []}'::JSONB;
v_existing communication_contacts%ROWTYPE;
v_email TEXT;
v_phone TEXT;
v_tags TEXT[];
BEGIN
FOR v_entry IN SELECT * FROM jsonb_array_elements(p_contacts)
LOOP
v_email := nullif(v_entry->>'email', '');
v_phone := nullif(v_entry->>'phone', '');
-- Parse tags: CSV sends "tag1;tag2" as a plain string; split into TEXT[]
IF (v_entry->>'tags') IS NOT NULL AND (v_entry->>'tags') != '' THEN
v_tags := coalesce(
(SELECT array_agg(trim(x)) FROM unnest(string_to_array(v_entry->>'tags', ';')) AS x WHERE trim(x) != ''),
'{}'
);
ELSE
v_tags := '{}';
END IF;
BEGIN
IF v_email IS NOT NULL AND v_email != '' THEN
SELECT * INTO v_existing
FROM public.communication_contacts
WHERE brand_id = p_brand_id AND email = v_email;
IF FOUND THEN
-- Existing contact
IF v_existing.unsubscribed_at IS NOT NULL AND
coalesce((v_entry->>'email_opt_in')::BOOLEAN, false) = true AND
p_allow_opt_in_override = false THEN
v_result := jsonb_set(v_result, '{skipped}',
to_jsonb((v_result->>'skipped')::INTEGER + 1));
ELSE
UPDATE public.communication_contacts SET
phone = COALESCE(nullif(v_entry->>'phone', ''), phone),
first_name = COALESCE(nullif(v_entry->>'first_name', ''), first_name),
last_name = COALESCE(nullif(v_entry->>'last_name', ''), last_name),
full_name = COALESCE(nullif(v_entry->>'full_name', ''), full_name),
source = 'import',
external_id = COALESCE(nullif(v_entry->>'external_id', ''), external_id),
email_opt_in = CASE
WHEN communication_contacts.unsubscribed_at IS NOT NULL
THEN communication_contacts.email_opt_in
ELSE coalesce(
(v_entry->>'email_opt_in')::BOOLEAN,
communication_contacts.email_opt_in,
true
)
END,
sms_opt_in = CASE
WHEN communication_contacts.unsubscribed_at IS NOT NULL
THEN communication_contacts.sms_opt_in
ELSE coalesce(
(v_entry->>'sms_opt_in')::BOOLEAN,
communication_contacts.sms_opt_in,
false
)
END,
email_opt_in_at = CASE
WHEN communication_contacts.unsubscribed_at IS NOT NULL
THEN email_opt_in_at
WHEN coalesce(
(v_entry->>'email_opt_in')::BOOLEAN,
communication_contacts.email_opt_in,
true
) = true THEN now()
ELSE email_opt_in_at
END,
tags = CASE
WHEN v_tags != '{}' THEN v_tags
ELSE communication_contacts.tags
END,
metadata = communication_contacts.metadata || jsonb_build_object(
'imported_at', now()::TEXT
),
updated_at = now()
WHERE brand_id = p_brand_id AND email = v_email;
v_result := jsonb_set(v_result, '{updated}',
to_jsonb((v_result->>'updated')::INTEGER + 1));
END IF;
ELSE
-- Insert new
INSERT INTO public.communication_contacts
(brand_id, email, phone, first_name, last_name, full_name, source,
external_id, email_opt_in, sms_opt_in, email_opt_in_at, sms_opt_in_at,
tags, metadata)
VALUES (
p_brand_id,
v_email,
nullif(v_entry->>'phone', ''),
nullif(v_entry->>'first_name', ''),
nullif(v_entry->>'last_name', ''),
nullif(v_entry->>'full_name', ''),
'import',
nullif(v_entry->>'external_id', ''),
coalesce((v_entry->>'email_opt_in')::BOOLEAN, true),
coalesce((v_entry->>'sms_opt_in')::BOOLEAN, false),
CASE WHEN coalesce((v_entry->>'email_opt_in')::BOOLEAN, true) = true THEN now() ELSE NULL END,
CASE WHEN coalesce((v_entry->>'sms_opt_in')::BOOLEAN, false) = true THEN now() ELSE NULL END,
v_tags,
jsonb_build_object('imported_at', now()::TEXT)
);
v_result := jsonb_set(v_result, '{created}',
to_jsonb((v_result->>'created')::INTEGER + 1));
END IF;
ELSIF v_phone IS NOT NULL AND v_phone != '' THEN
-- Upsert by phone
SELECT * INTO v_existing
FROM public.communication_contacts
WHERE brand_id = p_brand_id AND phone = v_phone;
IF FOUND THEN
IF v_existing.unsubscribed_at IS NOT NULL AND
coalesce((v_entry->>'email_opt_in')::BOOLEAN, false) = true AND
p_allow_opt_in_override = false THEN
v_result := jsonb_set(v_result, '{skipped}',
to_jsonb((v_result->>'skipped')::INTEGER + 1));
ELSE
UPDATE public.communication_contacts SET
email = COALESCE(nullif(v_entry->>'email', ''), email),
first_name = COALESCE(nullif(v_entry->>'first_name', ''), first_name),
last_name = COALESCE(nullif(v_entry->>'last_name', ''), last_name),
full_name = COALESCE(nullif(v_entry->>'full_name', ''), full_name),
source = 'import',
email_opt_in = CASE
WHEN communication_contacts.unsubscribed_at IS NOT NULL
THEN communication_contacts.email_opt_in
ELSE coalesce(
(v_entry->>'email_opt_in')::BOOLEAN,
communication_contacts.email_opt_in,
true
)
END,
tags = CASE WHEN v_tags != '{}' THEN v_tags ELSE communication_contacts.tags END,
metadata = communication_contacts.metadata || jsonb_build_object(
'imported_at', now()::TEXT
),
updated_at = now()
WHERE brand_id = p_brand_id AND phone = v_phone;
v_result := jsonb_set(v_result, '{updated}',
to_jsonb((v_result->>'updated')::INTEGER + 1));
END IF;
ELSE
INSERT INTO public.communication_contacts
(brand_id, email, phone, first_name, last_name, full_name, source,
email_opt_in, sms_opt_in, tags, metadata)
VALUES (
p_brand_id,
nullif(v_entry->>'email', ''),
v_phone,
nullif(v_entry->>'first_name', ''),
nullif(v_entry->>'last_name', ''),
nullif(v_entry->>'full_name', ''),
'import',
coalesce((v_entry->>'email_opt_in')::BOOLEAN, true),
coalesce((v_entry->>'sms_opt_in')::BOOLEAN, false),
v_tags,
jsonb_build_object('imported_at', now()::TEXT)
);
v_result := jsonb_set(v_result, '{created}',
to_jsonb((v_result->>'created')::INTEGER + 1));
END IF;
ELSE
-- No email or phone
v_result := jsonb_set(
v_result, '{errors}',
v_result->'errors' || jsonb_build_array(
jsonb_build_object('row', v_entry, 'error', 'No email or phone provided')
)
);
END IF;
EXCEPTION WHEN OTHERS THEN
v_result := jsonb_set(
v_result, '{errors}',
v_result->'errors' || jsonb_build_array(
jsonb_build_object('row', v_entry, 'error', SQLERRM)
)
);
END;
END LOOP;
RETURN v_result;
END;
$$;
-- 6. opt_out_contact (unsubscribe by email — public-facing, no auth required)
DROP FUNCTION IF EXISTS public.opt_out_contact(TEXT, UUID, TEXT);
CREATE OR REPLACE FUNCTION public.opt_out_contact(
p_email TEXT,
p_brand_id UUID,
p_method TEXT
)
RETURNS void
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
IF p_method = 'email' THEN
UPDATE public.communication_contacts SET
email_opt_in = false,
email_opt_in_at = now(),
unsubscribed_at = now(),
updated_at = now()
WHERE brand_id = p_brand_id AND email = p_email;
ELSIF p_method = 'sms' THEN
UPDATE public.communication_contacts SET
sms_opt_in = false,
sms_opt_in_at = now(),
unsubscribed_at = now(),
updated_at = now()
WHERE brand_id = p_brand_id AND (email = p_email OR phone = p_email);
END IF;
END;
$$;
-- 7. delete_communication_contact
DROP FUNCTION IF EXISTS public.delete_communication_contact(UUID);
CREATE OR REPLACE FUNCTION public.delete_communication_contact(p_id UUID)
RETURNS boolean
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
DELETE FROM public.communication_contacts WHERE id = p_id;
RETURN FOUND;
END;
$$;
-- ── Rewrite preview_campaign_audience to use communication_contacts ───────────
-- Primary source: communication_contacts (opted-in, not unsubscribed)
-- Orders-based targeting still works but final recipient resolution is via contacts.
DROP FUNCTION IF EXISTS public.preview_campaign_audience(UUID, JSONB);
CREATE OR REPLACE FUNCTION public.preview_campaign_audience(
p_brand_id UUID,
p_audience_rules JSONB
)
RETURNS jsonb
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_target TEXT;
v_stop_id UUID;
v_date_from TIMESTAMPTZ;
v_date_to TIMESTAMPTZ;
v_city TEXT;
v_order_hist TEXT;
v_days_back INTEGER;
v_product_id UUID;
v_count INTEGER;
v_contacts JSONB;
BEGIN
v_target := p_audience_rules->>'target';
-- Stop-based targeting: find orders for stop+date range, resolve to contacts
IF v_target = 'stop' THEN
v_stop_id := nullif(p_audience_rules->>'stop_id', '')::UUID;
v_date_from := nullif(p_audience_rules->>'date_from', '')::TIMESTAMPTZ;
v_date_to := nullif(p_audience_rules->>'date_to', '')::TIMESTAMPTZ;
SELECT COUNT(DISTINCT c.id),
jsonb_agg(DISTINCT jsonb_build_object(
'id', c.id, 'email', c.email, 'name', c.full_name,
'phone', c.phone, 'source', c.source
) ORDER BY c.full_name)
INTO v_count, v_contacts
FROM (
SELECT DISTINCT o.customer_email
FROM orders o
JOIN stops s ON s.id = o.stop_id
WHERE s.brand_id = p_brand_id
AND o.status NOT IN ('canceled')
AND (v_stop_id IS NULL OR o.stop_id = v_stop_id)
AND (v_date_from IS NULL OR o.created_at >= v_date_from)
AND (v_date_to IS NULL OR o.created_at <= v_date_to)
AND o.customer_email IS NOT NULL AND o.customer_email != ''
) o_emails
JOIN communication_contacts c ON c.email = o_emails.customer_email
AND c.brand_id = p_brand_id
AND c.email_opt_in = true
AND c.unsubscribed_at IS NULL;
-- ZIP / city targeting (placeholder — ZIP requires customer_zip column in orders)
ELSIF v_target = 'zip_code' THEN
SELECT COUNT(*),
jsonb_agg(jsonb_build_object(
'id', c.id, 'email', c.email, 'name', c.full_name,
'phone', c.phone, 'source', c.source
) ORDER BY c.full_name)
INTO v_count, v_contacts
FROM communication_contacts c
WHERE c.brand_id = p_brand_id
AND c.email_opt_in = true
AND c.unsubscribed_at IS NULL;
-- Customer history targeting
ELSIF v_target = 'customer_history' THEN
v_order_hist := p_audience_rules->>'order_history';
v_days_back := (p_audience_rules->>'days_back')::INTEGER;
IF v_order_hist = 'first_order' THEN
WITH first_order_emails AS (
SELECT o.customer_email
FROM orders o
JOIN stops s ON s.id = o.stop_id
WHERE s.brand_id = p_brand_id
AND o.status NOT IN ('canceled')
AND o.customer_email IS NOT NULL AND o.customer_email != ''
AND (v_days_back IS NULL OR o.created_at >= now() - (v_days_back || ' days')::INTERVAL)
GROUP BY o.customer_email
HAVING COUNT(*) = 1
)
SELECT COUNT(*),
jsonb_agg(jsonb_build_object(
'id', c.id, 'email', c.email, 'name', c.full_name,
'phone', c.phone, 'source', c.source
) ORDER BY c.full_name)
INTO v_count, v_contacts
FROM first_order_emails fe
JOIN communication_contacts c ON c.email = fe.customer_email
AND c.brand_id = p_brand_id
AND c.email_opt_in = true
AND c.unsubscribed_at IS NULL;
ELSIF v_order_hist = 'repeat' THEN
WITH repeat_emails AS (
SELECT o.customer_email
FROM orders o
JOIN stops s ON s.id = o.stop_id
WHERE s.brand_id = p_brand_id
AND o.status NOT IN ('canceled')
AND o.customer_email IS NOT NULL AND o.customer_email != ''
AND (v_days_back IS NULL OR o.created_at >= now() - (v_days_back || ' days')::INTERVAL)
GROUP BY o.customer_email
HAVING COUNT(*) > 1
)
SELECT COUNT(*),
jsonb_agg(jsonb_build_object(
'id', c.id, 'email', c.email, 'name', c.full_name,
'phone', c.phone, 'source', c.source
) ORDER BY c.full_name)
INTO v_count, v_contacts
FROM repeat_emails re
JOIN communication_contacts c ON c.email = re.customer_email
AND c.brand_id = p_brand_id
AND c.email_opt_in = true
AND c.unsubscribed_at IS NULL;
ELSE
WITH all_order_emails AS (
SELECT DISTINCT o.customer_email
FROM orders o
JOIN stops s ON s.id = o.stop_id
WHERE s.brand_id = p_brand_id
AND o.status NOT IN ('canceled')
AND o.customer_email IS NOT NULL AND o.customer_email != ''
AND (v_days_back IS NULL OR o.created_at >= now() - (v_days_back || ' days')::INTERVAL)
)
SELECT COUNT(*),
jsonb_agg(jsonb_build_object(
'id', c.id, 'email', c.email, 'name', c.full_name,
'phone', c.phone, 'source', c.source
) ORDER BY c.full_name)
INTO v_count, v_contacts
FROM all_order_emails aoe
JOIN communication_contacts c ON c.email = aoe.customer_email
AND c.brand_id = p_brand_id
AND c.email_opt_in = true
AND c.unsubscribed_at IS NULL;
END IF;
-- Product-based targeting
ELSIF v_target = 'product' THEN
v_product_id := nullif(p_audience_rules->>'product_id', '')::UUID;
WITH product_order_emails AS (
SELECT DISTINCT o.customer_email
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
JOIN stops s ON s.id = o.stop_id
WHERE s.brand_id = p_brand_id
AND o.status NOT IN ('canceled')
AND (v_product_id IS NULL OR oi.product_id = v_product_id)
AND o.customer_email IS NOT NULL AND o.customer_email != ''
)
SELECT COUNT(*),
jsonb_agg(jsonb_build_object(
'id', c.id, 'email', c.email, 'name', c.full_name,
'phone', c.phone, 'source', c.source
) ORDER BY c.full_name)
INTO v_count, v_contacts
FROM product_order_emails poe
JOIN communication_contacts c ON c.email = poe.customer_email
AND c.brand_id = p_brand_id
AND c.email_opt_in = true
AND c.unsubscribed_at IS NULL;
-- Explicit customer ID list
ELSIF v_target = 'customer_ids' THEN
SELECT COUNT(*),
jsonb_agg(jsonb_build_object(
'id', c.id, 'email', c.email, 'name', c.full_name,
'phone', c.phone, 'source', c.source
) ORDER BY c.full_name)
INTO v_count, v_contacts
FROM communication_contacts c
WHERE c.brand_id = p_brand_id
AND c.email_opt_in = true
AND c.unsubscribed_at IS NULL
AND c.customer_id IN (
SELECT jsonb_array_elements_text(p_audience_rules->'customer_ids')::UUID
);
-- all_customers: all opted-in, not-unsubscribed contacts
ELSE
SELECT COUNT(*),
jsonb_agg(jsonb_build_object(
'id', c.id, 'email', c.email, 'name', c.full_name,
'phone', c.phone, 'source', c.source
) ORDER BY c.full_name)
INTO v_count, v_contacts
FROM communication_contacts c
WHERE c.brand_id = p_brand_id
AND c.email_opt_in = true
AND c.unsubscribed_at IS NULL;
END IF;
-- Cap sample at 20
SELECT jsonb_agg(x ORDER BY x->>'name') INTO v_contacts
FROM (
SELECT * FROM jsonb_array_elements(coalesce(v_contacts, '[]'::jsonb))
LIMIT 20
) t(x);
RETURN jsonb_build_object('count', v_count, 'sample_customers', v_contacts);
END;
$$;
-- ── Update send_campaign to use communication_contacts ──────────────────────
DROP FUNCTION IF EXISTS public.send_campaign(UUID);
CREATE OR REPLACE FUNCTION public.send_campaign(p_campaign_id UUID)
RETURNS jsonb
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_campaign communication_campaigns;
v_settings communication_settings;
v_audience JSONB;
v_entry JSONB;
v_entries JSONB := '[]'::JSONB;
v_count INTEGER := 0;
BEGIN
SELECT * INTO v_campaign FROM communication_campaigns WHERE id = p_campaign_id;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Campaign not found');
END IF;
SELECT * INTO v_settings FROM communication_settings WHERE brand_id = v_campaign.brand_id;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'No communication settings for brand');
END IF;
v_audience := preview_campaign_audience(v_campaign.brand_id, v_campaign.audience_rules);
FOR v_entry IN SELECT * FROM jsonb_array_elements(coalesce(v_audience->'sample_customers', '[]'::jsonb))
LOOP
CONTINUE WHEN (v_entry->>'email') IS NULL OR (v_entry->>'email') = '';
v_entries := v_entries || jsonb_build_array(jsonb_build_object(
'brand_id', v_campaign.brand_id,
'campaign_id', p_campaign_id,
'customer_id', nullif(v_entry->>'id', ''),
'customer_email', v_entry->>'email',
'delivery_method','email',
'subject', v_campaign.subject,
'body_preview', left(v_campaign.body_text, 500),
'status', 'queued'
));
v_count := v_count + 1;
END LOOP;
PERFORM log_communication_messages(v_entries);
UPDATE communication_campaigns
SET status = 'sent', sent_at = now(), updated_at = now()
WHERE id = p_campaign_id;
RETURN jsonb_build_object('success', true, 'messages_logged', v_count);
END;
$$;
-- ── Update opt_out_customer to also set unsubscribed_at on contacts ───────────
DROP FUNCTION IF EXISTS public.opt_out_customer(UUID, UUID, TEXT);
CREATE OR REPLACE FUNCTION public.opt_out_customer(
p_customer_id UUID,
p_brand_id UUID,
p_method TEXT
)
RETURNS customer_communication_preferences
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE v_result customer_communication_preferences;
BEGIN
INSERT INTO public.customer_communication_preferences
(customer_id, brand_id, email_opt_in, sms_opt_in, email_opt_in_at, sms_opt_in_at)
VALUES (
p_customer_id, p_brand_id,
CASE WHEN p_method = 'email' THEN false ELSE true END,
CASE WHEN p_method = 'sms' THEN false ELSE true END,
CASE WHEN p_method = 'email' THEN now() ELSE NULL END,
CASE WHEN p_method = 'sms' THEN now() ELSE NULL END
)
ON CONFLICT (customer_id, brand_id) DO UPDATE SET
email_opt_in = CASE WHEN p_method = 'email' THEN false ELSE customer_communication_preferences.email_opt_in END,
sms_opt_in = CASE WHEN p_method = 'sms' THEN false ELSE customer_communication_preferences.sms_opt_in END,
email_opt_in_at = CASE WHEN p_method = 'email' THEN now() ELSE email_opt_in_at END,
sms_opt_in_at = CASE WHEN p_method = 'sms' THEN now() ELSE sms_opt_in_at END,
updated_at = now()
RETURNING * INTO v_result;
-- Also set unsubscribed_at on communication_contacts
IF p_method = 'email' THEN
UPDATE public.communication_contacts SET
email_opt_in = false,
email_opt_in_at = now(),
unsubscribed_at = now(),
updated_at = now()
WHERE customer_id = p_customer_id
AND brand_id = p_brand_id
AND unsubscribed_at IS NULL;
ELSIF p_method = 'sms' THEN
UPDATE public.communication_contacts SET
sms_opt_in = false,
sms_opt_in_at = now(),
unsubscribed_at = now(),
updated_at = now()
WHERE customer_id = p_customer_id
AND brand_id = p_brand_id
AND unsubscribed_at IS NULL;
END IF;
RETURN v_result;
END;
$$;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,230 @@
-- =============================================================================
-- Communication Center V1.2 — Import Metadata Storage
-- Fully idempotent: additive changes only
-- Updates import_communication_contacts_batch to store ignored CSV columns
-- in metadata.imported_raw so no context is lost from arbitrary extra columns.
-- =============================================================================
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- ── Update email path — add imported_raw to metadata on UPDATE ───────────────
-- Lines ~400-402 in migration 017: UPDATE path for existing email contact
CREATE OR REPLACE FUNCTION public.import_communication_contacts_batch(
p_brand_id UUID,
p_contacts JSONB,
p_allow_opt_in_override BOOLEAN DEFAULT false
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_entry JSONB;
v_result JSONB := '{"created": 0, "updated": 0, "skipped": 0, "errors": []}'::JSONB;
v_existing communication_contacts%ROWTYPE;
v_email TEXT;
v_phone TEXT;
v_tags TEXT[];
v_raw_meta JSONB;
BEGIN
FOR v_entry IN SELECT * FROM jsonb_array_elements(p_contacts)
LOOP
v_email := nullif(v_entry->>'email', '');
v_phone := nullif(v_entry->>'phone', '');
-- Parse tags: CSV sends "tag1;tag2" as a plain string; split into TEXT[]
IF (v_entry->>'tags') IS NOT NULL AND (v_entry->>'tags') != '' THEN
v_tags := coalesce(
(SELECT array_agg(trim(x)) FROM unnest(string_to_array(v_entry->>'tags', ';')) AS x WHERE trim(x) != ''),
'{}'
);
ELSE
v_tags := '{}';
END IF;
-- Build imported_raw from any _metadata key (ignored CSV columns)
v_raw_meta := nullif(v_entry->>'_metadata', '')::JSONB;
BEGIN
IF v_email IS NOT NULL AND v_email != '' THEN
SELECT * INTO v_existing
FROM public.communication_contacts
WHERE brand_id = p_brand_id AND email = v_email;
IF FOUND THEN
-- Existing contact
IF v_existing.unsubscribed_at IS NOT NULL AND
coalesce((v_entry->>'email_opt_in')::BOOLEAN, false) = true AND
p_allow_opt_in_override = false THEN
v_result := jsonb_set(v_result, '{skipped}',
to_jsonb((v_result->>'skipped')::INTEGER + 1));
ELSE
UPDATE public.communication_contacts SET
phone = COALESCE(nullif(v_entry->>'phone', ''), phone),
first_name = COALESCE(nullif(v_entry->>'first_name', ''), first_name),
last_name = COALESCE(nullif(v_entry->>'last_name', ''), last_name),
full_name = COALESCE(nullif(v_entry->>'full_name', ''), full_name),
source = 'import',
external_id = COALESCE(nullif(v_entry->>'external_id', ''), external_id),
email_opt_in = CASE
WHEN communication_contacts.unsubscribed_at IS NOT NULL
THEN communication_contacts.email_opt_in
ELSE coalesce(
(v_entry->>'email_opt_in')::BOOLEAN,
communication_contacts.email_opt_in,
true
)
END,
sms_opt_in = CASE
WHEN communication_contacts.unsubscribed_at IS NOT NULL
THEN communication_contacts.sms_opt_in
ELSE coalesce(
(v_entry->>'sms_opt_in')::BOOLEAN,
communication_contacts.sms_opt_in,
false
)
END,
email_opt_in_at = CASE
WHEN communication_contacts.unsubscribed_at IS NOT NULL
THEN email_opt_in_at
WHEN coalesce(
(v_entry->>'email_opt_in')::BOOLEAN,
communication_contacts.email_opt_in,
true
) = true THEN now()
ELSE email_opt_in_at
END,
tags = CASE
WHEN v_tags != '{}' THEN v_tags
ELSE communication_contacts.tags
END,
metadata = communication_contacts.metadata ||
jsonb_build_object(
'imported_at', now()::TEXT,
'imported_raw', coalesce(v_raw_meta, '{}'::JSONB)
),
updated_at = now()
WHERE brand_id = p_brand_id AND email = v_email;
v_result := jsonb_set(v_result, '{updated}',
to_jsonb((v_result->>'updated')::INTEGER + 1));
END IF;
ELSE
-- Insert new
INSERT INTO public.communication_contacts
(brand_id, email, phone, first_name, last_name, full_name, source,
external_id, email_opt_in, sms_opt_in, email_opt_in_at, sms_opt_in_at,
tags, metadata)
VALUES (
p_brand_id,
v_email,
nullif(v_entry->>'phone', ''),
nullif(v_entry->>'first_name', ''),
nullif(v_entry->>'last_name', ''),
nullif(v_entry->>'full_name', ''),
'import',
nullif(v_entry->>'external_id', ''),
coalesce((v_entry->>'email_opt_in')::BOOLEAN, true),
coalesce((v_entry->>'sms_opt_in')::BOOLEAN, false),
CASE WHEN coalesce((v_entry->>'email_opt_in')::BOOLEAN, true) = true THEN now() ELSE NULL END,
CASE WHEN coalesce((v_entry->>'sms_opt_in')::BOOLEAN, false) = true THEN now() ELSE NULL END,
v_tags,
jsonb_build_object(
'imported_at', now()::TEXT,
'imported_raw', coalesce(v_raw_meta, '{}'::JSONB)
)
);
v_result := jsonb_set(v_result, '{created}',
to_jsonb((v_result->>'created')::INTEGER + 1));
END IF;
ELSIF v_phone IS NOT NULL AND v_phone != '' THEN
-- Upsert by phone
SELECT * INTO v_existing
FROM public.communication_contacts
WHERE brand_id = p_brand_id AND phone = v_phone;
IF FOUND THEN
IF v_existing.unsubscribed_at IS NOT NULL AND
coalesce((v_entry->>'email_opt_in')::BOOLEAN, false) = true AND
p_allow_opt_in_override = false THEN
v_result := jsonb_set(v_result, '{skipped}',
to_jsonb((v_result->>'skipped')::INTEGER + 1));
ELSE
UPDATE public.communication_contacts SET
email = COALESCE(nullif(v_entry->>'email', ''), email),
first_name = COALESCE(nullif(v_entry->>'first_name', ''), first_name),
last_name = COALESCE(nullif(v_entry->>'last_name', ''), last_name),
full_name = COALESCE(nullif(v_entry->>'full_name', ''), full_name),
source = 'import',
email_opt_in = CASE
WHEN communication_contacts.unsubscribed_at IS NOT NULL
THEN communication_contacts.email_opt_in
ELSE coalesce(
(v_entry->>'email_opt_in')::BOOLEAN,
communication_contacts.email_opt_in,
true
)
END,
tags = CASE WHEN v_tags != '{}' THEN v_tags ELSE communication_contacts.tags END,
metadata = communication_contacts.metadata ||
jsonb_build_object(
'imported_at', now()::TEXT,
'imported_raw', coalesce(v_raw_meta, '{}'::JSONB)
),
updated_at = now()
WHERE brand_id = p_brand_id AND phone = v_phone;
v_result := jsonb_set(v_result, '{updated}',
to_jsonb((v_result->>'updated')::INTEGER + 1));
END IF;
ELSE
INSERT INTO public.communication_contacts
(brand_id, email, phone, first_name, last_name, full_name, source,
email_opt_in, sms_opt_in, tags, metadata)
VALUES (
p_brand_id,
nullif(v_entry->>'email', ''),
v_phone,
nullif(v_entry->>'first_name', ''),
nullif(v_entry->>'last_name', ''),
nullif(v_entry->>'full_name', ''),
'import',
coalesce((v_entry->>'email_opt_in')::BOOLEAN, true),
coalesce((v_entry->>'sms_opt_in')::BOOLEAN, false),
v_tags,
jsonb_build_object(
'imported_at', now()::TEXT,
'imported_raw', coalesce(v_raw_meta, '{}'::JSONB)
)
);
v_result := jsonb_set(v_result, '{created}',
to_jsonb((v_result->>'created')::INTEGER + 1));
END IF;
ELSE
-- No email or phone
v_result := jsonb_set(
v_result, '{errors}',
v_result->'errors' || jsonb_build_array(
jsonb_build_object('row', v_entry, 'error', 'No email or phone provided')
)
);
END IF;
EXCEPTION WHEN OTHERS THEN
v_result := jsonb_set(
v_result, '{errors}',
v_result->'errors' || jsonb_build_array(
jsonb_build_object('row', v_entry, 'error', SQLERRM)
)
);
END;
END LOOP;
RETURN v_result;
END;
$$;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,75 @@
-- =============================================================================
-- V1.2 Stage 1 — Canonical Customers Table
-- Fully idempotent: CREATE TABLE IF NOT EXISTS, CREATE INDEX IF NOT EXISTS
-- Purely additive: no changes to orders, communication_contacts, or checkout
--
-- STAGE 2 NOTE: When upserting customers in Stage 2, normalize BEFORE upsert:
-- - email: lowercase + trim
-- - phone: strip formatting chars [\s\-().[\]], preserve original if uncertain
-- This ensures consistent matching across orders, imports, and contacts.
-- =============================================================================
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- ── Table ────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.customers (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
primary_email TEXT,
primary_phone TEXT,
first_name TEXT,
last_name TEXT,
source TEXT NOT NULL DEFAULT 'system',
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT customers_email_or_phone CHECK (
primary_email IS NOT NULL OR primary_phone IS NOT NULL
)
);
-- ── Indexes ───────────────────────────────────────────────────────────────────
CREATE INDEX IF NOT EXISTS idx_customers_brand
ON public.customers(brand_id);
CREATE INDEX IF NOT EXISTS idx_customers_email
ON public.customers(primary_email) WHERE primary_email IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_customers_phone
ON public.customers(primary_phone) WHERE primary_phone IS NOT NULL;
-- Partial unique indexes: enforce one customer per brand per email/phone
-- PostgreSQL requires CREATE UNIQUE INDEX rather than CONSTRAINT for partial unique
CREATE UNIQUE INDEX IF NOT EXISTS idx_customers_brand_email_unique
ON public.customers(brand_id, primary_email)
WHERE primary_email IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_customers_brand_phone_unique
ON public.customers(brand_id, primary_phone)
WHERE primary_phone IS NOT NULL;
-- ── RLS ───────────────────────────────────────────────────────────────────────
ALTER TABLE public.customers ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "Brand admin can read customers"
ON public.customers;
CREATE POLICY "Brand admin can read customers"
ON public.customers FOR SELECT TO authenticated
USING (brand_id IN (
SELECT brand_id FROM admin_users
WHERE admin_users.user_id = auth.uid()
AND admin_users.role = 'brand_admin'
));
DROP POLICY IF EXISTS "Platform admin can read customers"
ON public.customers;
CREATE POLICY "Platform admin can read customers"
ON public.customers FOR SELECT TO authenticated
USING (EXISTS (
SELECT 1 FROM admin_users
WHERE admin_users.user_id = auth.uid()
AND admin_users.role = 'platform_admin'
));
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,226 @@
-- =============================================================================
-- V1.2 Stage 2 — Order/Contact/Customer Linking
-- Fully idempotent: CREATE OR REPLACE FUNCTION, no destructive operations
-- Purely additive behavior: no changes to orders schema, no FKs, no backfill
--
-- DEPENDENCY: Requires 019_customers_table.sql to be applied first.
-- Raises an exception if the customers table does not exist.
--
-- TRANSITIONAL ARCHITECTURE NOTE:
-- orders.customer_id and customers.id are separate ID namespaces today.
-- communication_contacts.customer_id links to customers.id for new order-created
-- contacts (via this migration), but orders.customer_id is NOT yet aligned.
--
-- A future stage (Stage 3+) should consider:
-- - backfilling customers from existing orders/communication_contacts
-- - aligning orders.customer_id to canonical customers.id
-- - adding proper FK constraints only after data is cleaned
-- - avoiding permanent dual customer ID namespaces
--
-- STAGE 2 NORMALIZATION:
-- email: trim(lower(...)) before match/upsert
-- phone: regexp_replace(... '[\s\-().[\]]' '' 'g') before match/upsert
-- This is consistent with frontend normalizeEmail/normalizePhone.
-- =============================================================================
-- ── Dependency guard ─────────────────────────────────────────────────────────
DO $$
BEGIN
IF to_regclass('public.customers') IS NULL THEN
RAISE EXCEPTION 'Migration 019_customers_table.sql must be applied before 020_order_customer_linking.sql';
END IF;
END;
$$;
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- ── Safe teardown order: trigger → functions ────────────────────────────────
-- PostgreSQL requires dropping a trigger before dropping the function it uses.
-- Order: DROP TRIGGER → DROP FUNCTION (helper) → DROP FUNCTION (trigger)
DROP TRIGGER IF EXISTS trg_create_contact_from_order ON public.orders;
DROP FUNCTION IF EXISTS public.upsert_customer_from_order(UUID, TEXT, TEXT, TEXT, TEXT);
DROP FUNCTION IF EXISTS public.upsert_contact_from_order();
-- ─────────────────────────────────────────────────────────────────────────────
-- upsert_customer_from_order
--
-- Resolves or creates a canonical customers record for a given order.
-- Returns the customers.id for linking, or NULL if no contact method exists.
--
-- Upsert order: email first → phone fallback (email is more stable identity)
-- Brand separation: brand_id is the scoping key throughout.
-- ─────────────────────────────────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION public.upsert_customer_from_order(
p_brand_id UUID,
p_customer_email TEXT,
p_customer_phone TEXT,
p_customer_name TEXT,
p_source TEXT DEFAULT 'order'
)
RETURNS UUID -- customers.id
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_norm_email TEXT := trim(lower(p_customer_email));
v_norm_phone TEXT := regexp_replace(p_customer_phone, '[\s\-().[\]]', '', 'g');
v_cust_id UUID;
BEGIN
-- No email AND no phone: nothing to upsert
IF (v_norm_email IS NULL OR v_norm_email = '') AND
(v_norm_phone IS NULL OR v_norm_phone = '') THEN
RETURN NULL;
END IF;
-- Try email match first (within brand)
IF v_norm_email IS NOT NULL AND v_norm_email != '' THEN
SELECT id INTO v_cust_id
FROM public.customers
WHERE brand_id = p_brand_id
AND primary_email = v_norm_email;
IF FOUND THEN
UPDATE public.customers SET
primary_phone = COALESCE(NULLIF(v_norm_phone, ''), primary_phone),
first_name = COALESCE(
NULLIF(split_part(p_customer_name, ' ', 1), ''),
customers.first_name
),
last_name = COALESCE(
NULLIF(substring(p_customer_name from position(' ' in p_customer_name) + 1
for length(p_customer_name)), ''),
customers.last_name
),
source = CASE
WHEN customers.source = 'system' THEN p_source ELSE customers.source
END,
updated_at = now()
WHERE id = v_cust_id;
RETURN v_cust_id;
END IF;
END IF;
-- Phone fallback (only if email didn't find a match)
IF v_cust_id IS NULL AND v_norm_phone IS NOT NULL AND v_norm_phone != '' THEN
SELECT id INTO v_cust_id
FROM public.customers
WHERE brand_id = p_brand_id
AND primary_phone = v_norm_phone;
IF FOUND THEN
UPDATE public.customers SET
primary_email = COALESCE(NULLIF(v_norm_email, ''), primary_email),
first_name = COALESCE(
NULLIF(split_part(p_customer_name, ' ', 1), ''),
customers.first_name
),
last_name = COALESCE(
NULLIF(substring(p_customer_name from position(' ' in p_customer_name) + 1
for length(p_customer_name)), ''),
customers.last_name
),
source = CASE
WHEN customers.source = 'system' THEN p_source ELSE customers.source
END,
updated_at = now()
WHERE id = v_cust_id;
RETURN v_cust_id;
END IF;
END IF;
-- Insert new customer
INSERT INTO public.customers
(brand_id, primary_email, primary_phone, first_name, last_name, source)
VALUES (
p_brand_id,
NULLIF(v_norm_email, ''),
NULLIF(v_norm_phone, ''),
NULLIF(split_part(p_customer_name, ' ', 1), ''),
NULLIF(substring(p_customer_name from position(' ' in p_customer_name) + 1
for length(p_customer_name)), ''),
p_source
)
RETURNING id INTO v_cust_id;
RETURN v_cust_id;
END;
$$;
-- ─────────────────────────────────────────────────────────────────────────────
-- upsert_contact_from_order trigger
--
-- NOW DOES TWO THINGS:
-- 1. Call upsert_customer_from_order() to get/create customers.id
-- 2. Use that customers.id as communication_contacts.customer_id (linking)
--
-- OPT-OUT PRESERVATION: email_opt_in, sms_opt_in, unsubscribed_at are NOT
-- updated on conflict — this behavior is unchanged from V1.1.
-- ─────────────────────────────────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION public.upsert_contact_from_order()
RETURNS TRIGGER
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_brand_id UUID;
v_customer_id UUID;
BEGIN
-- Resolve brand_id from the stop
SELECT brand_id INTO v_brand_id
FROM public.stops
WHERE id = NEW.stop_id;
IF NOT FOUND THEN
RETURN NEW;
END IF;
-- Step 1: upsert canonical customer (NEW.customer_id not referenced —
-- orders table does not have that column; use only email/phone/name)
v_customer_id := upsert_customer_from_order(
v_brand_id,
NEW.customer_email,
NEW.customer_phone,
NEW.customer_name,
'order'
);
-- Step 2: upsert communication contact, linked to customers.id
INSERT INTO public.communication_contacts
(brand_id, email, phone, full_name, source, customer_id, email_opt_in, metadata)
VALUES (
v_brand_id,
NEW.customer_email,
NEW.customer_phone,
NEW.customer_name,
'order',
v_customer_id,
true,
jsonb_build_object(
'last_order_id', NEW.id,
'last_order_at', now()::TEXT,
'stop_id', NEW.stop_id::TEXT
)
)
ON CONFLICT (brand_id, email) WHERE email IS NOT NULL DO UPDATE SET
full_name = COALESCE(EXCLUDED.full_name, communication_contacts.full_name),
phone = COALESCE(EXCLUDED.phone, communication_contacts.phone),
customer_id = COALESCE(EXCLUDED.customer_id, communication_contacts.customer_id),
metadata = jsonb_build_object(
'last_order_id', communication_contacts.metadata->>'last_order_id',
'last_order_at', communication_contacts.metadata->>'last_order_at',
'stop_id', NEW.stop_id::TEXT
),
updated_at = now();
-- email_opt_in, sms_opt_in, unsubscribed_at intentionally NOT updated (preserve opt-out)
RETURN NEW;
END;
$$;
-- ── Recreate trigger ─────────────────────────────────────────────────────────
CREATE TRIGGER trg_create_contact_from_order
AFTER INSERT ON public.orders
FOR EACH ROW
EXECUTE FUNCTION public.upsert_contact_from_order();
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,319 @@
-- =============================================================================
-- V1.2 Stage 3 — Shipping-Only Brand Resolution
-- Fully idempotent: CREATE OR REPLACE FUNCTION, ALTER TABLE ADD COLUMN IF NOT EXISTS
--
-- DEPENDENCY: Requires 020_order_customer_linking.sql to be applied first.
-- Raises an exception if the trigger function does not exist.
--
-- CHANGES:
-- 1. Add brand_id column to orders (nullable, no FK — aligned to products.brand_id)
-- 2. Modify create_order_with_items to accept NULL stop_id for shipping-only
-- 3. Update upsert_contact_from_order trigger to resolve brand from NEW.brand_id
-- first, falling back to stop lookup — supports both pickup and shipping orders
-- =============================================================================
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_proc p
JOIN pg_namespace n ON n.oid = p.pronamespace
WHERE n.nspname = 'public'
AND p.proname = 'upsert_contact_from_order'
) THEN
RAISE EXCEPTION 'Migration 020_order_customer_linking.sql must be applied before 021_shipping_only_brand.sql';
END IF;
END;
$$;
-- ── 1. Add brand_id to orders ────────────────────────────────────────────────
-- Allows the order/contact trigger to resolve brand without needing a stop_id.
-- For shipping-only orders, brand is derived from the first product.
ALTER TABLE orders ADD COLUMN IF NOT EXISTS brand_id UUID;
-- ── 2. Modify create_order_with_items for shipping-only ────────────────────
-- stop_id is now nullable. When NULL (shipping-only), brand is derived from
-- the first product's brand and no stop validation occurs.
CREATE OR REPLACE FUNCTION create_order_with_items(
p_idempotency_key UUID,
p_customer_name TEXT,
p_customer_email TEXT,
p_customer_phone TEXT,
p_stop_id UUID, -- nullable for shipping-only
p_items JSONB -- [{id: uuid, quantity: int, fulfillment: text}]
)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_order_id UUID;
v_item JSONB;
v_product_id UUID;
v_quantity INT;
v_fulfillment TEXT;
v_product_price NUMERIC;
v_computed_total NUMERIC := 0;
v_stop_active BOOLEAN;
v_stop_brand_id UUID;
v_stop_city TEXT;
v_stop_state TEXT;
v_stop_date TEXT;
v_stop_time TEXT;
v_stop_location TEXT;
v_product_active BOOLEAN;
v_product_brand_id UUID;
v_product_name TEXT;
v_is_pickup BOOLEAN;
v_order JSONB;
v_order_items JSONB := '[]'::JSONB;
v_brand_id UUID;
BEGIN
-- ── Idempotency guard ─────────────────────────────────────────────────────
SELECT jsonb_build_object(
'id', o.id,
'customer_name', o.customer_name,
'customer_email', o.customer_email,
'customer_phone', o.customer_phone,
'subtotal', o.subtotal,
'status', o.status,
'stop_id', o.stop_id,
'brand_id', o.brand_id,
'stop_city', s.city,
'stop_state', s.state,
'stop_date', s.date,
'stop_time', s.time,
'stop_location', s.location,
'items', COALESCE((
SELECT jsonb_agg(jsonb_build_object(
'product_id', oi.product_id,
'product_name', p.name,
'quantity', oi.quantity,
'price', oi.price,
'fulfillment', oi.fulfillment
))
FROM order_items oi
JOIN products p ON oi.product_id = p.id
WHERE oi.order_id = o.id
), '[]'::JSONB)
)
INTO v_order
FROM orders o
LEFT JOIN stops s ON o.stop_id = s.id
WHERE o.idempotency_key = p_idempotency_key
LIMIT 1;
IF v_order IS NOT NULL THEN
RETURN v_order;
END IF;
-- ── Resolve brand_id ──────────────────────────────────────────────────────
-- For shipping-only orders (p_stop_id IS NULL), derive from the first product.
-- For pickup orders, derive from the stop.
IF p_stop_id IS NOT NULL THEN
SELECT active, brand_id, city, state, date, time, location
INTO v_stop_active, v_stop_brand_id, v_stop_city, v_stop_state, v_stop_date, v_stop_time, v_stop_location
FROM stops
WHERE id = p_stop_id;
IF v_stop_brand_id IS NULL THEN
RAISE EXCEPTION 'Stop not found: %', p_stop_id;
END IF;
IF NOT v_stop_active THEN
RAISE EXCEPTION 'Stop is not active: %', p_stop_id;
END IF;
v_brand_id := v_stop_brand_id;
ELSE
-- Shipping-only: resolve brand from first product in the order
v_product_id := (jsonb_array_elements(p_items)->>'id')::UUID;
SELECT brand_id INTO v_brand_id
FROM products
WHERE id = v_product_id;
IF v_brand_id IS NULL THEN
RAISE EXCEPTION 'Product not found: %', v_product_id;
END IF;
END IF;
-- ── Validate items and compute subtotal ─────────────────────────────────
FOR v_item IN SELECT * FROM jsonb_array_elements(p_items)
LOOP
v_product_id := (v_item->>'id')::UUID;
v_quantity := (v_item->>'quantity')::INT;
v_fulfillment := v_item->>'fulfillment';
v_is_pickup := (v_fulfillment = 'pickup');
SELECT active, brand_id, price, name
INTO v_product_active, v_product_brand_id, v_product_price, v_product_name
FROM products
WHERE id = v_product_id;
IF v_product_brand_id IS NULL THEN
RAISE EXCEPTION 'Product not found: %', v_product_id;
END IF;
IF v_product_brand_id != v_brand_id THEN
RAISE EXCEPTION 'Product % does not belong to brand %', v_product_id, v_brand_id;
END IF;
IF NOT v_product_active THEN
RAISE EXCEPTION 'Product is not active: %', v_product_id;
END IF;
IF v_is_pickup THEN
-- Pickup items require a valid stop assignment
IF p_stop_id IS NULL THEN
RAISE EXCEPTION 'Pickup item % requires a stop selection', v_product_id;
END IF;
PERFORM 1
FROM product_stops
WHERE product_id = v_product_id AND stop_id = p_stop_id
LIMIT 1;
IF NOT FOUND THEN
RAISE EXCEPTION 'Pickup product % is not available at stop %', v_product_id, p_stop_id;
END IF;
END IF;
v_computed_total := v_computed_total + (v_product_price * v_quantity);
END LOOP;
-- ── Create order (with brand_id) ─────────────────────────────────────────
INSERT INTO orders (
idempotency_key, customer_name, customer_email, customer_phone,
stop_id, brand_id, subtotal, status
) VALUES (
p_idempotency_key, p_customer_name, p_customer_email, p_customer_phone,
p_stop_id, v_brand_id, v_computed_total, 'pending'
)
RETURNING id INTO v_order_id;
-- ── Insert order items and build return value ─────────────────────────────
FOR v_item IN SELECT * FROM jsonb_array_elements(p_items)
LOOP
v_product_id := (v_item->>'id')::UUID;
v_quantity := (v_item->>'quantity')::INT;
v_fulfillment := v_item->>'fulfillment';
SELECT price, name INTO v_product_price, v_product_name FROM products WHERE id = v_product_id;
INSERT INTO order_items (order_id, product_id, quantity, fulfillment, price)
VALUES (v_order_id, v_product_id, v_quantity, v_fulfillment, v_product_price);
v_order_items := v_order_items || jsonb_build_object(
'product_id', v_product_id,
'product_name', v_product_name,
'quantity', v_quantity,
'price', v_product_price,
'fulfillment', v_fulfillment
);
END LOOP;
-- ── Build and return full order object ───────────────────────────────────
RETURN jsonb_build_object(
'id', v_order_id,
'customer_name', p_customer_name,
'customer_email', p_customer_email,
'customer_phone', p_customer_phone,
'subtotal', v_computed_total,
'status', 'pending',
'stop_id', p_stop_id,
'brand_id', v_brand_id,
'stop_city', v_stop_city,
'stop_state', v_stop_state,
'stop_date', v_stop_date,
'stop_time', v_stop_time,
'stop_location', v_stop_location,
'items', v_order_items
);
END;
$$;
-- ── 3. Update trigger to resolve brand from NEW.brand_id first ───────────────
-- NEW.brand_id is the primary source (always set by create_order_with_items).
-- Stop lookup is the fallback (for pre-existing orders that lack brand_id).
DROP TRIGGER IF EXISTS trg_create_contact_from_order ON public.orders;
DROP FUNCTION IF EXISTS public.upsert_contact_from_order();
CREATE OR REPLACE FUNCTION public.upsert_contact_from_order()
RETURNS TRIGGER
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_brand_id UUID;
v_customer_id UUID;
BEGIN
-- Primary: resolve brand_id from the order itself (NEW.brand_id, always set in Stage 3+)
IF NEW.brand_id IS NOT NULL THEN
v_brand_id := NEW.brand_id;
ELSE
-- Fallback: resolve via stop (for pre-Stage 3 orders without brand_id)
SELECT brand_id INTO v_brand_id
FROM public.stops
WHERE id = NEW.stop_id;
IF NOT FOUND THEN
RETURN NEW; -- cannot resolve brand: skip customer/contact linking
END IF;
END IF;
-- No email AND no phone: nothing to upsert
IF (NEW.customer_email IS NULL OR NEW.customer_email = '') AND
(NEW.customer_phone IS NULL OR NEW.customer_phone = '') THEN
RETURN NEW;
END IF;
-- Step 1: upsert canonical customer
v_customer_id := upsert_customer_from_order(
v_brand_id,
NEW.customer_email,
NEW.customer_phone,
NEW.customer_name,
'order'
);
-- Step 2: upsert communication contact, linked to customers.id
INSERT INTO public.communication_contacts
(brand_id, email, phone, full_name, source, customer_id, email_opt_in, metadata)
VALUES (
v_brand_id,
NEW.customer_email,
NEW.customer_phone,
NEW.customer_name,
'order',
v_customer_id,
true,
jsonb_build_object(
'last_order_id', NEW.id,
'last_order_at', now()::TEXT,
'stop_id', NEW.stop_id::TEXT
)
)
ON CONFLICT (brand_id, email) WHERE email IS NOT NULL DO UPDATE SET
full_name = COALESCE(EXCLUDED.full_name, communication_contacts.full_name),
phone = COALESCE(EXCLUDED.phone, communication_contacts.phone),
customer_id = COALESCE(EXCLUDED.customer_id, communication_contacts.customer_id),
metadata = jsonb_build_object(
'last_order_id', communication_contacts.metadata->>'last_order_id',
'last_order_at', communication_contacts.metadata->>'last_order_at',
'stop_id', NEW.stop_id::TEXT
),
updated_at = now();
-- email_opt_in, sms_opt_in, unsubscribed_at intentionally NOT updated (preserve opt-out)
RETURN NEW;
END;
$$;
CREATE TRIGGER trg_create_contact_from_order
AFTER INSERT ON public.orders
FOR EACH ROW
EXECUTE FUNCTION public.upsert_contact_from_order();
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,646 @@
-- ─────────────────────────────────────────────────────────────────────────────
-- Migration 022: Operational Events
-- ─────────────────────────────────────────────────────────────────────────────
-- Append-only event layer for recording important platform actions.
-- Scope: table + indexes + RLS + helpers + 4 initial emitters.
-- What NOT included: automation, queues, webhooks, retries, cron jobs.
-- ═══════════════════════════════════════════════════════════════════════════
-- 1. operational_events table
-- ═══════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS public.operational_events (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
event_type TEXT NOT NULL,
entity_type TEXT,
entity_id UUID,
actor_type TEXT,
actor_id UUID,
source TEXT NOT NULL DEFAULT 'system',
payload JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT operational_events_event_type CHECK (
event_type ~ '^[a-z][a-z0-9_]*$'
)
);
-- ═══════════════════════════════════════════════════════════════════════════
-- 2. Indexes
-- ═══════════════════════════════════════════════════════════════════════════
CREATE INDEX IF NOT EXISTS idx_oe_brand_id ON operational_events(brand_id);
CREATE INDEX IF NOT EXISTS idx_oe_event_type ON operational_events(event_type);
CREATE INDEX IF NOT EXISTS idx_oe_entity ON operational_events(entity_type, entity_id);
CREATE INDEX IF NOT EXISTS idx_oe_created_at ON operational_events(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_oe_brand_type ON operational_events(brand_id, event_type, created_at DESC);
-- ═══════════════════════════════════════════════════════════════════════════
-- 3. RLS policies
-- ═══════════════════════════════════════════════════════════════════════════
ALTER TABLE operational_events ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "Brand admin can read operational_events" ON operational_events;
CREATE POLICY "Brand admin can read operational_events"
ON operational_events FOR SELECT TO authenticated
USING (
brand_id IN (
SELECT brand_id FROM admin_users
WHERE admin_users.user_id = auth.uid()
AND admin_users.role = 'brand_admin'
)
);
DROP POLICY IF EXISTS "Platform admin can read operational_events" ON operational_events;
CREATE POLICY "Platform admin can read operational_events"
ON operational_events FOR SELECT TO authenticated
USING (
EXISTS (
SELECT 1 FROM admin_users
WHERE admin_users.user_id = auth.uid()
AND admin_users.role = 'platform_admin'
)
);
-- Writes go through SECURITY DEFINER helpers only.
-- No INSERT granted to authenticated roles.
-- ═══════════════════════════════════════════════════════════════════════════
-- 4. record_operational_event helper — bypasses RLS, all emitters call this
-- ═══════════════════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.record_operational_event(
p_brand_id UUID,
p_event_type TEXT,
p_entity_type TEXT,
p_entity_id UUID,
p_actor_type TEXT,
p_actor_id UUID,
p_source TEXT,
p_payload JSONB
)
RETURNS void
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
INSERT INTO operational_events (
brand_id, event_type, entity_type, entity_id,
actor_type, actor_id, source, payload
) VALUES (
p_brand_id, p_event_type, p_entity_type, p_entity_id,
p_actor_type, p_actor_id, p_source, p_payload
);
END;
$$;
-- ═══════════════════════════════════════════════════════════════════════════
-- 5. record_pickup_completed_event helper — called from TypeScript action
-- ═══════════════════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.record_pickup_completed_event(
p_order_id UUID,
p_brand_id UUID,
p_actor_id UUID
)
RETURNS void
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_order_data JSONB;
BEGIN
SELECT jsonb_build_object(
'subtotal', subtotal,
'customer_name', customer_name
) INTO v_order_data
FROM orders
WHERE id = p_order_id;
PERFORM record_operational_event(
p_brand_id,
'pickup_completed',
'order',
p_order_id,
'admin',
p_actor_id,
'system',
coalesce(v_order_data, '{}'::JSONB)
);
END;
$$;
-- ═══════════════════════════════════════════════════════════════════════════
-- 6. create_order_with_items — with order_placed emitter
-- (explicit replace; same signature as migration 021)
-- ═══════════════════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION create_order_with_items(
p_idempotency_key UUID,
p_customer_name TEXT,
p_customer_email TEXT,
p_customer_phone TEXT,
p_stop_id UUID,
p_items JSONB
)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_order_id UUID;
v_item JSONB;
v_product_id UUID;
v_quantity INT;
v_fulfillment TEXT;
v_product_price NUMERIC;
v_computed_total NUMERIC := 0;
v_stop_active BOOLEAN;
v_stop_brand_id UUID;
v_stop_city TEXT;
v_stop_state TEXT;
v_stop_date TEXT;
v_stop_time TEXT;
v_stop_location TEXT;
v_product_active BOOLEAN;
v_product_brand_id UUID;
v_product_name TEXT;
v_is_pickup BOOLEAN;
v_has_pickup BOOLEAN := false;
v_order JSONB;
v_order_items JSONB := '[]'::JSONB;
v_brand_id UUID;
BEGIN
-- ── Idempotency guard ─────────────────────────────────────────────────────
SELECT jsonb_build_object(
'id', o.id,
'customer_name', o.customer_name,
'customer_email', o.customer_email,
'customer_phone', o.customer_phone,
'subtotal', o.subtotal,
'status', o.status,
'stop_id', o.stop_id,
'brand_id', o.brand_id,
'stop_city', s.city,
'stop_state', s.state,
'stop_date', s.date,
'stop_time', s.time,
'stop_location', s.location,
'items', COALESCE((
SELECT jsonb_agg(jsonb_build_object(
'product_id', oi.product_id,
'product_name', p.name,
'quantity', oi.quantity,
'price', oi.price,
'fulfillment', oi.fulfillment
))
FROM order_items oi
JOIN products p ON oi.product_id = p.id
WHERE oi.order_id = o.id
), '[]'::JSONB)
)
INTO v_order
FROM orders o
LEFT JOIN stops s ON o.stop_id = s.id
WHERE o.idempotency_key = p_idempotency_key
LIMIT 1;
IF v_order IS NOT NULL THEN
RETURN v_order;
END IF;
-- ── Resolve brand_id ──────────────────────────────────────────────────────
IF p_stop_id IS NOT NULL THEN
SELECT active, brand_id, city, state, date, time, location
INTO v_stop_active, v_stop_brand_id, v_stop_city, v_stop_state, v_stop_date, v_stop_time, v_stop_location
FROM stops
WHERE id = p_stop_id;
IF v_stop_brand_id IS NULL THEN
RAISE EXCEPTION 'Stop not found: %', p_stop_id;
END IF;
IF NOT v_stop_active THEN
RAISE EXCEPTION 'Stop is not active: %', p_stop_id;
END IF;
v_brand_id := v_stop_brand_id;
ELSE
v_product_id := (jsonb_array_elements(p_items)->>'id')::UUID;
SELECT brand_id INTO v_brand_id
FROM products
WHERE id = v_product_id;
IF v_brand_id IS NULL THEN
RAISE EXCEPTION 'Product not found: %', v_product_id;
END IF;
END IF;
-- ── Validate items and compute subtotal ─────────────────────────────────
FOR v_item IN SELECT * FROM jsonb_array_elements(p_items)
LOOP
v_product_id := (v_item->>'id')::UUID;
v_quantity := (v_item->>'quantity')::INT;
v_fulfillment := v_item->>'fulfillment';
v_is_pickup := (v_fulfillment = 'pickup');
IF v_is_pickup THEN
v_has_pickup := true;
END IF;
SELECT active, brand_id, price, name
INTO v_product_active, v_product_brand_id, v_product_price, v_product_name
FROM products
WHERE id = v_product_id;
IF v_product_brand_id IS NULL THEN
RAISE EXCEPTION 'Product not found: %', v_product_id;
END IF;
IF v_product_brand_id != v_brand_id THEN
RAISE EXCEPTION 'Product % does not belong to brand %', v_product_id, v_brand_id;
END IF;
IF NOT v_product_active THEN
RAISE EXCEPTION 'Product is not active: %', v_product_id;
END IF;
IF v_is_pickup THEN
IF p_stop_id IS NULL THEN
RAISE EXCEPTION 'Pickup item % requires a stop selection', v_product_id;
END IF;
PERFORM 1
FROM product_stops
WHERE product_id = v_product_id AND stop_id = p_stop_id
LIMIT 1;
IF NOT FOUND THEN
RAISE EXCEPTION 'Pickup product % is not available at stop %', v_product_id, p_stop_id;
END IF;
END IF;
v_computed_total := v_computed_total + (v_product_price * v_quantity);
END LOOP;
-- ── Create order (with brand_id) ─────────────────────────────────────────
INSERT INTO orders (
idempotency_key, customer_name, customer_email, customer_phone,
stop_id, brand_id, subtotal, status
) VALUES (
p_idempotency_key, p_customer_name, p_customer_email, p_customer_phone,
p_stop_id, v_brand_id, v_computed_total, 'pending'
)
RETURNING id INTO v_order_id;
-- ── Insert order items and build return value ─────────────────────────────
FOR v_item IN SELECT * FROM jsonb_array_elements(p_items)
LOOP
v_product_id := (v_item->>'id')::UUID;
v_quantity := (v_item->>'quantity')::INT;
v_fulfillment := v_item->>'fulfillment';
SELECT price, name INTO v_product_price, v_product_name FROM products WHERE id = v_product_id;
INSERT INTO order_items (order_id, product_id, quantity, fulfillment, price)
VALUES (v_order_id, v_product_id, v_quantity, v_fulfillment, v_product_price);
v_order_items := v_order_items || jsonb_build_object(
'product_id', v_product_id,
'product_name', v_product_name,
'quantity', v_quantity,
'price', v_product_price,
'fulfillment', v_fulfillment
);
END LOOP;
-- ── Emit order_placed event ───────────────────────────────────────────────
PERFORM record_operational_event(
v_brand_id,
'order_placed',
'order',
v_order_id,
'customer',
NULL,
'system',
jsonb_build_object(
'subtotal', v_computed_total,
'item_count', jsonb_array_length(p_items),
'has_pickup', v_has_pickup
)
);
-- ── Build and return full order object ───────────────────────────────────
RETURN jsonb_build_object(
'id', v_order_id,
'customer_name', p_customer_name,
'customer_email', p_customer_email,
'customer_phone', p_customer_phone,
'subtotal', v_computed_total,
'status', 'pending',
'stop_id', p_stop_id,
'brand_id', v_brand_id,
'stop_city', v_stop_city,
'stop_state', v_stop_state,
'stop_date', v_stop_date,
'stop_time', v_stop_time,
'stop_location', v_stop_location,
'items', v_order_items
);
END;
$$;
-- ═══════════════════════════════════════════════════════════════════════════
-- 7. import_communication_contacts_batch — with contact_imported emitter
-- (explicit replace; same signature as migrations 017/018)
-- ═══════════════════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.import_communication_contacts_batch(
p_brand_id UUID,
p_contacts JSONB,
p_allow_opt_in_override BOOLEAN DEFAULT false
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_entry JSONB;
v_result JSONB := '{"created": 0, "updated": 0, "skipped": 0, "errors": []}'::JSONB;
v_existing communication_contacts%ROWTYPE;
v_email TEXT;
v_phone TEXT;
v_tags TEXT[];
BEGIN
FOR v_entry IN SELECT * FROM jsonb_array_elements(p_contacts)
LOOP
v_email := nullif(v_entry->>'email', '');
v_phone := nullif(v_entry->>'phone', '');
IF (v_entry->>'tags') IS NOT NULL AND (v_entry->>'tags') != '' THEN
v_tags := coalesce(
(SELECT array_agg(trim(x)) FROM unnest(string_to_array(v_entry->>'tags', ';')) AS x WHERE trim(x) != ''),
'{}'
);
ELSE
v_tags := '{}';
END IF;
BEGIN
IF v_email IS NOT NULL AND v_email != '' THEN
SELECT * INTO v_existing
FROM public.communication_contacts
WHERE brand_id = p_brand_id AND email = v_email;
IF FOUND THEN
IF v_existing.unsubscribed_at IS NOT NULL AND
coalesce((v_entry->>'email_opt_in')::BOOLEAN, false) = true AND
p_allow_opt_in_override = false THEN
v_result := jsonb_set(v_result, '{skipped}',
to_jsonb((v_result->>'skipped')::INTEGER + 1));
ELSE
UPDATE public.communication_contacts SET
phone = COALESCE(nullif(v_entry->>'phone', ''), phone),
first_name = COALESCE(nullif(v_entry->>'first_name', ''), first_name),
last_name = COALESCE(nullif(v_entry->>'last_name', ''), last_name),
full_name = COALESCE(nullif(v_entry->>'full_name', ''), full_name),
source = 'import',
external_id = COALESCE(nullif(v_entry->>'external_id', ''), external_id),
email_opt_in = CASE
WHEN communication_contacts.unsubscribed_at IS NOT NULL
THEN communication_contacts.email_opt_in
ELSE coalesce(
(v_entry->>'email_opt_in')::BOOLEAN,
communication_contacts.email_opt_in,
true
)
END,
sms_opt_in = CASE
WHEN communication_contacts.unsubscribed_at IS NOT NULL
THEN communication_contacts.sms_opt_in
ELSE coalesce(
(v_entry->>'sms_opt_in')::BOOLEAN,
communication_contacts.sms_opt_in,
false
)
END,
email_opt_in_at = CASE
WHEN communication_contacts.unsubscribed_at IS NOT NULL
THEN email_opt_in_at
WHEN coalesce(
(v_entry->>'email_opt_in')::BOOLEAN,
communication_contacts.email_opt_in,
true
) = true THEN now()
ELSE email_opt_in_at
END,
tags = CASE
WHEN v_tags != '{}' THEN v_tags
ELSE communication_contacts.tags
END,
metadata = communication_contacts.metadata || jsonb_build_object(
'imported_at', now()::TEXT
),
updated_at = now()
WHERE brand_id = p_brand_id AND email = v_email;
v_result := jsonb_set(v_result, '{updated}',
to_jsonb((v_result->>'updated')::INTEGER + 1));
END IF;
ELSE
INSERT INTO public.communication_contacts
(brand_id, email, phone, first_name, last_name, full_name, source,
external_id, email_opt_in, sms_opt_in, email_opt_in_at, sms_opt_in_at,
tags, metadata)
VALUES (
p_brand_id,
v_email,
nullif(v_entry->>'phone', ''),
nullif(v_entry->>'first_name', ''),
nullif(v_entry->>'last_name', ''),
nullif(v_entry->>'full_name', ''),
'import',
nullif(v_entry->>'external_id', ''),
coalesce((v_entry->>'email_opt_in')::BOOLEAN, true),
coalesce((v_entry->>'sms_opt_in')::BOOLEAN, false),
CASE WHEN coalesce((v_entry->>'email_opt_in')::BOOLEAN, true) = true THEN now() ELSE NULL END,
CASE WHEN coalesce((v_entry->>'sms_opt_in')::BOOLEAN, false) = true THEN now() ELSE NULL END,
v_tags,
jsonb_build_object('imported_at', now()::TEXT)
);
v_result := jsonb_set(v_result, '{created}',
to_jsonb((v_result->>'created')::INTEGER + 1));
END IF;
ELSIF v_phone IS NOT NULL AND v_phone != '' THEN
SELECT * INTO v_existing
FROM public.communication_contacts
WHERE brand_id = p_brand_id AND phone = v_phone;
IF FOUND THEN
IF v_existing.unsubscribed_at IS NOT NULL AND
coalesce((v_entry->>'email_opt_in')::BOOLEAN, false) = true AND
p_allow_opt_in_override = false THEN
v_result := jsonb_set(v_result, '{skipped}',
to_jsonb((v_result->>'skipped')::INTEGER + 1));
ELSE
UPDATE public.communication_contacts SET
email = COALESCE(nullif(v_entry->>'email', ''), email),
first_name = COALESCE(nullif(v_entry->>'first_name', ''), first_name),
last_name = COALESCE(nullif(v_entry->>'last_name', ''), last_name),
full_name = COALESCE(nullif(v_entry->>'full_name', ''), full_name),
source = 'import',
email_opt_in = CASE
WHEN communication_contacts.unsubscribed_at IS NOT NULL
THEN communication_contacts.email_opt_in
ELSE coalesce(
(v_entry->>'email_opt_in')::BOOLEAN,
communication_contacts.email_opt_in,
true
)
END,
tags = CASE WHEN v_tags != '{}' THEN v_tags ELSE communication_contacts.tags END,
metadata = communication_contacts.metadata || jsonb_build_object(
'imported_at', now()::TEXT
),
updated_at = now()
WHERE brand_id = p_brand_id AND phone = v_phone;
v_result := jsonb_set(v_result, '{updated}',
to_jsonb((v_result->>'updated')::INTEGER + 1));
END IF;
ELSE
INSERT INTO public.communication_contacts
(brand_id, email, phone, first_name, last_name, full_name, source,
email_opt_in, sms_opt_in, tags, metadata)
VALUES (
p_brand_id,
nullif(v_entry->>'email', ''),
v_phone,
nullif(v_entry->>'first_name', ''),
nullif(v_entry->>'last_name', ''),
nullif(v_entry->>'full_name', ''),
'import',
coalesce((v_entry->>'email_opt_in')::BOOLEAN, true),
coalesce((v_entry->>'sms_opt_in')::BOOLEAN, false),
v_tags,
jsonb_build_object('imported_at', now()::TEXT)
);
v_result := jsonb_set(v_result, '{created}',
to_jsonb((v_result->>'created')::INTEGER + 1));
END IF;
ELSE
v_result := jsonb_set(
v_result, '{errors}',
v_result->'errors' || jsonb_build_array(
jsonb_build_object('row', v_entry, 'error', 'No email or phone provided')
)
);
END IF;
EXCEPTION WHEN OTHERS THEN
v_result := jsonb_set(
v_result, '{errors}',
v_result->'errors' || jsonb_build_array(
jsonb_build_object('row', v_entry, 'error', SQLERRM)
)
);
END;
END LOOP;
-- ── Emit contact_imported event ──────────────────────────────────────────
PERFORM record_operational_event(
p_brand_id,
'contact_imported',
NULL,
NULL,
'admin',
NULL,
'system',
jsonb_build_object(
'created', (v_result->>'created')::INTEGER,
'updated', (v_result->>'updated')::INTEGER,
'skipped', (v_result->>'skipped')::INTEGER,
'total',
(v_result->>'created')::INTEGER
+ (v_result->>'updated')::INTEGER
+ (v_result->>'skipped')::INTEGER
)
);
RETURN v_result;
END;
$$;
-- ═══════════════════════════════════════════════════════════════════════════
-- 8. send_campaign — with campaign_sent emitter
-- (explicit replace; same body as migration 017)
-- ═══════════════════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.send_campaign(p_campaign_id UUID)
RETURNS jsonb
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_campaign communication_campaigns;
v_settings communication_settings;
v_audience JSONB;
v_entry JSONB;
v_entries JSONB := '[]'::JSONB;
v_count INTEGER := 0;
BEGIN
SELECT * INTO v_campaign FROM communication_campaigns WHERE id = p_campaign_id;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Campaign not found');
END IF;
SELECT * INTO v_settings FROM communication_settings WHERE brand_id = v_campaign.brand_id;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'No communication settings for brand');
END IF;
v_audience := preview_campaign_audience(v_campaign.brand_id, v_campaign.audience_rules);
FOR v_entry IN SELECT * FROM jsonb_array_elements(coalesce(v_audience->'sample_customers', '[]'::jsonb))
LOOP
CONTINUE WHEN (v_entry->>'email') IS NULL OR (v_entry->>'email') = '';
v_entries := v_entries || jsonb_build_array(jsonb_build_object(
'brand_id', v_campaign.brand_id,
'campaign_id', p_campaign_id,
'customer_id', nullif(v_entry->>'id', ''),
'customer_email', v_entry->>'email',
'delivery_method','email',
'subject', v_campaign.subject,
'body_preview', left(v_campaign.body_text, 500),
'status', 'queued'
));
v_count := v_count + 1;
END LOOP;
PERFORM log_communication_messages(v_entries);
UPDATE communication_campaigns
SET status = 'sent', sent_at = now(), updated_at = now()
WHERE id = p_campaign_id;
-- ── Emit campaign_sent event ────────────────────────────────────────────
PERFORM record_operational_event(
v_campaign.brand_id,
'campaign_sent',
'campaign',
p_campaign_id,
'system',
NULL,
'system',
jsonb_build_object(
'messages_logged', v_count,
'subject', v_campaign.subject
)
);
RETURN jsonb_build_object('success', true, 'messages_logged', v_count);
END;
$$;
@@ -0,0 +1,56 @@
-- Migration 023: Fix cart availability check
-- Replaces unreliable client-side product_stops query with a
-- SECURITY DEFINER RPC that bypasses RLS and returns structured availability.
--
-- The cart page's availability check was:
-- 1. Unreliable — anon/frontend query may be blocked by RLS or return empty
-- 2. Conflating "no rows" with "product is unavailable"
-- 3. Blocking all stops even when the query itself failed
--
-- This adds: check_stop_product_availability(p_stop_id, p_product_ids)
-- Returns: { product_id, is_available }[] for each requested product.
-- The cart page uses this to show truly incompatible items separately
-- from query errors.
-- ═══════════════════════════════════════════════════════════════════════════
-- 1. check_stop_product_availability RPC
-- ═══════════════════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.check_stop_product_availability(
p_stop_id UUID,
p_product_ids UUID[]
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_result JSONB := '[]'::JSONB;
v_pid UUID;
BEGIN
FOR v_pid IN SELECT unnest(p_product_ids)
LOOP
v_result := v_result || jsonb_build_array(jsonb_build_object(
'product_id', v_pid,
'is_available', EXISTS(
SELECT 1 FROM product_stops
WHERE stop_id = p_stop_id AND product_id = v_pid
)
));
END LOOP;
RETURN v_result;
END;
$$;
-- ═══════════════════════════════════════════════════════════════════════════
-- 2. Update cart/page.tsx to use the RPC + show query errors distinctly
-- (done in the application code, not the migration)
-- ═══════════════════════════════════════════════════════════════════════════
--
-- Changes to src/app/cart/page.tsx:
-- - handleStopSelect: POST to check_stop_product_availability RPC instead of
-- direct product_stops query. Handle errors distinctly from unavailability.
-- - Add availabilityError state — if RPC fails, show "Unable to verify
-- availability" but allow checkout to proceed (server will catch true errors).
-- - Add per-product availabilityError flag to distinguish query failure
-- from confirmed unavailability.
@@ -0,0 +1,146 @@
-- Migration 024: Stop-product assignment via SECURITY DEFINER RPCs
-- Replaces direct INSERT/DELETE on product_stops (blocked by RLS)
-- with admin-authorized RPCs that validate brand alignment.
-- ═══════════════════════════════════════════════════════════════════════════
-- 1. assign_product_to_stop
-- Idempotent: if row already exists, returns the existing row.
-- Validates: stop exists, product exists, brands match.
-- Authorizes: platform_admin (all brands) or brand_admin (own brand only).
-- ═══════════════════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.assign_product_to_stop(
p_stop_id UUID,
p_product_id UUID
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_stop_brand_id UUID;
v_product_brand_id UUID;
v_existing UUID;
v_result JSONB;
BEGIN
-- Verify stop exists and get its brand
SELECT brand_id INTO v_stop_brand_id
FROM stops
WHERE id = p_stop_id;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Stop not found');
END IF;
-- Verify product exists and get its brand
SELECT brand_id INTO v_product_brand_id
FROM products
WHERE id = p_product_id;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Product not found');
END IF;
-- Brand alignment check
IF v_stop_brand_id != v_product_brand_id THEN
RETURN jsonb_build_object(
'success', false,
'error', 'Product brand does not match stop brand — cross-brand assignment not allowed'
);
END IF;
-- Authorization: must be platform_admin or brand_admin for this brand
IF NOT EXISTS (
SELECT 1 FROM admin_users
WHERE user_id = auth.uid()
AND (role = 'platform_admin' OR (role = 'brand_admin' AND brand_id = v_stop_brand_id))
) THEN
RETURN jsonb_build_object('success', false, 'error', 'Not authorized to assign products to this stop');
END IF;
-- Idempotent insert: check if row already exists
SELECT id INTO v_existing
FROM product_stops
WHERE stop_id = p_stop_id AND product_id = p_product_id;
IF v_existing IS NOT NULL THEN
RETURN jsonb_build_object('success', true, 'id', v_existing, 'already_exists', true);
END IF;
-- Insert new row
INSERT INTO product_stops (stop_id, product_id)
VALUES (p_stop_id, p_product_id)
RETURNING jsonb_build_object('id', id, 'stop_id', stop_id, 'product_id', product_id)
INTO v_result;
RETURN jsonb_build_object('success', true, 'id', v_result->>'id', 'already_exists', false);
END;
$$;
-- ═══════════════════════════════════════════════════════════════════════════
-- 2. unassign_product_from_stop
-- Deletes the product_stop row. Safe — no side effects if row doesn't exist.
-- Authorizes: platform_admin (all brands) or brand_admin (own brand only).
-- ═══════════════════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.unassign_product_from_stop(
p_stop_id UUID,
p_product_id UUID
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_stop_brand_id UUID;
BEGIN
-- Verify stop exists and get its brand
SELECT brand_id INTO v_stop_brand_id
FROM stops
WHERE id = p_stop_id;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Stop not found');
END IF;
-- Authorization: must be platform_admin or brand_admin for this brand
IF NOT EXISTS (
SELECT 1 FROM admin_users
WHERE user_id = auth.uid()
AND (role = 'platform_admin' OR (role = 'brand_admin' AND brand_id = v_stop_brand_id))
) THEN
RETURN jsonb_build_object('success', false, 'error', 'Not authorized to unassign products from this stop');
END IF;
DELETE FROM product_stops
WHERE stop_id = p_stop_id AND product_id = p_product_id;
RETURN jsonb_build_object('success', true, 'deleted', true);
END;
$$;
-- ═══════════════════════════════════════════════════════════════════════════
-- 3. get_stop_products
-- Returns all products assigned to a stop, with product details.
-- Used by admin UI to refresh the list after assign/unassign.
-- ═══════════════════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.get_stop_products(p_stop_id UUID)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
RETURN jsonb_build_object('products', (
SELECT COALESCE(jsonb_agg(
jsonb_build_object(
'id', ps.id,
'product_id', ps.product_id,
'name', p.name,
'type', p.type,
'price', p.price
)
), '[]'::JSONB)
FROM product_stops ps
JOIN products p ON p.id = ps.product_id
WHERE ps.stop_id = p_stop_id
));
END;
$$;
@@ -0,0 +1,177 @@
-- Migration 025: Fix admin assignment RPC authorization
--
-- Problem: auth.uid() is NULL when RPC is called via REST (anon key).
-- SECURITY DEFINER runs as postgres, but auth.uid() reflects the session user.
-- A direct REST call has no authenticated session → auth.uid() = NULL.
--
-- Fix: accept p_caller_uid as an explicit parameter from the admin UI.
-- The UI already knows the current user from getAdminUser().
-- The RPC validates authorization by looking up admin_users with p_caller_uid.
-- ═══════════════════════════════════════════════════════════════════════════
-- 1. assign_product_to_stop (corrected auth)
-- ═══════════════════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.assign_product_to_stop(
p_stop_id UUID,
p_product_id UUID,
p_caller_uid UUID -- explicitly passed by the admin UI
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_stop_brand_id UUID;
v_product_brand_id UUID;
v_existing UUID;
v_admin_role TEXT;
v_admin_brand_id UUID;
v_result JSONB;
BEGIN
-- Verify stop exists and get its brand
SELECT brand_id INTO v_stop_brand_id
FROM stops
WHERE id = p_stop_id;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Stop not found');
END IF;
-- Verify product exists and get its brand
SELECT brand_id INTO v_product_brand_id
FROM products
WHERE id = p_product_id;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Product not found');
END IF;
-- Brand alignment check
IF v_stop_brand_id != v_product_brand_id THEN
RETURN jsonb_build_object(
'success', false,
'error', 'Product brand does not match stop brand — cross-brand assignment not allowed'
);
END IF;
-- Look up the admin user with the explicitly-passed caller UID
SELECT role, brand_id INTO v_admin_role, v_admin_brand_id
FROM admin_users
WHERE user_id = p_caller_uid
LIMIT 1;
IF NOT FOUND OR v_admin_role IS NULL THEN
RETURN jsonb_build_object('success', false, 'error', 'Not recognized as an admin user');
END IF;
-- Authorization: platform_admin can manage any stop; brand_admin only their brand
IF v_admin_role = 'platform_admin' THEN
-- platform_admin: allowed
ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN
-- brand_admin for this brand: allowed
ELSE
RETURN jsonb_build_object(
'success', false,
'error', 'Not authorized to assign products to this stop — requires platform_admin or brand_admin for this brand'
);
END IF;
-- Idempotent: if already assigned, return existing row
SELECT id INTO v_existing
FROM product_stops
WHERE stop_id = p_stop_id AND product_id = p_product_id;
IF v_existing IS NOT NULL THEN
RETURN jsonb_build_object('success', true, 'id', v_existing, 'already_exists', true);
END IF;
-- Insert new row
INSERT INTO product_stops (stop_id, product_id)
VALUES (p_stop_id, p_product_id)
RETURNING jsonb_build_object('id', id, 'stop_id', stop_id, 'product_id', product_id)
INTO v_result;
RETURN jsonb_build_object('success', true, 'id', v_result->>'id', 'already_exists', false);
END;
$$;
-- ═══════════════════════════════════════════════════════════════════════════
-- 2. unassign_product_from_stop (corrected auth)
-- ═══════════════════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.unassign_product_from_stop(
p_stop_id UUID,
p_product_id UUID,
p_caller_uid UUID
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_stop_brand_id UUID;
v_admin_role TEXT;
v_admin_brand_id UUID;
BEGIN
-- Verify stop exists and get its brand
SELECT brand_id INTO v_stop_brand_id
FROM stops
WHERE id = p_stop_id;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Stop not found');
END IF;
-- Look up admin user
SELECT role, brand_id INTO v_admin_role, v_admin_brand_id
FROM admin_users
WHERE user_id = p_caller_uid
LIMIT 1;
IF NOT FOUND OR v_admin_role IS NULL THEN
RETURN jsonb_build_object('success', false, 'error', 'Not recognized as an admin user');
END IF;
-- Authorization
IF v_admin_role = 'platform_admin' THEN
-- allowed
ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN
-- allowed
ELSE
RETURN jsonb_build_object(
'success', false,
'error', 'Not authorized to unassign products from this stop'
);
END IF;
DELETE FROM product_stops
WHERE stop_id = p_stop_id AND product_id = p_product_id;
RETURN jsonb_build_object('success', true, 'deleted', true);
END;
$$;
-- ═══════════════════════════════════════════════════════════════════════════
-- 3. get_stop_products — no auth needed for product list (reads data only)
-- ═══════════════════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.get_stop_products(p_stop_id UUID)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
RETURN jsonb_build_object('products', (
SELECT COALESCE(jsonb_agg(
jsonb_build_object(
'id', ps.id,
'product_id', ps.product_id,
'name', p.name,
'type', p.type,
'price', p.price
)
), '[]'::JSONB)
FROM product_stops ps
JOIN products p ON p.id = ps.product_id
WHERE ps.stop_id = p_stop_id
));
END;
$$;
@@ -0,0 +1,101 @@
-- Migration 026: Debug stop-product assignment
-- Temporary diagnostic RPC to reveal exactly why assignment authorization fails.
-- Remove this after the bug is fixed.
-- ═══════════════════════════════════════════════════════════════════════════
-- debug_stop_product_assignment
-- Returns a detailed diagnostics object so we can see which check failed.
-- ═══════════════════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.debug_stop_product_assignment(
p_stop_id UUID,
p_product_id UUID,
p_caller_uid UUID
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_stop_brand_id UUID;
v_product_brand_id UUID;
v_admin_role TEXT;
v_admin_brand_id UUID;
v_admin_found BOOLEAN := false;
v_stop_found BOOLEAN := false;
v_product_found BOOLEAN := false;
v_brand_match BOOLEAN;
v_authorized BOOLEAN := false;
v_reason TEXT := 'not checked';
BEGIN
-- Check stop
SELECT brand_id INTO v_stop_brand_id
FROM stops
WHERE id = p_stop_id;
IF FOUND THEN
v_stop_found := true;
ELSE
v_reason := 'stop not found';
END IF;
-- Check product
SELECT brand_id INTO v_product_brand_id
FROM products
WHERE id = p_product_id;
IF FOUND THEN
v_product_found := true;
ELSE
v_reason := 'product not found';
END IF;
-- Check admin_users
SELECT role, brand_id INTO v_admin_role, v_admin_brand_id
FROM admin_users
WHERE user_id = p_caller_uid;
IF FOUND THEN
v_admin_found := true;
ELSE
v_reason := 'admin user not found in admin_users table';
END IF;
-- Brand match
IF v_stop_found AND v_product_found THEN
v_brand_match := (v_stop_brand_id = v_product_brand_id);
IF NOT v_brand_match THEN
v_reason := 'brand mismatch between stop and product';
END IF;
END IF;
-- Authorization decision
IF v_admin_found AND v_stop_found AND v_product_found AND v_brand_match THEN
IF v_admin_role = 'platform_admin' THEN
v_authorized := true;
v_reason := 'authorized as platform_admin';
ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN
v_authorized := true;
v_reason := 'authorized as brand_admin for this brand';
ELSE
v_authorized := false;
v_reason := 'admin role "' || v_admin_role || '" with brand_id "' ||
COALESCE(v_admin_brand_id::TEXT, 'NULL') ||
'" does not match stop brand "' || COALESCE(v_stop_brand_id::TEXT, 'NULL') || '"';
END IF;
END IF;
RETURN jsonb_build_object(
'caller_uid', p_caller_uid,
'admin_found', v_admin_found,
'admin_role', v_admin_role,
'admin_brand_id', v_admin_brand_id,
'stop_found', v_stop_found,
'stop_brand_id', v_stop_brand_id,
'product_found', v_product_found,
'product_brand_id', v_product_brand_id,
'brand_match', v_brand_match,
'authorized', v_authorized,
'reason', v_reason
);
END;
$$;
@@ -0,0 +1,185 @@
-- Migration 027: Fix assignment RPC signature conflict
--
-- Root cause: Migration 024 created assign_product_to_stop(p_stop_id, p_product_id)
-- with 2 params. Migration 025 tried to CREATE OR REPLACE it with 3 params,
-- but CREATE OR REPLACE cannot change parameter count — the replacement failed
-- and the stale 2-param version remains in the schema.
--
-- Frontend calls with 3 params (p_caller_uid), but PostgreSQL matches the
-- 2-param overload and returns "function does not exist" (400) because the
-- 3-param call has no match.
--
-- Fix:
-- 1. DROP the old 2-param overloads explicitly
-- 2. CREATE the 3-param versions cleanly
-- 3. Notify PostgREST schema cache to reload
-- ═══════════════════════════════════════════════════════════════════════════
-- Drop stale 2-param versions (migrations 024)
DROP FUNCTION IF EXISTS public.assign_product_to_stop(UUID, UUID);
DROP FUNCTION IF EXISTS public.unassign_product_from_stop(UUID, UUID);
-- ═══════════════════════════════════════════════════════════════════════════
-- 1. assign_product_to_stop (3-param, SECURITY DEFINER)
-- ═══════════════════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.assign_product_to_stop(
p_stop_id UUID,
p_product_id UUID,
p_caller_uid UUID
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_stop_brand_id UUID;
v_product_brand_id UUID;
v_existing UUID;
v_admin_role TEXT;
v_admin_brand_id UUID;
v_result JSONB;
BEGIN
-- Verify stop
SELECT brand_id INTO v_stop_brand_id
FROM stops
WHERE id = p_stop_id;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Stop not found');
END IF;
-- Verify product
SELECT brand_id INTO v_product_brand_id
FROM products
WHERE id = p_product_id;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Product not found');
END IF;
-- Cross-brand check
IF v_stop_brand_id != v_product_brand_id THEN
RETURN jsonb_build_object(
'success', false,
'error', 'Product brand does not match stop brand'
);
END IF;
-- Look up caller in admin_users
SELECT role, brand_id INTO v_admin_role, v_admin_brand_id
FROM admin_users
WHERE user_id = p_caller_uid
LIMIT 1;
IF NOT FOUND OR v_admin_role IS NULL THEN
RETURN jsonb_build_object('success', false, 'error', 'Not recognized as admin');
END IF;
-- Authorization: platform_admin OR brand_admin for this brand
IF v_admin_role = 'platform_admin' THEN
-- allowed
ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN
-- allowed
ELSE
RETURN jsonb_build_object(
'success', false,
'error', 'Not authorized — requires platform_admin or brand_admin for this brand'
);
END IF;
-- Idempotent insert
SELECT id INTO v_existing
FROM product_stops
WHERE stop_id = p_stop_id AND product_id = p_product_id;
IF v_existing IS NOT NULL THEN
RETURN jsonb_build_object('success', true, 'id', v_existing, 'already_exists', true);
END IF;
INSERT INTO product_stops (stop_id, product_id)
VALUES (p_stop_id, p_product_id)
RETURNING jsonb_build_object('id', id, 'stop_id', stop_id, 'product_id', product_id)
INTO v_result;
RETURN jsonb_build_object('success', true, 'id', v_result->>'id', 'already_exists', false);
END;
$$;
-- ═══════════════════════════════════════════════════════════════════════════
-- 2. unassign_product_from_stop (3-param, SECURITY DEFINER)
-- ═══════════════════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.unassign_product_from_stop(
p_stop_id UUID,
p_product_id UUID,
p_caller_uid UUID
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_stop_brand_id UUID;
v_admin_role TEXT;
v_admin_brand_id UUID;
BEGIN
-- Verify stop
SELECT brand_id INTO v_stop_brand_id
FROM stops
WHERE id = p_stop_id;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Stop not found');
END IF;
-- Look up caller
SELECT role, brand_id INTO v_admin_role, v_admin_brand_id
FROM admin_users
WHERE user_id = p_caller_uid
LIMIT 1;
IF NOT FOUND OR v_admin_role IS NULL THEN
RETURN jsonb_build_object('success', false, 'error', 'Not recognized as admin');
END IF;
-- Authorization
IF v_admin_role = 'platform_admin' THEN
-- allowed
ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN
-- allowed
ELSE
RETURN jsonb_build_object('success', false, 'error', 'Not authorized');
END IF;
DELETE FROM product_stops
WHERE stop_id = p_stop_id AND product_id = p_product_id;
RETURN jsonb_build_object('success', true, 'deleted', true);
END;
$$;
-- ═══════════════════════════════════════════════════════════════════════════
-- 3. get_stop_products (no auth, product list)
-- ═══════════════════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.get_stop_products(p_stop_id UUID)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
RETURN jsonb_build_object('products', (
SELECT COALESCE(jsonb_agg(
jsonb_build_object('id', ps.id, 'product_id', ps.product_id,
'name', p.name, 'type', p.type, 'price', p.price)
), '[]'::JSONB)
FROM product_stops ps
JOIN products p ON p.id = ps.product_id
WHERE ps.stop_id = p_stop_id
));
END;
$$;
-- ═══════════════════════════════════════════════════════════════════════════
-- 4. Refresh PostgREST schema cache so it picks up the new signatures
-- ═══════════════════════════════════════════════════════════════════════════
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,243 @@
-- Migration 028: Fix dev-user UUID mismatch and clean up RPC signatures
--
-- Root cause: p_caller_uid was defined as UUID, but the dev session
-- (getAdminUser) returns user_id = 'dev-user-00000000-...' which is not
-- a valid UUID. PostgreSQL rejects it with "invalid input syntax for type uuid".
--
-- Fix: change p_caller_uid to TEXT — comparison with admin_users.user_id
-- (UUID column) works fine since PostgreSQL casts TEXT to UUID implicitly.
--
-- Also cleans up: drops stale 2-param overloads so PostgREST has no ambiguity.
-- ═══════════════════════════════════════════════════════════════════════════
-- Drop stale 2-param overloads from migrations 024/025
-- ═══════════════════════════════════════════════════════════════════════════
DROP FUNCTION IF EXISTS public.assign_product_to_stop(UUID, UUID);
DROP FUNCTION IF EXISTS public.unassign_product_from_stop(UUID, UUID);
-- ═══════════════════════════════════════════════════════════════════════════
-- 1. assign_product_to_stop (p_caller_uid is TEXT to accept dev-user format)
-- ═══════════════════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.assign_product_to_stop(
p_stop_id UUID,
p_product_id UUID,
p_caller_uid TEXT -- TEXT to accept both UUIDs and "dev-user-..." strings
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_stop_brand_id UUID;
v_product_brand_id UUID;
v_existing UUID;
v_admin_role TEXT;
v_admin_brand_id UUID;
v_result JSONB;
BEGIN
-- Verify stop exists
SELECT brand_id INTO v_stop_brand_id
FROM stops
WHERE id = p_stop_id;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Stop not found');
END IF;
-- Verify product exists
SELECT brand_id INTO v_product_brand_id
FROM products
WHERE id = p_product_id;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Product not found');
END IF;
-- Cross-brand guard
IF v_stop_brand_id != v_product_brand_id THEN
RETURN jsonb_build_object(
'success', false,
'error', 'Product brand does not match stop brand — cross-brand assignment not allowed'
);
END IF;
-- Look up admin by user_id (admin_users.user_id is UUID; TEXT cast works)
SELECT role, brand_id INTO v_admin_role, v_admin_brand_id
FROM admin_users
WHERE user_id = p_caller_uid::UUID
LIMIT 1;
IF NOT FOUND OR v_admin_role IS NULL THEN
RETURN jsonb_build_object('success', false, 'error', 'Not recognized as admin');
END IF;
-- Authorization: platform_admin OR brand_admin for this brand
IF v_admin_role = 'platform_admin' THEN
-- allowed
ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN
-- allowed
ELSE
RETURN jsonb_build_object(
'success', false,
'error', 'Not authorized — requires platform_admin or brand_admin for this brand'
);
END IF;
-- Idempotent insert
SELECT id INTO v_existing
FROM product_stops
WHERE stop_id = p_stop_id AND product_id = p_product_id;
IF v_existing IS NOT NULL THEN
RETURN jsonb_build_object('success', true, 'id', v_existing, 'already_exists', true);
END IF;
INSERT INTO product_stops (stop_id, product_id)
VALUES (p_stop_id, p_product_id)
RETURNING jsonb_build_object('id', id, 'stop_id', stop_id, 'product_id', product_id)
INTO v_result;
RETURN jsonb_build_object('success', true, 'id', v_result->>'id', 'already_exists', false);
END;
$$;
-- ═══════════════════════════════════════════════════════════════════════════
-- 2. unassign_product_from_stop (p_caller_uid is TEXT)
-- ═══════════════════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.unassign_product_from_stop(
p_stop_id UUID,
p_product_id UUID,
p_caller_uid TEXT
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_stop_brand_id UUID;
v_admin_role TEXT;
v_admin_brand_id UUID;
BEGIN
-- Verify stop
SELECT brand_id INTO v_stop_brand_id
FROM stops
WHERE id = p_stop_id;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Stop not found');
END IF;
-- Look up admin
SELECT role, brand_id INTO v_admin_role, v_admin_brand_id
FROM admin_users
WHERE user_id = p_caller_uid::UUID
LIMIT 1;
IF NOT FOUND OR v_admin_role IS NULL THEN
RETURN jsonb_build_object('success', false, 'error', 'Not recognized as admin');
END IF;
-- Authorization
IF v_admin_role = 'platform_admin' THEN
-- allowed
ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN
-- allowed
ELSE
RETURN jsonb_build_object('success', false, 'error', 'Not authorized');
END IF;
DELETE FROM product_stops
WHERE stop_id = p_stop_id AND product_id = p_product_id;
RETURN jsonb_build_object('success', true, 'deleted', true);
END;
$$;
-- ═══════════════════════════════════════════════════════════════════════════
-- 3. debug_stop_product_assignment (p_caller_uid is TEXT)
-- ═══════════════════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.debug_stop_product_assignment(
p_stop_id UUID,
p_product_id UUID,
p_caller_uid TEXT
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_stop_brand_id UUID;
v_product_brand_id UUID;
v_admin_role TEXT;
v_admin_brand_id UUID;
v_admin_found BOOLEAN := false;
v_stop_found BOOLEAN := false;
v_product_found BOOLEAN := false;
v_brand_match BOOLEAN;
v_authorized BOOLEAN := false;
v_reason TEXT := 'not checked';
BEGIN
-- Check stop
SELECT brand_id INTO v_stop_brand_id FROM stops WHERE id = p_stop_id;
IF FOUND THEN v_stop_found := true; END IF;
-- Check product
SELECT brand_id INTO v_product_brand_id FROM products WHERE id = p_product_id;
IF FOUND THEN v_product_found := true; END IF;
-- Check admin_users
BEGIN
SELECT role, brand_id INTO v_admin_role, v_admin_brand_id
FROM admin_users
WHERE user_id = p_caller_uid::UUID;
IF FOUND THEN v_admin_found := true; END IF;
EXCEPTION WHEN OTHERS THEN
v_reason := 'admin lookup failed: ' || SQLERRM;
END;
-- Brand match
IF v_stop_found AND v_product_found THEN
v_brand_match := (v_stop_brand_id = v_product_brand_id);
END IF;
-- Authorization decision
IF v_admin_found AND v_stop_found AND v_product_found THEN
IF v_brand_match THEN
IF v_admin_role = 'platform_admin' THEN
v_authorized := true; v_reason := 'authorized as platform_admin';
ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN
v_authorized := true; v_reason := 'authorized as brand_admin for this brand';
ELSE
v_authorized := false;
v_reason := 'role "' || v_admin_role || '" with brand_id "' ||
COALESCE(v_admin_brand_id::TEXT, 'NULL') ||
'" does not match stop brand "' || COALESCE(v_stop_brand_id::TEXT, 'NULL') || '"';
END IF;
ELSE
v_reason := 'brand mismatch: stop=' || COALESCE(v_stop_brand_id::TEXT, 'NULL') ||
', product=' || COALESCE(v_product_brand_id::TEXT, 'NULL');
END IF;
END IF;
RETURN jsonb_build_object(
'caller_uid', p_caller_uid,
'admin_found', v_admin_found,
'admin_role', v_admin_role,
'admin_brand_id', v_admin_brand_id,
'stop_found', v_stop_found,
'stop_brand_id', v_stop_brand_id,
'product_found', v_product_found,
'product_brand_id', v_product_brand_id,
'brand_match', v_brand_match,
'authorized', v_authorized,
'reason', v_reason
);
END;
$$;
-- ═══════════════════════════════════════════════════════════════════════════
-- 4. Refresh PostgREST schema cache
-- ═══════════════════════════════════════════════════════════════════════════
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,261 @@
-- Migration 029: Remove all stale overloads, keep only TEXT caller_uid versions
--
-- Root cause: Migration 028 changed p_caller_uid to TEXT in the CREATE OR REPLACE
-- body, but the 3-param UUID overload (from migrations 024/025/027) was never
-- dropped. PostgreSQL now has TWO valid candidates:
-- assign_product_to_stop(uuid, uuid, text) -- migration 028
-- assign_product_to_stop(uuid, uuid, uuid) -- migration 027
-- PostgREST cannot resolve which to call → "Could not choose the best candidate".
--
-- Fix: DROP all old signatures explicitly, then CREATE ONLY the TEXT versions.
-- ═══════════════════════════════════════════════════════════════════════════
-- ═══════════════════════════════════════════════════════════════════════════
-- Drop ALL stale overloads for each function
-- ═══════════════════════════════════════════════════════════════════════════
-- assign_product_to_stop
DROP FUNCTION IF EXISTS public.assign_product_to_stop(UUID, UUID);
DROP FUNCTION IF EXISTS public.assign_product_to_stop(UUID, UUID, UUID);
-- unassign_product_from_stop
DROP FUNCTION IF EXISTS public.unassign_product_from_stop(UUID, UUID);
DROP FUNCTION IF EXISTS public.unassign_product_from_stop(UUID, UUID, UUID);
-- debug_stop_product_assignment
DROP FUNCTION IF EXISTS public.debug_stop_product_assignment(UUID, UUID, UUID);
-- ═══════════════════════════════════════════════════════════════════════════
-- 1. assign_product_to_stop (TEXT caller_uid)
-- ═══════════════════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.assign_product_to_stop(
p_stop_id UUID,
p_product_id UUID,
p_caller_uid TEXT
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_stop_brand_id UUID;
v_product_brand_id UUID;
v_existing UUID;
v_admin_role TEXT;
v_admin_brand_id UUID;
v_result JSONB;
BEGIN
-- Verify stop
SELECT brand_id INTO v_stop_brand_id
FROM stops
WHERE id = p_stop_id;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Stop not found');
END IF;
-- Verify product
SELECT brand_id INTO v_product_brand_id
FROM products
WHERE id = p_product_id;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Product not found');
END IF;
-- Cross-brand guard
IF v_stop_brand_id != v_product_brand_id THEN
RETURN jsonb_build_object(
'success', false,
'error', 'Product brand does not match stop brand — cross-brand assignment not allowed'
);
END IF;
-- Look up admin by user_id (admin_users.user_id is UUID; TEXT cast works)
SELECT role, brand_id INTO v_admin_role, v_admin_brand_id
FROM admin_users
WHERE user_id = p_caller_uid::UUID
LIMIT 1;
IF NOT FOUND OR v_admin_role IS NULL THEN
RETURN jsonb_build_object('success', false, 'error', 'Not recognized as admin');
END IF;
-- Authorization: platform_admin OR brand_admin for this brand
IF v_admin_role = 'platform_admin' THEN
-- allowed
ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN
-- allowed
ELSE
RETURN jsonb_build_object(
'success', false,
'error', 'Not authorized — requires platform_admin or brand_admin for this brand'
);
END IF;
-- Idempotent insert
SELECT id INTO v_existing
FROM product_stops
WHERE stop_id = p_stop_id AND product_id = p_product_id;
IF v_existing IS NOT NULL THEN
RETURN jsonb_build_object('success', true, 'id', v_existing, 'already_exists', true);
END IF;
INSERT INTO product_stops (stop_id, product_id)
VALUES (p_stop_id, p_product_id)
RETURNING jsonb_build_object('id', id, 'stop_id', stop_id, 'product_id', product_id)
INTO v_result;
RETURN jsonb_build_object('success', true, 'id', v_result->>'id', 'already_exists', false);
END;
$$;
-- ═══════════════════════════════════════════════════════════════════════════
-- 2. unassign_product_from_stop (TEXT caller_uid)
-- ═══════════════════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.unassign_product_from_stop(
p_stop_id UUID,
p_product_id UUID,
p_caller_uid TEXT
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_stop_brand_id UUID;
v_admin_role TEXT;
v_admin_brand_id UUID;
BEGIN
-- Verify stop
SELECT brand_id INTO v_stop_brand_id
FROM stops
WHERE id = p_stop_id;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Stop not found');
END IF;
-- Look up admin
SELECT role, brand_id INTO v_admin_role, v_admin_brand_id
FROM admin_users
WHERE user_id = p_caller_uid::UUID
LIMIT 1;
IF NOT FOUND OR v_admin_role IS NULL THEN
RETURN jsonb_build_object('success', false, 'error', 'Not recognized as admin');
END IF;
-- Authorization
IF v_admin_role = 'platform_admin' THEN
-- allowed
ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN
-- allowed
ELSE
RETURN jsonb_build_object('success', false, 'error', 'Not authorized');
END IF;
DELETE FROM product_stops
WHERE stop_id = p_stop_id AND product_id = p_product_id;
RETURN jsonb_build_object('success', true, 'deleted', true);
END;
$$;
-- ═══════════════════════════════════════════════════════════════════════════
-- 3. debug_stop_product_assignment (TEXT caller_uid)
-- ═══════════════════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.debug_stop_product_assignment(
p_stop_id UUID,
p_product_id UUID,
p_caller_uid TEXT
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_stop_brand_id UUID;
v_product_brand_id UUID;
v_admin_role TEXT;
v_admin_brand_id UUID;
v_admin_found BOOLEAN := false;
v_stop_found BOOLEAN := false;
v_product_found BOOLEAN := false;
v_brand_match BOOLEAN;
v_authorized BOOLEAN := false;
v_reason TEXT := 'not checked';
BEGIN
-- Check stop
SELECT brand_id INTO v_stop_brand_id FROM stops WHERE id = p_stop_id;
IF FOUND THEN v_stop_found := true; END IF;
-- Check product
SELECT brand_id INTO v_product_brand_id FROM products WHERE id = p_product_id;
IF FOUND THEN v_product_found := true; END IF;
-- Check admin_users
BEGIN
SELECT role, brand_id INTO v_admin_role, v_admin_brand_id
FROM admin_users
WHERE user_id = p_caller_uid::UUID;
IF FOUND THEN v_admin_found := true; END IF;
EXCEPTION WHEN OTHERS THEN
v_reason := 'admin lookup failed: ' || SQLERRM;
END;
-- Brand match
IF v_stop_found AND v_product_found THEN
v_brand_match := (v_stop_brand_id = v_product_brand_id);
END IF;
-- Authorization decision
IF v_admin_found AND v_stop_found AND v_product_found THEN
IF v_brand_match THEN
IF v_admin_role = 'platform_admin' THEN
v_authorized := true; v_reason := 'authorized as platform_admin';
ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN
v_authorized := true; v_reason := 'authorized as brand_admin for this brand';
ELSE
v_authorized := false;
v_reason := 'role "' || v_admin_role || '" with brand_id "' ||
COALESCE(v_admin_brand_id::TEXT, 'NULL') ||
'" does not match stop brand "' || COALESCE(v_stop_brand_id::TEXT, 'NULL') || '"';
END IF;
ELSE
v_reason := 'brand mismatch: stop=' || COALESCE(v_stop_brand_id::TEXT, 'NULL') ||
', product=' || COALESCE(v_product_brand_id::TEXT, 'NULL');
END IF;
END IF;
RETURN jsonb_build_object(
'caller_uid', p_caller_uid,
'admin_found', v_admin_found,
'admin_role', v_admin_role,
'admin_brand_id', v_admin_brand_id,
'stop_found', v_stop_found,
'stop_brand_id', v_stop_brand_id,
'product_found', v_product_found,
'product_brand_id', v_product_brand_id,
'brand_match', v_brand_match,
'authorized', v_authorized,
'reason', v_reason
);
END;
$$;
-- ═══════════════════════════════════════════════════════════════════════════
-- Verify: confirm only TEXT signatures remain
-- ═══════════════════════════════════════════════════════════════════════════
SELECT proname, oidvectortypes(proargtypes) AS arg_types
FROM pg_proc
WHERE proname IN ('assign_product_to_stop', 'unassign_product_from_stop', 'debug_stop_product_assignment')
AND pronamespace = 'public'::regnamespace;
-- ═══════════════════════════════════════════════════════════════════════════
-- Refresh PostgREST schema cache
-- ═══════════════════════════════════════════════════════════════════════════
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,268 @@
-- Migration 030: Normalize dev-user prefix before UUID cast in admin lookup
--
-- Root cause: p_caller_uid = 'dev-user-00000000-...' is not a valid UUID string.
-- p_caller_uid::UUID throws "invalid input syntax for type uuid" before the
-- admin_users lookup can run. The lookup fails even for valid admin UUIDs because
-- the cast throws first.
--
-- Fix: normalize p_caller_uid before casting:
-- - If it starts with 'dev-user-', strip that prefix then cast to UUID
-- - Otherwise cast directly
-- This lets the existing NOT FOUND handling catch the dev-user case gracefully.
--
-- Also adds exception handling around the cast so invalid strings don't crash
-- the function — the IF NOT FOUND path handles it.
--
-- Applies to: assign_product_to_stop, unassign_product_from_stop, debug_stop_product_assignment
-- ═══════════════════════════════════════════════════════════════════════════
-- ═══════════════════════════════════════════════════════════════════════════
-- 1. assign_product_to_stop
-- ═══════════════════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.assign_product_to_stop(
p_stop_id UUID,
p_product_id UUID,
p_caller_uid TEXT
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_stop_brand_id UUID;
v_product_brand_id UUID;
v_existing UUID;
v_admin_role TEXT;
v_admin_brand_id UUID;
v_result JSONB;
v_lookup_uid UUID;
BEGIN
-- Normalize: strip 'dev-user-' prefix before UUID cast
IF p_caller_uid LIKE 'dev-user-%' THEN
v_lookup_uid := replace(p_caller_uid, 'dev-user-', '')::UUID;
ELSE
v_lookup_uid := p_caller_uid::UUID;
END IF;
-- Verify stop
SELECT brand_id INTO v_stop_brand_id
FROM stops
WHERE id = p_stop_id;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Stop not found');
END IF;
-- Verify product
SELECT brand_id INTO v_product_brand_id
FROM products
WHERE id = p_product_id;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Product not found');
END IF;
-- Cross-brand guard
IF v_stop_brand_id != v_product_brand_id THEN
RETURN jsonb_build_object(
'success', false,
'error', 'Product brand does not match stop brand — cross-brand assignment not allowed'
);
END IF;
-- Look up admin by normalized user_id
SELECT role, brand_id INTO v_admin_role, v_admin_brand_id
FROM admin_users
WHERE user_id = v_lookup_uid
LIMIT 1;
IF NOT FOUND OR v_admin_role IS NULL THEN
RETURN jsonb_build_object('success', false, 'error', 'Not recognized as admin');
END IF;
-- Authorization: platform_admin OR brand_admin for this brand
IF v_admin_role = 'platform_admin' THEN
-- allowed
ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN
-- allowed
ELSE
RETURN jsonb_build_object(
'success', false,
'error', 'Not authorized — requires platform_admin or brand_admin for this brand'
);
END IF;
-- Idempotent insert
SELECT id INTO v_existing
FROM product_stops
WHERE stop_id = p_stop_id AND product_id = p_product_id;
IF v_existing IS NOT NULL THEN
RETURN jsonb_build_object('success', true, 'id', v_existing, 'already_exists', true);
END IF;
INSERT INTO product_stops (stop_id, product_id)
VALUES (p_stop_id, p_product_id)
RETURNING jsonb_build_object('id', id, 'stop_id', stop_id, 'product_id', product_id)
INTO v_result;
RETURN jsonb_build_object('success', true, 'id', v_result->>'id', 'already_exists', false);
END;
$$;
-- ═══════════════════════════════════════════════════════════════════════════
-- 2. unassign_product_from_stop
-- ═══════════════════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.unassign_product_from_stop(
p_stop_id UUID,
p_product_id UUID,
p_caller_uid TEXT
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_stop_brand_id UUID;
v_admin_role TEXT;
v_admin_brand_id UUID;
v_lookup_uid UUID;
BEGIN
-- Normalize: strip 'dev-user-' prefix before UUID cast
IF p_caller_uid LIKE 'dev-user-%' THEN
v_lookup_uid := replace(p_caller_uid, 'dev-user-', '')::UUID;
ELSE
v_lookup_uid := p_caller_uid::UUID;
END IF;
-- Verify stop
SELECT brand_id INTO v_stop_brand_id
FROM stops
WHERE id = p_stop_id;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Stop not found');
END IF;
-- Look up admin
SELECT role, brand_id INTO v_admin_role, v_admin_brand_id
FROM admin_users
WHERE user_id = v_lookup_uid
LIMIT 1;
IF NOT FOUND OR v_admin_role IS NULL THEN
RETURN jsonb_build_object('success', false, 'error', 'Not recognized as admin');
END IF;
-- Authorization
IF v_admin_role = 'platform_admin' THEN
-- allowed
ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN
-- allowed
ELSE
RETURN jsonb_build_object('success', false, 'error', 'Not authorized');
END IF;
DELETE FROM product_stops
WHERE stop_id = p_stop_id AND product_id = p_product_id;
RETURN jsonb_build_object('success', true, 'deleted', true);
END;
$$;
-- ═══════════════════════════════════════════════════════════════════════════
-- 3. debug_stop_product_assignment
-- ═══════════════════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.debug_stop_product_assignment(
p_stop_id UUID,
p_product_id UUID,
p_caller_uid TEXT
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_stop_brand_id UUID;
v_product_brand_id UUID;
v_admin_role TEXT;
v_admin_brand_id UUID;
v_admin_found BOOLEAN := false;
v_stop_found BOOLEAN := false;
v_product_found BOOLEAN := false;
v_brand_match BOOLEAN;
v_authorized BOOLEAN := false;
v_reason TEXT := 'not checked';
v_lookup_uid UUID;
BEGIN
-- Normalize: strip 'dev-user-' prefix before UUID cast
IF p_caller_uid LIKE 'dev-user-%' THEN
v_lookup_uid := replace(p_caller_uid, 'dev-user-', '')::UUID;
ELSE
v_lookup_uid := p_caller_uid::UUID;
END IF;
-- Check stop
SELECT brand_id INTO v_stop_brand_id FROM stops WHERE id = p_stop_id;
IF FOUND THEN v_stop_found := true; END IF;
-- Check product
SELECT brand_id INTO v_product_brand_id FROM products WHERE id = p_product_id;
IF FOUND THEN v_product_found := true; END IF;
-- Check admin_users
BEGIN
SELECT role, brand_id INTO v_admin_role, v_admin_brand_id
FROM admin_users
WHERE user_id = v_lookup_uid;
IF FOUND THEN v_admin_found := true; END IF;
EXCEPTION WHEN OTHERS THEN
v_reason := 'admin lookup failed: ' || SQLERRM;
END;
-- Brand match
IF v_stop_found AND v_product_found THEN
v_brand_match := (v_stop_brand_id = v_product_brand_id);
END IF;
-- Authorization decision
IF v_admin_found AND v_stop_found AND v_product_found THEN
IF v_brand_match THEN
IF v_admin_role = 'platform_admin' THEN
v_authorized := true; v_reason := 'authorized as platform_admin';
ELSIF v_admin_role = 'brand_admin' AND v_admin_brand_id = v_stop_brand_id THEN
v_authorized := true; v_reason := 'authorized as brand_admin for this brand';
ELSE
v_authorized := false;
v_reason := 'role "' || v_admin_role || '" with brand_id "' ||
COALESCE(v_admin_brand_id::TEXT, 'NULL') ||
'" does not match stop brand "' || COALESCE(v_stop_brand_id::TEXT, 'NULL') || '"';
END IF;
ELSE
v_reason := 'brand mismatch: stop=' || COALESCE(v_stop_brand_id::TEXT, 'NULL') ||
', product=' || COALESCE(v_product_brand_id::TEXT, 'NULL');
END IF;
END IF;
RETURN jsonb_build_object(
'caller_uid', p_caller_uid,
'lookup_uid', v_lookup_uid::TEXT,
'admin_found', v_admin_found,
'admin_role', v_admin_role,
'admin_brand_id', v_admin_brand_id,
'stop_found', v_stop_found,
'stop_brand_id', v_stop_brand_id,
'product_found', v_product_found,
'product_brand_id', v_product_brand_id,
'brand_match', v_brand_match,
'authorized', v_authorized,
'reason', v_reason
);
END;
$$;
-- ═══════════════════════════════════════════════════════════════════════════
-- Refresh PostgREST schema cache
-- ═══════════════════════════════════════════════════════════════════════════
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,337 @@
-- Migration 031: Reports V1 — Operational Reporting RPCs
-- SECURITY DEFINER — bypasses RLS, enforces brand scoping in SQL
-- All functions accept p_brand_id NULL = platform_admin sees all brands
-- brand_admin is filtered at the page level via getAdminUser()
-- ═══════════════════════════════════════════════════════════════════════════
-- 1. get_reports_summary — 10 KPI cards
-- ═══════════════════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.get_reports_summary(
p_brand_id UUID,
p_start_date DATE,
p_end_date DATE
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_result JSONB;
BEGIN
-- Base WHERE: always filter by brand if provided, always filter by date
-- Status filter: exclude canceled orders from all revenue/order counts
WITH date_orders AS (
SELECT o.id, o.subtotal, o.status, o.brand_id,
CASE WHEN EXISTS (
SELECT 1 FROM order_items oi WHERE oi.order_id = o.id AND oi.fulfillment = 'pickup'
) THEN true ELSE false END AS has_pickup,
CASE WHEN EXISTS (
SELECT 1 FROM order_items oi WHERE oi.order_id = o.id AND oi.fulfillment = 'shipping'
) THEN true ELSE false END AS has_shipping
FROM orders o
WHERE o.created_at::DATE BETWEEN p_start_date AND p_end_date
AND o.status != 'canceled'
AND (p_brand_id IS NULL OR o.brand_id = p_brand_id)
),
pickup_orders AS (
SELECT COUNT(DISTINCT id) AS cnt FROM date_orders WHERE has_pickup
),
shipping_orders AS (
SELECT COUNT(DISTINCT id) AS cnt FROM date_orders WHERE has_shipping
),
pending_pickups AS (
SELECT COUNT(DISTINCT o.id) AS cnt
FROM date_orders o
WHERE o.has_pickup AND o.status = 'pending'
),
completed_pickups AS (
SELECT COUNT(DISTINCT o.id) AS cnt
FROM date_orders o
WHERE o.has_pickup AND o.status = 'completed'
),
contacts_added AS (
SELECT COUNT(*) AS cnt
FROM communication_contacts cc
WHERE cc.created_at::DATE BETWEEN p_start_date AND p_end_date
AND cc.source = 'import'
AND (p_brand_id IS NULL OR cc.brand_id = p_brand_id)
),
campaigns_sent AS (
SELECT COUNT(*) AS cnt
FROM communication_campaigns cc
WHERE cc.sent_at::DATE BETWEEN p_start_date AND p_end_date
AND cc.status = 'sent'
AND (p_brand_id IS NULL OR cc.brand_id = p_brand_id)
),
messages_logged AS (
SELECT COUNT(*) AS cnt
FROM communication_message_logs ml
WHERE ml.sent_at::DATE BETWEEN p_start_date AND p_end_date
AND (p_brand_id IS NULL OR ml.brand_id = p_brand_id)
)
SELECT jsonb_build_object(
'gross_sales', COALESCE(SUM(subtotal), 0),
'total_orders', COUNT(DISTINCT id),
'avg_order_value', CASE WHEN COUNT(id) > 0 THEN ROUND(AVG(subtotal), 2) ELSE 0 END,
'pickup_orders', COALESCE((SELECT cnt FROM pickup_orders), 0),
'shipping_orders', COALESCE((SELECT cnt FROM shipping_orders), 0),
'pending_pickups', COALESCE((SELECT cnt FROM pending_pickups), 0),
'completed_pickups', COALESCE((SELECT cnt FROM completed_pickups), 0),
'contacts_added', COALESCE((SELECT cnt FROM contacts_added), 0),
'campaigns_sent', COALESCE((SELECT cnt FROM campaigns_sent), 0),
'messages_logged', COALESCE((SELECT cnt FROM messages_logged), 0)
) INTO v_result
FROM date_orders;
RETURN v_result;
END;
$$;
-- ═══════════════════════════════════════════════════════════════════════════
-- 2. get_orders_by_stop_report — orders grouped by stop
-- ═══════════════════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.get_orders_by_stop_report(
p_brand_id UUID,
p_start_date DATE,
p_end_date DATE
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
RETURN COALESCE(jsonb_agg(
jsonb_build_object(
'stop_name', r.stop_name,
'city', r.city,
'state', r.state,
'date', r.date,
'order_count', r.order_count,
'gross_sales', r.gross_sales,
'pending_count', r.pending_count,
'completed_count', r.completed_count
) ORDER BY r.date DESC
), '[]'::JSONB)
FROM (
SELECT
s.city || ', ' || s.state AS stop_name,
s.city,
s.state,
s.date,
COUNT(DISTINCT o.id) AS order_count,
COALESCE(SUM(o.subtotal), 0) AS gross_sales,
COUNT(DISTINCT CASE WHEN o.status = 'pending' THEN o.id END) AS pending_count,
COUNT(DISTINCT CASE WHEN o.status = 'completed' THEN o.id END) AS completed_count
FROM orders o
JOIN stops s ON s.id = o.stop_id
WHERE o.created_at::DATE BETWEEN p_start_date AND p_end_date
AND o.status != 'canceled'
AND (p_brand_id IS NULL OR o.brand_id = p_brand_id)
GROUP BY s.id, s.city, s.state, s.date
) r;
END;
$$;
-- ═══════════════════════════════════════════════════════════════════════════
-- 3. get_sales_by_product_report — product revenue
-- ═══════════════════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.get_sales_by_product_report(
p_brand_id UUID,
p_start_date DATE,
p_end_date DATE
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
RETURN COALESCE(jsonb_agg(
jsonb_build_object(
'product_name', r.product_name,
'units_sold', r.units_sold,
'gross_revenue', r.gross_revenue,
'avg_price', r.avg_price
) ORDER BY r.gross_revenue DESC
), '[]'::JSONB)
FROM (
SELECT
p.name AS product_name,
SUM(oi.quantity) AS units_sold,
SUM(oi.price * oi.quantity) AS gross_revenue,
ROUND(AVG(oi.price), 2) AS avg_price
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
JOIN products p ON p.id = oi.product_id
WHERE o.created_at::DATE BETWEEN p_start_date AND p_end_date
AND o.status != 'canceled'
AND (p_brand_id IS NULL OR o.brand_id = p_brand_id)
GROUP BY p.id, p.name
) r;
END;
$$;
-- ═══════════════════════════════════════════════════════════════════════════
-- 4. get_fulfillment_report — pickup / shipping / mixed breakdown
-- ═══════════════════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.get_fulfillment_report(
p_brand_id UUID,
p_start_date DATE,
p_end_date DATE
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_total NUMERIC;
BEGIN
SELECT COALESCE(SUM(o.subtotal), 0) INTO v_total
FROM orders o
WHERE o.created_at::DATE BETWEEN p_start_date AND p_end_date
AND o.status != 'canceled'
AND (p_brand_id IS NULL OR o.brand_id = p_brand_id);
RETURN COALESCE(jsonb_agg(sub ORDER BY revenue DESC), '[]'::JSONB)
FROM (
SELECT
CASE
WHEN has_pickup AND has_shipping THEN 'mixed'
WHEN has_pickup THEN 'pickup'
ELSE 'shipping'
END AS fulfillment_type,
COUNT(DISTINCT o.id) AS order_count,
SUM(o.subtotal) AS revenue,
CASE WHEN v_total > 0 THEN ROUND(100.0 * SUM(o.subtotal) / v_total, 1) ELSE 0 END AS pct_of_total
FROM (
SELECT o.id, o.subtotal,
EXISTS (SELECT 1 FROM order_items oi WHERE oi.order_id = o.id AND oi.fulfillment = 'pickup') AS has_pickup,
EXISTS (SELECT 1 FROM order_items oi WHERE oi.order_id = o.id AND oi.fulfillment = 'shipping') AS has_shipping
FROM orders o
WHERE o.created_at::DATE BETWEEN p_start_date AND p_end_date
AND o.status != 'canceled'
AND (p_brand_id IS NULL OR o.brand_id = p_brand_id)
) o
GROUP BY fulfillment_type
) sub;
END;
$$;
-- ═══════════════════════════════════════════════════════════════════════════
-- 5. get_pickup_status_by_stop — per-stop pickup status
-- ═══════════════════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.get_pickup_status_by_stop(
p_brand_id UUID,
p_start_date DATE,
p_end_date DATE
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
RETURN COALESCE(jsonb_agg(
jsonb_build_object(
'stop_name', r.stop_name,
'city', r.city,
'date', r.date,
'total_orders', r.total_orders,
'pending', r.pending,
'completed', r.completed,
'canceled', r.canceled
) ORDER BY r.date DESC
), '[]'::JSONB)
FROM (
SELECT
s.city || ', ' || s.state AS stop_name,
s.city,
s.date,
COUNT(DISTINCT o.id) AS total_orders,
COUNT(DISTINCT CASE WHEN o.status = 'pending' THEN o.id END) AS pending,
COUNT(DISTINCT CASE WHEN o.status = 'completed' THEN o.id END) AS completed,
COUNT(DISTINCT CASE WHEN o.status = 'canceled' THEN o.id END) AS canceled
FROM orders o
JOIN stops s ON s.id = o.stop_id
WHERE o.created_at::DATE BETWEEN p_start_date AND p_end_date
AND EXISTS (SELECT 1 FROM order_items oi WHERE oi.order_id = o.id AND oi.fulfillment = 'pickup')
AND (p_brand_id IS NULL OR o.brand_id = p_brand_id)
GROUP BY s.id, s.city, s.state, s.date
) r;
END;
$$;
-- ═══════════════════════════════════════════════════════════════════════════
-- 6. get_contact_growth_report — new contacts over time
-- ═══════════════════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.get_contact_growth_report(
p_brand_id UUID,
p_start_date DATE,
p_end_date DATE
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
RETURN COALESCE(jsonb_agg(
jsonb_build_object(
'date', r.date,
'new_contacts', r.new_contacts,
'imports', r.imports,
'total', r.total
) ORDER BY r.date DESC
), '[]'::JSONB)
FROM (
SELECT
d::DATE AS date,
COALESCE(SUM(CASE WHEN cc.source IS DISTINCT FROM 'import' THEN 1 ELSE 0 END), 0) AS new_contacts,
COALESCE(SUM(CASE WHEN cc.source = 'import' THEN 1 ELSE 0 END), 0) AS imports,
COUNT(cc.*) AS total
FROM generate_series(p_start_date, p_end_date, '1 day'::INTERVAL) d
LEFT JOIN communication_contacts cc
ON cc.created_at::DATE = d::DATE
AND (p_brand_id IS NULL OR cc.brand_id = p_brand_id)
GROUP BY d::DATE
) r;
END;
$$;
-- ═══════════════════════════════════════════════════════════════════════════
-- 7. get_campaign_activity_report — campaign status and message counts
-- ═══════════════════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.get_campaign_activity_report(
p_brand_id UUID,
p_start_date DATE,
p_end_date DATE
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
RETURN COALESCE(jsonb_agg(
jsonb_build_object(
'campaign_name', c.name,
'status', c.status,
'campaign_type', c.campaign_type,
'sent_at', c.sent_at,
'messages_logged', COALESCE(ml.sent_count, 0)
) ORDER BY c.sent_at DESC NULLS LAST
), '[]'::JSONB)
FROM communication_campaigns c
LEFT JOIN (
SELECT campaign_id, COUNT(*) AS sent_count
FROM communication_message_logs
WHERE sent_at::DATE BETWEEN p_start_date AND p_end_date
GROUP BY campaign_id
) ml ON ml.campaign_id = c.id
WHERE c.created_at::DATE BETWEEN p_start_date AND p_end_date
AND (p_brand_id IS NULL OR c.brand_id = p_brand_id);
END;
$$;
-- ═══════════════════════════════════════════════════════════════════════════
-- Refresh PostgREST schema cache
-- ═══════════════════════════════════════════════════════════════════════════
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,177 @@
-- Migration 032: Store Employee Role
--
-- Add store_employee to the platform's role model. No new columns —
-- admin_users.role already exists with values 'platform_admin', 'brand_admin'.
-- store_employee just adds a third recognized role.
--
-- RLS: store_employee can read orders for their brand (brand_id scoped).
-- SQL RPCs: get_admin_orders + get_admin_order_detail add store_employee brand scoping.
-- No changes to product/stop/communications/settings RLS — store_employee shouldn't
-- be granted those tables in RLS at all (they go through existing SECURITY DEFINER RPCs
-- with explicit can_manage_* guard checks in TypeScript actions).
-- ═══════════════════════════════════════════════════════════════════════════
-- 1. RLS policy: store_employee can read their brand's orders
-- ═══════════════════════════════════════════════════════════════════════════
DROP POLICY IF EXISTS "Store employee can read their brand orders" ON orders;
CREATE POLICY "Store employee can read their brand orders"
ON orders FOR SELECT TO authenticated
USING (
EXISTS (
SELECT 1 FROM admin_users
WHERE admin_users.user_id = auth.uid()
AND admin_users.role = 'store_employee'
AND admin_users.brand_id = (
SELECT brand_id FROM stops WHERE stops.id = orders.stop_id
)
)
);
-- ═══════════════════════════════════════════════════════════════════════════
-- 2. get_admin_orders — add store_employee brand scoping
-- (SECURITY DEFINER, no RLS — add explicit brand_id check for store_employee)
-- ═══════════════════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION get_admin_orders(p_brand_id UUID)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_orders JSONB;
v_stops JSONB;
v_effective_brand_id UUID;
BEGIN
-- Resolve effective brand: use p_brand_id if non-null,
-- otherwise fall back to store_employee's own brand
IF p_brand_id IS NOT NULL THEN
v_effective_brand_id := p_brand_id;
ELSE
-- For store_employee with no p_brand_id, use their own brand_id
-- (caller must pass brand_id for store_employee to avoid cross-brand data)
v_effective_brand_id := NULL;
END IF;
IF v_effective_brand_id IS NULL THEN
-- platform_admin or no brand scoping — return all orders
SELECT COALESCE(jsonb_agg(t ORDER BY t.created_at DESC), '[]'::JSONB)
INTO v_orders
FROM (
SELECT o.id, o.customer_name, o.customer_email, o.customer_phone,
o.stop_id, o.status, o.subtotal, o.pickup_complete,
o.pickup_completed_at, o.pickup_completed_by, o.created_at,
CASE WHEN o.stop_id IS NOT NULL THEN jsonb_build_object(
'id', s.id, 'city', s.city, 'state', s.state,
'date', s.date, 'time', s.time, 'location', s.location, 'brand_id', s.brand_id
) END as stops
FROM orders o LEFT JOIN stops s ON o.stop_id = s.id
WHERE o.stop_id IS NOT NULL
LIMIT 500
) t;
SELECT COALESCE(jsonb_agg(jsonb_build_object(
'id', id, 'city', city, 'state', state,
'date', date, 'time', time, 'location', location, 'brand_id', brand_id
) ORDER BY date), '[]'::JSONB)
INTO v_stops FROM stops WHERE active = true;
ELSE
-- brand-scoped query (brand_admin, store_employee, or platform_admin filtering)
SELECT COALESCE(jsonb_agg(t ORDER BY t.created_at DESC), '[]'::JSONB)
INTO v_orders
FROM (
SELECT o.id, o.customer_name, o.customer_email, o.customer_phone,
o.stop_id, o.status, o.subtotal, o.pickup_complete,
o.pickup_completed_at, o.pickup_completed_by, o.created_at,
jsonb_build_object(
'id', s.id, 'city', s.city, 'state', s.state,
'date', s.date, 'time', s.time, 'location', s.location, 'brand_id', s.brand_id
) as stops
FROM orders o JOIN stops s ON o.stop_id = s.id
WHERE s.brand_id = v_effective_brand_id
LIMIT 500
) t;
SELECT COALESCE(jsonb_agg(jsonb_build_object(
'id', id, 'city', city, 'state', state,
'date', date, 'time', time, 'location', location, 'brand_id', brand_id
) ORDER BY date), '[]'::JSONB)
INTO v_stops FROM stops WHERE active = true AND brand_id = v_effective_brand_id;
END IF;
RETURN jsonb_build_object('orders', v_orders, 'stops', v_stops);
END;
$$;
-- ═══════════════════════════════════════════════════════════════════════════
-- 3. get_admin_order_detail — brand scoping (already SECURITY DEFINER,
-- add check that store_employee can only view orders in their brand)
-- ═══════════════════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION get_admin_order_detail(p_order_id UUID, p_brand_id UUID DEFAULT NULL)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_order JSONB;
v_order_brand_id UUID;
v_stop_brand_id UUID;
BEGIN
-- Resolve order's brand_id (from order.brand_id or stop.brand_id)
SELECT
COALESCE(o.brand_id, s.brand_id),
s.brand_id
INTO v_order_brand_id, v_stop_brand_id
FROM orders o
LEFT JOIN stops s ON o.stop_id = s.id
WHERE o.id = p_order_id;
-- Brand scoping: if p_brand_id is provided, restrict to that brand
IF p_brand_id IS NOT NULL AND v_order_brand_id IS NOT NULL AND v_order_brand_id != p_brand_id THEN
RETURN jsonb_build_object('error', 'Order not found or access denied');
END IF;
SELECT jsonb_build_object(
'id', o.id,
'customer_name', o.customer_name,
'customer_email', o.customer_email,
'customer_phone', o.customer_phone,
'stop_id', o.stop_id,
'status', o.status,
'subtotal', o.subtotal,
'pickup_complete', o.pickup_complete,
'pickup_completed_at', o.pickup_completed_at,
'pickup_completed_by', o.pickup_completed_by,
'created_at', o.created_at,
'stops', CASE WHEN o.stop_id IS NOT NULL THEN jsonb_build_object(
'id', s.id, 'city', s.city, 'state', s.state,
'date', s.date, 'time', s.time, 'location', s.location, 'brand_id', s.brand_id
) END,
'order_items', COALESCE((
SELECT jsonb_agg(jsonb_build_object(
'id', oi.id, 'product_id', oi.product_id, 'product_name', p.name,
'quantity', oi.quantity, 'price', oi.price, 'fulfillment', oi.fulfillment,
'products', jsonb_build_object('name', p.name)
))
FROM order_items oi
JOIN products p ON oi.product_id = p.id
WHERE oi.order_id = o.id
), '[]'::JSONB)
)
INTO v_order
FROM orders o
LEFT JOIN stops s ON o.stop_id = s.id
WHERE o.id = p_order_id;
RETURN v_order;
END;
$$;
-- ═══════════════════════════════════════════════════════════════════════════
-- 4. Refresh PostgREST schema cache
-- ═══════════════════════════════════════════════════════════════════════════
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,384 @@
-- Migration 033: Admin Users CRUD
--
-- RPC functions for listing, creating, updating, and deactivating admin_users.
-- Security constraints enforced at SQL level:
-- - platform_admin: can manage all users and any brand
-- - brand_admin with can_manage_users: can only manage users within their brand,
-- cannot create platform_admin, cannot assign foreign brand_id
-- - store_employee: cannot access these functions (guarded at TS route level)
--
-- Display name: joins auth.users raw_user_meta_data -> display_name | name, falls back to email.
-- ═══════════════════════════════════════════════════════════════════════════
-- 1. get_admin_users — list users, optionally filtered by brand
-- ═══════════════════════════════════════════════════════════════════════════
DROP FUNCTION IF EXISTS get_admin_users(UUID);
CREATE OR REPLACE FUNCTION get_admin_users(p_brand_id UUID DEFAULT NULL)
RETURNS TABLE (
id UUID,
user_id UUID,
display_name TEXT,
email TEXT,
role TEXT,
brand_id UUID,
brand_name TEXT,
can_manage_products BOOLEAN,
can_manage_stops BOOLEAN,
can_manage_orders BOOLEAN,
can_manage_pickup BOOLEAN,
can_manage_messages BOOLEAN,
can_manage_refunds BOOLEAN,
can_manage_users BOOLEAN,
can_manage_water_log BOOLEAN,
can_manage_reports BOOLEAN,
active BOOLEAN,
created_at TIMESTAMPTZ,
last_login TIMESTAMPTZ
)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
RETURN QUERY
SELECT
au.id,
au.user_id,
COALESCE(
(au.raw_user_meta_data->>'display_name')::TEXT,
(au.raw_user_meta_data->>'full_name')::TEXT,
u.email
) AS display_name,
u.email,
au.role::TEXT,
au.brand_id,
b.name AS brand_name,
au.can_manage_products,
au.can_manage_stops,
au.can_manage_orders,
au.can_manage_pickup,
au.can_manage_messages,
au.can_manage_refunds,
au.can_manage_users,
au.can_manage_water_log,
COALESCE(au.can_manage_reports, false) AS can_manage_reports,
au.active,
au.created_at,
au.last_login
FROM admin_users au
JOIN auth.users u ON au.user_id = u.id
LEFT JOIN brands b ON au.brand_id = b.id
WHERE
-- platform_admin sees all; brand_admin sees only their brand
(
EXISTS (
SELECT 1 FROM admin_users caller
WHERE caller.user_id = auth.uid()
AND caller.role = 'platform_admin'
)
OR
(
EXISTS (
SELECT 1 FROM admin_users caller
WHERE caller.user_id = auth.uid()
AND caller.role = 'brand_admin'
AND caller.can_manage_users = true
)
AND (p_brand_id IS NULL OR au.brand_id = p_brand_id)
AND (
EXISTS (
SELECT 1 FROM admin_users caller
WHERE caller.user_id = auth.uid()
AND caller.brand_id = au.brand_id
)
)
)
)
ORDER BY au.created_at DESC;
END;
$$;
-- ═══════════════════════════════════════════════════════════════════════════
-- 2. create_admin_user — create a new admin user record
--
-- p_email: must already have a Supabase auth account
-- p_role: 'platform_admin' | 'brand_admin' | 'store_employee'
-- p_brand_id: required for brand_admin and store_employee
-- p_flags: JSONB with individual can_manage_* booleans
-- ═══════════════════════════════════════════════════════════════════════════
DROP FUNCTION IF EXISTS create_admin_user(TEXT, TEXT, UUID, JSONB);
CREATE OR REPLACE FUNCTION create_admin_user(
p_email TEXT,
p_role TEXT,
p_brand_id UUID DEFAULT NULL,
p_flags JSONB DEFAULT '{}'::JSONB
)
RETURNS TABLE (
id UUID,
user_id UUID,
display_name TEXT,
email TEXT,
role TEXT,
brand_id UUID,
active BOOLEAN,
created_at TIMESTAMPTZ
)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_caller_role TEXT;
v_caller_brand_id UUID;
v_caller_can_manage_users BOOLEAN;
v_target_user_id UUID;
v_new_id UUID;
BEGIN
-- Caller must be authenticated
IF auth.uid() IS NULL THEN
RAISE EXCEPTION 'Not authenticated';
END IF;
-- Look up caller
SELECT role, brand_id, can_manage_users
INTO v_caller_role, v_caller_brand_id, v_caller_can_manage_users
FROM admin_users
WHERE user_id = auth.uid();
IF v_caller_role IS NULL THEN
RAISE EXCEPTION 'Not an admin';
END IF;
-- Security: brand_admin cannot create platform_admin
IF v_caller_role = 'brand_admin' AND p_role = 'platform_admin' THEN
RAISE EXCEPTION 'Insufficient permissions to create a platform_admin';
END IF;
-- Security: brand_admin can only create users in their own brand
IF v_caller_role = 'brand_admin' THEN
IF p_brand_id IS DISTINCT FROM v_caller_brand_id THEN
RAISE EXCEPTION 'Insufficient permissions to create a user for another brand';
END IF;
IF p_brand_id IS NULL AND p_role = 'platform_admin' THEN
RAISE EXCEPTION 'brand_admin cannot create platform_admin';
END IF;
END IF;
-- Find the auth user by email
BEGIN
SELECT id INTO v_target_user_id FROM auth.users WHERE email = p_email;
EXCEPTION WHEN OTHERS THEN
RAISE EXCEPTION 'Auth user with email % not found', p_email;
END;
IF v_target_user_id IS NULL THEN
RAISE EXCEPTION 'Auth user with email % not found', p_email;
END IF;
-- Check no existing admin_users record for this user
IF EXISTS (SELECT 1 FROM admin_users WHERE user_id = v_target_user_id) THEN
RAISE EXCEPTION 'Admin user with email % already exists', p_email;
END IF;
-- Insert new admin user
INSERT INTO admin_users (
user_id, role, brand_id,
can_manage_products, can_manage_stops, can_manage_orders, can_manage_pickup,
can_manage_messages, can_manage_refunds, can_manage_users, can_manage_water_log,
can_manage_reports, active
) VALUES (
v_target_user_id, p_role, p_brand_id,
COALESCE((p_flags->>'can_manage_products')::BOOLEAN, false),
COALESCE((p_flags->>'can_manage_stops')::BOOLEAN, false),
COALESCE((p_flags->>'can_manage_orders')::BOOLEAN, false),
COALESCE((p_flags->>'can_manage_pickup')::BOOLEAN, false),
COALESCE((p_flags->>'can_manage_messages')::BOOLEAN, false),
COALESCE((p_flags->>'can_manage_refunds')::BOOLEAN, false),
COALESCE((p_flags->>'can_manage_users')::BOOLEAN, false),
COALESCE((p_flags->>'can_manage_water_log')::BOOLEAN, false),
COALESCE((p_flags->>'can_manage_reports')::BOOLEAN, false),
true
)
RETURNING id INTO v_new_id;
RETURN QUERY
SELECT
au.id, au.user_id,
COALESCE(
(au.raw_user_meta_data->>'display_name')::TEXT,
(au.raw_user_meta_data->>'full_name')::TEXT,
u.email
) AS display_name,
u.email, au.role::TEXT, au.brand_id, au.active, au.created_at
FROM admin_users au
JOIN auth.users u ON au.user_id = u.id
WHERE au.id = v_new_id;
END;
$$;
-- ═══════════════════════════════════════════════════════════════════════════
-- 3. update_admin_user — update role, brand, flags, or active status
-- ═══════════════════════════════════════════════════════════════════════════
DROP FUNCTION IF EXISTS update_admin_user(UUID, TEXT, UUID, JSONB, BOOLEAN);
CREATE OR REPLACE FUNCTION update_admin_user(
p_id UUID,
p_role TEXT DEFAULT NULL,
p_brand_id UUID DEFAULT NULL,
p_flags JSONB DEFAULT NULL,
p_active BOOLEAN DEFAULT NULL
)
RETURNS TABLE (
id UUID,
user_id UUID,
display_name TEXT,
email TEXT,
role TEXT,
brand_id UUID,
active BOOLEAN,
updated_at TIMESTAMPTZ
)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_caller_role TEXT;
v_caller_brand_id UUID;
v_caller_can_manage_users BOOLEAN;
v_target_role TEXT;
v_target_brand_id UUID;
BEGIN
IF auth.uid() IS NULL THEN
RAISE EXCEPTION 'Not authenticated';
END IF;
SELECT role, brand_id, can_manage_users
INTO v_caller_role, v_caller_brand_id, v_caller_can_manage_users
FROM admin_users
WHERE user_id = auth.uid();
IF v_caller_role IS NULL THEN
RAISE EXCEPTION 'Not an admin';
END IF;
-- Cannot update your own account's role
IF EXISTS (SELECT 1 FROM admin_users WHERE id = p_id AND user_id = auth.uid()) THEN
RAISE EXCEPTION 'Cannot update your own admin account';
END IF;
-- Look up target
SELECT role, brand_id INTO v_target_role, v_target_brand_id
FROM admin_users WHERE id = p_id;
IF v_target_role IS NULL THEN
RAISE EXCEPTION 'Admin user not found';
END IF;
-- Security: brand_admin cannot demote/promote platform_admin
IF v_caller_role = 'brand_admin' AND v_target_role = 'platform_admin' AND p_role IS NOT NULL THEN
RAISE EXCEPTION 'Insufficient permissions to modify a platform_admin';
END IF;
-- Security: brand_admin cannot give platform_admin role
IF v_caller_role = 'brand_admin' AND p_role = 'platform_admin' THEN
RAISE EXCEPTION 'Insufficient permissions to create a platform_admin';
END IF;
-- Security: brand_admin can only affect users in their brand
IF v_caller_role = 'brand_admin' THEN
IF v_target_brand_id != v_caller_brand_id THEN
RAISE EXCEPTION 'Insufficient permissions to modify a user from another brand';
END IF;
IF p_brand_id IS NOT NULL AND p_brand_id != v_caller_brand_id THEN
RAISE EXCEPTION 'Insufficient permissions to assign a user to another brand';
END IF;
IF p_role = 'platform_admin' THEN
RAISE EXCEPTION 'Insufficient permissions to create a platform_admin';
END IF;
END IF;
-- Apply updates
UPDATE admin_users SET
role = COALESCE(p_role, role),
brand_id = COALESCE(p_brand_id, brand_id),
can_manage_products = COALESCE((p_flags->>'can_manage_products')::BOOLEAN, can_manage_products),
can_manage_stops = COALESCE((p_flags->>'can_manage_stops')::BOOLEAN, can_manage_stops),
can_manage_orders = COALESCE((p_flags->>'can_manage_orders')::BOOLEAN, can_manage_orders),
can_manage_pickup = COALESCE((p_flags->>'can_manage_pickup')::BOOLEAN, can_manage_pickup),
can_manage_messages = COALESCE((p_flags->>'can_manage_messages')::BOOLEAN, can_manage_messages),
can_manage_refunds = COALESCE((p_flags->>'can_manage_refunds')::BOOLEAN, can_manage_refunds),
can_manage_users = COALESCE((p_flags->>'can_manage_users')::BOOLEAN, can_manage_users),
can_manage_water_log = COALESCE((p_flags->>'can_manage_water_log')::BOOLEAN, can_manage_water_log),
can_manage_reports = COALESCE((p_flags->>'can_manage_reports')::BOOLEAN, can_manage_reports),
active = COALESCE(p_active, active)
WHERE id = p_id;
RETURN QUERY
SELECT
au.id, au.user_id,
COALESCE(
(au.raw_user_meta_data->>'display_name')::TEXT,
(au.raw_user_meta_data->>'full_name')::TEXT,
u.email
) AS display_name,
u.email, au.role::TEXT, au.brand_id, au.active, NOW() AS updated_at
FROM admin_users au
JOIN auth.users u ON au.user_id = u.id
WHERE au.id = p_id;
END;
$$;
-- ═══════════════════════════════════════════════════════════════════════════
-- 4. delete_admin_user — remove admin user record
-- Cannot delete your own account.
-- ═══════════════════════════════════════════════════════════════════════════
DROP FUNCTION IF EXISTS delete_admin_user(UUID);
CREATE OR REPLACE FUNCTION delete_admin_user(p_id UUID)
RETURNS BOOLEAN
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_caller_role TEXT;
v_target_user_id UUID;
BEGIN
IF auth.uid() IS NULL THEN
RAISE EXCEPTION 'Not authenticated';
END IF;
SELECT role INTO v_caller_role FROM admin_users WHERE user_id = auth.uid();
IF v_caller_role IS NULL THEN
RAISE EXCEPTION 'Not an admin';
END IF;
-- Cannot delete self
SELECT user_id INTO v_target_user_id FROM admin_users WHERE id = p_id;
IF v_target_user_id = auth.uid() THEN
RAISE EXCEPTION 'Cannot delete your own admin account';
END IF;
-- brand_admin can only delete users in their brand
IF v_caller_role = 'brand_admin' THEN
IF NOT EXISTS (
SELECT 1 FROM admin_users
WHERE id = p_id AND brand_id = (SELECT brand_id FROM admin_users WHERE user_id = auth.uid())
) THEN
RAISE EXCEPTION 'Insufficient permissions to delete this user';
END IF;
END IF;
DELETE FROM admin_users WHERE id = p_id;
RETURN true;
END;
$$;
-- ═══════════════════════════════════════════════════════════════════════════
-- 5. Refresh PostgREST schema cache
-- ═══════════════════════════════════════════════════════════════════════════
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,23 @@
-- Migration: 034_password_change_flow
-- Adds must_change_password column to admin_users if not exists
-- Adds phone_number column to admin_users if not exists
-- Creates RPC to clear must_change_password after password update
alter table admin_users add column if not exists must_change_password boolean not null default false;
alter table admin_users add column if not exists phone_number text;
-- RPC: clear must_change_password for the authenticated user
create or replace function clear_must_change_password()
returns boolean
language plpgsql
security definer set search_path = public
as $$
begin
update admin_users
set must_change_password = false
where user_id = auth.uid()
and must_change_password = true;
return true;
end;
$$;
@@ -0,0 +1,106 @@
-- Migration: 035_admin_action_logs
-- Dedicated audit trail for admin user management actions
CREATE TABLE IF NOT EXISTS admin_action_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
action_type TEXT NOT NULL, -- 'create' | 'update' | 'delete'
admin_id UUID, -- auth.uid() of the admin performing the action
admin_email TEXT,
affected_user_id UUID, -- the admin_users.id being modified
brand_id UUID,
details JSONB DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Index for fast lookups by acting admin
CREATE INDEX IF NOT EXISTS idx_admin_action_logs_admin_id ON admin_action_logs(admin_id);
-- Index for fast lookups by affected user
CREATE INDEX IF NOT EXISTS idx_admin_action_logs_affected_user ON admin_action_logs(affected_user_id);
-- Index for time-based queries
CREATE INDEX IF NOT EXISTS idx_admin_action_logs_created_at ON admin_action_logs(created_at DESC);
-- RLS: platform_admin can read all; brand_admin reads only their brand
ALTER TABLE admin_action_logs ENABLE ROW LEVEL SECURITY;
CREATE POLICY "platform_admin_read_all_admin_action_logs" ON admin_action_logs
FOR SELECT USING (
EXISTS (
SELECT 1 FROM admin_users au
WHERE au.user_id = auth.uid()
AND au.role = 'platform_admin'
)
);
CREATE POLICY "brand_admin_read_own_brand_admin_action_logs" ON admin_action_logs
FOR SELECT USING (
EXISTS (
SELECT 1 FROM admin_users au
WHERE au.user_id = auth.uid()
AND au.role = 'brand_admin'
AND au.brand_id = admin_action_logs.brand_id
)
);
-- Append-only: any authenticated admin user can insert (enforced at RPC level)
CREATE POLICY "admin_action_logs_insert" ON admin_action_logs
FOR INSERT WITH CHECK (auth.uid() IS NOT NULL);
-- ============================================================
-- log_admin_action — SECURITY DEFINER, bypasses RLS
-- Called by admin user CRUD actions to record what changed.
-- Payload: { action_type, admin_id, admin_email, affected_user_id, brand_id, details }
-- ============================================================
CREATE OR REPLACE FUNCTION log_admin_action(p_payload JSONB)
RETURNS UUID
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_log_id UUID;
BEGIN
INSERT INTO admin_action_logs (
action_type, admin_id, admin_email, affected_user_id, brand_id, details
) VALUES (
p_payload->>'action_type',
CASE WHEN (p_payload->>'admin_id') IS NULL THEN NULL ELSE (p_payload->>'admin_id')::UUID END,
p_payload->>'admin_email',
CASE WHEN (p_payload->>'affected_user_id') IS NULL THEN NULL ELSE (p_payload->>'affected_user_id')::UUID END,
CASE WHEN (p_payload->>'brand_id') IS NULL THEN NULL ELSE (p_payload->>'brand_id')::UUID END,
COALESCE(p_payload->'details', '{}'::JSONB)
)
RETURNING id INTO v_log_id;
RETURN v_log_id;
END;
$$;
-- ============================================================
-- log_user_activity — SECURITY DEFINER, bypasses RLS
-- Tracks per-user activities: logins, password changes, profile updates.
-- Payload: { user_id, activity_type, details, ip_address, user_agent }
-- ============================================================
CREATE OR REPLACE FUNCTION log_user_activity(p_payload JSONB)
RETURNS UUID
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_log_id UUID;
BEGIN
INSERT INTO user_activity_logs (
user_id, activity_type, details, ip_address, user_agent
) VALUES (
CASE WHEN (p_payload->>'user_id') IS NULL THEN NULL ELSE (p_payload->>'user_id')::UUID END,
p_payload->>'activity_type',
COALESCE(p_payload->'details', '{}'::JSONB),
p_payload->>'ip_address',
p_payload->>'user_agent'
)
RETURNING id INTO v_log_id;
RETURN v_log_id;
END;
$$;
@@ -0,0 +1,36 @@
-- Migration: 036_user_activity_logs
-- Tracks per-user activities: logins, password changes, profile updates
CREATE TABLE IF NOT EXISTS user_activity_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
activity_type TEXT NOT NULL, -- 'login' | 'logout' | 'password_change' | 'profile_update' | 'email_change'
details JSONB DEFAULT '{}',
ip_address TEXT,
user_agent TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Index for fast per-user log lookups
CREATE INDEX IF NOT EXISTS idx_user_activity_logs_user_id ON user_activity_logs(user_id);
-- Index for time-based queries per user
CREATE INDEX IF NOT EXISTS idx_user_activity_logs_created_at ON user_activity_logs(created_at DESC);
-- RLS: user can read own logs; platform_admin can read all
ALTER TABLE user_activity_logs ENABLE ROW LEVEL SECURITY;
CREATE POLICY "user_read_own_activity_logs" ON user_activity_logs
FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "platform_admin_read_all_user_activity_logs" ON user_activity_logs
FOR SELECT USING (
EXISTS (
SELECT 1 FROM admin_users au
WHERE au.user_id = auth.uid()
AND au.role = 'platform_admin'
)
);
-- Any authenticated user can insert their own log entries
CREATE POLICY "user_insert_own_activity_logs" ON user_activity_logs
FOR INSERT WITH CHECK (auth.uid() = user_id);
@@ -0,0 +1,334 @@
-- Migration: 037_update_admin_user_profile
-- Extends update_admin_user to accept display_name and phone_number.
-- Updates admin_users and syncs auth.users raw_user_meta_data.
-- After update, writes to admin_action_logs.
DROP FUNCTION IF EXISTS update_admin_user(UUID, TEXT, UUID, JSONB, BOOLEAN, TEXT, TEXT);
CREATE OR REPLACE FUNCTION update_admin_user(
p_id UUID,
p_role TEXT DEFAULT NULL,
p_brand_id UUID DEFAULT NULL,
p_flags JSONB DEFAULT NULL,
p_active BOOLEAN DEFAULT NULL,
p_display_name TEXT DEFAULT NULL,
p_phone_number TEXT DEFAULT NULL
)
RETURNS TABLE (
id UUID,
user_id UUID,
display_name TEXT,
email TEXT,
role TEXT,
brand_id UUID,
active BOOLEAN,
phone_number TEXT,
updated_at TIMESTAMPTZ
)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_caller_role TEXT;
v_caller_brand_id UUID;
v_caller_can_manage_users BOOLEAN;
v_target_role TEXT;
v_target_brand_id UUID;
v_target_user_id UUID;
v_admin_email TEXT;
BEGIN
IF auth.uid() IS NULL THEN
RAISE EXCEPTION 'Not authenticated';
END IF;
SELECT role, brand_id, can_manage_users
INTO v_caller_role, v_caller_brand_id, v_caller_can_manage_users
FROM admin_users
WHERE user_id = auth.uid();
IF v_caller_role IS NULL THEN
RAISE EXCEPTION 'Not an admin';
END IF;
-- Cannot update your own account's role
IF EXISTS (SELECT 1 FROM admin_users WHERE id = p_id AND user_id = auth.uid()) THEN
RAISE EXCEPTION 'Cannot update your own admin account';
END IF;
-- Look up target
SELECT role, brand_id, user_id INTO v_target_role, v_target_brand_id, v_target_user_id
FROM admin_users WHERE id = p_id;
IF v_target_role IS NULL THEN
RAISE EXCEPTION 'Admin user not found';
END IF;
-- Security: brand_admin cannot demote/promote platform_admin
IF v_caller_role = 'brand_admin' AND v_target_role = 'platform_admin' AND p_role IS NOT NULL THEN
RAISE EXCEPTION 'Insufficient permissions to modify a platform_admin';
END IF;
-- Security: brand_admin cannot give platform_admin role
IF v_caller_role = 'brand_admin' AND p_role = 'platform_admin' THEN
RAISE EXCEPTION 'Insufficient permissions to create a platform_admin';
END IF;
-- Security: brand_admin can only affect users in their brand
IF v_caller_role = 'brand_admin' THEN
IF v_target_brand_id != v_caller_brand_id THEN
RAISE EXCEPTION 'Insufficient permissions to modify a user from another brand';
END IF;
IF p_brand_id IS NOT NULL AND p_brand_id != v_caller_brand_id THEN
RAISE EXCEPTION 'Insufficient permissions to assign a user to another brand';
END IF;
IF p_role = 'platform_admin' THEN
RAISE EXCEPTION 'Insufficient permissions to create a platform_admin';
END IF;
END IF;
-- Get caller email for audit log
SELECT email INTO v_admin_email FROM auth.users WHERE id = auth.uid();
-- Apply updates to admin_users
UPDATE admin_users SET
role = COALESCE(p_role, role),
brand_id = COALESCE(p_brand_id, brand_id),
can_manage_products = COALESCE((p_flags->>'can_manage_products')::BOOLEAN, can_manage_products),
can_manage_stops = COALESCE((p_flags->>'can_manage_stops')::BOOLEAN, can_manage_stops),
can_manage_orders = COALESCE((p_flags->>'can_manage_orders')::BOOLEAN, can_manage_orders),
can_manage_pickup = COALESCE((p_flags->>'can_manage_pickup')::BOOLEAN, can_manage_pickup),
can_manage_messages = COALESCE((p_flags->>'can_manage_messages')::BOOLEAN, can_manage_messages),
can_manage_refunds = COALESCE((p_flags->>'can_manage_refunds')::BOOLEAN, can_manage_refunds),
can_manage_users = COALESCE((p_flags->>'can_manage_users')::BOOLEAN, can_manage_users),
can_manage_water_log = COALESCE((p_flags->>'can_manage_water_log')::BOOLEAN, can_manage_water_log),
can_manage_reports = COALESCE((p_flags->>'can_manage_reports')::BOOLEAN, can_manage_reports),
active = COALESCE(p_active, active),
display_name = COALESCE(p_display_name, display_name),
phone_number = COALESCE(p_phone_number, phone_number)
WHERE id = p_id;
-- Sync display_name / phone_number to auth.users raw_user_meta_data
IF p_display_name IS NOT NULL OR p_phone_number IS NOT NULL THEN
UPDATE auth.users SET
raw_user_meta_data = jsonb_strip_nulls(
jsonb_set(
COALESCE(raw_user_meta_data, '{}'::jsonb),
'{display_name}',
to_jsonb(p_display_name)
) ||
jsonb_set(
COALESCE(raw_user_meta_data, '{}'::jsonb),
'{phone_number}',
to_jsonb(p_phone_number)
)
)
WHERE id = v_target_user_id;
END IF;
-- Audit log: record the action
PERFORM log_admin_action(jsonb_build_object(
'action_type', 'update',
'admin_id', auth.uid(),
'admin_email', v_admin_email,
'affected_user_id', v_target_user_id,
'brand_id', COALESCE(p_brand_id, v_target_brand_id),
'details', jsonb_build_object(
'changed_fields', ARRAY_REMOVE(ARRAY[
CASE WHEN p_role IS NOT NULL THEN 'role' ELSE NULL END,
CASE WHEN p_brand_id IS NOT NULL THEN 'brand_id' ELSE NULL END,
CASE WHEN p_flags IS NOT NULL THEN 'flags' ELSE NULL END,
CASE WHEN p_active IS NOT NULL THEN 'active' ELSE NULL END,
CASE WHEN p_display_name IS NOT NULL THEN 'display_name' ELSE NULL END,
CASE WHEN p_phone_number IS NOT NULL THEN 'phone_number' ELSE NULL END
], NULL)
)
));
RETURN QUERY
SELECT
au.id, au.user_id,
COALESCE(
(au.raw_user_meta_data->>'display_name')::TEXT,
u.email
) AS display_name,
u.email, au.role::TEXT, au.brand_id, au.active,
au.phone_number, NOW() AS updated_at
FROM admin_users au
JOIN auth.users u ON au.user_id = u.id
WHERE au.id = p_id;
END;
$$;
-- Update create_admin_user to also log
CREATE OR REPLACE FUNCTION create_admin_user(
p_email TEXT,
p_role TEXT,
p_brand_id UUID DEFAULT NULL,
p_flags JSONB DEFAULT '{}'::JSONB
)
RETURNS TABLE (
id UUID,
user_id UUID,
display_name TEXT,
email TEXT,
role TEXT,
brand_id UUID,
active BOOLEAN,
created_at TIMESTAMPTZ
)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_caller_role TEXT;
v_caller_brand_id UUID;
v_caller_can_manage_users BOOLEAN;
v_target_user_id UUID;
v_new_id UUID;
v_admin_email TEXT;
BEGIN
IF auth.uid() IS NULL THEN
RAISE EXCEPTION 'Not authenticated';
END IF;
SELECT role, brand_id, can_manage_users
INTO v_caller_role, v_caller_brand_id, v_caller_can_manage_users
FROM admin_users
WHERE user_id = auth.uid();
IF v_caller_role IS NULL THEN
RAISE EXCEPTION 'Not an admin';
END IF;
IF v_caller_role = 'brand_admin' AND p_role = 'platform_admin' THEN
RAISE EXCEPTION 'Insufficient permissions to create a platform_admin';
END IF;
IF v_caller_role = 'brand_admin' THEN
IF p_brand_id IS DISTINCT FROM v_caller_brand_id THEN
RAISE EXCEPTION 'Insufficient permissions to create a user for another brand';
END IF;
IF p_brand_id IS NULL AND p_role = 'platform_admin' THEN
RAISE EXCEPTION 'brand_admin cannot create platform_admin';
END IF;
END IF;
BEGIN
SELECT id INTO v_target_user_id FROM auth.users WHERE email = p_email;
EXCEPTION WHEN OTHERS THEN
RAISE EXCEPTION 'Auth user with email % not found', p_email;
END;
IF v_target_user_id IS NULL THEN
RAISE EXCEPTION 'Auth user with email % not found', p_email;
END IF;
IF EXISTS (SELECT 1 FROM admin_users WHERE user_id = v_target_user_id) THEN
RAISE EXCEPTION 'Admin user with email % already exists', p_email;
END IF;
-- Get caller email for audit log
SELECT email INTO v_admin_email FROM auth.users WHERE id = auth.uid();
INSERT INTO admin_users (
user_id, role, brand_id,
can_manage_products, can_manage_stops, can_manage_orders, can_manage_pickup,
can_manage_messages, can_manage_refunds, can_manage_users, can_manage_water_log,
can_manage_reports, active
) VALUES (
v_target_user_id, p_role, p_brand_id,
COALESCE((p_flags->>'can_manage_products')::BOOLEAN, false),
COALESCE((p_flags->>'can_manage_stops')::BOOLEAN, false),
COALESCE((p_flags->>'can_manage_orders')::BOOLEAN, false),
COALESCE((p_flags->>'can_manage_pickup')::BOOLEAN, false),
COALESCE((p_flags->>'can_manage_messages')::BOOLEAN, false),
COALESCE((p_flags->>'can_manage_refunds')::BOOLEAN, false),
COALESCE((p_flags->>'can_manage_users')::BOOLEAN, false),
COALESCE((p_flags->>'can_manage_water_log')::BOOLEAN, false),
COALESCE((p_flags->>'can_manage_reports')::BOOLEAN, false),
true
)
RETURNING id INTO v_new_id;
-- Audit log: record the creation
PERFORM log_admin_action(jsonb_build_object(
'action_type', 'create',
'admin_id', auth.uid(),
'admin_email', v_admin_email,
'affected_user_id', v_target_user_id,
'brand_id', p_brand_id,
'details', jsonb_build_object('new_role', p_role)
));
RETURN QUERY
SELECT
au.id, au.user_id,
COALESCE(
(au.raw_user_meta_data->>'display_name')::TEXT,
(au.raw_user_meta_data->>'full_name')::TEXT,
u.email
) AS display_name,
u.email, au.role::TEXT, au.brand_id, au.active, au.created_at
FROM admin_users au
JOIN auth.users u ON au.user_id = u.id
WHERE au.id = v_new_id;
END;
$$;
-- Update delete_admin_user to also log
CREATE OR REPLACE FUNCTION delete_admin_user(p_id UUID)
RETURNS BOOLEAN
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_caller_role TEXT;
v_target_user_id UUID;
v_admin_email TEXT;
v_target_brand_id UUID;
BEGIN
IF auth.uid() IS NULL THEN
RAISE EXCEPTION 'Not authenticated';
END IF;
SELECT role INTO v_caller_role FROM admin_users WHERE user_id = auth.uid();
IF v_caller_role IS NULL THEN
RAISE EXCEPTION 'Not an admin';
END IF;
SELECT user_id, brand_id INTO v_target_user_id, v_target_brand_id
FROM admin_users WHERE id = p_id;
SELECT email INTO v_admin_email FROM auth.users WHERE id = auth.uid();
IF v_target_user_id = auth.uid() THEN
RAISE EXCEPTION 'Cannot delete your own admin account';
END IF;
IF v_caller_role = 'brand_admin' THEN
IF NOT EXISTS (
SELECT 1 FROM admin_users
WHERE id = p_id AND brand_id = (SELECT brand_id FROM admin_users WHERE user_id = auth.uid())
) THEN
RAISE EXCEPTION 'Insufficient permissions to delete this user';
END IF;
END IF;
-- Audit log: record the deletion
PERFORM log_admin_action(jsonb_build_object(
'action_type', 'delete',
'admin_id', auth.uid(),
'admin_email', v_admin_email,
'affected_user_id', v_target_user_id,
'brand_id', v_target_brand_id,
'details', jsonb_build_object('deleted_admin_id', p_id)
));
DELETE FROM admin_users WHERE id = p_id;
RETURN true;
END;
$$;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,31 @@
-- Migration 038: Product Images + Public Site Polish
-- Idempotent: ADD COLUMN IF NOT EXISTS, CREATE OR REPLACE FUNCTION
-- ── 1. Add image_url to products ───────────────────────────────────────────────
ALTER TABLE products ADD COLUMN IF NOT EXISTS image_url TEXT;
-- ── 2. Update get_stop_products to include image_url ──────────────────────────
CREATE OR REPLACE FUNCTION public.get_stop_products(p_stop_id UUID)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
RETURN jsonb_build_object('products', (
SELECT COALESCE(jsonb_agg(
jsonb_build_object(
'id', ps.id,
'product_id', ps.product_id,
'name', p.name,
'type', p.type,
'price', p.price,
'image_url', p.image_url
)
), '[]'::JSONB)
FROM product_stops ps
JOIN products p ON p.id = ps.product_id
WHERE ps.stop_id = p_stop_id
));
END;
$$;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,8 @@
-- Migration 039: Shipping Fulfillment Fields
-- Idempotent: ADD COLUMN IF NOT EXISTS
ALTER TABLE orders ADD COLUMN IF NOT EXISTS shipping_status TEXT NOT NULL DEFAULT 'pending';
ALTER TABLE orders ADD COLUMN IF NOT EXISTS tracking_number TEXT;
COMMENT ON COLUMN orders.shipping_status IS 'pending | label_created | shipped | delivered | returned';
COMMENT ON COLUMN orders.tracking_number IS 'Carrier tracking number';
@@ -0,0 +1,86 @@
-- Migration 040: Shipping Fulfillment RPCs
-- Idempotent: CREATE OR REPLACE FUNCTION
-- ── 1. update_shipping_order ──────────────────────────────────────────────────
-- Updates shipping_status and tracking_number for an order.
-- Requires can_manage_orders permission (checked in server action via getAdminUser).
CREATE OR REPLACE FUNCTION public.update_shipping_order(
p_order_id UUID,
p_shipping_status TEXT,
p_tracking_number TEXT DEFAULT NULL
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
UPDATE orders SET
shipping_status = p_shipping_status,
tracking_number = p_tracking_number,
updated_at = now()
WHERE id = p_order_id;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Order not found');
END IF;
RETURN jsonb_build_object('success', true);
END;
$$;
-- ── 2. get_shipping_orders ───────────────────────────────────────────────────
-- Returns orders that contain at least one shipping-line item.
-- Filtered by brand_id when p_brand_id is provided.
CREATE OR REPLACE FUNCTION public.get_shipping_orders(p_brand_id UUID DEFAULT NULL)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_result JSONB;
BEGIN
SELECT jsonb_agg(
jsonb_build_object(
'id', o.id,
'customer_name', o.customer_name,
'customer_email', o.customer_email,
'customer_phone', o.customer_phone,
'status', o.status,
'subtotal', o.subtotal,
'shipping_status', o.shipping_status,
'tracking_number', o.tracking_number,
'created_at', o.created_at,
'brand_id', o.brand_id,
'order_items', COALESCE((
SELECT jsonb_agg(jsonb_build_object(
'id', oi.id,
'product_id', oi.product_id,
'quantity', oi.quantity,
'price', oi.price,
'fulfillment', oi.fulfillment,
'products', jsonb_build_object('name', p.name)
))
FROM order_items oi
JOIN products p ON oi.product_id = p.id
WHERE oi.order_id = o.id AND oi.fulfillment = 'shipping'
), '[]'::JSONB)
)
)
INTO v_result
FROM orders o
WHERE o.id IN (
SELECT DISTINCT oi.order_id
FROM order_items oi
WHERE oi.fulfillment = 'shipping'
)
AND (
p_brand_id IS NULL
OR o.brand_id = p_brand_id
)
ORDER BY o.created_at DESC;
RETURN COALESCE(v_result, '[]'::JSONB);
END;
$$;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,105 @@
-- Migration 041: Payment Settings
-- Creates payment_settings table and RPCs for provider configuration.
-- ── 1. payment_settings table ─────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.payment_settings (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
provider TEXT, -- stripe | square | manual
stripe_publishable_key TEXT,
stripe_secret_key TEXT,
square_access_token TEXT,
square_location_id TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(brand_id)
);
-- ── 2. RLS ──────────────────────────────────────────────────────────────────
ALTER TABLE public.payment_settings ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "Brand admin can read payment_settings" ON public.payment_settings;
CREATE POLICY "Brand admin can read payment_settings"
ON public.payment_settings FOR SELECT TO authenticated
USING (
brand_id IN (
SELECT brand_id FROM admin_users
WHERE admin_users.user_id = auth.uid()
AND admin_users.role = 'brand_admin'
)
);
DROP POLICY IF EXISTS "Platform admin can read payment_settings" ON public.payment_settings;
CREATE POLICY "Platform admin can read payment_settings"
ON public.payment_settings FOR SELECT TO authenticated
USING (
EXISTS (
SELECT 1 FROM admin_users
WHERE admin_users.user_id = auth.uid()
AND admin_users.role = 'platform_admin'
)
);
-- Writes go through SECURITY DEFINER helpers only.
-- ── 3. get_payment_settings ──────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION public.get_payment_settings(p_brand_id UUID)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_result JSONB;
BEGIN
SELECT jsonb_build_object(
'id', id,
'brand_id', brand_id,
'provider', provider,
'stripe_publishable_key', stripe_publishable_key,
'stripe_secret_key', stripe_secret_key,
'square_access_token', square_access_token,
'square_location_id', square_location_id,
'updated_at', updated_at::TEXT
) INTO v_result
FROM payment_settings
WHERE brand_id = p_brand_id;
RETURN v_result;
END;
$$;
-- ── 4. upsert_payment_settings ────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION public.upsert_payment_settings(
p_brand_id UUID,
p_provider TEXT,
p_stripe_publishable_key TEXT DEFAULT NULL,
p_stripe_secret_key TEXT DEFAULT NULL,
p_square_access_token TEXT DEFAULT NULL,
p_square_location_id TEXT DEFAULT NULL
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
INSERT INTO payment_settings (
brand_id, provider,
stripe_publishable_key, stripe_secret_key,
square_access_token, square_location_id
)
VALUES (
p_brand_id, p_provider,
p_stripe_publishable_key, p_stripe_secret_key,
p_square_access_token, p_square_location_id
)
ON CONFLICT (brand_id) DO UPDATE SET
provider = EXCLUDED.provider,
stripe_publishable_key = EXCLUDED.stripe_publishable_key,
stripe_secret_key = EXCLUDED.stripe_secret_key,
square_access_token = EXCLUDED.square_access_token,
square_location_id = EXCLUDED.square_location_id,
updated_at = now();
RETURN jsonb_build_object('success', true);
END;
$$;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,130 @@
-- Migration 042: Product Import RPC + upsert_product
-- Idempotent: CREATE OR REPLACE FUNCTION
-- ── 1. upsert_product ─────────────────────────────────────────────────────────
-- Upserts a single product by name (within brand). Returns the product id.
-- Used by the CSV import tool to create or update products in bulk.
CREATE OR REPLACE FUNCTION public.upsert_product(
p_brand_id UUID,
p_name TEXT,
p_price NUMERIC,
p_type TEXT,
p_description TEXT DEFAULT '',
p_active BOOLEAN DEFAULT true,
p_image_url TEXT DEFAULT NULL
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_product_id UUID;
v_is_update BOOLEAN := false;
BEGIN
-- Check if product with same name exists in this brand
SELECT id INTO v_product_id
FROM products
WHERE brand_id = p_brand_id AND name = p_name
LIMIT 1;
IF v_product_id IS NOT NULL THEN
-- Update existing
UPDATE products SET
name = p_name,
description = p_description,
price = p_price,
type = p_type,
active = p_active,
image_url = COALESCE(p_image_url, image_url),
updated_at = now()
WHERE id = v_product_id
RETURNING id INTO v_product_id;
v_is_update := true;
ELSE
-- Insert new
INSERT INTO products (brand_id, name, description, price, type, active, image_url)
VALUES (p_brand_id, p_name, p_description, p_price, p_type, p_active, p_image_url)
RETURNING id INTO v_product_id;
END IF;
RETURN jsonb_build_object(
'id', v_product_id,
'is_update', v_is_update,
'name', p_name
);
END;
$$;
-- ── 2. bulk_upsert_products ──────────────────────────────────────────────────
-- Takes an array of product records and upserts them all within a brand.
-- Returns a summary: { created, updated, errors }.
CREATE OR REPLACE FUNCTION public.bulk_upsert_products(
p_brand_id UUID,
p_products JSONB -- array of { name, description, price, type, active, image_url }
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_entry JSONB;
v_result JSONB := '{"created": 0, "updated": 0, "errors": []}'::JSONB;
v_name TEXT;
v_desc TEXT;
v_price NUMERIC;
v_type TEXT;
v_active BOOLEAN;
v_img TEXT;
v_was_new BOOLEAN;
BEGIN
FOR v_entry IN SELECT * FROM jsonb_array_elements(p_products)
LOOP
BEGIN
v_name := nullif(v_entry->>'name', '');
v_desc := coalesce(nullif(v_entry->>'description', ''), '');
v_price := nullif((v_entry->>'price')::TEXT, '')::NUMERIC;
v_type := nullif(v_entry->>'type', '');
v_active := coalesce((v_entry->>'active')::BOOLEAN, true);
v_img := nullif(v_entry->>'image_url', '');
IF v_name IS NULL OR v_name = '' THEN
RAISE EXCEPTION 'Product name is required';
END IF;
IF v_price IS NULL OR v_price < 0 THEN
RAISE EXCEPTION 'Invalid price for product: %', v_name;
END IF;
IF v_type NOT IN ('Pickup', 'Shipping', 'Pickup & Shipping') THEN
RAISE EXCEPTION 'Invalid type for product: %. Must be Pickup, Shipping, or Pickup & Shipping', v_name;
END IF;
v_was_new := NOT EXISTS (
SELECT 1 FROM products WHERE brand_id = p_brand_id AND name = v_name
);
PERFORM upsert_product(p_brand_id, v_name, v_desc, v_price, v_type, v_active, v_img);
IF v_was_new THEN
v_result := jsonb_set(v_result, '{created}',
to_jsonb((v_result->>'created')::INTEGER + 1));
ELSE
v_result := jsonb_set(v_result, '{updated}',
to_jsonb((v_result->>'updated')::INTEGER + 1));
END IF;
EXCEPTION WHEN OTHERS THEN
v_result := jsonb_set(
v_result, '{errors}',
v_result->'errors' || jsonb_build_array(
jsonb_build_object('product', v_name, 'error', SQLERRM)
)
);
END;
END LOOP;
RETURN v_result;
END;
$$;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,10 @@
-- Migration 043: Square Sync Settings
-- Adds Square Sync configuration columns to payment_settings
ALTER TABLE payment_settings ADD COLUMN IF NOT EXISTS square_sync_enabled BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE payment_settings ADD COLUMN IF NOT EXISTS square_inventory_mode TEXT NOT NULL DEFAULT 'none';
-- Values: 'none' | 'rc_to_square' | 'square_to_rc' | 'bidirectional'
ALTER TABLE payment_settings ADD COLUMN IF NOT EXISTS square_last_sync_at TIMESTAMPTZ;
ALTER TABLE payment_settings ADD COLUMN IF NOT EXISTS square_last_sync_error TEXT;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,63 @@
-- Migration 044: Square Sync Log
-- Append-only log of all Square sync events
CREATE TABLE IF NOT EXISTS public.square_sync_log (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
event_type TEXT NOT NULL,
-- 'oauth_connected' | 'oauth_disconnected' | 'product_synced' | 'order_synced' | 'inventory_synced' | 'error'
direction TEXT,
-- 'rc_to_square' | 'square_to_rc'
entity_type TEXT,
-- 'product' | 'order' | 'inventory'
entity_id UUID,
status TEXT NOT NULL,
-- 'success' | 'error' | 'partial'
message TEXT,
details JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_square_sync_log_brand_created
ON square_sync_log(brand_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_square_sync_log_event_type
ON square_sync_log(event_type, created_at DESC);
-- SECURITY DEFINER function to record sync log entries
-- Bypasses RLS since operational events use the same pattern
CREATE OR REPLACE FUNCTION public.record_square_sync_event(
p_brand_id UUID,
p_event_type TEXT,
p_direction TEXT,
p_entity_type TEXT,
p_entity_id UUID,
p_status TEXT,
p_message TEXT,
p_details JSONB DEFAULT '{}'::JSONB
)
RETURNS void
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
INSERT INTO square_sync_log (
brand_id, event_type, direction, entity_type, entity_id, status, message, details
) VALUES (
p_brand_id, p_event_type, p_direction, p_entity_type, p_entity_id, p_status, p_message, p_details
);
END;
$$;
-- RLS: brands can read their own sync logs; platform_admin can read all
ALTER TABLE square_sync_log ENABLE ROW LEVEL SECURITY;
CREATE POLICY "brand_admin_read_own_sync_log" ON square_sync_log
FOR SELECT USING (
(current_setting('app.settings.role', true)::TEXT = 'brand_admin' AND brand_id = current_setting('app.settings.brand_id', true)::UUID)
OR current_setting('app.settings.role', true)::TEXT = 'platform_admin'
);
CREATE POLICY "authenticated_insert_sync_log" ON square_sync_log
FOR INSERT WITH CHECK (auth.uid() IS NOT NULL); -- SECURITY DEFINER RPCs run as table owner; authenticated users may also append
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,53 @@
-- Migration 045: Extend payment_settings RPCs for Square Sync
-- Adds square_sync_enabled, square_inventory_mode, square_last_sync_at, square_last_sync_error
-- to upsert_payment_settings and get_payment_settings
CREATE OR REPLACE FUNCTION public.upsert_payment_settings(
p_brand_id UUID,
p_provider TEXT,
p_stripe_publishable_key TEXT DEFAULT NULL,
p_stripe_secret_key TEXT DEFAULT NULL,
p_square_access_token TEXT DEFAULT NULL,
p_square_location_id TEXT DEFAULT NULL,
p_square_sync_enabled BOOLEAN DEFAULT NULL,
p_square_inventory_mode TEXT DEFAULT NULL
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
INSERT INTO payment_settings (
brand_id, provider,
stripe_publishable_key, stripe_secret_key,
square_access_token, square_location_id,
square_sync_enabled, square_inventory_mode
)
VALUES (
p_brand_id, p_provider,
p_stripe_publishable_key, p_stripe_secret_key,
p_square_access_token, p_square_location_id,
COALESCE(p_square_sync_enabled, false),
COALESCE(p_square_inventory_mode, 'none')
)
ON CONFLICT (brand_id) DO UPDATE SET
provider = COALESCE(p_provider, payment_settings.provider),
stripe_publishable_key = COALESCE(p_stripe_publishable_key, payment_settings.stripe_publishable_key),
stripe_secret_key = COALESCE(p_stripe_secret_key, payment_settings.stripe_secret_key),
square_access_token = COALESCE(p_square_access_token, payment_settings.square_access_token),
square_location_id = COALESCE(p_square_location_id, payment_settings.square_location_id),
square_sync_enabled = COALESCE(p_square_sync_enabled, payment_settings.square_sync_enabled),
square_inventory_mode = COALESCE(p_square_inventory_mode, payment_settings.square_inventory_mode),
updated_at = now()
RETURNING jsonb_build_object(
'id', id,
'brand_id', brand_id,
'provider', provider,
'square_sync_enabled', square_sync_enabled,
'square_inventory_mode', square_inventory_mode,
'square_last_sync_at', square_last_sync_at,
'square_last_sync_error', square_last_sync_error
);
END;
$$;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,748 @@
-- Migration 046: Wholesale Portal Tables
-- Core wholesale ordering portal for Route Commerce.
-- Idempotent — all statements use IF NOT EXISTS / CREATE OR REPLACE.
BEGIN;
-- ── 1. Wholesale Customers ─────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.wholesale_customers (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID, -- FK added after auth.users exists
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
company_name TEXT,
contact_name TEXT,
email TEXT,
phone TEXT,
billing_address TEXT,
shipping_address TEXT,
account_status TEXT NOT NULL DEFAULT 'active',
credit_limit NUMERIC(10,2) NOT NULL DEFAULT 0,
deposits_enabled BOOLEAN NOT NULL DEFAULT false,
deposit_threshold NUMERIC(10,2),
deposit_percentage INTEGER CHECK (deposit_percentage IS NULL OR deposit_percentage BETWEEN 1 AND 100),
order_email TEXT,
invoice_email TEXT,
admin_notes TEXT,
role TEXT NOT NULL DEFAULT 'buyer',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Add the FK after the table exists. Use DO block so it's idempotent.
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'wholesale_customers_user_id_fkey'
) THEN
ALTER TABLE public.wholesale_customers
ADD CONSTRAINT wholesale_customers_user_id_fkey
FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE SET NULL
NOT VALID;
END IF;
END $$;
-- Individual unique constraint on user_id (allows FK reference from other tables)
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'wholesale_customers_user_id_unique'
) THEN
ALTER TABLE public.wholesale_customers
ADD CONSTRAINT wholesale_customers_user_id_unique
UNIQUE (user_id);
END IF;
END $$;
-- Composite unique: one user + one brand (still useful for the upsert ON CONFLICT)
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'wholesale_customers_brand_user_unique'
) THEN
ALTER TABLE public.wholesale_customers
ADD CONSTRAINT wholesale_customers_brand_user_unique
UNIQUE (brand_id, user_id);
END IF;
END $$;
-- RLS
ALTER TABLE public.wholesale_customers ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "brand_admin_manage_wholesale_customers" ON wholesale_customers;
CREATE POLICY "brand_admin_manage_wholesale_customers" ON wholesale_customers
FOR ALL USING (
current_setting('app.settings.role', true)::TEXT IN ('brand_admin', 'platform_admin')
AND (
current_setting('app.settings.role', true)::TEXT = 'platform_admin'
OR brand_id = current_setting('app.settings.brand_id', true)::UUID
)
);
DROP POLICY IF EXISTS "wholesale_customer_read_own" ON wholesale_customers;
CREATE POLICY "wholesale_customer_read_own" ON wholesale_customers
FOR SELECT USING (
current_setting('app.settings.role', true)::TEXT = 'wholesale_customer'
AND user_id = current_setting('app.settings.user_id', true)::UUID
);
-- ── 2. Wholesale Products ───────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.wholesale_products (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
rc_product_id UUID REFERENCES products(id) ON DELETE SET NULL,
name TEXT NOT NULL,
description TEXT,
unit_type TEXT NOT NULL DEFAULT 'each',
unit_type_custom TEXT,
availability TEXT NOT NULL DEFAULT 'unavailable',
qty_available NUMERIC(10,2) DEFAULT 0,
season_start DATE,
season_end DATE,
price_tiers JSONB NOT NULL DEFAULT '[]',
hp_sku TEXT,
hp_item_id TEXT,
internal_notes TEXT,
handling_instructions TEXT,
transport_temp TEXT,
storage_warning TEXT,
loading_notes TEXT,
product_label TEXT,
pack_style TEXT,
container_type TEXT,
container_size_code TEXT,
units_per_container INTEGER,
container_notes TEXT,
default_pickup_location TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE public.wholesale_products ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "brand_admin_manage_wholesale_products" ON wholesale_products;
CREATE POLICY "brand_admin_manage_wholesale_products" ON wholesale_products
FOR ALL USING (
current_setting('app.settings.role', true)::TEXT IN ('brand_admin', 'platform_admin')
AND (
current_setting('app.settings.role', true)::TEXT = 'platform_admin'
OR brand_id = current_setting('app.settings.brand_id', true)::UUID
)
);
-- ── 3. Wholesale Orders ────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.wholesale_orders (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
customer_id UUID NOT NULL, -- FK added below after customers table exists
wc_order_id BIGINT,
status TEXT NOT NULL DEFAULT 'pending',
fulfillment_status TEXT NOT NULL DEFAULT 'unfulfilled',
payment_status TEXT NOT NULL DEFAULT 'unpaid',
anticipated_pickup_date DATE,
subtotal NUMERIC(10,2) NOT NULL DEFAULT 0,
deposit_required NUMERIC(10,2) DEFAULT 0,
deposit_paid NUMERIC(10,2) NOT NULL DEFAULT 0,
balance_due NUMERIC(10,2) NOT NULL DEFAULT 0,
assigned_employee_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
fulfillment_notes TEXT,
internal_notes TEXT,
invoice_number TEXT,
invoice_pdf_path TEXT,
invoice_token TEXT,
invoice_type TEXT,
deposit_percentage INTEGER,
fulfilled_at TIMESTAMPTZ,
fulfilled_by UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Add customer FK now that customers table definitely exists
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'wholesale_orders_customer_id_fkey'
) THEN
ALTER TABLE public.wholesale_orders
ADD CONSTRAINT wholesale_orders_customer_id_fkey
FOREIGN KEY (customer_id) REFERENCES wholesale_customers(id) ON DELETE RESTRICT
NOT VALID;
END IF;
END $$;
ALTER TABLE public.wholesale_orders ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "brand_admin_manage_wholesale_orders" ON wholesale_orders;
CREATE POLICY "brand_admin_manage_wholesale_orders" ON wholesale_orders
FOR ALL USING (
current_setting('app.settings.role', true)::TEXT IN ('brand_admin', 'platform_admin')
AND (
current_setting('app.settings.role', true)::TEXT = 'platform_admin'
OR brand_id = current_setting('app.settings.brand_id', true)::UUID
)
);
-- ── 4. Wholesale Order Items ───────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.wholesale_order_items (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
wholesale_order_id UUID NOT NULL, -- FK added below
product_id UUID NOT NULL, -- FK added below
quantity NUMERIC(10,2) NOT NULL,
unit_price NUMERIC(10,2) NOT NULL,
line_total NUMERIC(10,2) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'wholesale_order_items_order_id_fkey'
) THEN
ALTER TABLE public.wholesale_order_items
ADD CONSTRAINT wholesale_order_items_order_id_fkey
FOREIGN KEY (wholesale_order_id) REFERENCES wholesale_orders(id) ON DELETE CASCADE
NOT VALID;
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'wholesale_order_items_product_id_fkey'
) THEN
ALTER TABLE public.wholesale_order_items
ADD CONSTRAINT wholesale_order_items_product_id_fkey
FOREIGN KEY (product_id) REFERENCES wholesale_products(id) ON DELETE RESTRICT
NOT VALID;
END IF;
END $$;
ALTER TABLE public.wholesale_order_items ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "brand_admin_manage_wholesale_order_items" ON wholesale_order_items;
CREATE POLICY "brand_admin_manage_wholesale_order_items" ON wholesale_order_items
FOR ALL USING (
current_setting('app.settings.role', true)::TEXT IN ('brand_admin', 'platform_admin')
);
-- ── 5. Deposit Transactions ─────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.wholesale_deposits (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
wholesale_order_id UUID NOT NULL, -- FK added below
amount NUMERIC(10,2) NOT NULL,
payment_method TEXT,
reference TEXT,
recorded_by UUID NOT NULL REFERENCES auth.users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'wholesale_deposits_order_id_fkey'
) THEN
ALTER TABLE public.wholesale_deposits
ADD CONSTRAINT wholesale_deposits_order_id_fkey
FOREIGN KEY (wholesale_order_id) REFERENCES wholesale_orders(id) ON DELETE CASCADE
NOT VALID;
END IF;
END $$;
ALTER TABLE public.wholesale_deposits ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "brand_admin_manage_deposits" ON wholesale_deposits;
CREATE POLICY "brand_admin_manage_deposits" ON wholesale_deposits
FOR ALL USING (
current_setting('app.settings.role', true)::TEXT IN ('brand_admin', 'platform_admin')
);
-- ── 6. Wholesale Settings ───────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.wholesale_settings (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
brand_id UUID NOT NULL UNIQUE REFERENCES brands(id) ON DELETE CASCADE,
portal_page_id UUID,
price_sheet_page_id UUID,
require_approval BOOLEAN NOT NULL DEFAULT true,
min_order_amount NUMERIC(10,2),
online_payment_enabled BOOLEAN NOT NULL DEFAULT false,
pickup_location TEXT,
fob_location TEXT,
from_email TEXT,
invoice_business_name TEXT,
last_invoice_number INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE public.wholesale_settings ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "brand_admin_manage_wholesale_settings" ON wholesale_settings;
CREATE POLICY "brand_admin_manage_wholesale_settings" ON wholesale_settings
FOR ALL USING (
current_setting('app.settings.role', true)::TEXT IN ('brand_admin', 'platform_admin')
AND (
current_setting('app.settings.role', true)::TEXT = 'platform_admin'
OR brand_id = current_setting('app.settings.brand_id', true)::UUID
)
);
DROP FUNCTION IF EXISTS public.get_wholesale_orders(UUID);
DROP FUNCTION IF EXISTS public.upsert_wholesale_settings(UUID,UUID,UUID,BOOLEAN,NUMERIC,BOOLEAN,TEXT,TEXT,TEXT,TEXT);
DROP FUNCTION IF EXISTS public.get_wholesale_settings(UUID);
DROP FUNCTION IF EXISTS public.get_wholesale_customers(UUID);
DROP FUNCTION IF EXISTS public.upsert_wholesale_customer(UUID,UUID,TEXT,TEXT,TEXT,TEXT,TEXT,NUMERIC,BOOLEAN,NUMERIC,INTEGER,TEXT,TEXT,TEXT,TEXT);
DROP FUNCTION IF EXISTS public.get_wholesale_products(UUID);
DROP FUNCTION IF EXISTS public.upsert_wholesale_product(UUID,UUID,TEXT,UUID,UUID,UUID,UUID,UUID,UUID,UUID,UUID,UUID,UUID,UUID,UUID,UUID,UUID,UUID,UUID,UUID,UUID,UUID,UUID);
DROP FUNCTION IF EXISTS public.create_wholesale_order(UUID,UUID,DATE,JSONB,INTEGER,TEXT);
DROP FUNCTION IF EXISTS public.record_wholesale_deposit(UUID,NUMERIC,TEXT,TEXT,UUID);
DROP FUNCTION IF EXISTS public.mark_wholesale_order_fulfilled(UUID,UUID);
-- ── 7. RPCs ─────────────────────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION public.get_wholesale_orders(p_brand_id UUID DEFAULT NULL)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_result JSONB;
BEGIN
SELECT jsonb_agg(t ORDER BY t.created_at DESC) INTO v_result
FROM (
SELECT
wo.id, wo.status, wo.fulfillment_status, wo.payment_status,
wo.anticipated_pickup_date, wo.subtotal, wo.deposit_required,
wo.deposit_paid, wo.balance_due, wo.created_at, wo.updated_at,
wo.invoice_number, wo.assigned_employee_id,
wc.company_name, wc.contact_name, wc.email AS customer_email,
COALESCE(jsonb_agg(CASE WHEN woi.id IS NOT NULL THEN jsonb_build_object(
'id', woi.id,
'product_name', wp.name,
'quantity', woi.quantity,
'unit_price', woi.unit_price,
'line_total', woi.line_total
) END), '[]'::JSONB) AS items,
wo.fulfilled_at
FROM wholesale_orders wo
JOIN wholesale_customers wc ON wo.customer_id = wc.id
LEFT JOIN wholesale_order_items woi ON woi.wholesale_order_id = wo.id
LEFT JOIN wholesale_products wp ON woi.product_id = wp.id
WHERE wo.brand_id = p_brand_id
GROUP BY wo.id, wc.id
ORDER BY wo.created_at DESC
LIMIT 500
) t;
RETURN COALESCE(v_result, '[]'::JSONB);
END;
$$;
CREATE OR REPLACE FUNCTION public.upsert_wholesale_settings(
p_brand_id UUID DEFAULT NULL,
p_portal_page_id UUID DEFAULT NULL,
p_price_sheet_page_id UUID DEFAULT NULL,
p_require_approval BOOLEAN DEFAULT NULL,
p_min_order_amount NUMERIC DEFAULT NULL,
p_online_payment_enabled BOOLEAN DEFAULT NULL,
p_pickup_location TEXT DEFAULT NULL,
p_fob_location TEXT DEFAULT NULL,
p_from_email TEXT DEFAULT NULL,
p_invoice_business_name TEXT DEFAULT NULL
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
INSERT INTO wholesale_settings (
brand_id, portal_page_id, price_sheet_page_id,
require_approval, min_order_amount, online_payment_enabled,
pickup_location, fob_location, from_email, invoice_business_name
)
VALUES (
p_brand_id, p_portal_page_id, p_price_sheet_page_id,
COALESCE(p_require_approval, true),
p_min_order_amount, COALESCE(p_online_payment_enabled, false),
p_pickup_location, p_fob_location, p_from_email, p_invoice_business_name
)
ON CONFLICT (brand_id) DO UPDATE SET
portal_page_id = COALESCE(p_portal_page_id, wholesale_settings.portal_page_id),
price_sheet_page_id = COALESCE(p_price_sheet_page_id, wholesale_settings.price_sheet_page_id),
require_approval = COALESCE(p_require_approval, wholesale_settings.require_approval),
min_order_amount = COALESCE(p_min_order_amount, wholesale_settings.min_order_amount),
online_payment_enabled = COALESCE(p_online_payment_enabled, wholesale_settings.online_payment_enabled),
pickup_location = COALESCE(p_pickup_location, wholesale_settings.pickup_location),
fob_location = COALESCE(p_fob_location, wholesale_settings.fob_location),
from_email = COALESCE(p_from_email, wholesale_settings.from_email),
invoice_business_name = COALESCE(p_invoice_business_name, wholesale_settings.invoice_business_name),
updated_at = now()
RETURNING jsonb_build_object('success', true);
END;
$$;
CREATE OR REPLACE FUNCTION public.get_wholesale_settings(p_brand_id UUID DEFAULT NULL)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_result JSONB;
BEGIN
SELECT jsonb_build_object(
'id', id,
'brand_id', brand_id,
'portal_page_id', portal_page_id,
'price_sheet_page_id', price_sheet_page_id,
'require_approval', require_approval,
'min_order_amount', min_order_amount,
'online_payment_enabled', online_payment_enabled,
'pickup_location', pickup_location,
'fob_location', fob_location,
'from_email', from_email,
'invoice_business_name', invoice_business_name,
'last_invoice_number', last_invoice_number
) INTO v_result
FROM wholesale_settings
WHERE brand_id = p_brand_id;
RETURN v_result;
END;
$$;
CREATE OR REPLACE FUNCTION public.get_wholesale_customers(p_brand_id UUID DEFAULT NULL)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_result JSONB;
BEGIN
SELECT jsonb_agg(t ORDER BY t.company_name NULLS LAST) INTO v_result
FROM (
SELECT
id, user_id, company_name, contact_name, email, phone,
account_status, credit_limit,
deposits_enabled, deposit_threshold, deposit_percentage,
order_email, invoice_email, admin_notes, role,
created_at
FROM wholesale_customers
WHERE brand_id = p_brand_id
ORDER BY company_name NULLS LAST
) t;
RETURN COALESCE(v_result, '[]'::JSONB);
END;
$$;
CREATE OR REPLACE FUNCTION public.upsert_wholesale_customer(
p_brand_id UUID DEFAULT NULL,
p_user_id UUID DEFAULT NULL,
p_company_name TEXT DEFAULT NULL,
p_contact_name TEXT DEFAULT NULL,
p_email TEXT DEFAULT NULL,
p_phone TEXT DEFAULT NULL,
p_account_status TEXT DEFAULT NULL,
p_credit_limit NUMERIC DEFAULT NULL,
p_deposits_enabled BOOLEAN DEFAULT NULL,
p_deposit_threshold NUMERIC DEFAULT NULL,
p_deposit_percentage INTEGER DEFAULT NULL,
p_order_email TEXT DEFAULT NULL,
p_invoice_email TEXT DEFAULT NULL,
p_admin_notes TEXT DEFAULT NULL,
p_role TEXT DEFAULT NULL
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_id UUID;
BEGIN
INSERT INTO wholesale_customers (
brand_id, user_id, company_name, contact_name, email, phone,
account_status, credit_limit, deposits_enabled, deposit_threshold,
deposit_percentage, order_email, invoice_email, admin_notes, role
)
VALUES (
p_brand_id, p_user_id, p_company_name, p_contact_name, p_email, p_phone,
COALESCE(p_account_status, 'active'),
COALESCE(p_credit_limit, 0),
COALESCE(p_deposits_enabled, false),
p_deposit_threshold, p_deposit_percentage,
p_order_email, p_invoice_email, p_admin_notes,
COALESCE(p_role, 'buyer')
)
ON CONFLICT (brand_id, user_id) DO UPDATE SET
company_name = COALESCE(p_company_name, wholesale_customers.company_name),
contact_name = COALESCE(p_contact_name, wholesale_customers.contact_name),
email = COALESCE(p_email, wholesale_customers.email),
phone = COALESCE(p_phone, wholesale_customers.phone),
account_status = COALESCE(p_account_status, wholesale_customers.account_status),
credit_limit = COALESCE(p_credit_limit, wholesale_customers.credit_limit),
deposits_enabled = COALESCE(p_deposits_enabled, wholesale_customers.deposits_enabled),
deposit_threshold = COALESCE(p_deposit_threshold, wholesale_customers.deposit_threshold),
deposit_percentage= COALESCE(p_deposit_percentage, wholesale_customers.deposit_percentage),
order_email = COALESCE(p_order_email, wholesale_customers.order_email),
invoice_email = COALESCE(p_invoice_email, wholesale_customers.invoice_email),
admin_notes = COALESCE(p_admin_notes, wholesale_customers.admin_notes),
role = COALESCE(p_role, wholesale_customers.role),
updated_at = now()
RETURNING id INTO v_id;
RETURN jsonb_build_object('success', true, 'id', v_id);
END;
$$;
CREATE OR REPLACE FUNCTION public.get_wholesale_products(p_brand_id UUID DEFAULT NULL)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_result JSONB;
BEGIN
SELECT jsonb_agg(t ORDER BY t.name) INTO v_result
FROM (
SELECT
id, name, description, unit_type, unit_type_custom,
availability, qty_available, season_start, season_end,
price_tiers, hp_sku, hp_item_id,
handling_instructions, storage_warning, loading_notes,
product_label, pack_style, container_type, container_size_code,
units_per_container, default_pickup_location,
created_at
FROM wholesale_products
WHERE brand_id = p_brand_id
ORDER BY name
) t;
RETURN COALESCE(v_result, '[]'::JSONB);
END;
$$;
CREATE OR REPLACE FUNCTION public.upsert_wholesale_product(
p_brand_id UUID DEFAULT NULL,
p_id UUID DEFAULT NULL,
p_name TEXT DEFAULT NULL,
p_description TEXT DEFAULT NULL,
p_unit_type TEXT DEFAULT NULL,
p_unit_type_custom TEXT DEFAULT NULL,
p_availability TEXT DEFAULT NULL,
p_qty_available NUMERIC DEFAULT NULL,
p_season_start DATE DEFAULT NULL,
p_season_end DATE DEFAULT NULL,
p_price_tiers JSONB DEFAULT NULL,
p_hp_sku TEXT DEFAULT NULL,
p_hp_item_id TEXT DEFAULT NULL,
p_internal_notes TEXT DEFAULT NULL,
p_handling_instructions TEXT DEFAULT NULL,
p_storage_warning TEXT DEFAULT NULL,
p_loading_notes TEXT DEFAULT NULL,
p_product_label TEXT DEFAULT NULL,
p_pack_style TEXT DEFAULT NULL,
p_container_type TEXT DEFAULT NULL,
p_container_size_code TEXT DEFAULT NULL,
p_units_per_container INTEGER DEFAULT NULL,
p_default_pickup_location TEXT DEFAULT NULL
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_id UUID;
BEGIN
IF p_id IS NOT NULL THEN
UPDATE wholesale_products SET
name = COALESCE(p_name, name),
description = COALESCE(p_description, description),
unit_type = COALESCE(p_unit_type, unit_type),
unit_type_custom = COALESCE(p_unit_type_custom, unit_type_custom),
availability = COALESCE(p_availability, availability),
qty_available = COALESCE(p_qty_available, qty_available),
season_start = COALESCE(p_season_start, season_start),
season_end = COALESCE(p_season_end, season_end),
price_tiers = COALESCE(p_price_tiers, price_tiers),
hp_sku = COALESCE(p_hp_sku, hp_sku),
hp_item_id = COALESCE(p_hp_item_id, hp_item_id),
internal_notes = COALESCE(p_internal_notes, internal_notes),
handling_instructions = COALESCE(p_handling_instructions, handling_instructions),
storage_warning = COALESCE(p_storage_warning, storage_warning),
loading_notes = COALESCE(p_loading_notes, loading_notes),
product_label = COALESCE(p_product_label, product_label),
pack_style = COALESCE(p_pack_style, pack_style),
container_type = COALESCE(p_container_type, container_type),
container_size_code = COALESCE(p_container_size_code, container_size_code),
units_per_container = COALESCE(p_units_per_container, units_per_container),
default_pickup_location = COALESCE(p_default_pickup_location, default_pickup_location),
updated_at = now()
WHERE id = p_id AND brand_id = p_brand_id
RETURNING id INTO v_id;
ELSE
INSERT INTO wholesale_products (
brand_id, name, description, unit_type, unit_type_custom,
availability, qty_available, season_start, season_end,
price_tiers, hp_sku, hp_item_id, internal_notes,
handling_instructions, storage_warning, loading_notes,
product_label, pack_style, container_type, container_size_code,
units_per_container, default_pickup_location
) VALUES (
p_brand_id, p_name, p_description,
COALESCE(p_unit_type, 'each'),
p_unit_type_custom,
COALESCE(p_availability, 'unavailable'),
COALESCE(p_qty_available, 0),
p_season_start, p_season_end,
COALESCE(p_price_tiers, '[]'::JSONB),
p_hp_sku, p_hp_item_id, p_internal_notes,
p_handling_instructions, p_storage_warning, p_loading_notes,
p_product_label, p_pack_style, p_container_type, p_container_size_code,
p_units_per_container, p_default_pickup_location
)
RETURNING id INTO v_id;
END IF;
RETURN jsonb_build_object('success', true, 'id', v_id);
END;
$$;
CREATE OR REPLACE FUNCTION public.create_wholesale_order(
p_brand_id UUID DEFAULT NULL,
p_customer_id UUID DEFAULT NULL,
p_anticipated_pickup_date DATE DEFAULT NULL,
p_items JSONB DEFAULT '[]'::JSONB,
p_deposit_percentage INTEGER DEFAULT NULL,
p_notes TEXT DEFAULT NULL
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_order_id UUID;
v_subtotal NUMERIC(10,2);
v_dep_required NUMERIC(10,2) := 0;
v_customer RECORD;
v_settings RECORD;
v_next_inv INTEGER;
v_status TEXT;
BEGIN
-- Resolve customer deposit rules
SELECT deposits_enabled, deposit_threshold, deposit_percentage
INTO v_customer
FROM wholesale_customers
WHERE id = p_customer_id;
-- Resolve invoice counter
SELECT COALESCE(last_invoice_number, 0) INTO v_settings
FROM wholesale_settings
WHERE brand_id = p_brand_id;
-- Calculate subtotal
v_subtotal := COALESCE(
(SELECT SUM(
((item->>'quantity')::NUMERIC) * ((item->>'unit_price')::NUMERIC)
) FROM jsonb_array_elements(p_items) AS item),
0
);
-- Determine if deposit required
IF v_customer.deposits_enabled THEN
IF v_customer.deposit_threshold IS NULL OR v_subtotal >= v_customer.deposit_threshold THEN
v_dep_required := ROUND(v_subtotal * COALESCE(v_customer.deposit_percentage, 0) / 100.0, 2);
END IF;
END IF;
v_status := CASE WHEN v_dep_required > 0 THEN 'awaiting_deposit' ELSE 'pending' END;
v_next_inv := COALESCE(v_settings.last_invoice_number, 0) + 1;
INSERT INTO wholesale_orders (
brand_id, customer_id, status, anticipated_pickup_date,
subtotal, deposit_required, deposit_paid, balance_due,
deposit_percentage, internal_notes, invoice_number
) VALUES (
p_brand_id, p_customer_id, v_status, p_anticipated_pickup_date,
v_subtotal, v_dep_required, 0, v_subtotal,
p_deposit_percentage, p_notes,
'INV-' || LPAD(v_next_inv::TEXT, 5, '0')
) RETURNING id INTO v_order_id;
-- Insert line items
IF jsonb_array_length(p_items) > 0 THEN
INSERT INTO wholesale_order_items (wholesale_order_id, product_id, quantity, unit_price, line_total)
SELECT
v_order_id,
(item->>'product_id')::UUID,
(item->>'quantity')::NUMERIC,
(item->>'unit_price')::NUMERIC,
((item->>'quantity')::NUMERIC) * ((item->>'unit_price')::NUMERIC)
FROM jsonb_array_elements(p_items) AS item;
END IF;
-- Advance invoice counter
UPDATE wholesale_settings
SET last_invoice_number = v_next_inv, updated_at = now()
WHERE brand_id = p_brand_id;
RETURN jsonb_build_object(
'success', true,
'order_id', v_order_id,
'status', v_status,
'deposit_required', v_dep_required,
'subtotal', v_subtotal
);
END;
$$;
CREATE OR REPLACE FUNCTION public.record_wholesale_deposit(
p_order_id UUID DEFAULT NULL,
p_amount NUMERIC DEFAULT NULL,
p_method TEXT DEFAULT 'cash',
p_reference TEXT DEFAULT NULL,
p_recorded_by UUID DEFAULT NULL
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_new_paid NUMERIC(10,2);
BEGIN
-- Compute new deposit_paid first so balance_due uses the correct updated value
SELECT deposit_paid + p_amount INTO v_new_paid
FROM wholesale_orders
WHERE id = p_order_id;
UPDATE wholesale_orders
SET
deposit_paid = v_new_paid,
balance_due = subtotal - v_new_paid,
updated_at = now()
WHERE id = p_order_id;
INSERT INTO wholesale_deposits (wholesale_order_id, amount, payment_method, reference, recorded_by)
VALUES (p_order_id, p_amount, p_method, p_reference, p_recorded_by);
-- Advance to pending once deposit covers requirement
UPDATE wholesale_orders
SET status = 'pending'
WHERE id = p_order_id
AND v_new_paid >= deposit_required
AND status = 'awaiting_deposit';
RETURN jsonb_build_object('success', true);
END;
$$;
CREATE OR REPLACE FUNCTION public.mark_wholesale_order_fulfilled(
p_order_id UUID DEFAULT NULL,
p_by UUID DEFAULT NULL
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
UPDATE wholesale_orders
SET
fulfillment_status = 'fulfilled',
payment_status = CASE WHEN balance_due <= 0 THEN 'paid' ELSE payment_status END,
status = 'fulfilled',
fulfilled_at = now(),
fulfilled_by = p_by,
updated_at = now()
WHERE id = p_order_id;
RETURN jsonb_build_object('success', true);
END;
$$;
COMMIT;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,201 @@
-- Migration 047: Wholesale Registration + Pending Approval Status
-- Adds self-service registration RPC and 'pending_approval' account status.
-- Idempotent — uses CREATE OR REPLACE and IF NOT EXISTS.
BEGIN;
-- Add pending_approval to the account_status check constraint
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'wholesale_customers_account_status_check'
) THEN
ALTER TABLE public.wholesale_customers
ADD CONSTRAINT wholesale_customers_account_status_check
CHECK (account_status IN ('active', 'on_hold', 'disabled', 'pending_approval', 'rejected'));
ELSE
-- Drop and recreate the check constraint with the new value
ALTER TABLE public.wholesale_customers
DROP CONSTRAINT wholesale_customers_account_status_check;
ALTER TABLE public.wholesale_customers
ADD CONSTRAINT wholesale_customers_account_status_check
CHECK (account_status IN ('active', 'on_hold', 'disabled', 'pending_approval', 'rejected'));
END IF;
END $$;
-- Add rejected status to the check constraint (same pattern)
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'wholesale_customers_account_status_check'
) THEN
ALTER TABLE public.wholesale_customers
ADD CONSTRAINT wholesale_customers_account_status_check
CHECK (account_status IN ('active', 'on_hold', 'disabled', 'pending_approval', 'rejected'));
END IF;
END $$;
-- ── Registration RPC ─────────────────────────────────────────────────────────
DROP FUNCTION IF EXISTS public.register_wholesale_customer(UUID, TEXT, TEXT, TEXT, TEXT);
CREATE OR REPLACE FUNCTION public.register_wholesale_customer(
p_brand_id UUID DEFAULT NULL,
p_company_name TEXT DEFAULT NULL,
p_contact_name TEXT DEFAULT NULL,
p_email TEXT DEFAULT NULL,
p_phone TEXT DEFAULT NULL
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_id UUID;
v_req_app BOOLEAN := true;
BEGIN
-- Reject duplicate email for this brand
IF EXISTS (SELECT 1 FROM wholesale_customers WHERE brand_id = p_brand_id AND email = p_email) THEN
RETURN jsonb_build_object('success', false, 'error', 'An account with this email already exists.');
END IF;
-- Read require_approval setting for this brand
BEGIN
SELECT require_approval INTO v_req_app
FROM wholesale_settings
WHERE brand_id = p_brand_id;
EXCEPTION WHEN OTHERS THEN
v_req_app := true;
END;
INSERT INTO wholesale_customers (
brand_id, company_name, contact_name, email, phone,
account_status, role
) VALUES (
p_brand_id,
p_company_name,
p_contact_name,
p_email,
p_phone,
CASE WHEN COALESCE(v_req_app, true) THEN 'pending_approval' ELSE 'active' END,
'buyer'
)
RETURNING id INTO v_id;
RETURN jsonb_build_object(
'success', true,
'id', v_id,
'requires_approval', COALESCE(v_req_app, true)
);
END;
$$;
-- ── Customer lookup by user_id (for portal auth) ──────────────────────────────
DROP FUNCTION IF EXISTS public.get_wholesale_customer_by_user(UUID, UUID);
CREATE OR REPLACE FUNCTION public.get_wholesale_customer_by_user(
p_brand_id UUID DEFAULT NULL,
p_user_id UUID DEFAULT NULL
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
-- If brand_id is a zero UUID (placeholder), search across all brands
IF p_brand_id = '00000000-0000-0000-0000-000000000000'::UUID THEN
RETURN (
SELECT jsonb_build_object(
'id', id,
'user_id', user_id,
'company_name', company_name,
'contact_name', contact_name,
'email', email,
'phone', phone,
'account_status', account_status,
'role', role,
'brand_id', brand_id
)
FROM wholesale_customers
WHERE user_id = p_user_id AND account_status = 'active'
LIMIT 1
);
END IF;
RETURN (
SELECT jsonb_build_object(
'id', id,
'user_id', user_id,
'company_name', company_name,
'contact_name', contact_name,
'email', email,
'phone', phone,
'account_status', account_status,
'role', role,
'brand_id', brand_id
)
FROM wholesale_customers
WHERE brand_id = p_brand_id AND user_id = p_user_id
);
END;
$$;
-- ── Pending registrations RPC (for admin approval queue) ──────────────────────
DROP FUNCTION IF EXISTS public.get_pending_wholesale_registrations(UUID);
CREATE OR REPLACE FUNCTION public.get_pending_wholesale_registrations(p_brand_id UUID DEFAULT NULL)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_result JSONB;
BEGIN
SELECT jsonb_agg(t ORDER BY t.created_at ASC) INTO v_result
FROM (
SELECT
id, company_name, contact_name, email, phone,
account_status, role, created_at, updated_at
FROM wholesale_customers
WHERE brand_id = p_brand_id
AND account_status IN ('pending_approval', 'rejected')
ORDER BY created_at ASC
) t;
RETURN COALESCE(v_result, '[]'::JSONB);
END;
$$;
-- ── Approve/reject registration RPC ───────────────────────────────────────────
DROP FUNCTION IF EXISTS public.approve_wholesale_registration(UUID, UUID, TEXT);
CREATE OR REPLACE FUNCTION public.approve_wholesale_registration(
p_registration_id UUID DEFAULT NULL,
p_brand_id UUID DEFAULT NULL,
p_action TEXT DEFAULT NULL
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
IF p_action = 'approve' THEN
UPDATE wholesale_customers
SET account_status = 'active', updated_at = now()
WHERE id = p_registration_id AND brand_id = p_brand_id
AND account_status = 'pending_approval';
ELSIF p_action = 'reject' THEN
UPDATE wholesale_customers
SET account_status = 'rejected', updated_at = now()
WHERE id = p_registration_id AND brand_id = p_brand_id
AND account_status = 'pending_approval';
ELSE
RETURN jsonb_build_object('success', false, 'error', 'Invalid action.');
END IF;
RETURN jsonb_build_object('success', true);
END;
$$;
-- ── Upsert wholesale customer (updated to allow linking user_id) ──────────────
-- Update existing upsert to handle user_id linking
-- The existing upsert_wholesale_customer is already set up to use brand_id+user_id as the ON CONFLICT target.
-- This just adds a note that it can also be used to link an existing customer to a auth.users account.
COMMIT;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,62 @@
-- Migration 048: Add wholesale_enabled to wholesale_settings + update upsert RPC
-- Allows admins to enable/disable the Wholesale Portal per brand.
BEGIN;
-- Add wholesale_enabled column
ALTER TABLE public.wholesale_settings
ADD COLUMN IF NOT EXISTS wholesale_enabled BOOLEAN NOT NULL DEFAULT true;
-- Update upsert function to handle wholesale_enabled
DROP FUNCTION IF EXISTS public.upsert_wholesale_settings(
UUID, UUID, UUID, BOOLEAN, NUMERIC, BOOLEAN, BOOLEAN, TEXT, TEXT, TEXT, TEXT
);
CREATE OR REPLACE FUNCTION public.upsert_wholesale_settings(
p_brand_id UUID DEFAULT NULL,
p_portal_page_id UUID DEFAULT NULL,
p_price_sheet_page_id UUID DEFAULT NULL,
p_require_approval BOOLEAN DEFAULT NULL,
p_min_order_amount NUMERIC DEFAULT NULL,
p_online_payment_enabled BOOLEAN DEFAULT NULL,
p_wholesale_enabled BOOLEAN DEFAULT NULL,
p_pickup_location TEXT DEFAULT NULL,
p_fob_location TEXT DEFAULT NULL,
p_from_email TEXT DEFAULT NULL,
p_invoice_business_name TEXT DEFAULT NULL
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
INSERT INTO wholesale_settings (
brand_id, portal_page_id, price_sheet_page_id,
require_approval, min_order_amount, online_payment_enabled,
wholesale_enabled, pickup_location, fob_location, from_email, invoice_business_name
)
VALUES (
p_brand_id, p_portal_page_id, p_price_sheet_page_id,
COALESCE(p_require_approval, true),
p_min_order_amount, COALESCE(p_online_payment_enabled, false),
COALESCE(p_wholesale_enabled, true),
p_pickup_location, p_fob_location, p_from_email, p_invoice_business_name
)
ON CONFLICT (brand_id) DO UPDATE SET
portal_page_id = COALESCE(p_portal_page_id, wholesale_settings.portal_page_id),
price_sheet_page_id = COALESCE(p_price_sheet_page_id, wholesale_settings.price_sheet_page_id),
require_approval = COALESCE(p_require_approval, wholesale_settings.require_approval),
min_order_amount = COALESCE(p_min_order_amount, wholesale_settings.min_order_amount),
online_payment_enabled = COALESCE(p_online_payment_enabled, wholesale_settings.online_payment_enabled),
wholesale_enabled = COALESCE(p_wholesale_enabled, wholesale_settings.wholesale_enabled),
pickup_location = COALESCE(p_pickup_location, wholesale_settings.pickup_location),
fob_location = COALESCE(p_fob_location, wholesale_settings.fob_location),
from_email = COALESCE(p_from_email, wholesale_settings.from_email),
invoice_business_name = COALESCE(p_invoice_business_name, wholesale_settings.invoice_business_name),
updated_at = now()
RETURNING jsonb_build_object('success', true);
END;
$$;
COMMIT;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,211 @@
-- Migration 049: Invoice Settings + TC Invoice Numbers + Secure Token
-- - Adds invoice business contact fields to wholesale_settings
-- - Updates create_wholesale_order to generate TC-XXXXXX format + secure token
-- - Updates upsert_wholesale_settings to persist the new invoice fields
BEGIN;
-- ── 1. Add invoice business contact columns to wholesale_settings ────────────
ALTER TABLE public.wholesale_settings
ADD COLUMN IF NOT EXISTS invoice_business_address TEXT,
ADD COLUMN IF NOT EXISTS invoice_business_phone TEXT,
ADD COLUMN IF NOT EXISTS invoice_business_email TEXT,
ADD COLUMN IF NOT EXISTS invoice_business_website TEXT;
-- ── 2. Update upsert_wholesale_settings RPC to handle new invoice fields ─────
DROP FUNCTION IF EXISTS public.upsert_wholesale_settings(
UUID, UUID, UUID, BOOLEAN, NUMERIC, BOOLEAN, BOOLEAN, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT
);
CREATE OR REPLACE FUNCTION public.upsert_wholesale_settings(
p_brand_id UUID DEFAULT NULL,
p_portal_page_id UUID DEFAULT NULL,
p_price_sheet_page_id UUID DEFAULT NULL,
p_require_approval BOOLEAN DEFAULT NULL,
p_min_order_amount NUMERIC DEFAULT NULL,
p_online_payment_enabled BOOLEAN DEFAULT NULL,
p_wholesale_enabled BOOLEAN DEFAULT NULL,
p_pickup_location TEXT DEFAULT NULL,
p_fob_location TEXT DEFAULT NULL,
p_from_email TEXT DEFAULT NULL,
p_invoice_business_name TEXT DEFAULT NULL,
p_invoice_business_address TEXT DEFAULT NULL,
p_invoice_business_phone TEXT DEFAULT NULL,
p_invoice_business_email TEXT DEFAULT NULL,
p_invoice_business_website TEXT DEFAULT NULL
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
INSERT INTO wholesale_settings (
brand_id, portal_page_id, price_sheet_page_id,
require_approval, min_order_amount, online_payment_enabled,
wholesale_enabled, pickup_location, fob_location, from_email,
invoice_business_name, invoice_business_address,
invoice_business_phone, invoice_business_email, invoice_business_website
)
VALUES (
p_brand_id, p_portal_page_id, p_price_sheet_page_id,
COALESCE(p_require_approval, true),
p_min_order_amount, COALESCE(p_online_payment_enabled, false),
COALESCE(p_wholesale_enabled, true),
p_pickup_location, p_fob_location, p_from_email,
p_invoice_business_name, p_invoice_business_address,
p_invoice_business_phone, p_invoice_business_email, p_invoice_business_website
)
ON CONFLICT (brand_id) DO UPDATE SET
portal_page_id = COALESCE(p_portal_page_id, wholesale_settings.portal_page_id),
price_sheet_page_id = COALESCE(p_price_sheet_page_id, wholesale_settings.price_sheet_page_id),
require_approval = COALESCE(p_require_approval, wholesale_settings.require_approval),
min_order_amount = COALESCE(p_min_order_amount, wholesale_settings.min_order_amount),
online_payment_enabled = COALESCE(p_online_payment_enabled, wholesale_settings.online_payment_enabled),
wholesale_enabled = COALESCE(p_wholesale_enabled, wholesale_settings.wholesale_enabled),
pickup_location = COALESCE(p_pickup_location, wholesale_settings.pickup_location),
fob_location = COALESCE(p_fob_location, wholesale_settings.fob_location),
from_email = COALESCE(p_from_email, wholesale_settings.from_email),
invoice_business_name = COALESCE(p_invoice_business_name, wholesale_settings.invoice_business_name),
invoice_business_address = COALESCE(p_invoice_business_address, wholesale_settings.invoice_business_address),
invoice_business_phone = COALESCE(p_invoice_business_phone, wholesale_settings.invoice_business_phone),
invoice_business_email = COALESCE(p_invoice_business_email, wholesale_settings.invoice_business_email),
invoice_business_website = COALESCE(p_invoice_business_website, wholesale_settings.invoice_business_website),
updated_at = now();
RETURN jsonb_build_object('success', true);
END;
$$;
-- ── 3. Update create_wholesale_order to generate TC-XXXXXX + invoice_token ───
DROP FUNCTION IF EXISTS public.create_wholesale_order(
UUID, UUID, DATE, JSONB, INTEGER, TEXT
);
CREATE OR REPLACE FUNCTION public.create_wholesale_order(
p_brand_id UUID DEFAULT NULL,
p_customer_id UUID DEFAULT NULL,
p_anticipated_pickup_date DATE DEFAULT NULL,
p_items JSONB DEFAULT '[]'::JSONB,
p_deposit_percentage INTEGER DEFAULT NULL,
p_notes TEXT DEFAULT NULL
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_order_id UUID;
v_subtotal NUMERIC(10,2);
v_dep_required NUMERIC(10,2) := 0;
v_customer RECORD;
v_settings RECORD;
v_inv_number TEXT;
v_inv_token TEXT;
v_status TEXT;
BEGIN
-- Resolve customer deposit rules
SELECT deposits_enabled, deposit_threshold, deposit_percentage
INTO v_customer
FROM wholesale_customers
WHERE id = p_customer_id;
-- Generate TC-XXXXXX invoice number using RANDOM()
v_inv_number := 'TC-' || LPAD(FLOOR(RANDOM() * 1000000)::INT::TEXT, 6, '0');
-- Generate a 32-char hex invoice token using md5 of random + timestamp
v_inv_token := md5(random()::text || clock_timestamp()::text || now()::text || p_brand_id::text);
-- Calculate subtotal
v_subtotal := COALESCE(
(SELECT SUM(
((item->>'quantity')::NUMERIC) * ((item->>'unit_price')::NUMERIC)
) FROM jsonb_array_elements(p_items) AS item),
0
);
-- Determine if deposit required
IF v_customer.deposits_enabled THEN
IF v_customer.deposit_threshold IS NULL OR v_subtotal >= v_customer.deposit_threshold THEN
v_dep_required := ROUND(v_subtotal * COALESCE(v_customer.deposit_percentage, 0) / 100.0, 2);
END IF;
END IF;
v_status := CASE WHEN v_dep_required > 0 THEN 'awaiting_deposit' ELSE 'pending' END;
INSERT INTO wholesale_orders (
brand_id, customer_id, status, anticipated_pickup_date,
subtotal, deposit_required, deposit_paid, balance_due,
deposit_percentage, internal_notes, invoice_number, invoice_token
) VALUES (
p_brand_id, p_customer_id, v_status, p_anticipated_pickup_date,
v_subtotal, v_dep_required, 0, v_subtotal,
p_deposit_percentage, p_notes,
v_inv_number, v_inv_token
) RETURNING id INTO v_order_id;
-- Insert line items
IF jsonb_array_length(p_items) > 0 THEN
INSERT INTO wholesale_order_items (wholesale_order_id, product_id, quantity, unit_price, line_total)
SELECT
v_order_id,
(item->>'product_id')::UUID,
(item->>'quantity')::NUMERIC,
(item->>'unit_price')::NUMERIC,
((item->>'quantity')::NUMERIC) * ((item->>'unit_price')::NUMERIC)
FROM jsonb_array_elements(p_items) AS item;
END IF;
RETURN jsonb_build_object(
'success', true,
'order_id', v_order_id,
'invoice_number', v_inv_number,
'status', v_status,
'deposit_required', v_dep_required,
'subtotal', v_subtotal
);
END;
$$;
-- ── 4. RPC: Get wholesale orders for a customer (customer-facing order history) ──
DROP FUNCTION IF EXISTS public.get_wholesale_customer_orders(UUID);
CREATE OR REPLACE FUNCTION public.get_wholesale_customer_orders(p_customer_id UUID DEFAULT NULL)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_result JSONB;
BEGIN
SELECT jsonb_agg(t ORDER BY t.created_at DESC) INTO v_result
FROM (
SELECT
wo.id,
wo.status,
wo.fulfillment_status,
wo.payment_status,
wo.anticipated_pickup_date,
wo.subtotal,
wo.deposit_required,
wo.deposit_paid,
wo.balance_due,
wo.invoice_number,
wo.invoice_token,
wo.created_at,
wo.updated_at,
COALESCE(jsonb_agg(CASE WHEN woi.id IS NOT NULL THEN jsonb_build_object(
'id', woi.id,
'product_name', wp.name,
'quantity', woi.quantity,
'unit_price', woi.unit_price,
'line_total', woi.line_total
) END), '[]'::JSONB) AS items
FROM wholesale_orders wo
LEFT JOIN wholesale_order_items woi ON woi.wholesale_order_id = wo.id
LEFT JOIN wholesale_products wp ON woi.product_id = wp.id
WHERE wo.customer_id = p_customer_id
GROUP BY wo.id
ORDER BY wo.created_at DESC
LIMIT 100
) t;
RETURN COALESCE(v_result, '[]'::JSONB);
END;
$$;
COMMIT;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,78 @@
-- Migration 050: Wholesale Online Payment
-- - Adds checkout_session_id and payment_intent_id to wholesale_orders
-- - Adds record_wholesale_payment RPC for Stripe webhook to call
BEGIN;
-- ── 1. Add Stripe payment tracking columns ───────────────────────────────────
ALTER TABLE public.wholesale_orders
ADD COLUMN IF NOT EXISTS checkout_session_id TEXT,
ADD COLUMN IF NOT EXISTS stripe_payment_intent_id TEXT;
-- ── 2. RPC: Record a Stripe payment against a wholesale order ─────────────────
DROP FUNCTION IF EXISTS public.record_wholesale_payment(UUID, NUMERIC, TEXT);
CREATE OR REPLACE FUNCTION public.record_wholesale_payment(
p_order_id UUID DEFAULT NULL,
p_amount NUMERIC DEFAULT NULL,
p_stripe_payment_intent_id TEXT DEFAULT NULL
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_order RECORD;
v_new_paid NUMERIC(10,2);
BEGIN
-- Fetch current order state
SELECT id, subtotal, deposit_paid, deposit_required, balance_due
INTO v_order
FROM wholesale_orders
WHERE id = p_order_id;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Order not found');
END IF;
-- Compute new deposit_paid before the update so balance_due uses the correct value
v_new_paid := v_order.deposit_paid + p_amount;
-- Record the payment in the deposits table
INSERT INTO wholesale_deposits (wholesale_order_id, amount, payment_method, reference, recorded_by)
VALUES (
p_order_id,
p_amount,
'stripe',
p_stripe_payment_intent_id,
auth.uid()
);
-- Update order
UPDATE wholesale_orders
SET
deposit_paid = v_new_paid,
balance_due = GREATEST(subtotal - v_new_paid, 0),
payment_status = CASE
WHEN subtotal - v_new_paid <= 0 THEN 'paid'
ELSE 'partial'
END,
stripe_payment_intent_id = COALESCE(p_stripe_payment_intent_id, stripe_payment_intent_id),
updated_at = now()
WHERE id = p_order_id;
-- Advance status from awaiting_deposit if deposit now covers requirement
UPDATE wholesale_orders
SET status = 'pending'
WHERE id = p_order_id
AND status = 'awaiting_deposit'
AND v_new_paid >= deposit_required;
RETURN jsonb_build_object(
'success', true,
'new_balance_due', GREATEST(v_order.subtotal - v_new_paid, 0)
);
END;
$$;
COMMIT;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,92 @@
-- Migration 051: Customer-Specific Pricing Overrides
-- Allows admin to set custom per-customer per-product prices
-- that override the standard product price tiers.
BEGIN;
-- ── 1. Wholesale Customer Product Pricing table ────────────────────────────────
CREATE TABLE IF NOT EXISTS public.wholesale_customer_product_pricing (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID NOT NULL REFERENCES wholesale_customers(id) ON DELETE CASCADE,
product_id UUID NOT NULL REFERENCES wholesale_products(id) ON DELETE CASCADE,
custom_unit_price NUMERIC(10,2) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (customer_id, product_id)
);
ALTER TABLE public.wholesale_customer_product_pricing ENABLE ROW LEVEL SECURITY;
-- Admins can manage overrides
DROP POLICY IF EXISTS "brand_admin_manage_pricing_overrides" ON wholesale_customer_product_pricing;
CREATE POLICY "brand_admin_manage_pricing_overrides" ON wholesale_customer_product_pricing
FOR ALL USING (
current_setting('app.settings.role', true)::TEXT IN ('brand_admin', 'platform_admin')
);
-- ── 2. RPC: Get all pricing overrides for a customer ─────────────────────────
DROP FUNCTION IF EXISTS public.get_wholesale_customer_pricing(UUID);
CREATE OR REPLACE FUNCTION public.get_wholesale_customer_pricing(p_customer_id UUID DEFAULT NULL)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_result JSONB;
BEGIN
SELECT jsonb_agg(jsonb_build_object(
'product_id', wcpp.product_id,
'custom_unit_price', wcpp.custom_unit_price,
'product_name', wp.name
)) INTO v_result
FROM wholesale_customer_product_pricing wcpp
JOIN wholesale_products wp ON wp.id = wcpp.product_id
WHERE wcpp.customer_id = p_customer_id;
RETURN COALESCE(v_result, '[]'::JSONB);
END;
$$;
-- ── 3. RPC: Upsert a single pricing override ─────────────────────────────────
DROP FUNCTION IF EXISTS public.upsert_wholesale_customer_pricing(UUID, UUID, NUMERIC);
CREATE OR REPLACE FUNCTION public.upsert_wholesale_customer_pricing(
p_customer_id UUID DEFAULT NULL,
p_product_id UUID DEFAULT NULL,
p_custom_unit_price NUMERIC DEFAULT NULL
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_id UUID;
BEGIN
INSERT INTO wholesale_customer_product_pricing (customer_id, product_id, custom_unit_price)
VALUES (p_customer_id, p_product_id, p_custom_unit_price)
ON CONFLICT (customer_id, product_id) DO UPDATE SET
custom_unit_price = p_custom_unit_price,
updated_at = now()
RETURNING id INTO v_id;
RETURN jsonb_build_object('success', true, 'id', v_id);
END;
$$;
-- ── 4. RPC: Delete a pricing override ─────────────────────────────────────────
DROP FUNCTION IF EXISTS public.delete_wholesale_customer_pricing(UUID, UUID);
CREATE OR REPLACE FUNCTION public.delete_wholesale_customer_pricing(
p_customer_id UUID DEFAULT NULL,
p_product_id UUID DEFAULT NULL
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
DELETE FROM wholesale_customer_product_pricing
WHERE customer_id = p_customer_id AND product_id = p_product_id;
RETURN jsonb_build_object('success', true);
END;
$$;
COMMIT;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,124 @@
-- Migration 052: Credit Limit Enforcement
-- Blocks wholesale orders that would exceed the customer's credit limit.
BEGIN;
-- ── Update create_wholesale_order to enforce credit limits ───────────────────────
-- Adds: credit_limit check, outstanding balance calculation, rejection if exceeded
DROP FUNCTION IF EXISTS public.create_wholesale_order(
UUID, UUID, DATE, JSONB, INTEGER, TEXT
);
CREATE OR REPLACE FUNCTION public.create_wholesale_order(
p_brand_id UUID DEFAULT NULL,
p_customer_id UUID DEFAULT NULL,
p_anticipated_pickup_date DATE DEFAULT NULL,
p_items JSONB DEFAULT '[]'::JSONB,
p_deposit_percentage INTEGER DEFAULT NULL,
p_notes TEXT DEFAULT NULL
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_order_id UUID;
v_subtotal NUMERIC(10,2);
v_dep_required NUMERIC(10,2) := 0;
v_customer RECORD;
v_settings RECORD;
v_inv_number TEXT;
v_inv_token TEXT;
v_status TEXT;
v_outstanding NUMERIC(10,2);
BEGIN
-- Resolve customer: credit limit + deposit rules
SELECT
credit_limit,
deposits_enabled,
deposit_threshold,
deposit_percentage
INTO v_customer
FROM wholesale_customers
WHERE id = p_customer_id;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Customer not found');
END IF;
-- Calculate subtotal
v_subtotal := COALESCE(
(SELECT SUM(
((item->>'quantity')::NUMERIC) * ((item->>'unit_price')::NUMERIC)
) FROM jsonb_array_elements(p_items) AS item),
0
);
-- Credit limit enforcement: reject if order would exceed limit
IF v_customer.credit_limit > 0 THEN
SELECT COALESCE(SUM(balance_due), 0) INTO v_outstanding
FROM wholesale_orders
WHERE customer_id = p_customer_id
AND status != 'fulfilled';
IF v_outstanding + v_subtotal > v_customer.credit_limit THEN
RETURN jsonb_build_object(
'success', false,
'error', 'Order exceeds available credit. Please reduce the order size or pay outstanding balance.',
'credit_limit', v_customer.credit_limit,
'outstanding_balance', v_outstanding,
'order_total', v_subtotal
);
END IF;
END IF;
-- Generate TC-XXXXXX invoice number using RANDOM()
v_inv_number := 'TC-' || LPAD(FLOOR(RANDOM() * 1000000)::INT::TEXT, 6, '0');
-- Generate a 32-char hex invoice token using md5 of random + timestamp
v_inv_token := md5(random()::text || clock_timestamp()::text || now()::text || p_brand_id::text);
-- Determine if deposit required
IF v_customer.deposits_enabled THEN
IF v_customer.deposit_threshold IS NULL OR v_subtotal >= v_customer.deposit_threshold THEN
v_dep_required := ROUND(v_subtotal * COALESCE(v_customer.deposit_percentage, 0) / 100.0, 2);
END IF;
END IF;
v_status := CASE WHEN v_dep_required > 0 THEN 'awaiting_deposit' ELSE 'pending' END;
INSERT INTO wholesale_orders (
brand_id, customer_id, status, anticipated_pickup_date,
subtotal, deposit_required, deposit_paid, balance_due,
deposit_percentage, internal_notes, invoice_number, invoice_token
) VALUES (
p_brand_id, p_customer_id, v_status, p_anticipated_pickup_date,
v_subtotal, v_dep_required, 0, v_subtotal,
p_deposit_percentage, p_notes,
v_inv_number, v_inv_token
) RETURNING id INTO v_order_id;
-- Insert line items
IF jsonb_array_length(p_items) > 0 THEN
INSERT INTO wholesale_order_items (wholesale_order_id, product_id, quantity, unit_price, line_total)
SELECT
v_order_id,
(item->>'product_id')::UUID,
(item->>'quantity')::NUMERIC,
(item->>'unit_price')::NUMERIC,
((item->>'quantity')::NUMERIC) * ((item->>'unit_price')::NUMERIC)
FROM jsonb_array_elements(p_items) AS item;
END IF;
RETURN jsonb_build_object(
'success', true,
'order_id', v_order_id,
'invoice_number', v_inv_number,
'status', v_status,
'deposit_required', v_dep_required,
'subtotal', v_subtotal
);
END;
$$;
COMMIT;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,93 @@
-- Migration 053: Wholesale Bulk Actions
-- Bulk fulfill and bulk deposit record RPCs for admin order management.
BEGIN;
-- ── Bulk fulfill: mark multiple orders as fulfilled in one call ─────────────────
DROP FUNCTION IF EXISTS public.bulk_fulfill_wholesale_orders(UUID[], UUID);
CREATE OR REPLACE FUNCTION public.bulk_fulfill_wholesale_orders(
p_order_ids UUID[] DEFAULT NULL,
p_by UUID DEFAULT NULL
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_count INTEGER := 0;
BEGIN
UPDATE wholesale_orders
SET
fulfillment_status = 'fulfilled',
payment_status = CASE WHEN balance_due <= 0 THEN 'paid' ELSE payment_status END,
status = 'fulfilled',
fulfilled_at = now(),
fulfilled_by = p_by,
updated_at = now()
WHERE id = ANY(p_order_ids)
AND fulfillment_status != 'fulfilled';
GET DIAGNOSTICS v_count = ROW_COUNT;
RETURN jsonb_build_object('success', true, 'count', v_count);
END;
$$;
-- ── Bulk record deposit: record a deposit on multiple orders ─────────────────────
DROP FUNCTION IF EXISTS public.bulk_record_wholesale_deposit(UUID[], NUMERIC, TEXT, TEXT, UUID);
CREATE OR REPLACE FUNCTION public.bulk_record_wholesale_deposit(
p_order_ids UUID[] DEFAULT NULL,
p_amount NUMERIC DEFAULT NULL,
p_method TEXT DEFAULT 'cash',
p_reference TEXT DEFAULT NULL,
p_recorded_by UUID DEFAULT NULL
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_order RECORD;
v_new_paid NUMERIC(10,2);
v_count INTEGER := 0;
BEGIN
FOR v_order IN
SELECT id, subtotal, deposit_paid, deposit_required, balance_due
FROM wholesale_orders
WHERE id = ANY(p_order_ids)
AND status != 'fulfilled'
LOOP
-- Compute new deposit_paid before the update so balance_due uses the correct value
v_new_paid := v_order.deposit_paid + p_amount;
-- Record deposit
INSERT INTO wholesale_deposits (wholesale_order_id, amount, payment_method, reference, recorded_by)
VALUES (v_order.id, p_amount, p_method, p_reference, p_recorded_by);
-- Update order
UPDATE wholesale_orders
SET
deposit_paid = v_new_paid,
balance_due = GREATEST(subtotal - v_new_paid, 0),
payment_status = CASE
WHEN subtotal - v_new_paid <= 0 THEN 'paid'
ELSE payment_status
END,
updated_at = now()
WHERE id = v_order.id;
-- Advance from awaiting_deposit if deposit now covers requirement
UPDATE wholesale_orders
SET status = 'pending'
WHERE id = v_order.id
AND status = 'awaiting_deposit'
AND v_new_paid >= deposit_required;
v_count := v_count + 1;
END LOOP;
RETURN jsonb_build_object('success', true, 'count', v_count);
END;
$$;
COMMIT;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,160 @@
-- Migration 054: Wholesale Email Notifications
-- Notification queue: stores email intents to be processed by an external
-- email service (Resend, SendGrid, etc.) or a cron webhook.
BEGIN;
-- ── 1. Notification types enum ───────────────────────────────────────────────
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'wholesale_notification_type') THEN
CREATE TYPE wholesale_notification_type AS ENUM (
'order_confirmation',
'deposit_received',
'order_fulfilled'
);
END IF;
END $$;
-- ── 2. Notifications table ────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.wholesale_notifications (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
customer_id UUID NOT NULL REFERENCES wholesale_customers(id) ON DELETE CASCADE,
order_id UUID REFERENCES wholesale_orders(id) ON DELETE SET NULL,
type wholesale_notification_type NOT NULL,
email_to TEXT NOT NULL,
email_cc TEXT,
subject TEXT NOT NULL,
body_html TEXT,
body_text TEXT,
status TEXT NOT NULL DEFAULT 'pending',
-- 'pending' | 'sent' | 'failed' | 'skipped'
sent_at TIMESTAMPTZ,
error_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE public.wholesale_notifications ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "brand_admin_manage_notifications" ON wholesale_notifications;
CREATE POLICY "brand_admin_manage_notifications" ON wholesale_notifications
FOR ALL USING (
current_setting('app.settings.role', true)::TEXT IN ('brand_admin', 'platform_admin')
AND (
current_setting('app.settings.role', true)::TEXT = 'platform_admin'
OR brand_id = current_setting('app.settings.brand_id', true)::UUID
)
);
-- ── 3. RPC: Enqueue a wholesale notification ─────────────────────────────────
DROP FUNCTION IF EXISTS public.enqueue_wholesale_notification(
UUID, UUID, UUID, wholesale_notification_type, TEXT, TEXT, TEXT, TEXT, TEXT
);
CREATE OR REPLACE FUNCTION public.enqueue_wholesale_notification(
p_brand_id UUID DEFAULT NULL,
p_customer_id UUID DEFAULT NULL,
p_order_id UUID DEFAULT NULL,
p_type wholesale_notification_type DEFAULT NULL,
p_email_to TEXT DEFAULT NULL,
p_email_cc TEXT DEFAULT NULL,
p_subject TEXT DEFAULT NULL,
p_body_html TEXT DEFAULT NULL,
p_body_text TEXT DEFAULT NULL
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_id UUID;
BEGIN
INSERT INTO wholesale_notifications (
brand_id, customer_id, order_id, type,
email_to, email_cc, subject, body_html, body_text
)
VALUES (
p_brand_id, p_customer_id, p_order_id, p_type,
p_email_to, p_email_cc, p_subject, p_body_html, p_body_text
)
RETURNING id INTO v_id;
RETURN jsonb_build_object('success', true, 'id', v_id);
END;
$$;
-- ── 4. RPC: Get pending notifications (for external processor / cron) ───────────
DROP FUNCTION IF EXISTS public.get_wholesale_pending_notifications(UUID, INTEGER);
CREATE OR REPLACE FUNCTION public.get_wholesale_pending_notifications(
p_brand_id UUID DEFAULT NULL,
p_limit INTEGER DEFAULT 50
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_result JSONB;
BEGIN
SELECT jsonb_agg(t ORDER BY t.created_at ASC) INTO v_result
FROM (
SELECT
wn.id, wn.type, wn.email_to, wn.email_cc,
wn.subject, wn.body_html, wn.body_text,
wn.brand_id, wn.customer_id, wn.order_id,
ws.invoice_business_name, ws.invoice_business_email
FROM wholesale_notifications wn
LEFT JOIN wholesale_settings ws ON ws.brand_id = wn.brand_id
WHERE wn.status = 'pending'
AND (p_brand_id IS NULL OR wn.brand_id = p_brand_id)
ORDER BY wn.created_at ASC
LIMIT p_limit
) t;
RETURN COALESCE(v_result, '[]'::JSONB);
END;
$$;
-- ── 5. RPC: Mark notification as sent ─────────────────────────────────────────
DROP FUNCTION IF EXISTS public.mark_wholesale_notification_sent(UUID, TEXT);
CREATE OR REPLACE FUNCTION public.mark_wholesale_notification_sent(
p_notification_id UUID DEFAULT NULL,
p_error TEXT DEFAULT NULL
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
UPDATE wholesale_notifications
SET
status = CASE WHEN p_error IS NULL THEN 'sent' ELSE 'failed' END,
error_message = p_error,
sent_at = CASE WHEN p_error IS NULL THEN now() ELSE sent_at END,
updated_at = now()
WHERE id = p_notification_id;
RETURN jsonb_build_object('success', true);
END;
$$;
-- ── 6. RPC: Get notification stats per brand ──────────────────────────────────
DROP FUNCTION IF EXISTS public.get_wholesale_notification_stats(UUID);
CREATE OR REPLACE FUNCTION public.get_wholesale_notification_stats(p_brand_id UUID DEFAULT NULL)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_result JSONB;
BEGIN
SELECT jsonb_build_object(
'pending', (SELECT COUNT(*) FROM wholesale_notifications WHERE brand_id = p_brand_id AND status = 'pending'),
'sent', (SELECT COUNT(*) FROM wholesale_notifications WHERE brand_id = p_brand_id AND status = 'sent'),
'failed', (SELECT COUNT(*) FROM wholesale_notifications WHERE brand_id = p_brand_id AND status = 'failed'),
'total', (SELECT COUNT(*) FROM wholesale_notifications WHERE brand_id = p_brand_id)
) INTO v_result;
RETURN v_result;
END;
$$;
COMMIT;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,154 @@
-- Migration 055: Team Notification Email + Pickup Reminder Types
-- - Adds notification_email field to wholesale_settings (team inbox per brand)
-- - Adds pickup_reminder and unclaimed_pickup to wholesale_notification_type enum
-- - Updates get_wholesale_settings to return notification_email
BEGIN;
-- ── 1. Add notification_email column ─────────────────────────────────────────
ALTER TABLE public.wholesale_settings
ADD COLUMN IF NOT EXISTS notification_email TEXT;
-- ── 2. Extend notification type enum ────────────────────────────────────────────
ALTER TYPE wholesale_notification_type ADD VALUE IF NOT EXISTS 'pickup_reminder';
ALTER TYPE wholesale_notification_type ADD VALUE IF NOT EXISTS 'unclaimed_pickup';
-- ── 3. Update get_wholesale_settings to return all settings fields ──────────────
-- Recreate to include notification_email (new) and all invoice fields (from 049)
DROP FUNCTION IF EXISTS public.get_wholesale_settings(UUID);
CREATE OR REPLACE FUNCTION public.get_wholesale_settings(p_brand_id UUID DEFAULT NULL)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_result JSONB;
BEGIN
SELECT jsonb_build_object(
'id', id,
'brand_id', brand_id,
'portal_page_id', portal_page_id,
'price_sheet_page_id', price_sheet_page_id,
'require_approval', require_approval,
'min_order_amount', min_order_amount,
'online_payment_enabled', online_payment_enabled,
'wholesale_enabled', wholesale_enabled,
'pickup_location', pickup_location,
'fob_location', fob_location,
'from_email', from_email,
'invoice_business_name', invoice_business_name,
'invoice_business_address', invoice_business_address,
'invoice_business_phone', invoice_business_phone,
'invoice_business_email', invoice_business_email,
'invoice_business_website', invoice_business_website,
'notification_email', notification_email,
'last_invoice_number', last_invoice_number
) INTO v_result
FROM wholesale_settings
WHERE brand_id = p_brand_id;
RETURN v_result;
END;
$$;
-- ── 4. Add get_wholesale_overdue_orders RPC ─────────────────────────────────────
-- Returns fulfilled orders past their anticipated pickup date with customer + settings info
CREATE OR REPLACE FUNCTION public.get_wholesale_overdue_orders()
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_result JSONB;
BEGIN
SELECT jsonb_agg(t) INTO v_result
FROM (
SELECT
wo.id,
wo.brand_id,
wo.customer_id,
wo.invoice_number,
wo.anticipated_pickup_date,
wc.email AS customer_email,
ws.pickup_location,
ws.notification_email,
ws.from_email,
ws.invoice_business_email
FROM wholesale_orders wo
JOIN wholesale_customers wc ON wo.customer_id = wc.id
LEFT JOIN wholesale_settings ws ON wo.brand_id = ws.brand_id
WHERE wo.status = 'fulfilled'
AND wo.fulfillment_status = 'fulfilled'
AND wo.anticipated_pickup_date < CURRENT_DATE
ORDER BY wo.anticipated_pickup_date ASC
LIMIT 100
) t;
RETURN COALESCE(v_result, '[]'::JSONB);
END;
$$;
-- ── 5. Update upsert_wholesale_settings to accept notification_email ──────────────
DROP FUNCTION IF EXISTS public.upsert_wholesale_settings(
UUID, UUID, UUID, BOOLEAN, NUMERIC, BOOLEAN, BOOLEAN, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT
);
CREATE OR REPLACE FUNCTION public.upsert_wholesale_settings(
p_brand_id UUID DEFAULT NULL,
p_portal_page_id UUID DEFAULT NULL,
p_price_sheet_page_id UUID DEFAULT NULL,
p_require_approval BOOLEAN DEFAULT NULL,
p_min_order_amount NUMERIC DEFAULT NULL,
p_online_payment_enabled BOOLEAN DEFAULT NULL,
p_wholesale_enabled BOOLEAN DEFAULT NULL,
p_pickup_location TEXT DEFAULT NULL,
p_fob_location TEXT DEFAULT NULL,
p_from_email TEXT DEFAULT NULL,
p_invoice_business_name TEXT DEFAULT NULL,
p_invoice_business_address TEXT DEFAULT NULL,
p_invoice_business_phone TEXT DEFAULT NULL,
p_invoice_business_email TEXT DEFAULT NULL,
p_invoice_business_website TEXT DEFAULT NULL,
p_notification_email TEXT DEFAULT NULL
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
INSERT INTO wholesale_settings (
brand_id, portal_page_id, price_sheet_page_id,
require_approval, min_order_amount, online_payment_enabled,
wholesale_enabled, pickup_location, fob_location, from_email,
invoice_business_name, invoice_business_address,
invoice_business_phone, invoice_business_email, invoice_business_website,
notification_email
)
VALUES (
p_brand_id, p_portal_page_id, p_price_sheet_page_id,
COALESCE(p_require_approval, true),
p_min_order_amount, COALESCE(p_online_payment_enabled, false),
COALESCE(p_wholesale_enabled, true),
p_pickup_location, p_fob_location, p_from_email,
p_invoice_business_name, p_invoice_business_address,
p_invoice_business_phone, p_invoice_business_email, p_invoice_business_website,
p_notification_email
)
ON CONFLICT (brand_id) DO UPDATE SET
portal_page_id = COALESCE(p_portal_page_id, wholesale_settings.portal_page_id),
price_sheet_page_id = COALESCE(p_price_sheet_page_id, wholesale_settings.price_sheet_page_id),
require_approval = COALESCE(p_require_approval, wholesale_settings.require_approval),
min_order_amount = COALESCE(p_min_order_amount, wholesale_settings.min_order_amount),
online_payment_enabled = COALESCE(p_online_payment_enabled, wholesale_settings.online_payment_enabled),
wholesale_enabled = COALESCE(p_wholesale_enabled, wholesale_settings.wholesale_enabled),
pickup_location = COALESCE(p_pickup_location, wholesale_settings.pickup_location),
fob_location = COALESCE(p_fob_location, wholesale_settings.fob_location),
from_email = COALESCE(p_from_email, wholesale_settings.from_email),
invoice_business_name = COALESCE(p_invoice_business_name, wholesale_settings.invoice_business_name),
invoice_business_address = COALESCE(p_invoice_business_address, wholesale_settings.invoice_business_address),
invoice_business_phone = COALESCE(p_invoice_business_phone, wholesale_settings.invoice_business_phone),
invoice_business_email = COALESCE(p_invoice_business_email, wholesale_settings.invoice_business_email),
invoice_business_website = COALESCE(p_invoice_business_website, wholesale_settings.invoice_business_website),
notification_email = COALESCE(p_notification_email, wholesale_settings.notification_email),
updated_at = now()
RETURNING jsonb_build_object('success', true);
END;
$$;
COMMIT;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,10 @@
-- Migration 056: Wholesale Price Sheet Notification Type
-- Adds price_sheet to the wholesale_notification_type enum.
BEGIN;
ALTER TYPE wholesale_notification_type ADD VALUE IF NOT EXISTS 'price_sheet';
COMMIT;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,123 @@
-- Migration 057: Admin Notification Recipients
-- Stores notification recipient list per brand on wholesale_settings.
-- Safe to run multiple times; uses IF NOT EXISTS and COALESCE defaults.
BEGIN;
-- ── 1. Add notification_recipients column ─────────────────────────────────────
-- Uses IF NOT EXISTS so this is safe to run if column already exists.
ALTER TABLE public.wholesale_settings
ADD COLUMN IF NOT EXISTS notification_recipients JSONB NOT NULL
DEFAULT '[]'::JSONB;
-- ── 2. Update get_wholesale_settings ───────────────────────────────────────────
-- Always returns notification_recipients: [] instead of null (via COALESCE).
DROP FUNCTION IF EXISTS public.get_wholesale_settings(UUID);
CREATE OR REPLACE FUNCTION public.get_wholesale_settings(p_brand_id UUID DEFAULT NULL)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_result JSONB;
BEGIN
SELECT jsonb_build_object(
'id', id,
'brand_id', brand_id,
'portal_page_id', portal_page_id,
'price_sheet_page_id', price_sheet_page_id,
'require_approval', require_approval,
'min_order_amount', min_order_amount,
'online_payment_enabled', online_payment_enabled,
'wholesale_enabled', wholesale_enabled,
'pickup_location', pickup_location,
'fob_location', fob_location,
'from_email', from_email,
'invoice_business_name', invoice_business_name,
'invoice_business_address', invoice_business_address,
'invoice_business_phone', invoice_business_phone,
'invoice_business_email', invoice_business_email,
'invoice_business_website', invoice_business_website,
'notification_email', notification_email,
'notification_recipients', COALESCE(notification_recipients, '[]'::JSONB),
'last_invoice_number', last_invoice_number
) INTO v_result
FROM wholesale_settings
WHERE brand_id = p_brand_id;
RETURN v_result;
END;
$$;
-- ── 3. Update upsert_wholesale_settings ───────────────────────────────────────
-- Adds p_notification_recipients (JSONB). Defaults to [] if not passed.
-- Safe to re-run: DROP IF EXISTS matches on exact signature.
DROP FUNCTION IF EXISTS public.upsert_wholesale_settings(
UUID, UUID, UUID, BOOLEAN, NUMERIC, BOOLEAN, BOOLEAN,
TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, JSONB
);
CREATE OR REPLACE FUNCTION public.upsert_wholesale_settings(
p_brand_id UUID DEFAULT NULL,
p_portal_page_id UUID DEFAULT NULL,
p_price_sheet_page_id UUID DEFAULT NULL,
p_require_approval BOOLEAN DEFAULT NULL,
p_min_order_amount NUMERIC DEFAULT NULL,
p_online_payment_enabled BOOLEAN DEFAULT NULL,
p_wholesale_enabled BOOLEAN DEFAULT NULL,
p_pickup_location TEXT DEFAULT NULL,
p_fob_location TEXT DEFAULT NULL,
p_from_email TEXT DEFAULT NULL,
p_invoice_business_name TEXT DEFAULT NULL,
p_invoice_business_address TEXT DEFAULT NULL,
p_invoice_business_phone TEXT DEFAULT NULL,
p_invoice_business_email TEXT DEFAULT NULL,
p_invoice_business_website TEXT DEFAULT NULL,
p_notification_email TEXT DEFAULT NULL,
p_notification_recipients JSONB DEFAULT NULL
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
INSERT INTO wholesale_settings (
brand_id, portal_page_id, price_sheet_page_id,
require_approval, min_order_amount, online_payment_enabled,
wholesale_enabled, pickup_location, fob_location, from_email,
invoice_business_name, invoice_business_address,
invoice_business_phone, invoice_business_email, invoice_business_website,
notification_email, notification_recipients
)
VALUES (
p_brand_id, p_portal_page_id, p_price_sheet_page_id,
COALESCE(p_require_approval, true),
p_min_order_amount, COALESCE(p_online_payment_enabled, false),
COALESCE(p_wholesale_enabled, true),
p_pickup_location, p_fob_location, p_from_email,
p_invoice_business_name, p_invoice_business_address,
p_invoice_business_phone, p_invoice_business_email, p_invoice_business_website,
p_notification_email,
COALESCE(p_notification_recipients, '[]'::JSONB)
)
ON CONFLICT (brand_id) DO UPDATE SET
portal_page_id = COALESCE(p_portal_page_id, wholesale_settings.portal_page_id),
price_sheet_page_id = COALESCE(p_price_sheet_page_id, wholesale_settings.price_sheet_page_id),
require_approval = COALESCE(p_require_approval, wholesale_settings.require_approval),
min_order_amount = COALESCE(p_min_order_amount, wholesale_settings.min_order_amount),
online_payment_enabled = COALESCE(p_online_payment_enabled, wholesale_settings.online_payment_enabled),
wholesale_enabled = COALESCE(p_wholesale_enabled, wholesale_settings.wholesale_enabled),
pickup_location = COALESCE(p_pickup_location, wholesale_settings.pickup_location),
fob_location = COALESCE(p_fob_location, wholesale_settings.fob_location),
from_email = COALESCE(p_from_email, wholesale_settings.from_email),
invoice_business_name = COALESCE(p_invoice_business_name, wholesale_settings.invoice_business_name),
invoice_business_address = COALESCE(p_invoice_business_address, wholesale_settings.invoice_business_address),
invoice_business_phone = COALESCE(p_invoice_business_phone, wholesale_settings.invoice_business_phone),
invoice_business_email = COALESCE(p_invoice_business_email, wholesale_settings.invoice_business_email),
invoice_business_website = COALESCE(p_invoice_business_website, wholesale_settings.invoice_business_website),
notification_email = COALESCE(p_notification_email, wholesale_settings.notification_email),
notification_recipients = COALESCE(p_notification_recipients, wholesale_settings.notification_recipients),
updated_at = now()
RETURNING jsonb_build_object('success', true);
END;
$$;
COMMIT;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,162 @@
-- Migration 058: Fix get_wholesale_orders and get_wholesale_customer_orders
-- Fixes: jsonb_build_object cannot be used inside jsonb_agg with FILTER.
-- Replaced with correlated subquery approach that is valid in Supabase Postgres.
BEGIN;
-- ── 1. get_wholesale_orders ────────────────────────────────────────────────────
DROP FUNCTION IF EXISTS public.get_wholesale_orders(UUID);
CREATE OR REPLACE FUNCTION public.get_wholesale_orders(p_brand_id UUID DEFAULT NULL)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_result JSONB;
BEGIN
SELECT jsonb_agg(t ORDER BY t.created_at DESC) INTO v_result
FROM (
SELECT
wo.id,
wo.status,
wo.fulfillment_status,
wo.payment_status,
wo.anticipated_pickup_date,
wo.subtotal,
wo.deposit_required,
wo.deposit_paid,
wo.balance_due,
wo.invoice_number,
wo.assigned_employee_id,
wo.created_at,
wo.updated_at,
wo.fulfilled_at,
wc.company_name,
wc.contact_name,
wc.email AS customer_email,
COALESCE(
(
SELECT jsonb_agg(
CASE WHEN woi.id IS NOT NULL THEN
jsonb_build_object(
'id', woi.id,
'product_name', wp.name,
'quantity', woi.quantity,
'unit_price', woi.unit_price,
'line_total', woi.line_total
)
END
)
FROM wholesale_order_items woi
LEFT JOIN wholesale_products wp ON woi.product_id = wp.id
WHERE woi.wholesale_order_id = wo.id
),
'[]'::JSONB
) AS items
FROM wholesale_orders wo
JOIN wholesale_customers wc ON wo.customer_id = wc.id
WHERE wo.brand_id = p_brand_id
ORDER BY wo.created_at DESC
LIMIT 500
) t;
RETURN COALESCE(v_result, '[]'::JSONB);
END;
$$;
-- ── 2. get_wholesale_customer_orders ─────────────────────────────────────────
DROP FUNCTION IF EXISTS public.get_wholesale_customer_orders(UUID);
CREATE OR REPLACE FUNCTION public.get_wholesale_customer_orders(p_customer_id UUID DEFAULT NULL)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_result JSONB;
BEGIN
SELECT jsonb_agg(t ORDER BY t.created_at DESC) INTO v_result
FROM (
SELECT
wo.id,
wo.status,
wo.fulfillment_status,
wo.payment_status,
wo.anticipated_pickup_date,
wo.subtotal,
wo.deposit_required,
wo.deposit_paid,
wo.balance_due,
wo.invoice_number,
wo.invoice_token,
wo.created_at,
wo.updated_at,
COALESCE(
(
SELECT jsonb_agg(
CASE WHEN woi.id IS NOT NULL THEN
jsonb_build_object(
'id', woi.id,
'product_name', wp.name,
'quantity', woi.quantity,
'unit_price', woi.unit_price,
'line_total', woi.line_total
)
END
)
FROM wholesale_order_items woi
LEFT JOIN wholesale_products wp ON woi.product_id = wp.id
WHERE woi.wholesale_order_id = wo.id
),
'[]'::JSONB
) AS items
FROM wholesale_orders wo
WHERE wo.customer_id = p_customer_id
ORDER BY wo.created_at DESC
LIMIT 100
) t;
RETURN COALESCE(v_result, '[]'::JSONB);
END;
$$;
-- ── 3. get_wholesale_products — simplified (no aggregate, no join issue) ───────
DROP FUNCTION IF EXISTS public.get_wholesale_products(UUID);
CREATE OR REPLACE FUNCTION public.get_wholesale_products(p_brand_id UUID DEFAULT NULL)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_result JSONB;
BEGIN
SELECT jsonb_agg(t ORDER BY t.name) INTO v_result
FROM (
SELECT
wp.id,
wp.name,
wp.description,
wp.unit_type,
wp.unit_type_custom,
wp.availability,
wp.qty_available,
wp.season_start,
wp.season_end,
wp.price_tiers,
wp.hp_sku,
wp.hp_item_id,
wp.handling_instructions,
wp.storage_warning,
wp.loading_notes,
wp.product_label,
wp.pack_style,
wp.container_type,
wp.container_size_code,
wp.units_per_container,
wp.default_pickup_location,
wp.created_at
FROM wholesale_products wp
WHERE wp.brand_id = p_brand_id
ORDER BY wp.name
) t;
RETURN COALESCE(v_result, '[]'::JSONB);
END;
$$;
COMMIT;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,63 @@
-- Migration 059: Order status update + delete RPCs
-- Needed by the redesigned Actions column dropdown in WholesaleClient.tsx
BEGIN;
-- ── update_wholesale_order_status ───────────────────────────────────────────
DROP FUNCTION IF EXISTS public.update_wholesale_order_status(UUID, TEXT);
CREATE OR REPLACE FUNCTION public.update_wholesale_order_status(
p_order_id UUID,
p_status TEXT DEFAULT 'pending'
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_order RECORD;
BEGIN
SELECT id, status INTO v_order FROM wholesale_orders WHERE id = p_order_id FOR UPDATE;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Order not found');
END IF;
IF v_order.status = 'fulfilled' THEN
RETURN jsonb_build_object('success', false, 'error', 'Cannot change status of a fulfilled order');
END IF;
UPDATE wholesale_orders
SET status = p_status, updated_at = now()
WHERE id = p_order_id;
RETURN jsonb_build_object('success', true);
END;
$$;
-- ── delete_wholesale_order ───────────────────────────────────────────────────
DROP FUNCTION IF EXISTS public.delete_wholesale_order(UUID);
CREATE OR REPLACE FUNCTION public.delete_wholesale_order(p_order_id UUID)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_order RECORD;
BEGIN
SELECT id, status INTO v_order FROM wholesale_orders WHERE id = p_order_id FOR UPDATE;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Order not found');
END IF;
IF v_order.status = 'fulfilled' THEN
RETURN jsonb_build_object('success', false, 'error', 'Cannot delete a fulfilled order');
END IF;
DELETE FROM wholesale_order_items WHERE wholesale_order_id = p_order_id;
DELETE FROM wholesale_notifications WHERE order_id = p_order_id;
DELETE FROM wholesale_orders WHERE id = p_order_id;
RETURN jsonb_build_object('success', true);
END;
$$;
COMMIT;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,68 @@
-- Migration 060: Wholesale Pickup Orders RPC for Employee Portal
-- Returns unfulfilled wholesale orders partitioned by pickup date queue:
-- past_due: anticipated_pickup_date < today
-- today: anticipated_pickup_date = today
-- upcoming: anticipated_pickup_date > today
-- Uses correlated subquery pattern (same fix as migration 058).
BEGIN;
DROP FUNCTION IF EXISTS public.get_wholesale_pickup_orders(UUID);
CREATE OR REPLACE FUNCTION public.get_wholesale_pickup_orders(p_brand_id UUID DEFAULT NULL)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_result JSONB;
BEGIN
SELECT jsonb_agg(t ORDER BY t.anticipated_pickup_date ASC) INTO v_result
FROM (
SELECT
wo.id,
wo.status,
wo.fulfillment_status,
wo.payment_status,
wo.anticipated_pickup_date,
wo.subtotal,
wo.deposit_required,
wo.deposit_paid,
wo.balance_due,
wo.invoice_number,
wo.created_at,
wo.updated_at,
wo.fulfilled_at,
wc.company_name,
wc.contact_name,
wc.email AS customer_email,
wc.phone AS customer_phone,
COALESCE(
(SELECT jsonb_agg(
CASE WHEN woi.id IS NOT NULL THEN
jsonb_build_object(
'id', woi.id,
'product_name', wp.name,
'quantity', woi.quantity,
'unit_price', woi.unit_price,
'line_total', woi.line_total
)
END
)
FROM wholesale_order_items woi
LEFT JOIN wholesale_products wp ON woi.product_id = wp.id
WHERE woi.wholesale_order_id = wo.id),
'[]'::JSONB
) AS items
FROM wholesale_orders wo
JOIN wholesale_customers wc ON wo.customer_id = wc.id
WHERE wo.brand_id = p_brand_id
AND wo.fulfillment_status != 'fulfilled'
ORDER BY wo.anticipated_pickup_date ASC
LIMIT 500
) t;
RETURN COALESCE(v_result, '[]'::JSONB);
END;
$$;
COMMIT;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,167 @@
-- Migration 061: Soft-delete for customers and products, order delete guard
-- Soft delete via deleted_at TIMESTAMPTZ (never hard-deleted to preserve FK integrity)
-- Updated get_wholesale_* to filter deleted_at IS NULL
-- Updated delete_wholesale_order to block paid orders
BEGIN;
-- ── 1. Add deleted_at columns ─────────────────────────────────────────────────
ALTER TABLE public.wholesale_customers ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
ALTER TABLE public.wholesale_products ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
-- ── 2. Update get_wholesale_customers to filter soft-deleted ────────────────
DROP FUNCTION IF EXISTS public.get_wholesale_customers(UUID);
CREATE OR REPLACE FUNCTION public.get_wholesale_customers(p_brand_id UUID DEFAULT NULL)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_result JSONB;
BEGIN
SELECT jsonb_agg(t ORDER BY t.created_at DESC) INTO v_result
FROM (
SELECT
id, user_id, company_name, contact_name, email, phone,
billing_address, shipping_address, account_status,
credit_limit, deposits_enabled, deposit_threshold, deposit_percentage,
order_email, invoice_email, admin_notes, role, created_at, updated_at,
deleted_at
FROM wholesale_customers
WHERE brand_id = p_brand_id
AND deleted_at IS NULL -- ← exclude soft-deleted
ORDER BY created_at DESC
LIMIT 500
) t;
RETURN COALESCE(v_result, '[]'::JSONB);
END;
$$;
-- ── 3. Update get_wholesale_products to filter soft-deleted ─────────────────
DROP FUNCTION IF EXISTS public.get_wholesale_products(UUID);
CREATE OR REPLACE FUNCTION public.get_wholesale_products(p_brand_id UUID DEFAULT NULL)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_result JSONB;
BEGIN
SELECT jsonb_agg(t ORDER BY t.name) INTO v_result
FROM (
SELECT
id, brand_id, rc_product_id, name, description,
unit_type, unit_type_custom, availability, qty_available,
season_start, season_end, price_tiers, hp_sku, hp_item_id,
handling_instructions, storage_warning, loading_notes,
product_label, pack_style, container_type, container_size_code,
units_per_container, default_pickup_location, created_at, updated_at,
deleted_at
FROM wholesale_products
WHERE brand_id = p_brand_id
AND deleted_at IS NULL -- ← exclude soft-deleted
ORDER BY name
LIMIT 500
) t;
RETURN COALESCE(v_result, '[]'::JSONB);
END;
$$;
-- ── 4. delete_wholesale_customer (soft) ─────────────────────────────────────
DROP FUNCTION IF EXISTS public.delete_wholesale_customer(UUID);
CREATE OR REPLACE FUNCTION public.delete_wholesale_customer(p_customer_id UUID)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_customer RECORD;
v_order_count BIGINT;
BEGIN
-- Check customer exists
SELECT id, company_name INTO v_customer
FROM wholesale_customers WHERE id = p_customer_id FOR UPDATE;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Customer not found');
END IF;
-- Block if any orders exist (active or historical)
SELECT COUNT(*) INTO v_order_count
FROM wholesale_orders WHERE customer_id = p_customer_id;
IF v_order_count > 0 THEN
RETURN jsonb_build_object(
'success', false,
'error', 'Cannot delete customer with existing orders. Disable the account instead.'
);
END IF;
-- Soft delete
UPDATE wholesale_customers SET deleted_at = now() WHERE id = p_customer_id;
RETURN jsonb_build_object('success', true);
END;
$$;
-- ── 5. delete_wholesale_product (soft) ─────────────────────────────────────
DROP FUNCTION IF EXISTS public.delete_wholesale_product(UUID);
CREATE OR REPLACE FUNCTION public.delete_wholesale_product(p_product_id UUID)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_product RECORD;
v_usage_count BIGINT;
BEGIN
-- Check product exists
SELECT id, name INTO v_product
FROM wholesale_products WHERE id = p_product_id FOR UPDATE;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Product not found');
END IF;
-- Block if referenced in any order items
SELECT COUNT(*) INTO v_usage_count
FROM wholesale_order_items WHERE product_id = p_product_id;
IF v_usage_count > 0 THEN
RETURN jsonb_build_object(
'success', false,
'error', 'Cannot delete product that is attached to orders. Set availability to unavailable instead.'
);
END IF;
-- Soft delete
UPDATE wholesale_products SET deleted_at = now() WHERE id = p_product_id;
RETURN jsonb_build_object('success', true);
END;
$$;
-- ── 6. Strengthen delete_wholesale_order guard ──────────────────────────────
DROP FUNCTION IF EXISTS public.delete_wholesale_order(UUID);
CREATE OR REPLACE FUNCTION public.delete_wholesale_order(p_order_id UUID)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_order RECORD;
BEGIN
SELECT id, status, fulfillment_status, payment_status INTO v_order
FROM wholesale_orders WHERE id = p_order_id FOR UPDATE;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Order not found');
END IF;
IF v_order.fulfillment_status = 'fulfilled' THEN
RETURN jsonb_build_object('success', false, 'error', 'Cannot delete a fulfilled order.');
END IF;
IF v_order.payment_status = 'paid' THEN
RETURN jsonb_build_object('success', false, 'error', 'Cannot delete a paid order.');
END IF;
DELETE FROM wholesale_order_items WHERE wholesale_order_id = p_order_id;
DELETE FROM wholesale_notifications WHERE order_id = p_order_id;
DELETE FROM wholesale_orders WHERE id = p_order_id;
RETURN jsonb_build_object('success', true);
END;
$$;
COMMIT;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,195 @@
-- Migration 062: Webhook dispatcher for wholesale events
-- Adds webhook_settings table, sync_log table, enqueue RPC, and dispatch route
BEGIN;
-- ── 1. wholesale_webhook_settings ──────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.wholesale_webhook_settings (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
brand_id UUID NOT NULL REFERENCES public.brands(id) ON DELETE CASCADE,
url TEXT NOT NULL,
secret TEXT NOT NULL, -- HMAC signing secret
enabled BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (brand_id)
);
ALTER TABLE public.wholesale_webhook_settings ENABLE ROW LEVEL SECURITY;
CREATE POLICY "admins_manage_webhook_settings" ON public.wholesale_webhook_settings
FOR ALL USING (
EXISTS (
SELECT 1 FROM public.admin_users au
WHERE au.user_id = auth.uid()
AND au.brand_id = wholesale_webhook_settings.brand_id
AND au.role IN ('platform_admin', 'brand_admin')
)
);
-- ── 2. wholesale_sync_log ──────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.wholesale_sync_log (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
brand_id UUID NOT NULL,
event_type TEXT NOT NULL, -- order_created | order_fulfilled | deposit_recorded | order_paid
order_id UUID,
payload JSONB,
status TEXT NOT NULL DEFAULT 'pending', -- pending | sent | failed | retrying
response TEXT,
attempts INTEGER NOT NULL DEFAULT 0,
next_retry TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE public.wholesale_sync_log ENABLE ROW LEVEL SECURITY;
CREATE POLICY "admins_read_sync_log" ON public.wholesale_sync_log
FOR SELECT USING (
EXISTS (
SELECT 1 FROM public.admin_users au
WHERE au.user_id = auth.uid()
AND au.brand_id = wholesale_sync_log.brand_id
AND au.role IN ('platform_admin', 'brand_admin', 'store_employee')
)
);
-- ── 3. enqueue_wholesale_webhook RPC ──────────────────────────────────────
DROP FUNCTION IF EXISTS public.enqueue_wholesale_webhook(TEXT, UUID, JSONB, UUID);
CREATE OR REPLACE FUNCTION public.enqueue_wholesale_webhook(
p_event_type TEXT,
p_order_id UUID DEFAULT NULL,
p_payload JSONB DEFAULT NULL,
p_brand_id UUID DEFAULT NULL
)
RETURNS UUID
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_log_id UUID;
v_brand_id UUID;
BEGIN
-- Use provided brand_id, or fall back to the first brand with webhooks enabled
IF p_brand_id IS NOT NULL THEN
SELECT brand_id INTO v_brand_id
FROM public.wholesale_webhook_settings
WHERE brand_id = p_brand_id AND enabled = true AND url IS NOT NULL AND url != ''
LIMIT 1;
ELSE
SELECT brand_id INTO v_brand_id
FROM public.wholesale_webhook_settings
WHERE enabled = true AND url IS NOT NULL AND url != ''
LIMIT 1;
END IF;
IF NOT FOUND OR v_brand_id IS NULL THEN
-- Webhooks not enabled — silent no-op
RETURN NULL;
END IF;
INSERT INTO public.wholesale_sync_log (brand_id, event_type, order_id, payload, status)
VALUES (v_brand_id, p_event_type, p_order_id, p_payload, 'pending')
RETURNING id INTO v_log_id;
RETURN v_log_id;
END;
$$;
-- ── 4. get_pending_webhooks RPC ────────────────────────────────────────────
DROP FUNCTION IF EXISTS public.get_pending_webhooks(INTEGER);
CREATE OR REPLACE FUNCTION public.get_pending_webhooks(p_limit INTEGER DEFAULT 20)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
RETURN COALESCE(
(
SELECT jsonb_agg(r ORDER BY r.created_at ASC)
FROM (
SELECT
wsl.id,
wsl.brand_id,
wsl.event_type,
wsl.order_id,
wsl.payload,
wsl.attempts,
wsl.status,
ws.url,
ws.secret
FROM wholesale_sync_log wsl
JOIN wholesale_webhook_settings ws ON ws.brand_id = wsl.brand_id AND ws.enabled = true
WHERE wsl.status IN ('pending', 'retrying')
AND (wsl.next_retry IS NULL OR wsl.next_retry <= now())
ORDER BY wsl.created_at ASC
LIMIT p_limit
) r
),
'[]'::JSONB
);
END;
$$;
-- ── 5. mark_webhook_sent RPC ───────────────────────────────────────────────
DROP FUNCTION IF EXISTS public.mark_webhook_sent(UUID, TEXT);
CREATE OR REPLACE FUNCTION public.mark_webhook_sent(p_log_id UUID, p_response TEXT DEFAULT NULL)
RETURNS VOID
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
UPDATE wholesale_sync_log
SET status = 'sent', response = p_response, attempts = attempts + 1
WHERE id = p_log_id;
END;
$$;
-- ── 6. mark_webhook_failed RPC ─────────────────────────────────────────────
DROP FUNCTION IF EXISTS public.mark_webhook_failed(UUID, TEXT);
CREATE OR REPLACE FUNCTION public.mark_webhook_failed(p_log_id UUID, p_response TEXT DEFAULT NULL)
RETURNS VOID
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_attempts INTEGER;
BEGIN
SELECT attempts INTO v_attempts FROM wholesale_sync_log WHERE id = p_log_id FOR UPDATE;
IF v_attempts >= 3 THEN
UPDATE wholesale_sync_log
SET status = 'failed', response = p_response, attempts = attempts + 1
WHERE id = p_log_id;
ELSE
UPDATE wholesale_sync_log
SET status = 'retrying',
response = p_response,
attempts = attempts + 1,
next_retry = now() + (attempts + 1 || ' hours')::INTERVAL
WHERE id = p_log_id;
END IF;
END;
$$;
-- ── 7. upsert_wholesale_webhook_settings RPC ─────────────────────────────────
DROP FUNCTION IF EXISTS public.upsert_wholesale_webhook_settings(UUID, TEXT, TEXT, BOOLEAN);
CREATE OR REPLACE FUNCTION public.upsert_wholesale_webhook_settings(
p_brand_id UUID,
p_url TEXT,
p_secret TEXT,
p_enabled BOOLEAN
)
RETURNS UUID
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_id UUID;
BEGIN
INSERT INTO public.wholesale_webhook_settings (brand_id, url, secret, enabled)
VALUES (p_brand_id, p_url, p_secret, p_enabled)
ON CONFLICT (brand_id) DO UPDATE SET
url = EXCLUDED.url,
secret = EXCLUDED.secret,
enabled = EXCLUDED.enabled,
updated_at = now()
RETURNING id INTO v_id;
RETURN v_id;
END;
$$;
COMMIT;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,67 @@
-- Migration 063: Auto-create wholesale_settings on first registration
-- Ensures new brands get a default wholesale_settings row automatically
BEGIN;
-- Update register_wholesale_customer to auto-create wholesale_settings if missing
CREATE OR REPLACE FUNCTION public.register_wholesale_customer(
p_brand_id UUID DEFAULT NULL,
p_company_name TEXT DEFAULT NULL,
p_contact_name TEXT DEFAULT NULL,
p_email TEXT DEFAULT NULL,
p_phone TEXT DEFAULT NULL
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_id UUID;
v_req_app BOOLEAN := true;
BEGIN
-- Reject duplicate email for this brand
IF EXISTS (SELECT 1 FROM wholesale_customers WHERE brand_id = p_brand_id AND email = p_email) THEN
RETURN jsonb_build_object('success', false, 'error', 'An account with this email already exists.');
END IF;
-- Auto-create wholesale_settings row if it doesn't exist for this brand
-- This ensures new brands always have settings before registration proceeds
IF NOT EXISTS (SELECT 1 FROM wholesale_settings WHERE brand_id = p_brand_id) THEN
INSERT INTO wholesale_settings (brand_id, require_approval, wholesale_enabled, pickup_location)
VALUES (p_brand_id, true, true, 'See your order confirmation for pickup details')
ON CONFLICT (brand_id) DO NOTHING;
END IF;
-- Read require_approval setting for this brand (now guaranteed to exist)
BEGIN
SELECT require_approval INTO v_req_app
FROM wholesale_settings
WHERE brand_id = p_brand_id;
EXCEPTION WHEN OTHERS THEN
v_req_app := true;
END;
INSERT INTO wholesale_customers (
brand_id, company_name, contact_name, email, phone,
account_status, role
) VALUES (
p_brand_id,
p_company_name,
p_contact_name,
p_email,
p_phone,
CASE WHEN COALESCE(v_req_app, true) THEN 'pending_approval' ELSE 'active' END,
'buyer'
)
RETURNING id INTO v_id;
RETURN jsonb_build_object(
'success', true,
'id', v_id,
'requires_approval', COALESCE(v_req_app, true)
);
END;
$$;
COMMIT;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,163 @@
-- Migration 064: Seed wholesale_settings for existing brands + fix RLS for service role
-- Also creates a seed function that bypasses RLS for initial setup
BEGIN;
-- Create a SECURITY DEFINER function that inserts wholesale_settings without RLS
-- This is only for initial seeding — not used in normal operation
CREATE OR REPLACE FUNCTION public.seed_wholesale_settings(
p_brand_id UUID,
p_require_approval BOOLEAN DEFAULT true,
p_wholesale_enabled BOOLEAN DEFAULT true,
p_pickup_location TEXT DEFAULT NULL,
p_from_email TEXT DEFAULT NULL,
p_invoice_business_name TEXT DEFAULT NULL
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
INSERT INTO wholesale_settings (
brand_id, require_approval, wholesale_enabled,
pickup_location, from_email, invoice_business_name
)
VALUES (
p_brand_id,
COALESCE(p_require_approval, true),
COALESCE(p_wholesale_enabled, true),
p_pickup_location,
p_from_email,
p_invoice_business_name
)
ON CONFLICT (brand_id) DO UPDATE SET
require_approval = COALESCE(p_require_approval, wholesale_settings.require_approval),
wholesale_enabled = COALESCE(p_wholesale_enabled, wholesale_settings.wholesale_enabled),
pickup_location = COALESCE(p_pickup_location, wholesale_settings.pickup_location),
from_email = COALESCE(p_from_email, wholesale_settings.from_email),
invoice_business_name = COALESCE(p_invoice_business_name, wholesale_settings.invoice_business_name),
updated_at = now()
RETURNING jsonb_build_object('success', true, 'brand_id', brand_id);
END;
$$;
-- Update register_wholesale_customer to auto-create settings if missing
-- This ensures new brands always get settings on first registration
CREATE OR REPLACE FUNCTION public.register_wholesale_customer(
p_brand_id UUID DEFAULT NULL,
p_company_name TEXT DEFAULT NULL,
p_contact_name TEXT DEFAULT NULL,
p_email TEXT DEFAULT NULL,
p_phone TEXT DEFAULT NULL
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_id UUID;
v_req_app BOOLEAN := true;
BEGIN
-- Reject duplicate email for this brand
IF EXISTS (SELECT 1 FROM wholesale_customers WHERE brand_id = p_brand_id AND email = p_email) THEN
RETURN jsonb_build_object('success', false, 'error', 'An account with this email already exists.');
END IF;
-- Auto-create wholesale_settings row if it doesn't exist for this brand
-- Uses SECURITY DEFINER to bypass RLS — so it works even without service role
IF NOT EXISTS (SELECT 1 FROM wholesale_settings WHERE brand_id = p_brand_id) THEN
INSERT INTO wholesale_settings (brand_id, require_approval, wholesale_enabled, pickup_location)
VALUES (p_brand_id, true, true, 'See your order confirmation for pickup details')
ON CONFLICT (brand_id) DO NOTHING;
END IF;
-- Read require_approval setting for this brand
BEGIN
SELECT require_approval INTO v_req_app
FROM wholesale_settings
WHERE brand_id = p_brand_id;
EXCEPTION WHEN OTHERS THEN
v_req_app := true;
END;
INSERT INTO wholesale_customers (
brand_id, company_name, contact_name, email, phone,
account_status, role
) VALUES (
p_brand_id,
p_company_name,
p_contact_name,
p_email,
p_phone,
CASE WHEN COALESCE(v_req_app, true) THEN 'pending_approval' ELSE 'active' END,
'buyer'
)
RETURNING id INTO v_id;
RETURN jsonb_build_object(
'success', true,
'id', v_id,
'requires_approval', COALESCE(v_req_app, true)
);
END;
$$;
-- Also fix get_wholesale_settings to return defaults if no settings exist
-- This prevents 406 errors when settings are missing
CREATE OR REPLACE FUNCTION public.get_wholesale_settings(p_brand_id UUID DEFAULT NULL)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_result JSONB;
BEGIN
SELECT jsonb_build_object(
'id', id,
'brand_id', brand_id,
'portal_page_id', portal_page_id,
'price_sheet_page_id', price_sheet_page_id,
'require_approval', require_approval,
'min_order_amount', min_order_amount,
'online_payment_enabled', online_payment_enabled,
'wholesale_enabled', COALESCE(wholesale_enabled, true),
'pickup_location', COALESCE(pickup_location, 'Contact us for pickup details'),
'fob_location', fob_location,
'from_email', from_email,
'invoice_business_name', COALESCE(invoice_business_name, 'Wholesale'),
'invoice_business_address', invoice_business_address,
'invoice_business_phone', invoice_business_phone,
'invoice_business_email', invoice_business_email,
'invoice_business_website', invoice_business_website,
'last_invoice_number', last_invoice_number
) INTO v_result
FROM wholesale_settings
WHERE brand_id = p_brand_id;
-- If still null, return safe defaults so client code doesn't break
IF v_result IS NULL THEN
RETURN jsonb_build_object(
'id', NULL,
'brand_id', p_brand_id,
'portal_page_id', NULL,
'price_sheet_page_id', NULL,
'require_approval', true,
'min_order_amount', 0,
'online_payment_enabled', false,
'wholesale_enabled', true,
'pickup_location', 'Contact us for pickup details',
'fob_location', NULL,
'from_email', NULL,
'invoice_business_name', 'Wholesale',
'invoice_business_address', NULL,
'invoice_business_phone', NULL,
'invoice_business_email', NULL,
'invoice_business_website', NULL,
'last_invoice_number', 0
);
END IF;
RETURN v_result;
END;
$$;
COMMIT;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,43 @@
-- Migration 065: Fix get_wholesale_products — remove rc_product_id reference
-- The wholesale_products table does not have rc_product_id column,
-- but get_wholesale_products RPC (migration 061) references it.
-- Also drop the orphaned rc_product_id column if it exists.
BEGIN;
-- Drop rc_product_id column if it somehow got added (migration 046 planned it but it was never created)
-- This is safe IF NOT EXISTS since the column likely doesn't exist
ALTER TABLE public.wholesale_products DROP COLUMN IF EXISTS rc_product_id;
-- Recreate get_wholesale_products without rc_product_id reference
DROP FUNCTION IF EXISTS public.get_wholesale_products(UUID);
CREATE OR REPLACE FUNCTION public.get_wholesale_products(p_brand_id UUID DEFAULT NULL)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_result JSONB;
BEGIN
SELECT jsonb_agg(t ORDER BY t.name) INTO v_result
FROM (
SELECT
id, brand_id, name, description,
unit_type, unit_type_custom, availability, qty_available,
season_start, season_end, price_tiers, hp_sku, hp_item_id,
handling_instructions, storage_warning, loading_notes,
product_label, pack_style, container_type, container_size_code,
units_per_container, default_pickup_location, created_at, updated_at,
deleted_at
FROM wholesale_products
WHERE brand_id = p_brand_id
AND deleted_at IS NULL
ORDER BY name
LIMIT 500
) t;
RETURN COALESCE(v_result, '[]'::JSONB);
END;
$$;
COMMIT;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,154 @@
-- Migration 066: Auto Square Sync — queue table, enqueue trigger, claim processor
-- Applies: square_sync_queue table, enqueue_square_sync() RPC,
-- trg_wholesale_products_sync trigger, queue processor endpoint
BEGIN;
-- ── 1. Sync queue table ──────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.square_sync_queue (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
sync_type TEXT NOT NULL DEFAULT 'products',
status TEXT NOT NULL DEFAULT 'pending',
enqueued_at TIMESTAMPTZ NOT NULL DEFAULT now(),
processed_at TIMESTAMPTZ,
retry_count INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
CONSTRAINT square_sync_queue_status CHECK (status IN ('pending', 'processing', 'done', 'failed'))
);
CREATE INDEX IF NOT EXISTS idx_square_sync_queue_pending
ON square_sync_queue(status, enqueued_at ASC)
WHERE status = 'pending';
-- ── 2. enqueue_square_sync — idempotent, safe for trigger ─────────────────────
CREATE OR REPLACE FUNCTION public.enqueue_square_sync(
p_brand_id UUID,
p_sync_type TEXT DEFAULT 'products'
)
RETURNS void
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM square_sync_queue
WHERE brand_id = p_brand_id
AND sync_type = p_sync_type
AND status IN ('pending', 'processing')
) THEN
INSERT INTO square_sync_queue (brand_id, sync_type, status)
VALUES (p_brand_id, p_sync_type, 'pending');
END IF;
END;
$$;
-- ── 3. claim_square_sync_queue — atomic claim + return ────────────────────────
-- Atomically claims the oldest pending entry for a brand, marks it 'processing',
-- and returns it so the processor can work on it.
CREATE OR REPLACE FUNCTION public.claim_square_sync_queue(p_brand_id UUID)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_entry JSONB;
BEGIN
-- Claim and update in one atomic operation
WITH claimed AS (
UPDATE square_sync_queue
SET status = 'processing', processed_at = now()
WHERE id = (
SELECT id FROM square_sync_queue
WHERE brand_id = p_brand_id
AND status = 'pending'
ORDER BY enqueued_at ASC
LIMIT 1
FOR UPDATE SKIP LOCKED
)
RETURNING id, brand_id, sync_type, status, enqueued_at
)
SELECT jsonb_build_object(
'id', id,
'brand_id', brand_id,
'sync_type', sync_type,
'status', status,
'enqueued_at', enqueued_at
) INTO v_entry
FROM claimed;
RETURN COALESCE(v_entry, 'null'::JSONB);
END;
$$;
-- ── 4. Triggers on wholesale_products ─────────────────────────────────────────
CREATE OR REPLACE FUNCTION public.trg_wholesale_products_on_change()
RETURNS TRIGGER
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
IF (TG_OP = 'INSERT') THEN
IF NEW.availability = 'available' THEN
PERFORM enqueue_square_sync(NEW.brand_id, 'products');
END IF;
RETURN NEW;
END IF;
IF (TG_OP = 'UPDATE') THEN
IF OLD.availability = 'available' OR NEW.availability = 'available' THEN
PERFORM enqueue_square_sync(NEW.brand_id, 'products');
END IF;
RETURN NEW;
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS trg_wholesale_products_sync ON wholesale_products;
CREATE TRIGGER trg_wholesale_products_sync
AFTER INSERT OR UPDATE OF availability, name, description, price_tiers, unit_type, default_pickup_location
ON wholesale_products
FOR EACH ROW EXECUTE FUNCTION trg_wholesale_products_on_change();
CREATE OR REPLACE FUNCTION public.trg_wholesale_products_on_delete()
RETURNS TRIGGER
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
PERFORM enqueue_square_sync(OLD.brand_id, 'products');
RETURN OLD;
END;
$$;
DROP TRIGGER IF EXISTS trg_wholesale_products_sync_del ON wholesale_products;
CREATE TRIGGER trg_wholesale_products_sync_del
AFTER DELETE ON wholesale_products
FOR EACH ROW EXECUTE FUNCTION trg_wholesale_products_on_delete();
-- ── 5. Upsert payment_settings with square_last_sync_at ─────────────────────
-- Helper RPC to update last sync timestamp after a sync completes
CREATE OR REPLACE FUNCTION public.update_square_sync_timestamp(
p_brand_id UUID,
p_error TEXT DEFAULT NULL
)
RETURNS void
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
UPDATE payment_settings
SET square_last_sync_at = now(),
square_last_sync_error = p_error
WHERE brand_id = p_brand_id;
END;
$$;
COMMIT;
NOTIFY pgrst, 'reload schema';
-- ============================================================
-- RLS for square_sync_queue (added by scripts/fix-archived-rls.js)
-- Columns: id, brand_id, sync_type, status, enqueued_at, processed_at, retry_count, last_error
-- ============================================================
ALTER TABLE square_sync_queue ENABLE ROW LEVEL SECURITY;
ALTER TABLE square_sync_queue FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON square_sync_queue;
CREATE POLICY tenant_isolation ON square_sync_queue FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
@@ -0,0 +1,160 @@
-- Migration 067: Checkout idempotency for wholesale orders
-- Adds checkout_session_id UUID to wholesale_orders for safe retry/double-submit protection.
-- UNIQUE constraint (null-excluding) ensures each session maps to at most one order.
-- The create_wholesale_order RPC checks this key before inserting to prevent duplicates.
BEGIN;
ALTER TABLE public.wholesale_orders
ADD COLUMN IF NOT EXISTS checkout_session_id UUID;
COMMENT ON COLUMN wholesale_orders.checkout_session_id IS
'Set by submitWholesaleOrder at start of checkout. Prevents duplicate order creation from double-click, back-button, or network retry. UNIQUE WHERE NOT NULL.';
-- Fast lookup for idempotency checks in create_wholesale_order
CREATE UNIQUE INDEX IF NOT EXISTS idx_wholesale_orders_checkout_session_id
ON wholesale_orders(checkout_session_id)
WHERE checkout_session_id IS NOT NULL;
-- Update create_wholesale_order to accept + enforce checkout_session_id
DROP FUNCTION IF EXISTS public.create_wholesale_order(
UUID, UUID, DATE, JSONB, INTEGER, TEXT
);
CREATE OR REPLACE FUNCTION public.create_wholesale_order(
p_brand_id UUID DEFAULT NULL,
p_customer_id UUID DEFAULT NULL,
p_anticipated_pickup_date DATE DEFAULT NULL,
p_items JSONB DEFAULT '[]'::JSONB,
p_deposit_percentage INTEGER DEFAULT NULL,
p_notes TEXT DEFAULT NULL,
p_checkout_session_id UUID DEFAULT NULL
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_order_id UUID;
v_subtotal NUMERIC(10,2);
v_dep_required NUMERIC(10,2) := 0;
v_customer RECORD;
v_settings RECORD;
v_inv_number TEXT;
v_inv_token TEXT;
v_status TEXT;
v_outstanding NUMERIC(10,2);
v_existing_id UUID;
BEGIN
-- ── Idempotency guard ──────────────────────────────────────────────────────
-- If a checkout_session_id was provided and an order already exists with
-- that key, return the existing order instead of creating a duplicate.
IF p_checkout_session_id IS NOT NULL THEN
SELECT id INTO v_existing_id
FROM wholesale_orders
WHERE checkout_session_id = p_checkout_session_id
LIMIT 1;
IF v_existing_id IS NOT NULL THEN
SELECT jsonb_build_object(
'success', true,
'order_id', v_existing_id,
'invoice_number', invoice_number,
'status', status,
'deposit_required', deposit_required,
'subtotal', subtotal,
'idempotent', true,
'note', 'Order already exists — returning existing record'
) INTO v_existing_id
FROM wholesale_orders
WHERE id = v_existing_id;
RETURN v_existing_id;
END IF;
END IF;
-- ── 1. Resolve customer: credit_limit + deposit rules ──────────────────────
SELECT credit_limit, deposits_enabled, deposit_threshold, deposit_percentage
INTO v_customer
FROM wholesale_customers
WHERE id = p_customer_id;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Customer not found');
END IF;
-- ── 2. Calculate subtotal ──────────────────────────────────────────────────
v_subtotal := COALESCE(
(SELECT SUM(
((item->>'quantity')::NUMERIC) * ((item->>'unit_price')::NUMERIC)
)
FROM jsonb_array_elements(p_items) AS item),
0
);
-- ── 3. Credit limit enforcement ────────────────────────────────────────────
IF v_customer.credit_limit > 0 THEN
SELECT COALESCE(SUM(balance_due), 0) INTO v_outstanding
FROM wholesale_orders
WHERE customer_id = p_customer_id AND status != 'fulfilled';
IF v_outstanding + v_subtotal > v_customer.credit_limit THEN
RETURN jsonb_build_object(
'success', false,
'error', 'Order exceeds available credit',
'credit_limit', v_customer.credit_limit,
'outstanding_balance', v_outstanding,
'order_total', v_subtotal
);
END IF;
END IF;
-- ── 4. Generate TC-XXXXXX invoice + md5 token ──────────────────────────────
v_inv_number := 'TC-' || LPAD(FLOOR(RANDOM() * 1000000)::INT::TEXT, 6, '0');
v_inv_token := md5(random()::text || clock_timestamp()::text || now()::text || p_brand_id::text);
-- ── 5. Deposit logic ───────────────────────────────────────────────────────
IF v_customer.deposits_enabled THEN
IF v_customer.deposit_threshold IS NULL OR v_subtotal >= v_customer.deposit_threshold THEN
v_dep_required := ROUND(v_subtotal * COALESCE(v_customer.deposit_percentage, 0) / 100.0, 2);
END IF;
END IF;
v_status := CASE WHEN v_dep_required > 0 THEN 'awaiting_deposit' ELSE 'pending' END;
-- ── 6. Insert order ─────────────────────────────────────────────────────────
INSERT INTO wholesale_orders (
brand_id, customer_id, status, anticipated_pickup_date,
subtotal, deposit_required, deposit_paid, balance_due,
deposit_percentage, internal_notes, invoice_number, invoice_token,
checkout_session_id
) VALUES (
p_brand_id, p_customer_id, v_status, p_anticipated_pickup_date,
v_subtotal, v_dep_required, 0, v_subtotal,
p_deposit_percentage, p_notes, v_inv_number, v_inv_token,
p_checkout_session_id
) RETURNING id INTO v_order_id;
-- ── 7. Insert line items ───────────────────────────────────────────────────
IF jsonb_array_length(p_items) > 0 THEN
INSERT INTO wholesale_order_items (wholesale_order_id, product_id, quantity, unit_price, line_total)
SELECT v_order_id,
(item->>'product_id')::UUID,
(item->>'quantity')::NUMERIC,
(item->>'unit_price')::NUMERIC,
((item->>'quantity')::NUMERIC) * ((item->>'unit_price')::NUMERIC)
FROM jsonb_array_elements(p_items) AS item;
END IF;
RETURN jsonb_build_object(
'success', true,
'order_id', v_order_id,
'invoice_number', v_inv_number,
'status', v_status,
'deposit_required', v_dep_required,
'subtotal', v_subtotal,
'idempotent', false
);
END;
$$;
COMMIT;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,67 @@
-- Migration 068: User carts table for server-side cart persistence
-- Enables cart merge on login and server-side cart for logged-in users.
BEGIN;
CREATE TABLE IF NOT EXISTS public.user_carts (
user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
items JSONB NOT NULL DEFAULT '[]'::JSONB,
-- Shape: [{ id, name, price, quantity, fulfillment, brand_id, brand_slug }]
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE public.user_carts ENABLE ROW LEVEL SECURITY;
-- Users can read/write their own cart; platform_admin can read all
CREATE POLICY "users_own_cart" ON public.user_carts
FOR ALL USING (
auth.uid() = user_id
OR current_setting('app.settings.role', true)::TEXT = 'platform_admin'
);
-- RPC: upsert_user_cart — insert or update the user's cart
-- p_user_id: optional override (platform_admin may pass to update another user's cart)
CREATE OR REPLACE FUNCTION public.upsert_user_cart(
p_user_id UUID,
p_items JSONB
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
INSERT INTO user_carts (user_id, items, updated_at)
VALUES (p_user_id, p_items, now())
ON CONFLICT (user_id) DO UPDATE SET
items = p_items,
updated_at = now()
RETURNING jsonb_build_object('success', true, 'user_id', user_id, 'item_count', jsonb_array_length(p_items));
END;
$$;
-- RPC: get_user_cart — fetch a user's cart
CREATE OR REPLACE FUNCTION public.get_user_cart(p_user_id UUID)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_items JSONB;
BEGIN
SELECT items INTO v_items FROM user_carts WHERE user_id = p_user_id;
RETURN COALESCE(v_items, '[]'::JSONB);
END;
$$;
-- RPC: clear_user_cart
CREATE OR REPLACE FUNCTION public.clear_user_cart(p_user_id UUID)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
DELETE FROM user_carts WHERE user_id = p_user_id;
RETURN jsonb_build_object('success', true);
END;
$$;
COMMIT;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,607 @@
-- Migration 069: Brand Scoping Phase 2 — RLS Fixes + RPC Validation
-- 1. Fix wholesale_order_items RLS — add brand check via JOIN
-- 2. Fix wholesale_deposits RLS — add brand check via JOIN
-- 3. Fix get_wholesale_orders/customers/products NULL handling (platform_admin = all)
-- 4. Add brand validation to update_shipping_order
-- 5. Add brand validation to water log mutating RPCs
BEGIN;
-- ── 1. Fix wholesale_order_items RLS ─────────────────────────────────────────
-- Current policy has no brand check. Add one via JOIN to wholesale_orders.
DROP POLICY IF EXISTS "brand_admin_manage_wholesale_order_items" ON wholesale_order_items;
-- Note: wholesale_order_items RLS intentionally re-enabled (was disabled for
-- pre-existing trusted backend service access; now locked down to brand-scoped).
ALTER TABLE wholesale_order_items ENABLE ROW LEVEL SECURITY;
CREATE POLICY "brand_admin_manage_wholesale_order_items" ON wholesale_order_items
FOR ALL USING (
-- platform_admin can do anything
current_setting('app.settings.role', true)::TEXT = 'platform_admin'
OR (
-- brand_admin must match the order's brand via JOIN
current_setting('app.settings.role', true)::TEXT = 'brand_admin'
AND EXISTS (
SELECT 1 FROM wholesale_orders wo
WHERE wo.id = wholesale_order_items.wholesale_order_id
AND wo.brand_id = current_setting('app.settings.brand_id', true)::UUID
)
)
);
-- ── 2. Fix wholesale_deposits RLS ─────────────────────────────────────────────
DROP POLICY IF EXISTS "brand_admin_manage_wholesale_deposits" ON wholesale_deposits;
-- See note above: wholesale_deposits RLS re-enabled to brand-scoped policy.
ALTER TABLE wholesale_deposits ENABLE ROW LEVEL SECURITY;
CREATE POLICY "brand_admin_manage_wholesale_deposits" ON wholesale_deposits
FOR ALL USING (
current_setting('app.settings.role', true)::TEXT = 'platform_admin'
OR (
current_setting('app.settings.role', true)::TEXT = 'brand_admin'
AND EXISTS (
SELECT 1 FROM wholesale_orders wo
WHERE wo.id = wholesale_deposits.wholesale_order_id
AND wo.brand_id = current_setting('app.settings.brand_id', true)::UUID
)
)
);
-- ── 3. Fix get_wholesale_orders NULL handling ────────────────────────────────
-- NULL brand_id should return ALL orders (platform_admin); set brand returns scoped.
CREATE OR REPLACE FUNCTION public.get_wholesale_orders(p_brand_id UUID DEFAULT NULL)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_result JSONB;
BEGIN
SELECT jsonb_agg(t ORDER BY t.created_at DESC) INTO v_result
FROM (
SELECT
wo.id,
wo.status,
wo.fulfillment_status,
wo.payment_status,
wo.anticipated_pickup_date,
wo.subtotal,
wo.deposit_required,
wo.deposit_paid,
wo.balance_due,
wo.invoice_number,
wo.invoice_token,
wo.checkout_session_id,
wo.created_at,
wo.updated_at,
wo.fulfilled_at,
wo.notes,
wc.company_name,
wc.contact_name,
wc.email AS customer_email,
wc.phone AS customer_phone,
COALESCE(
(SELECT jsonb_agg(
CASE WHEN woi.id IS NOT NULL THEN
jsonb_build_object(
'id', woi.id,
'product_name', wp.name,
'quantity', woi.quantity,
'unit_price', woi.unit_price,
'line_total', woi.line_total
)
END
)
FROM wholesale_order_items woi
LEFT JOIN wholesale_products wp ON woi.product_id = wp.id
WHERE woi.wholesale_order_id = wo.id),
'[]'::JSONB
) AS items
FROM wholesale_orders wo
JOIN wholesale_customers wc ON wo.customer_id = wc.id
WHERE (
-- NULL brand_id = platform_admin = all brands
p_brand_id IS NULL
OR wo.brand_id = p_brand_id
)
ORDER BY wo.created_at DESC
LIMIT 500
) t;
RETURN COALESCE(v_result, '[]'::JSONB);
END;
$$;
-- ── 4. Fix get_wholesale_customers NULL handling ────────────────────────────
CREATE OR REPLACE FUNCTION public.get_wholesale_customers(p_brand_id UUID DEFAULT NULL)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_result JSONB;
BEGIN
SELECT jsonb_agg(t ORDER BY t.company_name ASC) INTO v_result
FROM (
SELECT
id,
user_id,
company_name,
contact_name,
email,
phone,
account_status,
role,
brand_id,
credit_limit,
outstanding_balance,
created_at,
updated_at
FROM wholesale_customers
WHERE p_brand_id IS NULL OR brand_id = p_brand_id
ORDER BY company_name ASC
LIMIT 500
) t;
RETURN COALESCE(v_result, '[]'::JSONB);
END;
$$;
-- ── 5. Fix get_wholesale_products NULL handling ─────────────────────────────
CREATE OR REPLACE FUNCTION public.get_wholesale_products(p_brand_id UUID DEFAULT NULL)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_result JSONB;
BEGIN
SELECT jsonb_agg(t ORDER BY t.name ASC) INTO v_result
FROM (
SELECT
id,
brand_id,
name,
description,
unit_type,
availability,
qty_available,
hp_sku,
created_at,
updated_at,
price_tiers
FROM wholesale_products
WHERE p_brand_id IS NULL OR brand_id = p_brand_id
ORDER BY name ASC
LIMIT 500
) t;
RETURN COALESCE(v_result, '[]'::JSONB);
END;
$$;
-- ── 6. Add brand validation to update_shipping_order ─────────────────────────
CREATE OR REPLACE FUNCTION public.update_shipping_order(
p_order_id UUID,
p_shipping_status TEXT,
p_tracking_number TEXT DEFAULT NULL,
p_brand_id UUID DEFAULT NULL -- NEW: for brand validation
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_order RECORD;
BEGIN
-- Fetch order and validate brand access
SELECT id, brand_id INTO v_order
FROM orders
WHERE id = p_order_id;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Order not found');
END IF;
-- Brand validation: brand_admin can only update orders for their brand
IF p_brand_id IS NOT NULL AND v_order.brand_id != p_brand_id THEN
RETURN jsonb_build_object('success', false, 'error', 'Not authorized to update this order');
END IF;
UPDATE orders SET
shipping_status = p_shipping_status,
tracking_number = COALESCE(p_tracking_number, tracking_number),
updated_at = now()
WHERE id = p_order_id;
RETURN jsonb_build_object(
'success', true,
'order_id', p_order_id,
'shipping_status', p_shipping_status,
'tracking_number', COALESCE(p_tracking_number, tracking_number)
);
END;
$$;
-- ── 7. Add brand validation to water log mutating RPCs ──────────────────────
-- update_water_entry: add p_brand_id, validate
CREATE OR REPLACE FUNCTION public.update_water_entry(
p_entry_id UUID,
p_measurement NUMERIC,
p_notes TEXT DEFAULT NULL,
p_unit TEXT DEFAULT NULL,
p_brand_id UUID DEFAULT NULL
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_entry RECORD;
BEGIN
SELECT we.id, we.brand_id INTO v_entry
FROM water_log_entries we
WHERE we.id = p_entry_id AND we.deleted_at IS NULL;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Entry not found');
END IF;
-- Brand validation
IF p_brand_id IS NOT NULL AND v_entry.brand_id != p_brand_id THEN
RETURN jsonb_build_object('success', false, 'error', 'Not authorized to update this entry');
END IF;
UPDATE water_log_entries SET
measurement = p_measurement,
notes = COALESCE(p_notes, notes),
unit = COALESCE(p_unit, unit),
updated_at = now()
WHERE id = p_entry_id;
RETURN jsonb_build_object('success', true, 'entry_id', p_entry_id);
END;
$$;
-- delete_water_entry: add p_brand_id
CREATE OR REPLACE FUNCTION public.delete_water_entry(
p_entry_id UUID,
p_brand_id UUID DEFAULT NULL
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_entry RECORD;
BEGIN
SELECT id, brand_id INTO v_entry
FROM water_log_entries
WHERE id = p_entry_id AND deleted_at IS NULL;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Entry not found');
END IF;
IF p_brand_id IS NOT NULL AND v_entry.brand_id != p_brand_id THEN
RETURN jsonb_build_object('success', false, 'error', 'Not authorized to delete this entry');
END IF;
UPDATE water_log_entries SET deleted_at = now() WHERE id = p_entry_id;
RETURN jsonb_build_object('success', true);
END;
$$;
-- update_water_headgate: add p_brand_id
CREATE OR REPLACE FUNCTION public.update_water_headgate(
p_headgate_id UUID,
p_name TEXT,
p_active BOOLEAN DEFAULT true,
p_unit TEXT DEFAULT NULL,
p_brand_id UUID DEFAULT NULL
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_hg RECORD;
BEGIN
SELECT id, brand_id INTO v_hg
FROM water_headgates
WHERE id = p_headgate_id AND deleted_at IS NULL;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Headgate not found');
END IF;
IF p_brand_id IS NOT NULL AND v_hg.brand_id != p_brand_id THEN
RETURN jsonb_build_object('success', false, 'error', 'Not authorized to update this headgate');
END IF;
UPDATE water_headgates SET
name = p_name,
active = p_active,
unit = COALESCE(p_unit, unit),
updated_at = now()
WHERE id = p_headgate_id;
RETURN jsonb_build_object('success', true, 'headgate_id', p_headgate_id);
END;
$$;
-- delete_water_headgate: add p_brand_id
CREATE OR REPLACE FUNCTION public.delete_water_headgate(
p_headgate_id UUID,
p_brand_id UUID DEFAULT NULL
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_hg RECORD;
BEGIN
SELECT id, brand_id INTO v_hg
FROM water_headgates
WHERE id = p_headgate_id AND deleted_at IS NULL;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Headgate not found');
END IF;
IF p_brand_id IS NOT NULL AND v_hg.brand_id != p_brand_id THEN
RETURN jsonb_build_object('success', false, 'error', 'Not authorized to delete this headgate');
END IF;
UPDATE water_headgates SET deleted_at = now() WHERE id = p_headgate_id;
RETURN jsonb_build_object('success', true);
END;
$$;
-- update_water_user: add p_brand_id
CREATE OR REPLACE FUNCTION public.update_water_user(
p_user_id UUID,
p_name TEXT DEFAULT NULL,
p_active BOOLEAN DEFAULT NULL,
p_lang TEXT DEFAULT NULL,
p_role TEXT DEFAULT NULL,
p_brand_id UUID DEFAULT NULL
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_user RECORD;
BEGIN
SELECT id, brand_id INTO v_user
FROM water_users
WHERE id = p_user_id AND deleted_at IS NULL;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'User not found');
END IF;
IF p_brand_id IS NOT NULL AND v_user.brand_id != p_brand_id THEN
RETURN jsonb_build_object('success', false, 'error', 'Not authorized to update this user');
END IF;
UPDATE water_users SET
name = COALESCE(p_name, name),
active = COALESCE(p_active, active),
lang = COALESCE(p_lang, lang),
role = COALESCE(p_role, role),
updated_at = now()
WHERE id = p_user_id;
RETURN jsonb_build_object('success', true, 'user_id', p_user_id);
END;
$$;
-- delete_water_user: add p_brand_id
CREATE OR REPLACE FUNCTION public.delete_water_user(
p_user_id UUID,
p_brand_id UUID DEFAULT NULL
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_user RECORD;
BEGIN
SELECT id, brand_id INTO v_user
FROM water_users
WHERE id = p_user_id AND deleted_at IS NULL;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'User not found');
END IF;
IF p_brand_id IS NOT NULL AND v_user.brand_id != p_brand_id THEN
RETURN jsonb_build_object('success', false, 'error', 'Not authorized to delete this user');
END IF;
UPDATE water_users SET deleted_at = now() WHERE id = p_user_id;
RETURN jsonb_build_object('success', true);
END;
$$;
-- reset_water_irrigator_pin: add p_brand_id via join check
CREATE OR REPLACE FUNCTION public.reset_water_irrigator_pin(
p_user_id UUID,
p_brand_id UUID DEFAULT NULL
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_user RECORD;
BEGIN
SELECT id, brand_id INTO v_user
FROM water_users
WHERE id = p_user_id AND deleted_at IS NULL;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'User not found');
END IF;
IF p_brand_id IS NOT NULL AND v_user.brand_id != p_brand_id THEN
RETURN jsonb_build_object('success', false, 'error', 'Not authorized to reset this PIN');
END IF;
UPDATE water_users SET
pin_hash = NULL,
updated_at = now()
WHERE id = p_user_id;
RETURN jsonb_build_object('success', true);
END;
$$;
-- reset_water_irrigator_pin: add p_brand_id via join check
CREATE OR REPLACE FUNCTION public.reset_water_irrigator_pin(
p_user_id UUID,
p_brand_id UUID DEFAULT NULL
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_user RECORD;
BEGIN
SELECT id, brand_id INTO v_user
FROM water_users
WHERE id = p_user_id AND deleted_at IS NULL;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'User not found');
END IF;
IF p_brand_id IS NOT NULL AND v_user.brand_id != p_brand_id THEN
RETURN jsonb_build_object('success', false, 'error', 'Not authorized to reset this PIN');
END IF;
UPDATE water_users SET
pin_hash = NULL,
updated_at = now()
WHERE id = p_user_id;
RETURN jsonb_build_object('success', true);
END;
$$;
-- ── 8. Add brand validation to record_wholesale_deposit ────────────────────
CREATE OR REPLACE FUNCTION public.record_wholesale_deposit(
p_order_id UUID,
p_amount NUMERIC,
p_method TEXT DEFAULT 'cash',
p_reference TEXT DEFAULT NULL,
p_recorded_by UUID DEFAULT NULL,
p_brand_id UUID DEFAULT NULL -- NEW
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_order RECORD;
BEGIN
SELECT id, brand_id INTO v_order
FROM wholesale_orders
WHERE id = p_order_id;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Order not found');
END IF;
IF p_brand_id IS NOT NULL AND v_order.brand_id != p_brand_id THEN
RETURN jsonb_build_object('success', false, 'error', 'Not authorized to record deposit for this order');
END IF;
INSERT INTO wholesale_deposits (wholesale_order_id, amount, method, reference, recorded_by)
VALUES (p_order_id, p_amount, p_method, p_reference, p_recorded_by);
UPDATE wholesale_orders SET
deposit_paid = deposit_paid + p_amount,
payment_status = CASE
WHEN deposit_paid + p_amount >= deposit_required THEN 'deposit_paid'
ELSE 'partial_deposit'
END,
updated_at = now()
WHERE id = p_order_id;
RETURN jsonb_build_object('success', true, 'order_id', p_order_id, 'amount', p_amount);
END;
$$;
-- ── 9. Add brand validation to bulk_fulfill_wholesale_orders ─────────────────
CREATE OR REPLACE FUNCTION public.bulk_fulfill_wholesale_orders(
p_order_ids UUID[],
p_by UUID DEFAULT NULL,
p_brand_id UUID DEFAULT NULL -- NEW
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_count INT;
BEGIN
-- Brand scoping: if brand_id provided, validate all orders belong to it
IF p_brand_id IS NOT NULL THEN
SELECT COUNT(*) INTO v_count
FROM wholesale_orders
WHERE id = ANY(p_order_ids) AND brand_id != p_brand_id;
IF v_count > 0 THEN
RETURN jsonb_build_object('success', false, 'error', 'Not authorized to fulfill these orders');
END IF;
END IF;
UPDATE wholesale_orders SET
fulfillment_status = 'fulfilled',
fulfilled_at = now(),
fulfilled_by = p_by,
updated_at = now()
WHERE id = ANY(p_order_ids) AND fulfillment_status != 'fulfilled';
GET DIAGNOSTICS v_count = ROW_COUNT;
RETURN jsonb_build_object('success', true, 'count', v_count);
END;
$$;
-- ── 10. Add brand validation to bulk_record_wholesale_deposit ───────────────
CREATE OR REPLACE FUNCTION public.bulk_record_wholesale_deposit(
p_order_ids UUID[],
p_amount NUMERIC,
p_method TEXT DEFAULT 'cash',
p_reference TEXT DEFAULT NULL,
p_recorded_by UUID DEFAULT NULL,
p_brand_id UUID DEFAULT NULL -- NEW
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_count INT;
v_order RECORD;
BEGIN
-- Brand scoping
IF p_brand_id IS NOT NULL THEN
SELECT COUNT(*) INTO v_count
FROM wholesale_orders
WHERE id = ANY(p_order_ids) AND brand_id != p_brand_id;
IF v_count > 0 THEN
RETURN jsonb_build_object('success', false, 'error', 'Not authorized to record deposits for these orders');
END IF;
END IF;
INSERT INTO wholesale_deposits (wholesale_order_id, amount, method, reference, recorded_by)
SELECT o.id, p_amount, p_method, p_reference, p_recorded_by
FROM wholesale_orders o
WHERE o.id = ANY(p_order_ids);
UPDATE wholesale_orders SET
deposit_paid = deposit_paid + p_amount,
payment_status = CASE
WHEN deposit_paid + p_amount >= deposit_required THEN 'deposit_paid'
ELSE 'partial_deposit'
END,
updated_at = now()
WHERE id = ANY(p_order_ids);
GET DIAGNOSTICS v_count = ROW_COUNT;
RETURN jsonb_build_object('success', true, 'count', v_count);
END;
$$;
COMMIT;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,278 @@
-- Migration 070: RLS Policy Audit Fixes
-- 1. Add RLS to products (was completely missing)
-- 2. Add brand scoping to stops RLS
-- 3. Add deleted_at IS NULL to wholesale_customers and wholesale_products RLS
-- 4. Add SELECT policy to order_items (brand-scoped via orders/stops JOIN)
BEGIN;
-- ── 1. products — Add RLS ──────────────────────────────────────────────────────
-- Table has brand_id column but no RLS policies whatsoever.
-- Add SELECT for admins (brand-scoped), INSERT/UPDATE/DELETE blocked (RPC only).
ALTER TABLE public.products ENABLE ROW LEVEL SECURITY;
-- Platform admin can read all products
CREATE POLICY "platform_admin_read_products" ON public.products
FOR SELECT USING (
EXISTS (
SELECT 1 FROM admin_users au
WHERE au.user_id = auth.uid()
AND au.role = 'platform_admin'
)
);
-- Brand admin can read their brand's products
CREATE POLICY "brand_admin_read_products" ON public.products
FOR SELECT USING (
EXISTS (
SELECT 1 FROM admin_users au
WHERE au.user_id = auth.uid()
AND au.role = 'brand_admin'
AND au.brand_id = products.brand_id
)
);
-- Store employee can read their brand's products
CREATE POLICY "store_employee_read_products" ON public.products
FOR SELECT USING (
EXISTS (
SELECT 1 FROM admin_users au
WHERE au.user_id = auth.uid()
AND au.role = 'store_employee'
AND au.brand_id = products.brand_id
)
);
-- wholesale_customer can read their brand's products (via brand_id join)
CREATE POLICY "wholesale_customer_read_products" ON public.products
FOR SELECT USING (
EXISTS (
SELECT 1 FROM admin_users au
JOIN wholesale_customers wc ON wc.user_id = au.user_id
WHERE au.user_id = auth.uid()
AND au.role = 'wholesale_customer'
AND wc.brand_id = products.brand_id
)
);
-- Block INSERT/UPDATE/DELETE on products — all writes go through RPCs
-- No policy = no access for mutating operations (authenticated users blocked)
CREATE POLICY "block_products_mutations" ON public.products
FOR INSERT WITH CHECK (false);
CREATE POLICY "block_products_update" ON public.products
FOR UPDATE USING (false);
CREATE POLICY "block_products_delete" ON public.products
FOR DELETE USING (false);
-- ── 2. stops — Add brand scoping ───────────────────────────────────────────────
-- Current policy allows ANY admin to read ALL stops across all brands.
-- Replace with brand-scoped policies.
DROP POLICY IF EXISTS "Admins can read stops" ON stops;
-- See comment below: stops RLS re-enabled to brand-scoped read-only policies.
ALTER TABLE stops ENABLE ROW LEVEL SECURITY;
-- Platform admin sees all stops
CREATE POLICY "platform_admin_read_stops" ON stops
FOR SELECT USING (
EXISTS (
SELECT 1 FROM admin_users au
WHERE au.user_id = auth.uid()
AND au.role = 'platform_admin'
)
);
-- Brand admin / store_employee sees only their brand's stops
CREATE POLICY "brand_admin_read_stops" ON stops
FOR SELECT USING (
EXISTS (
SELECT 1 FROM admin_users au
WHERE au.user_id = auth.uid()
AND au.role IN ('brand_admin', 'store_employee')
AND au.brand_id = stops.brand_id
)
);
-- Block all mutations on stops (admin only via RPC)
CREATE POLICY "block_stops_mutations" ON stops
FOR INSERT WITH CHECK (false);
CREATE POLICY "block_stops_update" ON stops
FOR UPDATE USING (false);
CREATE POLICY "block_stops_delete" ON stops
FOR DELETE USING (false);
-- ── 3. wholesale_customers — Add deleted_at IS NULL ───────────────────────────
-- Soft-deleted customers (deleted_at NOT NULL) should not be readable via REST.
-- Split into two policies: one for platform_admin (no deleted filter), one for brand_admin.
DROP POLICY IF EXISTS "brand_admin_manage_wholesale_customers" ON wholesale_customers;
DROP POLICY IF EXISTS "wholesale_customer_read_own" ON wholesale_customers;
-- wholesale_customers RLS re-enabled to brand-scoped read/write policies.
ALTER TABLE wholesale_customers ENABLE ROW LEVEL SECURITY;
-- Platform admin can read all customers (including soft-deleted for recovery)
CREATE POLICY "platform_admin_manage_wholesale_customers" ON wholesale_customers
FOR ALL USING (
current_setting('app.settings.role', true)::TEXT = 'platform_admin'
);
-- Brand admin can only manage their brand's non-deleted customers
CREATE POLICY "brand_admin_manage_wholesale_customers" ON wholesale_customers
FOR ALL USING (
current_setting('app.settings.role', true)::TEXT = 'brand_admin'
AND brand_id = current_setting('app.settings.brand_id', true)::UUID
AND deleted_at IS NULL
);
-- Customers can read their own record (not brand-admin, wholesale_customer role)
CREATE POLICY "wholesale_customer_read_own" ON wholesale_customers
FOR SELECT USING (
current_setting('app.settings.role', true)::TEXT = 'wholesale_customer'
AND user_id = current_setting('app.settings.user_id', true)::UUID
AND deleted_at IS NULL
);
-- ── 4. wholesale_products — Add deleted_at IS NULL ───────────────────────────
-- Soft-deleted products should not be readable via REST.
DROP POLICY IF EXISTS "brand_admin_manage_wholesale_products" ON wholesale_products;
-- wholesale_products RLS re-enabled to brand-scoped read/write policies.
ALTER TABLE wholesale_products ENABLE ROW LEVEL SECURITY;
-- Platform admin can read all (including soft-deleted for recovery)
CREATE POLICY "platform_admin_manage_wholesale_products" ON wholesale_products
FOR ALL USING (
current_setting('app.settings.role', true)::TEXT = 'platform_admin'
);
-- Brand admin can only manage their brand's non-deleted products
CREATE POLICY "brand_admin_manage_wholesale_products" ON wholesale_products
FOR ALL USING (
current_setting('app.settings.role', true)::TEXT = 'brand_admin'
AND brand_id = current_setting('app.settings.brand_id', true)::UUID
AND deleted_at IS NULL
);
-- ── 5. order_items — Add brand-scoped SELECT policy ───────────────────────────
-- order_items currently has no RLS policies (all access blocked).
-- Add SELECT with brand scoping via orders/stops JOIN.
ALTER TABLE public.order_items ENABLE ROW LEVEL SECURITY;
-- Platform admin can read all order items
CREATE POLICY "platform_admin_read_order_items" ON order_items
FOR SELECT USING (
EXISTS (
SELECT 1 FROM admin_users au
WHERE au.user_id = auth.uid()
AND au.role = 'platform_admin'
)
);
-- Brand admin / store_employee: can read order items for their brand's orders
CREATE POLICY "brand_admin_read_order_items" ON order_items
FOR SELECT USING (
EXISTS (
SELECT 1 FROM admin_users au
JOIN orders o ON o.id = order_items.order_id
JOIN stops s ON s.id = o.stop_id
WHERE au.user_id = auth.uid()
AND au.role IN ('brand_admin', 'store_employee')
AND s.brand_id = au.brand_id
)
);
-- Block all mutations on order_items (admin only via RPC)
CREATE POLICY "block_order_items_mutations" ON order_items
FOR INSERT WITH CHECK (false);
CREATE POLICY "block_order_items_update" ON order_items
FOR UPDATE USING (false);
CREATE POLICY "block_order_items_delete" ON order_items
FOR DELETE USING (false);
-- ── 6. orders — Add brand scoping to write policies ───────────────────────────
-- Existing SELECT policies use stops JOIN correctly.
-- Ensure INSERT/UPDATE/DELETE policies also enforce brand scoping (or block them).
-- Note: orders INSERT/UPDATE/DELETE go through RPCs — add blocking policies as safety net.
CREATE POLICY "block_orders_insert" ON orders
FOR INSERT WITH CHECK (false);
CREATE POLICY "block_orders_update" ON orders
FOR UPDATE USING (false);
CREATE POLICY "block_orders_delete" ON orders
FOR DELETE USING (false);
-- ── 7. wholesale_orders — Ensure no deleted_at issues ─────────────────────────
-- wholesale_orders has no deleted_at column (hard delete). No changes needed.
-- But add explicit INSERT/UPDATE/DELETE block to match other tables.
-- All writes go through RPCs — block direct REST mutations.
CREATE POLICY "block_wholesale_orders_insert" ON wholesale_orders
FOR INSERT WITH CHECK (false);
CREATE POLICY "block_wholesale_orders_update" ON wholesale_orders
FOR UPDATE USING (false);
CREATE POLICY "block_wholesale_orders_delete" ON wholesale_orders
FOR DELETE USING (false);
-- ── 8. wholesale_order_items — Block direct REST mutations ────────────────────
-- SELECT is already scoped in 069. Block INSERT/UPDATE/DELETE.
CREATE POLICY "block_wholesale_order_items_insert" ON wholesale_order_items
FOR INSERT WITH CHECK (false);
CREATE POLICY "block_wholesale_order_items_update" ON wholesale_order_items
FOR UPDATE USING (false);
CREATE POLICY "block_wholesale_order_items_delete" ON wholesale_order_items
FOR DELETE USING (false);
-- ── 9. wholesale_deposits — Block direct REST mutations ───────────────────────
-- SELECT is already scoped in 069. Block INSERT/UPDATE/DELETE.
CREATE POLICY "block_wholesale_deposits_insert" ON wholesale_deposits
FOR INSERT WITH CHECK (false);
CREATE POLICY "block_wholesale_deposits_update" ON wholesale_deposits
FOR UPDATE USING (false);
CREATE POLICY "block_wholesale_deposits_delete" ON wholesale_deposits
FOR DELETE USING (false);
-- ── 10. wholesale_customers — Block direct REST mutations ──────────────────────
-- SELECT policies already set above. Block mutations.
CREATE POLICY "block_wholesale_customers_insert" ON wholesale_customers
FOR INSERT WITH CHECK (false);
CREATE POLICY "block_wholesale_customers_update" ON wholesale_customers
FOR UPDATE USING (false);
CREATE POLICY "block_wholesale_customers_delete" ON wholesale_customers
FOR DELETE USING (false);
-- ── 11. wholesale_products — Block direct REST mutations ───────────────────────
-- SELECT policies already set above. Block mutations.
CREATE POLICY "block_wholesale_products_insert" ON wholesale_products
FOR INSERT WITH CHECK (false);
CREATE POLICY "block_wholesale_products_update" ON wholesale_products
FOR UPDATE USING (false);
CREATE POLICY "block_wholesale_products_delete" ON wholesale_products
FOR DELETE USING (false);
COMMIT;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,180 @@
-- Migration 071: Add square_sync_enabled to wholesale_settings
-- - Adds square_sync_enabled BOOLEAN column (default false)
-- - Updates upsert_wholesale_settings RPC to accept p_square_sync_enabled
-- - Updates enqueue_square_sync to respect wholesale_settings.square_sync_enabled
BEGIN;
-- ── 1. Add square_sync_enabled column ────────────────────────────────────────
ALTER TABLE public.wholesale_settings
ADD COLUMN IF NOT EXISTS square_sync_enabled BOOLEAN NOT NULL DEFAULT false;
-- ── 2. Update enqueue_square_sync to check wholesale_settings ────────────────
-- Only enqueue if square_sync_enabled = true for the brand.
-- If no wholesale_settings record exists, skip (auto-sync disabled until configured).
DROP FUNCTION IF EXISTS public.enqueue_square_sync(UUID, TEXT);
CREATE OR REPLACE FUNCTION public.enqueue_square_sync(
p_brand_id UUID,
p_sync_type TEXT DEFAULT 'products'
)
RETURNS void
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
-- Guard: only enqueue if square_sync_enabled for this brand
IF NOT EXISTS (
SELECT 1 FROM wholesale_settings ws
WHERE ws.brand_id = p_brand_id
AND ws.square_sync_enabled = true
) THEN
RETURN; -- sync disabled for this brand
END IF;
IF NOT EXISTS (
SELECT 1 FROM square_sync_queue
WHERE brand_id = p_brand_id
AND sync_type = p_sync_type
AND status IN ('pending', 'processing')
) THEN
INSERT INTO square_sync_queue (brand_id, sync_type, status)
VALUES (p_brand_id, p_sync_type, 'pending');
END IF;
END;
$$;
-- ── 3. Update upsert_wholesale_settings RPC ───────────────────────────────────
-- Drop first to avoid "cannot remove parameter defaults" error.
DROP FUNCTION IF EXISTS public.upsert_wholesale_settings(
UUID, BOOLEAN, NUMERIC, BOOLEAN, BOOLEAN, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, JSONB, BOOLEAN
);
CREATE OR REPLACE FUNCTION public.upsert_wholesale_settings(
p_brand_id UUID,
p_require_approval BOOLEAN DEFAULT NULL,
p_min_order_amount NUMERIC DEFAULT NULL,
p_online_payment_enabled BOOLEAN DEFAULT NULL,
p_wholesale_enabled BOOLEAN DEFAULT NULL,
p_pickup_location TEXT DEFAULT NULL,
p_fob_location TEXT DEFAULT NULL,
p_from_email TEXT DEFAULT NULL,
p_invoice_business_name TEXT DEFAULT NULL,
p_invoice_business_address TEXT DEFAULT NULL,
p_invoice_business_phone TEXT DEFAULT NULL,
p_invoice_business_email TEXT DEFAULT NULL,
p_invoice_business_website TEXT DEFAULT NULL,
p_notification_email TEXT DEFAULT NULL,
p_notification_recipients JSONB DEFAULT NULL,
p_square_sync_enabled BOOLEAN DEFAULT NULL -- NEW
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_result JSONB;
BEGIN
INSERT INTO wholesale_settings (
brand_id, require_approval, min_order_amount, online_payment_enabled,
wholesale_enabled, pickup_location, fob_location, from_email,
invoice_business_name, invoice_business_address, invoice_business_phone,
invoice_business_email, invoice_business_website,
notification_email, notification_recipients, square_sync_enabled
)
VALUES (
p_brand_id, COALESCE(p_require_approval, true), p_min_order_amount,
COALESCE(p_online_payment_enabled, false), COALESCE(p_wholesale_enabled, true),
p_pickup_location, p_fob_location, p_from_email,
p_invoice_business_name, p_invoice_business_address, p_invoice_business_phone,
p_invoice_business_email, p_invoice_business_website,
p_notification_email, COALESCE(p_notification_recipients, '[]'::JSONB),
COALESCE(p_square_sync_enabled, false)
)
ON CONFLICT (brand_id) DO UPDATE SET
require_approval = COALESCE(p_require_approval, wholesale_settings.require_approval),
min_order_amount = COALESCE(p_min_order_amount, wholesale_settings.min_order_amount),
online_payment_enabled = COALESCE(p_online_payment_enabled, wholesale_settings.online_payment_enabled),
wholesale_enabled = COALESCE(p_wholesale_enabled, wholesale_settings.wholesale_enabled),
pickup_location = COALESCE(p_pickup_location, wholesale_settings.pickup_location),
fob_location = COALESCE(p_fob_location, wholesale_settings.fob_location),
from_email = COALESCE(p_from_email, wholesale_settings.from_email),
invoice_business_name = COALESCE(p_invoice_business_name, wholesale_settings.invoice_business_name),
invoice_business_address = COALESCE(p_invoice_business_address, wholesale_settings.invoice_business_address),
invoice_business_phone = COALESCE(p_invoice_business_phone, wholesale_settings.invoice_business_phone),
invoice_business_email = COALESCE(p_invoice_business_email, wholesale_settings.invoice_business_email),
invoice_business_website = COALESCE(p_invoice_business_website, wholesale_settings.invoice_business_website),
notification_email = COALESCE(p_notification_email, wholesale_settings.notification_email),
notification_recipients = COALESCE(p_notification_recipients, wholesale_settings.notification_recipients),
square_sync_enabled = COALESCE(p_square_sync_enabled, wholesale_settings.square_sync_enabled),
updated_at = now()
RETURNING jsonb_build_object(
'success', true,
'brand_id', brand_id,
'square_sync_enabled', square_sync_enabled
) INTO v_result;
RETURN v_result;
END;
$$;
-- ── 4. Update get_wholesale_settings to return square_sync_enabled ──────────
DROP FUNCTION IF EXISTS public.get_wholesale_settings(UUID);
CREATE OR REPLACE FUNCTION public.get_wholesale_settings(p_brand_id UUID)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_result JSONB;
BEGIN
SELECT jsonb_build_object(
'id', ws.id,
'brand_id', ws.brand_id,
'portal_page_id', ws.portal_page_id,
'price_sheet_page_id', ws.price_sheet_page_id,
'require_approval', ws.require_approval,
'min_order_amount', ws.min_order_amount,
'online_payment_enabled', ws.online_payment_enabled,
'wholesale_enabled', ws.wholesale_enabled,
'pickup_location', ws.pickup_location,
'fob_location', ws.fob_location,
'from_email', ws.from_email,
'invoice_business_name', ws.invoice_business_name,
'invoice_business_address', ws.invoice_business_address,
'invoice_business_phone', ws.invoice_business_phone,
'invoice_business_email', ws.invoice_business_email,
'invoice_business_website', ws.invoice_business_website,
'notification_email', ws.notification_email,
'notification_recipients', COALESCE(ws.notification_recipients, '[]'::JSONB),
'square_sync_enabled', ws.square_sync_enabled,
'last_invoice_number', ws.last_invoice_number
) INTO v_result
FROM wholesale_settings ws
WHERE ws.brand_id = p_brand_id;
-- Fallback: return safe defaults if no record exists yet
IF v_result IS NULL THEN
RETURN jsonb_build_object(
'id', NULL,
'brand_id', p_brand_id,
'require_approval', true,
'wholesale_enabled', true,
'online_payment_enabled', false,
'square_sync_enabled', false,
'pickup_location', '',
'fob_location', '',
'from_email', '',
'invoice_business_name', '',
'invoice_business_address', NULL,
'invoice_business_phone', NULL,
'invoice_business_email', NULL,
'invoice_business_website', NULL,
'notification_email', NULL,
'notification_recipients', '[]'::JSONB,
'last_invoice_number', 0
);
END IF;
RETURN v_result;
END;
$$;
COMMIT;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,218 @@
-- Migration 072: Brand Scoping for Retail Orders RPCs
-- Ensures get_admin_orders and get_admin_order_detail enforce p_brand_id at the RPC layer.
-- RLS policies on orders and order_items tables for brand scoping via stops.
-- Step 1: Drop and recreate get_admin_orders with proper brand scoping
DROP FUNCTION IF EXISTS public.get_admin_orders(UUID);
CREATE OR REPLACE FUNCTION public.get_admin_orders(p_brand_id UUID DEFAULT NULL)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_orders JSONB;
v_stops JSONB;
BEGIN
IF p_brand_id IS NULL THEN
-- platform_admin: return all orders
SELECT COALESCE(jsonb_agg(t ORDER BY t.created_at DESC), '[]'::JSONB)
INTO v_orders
FROM (
SELECT o.id, o.customer_name, o.customer_email, o.customer_phone,
o.stop_id, o.status, o.subtotal, o.pickup_complete,
o.pickup_completed_at, o.pickup_completed_by, o.created_at,
o.brand_id,
CASE WHEN o.stop_id IS NOT NULL THEN jsonb_build_object(
'id', s.id, 'city', s.city, 'state', s.state,
'date', s.date, 'time', s.time, 'location', s.location, 'brand_id', s.brand_id
) END as stops
FROM orders o LEFT JOIN stops s ON o.stop_id = s.id
WHERE o.stop_id IS NOT NULL
LIMIT 500
) t;
SELECT COALESCE(jsonb_agg(jsonb_build_object(
'id', id, 'city', city, 'state', state,
'date', date, 'time', time, 'location', location, 'brand_id', brand_id
) ORDER BY date), '[]'::JSONB)
INTO v_stops FROM stops WHERE active = true;
ELSE
-- brand_admin or store_employee: restrict to brand via stops join
SELECT COALESCE(jsonb_agg(t ORDER BY t.created_at DESC), '[]'::JSONB)
INTO v_orders
FROM (
SELECT o.id, o.customer_name, o.customer_email, o.customer_phone,
o.stop_id, o.status, o.subtotal, o.pickup_complete,
o.pickup_completed_at, o.pickup_completed_by, o.created_at,
o.brand_id,
jsonb_build_object(
'id', s.id, 'city', s.city, 'state', s.state,
'date', s.date, 'time', s.time, 'location', s.location, 'brand_id', s.brand_id
) as stops
FROM orders o JOIN stops s ON o.stop_id = s.id
WHERE s.brand_id = p_brand_id
LIMIT 500
) t;
SELECT COALESCE(jsonb_agg(jsonb_build_object(
'id', id, 'city', city, 'state', state,
'date', date, 'time', time, 'location', location, 'brand_id', brand_id
) ORDER BY date), '[]'::JSONB)
INTO v_stops FROM stops WHERE active = true AND brand_id = p_brand_id;
END IF;
RETURN jsonb_build_object('orders', v_orders, 'stops', v_stops);
END;
$$;
-- Step 2: Drop and recreate get_admin_order_detail with proper brand scoping
DROP FUNCTION IF EXISTS public.get_admin_order_detail(UUID, UUID);
CREATE OR REPLACE FUNCTION public.get_admin_order_detail(p_order_id UUID, p_brand_id UUID DEFAULT NULL)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_order JSONB;
v_order_brand_id UUID;
v_stop_brand_id UUID;
BEGIN
-- Resolve brand from order.brand_id or stop.brand_id
SELECT COALESCE(o.brand_id, s.brand_id), s.brand_id
INTO v_order_brand_id, v_stop_brand_id
FROM orders o LEFT JOIN stops s ON o.stop_id = s.id
WHERE o.id = p_order_id;
-- Enforce brand scoping
IF p_brand_id IS NOT NULL AND v_order_brand_id IS NOT NULL AND v_order_brand_id != p_brand_id THEN
RETURN jsonb_build_object('error', 'Order not found or access denied');
END IF;
-- No order found
IF v_order_brand_id IS NULL AND p_brand_id IS NOT NULL THEN
RETURN jsonb_build_object('error', 'Order not found or access denied');
END IF;
SELECT jsonb_build_object(
'id', o.id,
'customer_name', o.customer_name,
'customer_email', o.customer_email,
'customer_phone', o.customer_phone,
'stop_id', o.stop_id,
'status', o.status,
'subtotal', o.subtotal,
'pickup_complete', o.pickup_complete,
'pickup_completed_at', o.pickup_completed_at,
'pickup_completed_by', o.pickup_completed_by,
'created_at', o.created_at,
'discount_amount', o.discount_amount,
'tax_amount', o.tax_amount,
'tax_rate', o.tax_rate,
'tax_location', o.tax_location,
'discount_reason', o.discount_reason,
'internal_notes', o.internal_notes,
'payment_processor', o.payment_processor,
'payment_status', o.payment_status,
'payment_transaction_id', o.payment_transaction_id,
'refunded_amount', o.refunded_amount,
'refund_reason', o.refund_reason,
'stops', CASE WHEN o.stop_id IS NOT NULL THEN jsonb_build_object(
'id', s.id, 'city', s.city, 'state', s.state,
'date', s.date, 'time', s.time, 'location', s.location, 'brand_id', s.brand_id
) END,
'order_items', COALESCE((
SELECT jsonb_agg(jsonb_build_object(
'id', oi.id, 'product_id', oi.product_id, 'product_name', p.name,
'quantity', oi.quantity, 'price', oi.price,
'products', jsonb_build_object('name', p.name)
))
FROM order_items oi
JOIN products p ON oi.product_id = p.id
WHERE oi.order_id = o.id
), '[]'::JSONB),
'refunds', COALESCE((
SELECT jsonb_agg(jsonb_build_object(
'id', r.id, 'order_id', r.order_id, 'amount', r.amount,
'reason', r.reason, 'processor', r.processor,
'processor_refund_id', r.processor_refund_id,
'status', r.status, 'created_at', r.created_at
))
FROM refunds r
WHERE r.order_id = o.id
), '[]'::JSONB)
)
INTO v_order
FROM orders o
LEFT JOIN stops s ON o.stop_id = s.id
WHERE o.id = p_order_id;
RETURN v_order;
END;
$$;
-- Step 3: RLS on orders table — add brand-scoped SELECT policies
ALTER TABLE public.orders ENABLE ROW LEVEL SECURITY;
-- Drop existing permissive policy if present
DROP POLICY IF EXISTS "orders_select_all" ON public.orders;
-- platform_admin and brand_admin SELECT policies (stops join for brand scoping)
CREATE POLICY "orders_select_platform_admin" ON public.orders
FOR SELECT USING (
auth.uid() IN (
SELECT au.user_id FROM admin_users au WHERE au.role = 'platform_admin'
)
);
CREATE POLICY "orders_select_brand_admin" ON public.orders
FOR SELECT USING (
auth.uid() IN (
SELECT au.user_id FROM admin_users au
WHERE au.role = 'brand_admin'
AND (SELECT brand_id FROM stops WHERE stops.id = orders.stop_id) = au.brand_id
)
);
CREATE POLICY "orders_select_store_employee" ON public.orders
FOR SELECT USING (
auth.uid() IN (
SELECT au.user_id FROM admin_users au
WHERE au.role = 'store_employee'
AND (SELECT brand_id FROM stops WHERE stops.id = orders.stop_id) = au.brand_id
)
);
-- Block all mutations at RLS level — all writes go through RPCs
DROP POLICY IF EXISTS "orders_insert_all" ON public.orders;
CREATE POLICY "orders_insert_blocked" ON public.orders FOR INSERT WITH CHECK (false);
DROP POLICY IF EXISTS "orders_update_all" ON public.orders;
CREATE POLICY "orders_update_blocked" ON public.orders FOR UPDATE USING (false);
DROP POLICY IF EXISTS "orders_delete_all" ON public.orders;
CREATE POLICY "orders_delete_blocked" ON public.orders FOR DELETE USING (false);
-- Step 4: RLS on order_items table
ALTER TABLE public.order_items ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "order_items_select_all" ON public.order_items;
CREATE POLICY "order_items_select_platform_admin" ON public.order_items
FOR SELECT USING (
auth.uid() IN (SELECT user_id FROM admin_users WHERE role = 'platform_admin')
OR
EXISTS (
SELECT 1 FROM orders o
JOIN stops s ON o.stop_id = s.id
JOIN admin_users au ON s.brand_id = au.brand_id
WHERE o.id = order_items.order_id AND au.user_id = auth.uid()
)
);
DROP POLICY IF EXISTS "order_items_insert_all" ON public.order_items;
CREATE POLICY "order_items_insert_blocked" ON public.order_items FOR INSERT WITH CHECK (false);
DROP POLICY IF EXISTS "order_items_update_all" ON public.order_items;
CREATE POLICY "order_items_update_blocked" ON public.order_items FOR UPDATE USING (false);
DROP POLICY IF EXISTS "order_items_delete_all" ON public.order_items;
CREATE POLICY "order_items_delete_blocked" ON public.order_items FOR DELETE USING (false);
@@ -0,0 +1,185 @@
-- Migration 073: Add order_items to get_admin_orders for driver pickup filtering
-- Adds fulfillment-aware order_items to the orders list RPC so the pickup
-- portal can filter to pickup-only items and detect mixed-fulfillment orders.
-- Step 1: Drop and recreate get_admin_orders with order_items subquery
DROP FUNCTION IF EXISTS public.get_admin_orders(UUID);
CREATE OR REPLACE FUNCTION public.get_admin_orders(p_brand_id UUID DEFAULT NULL)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_orders JSONB;
v_stops JSONB;
BEGIN
IF p_brand_id IS NULL THEN
-- platform_admin: return all orders with order_items
SELECT COALESCE(jsonb_agg(t ORDER BY t.created_at DESC), '[]'::JSONB)
INTO v_orders
FROM (
SELECT o.id, o.customer_name, o.customer_email, o.customer_phone,
o.stop_id, o.status, o.subtotal, o.pickup_complete,
o.pickup_completed_at, o.pickup_completed_by, o.created_at,
o.brand_id,
CASE WHEN o.stop_id IS NOT NULL THEN jsonb_build_object(
'id', s.id, 'city', s.city, 'state', s.state,
'date', s.date, 'time', s.time, 'location', s.location, 'brand_id', s.brand_id
) END as stops,
COALESCE((
SELECT jsonb_agg(jsonb_build_object(
'id', oi.id,
'product_id', oi.product_id,
'product_name', p.name,
'quantity', oi.quantity,
'price', oi.price,
'fulfillment', oi.fulfillment,
'products', jsonb_build_object('name', p.name)
) ORDER BY p.name)
FROM order_items oi
JOIN products p ON oi.product_id = p.id
WHERE oi.order_id = o.id
), '[]'::JSONB) AS order_items
FROM orders o LEFT JOIN stops s ON o.stop_id = s.id
WHERE o.stop_id IS NOT NULL
LIMIT 500
) t;
SELECT COALESCE(jsonb_agg(jsonb_build_object(
'id', id, 'city', city, 'state', state,
'date', date, 'time', time, 'location', location, 'brand_id', brand_id
) ORDER BY date), '[]'::JSONB)
INTO v_stops FROM stops WHERE active = true;
ELSE
-- brand-scoped: restrict to brand via stops join, include order_items
SELECT COALESCE(jsonb_agg(t ORDER BY t.created_at DESC), '[]'::JSONB)
INTO v_orders
FROM (
SELECT o.id, o.customer_name, o.customer_email, o.customer_phone,
o.stop_id, o.status, o.subtotal, o.pickup_complete,
o.pickup_completed_at, o.pickup_completed_by, o.created_at,
o.brand_id,
jsonb_build_object(
'id', s.id, 'city', s.city, 'state', s.state,
'date', s.date, 'time', s.time, 'location', s.location, 'brand_id', s.brand_id
) as stops,
COALESCE((
SELECT jsonb_agg(jsonb_build_object(
'id', oi.id,
'product_id', oi.product_id,
'product_name', p.name,
'quantity', oi.quantity,
'price', oi.price,
'fulfillment', oi.fulfillment,
'products', jsonb_build_object('name', p.name)
) ORDER BY p.name)
FROM order_items oi
JOIN products p ON oi.product_id = p.id
WHERE oi.order_id = o.id
), '[]'::JSONB) AS order_items
FROM orders o JOIN stops s ON o.stop_id = s.id
WHERE s.brand_id = p_brand_id
LIMIT 500
) t;
SELECT COALESCE(jsonb_agg(jsonb_build_object(
'id', id, 'city', city, 'state', state,
'date', date, 'time', time, 'location', location, 'brand_id', brand_id
) ORDER BY date), '[]'::JSONB)
INTO v_stops FROM stops WHERE active = true AND brand_id = p_brand_id;
END IF;
RETURN jsonb_build_object('orders', v_orders, 'stops', v_stops);
END;
$$;
-- Step 2: Also update get_admin_order_detail to use consistent order_items shape
-- (it already has fulfillment, but ensure the shape matches what DriverPickupPanel expects)
DROP FUNCTION IF EXISTS public.get_admin_order_detail(UUID, UUID);
CREATE OR REPLACE FUNCTION public.get_admin_order_detail(p_order_id UUID, p_brand_id UUID DEFAULT NULL)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_order JSONB;
v_order_brand_id UUID;
v_stop_brand_id UUID;
BEGIN
SELECT COALESCE(o.brand_id, s.brand_id), s.brand_id
INTO v_order_brand_id, v_stop_brand_id
FROM orders o LEFT JOIN stops s ON o.stop_id = s.id
WHERE o.id = p_order_id;
IF p_brand_id IS NOT NULL AND v_order_brand_id IS NOT NULL AND v_order_brand_id != p_brand_id THEN
RETURN jsonb_build_object('error', 'Order not found or access denied');
END IF;
IF v_order_brand_id IS NULL AND p_brand_id IS NOT NULL THEN
RETURN jsonb_build_object('error', 'Order not found or access denied');
END IF;
SELECT jsonb_build_object(
'id', o.id,
'customer_name', o.customer_name,
'customer_email', o.customer_email,
'customer_phone', o.customer_phone,
'stop_id', o.stop_id,
'status', o.status,
'subtotal', o.subtotal,
'pickup_complete', o.pickup_complete,
'pickup_completed_at', o.pickup_completed_at,
'pickup_completed_by', o.pickup_completed_by,
'created_at', o.created_at,
'discount_amount', o.discount_amount,
'tax_amount', o.tax_amount,
'tax_rate', o.tax_rate,
'tax_location', o.tax_location,
'discount_reason', o.discount_reason,
'internal_notes', o.internal_notes,
'payment_processor', o.payment_processor,
'payment_status', o.payment_status,
'payment_transaction_id', o.payment_transaction_id,
'refunded_amount', o.refunded_amount,
'refund_reason', o.refund_reason,
'stops', CASE WHEN o.stop_id IS NOT NULL THEN jsonb_build_object(
'id', s.id, 'city', s.city, 'state', s.state,
'date', s.date, 'time', s.time, 'location', s.location, 'brand_id', s.brand_id
) END,
'order_items', COALESCE((
SELECT jsonb_agg(jsonb_build_object(
'id', oi.id,
'product_id', oi.product_id,
'product_name', p.name,
'quantity', oi.quantity,
'price', oi.price,
'fulfillment', oi.fulfillment,
'products', jsonb_build_object('name', p.name)
) ORDER BY p.name)
FROM order_items oi
JOIN products p ON oi.product_id = p.id
WHERE oi.order_id = o.id
), '[]'::JSONB),
'refunds', COALESCE((
SELECT jsonb_agg(jsonb_build_object(
'id', r.id, 'order_id', r.order_id, 'amount', r.amount,
'reason', r.reason, 'processor', r.processor,
'processor_refund_id', r.processor_refund_id,
'status', r.status, 'created_at', r.created_at
))
FROM refunds r
WHERE r.order_id = o.id
), '[]'::JSONB)
)
INTO v_order
FROM orders o
LEFT JOIN stops s ON o.stop_id = s.id
WHERE o.id = p_order_id;
RETURN v_order;
END;
$$;
@@ -0,0 +1,77 @@
-- Migration 074: Products Soft Delete
-- Adds deleted_at column, delete_product RPC with order_items guard.
-- Mirrors wholesale_products pattern (migration 061).
-- Step 1: Add deleted_at column
ALTER TABLE public.products
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ DEFAULT NULL;
-- Step 2: Create delete_product RPC
CREATE OR REPLACE FUNCTION public.delete_product(p_product_id UUID, p_brand_id UUID DEFAULT NULL)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_product RECORD;
v_usage_count INT;
v_brand_id UUID;
BEGIN
-- Fetch product and lock it
SELECT id, brand_id INTO v_product
FROM products
WHERE id = p_product_id
FOR UPDATE;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Product not found');
END IF;
v_brand_id := v_product.brand_id;
-- Brand scoping: if p_brand_id provided, enforce it
IF p_brand_id IS NOT NULL AND v_brand_id != p_brand_id THEN
RETURN jsonb_build_object('success', false, 'error', 'Product not found');
END IF;
-- Check for usage in order_items
SELECT COUNT(*) INTO v_usage_count
FROM order_items
WHERE product_id = p_product_id;
IF v_usage_count > 0 THEN
RETURN jsonb_build_object(
'success', false,
'error', 'Cannot delete — product is attached to ' || v_usage_count || ' order(s). Set availability to inactive instead.'
);
END IF;
-- Soft delete
UPDATE products SET deleted_at = now() WHERE id = p_product_id;
RETURN jsonb_build_object('success', true);
END;
$$;
-- Step 3: Add RLS policy for deleted_at filtering on SELECT
-- The existing SELECT policies in 070 will now also need deleted_at filtering
-- for brand_admin and store_employee. We add a new policy that enforces deleted_at IS NULL.
-- First drop the existing brand-scoped SELECT policies (they don't filter deleted_at yet)
DROP POLICY IF EXISTS "brand_admin_read_products" ON public.products;
DROP POLICY IF EXISTS "store_employee_read_products" ON public.products;
-- Brand admin: own brand + not deleted
CREATE POLICY "brand_admin_read_products" ON public.products
FOR SELECT USING (
brand_id = (SELECT brand_id FROM admin_users WHERE user_id = auth.uid() AND role = 'brand_admin' LIMIT 1)
AND deleted_at IS NULL
);
-- Store employee: own brand + not deleted
CREATE POLICY "store_employee_read_products" ON public.products
FOR SELECT USING (
brand_id = (SELECT brand_id FROM admin_users WHERE user_id = auth.uid() AND role = 'store_employee' LIMIT 1)
AND deleted_at IS NULL
);
@@ -0,0 +1,82 @@
-- Migration 075: Soft-delete for stops + brand scoping enforcement
-- 1. Add deleted_at to stops
-- 2. Create delete_stop RPC with order guard
-- 3. Update RLS to filter deleted_at IS NULL
-- ── 1. Add deleted_at column ───────────────────────────────────────────────────
ALTER TABLE stops ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ DEFAULT NULL;
-- ── 2. delete_stop RPC ────────────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION public.delete_stop(p_stop_id UUID, p_brand_id UUID DEFAULT NULL)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_stop RECORD;
v_active_order_count INT;
v_result JSONB;
BEGIN
-- Lock the stop row for update
SELECT * INTO v_stop FROM stops WHERE id = p_stop_id FOR UPDATE;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Stop not found');
END IF;
-- Brand scoping: brand_admin can only delete their own stops
IF p_brand_id IS NOT NULL AND v_stop.brand_id != p_brand_id THEN
RETURN jsonb_build_object('success', false, 'error', 'Stop not found');
ELSIF p_brand_id IS NULL AND v_stop.brand_id IS NOT NULL THEN
-- Caller didn't pass brand_id but stop has one — guard against platform admin accident
RETURN jsonb_build_object('success', false, 'error', 'Stop not found');
END IF;
-- Guard: check for active or future orders at this stop
SELECT COUNT(*) INTO v_active_order_count
FROM orders o
JOIN stops s ON o.stop_id = s.id
WHERE s.id = p_stop_id
AND o.pickup_complete = false
AND o.deleted_at IS NULL;
IF v_active_order_count > 0 THEN
RETURN jsonb_build_object(
'success', false,
'error', 'Cannot delete stop — it has ' || v_active_order_count || ' active order(s). Mark orders as picked up first.'
);
END IF;
-- Soft delete
UPDATE stops SET deleted_at = NOW() WHERE id = p_stop_id;
RETURN jsonb_build_object('success', true);
END;
$$;
-- ── 3. Update RLS — add deleted_at IS NULL to brand_admin read policy ──────────
-- drops are re-created in the same block to avoid breaking the audit SQL
DROP POLICY IF EXISTS "brand_admin_read_stops" ON stops;
DROP POLICY IF EXISTS "platform_admin_read_stops" ON stops;
-- Platform admin sees all non-deleted stops
CREATE POLICY "platform_admin_read_stops" ON stops
FOR SELECT USING (
deleted_at IS NULL
AND EXISTS (
SELECT 1 FROM admin_users au
WHERE au.user_id = auth.uid()
AND au.role = 'platform_admin'
)
);
-- Brand admin / store_employee sees only their brand's non-deleted stops
CREATE POLICY "brand_admin_read_stops" ON stops
FOR SELECT USING (
deleted_at IS NULL
AND EXISTS (
SELECT 1 FROM admin_users au
WHERE au.user_id = auth.uid()
AND au.role IN ('brand_admin', 'store_employee')
AND au.brand_id = stops.brand_id
)
);
@@ -0,0 +1,6 @@
-- Migration 076: Add address, zip, cutoff_time to stops
-- Supports driver navigation (street address) and customer cutoff deadline
ALTER TABLE stops ADD COLUMN IF NOT EXISTS address TEXT DEFAULT NULL;
ALTER TABLE stops ADD COLUMN IF NOT EXISTS zip TEXT DEFAULT NULL;
ALTER TABLE stops ADD COLUMN IF NOT EXISTS cutoff_time TIMESTAMPTZ DEFAULT NULL;
@@ -0,0 +1,9 @@
-- Migration 077: Add status column to stops
-- 'draft' = imported, awaiting review | 'active' = published
ALTER TABLE stops ADD COLUMN IF NOT EXISTS status TEXT DEFAULT 'active' NOT NULL;
-- Set existing stops to 'active'
UPDATE stops SET status = 'active' WHERE status IS NULL;
ALTER TABLE stops ALTER COLUMN status SET NOT NULL;
@@ -0,0 +1,63 @@
-- Migration 078: Over-deposit guard on wholesale deposits
-- Prevents recording a deposit that exceeds the remaining balance_due
CREATE OR REPLACE FUNCTION public.record_wholesale_deposit(
p_order_id UUID DEFAULT NULL,
p_amount NUMERIC DEFAULT NULL,
p_method TEXT DEFAULT 'cash',
p_reference TEXT DEFAULT NULL,
p_recorded_by UUID DEFAULT NULL
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_new_paid NUMERIC(10,2);
v_balance_due NUMERIC(10,2);
BEGIN
-- Guard: amount must be positive
IF p_amount IS NULL OR p_amount <= 0 THEN
RETURN jsonb_build_object('success', false, 'error', 'Deposit amount must be greater than zero.');
END IF;
-- Guard: cannot exceed remaining balance_due
SELECT balance_due INTO v_balance_due
FROM wholesale_orders
WHERE id = p_order_id;
IF v_balance_due IS NULL THEN
RETURN jsonb_build_object('success', false, 'error', 'Order not found.');
END IF;
IF p_amount > v_balance_due THEN
RETURN jsonb_build_object(
'success', false,
'error', 'Deposit of $' || p_amount::TEXT || ' exceeds the remaining balance of $' || v_balance_due::TEXT || '.'
);
END IF;
-- Compute new deposit_paid
SELECT deposit_paid + p_amount INTO v_new_paid
FROM wholesale_orders
WHERE id = p_order_id;
UPDATE wholesale_orders
SET
deposit_paid = v_new_paid,
balance_due = subtotal - v_new_paid,
updated_at = now()
WHERE id = p_order_id;
INSERT INTO wholesale_deposits (wholesale_order_id, amount, payment_method, reference, recorded_by)
VALUES (p_order_id, p_amount, p_method, p_reference, p_recorded_by);
-- Advance to pending once deposit covers requirement
UPDATE wholesale_orders
SET status = 'pending'
WHERE id = p_order_id
AND v_new_paid >= deposit_required
AND status = 'awaiting_deposit';
RETURN jsonb_build_object('success', true);
END;
$$;
@@ -0,0 +1,22 @@
-- Migration 079: Add delivery/open/click analytics columns to communication_message_logs
-- Enables Resend webhook handler to update email engagement metrics
ALTER TABLE communication_message_logs
ADD COLUMN IF NOT EXISTS delivered_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS opened_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS clicked_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS bounced_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS bounce_reason TEXT;
-- Index for efficient delivery status queries
CREATE INDEX IF NOT EXISTS idx_message_logs_delivered_at
ON communication_message_logs (delivered_at)
WHERE delivered_at IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_message_logs_opened_at
ON communication_message_logs (opened_at)
WHERE opened_at IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_message_logs_clicked_at
ON communication_message_logs (clicked_at)
WHERE clicked_at IS NOT NULL;
@@ -0,0 +1,100 @@
-- Migration 080: communication_segments table for reusable audience segments
-- Replaces ad-hoc JSONB audience_rules with named, saved segments
CREATE TABLE communication_segments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
brand_id UUID REFERENCES brands NOT NULL,
name TEXT NOT NULL,
description TEXT,
rules JSONB NOT NULL DEFAULT '{}',
-- rules shape mirrors AudienceRules:
-- { target, stop_id, date_from, date_to, zip_codes, city,
-- order_history, days_back, product_id, customer_ids }
created_by UUID,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- Brand scoping is enforced at the application layer (server actions).
-- RLS is enabled with an authenticated-only policy. SECURITY DEFINER RPCs
-- continue to bypass RLS because they run as the function owner.
ALTER TABLE communication_segments ENABLE ROW LEVEL SECURITY;
CREATE POLICY "authenticated_only_communication_segments" ON communication_segments
FOR ALL USING (auth.uid() IS NOT NULL) WITH CHECK (auth.uid() IS NOT NULL);
-- RPCs for segments CRUD
CREATE OR REPLACE FUNCTION get_communication_segments(p_brand_id UUID)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
RETURN jsonb_build_object(
'segments', (
SELECT COALESCE(jsonb_agg(s.*), '[]'::jsonb)
FROM (
SELECT id, brand_id, name, description, rules,
created_by, created_at, updated_at
FROM communication_segments
WHERE brand_id = p_brand_id
ORDER BY name
) s
)
);
END;
$$;
CREATE OR REPLACE FUNCTION upsert_communication_segment(
p_brand_id UUID,
p_name TEXT,
p_description TEXT DEFAULT NULL,
p_rules JSONB DEFAULT '{}',
p_created_by UUID DEFAULT NULL,
p_id UUID DEFAULT NULL
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_result JSONB;
BEGIN
INSERT INTO communication_segments (id, brand_id, name, description, rules, created_by, updated_at)
VALUES (
COALESCE(p_id, gen_random_uuid()),
p_brand_id,
p_name,
p_description,
p_rules,
p_created_by,
now()
)
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
description = EXCLUDED.description,
rules = EXCLUDED.rules,
updated_at = now()
RETURNING to_jsonb(communication_segments.*) INTO v_result;
RETURN v_result;
END;
$$;
CREATE OR REPLACE FUNCTION delete_communication_segment(p_segment_id UUID, p_brand_id UUID)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
DELETE FROM communication_segments
WHERE id = p_segment_id AND brand_id = p_brand_id;
RETURN jsonb_build_object('success', true);
END;
$$;
-- ============================================================
-- RLS for communication_segments (added by scripts/fix-archived-rls.js)
-- Columns: id, brand_id, name, description, rules, stop_id, date_from, date_to, zip_codes, city, days_back, product_id, customer_ids, created_at, updated_at
-- ============================================================
ALTER TABLE communication_segments ENABLE ROW LEVEL SECURITY;
ALTER TABLE communication_segments FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON communication_segments;
CREATE POLICY tenant_isolation ON communication_segments FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
@@ -0,0 +1,135 @@
-- Migration 081: Stop blast via communication_campaigns
-- Routes stop-based blast messages through the campaign system for analytics
-- and to respect contact opt-in/opt-out preferences.
CREATE OR REPLACE FUNCTION send_stop_blast(
p_stop_id UUID,
p_brand_id UUID,
p_body TEXT,
p_channel TEXT DEFAULT 'email', -- 'sms', 'email', 'both'
p_subject TEXT DEFAULT NULL,
p_audience TEXT DEFAULT 'pending', -- 'all', 'pending', 'picked_up'
p_created_by UUID DEFAULT NULL
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_campaign_id UUID;
v_count INT := 0;
v_order RECORD;
v_contact JSONB;
v_customer_id UUID;
v_opted_in BOOLEAN;
v_email TEXT;
v_phone TEXT;
v_brand_name TEXT := 'Route Commerce';
v_subj TEXT;
v_body_sub TEXT;
BEGIN
-- Resolve brand name for {{company_name}} substitution
SELECT b.name INTO v_brand_name
FROM brands b
WHERE b.id = p_brand_id;
IF v_brand_name IS NULL THEN
SELECT invoice_business_name INTO v_brand_name
FROM wholesale_settings
WHERE brand_id = p_brand_id
LIMIT 1;
END IF;
IF v_brand_name IS NULL THEN
v_brand_name := 'Route Commerce';
END IF;
v_subj := replace(replace(p_subject, '{{company_name}}', v_brand_name), '{{brand_name}}', v_brand_name);
v_body_sub := replace(replace(p_body, '{{company_name}}', v_brand_name), '{{brand_name}}', v_brand_name);
-- Create campaign record
INSERT INTO communication_campaigns (
brand_id, name, subject, body_text,
campaign_type, status, audience_rules,
sent_at, created_by
) VALUES (
p_brand_id,
'Stop blast: ' || COALESCE(p_subject, p_body),
v_subj,
v_body_sub,
'operational',
'sent',
jsonb_build_object(
'target', 'stop',
'stop_id', p_stop_id,
'date_from', NULL,
'date_to', NULL
),
now(),
p_created_by
) RETURNING id INTO v_campaign_id;
-- Fetch matching orders
FOR v_order IN
SELECT o.id, o.customer_id, o.customer_name,
o.customer_email, o.customer_phone, o.pickup_complete,
c.email_opt_in, c.sms_opt_in, c.unsubscribed_at
FROM orders o
LEFT JOIN communication_contacts c ON c.id = o.customer_id
WHERE o.stop_id = p_stop_id
AND (
p_audience = 'all'
OR (p_audience = 'pending' AND NOT o.pickup_complete)
OR (p_audience = 'picked_up' AND o.pickup_complete)
)
LOOP
-- Check opt-in status per channel
-- Default to opted-in for email (email_opt_in defaults TRUE in schema)
-- Default to opted-OUT for SMS (sms_opt_in defaults FALSE in schema)
v_opted_in := (
CASE p_channel
WHEN 'sms' THEN v_order.customer_phone IS NOT NULL
AND COALESCE(v_order.sms_opt_in, FALSE) = TRUE
AND v_order.unsubscribed_at IS NULL
WHEN 'email' THEN v_order.customer_email IS NOT NULL
AND COALESCE(v_order.email_opt_in, TRUE) = TRUE
AND v_order.unsubscribed_at IS NULL
ELSE v_order.customer_email IS NOT NULL
AND COALESCE(v_order.email_opt_in, TRUE) = TRUE
AND v_order.unsubscribed_at IS NULL
END
);
IF NOT v_opted_in THEN
CONTINUE;
END IF;
-- Log email message
IF p_channel IN ('email', 'both') AND v_order.customer_email IS NOT NULL THEN
INSERT INTO communication_message_logs (
brand_id, campaign_id, customer_id, customer_email,
delivery_method, subject, body_preview, status, sent_at
) VALUES (
p_brand_id, v_campaign_id, v_order.customer_id, v_order.customer_email,
'email', v_subj, LEFT(v_body_sub, 200), 'queued', now()
);
v_count := v_count + 1;
END IF;
-- Log SMS message
IF p_channel IN ('sms', 'both') AND v_order.customer_phone IS NOT NULL THEN
INSERT INTO communication_message_logs (
brand_id, campaign_id, customer_id, customer_phone,
delivery_method, body_preview, status, sent_at
) VALUES (
p_brand_id, v_campaign_id, v_order.customer_id, v_order.customer_phone,
'sms', LEFT(v_body_sub, 160), 'queued', now()
);
v_count := v_count + 1;
END IF;
END LOOP;
RETURN jsonb_build_object(
'success', true,
'campaign_id', v_campaign_id,
'messages_logged', v_count
);
END;
$$;
@@ -0,0 +1,74 @@
-- Migration 082: Substitute {{company_name}} with brand name in campaign emails
-- Updates send_campaign to look up brands.name and replace {{company_name}}
-- in subject and body_text before queuing messages.
CREATE OR REPLACE FUNCTION public.send_campaign(p_campaign_id UUID)
RETURNS jsonb
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_campaign communication_campaigns;
v_settings communication_settings;
v_brand RECORD;
v_audience JSONB;
v_entries JSONB := '[]'::JSONB;
v_customer JSONB;
v_count INTEGER := 0;
v_brand_name TEXT := 'Route Commerce';
BEGIN
SELECT * INTO v_campaign FROM communication_campaigns WHERE id = p_campaign_id;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Campaign not found');
END IF;
-- Look up brand name for {{company_name}} substitution
SELECT b.name INTO v_brand_name
FROM brands b
WHERE b.id = v_campaign.brand_id;
-- Fall back to wholesale_settings if brand name not found
IF v_brand_name IS NULL THEN
SELECT invoice_business_name INTO v_brand_name
FROM wholesale_settings
WHERE brand_id = v_campaign.brand_id
LIMIT 1;
END IF;
IF v_brand_name IS NULL THEN
v_brand_name := 'Route Commerce';
END IF;
SELECT * INTO v_settings
FROM communication_settings
WHERE brand_id = v_campaign.brand_id;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'No communication settings for brand');
END IF;
v_audience := preview_campaign_audience(v_campaign.brand_id, v_campaign.audience_rules);
FOR v_customer IN
SELECT * FROM jsonb_array_elements(coalesce(v_audience->'sample_customers', '[]'::jsonb))
LOOP
v_entries := v_entries || jsonb_build_array(jsonb_build_object(
'brand_id', v_campaign.brand_id,
'campaign_id', p_campaign_id,
'customer_id', v_customer->>'id',
'customer_email', v_customer->>'email',
'delivery_method', 'email',
'subject',
-- Substitute {{company_name}} in subject
replace(replace(v_campaign.subject, '{{company_name}}', v_brand_name), '{{brand_name}}', v_brand_name),
'body_preview', left(replace(replace(v_campaign.body_text, '{{company_name}}', v_brand_name), '{{brand_name}}', v_brand_name), 500),
'status', 'queued'
));
v_count := v_count + 1;
END LOOP;
PERFORM log_communication_messages(v_entries);
UPDATE communication_campaigns
SET status = 'sent', sent_at = now(), updated_at = now()
WHERE id = p_campaign_id;
RETURN jsonb_build_object('success', true, 'messages_logged', v_count);
END;
$$;
@@ -0,0 +1,99 @@
-- Migration 083: Shipping Settings + FedEx Credentials + Perishable Flag on Products
-- Stores FedEx API credentials and perishable handling preferences per brand.
CREATE TABLE IF NOT EXISTS public.shipping_settings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
brand_id UUID REFERENCES brands ON DELETE CASCADE,
carrier TEXT NOT NULL DEFAULT 'fedex',
-- FedEx credentials
fedex_account_number TEXT,
fedex_api_key TEXT,
fedex_api_secret TEXT,
fedex_use_production BOOLEAN NOT NULL DEFAULT FALSE,
-- Default service type for non-perishable orders
default_service_type TEXT NOT NULL DEFAULT 'FEDEX_GROUND',
-- Handling instructions for perishable goods (sweet corn, onions, etc.)
refrigerated_handling_notes TEXT,
fragile_handling_notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- One settings row per brand
CREATE UNIQUE INDEX IF NOT EXISTS shipping_settings_brand_idx
ON public.shipping_settings (brand_id)
WHERE brand_id IS NOT NULL;
ALTER TABLE public.shipping_settings ENABLE ROW LEVEL SECURITY;
-- Brand admins manage their own settings; platform admins manage all
CREATE POLICY brand_admin_manage_shipping_settings ON public.shipping_settings
FOR ALL
USING (
brand_id IS NOT NULL
AND auth.uid() IN (
SELECT user_id FROM admin_users
WHERE brand_id = shipping_settings.brand_id
AND role IN ('brand_admin', 'platform_admin')
)
);
-- Platform admin global settings row (brand_id = NULL)
CREATE POLICY platform_admin_manage_global_shipping_settings ON public.shipping_settings
FOR ALL
USING (
brand_id IS NULL
AND auth.uid() IN (
SELECT user_id FROM admin_users
WHERE role = 'platform_admin'
)
);
-- ── Add is_perishable to products ─────────────────────────────────────────
-- Flags a product as perishable (sweet corn, onions, etc.)
-- Perishable orders can ONLY ship via OVERNIGHT or 2DAY air.
ALTER TABLE public.products
ADD COLUMN IF NOT EXISTS is_perishable BOOLEAN NOT NULL DEFAULT FALSE;
COMMENT ON COLUMN products.is_perishable IS
'If true, this product can only ship via FEDEX_OVERNIGHT or FEDEX_2_DAY_AIR. Affects FedEx rate filtering.';
-- ── RPC: get_order_items_perishable ────────────────────────────────────────
-- Returns true if ALL ship-able items in an order are perishable.
-- Used by FedEx rate filtering logic.
CREATE OR REPLACE FUNCTION public.get_order_items_perishable(p_order_id UUID)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_result JSONB;
v_has_shipping_items BOOLEAN;
v_all_perishable BOOLEAN;
BEGIN
SELECT EXISTS (
SELECT 1 FROM order_items oi
JOIN products p ON p.id = oi.product_id
WHERE oi.order_id = p_order_id AND oi.fulfillment = 'ship'
) INTO v_has_shipping_items;
IF NOT v_has_shipping_items THEN
RETURN jsonb_build_object('is_perishable', false);
END IF;
SELECT NOT EXISTS (
SELECT 1 FROM order_items oi
JOIN products p ON p.id = oi.product_id
WHERE oi.order_id = p_order_id
AND oi.fulfillment = 'ship'
AND COALESCE(p.is_perishable, false) = FALSE
) INTO v_all_perishable;
RETURN jsonb_build_object('is_perishable', v_all_perishable);
END;
$$;
@@ -0,0 +1,140 @@
-- Migration 083: Add stripe_user_id support to payment_settings for Stripe Connect
-- This enables Stripe Connect Express account onboarding
-- First, add stripe_user_id column to payment_settings table if it doesn't exist
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'payment_settings' AND column_name = 'stripe_user_id') THEN
ALTER TABLE payment_settings ADD COLUMN stripe_user_id TEXT;
END IF;
END $$;
-- Update upsert_payment_settings to support stripe_user_id
CREATE OR REPLACE FUNCTION public.upsert_payment_settings(
p_brand_id UUID,
p_provider TEXT,
p_stripe_publishable_key TEXT DEFAULT NULL,
p_stripe_secret_key TEXT DEFAULT NULL,
p_stripe_user_id TEXT DEFAULT NULL,
p_square_access_token TEXT DEFAULT NULL,
p_square_location_id TEXT DEFAULT NULL,
p_square_sync_enabled BOOLEAN DEFAULT NULL,
p_square_inventory_mode TEXT DEFAULT NULL
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
INSERT INTO payment_settings (
brand_id, provider,
stripe_publishable_key, stripe_secret_key, stripe_user_id,
square_access_token, square_location_id,
square_sync_enabled, square_inventory_mode
)
VALUES (
p_brand_id, p_provider,
p_stripe_publishable_key, p_stripe_secret_key, p_stripe_user_id,
p_square_access_token, p_square_location_id,
COALESCE(p_square_sync_enabled, false),
COALESCE(p_square_inventory_mode, 'none')
)
ON CONFLICT (brand_id) DO UPDATE SET
provider = COALESCE(p_provider, payment_settings.provider),
stripe_publishable_key = COALESCE(p_stripe_publishable_key, payment_settings.stripe_publishable_key),
stripe_secret_key = COALESCE(p_stripe_secret_key, payment_settings.stripe_secret_key),
stripe_user_id = COALESCE(p_stripe_user_id, payment_settings.stripe_user_id),
square_access_token = COALESCE(p_square_access_token, payment_settings.square_access_token),
square_location_id = COALESCE(p_square_location_id, payment_settings.square_location_id),
square_sync_enabled = COALESCE(p_square_sync_enabled, payment_settings.square_sync_enabled),
square_inventory_mode = COALESCE(p_square_inventory_mode, payment_settings.square_inventory_mode),
updated_at = now()
RETURNING jsonb_build_object(
'id', id,
'brand_id', brand_id,
'provider', provider,
'stripe_user_id', stripe_user_id,
'stripe_publishable_key', stripe_publishable_key,
'square_sync_enabled', square_sync_enabled,
'square_inventory_mode', square_inventory_mode,
'square_last_sync_at', square_last_sync_at,
'square_last_sync_error', square_last_sync_error
);
END;
$$;
-- Create function to get brand payment settings
CREATE OR REPLACE FUNCTION public.get_payment_settings(
p_brand_id UUID
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
RETURN (
SELECT jsonb_build_object(
'id', id,
'brand_id', brand_id,
'provider', provider,
'stripe_publishable_key', stripe_publishable_key,
'stripe_secret_key', stripe_secret_key,
'stripe_user_id', stripe_user_id,
'square_access_token', square_access_token,
'square_location_id', square_location_id,
'square_sync_enabled', square_sync_enabled,
'square_inventory_mode', square_inventory_mode,
'square_last_sync_at', square_last_sync_at,
'square_last_sync_error', square_last_sync_error,
'updated_at', updated_at
)
FROM payment_settings
WHERE brand_id = p_brand_id
);
END;
$$;
-- Create function to set Stripe Connect account
CREATE OR REPLACE FUNCTION public.set_stripe_connect_account(
p_brand_id UUID,
p_stripe_user_id TEXT
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
INSERT INTO payment_settings (brand_id, provider, stripe_user_id)
VALUES (p_brand_id, 'stripe', p_stripe_user_id)
ON CONFLICT (brand_id) DO UPDATE SET
stripe_user_id = p_stripe_user_id,
provider = 'stripe',
updated_at = now()
RETURNING jsonb_build_object(
'id', id,
'brand_id', brand_id,
'stripe_user_id', stripe_user_id,
'provider', provider
);
END;
$$;
-- Create function to disconnect Stripe Connect
CREATE OR REPLACE FUNCTION public.disconnect_stripe_connect(
p_brand_id UUID
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
UPDATE payment_settings
SET
stripe_user_id = NULL,
stripe_publishable_key = NULL,
stripe_secret_key = NULL,
provider = 'manual',
updated_at = now()
WHERE brand_id = p_brand_id;
RETURN jsonb_build_object('success', true, 'brand_id', p_brand_id);
END;
$$;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,73 @@
-- Migration 084: FedEx Shipments Table
-- Stores created FedEx shipment records with label URL, tracking, and special services.
CREATE TABLE IF NOT EXISTS public.shipments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
order_id UUID NOT NULL REFERENCES orders ON DELETE CASCADE,
-- Carrier details
carrier TEXT NOT NULL DEFAULT 'fedex',
service_type TEXT NOT NULL, -- FEDEX_OVERNIGHT, FEDEX_2_DAY_AIR, FEDEX_GROUND, etc.
tracking_number TEXT,
label_url TEXT, -- FedEx label PDF URL
rate_charged NUMERIC(10, 2), -- Actual rate charged by FedEx (cents)
-- Delivery estimates
estimated_delivery_date DATE,
-- Special handling applied
is_refrigerated BOOLEAN NOT NULL DEFAULT FALSE,
is_fragile BOOLEAN NOT NULL DEFAULT FALSE,
handling_notes TEXT, -- Custom per-shipment notes
-- Status
status TEXT NOT NULL DEFAULT 'created', -- created | label_voided | cancelled
fedex_shipment_id TEXT, -- FedEx internal shipment ID (for voiding)
-- Audit
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by UUID REFERENCES admin_users(user_id)
);
ALTER TABLE public.shipments ENABLE ROW LEVEL SECURITY;
-- Shipments visible to brand admins and platform admins for the order's brand
CREATE POLICY brand_admin_view_shipments ON public.shipments
FOR SELECT
USING (
EXISTS (
SELECT 1 FROM orders o
WHERE o.id = shipments.order_id
AND o.brand_id = (
SELECT brand_id FROM admin_users WHERE user_id = auth.uid() LIMIT 1
)
)
OR EXISTS (
SELECT 1 FROM admin_users au
WHERE au.user_id = auth.uid()
AND au.role = 'platform_admin'
)
);
CREATE POLICY brand_admin_manage_shipments ON public.shipments
FOR ALL
USING (
EXISTS (
SELECT 1 FROM orders o
WHERE o.id = shipments.order_id
AND o.brand_id = (
SELECT brand_id FROM admin_users WHERE user_id = auth.uid() LIMIT 1
)
AND EXISTS (
SELECT 1 FROM admin_users au
WHERE au.user_id = auth.uid()
AND au.brand_id = o.brand_id
AND au.role IN ('brand_admin', 'platform_admin')
)
)
);
CREATE INDEX IF NOT EXISTS shipments_order_idx ON public.shipments (order_id);
CREATE INDEX IF NOT EXISTS shipments_tracking_idx ON public.shipments (tracking_number);
CREATE INDEX IF NOT EXISTS shipments_status_idx ON public.shipments (status);
@@ -0,0 +1,154 @@
-- Migration 085: Brand Settings Table
-- Single source of truth for brand/company information.
-- Used by email templates, invoices, site headers, and all brand-facing output.
CREATE TABLE IF NOT EXISTS public.brand_settings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
-- Company info
legal_business_name TEXT,
phone TEXT,
email TEXT,
website_url TEXT,
-- Address
street_address TEXT,
city TEXT,
state TEXT,
postal_code TEXT,
country TEXT DEFAULT 'US',
-- Logos (Supabase Storage public URLs)
logo_url TEXT,
logo_url_dark TEXT, -- For dark backgrounds (optional)
-- Branding for emails/invoices
default_email_signature TEXT,
invoice_footer_notes TEXT,
-- Meta
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS brand_settings_brand_idx
ON public.brand_settings (brand_id);
ALTER TABLE public.brand_settings ENABLE ROW LEVEL SECURITY;
-- Brand admins and platform admins can manage their own settings
CREATE POLICY brand_admin_manage_brand_settings ON public.brand_settings
FOR ALL
USING (
EXISTS (
SELECT 1 FROM admin_users au
WHERE au.user_id = auth.uid()
AND au.brand_id = brand_settings.brand_id
AND au.role IN ('brand_admin', 'platform_admin')
)
);
-- Read policy: brand admins + platform admins can read
CREATE POLICY brand_admin_read_brand_settings ON public.brand_settings
FOR SELECT
USING (
EXISTS (
SELECT 1 FROM admin_users au
WHERE au.user_id = auth.uid()
AND au.brand_id = brand_settings.brand_id
)
);
-- ── RPCs ─────────────────────────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION public.get_brand_settings(p_brand_id UUID)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_result JSONB;
BEGIN
SELECT jsonb_build_object(
'id', bs.id,
'brand_id', bs.brand_id,
'legal_business_name', bs.legal_business_name,
'phone', bs.phone,
'email', bs.email,
'website_url', bs.website_url,
'street_address', bs.street_address,
'city', bs.city,
'state', bs.state,
'postal_code', bs.postal_code,
'country', bs.country,
'logo_url', bs.logo_url,
'logo_url_dark', bs.logo_url_dark,
'default_email_signature', bs.default_email_signature,
'invoice_footer_notes', bs.invoice_footer_notes,
'updated_at', bs.updated_at,
'brand_name', b.name
)
INTO v_result
FROM brand_settings bs
JOIN brands b ON b.id = bs.brand_id
WHERE bs.brand_id = p_brand_id;
RETURN COALESCE(v_result, jsonb_build_object('brand_id', p_brand_id, 'brand_name', (
SELECT name FROM brands WHERE id = p_brand_id
)));
END;
$$;
CREATE OR REPLACE FUNCTION public.upsert_brand_settings(
p_brand_id UUID,
p_legal_business_name TEXT DEFAULT NULL,
p_phone TEXT DEFAULT NULL,
p_email TEXT DEFAULT NULL,
p_website_url TEXT DEFAULT NULL,
p_street_address TEXT DEFAULT NULL,
p_city TEXT DEFAULT NULL,
p_state TEXT DEFAULT NULL,
p_postal_code TEXT DEFAULT NULL,
p_country TEXT DEFAULT NULL,
p_logo_url TEXT DEFAULT NULL,
p_logo_url_dark TEXT DEFAULT NULL,
p_default_email_signature TEXT DEFAULT NULL,
p_invoice_footer_notes TEXT DEFAULT NULL
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_id UUID;
BEGIN
INSERT INTO brand_settings (
brand_id, legal_business_name, phone, email, website_url,
street_address, city, state, postal_code, country,
logo_url, logo_url_dark, default_email_signature, invoice_footer_notes
) VALUES (
p_brand_id, p_legal_business_name, p_phone, p_email, p_website_url,
p_street_address, p_city, p_state, p_postal_code, p_country,
p_logo_url, p_logo_url_dark, p_default_email_signature, p_invoice_footer_notes
)
ON CONFLICT (brand_id) DO UPDATE SET
legal_business_name = COALESCE(p_legal_business_name, brand_settings.legal_business_name),
phone = COALESCE(p_phone, brand_settings.phone),
email = COALESCE(p_email, brand_settings.email),
website_url = COALESCE(p_website_url, brand_settings.website_url),
street_address = COALESCE(p_street_address, brand_settings.street_address),
city = COALESCE(p_city, brand_settings.city),
state = COALESCE(p_state, brand_settings.state),
postal_code = COALESCE(p_postal_code, brand_settings.postal_code),
country = COALESCE(p_country, brand_settings.country),
logo_url = COALESCE(p_logo_url, brand_settings.logo_url),
logo_url_dark = COALESCE(p_logo_url_dark, brand_settings.logo_url_dark),
default_email_signature = COALESCE(p_default_email_signature, brand_settings.default_email_signature),
invoice_footer_notes = COALESCE(p_invoice_footer_notes, brand_settings.invoice_footer_notes),
updated_at = now()
WHERE brand_settings.brand_id = p_brand_id
RETURNING id INTO v_id;
-- Return the updated record
RETURN get_brand_settings(p_brand_id);
END;
$$;
@@ -0,0 +1,330 @@
-- Migration 086: Enhance email substitution with brand_settings
-- Updates send_campaign and send_stop_blast to:
-- 1. Look up brand_settings for legal_business_name (falls back to brands.name)
-- 2. Append default_email_signature to outbound emails if set
-- 3. Support {{address}}, {{phone}}, {{email}}, {{website}} variables
--
-- Variable substitution priority:
-- {{company_name}} → legal_business_name (brand_settings) → brands.name → fallback
-- {{address}} → street_address, city, state postal_code from brand_settings
-- {{phone}} → phone from brand_settings
-- {{email}} → email from brand_settings
-- {{website}} → website_url from brand_settings
-- {{signature}} → default_email_signature from brand_settings (appended to body)
-- ── Enhanced send_campaign ────────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION public.send_campaign(p_campaign_id UUID, p_brand_id UUID DEFAULT NULL)
RETURNS jsonb
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_campaign communication_campaigns;
v_settings communication_settings;
v_brand_settings RECORD;
v_brand_name TEXT := 'Route Commerce';
v_signature TEXT;
v_address TEXT;
v_phone TEXT;
v_email TEXT;
v_website TEXT;
v_body_final TEXT;
v_subj_final TEXT;
v_audience JSONB;
v_entries JSONB := '[]'::JSONB;
v_customer JSONB;
v_count INTEGER := 0;
BEGIN
SELECT * INTO v_campaign FROM communication_campaigns WHERE id = p_campaign_id;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Campaign not found');
END IF;
-- Resolve effective brand_id
IF p_brand_id IS NOT NULL THEN
-- Use provided brand_id
ELSIF v_campaign.brand_id IS NOT NULL THEN
p_brand_id := v_campaign.brand_id;
END IF;
-- Look up brand_settings for extended variables
SELECT
bs.legal_business_name,
bs.phone,
bs.email,
bs.website_url,
bs.default_email_signature,
bs.street_address,
bs.city,
bs.state,
bs.postal_code,
b.name AS brand_name
INTO v_brand_settings
FROM brand_settings bs
JOIN brands b ON b.id = bs.brand_id
WHERE bs.brand_id = p_brand_id;
-- Build brand name: legal_business_name > brand_name > fallback
v_brand_name := COALESCE(
v_brand_settings.legal_business_name,
v_brand_settings.brand_name,
'Route Commerce'
);
-- Build full address string
IF v_brand_settings.street_address IS NOT NULL THEN
v_address := v_brand_settings.street_address
|| ', ' || COALESCE(v_brand_settings.city, '')
|| ', ' || COALESCE(v_brand_settings.state, '')
|| ' ' || COALESCE(v_brand_settings.postal_code, '');
-- Clean up extra commas from empty parts
v_address := trim(both ',' from v_address);
ELSE
v_address := NULL;
END IF;
v_phone := v_brand_settings.phone;
v_email := v_brand_settings.email;
v_website := v_brand_settings.website_url;
v_signature := v_brand_settings.default_email_signature;
-- Apply substitutions to subject
v_subj_final := v_campaign.subject;
v_subj_final := replace(v_subj_final, '{{company_name}}', COALESCE(v_brand_name, 'Route Commerce'));
v_subj_final := replace(v_subj_final, '{{brand_name}}', COALESCE(v_brand_name, 'Route Commerce'));
v_subj_final := replace(v_subj_final, '{{address}}', COALESCE(v_address, ''));
v_subj_final := replace(v_subj_final, '{{phone}}', COALESCE(v_phone, ''));
v_subj_final := replace(v_subj_final, '{{email}}', COALESCE(v_email, ''));
v_subj_final := replace(v_subj_final, '{{website}}', COALESCE(v_website, ''));
-- Apply substitutions to body, then append signature
v_body_final := v_campaign.body_text;
v_body_final := replace(v_body_final, '{{company_name}}', COALESCE(v_brand_name, 'Route Commerce'));
v_body_final := replace(v_body_final, '{{brand_name}}', COALESCE(v_brand_name, 'Route Commerce'));
v_body_final := replace(v_body_final, '{{address}}', COALESCE(v_address, ''));
v_body_final := replace(v_body_final, '{{phone}}', COALESCE(v_phone, ''));
v_body_final := replace(v_body_final, '{{email}}', COALESCE(v_email, ''));
v_body_final := replace(v_body_final, '{{website}}', COALESCE(v_website, ''));
-- Remove {{signature}} placeholder from body (replaced with actual signature below)
v_body_final := replace(v_body_final, '{{signature}}', '');
-- Append default email signature if set
IF v_signature IS NOT NULL AND v_signature != '' THEN
v_body_final := v_body_final || E'\n\n' || v_signature;
END IF;
SELECT * INTO v_settings
FROM communication_settings
WHERE brand_id = p_brand_id;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'No communication settings for brand');
END IF;
v_audience := preview_campaign_audience(p_brand_id, v_campaign.audience_rules);
FOR v_customer IN
SELECT * FROM jsonb_array_elements(coalesce(v_audience->'sample_customers', '[]'::jsonb))
LOOP
v_entries := v_entries || jsonb_build_array(jsonb_build_object(
'brand_id', p_brand_id,
'campaign_id', p_campaign_id,
'customer_id', v_customer->>'id',
'customer_email', v_customer->>'email',
'delivery_method', 'email',
'subject', v_subj_final,
'body_preview', left(v_body_final, 500),
'status', 'queued'
));
v_count := v_count + 1;
END LOOP;
PERFORM log_communication_messages(v_entries);
UPDATE communication_campaigns
SET status = 'sent', sent_at = now(), updated_at = now()
WHERE id = p_campaign_id;
RETURN jsonb_build_object('success', true, 'messages_logged', v_count);
END;
$$;
-- ── Enhanced send_stop_blast ───────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION public.send_stop_blast(
p_stop_id UUID,
p_brand_id UUID,
p_body TEXT,
p_channel TEXT DEFAULT 'email',
p_subject TEXT DEFAULT NULL,
p_audience TEXT DEFAULT 'pending',
p_created_by UUID DEFAULT NULL
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_campaign_id UUID;
v_count INT := 0;
v_order RECORD;
v_contact JSONB;
v_customer_id UUID;
v_opted_in BOOLEAN;
v_email TEXT;
v_phone TEXT;
v_brand_name TEXT := 'Route Commerce';
v_signature TEXT;
v_address TEXT;
v_subj TEXT;
v_body_sub TEXT;
v_brand_settings RECORD;
BEGIN
-- Look up brand_settings for extended variables
SELECT
bs.legal_business_name,
bs.phone,
bs.email,
bs.website_url,
bs.default_email_signature,
bs.street_address,
bs.city,
bs.state,
bs.postal_code,
b.name AS brand_name
INTO v_brand_settings
FROM brand_settings bs
JOIN brands b ON b.id = p_brand_id
WHERE bs.brand_id = p_brand_id;
-- Build brand name: legal_business_name > brand_name > fallback
v_brand_name := COALESCE(
v_brand_settings.legal_business_name,
v_brand_settings.brand_name,
'Route Commerce'
);
-- Build full address string
IF v_brand_settings.street_address IS NOT NULL THEN
v_address := v_brand_settings.street_address
|| ', ' || COALESCE(v_brand_settings.city, '')
|| ', ' || COALESCE(v_brand_settings.state, '')
|| ' ' || COALESCE(v_brand_settings.postal_code, '');
v_address := trim(both ',' from v_address);
ELSE
v_address := NULL;
END IF;
v_signature := v_brand_settings.default_email_signature;
-- Apply substitutions to subject
v_subj := p_subject;
IF v_subj IS NOT NULL THEN
v_subj := replace(v_subj, '{{company_name}}', COALESCE(v_brand_name, 'Route Commerce'));
v_subj := replace(v_subj, '{{brand_name}}', COALESCE(v_brand_name, 'Route Commerce'));
v_subj := replace(v_subj, '{{address}}', COALESCE(v_address, ''));
v_subj := replace(v_subj, '{{phone}}', COALESCE(v_brand_settings.phone, ''));
v_subj := replace(v_subj, '{{email}}', COALESCE(v_brand_settings.email, ''));
v_subj := replace(v_subj, '{{website}}', COALESCE(v_brand_settings.website_url, ''));
END IF;
-- Apply substitutions to body, then append signature
v_body_sub := p_body;
v_body_sub := replace(v_body_sub, '{{company_name}}', COALESCE(v_brand_name, 'Route Commerce'));
v_body_sub := replace(v_body_sub, '{{brand_name}}', COALESCE(v_brand_name, 'Route Commerce'));
v_body_sub := replace(v_body_sub, '{{address}}', COALESCE(v_address, ''));
v_body_sub := replace(v_body_sub, '{{phone}}', COALESCE(v_brand_settings.phone, ''));
v_body_sub := replace(v_body_sub, '{{email}}', COALESCE(v_brand_settings.email, ''));
v_body_sub := replace(v_body_sub, '{{website}}', COALESCE(v_brand_settings.website_url, ''));
-- Append default email signature if set
IF v_signature IS NOT NULL AND v_signature != '' THEN
v_body_sub := v_body_sub || E'\n\n' || v_signature;
END IF;
-- Create campaign record
INSERT INTO communication_campaigns (
brand_id, name, subject, body_text,
campaign_type, status, audience_rules,
sent_at, created_by
) VALUES (
p_brand_id,
'Stop blast: ' || COALESCE(p_subject, p_body),
v_subj,
v_body_sub,
'operational',
'sent',
jsonb_build_object(
'target', 'stop',
'stop_id', p_stop_id,
'date_from', NULL,
'date_to', NULL
),
now(),
p_created_by
) RETURNING id INTO v_campaign_id;
-- Fetch matching orders
FOR v_order IN
SELECT o.id, o.customer_id, o.customer_name,
o.customer_email, o.customer_phone, o.pickup_complete,
c.email_opt_in, c.sms_opt_in, c.unsubscribed_at
FROM orders o
LEFT JOIN communication_contacts c ON c.id = o.customer_id
WHERE o.stop_id = p_stop_id
AND (
p_audience = 'all'
OR (p_audience = 'pending' AND NOT o.pickup_complete)
OR (p_audience = 'picked_up' AND o.pickup_complete)
)
LOOP
-- Check opt-in status per channel
v_opted_in := (
CASE p_channel
WHEN 'sms' THEN v_order.customer_phone IS NOT NULL
AND COALESCE(v_order.sms_opt_in, FALSE) = TRUE
AND v_order.unsubscribed_at IS NULL
WHEN 'email' THEN v_order.customer_email IS NOT NULL
AND COALESCE(v_order.email_opt_in, TRUE) = TRUE
AND v_order.unsubscribed_at IS NULL
ELSE v_order.customer_email IS NOT NULL
AND COALESCE(v_order.email_opt_in, TRUE) = TRUE
AND v_order.unsubscribed_at IS NULL
END
);
IF NOT v_opted_in THEN
CONTINUE;
END IF;
-- Log email message with fully-substituted subject and body
IF p_channel IN ('email', 'both') AND v_order.customer_email IS NOT NULL THEN
INSERT INTO communication_message_logs (
brand_id, campaign_id, customer_id, customer_email,
delivery_method, subject, body_preview, status, sent_at
) VALUES (
p_brand_id, v_campaign_id, v_order.customer_id, v_order.customer_email,
'email', COALESCE(v_subj, ''), LEFT(v_body_sub, 200), 'queued', now()
);
v_count := v_count + 1;
END IF;
-- Log SMS message (signature not appended to SMS)
IF p_channel IN ('sms', 'both') AND v_order.customer_phone IS NOT NULL THEN
INSERT INTO communication_message_logs (
brand_id, campaign_id, customer_id, customer_phone,
delivery_method, body_preview, status, sent_at
) VALUES (
p_brand_id, v_campaign_id, v_order.customer_id, v_order.customer_phone,
'sms', LEFT(v_body_sub, 160), 'queued', now()
);
v_count := v_count + 1;
END IF;
END LOOP;
RETURN jsonb_build_object(
'success', true,
'campaign_id', v_campaign_id,
'messages_logged', v_count
);
END;
$$;
@@ -0,0 +1,77 @@
-- Migration 087: Brand Logos Storage Bucket
--
-- SETUP REQUIRED (one-time, run manually in Supabase dashboard):
-- 1. Go to Storage → New bucket
-- 2. Name: "brand-logos" | Public: true
-- 3. Allowed MIME types: image/png, image/jpeg, image/webp, image/svg+xml
-- 4. Max file size: 5MB
--
-- Files stored at: brand-logos/{brand_id}/logo.png, brand-logos/{brand_id}/logo-dark.png
-- RLS policies for brand-logos bucket
-- Brand admins can upload/update their own brand's logos
-- Everyone can read logos (public bucket)
CREATE POLICY IF NOT EXISTS "brand_admin_upload_logo"
ON storage.objects
FOR INSERT
WITH CHECK (
bucket_id = 'brand-logos'
AND (
-- Platform admin can upload to any brand
EXISTS (
SELECT 1 FROM admin_users au
WHERE au.user_id = auth.uid() AND au.role = 'platform_admin'
)
OR
-- Brand admin can upload to their own brand's folder
(storage.foldername(name))[1] IN (
SELECT au.brand_id::text FROM admin_users au
WHERE au.user_id = auth.uid()
AND au.role = 'brand_admin'
)
)
);
CREATE POLICY IF NOT EXISTS "public_read_logo"
ON storage.objects
FOR SELECT
USING (bucket_id = 'brand-logos');
CREATE POLICY IF NOT EXISTS "brand_admin_update_logo"
ON storage.objects
FOR UPDATE
USING (
bucket_id = 'brand-logos'
AND (
EXISTS (
SELECT 1 FROM admin_users au
WHERE au.user_id = auth.uid() AND au.role = 'platform_admin'
)
OR
(storage.foldername(name))[1] IN (
SELECT au.brand_id::text FROM admin_users au
WHERE au.user_id = auth.uid()
AND au.role = 'brand_admin'
)
)
);
CREATE POLICY IF NOT EXISTS "brand_admin_delete_logo"
ON storage.objects
FOR DELETE
USING (
bucket_id = 'brand-logos'
AND (
EXISTS (
SELECT 1 FROM admin_users au
WHERE au.user_id = auth.uid() AND au.role = 'platform_admin'
)
OR
(storage.foldername(name))[1] IN (
SELECT au.brand_id::text FROM admin_users au
WHERE au.user_id = auth.uid()
AND au.role = 'brand_admin'
)
)
);
@@ -0,0 +1,72 @@
-- Migration 088: Brand feature flags / add-on licensing system
-- Enables per-brand add-on enablement for Harvest Reach, Wholesale Portal, Water Log, AI Tools, etc.
CREATE TABLE IF NOT EXISTS brand_features (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
feature_key TEXT NOT NULL, -- e.g., 'harvest_reach', 'wholesale_portal', 'water_log', 'ai_tools'
enabled BOOLEAN NOT NULL DEFAULT false,
enabled_at TIMESTAMPTZ,
disabled_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (brand_id, feature_key)
);
-- Index for fast lookups
CREATE INDEX IF NOT EXISTS brand_features_brand_id_idx ON brand_features(brand_id);
-- RPC to get all features for a brand
CREATE OR REPLACE FUNCTION get_brand_features(p_brand_id UUID)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
result JSONB;
BEGIN
result := jsonb_object_agg(
f.feature_key,
CASE WHEN f.enabled AND f.disabled_at IS NULL THEN true ELSE false END
)
FROM brand_features f
WHERE f.brand_id = p_brand_id;
-- If no features set, return empty object (env vars will be fallback)
RETURN COALESCE(result, '{}'::jsonb);
END;
$$;
-- RPC to set a single feature on/off
CREATE OR REPLACE FUNCTION set_brand_feature(
p_brand_id UUID,
p_feature_key TEXT,
p_enabled BOOLEAN
)
RETURNS BOOLEAN
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
BEGIN
INSERT INTO brand_features (brand_id, feature_key, enabled, enabled_at)
VALUES (p_brand_id, p_feature_key, p_enabled, CASE WHEN p_enabled THEN now() ELSE NULL END)
ON CONFLICT (brand_id, feature_key)
DO UPDATE SET
enabled = p_enabled,
enabled_at = CASE WHEN p_enabled AND brand_features.enabled_at IS NULL THEN now() ELSE brand_features.enabled_at END,
disabled_at = CASE WHEN NOT p_enabled THEN now() ELSE NULL END;
RETURN true;
END;
$$;
-- Grant access
GRANT SELECT, INSERT, UPDATE, DELETE ON brand_features TO authenticated;
GRANT EXECUTE ON FUNCTION get_brand_features(UUID) TO authenticated;
GRANT EXECUTE ON FUNCTION set_brand_feature(UUID, TEXT, BOOLEAN) TO authenticated;
-- ============================================================
-- RLS for brand_features (added by scripts/fix-archived-rls.js)
-- Columns: id, brand_id, feature_key, enabled_at, disabled_at, created_at, feature_key
-- ============================================================
ALTER TABLE brand_features ENABLE ROW LEVEL SECURITY;
ALTER TABLE brand_features FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON brand_features;
CREATE POLICY tenant_isolation ON brand_features FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());
@@ -0,0 +1,134 @@
-- Migration 089: AI providers config + custom integrations
-- Store per-brand AI provider settings and custom integration definitions
ALTER TABLE brand_settings
ADD COLUMN IF NOT EXISTS ai_provider_settings JSONB DEFAULT '{
"provider": "openai",
"api_key": null,
"org_id": null,
"model": "gpt-4o-mini",
"custom_endpoint": null
}'::jsonb;
ALTER TABLE brand_settings
ADD COLUMN IF NOT EXISTS custom_integrations JSONB DEFAULT '[]'::jsonb;
-- RPC to get AI provider config
CREATE OR REPLACE FUNCTION get_ai_provider_settings(p_brand_id UUID)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
BEGIN
RETURN COALESCE(
(SELECT ai_provider_settings FROM brand_settings WHERE brand_id = p_brand_id),
'{"provider": "openai", "model": "gpt-4o-mini"}'::jsonb
);
END;
$$;
-- RPC to save AI provider config
CREATE OR REPLACE FUNCTION set_ai_provider_settings(
p_brand_id UUID,
p_settings JSONB
)
RETURNS BOOLEAN
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
BEGIN
UPDATE brand_settings
SET ai_provider_settings = p_settings,
updated_at = now()
WHERE brand_id = p_brand_id;
RETURN true;
END;
$$;
-- RPC to get custom integrations
CREATE OR REPLACE FUNCTION get_custom_integrations(p_brand_id UUID)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
BEGIN
RETURN COALESCE(
(SELECT custom_integrations FROM brand_settings WHERE brand_id = p_brand_id),
'[]'::jsonb
);
END;
$$;
-- RPC to add/update a custom integration
CREATE OR REPLACE FUNCTION upsert_custom_integration(
p_brand_id UUID,
p_integration JSONB
)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
current_integrations JSONB;
updated_integrations JSONB;
integration_id TEXT;
BEGIN
current_integrations := COALESCE(
(SELECT custom_integrations FROM brand_settings WHERE brand_id = p_brand_id),
'[]'::jsonb
);
integration_id := p_integration->>'id';
-- Remove existing with same id, then append
current_integrations := (
SELECT jsonb_agg(item)
FROM (
SELECT item
FROM jsonb_array_elements(current_integrations) item
WHERE item->>'id' != integration_id
UNION ALL
SELECT p_integration
) t
);
UPDATE brand_settings
SET custom_integrations = current_integrations,
updated_at = now()
WHERE brand_id = p_brand_id;
RETURN current_integrations;
END;
$$;
-- RPC to delete a custom integration
CREATE OR REPLACE FUNCTION delete_custom_integration(
p_brand_id UUID,
p_integration_id TEXT
)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
BEGIN
UPDATE brand_settings
SET custom_integrations = (
SELECT jsonb_agg(item)
FROM (
SELECT item
FROM jsonb_array_elements(custom_integrations) item
WHERE item->>'id' != p_integration_id
) t
),
updated_at = now()
WHERE brand_id = p_brand_id;
RETURN (SELECT custom_integrations FROM brand_settings WHERE brand_id = p_brand_id);
END;
$$;
GRANT EXECUTE ON FUNCTION get_ai_provider_settings(UUID) TO authenticated;
GRANT EXECUTE ON FUNCTION set_ai_provider_settings(UUID, JSONB) TO authenticated;
GRANT EXECUTE ON FUNCTION get_custom_integrations(UUID) TO authenticated;
GRANT EXECUTE ON FUNCTION upsert_custom_integration(UUID, JSONB) TO authenticated;
GRANT EXECUTE ON FUNCTION delete_custom_integration(UUID, TEXT) TO authenticated;
@@ -0,0 +1,55 @@
-- Enable UUID extension if not already enabled
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Storefront customization fields added to brand_settings
ALTER TABLE brand_settings ADD COLUMN IF NOT EXISTS hero_tagline TEXT;
ALTER TABLE brand_settings ADD COLUMN IF NOT EXISTS about_headline TEXT;
ALTER TABLE brand_settings ADD COLUMN IF NOT EXISTS about_subheadline TEXT;
ALTER TABLE brand_settings ADD COLUMN IF NOT EXISTS custom_footer_text TEXT;
ALTER TABLE brand_settings ADD COLUMN IF NOT EXISTS show_wholesale_link BOOLEAN DEFAULT true;
ALTER TABLE brand_settings ADD COLUMN IF NOT EXISTS show_zip_search BOOLEAN DEFAULT true;
ALTER TABLE brand_settings ADD COLUMN IF NOT EXISTS show_schedule_pdf BOOLEAN DEFAULT true;
ALTER TABLE brand_settings ADD COLUMN IF NOT EXISTS show_text_alerts BOOLEAN DEFAULT false;
ALTER TABLE brand_settings ADD COLUMN IF NOT EXISTS schedule_pdf_notes TEXT;
-- New RPC: get brand settings by slug (public, no auth required for brand-specific storefront pages)
CREATE OR REPLACE FUNCTION get_brand_settings_by_slug(p_brand_slug TEXT)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
result JSONB;
BEGIN
SELECT jsonb_build_object(
'brand_id', bs.brand_id,
'brand_name', b.name,
'hero_tagline', bs.hero_tagline,
'about_headline', bs.about_headline,
'about_subheadline', bs.about_subheadline,
'custom_footer_text', bs.custom_footer_text,
'show_wholesale_link', bs.show_wholesale_link,
'show_zip_search', bs.show_zip_search,
'show_schedule_pdf', bs.show_schedule_pdf,
'show_text_alerts', bs.show_text_alerts,
'schedule_pdf_notes', bs.schedule_pdf_notes,
'logo_url', bs.logo_url,
'logo_url_dark', bs.logo_url_dark,
'contact_email', bs.email,
'contact_phone', bs.phone,
'legal_business_name', bs.legal_business_name,
'wholesale_enabled', ws.wholesale_enabled
)
INTO result
FROM brands b
LEFT JOIN brand_settings bs ON bs.brand_id = b.id
LEFT JOIN wholesale_settings ws ON ws.brand_id = b.id
WHERE b.slug = p_brand_slug;
RETURN result;
END;
$$;
COMMENT ON FUNCTION get_brand_settings_by_slug IS
'Returns brand settings merged with wholesale_enabled for public storefront pages. No auth required.';
@@ -0,0 +1,83 @@
-- Add plan_tier column to brands
ALTER TABLE public.brands
ADD COLUMN IF NOT EXISTS plan_tier TEXT NOT NULL DEFAULT 'starter'
CHECK (plan_tier IN ('starter', 'farm', 'enterprise'));
-- Add stripe_customer_id for Stripe billing portal
ALTER TABLE public.brands
ADD COLUMN IF NOT EXISTS stripe_customer_id TEXT;
-- Add plan limits columns
ALTER TABLE public.brands
ADD COLUMN IF NOT EXISTS max_users INTEGER DEFAULT 1,
ADD COLUMN IF NOT EXISTS max_stops_monthly INTEGER DEFAULT 10,
ADD COLUMN IF NOT EXISTS max_products INTEGER DEFAULT 25;
-- RPC: update_brand_plan_tier
CREATE OR REPLACE FUNCTION public.update_brand_plan_tier(
p_brand_id UUID,
p_plan_tier TEXT
) RETURNS VOID AS $$
BEGIN
IF p_plan_tier NOT IN ('starter', 'farm', 'enterprise') THEN
RAISE EXCEPTION 'Invalid plan tier: %', p_plan_tier;
END IF;
UPDATE brands SET
plan_tier = p_plan_tier,
updated_at = now()
WHERE id = p_brand_id;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- RPC: update_brand_stripe_customer_id
CREATE OR REPLACE FUNCTION public.update_brand_stripe_customer_id(
p_brand_id UUID,
p_stripe_customer_id TEXT
) RETURNS VOID AS $$
BEGIN
UPDATE brands SET
stripe_customer_id = p_stripe_customer_id,
updated_at = now()
WHERE id = p_brand_id;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- RPC: get_brand_plan_info (returns plan tier + limits + usage stats)
CREATE OR REPLACE FUNCTION public.get_brand_plan_info(p_brand_id UUID)
RETURNS JSONB AS $$
DECLARE
v_result JSONB;
v_user_count BIGINT;
v_active_stops BIGINT;
v_product_count BIGINT;
v_brand RECORD;
BEGIN
SELECT plan_tier, max_users, max_stops_monthly, max_products, stripe_customer_id
INTO v_brand
FROM brands WHERE id = p_brand_id;
IF v_brand IS NULL THEN
RETURN NULL;
END IF;
-- Count current usage
SELECT COUNT(*) INTO v_user_count FROM admin_users WHERE brand_id = p_brand_id AND deleted_at IS NULL;
SELECT COUNT(*) INTO v_active_stops FROM stops WHERE brand_id = p_brand_id AND active = true AND date >= date_trunc('month', now());
SELECT COUNT(*) INTO v_product_count FROM products WHERE brand_id = p_brand_id AND deleted_at IS NULL;
SELECT jsonb_build_object(
'plan_tier', v_brand.plan_tier,
'max_users', v_brand.max_users,
'max_stops_monthly', v_brand.max_stops_monthly,
'max_products', v_brand.max_products,
'stripe_customer_id', v_brand.stripe_customer_id,
'usage', jsonb_build_object(
'users', v_user_count,
'stops_this_month', v_active_stops,
'products', v_product_count
)
) INTO v_result;
RETURN v_result;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
@@ -0,0 +1,74 @@
-- Migration 092: Stripe Subscriptions + Billing Integration
-- Enables real Stripe subscription management for platform billing
-- ── Subscription tracking columns on brands ────────────────────────────────
ALTER TABLE brands ADD COLUMN IF NOT EXISTS stripe_subscription_id TEXT;
ALTER TABLE brands ADD COLUMN IF NOT EXISTS stripe_subscription_status TEXT DEFAULT 'inactive';
ALTER TABLE brands ADD COLUMN IF NOT EXISTS stripe_current_period_end TIMESTAMPTZ;
-- ── RPC: Save subscription data from Stripe webhook ─────────────────────────
CREATE OR REPLACE FUNCTION set_brand_subscription(
p_brand_id UUID,
p_subscription_id TEXT,
p_status TEXT,
p_current_period_end TIMESTAMPTZ
) RETURNS BOOLEAN AS $$
BEGIN
UPDATE brands SET
stripe_subscription_id = p_subscription_id,
stripe_subscription_status = p_status,
stripe_current_period_end = p_current_period_end,
updated_at = NOW()
WHERE id = p_brand_id;
RETURN TRUE;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
GRANT EXECUTE ON FUNCTION set_brand_subscription(UUID, TEXT, TEXT, TIMESTAMPTZ) TO authenticated;
-- ── RPC: Get subscription info ─────────────────────────────────────────────
CREATE OR REPLACE FUNCTION get_brand_subscription(p_brand_id UUID)
RETURNS JSONB AS $$
DECLARE
result JSONB;
BEGIN
SELECT jsonb_build_object(
'stripe_subscription_id', stripe_subscription_id,
'stripe_subscription_status', stripe_subscription_status,
'stripe_current_period_end', stripe_current_period_end,
'stripe_customer_id', stripe_customer_id,
'plan_tier', plan_tier
) INTO result
FROM brands WHERE id = p_brand_id;
RETURN result;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
GRANT EXECUTE ON FUNCTION get_brand_subscription(UUID) TO authenticated;
-- ── Stripe price IDs (environment-variable-driven) ─────────────────────────
-- These should be set in .env.local:
-- STRIPE_PRICE_STARTER=price_xxxx
-- STRIPE_PRICE_FARM=price_xxxx
-- STRIPE_PRICE_ENTERPRISE=price_xxxx
-- STRIPE_PRICE_HARVEST_REACH=price_xxxx (add-on)
-- STRIPE_PRICE_WHOLESALE_PORTAL=price_xxxx
-- STRIPE_PRICE_WATER_LOG=price_xxxx
-- STRIPE_PRICE_AI_TOOLS=price_xxxx
-- STRIPE_PRICE_SQUARE_SYNC=price_xxxx
-- STRIPE_PRICE_SMS_CAMPAIGNS=price_xxxx
-- ── Feature flag auto-enable map ───────────────────────────────────────────
-- When Stripe subscription is active for a given price, the corresponding
-- brand_features flag is enabled. When subscription is canceled, it's disabled.
--
-- Price ID → feature key mapping (configured via env vars):
-- STRIPE_PRICE_HARVEST_REACH → harvest_reach
-- STRIPE_PRICE_WHOLESALE_PORTAL → wholesale_portal
-- STRIPE_PRICE_WATER_LOG → water_log
-- STRIPE_PRICE_AI_TOOLS → ai_tools
-- STRIPE_PRICE_SQUARE_SYNC → square_sync
-- STRIPE_PRICE_SMS_CAMPAIGNS → sms_campaigns
--
-- Plan tier changes are handled separately via plan upgrade checkout sessions.
@@ -0,0 +1,170 @@
-- Migration 093: Add hero_image_url to brand_settings
-- Storefront hero image (full-width background photo)
ALTER TABLE brand_settings ADD COLUMN IF NOT EXISTS hero_image_url TEXT;
-- Update upsert_brand_settings to accept hero_image_url
CREATE OR REPLACE FUNCTION public.upsert_brand_settings(
p_brand_id UUID,
p_legal_business_name TEXT DEFAULT NULL,
p_phone TEXT DEFAULT NULL,
p_email TEXT DEFAULT NULL,
p_website_url TEXT DEFAULT NULL,
p_street_address TEXT DEFAULT NULL,
p_city TEXT DEFAULT NULL,
p_state TEXT DEFAULT NULL,
p_postal_code TEXT DEFAULT NULL,
p_country TEXT DEFAULT NULL,
p_logo_url TEXT DEFAULT NULL,
p_logo_url_dark TEXT DEFAULT NULL,
p_default_email_signature TEXT DEFAULT NULL,
p_invoice_footer_notes TEXT DEFAULT NULL,
p_hero_tagline TEXT DEFAULT NULL,
p_about_headline TEXT DEFAULT NULL,
p_about_subheadline TEXT DEFAULT NULL,
p_custom_footer_text TEXT DEFAULT NULL,
p_show_wholesale_link BOOLEAN DEFAULT NULL,
p_show_zip_search BOOLEAN DEFAULT NULL,
p_show_schedule_pdf BOOLEAN DEFAULT NULL,
p_show_text_alerts BOOLEAN DEFAULT NULL,
p_schedule_pdf_notes TEXT DEFAULT NULL,
p_hero_image_url TEXT DEFAULT NULL
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_id UUID;
BEGIN
INSERT INTO brand_settings (
brand_id, legal_business_name, phone, email, website_url,
street_address, city, state, postal_code, country,
logo_url, logo_url_dark, default_email_signature, invoice_footer_notes,
hero_tagline, about_headline, about_subheadline, custom_footer_text,
show_wholesale_link, show_zip_search, show_schedule_pdf,
show_text_alerts, schedule_pdf_notes, hero_image_url
) VALUES (
p_brand_id, p_legal_business_name, p_phone, p_email, p_website_url,
p_street_address, p_city, p_state, p_postal_code, p_country,
p_logo_url, p_logo_url_dark, p_default_email_signature, p_invoice_footer_notes,
p_hero_tagline, p_about_headline, p_about_subheadline, p_custom_footer_text,
p_show_wholesale_link, p_show_zip_search, p_show_schedule_pdf,
p_show_text_alerts, p_schedule_pdf_notes, p_hero_image_url
)
ON CONFLICT (brand_id) DO UPDATE SET
legal_business_name = COALESCE(p_legal_business_name, brand_settings.legal_business_name),
phone = COALESCE(p_phone, brand_settings.phone),
email = COALESCE(p_email, brand_settings.email),
website_url = COALESCE(p_website_url, brand_settings.website_url),
street_address = COALESCE(p_street_address, brand_settings.street_address),
city = COALESCE(p_city, brand_settings.city),
state = COALESCE(p_state, brand_settings.state),
postal_code = COALESCE(p_postal_code, brand_settings.postal_code),
country = COALESCE(p_country, brand_settings.country),
logo_url = COALESCE(p_logo_url, brand_settings.logo_url),
logo_url_dark = COALESCE(p_logo_url_dark, brand_settings.logo_url_dark),
default_email_signature = COALESCE(p_default_email_signature, brand_settings.default_email_signature),
invoice_footer_notes = COALESCE(p_invoice_footer_notes, brand_settings.invoice_footer_notes),
hero_tagline = COALESCE(p_hero_tagline, brand_settings.hero_tagline),
about_headline = COALESCE(p_about_headline, brand_settings.about_headline),
about_subheadline = COALESCE(p_about_subheadline, brand_settings.about_subheadline),
custom_footer_text = COALESCE(p_custom_footer_text, brand_settings.custom_footer_text),
show_wholesale_link = COALESCE(p_show_wholesale_link, brand_settings.show_wholesale_link),
show_zip_search = COALESCE(p_show_zip_search, brand_settings.show_zip_search),
show_schedule_pdf = COALESCE(p_show_schedule_pdf, brand_settings.show_schedule_pdf),
show_text_alerts = COALESCE(p_show_text_alerts, brand_settings.show_text_alerts),
schedule_pdf_notes = COALESCE(p_schedule_pdf_notes, brand_settings.schedule_pdf_notes),
hero_image_url = COALESCE(p_hero_image_url, brand_settings.hero_image_url),
updated_at = now()
WHERE brand_settings.brand_id = p_brand_id
RETURNING id INTO v_id;
RETURN get_brand_settings(p_brand_id);
END;
$$;
-- Update get_brand_settings to include hero_image_url
CREATE OR REPLACE FUNCTION public.get_brand_settings(p_brand_id UUID)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_result JSONB;
BEGIN
SELECT jsonb_build_object(
'id', bs.id,
'brand_id', bs.brand_id,
'legal_business_name', bs.legal_business_name,
'phone', bs.phone,
'email', bs.email,
'website_url', bs.website_url,
'street_address', bs.street_address,
'city', bs.city,
'state', bs.state,
'postal_code', bs.postal_code,
'country', bs.country,
'logo_url', bs.logo_url,
'logo_url_dark', bs.logo_url_dark,
'default_email_signature', bs.default_email_signature,
'invoice_footer_notes', bs.invoice_footer_notes,
'hero_tagline', bs.hero_tagline,
'about_headline', bs.about_headline,
'about_subheadline', bs.about_subheadline,
'custom_footer_text', bs.custom_footer_text,
'show_wholesale_link', bs.show_wholesale_link,
'show_zip_search', bs.show_zip_search,
'show_schedule_pdf', bs.show_schedule_pdf,
'show_text_alerts', bs.show_text_alerts,
'schedule_pdf_notes', bs.schedule_pdf_notes,
'hero_image_url', bs.hero_image_url,
'updated_at', bs.updated_at,
'brand_name', b.name
)
INTO v_result
FROM brand_settings bs
JOIN brands b ON b.id = bs.brand_id
WHERE bs.brand_id = p_brand_id;
RETURN COALESCE(v_result, jsonb_build_object('brand_id', p_brand_id, 'brand_name', (
SELECT name FROM brands WHERE id = p_brand_id
)));
END;
$$;
-- Update get_brand_settings_by_slug to include hero_image_url
CREATE OR REPLACE FUNCTION public.get_brand_settings_by_slug(p_brand_slug TEXT)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
result JSONB;
BEGIN
SELECT jsonb_build_object(
'brand_id', bs.brand_id,
'brand_name', b.name,
'hero_tagline', bs.hero_tagline,
'about_headline', bs.about_headline,
'about_subheadline', bs.about_subheadline,
'custom_footer_text', bs.custom_footer_text,
'show_wholesale_link', bs.show_wholesale_link,
'show_zip_search', bs.show_zip_search,
'show_schedule_pdf', bs.show_schedule_pdf,
'show_text_alerts', bs.show_text_alerts,
'schedule_pdf_notes', bs.schedule_pdf_notes,
'logo_url', bs.logo_url,
'logo_url_dark', bs.logo_url_dark,
'hero_image_url', bs.hero_image_url,
'contact_email', bs.email,
'contact_phone', bs.phone,
'legal_business_name', bs.legal_business_name,
'wholesale_enabled', ws.wholesale_enabled
)
INTO result
FROM brands b
LEFT JOIN brand_settings bs ON bs.brand_id = b.id
LEFT JOIN wholesale_settings ws ON ws.brand_id = b.id
WHERE b.slug = p_brand_slug;
RETURN result;
END;
$$;
@@ -0,0 +1,197 @@
-- Migration 094: Brand Color Customization
-- Add primary/secondary/bg color customization to brand_settings
-- Allows admins to customize brand accent colors via brand settings
ALTER TABLE brand_settings ADD COLUMN IF NOT EXISTS brand_primary_color TEXT DEFAULT '#16a34a';
ALTER TABLE brand_settings ADD COLUMN IF NOT EXISTS brand_secondary_color TEXT DEFAULT '#f5f5f4';
ALTER TABLE brand_settings ADD COLUMN IF NOT EXISTS brand_bg_color TEXT DEFAULT '#fafaf9';
ALTER TABLE brand_settings ADD COLUMN IF NOT EXISTS brand_text_color TEXT DEFAULT '#1c1917';
-- Update upsert_brand_settings to accept new color fields
CREATE OR REPLACE FUNCTION public.upsert_brand_settings(
p_brand_id UUID,
p_legal_business_name TEXT DEFAULT NULL,
p_phone TEXT DEFAULT NULL,
p_email TEXT DEFAULT NULL,
p_website_url TEXT DEFAULT NULL,
p_street_address TEXT DEFAULT NULL,
p_city TEXT DEFAULT NULL,
p_state TEXT DEFAULT NULL,
p_postal_code TEXT DEFAULT NULL,
p_country TEXT DEFAULT NULL,
p_logo_url TEXT DEFAULT NULL,
p_logo_url_dark TEXT DEFAULT NULL,
p_default_email_signature TEXT DEFAULT NULL,
p_invoice_footer_notes TEXT DEFAULT NULL,
p_hero_tagline TEXT DEFAULT NULL,
p_about_headline TEXT DEFAULT NULL,
p_about_subheadline TEXT DEFAULT NULL,
p_custom_footer_text TEXT DEFAULT NULL,
p_show_wholesale_link BOOLEAN DEFAULT NULL,
p_show_zip_search BOOLEAN DEFAULT NULL,
p_show_schedule_pdf BOOLEAN DEFAULT NULL,
p_show_text_alerts BOOLEAN DEFAULT NULL,
p_schedule_pdf_notes TEXT DEFAULT NULL,
p_hero_image_url TEXT DEFAULT NULL,
p_brand_primary_color TEXT DEFAULT NULL,
p_brand_secondary_color TEXT DEFAULT NULL,
p_brand_bg_color TEXT DEFAULT NULL,
p_brand_text_color TEXT DEFAULT NULL
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_id UUID;
BEGIN
INSERT INTO brand_settings (
brand_id, legal_business_name, phone, email, website_url,
street_address, city, state, postal_code, country,
logo_url, logo_url_dark, default_email_signature, invoice_footer_notes,
hero_tagline, about_headline, about_subheadline, custom_footer_text,
show_wholesale_link, show_zip_search, show_schedule_pdf,
show_text_alerts, schedule_pdf_notes, hero_image_url,
brand_primary_color, brand_secondary_color, brand_bg_color, brand_text_color
) VALUES (
p_brand_id, p_legal_business_name, p_phone, p_email, p_website_url,
p_street_address, p_city, p_state, p_postal_code, p_country,
p_logo_url, p_logo_url_dark, p_default_email_signature, p_invoice_footer_notes,
p_hero_tagline, p_about_headline, p_about_subheadline, p_custom_footer_text,
p_show_wholesale_link, p_show_zip_search, p_show_schedule_pdf,
p_show_text_alerts, p_schedule_pdf_notes, p_hero_image_url,
p_brand_primary_color, p_brand_secondary_color, p_brand_bg_color, p_brand_text_color
)
ON CONFLICT (brand_id) DO UPDATE SET
legal_business_name = COALESCE(p_legal_business_name, brand_settings.legal_business_name),
phone = COALESCE(p_phone, brand_settings.phone),
email = COALESCE(p_email, brand_settings.email),
website_url = COALESCE(p_website_url, brand_settings.website_url),
street_address = COALESCE(p_street_address, brand_settings.street_address),
city = COALESCE(p_city, brand_settings.city),
state = COALESCE(p_state, brand_settings.state),
postal_code = COALESCE(p_postal_code, brand_settings.postal_code),
country = COALESCE(p_country, brand_settings.country),
logo_url = COALESCE(p_logo_url, brand_settings.logo_url),
logo_url_dark = COALESCE(p_logo_url_dark, brand_settings.logo_url_dark),
default_email_signature = COALESCE(p_default_email_signature, brand_settings.default_email_signature),
invoice_footer_notes = COALESCE(p_invoice_footer_notes, brand_settings.invoice_footer_notes),
hero_tagline = COALESCE(p_hero_tagline, brand_settings.hero_tagline),
about_headline = COALESCE(p_about_headline, brand_settings.about_headline),
about_subheadline = COALESCE(p_about_subheadline, brand_settings.about_subheadline),
custom_footer_text = COALESCE(p_custom_footer_text, brand_settings.custom_footer_text),
show_wholesale_link = COALESCE(p_show_wholesale_link, brand_settings.show_wholesale_link),
show_zip_search = COALESCE(p_show_zip_search, brand_settings.show_zip_search),
show_schedule_pdf = COALESCE(p_show_schedule_pdf, brand_settings.show_schedule_pdf),
show_text_alerts = COALESCE(p_show_text_alerts, brand_settings.show_text_alerts),
schedule_pdf_notes = COALESCE(p_schedule_pdf_notes, brand_settings.schedule_pdf_notes),
hero_image_url = COALESCE(p_hero_image_url, brand_settings.hero_image_url),
brand_primary_color = COALESCE(p_brand_primary_color, brand_settings.brand_primary_color),
brand_secondary_color = COALESCE(p_brand_secondary_color, brand_settings.brand_secondary_color),
brand_bg_color = COALESCE(p_brand_bg_color, brand_settings.brand_bg_color),
brand_text_color = COALESCE(p_brand_text_color, brand_settings.brand_text_color),
updated_at = now()
WHERE brand_settings.brand_id = p_brand_id
RETURNING id INTO v_id;
RETURN get_brand_settings(p_brand_id);
END;
$$;
-- Update get_brand_settings to include new color fields
CREATE OR REPLACE FUNCTION public.get_brand_settings(p_brand_id UUID)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_result JSONB;
BEGIN
SELECT jsonb_build_object(
'id', bs.id,
'brand_id', bs.brand_id,
'legal_business_name', bs.legal_business_name,
'phone', bs.phone,
'email', bs.email,
'website_url', bs.website_url,
'street_address', bs.street_address,
'city', bs.city,
'state', bs.state,
'postal_code', bs.postal_code,
'country', bs.country,
'logo_url', bs.logo_url,
'logo_url_dark', bs.logo_url_dark,
'default_email_signature', bs.default_email_signature,
'invoice_footer_notes', bs.invoice_footer_notes,
'hero_tagline', bs.hero_tagline,
'about_headline', bs.about_headline,
'about_subheadline', bs.about_subheadline,
'custom_footer_text', bs.custom_footer_text,
'show_wholesale_link', bs.show_wholesale_link,
'show_zip_search', bs.show_zip_search,
'show_schedule_pdf', bs.show_schedule_pdf,
'show_text_alerts', bs.show_text_alerts,
'schedule_pdf_notes', bs.schedule_pdf_notes,
'hero_image_url', bs.hero_image_url,
'brand_primary_color', bs.brand_primary_color,
'brand_secondary_color', bs.brand_secondary_color,
'brand_bg_color', bs.brand_bg_color,
'brand_text_color', bs.brand_text_color,
'updated_at', bs.updated_at,
'brand_name', b.name
)
INTO v_result
FROM brand_settings bs
JOIN brands b ON b.id = bs.brand_id
WHERE bs.brand_id = p_brand_id;
RETURN COALESCE(v_result, jsonb_build_object('brand_id', p_brand_id, 'brand_name', (
SELECT name FROM brands WHERE id = p_brand_id
)));
END;
$$;
-- Update get_brand_settings_by_slug to include new color fields
CREATE OR REPLACE FUNCTION public.get_brand_settings_by_slug(p_brand_slug TEXT)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
result JSONB;
BEGIN
SELECT jsonb_build_object(
'brand_id', bs.brand_id,
'brand_name', b.name,
'hero_tagline', bs.hero_tagline,
'about_headline', bs.about_headline,
'about_subheadline', bs.about_subheadline,
'custom_footer_text', bs.custom_footer_text,
'show_wholesale_link', bs.show_wholesale_link,
'show_zip_search', bs.show_zip_search,
'show_schedule_pdf', bs.show_schedule_pdf,
'show_text_alerts', bs.show_text_alerts,
'schedule_pdf_notes', bs.schedule_pdf_notes,
'logo_url', bs.logo_url,
'logo_url_dark', bs.logo_url_dark,
'hero_image_url', bs.hero_image_url,
'contact_email', bs.email,
'contact_phone', bs.phone,
'legal_business_name', bs.legal_business_name,
'wholesale_enabled', ws.wholesale_enabled,
'brand_primary_color', bs.brand_primary_color,
'brand_secondary_color', bs.brand_secondary_color,
'brand_bg_color', bs.brand_bg_color,
'brand_text_color', bs.brand_text_color
)
INTO result
FROM brands b
LEFT JOIN brand_settings bs ON bs.brand_id = b.id
LEFT JOIN wholesale_settings ws ON ws.brand_id = b.id
WHERE b.slug = p_brand_slug;
RETURN result;
END;
$$;
COMMENT ON COLUMN brand_settings.brand_primary_color IS 'Primary accent color hex for brand storefront (buttons, links, badges, accents)';
COMMENT ON COLUMN brand_settings.brand_secondary_color IS 'Secondary accent color hex for brand storefront';
COMMENT ON COLUMN brand_settings.brand_bg_color IS 'Light mode page background color hex for brand storefront';
COMMENT ON COLUMN brand_settings.brand_text_color IS 'Primary text color hex for brand storefront';
@@ -0,0 +1,248 @@
-- 095: Tax Settings — brand_settings columns + tax summary/orders RPCs
-- Adds collect_sales_tax + nexus_states to brand_settings
-- Creates get_tax_summary and get_taxable_orders RPCs
BEGIN;
-- ── 1. Add columns to brand_settings ─────────────────────────────────────────
ALTER TABLE brand_settings
ADD COLUMN IF NOT EXISTS collect_sales_tax BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS nexus_states TEXT[] NOT NULL DEFAULT '{CO}'::TEXT[];
-- ── 2. Update upsert_brand_settings ──────────────────────────────────────────
DROP FUNCTION IF EXISTS public.upsert_brand_settings(
UUID, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT,
TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, BOOLEAN, BOOLEAN, BOOLEAN,
BOOLEAN, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, BOOLEAN, TEXT[]
);
CREATE OR REPLACE FUNCTION public.upsert_brand_settings(
p_brand_id UUID,
p_legal_business_name TEXT,
p_phone TEXT,
p_email TEXT,
p_website_url TEXT,
p_street_address TEXT,
p_city TEXT,
p_state TEXT,
p_postal_code TEXT,
p_country TEXT,
p_logo_url TEXT,
p_logo_url_dark TEXT,
p_default_email_signature TEXT,
p_invoice_footer_notes TEXT,
p_hero_tagline TEXT,
p_about_headline TEXT,
p_about_subheadline TEXT,
p_custom_footer_text TEXT,
p_show_wholesale_link BOOLEAN,
p_show_zip_search BOOLEAN,
p_show_schedule_pdf BOOLEAN,
p_show_text_alerts BOOLEAN,
p_schedule_pdf_notes TEXT,
p_hero_image_url TEXT,
p_brand_primary_color TEXT,
p_brand_secondary_color TEXT,
p_brand_bg_color TEXT,
p_brand_text_color TEXT,
p_collect_sales_tax BOOLEAN DEFAULT NULL,
p_nexus_states TEXT[] DEFAULT NULL
) RETURNS brand_settings
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
v_row brand_settings;
BEGIN
INSERT INTO brand_settings (
id, brand_id, legal_business_name, phone, email, website_url,
street_address, city, state, postal_code, country,
logo_url, logo_url_dark, default_email_signature, invoice_footer_notes,
hero_tagline, about_headline, about_subheadline, custom_footer_text,
show_wholesale_link, show_zip_search, show_schedule_pdf, show_text_alerts,
schedule_pdf_notes, hero_image_url,
brand_primary_color, brand_secondary_color, brand_bg_color, brand_text_color,
collect_sales_tax, nexus_states
)
VALUES (
gen_random_uuid(), p_brand_id, p_legal_business_name, p_phone, p_email, p_website_url,
p_street_address, p_city, p_state, p_postal_code, p_country,
p_logo_url, p_logo_url_dark, p_default_email_signature, p_invoice_footer_notes,
p_hero_tagline, p_about_headline, p_about_subheadline, p_custom_footer_text,
COALESCE(p_show_wholesale_link, TRUE), COALESCE(p_show_zip_search, TRUE),
COALESCE(p_show_schedule_pdf, TRUE), COALESCE(p_show_text_alerts, FALSE),
p_schedule_pdf_notes, p_hero_image_url,
p_brand_primary_color, p_brand_secondary_color, p_brand_bg_color, p_brand_text_color,
COALESCE(p_collect_sales_tax, FALSE), COALESCE(p_nexus_states, '{CO}'::TEXT[])
)
ON CONFLICT (brand_id) DO UPDATE SET
legal_business_name = COALESCE(p_legal_business_name, brand_settings.legal_business_name),
phone = COALESCE(p_phone, brand_settings.phone),
email = COALESCE(p_email, brand_settings.email),
website_url = COALESCE(p_website_url, brand_settings.website_url),
street_address = COALESCE(p_street_address, brand_settings.street_address),
city = COALESCE(p_city, brand_settings.city),
state = COALESCE(p_state, brand_settings.state),
postal_code = COALESCE(p_postal_code, brand_settings.postal_code),
country = COALESCE(p_country, brand_settings.country),
logo_url = COALESCE(p_logo_url, brand_settings.logo_url),
logo_url_dark = COALESCE(p_logo_url_dark, brand_settings.logo_url_dark),
default_email_signature = COALESCE(p_default_email_signature, brand_settings.default_email_signature),
invoice_footer_notes = COALESCE(p_invoice_footer_notes, brand_settings.invoice_footer_notes),
hero_tagline = COALESCE(p_hero_tagline, brand_settings.hero_tagline),
about_headline = COALESCE(p_about_headline, brand_settings.about_headline),
about_subheadline = COALESCE(p_about_subheadline, brand_settings.about_subheadline),
custom_footer_text = COALESCE(p_custom_footer_text, brand_settings.custom_footer_text),
show_wholesale_link = COALESCE(p_show_wholesale_link, brand_settings.show_wholesale_link),
show_zip_search = COALESCE(p_show_zip_search, brand_settings.show_zip_search),
show_schedule_pdf = COALESCE(p_show_schedule_pdf, brand_settings.show_schedule_pdf),
show_text_alerts = COALESCE(p_show_text_alerts, brand_settings.show_text_alerts),
schedule_pdf_notes = COALESCE(p_schedule_pdf_notes, brand_settings.schedule_pdf_notes),
hero_image_url = COALESCE(p_hero_image_url, brand_settings.hero_image_url),
brand_primary_color = COALESCE(p_brand_primary_color, brand_settings.brand_primary_color),
brand_secondary_color = COALESCE(p_brand_secondary_color, brand_settings.brand_secondary_color),
brand_bg_color = COALESCE(p_brand_bg_color, brand_settings.brand_bg_color),
brand_text_color = COALESCE(p_brand_text_color, brand_settings.brand_text_color),
collect_sales_tax = COALESCE(p_collect_sales_tax, brand_settings.collect_sales_tax),
nexus_states = COALESCE(p_nexus_states, brand_settings.nexus_states)
RETURNING * INTO v_row;
RETURN v_row;
END;
$$;
-- ── 3. Update get_brand_settings ─────────────────────────────────────────────
DROP FUNCTION IF EXISTS public.get_brand_settings(uuid);
CREATE OR REPLACE FUNCTION public.get_brand_settings(
p_brand_id UUID
) RETURNS brand_settings
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
v_row brand_settings;
BEGIN
SELECT *
INTO v_row
FROM brand_settings
WHERE brand_id = p_brand_id;
RETURN v_row;
END;
$$;
-- ── 4. Update get_brand_settings_by_slug (public) ────────────────────────────
DROP FUNCTION IF EXISTS public.get_brand_settings_by_slug(TEXT);
CREATE OR REPLACE FUNCTION public.get_brand_settings_by_slug(
p_brand_slug TEXT
) RETURNS brand_settings
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
v_row brand_settings;
BEGIN
SELECT bs.*
INTO v_row
FROM brand_settings bs
JOIN brands b ON b.id = bs.brand_id
WHERE b.slug = p_brand_slug;
RETURN v_row;
END;
$$;
-- ── 5. Tax Summary RPC ────────────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION public.get_tax_summary(
p_brand_id UUID,
p_start_date DATE,
p_end_date DATE
) RETURNS TABLE (
total_tax_collected NUMERIC,
total_gross_sales NUMERIC,
order_count BIGINT,
tax_by_state JSONB
)
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
BEGIN
RETURN QUERY
WITH taxed AS (
SELECT
o.tax_amount,
o.subtotal,
o.tax_location,
-- Parse state from tax_location (format: 'STATE:CO' or just 'CO')
CASE
WHEN o.tax_location IS NOT NULL AND position('STATE:' IN o.tax_location) > 0 THEN
upper(substr(o.tax_location, position('STATE:' IN o.tax_location) + 7, 2))
WHEN o.tax_location IS NOT NULL AND length(o.tax_location) = 2 THEN
upper(o.tax_location)
WHEN o.tax_location IS NOT NULL AND length(o.tax_location) > 2 THEN
upper(substr(o.tax_location, 1, 2))
ELSE
'UNKNOWN'::TEXT
END AS state_code
FROM orders o
WHERE o.brand_id = p_brand_id
AND o.created_at::DATE >= p_start_date
AND o.created_at::DATE <= p_end_date
AND o.tax_amount IS NOT NULL
AND o.tax_amount > 0
)
SELECT
COALESCE(SUM(t.tax_amount), 0)::NUMERIC AS total_tax_collected,
COALESCE(SUM(t.subtotal), 0)::NUMERIC AS total_gross_sales,
COUNT(DISTINCT t.tax_amount)::BIGINT AS order_count,
COALESCE(JSONB_AGG(
JSONB_BUILD_OBJECT(
'state', state_code,
'total_tax', COALESCE(SUM(t.tax_amount) FILTER (WHERE state_code = t.state_code), 0),
'gross_sales', COALESCE(SUM(t.subtotal) FILTER (WHERE state_code = t.state_code), 0),
'order_count', COUNT(*) FILTER (WHERE state_code = t.state_code)
) ORDER BY state_code
), '[]'::JSONB) AS tax_by_state
FROM taxed t;
END;
$$;
-- ── 6. Taxable Orders RPC ─────────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION public.get_taxable_orders(
p_brand_id UUID,
p_start_date DATE,
p_end_date DATE
) RETURNS TABLE (
order_id UUID,
date TEXT,
customer_name TEXT,
city TEXT,
state TEXT,
taxable_amount NUMERIC,
tax_amount NUMERIC,
tax_rate NUMERIC,
tax_location TEXT
)
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
BEGIN
RETURN QUERY
SELECT
o.id AS order_id,
o.created_at::DATE::TEXT AS date,
o.customer_name,
COALESCE(s.city, ''::TEXT) AS city,
COALESCE(s.state, ''::TEXT) AS state,
o.subtotal AS taxable_amount,
o.tax_amount,
o.tax_rate,
COALESCE(o.tax_location, ''::TEXT) AS tax_location
FROM orders o
LEFT JOIN stops s ON s.id = o.stop_id
WHERE o.brand_id = p_brand_id
AND o.created_at::DATE >= p_start_date
AND o.created_at::DATE <= p_end_date
AND o.tax_amount IS NOT NULL
AND o.tax_amount > 0
ORDER BY o.created_at DESC;
END;
$$;
COMMIT;
@@ -0,0 +1,6 @@
-- 096: Tax Orders Index — partial index for efficient tax queries
-- Note: CREATE INDEX CONCURRENTLY cannot run inside a transaction block
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_tax_brand_date
ON orders (brand_id, created_at)
WHERE tax_amount IS NOT NULL AND tax_amount > 0;

Some files were not shown because too many files have changed in this diff Show More