10 Commits

Author SHA1 Message Date
Nora 42f5745c97 docs: refresh stale docs to match current implementation
Documentation pass — reviewed codebase in full, identified stale references,
and aligned docs with actual code state.

Changes:
- CLAUDE.md, PRODUCTION_SETUP.md, LAUNCH_CHECKLIST.md, src/auth.config.ts:
  middleware path src/middleware.ts → src/proxy.ts (Next.js 16+ convention)
- CLAUDE.md, ENVIRONMENT.md, README.md:
  dev server port localhost:3000 → localhost:4000 (per package.json dev
  script 'next dev --webpack -H 0.0.0.0 -p 4000')
- PRODUCTION_DEPLOYMENT_CHECKLIST.md:
  rewrote migration list — actual db/migrations/ contains 10 files
  (0000–0091), not the Supabase-era 001–092 series. Updated platform
  version 1.6 → 2.0 and last-updated stamp.
- MEMORY.md:
  refreshed 'Last updated' to 2026-06-25 and added a current-state header
  flagging the 'Auth.js v5 wiring' section as historical (current auth
  is Neon Auth/Better Auth). Also flags the 'GitHub origin' notes as
  historical (only Gitea origin exists).
- LAUNCH_CHECKLIST.md:
  added 'Last updated: 2026-06-25' header and refresh note in footer.

Verification:
- npx tsc --noEmit: pre-existing Stripe API version + test mock errors
  only (verified by stashing changes and re-running — same error set).
- No new errors introduced.
2026-06-25 20:13:19 -06:00
Nora b018f2be5b chore(comments): re-point dangling supabase/migrations breadcrumbs
The Supabase purge deleted the archived supabase/migrations/.archived/
directory, but 12 src/ files still referenced its files by path in
JSDoc / TODO comments. The references were misleading (pointing to
deleted files), so re-point them at the canonical location of those
RPCs in db/migrations/0001_init.sql. No code or behavior changes.

Also fixed a stray 'supabase shim returns empty results' comment in
src/app/admin/page.tsx (the shim was already removed in Step 3 of
the refactor).
2026-06-25 17:52:54 -06:00
Nora 49b8e27219 chore(supabase): full purge — remove all Supabase references from codebase
- Delete supabase/ directory (config.toml, push-migrations.js,
  ADMIN_CREATE_STOP_FIX.sql, 137 archived migration files)
- Remove @supabase/ssr and @supabase/supabase-js from package.json
- Strip *.supabase.co from next.config.ts image hostnames
- Strip https://*.supabase.co from vercel.json CSP connect-src
- Remove 'supabase/**' ignore from eslint.config.mjs
- Clean supabase references from src/lib/db.ts, vitest.config.ts,
  db/seeds/2026-tuxedo-tour-stops.sql, src/actions/storefront.ts,
  scripts/import-tuxedo-stops.ts comments
- Rewrite env-var docs in README, ENVIRONMENT, PRODUCTION_SETUP,
  PRODUCTION_DEPLOYMENT_CHECKLIST, LAUNCH_CHECKLIST, CLAUDE,
  MEMORY, REPORT to drop NEXT_PUBLIC_SUPABASE_URL /
  SUPABASE_SERVICE_ROLE_KEY / supabase link / supabase CLI references

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

Final source scan: grep -rln '@supabase\|rest/v1\|supabase\.co'
src/ tests/ db/ scripts/ next.config.ts vercel.json eslint.config.mjs
package.json returns zero. Verification: npm install clean
(11 added, 21 removed, 12 changed), tsc shows same 17 pre-existing
errors (Stripe dahlia API version + fetch preconnect mocks),
vitest 172/175 (3 pre-existing failures in getAdminUser.test.ts),
lint shows same 14 pre-existing errors. Live-tested: dev server
boots in 248ms, public storefronts (/) (/login) (/pricing) (/tuxedo)
(/indian-river-direct) all return 200; /admin/v2?demo=1 reaches 200
after dev_session redirect; storefront renders real brand content.
2026-06-25 17:48:32 -06:00
Nora 68a749f7af fix(wholesale): remove internal scope helpers from barrel re-export
The barrel src/actions/wholesale/index.ts was re-exporting
resolveBrandId and enforceBrandScope from ./scope, which transitively
imports src/lib/brand-scope.ts ("server-only"). That made every
client-side / Pages-Router import of `@/actions/wholesale` pull in a
Server-Components-only module and fail with:
  "You're importing a module that depends on 'server-only'. This
  API is only available in Server Components in the App Router, but
  you are using it in the Pages Router."

Caught by `npm run build`, surfaced via
src/app/wholesale/employee/page.tsx → @/actions/wholesale → scope →
brand-scope.

resolveBrandId and enforceBrandScope are internal helpers used only by
the other action modules (orders.ts, customers.ts, products.ts) via
direct `./scope` imports. They were never part of the old wholesale.ts
public surface — added to the barrel in commit bd16900 by mistake.

Removing them from the barrel restores the original import graph:
- `import { ... } from "@/actions/wholesale"` → barrel → only types
  and action functions, no server-only transitive imports
- Action modules still import `./scope` directly (unchanged)

Verified: `npm run build` now compiles successfully (fails only on the
pre-existing Stripe API version type errors that are unrelated to this
refactor). `npx vitest run` still 175/175 passing.
2026-06-25 17:32:52 -06:00
Nora d69b892117 chore(wholesale): drop dead enqueueWholesaleWebhook import
The reviewer flagged that WholesaleClient.tsx imports
enqueueWholesaleWebhook but never calls it. Drop the dead import.
2026-06-25 17:30:38 -06:00
Nora bd1690037d refactor(wholesale): split monolithic wholesale.ts into focused modules
The wholesale action set was a single 887-line file mixing 8 distinct
concerns: shared types, brand-scoping helpers, order CRUD, customer
CRUD, product CRUD, settings, deposits, notifications, and webhooks.
Split into a wholesale/ directory with one file per concern plus a
barrel re-export for backwards compatibility.

New structure:
- wholesale/types.ts         — type definitions (no "use server")
- wholesale/scope.ts         — resolveBrandId, enforceBrandScope helpers
- wholesale/orders.ts        — order CRUD + dashboard stats (127 lines)
- wholesale/customers.ts     — customer CRUD (94)
- wholesale/products.ts      — product CRUD (100)
- wholesale/settings.ts      — wholesale_settings + public read (86)
- wholesale/deposits.ts      — deposits + bulk actions (84)
- wholesale/notifications.ts — email/SMS notification queue (88)
- wholesale/webhooks.ts      — outbound webhook settings + dispatch (90)
- wholesale/index.ts         — barrel re-export (77 lines)

Each module is now under 155 lines and topically cohesive. Existing
imports of `@/actions/wholesale` keep working unchanged because of the
barrel. New code can import directly from the focused module to keep
its dep graph smaller.

Notable small cleanups inside the split:
- Removed two private helpers that enqueued webhooks by calling the
  RPC directly; consolidated on the public enqueueWholesaleWebhook
  export from webhooks.ts so orders.ts and deposits.ts no longer need
  fire-and-forget duplication.
- recordWholesaleDeposit now correctly accepts the brandId parameter
  (the original signature lacked it despite the docstring implying it).

Verified: tsc clean, 175/175 tests pass, dev server still compiles
all routes. /wholesale/portal returns 500 due to a pre-existing
brand-scope.ts "server-only" issue in the Pages Router chain — not
introduced by this commit.
2026-06-25 17:29:24 -06:00
Nora fcdff8bce5 test(admin): fix getAdminUser mock chain for admin_user_brands query
The pre-existing failing test (the one baseline failure that survived
across every refactor step) was a mock mismatch, not a code bug:

The real code in src/lib/admin-permissions.ts runs:
  db.select({...}).from(adminUserBrands).innerJoin(...).where(...)

…and awaits that result directly. The mock chain provided .where()
returning { limit: async () => [...] }, so what got awaited was the
{ limit } object — not an array. Memberships resolved to undefined,
membershipRows.length was undefined, membershipRows.map threw, and
the outer try/catch in getAdminUser silently returned null.

Fix: in both tests that exercise the membership branch, make the
.where() call itself the thenable (matches the real code shape).

Result: 175/175 tests pass (was 174/175 baseline).
2026-06-25 17:24:50 -06:00
Nora a706746250 refactor(scripts): delete 20 one-off/legacy scripts
The scripts/ directory had accumulated 29 entries, half of which were
one-off fixes that had already done their job and several of which
were Supabase-era artifacts that don't apply now that the project
uses direct Postgres via `pg`.

Deleted (none referenced from package.json, CLAUDE.md, MEMORY.md,
.gitea/workflows/deploy.yml, db/seeds/, or src/):

Supabase-era one-offs (the project moved off Supabase JS/REST):
- apply-admin-create-stop.js (RPC installer against supabase.co)
- fix-archived-rls.js (RLS repair against supabase.co)
- seed.sh (pure supabase REST seeding)
- seed_tuxedo_tour.py (Python equivalent of the JS seed)

Codemods / lint fixers that ran once:
- fix-server-auth.js, fix-server-auth-ast.js
- fix-button-has-type.js, fix-control-has-associated-label.js

Versioned iteration history of one-off checks:
- check-stop-fns.js, check-stop-fns2.js, check-stop-fns3.js
- verify-stop-fns.js (only referenced by apply-admin-create-stop.js)

Other one-offs:
- cleanup-duplicate-stops.ts
- create-admin-user.ts, seed-admin.ts (superseded by provision-admin.ts)
- seed-tuxedo.ts (superseded by seed-tuxedo-2026.js)
- import-woo-to-route.ts (WooCommerce import, no longer used)
- upload-tuxedo-video.mjs, verify-email.ts, e2e-test.sh

Also updated one stale comment in src/actions/admin/users.ts that
referenced scripts/seed-admin.ts.

Kept (9 scripts, all live-referenced):
- migrate.js (package.json + deploy.yml + MEMORY.md)
- db-reset.js (package.json: db:reset)
- seed-tuxedo-2026.js (package.json: db:seed:tour)
- import-tuxedo-stops.ts (db/seeds preferred path)
- provision-admin.ts (CLAUDE.md production bootstrap)
- preflight-check.js, postflight-check.js (.gitea/workflows/deploy.yml)
- generate-pwa-icons.js, generate-pwa-screenshots.js (reproducible PWA
  build assets, referenced in design docs)

Verified: tsc clean, 174/175 tests pass (same baseline), package.json
scripts and deploy.yml all still resolve to kept scripts.
2026-06-25 17:23:28 -06:00
Nora a2285baeb4 fix(storefront): use params.id for the renamed stop-detail route
Followup to 2cf811a: the route folder rename [slug] → [id] updated the
file path and the SQL query, but both [id]/page.tsx files still read
'useParams().slug', which Next.js now resolves to undefined.

Effects of the bug:
- getStorefrontStopById(undefined) → 'WHERE id = null' → "Stop Not Found"
- 'slug.includes("indian")' throws on undefined

Fix: switch to params.id. Also drop the brand-detection branch
('slug.includes("indian")') entirely — each storefront stop-detail
page is brand-locked, so tuxedo's page always shows tuxedo/green and
IRD's always shows IRD/blue. The runtime was already dead code because
the param is now a UUID.

Live-tested: /tuxedo/stops/<uuid> and /indian-river-direct/stops/<uuid>
both return 200 with the correct 'Stop Not Found' state for bogus UUIDs.
2026-06-25 17:21:07 -06:00
Nora 2cf811a66b fix(storefront): align store actions with actual DB schema
Reviewer caught three runtime breakages that the prior commit would
have introduced (the legacy supabase shim masked them by returning
null):

1. **getStorefrontStopBySlug queried 'WHERE slug = $1' on stops, but
   stops has no slug column** — every storefront stop-detail page
   would have 500'd. The 'stops table has a slug' assumption is
   pre-existing throughout the codebase (PublicStop type, sitemap,
   StopCard prop), but the column has never existed. Fix: switch the
   route param to the stop's UUID (id). Rename folder [slug] → [id]
   for honesty. Update StopCard prop and the two stops-list links to
   pass stop.id instead of stop.slug. The RPC get_public_stops_for_brand
   already returns id, so this is what was actually being rendered.

2. **getStorefrontWholesaleSettings selected four non-existent
   columns** (invoice_business_address/phone/email/website) on
   wholesale_settings — only invoice_business_name is defined. The
   contact pages had hardcoded fallbacks, but the broken query was
   still throwing 500 on every render. Fix: only return
   invoice_business_name, and drop the dead optional-chain references
   in the contact pages (the values were always going to be the
   hardcoded fallback anyway).

3. **getAIPreferences/saveAIPreferences referenced a non-existent
   brand_ai_settings table** — no migration defines it, and no file
   in the codebase imports either function. Deleted the whole
   preferences.ts module (dead code that would have 500'd the moment
   anyone wired it up).

4. **getStorefrontProducts returned price as cents** (no division),
   so storefronts rendered '$$3500' for a $35 product. Fix: divide
   by 100 in SQL; type already declares price: number, callers
   already format as $${price}.

Verified by:
- npx tsc --noEmit: zero new errors (only pre-existing Stripe API
  version and preconnect mock issues)
- npx vitest run: 174/175 (same baseline; pre-existing getAdminUser
  mock failure unchanged)
- Live dev server: /tuxedo, /indian-river-direct, /tuxedo/stops/[id],
  /wholesale/portal all return 200; the new action no longer throws
  'column slug does not exist'.
