chore(supabase): full purge — remove all Supabase references from codebase

- Delete supabase/ directory (config.toml, push-migrations.js,
  ADMIN_CREATE_STOP_FIX.sql, 137 archived migration files)
- Remove @supabase/ssr and @supabase/supabase-js from package.json
- Strip *.supabase.co from next.config.ts image hostnames
- Strip https://*.supabase.co from vercel.json CSP connect-src
- Remove 'supabase/**' ignore from eslint.config.mjs
- Clean supabase references from src/lib/db.ts, vitest.config.ts,
  db/seeds/2026-tuxedo-tour-stops.sql, src/actions/storefront.ts,
  scripts/import-tuxedo-stops.ts comments
- Rewrite env-var docs in README, ENVIRONMENT, PRODUCTION_SETUP,
  PRODUCTION_DEPLOYMENT_CHECKLIST, LAUNCH_CHECKLIST, CLAUDE,
  MEMORY, REPORT to drop NEXT_PUBLIC_SUPABASE_URL /
  SUPABASE_SERVICE_ROLE_KEY / supabase link / supabase CLI references

The canonical migration runner is now scripts/migrate.js (uses pg
directly via DATABASE_URL). Migrations live in db/migrations/. The
Supabase CLI is no longer in the codebase. The only remaining
'@supabase/*' in the dep tree is @supabase/auth-js as a transitive
of @neondatabase/auth (Neon Auth / Better Auth).

