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