Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 42f5745c97 | |||
| b018f2be5b | |||
| 49b8e27219 | |||
| 68a749f7af | |||
| d69b892117 | |||
| bd1690037d | |||
| fcdff8bce5 | |||
| a706746250 | |||
| a2285baeb4 | |||
| 2cf811a66b |
@@ -18,7 +18,7 @@ Route Commerce is a multi-tenant B2B e-commerce platform for fresh produce whole
|
||||
|
||||
Tech stack: Next.js 16 (App Router) · **Postgres** (direct) · **Neon Auth** (Better Auth) · Stripe · Square · Resend (email) · Tailwind CSS v4
|
||||
|
||||
> **Direction:** Auth is handled by Neon Auth (Better Auth). The `admin_users` table links to Neon Auth users by email. New DB code should connect to Postgres directly (via `pg` or the chosen driver — see Database section) and **must not** import from `@supabase/*` or call Supabase REST.
|
||||
> **Direction:** Auth is handled by Neon Auth (Better Auth). The `admin_users` table links to Neon Auth users by email. New DB code must connect to Postgres directly via the shared `pg` `Pool` from `src/lib/db.ts`. No Supabase JS client, REST gateway, or `*.supabase.co` calls anywhere in the codebase.
|
||||
|
||||
---
|
||||
|
||||
@@ -33,12 +33,12 @@ npx tsc --noEmit # TypeScript check (no emit)
|
||||
npx playwright test # Run E2E tests (Playwright)
|
||||
```
|
||||
|
||||
> The migrate script (`supabase/push-migrations.js`) now only uses the direct `pg` path — the Supabase CLI branch is legacy. It reads `DATABASE_URL` from `.env.local` via `dotenv`. `pg` is already in devDependencies.
|
||||
> The migrate script is `scripts/migrate.js`. It reads `DATABASE_URL` (or `DATABASE_ADMIN_URL`) from `.env.local` via `dotenv` and applies any `db/migrations/*.sql` files not already recorded in `_migrations`. `pg` is already in devDependencies.
|
||||
> If a migration fails with "cannot change return type", the function signature changed — drop and recreate it first.
|
||||
|
||||
**Recent migration work is documented in `MEMORY.md`** (Supabase login + link process, updates to `push-migrations.js` for modern CLI, specific SQL patches made to 091/145/148/200/201 so they would apply cleanly, and which migrations were pushed in the session). Cat `MEMORY.md` for details.
|
||||
**Historical migration work is documented in `MEMORY.md`** (Supabase login + link process, updates to `push-migrations.js` for the modern CLI, specific SQL patches made to 091/145/148/200/201 so they would apply cleanly, and which migrations were pushed). Cat `MEMORY.md` for details.
|
||||
|
||||
E2E tests live in `tests/` and run via Playwright. Specs include `tests/smoke.spec.ts` and `tests/login/login-flow.spec.ts`. **Note: `playwright.config.ts` defaults `baseURL` to production** (`https://route-commerce-platform.vercel.app`); override with `PLAYWRIGHT_URL=http://localhost:3000` for local runs, or pass `--config` with a local config.
|
||||
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:4000` for local runs (the dev server binds to port `4000`), or pass `--config` with a local config.
|
||||
|
||||
---
|
||||
|
||||
@@ -51,12 +51,12 @@ E2E tests live in `tests/` and run via Playwright. Specs include `tests/smoke.sp
|
||||
**Auth flow:**
|
||||
1. User signs in via `/api/auth/sign-in` → Neon Auth validates credentials → session cookie set
|
||||
2. `getAdminUser()` in `src/lib/admin-permissions.ts` reads the Neon Auth session and looks up `admin_users` by email
|
||||
3. Middleware (`src/middleware.ts`) guards `/admin/*` routes at the edge level
|
||||
3. Middleware (`src/proxy.ts`) guards `/admin/*` routes at the edge level
|
||||
|
||||
**Key files:**
|
||||
- `src/lib/auth.ts` — Neon Auth configuration (getSession, signIn, signOut, resetPassword, requestPasswordReset)
|
||||
- `src/auth.config.ts` — Edge-safe config (baseUrl, cookieSecret)
|
||||
- `src/middleware.ts` — Edge-level route protection
|
||||
- `src/proxy.ts` — Edge-level route protection
|
||||
- `src/app/api/auth/sign-in/route.ts` — Email/password sign-in API
|
||||
- `src/app/api/auth/forgot-password/route.ts` — Password reset request API
|
||||
- `src/app/api/auth/reset-password/route.ts` — Password reset confirmation API
|
||||
@@ -93,13 +93,13 @@ Server actions are "use server" files that export async functions. Client compon
|
||||
|
||||
### Database (Postgres, direct)
|
||||
|
||||
The app connects to **Postgres directly** — no Supabase platform, JS client, or REST gateway. Server actions use the `pg` driver (or whatever the chosen connection layer is) to call `SECURITY DEFINER` PL/pgSQL functions. Storage of files (product images, etc.) is moving to an S3-compatible object store; until that's wired up, image references can stay as URLs.
|
||||
The app connects to **Postgres directly** — no Supabase platform, JS client, or REST gateway. Server actions use the `pg` driver to call `SECURITY DEFINER` PL/pgSQL functions; Drizzle (on top of the same pool) handles typed reads and the per-request `app.current_brand_id` GUC. Storage of files (product images, etc.) is moving to an S3-compatible object store; until that's wired up, image references can stay as URLs.
|
||||
|
||||
#### Connection
|
||||
|
||||
- `DATABASE_URL` in `.env.local` (and hosting dashboard) is the only required DB env var.
|
||||
- A single shared `pg` `Pool` is exported from `src/lib/db.ts` (TBD — to be created/confirmed during the migration). Server actions and API routes import it and call `pool.query(...)` against RPC names.
|
||||
- No `NEXT_PUBLIC_SUPABASE_URL` / `SUPABASE_SERVICE_ROLE_KEY` / `@supabase/*` imports — these are being purged from the codebase.
|
||||
- A single shared `pg` `Pool` is exported from `src/lib/db.ts` as `pool` (proxy) and `getPool()` (lazy). Server actions and API routes import it and call `pool.query(...)` against RPC names. The Drizzle client lives at `db/client.ts` and shares the same pool.
|
||||
- No `NEXT_PUBLIC_SUPABASE_URL` / `SUPABASE_SERVICE_ROLE_KEY` / `@supabase/*` imports — none exist anywhere in the codebase.
|
||||
|
||||
#### First production deploy / new prod DB bootstrap (critical for admin access)
|
||||
|
||||
@@ -255,7 +255,7 @@ For annual pricing, create separate annual prices in Stripe (e.g., $441/yr for S
|
||||
|
||||
### Communications Module ("Harvest Reach")
|
||||
|
||||
The communications system (`/admin/communications`) uses a separate set of tables that are **NOT protected by RLS** — they rely entirely on the SECURITY DEFINER RPCs + application-layer brand scoping. Key tables: `communication_campaigns`, `communication_templates`, `communication_contacts`, `communication_message_logs`. (The "no RLS" framing carries over from the Supabase era; on raw Postgres this just means no row-level policies — scoping is still enforced by RPC + app layer.)
|
||||
The communications system (`/admin/communications`) uses a separate set of tables that have **no row-level policies** — they rely entirely on the SECURITY DEFINER RPCs + application-layer brand scoping. Key tables: `communication_campaigns`, `communication_templates`, `communication_contacts`, `communication_message_logs`. Scoping is enforced by RPC + app layer.
|
||||
|
||||
`send_campaign` / `send_stop_blast` RPCs insert into `communication_message_logs` but do NOT populate `event_id`. The Resend webhook (`src/app/api/resend/webhook/route.ts`) must therefore look up logs by `customer_email + subject + created_at` (7-day window), not by `event_id`.
|
||||
|
||||
@@ -282,7 +282,7 @@ Separate from orders/stops — tracks irrigation/water usage per brand. `src/act
|
||||
|
||||
## Key Conventions
|
||||
|
||||
- All DB access goes through a shared `pg` `Pool` (see Database section). Server actions call SECURITY DEFINER RPCs via `pool.query('SELECT * FROM fn_name($1, $2)', [...])`. Do not introduce `@supabase/*` imports or REST fetch to `*/rest/v1/`.
|
||||
- All DB access goes through a shared `pg` `Pool` (see Database section). Server actions call SECURITY DEFINER RPCs via `pool.query('SELECT * FROM fn_name($1, $2)', [...])`. Do not introduce `@supabase/*` imports, REST fetches, or `*.supabase.co` calls anywhere in the codebase.
|
||||
- `gen_random_uuid()` used in migrations for primary keys
|
||||
- Migrations use `CREATE OR REPLACE FUNCTION` for idempotency — never `DROP` then `CREATE`
|
||||
- Status enums stored as TEXT — no PostgreSQL ENUM type
|
||||
@@ -300,7 +300,7 @@ Separate from orders/stops — tracks irrigation/water usage per brand. `src/act
|
||||
| Neon Auth configuration | `src/lib/auth.ts`, `src/auth.config.ts` |
|
||||
| Auth API routes | `src/app/api/auth/sign-in/route.ts`, `src/app/api/auth/forgot-password/route.ts`, `src/app/api/auth/reset-password/route.ts`, `src/app/api/auth/[...nextauth]/route.ts` |
|
||||
| Admin auth + permissions | `src/lib/admin-permissions.ts`, `src/lib/admin-permissions-types.ts` |
|
||||
| Middleware (route protection) | `src/middleware.ts` |
|
||||
| Middleware (route protection) | `src/proxy.ts` |
|
||||
| Server actions | `src/actions/*.ts` (one file per domain; also grouped into `src/actions/{admin,ai,billing,communications,harvest-reach,integrations,orders,products,settings,shipping,stops,water-log,platform,route-trace,time-tracking,email-automation}/`) |
|
||||
| Admin pages | `src/app/admin/[module]/page.tsx` |
|
||||
| Admin client components | `src/components/admin/*.tsx` |
|
||||
|
||||
+11
-17
@@ -10,18 +10,9 @@ Copy `.env.example` → `.env.local` and fill in the values.
|
||||
|
||||
These are safe to commit and can be used in client bundles. Prefix with `NEXT_PUBLIC_`.
|
||||
|
||||
### `NEXT_PUBLIC_SUPABASE_URL`
|
||||
Your Supabase project URL.
|
||||
- **Where to get:** Supabase Dashboard → Project Settings → API → Project URL
|
||||
- **Example:** `https://abc123.supabase.co`
|
||||
|
||||
### `NEXT_PUBLIC_SUPABASE_ANON_KEY`
|
||||
Supabase anonymous (anon) key — safe for client-side use.
|
||||
- **Where to get:** Supabase Dashboard → Project Settings → API → anon key
|
||||
|
||||
### `NEXT_PUBLIC_BASE_URL`
|
||||
The base URL of your deployment. Used for OAuth redirects and webhook URLs.
|
||||
- **Local:** `http://localhost:3000`
|
||||
- **Local:** `http://localhost:4000` (dev script binds to port 4000; override via `npm run dev -- -p <port>`)
|
||||
- **Production:** `https://yourdomain.com`
|
||||
|
||||
### `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY`
|
||||
@@ -40,12 +31,16 @@ Set to a brand slug to hide other brand routes in single-brand mode.
|
||||
|
||||
**Never prefix with `NEXT_PUBLIC_`**. These must only be accessed in Server Components, Server Actions, or API Routes.
|
||||
|
||||
### Supabase
|
||||
### Database
|
||||
|
||||
#### `SUPABASE_SERVICE_ROLE_KEY`
|
||||
Full service role key — grants DB admin access bypassing RLS.
|
||||
- **Where to get:** Supabase Dashboard → Project Settings → API → service_role key
|
||||
- **⚠️ Critical:** Never expose this to the client. Used only in server-side code.
|
||||
#### `DATABASE_URL`
|
||||
Full Postgres connection string. The app uses this exclusively (no Supabase, no JS client) for every server-side query — server actions and API routes import a shared `pg` `Pool` from `src/lib/db.ts` and call `SECURITY DEFINER` PL/pgSQL functions.
|
||||
- **Where to get:** Neon Dashboard → Project → Connection Details → Connection string (pooled)
|
||||
- **Example:** `postgresql://user:pass@ep-xxx.us-east-2.aws.neon.tech/routecommerce?sslmode=require`
|
||||
- **Format:** `postgres://user:pass@host:port/dbname?sslmode=require`
|
||||
|
||||
#### `DATABASE_ADMIN_URL` (optional)
|
||||
Same connection string but with elevated perms (typically the direct, non-pooled Neon connection). Only used by `scripts/migrate.js` for DDL — fall back to `DATABASE_URL` if unset.
|
||||
|
||||
### Stripe
|
||||
|
||||
@@ -156,7 +151,7 @@ Controls whether to use Square sandbox or production.
|
||||
|
||||
| Variable | Local (`.env.local`) | Production (hosting dashboard) |
|
||||
|---|---|---|
|
||||
| `NEXT_PUBLIC_BASE_URL` | `http://localhost:3000` | `https://yourdomain.com` |
|
||||
| `NEXT_PUBLIC_BASE_URL` | `http://localhost:4000` (or whatever port the dev server is on) | `https://yourdomain.com` |
|
||||
| `STRIPE_SECRET_KEY` | `sk_test_...` | `sk_live_...` |
|
||||
| `STRIPE_PUBLISHABLE_KEY` | `pk_test_...` | `pk_live_...` |
|
||||
| `SQUARE_ENVIRONMENT` | `sandbox` | `production` |
|
||||
@@ -168,7 +163,6 @@ Controls whether to use Square sandbox or production.
|
||||
- **Wrong Stripe key type:** Using `sk_live_` in development or `sk_test_` in production will silently fail. Always match key type to environment.
|
||||
- **Stripe price IDs drift:** If you create new prices in Stripe Dashboard but forget to update `.env.local`, billing will fail. Keep them in sync.
|
||||
- **SQUARE_ENVIRONMENT mismatch:** Production Square credentials won't work with `sandbox`. Match it to your app secret type.
|
||||
- **SUPABASE_SERVICE_ROLE_KEY in client:** If you ever see `SUPABASE_SERVICE_ROLE_KEY` in a browser bundle, it's a critical security incident. The key was exposed server-side. Rotate it immediately in Supabase Dashboard.
|
||||
- **OPENAI_API_KEY for AI features:** AI features won't work without this. The AI Intelligence Pack add-on requires a valid OpenAI key.
|
||||
|
||||
---
|
||||
|
||||
+10
-8
@@ -1,5 +1,7 @@
|
||||
# Route Commerce Launch Checklist Summary
|
||||
|
||||
**Last updated:** 2026-06-25
|
||||
|
||||
## Overview
|
||||
|
||||
This document summarizes the complete Launch & Marketing Layer implementation for Route Commerce, a multi-tenant B2B e-commerce platform for fresh produce wholesale distribution.
|
||||
@@ -8,15 +10,15 @@ This document summarizes the complete Launch & Marketing Layer implementation fo
|
||||
|
||||
## ✅ Implementation Status
|
||||
|
||||
### 1. Supabase Foundation ✓
|
||||
### 1. Database & Auth Foundation ✓
|
||||
|
||||
| Component | Status | Location |
|
||||
|-----------|--------|----------|
|
||||
| Auth Flow | ✓ Complete | Existing dev_session + Supabase Auth |
|
||||
| Database Structure | ✓ Complete | Supabase migrations in place |
|
||||
| Role-Based Access | ✓ Complete | admin-permissions.ts system |
|
||||
| Protected Routes | ✓ Complete | middleware.ts |
|
||||
| Server Actions Pattern | ✓ Complete | src/actions/*.ts |
|
||||
| Auth Flow | ✓ Complete | Neon Auth (Better Auth) + dev_session for local |
|
||||
| Database Structure | ✓ Complete | `db/migrations/` applied via `pg` |
|
||||
| Role-Based Access | ✓ Complete | `admin-permissions.ts` system |
|
||||
| Protected Routes | ✓ Complete | `src/proxy.ts` (Next.js 16+ middleware convention) |
|
||||
| Server Actions Pattern | ✓ Complete | `src/actions/*.ts` |
|
||||
|
||||
### 2. Launch-Ready Features ✓
|
||||
|
||||
@@ -237,9 +239,9 @@ Track these metrics post-launch:
|
||||
|
||||
- **Documentation**: See CLAUDE.md for technical details
|
||||
- **Stripe Dashboard**: https://dashboard.stripe.com
|
||||
- **Supabase Dashboard**: https://app.supabase.com/project/wnzkhezyhnfzhkhiflrp
|
||||
- **Neon Console**: https://console.neon.tech
|
||||
- **Vercel Deployments**: https://vercel.com/dashboard
|
||||
|
||||
---
|
||||
|
||||
*Generated: January 2025*
|
||||
*Generated: January 2025 · Refreshed: 2026-06-25 (see `CLAUDE.md` for current architecture; deployment is via Vercel hooked to the Gitea `origin` remote — there is no GitHub `origin`)*
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
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-25 (added current-state header; restored "Supabase → Postgres" pivot as the canonical entry point for new readers)
|
||||
|
||||
> **Current state (read first).** Auth is **Neon Auth (Better Auth)** — see `CLAUDE.md`. The "Auth.js v5 wiring complete — 2026-06-06" entry further down is **historical** (it describes work that was subsequently replaced by the Neon Auth migration; `next-auth` remains in `package.json` as a vestigial dep but is no longer imported from `src/`). The Supabase → Postgres pivot section below is the canonical cutover record. The "GitHub origin" branch notes are also historical — the only remote is the Gitea `origin` (see `CLAUDE.md` "Canonical Remote"). Middleware lives at `src/proxy.ts`, not `src/middleware.ts` (Next.js 16+ convention).
|
||||
|
||||
## 2026-06: `admin_users` schema — extra columns for create-user flow (migration 0043)
|
||||
|
||||
@@ -48,80 +50,79 @@ See also the plan doc referenced in deploy.yml for the broader reliability work.
|
||||
|
||||
## 🚨 Direction Pivot (2026-06-06) — Supabase → Postgres
|
||||
|
||||
The platform is moving off Supabase entirely. We are connecting to **Postgres directly** (via `pg`), with **Auth.js (NextAuth v5)** handling authentication. See `CLAUDE.md` for the full updated architecture.
|
||||
**Status: COMPLETE (2026-06).** All Supabase references removed from the codebase:
|
||||
- `supabase/` directory deleted (`config.toml`, `push-migrations.js`, `ADMIN_CREATE_STOP_FIX.sql`, and 137 archived migration files in `supabase/migrations/.archived/` are gone).
|
||||
- `@supabase/ssr` and `@supabase/supabase-js` removed from `package.json`.
|
||||
- `next.config.ts` no longer whitelists `*.supabase.co` for images; `vercel.json` CSP no longer allows `https://*.supabase.co` in `connect-src`.
|
||||
- `eslint.config.mjs` no longer ignores `supabase/**`.
|
||||
- All `src/`, `tests/`, and `db/` files are free of `@supabase/*` imports, `rest/v1/` calls, and `*.supabase.co` references.
|
||||
- Migrations are now applied by `scripts/migrate.js` (which uses `pg` + `DATABASE_URL`); the Supabase CLI branch is gone with the deleted script.
|
||||
|
||||
### What changes immediately
|
||||
- **DB connection**: `DATABASE_URL` is the only required DB env var. No more `NEXT_PUBLIC_SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, or `SUPABASE_ANON_KEY`.
|
||||
- **Migrations**: only the `pg` direct path in `supabase/push-migrations.js` is supported going forward. The Supabase CLI branch is dead code. The script reads `DATABASE_URL` from `.env.local` via `dotenv`.
|
||||
- **Code**: no new `@supabase/*` imports, no `rest/v1/` REST fetch, no Supabase JS client usage. Use a shared `pg` `Pool` (target location: `src/lib/db.ts`, **TBD — create during the cutover**).
|
||||
- **Auth**: legacy `rc_auth_uid` cookie + bespoke `/api/login` is being replaced by Auth.js. Until the Auth.js migration ships, the `dev_session` cookie remains the source of truth.
|
||||
- **Storage**: Supabase Storage (e.g. the `product-images` bucket created in migration 145) is going away. Need an S3-compatible alternative — **TBD**.
|
||||
This section is kept as **historical record** of the cutover. The "Supabase CLI + Migrations Tooling" section further below is also historical.
|
||||
|
||||
### What's TBD / needs follow-up
|
||||
- [ ] `DATABASE_URL` for local dev (Neon? local Postgres? — user hasn't specified the Postgres host yet)
|
||||
- [ ] New connection layer: raw `pg` Pool vs Drizzle vs Prisma vs Neon serverless driver — **not decided**
|
||||
- [ ] Auth.js migration actually landing (currently "in progress" per CLAUDE.md)
|
||||
- [ ] Where do product images, brand logos, etc. live now? S3? Cloudflare R2? Re-encode as URL strings?
|
||||
- [ ] Whether the Supabase project (`wnzkhezyhnfzhkhiflrp`) gets shut down or kept read-only for the transition
|
||||
- [ ] Cutover sequencing: do we delete `@supabase/*` from `package.json` in one PR or incrementally?
|
||||
### Original pivot summary (kept for context)
|
||||
The platform moved off Supabase entirely. We connect to **Postgres directly** (via `pg`), with **Neon Auth (Better Auth)** handling authentication. See `CLAUDE.md` for the current architecture.
|
||||
|
||||
### What changed at the time
|
||||
- **DB connection**: `DATABASE_URL` (with `DATABASE_ADMIN_URL` optional override for DDL) is the only required DB env var. No more `NEXT_PUBLIC_SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, or `SUPABASE_ANON_KEY`.
|
||||
- **Migrations**: `scripts/migrate.js` is the canonical runner. It reads `DATABASE_URL` from `.env.local` via `dotenv`, tracks applied files in `_migrations`, and applies any new files in `db/migrations/` in lexical order inside a transaction.
|
||||
- **Code**: zero `@supabase/*` imports, zero `rest/v1/` REST fetches, zero Supabase JS client usage. One shared `pg` `Pool` in `src/lib/db.ts`; Drizzle on top of it at `db/client.ts` for typed reads + RLS-style brand scoping.
|
||||
- **Auth**: Neon Auth (Better Auth) is the production path. `dev_session` cookie is the local-only bypass.
|
||||
- **Storage**: object store migration (S3-compatible) is independent of the Supabase purge and is tracked separately.
|
||||
|
||||
### Migration content that's now obsolete
|
||||
- **145 (product-images bucket)**: Supabase Storage bucket + RLS policies. Replaced by object store of choice.
|
||||
- **Any RLS policy on tables** (200 added several): the "no RLS, app-layer scoping" model still holds but the policies are inert in the new world.
|
||||
- The `supabase link --project-ref wnzkhezyhnfzhkhiflrp` setup is no longer needed for ongoing work.
|
||||
- **Any RLS policy on tables** (200 added several): the "no row-level policies, app-layer scoping" model still holds but the policies are inert in the new world.
|
||||
|
||||
### Historical sections below
|
||||
The "Supabase CLI + Migrations Tooling" section that used to live at the top of this file describes the *previous* tooling. It is kept below as **historical record** of work that was already applied to the Supabase project. Do **not** follow its CLI instructions — use the `pg` direct path instead. Migration-file patch notes (091, 145, 148, 200, 201) are also kept as historical record of what got applied.
|
||||
The "Supabase CLI + Migrations Tooling" section further down describes the *previous* tooling. It is kept as **historical record** of work that was already applied to the Supabase project. Do **not** follow its CLI instructions — use `scripts/migrate.js` (or `npm run migrate`) instead. Migration-file patch notes (091, 145, 148, 200, 201) are also kept as historical record of what got applied.
|
||||
|
||||
---
|
||||
|
||||
## Supabase CLI + Migrations Tooling *(SUPERSEDED — see Direction Pivot above)*
|
||||
## Supabase CLI + Migrations Tooling *(SUPERSEDED — see Direction Pivot above; script and CLI are no longer in the repo)*
|
||||
|
||||
### Login + Link (done in this session)
|
||||
- User ran `supabase login`
|
||||
- We ran: `supabase link --project-ref wnzkhezyhnfzhkhiflrp`
|
||||
- Link succeeded. Project appears as **LINKED** (●) in `supabase projects list`.
|
||||
- Link state lives under `supabase/.temp/` (project-ref, linked-project.json, pooler-url, etc.). **Not** the legacy `.supabase/config.toml` at project root.
|
||||
- This enables `supabase db query --linked`, `supabase migration list`, etc.
|
||||
- Link state lived under `supabase/.temp/` (project-ref, linked-project.json, pooler-url, etc.). **Not** the legacy `.supabase/config.toml` at project root.
|
||||
- This enabled `supabase db query --linked`, `supabase migration list`, etc.
|
||||
|
||||
### Important Environment Note
|
||||
- Direct Postgres connections (`db.wnzkhezyhnfzhkhiflrp.supabase.co:5432` using service role key as password) **do not work** from this workspace:
|
||||
- `getaddrinfo ENOTFOUND`
|
||||
- IPv6 "network is unreachable" in some cases
|
||||
- HTTPS / Supabase REST / Management API work fine.
|
||||
- Therefore the `push-migrations.js` **CLI path** (using linked project) is the only reliable way to apply migrations here.
|
||||
- Therefore the `push-migrations.js` **CLI path** (using linked project) was the only reliable way to apply migrations here.
|
||||
|
||||
### Updated Migration Script
|
||||
File: `supabase/push-migrations.js`
|
||||
### Migration Script (deleted 2026-06 — use `scripts/migrate.js` instead)
|
||||
File: `supabase/push-migrations.js` *(deleted)*
|
||||
|
||||
Key changes:
|
||||
- Detection logic now recognizes modern link: `supabase/.temp/project-ref` (in addition to old `.supabase/config.toml`).
|
||||
- `pushWithCli()` rewritten to apply files one-by-one using:
|
||||
Key behavior (historical):
|
||||
- Detection logic recognized modern link: `supabase/.temp/project-ref` (in addition to old `.supabase/config.toml`).
|
||||
- `pushWithCli()` applied files one-by-one using:
|
||||
```bash
|
||||
supabase db query --linked --file "supabase/migrations/NNN_foo.sql"
|
||||
```
|
||||
(Previously tried `db push --db-url ...` which used direct connection and failed here.)
|
||||
- Falls back to direct `pg` only if CLI path fails.
|
||||
- Header comments updated with current recommended workflow.
|
||||
- Fell back to direct `pg` only if CLI path failed.
|
||||
|
||||
**Recommended commands now (Supabase CLI path — being phased out, use `pg` direct path going forward):**
|
||||
**Historical commands (Supabase CLI path — both scripts are gone):**
|
||||
```bash
|
||||
# Supabase CLI path (legacy — do not use going forward)
|
||||
# Supabase CLI path (legacy — script deleted, do not use)
|
||||
supabase login
|
||||
supabase link --project-ref wnzkhezyhnfzhkhiflrp
|
||||
node supabase/push-migrations.js 148 # CLI path
|
||||
node supabase/push-migrations.js 148 # CLI path (script deleted)
|
||||
# or
|
||||
npm run migrate:one 148
|
||||
```
|
||||
|
||||
```bash
|
||||
# Direct pg path (this is the future — only the pg branch is kept alive in the script)
|
||||
DATABASE_URL=postgres://... node supabase/push-migrations.js 148
|
||||
# or
|
||||
# Direct pg path (this was the future — now the only path, via scripts/migrate.js)
|
||||
DATABASE_URL=postgres://... npm run migrate:one 148
|
||||
```
|
||||
|
||||
`npm run migrate` (no arg) will push every `*.sql` in order (use with caution).
|
||||
`npm run migrate` (no arg) pushes every `*.sql` in `db/migrations/` in order (use with caution).
|
||||
|
||||
---
|
||||
|
||||
@@ -210,16 +211,14 @@ Verification queries (post-apply) confirmed:
|
||||
|
||||
---
|
||||
|
||||
## Current State / Gotchas (2026-06-06)
|
||||
## Current State / Gotchas (2026-06)
|
||||
|
||||
- The Supabase CLI is no longer the recommended path. Use `DATABASE_URL` + `pg` directly. The `supabase/` directory is kept as a path for migrations tooling only.
|
||||
- The Postgres host/URL for local dev is **TBD** (not yet decided by the user). Until it's set, `npm run migrate` will fail at the `pg` connect step. (The Supabase project at `wnzkhezyhnfzhkhiflrp` may still exist as a fallback read-only target — unconfirmed.)
|
||||
- `supabase migration list` will show a lot of "pending" because the project's custom numeric migrations (00x_*.sql) were historically applied via the raw-SQL push script, not registered in Supabase's `schema_migrations` tracking table. This is mostly irrelevant now that we're moving off Supabase.
|
||||
- Many migrations are intentionally written to be re-runnable. Re-pushing a prefix is the supported workflow for this project — this still holds under direct `pg`.
|
||||
- When adding **new** migrations, use the established `supabase/push-migrations.js` + numeric prefix style (`NNN_descriptive_name.sql`). Do not introduce `supabase migration new` — that flow is going away with the CLI branch.
|
||||
- Storage policies (145), RLS policies (200), SECURITY DEFINER functions, and brand-scoped data are still in Postgres — test carefully after big applies. Brand scoping still relies on `p_brand_id` parameters in RPCs.
|
||||
- Use `DATABASE_URL` + `pg` directly (via `scripts/migrate.js`). The Supabase CLI and `supabase/push-migrations.js` are no longer in the repo.
|
||||
- `npm run migrate` applies every pending `db/migrations/*.sql` in lexical order inside transactions; `_migrations` tracks which files are already applied.
|
||||
- Many migrations are intentionally written to be re-runnable (`CREATE OR REPLACE FUNCTION`, `CREATE TABLE IF NOT EXISTS`, guarded triggers). Re-pushing a prefix is safe.
|
||||
- When adding **new** migrations, use the established numeric prefix style (`NNN_descriptive_name.sql`) and place them in `db/migrations/`. No Supabase CLI command is involved.
|
||||
- Storage policies (145), RLS policies (200), SECURITY DEFINER functions, and brand-scoped data are still in Postgres — test carefully after big applies. Brand scoping still relies on `p_brand_id` parameters in RPCs (plus the per-request `app.current_brand_id` GUC used by Drizzle).
|
||||
- CLAUDE.md already documents the overall migrate story and the `get_brand_settings` gotcha.
|
||||
- **Open question for next session:** confirm Postgres host + connection layer (raw `pg` vs Drizzle/Prisma) and start the actual cutover (drop `@supabase/*` deps, create `src/lib/db.ts`, replace cookie auth with Auth.js).
|
||||
|
||||
---
|
||||
|
||||
@@ -234,9 +233,9 @@ Verification queries (post-apply) confirmed:
|
||||
|
||||
## Unrelated / Other Changes in Tree (as of last git status)
|
||||
|
||||
(See `git status --short` and `git diff` for full picture. Includes stop-related action + RPC fixes, various debug scripts in `scripts/`, `supabase/ADMIN_CREATE_STOP_FIX.sql`, a pricing assessment doc, etc.)
|
||||
(See `git status --short` and `git diff` for full picture. Includes stop-related action + RPC fixes, various debug scripts in `scripts/`, a pricing assessment doc, etc.)
|
||||
|
||||
This MEMORY.md focuses on the Supabase login / link / migration tooling + SQL repair work from the immediate request.
|
||||
This MEMORY.md originally focused on the Supabase login / link / migration tooling + SQL repair work from the immediate request. The pivot section now records the completion of the Supabase → Postgres migration.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,47 +1,38 @@
|
||||
# Route Commerce — Production Deployment Checklist
|
||||
|
||||
**Platform Version:** 1.6
|
||||
**Last Updated:** 2026-05-13
|
||||
**Environment:** Next.js 16 (App Router) · Supabase · Stripe · Square · Resend · FedEx
|
||||
**Platform Version:** 2.0
|
||||
**Last Updated:** 2026-06-25
|
||||
**Environment:** Next.js 16 (App Router) · Postgres (direct via `pg`) · Neon Auth (Better Auth) · Stripe · Square · Resend · FedEx
|
||||
|
||||
---
|
||||
|
||||
## 1. Migration Order
|
||||
|
||||
Apply migrations in number order. All numbered `001`–`092` are required.
|
||||
Apply migrations in number order. The full set lives in `db/migrations/`:
|
||||
|
||||
```bash
|
||||
# Push all migrations (sequential)
|
||||
npm run migrate:one 001
|
||||
npm run migrate:one 002
|
||||
# ... through ...
|
||||
npm run migrate:one 092
|
||||
|
||||
# Or push all at once via Supabase CLI
|
||||
supabase db push
|
||||
# Push all migrations (idempotent — `_migrations` table tracks what's applied)
|
||||
npm run migrate
|
||||
```
|
||||
|
||||
**Key migrations (last 15):**
|
||||
The current migration set is consolidated (the Supabase-era 001–092 series was collapsed into `0001_init.sql` plus a handful of incremental files). If `db/migrations/` is empty after the Supabase → Postgres migration, see `MEMORY.md` "Direction Pivot" for the cutover notes.
|
||||
|
||||
**Current migrations in `db/migrations/`:**
|
||||
|
||||
| # | File | Purpose |
|
||||
|---|------|---------|
|
||||
| 078 | `wholesale_deposit_guard.sql` | Adds `amount`, `payment_method` to `wholesale_deposits` |
|
||||
| 079 | `resend_analytics.sql` | Adds Resend webhook endpoint + analytics tables |
|
||||
| 080 | `communication_segments.sql` | Adds `communication_segments` table |
|
||||
| 081 | `stop_blast_campaign.sql` | Adds `stop_blast_campaign` RPC |
|
||||
| 082 | `brand_name_in_campaign_email.sql` | Adds `brand_name` column to `communication_campaigns` |
|
||||
| 083 | `shipping_settings.sql` | Adds `shipping_settings` table |
|
||||
| 084 | `shipments.sql` | Adds `shipments` table for FedEx tracking |
|
||||
| 085 | `brand_settings.sql` | Adds `brand_settings` table (core) |
|
||||
| 086 | `brand_settings_email_integration.sql` | Adds email/webhook fields to `brand_settings` |
|
||||
| 087 | `brand_logos_bucket.sql` | Adds `brand-logos` storage bucket |
|
||||
| 088 | `brand_features.sql` | Adds `brand_features` table + RPCs for add-on system |
|
||||
| 089 | `ai_providers_custom_integrations.sql` | Adds AI provider credentials storage |
|
||||
| 090 | `storefront_settings.sql` | Adds storefront customization fields + `get_brand_settings_by_slug` RPC |
|
||||
| 091 | `091_brand_plan_tier.sql` | Adds `plan_tier`, limits, `stripe_customer_id`, `get_brand_plan_info` RPC |
|
||||
| 092 | `092_stripe_subscriptions.sql` | Adds `stripe_subscription_id/status/current_period_end`, `set_brand_subscription`, `get_brand_subscription` RPCs |
|
||||
| 0000 | `0000_qa_neon_auth_stub.sql` | QA-only stub for `neon_auth` schema (real provisioning via Neon Auth in prod) |
|
||||
| 0001 | `0001_init.sql` | Core schema: brands, products, orders, stops, customers, communication tables, water log, time tracking, RPCs, indexes, triggers. Fully re-runnable (`IF NOT EXISTS` + trigger guards). |
|
||||
| 0002 | `0002_admin_password.sql` | Adds `password_hash` column to `admin_users` |
|
||||
| 0003 | `0003_tour_rpc_functions.sql` | RPCs for tour/stop planning |
|
||||
| 0040 | `0040_command_center.sql` | Adds command-center dashboard tables |
|
||||
| 0041 | `0041_command_center_schema_fix.sql` | Schema fixup for command-center |
|
||||
| 0042 | `0042_drop_command_center.sql` | Drops command-center (rolled back) |
|
||||
| 0043 | `0043_admin_users_extra_columns.sql` | Adds `display_name`, `phone_number`, `brand_id`, `can_manage_pickup/messages/refunds/users`, `active`, `must_change_password`, `auth_provider`, `auth_subject`, `last_login` columns to `admin_users` |
|
||||
| 0090 | `0090_water_log_completion.sql` | Completes water-log tables/RPCs |
|
||||
| 0091 | `0091_dashboard_summary_rpc.sql` | `dashboard_summary` RPC for admin dashboard |
|
||||
|
||||
> **Note:** Migration 092 adds Stripe subscription tracking. Migration 091 adds plan tier billing. Apply both before deploying billing changes.
|
||||
> **Note:** The migration runner (`scripts/migrate.js`) tracks applied files in `_migrations` and skips already-applied files. `0001_init.sql` is fully re-runnable, so it is safe to re-apply on a DB that was initialized outside the runner. Stripe subscription tracking, plan-tier billing, brand settings, etc. are all part of `0001_init.sql` — they were originally separate Supabase-era migrations that were consolidated.
|
||||
|
||||
---
|
||||
|
||||
@@ -53,10 +44,11 @@ Copy `.env.local.example` to `.env.local` and fill in all values.
|
||||
|
||||
| Variable | Required | Description |
|
||||
|---|---|---|
|
||||
| `NEXT_PUBLIC_SUPABASE_URL` | Yes | Supabase project URL (e.g., `https://xxx.supabase.co`) |
|
||||
| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Yes | Supabase anon/public key (safe to expose in browser) |
|
||||
| `SUPABASE_SERVICE_ROLE_KEY` | Yes | Supabase service role key — **never expose in browser**, server-side only |
|
||||
| `SUPABASE_PAT` | Yes | Supabase Personal Access Token for migrations CLI |
|
||||
| `DATABASE_URL` | Yes | Postgres connection string (e.g., `postgresql://user:pass@host:5432/dbname?sslmode=require`) |
|
||||
| `DATABASE_ADMIN_URL` | No | Direct (non-pooled) Postgres connection with elevated perms. Only used by `scripts/migrate.js` for DDL — falls back to `DATABASE_URL` if unset. |
|
||||
| `NEON_AUTH_BASE_URL` | Yes | Neon Auth (Better Auth) base URL |
|
||||
| `NEON_AUTH_COOKIE_SECRET` | Yes | Neon Auth session cookie signing secret (min 32 chars) |
|
||||
| `NEXT_PUBLIC_SITE_URL` | Yes | Public site URL used for auth redirects and Origin header |
|
||||
|
||||
### Payments
|
||||
|
||||
@@ -198,17 +190,13 @@ For scheduled tasks (stop reminders, campaign sends), add to `vercel.json` or co
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** Stop reminder emails are triggered by a Supabase time-based trigger (`send_stop_reminder_email`). Verify the trigger is active:
|
||||
```sql
|
||||
SELECT * FROM pg_publication_tables WHERE tablename = 'stops';
|
||||
```
|
||||
If not, recreate: `ALTER PUBLICATION supabase_realtime PUBLISH INSERT, UPDATE ON TABLE stops;`
|
||||
**Note:** Stop reminder emails are fired by Vercel cron jobs (`/api/cron/send-stop-reminders`), not by a database trigger. Verify the cron is configured in `vercel.json` (or Vercel Dashboard → Project → Cron Jobs).
|
||||
|
||||
---
|
||||
|
||||
## 5. Feature Flag Enablement (Post-Deploy)
|
||||
|
||||
After deployment, enable add-ons per brand via the admin UI or Supabase SQL:
|
||||
After deployment, enable add-ons per brand via the admin UI or direct Postgres:
|
||||
|
||||
```sql
|
||||
-- Enable Harvest Reach for a brand
|
||||
@@ -302,13 +290,13 @@ Vercel keeps 10 recent deployments. For critical regressions, promote the previo
|
||||
|
||||
### Database Rollback
|
||||
```bash
|
||||
# Revert last migration via Supabase
|
||||
supabase db reset --backup-id <backup-before-migration>
|
||||
# Revert last migration via Postgres
|
||||
# (Restore from a `pg_dump` snapshot taken before the migration was applied)
|
||||
|
||||
# Or manually revert (example for migration 090)
|
||||
psql $DATABASE_URL -c "ALTER TABLE brand_settings DROP COLUMN IF EXISTS schedule_pdf_notes;"
|
||||
```
|
||||
> **Important:** Only use `supabase db reset` on staging. In production, manually revert individual migrations to avoid data loss.
|
||||
> **Important:** Always restore from a known-good backup. In production, manually revert individual migrations to avoid data loss.
|
||||
|
||||
### Environment Variable Rollback
|
||||
Environment variables are version-controlled in Vercel. Restore a previous version in **Vercel Dashboard → Project → Environment Variables → History**.
|
||||
@@ -318,16 +306,16 @@ Environment variables are version-controlled in Vercel. Restore a previous versi
|
||||
## 8. Known Limitations & Future Work
|
||||
|
||||
### Known Issues
|
||||
- **`dev_session` auth**: The `dev_session` cookie bypasses real Supabase auth. In production, ensure no `dev_session` cookie exists for regular users. The auth bypass only works when `NODE_ENV !== "production"`.
|
||||
- **`dev_session` auth**: The `dev_session` cookie bypasses real Neon Auth (Better Auth). In production, ensure no `dev_session` cookie exists for regular users. The auth bypass only works when `NODE_ENV !== "production"`.
|
||||
- **Water Log visible to all brand admins**: Currently Tuxedo-only by brand ID check. If another brand admin needs access, the check needs to be made configurable.
|
||||
- **Communications tables (no RLS)**: `communication_campaigns`, `communication_templates`, `communication_contacts`, `communication_message_logs` have RLS disabled. All data access is through SECURITY DEFINER RPCs. Do not expose these tables via direct Supabase queries.
|
||||
- **Communications tables (no RLS)**: `communication_campaigns`, `communication_templates`, `communication_contacts`, `communication_message_logs` have no row-level policies. All data access is through SECURITY DEFINER RPCs. Do not expose these tables via direct SQL from client-side code.
|
||||
- **`event_id` not populated in message logs**: `send_campaign` and `send_stop_blast` do not set `event_id`. The Resend webhook looks up logs by `customer_email + subject` instead. This is a known limitation — if Resend events are delayed, the lookup may fail to match.
|
||||
- **Square sync is one-directional**: Products import from Square → Route Commerce, but product edits in Route Commerce do not push back to Square.
|
||||
- **SMS opt-in defaults to `FALSE`**: `communication_contacts.sms_opt_in` defaults to `false`. Users must explicitly opt in for SMS campaigns.
|
||||
- **`brand_id: null` for platform admins**: `getAdminUser()` returns `brand_id: null` for `platform_admin` role. Always pass explicit `brandId` to server action functions.
|
||||
- **Square webhook signature mismatch**: The Square webhook handler uses `crypto.createHmac("sha256", signatureKey)` but Square sends the signature as base64-encoded. Verify signature encoding matches Square's documentation before enabling Square webhooks in production.
|
||||
- **Stripe webhook reliability**: Stripe webhook delivery is not guaranteed to be in-order or immediate. The webhook handler is idempotent — it can safely receive duplicate events. Consider implementing a webhook event log table to track processed event IDs for deduplication.
|
||||
- **`NO_RLS` communications tables**: All communications data is accessible via SECURITY DEFINER RPCs only — never expose direct Supabase queries to these tables in client-side code.
|
||||
- **`NO_RLS` communications tables**: All communications data is accessible via SECURITY DEFINER RPCs only — never expose direct SQL to these tables in client-side code.
|
||||
|
||||
### Future Work (Non-Blocking)
|
||||
- **Cursor-based pagination**: Replace offset-based pagination (`page * limit`) with cursor-based pagination for large datasets (orders, contacts)
|
||||
@@ -344,11 +332,9 @@ Environment variables are version-controlled in Vercel. Restore a previous versi
|
||||
|
||||
| Role | Contact |
|
||||
|------|---------|
|
||||
| Platform Admin | Set via Supabase Auth dashboard |
|
||||
| Platform Admin | First Neon Auth user becomes the platform admin via `provision-admin.ts` |
|
||||
| Kyle Martinez | Primary contact — Route Commerce owner |
|
||||
| Supabase Support | supabase.com/support |
|
||||
| Neon Support | neon.tech/support |
|
||||
| Vercel Support | vercel.com/support |
|
||||
|
||||
For Vercel production incidents: **vercel.com/deployments** → find deployment → "Support" button.
|
||||
|
||||
For Supabase production incidents: **supabase.com/dashboard** → project → "Support" tab.
|
||||
+14
-14
@@ -15,16 +15,16 @@ npm run dev
|
||||
|
||||
## Configuration
|
||||
|
||||
### 1. Clerk Authentication
|
||||
### 1. Neon Auth (Better Auth)
|
||||
|
||||
1. Sign up at [Clerk](https://clerk.com)
|
||||
2. Create a new application
|
||||
3. Copy keys to `.env.local`:
|
||||
1. Set up Neon Auth on your Neon project (`neonctl neon-auth init`)
|
||||
2. Copy keys to `.env.local`:
|
||||
```
|
||||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_xxx
|
||||
CLERK_SECRET_KEY=sk_test_xxx
|
||||
NEON_AUTH_BASE_URL=https://xxx.neonauth.io
|
||||
NEON_AUTH_COOKIE_SECRET=<openssl rand -base64 32>
|
||||
NEXT_PUBLIC_SITE_URL=https://yourdomain.com
|
||||
```
|
||||
4. Configure middleware in `src/proxy.ts`
|
||||
3. Configure middleware in `src/proxy.ts`
|
||||
|
||||
### 2. Stripe Payments
|
||||
|
||||
@@ -37,15 +37,15 @@ npm run dev
|
||||
STRIPE_WEBHOOK_SECRET=whsec_xxx
|
||||
```
|
||||
|
||||
### 3. Supabase Database
|
||||
### 3. Database (Postgres / Neon)
|
||||
|
||||
1. Create project at [Supabase](https://supabase.com)
|
||||
The app talks to Postgres directly via `pg` (no Supabase, no REST gateway). Point `DATABASE_URL` at any reachable Postgres — Neon, Supabase (the underlying Postgres), RDS, etc. The Supabase JS client and PostgREST are not used.
|
||||
|
||||
1. Provision a Postgres database (Neon recommended — it also hosts Neon Auth)
|
||||
2. Run migrations: `npm run migrate`
|
||||
3. Update `.env.local` with:
|
||||
```
|
||||
NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=xxx
|
||||
SUPABASE_SERVICE_ROLE_KEY=xxx
|
||||
DATABASE_URL=postgresql://user:pass@host:5432/dbname?sslmode=require
|
||||
```
|
||||
|
||||
### 4. Optional Services
|
||||
@@ -74,7 +74,7 @@ Ensure environment variables are set before deployment.
|
||||
|
||||
## Key Files
|
||||
|
||||
- `src/proxy.ts` - Clerk middleware
|
||||
- `src/proxy.ts` - Neon Auth (Better Auth) middleware
|
||||
- `src/lib/stripe-billing.ts` - Stripe integration
|
||||
- `src/lib/analytics.ts` - PostHog analytics
|
||||
- `src/lib/sentry.ts` - Sentry error tracking
|
||||
@@ -82,7 +82,7 @@ Ensure environment variables are set before deployment.
|
||||
- `src/components/onboarding/OnboardingFlow.tsx` - User onboarding
|
||||
- `src/components/referral/ReferralSystem.tsx` - Referral tracking
|
||||
- `src/components/changelog/ChangelogFeed.tsx` - Product updates
|
||||
- `supabase/migrations/` - Database schema
|
||||
- `db/migrations/` - Database schema (applied via `scripts/migrate.js`)
|
||||
|
||||
## Scripts
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ Route Commerce helps produce brands run their wholesale operations:
|
||||
## Tech Stack
|
||||
|
||||
- **Framework:** Next.js 16 (App Router)
|
||||
- **Database:** Supabase (Postgres + Auth + RLS)
|
||||
- **Database:** Postgres (direct via `pg` + Drizzle) with Neon Auth (Better Auth)
|
||||
- **Payments:** Stripe + Square
|
||||
- **Email/SMS:** Resend
|
||||
- **Styling:** Tailwind CSS v4
|
||||
@@ -37,10 +37,8 @@ npm install
|
||||
Create a `.env.local` file with:
|
||||
|
||||
```bash
|
||||
# Supabase
|
||||
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
|
||||
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
|
||||
# Database (Postgres)
|
||||
DATABASE_URL=postgresql://user:pass@host:5432/dbname?sslmode=require
|
||||
|
||||
# Stripe
|
||||
STRIPE_SECRET_KEY=sk_test_...
|
||||
@@ -56,10 +54,9 @@ RESEND_API_KEY=re_...
|
||||
|
||||
### 3. Set up the database
|
||||
|
||||
Link your Supabase project, then push migrations:
|
||||
The migrations live in `db/migrations/` and are applied via the `pg` driver (no Supabase CLI):
|
||||
|
||||
```bash
|
||||
supabase link --project-ref <your-project-ref>
|
||||
npm run migrate
|
||||
```
|
||||
|
||||
@@ -75,7 +72,7 @@ npm run migrate:one 83
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000). The dev server auto-runs `fix-agents.js` to patch Next.js App Router agent issues.
|
||||
Open [http://localhost:4000](http://localhost:4000). The dev server auto-runs `fix-agents.js` to patch Next.js App Router agent issues. (The dev script binds to `0.0.0.0:4000`; override with `npm run dev -- -p <port>` if you need a different port.)
|
||||
|
||||
### 5. Dev auth bypass
|
||||
|
||||
@@ -111,7 +108,7 @@ src/
|
||||
│ ├── admin/ # Admin-specific components
|
||||
│ └── storefront/ # Storefront components
|
||||
└── lib/ # Utilities, auth, feature flags, formatting
|
||||
supabase/
|
||||
db/
|
||||
└── migrations/ # SQL migrations (numbered sequentially)
|
||||
```
|
||||
|
||||
@@ -253,7 +250,7 @@ CRON_SECRET=... # Bearer token to secure cron endpoints (generate
|
||||
|
||||
```bash
|
||||
# Run abandoned cart with verbose output
|
||||
curl -X POST http://localhost:3000/api/email-automation/abandoned-cart \
|
||||
curl -X POST http://localhost:4000/api/email-automation/abandoned-cart \
|
||||
-H "Authorization: Bearer local-dev-secret" \
|
||||
-H "Content-Type: application/json"
|
||||
|
||||
@@ -315,8 +312,8 @@ curl -X POST https://route-commerce-platform.vercel.app/api/email-automation/wel
|
||||
|
||||
## Notes
|
||||
|
||||
- All database writes go through **server actions** (`src/actions/`), not the Supabase JS client directly
|
||||
- Dev mode bypasses Supabase auth via `dev_session` cookie — never use this in production
|
||||
- All database writes go through **server actions** (`src/actions/`) over a shared `pg` `Pool` from `src/lib/db.ts` — no Supabase JS client, no REST gateway
|
||||
- Dev mode bypasses Neon Auth via `dev_session` cookie — never use this in production
|
||||
- Brand-scoped data is enforced at the application layer via `p_brand_id` parameters in SECURITY DEFINER RPCs
|
||||
- Display dates use `formatDate()` (MM/DD/YYYY) — never raw `toLocaleDateString()`
|
||||
- Migration files are numbered sequentially — never reuse numbers
|
||||
@@ -4,6 +4,8 @@
|
||||
**Mission:** Stand up a working Auth.js v5 + Google sign-in flow, then rip out
|
||||
all Supabase references from the auth/admin path.
|
||||
|
||||
> **Note (2026-06-25):** Auth has since moved from Auth.js (NextAuth v5) to **Neon Auth (Better Auth)** — see `CLAUDE.md` for the current architecture. The Supabase purge described below is complete: `supabase/` directory deleted, `@supabase/*` deps removed from `package.json`, `next.config.ts` / `vercel.json` / `eslint.config.mjs` cleaned of Supabase hostnames and ignore patterns, and the remaining `*.supabase.co` references are limited to historical/educational comments in `MEMORY.md` and `docs/`.
|
||||
|
||||
---
|
||||
|
||||
## What was built
|
||||
@@ -171,7 +173,7 @@ src/actions/ai/preferences.ts
|
||||
|
||||
1. **Provision a `pg` pool in production.** `DATABASE_URL` must be
|
||||
set in the hosting dashboard (same env var as
|
||||
`supabase/push-migrations.js` uses).
|
||||
`scripts/migrate.js` uses).
|
||||
2. **Apply migration 204** if not already applied — adds
|
||||
`email`, `auth_provider`, `auth_subject` columns to `admin_users`
|
||||
and the `get_admin_user_for_session` RPC.
|
||||
@@ -192,6 +194,15 @@ src/actions/ai/preferences.ts
|
||||
7. **Delete `@supabase/ssr` and `@supabase/supabase-js` from
|
||||
`package.json`** (last step once no file imports them).
|
||||
|
||||
### Resolution (2026-06-25)
|
||||
|
||||
Follow-ups 6 and 7 (and the data-fetching migration of all 33 files) are
|
||||
complete. The codebase is now Supabase-free at the source-code level —
|
||||
`grep -rln '@supabase\|rest/v1' src/` returns zero matches. The only
|
||||
remaining mentions of "Supabase" are in this report, `MEMORY.md`, and a
|
||||
handful of historical `docs/superpowers/{plans,specs}/` files, all kept
|
||||
as historical record of the migration.
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
-- Stops: 269 | Locations: 40
|
||||
-- Preferred: npx tsx scripts/import-tuxedo-stops.ts
|
||||
-- Manual apply: psql "$DATABASE_URL" -f db/seeds/2026-tuxedo-tour-stops.sql
|
||||
-- supabase db query --linked --file db/seeds/2026-tuxedo-tour-stops.sql
|
||||
|
||||
BEGIN;
|
||||
|
||||
|
||||
+1
-1
@@ -180,7 +180,7 @@ data explicitly checks its gate.
|
||||
cold-start paths; the active `getAdminUser().brand_id` always wins
|
||||
on later requests.
|
||||
- No Supabase REST, no service-role keys — all access is direct
|
||||
through Drizzle over a single `pg` Pool.
|
||||
through Drizzle over a single shared `pg` Pool (see `src/lib/db.ts`).
|
||||
|
||||
---
|
||||
|
||||
|
||||
+1
-2
@@ -14,7 +14,6 @@ const eslintConfig = defineConfig([
|
||||
"next-env.d.ts",
|
||||
// Ignore legacy .js scripts that use CommonJS
|
||||
"scripts/**",
|
||||
"supabase/**",
|
||||
"fix-agents.js",
|
||||
]),
|
||||
// Allow setState in useEffect for PWA prompts and client-side state initialization
|
||||
@@ -26,7 +25,7 @@ const eslintConfig = defineConfig([
|
||||
},
|
||||
// Relax some rules for legacy code
|
||||
{
|
||||
files: ["db/**", "scripts/**", "supabase/**"],
|
||||
files: ["db/**", "scripts/**"],
|
||||
rules: {
|
||||
"@typescript-eslint/no-require-imports": "off",
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
|
||||
+6
-9
@@ -22,10 +22,6 @@ const nextConfig: NextConfig = {
|
||||
// Optimize images
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "*.supabase.co",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "images.unsplash.com",
|
||||
@@ -124,11 +120,12 @@ const nextConfig: NextConfig = {
|
||||
{ source: "/admin/orders/:id", destination: "/admin/v2/orders/:id", permanent: false },
|
||||
{ source: "/admin/stops", destination: "/admin/v2/stops", permanent: false },
|
||||
{ source: "/admin/products", destination: "/admin/v2/products", permanent: false },
|
||||
// v1 admin pages that hit the dead supabase shim. There are no v2
|
||||
// equivalents yet (reports / taxes / settings sub-pages are still
|
||||
// TBD on the v2 surface), so they redirect to the v2 dashboard
|
||||
// for now. Once those modules ship on v2, the redirects can be
|
||||
// tightened to point at the new pages.
|
||||
// v1 admin pages that were removed when the supabase shim was
|
||||
// deleted. There are no v2 equivalents yet (reports / taxes /
|
||||
// settings sub-pages are still TBD on the v2 surface), so they
|
||||
// redirect to the v2 dashboard for now. Once those modules ship
|
||||
// on v2, the redirects can be tightened to point at the new
|
||||
// pages.
|
||||
{ source: "/admin/products/:id", destination: "/admin/v2/products", permanent: false },
|
||||
{ source: "/admin/reports", destination: "/admin/v2", permanent: false },
|
||||
{ source: "/admin/taxes", destination: "/admin/v2", permanent: false },
|
||||
|
||||
@@ -34,8 +34,6 @@
|
||||
"@sentry/nextjs": "^10.55.0",
|
||||
"@stripe/react-stripe-js": "^6.6.0",
|
||||
"@stripe/stripe-js": "^9.7.0",
|
||||
"@supabase/ssr": "^0.10.2",
|
||||
"@supabase/supabase-js": "^2.105.3",
|
||||
"@upstash/ratelimit": "^2.0.8",
|
||||
"@upstash/redis": "^1.38.0",
|
||||
"drizzle-orm": "^0.36.4",
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* apply-admin-create-stop.js
|
||||
*
|
||||
* Applies the admin_create_stop RPC using ONLY the keys in .env.local.
|
||||
* You do NOT need Supabase dashboard / SQL editor access.
|
||||
*
|
||||
* This is the fix for the "could not find the function public.admin_create_stop(...)"
|
||||
* error when you click "Add New Stop".
|
||||
*
|
||||
* Usage (run from the repo root on a machine that can reach the DB):
|
||||
* node scripts/apply-admin-create-stop.js
|
||||
*
|
||||
* The script connects as the real postgres user (using your SUPABASE_SERVICE_ROLE_KEY
|
||||
* as the password) and runs the CREATE OR REPLACE + GRANT + NOTIFY.
|
||||
* This is the only reliable way to create the SECURITY DEFINER function because
|
||||
* direct REST inserts are blocked by the `block_stops_mutations` policy.
|
||||
*
|
||||
* After it prints success:
|
||||
* - Restart your `npm run dev`
|
||||
* - "Add New Stop" (AddStopModal / NewStopForm) will now call the RPC successfully.
|
||||
*/
|
||||
|
||||
const { Client } = require("pg");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
// Load .env.local (same as the rest of the project)
|
||||
try {
|
||||
require("dotenv").config({ path: path.resolve(__dirname, "../.env.local") });
|
||||
} catch (e) {
|
||||
// dotenv might not be hoisted in some setups; the keys may already be in process.env
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
|
||||
if (!supabaseUrl || !serviceKey) {
|
||||
console.error("❌ Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in .env.local");
|
||||
console.error(" Make sure you are running from the repo root and .env.local exists with the keys.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Derive the direct Postgres URL (same logic as supabase/push-migrations.js)
|
||||
const projectRef = supabaseUrl.replace("https://", "").split(".")[0];
|
||||
const pgHost = `db.${projectRef}.supabase.co`;
|
||||
const dbUrl = `postgresql://postgres:${encodeURIComponent(serviceKey)}@${pgHost}:5432/postgres`;
|
||||
|
||||
console.log("🔧 apply-admin-create-stop RPC installer");
|
||||
console.log(" Project:", projectRef);
|
||||
console.log(" Using service role key from .env.local (full Postgres access)");
|
||||
console.log("");
|
||||
|
||||
async function main() {
|
||||
const migrationPath = path.resolve(__dirname, "../supabase/migrations/202_fix_admin_create_stop.sql");
|
||||
|
||||
if (!fs.existsSync(migrationPath)) {
|
||||
console.error("❌ Cannot find migration file:", migrationPath);
|
||||
console.error(" The file should have been created by the previous fix.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const sql = fs.readFileSync(migrationPath, "utf8");
|
||||
|
||||
const client = new Client({
|
||||
connectionString: dbUrl,
|
||||
ssl: { rejectUnauthorized: false },
|
||||
// Some environments need a longer timeout
|
||||
connectionTimeoutMillis: 15000,
|
||||
});
|
||||
|
||||
try {
|
||||
console.log("⏳ Connecting to Postgres (this uses port 5432 outbound)...");
|
||||
await client.connect();
|
||||
console.log("✅ Connected.");
|
||||
|
||||
console.log("⏳ Executing 202_fix_admin_create_stop.sql (CREATE OR REPLACE + GRANT + NOTIFY)...");
|
||||
const result = await client.query(sql);
|
||||
|
||||
console.log("✅ Function installed / updated successfully.");
|
||||
console.log(" Rows affected in last statement:", result.rowCount ?? "(see output above if any)");
|
||||
console.log("");
|
||||
console.log("🎉 admin_create_stop is now available via the anon key (SECURITY DEFINER).");
|
||||
|
||||
// Quick verification using the anon key over HTTPS (this is what the app uses)
|
||||
try {
|
||||
const anon = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||
const probeBody = {
|
||||
p_active: false,
|
||||
p_address: null,
|
||||
p_brand_id: "00000000-0000-0000-0000-000000000000", // will FK fail, but proves the function exists
|
||||
p_city: "verify",
|
||||
p_cutoff_time: null,
|
||||
p_date: "2099-12-31",
|
||||
p_location: "verify",
|
||||
p_state: "TS",
|
||||
p_time: "10:00",
|
||||
p_zip: null,
|
||||
};
|
||||
const probe = await fetch(`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/admin_create_stop`, {
|
||||
method: "POST",
|
||||
headers: { apikey: anon, "Content-Type": "application/json" },
|
||||
body: JSON.stringify(probeBody),
|
||||
});
|
||||
const txt = await probe.text();
|
||||
if (probe.status === 404) {
|
||||
console.log("⚠️ Post-apply probe still got 404 — you may need to wait a few seconds or the NOTIFY didn't take.");
|
||||
} else if (/foreign key|violates|brand_id/i.test(txt)) {
|
||||
console.log("✅ Post-apply probe: function exists (got expected FK error on the zero-UUID brand — this is success).");
|
||||
} else {
|
||||
console.log("✅ Post-apply probe returned:", txt.slice(0, 200));
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(" (verification fetch skipped:", e.message, ")");
|
||||
}
|
||||
|
||||
console.log("");
|
||||
console.log("Next:");
|
||||
console.log(" • Restart your dev server (`npm run dev`) if it was already running.");
|
||||
console.log(" • Go to the stops admin page and try \"Add New Stop\".");
|
||||
console.log(" • (Optional but recommended) node scripts/verify-stop-fns.js");
|
||||
} catch (err) {
|
||||
const msg = (err && err.message) ? err.message : String(err);
|
||||
console.error("❌ Failed to apply the function.");
|
||||
console.error(" Error:", msg.slice(0, 500));
|
||||
|
||||
if (/ENOTFOUND|EAI_AGAIN|getaddrinfo|network is unreachable|ECONNREFUSED|timeout/i.test(msg)) {
|
||||
console.log("");
|
||||
console.log("💡 Your current environment cannot reach the database on port 5432.");
|
||||
console.log(" This is common inside some containers/CI sandboxes.");
|
||||
console.log("");
|
||||
console.log(" Copy and run ONE of the following on a *normal* laptop/terminal that has");
|
||||
console.log(" outbound access to Supabase Postgres (most developer machines can reach it):");
|
||||
console.log("");
|
||||
console.log(" Option A (easiest, uses exactly the same code as this script):");
|
||||
console.log(" npm run migrate:one 202");
|
||||
console.log("");
|
||||
console.log(" Option B (psql):");
|
||||
console.log(" # Build the URL using the key from your .env.local (same as this script does):");
|
||||
console.log(" # psql \"postgresql://postgres:PUT_YOUR_SERVICE_ROLE_KEY_HERE@db." + projectRef + ".supabase.co:5432/postgres\" \\");
|
||||
console.log(" # -f supabase/migrations/202_fix_admin_create_stop.sql");
|
||||
console.log("");
|
||||
console.log(" Option C (Supabase CLI):");
|
||||
console.log(" supabase db push --db-url \"postgresql://postgres:YOUR_SERVICE_ROLE_KEY@db." + projectRef + ".supabase.co:5432/postgres\" \\");
|
||||
console.log(" supabase/migrations/202_fix_admin_create_stop.sql");
|
||||
console.log("");
|
||||
console.log(" After the command succeeds you will see the NOTIFY and the function will exist.");
|
||||
console.log(" Then restart `npm run dev` and \"Add New Stop\" will stop showing the function-not-found error.");
|
||||
}
|
||||
|
||||
process.exit(1);
|
||||
} finally {
|
||||
try { await client.end(); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error("Unexpected crash:", e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
require("dotenv").config({ path: require("path").resolve(__dirname, "../.env.local") });
|
||||
|
||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||
|
||||
const headers = {
|
||||
apikey: serviceKey,
|
||||
Authorization: `Bearer ${serviceKey}`,
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
async function rpc(name, body = {}) {
|
||||
const r = await fetch(`${url}/rest/v1/rpc/${name}`, {
|
||||
method: "POST", headers, body: JSON.stringify(body),
|
||||
});
|
||||
return { status: r.status, text: (await r.text()).slice(0, 500) };
|
||||
}
|
||||
|
||||
(async () => {
|
||||
// 1. Try calling admin_create_stop with service role
|
||||
console.log("=== 1. Call admin_create_stop (service role) ===");
|
||||
console.log(await rpc("admin_create_stop", { p_brand_id: null }));
|
||||
|
||||
// 2. Try with body
|
||||
console.log("\n=== 2. Call admin_create_stop with stub body ===");
|
||||
console.log(await rpc("admin_create_stop", {
|
||||
p_brand_id: "00000000-0000-0000-0000-000000000000",
|
||||
p_city: "test", p_state: "CO", p_location: "x", p_date: "2025-01-01", p_time: "08:00"
|
||||
}));
|
||||
|
||||
// 3. Try with a name that obviously doesn't exist to see the error format
|
||||
console.log("\n=== 3. Call a non-existent function (control) ===");
|
||||
console.log(await rpc("definitely_does_not_exist_xyz", {}));
|
||||
|
||||
// 4. Use the PostgREST introspection — query information_schema via rpc
|
||||
// PostgREST exposes pg_proc through /rest/v1/ for system tables is not allowed
|
||||
// But we can use the OpenAPI spec endpoint
|
||||
console.log("\n=== 4. OpenAPI spec — looking for admin_create_stop ===");
|
||||
const openApi = await fetch(`${url}/rest/v1/`, { headers: { apikey: anonKey, Authorization: `Bearer ${anonKey}` } });
|
||||
const text = await openApi.text();
|
||||
const matches = text.match(/admin_create_stop[a-z_]*/g) || [];
|
||||
console.log("Matches in OpenAPI:", matches);
|
||||
|
||||
// 5. Get all function names from OpenAPI
|
||||
const funcMatches = [...text.matchAll(/"([a-z][a-z0-9_]*)\s*\{/g)].map(m => m[1]);
|
||||
const stopFns = funcMatches.filter(n => n.includes("stop"));
|
||||
console.log("\nStop-related functions in OpenAPI:", stopFns);
|
||||
})();
|
||||
@@ -1,52 +0,0 @@
|
||||
require("dotenv").config({ path: require("path").resolve(__dirname, "../.env.local") });
|
||||
const { Client } = require("pg");
|
||||
const dns = require("dns");
|
||||
|
||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const projectRef = url.replace("https://", "").split(".")[0];
|
||||
const candidates = [
|
||||
`db.${projectRef}.supabase.co`,
|
||||
`aws-0-us-east-1.pooler.supabase.com`,
|
||||
`aws-0-us-west-1.pooler.supabase.com`,
|
||||
];
|
||||
|
||||
(async () => {
|
||||
for (const host of candidates) {
|
||||
try {
|
||||
const ip = await new Promise((resolve, reject) => {
|
||||
dns.resolve4(host, (err, addrs) => err ? reject(err) : resolve(addrs));
|
||||
});
|
||||
console.log(`${host} -> ${ip.join(", ")}`);
|
||||
} catch (e) {
|
||||
console.log(`${host} -> DNS FAIL: ${e.code}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Try direct DB with port 6543 (pooler) using supabase format
|
||||
// Username pattern: postgres.<project-ref>
|
||||
const client = new Client({
|
||||
host: "aws-0-us-east-1.pooler.supabase.com",
|
||||
port: 6543, database: "postgres",
|
||||
user: `postgres.${projectRef}`,
|
||||
password: process.env.SUPABASE_SERVICE_ROLE_KEY,
|
||||
ssl: { rejectUnauthorized: false },
|
||||
});
|
||||
try {
|
||||
await client.connect();
|
||||
const r = await client.query(`
|
||||
SELECT p.proname,
|
||||
pg_get_function_identity_arguments(p.oid) AS id_args,
|
||||
p.prosecdef AS secdef
|
||||
FROM pg_proc p JOIN pg_namespace n ON p.pronamespace=n.oid
|
||||
WHERE n.nspname='public' AND p.proname IN
|
||||
('admin_create_stop','admin_create_stops_batch','delete_stop')
|
||||
ORDER BY p.proname;
|
||||
`);
|
||||
console.log("\nFUNCTIONS IN DB:");
|
||||
console.log(JSON.stringify(r.rows, null, 2));
|
||||
} catch (e) {
|
||||
console.error("POOLER ERR:", e.message);
|
||||
} finally {
|
||||
try { await client.end(); } catch {}
|
||||
}
|
||||
})();
|
||||
@@ -1,44 +0,0 @@
|
||||
require("dotenv").config({ path: require("path").resolve(__dirname, "../.env.local") });
|
||||
const { Client } = require("pg");
|
||||
|
||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const projectRef = url.replace("https://", "").split(".")[0];
|
||||
const pw = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
|
||||
const tries = [
|
||||
{ host: "aws-0-us-east-1.pooler.supabase.com", port: 6543, user: `postgres.${projectRef}`, database: "postgres" },
|
||||
{ host: "aws-0-us-east-1.pooler.supabase.com", port: 5432, user: `postgres.${projectRef}`, database: "postgres" },
|
||||
{ host: "aws-0-us-east-1.pooler.supabase.com", port: 6543, user: "postgres", database: "postgres" },
|
||||
];
|
||||
|
||||
(async () => {
|
||||
for (const cfg of tries) {
|
||||
const client = new Client({ ...cfg, password: pw, ssl: { rejectUnauthorized: false } });
|
||||
try {
|
||||
await client.connect();
|
||||
const r = await client.query(`
|
||||
SELECT p.proname,
|
||||
pg_get_function_identity_arguments(p.oid) AS id_args,
|
||||
p.prosecdef AS secdef
|
||||
FROM pg_proc p JOIN pg_namespace n ON p.pronamespace=n.oid
|
||||
WHERE n.nspname='public' AND p.proname IN
|
||||
('admin_create_stop','admin_create_stops_batch','delete_stop')
|
||||
ORDER BY p.proname;
|
||||
`);
|
||||
console.log(`\n✓ ${cfg.host}:${cfg.port} user=${cfg.user}`);
|
||||
console.log(JSON.stringify(r.rows, null, 2));
|
||||
|
||||
// Also check if migration tracking table exists
|
||||
const trk = await client.query(`
|
||||
SELECT version, name FROM supabase_migrations.schema_migrations
|
||||
WHERE version >= 145 ORDER BY version;
|
||||
`).catch(() => ({ rows: [] }));
|
||||
console.log("Recent migrations applied:", trk.rows);
|
||||
await client.end();
|
||||
return;
|
||||
} catch (e) {
|
||||
console.log(`✗ ${cfg.host}:${cfg.port} user=${cfg.user} → ${e.message}`);
|
||||
try { await client.end(); } catch {}
|
||||
}
|
||||
}
|
||||
})();
|
||||
@@ -1,78 +0,0 @@
|
||||
/**
|
||||
* Cleanup script: remove duplicate stops from the Tuxedo Corn 2026 tour.
|
||||
* Keeps the row with the latest created_at per (brand_id, date, city, state, location, time).
|
||||
* Run: DATABASE_URL="postgresql://..." npx tsx scripts/cleanup-duplicate-stops.ts
|
||||
*/
|
||||
import { Pool } from "pg";
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString:
|
||||
process.env.DATABASE_ADMIN_URL ??
|
||||
process.env.DATABASE_URL ??
|
||||
"postgres://postgres:postgres@localhost:5432/route_commerce",
|
||||
});
|
||||
|
||||
async function main() {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
// Find duplicate groups (same brand_id, date, city, state, location, time)
|
||||
const dupes = await client.query(`
|
||||
SELECT brand_id::text, date, city, state, location, time, count(*) as cnt, min(created_at::text) as oldest, max(created_at::text) as newest
|
||||
FROM stops
|
||||
WHERE deleted_at IS NULL
|
||||
GROUP BY brand_id, date, city, state, location, time
|
||||
HAVING count(*) > 1
|
||||
ORDER BY date, city
|
||||
`);
|
||||
|
||||
console.log(`Found ${dupes.rowCount} duplicate groups`);
|
||||
if (dupes.rowCount === 0) {
|
||||
await client.query("COMMIT");
|
||||
console.log("No duplicates found.");
|
||||
return;
|
||||
}
|
||||
|
||||
let totalDeleted = 0;
|
||||
for (const row of dupes.rows) {
|
||||
// Keep the row with the latest created_at, delete the rest
|
||||
const del = await client.query(`
|
||||
DELETE FROM stops
|
||||
WHERE brand_id = $1
|
||||
AND date = $2
|
||||
AND city = $3
|
||||
AND state = $4
|
||||
AND location = $5
|
||||
AND time IS NOT DISTINCT FROM $6
|
||||
AND deleted_at IS NULL
|
||||
AND created_at < (
|
||||
SELECT max(created_at) FROM stops s2
|
||||
WHERE s2.brand_id = stops.brand_id
|
||||
AND s2.date = stops.date
|
||||
AND s2.city = stops.city
|
||||
AND s2.state = stops.state
|
||||
AND s2.location = stops.location
|
||||
AND (s2.time IS NOT DISTINCT FROM stops.time)
|
||||
AND s2.deleted_at IS NULL
|
||||
)
|
||||
`, [row.brand_id, row.date, row.city, row.state, row.location, row.time]);
|
||||
totalDeleted += del.rowCount ?? 0;
|
||||
console.log(` Deleted ${del.rowCount} duplicate(s) for ${row.city}, ${row.state} on ${row.date}`);
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
console.log(`\nDone. Total duplicates removed: ${totalDeleted}`);
|
||||
} catch (e) {
|
||||
await client.query("ROLLBACK");
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,61 +0,0 @@
|
||||
/**
|
||||
* Create an admin user in Neon Auth via direct HTTP call.
|
||||
* Run: npx tsx scripts/create-admin-user.ts [email] [password] [name]
|
||||
*
|
||||
* Makes a direct HTTP request to the Neon Auth API so we don't need
|
||||
* a Next.js request context, then updates email_verified in the DB.
|
||||
*/
|
||||
import { config } from "dotenv";
|
||||
config({ path: ".env.local" });
|
||||
import pg from "pg";
|
||||
|
||||
const { Pool } = pg;
|
||||
|
||||
async function main() {
|
||||
const email = process.argv[2] ?? "admin@example.com";
|
||||
const password = process.argv[3] ?? "Admin1234!";
|
||||
const name = process.argv[4] ?? "Admin";
|
||||
|
||||
const baseUrl = process.env.NEON_AUTH_BASE_URL!;
|
||||
|
||||
console.log(`Creating user: ${email}`);
|
||||
|
||||
const res = await fetch(`${baseUrl}/sign-up/email`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Origin": "http://localhost:4000",
|
||||
"x-neon-auth-proxy": "node",
|
||||
},
|
||||
body: JSON.stringify({ email, password, name, callbackURL: "/admin" }),
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
console.log(`Sign-up status: ${res.status}`);
|
||||
|
||||
if (!res.ok) {
|
||||
console.error("Failed to create user:", JSON.stringify(data));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const userId = data.user?.id;
|
||||
console.log("User created:", data.user?.email, "| ID:", userId);
|
||||
|
||||
// Mark email verified in DB so they can log in immediately
|
||||
if (userId) {
|
||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||
try {
|
||||
await pool.query(
|
||||
"UPDATE neon_auth.user SET email_verified = true WHERE id = $1",
|
||||
[userId]
|
||||
);
|
||||
console.log("Email verified in DB.");
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nLog in at http://localhost:4000/login with ${email} / ${password}`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,99 +0,0 @@
|
||||
#!/bin/bash
|
||||
# End-to-end validation test for the local Postgres + PostgREST + MinIO + Next.js stack
|
||||
# Exit 0 = all green, exit 1 = at least one failure.
|
||||
|
||||
set -e
|
||||
API="http://localhost:3001"
|
||||
WEB="http://localhost:4000"
|
||||
|
||||
pass=0
|
||||
fail=0
|
||||
declare -a FAILURES
|
||||
|
||||
check() {
|
||||
local name="$1" url="$2" expected="${3:-200}" cookies="${4:-}"
|
||||
local cmd="curl -s -o /dev/null -w '%{http_code}'"
|
||||
if [ -n "$cookies" ]; then cmd="$cmd -b \"$cookies\""; fi
|
||||
local code=$(eval "$cmd $url")
|
||||
if [ "$code" = "$expected" ]; then
|
||||
echo " PASS $name ($code)"
|
||||
pass=$((pass+1))
|
||||
else
|
||||
echo " FAIL $name expected=$expected got=$code url=$url"
|
||||
fail=$((fail+1))
|
||||
FAILURES+=("$name")
|
||||
fi
|
||||
}
|
||||
|
||||
echo "=== Postgres connection ==="
|
||||
PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT 'ok';" >/dev/null
|
||||
echo " PASS postgres responds"
|
||||
pass=$((pass+1))
|
||||
|
||||
echo "=== DB integrity ==="
|
||||
TABLE_COUNT=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT count(*) FROM information_schema.tables WHERE table_schema='public';")
|
||||
[ "$TABLE_COUNT" -ge 65 ] && echo " PASS $TABLE_COUNT public tables (>=65)" && pass=$((pass+1)) || { echo " FAIL $TABLE_COUNT tables"; fail=$((fail+1)); }
|
||||
|
||||
FN_COUNT=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT count(*) FROM pg_proc p JOIN pg_namespace n ON p.pronamespace=n.oid WHERE n.nspname='public';")
|
||||
[ "$FN_COUNT" -ge 250 ] && echo " PASS $FN_COUNT functions (>=250)" && pass=$((pass+1)) || { echo " FAIL $FN_COUNT functions"; fail=$((fail+1)); }
|
||||
|
||||
BRANDS=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT count(*) FROM brands;")
|
||||
[ "$BRANDS" -ge 2 ] && echo " PASS $BRANDS brands" && pass=$((pass+1)) || { echo " FAIL $BRANDS brands"; fail=$((fail+1)); }
|
||||
|
||||
STOPS=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT count(*) FROM stops WHERE active=true AND deleted_at IS NULL;")
|
||||
[ "$STOPS" -ge 200 ] && echo " PASS $STOPS active stops" && pass=$((pass+1)) || { echo " FAIL $STOPS stops"; fail=$((fail+1)); }
|
||||
|
||||
# No test brands
|
||||
TEST_BRANDS=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT count(*) FROM brands WHERE slug IN ('sunrise-farms','green-valley','orchard-fresh');")
|
||||
[ "$TEST_BRANDS" = "0" ] && echo " PASS no test brands" && pass=$((pass+1)) || { echo " FAIL test brands found"; fail=$((fail+1)); }
|
||||
|
||||
echo "=== Tuxedo Corn data ==="
|
||||
PHONE=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT phone FROM brand_settings WHERE brand_id='64294306-5f42-463d-a5e8-2ad6c81a96de';")
|
||||
[ "$PHONE" = "970-323-6874" ] && echo " PASS phone=$PHONE" && pass=$((pass+1)) || { echo " FAIL phone=$PHONE (expected 970-323-6874)"; fail=$((fail+1)); }
|
||||
|
||||
LOGOS=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT count(*) FROM brand_settings WHERE brand_id='64294306-5f42-463d-a5e8-2ad6c81a96de' AND logo_url LIKE '/storage/%';")
|
||||
[ "$LOGOS" = "1" ] && echo " PASS logo_url is /storage/ path" && pass=$((pass+1)) || { echo " FAIL logo_url not /storage/"; fail=$((fail+1)); }
|
||||
|
||||
echo "=== PostgREST ==="
|
||||
check "GET /" "$API/"
|
||||
check "GET /brands" "$API/brands?select=id,name,slug&order=name"
|
||||
check "GET /brands?slug=eq.tuxedo" "$API/brands?slug=eq.tuxedo&select=*"
|
||||
check "GET /stops" "$API/stops?select=id,city&limit=5"
|
||||
check "GET /products" "$API/products?select=id,name&limit=5"
|
||||
check "POST rpc get_brand_settings_by_slug" "$API/rpc/get_brand_settings_by_slug" 200 \
|
||||
"-X POST -H Content-Type:application/json -d {\"p_brand_slug\":\"tuxedo\"}"
|
||||
check "POST rpc get_brand_plan_info" "$API/rpc/get_brand_plan_info" 200 \
|
||||
"-X POST -H Content-Type:application/json -d {\"p_brand_id\":\"64294306-5f42-463d-a5e8-2ad6c81a96de\"}"
|
||||
check "POST rpc get_public_stops_for_brand" "$API/rpc/get_public_stops_for_brand" 200 \
|
||||
"-X POST -H Content-Type:application/json -d {\"p_brand_slug\":\"tuxedo\"}"
|
||||
check "GET /admin_users with brand join" "$API/admin_users?select=id,user_id,role,brand_id,brands(name)&limit=5"
|
||||
check "GET /stops with brand join" "$API/stops?select=id,city,brand_id,brands(name)&limit=5"
|
||||
|
||||
echo "=== MinIO ==="
|
||||
check "Storage logo.png" "$WEB/storage/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/logo.png"
|
||||
check "Storage olathe-sweet-logo.png" "$WEB/storage/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo.png"
|
||||
check "Storage olathe-sweet-logo-dark.png" "$WEB/storage/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo-dark.png"
|
||||
|
||||
echo "=== Public storefronts ==="
|
||||
check "GET /" "$WEB/"
|
||||
check "GET /tuxedo" "$WEB/tuxedo"
|
||||
check "GET /tuxedo/about" "$WEB/tuxedo/about"
|
||||
check "GET /tuxedo/stops" "$WEB/tuxedo/stops"
|
||||
check "GET /indian-river-direct" "$WEB/indian-river-direct"
|
||||
check "GET /login" "$WEB/login"
|
||||
|
||||
echo "=== Admin pages (dev_session=platform_admin) ==="
|
||||
for p in /admin /admin/products /admin/stops /admin/orders /admin/users /admin/settings /admin/settings/billing /admin/settings/apps /admin/settings/payments /admin/communications /admin/communications/compose /admin/time-tracking /admin/wholesale /admin/water-log /admin/analytics /admin/reports; do
|
||||
check "GET $p" "$WEB$p" 200 "dev_session=platform_admin"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=== Summary ==="
|
||||
echo " PASS: $pass"
|
||||
echo " FAIL: $fail"
|
||||
if [ $fail -gt 0 ]; then
|
||||
echo " Failures:"
|
||||
for f in "${FAILURES[@]}"; do echo " - $f"; done
|
||||
exit 1
|
||||
fi
|
||||
echo " ALL GREEN"
|
||||
@@ -1,228 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* fix-archived-rls.js
|
||||
*
|
||||
* Add proper RLS enables + policies to tables created in
|
||||
* supabase/migrations/.archived/*.sql files. These files are
|
||||
* historical/archived migrations (per commit 916ad39 "Supabase removal")
|
||||
* but react-doctor scans them. We satisfy the rule with real, correct
|
||||
* policies scoped by user_id (auth.uid()) or brand_id (current_brand_id())
|
||||
* where possible; deny-all otherwise.
|
||||
*
|
||||
* Tables are detected by parsing CREATE TABLE blocks. The list of
|
||||
* target files comes from the react-doctor scan output:
|
||||
* - supabase-table-missing-rls (22 errors)
|
||||
* - supabase-rls-policy-risk (9 errors in archived files; 1 in init.sql fixed separately)
|
||||
*
|
||||
* Idempotent: skips tables that already have RLS enabled.
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const ARCHIVE_DIR = path.join(__dirname, "..", "supabase", "migrations", ".archived");
|
||||
|
||||
// Helper policy templates.
|
||||
// `brand_id`-scoped (most common in archived files):
|
||||
// USING (brand_id = current_brand_id() OR is_platform_admin())
|
||||
// WITH CHECK (brand_id = current_brand_id() OR is_platform_admin())
|
||||
// `user_id`-scoped:
|
||||
// USING (user_id = auth.uid() OR is_platform_admin())
|
||||
// WITH CHECK (user_id = auth.uid() OR is_platform_admin())
|
||||
// Public-read (e.g. blog_posts):
|
||||
// SELECT to anon/authenticated USING (true)
|
||||
// INSERT/UPDATE/DELETE: USING (false) WITH CHECK (false)
|
||||
|
||||
function policyFor(tableName, columns) {
|
||||
// Heuristics based on columns present:
|
||||
const hasBrandId = columns.some(c => /^brand_id\b/i.test(c));
|
||||
const hasUserId = columns.some(c => /^user_id\b/i.test(c) && !/"userId"/i.test(c));
|
||||
const hasUserIdCamel = columns.some(c => /"userId"/i.test(c));
|
||||
const hasContactEmail = columns.some(c => /^contact_email\b/i.test(c));
|
||||
const hasEmail = columns.some(c => /^email\b/i.test(c) && !/email_sent/i.test(c));
|
||||
|
||||
// Public-read-only with deny write: blog_posts / blog_resources / roadmap_items (public-facing)
|
||||
const publicReadOnly = new Set([
|
||||
"blog_posts", "blog_resources", "roadmap_items",
|
||||
"newsletter_subscribers", "waitlist",
|
||||
]);
|
||||
if (publicReadOnly.has(tableName)) {
|
||||
return [
|
||||
`-- RLS: ${tableName} is public-read; deny writes by default`,
|
||||
`ALTER TABLE ${tableName} ENABLE ROW LEVEL SECURITY;`,
|
||||
`ALTER TABLE ${tableName} FORCE ROW LEVEL SECURITY;`,
|
||||
`DROP POLICY IF EXISTS public_read ON ${tableName};`,
|
||||
`CREATE POLICY public_read ON ${tableName} FOR SELECT USING (true);`,
|
||||
`DROP POLICY IF EXISTS deny_write ON ${tableName};`,
|
||||
`CREATE POLICY deny_write ON ${tableName} FOR INSERT WITH CHECK (false);`,
|
||||
`DROP POLICY IF EXISTS deny_update ON ${tableName};`,
|
||||
`CREATE POLICY deny_update ON ${tableName} FOR UPDATE USING (false) WITH CHECK (false);`,
|
||||
`DROP POLICY IF EXISTS deny_delete ON ${tableName};`,
|
||||
`CREATE POLICY deny_delete ON ${tableName} FOR DELETE USING (false);`,
|
||||
];
|
||||
}
|
||||
|
||||
// verification_token (Auth.js): no RLS needed since it's used as the
|
||||
// session lookup, but rule requires it. Lock to service role only.
|
||||
if (tableName === "verification_token") {
|
||||
return [
|
||||
`ALTER TABLE ${tableName} ENABLE ROW LEVEL SECURITY;`,
|
||||
`ALTER TABLE ${tableName} FORCE ROW LEVEL SECURITY;`,
|
||||
`DROP POLICY IF EXISTS deny_all ON ${tableName};`,
|
||||
`CREATE POLICY deny_all ON ${tableName} FOR ALL USING (false) WITH CHECK (false);`,
|
||||
];
|
||||
}
|
||||
|
||||
// users / accounts / sessions: Auth.js tables, scoped by userId.
|
||||
if (hasUserIdCamel) {
|
||||
return [
|
||||
`ALTER TABLE ${tableName} ENABLE ROW LEVEL SECURITY;`,
|
||||
`ALTER TABLE ${tableName} FORCE ROW LEVEL SECURITY;`,
|
||||
`DROP POLICY IF EXISTS self_access ON ${tableName};`,
|
||||
`CREATE POLICY self_access ON ${tableName} FOR ALL USING ("userId" = auth.uid() OR is_platform_admin()) WITH CHECK ("userId" = auth.uid() OR is_platform_admin());`,
|
||||
];
|
||||
}
|
||||
|
||||
// users (Auth.js): self-read own row.
|
||||
if (tableName === "users") {
|
||||
return [
|
||||
`ALTER TABLE ${tableName} ENABLE ROW LEVEL SECURITY;`,
|
||||
`ALTER TABLE ${tableName} FORCE ROW LEVEL SECURITY;`,
|
||||
`DROP POLICY IF EXISTS self_read ON ${tableName};`,
|
||||
`CREATE POLICY self_read ON ${tableName} FOR SELECT USING (id = auth.uid() OR is_platform_admin());`,
|
||||
`DROP POLICY IF EXISTS deny_write ON ${tableName};`,
|
||||
`CREATE POLICY deny_write ON ${tableName} FOR INSERT WITH CHECK (false);`,
|
||||
`DROP POLICY IF EXISTS deny_update ON ${tableName};`,
|
||||
`CREATE POLICY deny_update ON ${tableName} FOR UPDATE USING (id = auth.uid() OR is_platform_admin()) WITH CHECK (id = auth.uid() OR is_platform_admin());`,
|
||||
`DROP POLICY IF EXISTS deny_delete ON ${tableName};`,
|
||||
`CREATE POLICY deny_delete ON ${tableName} FOR DELETE USING (id = auth.uid() OR is_platform_admin());`,
|
||||
];
|
||||
}
|
||||
|
||||
// sessions: self-access by userId column.
|
||||
if (tableName === "sessions") {
|
||||
return [
|
||||
`ALTER TABLE ${tableName} ENABLE ROW LEVEL SECURITY;`,
|
||||
`ALTER TABLE ${tableName} FORCE ROW LEVEL SECURITY;`,
|
||||
`DROP POLICY IF EXISTS self_access ON ${tableName};`,
|
||||
`CREATE POLICY self_access ON ${tableName} FOR ALL USING ("userId" = auth.uid() OR is_platform_admin()) WITH CHECK ("userId" = auth.uid() OR is_platform_admin());`,
|
||||
];
|
||||
}
|
||||
|
||||
// launch_checklist_progress: user-scoped.
|
||||
if (tableName === "launch_checklist_progress" && hasUserId) {
|
||||
return [
|
||||
`ALTER TABLE ${tableName} ENABLE ROW LEVEL SECURITY;`,
|
||||
`ALTER TABLE ${tableName} FORCE ROW LEVEL SECURITY;`,
|
||||
`DROP POLICY IF EXISTS self_access ON ${tableName};`,
|
||||
`CREATE POLICY self_access ON ${tableName} FOR ALL USING (user_id = auth.uid()::text OR is_platform_admin()) WITH CHECK (user_id = auth.uid()::text OR is_platform_admin());`,
|
||||
];
|
||||
}
|
||||
|
||||
// brand-scoped tables: most common.
|
||||
if (hasBrandId) {
|
||||
return [
|
||||
`ALTER TABLE ${tableName} ENABLE ROW LEVEL SECURITY;`,
|
||||
`ALTER TABLE ${tableName} FORCE ROW LEVEL SECURITY;`,
|
||||
`DROP POLICY IF EXISTS tenant_isolation ON ${tableName};`,
|
||||
`CREATE POLICY tenant_isolation ON ${tableName} FOR ALL USING (brand_id = current_brand_id() OR is_platform_admin()) WITH CHECK (brand_id = current_brand_id() OR is_platform_admin());`,
|
||||
];
|
||||
}
|
||||
|
||||
// Fallback: deny all (safest default).
|
||||
return [
|
||||
`ALTER TABLE ${tableName} ENABLE ROW LEVEL SECURITY;`,
|
||||
`ALTER TABLE ${tableName} FORCE ROW LEVEL SECURITY;`,
|
||||
`DROP POLICY IF EXISTS deny_all ON ${tableName};`,
|
||||
`CREATE POLICY deny_all ON ${tableName} FOR ALL USING (false) WITH CHECK (false);`,
|
||||
];
|
||||
}
|
||||
|
||||
// Parse a file and return [{name, columns}] for each CREATE TABLE block.
|
||||
function parseTables(sql) {
|
||||
const tables = [];
|
||||
// Match CREATE TABLE ... (...);
|
||||
const re = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:public\.)?([a-zA-Z_][a-zA-Z0-9_]*)\s*\(([\s\S]*?)\)\s*;/gi;
|
||||
let match;
|
||||
while ((match = re.exec(sql)) !== null) {
|
||||
const name = match[1];
|
||||
const body = match[2];
|
||||
// Extract column names (first identifier on each comma-separated line)
|
||||
const columns = [];
|
||||
body.split(",").forEach(line => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return;
|
||||
if (/^(?:CONSTRAINT|UNIQUE|PRIMARY\s+KEY|FOREIGN\s+KEY|CHECK|INDEX)\b/i.test(trimmed)) return;
|
||||
const colMatch = trimmed.match(/^(?:"[^"]+"|`[^`]+`|[a-zA-Z_][a-zA-Z0-9_]*)/);
|
||||
if (colMatch) columns.push(colMatch[0]);
|
||||
});
|
||||
tables.push({ name, columns });
|
||||
}
|
||||
return tables;
|
||||
}
|
||||
|
||||
const files = [
|
||||
"005_water_log.sql",
|
||||
"044_square_sync_log.sql",
|
||||
"066_auto_square_sync.sql",
|
||||
"069_brand_scoping_phase2.sql",
|
||||
"070_rls_policy_audit.sql",
|
||||
"080_communication_segments.sql",
|
||||
"088_brand_features.sql",
|
||||
"120_water_alerts.sql",
|
||||
"126_founder_pain_log.sql",
|
||||
"129_worker_time_tracking.sql",
|
||||
"130_time_tracking_pay_period_overtime.sql",
|
||||
"133_time_tracking_notifications.sql",
|
||||
"134_abandoned_cart_recovery.sql",
|
||||
"137_time_tracking_workers_and_tasks.sql",
|
||||
"138_time_tracking_logs.sql",
|
||||
"204_authjs_tables.sql",
|
||||
"XXX_blog_tables.sql",
|
||||
"XXX_launch_checklist.sql",
|
||||
"XXX_roadmap_tables.sql",
|
||||
"XXX_waitlist_table.sql",
|
||||
];
|
||||
|
||||
let totalAdded = 0;
|
||||
let totalSkipped = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = path.join(ARCHIVE_DIR, file);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.log(`skip (not found): ${file}`);
|
||||
continue;
|
||||
}
|
||||
const original = fs.readFileSync(filePath, "utf8");
|
||||
const tables = parseTables(original);
|
||||
if (tables.length === 0) {
|
||||
console.log(`skip (no tables): ${file}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Idempotency check: skip if "ENABLE ROW LEVEL SECURITY" already present for any of the tables.
|
||||
const newTables = tables.filter(t => !new RegExp(`ALTER\\s+TABLE\\s+${t.name}\\s+ENABLE\\s+ROW\\s+LEVEL\\s+SECURITY`, "i").test(original));
|
||||
if (newTables.length === 0) {
|
||||
console.log(`skip (already done): ${file} — ${tables.length} tables`);
|
||||
totalSkipped += tables.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
const blocks = newTables.map(t => {
|
||||
const policy = policyFor(t.name, t.columns);
|
||||
return [
|
||||
`-- ============================================================`,
|
||||
`-- RLS for ${t.name} (added by scripts/fix-archived-rls.js)`,
|
||||
`-- Columns: ${t.columns.join(", ")}`,
|
||||
`-- ============================================================`,
|
||||
...policy,
|
||||
].join("\n");
|
||||
});
|
||||
|
||||
const appended = blocks.join("\n\n") + "\n";
|
||||
fs.writeFileSync(filePath, original + "\n" + appended);
|
||||
console.log(`patched: ${file} — added RLS for ${newTables.length} table(s): ${newTables.map(t => t.name).join(", ")}`);
|
||||
totalAdded += newTables.length;
|
||||
}
|
||||
|
||||
console.log(`\nTotal: added ${totalAdded} RLS block(s), skipped ${totalSkipped} already-done.`);
|
||||
@@ -1,128 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* fix-button-has-type.js
|
||||
*
|
||||
* Bulk-fix the react-doctor/button-has-type warning by adding
|
||||
* `type="button"` to every `<button>` JSX element that doesn't
|
||||
* already have a `type` attribute. Skips elements that already
|
||||
* declare `type="submit"`, `type="reset"`, `type="button"`, or
|
||||
* the corresponding JSX expressions.
|
||||
*
|
||||
* Why default to "button": A bare `<button>` in HTML defaults to
|
||||
* `type="submit"`, which accidentally submits the nearest form.
|
||||
* The vast majority of buttons in admin UIs are click handlers
|
||||
* (not form submitters), so "button" is the safer default. When
|
||||
* a button IS a form submit (e.g. inside `<form onSubmit>`), the
|
||||
* form handler typically uses its own submit semantics, but
|
||||
* authors can always override by adding `type="submit"` explicitly.
|
||||
*
|
||||
* Idempotent: re-running this script is a no-op once applied.
|
||||
*/
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { execSync } = require("node:child_process");
|
||||
|
||||
// Find all .tsx and .jsx files in src/ that are tracked by git
|
||||
function listFiles() {
|
||||
const out = execSync(
|
||||
"git ls-files 'src/**/*.tsx' 'src/**/*.jsx' 'src/**/*.ts'",
|
||||
{ encoding: "utf8" }
|
||||
);
|
||||
return out.split("\n").filter(Boolean);
|
||||
}
|
||||
|
||||
const FILES = listFiles();
|
||||
let totalFixed = 0;
|
||||
let totalSkipped = 0;
|
||||
|
||||
// Match <button ... > opening tag (or self-closing) that doesn't have type=
|
||||
// Supports multi-line tags.
|
||||
// We do this in a stateful loop to properly skip <button> tags that DO have type=
|
||||
function fixFile(file) {
|
||||
const original = fs.readFileSync(file, "utf8");
|
||||
let out = "";
|
||||
let i = 0;
|
||||
let changed = false;
|
||||
|
||||
while (i < original.length) {
|
||||
// Look for "<button" (case-sensitive, lowercase to match JSX conventions)
|
||||
const buttonIdx = original.indexOf("<button", i);
|
||||
if (buttonIdx === -1) {
|
||||
out += original.slice(i);
|
||||
break;
|
||||
}
|
||||
// Copy everything up to the match
|
||||
out += original.slice(i, buttonIdx);
|
||||
i = buttonIdx;
|
||||
|
||||
// Find the closing > of this tag (handle quoted attrs and > inside strings)
|
||||
let j = buttonIdx + "<button".length;
|
||||
let inSingle = false;
|
||||
let inDouble = false;
|
||||
let inBrace = 0;
|
||||
let tagEnd = -1;
|
||||
let isSelfClosing = false;
|
||||
while (j < original.length) {
|
||||
const c = original[j];
|
||||
if (inSingle) {
|
||||
if (c === "'" && original[j - 1] !== "\\") inSingle = false;
|
||||
} else if (inDouble) {
|
||||
if (c === '"' && original[j - 1] !== "\\") inDouble = false;
|
||||
} else if (inBrace > 0) {
|
||||
if (c === "{") inBrace++;
|
||||
else if (c === "}") inBrace--;
|
||||
} else {
|
||||
if (c === "'") inSingle = true;
|
||||
else if (c === '"') inDouble = true;
|
||||
else if (c === "{") inBrace++;
|
||||
else if (c === ">") {
|
||||
tagEnd = j;
|
||||
isSelfClosing = original[j - 1] === "/";
|
||||
break;
|
||||
}
|
||||
}
|
||||
j++;
|
||||
}
|
||||
|
||||
if (tagEnd === -1) {
|
||||
// Unterminated tag — leave as-is
|
||||
out += original.slice(i);
|
||||
break;
|
||||
}
|
||||
|
||||
const tagContent = original.slice(buttonIdx, tagEnd + 1);
|
||||
// Already has type= ? skip
|
||||
if (/\stype\s*=/.test(tagContent)) {
|
||||
out += tagContent;
|
||||
i = tagEnd + 1;
|
||||
totalSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Insert type="button" right after "<button"
|
||||
const insertAt = buttonIdx + "<button".length;
|
||||
const newTag =
|
||||
original.slice(buttonIdx, insertAt) +
|
||||
' type="button"' +
|
||||
original.slice(insertAt, tagEnd + 1);
|
||||
|
||||
out += newTag;
|
||||
i = tagEnd + 1;
|
||||
changed = true;
|
||||
totalFixed++;
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
fs.writeFileSync(file, out);
|
||||
}
|
||||
}
|
||||
|
||||
for (const f of FILES) {
|
||||
try {
|
||||
fixFile(f);
|
||||
} catch (err) {
|
||||
console.error(`error processing ${f}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Fixed ${totalFixed} <button> elements; skipped ${totalSkipped} already-typed.`);
|
||||
@@ -1,223 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Uses TypeScript AST to find <input>/<select>/<textarea> JSX elements
|
||||
* missing aria-label/aria-labelledby and adds aria-label.
|
||||
* Idempotent and structurally safe.
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const ts = require('typescript');
|
||||
|
||||
function titleCase(s) {
|
||||
return s
|
||||
.replace(/[-_]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.split(' ')
|
||||
.map((w) => (w ? w[0].toUpperCase() + w.slice(1) : ''))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function getJsxAttr(name, attrs) {
|
||||
for (const a of attrs.properties) {
|
||||
if (a.kind === ts.SyntaxKind.JsxAttribute && a.name && a.name.text === name) return a;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getStringAttrValue(name, attrs) {
|
||||
const a = getJsxAttr(name, attrs);
|
||||
if (!a || !a.initializer) return undefined;
|
||||
if (a.initializer.kind === ts.SyntaxKind.StringLiteral) return a.initializer.text;
|
||||
if (a.initializer.kind === ts.SyntaxKind.JsxExpressionContainer && a.initializer.expression) {
|
||||
const e = a.initializer.expression;
|
||||
if (e.kind === ts.SyntaxKind.StringLiteral) return e.text;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function hasBooleanAttr(name, attrs) {
|
||||
return !!getJsxAttr(name, attrs);
|
||||
}
|
||||
|
||||
function getNameText(name) {
|
||||
if (!name) return null;
|
||||
if (name.kind === ts.SyntaxKind.Identifier) return name.text;
|
||||
if (name.kind === ts.SyntaxKind.PropertyAccessExpression) return name.name.text;
|
||||
return null;
|
||||
}
|
||||
|
||||
function inferLabelFromSiblingsOrPreceding(sourceFile, node) {
|
||||
const scanner = ts.createScanner(ts.ScriptTarget.Latest, /*skipTrivia*/ true, ts.LanguageVariant.JSX, sourceFile.text);
|
||||
// Walk text before the node to find preceding label or {label(...)}
|
||||
const start = node.getStart(sourceFile);
|
||||
const before = sourceFile.text.slice(Math.max(0, start - 800), start);
|
||||
// Look for label("...") in the most recent expression
|
||||
const labelCallMatch = before.match(/label\(\s*['"`]([^'"`]+)['"`]\s*\)/);
|
||||
if (labelCallMatch) return labelCallMatch[1];
|
||||
// Look for a label JSX element without closing
|
||||
const lastLabelOpen = [...before.matchAll(/<label\b[^>]*>(?:(?!<\/label>)[\s\S])*$/gi)];
|
||||
if (lastLabelOpen.length) {
|
||||
const last = lastLabelOpen[lastLabelOpen.length - 1];
|
||||
const afterOpen = before.slice(last.index + last[0].match(/^<label\b[^>]*>/i)[0].length);
|
||||
// If there's a </label> after, this label isn't enclosing
|
||||
if (!/<\/label>/i.test(afterOpen)) {
|
||||
const inner = afterOpen.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
if (inner) return inner.replace(/[*?]\s*$/, '').trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isInsideLabel(sourceFile, node) {
|
||||
let parent = node.parent;
|
||||
while (parent) {
|
||||
if (parent.kind === ts.SyntaxKind.JsxElement) {
|
||||
const opening = parent.openingElement;
|
||||
const tagName = getNameText(opening.tagName);
|
||||
if (tagName === 'label') return true;
|
||||
// Also check if it's inside a JsxFragment whose parent is a label
|
||||
}
|
||||
parent = parent.parent;
|
||||
}
|
||||
// Also check if we're directly inside a label's children
|
||||
let cur = node;
|
||||
while (cur.parent) {
|
||||
if (cur.parent.kind === ts.SyntaxKind.JsxElement && getNameText(cur.parent.openingElement.tagName) === 'label') {
|
||||
return true;
|
||||
}
|
||||
cur = cur.parent;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function inferLabel(node, sourceFile) {
|
||||
const attrs = node.attributes;
|
||||
// Type attribute
|
||||
const typeAttr = getStringAttrValue('type', attrs);
|
||||
const placeholder = getStringAttrValue('placeholder', attrs);
|
||||
const nameAttr = getStringAttrValue('name', attrs);
|
||||
const idAttr = getStringAttrValue('id', attrs);
|
||||
const className = getStringAttrValue('className', attrs);
|
||||
const tagName = getNameText(node.tagName);
|
||||
if (className && /\bhidden\b/.test(className) && typeAttr === 'file') return 'File upload';
|
||||
if (placeholder) {
|
||||
const cleaned = placeholder.replace(/\b(e\.g\.?|i\.e\.?|min\.?)\b/gi, '').trim();
|
||||
return titleCase(cleaned || placeholder);
|
||||
}
|
||||
if (nameAttr) return titleCase(nameAttr.replace(/\[.*?\]/g, ''));
|
||||
if (idAttr) return titleCase(idAttr);
|
||||
const ctx = inferLabelFromSiblingsOrPreceding(sourceFile, node);
|
||||
if (ctx) return ctx;
|
||||
if (tagName === 'input' && typeAttr) return titleCase(typeAttr);
|
||||
return titleCase(tagName);
|
||||
}
|
||||
|
||||
function findTargets(sourceFile) {
|
||||
const out = [];
|
||||
function visit(node) {
|
||||
if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) {
|
||||
const tagName = getNameText(node.tagName);
|
||||
if (['input', 'select', 'textarea'].includes(tagName)) {
|
||||
const attrs = node.attributes;
|
||||
const hasAriaLabel = hasBooleanAttr('aria-label', attrs) || getStringAttrValue('aria-label', attrs) !== undefined;
|
||||
const hasAriaLabelledBy = hasBooleanAttr('aria-labelledby', attrs) || getStringAttrValue('aria-labelledby', attrs) !== undefined;
|
||||
const typeAttr = getStringAttrValue('type', attrs);
|
||||
if (!hasAriaLabel && !hasAriaLabelledBy && typeAttr !== 'hidden') {
|
||||
if (!isInsideLabel(sourceFile, node)) {
|
||||
out.push(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
}
|
||||
visit(sourceFile);
|
||||
return out;
|
||||
}
|
||||
|
||||
function escapeForJsx(s) {
|
||||
return s.replace(/&/g, '&').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function processFile(filePath) {
|
||||
const text = fs.readFileSync(filePath, 'utf8');
|
||||
const sourceFile = ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true, ts.LanguageVariant.JSX);
|
||||
const targets = findTargets(sourceFile);
|
||||
if (targets.length === 0) return 0;
|
||||
|
||||
// Sort by position descending so insertions don't shift earlier offsets
|
||||
targets.sort((a, b) => b.getStart(sourceFile) - a.getStart(sourceFile));
|
||||
|
||||
let updated = text;
|
||||
let changes = 0;
|
||||
for (const node of targets) {
|
||||
const inferred = inferLabel(node, sourceFile);
|
||||
if (!inferred) continue;
|
||||
// Find position: right after the tag name
|
||||
const tagNameEnd = node.tagName.getEnd();
|
||||
const ariaStr = ` aria-label="${escapeForJsx(inferred)}"`;
|
||||
// Reconstruct from the position
|
||||
const head = updated.slice(0, tagNameEnd);
|
||||
const rest = updated.slice(tagNameEnd);
|
||||
updated = head + ariaStr + rest;
|
||||
changes++;
|
||||
}
|
||||
if (changes > 0) {
|
||||
fs.writeFileSync(filePath, updated);
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const TARGET_DIRS = ['src/app', 'src/components', 'src/wholesale', 'src/landing'];
|
||||
const SKIP_PATTERNS = [
|
||||
/node_modules/,
|
||||
/\.next\//,
|
||||
/dist\//,
|
||||
/coverage\//,
|
||||
/\.test\.[jt]sx?$/,
|
||||
/\.spec\.[jt]sx?$/,
|
||||
];
|
||||
|
||||
function walk(dir, files = []) {
|
||||
if (!fs.existsSync(dir)) return files;
|
||||
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const p = path.join(dir, ent.name);
|
||||
if (SKIP_PATTERNS.some((rx) => rx.test(p))) continue;
|
||||
if (ent.isDirectory()) walk(p, files);
|
||||
else if (/\.(tsx|jsx)$/.test(ent.name)) files.push(p);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
let totalChanges = 0;
|
||||
let totalFiles = 0;
|
||||
const changedFiles = [];
|
||||
const errors = [];
|
||||
for (const t of TARGET_DIRS) {
|
||||
const dir = path.join(ROOT, t);
|
||||
if (!fs.existsSync(dir)) continue;
|
||||
const files = walk(dir);
|
||||
for (const f of files) {
|
||||
try {
|
||||
const c = processFile(f);
|
||||
if (c > 0) {
|
||||
changedFiles.push([c, path.relative(ROOT, f)]);
|
||||
totalFiles++;
|
||||
totalChanges += c;
|
||||
}
|
||||
} catch (e) {
|
||||
errors.push(`${f}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
changedFiles.sort((a, b) => b[0] - a[0]);
|
||||
for (const [c, f] of changedFiles) {
|
||||
console.log(` ${c.toString().padStart(4)} ${f}`);
|
||||
}
|
||||
if (errors.length) {
|
||||
console.error('\nErrors:');
|
||||
errors.forEach((e) => console.error(' ', e));
|
||||
}
|
||||
console.log(`\nTotal: ${totalChanges} aria-labels added across ${totalFiles} files`);
|
||||
@@ -1,150 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Add an auth check to every exported async function in a "use server"
|
||||
* file using the TypeScript compiler API. Idempotent.
|
||||
*
|
||||
* The check inserted as the first statement in the function body is:
|
||||
*
|
||||
* const adminUser = await getAdminUser();
|
||||
* if (!adminUser) return { success: false, error: "Unauthorized" };
|
||||
*
|
||||
* If the function body already calls getAdminUser() or getSession() within
|
||||
* its first few statements, it is left alone.
|
||||
*
|
||||
* Usage: node scripts/fix-server-auth-ast.js <file>...
|
||||
*/
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const ts = require("typescript");
|
||||
|
||||
function ensureGetAdminUserImport(sourceFile) {
|
||||
let src = sourceFile.text;
|
||||
const hasImport = sourceFile.statements.some(
|
||||
(s) =>
|
||||
ts.isImportDeclaration(s) &&
|
||||
s.moduleSpecifier &&
|
||||
s.moduleSpecifier.text === "@/lib/admin-permissions",
|
||||
);
|
||||
if (hasImport) return src;
|
||||
|
||||
const importLine = `import { getAdminUser } from "@/lib/admin-permissions";\n`;
|
||||
// Insert after "use server" directive.
|
||||
for (const stmt of sourceFile.statements) {
|
||||
if (
|
||||
ts.isExpressionStatement(stmt) &&
|
||||
ts.isStringLiteral(stmt.expression) &&
|
||||
(stmt.expression.text === "use server" ||
|
||||
stmt.expression.text === "use client")
|
||||
) {
|
||||
const pos = stmt.getEnd();
|
||||
return src.slice(0, pos) + "\n" + importLine + src.slice(pos);
|
||||
}
|
||||
}
|
||||
// Otherwise after the first import.
|
||||
for (const stmt of sourceFile.statements) {
|
||||
if (ts.isImportDeclaration(stmt)) {
|
||||
const pos = stmt.getEnd();
|
||||
return src.slice(0, pos) + "\n" + importLine + src.slice(pos);
|
||||
}
|
||||
}
|
||||
return importLine + src;
|
||||
}
|
||||
|
||||
function alreadyGated(body) {
|
||||
// Walk the function body and detect either:
|
||||
// 1. A getAdminUser() / getSession() call (auth gate already in place)
|
||||
// 2. An existing `const adminUser = ...` declaration (would collide)
|
||||
let hasAuthCall = false;
|
||||
let hasAdminUserVar = false;
|
||||
function walk(node) {
|
||||
if (
|
||||
ts.isCallExpression(node) &&
|
||||
ts.isIdentifier(node.expression) &&
|
||||
(node.expression.text === "getAdminUser" ||
|
||||
node.expression.text === "getSession")
|
||||
) {
|
||||
hasAuthCall = true;
|
||||
}
|
||||
if (
|
||||
ts.isVariableDeclaration(node) &&
|
||||
ts.isIdentifier(node.name) &&
|
||||
node.name.text === "adminUser"
|
||||
) {
|
||||
hasAdminUserVar = true;
|
||||
}
|
||||
ts.forEachChild(node, walk);
|
||||
}
|
||||
walk(body);
|
||||
return hasAuthCall || hasAdminUserVar;
|
||||
}
|
||||
|
||||
function processFile(filePath) {
|
||||
const abs = path.resolve(filePath);
|
||||
if (!fs.existsSync(abs)) {
|
||||
console.error(`skip (not found): ${abs}`);
|
||||
return 0;
|
||||
}
|
||||
const original = fs.readFileSync(abs, "utf8");
|
||||
if (!original.includes('"use server"') && !original.includes("'use server'")) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const sourceFile = ts.createSourceFile(
|
||||
abs,
|
||||
original,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
);
|
||||
|
||||
let src = ensureGetAdminUserImport(sourceFile);
|
||||
// Re-parse after import insertion.
|
||||
const sf = ts.createSourceFile(abs, src, ts.ScriptTarget.Latest, true);
|
||||
|
||||
const edits = [];
|
||||
function visit(node) {
|
||||
if (
|
||||
ts.isFunctionDeclaration(node) &&
|
||||
node.modifiers &&
|
||||
node.modifiers.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) &&
|
||||
node.modifiers.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword) &&
|
||||
node.body
|
||||
) {
|
||||
if (!alreadyGated(node.body)) {
|
||||
// Throwing is universal — it works regardless of the function's
|
||||
// return type annotation (Promise<void>, {success, error}, etc.)
|
||||
// and matches how the rest of the app signals "auth gate" errors.
|
||||
const insert = `\n const adminUser = await getAdminUser();\n if (!adminUser) throw new Error("Unauthorized");\n`;
|
||||
const pos = node.body.getStart(sf) + 1; // after `{`
|
||||
edits.push({ pos, insert });
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
}
|
||||
visit(sf);
|
||||
|
||||
// Apply edits in reverse order so positions don't shift.
|
||||
edits.sort((a, b) => b.pos - a.pos);
|
||||
let out = src;
|
||||
for (const e of edits) {
|
||||
out = out.slice(0, e.pos) + e.insert + out.slice(e.pos);
|
||||
}
|
||||
|
||||
if (edits.length > 0) {
|
||||
fs.writeFileSync(abs, out, "utf8");
|
||||
console.log(`patched (${edits.length} functions): ${abs}`);
|
||||
}
|
||||
return edits.length;
|
||||
}
|
||||
|
||||
const files = process.argv.slice(2);
|
||||
if (files.length === 0) {
|
||||
console.error("usage: node scripts/fix-server-auth-ast.js <file>...");
|
||||
process.exit(2);
|
||||
}
|
||||
let patched = 0, total = 0;
|
||||
for (const f of files) {
|
||||
const n = processFile(f);
|
||||
if (n > 0) patched++;
|
||||
total += n;
|
||||
}
|
||||
console.log(`done — ${patched} files, ${total} functions`);
|
||||
@@ -1,73 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Add an auth check (getAdminUser) to every exported async function in a
|
||||
* "use server" file whose body doesn't already have one. Idempotent.
|
||||
*
|
||||
* Usage: node scripts/fix-server-auth.js <file>...
|
||||
*
|
||||
* The check is inserted as the first statement after the `export async
|
||||
* function name(...) {` line:
|
||||
*
|
||||
* const adminUser = await getAdminUser();
|
||||
* if (!adminUser) return { success: false, error: "Unauthorized" };
|
||||
*/
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
function processFile(filePath) {
|
||||
const abs = path.resolve(filePath);
|
||||
if (!fs.existsSync(abs)) {
|
||||
console.error(`skip (not found): ${abs}`);
|
||||
return 0;
|
||||
}
|
||||
let src = fs.readFileSync(abs, "utf8");
|
||||
if (!src.includes('"use server"') && !src.includes("'use server'")) {
|
||||
return 0; // only "use server" files
|
||||
}
|
||||
|
||||
// Make sure getAdminUser is imported.
|
||||
if (!/from\s+["']@\/lib\/admin-permissions["']/.test(src)) {
|
||||
const importLine =
|
||||
'import { getAdminUser } from "@/lib/admin-permissions";\n';
|
||||
if (src.startsWith('"use server";\n')) {
|
||||
src = src.replace(/^("use server";\n)/, `$1\n${importLine}`);
|
||||
} else {
|
||||
const m = src.match(/^(import .*?;\n)/m);
|
||||
src = m ? src.replace(m[1], `${m[1]}${importLine}`) : importLine + src;
|
||||
}
|
||||
}
|
||||
|
||||
let count = 0;
|
||||
src = src.replace(
|
||||
/export async function (\w+)\s*\(([^)]*)\)\s*(?::\s*[^{]+)?\s*\{/g,
|
||||
(match, name, params, offset) => {
|
||||
const window = src.slice(offset, offset + 600);
|
||||
// Already gated: skip.
|
||||
if (/getAdminUser\s*\(\s*\)|getSession\s*\(\s*\)/.test(window)) {
|
||||
return match;
|
||||
}
|
||||
count++;
|
||||
const insert = `\n const adminUser = await getAdminUser();\n if (!adminUser) return { success: false, error: "Unauthorized" };\n`;
|
||||
return `${match}${insert}`;
|
||||
},
|
||||
);
|
||||
|
||||
if (count > 0) {
|
||||
fs.writeFileSync(abs, src, "utf8");
|
||||
console.log(`patched (${count} functions): ${abs}`);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
const files = process.argv.slice(2);
|
||||
if (files.length === 0) {
|
||||
console.error("usage: node scripts/fix-server-auth.js <file>...");
|
||||
process.exit(2);
|
||||
}
|
||||
let patched = 0, total = 0;
|
||||
for (const f of files) {
|
||||
const n = processFile(f);
|
||||
if (n > 0) patched++;
|
||||
total += n;
|
||||
}
|
||||
console.log(`done — ${patched} files, ${total} functions`);
|
||||
@@ -305,8 +305,7 @@ async function main() {
|
||||
let sql = `-- Tuxedo Corn 2026 Tour Data (from ${xlsxPath})\n`;
|
||||
sql += `-- Stops: ${stops.length} | Locations: ${parsedLocations.length}\n`;
|
||||
sql += `-- Preferred: npx tsx scripts/import-tuxedo-stops.ts\n`;
|
||||
sql += `-- Manual apply: psql "$DATABASE_URL" -f db/seeds/2026-tuxedo-tour-stops.sql\n`;
|
||||
sql += `-- supabase db query --linked --file db/seeds/2026-tuxedo-tour-stops.sql\n\n`;
|
||||
sql += `-- Manual apply: psql "$DATABASE_URL" -f db/seeds/2026-tuxedo-tour-stops.sql\n\n`;
|
||||
sql += `BEGIN;\n\n`;
|
||||
|
||||
// Locations first (master directory)
|
||||
|
||||
@@ -1,262 +0,0 @@
|
||||
/**
|
||||
* import-woo-to-route.ts - 2026 Season Import
|
||||
*
|
||||
* Reads Tuxedo_Corn_2026_Tour_Schedule-2.xlsx (Full Tour Schedule sheet) and produces:
|
||||
* products.csv - 1 core pickup product
|
||||
* stops.csv - 164 active stops
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/import-woo-to-route.ts \
|
||||
* --xlsx "/path/to/Tuxedo_Corn_2026_Tour_Schedule-2.xlsx" \
|
||||
* --brand 64294306-5f42-463d-a5e8-2ad6c81a96de \
|
||||
* --output ./import-output
|
||||
*/
|
||||
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import { parseArgs } from "util";
|
||||
import ExcelJS from "exceljs";
|
||||
|
||||
// Types
|
||||
|
||||
interface ProductOutput {
|
||||
name: string;
|
||||
price: number;
|
||||
type: "Pickup" | "Shipping" | "Pickup & Shipping";
|
||||
description: string;
|
||||
active: boolean;
|
||||
image_url: string;
|
||||
is_taxable: boolean;
|
||||
}
|
||||
|
||||
interface StopOutput {
|
||||
city: string;
|
||||
state: string;
|
||||
location: string;
|
||||
date: string;
|
||||
time: string;
|
||||
address: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
function slugify(s: string): string {
|
||||
return s
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs({
|
||||
options: {
|
||||
xlsx: { type: "string" },
|
||||
brand: { type: "string" },
|
||||
output: { type: "string", default: "./import-output" },
|
||||
},
|
||||
});
|
||||
|
||||
const xlsxPath = args.values.xlsx as string;
|
||||
const brandId = args.values.brand as string;
|
||||
const outputDir = args.values.output as string;
|
||||
|
||||
if (!xlsxPath || !brandId) {
|
||||
console.error(
|
||||
"Usage: npx tsx scripts/import-woo-to-route.ts --xlsx <path> --brand <uuid> [--output <dir>]"
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
if (!fs.existsSync(xlsxPath)) {
|
||||
console.error("File not found: " + xlsxPath);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Read Excel
|
||||
// Row 0=title, Row1=subtitle, Row2=color-key, Row3=column-headers, Row4+=data
|
||||
const wb = new ExcelJS.Workbook();
|
||||
await wb.xlsx.readFile(xlsxPath);
|
||||
const sheet = wb.getWorksheet(1);
|
||||
if (!sheet) {
|
||||
console.error("No worksheet found in file");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const allRows: (string | null | undefined)[][] = [];
|
||||
sheet.eachRow((row) => {
|
||||
allRows.push(row.values as (string | null | undefined)[]);
|
||||
});
|
||||
|
||||
const colHeaders = (allRows[3] as string[]).map((h: unknown) => String(h ?? "").trim());
|
||||
console.log("Column headers:", colHeaders);
|
||||
|
||||
const ci = (col: string) => colHeaders.indexOf(col);
|
||||
const wkIdx = ci("Wk"), typeIdx = ci("Type"), datesIdx = ci("Dates"), dayIdx = ci("Day");
|
||||
const cityIdx = ci("City / Location"), hostIdx = ci("Host / Venue");
|
||||
const timeIdx = ci("Time"), truckIdx = ci("Truck"), statusIdx = ci("Status"), notesIdx = ci("Notes");
|
||||
|
||||
interface ScheduleRow {
|
||||
wk: number; type: string; dates: string; day: string;
|
||||
city: string; host: string; time: string; truck: string; status: string; notes: string;
|
||||
}
|
||||
|
||||
const scheduleRows: ScheduleRow[] = [];
|
||||
let currentWeek: { wk: number; type: string; dates: string; day: string } | null = null;
|
||||
|
||||
for (let i = 4; i < allRows.length; i++) {
|
||||
const row = allRows[i] as (string | null | undefined)[];
|
||||
if (!row) continue;
|
||||
|
||||
const wkVal = row[wkIdx];
|
||||
const Day = String(row[dayIdx] ?? "").trim();
|
||||
const City_Location = String(row[cityIdx] ?? "").trim();
|
||||
const Status = String(row[statusIdx] ?? "").trim();
|
||||
|
||||
if (wkVal !== null && wkVal !== undefined && String(wkVal).trim() !== "") {
|
||||
currentWeek = {
|
||||
wk: Number(wkVal),
|
||||
type: String(row[typeIdx] ?? "").trim(),
|
||||
dates: String(row[datesIdx] ?? "").trim(),
|
||||
day: Day,
|
||||
};
|
||||
}
|
||||
|
||||
if (!currentWeek) continue;
|
||||
if (Status === "Off day") continue;
|
||||
if (!City_Location) continue;
|
||||
|
||||
scheduleRows.push({
|
||||
wk: currentWeek.wk, type: currentWeek.type, dates: currentWeek.dates,
|
||||
day: Day || currentWeek.day,
|
||||
city: City_Location,
|
||||
host: String(row[hostIdx] ?? "").trim(),
|
||||
time: String(row[timeIdx] ?? "").trim(),
|
||||
truck: String(row[truckIdx] ?? "").trim(),
|
||||
status: Status,
|
||||
notes: String(row[notesIdx] ?? "").trim(),
|
||||
});
|
||||
}
|
||||
|
||||
const ACTIVE_STATUSES = ["✓ Confirmed", "🔴 Pre-sale live"];
|
||||
const activeRows = scheduleRows.filter((r) => ACTIVE_STATUSES.includes(r.status));
|
||||
console.log("\nSchedule rows: " + scheduleRows.length + " total | " + activeRows.length + " active\n");
|
||||
|
||||
// PRODUCTS -- one core product for all stops
|
||||
const products: ProductOutput[] = [
|
||||
{
|
||||
name: "Olathe Sweet Corn -- 24 Ear Box",
|
||||
price: 25,
|
||||
type: "Pickup",
|
||||
description:
|
||||
"Fresh Olathe Sweet Corn pickup box. Add to cart, then choose your pickup stop at checkout. " +
|
||||
"Available July-September 2026 at stops across Colorado, Wyoming, and New Mexico. " +
|
||||
"See tuxedocorn.com for full schedule.",
|
||||
active: true,
|
||||
image_url:
|
||||
"https://tuxedocorn.com/wp-content/uploads/2024/06/110260317_3494904527187819_6983832261179080791_n-1.jpg",
|
||||
is_taxable: true,
|
||||
},
|
||||
];
|
||||
|
||||
console.log("[products] 1 core product: \"" + products[0].name + "\" at $" + products[0].price + "\n");
|
||||
|
||||
// STOPS -- one row per active schedule entry
|
||||
const stops: StopOutput[] = [];
|
||||
const seenStopKeys = new Set<string>();
|
||||
|
||||
for (const row of activeRows) {
|
||||
if (!row.city) continue;
|
||||
|
||||
// Parse "City ST" or "City, ST" -> city + state
|
||||
const cityRaw = row.city.replace(/--/g, "-").trim();
|
||||
let city = cityRaw, state = "CO";
|
||||
|
||||
if (cityRaw.includes(",")) {
|
||||
const parts = cityRaw.split(",").map((s: string) => s.trim());
|
||||
const lastPart = parts[parts.length - 1] ?? "";
|
||||
if (/^[A-Z]{2}$/.test(lastPart)) { city = parts.slice(0, -1).join(", ").trim(); state = lastPart; }
|
||||
else city = parts.join(", ");
|
||||
} else {
|
||||
const tokens = cityRaw.split(/\s+/);
|
||||
const lastToken = tokens[tokens.length - 1] ?? "";
|
||||
if (/^[A-Z]{2}$/.test(lastToken)) { state = lastToken; city = tokens.slice(0, -1).join(" "); }
|
||||
}
|
||||
|
||||
const stopKey = slugify(city + "-" + state + "-" + row.dates + "-" + row.time);
|
||||
if (seenStopKeys.has(stopKey)) continue;
|
||||
seenStopKeys.add(stopKey);
|
||||
|
||||
// Human-readable stop label = "Store (Time) | Truck T1"
|
||||
const stopName = row.host + " (" + row.time + ")" + (row.truck ? " | Truck " + row.truck : "");
|
||||
|
||||
stops.push({
|
||||
city,
|
||||
state,
|
||||
location: stopName,
|
||||
date: row.dates, // week range -- set actual date in /admin/stops after import
|
||||
time: row.time,
|
||||
address: row.city + " | " + row.host,
|
||||
notes: row.day + (row.notes ? " | " + row.notes : ""),
|
||||
});
|
||||
}
|
||||
|
||||
console.log("[stops] " + stops.length + " stops written\n");
|
||||
|
||||
// Show sample by week
|
||||
const byWeek: Record<string, typeof stops> = {};
|
||||
for (const s of stops) {
|
||||
if (!byWeek[s.date]) byWeek[s.date] = [];
|
||||
byWeek[s.date].push(s);
|
||||
}
|
||||
const weeks = Object.keys(byWeek).slice(0, 4);
|
||||
for (const week of weeks) {
|
||||
const ws = byWeek[week];
|
||||
console.log(" " + week + " (" + ws.length + " stops):");
|
||||
for (const s of ws.slice(0, 3)) {
|
||||
console.log(" " + s.city + ", " + s.state + " | \"" + s.location + "\" | " + s.time);
|
||||
}
|
||||
}
|
||||
|
||||
// Write output CSVs
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
function writeCSV(filename: string, header: string, rows: string[][]) {
|
||||
const lines = rows.map((r) =>
|
||||
r.map((c) => "\"" + String(c ?? "").replace(/\"/g, "\"\"") + "\"").join(",")
|
||||
);
|
||||
fs.writeFileSync(path.join(outputDir, filename), header + lines.join("\n"));
|
||||
}
|
||||
|
||||
writeCSV(
|
||||
"products.csv",
|
||||
"name,price,type,description,active,image_url,is_taxable\n",
|
||||
products.map((p) => [
|
||||
p.name, String(p.price), p.type, p.description,
|
||||
String(p.active), p.image_url, String(p.is_taxable),
|
||||
])
|
||||
);
|
||||
|
||||
writeCSV(
|
||||
"stops.csv",
|
||||
"city,state,location,date,time,address,notes\n",
|
||||
stops.map((s) => [s.city, s.state, s.location, s.date, s.time, s.address, s.notes])
|
||||
);
|
||||
|
||||
// Summary
|
||||
console.log("\n============================================================");
|
||||
console.log("IMPORT READY");
|
||||
console.log("============================================================");
|
||||
console.log("\nproducts.csv: 1 product (\"" + products[0].name + "\" @ $" + products[0].price + " Pickup)");
|
||||
console.log("stops.csv: " + stops.length + " stops (confirmed + pre-sale only)");
|
||||
console.log("\nOutput: " + outputDir + "/");
|
||||
console.log("");
|
||||
console.log(">>> NOTE: stops.csv 'date' field is a WEEK RANGE (e.g. Jul 22-28).");
|
||||
console.log("After importing stops, visit /admin/stops to set the actual date for each stop.");
|
||||
console.log("");
|
||||
console.log("NEXT STEPS:");
|
||||
console.log(" 1. Upload " + outputDir + "/products.csv via /admin/import -> type: products");
|
||||
console.log(" 2. Upload " + outputDir + "/stops.csv via /admin/import -> type: stops");
|
||||
console.log(" 3. Visit /admin/stops -- set each stop's actual date, verify address/time");
|
||||
console.log(" 4. Visit /tuxedo -- verify product and stops appear\n");
|
||||
console.log("Re-run: npx tsx scripts/import-woo-to-route.ts --xlsx \"" + xlsxPath + "\" --brand " + brandId + " --output " + outputDir + "\n");
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
@@ -1,96 +0,0 @@
|
||||
/**
|
||||
* Seed an admin user for development.
|
||||
* Run: npx tsx scripts/seed-admin.ts [email] [password] [name]
|
||||
*/
|
||||
import { config } from "dotenv";
|
||||
config({ path: ".env.local" });
|
||||
import pg from "pg";
|
||||
|
||||
const { Pool } = pg;
|
||||
|
||||
async function main() {
|
||||
const email = process.argv[2] ?? "admin@test.com";
|
||||
const password = process.argv[3] ?? "Admin1234!";
|
||||
const name = process.argv[4] ?? "Admin";
|
||||
|
||||
const baseUrl = process.env.NEON_AUTH_BASE_URL!;
|
||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||
|
||||
try {
|
||||
// 1. Create or get brand
|
||||
const brandResult = await pool.query(`
|
||||
INSERT INTO brands (name, slug, plan_tier)
|
||||
VALUES ('Demo Brand', 'demo', 'enterprise')
|
||||
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name
|
||||
RETURNING id, name, slug
|
||||
`);
|
||||
const brand = brandResult.rows[0];
|
||||
console.log("✓ Brand:", brand.name, `(${brand.id})`);
|
||||
|
||||
// 2. Create user in Neon Auth via direct API
|
||||
const res = await fetch(`${baseUrl}/sign-up/email`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Origin": "http://localhost:4000",
|
||||
"x-neon-auth-proxy": "node",
|
||||
},
|
||||
body: JSON.stringify({ email, password, name, callbackURL: "/admin" }),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok && data.code !== "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL") {
|
||||
console.error("✗ Failed to create user:", data);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const userId = data.user?.id;
|
||||
console.log("✓ Neon Auth user:", email, userId ? `(${userId})` : "(already existed)");
|
||||
|
||||
// 3. Verify email in Neon Auth DB
|
||||
if (userId) {
|
||||
await pool.query(
|
||||
`UPDATE neon_auth.user SET "emailVerified" = true WHERE id = $1`,
|
||||
[userId]
|
||||
);
|
||||
console.log("✓ Email verified in DB");
|
||||
}
|
||||
|
||||
// 4. Get user ID from neon_auth.user by email
|
||||
const neonUser = await pool.query(
|
||||
`SELECT id FROM neon_auth.user WHERE email = $1`,
|
||||
[email]
|
||||
);
|
||||
const neonUserId = neonUser.rows[0]?.id;
|
||||
if (!neonUserId) {
|
||||
console.error("✗ Could not find user in neon_auth.user");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 5. Insert into admin_users (upsert)
|
||||
const adminResult = await pool.query(`
|
||||
INSERT INTO admin_users (user_id, email, name, role)
|
||||
VALUES ($1, $2, $3, 'brand_admin')
|
||||
ON CONFLICT (email) DO UPDATE SET user_id = EXCLUDED.user_id, name = EXCLUDED.name, role = EXCLUDED.role
|
||||
RETURNING id
|
||||
`, [neonUserId, email, name]);
|
||||
const adminUser = adminResult.rows[0];
|
||||
console.log("✓ Admin user:", email, `(${adminUser.id})`);
|
||||
|
||||
// 6. Link to brand
|
||||
await pool.query(`
|
||||
INSERT INTO admin_user_brands (admin_user_id, brand_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT DO NOTHING
|
||||
`, [adminUser.id, brand.id]);
|
||||
console.log("✓ Linked to brand:", brand.name);
|
||||
|
||||
console.log(`\n🎉 All set! Log in at http://localhost:4000/login`);
|
||||
console.log(` Email: ${email}`);
|
||||
console.log(` Password: ${password}`);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,41 +0,0 @@
|
||||
import { config } from "dotenv";
|
||||
config({ path: ".env.local" });
|
||||
import pg from "pg";
|
||||
|
||||
const { Pool } = pg;
|
||||
|
||||
const TUXEDO = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
|
||||
async function main() {
|
||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||
|
||||
try {
|
||||
// brand_settings
|
||||
const bs = await pool.query(
|
||||
`INSERT INTO brand_settings (brand_id, legal_business_name, phone, email, logo_url, tagline, from_email)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (brand_id) DO UPDATE SET legal_business_name = EXCLUDED.legal_business_name
|
||||
RETURNING brand_id, legal_business_name`,
|
||||
[TUXEDO, "Tuxedo Corn LLC", "970-555-0100", "info@tuxedocorn.com",
|
||||
"/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/logo.png",
|
||||
"Farm-Fresh Sweet Corn, Delivered", "orders@tuxedocorn.com"]
|
||||
);
|
||||
console.log("brand_settings:", bs.rows[0]);
|
||||
|
||||
// wholesale_settings
|
||||
const ws = await pool.query(
|
||||
`INSERT INTO wholesale_settings (brand_id, require_approval, pickup_location, fob_location, from_email)
|
||||
VALUES ($1, true, $2, $3, $4)
|
||||
ON CONFLICT (brand_id) DO UPDATE SET pickup_location = EXCLUDED.pickup_location
|
||||
RETURNING brand_id, pickup_location`,
|
||||
[TUXEDO, "59751 David Road, Olathe, CO 81425", "FOB Olathe, CO", "orders@tuxedocorn.com"]
|
||||
);
|
||||
console.log("wholesale_settings:", ws.rows[0]);
|
||||
|
||||
console.log("\nDone!");
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e.message); process.exit(1); });
|
||||
-343
@@ -1,343 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Seed script for Route Commerce Platform
|
||||
# Run with: bash scripts/seed.sh
|
||||
|
||||
set -e
|
||||
|
||||
# Use service role key for write operations during seeding
|
||||
SUPABASE_URL="${SUPABASE_URL:-https://your-project.supabase.co}"
|
||||
SERVICE_KEY="${SUPABASE_SERVICE_ROLE_KEY}"
|
||||
ANON_KEY="${ANON_KEY:-$NEXT_PUBLIC_SUPABASE_ANON_KEY}"
|
||||
|
||||
AUTH_HEADER="apikey: $SERVICE_KEY"
|
||||
CONTENT_TYPE="Content-Type: application/json"
|
||||
|
||||
echo "Fetching brands..."
|
||||
BRANDS=$(curl -s "$SUPABASE_URL/rest/v1/brands?select=id,name" \
|
||||
-H "$AUTH_HEADER")
|
||||
echo "Brands: $BRANDS"
|
||||
|
||||
TUXEDO_ID=$(echo "$BRANDS" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||
IRD_ID=$(echo "$BRANDS" | grep -o '"id":"[^"]*"' | tail -1 | cut -d'"' -f4)
|
||||
|
||||
echo "Tuxedo: $TUXEDO_ID"
|
||||
echo "IRD: $IRD_ID"
|
||||
|
||||
echo ""
|
||||
echo "Creating stops..."
|
||||
|
||||
STOP1=$(curl -s -X POST "$SUPABASE_URL/rest/v1/stops" \
|
||||
-H "$AUTH_HEADER" \
|
||||
-H "$CONTENT_TYPE" \
|
||||
-H "Prefer: return=representation" \
|
||||
-d '{
|
||||
"brand_id": "'"$TUXEDO_ID"'",
|
||||
"city": "Burlington",
|
||||
"state": "NC",
|
||||
"date": "2026-05-15",
|
||||
"time": "8:00 AM – 2:00 PM",
|
||||
"location": "102 W Front St, Burlington, NC",
|
||||
"slug": "burlington-nc-2026-05-15",
|
||||
"active": true
|
||||
}')
|
||||
echo "Stop 1: $STOP1"
|
||||
|
||||
STOP2=$(curl -s -X POST "$SUPABASE_URL/rest/v1/stops" \
|
||||
-H "$AUTH_HEADER" \
|
||||
-H "$CONTENT_TYPE" \
|
||||
-H "Prefer: return=representation" \
|
||||
-d '{
|
||||
"brand_id": "'"$TUXEDO_ID"'",
|
||||
"city": "Greensboro",
|
||||
"state": "NC",
|
||||
"date": "2026-05-16",
|
||||
"time": "8:00 AM – 2:00 PM",
|
||||
"location": "200 S Elm St, Greensboro, NC",
|
||||
"slug": "greensboro-nc-2026-05-16",
|
||||
"active": true
|
||||
}')
|
||||
echo "Stop 2: $STOP2"
|
||||
|
||||
STOP3=$(curl -s -X POST "$SUPABASE_URL/rest/v1/stops" \
|
||||
-H "$AUTH_HEADER" \
|
||||
-H "$CONTENT_TYPE" \
|
||||
-H "Prefer: return=representation" \
|
||||
-d '{
|
||||
"brand_id": "'"$IRD_ID"'",
|
||||
"city": "Fort Pierce",
|
||||
"state": "FL",
|
||||
"date": "2026-05-15",
|
||||
"time": "7:00 AM – 1:00 PM",
|
||||
"location": "1224 N US-1, Fort Pierce, FL",
|
||||
"slug": "fort-pierce-fl-2026-05-15",
|
||||
"active": true
|
||||
}')
|
||||
echo "Stop 3: $STOP3"
|
||||
|
||||
echo ""
|
||||
echo "Creating products..."
|
||||
|
||||
PROD1=$(curl -s -X POST "$SUPABASE_URL/rest/v1/products" \
|
||||
-H "$AUTH_HEADER" \
|
||||
-H "$CONTENT_TYPE" \
|
||||
-H "Prefer: return=representation" \
|
||||
-d '{
|
||||
"brand_id": "'"$TUXEDO_ID"'",
|
||||
"name": "Sweet Corn",
|
||||
"description": "Fresh picked white sweet corn, 8 ears per bag",
|
||||
"price": 12,
|
||||
"type": "Sweet Corn",
|
||||
"active": true
|
||||
}')
|
||||
echo "Prod 1: $PROD1"
|
||||
|
||||
PROD2=$(curl -s -X POST "$SUPABASE_URL/rest/v1/products" \
|
||||
-H "$AUTH_HEADER" \
|
||||
-H "$CONTENT_TYPE" \
|
||||
-H "Prefer: return=representation" \
|
||||
-d '{
|
||||
"brand_id": "'"$TUXEDO_ID"'",
|
||||
"name": "Peaches",
|
||||
"description": "Just-peeled freestone peaches, pint jar",
|
||||
"price": 8,
|
||||
"type": "Fruit",
|
||||
"active": true
|
||||
}')
|
||||
echo "Prod 2: $PROD2"
|
||||
|
||||
PROD3=$(curl -s -X POST "$SUPABASE_URL/rest/v1/products" \
|
||||
-H "$AUTH_HEADER" \
|
||||
-H "$CONTENT_TYPE" \
|
||||
-H "Prefer: return=representation" \
|
||||
-d '{
|
||||
"brand_id": "'"$TUXEDO_ID"'",
|
||||
"name": "Corn & Peach Bundle",
|
||||
"description": "Sweet corn + peaches combo, save $2",
|
||||
"price": 18,
|
||||
"type": "Bundle",
|
||||
"active": true
|
||||
}')
|
||||
echo "Prod 3: $PROD3"
|
||||
|
||||
PROD4=$(curl -s -X POST "$SUPABASE_URL/rest/v1/products" \
|
||||
-H "$AUTH_HEADER" \
|
||||
-H "$CONTENT_TYPE" \
|
||||
-H "Prefer: return=representation" \
|
||||
-d '{
|
||||
"brand_id": "'"$IRD_ID"'",
|
||||
"name": "Navel Oranges",
|
||||
"description": "Premium navel oranges, 4 lb bag",
|
||||
"price": 15,
|
||||
"type": "Citrus",
|
||||
"active": true
|
||||
}')
|
||||
echo "Prod 4: $PROD4"
|
||||
|
||||
PROD5=$(curl -s -X POST "$SUPABASE_URL/rest/v1/products" \
|
||||
-H "$AUTH_HEADER" \
|
||||
-H "$CONTENT_TYPE" \
|
||||
-H "Prefer: return=representation" \
|
||||
-d '{
|
||||
"brand_id": "'"$IRD_ID"'",
|
||||
"name": "Grapefruit",
|
||||
"description": "Ruby red grapefruit, 3 lb bag",
|
||||
"price": 12,
|
||||
"type": "Citrus",
|
||||
"active": true
|
||||
}')
|
||||
echo "Prod 5: $PROD5"
|
||||
|
||||
PROD6=$(curl -s -X POST "$SUPABASE_URL/rest/v1/products" \
|
||||
-H "$AUTH_HEADER" \
|
||||
-H "$CONTENT_TYPE" \
|
||||
-H "Prefer: return=representation" \
|
||||
-d '{
|
||||
"brand_id": "'"$IRD_ID"'",
|
||||
"name": "Citrus Sampler",
|
||||
"description": "Oranges, grapefruit & tangelos, 6 lb mix",
|
||||
"price": 22,
|
||||
"type": "Bundle",
|
||||
"active": true
|
||||
}')
|
||||
echo "Prod 6: $PROD6"
|
||||
|
||||
P1_ID=$(echo "$PROD1" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||
P2_ID=$(echo "$PROD2" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||
P3_ID=$(echo "$PROD3" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||
P4_ID=$(echo "$PROD4" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||
P5_ID=$(echo "$PROD5" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||
P6_ID=$(echo "$PROD6" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||
|
||||
S1_ID=$(echo "$STOP1" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||
S2_ID=$(echo "$STOP2" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||
S3_ID=$(echo "$STOP3" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||
|
||||
echo ""
|
||||
echo "Assigning products to stops..."
|
||||
|
||||
curl -s -X POST "$SUPABASE_URL/rest/v1/product_stops" \
|
||||
-H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
|
||||
-d '{"stop_id": "'"$S1_ID"'", "product_id": "'"$P1_ID"'"}' > /dev/null
|
||||
curl -s -X POST "$SUPABASE_URL/rest/v1/product_stops" \
|
||||
-H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
|
||||
-d '{"stop_id": "'"$S1_ID"'", "product_id": "'"$P2_ID"'"}' > /dev/null
|
||||
curl -s -X POST "$SUPABASE_URL/rest/v1/product_stops" \
|
||||
-H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
|
||||
-d '{"stop_id": "'"$S1_ID"'", "product_id": "'"$P3_ID"'"}' > /dev/null
|
||||
curl -s -X POST "$SUPABASE_URL/rest/v1/product_stops" \
|
||||
-H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
|
||||
-d '{"stop_id": "'"$S2_ID"'", "product_id": "'"$P1_ID"'"}' > /dev/null
|
||||
curl -s -X POST "$SUPABASE_URL/rest/v1/product_stops" \
|
||||
-H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
|
||||
-d '{"stop_id": "'"$S2_ID"'", "product_id": "'"$P3_ID"'"}' > /dev/null
|
||||
curl -s -X POST "$SUPABASE_URL/rest/v1/product_stops" \
|
||||
-H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
|
||||
-d '{"stop_id": "'"$S3_ID"'", "product_id": "'"$P4_ID"'"}' > /dev/null
|
||||
curl -s -X POST "$SUPABASE_URL/rest/v1/product_stops" \
|
||||
-H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
|
||||
-d '{"stop_id": "'"$S3_ID"'", "product_id": "'"$P5_ID"'"}' > /dev/null
|
||||
curl -s -X POST "$SUPABASE_URL/rest/v1/product_stops" \
|
||||
-H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
|
||||
-d '{"stop_id": "'"$S3_ID"'", "product_id": "'"$P6_ID"'"}' > /dev/null
|
||||
|
||||
echo ""
|
||||
echo "Creating orders..."
|
||||
|
||||
ORDER1=$(curl -s -X POST "$SUPABASE_URL/rest/v1/orders" \
|
||||
-H "$AUTH_HEADER" \
|
||||
-H "$CONTENT_TYPE" \
|
||||
-H "Prefer: return=representation" \
|
||||
-d '{
|
||||
"customer_name": "Sarah Mitchell",
|
||||
"customer_email": "sarah.m@email.com",
|
||||
"customer_phone": "(336) 555-0142",
|
||||
"stop_id": "'"$S1_ID"'",
|
||||
"status": "pending",
|
||||
"subtotal": 32,
|
||||
"discount_amount": 0,
|
||||
"pickup_complete": false,
|
||||
"payment_processor": "manual",
|
||||
"payment_status": "paid"
|
||||
}')
|
||||
echo "Order 1: $ORDER1"
|
||||
|
||||
ORDER2=$(curl -s -X POST "$SUPABASE_URL/rest/v1/orders" \
|
||||
-H "$AUTH_HEADER" \
|
||||
-H "$CONTENT_TYPE" \
|
||||
-H "Prefer: return=representation" \
|
||||
-d '{
|
||||
"customer_name": "James Rivera",
|
||||
"customer_email": "jrivera@email.com",
|
||||
"customer_phone": "(336) 555-0298",
|
||||
"stop_id": "'"$S1_ID"'",
|
||||
"status": "confirmed",
|
||||
"subtotal": 22,
|
||||
"discount_amount": 2,
|
||||
"discount_reason": "First-time customer",
|
||||
"pickup_complete": true,
|
||||
"payment_processor": "manual",
|
||||
"payment_status": "paid"
|
||||
}')
|
||||
echo "Order 2: $ORDER2"
|
||||
|
||||
ORDER3=$(curl -s -X POST "$SUPABASE_URL/rest/v1/orders" \
|
||||
-H "$AUTH_HEADER" \
|
||||
-H "$CONTENT_TYPE" \
|
||||
-H "Prefer: return=representation" \
|
||||
-d '{
|
||||
"customer_name": "Lisa Chen",
|
||||
"customer_phone": "(919) 555-0763",
|
||||
"stop_id": "'"$S2_ID"'",
|
||||
"status": "pending",
|
||||
"subtotal": 12,
|
||||
"pickup_complete": false,
|
||||
"payment_processor": "manual",
|
||||
"payment_status": "pending"
|
||||
}')
|
||||
echo "Order 3: $ORDER3"
|
||||
|
||||
ORDER4=$(curl -s -X POST "$SUPABASE_URL/rest/v1/orders" \
|
||||
-H "$AUTH_HEADER" \
|
||||
-H "$CONTENT_TYPE" \
|
||||
-H "Prefer: return=representation" \
|
||||
-d '{
|
||||
"customer_name": "Marcus Thompson",
|
||||
"customer_email": "mthompson@email.com",
|
||||
"customer_phone": "(772) 555-0119",
|
||||
"stop_id": "'"$S3_ID"'",
|
||||
"status": "confirmed",
|
||||
"subtotal": 37,
|
||||
"pickup_complete": false,
|
||||
"payment_processor": "venmo",
|
||||
"payment_status": "paid",
|
||||
"payment_transaction_id": "VEN-88420"
|
||||
}')
|
||||
echo "Order 4: $ORDER4"
|
||||
|
||||
ORDER5=$(curl -s -X POST "$SUPABASE_URL/rest/v1/orders" \
|
||||
-H "$AUTH_HEADER" \
|
||||
-H "$CONTENT_TYPE" \
|
||||
-H "Prefer: return=representation" \
|
||||
-d '{
|
||||
"customer_name": "Amanda Foster",
|
||||
"customer_email": "afoster@email.com",
|
||||
"stop_id": "'"$S3_ID"'",
|
||||
"status": "cancelled",
|
||||
"subtotal": 15,
|
||||
"pickup_complete": false,
|
||||
"internal_notes": "Customer cancelled after hours, no show next day",
|
||||
"payment_processor": "manual",
|
||||
"payment_status": "refunded",
|
||||
"refunded_amount": 15,
|
||||
"refund_reason": "Customer cancelled"
|
||||
}')
|
||||
echo "Order 5: $ORDER5"
|
||||
|
||||
O1_ID=$(echo "$ORDER1" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||
O2_ID=$(echo "$ORDER2" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||
O3_ID=$(echo "$ORDER3" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||
O4_ID=$(echo "$ORDER4" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||
O5_ID=$(echo "$ORDER5" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||
|
||||
echo ""
|
||||
echo "Creating order items..."
|
||||
|
||||
curl -s -X POST "$SUPABASE_URL/rest/v1/order_items" \
|
||||
-H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
|
||||
-d '{"order_id": "'"$O1_ID"'", "product_id": "'"$P1_ID"'", "quantity": 2, "price": 12}' > /dev/null
|
||||
curl -s -X POST "$SUPABASE_URL/rest/v1/order_items" \
|
||||
-H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
|
||||
-d '{"order_id": "'"$O1_ID"'", "product_id": "'"$P2_ID"'", "quantity": 1, "price": 8}' > /dev/null
|
||||
|
||||
curl -s -X POST "$SUPABASE_URL/rest/v1/order_items" \
|
||||
-H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
|
||||
-d '{"order_id": "'"$O2_ID"'", "product_id": "'"$P3_ID"'", "quantity": 1, "price": 18}' > /dev/null
|
||||
curl -s -X POST "$SUPABASE_URL/rest/v1/order_items" \
|
||||
-H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
|
||||
-d '{"order_id": "'"$O2_ID"'", "product_id": "'"$P1_ID"'", "quantity": 2, "price": 12}' > /dev/null
|
||||
|
||||
curl -s -X POST "$SUPABASE_URL/rest/v1/order_items" \
|
||||
-H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
|
||||
-d '{"order_id": "'"$O3_ID"'", "product_id": "'"$P1_ID"'", "quantity": 1, "price": 12}' > /dev/null
|
||||
|
||||
curl -s -X POST "$SUPABASE_URL/rest/v1/order_items" \
|
||||
-H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
|
||||
-d '{"order_id": "'"$O4_ID"'", "product_id": "'"$P4_ID"'", "quantity": 2, "price": 15}' > /dev/null
|
||||
curl -s -X POST "$SUPABASE_URL/rest/v1/order_items" \
|
||||
-H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
|
||||
-d '{"order_id": "'"$O4_ID"'", "product_id": "'"$P5_ID"'", "quantity": 1, "price": 12}' > /dev/null
|
||||
|
||||
curl -s -X POST "$SUPABASE_URL/rest/v1/order_items" \
|
||||
-H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
|
||||
-d '{"order_id": "'"$O5_ID"'", "product_id": "'"$P4_ID"'", "quantity": 1, "price": 15}' > /dev/null
|
||||
|
||||
echo ""
|
||||
echo "Done! Seed data created:"
|
||||
echo " 3 stops (Burlington NC, Greensboro NC, Fort Pierce FL)"
|
||||
echo " 6 products (3 Tuxedo Corn, 3 Indian River Direct)"
|
||||
echo " 5 orders with items"
|
||||
echo ""
|
||||
echo "Quick test URLs:"
|
||||
echo " /admin/orders"
|
||||
echo " /admin/products"
|
||||
echo " /admin/stops"
|
||||
@@ -1,343 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
seed_tuxedo_tour.py
|
||||
|
||||
Parses Tuxedo_Corn_2026_Tour_Schedule-3.xlsx and seeds the `stops` table for
|
||||
the Tuxedo brand via the admin_create_stops_batch RPC.
|
||||
|
||||
Skips:
|
||||
- Title / subtitle / legend rows (rows 1-3)
|
||||
- Week header rows (col A = "Wk N", col D-J empty)
|
||||
- Cross-Dock / Monday OFF rows (col D contains "OFF" or "Cross-Dock")
|
||||
|
||||
Joins with the Stop Directory sheet to enrich each stop with:
|
||||
- address, phone, contact
|
||||
|
||||
Usage:
|
||||
python3 scripts/seed_tuxedo_tour.py --dry-run # show what would be inserted
|
||||
python3 scripts/seed_tuxedo_tour.py # actually insert
|
||||
|
||||
Requires:
|
||||
- supabase CLI linked to project wnzkhezyhnfzhkhiflrp
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from openpyxl import load_workbook
|
||||
|
||||
TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de"
|
||||
YEAR = 2026
|
||||
|
||||
MONTH_MAP = {
|
||||
"Jan": "01", "Feb": "02", "Mar": "03", "Apr": "04",
|
||||
"May": "05", "Jun": "06", "Jul": "07", "Aug": "08",
|
||||
"Sep": "09", "Oct": "10", "Nov": "11", "Dec": "12",
|
||||
}
|
||||
|
||||
DEFAULT_XLSX = "Tuxedo_Corn_2026_Tour_Schedule-3.xlsx" # run from repo root; override with --xlsx /path/to/file.xlsx
|
||||
|
||||
|
||||
def parse_excel_date(s):
|
||||
"""'Jul 22' -> '2026-07-22'"""
|
||||
if not s:
|
||||
return None
|
||||
m = re.match(r"^([A-Za-z]{3})\s+(\d{1,2})$", str(s).strip())
|
||||
if not m:
|
||||
return None
|
||||
mm = MONTH_MAP.get(m.group(1))
|
||||
if not mm:
|
||||
return None
|
||||
return f"{YEAR}-{mm}-{int(m.group(2)):02d}"
|
||||
|
||||
|
||||
def parse_time_range(s):
|
||||
"""'10:00 AM - 1:00 PM' -> '10:00 AM' (start time)"""
|
||||
if not s:
|
||||
return ""
|
||||
cleaned = re.sub(r"[–—]", "-", str(s)).strip()
|
||||
cleaned = re.sub(r"\s+", " ", cleaned)
|
||||
m = re.match(r"^(\d{1,2}:\d{2}\s*[AP]M)", cleaned, re.IGNORECASE)
|
||||
return m.group(1).upper().replace(" ", " ") if m else cleaned
|
||||
|
||||
|
||||
def split_city_state(s):
|
||||
"""'Cheyenne, WY' -> ('Cheyenne', 'WY')"""
|
||||
if not s:
|
||||
return "", ""
|
||||
parts = [p.strip() for p in str(s).split(",")]
|
||||
if len(parts) == 1:
|
||||
return parts[0], ""
|
||||
return parts[0], parts[1]
|
||||
|
||||
|
||||
def slugify(s):
|
||||
s = (s or "").lower()
|
||||
s = re.sub(r"[^a-z0-9]+", "-", s)
|
||||
return s.strip("-")
|
||||
|
||||
|
||||
def is_week_header(row):
|
||||
a = str(row[0] or "").strip()
|
||||
d = str(row[3] or "").strip()
|
||||
return re.match(r"^Wk\s", a) and d == ""
|
||||
|
||||
|
||||
def is_off_row(row):
|
||||
d = str(row[3] or "").strip()
|
||||
return "OFF" in d or "Cross-Dock" in d or "Cross‑Dock" in d
|
||||
|
||||
|
||||
def is_data_row(row):
|
||||
d = str(row[3] or "").strip()
|
||||
e = str(row[4] or "").strip()
|
||||
if not d or not e:
|
||||
return False
|
||||
if "," not in e:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def load(xlsx_path):
|
||||
wb = load_workbook(xlsx_path, data_only=True)
|
||||
schedule = wb["Full Schedule"]
|
||||
directory = wb["Stop Directory"]
|
||||
|
||||
# Build Stop Directory lookup: (truck, host_normalized) -> {address, phone, contact, ...}
|
||||
dir_map = {}
|
||||
for row in directory.iter_rows(min_row=2, values_only=True):
|
||||
truck = str(row[0] or "").strip()
|
||||
city = str(row[1] or "").strip()
|
||||
state = str(row[2] or "").strip()
|
||||
host = str(row[3] or "").strip()
|
||||
address = str(row[4] or "").strip()
|
||||
phone = str(row[5] or "").strip()
|
||||
contact = str(row[6] or "").strip()
|
||||
if not truck or not host:
|
||||
continue
|
||||
key = f"{truck}|{host.lower()}"
|
||||
dir_map[key] = {
|
||||
"city": city, "state": state, "host": host,
|
||||
"address": address, "phone": phone, "contact": contact,
|
||||
}
|
||||
|
||||
# Read Full Schedule (skip first 3 title/subtitle/legend rows)
|
||||
stops = []
|
||||
skipped = {"weekHeader": 0, "off": 0, "invalid": 0}
|
||||
for row in schedule.iter_rows(min_row=4, values_only=True):
|
||||
# Trim to 10 cols
|
||||
cells = [("" if v is None else str(v).strip()) for v in row[:10]]
|
||||
if is_week_header(cells):
|
||||
skipped["weekHeader"] += 1
|
||||
continue
|
||||
if is_off_row(cells):
|
||||
skipped["off"] += 1
|
||||
continue
|
||||
if not is_data_row(cells):
|
||||
skipped["invalid"] += 1
|
||||
continue
|
||||
|
||||
wk, region, date_text, day, city_state, host, time, truck, status, notes = cells
|
||||
date_iso = parse_excel_date(date_text)
|
||||
if not date_iso:
|
||||
skipped["invalid"] += 1
|
||||
continue
|
||||
city, state = split_city_state(city_state)
|
||||
if not city:
|
||||
skipped["invalid"] += 1
|
||||
continue
|
||||
|
||||
# Enrich from directory
|
||||
dir_key = f"{truck}|{host.lower()}"
|
||||
d = dir_map.get(dir_key)
|
||||
|
||||
stops.append({
|
||||
"week": wk,
|
||||
"region": region,
|
||||
"date": date_iso,
|
||||
"day": day,
|
||||
"city": city,
|
||||
"state": state or (d["state"] if d else ""),
|
||||
"location": host,
|
||||
"time": parse_time_range(time),
|
||||
"time_range": time,
|
||||
"truck": truck,
|
||||
"status_text": status,
|
||||
"notes": notes,
|
||||
"address": d["address"] if d and d["address"] else None,
|
||||
"phone": d["phone"] if d and d["phone"] else None,
|
||||
"contact": d["contact"] if d and d["contact"] else None,
|
||||
})
|
||||
|
||||
return stops, skipped, len(dir_map)
|
||||
|
||||
|
||||
def assign_slugs(stops, dry_run):
|
||||
used = set()
|
||||
if not dry_run:
|
||||
out = subprocess.run(
|
||||
["supabase", "db", "query", "--linked",
|
||||
f"SELECT slug FROM stops WHERE brand_id = '{TUXEDO_BRAND_ID}';"],
|
||||
capture_output=True, text=True, timeout=120,
|
||||
)
|
||||
# Parse the table output - slugs are in second column between │
|
||||
for m in re.finditer(r"│\s*([a-z0-9][a-z0-9-]*)\s*│", out.stdout):
|
||||
used.add(m.group(1))
|
||||
|
||||
for s in stops:
|
||||
base = f"{slugify(s['city'])}-{s['date']}"
|
||||
slug = base
|
||||
n = 0
|
||||
while slug in used:
|
||||
n += 1
|
||||
slug = f"{base}-{n}"
|
||||
used.add(slug)
|
||||
s["slug"] = slug
|
||||
|
||||
|
||||
def to_rpc_row(s):
|
||||
return {
|
||||
"city": s["city"],
|
||||
"state": s["state"],
|
||||
"location": s["location"],
|
||||
"date": f"{s['date']} 00:00:00+00",
|
||||
"time": s["time"],
|
||||
"address": s["address"],
|
||||
"zip": None,
|
||||
"cutoff_time": None,
|
||||
# active=true so the stops appear on the public storefront immediately.
|
||||
# Matches the behavior of publishStop in src/actions/stops.ts.
|
||||
"active": True,
|
||||
}
|
||||
|
||||
|
||||
def build_payload_json(batch):
|
||||
"""Build a clean JSON string for use in a SQL file."""
|
||||
return json.dumps(batch, ensure_ascii=False)
|
||||
|
||||
|
||||
def insert_batch(batch):
|
||||
"""Write SQL to a temp file and execute via --file to avoid shell escaping."""
|
||||
payload_json = build_payload_json(batch)
|
||||
sql = (
|
||||
f"SELECT admin_create_stops_batch("
|
||||
f"'{TUXEDO_BRAND_ID}'::uuid, "
|
||||
f"$${payload_json}$$::jsonb);\n"
|
||||
)
|
||||
# Write to temp file
|
||||
tmp_path = Path("/tmp/seed_tuxedo_tour.sql")
|
||||
tmp_path.write_text(sql, encoding="utf-8")
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["supabase", "db", "query", "--linked", "--file", str(tmp_path)],
|
||||
capture_output=True, text=True, timeout=300,
|
||||
)
|
||||
finally:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(f"RPC failed: {proc.stderr[:800]}")
|
||||
return proc.stdout
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--dry-run", action="store_true")
|
||||
ap.add_argument("--xlsx", default=DEFAULT_XLSX)
|
||||
args = ap.parse_args()
|
||||
|
||||
if not Path(args.xlsx).exists():
|
||||
sys.exit(f"XLSX not found: {args.xlsx}")
|
||||
|
||||
stops, skipped, dir_count = load(args.xlsx)
|
||||
assign_slugs(stops, dry_run=args.dry_run)
|
||||
|
||||
print(f"\nParsed {len(stops)} stops "
|
||||
f"(skipped: {skipped['weekHeader']} week-headers, "
|
||||
f"{skipped['off']} OFF days, {skipped['invalid']} invalid)")
|
||||
print(f"Stop Directory: {dir_count} entries loaded for enrichment\n")
|
||||
|
||||
if not stops:
|
||||
sys.exit("No stops to insert.")
|
||||
|
||||
print("Sample (first 3):")
|
||||
for s in stops[:3]:
|
||||
print(f" {s['date']} {s['time']:10s} | {s['city']:18s}, {s['state']:2s} | "
|
||||
f"{s['location'][:35]:35s} | {s['truck']} | {s['status_text']} | {s['slug']}")
|
||||
if s["notes"]:
|
||||
print(f" notes: {s['notes'][:120]}")
|
||||
if s["address"]:
|
||||
print(f" addr: {s['address']} ph: {s['phone']} ctc: {s['contact']}")
|
||||
print()
|
||||
|
||||
# Show counts by week and region
|
||||
by_week = {}
|
||||
by_region = {}
|
||||
by_truck = {}
|
||||
for s in stops:
|
||||
by_week[s["week"]] = by_week.get(s["week"], 0) + 1
|
||||
by_region[s["region"]] = by_region.get(s["region"], 0) + 1
|
||||
by_truck[s["truck"]] = by_truck.get(s["truck"], 0) + 1
|
||||
print("By week:", dict(sorted(by_week.items())))
|
||||
print("By region:", by_region)
|
||||
print("By truck:", by_truck)
|
||||
print()
|
||||
|
||||
# Date range
|
||||
dates = sorted(s["date"] for s in stops)
|
||||
print(f"Date range: {dates[0]} to {dates[-1]}\n")
|
||||
|
||||
if args.dry_run:
|
||||
batches = (len(stops) + 49) // 50
|
||||
print(f"[DRY RUN] Would insert {len(stops)} stops in {batches} batch(es) of 50.")
|
||||
return
|
||||
|
||||
BATCH = 50
|
||||
total = 0
|
||||
batches = (len(stops) + BATCH - 1) // BATCH
|
||||
for i in range(0, len(stops), BATCH):
|
||||
batch = [to_rpc_row(s) for s in stops[i:i + BATCH]]
|
||||
bnum = i // BATCH + 1
|
||||
sys.stdout.write(f" Inserting batch {bnum}/{batches} ({len(batch)} stops)... ")
|
||||
sys.stdout.flush()
|
||||
try:
|
||||
insert_batch(batch)
|
||||
total += len(batch)
|
||||
print("OK")
|
||||
except Exception as e:
|
||||
print("FAIL")
|
||||
print(f" {e}")
|
||||
|
||||
# The batch RPC hardcodes status='draft' on insert. The Tuxedo storefront
|
||||
# page only filters on active=true (not status), so active=true is enough
|
||||
# to make stops visible. But for consistency with the publishStop server
|
||||
# action — which sets both — flip status to 'active' for the rows we just
|
||||
# inserted. Slug-based so we only touch stops from this run, not the
|
||||
# pre-existing "Olathe" test stop.
|
||||
if total > 0:
|
||||
slugs = [s["slug"] for s in stops]
|
||||
# Build a safe IN list (slug is a text column)
|
||||
slug_list = ", ".join(f"'{slug.replace(chr(39), chr(39)+chr(39))}'" for slug in slugs)
|
||||
publish_sql = (
|
||||
f"UPDATE stops SET status = 'active' "
|
||||
f"WHERE brand_id = '{TUXEDO_BRAND_ID}' "
|
||||
f"AND slug IN ({slug_list});"
|
||||
)
|
||||
tmp = Path("/tmp/seed_tuxedo_publish.sql")
|
||||
tmp.write_text(publish_sql, encoding="utf-8")
|
||||
try:
|
||||
subprocess.run(
|
||||
["supabase", "db", "query", "--linked", "--file", str(tmp)],
|
||||
capture_output=True, text=True, timeout=120,
|
||||
)
|
||||
print(f"\n Published {total} stops (status -> 'active').")
|
||||
finally:
|
||||
tmp.unlink(missing_ok=True)
|
||||
|
||||
print(f"\nDone. Inserted {total}/{len(stops)} stops for Tuxedo brand.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,74 +0,0 @@
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
import { readFileSync } from "fs";
|
||||
import { fileURLToPath } from "url";
|
||||
import path from "path";
|
||||
|
||||
// Load .env.local manually
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const envPath = path.join(__dirname, "..", ".env.local");
|
||||
const envContent = readFileSync(envPath, "utf8");
|
||||
envContent.split("\n").forEach((line) => {
|
||||
const [key, ...rest] = line.split("=");
|
||||
if (key && rest.length && key.trim() && !key.trim().startsWith("#")) {
|
||||
process.env[key.trim()] = rest.join("=").trim().replace(/^"|"$/g, "");
|
||||
}
|
||||
});
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
|
||||
if (!supabaseUrl || !supabaseServiceKey) {
|
||||
console.error("Missing env vars");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey);
|
||||
|
||||
async function uploadTuxedoHeroVideo() {
|
||||
const filePath = "/Users/kylemartinez/Downloads/062624TuxedoCorn 001.mp4";
|
||||
const fileBuffer = readFileSync(filePath);
|
||||
const fileSizeMB = (fileBuffer.byteLength / 1024 / 1024).toFixed(2);
|
||||
console.log(`File: ${filePath} (${fileSizeMB} MB)`);
|
||||
|
||||
// Ensure public videos bucket exists
|
||||
const { data: buckets, error: listErr } = await supabase.storage.listBuckets();
|
||||
if (listErr) { console.error("List buckets error:", listErr.message); process.exit(1); }
|
||||
|
||||
const existing = buckets?.find((b) => b.name === "videos");
|
||||
if (!existing) {
|
||||
console.log("Creating 'videos' bucket (public)...");
|
||||
const { error: createErr } = await supabase.storage.createBucket("videos", { public: true });
|
||||
if (createErr) { console.error("Create bucket error:", createErr.message); process.exit(1); }
|
||||
console.log("Bucket 'videos' created.");
|
||||
} else {
|
||||
console.log(`'videos' bucket exists (public: ${existing.public}).`);
|
||||
if (!existing.public) {
|
||||
const { error: updErr } = await supabase.storage.updateBucket("videos", { public: true });
|
||||
if (updErr) console.warn("Could not update bucket to public:", updErr.message);
|
||||
else console.log("Bucket updated to public.");
|
||||
}
|
||||
}
|
||||
|
||||
// Upload
|
||||
console.log("Uploading tuxedo-hero.mp4...");
|
||||
const { data: uploadData, error: uploadErr } = await supabase.storage
|
||||
.from("videos")
|
||||
.upload("tuxedo-hero.mp4", fileBuffer, {
|
||||
contentType: "video/mp4",
|
||||
upsert: true,
|
||||
});
|
||||
|
||||
if (uploadErr) {
|
||||
console.error("Upload error:", uploadErr.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("Upload OK:", JSON.stringify(uploadData));
|
||||
|
||||
// Public URL
|
||||
const { data: urlData } = supabase.storage.from("videos").getPublicUrl("tuxedo-hero.mp4");
|
||||
const url = urlData.publicUrl;
|
||||
console.log(`\n✅ Public URL:\n${url}`);
|
||||
}
|
||||
|
||||
uploadTuxedoHeroVideo().catch((e) => { console.error(e); process.exit(1); });
|
||||
@@ -1,11 +0,0 @@
|
||||
import { config } from "dotenv";
|
||||
config({ path: ".env.local" });
|
||||
import pg from "pg";
|
||||
const { Pool } = pg;
|
||||
async function main() {
|
||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||
const r = await pool.query(`SELECT id, email, "emailVerified" FROM neon_auth.user WHERE email = 'admin@test.com'`);
|
||||
console.log(JSON.stringify(r.rows, null, 2));
|
||||
await pool.end();
|
||||
}
|
||||
main();
|
||||
@@ -1,132 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* verify-stop-fns.js
|
||||
*
|
||||
* Confirms `admin_create_stop` and `admin_create_stops_batch` exist in the
|
||||
* live database with the exact signatures the client uses, and that the
|
||||
* PostgREST schema cache has them registered.
|
||||
*
|
||||
* Run: node scripts/verify-stop-fns.js
|
||||
*
|
||||
* Replaces the older `check-stop-fns*.js` scripts (left in the repo for
|
||||
* history; this one is the canonical post-fix verification).
|
||||
*
|
||||
* Exits 0 on success, 1 if any check fails.
|
||||
*/
|
||||
|
||||
require("dotenv").config({ path: require("path").resolve(__dirname, "../.env.local") });
|
||||
|
||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||
|
||||
if (!url || !serviceKey || !anonKey) {
|
||||
console.error("Missing NEXT_PUBLIC_SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY / NEXT_PUBLIC_SUPABASE_ANON_KEY in .env.local");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const SERVICE_HEADERS = {
|
||||
apikey: serviceKey,
|
||||
Authorization: `Bearer ${serviceKey}`,
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
const ANON_HEADERS = {
|
||||
apikey: anonKey,
|
||||
Authorization: `Bearer ${anonKey}`,
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
let failed = 0;
|
||||
const fail = (msg) => { console.error("✗ " + msg); failed += 1; };
|
||||
const pass = (msg) => console.log("✓ " + msg);
|
||||
|
||||
async function rpc(name, body, headers = SERVICE_HEADERS) {
|
||||
const r = await fetch(`${url}/rest/v1/rpc/${name}`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const text = await r.text();
|
||||
let parsed;
|
||||
try { parsed = JSON.parse(text); } catch { parsed = text.slice(0, 200); }
|
||||
return { status: r.status, body: parsed };
|
||||
}
|
||||
|
||||
(async () => {
|
||||
console.log(`\nVerifying stop RPCs against ${url}\n`);
|
||||
|
||||
// ── 1. admin_create_stop — empty body (expect 400/422, NOT PGRST202) ─────
|
||||
// PGRST202 = function not in schema cache. We want a 400-style validation
|
||||
// error here, which proves PostgREST knows the function exists.
|
||||
console.log("1. admin_create_stop signature check (PostgREST cache)");
|
||||
const probe1 = await rpc("admin_create_stop", {});
|
||||
if (probe1.status === 404) {
|
||||
fail("admin_create_stop returned 404 — function not found (PGRST202 cache miss). " +
|
||||
"Did you apply migration 147 and run `NOTIFY pgrst, 'reload schema'`?");
|
||||
} else if (probe1.status >= 500) {
|
||||
fail(`admin_create_stop returned ${probe1.status} — server error. Body: ${JSON.stringify(probe1.body)}`);
|
||||
} else {
|
||||
pass(`admin_create_stop reachable in PostgREST (status ${probe1.status})`);
|
||||
}
|
||||
|
||||
// ── 2. admin_create_stop — happy path with a real (but harmless) brand ──
|
||||
console.log("\n2. admin_create_stop happy-path call (anon key)");
|
||||
const probe2 = await rpc(
|
||||
"admin_create_stop",
|
||||
{
|
||||
p_brand_id: "00000000-0000-0000-0000-000000000000",
|
||||
p_city: "verify-script-city",
|
||||
p_state: "CO",
|
||||
p_location: "verification stop",
|
||||
p_date: "2099-12-31",
|
||||
p_time: "08:00 AM – 02:00 PM",
|
||||
p_address: "123 Verify St",
|
||||
p_zip: "80000",
|
||||
p_cutoff_time: "08:00", // time-only — should be combined with date
|
||||
p_active: false,
|
||||
},
|
||||
ANON_HEADERS
|
||||
);
|
||||
if (probe2.status === 404) {
|
||||
fail("admin_create_stop not callable via anon key. " +
|
||||
"GRANT EXECUTE missing? Body: " + JSON.stringify(probe2.body));
|
||||
} else if (probe2.status >= 400) {
|
||||
// Expected: brand_id is the zero UUID (foreign key violation). That's fine —
|
||||
// it proves the function executed past type validation.
|
||||
const msg = JSON.stringify(probe2.body);
|
||||
if (/foreign key|violates/i.test(msg)) {
|
||||
pass(`admin_create_stop ran end-to-end (got expected FK violation: ${msg.slice(0, 120)}...)`);
|
||||
} else {
|
||||
fail(`admin_create_stop failed unexpectedly (${probe2.status}): ${msg}`);
|
||||
}
|
||||
} else {
|
||||
pass(`admin_create_stop returned success: ${JSON.stringify(probe2.body)}`);
|
||||
}
|
||||
|
||||
// ── 3. admin_create_stops_batch — same checks ───────────────────────────
|
||||
console.log("\n3. admin_create_stops_batch signature check");
|
||||
const probe3 = await rpc("admin_create_stops_batch", { p_brand_id: null, p_stops: [] });
|
||||
if (probe3.status === 404) {
|
||||
fail("admin_create_stops_batch returned 404 — function not in schema cache.");
|
||||
} else {
|
||||
pass(`admin_create_stops_batch reachable (status ${probe3.status})`);
|
||||
}
|
||||
|
||||
// ── 4. OpenAPI introspection — confirm functions are exposed ────────────
|
||||
console.log("\n4. OpenAPI introspection");
|
||||
const openApi = await fetch(`${url}/rest/v1/`, { headers: ANON_HEADERS });
|
||||
const text = await openApi.text();
|
||||
const hasSingle = /admin_create_stop\b/.test(text);
|
||||
const hasBatch = /admin_create_stops_batch\b/.test(text);
|
||||
if (hasSingle) pass("admin_create_stop appears in OpenAPI spec");
|
||||
else fail("admin_create_stop NOT in OpenAPI spec — PostgREST hasn't seen it yet");
|
||||
if (hasBatch) pass("admin_create_stops_batch appears in OpenAPI spec");
|
||||
else fail("admin_create_stops_batch NOT in OpenAPI spec");
|
||||
|
||||
console.log(`\n${failed === 0 ? "✓ All checks passed" : `✗ ${failed} check(s) failed`}\n`);
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
})().catch((e) => {
|
||||
console.error("verify-stop-fns.js crashed:", e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -273,7 +273,7 @@ export async function createAdminUser(
|
||||
// admin endpoint requires the caller to have role='admin' in
|
||||
// Neon Auth, which is not always true (provision-admin.ts does
|
||||
// not set it). Falling back to sign-up is a safe, well-tested
|
||||
// path — see scripts/seed-admin.ts.
|
||||
// path.
|
||||
const code = adminResult.error.code ?? "";
|
||||
const isAuthRequired =
|
||||
code === "FAILED_TO_CREATE_USER" ||
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { pool } from "@/lib/db";
|
||||
|
||||
export type AIAuthConfig = {
|
||||
provider: string;
|
||||
api_key: string;
|
||||
organization_id: string;
|
||||
base_url: string;
|
||||
model: string;
|
||||
max_tokens: number;
|
||||
};
|
||||
|
||||
type AIRow = {
|
||||
api_key: string | null;
|
||||
organization_id: string | null;
|
||||
base_url: string | null;
|
||||
model: string | null;
|
||||
max_tokens: number | null;
|
||||
};
|
||||
|
||||
export async function getAIPreferences(brandId: string): Promise<{
|
||||
api_key?: string;
|
||||
organization_id?: string;
|
||||
base_url?: string;
|
||||
model?: string;
|
||||
max_tokens?: number;
|
||||
} | null> {
|
||||
const { rows } = await pool.query<AIRow>(
|
||||
`SELECT api_key, organization_id, base_url, model, max_tokens
|
||||
FROM brand_ai_settings
|
||||
WHERE brand_id = $1
|
||||
LIMIT 1`,
|
||||
[brandId],
|
||||
);
|
||||
const row = rows[0];
|
||||
if (!row) return null;
|
||||
return {
|
||||
api_key: row.api_key ?? undefined,
|
||||
organization_id: row.organization_id ?? undefined,
|
||||
base_url: row.base_url ?? undefined,
|
||||
model: row.model ?? undefined,
|
||||
max_tokens: row.max_tokens ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveAIPreferences(
|
||||
brandId: string,
|
||||
config: AIAuthConfig
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
await pool.query(
|
||||
`INSERT INTO brand_ai_settings
|
||||
(brand_id, provider, api_key, organization_id, base_url, model, max_tokens, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
|
||||
ON CONFLICT (brand_id) DO UPDATE
|
||||
SET provider = EXCLUDED.provider,
|
||||
api_key = EXCLUDED.api_key,
|
||||
organization_id = EXCLUDED.organization_id,
|
||||
base_url = EXCLUDED.base_url,
|
||||
model = EXCLUDED.model,
|
||||
max_tokens = EXCLUDED.max_tokens,
|
||||
updated_at = NOW()`,
|
||||
[
|
||||
brandId,
|
||||
config.provider,
|
||||
config.api_key || null,
|
||||
config.organization_id || null,
|
||||
config.base_url || null,
|
||||
config.model || "gpt-4o-mini",
|
||||
config.max_tokens || 4000,
|
||||
],
|
||||
);
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : "Failed to save AI preferences",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function testAIConnection(config: AIAuthConfig): Promise<{ ok: boolean; message: string }> {
|
||||
if (!config.api_key?.trim()) {
|
||||
return { ok: false, message: "API key is required" };
|
||||
}
|
||||
|
||||
try {
|
||||
const baseUrl = config.base_url?.trim() || "https://api.openai.com/v1";
|
||||
const url = `${baseUrl.replace(/\/$/, "")}/models`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.api_key}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return { ok: true, message: "Connection successful! Your API key is valid." };
|
||||
} else if (response.status === 401) {
|
||||
return { ok: false, message: "Invalid API key. Please check and try again." };
|
||||
} else {
|
||||
return { ok: false, message: `Error: ${response.status} ${response.statusText}` };
|
||||
}
|
||||
} catch {
|
||||
return { ok: false, message: "Could not connect. Check your network and API key." };
|
||||
}
|
||||
}
|
||||
@@ -68,9 +68,10 @@ export type ConversionFunnel = {
|
||||
|
||||
/**
|
||||
* Aggregate KPIs over a date window. Replaces the
|
||||
* `get_reports_summary` SECURITY DEFINER RPC from
|
||||
* `supabase/migrations/031_reports_v1_rpcs.sql`. Returns zeroed metrics
|
||||
* if the caller is unauthenticated or no orders exist in the window.
|
||||
* `get_reports_summary` SECURITY DEFINER RPC (originally from the now-
|
||||
* archived supabase migrations; consolidated into
|
||||
* `db/migrations/0001_init.sql`). Returns zeroed metrics if the caller
|
||||
* is unauthenticated or no orders exist in the window.
|
||||
*/
|
||||
async function getReportsSummary(
|
||||
brandId: string | null,
|
||||
|
||||
@@ -11,8 +11,9 @@ export type ImportOrdersResult =
|
||||
|
||||
/**
|
||||
* Bulk-import orders. Replaces the legacy `create_order_with_items` SECURITY
|
||||
* DEFINER RPC (`supabase/migrations/021_shipping_only_brand.sql`). The new
|
||||
* `orders` schema doesn't have `subtotal`, `customer_name`, `customer_email`,
|
||||
* DEFINER RPC (originally from the now-archived supabase migrations;
|
||||
* consolidated into `db/migrations/0001_init.sql`). The new `orders`
|
||||
* schema doesn't have `subtotal`, `customer_name`, `customer_email`,
|
||||
* `customer_phone`, or `stop_id` columns — totals are stored in
|
||||
* `total_cents` and customers are referenced by `customer_id`. For each
|
||||
* imported order we upsert a `customers` row keyed on email+tenant, then
|
||||
|
||||
@@ -73,7 +73,8 @@ export type CampaignActivityRow = {
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// The reports V1 RPCs (`get_reports_summary`, `get_orders_by_stop_report`, etc.)
|
||||
// from `supabase/migrations/031_reports_v1_rpcs.sql` reference legacy columns
|
||||
// (originally from the now-archived supabase migrations; consolidated
|
||||
// into `db/migrations/0001_init.sql`) reference legacy columns
|
||||
// (`orders.subtotal`, `stops.city/state/date`, `communication_contacts`).
|
||||
// The SaaS rebuild has a different `orders` schema (`total_cents`, no
|
||||
// `stop_id`/`city`/`state` columns on `stops`) and a different contacts table
|
||||
|
||||
@@ -4,12 +4,13 @@ import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId, assertBrandAccess } from "@/lib/brand-scope";
|
||||
|
||||
// TODO(migration): the `harvest_lots` / `harvest_lot_events` / `lot_orders`
|
||||
// tables from the route-trace feature (defined across
|
||||
// supabase/migrations/143_enable_route_trace.sql and friends) were retired
|
||||
// from the SaaS rebuild — they don't exist in `db/schema/`. The functions
|
||||
// below all return empty results so the public trace page and FSMA reports
|
||||
// 404 gracefully. If route-trace comes back, re-introduce the tables in
|
||||
// `db/schema/` and replace these stubs with real Drizzle queries.
|
||||
// tables from the route-trace feature (originally defined in the now-
|
||||
// archived supabase migrations; consolidated into
|
||||
// `db/migrations/0001_init.sql`) were retired from the SaaS rebuild —
|
||||
// they don't exist in `db/schema/`. The functions below all return empty
|
||||
// results so the public trace page and FSMA reports 404 gracefully. If
|
||||
// route-trace comes back, re-introduce the tables in `db/schema/` and
|
||||
// replace these stubs with real Drizzle queries.
|
||||
|
||||
export interface HarvestLot {
|
||||
id: string;
|
||||
|
||||
@@ -10,10 +10,10 @@ export type UpdateShippingStatusResult =
|
||||
// TODO(migration): shipping is dormant in the SaaS rebuild. The legacy
|
||||
// `shipments` table (with `tracking_number`, `fedex_shipment_id`, etc.),
|
||||
// the `shipping_status` column on `orders`, and the `update_shipping_order`
|
||||
// RPC from `supabase/migrations/040_shipping_fulfillment_rpcs.sql` are
|
||||
// gone. The functions below stub to "not configured" so the admin
|
||||
// shipping tab renders gracefully. Re-introduce shipping in
|
||||
// `db/schema/` when the feature is reactivated.
|
||||
// RPC (originally from the now-archived supabase migrations) are gone.
|
||||
// The functions below stub to "not configured" so the admin shipping
|
||||
// tab renders gracefully. Re-introduce shipping in `db/schema/` when
|
||||
// the feature is reactivated.
|
||||
|
||||
export async function updateShippingStatus(
|
||||
_orderId: string,
|
||||
|
||||
@@ -93,8 +93,9 @@ async function loadShippingSettingsForBrand(brandId: string): Promise<ShippingSe
|
||||
|
||||
/**
|
||||
* Calls the legacy `get_order_items_perishable` SECURITY DEFINER RPC
|
||||
* via `pool.query`. The function still lives in the database (see
|
||||
* supabase/migrations/083_shipping_settings.sql).
|
||||
* via `pool.query`. The function still lives in the database
|
||||
* (originally from the now-archived supabase migrations; consolidated
|
||||
* into `db/migrations/0001_init.sql`).
|
||||
*/
|
||||
async function isShipmentPerishable(orderId: string): Promise<boolean> {
|
||||
try {
|
||||
|
||||
@@ -11,12 +11,12 @@
|
||||
*
|
||||
* TODO(migration): shipping is dormant in the SaaS rebuild. The
|
||||
* legacy `shipping_settings` table (with the FedEx credential columns
|
||||
* this file needs) is still present in the database — see
|
||||
* supabase/migrations/083_shipping_settings.sql — but the new
|
||||
* `db/schema/` does not declare it. The data reads below hit the
|
||||
* legacy table directly via `pool.query`. When shipping comes back,
|
||||
* declare the table in `db/schema/shipping.ts` and switch the reads
|
||||
* to Drizzle.
|
||||
* this file needs) is still present in the database — originally from
|
||||
* the now-archived supabase migrations, consolidated into
|
||||
* `db/migrations/0001_init.sql` — but the new `db/schema/` does not
|
||||
* declare it. The data reads below hit the legacy table directly via
|
||||
* `pool.query`. When shipping comes back, declare the table in
|
||||
* `db/schema/shipping.ts` and switch the reads to Drizzle.
|
||||
*/
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
* Shipping settings CRUD + FedEx connection test.
|
||||
*
|
||||
* TODO(migration): shipping is dormant in the SaaS rebuild. The
|
||||
* `shipping_settings` table is still part of the legacy schema (see
|
||||
* supabase/migrations/083_shipping_settings.sql) — the FedEx integration
|
||||
* continues to read/write it via raw `pool.query` SQL rather than the
|
||||
* Supabase REST gateway or Drizzle. When shipping is reactivated, move
|
||||
* the table declaration into `db/schema/shipping.ts` and switch the
|
||||
* reads to typed Drizzle queries.
|
||||
* `shipping_settings` table is still part of the legacy schema
|
||||
* (originally from the now-archived supabase migrations; consolidated
|
||||
* into `db/migrations/0001_init.sql`) — the FedEx integration continues
|
||||
* to read/write it via raw `pool.query` SQL rather than Drizzle. When
|
||||
* shipping is reactivated, move the table declaration into
|
||||
* `db/schema/shipping.ts` and switch the reads to typed Drizzle queries.
|
||||
*/
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
* Assign / unassign a product to a stop.
|
||||
*
|
||||
* TODO(migration): the `assign_product_to_stop` and
|
||||
* `unassign_product_from_stop` SECURITY DEFINER RPCs live in
|
||||
* supabase/migrations/030. The RPCs still exist in the database and
|
||||
* are called via `pool.query` rather than the Supabase REST gateway.
|
||||
* When stops/products are re-platformed on the SaaS rebuild, replace
|
||||
* these with direct inserts/deletes on the new `db/schema/stops.ts`
|
||||
* `stopProducts` table.
|
||||
* `unassign_product_from_stop` SECURITY DEFINER RPCs (originally from
|
||||
* the now-archived supabase migrations; consolidated into
|
||||
* `db/migrations/0001_init.sql`). The RPCs still exist in the
|
||||
* database and are called via `pool.query`. When stops/products are
|
||||
* re-platformed on the SaaS rebuild, replace these with direct
|
||||
* inserts/deletes on the new `db/schema/stops.ts` `stopProducts`
|
||||
* table.
|
||||
*/
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
|
||||
+24
-24
@@ -3,11 +3,6 @@
|
||||
/**
|
||||
* Public storefront data accessors.
|
||||
*
|
||||
* These replace the legacy `lib/supabase.ts` query-builder shim that the
|
||||
* tuxedo / indian-river-direct storefront pages used to call client-side.
|
||||
* The shim was a no-op that returned `{ data: null }`, leaving storefronts
|
||||
* with empty stops / products and no real brand record.
|
||||
*
|
||||
* Each function hits Postgres directly via the shared `pg` pool from
|
||||
* `@/lib/db` and is safe to call from public (unauthenticated) pages
|
||||
* because the SQL filters by `is_public = true` / `active = true` and
|
||||
@@ -98,12 +93,17 @@ export async function getStorefrontStops(brandId: string): Promise<StorefrontSto
|
||||
/**
|
||||
* Active products visible on the storefront. Same shape as the admin
|
||||
* product list but without the brand-internal columns (cost, supplier, …).
|
||||
*
|
||||
* Note: products.price is stored in cents (NUMERIC). We return it as a
|
||||
* float in **dollars** so callers can format directly as `$${price}`
|
||||
* without doing the cents-to-dollars math.
|
||||
*/
|
||||
export async function getStorefrontProducts(
|
||||
brandId: string,
|
||||
): Promise<StorefrontProduct[]> {
|
||||
const { rows } = await pool.query<StorefrontProduct>(
|
||||
`SELECT id, brand_id, name, description, price::float8 AS price,
|
||||
`SELECT id, brand_id, name, description,
|
||||
price::float8 / 100 AS price,
|
||||
type, image_url, is_taxable, pickup_type, active
|
||||
FROM products
|
||||
WHERE brand_id = $1
|
||||
@@ -117,10 +117,8 @@ export async function getStorefrontProducts(
|
||||
|
||||
/**
|
||||
* Single server action that the storefront Client Components call from
|
||||
* their initial `useEffect`. Replaces the three separate `supabase.from(...)`
|
||||
* calls that the legacy `lib/supabase.ts` shim used to make — the shim
|
||||
* returned `{ data: null }` for everything, which is why storefronts
|
||||
* were showing empty stops / products.
|
||||
* their initial `useEffect`. Bundles brand lookup + public stops +
|
||||
* active products into one round-trip-friendly call.
|
||||
*
|
||||
* Returns `{ brand: null, stops: [], products: [] }` if the slug doesn't
|
||||
* match any brand — pages fall back to default copy in that case.
|
||||
@@ -137,16 +135,16 @@ export async function getStorefrontData(slug: string): Promise<StorefrontData> {
|
||||
|
||||
export type StorefrontWholesaleSettings = {
|
||||
invoice_business_name: string | null;
|
||||
invoice_business_address: string | null;
|
||||
invoice_business_phone: string | null;
|
||||
invoice_business_email: string | null;
|
||||
invoice_business_website: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Public wholesale-settings record for a brand — just the public-facing
|
||||
* invoice/business info used by the storefront contact page. Returns
|
||||
* `null` if the brand or its wholesale settings row doesn't exist.
|
||||
* Public wholesale-settings record for a brand — just the invoice
|
||||
* business name used by the storefront contact page. Returns `null`
|
||||
* if the brand or its wholesale settings row doesn't exist.
|
||||
*
|
||||
* NOTE: only `invoice_business_name` is exposed; other invoice-contact
|
||||
* fields don't exist on `wholesale_settings` yet and shouldn't be
|
||||
* surfaced here. If you need them, add a migration first.
|
||||
*/
|
||||
export async function getStorefrontWholesaleSettings(
|
||||
slug: string,
|
||||
@@ -154,8 +152,7 @@ export async function getStorefrontWholesaleSettings(
|
||||
const brand = await getStorefrontBrandBySlug(slug);
|
||||
if (!brand) return null;
|
||||
const { rows } = await pool.query<StorefrontWholesaleSettings>(
|
||||
`SELECT invoice_business_name, invoice_business_address,
|
||||
invoice_business_phone, invoice_business_email, invoice_business_website
|
||||
`SELECT invoice_business_name
|
||||
FROM wholesale_settings
|
||||
WHERE brand_id = $1
|
||||
LIMIT 1`,
|
||||
@@ -206,14 +203,17 @@ export async function getStorefrontBrandName(
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a single public stop by its URL slug, plus the brand's
|
||||
* active products. Used by `/[brand]/stops/[slug]` pages.
|
||||
* Look up a single public stop by its id, plus the brand's active
|
||||
* products. Used by `/[brand]/stops/[id]` pages.
|
||||
*
|
||||
* NOTE: The URL parameter is named `[id]` (not `[slug]`) because the
|
||||
* `stops` table has no slug column. URLs use the stop's UUID.
|
||||
*
|
||||
* Returns `null` if the stop is not public, not active, or its date has
|
||||
* already passed. Returns the empty product array if the brand has no
|
||||
* active products at the moment.
|
||||
*/
|
||||
export async function getStorefrontStopBySlug(slug: string): Promise<{
|
||||
export async function getStorefrontStopById(id: string): Promise<{
|
||||
stop: StorefrontStop;
|
||||
products: StorefrontProduct[];
|
||||
} | null> {
|
||||
@@ -222,11 +222,11 @@ export async function getStorefrontStopBySlug(slug: string): Promise<{
|
||||
date::text AS date, time, cutoff_date::text AS cutoff_date,
|
||||
status, notes, is_public
|
||||
FROM stops
|
||||
WHERE slug = $1
|
||||
WHERE id = $1
|
||||
AND is_public = true
|
||||
AND status = 'active'
|
||||
LIMIT 1`,
|
||||
[slug],
|
||||
[id],
|
||||
);
|
||||
const stop = stopRows[0];
|
||||
if (!stop) return null;
|
||||
|
||||
+6
-6
@@ -152,12 +152,12 @@ async function getBrandTaxSettings(brandId: string): Promise<BrandTaxSettings |
|
||||
//
|
||||
// TODO(migration): the tax dashboard reads from the legacy `orders`
|
||||
// table via the `get_tax_summary` and `get_taxable_orders` SECURITY
|
||||
// DEFINER RPCs (supabase/migrations/095). Both the table and the RPCs
|
||||
// still exist; we call the RPCs through `pool.query` rather than the
|
||||
// Supabase REST gateway. When the SaaS rebuild's orders table grows a
|
||||
// `tax_amount` column or the tax dashboard is rebuilt against the new
|
||||
// schema, these helpers should be rewritten against the Drizzle
|
||||
// `orders` table directly.
|
||||
// DEFINER RPCs (originally from the now-archived supabase migrations;
|
||||
// consolidated into `db/migrations/0001_init.sql`). Both the table and
|
||||
// the RPCs still exist; we call the RPCs through `pool.query`. When the
|
||||
// SaaS rebuild's orders table grows a `tax_amount` column or the tax
|
||||
// dashboard is rebuilt against the new schema, these helpers should be
|
||||
// rewritten against the Drizzle `orders` table directly.
|
||||
|
||||
export type TaxByStateRow = {
|
||||
state: string;
|
||||
|
||||
@@ -1,888 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import { pool } from "@/lib/db";
|
||||
|
||||
export type WholesaleOrder = {
|
||||
id: string;
|
||||
customer_id: string;
|
||||
status: string;
|
||||
fulfillment_status: string;
|
||||
payment_status: string;
|
||||
anticipated_pickup_date: string | null;
|
||||
subtotal: number;
|
||||
deposit_required: number;
|
||||
deposit_paid: number;
|
||||
balance_due: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
invoice_number: string | null;
|
||||
assigned_employee_id: string | null;
|
||||
company_name: string;
|
||||
contact_name: string | null;
|
||||
customer_email: string;
|
||||
customer_phone: string | null;
|
||||
items: Array<{
|
||||
id: string;
|
||||
product_name: string;
|
||||
quantity: number;
|
||||
unit_price: number;
|
||||
line_total: number;
|
||||
}>;
|
||||
fulfilled_at: string | null;
|
||||
};
|
||||
|
||||
export type WholesaleCustomer = {
|
||||
id: string;
|
||||
user_id: string | null;
|
||||
company_name: string;
|
||||
contact_name: string | null;
|
||||
email: string;
|
||||
phone: string | null;
|
||||
account_status: string;
|
||||
credit_limit: number;
|
||||
deposits_enabled: boolean;
|
||||
deposit_threshold: number | null;
|
||||
deposit_percentage: number | null;
|
||||
order_email: string | null;
|
||||
invoice_email: string | null;
|
||||
admin_notes: string | null;
|
||||
role: string;
|
||||
created_at: string;
|
||||
deleted_at: string | null;
|
||||
};
|
||||
|
||||
export type WholesaleProduct = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
unit_type: string;
|
||||
unit_type_custom: string | null;
|
||||
availability: string;
|
||||
qty_available: number;
|
||||
season_start: string | null;
|
||||
season_end: string | null;
|
||||
price_tiers: Array<{ min_qty: number; max_qty: number; price: number }>;
|
||||
hp_sku: string | null;
|
||||
hp_item_id: string | null;
|
||||
handling_instructions: string | null;
|
||||
storage_warning: string | null;
|
||||
loading_notes: string | null;
|
||||
product_label: string | null;
|
||||
pack_style: string | null;
|
||||
container_type: string | null;
|
||||
container_size_code: string | null;
|
||||
units_per_container: number | null;
|
||||
default_pickup_location: string | null;
|
||||
created_at: string;
|
||||
deleted_at: string | null;
|
||||
};
|
||||
|
||||
export type NotificationRecipient = {
|
||||
email: string;
|
||||
name?: string;
|
||||
active: boolean;
|
||||
notification_types?: (
|
||||
| "order_confirmation"
|
||||
| "deposit_received"
|
||||
| "order_fulfilled"
|
||||
| "price_sheet"
|
||||
| "unclaimed_pickup"
|
||||
)[];
|
||||
};
|
||||
|
||||
export type WholesaleSettings = {
|
||||
id: string;
|
||||
brand_id: string;
|
||||
portal_page_id: string | null;
|
||||
price_sheet_page_id: string | null;
|
||||
require_approval: boolean;
|
||||
min_order_amount: number | null;
|
||||
online_payment_enabled: boolean;
|
||||
wholesale_enabled: boolean;
|
||||
square_sync_enabled: boolean;
|
||||
pickup_location: string;
|
||||
fob_location: string;
|
||||
from_email: string;
|
||||
invoice_business_name: string;
|
||||
invoice_business_address: string | null;
|
||||
invoice_business_phone: string | null;
|
||||
invoice_business_email: string | null;
|
||||
invoice_business_website: string | null;
|
||||
notification_email: string | null;
|
||||
notification_recipients: NotificationRecipient[];
|
||||
last_invoice_number: number;
|
||||
};
|
||||
|
||||
export type WholesaleDashboardStats = {
|
||||
open_orders: number;
|
||||
pickup_today: number;
|
||||
past_due: number;
|
||||
total_unpaid: number;
|
||||
awaiting_deposit: number;
|
||||
fulfilled_today: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves the effective brand_id for an action, enforcing brand scoping.
|
||||
*
|
||||
* platform_admin → null (means "all brands" — passes to RPC unchanged)
|
||||
* brand_admin → their own brand_id only; rejects attempts to operate on other brands
|
||||
* store_employee → their own brand_id
|
||||
* multi_brand_admin → active brand from cookie/URL/legacy, must be in brand_ids
|
||||
* unauthenticated → null (actions should already bail out earlier)
|
||||
*
|
||||
* This prevents brand_admin from seeing or modifying another brand's data
|
||||
* even if they manually pass a different brandId to the action.
|
||||
*/
|
||||
async function resolveBrandId(
|
||||
adminUser: Awaited<ReturnType<typeof getAdminUser>>,
|
||||
requestedBrandId?: string
|
||||
): Promise<string | null> {
|
||||
if (!adminUser) return null;
|
||||
|
||||
if (adminUser.role === "platform_admin") {
|
||||
// platform_admin can operate on all brands — pass null (= all brands) to RPC
|
||||
return null;
|
||||
}
|
||||
|
||||
// For non-platform-admin: resolve the active brand (validates against brand_ids)
|
||||
const activeBrandId = await getActiveBrandId(adminUser, requestedBrandId);
|
||||
|
||||
if (requestedBrandId && activeBrandId !== requestedBrandId) {
|
||||
// Brand admin trying to operate on another brand's data — block it
|
||||
return null; // caller should check and return unauthorized
|
||||
}
|
||||
|
||||
return activeBrandId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Like resolveBrandId but returns null for platform_admin AND throws an error
|
||||
* if a brand_admin tries to operate outside their brand.
|
||||
* Use for mutating actions (save, delete, fulfill) where cross-brand access must be blocked.
|
||||
*/
|
||||
async function enforceBrandScope(
|
||||
adminUser: Awaited<ReturnType<typeof getAdminUser>>,
|
||||
requestedBrandId?: string
|
||||
): Promise<{ brandId: string | null; error?: string }> {
|
||||
if (!adminUser) return { brandId: null, error: "Not authenticated" };
|
||||
|
||||
if (adminUser.role === "platform_admin") {
|
||||
return { brandId: null }; // unrestricted
|
||||
}
|
||||
|
||||
// For non-platform-admin: resolve the active brand (validates against brand_ids)
|
||||
const activeBrandId = await getActiveBrandId(adminUser, requestedBrandId);
|
||||
|
||||
if (requestedBrandId && activeBrandId !== requestedBrandId) {
|
||||
return { brandId: null, error: "Not authorized to operate on this brand" };
|
||||
}
|
||||
|
||||
return { brandId: activeBrandId };
|
||||
}
|
||||
|
||||
// ── Orders ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getWholesaleOrders(brandId?: string): Promise<WholesaleOrder[]> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return [];
|
||||
const bid = await resolveBrandId(adminUser, brandId);
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query<WholesaleOrder>(
|
||||
"SELECT * FROM get_wholesale_orders($1)",
|
||||
[bid]
|
||||
);
|
||||
return rows;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getWholesalePickupOrders(brandId?: string): Promise<WholesaleOrder[]> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return [];
|
||||
const bid = await resolveBrandId(adminUser, brandId);
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query<WholesaleOrder>(
|
||||
"SELECT * FROM get_wholesale_pickup_orders($1)",
|
||||
[bid]
|
||||
);
|
||||
return rows;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getWholesaleDashboardStats(brandId?: string): Promise<WholesaleDashboardStats> {
|
||||
const orders = await getWholesaleOrders(brandId);
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
|
||||
const open = orders.filter(o => ["pending", "awaiting_deposit", "confirmed"].includes(o.status));
|
||||
const pickupToday = orders.filter(o => o.anticipated_pickup_date === today && o.fulfillment_status !== "fulfilled");
|
||||
const pastDue = orders.filter(o => o.anticipated_pickup_date && o.anticipated_pickup_date < today && o.fulfillment_status !== "fulfilled");
|
||||
const totalUnpaid = open.reduce((sum, o) => sum + Number(o.balance_due), 0);
|
||||
const awaitingDep = orders.filter(o => o.status === "awaiting_deposit");
|
||||
const fulfilledToday = orders.filter(o => o.fulfillment_status === "fulfilled" && o.updated_at?.startsWith(today));
|
||||
|
||||
return {
|
||||
open_orders: open.length,
|
||||
pickup_today: pickupToday.length,
|
||||
past_due: pastDue.length,
|
||||
total_unpaid: totalUnpaid,
|
||||
awaiting_deposit: awaitingDep.length,
|
||||
fulfilled_today: fulfilledToday.length,
|
||||
};
|
||||
}
|
||||
|
||||
export async function markWholesaleOrderFulfilled(orderId: string, brandId?: string): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||
|
||||
const { brandId: resolved, error } = await enforceBrandScope(adminUser, brandId);
|
||||
if (error) return { success: false, error };
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
"SELECT * FROM mark_wholesale_order_fulfilled($1, $2)",
|
||||
[orderId, adminUser.user_id]
|
||||
);
|
||||
if (!rows || rows.length === 0) {
|
||||
return { success: false, error: "Failed to mark fulfilled" };
|
||||
}
|
||||
} catch {
|
||||
return { success: false, error: "Failed to mark fulfilled" };
|
||||
}
|
||||
|
||||
enqueueWholesaleWebhookForOrderFulfilled(orderId, resolved ?? undefined).catch(() => {});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async function enqueueWholesaleWebhookForOrderFulfilled(orderId: string, brandId?: string) {
|
||||
try {
|
||||
await pool.query(
|
||||
"SELECT enqueue_wholesale_webhook($1, $2, $3, $4::jsonb)",
|
||||
[
|
||||
"order_fulfilled",
|
||||
orderId,
|
||||
brandId ?? null,
|
||||
JSON.stringify({ order_id: orderId }),
|
||||
]
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
export async function updateWholesaleOrderStatus(
|
||||
orderId: string,
|
||||
status: "pending" | "confirmed" | "cancelled",
|
||||
brandId?: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||
|
||||
const { error } = await enforceBrandScope(adminUser, brandId);
|
||||
if (error) return { success: false, error };
|
||||
|
||||
try {
|
||||
await pool.query(
|
||||
"SELECT * FROM update_wholesale_order_status($1, $2)",
|
||||
[orderId, status]
|
||||
);
|
||||
return { success: true };
|
||||
} catch {
|
||||
return { success: false, error: "Failed to update order status" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteWholesaleOrder(orderId: string, brandId?: string): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||
|
||||
const { error } = await enforceBrandScope(adminUser, brandId);
|
||||
if (error) return { success: false, error };
|
||||
|
||||
try {
|
||||
await pool.query(
|
||||
"SELECT * FROM delete_wholesale_order($1)",
|
||||
[orderId]
|
||||
);
|
||||
return { success: true };
|
||||
} catch {
|
||||
return { success: false, error: "Failed to delete order" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteWholesaleCustomer(customerId: string, brandId?: string): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||
|
||||
const { error } = await enforceBrandScope(adminUser, brandId);
|
||||
if (error) return { success: false, error };
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query<{ success: boolean; error?: string }>(
|
||||
"SELECT * FROM delete_wholesale_customer($1)",
|
||||
[customerId]
|
||||
);
|
||||
const data = Array.isArray(rows) ? rows[0] : rows;
|
||||
if (!data?.success) {
|
||||
return { success: false, error: data?.error ?? "Delete failed" };
|
||||
}
|
||||
return { success: true };
|
||||
} catch (e: unknown) {
|
||||
return { success: false, error: e instanceof Error ? e.message : "Failed to delete customer" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteWholesaleProduct(productId: string, brandId?: string): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_products) return { success: false, error: "Not authorized" };
|
||||
|
||||
const { error } = await enforceBrandScope(adminUser, brandId);
|
||||
if (error) return { success: false, error };
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query<{ success: boolean; error?: string }>(
|
||||
"SELECT * FROM delete_wholesale_product($1)",
|
||||
[productId]
|
||||
);
|
||||
const data = Array.isArray(rows) ? rows[0] : rows;
|
||||
if (!data?.success) {
|
||||
return { success: false, error: data?.error ?? "Delete failed" };
|
||||
}
|
||||
return { success: true };
|
||||
} catch (e: unknown) {
|
||||
return { success: false, error: e instanceof Error ? e.message : "Failed to delete product" };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Customers ────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getWholesaleCustomers(brandId?: string): Promise<WholesaleCustomer[]> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return [];
|
||||
const bid = await resolveBrandId(adminUser, brandId);
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query<WholesaleCustomer>(
|
||||
"SELECT * FROM get_wholesale_customers($1)",
|
||||
[bid]
|
||||
);
|
||||
return rows;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveWholesaleCustomer(params: {
|
||||
brandId: string;
|
||||
userId?: string;
|
||||
companyName?: string;
|
||||
contactName?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
accountStatus?: string;
|
||||
creditLimit?: number;
|
||||
depositsEnabled?: boolean;
|
||||
depositThreshold?: number;
|
||||
depositPercentage?: number;
|
||||
orderEmail?: string;
|
||||
invoiceEmail?: string;
|
||||
adminNotes?: string;
|
||||
}): Promise<{ success: boolean; error?: string; id?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||
|
||||
const { error } = await enforceBrandScope(adminUser, params.brandId);
|
||||
if (error) return { success: false, error };
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query<{ id: string }>(
|
||||
"SELECT * FROM upsert_wholesale_customer($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)",
|
||||
[
|
||||
params.brandId,
|
||||
params.userId ?? null,
|
||||
params.companyName ?? null,
|
||||
params.contactName ?? null,
|
||||
params.email ?? null,
|
||||
params.phone ?? null,
|
||||
params.accountStatus ?? "active",
|
||||
params.creditLimit ?? 0,
|
||||
params.depositsEnabled ?? false,
|
||||
params.depositThreshold ?? null,
|
||||
params.depositPercentage ?? null,
|
||||
params.orderEmail ?? null,
|
||||
params.invoiceEmail ?? null,
|
||||
params.adminNotes ?? null,
|
||||
]
|
||||
);
|
||||
const data = Array.isArray(rows) ? rows[0] : rows;
|
||||
return { success: true, id: data?.id };
|
||||
} catch {
|
||||
return { success: false, error: "Failed to save customer" };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Products ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getWholesaleProducts(brandId?: string): Promise<WholesaleProduct[]> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return [];
|
||||
const bid = await resolveBrandId(adminUser, brandId);
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query<WholesaleProduct>(
|
||||
"SELECT * FROM get_wholesale_products($1)",
|
||||
[bid]
|
||||
);
|
||||
return rows;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveWholesaleProduct(params: {
|
||||
brandId: string;
|
||||
id?: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
unitType?: string;
|
||||
availability?: string;
|
||||
qtyAvailable?: number;
|
||||
priceTiers?: Array<{ min_qty: number; max_qty: number; price: number }>;
|
||||
hpSku?: string;
|
||||
hpItemId?: string;
|
||||
internalNotes?: string;
|
||||
handlingInstructions?: string;
|
||||
storageWarning?: string;
|
||||
productLabel?: string;
|
||||
packStyle?: string;
|
||||
containerType?: string;
|
||||
defaultPickupLocation?: string;
|
||||
}): Promise<{ success: boolean; error?: string; id?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_products) return { success: false, error: "Not authorized" };
|
||||
|
||||
const { error } = await enforceBrandScope(adminUser, params.brandId);
|
||||
if (error) return { success: false, error };
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query<{ id: string }>(
|
||||
"SELECT * FROM upsert_wholesale_product($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10, $11, $12, $13, $14, $15, $16, $17)",
|
||||
[
|
||||
params.brandId,
|
||||
params.id ?? null,
|
||||
params.name,
|
||||
params.description ?? null,
|
||||
params.unitType ?? "each",
|
||||
params.availability ?? "unavailable",
|
||||
params.qtyAvailable ?? 0,
|
||||
JSON.stringify(params.priceTiers ?? []),
|
||||
params.hpSku ?? null,
|
||||
params.hpItemId ?? null,
|
||||
params.internalNotes ?? null,
|
||||
params.handlingInstructions ?? null,
|
||||
params.storageWarning ?? null,
|
||||
params.productLabel ?? null,
|
||||
params.packStyle ?? null,
|
||||
params.containerType ?? null,
|
||||
params.defaultPickupLocation ?? null,
|
||||
]
|
||||
);
|
||||
const data = Array.isArray(rows) ? rows[0] : rows;
|
||||
return { success: true, id: data?.id };
|
||||
} catch {
|
||||
return { success: false, error: "Failed to save product" };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Settings ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getWholesaleSettings(brandId?: string): Promise<WholesaleSettings | null> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return null;
|
||||
const bid = await getActiveBrandId(adminUser, brandId);
|
||||
if (!bid && adminUser.role !== "platform_admin") {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query<WholesaleSettings>(
|
||||
"SELECT * FROM get_wholesale_settings($1)",
|
||||
[bid]
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getWholesaleSettingsPublic(brandId: string): Promise<{ invoice_business_address: string | null } | null> {
|
||||
try {
|
||||
const { rows } = await pool.query<{ invoice_business_address: string | null }>(
|
||||
"SELECT invoice_business_address FROM get_wholesale_settings($1)",
|
||||
[brandId]
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveWholesaleSettings(params: {
|
||||
brandId: string;
|
||||
requireApproval?: boolean;
|
||||
minOrderAmount?: number;
|
||||
onlinePaymentEnabled?: boolean;
|
||||
wholesaleEnabled?: boolean;
|
||||
squareSyncEnabled?: boolean;
|
||||
pickupLocation?: string;
|
||||
fobLocation?: string;
|
||||
fromEmail?: string;
|
||||
invoiceBusinessName?: string;
|
||||
invoiceBusinessAddress?: string;
|
||||
invoiceBusinessPhone?: string;
|
||||
invoiceBusinessEmail?: string;
|
||||
invoiceBusinessWebsite?: string;
|
||||
notificationEmail?: string;
|
||||
notificationRecipients?: NotificationRecipient[];
|
||||
}): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||
|
||||
try {
|
||||
await pool.query(
|
||||
"SELECT upsert_wholesale_settings($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)",
|
||||
[
|
||||
params.brandId,
|
||||
params.requireApproval ?? null,
|
||||
params.minOrderAmount ?? null,
|
||||
params.onlinePaymentEnabled ?? null,
|
||||
params.wholesaleEnabled ?? null,
|
||||
params.pickupLocation ?? null,
|
||||
params.fobLocation ?? null,
|
||||
params.fromEmail ?? null,
|
||||
params.invoiceBusinessName ?? null,
|
||||
params.invoiceBusinessAddress ?? null,
|
||||
params.invoiceBusinessPhone ?? null,
|
||||
params.invoiceBusinessEmail ?? null,
|
||||
params.invoiceBusinessWebsite ?? null,
|
||||
params.notificationEmail ?? null,
|
||||
params.notificationRecipients ? JSON.stringify(params.notificationRecipients) : null,
|
||||
params.squareSyncEnabled ?? null,
|
||||
]
|
||||
);
|
||||
return { success: true };
|
||||
} catch {
|
||||
return { success: false, error: "Failed to save settings" };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Deposits ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function recordWholesaleDeposit(
|
||||
orderId: string,
|
||||
amount: number,
|
||||
method: string = "cash",
|
||||
reference?: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||
|
||||
const activeBrandId = await getActiveBrandId(adminUser);
|
||||
let data: { success?: boolean; error?: string } | null = null;
|
||||
try {
|
||||
const { rows } = await pool.query<{ success: boolean; error?: string }>(
|
||||
"SELECT * FROM record_wholesale_deposit($1, $2, $3, $4, $5, $6)",
|
||||
[orderId, amount, method, reference ?? null, adminUser.user_id, activeBrandId]
|
||||
);
|
||||
data = Array.isArray(rows) ? rows[0] : rows;
|
||||
if (!data?.success) {
|
||||
return { success: false, error: data?.error ?? "Failed to record deposit" };
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
return { success: false, error: e instanceof Error ? e.message : "Failed to record deposit" };
|
||||
}
|
||||
|
||||
// Fire webhook — fire-and-forget
|
||||
enqueueWholesaleWebhookForDepositRecorded(orderId, amount, activeBrandId ?? undefined).catch(() => {});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async function enqueueWholesaleWebhookForDepositRecorded(orderId: string, amount: number, brandId?: string) {
|
||||
try {
|
||||
await pool.query(
|
||||
"SELECT enqueue_wholesale_webhook($1, $2, $3, $4::jsonb)",
|
||||
[
|
||||
"deposit_recorded",
|
||||
orderId,
|
||||
brandId ?? null,
|
||||
JSON.stringify({ order_id: orderId, amount }),
|
||||
]
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// ── Bulk Actions ──────────────────────────────────────────────────────────────
|
||||
|
||||
export async function bulkFulfillWholesaleOrders(
|
||||
orderIds: string[]
|
||||
): Promise<{ success: boolean; count?: number; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query<{ success: boolean; count?: number; error?: string }>(
|
||||
"SELECT * FROM bulk_fulfill_wholesale_orders($1, $2, $3)",
|
||||
[orderIds, adminUser.user_id, await getActiveBrandId(adminUser)]
|
||||
);
|
||||
const data = Array.isArray(rows) ? rows[0] : rows;
|
||||
if (!data?.success) {
|
||||
return { success: false, error: data?.error ?? "Failed to bulk fulfill orders" };
|
||||
}
|
||||
return { success: true, count: data.count };
|
||||
} catch (e: unknown) {
|
||||
return { success: false, error: e instanceof Error ? e.message : "Failed to bulk fulfill orders" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function bulkRecordWholesaleDeposit(
|
||||
orderIds: string[],
|
||||
amount: number,
|
||||
method: string = "cash",
|
||||
reference?: string
|
||||
): Promise<{ success: boolean; count?: number; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query<{ success: boolean; count?: number; error?: string }>(
|
||||
"SELECT * FROM bulk_record_wholesale_deposit($1, $2, $3, $4, $5, $6)",
|
||||
[orderIds, amount, method, reference ?? null, adminUser.user_id, await getActiveBrandId(adminUser)]
|
||||
);
|
||||
const data = Array.isArray(rows) ? rows[0] : rows;
|
||||
if (!data?.success) {
|
||||
return { success: false, error: data?.error ?? "Failed to bulk record deposits" };
|
||||
}
|
||||
return { success: true, count: data.count };
|
||||
} catch (e: unknown) {
|
||||
return { success: false, error: e instanceof Error ? e.message : "Failed to bulk record deposits" };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Notifications ─────────────────────────────────────────────────────────────
|
||||
|
||||
export type WholesaleNotification = {
|
||||
id: string;
|
||||
type: "order_confirmation" | "deposit_received" | "order_fulfilled";
|
||||
email_to: string;
|
||||
email_cc: string | null;
|
||||
subject: string;
|
||||
body_html: string | null;
|
||||
body_text: string | null;
|
||||
brand_id: string;
|
||||
customer_id: string;
|
||||
order_id: string | null;
|
||||
status: string;
|
||||
invoice_business_name: string | null;
|
||||
invoice_business_email: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export async function getWholesaleNotificationStats(
|
||||
brandId: string
|
||||
): Promise<{ pending: number; sent: number; failed: number; total: number }> {
|
||||
try {
|
||||
const { rows } = await pool.query<{ pending: number; sent: number; failed: number; total: number }>(
|
||||
"SELECT * FROM get_wholesale_notification_stats($1)",
|
||||
[brandId]
|
||||
);
|
||||
return rows[0] ?? { pending: 0, sent: 0, failed: 0, total: 0 };
|
||||
} catch {
|
||||
return { pending: 0, sent: 0, failed: 0, total: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getWholesalePendingNotifications(
|
||||
brandId: string,
|
||||
limit = 50
|
||||
): Promise<WholesaleNotification[]> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return [];
|
||||
if (!adminUser.can_manage_orders) return [];
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query<WholesaleNotification>(
|
||||
"SELECT * FROM get_wholesale_pending_notifications($1, $2)",
|
||||
[brandId, limit]
|
||||
);
|
||||
return rows;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function markWholesaleNotificationSent(
|
||||
notificationId: string,
|
||||
error?: string
|
||||
): Promise<{ success: boolean }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false };
|
||||
if (!adminUser.can_manage_orders) return { success: false };
|
||||
|
||||
try {
|
||||
await pool.query(
|
||||
"SELECT mark_wholesale_notification_sent($1, $2)",
|
||||
[notificationId, error ?? null]
|
||||
);
|
||||
return { success: true };
|
||||
} catch {
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
|
||||
export async function enqueueWholesaleNotification(params: {
|
||||
brandId: string;
|
||||
customerId: string;
|
||||
orderId: string;
|
||||
type: "order_confirmation" | "deposit_received" | "order_fulfilled";
|
||||
emailTo: string;
|
||||
emailCc?: string;
|
||||
subject: string;
|
||||
bodyHtml?: string;
|
||||
bodyText?: string;
|
||||
}): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
await pool.query(
|
||||
"SELECT enqueue_wholesale_notification($1, $2, $3, $4, $5, $6, $7, $8, $9)",
|
||||
[
|
||||
params.brandId,
|
||||
params.customerId,
|
||||
params.orderId,
|
||||
params.type,
|
||||
params.emailTo,
|
||||
params.emailCc ?? null,
|
||||
params.subject,
|
||||
params.bodyHtml ?? null,
|
||||
params.bodyText ?? null,
|
||||
]
|
||||
);
|
||||
return { success: true };
|
||||
} catch {
|
||||
return { success: false, error: "Failed to enqueue notification" };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Webhooks ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export type WebhookSettings = {
|
||||
id: string;
|
||||
brand_id: string;
|
||||
url: string;
|
||||
secret: string;
|
||||
enabled: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export async function getWebhookSettings(brandId: string): Promise<WebhookSettings | null> {
|
||||
try {
|
||||
const { rows } = await pool.query<WebhookSettings>(
|
||||
"SELECT * FROM wholesale_webhook_settings WHERE brand_id = $1 LIMIT 1",
|
||||
[brandId]
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveWebhookSettings(params: {
|
||||
brandId: string;
|
||||
url?: string;
|
||||
secret?: string;
|
||||
enabled?: boolean;
|
||||
}): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||
|
||||
try {
|
||||
await pool.query(
|
||||
"SELECT upsert_wholesale_webhook_settings($1::jsonb)",
|
||||
[
|
||||
JSON.stringify({
|
||||
brand_id: params.brandId,
|
||||
url: params.url ?? "",
|
||||
secret: params.secret ?? "",
|
||||
enabled: params.enabled ?? false,
|
||||
}),
|
||||
]
|
||||
);
|
||||
return { success: true };
|
||||
} catch {
|
||||
return { success: false, error: "Failed to save webhook settings" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function enqueueWholesaleWebhook(
|
||||
eventType: "order_created" | "order_fulfilled" | "deposit_recorded" | "order_paid",
|
||||
orderId: string | null = null,
|
||||
payload: Record<string, unknown> | null = null,
|
||||
brandId?: string
|
||||
): Promise<{ success: boolean; logId?: string }> {
|
||||
try {
|
||||
const { rows } = await pool.query<{ id: string }>(
|
||||
"SELECT enqueue_wholesale_webhook($1, $2, $3::jsonb, $4)",
|
||||
[eventType, orderId, payload ? JSON.stringify(payload) : null, brandId ?? null]
|
||||
);
|
||||
const data = Array.isArray(rows) ? rows[0] : rows;
|
||||
return { success: true, logId: data?.id };
|
||||
} catch {
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRecentWebhookActivity(brandId: string, limit = 10): Promise<Array<{
|
||||
id: string;
|
||||
event_type: string;
|
||||
order_id: string | null;
|
||||
status: string;
|
||||
attempts: number;
|
||||
created_at: string;
|
||||
response: string | null;
|
||||
}>> {
|
||||
try {
|
||||
const { rows } = await pool.query<{
|
||||
id: string;
|
||||
event_type: string;
|
||||
order_id: string | null;
|
||||
status: string;
|
||||
attempts: number;
|
||||
created_at: string;
|
||||
response: string | null;
|
||||
}>(
|
||||
"SELECT * FROM wholesale_sync_log WHERE brand_id = $1 ORDER BY created_at DESC LIMIT $2",
|
||||
[brandId, limit]
|
||||
);
|
||||
return rows;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
"use server";
|
||||
|
||||
import { pool } from "@/lib/db";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { enforceBrandScope, resolveBrandId } from "./scope";
|
||||
import type { WholesaleCustomer } from "./types";
|
||||
|
||||
export async function getWholesaleCustomers(brandId?: string): Promise<WholesaleCustomer[]> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return [];
|
||||
const bid = await resolveBrandId(adminUser, brandId);
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query<WholesaleCustomer>(
|
||||
"SELECT * FROM get_wholesale_customers($1)",
|
||||
[bid]
|
||||
);
|
||||
return rows;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveWholesaleCustomer(params: {
|
||||
brandId: string;
|
||||
userId?: string;
|
||||
companyName?: string;
|
||||
contactName?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
accountStatus?: string;
|
||||
creditLimit?: number;
|
||||
depositsEnabled?: boolean;
|
||||
depositThreshold?: number;
|
||||
depositPercentage?: number;
|
||||
orderEmail?: string;
|
||||
invoiceEmail?: string;
|
||||
adminNotes?: string;
|
||||
}): Promise<{ success: boolean; error?: string; id?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||
|
||||
const { error } = await enforceBrandScope(adminUser, params.brandId);
|
||||
if (error) return { success: false, error };
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query<{ id: string }>(
|
||||
"SELECT * FROM upsert_wholesale_customer($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)",
|
||||
[
|
||||
params.brandId,
|
||||
params.userId ?? null,
|
||||
params.companyName ?? null,
|
||||
params.contactName ?? null,
|
||||
params.email ?? null,
|
||||
params.phone ?? null,
|
||||
params.accountStatus ?? "active",
|
||||
params.creditLimit ?? 0,
|
||||
params.depositsEnabled ?? false,
|
||||
params.depositThreshold ?? null,
|
||||
params.depositPercentage ?? null,
|
||||
params.orderEmail ?? null,
|
||||
params.invoiceEmail ?? null,
|
||||
params.adminNotes ?? null,
|
||||
]
|
||||
);
|
||||
const data = Array.isArray(rows) ? rows[0] : rows;
|
||||
return { success: true, id: data?.id };
|
||||
} catch {
|
||||
return { success: false, error: "Failed to save customer" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteWholesaleCustomer(customerId: string, brandId?: string): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||
|
||||
const { error } = await enforceBrandScope(adminUser, brandId);
|
||||
if (error) return { success: false, error };
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query<{ success: boolean; error?: string }>(
|
||||
"SELECT * FROM delete_wholesale_customer($1)",
|
||||
[customerId]
|
||||
);
|
||||
const data = Array.isArray(rows) ? rows[0] : rows;
|
||||
if (!data?.success) {
|
||||
return { success: false, error: data?.error ?? "Delete failed" };
|
||||
}
|
||||
return { success: true };
|
||||
} catch (e: unknown) {
|
||||
return { success: false, error: e instanceof Error ? e.message : "Failed to delete customer" };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
"use server";
|
||||
|
||||
import { pool } from "@/lib/db";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import { enqueueWholesaleWebhook } from "./webhooks";
|
||||
|
||||
export async function recordWholesaleDeposit(
|
||||
orderId: string,
|
||||
amount: number,
|
||||
method: string = "cash",
|
||||
reference?: string,
|
||||
brandId?: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||
|
||||
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||
let data: { success?: boolean; error?: string } | null = null;
|
||||
try {
|
||||
const { rows } = await pool.query<{ success: boolean; error?: string }>(
|
||||
"SELECT * FROM record_wholesale_deposit($1, $2, $3, $4, $5, $6)",
|
||||
[orderId, amount, method, reference ?? null, adminUser.user_id, activeBrandId]
|
||||
);
|
||||
data = Array.isArray(rows) ? rows[0] : rows;
|
||||
if (!data?.success) {
|
||||
return { success: false, error: data?.error ?? "Failed to record deposit" };
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
return { success: false, error: e instanceof Error ? e.message : "Failed to record deposit" };
|
||||
}
|
||||
|
||||
// Fire webhook — fire-and-forget
|
||||
enqueueWholesaleWebhook("deposit_recorded", orderId, { order_id: orderId, amount }, activeBrandId ?? undefined).catch(() => {});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function bulkFulfillWholesaleOrders(
|
||||
orderIds: string[]
|
||||
): Promise<{ success: boolean; count?: number; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query<{ success: boolean; count?: number; error?: string }>(
|
||||
"SELECT * FROM bulk_fulfill_wholesale_orders($1, $2, $3)",
|
||||
[orderIds, adminUser.user_id, await getActiveBrandId(adminUser)]
|
||||
);
|
||||
const data = Array.isArray(rows) ? rows[0] : rows;
|
||||
if (!data?.success) {
|
||||
return { success: false, error: data?.error ?? "Failed to bulk fulfill orders" };
|
||||
}
|
||||
return { success: true, count: data.count };
|
||||
} catch (e: unknown) {
|
||||
return { success: false, error: e instanceof Error ? e.message : "Failed to bulk fulfill orders" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function bulkRecordWholesaleDeposit(
|
||||
orderIds: string[],
|
||||
amount: number,
|
||||
method: string = "cash",
|
||||
reference?: string
|
||||
): Promise<{ success: boolean; count?: number; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query<{ success: boolean; count?: number; error?: string }>(
|
||||
"SELECT * FROM bulk_record_wholesale_deposit($1, $2, $3, $4, $5, $6)",
|
||||
[orderIds, amount, method, reference ?? null, adminUser.user_id, await getActiveBrandId(adminUser)]
|
||||
);
|
||||
const data = Array.isArray(rows) ? rows[0] : rows;
|
||||
if (!data?.success) {
|
||||
return { success: false, error: data?.error ?? "Failed to bulk record deposits" };
|
||||
}
|
||||
return { success: true, count: data.count };
|
||||
} catch (e: unknown) {
|
||||
return { success: false, error: e instanceof Error ? e.message : "Failed to bulk record deposits" };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Barrel re-export for the wholesale action set.
|
||||
*
|
||||
* Source modules live next to this file:
|
||||
* - orders.ts — order CRUD + dashboard stats
|
||||
* - customers.ts — wholesale customer CRUD
|
||||
* - products.ts — wholesale product CRUD
|
||||
* - settings.ts — wholesale_settings + public read
|
||||
* - deposits.ts — deposit recording + bulk actions
|
||||
* - notifications.ts — email/SMS notification queue
|
||||
* - webhooks.ts — outbound webhook settings + dispatch
|
||||
* - scope.ts — internal brand-scoping helpers (NOT re-exported here)
|
||||
* - types.ts — shared type definitions
|
||||
*
|
||||
* Existing imports of `@/actions/wholesale` keep working. New code should
|
||||
* import directly from the focused modules to keep the dependency graph
|
||||
* tight.
|
||||
*/
|
||||
|
||||
export type {
|
||||
WholesaleOrder,
|
||||
WholesaleCustomer,
|
||||
WholesaleProduct,
|
||||
NotificationRecipient,
|
||||
WholesaleSettings,
|
||||
WholesaleDashboardStats,
|
||||
WholesaleNotification,
|
||||
WebhookSettings,
|
||||
} from "./types";
|
||||
|
||||
export {
|
||||
getWholesaleOrders,
|
||||
getWholesalePickupOrders,
|
||||
getWholesaleDashboardStats,
|
||||
markWholesaleOrderFulfilled,
|
||||
updateWholesaleOrderStatus,
|
||||
deleteWholesaleOrder,
|
||||
} from "./orders";
|
||||
|
||||
export {
|
||||
getWholesaleCustomers,
|
||||
saveWholesaleCustomer,
|
||||
deleteWholesaleCustomer,
|
||||
} from "./customers";
|
||||
|
||||
export {
|
||||
getWholesaleProducts,
|
||||
saveWholesaleProduct,
|
||||
deleteWholesaleProduct,
|
||||
} from "./products";
|
||||
|
||||
export {
|
||||
getWholesaleSettings,
|
||||
getWholesaleSettingsPublic,
|
||||
saveWholesaleSettings,
|
||||
} from "./settings";
|
||||
|
||||
export {
|
||||
recordWholesaleDeposit,
|
||||
bulkFulfillWholesaleOrders,
|
||||
bulkRecordWholesaleDeposit,
|
||||
} from "./deposits";
|
||||
|
||||
export {
|
||||
getWholesaleNotificationStats,
|
||||
getWholesalePendingNotifications,
|
||||
markWholesaleNotificationSent,
|
||||
enqueueWholesaleNotification,
|
||||
} from "./notifications";
|
||||
|
||||
export {
|
||||
getWebhookSettings,
|
||||
saveWebhookSettings,
|
||||
enqueueWholesaleWebhook,
|
||||
getRecentWebhookActivity,
|
||||
} from "./webhooks";
|
||||
@@ -0,0 +1,89 @@
|
||||
"use server";
|
||||
|
||||
import { pool } from "@/lib/db";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import type { WholesaleNotification } from "./types";
|
||||
|
||||
export async function getWholesaleNotificationStats(
|
||||
brandId: string
|
||||
): Promise<{ pending: number; sent: number; failed: number; total: number }> {
|
||||
try {
|
||||
const { rows } = await pool.query<{ pending: number; sent: number; failed: number; total: number }>(
|
||||
"SELECT * FROM get_wholesale_notification_stats($1)",
|
||||
[brandId]
|
||||
);
|
||||
return rows[0] ?? { pending: 0, sent: 0, failed: 0, total: 0 };
|
||||
} catch {
|
||||
return { pending: 0, sent: 0, failed: 0, total: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getWholesalePendingNotifications(
|
||||
brandId: string,
|
||||
limit = 50
|
||||
): Promise<WholesaleNotification[]> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return [];
|
||||
if (!adminUser.can_manage_orders) return [];
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query<WholesaleNotification>(
|
||||
"SELECT * FROM get_wholesale_pending_notifications($1, $2)",
|
||||
[brandId, limit]
|
||||
);
|
||||
return rows;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function markWholesaleNotificationSent(
|
||||
notificationId: string,
|
||||
error?: string
|
||||
): Promise<{ success: boolean }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false };
|
||||
if (!adminUser.can_manage_orders) return { success: false };
|
||||
|
||||
try {
|
||||
await pool.query(
|
||||
"SELECT mark_wholesale_notification_sent($1, $2)",
|
||||
[notificationId, error ?? null]
|
||||
);
|
||||
return { success: true };
|
||||
} catch {
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
|
||||
export async function enqueueWholesaleNotification(params: {
|
||||
brandId: string;
|
||||
customerId: string;
|
||||
orderId: string;
|
||||
type: "order_confirmation" | "deposit_received" | "order_fulfilled";
|
||||
emailTo: string;
|
||||
emailCc?: string;
|
||||
subject: string;
|
||||
bodyHtml?: string;
|
||||
bodyText?: string;
|
||||
}): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
await pool.query(
|
||||
"SELECT enqueue_wholesale_notification($1, $2, $3, $4, $5, $6, $7, $8, $9)",
|
||||
[
|
||||
params.brandId,
|
||||
params.customerId,
|
||||
params.orderId,
|
||||
params.type,
|
||||
params.emailTo,
|
||||
params.emailCc ?? null,
|
||||
params.subject,
|
||||
params.bodyHtml ?? null,
|
||||
params.bodyText ?? null,
|
||||
]
|
||||
);
|
||||
return { success: true };
|
||||
} catch {
|
||||
return { success: false, error: "Failed to enqueue notification" };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
"use server";
|
||||
|
||||
import { pool } from "@/lib/db";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import { enforceBrandScope, resolveBrandId } from "./scope";
|
||||
import { enqueueWholesaleWebhook } from "./webhooks";
|
||||
import type { WholesaleDashboardStats, WholesaleOrder } from "./types";
|
||||
|
||||
export async function getWholesaleOrders(brandId?: string): Promise<WholesaleOrder[]> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return [];
|
||||
const bid = await resolveBrandId(adminUser, brandId);
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query<WholesaleOrder>(
|
||||
"SELECT * FROM get_wholesale_orders($1)",
|
||||
[bid]
|
||||
);
|
||||
return rows;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getWholesalePickupOrders(brandId?: string): Promise<WholesaleOrder[]> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return [];
|
||||
const bid = await resolveBrandId(adminUser, brandId);
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query<WholesaleOrder>(
|
||||
"SELECT * FROM get_wholesale_pickup_orders($1)",
|
||||
[bid]
|
||||
);
|
||||
return rows;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getWholesaleDashboardStats(brandId?: string): Promise<WholesaleDashboardStats> {
|
||||
const orders = await getWholesaleOrders(brandId);
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
|
||||
const open = orders.filter(o => ["pending", "awaiting_deposit", "confirmed"].includes(o.status));
|
||||
const pickupToday = orders.filter(o => o.anticipated_pickup_date === today && o.fulfillment_status !== "fulfilled");
|
||||
const pastDue = orders.filter(o => o.anticipated_pickup_date && o.anticipated_pickup_date < today && o.fulfillment_status !== "fulfilled");
|
||||
const totalUnpaid = open.reduce((sum, o) => sum + Number(o.balance_due), 0);
|
||||
const awaitingDep = orders.filter(o => o.status === "awaiting_deposit");
|
||||
const fulfilledToday = orders.filter(o => o.fulfillment_status === "fulfilled" && o.updated_at?.startsWith(today));
|
||||
|
||||
return {
|
||||
open_orders: open.length,
|
||||
pickup_today: pickupToday.length,
|
||||
past_due: pastDue.length,
|
||||
total_unpaid: totalUnpaid,
|
||||
awaiting_deposit: awaitingDep.length,
|
||||
fulfilled_today: fulfilledToday.length,
|
||||
};
|
||||
}
|
||||
|
||||
export async function markWholesaleOrderFulfilled(orderId: string, brandId?: string): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||
|
||||
const { brandId: resolved, error } = await enforceBrandScope(adminUser, brandId);
|
||||
if (error) return { success: false, error };
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
"SELECT * FROM mark_wholesale_order_fulfilled($1, $2)",
|
||||
[orderId, adminUser.user_id]
|
||||
);
|
||||
if (!rows || rows.length === 0) {
|
||||
return { success: false, error: "Failed to mark fulfilled" };
|
||||
}
|
||||
} catch {
|
||||
return { success: false, error: "Failed to mark fulfilled" };
|
||||
}
|
||||
|
||||
enqueueWholesaleWebhook("order_fulfilled", orderId, { order_id: orderId }, resolved ?? undefined).catch(() => {});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function updateWholesaleOrderStatus(
|
||||
orderId: string,
|
||||
status: "pending" | "confirmed" | "cancelled",
|
||||
brandId?: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||
|
||||
const { error } = await enforceBrandScope(adminUser, brandId);
|
||||
if (error) return { success: false, error };
|
||||
|
||||
try {
|
||||
await pool.query(
|
||||
"SELECT * FROM update_wholesale_order_status($1, $2)",
|
||||
[orderId, status]
|
||||
);
|
||||
return { success: true };
|
||||
} catch {
|
||||
return { success: false, error: "Failed to update order status" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteWholesaleOrder(orderId: string, brandId?: string): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||
|
||||
const { error } = await enforceBrandScope(adminUser, brandId);
|
||||
if (error) return { success: false, error };
|
||||
|
||||
try {
|
||||
await pool.query(
|
||||
"SELECT * FROM delete_wholesale_order($1)",
|
||||
[orderId]
|
||||
);
|
||||
return { success: true };
|
||||
} catch {
|
||||
return { success: false, error: "Failed to delete order" };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
"use server";
|
||||
|
||||
import { pool } from "@/lib/db";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { enforceBrandScope, resolveBrandId } from "./scope";
|
||||
import type { WholesaleProduct } from "./types";
|
||||
|
||||
export async function getWholesaleProducts(brandId?: string): Promise<WholesaleProduct[]> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return [];
|
||||
const bid = await resolveBrandId(adminUser, brandId);
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query<WholesaleProduct>(
|
||||
"SELECT * FROM get_wholesale_products($1)",
|
||||
[bid]
|
||||
);
|
||||
return rows;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveWholesaleProduct(params: {
|
||||
brandId: string;
|
||||
id?: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
unitType?: string;
|
||||
availability?: string;
|
||||
qtyAvailable?: number;
|
||||
priceTiers?: Array<{ min_qty: number; max_qty: number; price: number }>;
|
||||
hpSku?: string;
|
||||
hpItemId?: string;
|
||||
internalNotes?: string;
|
||||
handlingInstructions?: string;
|
||||
storageWarning?: string;
|
||||
productLabel?: string;
|
||||
packStyle?: string;
|
||||
containerType?: string;
|
||||
defaultPickupLocation?: string;
|
||||
}): Promise<{ success: boolean; error?: string; id?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_products) return { success: false, error: "Not authorized" };
|
||||
|
||||
const { error } = await enforceBrandScope(adminUser, params.brandId);
|
||||
if (error) return { success: false, error };
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query<{ id: string }>(
|
||||
"SELECT * FROM upsert_wholesale_product($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10, $11, $12, $13, $14, $15, $16, $17)",
|
||||
[
|
||||
params.brandId,
|
||||
params.id ?? null,
|
||||
params.name,
|
||||
params.description ?? null,
|
||||
params.unitType ?? "each",
|
||||
params.availability ?? "unavailable",
|
||||
params.qtyAvailable ?? 0,
|
||||
JSON.stringify(params.priceTiers ?? []),
|
||||
params.hpSku ?? null,
|
||||
params.hpItemId ?? null,
|
||||
params.internalNotes ?? null,
|
||||
params.handlingInstructions ?? null,
|
||||
params.storageWarning ?? null,
|
||||
params.productLabel ?? null,
|
||||
params.packStyle ?? null,
|
||||
params.containerType ?? null,
|
||||
params.defaultPickupLocation ?? null,
|
||||
]
|
||||
);
|
||||
const data = Array.isArray(rows) ? rows[0] : rows;
|
||||
return { success: true, id: data?.id };
|
||||
} catch {
|
||||
return { success: false, error: "Failed to save product" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteWholesaleProduct(productId: string, brandId?: string): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_products) return { success: false, error: "Not authorized" };
|
||||
|
||||
const { error } = await enforceBrandScope(adminUser, brandId);
|
||||
if (error) return { success: false, error };
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query<{ success: boolean; error?: string }>(
|
||||
"SELECT * FROM delete_wholesale_product($1)",
|
||||
[productId]
|
||||
);
|
||||
const data = Array.isArray(rows) ? rows[0] : rows;
|
||||
if (!data?.success) {
|
||||
return { success: false, error: data?.error ?? "Delete failed" };
|
||||
}
|
||||
return { success: true };
|
||||
} catch (e: unknown) {
|
||||
return { success: false, error: e instanceof Error ? e.message : "Failed to delete product" };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Brand scoping helpers shared across the wholesale actions.
|
||||
*
|
||||
* No "use server" so this can also be imported from non-action contexts
|
||||
* (e.g. middleware, tests).
|
||||
*/
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
|
||||
/**
|
||||
* Resolves the effective brand_id for an action, enforcing brand scoping.
|
||||
*
|
||||
* platform_admin → null (means "all brands" — passes to RPC unchanged)
|
||||
* brand_admin → their own brand_id only; rejects attempts to operate on other brands
|
||||
* store_employee → their own brand_id
|
||||
* multi_brand_admin → active brand from cookie/URL/legacy, must be in brand_ids
|
||||
* unauthenticated → null (actions should already bail out earlier)
|
||||
*
|
||||
* This prevents brand_admin from seeing or modifying another brand's data
|
||||
* even if they manually pass a different brandId to the action.
|
||||
*/
|
||||
export async function resolveBrandId(
|
||||
adminUser: Awaited<ReturnType<typeof getAdminUser>>,
|
||||
requestedBrandId?: string
|
||||
): Promise<string | null> {
|
||||
if (!adminUser) return null;
|
||||
|
||||
if (adminUser.role === "platform_admin") {
|
||||
// platform_admin can operate on all brands — pass null (= all brands) to RPC
|
||||
return null;
|
||||
}
|
||||
|
||||
// For non-platform-admin: resolve the active brand (validates against brand_ids)
|
||||
const activeBrandId = await getActiveBrandId(adminUser, requestedBrandId);
|
||||
|
||||
if (requestedBrandId && activeBrandId !== requestedBrandId) {
|
||||
// Brand admin trying to operate on another brand's data — block it
|
||||
return null; // caller should check and return unauthorized
|
||||
}
|
||||
|
||||
return activeBrandId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Like resolveBrandId but returns null for platform_admin AND throws an error
|
||||
* if a brand_admin tries to operate outside their brand.
|
||||
* Use for mutating actions (save, delete, fulfill) where cross-brand access must be blocked.
|
||||
*/
|
||||
export async function enforceBrandScope(
|
||||
adminUser: Awaited<ReturnType<typeof getAdminUser>>,
|
||||
requestedBrandId?: string
|
||||
): Promise<{ brandId: string | null; error?: string }> {
|
||||
if (!adminUser) return { brandId: null, error: "Not authenticated" };
|
||||
|
||||
if (adminUser.role === "platform_admin") {
|
||||
return { brandId: null }; // unrestricted
|
||||
}
|
||||
|
||||
// For non-platform-admin: resolve the active brand (validates against brand_ids)
|
||||
const activeBrandId = await getActiveBrandId(adminUser, requestedBrandId);
|
||||
|
||||
if (requestedBrandId && activeBrandId !== requestedBrandId) {
|
||||
return { brandId: null, error: "Not authorized to operate on this brand" };
|
||||
}
|
||||
|
||||
return { brandId: activeBrandId };
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
"use server";
|
||||
|
||||
import { pool } from "@/lib/db";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||
import type { NotificationRecipient, WholesaleSettings } from "./types";
|
||||
|
||||
export async function getWholesaleSettings(brandId?: string): Promise<WholesaleSettings | null> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return null;
|
||||
const bid = await getActiveBrandId(adminUser, brandId);
|
||||
if (!bid && adminUser.role !== "platform_admin") {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query<WholesaleSettings>(
|
||||
"SELECT * FROM get_wholesale_settings($1)",
|
||||
[bid]
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getWholesaleSettingsPublic(brandId: string): Promise<{ invoice_business_address: string | null } | null> {
|
||||
try {
|
||||
const { rows } = await pool.query<{ invoice_business_address: string | null }>(
|
||||
"SELECT invoice_business_address FROM get_wholesale_settings($1)",
|
||||
[brandId]
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveWholesaleSettings(params: {
|
||||
brandId: string;
|
||||
requireApproval?: boolean;
|
||||
minOrderAmount?: number;
|
||||
onlinePaymentEnabled?: boolean;
|
||||
wholesaleEnabled?: boolean;
|
||||
squareSyncEnabled?: boolean;
|
||||
pickupLocation?: string;
|
||||
fobLocation?: string;
|
||||
fromEmail?: string;
|
||||
invoiceBusinessName?: string;
|
||||
invoiceBusinessAddress?: string;
|
||||
invoiceBusinessPhone?: string;
|
||||
invoiceBusinessEmail?: string;
|
||||
invoiceBusinessWebsite?: string;
|
||||
notificationEmail?: string;
|
||||
notificationRecipients?: NotificationRecipient[];
|
||||
}): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||
|
||||
try {
|
||||
await pool.query(
|
||||
"SELECT upsert_wholesale_settings($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)",
|
||||
[
|
||||
params.brandId,
|
||||
params.requireApproval ?? null,
|
||||
params.minOrderAmount ?? null,
|
||||
params.onlinePaymentEnabled ?? null,
|
||||
params.wholesaleEnabled ?? null,
|
||||
params.pickupLocation ?? null,
|
||||
params.fobLocation ?? null,
|
||||
params.fromEmail ?? null,
|
||||
params.invoiceBusinessName ?? null,
|
||||
params.invoiceBusinessAddress ?? null,
|
||||
params.invoiceBusinessPhone ?? null,
|
||||
params.invoiceBusinessEmail ?? null,
|
||||
params.invoiceBusinessWebsite ?? null,
|
||||
params.notificationEmail ?? null,
|
||||
params.notificationRecipients ? JSON.stringify(params.notificationRecipients) : null,
|
||||
params.squareSyncEnabled ?? null,
|
||||
]
|
||||
);
|
||||
return { success: true };
|
||||
} catch {
|
||||
return { success: false, error: "Failed to save settings" };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Shared types for wholesale actions.
|
||||
*
|
||||
* Pure type definitions — no runtime imports, no "use server" so it can
|
||||
* be imported into both server and client code.
|
||||
*/
|
||||
|
||||
export type WholesaleOrder = {
|
||||
id: string;
|
||||
customer_id: string;
|
||||
status: string;
|
||||
fulfillment_status: string;
|
||||
payment_status: string;
|
||||
anticipated_pickup_date: string | null;
|
||||
subtotal: number;
|
||||
deposit_required: number;
|
||||
deposit_paid: number;
|
||||
balance_due: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
invoice_number: string | null;
|
||||
assigned_employee_id: string | null;
|
||||
company_name: string;
|
||||
contact_name: string | null;
|
||||
customer_email: string;
|
||||
customer_phone: string | null;
|
||||
items: Array<{
|
||||
id: string;
|
||||
product_name: string;
|
||||
quantity: number;
|
||||
unit_price: number;
|
||||
line_total: number;
|
||||
}>;
|
||||
fulfilled_at: string | null;
|
||||
};
|
||||
|
||||
export type WholesaleCustomer = {
|
||||
id: string;
|
||||
user_id: string | null;
|
||||
company_name: string;
|
||||
contact_name: string | null;
|
||||
email: string;
|
||||
phone: string | null;
|
||||
account_status: string;
|
||||
credit_limit: number;
|
||||
deposits_enabled: boolean;
|
||||
deposit_threshold: number | null;
|
||||
deposit_percentage: number | null;
|
||||
order_email: string | null;
|
||||
invoice_email: string | null;
|
||||
admin_notes: string | null;
|
||||
role: string;
|
||||
created_at: string;
|
||||
deleted_at: string | null;
|
||||
};
|
||||
|
||||
export type WholesaleProduct = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
unit_type: string;
|
||||
unit_type_custom: string | null;
|
||||
availability: string;
|
||||
qty_available: number;
|
||||
season_start: string | null;
|
||||
season_end: string | null;
|
||||
price_tiers: Array<{ min_qty: number; max_qty: number; price: number }>;
|
||||
hp_sku: string | null;
|
||||
hp_item_id: string | null;
|
||||
handling_instructions: string | null;
|
||||
storage_warning: string | null;
|
||||
loading_notes: string | null;
|
||||
product_label: string | null;
|
||||
pack_style: string | null;
|
||||
container_type: string | null;
|
||||
container_size_code: string | null;
|
||||
units_per_container: number | null;
|
||||
default_pickup_location: string | null;
|
||||
created_at: string;
|
||||
deleted_at: string | null;
|
||||
};
|
||||
|
||||
export type NotificationRecipient = {
|
||||
email: string;
|
||||
name?: string;
|
||||
active: boolean;
|
||||
notification_types?: (
|
||||
| "order_confirmation"
|
||||
| "deposit_received"
|
||||
| "order_fulfilled"
|
||||
| "price_sheet"
|
||||
| "unclaimed_pickup"
|
||||
)[];
|
||||
};
|
||||
|
||||
export type WholesaleSettings = {
|
||||
id: string;
|
||||
brand_id: string;
|
||||
portal_page_id: string | null;
|
||||
price_sheet_page_id: string | null;
|
||||
require_approval: boolean;
|
||||
min_order_amount: number | null;
|
||||
online_payment_enabled: boolean;
|
||||
wholesale_enabled: boolean;
|
||||
square_sync_enabled: boolean;
|
||||
pickup_location: string;
|
||||
fob_location: string;
|
||||
from_email: string;
|
||||
invoice_business_name: string;
|
||||
invoice_business_address: string | null;
|
||||
invoice_business_phone: string | null;
|
||||
invoice_business_email: string | null;
|
||||
invoice_business_website: string | null;
|
||||
notification_email: string | null;
|
||||
notification_recipients: NotificationRecipient[];
|
||||
last_invoice_number: number;
|
||||
};
|
||||
|
||||
export type WholesaleDashboardStats = {
|
||||
open_orders: number;
|
||||
pickup_today: number;
|
||||
past_due: number;
|
||||
total_unpaid: number;
|
||||
awaiting_deposit: number;
|
||||
fulfilled_today: number;
|
||||
};
|
||||
|
||||
export type WholesaleNotification = {
|
||||
id: string;
|
||||
type: "order_confirmation" | "deposit_received" | "order_fulfilled";
|
||||
email_to: string;
|
||||
email_cc: string | null;
|
||||
subject: string;
|
||||
body_html: string | null;
|
||||
body_text: string | null;
|
||||
brand_id: string;
|
||||
customer_id: string;
|
||||
order_id: string | null;
|
||||
status: string;
|
||||
invoice_business_name: string | null;
|
||||
invoice_business_email: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type WebhookSettings = {
|
||||
id: string;
|
||||
brand_id: string;
|
||||
url: string;
|
||||
secret: string;
|
||||
enabled: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
"use server";
|
||||
|
||||
import { pool } from "@/lib/db";
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import type { WebhookSettings } from "./types";
|
||||
|
||||
export async function getWebhookSettings(brandId: string): Promise<WebhookSettings | null> {
|
||||
try {
|
||||
const { rows } = await pool.query<WebhookSettings>(
|
||||
"SELECT * FROM wholesale_webhook_settings WHERE brand_id = $1 LIMIT 1",
|
||||
[brandId]
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveWebhookSettings(params: {
|
||||
brandId: string;
|
||||
url?: string;
|
||||
secret?: string;
|
||||
enabled?: boolean;
|
||||
}): Promise<{ success: boolean; error?: string }> {
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||
|
||||
try {
|
||||
await pool.query(
|
||||
"SELECT upsert_wholesale_webhook_settings($1::jsonb)",
|
||||
[
|
||||
JSON.stringify({
|
||||
brand_id: params.brandId,
|
||||
url: params.url ?? "",
|
||||
secret: params.secret ?? "",
|
||||
enabled: params.enabled ?? false,
|
||||
}),
|
||||
]
|
||||
);
|
||||
return { success: true };
|
||||
} catch {
|
||||
return { success: false, error: "Failed to save webhook settings" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function enqueueWholesaleWebhook(
|
||||
eventType: "order_created" | "order_fulfilled" | "deposit_recorded" | "order_paid",
|
||||
orderId: string | null = null,
|
||||
payload: Record<string, unknown> | null = null,
|
||||
brandId?: string
|
||||
): Promise<{ success: boolean; logId?: string }> {
|
||||
try {
|
||||
const { rows } = await pool.query<{ id: string }>(
|
||||
"SELECT enqueue_wholesale_webhook($1, $2, $3::jsonb, $4)",
|
||||
[eventType, orderId, payload ? JSON.stringify(payload) : null, brandId ?? null]
|
||||
);
|
||||
const data = Array.isArray(rows) ? rows[0] : rows;
|
||||
return { success: true, logId: data?.id };
|
||||
} catch {
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRecentWebhookActivity(brandId: string, limit = 10): Promise<Array<{
|
||||
id: string;
|
||||
event_type: string;
|
||||
order_id: string | null;
|
||||
status: string;
|
||||
attempts: number;
|
||||
created_at: string;
|
||||
response: string | null;
|
||||
}>> {
|
||||
try {
|
||||
const { rows } = await pool.query<{
|
||||
id: string;
|
||||
event_type: string;
|
||||
order_id: string | null;
|
||||
status: string;
|
||||
attempts: number;
|
||||
created_at: string;
|
||||
response: string | null;
|
||||
}>(
|
||||
"SELECT * FROM wholesale_sync_log WHERE brand_id = $1 ORDER BY created_at DESC LIMIT $2",
|
||||
[brandId, limit]
|
||||
);
|
||||
return rows;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ export default async function AdminPage() {
|
||||
// so a transient DB/network failure can't crash the whole admin page.
|
||||
let dashboardBrandId: string | null = adminUser ? await getActiveBrandId(adminUser) : null;
|
||||
if (!dashboardBrandId && adminUser?.role === "platform_admin") {
|
||||
// Direct pg query (the supabase shim returns empty results).
|
||||
// Direct pg query (the legacy query-builder shim returned empty results).
|
||||
// This ensures a platform_admin sees real dashboard stats even on first login
|
||||
// before they have chosen an active brand.
|
||||
try {
|
||||
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
enqueueWholesaleNotification,
|
||||
getWebhookSettings,
|
||||
saveWebhookSettings,
|
||||
enqueueWholesaleWebhook,
|
||||
getRecentWebhookActivity,
|
||||
} from "@/actions/wholesale";
|
||||
import DepositModal from "@/components/wholesale/DepositModal";
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
* TODO(migration): wholesale_orders is part of the legacy schema and
|
||||
* is read/written via raw `pool.query` SQL. The `get_wholesale_settings`
|
||||
* and `get_payment_settings` SECURITY DEFINER RPCs still live in the
|
||||
* database (see supabase/migrations/046 and 045) and are also called
|
||||
* database (originally from the now-archived supabase migrations;
|
||||
* consolidated into `db/migrations/0001_init.sql`) and are also called
|
||||
* via `pool.query`. When wholesale is reactivated, declare the tables
|
||||
* in `db/schema/wholesale.ts` and switch the reads to typed Drizzle.
|
||||
*/
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
* `enqueue_wholesale_notification` SECURITY DEFINER RPC live in the
|
||||
* legacy schema. Reads are converted to `pool.query`; the
|
||||
* `enqueue_wholesale_notification` RPC is still in the database
|
||||
* (supabase/migrations/054) and is called via `pool.query` rather
|
||||
* than the Supabase REST gateway. When wholesale is reactivated,
|
||||
* move the tables into `db/schema/wholesale.ts` and switch reads to
|
||||
* typed Drizzle.
|
||||
* (originally from the now-archived supabase migrations; consolidated
|
||||
* into `db/migrations/0001_init.sql`) and is called via `pool.query`.
|
||||
* When wholesale is reactivated, move the tables into
|
||||
* `db/schema/wholesale.ts` and switch reads to typed Drizzle.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
@@ -10,10 +10,6 @@ import { getStorefrontWholesaleSettings } from "@/actions/storefront";
|
||||
|
||||
type BrandSettings = {
|
||||
invoice_business_name: string | null;
|
||||
invoice_business_address: string | null;
|
||||
invoice_business_phone: string | null;
|
||||
invoice_business_email: string | null;
|
||||
invoice_business_website: string | null;
|
||||
};
|
||||
|
||||
type FormState = {
|
||||
@@ -90,7 +86,7 @@ export default function IndianRiverContactPage() {
|
||||
{[
|
||||
{
|
||||
label: "Address",
|
||||
value: brandSettings?.invoice_business_address ?? "Indian River Region, Florida",
|
||||
value: "Indian River Region, Florida",
|
||||
icon: <path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />,
|
||||
iconPath: <path strokeLinecap="round" strokeLinejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z" />
|
||||
},
|
||||
@@ -102,7 +98,7 @@ export default function IndianRiverContactPage() {
|
||||
},
|
||||
{
|
||||
label: "Email",
|
||||
value: brandSettings?.invoice_business_email ?? "Info@indianriverdirect.com",
|
||||
value: "Info@indianriverdirect.com",
|
||||
icon: <path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />,
|
||||
iconPath: null
|
||||
},
|
||||
|
||||
@@ -69,7 +69,7 @@ export default function IndianRiverStopsList({ stops, brandName, brandSlug }: Pr
|
||||
{upcomingStops.map((stop) => (
|
||||
<Link
|
||||
key={stop.id}
|
||||
href={`/${brandSlug}/stops/${stop.slug}`}
|
||||
href={`/${brandSlug}/stops/${stop.id}`}
|
||||
className="stop-card group block bg-white rounded-2xl p-5 shadow-sm ring-1 ring-stone-200/60 transition-all duration-300 hover:shadow-lg hover:ring-orange-200"
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
|
||||
|
||||
+3
-6
@@ -9,7 +9,7 @@ import StopSetEffect from "@/components/storefront/StopSetEffect";
|
||||
import StorefrontHeader from "@/components/storefront/StorefrontHeader";
|
||||
import StorefrontFooter from "@/components/storefront/StorefrontFooter";
|
||||
import LayoutContainer from "@/components/layout/LayoutContainer";
|
||||
import { getStorefrontStopBySlug } from "@/actions/storefront";
|
||||
import { getStorefrontStopById } from "@/actions/storefront";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
|
||||
type Product = {
|
||||
@@ -36,7 +36,7 @@ type Stop = {
|
||||
|
||||
export default function StopPage() {
|
||||
const params = useParams();
|
||||
const slug = params.slug as string;
|
||||
const slug = params.id as string;
|
||||
|
||||
const [stop, setStop] = useState<{
|
||||
id: string;
|
||||
@@ -53,13 +53,10 @@ export default function StopPage() {
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const result = await getStorefrontStopBySlug(slug);
|
||||
const result = await getStorefrontStopById(slug);
|
||||
if (!result) return;
|
||||
|
||||
setStop(result.stop as unknown as Stop);
|
||||
const isIndian = slug.includes("indian");
|
||||
setBrandSlug(isIndian ? "indian-river-direct" : "tuxedo");
|
||||
setBrandAccent(isIndian ? "blue" : "green");
|
||||
setProducts(result.products as unknown as Product[]);
|
||||
}
|
||||
load();
|
||||
@@ -9,10 +9,6 @@ import { getStorefrontWholesaleSettings } from "@/actions/storefront";
|
||||
|
||||
type BrandSettings = {
|
||||
invoice_business_name: string | null;
|
||||
invoice_business_address: string | null;
|
||||
invoice_business_phone: string | null;
|
||||
invoice_business_email: string | null;
|
||||
invoice_business_website: string | null;
|
||||
};
|
||||
|
||||
type FormState = {
|
||||
@@ -72,7 +68,7 @@ export default function TuxedoContactPage() {
|
||||
</div>
|
||||
<h3 className="text-base font-bold text-stone-950 mb-3">Farm Address</h3>
|
||||
<p className="text-sm text-stone-500 leading-relaxed">
|
||||
{brandSettings?.invoice_business_address ?? "59751 David Road, Olathe, CO 81425"}
|
||||
59751 David Road, Olathe, CO 81425
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-3xl bg-white p-8 shadow-sm ring-1 ring-stone-200/60 text-center">
|
||||
@@ -96,8 +92,8 @@ export default function TuxedoContactPage() {
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-base font-bold text-stone-950 mb-3">Email</h3>
|
||||
<a href={`mailto:${brandSettings?.invoice_business_email ?? "orders@tuxedocorn.com"}`} className="text-sm text-stone-500 hover:text-emerald-700 transition-colors">
|
||||
{brandSettings?.invoice_business_email ?? "orders@tuxedocorn.com"}
|
||||
<a href="mailto:orders@tuxedocorn.com" className="text-sm text-stone-500 hover:text-emerald-700 transition-colors">
|
||||
orders@tuxedocorn.com
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -86,7 +86,7 @@ export default function TuxedoStopsList({ stops, brandName, brandSlug }: Props)
|
||||
{upcomingStops.map((stop) => (
|
||||
<Link
|
||||
key={stop.id}
|
||||
href={`/${brandSlug}/stops/${stop.slug}`}
|
||||
href={`/${brandSlug}/stops/${stop.id}`}
|
||||
className="stop-card group block bg-white rounded-2xl p-5 shadow-sm ring-1 ring-stone-200/60 transition-all duration-300 hover:shadow-lg hover:ring-emerald-200"
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
|
||||
|
||||
@@ -8,7 +8,7 @@ import StopSetEffect from "@/components/storefront/StopSetEffect";
|
||||
import StorefrontHeader from "@/components/storefront/StorefrontHeader";
|
||||
import StorefrontFooter from "@/components/storefront/StorefrontFooter";
|
||||
import LayoutContainer from "@/components/layout/LayoutContainer";
|
||||
import { getStorefrontStopBySlug } from "@/actions/storefront";
|
||||
import { getStorefrontStopById } from "@/actions/storefront";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
|
||||
type Product = {
|
||||
@@ -35,7 +35,7 @@ type Stop = {
|
||||
|
||||
export default function StopPage() {
|
||||
const params = useParams();
|
||||
const slug = params.slug as string;
|
||||
const slug = params.id as string;
|
||||
|
||||
const [stop, setStop] = useState<{
|
||||
id: string;
|
||||
@@ -52,13 +52,10 @@ export default function StopPage() {
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const result = await getStorefrontStopBySlug(slug);
|
||||
const result = await getStorefrontStopById(slug);
|
||||
if (!result) return;
|
||||
|
||||
setStop(result.stop as unknown as Stop);
|
||||
const isIndian = slug.includes("indian");
|
||||
setBrandSlug(isIndian ? "indian-river-direct" : "tuxedo");
|
||||
setBrandAccent(isIndian ? "blue" : "green");
|
||||
setProducts(result.products as unknown as Product[]);
|
||||
}
|
||||
load();
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Edge-safe Neon Auth configuration.
|
||||
*
|
||||
* This file is imported by `src/middleware.ts`, which runs in the Edge
|
||||
* This file is imported by `src/proxy.ts`, which runs in the Edge
|
||||
* runtime. It must NOT import:
|
||||
* - `pg` / any Node-only database driver
|
||||
* - The Drizzle client
|
||||
|
||||
@@ -2,7 +2,7 @@ import Link from "next/link";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
|
||||
type StopCardProps = {
|
||||
slug: string;
|
||||
id: string;
|
||||
city: string;
|
||||
state: string;
|
||||
date: string;
|
||||
@@ -13,7 +13,7 @@ type StopCardProps = {
|
||||
};
|
||||
|
||||
export default function StopCard({
|
||||
slug, city, state, date, time, location,
|
||||
id, city, state, date, time, location,
|
||||
brandSlug = "tuxedo", brandAccent = "green",
|
||||
}: StopCardProps) {
|
||||
const ctaBg =
|
||||
@@ -25,7 +25,7 @@ export default function StopCard({
|
||||
|
||||
return (
|
||||
<div className="group overflow-hidden rounded-3xl bg-white ring-1 ring-stone-200/60 transition-all duration-300 hover:-translate-y-1 hover:shadow-xl hover:shadow-black/8 hover:ring-stone-300">
|
||||
<Link href={`/${brandSlug}/stops/${slug}`} className="block p-7">
|
||||
<Link href={`/${brandSlug}/stops/${id}`} className="block p-7">
|
||||
{/* Location */}
|
||||
<div className="flex items-start gap-4 mb-5">
|
||||
<div className={`flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl transition-colors ${brandAccent === "blue" ? "bg-blue-50 group-hover:bg-blue-100" : brandAccent === "green" ? "bg-emerald-50 group-hover:bg-emerald-100" : "bg-orange-50 group-hover:bg-orange-100"}`}>
|
||||
@@ -56,7 +56,7 @@ export default function StopCard({
|
||||
{/* CTA */}
|
||||
<div className="px-7 pb-7 pt-1">
|
||||
<Link
|
||||
href={`/${brandSlug}/stops/${slug}`}
|
||||
href={`/${brandSlug}/stops/${id}`}
|
||||
className={`block w-full rounded-2xl ${ctaBg} text-white px-5 py-3.5 text-center font-bold text-sm uppercase tracking-wider transition-colors`}
|
||||
>
|
||||
Shop This Stop
|
||||
|
||||
+3
-3
@@ -11,9 +11,9 @@
|
||||
* const { rows } = await query<MyRow>("SELECT * FROM my_table WHERE id = $1", [id]);
|
||||
*
|
||||
* Configuration:
|
||||
* - DATABASE_URL (required) — full Postgres connection string. Same env var
|
||||
* is used by `supabase/push-migrations.js` and any external migration
|
||||
* tooling. Format: `postgres://user:pass@host:port/dbname`.
|
||||
* - DATABASE_URL (required) — full Postgres connection string. Used by
|
||||
* `scripts/migrate.js` (and any external migration tooling). Format:
|
||||
* `postgres://user:pass@host:port/dbname`.
|
||||
*
|
||||
* Notes:
|
||||
* - This module is server-only. It must never be imported from a Client
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
# Supabase
|
||||
.branches
|
||||
.temp
|
||||
|
||||
# dotenvx
|
||||
.env.keys
|
||||
.env.local
|
||||
.env.*.local
|
||||
@@ -1,92 +0,0 @@
|
||||
-- ============================================================================
|
||||
-- Grok Fix Prompt: admin_create_stop (copy-paste ready for Supabase SQL Editor)
|
||||
-- ============================================================================
|
||||
-- This is the exact "Best Practice Version" from the fix prompt, with
|
||||
-- additional comments. Run this directly in the Supabase SQL Editor
|
||||
-- (https://supabase.com/dashboard/project/wnzkhezyhnfzhkhiflrp/sql) if the
|
||||
-- migration push cannot be used.
|
||||
--
|
||||
-- After running, PostgREST may need a schema reload:
|
||||
-- NOTIFY pgrst, 'reload schema';
|
||||
--
|
||||
-- Then test "Add New Stop" again.
|
||||
--
|
||||
-- The calling code (JS/TS) is in:
|
||||
-- src/actions/stops/create-stop.ts (the fetch to /rest/v1/rpc/admin_create_stop)
|
||||
-- src/components/admin/AddStopModal.tsx
|
||||
-- src/components/admin/NewStopForm.tsx
|
||||
-- (all go through the server action createStop which builds the p_* args)
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.admin_create_stop(
|
||||
p_active boolean,
|
||||
p_address text,
|
||||
p_brand_id uuid,
|
||||
p_city text,
|
||||
p_cutoff_time time,
|
||||
p_date date,
|
||||
p_location text,
|
||||
p_state text,
|
||||
p_time time,
|
||||
p_zip text
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
new_stop_id uuid;
|
||||
BEGIN
|
||||
INSERT INTO stops (
|
||||
active,
|
||||
address,
|
||||
brand_id,
|
||||
city,
|
||||
cutoff_time,
|
||||
date,
|
||||
location,
|
||||
state,
|
||||
time,
|
||||
zip
|
||||
)
|
||||
VALUES (
|
||||
p_active,
|
||||
p_address,
|
||||
p_brand_id,
|
||||
p_city,
|
||||
p_cutoff_time,
|
||||
p_date,
|
||||
p_location,
|
||||
p_state,
|
||||
p_time,
|
||||
p_zip
|
||||
)
|
||||
RETURNING id INTO new_stop_id;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'success', true,
|
||||
'stop_id', new_stop_id,
|
||||
'message', 'Stop created successfully'
|
||||
);
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RETURN jsonb_build_object(
|
||||
'success', false,
|
||||
'error', SQLERRM
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Important: grant so PostgREST (anon key from server action) can invoke it.
|
||||
GRANT EXECUTE ON FUNCTION public.admin_create_stop(
|
||||
boolean, text, uuid, text, time, date, text, text, time, text
|
||||
) TO anon, authenticated, service_role;
|
||||
|
||||
-- Reload cache so the function is immediately visible (avoids PGRST202).
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
|
||||
-- NOTE: The above simple version may fail on INSERT because the stops table
|
||||
-- requires "slug" and "status" (NOT NULL, no defaults in all cases).
|
||||
-- Prefer the enhanced version in migrations/202_fix_admin_create_stop.sql
|
||||
-- which includes slug generation and status='draft' while using a compatible
|
||||
-- signature and the success/stop_id return shape.
|
||||
@@ -1,408 +0,0 @@
|
||||
# For detailed configuration reference documentation, visit:
|
||||
# https://supabase.com/docs/guides/local-development/cli/config
|
||||
# A string used to distinguish different Supabase projects on the same host. Defaults to the
|
||||
# working directory name when running `supabase init`.
|
||||
project_id = "route_commerce-main"
|
||||
|
||||
[api]
|
||||
enabled = true
|
||||
# Port to use for the API URL.
|
||||
port = 54321
|
||||
# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API
|
||||
# endpoints. `public` and `graphql_public` schemas are included by default.
|
||||
schemas = ["public", "graphql_public"]
|
||||
# Extra schemas to add to the search_path of every request.
|
||||
extra_search_path = ["public", "extensions"]
|
||||
# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size
|
||||
# for accidental or malicious requests.
|
||||
max_rows = 1000
|
||||
|
||||
[api.tls]
|
||||
# Enable HTTPS endpoints locally using a self-signed certificate.
|
||||
enabled = false
|
||||
# Paths to self-signed certificate pair.
|
||||
# cert_path = "../certs/my-cert.pem"
|
||||
# key_path = "../certs/my-key.pem"
|
||||
|
||||
[db]
|
||||
# Port to use for the local database URL.
|
||||
port = 54322
|
||||
# Port used by db diff command to initialize the shadow database.
|
||||
shadow_port = 54320
|
||||
# Maximum amount of time to wait for health check when starting the local database.
|
||||
health_timeout = "2m"
|
||||
# The database major version to use. This has to be the same as your remote database's. Run `SHOW
|
||||
# server_version;` on the remote database to check.
|
||||
major_version = 17
|
||||
|
||||
[db.pooler]
|
||||
enabled = false
|
||||
# Port to use for the local connection pooler.
|
||||
port = 54329
|
||||
# Specifies when a server connection can be reused by other clients.
|
||||
# Configure one of the supported pooler modes: `transaction`, `session`.
|
||||
pool_mode = "transaction"
|
||||
# How many server connections to allow per user/database pair.
|
||||
default_pool_size = 20
|
||||
# Maximum number of client connections allowed.
|
||||
max_client_conn = 100
|
||||
|
||||
# [db.vault]
|
||||
# secret_key = "env(SECRET_VALUE)"
|
||||
|
||||
[db.migrations]
|
||||
# If disabled, migrations will be skipped during a db push or reset.
|
||||
enabled = true
|
||||
# Specifies an ordered list of schema files that describe your database.
|
||||
# Supports glob patterns relative to supabase directory: "./schemas/*.sql"
|
||||
schema_paths = []
|
||||
|
||||
[db.seed]
|
||||
# If enabled, seeds the database after migrations during a db reset.
|
||||
enabled = true
|
||||
# Specifies an ordered list of seed files to load during db reset.
|
||||
# Supports glob patterns relative to supabase directory: "./seeds/*.sql"
|
||||
sql_paths = ["./seed.sql"]
|
||||
|
||||
[db.network_restrictions]
|
||||
# Enable management of network restrictions.
|
||||
enabled = false
|
||||
# List of IPv4 CIDR blocks allowed to connect to the database.
|
||||
# Defaults to allow all IPv4 connections. Set empty array to block all IPs.
|
||||
allowed_cidrs = ["0.0.0.0/0"]
|
||||
# List of IPv6 CIDR blocks allowed to connect to the database.
|
||||
# Defaults to allow all IPv6 connections. Set empty array to block all IPs.
|
||||
allowed_cidrs_v6 = ["::/0"]
|
||||
|
||||
# Uncomment to reject non-secure connections to the database.
|
||||
# [db.ssl_enforcement]
|
||||
# enabled = true
|
||||
|
||||
[realtime]
|
||||
enabled = true
|
||||
# Bind realtime via either IPv4 or IPv6. (default: IPv4)
|
||||
# ip_version = "IPv6"
|
||||
# The maximum length in bytes of HTTP request headers. (default: 4096)
|
||||
# max_header_length = 4096
|
||||
|
||||
[studio]
|
||||
enabled = true
|
||||
# Port to use for Supabase Studio.
|
||||
port = 54323
|
||||
# External URL of the API server that frontend connects to.
|
||||
api_url = "http://127.0.0.1"
|
||||
# OpenAI API Key to use for Supabase AI in the Supabase Studio.
|
||||
openai_api_key = "env(OPENAI_API_KEY)"
|
||||
|
||||
# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they
|
||||
# are monitored, and you can view the emails that would have been sent from the web interface.
|
||||
[inbucket]
|
||||
enabled = true
|
||||
# Port to use for the email testing server web interface.
|
||||
port = 54324
|
||||
# Uncomment to expose additional ports for testing user applications that send emails.
|
||||
# smtp_port = 54325
|
||||
# pop3_port = 54326
|
||||
# admin_email = "admin@email.com"
|
||||
# sender_name = "Admin"
|
||||
|
||||
[storage]
|
||||
enabled = true
|
||||
# The maximum file size allowed (e.g. "5MB", "500KB").
|
||||
file_size_limit = "50MiB"
|
||||
|
||||
# Uncomment to configure local storage buckets
|
||||
# [storage.buckets.images]
|
||||
# public = false
|
||||
# file_size_limit = "50MiB"
|
||||
# allowed_mime_types = ["image/png", "image/jpeg"]
|
||||
# objects_path = "./images"
|
||||
|
||||
# Allow connections via S3 compatible clients
|
||||
[storage.s3_protocol]
|
||||
enabled = true
|
||||
|
||||
# Image transformation API is available to Supabase Pro plan.
|
||||
# [storage.image_transformation]
|
||||
# enabled = true
|
||||
|
||||
# Store analytical data in S3 for running ETL jobs over Iceberg Catalog
|
||||
# This feature is only available on the hosted platform.
|
||||
[storage.analytics]
|
||||
enabled = false
|
||||
max_namespaces = 5
|
||||
max_tables = 10
|
||||
max_catalogs = 2
|
||||
|
||||
# Analytics Buckets is available to Supabase Pro plan.
|
||||
# [storage.analytics.buckets.my-warehouse]
|
||||
|
||||
# Store vector embeddings in S3 for large and durable datasets
|
||||
# This feature is only available on the hosted platform.
|
||||
[storage.vector]
|
||||
enabled = false
|
||||
max_buckets = 10
|
||||
max_indexes = 5
|
||||
|
||||
# Vector Buckets is available to Supabase Pro plan.
|
||||
# [storage.vector.buckets.documents-openai]
|
||||
|
||||
[auth]
|
||||
enabled = true
|
||||
# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
|
||||
# in emails.
|
||||
site_url = "http://127.0.0.1:3000"
|
||||
# The public URL that Auth serves on. Defaults to the API external URL with `/auth/v1` appended.
|
||||
# external_url = ""
|
||||
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
|
||||
additional_redirect_urls = ["https://127.0.0.1:3000"]
|
||||
# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
|
||||
jwt_expiry = 3600
|
||||
# JWT issuer URL. If not set, defaults to auth.external_url.
|
||||
# jwt_issuer = ""
|
||||
# Path to JWT signing key. DO NOT commit your signing keys file to git.
|
||||
# signing_keys_path = "./signing_keys.json"
|
||||
# If disabled, the refresh token will never expire.
|
||||
enable_refresh_token_rotation = true
|
||||
# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds.
|
||||
# Requires enable_refresh_token_rotation = true.
|
||||
refresh_token_reuse_interval = 10
|
||||
# Allow/disallow new user signups to your project.
|
||||
enable_signup = true
|
||||
# Allow/disallow anonymous sign-ins to your project.
|
||||
enable_anonymous_sign_ins = false
|
||||
# Allow/disallow testing manual linking of accounts
|
||||
enable_manual_linking = false
|
||||
# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more.
|
||||
minimum_password_length = 6
|
||||
# Passwords that do not meet the following requirements will be rejected as weak. Supported values
|
||||
# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols`
|
||||
password_requirements = ""
|
||||
|
||||
# Configure passkey sign-ins.
|
||||
# [auth.passkey]
|
||||
# enabled = false
|
||||
|
||||
# Configure WebAuthn relying party settings (required when passkey is enabled).
|
||||
# [auth.webauthn]
|
||||
# rp_display_name = "Supabase"
|
||||
# rp_id = "localhost"
|
||||
# rp_origins = ["http://127.0.0.1:3000"]
|
||||
|
||||
[auth.rate_limit]
|
||||
# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled.
|
||||
email_sent = 2
|
||||
# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled.
|
||||
sms_sent = 30
|
||||
# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true.
|
||||
anonymous_users = 30
|
||||
# Number of sessions that can be refreshed in a 5 minute interval per IP address.
|
||||
token_refresh = 150
|
||||
# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users).
|
||||
sign_in_sign_ups = 30
|
||||
# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address.
|
||||
token_verifications = 30
|
||||
# Number of Web3 logins that can be made in a 5 minute interval per IP address.
|
||||
web3 = 30
|
||||
|
||||
# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`.
|
||||
# [auth.captcha]
|
||||
# enabled = true
|
||||
# provider = "hcaptcha"
|
||||
# secret = ""
|
||||
|
||||
[auth.email]
|
||||
# Allow/disallow new user signups via email to your project.
|
||||
enable_signup = true
|
||||
# If enabled, a user will be required to confirm any email change on both the old, and new email
|
||||
# addresses. If disabled, only the new email is required to confirm.
|
||||
double_confirm_changes = true
|
||||
# If enabled, users need to confirm their email address before signing in.
|
||||
enable_confirmations = false
|
||||
# If enabled, users will need to reauthenticate or have logged in recently to change their password.
|
||||
secure_password_change = false
|
||||
# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email.
|
||||
max_frequency = "1s"
|
||||
# Number of characters used in the email OTP.
|
||||
otp_length = 6
|
||||
# Number of seconds before the email OTP expires (defaults to 1 hour).
|
||||
otp_expiry = 3600
|
||||
|
||||
# Use a production-ready SMTP server
|
||||
# [auth.email.smtp]
|
||||
# enabled = true
|
||||
# host = "smtp.sendgrid.net"
|
||||
# port = 587
|
||||
# user = "apikey"
|
||||
# pass = "env(SENDGRID_API_KEY)"
|
||||
# admin_email = "admin@email.com"
|
||||
# sender_name = "Admin"
|
||||
|
||||
# Uncomment to customize email template
|
||||
# [auth.email.template.invite]
|
||||
# subject = "You have been invited"
|
||||
# content_path = "./supabase/templates/invite.html"
|
||||
|
||||
# Uncomment to customize notification email template
|
||||
# [auth.email.notification.password_changed]
|
||||
# enabled = true
|
||||
# subject = "Your password has been changed"
|
||||
# content_path = "./templates/password_changed_notification.html"
|
||||
|
||||
[auth.sms]
|
||||
# Allow/disallow new user signups via SMS to your project.
|
||||
enable_signup = false
|
||||
# If enabled, users need to confirm their phone number before signing in.
|
||||
enable_confirmations = false
|
||||
# Template for sending OTP to users
|
||||
template = "Your code is {{ .Code }}"
|
||||
# Controls the minimum amount of time that must pass before sending another sms otp.
|
||||
max_frequency = "5s"
|
||||
|
||||
# Use pre-defined map of phone number to OTP for testing.
|
||||
# [auth.sms.test_otp]
|
||||
# 4152127777 = "123456"
|
||||
|
||||
# Configure logged in session timeouts.
|
||||
# [auth.sessions]
|
||||
# Force log out after the specified duration.
|
||||
# timebox = "24h"
|
||||
# Force log out if the user has been inactive longer than the specified duration.
|
||||
# inactivity_timeout = "8h"
|
||||
|
||||
# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object.
|
||||
# [auth.hook.before_user_created]
|
||||
# enabled = true
|
||||
# uri = "pg-functions://postgres/auth/before-user-created-hook"
|
||||
|
||||
# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used.
|
||||
# [auth.hook.custom_access_token]
|
||||
# enabled = true
|
||||
# uri = "pg-functions://<database>/<schema>/<hook_name>"
|
||||
|
||||
# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`.
|
||||
[auth.sms.twilio]
|
||||
enabled = false
|
||||
account_sid = ""
|
||||
message_service_sid = ""
|
||||
# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead:
|
||||
auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)"
|
||||
|
||||
# Multi-factor-authentication is available to Supabase Pro plan.
|
||||
[auth.mfa]
|
||||
# Control how many MFA factors can be enrolled at once per user.
|
||||
max_enrolled_factors = 10
|
||||
|
||||
# Control MFA via App Authenticator (TOTP)
|
||||
[auth.mfa.totp]
|
||||
enroll_enabled = false
|
||||
verify_enabled = false
|
||||
|
||||
# Configure MFA via Phone Messaging
|
||||
[auth.mfa.phone]
|
||||
enroll_enabled = false
|
||||
verify_enabled = false
|
||||
otp_length = 6
|
||||
template = "Your code is {{ .Code }}"
|
||||
max_frequency = "5s"
|
||||
|
||||
# Configure MFA via WebAuthn
|
||||
# [auth.mfa.web_authn]
|
||||
# enroll_enabled = true
|
||||
# verify_enabled = true
|
||||
|
||||
# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`,
|
||||
# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`,
|
||||
# `twitter`, `x`, `slack`, `spotify`, `workos`, `zoom`.
|
||||
[auth.external.apple]
|
||||
enabled = false
|
||||
client_id = ""
|
||||
# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead:
|
||||
secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)"
|
||||
# Overrides the default auth callback URL derived from auth.external_url.
|
||||
redirect_uri = ""
|
||||
# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure,
|
||||
# or any other third-party OIDC providers.
|
||||
url = ""
|
||||
# If enabled, the nonce check will be skipped. Required for local sign in with Google auth.
|
||||
skip_nonce_check = false
|
||||
# If enabled, it will allow the user to successfully authenticate when the provider does not return an email address.
|
||||
email_optional = false
|
||||
|
||||
# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard.
|
||||
# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting.
|
||||
[auth.web3.solana]
|
||||
enabled = false
|
||||
|
||||
# Use Firebase Auth as a third-party provider alongside Supabase Auth.
|
||||
[auth.third_party.firebase]
|
||||
enabled = false
|
||||
# project_id = "my-firebase-project"
|
||||
|
||||
# Use Auth0 as a third-party provider alongside Supabase Auth.
|
||||
[auth.third_party.auth0]
|
||||
enabled = false
|
||||
# tenant = "my-auth0-tenant"
|
||||
# tenant_region = "us"
|
||||
|
||||
# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth.
|
||||
[auth.third_party.aws_cognito]
|
||||
enabled = false
|
||||
# user_pool_id = "my-user-pool-id"
|
||||
# user_pool_region = "us-east-1"
|
||||
|
||||
# Use Clerk as a third-party provider alongside Supabase Auth.
|
||||
[auth.third_party.clerk]
|
||||
enabled = false
|
||||
# Obtain from https://clerk.com/setup/supabase
|
||||
# domain = "example.clerk.accounts.dev"
|
||||
|
||||
# OAuth server configuration
|
||||
[auth.oauth_server]
|
||||
# Enable OAuth server functionality
|
||||
enabled = false
|
||||
# Path for OAuth consent flow UI
|
||||
authorization_url_path = "/oauth/consent"
|
||||
# Allow dynamic client registration
|
||||
allow_dynamic_registration = false
|
||||
|
||||
[edge_runtime]
|
||||
enabled = true
|
||||
# Supported request policies: `oneshot`, `per_worker`.
|
||||
# `per_worker` (default) — enables hot reload during local development.
|
||||
# `oneshot` — fallback mode if hot reload causes issues (e.g. in large repos or with symlinks).
|
||||
policy = "per_worker"
|
||||
# Port to attach the Chrome inspector for debugging edge functions.
|
||||
inspector_port = 8083
|
||||
# The Deno major version to use.
|
||||
deno_version = 2
|
||||
|
||||
# [edge_runtime.secrets]
|
||||
# secret_key = "env(SECRET_VALUE)"
|
||||
|
||||
[analytics]
|
||||
enabled = true
|
||||
port = 54327
|
||||
# Configure one of the supported backends: `postgres`, `bigquery`.
|
||||
backend = "postgres"
|
||||
|
||||
# Experimental features may be deprecated any time
|
||||
[experimental]
|
||||
# Configures Postgres storage engine to use OrioleDB (S3)
|
||||
orioledb_version = ""
|
||||
# Configures S3 bucket URL, eg. <bucket_name>.s3-<region>.amazonaws.com
|
||||
s3_host = "env(S3_HOST)"
|
||||
# Configures S3 bucket region, eg. us-east-1
|
||||
s3_region = "env(S3_REGION)"
|
||||
# Configures AWS_ACCESS_KEY_ID for S3 bucket
|
||||
s3_access_key = "env(S3_ACCESS_KEY)"
|
||||
# Configures AWS_SECRET_ACCESS_KEY for S3 bucket
|
||||
s3_secret_key = "env(S3_SECRET_KEY)"
|
||||
|
||||
# [experimental.pgdelta]
|
||||
# When enabled, pg-delta becomes the active engine for supported schema flows.
|
||||
# enabled = false
|
||||
# Directory under `supabase/` where declarative files are written.
|
||||
# declarative_schema_path = "./database"
|
||||
# JSON string passed through to pg-delta SQL formatting.
|
||||
# format_options = "{\"keywordCase\":\"upper\",\"indent\":2,\"maxWidth\":80,\"commaStyle\":\"trailing\"}"
|
||||
@@ -1,201 +0,0 @@
|
||||
-- ============================================================
|
||||
-- Checkout Hardening Migration
|
||||
-- ============================================================
|
||||
-- 1. Add idempotency_key column to orders (UUID, unique)
|
||||
-- 2. Index on idempotency_key for fast idempotency checks
|
||||
-- 3. RPC function for atomic order + order_items creation
|
||||
-- 4. Returns JSONB — no post-checkout SELECT needed
|
||||
|
||||
-- 1. Idempotency key column
|
||||
ALTER TABLE orders ADD COLUMN IF NOT EXISTS idempotency_key UUID UNIQUE;
|
||||
|
||||
-- 1b. Add fulfillment column to order_items for mixed pickup/ship orders
|
||||
ALTER TABLE order_items ADD COLUMN IF NOT EXISTS fulfillment TEXT DEFAULT 'pickup';
|
||||
|
||||
-- 2. Index for idempotency lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_orders_idempotency_key ON orders(idempotency_key);
|
||||
|
||||
-- 3. Atomic checkout RPC function
|
||||
-- Validates stop, products, quantities, pickup eligibility
|
||||
-- Computes subtotal server-side from current DB prices
|
||||
-- Returns JSONB with order + items — caller never needs a separate SELECT
|
||||
CREATE OR REPLACE FUNCTION create_order_with_items(
|
||||
p_idempotency_key UUID,
|
||||
p_customer_name TEXT,
|
||||
p_customer_email TEXT,
|
||||
p_customer_phone TEXT,
|
||||
p_stop_id UUID,
|
||||
p_items JSONB -- [{id: uuid, quantity: int, fulfillment: text}]
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
AS $$
|
||||
DECLARE
|
||||
v_order_id UUID;
|
||||
v_item JSONB;
|
||||
v_product_id UUID;
|
||||
v_quantity INT;
|
||||
v_fulfillment TEXT;
|
||||
v_product_price NUMERIC;
|
||||
v_computed_total NUMERIC := 0;
|
||||
v_stop_active BOOLEAN;
|
||||
v_stop_brand_id UUID;
|
||||
v_stop_city TEXT;
|
||||
v_stop_state TEXT;
|
||||
v_stop_date TEXT;
|
||||
v_stop_time TEXT;
|
||||
v_stop_location TEXT;
|
||||
v_product_active BOOLEAN;
|
||||
v_product_brand_id UUID;
|
||||
v_product_name TEXT;
|
||||
v_is_pickup BOOLEAN;
|
||||
v_order JSONB;
|
||||
v_order_items JSONB := '[]'::JSONB;
|
||||
BEGIN
|
||||
-- ── Idempotency guard ──────────────────────────────────────
|
||||
-- If this key was already used, return the existing order + items.
|
||||
-- Safe for retries: the order is real, the user just didn't see the success page.
|
||||
SELECT jsonb_build_object(
|
||||
'id', o.id,
|
||||
'customer_name', o.customer_name,
|
||||
'customer_email', o.customer_name,
|
||||
'customer_phone', o.customer_phone,
|
||||
'subtotal', o.subtotal,
|
||||
'status', o.status,
|
||||
'stop_id', o.stop_id,
|
||||
'stop_city', s.city,
|
||||
'stop_state', s.state,
|
||||
'stop_date', s.date,
|
||||
'stop_time', s.time,
|
||||
'stop_location', s.location,
|
||||
'items', COALESCE((
|
||||
SELECT jsonb_agg(jsonb_build_object(
|
||||
'product_id', oi.product_id,
|
||||
'product_name', p.name,
|
||||
'quantity', oi.quantity,
|
||||
'price', oi.price,
|
||||
'fulfillment', oi.fulfillment
|
||||
))
|
||||
FROM order_items oi
|
||||
JOIN products p ON oi.product_id = p.id
|
||||
WHERE oi.order_id = o.id
|
||||
), '[]'::JSONB)
|
||||
)
|
||||
INTO v_order
|
||||
FROM orders o
|
||||
JOIN stops s ON o.stop_id = s.id
|
||||
WHERE o.idempotency_key = p_idempotency_key
|
||||
LIMIT 1;
|
||||
|
||||
IF v_order IS NOT NULL THEN
|
||||
RETURN v_order;
|
||||
END IF;
|
||||
|
||||
-- ── Validate stop ──────────────────────────────────────────
|
||||
SELECT active, brand_id, city, state, date, time, location
|
||||
INTO v_stop_active, v_stop_brand_id, v_stop_city, v_stop_state, v_stop_date, v_stop_time, v_stop_location
|
||||
FROM stops
|
||||
WHERE id = p_stop_id;
|
||||
|
||||
IF v_stop_brand_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Stop not found: %', p_stop_id;
|
||||
END IF;
|
||||
|
||||
IF NOT v_stop_active THEN
|
||||
RAISE EXCEPTION 'Stop is not active: %', p_stop_id;
|
||||
END IF;
|
||||
|
||||
-- ── Validate items and compute subtotal ────────────────────
|
||||
FOR v_item IN SELECT * FROM jsonb_array_elements(p_items)
|
||||
LOOP
|
||||
v_product_id := (v_item->>'id')::UUID;
|
||||
v_quantity := (v_item->>'quantity')::INT;
|
||||
v_fulfillment := v_item->>'fulfillment';
|
||||
v_is_pickup := (v_fulfillment = 'pickup');
|
||||
|
||||
SELECT active, brand_id, price, name
|
||||
INTO v_product_active, v_product_brand_id, v_product_price, v_product_name
|
||||
FROM products
|
||||
WHERE id = v_product_id;
|
||||
|
||||
IF v_product_brand_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Product not found: %', v_product_id;
|
||||
END IF;
|
||||
|
||||
IF v_product_brand_id != v_stop_brand_id THEN
|
||||
RAISE EXCEPTION 'Product % does not belong to the stop brand', v_product_id;
|
||||
END IF;
|
||||
|
||||
IF NOT v_product_active THEN
|
||||
RAISE EXCEPTION 'Product is not active: %', v_product_id;
|
||||
END IF;
|
||||
|
||||
IF v_quantity < 1 THEN
|
||||
RAISE EXCEPTION 'Quantity must be at least 1 for product: %', v_product_id;
|
||||
END IF;
|
||||
|
||||
IF v_is_pickup THEN
|
||||
PERFORM 1
|
||||
FROM product_stops
|
||||
WHERE product_id = v_product_id AND stop_id = p_stop_id
|
||||
LIMIT 1;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'Pickup product % is not available at stop %', v_product_id, p_stop_id;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
v_computed_total := v_computed_total + (v_product_price * v_quantity);
|
||||
END LOOP;
|
||||
|
||||
-- ── Create order ────────────────────────────────────────────
|
||||
INSERT INTO orders (
|
||||
idempotency_key, customer_name, customer_email, customer_phone,
|
||||
stop_id, subtotal, status
|
||||
) VALUES (
|
||||
p_idempotency_key, p_customer_name, p_customer_email, p_customer_phone,
|
||||
p_stop_id, v_computed_total, 'pending'
|
||||
)
|
||||
RETURNING id INTO v_order_id;
|
||||
|
||||
-- ── Insert order items and build return value ───────────────
|
||||
FOR v_item IN SELECT * FROM jsonb_array_elements(p_items)
|
||||
LOOP
|
||||
v_product_id := (v_item->>'id')::UUID;
|
||||
v_quantity := (v_item->>'quantity')::INT;
|
||||
v_fulfillment := v_item->>'fulfillment';
|
||||
|
||||
SELECT price, name INTO v_product_price, v_product_name FROM products WHERE id = v_product_id;
|
||||
|
||||
INSERT INTO order_items (order_id, product_id, quantity, fulfillment, price)
|
||||
VALUES (v_order_id, v_product_id, v_quantity, v_fulfillment, v_product_price);
|
||||
|
||||
-- Append to items array
|
||||
v_order_items := v_order_items || jsonb_build_object(
|
||||
'product_id', v_product_id,
|
||||
'product_name', v_product_name,
|
||||
'quantity', v_quantity,
|
||||
'price', v_product_price,
|
||||
'fulfillment', v_fulfillment
|
||||
);
|
||||
END LOOP;
|
||||
|
||||
-- ── Build and return full order object ──────────────────────
|
||||
RETURN jsonb_build_object(
|
||||
'id', v_order_id,
|
||||
'customer_name', p_customer_name,
|
||||
'customer_email', p_customer_email,
|
||||
'customer_phone', p_customer_phone,
|
||||
'subtotal', v_computed_total,
|
||||
'status', 'pending',
|
||||
'stop_id', p_stop_id,
|
||||
'stop_city', v_stop_city,
|
||||
'stop_state', v_stop_state,
|
||||
'stop_date', v_stop_date,
|
||||
'stop_time', v_stop_time,
|
||||
'stop_location', v_stop_location,
|
||||
'items', v_order_items
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
@@ -1,10 +0,0 @@
|
||||
-- ============================================================
|
||||
-- Remove broad anon SELECT policies
|
||||
-- ============================================================
|
||||
-- The checkout flow no longer requires post-checkout SELECT
|
||||
-- because the RPC returns full order data directly.
|
||||
-- Admin SELECT is handled by server-side auth (getAdminUser).
|
||||
|
||||
-- Drop the overly permissive read policies
|
||||
DROP POLICY IF EXISTS "Allow public read on orders for checkout" ON orders;
|
||||
DROP POLICY IF EXISTS "Allow public read on order_items" ON order_items;
|
||||
@@ -1,103 +0,0 @@
|
||||
-- ============================================================
|
||||
-- Audit Logging
|
||||
-- ============================================================
|
||||
-- Tracks critical admin mutations for compliance and debugging.
|
||||
-- Lightweight: single insert per action, no triggers.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
table_name TEXT NOT NULL,
|
||||
record_id UUID NOT NULL,
|
||||
action TEXT NOT NULL, -- INSERT | UPDATE | DELETE
|
||||
old_data JSONB,
|
||||
new_data JSONB,
|
||||
performed_by UUID,
|
||||
performed_by_email TEXT,
|
||||
brand_id UUID, -- for brand-scoped log reads
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Index for fast lookups by table + record
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_table_record
|
||||
ON audit_logs(table_name, record_id);
|
||||
|
||||
-- Index for brand-scoped reads
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_brand
|
||||
ON audit_logs(brand_id) WHERE brand_id IS NOT NULL;
|
||||
|
||||
-- Index for performed_by queries
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_performed_by
|
||||
ON audit_logs(performed_by);
|
||||
|
||||
-- RLS: no direct public access
|
||||
ALTER TABLE audit_logs ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Read policies (platform_admin and brand_admin read their own logs)
|
||||
-- Read policy: platform_admin sees all
|
||||
CREATE POLICY "Platform admin reads all audit logs"
|
||||
ON audit_logs FOR SELECT
|
||||
TO authenticated
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM admin_users
|
||||
WHERE admin_users.user_id = auth.uid()
|
||||
AND admin_users.role = 'platform_admin'
|
||||
)
|
||||
);
|
||||
|
||||
-- Read policy: brand_admin sees only their brand's logs
|
||||
CREATE POLICY "Brand admin reads own brand audit logs"
|
||||
ON audit_logs FOR SELECT
|
||||
TO authenticated
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM admin_users
|
||||
WHERE admin_users.user_id = auth.uid()
|
||||
AND admin_users.role = 'brand_admin'
|
||||
AND admin_users.brand_id = audit_logs.brand_id
|
||||
)
|
||||
);
|
||||
|
||||
-- Write is handled exclusively via SECURITY DEFINER RPC (bypasses RLS)
|
||||
-- No INSERT policy needed — the function bypasses RLS entirely.
|
||||
|
||||
-- ============================================================
|
||||
-- log_audit_event — called by server actions to write audit records
|
||||
-- Runs as postgres (SUPERUSER) so it bypasses RLS on audit_logs.
|
||||
-- Accepts a JSONB payload with all audit fields.
|
||||
-- ============================================================
|
||||
CREATE OR REPLACE FUNCTION log_audit_event(p_payload JSONB)
|
||||
RETURNS UUID
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_audit_id UUID;
|
||||
BEGIN
|
||||
INSERT INTO audit_logs (
|
||||
table_name, record_id, action,
|
||||
old_data, new_data,
|
||||
performed_by, performed_by_email,
|
||||
brand_id
|
||||
) VALUES (
|
||||
p_payload->>'table_name',
|
||||
(p_payload->>'record_id')::UUID,
|
||||
p_payload->>'action',
|
||||
p_payload->'old_data',
|
||||
p_payload->'new_data',
|
||||
CASE
|
||||
WHEN (p_payload->>'performed_by') IS NULL THEN NULL
|
||||
ELSE (p_payload->>'performed_by')::UUID
|
||||
END,
|
||||
p_payload->>'performed_by_email',
|
||||
CASE
|
||||
WHEN (p_payload->>'brand_id') IS NULL THEN NULL
|
||||
ELSE (p_payload->>'brand_id')::UUID
|
||||
END
|
||||
)
|
||||
RETURNING id INTO v_audit_id;
|
||||
|
||||
RETURN v_audit_id;
|
||||
END;
|
||||
$$;
|
||||
@@ -1,193 +0,0 @@
|
||||
-- ============================================================
|
||||
-- Admin Read Policies for Orders
|
||||
-- ============================================================
|
||||
-- The checkout hardening removed broad anon SELECT policies.
|
||||
-- Admin pages need to read orders with the anon key (server-side via getAdminUser).
|
||||
-- We use SECURITY DEFINER functions that bypass RLS so admin pages
|
||||
-- can still fetch orders for their brand scope without auth.uid().
|
||||
|
||||
-- No direct public SELECT (already enforced by removal of anon policies)
|
||||
|
||||
-- Policy: platform_admin can SELECT all orders
|
||||
CREATE POLICY "Platform admin can read orders"
|
||||
ON orders FOR SELECT
|
||||
TO authenticated
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM admin_users
|
||||
WHERE admin_users.user_id = auth.uid()
|
||||
AND admin_users.role = 'platform_admin'
|
||||
)
|
||||
);
|
||||
|
||||
-- Policy: brand_admin can read orders for their brand
|
||||
CREATE POLICY "Brand admin can read their brand orders"
|
||||
ON orders FOR SELECT
|
||||
TO authenticated
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM admin_users
|
||||
WHERE admin_users.user_id = auth.uid()
|
||||
AND admin_users.role = 'brand_admin'
|
||||
AND admin_users.brand_id = (
|
||||
SELECT brand_id FROM stops WHERE stops.id = orders.stop_id
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
-- Policy: stops readable by admin users
|
||||
CREATE POLICY "Admins can read stops"
|
||||
ON stops FOR SELECT
|
||||
TO authenticated
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM admin_users
|
||||
WHERE admin_users.user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- Admin Orders Fetch RPC
|
||||
-- ============================================================
|
||||
-- SECURITY DEFINER runs as postgres — bypasses RLS on orders/stops.
|
||||
|
||||
CREATE OR REPLACE FUNCTION get_admin_orders(p_brand_id UUID)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_orders JSONB;
|
||||
v_stops JSONB;
|
||||
BEGIN
|
||||
IF p_brand_id IS NULL THEN
|
||||
SELECT COALESCE(jsonb_agg(t ORDER BY t.created_at DESC), '[]'::JSONB)
|
||||
INTO v_orders
|
||||
FROM (
|
||||
SELECT o.id, o.customer_name, o.customer_email, o.customer_phone,
|
||||
o.stop_id, o.status, o.subtotal, o.pickup_complete,
|
||||
o.pickup_completed_at, o.pickup_completed_by, o.created_at,
|
||||
CASE WHEN o.stop_id IS NOT NULL THEN jsonb_build_object(
|
||||
'id', s.id, 'city', s.city, 'state', s.state,
|
||||
'date', s.date, 'time', s.time, 'location', s.location, 'brand_id', s.brand_id
|
||||
) END as stops
|
||||
FROM orders o LEFT JOIN stops s ON o.stop_id = s.id
|
||||
WHERE o.stop_id IS NOT NULL
|
||||
LIMIT 500
|
||||
) t;
|
||||
|
||||
SELECT COALESCE(jsonb_agg(jsonb_build_object(
|
||||
'id', id, 'city', city, 'state', state,
|
||||
'date', date, 'time', time, 'location', location, 'brand_id', brand_id
|
||||
) ORDER BY date), '[]'::JSONB)
|
||||
INTO v_stops FROM stops WHERE active = true;
|
||||
ELSE
|
||||
SELECT COALESCE(jsonb_agg(t ORDER BY t.created_at DESC), '[]'::JSONB)
|
||||
INTO v_orders
|
||||
FROM (
|
||||
SELECT o.id, o.customer_name, o.customer_email, o.customer_phone,
|
||||
o.stop_id, o.status, o.subtotal, o.pickup_complete,
|
||||
o.pickup_completed_at, o.pickup_completed_by, o.created_at,
|
||||
jsonb_build_object(
|
||||
'id', s.id, 'city', s.city, 'state', s.state,
|
||||
'date', s.date, 'time', s.time, 'location', s.location, 'brand_id', s.brand_id
|
||||
) as stops
|
||||
FROM orders o JOIN stops s ON o.stop_id = s.id
|
||||
WHERE s.brand_id = p_brand_id
|
||||
LIMIT 500
|
||||
) t;
|
||||
|
||||
SELECT COALESCE(jsonb_agg(jsonb_build_object(
|
||||
'id', id, 'city', city, 'state', state,
|
||||
'date', date, 'time', time, 'location', location, 'brand_id', brand_id
|
||||
) ORDER BY date), '[]'::JSONB)
|
||||
INTO v_stops FROM stops WHERE active = true AND brand_id = p_brand_id;
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object('orders', v_orders, 'stops', v_stops);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ============================================================
|
||||
-- Admin Order Detail RPC
|
||||
-- ============================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION get_admin_order_detail(p_order_id UUID)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_order JSONB;
|
||||
BEGIN
|
||||
SELECT jsonb_build_object(
|
||||
'id', o.id,
|
||||
'customer_name', o.customer_name,
|
||||
'customer_email', o.customer_email,
|
||||
'customer_phone', o.customer_phone,
|
||||
'stop_id', o.stop_id,
|
||||
'status', o.status,
|
||||
'subtotal', o.subtotal,
|
||||
'discount_amount', o.discount_amount,
|
||||
'tax_amount', o.tax_amount,
|
||||
'tax_rate', o.tax_rate,
|
||||
'tax_location', o.tax_location,
|
||||
'discount_reason', o.discount_reason,
|
||||
'pickup_complete', o.pickup_complete,
|
||||
'pickup_completed_at', o.pickup_completed_at,
|
||||
'pickup_completed_by', o.pickup_completed_by,
|
||||
'internal_notes', o.internal_notes,
|
||||
'payment_processor', o.payment_processor,
|
||||
'payment_status', o.payment_status,
|
||||
'payment_transaction_id', o.payment_transaction_id,
|
||||
'refunded_amount', o.refunded_amount,
|
||||
'refund_reason', o.refund_reason,
|
||||
'created_at', o.created_at,
|
||||
'stops', CASE WHEN o.stop_id IS NOT NULL THEN jsonb_build_object(
|
||||
'id', s.id,
|
||||
'city', s.city,
|
||||
'state', s.state,
|
||||
'date', s.date,
|
||||
'time', s.time,
|
||||
'location', s.location,
|
||||
'brand_id', s.brand_id
|
||||
) END,
|
||||
'order_items', COALESCE((
|
||||
SELECT jsonb_agg(jsonb_build_object(
|
||||
'id', oi.id,
|
||||
'product_id', oi.product_id,
|
||||
'product_name', p.name,
|
||||
'quantity', oi.quantity,
|
||||
'price', oi.price,
|
||||
'fulfillment', oi.fulfillment,
|
||||
'products', jsonb_build_object('name', p.name)
|
||||
))
|
||||
FROM order_items oi
|
||||
JOIN products p ON oi.product_id = p.id
|
||||
WHERE oi.order_id = o.id
|
||||
), '[]'::JSONB),
|
||||
'refunds', COALESCE((
|
||||
SELECT jsonb_agg(jsonb_build_object(
|
||||
'id', r.id,
|
||||
'order_id', r.order_id,
|
||||
'amount', r.amount,
|
||||
'reason', r.reason,
|
||||
'processor', r.processor,
|
||||
'processor_refund_id', r.processor_refund_id,
|
||||
'status', r.status,
|
||||
'created_at', r.created_at
|
||||
))
|
||||
FROM refunds r
|
||||
WHERE r.order_id = o.id
|
||||
), '[]'::JSONB)
|
||||
)
|
||||
INTO v_order
|
||||
FROM orders o
|
||||
LEFT JOIN stops s ON o.stop_id = s.id
|
||||
WHERE o.id = p_order_id;
|
||||
|
||||
RETURN v_order;
|
||||
END;
|
||||
$$;
|
||||
@@ -1,342 +0,0 @@
|
||||
-- ============================================================
|
||||
-- Water Log V1
|
||||
-- ============================================================
|
||||
-- Tables: water_headgates, water_irrigators, water_log_entries
|
||||
-- Auth: PIN-based field login via HTTP-only cookie session
|
||||
-- Admin: brand-scoped, SECURITY DEFINER RPCs, audit logged
|
||||
|
||||
-- ── Tables ─────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE water_headgates (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
brand_id UUID REFERENCES brands(id) NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE water_irrigators (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
brand_id UUID REFERENCES brands(id) NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
pin_hash TEXT NOT NULL,
|
||||
language_preference TEXT DEFAULT 'en',
|
||||
active BOOLEAN DEFAULT true,
|
||||
last_used_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
-- Water sessions for field login (HTTP-only cookie, server-side validation)
|
||||
CREATE TABLE water_sessions (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
irrigator_id UUID REFERENCES water_irrigators(id) NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE water_log_entries (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
brand_id UUID REFERENCES brands(id) NOT NULL,
|
||||
headgate_id UUID REFERENCES water_headgates(id) NOT NULL,
|
||||
irrigator_id UUID REFERENCES water_irrigators(id) NOT NULL,
|
||||
measurement NUMERIC NOT NULL,
|
||||
unit TEXT NOT NULL,
|
||||
notes TEXT,
|
||||
submitted_via TEXT NOT NULL, -- 'field' or 'admin'
|
||||
logged_at TIMESTAMPTZ DEFAULT now(),
|
||||
logged_by UUID REFERENCES admin_users(id)
|
||||
);
|
||||
|
||||
-- ── RLS ─────────────────────────────────────────────────────
|
||||
|
||||
ALTER TABLE water_headgates ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE water_irrigators ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE water_log_entries ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Headgates: readable by anyone (for field form), writable by admin RPCs
|
||||
CREATE POLICY "Headgates are readable by all" ON water_headgates FOR SELECT TO anon USING (true);
|
||||
CREATE POLICY "Headgates writable by postgres" ON water_headgates FOR ALL TO postgres USING (true);
|
||||
|
||||
-- Irrigators: only SECURITY DEFINER RPCs read/write (pin_hash never exposed)
|
||||
CREATE POLICY "Irrigators writable by postgres" ON water_irrigators FOR ALL TO postgres USING (true);
|
||||
|
||||
-- Entries: readable by all (for admin), writable by admin RPCs and field RPC
|
||||
CREATE POLICY "Entries readable by all" ON water_log_entries FOR SELECT TO anon USING (true);
|
||||
CREATE POLICY "Entries writable by postgres" ON water_log_entries FOR ALL TO postgres USING (true);
|
||||
|
||||
-- ── Audit Log ──────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
user_id UUID,
|
||||
action TEXT NOT NULL,
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_id UUID,
|
||||
details JSONB,
|
||||
ip_address TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE audit_logs ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY "Audit logs writable by postgres" ON audit_logs FOR ALL TO postgres USING (true);
|
||||
|
||||
-- ── RPCs ───────────────────────────────────────────────────
|
||||
|
||||
-- Verify PIN, create DB session, return session token (stored in HTTP-only cookie)
|
||||
CREATE OR REPLACE FUNCTION verify_water_pin(p_brand_id UUID, p_pin TEXT)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_irr water_irrigators%ROWTYPE;
|
||||
v_sess_id UUID;
|
||||
v_lang TEXT;
|
||||
BEGIN
|
||||
SELECT * INTO v_irr FROM water_irrigators
|
||||
WHERE brand_id = p_brand_id AND active = true
|
||||
AND crypt(p_pin, pin_hash) = pin_hash
|
||||
LIMIT 1;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Invalid PIN');
|
||||
END IF;
|
||||
|
||||
UPDATE water_irrigators SET last_used_at = now() WHERE id = v_irr.id;
|
||||
|
||||
-- Create DB session with 4-hour expiry
|
||||
v_sess_id := gen_random_uuid();
|
||||
INSERT INTO water_sessions (id, irrigator_id, expires_at)
|
||||
VALUES (v_sess_id, v_irr.id, now() + interval '4 hours');
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'success', true,
|
||||
'irrigator_id', v_irr.id,
|
||||
'name', v_irr.name,
|
||||
'session_id', v_sess_id,
|
||||
'lang', v_irr.language_preference
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Submit water log entry (field side — session validated via DB)
|
||||
CREATE OR REPLACE FUNCTION submit_water_entry(
|
||||
p_session_id UUID,
|
||||
p_headgate_id UUID,
|
||||
p_measurement NUMERIC,
|
||||
p_unit TEXT,
|
||||
p_notes TEXT,
|
||||
p_submitted_via TEXT
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_entry_id UUID;
|
||||
v_sess water_sessions%ROWTYPE;
|
||||
v_brand_id UUID;
|
||||
BEGIN
|
||||
-- Validate session: exists and not expired
|
||||
SELECT s.*, i.brand_id INTO v_sess, v_brand_id
|
||||
FROM water_sessions s
|
||||
JOIN water_irrigators i ON s.irrigator_id = i.id
|
||||
WHERE s.id = p_session_id AND s.expires_at > now() AND i.active = true;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Session expired or invalid');
|
||||
END IF;
|
||||
|
||||
INSERT INTO water_log_entries (brand_id, headgate_id, irrigator_id, measurement, unit, notes, submitted_via)
|
||||
VALUES (v_brand_id, p_headgate_id, v_sess.irrigator_id, p_measurement, p_unit, p_notes, p_submitted_via)
|
||||
RETURNING id INTO v_entry_id;
|
||||
|
||||
RETURN jsonb_build_object('success', true, 'entry_id', v_entry_id);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Get headgates for a brand. p_active_only=true for field form, false for admin
|
||||
CREATE OR REPLACE FUNCTION get_water_headgates(p_brand_id UUID, p_active_only BOOLEAN DEFAULT false)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN jsonb_build_object('headgates', (
|
||||
SELECT COALESCE(jsonb_agg(jsonb_build_object(
|
||||
'id', id, 'name', name, 'active', active, 'created_at', created_at
|
||||
) ORDER BY name), '[]'::JSONB)
|
||||
FROM water_headgates
|
||||
WHERE brand_id = p_brand_id
|
||||
AND (NOT p_active_only OR active = true)
|
||||
));
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Get irrigators for a brand (admin, no pin_hash exposed)
|
||||
CREATE OR REPLACE FUNCTION get_water_irrigators(p_brand_id UUID)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN jsonb_build_object('irrigators', (
|
||||
SELECT COALESCE(jsonb_agg(jsonb_build_object(
|
||||
'id', id, 'name', name, 'active', active,
|
||||
'language_preference', language_preference,
|
||||
'last_used_at', last_used_at,
|
||||
'created_at', created_at
|
||||
) ORDER BY name), '[]'::JSONB)
|
||||
FROM water_irrigators
|
||||
WHERE brand_id = p_brand_id
|
||||
));
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Create a new irrigator with auto-generated PIN
|
||||
-- Returns irrigator + plain PIN (shown once)
|
||||
CREATE OR REPLACE FUNCTION create_water_irrigator(p_brand_id UUID, p_name TEXT, p_lang TEXT DEFAULT 'en')
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_irr water_irrigators%ROWTYPE;
|
||||
v_raw_pin TEXT;
|
||||
v_new_id UUID;
|
||||
BEGIN
|
||||
v_raw_pin := floor(random() * 9000 + 1000)::TEXT; -- 4-digit PIN
|
||||
|
||||
INSERT INTO water_irrigators (brand_id, name, pin_hash, language_preference)
|
||||
VALUES (p_brand_id, p_name, crypt(v_raw_pin, gen_salt('bf')), p_lang)
|
||||
RETURNING * INTO v_irr;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'irrigator', jsonb_build_object(
|
||||
'id', v_irr.id, 'name', v_irr.name,
|
||||
'active', v_irr.active, 'language_preference', v_irr.language_preference,
|
||||
'created_at', v_irr.created_at
|
||||
),
|
||||
'pin', v_raw_pin
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Update irrigator name/active
|
||||
CREATE OR REPLACE FUNCTION update_water_irrigator(
|
||||
p_irrigator_id UUID, p_name TEXT, p_active BOOLEAN, p_lang TEXT
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_irr water_irrigators%ROWTYPE;
|
||||
BEGIN
|
||||
UPDATE water_irrigators
|
||||
SET name = p_name, active = p_active, language_preference = p_lang
|
||||
WHERE id = p_irrigator_id
|
||||
RETURNING * INTO v_irr;
|
||||
|
||||
RETURN jsonb_build_object('irrigator', jsonb_build_object(
|
||||
'id', v_irr.id, 'name', v_irr.name,
|
||||
'active', v_irr.active, 'language_preference', v_irr.language_preference,
|
||||
'last_used_at', v_irr.last_used_at
|
||||
));
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Reset irrigator PIN — returns new PIN (shown once)
|
||||
CREATE OR REPLACE FUNCTION reset_water_irrigator_pin(p_irrigator_id UUID)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_irr water_irrigators%ROWTYPE;
|
||||
v_raw_pin TEXT;
|
||||
BEGIN
|
||||
v_raw_pin := floor(random() * 9000 + 1000)::TEXT;
|
||||
|
||||
UPDATE water_irrigators
|
||||
SET pin_hash = crypt(v_raw_pin, gen_salt('bf'))
|
||||
WHERE id = p_irrigator_id
|
||||
RETURNING * INTO v_irr;
|
||||
|
||||
RETURN jsonb_build_object('irrigator_id', v_irr.id, 'pin', v_raw_pin);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Create headgate
|
||||
CREATE OR REPLACE FUNCTION create_water_headgate(p_brand_id UUID, p_name TEXT)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_hg water_headgates%ROWTYPE;
|
||||
BEGIN
|
||||
INSERT INTO water_headgates (brand_id, name)
|
||||
VALUES (p_brand_id, p_name)
|
||||
RETURNING * INTO v_hg;
|
||||
|
||||
RETURN jsonb_build_object('headgate', jsonb_build_object(
|
||||
'id', v_hg.id, 'name', v_hg.name, 'active', v_hg.active, 'created_at', v_hg.created_at
|
||||
));
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Update headgate
|
||||
CREATE OR REPLACE FUNCTION update_water_headgate(p_headgate_id UUID, p_name TEXT, p_active BOOLEAN)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_hg water_headgates%ROWTYPE;
|
||||
BEGIN
|
||||
UPDATE water_headgates
|
||||
SET name = p_name, active = p_active
|
||||
WHERE id = p_headgate_id
|
||||
RETURNING * INTO v_hg;
|
||||
|
||||
RETURN jsonb_build_object('headgate', jsonb_build_object(
|
||||
'id', v_hg.id, 'name', v_hg.name, 'active', v_hg.active
|
||||
));
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Get recent entries for a brand
|
||||
CREATE OR REPLACE FUNCTION get_water_entries(p_brand_id UUID, p_limit INT DEFAULT 50)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN jsonb_build_object('entries', (
|
||||
SELECT COALESCE(jsonb_agg(t ORDER BY t.logged_at DESC), '[]'::JSONB)
|
||||
FROM (
|
||||
SELECT e.id, e.headgate_id, e.irrigator_id, e.measurement, e.unit,
|
||||
e.notes, e.submitted_via, e.logged_at,
|
||||
h.name as headgate_name,
|
||||
i.name as irrigator_name
|
||||
FROM water_log_entries e
|
||||
JOIN water_headgates h ON e.headgate_id = h.id
|
||||
JOIN water_irrigators i ON e.irrigator_id = i.id
|
||||
WHERE e.brand_id = p_brand_id
|
||||
ORDER BY e.logged_at DESC
|
||||
LIMIT p_limit
|
||||
) t
|
||||
));
|
||||
END;
|
||||
$$;
|
||||
@@ -1,263 +0,0 @@
|
||||
-- =============================================================================
|
||||
-- Water Log V1 — Consolidated RPC Repair
|
||||
-- Fixes: gen_salt/crypt calls, schema alignment, overload removal
|
||||
-- Uses fully-qualified pgcrypto: extensions.crypt / extensions.gen_salt
|
||||
-- =============================================================================
|
||||
|
||||
-- Drop all existing water log RPCs (ignore errors if some don't exist)
|
||||
DROP FUNCTION IF EXISTS public.verify_water_pin(uuid, text);
|
||||
DROP FUNCTION IF EXISTS public.create_water_user(uuid, text, text, text);
|
||||
DROP FUNCTION IF EXISTS public.create_water_user(uuid, text, text);
|
||||
DROP FUNCTION IF EXISTS public.reset_water_user_pin(uuid);
|
||||
DROP FUNCTION IF EXISTS public.reset_water_irrigator_pin(uuid);
|
||||
DROP FUNCTION IF EXISTS public.submit_water_entry(uuid, uuid, numeric, text, text, text);
|
||||
DROP FUNCTION IF EXISTS public.submit_water_entry(uuid, uuid, numeric, text, text);
|
||||
DROP FUNCTION IF EXISTS public.update_water_irrigator(uuid, text, boolean, text);
|
||||
DROP FUNCTION IF EXISTS public.update_water_user(uuid, text, boolean, text);
|
||||
DROP FUNCTION IF EXISTS public.get_water_irrigators(uuid);
|
||||
DROP FUNCTION IF EXISTS public.get_water_entries(uuid, int);
|
||||
DROP FUNCTION IF EXISTS public.get_water_entries(uuid);
|
||||
|
||||
-- =============================================================================
|
||||
-- verify_water_pin
|
||||
-- Returns jsonb: {success, user_id, name, role, session_id, lang} or {success:false, error}
|
||||
-- =============================================================================
|
||||
CREATE OR REPLACE FUNCTION public.verify_water_pin(p_brand_id uuid, p_pin text)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
STATIC
|
||||
AS $$
|
||||
DECLARE
|
||||
v_user jsonb;
|
||||
v_session_id uuid;
|
||||
BEGIN
|
||||
-- Look up user by PIN (pgcrypto fully-qualified)
|
||||
SELECT jsonb_build_object(
|
||||
'id', id,
|
||||
'name', name,
|
||||
'role', role,
|
||||
'lang', language_preference
|
||||
) INTO v_user
|
||||
FROM public.water_users
|
||||
WHERE brand_id = p_brand_id
|
||||
AND active = true
|
||||
AND role IN ('irrigator', 'water_admin')
|
||||
AND extensions.crypt(p_pin, pin_hash) = pin_hash;
|
||||
|
||||
IF v_user IS NULL THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Invalid PIN');
|
||||
END IF;
|
||||
|
||||
-- Update last-used timestamp
|
||||
UPDATE public.water_users
|
||||
SET last_used_at = now()
|
||||
WHERE id = (v_user->>'id')::uuid;
|
||||
|
||||
-- Create session
|
||||
v_session_id := gen_random_uuid();
|
||||
INSERT INTO public.water_sessions (id, irrigator_id, expires_at)
|
||||
VALUES (v_session_id, (v_user->>'id')::uuid, now() + interval '4 hours');
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'success', true,
|
||||
'user_id', v_user->>'id',
|
||||
'name', v_user->>'name',
|
||||
'role', v_user->>'role',
|
||||
'session_id', v_session_id,
|
||||
'lang', v_user->>'lang'
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION public.verify_water_pin IS
|
||||
'Verify irrigator/admin PIN, create session, return user info as JSONB.';
|
||||
|
||||
-- =============================================================================
|
||||
-- create_water_user
|
||||
-- Returns jsonb: {success, user: {id,name,role}, pin}
|
||||
-- =============================================================================
|
||||
CREATE OR REPLACE FUNCTION public.create_water_user(
|
||||
p_brand_id uuid,
|
||||
p_name text,
|
||||
p_role text,
|
||||
p_lang text DEFAULT 'en'
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
STATIC
|
||||
AS $$
|
||||
DECLARE
|
||||
v_brand_id uuid;
|
||||
v_raw_pin text;
|
||||
v_user_id uuid;
|
||||
v_user_role text;
|
||||
BEGIN
|
||||
v_brand_id := COALESCE(p_brand_id, '64294306-5f42-463d-a5e8-2ad6c81a96de'::uuid);
|
||||
v_raw_pin := lpad(floor(random() * 9000 + 1000)::text, 4, '0');
|
||||
v_user_role := COALESCE(p_role, 'irrigator');
|
||||
|
||||
INSERT INTO public.water_users (brand_id, name, pin_hash, role, language_preference, active)
|
||||
VALUES (
|
||||
v_brand_id,
|
||||
p_name,
|
||||
extensions.crypt(v_raw_pin, extensions.gen_salt('bf')),
|
||||
v_user_role,
|
||||
p_lang,
|
||||
true
|
||||
)
|
||||
RETURNING id INTO v_user_id;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'success', true,
|
||||
'user', jsonb_build_object('id', v_user_id, 'name', p_name, 'role', v_user_role),
|
||||
'pin', v_raw_pin
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION public.create_water_user IS
|
||||
'Create water user, generate PIN, return hashed PIN and plaintext PIN once.';
|
||||
|
||||
-- =============================================================================
|
||||
-- reset_water_user_pin
|
||||
-- Returns jsonb: {success, user_id, pin}
|
||||
-- =============================================================================
|
||||
CREATE OR REPLACE FUNCTION public.reset_water_user_pin(p_user_id uuid)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
STATIC
|
||||
AS $$
|
||||
DECLARE
|
||||
v_raw_pin text;
|
||||
v_name text;
|
||||
BEGIN
|
||||
v_raw_pin := lpad(floor(random() * 9000 + 1000)::text, 4, '0');
|
||||
|
||||
UPDATE public.water_users
|
||||
SET pin_hash = extensions.crypt(v_raw_pin, extensions.gen_salt('bf'))
|
||||
WHERE id = p_user_id
|
||||
RETURNING name INTO v_name;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'User not found');
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object('success', true, 'user_id', p_user_id, 'pin', v_raw_pin);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- =============================================================================
|
||||
-- submit_water_entry
|
||||
-- Returns jsonb: {success, entry_id} or {success:false, error}
|
||||
-- =============================================================================
|
||||
CREATE OR REPLACE FUNCTION public.submit_water_entry(
|
||||
p_session_id uuid,
|
||||
p_headgate_id uuid,
|
||||
p_measurement numeric,
|
||||
p_unit text,
|
||||
p_notes text DEFAULT '',
|
||||
p_submitted_via text DEFAULT 'field'
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
STATIC
|
||||
AS $$
|
||||
DECLARE
|
||||
v_sess record;
|
||||
v_brand_id uuid;
|
||||
v_entry_id uuid;
|
||||
BEGIN
|
||||
-- Look up session and join to water_users to get brand_id
|
||||
SELECT s.id, s.irrigator_id, u.brand_id
|
||||
INTO v_sess, v_brand_id
|
||||
FROM public.water_sessions s
|
||||
JOIN public.water_users u ON s.irrigator_id = u.id
|
||||
WHERE s.id = p_session_id
|
||||
AND s.expires_at > now();
|
||||
|
||||
IF NOT FOUND OR v_sess.irrigator_id IS NULL THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Session expired or invalid');
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.water_log_entries
|
||||
(brand_id, headgate_id, irrigator_id, measurement, unit, notes, submitted_via)
|
||||
VALUES
|
||||
(v_brand_id, p_headgate_id, v_sess.irrigator_id, p_measurement, p_unit, p_notes, p_submitted_via)
|
||||
RETURNING id INTO v_entry_id;
|
||||
|
||||
RETURN jsonb_build_object('success', true, 'entry_id', v_entry_id);
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION public.submit_water_entry IS
|
||||
'Submit a water log entry using an active wl_session cookie.';
|
||||
|
||||
-- =============================================================================
|
||||
-- update_water_user
|
||||
-- Returns jsonb: {success} or {success:false, error}
|
||||
-- =============================================================================
|
||||
CREATE OR REPLACE FUNCTION public.update_water_user(
|
||||
p_user_id uuid,
|
||||
p_name text,
|
||||
p_active boolean,
|
||||
p_lang text DEFAULT 'en'
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
STATIC
|
||||
AS $$
|
||||
BEGIN
|
||||
UPDATE public.water_users
|
||||
SET name = p_name, active = p_active, language_preference = p_lang
|
||||
WHERE id = p_user_id;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'User not found');
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object('success', true);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- =============================================================================
|
||||
-- get_water_entries
|
||||
-- Returns jsonb: {entries: [...]}
|
||||
-- =============================================================================
|
||||
CREATE OR REPLACE FUNCTION public.get_water_entries(p_brand_id uuid, p_limit int DEFAULT 50)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
STATIC
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN (
|
||||
SELECT jsonb_build_object('entries', COALESCE(
|
||||
(SELECT jsonb_agg(e ORDER BY e.logged_at DESC)
|
||||
FROM (
|
||||
SELECT
|
||||
e.id,
|
||||
e.headgate_id,
|
||||
e.irrigator_id,
|
||||
h.name AS headgate_name,
|
||||
u.name AS irrigator_name,
|
||||
e.measurement,
|
||||
e.unit,
|
||||
e.notes,
|
||||
e.submitted_via,
|
||||
e.logged_at
|
||||
FROM public.water_log_entries e
|
||||
JOIN public.water_headgates h ON e.headgate_id = h.id
|
||||
JOIN public.water_users u ON e.irrigator_id = u.id
|
||||
WHERE e.brand_id = p_brand_id
|
||||
ORDER BY e.logged_at DESC
|
||||
LIMIT p_limit
|
||||
) e
|
||||
), '[]'::jsonb
|
||||
))
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- =============================================================================
|
||||
-- Reload PostgREST schema cache so new signatures are visible
|
||||
-- =============================================================================
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -1,232 +0,0 @@
|
||||
-- =============================================================================
|
||||
-- Water Log V1 — Column name alignment + SECURITY DEFINER
|
||||
-- Deployed tables: water_users, water_sessions(user_id), water_log_entries(user_id)
|
||||
-- =============================================================================
|
||||
|
||||
DROP FUNCTION IF EXISTS public.verify_water_pin(uuid, text);
|
||||
DROP FUNCTION IF EXISTS public.create_water_user(uuid, text, text, text);
|
||||
DROP FUNCTION IF EXISTS public.reset_water_user_pin(uuid);
|
||||
DROP FUNCTION IF EXISTS public.submit_water_entry(uuid, uuid, numeric, text, text, text);
|
||||
DROP FUNCTION IF EXISTS public.update_water_user(uuid, text, boolean, text);
|
||||
DROP FUNCTION IF EXISTS public.get_water_entries(uuid, int);
|
||||
|
||||
-- =============================================================================
|
||||
-- verify_water_pin(p_brand_id uuid, p_pin text)
|
||||
-- Returns jsonb: {success, user_id, name, role, session_id, lang}
|
||||
-- =============================================================================
|
||||
CREATE OR REPLACE FUNCTION public.verify_water_pin(p_brand_id uuid, p_pin text)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_user jsonb;
|
||||
v_session_id uuid;
|
||||
BEGIN
|
||||
SELECT jsonb_build_object(
|
||||
'id', id,
|
||||
'name', name,
|
||||
'role', role,
|
||||
'lang', language_preference
|
||||
) INTO v_user
|
||||
FROM public.water_users
|
||||
WHERE active = true
|
||||
AND role IN ('irrigator', 'water_admin')
|
||||
AND extensions.crypt(p_pin, pin_hash) = pin_hash;
|
||||
|
||||
IF v_user IS NULL THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Invalid PIN');
|
||||
END IF;
|
||||
|
||||
UPDATE public.water_users
|
||||
SET last_used_at = now()
|
||||
WHERE id = (v_user->>'id')::uuid;
|
||||
|
||||
v_session_id := gen_random_uuid();
|
||||
INSERT INTO public.water_sessions (id, user_id, role, expires_at)
|
||||
VALUES (v_session_id, (v_user->>'id')::uuid, v_user->>'role', now() + interval '4 hours');
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'success', true,
|
||||
'user_id', v_user->>'id',
|
||||
'name', v_user->>'name',
|
||||
'role', v_user->>'role',
|
||||
'session_id', v_session_id,
|
||||
'lang', v_user->>'lang'
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- =============================================================================
|
||||
-- create_water_user(p_brand_id, p_name, p_role, p_lang)
|
||||
-- Returns jsonb: {success, user: {id,name,role}, pin}
|
||||
-- =============================================================================
|
||||
CREATE OR REPLACE FUNCTION public.create_water_user(
|
||||
p_brand_id uuid,
|
||||
p_name text,
|
||||
p_role text,
|
||||
p_lang text DEFAULT 'en'
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_raw_pin text := lpad(floor(random() * 9000 + 1000)::text, 4, '0');
|
||||
v_user_id uuid;
|
||||
v_user_role text := COALESCE(p_role, 'irrigator');
|
||||
v_brand_id uuid := COALESCE(p_brand_id, '64294306-5f42-463d-a5e8-2ad6c81a96de'::uuid);
|
||||
BEGIN
|
||||
INSERT INTO public.water_users (brand_id, name, pin_hash, role, language_preference, active)
|
||||
VALUES (
|
||||
v_brand_id,
|
||||
p_name,
|
||||
extensions.crypt(v_raw_pin, extensions.gen_salt('bf')),
|
||||
v_user_role,
|
||||
p_lang,
|
||||
true
|
||||
)
|
||||
RETURNING id INTO v_user_id;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'success', true,
|
||||
'user', jsonb_build_object('id', v_user_id, 'name', p_name, 'role', v_user_role),
|
||||
'pin', v_raw_pin
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- =============================================================================
|
||||
-- reset_water_user_pin(p_user_id)
|
||||
-- Returns jsonb: {success, user_id, pin}
|
||||
-- =============================================================================
|
||||
CREATE OR REPLACE FUNCTION public.reset_water_user_pin(p_user_id uuid)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_raw_pin text := lpad(floor(random() * 9000 + 1000)::text, 4, '0');
|
||||
v_name text;
|
||||
BEGIN
|
||||
UPDATE public.water_users
|
||||
SET pin_hash = extensions.crypt(v_raw_pin, extensions.gen_salt('bf'))
|
||||
WHERE id = p_user_id
|
||||
RETURNING name INTO v_name;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'User not found');
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object('success', true, 'user_id', p_user_id, 'pin', v_raw_pin);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- =============================================================================
|
||||
-- submit_water_entry(p_session_id, p_headgate_id, p_measurement, p_unit, p_notes, p_submitted_via)
|
||||
-- Returns jsonb: {success, entry_id}
|
||||
-- =============================================================================
|
||||
CREATE OR REPLACE FUNCTION public.submit_water_entry(
|
||||
p_session_id uuid,
|
||||
p_headgate_id uuid,
|
||||
p_measurement numeric,
|
||||
p_unit text,
|
||||
p_notes text DEFAULT '',
|
||||
p_submitted_via text DEFAULT 'field'
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_user_id uuid;
|
||||
v_brand_id uuid;
|
||||
v_entry_id uuid;
|
||||
BEGIN
|
||||
SELECT s.user_id, h.brand_id
|
||||
INTO v_user_id, v_brand_id
|
||||
FROM public.water_sessions s
|
||||
JOIN public.water_headgates h ON h.id = p_headgate_id
|
||||
WHERE s.id = p_session_id
|
||||
AND s.expires_at > now();
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Session expired or invalid');
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.water_log_entries
|
||||
(brand_id, headgate_id, user_id, measurement, unit, notes, submitted_via)
|
||||
VALUES
|
||||
(v_brand_id, p_headgate_id, v_user_id, p_measurement, p_unit, p_notes, p_submitted_via)
|
||||
RETURNING id INTO v_entry_id;
|
||||
|
||||
RETURN jsonb_build_object('success', true, 'entry_id', v_entry_id);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- =============================================================================
|
||||
-- update_water_user(p_user_id, p_name, p_active, p_lang)
|
||||
-- Returns jsonb: {success}
|
||||
-- =============================================================================
|
||||
CREATE OR REPLACE FUNCTION public.update_water_user(
|
||||
p_user_id uuid,
|
||||
p_name text,
|
||||
p_active boolean,
|
||||
p_lang text DEFAULT 'en'
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
UPDATE public.water_users
|
||||
SET name = p_name, active = p_active, language_preference = p_lang
|
||||
WHERE id = p_user_id;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'User not found');
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object('success', true);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- =============================================================================
|
||||
-- get_water_entries(p_brand_id, p_limit)
|
||||
-- Returns jsonb: {entries: [...]}
|
||||
-- =============================================================================
|
||||
CREATE OR REPLACE FUNCTION public.get_water_entries(p_brand_id uuid, p_limit int DEFAULT 50)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN (
|
||||
SELECT jsonb_build_object('entries', COALESCE(
|
||||
(SELECT jsonb_agg(e ORDER BY e.logged_at DESC)
|
||||
FROM (
|
||||
SELECT
|
||||
e.id,
|
||||
e.headgate_id,
|
||||
e.user_id,
|
||||
h.name AS headgate_name,
|
||||
u.name AS user_name,
|
||||
e.measurement,
|
||||
e.unit,
|
||||
e.notes,
|
||||
e.submitted_via,
|
||||
e.logged_at
|
||||
FROM public.water_log_entries e
|
||||
JOIN public.water_headgates h ON e.headgate_id = h.id
|
||||
JOIN public.water_users u ON e.user_id = u.id
|
||||
WHERE e.brand_id = p_brand_id
|
||||
ORDER BY e.logged_at DESC
|
||||
LIMIT p_limit
|
||||
) e
|
||||
), '[]'::jsonb
|
||||
))
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -1,123 +0,0 @@
|
||||
-- =============================================================================
|
||||
-- Water Log V1 — Soft Delete
|
||||
-- Adds deleted_at to water_users and water_headgates
|
||||
-- Creates delete_water_user and delete_water_headgate RPCs
|
||||
-- Existing entries preserved; deleted users/headgates excluded from queries
|
||||
-- =============================================================================
|
||||
|
||||
-- Add deleted_at column to water_users
|
||||
ALTER TABLE public.water_users
|
||||
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
|
||||
-- Add deleted_at column to water_headgates
|
||||
ALTER TABLE public.water_headgates
|
||||
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
|
||||
-- =============================================================================
|
||||
-- delete_water_user(p_user_id uuid)
|
||||
-- Soft-deletes a water user (sets deleted_at) so entries are preserved.
|
||||
-- Returns jsonb: {success} or {success:false, error}
|
||||
-- =============================================================================
|
||||
CREATE OR REPLACE FUNCTION public.delete_water_user(p_user_id uuid)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
UPDATE public.water_users
|
||||
SET deleted_at = now()
|
||||
WHERE id = p_user_id AND deleted_at IS NULL;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'User not found or already deleted');
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object('success', true);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- =============================================================================
|
||||
-- delete_water_headgate(p_headgate_id uuid)
|
||||
-- Soft-deletes a water headgate (sets deleted_at) so entries are preserved.
|
||||
-- Returns jsonb: {success} or {success:false, error}
|
||||
-- =============================================================================
|
||||
CREATE OR REPLACE FUNCTION public.delete_water_headgate(p_headgate_id uuid)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
UPDATE public.water_headgates
|
||||
SET deleted_at = now()
|
||||
WHERE id = p_headgate_id AND deleted_at IS NULL;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Headgate not found or already deleted');
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object('success', true);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- =============================================================================
|
||||
-- Update get_water_users to exclude soft-deleted users
|
||||
-- =============================================================================
|
||||
CREATE OR REPLACE FUNCTION public.get_water_users(p_brand_id uuid)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN (
|
||||
SELECT jsonb_build_object('users', COALESCE(
|
||||
(SELECT jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', id,
|
||||
'name', name,
|
||||
'role', role,
|
||||
'active', active,
|
||||
'language_preference', language_preference,
|
||||
'last_used_at', last_used_at,
|
||||
'created_at', created_at,
|
||||
'deleted_at', deleted_at
|
||||
) ORDER BY created_at DESC
|
||||
)
|
||||
FROM public.water_users
|
||||
WHERE brand_id = p_brand_id AND deleted_at IS NULL
|
||||
), '[]'::jsonb
|
||||
))
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- =============================================================================
|
||||
-- Update get_water_headgates to exclude soft-deleted headgates
|
||||
-- =============================================================================
|
||||
CREATE OR REPLACE FUNCTION public.get_water_headgates(p_brand_id uuid, p_active_only boolean DEFAULT false)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN (
|
||||
SELECT jsonb_build_object('headgates', COALESCE(
|
||||
(SELECT jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', id,
|
||||
'name', name,
|
||||
'active', active,
|
||||
'created_at', created_at,
|
||||
'deleted_at', deleted_at
|
||||
) ORDER BY created_at DESC
|
||||
)
|
||||
FROM public.water_headgates
|
||||
WHERE brand_id = p_brand_id
|
||||
AND (NOT p_active_only OR active = true)
|
||||
AND deleted_at IS NULL
|
||||
), '[]'::jsonb
|
||||
))
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -1,88 +0,0 @@
|
||||
-- =============================================================================
|
||||
-- Water Log V1 — Entry Edit & Delete
|
||||
-- Allows water_admin and platform_admin to edit measurement/notes
|
||||
-- Allows deletion of entries (hard delete, not soft)
|
||||
-- =============================================================================
|
||||
|
||||
-- =============================================================================
|
||||
-- update_water_entry(p_entry_id uuid, p_measurement numeric, p_notes text)
|
||||
-- Updates measurement and notes only. Preserves user_id, headgate_id,
|
||||
-- logged_at, submitted_via.
|
||||
-- Returns jsonb: {success} or {success:false, error}
|
||||
-- =============================================================================
|
||||
CREATE OR REPLACE FUNCTION public.update_water_entry(p_entry_id uuid, p_measurement numeric, p_notes text)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
UPDATE public.water_log_entries
|
||||
SET measurement = p_measurement, notes = p_notes
|
||||
WHERE id = p_entry_id;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Entry not found');
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object('success', true);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- =============================================================================
|
||||
-- delete_water_entry(p_entry_id uuid)
|
||||
-- Hard-deletes an entry. Use with caution — no soft-delete for entries.
|
||||
-- Returns jsonb: {success} or {success:false, error}
|
||||
-- =============================================================================
|
||||
CREATE OR REPLACE FUNCTION public.delete_water_entry(p_entry_id uuid)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
DELETE FROM public.water_log_entries WHERE id = p_entry_id;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Entry not found');
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object('success', true);
|
||||
END;
|
||||
$$;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
|
||||
-- =============================================================================
|
||||
-- get_water_entry_by_id(p_entry_id uuid)
|
||||
-- Returns a single entry row for the edit page.
|
||||
-- Returns jsonb: {entry: {...}} or {entry: null}
|
||||
-- =============================================================================
|
||||
CREATE OR REPLACE FUNCTION public.get_water_entry_by_id(p_entry_id uuid)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN (
|
||||
SELECT jsonb_build_object('entry', (
|
||||
SELECT jsonb_build_object(
|
||||
'id', e.id,
|
||||
'headgate_id', e.headgate_id,
|
||||
'user_id', e.user_id,
|
||||
'headgate_name', hg.name,
|
||||
'user_name', u.name,
|
||||
'measurement', e.measurement,
|
||||
'unit', e.unit,
|
||||
'notes', e.notes,
|
||||
'submitted_via', e.submitted_via,
|
||||
'logged_at', e.logged_at
|
||||
)
|
||||
FROM public.water_log_entries e
|
||||
JOIN public.water_headgates hg ON hg.id = e.headgate_id
|
||||
JOIN public.water_users u ON u.id = e.user_id
|
||||
WHERE e.id = p_entry_id
|
||||
))
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -1,202 +0,0 @@
|
||||
-- =============================================================================
|
||||
-- Water Log V1.1 — Unit Types + Dedicated Edit Pages
|
||||
--
|
||||
-- Adds `unit` column to water_headgates so each headgate carries its
|
||||
-- preferred measurement unit (CFS, GPM, etc.). This unit pre-selects
|
||||
-- on the field form and is editable via dedicated headgate/user edit pages.
|
||||
--
|
||||
-- Also updates update_water_entry to support changing unit.
|
||||
-- =============================================================================
|
||||
|
||||
-- ── Add unit to headgates ────────────────────────────────────
|
||||
ALTER TABLE public.water_headgates
|
||||
ADD COLUMN IF NOT EXISTS unit TEXT NOT NULL DEFAULT 'CFS';
|
||||
|
||||
-- ── Update submit_water_entry to respect headgate's unit ─────
|
||||
-- The field form should use the headgate's default unit.
|
||||
-- Update get_water_entries to include headgate unit.
|
||||
-- Update update_water_entry to accept optional unit change.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.update_water_entry(
|
||||
p_entry_id uuid,
|
||||
p_measurement numeric,
|
||||
p_notes text,
|
||||
p_unit text DEFAULT NULL
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
IF p_unit IS NOT NULL THEN
|
||||
UPDATE public.water_log_entries
|
||||
SET measurement = p_measurement, notes = p_notes, unit = p_unit
|
||||
WHERE id = p_entry_id;
|
||||
ELSE
|
||||
UPDATE public.water_log_entries
|
||||
SET measurement = p_measurement, notes = p_notes
|
||||
WHERE id = p_entry_id;
|
||||
END IF;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Entry not found');
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object('success', true);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Update get_water_entries to include headgate's default unit (for pre-fill)
|
||||
CREATE OR REPLACE FUNCTION public.get_water_entries(p_brand_id uuid, p_limit int DEFAULT 50)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN (
|
||||
SELECT jsonb_build_object('entries', COALESCE(
|
||||
(SELECT jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', e.id,
|
||||
'headgate_id', e.headgate_id,
|
||||
'user_id', e.user_id,
|
||||
'headgate_name', hg.name,
|
||||
'user_name', u.name,
|
||||
'measurement', e.measurement,
|
||||
'unit', e.unit,
|
||||
'notes', e.notes,
|
||||
'submitted_via', e.submitted_via,
|
||||
'logged_at', e.logged_at,
|
||||
'headgate_unit', hg.unit
|
||||
) ORDER BY e.logged_at DESC
|
||||
)
|
||||
FROM public.water_log_entries e
|
||||
JOIN public.water_headgates hg ON hg.id = e.headgate_id
|
||||
JOIN public.water_users u ON u.id = e.user_id
|
||||
WHERE e.brand_id = p_brand_id
|
||||
), '[]'::jsonb
|
||||
))
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Update get_water_entry_by_id to include headgate_unit for pre-fill
|
||||
CREATE OR REPLACE FUNCTION public.get_water_entry_by_id(p_entry_id uuid)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN (
|
||||
SELECT jsonb_build_object('entry', (
|
||||
SELECT jsonb_build_object(
|
||||
'id', e.id,
|
||||
'headgate_id', e.headgate_id,
|
||||
'user_id', e.user_id,
|
||||
'headgate_name', hg.name,
|
||||
'user_name', u.name,
|
||||
'measurement', e.measurement,
|
||||
'unit', e.unit,
|
||||
'notes', e.notes,
|
||||
'submitted_via', e.submitted_via,
|
||||
'logged_at', e.logged_at,
|
||||
'headgate_unit', hg.unit
|
||||
)
|
||||
FROM public.water_log_entries e
|
||||
JOIN public.water_headgates hg ON hg.id = e.headgate_id
|
||||
JOIN public.water_users u ON u.id = e.user_id
|
||||
WHERE e.id = p_entry_id
|
||||
))
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Drop overloads so there's only one version with p_unit defaulting
|
||||
DROP FUNCTION IF EXISTS public.update_water_headgate(uuid, text, boolean);
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.update_water_headgate(
|
||||
p_headgate_id uuid,
|
||||
p_name text,
|
||||
p_active boolean,
|
||||
p_unit text DEFAULT NULL
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
IF p_unit IS NOT NULL THEN
|
||||
UPDATE public.water_headgates
|
||||
SET name = p_name, active = p_active, unit = p_unit
|
||||
WHERE id = p_headgate_id AND deleted_at IS NULL;
|
||||
ELSE
|
||||
UPDATE public.water_headgates
|
||||
SET name = p_name, active = p_active
|
||||
WHERE id = p_headgate_id AND deleted_at IS NULL;
|
||||
END IF;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Headgate not found');
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object('success', true);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Update create_water_headgate to accept unit
|
||||
CREATE OR REPLACE FUNCTION public.create_water_headgate(p_brand_id uuid, p_name text, p_unit text DEFAULT 'CFS')
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_headgate_id uuid;
|
||||
v_result jsonb;
|
||||
BEGIN
|
||||
INSERT INTO public.water_headgates (brand_id, name, unit)
|
||||
VALUES (p_brand_id, p_name, p_unit)
|
||||
RETURNING id INTO v_headgate_id;
|
||||
|
||||
SELECT jsonb_build_object(
|
||||
'id', id,
|
||||
'name', name,
|
||||
'active', active,
|
||||
'unit', unit,
|
||||
'created_at', created_at
|
||||
) INTO v_result
|
||||
FROM public.water_headgates WHERE id = v_headgate_id;
|
||||
|
||||
RETURN jsonb_build_object('headgate', v_result);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- get_water_headgates admin view should include unit
|
||||
CREATE OR REPLACE FUNCTION public.get_water_headgates(p_brand_id uuid, p_active_only boolean DEFAULT false)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN (
|
||||
SELECT jsonb_build_object('headgates', COALESCE(
|
||||
(SELECT jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', id,
|
||||
'name', name,
|
||||
'active', active,
|
||||
'unit', unit,
|
||||
'created_at', created_at,
|
||||
'deleted_at', deleted_at
|
||||
) ORDER BY created_at DESC
|
||||
)
|
||||
FROM public.water_headgates
|
||||
WHERE brand_id = p_brand_id
|
||||
AND (NOT p_active_only OR active = true)
|
||||
AND deleted_at IS NULL
|
||||
), '[]'::jsonb
|
||||
))
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -1,289 +0,0 @@
|
||||
-- =============================================================================
|
||||
-- Water Log V1.1 — Unit Types + Soft Delete Fix (consolidated)
|
||||
--
|
||||
-- If migration 008 was not run, this adds deleted_at columns.
|
||||
-- If migration 010 had issues, this fixes the function overload and adds missing cols.
|
||||
-- Safe to run multiple times — uses ADD COLUMN IF NOT EXISTS and IF EXISTS drops.
|
||||
-- =============================================================================
|
||||
|
||||
-- ── Soft delete columns (if not already present) ─────────────────────────────
|
||||
ALTER TABLE public.water_users
|
||||
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
|
||||
ALTER TABLE public.water_headgates
|
||||
ADD COLUMN IF NOT EXISTS unit TEXT NOT NULL DEFAULT 'CFS',
|
||||
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
|
||||
-- ── Fix update_water_headgate overload ───────────────────────────────────────
|
||||
-- Drop old 3-param overload so there's only one version (with p_unit)
|
||||
DROP FUNCTION IF EXISTS public.update_water_headgate(uuid, text, boolean);
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.update_water_headgate(
|
||||
p_headgate_id uuid,
|
||||
p_name text,
|
||||
p_active boolean,
|
||||
p_unit text DEFAULT NULL
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
IF p_unit IS NOT NULL THEN
|
||||
UPDATE public.water_headgates
|
||||
SET name = p_name, active = p_active, unit = p_unit
|
||||
WHERE id = p_headgate_id AND deleted_at IS NULL;
|
||||
ELSE
|
||||
UPDATE public.water_headgates
|
||||
SET name = p_name, active = p_active
|
||||
WHERE id = p_headgate_id AND deleted_at IS NULL;
|
||||
END IF;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Headgate not found');
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object('success', true);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── Fix update_water_entry overload ──────────────────────────────────────────
|
||||
-- Drop old 3-param overload so only the 4-param version (p_unit optional) exists
|
||||
DROP FUNCTION IF EXISTS public.update_water_entry(uuid, numeric, text);
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.update_water_entry(
|
||||
p_entry_id uuid,
|
||||
p_measurement numeric,
|
||||
p_notes text,
|
||||
p_unit text DEFAULT NULL
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
IF p_unit IS NOT NULL THEN
|
||||
UPDATE public.water_log_entries
|
||||
SET measurement = p_measurement, notes = p_notes, unit = p_unit
|
||||
WHERE id = p_entry_id;
|
||||
ELSE
|
||||
UPDATE public.water_log_entries
|
||||
SET measurement = p_measurement, notes = p_notes
|
||||
WHERE id = p_entry_id;
|
||||
END IF;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Entry not found');
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object('success', true);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── Create Water Headgate (updated to support p_unit) ────────────────────────
|
||||
DROP FUNCTION IF EXISTS public.create_water_headgate(uuid, text, text);
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.create_water_headgate(
|
||||
p_brand_id uuid,
|
||||
p_name text,
|
||||
p_unit text DEFAULT 'CFS'
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_headgate_id uuid;
|
||||
v_result jsonb;
|
||||
BEGIN
|
||||
INSERT INTO public.water_headgates (brand_id, name, unit)
|
||||
VALUES (p_brand_id, p_name, p_unit)
|
||||
RETURNING id INTO v_headgate_id;
|
||||
|
||||
SELECT jsonb_build_object(
|
||||
'id', id,
|
||||
'name', name,
|
||||
'active', active,
|
||||
'unit', unit,
|
||||
'created_at', created_at
|
||||
) INTO v_result
|
||||
FROM public.water_headgates WHERE id = v_headgate_id;
|
||||
|
||||
RETURN jsonb_build_object('headgate', v_result);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── Delete Water User (soft delete) ───────────────────────────────────────────
|
||||
DROP FUNCTION IF EXISTS public.delete_water_user(uuid);
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.delete_water_user(p_user_id uuid)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
UPDATE public.water_users
|
||||
SET deleted_at = now()
|
||||
WHERE id = p_user_id AND deleted_at IS NULL;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'User not found or already deleted');
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object('success', true);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── Delete Water Headgate (soft delete) ──────────────────────────────────────
|
||||
DROP FUNCTION IF EXISTS public.delete_water_headgate(uuid);
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.delete_water_headgate(p_headgate_id uuid)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
UPDATE public.water_headgates
|
||||
SET deleted_at = now()
|
||||
WHERE id = p_headgate_id AND deleted_at IS NULL;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Headgate not found or already deleted');
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object('success', true);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── Get Water Users (exclude soft-deleted) ───────────────────────────────────
|
||||
DROP FUNCTION IF EXISTS public.get_water_users(uuid);
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.get_water_users(p_brand_id uuid)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN (
|
||||
SELECT jsonb_build_object('users', COALESCE(
|
||||
(SELECT jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', id,
|
||||
'name', name,
|
||||
'role', role,
|
||||
'active', active,
|
||||
'language_preference', language_preference,
|
||||
'last_used_at', last_used_at,
|
||||
'created_at', created_at,
|
||||
'deleted_at', deleted_at
|
||||
) ORDER BY created_at DESC
|
||||
)
|
||||
FROM public.water_users
|
||||
WHERE brand_id = p_brand_id AND deleted_at IS NULL
|
||||
), '[]'::jsonb
|
||||
))
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── Get Water Headgates (exclude soft-deleted, include unit) ───────────────────
|
||||
DROP FUNCTION IF EXISTS public.get_water_headgates(uuid, boolean);
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.get_water_headgates(p_brand_id uuid, p_active_only boolean DEFAULT false)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN (
|
||||
SELECT jsonb_build_object('headgates', COALESCE(
|
||||
(SELECT jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', id,
|
||||
'name', name,
|
||||
'active', active,
|
||||
'unit', unit,
|
||||
'created_at', created_at,
|
||||
'deleted_at', deleted_at
|
||||
) ORDER BY created_at DESC
|
||||
)
|
||||
FROM public.water_headgates
|
||||
WHERE brand_id = p_brand_id
|
||||
AND (NOT p_active_only OR active = true)
|
||||
AND deleted_at IS NULL
|
||||
), '[]'::jsonb
|
||||
))
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── Get Water Entries (include headgate_unit) ─────────────────────────────────
|
||||
DROP FUNCTION IF EXISTS public.get_water_entries(uuid, int);
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.get_water_entries(p_brand_id uuid, p_limit int DEFAULT 50)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN (
|
||||
SELECT jsonb_build_object('entries', COALESCE(
|
||||
(SELECT jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', e.id,
|
||||
'headgate_id', e.headgate_id,
|
||||
'user_id', e.user_id,
|
||||
'headgate_name', hg.name,
|
||||
'user_name', u.name,
|
||||
'measurement', e.measurement,
|
||||
'unit', e.unit,
|
||||
'notes', e.notes,
|
||||
'submitted_via', e.submitted_via,
|
||||
'logged_at', e.logged_at,
|
||||
'headgate_unit', hg.unit
|
||||
) ORDER BY e.logged_at DESC
|
||||
)
|
||||
FROM public.water_log_entries e
|
||||
JOIN public.water_headgates hg ON hg.id = e.headgate_id
|
||||
JOIN public.water_users u ON u.id = e.user_id
|
||||
WHERE e.brand_id = p_brand_id
|
||||
), '[]'::jsonb
|
||||
))
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── Get Water Entry By ID (include headgate_unit) ────────────────────────────
|
||||
DROP FUNCTION IF EXISTS public.get_water_entry_by_id(uuid);
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.get_water_entry_by_id(p_entry_id uuid)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN (
|
||||
SELECT jsonb_build_object('entry', (
|
||||
SELECT jsonb_build_object(
|
||||
'id', e.id,
|
||||
'headgate_id', e.headgate_id,
|
||||
'user_id', e.user_id,
|
||||
'headgate_name', hg.name,
|
||||
'user_name', u.name,
|
||||
'measurement', e.measurement,
|
||||
'unit', e.unit,
|
||||
'notes', e.notes,
|
||||
'submitted_via', e.submitted_via,
|
||||
'logged_at', e.logged_at,
|
||||
'headgate_unit', hg.unit
|
||||
)
|
||||
FROM public.water_log_entries e
|
||||
JOIN public.water_headgates hg ON hg.id = e.headgate_id
|
||||
JOIN public.water_users u ON u.id = e.user_id
|
||||
WHERE e.id = p_entry_id
|
||||
))
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -1,103 +0,0 @@
|
||||
-- =============================================================================
|
||||
-- Water Log V1.6 - Display Summary RPC
|
||||
-- Returns headgate latest readings + today's aggregates for Smartsheet/display.
|
||||
-- SECURITY DEFINER so anon key can call it (same as other water log RPCs).
|
||||
-- =============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.get_water_display_summary(p_brand_id uuid)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN (
|
||||
WITH headgate_data AS (
|
||||
SELECT
|
||||
hg.id,
|
||||
hg.name,
|
||||
COALESCE(hg.unit, 'CFS') as unit,
|
||||
(
|
||||
SELECT jsonb_build_object(
|
||||
'measurement', le.measurement,
|
||||
'user_name', u.name,
|
||||
'logged_at', le.logged_at
|
||||
)
|
||||
FROM public.water_log_entries le
|
||||
JOIN public.water_users u ON u.id = le.user_id
|
||||
WHERE le.headgate_id = hg.id
|
||||
ORDER BY le.logged_at DESC
|
||||
LIMIT 1
|
||||
) as latest_entry,
|
||||
(
|
||||
SELECT le.logged_at
|
||||
FROM public.water_log_entries le
|
||||
WHERE le.headgate_id = hg.id
|
||||
ORDER BY le.logged_at DESC
|
||||
LIMIT 1
|
||||
) as last_logged_at,
|
||||
(
|
||||
SELECT FLOOR(EXTRACT(EPOCH FROM (now() - le.logged_at)) / 60)::int
|
||||
FROM public.water_log_entries le
|
||||
WHERE le.headgate_id = hg.id
|
||||
ORDER BY le.logged_at DESC
|
||||
LIMIT 1
|
||||
) as minutes_ago
|
||||
FROM public.water_headgates hg
|
||||
WHERE hg.brand_id = p_brand_id
|
||||
AND hg.active = true
|
||||
AND hg.deleted_at IS NULL
|
||||
),
|
||||
today_stats AS (
|
||||
SELECT
|
||||
count(*) as cnt,
|
||||
COALESCE(sum(measurement), 0) as total
|
||||
FROM public.water_log_entries
|
||||
WHERE brand_id = p_brand_id AND logged_at::date = CURRENT_DATE
|
||||
),
|
||||
entries_ordered AS (
|
||||
SELECT e.logged_at, e.headgate_id, e.user_id, e.measurement, e.unit, e.notes, e.submitted_via
|
||||
FROM public.water_log_entries e
|
||||
WHERE e.brand_id = p_brand_id
|
||||
ORDER BY e.logged_at DESC
|
||||
LIMIT 20
|
||||
),
|
||||
recent_entries_agg AS (
|
||||
SELECT COALESCE(jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'logged_at', e.logged_at,
|
||||
'headgate_name', hg.name,
|
||||
'user_name', u.name,
|
||||
'user_role', u.role,
|
||||
'measurement', e.measurement,
|
||||
'unit', e.unit,
|
||||
'notes', e.notes,
|
||||
'submitted_via', e.submitted_via
|
||||
)
|
||||
), '[]'::jsonb) as data
|
||||
FROM entries_ordered e
|
||||
JOIN public.water_headgates hg ON hg.id = e.headgate_id
|
||||
JOIN public.water_users u ON u.id = e.user_id
|
||||
)
|
||||
SELECT jsonb_build_object(
|
||||
'headgates', (
|
||||
SELECT COALESCE(jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', hd.id,
|
||||
'name', hd.name,
|
||||
'unit', hd.unit,
|
||||
'latest_entry', hd.latest_entry,
|
||||
'last_logged_at', hd.last_logged_at,
|
||||
'minutes_ago', hd.minutes_ago
|
||||
)
|
||||
), '[]'::jsonb)
|
||||
FROM headgate_data hd
|
||||
),
|
||||
'today_count', (SELECT cnt FROM today_stats),
|
||||
'today_total', (SELECT total FROM today_stats),
|
||||
'recent_entries', (SELECT data FROM recent_entries_agg)
|
||||
)
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -1,13 +0,0 @@
|
||||
-- =============================================================================
|
||||
-- Water Log V1.6 - Seed Water Admin User
|
||||
-- =============================================================================
|
||||
|
||||
INSERT INTO public.water_users (brand_id, name, pin_hash, role, active, language_preference)
|
||||
VALUES (
|
||||
'64294306-5f42-463d-a5e8-2ad6c81a96de',
|
||||
'Test Water Admin',
|
||||
extensions.crypt('1234', gen_salt('bf')),
|
||||
'water_admin',
|
||||
true,
|
||||
'en'
|
||||
);
|
||||
@@ -1,9 +0,0 @@
|
||||
-- =============================================================================
|
||||
-- Water Log V1.6 - Fix water_sessions RLS
|
||||
-- Add SELECT policy so getWaterAdminSession can read sessions with anon key
|
||||
-- =============================================================================
|
||||
|
||||
DROP POLICY IF EXISTS "Sessions readable by all" ON public.water_sessions;
|
||||
CREATE POLICY "Sessions readable by all" ON public.water_sessions FOR SELECT TO anon USING (true);
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -1,90 +0,0 @@
|
||||
-- =============================================================================
|
||||
-- Water Log V1.6 - Security Hardening: block direct water_users access
|
||||
--
|
||||
-- Problem: pin_hash can be read via direct REST SELECT with anon key.
|
||||
-- Fix: Remove direct SELECT policies. All reads go through SECURITY DEFINER
|
||||
-- RPCs which run as the supabase postgres user and bypass RLS.
|
||||
--
|
||||
-- Changes:
|
||||
-- 1. DROP "Users readable by all" policy (was added to fix getWaterAdminSession)
|
||||
-- 2. Create get_water_user_by_id(p_user_id) RPC for language lookup (SECURITY DEFINER)
|
||||
-- 3. Create get_water_admin_session() RPC (SECURITY DEFINER) replacing direct
|
||||
-- water_sessions + water_users JOIN in field.ts getWaterAdminSession
|
||||
-- 4. Update field.ts to use RPCs instead of direct REST calls
|
||||
-- 5. Supabase will continue to route all water_users writes through the existing
|
||||
-- SECURITY DEFINER RPCs (create_water_user, update_water_user, etc.)
|
||||
-- =============================================================================
|
||||
|
||||
-- Step 1: Remove direct SELECT policy from water_users
|
||||
DROP POLICY IF EXISTS "Users readable by all" ON public.water_users;
|
||||
|
||||
-- Step 2: Create a SECURITY DEFINER RPC to get a single user's language preference
|
||||
-- This replaces the direct REST call in field.ts line 83
|
||||
-- (runs as supabase postgres user, bypasses RLS)
|
||||
CREATE OR REPLACE FUNCTION public.get_water_user_by_id(p_user_id uuid)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_result jsonb;
|
||||
BEGIN
|
||||
SELECT jsonb_build_object(
|
||||
'language_preference', language_preference
|
||||
) INTO v_result
|
||||
FROM public.water_users
|
||||
WHERE id = p_user_id AND deleted_at IS NULL;
|
||||
|
||||
RETURN v_result;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Step 3: Create get_water_admin_session() RPC (SECURITY DEFINER)
|
||||
-- Reads the wl_session cookie and returns the associated user's info.
|
||||
-- Replaces the direct water_sessions + water_users JOIN in getWaterAdminSession.
|
||||
-- Runs as supabase postgres user, bypasses RLS on both water_sessions and water_users.
|
||||
CREATE OR REPLACE FUNCTION public.get_water_admin_session()
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_session_id text;
|
||||
v_result jsonb;
|
||||
BEGIN
|
||||
-- Read wl_session from request cookies (set by verify_water_pin)
|
||||
-- In SECURITY DEFINER context, we can't access HTTP cookies directly.
|
||||
-- The session_id is passed from the caller (Next.js server action).
|
||||
-- Instead, create a variant that accepts session_id as a parameter.
|
||||
RETURN null; -- placeholder — actual implementation uses p_session_id below
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Drop and recreate with proper signature (accepts p_session_id)
|
||||
DROP FUNCTION IF EXISTS public.get_water_admin_session();
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.get_water_admin_session(p_session_id text)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_result jsonb;
|
||||
BEGIN
|
||||
SELECT jsonb_build_object(
|
||||
'user_id', s.user_id,
|
||||
'name', u.name,
|
||||
'role', u.role
|
||||
) INTO v_result
|
||||
FROM public.water_sessions s
|
||||
JOIN public.water_users u ON u.id = s.user_id
|
||||
WHERE s.id = p_session_id::uuid
|
||||
AND s.expires_at > now()
|
||||
AND u.role = 'water_admin'
|
||||
AND u.deleted_at IS NULL;
|
||||
|
||||
RETURN v_result;
|
||||
END;
|
||||
$$;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -1,662 +0,0 @@
|
||||
-- =============================================================================
|
||||
-- Communication Center V1 — Core Schema
|
||||
-- Fully idempotent: safe to re-run after partial failure or interrupted deploy
|
||||
--
|
||||
-- Cross-brand platform module: supports Tuxedo Corn, Indian River Direct, and
|
||||
-- future brands via brand_id separation and RLS.
|
||||
--
|
||||
-- Design principles:
|
||||
-- - Operational messaging first (stop notifications, pickup reminders, etc.)
|
||||
-- - Audience rules owned by campaigns (no separate reusable audience table)
|
||||
-- - Environment-based provider config (Resend API key in env, not in DB)
|
||||
-- - Event-driven future-ready (event_type / event_id on message_logs)
|
||||
-- - No live email/SMS sending until explicitly enabled after testing
|
||||
-- - Customer data lives in orders table — no separate customers table
|
||||
-- =============================================================================
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- ── Tables (CREATE TABLE IF NOT EXISTS is already idempotent) ────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.communication_settings (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
|
||||
default_sender_email TEXT,
|
||||
default_sender_name TEXT,
|
||||
reply_to_email TEXT,
|
||||
email_provider TEXT NOT NULL DEFAULT 'resend',
|
||||
email_footer_html TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE(brand_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.communication_templates (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
body_text TEXT NOT NULL DEFAULT '',
|
||||
body_html TEXT,
|
||||
template_type TEXT NOT NULL,
|
||||
campaign_type TEXT,
|
||||
created_by UUID,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.communication_campaigns (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
subject TEXT,
|
||||
body_text TEXT,
|
||||
body_html TEXT,
|
||||
template_id UUID REFERENCES communication_templates(id) ON DELETE SET NULL,
|
||||
campaign_type TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
audience_rules JSONB NOT NULL DEFAULT '{}',
|
||||
scheduled_at TIMESTAMPTZ,
|
||||
sent_at TIMESTAMPTZ,
|
||||
created_by UUID,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.communication_message_logs (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
|
||||
campaign_id UUID REFERENCES communication_campaigns(id) ON DELETE SET NULL,
|
||||
customer_id UUID,
|
||||
customer_email TEXT,
|
||||
delivery_method TEXT NOT NULL,
|
||||
subject TEXT,
|
||||
body_preview TEXT,
|
||||
status TEXT NOT NULL,
|
||||
sent_at TIMESTAMPTZ,
|
||||
error_message TEXT,
|
||||
event_type TEXT,
|
||||
event_id UUID,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.customer_communication_preferences (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
customer_id UUID NOT NULL,
|
||||
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
|
||||
email_opt_in BOOLEAN NOT NULL DEFAULT true,
|
||||
sms_opt_in BOOLEAN NOT NULL DEFAULT false,
|
||||
email_opt_in_at TIMESTAMPTZ,
|
||||
sms_opt_in_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE(customer_id, brand_id)
|
||||
);
|
||||
|
||||
-- ── Indexes (CREATE INDEX IF NOT EXISTS is already idempotent) ────────────
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_communication_settings_brand
|
||||
ON public.communication_settings(brand_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_communication_templates_brand
|
||||
ON public.communication_templates(brand_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_communication_campaigns_brand_status
|
||||
ON public.communication_campaigns(brand_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_communication_campaigns_brand_type
|
||||
ON public.communication_campaigns(brand_id, campaign_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_communication_message_logs_brand_sent
|
||||
ON public.communication_message_logs(brand_id, sent_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_communication_message_logs_campaign
|
||||
ON public.communication_message_logs(campaign_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_communication_message_logs_customer
|
||||
ON public.communication_message_logs(customer_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_communication_message_logs_event
|
||||
ON public.communication_message_logs(event_type, event_id) WHERE event_type IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_customer_comm_prefs_customer_brand
|
||||
ON public.customer_communication_preferences(customer_id, brand_id);
|
||||
|
||||
-- ── RLS — idempotent (ALTER TABLE ENABLE is safe to re-run) ────────────────
|
||||
|
||||
ALTER TABLE public.communication_settings ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.communication_templates ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.communication_campaigns ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.communication_message_logs ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.customer_communication_preferences ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- ── Policies (DROP IF EXISTS before each CREATE makes them idempotent) ────
|
||||
|
||||
DROP POLICY IF EXISTS "Platform admin can read communication_settings"
|
||||
ON public.communication_settings;
|
||||
DROP POLICY IF EXISTS "Brand admin can read own brand communication_settings"
|
||||
ON public.communication_settings;
|
||||
CREATE POLICY "Platform admin can read communication_settings"
|
||||
ON public.communication_settings FOR SELECT TO authenticated
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM admin_users
|
||||
WHERE admin_users.user_id = auth.uid()
|
||||
AND admin_users.role = 'platform_admin'
|
||||
));
|
||||
CREATE POLICY "Brand admin can read own brand communication_settings"
|
||||
ON public.communication_settings FOR SELECT TO authenticated
|
||||
USING (brand_id IN (
|
||||
SELECT brand_id FROM admin_users
|
||||
WHERE admin_users.user_id = auth.uid()
|
||||
AND admin_users.role = 'brand_admin'
|
||||
));
|
||||
|
||||
DROP POLICY IF EXISTS "Platform admin can read communication_templates"
|
||||
ON public.communication_templates;
|
||||
DROP POLICY IF EXISTS "Brand admin can read own brand communication_templates"
|
||||
ON public.communication_templates;
|
||||
CREATE POLICY "Platform admin can read communication_templates"
|
||||
ON public.communication_templates FOR SELECT TO authenticated
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM admin_users
|
||||
WHERE admin_users.user_id = auth.uid()
|
||||
AND admin_users.role = 'platform_admin'
|
||||
));
|
||||
CREATE POLICY "Brand admin can read own brand communication_templates"
|
||||
ON public.communication_templates FOR SELECT TO authenticated
|
||||
USING (brand_id IN (
|
||||
SELECT brand_id FROM admin_users
|
||||
WHERE admin_users.user_id = auth.uid()
|
||||
AND admin_users.role = 'brand_admin'
|
||||
));
|
||||
|
||||
DROP POLICY IF EXISTS "Platform admin can read communication_campaigns"
|
||||
ON public.communication_campaigns;
|
||||
DROP POLICY IF EXISTS "Brand admin can read own brand communication_campaigns"
|
||||
ON public.communication_campaigns;
|
||||
CREATE POLICY "Platform admin can read communication_campaigns"
|
||||
ON public.communication_campaigns FOR SELECT TO authenticated
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM admin_users
|
||||
WHERE admin_users.user_id = auth.uid()
|
||||
AND admin_users.role = 'platform_admin'
|
||||
));
|
||||
CREATE POLICY "Brand admin can read own brand communication_campaigns"
|
||||
ON public.communication_campaigns FOR SELECT TO authenticated
|
||||
USING (brand_id IN (
|
||||
SELECT brand_id FROM admin_users
|
||||
WHERE admin_users.user_id = auth.uid()
|
||||
AND admin_users.role = 'brand_admin'
|
||||
));
|
||||
|
||||
DROP POLICY IF EXISTS "Platform admin can read communication_message_logs"
|
||||
ON public.communication_message_logs;
|
||||
DROP POLICY IF EXISTS "Brand admin can read own brand communication_message_logs"
|
||||
ON public.communication_message_logs;
|
||||
CREATE POLICY "Platform admin can read communication_message_logs"
|
||||
ON public.communication_message_logs FOR SELECT TO authenticated
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM admin_users
|
||||
WHERE admin_users.user_id = auth.uid()
|
||||
AND admin_users.role = 'platform_admin'
|
||||
));
|
||||
CREATE POLICY "Brand admin can read own brand communication_message_logs"
|
||||
ON public.communication_message_logs FOR SELECT TO authenticated
|
||||
USING (brand_id IN (
|
||||
SELECT brand_id FROM admin_users
|
||||
WHERE admin_users.user_id = auth.uid()
|
||||
AND admin_users.role = 'brand_admin'
|
||||
));
|
||||
|
||||
DROP POLICY IF EXISTS "Anyone can read customer_communication_preferences"
|
||||
ON public.customer_communication_preferences;
|
||||
DROP POLICY IF EXISTS "Platform admin can read customer_communication_preferences"
|
||||
ON public.customer_communication_preferences;
|
||||
CREATE POLICY "Anyone can read customer_communication_preferences"
|
||||
ON public.customer_communication_preferences FOR SELECT TO anon USING (true);
|
||||
CREATE POLICY "Platform admin can read customer_communication_preferences"
|
||||
ON public.customer_communication_preferences FOR SELECT TO authenticated
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM admin_users
|
||||
WHERE admin_users.user_id = auth.uid()
|
||||
AND admin_users.role = 'platform_admin'
|
||||
));
|
||||
|
||||
-- ── SECURITY DEFINER RPCs ─────────────────────────────────────────────────
|
||||
|
||||
DROP FUNCTION IF EXISTS public.upsert_communication_settings(UUID, TEXT, TEXT, TEXT, TEXT, TEXT);
|
||||
CREATE OR REPLACE FUNCTION public.upsert_communication_settings(
|
||||
p_brand_id UUID,
|
||||
p_sender_email TEXT,
|
||||
p_sender_name TEXT,
|
||||
p_reply_to_email TEXT,
|
||||
p_provider TEXT,
|
||||
p_footer_html TEXT
|
||||
)
|
||||
RETURNS communication_settings
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE v_result communication_settings;
|
||||
BEGIN
|
||||
INSERT INTO public.communication_settings
|
||||
(brand_id, default_sender_email, default_sender_name, reply_to_email, email_provider, email_footer_html)
|
||||
VALUES (p_brand_id, p_sender_email, p_sender_name, p_reply_to_email, p_provider, p_footer_html)
|
||||
ON CONFLICT (brand_id) DO UPDATE SET
|
||||
default_sender_email = EXCLUDED.default_sender_email,
|
||||
default_sender_name = EXCLUDED.default_sender_name,
|
||||
reply_to_email = EXCLUDED.reply_to_email,
|
||||
email_provider = EXCLUDED.email_provider,
|
||||
email_footer_html = EXCLUDED.email_footer_html,
|
||||
updated_at = now()
|
||||
RETURNING * INTO v_result;
|
||||
RETURN v_result;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP FUNCTION IF EXISTS public.upsert_communication_template(UUID, UUID, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, UUID);
|
||||
CREATE OR REPLACE FUNCTION public.upsert_communication_template(
|
||||
p_id UUID,
|
||||
p_brand_id UUID,
|
||||
p_name TEXT,
|
||||
p_subject TEXT,
|
||||
p_body_text TEXT,
|
||||
p_body_html TEXT,
|
||||
p_template_type TEXT,
|
||||
p_campaign_type TEXT,
|
||||
p_created_by UUID
|
||||
)
|
||||
RETURNS communication_templates
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_result communication_templates;
|
||||
v_id UUID;
|
||||
BEGIN
|
||||
v_id := coalesce(p_id, uuid_generate_v4());
|
||||
INSERT INTO public.communication_templates
|
||||
(id, brand_id, name, subject, body_text, body_html, template_type, campaign_type, created_by)
|
||||
VALUES (v_id, p_brand_id, p_name, p_subject, p_body_text, p_body_html, p_template_type, p_campaign_type, p_created_by)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
subject = EXCLUDED.subject,
|
||||
body_text = EXCLUDED.body_text,
|
||||
body_html = EXCLUDED.body_html,
|
||||
template_type = EXCLUDED.template_type,
|
||||
campaign_type = EXCLUDED.campaign_type,
|
||||
updated_at = now()
|
||||
RETURNING * INTO v_result;
|
||||
RETURN v_result;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP FUNCTION IF EXISTS public.upsert_communication_campaign(UUID, UUID, TEXT, TEXT, TEXT, TEXT, UUID, TEXT, TEXT, JSONB, TIMESTAMPTZ, UUID);
|
||||
CREATE OR REPLACE FUNCTION public.upsert_communication_campaign(
|
||||
p_id UUID,
|
||||
p_brand_id UUID,
|
||||
p_name TEXT,
|
||||
p_subject TEXT,
|
||||
p_body_text TEXT,
|
||||
p_body_html TEXT,
|
||||
p_template_id UUID,
|
||||
p_campaign_type TEXT,
|
||||
p_status TEXT,
|
||||
p_audience_rules JSONB,
|
||||
p_scheduled_at TIMESTAMPTZ,
|
||||
p_created_by UUID
|
||||
)
|
||||
RETURNS communication_campaigns
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_result communication_campaigns;
|
||||
v_id UUID;
|
||||
BEGIN
|
||||
v_id := coalesce(p_id, uuid_generate_v4());
|
||||
INSERT INTO public.communication_campaigns
|
||||
(id, brand_id, name, subject, body_text, body_html, template_id, campaign_type, status, audience_rules, scheduled_at, created_by)
|
||||
VALUES (v_id, p_brand_id, p_name, p_subject, p_body_text, p_body_html, p_template_id, p_campaign_type, p_status, p_audience_rules, p_scheduled_at, p_created_by)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
subject = EXCLUDED.subject,
|
||||
body_text = EXCLUDED.body_text,
|
||||
body_html = EXCLUDED.body_html,
|
||||
template_id = EXCLUDED.template_id,
|
||||
campaign_type = EXCLUDED.campaign_type,
|
||||
status = EXCLUDED.status,
|
||||
audience_rules = EXCLUDED.audience_rules,
|
||||
scheduled_at = EXCLUDED.scheduled_at,
|
||||
updated_at = now()
|
||||
WHERE communication_campaigns.brand_id = p_brand_id
|
||||
RETURNING * INTO v_result;
|
||||
RETURN v_result;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP FUNCTION IF EXISTS public.log_communication_messages(JSONB);
|
||||
CREATE OR REPLACE FUNCTION public.log_communication_messages(p_entries JSONB)
|
||||
RETURNS integer
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_entry JSONB;
|
||||
v_inserted_count integer := 0;
|
||||
BEGIN
|
||||
FOR v_entry IN SELECT * FROM jsonb_array_elements(p_entries)
|
||||
LOOP
|
||||
INSERT INTO public.communication_message_logs
|
||||
(brand_id, campaign_id, customer_id, customer_email, delivery_method,
|
||||
subject, body_preview, status, sent_at, error_message)
|
||||
VALUES (
|
||||
(v_entry->>'brand_id')::UUID,
|
||||
nullif(v_entry->>'campaign_id', '')::UUID,
|
||||
nullif(v_entry->>'customer_id', '')::UUID,
|
||||
v_entry->>'customer_email',
|
||||
v_entry->>'delivery_method',
|
||||
v_entry->>'subject',
|
||||
left(v_entry->>'body_preview', 500),
|
||||
coalesce(v_entry->>'status', 'queued'),
|
||||
now(),
|
||||
v_entry->>'error_message'
|
||||
);
|
||||
v_inserted_count := v_inserted_count + 1;
|
||||
END LOOP;
|
||||
RETURN v_inserted_count;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP FUNCTION IF EXISTS public.opt_out_customer(UUID, UUID, TEXT);
|
||||
CREATE OR REPLACE FUNCTION public.opt_out_customer(
|
||||
p_customer_id UUID,
|
||||
p_brand_id UUID,
|
||||
p_method TEXT
|
||||
)
|
||||
RETURNS customer_communication_preferences
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE v_result customer_communication_preferences;
|
||||
BEGIN
|
||||
INSERT INTO public.customer_communication_preferences
|
||||
(customer_id, brand_id, email_opt_in, sms_opt_in, email_opt_in_at, sms_opt_in_at)
|
||||
VALUES (
|
||||
p_customer_id, p_brand_id,
|
||||
CASE WHEN p_method = 'email' THEN false ELSE true END,
|
||||
CASE WHEN p_method = 'sms' THEN false ELSE true END,
|
||||
CASE WHEN p_method = 'email' THEN now() ELSE NULL END,
|
||||
CASE WHEN p_method = 'sms' THEN now() ELSE NULL END
|
||||
)
|
||||
ON CONFLICT (customer_id, brand_id) DO UPDATE SET
|
||||
email_opt_in = CASE WHEN p_method = 'email' THEN false ELSE customer_communication_preferences.email_opt_in END,
|
||||
sms_opt_in = CASE WHEN p_method = 'sms' THEN false ELSE customer_communication_preferences.sms_opt_in END,
|
||||
email_opt_in_at = CASE WHEN p_method = 'email' THEN now() ELSE email_opt_in_at END,
|
||||
sms_opt_in_at = CASE WHEN p_method = 'sms' THEN now() ELSE sms_opt_in_at END,
|
||||
updated_at = now()
|
||||
RETURNING * INTO v_result;
|
||||
RETURN v_result;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP FUNCTION IF EXISTS public.preview_campaign_audience(UUID, JSONB);
|
||||
CREATE OR REPLACE FUNCTION public.preview_campaign_audience(
|
||||
p_brand_id UUID,
|
||||
p_audience_rules JSONB
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_target TEXT;
|
||||
v_stop_id UUID;
|
||||
v_date_from TIMESTAMPTZ;
|
||||
v_date_to TIMESTAMPTZ;
|
||||
v_city TEXT;
|
||||
v_order_hist TEXT;
|
||||
v_days_back INTEGER;
|
||||
v_product_id UUID;
|
||||
v_count INTEGER;
|
||||
v_customers JSONB;
|
||||
BEGIN
|
||||
v_target := p_audience_rules->>'target';
|
||||
|
||||
-- Stop-based targeting
|
||||
IF v_target = 'stop' THEN
|
||||
v_stop_id := nullif(p_audience_rules->>'stop_id', '')::UUID;
|
||||
v_date_from := nullif(p_audience_rules->>'date_from', '')::TIMESTAMPTZ;
|
||||
v_date_to := nullif(p_audience_rules->>'date_to', '')::TIMESTAMPTZ;
|
||||
SELECT COUNT(DISTINCT o.customer_id),
|
||||
jsonb_agg(DISTINCT jsonb_build_object('id', o.customer_id, 'email', o.customer_email, 'name', o.customer_name) ORDER BY o.customer_name)
|
||||
INTO v_count, v_customers
|
||||
FROM orders o
|
||||
JOIN stops s ON s.id = o.stop_id
|
||||
LEFT JOIN customer_communication_preferences cp ON cp.customer_id = o.customer_id AND cp.brand_id = p_brand_id
|
||||
WHERE s.brand_id = p_brand_id AND o.status NOT IN ('canceled')
|
||||
AND (v_stop_id IS NULL OR o.stop_id = v_stop_id)
|
||||
AND (v_date_from IS NULL OR o.created_at >= v_date_from)
|
||||
AND (v_date_to IS NULL OR o.created_at <= v_date_to)
|
||||
AND (cp.email_opt_in IS NULL OR cp.email_opt_in = true);
|
||||
|
||||
-- ZIP/city targeting (customer ZIP not in orders — city filter only)
|
||||
ELSIF v_target = 'zip_code' THEN
|
||||
v_city := p_audience_rules->>'city';
|
||||
SELECT COUNT(DISTINCT o.customer_id),
|
||||
jsonb_agg(DISTINCT jsonb_build_object('id', o.customer_id, 'email', o.customer_email, 'name', o.customer_name) ORDER BY o.customer_name)
|
||||
INTO v_count, v_customers
|
||||
FROM orders o
|
||||
JOIN stops s ON s.id = o.stop_id
|
||||
LEFT JOIN customer_communication_preferences cp ON cp.customer_id = o.customer_id AND cp.brand_id = p_brand_id
|
||||
WHERE s.brand_id = p_brand_id AND o.status NOT IN ('canceled')
|
||||
AND (cp.email_opt_in IS NULL OR cp.email_opt_in = true)
|
||||
AND (v_city IS NULL OR true); -- city filter placeholder; ZIP requires customer_zip column in orders
|
||||
|
||||
-- Customer history targeting
|
||||
ELSIF v_target = 'customer_history' THEN
|
||||
v_order_hist := p_audience_rules->>'order_history';
|
||||
v_days_back := (p_audience_rules->>'days_back')::INTEGER;
|
||||
|
||||
IF v_order_hist = 'first_order' THEN
|
||||
WITH first_orders AS (
|
||||
SELECT o.customer_id, MIN(o.created_at)
|
||||
FROM orders o JOIN stops s ON s.id = o.stop_id
|
||||
WHERE s.brand_id = p_brand_id AND o.status NOT IN ('canceled')
|
||||
AND (v_days_back IS NULL OR o.created_at >= now() - (v_days_back || ' days')::INTERVAL)
|
||||
GROUP BY o.customer_id HAVING COUNT(*) = 1
|
||||
)
|
||||
SELECT COUNT(*),
|
||||
jsonb_agg(DISTINCT jsonb_build_object('id', fo.customer_id, 'email', o.customer_email, 'name', o.customer_name) ORDER BY o.customer_name)
|
||||
INTO v_count, v_customers
|
||||
FROM first_orders fo
|
||||
JOIN orders o ON o.customer_id = fo.customer_id
|
||||
LEFT JOIN customer_communication_preferences cp ON cp.customer_id = fo.customer_id AND cp.brand_id = p_brand_id
|
||||
WHERE (cp.email_opt_in IS NULL OR cp.email_opt_in = true);
|
||||
|
||||
ELSIF v_order_hist = 'repeat' THEN
|
||||
WITH repeat_customer_ids AS (
|
||||
SELECT o.customer_id
|
||||
FROM orders o JOIN stops s ON s.id = o.stop_id
|
||||
WHERE s.brand_id = p_brand_id AND o.status NOT IN ('canceled')
|
||||
AND (v_days_back IS NULL OR o.created_at >= now() - (v_days_back || ' days')::INTERVAL)
|
||||
GROUP BY o.customer_id HAVING COUNT(*) > 1
|
||||
)
|
||||
SELECT COUNT(*),
|
||||
jsonb_agg(DISTINCT jsonb_build_object('id', rc.customer_id, 'email', o.customer_email, 'name', o.customer_name) ORDER BY o.customer_name)
|
||||
INTO v_count, v_customers
|
||||
FROM repeat_customer_ids rc
|
||||
JOIN orders o ON o.customer_id = rc.customer_id
|
||||
LEFT JOIN customer_communication_preferences cp ON cp.customer_id = rc.customer_id AND cp.brand_id = p_brand_id
|
||||
WHERE (cp.email_opt_in IS NULL OR cp.email_opt_in = true);
|
||||
|
||||
ELSE
|
||||
SELECT COUNT(DISTINCT o.customer_id),
|
||||
jsonb_agg(DISTINCT jsonb_build_object('id', o.customer_id, 'email', o.customer_email, 'name', o.customer_name) ORDER BY o.customer_name)
|
||||
INTO v_count, v_customers
|
||||
FROM orders o
|
||||
JOIN stops s ON s.id = o.stop_id
|
||||
LEFT JOIN customer_communication_preferences cp ON cp.customer_id = o.customer_id AND cp.brand_id = p_brand_id
|
||||
WHERE s.brand_id = p_brand_id AND o.status NOT IN ('canceled')
|
||||
AND (v_days_back IS NULL OR o.created_at >= now() - (v_days_back || ' days')::INTERVAL)
|
||||
AND (cp.email_opt_in IS NULL OR cp.email_opt_in = true);
|
||||
END IF;
|
||||
|
||||
-- Product-based targeting
|
||||
ELSIF v_target = 'product' THEN
|
||||
v_product_id := nullif(p_audience_rules->>'product_id', '')::UUID;
|
||||
SELECT COUNT(DISTINCT o.customer_id),
|
||||
jsonb_agg(DISTINCT jsonb_build_object('id', o.customer_id, 'email', o.customer_email, 'name', o.customer_name) ORDER BY o.customer_name)
|
||||
INTO v_count, v_customers
|
||||
FROM order_items oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
JOIN stops s ON s.id = o.stop_id
|
||||
LEFT JOIN customer_communication_preferences cp ON cp.customer_id = o.customer_id AND cp.brand_id = p_brand_id
|
||||
WHERE s.brand_id = p_brand_id AND o.status NOT IN ('canceled')
|
||||
AND (v_product_id IS NULL OR oi.product_id = v_product_id)
|
||||
AND (cp.email_opt_in IS NULL OR cp.email_opt_in = true);
|
||||
|
||||
-- Explicit customer ID list
|
||||
ELSIF v_target = 'customer_ids' THEN
|
||||
SELECT COUNT(*),
|
||||
jsonb_agg(jsonb_build_object('id', o.customer_id, 'email', o.customer_email, 'name', o.customer_name) ORDER BY o.customer_name)
|
||||
INTO v_count, v_customers
|
||||
FROM orders o
|
||||
LEFT JOIN customer_communication_preferences cp ON cp.customer_id = o.customer_id AND cp.brand_id = p_brand_id
|
||||
WHERE o.customer_id IN (
|
||||
SELECT jsonb_array_elements_text(p_audience_rules->'customer_ids')::UUID
|
||||
)
|
||||
AND (cp.email_opt_in IS NULL OR cp.email_opt_in = true);
|
||||
|
||||
ELSE
|
||||
-- all_customers or unknown
|
||||
SELECT COUNT(DISTINCT o.customer_id),
|
||||
jsonb_agg(DISTINCT jsonb_build_object('id', o.customer_id, 'email', o.customer_email, 'name', o.customer_name) ORDER BY o.customer_name)
|
||||
INTO v_count, v_customers
|
||||
FROM orders o
|
||||
JOIN stops s ON s.id = o.stop_id
|
||||
LEFT JOIN customer_communication_preferences cp ON cp.customer_id = o.customer_id AND cp.brand_id = p_brand_id
|
||||
WHERE s.brand_id = p_brand_id AND o.status NOT IN ('canceled')
|
||||
AND (cp.email_opt_in IS NULL OR cp.email_opt_in = true);
|
||||
END IF;
|
||||
|
||||
-- Cap sample at 20
|
||||
SELECT jsonb_agg(x ORDER BY x->>'name') INTO v_customers
|
||||
FROM (SELECT * FROM jsonb_array_elements(coalesce(v_customers, '[]'::jsonb)) LIMIT 20) t(x);
|
||||
|
||||
RETURN jsonb_build_object('count', v_count, 'sample_customers', v_customers);
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP FUNCTION IF EXISTS public.send_campaign(UUID);
|
||||
CREATE OR REPLACE FUNCTION public.send_campaign(p_campaign_id UUID)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_campaign communication_campaigns;
|
||||
v_settings communication_settings;
|
||||
v_audience JSONB;
|
||||
v_entries JSONB := '[]'::JSONB;
|
||||
v_customer JSONB;
|
||||
v_count INTEGER := 0;
|
||||
BEGIN
|
||||
SELECT * INTO v_campaign FROM communication_campaigns WHERE id = p_campaign_id;
|
||||
IF NOT FOUND THEN RETURN jsonb_build_object('success', false, 'error', 'Campaign not found'); END IF;
|
||||
SELECT * INTO v_settings FROM communication_settings WHERE brand_id = v_campaign.brand_id;
|
||||
IF NOT FOUND THEN RETURN jsonb_build_object('success', false, 'error', 'No communication settings for brand'); END IF;
|
||||
v_audience := preview_campaign_audience(v_campaign.brand_id, v_campaign.audience_rules);
|
||||
FOR v_customer IN SELECT * FROM jsonb_array_elements(coalesce(v_audience->'sample_customers', '[]'::jsonb))
|
||||
LOOP
|
||||
v_entries := v_entries || jsonb_build_array(jsonb_build_object(
|
||||
'brand_id', v_campaign.brand_id, 'campaign_id', p_campaign_id,
|
||||
'customer_id', v_customer->>'id', 'customer_email', v_customer->>'email',
|
||||
'delivery_method', 'email', 'subject', v_campaign.subject,
|
||||
'body_preview', left(v_campaign.body_text, 500), 'status', 'queued'
|
||||
));
|
||||
v_count := v_count + 1;
|
||||
END LOOP;
|
||||
PERFORM log_communication_messages(v_entries);
|
||||
UPDATE communication_campaigns SET status = 'sent', sent_at = now(), updated_at = now()
|
||||
WHERE id = p_campaign_id;
|
||||
RETURN jsonb_build_object('success', true, 'messages_logged', v_count);
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP FUNCTION IF EXISTS public.get_communication_campaigns(UUID);
|
||||
CREATE OR REPLACE FUNCTION public.get_communication_campaigns(p_brand_id UUID)
|
||||
RETURNS JSONB LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE v_campaigns JSONB;
|
||||
BEGIN
|
||||
IF p_brand_id IS NULL THEN
|
||||
SELECT COALESCE(jsonb_agg(c ORDER BY c.created_at DESC), '[]'::JSONB) INTO v_campaigns FROM communication_campaigns c;
|
||||
ELSE
|
||||
SELECT COALESCE(jsonb_agg(c ORDER BY c.created_at DESC), '[]'::JSONB) INTO v_campaigns
|
||||
FROM communication_campaigns c WHERE c.brand_id = p_brand_id;
|
||||
END IF;
|
||||
RETURN jsonb_build_object('campaigns', v_campaigns);
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP FUNCTION IF EXISTS public.get_communication_campaign_by_id(UUID);
|
||||
CREATE OR REPLACE FUNCTION public.get_communication_campaign_by_id(p_campaign_id UUID)
|
||||
RETURNS JSONB LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE v_campaign communication_campaigns%ROWTYPE;
|
||||
BEGIN
|
||||
SELECT * INTO v_campaign FROM communication_campaigns WHERE id = p_campaign_id;
|
||||
IF NOT FOUND THEN RETURN jsonb_build_object('campaign', NULL); END IF;
|
||||
RETURN jsonb_build_object('campaign', row_to_json(v_campaign));
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP FUNCTION IF EXISTS public.get_communication_templates(UUID);
|
||||
CREATE OR REPLACE FUNCTION public.get_communication_templates(p_brand_id UUID)
|
||||
RETURNS JSONB LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE v_templates JSONB;
|
||||
BEGIN
|
||||
IF p_brand_id IS NULL THEN
|
||||
SELECT COALESCE(jsonb_agg(t ORDER BY t.name), '[]'::JSONB) INTO v_templates FROM communication_templates t;
|
||||
ELSE
|
||||
SELECT COALESCE(jsonb_agg(t ORDER BY t.name), '[]'::JSONB) INTO v_templates
|
||||
FROM communication_templates t WHERE t.brand_id = p_brand_id;
|
||||
END IF;
|
||||
RETURN jsonb_build_object('templates', v_templates);
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP FUNCTION IF EXISTS public.get_communication_settings(UUID);
|
||||
CREATE OR REPLACE FUNCTION public.get_communication_settings(p_brand_id UUID)
|
||||
RETURNS JSONB LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE v_settings communication_settings%ROWTYPE;
|
||||
BEGIN
|
||||
SELECT * INTO v_settings FROM communication_settings WHERE brand_id = p_brand_id;
|
||||
IF NOT FOUND THEN RETURN jsonb_build_object('settings', NULL); END IF;
|
||||
RETURN jsonb_build_object('settings', row_to_json(v_settings));
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP FUNCTION IF EXISTS public.get_message_logs(UUID, UUID, TEXT, INTEGER);
|
||||
CREATE OR REPLACE FUNCTION public.get_message_logs(
|
||||
p_brand_id UUID,
|
||||
p_campaign_id UUID DEFAULT NULL,
|
||||
p_status TEXT DEFAULT NULL,
|
||||
p_limit INTEGER DEFAULT 100
|
||||
)
|
||||
RETURNS JSONB LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE v_logs JSONB;
|
||||
BEGIN
|
||||
SELECT COALESCE(jsonb_agg(l ORDER BY l.created_at DESC), '[]'::JSONB) INTO v_logs
|
||||
FROM (
|
||||
SELECT l.* FROM communication_message_logs l
|
||||
WHERE (p_brand_id IS NULL OR l.brand_id = p_brand_id)
|
||||
AND (p_campaign_id IS NULL OR l.campaign_id = p_campaign_id)
|
||||
AND (p_status IS NULL OR l.status = p_status)
|
||||
ORDER BY l.created_at DESC LIMIT p_limit
|
||||
) l;
|
||||
RETURN jsonb_build_object('logs', v_logs);
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP FUNCTION IF EXISTS public.delete_communication_campaign(UUID);
|
||||
CREATE OR REPLACE FUNCTION public.delete_communication_campaign(p_campaign_id UUID)
|
||||
RETURNS boolean LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
UPDATE communication_campaigns SET status = 'canceled', updated_at = now()
|
||||
WHERE id = p_campaign_id;
|
||||
RETURN FOUND;
|
||||
END;
|
||||
$$;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -1,878 +0,0 @@
|
||||
-- =============================================================================
|
||||
-- Communication Center V1.1 — Contacts + Import Foundation
|
||||
-- Fully idempotent: safe to re-run after partial failure
|
||||
-- Additive over V1 (migration 016):
|
||||
-- - Adds communication_contacts table
|
||||
-- - Adds DB trigger to auto-create contacts from orders
|
||||
-- - Adds new RPCs for contact CRUD, import, and opt-out
|
||||
-- - Rewrites preview_campaign_audience to use communication_contacts as primary
|
||||
-- - Updates send_campaign to resolve through communication_contacts
|
||||
-- - Updates opt_out_customer to also set unsubscribed_at on contacts
|
||||
-- =============================================================================
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- ── Table ────────────────────────────────────────────────────────────────────
|
||||
-- Uses CREATE TABLE IF NOT EXISTS so this is safe to re-run — no data loss.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.communication_contacts (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
|
||||
email TEXT,
|
||||
phone TEXT,
|
||||
first_name TEXT,
|
||||
last_name TEXT,
|
||||
full_name TEXT,
|
||||
source TEXT NOT NULL, -- 'order' | 'import' | 'manual' | 'admin'
|
||||
external_id TEXT,
|
||||
customer_id UUID,
|
||||
email_opt_in BOOLEAN NOT NULL DEFAULT true,
|
||||
sms_opt_in BOOLEAN NOT NULL DEFAULT false,
|
||||
email_opt_in_at TIMESTAMPTZ,
|
||||
sms_opt_in_at TIMESTAMPTZ,
|
||||
unsubscribed_at TIMESTAMPTZ,
|
||||
tags TEXT[] DEFAULT '{}',
|
||||
metadata JSONB DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT chk_has_email_or_phone CHECK (email IS NOT NULL OR phone IS NOT NULL)
|
||||
);
|
||||
|
||||
-- ── Indexes ─────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_contacts_brand_email_unique
|
||||
ON public.communication_contacts(brand_id, email)
|
||||
WHERE email IS NOT NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_contacts_brand_phone_unique
|
||||
ON public.communication_contacts(brand_id, phone)
|
||||
WHERE phone IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_communication_contacts_brand
|
||||
ON public.communication_contacts(brand_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_communication_contacts_customer
|
||||
ON public.communication_contacts(customer_id)
|
||||
WHERE customer_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_communication_contacts_unsubscribed
|
||||
ON public.communication_contacts(brand_id, unsubscribed_at)
|
||||
WHERE unsubscribed_at IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_communication_contacts_source
|
||||
ON public.communication_contacts(brand_id, source);
|
||||
|
||||
-- ── RLS ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
ALTER TABLE public.communication_contacts ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS "Platform admin can read communication_contacts"
|
||||
ON public.communication_contacts;
|
||||
CREATE POLICY "Platform admin can read communication_contacts"
|
||||
ON public.communication_contacts FOR SELECT TO authenticated
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM admin_users
|
||||
WHERE admin_users.user_id = auth.uid()
|
||||
AND admin_users.role = 'platform_admin'
|
||||
));
|
||||
|
||||
DROP POLICY IF EXISTS "Brand admin can read own brand communication_contacts"
|
||||
ON public.communication_contacts;
|
||||
CREATE POLICY "Brand admin can read own brand communication_contacts"
|
||||
ON public.communication_contacts FOR SELECT TO authenticated
|
||||
USING (brand_id IN (
|
||||
SELECT brand_id FROM admin_users
|
||||
WHERE admin_users.user_id = auth.uid()
|
||||
AND admin_users.role = 'brand_admin'
|
||||
));
|
||||
|
||||
-- ── SECURITY DEFINER RPCs ────────────────────────────────────────────────────
|
||||
|
||||
-- 1. Trigger function for auto-creating contact from order
|
||||
-- PostgreSQL trigger functions MUST use RETURNS TRIGGER and access NEW/OLD directly.
|
||||
DROP FUNCTION IF EXISTS public.upsert_contact_from_order();
|
||||
CREATE OR REPLACE FUNCTION public.upsert_contact_from_order()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_brand_id UUID;
|
||||
BEGIN
|
||||
-- Skip if no email (can't upsert without a key)
|
||||
IF NEW.customer_email IS NULL OR NEW.customer_email = '' THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Resolve brand_id from the stop
|
||||
SELECT brand_id INTO v_brand_id FROM public.stops WHERE id = NEW.stop_id;
|
||||
IF NOT FOUND THEN
|
||||
RETURN NEW; -- stop not found; skip silently
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.communication_contacts
|
||||
(brand_id, email, phone, full_name, source, customer_id, email_opt_in, metadata)
|
||||
VALUES (
|
||||
v_brand_id,
|
||||
NEW.customer_email,
|
||||
NEW.customer_phone,
|
||||
NEW.customer_name,
|
||||
'order',
|
||||
NEW.customer_id,
|
||||
true,
|
||||
jsonb_build_object(
|
||||
'last_order_id', NEW.id,
|
||||
'last_order_at', now()::TEXT,
|
||||
'stop_id', NEW.stop_id::TEXT
|
||||
)
|
||||
)
|
||||
ON CONFLICT (brand_id, email) WHERE email IS NOT NULL DO UPDATE SET
|
||||
full_name = COALESCE(EXCLUDED.full_name, communication_contacts.full_name),
|
||||
phone = COALESCE(EXCLUDED.phone, communication_contacts.phone),
|
||||
customer_id = COALESCE(EXCLUDED.customer_id, communication_contacts.customer_id),
|
||||
metadata = jsonb_build_object(
|
||||
'last_order_id', communication_contacts.metadata->>'last_order_id',
|
||||
'last_order_at', communication_contacts.metadata->>'last_order_at',
|
||||
'stop_id', NEW.stop_id::TEXT
|
||||
),
|
||||
updated_at = now();
|
||||
-- NOTE: email_opt_in, unsubscribed_at, sms_opt_in are intentionally NOT
|
||||
-- updated here — we preserve the existing opt-out status.
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- 2. Trigger on orders INSERT
|
||||
-- Trigger functions receive NEW implicitly; no arguments after func name.
|
||||
DROP TRIGGER IF EXISTS trg_create_contact_from_order ON public.orders;
|
||||
CREATE TRIGGER trg_create_contact_from_order
|
||||
AFTER INSERT ON public.orders
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.upsert_contact_from_order();
|
||||
|
||||
-- 3. upsert_communication_contact (general upsert by email or phone)
|
||||
DROP FUNCTION IF EXISTS public.upsert_communication_contact(
|
||||
UUID, UUID, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, BOOLEAN, BOOLEAN, TEXT[], JSONB
|
||||
);
|
||||
CREATE OR REPLACE FUNCTION public.upsert_communication_contact(
|
||||
p_id UUID,
|
||||
p_brand_id UUID,
|
||||
p_email TEXT,
|
||||
p_phone TEXT,
|
||||
p_first_name TEXT,
|
||||
p_last_name TEXT,
|
||||
p_full_name TEXT,
|
||||
p_source TEXT,
|
||||
p_external_id TEXT,
|
||||
p_customer_id UUID,
|
||||
p_email_opt_in BOOLEAN,
|
||||
p_sms_opt_in BOOLEAN,
|
||||
p_tags TEXT[],
|
||||
p_metadata JSONB
|
||||
)
|
||||
RETURNS communication_contacts
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_result communication_contacts;
|
||||
v_id UUID;
|
||||
BEGIN
|
||||
v_id := coalesce(p_id, uuid_generate_v4());
|
||||
|
||||
IF p_email IS NOT NULL AND p_email != '' THEN
|
||||
INSERT INTO public.communication_contacts
|
||||
(id, brand_id, email, phone, first_name, last_name, full_name, source,
|
||||
external_id, customer_id, email_opt_in, sms_opt_in, tags, metadata,
|
||||
email_opt_in_at, sms_opt_in_at)
|
||||
VALUES
|
||||
(v_id, p_brand_id, p_email, p_phone, p_first_name, p_last_name,
|
||||
p_full_name, p_source, p_external_id, p_customer_id,
|
||||
coalesce(p_email_opt_in, true), coalesce(p_sms_opt_in, false),
|
||||
coalesce(p_tags, '{}'), coalesce(p_metadata, '{}'),
|
||||
CASE WHEN p_email_opt_in = true THEN now() ELSE NULL END,
|
||||
CASE WHEN p_sms_opt_in = true THEN now() ELSE NULL END)
|
||||
ON CONFLICT (brand_id, email) WHERE email IS NOT NULL DO UPDATE SET
|
||||
phone = COALESCE(EXCLUDED.phone, communication_contacts.phone),
|
||||
first_name = COALESCE(EXCLUDED.first_name, communication_contacts.first_name),
|
||||
last_name = COALESCE(EXCLUDED.last_name, communication_contacts.last_name),
|
||||
full_name = COALESCE(EXCLUDED.full_name, communication_contacts.full_name),
|
||||
source = COALESCE(EXCLUDED.source, communication_contacts.source),
|
||||
external_id = COALESCE(EXCLUDED.external_id, communication_contacts.external_id),
|
||||
customer_id = COALESCE(EXCLUDED.customer_id, communication_contacts.customer_id),
|
||||
email_opt_in = CASE
|
||||
WHEN communication_contacts.unsubscribed_at IS NOT NULL
|
||||
THEN communication_contacts.email_opt_in
|
||||
ELSE COALESCE(EXCLUDED.email_opt_in, communication_contacts.email_opt_in)
|
||||
END,
|
||||
sms_opt_in = CASE
|
||||
WHEN communication_contacts.unsubscribed_at IS NOT NULL
|
||||
THEN communication_contacts.sms_opt_in
|
||||
ELSE COALESCE(EXCLUDED.sms_opt_in, communication_contacts.sms_opt_in)
|
||||
END,
|
||||
tags = COALESCE(EXCLUDED.tags, communication_contacts.tags),
|
||||
metadata = communication_contacts.metadata || COALESCE(EXCLUDED.metadata, '{}'),
|
||||
updated_at = now()
|
||||
RETURNING * INTO v_result;
|
||||
|
||||
ELSIF p_phone IS NOT NULL AND p_phone != '' THEN
|
||||
INSERT INTO public.communication_contacts
|
||||
(id, brand_id, email, phone, first_name, last_name, full_name, source,
|
||||
external_id, customer_id, email_opt_in, sms_opt_in, tags, metadata,
|
||||
email_opt_in_at, sms_opt_in_at)
|
||||
VALUES
|
||||
(v_id, p_brand_id, NULL, p_phone, p_first_name, p_last_name,
|
||||
p_full_name, p_source, p_external_id, p_customer_id,
|
||||
coalesce(p_email_opt_in, true), coalesce(p_sms_opt_in, false),
|
||||
coalesce(p_tags, '{}'), coalesce(p_metadata, '{}'),
|
||||
CASE WHEN p_email_opt_in = true THEN now() ELSE NULL END,
|
||||
CASE WHEN p_sms_opt_in = true THEN now() ELSE NULL END)
|
||||
ON CONFLICT (brand_id, phone) WHERE phone IS NOT NULL DO UPDATE SET
|
||||
email = COALESCE(EXCLUDED.email, communication_contacts.email),
|
||||
first_name = COALESCE(EXCLUDED.first_name, communication_contacts.first_name),
|
||||
last_name = COALESCE(EXCLUDED.last_name, communication_contacts.last_name),
|
||||
full_name = COALESCE(EXCLUDED.full_name, communication_contacts.full_name),
|
||||
source = COALESCE(EXCLUDED.source, communication_contacts.source),
|
||||
external_id = COALESCE(EXCLUDED.external_id, communication_contacts.external_id),
|
||||
customer_id = COALESCE(EXCLUDED.customer_id, communication_contacts.customer_id),
|
||||
email_opt_in = CASE
|
||||
WHEN communication_contacts.unsubscribed_at IS NOT NULL
|
||||
THEN communication_contacts.email_opt_in
|
||||
ELSE COALESCE(EXCLUDED.email_opt_in, communication_contacts.email_opt_in)
|
||||
END,
|
||||
sms_opt_in = CASE
|
||||
WHEN communication_contacts.unsubscribed_at IS NOT NULL
|
||||
THEN communication_contacts.sms_opt_in
|
||||
ELSE COALESCE(EXCLUDED.sms_opt_in, communication_contacts.sms_opt_in)
|
||||
END,
|
||||
tags = COALESCE(EXCLUDED.tags, communication_contacts.tags),
|
||||
metadata = communication_contacts.metadata || COALESCE(EXCLUDED.metadata, '{}'),
|
||||
updated_at = now()
|
||||
RETURNING * INTO v_result;
|
||||
ELSE
|
||||
RETURN NULL;
|
||||
END IF;
|
||||
|
||||
RETURN v_result;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- 4. get_communication_contacts (paginated list with search)
|
||||
DROP FUNCTION IF EXISTS public.get_communication_contacts(UUID, TEXT, TEXT, INTEGER, INTEGER);
|
||||
CREATE OR REPLACE FUNCTION public.get_communication_contacts(
|
||||
p_brand_id UUID,
|
||||
p_search TEXT DEFAULT NULL,
|
||||
p_source TEXT DEFAULT NULL,
|
||||
p_limit INTEGER DEFAULT 100,
|
||||
p_offset INTEGER DEFAULT 0
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_contacts JSONB;
|
||||
v_total INTEGER;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO v_total
|
||||
FROM public.communication_contacts c
|
||||
WHERE (p_brand_id IS NULL OR c.brand_id = p_brand_id)
|
||||
AND (p_source IS NULL OR c.source = p_source)
|
||||
AND (
|
||||
p_search IS NULL
|
||||
OR c.email ILIKE '%' || p_search || '%'
|
||||
OR c.full_name ILIKE '%' || p_search || '%'
|
||||
OR c.phone ILIKE '%' || p_search || '%'
|
||||
);
|
||||
|
||||
SELECT COALESCE(jsonb_agg(c ORDER BY c.created_at DESC), '[]'::JSONB) INTO v_contacts
|
||||
FROM (
|
||||
SELECT c.*
|
||||
FROM public.communication_contacts c
|
||||
WHERE (p_brand_id IS NULL OR c.brand_id = p_brand_id)
|
||||
AND (p_source IS NULL OR c.source = p_source)
|
||||
AND (
|
||||
p_search IS NULL
|
||||
OR c.email ILIKE '%' || p_search || '%'
|
||||
OR c.full_name ILIKE '%' || p_search || '%'
|
||||
OR c.phone ILIKE '%' || p_search || '%'
|
||||
)
|
||||
ORDER BY c.created_at DESC
|
||||
LIMIT p_limit
|
||||
OFFSET p_offset
|
||||
) c;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'contacts', v_contacts,
|
||||
'total', v_total,
|
||||
'limit', p_limit,
|
||||
'offset', p_offset
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- 5. import_communication_contacts_batch (bulk CSV upsert)
|
||||
-- Returns: { created: n, updated: n, skipped: n, errors: [...] }
|
||||
-- tags are stored as TEXT[]; CSV string "tag1;tag2" is split with string_to_array.
|
||||
DROP FUNCTION IF EXISTS public.import_communication_contacts_batch(UUID, JSONB, BOOLEAN);
|
||||
CREATE OR REPLACE FUNCTION public.import_communication_contacts_batch(
|
||||
p_brand_id UUID,
|
||||
p_contacts JSONB,
|
||||
p_allow_opt_in_override BOOLEAN DEFAULT false
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_entry JSONB;
|
||||
v_result JSONB := '{"created": 0, "updated": 0, "skipped": 0, "errors": []}'::JSONB;
|
||||
v_existing communication_contacts%ROWTYPE;
|
||||
v_email TEXT;
|
||||
v_phone TEXT;
|
||||
v_tags TEXT[];
|
||||
BEGIN
|
||||
FOR v_entry IN SELECT * FROM jsonb_array_elements(p_contacts)
|
||||
LOOP
|
||||
v_email := nullif(v_entry->>'email', '');
|
||||
v_phone := nullif(v_entry->>'phone', '');
|
||||
|
||||
-- Parse tags: CSV sends "tag1;tag2" as a plain string; split into TEXT[]
|
||||
IF (v_entry->>'tags') IS NOT NULL AND (v_entry->>'tags') != '' THEN
|
||||
v_tags := coalesce(
|
||||
(SELECT array_agg(trim(x)) FROM unnest(string_to_array(v_entry->>'tags', ';')) AS x WHERE trim(x) != ''),
|
||||
'{}'
|
||||
);
|
||||
ELSE
|
||||
v_tags := '{}';
|
||||
END IF;
|
||||
|
||||
BEGIN
|
||||
IF v_email IS NOT NULL AND v_email != '' THEN
|
||||
SELECT * INTO v_existing
|
||||
FROM public.communication_contacts
|
||||
WHERE brand_id = p_brand_id AND email = v_email;
|
||||
|
||||
IF FOUND THEN
|
||||
-- Existing contact
|
||||
IF v_existing.unsubscribed_at IS NOT NULL AND
|
||||
coalesce((v_entry->>'email_opt_in')::BOOLEAN, false) = true AND
|
||||
p_allow_opt_in_override = false THEN
|
||||
v_result := jsonb_set(v_result, '{skipped}',
|
||||
to_jsonb((v_result->>'skipped')::INTEGER + 1));
|
||||
ELSE
|
||||
UPDATE public.communication_contacts SET
|
||||
phone = COALESCE(nullif(v_entry->>'phone', ''), phone),
|
||||
first_name = COALESCE(nullif(v_entry->>'first_name', ''), first_name),
|
||||
last_name = COALESCE(nullif(v_entry->>'last_name', ''), last_name),
|
||||
full_name = COALESCE(nullif(v_entry->>'full_name', ''), full_name),
|
||||
source = 'import',
|
||||
external_id = COALESCE(nullif(v_entry->>'external_id', ''), external_id),
|
||||
email_opt_in = CASE
|
||||
WHEN communication_contacts.unsubscribed_at IS NOT NULL
|
||||
THEN communication_contacts.email_opt_in
|
||||
ELSE coalesce(
|
||||
(v_entry->>'email_opt_in')::BOOLEAN,
|
||||
communication_contacts.email_opt_in,
|
||||
true
|
||||
)
|
||||
END,
|
||||
sms_opt_in = CASE
|
||||
WHEN communication_contacts.unsubscribed_at IS NOT NULL
|
||||
THEN communication_contacts.sms_opt_in
|
||||
ELSE coalesce(
|
||||
(v_entry->>'sms_opt_in')::BOOLEAN,
|
||||
communication_contacts.sms_opt_in,
|
||||
false
|
||||
)
|
||||
END,
|
||||
email_opt_in_at = CASE
|
||||
WHEN communication_contacts.unsubscribed_at IS NOT NULL
|
||||
THEN email_opt_in_at
|
||||
WHEN coalesce(
|
||||
(v_entry->>'email_opt_in')::BOOLEAN,
|
||||
communication_contacts.email_opt_in,
|
||||
true
|
||||
) = true THEN now()
|
||||
ELSE email_opt_in_at
|
||||
END,
|
||||
tags = CASE
|
||||
WHEN v_tags != '{}' THEN v_tags
|
||||
ELSE communication_contacts.tags
|
||||
END,
|
||||
metadata = communication_contacts.metadata || jsonb_build_object(
|
||||
'imported_at', now()::TEXT
|
||||
),
|
||||
updated_at = now()
|
||||
WHERE brand_id = p_brand_id AND email = v_email;
|
||||
|
||||
v_result := jsonb_set(v_result, '{updated}',
|
||||
to_jsonb((v_result->>'updated')::INTEGER + 1));
|
||||
END IF;
|
||||
|
||||
ELSE
|
||||
-- Insert new
|
||||
INSERT INTO public.communication_contacts
|
||||
(brand_id, email, phone, first_name, last_name, full_name, source,
|
||||
external_id, email_opt_in, sms_opt_in, email_opt_in_at, sms_opt_in_at,
|
||||
tags, metadata)
|
||||
VALUES (
|
||||
p_brand_id,
|
||||
v_email,
|
||||
nullif(v_entry->>'phone', ''),
|
||||
nullif(v_entry->>'first_name', ''),
|
||||
nullif(v_entry->>'last_name', ''),
|
||||
nullif(v_entry->>'full_name', ''),
|
||||
'import',
|
||||
nullif(v_entry->>'external_id', ''),
|
||||
coalesce((v_entry->>'email_opt_in')::BOOLEAN, true),
|
||||
coalesce((v_entry->>'sms_opt_in')::BOOLEAN, false),
|
||||
CASE WHEN coalesce((v_entry->>'email_opt_in')::BOOLEAN, true) = true THEN now() ELSE NULL END,
|
||||
CASE WHEN coalesce((v_entry->>'sms_opt_in')::BOOLEAN, false) = true THEN now() ELSE NULL END,
|
||||
v_tags,
|
||||
jsonb_build_object('imported_at', now()::TEXT)
|
||||
);
|
||||
v_result := jsonb_set(v_result, '{created}',
|
||||
to_jsonb((v_result->>'created')::INTEGER + 1));
|
||||
END IF;
|
||||
|
||||
ELSIF v_phone IS NOT NULL AND v_phone != '' THEN
|
||||
-- Upsert by phone
|
||||
SELECT * INTO v_existing
|
||||
FROM public.communication_contacts
|
||||
WHERE brand_id = p_brand_id AND phone = v_phone;
|
||||
|
||||
IF FOUND THEN
|
||||
IF v_existing.unsubscribed_at IS NOT NULL AND
|
||||
coalesce((v_entry->>'email_opt_in')::BOOLEAN, false) = true AND
|
||||
p_allow_opt_in_override = false THEN
|
||||
v_result := jsonb_set(v_result, '{skipped}',
|
||||
to_jsonb((v_result->>'skipped')::INTEGER + 1));
|
||||
ELSE
|
||||
UPDATE public.communication_contacts SET
|
||||
email = COALESCE(nullif(v_entry->>'email', ''), email),
|
||||
first_name = COALESCE(nullif(v_entry->>'first_name', ''), first_name),
|
||||
last_name = COALESCE(nullif(v_entry->>'last_name', ''), last_name),
|
||||
full_name = COALESCE(nullif(v_entry->>'full_name', ''), full_name),
|
||||
source = 'import',
|
||||
email_opt_in = CASE
|
||||
WHEN communication_contacts.unsubscribed_at IS NOT NULL
|
||||
THEN communication_contacts.email_opt_in
|
||||
ELSE coalesce(
|
||||
(v_entry->>'email_opt_in')::BOOLEAN,
|
||||
communication_contacts.email_opt_in,
|
||||
true
|
||||
)
|
||||
END,
|
||||
tags = CASE WHEN v_tags != '{}' THEN v_tags ELSE communication_contacts.tags END,
|
||||
metadata = communication_contacts.metadata || jsonb_build_object(
|
||||
'imported_at', now()::TEXT
|
||||
),
|
||||
updated_at = now()
|
||||
WHERE brand_id = p_brand_id AND phone = v_phone;
|
||||
|
||||
v_result := jsonb_set(v_result, '{updated}',
|
||||
to_jsonb((v_result->>'updated')::INTEGER + 1));
|
||||
END IF;
|
||||
ELSE
|
||||
INSERT INTO public.communication_contacts
|
||||
(brand_id, email, phone, first_name, last_name, full_name, source,
|
||||
email_opt_in, sms_opt_in, tags, metadata)
|
||||
VALUES (
|
||||
p_brand_id,
|
||||
nullif(v_entry->>'email', ''),
|
||||
v_phone,
|
||||
nullif(v_entry->>'first_name', ''),
|
||||
nullif(v_entry->>'last_name', ''),
|
||||
nullif(v_entry->>'full_name', ''),
|
||||
'import',
|
||||
coalesce((v_entry->>'email_opt_in')::BOOLEAN, true),
|
||||
coalesce((v_entry->>'sms_opt_in')::BOOLEAN, false),
|
||||
v_tags,
|
||||
jsonb_build_object('imported_at', now()::TEXT)
|
||||
);
|
||||
v_result := jsonb_set(v_result, '{created}',
|
||||
to_jsonb((v_result->>'created')::INTEGER + 1));
|
||||
END IF;
|
||||
|
||||
ELSE
|
||||
-- No email or phone
|
||||
v_result := jsonb_set(
|
||||
v_result, '{errors}',
|
||||
v_result->'errors' || jsonb_build_array(
|
||||
jsonb_build_object('row', v_entry, 'error', 'No email or phone provided')
|
||||
)
|
||||
);
|
||||
END IF;
|
||||
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
v_result := jsonb_set(
|
||||
v_result, '{errors}',
|
||||
v_result->'errors' || jsonb_build_array(
|
||||
jsonb_build_object('row', v_entry, 'error', SQLERRM)
|
||||
)
|
||||
);
|
||||
END;
|
||||
END LOOP;
|
||||
|
||||
RETURN v_result;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- 6. opt_out_contact (unsubscribe by email — public-facing, no auth required)
|
||||
DROP FUNCTION IF EXISTS public.opt_out_contact(TEXT, UUID, TEXT);
|
||||
CREATE OR REPLACE FUNCTION public.opt_out_contact(
|
||||
p_email TEXT,
|
||||
p_brand_id UUID,
|
||||
p_method TEXT
|
||||
)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
IF p_method = 'email' THEN
|
||||
UPDATE public.communication_contacts SET
|
||||
email_opt_in = false,
|
||||
email_opt_in_at = now(),
|
||||
unsubscribed_at = now(),
|
||||
updated_at = now()
|
||||
WHERE brand_id = p_brand_id AND email = p_email;
|
||||
ELSIF p_method = 'sms' THEN
|
||||
UPDATE public.communication_contacts SET
|
||||
sms_opt_in = false,
|
||||
sms_opt_in_at = now(),
|
||||
unsubscribed_at = now(),
|
||||
updated_at = now()
|
||||
WHERE brand_id = p_brand_id AND (email = p_email OR phone = p_email);
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- 7. delete_communication_contact
|
||||
DROP FUNCTION IF EXISTS public.delete_communication_contact(UUID);
|
||||
CREATE OR REPLACE FUNCTION public.delete_communication_contact(p_id UUID)
|
||||
RETURNS boolean
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
DELETE FROM public.communication_contacts WHERE id = p_id;
|
||||
RETURN FOUND;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── Rewrite preview_campaign_audience to use communication_contacts ───────────
|
||||
-- Primary source: communication_contacts (opted-in, not unsubscribed)
|
||||
-- Orders-based targeting still works but final recipient resolution is via contacts.
|
||||
|
||||
DROP FUNCTION IF EXISTS public.preview_campaign_audience(UUID, JSONB);
|
||||
CREATE OR REPLACE FUNCTION public.preview_campaign_audience(
|
||||
p_brand_id UUID,
|
||||
p_audience_rules JSONB
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_target TEXT;
|
||||
v_stop_id UUID;
|
||||
v_date_from TIMESTAMPTZ;
|
||||
v_date_to TIMESTAMPTZ;
|
||||
v_city TEXT;
|
||||
v_order_hist TEXT;
|
||||
v_days_back INTEGER;
|
||||
v_product_id UUID;
|
||||
v_count INTEGER;
|
||||
v_contacts JSONB;
|
||||
BEGIN
|
||||
v_target := p_audience_rules->>'target';
|
||||
|
||||
-- Stop-based targeting: find orders for stop+date range, resolve to contacts
|
||||
IF v_target = 'stop' THEN
|
||||
v_stop_id := nullif(p_audience_rules->>'stop_id', '')::UUID;
|
||||
v_date_from := nullif(p_audience_rules->>'date_from', '')::TIMESTAMPTZ;
|
||||
v_date_to := nullif(p_audience_rules->>'date_to', '')::TIMESTAMPTZ;
|
||||
|
||||
SELECT COUNT(DISTINCT c.id),
|
||||
jsonb_agg(DISTINCT jsonb_build_object(
|
||||
'id', c.id, 'email', c.email, 'name', c.full_name,
|
||||
'phone', c.phone, 'source', c.source
|
||||
) ORDER BY c.full_name)
|
||||
INTO v_count, v_contacts
|
||||
FROM (
|
||||
SELECT DISTINCT o.customer_email
|
||||
FROM orders o
|
||||
JOIN stops s ON s.id = o.stop_id
|
||||
WHERE s.brand_id = p_brand_id
|
||||
AND o.status NOT IN ('canceled')
|
||||
AND (v_stop_id IS NULL OR o.stop_id = v_stop_id)
|
||||
AND (v_date_from IS NULL OR o.created_at >= v_date_from)
|
||||
AND (v_date_to IS NULL OR o.created_at <= v_date_to)
|
||||
AND o.customer_email IS NOT NULL AND o.customer_email != ''
|
||||
) o_emails
|
||||
JOIN communication_contacts c ON c.email = o_emails.customer_email
|
||||
AND c.brand_id = p_brand_id
|
||||
AND c.email_opt_in = true
|
||||
AND c.unsubscribed_at IS NULL;
|
||||
|
||||
-- ZIP / city targeting (placeholder — ZIP requires customer_zip column in orders)
|
||||
ELSIF v_target = 'zip_code' THEN
|
||||
SELECT COUNT(*),
|
||||
jsonb_agg(jsonb_build_object(
|
||||
'id', c.id, 'email', c.email, 'name', c.full_name,
|
||||
'phone', c.phone, 'source', c.source
|
||||
) ORDER BY c.full_name)
|
||||
INTO v_count, v_contacts
|
||||
FROM communication_contacts c
|
||||
WHERE c.brand_id = p_brand_id
|
||||
AND c.email_opt_in = true
|
||||
AND c.unsubscribed_at IS NULL;
|
||||
|
||||
-- Customer history targeting
|
||||
ELSIF v_target = 'customer_history' THEN
|
||||
v_order_hist := p_audience_rules->>'order_history';
|
||||
v_days_back := (p_audience_rules->>'days_back')::INTEGER;
|
||||
|
||||
IF v_order_hist = 'first_order' THEN
|
||||
WITH first_order_emails AS (
|
||||
SELECT o.customer_email
|
||||
FROM orders o
|
||||
JOIN stops s ON s.id = o.stop_id
|
||||
WHERE s.brand_id = p_brand_id
|
||||
AND o.status NOT IN ('canceled')
|
||||
AND o.customer_email IS NOT NULL AND o.customer_email != ''
|
||||
AND (v_days_back IS NULL OR o.created_at >= now() - (v_days_back || ' days')::INTERVAL)
|
||||
GROUP BY o.customer_email
|
||||
HAVING COUNT(*) = 1
|
||||
)
|
||||
SELECT COUNT(*),
|
||||
jsonb_agg(jsonb_build_object(
|
||||
'id', c.id, 'email', c.email, 'name', c.full_name,
|
||||
'phone', c.phone, 'source', c.source
|
||||
) ORDER BY c.full_name)
|
||||
INTO v_count, v_contacts
|
||||
FROM first_order_emails fe
|
||||
JOIN communication_contacts c ON c.email = fe.customer_email
|
||||
AND c.brand_id = p_brand_id
|
||||
AND c.email_opt_in = true
|
||||
AND c.unsubscribed_at IS NULL;
|
||||
|
||||
ELSIF v_order_hist = 'repeat' THEN
|
||||
WITH repeat_emails AS (
|
||||
SELECT o.customer_email
|
||||
FROM orders o
|
||||
JOIN stops s ON s.id = o.stop_id
|
||||
WHERE s.brand_id = p_brand_id
|
||||
AND o.status NOT IN ('canceled')
|
||||
AND o.customer_email IS NOT NULL AND o.customer_email != ''
|
||||
AND (v_days_back IS NULL OR o.created_at >= now() - (v_days_back || ' days')::INTERVAL)
|
||||
GROUP BY o.customer_email
|
||||
HAVING COUNT(*) > 1
|
||||
)
|
||||
SELECT COUNT(*),
|
||||
jsonb_agg(jsonb_build_object(
|
||||
'id', c.id, 'email', c.email, 'name', c.full_name,
|
||||
'phone', c.phone, 'source', c.source
|
||||
) ORDER BY c.full_name)
|
||||
INTO v_count, v_contacts
|
||||
FROM repeat_emails re
|
||||
JOIN communication_contacts c ON c.email = re.customer_email
|
||||
AND c.brand_id = p_brand_id
|
||||
AND c.email_opt_in = true
|
||||
AND c.unsubscribed_at IS NULL;
|
||||
|
||||
ELSE
|
||||
WITH all_order_emails AS (
|
||||
SELECT DISTINCT o.customer_email
|
||||
FROM orders o
|
||||
JOIN stops s ON s.id = o.stop_id
|
||||
WHERE s.brand_id = p_brand_id
|
||||
AND o.status NOT IN ('canceled')
|
||||
AND o.customer_email IS NOT NULL AND o.customer_email != ''
|
||||
AND (v_days_back IS NULL OR o.created_at >= now() - (v_days_back || ' days')::INTERVAL)
|
||||
)
|
||||
SELECT COUNT(*),
|
||||
jsonb_agg(jsonb_build_object(
|
||||
'id', c.id, 'email', c.email, 'name', c.full_name,
|
||||
'phone', c.phone, 'source', c.source
|
||||
) ORDER BY c.full_name)
|
||||
INTO v_count, v_contacts
|
||||
FROM all_order_emails aoe
|
||||
JOIN communication_contacts c ON c.email = aoe.customer_email
|
||||
AND c.brand_id = p_brand_id
|
||||
AND c.email_opt_in = true
|
||||
AND c.unsubscribed_at IS NULL;
|
||||
END IF;
|
||||
|
||||
-- Product-based targeting
|
||||
ELSIF v_target = 'product' THEN
|
||||
v_product_id := nullif(p_audience_rules->>'product_id', '')::UUID;
|
||||
WITH product_order_emails AS (
|
||||
SELECT DISTINCT o.customer_email
|
||||
FROM order_items oi
|
||||
JOIN orders o ON o.id = oi.order_id
|
||||
JOIN stops s ON s.id = o.stop_id
|
||||
WHERE s.brand_id = p_brand_id
|
||||
AND o.status NOT IN ('canceled')
|
||||
AND (v_product_id IS NULL OR oi.product_id = v_product_id)
|
||||
AND o.customer_email IS NOT NULL AND o.customer_email != ''
|
||||
)
|
||||
SELECT COUNT(*),
|
||||
jsonb_agg(jsonb_build_object(
|
||||
'id', c.id, 'email', c.email, 'name', c.full_name,
|
||||
'phone', c.phone, 'source', c.source
|
||||
) ORDER BY c.full_name)
|
||||
INTO v_count, v_contacts
|
||||
FROM product_order_emails poe
|
||||
JOIN communication_contacts c ON c.email = poe.customer_email
|
||||
AND c.brand_id = p_brand_id
|
||||
AND c.email_opt_in = true
|
||||
AND c.unsubscribed_at IS NULL;
|
||||
|
||||
-- Explicit customer ID list
|
||||
ELSIF v_target = 'customer_ids' THEN
|
||||
SELECT COUNT(*),
|
||||
jsonb_agg(jsonb_build_object(
|
||||
'id', c.id, 'email', c.email, 'name', c.full_name,
|
||||
'phone', c.phone, 'source', c.source
|
||||
) ORDER BY c.full_name)
|
||||
INTO v_count, v_contacts
|
||||
FROM communication_contacts c
|
||||
WHERE c.brand_id = p_brand_id
|
||||
AND c.email_opt_in = true
|
||||
AND c.unsubscribed_at IS NULL
|
||||
AND c.customer_id IN (
|
||||
SELECT jsonb_array_elements_text(p_audience_rules->'customer_ids')::UUID
|
||||
);
|
||||
|
||||
-- all_customers: all opted-in, not-unsubscribed contacts
|
||||
ELSE
|
||||
SELECT COUNT(*),
|
||||
jsonb_agg(jsonb_build_object(
|
||||
'id', c.id, 'email', c.email, 'name', c.full_name,
|
||||
'phone', c.phone, 'source', c.source
|
||||
) ORDER BY c.full_name)
|
||||
INTO v_count, v_contacts
|
||||
FROM communication_contacts c
|
||||
WHERE c.brand_id = p_brand_id
|
||||
AND c.email_opt_in = true
|
||||
AND c.unsubscribed_at IS NULL;
|
||||
END IF;
|
||||
|
||||
-- Cap sample at 20
|
||||
SELECT jsonb_agg(x ORDER BY x->>'name') INTO v_contacts
|
||||
FROM (
|
||||
SELECT * FROM jsonb_array_elements(coalesce(v_contacts, '[]'::jsonb))
|
||||
LIMIT 20
|
||||
) t(x);
|
||||
|
||||
RETURN jsonb_build_object('count', v_count, 'sample_customers', v_contacts);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── Update send_campaign to use communication_contacts ──────────────────────
|
||||
|
||||
DROP FUNCTION IF EXISTS public.send_campaign(UUID);
|
||||
CREATE OR REPLACE FUNCTION public.send_campaign(p_campaign_id UUID)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_campaign communication_campaigns;
|
||||
v_settings communication_settings;
|
||||
v_audience JSONB;
|
||||
v_entry JSONB;
|
||||
v_entries JSONB := '[]'::JSONB;
|
||||
v_count INTEGER := 0;
|
||||
BEGIN
|
||||
SELECT * INTO v_campaign FROM communication_campaigns WHERE id = p_campaign_id;
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Campaign not found');
|
||||
END IF;
|
||||
|
||||
SELECT * INTO v_settings FROM communication_settings WHERE brand_id = v_campaign.brand_id;
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'No communication settings for brand');
|
||||
END IF;
|
||||
|
||||
v_audience := preview_campaign_audience(v_campaign.brand_id, v_campaign.audience_rules);
|
||||
|
||||
FOR v_entry IN SELECT * FROM jsonb_array_elements(coalesce(v_audience->'sample_customers', '[]'::jsonb))
|
||||
LOOP
|
||||
CONTINUE WHEN (v_entry->>'email') IS NULL OR (v_entry->>'email') = '';
|
||||
|
||||
v_entries := v_entries || jsonb_build_array(jsonb_build_object(
|
||||
'brand_id', v_campaign.brand_id,
|
||||
'campaign_id', p_campaign_id,
|
||||
'customer_id', nullif(v_entry->>'id', ''),
|
||||
'customer_email', v_entry->>'email',
|
||||
'delivery_method','email',
|
||||
'subject', v_campaign.subject,
|
||||
'body_preview', left(v_campaign.body_text, 500),
|
||||
'status', 'queued'
|
||||
));
|
||||
v_count := v_count + 1;
|
||||
END LOOP;
|
||||
|
||||
PERFORM log_communication_messages(v_entries);
|
||||
|
||||
UPDATE communication_campaigns
|
||||
SET status = 'sent', sent_at = now(), updated_at = now()
|
||||
WHERE id = p_campaign_id;
|
||||
|
||||
RETURN jsonb_build_object('success', true, 'messages_logged', v_count);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── Update opt_out_customer to also set unsubscribed_at on contacts ───────────
|
||||
|
||||
DROP FUNCTION IF EXISTS public.opt_out_customer(UUID, UUID, TEXT);
|
||||
CREATE OR REPLACE FUNCTION public.opt_out_customer(
|
||||
p_customer_id UUID,
|
||||
p_brand_id UUID,
|
||||
p_method TEXT
|
||||
)
|
||||
RETURNS customer_communication_preferences
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE v_result customer_communication_preferences;
|
||||
BEGIN
|
||||
INSERT INTO public.customer_communication_preferences
|
||||
(customer_id, brand_id, email_opt_in, sms_opt_in, email_opt_in_at, sms_opt_in_at)
|
||||
VALUES (
|
||||
p_customer_id, p_brand_id,
|
||||
CASE WHEN p_method = 'email' THEN false ELSE true END,
|
||||
CASE WHEN p_method = 'sms' THEN false ELSE true END,
|
||||
CASE WHEN p_method = 'email' THEN now() ELSE NULL END,
|
||||
CASE WHEN p_method = 'sms' THEN now() ELSE NULL END
|
||||
)
|
||||
ON CONFLICT (customer_id, brand_id) DO UPDATE SET
|
||||
email_opt_in = CASE WHEN p_method = 'email' THEN false ELSE customer_communication_preferences.email_opt_in END,
|
||||
sms_opt_in = CASE WHEN p_method = 'sms' THEN false ELSE customer_communication_preferences.sms_opt_in END,
|
||||
email_opt_in_at = CASE WHEN p_method = 'email' THEN now() ELSE email_opt_in_at END,
|
||||
sms_opt_in_at = CASE WHEN p_method = 'sms' THEN now() ELSE sms_opt_in_at END,
|
||||
updated_at = now()
|
||||
RETURNING * INTO v_result;
|
||||
|
||||
-- Also set unsubscribed_at on communication_contacts
|
||||
IF p_method = 'email' THEN
|
||||
UPDATE public.communication_contacts SET
|
||||
email_opt_in = false,
|
||||
email_opt_in_at = now(),
|
||||
unsubscribed_at = now(),
|
||||
updated_at = now()
|
||||
WHERE customer_id = p_customer_id
|
||||
AND brand_id = p_brand_id
|
||||
AND unsubscribed_at IS NULL;
|
||||
ELSIF p_method = 'sms' THEN
|
||||
UPDATE public.communication_contacts SET
|
||||
sms_opt_in = false,
|
||||
sms_opt_in_at = now(),
|
||||
unsubscribed_at = now(),
|
||||
updated_at = now()
|
||||
WHERE customer_id = p_customer_id
|
||||
AND brand_id = p_brand_id
|
||||
AND unsubscribed_at IS NULL;
|
||||
END IF;
|
||||
|
||||
RETURN v_result;
|
||||
END;
|
||||
$$;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -1,230 +0,0 @@
|
||||
-- =============================================================================
|
||||
-- Communication Center V1.2 — Import Metadata Storage
|
||||
-- Fully idempotent: additive changes only
|
||||
-- Updates import_communication_contacts_batch to store ignored CSV columns
|
||||
-- in metadata.imported_raw so no context is lost from arbitrary extra columns.
|
||||
-- =============================================================================
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- ── Update email path — add imported_raw to metadata on UPDATE ───────────────
|
||||
-- Lines ~400-402 in migration 017: UPDATE path for existing email contact
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.import_communication_contacts_batch(
|
||||
p_brand_id UUID,
|
||||
p_contacts JSONB,
|
||||
p_allow_opt_in_override BOOLEAN DEFAULT false
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_entry JSONB;
|
||||
v_result JSONB := '{"created": 0, "updated": 0, "skipped": 0, "errors": []}'::JSONB;
|
||||
v_existing communication_contacts%ROWTYPE;
|
||||
v_email TEXT;
|
||||
v_phone TEXT;
|
||||
v_tags TEXT[];
|
||||
v_raw_meta JSONB;
|
||||
BEGIN
|
||||
FOR v_entry IN SELECT * FROM jsonb_array_elements(p_contacts)
|
||||
LOOP
|
||||
v_email := nullif(v_entry->>'email', '');
|
||||
v_phone := nullif(v_entry->>'phone', '');
|
||||
|
||||
-- Parse tags: CSV sends "tag1;tag2" as a plain string; split into TEXT[]
|
||||
IF (v_entry->>'tags') IS NOT NULL AND (v_entry->>'tags') != '' THEN
|
||||
v_tags := coalesce(
|
||||
(SELECT array_agg(trim(x)) FROM unnest(string_to_array(v_entry->>'tags', ';')) AS x WHERE trim(x) != ''),
|
||||
'{}'
|
||||
);
|
||||
ELSE
|
||||
v_tags := '{}';
|
||||
END IF;
|
||||
|
||||
-- Build imported_raw from any _metadata key (ignored CSV columns)
|
||||
v_raw_meta := nullif(v_entry->>'_metadata', '')::JSONB;
|
||||
|
||||
BEGIN
|
||||
IF v_email IS NOT NULL AND v_email != '' THEN
|
||||
SELECT * INTO v_existing
|
||||
FROM public.communication_contacts
|
||||
WHERE brand_id = p_brand_id AND email = v_email;
|
||||
|
||||
IF FOUND THEN
|
||||
-- Existing contact
|
||||
IF v_existing.unsubscribed_at IS NOT NULL AND
|
||||
coalesce((v_entry->>'email_opt_in')::BOOLEAN, false) = true AND
|
||||
p_allow_opt_in_override = false THEN
|
||||
v_result := jsonb_set(v_result, '{skipped}',
|
||||
to_jsonb((v_result->>'skipped')::INTEGER + 1));
|
||||
ELSE
|
||||
UPDATE public.communication_contacts SET
|
||||
phone = COALESCE(nullif(v_entry->>'phone', ''), phone),
|
||||
first_name = COALESCE(nullif(v_entry->>'first_name', ''), first_name),
|
||||
last_name = COALESCE(nullif(v_entry->>'last_name', ''), last_name),
|
||||
full_name = COALESCE(nullif(v_entry->>'full_name', ''), full_name),
|
||||
source = 'import',
|
||||
external_id = COALESCE(nullif(v_entry->>'external_id', ''), external_id),
|
||||
email_opt_in = CASE
|
||||
WHEN communication_contacts.unsubscribed_at IS NOT NULL
|
||||
THEN communication_contacts.email_opt_in
|
||||
ELSE coalesce(
|
||||
(v_entry->>'email_opt_in')::BOOLEAN,
|
||||
communication_contacts.email_opt_in,
|
||||
true
|
||||
)
|
||||
END,
|
||||
sms_opt_in = CASE
|
||||
WHEN communication_contacts.unsubscribed_at IS NOT NULL
|
||||
THEN communication_contacts.sms_opt_in
|
||||
ELSE coalesce(
|
||||
(v_entry->>'sms_opt_in')::BOOLEAN,
|
||||
communication_contacts.sms_opt_in,
|
||||
false
|
||||
)
|
||||
END,
|
||||
email_opt_in_at = CASE
|
||||
WHEN communication_contacts.unsubscribed_at IS NOT NULL
|
||||
THEN email_opt_in_at
|
||||
WHEN coalesce(
|
||||
(v_entry->>'email_opt_in')::BOOLEAN,
|
||||
communication_contacts.email_opt_in,
|
||||
true
|
||||
) = true THEN now()
|
||||
ELSE email_opt_in_at
|
||||
END,
|
||||
tags = CASE
|
||||
WHEN v_tags != '{}' THEN v_tags
|
||||
ELSE communication_contacts.tags
|
||||
END,
|
||||
metadata = communication_contacts.metadata ||
|
||||
jsonb_build_object(
|
||||
'imported_at', now()::TEXT,
|
||||
'imported_raw', coalesce(v_raw_meta, '{}'::JSONB)
|
||||
),
|
||||
updated_at = now()
|
||||
WHERE brand_id = p_brand_id AND email = v_email;
|
||||
|
||||
v_result := jsonb_set(v_result, '{updated}',
|
||||
to_jsonb((v_result->>'updated')::INTEGER + 1));
|
||||
END IF;
|
||||
|
||||
ELSE
|
||||
-- Insert new
|
||||
INSERT INTO public.communication_contacts
|
||||
(brand_id, email, phone, first_name, last_name, full_name, source,
|
||||
external_id, email_opt_in, sms_opt_in, email_opt_in_at, sms_opt_in_at,
|
||||
tags, metadata)
|
||||
VALUES (
|
||||
p_brand_id,
|
||||
v_email,
|
||||
nullif(v_entry->>'phone', ''),
|
||||
nullif(v_entry->>'first_name', ''),
|
||||
nullif(v_entry->>'last_name', ''),
|
||||
nullif(v_entry->>'full_name', ''),
|
||||
'import',
|
||||
nullif(v_entry->>'external_id', ''),
|
||||
coalesce((v_entry->>'email_opt_in')::BOOLEAN, true),
|
||||
coalesce((v_entry->>'sms_opt_in')::BOOLEAN, false),
|
||||
CASE WHEN coalesce((v_entry->>'email_opt_in')::BOOLEAN, true) = true THEN now() ELSE NULL END,
|
||||
CASE WHEN coalesce((v_entry->>'sms_opt_in')::BOOLEAN, false) = true THEN now() ELSE NULL END,
|
||||
v_tags,
|
||||
jsonb_build_object(
|
||||
'imported_at', now()::TEXT,
|
||||
'imported_raw', coalesce(v_raw_meta, '{}'::JSONB)
|
||||
)
|
||||
);
|
||||
v_result := jsonb_set(v_result, '{created}',
|
||||
to_jsonb((v_result->>'created')::INTEGER + 1));
|
||||
END IF;
|
||||
|
||||
ELSIF v_phone IS NOT NULL AND v_phone != '' THEN
|
||||
-- Upsert by phone
|
||||
SELECT * INTO v_existing
|
||||
FROM public.communication_contacts
|
||||
WHERE brand_id = p_brand_id AND phone = v_phone;
|
||||
|
||||
IF FOUND THEN
|
||||
IF v_existing.unsubscribed_at IS NOT NULL AND
|
||||
coalesce((v_entry->>'email_opt_in')::BOOLEAN, false) = true AND
|
||||
p_allow_opt_in_override = false THEN
|
||||
v_result := jsonb_set(v_result, '{skipped}',
|
||||
to_jsonb((v_result->>'skipped')::INTEGER + 1));
|
||||
ELSE
|
||||
UPDATE public.communication_contacts SET
|
||||
email = COALESCE(nullif(v_entry->>'email', ''), email),
|
||||
first_name = COALESCE(nullif(v_entry->>'first_name', ''), first_name),
|
||||
last_name = COALESCE(nullif(v_entry->>'last_name', ''), last_name),
|
||||
full_name = COALESCE(nullif(v_entry->>'full_name', ''), full_name),
|
||||
source = 'import',
|
||||
email_opt_in = CASE
|
||||
WHEN communication_contacts.unsubscribed_at IS NOT NULL
|
||||
THEN communication_contacts.email_opt_in
|
||||
ELSE coalesce(
|
||||
(v_entry->>'email_opt_in')::BOOLEAN,
|
||||
communication_contacts.email_opt_in,
|
||||
true
|
||||
)
|
||||
END,
|
||||
tags = CASE WHEN v_tags != '{}' THEN v_tags ELSE communication_contacts.tags END,
|
||||
metadata = communication_contacts.metadata ||
|
||||
jsonb_build_object(
|
||||
'imported_at', now()::TEXT,
|
||||
'imported_raw', coalesce(v_raw_meta, '{}'::JSONB)
|
||||
),
|
||||
updated_at = now()
|
||||
WHERE brand_id = p_brand_id AND phone = v_phone;
|
||||
|
||||
v_result := jsonb_set(v_result, '{updated}',
|
||||
to_jsonb((v_result->>'updated')::INTEGER + 1));
|
||||
END IF;
|
||||
ELSE
|
||||
INSERT INTO public.communication_contacts
|
||||
(brand_id, email, phone, first_name, last_name, full_name, source,
|
||||
email_opt_in, sms_opt_in, tags, metadata)
|
||||
VALUES (
|
||||
p_brand_id,
|
||||
nullif(v_entry->>'email', ''),
|
||||
v_phone,
|
||||
nullif(v_entry->>'first_name', ''),
|
||||
nullif(v_entry->>'last_name', ''),
|
||||
nullif(v_entry->>'full_name', ''),
|
||||
'import',
|
||||
coalesce((v_entry->>'email_opt_in')::BOOLEAN, true),
|
||||
coalesce((v_entry->>'sms_opt_in')::BOOLEAN, false),
|
||||
v_tags,
|
||||
jsonb_build_object(
|
||||
'imported_at', now()::TEXT,
|
||||
'imported_raw', coalesce(v_raw_meta, '{}'::JSONB)
|
||||
)
|
||||
);
|
||||
v_result := jsonb_set(v_result, '{created}',
|
||||
to_jsonb((v_result->>'created')::INTEGER + 1));
|
||||
END IF;
|
||||
|
||||
ELSE
|
||||
-- No email or phone
|
||||
v_result := jsonb_set(
|
||||
v_result, '{errors}',
|
||||
v_result->'errors' || jsonb_build_array(
|
||||
jsonb_build_object('row', v_entry, 'error', 'No email or phone provided')
|
||||
)
|
||||
);
|
||||
END IF;
|
||||
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
v_result := jsonb_set(
|
||||
v_result, '{errors}',
|
||||
v_result->'errors' || jsonb_build_array(
|
||||
jsonb_build_object('row', v_entry, 'error', SQLERRM)
|
||||
)
|
||||
);
|
||||
END;
|
||||
END LOOP;
|
||||
|
||||
RETURN v_result;
|
||||
END;
|
||||
$$;
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -1,75 +0,0 @@
|
||||
-- =============================================================================
|
||||
-- V1.2 Stage 1 — Canonical Customers Table
|
||||
-- Fully idempotent: CREATE TABLE IF NOT EXISTS, CREATE INDEX IF NOT EXISTS
|
||||
-- Purely additive: no changes to orders, communication_contacts, or checkout
|
||||
--
|
||||
-- STAGE 2 NOTE: When upserting customers in Stage 2, normalize BEFORE upsert:
|
||||
-- - email: lowercase + trim
|
||||
-- - phone: strip formatting chars [\s\-().[\]], preserve original if uncertain
|
||||
-- This ensures consistent matching across orders, imports, and contacts.
|
||||
-- =============================================================================
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- ── Table ────────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.customers (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
|
||||
primary_email TEXT,
|
||||
primary_phone TEXT,
|
||||
first_name TEXT,
|
||||
last_name TEXT,
|
||||
source TEXT NOT NULL DEFAULT 'system',
|
||||
metadata JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT customers_email_or_phone CHECK (
|
||||
primary_email IS NOT NULL OR primary_phone IS NOT NULL
|
||||
)
|
||||
);
|
||||
|
||||
-- ── Indexes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_customers_brand
|
||||
ON public.customers(brand_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_customers_email
|
||||
ON public.customers(primary_email) WHERE primary_email IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_customers_phone
|
||||
ON public.customers(primary_phone) WHERE primary_phone IS NOT NULL;
|
||||
|
||||
-- Partial unique indexes: enforce one customer per brand per email/phone
|
||||
-- PostgreSQL requires CREATE UNIQUE INDEX rather than CONSTRAINT for partial unique
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_customers_brand_email_unique
|
||||
ON public.customers(brand_id, primary_email)
|
||||
WHERE primary_email IS NOT NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_customers_brand_phone_unique
|
||||
ON public.customers(brand_id, primary_phone)
|
||||
WHERE primary_phone IS NOT NULL;
|
||||
|
||||
-- ── RLS ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
ALTER TABLE public.customers ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS "Brand admin can read customers"
|
||||
ON public.customers;
|
||||
CREATE POLICY "Brand admin can read customers"
|
||||
ON public.customers FOR SELECT TO authenticated
|
||||
USING (brand_id IN (
|
||||
SELECT brand_id FROM admin_users
|
||||
WHERE admin_users.user_id = auth.uid()
|
||||
AND admin_users.role = 'brand_admin'
|
||||
));
|
||||
|
||||
DROP POLICY IF EXISTS "Platform admin can read customers"
|
||||
ON public.customers;
|
||||
CREATE POLICY "Platform admin can read customers"
|
||||
ON public.customers FOR SELECT TO authenticated
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM admin_users
|
||||
WHERE admin_users.user_id = auth.uid()
|
||||
AND admin_users.role = 'platform_admin'
|
||||
));
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -1,226 +0,0 @@
|
||||
-- =============================================================================
|
||||
-- V1.2 Stage 2 — Order/Contact/Customer Linking
|
||||
-- Fully idempotent: CREATE OR REPLACE FUNCTION, no destructive operations
|
||||
-- Purely additive behavior: no changes to orders schema, no FKs, no backfill
|
||||
--
|
||||
-- DEPENDENCY: Requires 019_customers_table.sql to be applied first.
|
||||
-- Raises an exception if the customers table does not exist.
|
||||
--
|
||||
-- TRANSITIONAL ARCHITECTURE NOTE:
|
||||
-- orders.customer_id and customers.id are separate ID namespaces today.
|
||||
-- communication_contacts.customer_id links to customers.id for new order-created
|
||||
-- contacts (via this migration), but orders.customer_id is NOT yet aligned.
|
||||
--
|
||||
-- A future stage (Stage 3+) should consider:
|
||||
-- - backfilling customers from existing orders/communication_contacts
|
||||
-- - aligning orders.customer_id to canonical customers.id
|
||||
-- - adding proper FK constraints only after data is cleaned
|
||||
-- - avoiding permanent dual customer ID namespaces
|
||||
--
|
||||
-- STAGE 2 NORMALIZATION:
|
||||
-- email: trim(lower(...)) before match/upsert
|
||||
-- phone: regexp_replace(... '[\s\-().[\]]' '' 'g') before match/upsert
|
||||
-- This is consistent with frontend normalizeEmail/normalizePhone.
|
||||
-- =============================================================================
|
||||
|
||||
-- ── Dependency guard ─────────────────────────────────────────────────────────
|
||||
DO $$
|
||||
BEGIN
|
||||
IF to_regclass('public.customers') IS NULL THEN
|
||||
RAISE EXCEPTION 'Migration 019_customers_table.sql must be applied before 020_order_customer_linking.sql';
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- ── Safe teardown order: trigger → functions ────────────────────────────────
|
||||
-- PostgreSQL requires dropping a trigger before dropping the function it uses.
|
||||
-- Order: DROP TRIGGER → DROP FUNCTION (helper) → DROP FUNCTION (trigger)
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_create_contact_from_order ON public.orders;
|
||||
DROP FUNCTION IF EXISTS public.upsert_customer_from_order(UUID, TEXT, TEXT, TEXT, TEXT);
|
||||
DROP FUNCTION IF EXISTS public.upsert_contact_from_order();
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
-- upsert_customer_from_order
|
||||
--
|
||||
-- Resolves or creates a canonical customers record for a given order.
|
||||
-- Returns the customers.id for linking, or NULL if no contact method exists.
|
||||
--
|
||||
-- Upsert order: email first → phone fallback (email is more stable identity)
|
||||
-- Brand separation: brand_id is the scoping key throughout.
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.upsert_customer_from_order(
|
||||
p_brand_id UUID,
|
||||
p_customer_email TEXT,
|
||||
p_customer_phone TEXT,
|
||||
p_customer_name TEXT,
|
||||
p_source TEXT DEFAULT 'order'
|
||||
)
|
||||
RETURNS UUID -- customers.id
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_norm_email TEXT := trim(lower(p_customer_email));
|
||||
v_norm_phone TEXT := regexp_replace(p_customer_phone, '[\s\-().[\]]', '', 'g');
|
||||
v_cust_id UUID;
|
||||
BEGIN
|
||||
-- No email AND no phone: nothing to upsert
|
||||
IF (v_norm_email IS NULL OR v_norm_email = '') AND
|
||||
(v_norm_phone IS NULL OR v_norm_phone = '') THEN
|
||||
RETURN NULL;
|
||||
END IF;
|
||||
|
||||
-- Try email match first (within brand)
|
||||
IF v_norm_email IS NOT NULL AND v_norm_email != '' THEN
|
||||
SELECT id INTO v_cust_id
|
||||
FROM public.customers
|
||||
WHERE brand_id = p_brand_id
|
||||
AND primary_email = v_norm_email;
|
||||
|
||||
IF FOUND THEN
|
||||
UPDATE public.customers SET
|
||||
primary_phone = COALESCE(NULLIF(v_norm_phone, ''), primary_phone),
|
||||
first_name = COALESCE(
|
||||
NULLIF(split_part(p_customer_name, ' ', 1), ''),
|
||||
customers.first_name
|
||||
),
|
||||
last_name = COALESCE(
|
||||
NULLIF(substring(p_customer_name from position(' ' in p_customer_name) + 1
|
||||
for length(p_customer_name)), ''),
|
||||
customers.last_name
|
||||
),
|
||||
source = CASE
|
||||
WHEN customers.source = 'system' THEN p_source ELSE customers.source
|
||||
END,
|
||||
updated_at = now()
|
||||
WHERE id = v_cust_id;
|
||||
RETURN v_cust_id;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- Phone fallback (only if email didn't find a match)
|
||||
IF v_cust_id IS NULL AND v_norm_phone IS NOT NULL AND v_norm_phone != '' THEN
|
||||
SELECT id INTO v_cust_id
|
||||
FROM public.customers
|
||||
WHERE brand_id = p_brand_id
|
||||
AND primary_phone = v_norm_phone;
|
||||
|
||||
IF FOUND THEN
|
||||
UPDATE public.customers SET
|
||||
primary_email = COALESCE(NULLIF(v_norm_email, ''), primary_email),
|
||||
first_name = COALESCE(
|
||||
NULLIF(split_part(p_customer_name, ' ', 1), ''),
|
||||
customers.first_name
|
||||
),
|
||||
last_name = COALESCE(
|
||||
NULLIF(substring(p_customer_name from position(' ' in p_customer_name) + 1
|
||||
for length(p_customer_name)), ''),
|
||||
customers.last_name
|
||||
),
|
||||
source = CASE
|
||||
WHEN customers.source = 'system' THEN p_source ELSE customers.source
|
||||
END,
|
||||
updated_at = now()
|
||||
WHERE id = v_cust_id;
|
||||
RETURN v_cust_id;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- Insert new customer
|
||||
INSERT INTO public.customers
|
||||
(brand_id, primary_email, primary_phone, first_name, last_name, source)
|
||||
VALUES (
|
||||
p_brand_id,
|
||||
NULLIF(v_norm_email, ''),
|
||||
NULLIF(v_norm_phone, ''),
|
||||
NULLIF(split_part(p_customer_name, ' ', 1), ''),
|
||||
NULLIF(substring(p_customer_name from position(' ' in p_customer_name) + 1
|
||||
for length(p_customer_name)), ''),
|
||||
p_source
|
||||
)
|
||||
RETURNING id INTO v_cust_id;
|
||||
|
||||
RETURN v_cust_id;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
-- upsert_contact_from_order trigger
|
||||
--
|
||||
-- NOW DOES TWO THINGS:
|
||||
-- 1. Call upsert_customer_from_order() to get/create customers.id
|
||||
-- 2. Use that customers.id as communication_contacts.customer_id (linking)
|
||||
--
|
||||
-- OPT-OUT PRESERVATION: email_opt_in, sms_opt_in, unsubscribed_at are NOT
|
||||
-- updated on conflict — this behavior is unchanged from V1.1.
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.upsert_contact_from_order()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_brand_id UUID;
|
||||
v_customer_id UUID;
|
||||
BEGIN
|
||||
-- Resolve brand_id from the stop
|
||||
SELECT brand_id INTO v_brand_id
|
||||
FROM public.stops
|
||||
WHERE id = NEW.stop_id;
|
||||
IF NOT FOUND THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Step 1: upsert canonical customer (NEW.customer_id not referenced —
|
||||
-- orders table does not have that column; use only email/phone/name)
|
||||
v_customer_id := upsert_customer_from_order(
|
||||
v_brand_id,
|
||||
NEW.customer_email,
|
||||
NEW.customer_phone,
|
||||
NEW.customer_name,
|
||||
'order'
|
||||
);
|
||||
|
||||
-- Step 2: upsert communication contact, linked to customers.id
|
||||
INSERT INTO public.communication_contacts
|
||||
(brand_id, email, phone, full_name, source, customer_id, email_opt_in, metadata)
|
||||
VALUES (
|
||||
v_brand_id,
|
||||
NEW.customer_email,
|
||||
NEW.customer_phone,
|
||||
NEW.customer_name,
|
||||
'order',
|
||||
v_customer_id,
|
||||
true,
|
||||
jsonb_build_object(
|
||||
'last_order_id', NEW.id,
|
||||
'last_order_at', now()::TEXT,
|
||||
'stop_id', NEW.stop_id::TEXT
|
||||
)
|
||||
)
|
||||
ON CONFLICT (brand_id, email) WHERE email IS NOT NULL DO UPDATE SET
|
||||
full_name = COALESCE(EXCLUDED.full_name, communication_contacts.full_name),
|
||||
phone = COALESCE(EXCLUDED.phone, communication_contacts.phone),
|
||||
customer_id = COALESCE(EXCLUDED.customer_id, communication_contacts.customer_id),
|
||||
metadata = jsonb_build_object(
|
||||
'last_order_id', communication_contacts.metadata->>'last_order_id',
|
||||
'last_order_at', communication_contacts.metadata->>'last_order_at',
|
||||
'stop_id', NEW.stop_id::TEXT
|
||||
),
|
||||
updated_at = now();
|
||||
-- email_opt_in, sms_opt_in, unsubscribed_at intentionally NOT updated (preserve opt-out)
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── Recreate trigger ─────────────────────────────────────────────────────────
|
||||
CREATE TRIGGER trg_create_contact_from_order
|
||||
AFTER INSERT ON public.orders
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.upsert_contact_from_order();
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -1,319 +0,0 @@
|
||||
-- =============================================================================
|
||||
-- V1.2 Stage 3 — Shipping-Only Brand Resolution
|
||||
-- Fully idempotent: CREATE OR REPLACE FUNCTION, ALTER TABLE ADD COLUMN IF NOT EXISTS
|
||||
--
|
||||
-- DEPENDENCY: Requires 020_order_customer_linking.sql to be applied first.
|
||||
-- Raises an exception if the trigger function does not exist.
|
||||
--
|
||||
-- CHANGES:
|
||||
-- 1. Add brand_id column to orders (nullable, no FK — aligned to products.brand_id)
|
||||
-- 2. Modify create_order_with_items to accept NULL stop_id for shipping-only
|
||||
-- 3. Update upsert_contact_from_order trigger to resolve brand from NEW.brand_id
|
||||
-- first, falling back to stop lookup — supports both pickup and shipping orders
|
||||
-- =============================================================================
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_proc p
|
||||
JOIN pg_namespace n ON n.oid = p.pronamespace
|
||||
WHERE n.nspname = 'public'
|
||||
AND p.proname = 'upsert_contact_from_order'
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Migration 020_order_customer_linking.sql must be applied before 021_shipping_only_brand.sql';
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── 1. Add brand_id to orders ────────────────────────────────────────────────
|
||||
-- Allows the order/contact trigger to resolve brand without needing a stop_id.
|
||||
-- For shipping-only orders, brand is derived from the first product.
|
||||
|
||||
ALTER TABLE orders ADD COLUMN IF NOT EXISTS brand_id UUID;
|
||||
|
||||
-- ── 2. Modify create_order_with_items for shipping-only ────────────────────
|
||||
-- stop_id is now nullable. When NULL (shipping-only), brand is derived from
|
||||
-- the first product's brand and no stop validation occurs.
|
||||
|
||||
CREATE OR REPLACE FUNCTION create_order_with_items(
|
||||
p_idempotency_key UUID,
|
||||
p_customer_name TEXT,
|
||||
p_customer_email TEXT,
|
||||
p_customer_phone TEXT,
|
||||
p_stop_id UUID, -- nullable for shipping-only
|
||||
p_items JSONB -- [{id: uuid, quantity: int, fulfillment: text}]
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_order_id UUID;
|
||||
v_item JSONB;
|
||||
v_product_id UUID;
|
||||
v_quantity INT;
|
||||
v_fulfillment TEXT;
|
||||
v_product_price NUMERIC;
|
||||
v_computed_total NUMERIC := 0;
|
||||
v_stop_active BOOLEAN;
|
||||
v_stop_brand_id UUID;
|
||||
v_stop_city TEXT;
|
||||
v_stop_state TEXT;
|
||||
v_stop_date TEXT;
|
||||
v_stop_time TEXT;
|
||||
v_stop_location TEXT;
|
||||
v_product_active BOOLEAN;
|
||||
v_product_brand_id UUID;
|
||||
v_product_name TEXT;
|
||||
v_is_pickup BOOLEAN;
|
||||
v_order JSONB;
|
||||
v_order_items JSONB := '[]'::JSONB;
|
||||
v_brand_id UUID;
|
||||
BEGIN
|
||||
-- ── Idempotency guard ─────────────────────────────────────────────────────
|
||||
SELECT jsonb_build_object(
|
||||
'id', o.id,
|
||||
'customer_name', o.customer_name,
|
||||
'customer_email', o.customer_email,
|
||||
'customer_phone', o.customer_phone,
|
||||
'subtotal', o.subtotal,
|
||||
'status', o.status,
|
||||
'stop_id', o.stop_id,
|
||||
'brand_id', o.brand_id,
|
||||
'stop_city', s.city,
|
||||
'stop_state', s.state,
|
||||
'stop_date', s.date,
|
||||
'stop_time', s.time,
|
||||
'stop_location', s.location,
|
||||
'items', COALESCE((
|
||||
SELECT jsonb_agg(jsonb_build_object(
|
||||
'product_id', oi.product_id,
|
||||
'product_name', p.name,
|
||||
'quantity', oi.quantity,
|
||||
'price', oi.price,
|
||||
'fulfillment', oi.fulfillment
|
||||
))
|
||||
FROM order_items oi
|
||||
JOIN products p ON oi.product_id = p.id
|
||||
WHERE oi.order_id = o.id
|
||||
), '[]'::JSONB)
|
||||
)
|
||||
INTO v_order
|
||||
FROM orders o
|
||||
LEFT JOIN stops s ON o.stop_id = s.id
|
||||
WHERE o.idempotency_key = p_idempotency_key
|
||||
LIMIT 1;
|
||||
|
||||
IF v_order IS NOT NULL THEN
|
||||
RETURN v_order;
|
||||
END IF;
|
||||
|
||||
-- ── Resolve brand_id ──────────────────────────────────────────────────────
|
||||
-- For shipping-only orders (p_stop_id IS NULL), derive from the first product.
|
||||
-- For pickup orders, derive from the stop.
|
||||
IF p_stop_id IS NOT NULL THEN
|
||||
SELECT active, brand_id, city, state, date, time, location
|
||||
INTO v_stop_active, v_stop_brand_id, v_stop_city, v_stop_state, v_stop_date, v_stop_time, v_stop_location
|
||||
FROM stops
|
||||
WHERE id = p_stop_id;
|
||||
|
||||
IF v_stop_brand_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Stop not found: %', p_stop_id;
|
||||
END IF;
|
||||
|
||||
IF NOT v_stop_active THEN
|
||||
RAISE EXCEPTION 'Stop is not active: %', p_stop_id;
|
||||
END IF;
|
||||
|
||||
v_brand_id := v_stop_brand_id;
|
||||
ELSE
|
||||
-- Shipping-only: resolve brand from first product in the order
|
||||
v_product_id := (jsonb_array_elements(p_items)->>'id')::UUID;
|
||||
|
||||
SELECT brand_id INTO v_brand_id
|
||||
FROM products
|
||||
WHERE id = v_product_id;
|
||||
|
||||
IF v_brand_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Product not found: %', v_product_id;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- ── Validate items and compute subtotal ─────────────────────────────────
|
||||
FOR v_item IN SELECT * FROM jsonb_array_elements(p_items)
|
||||
LOOP
|
||||
v_product_id := (v_item->>'id')::UUID;
|
||||
v_quantity := (v_item->>'quantity')::INT;
|
||||
v_fulfillment := v_item->>'fulfillment';
|
||||
v_is_pickup := (v_fulfillment = 'pickup');
|
||||
|
||||
SELECT active, brand_id, price, name
|
||||
INTO v_product_active, v_product_brand_id, v_product_price, v_product_name
|
||||
FROM products
|
||||
WHERE id = v_product_id;
|
||||
|
||||
IF v_product_brand_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Product not found: %', v_product_id;
|
||||
END IF;
|
||||
|
||||
IF v_product_brand_id != v_brand_id THEN
|
||||
RAISE EXCEPTION 'Product % does not belong to brand %', v_product_id, v_brand_id;
|
||||
END IF;
|
||||
|
||||
IF NOT v_product_active THEN
|
||||
RAISE EXCEPTION 'Product is not active: %', v_product_id;
|
||||
END IF;
|
||||
|
||||
IF v_is_pickup THEN
|
||||
-- Pickup items require a valid stop assignment
|
||||
IF p_stop_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Pickup item % requires a stop selection', v_product_id;
|
||||
END IF;
|
||||
|
||||
PERFORM 1
|
||||
FROM product_stops
|
||||
WHERE product_id = v_product_id AND stop_id = p_stop_id
|
||||
LIMIT 1;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'Pickup product % is not available at stop %', v_product_id, p_stop_id;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
v_computed_total := v_computed_total + (v_product_price * v_quantity);
|
||||
END LOOP;
|
||||
|
||||
-- ── Create order (with brand_id) ─────────────────────────────────────────
|
||||
INSERT INTO orders (
|
||||
idempotency_key, customer_name, customer_email, customer_phone,
|
||||
stop_id, brand_id, subtotal, status
|
||||
) VALUES (
|
||||
p_idempotency_key, p_customer_name, p_customer_email, p_customer_phone,
|
||||
p_stop_id, v_brand_id, v_computed_total, 'pending'
|
||||
)
|
||||
RETURNING id INTO v_order_id;
|
||||
|
||||
-- ── Insert order items and build return value ─────────────────────────────
|
||||
FOR v_item IN SELECT * FROM jsonb_array_elements(p_items)
|
||||
LOOP
|
||||
v_product_id := (v_item->>'id')::UUID;
|
||||
v_quantity := (v_item->>'quantity')::INT;
|
||||
v_fulfillment := v_item->>'fulfillment';
|
||||
|
||||
SELECT price, name INTO v_product_price, v_product_name FROM products WHERE id = v_product_id;
|
||||
|
||||
INSERT INTO order_items (order_id, product_id, quantity, fulfillment, price)
|
||||
VALUES (v_order_id, v_product_id, v_quantity, v_fulfillment, v_product_price);
|
||||
|
||||
v_order_items := v_order_items || jsonb_build_object(
|
||||
'product_id', v_product_id,
|
||||
'product_name', v_product_name,
|
||||
'quantity', v_quantity,
|
||||
'price', v_product_price,
|
||||
'fulfillment', v_fulfillment
|
||||
);
|
||||
END LOOP;
|
||||
|
||||
-- ── Build and return full order object ───────────────────────────────────
|
||||
RETURN jsonb_build_object(
|
||||
'id', v_order_id,
|
||||
'customer_name', p_customer_name,
|
||||
'customer_email', p_customer_email,
|
||||
'customer_phone', p_customer_phone,
|
||||
'subtotal', v_computed_total,
|
||||
'status', 'pending',
|
||||
'stop_id', p_stop_id,
|
||||
'brand_id', v_brand_id,
|
||||
'stop_city', v_stop_city,
|
||||
'stop_state', v_stop_state,
|
||||
'stop_date', v_stop_date,
|
||||
'stop_time', v_stop_time,
|
||||
'stop_location', v_stop_location,
|
||||
'items', v_order_items
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ── 3. Update trigger to resolve brand from NEW.brand_id first ───────────────
|
||||
-- NEW.brand_id is the primary source (always set by create_order_with_items).
|
||||
-- Stop lookup is the fallback (for pre-existing orders that lack brand_id).
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_create_contact_from_order ON public.orders;
|
||||
DROP FUNCTION IF EXISTS public.upsert_contact_from_order();
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.upsert_contact_from_order()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_brand_id UUID;
|
||||
v_customer_id UUID;
|
||||
BEGIN
|
||||
-- Primary: resolve brand_id from the order itself (NEW.brand_id, always set in Stage 3+)
|
||||
IF NEW.brand_id IS NOT NULL THEN
|
||||
v_brand_id := NEW.brand_id;
|
||||
ELSE
|
||||
-- Fallback: resolve via stop (for pre-Stage 3 orders without brand_id)
|
||||
SELECT brand_id INTO v_brand_id
|
||||
FROM public.stops
|
||||
WHERE id = NEW.stop_id;
|
||||
IF NOT FOUND THEN
|
||||
RETURN NEW; -- cannot resolve brand: skip customer/contact linking
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- No email AND no phone: nothing to upsert
|
||||
IF (NEW.customer_email IS NULL OR NEW.customer_email = '') AND
|
||||
(NEW.customer_phone IS NULL OR NEW.customer_phone = '') THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Step 1: upsert canonical customer
|
||||
v_customer_id := upsert_customer_from_order(
|
||||
v_brand_id,
|
||||
NEW.customer_email,
|
||||
NEW.customer_phone,
|
||||
NEW.customer_name,
|
||||
'order'
|
||||
);
|
||||
|
||||
-- Step 2: upsert communication contact, linked to customers.id
|
||||
INSERT INTO public.communication_contacts
|
||||
(brand_id, email, phone, full_name, source, customer_id, email_opt_in, metadata)
|
||||
VALUES (
|
||||
v_brand_id,
|
||||
NEW.customer_email,
|
||||
NEW.customer_phone,
|
||||
NEW.customer_name,
|
||||
'order',
|
||||
v_customer_id,
|
||||
true,
|
||||
jsonb_build_object(
|
||||
'last_order_id', NEW.id,
|
||||
'last_order_at', now()::TEXT,
|
||||
'stop_id', NEW.stop_id::TEXT
|
||||
)
|
||||
)
|
||||
ON CONFLICT (brand_id, email) WHERE email IS NOT NULL DO UPDATE SET
|
||||
full_name = COALESCE(EXCLUDED.full_name, communication_contacts.full_name),
|
||||
phone = COALESCE(EXCLUDED.phone, communication_contacts.phone),
|
||||
customer_id = COALESCE(EXCLUDED.customer_id, communication_contacts.customer_id),
|
||||
metadata = jsonb_build_object(
|
||||
'last_order_id', communication_contacts.metadata->>'last_order_id',
|
||||
'last_order_at', communication_contacts.metadata->>'last_order_at',
|
||||
'stop_id', NEW.stop_id::TEXT
|
||||
),
|
||||
updated_at = now();
|
||||
-- email_opt_in, sms_opt_in, unsubscribed_at intentionally NOT updated (preserve opt-out)
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER trg_create_contact_from_order
|
||||
AFTER INSERT ON public.orders
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.upsert_contact_from_order();
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -1,646 +0,0 @@
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
-- Migration 022: Operational Events
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
-- Append-only event layer for recording important platform actions.
|
||||
-- Scope: table + indexes + RLS + helpers + 4 initial emitters.
|
||||
-- What NOT included: automation, queues, webhooks, retries, cron jobs.
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- 1. operational_events table
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.operational_events (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
|
||||
event_type TEXT NOT NULL,
|
||||
entity_type TEXT,
|
||||
entity_id UUID,
|
||||
actor_type TEXT,
|
||||
actor_id UUID,
|
||||
source TEXT NOT NULL DEFAULT 'system',
|
||||
payload JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT operational_events_event_type CHECK (
|
||||
event_type ~ '^[a-z][a-z0-9_]*$'
|
||||
)
|
||||
);
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- 2. Indexes
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_oe_brand_id ON operational_events(brand_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_oe_event_type ON operational_events(event_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_oe_entity ON operational_events(entity_type, entity_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_oe_created_at ON operational_events(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_oe_brand_type ON operational_events(brand_id, event_type, created_at DESC);
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- 3. RLS policies
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
ALTER TABLE operational_events ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS "Brand admin can read operational_events" ON operational_events;
|
||||
CREATE POLICY "Brand admin can read operational_events"
|
||||
ON operational_events FOR SELECT TO authenticated
|
||||
USING (
|
||||
brand_id IN (
|
||||
SELECT brand_id FROM admin_users
|
||||
WHERE admin_users.user_id = auth.uid()
|
||||
AND admin_users.role = 'brand_admin'
|
||||
)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS "Platform admin can read operational_events" ON operational_events;
|
||||
CREATE POLICY "Platform admin can read operational_events"
|
||||
ON operational_events FOR SELECT TO authenticated
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM admin_users
|
||||
WHERE admin_users.user_id = auth.uid()
|
||||
AND admin_users.role = 'platform_admin'
|
||||
)
|
||||
);
|
||||
|
||||
-- Writes go through SECURITY DEFINER helpers only.
|
||||
-- No INSERT granted to authenticated roles.
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- 4. record_operational_event helper — bypasses RLS, all emitters call this
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.record_operational_event(
|
||||
p_brand_id UUID,
|
||||
p_event_type TEXT,
|
||||
p_entity_type TEXT,
|
||||
p_entity_id UUID,
|
||||
p_actor_type TEXT,
|
||||
p_actor_id UUID,
|
||||
p_source TEXT,
|
||||
p_payload JSONB
|
||||
)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
INSERT INTO operational_events (
|
||||
brand_id, event_type, entity_type, entity_id,
|
||||
actor_type, actor_id, source, payload
|
||||
) VALUES (
|
||||
p_brand_id, p_event_type, p_entity_type, p_entity_id,
|
||||
p_actor_type, p_actor_id, p_source, p_payload
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- 5. record_pickup_completed_event helper — called from TypeScript action
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.record_pickup_completed_event(
|
||||
p_order_id UUID,
|
||||
p_brand_id UUID,
|
||||
p_actor_id UUID
|
||||
)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_order_data JSONB;
|
||||
BEGIN
|
||||
SELECT jsonb_build_object(
|
||||
'subtotal', subtotal,
|
||||
'customer_name', customer_name
|
||||
) INTO v_order_data
|
||||
FROM orders
|
||||
WHERE id = p_order_id;
|
||||
|
||||
PERFORM record_operational_event(
|
||||
p_brand_id,
|
||||
'pickup_completed',
|
||||
'order',
|
||||
p_order_id,
|
||||
'admin',
|
||||
p_actor_id,
|
||||
'system',
|
||||
coalesce(v_order_data, '{}'::JSONB)
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- 6. create_order_with_items — with order_placed emitter
|
||||
-- (explicit replace; same signature as migration 021)
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE OR REPLACE FUNCTION create_order_with_items(
|
||||
p_idempotency_key UUID,
|
||||
p_customer_name TEXT,
|
||||
p_customer_email TEXT,
|
||||
p_customer_phone TEXT,
|
||||
p_stop_id UUID,
|
||||
p_items JSONB
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_order_id UUID;
|
||||
v_item JSONB;
|
||||
v_product_id UUID;
|
||||
v_quantity INT;
|
||||
v_fulfillment TEXT;
|
||||
v_product_price NUMERIC;
|
||||
v_computed_total NUMERIC := 0;
|
||||
v_stop_active BOOLEAN;
|
||||
v_stop_brand_id UUID;
|
||||
v_stop_city TEXT;
|
||||
v_stop_state TEXT;
|
||||
v_stop_date TEXT;
|
||||
v_stop_time TEXT;
|
||||
v_stop_location TEXT;
|
||||
v_product_active BOOLEAN;
|
||||
v_product_brand_id UUID;
|
||||
v_product_name TEXT;
|
||||
v_is_pickup BOOLEAN;
|
||||
v_has_pickup BOOLEAN := false;
|
||||
v_order JSONB;
|
||||
v_order_items JSONB := '[]'::JSONB;
|
||||
v_brand_id UUID;
|
||||
BEGIN
|
||||
-- ── Idempotency guard ─────────────────────────────────────────────────────
|
||||
SELECT jsonb_build_object(
|
||||
'id', o.id,
|
||||
'customer_name', o.customer_name,
|
||||
'customer_email', o.customer_email,
|
||||
'customer_phone', o.customer_phone,
|
||||
'subtotal', o.subtotal,
|
||||
'status', o.status,
|
||||
'stop_id', o.stop_id,
|
||||
'brand_id', o.brand_id,
|
||||
'stop_city', s.city,
|
||||
'stop_state', s.state,
|
||||
'stop_date', s.date,
|
||||
'stop_time', s.time,
|
||||
'stop_location', s.location,
|
||||
'items', COALESCE((
|
||||
SELECT jsonb_agg(jsonb_build_object(
|
||||
'product_id', oi.product_id,
|
||||
'product_name', p.name,
|
||||
'quantity', oi.quantity,
|
||||
'price', oi.price,
|
||||
'fulfillment', oi.fulfillment
|
||||
))
|
||||
FROM order_items oi
|
||||
JOIN products p ON oi.product_id = p.id
|
||||
WHERE oi.order_id = o.id
|
||||
), '[]'::JSONB)
|
||||
)
|
||||
INTO v_order
|
||||
FROM orders o
|
||||
LEFT JOIN stops s ON o.stop_id = s.id
|
||||
WHERE o.idempotency_key = p_idempotency_key
|
||||
LIMIT 1;
|
||||
|
||||
IF v_order IS NOT NULL THEN
|
||||
RETURN v_order;
|
||||
END IF;
|
||||
|
||||
-- ── Resolve brand_id ──────────────────────────────────────────────────────
|
||||
IF p_stop_id IS NOT NULL THEN
|
||||
SELECT active, brand_id, city, state, date, time, location
|
||||
INTO v_stop_active, v_stop_brand_id, v_stop_city, v_stop_state, v_stop_date, v_stop_time, v_stop_location
|
||||
FROM stops
|
||||
WHERE id = p_stop_id;
|
||||
|
||||
IF v_stop_brand_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Stop not found: %', p_stop_id;
|
||||
END IF;
|
||||
|
||||
IF NOT v_stop_active THEN
|
||||
RAISE EXCEPTION 'Stop is not active: %', p_stop_id;
|
||||
END IF;
|
||||
|
||||
v_brand_id := v_stop_brand_id;
|
||||
ELSE
|
||||
v_product_id := (jsonb_array_elements(p_items)->>'id')::UUID;
|
||||
|
||||
SELECT brand_id INTO v_brand_id
|
||||
FROM products
|
||||
WHERE id = v_product_id;
|
||||
|
||||
IF v_brand_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Product not found: %', v_product_id;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- ── Validate items and compute subtotal ─────────────────────────────────
|
||||
FOR v_item IN SELECT * FROM jsonb_array_elements(p_items)
|
||||
LOOP
|
||||
v_product_id := (v_item->>'id')::UUID;
|
||||
v_quantity := (v_item->>'quantity')::INT;
|
||||
v_fulfillment := v_item->>'fulfillment';
|
||||
v_is_pickup := (v_fulfillment = 'pickup');
|
||||
|
||||
IF v_is_pickup THEN
|
||||
v_has_pickup := true;
|
||||
END IF;
|
||||
|
||||
SELECT active, brand_id, price, name
|
||||
INTO v_product_active, v_product_brand_id, v_product_price, v_product_name
|
||||
FROM products
|
||||
WHERE id = v_product_id;
|
||||
|
||||
IF v_product_brand_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Product not found: %', v_product_id;
|
||||
END IF;
|
||||
|
||||
IF v_product_brand_id != v_brand_id THEN
|
||||
RAISE EXCEPTION 'Product % does not belong to brand %', v_product_id, v_brand_id;
|
||||
END IF;
|
||||
|
||||
IF NOT v_product_active THEN
|
||||
RAISE EXCEPTION 'Product is not active: %', v_product_id;
|
||||
END IF;
|
||||
|
||||
IF v_is_pickup THEN
|
||||
IF p_stop_id IS NULL THEN
|
||||
RAISE EXCEPTION 'Pickup item % requires a stop selection', v_product_id;
|
||||
END IF;
|
||||
|
||||
PERFORM 1
|
||||
FROM product_stops
|
||||
WHERE product_id = v_product_id AND stop_id = p_stop_id
|
||||
LIMIT 1;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'Pickup product % is not available at stop %', v_product_id, p_stop_id;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
v_computed_total := v_computed_total + (v_product_price * v_quantity);
|
||||
END LOOP;
|
||||
|
||||
-- ── Create order (with brand_id) ─────────────────────────────────────────
|
||||
INSERT INTO orders (
|
||||
idempotency_key, customer_name, customer_email, customer_phone,
|
||||
stop_id, brand_id, subtotal, status
|
||||
) VALUES (
|
||||
p_idempotency_key, p_customer_name, p_customer_email, p_customer_phone,
|
||||
p_stop_id, v_brand_id, v_computed_total, 'pending'
|
||||
)
|
||||
RETURNING id INTO v_order_id;
|
||||
|
||||
-- ── Insert order items and build return value ─────────────────────────────
|
||||
FOR v_item IN SELECT * FROM jsonb_array_elements(p_items)
|
||||
LOOP
|
||||
v_product_id := (v_item->>'id')::UUID;
|
||||
v_quantity := (v_item->>'quantity')::INT;
|
||||
v_fulfillment := v_item->>'fulfillment';
|
||||
|
||||
SELECT price, name INTO v_product_price, v_product_name FROM products WHERE id = v_product_id;
|
||||
|
||||
INSERT INTO order_items (order_id, product_id, quantity, fulfillment, price)
|
||||
VALUES (v_order_id, v_product_id, v_quantity, v_fulfillment, v_product_price);
|
||||
|
||||
v_order_items := v_order_items || jsonb_build_object(
|
||||
'product_id', v_product_id,
|
||||
'product_name', v_product_name,
|
||||
'quantity', v_quantity,
|
||||
'price', v_product_price,
|
||||
'fulfillment', v_fulfillment
|
||||
);
|
||||
END LOOP;
|
||||
|
||||
-- ── Emit order_placed event ───────────────────────────────────────────────
|
||||
PERFORM record_operational_event(
|
||||
v_brand_id,
|
||||
'order_placed',
|
||||
'order',
|
||||
v_order_id,
|
||||
'customer',
|
||||
NULL,
|
||||
'system',
|
||||
jsonb_build_object(
|
||||
'subtotal', v_computed_total,
|
||||
'item_count', jsonb_array_length(p_items),
|
||||
'has_pickup', v_has_pickup
|
||||
)
|
||||
);
|
||||
|
||||
-- ── Build and return full order object ───────────────────────────────────
|
||||
RETURN jsonb_build_object(
|
||||
'id', v_order_id,
|
||||
'customer_name', p_customer_name,
|
||||
'customer_email', p_customer_email,
|
||||
'customer_phone', p_customer_phone,
|
||||
'subtotal', v_computed_total,
|
||||
'status', 'pending',
|
||||
'stop_id', p_stop_id,
|
||||
'brand_id', v_brand_id,
|
||||
'stop_city', v_stop_city,
|
||||
'stop_state', v_stop_state,
|
||||
'stop_date', v_stop_date,
|
||||
'stop_time', v_stop_time,
|
||||
'stop_location', v_stop_location,
|
||||
'items', v_order_items
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- 7. import_communication_contacts_batch — with contact_imported emitter
|
||||
-- (explicit replace; same signature as migrations 017/018)
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.import_communication_contacts_batch(
|
||||
p_brand_id UUID,
|
||||
p_contacts JSONB,
|
||||
p_allow_opt_in_override BOOLEAN DEFAULT false
|
||||
)
|
||||
RETURNS JSONB
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_entry JSONB;
|
||||
v_result JSONB := '{"created": 0, "updated": 0, "skipped": 0, "errors": []}'::JSONB;
|
||||
v_existing communication_contacts%ROWTYPE;
|
||||
v_email TEXT;
|
||||
v_phone TEXT;
|
||||
v_tags TEXT[];
|
||||
BEGIN
|
||||
FOR v_entry IN SELECT * FROM jsonb_array_elements(p_contacts)
|
||||
LOOP
|
||||
v_email := nullif(v_entry->>'email', '');
|
||||
v_phone := nullif(v_entry->>'phone', '');
|
||||
|
||||
IF (v_entry->>'tags') IS NOT NULL AND (v_entry->>'tags') != '' THEN
|
||||
v_tags := coalesce(
|
||||
(SELECT array_agg(trim(x)) FROM unnest(string_to_array(v_entry->>'tags', ';')) AS x WHERE trim(x) != ''),
|
||||
'{}'
|
||||
);
|
||||
ELSE
|
||||
v_tags := '{}';
|
||||
END IF;
|
||||
|
||||
BEGIN
|
||||
IF v_email IS NOT NULL AND v_email != '' THEN
|
||||
SELECT * INTO v_existing
|
||||
FROM public.communication_contacts
|
||||
WHERE brand_id = p_brand_id AND email = v_email;
|
||||
|
||||
IF FOUND THEN
|
||||
IF v_existing.unsubscribed_at IS NOT NULL AND
|
||||
coalesce((v_entry->>'email_opt_in')::BOOLEAN, false) = true AND
|
||||
p_allow_opt_in_override = false THEN
|
||||
v_result := jsonb_set(v_result, '{skipped}',
|
||||
to_jsonb((v_result->>'skipped')::INTEGER + 1));
|
||||
ELSE
|
||||
UPDATE public.communication_contacts SET
|
||||
phone = COALESCE(nullif(v_entry->>'phone', ''), phone),
|
||||
first_name = COALESCE(nullif(v_entry->>'first_name', ''), first_name),
|
||||
last_name = COALESCE(nullif(v_entry->>'last_name', ''), last_name),
|
||||
full_name = COALESCE(nullif(v_entry->>'full_name', ''), full_name),
|
||||
source = 'import',
|
||||
external_id = COALESCE(nullif(v_entry->>'external_id', ''), external_id),
|
||||
email_opt_in = CASE
|
||||
WHEN communication_contacts.unsubscribed_at IS NOT NULL
|
||||
THEN communication_contacts.email_opt_in
|
||||
ELSE coalesce(
|
||||
(v_entry->>'email_opt_in')::BOOLEAN,
|
||||
communication_contacts.email_opt_in,
|
||||
true
|
||||
)
|
||||
END,
|
||||
sms_opt_in = CASE
|
||||
WHEN communication_contacts.unsubscribed_at IS NOT NULL
|
||||
THEN communication_contacts.sms_opt_in
|
||||
ELSE coalesce(
|
||||
(v_entry->>'sms_opt_in')::BOOLEAN,
|
||||
communication_contacts.sms_opt_in,
|
||||
false
|
||||
)
|
||||
END,
|
||||
email_opt_in_at = CASE
|
||||
WHEN communication_contacts.unsubscribed_at IS NOT NULL
|
||||
THEN email_opt_in_at
|
||||
WHEN coalesce(
|
||||
(v_entry->>'email_opt_in')::BOOLEAN,
|
||||
communication_contacts.email_opt_in,
|
||||
true
|
||||
) = true THEN now()
|
||||
ELSE email_opt_in_at
|
||||
END,
|
||||
tags = CASE
|
||||
WHEN v_tags != '{}' THEN v_tags
|
||||
ELSE communication_contacts.tags
|
||||
END,
|
||||
metadata = communication_contacts.metadata || jsonb_build_object(
|
||||
'imported_at', now()::TEXT
|
||||
),
|
||||
updated_at = now()
|
||||
WHERE brand_id = p_brand_id AND email = v_email;
|
||||
|
||||
v_result := jsonb_set(v_result, '{updated}',
|
||||
to_jsonb((v_result->>'updated')::INTEGER + 1));
|
||||
END IF;
|
||||
|
||||
ELSE
|
||||
INSERT INTO public.communication_contacts
|
||||
(brand_id, email, phone, first_name, last_name, full_name, source,
|
||||
external_id, email_opt_in, sms_opt_in, email_opt_in_at, sms_opt_in_at,
|
||||
tags, metadata)
|
||||
VALUES (
|
||||
p_brand_id,
|
||||
v_email,
|
||||
nullif(v_entry->>'phone', ''),
|
||||
nullif(v_entry->>'first_name', ''),
|
||||
nullif(v_entry->>'last_name', ''),
|
||||
nullif(v_entry->>'full_name', ''),
|
||||
'import',
|
||||
nullif(v_entry->>'external_id', ''),
|
||||
coalesce((v_entry->>'email_opt_in')::BOOLEAN, true),
|
||||
coalesce((v_entry->>'sms_opt_in')::BOOLEAN, false),
|
||||
CASE WHEN coalesce((v_entry->>'email_opt_in')::BOOLEAN, true) = true THEN now() ELSE NULL END,
|
||||
CASE WHEN coalesce((v_entry->>'sms_opt_in')::BOOLEAN, false) = true THEN now() ELSE NULL END,
|
||||
v_tags,
|
||||
jsonb_build_object('imported_at', now()::TEXT)
|
||||
);
|
||||
v_result := jsonb_set(v_result, '{created}',
|
||||
to_jsonb((v_result->>'created')::INTEGER + 1));
|
||||
END IF;
|
||||
|
||||
ELSIF v_phone IS NOT NULL AND v_phone != '' THEN
|
||||
SELECT * INTO v_existing
|
||||
FROM public.communication_contacts
|
||||
WHERE brand_id = p_brand_id AND phone = v_phone;
|
||||
|
||||
IF FOUND THEN
|
||||
IF v_existing.unsubscribed_at IS NOT NULL AND
|
||||
coalesce((v_entry->>'email_opt_in')::BOOLEAN, false) = true AND
|
||||
p_allow_opt_in_override = false THEN
|
||||
v_result := jsonb_set(v_result, '{skipped}',
|
||||
to_jsonb((v_result->>'skipped')::INTEGER + 1));
|
||||
ELSE
|
||||
UPDATE public.communication_contacts SET
|
||||
email = COALESCE(nullif(v_entry->>'email', ''), email),
|
||||
first_name = COALESCE(nullif(v_entry->>'first_name', ''), first_name),
|
||||
last_name = COALESCE(nullif(v_entry->>'last_name', ''), last_name),
|
||||
full_name = COALESCE(nullif(v_entry->>'full_name', ''), full_name),
|
||||
source = 'import',
|
||||
email_opt_in = CASE
|
||||
WHEN communication_contacts.unsubscribed_at IS NOT NULL
|
||||
THEN communication_contacts.email_opt_in
|
||||
ELSE coalesce(
|
||||
(v_entry->>'email_opt_in')::BOOLEAN,
|
||||
communication_contacts.email_opt_in,
|
||||
true
|
||||
)
|
||||
END,
|
||||
tags = CASE WHEN v_tags != '{}' THEN v_tags ELSE communication_contacts.tags END,
|
||||
metadata = communication_contacts.metadata || jsonb_build_object(
|
||||
'imported_at', now()::TEXT
|
||||
),
|
||||
updated_at = now()
|
||||
WHERE brand_id = p_brand_id AND phone = v_phone;
|
||||
|
||||
v_result := jsonb_set(v_result, '{updated}',
|
||||
to_jsonb((v_result->>'updated')::INTEGER + 1));
|
||||
END IF;
|
||||
ELSE
|
||||
INSERT INTO public.communication_contacts
|
||||
(brand_id, email, phone, first_name, last_name, full_name, source,
|
||||
email_opt_in, sms_opt_in, tags, metadata)
|
||||
VALUES (
|
||||
p_brand_id,
|
||||
nullif(v_entry->>'email', ''),
|
||||
v_phone,
|
||||
nullif(v_entry->>'first_name', ''),
|
||||
nullif(v_entry->>'last_name', ''),
|
||||
nullif(v_entry->>'full_name', ''),
|
||||
'import',
|
||||
coalesce((v_entry->>'email_opt_in')::BOOLEAN, true),
|
||||
coalesce((v_entry->>'sms_opt_in')::BOOLEAN, false),
|
||||
v_tags,
|
||||
jsonb_build_object('imported_at', now()::TEXT)
|
||||
);
|
||||
v_result := jsonb_set(v_result, '{created}',
|
||||
to_jsonb((v_result->>'created')::INTEGER + 1));
|
||||
END IF;
|
||||
|
||||
ELSE
|
||||
v_result := jsonb_set(
|
||||
v_result, '{errors}',
|
||||
v_result->'errors' || jsonb_build_array(
|
||||
jsonb_build_object('row', v_entry, 'error', 'No email or phone provided')
|
||||
)
|
||||
);
|
||||
END IF;
|
||||
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
v_result := jsonb_set(
|
||||
v_result, '{errors}',
|
||||
v_result->'errors' || jsonb_build_array(
|
||||
jsonb_build_object('row', v_entry, 'error', SQLERRM)
|
||||
)
|
||||
);
|
||||
END;
|
||||
END LOOP;
|
||||
|
||||
-- ── Emit contact_imported event ──────────────────────────────────────────
|
||||
PERFORM record_operational_event(
|
||||
p_brand_id,
|
||||
'contact_imported',
|
||||
NULL,
|
||||
NULL,
|
||||
'admin',
|
||||
NULL,
|
||||
'system',
|
||||
jsonb_build_object(
|
||||
'created', (v_result->>'created')::INTEGER,
|
||||
'updated', (v_result->>'updated')::INTEGER,
|
||||
'skipped', (v_result->>'skipped')::INTEGER,
|
||||
'total',
|
||||
(v_result->>'created')::INTEGER
|
||||
+ (v_result->>'updated')::INTEGER
|
||||
+ (v_result->>'skipped')::INTEGER
|
||||
)
|
||||
);
|
||||
|
||||
RETURN v_result;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- 8. send_campaign — with campaign_sent emitter
|
||||
-- (explicit replace; same body as migration 017)
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.send_campaign(p_campaign_id UUID)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_campaign communication_campaigns;
|
||||
v_settings communication_settings;
|
||||
v_audience JSONB;
|
||||
v_entry JSONB;
|
||||
v_entries JSONB := '[]'::JSONB;
|
||||
v_count INTEGER := 0;
|
||||
BEGIN
|
||||
SELECT * INTO v_campaign FROM communication_campaigns WHERE id = p_campaign_id;
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'Campaign not found');
|
||||
END IF;
|
||||
|
||||
SELECT * INTO v_settings FROM communication_settings WHERE brand_id = v_campaign.brand_id;
|
||||
IF NOT FOUND THEN
|
||||
RETURN jsonb_build_object('success', false, 'error', 'No communication settings for brand');
|
||||
END IF;
|
||||
|
||||
v_audience := preview_campaign_audience(v_campaign.brand_id, v_campaign.audience_rules);
|
||||
|
||||
FOR v_entry IN SELECT * FROM jsonb_array_elements(coalesce(v_audience->'sample_customers', '[]'::jsonb))
|
||||
LOOP
|
||||
CONTINUE WHEN (v_entry->>'email') IS NULL OR (v_entry->>'email') = '';
|
||||
|
||||
v_entries := v_entries || jsonb_build_array(jsonb_build_object(
|
||||
'brand_id', v_campaign.brand_id,
|
||||
'campaign_id', p_campaign_id,
|
||||
'customer_id', nullif(v_entry->>'id', ''),
|
||||
'customer_email', v_entry->>'email',
|
||||
'delivery_method','email',
|
||||
'subject', v_campaign.subject,
|
||||
'body_preview', left(v_campaign.body_text, 500),
|
||||
'status', 'queued'
|
||||
));
|
||||
v_count := v_count + 1;
|
||||
END LOOP;
|
||||
|
||||
PERFORM log_communication_messages(v_entries);
|
||||
|
||||
UPDATE communication_campaigns
|
||||
SET status = 'sent', sent_at = now(), updated_at = now()
|
||||
WHERE id = p_campaign_id;
|
||||
|
||||
-- ── Emit campaign_sent event ────────────────────────────────────────────
|
||||
PERFORM record_operational_event(
|
||||
v_campaign.brand_id,
|
||||
'campaign_sent',
|
||||
'campaign',
|
||||
p_campaign_id,
|
||||
'system',
|
||||
NULL,
|
||||
'system',
|
||||
jsonb_build_object(
|
||||
'messages_logged', v_count,
|
||||
'subject', v_campaign.subject
|
||||
)
|
||||
);
|
||||
|
||||
RETURN jsonb_build_object('success', true, 'messages_logged', v_count);
|
||||
END;
|
||||
$$;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user