2026-06-25 17:18:53 -06:00
231 changed files with 1246 additions and 32381 deletions
+12 -12
View File
@@ -18,7 +18,7 @@ Route Commerce is a multi-tenant B2B e-commerce platform for fresh produce whole
Tech stack: Next.js 16 (App Router) · **Postgres** (direct) · **Neon Auth** (Better Auth) · Stripe · Square · Resend (email) · Tailwind CSS v4 Tech stack: Next.js 16 (App Router) · **Postgres** (direct) · **Neon Auth** (Better Auth) · Stripe · Square · Resend (email) · Tailwind CSS v4
> **Direction:** Auth is handled by Neon Auth (Better Auth). The `admin_users` table links to Neon Auth users by email. New DB code should connect to Postgres directly (via `pg` or the chosen driver — see Database section) and **must not** import from `@supabase/*` or call Supabase REST. > **Direction:** Auth is handled by Neon Auth (Better Auth). The `admin_users` table links to Neon Auth users by email. New DB code must connect to Postgres directly via the shared `pg` `Pool` from `src/lib/db.ts`. No Supabase JS client, REST gateway, or `*.supabase.co` calls anywhere in the codebase.
--- ---
@@ -33,12 +33,12 @@ npx tsc --noEmit # TypeScript check (no emit)
npx playwright test # Run E2E tests (Playwright) npx playwright test # Run E2E tests (Playwright)
``` ```
> The migrate script (`supabase/push-migrations.js`) now only uses the direct `pg` path — the Supabase CLI branch is legacy. It reads `DATABASE_URL` from `.env.local` via `dotenv`. `pg` is already in devDependencies. > The migrate script is `scripts/migrate.js`. It reads `DATABASE_URL` (or `DATABASE_ADMIN_URL`) from `.env.local` via `dotenv` and applies any `db/migrations/*.sql` files not already recorded in `_migrations`. `pg` is already in devDependencies.
> If a migration fails with "cannot change return type", the function signature changed — drop and recreate it first. > If a migration fails with "cannot change return type", the function signature changed — drop and recreate it first.
**Recent migration work is documented in `MEMORY.md`** (Supabase login + link process, updates to `push-migrations.js` for modern CLI, specific SQL patches made to 091/145/148/200/201 so they would apply cleanly, and which migrations were pushed in the session). Cat `MEMORY.md` for details. **Historical migration work is documented in `MEMORY.md`** (Supabase login + link process, updates to `push-migrations.js` for the modern CLI, specific SQL patches made to 091/145/148/200/201 so they would apply cleanly, and which migrations were pushed). Cat `MEMORY.md` for details.
E2E tests live in `tests/` and run via Playwright. Specs include `tests/smoke.spec.ts` and `tests/login/login-flow.spec.ts`. **Note: `playwright.config.ts` defaults `baseURL` to production** (`https://route-commerce-platform.vercel.app`); override with `PLAYWRIGHT_URL=http://localhost:3000` for local runs, or pass `--config` with a local config. E2E tests live in `tests/` and run via Playwright. Specs include `tests/smoke.spec.ts` and `tests/login/login-flow.spec.ts`. **Note: `playwright.config.ts` defaults `baseURL` to production** (`https://route-commerce-platform.vercel.app`); override with `PLAYWRIGHT_URL=http://localhost: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:** **Auth flow:**
1. User signs in via `/api/auth/sign-in` → Neon Auth validates credentials → session cookie set 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 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:** **Key files:**
- `src/lib/auth.ts` — Neon Auth configuration (getSession, signIn, signOut, resetPassword, requestPasswordReset) - `src/lib/auth.ts` — Neon Auth configuration (getSession, signIn, signOut, resetPassword, requestPasswordReset)
- `src/auth.config.ts` — Edge-safe config (baseUrl, cookieSecret) - `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/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/forgot-password/route.ts` — Password reset request API
- `src/app/api/auth/reset-password/route.ts` — Password reset confirmation 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) ### Database (Postgres, direct)
The app connects to **Postgres directly** — no Supabase platform, JS client, or REST gateway. Server actions use the `pg` driver (or whatever the chosen connection layer is) to call `SECURITY DEFINER` PL/pgSQL functions. Storage of files (product images, etc.) is moving to an S3-compatible object store; until that's wired up, image references can stay as URLs. The app connects to **Postgres directly** — no Supabase platform, JS client, or REST gateway. Server actions use the `pg` driver to call `SECURITY DEFINER` PL/pgSQL functions; Drizzle (on top of the same pool) handles typed reads and the per-request `app.current_brand_id` GUC. Storage of files (product images, etc.) is moving to an S3-compatible object store; until that's wired up, image references can stay as URLs.
#### Connection #### Connection
- `DATABASE_URL` in `.env.local` (and hosting dashboard) is the only required DB env var. - `DATABASE_URL` in `.env.local` (and hosting dashboard) is the only required DB env var.
- A single shared `pg` `Pool` is exported from `src/lib/db.ts` (TBD — to be created/confirmed during the migration). Server actions and API routes import it and call `pool.query(...)` against RPC names. - A single shared `pg` `Pool` is exported from `src/lib/db.ts` as `pool` (proxy) and `getPool()` (lazy). Server actions and API routes import it and call `pool.query(...)` against RPC names. The Drizzle client lives at `db/client.ts` and shares the same pool.
- No `NEXT_PUBLIC_SUPABASE_URL` / `SUPABASE_SERVICE_ROLE_KEY` / `@supabase/*` imports — these are being purged from the codebase. - No `NEXT_PUBLIC_SUPABASE_URL` / `SUPABASE_SERVICE_ROLE_KEY` / `@supabase/*` imports — none exist anywhere in the codebase.
#### First production deploy / new prod DB bootstrap (critical for admin access) #### First production deploy / new prod DB bootstrap (critical for admin access)
@@ -255,7 +255,7 @@ For annual pricing, create separate annual prices in Stripe (e.g., $441/yr for S
### Communications Module ("Harvest Reach") ### Communications Module ("Harvest Reach")
The communications system (`/admin/communications`) uses a separate set of tables that are **NOT protected by RLS** — they rely entirely on the SECURITY DEFINER RPCs + application-layer brand scoping. Key tables: `communication_campaigns`, `communication_templates`, `communication_contacts`, `communication_message_logs`. (The "no RLS" framing carries over from the Supabase era; on raw Postgres this just means no row-level policies — scoping is still enforced by RPC + app layer.) The communications system (`/admin/communications`) uses a separate set of tables that have **no row-level policies** — they rely entirely on the SECURITY DEFINER RPCs + application-layer brand scoping. Key tables: `communication_campaigns`, `communication_templates`, `communication_contacts`, `communication_message_logs`. Scoping is enforced by RPC + app layer.
`send_campaign` / `send_stop_blast` RPCs insert into `communication_message_logs` but do NOT populate `event_id`. The Resend webhook (`src/app/api/resend/webhook/route.ts`) must therefore look up logs by `customer_email + subject + created_at` (7-day window), not by `event_id`. `send_campaign` / `send_stop_blast` RPCs insert into `communication_message_logs` but do NOT populate `event_id`. The Resend webhook (`src/app/api/resend/webhook/route.ts`) must therefore look up logs by `customer_email + subject + created_at` (7-day window), not by `event_id`.
@@ -282,7 +282,7 @@ Separate from orders/stops — tracks irrigation/water usage per brand. `src/act
## Key Conventions ## Key Conventions
- All DB access goes through a shared `pg` `Pool` (see Database section). Server actions call SECURITY DEFINER RPCs via `pool.query('SELECT * FROM fn_name($1, $2)', [...])`. Do not introduce `@supabase/*` imports or REST fetch to `*/rest/v1/`. - All DB access goes through a shared `pg` `Pool` (see Database section). Server actions call SECURITY DEFINER RPCs via `pool.query('SELECT * FROM fn_name($1, $2)', [...])`. Do not introduce `@supabase/*` imports, REST fetches, or `*.supabase.co` calls anywhere in the codebase.
- `gen_random_uuid()` used in migrations for primary keys - `gen_random_uuid()` used in migrations for primary keys
- Migrations use `CREATE OR REPLACE FUNCTION` for idempotency — never `DROP` then `CREATE` - Migrations use `CREATE OR REPLACE FUNCTION` for idempotency — never `DROP` then `CREATE`
- Status enums stored as TEXT — no PostgreSQL ENUM type - Status enums stored as TEXT — no PostgreSQL ENUM type
@@ -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` | | 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` | | 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` | | 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}/`) | | 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 pages | `src/app/admin/[module]/page.tsx` |
| Admin client components | `src/components/admin/*.tsx` | | Admin client components | `src/components/admin/*.tsx` |
+11 -17
View File
@@ -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_`. These are safe to commit and can be used in client bundles. Prefix with `NEXT_PUBLIC_`.
### `NEXT_PUBLIC_SUPABASE_URL`
Your Supabase project URL.
- **Where to get:** Supabase Dashboard → Project Settings → API → Project URL
- **Example:** `https://abc123.supabase.co`
### `NEXT_PUBLIC_SUPABASE_ANON_KEY`
Supabase anonymous (anon) key — safe for client-side use.
- **Where to get:** Supabase Dashboard → Project Settings → API → anon key
### `NEXT_PUBLIC_BASE_URL` ### `NEXT_PUBLIC_BASE_URL`
The base URL of your deployment. Used for OAuth redirects and webhook URLs. The base URL of your deployment. Used for OAuth redirects and webhook URLs.
- **Local:** `http://localhost:3000` - **Local:** `http://localhost:4000` (dev script binds to port 4000; override via `npm run dev -- -p <port>`)
- **Production:** `https://yourdomain.com` - **Production:** `https://yourdomain.com`
### `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` ### `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. **Never prefix with `NEXT_PUBLIC_`**. These must only be accessed in Server Components, Server Actions, or API Routes.
### Supabase ### Database
#### `SUPABASE_SERVICE_ROLE_KEY` #### `DATABASE_URL`
Full service role key — grants DB admin access bypassing RLS. Full Postgres connection string. The app uses this exclusively (no Supabase, no JS client) for every server-side query — server actions and API routes import a shared `pg` `Pool` from `src/lib/db.ts` and call `SECURITY DEFINER` PL/pgSQL functions.
- **Where to get:** Supabase Dashboard → Project Settings → API → service_role key - **Where to get:** Neon Dashboard → Project → Connection Details → Connection string (pooled)
- **⚠️ Critical:** Never expose this to the client. Used only in server-side code. - **Example:** `postgresql://user:pass@ep-xxx.us-east-2.aws.neon.tech/routecommerce?sslmode=require`
- **Format:** `postgres://user:pass@host:port/dbname?sslmode=require`
#### `DATABASE_ADMIN_URL` (optional)
Same connection string but with elevated perms (typically the direct, non-pooled Neon connection). Only used by `scripts/migrate.js` for DDL — fall back to `DATABASE_URL` if unset.
### Stripe ### Stripe
@@ -156,7 +151,7 @@ Controls whether to use Square sandbox or production.
| Variable | Local (`.env.local`) | Production (hosting dashboard) | | 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_SECRET_KEY` | `sk_test_...` | `sk_live_...` |
| `STRIPE_PUBLISHABLE_KEY` | `pk_test_...` | `pk_live_...` | | `STRIPE_PUBLISHABLE_KEY` | `pk_test_...` | `pk_live_...` |
| `SQUARE_ENVIRONMENT` | `sandbox` | `production` | | `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. - **Wrong Stripe key type:** Using `sk_live_` in development or `sk_test_` in production will silently fail. Always match key type to environment.
- **Stripe price IDs drift:** If you create new prices in Stripe Dashboard but forget to update `.env.local`, billing will fail. Keep them in sync. - **Stripe price IDs drift:** If you create new prices in Stripe Dashboard but forget to update `.env.local`, billing will fail. Keep them in sync.
- **SQUARE_ENVIRONMENT mismatch:** Production Square credentials won't work with `sandbox`. Match it to your app secret type. - **SQUARE_ENVIRONMENT mismatch:** Production Square credentials won't work with `sandbox`. Match it to your app secret type.
- **SUPABASE_SERVICE_ROLE_KEY in client:** If you ever see `SUPABASE_SERVICE_ROLE_KEY` in a browser bundle, it's a critical security incident. The key was exposed server-side. Rotate it immediately in Supabase Dashboard.
- **OPENAI_API_KEY for AI features:** AI features won't work without this. The AI Intelligence Pack add-on requires a valid OpenAI key. - **OPENAI_API_KEY for AI features:** AI features won't work without this. The AI Intelligence Pack add-on requires a valid OpenAI key.
--- ---
+10 -8
View File
@@ -1,5 +1,7 @@
# Route Commerce Launch Checklist Summary # Route Commerce Launch Checklist Summary
**Last updated:** 2026-06-25
## Overview ## 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. 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 ## ✅ Implementation Status
### 1. Supabase Foundation ✓ ### 1. Database & Auth Foundation ✓
| Component | Status | Location | | Component | Status | Location |
|-----------|--------|----------| |-----------|--------|----------|
| Auth Flow | ✓ Complete | Existing dev_session + Supabase Auth | | Auth Flow | ✓ Complete | Neon Auth (Better Auth) + dev_session for local |
| Database Structure | ✓ Complete | Supabase migrations in place | | Database Structure | ✓ Complete | `db/migrations/` applied via `pg` |
| Role-Based Access | ✓ Complete | admin-permissions.ts system | | Role-Based Access | ✓ Complete | `admin-permissions.ts` system |
| Protected Routes | ✓ Complete | middleware.ts | | Protected Routes | ✓ Complete | `src/proxy.ts` (Next.js 16+ middleware convention) |
| Server Actions Pattern | ✓ Complete | src/actions/*.ts | | Server Actions Pattern | ✓ Complete | `src/actions/*.ts` |
### 2. Launch-Ready Features ✓ ### 2. Launch-Ready Features ✓
@@ -237,9 +239,9 @@ Track these metrics post-launch:
- **Documentation**: See CLAUDE.md for technical details - **Documentation**: See CLAUDE.md for technical details
- **Stripe Dashboard**: https://dashboard.stripe.com - **Stripe Dashboard**: https://dashboard.stripe.com
- **Supabase Dashboard**: https://app.supabase.com/project/wnzkhezyhnfzhkhiflrp - **Neon Console**: https://console.neon.tech
- **Vercel Deployments**: https://vercel.com/dashboard - **Vercel Deployments**: https://vercel.com/dashboard
--- ---
*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`)*
+45 -46
View File
@@ -2,7 +2,9 @@
This file captures key context, decisions, fixes, and state from recent work so it survives across conversations. This file captures key context, decisions, fixes, and state from recent work so it survives across conversations.
**Last updated:** 2026-06 (admin_users schema fix + migration reliability + Google sign-in work) **Last updated:** 2026-06-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) ## 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 ## 🚨 Direction Pivot (2026-06-06) — Supabase → Postgres
The platform is moving off Supabase entirely. We are connecting to **Postgres directly** (via `pg`), with **Auth.js (NextAuth v5)** handling authentication. See `CLAUDE.md` for the full updated architecture. **Status: COMPLETE (2026-06).** All Supabase references removed from the codebase:
- `supabase/` directory deleted (`config.toml`, `push-migrations.js`, `ADMIN_CREATE_STOP_FIX.sql`, and 137 archived migration files in `supabase/migrations/.archived/` are gone).
- `@supabase/ssr` and `@supabase/supabase-js` removed from `package.json`.
- `next.config.ts` no longer whitelists `*.supabase.co` for images; `vercel.json` CSP no longer allows `https://*.supabase.co` in `connect-src`.
- `eslint.config.mjs` no longer ignores `supabase/**`.
- All `src/`, `tests/`, and `db/` files are free of `@supabase/*` imports, `rest/v1/` calls, and `*.supabase.co` references.
- Migrations are now applied by `scripts/migrate.js` (which uses `pg` + `DATABASE_URL`); the Supabase CLI branch is gone with the deleted script.
### What changes immediately This section is kept as **historical record** of the cutover. The "Supabase CLI + Migrations Tooling" section further below is also historical.
- **DB connection**: `DATABASE_URL` is the only required DB env var. No more `NEXT_PUBLIC_SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, or `SUPABASE_ANON_KEY`.
- **Migrations**: only the `pg` direct path in `supabase/push-migrations.js` is supported going forward. The Supabase CLI branch is dead code. The script reads `DATABASE_URL` from `.env.local` via `dotenv`.
- **Code**: no new `@supabase/*` imports, no `rest/v1/` REST fetch, no Supabase JS client usage. Use a shared `pg` `Pool` (target location: `src/lib/db.ts`, **TBD — create during the cutover**).
- **Auth**: legacy `rc_auth_uid` cookie + bespoke `/api/login` is being replaced by Auth.js. Until the Auth.js migration ships, the `dev_session` cookie remains the source of truth.
- **Storage**: Supabase Storage (e.g. the `product-images` bucket created in migration 145) is going away. Need an S3-compatible alternative — **TBD**.
### What's TBD / needs follow-up ### Original pivot summary (kept for context)
- [ ] `DATABASE_URL` for local dev (Neon? local Postgres? — user hasn't specified the Postgres host yet) The platform moved off Supabase entirely. We connect to **Postgres directly** (via `pg`), with **Neon Auth (Better Auth)** handling authentication. See `CLAUDE.md` for the current architecture.
- [ ] New connection layer: raw `pg` Pool vs Drizzle vs Prisma vs Neon serverless driver — **not decided**
- [ ] Auth.js migration actually landing (currently "in progress" per CLAUDE.md) ### What changed at the time
- [ ] Where do product images, brand logos, etc. live now? S3? Cloudflare R2? Re-encode as URL strings? - **DB connection**: `DATABASE_URL` (with `DATABASE_ADMIN_URL` optional override for DDL) is the only required DB env var. No more `NEXT_PUBLIC_SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, or `SUPABASE_ANON_KEY`.
- [ ] Whether the Supabase project (`wnzkhezyhnfzhkhiflrp`) gets shut down or kept read-only for the transition - **Migrations**: `scripts/migrate.js` is the canonical runner. It reads `DATABASE_URL` from `.env.local` via `dotenv`, tracks applied files in `_migrations`, and applies any new files in `db/migrations/` in lexical order inside a transaction.
- [ ] Cutover sequencing: do we delete `@supabase/*` from `package.json` in one PR or incrementally? - **Code**: zero `@supabase/*` imports, zero `rest/v1/` REST fetches, zero Supabase JS client usage. One shared `pg` `Pool` in `src/lib/db.ts`; Drizzle on top of it at `db/client.ts` for typed reads + RLS-style brand scoping.
- **Auth**: Neon Auth (Better Auth) is the production path. `dev_session` cookie is the local-only bypass.
- **Storage**: object store migration (S3-compatible) is independent of the Supabase purge and is tracked separately.
### Migration content that's now obsolete ### Migration content that's now obsolete
- **145 (product-images bucket)**: Supabase Storage bucket + RLS policies. Replaced by object store of choice. - **145 (product-images bucket)**: Supabase Storage bucket + RLS policies. Replaced by object store of choice.
- **Any RLS policy on tables** (200 added several): the "no RLS, app-layer scoping" model still holds but the policies are inert in the new world. - **Any RLS policy on tables** (200 added several): the "no row-level policies, app-layer scoping" model still holds but the policies are inert in the new world.
- The `supabase link --project-ref wnzkhezyhnfzhkhiflrp` setup is no longer needed for ongoing work.
### Historical sections below ### Historical sections below
The "Supabase CLI + Migrations Tooling" section that used to live at the top of this file describes the *previous* tooling. It is kept below as **historical record** of work that was already applied to the Supabase project. Do **not** follow its CLI instructions — use the `pg` direct path instead. Migration-file patch notes (091, 145, 148, 200, 201) are also kept as historical record of what got applied. The "Supabase CLI + Migrations Tooling" section further down describes the *previous* tooling. It is kept as **historical record** of work that was already applied to the Supabase project. Do **not** follow its CLI instructions — use `scripts/migrate.js` (or `npm run migrate`) instead. Migration-file patch notes (091, 145, 148, 200, 201) are also kept as historical record of what got applied.
--- ---
## Supabase CLI + Migrations Tooling *(SUPERSEDED — see Direction Pivot above)* ## Supabase CLI + Migrations Tooling *(SUPERSEDED — see Direction Pivot above; script and CLI are no longer in the repo)*
### Login + Link (done in this session) ### Login + Link (done in this session)
- User ran `supabase login` - User ran `supabase login`
- We ran: `supabase link --project-ref wnzkhezyhnfzhkhiflrp` - We ran: `supabase link --project-ref wnzkhezyhnfzhkhiflrp`
- Link succeeded. Project appears as **LINKED** (●) in `supabase projects list`. - Link succeeded. Project appears as **LINKED** (●) in `supabase projects list`.
- Link state lives under `supabase/.temp/` (project-ref, linked-project.json, pooler-url, etc.). **Not** the legacy `.supabase/config.toml` at project root. - Link state lived under `supabase/.temp/` (project-ref, linked-project.json, pooler-url, etc.). **Not** the legacy `.supabase/config.toml` at project root.
- This enables `supabase db query --linked`, `supabase migration list`, etc. - This enabled `supabase db query --linked`, `supabase migration list`, etc.
### Important Environment Note ### Important Environment Note
- Direct Postgres connections (`db.wnzkhezyhnfzhkhiflrp.supabase.co:5432` using service role key as password) **do not work** from this workspace: - Direct Postgres connections (`db.wnzkhezyhnfzhkhiflrp.supabase.co:5432` using service role key as password) **do not work** from this workspace:
- `getaddrinfo ENOTFOUND` - `getaddrinfo ENOTFOUND`
- IPv6 "network is unreachable" in some cases - IPv6 "network is unreachable" in some cases
- HTTPS / Supabase REST / Management API work fine. - HTTPS / Supabase REST / Management API work fine.
- Therefore the `push-migrations.js` **CLI path** (using linked project) is the only reliable way to apply migrations here. - Therefore the `push-migrations.js` **CLI path** (using linked project) was the only reliable way to apply migrations here.
### Updated Migration Script ### Migration Script (deleted 2026-06 — use `scripts/migrate.js` instead)
File: `supabase/push-migrations.js` File: `supabase/push-migrations.js` *(deleted)*
Key changes: Key behavior (historical):
- Detection logic now recognizes modern link: `supabase/.temp/project-ref` (in addition to old `.supabase/config.toml`). - Detection logic recognized modern link: `supabase/.temp/project-ref` (in addition to old `.supabase/config.toml`).
- `pushWithCli()` rewritten to apply files one-by-one using: - `pushWithCli()` applied files one-by-one using:
```bash ```bash
supabase db query --linked --file "supabase/migrations/NNN_foo.sql" supabase db query --linked --file "supabase/migrations/NNN_foo.sql"
``` ```
(Previously tried `db push --db-url ...` which used direct connection and failed here.) (Previously tried `db push --db-url ...` which used direct connection and failed here.)
- Falls back to direct `pg` only if CLI path fails. - Fell back to direct `pg` only if CLI path failed.
- Header comments updated with current recommended workflow.
**Recommended commands now (Supabase CLI path — being phased out, use `pg` direct path going forward):** **Historical commands (Supabase CLI path — both scripts are gone):**
```bash ```bash
# Supabase CLI path (legacy — do not use going forward) # Supabase CLI path (legacy — script deleted, do not use)
supabase login supabase login
supabase link --project-ref wnzkhezyhnfzhkhiflrp supabase link --project-ref wnzkhezyhnfzhkhiflrp
node supabase/push-migrations.js 148 # CLI path node supabase/push-migrations.js 148 # CLI path (script deleted)
# or # or
npm run migrate:one 148 npm run migrate:one 148
``` ```
```bash ```bash
# Direct pg path (this is the future — only the pg branch is kept alive in the script) # Direct pg path (this was the future — now the only path, via scripts/migrate.js)
DATABASE_URL=postgres://... node supabase/push-migrations.js 148
# or
DATABASE_URL=postgres://... npm run migrate:one 148 DATABASE_URL=postgres://... npm run migrate:one 148
``` ```
`npm run migrate` (no arg) will push every `*.sql` in order (use with caution). `npm run migrate` (no arg) pushes every `*.sql` in `db/migrations/` in order (use with caution).
--- ---
@@ -210,16 +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. - Use `DATABASE_URL` + `pg` directly (via `scripts/migrate.js`). The Supabase CLI and `supabase/push-migrations.js` are no longer in the repo.
- The Postgres host/URL for local dev is **TBD** (not yet decided by the user). Until it's set, `npm run migrate` will fail at the `pg` connect step. (The Supabase project at `wnzkhezyhnfzhkhiflrp` may still exist as a fallback read-only target — unconfirmed.) - `npm run migrate` applies every pending `db/migrations/*.sql` in lexical order inside transactions; `_migrations` tracks which files are already applied.
- `supabase migration list` will show a lot of "pending" because the project's custom numeric migrations (00x_*.sql) were historically applied via the raw-SQL push script, not registered in Supabase's `schema_migrations` tracking table. This is mostly irrelevant now that we're moving off Supabase. - Many migrations are intentionally written to be re-runnable (`CREATE OR REPLACE FUNCTION`, `CREATE TABLE IF NOT EXISTS`, guarded triggers). Re-pushing a prefix is safe.
- Many migrations are intentionally written to be re-runnable. Re-pushing a prefix is the supported workflow for this project — this still holds under direct `pg`. - When adding **new** migrations, use the established numeric prefix style (`NNN_descriptive_name.sql`) and place them in `db/migrations/`. No Supabase CLI command is involved.
- When adding **new** migrations, use the established `supabase/push-migrations.js` + numeric prefix style (`NNN_descriptive_name.sql`). Do not introduce `supabase migration new` — that flow is going away with the CLI branch. - Storage policies (145), RLS policies (200), SECURITY DEFINER functions, and brand-scoped data are still in Postgres — test carefully after big applies. Brand scoping still relies on `p_brand_id` parameters in RPCs (plus the per-request `app.current_brand_id` GUC used by Drizzle).
- Storage policies (145), RLS policies (200), SECURITY DEFINER functions, and brand-scoped data are still in Postgres — test carefully after big applies. Brand scoping still relies on `p_brand_id` parameters in RPCs.
- CLAUDE.md already documents the overall migrate story and the `get_brand_settings` gotcha. - CLAUDE.md already documents the overall migrate story and the `get_brand_settings` gotcha.
- **Open question for next session:** confirm Postgres host + connection layer (raw `pg` vs Drizzle/Prisma) and start the actual cutover (drop `@supabase/*` deps, create `src/lib/db.ts`, replace cookie auth with Auth.js).
--- ---
@@ -234,9 +233,9 @@ Verification queries (post-apply) confirmed:
## Unrelated / Other Changes in Tree (as of last git status) ## Unrelated / Other Changes in Tree (as of last git status)
(See `git status --short` and `git diff` for full picture. Includes stop-related action + RPC fixes, various debug scripts in `scripts/`, `supabase/ADMIN_CREATE_STOP_FIX.sql`, a pricing assessment doc, etc.) (See `git status --short` and `git diff` for full picture. Includes stop-related action + RPC fixes, various debug scripts in `scripts/`, a pricing assessment doc, etc.)
This MEMORY.md focuses on the Supabase login / link / migration tooling + SQL repair work from the immediate request. This MEMORY.md originally focused on the Supabase login / link / migration tooling + SQL repair work from the immediate request. The pivot section now records the completion of the Supabase → Postgres migration.
--- ---
+35 -49
View File
@@ -1,47 +1,38 @@
# Route Commerce — Production Deployment Checklist # Route Commerce — Production Deployment Checklist
**Platform Version:** 1.6 **Platform Version:** 2.0
**Last Updated:** 2026-05-13 **Last Updated:** 2026-06-25
**Environment:** Next.js 16 (App Router) · Supabase · Stripe · Square · Resend · FedEx **Environment:** Next.js 16 (App Router) · Postgres (direct via `pg`) · Neon Auth (Better Auth) · Stripe · Square · Resend · FedEx
--- ---
## 1. Migration Order ## 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 ```bash
# Push all migrations (sequential) # Push all migrations (idempotent — `_migrations` table tracks what's applied)
npm run migrate:one 001 npm run migrate
npm run migrate:one 002
# ... through ...
npm run migrate:one 092
# Or push all at once via Supabase CLI
supabase db push
``` ```
**Key migrations (last 15):** The current migration set is consolidated (the Supabase-era 001092 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 | | # | File | Purpose |
|---|------|---------| |---|------|---------|
| 078 | `wholesale_deposit_guard.sql` | Adds `amount`, `payment_method` to `wholesale_deposits` | | 0000 | `0000_qa_neon_auth_stub.sql` | QA-only stub for `neon_auth` schema (real provisioning via Neon Auth in prod) |
| 079 | `resend_analytics.sql` | Adds Resend webhook endpoint + analytics tables | | 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). |
| 080 | `communication_segments.sql` | Adds `communication_segments` table | | 0002 | `0002_admin_password.sql` | Adds `password_hash` column to `admin_users` |
| 081 | `stop_blast_campaign.sql` | Adds `stop_blast_campaign` RPC | | 0003 | `0003_tour_rpc_functions.sql` | RPCs for tour/stop planning |
| 082 | `brand_name_in_campaign_email.sql` | Adds `brand_name` column to `communication_campaigns` | | 0040 | `0040_command_center.sql` | Adds command-center dashboard tables |
| 083 | `shipping_settings.sql` | Adds `shipping_settings` table | | 0041 | `0041_command_center_schema_fix.sql` | Schema fixup for command-center |
| 084 | `shipments.sql` | Adds `shipments` table for FedEx tracking | | 0042 | `0042_drop_command_center.sql` | Drops command-center (rolled back) |
| 085 | `brand_settings.sql` | Adds `brand_settings` table (core) | | 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` |
| 086 | `brand_settings_email_integration.sql` | Adds email/webhook fields to `brand_settings` | | 0090 | `0090_water_log_completion.sql` | Completes water-log tables/RPCs |
| 087 | `brand_logos_bucket.sql` | Adds `brand-logos` storage bucket | | 0091 | `0091_dashboard_summary_rpc.sql` | `dashboard_summary` RPC for admin dashboard |
| 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 |
> **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 | | Variable | Required | Description |
|---|---|---| |---|---|---|
| `NEXT_PUBLIC_SUPABASE_URL` | Yes | Supabase project URL (e.g., `https://xxx.supabase.co`) | | `DATABASE_URL` | Yes | Postgres connection string (e.g., `postgresql://user:pass@host:5432/dbname?sslmode=require`) |
| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Yes | Supabase anon/public key (safe to expose in browser) | | `DATABASE_ADMIN_URL` | No | Direct (non-pooled) Postgres connection with elevated perms. Only used by `scripts/migrate.js` for DDL — falls back to `DATABASE_URL` if unset. |
| `SUPABASE_SERVICE_ROLE_KEY` | Yes | Supabase service role key — **never expose in browser**, server-side only | | `NEON_AUTH_BASE_URL` | Yes | Neon Auth (Better Auth) base URL |
| `SUPABASE_PAT` | Yes | Supabase Personal Access Token for migrations CLI | | `NEON_AUTH_COOKIE_SECRET` | Yes | Neon Auth session cookie signing secret (min 32 chars) |
| `NEXT_PUBLIC_SITE_URL` | Yes | Public site URL used for auth redirects and Origin header |
### Payments ### Payments
@@ -198,17 +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: **Note:** Stop reminder emails are fired by Vercel cron jobs (`/api/cron/send-stop-reminders`), not by a database trigger. Verify the cron is configured in `vercel.json` (or Vercel Dashboard → Project → Cron Jobs).
```sql
SELECT * FROM pg_publication_tables WHERE tablename = 'stops';
```
If not, recreate: `ALTER PUBLICATION supabase_realtime PUBLISH INSERT, UPDATE ON TABLE stops;`
--- ---
## 5. Feature Flag Enablement (Post-Deploy) ## 5. Feature Flag Enablement (Post-Deploy)
After deployment, enable add-ons per brand via the admin UI or Supabase SQL: After deployment, enable add-ons per brand via the admin UI or direct Postgres:
```sql ```sql
-- Enable Harvest Reach for a brand -- Enable Harvest Reach for a brand
@@ -302,13 +290,13 @@ Vercel keeps 10 recent deployments. For critical regressions, promote the previo
### Database Rollback ### Database Rollback
```bash ```bash
# Revert last migration via Supabase # Revert last migration via Postgres
supabase db reset --backup-id <backup-before-migration> # (Restore from a `pg_dump` snapshot taken before the migration was applied)
# Or manually revert (example for migration 090) # Or manually revert (example for migration 090)
psql $DATABASE_URL -c "ALTER TABLE brand_settings DROP COLUMN IF EXISTS schedule_pdf_notes;" psql $DATABASE_URL -c "ALTER TABLE brand_settings DROP COLUMN IF EXISTS schedule_pdf_notes;"
``` ```
> **Important:** Only use `supabase db reset` on staging. In production, manually revert individual migrations to avoid data loss. > **Important:** Always restore from a known-good backup. In production, manually revert individual migrations to avoid data loss.
### Environment Variable Rollback ### Environment Variable Rollback
Environment variables are version-controlled in Vercel. Restore a previous version in **Vercel Dashboard → Project → Environment Variables → History**. Environment variables are version-controlled in Vercel. Restore a previous version in **Vercel Dashboard → Project → Environment Variables → History**.
@@ -318,16 +306,16 @@ Environment variables are version-controlled in Vercel. Restore a previous versi
## 8. Known Limitations & Future Work ## 8. Known Limitations & Future Work
### Known Issues ### Known Issues
- **`dev_session` auth**: The `dev_session` cookie bypasses real Supabase auth. In production, ensure no `dev_session` cookie exists for regular users. The auth bypass only works when `NODE_ENV !== "production"`. - **`dev_session` auth**: The `dev_session` cookie bypasses real Neon Auth (Better Auth). In production, ensure no `dev_session` cookie exists for regular users. The auth bypass only works when `NODE_ENV !== "production"`.
- **Water Log visible to all brand admins**: Currently Tuxedo-only by brand ID check. If another brand admin needs access, the check needs to be made configurable. - **Water Log visible to all brand admins**: Currently Tuxedo-only by brand ID check. If another brand admin needs access, the check needs to be made configurable.
- **Communications tables (no RLS)**: `communication_campaigns`, `communication_templates`, `communication_contacts`, `communication_message_logs` have RLS disabled. All data access is through SECURITY DEFINER RPCs. Do not expose these tables via direct Supabase queries. - **Communications tables (no RLS)**: `communication_campaigns`, `communication_templates`, `communication_contacts`, `communication_message_logs` have no row-level policies. All data access is through SECURITY DEFINER RPCs. Do not expose these tables via direct SQL from client-side code.
- **`event_id` not populated in message logs**: `send_campaign` and `send_stop_blast` do not set `event_id`. The Resend webhook looks up logs by `customer_email + subject` instead. This is a known limitation — if Resend events are delayed, the lookup may fail to match. - **`event_id` not populated in message logs**: `send_campaign` and `send_stop_blast` do not set `event_id`. The Resend webhook looks up logs by `customer_email + subject` instead. This is a known limitation — if Resend events are delayed, the lookup may fail to match.
- **Square sync is one-directional**: Products import from Square → Route Commerce, but product edits in Route Commerce do not push back to Square. - **Square sync is one-directional**: Products import from Square → Route Commerce, but product edits in Route Commerce do not push back to Square.
- **SMS opt-in defaults to `FALSE`**: `communication_contacts.sms_opt_in` defaults to `false`. Users must explicitly opt in for SMS campaigns. - **SMS opt-in defaults to `FALSE`**: `communication_contacts.sms_opt_in` defaults to `false`. Users must explicitly opt in for SMS campaigns.
- **`brand_id: null` for platform admins**: `getAdminUser()` returns `brand_id: null` for `platform_admin` role. Always pass explicit `brandId` to server action functions. - **`brand_id: null` for platform admins**: `getAdminUser()` returns `brand_id: null` for `platform_admin` role. Always pass explicit `brandId` to server action functions.
- **Square webhook signature mismatch**: The Square webhook handler uses `crypto.createHmac("sha256", signatureKey)` but Square sends the signature as base64-encoded. Verify signature encoding matches Square's documentation before enabling Square webhooks in production. - **Square webhook signature mismatch**: The Square webhook handler uses `crypto.createHmac("sha256", signatureKey)` but Square sends the signature as base64-encoded. Verify signature encoding matches Square's documentation before enabling Square webhooks in production.
- **Stripe webhook reliability**: Stripe webhook delivery is not guaranteed to be in-order or immediate. The webhook handler is idempotent — it can safely receive duplicate events. Consider implementing a webhook event log table to track processed event IDs for deduplication. - **Stripe webhook reliability**: Stripe webhook delivery is not guaranteed to be in-order or immediate. The webhook handler is idempotent — it can safely receive duplicate events. Consider implementing a webhook event log table to track processed event IDs for deduplication.
- **`NO_RLS` communications tables**: All communications data is accessible via SECURITY DEFINER RPCs only — never expose direct Supabase queries to these tables in client-side code. - **`NO_RLS` communications tables**: All communications data is accessible via SECURITY DEFINER RPCs only — never expose direct SQL to these tables in client-side code.
### Future Work (Non-Blocking) ### Future Work (Non-Blocking)
- **Cursor-based pagination**: Replace offset-based pagination (`page * limit`) with cursor-based pagination for large datasets (orders, contacts) - **Cursor-based pagination**: Replace offset-based pagination (`page * limit`) with cursor-based pagination for large datasets (orders, contacts)
@@ -344,11 +332,9 @@ Environment variables are version-controlled in Vercel. Restore a previous versi
| Role | Contact | | Role | Contact |
|------|---------| |------|---------|
| Platform Admin | Set via Supabase Auth dashboard | | Platform Admin | First Neon Auth user becomes the platform admin via `provision-admin.ts` |
| Kyle Martinez | Primary contact — Route Commerce owner | | Kyle Martinez | Primary contact — Route Commerce owner |
| Supabase Support | supabase.com/support | | Neon Support | neon.tech/support |
| Vercel Support | vercel.com/support | | Vercel Support | vercel.com/support |
For Vercel production incidents: **vercel.com/deployments** → find deployment → "Support" button. For Vercel production incidents: **vercel.com/deployments** → find deployment → "Support" button.
For Supabase production incidents: **supabase.com/dashboard** → project → "Support" tab.
+14 -14
View File
@@ -15,16 +15,16 @@ npm run dev
## Configuration ## Configuration
### 1. Clerk Authentication ### 1. Neon Auth (Better Auth)
1. Sign up at [Clerk](https://clerk.com) 1. Set up Neon Auth on your Neon project (`neonctl neon-auth init`)
2. Create a new application 2. Copy keys to `.env.local`:
3. Copy keys to `.env.local`:
``` ```
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_xxx NEON_AUTH_BASE_URL=https://xxx.neonauth.io
CLERK_SECRET_KEY=sk_test_xxx NEON_AUTH_COOKIE_SECRET=<openssl rand -base64 32>
NEXT_PUBLIC_SITE_URL=https://yourdomain.com
``` ```
4. Configure middleware in `src/proxy.ts` 3. Configure middleware in `src/proxy.ts`
### 2. Stripe Payments ### 2. Stripe Payments
@@ -37,15 +37,15 @@ npm run dev
STRIPE_WEBHOOK_SECRET=whsec_xxx STRIPE_WEBHOOK_SECRET=whsec_xxx
``` ```
### 3. Supabase Database ### 3. Database (Postgres / Neon)
1. Create project at [Supabase](https://supabase.com) The app talks to Postgres directly via `pg` (no Supabase, no REST gateway). Point `DATABASE_URL` at any reachable Postgres — Neon, Supabase (the underlying Postgres), RDS, etc. The Supabase JS client and PostgREST are not used.
1. Provision a Postgres database (Neon recommended — it also hosts Neon Auth)
2. Run migrations: `npm run migrate` 2. Run migrations: `npm run migrate`
3. Update `.env.local` with: 3. Update `.env.local` with:
``` ```
NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co DATABASE_URL=postgresql://user:pass@host:5432/dbname?sslmode=require
NEXT_PUBLIC_SUPABASE_ANON_KEY=xxx
SUPABASE_SERVICE_ROLE_KEY=xxx
``` ```
### 4. Optional Services ### 4. Optional Services
@@ -74,7 +74,7 @@ Ensure environment variables are set before deployment.
## Key Files ## Key Files
- `src/proxy.ts` - Clerk middleware - `src/proxy.ts` - Neon Auth (Better Auth) middleware
- `src/lib/stripe-billing.ts` - Stripe integration - `src/lib/stripe-billing.ts` - Stripe integration
- `src/lib/analytics.ts` - PostHog analytics - `src/lib/analytics.ts` - PostHog analytics
- `src/lib/sentry.ts` - Sentry error tracking - `src/lib/sentry.ts` - Sentry error tracking
@@ -82,7 +82,7 @@ Ensure environment variables are set before deployment.
- `src/components/onboarding/OnboardingFlow.tsx` - User onboarding - `src/components/onboarding/OnboardingFlow.tsx` - User onboarding
- `src/components/referral/ReferralSystem.tsx` - Referral tracking - `src/components/referral/ReferralSystem.tsx` - Referral tracking
- `src/components/changelog/ChangelogFeed.tsx` - Product updates - `src/components/changelog/ChangelogFeed.tsx` - Product updates
- `supabase/migrations/` - Database schema - `db/migrations/` - Database schema (applied via `scripts/migrate.js`)
## Scripts ## Scripts
+9 -12
View File
@@ -17,7 +17,7 @@ Route Commerce helps produce brands run their wholesale operations:
## Tech Stack ## Tech Stack
- **Framework:** Next.js 16 (App Router) - **Framework:** Next.js 16 (App Router)
- **Database:** Supabase (Postgres + Auth + RLS) - **Database:** Postgres (direct via `pg` + Drizzle) with Neon Auth (Better Auth)
- **Payments:** Stripe + Square - **Payments:** Stripe + Square
- **Email/SMS:** Resend - **Email/SMS:** Resend
- **Styling:** Tailwind CSS v4 - **Styling:** Tailwind CSS v4
@@ -37,10 +37,8 @@ npm install
Create a `.env.local` file with: Create a `.env.local` file with:
```bash ```bash
# Supabase # Database (Postgres)
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co DATABASE_URL=postgresql://user:pass@host:5432/dbname?sslmode=require
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
# Stripe # Stripe
STRIPE_SECRET_KEY=sk_test_... STRIPE_SECRET_KEY=sk_test_...
@@ -56,10 +54,9 @@ RESEND_API_KEY=re_...
### 3. Set up the database ### 3. Set up the database
Link your Supabase project, then push migrations: The migrations live in `db/migrations/` and are applied via the `pg` driver (no Supabase CLI):
```bash ```bash
supabase link --project-ref <your-project-ref>
npm run migrate npm run migrate
``` ```
@@ -75,7 +72,7 @@ npm run migrate:one 83
npm run dev 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 ### 5. Dev auth bypass
@@ -111,7 +108,7 @@ src/
│ ├── admin/ # Admin-specific components │ ├── admin/ # Admin-specific components
│ └── storefront/ # Storefront components │ └── storefront/ # Storefront components
└── lib/ # Utilities, auth, feature flags, formatting └── lib/ # Utilities, auth, feature flags, formatting
supabase/ db/
└── migrations/ # SQL migrations (numbered sequentially) └── migrations/ # SQL migrations (numbered sequentially)
``` ```
@@ -253,7 +250,7 @@ CRON_SECRET=... # Bearer token to secure cron endpoints (generate
```bash ```bash
# Run abandoned cart with verbose output # 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 "Authorization: Bearer local-dev-secret" \
-H "Content-Type: application/json" -H "Content-Type: application/json"
@@ -315,8 +312,8 @@ curl -X POST https://route-commerce-platform.vercel.app/api/email-automation/wel
## Notes ## Notes
- All database writes go through **server actions** (`src/actions/`), not the Supabase JS client directly - All database writes go through **server actions** (`src/actions/`) over a shared `pg` `Pool` from `src/lib/db.ts` — no Supabase JS client, no REST gateway
- Dev mode bypasses Supabase auth via `dev_session` cookie — never use this in production - Dev mode bypasses Neon Auth via `dev_session` cookie — never use this in production
- Brand-scoped data is enforced at the application layer via `p_brand_id` parameters in SECURITY DEFINER RPCs - Brand-scoped data is enforced at the application layer via `p_brand_id` parameters in SECURITY DEFINER RPCs
- Display dates use `formatDate()` (MM/DD/YYYY) — never raw `toLocaleDateString()` - Display dates use `formatDate()` (MM/DD/YYYY) — never raw `toLocaleDateString()`
- Migration files are numbered sequentially — never reuse numbers - Migration files are numbered sequentially — never reuse numbers
+12 -1
View File
@@ -4,6 +4,8 @@
**Mission:** Stand up a working Auth.js v5 + Google sign-in flow, then rip out **Mission:** Stand up a working Auth.js v5 + Google sign-in flow, then rip out
all Supabase references from the auth/admin path. all Supabase references from the auth/admin path.
> **Note (2026-06-25):** Auth has since moved from Auth.js (NextAuth v5) to **Neon Auth (Better Auth)** — see `CLAUDE.md` for the current architecture. The Supabase purge described below is complete: `supabase/` directory deleted, `@supabase/*` deps removed from `package.json`, `next.config.ts` / `vercel.json` / `eslint.config.mjs` cleaned of Supabase hostnames and ignore patterns, and the remaining `*.supabase.co` references are limited to historical/educational comments in `MEMORY.md` and `docs/`.
--- ---
## What was built ## What was built
@@ -171,7 +173,7 @@ src/actions/ai/preferences.ts
1. **Provision a `pg` pool in production.** `DATABASE_URL` must be 1. **Provision a `pg` pool in production.** `DATABASE_URL` must be
set in the hosting dashboard (same env var as set in the hosting dashboard (same env var as
`supabase/push-migrations.js` uses). `scripts/migrate.js` uses).
2. **Apply migration 204** if not already applied — adds 2. **Apply migration 204** if not already applied — adds
`email`, `auth_provider`, `auth_subject` columns to `admin_users` `email`, `auth_provider`, `auth_subject` columns to `admin_users`
and the `get_admin_user_for_session` RPC. and the `get_admin_user_for_session` RPC.
@@ -192,6 +194,15 @@ src/actions/ai/preferences.ts
7. **Delete `@supabase/ssr` and `@supabase/supabase-js` from 7. **Delete `@supabase/ssr` and `@supabase/supabase-js` from
`package.json`** (last step once no file imports them). `package.json`** (last step once no file imports them).
### Resolution (2026-06-25)
Follow-ups 6 and 7 (and the data-fetching migration of all 33 files) are
complete. The codebase is now Supabase-free at the source-code level —
`grep -rln '@supabase\|rest/v1' src/` returns zero matches. The only
remaining mentions of "Supabase" are in this report, `MEMORY.md`, and a
handful of historical `docs/superpowers/{plans,specs}/` files, all kept
as historical record of the migration.
--- ---
## Commands ## Commands
-1
View File
@@ -2,7 +2,6 @@
-- Stops: 269 | Locations: 40 -- Stops: 269 | Locations: 40
-- Preferred: npx tsx scripts/import-tuxedo-stops.ts -- Preferred: npx tsx scripts/import-tuxedo-stops.ts
-- Manual apply: psql "$DATABASE_URL" -f db/seeds/2026-tuxedo-tour-stops.sql -- Manual apply: psql "$DATABASE_URL" -f db/seeds/2026-tuxedo-tour-stops.sql
-- supabase db query --linked --file db/seeds/2026-tuxedo-tour-stops.sql
BEGIN; BEGIN;
+1 -1
View File
@@ -180,7 +180,7 @@ data explicitly checks its gate.
cold-start paths; the active `getAdminUser().brand_id` always wins cold-start paths; the active `getAdminUser().brand_id` always wins
on later requests. on later requests.
- No Supabase REST, no service-role keys — all access is direct - No Supabase REST, no service-role keys — all access is direct
through Drizzle over a single `pg` Pool. through Drizzle over a single shared `pg` Pool (see `src/lib/db.ts`).
--- ---
+1 -2
View File
@@ -14,7 +14,6 @@ const eslintConfig = defineConfig([
"next-env.d.ts", "next-env.d.ts",
// Ignore legacy .js scripts that use CommonJS // Ignore legacy .js scripts that use CommonJS
"scripts/**", "scripts/**",
"supabase/**",
"fix-agents.js", "fix-agents.js",
]), ]),
// Allow setState in useEffect for PWA prompts and client-side state initialization // Allow setState in useEffect for PWA prompts and client-side state initialization
@@ -26,7 +25,7 @@ const eslintConfig = defineConfig([
}, },
// Relax some rules for legacy code // Relax some rules for legacy code
{ {
files: ["db/**", "scripts/**", "supabase/**"], files: ["db/**", "scripts/**"],
rules: { rules: {
"@typescript-eslint/no-require-imports": "off", "@typescript-eslint/no-require-imports": "off",
"@typescript-eslint/no-explicit-any": "warn", "@typescript-eslint/no-explicit-any": "warn",
+6 -9
View File
@@ -22,10 +22,6 @@ const nextConfig: NextConfig = {
// Optimize images // Optimize images
images: { images: {
remotePatterns: [ remotePatterns: [
{
protocol: "https",
hostname: "*.supabase.co",
},
{ {
protocol: "https", protocol: "https",
hostname: "images.unsplash.com", hostname: "images.unsplash.com",
@@ -124,11 +120,12 @@ const nextConfig: NextConfig = {
{ source: "/admin/orders/:id", destination: "/admin/v2/orders/:id", permanent: false }, { source: "/admin/orders/:id", destination: "/admin/v2/orders/:id", permanent: false },
{ source: "/admin/stops", destination: "/admin/v2/stops", permanent: false }, { source: "/admin/stops", destination: "/admin/v2/stops", permanent: false },
{ source: "/admin/products", destination: "/admin/v2/products", permanent: false }, { source: "/admin/products", destination: "/admin/v2/products", permanent: false },
// v1 admin pages that hit the dead supabase shim. There are no v2 // v1 admin pages that were removed when the supabase shim was
// equivalents yet (reports / taxes / settings sub-pages are still // deleted. There are no v2 equivalents yet (reports / taxes /
// TBD on the v2 surface), so they redirect to the v2 dashboard // settings sub-pages are still TBD on the v2 surface), so they
// for now. Once those modules ship on v2, the redirects can be // redirect to the v2 dashboard for now. Once those modules ship
// tightened to point at the new pages. // on v2, the redirects can be tightened to point at the new
// pages.
{ source: "/admin/products/:id", destination: "/admin/v2/products", permanent: false }, { source: "/admin/products/:id", destination: "/admin/v2/products", permanent: false },
{ source: "/admin/reports", destination: "/admin/v2", permanent: false }, { source: "/admin/reports", destination: "/admin/v2", permanent: false },
{ source: "/admin/taxes", destination: "/admin/v2", permanent: false }, { source: "/admin/taxes", destination: "/admin/v2", permanent: false },
-2
View File
@@ -34,8 +34,6 @@
"@sentry/nextjs": "^10.55.0", "@sentry/nextjs": "^10.55.0",
"@stripe/react-stripe-js": "^6.6.0", "@stripe/react-stripe-js": "^6.6.0",
"@stripe/stripe-js": "^9.7.0", "@stripe/stripe-js": "^9.7.0",
"@supabase/ssr": "^0.10.2",
"@supabase/supabase-js": "^2.105.3",
"@upstash/ratelimit": "^2.0.8", "@upstash/ratelimit": "^2.0.8",
"@upstash/redis": "^1.38.0", "@upstash/redis": "^1.38.0",
"drizzle-orm": "^0.36.4", "drizzle-orm": "^0.36.4",
-160
View File
@@ -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);
});
-49
View File
@@ -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);
})();
-52
View File
@@ -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 {}
}
})();
-44
View File
@@ -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 {}
}
}
})();
-78
View File
@@ -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);
});
-61
View File
@@ -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();
-99
View File
@@ -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"
-228
View File
@@ -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.`);
-128
View File
@@ -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.`);
-223
View File
@@ -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, '&amp;').replace(/"/g, '&quot;');
}
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`);
-150
View File
@@ -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`);
-73
View File
@@ -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`);
+1 -2
View File
@@ -305,8 +305,7 @@ async function main() {
let sql = `-- Tuxedo Corn 2026 Tour Data (from ${xlsxPath})\n`; let sql = `-- Tuxedo Corn 2026 Tour Data (from ${xlsxPath})\n`;
sql += `-- Stops: ${stops.length} | Locations: ${parsedLocations.length}\n`; sql += `-- Stops: ${stops.length} | Locations: ${parsedLocations.length}\n`;
sql += `-- Preferred: npx tsx scripts/import-tuxedo-stops.ts\n`; sql += `-- Preferred: npx tsx scripts/import-tuxedo-stops.ts\n`;
sql += `-- Manual apply: psql "$DATABASE_URL" -f db/seeds/2026-tuxedo-tour-stops.sql\n`; sql += `-- Manual apply: psql "$DATABASE_URL" -f db/seeds/2026-tuxedo-tour-stops.sql\n\n`;
sql += `-- supabase db query --linked --file db/seeds/2026-tuxedo-tour-stops.sql\n\n`;
sql += `BEGIN;\n\n`; sql += `BEGIN;\n\n`;
// Locations first (master directory) // Locations first (master directory)
-262
View File
@@ -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);
-96
View File
@@ -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();
-41
View File
@@ -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
View File
@@ -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"
-343
View File
@@ -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 "CrossDock" 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()
-74
View File
@@ -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); });
-11
View File
@@ -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();
-132
View File
@@ -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);
});
+1 -1
View File
@@ -273,7 +273,7 @@ export async function createAdminUser(
// admin endpoint requires the caller to have role='admin' in // admin endpoint requires the caller to have role='admin' in
// Neon Auth, which is not always true (provision-admin.ts does // Neon Auth, which is not always true (provision-admin.ts does
// not set it). Falling back to sign-up is a safe, well-tested // 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 code = adminResult.error.code ?? "";
const isAuthRequired = const isAuthRequired =
code === "FAILED_TO_CREATE_USER" || code === "FAILED_TO_CREATE_USER" ||
-108
View File
@@ -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." };
}
}
+4 -3
View File
@@ -68,9 +68,10 @@ export type ConversionFunnel = {
/** /**
* Aggregate KPIs over a date window. Replaces the * Aggregate KPIs over a date window. Replaces the
* `get_reports_summary` SECURITY DEFINER RPC from * `get_reports_summary` SECURITY DEFINER RPC (originally from the now-
* `supabase/migrations/031_reports_v1_rpcs.sql`. Returns zeroed metrics * archived supabase migrations; consolidated into
* if the caller is unauthenticated or no orders exist in the window. * `db/migrations/0001_init.sql`). Returns zeroed metrics if the caller
* is unauthenticated or no orders exist in the window.
*/ */
async function getReportsSummary( async function getReportsSummary(
brandId: string | null, brandId: string | null,
+3 -2
View File
@@ -11,8 +11,9 @@ export type ImportOrdersResult =
/** /**
* Bulk-import orders. Replaces the legacy `create_order_with_items` SECURITY * Bulk-import orders. Replaces the legacy `create_order_with_items` SECURITY
* DEFINER RPC (`supabase/migrations/021_shipping_only_brand.sql`). The new * DEFINER RPC (originally from the now-archived supabase migrations;
* `orders` schema doesn't have `subtotal`, `customer_name`, `customer_email`, * 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 * `customer_phone`, or `stop_id` columns — totals are stored in
* `total_cents` and customers are referenced by `customer_id`. For each * `total_cents` and customers are referenced by `customer_id`. For each
* imported order we upsert a `customers` row keyed on email+tenant, then * imported order we upsert a `customers` row keyed on email+tenant, then
+2 -1
View File
@@ -73,7 +73,8 @@ export type CampaignActivityRow = {
// ── Helpers ────────────────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────────────────
// //
// The reports V1 RPCs (`get_reports_summary`, `get_orders_by_stop_report`, etc.) // 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`). // (`orders.subtotal`, `stops.city/state/date`, `communication_contacts`).
// The SaaS rebuild has a different `orders` schema (`total_cents`, no // The SaaS rebuild has a different `orders` schema (`total_cents`, no
// `stop_id`/`city`/`state` columns on `stops`) and a different contacts table // `stop_id`/`city`/`state` columns on `stops`) and a different contacts table
+7 -6
View File
@@ -4,12 +4,13 @@ import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId, assertBrandAccess } from "@/lib/brand-scope"; import { getActiveBrandId, assertBrandAccess } from "@/lib/brand-scope";
// TODO(migration): the `harvest_lots` / `harvest_lot_events` / `lot_orders` // TODO(migration): the `harvest_lots` / `harvest_lot_events` / `lot_orders`
// tables from the route-trace feature (defined across // tables from the route-trace feature (originally defined in the now-
// supabase/migrations/143_enable_route_trace.sql and friends) were retired // archived supabase migrations; consolidated into
// from the SaaS rebuild — they don't exist in `db/schema/`. The functions // `db/migrations/0001_init.sql`) were retired from the SaaS rebuild —
// below all return empty results so the public trace page and FSMA reports // they don't exist in `db/schema/`. The functions below all return empty
// 404 gracefully. If route-trace comes back, re-introduce the tables in // results so the public trace page and FSMA reports 404 gracefully. If
// `db/schema/` and replace these stubs with real Drizzle queries. // route-trace comes back, re-introduce the tables in `db/schema/` and
// replace these stubs with real Drizzle queries.
export interface HarvestLot { export interface HarvestLot {
id: string; id: string;
+4 -4
View File
@@ -10,10 +10,10 @@ export type UpdateShippingStatusResult =
// TODO(migration): shipping is dormant in the SaaS rebuild. The legacy // TODO(migration): shipping is dormant in the SaaS rebuild. The legacy
// `shipments` table (with `tracking_number`, `fedex_shipment_id`, etc.), // `shipments` table (with `tracking_number`, `fedex_shipment_id`, etc.),
// the `shipping_status` column on `orders`, and the `update_shipping_order` // the `shipping_status` column on `orders`, and the `update_shipping_order`
// RPC from `supabase/migrations/040_shipping_fulfillment_rpcs.sql` are // RPC (originally from the now-archived supabase migrations) are gone.
// gone. The functions below stub to "not configured" so the admin // The functions below stub to "not configured" so the admin shipping
// shipping tab renders gracefully. Re-introduce shipping in // tab renders gracefully. Re-introduce shipping in `db/schema/` when
// `db/schema/` when the feature is reactivated. // the feature is reactivated.
export async function updateShippingStatus( export async function updateShippingStatus(
_orderId: string, _orderId: string,
+3 -2
View File
@@ -93,8 +93,9 @@ async function loadShippingSettingsForBrand(brandId: string): Promise<ShippingSe
/** /**
* Calls the legacy `get_order_items_perishable` SECURITY DEFINER RPC * Calls the legacy `get_order_items_perishable` SECURITY DEFINER RPC
* via `pool.query`. The function still lives in the database (see * via `pool.query`. The function still lives in the database
* supabase/migrations/083_shipping_settings.sql). * (originally from the now-archived supabase migrations; consolidated
* into `db/migrations/0001_init.sql`).
*/ */
async function isShipmentPerishable(orderId: string): Promise<boolean> { async function isShipmentPerishable(orderId: string): Promise<boolean> {
try { try {
+6 -6
View File
@@ -11,12 +11,12 @@
* *
* TODO(migration): shipping is dormant in the SaaS rebuild. The * TODO(migration): shipping is dormant in the SaaS rebuild. The
* legacy `shipping_settings` table (with the FedEx credential columns * legacy `shipping_settings` table (with the FedEx credential columns
* this file needs) is still present in the database — see * this file needs) is still present in the database — originally from
* supabase/migrations/083_shipping_settings.sql — but the new * the now-archived supabase migrations, consolidated into
* `db/schema/` does not declare it. The data reads below hit the * `db/migrations/0001_init.sql` — but the new `db/schema/` does not
* legacy table directly via `pool.query`. When shipping comes back, * declare it. The data reads below hit the legacy table directly via
* declare the table in `db/schema/shipping.ts` and switch the reads * `pool.query`. When shipping comes back, declare the table in
* to Drizzle. * `db/schema/shipping.ts` and switch the reads to Drizzle.
*/ */
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
+6 -6
View File
@@ -4,12 +4,12 @@
* Shipping settings CRUD + FedEx connection test. * Shipping settings CRUD + FedEx connection test.
* *
* TODO(migration): shipping is dormant in the SaaS rebuild. The * TODO(migration): shipping is dormant in the SaaS rebuild. The
* `shipping_settings` table is still part of the legacy schema (see * `shipping_settings` table is still part of the legacy schema
* supabase/migrations/083_shipping_settings.sql) — the FedEx integration * (originally from the now-archived supabase migrations; consolidated
* continues to read/write it via raw `pool.query` SQL rather than the * into `db/migrations/0001_init.sql`) — the FedEx integration continues
* Supabase REST gateway or Drizzle. When shipping is reactivated, move * to read/write it via raw `pool.query` SQL rather than Drizzle. When
* the table declaration into `db/schema/shipping.ts` and switch the * shipping is reactivated, move the table declaration into
* reads to typed Drizzle queries. * `db/schema/shipping.ts` and switch the reads to typed Drizzle queries.
*/ */
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
+7 -6
View File
@@ -4,12 +4,13 @@
* Assign / unassign a product to a stop. * Assign / unassign a product to a stop.
* *
* TODO(migration): the `assign_product_to_stop` and * TODO(migration): the `assign_product_to_stop` and
* `unassign_product_from_stop` SECURITY DEFINER RPCs live in * `unassign_product_from_stop` SECURITY DEFINER RPCs (originally from
* supabase/migrations/030. The RPCs still exist in the database and * the now-archived supabase migrations; consolidated into
* are called via `pool.query` rather than the Supabase REST gateway. * `db/migrations/0001_init.sql`). The RPCs still exist in the
* When stops/products are re-platformed on the SaaS rebuild, replace * database and are called via `pool.query`. When stops/products are
* these with direct inserts/deletes on the new `db/schema/stops.ts` * re-platformed on the SaaS rebuild, replace these with direct
* `stopProducts` table. * inserts/deletes on the new `db/schema/stops.ts` `stopProducts`
* table.
*/ */
import { getAdminUser } from "@/lib/admin-permissions"; import { getAdminUser } from "@/lib/admin-permissions";
+24 -24
View File
@@ -3,11 +3,6 @@
/** /**
* Public storefront data accessors. * Public storefront data accessors.
* *
* These replace the legacy `lib/supabase.ts` query-builder shim that the
* tuxedo / indian-river-direct storefront pages used to call client-side.
* The shim was a no-op that returned `{ data: null }`, leaving storefronts
* with empty stops / products and no real brand record.
*
* Each function hits Postgres directly via the shared `pg` pool from * Each function hits Postgres directly via the shared `pg` pool from
* `@/lib/db` and is safe to call from public (unauthenticated) pages * `@/lib/db` and is safe to call from public (unauthenticated) pages
* because the SQL filters by `is_public = true` / `active = true` and * because the SQL filters by `is_public = true` / `active = true` and
@@ -98,12 +93,17 @@ export async function getStorefrontStops(brandId: string): Promise<StorefrontSto
/** /**
* Active products visible on the storefront. Same shape as the admin * Active products visible on the storefront. Same shape as the admin
* product list but without the brand-internal columns (cost, supplier, …). * 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( export async function getStorefrontProducts(
brandId: string, brandId: string,
): Promise<StorefrontProduct[]> { ): Promise<StorefrontProduct[]> {
const { rows } = await pool.query<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 type, image_url, is_taxable, pickup_type, active
FROM products FROM products
WHERE brand_id = $1 WHERE brand_id = $1
@@ -117,10 +117,8 @@ export async function getStorefrontProducts(
/** /**
* Single server action that the storefront Client Components call from * Single server action that the storefront Client Components call from
* their initial `useEffect`. Replaces the three separate `supabase.from(...)` * their initial `useEffect`. Bundles brand lookup + public stops +
* calls that the legacy `lib/supabase.ts` shim used to make — the shim * active products into one round-trip-friendly call.
* returned `{ data: null }` for everything, which is why storefronts
* were showing empty stops / products.
* *
* Returns `{ brand: null, stops: [], products: [] }` if the slug doesn't * Returns `{ brand: null, stops: [], products: [] }` if the slug doesn't
* match any brand — pages fall back to default copy in that case. * match any brand — pages fall back to default copy in that case.
@@ -137,16 +135,16 @@ export async function getStorefrontData(slug: string): Promise<StorefrontData> {
export type StorefrontWholesaleSettings = { export type StorefrontWholesaleSettings = {
invoice_business_name: string | null; 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 * Public wholesale-settings record for a brand — just the invoice
* invoice/business info used by the storefront contact page. Returns * business name used by the storefront contact page. Returns `null`
* `null` if the brand or its wholesale settings row doesn't exist. * 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( export async function getStorefrontWholesaleSettings(
slug: string, slug: string,
@@ -154,8 +152,7 @@ export async function getStorefrontWholesaleSettings(
const brand = await getStorefrontBrandBySlug(slug); const brand = await getStorefrontBrandBySlug(slug);
if (!brand) return null; if (!brand) return null;
const { rows } = await pool.query<StorefrontWholesaleSettings>( const { rows } = await pool.query<StorefrontWholesaleSettings>(
`SELECT invoice_business_name, invoice_business_address, `SELECT invoice_business_name
invoice_business_phone, invoice_business_email, invoice_business_website
FROM wholesale_settings FROM wholesale_settings
WHERE brand_id = $1 WHERE brand_id = $1
LIMIT 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 * Look up a single public stop by its id, plus the brand's active
* active products. Used by `/[brand]/stops/[slug]` pages. * 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 * 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 * already passed. Returns the empty product array if the brand has no
* active products at the moment. * active products at the moment.
*/ */
export async function getStorefrontStopBySlug(slug: string): Promise<{ export async function getStorefrontStopById(id: string): Promise<{
stop: StorefrontStop; stop: StorefrontStop;
products: StorefrontProduct[]; products: StorefrontProduct[];
} | null> { } | null> {
@@ -222,11 +222,11 @@ export async function getStorefrontStopBySlug(slug: string): Promise<{
date::text AS date, time, cutoff_date::text AS cutoff_date, date::text AS date, time, cutoff_date::text AS cutoff_date,
status, notes, is_public status, notes, is_public
FROM stops FROM stops
WHERE slug = $1 WHERE id = $1
AND is_public = true AND is_public = true
AND status = 'active' AND status = 'active'
LIMIT 1`, LIMIT 1`,
[slug], [id],
); );
const stop = stopRows[0]; const stop = stopRows[0];
if (!stop) return null; if (!stop) return null;
+6 -6
View File
@@ -152,12 +152,12 @@ async function getBrandTaxSettings(brandId: string): Promise<BrandTaxSettings |
// //
// TODO(migration): the tax dashboard reads from the legacy `orders` // TODO(migration): the tax dashboard reads from the legacy `orders`
// table via the `get_tax_summary` and `get_taxable_orders` SECURITY // table via the `get_tax_summary` and `get_taxable_orders` SECURITY
// DEFINER RPCs (supabase/migrations/095). Both the table and the RPCs // DEFINER RPCs (originally from the now-archived supabase migrations;
// still exist; we call the RPCs through `pool.query` rather than the // consolidated into `db/migrations/0001_init.sql`). Both the table and
// Supabase REST gateway. When the SaaS rebuild's orders table grows a // the RPCs still exist; we call the RPCs through `pool.query`. When the
// `tax_amount` column or the tax dashboard is rebuilt against the new // SaaS rebuild's orders table grows a `tax_amount` column or the tax
// schema, these helpers should be rewritten against the Drizzle // dashboard is rebuilt against the new schema, these helpers should be
// `orders` table directly. // rewritten against the Drizzle `orders` table directly.
export type TaxByStateRow = { export type TaxByStateRow = {
state: string; state: string;
-888
View File
@@ -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 [];
}
}
+95
View File
@@ -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" };
}
}
+85
View File
@@ -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" };
}
}
+76
View File
@@ -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";
+89
View File
@@ -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" };
}
}
+128
View File
@@ -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" };
}
}
+101
View File
@@ -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" };
}
}
+68
View File
@@ -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 };
}
+87
View File
@@ -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" };
}
}
+153
View File
@@ -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;
};
+91
View File
@@ -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 [];
}
}
+1 -1
View File
@@ -32,7 +32,7 @@ export default async function AdminPage() {
// so a transient DB/network failure can't crash the whole admin page. // so a transient DB/network failure can't crash the whole admin page.
let dashboardBrandId: string | null = adminUser ? await getActiveBrandId(adminUser) : null; let dashboardBrandId: string | null = adminUser ? await getActiveBrandId(adminUser) : null;
if (!dashboardBrandId && adminUser?.role === "platform_admin") { 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 // This ensures a platform_admin sees real dashboard stats even on first login
// before they have chosen an active brand. // before they have chosen an active brand.
try { try {
@@ -28,7 +28,6 @@ import {
enqueueWholesaleNotification, enqueueWholesaleNotification,
getWebhookSettings, getWebhookSettings,
saveWebhookSettings, saveWebhookSettings,
enqueueWholesaleWebhook,
getRecentWebhookActivity, getRecentWebhookActivity,
} from "@/actions/wholesale"; } from "@/actions/wholesale";
import DepositModal from "@/components/wholesale/DepositModal"; import DepositModal from "@/components/wholesale/DepositModal";
+2 -1
View File
@@ -4,7 +4,8 @@
* TODO(migration): wholesale_orders is part of the legacy schema and * TODO(migration): wholesale_orders is part of the legacy schema and
* is read/written via raw `pool.query` SQL. The `get_wholesale_settings` * is read/written via raw `pool.query` SQL. The `get_wholesale_settings`
* and `get_payment_settings` SECURITY DEFINER RPCs still live in the * 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 * via `pool.query`. When wholesale is reactivated, declare the tables
* in `db/schema/wholesale.ts` and switch the reads to typed Drizzle. * in `db/schema/wholesale.ts` and switch the reads to typed Drizzle.
*/ */
+4 -4
View File
@@ -5,10 +5,10 @@
* `enqueue_wholesale_notification` SECURITY DEFINER RPC live in the * `enqueue_wholesale_notification` SECURITY DEFINER RPC live in the
* legacy schema. Reads are converted to `pool.query`; the * legacy schema. Reads are converted to `pool.query`; the
* `enqueue_wholesale_notification` RPC is still in the database * `enqueue_wholesale_notification` RPC is still in the database
* (supabase/migrations/054) and is called via `pool.query` rather * (originally from the now-archived supabase migrations; consolidated
* than the Supabase REST gateway. When wholesale is reactivated, * into `db/migrations/0001_init.sql`) and is called via `pool.query`.
* move the tables into `db/schema/wholesale.ts` and switch reads to * When wholesale is reactivated, move the tables into
* typed Drizzle. * `db/schema/wholesale.ts` and switch reads to typed Drizzle.
*/ */
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
@@ -10,10 +10,6 @@ import { getStorefrontWholesaleSettings } from "@/actions/storefront";
type BrandSettings = { type BrandSettings = {
invoice_business_name: string | null; 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 = { type FormState = {
@@ -90,7 +86,7 @@ export default function IndianRiverContactPage() {
{[ {[
{ {
label: "Address", 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" />, 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" /> 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", 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" />, 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 iconPath: null
}, },
@@ -69,7 +69,7 @@ export default function IndianRiverStopsList({ stops, brandName, brandSlug }: Pr
{upcomingStops.map((stop) => ( {upcomingStops.map((stop) => (
<Link <Link
key={stop.id} 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" 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"> <div className="flex flex-col sm:flex-row sm:items-center gap-4">
@@ -9,7 +9,7 @@ import StopSetEffect from "@/components/storefront/StopSetEffect";
import StorefrontHeader from "@/components/storefront/StorefrontHeader"; import StorefrontHeader from "@/components/storefront/StorefrontHeader";
import StorefrontFooter from "@/components/storefront/StorefrontFooter"; import StorefrontFooter from "@/components/storefront/StorefrontFooter";
import LayoutContainer from "@/components/layout/LayoutContainer"; import LayoutContainer from "@/components/layout/LayoutContainer";
import { getStorefrontStopBySlug } from "@/actions/storefront"; import { getStorefrontStopById } from "@/actions/storefront";
import { formatDate } from "@/lib/format-date"; import { formatDate } from "@/lib/format-date";
type Product = { type Product = {
@@ -36,7 +36,7 @@ type Stop = {
export default function StopPage() { export default function StopPage() {
const params = useParams(); const params = useParams();
const slug = params.slug as string; const slug = params.id as string;
const [stop, setStop] = useState<{ const [stop, setStop] = useState<{
id: string; id: string;
@@ -53,13 +53,10 @@ export default function StopPage() {
useEffect(() => { useEffect(() => {
async function load() { async function load() {
const result = await getStorefrontStopBySlug(slug); const result = await getStorefrontStopById(slug);
if (!result) return; if (!result) return;
setStop(result.stop as unknown as Stop); 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[]); setProducts(result.products as unknown as Product[]);
} }
load(); load();
+3 -7
View File
@@ -9,10 +9,6 @@ import { getStorefrontWholesaleSettings } from "@/actions/storefront";
type BrandSettings = { type BrandSettings = {
invoice_business_name: string | null; 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 = { type FormState = {
@@ -72,7 +68,7 @@ export default function TuxedoContactPage() {
</div> </div>
<h3 className="text-base font-bold text-stone-950 mb-3">Farm Address</h3> <h3 className="text-base font-bold text-stone-950 mb-3">Farm Address</h3>
<p className="text-sm text-stone-500 leading-relaxed"> <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> </p>
</div> </div>
<div className="rounded-3xl bg-white p-8 shadow-sm ring-1 ring-stone-200/60 text-center"> <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> </svg>
</div> </div>
<h3 className="text-base font-bold text-stone-950 mb-3">Email</h3> <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"> <a href="mailto:orders@tuxedocorn.com" className="text-sm text-stone-500 hover:text-emerald-700 transition-colors">
{brandSettings?.invoice_business_email ?? "orders@tuxedocorn.com"} orders@tuxedocorn.com
</a> </a>
</div> </div>
</div> </div>
+1 -1
View File
@@ -86,7 +86,7 @@ export default function TuxedoStopsList({ stops, brandName, brandSlug }: Props)
{upcomingStops.map((stop) => ( {upcomingStops.map((stop) => (
<Link <Link
key={stop.id} 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" 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"> <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 StorefrontHeader from "@/components/storefront/StorefrontHeader";
import StorefrontFooter from "@/components/storefront/StorefrontFooter"; import StorefrontFooter from "@/components/storefront/StorefrontFooter";
import LayoutContainer from "@/components/layout/LayoutContainer"; import LayoutContainer from "@/components/layout/LayoutContainer";
import { getStorefrontStopBySlug } from "@/actions/storefront"; import { getStorefrontStopById } from "@/actions/storefront";
import { formatDate } from "@/lib/format-date"; import { formatDate } from "@/lib/format-date";
type Product = { type Product = {
@@ -35,7 +35,7 @@ type Stop = {
export default function StopPage() { export default function StopPage() {
const params = useParams(); const params = useParams();
const slug = params.slug as string; const slug = params.id as string;
const [stop, setStop] = useState<{ const [stop, setStop] = useState<{
id: string; id: string;
@@ -52,13 +52,10 @@ export default function StopPage() {
useEffect(() => { useEffect(() => {
async function load() { async function load() {
const result = await getStorefrontStopBySlug(slug); const result = await getStorefrontStopById(slug);
if (!result) return; if (!result) return;
setStop(result.stop as unknown as Stop); 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[]); setProducts(result.products as unknown as Product[]);
} }
load(); load();
+1 -1
View File
@@ -1,7 +1,7 @@
/** /**
* Edge-safe Neon Auth configuration. * 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: * runtime. It must NOT import:
* - `pg` / any Node-only database driver * - `pg` / any Node-only database driver
* - The Drizzle client * - The Drizzle client
+4 -4
View File
@@ -2,7 +2,7 @@ import Link from "next/link";
import { formatDate } from "@/lib/format-date"; import { formatDate } from "@/lib/format-date";
type StopCardProps = { type StopCardProps = {
slug: string; id: string;
city: string; city: string;
state: string; state: string;
date: string; date: string;
@@ -13,7 +13,7 @@ type StopCardProps = {
}; };
export default function StopCard({ export default function StopCard({
slug, city, state, date, time, location, id, city, state, date, time, location,
brandSlug = "tuxedo", brandAccent = "green", brandSlug = "tuxedo", brandAccent = "green",
}: StopCardProps) { }: StopCardProps) {
const ctaBg = const ctaBg =
@@ -25,7 +25,7 @@ export default function StopCard({
return ( 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"> <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 */} {/* Location */}
<div className="flex items-start gap-4 mb-5"> <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"}`}> <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 */} {/* CTA */}
<div className="px-7 pb-7 pt-1"> <div className="px-7 pb-7 pt-1">
<Link <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`} 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 Shop This Stop
+3 -3
View File
@@ -11,9 +11,9 @@
* const { rows } = await query<MyRow>("SELECT * FROM my_table WHERE id = $1", [id]); * const { rows } = await query<MyRow>("SELECT * FROM my_table WHERE id = $1", [id]);
* *
* Configuration: * Configuration:
* - DATABASE_URL (required) — full Postgres connection string. Same env var * - DATABASE_URL (required) — full Postgres connection string. Used by
* is used by `supabase/push-migrations.js` and any external migration * `scripts/migrate.js` (and any external migration tooling). Format:
* tooling. Format: `postgres://user:pass@host:port/dbname`. * `postgres://user:pass@host:port/dbname`.
* *
* Notes: * Notes:
* - This module is server-only. It must never be imported from a Client * - This module is server-only. It must never be imported from a Client
-8
View File
@@ -1,8 +0,0 @@
# Supabase
.branches
.temp
# dotenvx
.env.keys
.env.local
.env.*.local
-92
View File
@@ -1,92 +0,0 @@
-- ============================================================================
-- Grok Fix Prompt: admin_create_stop (copy-paste ready for Supabase SQL Editor)
-- ============================================================================
-- This is the exact "Best Practice Version" from the fix prompt, with
-- additional comments. Run this directly in the Supabase SQL Editor
-- (https://supabase.com/dashboard/project/wnzkhezyhnfzhkhiflrp/sql) if the
-- migration push cannot be used.
--
-- After running, PostgREST may need a schema reload:
-- NOTIFY pgrst, 'reload schema';
--
-- Then test "Add New Stop" again.
--
-- The calling code (JS/TS) is in:
-- src/actions/stops/create-stop.ts (the fetch to /rest/v1/rpc/admin_create_stop)
-- src/components/admin/AddStopModal.tsx
-- src/components/admin/NewStopForm.tsx
-- (all go through the server action createStop which builds the p_* args)
-- ============================================================================
CREATE OR REPLACE FUNCTION public.admin_create_stop(
p_active boolean,
p_address text,
p_brand_id uuid,
p_city text,
p_cutoff_time time,
p_date date,
p_location text,
p_state text,
p_time time,
p_zip text
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
new_stop_id uuid;
BEGIN
INSERT INTO stops (
active,
address,
brand_id,
city,
cutoff_time,
date,
location,
state,
time,
zip
)
VALUES (
p_active,
p_address,
p_brand_id,
p_city,
p_cutoff_time,
p_date,
p_location,
p_state,
p_time,
p_zip
)
RETURNING id INTO new_stop_id;
RETURN jsonb_build_object(
'success', true,
'stop_id', new_stop_id,
'message', 'Stop created successfully'
);
EXCEPTION WHEN OTHERS THEN
RETURN jsonb_build_object(
'success', false,
'error', SQLERRM
);
END;
$$;
-- Important: grant so PostgREST (anon key from server action) can invoke it.
GRANT EXECUTE ON FUNCTION public.admin_create_stop(
boolean, text, uuid, text, time, date, text, text, time, text
) TO anon, authenticated, service_role;
-- Reload cache so the function is immediately visible (avoids PGRST202).
NOTIFY pgrst, 'reload schema';
-- NOTE: The above simple version may fail on INSERT because the stops table
-- requires "slug" and "status" (NOT NULL, no defaults in all cases).
-- Prefer the enhanced version in migrations/202_fix_admin_create_stop.sql
-- which includes slug generation and status='draft' while using a compatible
-- signature and the success/stop_id return shape.
-408
View File
@@ -1,408 +0,0 @@
# For detailed configuration reference documentation, visit:
# https://supabase.com/docs/guides/local-development/cli/config
# A string used to distinguish different Supabase projects on the same host. Defaults to the
# working directory name when running `supabase init`.
project_id = "route_commerce-main"
[api]
enabled = true
# Port to use for the API URL.
port = 54321
# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API
# endpoints. `public` and `graphql_public` schemas are included by default.
schemas = ["public", "graphql_public"]
# Extra schemas to add to the search_path of every request.
extra_search_path = ["public", "extensions"]
# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size
# for accidental or malicious requests.
max_rows = 1000
[api.tls]
# Enable HTTPS endpoints locally using a self-signed certificate.
enabled = false
# Paths to self-signed certificate pair.
# cert_path = "../certs/my-cert.pem"
# key_path = "../certs/my-key.pem"
[db]
# Port to use for the local database URL.
port = 54322
# Port used by db diff command to initialize the shadow database.
shadow_port = 54320
# Maximum amount of time to wait for health check when starting the local database.
health_timeout = "2m"
# The database major version to use. This has to be the same as your remote database's. Run `SHOW
# server_version;` on the remote database to check.
major_version = 17
[db.pooler]
enabled = false
# Port to use for the local connection pooler.
port = 54329
# Specifies when a server connection can be reused by other clients.
# Configure one of the supported pooler modes: `transaction`, `session`.
pool_mode = "transaction"
# How many server connections to allow per user/database pair.
default_pool_size = 20
# Maximum number of client connections allowed.
max_client_conn = 100
# [db.vault]
# secret_key = "env(SECRET_VALUE)"
[db.migrations]
# If disabled, migrations will be skipped during a db push or reset.
enabled = true
# Specifies an ordered list of schema files that describe your database.
# Supports glob patterns relative to supabase directory: "./schemas/*.sql"
schema_paths = []
[db.seed]
# If enabled, seeds the database after migrations during a db reset.
enabled = true
# Specifies an ordered list of seed files to load during db reset.
# Supports glob patterns relative to supabase directory: "./seeds/*.sql"
sql_paths = ["./seed.sql"]
[db.network_restrictions]
# Enable management of network restrictions.
enabled = false
# List of IPv4 CIDR blocks allowed to connect to the database.
# Defaults to allow all IPv4 connections. Set empty array to block all IPs.
allowed_cidrs = ["0.0.0.0/0"]
# List of IPv6 CIDR blocks allowed to connect to the database.
# Defaults to allow all IPv6 connections. Set empty array to block all IPs.
allowed_cidrs_v6 = ["::/0"]
# Uncomment to reject non-secure connections to the database.
# [db.ssl_enforcement]
# enabled = true
[realtime]
enabled = true
# Bind realtime via either IPv4 or IPv6. (default: IPv4)
# ip_version = "IPv6"
# The maximum length in bytes of HTTP request headers. (default: 4096)
# max_header_length = 4096
[studio]
enabled = true
# Port to use for Supabase Studio.
port = 54323
# External URL of the API server that frontend connects to.
api_url = "http://127.0.0.1"
# OpenAI API Key to use for Supabase AI in the Supabase Studio.
openai_api_key = "env(OPENAI_API_KEY)"
# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they
# are monitored, and you can view the emails that would have been sent from the web interface.
[inbucket]
enabled = true
# Port to use for the email testing server web interface.
port = 54324
# Uncomment to expose additional ports for testing user applications that send emails.
# smtp_port = 54325
# pop3_port = 54326
# admin_email = "admin@email.com"
# sender_name = "Admin"
[storage]
enabled = true
# The maximum file size allowed (e.g. "5MB", "500KB").
file_size_limit = "50MiB"
# Uncomment to configure local storage buckets
# [storage.buckets.images]
# public = false
# file_size_limit = "50MiB"
# allowed_mime_types = ["image/png", "image/jpeg"]
# objects_path = "./images"
# Allow connections via S3 compatible clients
[storage.s3_protocol]
enabled = true
# Image transformation API is available to Supabase Pro plan.
# [storage.image_transformation]
# enabled = true
# Store analytical data in S3 for running ETL jobs over Iceberg Catalog
# This feature is only available on the hosted platform.
[storage.analytics]
enabled = false
max_namespaces = 5
max_tables = 10
max_catalogs = 2
# Analytics Buckets is available to Supabase Pro plan.
# [storage.analytics.buckets.my-warehouse]
# Store vector embeddings in S3 for large and durable datasets
# This feature is only available on the hosted platform.
[storage.vector]
enabled = false
max_buckets = 10
max_indexes = 5
# Vector Buckets is available to Supabase Pro plan.
# [storage.vector.buckets.documents-openai]
[auth]
enabled = true
# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
# in emails.
site_url = "http://127.0.0.1:3000"
# The public URL that Auth serves on. Defaults to the API external URL with `/auth/v1` appended.
# external_url = ""
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
additional_redirect_urls = ["https://127.0.0.1:3000"]
# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
jwt_expiry = 3600
# JWT issuer URL. If not set, defaults to auth.external_url.
# jwt_issuer = ""
# Path to JWT signing key. DO NOT commit your signing keys file to git.
# signing_keys_path = "./signing_keys.json"
# If disabled, the refresh token will never expire.
enable_refresh_token_rotation = true
# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds.
# Requires enable_refresh_token_rotation = true.
refresh_token_reuse_interval = 10
# Allow/disallow new user signups to your project.
enable_signup = true
# Allow/disallow anonymous sign-ins to your project.
enable_anonymous_sign_ins = false
# Allow/disallow testing manual linking of accounts
enable_manual_linking = false
# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more.
minimum_password_length = 6
# Passwords that do not meet the following requirements will be rejected as weak. Supported values
# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols`
password_requirements = ""
# Configure passkey sign-ins.
# [auth.passkey]
# enabled = false
# Configure WebAuthn relying party settings (required when passkey is enabled).
# [auth.webauthn]
# rp_display_name = "Supabase"
# rp_id = "localhost"
# rp_origins = ["http://127.0.0.1:3000"]
[auth.rate_limit]
# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled.
email_sent = 2
# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled.
sms_sent = 30
# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true.
anonymous_users = 30
# Number of sessions that can be refreshed in a 5 minute interval per IP address.
token_refresh = 150
# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users).
sign_in_sign_ups = 30
# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address.
token_verifications = 30
# Number of Web3 logins that can be made in a 5 minute interval per IP address.
web3 = 30
# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`.
# [auth.captcha]
# enabled = true
# provider = "hcaptcha"
# secret = ""
[auth.email]
# Allow/disallow new user signups via email to your project.
enable_signup = true
# If enabled, a user will be required to confirm any email change on both the old, and new email
# addresses. If disabled, only the new email is required to confirm.
double_confirm_changes = true
# If enabled, users need to confirm their email address before signing in.
enable_confirmations = false
# If enabled, users will need to reauthenticate or have logged in recently to change their password.
secure_password_change = false
# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email.
max_frequency = "1s"
# Number of characters used in the email OTP.
otp_length = 6
# Number of seconds before the email OTP expires (defaults to 1 hour).
otp_expiry = 3600
# Use a production-ready SMTP server
# [auth.email.smtp]
# enabled = true
# host = "smtp.sendgrid.net"
# port = 587
# user = "apikey"
# pass = "env(SENDGRID_API_KEY)"
# admin_email = "admin@email.com"
# sender_name = "Admin"
# Uncomment to customize email template
# [auth.email.template.invite]
# subject = "You have been invited"
# content_path = "./supabase/templates/invite.html"
# Uncomment to customize notification email template
# [auth.email.notification.password_changed]
# enabled = true
# subject = "Your password has been changed"
# content_path = "./templates/password_changed_notification.html"
[auth.sms]
# Allow/disallow new user signups via SMS to your project.
enable_signup = false
# If enabled, users need to confirm their phone number before signing in.
enable_confirmations = false
# Template for sending OTP to users
template = "Your code is {{ .Code }}"
# Controls the minimum amount of time that must pass before sending another sms otp.
max_frequency = "5s"
# Use pre-defined map of phone number to OTP for testing.
# [auth.sms.test_otp]
# 4152127777 = "123456"
# Configure logged in session timeouts.
# [auth.sessions]
# Force log out after the specified duration.
# timebox = "24h"
# Force log out if the user has been inactive longer than the specified duration.
# inactivity_timeout = "8h"
# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object.
# [auth.hook.before_user_created]
# enabled = true
# uri = "pg-functions://postgres/auth/before-user-created-hook"
# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used.
# [auth.hook.custom_access_token]
# enabled = true
# uri = "pg-functions://<database>/<schema>/<hook_name>"
# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`.
[auth.sms.twilio]
enabled = false
account_sid = ""
message_service_sid = ""
# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead:
auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)"
# Multi-factor-authentication is available to Supabase Pro plan.
[auth.mfa]
# Control how many MFA factors can be enrolled at once per user.
max_enrolled_factors = 10
# Control MFA via App Authenticator (TOTP)
[auth.mfa.totp]
enroll_enabled = false
verify_enabled = false
# Configure MFA via Phone Messaging
[auth.mfa.phone]
enroll_enabled = false
verify_enabled = false
otp_length = 6
template = "Your code is {{ .Code }}"
max_frequency = "5s"
# Configure MFA via WebAuthn
# [auth.mfa.web_authn]
# enroll_enabled = true
# verify_enabled = true
# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`,
# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`,
# `twitter`, `x`, `slack`, `spotify`, `workos`, `zoom`.
[auth.external.apple]
enabled = false
client_id = ""
# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead:
secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)"
# Overrides the default auth callback URL derived from auth.external_url.
redirect_uri = ""
# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure,
# or any other third-party OIDC providers.
url = ""
# If enabled, the nonce check will be skipped. Required for local sign in with Google auth.
skip_nonce_check = false
# If enabled, it will allow the user to successfully authenticate when the provider does not return an email address.
email_optional = false
# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard.
# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting.
[auth.web3.solana]
enabled = false
# Use Firebase Auth as a third-party provider alongside Supabase Auth.
[auth.third_party.firebase]
enabled = false
# project_id = "my-firebase-project"
# Use Auth0 as a third-party provider alongside Supabase Auth.
[auth.third_party.auth0]
enabled = false
# tenant = "my-auth0-tenant"
# tenant_region = "us"
# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth.
[auth.third_party.aws_cognito]
enabled = false
# user_pool_id = "my-user-pool-id"
# user_pool_region = "us-east-1"
# Use Clerk as a third-party provider alongside Supabase Auth.
[auth.third_party.clerk]
enabled = false
# Obtain from https://clerk.com/setup/supabase
# domain = "example.clerk.accounts.dev"
# OAuth server configuration
[auth.oauth_server]
# Enable OAuth server functionality
enabled = false
# Path for OAuth consent flow UI
authorization_url_path = "/oauth/consent"
# Allow dynamic client registration
allow_dynamic_registration = false
[edge_runtime]
enabled = true
# Supported request policies: `oneshot`, `per_worker`.
# `per_worker` (default) — enables hot reload during local development.
# `oneshot` — fallback mode if hot reload causes issues (e.g. in large repos or with symlinks).
policy = "per_worker"
# Port to attach the Chrome inspector for debugging edge functions.
inspector_port = 8083
# The Deno major version to use.
deno_version = 2
# [edge_runtime.secrets]
# secret_key = "env(SECRET_VALUE)"
[analytics]
enabled = true
port = 54327
# Configure one of the supported backends: `postgres`, `bigquery`.
backend = "postgres"
# Experimental features may be deprecated any time
[experimental]
# Configures Postgres storage engine to use OrioleDB (S3)
orioledb_version = ""
# Configures S3 bucket URL, eg. <bucket_name>.s3-<region>.amazonaws.com
s3_host = "env(S3_HOST)"
# Configures S3 bucket region, eg. us-east-1
s3_region = "env(S3_REGION)"
# Configures AWS_ACCESS_KEY_ID for S3 bucket
s3_access_key = "env(S3_ACCESS_KEY)"
# Configures AWS_SECRET_ACCESS_KEY for S3 bucket
s3_secret_key = "env(S3_SECRET_KEY)"
# [experimental.pgdelta]
# When enabled, pg-delta becomes the active engine for supported schema flows.
# enabled = false
# Directory under `supabase/` where declarative files are written.
# declarative_schema_path = "./database"
# JSON string passed through to pg-delta SQL formatting.
# format_options = "{\"keywordCase\":\"upper\",\"indent\":2,\"maxWidth\":80,\"commaStyle\":\"trailing\"}"
@@ -1,201 +0,0 @@
-- ============================================================
-- Checkout Hardening Migration
-- ============================================================
-- 1. Add idempotency_key column to orders (UUID, unique)
-- 2. Index on idempotency_key for fast idempotency checks
-- 3. RPC function for atomic order + order_items creation
-- 4. Returns JSONB — no post-checkout SELECT needed
-- 1. Idempotency key column
ALTER TABLE orders ADD COLUMN IF NOT EXISTS idempotency_key UUID UNIQUE;
-- 1b. Add fulfillment column to order_items for mixed pickup/ship orders
ALTER TABLE order_items ADD COLUMN IF NOT EXISTS fulfillment TEXT DEFAULT 'pickup';
-- 2. Index for idempotency lookups
CREATE INDEX IF NOT EXISTS idx_orders_idempotency_key ON orders(idempotency_key);
-- 3. Atomic checkout RPC function
-- Validates stop, products, quantities, pickup eligibility
-- Computes subtotal server-side from current DB prices
-- Returns JSONB with order + items — caller never needs a separate SELECT
CREATE OR REPLACE FUNCTION create_order_with_items(
p_idempotency_key UUID,
p_customer_name TEXT,
p_customer_email TEXT,
p_customer_phone TEXT,
p_stop_id UUID,
p_items JSONB -- [{id: uuid, quantity: int, fulfillment: text}]
)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
v_order_id UUID;
v_item JSONB;
v_product_id UUID;
v_quantity INT;
v_fulfillment TEXT;
v_product_price NUMERIC;
v_computed_total NUMERIC := 0;
v_stop_active BOOLEAN;
v_stop_brand_id UUID;
v_stop_city TEXT;
v_stop_state TEXT;
v_stop_date TEXT;
v_stop_time TEXT;
v_stop_location TEXT;
v_product_active BOOLEAN;
v_product_brand_id UUID;
v_product_name TEXT;
v_is_pickup BOOLEAN;
v_order JSONB;
v_order_items JSONB := '[]'::JSONB;
BEGIN
-- ── Idempotency guard ──────────────────────────────────────
-- If this key was already used, return the existing order + items.
-- Safe for retries: the order is real, the user just didn't see the success page.
SELECT jsonb_build_object(
'id', o.id,
'customer_name', o.customer_name,
'customer_email', o.customer_name,
'customer_phone', o.customer_phone,
'subtotal', o.subtotal,
'status', o.status,
'stop_id', o.stop_id,
'stop_city', s.city,
'stop_state', s.state,
'stop_date', s.date,
'stop_time', s.time,
'stop_location', s.location,
'items', COALESCE((
SELECT jsonb_agg(jsonb_build_object(
'product_id', oi.product_id,
'product_name', p.name,
'quantity', oi.quantity,
'price', oi.price,
'fulfillment', oi.fulfillment
))
FROM order_items oi
JOIN products p ON oi.product_id = p.id
WHERE oi.order_id = o.id
), '[]'::JSONB)
)
INTO v_order
FROM orders o
JOIN stops s ON o.stop_id = s.id
WHERE o.idempotency_key = p_idempotency_key
LIMIT 1;
IF v_order IS NOT NULL THEN
RETURN v_order;
END IF;
-- ── Validate stop ──────────────────────────────────────────
SELECT active, brand_id, city, state, date, time, location
INTO v_stop_active, v_stop_brand_id, v_stop_city, v_stop_state, v_stop_date, v_stop_time, v_stop_location
FROM stops
WHERE id = p_stop_id;
IF v_stop_brand_id IS NULL THEN
RAISE EXCEPTION 'Stop not found: %', p_stop_id;
END IF;
IF NOT v_stop_active THEN
RAISE EXCEPTION 'Stop is not active: %', p_stop_id;
END IF;
-- ── Validate items and compute subtotal ────────────────────
FOR v_item IN SELECT * FROM jsonb_array_elements(p_items)
LOOP
v_product_id := (v_item->>'id')::UUID;
v_quantity := (v_item->>'quantity')::INT;
v_fulfillment := v_item->>'fulfillment';
v_is_pickup := (v_fulfillment = 'pickup');
SELECT active, brand_id, price, name
INTO v_product_active, v_product_brand_id, v_product_price, v_product_name
FROM products
WHERE id = v_product_id;
IF v_product_brand_id IS NULL THEN
RAISE EXCEPTION 'Product not found: %', v_product_id;
END IF;
IF v_product_brand_id != v_stop_brand_id THEN
RAISE EXCEPTION 'Product % does not belong to the stop brand', v_product_id;
END IF;
IF NOT v_product_active THEN
RAISE EXCEPTION 'Product is not active: %', v_product_id;
END IF;
IF v_quantity < 1 THEN
RAISE EXCEPTION 'Quantity must be at least 1 for product: %', v_product_id;
END IF;
IF v_is_pickup THEN
PERFORM 1
FROM product_stops
WHERE product_id = v_product_id AND stop_id = p_stop_id
LIMIT 1;
IF NOT FOUND THEN
RAISE EXCEPTION 'Pickup product % is not available at stop %', v_product_id, p_stop_id;
END IF;
END IF;
v_computed_total := v_computed_total + (v_product_price * v_quantity);
END LOOP;
-- ── Create order ────────────────────────────────────────────
INSERT INTO orders (
idempotency_key, customer_name, customer_email, customer_phone,
stop_id, subtotal, status
) VALUES (
p_idempotency_key, p_customer_name, p_customer_email, p_customer_phone,
p_stop_id, v_computed_total, 'pending'
)
RETURNING id INTO v_order_id;
-- ── Insert order items and build return value ───────────────
FOR v_item IN SELECT * FROM jsonb_array_elements(p_items)
LOOP
v_product_id := (v_item->>'id')::UUID;
v_quantity := (v_item->>'quantity')::INT;
v_fulfillment := v_item->>'fulfillment';
SELECT price, name INTO v_product_price, v_product_name FROM products WHERE id = v_product_id;
INSERT INTO order_items (order_id, product_id, quantity, fulfillment, price)
VALUES (v_order_id, v_product_id, v_quantity, v_fulfillment, v_product_price);
-- Append to items array
v_order_items := v_order_items || jsonb_build_object(
'product_id', v_product_id,
'product_name', v_product_name,
'quantity', v_quantity,
'price', v_product_price,
'fulfillment', v_fulfillment
);
END LOOP;
-- ── Build and return full order object ──────────────────────
RETURN jsonb_build_object(
'id', v_order_id,
'customer_name', p_customer_name,
'customer_email', p_customer_email,
'customer_phone', p_customer_phone,
'subtotal', v_computed_total,
'status', 'pending',
'stop_id', p_stop_id,
'stop_city', v_stop_city,
'stop_state', v_stop_state,
'stop_date', v_stop_date,
'stop_time', v_stop_time,
'stop_location', v_stop_location,
'items', v_order_items
);
END;
$$;
@@ -1,10 +0,0 @@
-- ============================================================
-- Remove broad anon SELECT policies
-- ============================================================
-- The checkout flow no longer requires post-checkout SELECT
-- because the RPC returns full order data directly.
-- Admin SELECT is handled by server-side auth (getAdminUser).
-- Drop the overly permissive read policies
DROP POLICY IF EXISTS "Allow public read on orders for checkout" ON orders;
DROP POLICY IF EXISTS "Allow public read on order_items" ON order_items;
@@ -1,103 +0,0 @@
-- ============================================================
-- Audit Logging
-- ============================================================
-- Tracks critical admin mutations for compliance and debugging.
-- Lightweight: single insert per action, no triggers.
CREATE TABLE IF NOT EXISTS audit_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
table_name TEXT NOT NULL,
record_id UUID NOT NULL,
action TEXT NOT NULL, -- INSERT | UPDATE | DELETE
old_data JSONB,
new_data JSONB,
performed_by UUID,
performed_by_email TEXT,
brand_id UUID, -- for brand-scoped log reads
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Index for fast lookups by table + record
CREATE INDEX IF NOT EXISTS idx_audit_logs_table_record
ON audit_logs(table_name, record_id);
-- Index for brand-scoped reads
CREATE INDEX IF NOT EXISTS idx_audit_logs_brand
ON audit_logs(brand_id) WHERE brand_id IS NOT NULL;
-- Index for performed_by queries
CREATE INDEX IF NOT EXISTS idx_audit_logs_performed_by
ON audit_logs(performed_by);
-- RLS: no direct public access
ALTER TABLE audit_logs ENABLE ROW LEVEL SECURITY;
-- Read policies (platform_admin and brand_admin read their own logs)
-- Read policy: platform_admin sees all
CREATE POLICY "Platform admin reads all audit logs"
ON audit_logs FOR SELECT
TO authenticated
USING (
EXISTS (
SELECT 1 FROM admin_users
WHERE admin_users.user_id = auth.uid()
AND admin_users.role = 'platform_admin'
)
);
-- Read policy: brand_admin sees only their brand's logs
CREATE POLICY "Brand admin reads own brand audit logs"
ON audit_logs FOR SELECT
TO authenticated
USING (
EXISTS (
SELECT 1 FROM admin_users
WHERE admin_users.user_id = auth.uid()
AND admin_users.role = 'brand_admin'
AND admin_users.brand_id = audit_logs.brand_id
)
);
-- Write is handled exclusively via SECURITY DEFINER RPC (bypasses RLS)
-- No INSERT policy needed — the function bypasses RLS entirely.
-- ============================================================
-- log_audit_event — called by server actions to write audit records
-- Runs as postgres (SUPERUSER) so it bypasses RLS on audit_logs.
-- Accepts a JSONB payload with all audit fields.
-- ============================================================
CREATE OR REPLACE FUNCTION log_audit_event(p_payload JSONB)
RETURNS UUID
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_audit_id UUID;
BEGIN
INSERT INTO audit_logs (
table_name, record_id, action,
old_data, new_data,
performed_by, performed_by_email,
brand_id
) VALUES (
p_payload->>'table_name',
(p_payload->>'record_id')::UUID,
p_payload->>'action',
p_payload->'old_data',
p_payload->'new_data',
CASE
WHEN (p_payload->>'performed_by') IS NULL THEN NULL
ELSE (p_payload->>'performed_by')::UUID
END,
p_payload->>'performed_by_email',
CASE
WHEN (p_payload->>'brand_id') IS NULL THEN NULL
ELSE (p_payload->>'brand_id')::UUID
END
)
RETURNING id INTO v_audit_id;
RETURN v_audit_id;
END;
$$;
@@ -1,193 +0,0 @@
-- ============================================================
-- Admin Read Policies for Orders
-- ============================================================
-- The checkout hardening removed broad anon SELECT policies.
-- Admin pages need to read orders with the anon key (server-side via getAdminUser).
-- We use SECURITY DEFINER functions that bypass RLS so admin pages
-- can still fetch orders for their brand scope without auth.uid().
-- No direct public SELECT (already enforced by removal of anon policies)
-- Policy: platform_admin can SELECT all orders
CREATE POLICY "Platform admin can read orders"
ON orders FOR SELECT
TO authenticated
USING (
EXISTS (
SELECT 1 FROM admin_users
WHERE admin_users.user_id = auth.uid()
AND admin_users.role = 'platform_admin'
)
);
-- Policy: brand_admin can read orders for their brand
CREATE POLICY "Brand admin can read their brand orders"
ON orders FOR SELECT
TO authenticated
USING (
EXISTS (
SELECT 1 FROM admin_users
WHERE admin_users.user_id = auth.uid()
AND admin_users.role = 'brand_admin'
AND admin_users.brand_id = (
SELECT brand_id FROM stops WHERE stops.id = orders.stop_id
)
)
);
-- Policy: stops readable by admin users
CREATE POLICY "Admins can read stops"
ON stops FOR SELECT
TO authenticated
USING (
EXISTS (
SELECT 1 FROM admin_users
WHERE admin_users.user_id = auth.uid()
)
);
-- ============================================================
-- Admin Orders Fetch RPC
-- ============================================================
-- SECURITY DEFINER runs as postgres — bypasses RLS on orders/stops.
CREATE OR REPLACE FUNCTION get_admin_orders(p_brand_id UUID)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_orders JSONB;
v_stops JSONB;
BEGIN
IF p_brand_id IS NULL THEN
SELECT COALESCE(jsonb_agg(t ORDER BY t.created_at DESC), '[]'::JSONB)
INTO v_orders
FROM (
SELECT o.id, o.customer_name, o.customer_email, o.customer_phone,
o.stop_id, o.status, o.subtotal, o.pickup_complete,
o.pickup_completed_at, o.pickup_completed_by, o.created_at,
CASE WHEN o.stop_id IS NOT NULL THEN jsonb_build_object(
'id', s.id, 'city', s.city, 'state', s.state,
'date', s.date, 'time', s.time, 'location', s.location, 'brand_id', s.brand_id
) END as stops
FROM orders o LEFT JOIN stops s ON o.stop_id = s.id
WHERE o.stop_id IS NOT NULL
LIMIT 500
) t;
SELECT COALESCE(jsonb_agg(jsonb_build_object(
'id', id, 'city', city, 'state', state,
'date', date, 'time', time, 'location', location, 'brand_id', brand_id
) ORDER BY date), '[]'::JSONB)
INTO v_stops FROM stops WHERE active = true;
ELSE
SELECT COALESCE(jsonb_agg(t ORDER BY t.created_at DESC), '[]'::JSONB)
INTO v_orders
FROM (
SELECT o.id, o.customer_name, o.customer_email, o.customer_phone,
o.stop_id, o.status, o.subtotal, o.pickup_complete,
o.pickup_completed_at, o.pickup_completed_by, o.created_at,
jsonb_build_object(
'id', s.id, 'city', s.city, 'state', s.state,
'date', s.date, 'time', s.time, 'location', s.location, 'brand_id', s.brand_id
) as stops
FROM orders o JOIN stops s ON o.stop_id = s.id
WHERE s.brand_id = p_brand_id
LIMIT 500
) t;
SELECT COALESCE(jsonb_agg(jsonb_build_object(
'id', id, 'city', city, 'state', state,
'date', date, 'time', time, 'location', location, 'brand_id', brand_id
) ORDER BY date), '[]'::JSONB)
INTO v_stops FROM stops WHERE active = true AND brand_id = p_brand_id;
END IF;
RETURN jsonb_build_object('orders', v_orders, 'stops', v_stops);
END;
$$;
-- ============================================================
-- Admin Order Detail RPC
-- ============================================================
CREATE OR REPLACE FUNCTION get_admin_order_detail(p_order_id UUID)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_order JSONB;
BEGIN
SELECT jsonb_build_object(
'id', o.id,
'customer_name', o.customer_name,
'customer_email', o.customer_email,
'customer_phone', o.customer_phone,
'stop_id', o.stop_id,
'status', o.status,
'subtotal', o.subtotal,
'discount_amount', o.discount_amount,
'tax_amount', o.tax_amount,
'tax_rate', o.tax_rate,
'tax_location', o.tax_location,
'discount_reason', o.discount_reason,
'pickup_complete', o.pickup_complete,
'pickup_completed_at', o.pickup_completed_at,
'pickup_completed_by', o.pickup_completed_by,
'internal_notes', o.internal_notes,
'payment_processor', o.payment_processor,
'payment_status', o.payment_status,
'payment_transaction_id', o.payment_transaction_id,
'refunded_amount', o.refunded_amount,
'refund_reason', o.refund_reason,
'created_at', o.created_at,
'stops', CASE WHEN o.stop_id IS NOT NULL THEN jsonb_build_object(
'id', s.id,
'city', s.city,
'state', s.state,
'date', s.date,
'time', s.time,
'location', s.location,
'brand_id', s.brand_id
) END,
'order_items', COALESCE((
SELECT jsonb_agg(jsonb_build_object(
'id', oi.id,
'product_id', oi.product_id,
'product_name', p.name,
'quantity', oi.quantity,
'price', oi.price,
'fulfillment', oi.fulfillment,
'products', jsonb_build_object('name', p.name)
))
FROM order_items oi
JOIN products p ON oi.product_id = p.id
WHERE oi.order_id = o.id
), '[]'::JSONB),
'refunds', COALESCE((
SELECT jsonb_agg(jsonb_build_object(
'id', r.id,
'order_id', r.order_id,
'amount', r.amount,
'reason', r.reason,
'processor', r.processor,
'processor_refund_id', r.processor_refund_id,
'status', r.status,
'created_at', r.created_at
))
FROM refunds r
WHERE r.order_id = o.id
), '[]'::JSONB)
)
INTO v_order
FROM orders o
LEFT JOIN stops s ON o.stop_id = s.id
WHERE o.id = p_order_id;
RETURN v_order;
END;
$$;
@@ -1,342 +0,0 @@
-- ============================================================
-- Water Log V1
-- ============================================================
-- Tables: water_headgates, water_irrigators, water_log_entries
-- Auth: PIN-based field login via HTTP-only cookie session
-- Admin: brand-scoped, SECURITY DEFINER RPCs, audit logged
-- ── Tables ─────────────────────────────────────────────────
CREATE TABLE water_headgates (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
brand_id UUID REFERENCES brands(id) NOT NULL,
name TEXT NOT NULL,
active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE water_irrigators (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
brand_id UUID REFERENCES brands(id) NOT NULL,
name TEXT NOT NULL,
pin_hash TEXT NOT NULL,
language_preference TEXT DEFAULT 'en',
active BOOLEAN DEFAULT true,
last_used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now()
);
-- Water sessions for field login (HTTP-only cookie, server-side validation)
CREATE TABLE water_sessions (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
irrigator_id UUID REFERENCES water_irrigators(id) NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE water_log_entries (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
brand_id UUID REFERENCES brands(id) NOT NULL,
headgate_id UUID REFERENCES water_headgates(id) NOT NULL,
irrigator_id UUID REFERENCES water_irrigators(id) NOT NULL,
measurement NUMERIC NOT NULL,
unit TEXT NOT NULL,
notes TEXT,
submitted_via TEXT NOT NULL, -- 'field' or 'admin'
logged_at TIMESTAMPTZ DEFAULT now(),
logged_by UUID REFERENCES admin_users(id)
);
-- ── RLS ─────────────────────────────────────────────────────
ALTER TABLE water_headgates ENABLE ROW LEVEL SECURITY;
ALTER TABLE water_irrigators ENABLE ROW LEVEL SECURITY;
ALTER TABLE water_log_entries ENABLE ROW LEVEL SECURITY;
-- Headgates: readable by anyone (for field form), writable by admin RPCs
CREATE POLICY "Headgates are readable by all" ON water_headgates FOR SELECT TO anon USING (true);
CREATE POLICY "Headgates writable by postgres" ON water_headgates FOR ALL TO postgres USING (true);
-- Irrigators: only SECURITY DEFINER RPCs read/write (pin_hash never exposed)
CREATE POLICY "Irrigators writable by postgres" ON water_irrigators FOR ALL TO postgres USING (true);
-- Entries: readable by all (for admin), writable by admin RPCs and field RPC
CREATE POLICY "Entries readable by all" ON water_log_entries FOR SELECT TO anon USING (true);
CREATE POLICY "Entries writable by postgres" ON water_log_entries FOR ALL TO postgres USING (true);
-- ── Audit Log ──────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS audit_logs (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
user_id UUID,
action TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id UUID,
details JSONB,
ip_address TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
ALTER TABLE audit_logs ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Audit logs writable by postgres" ON audit_logs FOR ALL TO postgres USING (true);
-- ── RPCs ───────────────────────────────────────────────────
-- Verify PIN, create DB session, return session token (stored in HTTP-only cookie)
CREATE OR REPLACE FUNCTION verify_water_pin(p_brand_id UUID, p_pin TEXT)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_irr water_irrigators%ROWTYPE;
v_sess_id UUID;
v_lang TEXT;
BEGIN
SELECT * INTO v_irr FROM water_irrigators
WHERE brand_id = p_brand_id AND active = true
AND crypt(p_pin, pin_hash) = pin_hash
LIMIT 1;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Invalid PIN');
END IF;
UPDATE water_irrigators SET last_used_at = now() WHERE id = v_irr.id;
-- Create DB session with 4-hour expiry
v_sess_id := gen_random_uuid();
INSERT INTO water_sessions (id, irrigator_id, expires_at)
VALUES (v_sess_id, v_irr.id, now() + interval '4 hours');
RETURN jsonb_build_object(
'success', true,
'irrigator_id', v_irr.id,
'name', v_irr.name,
'session_id', v_sess_id,
'lang', v_irr.language_preference
);
END;
$$;
-- Submit water log entry (field side — session validated via DB)
CREATE OR REPLACE FUNCTION submit_water_entry(
p_session_id UUID,
p_headgate_id UUID,
p_measurement NUMERIC,
p_unit TEXT,
p_notes TEXT,
p_submitted_via TEXT
)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_entry_id UUID;
v_sess water_sessions%ROWTYPE;
v_brand_id UUID;
BEGIN
-- Validate session: exists and not expired
SELECT s.*, i.brand_id INTO v_sess, v_brand_id
FROM water_sessions s
JOIN water_irrigators i ON s.irrigator_id = i.id
WHERE s.id = p_session_id AND s.expires_at > now() AND i.active = true;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Session expired or invalid');
END IF;
INSERT INTO water_log_entries (brand_id, headgate_id, irrigator_id, measurement, unit, notes, submitted_via)
VALUES (v_brand_id, p_headgate_id, v_sess.irrigator_id, p_measurement, p_unit, p_notes, p_submitted_via)
RETURNING id INTO v_entry_id;
RETURN jsonb_build_object('success', true, 'entry_id', v_entry_id);
END;
$$;
-- Get headgates for a brand. p_active_only=true for field form, false for admin
CREATE OR REPLACE FUNCTION get_water_headgates(p_brand_id UUID, p_active_only BOOLEAN DEFAULT false)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
RETURN jsonb_build_object('headgates', (
SELECT COALESCE(jsonb_agg(jsonb_build_object(
'id', id, 'name', name, 'active', active, 'created_at', created_at
) ORDER BY name), '[]'::JSONB)
FROM water_headgates
WHERE brand_id = p_brand_id
AND (NOT p_active_only OR active = true)
));
END;
$$;
-- Get irrigators for a brand (admin, no pin_hash exposed)
CREATE OR REPLACE FUNCTION get_water_irrigators(p_brand_id UUID)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
RETURN jsonb_build_object('irrigators', (
SELECT COALESCE(jsonb_agg(jsonb_build_object(
'id', id, 'name', name, 'active', active,
'language_preference', language_preference,
'last_used_at', last_used_at,
'created_at', created_at
) ORDER BY name), '[]'::JSONB)
FROM water_irrigators
WHERE brand_id = p_brand_id
));
END;
$$;
-- Create a new irrigator with auto-generated PIN
-- Returns irrigator + plain PIN (shown once)
CREATE OR REPLACE FUNCTION create_water_irrigator(p_brand_id UUID, p_name TEXT, p_lang TEXT DEFAULT 'en')
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_irr water_irrigators%ROWTYPE;
v_raw_pin TEXT;
v_new_id UUID;
BEGIN
v_raw_pin := floor(random() * 9000 + 1000)::TEXT; -- 4-digit PIN
INSERT INTO water_irrigators (brand_id, name, pin_hash, language_preference)
VALUES (p_brand_id, p_name, crypt(v_raw_pin, gen_salt('bf')), p_lang)
RETURNING * INTO v_irr;
RETURN jsonb_build_object(
'irrigator', jsonb_build_object(
'id', v_irr.id, 'name', v_irr.name,
'active', v_irr.active, 'language_preference', v_irr.language_preference,
'created_at', v_irr.created_at
),
'pin', v_raw_pin
);
END;
$$;
-- Update irrigator name/active
CREATE OR REPLACE FUNCTION update_water_irrigator(
p_irrigator_id UUID, p_name TEXT, p_active BOOLEAN, p_lang TEXT
)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_irr water_irrigators%ROWTYPE;
BEGIN
UPDATE water_irrigators
SET name = p_name, active = p_active, language_preference = p_lang
WHERE id = p_irrigator_id
RETURNING * INTO v_irr;
RETURN jsonb_build_object('irrigator', jsonb_build_object(
'id', v_irr.id, 'name', v_irr.name,
'active', v_irr.active, 'language_preference', v_irr.language_preference,
'last_used_at', v_irr.last_used_at
));
END;
$$;
-- Reset irrigator PIN — returns new PIN (shown once)
CREATE OR REPLACE FUNCTION reset_water_irrigator_pin(p_irrigator_id UUID)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_irr water_irrigators%ROWTYPE;
v_raw_pin TEXT;
BEGIN
v_raw_pin := floor(random() * 9000 + 1000)::TEXT;
UPDATE water_irrigators
SET pin_hash = crypt(v_raw_pin, gen_salt('bf'))
WHERE id = p_irrigator_id
RETURNING * INTO v_irr;
RETURN jsonb_build_object('irrigator_id', v_irr.id, 'pin', v_raw_pin);
END;
$$;
-- Create headgate
CREATE OR REPLACE FUNCTION create_water_headgate(p_brand_id UUID, p_name TEXT)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_hg water_headgates%ROWTYPE;
BEGIN
INSERT INTO water_headgates (brand_id, name)
VALUES (p_brand_id, p_name)
RETURNING * INTO v_hg;
RETURN jsonb_build_object('headgate', jsonb_build_object(
'id', v_hg.id, 'name', v_hg.name, 'active', v_hg.active, 'created_at', v_hg.created_at
));
END;
$$;
-- Update headgate
CREATE OR REPLACE FUNCTION update_water_headgate(p_headgate_id UUID, p_name TEXT, p_active BOOLEAN)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_hg water_headgates%ROWTYPE;
BEGIN
UPDATE water_headgates
SET name = p_name, active = p_active
WHERE id = p_headgate_id
RETURNING * INTO v_hg;
RETURN jsonb_build_object('headgate', jsonb_build_object(
'id', v_hg.id, 'name', v_hg.name, 'active', v_hg.active
));
END;
$$;
-- Get recent entries for a brand
CREATE OR REPLACE FUNCTION get_water_entries(p_brand_id UUID, p_limit INT DEFAULT 50)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
RETURN jsonb_build_object('entries', (
SELECT COALESCE(jsonb_agg(t ORDER BY t.logged_at DESC), '[]'::JSONB)
FROM (
SELECT e.id, e.headgate_id, e.irrigator_id, e.measurement, e.unit,
e.notes, e.submitted_via, e.logged_at,
h.name as headgate_name,
i.name as irrigator_name
FROM water_log_entries e
JOIN water_headgates h ON e.headgate_id = h.id
JOIN water_irrigators i ON e.irrigator_id = i.id
WHERE e.brand_id = p_brand_id
ORDER BY e.logged_at DESC
LIMIT p_limit
) t
));
END;
$$;
@@ -1,263 +0,0 @@
-- =============================================================================
-- Water Log V1 — Consolidated RPC Repair
-- Fixes: gen_salt/crypt calls, schema alignment, overload removal
-- Uses fully-qualified pgcrypto: extensions.crypt / extensions.gen_salt
-- =============================================================================
-- Drop all existing water log RPCs (ignore errors if some don't exist)
DROP FUNCTION IF EXISTS public.verify_water_pin(uuid, text);
DROP FUNCTION IF EXISTS public.create_water_user(uuid, text, text, text);
DROP FUNCTION IF EXISTS public.create_water_user(uuid, text, text);
DROP FUNCTION IF EXISTS public.reset_water_user_pin(uuid);
DROP FUNCTION IF EXISTS public.reset_water_irrigator_pin(uuid);
DROP FUNCTION IF EXISTS public.submit_water_entry(uuid, uuid, numeric, text, text, text);
DROP FUNCTION IF EXISTS public.submit_water_entry(uuid, uuid, numeric, text, text);
DROP FUNCTION IF EXISTS public.update_water_irrigator(uuid, text, boolean, text);
DROP FUNCTION IF EXISTS public.update_water_user(uuid, text, boolean, text);
DROP FUNCTION IF EXISTS public.get_water_irrigators(uuid);
DROP FUNCTION IF EXISTS public.get_water_entries(uuid, int);
DROP FUNCTION IF EXISTS public.get_water_entries(uuid);
-- =============================================================================
-- verify_water_pin
-- Returns jsonb: {success, user_id, name, role, session_id, lang} or {success:false, error}
-- =============================================================================
CREATE OR REPLACE FUNCTION public.verify_water_pin(p_brand_id uuid, p_pin text)
RETURNS jsonb
LANGUAGE plpgsql
STATIC
AS $$
DECLARE
v_user jsonb;
v_session_id uuid;
BEGIN
-- Look up user by PIN (pgcrypto fully-qualified)
SELECT jsonb_build_object(
'id', id,
'name', name,
'role', role,
'lang', language_preference
) INTO v_user
FROM public.water_users
WHERE brand_id = p_brand_id
AND active = true
AND role IN ('irrigator', 'water_admin')
AND extensions.crypt(p_pin, pin_hash) = pin_hash;
IF v_user IS NULL THEN
RETURN jsonb_build_object('success', false, 'error', 'Invalid PIN');
END IF;
-- Update last-used timestamp
UPDATE public.water_users
SET last_used_at = now()
WHERE id = (v_user->>'id')::uuid;
-- Create session
v_session_id := gen_random_uuid();
INSERT INTO public.water_sessions (id, irrigator_id, expires_at)
VALUES (v_session_id, (v_user->>'id')::uuid, now() + interval '4 hours');
RETURN jsonb_build_object(
'success', true,
'user_id', v_user->>'id',
'name', v_user->>'name',
'role', v_user->>'role',
'session_id', v_session_id,
'lang', v_user->>'lang'
);
END;
$$;
COMMENT ON FUNCTION public.verify_water_pin IS
'Verify irrigator/admin PIN, create session, return user info as JSONB.';
-- =============================================================================
-- create_water_user
-- Returns jsonb: {success, user: {id,name,role}, pin}
-- =============================================================================
CREATE OR REPLACE FUNCTION public.create_water_user(
p_brand_id uuid,
p_name text,
p_role text,
p_lang text DEFAULT 'en'
)
RETURNS jsonb
LANGUAGE plpgsql
STATIC
AS $$
DECLARE
v_brand_id uuid;
v_raw_pin text;
v_user_id uuid;
v_user_role text;
BEGIN
v_brand_id := COALESCE(p_brand_id, '64294306-5f42-463d-a5e8-2ad6c81a96de'::uuid);
v_raw_pin := lpad(floor(random() * 9000 + 1000)::text, 4, '0');
v_user_role := COALESCE(p_role, 'irrigator');
INSERT INTO public.water_users (brand_id, name, pin_hash, role, language_preference, active)
VALUES (
v_brand_id,
p_name,
extensions.crypt(v_raw_pin, extensions.gen_salt('bf')),
v_user_role,
p_lang,
true
)
RETURNING id INTO v_user_id;
RETURN jsonb_build_object(
'success', true,
'user', jsonb_build_object('id', v_user_id, 'name', p_name, 'role', v_user_role),
'pin', v_raw_pin
);
END;
$$;
COMMENT ON FUNCTION public.create_water_user IS
'Create water user, generate PIN, return hashed PIN and plaintext PIN once.';
-- =============================================================================
-- reset_water_user_pin
-- Returns jsonb: {success, user_id, pin}
-- =============================================================================
CREATE OR REPLACE FUNCTION public.reset_water_user_pin(p_user_id uuid)
RETURNS jsonb
LANGUAGE plpgsql
STATIC
AS $$
DECLARE
v_raw_pin text;
v_name text;
BEGIN
v_raw_pin := lpad(floor(random() * 9000 + 1000)::text, 4, '0');
UPDATE public.water_users
SET pin_hash = extensions.crypt(v_raw_pin, extensions.gen_salt('bf'))
WHERE id = p_user_id
RETURNING name INTO v_name;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'User not found');
END IF;
RETURN jsonb_build_object('success', true, 'user_id', p_user_id, 'pin', v_raw_pin);
END;
$$;
-- =============================================================================
-- submit_water_entry
-- Returns jsonb: {success, entry_id} or {success:false, error}
-- =============================================================================
CREATE OR REPLACE FUNCTION public.submit_water_entry(
p_session_id uuid,
p_headgate_id uuid,
p_measurement numeric,
p_unit text,
p_notes text DEFAULT '',
p_submitted_via text DEFAULT 'field'
)
RETURNS jsonb
LANGUAGE plpgsql
STATIC
AS $$
DECLARE
v_sess record;
v_brand_id uuid;
v_entry_id uuid;
BEGIN
-- Look up session and join to water_users to get brand_id
SELECT s.id, s.irrigator_id, u.brand_id
INTO v_sess, v_brand_id
FROM public.water_sessions s
JOIN public.water_users u ON s.irrigator_id = u.id
WHERE s.id = p_session_id
AND s.expires_at > now();
IF NOT FOUND OR v_sess.irrigator_id IS NULL THEN
RETURN jsonb_build_object('success', false, 'error', 'Session expired or invalid');
END IF;
INSERT INTO public.water_log_entries
(brand_id, headgate_id, irrigator_id, measurement, unit, notes, submitted_via)
VALUES
(v_brand_id, p_headgate_id, v_sess.irrigator_id, p_measurement, p_unit, p_notes, p_submitted_via)
RETURNING id INTO v_entry_id;
RETURN jsonb_build_object('success', true, 'entry_id', v_entry_id);
END;
$$;
COMMENT ON FUNCTION public.submit_water_entry IS
'Submit a water log entry using an active wl_session cookie.';
-- =============================================================================
-- update_water_user
-- Returns jsonb: {success} or {success:false, error}
-- =============================================================================
CREATE OR REPLACE FUNCTION public.update_water_user(
p_user_id uuid,
p_name text,
p_active boolean,
p_lang text DEFAULT 'en'
)
RETURNS jsonb
LANGUAGE plpgsql
STATIC
AS $$
BEGIN
UPDATE public.water_users
SET name = p_name, active = p_active, language_preference = p_lang
WHERE id = p_user_id;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'User not found');
END IF;
RETURN jsonb_build_object('success', true);
END;
$$;
-- =============================================================================
-- get_water_entries
-- Returns jsonb: {entries: [...]}
-- =============================================================================
CREATE OR REPLACE FUNCTION public.get_water_entries(p_brand_id uuid, p_limit int DEFAULT 50)
RETURNS jsonb
LANGUAGE plpgsql
STATIC
AS $$
BEGIN
RETURN (
SELECT jsonb_build_object('entries', COALESCE(
(SELECT jsonb_agg(e ORDER BY e.logged_at DESC)
FROM (
SELECT
e.id,
e.headgate_id,
e.irrigator_id,
h.name AS headgate_name,
u.name AS irrigator_name,
e.measurement,
e.unit,
e.notes,
e.submitted_via,
e.logged_at
FROM public.water_log_entries e
JOIN public.water_headgates h ON e.headgate_id = h.id
JOIN public.water_users u ON e.irrigator_id = u.id
WHERE e.brand_id = p_brand_id
ORDER BY e.logged_at DESC
LIMIT p_limit
) e
), '[]'::jsonb
))
);
END;
$$;
-- =============================================================================
-- Reload PostgREST schema cache so new signatures are visible
-- =============================================================================
NOTIFY pgrst, 'reload schema';
@@ -1,232 +0,0 @@
-- =============================================================================
-- Water Log V1 — Column name alignment + SECURITY DEFINER
-- Deployed tables: water_users, water_sessions(user_id), water_log_entries(user_id)
-- =============================================================================
DROP FUNCTION IF EXISTS public.verify_water_pin(uuid, text);
DROP FUNCTION IF EXISTS public.create_water_user(uuid, text, text, text);
DROP FUNCTION IF EXISTS public.reset_water_user_pin(uuid);
DROP FUNCTION IF EXISTS public.submit_water_entry(uuid, uuid, numeric, text, text, text);
DROP FUNCTION IF EXISTS public.update_water_user(uuid, text, boolean, text);
DROP FUNCTION IF EXISTS public.get_water_entries(uuid, int);
-- =============================================================================
-- verify_water_pin(p_brand_id uuid, p_pin text)
-- Returns jsonb: {success, user_id, name, role, session_id, lang}
-- =============================================================================
CREATE OR REPLACE FUNCTION public.verify_water_pin(p_brand_id uuid, p_pin text)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_user jsonb;
v_session_id uuid;
BEGIN
SELECT jsonb_build_object(
'id', id,
'name', name,
'role', role,
'lang', language_preference
) INTO v_user
FROM public.water_users
WHERE active = true
AND role IN ('irrigator', 'water_admin')
AND extensions.crypt(p_pin, pin_hash) = pin_hash;
IF v_user IS NULL THEN
RETURN jsonb_build_object('success', false, 'error', 'Invalid PIN');
END IF;
UPDATE public.water_users
SET last_used_at = now()
WHERE id = (v_user->>'id')::uuid;
v_session_id := gen_random_uuid();
INSERT INTO public.water_sessions (id, user_id, role, expires_at)
VALUES (v_session_id, (v_user->>'id')::uuid, v_user->>'role', now() + interval '4 hours');
RETURN jsonb_build_object(
'success', true,
'user_id', v_user->>'id',
'name', v_user->>'name',
'role', v_user->>'role',
'session_id', v_session_id,
'lang', v_user->>'lang'
);
END;
$$;
-- =============================================================================
-- create_water_user(p_brand_id, p_name, p_role, p_lang)
-- Returns jsonb: {success, user: {id,name,role}, pin}
-- =============================================================================
CREATE OR REPLACE FUNCTION public.create_water_user(
p_brand_id uuid,
p_name text,
p_role text,
p_lang text DEFAULT 'en'
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_raw_pin text := lpad(floor(random() * 9000 + 1000)::text, 4, '0');
v_user_id uuid;
v_user_role text := COALESCE(p_role, 'irrigator');
v_brand_id uuid := COALESCE(p_brand_id, '64294306-5f42-463d-a5e8-2ad6c81a96de'::uuid);
BEGIN
INSERT INTO public.water_users (brand_id, name, pin_hash, role, language_preference, active)
VALUES (
v_brand_id,
p_name,
extensions.crypt(v_raw_pin, extensions.gen_salt('bf')),
v_user_role,
p_lang,
true
)
RETURNING id INTO v_user_id;
RETURN jsonb_build_object(
'success', true,
'user', jsonb_build_object('id', v_user_id, 'name', p_name, 'role', v_user_role),
'pin', v_raw_pin
);
END;
$$;
-- =============================================================================
-- reset_water_user_pin(p_user_id)
-- Returns jsonb: {success, user_id, pin}
-- =============================================================================
CREATE OR REPLACE FUNCTION public.reset_water_user_pin(p_user_id uuid)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_raw_pin text := lpad(floor(random() * 9000 + 1000)::text, 4, '0');
v_name text;
BEGIN
UPDATE public.water_users
SET pin_hash = extensions.crypt(v_raw_pin, extensions.gen_salt('bf'))
WHERE id = p_user_id
RETURNING name INTO v_name;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'User not found');
END IF;
RETURN jsonb_build_object('success', true, 'user_id', p_user_id, 'pin', v_raw_pin);
END;
$$;
-- =============================================================================
-- submit_water_entry(p_session_id, p_headgate_id, p_measurement, p_unit, p_notes, p_submitted_via)
-- Returns jsonb: {success, entry_id}
-- =============================================================================
CREATE OR REPLACE FUNCTION public.submit_water_entry(
p_session_id uuid,
p_headgate_id uuid,
p_measurement numeric,
p_unit text,
p_notes text DEFAULT '',
p_submitted_via text DEFAULT 'field'
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_user_id uuid;
v_brand_id uuid;
v_entry_id uuid;
BEGIN
SELECT s.user_id, h.brand_id
INTO v_user_id, v_brand_id
FROM public.water_sessions s
JOIN public.water_headgates h ON h.id = p_headgate_id
WHERE s.id = p_session_id
AND s.expires_at > now();
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Session expired or invalid');
END IF;
INSERT INTO public.water_log_entries
(brand_id, headgate_id, user_id, measurement, unit, notes, submitted_via)
VALUES
(v_brand_id, p_headgate_id, v_user_id, p_measurement, p_unit, p_notes, p_submitted_via)
RETURNING id INTO v_entry_id;
RETURN jsonb_build_object('success', true, 'entry_id', v_entry_id);
END;
$$;
-- =============================================================================
-- update_water_user(p_user_id, p_name, p_active, p_lang)
-- Returns jsonb: {success}
-- =============================================================================
CREATE OR REPLACE FUNCTION public.update_water_user(
p_user_id uuid,
p_name text,
p_active boolean,
p_lang text DEFAULT 'en'
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
BEGIN
UPDATE public.water_users
SET name = p_name, active = p_active, language_preference = p_lang
WHERE id = p_user_id;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'User not found');
END IF;
RETURN jsonb_build_object('success', true);
END;
$$;
-- =============================================================================
-- get_water_entries(p_brand_id, p_limit)
-- Returns jsonb: {entries: [...]}
-- =============================================================================
CREATE OR REPLACE FUNCTION public.get_water_entries(p_brand_id uuid, p_limit int DEFAULT 50)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
BEGIN
RETURN (
SELECT jsonb_build_object('entries', COALESCE(
(SELECT jsonb_agg(e ORDER BY e.logged_at DESC)
FROM (
SELECT
e.id,
e.headgate_id,
e.user_id,
h.name AS headgate_name,
u.name AS user_name,
e.measurement,
e.unit,
e.notes,
e.submitted_via,
e.logged_at
FROM public.water_log_entries e
JOIN public.water_headgates h ON e.headgate_id = h.id
JOIN public.water_users u ON e.user_id = u.id
WHERE e.brand_id = p_brand_id
ORDER BY e.logged_at DESC
LIMIT p_limit
) e
), '[]'::jsonb
))
);
END;
$$;
NOTIFY pgrst, 'reload schema';
@@ -1,123 +0,0 @@
-- =============================================================================
-- Water Log V1 — Soft Delete
-- Adds deleted_at to water_users and water_headgates
-- Creates delete_water_user and delete_water_headgate RPCs
-- Existing entries preserved; deleted users/headgates excluded from queries
-- =============================================================================
-- Add deleted_at column to water_users
ALTER TABLE public.water_users
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
-- Add deleted_at column to water_headgates
ALTER TABLE public.water_headgates
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
-- =============================================================================
-- delete_water_user(p_user_id uuid)
-- Soft-deletes a water user (sets deleted_at) so entries are preserved.
-- Returns jsonb: {success} or {success:false, error}
-- =============================================================================
CREATE OR REPLACE FUNCTION public.delete_water_user(p_user_id uuid)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
BEGIN
UPDATE public.water_users
SET deleted_at = now()
WHERE id = p_user_id AND deleted_at IS NULL;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'User not found or already deleted');
END IF;
RETURN jsonb_build_object('success', true);
END;
$$;
-- =============================================================================
-- delete_water_headgate(p_headgate_id uuid)
-- Soft-deletes a water headgate (sets deleted_at) so entries are preserved.
-- Returns jsonb: {success} or {success:false, error}
-- =============================================================================
CREATE OR REPLACE FUNCTION public.delete_water_headgate(p_headgate_id uuid)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
BEGIN
UPDATE public.water_headgates
SET deleted_at = now()
WHERE id = p_headgate_id AND deleted_at IS NULL;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Headgate not found or already deleted');
END IF;
RETURN jsonb_build_object('success', true);
END;
$$;
-- =============================================================================
-- Update get_water_users to exclude soft-deleted users
-- =============================================================================
CREATE OR REPLACE FUNCTION public.get_water_users(p_brand_id uuid)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
BEGIN
RETURN (
SELECT jsonb_build_object('users', COALESCE(
(SELECT jsonb_agg(
jsonb_build_object(
'id', id,
'name', name,
'role', role,
'active', active,
'language_preference', language_preference,
'last_used_at', last_used_at,
'created_at', created_at,
'deleted_at', deleted_at
) ORDER BY created_at DESC
)
FROM public.water_users
WHERE brand_id = p_brand_id AND deleted_at IS NULL
), '[]'::jsonb
))
);
END;
$$;
-- =============================================================================
-- Update get_water_headgates to exclude soft-deleted headgates
-- =============================================================================
CREATE OR REPLACE FUNCTION public.get_water_headgates(p_brand_id uuid, p_active_only boolean DEFAULT false)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
BEGIN
RETURN (
SELECT jsonb_build_object('headgates', COALESCE(
(SELECT jsonb_agg(
jsonb_build_object(
'id', id,
'name', name,
'active', active,
'created_at', created_at,
'deleted_at', deleted_at
) ORDER BY created_at DESC
)
FROM public.water_headgates
WHERE brand_id = p_brand_id
AND (NOT p_active_only OR active = true)
AND deleted_at IS NULL
), '[]'::jsonb
))
);
END;
$$;
NOTIFY pgrst, 'reload schema';
@@ -1,88 +0,0 @@
-- =============================================================================
-- Water Log V1 — Entry Edit & Delete
-- Allows water_admin and platform_admin to edit measurement/notes
-- Allows deletion of entries (hard delete, not soft)
-- =============================================================================
-- =============================================================================
-- update_water_entry(p_entry_id uuid, p_measurement numeric, p_notes text)
-- Updates measurement and notes only. Preserves user_id, headgate_id,
-- logged_at, submitted_via.
-- Returns jsonb: {success} or {success:false, error}
-- =============================================================================
CREATE OR REPLACE FUNCTION public.update_water_entry(p_entry_id uuid, p_measurement numeric, p_notes text)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
BEGIN
UPDATE public.water_log_entries
SET measurement = p_measurement, notes = p_notes
WHERE id = p_entry_id;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Entry not found');
END IF;
RETURN jsonb_build_object('success', true);
END;
$$;
-- =============================================================================
-- delete_water_entry(p_entry_id uuid)
-- Hard-deletes an entry. Use with caution — no soft-delete for entries.
-- Returns jsonb: {success} or {success:false, error}
-- =============================================================================
CREATE OR REPLACE FUNCTION public.delete_water_entry(p_entry_id uuid)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
BEGIN
DELETE FROM public.water_log_entries WHERE id = p_entry_id;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Entry not found');
END IF;
RETURN jsonb_build_object('success', true);
END;
$$;
NOTIFY pgrst, 'reload schema';
-- =============================================================================
-- get_water_entry_by_id(p_entry_id uuid)
-- Returns a single entry row for the edit page.
-- Returns jsonb: {entry: {...}} or {entry: null}
-- =============================================================================
CREATE OR REPLACE FUNCTION public.get_water_entry_by_id(p_entry_id uuid)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
BEGIN
RETURN (
SELECT jsonb_build_object('entry', (
SELECT jsonb_build_object(
'id', e.id,
'headgate_id', e.headgate_id,
'user_id', e.user_id,
'headgate_name', hg.name,
'user_name', u.name,
'measurement', e.measurement,
'unit', e.unit,
'notes', e.notes,
'submitted_via', e.submitted_via,
'logged_at', e.logged_at
)
FROM public.water_log_entries e
JOIN public.water_headgates hg ON hg.id = e.headgate_id
JOIN public.water_users u ON u.id = e.user_id
WHERE e.id = p_entry_id
))
);
END;
$$;
NOTIFY pgrst, 'reload schema';
@@ -1,202 +0,0 @@
-- =============================================================================
-- Water Log V1.1 — Unit Types + Dedicated Edit Pages
--
-- Adds `unit` column to water_headgates so each headgate carries its
-- preferred measurement unit (CFS, GPM, etc.). This unit pre-selects
-- on the field form and is editable via dedicated headgate/user edit pages.
--
-- Also updates update_water_entry to support changing unit.
-- =============================================================================
-- ── Add unit to headgates ────────────────────────────────────
ALTER TABLE public.water_headgates
ADD COLUMN IF NOT EXISTS unit TEXT NOT NULL DEFAULT 'CFS';
-- ── Update submit_water_entry to respect headgate's unit ─────
-- The field form should use the headgate's default unit.
-- Update get_water_entries to include headgate unit.
-- Update update_water_entry to accept optional unit change.
CREATE OR REPLACE FUNCTION public.update_water_entry(
p_entry_id uuid,
p_measurement numeric,
p_notes text,
p_unit text DEFAULT NULL
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
BEGIN
IF p_unit IS NOT NULL THEN
UPDATE public.water_log_entries
SET measurement = p_measurement, notes = p_notes, unit = p_unit
WHERE id = p_entry_id;
ELSE
UPDATE public.water_log_entries
SET measurement = p_measurement, notes = p_notes
WHERE id = p_entry_id;
END IF;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Entry not found');
END IF;
RETURN jsonb_build_object('success', true);
END;
$$;
-- Update get_water_entries to include headgate's default unit (for pre-fill)
CREATE OR REPLACE FUNCTION public.get_water_entries(p_brand_id uuid, p_limit int DEFAULT 50)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
BEGIN
RETURN (
SELECT jsonb_build_object('entries', COALESCE(
(SELECT jsonb_agg(
jsonb_build_object(
'id', e.id,
'headgate_id', e.headgate_id,
'user_id', e.user_id,
'headgate_name', hg.name,
'user_name', u.name,
'measurement', e.measurement,
'unit', e.unit,
'notes', e.notes,
'submitted_via', e.submitted_via,
'logged_at', e.logged_at,
'headgate_unit', hg.unit
) ORDER BY e.logged_at DESC
)
FROM public.water_log_entries e
JOIN public.water_headgates hg ON hg.id = e.headgate_id
JOIN public.water_users u ON u.id = e.user_id
WHERE e.brand_id = p_brand_id
), '[]'::jsonb
))
);
END;
$$;
-- Update get_water_entry_by_id to include headgate_unit for pre-fill
CREATE OR REPLACE FUNCTION public.get_water_entry_by_id(p_entry_id uuid)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
BEGIN
RETURN (
SELECT jsonb_build_object('entry', (
SELECT jsonb_build_object(
'id', e.id,
'headgate_id', e.headgate_id,
'user_id', e.user_id,
'headgate_name', hg.name,
'user_name', u.name,
'measurement', e.measurement,
'unit', e.unit,
'notes', e.notes,
'submitted_via', e.submitted_via,
'logged_at', e.logged_at,
'headgate_unit', hg.unit
)
FROM public.water_log_entries e
JOIN public.water_headgates hg ON hg.id = e.headgate_id
JOIN public.water_users u ON u.id = e.user_id
WHERE e.id = p_entry_id
))
);
END;
$$;
-- Drop overloads so there's only one version with p_unit defaulting
DROP FUNCTION IF EXISTS public.update_water_headgate(uuid, text, boolean);
CREATE OR REPLACE FUNCTION public.update_water_headgate(
p_headgate_id uuid,
p_name text,
p_active boolean,
p_unit text DEFAULT NULL
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
BEGIN
IF p_unit IS NOT NULL THEN
UPDATE public.water_headgates
SET name = p_name, active = p_active, unit = p_unit
WHERE id = p_headgate_id AND deleted_at IS NULL;
ELSE
UPDATE public.water_headgates
SET name = p_name, active = p_active
WHERE id = p_headgate_id AND deleted_at IS NULL;
END IF;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Headgate not found');
END IF;
RETURN jsonb_build_object('success', true);
END;
$$;
-- Update create_water_headgate to accept unit
CREATE OR REPLACE FUNCTION public.create_water_headgate(p_brand_id uuid, p_name text, p_unit text DEFAULT 'CFS')
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_headgate_id uuid;
v_result jsonb;
BEGIN
INSERT INTO public.water_headgates (brand_id, name, unit)
VALUES (p_brand_id, p_name, p_unit)
RETURNING id INTO v_headgate_id;
SELECT jsonb_build_object(
'id', id,
'name', name,
'active', active,
'unit', unit,
'created_at', created_at
) INTO v_result
FROM public.water_headgates WHERE id = v_headgate_id;
RETURN jsonb_build_object('headgate', v_result);
END;
$$;
-- get_water_headgates admin view should include unit
CREATE OR REPLACE FUNCTION public.get_water_headgates(p_brand_id uuid, p_active_only boolean DEFAULT false)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
BEGIN
RETURN (
SELECT jsonb_build_object('headgates', COALESCE(
(SELECT jsonb_agg(
jsonb_build_object(
'id', id,
'name', name,
'active', active,
'unit', unit,
'created_at', created_at,
'deleted_at', deleted_at
) ORDER BY created_at DESC
)
FROM public.water_headgates
WHERE brand_id = p_brand_id
AND (NOT p_active_only OR active = true)
AND deleted_at IS NULL
), '[]'::jsonb
))
);
END;
$$;
NOTIFY pgrst, 'reload schema';
@@ -1,289 +0,0 @@
-- =============================================================================
-- Water Log V1.1 — Unit Types + Soft Delete Fix (consolidated)
--
-- If migration 008 was not run, this adds deleted_at columns.
-- If migration 010 had issues, this fixes the function overload and adds missing cols.
-- Safe to run multiple times — uses ADD COLUMN IF NOT EXISTS and IF EXISTS drops.
-- =============================================================================
-- ── Soft delete columns (if not already present) ─────────────────────────────
ALTER TABLE public.water_users
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
ALTER TABLE public.water_headgates
ADD COLUMN IF NOT EXISTS unit TEXT NOT NULL DEFAULT 'CFS',
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
-- ── Fix update_water_headgate overload ───────────────────────────────────────
-- Drop old 3-param overload so there's only one version (with p_unit)
DROP FUNCTION IF EXISTS public.update_water_headgate(uuid, text, boolean);
CREATE OR REPLACE FUNCTION public.update_water_headgate(
p_headgate_id uuid,
p_name text,
p_active boolean,
p_unit text DEFAULT NULL
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
BEGIN
IF p_unit IS NOT NULL THEN
UPDATE public.water_headgates
SET name = p_name, active = p_active, unit = p_unit
WHERE id = p_headgate_id AND deleted_at IS NULL;
ELSE
UPDATE public.water_headgates
SET name = p_name, active = p_active
WHERE id = p_headgate_id AND deleted_at IS NULL;
END IF;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Headgate not found');
END IF;
RETURN jsonb_build_object('success', true);
END;
$$;
-- ── Fix update_water_entry overload ──────────────────────────────────────────
-- Drop old 3-param overload so only the 4-param version (p_unit optional) exists
DROP FUNCTION IF EXISTS public.update_water_entry(uuid, numeric, text);
CREATE OR REPLACE FUNCTION public.update_water_entry(
p_entry_id uuid,
p_measurement numeric,
p_notes text,
p_unit text DEFAULT NULL
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
BEGIN
IF p_unit IS NOT NULL THEN
UPDATE public.water_log_entries
SET measurement = p_measurement, notes = p_notes, unit = p_unit
WHERE id = p_entry_id;
ELSE
UPDATE public.water_log_entries
SET measurement = p_measurement, notes = p_notes
WHERE id = p_entry_id;
END IF;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Entry not found');
END IF;
RETURN jsonb_build_object('success', true);
END;
$$;
-- ── Create Water Headgate (updated to support p_unit) ────────────────────────
DROP FUNCTION IF EXISTS public.create_water_headgate(uuid, text, text);
CREATE OR REPLACE FUNCTION public.create_water_headgate(
p_brand_id uuid,
p_name text,
p_unit text DEFAULT 'CFS'
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_headgate_id uuid;
v_result jsonb;
BEGIN
INSERT INTO public.water_headgates (brand_id, name, unit)
VALUES (p_brand_id, p_name, p_unit)
RETURNING id INTO v_headgate_id;
SELECT jsonb_build_object(
'id', id,
'name', name,
'active', active,
'unit', unit,
'created_at', created_at
) INTO v_result
FROM public.water_headgates WHERE id = v_headgate_id;
RETURN jsonb_build_object('headgate', v_result);
END;
$$;
-- ── Delete Water User (soft delete) ───────────────────────────────────────────
DROP FUNCTION IF EXISTS public.delete_water_user(uuid);
CREATE OR REPLACE FUNCTION public.delete_water_user(p_user_id uuid)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
BEGIN
UPDATE public.water_users
SET deleted_at = now()
WHERE id = p_user_id AND deleted_at IS NULL;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'User not found or already deleted');
END IF;
RETURN jsonb_build_object('success', true);
END;
$$;
-- ── Delete Water Headgate (soft delete) ──────────────────────────────────────
DROP FUNCTION IF EXISTS public.delete_water_headgate(uuid);
CREATE OR REPLACE FUNCTION public.delete_water_headgate(p_headgate_id uuid)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
BEGIN
UPDATE public.water_headgates
SET deleted_at = now()
WHERE id = p_headgate_id AND deleted_at IS NULL;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Headgate not found or already deleted');
END IF;
RETURN jsonb_build_object('success', true);
END;
$$;
-- ── Get Water Users (exclude soft-deleted) ───────────────────────────────────
DROP FUNCTION IF EXISTS public.get_water_users(uuid);
CREATE OR REPLACE FUNCTION public.get_water_users(p_brand_id uuid)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
BEGIN
RETURN (
SELECT jsonb_build_object('users', COALESCE(
(SELECT jsonb_agg(
jsonb_build_object(
'id', id,
'name', name,
'role', role,
'active', active,
'language_preference', language_preference,
'last_used_at', last_used_at,
'created_at', created_at,
'deleted_at', deleted_at
) ORDER BY created_at DESC
)
FROM public.water_users
WHERE brand_id = p_brand_id AND deleted_at IS NULL
), '[]'::jsonb
))
);
END;
$$;
-- ── Get Water Headgates (exclude soft-deleted, include unit) ───────────────────
DROP FUNCTION IF EXISTS public.get_water_headgates(uuid, boolean);
CREATE OR REPLACE FUNCTION public.get_water_headgates(p_brand_id uuid, p_active_only boolean DEFAULT false)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
BEGIN
RETURN (
SELECT jsonb_build_object('headgates', COALESCE(
(SELECT jsonb_agg(
jsonb_build_object(
'id', id,
'name', name,
'active', active,
'unit', unit,
'created_at', created_at,
'deleted_at', deleted_at
) ORDER BY created_at DESC
)
FROM public.water_headgates
WHERE brand_id = p_brand_id
AND (NOT p_active_only OR active = true)
AND deleted_at IS NULL
), '[]'::jsonb
))
);
END;
$$;
-- ── Get Water Entries (include headgate_unit) ─────────────────────────────────
DROP FUNCTION IF EXISTS public.get_water_entries(uuid, int);
CREATE OR REPLACE FUNCTION public.get_water_entries(p_brand_id uuid, p_limit int DEFAULT 50)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
BEGIN
RETURN (
SELECT jsonb_build_object('entries', COALESCE(
(SELECT jsonb_agg(
jsonb_build_object(
'id', e.id,
'headgate_id', e.headgate_id,
'user_id', e.user_id,
'headgate_name', hg.name,
'user_name', u.name,
'measurement', e.measurement,
'unit', e.unit,
'notes', e.notes,
'submitted_via', e.submitted_via,
'logged_at', e.logged_at,
'headgate_unit', hg.unit
) ORDER BY e.logged_at DESC
)
FROM public.water_log_entries e
JOIN public.water_headgates hg ON hg.id = e.headgate_id
JOIN public.water_users u ON u.id = e.user_id
WHERE e.brand_id = p_brand_id
), '[]'::jsonb
))
);
END;
$$;
-- ── Get Water Entry By ID (include headgate_unit) ────────────────────────────
DROP FUNCTION IF EXISTS public.get_water_entry_by_id(uuid);
CREATE OR REPLACE FUNCTION public.get_water_entry_by_id(p_entry_id uuid)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
BEGIN
RETURN (
SELECT jsonb_build_object('entry', (
SELECT jsonb_build_object(
'id', e.id,
'headgate_id', e.headgate_id,
'user_id', e.user_id,
'headgate_name', hg.name,
'user_name', u.name,
'measurement', e.measurement,
'unit', e.unit,
'notes', e.notes,
'submitted_via', e.submitted_via,
'logged_at', e.logged_at,
'headgate_unit', hg.unit
)
FROM public.water_log_entries e
JOIN public.water_headgates hg ON hg.id = e.headgate_id
JOIN public.water_users u ON u.id = e.user_id
WHERE e.id = p_entry_id
))
);
END;
$$;
NOTIFY pgrst, 'reload schema';
@@ -1,103 +0,0 @@
-- =============================================================================
-- Water Log V1.6 - Display Summary RPC
-- Returns headgate latest readings + today's aggregates for Smartsheet/display.
-- SECURITY DEFINER so anon key can call it (same as other water log RPCs).
-- =============================================================================
CREATE OR REPLACE FUNCTION public.get_water_display_summary(p_brand_id uuid)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
BEGIN
RETURN (
WITH headgate_data AS (
SELECT
hg.id,
hg.name,
COALESCE(hg.unit, 'CFS') as unit,
(
SELECT jsonb_build_object(
'measurement', le.measurement,
'user_name', u.name,
'logged_at', le.logged_at
)
FROM public.water_log_entries le
JOIN public.water_users u ON u.id = le.user_id
WHERE le.headgate_id = hg.id
ORDER BY le.logged_at DESC
LIMIT 1
) as latest_entry,
(
SELECT le.logged_at
FROM public.water_log_entries le
WHERE le.headgate_id = hg.id
ORDER BY le.logged_at DESC
LIMIT 1
) as last_logged_at,
(
SELECT FLOOR(EXTRACT(EPOCH FROM (now() - le.logged_at)) / 60)::int
FROM public.water_log_entries le
WHERE le.headgate_id = hg.id
ORDER BY le.logged_at DESC
LIMIT 1
) as minutes_ago
FROM public.water_headgates hg
WHERE hg.brand_id = p_brand_id
AND hg.active = true
AND hg.deleted_at IS NULL
),
today_stats AS (
SELECT
count(*) as cnt,
COALESCE(sum(measurement), 0) as total
FROM public.water_log_entries
WHERE brand_id = p_brand_id AND logged_at::date = CURRENT_DATE
),
entries_ordered AS (
SELECT e.logged_at, e.headgate_id, e.user_id, e.measurement, e.unit, e.notes, e.submitted_via
FROM public.water_log_entries e
WHERE e.brand_id = p_brand_id
ORDER BY e.logged_at DESC
LIMIT 20
),
recent_entries_agg AS (
SELECT COALESCE(jsonb_agg(
jsonb_build_object(
'logged_at', e.logged_at,
'headgate_name', hg.name,
'user_name', u.name,
'user_role', u.role,
'measurement', e.measurement,
'unit', e.unit,
'notes', e.notes,
'submitted_via', e.submitted_via
)
), '[]'::jsonb) as data
FROM entries_ordered e
JOIN public.water_headgates hg ON hg.id = e.headgate_id
JOIN public.water_users u ON u.id = e.user_id
)
SELECT jsonb_build_object(
'headgates', (
SELECT COALESCE(jsonb_agg(
jsonb_build_object(
'id', hd.id,
'name', hd.name,
'unit', hd.unit,
'latest_entry', hd.latest_entry,
'last_logged_at', hd.last_logged_at,
'minutes_ago', hd.minutes_ago
)
), '[]'::jsonb)
FROM headgate_data hd
),
'today_count', (SELECT cnt FROM today_stats),
'today_total', (SELECT total FROM today_stats),
'recent_entries', (SELECT data FROM recent_entries_agg)
)
);
END;
$$;
NOTIFY pgrst, 'reload schema';
@@ -1,13 +0,0 @@
-- =============================================================================
-- Water Log V1.6 - Seed Water Admin User
-- =============================================================================
INSERT INTO public.water_users (brand_id, name, pin_hash, role, active, language_preference)
VALUES (
'64294306-5f42-463d-a5e8-2ad6c81a96de',
'Test Water Admin',
extensions.crypt('1234', gen_salt('bf')),
'water_admin',
true,
'en'
);
@@ -1,9 +0,0 @@
-- =============================================================================
-- Water Log V1.6 - Fix water_sessions RLS
-- Add SELECT policy so getWaterAdminSession can read sessions with anon key
-- =============================================================================
DROP POLICY IF EXISTS "Sessions readable by all" ON public.water_sessions;
CREATE POLICY "Sessions readable by all" ON public.water_sessions FOR SELECT TO anon USING (true);
NOTIFY pgrst, 'reload schema';
@@ -1,90 +0,0 @@
-- =============================================================================
-- Water Log V1.6 - Security Hardening: block direct water_users access
--
-- Problem: pin_hash can be read via direct REST SELECT with anon key.
-- Fix: Remove direct SELECT policies. All reads go through SECURITY DEFINER
-- RPCs which run as the supabase postgres user and bypass RLS.
--
-- Changes:
-- 1. DROP "Users readable by all" policy (was added to fix getWaterAdminSession)
-- 2. Create get_water_user_by_id(p_user_id) RPC for language lookup (SECURITY DEFINER)
-- 3. Create get_water_admin_session() RPC (SECURITY DEFINER) replacing direct
-- water_sessions + water_users JOIN in field.ts getWaterAdminSession
-- 4. Update field.ts to use RPCs instead of direct REST calls
-- 5. Supabase will continue to route all water_users writes through the existing
-- SECURITY DEFINER RPCs (create_water_user, update_water_user, etc.)
-- =============================================================================
-- Step 1: Remove direct SELECT policy from water_users
DROP POLICY IF EXISTS "Users readable by all" ON public.water_users;
-- Step 2: Create a SECURITY DEFINER RPC to get a single user's language preference
-- This replaces the direct REST call in field.ts line 83
-- (runs as supabase postgres user, bypasses RLS)
CREATE OR REPLACE FUNCTION public.get_water_user_by_id(p_user_id uuid)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_result jsonb;
BEGIN
SELECT jsonb_build_object(
'language_preference', language_preference
) INTO v_result
FROM public.water_users
WHERE id = p_user_id AND deleted_at IS NULL;
RETURN v_result;
END;
$$;
-- Step 3: Create get_water_admin_session() RPC (SECURITY DEFINER)
-- Reads the wl_session cookie and returns the associated user's info.
-- Replaces the direct water_sessions + water_users JOIN in getWaterAdminSession.
-- Runs as supabase postgres user, bypasses RLS on both water_sessions and water_users.
CREATE OR REPLACE FUNCTION public.get_water_admin_session()
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_session_id text;
v_result jsonb;
BEGIN
-- Read wl_session from request cookies (set by verify_water_pin)
-- In SECURITY DEFINER context, we can't access HTTP cookies directly.
-- The session_id is passed from the caller (Next.js server action).
-- Instead, create a variant that accepts session_id as a parameter.
RETURN null; -- placeholder — actual implementation uses p_session_id below
END;
$$;
-- Drop and recreate with proper signature (accepts p_session_id)
DROP FUNCTION IF EXISTS public.get_water_admin_session();
CREATE OR REPLACE FUNCTION public.get_water_admin_session(p_session_id text)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_result jsonb;
BEGIN
SELECT jsonb_build_object(
'user_id', s.user_id,
'name', u.name,
'role', u.role
) INTO v_result
FROM public.water_sessions s
JOIN public.water_users u ON u.id = s.user_id
WHERE s.id = p_session_id::uuid
AND s.expires_at > now()
AND u.role = 'water_admin'
AND u.deleted_at IS NULL;
RETURN v_result;
END;
$$;
NOTIFY pgrst, 'reload schema';
@@ -1,662 +0,0 @@
-- =============================================================================
-- Communication Center V1 — Core Schema
-- Fully idempotent: safe to re-run after partial failure or interrupted deploy
--
-- Cross-brand platform module: supports Tuxedo Corn, Indian River Direct, and
-- future brands via brand_id separation and RLS.
--
-- Design principles:
-- - Operational messaging first (stop notifications, pickup reminders, etc.)
-- - Audience rules owned by campaigns (no separate reusable audience table)
-- - Environment-based provider config (Resend API key in env, not in DB)
-- - Event-driven future-ready (event_type / event_id on message_logs)
-- - No live email/SMS sending until explicitly enabled after testing
-- - Customer data lives in orders table — no separate customers table
-- =============================================================================
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- ── Tables (CREATE TABLE IF NOT EXISTS is already idempotent) ────────────
CREATE TABLE IF NOT EXISTS public.communication_settings (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
default_sender_email TEXT,
default_sender_name TEXT,
reply_to_email TEXT,
email_provider TEXT NOT NULL DEFAULT 'resend',
email_footer_html TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(brand_id)
);
CREATE TABLE IF NOT EXISTS public.communication_templates (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
name TEXT NOT NULL,
subject TEXT NOT NULL,
body_text TEXT NOT NULL DEFAULT '',
body_html TEXT,
template_type TEXT NOT NULL,
campaign_type TEXT,
created_by UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS public.communication_campaigns (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
name TEXT NOT NULL,
subject TEXT,
body_text TEXT,
body_html TEXT,
template_id UUID REFERENCES communication_templates(id) ON DELETE SET NULL,
campaign_type TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft',
audience_rules JSONB NOT NULL DEFAULT '{}',
scheduled_at TIMESTAMPTZ,
sent_at TIMESTAMPTZ,
created_by UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS public.communication_message_logs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
campaign_id UUID REFERENCES communication_campaigns(id) ON DELETE SET NULL,
customer_id UUID,
customer_email TEXT,
delivery_method TEXT NOT NULL,
subject TEXT,
body_preview TEXT,
status TEXT NOT NULL,
sent_at TIMESTAMPTZ,
error_message TEXT,
event_type TEXT,
event_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS public.customer_communication_preferences (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
customer_id UUID NOT NULL,
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
email_opt_in BOOLEAN NOT NULL DEFAULT true,
sms_opt_in BOOLEAN NOT NULL DEFAULT false,
email_opt_in_at TIMESTAMPTZ,
sms_opt_in_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(customer_id, brand_id)
);
-- ── Indexes (CREATE INDEX IF NOT EXISTS is already idempotent) ────────────
CREATE INDEX IF NOT EXISTS idx_communication_settings_brand
ON public.communication_settings(brand_id);
CREATE INDEX IF NOT EXISTS idx_communication_templates_brand
ON public.communication_templates(brand_id);
CREATE INDEX IF NOT EXISTS idx_communication_campaigns_brand_status
ON public.communication_campaigns(brand_id, status);
CREATE INDEX IF NOT EXISTS idx_communication_campaigns_brand_type
ON public.communication_campaigns(brand_id, campaign_type);
CREATE INDEX IF NOT EXISTS idx_communication_message_logs_brand_sent
ON public.communication_message_logs(brand_id, sent_at DESC);
CREATE INDEX IF NOT EXISTS idx_communication_message_logs_campaign
ON public.communication_message_logs(campaign_id);
CREATE INDEX IF NOT EXISTS idx_communication_message_logs_customer
ON public.communication_message_logs(customer_id);
CREATE INDEX IF NOT EXISTS idx_communication_message_logs_event
ON public.communication_message_logs(event_type, event_id) WHERE event_type IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_customer_comm_prefs_customer_brand
ON public.customer_communication_preferences(customer_id, brand_id);
-- ── RLS — idempotent (ALTER TABLE ENABLE is safe to re-run) ────────────────
ALTER TABLE public.communication_settings ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.communication_templates ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.communication_campaigns ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.communication_message_logs ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.customer_communication_preferences ENABLE ROW LEVEL SECURITY;
-- ── Policies (DROP IF EXISTS before each CREATE makes them idempotent) ────
DROP POLICY IF EXISTS "Platform admin can read communication_settings"
ON public.communication_settings;
DROP POLICY IF EXISTS "Brand admin can read own brand communication_settings"
ON public.communication_settings;
CREATE POLICY "Platform admin can read communication_settings"
ON public.communication_settings FOR SELECT TO authenticated
USING (EXISTS (
SELECT 1 FROM admin_users
WHERE admin_users.user_id = auth.uid()
AND admin_users.role = 'platform_admin'
));
CREATE POLICY "Brand admin can read own brand communication_settings"
ON public.communication_settings FOR SELECT TO authenticated
USING (brand_id IN (
SELECT brand_id FROM admin_users
WHERE admin_users.user_id = auth.uid()
AND admin_users.role = 'brand_admin'
));
DROP POLICY IF EXISTS "Platform admin can read communication_templates"
ON public.communication_templates;
DROP POLICY IF EXISTS "Brand admin can read own brand communication_templates"
ON public.communication_templates;
CREATE POLICY "Platform admin can read communication_templates"
ON public.communication_templates FOR SELECT TO authenticated
USING (EXISTS (
SELECT 1 FROM admin_users
WHERE admin_users.user_id = auth.uid()
AND admin_users.role = 'platform_admin'
));
CREATE POLICY "Brand admin can read own brand communication_templates"
ON public.communication_templates FOR SELECT TO authenticated
USING (brand_id IN (
SELECT brand_id FROM admin_users
WHERE admin_users.user_id = auth.uid()
AND admin_users.role = 'brand_admin'
));
DROP POLICY IF EXISTS "Platform admin can read communication_campaigns"
ON public.communication_campaigns;
DROP POLICY IF EXISTS "Brand admin can read own brand communication_campaigns"
ON public.communication_campaigns;
CREATE POLICY "Platform admin can read communication_campaigns"
ON public.communication_campaigns FOR SELECT TO authenticated
USING (EXISTS (
SELECT 1 FROM admin_users
WHERE admin_users.user_id = auth.uid()
AND admin_users.role = 'platform_admin'
));
CREATE POLICY "Brand admin can read own brand communication_campaigns"
ON public.communication_campaigns FOR SELECT TO authenticated
USING (brand_id IN (
SELECT brand_id FROM admin_users
WHERE admin_users.user_id = auth.uid()
AND admin_users.role = 'brand_admin'
));
DROP POLICY IF EXISTS "Platform admin can read communication_message_logs"
ON public.communication_message_logs;
DROP POLICY IF EXISTS "Brand admin can read own brand communication_message_logs"
ON public.communication_message_logs;
CREATE POLICY "Platform admin can read communication_message_logs"
ON public.communication_message_logs FOR SELECT TO authenticated
USING (EXISTS (
SELECT 1 FROM admin_users
WHERE admin_users.user_id = auth.uid()
AND admin_users.role = 'platform_admin'
));
CREATE POLICY "Brand admin can read own brand communication_message_logs"
ON public.communication_message_logs FOR SELECT TO authenticated
USING (brand_id IN (
SELECT brand_id FROM admin_users
WHERE admin_users.user_id = auth.uid()
AND admin_users.role = 'brand_admin'
));
DROP POLICY IF EXISTS "Anyone can read customer_communication_preferences"
ON public.customer_communication_preferences;
DROP POLICY IF EXISTS "Platform admin can read customer_communication_preferences"
ON public.customer_communication_preferences;
CREATE POLICY "Anyone can read customer_communication_preferences"
ON public.customer_communication_preferences FOR SELECT TO anon USING (true);
CREATE POLICY "Platform admin can read customer_communication_preferences"
ON public.customer_communication_preferences FOR SELECT TO authenticated
USING (EXISTS (
SELECT 1 FROM admin_users
WHERE admin_users.user_id = auth.uid()
AND admin_users.role = 'platform_admin'
));
-- ── SECURITY DEFINER RPCs ─────────────────────────────────────────────────
DROP FUNCTION IF EXISTS public.upsert_communication_settings(UUID, TEXT, TEXT, TEXT, TEXT, TEXT);
CREATE OR REPLACE FUNCTION public.upsert_communication_settings(
p_brand_id UUID,
p_sender_email TEXT,
p_sender_name TEXT,
p_reply_to_email TEXT,
p_provider TEXT,
p_footer_html TEXT
)
RETURNS communication_settings
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE v_result communication_settings;
BEGIN
INSERT INTO public.communication_settings
(brand_id, default_sender_email, default_sender_name, reply_to_email, email_provider, email_footer_html)
VALUES (p_brand_id, p_sender_email, p_sender_name, p_reply_to_email, p_provider, p_footer_html)
ON CONFLICT (brand_id) DO UPDATE SET
default_sender_email = EXCLUDED.default_sender_email,
default_sender_name = EXCLUDED.default_sender_name,
reply_to_email = EXCLUDED.reply_to_email,
email_provider = EXCLUDED.email_provider,
email_footer_html = EXCLUDED.email_footer_html,
updated_at = now()
RETURNING * INTO v_result;
RETURN v_result;
END;
$$;
DROP FUNCTION IF EXISTS public.upsert_communication_template(UUID, UUID, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, UUID);
CREATE OR REPLACE FUNCTION public.upsert_communication_template(
p_id UUID,
p_brand_id UUID,
p_name TEXT,
p_subject TEXT,
p_body_text TEXT,
p_body_html TEXT,
p_template_type TEXT,
p_campaign_type TEXT,
p_created_by UUID
)
RETURNS communication_templates
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_result communication_templates;
v_id UUID;
BEGIN
v_id := coalesce(p_id, uuid_generate_v4());
INSERT INTO public.communication_templates
(id, brand_id, name, subject, body_text, body_html, template_type, campaign_type, created_by)
VALUES (v_id, p_brand_id, p_name, p_subject, p_body_text, p_body_html, p_template_type, p_campaign_type, p_created_by)
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
subject = EXCLUDED.subject,
body_text = EXCLUDED.body_text,
body_html = EXCLUDED.body_html,
template_type = EXCLUDED.template_type,
campaign_type = EXCLUDED.campaign_type,
updated_at = now()
RETURNING * INTO v_result;
RETURN v_result;
END;
$$;
DROP FUNCTION IF EXISTS public.upsert_communication_campaign(UUID, UUID, TEXT, TEXT, TEXT, TEXT, UUID, TEXT, TEXT, JSONB, TIMESTAMPTZ, UUID);
CREATE OR REPLACE FUNCTION public.upsert_communication_campaign(
p_id UUID,
p_brand_id UUID,
p_name TEXT,
p_subject TEXT,
p_body_text TEXT,
p_body_html TEXT,
p_template_id UUID,
p_campaign_type TEXT,
p_status TEXT,
p_audience_rules JSONB,
p_scheduled_at TIMESTAMPTZ,
p_created_by UUID
)
RETURNS communication_campaigns
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_result communication_campaigns;
v_id UUID;
BEGIN
v_id := coalesce(p_id, uuid_generate_v4());
INSERT INTO public.communication_campaigns
(id, brand_id, name, subject, body_text, body_html, template_id, campaign_type, status, audience_rules, scheduled_at, created_by)
VALUES (v_id, p_brand_id, p_name, p_subject, p_body_text, p_body_html, p_template_id, p_campaign_type, p_status, p_audience_rules, p_scheduled_at, p_created_by)
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
subject = EXCLUDED.subject,
body_text = EXCLUDED.body_text,
body_html = EXCLUDED.body_html,
template_id = EXCLUDED.template_id,
campaign_type = EXCLUDED.campaign_type,
status = EXCLUDED.status,
audience_rules = EXCLUDED.audience_rules,
scheduled_at = EXCLUDED.scheduled_at,
updated_at = now()
WHERE communication_campaigns.brand_id = p_brand_id
RETURNING * INTO v_result;
RETURN v_result;
END;
$$;
DROP FUNCTION IF EXISTS public.log_communication_messages(JSONB);
CREATE OR REPLACE FUNCTION public.log_communication_messages(p_entries JSONB)
RETURNS integer
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_entry JSONB;
v_inserted_count integer := 0;
BEGIN
FOR v_entry IN SELECT * FROM jsonb_array_elements(p_entries)
LOOP
INSERT INTO public.communication_message_logs
(brand_id, campaign_id, customer_id, customer_email, delivery_method,
subject, body_preview, status, sent_at, error_message)
VALUES (
(v_entry->>'brand_id')::UUID,
nullif(v_entry->>'campaign_id', '')::UUID,
nullif(v_entry->>'customer_id', '')::UUID,
v_entry->>'customer_email',
v_entry->>'delivery_method',
v_entry->>'subject',
left(v_entry->>'body_preview', 500),
coalesce(v_entry->>'status', 'queued'),
now(),
v_entry->>'error_message'
);
v_inserted_count := v_inserted_count + 1;
END LOOP;
RETURN v_inserted_count;
END;
$$;
DROP FUNCTION IF EXISTS public.opt_out_customer(UUID, UUID, TEXT);
CREATE OR REPLACE FUNCTION public.opt_out_customer(
p_customer_id UUID,
p_brand_id UUID,
p_method TEXT
)
RETURNS customer_communication_preferences
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE v_result customer_communication_preferences;
BEGIN
INSERT INTO public.customer_communication_preferences
(customer_id, brand_id, email_opt_in, sms_opt_in, email_opt_in_at, sms_opt_in_at)
VALUES (
p_customer_id, p_brand_id,
CASE WHEN p_method = 'email' THEN false ELSE true END,
CASE WHEN p_method = 'sms' THEN false ELSE true END,
CASE WHEN p_method = 'email' THEN now() ELSE NULL END,
CASE WHEN p_method = 'sms' THEN now() ELSE NULL END
)
ON CONFLICT (customer_id, brand_id) DO UPDATE SET
email_opt_in = CASE WHEN p_method = 'email' THEN false ELSE customer_communication_preferences.email_opt_in END,
sms_opt_in = CASE WHEN p_method = 'sms' THEN false ELSE customer_communication_preferences.sms_opt_in END,
email_opt_in_at = CASE WHEN p_method = 'email' THEN now() ELSE email_opt_in_at END,
sms_opt_in_at = CASE WHEN p_method = 'sms' THEN now() ELSE sms_opt_in_at END,
updated_at = now()
RETURNING * INTO v_result;
RETURN v_result;
END;
$$;
DROP FUNCTION IF EXISTS public.preview_campaign_audience(UUID, JSONB);
CREATE OR REPLACE FUNCTION public.preview_campaign_audience(
p_brand_id UUID,
p_audience_rules JSONB
)
RETURNS jsonb
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_target TEXT;
v_stop_id UUID;
v_date_from TIMESTAMPTZ;
v_date_to TIMESTAMPTZ;
v_city TEXT;
v_order_hist TEXT;
v_days_back INTEGER;
v_product_id UUID;
v_count INTEGER;
v_customers JSONB;
BEGIN
v_target := p_audience_rules->>'target';
-- Stop-based targeting
IF v_target = 'stop' THEN
v_stop_id := nullif(p_audience_rules->>'stop_id', '')::UUID;
v_date_from := nullif(p_audience_rules->>'date_from', '')::TIMESTAMPTZ;
v_date_to := nullif(p_audience_rules->>'date_to', '')::TIMESTAMPTZ;
SELECT COUNT(DISTINCT o.customer_id),
jsonb_agg(DISTINCT jsonb_build_object('id', o.customer_id, 'email', o.customer_email, 'name', o.customer_name) ORDER BY o.customer_name)
INTO v_count, v_customers
FROM orders o
JOIN stops s ON s.id = o.stop_id
LEFT JOIN customer_communication_preferences cp ON cp.customer_id = o.customer_id AND cp.brand_id = p_brand_id
WHERE s.brand_id = p_brand_id AND o.status NOT IN ('canceled')
AND (v_stop_id IS NULL OR o.stop_id = v_stop_id)
AND (v_date_from IS NULL OR o.created_at >= v_date_from)
AND (v_date_to IS NULL OR o.created_at <= v_date_to)
AND (cp.email_opt_in IS NULL OR cp.email_opt_in = true);
-- ZIP/city targeting (customer ZIP not in orders — city filter only)
ELSIF v_target = 'zip_code' THEN
v_city := p_audience_rules->>'city';
SELECT COUNT(DISTINCT o.customer_id),
jsonb_agg(DISTINCT jsonb_build_object('id', o.customer_id, 'email', o.customer_email, 'name', o.customer_name) ORDER BY o.customer_name)
INTO v_count, v_customers
FROM orders o
JOIN stops s ON s.id = o.stop_id
LEFT JOIN customer_communication_preferences cp ON cp.customer_id = o.customer_id AND cp.brand_id = p_brand_id
WHERE s.brand_id = p_brand_id AND o.status NOT IN ('canceled')
AND (cp.email_opt_in IS NULL OR cp.email_opt_in = true)
AND (v_city IS NULL OR true); -- city filter placeholder; ZIP requires customer_zip column in orders
-- Customer history targeting
ELSIF v_target = 'customer_history' THEN
v_order_hist := p_audience_rules->>'order_history';
v_days_back := (p_audience_rules->>'days_back')::INTEGER;
IF v_order_hist = 'first_order' THEN
WITH first_orders AS (
SELECT o.customer_id, MIN(o.created_at)
FROM orders o JOIN stops s ON s.id = o.stop_id
WHERE s.brand_id = p_brand_id AND o.status NOT IN ('canceled')
AND (v_days_back IS NULL OR o.created_at >= now() - (v_days_back || ' days')::INTERVAL)
GROUP BY o.customer_id HAVING COUNT(*) = 1
)
SELECT COUNT(*),
jsonb_agg(DISTINCT jsonb_build_object('id', fo.customer_id, 'email', o.customer_email, 'name', o.customer_name) ORDER BY o.customer_name)
INTO v_count, v_customers
FROM first_orders fo
JOIN orders o ON o.customer_id = fo.customer_id
LEFT JOIN customer_communication_preferences cp ON cp.customer_id = fo.customer_id AND cp.brand_id = p_brand_id
WHERE (cp.email_opt_in IS NULL OR cp.email_opt_in = true);
ELSIF v_order_hist = 'repeat' THEN
WITH repeat_customer_ids AS (
SELECT o.customer_id
FROM orders o JOIN stops s ON s.id = o.stop_id
WHERE s.brand_id = p_brand_id AND o.status NOT IN ('canceled')
AND (v_days_back IS NULL OR o.created_at >= now() - (v_days_back || ' days')::INTERVAL)
GROUP BY o.customer_id HAVING COUNT(*) > 1
)
SELECT COUNT(*),
jsonb_agg(DISTINCT jsonb_build_object('id', rc.customer_id, 'email', o.customer_email, 'name', o.customer_name) ORDER BY o.customer_name)
INTO v_count, v_customers
FROM repeat_customer_ids rc
JOIN orders o ON o.customer_id = rc.customer_id
LEFT JOIN customer_communication_preferences cp ON cp.customer_id = rc.customer_id AND cp.brand_id = p_brand_id
WHERE (cp.email_opt_in IS NULL OR cp.email_opt_in = true);
ELSE
SELECT COUNT(DISTINCT o.customer_id),
jsonb_agg(DISTINCT jsonb_build_object('id', o.customer_id, 'email', o.customer_email, 'name', o.customer_name) ORDER BY o.customer_name)
INTO v_count, v_customers
FROM orders o
JOIN stops s ON s.id = o.stop_id
LEFT JOIN customer_communication_preferences cp ON cp.customer_id = o.customer_id AND cp.brand_id = p_brand_id
WHERE s.brand_id = p_brand_id AND o.status NOT IN ('canceled')
AND (v_days_back IS NULL OR o.created_at >= now() - (v_days_back || ' days')::INTERVAL)
AND (cp.email_opt_in IS NULL OR cp.email_opt_in = true);
END IF;
-- Product-based targeting
ELSIF v_target = 'product' THEN
v_product_id := nullif(p_audience_rules->>'product_id', '')::UUID;
SELECT COUNT(DISTINCT o.customer_id),
jsonb_agg(DISTINCT jsonb_build_object('id', o.customer_id, 'email', o.customer_email, 'name', o.customer_name) ORDER BY o.customer_name)
INTO v_count, v_customers
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
JOIN stops s ON s.id = o.stop_id
LEFT JOIN customer_communication_preferences cp ON cp.customer_id = o.customer_id AND cp.brand_id = p_brand_id
WHERE s.brand_id = p_brand_id AND o.status NOT IN ('canceled')
AND (v_product_id IS NULL OR oi.product_id = v_product_id)
AND (cp.email_opt_in IS NULL OR cp.email_opt_in = true);
-- Explicit customer ID list
ELSIF v_target = 'customer_ids' THEN
SELECT COUNT(*),
jsonb_agg(jsonb_build_object('id', o.customer_id, 'email', o.customer_email, 'name', o.customer_name) ORDER BY o.customer_name)
INTO v_count, v_customers
FROM orders o
LEFT JOIN customer_communication_preferences cp ON cp.customer_id = o.customer_id AND cp.brand_id = p_brand_id
WHERE o.customer_id IN (
SELECT jsonb_array_elements_text(p_audience_rules->'customer_ids')::UUID
)
AND (cp.email_opt_in IS NULL OR cp.email_opt_in = true);
ELSE
-- all_customers or unknown
SELECT COUNT(DISTINCT o.customer_id),
jsonb_agg(DISTINCT jsonb_build_object('id', o.customer_id, 'email', o.customer_email, 'name', o.customer_name) ORDER BY o.customer_name)
INTO v_count, v_customers
FROM orders o
JOIN stops s ON s.id = o.stop_id
LEFT JOIN customer_communication_preferences cp ON cp.customer_id = o.customer_id AND cp.brand_id = p_brand_id
WHERE s.brand_id = p_brand_id AND o.status NOT IN ('canceled')
AND (cp.email_opt_in IS NULL OR cp.email_opt_in = true);
END IF;
-- Cap sample at 20
SELECT jsonb_agg(x ORDER BY x->>'name') INTO v_customers
FROM (SELECT * FROM jsonb_array_elements(coalesce(v_customers, '[]'::jsonb)) LIMIT 20) t(x);
RETURN jsonb_build_object('count', v_count, 'sample_customers', v_customers);
END;
$$;
DROP FUNCTION IF EXISTS public.send_campaign(UUID);
CREATE OR REPLACE FUNCTION public.send_campaign(p_campaign_id UUID)
RETURNS jsonb
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_campaign communication_campaigns;
v_settings communication_settings;
v_audience JSONB;
v_entries JSONB := '[]'::JSONB;
v_customer JSONB;
v_count INTEGER := 0;
BEGIN
SELECT * INTO v_campaign FROM communication_campaigns WHERE id = p_campaign_id;
IF NOT FOUND THEN RETURN jsonb_build_object('success', false, 'error', 'Campaign not found'); END IF;
SELECT * INTO v_settings FROM communication_settings WHERE brand_id = v_campaign.brand_id;
IF NOT FOUND THEN RETURN jsonb_build_object('success', false, 'error', 'No communication settings for brand'); END IF;
v_audience := preview_campaign_audience(v_campaign.brand_id, v_campaign.audience_rules);
FOR v_customer IN SELECT * FROM jsonb_array_elements(coalesce(v_audience->'sample_customers', '[]'::jsonb))
LOOP
v_entries := v_entries || jsonb_build_array(jsonb_build_object(
'brand_id', v_campaign.brand_id, 'campaign_id', p_campaign_id,
'customer_id', v_customer->>'id', 'customer_email', v_customer->>'email',
'delivery_method', 'email', 'subject', v_campaign.subject,
'body_preview', left(v_campaign.body_text, 500), 'status', 'queued'
));
v_count := v_count + 1;
END LOOP;
PERFORM log_communication_messages(v_entries);
UPDATE communication_campaigns SET status = 'sent', sent_at = now(), updated_at = now()
WHERE id = p_campaign_id;
RETURN jsonb_build_object('success', true, 'messages_logged', v_count);
END;
$$;
DROP FUNCTION IF EXISTS public.get_communication_campaigns(UUID);
CREATE OR REPLACE FUNCTION public.get_communication_campaigns(p_brand_id UUID)
RETURNS JSONB LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE v_campaigns JSONB;
BEGIN
IF p_brand_id IS NULL THEN
SELECT COALESCE(jsonb_agg(c ORDER BY c.created_at DESC), '[]'::JSONB) INTO v_campaigns FROM communication_campaigns c;
ELSE
SELECT COALESCE(jsonb_agg(c ORDER BY c.created_at DESC), '[]'::JSONB) INTO v_campaigns
FROM communication_campaigns c WHERE c.brand_id = p_brand_id;
END IF;
RETURN jsonb_build_object('campaigns', v_campaigns);
END;
$$;
DROP FUNCTION IF EXISTS public.get_communication_campaign_by_id(UUID);
CREATE OR REPLACE FUNCTION public.get_communication_campaign_by_id(p_campaign_id UUID)
RETURNS JSONB LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE v_campaign communication_campaigns%ROWTYPE;
BEGIN
SELECT * INTO v_campaign FROM communication_campaigns WHERE id = p_campaign_id;
IF NOT FOUND THEN RETURN jsonb_build_object('campaign', NULL); END IF;
RETURN jsonb_build_object('campaign', row_to_json(v_campaign));
END;
$$;
DROP FUNCTION IF EXISTS public.get_communication_templates(UUID);
CREATE OR REPLACE FUNCTION public.get_communication_templates(p_brand_id UUID)
RETURNS JSONB LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE v_templates JSONB;
BEGIN
IF p_brand_id IS NULL THEN
SELECT COALESCE(jsonb_agg(t ORDER BY t.name), '[]'::JSONB) INTO v_templates FROM communication_templates t;
ELSE
SELECT COALESCE(jsonb_agg(t ORDER BY t.name), '[]'::JSONB) INTO v_templates
FROM communication_templates t WHERE t.brand_id = p_brand_id;
END IF;
RETURN jsonb_build_object('templates', v_templates);
END;
$$;
DROP FUNCTION IF EXISTS public.get_communication_settings(UUID);
CREATE OR REPLACE FUNCTION public.get_communication_settings(p_brand_id UUID)
RETURNS JSONB LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE v_settings communication_settings%ROWTYPE;
BEGIN
SELECT * INTO v_settings FROM communication_settings WHERE brand_id = p_brand_id;
IF NOT FOUND THEN RETURN jsonb_build_object('settings', NULL); END IF;
RETURN jsonb_build_object('settings', row_to_json(v_settings));
END;
$$;
DROP FUNCTION IF EXISTS public.get_message_logs(UUID, UUID, TEXT, INTEGER);
CREATE OR REPLACE FUNCTION public.get_message_logs(
p_brand_id UUID,
p_campaign_id UUID DEFAULT NULL,
p_status TEXT DEFAULT NULL,
p_limit INTEGER DEFAULT 100
)
RETURNS JSONB LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE v_logs JSONB;
BEGIN
SELECT COALESCE(jsonb_agg(l ORDER BY l.created_at DESC), '[]'::JSONB) INTO v_logs
FROM (
SELECT l.* FROM communication_message_logs l
WHERE (p_brand_id IS NULL OR l.brand_id = p_brand_id)
AND (p_campaign_id IS NULL OR l.campaign_id = p_campaign_id)
AND (p_status IS NULL OR l.status = p_status)
ORDER BY l.created_at DESC LIMIT p_limit
) l;
RETURN jsonb_build_object('logs', v_logs);
END;
$$;
DROP FUNCTION IF EXISTS public.delete_communication_campaign(UUID);
CREATE OR REPLACE FUNCTION public.delete_communication_campaign(p_campaign_id UUID)
RETURNS boolean LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
UPDATE communication_campaigns SET status = 'canceled', updated_at = now()
WHERE id = p_campaign_id;
RETURN FOUND;
END;
$$;
NOTIFY pgrst, 'reload schema';
@@ -1,878 +0,0 @@
-- =============================================================================
-- Communication Center V1.1 — Contacts + Import Foundation
-- Fully idempotent: safe to re-run after partial failure
-- Additive over V1 (migration 016):
-- - Adds communication_contacts table
-- - Adds DB trigger to auto-create contacts from orders
-- - Adds new RPCs for contact CRUD, import, and opt-out
-- - Rewrites preview_campaign_audience to use communication_contacts as primary
-- - Updates send_campaign to resolve through communication_contacts
-- - Updates opt_out_customer to also set unsubscribed_at on contacts
-- =============================================================================
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- ── Table ────────────────────────────────────────────────────────────────────
-- Uses CREATE TABLE IF NOT EXISTS so this is safe to re-run — no data loss.
CREATE TABLE IF NOT EXISTS public.communication_contacts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
email TEXT,
phone TEXT,
first_name TEXT,
last_name TEXT,
full_name TEXT,
source TEXT NOT NULL, -- 'order' | 'import' | 'manual' | 'admin'
external_id TEXT,
customer_id UUID,
email_opt_in BOOLEAN NOT NULL DEFAULT true,
sms_opt_in BOOLEAN NOT NULL DEFAULT false,
email_opt_in_at TIMESTAMPTZ,
sms_opt_in_at TIMESTAMPTZ,
unsubscribed_at TIMESTAMPTZ,
tags TEXT[] DEFAULT '{}',
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT chk_has_email_or_phone CHECK (email IS NOT NULL OR phone IS NOT NULL)
);
-- ── Indexes ─────────────────────────────────────────────────────────────────
CREATE UNIQUE INDEX IF NOT EXISTS idx_contacts_brand_email_unique
ON public.communication_contacts(brand_id, email)
WHERE email IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_contacts_brand_phone_unique
ON public.communication_contacts(brand_id, phone)
WHERE phone IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_communication_contacts_brand
ON public.communication_contacts(brand_id);
CREATE INDEX IF NOT EXISTS idx_communication_contacts_customer
ON public.communication_contacts(customer_id)
WHERE customer_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_communication_contacts_unsubscribed
ON public.communication_contacts(brand_id, unsubscribed_at)
WHERE unsubscribed_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_communication_contacts_source
ON public.communication_contacts(brand_id, source);
-- ── RLS ─────────────────────────────────────────────────────────────────────
ALTER TABLE public.communication_contacts ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "Platform admin can read communication_contacts"
ON public.communication_contacts;
CREATE POLICY "Platform admin can read communication_contacts"
ON public.communication_contacts FOR SELECT TO authenticated
USING (EXISTS (
SELECT 1 FROM admin_users
WHERE admin_users.user_id = auth.uid()
AND admin_users.role = 'platform_admin'
));
DROP POLICY IF EXISTS "Brand admin can read own brand communication_contacts"
ON public.communication_contacts;
CREATE POLICY "Brand admin can read own brand communication_contacts"
ON public.communication_contacts FOR SELECT TO authenticated
USING (brand_id IN (
SELECT brand_id FROM admin_users
WHERE admin_users.user_id = auth.uid()
AND admin_users.role = 'brand_admin'
));
-- ── SECURITY DEFINER RPCs ────────────────────────────────────────────────────
-- 1. Trigger function for auto-creating contact from order
-- PostgreSQL trigger functions MUST use RETURNS TRIGGER and access NEW/OLD directly.
DROP FUNCTION IF EXISTS public.upsert_contact_from_order();
CREATE OR REPLACE FUNCTION public.upsert_contact_from_order()
RETURNS TRIGGER
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_brand_id UUID;
BEGIN
-- Skip if no email (can't upsert without a key)
IF NEW.customer_email IS NULL OR NEW.customer_email = '' THEN
RETURN NEW;
END IF;
-- Resolve brand_id from the stop
SELECT brand_id INTO v_brand_id FROM public.stops WHERE id = NEW.stop_id;
IF NOT FOUND THEN
RETURN NEW; -- stop not found; skip silently
END IF;
INSERT INTO public.communication_contacts
(brand_id, email, phone, full_name, source, customer_id, email_opt_in, metadata)
VALUES (
v_brand_id,
NEW.customer_email,
NEW.customer_phone,
NEW.customer_name,
'order',
NEW.customer_id,
true,
jsonb_build_object(
'last_order_id', NEW.id,
'last_order_at', now()::TEXT,
'stop_id', NEW.stop_id::TEXT
)
)
ON CONFLICT (brand_id, email) WHERE email IS NOT NULL DO UPDATE SET
full_name = COALESCE(EXCLUDED.full_name, communication_contacts.full_name),
phone = COALESCE(EXCLUDED.phone, communication_contacts.phone),
customer_id = COALESCE(EXCLUDED.customer_id, communication_contacts.customer_id),
metadata = jsonb_build_object(
'last_order_id', communication_contacts.metadata->>'last_order_id',
'last_order_at', communication_contacts.metadata->>'last_order_at',
'stop_id', NEW.stop_id::TEXT
),
updated_at = now();
-- NOTE: email_opt_in, unsubscribed_at, sms_opt_in are intentionally NOT
-- updated here — we preserve the existing opt-out status.
RETURN NEW;
END;
$$;
-- 2. Trigger on orders INSERT
-- Trigger functions receive NEW implicitly; no arguments after func name.
DROP TRIGGER IF EXISTS trg_create_contact_from_order ON public.orders;
CREATE TRIGGER trg_create_contact_from_order
AFTER INSERT ON public.orders
FOR EACH ROW
EXECUTE FUNCTION public.upsert_contact_from_order();
-- 3. upsert_communication_contact (general upsert by email or phone)
DROP FUNCTION IF EXISTS public.upsert_communication_contact(
UUID, UUID, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, BOOLEAN, BOOLEAN, TEXT[], JSONB
);
CREATE OR REPLACE FUNCTION public.upsert_communication_contact(
p_id UUID,
p_brand_id UUID,
p_email TEXT,
p_phone TEXT,
p_first_name TEXT,
p_last_name TEXT,
p_full_name TEXT,
p_source TEXT,
p_external_id TEXT,
p_customer_id UUID,
p_email_opt_in BOOLEAN,
p_sms_opt_in BOOLEAN,
p_tags TEXT[],
p_metadata JSONB
)
RETURNS communication_contacts
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_result communication_contacts;
v_id UUID;
BEGIN
v_id := coalesce(p_id, uuid_generate_v4());
IF p_email IS NOT NULL AND p_email != '' THEN
INSERT INTO public.communication_contacts
(id, brand_id, email, phone, first_name, last_name, full_name, source,
external_id, customer_id, email_opt_in, sms_opt_in, tags, metadata,
email_opt_in_at, sms_opt_in_at)
VALUES
(v_id, p_brand_id, p_email, p_phone, p_first_name, p_last_name,
p_full_name, p_source, p_external_id, p_customer_id,
coalesce(p_email_opt_in, true), coalesce(p_sms_opt_in, false),
coalesce(p_tags, '{}'), coalesce(p_metadata, '{}'),
CASE WHEN p_email_opt_in = true THEN now() ELSE NULL END,
CASE WHEN p_sms_opt_in = true THEN now() ELSE NULL END)
ON CONFLICT (brand_id, email) WHERE email IS NOT NULL DO UPDATE SET
phone = COALESCE(EXCLUDED.phone, communication_contacts.phone),
first_name = COALESCE(EXCLUDED.first_name, communication_contacts.first_name),
last_name = COALESCE(EXCLUDED.last_name, communication_contacts.last_name),
full_name = COALESCE(EXCLUDED.full_name, communication_contacts.full_name),
source = COALESCE(EXCLUDED.source, communication_contacts.source),
external_id = COALESCE(EXCLUDED.external_id, communication_contacts.external_id),
customer_id = COALESCE(EXCLUDED.customer_id, communication_contacts.customer_id),
email_opt_in = CASE
WHEN communication_contacts.unsubscribed_at IS NOT NULL
THEN communication_contacts.email_opt_in
ELSE COALESCE(EXCLUDED.email_opt_in, communication_contacts.email_opt_in)
END,
sms_opt_in = CASE
WHEN communication_contacts.unsubscribed_at IS NOT NULL
THEN communication_contacts.sms_opt_in
ELSE COALESCE(EXCLUDED.sms_opt_in, communication_contacts.sms_opt_in)
END,
tags = COALESCE(EXCLUDED.tags, communication_contacts.tags),
metadata = communication_contacts.metadata || COALESCE(EXCLUDED.metadata, '{}'),
updated_at = now()
RETURNING * INTO v_result;
ELSIF p_phone IS NOT NULL AND p_phone != '' THEN
INSERT INTO public.communication_contacts
(id, brand_id, email, phone, first_name, last_name, full_name, source,
external_id, customer_id, email_opt_in, sms_opt_in, tags, metadata,
email_opt_in_at, sms_opt_in_at)
VALUES
(v_id, p_brand_id, NULL, p_phone, p_first_name, p_last_name,
p_full_name, p_source, p_external_id, p_customer_id,
coalesce(p_email_opt_in, true), coalesce(p_sms_opt_in, false),
coalesce(p_tags, '{}'), coalesce(p_metadata, '{}'),
CASE WHEN p_email_opt_in = true THEN now() ELSE NULL END,
CASE WHEN p_sms_opt_in = true THEN now() ELSE NULL END)
ON CONFLICT (brand_id, phone) WHERE phone IS NOT NULL DO UPDATE SET
email = COALESCE(EXCLUDED.email, communication_contacts.email),
first_name = COALESCE(EXCLUDED.first_name, communication_contacts.first_name),
last_name = COALESCE(EXCLUDED.last_name, communication_contacts.last_name),
full_name = COALESCE(EXCLUDED.full_name, communication_contacts.full_name),
source = COALESCE(EXCLUDED.source, communication_contacts.source),
external_id = COALESCE(EXCLUDED.external_id, communication_contacts.external_id),
customer_id = COALESCE(EXCLUDED.customer_id, communication_contacts.customer_id),
email_opt_in = CASE
WHEN communication_contacts.unsubscribed_at IS NOT NULL
THEN communication_contacts.email_opt_in
ELSE COALESCE(EXCLUDED.email_opt_in, communication_contacts.email_opt_in)
END,
sms_opt_in = CASE
WHEN communication_contacts.unsubscribed_at IS NOT NULL
THEN communication_contacts.sms_opt_in
ELSE COALESCE(EXCLUDED.sms_opt_in, communication_contacts.sms_opt_in)
END,
tags = COALESCE(EXCLUDED.tags, communication_contacts.tags),
metadata = communication_contacts.metadata || COALESCE(EXCLUDED.metadata, '{}'),
updated_at = now()
RETURNING * INTO v_result;
ELSE
RETURN NULL;
END IF;
RETURN v_result;
END;
$$;
-- 4. get_communication_contacts (paginated list with search)
DROP FUNCTION IF EXISTS public.get_communication_contacts(UUID, TEXT, TEXT, INTEGER, INTEGER);
CREATE OR REPLACE FUNCTION public.get_communication_contacts(
p_brand_id UUID,
p_search TEXT DEFAULT NULL,
p_source TEXT DEFAULT NULL,
p_limit INTEGER DEFAULT 100,
p_offset INTEGER DEFAULT 0
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_contacts JSONB;
v_total INTEGER;
BEGIN
SELECT COUNT(*) INTO v_total
FROM public.communication_contacts c
WHERE (p_brand_id IS NULL OR c.brand_id = p_brand_id)
AND (p_source IS NULL OR c.source = p_source)
AND (
p_search IS NULL
OR c.email ILIKE '%' || p_search || '%'
OR c.full_name ILIKE '%' || p_search || '%'
OR c.phone ILIKE '%' || p_search || '%'
);
SELECT COALESCE(jsonb_agg(c ORDER BY c.created_at DESC), '[]'::JSONB) INTO v_contacts
FROM (
SELECT c.*
FROM public.communication_contacts c
WHERE (p_brand_id IS NULL OR c.brand_id = p_brand_id)
AND (p_source IS NULL OR c.source = p_source)
AND (
p_search IS NULL
OR c.email ILIKE '%' || p_search || '%'
OR c.full_name ILIKE '%' || p_search || '%'
OR c.phone ILIKE '%' || p_search || '%'
)
ORDER BY c.created_at DESC
LIMIT p_limit
OFFSET p_offset
) c;
RETURN jsonb_build_object(
'contacts', v_contacts,
'total', v_total,
'limit', p_limit,
'offset', p_offset
);
END;
$$;
-- 5. import_communication_contacts_batch (bulk CSV upsert)
-- Returns: { created: n, updated: n, skipped: n, errors: [...] }
-- tags are stored as TEXT[]; CSV string "tag1;tag2" is split with string_to_array.
DROP FUNCTION IF EXISTS public.import_communication_contacts_batch(UUID, JSONB, BOOLEAN);
CREATE OR REPLACE FUNCTION public.import_communication_contacts_batch(
p_brand_id UUID,
p_contacts JSONB,
p_allow_opt_in_override BOOLEAN DEFAULT false
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_entry JSONB;
v_result JSONB := '{"created": 0, "updated": 0, "skipped": 0, "errors": []}'::JSONB;
v_existing communication_contacts%ROWTYPE;
v_email TEXT;
v_phone TEXT;
v_tags TEXT[];
BEGIN
FOR v_entry IN SELECT * FROM jsonb_array_elements(p_contacts)
LOOP
v_email := nullif(v_entry->>'email', '');
v_phone := nullif(v_entry->>'phone', '');
-- Parse tags: CSV sends "tag1;tag2" as a plain string; split into TEXT[]
IF (v_entry->>'tags') IS NOT NULL AND (v_entry->>'tags') != '' THEN
v_tags := coalesce(
(SELECT array_agg(trim(x)) FROM unnest(string_to_array(v_entry->>'tags', ';')) AS x WHERE trim(x) != ''),
'{}'
);
ELSE
v_tags := '{}';
END IF;
BEGIN
IF v_email IS NOT NULL AND v_email != '' THEN
SELECT * INTO v_existing
FROM public.communication_contacts
WHERE brand_id = p_brand_id AND email = v_email;
IF FOUND THEN
-- Existing contact
IF v_existing.unsubscribed_at IS NOT NULL AND
coalesce((v_entry->>'email_opt_in')::BOOLEAN, false) = true AND
p_allow_opt_in_override = false THEN
v_result := jsonb_set(v_result, '{skipped}',
to_jsonb((v_result->>'skipped')::INTEGER + 1));
ELSE
UPDATE public.communication_contacts SET
phone = COALESCE(nullif(v_entry->>'phone', ''), phone),
first_name = COALESCE(nullif(v_entry->>'first_name', ''), first_name),
last_name = COALESCE(nullif(v_entry->>'last_name', ''), last_name),
full_name = COALESCE(nullif(v_entry->>'full_name', ''), full_name),
source = 'import',
external_id = COALESCE(nullif(v_entry->>'external_id', ''), external_id),
email_opt_in = CASE
WHEN communication_contacts.unsubscribed_at IS NOT NULL
THEN communication_contacts.email_opt_in
ELSE coalesce(
(v_entry->>'email_opt_in')::BOOLEAN,
communication_contacts.email_opt_in,
true
)
END,
sms_opt_in = CASE
WHEN communication_contacts.unsubscribed_at IS NOT NULL
THEN communication_contacts.sms_opt_in
ELSE coalesce(
(v_entry->>'sms_opt_in')::BOOLEAN,
communication_contacts.sms_opt_in,
false
)
END,
email_opt_in_at = CASE
WHEN communication_contacts.unsubscribed_at IS NOT NULL
THEN email_opt_in_at
WHEN coalesce(
(v_entry->>'email_opt_in')::BOOLEAN,
communication_contacts.email_opt_in,
true
) = true THEN now()
ELSE email_opt_in_at
END,
tags = CASE
WHEN v_tags != '{}' THEN v_tags
ELSE communication_contacts.tags
END,
metadata = communication_contacts.metadata || jsonb_build_object(
'imported_at', now()::TEXT
),
updated_at = now()
WHERE brand_id = p_brand_id AND email = v_email;
v_result := jsonb_set(v_result, '{updated}',
to_jsonb((v_result->>'updated')::INTEGER + 1));
END IF;
ELSE
-- Insert new
INSERT INTO public.communication_contacts
(brand_id, email, phone, first_name, last_name, full_name, source,
external_id, email_opt_in, sms_opt_in, email_opt_in_at, sms_opt_in_at,
tags, metadata)
VALUES (
p_brand_id,
v_email,
nullif(v_entry->>'phone', ''),
nullif(v_entry->>'first_name', ''),
nullif(v_entry->>'last_name', ''),
nullif(v_entry->>'full_name', ''),
'import',
nullif(v_entry->>'external_id', ''),
coalesce((v_entry->>'email_opt_in')::BOOLEAN, true),
coalesce((v_entry->>'sms_opt_in')::BOOLEAN, false),
CASE WHEN coalesce((v_entry->>'email_opt_in')::BOOLEAN, true) = true THEN now() ELSE NULL END,
CASE WHEN coalesce((v_entry->>'sms_opt_in')::BOOLEAN, false) = true THEN now() ELSE NULL END,
v_tags,
jsonb_build_object('imported_at', now()::TEXT)
);
v_result := jsonb_set(v_result, '{created}',
to_jsonb((v_result->>'created')::INTEGER + 1));
END IF;
ELSIF v_phone IS NOT NULL AND v_phone != '' THEN
-- Upsert by phone
SELECT * INTO v_existing
FROM public.communication_contacts
WHERE brand_id = p_brand_id AND phone = v_phone;
IF FOUND THEN
IF v_existing.unsubscribed_at IS NOT NULL AND
coalesce((v_entry->>'email_opt_in')::BOOLEAN, false) = true AND
p_allow_opt_in_override = false THEN
v_result := jsonb_set(v_result, '{skipped}',
to_jsonb((v_result->>'skipped')::INTEGER + 1));
ELSE
UPDATE public.communication_contacts SET
email = COALESCE(nullif(v_entry->>'email', ''), email),
first_name = COALESCE(nullif(v_entry->>'first_name', ''), first_name),
last_name = COALESCE(nullif(v_entry->>'last_name', ''), last_name),
full_name = COALESCE(nullif(v_entry->>'full_name', ''), full_name),
source = 'import',
email_opt_in = CASE
WHEN communication_contacts.unsubscribed_at IS NOT NULL
THEN communication_contacts.email_opt_in
ELSE coalesce(
(v_entry->>'email_opt_in')::BOOLEAN,
communication_contacts.email_opt_in,
true
)
END,
tags = CASE WHEN v_tags != '{}' THEN v_tags ELSE communication_contacts.tags END,
metadata = communication_contacts.metadata || jsonb_build_object(
'imported_at', now()::TEXT
),
updated_at = now()
WHERE brand_id = p_brand_id AND phone = v_phone;
v_result := jsonb_set(v_result, '{updated}',
to_jsonb((v_result->>'updated')::INTEGER + 1));
END IF;
ELSE
INSERT INTO public.communication_contacts
(brand_id, email, phone, first_name, last_name, full_name, source,
email_opt_in, sms_opt_in, tags, metadata)
VALUES (
p_brand_id,
nullif(v_entry->>'email', ''),
v_phone,
nullif(v_entry->>'first_name', ''),
nullif(v_entry->>'last_name', ''),
nullif(v_entry->>'full_name', ''),
'import',
coalesce((v_entry->>'email_opt_in')::BOOLEAN, true),
coalesce((v_entry->>'sms_opt_in')::BOOLEAN, false),
v_tags,
jsonb_build_object('imported_at', now()::TEXT)
);
v_result := jsonb_set(v_result, '{created}',
to_jsonb((v_result->>'created')::INTEGER + 1));
END IF;
ELSE
-- No email or phone
v_result := jsonb_set(
v_result, '{errors}',
v_result->'errors' || jsonb_build_array(
jsonb_build_object('row', v_entry, 'error', 'No email or phone provided')
)
);
END IF;
EXCEPTION WHEN OTHERS THEN
v_result := jsonb_set(
v_result, '{errors}',
v_result->'errors' || jsonb_build_array(
jsonb_build_object('row', v_entry, 'error', SQLERRM)
)
);
END;
END LOOP;
RETURN v_result;
END;
$$;
-- 6. opt_out_contact (unsubscribe by email — public-facing, no auth required)
DROP FUNCTION IF EXISTS public.opt_out_contact(TEXT, UUID, TEXT);
CREATE OR REPLACE FUNCTION public.opt_out_contact(
p_email TEXT,
p_brand_id UUID,
p_method TEXT
)
RETURNS void
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
IF p_method = 'email' THEN
UPDATE public.communication_contacts SET
email_opt_in = false,
email_opt_in_at = now(),
unsubscribed_at = now(),
updated_at = now()
WHERE brand_id = p_brand_id AND email = p_email;
ELSIF p_method = 'sms' THEN
UPDATE public.communication_contacts SET
sms_opt_in = false,
sms_opt_in_at = now(),
unsubscribed_at = now(),
updated_at = now()
WHERE brand_id = p_brand_id AND (email = p_email OR phone = p_email);
END IF;
END;
$$;
-- 7. delete_communication_contact
DROP FUNCTION IF EXISTS public.delete_communication_contact(UUID);
CREATE OR REPLACE FUNCTION public.delete_communication_contact(p_id UUID)
RETURNS boolean
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
DELETE FROM public.communication_contacts WHERE id = p_id;
RETURN FOUND;
END;
$$;
-- ── Rewrite preview_campaign_audience to use communication_contacts ───────────
-- Primary source: communication_contacts (opted-in, not unsubscribed)
-- Orders-based targeting still works but final recipient resolution is via contacts.
DROP FUNCTION IF EXISTS public.preview_campaign_audience(UUID, JSONB);
CREATE OR REPLACE FUNCTION public.preview_campaign_audience(
p_brand_id UUID,
p_audience_rules JSONB
)
RETURNS jsonb
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_target TEXT;
v_stop_id UUID;
v_date_from TIMESTAMPTZ;
v_date_to TIMESTAMPTZ;
v_city TEXT;
v_order_hist TEXT;
v_days_back INTEGER;
v_product_id UUID;
v_count INTEGER;
v_contacts JSONB;
BEGIN
v_target := p_audience_rules->>'target';
-- Stop-based targeting: find orders for stop+date range, resolve to contacts
IF v_target = 'stop' THEN
v_stop_id := nullif(p_audience_rules->>'stop_id', '')::UUID;
v_date_from := nullif(p_audience_rules->>'date_from', '')::TIMESTAMPTZ;
v_date_to := nullif(p_audience_rules->>'date_to', '')::TIMESTAMPTZ;
SELECT COUNT(DISTINCT c.id),
jsonb_agg(DISTINCT jsonb_build_object(
'id', c.id, 'email', c.email, 'name', c.full_name,
'phone', c.phone, 'source', c.source
) ORDER BY c.full_name)
INTO v_count, v_contacts
FROM (
SELECT DISTINCT o.customer_email
FROM orders o
JOIN stops s ON s.id = o.stop_id
WHERE s.brand_id = p_brand_id
AND o.status NOT IN ('canceled')
AND (v_stop_id IS NULL OR o.stop_id = v_stop_id)
AND (v_date_from IS NULL OR o.created_at >= v_date_from)
AND (v_date_to IS NULL OR o.created_at <= v_date_to)
AND o.customer_email IS NOT NULL AND o.customer_email != ''
) o_emails
JOIN communication_contacts c ON c.email = o_emails.customer_email
AND c.brand_id = p_brand_id
AND c.email_opt_in = true
AND c.unsubscribed_at IS NULL;
-- ZIP / city targeting (placeholder — ZIP requires customer_zip column in orders)
ELSIF v_target = 'zip_code' THEN
SELECT COUNT(*),
jsonb_agg(jsonb_build_object(
'id', c.id, 'email', c.email, 'name', c.full_name,
'phone', c.phone, 'source', c.source
) ORDER BY c.full_name)
INTO v_count, v_contacts
FROM communication_contacts c
WHERE c.brand_id = p_brand_id
AND c.email_opt_in = true
AND c.unsubscribed_at IS NULL;
-- Customer history targeting
ELSIF v_target = 'customer_history' THEN
v_order_hist := p_audience_rules->>'order_history';
v_days_back := (p_audience_rules->>'days_back')::INTEGER;
IF v_order_hist = 'first_order' THEN
WITH first_order_emails AS (
SELECT o.customer_email
FROM orders o
JOIN stops s ON s.id = o.stop_id
WHERE s.brand_id = p_brand_id
AND o.status NOT IN ('canceled')
AND o.customer_email IS NOT NULL AND o.customer_email != ''
AND (v_days_back IS NULL OR o.created_at >= now() - (v_days_back || ' days')::INTERVAL)
GROUP BY o.customer_email
HAVING COUNT(*) = 1
)
SELECT COUNT(*),
jsonb_agg(jsonb_build_object(
'id', c.id, 'email', c.email, 'name', c.full_name,
'phone', c.phone, 'source', c.source
) ORDER BY c.full_name)
INTO v_count, v_contacts
FROM first_order_emails fe
JOIN communication_contacts c ON c.email = fe.customer_email
AND c.brand_id = p_brand_id
AND c.email_opt_in = true
AND c.unsubscribed_at IS NULL;
ELSIF v_order_hist = 'repeat' THEN
WITH repeat_emails AS (
SELECT o.customer_email
FROM orders o
JOIN stops s ON s.id = o.stop_id
WHERE s.brand_id = p_brand_id
AND o.status NOT IN ('canceled')
AND o.customer_email IS NOT NULL AND o.customer_email != ''
AND (v_days_back IS NULL OR o.created_at >= now() - (v_days_back || ' days')::INTERVAL)
GROUP BY o.customer_email
HAVING COUNT(*) > 1
)
SELECT COUNT(*),
jsonb_agg(jsonb_build_object(
'id', c.id, 'email', c.email, 'name', c.full_name,
'phone', c.phone, 'source', c.source
) ORDER BY c.full_name)
INTO v_count, v_contacts
FROM repeat_emails re
JOIN communication_contacts c ON c.email = re.customer_email
AND c.brand_id = p_brand_id
AND c.email_opt_in = true
AND c.unsubscribed_at IS NULL;
ELSE
WITH all_order_emails AS (
SELECT DISTINCT o.customer_email
FROM orders o
JOIN stops s ON s.id = o.stop_id
WHERE s.brand_id = p_brand_id
AND o.status NOT IN ('canceled')
AND o.customer_email IS NOT NULL AND o.customer_email != ''
AND (v_days_back IS NULL OR o.created_at >= now() - (v_days_back || ' days')::INTERVAL)
)
SELECT COUNT(*),
jsonb_agg(jsonb_build_object(
'id', c.id, 'email', c.email, 'name', c.full_name,
'phone', c.phone, 'source', c.source
) ORDER BY c.full_name)
INTO v_count, v_contacts
FROM all_order_emails aoe
JOIN communication_contacts c ON c.email = aoe.customer_email
AND c.brand_id = p_brand_id
AND c.email_opt_in = true
AND c.unsubscribed_at IS NULL;
END IF;
-- Product-based targeting
ELSIF v_target = 'product' THEN
v_product_id := nullif(p_audience_rules->>'product_id', '')::UUID;
WITH product_order_emails AS (
SELECT DISTINCT o.customer_email
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
JOIN stops s ON s.id = o.stop_id
WHERE s.brand_id = p_brand_id
AND o.status NOT IN ('canceled')
AND (v_product_id IS NULL OR oi.product_id = v_product_id)
AND o.customer_email IS NOT NULL AND o.customer_email != ''
)
SELECT COUNT(*),
jsonb_agg(jsonb_build_object(
'id', c.id, 'email', c.email, 'name', c.full_name,
'phone', c.phone, 'source', c.source
) ORDER BY c.full_name)
INTO v_count, v_contacts
FROM product_order_emails poe
JOIN communication_contacts c ON c.email = poe.customer_email
AND c.brand_id = p_brand_id
AND c.email_opt_in = true
AND c.unsubscribed_at IS NULL;
-- Explicit customer ID list
ELSIF v_target = 'customer_ids' THEN
SELECT COUNT(*),
jsonb_agg(jsonb_build_object(
'id', c.id, 'email', c.email, 'name', c.full_name,
'phone', c.phone, 'source', c.source
) ORDER BY c.full_name)
INTO v_count, v_contacts
FROM communication_contacts c
WHERE c.brand_id = p_brand_id
AND c.email_opt_in = true
AND c.unsubscribed_at IS NULL
AND c.customer_id IN (
SELECT jsonb_array_elements_text(p_audience_rules->'customer_ids')::UUID
);
-- all_customers: all opted-in, not-unsubscribed contacts
ELSE
SELECT COUNT(*),
jsonb_agg(jsonb_build_object(
'id', c.id, 'email', c.email, 'name', c.full_name,
'phone', c.phone, 'source', c.source
) ORDER BY c.full_name)
INTO v_count, v_contacts
FROM communication_contacts c
WHERE c.brand_id = p_brand_id
AND c.email_opt_in = true
AND c.unsubscribed_at IS NULL;
END IF;
-- Cap sample at 20
SELECT jsonb_agg(x ORDER BY x->>'name') INTO v_contacts
FROM (
SELECT * FROM jsonb_array_elements(coalesce(v_contacts, '[]'::jsonb))
LIMIT 20
) t(x);
RETURN jsonb_build_object('count', v_count, 'sample_customers', v_contacts);
END;
$$;
-- ── Update send_campaign to use communication_contacts ──────────────────────
DROP FUNCTION IF EXISTS public.send_campaign(UUID);
CREATE OR REPLACE FUNCTION public.send_campaign(p_campaign_id UUID)
RETURNS jsonb
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_campaign communication_campaigns;
v_settings communication_settings;
v_audience JSONB;
v_entry JSONB;
v_entries JSONB := '[]'::JSONB;
v_count INTEGER := 0;
BEGIN
SELECT * INTO v_campaign FROM communication_campaigns WHERE id = p_campaign_id;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Campaign not found');
END IF;
SELECT * INTO v_settings FROM communication_settings WHERE brand_id = v_campaign.brand_id;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'No communication settings for brand');
END IF;
v_audience := preview_campaign_audience(v_campaign.brand_id, v_campaign.audience_rules);
FOR v_entry IN SELECT * FROM jsonb_array_elements(coalesce(v_audience->'sample_customers', '[]'::jsonb))
LOOP
CONTINUE WHEN (v_entry->>'email') IS NULL OR (v_entry->>'email') = '';
v_entries := v_entries || jsonb_build_array(jsonb_build_object(
'brand_id', v_campaign.brand_id,
'campaign_id', p_campaign_id,
'customer_id', nullif(v_entry->>'id', ''),
'customer_email', v_entry->>'email',
'delivery_method','email',
'subject', v_campaign.subject,
'body_preview', left(v_campaign.body_text, 500),
'status', 'queued'
));
v_count := v_count + 1;
END LOOP;
PERFORM log_communication_messages(v_entries);
UPDATE communication_campaigns
SET status = 'sent', sent_at = now(), updated_at = now()
WHERE id = p_campaign_id;
RETURN jsonb_build_object('success', true, 'messages_logged', v_count);
END;
$$;
-- ── Update opt_out_customer to also set unsubscribed_at on contacts ───────────
DROP FUNCTION IF EXISTS public.opt_out_customer(UUID, UUID, TEXT);
CREATE OR REPLACE FUNCTION public.opt_out_customer(
p_customer_id UUID,
p_brand_id UUID,
p_method TEXT
)
RETURNS customer_communication_preferences
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE v_result customer_communication_preferences;
BEGIN
INSERT INTO public.customer_communication_preferences
(customer_id, brand_id, email_opt_in, sms_opt_in, email_opt_in_at, sms_opt_in_at)
VALUES (
p_customer_id, p_brand_id,
CASE WHEN p_method = 'email' THEN false ELSE true END,
CASE WHEN p_method = 'sms' THEN false ELSE true END,
CASE WHEN p_method = 'email' THEN now() ELSE NULL END,
CASE WHEN p_method = 'sms' THEN now() ELSE NULL END
)
ON CONFLICT (customer_id, brand_id) DO UPDATE SET
email_opt_in = CASE WHEN p_method = 'email' THEN false ELSE customer_communication_preferences.email_opt_in END,
sms_opt_in = CASE WHEN p_method = 'sms' THEN false ELSE customer_communication_preferences.sms_opt_in END,
email_opt_in_at = CASE WHEN p_method = 'email' THEN now() ELSE email_opt_in_at END,
sms_opt_in_at = CASE WHEN p_method = 'sms' THEN now() ELSE sms_opt_in_at END,
updated_at = now()
RETURNING * INTO v_result;
-- Also set unsubscribed_at on communication_contacts
IF p_method = 'email' THEN
UPDATE public.communication_contacts SET
email_opt_in = false,
email_opt_in_at = now(),
unsubscribed_at = now(),
updated_at = now()
WHERE customer_id = p_customer_id
AND brand_id = p_brand_id
AND unsubscribed_at IS NULL;
ELSIF p_method = 'sms' THEN
UPDATE public.communication_contacts SET
sms_opt_in = false,
sms_opt_in_at = now(),
unsubscribed_at = now(),
updated_at = now()
WHERE customer_id = p_customer_id
AND brand_id = p_brand_id
AND unsubscribed_at IS NULL;
END IF;
RETURN v_result;
END;
$$;
NOTIFY pgrst, 'reload schema';
@@ -1,230 +0,0 @@
-- =============================================================================
-- Communication Center V1.2 — Import Metadata Storage
-- Fully idempotent: additive changes only
-- Updates import_communication_contacts_batch to store ignored CSV columns
-- in metadata.imported_raw so no context is lost from arbitrary extra columns.
-- =============================================================================
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- ── Update email path — add imported_raw to metadata on UPDATE ───────────────
-- Lines ~400-402 in migration 017: UPDATE path for existing email contact
CREATE OR REPLACE FUNCTION public.import_communication_contacts_batch(
p_brand_id UUID,
p_contacts JSONB,
p_allow_opt_in_override BOOLEAN DEFAULT false
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_entry JSONB;
v_result JSONB := '{"created": 0, "updated": 0, "skipped": 0, "errors": []}'::JSONB;
v_existing communication_contacts%ROWTYPE;
v_email TEXT;
v_phone TEXT;
v_tags TEXT[];
v_raw_meta JSONB;
BEGIN
FOR v_entry IN SELECT * FROM jsonb_array_elements(p_contacts)
LOOP
v_email := nullif(v_entry->>'email', '');
v_phone := nullif(v_entry->>'phone', '');
-- Parse tags: CSV sends "tag1;tag2" as a plain string; split into TEXT[]
IF (v_entry->>'tags') IS NOT NULL AND (v_entry->>'tags') != '' THEN
v_tags := coalesce(
(SELECT array_agg(trim(x)) FROM unnest(string_to_array(v_entry->>'tags', ';')) AS x WHERE trim(x) != ''),
'{}'
);
ELSE
v_tags := '{}';
END IF;
-- Build imported_raw from any _metadata key (ignored CSV columns)
v_raw_meta := nullif(v_entry->>'_metadata', '')::JSONB;
BEGIN
IF v_email IS NOT NULL AND v_email != '' THEN
SELECT * INTO v_existing
FROM public.communication_contacts
WHERE brand_id = p_brand_id AND email = v_email;
IF FOUND THEN
-- Existing contact
IF v_existing.unsubscribed_at IS NOT NULL AND
coalesce((v_entry->>'email_opt_in')::BOOLEAN, false) = true AND
p_allow_opt_in_override = false THEN
v_result := jsonb_set(v_result, '{skipped}',
to_jsonb((v_result->>'skipped')::INTEGER + 1));
ELSE
UPDATE public.communication_contacts SET
phone = COALESCE(nullif(v_entry->>'phone', ''), phone),
first_name = COALESCE(nullif(v_entry->>'first_name', ''), first_name),
last_name = COALESCE(nullif(v_entry->>'last_name', ''), last_name),
full_name = COALESCE(nullif(v_entry->>'full_name', ''), full_name),
source = 'import',
external_id = COALESCE(nullif(v_entry->>'external_id', ''), external_id),
email_opt_in = CASE
WHEN communication_contacts.unsubscribed_at IS NOT NULL
THEN communication_contacts.email_opt_in
ELSE coalesce(
(v_entry->>'email_opt_in')::BOOLEAN,
communication_contacts.email_opt_in,
true
)
END,
sms_opt_in = CASE
WHEN communication_contacts.unsubscribed_at IS NOT NULL
THEN communication_contacts.sms_opt_in
ELSE coalesce(
(v_entry->>'sms_opt_in')::BOOLEAN,
communication_contacts.sms_opt_in,
false
)
END,
email_opt_in_at = CASE
WHEN communication_contacts.unsubscribed_at IS NOT NULL
THEN email_opt_in_at
WHEN coalesce(
(v_entry->>'email_opt_in')::BOOLEAN,
communication_contacts.email_opt_in,
true
) = true THEN now()
ELSE email_opt_in_at
END,
tags = CASE
WHEN v_tags != '{}' THEN v_tags
ELSE communication_contacts.tags
END,
metadata = communication_contacts.metadata ||
jsonb_build_object(
'imported_at', now()::TEXT,
'imported_raw', coalesce(v_raw_meta, '{}'::JSONB)
),
updated_at = now()
WHERE brand_id = p_brand_id AND email = v_email;
v_result := jsonb_set(v_result, '{updated}',
to_jsonb((v_result->>'updated')::INTEGER + 1));
END IF;
ELSE
-- Insert new
INSERT INTO public.communication_contacts
(brand_id, email, phone, first_name, last_name, full_name, source,
external_id, email_opt_in, sms_opt_in, email_opt_in_at, sms_opt_in_at,
tags, metadata)
VALUES (
p_brand_id,
v_email,
nullif(v_entry->>'phone', ''),
nullif(v_entry->>'first_name', ''),
nullif(v_entry->>'last_name', ''),
nullif(v_entry->>'full_name', ''),
'import',
nullif(v_entry->>'external_id', ''),
coalesce((v_entry->>'email_opt_in')::BOOLEAN, true),
coalesce((v_entry->>'sms_opt_in')::BOOLEAN, false),
CASE WHEN coalesce((v_entry->>'email_opt_in')::BOOLEAN, true) = true THEN now() ELSE NULL END,
CASE WHEN coalesce((v_entry->>'sms_opt_in')::BOOLEAN, false) = true THEN now() ELSE NULL END,
v_tags,
jsonb_build_object(
'imported_at', now()::TEXT,
'imported_raw', coalesce(v_raw_meta, '{}'::JSONB)
)
);
v_result := jsonb_set(v_result, '{created}',
to_jsonb((v_result->>'created')::INTEGER + 1));
END IF;
ELSIF v_phone IS NOT NULL AND v_phone != '' THEN
-- Upsert by phone
SELECT * INTO v_existing
FROM public.communication_contacts
WHERE brand_id = p_brand_id AND phone = v_phone;
IF FOUND THEN
IF v_existing.unsubscribed_at IS NOT NULL AND
coalesce((v_entry->>'email_opt_in')::BOOLEAN, false) = true AND
p_allow_opt_in_override = false THEN
v_result := jsonb_set(v_result, '{skipped}',
to_jsonb((v_result->>'skipped')::INTEGER + 1));
ELSE
UPDATE public.communication_contacts SET
email = COALESCE(nullif(v_entry->>'email', ''), email),
first_name = COALESCE(nullif(v_entry->>'first_name', ''), first_name),
last_name = COALESCE(nullif(v_entry->>'last_name', ''), last_name),
full_name = COALESCE(nullif(v_entry->>'full_name', ''), full_name),
source = 'import',
email_opt_in = CASE
WHEN communication_contacts.unsubscribed_at IS NOT NULL
THEN communication_contacts.email_opt_in
ELSE coalesce(
(v_entry->>'email_opt_in')::BOOLEAN,
communication_contacts.email_opt_in,
true
)
END,
tags = CASE WHEN v_tags != '{}' THEN v_tags ELSE communication_contacts.tags END,
metadata = communication_contacts.metadata ||
jsonb_build_object(
'imported_at', now()::TEXT,
'imported_raw', coalesce(v_raw_meta, '{}'::JSONB)
),
updated_at = now()
WHERE brand_id = p_brand_id AND phone = v_phone;
v_result := jsonb_set(v_result, '{updated}',
to_jsonb((v_result->>'updated')::INTEGER + 1));
END IF;
ELSE
INSERT INTO public.communication_contacts
(brand_id, email, phone, first_name, last_name, full_name, source,
email_opt_in, sms_opt_in, tags, metadata)
VALUES (
p_brand_id,
nullif(v_entry->>'email', ''),
v_phone,
nullif(v_entry->>'first_name', ''),
nullif(v_entry->>'last_name', ''),
nullif(v_entry->>'full_name', ''),
'import',
coalesce((v_entry->>'email_opt_in')::BOOLEAN, true),
coalesce((v_entry->>'sms_opt_in')::BOOLEAN, false),
v_tags,
jsonb_build_object(
'imported_at', now()::TEXT,
'imported_raw', coalesce(v_raw_meta, '{}'::JSONB)
)
);
v_result := jsonb_set(v_result, '{created}',
to_jsonb((v_result->>'created')::INTEGER + 1));
END IF;
ELSE
-- No email or phone
v_result := jsonb_set(
v_result, '{errors}',
v_result->'errors' || jsonb_build_array(
jsonb_build_object('row', v_entry, 'error', 'No email or phone provided')
)
);
END IF;
EXCEPTION WHEN OTHERS THEN
v_result := jsonb_set(
v_result, '{errors}',
v_result->'errors' || jsonb_build_array(
jsonb_build_object('row', v_entry, 'error', SQLERRM)
)
);
END;
END LOOP;
RETURN v_result;
END;
$$;
NOTIFY pgrst, 'reload schema';
@@ -1,75 +0,0 @@
-- =============================================================================
-- V1.2 Stage 1 — Canonical Customers Table
-- Fully idempotent: CREATE TABLE IF NOT EXISTS, CREATE INDEX IF NOT EXISTS
-- Purely additive: no changes to orders, communication_contacts, or checkout
--
-- STAGE 2 NOTE: When upserting customers in Stage 2, normalize BEFORE upsert:
-- - email: lowercase + trim
-- - phone: strip formatting chars [\s\-().[\]], preserve original if uncertain
-- This ensures consistent matching across orders, imports, and contacts.
-- =============================================================================
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- ── Table ────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.customers (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
primary_email TEXT,
primary_phone TEXT,
first_name TEXT,
last_name TEXT,
source TEXT NOT NULL DEFAULT 'system',
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT customers_email_or_phone CHECK (
primary_email IS NOT NULL OR primary_phone IS NOT NULL
)
);
-- ── Indexes ───────────────────────────────────────────────────────────────────
CREATE INDEX IF NOT EXISTS idx_customers_brand
ON public.customers(brand_id);
CREATE INDEX IF NOT EXISTS idx_customers_email
ON public.customers(primary_email) WHERE primary_email IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_customers_phone
ON public.customers(primary_phone) WHERE primary_phone IS NOT NULL;
-- Partial unique indexes: enforce one customer per brand per email/phone
-- PostgreSQL requires CREATE UNIQUE INDEX rather than CONSTRAINT for partial unique
CREATE UNIQUE INDEX IF NOT EXISTS idx_customers_brand_email_unique
ON public.customers(brand_id, primary_email)
WHERE primary_email IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_customers_brand_phone_unique
ON public.customers(brand_id, primary_phone)
WHERE primary_phone IS NOT NULL;
-- ── RLS ───────────────────────────────────────────────────────────────────────
ALTER TABLE public.customers ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "Brand admin can read customers"
ON public.customers;
CREATE POLICY "Brand admin can read customers"
ON public.customers FOR SELECT TO authenticated
USING (brand_id IN (
SELECT brand_id FROM admin_users
WHERE admin_users.user_id = auth.uid()
AND admin_users.role = 'brand_admin'
));
DROP POLICY IF EXISTS "Platform admin can read customers"
ON public.customers;
CREATE POLICY "Platform admin can read customers"
ON public.customers FOR SELECT TO authenticated
USING (EXISTS (
SELECT 1 FROM admin_users
WHERE admin_users.user_id = auth.uid()
AND admin_users.role = 'platform_admin'
));
NOTIFY pgrst, 'reload schema';
@@ -1,226 +0,0 @@
-- =============================================================================
-- V1.2 Stage 2 — Order/Contact/Customer Linking
-- Fully idempotent: CREATE OR REPLACE FUNCTION, no destructive operations
-- Purely additive behavior: no changes to orders schema, no FKs, no backfill
--
-- DEPENDENCY: Requires 019_customers_table.sql to be applied first.
-- Raises an exception if the customers table does not exist.
--
-- TRANSITIONAL ARCHITECTURE NOTE:
-- orders.customer_id and customers.id are separate ID namespaces today.
-- communication_contacts.customer_id links to customers.id for new order-created
-- contacts (via this migration), but orders.customer_id is NOT yet aligned.
--
-- A future stage (Stage 3+) should consider:
-- - backfilling customers from existing orders/communication_contacts
-- - aligning orders.customer_id to canonical customers.id
-- - adding proper FK constraints only after data is cleaned
-- - avoiding permanent dual customer ID namespaces
--
-- STAGE 2 NORMALIZATION:
-- email: trim(lower(...)) before match/upsert
-- phone: regexp_replace(... '[\s\-().[\]]' '' 'g') before match/upsert
-- This is consistent with frontend normalizeEmail/normalizePhone.
-- =============================================================================
-- ── Dependency guard ─────────────────────────────────────────────────────────
DO $$
BEGIN
IF to_regclass('public.customers') IS NULL THEN
RAISE EXCEPTION 'Migration 019_customers_table.sql must be applied before 020_order_customer_linking.sql';
END IF;
END;
$$;
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- ── Safe teardown order: trigger → functions ────────────────────────────────
-- PostgreSQL requires dropping a trigger before dropping the function it uses.
-- Order: DROP TRIGGER → DROP FUNCTION (helper) → DROP FUNCTION (trigger)
DROP TRIGGER IF EXISTS trg_create_contact_from_order ON public.orders;
DROP FUNCTION IF EXISTS public.upsert_customer_from_order(UUID, TEXT, TEXT, TEXT, TEXT);
DROP FUNCTION IF EXISTS public.upsert_contact_from_order();
-- ─────────────────────────────────────────────────────────────────────────────
-- upsert_customer_from_order
--
-- Resolves or creates a canonical customers record for a given order.
-- Returns the customers.id for linking, or NULL if no contact method exists.
--
-- Upsert order: email first → phone fallback (email is more stable identity)
-- Brand separation: brand_id is the scoping key throughout.
-- ─────────────────────────────────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION public.upsert_customer_from_order(
p_brand_id UUID,
p_customer_email TEXT,
p_customer_phone TEXT,
p_customer_name TEXT,
p_source TEXT DEFAULT 'order'
)
RETURNS UUID -- customers.id
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_norm_email TEXT := trim(lower(p_customer_email));
v_norm_phone TEXT := regexp_replace(p_customer_phone, '[\s\-().[\]]', '', 'g');
v_cust_id UUID;
BEGIN
-- No email AND no phone: nothing to upsert
IF (v_norm_email IS NULL OR v_norm_email = '') AND
(v_norm_phone IS NULL OR v_norm_phone = '') THEN
RETURN NULL;
END IF;
-- Try email match first (within brand)
IF v_norm_email IS NOT NULL AND v_norm_email != '' THEN
SELECT id INTO v_cust_id
FROM public.customers
WHERE brand_id = p_brand_id
AND primary_email = v_norm_email;
IF FOUND THEN
UPDATE public.customers SET
primary_phone = COALESCE(NULLIF(v_norm_phone, ''), primary_phone),
first_name = COALESCE(
NULLIF(split_part(p_customer_name, ' ', 1), ''),
customers.first_name
),
last_name = COALESCE(
NULLIF(substring(p_customer_name from position(' ' in p_customer_name) + 1
for length(p_customer_name)), ''),
customers.last_name
),
source = CASE
WHEN customers.source = 'system' THEN p_source ELSE customers.source
END,
updated_at = now()
WHERE id = v_cust_id;
RETURN v_cust_id;
END IF;
END IF;
-- Phone fallback (only if email didn't find a match)
IF v_cust_id IS NULL AND v_norm_phone IS NOT NULL AND v_norm_phone != '' THEN
SELECT id INTO v_cust_id
FROM public.customers
WHERE brand_id = p_brand_id
AND primary_phone = v_norm_phone;
IF FOUND THEN
UPDATE public.customers SET
primary_email = COALESCE(NULLIF(v_norm_email, ''), primary_email),
first_name = COALESCE(
NULLIF(split_part(p_customer_name, ' ', 1), ''),
customers.first_name
),
last_name = COALESCE(
NULLIF(substring(p_customer_name from position(' ' in p_customer_name) + 1
for length(p_customer_name)), ''),
customers.last_name
),
source = CASE
WHEN customers.source = 'system' THEN p_source ELSE customers.source
END,
updated_at = now()
WHERE id = v_cust_id;
RETURN v_cust_id;
END IF;
END IF;
-- Insert new customer
INSERT INTO public.customers
(brand_id, primary_email, primary_phone, first_name, last_name, source)
VALUES (
p_brand_id,
NULLIF(v_norm_email, ''),
NULLIF(v_norm_phone, ''),
NULLIF(split_part(p_customer_name, ' ', 1), ''),
NULLIF(substring(p_customer_name from position(' ' in p_customer_name) + 1
for length(p_customer_name)), ''),
p_source
)
RETURNING id INTO v_cust_id;
RETURN v_cust_id;
END;
$$;
-- ─────────────────────────────────────────────────────────────────────────────
-- upsert_contact_from_order trigger
--
-- NOW DOES TWO THINGS:
-- 1. Call upsert_customer_from_order() to get/create customers.id
-- 2. Use that customers.id as communication_contacts.customer_id (linking)
--
-- OPT-OUT PRESERVATION: email_opt_in, sms_opt_in, unsubscribed_at are NOT
-- updated on conflict — this behavior is unchanged from V1.1.
-- ─────────────────────────────────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION public.upsert_contact_from_order()
RETURNS TRIGGER
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_brand_id UUID;
v_customer_id UUID;
BEGIN
-- Resolve brand_id from the stop
SELECT brand_id INTO v_brand_id
FROM public.stops
WHERE id = NEW.stop_id;
IF NOT FOUND THEN
RETURN NEW;
END IF;
-- Step 1: upsert canonical customer (NEW.customer_id not referenced —
-- orders table does not have that column; use only email/phone/name)
v_customer_id := upsert_customer_from_order(
v_brand_id,
NEW.customer_email,
NEW.customer_phone,
NEW.customer_name,
'order'
);
-- Step 2: upsert communication contact, linked to customers.id
INSERT INTO public.communication_contacts
(brand_id, email, phone, full_name, source, customer_id, email_opt_in, metadata)
VALUES (
v_brand_id,
NEW.customer_email,
NEW.customer_phone,
NEW.customer_name,
'order',
v_customer_id,
true,
jsonb_build_object(
'last_order_id', NEW.id,
'last_order_at', now()::TEXT,
'stop_id', NEW.stop_id::TEXT
)
)
ON CONFLICT (brand_id, email) WHERE email IS NOT NULL DO UPDATE SET
full_name = COALESCE(EXCLUDED.full_name, communication_contacts.full_name),
phone = COALESCE(EXCLUDED.phone, communication_contacts.phone),
customer_id = COALESCE(EXCLUDED.customer_id, communication_contacts.customer_id),
metadata = jsonb_build_object(
'last_order_id', communication_contacts.metadata->>'last_order_id',
'last_order_at', communication_contacts.metadata->>'last_order_at',
'stop_id', NEW.stop_id::TEXT
),
updated_at = now();
-- email_opt_in, sms_opt_in, unsubscribed_at intentionally NOT updated (preserve opt-out)
RETURN NEW;
END;
$$;
-- ── Recreate trigger ─────────────────────────────────────────────────────────
CREATE TRIGGER trg_create_contact_from_order
AFTER INSERT ON public.orders
FOR EACH ROW
EXECUTE FUNCTION public.upsert_contact_from_order();
NOTIFY pgrst, 'reload schema';
@@ -1,319 +0,0 @@
-- =============================================================================
-- V1.2 Stage 3 — Shipping-Only Brand Resolution
-- Fully idempotent: CREATE OR REPLACE FUNCTION, ALTER TABLE ADD COLUMN IF NOT EXISTS
--
-- DEPENDENCY: Requires 020_order_customer_linking.sql to be applied first.
-- Raises an exception if the trigger function does not exist.
--
-- CHANGES:
-- 1. Add brand_id column to orders (nullable, no FK — aligned to products.brand_id)
-- 2. Modify create_order_with_items to accept NULL stop_id for shipping-only
-- 3. Update upsert_contact_from_order trigger to resolve brand from NEW.brand_id
-- first, falling back to stop lookup — supports both pickup and shipping orders
-- =============================================================================
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_proc p
JOIN pg_namespace n ON n.oid = p.pronamespace
WHERE n.nspname = 'public'
AND p.proname = 'upsert_contact_from_order'
) THEN
RAISE EXCEPTION 'Migration 020_order_customer_linking.sql must be applied before 021_shipping_only_brand.sql';
END IF;
END;
$$;
-- ── 1. Add brand_id to orders ────────────────────────────────────────────────
-- Allows the order/contact trigger to resolve brand without needing a stop_id.
-- For shipping-only orders, brand is derived from the first product.
ALTER TABLE orders ADD COLUMN IF NOT EXISTS brand_id UUID;
-- ── 2. Modify create_order_with_items for shipping-only ────────────────────
-- stop_id is now nullable. When NULL (shipping-only), brand is derived from
-- the first product's brand and no stop validation occurs.
CREATE OR REPLACE FUNCTION create_order_with_items(
p_idempotency_key UUID,
p_customer_name TEXT,
p_customer_email TEXT,
p_customer_phone TEXT,
p_stop_id UUID, -- nullable for shipping-only
p_items JSONB -- [{id: uuid, quantity: int, fulfillment: text}]
)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_order_id UUID;
v_item JSONB;
v_product_id UUID;
v_quantity INT;
v_fulfillment TEXT;
v_product_price NUMERIC;
v_computed_total NUMERIC := 0;
v_stop_active BOOLEAN;
v_stop_brand_id UUID;
v_stop_city TEXT;
v_stop_state TEXT;
v_stop_date TEXT;
v_stop_time TEXT;
v_stop_location TEXT;
v_product_active BOOLEAN;
v_product_brand_id UUID;
v_product_name TEXT;
v_is_pickup BOOLEAN;
v_order JSONB;
v_order_items JSONB := '[]'::JSONB;
v_brand_id UUID;
BEGIN
-- ── Idempotency guard ─────────────────────────────────────────────────────
SELECT jsonb_build_object(
'id', o.id,
'customer_name', o.customer_name,
'customer_email', o.customer_email,
'customer_phone', o.customer_phone,
'subtotal', o.subtotal,
'status', o.status,
'stop_id', o.stop_id,
'brand_id', o.brand_id,
'stop_city', s.city,
'stop_state', s.state,
'stop_date', s.date,
'stop_time', s.time,
'stop_location', s.location,
'items', COALESCE((
SELECT jsonb_agg(jsonb_build_object(
'product_id', oi.product_id,
'product_name', p.name,
'quantity', oi.quantity,
'price', oi.price,
'fulfillment', oi.fulfillment
))
FROM order_items oi
JOIN products p ON oi.product_id = p.id
WHERE oi.order_id = o.id
), '[]'::JSONB)
)
INTO v_order
FROM orders o
LEFT JOIN stops s ON o.stop_id = s.id
WHERE o.idempotency_key = p_idempotency_key
LIMIT 1;
IF v_order IS NOT NULL THEN
RETURN v_order;
END IF;
-- ── Resolve brand_id ──────────────────────────────────────────────────────
-- For shipping-only orders (p_stop_id IS NULL), derive from the first product.
-- For pickup orders, derive from the stop.
IF p_stop_id IS NOT NULL THEN
SELECT active, brand_id, city, state, date, time, location
INTO v_stop_active, v_stop_brand_id, v_stop_city, v_stop_state, v_stop_date, v_stop_time, v_stop_location
FROM stops
WHERE id = p_stop_id;
IF v_stop_brand_id IS NULL THEN
RAISE EXCEPTION 'Stop not found: %', p_stop_id;
END IF;
IF NOT v_stop_active THEN
RAISE EXCEPTION 'Stop is not active: %', p_stop_id;
END IF;
v_brand_id := v_stop_brand_id;
ELSE
-- Shipping-only: resolve brand from first product in the order
v_product_id := (jsonb_array_elements(p_items)->>'id')::UUID;
SELECT brand_id INTO v_brand_id
FROM products
WHERE id = v_product_id;
IF v_brand_id IS NULL THEN
RAISE EXCEPTION 'Product not found: %', v_product_id;
END IF;
END IF;
-- ── Validate items and compute subtotal ─────────────────────────────────
FOR v_item IN SELECT * FROM jsonb_array_elements(p_items)
LOOP
v_product_id := (v_item->>'id')::UUID;
v_quantity := (v_item->>'quantity')::INT;
v_fulfillment := v_item->>'fulfillment';
v_is_pickup := (v_fulfillment = 'pickup');
SELECT active, brand_id, price, name
INTO v_product_active, v_product_brand_id, v_product_price, v_product_name
FROM products
WHERE id = v_product_id;
IF v_product_brand_id IS NULL THEN
RAISE EXCEPTION 'Product not found: %', v_product_id;
END IF;
IF v_product_brand_id != v_brand_id THEN
RAISE EXCEPTION 'Product % does not belong to brand %', v_product_id, v_brand_id;
END IF;
IF NOT v_product_active THEN
RAISE EXCEPTION 'Product is not active: %', v_product_id;
END IF;
IF v_is_pickup THEN
-- Pickup items require a valid stop assignment
IF p_stop_id IS NULL THEN
RAISE EXCEPTION 'Pickup item % requires a stop selection', v_product_id;
END IF;
PERFORM 1
FROM product_stops
WHERE product_id = v_product_id AND stop_id = p_stop_id
LIMIT 1;
IF NOT FOUND THEN
RAISE EXCEPTION 'Pickup product % is not available at stop %', v_product_id, p_stop_id;
END IF;
END IF;
v_computed_total := v_computed_total + (v_product_price * v_quantity);
END LOOP;
-- ── Create order (with brand_id) ─────────────────────────────────────────
INSERT INTO orders (
idempotency_key, customer_name, customer_email, customer_phone,
stop_id, brand_id, subtotal, status
) VALUES (
p_idempotency_key, p_customer_name, p_customer_email, p_customer_phone,
p_stop_id, v_brand_id, v_computed_total, 'pending'
)
RETURNING id INTO v_order_id;
-- ── Insert order items and build return value ─────────────────────────────
FOR v_item IN SELECT * FROM jsonb_array_elements(p_items)
LOOP
v_product_id := (v_item->>'id')::UUID;
v_quantity := (v_item->>'quantity')::INT;
v_fulfillment := v_item->>'fulfillment';
SELECT price, name INTO v_product_price, v_product_name FROM products WHERE id = v_product_id;
INSERT INTO order_items (order_id, product_id, quantity, fulfillment, price)
VALUES (v_order_id, v_product_id, v_quantity, v_fulfillment, v_product_price);
v_order_items := v_order_items || jsonb_build_object(
'product_id', v_product_id,
'product_name', v_product_name,
'quantity', v_quantity,
'price', v_product_price,
'fulfillment', v_fulfillment
);
END LOOP;
-- ── Build and return full order object ───────────────────────────────────
RETURN jsonb_build_object(
'id', v_order_id,
'customer_name', p_customer_name,
'customer_email', p_customer_email,
'customer_phone', p_customer_phone,
'subtotal', v_computed_total,
'status', 'pending',
'stop_id', p_stop_id,
'brand_id', v_brand_id,
'stop_city', v_stop_city,
'stop_state', v_stop_state,
'stop_date', v_stop_date,
'stop_time', v_stop_time,
'stop_location', v_stop_location,
'items', v_order_items
);
END;
$$;
-- ── 3. Update trigger to resolve brand from NEW.brand_id first ───────────────
-- NEW.brand_id is the primary source (always set by create_order_with_items).
-- Stop lookup is the fallback (for pre-existing orders that lack brand_id).
DROP TRIGGER IF EXISTS trg_create_contact_from_order ON public.orders;
DROP FUNCTION IF EXISTS public.upsert_contact_from_order();
CREATE OR REPLACE FUNCTION public.upsert_contact_from_order()
RETURNS TRIGGER
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_brand_id UUID;
v_customer_id UUID;
BEGIN
-- Primary: resolve brand_id from the order itself (NEW.brand_id, always set in Stage 3+)
IF NEW.brand_id IS NOT NULL THEN
v_brand_id := NEW.brand_id;
ELSE
-- Fallback: resolve via stop (for pre-Stage 3 orders without brand_id)
SELECT brand_id INTO v_brand_id
FROM public.stops
WHERE id = NEW.stop_id;
IF NOT FOUND THEN
RETURN NEW; -- cannot resolve brand: skip customer/contact linking
END IF;
END IF;
-- No email AND no phone: nothing to upsert
IF (NEW.customer_email IS NULL OR NEW.customer_email = '') AND
(NEW.customer_phone IS NULL OR NEW.customer_phone = '') THEN
RETURN NEW;
END IF;
-- Step 1: upsert canonical customer
v_customer_id := upsert_customer_from_order(
v_brand_id,
NEW.customer_email,
NEW.customer_phone,
NEW.customer_name,
'order'
);
-- Step 2: upsert communication contact, linked to customers.id
INSERT INTO public.communication_contacts
(brand_id, email, phone, full_name, source, customer_id, email_opt_in, metadata)
VALUES (
v_brand_id,
NEW.customer_email,
NEW.customer_phone,
NEW.customer_name,
'order',
v_customer_id,
true,
jsonb_build_object(
'last_order_id', NEW.id,
'last_order_at', now()::TEXT,
'stop_id', NEW.stop_id::TEXT
)
)
ON CONFLICT (brand_id, email) WHERE email IS NOT NULL DO UPDATE SET
full_name = COALESCE(EXCLUDED.full_name, communication_contacts.full_name),
phone = COALESCE(EXCLUDED.phone, communication_contacts.phone),
customer_id = COALESCE(EXCLUDED.customer_id, communication_contacts.customer_id),
metadata = jsonb_build_object(
'last_order_id', communication_contacts.metadata->>'last_order_id',
'last_order_at', communication_contacts.metadata->>'last_order_at',
'stop_id', NEW.stop_id::TEXT
),
updated_at = now();
-- email_opt_in, sms_opt_in, unsubscribed_at intentionally NOT updated (preserve opt-out)
RETURN NEW;
END;
$$;
CREATE TRIGGER trg_create_contact_from_order
AFTER INSERT ON public.orders
FOR EACH ROW
EXECUTE FUNCTION public.upsert_contact_from_order();
NOTIFY pgrst, 'reload schema';
@@ -1,646 +0,0 @@
-- ─────────────────────────────────────────────────────────────────────────────
-- Migration 022: Operational Events
-- ─────────────────────────────────────────────────────────────────────────────
-- Append-only event layer for recording important platform actions.
-- Scope: table + indexes + RLS + helpers + 4 initial emitters.
-- What NOT included: automation, queues, webhooks, retries, cron jobs.
-- ═══════════════════════════════════════════════════════════════════════════
-- 1. operational_events table
-- ═══════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS public.operational_events (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
brand_id UUID NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
event_type TEXT NOT NULL,
entity_type TEXT,
entity_id UUID,
actor_type TEXT,
actor_id UUID,
source TEXT NOT NULL DEFAULT 'system',
payload JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT operational_events_event_type CHECK (
event_type ~ '^[a-z][a-z0-9_]*$'
)
);
-- ═══════════════════════════════════════════════════════════════════════════
-- 2. Indexes
-- ═══════════════════════════════════════════════════════════════════════════
CREATE INDEX IF NOT EXISTS idx_oe_brand_id ON operational_events(brand_id);
CREATE INDEX IF NOT EXISTS idx_oe_event_type ON operational_events(event_type);
CREATE INDEX IF NOT EXISTS idx_oe_entity ON operational_events(entity_type, entity_id);
CREATE INDEX IF NOT EXISTS idx_oe_created_at ON operational_events(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_oe_brand_type ON operational_events(brand_id, event_type, created_at DESC);
-- ═══════════════════════════════════════════════════════════════════════════
-- 3. RLS policies
-- ═══════════════════════════════════════════════════════════════════════════
ALTER TABLE operational_events ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "Brand admin can read operational_events" ON operational_events;
CREATE POLICY "Brand admin can read operational_events"
ON operational_events FOR SELECT TO authenticated
USING (
brand_id IN (
SELECT brand_id FROM admin_users
WHERE admin_users.user_id = auth.uid()
AND admin_users.role = 'brand_admin'
)
);
DROP POLICY IF EXISTS "Platform admin can read operational_events" ON operational_events;
CREATE POLICY "Platform admin can read operational_events"
ON operational_events FOR SELECT TO authenticated
USING (
EXISTS (
SELECT 1 FROM admin_users
WHERE admin_users.user_id = auth.uid()
AND admin_users.role = 'platform_admin'
)
);
-- Writes go through SECURITY DEFINER helpers only.
-- No INSERT granted to authenticated roles.
-- ═══════════════════════════════════════════════════════════════════════════
-- 4. record_operational_event helper — bypasses RLS, all emitters call this
-- ═══════════════════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.record_operational_event(
p_brand_id UUID,
p_event_type TEXT,
p_entity_type TEXT,
p_entity_id UUID,
p_actor_type TEXT,
p_actor_id UUID,
p_source TEXT,
p_payload JSONB
)
RETURNS void
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
INSERT INTO operational_events (
brand_id, event_type, entity_type, entity_id,
actor_type, actor_id, source, payload
) VALUES (
p_brand_id, p_event_type, p_entity_type, p_entity_id,
p_actor_type, p_actor_id, p_source, p_payload
);
END;
$$;
-- ═══════════════════════════════════════════════════════════════════════════
-- 5. record_pickup_completed_event helper — called from TypeScript action
-- ═══════════════════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.record_pickup_completed_event(
p_order_id UUID,
p_brand_id UUID,
p_actor_id UUID
)
RETURNS void
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_order_data JSONB;
BEGIN
SELECT jsonb_build_object(
'subtotal', subtotal,
'customer_name', customer_name
) INTO v_order_data
FROM orders
WHERE id = p_order_id;
PERFORM record_operational_event(
p_brand_id,
'pickup_completed',
'order',
p_order_id,
'admin',
p_actor_id,
'system',
coalesce(v_order_data, '{}'::JSONB)
);
END;
$$;
-- ═══════════════════════════════════════════════════════════════════════════
-- 6. create_order_with_items — with order_placed emitter
-- (explicit replace; same signature as migration 021)
-- ═══════════════════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION create_order_with_items(
p_idempotency_key UUID,
p_customer_name TEXT,
p_customer_email TEXT,
p_customer_phone TEXT,
p_stop_id UUID,
p_items JSONB
)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_order_id UUID;
v_item JSONB;
v_product_id UUID;
v_quantity INT;
v_fulfillment TEXT;
v_product_price NUMERIC;
v_computed_total NUMERIC := 0;
v_stop_active BOOLEAN;
v_stop_brand_id UUID;
v_stop_city TEXT;
v_stop_state TEXT;
v_stop_date TEXT;
v_stop_time TEXT;
v_stop_location TEXT;
v_product_active BOOLEAN;
v_product_brand_id UUID;
v_product_name TEXT;
v_is_pickup BOOLEAN;
v_has_pickup BOOLEAN := false;
v_order JSONB;
v_order_items JSONB := '[]'::JSONB;
v_brand_id UUID;
BEGIN
-- ── Idempotency guard ─────────────────────────────────────────────────────
SELECT jsonb_build_object(
'id', o.id,
'customer_name', o.customer_name,
'customer_email', o.customer_email,
'customer_phone', o.customer_phone,
'subtotal', o.subtotal,
'status', o.status,
'stop_id', o.stop_id,
'brand_id', o.brand_id,
'stop_city', s.city,
'stop_state', s.state,
'stop_date', s.date,
'stop_time', s.time,
'stop_location', s.location,
'items', COALESCE((
SELECT jsonb_agg(jsonb_build_object(
'product_id', oi.product_id,
'product_name', p.name,
'quantity', oi.quantity,
'price', oi.price,
'fulfillment', oi.fulfillment
))
FROM order_items oi
JOIN products p ON oi.product_id = p.id
WHERE oi.order_id = o.id
), '[]'::JSONB)
)
INTO v_order
FROM orders o
LEFT JOIN stops s ON o.stop_id = s.id
WHERE o.idempotency_key = p_idempotency_key
LIMIT 1;
IF v_order IS NOT NULL THEN
RETURN v_order;
END IF;
-- ── Resolve brand_id ──────────────────────────────────────────────────────
IF p_stop_id IS NOT NULL THEN
SELECT active, brand_id, city, state, date, time, location
INTO v_stop_active, v_stop_brand_id, v_stop_city, v_stop_state, v_stop_date, v_stop_time, v_stop_location
FROM stops
WHERE id = p_stop_id;
IF v_stop_brand_id IS NULL THEN
RAISE EXCEPTION 'Stop not found: %', p_stop_id;
END IF;
IF NOT v_stop_active THEN
RAISE EXCEPTION 'Stop is not active: %', p_stop_id;
END IF;
v_brand_id := v_stop_brand_id;
ELSE
v_product_id := (jsonb_array_elements(p_items)->>'id')::UUID;
SELECT brand_id INTO v_brand_id
FROM products
WHERE id = v_product_id;
IF v_brand_id IS NULL THEN
RAISE EXCEPTION 'Product not found: %', v_product_id;
END IF;
END IF;
-- ── Validate items and compute subtotal ─────────────────────────────────
FOR v_item IN SELECT * FROM jsonb_array_elements(p_items)
LOOP
v_product_id := (v_item->>'id')::UUID;
v_quantity := (v_item->>'quantity')::INT;
v_fulfillment := v_item->>'fulfillment';
v_is_pickup := (v_fulfillment = 'pickup');
IF v_is_pickup THEN
v_has_pickup := true;
END IF;
SELECT active, brand_id, price, name
INTO v_product_active, v_product_brand_id, v_product_price, v_product_name
FROM products
WHERE id = v_product_id;
IF v_product_brand_id IS NULL THEN
RAISE EXCEPTION 'Product not found: %', v_product_id;
END IF;
IF v_product_brand_id != v_brand_id THEN
RAISE EXCEPTION 'Product % does not belong to brand %', v_product_id, v_brand_id;
END IF;
IF NOT v_product_active THEN
RAISE EXCEPTION 'Product is not active: %', v_product_id;
END IF;
IF v_is_pickup THEN
IF p_stop_id IS NULL THEN
RAISE EXCEPTION 'Pickup item % requires a stop selection', v_product_id;
END IF;
PERFORM 1
FROM product_stops
WHERE product_id = v_product_id AND stop_id = p_stop_id
LIMIT 1;
IF NOT FOUND THEN
RAISE EXCEPTION 'Pickup product % is not available at stop %', v_product_id, p_stop_id;
END IF;
END IF;
v_computed_total := v_computed_total + (v_product_price * v_quantity);
END LOOP;
-- ── Create order (with brand_id) ─────────────────────────────────────────
INSERT INTO orders (
idempotency_key, customer_name, customer_email, customer_phone,
stop_id, brand_id, subtotal, status
) VALUES (
p_idempotency_key, p_customer_name, p_customer_email, p_customer_phone,
p_stop_id, v_brand_id, v_computed_total, 'pending'
)
RETURNING id INTO v_order_id;
-- ── Insert order items and build return value ─────────────────────────────
FOR v_item IN SELECT * FROM jsonb_array_elements(p_items)
LOOP
v_product_id := (v_item->>'id')::UUID;
v_quantity := (v_item->>'quantity')::INT;
v_fulfillment := v_item->>'fulfillment';
SELECT price, name INTO v_product_price, v_product_name FROM products WHERE id = v_product_id;
INSERT INTO order_items (order_id, product_id, quantity, fulfillment, price)
VALUES (v_order_id, v_product_id, v_quantity, v_fulfillment, v_product_price);
v_order_items := v_order_items || jsonb_build_object(
'product_id', v_product_id,
'product_name', v_product_name,
'quantity', v_quantity,
'price', v_product_price,
'fulfillment', v_fulfillment
);
END LOOP;
-- ── Emit order_placed event ───────────────────────────────────────────────
PERFORM record_operational_event(
v_brand_id,
'order_placed',
'order',
v_order_id,
'customer',
NULL,
'system',
jsonb_build_object(
'subtotal', v_computed_total,
'item_count', jsonb_array_length(p_items),
'has_pickup', v_has_pickup
)
);
-- ── Build and return full order object ───────────────────────────────────
RETURN jsonb_build_object(
'id', v_order_id,
'customer_name', p_customer_name,
'customer_email', p_customer_email,
'customer_phone', p_customer_phone,
'subtotal', v_computed_total,
'status', 'pending',
'stop_id', p_stop_id,
'brand_id', v_brand_id,
'stop_city', v_stop_city,
'stop_state', v_stop_state,
'stop_date', v_stop_date,
'stop_time', v_stop_time,
'stop_location', v_stop_location,
'items', v_order_items
);
END;
$$;
-- ═══════════════════════════════════════════════════════════════════════════
-- 7. import_communication_contacts_batch — with contact_imported emitter
-- (explicit replace; same signature as migrations 017/018)
-- ═══════════════════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.import_communication_contacts_batch(
p_brand_id UUID,
p_contacts JSONB,
p_allow_opt_in_override BOOLEAN DEFAULT false
)
RETURNS JSONB
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_entry JSONB;
v_result JSONB := '{"created": 0, "updated": 0, "skipped": 0, "errors": []}'::JSONB;
v_existing communication_contacts%ROWTYPE;
v_email TEXT;
v_phone TEXT;
v_tags TEXT[];
BEGIN
FOR v_entry IN SELECT * FROM jsonb_array_elements(p_contacts)
LOOP
v_email := nullif(v_entry->>'email', '');
v_phone := nullif(v_entry->>'phone', '');
IF (v_entry->>'tags') IS NOT NULL AND (v_entry->>'tags') != '' THEN
v_tags := coalesce(
(SELECT array_agg(trim(x)) FROM unnest(string_to_array(v_entry->>'tags', ';')) AS x WHERE trim(x) != ''),
'{}'
);
ELSE
v_tags := '{}';
END IF;
BEGIN
IF v_email IS NOT NULL AND v_email != '' THEN
SELECT * INTO v_existing
FROM public.communication_contacts
WHERE brand_id = p_brand_id AND email = v_email;
IF FOUND THEN
IF v_existing.unsubscribed_at IS NOT NULL AND
coalesce((v_entry->>'email_opt_in')::BOOLEAN, false) = true AND
p_allow_opt_in_override = false THEN
v_result := jsonb_set(v_result, '{skipped}',
to_jsonb((v_result->>'skipped')::INTEGER + 1));
ELSE
UPDATE public.communication_contacts SET
phone = COALESCE(nullif(v_entry->>'phone', ''), phone),
first_name = COALESCE(nullif(v_entry->>'first_name', ''), first_name),
last_name = COALESCE(nullif(v_entry->>'last_name', ''), last_name),
full_name = COALESCE(nullif(v_entry->>'full_name', ''), full_name),
source = 'import',
external_id = COALESCE(nullif(v_entry->>'external_id', ''), external_id),
email_opt_in = CASE
WHEN communication_contacts.unsubscribed_at IS NOT NULL
THEN communication_contacts.email_opt_in
ELSE coalesce(
(v_entry->>'email_opt_in')::BOOLEAN,
communication_contacts.email_opt_in,
true
)
END,
sms_opt_in = CASE
WHEN communication_contacts.unsubscribed_at IS NOT NULL
THEN communication_contacts.sms_opt_in
ELSE coalesce(
(v_entry->>'sms_opt_in')::BOOLEAN,
communication_contacts.sms_opt_in,
false
)
END,
email_opt_in_at = CASE
WHEN communication_contacts.unsubscribed_at IS NOT NULL
THEN email_opt_in_at
WHEN coalesce(
(v_entry->>'email_opt_in')::BOOLEAN,
communication_contacts.email_opt_in,
true
) = true THEN now()
ELSE email_opt_in_at
END,
tags = CASE
WHEN v_tags != '{}' THEN v_tags
ELSE communication_contacts.tags
END,
metadata = communication_contacts.metadata || jsonb_build_object(
'imported_at', now()::TEXT
),
updated_at = now()
WHERE brand_id = p_brand_id AND email = v_email;
v_result := jsonb_set(v_result, '{updated}',
to_jsonb((v_result->>'updated')::INTEGER + 1));
END IF;
ELSE
INSERT INTO public.communication_contacts
(brand_id, email, phone, first_name, last_name, full_name, source,
external_id, email_opt_in, sms_opt_in, email_opt_in_at, sms_opt_in_at,
tags, metadata)
VALUES (
p_brand_id,
v_email,
nullif(v_entry->>'phone', ''),
nullif(v_entry->>'first_name', ''),
nullif(v_entry->>'last_name', ''),
nullif(v_entry->>'full_name', ''),
'import',
nullif(v_entry->>'external_id', ''),
coalesce((v_entry->>'email_opt_in')::BOOLEAN, true),
coalesce((v_entry->>'sms_opt_in')::BOOLEAN, false),
CASE WHEN coalesce((v_entry->>'email_opt_in')::BOOLEAN, true) = true THEN now() ELSE NULL END,
CASE WHEN coalesce((v_entry->>'sms_opt_in')::BOOLEAN, false) = true THEN now() ELSE NULL END,
v_tags,
jsonb_build_object('imported_at', now()::TEXT)
);
v_result := jsonb_set(v_result, '{created}',
to_jsonb((v_result->>'created')::INTEGER + 1));
END IF;
ELSIF v_phone IS NOT NULL AND v_phone != '' THEN
SELECT * INTO v_existing
FROM public.communication_contacts
WHERE brand_id = p_brand_id AND phone = v_phone;
IF FOUND THEN
IF v_existing.unsubscribed_at IS NOT NULL AND
coalesce((v_entry->>'email_opt_in')::BOOLEAN, false) = true AND
p_allow_opt_in_override = false THEN
v_result := jsonb_set(v_result, '{skipped}',
to_jsonb((v_result->>'skipped')::INTEGER + 1));
ELSE
UPDATE public.communication_contacts SET
email = COALESCE(nullif(v_entry->>'email', ''), email),
first_name = COALESCE(nullif(v_entry->>'first_name', ''), first_name),
last_name = COALESCE(nullif(v_entry->>'last_name', ''), last_name),
full_name = COALESCE(nullif(v_entry->>'full_name', ''), full_name),
source = 'import',
email_opt_in = CASE
WHEN communication_contacts.unsubscribed_at IS NOT NULL
THEN communication_contacts.email_opt_in
ELSE coalesce(
(v_entry->>'email_opt_in')::BOOLEAN,
communication_contacts.email_opt_in,
true
)
END,
tags = CASE WHEN v_tags != '{}' THEN v_tags ELSE communication_contacts.tags END,
metadata = communication_contacts.metadata || jsonb_build_object(
'imported_at', now()::TEXT
),
updated_at = now()
WHERE brand_id = p_brand_id AND phone = v_phone;
v_result := jsonb_set(v_result, '{updated}',
to_jsonb((v_result->>'updated')::INTEGER + 1));
END IF;
ELSE
INSERT INTO public.communication_contacts
(brand_id, email, phone, first_name, last_name, full_name, source,
email_opt_in, sms_opt_in, tags, metadata)
VALUES (
p_brand_id,
nullif(v_entry->>'email', ''),
v_phone,
nullif(v_entry->>'first_name', ''),
nullif(v_entry->>'last_name', ''),
nullif(v_entry->>'full_name', ''),
'import',
coalesce((v_entry->>'email_opt_in')::BOOLEAN, true),
coalesce((v_entry->>'sms_opt_in')::BOOLEAN, false),
v_tags,
jsonb_build_object('imported_at', now()::TEXT)
);
v_result := jsonb_set(v_result, '{created}',
to_jsonb((v_result->>'created')::INTEGER + 1));
END IF;
ELSE
v_result := jsonb_set(
v_result, '{errors}',
v_result->'errors' || jsonb_build_array(
jsonb_build_object('row', v_entry, 'error', 'No email or phone provided')
)
);
END IF;
EXCEPTION WHEN OTHERS THEN
v_result := jsonb_set(
v_result, '{errors}',
v_result->'errors' || jsonb_build_array(
jsonb_build_object('row', v_entry, 'error', SQLERRM)
)
);
END;
END LOOP;
-- ── Emit contact_imported event ──────────────────────────────────────────
PERFORM record_operational_event(
p_brand_id,
'contact_imported',
NULL,
NULL,
'admin',
NULL,
'system',
jsonb_build_object(
'created', (v_result->>'created')::INTEGER,
'updated', (v_result->>'updated')::INTEGER,
'skipped', (v_result->>'skipped')::INTEGER,
'total',
(v_result->>'created')::INTEGER
+ (v_result->>'updated')::INTEGER
+ (v_result->>'skipped')::INTEGER
)
);
RETURN v_result;
END;
$$;
-- ═══════════════════════════════════════════════════════════════════════════
-- 8. send_campaign — with campaign_sent emitter
-- (explicit replace; same body as migration 017)
-- ═══════════════════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.send_campaign(p_campaign_id UUID)
RETURNS jsonb
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_campaign communication_campaigns;
v_settings communication_settings;
v_audience JSONB;
v_entry JSONB;
v_entries JSONB := '[]'::JSONB;
v_count INTEGER := 0;
BEGIN
SELECT * INTO v_campaign FROM communication_campaigns WHERE id = p_campaign_id;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'Campaign not found');
END IF;
SELECT * INTO v_settings FROM communication_settings WHERE brand_id = v_campaign.brand_id;
IF NOT FOUND THEN
RETURN jsonb_build_object('success', false, 'error', 'No communication settings for brand');
END IF;
v_audience := preview_campaign_audience(v_campaign.brand_id, v_campaign.audience_rules);
FOR v_entry IN SELECT * FROM jsonb_array_elements(coalesce(v_audience->'sample_customers', '[]'::jsonb))
LOOP
CONTINUE WHEN (v_entry->>'email') IS NULL OR (v_entry->>'email') = '';
v_entries := v_entries || jsonb_build_array(jsonb_build_object(
'brand_id', v_campaign.brand_id,
'campaign_id', p_campaign_id,
'customer_id', nullif(v_entry->>'id', ''),
'customer_email', v_entry->>'email',
'delivery_method','email',
'subject', v_campaign.subject,
'body_preview', left(v_campaign.body_text, 500),
'status', 'queued'
));
v_count := v_count + 1;
END LOOP;
PERFORM log_communication_messages(v_entries);
UPDATE communication_campaigns
SET status = 'sent', sent_at = now(), updated_at = now()
WHERE id = p_campaign_id;
-- ── Emit campaign_sent event ────────────────────────────────────────────
PERFORM record_operational_event(
v_campaign.brand_id,
'campaign_sent',
'campaign',
p_campaign_id,
'system',
NULL,
'system',
jsonb_build_object(
'messages_logged', v_count,
'subject', v_campaign.subject
)
);
RETURN jsonb_build_object('success', true, 'messages_logged', v_count);
END;
$$;

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