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:
@@ -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
|
||||
|
||||
> **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)
|
||||
```
|
||||
|
||||
> 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.
|
||||
|
||||
**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.
|
||||
|
||||
@@ -93,13 +93,13 @@ Server actions are "use server" files that export async functions. Client compon
|
||||
|
||||
### 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
|
||||
|
||||
- `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.
|
||||
- No `NEXT_PUBLIC_SUPABASE_URL` / `SUPABASE_SERVICE_ROLE_KEY` / `@supabase/*` imports — these are being purged from the codebase.
|
||||
- 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 — none exist anywhere in the codebase.
|
||||
|
||||
#### 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")
|
||||
|
||||
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`.
|
||||
|
||||
@@ -282,7 +282,7 @@ Separate from orders/stops — tracks irrigation/water usage per brand. `src/act
|
||||
|
||||
## 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
|
||||
- Migrations use `CREATE OR REPLACE FUNCTION` for idempotency — never `DROP` then `CREATE`
|
||||
- Status enums stored as TEXT — no PostgreSQL ENUM type
|
||||
|
||||
+9
-15
@@ -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_`.
|
||||
|
||||
### `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`
|
||||
The base URL of your deployment. Used for OAuth redirects and webhook URLs.
|
||||
- **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.
|
||||
|
||||
### Supabase
|
||||
### Database
|
||||
|
||||
#### `SUPABASE_SERVICE_ROLE_KEY`
|
||||
Full service role key — grants DB admin access bypassing RLS.
|
||||
- **Where to get:** Supabase Dashboard → Project Settings → API → service_role key
|
||||
- **⚠️ Critical:** Never expose this to the client. Used only in server-side code.
|
||||
#### `DATABASE_URL`
|
||||
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:** Neon Dashboard → Project → Connection Details → Connection string (pooled)
|
||||
- **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
|
||||
|
||||
@@ -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.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
+7
-7
@@ -8,15 +8,15 @@ This document summarizes the complete Launch & Marketing Layer implementation fo
|
||||
|
||||
## ✅ Implementation Status
|
||||
|
||||
### 1. Supabase Foundation ✓
|
||||
### 1. Database & Auth Foundation ✓
|
||||
|
||||
| Component | Status | Location |
|
||||
|-----------|--------|----------|
|
||||
| Auth Flow | ✓ Complete | Existing dev_session + Supabase Auth |
|
||||
| Database Structure | ✓ Complete | Supabase migrations in place |
|
||||
| Role-Based Access | ✓ Complete | admin-permissions.ts system |
|
||||
| Protected Routes | ✓ Complete | middleware.ts |
|
||||
| Server Actions Pattern | ✓ Complete | src/actions/*.ts |
|
||||
| Auth Flow | ✓ Complete | Neon Auth (Better Auth) + dev_session for local |
|
||||
| Database Structure | ✓ Complete | `db/migrations/` applied via `pg` |
|
||||
| Role-Based Access | ✓ Complete | `admin-permissions.ts` system |
|
||||
| Protected Routes | ✓ Complete | `middleware.ts` |
|
||||
| Server Actions Pattern | ✓ Complete | `src/actions/*.ts` |
|
||||
|
||||
### 2. Launch-Ready Features ✓
|
||||
|
||||
@@ -237,7 +237,7 @@ Track these metrics post-launch:
|
||||
|
||||
- **Documentation**: See CLAUDE.md for technical details
|
||||
- **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
|
||||
|
||||
---
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
|
||||
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
|
||||
- **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**.
|
||||
This section is kept as **historical record** of the cutover. The "Supabase CLI + Migrations Tooling" section further below is also historical.
|
||||
|
||||
### What's TBD / needs follow-up
|
||||
- [ ] `DATABASE_URL` for local dev (Neon? local Postgres? — user hasn't specified the Postgres host yet)
|
||||
- [ ] 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)
|
||||
- [ ] Where do product images, brand logos, etc. live now? S3? Cloudflare R2? Re-encode as URL strings?
|
||||
- [ ] Whether the Supabase project (`wnzkhezyhnfzhkhiflrp`) gets shut down or kept read-only for the transition
|
||||
- [ ] Cutover sequencing: do we delete `@supabase/*` from `package.json` in one PR or incrementally?
|
||||
### Original pivot summary (kept for context)
|
||||
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.
|
||||
|
||||
### What changed at the time
|
||||
- **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`.
|
||||
- **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.
|
||||
- **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
|
||||
- **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.
|
||||
- The `supabase link --project-ref wnzkhezyhnfzhkhiflrp` setup is no longer needed for ongoing work.
|
||||
- **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.
|
||||
|
||||
### 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)
|
||||
- User ran `supabase login`
|
||||
- We ran: `supabase link --project-ref wnzkhezyhnfzhkhiflrp`
|
||||
- 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.
|
||||
- This enables `supabase db query --linked`, `supabase migration list`, etc.
|
||||
- Link state lived under `supabase/.temp/` (project-ref, linked-project.json, pooler-url, etc.). **Not** the legacy `.supabase/config.toml` at project root.
|
||||
- This enabled `supabase db query --linked`, `supabase migration list`, etc.
|
||||
|
||||
### Important Environment Note
|
||||
- Direct Postgres connections (`db.wnzkhezyhnfzhkhiflrp.supabase.co:5432` using service role key as password) **do not work** from this workspace:
|
||||
- `getaddrinfo ENOTFOUND`
|
||||
- IPv6 "network is unreachable" in some cases
|
||||
- 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
|
||||
File: `supabase/push-migrations.js`
|
||||
### Migration Script (deleted 2026-06 — use `scripts/migrate.js` instead)
|
||||
File: `supabase/push-migrations.js` *(deleted)*
|
||||
|
||||
Key changes:
|
||||
- Detection logic now recognizes modern link: `supabase/.temp/project-ref` (in addition to old `.supabase/config.toml`).
|
||||
- `pushWithCli()` rewritten to apply files one-by-one using:
|
||||
Key behavior (historical):
|
||||
- Detection logic recognized modern link: `supabase/.temp/project-ref` (in addition to old `.supabase/config.toml`).
|
||||
- `pushWithCli()` applied files one-by-one using:
|
||||
```bash
|
||||
supabase db query --linked --file "supabase/migrations/NNN_foo.sql"
|
||||
```
|
||||
(Previously tried `db push --db-url ...` which used direct connection and failed here.)
|
||||
- Falls back to direct `pg` only if CLI path fails.
|
||||
- Header comments updated with current recommended workflow.
|
||||
- Fell back to direct `pg` only if CLI path failed.
|
||||
|
||||
**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
|
||||
# Supabase CLI path (legacy — do not use going forward)
|
||||
# Supabase CLI path (legacy — script deleted, do not use)
|
||||
supabase login
|
||||
supabase link --project-ref wnzkhezyhnfzhkhiflrp
|
||||
node supabase/push-migrations.js 148 # CLI path
|
||||
node supabase/push-migrations.js 148 # CLI path (script deleted)
|
||||
# or
|
||||
npm run migrate:one 148
|
||||
```
|
||||
|
||||
```bash
|
||||
# Direct pg path (this is the future — only the pg branch is kept alive in the script)
|
||||
DATABASE_URL=postgres://... node supabase/push-migrations.js 148
|
||||
# or
|
||||
# Direct pg path (this was the future — now the only path, via scripts/migrate.js)
|
||||
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.
|
||||
- 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.)
|
||||
- `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. Re-pushing a prefix is the supported workflow for this project — this still holds under direct `pg`.
|
||||
- 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.
|
||||
- Use `DATABASE_URL` + `pg` directly (via `scripts/migrate.js`). The Supabase CLI and `supabase/push-migrations.js` are no longer in the repo.
|
||||
- `npm run migrate` applies every pending `db/migrations/*.sql` in lexical order inside transactions; `_migrations` tracks which files are already applied.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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).
|
||||
- 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)
|
||||
|
||||
(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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Platform Version:** 1.6
|
||||
**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.
|
||||
|
||||
```bash
|
||||
# Push all migrations (sequential)
|
||||
# Push all migrations (sequential, no Supabase CLI required)
|
||||
npm run migrate:one 001
|
||||
npm run migrate:one 002
|
||||
# ... through ...
|
||||
npm run migrate:one 092
|
||||
|
||||
# Or push all at once via Supabase CLI
|
||||
supabase db push
|
||||
# Or push all at once
|
||||
npm run migrate
|
||||
```
|
||||
|
||||
**Key migrations (last 15):**
|
||||
@@ -53,10 +53,11 @@ Copy `.env.local.example` to `.env.local` and fill in all values.
|
||||
|
||||
| Variable | Required | Description |
|
||||
|---|---|---|
|
||||
| `NEXT_PUBLIC_SUPABASE_URL` | Yes | Supabase project URL (e.g., `https://xxx.supabase.co`) |
|
||||
| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Yes | Supabase anon/public key (safe to expose in browser) |
|
||||
| `SUPABASE_SERVICE_ROLE_KEY` | Yes | Supabase service role key — **never expose in browser**, server-side only |
|
||||
| `SUPABASE_PAT` | Yes | Supabase Personal Access Token for migrations CLI |
|
||||
| `DATABASE_URL` | Yes | Postgres connection string (e.g., `postgresql://user:pass@host:5432/dbname?sslmode=require`) |
|
||||
| `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. |
|
||||
| `NEON_AUTH_BASE_URL` | Yes | Neon Auth (Better Auth) base URL |
|
||||
| `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
|
||||
|
||||
@@ -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:
|
||||
```sql
|
||||
SELECT * FROM pg_publication_tables WHERE tablename = 'stops';
|
||||
```
|
||||
If not, recreate: `ALTER PUBLICATION supabase_realtime PUBLISH INSERT, UPDATE ON TABLE stops;`
|
||||
**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).
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
-- Enable Harvest Reach for a brand
|
||||
@@ -302,13 +299,13 @@ Vercel keeps 10 recent deployments. For critical regressions, promote the previo
|
||||
|
||||
### Database Rollback
|
||||
```bash
|
||||
# Revert last migration via Supabase
|
||||
supabase db reset --backup-id <backup-before-migration>
|
||||
# Revert last migration via Postgres
|
||||
# (Restore from a `pg_dump` snapshot taken before the migration was applied)
|
||||
|
||||
# Or manually revert (example for migration 090)
|
||||
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 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
|
||||
|
||||
### 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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **`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.
|
||||
- **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)
|
||||
- **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 |
|
||||
|------|---------|
|
||||
| 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 |
|
||||
| Supabase Support | supabase.com/support |
|
||||
| Neon Support | neon.tech/support |
|
||||
| Vercel Support | vercel.com/support |
|
||||
|
||||
For Vercel production incidents: **vercel.com/deployments** → find deployment → "Support" button.
|
||||
|
||||
For Supabase production incidents: **supabase.com/dashboard** → project → "Support" tab.
|
||||
For Vercel production incidents: **vercel.com/deployments** → find deployment → "Support" button.
|
||||
+14
-14
@@ -15,16 +15,16 @@ npm run dev
|
||||
|
||||
## Configuration
|
||||
|
||||
### 1. Clerk Authentication
|
||||
### 1. Neon Auth (Better Auth)
|
||||
|
||||
1. Sign up at [Clerk](https://clerk.com)
|
||||
2. Create a new application
|
||||
3. Copy keys to `.env.local`:
|
||||
1. Set up Neon Auth on your Neon project (`neonctl neon-auth init`)
|
||||
2. Copy keys to `.env.local`:
|
||||
```
|
||||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_xxx
|
||||
CLERK_SECRET_KEY=sk_test_xxx
|
||||
NEON_AUTH_BASE_URL=https://xxx.neonauth.io
|
||||
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
|
||||
|
||||
@@ -37,15 +37,15 @@ npm run dev
|
||||
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`
|
||||
3. Update `.env.local` with:
|
||||
```
|
||||
NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=xxx
|
||||
SUPABASE_SERVICE_ROLE_KEY=xxx
|
||||
DATABASE_URL=postgresql://user:pass@host:5432/dbname?sslmode=require
|
||||
```
|
||||
|
||||
### 4. Optional Services
|
||||
@@ -74,7 +74,7 @@ Ensure environment variables are set before deployment.
|
||||
|
||||
## Key Files
|
||||
|
||||
- `src/proxy.ts` - Clerk middleware
|
||||
- `src/middleware.ts` - Neon Auth (Better Auth) middleware
|
||||
- `src/lib/stripe-billing.ts` - Stripe integration
|
||||
- `src/lib/analytics.ts` - PostHog analytics
|
||||
- `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/referral/ReferralSystem.tsx` - Referral tracking
|
||||
- `src/components/changelog/ChangelogFeed.tsx` - Product updates
|
||||
- `supabase/migrations/` - Database schema
|
||||
- `db/migrations/` - Database schema (applied via `scripts/migrate.js`)
|
||||
|
||||
## Scripts
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ Route Commerce helps produce brands run their wholesale operations:
|
||||
## Tech Stack
|
||||
|
||||
- **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
|
||||
- **Email/SMS:** Resend
|
||||
- **Styling:** Tailwind CSS v4
|
||||
@@ -37,10 +37,8 @@ npm install
|
||||
Create a `.env.local` file with:
|
||||
|
||||
```bash
|
||||
# Supabase
|
||||
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
|
||||
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
|
||||
# Database (Postgres)
|
||||
DATABASE_URL=postgresql://user:pass@host:5432/dbname?sslmode=require
|
||||
|
||||
# Stripe
|
||||
STRIPE_SECRET_KEY=sk_test_...
|
||||
@@ -56,10 +54,9 @@ RESEND_API_KEY=re_...
|
||||
|
||||
### 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
|
||||
supabase link --project-ref <your-project-ref>
|
||||
npm run migrate
|
||||
```
|
||||
|
||||
@@ -111,7 +108,7 @@ src/
|
||||
│ ├── admin/ # Admin-specific components
|
||||
│ └── storefront/ # Storefront components
|
||||
└── lib/ # Utilities, auth, feature flags, formatting
|
||||
supabase/
|
||||
db/
|
||||
└── migrations/ # SQL migrations (numbered sequentially)
|
||||
```
|
||||
|
||||
@@ -315,8 +312,8 @@ curl -X POST https://route-commerce-platform.vercel.app/api/email-automation/wel
|
||||
|
||||
## Notes
|
||||
|
||||
- All database writes go through **server actions** (`src/actions/`), not the Supabase JS client directly
|
||||
- Dev mode bypasses Supabase auth via `dev_session` cookie — never use this in production
|
||||
- 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 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
|
||||
- Display dates use `formatDate()` (MM/DD/YYYY) — never raw `toLocaleDateString()`
|
||||
- Migration files are numbered sequentially — never reuse numbers
|
||||
@@ -4,6 +4,8 @@
|
||||
**Mission:** Stand up a working Auth.js v5 + Google sign-in flow, then rip out
|
||||
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
|
||||
@@ -171,7 +173,7 @@ src/actions/ai/preferences.ts
|
||||
|
||||
1. **Provision a `pg` pool in production.** `DATABASE_URL` must be
|
||||
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
|
||||
`email`, `auth_provider`, `auth_subject` columns to `admin_users`
|
||||
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
|
||||
`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
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
-- Stops: 269 | Locations: 40
|
||||
-- Preferred: npx tsx scripts/import-tuxedo-stops.ts
|
||||
-- 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;
|
||||
|
||||
|
||||
+1
-1
@@ -180,7 +180,7 @@ data explicitly checks its gate.
|
||||
cold-start paths; the active `getAdminUser().brand_id` always wins
|
||||
on later requests.
|
||||
- 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
@@ -14,7 +14,6 @@ const eslintConfig = defineConfig([
|
||||
"next-env.d.ts",
|
||||
// Ignore legacy .js scripts that use CommonJS
|
||||
"scripts/**",
|
||||
"supabase/**",
|
||||
"fix-agents.js",
|
||||
]),
|
||||
// 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
|
||||
{
|
||||
files: ["db/**", "scripts/**", "supabase/**"],
|
||||
files: ["db/**", "scripts/**"],
|
||||
rules: {
|
||||
"@typescript-eslint/no-require-imports": "off",
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
|
||||
+6
-9
@@ -22,10 +22,6 @@ const nextConfig: NextConfig = {
|
||||
// Optimize images
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "*.supabase.co",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "images.unsplash.com",
|
||||
@@ -124,11 +120,12 @@ const nextConfig: NextConfig = {
|
||||
{ source: "/admin/orders/:id", destination: "/admin/v2/orders/:id", permanent: false },
|
||||
{ source: "/admin/stops", destination: "/admin/v2/stops", permanent: false },
|
||||
{ source: "/admin/products", destination: "/admin/v2/products", permanent: false },
|
||||
// v1 admin pages that hit the dead supabase shim. There are no v2
|
||||
// equivalents yet (reports / taxes / settings sub-pages are still
|
||||
// TBD on the v2 surface), so they redirect to the v2 dashboard
|
||||
// for now. Once those modules ship on v2, the redirects can be
|
||||
// tightened to point at the new pages.
|
||||
// v1 admin pages that were removed when the supabase shim was
|
||||
// deleted. There are no v2 equivalents yet (reports / taxes /
|
||||
// settings sub-pages are still TBD on the v2 surface), so they
|
||||
// redirect to the v2 dashboard for now. Once those modules ship
|
||||
// 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/reports", destination: "/admin/v2", permanent: false },
|
||||
{ source: "/admin/taxes", destination: "/admin/v2", permanent: false },
|
||||
|
||||
@@ -34,8 +34,6 @@
|
||||
"@sentry/nextjs": "^10.55.0",
|
||||
"@stripe/react-stripe-js": "^6.6.0",
|
||||
"@stripe/stripe-js": "^9.7.0",
|
||||
"@supabase/ssr": "^0.10.2",
|
||||
"@supabase/supabase-js": "^2.105.3",
|
||||
"@upstash/ratelimit": "^2.0.8",
|
||||
"@upstash/redis": "^1.38.0",
|
||||
"drizzle-orm": "^0.36.4",
|
||||
|
||||
@@ -305,8 +305,7 @@ async function main() {
|
||||
let sql = `-- Tuxedo Corn 2026 Tour Data (from ${xlsxPath})\n`;
|
||||
sql += `-- Stops: ${stops.length} | Locations: ${parsedLocations.length}\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 += `-- supabase db query --linked --file db/seeds/2026-tuxedo-tour-stops.sql\n\n`;
|
||||
sql += `-- Manual apply: psql "$DATABASE_URL" -f db/seeds/2026-tuxedo-tour-stops.sql\n\n`;
|
||||
sql += `BEGIN;\n\n`;
|
||||
|
||||
// Locations first (master directory)
|
||||
|
||||
@@ -3,11 +3,6 @@
|
||||
/**
|
||||
* 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
|
||||
* `@/lib/db` and is safe to call from public (unauthenticated) pages
|
||||
* 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
|
||||
* their initial `useEffect`. Replaces the three separate `supabase.from(...)`
|
||||
* calls that the legacy `lib/supabase.ts` shim used to make — the shim
|
||||
* returned `{ data: null }` for everything, which is why storefronts
|
||||
* were showing empty stops / products.
|
||||
* their initial `useEffect`. Bundles brand lookup + public stops +
|
||||
* active products into one round-trip-friendly call.
|
||||
*
|
||||
* Returns `{ brand: null, stops: [], products: [] }` if the slug doesn't
|
||||
* match any brand — pages fall back to default copy in that case.
|
||||
|
||||
+3
-3
@@ -11,9 +11,9 @@
|
||||
* const { rows } = await query<MyRow>("SELECT * FROM my_table WHERE id = $1", [id]);
|
||||
*
|
||||
* Configuration:
|
||||
* - DATABASE_URL (required) — full Postgres connection string. Same env var
|
||||
* is used by `supabase/push-migrations.js` and any external migration
|
||||
* tooling. Format: `postgres://user:pass@host:port/dbname`.
|
||||
* - DATABASE_URL (required) — full Postgres connection string. Used by
|
||||
* `scripts/migrate.js` (and any external migration tooling). Format:
|
||||
* `postgres://user:pass@host:port/dbname`.
|
||||
*
|
||||
* Notes:
|
||||
* - This module is server-only. It must never be imported from a Client
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
# Supabase
|
||||
.branches
|
||||
.temp
|
||||
|
||||
# dotenvx
|
||||
.env.keys
|
||||
.env.local
|
||||
.env.*.local
|
||||
@@ -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.
|
||||
@@ -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
Reference in New Issue
Block a user