Final source scan: grep -rln '@supabase\|rest/v1\|supabase\.co'
src/ tests/ db/ scripts/ next.config.ts vercel.json eslint.config.mjs
package.json returns zero. Verification: npm install clean
(11 added, 21 removed, 12 changed), tsc shows same 17 pre-existing
errors (Stripe dahlia API version + fetch preconnect mocks),
vitest 172/175 (3 pre-existing failures in getAdminUser.test.ts),
lint shows same 14 pre-existing errors. Live-tested: dev server
boots in 248ms, public storefronts (/) (/login) (/pricing) (/tuxedo)
(/indian-river-direct) all return 200; /admin/v2?demo=1 reaches 200
after dev_session redirect; storefront renders real brand content.
This commit is contained in:
Nora
2026-06-25 17:48:32 -06:00
parent 68a749f7af
commit 49b8e27219
171 changed files with 136 additions and 28594 deletions
+8 -8
View File
@@ -18,7 +18,7 @@ Route Commerce is a multi-tenant B2B e-commerce platform for fresh produce whole
Tech stack: Next.js 16 (App Router) · **Postgres** (direct) · **Neon Auth** (Better Auth) · Stripe · Square · Resend (email) · Tailwind CSS v4 Tech stack: Next.js 16 (App Router) · **Postgres** (direct) · **Neon Auth** (Better Auth) · Stripe · Square · Resend (email) · Tailwind CSS v4
> **Direction:** Auth is handled by Neon Auth (Better Auth). The `admin_users` table links to Neon Auth users by email. New DB code should connect to Postgres directly (via `pg` or the chosen driver — see Database section) and **must not** import from `@supabase/*` or call Supabase REST. > **Direction:** Auth is handled by Neon Auth (Better Auth). The `admin_users` table links to Neon Auth users by email. New DB code must connect to Postgres directly via the shared `pg` `Pool` from `src/lib/db.ts`. No Supabase JS client, REST gateway, or `*.supabase.co` calls anywhere in the codebase.
--- ---
@@ -33,10 +33,10 @@ npx tsc --noEmit # TypeScript check (no emit)
npx playwright test # Run E2E tests (Playwright) npx playwright test # Run E2E tests (Playwright)
``` ```
> The migrate script (`supabase/push-migrations.js`) now only uses the direct `pg` path — the Supabase CLI branch is legacy. It reads `DATABASE_URL` from `.env.local` via `dotenv`. `pg` is already in devDependencies. > The migrate script is `scripts/migrate.js`. It reads `DATABASE_URL` (or `DATABASE_ADMIN_URL`) from `.env.local` via `dotenv` and applies any `db/migrations/*.sql` files not already recorded in `_migrations`. `pg` is already in devDependencies.
> If a migration fails with "cannot change return type", the function signature changed — drop and recreate it first. > If a migration fails with "cannot change return type", the function signature changed — drop and recreate it first.
**Recent migration work is documented in `MEMORY.md`** (Supabase login + link process, updates to `push-migrations.js` for modern CLI, specific SQL patches made to 091/145/148/200/201 so they would apply cleanly, and which migrations were pushed in the session). Cat `MEMORY.md` for details. **Historical migration work is documented in `MEMORY.md`** (Supabase login + link process, updates to `push-migrations.js` for the modern CLI, specific SQL patches made to 091/145/148/200/201 so they would apply cleanly, and which migrations were pushed). Cat `MEMORY.md` for details.
E2E tests live in `tests/` and run via Playwright. Specs include `tests/smoke.spec.ts` and `tests/login/login-flow.spec.ts`. **Note: `playwright.config.ts` defaults `baseURL` to production** (`https://route-commerce-platform.vercel.app`); override with `PLAYWRIGHT_URL=http://localhost:3000` for local runs, or pass `--config` with a local config. E2E tests live in `tests/` and run via Playwright. Specs include `tests/smoke.spec.ts` and `tests/login/login-flow.spec.ts`. **Note: `playwright.config.ts` defaults `baseURL` to production** (`https://route-commerce-platform.vercel.app`); override with `PLAYWRIGHT_URL=http://localhost:3000` for local runs, or pass `--config` with a local config.
@@ -93,13 +93,13 @@ Server actions are "use server" files that export async functions. Client compon
### Database (Postgres, direct) ### Database (Postgres, direct)
The app connects to **Postgres directly** — no Supabase platform, JS client, or REST gateway. Server actions use the `pg` driver (or whatever the chosen connection layer is) to call `SECURITY DEFINER` PL/pgSQL functions. Storage of files (product images, etc.) is moving to an S3-compatible object store; until that's wired up, image references can stay as URLs. The app connects to **Postgres directly** — no Supabase platform, JS client, or REST gateway. Server actions use the `pg` driver to call `SECURITY DEFINER` PL/pgSQL functions; Drizzle (on top of the same pool) handles typed reads and the per-request `app.current_brand_id` GUC. Storage of files (product images, etc.) is moving to an S3-compatible object store; until that's wired up, image references can stay as URLs.
#### Connection #### Connection
- `DATABASE_URL` in `.env.local` (and hosting dashboard) is the only required DB env var. - `DATABASE_URL` in `.env.local` (and hosting dashboard) is the only required DB env var.
- A single shared `pg` `Pool` is exported from `src/lib/db.ts` (TBD — to be created/confirmed during the migration). Server actions and API routes import it and call `pool.query(...)` against RPC names. - A single shared `pg` `Pool` is exported from `src/lib/db.ts` as `pool` (proxy) and `getPool()` (lazy). Server actions and API routes import it and call `pool.query(...)` against RPC names. The Drizzle client lives at `db/client.ts` and shares the same pool.
- No `NEXT_PUBLIC_SUPABASE_URL` / `SUPABASE_SERVICE_ROLE_KEY` / `@supabase/*` imports — these are being purged from the codebase. - No `NEXT_PUBLIC_SUPABASE_URL` / `SUPABASE_SERVICE_ROLE_KEY` / `@supabase/*` imports — none exist anywhere in the codebase.
#### First production deploy / new prod DB bootstrap (critical for admin access) #### First production deploy / new prod DB bootstrap (critical for admin access)
@@ -255,7 +255,7 @@ For annual pricing, create separate annual prices in Stripe (e.g., $441/yr for S
### Communications Module ("Harvest Reach") ### Communications Module ("Harvest Reach")
The communications system (`/admin/communications`) uses a separate set of tables that are **NOT protected by RLS** — they rely entirely on the SECURITY DEFINER RPCs + application-layer brand scoping. Key tables: `communication_campaigns`, `communication_templates`, `communication_contacts`, `communication_message_logs`. (The "no RLS" framing carries over from the Supabase era; on raw Postgres this just means no row-level policies — scoping is still enforced by RPC + app layer.) The communications system (`/admin/communications`) uses a separate set of tables that have **no row-level policies** — they rely entirely on the SECURITY DEFINER RPCs + application-layer brand scoping. Key tables: `communication_campaigns`, `communication_templates`, `communication_contacts`, `communication_message_logs`. Scoping is enforced by RPC + app layer.
`send_campaign` / `send_stop_blast` RPCs insert into `communication_message_logs` but do NOT populate `event_id`. The Resend webhook (`src/app/api/resend/webhook/route.ts`) must therefore look up logs by `customer_email + subject + created_at` (7-day window), not by `event_id`. `send_campaign` / `send_stop_blast` RPCs insert into `communication_message_logs` but do NOT populate `event_id`. The Resend webhook (`src/app/api/resend/webhook/route.ts`) must therefore look up logs by `customer_email + subject + created_at` (7-day window), not by `event_id`.
@@ -282,7 +282,7 @@ Separate from orders/stops — tracks irrigation/water usage per brand. `src/act
## Key Conventions ## Key Conventions
- All DB access goes through a shared `pg` `Pool` (see Database section). Server actions call SECURITY DEFINER RPCs via `pool.query('SELECT * FROM fn_name($1, $2)', [...])`. Do not introduce `@supabase/*` imports or REST fetch to `*/rest/v1/`. - All DB access goes through a shared `pg` `Pool` (see Database section). Server actions call SECURITY DEFINER RPCs via `pool.query('SELECT * FROM fn_name($1, $2)', [...])`. Do not introduce `@supabase/*` imports, REST fetches, or `*.supabase.co` calls anywhere in the codebase.
- `gen_random_uuid()` used in migrations for primary keys - `gen_random_uuid()` used in migrations for primary keys
- Migrations use `CREATE OR REPLACE FUNCTION` for idempotency — never `DROP` then `CREATE` - Migrations use `CREATE OR REPLACE FUNCTION` for idempotency — never `DROP` then `CREATE`
- Status enums stored as TEXT — no PostgreSQL ENUM type - Status enums stored as TEXT — no PostgreSQL ENUM type
+9 -15
View File
@@ -10,15 +10,6 @@ Copy `.env.example` → `.env.local` and fill in the values.
These are safe to commit and can be used in client bundles. Prefix with `NEXT_PUBLIC_`. These are safe to commit and can be used in client bundles. Prefix with `NEXT_PUBLIC_`.
### `NEXT_PUBLIC_SUPABASE_URL`
Your Supabase project URL.
- **Where to get:** Supabase Dashboard → Project Settings → API → Project URL
- **Example:** `https://abc123.supabase.co`
### `NEXT_PUBLIC_SUPABASE_ANON_KEY`
Supabase anonymous (anon) key — safe for client-side use.
- **Where to get:** Supabase Dashboard → Project Settings → API → anon key
### `NEXT_PUBLIC_BASE_URL` ### `NEXT_PUBLIC_BASE_URL`
The base URL of your deployment. Used for OAuth redirects and webhook URLs. The base URL of your deployment. Used for OAuth redirects and webhook URLs.
- **Local:** `http://localhost:3000` - **Local:** `http://localhost:3000`
@@ -40,12 +31,16 @@ Set to a brand slug to hide other brand routes in single-brand mode.
**Never prefix with `NEXT_PUBLIC_`**. These must only be accessed in Server Components, Server Actions, or API Routes. **Never prefix with `NEXT_PUBLIC_`**. These must only be accessed in Server Components, Server Actions, or API Routes.
### Supabase ### Database
#### `SUPABASE_SERVICE_ROLE_KEY` #### `DATABASE_URL`
Full service role key — grants DB admin access bypassing RLS. Full Postgres connection string. The app uses this exclusively (no Supabase, no JS client) for every server-side query — server actions and API routes import a shared `pg` `Pool` from `src/lib/db.ts` and call `SECURITY DEFINER` PL/pgSQL functions.
- **Where to get:** Supabase Dashboard → Project Settings → API → service_role key - **Where to get:** Neon Dashboard → Project → Connection Details → Connection string (pooled)
- **⚠️ Critical:** Never expose this to the client. Used only in server-side code. - **Example:** `postgresql://user:pass@ep-xxx.us-east-2.aws.neon.tech/routecommerce?sslmode=require`
- **Format:** `postgres://user:pass@host:port/dbname?sslmode=require`
#### `DATABASE_ADMIN_URL` (optional)
Same connection string but with elevated perms (typically the direct, non-pooled Neon connection). Only used by `scripts/migrate.js` for DDL — fall back to `DATABASE_URL` if unset.
### Stripe ### Stripe
@@ -168,7 +163,6 @@ Controls whether to use Square sandbox or production.
- **Wrong Stripe key type:** Using `sk_live_` in development or `sk_test_` in production will silently fail. Always match key type to environment. - **Wrong Stripe key type:** Using `sk_live_` in development or `sk_test_` in production will silently fail. Always match key type to environment.
- **Stripe price IDs drift:** If you create new prices in Stripe Dashboard but forget to update `.env.local`, billing will fail. Keep them in sync. - **Stripe price IDs drift:** If you create new prices in Stripe Dashboard but forget to update `.env.local`, billing will fail. Keep them in sync.
- **SQUARE_ENVIRONMENT mismatch:** Production Square credentials won't work with `sandbox`. Match it to your app secret type. - **SQUARE_ENVIRONMENT mismatch:** Production Square credentials won't work with `sandbox`. Match it to your app secret type.
- **SUPABASE_SERVICE_ROLE_KEY in client:** If you ever see `SUPABASE_SERVICE_ROLE_KEY` in a browser bundle, it's a critical security incident. The key was exposed server-side. Rotate it immediately in Supabase Dashboard.
- **OPENAI_API_KEY for AI features:** AI features won't work without this. The AI Intelligence Pack add-on requires a valid OpenAI key. - **OPENAI_API_KEY for AI features:** AI features won't work without this. The AI Intelligence Pack add-on requires a valid OpenAI key.
--- ---
+7 -7
View File
@@ -8,15 +8,15 @@ This document summarizes the complete Launch & Marketing Layer implementation fo
## ✅ Implementation Status ## ✅ Implementation Status
### 1. Supabase Foundation ✓ ### 1. Database & Auth Foundation ✓
| Component | Status | Location | | Component | Status | Location |
|-----------|--------|----------| |-----------|--------|----------|
| Auth Flow | ✓ Complete | Existing dev_session + Supabase Auth | | Auth Flow | ✓ Complete | Neon Auth (Better Auth) + dev_session for local |
| Database Structure | ✓ Complete | Supabase migrations in place | | Database Structure | ✓ Complete | `db/migrations/` applied via `pg` |
| Role-Based Access | ✓ Complete | admin-permissions.ts system | | Role-Based Access | ✓ Complete | `admin-permissions.ts` system |
| Protected Routes | ✓ Complete | middleware.ts | | Protected Routes | ✓ Complete | `middleware.ts` |
| Server Actions Pattern | ✓ Complete | src/actions/*.ts | | Server Actions Pattern | ✓ Complete | `src/actions/*.ts` |
### 2. Launch-Ready Features ✓ ### 2. Launch-Ready Features ✓
@@ -237,7 +237,7 @@ Track these metrics post-launch:
- **Documentation**: See CLAUDE.md for technical details - **Documentation**: See CLAUDE.md for technical details
- **Stripe Dashboard**: https://dashboard.stripe.com - **Stripe Dashboard**: https://dashboard.stripe.com
- **Supabase Dashboard**: https://app.supabase.com/project/wnzkhezyhnfzhkhiflrp - **Neon Console**: https://console.neon.tech
- **Vercel Deployments**: https://vercel.com/dashboard - **Vercel Deployments**: https://vercel.com/dashboard
--- ---
+43 -46
View File
@@ -2,7 +2,7 @@
This file captures key context, decisions, fixes, and state from recent work so it survives across conversations. This file captures key context, decisions, fixes, and state from recent work so it survives across conversations.
**Last updated:** 2026-06 (admin_users schema fix + migration reliability + Google sign-in work) **Last updated:** 2026-06 (admin_users schema fix + migration reliability + Google sign-in work; full Supabase purge)
## 2026-06: `admin_users` schema — extra columns for create-user flow (migration 0043) ## 2026-06: `admin_users` schema — extra columns for create-user flow (migration 0043)
@@ -48,80 +48,79 @@ See also the plan doc referenced in deploy.yml for the broader reliability work.
## 🚨 Direction Pivot (2026-06-06) — Supabase → Postgres ## 🚨 Direction Pivot (2026-06-06) — Supabase → Postgres
The platform is moving off Supabase entirely. We are connecting to **Postgres directly** (via `pg`), with **Auth.js (NextAuth v5)** handling authentication. See `CLAUDE.md` for the full updated architecture. **Status: COMPLETE (2026-06).** All Supabase references removed from the codebase:
- `supabase/` directory deleted (`config.toml`, `push-migrations.js`, `ADMIN_CREATE_STOP_FIX.sql`, and 137 archived migration files in `supabase/migrations/.archived/` are gone).
- `@supabase/ssr` and `@supabase/supabase-js` removed from `package.json`.
- `next.config.ts` no longer whitelists `*.supabase.co` for images; `vercel.json` CSP no longer allows `https://*.supabase.co` in `connect-src`.
- `eslint.config.mjs` no longer ignores `supabase/**`.
- All `src/`, `tests/`, and `db/` files are free of `@supabase/*` imports, `rest/v1/` calls, and `*.supabase.co` references.
- Migrations are now applied by `scripts/migrate.js` (which uses `pg` + `DATABASE_URL`); the Supabase CLI branch is gone with the deleted script.
### What changes immediately This section is kept as **historical record** of the cutover. The "Supabase CLI + Migrations Tooling" section further below is also historical.
- **DB connection**: `DATABASE_URL` is the only required DB env var. No more `NEXT_PUBLIC_SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, or `SUPABASE_ANON_KEY`.
- **Migrations**: only the `pg` direct path in `supabase/push-migrations.js` is supported going forward. The Supabase CLI branch is dead code. The script reads `DATABASE_URL` from `.env.local` via `dotenv`.
- **Code**: no new `@supabase/*` imports, no `rest/v1/` REST fetch, no Supabase JS client usage. Use a shared `pg` `Pool` (target location: `src/lib/db.ts`, **TBD — create during the cutover**).
- **Auth**: legacy `rc_auth_uid` cookie + bespoke `/api/login` is being replaced by Auth.js. Until the Auth.js migration ships, the `dev_session` cookie remains the source of truth.
- **Storage**: Supabase Storage (e.g. the `product-images` bucket created in migration 145) is going away. Need an S3-compatible alternative — **TBD**.
### What's TBD / needs follow-up ### Original pivot summary (kept for context)
- [ ] `DATABASE_URL` for local dev (Neon? local Postgres? — user hasn't specified the Postgres host yet) The platform moved off Supabase entirely. We connect to **Postgres directly** (via `pg`), with **Neon Auth (Better Auth)** handling authentication. See `CLAUDE.md` for the current architecture.
- [ ] New connection layer: raw `pg` Pool vs Drizzle vs Prisma vs Neon serverless driver — **not decided**
- [ ] Auth.js migration actually landing (currently "in progress" per CLAUDE.md) ### What changed at the time
- [ ] Where do product images, brand logos, etc. live now? S3? Cloudflare R2? Re-encode as URL strings? - **DB connection**: `DATABASE_URL` (with `DATABASE_ADMIN_URL` optional override for DDL) is the only required DB env var. No more `NEXT_PUBLIC_SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, or `SUPABASE_ANON_KEY`.
- [ ] Whether the Supabase project (`wnzkhezyhnfzhkhiflrp`) gets shut down or kept read-only for the transition - **Migrations**: `scripts/migrate.js` is the canonical runner. It reads `DATABASE_URL` from `.env.local` via `dotenv`, tracks applied files in `_migrations`, and applies any new files in `db/migrations/` in lexical order inside a transaction.
- [ ] Cutover sequencing: do we delete `@supabase/*` from `package.json` in one PR or incrementally? - **Code**: zero `@supabase/*` imports, zero `rest/v1/` REST fetches, zero Supabase JS client usage. One shared `pg` `Pool` in `src/lib/db.ts`; Drizzle on top of it at `db/client.ts` for typed reads + RLS-style brand scoping.
- **Auth**: Neon Auth (Better Auth) is the production path. `dev_session` cookie is the local-only bypass.
- **Storage**: object store migration (S3-compatible) is independent of the Supabase purge and is tracked separately.
### Migration content that's now obsolete ### Migration content that's now obsolete
- **145 (product-images bucket)**: Supabase Storage bucket + RLS policies. Replaced by object store of choice. - **145 (product-images bucket)**: Supabase Storage bucket + RLS policies. Replaced by object store of choice.
- **Any RLS policy on tables** (200 added several): the "no RLS, app-layer scoping" model still holds but the policies are inert in the new world. - **Any RLS policy on tables** (200 added several): the "no row-level policies, app-layer scoping" model still holds but the policies are inert in the new world.
- The `supabase link --project-ref wnzkhezyhnfzhkhiflrp` setup is no longer needed for ongoing work.
### Historical sections below ### Historical sections below
The "Supabase CLI + Migrations Tooling" section that used to live at the top of this file describes the *previous* tooling. It is kept below as **historical record** of work that was already applied to the Supabase project. Do **not** follow its CLI instructions — use the `pg` direct path instead. Migration-file patch notes (091, 145, 148, 200, 201) are also kept as historical record of what got applied. The "Supabase CLI + Migrations Tooling" section further down describes the *previous* tooling. It is kept as **historical record** of work that was already applied to the Supabase project. Do **not** follow its CLI instructions — use `scripts/migrate.js` (or `npm run migrate`) instead. Migration-file patch notes (091, 145, 148, 200, 201) are also kept as historical record of what got applied.
--- ---
## Supabase CLI + Migrations Tooling *(SUPERSEDED — see Direction Pivot above)* ## Supabase CLI + Migrations Tooling *(SUPERSEDED — see Direction Pivot above; script and CLI are no longer in the repo)*
### Login + Link (done in this session) ### Login + Link (done in this session)
- User ran `supabase login` - User ran `supabase login`
- We ran: `supabase link --project-ref wnzkhezyhnfzhkhiflrp` - We ran: `supabase link --project-ref wnzkhezyhnfzhkhiflrp`
- Link succeeded. Project appears as **LINKED** (●) in `supabase projects list`. - Link succeeded. Project appears as **LINKED** (●) in `supabase projects list`.
- Link state lives under `supabase/.temp/` (project-ref, linked-project.json, pooler-url, etc.). **Not** the legacy `.supabase/config.toml` at project root. - Link state lived under `supabase/.temp/` (project-ref, linked-project.json, pooler-url, etc.). **Not** the legacy `.supabase/config.toml` at project root.
- This enables `supabase db query --linked`, `supabase migration list`, etc. - This enabled `supabase db query --linked`, `supabase migration list`, etc.
### Important Environment Note ### Important Environment Note
- Direct Postgres connections (`db.wnzkhezyhnfzhkhiflrp.supabase.co:5432` using service role key as password) **do not work** from this workspace: - Direct Postgres connections (`db.wnzkhezyhnfzhkhiflrp.supabase.co:5432` using service role key as password) **do not work** from this workspace:
- `getaddrinfo ENOTFOUND` - `getaddrinfo ENOTFOUND`
- IPv6 "network is unreachable" in some cases - IPv6 "network is unreachable" in some cases
- HTTPS / Supabase REST / Management API work fine. - HTTPS / Supabase REST / Management API work fine.
- Therefore the `push-migrations.js` **CLI path** (using linked project) is the only reliable way to apply migrations here. - Therefore the `push-migrations.js` **CLI path** (using linked project) was the only reliable way to apply migrations here.
### Updated Migration Script ### Migration Script (deleted 2026-06 — use `scripts/migrate.js` instead)
File: `supabase/push-migrations.js` File: `supabase/push-migrations.js` *(deleted)*
Key changes: Key behavior (historical):
- Detection logic now recognizes modern link: `supabase/.temp/project-ref` (in addition to old `.supabase/config.toml`). - Detection logic recognized modern link: `supabase/.temp/project-ref` (in addition to old `.supabase/config.toml`).
- `pushWithCli()` rewritten to apply files one-by-one using: - `pushWithCli()` applied files one-by-one using:
```bash ```bash
supabase db query --linked --file "supabase/migrations/NNN_foo.sql" supabase db query --linked --file "supabase/migrations/NNN_foo.sql"
``` ```
(Previously tried `db push --db-url ...` which used direct connection and failed here.) (Previously tried `db push --db-url ...` which used direct connection and failed here.)
- Falls back to direct `pg` only if CLI path fails. - Fell back to direct `pg` only if CLI path failed.
- Header comments updated with current recommended workflow.
**Recommended commands now (Supabase CLI path — being phased out, use `pg` direct path going forward):** **Historical commands (Supabase CLI path — both scripts are gone):**
```bash ```bash
# Supabase CLI path (legacy — do not use going forward) # Supabase CLI path (legacy — script deleted, do not use)
supabase login supabase login
supabase link --project-ref wnzkhezyhnfzhkhiflrp supabase link --project-ref wnzkhezyhnfzhkhiflrp
node supabase/push-migrations.js 148 # CLI path node supabase/push-migrations.js 148 # CLI path (script deleted)
# or # or
npm run migrate:one 148 npm run migrate:one 148
``` ```
```bash ```bash
# Direct pg path (this is the future — only the pg branch is kept alive in the script) # Direct pg path (this was the future — now the only path, via scripts/migrate.js)
DATABASE_URL=postgres://... node supabase/push-migrations.js 148
# or
DATABASE_URL=postgres://... npm run migrate:one 148 DATABASE_URL=postgres://... npm run migrate:one 148
``` ```
`npm run migrate` (no arg) will push every `*.sql` in order (use with caution). `npm run migrate` (no arg) pushes every `*.sql` in `db/migrations/` in order (use with caution).
--- ---
@@ -210,16 +209,14 @@ Verification queries (post-apply) confirmed:
--- ---
## Current State / Gotchas (2026-06-06) ## Current State / Gotchas (2026-06)
- The Supabase CLI is no longer the recommended path. Use `DATABASE_URL` + `pg` directly. The `supabase/` directory is kept as a path for migrations tooling only. - Use `DATABASE_URL` + `pg` directly (via `scripts/migrate.js`). The Supabase CLI and `supabase/push-migrations.js` are no longer in the repo.
- The Postgres host/URL for local dev is **TBD** (not yet decided by the user). Until it's set, `npm run migrate` will fail at the `pg` connect step. (The Supabase project at `wnzkhezyhnfzhkhiflrp` may still exist as a fallback read-only target — unconfirmed.) - `npm run migrate` applies every pending `db/migrations/*.sql` in lexical order inside transactions; `_migrations` tracks which files are already applied.
- `supabase migration list` will show a lot of "pending" because the project's custom numeric migrations (00x_*.sql) were historically applied via the raw-SQL push script, not registered in Supabase's `schema_migrations` tracking table. This is mostly irrelevant now that we're moving off Supabase. - Many migrations are intentionally written to be re-runnable (`CREATE OR REPLACE FUNCTION`, `CREATE TABLE IF NOT EXISTS`, guarded triggers). Re-pushing a prefix is safe.
- Many migrations are intentionally written to be re-runnable. Re-pushing a prefix is the supported workflow for this project — this still holds under direct `pg`. - When adding **new** migrations, use the established numeric prefix style (`NNN_descriptive_name.sql`) and place them in `db/migrations/`. No Supabase CLI command is involved.
- When adding **new** migrations, use the established `supabase/push-migrations.js` + numeric prefix style (`NNN_descriptive_name.sql`). Do not introduce `supabase migration new` — that flow is going away with the CLI branch. - Storage policies (145), RLS policies (200), SECURITY DEFINER functions, and brand-scoped data are still in Postgres — test carefully after big applies. Brand scoping still relies on `p_brand_id` parameters in RPCs (plus the per-request `app.current_brand_id` GUC used by Drizzle).
- Storage policies (145), RLS policies (200), SECURITY DEFINER functions, and brand-scoped data are still in Postgres — test carefully after big applies. Brand scoping still relies on `p_brand_id` parameters in RPCs.
- CLAUDE.md already documents the overall migrate story and the `get_brand_settings` gotcha. - CLAUDE.md already documents the overall migrate story and the `get_brand_settings` gotcha.
- **Open question for next session:** confirm Postgres host + connection layer (raw `pg` vs Drizzle/Prisma) and start the actual cutover (drop `@supabase/*` deps, create `src/lib/db.ts`, replace cookie auth with Auth.js).
--- ---
@@ -234,9 +231,9 @@ Verification queries (post-apply) confirmed:
## Unrelated / Other Changes in Tree (as of last git status) ## Unrelated / Other Changes in Tree (as of last git status)
(See `git status --short` and `git diff` for full picture. Includes stop-related action + RPC fixes, various debug scripts in `scripts/`, `supabase/ADMIN_CREATE_STOP_FIX.sql`, a pricing assessment doc, etc.) (See `git status --short` and `git diff` for full picture. Includes stop-related action + RPC fixes, various debug scripts in `scripts/`, a pricing assessment doc, etc.)
This MEMORY.md focuses on the Supabase login / link / migration tooling + SQL repair work from the immediate request. This MEMORY.md originally focused on the Supabase login / link / migration tooling + SQL repair work from the immediate request. The pivot section now records the completion of the Supabase → Postgres migration.
--- ---
+19 -24
View File
@@ -2,7 +2,7 @@
**Platform Version:** 1.6 **Platform Version:** 1.6
**Last Updated:** 2026-05-13 **Last Updated:** 2026-05-13
**Environment:** Next.js 16 (App Router) · Supabase · Stripe · Square · Resend · FedEx **Environment:** Next.js 16 (App Router) · Postgres (direct via `pg`) · Neon Auth (Better Auth) · Stripe · Square · Resend · FedEx
--- ---
@@ -11,14 +11,14 @@
Apply migrations in number order. All numbered `001``092` are required. Apply migrations in number order. All numbered `001``092` are required.
```bash ```bash
# Push all migrations (sequential) # Push all migrations (sequential, no Supabase CLI required)
npm run migrate:one 001 npm run migrate:one 001
npm run migrate:one 002 npm run migrate:one 002
# ... through ... # ... through ...
npm run migrate:one 092 npm run migrate:one 092
# Or push all at once via Supabase CLI # Or push all at once
supabase db push npm run migrate
``` ```
**Key migrations (last 15):** **Key migrations (last 15):**
@@ -53,10 +53,11 @@ Copy `.env.local.example` to `.env.local` and fill in all values.
| Variable | Required | Description | | Variable | Required | Description |
|---|---|---| |---|---|---|
| `NEXT_PUBLIC_SUPABASE_URL` | Yes | Supabase project URL (e.g., `https://xxx.supabase.co`) | | `DATABASE_URL` | Yes | Postgres connection string (e.g., `postgresql://user:pass@host:5432/dbname?sslmode=require`) |
| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Yes | Supabase anon/public key (safe to expose in browser) | | `DATABASE_ADMIN_URL` | No | Direct (non-pooled) Postgres connection with elevated perms. Only used by `scripts/migrate.js` for DDL — falls back to `DATABASE_URL` if unset. |
| `SUPABASE_SERVICE_ROLE_KEY` | Yes | Supabase service role key — **never expose in browser**, server-side only | | `NEON_AUTH_BASE_URL` | Yes | Neon Auth (Better Auth) base URL |
| `SUPABASE_PAT` | Yes | Supabase Personal Access Token for migrations CLI | | `NEON_AUTH_COOKIE_SECRET` | Yes | Neon Auth session cookie signing secret (min 32 chars) |
| `NEXT_PUBLIC_SITE_URL` | Yes | Public site URL used for auth redirects and Origin header |
### Payments ### Payments
@@ -198,17 +199,13 @@ For scheduled tasks (stop reminders, campaign sends), add to `vercel.json` or co
} }
``` ```
**Note:** Stop reminder emails are triggered by a Supabase time-based trigger (`send_stop_reminder_email`). Verify the trigger is active: **Note:** Stop reminder emails are fired by Vercel cron jobs (`/api/cron/send-stop-reminders`), not by a database trigger. Verify the cron is configured in `vercel.json` (or Vercel Dashboard → Project → Cron Jobs).
```sql
SELECT * FROM pg_publication_tables WHERE tablename = 'stops';
```
If not, recreate: `ALTER PUBLICATION supabase_realtime PUBLISH INSERT, UPDATE ON TABLE stops;`
--- ---
## 5. Feature Flag Enablement (Post-Deploy) ## 5. Feature Flag Enablement (Post-Deploy)
After deployment, enable add-ons per brand via the admin UI or Supabase SQL: After deployment, enable add-ons per brand via the admin UI or direct Postgres:
```sql ```sql
-- Enable Harvest Reach for a brand -- Enable Harvest Reach for a brand
@@ -302,13 +299,13 @@ Vercel keeps 10 recent deployments. For critical regressions, promote the previo
### Database Rollback ### Database Rollback
```bash ```bash
# Revert last migration via Supabase # Revert last migration via Postgres
supabase db reset --backup-id <backup-before-migration> # (Restore from a `pg_dump` snapshot taken before the migration was applied)
# Or manually revert (example for migration 090) # Or manually revert (example for migration 090)
psql $DATABASE_URL -c "ALTER TABLE brand_settings DROP COLUMN IF EXISTS schedule_pdf_notes;" psql $DATABASE_URL -c "ALTER TABLE brand_settings DROP COLUMN IF EXISTS schedule_pdf_notes;"
``` ```
> **Important:** Only use `supabase db reset` on staging. In production, manually revert individual migrations to avoid data loss. > **Important:** Always restore from a known-good backup. In production, manually revert individual migrations to avoid data loss.
### Environment Variable Rollback ### Environment Variable Rollback
Environment variables are version-controlled in Vercel. Restore a previous version in **Vercel Dashboard → Project → Environment Variables → History**. Environment variables are version-controlled in Vercel. Restore a previous version in **Vercel Dashboard → Project → Environment Variables → History**.
@@ -318,16 +315,16 @@ Environment variables are version-controlled in Vercel. Restore a previous versi
## 8. Known Limitations & Future Work ## 8. Known Limitations & Future Work
### Known Issues ### Known Issues
- **`dev_session` auth**: The `dev_session` cookie bypasses real Supabase auth. In production, ensure no `dev_session` cookie exists for regular users. The auth bypass only works when `NODE_ENV !== "production"`. - **`dev_session` auth**: The `dev_session` cookie bypasses real Neon Auth (Better Auth). In production, ensure no `dev_session` cookie exists for regular users. The auth bypass only works when `NODE_ENV !== "production"`.
- **Water Log visible to all brand admins**: Currently Tuxedo-only by brand ID check. If another brand admin needs access, the check needs to be made configurable. - **Water Log visible to all brand admins**: Currently Tuxedo-only by brand ID check. If another brand admin needs access, the check needs to be made configurable.
- **Communications tables (no RLS)**: `communication_campaigns`, `communication_templates`, `communication_contacts`, `communication_message_logs` have RLS disabled. All data access is through SECURITY DEFINER RPCs. Do not expose these tables via direct Supabase queries. - **Communications tables (no RLS)**: `communication_campaigns`, `communication_templates`, `communication_contacts`, `communication_message_logs` have no row-level policies. All data access is through SECURITY DEFINER RPCs. Do not expose these tables via direct SQL from client-side code.
- **`event_id` not populated in message logs**: `send_campaign` and `send_stop_blast` do not set `event_id`. The Resend webhook looks up logs by `customer_email + subject` instead. This is a known limitation — if Resend events are delayed, the lookup may fail to match. - **`event_id` not populated in message logs**: `send_campaign` and `send_stop_blast` do not set `event_id`. The Resend webhook looks up logs by `customer_email + subject` instead. This is a known limitation — if Resend events are delayed, the lookup may fail to match.
- **Square sync is one-directional**: Products import from Square → Route Commerce, but product edits in Route Commerce do not push back to Square. - **Square sync is one-directional**: Products import from Square → Route Commerce, but product edits in Route Commerce do not push back to Square.
- **SMS opt-in defaults to `FALSE`**: `communication_contacts.sms_opt_in` defaults to `false`. Users must explicitly opt in for SMS campaigns. - **SMS opt-in defaults to `FALSE`**: `communication_contacts.sms_opt_in` defaults to `false`. Users must explicitly opt in for SMS campaigns.
- **`brand_id: null` for platform admins**: `getAdminUser()` returns `brand_id: null` for `platform_admin` role. Always pass explicit `brandId` to server action functions. - **`brand_id: null` for platform admins**: `getAdminUser()` returns `brand_id: null` for `platform_admin` role. Always pass explicit `brandId` to server action functions.
- **Square webhook signature mismatch**: The Square webhook handler uses `crypto.createHmac("sha256", signatureKey)` but Square sends the signature as base64-encoded. Verify signature encoding matches Square's documentation before enabling Square webhooks in production. - **Square webhook signature mismatch**: The Square webhook handler uses `crypto.createHmac("sha256", signatureKey)` but Square sends the signature as base64-encoded. Verify signature encoding matches Square's documentation before enabling Square webhooks in production.
- **Stripe webhook reliability**: Stripe webhook delivery is not guaranteed to be in-order or immediate. The webhook handler is idempotent — it can safely receive duplicate events. Consider implementing a webhook event log table to track processed event IDs for deduplication. - **Stripe webhook reliability**: Stripe webhook delivery is not guaranteed to be in-order or immediate. The webhook handler is idempotent — it can safely receive duplicate events. Consider implementing a webhook event log table to track processed event IDs for deduplication.
- **`NO_RLS` communications tables**: All communications data is accessible via SECURITY DEFINER RPCs only — never expose direct Supabase queries to these tables in client-side code. - **`NO_RLS` communications tables**: All communications data is accessible via SECURITY DEFINER RPCs only — never expose direct SQL to these tables in client-side code.
### Future Work (Non-Blocking) ### Future Work (Non-Blocking)
- **Cursor-based pagination**: Replace offset-based pagination (`page * limit`) with cursor-based pagination for large datasets (orders, contacts) - **Cursor-based pagination**: Replace offset-based pagination (`page * limit`) with cursor-based pagination for large datasets (orders, contacts)
@@ -344,11 +341,9 @@ Environment variables are version-controlled in Vercel. Restore a previous versi
| Role | Contact | | Role | Contact |
|------|---------| |------|---------|
| Platform Admin | Set via Supabase Auth dashboard | | Platform Admin | First Neon Auth user becomes the platform admin via `provision-admin.ts` |
| Kyle Martinez | Primary contact — Route Commerce owner | | Kyle Martinez | Primary contact — Route Commerce owner |
| Supabase Support | supabase.com/support | | Neon Support | neon.tech/support |
| Vercel Support | vercel.com/support | | Vercel Support | vercel.com/support |
For Vercel production incidents: **vercel.com/deployments** → find deployment → "Support" button. For Vercel production incidents: **vercel.com/deployments** → find deployment → "Support" button.
For Supabase production incidents: **supabase.com/dashboard** → project → "Support" tab.
+14 -14
View File
@@ -15,16 +15,16 @@ npm run dev
## Configuration ## Configuration
### 1. Clerk Authentication ### 1. Neon Auth (Better Auth)
1. Sign up at [Clerk](https://clerk.com) 1. Set up Neon Auth on your Neon project (`neonctl neon-auth init`)
2. Create a new application 2. Copy keys to `.env.local`:
3. Copy keys to `.env.local`:
``` ```
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_xxx NEON_AUTH_BASE_URL=https://xxx.neonauth.io
CLERK_SECRET_KEY=sk_test_xxx NEON_AUTH_COOKIE_SECRET=<openssl rand -base64 32>
NEXT_PUBLIC_SITE_URL=https://yourdomain.com
``` ```
4. Configure middleware in `src/proxy.ts` 3. Configure middleware in `src/middleware.ts`
### 2. Stripe Payments ### 2. Stripe Payments
@@ -37,15 +37,15 @@ npm run dev
STRIPE_WEBHOOK_SECRET=whsec_xxx STRIPE_WEBHOOK_SECRET=whsec_xxx
``` ```
### 3. Supabase Database ### 3. Database (Postgres / Neon)
1. Create project at [Supabase](https://supabase.com) The app talks to Postgres directly via `pg` (no Supabase, no REST gateway). Point `DATABASE_URL` at any reachable Postgres — Neon, Supabase (the underlying Postgres), RDS, etc. The Supabase JS client and PostgREST are not used.
1. Provision a Postgres database (Neon recommended — it also hosts Neon Auth)
2. Run migrations: `npm run migrate` 2. Run migrations: `npm run migrate`
3. Update `.env.local` with: 3. Update `.env.local` with:
``` ```
NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co DATABASE_URL=postgresql://user:pass@host:5432/dbname?sslmode=require
NEXT_PUBLIC_SUPABASE_ANON_KEY=xxx
SUPABASE_SERVICE_ROLE_KEY=xxx
``` ```
### 4. Optional Services ### 4. Optional Services
@@ -74,7 +74,7 @@ Ensure environment variables are set before deployment.
## Key Files ## Key Files
- `src/proxy.ts` - Clerk middleware - `src/middleware.ts` - Neon Auth (Better Auth) middleware
- `src/lib/stripe-billing.ts` - Stripe integration - `src/lib/stripe-billing.ts` - Stripe integration
- `src/lib/analytics.ts` - PostHog analytics - `src/lib/analytics.ts` - PostHog analytics
- `src/lib/sentry.ts` - Sentry error tracking - `src/lib/sentry.ts` - Sentry error tracking
@@ -82,7 +82,7 @@ Ensure environment variables are set before deployment.
- `src/components/onboarding/OnboardingFlow.tsx` - User onboarding - `src/components/onboarding/OnboardingFlow.tsx` - User onboarding
- `src/components/referral/ReferralSystem.tsx` - Referral tracking - `src/components/referral/ReferralSystem.tsx` - Referral tracking
- `src/components/changelog/ChangelogFeed.tsx` - Product updates - `src/components/changelog/ChangelogFeed.tsx` - Product updates
- `supabase/migrations/` - Database schema - `db/migrations/` - Database schema (applied via `scripts/migrate.js`)
## Scripts ## Scripts
+7 -10
View File
@@ -17,7 +17,7 @@ Route Commerce helps produce brands run their wholesale operations:
## Tech Stack ## Tech Stack
- **Framework:** Next.js 16 (App Router) - **Framework:** Next.js 16 (App Router)
- **Database:** Supabase (Postgres + Auth + RLS) - **Database:** Postgres (direct via `pg` + Drizzle) with Neon Auth (Better Auth)
- **Payments:** Stripe + Square - **Payments:** Stripe + Square
- **Email/SMS:** Resend - **Email/SMS:** Resend
- **Styling:** Tailwind CSS v4 - **Styling:** Tailwind CSS v4
@@ -37,10 +37,8 @@ npm install
Create a `.env.local` file with: Create a `.env.local` file with:
```bash ```bash
# Supabase # Database (Postgres)
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co DATABASE_URL=postgresql://user:pass@host:5432/dbname?sslmode=require
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
# Stripe # Stripe
STRIPE_SECRET_KEY=sk_test_... STRIPE_SECRET_KEY=sk_test_...
@@ -56,10 +54,9 @@ RESEND_API_KEY=re_...
### 3. Set up the database ### 3. Set up the database
Link your Supabase project, then push migrations: The migrations live in `db/migrations/` and are applied via the `pg` driver (no Supabase CLI):
```bash ```bash
supabase link --project-ref <your-project-ref>
npm run migrate npm run migrate
``` ```
@@ -111,7 +108,7 @@ src/
│ ├── admin/ # Admin-specific components │ ├── admin/ # Admin-specific components
│ └── storefront/ # Storefront components │ └── storefront/ # Storefront components
└── lib/ # Utilities, auth, feature flags, formatting └── lib/ # Utilities, auth, feature flags, formatting
supabase/ db/
└── migrations/ # SQL migrations (numbered sequentially) └── migrations/ # SQL migrations (numbered sequentially)
``` ```
@@ -315,8 +312,8 @@ curl -X POST https://route-commerce-platform.vercel.app/api/email-automation/wel
## Notes ## Notes
- All database writes go through **server actions** (`src/actions/`), not the Supabase JS client directly - All database writes go through **server actions** (`src/actions/`) over a shared `pg` `Pool` from `src/lib/db.ts` — no Supabase JS client, no REST gateway
- Dev mode bypasses Supabase auth via `dev_session` cookie — never use this in production - Dev mode bypasses Neon Auth via `dev_session` cookie — never use this in production
- Brand-scoped data is enforced at the application layer via `p_brand_id` parameters in SECURITY DEFINER RPCs - Brand-scoped data is enforced at the application layer via `p_brand_id` parameters in SECURITY DEFINER RPCs
- Display dates use `formatDate()` (MM/DD/YYYY) — never raw `toLocaleDateString()` - Display dates use `formatDate()` (MM/DD/YYYY) — never raw `toLocaleDateString()`
- Migration files are numbered sequentially — never reuse numbers - Migration files are numbered sequentially — never reuse numbers
+12 -1
View File
@@ -4,6 +4,8 @@
**Mission:** Stand up a working Auth.js v5 + Google sign-in flow, then rip out **Mission:** Stand up a working Auth.js v5 + Google sign-in flow, then rip out
all Supabase references from the auth/admin path. all Supabase references from the auth/admin path.
> **Note (2026-06-25):** Auth has since moved from Auth.js (NextAuth v5) to **Neon Auth (Better Auth)** — see `CLAUDE.md` for the current architecture. The Supabase purge described below is complete: `supabase/` directory deleted, `@supabase/*` deps removed from `package.json`, `next.config.ts` / `vercel.json` / `eslint.config.mjs` cleaned of Supabase hostnames and ignore patterns, and the remaining `*.supabase.co` references are limited to historical/educational comments in `MEMORY.md` and `docs/`.
--- ---
## What was built ## What was built
@@ -171,7 +173,7 @@ src/actions/ai/preferences.ts
1. **Provision a `pg` pool in production.** `DATABASE_URL` must be 1. **Provision a `pg` pool in production.** `DATABASE_URL` must be
set in the hosting dashboard (same env var as set in the hosting dashboard (same env var as
`supabase/push-migrations.js` uses). `scripts/migrate.js` uses).
2. **Apply migration 204** if not already applied — adds 2. **Apply migration 204** if not already applied — adds
`email`, `auth_provider`, `auth_subject` columns to `admin_users` `email`, `auth_provider`, `auth_subject` columns to `admin_users`
and the `get_admin_user_for_session` RPC. and the `get_admin_user_for_session` RPC.
@@ -192,6 +194,15 @@ src/actions/ai/preferences.ts
7. **Delete `@supabase/ssr` and `@supabase/supabase-js` from 7. **Delete `@supabase/ssr` and `@supabase/supabase-js` from
`package.json`** (last step once no file imports them). `package.json`** (last step once no file imports them).
### Resolution (2026-06-25)
Follow-ups 6 and 7 (and the data-fetching migration of all 33 files) are
complete. The codebase is now Supabase-free at the source-code level —
`grep -rln '@supabase\|rest/v1' src/` returns zero matches. The only
remaining mentions of "Supabase" are in this report, `MEMORY.md`, and a
handful of historical `docs/superpowers/{plans,specs}/` files, all kept
as historical record of the migration.
--- ---
## Commands ## Commands
-1
View File
@@ -2,7 +2,6 @@
-- Stops: 269 | Locations: 40 -- Stops: 269 | Locations: 40
-- Preferred: npx tsx scripts/import-tuxedo-stops.ts -- Preferred: npx tsx scripts/import-tuxedo-stops.ts
-- Manual apply: psql "$DATABASE_URL" -f db/seeds/2026-tuxedo-tour-stops.sql -- Manual apply: psql "$DATABASE_URL" -f db/seeds/2026-tuxedo-tour-stops.sql
-- supabase db query --linked --file db/seeds/2026-tuxedo-tour-stops.sql
BEGIN; BEGIN;
+1 -1
View File
@@ -180,7 +180,7 @@ data explicitly checks its gate.
cold-start paths; the active `getAdminUser().brand_id` always wins cold-start paths; the active `getAdminUser().brand_id` always wins
on later requests. on later requests.
- No Supabase REST, no service-role keys — all access is direct - No Supabase REST, no service-role keys — all access is direct
through Drizzle over a single `pg` Pool. through Drizzle over a single shared `pg` Pool (see `src/lib/db.ts`).
--- ---
+1 -2
View File
@@ -14,7 +14,6 @@ const eslintConfig = defineConfig([
"next-env.d.ts", "next-env.d.ts",
// Ignore legacy .js scripts that use CommonJS // Ignore legacy .js scripts that use CommonJS
"scripts/**", "scripts/**",
"supabase/**",
"fix-agents.js", "fix-agents.js",
]), ]),
// Allow setState in useEffect for PWA prompts and client-side state initialization // Allow setState in useEffect for PWA prompts and client-side state initialization
@@ -26,7 +25,7 @@ const eslintConfig = defineConfig([
}, },
// Relax some rules for legacy code // Relax some rules for legacy code
{ {
files: ["db/**", "scripts/**", "supabase/**"], files: ["db/**", "scripts/**"],
rules: { rules: {
"@typescript-eslint/no-require-imports": "off", "@typescript-eslint/no-require-imports": "off",
"@typescript-eslint/no-explicit-any": "warn", "@typescript-eslint/no-explicit-any": "warn",
+6 -9
View File
@@ -22,10 +22,6 @@ const nextConfig: NextConfig = {
// Optimize images // Optimize images
images: { images: {
remotePatterns: [ remotePatterns: [
{
protocol: "https",
hostname: "*.supabase.co",
},
{ {
protocol: "https", protocol: "https",
hostname: "images.unsplash.com", hostname: "images.unsplash.com",
@@ -124,11 +120,12 @@ const nextConfig: NextConfig = {
{ source: "/admin/orders/:id", destination: "/admin/v2/orders/:id", permanent: false }, { source: "/admin/orders/:id", destination: "/admin/v2/orders/:id", permanent: false },
{ source: "/admin/stops", destination: "/admin/v2/stops", permanent: false }, { source: "/admin/stops", destination: "/admin/v2/stops", permanent: false },
{ source: "/admin/products", destination: "/admin/v2/products", permanent: false }, { source: "/admin/products", destination: "/admin/v2/products", permanent: false },
// v1 admin pages that hit the dead supabase shim. There are no v2 // v1 admin pages that were removed when the supabase shim was
// equivalents yet (reports / taxes / settings sub-pages are still // deleted. There are no v2 equivalents yet (reports / taxes /
// TBD on the v2 surface), so they redirect to the v2 dashboard // settings sub-pages are still TBD on the v2 surface), so they
// for now. Once those modules ship on v2, the redirects can be // redirect to the v2 dashboard for now. Once those modules ship
// tightened to point at the new pages. // on v2, the redirects can be tightened to point at the new
// pages.
{ source: "/admin/products/:id", destination: "/admin/v2/products", permanent: false }, { source: "/admin/products/:id", destination: "/admin/v2/products", permanent: false },
{ source: "/admin/reports", destination: "/admin/v2", permanent: false }, { source: "/admin/reports", destination: "/admin/v2", permanent: false },
{ source: "/admin/taxes", destination: "/admin/v2", permanent: false }, { source: "/admin/taxes", destination: "/admin/v2", permanent: false },
-2
View File
@@ -34,8 +34,6 @@
"@sentry/nextjs": "^10.55.0", "@sentry/nextjs": "^10.55.0",
"@stripe/react-stripe-js": "^6.6.0", "@stripe/react-stripe-js": "^6.6.0",
"@stripe/stripe-js": "^9.7.0", "@stripe/stripe-js": "^9.7.0",
"@supabase/ssr": "^0.10.2",
"@supabase/supabase-js": "^2.105.3",
"@upstash/ratelimit": "^2.0.8", "@upstash/ratelimit": "^2.0.8",
"@upstash/redis": "^1.38.0", "@upstash/redis": "^1.38.0",
"drizzle-orm": "^0.36.4", "drizzle-orm": "^0.36.4",
+1 -2
View File
@@ -305,8 +305,7 @@ async function main() {
let sql = `-- Tuxedo Corn 2026 Tour Data (from ${xlsxPath})\n`; let sql = `-- Tuxedo Corn 2026 Tour Data (from ${xlsxPath})\n`;
sql += `-- Stops: ${stops.length} | Locations: ${parsedLocations.length}\n`; sql += `-- Stops: ${stops.length} | Locations: ${parsedLocations.length}\n`;
sql += `-- Preferred: npx tsx scripts/import-tuxedo-stops.ts\n`; sql += `-- Preferred: npx tsx scripts/import-tuxedo-stops.ts\n`;
sql += `-- Manual apply: psql "$DATABASE_URL" -f db/seeds/2026-tuxedo-tour-stops.sql\n`; sql += `-- Manual apply: psql "$DATABASE_URL" -f db/seeds/2026-tuxedo-tour-stops.sql\n\n`;
sql += `-- supabase db query --linked --file db/seeds/2026-tuxedo-tour-stops.sql\n\n`;
sql += `BEGIN;\n\n`; sql += `BEGIN;\n\n`;
// Locations first (master directory) // Locations first (master directory)
+2 -9
View File
@@ -3,11 +3,6 @@
/** /**
* Public storefront data accessors. * Public storefront data accessors.
* *
* These replace the legacy `lib/supabase.ts` query-builder shim that the
* tuxedo / indian-river-direct storefront pages used to call client-side.
* The shim was a no-op that returned `{ data: null }`, leaving storefronts
* with empty stops / products and no real brand record.
*
* Each function hits Postgres directly via the shared `pg` pool from * Each function hits Postgres directly via the shared `pg` pool from
* `@/lib/db` and is safe to call from public (unauthenticated) pages * `@/lib/db` and is safe to call from public (unauthenticated) pages
* because the SQL filters by `is_public = true` / `active = true` and * because the SQL filters by `is_public = true` / `active = true` and
@@ -122,10 +117,8 @@ export async function getStorefrontProducts(
/** /**
* Single server action that the storefront Client Components call from * Single server action that the storefront Client Components call from
* their initial `useEffect`. Replaces the three separate `supabase.from(...)` * their initial `useEffect`. Bundles brand lookup + public stops +
* calls that the legacy `lib/supabase.ts` shim used to make — the shim * active products into one round-trip-friendly call.
* returned `{ data: null }` for everything, which is why storefronts
* were showing empty stops / products.
* *
* Returns `{ brand: null, stops: [], products: [] }` if the slug doesn't * Returns `{ brand: null, stops: [], products: [] }` if the slug doesn't
* match any brand — pages fall back to default copy in that case. * match any brand — pages fall back to default copy in that case.
+3 -3
View File
@@ -11,9 +11,9 @@
* const { rows } = await query<MyRow>("SELECT * FROM my_table WHERE id = $1", [id]); * const { rows } = await query<MyRow>("SELECT * FROM my_table WHERE id = $1", [id]);
* *
* Configuration: * Configuration:
* - DATABASE_URL (required) — full Postgres connection string. Same env var * - DATABASE_URL (required) — full Postgres connection string. Used by
* is used by `supabase/push-migrations.js` and any external migration * `scripts/migrate.js` (and any external migration tooling). Format:
* tooling. Format: `postgres://user:pass@host:port/dbname`. * `postgres://user:pass@host:port/dbname`.
* *
* Notes: * Notes:
* - This module is server-only. It must never be imported from a Client * - This module is server-only. It must never be imported from a Client
-8
View File
@@ -1,8 +0,0 @@
# Supabase
.branches
.temp
# dotenvx
.env.keys
.env.local
.env.*.local
-92
View File
@@ -1,92 +0,0 @@
-- ============================================================================
-- 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
@@ -1,408 +0,0 @@
# 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\"}"
@@ -1,201 +0,0 @@
-- ============================================================
-- 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;
$$;
@@ -1,10 +0,0 @@
-- ============================================================
-- 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;
@@ -1,103 +0,0 @@
-- ============================================================
-- 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;
$$;
@@ -1,193 +0,0 @@
-- ============================================================
-- 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;
$$;
@@ -1,342 +0,0 @@
-- ============================================================
-- 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: readable by anyone (for field form), writable by admin RPCs
CREATE POLICY "Headgates are readable by all" ON water_headgates FOR SELECT TO anon USING (true);
CREATE POLICY "Headgates writable by postgres" ON water_headgates FOR ALL TO postgres USING (true);
-- Irrigators: only SECURITY DEFINER RPCs read/write (pin_hash never exposed)
CREATE POLICY "Irrigators writable by postgres" ON water_irrigators FOR ALL TO postgres USING (true);
-- Entries: readable by all (for admin), writable by admin RPCs and field RPC
CREATE POLICY "Entries readable by all" ON water_log_entries FOR SELECT TO anon USING (true);
CREATE POLICY "Entries writable by postgres" ON water_log_entries FOR ALL TO postgres USING (true);
-- ── 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 postgres" ON audit_logs FOR ALL TO postgres USING (true);
-- ── 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;
$$;
@@ -1,263 +0,0 @@
-- =============================================================================
-- 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';
@@ -1,232 +0,0 @@
-- =============================================================================
-- 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';
@@ -1,123 +0,0 @@
-- =============================================================================
-- 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';
@@ -1,88 +0,0 @@
-- =============================================================================
-- 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';
@@ -1,202 +0,0 @@
-- =============================================================================
-- 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';
@@ -1,289 +0,0 @@
-- =============================================================================
-- 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';
@@ -1,103 +0,0 @@
-- =============================================================================
-- 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';
@@ -1,13 +0,0 @@
-- =============================================================================
-- 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'
);
@@ -1,9 +0,0 @@
-- =============================================================================
-- 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';
@@ -1,90 +0,0 @@
-- =============================================================================
-- 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';
@@ -1,662 +0,0 @@
-- =============================================================================
-- 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';
@@ -1,878 +0,0 @@
-- =============================================================================
-- 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';
@@ -1,230 +0,0 @@
-- =============================================================================
-- 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';
@@ -1,75 +0,0 @@
-- =============================================================================
-- 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';
@@ -1,226 +0,0 @@
-- =============================================================================
-- 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';
@@ -1,319 +0,0 @@
-- =============================================================================
-- 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';
@@ -1,646 +0,0 @@
-- ─────────────────────────────────────────────────────────────────────────────
-- 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;
$$;
@@ -1,56 +0,0 @@
-- 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.
@@ -1,146 +0,0 @@
-- 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;
$$;
@@ -1,177 +0,0 @@
-- 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;
$$;
@@ -1,101 +0,0 @@
-- 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;
$$;
@@ -1,185 +0,0 @@
-- 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';
@@ -1,243 +0,0 @@
-- 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';
@@ -1,261 +0,0 @@
-- 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';
@@ -1,268 +0,0 @@
-- 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';
@@ -1,337 +0,0 @@
-- 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';
@@ -1,177 +0,0 @@
-- 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';
@@ -1,384 +0,0 @@
-- 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';
@@ -1,23 +0,0 @@
-- 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;
$$;
@@ -1,106 +0,0 @@
-- 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;
$$;
@@ -1,36 +0,0 @@
-- 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);
@@ -1,334 +0,0 @@
-- 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';
@@ -1,31 +0,0 @@
-- 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';
@@ -1,8 +0,0 @@
-- 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';
@@ -1,86 +0,0 @@
-- 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';
@@ -1,105 +0,0 @@
-- 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';
@@ -1,130 +0,0 @@
-- 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';
@@ -1,10 +0,0 @@
-- 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';
@@ -1,63 +0,0 @@
-- 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 "service_insert_sync_log" ON square_sync_log
FOR INSERT WITH CHECK (true); -- service role bypasses RLS via SECURITY DEFINER
NOTIFY pgrst, 'reload schema';
@@ -1,53 +0,0 @@
-- 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';
@@ -1,748 +0,0 @@
-- 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';
@@ -1,201 +0,0 @@
-- 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';
@@ -1,62 +0,0 @@
-- 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';
@@ -1,211 +0,0 @@
-- 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';
@@ -1,78 +0,0 @@
-- 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';
@@ -1,92 +0,0 @@
-- 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';
@@ -1,124 +0,0 @@
-- 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';
@@ -1,93 +0,0 @@
-- 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';
@@ -1,160 +0,0 @@
-- 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';
@@ -1,154 +0,0 @@
-- 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';
@@ -1,10 +0,0 @@
-- 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';
@@ -1,123 +0,0 @@
-- 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';
@@ -1,162 +0,0 @@
-- 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';
@@ -1,63 +0,0 @@
-- 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';
@@ -1,68 +0,0 @@
-- 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';
@@ -1,167 +0,0 @@
-- 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';
@@ -1,195 +0,0 @@
-- 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';
@@ -1,67 +0,0 @@
-- 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';
@@ -1,163 +0,0 @@
-- 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';
@@ -1,43 +0,0 @@
-- 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';
@@ -1,146 +0,0 @@
-- 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';
@@ -1,160 +0,0 @@
-- 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';
@@ -1,67 +0,0 @@
-- 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';
@@ -1,606 +0,0 @@
-- 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;
ALTER TABLE wholesale_order_items DISABLE ROW LEVEL SECURITY;
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;
ALTER TABLE wholesale_deposits DISABLE ROW LEVEL SECURITY;
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';
@@ -1,278 +0,0 @@
-- 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;
ALTER TABLE stops DISABLE ROW LEVEL SECURITY;
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;
ALTER TABLE wholesale_customers DISABLE ROW LEVEL SECURITY;
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;
ALTER TABLE wholesale_products DISABLE ROW LEVEL SECURITY;
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';
@@ -1,180 +0,0 @@
-- 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';
@@ -1,218 +0,0 @@
-- 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);
@@ -1,185 +0,0 @@
-- 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;
$$;
@@ -1,77 +0,0 @@
-- 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
);
@@ -1,82 +0,0 @@
-- 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
)
);
@@ -1,6 +0,0 @@
-- 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;
@@ -1,9 +0,0 @@
-- 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;
@@ -1,63 +0,0 @@
-- 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;
$$;
@@ -1,22 +0,0 @@
-- 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;
@@ -1,89 +0,0 @@
-- 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).
-- The SECURITY DEFINER RPCs bypass RLS, so no policies are needed.
-- If RLS is needed later, add permissive policies here.
ALTER TABLE communication_segments DISABLE ROW LEVEL SECURITY;
-- 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;
$$;
@@ -1,135 +0,0 @@
-- 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;
$$;

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