Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 95eab42f4b | |||
| b018f2be5b | |||
| 49b8e27219 | |||
| 68a749f7af | |||
| d69b892117 | |||
| bd1690037d | |||
| fcdff8bce5 | |||
| a706746250 | |||
| a2285baeb4 | |||
| 2cf811a66b | |||
| 2daa8fd4b6 | |||
| 9f3dc9b68e | |||
| 6452363989 | |||
| 09f18de652 | |||
| 81ab512a5b |
@@ -12,13 +12,45 @@ There is exactly one remote — `origin` — pointing to the self-hosted Gitea r
|
|||||||
|
|
||||||
Do **not** add GitHub remotes. There is no `origin` on github.com and no separate "dev" repo. If you see `github.com/dzinesco/*` URLs in `.git/config`, that is stale configuration from a previous fork and should be removed (`git remote remove`).
|
Do **not** add GitHub remotes. There is no `origin` on github.com and no separate "dev" repo. If you see `github.com/dzinesco/*` URLs in `.git/config`, that is stale configuration from a previous fork and should be removed (`git remote remove`).
|
||||||
|
|
||||||
|
### Gitea authentication (SSH)
|
||||||
|
|
||||||
|
This workspace has been using Gitea via SSH for all operations. The Gitea instance is self-hosted at `git.crispygoat.com` (web UI: `https://git.crispygoat.com`, API base: `https://git.crispygoat.com/api/v1`).
|
||||||
|
|
||||||
|
**SSH key:** `~/.ssh/id_ed25519_crispygoat` is the dedicated key registered with the Gitea instance (alias `dog` per the Gitea SSH banner). Add it to the agent before any git operation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
eval "$(ssh-agent -s)"
|
||||||
|
ssh-add ~/.ssh/id_ed25519_crispygoat
|
||||||
|
```
|
||||||
|
|
||||||
|
The SSH config (`~/.ssh/config`) already aliases the host as `crispygoat` with `AddKeysToAgent yes`, so subsequent shells can just `ssh crispygoat` / `git push origin ...` without re-adding.
|
||||||
|
|
||||||
|
**Verified working commands:**
|
||||||
|
- `git push origin <branch>` — push (this is the primary workflow)
|
||||||
|
- `ssh -p 22 git@git.crispygoat.com` — auth check; Gitea returns "successfully authenticated" + "Gitea does not provide shell access"
|
||||||
|
|
||||||
|
**API token:** This environment does **not** have a Gitea personal access token in any known location (no `~/.config/gitea`, no `GITEA_ACCESS_TOKEN` env, no token in `~/.config/gh/hosts.yml`). Do not assume a token is available — auth attempts will return `{"message":"invalid username, password or token"}`.
|
||||||
|
|
||||||
|
**`tea` CLI / `gitea-mcp`:** Both are installed but require a token; `--ssh-agent-key` is recognized by `tea logins add` but the underlying SDK still falls back to requiring a token. Treat these as unavailable until a token is provisioned.
|
||||||
|
|
||||||
|
### Opening pull requests (the workflow that actually works)
|
||||||
|
|
||||||
|
Because no Gitea API token is available in this environment, the PR creation flow is:
|
||||||
|
|
||||||
|
1. Create a feature branch: `git checkout -b docs/<date>-<description>`
|
||||||
|
2. Commit the changes (no need to commit unrelated files — leave the worktree dirty but `git add` only what you intend to ship)
|
||||||
|
3. Push: `git push -u origin <branch>`
|
||||||
|
4. The Gitea push hook prints a "Create a new pull request" URL on stderr, e.g. `https://git.crispygoat.com/tyler/route-commerce/pulls/new/<branch>` — open that URL in a browser to file the PR through the web UI.
|
||||||
|
|
||||||
|
If a future environment provides a Gitea token, the `tea pr create` and `curl POST /api/v1/repos/tyler/route-commerce/pulls` flows become available; the auth pattern is `Authorization: token <TOKEN>` on the REST API or `tea logins add -t <TOKEN> -u https://git.crispygoat.com` for the CLI.
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
Route Commerce is a multi-tenant B2B e-commerce platform for fresh produce wholesale distribution. Brands sell to customers who pick up at scheduled stops or receive shipments. The platform includes admin dashboards for order management, stop/route scheduling, product catalogs, payment processing (Stripe + Square), and a communications module ("Harvest Reach") for email/SMS campaigns.
|
Route Commerce is a multi-tenant B2B e-commerce platform for fresh produce wholesale distribution. Brands sell to customers who pick up at scheduled stops or receive shipments. The platform includes admin dashboards for order management, stop/route scheduling, product catalogs, payment processing (Stripe + Square), and a communications module ("Harvest Reach") for email/SMS campaigns.
|
||||||
|
|
||||||
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 +65,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 +83,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 +125,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 +287,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 +314,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 +332,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
@@ -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
@@ -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`)*
|
||||||
@@ -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; documented the SSH-based Gitea workflow)
|
||||||
|
|
||||||
|
> **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). Repo operations use **SSH** (`~/.ssh/id_ed25519_crispygoat`) — no Gitea API token is provisioned in this env, so `tea`/`gitea-mcp`/REST API are unavailable for write operations; PRs are opened by `git push` + clicking the URL the Gitea push hook prints.
|
||||||
|
|
||||||
## 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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 001–092 series was collapsed into `0001_init.sql` plus a handful of incremental files). If `db/migrations/` is empty after the Supabase → Postgres migration, see `MEMORY.md` "Direction Pivot" for the cutover notes.
|
||||||
|
|
||||||
|
**Current migrations in `db/migrations/`:**
|
||||||
|
|
||||||
| # | File | Purpose |
|
| # | 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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
+12
-49
@@ -23,38 +23,22 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import "server-only";
|
import "server-only";
|
||||||
import { Pool, type PoolClient } from "pg";
|
|
||||||
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
|
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
|
||||||
import * as schema from "./schema";
|
import * as schema from "./schema";
|
||||||
|
import { getPool, withTx } from "@/lib/db";
|
||||||
|
|
||||||
type Schema = typeof schema;
|
type Schema = typeof schema;
|
||||||
export type Db = NodePgDatabase<Schema>;
|
export type Db = NodePgDatabase<Schema>;
|
||||||
|
|
||||||
let _pool: Pool | null = null;
|
/**
|
||||||
|
* The Drizzle layer reuses the shared `pg` `Pool` from `@/lib/db` so the
|
||||||
function getPool(): Pool {
|
* app connects to Postgres through a single pool. Connection settings
|
||||||
if (_pool) return _pool;
|
* (`PG_POOL_MAX`, `PG_POOL_IDLE_MS`, `PG_POOL_CONN_TIMEOUT_MS`,
|
||||||
const connectionString = process.env.DATABASE_URL;
|
* `DATABASE_URL`) live in one place. See `src/lib/db.ts` for the config.
|
||||||
if (!connectionString) {
|
*
|
||||||
throw new Error(
|
* The `withBrand` and `withPlatformAdmin` helpers reuse `withTx` from
|
||||||
"DATABASE_URL is not set. Add it to .env.local (see .env.example).",
|
* `@/lib/db` so the BEGIN/COMMIT/ROLLBACK handling has a single home.
|
||||||
);
|
*/
|
||||||
}
|
|
||||||
_pool = new Pool({
|
|
||||||
connectionString,
|
|
||||||
max: parseInt(process.env.PG_POOL_MAX ?? "10", 10),
|
|
||||||
idleTimeoutMillis: parseInt(process.env.PG_POOL_IDLE_MS ?? "30000", 10),
|
|
||||||
connectionTimeoutMillis: parseInt(
|
|
||||||
process.env.PG_POOL_CONN_TIMEOUT_MS ?? "10000",
|
|
||||||
10,
|
|
||||||
),
|
|
||||||
allowExitOnIdle: false,
|
|
||||||
});
|
|
||||||
_pool.on("error", (err) => {
|
|
||||||
console.error("[db] idle client error", err);
|
|
||||||
});
|
|
||||||
return _pool;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run `fn` with a Drizzle client. No brand context is set — the caller
|
* Run `fn` with a Drizzle client. No brand context is set — the caller
|
||||||
@@ -83,7 +67,7 @@ export async function withBrand<T>(
|
|||||||
brandId: string,
|
brandId: string,
|
||||||
fn: (db: Db) => Promise<T>,
|
fn: (db: Db) => Promise<T>,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
return runInTransaction(async (client) => {
|
return withTx(async (client) => {
|
||||||
// set_config(setting, value, is_local) — is_local=true makes it
|
// set_config(setting, value, is_local) — is_local=true makes it
|
||||||
// transaction-local so it auto-resets at COMMIT/ROLLBACK and never
|
// transaction-local so it auto-resets at COMMIT/ROLLBACK and never
|
||||||
// leaks across pooled connections.
|
// leaks across pooled connections.
|
||||||
@@ -103,7 +87,7 @@ export async function withBrand<T>(
|
|||||||
export async function withPlatformAdmin<T>(
|
export async function withPlatformAdmin<T>(
|
||||||
fn: (db: Db) => Promise<T>,
|
fn: (db: Db) => Promise<T>,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
return runInTransaction(async (client) => {
|
return withTx(async (client) => {
|
||||||
await client.query("SELECT set_config('app.current_brand_id', '', true)");
|
await client.query("SELECT set_config('app.current_brand_id', '', true)");
|
||||||
await client.query("SELECT set_config('app.platform_admin', 'true', true)");
|
await client.query("SELECT set_config('app.platform_admin', 'true', true)");
|
||||||
const db = drizzle(client, { schema });
|
const db = drizzle(client, { schema });
|
||||||
@@ -111,25 +95,4 @@ export async function withPlatformAdmin<T>(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runInTransaction<T>(
|
|
||||||
fn: (client: PoolClient) => Promise<T>,
|
|
||||||
): Promise<T> {
|
|
||||||
const client = await getPool().connect();
|
|
||||||
try {
|
|
||||||
await client.query("BEGIN");
|
|
||||||
const result = await fn(client);
|
|
||||||
await client.query("COMMIT");
|
|
||||||
return result;
|
|
||||||
} catch (err) {
|
|
||||||
try {
|
|
||||||
await client.query("ROLLBACK");
|
|
||||||
} catch {
|
|
||||||
// ignore secondary rollback failure
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
} finally {
|
|
||||||
client.release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export { schema };
|
export { schema };
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
-- Stops: 269 | Locations: 40
|
-- Stops: 269 | Locations: 40
|
||||||
-- Preferred: npx tsx scripts/import-tuxedo-stops.ts
|
-- Preferred: npx tsx scripts/import-tuxedo-stops.ts
|
||||||
-- Manual apply: psql "$DATABASE_URL" -f db/seeds/2026-tuxedo-tour-stops.sql
|
-- Manual apply: psql "$DATABASE_URL" -f db/seeds/2026-tuxedo-tour-stops.sql
|
||||||
-- supabase db query --linked --file db/seeds/2026-tuxedo-tour-stops.sql
|
|
||||||
|
|
||||||
BEGIN;
|
BEGIN;
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -180,7 +180,7 @@ data explicitly checks its gate.
|
|||||||
cold-start paths; the active `getAdminUser().brand_id` always wins
|
cold-start paths; the active `getAdminUser().brand_id` always wins
|
||||||
on later requests.
|
on later requests.
|
||||||
- No Supabase REST, no service-role keys — all access is direct
|
- No Supabase REST, no service-role keys — all access is direct
|
||||||
through Drizzle over a single `pg` Pool.
|
through Drizzle over a single shared `pg` Pool (see `src/lib/db.ts`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+1
-2
@@ -14,7 +14,6 @@ const eslintConfig = defineConfig([
|
|||||||
"next-env.d.ts",
|
"next-env.d.ts",
|
||||||
// Ignore legacy .js scripts that use CommonJS
|
// Ignore legacy .js scripts that use CommonJS
|
||||||
"scripts/**",
|
"scripts/**",
|
||||||
"supabase/**",
|
|
||||||
"fix-agents.js",
|
"fix-agents.js",
|
||||||
]),
|
]),
|
||||||
// Allow setState in useEffect for PWA prompts and client-side state initialization
|
// Allow setState in useEffect for PWA prompts and client-side state initialization
|
||||||
@@ -26,7 +25,7 @@ const eslintConfig = defineConfig([
|
|||||||
},
|
},
|
||||||
// Relax some rules for legacy code
|
// Relax some rules for legacy code
|
||||||
{
|
{
|
||||||
files: ["db/**", "scripts/**", "supabase/**"],
|
files: ["db/**", "scripts/**"],
|
||||||
rules: {
|
rules: {
|
||||||
"@typescript-eslint/no-require-imports": "off",
|
"@typescript-eslint/no-require-imports": "off",
|
||||||
"@typescript-eslint/no-explicit-any": "warn",
|
"@typescript-eslint/no-explicit-any": "warn",
|
||||||
|
|||||||
+12
-4
@@ -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,6 +120,18 @@ 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 were removed when the supabase shim was
|
||||||
|
// deleted. There are no v2 equivalents yet (reports / taxes /
|
||||||
|
// settings sub-pages are still TBD on the v2 surface), so they
|
||||||
|
// redirect to the v2 dashboard for now. Once those modules ship
|
||||||
|
// on v2, the redirects can be tightened to point at the new
|
||||||
|
// pages.
|
||||||
|
{ source: "/admin/products/:id", destination: "/admin/v2/products", permanent: false },
|
||||||
|
{ source: "/admin/reports", destination: "/admin/v2", permanent: false },
|
||||||
|
{ source: "/admin/taxes", destination: "/admin/v2", permanent: false },
|
||||||
|
{ source: "/admin/settings/shipping", destination: "/admin/settings", permanent: false },
|
||||||
|
{ source: "/admin/settings/integrations", destination: "/admin/settings", permanent: false },
|
||||||
|
{ source: "/admin/settings/billing", destination: "/admin/settings", permanent: false },
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -34,8 +34,6 @@
|
|||||||
"@sentry/nextjs": "^10.55.0",
|
"@sentry/nextjs": "^10.55.0",
|
||||||
"@stripe/react-stripe-js": "^6.6.0",
|
"@stripe/react-stripe-js": "^6.6.0",
|
||||||
"@stripe/stripe-js": "^9.7.0",
|
"@stripe/stripe-js": "^9.7.0",
|
||||||
"@supabase/ssr": "^0.10.2",
|
|
||||||
"@supabase/supabase-js": "^2.105.3",
|
|
||||||
"@upstash/ratelimit": "^2.0.8",
|
"@upstash/ratelimit": "^2.0.8",
|
||||||
"@upstash/redis": "^1.38.0",
|
"@upstash/redis": "^1.38.0",
|
||||||
"drizzle-orm": "^0.36.4",
|
"drizzle-orm": "^0.36.4",
|
||||||
|
|||||||
@@ -1,160 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
/**
|
|
||||||
* apply-admin-create-stop.js
|
|
||||||
*
|
|
||||||
* Applies the admin_create_stop RPC using ONLY the keys in .env.local.
|
|
||||||
* You do NOT need Supabase dashboard / SQL editor access.
|
|
||||||
*
|
|
||||||
* This is the fix for the "could not find the function public.admin_create_stop(...)"
|
|
||||||
* error when you click "Add New Stop".
|
|
||||||
*
|
|
||||||
* Usage (run from the repo root on a machine that can reach the DB):
|
|
||||||
* node scripts/apply-admin-create-stop.js
|
|
||||||
*
|
|
||||||
* The script connects as the real postgres user (using your SUPABASE_SERVICE_ROLE_KEY
|
|
||||||
* as the password) and runs the CREATE OR REPLACE + GRANT + NOTIFY.
|
|
||||||
* This is the only reliable way to create the SECURITY DEFINER function because
|
|
||||||
* direct REST inserts are blocked by the `block_stops_mutations` policy.
|
|
||||||
*
|
|
||||||
* After it prints success:
|
|
||||||
* - Restart your `npm run dev`
|
|
||||||
* - "Add New Stop" (AddStopModal / NewStopForm) will now call the RPC successfully.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { Client } = require("pg");
|
|
||||||
const fs = require("fs");
|
|
||||||
const path = require("path");
|
|
||||||
|
|
||||||
// Load .env.local (same as the rest of the project)
|
|
||||||
try {
|
|
||||||
require("dotenv").config({ path: path.resolve(__dirname, "../.env.local") });
|
|
||||||
} catch (e) {
|
|
||||||
// dotenv might not be hoisted in some setups; the keys may already be in process.env
|
|
||||||
}
|
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
|
||||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
|
||||||
|
|
||||||
if (!supabaseUrl || !serviceKey) {
|
|
||||||
console.error("❌ Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in .env.local");
|
|
||||||
console.error(" Make sure you are running from the repo root and .env.local exists with the keys.");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Derive the direct Postgres URL (same logic as supabase/push-migrations.js)
|
|
||||||
const projectRef = supabaseUrl.replace("https://", "").split(".")[0];
|
|
||||||
const pgHost = `db.${projectRef}.supabase.co`;
|
|
||||||
const dbUrl = `postgresql://postgres:${encodeURIComponent(serviceKey)}@${pgHost}:5432/postgres`;
|
|
||||||
|
|
||||||
console.log("🔧 apply-admin-create-stop RPC installer");
|
|
||||||
console.log(" Project:", projectRef);
|
|
||||||
console.log(" Using service role key from .env.local (full Postgres access)");
|
|
||||||
console.log("");
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const migrationPath = path.resolve(__dirname, "../supabase/migrations/202_fix_admin_create_stop.sql");
|
|
||||||
|
|
||||||
if (!fs.existsSync(migrationPath)) {
|
|
||||||
console.error("❌ Cannot find migration file:", migrationPath);
|
|
||||||
console.error(" The file should have been created by the previous fix.");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sql = fs.readFileSync(migrationPath, "utf8");
|
|
||||||
|
|
||||||
const client = new Client({
|
|
||||||
connectionString: dbUrl,
|
|
||||||
ssl: { rejectUnauthorized: false },
|
|
||||||
// Some environments need a longer timeout
|
|
||||||
connectionTimeoutMillis: 15000,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log("⏳ Connecting to Postgres (this uses port 5432 outbound)...");
|
|
||||||
await client.connect();
|
|
||||||
console.log("✅ Connected.");
|
|
||||||
|
|
||||||
console.log("⏳ Executing 202_fix_admin_create_stop.sql (CREATE OR REPLACE + GRANT + NOTIFY)...");
|
|
||||||
const result = await client.query(sql);
|
|
||||||
|
|
||||||
console.log("✅ Function installed / updated successfully.");
|
|
||||||
console.log(" Rows affected in last statement:", result.rowCount ?? "(see output above if any)");
|
|
||||||
console.log("");
|
|
||||||
console.log("🎉 admin_create_stop is now available via the anon key (SECURITY DEFINER).");
|
|
||||||
|
|
||||||
// Quick verification using the anon key over HTTPS (this is what the app uses)
|
|
||||||
try {
|
|
||||||
const anon = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
|
||||||
const probeBody = {
|
|
||||||
p_active: false,
|
|
||||||
p_address: null,
|
|
||||||
p_brand_id: "00000000-0000-0000-0000-000000000000", // will FK fail, but proves the function exists
|
|
||||||
p_city: "verify",
|
|
||||||
p_cutoff_time: null,
|
|
||||||
p_date: "2099-12-31",
|
|
||||||
p_location: "verify",
|
|
||||||
p_state: "TS",
|
|
||||||
p_time: "10:00",
|
|
||||||
p_zip: null,
|
|
||||||
};
|
|
||||||
const probe = await fetch(`${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/admin_create_stop`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { apikey: anon, "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(probeBody),
|
|
||||||
});
|
|
||||||
const txt = await probe.text();
|
|
||||||
if (probe.status === 404) {
|
|
||||||
console.log("⚠️ Post-apply probe still got 404 — you may need to wait a few seconds or the NOTIFY didn't take.");
|
|
||||||
} else if (/foreign key|violates|brand_id/i.test(txt)) {
|
|
||||||
console.log("✅ Post-apply probe: function exists (got expected FK error on the zero-UUID brand — this is success).");
|
|
||||||
} else {
|
|
||||||
console.log("✅ Post-apply probe returned:", txt.slice(0, 200));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.log(" (verification fetch skipped:", e.message, ")");
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("");
|
|
||||||
console.log("Next:");
|
|
||||||
console.log(" • Restart your dev server (`npm run dev`) if it was already running.");
|
|
||||||
console.log(" • Go to the stops admin page and try \"Add New Stop\".");
|
|
||||||
console.log(" • (Optional but recommended) node scripts/verify-stop-fns.js");
|
|
||||||
} catch (err) {
|
|
||||||
const msg = (err && err.message) ? err.message : String(err);
|
|
||||||
console.error("❌ Failed to apply the function.");
|
|
||||||
console.error(" Error:", msg.slice(0, 500));
|
|
||||||
|
|
||||||
if (/ENOTFOUND|EAI_AGAIN|getaddrinfo|network is unreachable|ECONNREFUSED|timeout/i.test(msg)) {
|
|
||||||
console.log("");
|
|
||||||
console.log("💡 Your current environment cannot reach the database on port 5432.");
|
|
||||||
console.log(" This is common inside some containers/CI sandboxes.");
|
|
||||||
console.log("");
|
|
||||||
console.log(" Copy and run ONE of the following on a *normal* laptop/terminal that has");
|
|
||||||
console.log(" outbound access to Supabase Postgres (most developer machines can reach it):");
|
|
||||||
console.log("");
|
|
||||||
console.log(" Option A (easiest, uses exactly the same code as this script):");
|
|
||||||
console.log(" npm run migrate:one 202");
|
|
||||||
console.log("");
|
|
||||||
console.log(" Option B (psql):");
|
|
||||||
console.log(" # Build the URL using the key from your .env.local (same as this script does):");
|
|
||||||
console.log(" # psql \"postgresql://postgres:PUT_YOUR_SERVICE_ROLE_KEY_HERE@db." + projectRef + ".supabase.co:5432/postgres\" \\");
|
|
||||||
console.log(" # -f supabase/migrations/202_fix_admin_create_stop.sql");
|
|
||||||
console.log("");
|
|
||||||
console.log(" Option C (Supabase CLI):");
|
|
||||||
console.log(" supabase db push --db-url \"postgresql://postgres:YOUR_SERVICE_ROLE_KEY@db." + projectRef + ".supabase.co:5432/postgres\" \\");
|
|
||||||
console.log(" supabase/migrations/202_fix_admin_create_stop.sql");
|
|
||||||
console.log("");
|
|
||||||
console.log(" After the command succeeds you will see the NOTIFY and the function will exist.");
|
|
||||||
console.log(" Then restart `npm run dev` and \"Add New Stop\" will stop showing the function-not-found error.");
|
|
||||||
}
|
|
||||||
|
|
||||||
process.exit(1);
|
|
||||||
} finally {
|
|
||||||
try { await client.end(); } catch {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((e) => {
|
|
||||||
console.error("Unexpected crash:", e);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
require("dotenv").config({ path: require("path").resolve(__dirname, "../.env.local") });
|
|
||||||
|
|
||||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
|
||||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
|
||||||
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
|
||||||
|
|
||||||
const headers = {
|
|
||||||
apikey: serviceKey,
|
|
||||||
Authorization: `Bearer ${serviceKey}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
};
|
|
||||||
|
|
||||||
async function rpc(name, body = {}) {
|
|
||||||
const r = await fetch(`${url}/rest/v1/rpc/${name}`, {
|
|
||||||
method: "POST", headers, body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
return { status: r.status, text: (await r.text()).slice(0, 500) };
|
|
||||||
}
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
// 1. Try calling admin_create_stop with service role
|
|
||||||
console.log("=== 1. Call admin_create_stop (service role) ===");
|
|
||||||
console.log(await rpc("admin_create_stop", { p_brand_id: null }));
|
|
||||||
|
|
||||||
// 2. Try with body
|
|
||||||
console.log("\n=== 2. Call admin_create_stop with stub body ===");
|
|
||||||
console.log(await rpc("admin_create_stop", {
|
|
||||||
p_brand_id: "00000000-0000-0000-0000-000000000000",
|
|
||||||
p_city: "test", p_state: "CO", p_location: "x", p_date: "2025-01-01", p_time: "08:00"
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 3. Try with a name that obviously doesn't exist to see the error format
|
|
||||||
console.log("\n=== 3. Call a non-existent function (control) ===");
|
|
||||||
console.log(await rpc("definitely_does_not_exist_xyz", {}));
|
|
||||||
|
|
||||||
// 4. Use the PostgREST introspection — query information_schema via rpc
|
|
||||||
// PostgREST exposes pg_proc through /rest/v1/ for system tables is not allowed
|
|
||||||
// But we can use the OpenAPI spec endpoint
|
|
||||||
console.log("\n=== 4. OpenAPI spec — looking for admin_create_stop ===");
|
|
||||||
const openApi = await fetch(`${url}/rest/v1/`, { headers: { apikey: anonKey, Authorization: `Bearer ${anonKey}` } });
|
|
||||||
const text = await openApi.text();
|
|
||||||
const matches = text.match(/admin_create_stop[a-z_]*/g) || [];
|
|
||||||
console.log("Matches in OpenAPI:", matches);
|
|
||||||
|
|
||||||
// 5. Get all function names from OpenAPI
|
|
||||||
const funcMatches = [...text.matchAll(/"([a-z][a-z0-9_]*)\s*\{/g)].map(m => m[1]);
|
|
||||||
const stopFns = funcMatches.filter(n => n.includes("stop"));
|
|
||||||
console.log("\nStop-related functions in OpenAPI:", stopFns);
|
|
||||||
})();
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
require("dotenv").config({ path: require("path").resolve(__dirname, "../.env.local") });
|
|
||||||
const { Client } = require("pg");
|
|
||||||
const dns = require("dns");
|
|
||||||
|
|
||||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
|
||||||
const projectRef = url.replace("https://", "").split(".")[0];
|
|
||||||
const candidates = [
|
|
||||||
`db.${projectRef}.supabase.co`,
|
|
||||||
`aws-0-us-east-1.pooler.supabase.com`,
|
|
||||||
`aws-0-us-west-1.pooler.supabase.com`,
|
|
||||||
];
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
for (const host of candidates) {
|
|
||||||
try {
|
|
||||||
const ip = await new Promise((resolve, reject) => {
|
|
||||||
dns.resolve4(host, (err, addrs) => err ? reject(err) : resolve(addrs));
|
|
||||||
});
|
|
||||||
console.log(`${host} -> ${ip.join(", ")}`);
|
|
||||||
} catch (e) {
|
|
||||||
console.log(`${host} -> DNS FAIL: ${e.code}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try direct DB with port 6543 (pooler) using supabase format
|
|
||||||
// Username pattern: postgres.<project-ref>
|
|
||||||
const client = new Client({
|
|
||||||
host: "aws-0-us-east-1.pooler.supabase.com",
|
|
||||||
port: 6543, database: "postgres",
|
|
||||||
user: `postgres.${projectRef}`,
|
|
||||||
password: process.env.SUPABASE_SERVICE_ROLE_KEY,
|
|
||||||
ssl: { rejectUnauthorized: false },
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
await client.connect();
|
|
||||||
const r = await client.query(`
|
|
||||||
SELECT p.proname,
|
|
||||||
pg_get_function_identity_arguments(p.oid) AS id_args,
|
|
||||||
p.prosecdef AS secdef
|
|
||||||
FROM pg_proc p JOIN pg_namespace n ON p.pronamespace=n.oid
|
|
||||||
WHERE n.nspname='public' AND p.proname IN
|
|
||||||
('admin_create_stop','admin_create_stops_batch','delete_stop')
|
|
||||||
ORDER BY p.proname;
|
|
||||||
`);
|
|
||||||
console.log("\nFUNCTIONS IN DB:");
|
|
||||||
console.log(JSON.stringify(r.rows, null, 2));
|
|
||||||
} catch (e) {
|
|
||||||
console.error("POOLER ERR:", e.message);
|
|
||||||
} finally {
|
|
||||||
try { await client.end(); } catch {}
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
require("dotenv").config({ path: require("path").resolve(__dirname, "../.env.local") });
|
|
||||||
const { Client } = require("pg");
|
|
||||||
|
|
||||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
|
||||||
const projectRef = url.replace("https://", "").split(".")[0];
|
|
||||||
const pw = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
|
||||||
|
|
||||||
const tries = [
|
|
||||||
{ host: "aws-0-us-east-1.pooler.supabase.com", port: 6543, user: `postgres.${projectRef}`, database: "postgres" },
|
|
||||||
{ host: "aws-0-us-east-1.pooler.supabase.com", port: 5432, user: `postgres.${projectRef}`, database: "postgres" },
|
|
||||||
{ host: "aws-0-us-east-1.pooler.supabase.com", port: 6543, user: "postgres", database: "postgres" },
|
|
||||||
];
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
for (const cfg of tries) {
|
|
||||||
const client = new Client({ ...cfg, password: pw, ssl: { rejectUnauthorized: false } });
|
|
||||||
try {
|
|
||||||
await client.connect();
|
|
||||||
const r = await client.query(`
|
|
||||||
SELECT p.proname,
|
|
||||||
pg_get_function_identity_arguments(p.oid) AS id_args,
|
|
||||||
p.prosecdef AS secdef
|
|
||||||
FROM pg_proc p JOIN pg_namespace n ON p.pronamespace=n.oid
|
|
||||||
WHERE n.nspname='public' AND p.proname IN
|
|
||||||
('admin_create_stop','admin_create_stops_batch','delete_stop')
|
|
||||||
ORDER BY p.proname;
|
|
||||||
`);
|
|
||||||
console.log(`\n✓ ${cfg.host}:${cfg.port} user=${cfg.user}`);
|
|
||||||
console.log(JSON.stringify(r.rows, null, 2));
|
|
||||||
|
|
||||||
// Also check if migration tracking table exists
|
|
||||||
const trk = await client.query(`
|
|
||||||
SELECT version, name FROM supabase_migrations.schema_migrations
|
|
||||||
WHERE version >= 145 ORDER BY version;
|
|
||||||
`).catch(() => ({ rows: [] }));
|
|
||||||
console.log("Recent migrations applied:", trk.rows);
|
|
||||||
await client.end();
|
|
||||||
return;
|
|
||||||
} catch (e) {
|
|
||||||
console.log(`✗ ${cfg.host}:${cfg.port} user=${cfg.user} → ${e.message}`);
|
|
||||||
try { await client.end(); } catch {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
/**
|
|
||||||
* Cleanup script: remove duplicate stops from the Tuxedo Corn 2026 tour.
|
|
||||||
* Keeps the row with the latest created_at per (brand_id, date, city, state, location, time).
|
|
||||||
* Run: DATABASE_URL="postgresql://..." npx tsx scripts/cleanup-duplicate-stops.ts
|
|
||||||
*/
|
|
||||||
import { Pool } from "pg";
|
|
||||||
|
|
||||||
const pool = new Pool({
|
|
||||||
connectionString:
|
|
||||||
process.env.DATABASE_ADMIN_URL ??
|
|
||||||
process.env.DATABASE_URL ??
|
|
||||||
"postgres://postgres:postgres@localhost:5432/route_commerce",
|
|
||||||
});
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const client = await pool.connect();
|
|
||||||
try {
|
|
||||||
await client.query("BEGIN");
|
|
||||||
|
|
||||||
// Find duplicate groups (same brand_id, date, city, state, location, time)
|
|
||||||
const dupes = await client.query(`
|
|
||||||
SELECT brand_id::text, date, city, state, location, time, count(*) as cnt, min(created_at::text) as oldest, max(created_at::text) as newest
|
|
||||||
FROM stops
|
|
||||||
WHERE deleted_at IS NULL
|
|
||||||
GROUP BY brand_id, date, city, state, location, time
|
|
||||||
HAVING count(*) > 1
|
|
||||||
ORDER BY date, city
|
|
||||||
`);
|
|
||||||
|
|
||||||
console.log(`Found ${dupes.rowCount} duplicate groups`);
|
|
||||||
if (dupes.rowCount === 0) {
|
|
||||||
await client.query("COMMIT");
|
|
||||||
console.log("No duplicates found.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let totalDeleted = 0;
|
|
||||||
for (const row of dupes.rows) {
|
|
||||||
// Keep the row with the latest created_at, delete the rest
|
|
||||||
const del = await client.query(`
|
|
||||||
DELETE FROM stops
|
|
||||||
WHERE brand_id = $1
|
|
||||||
AND date = $2
|
|
||||||
AND city = $3
|
|
||||||
AND state = $4
|
|
||||||
AND location = $5
|
|
||||||
AND time IS NOT DISTINCT FROM $6
|
|
||||||
AND deleted_at IS NULL
|
|
||||||
AND created_at < (
|
|
||||||
SELECT max(created_at) FROM stops s2
|
|
||||||
WHERE s2.brand_id = stops.brand_id
|
|
||||||
AND s2.date = stops.date
|
|
||||||
AND s2.city = stops.city
|
|
||||||
AND s2.state = stops.state
|
|
||||||
AND s2.location = stops.location
|
|
||||||
AND (s2.time IS NOT DISTINCT FROM stops.time)
|
|
||||||
AND s2.deleted_at IS NULL
|
|
||||||
)
|
|
||||||
`, [row.brand_id, row.date, row.city, row.state, row.location, row.time]);
|
|
||||||
totalDeleted += del.rowCount ?? 0;
|
|
||||||
console.log(` Deleted ${del.rowCount} duplicate(s) for ${row.city}, ${row.state} on ${row.date}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
await client.query("COMMIT");
|
|
||||||
console.log(`\nDone. Total duplicates removed: ${totalDeleted}`);
|
|
||||||
} catch (e) {
|
|
||||||
await client.query("ROLLBACK");
|
|
||||||
throw e;
|
|
||||||
} finally {
|
|
||||||
client.release();
|
|
||||||
await pool.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((e) => {
|
|
||||||
console.error(e);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
/**
|
|
||||||
* Create an admin user in Neon Auth via direct HTTP call.
|
|
||||||
* Run: npx tsx scripts/create-admin-user.ts [email] [password] [name]
|
|
||||||
*
|
|
||||||
* Makes a direct HTTP request to the Neon Auth API so we don't need
|
|
||||||
* a Next.js request context, then updates email_verified in the DB.
|
|
||||||
*/
|
|
||||||
import { config } from "dotenv";
|
|
||||||
config({ path: ".env.local" });
|
|
||||||
import pg from "pg";
|
|
||||||
|
|
||||||
const { Pool } = pg;
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const email = process.argv[2] ?? "admin@example.com";
|
|
||||||
const password = process.argv[3] ?? "Admin1234!";
|
|
||||||
const name = process.argv[4] ?? "Admin";
|
|
||||||
|
|
||||||
const baseUrl = process.env.NEON_AUTH_BASE_URL!;
|
|
||||||
|
|
||||||
console.log(`Creating user: ${email}`);
|
|
||||||
|
|
||||||
const res = await fetch(`${baseUrl}/sign-up/email`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Origin": "http://localhost:4000",
|
|
||||||
"x-neon-auth-proxy": "node",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ email, password, name, callbackURL: "/admin" }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await res.json().catch(() => ({}));
|
|
||||||
console.log(`Sign-up status: ${res.status}`);
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
console.error("Failed to create user:", JSON.stringify(data));
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const userId = data.user?.id;
|
|
||||||
console.log("User created:", data.user?.email, "| ID:", userId);
|
|
||||||
|
|
||||||
// Mark email verified in DB so they can log in immediately
|
|
||||||
if (userId) {
|
|
||||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
|
||||||
try {
|
|
||||||
await pool.query(
|
|
||||||
"UPDATE neon_auth.user SET email_verified = true WHERE id = $1",
|
|
||||||
[userId]
|
|
||||||
);
|
|
||||||
console.log("Email verified in DB.");
|
|
||||||
} finally {
|
|
||||||
await pool.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`\nLog in at http://localhost:4000/login with ${email} / ${password}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# End-to-end validation test for the local Postgres + PostgREST + MinIO + Next.js stack
|
|
||||||
# Exit 0 = all green, exit 1 = at least one failure.
|
|
||||||
|
|
||||||
set -e
|
|
||||||
API="http://localhost:3001"
|
|
||||||
WEB="http://localhost:4000"
|
|
||||||
|
|
||||||
pass=0
|
|
||||||
fail=0
|
|
||||||
declare -a FAILURES
|
|
||||||
|
|
||||||
check() {
|
|
||||||
local name="$1" url="$2" expected="${3:-200}" cookies="${4:-}"
|
|
||||||
local cmd="curl -s -o /dev/null -w '%{http_code}'"
|
|
||||||
if [ -n "$cookies" ]; then cmd="$cmd -b \"$cookies\""; fi
|
|
||||||
local code=$(eval "$cmd $url")
|
|
||||||
if [ "$code" = "$expected" ]; then
|
|
||||||
echo " PASS $name ($code)"
|
|
||||||
pass=$((pass+1))
|
|
||||||
else
|
|
||||||
echo " FAIL $name expected=$expected got=$code url=$url"
|
|
||||||
fail=$((fail+1))
|
|
||||||
FAILURES+=("$name")
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "=== Postgres connection ==="
|
|
||||||
PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT 'ok';" >/dev/null
|
|
||||||
echo " PASS postgres responds"
|
|
||||||
pass=$((pass+1))
|
|
||||||
|
|
||||||
echo "=== DB integrity ==="
|
|
||||||
TABLE_COUNT=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT count(*) FROM information_schema.tables WHERE table_schema='public';")
|
|
||||||
[ "$TABLE_COUNT" -ge 65 ] && echo " PASS $TABLE_COUNT public tables (>=65)" && pass=$((pass+1)) || { echo " FAIL $TABLE_COUNT tables"; fail=$((fail+1)); }
|
|
||||||
|
|
||||||
FN_COUNT=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT count(*) FROM pg_proc p JOIN pg_namespace n ON p.pronamespace=n.oid WHERE n.nspname='public';")
|
|
||||||
[ "$FN_COUNT" -ge 250 ] && echo " PASS $FN_COUNT functions (>=250)" && pass=$((pass+1)) || { echo " FAIL $FN_COUNT functions"; fail=$((fail+1)); }
|
|
||||||
|
|
||||||
BRANDS=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT count(*) FROM brands;")
|
|
||||||
[ "$BRANDS" -ge 2 ] && echo " PASS $BRANDS brands" && pass=$((pass+1)) || { echo " FAIL $BRANDS brands"; fail=$((fail+1)); }
|
|
||||||
|
|
||||||
STOPS=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT count(*) FROM stops WHERE active=true AND deleted_at IS NULL;")
|
|
||||||
[ "$STOPS" -ge 200 ] && echo " PASS $STOPS active stops" && pass=$((pass+1)) || { echo " FAIL $STOPS stops"; fail=$((fail+1)); }
|
|
||||||
|
|
||||||
# No test brands
|
|
||||||
TEST_BRANDS=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT count(*) FROM brands WHERE slug IN ('sunrise-farms','green-valley','orchard-fresh');")
|
|
||||||
[ "$TEST_BRANDS" = "0" ] && echo " PASS no test brands" && pass=$((pass+1)) || { echo " FAIL test brands found"; fail=$((fail+1)); }
|
|
||||||
|
|
||||||
echo "=== Tuxedo Corn data ==="
|
|
||||||
PHONE=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT phone FROM brand_settings WHERE brand_id='64294306-5f42-463d-a5e8-2ad6c81a96de';")
|
|
||||||
[ "$PHONE" = "970-323-6874" ] && echo " PASS phone=$PHONE" && pass=$((pass+1)) || { echo " FAIL phone=$PHONE (expected 970-323-6874)"; fail=$((fail+1)); }
|
|
||||||
|
|
||||||
LOGOS=$(PGPASSWORD=routecommerce_dev_password psql -U routecommerce -h 127.0.0.1 -d route_commerce -tA -c "SELECT count(*) FROM brand_settings WHERE brand_id='64294306-5f42-463d-a5e8-2ad6c81a96de' AND logo_url LIKE '/storage/%';")
|
|
||||||
[ "$LOGOS" = "1" ] && echo " PASS logo_url is /storage/ path" && pass=$((pass+1)) || { echo " FAIL logo_url not /storage/"; fail=$((fail+1)); }
|
|
||||||
|
|
||||||
echo "=== PostgREST ==="
|
|
||||||
check "GET /" "$API/"
|
|
||||||
check "GET /brands" "$API/brands?select=id,name,slug&order=name"
|
|
||||||
check "GET /brands?slug=eq.tuxedo" "$API/brands?slug=eq.tuxedo&select=*"
|
|
||||||
check "GET /stops" "$API/stops?select=id,city&limit=5"
|
|
||||||
check "GET /products" "$API/products?select=id,name&limit=5"
|
|
||||||
check "POST rpc get_brand_settings_by_slug" "$API/rpc/get_brand_settings_by_slug" 200 \
|
|
||||||
"-X POST -H Content-Type:application/json -d {\"p_brand_slug\":\"tuxedo\"}"
|
|
||||||
check "POST rpc get_brand_plan_info" "$API/rpc/get_brand_plan_info" 200 \
|
|
||||||
"-X POST -H Content-Type:application/json -d {\"p_brand_id\":\"64294306-5f42-463d-a5e8-2ad6c81a96de\"}"
|
|
||||||
check "POST rpc get_public_stops_for_brand" "$API/rpc/get_public_stops_for_brand" 200 \
|
|
||||||
"-X POST -H Content-Type:application/json -d {\"p_brand_slug\":\"tuxedo\"}"
|
|
||||||
check "GET /admin_users with brand join" "$API/admin_users?select=id,user_id,role,brand_id,brands(name)&limit=5"
|
|
||||||
check "GET /stops with brand join" "$API/stops?select=id,city,brand_id,brands(name)&limit=5"
|
|
||||||
|
|
||||||
echo "=== MinIO ==="
|
|
||||||
check "Storage logo.png" "$WEB/storage/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/logo.png"
|
|
||||||
check "Storage olathe-sweet-logo.png" "$WEB/storage/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo.png"
|
|
||||||
check "Storage olathe-sweet-logo-dark.png" "$WEB/storage/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo-dark.png"
|
|
||||||
|
|
||||||
echo "=== Public storefronts ==="
|
|
||||||
check "GET /" "$WEB/"
|
|
||||||
check "GET /tuxedo" "$WEB/tuxedo"
|
|
||||||
check "GET /tuxedo/about" "$WEB/tuxedo/about"
|
|
||||||
check "GET /tuxedo/stops" "$WEB/tuxedo/stops"
|
|
||||||
check "GET /indian-river-direct" "$WEB/indian-river-direct"
|
|
||||||
check "GET /login" "$WEB/login"
|
|
||||||
|
|
||||||
echo "=== Admin pages (dev_session=platform_admin) ==="
|
|
||||||
for p in /admin /admin/products /admin/stops /admin/orders /admin/users /admin/settings /admin/settings/billing /admin/settings/apps /admin/settings/payments /admin/communications /admin/communications/compose /admin/time-tracking /admin/wholesale /admin/water-log /admin/analytics /admin/reports; do
|
|
||||||
check "GET $p" "$WEB$p" 200 "dev_session=platform_admin"
|
|
||||||
done
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "=== Summary ==="
|
|
||||||
echo " PASS: $pass"
|
|
||||||
echo " FAIL: $fail"
|
|
||||||
if [ $fail -gt 0 ]; then
|
|
||||||
echo " Failures:"
|
|
||||||
for f in "${FAILURES[@]}"; do echo " - $f"; done
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo " ALL GREEN"
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -1,262 +0,0 @@
|
|||||||
/**
|
|
||||||
* import-woo-to-route.ts - 2026 Season Import
|
|
||||||
*
|
|
||||||
* Reads Tuxedo_Corn_2026_Tour_Schedule-2.xlsx (Full Tour Schedule sheet) and produces:
|
|
||||||
* products.csv - 1 core pickup product
|
|
||||||
* stops.csv - 164 active stops
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* npx tsx scripts/import-woo-to-route.ts \
|
|
||||||
* --xlsx "/path/to/Tuxedo_Corn_2026_Tour_Schedule-2.xlsx" \
|
|
||||||
* --brand 64294306-5f42-463d-a5e8-2ad6c81a96de \
|
|
||||||
* --output ./import-output
|
|
||||||
*/
|
|
||||||
|
|
||||||
import * as fs from "fs";
|
|
||||||
import * as path from "path";
|
|
||||||
import { parseArgs } from "util";
|
|
||||||
import ExcelJS from "exceljs";
|
|
||||||
|
|
||||||
// Types
|
|
||||||
|
|
||||||
interface ProductOutput {
|
|
||||||
name: string;
|
|
||||||
price: number;
|
|
||||||
type: "Pickup" | "Shipping" | "Pickup & Shipping";
|
|
||||||
description: string;
|
|
||||||
active: boolean;
|
|
||||||
image_url: string;
|
|
||||||
is_taxable: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StopOutput {
|
|
||||||
city: string;
|
|
||||||
state: string;
|
|
||||||
location: string;
|
|
||||||
date: string;
|
|
||||||
time: string;
|
|
||||||
address: string;
|
|
||||||
notes: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function slugify(s: string): string {
|
|
||||||
return s
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9]+/g, "-")
|
|
||||||
.replace(/^-|-$/g, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const args = parseArgs({
|
|
||||||
options: {
|
|
||||||
xlsx: { type: "string" },
|
|
||||||
brand: { type: "string" },
|
|
||||||
output: { type: "string", default: "./import-output" },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const xlsxPath = args.values.xlsx as string;
|
|
||||||
const brandId = args.values.brand as string;
|
|
||||||
const outputDir = args.values.output as string;
|
|
||||||
|
|
||||||
if (!xlsxPath || !brandId) {
|
|
||||||
console.error(
|
|
||||||
"Usage: npx tsx scripts/import-woo-to-route.ts --xlsx <path> --brand <uuid> [--output <dir>]"
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
if (!fs.existsSync(xlsxPath)) {
|
|
||||||
console.error("File not found: " + xlsxPath);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read Excel
|
|
||||||
// Row 0=title, Row1=subtitle, Row2=color-key, Row3=column-headers, Row4+=data
|
|
||||||
const wb = new ExcelJS.Workbook();
|
|
||||||
await wb.xlsx.readFile(xlsxPath);
|
|
||||||
const sheet = wb.getWorksheet(1);
|
|
||||||
if (!sheet) {
|
|
||||||
console.error("No worksheet found in file");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const allRows: (string | null | undefined)[][] = [];
|
|
||||||
sheet.eachRow((row) => {
|
|
||||||
allRows.push(row.values as (string | null | undefined)[]);
|
|
||||||
});
|
|
||||||
|
|
||||||
const colHeaders = (allRows[3] as string[]).map((h: unknown) => String(h ?? "").trim());
|
|
||||||
console.log("Column headers:", colHeaders);
|
|
||||||
|
|
||||||
const ci = (col: string) => colHeaders.indexOf(col);
|
|
||||||
const wkIdx = ci("Wk"), typeIdx = ci("Type"), datesIdx = ci("Dates"), dayIdx = ci("Day");
|
|
||||||
const cityIdx = ci("City / Location"), hostIdx = ci("Host / Venue");
|
|
||||||
const timeIdx = ci("Time"), truckIdx = ci("Truck"), statusIdx = ci("Status"), notesIdx = ci("Notes");
|
|
||||||
|
|
||||||
interface ScheduleRow {
|
|
||||||
wk: number; type: string; dates: string; day: string;
|
|
||||||
city: string; host: string; time: string; truck: string; status: string; notes: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const scheduleRows: ScheduleRow[] = [];
|
|
||||||
let currentWeek: { wk: number; type: string; dates: string; day: string } | null = null;
|
|
||||||
|
|
||||||
for (let i = 4; i < allRows.length; i++) {
|
|
||||||
const row = allRows[i] as (string | null | undefined)[];
|
|
||||||
if (!row) continue;
|
|
||||||
|
|
||||||
const wkVal = row[wkIdx];
|
|
||||||
const Day = String(row[dayIdx] ?? "").trim();
|
|
||||||
const City_Location = String(row[cityIdx] ?? "").trim();
|
|
||||||
const Status = String(row[statusIdx] ?? "").trim();
|
|
||||||
|
|
||||||
if (wkVal !== null && wkVal !== undefined && String(wkVal).trim() !== "") {
|
|
||||||
currentWeek = {
|
|
||||||
wk: Number(wkVal),
|
|
||||||
type: String(row[typeIdx] ?? "").trim(),
|
|
||||||
dates: String(row[datesIdx] ?? "").trim(),
|
|
||||||
day: Day,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!currentWeek) continue;
|
|
||||||
if (Status === "Off day") continue;
|
|
||||||
if (!City_Location) continue;
|
|
||||||
|
|
||||||
scheduleRows.push({
|
|
||||||
wk: currentWeek.wk, type: currentWeek.type, dates: currentWeek.dates,
|
|
||||||
day: Day || currentWeek.day,
|
|
||||||
city: City_Location,
|
|
||||||
host: String(row[hostIdx] ?? "").trim(),
|
|
||||||
time: String(row[timeIdx] ?? "").trim(),
|
|
||||||
truck: String(row[truckIdx] ?? "").trim(),
|
|
||||||
status: Status,
|
|
||||||
notes: String(row[notesIdx] ?? "").trim(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const ACTIVE_STATUSES = ["✓ Confirmed", "🔴 Pre-sale live"];
|
|
||||||
const activeRows = scheduleRows.filter((r) => ACTIVE_STATUSES.includes(r.status));
|
|
||||||
console.log("\nSchedule rows: " + scheduleRows.length + " total | " + activeRows.length + " active\n");
|
|
||||||
|
|
||||||
// PRODUCTS -- one core product for all stops
|
|
||||||
const products: ProductOutput[] = [
|
|
||||||
{
|
|
||||||
name: "Olathe Sweet Corn -- 24 Ear Box",
|
|
||||||
price: 25,
|
|
||||||
type: "Pickup",
|
|
||||||
description:
|
|
||||||
"Fresh Olathe Sweet Corn pickup box. Add to cart, then choose your pickup stop at checkout. " +
|
|
||||||
"Available July-September 2026 at stops across Colorado, Wyoming, and New Mexico. " +
|
|
||||||
"See tuxedocorn.com for full schedule.",
|
|
||||||
active: true,
|
|
||||||
image_url:
|
|
||||||
"https://tuxedocorn.com/wp-content/uploads/2024/06/110260317_3494904527187819_6983832261179080791_n-1.jpg",
|
|
||||||
is_taxable: true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
console.log("[products] 1 core product: \"" + products[0].name + "\" at $" + products[0].price + "\n");
|
|
||||||
|
|
||||||
// STOPS -- one row per active schedule entry
|
|
||||||
const stops: StopOutput[] = [];
|
|
||||||
const seenStopKeys = new Set<string>();
|
|
||||||
|
|
||||||
for (const row of activeRows) {
|
|
||||||
if (!row.city) continue;
|
|
||||||
|
|
||||||
// Parse "City ST" or "City, ST" -> city + state
|
|
||||||
const cityRaw = row.city.replace(/--/g, "-").trim();
|
|
||||||
let city = cityRaw, state = "CO";
|
|
||||||
|
|
||||||
if (cityRaw.includes(",")) {
|
|
||||||
const parts = cityRaw.split(",").map((s: string) => s.trim());
|
|
||||||
const lastPart = parts[parts.length - 1] ?? "";
|
|
||||||
if (/^[A-Z]{2}$/.test(lastPart)) { city = parts.slice(0, -1).join(", ").trim(); state = lastPart; }
|
|
||||||
else city = parts.join(", ");
|
|
||||||
} else {
|
|
||||||
const tokens = cityRaw.split(/\s+/);
|
|
||||||
const lastToken = tokens[tokens.length - 1] ?? "";
|
|
||||||
if (/^[A-Z]{2}$/.test(lastToken)) { state = lastToken; city = tokens.slice(0, -1).join(" "); }
|
|
||||||
}
|
|
||||||
|
|
||||||
const stopKey = slugify(city + "-" + state + "-" + row.dates + "-" + row.time);
|
|
||||||
if (seenStopKeys.has(stopKey)) continue;
|
|
||||||
seenStopKeys.add(stopKey);
|
|
||||||
|
|
||||||
// Human-readable stop label = "Store (Time) | Truck T1"
|
|
||||||
const stopName = row.host + " (" + row.time + ")" + (row.truck ? " | Truck " + row.truck : "");
|
|
||||||
|
|
||||||
stops.push({
|
|
||||||
city,
|
|
||||||
state,
|
|
||||||
location: stopName,
|
|
||||||
date: row.dates, // week range -- set actual date in /admin/stops after import
|
|
||||||
time: row.time,
|
|
||||||
address: row.city + " | " + row.host,
|
|
||||||
notes: row.day + (row.notes ? " | " + row.notes : ""),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("[stops] " + stops.length + " stops written\n");
|
|
||||||
|
|
||||||
// Show sample by week
|
|
||||||
const byWeek: Record<string, typeof stops> = {};
|
|
||||||
for (const s of stops) {
|
|
||||||
if (!byWeek[s.date]) byWeek[s.date] = [];
|
|
||||||
byWeek[s.date].push(s);
|
|
||||||
}
|
|
||||||
const weeks = Object.keys(byWeek).slice(0, 4);
|
|
||||||
for (const week of weeks) {
|
|
||||||
const ws = byWeek[week];
|
|
||||||
console.log(" " + week + " (" + ws.length + " stops):");
|
|
||||||
for (const s of ws.slice(0, 3)) {
|
|
||||||
console.log(" " + s.city + ", " + s.state + " | \"" + s.location + "\" | " + s.time);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write output CSVs
|
|
||||||
fs.mkdirSync(outputDir, { recursive: true });
|
|
||||||
|
|
||||||
function writeCSV(filename: string, header: string, rows: string[][]) {
|
|
||||||
const lines = rows.map((r) =>
|
|
||||||
r.map((c) => "\"" + String(c ?? "").replace(/\"/g, "\"\"") + "\"").join(",")
|
|
||||||
);
|
|
||||||
fs.writeFileSync(path.join(outputDir, filename), header + lines.join("\n"));
|
|
||||||
}
|
|
||||||
|
|
||||||
writeCSV(
|
|
||||||
"products.csv",
|
|
||||||
"name,price,type,description,active,image_url,is_taxable\n",
|
|
||||||
products.map((p) => [
|
|
||||||
p.name, String(p.price), p.type, p.description,
|
|
||||||
String(p.active), p.image_url, String(p.is_taxable),
|
|
||||||
])
|
|
||||||
);
|
|
||||||
|
|
||||||
writeCSV(
|
|
||||||
"stops.csv",
|
|
||||||
"city,state,location,date,time,address,notes\n",
|
|
||||||
stops.map((s) => [s.city, s.state, s.location, s.date, s.time, s.address, s.notes])
|
|
||||||
);
|
|
||||||
|
|
||||||
// Summary
|
|
||||||
console.log("\n============================================================");
|
|
||||||
console.log("IMPORT READY");
|
|
||||||
console.log("============================================================");
|
|
||||||
console.log("\nproducts.csv: 1 product (\"" + products[0].name + "\" @ $" + products[0].price + " Pickup)");
|
|
||||||
console.log("stops.csv: " + stops.length + " stops (confirmed + pre-sale only)");
|
|
||||||
console.log("\nOutput: " + outputDir + "/");
|
|
||||||
console.log("");
|
|
||||||
console.log(">>> NOTE: stops.csv 'date' field is a WEEK RANGE (e.g. Jul 22-28).");
|
|
||||||
console.log("After importing stops, visit /admin/stops to set the actual date for each stop.");
|
|
||||||
console.log("");
|
|
||||||
console.log("NEXT STEPS:");
|
|
||||||
console.log(" 1. Upload " + outputDir + "/products.csv via /admin/import -> type: products");
|
|
||||||
console.log(" 2. Upload " + outputDir + "/stops.csv via /admin/import -> type: stops");
|
|
||||||
console.log(" 3. Visit /admin/stops -- set each stop's actual date, verify address/time");
|
|
||||||
console.log(" 4. Visit /tuxedo -- verify product and stops appear\n");
|
|
||||||
console.log("Re-run: npx tsx scripts/import-woo-to-route.ts --xlsx \"" + xlsxPath + "\" --brand " + brandId + " --output " + outputDir + "\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch(console.error);
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
/**
|
|
||||||
* Seed an admin user for development.
|
|
||||||
* Run: npx tsx scripts/seed-admin.ts [email] [password] [name]
|
|
||||||
*/
|
|
||||||
import { config } from "dotenv";
|
|
||||||
config({ path: ".env.local" });
|
|
||||||
import pg from "pg";
|
|
||||||
|
|
||||||
const { Pool } = pg;
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const email = process.argv[2] ?? "admin@test.com";
|
|
||||||
const password = process.argv[3] ?? "Admin1234!";
|
|
||||||
const name = process.argv[4] ?? "Admin";
|
|
||||||
|
|
||||||
const baseUrl = process.env.NEON_AUTH_BASE_URL!;
|
|
||||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 1. Create or get brand
|
|
||||||
const brandResult = await pool.query(`
|
|
||||||
INSERT INTO brands (name, slug, plan_tier)
|
|
||||||
VALUES ('Demo Brand', 'demo', 'enterprise')
|
|
||||||
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name
|
|
||||||
RETURNING id, name, slug
|
|
||||||
`);
|
|
||||||
const brand = brandResult.rows[0];
|
|
||||||
console.log("✓ Brand:", brand.name, `(${brand.id})`);
|
|
||||||
|
|
||||||
// 2. Create user in Neon Auth via direct API
|
|
||||||
const res = await fetch(`${baseUrl}/sign-up/email`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Origin": "http://localhost:4000",
|
|
||||||
"x-neon-auth-proxy": "node",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ email, password, name, callbackURL: "/admin" }),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
if (!res.ok && data.code !== "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL") {
|
|
||||||
console.error("✗ Failed to create user:", data);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const userId = data.user?.id;
|
|
||||||
console.log("✓ Neon Auth user:", email, userId ? `(${userId})` : "(already existed)");
|
|
||||||
|
|
||||||
// 3. Verify email in Neon Auth DB
|
|
||||||
if (userId) {
|
|
||||||
await pool.query(
|
|
||||||
`UPDATE neon_auth.user SET "emailVerified" = true WHERE id = $1`,
|
|
||||||
[userId]
|
|
||||||
);
|
|
||||||
console.log("✓ Email verified in DB");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Get user ID from neon_auth.user by email
|
|
||||||
const neonUser = await pool.query(
|
|
||||||
`SELECT id FROM neon_auth.user WHERE email = $1`,
|
|
||||||
[email]
|
|
||||||
);
|
|
||||||
const neonUserId = neonUser.rows[0]?.id;
|
|
||||||
if (!neonUserId) {
|
|
||||||
console.error("✗ Could not find user in neon_auth.user");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Insert into admin_users (upsert)
|
|
||||||
const adminResult = await pool.query(`
|
|
||||||
INSERT INTO admin_users (user_id, email, name, role)
|
|
||||||
VALUES ($1, $2, $3, 'brand_admin')
|
|
||||||
ON CONFLICT (email) DO UPDATE SET user_id = EXCLUDED.user_id, name = EXCLUDED.name, role = EXCLUDED.role
|
|
||||||
RETURNING id
|
|
||||||
`, [neonUserId, email, name]);
|
|
||||||
const adminUser = adminResult.rows[0];
|
|
||||||
console.log("✓ Admin user:", email, `(${adminUser.id})`);
|
|
||||||
|
|
||||||
// 6. Link to brand
|
|
||||||
await pool.query(`
|
|
||||||
INSERT INTO admin_user_brands (admin_user_id, brand_id)
|
|
||||||
VALUES ($1, $2)
|
|
||||||
ON CONFLICT DO NOTHING
|
|
||||||
`, [adminUser.id, brand.id]);
|
|
||||||
console.log("✓ Linked to brand:", brand.name);
|
|
||||||
|
|
||||||
console.log(`\n🎉 All set! Log in at http://localhost:4000/login`);
|
|
||||||
console.log(` Email: ${email}`);
|
|
||||||
console.log(` Password: ${password}`);
|
|
||||||
} finally {
|
|
||||||
await pool.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import { config } from "dotenv";
|
|
||||||
config({ path: ".env.local" });
|
|
||||||
import pg from "pg";
|
|
||||||
|
|
||||||
const { Pool } = pg;
|
|
||||||
|
|
||||||
const TUXEDO = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
|
||||||
|
|
||||||
try {
|
|
||||||
// brand_settings
|
|
||||||
const bs = await pool.query(
|
|
||||||
`INSERT INTO brand_settings (brand_id, legal_business_name, phone, email, logo_url, tagline, from_email)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
||||||
ON CONFLICT (brand_id) DO UPDATE SET legal_business_name = EXCLUDED.legal_business_name
|
|
||||||
RETURNING brand_id, legal_business_name`,
|
|
||||||
[TUXEDO, "Tuxedo Corn LLC", "970-555-0100", "info@tuxedocorn.com",
|
|
||||||
"/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/logo.png",
|
|
||||||
"Farm-Fresh Sweet Corn, Delivered", "orders@tuxedocorn.com"]
|
|
||||||
);
|
|
||||||
console.log("brand_settings:", bs.rows[0]);
|
|
||||||
|
|
||||||
// wholesale_settings
|
|
||||||
const ws = await pool.query(
|
|
||||||
`INSERT INTO wholesale_settings (brand_id, require_approval, pickup_location, fob_location, from_email)
|
|
||||||
VALUES ($1, true, $2, $3, $4)
|
|
||||||
ON CONFLICT (brand_id) DO UPDATE SET pickup_location = EXCLUDED.pickup_location
|
|
||||||
RETURNING brand_id, pickup_location`,
|
|
||||||
[TUXEDO, "59751 David Road, Olathe, CO 81425", "FOB Olathe, CO", "orders@tuxedocorn.com"]
|
|
||||||
);
|
|
||||||
console.log("wholesale_settings:", ws.rows[0]);
|
|
||||||
|
|
||||||
console.log("\nDone!");
|
|
||||||
} finally {
|
|
||||||
await pool.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((e) => { console.error(e.message); process.exit(1); });
|
|
||||||
-343
@@ -1,343 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Seed script for Route Commerce Platform
|
|
||||||
# Run with: bash scripts/seed.sh
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Use service role key for write operations during seeding
|
|
||||||
SUPABASE_URL="${SUPABASE_URL:-https://your-project.supabase.co}"
|
|
||||||
SERVICE_KEY="${SUPABASE_SERVICE_ROLE_KEY}"
|
|
||||||
ANON_KEY="${ANON_KEY:-$NEXT_PUBLIC_SUPABASE_ANON_KEY}"
|
|
||||||
|
|
||||||
AUTH_HEADER="apikey: $SERVICE_KEY"
|
|
||||||
CONTENT_TYPE="Content-Type: application/json"
|
|
||||||
|
|
||||||
echo "Fetching brands..."
|
|
||||||
BRANDS=$(curl -s "$SUPABASE_URL/rest/v1/brands?select=id,name" \
|
|
||||||
-H "$AUTH_HEADER")
|
|
||||||
echo "Brands: $BRANDS"
|
|
||||||
|
|
||||||
TUXEDO_ID=$(echo "$BRANDS" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
|
||||||
IRD_ID=$(echo "$BRANDS" | grep -o '"id":"[^"]*"' | tail -1 | cut -d'"' -f4)
|
|
||||||
|
|
||||||
echo "Tuxedo: $TUXEDO_ID"
|
|
||||||
echo "IRD: $IRD_ID"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Creating stops..."
|
|
||||||
|
|
||||||
STOP1=$(curl -s -X POST "$SUPABASE_URL/rest/v1/stops" \
|
|
||||||
-H "$AUTH_HEADER" \
|
|
||||||
-H "$CONTENT_TYPE" \
|
|
||||||
-H "Prefer: return=representation" \
|
|
||||||
-d '{
|
|
||||||
"brand_id": "'"$TUXEDO_ID"'",
|
|
||||||
"city": "Burlington",
|
|
||||||
"state": "NC",
|
|
||||||
"date": "2026-05-15",
|
|
||||||
"time": "8:00 AM – 2:00 PM",
|
|
||||||
"location": "102 W Front St, Burlington, NC",
|
|
||||||
"slug": "burlington-nc-2026-05-15",
|
|
||||||
"active": true
|
|
||||||
}')
|
|
||||||
echo "Stop 1: $STOP1"
|
|
||||||
|
|
||||||
STOP2=$(curl -s -X POST "$SUPABASE_URL/rest/v1/stops" \
|
|
||||||
-H "$AUTH_HEADER" \
|
|
||||||
-H "$CONTENT_TYPE" \
|
|
||||||
-H "Prefer: return=representation" \
|
|
||||||
-d '{
|
|
||||||
"brand_id": "'"$TUXEDO_ID"'",
|
|
||||||
"city": "Greensboro",
|
|
||||||
"state": "NC",
|
|
||||||
"date": "2026-05-16",
|
|
||||||
"time": "8:00 AM – 2:00 PM",
|
|
||||||
"location": "200 S Elm St, Greensboro, NC",
|
|
||||||
"slug": "greensboro-nc-2026-05-16",
|
|
||||||
"active": true
|
|
||||||
}')
|
|
||||||
echo "Stop 2: $STOP2"
|
|
||||||
|
|
||||||
STOP3=$(curl -s -X POST "$SUPABASE_URL/rest/v1/stops" \
|
|
||||||
-H "$AUTH_HEADER" \
|
|
||||||
-H "$CONTENT_TYPE" \
|
|
||||||
-H "Prefer: return=representation" \
|
|
||||||
-d '{
|
|
||||||
"brand_id": "'"$IRD_ID"'",
|
|
||||||
"city": "Fort Pierce",
|
|
||||||
"state": "FL",
|
|
||||||
"date": "2026-05-15",
|
|
||||||
"time": "7:00 AM – 1:00 PM",
|
|
||||||
"location": "1224 N US-1, Fort Pierce, FL",
|
|
||||||
"slug": "fort-pierce-fl-2026-05-15",
|
|
||||||
"active": true
|
|
||||||
}')
|
|
||||||
echo "Stop 3: $STOP3"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Creating products..."
|
|
||||||
|
|
||||||
PROD1=$(curl -s -X POST "$SUPABASE_URL/rest/v1/products" \
|
|
||||||
-H "$AUTH_HEADER" \
|
|
||||||
-H "$CONTENT_TYPE" \
|
|
||||||
-H "Prefer: return=representation" \
|
|
||||||
-d '{
|
|
||||||
"brand_id": "'"$TUXEDO_ID"'",
|
|
||||||
"name": "Sweet Corn",
|
|
||||||
"description": "Fresh picked white sweet corn, 8 ears per bag",
|
|
||||||
"price": 12,
|
|
||||||
"type": "Sweet Corn",
|
|
||||||
"active": true
|
|
||||||
}')
|
|
||||||
echo "Prod 1: $PROD1"
|
|
||||||
|
|
||||||
PROD2=$(curl -s -X POST "$SUPABASE_URL/rest/v1/products" \
|
|
||||||
-H "$AUTH_HEADER" \
|
|
||||||
-H "$CONTENT_TYPE" \
|
|
||||||
-H "Prefer: return=representation" \
|
|
||||||
-d '{
|
|
||||||
"brand_id": "'"$TUXEDO_ID"'",
|
|
||||||
"name": "Peaches",
|
|
||||||
"description": "Just-peeled freestone peaches, pint jar",
|
|
||||||
"price": 8,
|
|
||||||
"type": "Fruit",
|
|
||||||
"active": true
|
|
||||||
}')
|
|
||||||
echo "Prod 2: $PROD2"
|
|
||||||
|
|
||||||
PROD3=$(curl -s -X POST "$SUPABASE_URL/rest/v1/products" \
|
|
||||||
-H "$AUTH_HEADER" \
|
|
||||||
-H "$CONTENT_TYPE" \
|
|
||||||
-H "Prefer: return=representation" \
|
|
||||||
-d '{
|
|
||||||
"brand_id": "'"$TUXEDO_ID"'",
|
|
||||||
"name": "Corn & Peach Bundle",
|
|
||||||
"description": "Sweet corn + peaches combo, save $2",
|
|
||||||
"price": 18,
|
|
||||||
"type": "Bundle",
|
|
||||||
"active": true
|
|
||||||
}')
|
|
||||||
echo "Prod 3: $PROD3"
|
|
||||||
|
|
||||||
PROD4=$(curl -s -X POST "$SUPABASE_URL/rest/v1/products" \
|
|
||||||
-H "$AUTH_HEADER" \
|
|
||||||
-H "$CONTENT_TYPE" \
|
|
||||||
-H "Prefer: return=representation" \
|
|
||||||
-d '{
|
|
||||||
"brand_id": "'"$IRD_ID"'",
|
|
||||||
"name": "Navel Oranges",
|
|
||||||
"description": "Premium navel oranges, 4 lb bag",
|
|
||||||
"price": 15,
|
|
||||||
"type": "Citrus",
|
|
||||||
"active": true
|
|
||||||
}')
|
|
||||||
echo "Prod 4: $PROD4"
|
|
||||||
|
|
||||||
PROD5=$(curl -s -X POST "$SUPABASE_URL/rest/v1/products" \
|
|
||||||
-H "$AUTH_HEADER" \
|
|
||||||
-H "$CONTENT_TYPE" \
|
|
||||||
-H "Prefer: return=representation" \
|
|
||||||
-d '{
|
|
||||||
"brand_id": "'"$IRD_ID"'",
|
|
||||||
"name": "Grapefruit",
|
|
||||||
"description": "Ruby red grapefruit, 3 lb bag",
|
|
||||||
"price": 12,
|
|
||||||
"type": "Citrus",
|
|
||||||
"active": true
|
|
||||||
}')
|
|
||||||
echo "Prod 5: $PROD5"
|
|
||||||
|
|
||||||
PROD6=$(curl -s -X POST "$SUPABASE_URL/rest/v1/products" \
|
|
||||||
-H "$AUTH_HEADER" \
|
|
||||||
-H "$CONTENT_TYPE" \
|
|
||||||
-H "Prefer: return=representation" \
|
|
||||||
-d '{
|
|
||||||
"brand_id": "'"$IRD_ID"'",
|
|
||||||
"name": "Citrus Sampler",
|
|
||||||
"description": "Oranges, grapefruit & tangelos, 6 lb mix",
|
|
||||||
"price": 22,
|
|
||||||
"type": "Bundle",
|
|
||||||
"active": true
|
|
||||||
}')
|
|
||||||
echo "Prod 6: $PROD6"
|
|
||||||
|
|
||||||
P1_ID=$(echo "$PROD1" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
|
||||||
P2_ID=$(echo "$PROD2" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
|
||||||
P3_ID=$(echo "$PROD3" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
|
||||||
P4_ID=$(echo "$PROD4" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
|
||||||
P5_ID=$(echo "$PROD5" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
|
||||||
P6_ID=$(echo "$PROD6" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
|
||||||
|
|
||||||
S1_ID=$(echo "$STOP1" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
|
||||||
S2_ID=$(echo "$STOP2" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
|
||||||
S3_ID=$(echo "$STOP3" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Assigning products to stops..."
|
|
||||||
|
|
||||||
curl -s -X POST "$SUPABASE_URL/rest/v1/product_stops" \
|
|
||||||
-H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
|
|
||||||
-d '{"stop_id": "'"$S1_ID"'", "product_id": "'"$P1_ID"'"}' > /dev/null
|
|
||||||
curl -s -X POST "$SUPABASE_URL/rest/v1/product_stops" \
|
|
||||||
-H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
|
|
||||||
-d '{"stop_id": "'"$S1_ID"'", "product_id": "'"$P2_ID"'"}' > /dev/null
|
|
||||||
curl -s -X POST "$SUPABASE_URL/rest/v1/product_stops" \
|
|
||||||
-H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
|
|
||||||
-d '{"stop_id": "'"$S1_ID"'", "product_id": "'"$P3_ID"'"}' > /dev/null
|
|
||||||
curl -s -X POST "$SUPABASE_URL/rest/v1/product_stops" \
|
|
||||||
-H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
|
|
||||||
-d '{"stop_id": "'"$S2_ID"'", "product_id": "'"$P1_ID"'"}' > /dev/null
|
|
||||||
curl -s -X POST "$SUPABASE_URL/rest/v1/product_stops" \
|
|
||||||
-H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
|
|
||||||
-d '{"stop_id": "'"$S2_ID"'", "product_id": "'"$P3_ID"'"}' > /dev/null
|
|
||||||
curl -s -X POST "$SUPABASE_URL/rest/v1/product_stops" \
|
|
||||||
-H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
|
|
||||||
-d '{"stop_id": "'"$S3_ID"'", "product_id": "'"$P4_ID"'"}' > /dev/null
|
|
||||||
curl -s -X POST "$SUPABASE_URL/rest/v1/product_stops" \
|
|
||||||
-H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
|
|
||||||
-d '{"stop_id": "'"$S3_ID"'", "product_id": "'"$P5_ID"'"}' > /dev/null
|
|
||||||
curl -s -X POST "$SUPABASE_URL/rest/v1/product_stops" \
|
|
||||||
-H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
|
|
||||||
-d '{"stop_id": "'"$S3_ID"'", "product_id": "'"$P6_ID"'"}' > /dev/null
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Creating orders..."
|
|
||||||
|
|
||||||
ORDER1=$(curl -s -X POST "$SUPABASE_URL/rest/v1/orders" \
|
|
||||||
-H "$AUTH_HEADER" \
|
|
||||||
-H "$CONTENT_TYPE" \
|
|
||||||
-H "Prefer: return=representation" \
|
|
||||||
-d '{
|
|
||||||
"customer_name": "Sarah Mitchell",
|
|
||||||
"customer_email": "sarah.m@email.com",
|
|
||||||
"customer_phone": "(336) 555-0142",
|
|
||||||
"stop_id": "'"$S1_ID"'",
|
|
||||||
"status": "pending",
|
|
||||||
"subtotal": 32,
|
|
||||||
"discount_amount": 0,
|
|
||||||
"pickup_complete": false,
|
|
||||||
"payment_processor": "manual",
|
|
||||||
"payment_status": "paid"
|
|
||||||
}')
|
|
||||||
echo "Order 1: $ORDER1"
|
|
||||||
|
|
||||||
ORDER2=$(curl -s -X POST "$SUPABASE_URL/rest/v1/orders" \
|
|
||||||
-H "$AUTH_HEADER" \
|
|
||||||
-H "$CONTENT_TYPE" \
|
|
||||||
-H "Prefer: return=representation" \
|
|
||||||
-d '{
|
|
||||||
"customer_name": "James Rivera",
|
|
||||||
"customer_email": "jrivera@email.com",
|
|
||||||
"customer_phone": "(336) 555-0298",
|
|
||||||
"stop_id": "'"$S1_ID"'",
|
|
||||||
"status": "confirmed",
|
|
||||||
"subtotal": 22,
|
|
||||||
"discount_amount": 2,
|
|
||||||
"discount_reason": "First-time customer",
|
|
||||||
"pickup_complete": true,
|
|
||||||
"payment_processor": "manual",
|
|
||||||
"payment_status": "paid"
|
|
||||||
}')
|
|
||||||
echo "Order 2: $ORDER2"
|
|
||||||
|
|
||||||
ORDER3=$(curl -s -X POST "$SUPABASE_URL/rest/v1/orders" \
|
|
||||||
-H "$AUTH_HEADER" \
|
|
||||||
-H "$CONTENT_TYPE" \
|
|
||||||
-H "Prefer: return=representation" \
|
|
||||||
-d '{
|
|
||||||
"customer_name": "Lisa Chen",
|
|
||||||
"customer_phone": "(919) 555-0763",
|
|
||||||
"stop_id": "'"$S2_ID"'",
|
|
||||||
"status": "pending",
|
|
||||||
"subtotal": 12,
|
|
||||||
"pickup_complete": false,
|
|
||||||
"payment_processor": "manual",
|
|
||||||
"payment_status": "pending"
|
|
||||||
}')
|
|
||||||
echo "Order 3: $ORDER3"
|
|
||||||
|
|
||||||
ORDER4=$(curl -s -X POST "$SUPABASE_URL/rest/v1/orders" \
|
|
||||||
-H "$AUTH_HEADER" \
|
|
||||||
-H "$CONTENT_TYPE" \
|
|
||||||
-H "Prefer: return=representation" \
|
|
||||||
-d '{
|
|
||||||
"customer_name": "Marcus Thompson",
|
|
||||||
"customer_email": "mthompson@email.com",
|
|
||||||
"customer_phone": "(772) 555-0119",
|
|
||||||
"stop_id": "'"$S3_ID"'",
|
|
||||||
"status": "confirmed",
|
|
||||||
"subtotal": 37,
|
|
||||||
"pickup_complete": false,
|
|
||||||
"payment_processor": "venmo",
|
|
||||||
"payment_status": "paid",
|
|
||||||
"payment_transaction_id": "VEN-88420"
|
|
||||||
}')
|
|
||||||
echo "Order 4: $ORDER4"
|
|
||||||
|
|
||||||
ORDER5=$(curl -s -X POST "$SUPABASE_URL/rest/v1/orders" \
|
|
||||||
-H "$AUTH_HEADER" \
|
|
||||||
-H "$CONTENT_TYPE" \
|
|
||||||
-H "Prefer: return=representation" \
|
|
||||||
-d '{
|
|
||||||
"customer_name": "Amanda Foster",
|
|
||||||
"customer_email": "afoster@email.com",
|
|
||||||
"stop_id": "'"$S3_ID"'",
|
|
||||||
"status": "cancelled",
|
|
||||||
"subtotal": 15,
|
|
||||||
"pickup_complete": false,
|
|
||||||
"internal_notes": "Customer cancelled after hours, no show next day",
|
|
||||||
"payment_processor": "manual",
|
|
||||||
"payment_status": "refunded",
|
|
||||||
"refunded_amount": 15,
|
|
||||||
"refund_reason": "Customer cancelled"
|
|
||||||
}')
|
|
||||||
echo "Order 5: $ORDER5"
|
|
||||||
|
|
||||||
O1_ID=$(echo "$ORDER1" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
|
||||||
O2_ID=$(echo "$ORDER2" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
|
||||||
O3_ID=$(echo "$ORDER3" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
|
||||||
O4_ID=$(echo "$ORDER4" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
|
||||||
O5_ID=$(echo "$ORDER5" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Creating order items..."
|
|
||||||
|
|
||||||
curl -s -X POST "$SUPABASE_URL/rest/v1/order_items" \
|
|
||||||
-H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
|
|
||||||
-d '{"order_id": "'"$O1_ID"'", "product_id": "'"$P1_ID"'", "quantity": 2, "price": 12}' > /dev/null
|
|
||||||
curl -s -X POST "$SUPABASE_URL/rest/v1/order_items" \
|
|
||||||
-H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
|
|
||||||
-d '{"order_id": "'"$O1_ID"'", "product_id": "'"$P2_ID"'", "quantity": 1, "price": 8}' > /dev/null
|
|
||||||
|
|
||||||
curl -s -X POST "$SUPABASE_URL/rest/v1/order_items" \
|
|
||||||
-H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
|
|
||||||
-d '{"order_id": "'"$O2_ID"'", "product_id": "'"$P3_ID"'", "quantity": 1, "price": 18}' > /dev/null
|
|
||||||
curl -s -X POST "$SUPABASE_URL/rest/v1/order_items" \
|
|
||||||
-H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
|
|
||||||
-d '{"order_id": "'"$O2_ID"'", "product_id": "'"$P1_ID"'", "quantity": 2, "price": 12}' > /dev/null
|
|
||||||
|
|
||||||
curl -s -X POST "$SUPABASE_URL/rest/v1/order_items" \
|
|
||||||
-H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
|
|
||||||
-d '{"order_id": "'"$O3_ID"'", "product_id": "'"$P1_ID"'", "quantity": 1, "price": 12}' > /dev/null
|
|
||||||
|
|
||||||
curl -s -X POST "$SUPABASE_URL/rest/v1/order_items" \
|
|
||||||
-H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
|
|
||||||
-d '{"order_id": "'"$O4_ID"'", "product_id": "'"$P4_ID"'", "quantity": 2, "price": 15}' > /dev/null
|
|
||||||
curl -s -X POST "$SUPABASE_URL/rest/v1/order_items" \
|
|
||||||
-H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
|
|
||||||
-d '{"order_id": "'"$O4_ID"'", "product_id": "'"$P5_ID"'", "quantity": 1, "price": 12}' > /dev/null
|
|
||||||
|
|
||||||
curl -s -X POST "$SUPABASE_URL/rest/v1/order_items" \
|
|
||||||
-H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
|
|
||||||
-d '{"order_id": "'"$O5_ID"'", "product_id": "'"$P4_ID"'", "quantity": 1, "price": 15}' > /dev/null
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Done! Seed data created:"
|
|
||||||
echo " 3 stops (Burlington NC, Greensboro NC, Fort Pierce FL)"
|
|
||||||
echo " 6 products (3 Tuxedo Corn, 3 Indian River Direct)"
|
|
||||||
echo " 5 orders with items"
|
|
||||||
echo ""
|
|
||||||
echo "Quick test URLs:"
|
|
||||||
echo " /admin/orders"
|
|
||||||
echo " /admin/products"
|
|
||||||
echo " /admin/stops"
|
|
||||||
@@ -1,343 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
seed_tuxedo_tour.py
|
|
||||||
|
|
||||||
Parses Tuxedo_Corn_2026_Tour_Schedule-3.xlsx and seeds the `stops` table for
|
|
||||||
the Tuxedo brand via the admin_create_stops_batch RPC.
|
|
||||||
|
|
||||||
Skips:
|
|
||||||
- Title / subtitle / legend rows (rows 1-3)
|
|
||||||
- Week header rows (col A = "Wk N", col D-J empty)
|
|
||||||
- Cross-Dock / Monday OFF rows (col D contains "OFF" or "Cross-Dock")
|
|
||||||
|
|
||||||
Joins with the Stop Directory sheet to enrich each stop with:
|
|
||||||
- address, phone, contact
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
python3 scripts/seed_tuxedo_tour.py --dry-run # show what would be inserted
|
|
||||||
python3 scripts/seed_tuxedo_tour.py # actually insert
|
|
||||||
|
|
||||||
Requires:
|
|
||||||
- supabase CLI linked to project wnzkhezyhnfzhkhiflrp
|
|
||||||
"""
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from openpyxl import load_workbook
|
|
||||||
|
|
||||||
TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de"
|
|
||||||
YEAR = 2026
|
|
||||||
|
|
||||||
MONTH_MAP = {
|
|
||||||
"Jan": "01", "Feb": "02", "Mar": "03", "Apr": "04",
|
|
||||||
"May": "05", "Jun": "06", "Jul": "07", "Aug": "08",
|
|
||||||
"Sep": "09", "Oct": "10", "Nov": "11", "Dec": "12",
|
|
||||||
}
|
|
||||||
|
|
||||||
DEFAULT_XLSX = "Tuxedo_Corn_2026_Tour_Schedule-3.xlsx" # run from repo root; override with --xlsx /path/to/file.xlsx
|
|
||||||
|
|
||||||
|
|
||||||
def parse_excel_date(s):
|
|
||||||
"""'Jul 22' -> '2026-07-22'"""
|
|
||||||
if not s:
|
|
||||||
return None
|
|
||||||
m = re.match(r"^([A-Za-z]{3})\s+(\d{1,2})$", str(s).strip())
|
|
||||||
if not m:
|
|
||||||
return None
|
|
||||||
mm = MONTH_MAP.get(m.group(1))
|
|
||||||
if not mm:
|
|
||||||
return None
|
|
||||||
return f"{YEAR}-{mm}-{int(m.group(2)):02d}"
|
|
||||||
|
|
||||||
|
|
||||||
def parse_time_range(s):
|
|
||||||
"""'10:00 AM - 1:00 PM' -> '10:00 AM' (start time)"""
|
|
||||||
if not s:
|
|
||||||
return ""
|
|
||||||
cleaned = re.sub(r"[–—]", "-", str(s)).strip()
|
|
||||||
cleaned = re.sub(r"\s+", " ", cleaned)
|
|
||||||
m = re.match(r"^(\d{1,2}:\d{2}\s*[AP]M)", cleaned, re.IGNORECASE)
|
|
||||||
return m.group(1).upper().replace(" ", " ") if m else cleaned
|
|
||||||
|
|
||||||
|
|
||||||
def split_city_state(s):
|
|
||||||
"""'Cheyenne, WY' -> ('Cheyenne', 'WY')"""
|
|
||||||
if not s:
|
|
||||||
return "", ""
|
|
||||||
parts = [p.strip() for p in str(s).split(",")]
|
|
||||||
if len(parts) == 1:
|
|
||||||
return parts[0], ""
|
|
||||||
return parts[0], parts[1]
|
|
||||||
|
|
||||||
|
|
||||||
def slugify(s):
|
|
||||||
s = (s or "").lower()
|
|
||||||
s = re.sub(r"[^a-z0-9]+", "-", s)
|
|
||||||
return s.strip("-")
|
|
||||||
|
|
||||||
|
|
||||||
def is_week_header(row):
|
|
||||||
a = str(row[0] or "").strip()
|
|
||||||
d = str(row[3] or "").strip()
|
|
||||||
return re.match(r"^Wk\s", a) and d == ""
|
|
||||||
|
|
||||||
|
|
||||||
def is_off_row(row):
|
|
||||||
d = str(row[3] or "").strip()
|
|
||||||
return "OFF" in d or "Cross-Dock" in d or "Cross‑Dock" in d
|
|
||||||
|
|
||||||
|
|
||||||
def is_data_row(row):
|
|
||||||
d = str(row[3] or "").strip()
|
|
||||||
e = str(row[4] or "").strip()
|
|
||||||
if not d or not e:
|
|
||||||
return False
|
|
||||||
if "," not in e:
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def load(xlsx_path):
|
|
||||||
wb = load_workbook(xlsx_path, data_only=True)
|
|
||||||
schedule = wb["Full Schedule"]
|
|
||||||
directory = wb["Stop Directory"]
|
|
||||||
|
|
||||||
# Build Stop Directory lookup: (truck, host_normalized) -> {address, phone, contact, ...}
|
|
||||||
dir_map = {}
|
|
||||||
for row in directory.iter_rows(min_row=2, values_only=True):
|
|
||||||
truck = str(row[0] or "").strip()
|
|
||||||
city = str(row[1] or "").strip()
|
|
||||||
state = str(row[2] or "").strip()
|
|
||||||
host = str(row[3] or "").strip()
|
|
||||||
address = str(row[4] or "").strip()
|
|
||||||
phone = str(row[5] or "").strip()
|
|
||||||
contact = str(row[6] or "").strip()
|
|
||||||
if not truck or not host:
|
|
||||||
continue
|
|
||||||
key = f"{truck}|{host.lower()}"
|
|
||||||
dir_map[key] = {
|
|
||||||
"city": city, "state": state, "host": host,
|
|
||||||
"address": address, "phone": phone, "contact": contact,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Read Full Schedule (skip first 3 title/subtitle/legend rows)
|
|
||||||
stops = []
|
|
||||||
skipped = {"weekHeader": 0, "off": 0, "invalid": 0}
|
|
||||||
for row in schedule.iter_rows(min_row=4, values_only=True):
|
|
||||||
# Trim to 10 cols
|
|
||||||
cells = [("" if v is None else str(v).strip()) for v in row[:10]]
|
|
||||||
if is_week_header(cells):
|
|
||||||
skipped["weekHeader"] += 1
|
|
||||||
continue
|
|
||||||
if is_off_row(cells):
|
|
||||||
skipped["off"] += 1
|
|
||||||
continue
|
|
||||||
if not is_data_row(cells):
|
|
||||||
skipped["invalid"] += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
wk, region, date_text, day, city_state, host, time, truck, status, notes = cells
|
|
||||||
date_iso = parse_excel_date(date_text)
|
|
||||||
if not date_iso:
|
|
||||||
skipped["invalid"] += 1
|
|
||||||
continue
|
|
||||||
city, state = split_city_state(city_state)
|
|
||||||
if not city:
|
|
||||||
skipped["invalid"] += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Enrich from directory
|
|
||||||
dir_key = f"{truck}|{host.lower()}"
|
|
||||||
d = dir_map.get(dir_key)
|
|
||||||
|
|
||||||
stops.append({
|
|
||||||
"week": wk,
|
|
||||||
"region": region,
|
|
||||||
"date": date_iso,
|
|
||||||
"day": day,
|
|
||||||
"city": city,
|
|
||||||
"state": state or (d["state"] if d else ""),
|
|
||||||
"location": host,
|
|
||||||
"time": parse_time_range(time),
|
|
||||||
"time_range": time,
|
|
||||||
"truck": truck,
|
|
||||||
"status_text": status,
|
|
||||||
"notes": notes,
|
|
||||||
"address": d["address"] if d and d["address"] else None,
|
|
||||||
"phone": d["phone"] if d and d["phone"] else None,
|
|
||||||
"contact": d["contact"] if d and d["contact"] else None,
|
|
||||||
})
|
|
||||||
|
|
||||||
return stops, skipped, len(dir_map)
|
|
||||||
|
|
||||||
|
|
||||||
def assign_slugs(stops, dry_run):
|
|
||||||
used = set()
|
|
||||||
if not dry_run:
|
|
||||||
out = subprocess.run(
|
|
||||||
["supabase", "db", "query", "--linked",
|
|
||||||
f"SELECT slug FROM stops WHERE brand_id = '{TUXEDO_BRAND_ID}';"],
|
|
||||||
capture_output=True, text=True, timeout=120,
|
|
||||||
)
|
|
||||||
# Parse the table output - slugs are in second column between │
|
|
||||||
for m in re.finditer(r"│\s*([a-z0-9][a-z0-9-]*)\s*│", out.stdout):
|
|
||||||
used.add(m.group(1))
|
|
||||||
|
|
||||||
for s in stops:
|
|
||||||
base = f"{slugify(s['city'])}-{s['date']}"
|
|
||||||
slug = base
|
|
||||||
n = 0
|
|
||||||
while slug in used:
|
|
||||||
n += 1
|
|
||||||
slug = f"{base}-{n}"
|
|
||||||
used.add(slug)
|
|
||||||
s["slug"] = slug
|
|
||||||
|
|
||||||
|
|
||||||
def to_rpc_row(s):
|
|
||||||
return {
|
|
||||||
"city": s["city"],
|
|
||||||
"state": s["state"],
|
|
||||||
"location": s["location"],
|
|
||||||
"date": f"{s['date']} 00:00:00+00",
|
|
||||||
"time": s["time"],
|
|
||||||
"address": s["address"],
|
|
||||||
"zip": None,
|
|
||||||
"cutoff_time": None,
|
|
||||||
# active=true so the stops appear on the public storefront immediately.
|
|
||||||
# Matches the behavior of publishStop in src/actions/stops.ts.
|
|
||||||
"active": True,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def build_payload_json(batch):
|
|
||||||
"""Build a clean JSON string for use in a SQL file."""
|
|
||||||
return json.dumps(batch, ensure_ascii=False)
|
|
||||||
|
|
||||||
|
|
||||||
def insert_batch(batch):
|
|
||||||
"""Write SQL to a temp file and execute via --file to avoid shell escaping."""
|
|
||||||
payload_json = build_payload_json(batch)
|
|
||||||
sql = (
|
|
||||||
f"SELECT admin_create_stops_batch("
|
|
||||||
f"'{TUXEDO_BRAND_ID}'::uuid, "
|
|
||||||
f"$${payload_json}$$::jsonb);\n"
|
|
||||||
)
|
|
||||||
# Write to temp file
|
|
||||||
tmp_path = Path("/tmp/seed_tuxedo_tour.sql")
|
|
||||||
tmp_path.write_text(sql, encoding="utf-8")
|
|
||||||
try:
|
|
||||||
proc = subprocess.run(
|
|
||||||
["supabase", "db", "query", "--linked", "--file", str(tmp_path)],
|
|
||||||
capture_output=True, text=True, timeout=300,
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
tmp_path.unlink(missing_ok=True)
|
|
||||||
if proc.returncode != 0:
|
|
||||||
raise RuntimeError(f"RPC failed: {proc.stderr[:800]}")
|
|
||||||
return proc.stdout
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
ap = argparse.ArgumentParser()
|
|
||||||
ap.add_argument("--dry-run", action="store_true")
|
|
||||||
ap.add_argument("--xlsx", default=DEFAULT_XLSX)
|
|
||||||
args = ap.parse_args()
|
|
||||||
|
|
||||||
if not Path(args.xlsx).exists():
|
|
||||||
sys.exit(f"XLSX not found: {args.xlsx}")
|
|
||||||
|
|
||||||
stops, skipped, dir_count = load(args.xlsx)
|
|
||||||
assign_slugs(stops, dry_run=args.dry_run)
|
|
||||||
|
|
||||||
print(f"\nParsed {len(stops)} stops "
|
|
||||||
f"(skipped: {skipped['weekHeader']} week-headers, "
|
|
||||||
f"{skipped['off']} OFF days, {skipped['invalid']} invalid)")
|
|
||||||
print(f"Stop Directory: {dir_count} entries loaded for enrichment\n")
|
|
||||||
|
|
||||||
if not stops:
|
|
||||||
sys.exit("No stops to insert.")
|
|
||||||
|
|
||||||
print("Sample (first 3):")
|
|
||||||
for s in stops[:3]:
|
|
||||||
print(f" {s['date']} {s['time']:10s} | {s['city']:18s}, {s['state']:2s} | "
|
|
||||||
f"{s['location'][:35]:35s} | {s['truck']} | {s['status_text']} | {s['slug']}")
|
|
||||||
if s["notes"]:
|
|
||||||
print(f" notes: {s['notes'][:120]}")
|
|
||||||
if s["address"]:
|
|
||||||
print(f" addr: {s['address']} ph: {s['phone']} ctc: {s['contact']}")
|
|
||||||
print()
|
|
||||||
|
|
||||||
# Show counts by week and region
|
|
||||||
by_week = {}
|
|
||||||
by_region = {}
|
|
||||||
by_truck = {}
|
|
||||||
for s in stops:
|
|
||||||
by_week[s["week"]] = by_week.get(s["week"], 0) + 1
|
|
||||||
by_region[s["region"]] = by_region.get(s["region"], 0) + 1
|
|
||||||
by_truck[s["truck"]] = by_truck.get(s["truck"], 0) + 1
|
|
||||||
print("By week:", dict(sorted(by_week.items())))
|
|
||||||
print("By region:", by_region)
|
|
||||||
print("By truck:", by_truck)
|
|
||||||
print()
|
|
||||||
|
|
||||||
# Date range
|
|
||||||
dates = sorted(s["date"] for s in stops)
|
|
||||||
print(f"Date range: {dates[0]} to {dates[-1]}\n")
|
|
||||||
|
|
||||||
if args.dry_run:
|
|
||||||
batches = (len(stops) + 49) // 50
|
|
||||||
print(f"[DRY RUN] Would insert {len(stops)} stops in {batches} batch(es) of 50.")
|
|
||||||
return
|
|
||||||
|
|
||||||
BATCH = 50
|
|
||||||
total = 0
|
|
||||||
batches = (len(stops) + BATCH - 1) // BATCH
|
|
||||||
for i in range(0, len(stops), BATCH):
|
|
||||||
batch = [to_rpc_row(s) for s in stops[i:i + BATCH]]
|
|
||||||
bnum = i // BATCH + 1
|
|
||||||
sys.stdout.write(f" Inserting batch {bnum}/{batches} ({len(batch)} stops)... ")
|
|
||||||
sys.stdout.flush()
|
|
||||||
try:
|
|
||||||
insert_batch(batch)
|
|
||||||
total += len(batch)
|
|
||||||
print("OK")
|
|
||||||
except Exception as e:
|
|
||||||
print("FAIL")
|
|
||||||
print(f" {e}")
|
|
||||||
|
|
||||||
# The batch RPC hardcodes status='draft' on insert. The Tuxedo storefront
|
|
||||||
# page only filters on active=true (not status), so active=true is enough
|
|
||||||
# to make stops visible. But for consistency with the publishStop server
|
|
||||||
# action — which sets both — flip status to 'active' for the rows we just
|
|
||||||
# inserted. Slug-based so we only touch stops from this run, not the
|
|
||||||
# pre-existing "Olathe" test stop.
|
|
||||||
if total > 0:
|
|
||||||
slugs = [s["slug"] for s in stops]
|
|
||||||
# Build a safe IN list (slug is a text column)
|
|
||||||
slug_list = ", ".join(f"'{slug.replace(chr(39), chr(39)+chr(39))}'" for slug in slugs)
|
|
||||||
publish_sql = (
|
|
||||||
f"UPDATE stops SET status = 'active' "
|
|
||||||
f"WHERE brand_id = '{TUXEDO_BRAND_ID}' "
|
|
||||||
f"AND slug IN ({slug_list});"
|
|
||||||
)
|
|
||||||
tmp = Path("/tmp/seed_tuxedo_publish.sql")
|
|
||||||
tmp.write_text(publish_sql, encoding="utf-8")
|
|
||||||
try:
|
|
||||||
subprocess.run(
|
|
||||||
["supabase", "db", "query", "--linked", "--file", str(tmp)],
|
|
||||||
capture_output=True, text=True, timeout=120,
|
|
||||||
)
|
|
||||||
print(f"\n Published {total} stops (status -> 'active').")
|
|
||||||
finally:
|
|
||||||
tmp.unlink(missing_ok=True)
|
|
||||||
|
|
||||||
print(f"\nDone. Inserted {total}/{len(stops)} stops for Tuxedo brand.")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
import { createClient } from "@supabase/supabase-js";
|
|
||||||
import { readFileSync } from "fs";
|
|
||||||
import { fileURLToPath } from "url";
|
|
||||||
import path from "path";
|
|
||||||
|
|
||||||
// Load .env.local manually
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
||||||
const envPath = path.join(__dirname, "..", ".env.local");
|
|
||||||
const envContent = readFileSync(envPath, "utf8");
|
|
||||||
envContent.split("\n").forEach((line) => {
|
|
||||||
const [key, ...rest] = line.split("=");
|
|
||||||
if (key && rest.length && key.trim() && !key.trim().startsWith("#")) {
|
|
||||||
process.env[key.trim()] = rest.join("=").trim().replace(/^"|"$/g, "");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
|
||||||
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
|
||||||
|
|
||||||
if (!supabaseUrl || !supabaseServiceKey) {
|
|
||||||
console.error("Missing env vars");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const supabase = createClient(supabaseUrl, supabaseServiceKey);
|
|
||||||
|
|
||||||
async function uploadTuxedoHeroVideo() {
|
|
||||||
const filePath = "/Users/kylemartinez/Downloads/062624TuxedoCorn 001.mp4";
|
|
||||||
const fileBuffer = readFileSync(filePath);
|
|
||||||
const fileSizeMB = (fileBuffer.byteLength / 1024 / 1024).toFixed(2);
|
|
||||||
console.log(`File: ${filePath} (${fileSizeMB} MB)`);
|
|
||||||
|
|
||||||
// Ensure public videos bucket exists
|
|
||||||
const { data: buckets, error: listErr } = await supabase.storage.listBuckets();
|
|
||||||
if (listErr) { console.error("List buckets error:", listErr.message); process.exit(1); }
|
|
||||||
|
|
||||||
const existing = buckets?.find((b) => b.name === "videos");
|
|
||||||
if (!existing) {
|
|
||||||
console.log("Creating 'videos' bucket (public)...");
|
|
||||||
const { error: createErr } = await supabase.storage.createBucket("videos", { public: true });
|
|
||||||
if (createErr) { console.error("Create bucket error:", createErr.message); process.exit(1); }
|
|
||||||
console.log("Bucket 'videos' created.");
|
|
||||||
} else {
|
|
||||||
console.log(`'videos' bucket exists (public: ${existing.public}).`);
|
|
||||||
if (!existing.public) {
|
|
||||||
const { error: updErr } = await supabase.storage.updateBucket("videos", { public: true });
|
|
||||||
if (updErr) console.warn("Could not update bucket to public:", updErr.message);
|
|
||||||
else console.log("Bucket updated to public.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upload
|
|
||||||
console.log("Uploading tuxedo-hero.mp4...");
|
|
||||||
const { data: uploadData, error: uploadErr } = await supabase.storage
|
|
||||||
.from("videos")
|
|
||||||
.upload("tuxedo-hero.mp4", fileBuffer, {
|
|
||||||
contentType: "video/mp4",
|
|
||||||
upsert: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (uploadErr) {
|
|
||||||
console.error("Upload error:", uploadErr.message);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("Upload OK:", JSON.stringify(uploadData));
|
|
||||||
|
|
||||||
// Public URL
|
|
||||||
const { data: urlData } = supabase.storage.from("videos").getPublicUrl("tuxedo-hero.mp4");
|
|
||||||
const url = urlData.publicUrl;
|
|
||||||
console.log(`\n✅ Public URL:\n${url}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
uploadTuxedoHeroVideo().catch((e) => { console.error(e); process.exit(1); });
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { config } from "dotenv";
|
|
||||||
config({ path: ".env.local" });
|
|
||||||
import pg from "pg";
|
|
||||||
const { Pool } = pg;
|
|
||||||
async function main() {
|
|
||||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
|
||||||
const r = await pool.query(`SELECT id, email, "emailVerified" FROM neon_auth.user WHERE email = 'admin@test.com'`);
|
|
||||||
console.log(JSON.stringify(r.rows, null, 2));
|
|
||||||
await pool.end();
|
|
||||||
}
|
|
||||||
main();
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
/**
|
|
||||||
* verify-stop-fns.js
|
|
||||||
*
|
|
||||||
* Confirms `admin_create_stop` and `admin_create_stops_batch` exist in the
|
|
||||||
* live database with the exact signatures the client uses, and that the
|
|
||||||
* PostgREST schema cache has them registered.
|
|
||||||
*
|
|
||||||
* Run: node scripts/verify-stop-fns.js
|
|
||||||
*
|
|
||||||
* Replaces the older `check-stop-fns*.js` scripts (left in the repo for
|
|
||||||
* history; this one is the canonical post-fix verification).
|
|
||||||
*
|
|
||||||
* Exits 0 on success, 1 if any check fails.
|
|
||||||
*/
|
|
||||||
|
|
||||||
require("dotenv").config({ path: require("path").resolve(__dirname, "../.env.local") });
|
|
||||||
|
|
||||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
|
||||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
|
||||||
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
|
||||||
|
|
||||||
if (!url || !serviceKey || !anonKey) {
|
|
||||||
console.error("Missing NEXT_PUBLIC_SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY / NEXT_PUBLIC_SUPABASE_ANON_KEY in .env.local");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const SERVICE_HEADERS = {
|
|
||||||
apikey: serviceKey,
|
|
||||||
Authorization: `Bearer ${serviceKey}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
};
|
|
||||||
|
|
||||||
const ANON_HEADERS = {
|
|
||||||
apikey: anonKey,
|
|
||||||
Authorization: `Bearer ${anonKey}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
};
|
|
||||||
|
|
||||||
let failed = 0;
|
|
||||||
const fail = (msg) => { console.error("✗ " + msg); failed += 1; };
|
|
||||||
const pass = (msg) => console.log("✓ " + msg);
|
|
||||||
|
|
||||||
async function rpc(name, body, headers = SERVICE_HEADERS) {
|
|
||||||
const r = await fetch(`${url}/rest/v1/rpc/${name}`, {
|
|
||||||
method: "POST",
|
|
||||||
headers,
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
const text = await r.text();
|
|
||||||
let parsed;
|
|
||||||
try { parsed = JSON.parse(text); } catch { parsed = text.slice(0, 200); }
|
|
||||||
return { status: r.status, body: parsed };
|
|
||||||
}
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
console.log(`\nVerifying stop RPCs against ${url}\n`);
|
|
||||||
|
|
||||||
// ── 1. admin_create_stop — empty body (expect 400/422, NOT PGRST202) ─────
|
|
||||||
// PGRST202 = function not in schema cache. We want a 400-style validation
|
|
||||||
// error here, which proves PostgREST knows the function exists.
|
|
||||||
console.log("1. admin_create_stop signature check (PostgREST cache)");
|
|
||||||
const probe1 = await rpc("admin_create_stop", {});
|
|
||||||
if (probe1.status === 404) {
|
|
||||||
fail("admin_create_stop returned 404 — function not found (PGRST202 cache miss). " +
|
|
||||||
"Did you apply migration 147 and run `NOTIFY pgrst, 'reload schema'`?");
|
|
||||||
} else if (probe1.status >= 500) {
|
|
||||||
fail(`admin_create_stop returned ${probe1.status} — server error. Body: ${JSON.stringify(probe1.body)}`);
|
|
||||||
} else {
|
|
||||||
pass(`admin_create_stop reachable in PostgREST (status ${probe1.status})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 2. admin_create_stop — happy path with a real (but harmless) brand ──
|
|
||||||
console.log("\n2. admin_create_stop happy-path call (anon key)");
|
|
||||||
const probe2 = await rpc(
|
|
||||||
"admin_create_stop",
|
|
||||||
{
|
|
||||||
p_brand_id: "00000000-0000-0000-0000-000000000000",
|
|
||||||
p_city: "verify-script-city",
|
|
||||||
p_state: "CO",
|
|
||||||
p_location: "verification stop",
|
|
||||||
p_date: "2099-12-31",
|
|
||||||
p_time: "08:00 AM – 02:00 PM",
|
|
||||||
p_address: "123 Verify St",
|
|
||||||
p_zip: "80000",
|
|
||||||
p_cutoff_time: "08:00", // time-only — should be combined with date
|
|
||||||
p_active: false,
|
|
||||||
},
|
|
||||||
ANON_HEADERS
|
|
||||||
);
|
|
||||||
if (probe2.status === 404) {
|
|
||||||
fail("admin_create_stop not callable via anon key. " +
|
|
||||||
"GRANT EXECUTE missing? Body: " + JSON.stringify(probe2.body));
|
|
||||||
} else if (probe2.status >= 400) {
|
|
||||||
// Expected: brand_id is the zero UUID (foreign key violation). That's fine —
|
|
||||||
// it proves the function executed past type validation.
|
|
||||||
const msg = JSON.stringify(probe2.body);
|
|
||||||
if (/foreign key|violates/i.test(msg)) {
|
|
||||||
pass(`admin_create_stop ran end-to-end (got expected FK violation: ${msg.slice(0, 120)}...)`);
|
|
||||||
} else {
|
|
||||||
fail(`admin_create_stop failed unexpectedly (${probe2.status}): ${msg}`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
pass(`admin_create_stop returned success: ${JSON.stringify(probe2.body)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 3. admin_create_stops_batch — same checks ───────────────────────────
|
|
||||||
console.log("\n3. admin_create_stops_batch signature check");
|
|
||||||
const probe3 = await rpc("admin_create_stops_batch", { p_brand_id: null, p_stops: [] });
|
|
||||||
if (probe3.status === 404) {
|
|
||||||
fail("admin_create_stops_batch returned 404 — function not in schema cache.");
|
|
||||||
} else {
|
|
||||||
pass(`admin_create_stops_batch reachable (status ${probe3.status})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 4. OpenAPI introspection — confirm functions are exposed ────────────
|
|
||||||
console.log("\n4. OpenAPI introspection");
|
|
||||||
const openApi = await fetch(`${url}/rest/v1/`, { headers: ANON_HEADERS });
|
|
||||||
const text = await openApi.text();
|
|
||||||
const hasSingle = /admin_create_stop\b/.test(text);
|
|
||||||
const hasBatch = /admin_create_stops_batch\b/.test(text);
|
|
||||||
if (hasSingle) pass("admin_create_stop appears in OpenAPI spec");
|
|
||||||
else fail("admin_create_stop NOT in OpenAPI spec — PostgREST hasn't seen it yet");
|
|
||||||
if (hasBatch) pass("admin_create_stops_batch appears in OpenAPI spec");
|
|
||||||
else fail("admin_create_stops_batch NOT in OpenAPI spec");
|
|
||||||
|
|
||||||
console.log(`\n${failed === 0 ? "✓ All checks passed" : `✗ ${failed} check(s) failed`}\n`);
|
|
||||||
process.exit(failed === 0 ? 0 : 1);
|
|
||||||
})().catch((e) => {
|
|
||||||
console.error("verify-stop-fns.js crashed:", e);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -273,7 +273,7 @@ export async function createAdminUser(
|
|||||||
// admin endpoint requires the caller to have role='admin' in
|
// 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" ||
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
"use server";
|
|
||||||
|
|
||||||
import { supabase } from "@/lib/supabase";
|
|
||||||
|
|
||||||
export type AIAuthConfig = {
|
|
||||||
provider: string;
|
|
||||||
api_key: string;
|
|
||||||
organization_id: string;
|
|
||||||
base_url: string;
|
|
||||||
model: string;
|
|
||||||
max_tokens: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function getAIPreferences(brandId: string): Promise<{
|
|
||||||
api_key?: string;
|
|
||||||
organization_id?: string;
|
|
||||||
base_url?: string;
|
|
||||||
model?: string;
|
|
||||||
max_tokens?: number;
|
|
||||||
} | null> {
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from("brand_ai_settings")
|
|
||||||
.select("api_key, organization_id, base_url, model, max_tokens")
|
|
||||||
.eq("brand_id", brandId)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (error || !data) return null;
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function saveAIPreferences(
|
|
||||||
brandId: string,
|
|
||||||
config: AIAuthConfig
|
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
|
||||||
const result = await supabase
|
|
||||||
.from("brand_ai_settings")
|
|
||||||
.upsert({
|
|
||||||
brand_id: brandId,
|
|
||||||
provider: config.provider,
|
|
||||||
api_key: config.api_key || null,
|
|
||||||
organization_id: config.organization_id || null,
|
|
||||||
base_url: config.base_url || null,
|
|
||||||
model: config.model || "gpt-4o-mini",
|
|
||||||
max_tokens: config.max_tokens || 4000,
|
|
||||||
updated_at: new Date().toISOString(),
|
|
||||||
}) as { data: unknown; error: { message: string } | null };
|
|
||||||
|
|
||||||
if (result.error) return { success: false, error: result.error.message };
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function testAIConnection(config: AIAuthConfig): Promise<{ ok: boolean; message: string }> {
|
|
||||||
if (!config.api_key?.trim()) {
|
|
||||||
return { ok: false, message: "API key is required" };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const baseUrl = config.base_url?.trim() || "https://api.openai.com/v1";
|
|
||||||
const url = `${baseUrl.replace(/\/$/, "")}/models`;
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${config.api_key}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
return { ok: true, message: "Connection successful! Your API key is valid." };
|
|
||||||
} else if (response.status === 401) {
|
|
||||||
return { ok: false, message: "Invalid API key. Please check and try again." };
|
|
||||||
} else {
|
|
||||||
return { ok: false, message: `Error: ${response.status} ${response.statusText}` };
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return { ok: false, message: "Could not connect. Check your network and API key." };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -68,9 +68,10 @@ export type ConversionFunnel = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Aggregate KPIs over a date window. Replaces the
|
* 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,
|
||||||
|
|||||||
@@ -243,14 +243,24 @@ export async function getBrandSettings(brandId: string): Promise<GetBrandSetting
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Public version for storefront pages — uses slug, no auth required
|
// Public version for storefront pages — uses slug, no auth required.
|
||||||
|
//
|
||||||
|
// Inlined as a join against `brands` + `brand_settings` LEFT JOIN
|
||||||
|
// `wholesale_settings` instead of calling `get_brand_settings_by_slug`.
|
||||||
|
// That RPC is referenced from this code path but is not defined in any
|
||||||
|
// shipped migration, so the function-on-the-server call would throw
|
||||||
|
// `function get_brand_settings_by_slug(unknown) does not exist` and the
|
||||||
|
// storefront would silently fall back to defaults. The inlined query
|
||||||
|
// has the same shape and never fails because of a missing RPC.
|
||||||
export async function getBrandSettingsPublic(brandSlug: string): Promise<GetBrandSettingsResult & { wholesaleEnabled?: boolean | null }> {
|
export async function getBrandSettingsPublic(brandSlug: string): Promise<GetBrandSettingsResult & { wholesaleEnabled?: boolean | null }> {
|
||||||
// Wrapped in try/catch so a build-time DB outage (ECONNREFUSED) doesn't
|
|
||||||
// crash the prerender — the page just falls back to its default brand
|
|
||||||
// name and revalidates from a real request later.
|
|
||||||
try {
|
try {
|
||||||
const { rows } = await pool.query<BrandSettings & { wholesale_enabled?: boolean | null }>(
|
const { rows } = await pool.query<BrandSettings & { wholesale_enabled?: boolean | null }>(
|
||||||
"SELECT * FROM get_brand_settings_by_slug($1)",
|
`SELECT bs.*, b.name AS brand_name, ws.wholesale_enabled
|
||||||
|
FROM brands b
|
||||||
|
JOIN brand_settings bs ON bs.brand_id = b.id
|
||||||
|
LEFT JOIN wholesale_settings ws ON ws.brand_id = b.id
|
||||||
|
WHERE b.slug = $1
|
||||||
|
LIMIT 1`,
|
||||||
[brandSlug],
|
[brandSlug],
|
||||||
);
|
);
|
||||||
const data = rows[0];
|
const data = rows[0];
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -76,4 +76,31 @@ export async function getSyncLog(brandId: string): Promise<{
|
|||||||
);
|
);
|
||||||
|
|
||||||
return { success: true, logs };
|
return { success: true, logs };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pending-item count for the dashboard widget badge. Used by
|
||||||
|
* `SquareSyncWidget` so it can refresh without re-running the full
|
||||||
|
* sync log query. Returns 0 when the caller isn't authorized — never
|
||||||
|
* throws, so callers can render "0" while the auth check settles.
|
||||||
|
*/
|
||||||
|
export async function getSquareQueueCount(brandId: string): Promise<number> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return 0;
|
||||||
|
try {
|
||||||
|
assertBrandAccess(adminUser, brandId);
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query<{ count: string }>(
|
||||||
|
`SELECT COUNT(*)::text AS count
|
||||||
|
FROM square_sync_queue
|
||||||
|
WHERE brand_id = $1 AND status = 'pending'`,
|
||||||
|
[brandId],
|
||||||
|
);
|
||||||
|
return Number(rows[0]?.count ?? 0);
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public storefront data accessors.
|
||||||
|
*
|
||||||
|
* Each function hits Postgres directly via the shared `pg` pool from
|
||||||
|
* `@/lib/db` and is safe to call from public (unauthenticated) pages
|
||||||
|
* because the SQL filters by `is_public = true` / `active = true` and
|
||||||
|
* never returns anything the storefront shouldn't expose.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { pool } from "@/lib/db";
|
||||||
|
|
||||||
|
export type StorefrontBrand = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StorefrontStop = {
|
||||||
|
id: string;
|
||||||
|
brand_id: string;
|
||||||
|
name: string | null;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
zip: string | null;
|
||||||
|
address: string | null;
|
||||||
|
location: string | null;
|
||||||
|
date: string;
|
||||||
|
time: string | null;
|
||||||
|
cutoff_date: string | null;
|
||||||
|
status: string;
|
||||||
|
notes: string | null;
|
||||||
|
is_public: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StorefrontProduct = {
|
||||||
|
id: string;
|
||||||
|
brand_id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
price: number;
|
||||||
|
type: string | null;
|
||||||
|
image_url: string | null;
|
||||||
|
is_taxable: boolean | null;
|
||||||
|
pickup_type: string | null;
|
||||||
|
active: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StorefrontData = {
|
||||||
|
brand: StorefrontBrand | null;
|
||||||
|
stops: StorefrontStop[];
|
||||||
|
products: StorefrontProduct[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look up a brand by its public slug. Returns `null` if no brand matches
|
||||||
|
* (which means the storefront slug is wrong, or the brand was renamed).
|
||||||
|
*/
|
||||||
|
export async function getStorefrontBrandBySlug(
|
||||||
|
slug: string,
|
||||||
|
): Promise<StorefrontBrand | null> {
|
||||||
|
const { rows } = await pool.query<StorefrontBrand>(
|
||||||
|
`SELECT id, name, slug
|
||||||
|
FROM brands
|
||||||
|
WHERE slug = $1
|
||||||
|
LIMIT 1`,
|
||||||
|
[slug],
|
||||||
|
);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public stops for a brand storefront. Filters out draft / private stops
|
||||||
|
* and any stop whose date has already passed.
|
||||||
|
*/
|
||||||
|
export async function getStorefrontStops(brandId: string): Promise<StorefrontStop[]> {
|
||||||
|
const { rows } = await pool.query<StorefrontStop>(
|
||||||
|
`SELECT id, brand_id, name, city, state, zip, address, location,
|
||||||
|
date::text AS date, time, cutoff_date::text AS cutoff_date,
|
||||||
|
status, notes, is_public
|
||||||
|
FROM stops
|
||||||
|
WHERE brand_id = $1
|
||||||
|
AND is_public = true
|
||||||
|
AND status = 'active'
|
||||||
|
AND date >= CURRENT_DATE
|
||||||
|
ORDER BY date ASC, time ASC NULLS LAST`,
|
||||||
|
[brandId],
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Active products visible on the storefront. Same shape as the admin
|
||||||
|
* product list but without the brand-internal columns (cost, supplier, …).
|
||||||
|
*
|
||||||
|
* Note: products.price is stored in cents (NUMERIC). We return it as a
|
||||||
|
* float in **dollars** so callers can format directly as `$${price}`
|
||||||
|
* without doing the cents-to-dollars math.
|
||||||
|
*/
|
||||||
|
export async function getStorefrontProducts(
|
||||||
|
brandId: string,
|
||||||
|
): Promise<StorefrontProduct[]> {
|
||||||
|
const { rows } = await pool.query<StorefrontProduct>(
|
||||||
|
`SELECT id, brand_id, name, description,
|
||||||
|
price::float8 / 100 AS price,
|
||||||
|
type, image_url, is_taxable, pickup_type, active
|
||||||
|
FROM products
|
||||||
|
WHERE brand_id = $1
|
||||||
|
AND active = true
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
ORDER BY name ASC`,
|
||||||
|
[brandId],
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single server action that the storefront Client Components call from
|
||||||
|
* their initial `useEffect`. Bundles brand lookup + public stops +
|
||||||
|
* active products into one round-trip-friendly call.
|
||||||
|
*
|
||||||
|
* Returns `{ brand: null, stops: [], products: [] }` if the slug doesn't
|
||||||
|
* match any brand — pages fall back to default copy in that case.
|
||||||
|
*/
|
||||||
|
export async function getStorefrontData(slug: string): Promise<StorefrontData> {
|
||||||
|
const brand = await getStorefrontBrandBySlug(slug);
|
||||||
|
if (!brand) return { brand: null, stops: [], products: [] };
|
||||||
|
const [stops, products] = await Promise.all([
|
||||||
|
getStorefrontStops(brand.id),
|
||||||
|
getStorefrontProducts(brand.id),
|
||||||
|
]);
|
||||||
|
return { brand, stops, products };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StorefrontWholesaleSettings = {
|
||||||
|
invoice_business_name: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public wholesale-settings record for a brand — just the invoice
|
||||||
|
* business name used by the storefront contact page. Returns `null`
|
||||||
|
* if the brand or its wholesale settings row doesn't exist.
|
||||||
|
*
|
||||||
|
* NOTE: only `invoice_business_name` is exposed; other invoice-contact
|
||||||
|
* fields don't exist on `wholesale_settings` yet and shouldn't be
|
||||||
|
* surfaced here. If you need them, add a migration first.
|
||||||
|
*/
|
||||||
|
export async function getStorefrontWholesaleSettings(
|
||||||
|
slug: string,
|
||||||
|
): Promise<StorefrontWholesaleSettings | null> {
|
||||||
|
const brand = await getStorefrontBrandBySlug(slug);
|
||||||
|
if (!brand) return null;
|
||||||
|
const { rows } = await pool.query<StorefrontWholesaleSettings>(
|
||||||
|
`SELECT invoice_business_name
|
||||||
|
FROM wholesale_settings
|
||||||
|
WHERE brand_id = $1
|
||||||
|
LIMIT 1`,
|
||||||
|
[brand.id],
|
||||||
|
);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WholesalePortalConfig = {
|
||||||
|
wholesale_enabled: boolean | null;
|
||||||
|
online_payment_enabled: boolean | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public wholesale portal flags for a brand. Used by the customer-facing
|
||||||
|
* `/wholesale/portal` page to decide whether to allow sign-in and whether
|
||||||
|
* to show the online-payment button. Doesn't require auth (the portal
|
||||||
|
* already validated the session before this is called).
|
||||||
|
*/
|
||||||
|
export async function getWholesalePortalConfig(
|
||||||
|
brandId: string,
|
||||||
|
): Promise<WholesalePortalConfig | null> {
|
||||||
|
const { rows } = await pool.query<WholesalePortalConfig>(
|
||||||
|
`SELECT wholesale_enabled, online_payment_enabled
|
||||||
|
FROM wholesale_settings
|
||||||
|
WHERE brand_id = $1
|
||||||
|
LIMIT 1`,
|
||||||
|
[brandId],
|
||||||
|
);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BrandName = { name: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a brand's display name by id. Public — used by the wholesale
|
||||||
|
* portal header to render the brand name without leaking any other
|
||||||
|
* brand columns (logo, plan, stripe keys, etc.).
|
||||||
|
*/
|
||||||
|
export async function getStorefrontBrandName(
|
||||||
|
brandId: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const { rows } = await pool.query<BrandName>(
|
||||||
|
`SELECT name FROM brands WHERE id = $1 LIMIT 1`,
|
||||||
|
[brandId],
|
||||||
|
);
|
||||||
|
return rows[0]?.name ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look up a single public stop by its id, plus the brand's active
|
||||||
|
* products. Used by `/[brand]/stops/[id]` pages.
|
||||||
|
*
|
||||||
|
* NOTE: The URL parameter is named `[id]` (not `[slug]`) because the
|
||||||
|
* `stops` table has no slug column. URLs use the stop's UUID.
|
||||||
|
*
|
||||||
|
* Returns `null` if the stop is not public, not active, or its date has
|
||||||
|
* already passed. Returns the empty product array if the brand has no
|
||||||
|
* active products at the moment.
|
||||||
|
*/
|
||||||
|
export async function getStorefrontStopById(id: string): Promise<{
|
||||||
|
stop: StorefrontStop;
|
||||||
|
products: StorefrontProduct[];
|
||||||
|
} | null> {
|
||||||
|
const { rows: stopRows } = await pool.query<StorefrontStop>(
|
||||||
|
`SELECT id, brand_id, name, city, state, zip, address, location,
|
||||||
|
date::text AS date, time, cutoff_date::text AS cutoff_date,
|
||||||
|
status, notes, is_public
|
||||||
|
FROM stops
|
||||||
|
WHERE id = $1
|
||||||
|
AND is_public = true
|
||||||
|
AND status = 'active'
|
||||||
|
LIMIT 1`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
const stop = stopRows[0];
|
||||||
|
if (!stop) return null;
|
||||||
|
const products = await getStorefrontProducts(stop.brand_id);
|
||||||
|
return { stop, products };
|
||||||
|
}
|
||||||
+6
-6
@@ -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;
|
||||||
|
|||||||
@@ -1,888 +0,0 @@
|
|||||||
"use server";
|
|
||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
|
||||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
|
||||||
import { pool } from "@/lib/db";
|
|
||||||
|
|
||||||
export type WholesaleOrder = {
|
|
||||||
id: string;
|
|
||||||
customer_id: string;
|
|
||||||
status: string;
|
|
||||||
fulfillment_status: string;
|
|
||||||
payment_status: string;
|
|
||||||
anticipated_pickup_date: string | null;
|
|
||||||
subtotal: number;
|
|
||||||
deposit_required: number;
|
|
||||||
deposit_paid: number;
|
|
||||||
balance_due: number;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
invoice_number: string | null;
|
|
||||||
assigned_employee_id: string | null;
|
|
||||||
company_name: string;
|
|
||||||
contact_name: string | null;
|
|
||||||
customer_email: string;
|
|
||||||
customer_phone: string | null;
|
|
||||||
items: Array<{
|
|
||||||
id: string;
|
|
||||||
product_name: string;
|
|
||||||
quantity: number;
|
|
||||||
unit_price: number;
|
|
||||||
line_total: number;
|
|
||||||
}>;
|
|
||||||
fulfilled_at: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WholesaleCustomer = {
|
|
||||||
id: string;
|
|
||||||
user_id: string | null;
|
|
||||||
company_name: string;
|
|
||||||
contact_name: string | null;
|
|
||||||
email: string;
|
|
||||||
phone: string | null;
|
|
||||||
account_status: string;
|
|
||||||
credit_limit: number;
|
|
||||||
deposits_enabled: boolean;
|
|
||||||
deposit_threshold: number | null;
|
|
||||||
deposit_percentage: number | null;
|
|
||||||
order_email: string | null;
|
|
||||||
invoice_email: string | null;
|
|
||||||
admin_notes: string | null;
|
|
||||||
role: string;
|
|
||||||
created_at: string;
|
|
||||||
deleted_at: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WholesaleProduct = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description: string | null;
|
|
||||||
unit_type: string;
|
|
||||||
unit_type_custom: string | null;
|
|
||||||
availability: string;
|
|
||||||
qty_available: number;
|
|
||||||
season_start: string | null;
|
|
||||||
season_end: string | null;
|
|
||||||
price_tiers: Array<{ min_qty: number; max_qty: number; price: number }>;
|
|
||||||
hp_sku: string | null;
|
|
||||||
hp_item_id: string | null;
|
|
||||||
handling_instructions: string | null;
|
|
||||||
storage_warning: string | null;
|
|
||||||
loading_notes: string | null;
|
|
||||||
product_label: string | null;
|
|
||||||
pack_style: string | null;
|
|
||||||
container_type: string | null;
|
|
||||||
container_size_code: string | null;
|
|
||||||
units_per_container: number | null;
|
|
||||||
default_pickup_location: string | null;
|
|
||||||
created_at: string;
|
|
||||||
deleted_at: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type NotificationRecipient = {
|
|
||||||
email: string;
|
|
||||||
name?: string;
|
|
||||||
active: boolean;
|
|
||||||
notification_types?: (
|
|
||||||
| "order_confirmation"
|
|
||||||
| "deposit_received"
|
|
||||||
| "order_fulfilled"
|
|
||||||
| "price_sheet"
|
|
||||||
| "unclaimed_pickup"
|
|
||||||
)[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WholesaleSettings = {
|
|
||||||
id: string;
|
|
||||||
brand_id: string;
|
|
||||||
portal_page_id: string | null;
|
|
||||||
price_sheet_page_id: string | null;
|
|
||||||
require_approval: boolean;
|
|
||||||
min_order_amount: number | null;
|
|
||||||
online_payment_enabled: boolean;
|
|
||||||
wholesale_enabled: boolean;
|
|
||||||
square_sync_enabled: boolean;
|
|
||||||
pickup_location: string;
|
|
||||||
fob_location: string;
|
|
||||||
from_email: string;
|
|
||||||
invoice_business_name: string;
|
|
||||||
invoice_business_address: string | null;
|
|
||||||
invoice_business_phone: string | null;
|
|
||||||
invoice_business_email: string | null;
|
|
||||||
invoice_business_website: string | null;
|
|
||||||
notification_email: string | null;
|
|
||||||
notification_recipients: NotificationRecipient[];
|
|
||||||
last_invoice_number: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WholesaleDashboardStats = {
|
|
||||||
open_orders: number;
|
|
||||||
pickup_today: number;
|
|
||||||
past_due: number;
|
|
||||||
total_unpaid: number;
|
|
||||||
awaiting_deposit: number;
|
|
||||||
fulfilled_today: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves the effective brand_id for an action, enforcing brand scoping.
|
|
||||||
*
|
|
||||||
* platform_admin → null (means "all brands" — passes to RPC unchanged)
|
|
||||||
* brand_admin → their own brand_id only; rejects attempts to operate on other brands
|
|
||||||
* store_employee → their own brand_id
|
|
||||||
* multi_brand_admin → active brand from cookie/URL/legacy, must be in brand_ids
|
|
||||||
* unauthenticated → null (actions should already bail out earlier)
|
|
||||||
*
|
|
||||||
* This prevents brand_admin from seeing or modifying another brand's data
|
|
||||||
* even if they manually pass a different brandId to the action.
|
|
||||||
*/
|
|
||||||
async function resolveBrandId(
|
|
||||||
adminUser: Awaited<ReturnType<typeof getAdminUser>>,
|
|
||||||
requestedBrandId?: string
|
|
||||||
): Promise<string | null> {
|
|
||||||
if (!adminUser) return null;
|
|
||||||
|
|
||||||
if (adminUser.role === "platform_admin") {
|
|
||||||
// platform_admin can operate on all brands — pass null (= all brands) to RPC
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For non-platform-admin: resolve the active brand (validates against brand_ids)
|
|
||||||
const activeBrandId = await getActiveBrandId(adminUser, requestedBrandId);
|
|
||||||
|
|
||||||
if (requestedBrandId && activeBrandId !== requestedBrandId) {
|
|
||||||
// Brand admin trying to operate on another brand's data — block it
|
|
||||||
return null; // caller should check and return unauthorized
|
|
||||||
}
|
|
||||||
|
|
||||||
return activeBrandId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Like resolveBrandId but returns null for platform_admin AND throws an error
|
|
||||||
* if a brand_admin tries to operate outside their brand.
|
|
||||||
* Use for mutating actions (save, delete, fulfill) where cross-brand access must be blocked.
|
|
||||||
*/
|
|
||||||
async function enforceBrandScope(
|
|
||||||
adminUser: Awaited<ReturnType<typeof getAdminUser>>,
|
|
||||||
requestedBrandId?: string
|
|
||||||
): Promise<{ brandId: string | null; error?: string }> {
|
|
||||||
if (!adminUser) return { brandId: null, error: "Not authenticated" };
|
|
||||||
|
|
||||||
if (adminUser.role === "platform_admin") {
|
|
||||||
return { brandId: null }; // unrestricted
|
|
||||||
}
|
|
||||||
|
|
||||||
// For non-platform-admin: resolve the active brand (validates against brand_ids)
|
|
||||||
const activeBrandId = await getActiveBrandId(adminUser, requestedBrandId);
|
|
||||||
|
|
||||||
if (requestedBrandId && activeBrandId !== requestedBrandId) {
|
|
||||||
return { brandId: null, error: "Not authorized to operate on this brand" };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { brandId: activeBrandId };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Orders ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export async function getWholesaleOrders(brandId?: string): Promise<WholesaleOrder[]> {
|
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) return [];
|
|
||||||
const bid = await resolveBrandId(adminUser, brandId);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { rows } = await pool.query<WholesaleOrder>(
|
|
||||||
"SELECT * FROM get_wholesale_orders($1)",
|
|
||||||
[bid]
|
|
||||||
);
|
|
||||||
return rows;
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getWholesalePickupOrders(brandId?: string): Promise<WholesaleOrder[]> {
|
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) return [];
|
|
||||||
const bid = await resolveBrandId(adminUser, brandId);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { rows } = await pool.query<WholesaleOrder>(
|
|
||||||
"SELECT * FROM get_wholesale_pickup_orders($1)",
|
|
||||||
[bid]
|
|
||||||
);
|
|
||||||
return rows;
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getWholesaleDashboardStats(brandId?: string): Promise<WholesaleDashboardStats> {
|
|
||||||
const orders = await getWholesaleOrders(brandId);
|
|
||||||
const today = new Date().toISOString().split("T")[0];
|
|
||||||
|
|
||||||
const open = orders.filter(o => ["pending", "awaiting_deposit", "confirmed"].includes(o.status));
|
|
||||||
const pickupToday = orders.filter(o => o.anticipated_pickup_date === today && o.fulfillment_status !== "fulfilled");
|
|
||||||
const pastDue = orders.filter(o => o.anticipated_pickup_date && o.anticipated_pickup_date < today && o.fulfillment_status !== "fulfilled");
|
|
||||||
const totalUnpaid = open.reduce((sum, o) => sum + Number(o.balance_due), 0);
|
|
||||||
const awaitingDep = orders.filter(o => o.status === "awaiting_deposit");
|
|
||||||
const fulfilledToday = orders.filter(o => o.fulfillment_status === "fulfilled" && o.updated_at?.startsWith(today));
|
|
||||||
|
|
||||||
return {
|
|
||||||
open_orders: open.length,
|
|
||||||
pickup_today: pickupToday.length,
|
|
||||||
past_due: pastDue.length,
|
|
||||||
total_unpaid: totalUnpaid,
|
|
||||||
awaiting_deposit: awaitingDep.length,
|
|
||||||
fulfilled_today: fulfilledToday.length,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function markWholesaleOrderFulfilled(orderId: string, brandId?: string): Promise<{ success: boolean; error?: string }> {
|
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
|
||||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
|
||||||
|
|
||||||
const { brandId: resolved, error } = await enforceBrandScope(adminUser, brandId);
|
|
||||||
if (error) return { success: false, error };
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { rows } = await pool.query(
|
|
||||||
"SELECT * FROM mark_wholesale_order_fulfilled($1, $2)",
|
|
||||||
[orderId, adminUser.user_id]
|
|
||||||
);
|
|
||||||
if (!rows || rows.length === 0) {
|
|
||||||
return { success: false, error: "Failed to mark fulfilled" };
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return { success: false, error: "Failed to mark fulfilled" };
|
|
||||||
}
|
|
||||||
|
|
||||||
enqueueWholesaleWebhookForOrderFulfilled(orderId, resolved ?? undefined).catch(() => {});
|
|
||||||
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function enqueueWholesaleWebhookForOrderFulfilled(orderId: string, brandId?: string) {
|
|
||||||
try {
|
|
||||||
await pool.query(
|
|
||||||
"SELECT enqueue_wholesale_webhook($1, $2, $3, $4::jsonb)",
|
|
||||||
[
|
|
||||||
"order_fulfilled",
|
|
||||||
orderId,
|
|
||||||
brandId ?? null,
|
|
||||||
JSON.stringify({ order_id: orderId }),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateWholesaleOrderStatus(
|
|
||||||
orderId: string,
|
|
||||||
status: "pending" | "confirmed" | "cancelled",
|
|
||||||
brandId?: string
|
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
|
||||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
|
||||||
|
|
||||||
const { error } = await enforceBrandScope(adminUser, brandId);
|
|
||||||
if (error) return { success: false, error };
|
|
||||||
|
|
||||||
try {
|
|
||||||
await pool.query(
|
|
||||||
"SELECT * FROM update_wholesale_order_status($1, $2)",
|
|
||||||
[orderId, status]
|
|
||||||
);
|
|
||||||
return { success: true };
|
|
||||||
} catch {
|
|
||||||
return { success: false, error: "Failed to update order status" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteWholesaleOrder(orderId: string, brandId?: string): Promise<{ success: boolean; error?: string }> {
|
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
|
||||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
|
||||||
|
|
||||||
const { error } = await enforceBrandScope(adminUser, brandId);
|
|
||||||
if (error) return { success: false, error };
|
|
||||||
|
|
||||||
try {
|
|
||||||
await pool.query(
|
|
||||||
"SELECT * FROM delete_wholesale_order($1)",
|
|
||||||
[orderId]
|
|
||||||
);
|
|
||||||
return { success: true };
|
|
||||||
} catch {
|
|
||||||
return { success: false, error: "Failed to delete order" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteWholesaleCustomer(customerId: string, brandId?: string): Promise<{ success: boolean; error?: string }> {
|
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
|
||||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
|
||||||
|
|
||||||
const { error } = await enforceBrandScope(adminUser, brandId);
|
|
||||||
if (error) return { success: false, error };
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { rows } = await pool.query<{ success: boolean; error?: string }>(
|
|
||||||
"SELECT * FROM delete_wholesale_customer($1)",
|
|
||||||
[customerId]
|
|
||||||
);
|
|
||||||
const data = Array.isArray(rows) ? rows[0] : rows;
|
|
||||||
if (!data?.success) {
|
|
||||||
return { success: false, error: data?.error ?? "Delete failed" };
|
|
||||||
}
|
|
||||||
return { success: true };
|
|
||||||
} catch (e: unknown) {
|
|
||||||
return { success: false, error: e instanceof Error ? e.message : "Failed to delete customer" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteWholesaleProduct(productId: string, brandId?: string): Promise<{ success: boolean; error?: string }> {
|
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
|
||||||
if (!adminUser.can_manage_products) return { success: false, error: "Not authorized" };
|
|
||||||
|
|
||||||
const { error } = await enforceBrandScope(adminUser, brandId);
|
|
||||||
if (error) return { success: false, error };
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { rows } = await pool.query<{ success: boolean; error?: string }>(
|
|
||||||
"SELECT * FROM delete_wholesale_product($1)",
|
|
||||||
[productId]
|
|
||||||
);
|
|
||||||
const data = Array.isArray(rows) ? rows[0] : rows;
|
|
||||||
if (!data?.success) {
|
|
||||||
return { success: false, error: data?.error ?? "Delete failed" };
|
|
||||||
}
|
|
||||||
return { success: true };
|
|
||||||
} catch (e: unknown) {
|
|
||||||
return { success: false, error: e instanceof Error ? e.message : "Failed to delete product" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Customers ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export async function getWholesaleCustomers(brandId?: string): Promise<WholesaleCustomer[]> {
|
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) return [];
|
|
||||||
const bid = await resolveBrandId(adminUser, brandId);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { rows } = await pool.query<WholesaleCustomer>(
|
|
||||||
"SELECT * FROM get_wholesale_customers($1)",
|
|
||||||
[bid]
|
|
||||||
);
|
|
||||||
return rows;
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function saveWholesaleCustomer(params: {
|
|
||||||
brandId: string;
|
|
||||||
userId?: string;
|
|
||||||
companyName?: string;
|
|
||||||
contactName?: string;
|
|
||||||
email?: string;
|
|
||||||
phone?: string;
|
|
||||||
accountStatus?: string;
|
|
||||||
creditLimit?: number;
|
|
||||||
depositsEnabled?: boolean;
|
|
||||||
depositThreshold?: number;
|
|
||||||
depositPercentage?: number;
|
|
||||||
orderEmail?: string;
|
|
||||||
invoiceEmail?: string;
|
|
||||||
adminNotes?: string;
|
|
||||||
}): Promise<{ success: boolean; error?: string; id?: string }> {
|
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
|
||||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
|
||||||
|
|
||||||
const { error } = await enforceBrandScope(adminUser, params.brandId);
|
|
||||||
if (error) return { success: false, error };
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { rows } = await pool.query<{ id: string }>(
|
|
||||||
"SELECT * FROM upsert_wholesale_customer($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)",
|
|
||||||
[
|
|
||||||
params.brandId,
|
|
||||||
params.userId ?? null,
|
|
||||||
params.companyName ?? null,
|
|
||||||
params.contactName ?? null,
|
|
||||||
params.email ?? null,
|
|
||||||
params.phone ?? null,
|
|
||||||
params.accountStatus ?? "active",
|
|
||||||
params.creditLimit ?? 0,
|
|
||||||
params.depositsEnabled ?? false,
|
|
||||||
params.depositThreshold ?? null,
|
|
||||||
params.depositPercentage ?? null,
|
|
||||||
params.orderEmail ?? null,
|
|
||||||
params.invoiceEmail ?? null,
|
|
||||||
params.adminNotes ?? null,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
const data = Array.isArray(rows) ? rows[0] : rows;
|
|
||||||
return { success: true, id: data?.id };
|
|
||||||
} catch {
|
|
||||||
return { success: false, error: "Failed to save customer" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Products ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export async function getWholesaleProducts(brandId?: string): Promise<WholesaleProduct[]> {
|
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) return [];
|
|
||||||
const bid = await resolveBrandId(adminUser, brandId);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { rows } = await pool.query<WholesaleProduct>(
|
|
||||||
"SELECT * FROM get_wholesale_products($1)",
|
|
||||||
[bid]
|
|
||||||
);
|
|
||||||
return rows;
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function saveWholesaleProduct(params: {
|
|
||||||
brandId: string;
|
|
||||||
id?: string;
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
unitType?: string;
|
|
||||||
availability?: string;
|
|
||||||
qtyAvailable?: number;
|
|
||||||
priceTiers?: Array<{ min_qty: number; max_qty: number; price: number }>;
|
|
||||||
hpSku?: string;
|
|
||||||
hpItemId?: string;
|
|
||||||
internalNotes?: string;
|
|
||||||
handlingInstructions?: string;
|
|
||||||
storageWarning?: string;
|
|
||||||
productLabel?: string;
|
|
||||||
packStyle?: string;
|
|
||||||
containerType?: string;
|
|
||||||
defaultPickupLocation?: string;
|
|
||||||
}): Promise<{ success: boolean; error?: string; id?: string }> {
|
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
|
||||||
if (!adminUser.can_manage_products) return { success: false, error: "Not authorized" };
|
|
||||||
|
|
||||||
const { error } = await enforceBrandScope(adminUser, params.brandId);
|
|
||||||
if (error) return { success: false, error };
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { rows } = await pool.query<{ id: string }>(
|
|
||||||
"SELECT * FROM upsert_wholesale_product($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10, $11, $12, $13, $14, $15, $16, $17)",
|
|
||||||
[
|
|
||||||
params.brandId,
|
|
||||||
params.id ?? null,
|
|
||||||
params.name,
|
|
||||||
params.description ?? null,
|
|
||||||
params.unitType ?? "each",
|
|
||||||
params.availability ?? "unavailable",
|
|
||||||
params.qtyAvailable ?? 0,
|
|
||||||
JSON.stringify(params.priceTiers ?? []),
|
|
||||||
params.hpSku ?? null,
|
|
||||||
params.hpItemId ?? null,
|
|
||||||
params.internalNotes ?? null,
|
|
||||||
params.handlingInstructions ?? null,
|
|
||||||
params.storageWarning ?? null,
|
|
||||||
params.productLabel ?? null,
|
|
||||||
params.packStyle ?? null,
|
|
||||||
params.containerType ?? null,
|
|
||||||
params.defaultPickupLocation ?? null,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
const data = Array.isArray(rows) ? rows[0] : rows;
|
|
||||||
return { success: true, id: data?.id };
|
|
||||||
} catch {
|
|
||||||
return { success: false, error: "Failed to save product" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Settings ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export async function getWholesaleSettings(brandId?: string): Promise<WholesaleSettings | null> {
|
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) return null;
|
|
||||||
const bid = await getActiveBrandId(adminUser, brandId);
|
|
||||||
if (!bid && adminUser.role !== "platform_admin") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { rows } = await pool.query<WholesaleSettings>(
|
|
||||||
"SELECT * FROM get_wholesale_settings($1)",
|
|
||||||
[bid]
|
|
||||||
);
|
|
||||||
return rows[0] ?? null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getWholesaleSettingsPublic(brandId: string): Promise<{ invoice_business_address: string | null } | null> {
|
|
||||||
try {
|
|
||||||
const { rows } = await pool.query<{ invoice_business_address: string | null }>(
|
|
||||||
"SELECT invoice_business_address FROM get_wholesale_settings($1)",
|
|
||||||
[brandId]
|
|
||||||
);
|
|
||||||
return rows[0] ?? null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function saveWholesaleSettings(params: {
|
|
||||||
brandId: string;
|
|
||||||
requireApproval?: boolean;
|
|
||||||
minOrderAmount?: number;
|
|
||||||
onlinePaymentEnabled?: boolean;
|
|
||||||
wholesaleEnabled?: boolean;
|
|
||||||
squareSyncEnabled?: boolean;
|
|
||||||
pickupLocation?: string;
|
|
||||||
fobLocation?: string;
|
|
||||||
fromEmail?: string;
|
|
||||||
invoiceBusinessName?: string;
|
|
||||||
invoiceBusinessAddress?: string;
|
|
||||||
invoiceBusinessPhone?: string;
|
|
||||||
invoiceBusinessEmail?: string;
|
|
||||||
invoiceBusinessWebsite?: string;
|
|
||||||
notificationEmail?: string;
|
|
||||||
notificationRecipients?: NotificationRecipient[];
|
|
||||||
}): Promise<{ success: boolean; error?: string }> {
|
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
|
||||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
|
||||||
|
|
||||||
try {
|
|
||||||
await pool.query(
|
|
||||||
"SELECT upsert_wholesale_settings($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)",
|
|
||||||
[
|
|
||||||
params.brandId,
|
|
||||||
params.requireApproval ?? null,
|
|
||||||
params.minOrderAmount ?? null,
|
|
||||||
params.onlinePaymentEnabled ?? null,
|
|
||||||
params.wholesaleEnabled ?? null,
|
|
||||||
params.pickupLocation ?? null,
|
|
||||||
params.fobLocation ?? null,
|
|
||||||
params.fromEmail ?? null,
|
|
||||||
params.invoiceBusinessName ?? null,
|
|
||||||
params.invoiceBusinessAddress ?? null,
|
|
||||||
params.invoiceBusinessPhone ?? null,
|
|
||||||
params.invoiceBusinessEmail ?? null,
|
|
||||||
params.invoiceBusinessWebsite ?? null,
|
|
||||||
params.notificationEmail ?? null,
|
|
||||||
params.notificationRecipients ? JSON.stringify(params.notificationRecipients) : null,
|
|
||||||
params.squareSyncEnabled ?? null,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
return { success: true };
|
|
||||||
} catch {
|
|
||||||
return { success: false, error: "Failed to save settings" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Deposits ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export async function recordWholesaleDeposit(
|
|
||||||
orderId: string,
|
|
||||||
amount: number,
|
|
||||||
method: string = "cash",
|
|
||||||
reference?: string
|
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
|
||||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
|
||||||
|
|
||||||
const activeBrandId = await getActiveBrandId(adminUser);
|
|
||||||
let data: { success?: boolean; error?: string } | null = null;
|
|
||||||
try {
|
|
||||||
const { rows } = await pool.query<{ success: boolean; error?: string }>(
|
|
||||||
"SELECT * FROM record_wholesale_deposit($1, $2, $3, $4, $5, $6)",
|
|
||||||
[orderId, amount, method, reference ?? null, adminUser.user_id, activeBrandId]
|
|
||||||
);
|
|
||||||
data = Array.isArray(rows) ? rows[0] : rows;
|
|
||||||
if (!data?.success) {
|
|
||||||
return { success: false, error: data?.error ?? "Failed to record deposit" };
|
|
||||||
}
|
|
||||||
} catch (e: unknown) {
|
|
||||||
return { success: false, error: e instanceof Error ? e.message : "Failed to record deposit" };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fire webhook — fire-and-forget
|
|
||||||
enqueueWholesaleWebhookForDepositRecorded(orderId, amount, activeBrandId ?? undefined).catch(() => {});
|
|
||||||
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function enqueueWholesaleWebhookForDepositRecorded(orderId: string, amount: number, brandId?: string) {
|
|
||||||
try {
|
|
||||||
await pool.query(
|
|
||||||
"SELECT enqueue_wholesale_webhook($1, $2, $3, $4::jsonb)",
|
|
||||||
[
|
|
||||||
"deposit_recorded",
|
|
||||||
orderId,
|
|
||||||
brandId ?? null,
|
|
||||||
JSON.stringify({ order_id: orderId, amount }),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Bulk Actions ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export async function bulkFulfillWholesaleOrders(
|
|
||||||
orderIds: string[]
|
|
||||||
): Promise<{ success: boolean; count?: number; error?: string }> {
|
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
|
||||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { rows } = await pool.query<{ success: boolean; count?: number; error?: string }>(
|
|
||||||
"SELECT * FROM bulk_fulfill_wholesale_orders($1, $2, $3)",
|
|
||||||
[orderIds, adminUser.user_id, await getActiveBrandId(adminUser)]
|
|
||||||
);
|
|
||||||
const data = Array.isArray(rows) ? rows[0] : rows;
|
|
||||||
if (!data?.success) {
|
|
||||||
return { success: false, error: data?.error ?? "Failed to bulk fulfill orders" };
|
|
||||||
}
|
|
||||||
return { success: true, count: data.count };
|
|
||||||
} catch (e: unknown) {
|
|
||||||
return { success: false, error: e instanceof Error ? e.message : "Failed to bulk fulfill orders" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function bulkRecordWholesaleDeposit(
|
|
||||||
orderIds: string[],
|
|
||||||
amount: number,
|
|
||||||
method: string = "cash",
|
|
||||||
reference?: string
|
|
||||||
): Promise<{ success: boolean; count?: number; error?: string }> {
|
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
|
||||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { rows } = await pool.query<{ success: boolean; count?: number; error?: string }>(
|
|
||||||
"SELECT * FROM bulk_record_wholesale_deposit($1, $2, $3, $4, $5, $6)",
|
|
||||||
[orderIds, amount, method, reference ?? null, adminUser.user_id, await getActiveBrandId(adminUser)]
|
|
||||||
);
|
|
||||||
const data = Array.isArray(rows) ? rows[0] : rows;
|
|
||||||
if (!data?.success) {
|
|
||||||
return { success: false, error: data?.error ?? "Failed to bulk record deposits" };
|
|
||||||
}
|
|
||||||
return { success: true, count: data.count };
|
|
||||||
} catch (e: unknown) {
|
|
||||||
return { success: false, error: e instanceof Error ? e.message : "Failed to bulk record deposits" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Notifications ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export type WholesaleNotification = {
|
|
||||||
id: string;
|
|
||||||
type: "order_confirmation" | "deposit_received" | "order_fulfilled";
|
|
||||||
email_to: string;
|
|
||||||
email_cc: string | null;
|
|
||||||
subject: string;
|
|
||||||
body_html: string | null;
|
|
||||||
body_text: string | null;
|
|
||||||
brand_id: string;
|
|
||||||
customer_id: string;
|
|
||||||
order_id: string | null;
|
|
||||||
status: string;
|
|
||||||
invoice_business_name: string | null;
|
|
||||||
invoice_business_email: string | null;
|
|
||||||
created_at: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function getWholesaleNotificationStats(
|
|
||||||
brandId: string
|
|
||||||
): Promise<{ pending: number; sent: number; failed: number; total: number }> {
|
|
||||||
try {
|
|
||||||
const { rows } = await pool.query<{ pending: number; sent: number; failed: number; total: number }>(
|
|
||||||
"SELECT * FROM get_wholesale_notification_stats($1)",
|
|
||||||
[brandId]
|
|
||||||
);
|
|
||||||
return rows[0] ?? { pending: 0, sent: 0, failed: 0, total: 0 };
|
|
||||||
} catch {
|
|
||||||
return { pending: 0, sent: 0, failed: 0, total: 0 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getWholesalePendingNotifications(
|
|
||||||
brandId: string,
|
|
||||||
limit = 50
|
|
||||||
): Promise<WholesaleNotification[]> {
|
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) return [];
|
|
||||||
if (!adminUser.can_manage_orders) return [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { rows } = await pool.query<WholesaleNotification>(
|
|
||||||
"SELECT * FROM get_wholesale_pending_notifications($1, $2)",
|
|
||||||
[brandId, limit]
|
|
||||||
);
|
|
||||||
return rows;
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function markWholesaleNotificationSent(
|
|
||||||
notificationId: string,
|
|
||||||
error?: string
|
|
||||||
): Promise<{ success: boolean }> {
|
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) return { success: false };
|
|
||||||
if (!adminUser.can_manage_orders) return { success: false };
|
|
||||||
|
|
||||||
try {
|
|
||||||
await pool.query(
|
|
||||||
"SELECT mark_wholesale_notification_sent($1, $2)",
|
|
||||||
[notificationId, error ?? null]
|
|
||||||
);
|
|
||||||
return { success: true };
|
|
||||||
} catch {
|
|
||||||
return { success: false };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function enqueueWholesaleNotification(params: {
|
|
||||||
brandId: string;
|
|
||||||
customerId: string;
|
|
||||||
orderId: string;
|
|
||||||
type: "order_confirmation" | "deposit_received" | "order_fulfilled";
|
|
||||||
emailTo: string;
|
|
||||||
emailCc?: string;
|
|
||||||
subject: string;
|
|
||||||
bodyHtml?: string;
|
|
||||||
bodyText?: string;
|
|
||||||
}): Promise<{ success: boolean; error?: string }> {
|
|
||||||
try {
|
|
||||||
await pool.query(
|
|
||||||
"SELECT enqueue_wholesale_notification($1, $2, $3, $4, $5, $6, $7, $8, $9)",
|
|
||||||
[
|
|
||||||
params.brandId,
|
|
||||||
params.customerId,
|
|
||||||
params.orderId,
|
|
||||||
params.type,
|
|
||||||
params.emailTo,
|
|
||||||
params.emailCc ?? null,
|
|
||||||
params.subject,
|
|
||||||
params.bodyHtml ?? null,
|
|
||||||
params.bodyText ?? null,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
return { success: true };
|
|
||||||
} catch {
|
|
||||||
return { success: false, error: "Failed to enqueue notification" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Webhooks ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export type WebhookSettings = {
|
|
||||||
id: string;
|
|
||||||
brand_id: string;
|
|
||||||
url: string;
|
|
||||||
secret: string;
|
|
||||||
enabled: boolean;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function getWebhookSettings(brandId: string): Promise<WebhookSettings | null> {
|
|
||||||
try {
|
|
||||||
const { rows } = await pool.query<WebhookSettings>(
|
|
||||||
"SELECT * FROM wholesale_webhook_settings WHERE brand_id = $1 LIMIT 1",
|
|
||||||
[brandId]
|
|
||||||
);
|
|
||||||
return rows[0] ?? null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function saveWebhookSettings(params: {
|
|
||||||
brandId: string;
|
|
||||||
url?: string;
|
|
||||||
secret?: string;
|
|
||||||
enabled?: boolean;
|
|
||||||
}): Promise<{ success: boolean; error?: string }> {
|
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
|
||||||
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
|
||||||
|
|
||||||
try {
|
|
||||||
await pool.query(
|
|
||||||
"SELECT upsert_wholesale_webhook_settings($1::jsonb)",
|
|
||||||
[
|
|
||||||
JSON.stringify({
|
|
||||||
brand_id: params.brandId,
|
|
||||||
url: params.url ?? "",
|
|
||||||
secret: params.secret ?? "",
|
|
||||||
enabled: params.enabled ?? false,
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
return { success: true };
|
|
||||||
} catch {
|
|
||||||
return { success: false, error: "Failed to save webhook settings" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function enqueueWholesaleWebhook(
|
|
||||||
eventType: "order_created" | "order_fulfilled" | "deposit_recorded" | "order_paid",
|
|
||||||
orderId: string | null = null,
|
|
||||||
payload: Record<string, unknown> | null = null,
|
|
||||||
brandId?: string
|
|
||||||
): Promise<{ success: boolean; logId?: string }> {
|
|
||||||
try {
|
|
||||||
const { rows } = await pool.query<{ id: string }>(
|
|
||||||
"SELECT enqueue_wholesale_webhook($1, $2, $3::jsonb, $4)",
|
|
||||||
[eventType, orderId, payload ? JSON.stringify(payload) : null, brandId ?? null]
|
|
||||||
);
|
|
||||||
const data = Array.isArray(rows) ? rows[0] : rows;
|
|
||||||
return { success: true, logId: data?.id };
|
|
||||||
} catch {
|
|
||||||
return { success: false };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getRecentWebhookActivity(brandId: string, limit = 10): Promise<Array<{
|
|
||||||
id: string;
|
|
||||||
event_type: string;
|
|
||||||
order_id: string | null;
|
|
||||||
status: string;
|
|
||||||
attempts: number;
|
|
||||||
created_at: string;
|
|
||||||
response: string | null;
|
|
||||||
}>> {
|
|
||||||
try {
|
|
||||||
const { rows } = await pool.query<{
|
|
||||||
id: string;
|
|
||||||
event_type: string;
|
|
||||||
order_id: string | null;
|
|
||||||
status: string;
|
|
||||||
attempts: number;
|
|
||||||
created_at: string;
|
|
||||||
response: string | null;
|
|
||||||
}>(
|
|
||||||
"SELECT * FROM wholesale_sync_log WHERE brand_id = $1 ORDER BY created_at DESC LIMIT $2",
|
|
||||||
[brandId, limit]
|
|
||||||
);
|
|
||||||
return rows;
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { pool } from "@/lib/db";
|
||||||
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { enforceBrandScope, resolveBrandId } from "./scope";
|
||||||
|
import type { WholesaleCustomer } from "./types";
|
||||||
|
|
||||||
|
export async function getWholesaleCustomers(brandId?: string): Promise<WholesaleCustomer[]> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return [];
|
||||||
|
const bid = await resolveBrandId(adminUser, brandId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query<WholesaleCustomer>(
|
||||||
|
"SELECT * FROM get_wholesale_customers($1)",
|
||||||
|
[bid]
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveWholesaleCustomer(params: {
|
||||||
|
brandId: string;
|
||||||
|
userId?: string;
|
||||||
|
companyName?: string;
|
||||||
|
contactName?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
accountStatus?: string;
|
||||||
|
creditLimit?: number;
|
||||||
|
depositsEnabled?: boolean;
|
||||||
|
depositThreshold?: number;
|
||||||
|
depositPercentage?: number;
|
||||||
|
orderEmail?: string;
|
||||||
|
invoiceEmail?: string;
|
||||||
|
adminNotes?: string;
|
||||||
|
}): Promise<{ success: boolean; error?: string; id?: string }> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||||
|
|
||||||
|
const { error } = await enforceBrandScope(adminUser, params.brandId);
|
||||||
|
if (error) return { success: false, error };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query<{ id: string }>(
|
||||||
|
"SELECT * FROM upsert_wholesale_customer($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)",
|
||||||
|
[
|
||||||
|
params.brandId,
|
||||||
|
params.userId ?? null,
|
||||||
|
params.companyName ?? null,
|
||||||
|
params.contactName ?? null,
|
||||||
|
params.email ?? null,
|
||||||
|
params.phone ?? null,
|
||||||
|
params.accountStatus ?? "active",
|
||||||
|
params.creditLimit ?? 0,
|
||||||
|
params.depositsEnabled ?? false,
|
||||||
|
params.depositThreshold ?? null,
|
||||||
|
params.depositPercentage ?? null,
|
||||||
|
params.orderEmail ?? null,
|
||||||
|
params.invoiceEmail ?? null,
|
||||||
|
params.adminNotes ?? null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
const data = Array.isArray(rows) ? rows[0] : rows;
|
||||||
|
return { success: true, id: data?.id };
|
||||||
|
} catch {
|
||||||
|
return { success: false, error: "Failed to save customer" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteWholesaleCustomer(customerId: string, brandId?: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||||
|
|
||||||
|
const { error } = await enforceBrandScope(adminUser, brandId);
|
||||||
|
if (error) return { success: false, error };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query<{ success: boolean; error?: string }>(
|
||||||
|
"SELECT * FROM delete_wholesale_customer($1)",
|
||||||
|
[customerId]
|
||||||
|
);
|
||||||
|
const data = Array.isArray(rows) ? rows[0] : rows;
|
||||||
|
if (!data?.success) {
|
||||||
|
return { success: false, error: data?.error ?? "Delete failed" };
|
||||||
|
}
|
||||||
|
return { success: true };
|
||||||
|
} catch (e: unknown) {
|
||||||
|
return { success: false, error: e instanceof Error ? e.message : "Failed to delete customer" };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { pool } from "@/lib/db";
|
||||||
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
|
import { enqueueWholesaleWebhook } from "./webhooks";
|
||||||
|
|
||||||
|
export async function recordWholesaleDeposit(
|
||||||
|
orderId: string,
|
||||||
|
amount: number,
|
||||||
|
method: string = "cash",
|
||||||
|
reference?: string,
|
||||||
|
brandId?: string
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||||
|
|
||||||
|
const activeBrandId = await getActiveBrandId(adminUser, brandId);
|
||||||
|
let data: { success?: boolean; error?: string } | null = null;
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query<{ success: boolean; error?: string }>(
|
||||||
|
"SELECT * FROM record_wholesale_deposit($1, $2, $3, $4, $5, $6)",
|
||||||
|
[orderId, amount, method, reference ?? null, adminUser.user_id, activeBrandId]
|
||||||
|
);
|
||||||
|
data = Array.isArray(rows) ? rows[0] : rows;
|
||||||
|
if (!data?.success) {
|
||||||
|
return { success: false, error: data?.error ?? "Failed to record deposit" };
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
return { success: false, error: e instanceof Error ? e.message : "Failed to record deposit" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fire webhook — fire-and-forget
|
||||||
|
enqueueWholesaleWebhook("deposit_recorded", orderId, { order_id: orderId, amount }, activeBrandId ?? undefined).catch(() => {});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bulkFulfillWholesaleOrders(
|
||||||
|
orderIds: string[]
|
||||||
|
): Promise<{ success: boolean; count?: number; error?: string }> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query<{ success: boolean; count?: number; error?: string }>(
|
||||||
|
"SELECT * FROM bulk_fulfill_wholesale_orders($1, $2, $3)",
|
||||||
|
[orderIds, adminUser.user_id, await getActiveBrandId(adminUser)]
|
||||||
|
);
|
||||||
|
const data = Array.isArray(rows) ? rows[0] : rows;
|
||||||
|
if (!data?.success) {
|
||||||
|
return { success: false, error: data?.error ?? "Failed to bulk fulfill orders" };
|
||||||
|
}
|
||||||
|
return { success: true, count: data.count };
|
||||||
|
} catch (e: unknown) {
|
||||||
|
return { success: false, error: e instanceof Error ? e.message : "Failed to bulk fulfill orders" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bulkRecordWholesaleDeposit(
|
||||||
|
orderIds: string[],
|
||||||
|
amount: number,
|
||||||
|
method: string = "cash",
|
||||||
|
reference?: string
|
||||||
|
): Promise<{ success: boolean; count?: number; error?: string }> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query<{ success: boolean; count?: number; error?: string }>(
|
||||||
|
"SELECT * FROM bulk_record_wholesale_deposit($1, $2, $3, $4, $5, $6)",
|
||||||
|
[orderIds, amount, method, reference ?? null, adminUser.user_id, await getActiveBrandId(adminUser)]
|
||||||
|
);
|
||||||
|
const data = Array.isArray(rows) ? rows[0] : rows;
|
||||||
|
if (!data?.success) {
|
||||||
|
return { success: false, error: data?.error ?? "Failed to bulk record deposits" };
|
||||||
|
}
|
||||||
|
return { success: true, count: data.count };
|
||||||
|
} catch (e: unknown) {
|
||||||
|
return { success: false, error: e instanceof Error ? e.message : "Failed to bulk record deposits" };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* Barrel re-export for the wholesale action set.
|
||||||
|
*
|
||||||
|
* Source modules live next to this file:
|
||||||
|
* - orders.ts — order CRUD + dashboard stats
|
||||||
|
* - customers.ts — wholesale customer CRUD
|
||||||
|
* - products.ts — wholesale product CRUD
|
||||||
|
* - settings.ts — wholesale_settings + public read
|
||||||
|
* - deposits.ts — deposit recording + bulk actions
|
||||||
|
* - notifications.ts — email/SMS notification queue
|
||||||
|
* - webhooks.ts — outbound webhook settings + dispatch
|
||||||
|
* - scope.ts — internal brand-scoping helpers (NOT re-exported here)
|
||||||
|
* - types.ts — shared type definitions
|
||||||
|
*
|
||||||
|
* Existing imports of `@/actions/wholesale` keep working. New code should
|
||||||
|
* import directly from the focused modules to keep the dependency graph
|
||||||
|
* tight.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type {
|
||||||
|
WholesaleOrder,
|
||||||
|
WholesaleCustomer,
|
||||||
|
WholesaleProduct,
|
||||||
|
NotificationRecipient,
|
||||||
|
WholesaleSettings,
|
||||||
|
WholesaleDashboardStats,
|
||||||
|
WholesaleNotification,
|
||||||
|
WebhookSettings,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
export {
|
||||||
|
getWholesaleOrders,
|
||||||
|
getWholesalePickupOrders,
|
||||||
|
getWholesaleDashboardStats,
|
||||||
|
markWholesaleOrderFulfilled,
|
||||||
|
updateWholesaleOrderStatus,
|
||||||
|
deleteWholesaleOrder,
|
||||||
|
} from "./orders";
|
||||||
|
|
||||||
|
export {
|
||||||
|
getWholesaleCustomers,
|
||||||
|
saveWholesaleCustomer,
|
||||||
|
deleteWholesaleCustomer,
|
||||||
|
} from "./customers";
|
||||||
|
|
||||||
|
export {
|
||||||
|
getWholesaleProducts,
|
||||||
|
saveWholesaleProduct,
|
||||||
|
deleteWholesaleProduct,
|
||||||
|
} from "./products";
|
||||||
|
|
||||||
|
export {
|
||||||
|
getWholesaleSettings,
|
||||||
|
getWholesaleSettingsPublic,
|
||||||
|
saveWholesaleSettings,
|
||||||
|
} from "./settings";
|
||||||
|
|
||||||
|
export {
|
||||||
|
recordWholesaleDeposit,
|
||||||
|
bulkFulfillWholesaleOrders,
|
||||||
|
bulkRecordWholesaleDeposit,
|
||||||
|
} from "./deposits";
|
||||||
|
|
||||||
|
export {
|
||||||
|
getWholesaleNotificationStats,
|
||||||
|
getWholesalePendingNotifications,
|
||||||
|
markWholesaleNotificationSent,
|
||||||
|
enqueueWholesaleNotification,
|
||||||
|
} from "./notifications";
|
||||||
|
|
||||||
|
export {
|
||||||
|
getWebhookSettings,
|
||||||
|
saveWebhookSettings,
|
||||||
|
enqueueWholesaleWebhook,
|
||||||
|
getRecentWebhookActivity,
|
||||||
|
} from "./webhooks";
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { pool } from "@/lib/db";
|
||||||
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import type { WholesaleNotification } from "./types";
|
||||||
|
|
||||||
|
export async function getWholesaleNotificationStats(
|
||||||
|
brandId: string
|
||||||
|
): Promise<{ pending: number; sent: number; failed: number; total: number }> {
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query<{ pending: number; sent: number; failed: number; total: number }>(
|
||||||
|
"SELECT * FROM get_wholesale_notification_stats($1)",
|
||||||
|
[brandId]
|
||||||
|
);
|
||||||
|
return rows[0] ?? { pending: 0, sent: 0, failed: 0, total: 0 };
|
||||||
|
} catch {
|
||||||
|
return { pending: 0, sent: 0, failed: 0, total: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWholesalePendingNotifications(
|
||||||
|
brandId: string,
|
||||||
|
limit = 50
|
||||||
|
): Promise<WholesaleNotification[]> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return [];
|
||||||
|
if (!adminUser.can_manage_orders) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query<WholesaleNotification>(
|
||||||
|
"SELECT * FROM get_wholesale_pending_notifications($1, $2)",
|
||||||
|
[brandId, limit]
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markWholesaleNotificationSent(
|
||||||
|
notificationId: string,
|
||||||
|
error?: string
|
||||||
|
): Promise<{ success: boolean }> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return { success: false };
|
||||||
|
if (!adminUser.can_manage_orders) return { success: false };
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pool.query(
|
||||||
|
"SELECT mark_wholesale_notification_sent($1, $2)",
|
||||||
|
[notificationId, error ?? null]
|
||||||
|
);
|
||||||
|
return { success: true };
|
||||||
|
} catch {
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function enqueueWholesaleNotification(params: {
|
||||||
|
brandId: string;
|
||||||
|
customerId: string;
|
||||||
|
orderId: string;
|
||||||
|
type: "order_confirmation" | "deposit_received" | "order_fulfilled";
|
||||||
|
emailTo: string;
|
||||||
|
emailCc?: string;
|
||||||
|
subject: string;
|
||||||
|
bodyHtml?: string;
|
||||||
|
bodyText?: string;
|
||||||
|
}): Promise<{ success: boolean; error?: string }> {
|
||||||
|
try {
|
||||||
|
await pool.query(
|
||||||
|
"SELECT enqueue_wholesale_notification($1, $2, $3, $4, $5, $6, $7, $8, $9)",
|
||||||
|
[
|
||||||
|
params.brandId,
|
||||||
|
params.customerId,
|
||||||
|
params.orderId,
|
||||||
|
params.type,
|
||||||
|
params.emailTo,
|
||||||
|
params.emailCc ?? null,
|
||||||
|
params.subject,
|
||||||
|
params.bodyHtml ?? null,
|
||||||
|
params.bodyText ?? null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
return { success: true };
|
||||||
|
} catch {
|
||||||
|
return { success: false, error: "Failed to enqueue notification" };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { pool } from "@/lib/db";
|
||||||
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
|
import { enforceBrandScope, resolveBrandId } from "./scope";
|
||||||
|
import { enqueueWholesaleWebhook } from "./webhooks";
|
||||||
|
import type { WholesaleDashboardStats, WholesaleOrder } from "./types";
|
||||||
|
|
||||||
|
export async function getWholesaleOrders(brandId?: string): Promise<WholesaleOrder[]> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return [];
|
||||||
|
const bid = await resolveBrandId(adminUser, brandId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query<WholesaleOrder>(
|
||||||
|
"SELECT * FROM get_wholesale_orders($1)",
|
||||||
|
[bid]
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWholesalePickupOrders(brandId?: string): Promise<WholesaleOrder[]> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return [];
|
||||||
|
const bid = await resolveBrandId(adminUser, brandId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query<WholesaleOrder>(
|
||||||
|
"SELECT * FROM get_wholesale_pickup_orders($1)",
|
||||||
|
[bid]
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWholesaleDashboardStats(brandId?: string): Promise<WholesaleDashboardStats> {
|
||||||
|
const orders = await getWholesaleOrders(brandId);
|
||||||
|
const today = new Date().toISOString().split("T")[0];
|
||||||
|
|
||||||
|
const open = orders.filter(o => ["pending", "awaiting_deposit", "confirmed"].includes(o.status));
|
||||||
|
const pickupToday = orders.filter(o => o.anticipated_pickup_date === today && o.fulfillment_status !== "fulfilled");
|
||||||
|
const pastDue = orders.filter(o => o.anticipated_pickup_date && o.anticipated_pickup_date < today && o.fulfillment_status !== "fulfilled");
|
||||||
|
const totalUnpaid = open.reduce((sum, o) => sum + Number(o.balance_due), 0);
|
||||||
|
const awaitingDep = orders.filter(o => o.status === "awaiting_deposit");
|
||||||
|
const fulfilledToday = orders.filter(o => o.fulfillment_status === "fulfilled" && o.updated_at?.startsWith(today));
|
||||||
|
|
||||||
|
return {
|
||||||
|
open_orders: open.length,
|
||||||
|
pickup_today: pickupToday.length,
|
||||||
|
past_due: pastDue.length,
|
||||||
|
total_unpaid: totalUnpaid,
|
||||||
|
awaiting_deposit: awaitingDep.length,
|
||||||
|
fulfilled_today: fulfilledToday.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markWholesaleOrderFulfilled(orderId: string, brandId?: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||||
|
|
||||||
|
const { brandId: resolved, error } = await enforceBrandScope(adminUser, brandId);
|
||||||
|
if (error) return { success: false, error };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
"SELECT * FROM mark_wholesale_order_fulfilled($1, $2)",
|
||||||
|
[orderId, adminUser.user_id]
|
||||||
|
);
|
||||||
|
if (!rows || rows.length === 0) {
|
||||||
|
return { success: false, error: "Failed to mark fulfilled" };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return { success: false, error: "Failed to mark fulfilled" };
|
||||||
|
}
|
||||||
|
|
||||||
|
enqueueWholesaleWebhook("order_fulfilled", orderId, { order_id: orderId }, resolved ?? undefined).catch(() => {});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateWholesaleOrderStatus(
|
||||||
|
orderId: string,
|
||||||
|
status: "pending" | "confirmed" | "cancelled",
|
||||||
|
brandId?: string
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||||
|
|
||||||
|
const { error } = await enforceBrandScope(adminUser, brandId);
|
||||||
|
if (error) return { success: false, error };
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pool.query(
|
||||||
|
"SELECT * FROM update_wholesale_order_status($1, $2)",
|
||||||
|
[orderId, status]
|
||||||
|
);
|
||||||
|
return { success: true };
|
||||||
|
} catch {
|
||||||
|
return { success: false, error: "Failed to update order status" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteWholesaleOrder(orderId: string, brandId?: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||||
|
|
||||||
|
const { error } = await enforceBrandScope(adminUser, brandId);
|
||||||
|
if (error) return { success: false, error };
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pool.query(
|
||||||
|
"SELECT * FROM delete_wholesale_order($1)",
|
||||||
|
[orderId]
|
||||||
|
);
|
||||||
|
return { success: true };
|
||||||
|
} catch {
|
||||||
|
return { success: false, error: "Failed to delete order" };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { pool } from "@/lib/db";
|
||||||
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { enforceBrandScope, resolveBrandId } from "./scope";
|
||||||
|
import type { WholesaleProduct } from "./types";
|
||||||
|
|
||||||
|
export async function getWholesaleProducts(brandId?: string): Promise<WholesaleProduct[]> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return [];
|
||||||
|
const bid = await resolveBrandId(adminUser, brandId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query<WholesaleProduct>(
|
||||||
|
"SELECT * FROM get_wholesale_products($1)",
|
||||||
|
[bid]
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveWholesaleProduct(params: {
|
||||||
|
brandId: string;
|
||||||
|
id?: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
unitType?: string;
|
||||||
|
availability?: string;
|
||||||
|
qtyAvailable?: number;
|
||||||
|
priceTiers?: Array<{ min_qty: number; max_qty: number; price: number }>;
|
||||||
|
hpSku?: string;
|
||||||
|
hpItemId?: string;
|
||||||
|
internalNotes?: string;
|
||||||
|
handlingInstructions?: string;
|
||||||
|
storageWarning?: string;
|
||||||
|
productLabel?: string;
|
||||||
|
packStyle?: string;
|
||||||
|
containerType?: string;
|
||||||
|
defaultPickupLocation?: string;
|
||||||
|
}): Promise<{ success: boolean; error?: string; id?: string }> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
if (!adminUser.can_manage_products) return { success: false, error: "Not authorized" };
|
||||||
|
|
||||||
|
const { error } = await enforceBrandScope(adminUser, params.brandId);
|
||||||
|
if (error) return { success: false, error };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query<{ id: string }>(
|
||||||
|
"SELECT * FROM upsert_wholesale_product($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10, $11, $12, $13, $14, $15, $16, $17)",
|
||||||
|
[
|
||||||
|
params.brandId,
|
||||||
|
params.id ?? null,
|
||||||
|
params.name,
|
||||||
|
params.description ?? null,
|
||||||
|
params.unitType ?? "each",
|
||||||
|
params.availability ?? "unavailable",
|
||||||
|
params.qtyAvailable ?? 0,
|
||||||
|
JSON.stringify(params.priceTiers ?? []),
|
||||||
|
params.hpSku ?? null,
|
||||||
|
params.hpItemId ?? null,
|
||||||
|
params.internalNotes ?? null,
|
||||||
|
params.handlingInstructions ?? null,
|
||||||
|
params.storageWarning ?? null,
|
||||||
|
params.productLabel ?? null,
|
||||||
|
params.packStyle ?? null,
|
||||||
|
params.containerType ?? null,
|
||||||
|
params.defaultPickupLocation ?? null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
const data = Array.isArray(rows) ? rows[0] : rows;
|
||||||
|
return { success: true, id: data?.id };
|
||||||
|
} catch {
|
||||||
|
return { success: false, error: "Failed to save product" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteWholesaleProduct(productId: string, brandId?: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
if (!adminUser.can_manage_products) return { success: false, error: "Not authorized" };
|
||||||
|
|
||||||
|
const { error } = await enforceBrandScope(adminUser, brandId);
|
||||||
|
if (error) return { success: false, error };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query<{ success: boolean; error?: string }>(
|
||||||
|
"SELECT * FROM delete_wholesale_product($1)",
|
||||||
|
[productId]
|
||||||
|
);
|
||||||
|
const data = Array.isArray(rows) ? rows[0] : rows;
|
||||||
|
if (!data?.success) {
|
||||||
|
return { success: false, error: data?.error ?? "Delete failed" };
|
||||||
|
}
|
||||||
|
return { success: true };
|
||||||
|
} catch (e: unknown) {
|
||||||
|
return { success: false, error: e instanceof Error ? e.message : "Failed to delete product" };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* Brand scoping helpers shared across the wholesale actions.
|
||||||
|
*
|
||||||
|
* No "use server" so this can also be imported from non-action contexts
|
||||||
|
* (e.g. middleware, tests).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the effective brand_id for an action, enforcing brand scoping.
|
||||||
|
*
|
||||||
|
* platform_admin → null (means "all brands" — passes to RPC unchanged)
|
||||||
|
* brand_admin → their own brand_id only; rejects attempts to operate on other brands
|
||||||
|
* store_employee → their own brand_id
|
||||||
|
* multi_brand_admin → active brand from cookie/URL/legacy, must be in brand_ids
|
||||||
|
* unauthenticated → null (actions should already bail out earlier)
|
||||||
|
*
|
||||||
|
* This prevents brand_admin from seeing or modifying another brand's data
|
||||||
|
* even if they manually pass a different brandId to the action.
|
||||||
|
*/
|
||||||
|
export async function resolveBrandId(
|
||||||
|
adminUser: Awaited<ReturnType<typeof getAdminUser>>,
|
||||||
|
requestedBrandId?: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (!adminUser) return null;
|
||||||
|
|
||||||
|
if (adminUser.role === "platform_admin") {
|
||||||
|
// platform_admin can operate on all brands — pass null (= all brands) to RPC
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For non-platform-admin: resolve the active brand (validates against brand_ids)
|
||||||
|
const activeBrandId = await getActiveBrandId(adminUser, requestedBrandId);
|
||||||
|
|
||||||
|
if (requestedBrandId && activeBrandId !== requestedBrandId) {
|
||||||
|
// Brand admin trying to operate on another brand's data — block it
|
||||||
|
return null; // caller should check and return unauthorized
|
||||||
|
}
|
||||||
|
|
||||||
|
return activeBrandId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Like resolveBrandId but returns null for platform_admin AND throws an error
|
||||||
|
* if a brand_admin tries to operate outside their brand.
|
||||||
|
* Use for mutating actions (save, delete, fulfill) where cross-brand access must be blocked.
|
||||||
|
*/
|
||||||
|
export async function enforceBrandScope(
|
||||||
|
adminUser: Awaited<ReturnType<typeof getAdminUser>>,
|
||||||
|
requestedBrandId?: string
|
||||||
|
): Promise<{ brandId: string | null; error?: string }> {
|
||||||
|
if (!adminUser) return { brandId: null, error: "Not authenticated" };
|
||||||
|
|
||||||
|
if (adminUser.role === "platform_admin") {
|
||||||
|
return { brandId: null }; // unrestricted
|
||||||
|
}
|
||||||
|
|
||||||
|
// For non-platform-admin: resolve the active brand (validates against brand_ids)
|
||||||
|
const activeBrandId = await getActiveBrandId(adminUser, requestedBrandId);
|
||||||
|
|
||||||
|
if (requestedBrandId && activeBrandId !== requestedBrandId) {
|
||||||
|
return { brandId: null, error: "Not authorized to operate on this brand" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { brandId: activeBrandId };
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { pool } from "@/lib/db";
|
||||||
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
|
import type { NotificationRecipient, WholesaleSettings } from "./types";
|
||||||
|
|
||||||
|
export async function getWholesaleSettings(brandId?: string): Promise<WholesaleSettings | null> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return null;
|
||||||
|
const bid = await getActiveBrandId(adminUser, brandId);
|
||||||
|
if (!bid && adminUser.role !== "platform_admin") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query<WholesaleSettings>(
|
||||||
|
"SELECT * FROM get_wholesale_settings($1)",
|
||||||
|
[bid]
|
||||||
|
);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWholesaleSettingsPublic(brandId: string): Promise<{ invoice_business_address: string | null } | null> {
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query<{ invoice_business_address: string | null }>(
|
||||||
|
"SELECT invoice_business_address FROM get_wholesale_settings($1)",
|
||||||
|
[brandId]
|
||||||
|
);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveWholesaleSettings(params: {
|
||||||
|
brandId: string;
|
||||||
|
requireApproval?: boolean;
|
||||||
|
minOrderAmount?: number;
|
||||||
|
onlinePaymentEnabled?: boolean;
|
||||||
|
wholesaleEnabled?: boolean;
|
||||||
|
squareSyncEnabled?: boolean;
|
||||||
|
pickupLocation?: string;
|
||||||
|
fobLocation?: string;
|
||||||
|
fromEmail?: string;
|
||||||
|
invoiceBusinessName?: string;
|
||||||
|
invoiceBusinessAddress?: string;
|
||||||
|
invoiceBusinessPhone?: string;
|
||||||
|
invoiceBusinessEmail?: string;
|
||||||
|
invoiceBusinessWebsite?: string;
|
||||||
|
notificationEmail?: string;
|
||||||
|
notificationRecipients?: NotificationRecipient[];
|
||||||
|
}): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pool.query(
|
||||||
|
"SELECT upsert_wholesale_settings($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)",
|
||||||
|
[
|
||||||
|
params.brandId,
|
||||||
|
params.requireApproval ?? null,
|
||||||
|
params.minOrderAmount ?? null,
|
||||||
|
params.onlinePaymentEnabled ?? null,
|
||||||
|
params.wholesaleEnabled ?? null,
|
||||||
|
params.pickupLocation ?? null,
|
||||||
|
params.fobLocation ?? null,
|
||||||
|
params.fromEmail ?? null,
|
||||||
|
params.invoiceBusinessName ?? null,
|
||||||
|
params.invoiceBusinessAddress ?? null,
|
||||||
|
params.invoiceBusinessPhone ?? null,
|
||||||
|
params.invoiceBusinessEmail ?? null,
|
||||||
|
params.invoiceBusinessWebsite ?? null,
|
||||||
|
params.notificationEmail ?? null,
|
||||||
|
params.notificationRecipients ? JSON.stringify(params.notificationRecipients) : null,
|
||||||
|
params.squareSyncEnabled ?? null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
return { success: true };
|
||||||
|
} catch {
|
||||||
|
return { success: false, error: "Failed to save settings" };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
/**
|
||||||
|
* Shared types for wholesale actions.
|
||||||
|
*
|
||||||
|
* Pure type definitions — no runtime imports, no "use server" so it can
|
||||||
|
* be imported into both server and client code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type WholesaleOrder = {
|
||||||
|
id: string;
|
||||||
|
customer_id: string;
|
||||||
|
status: string;
|
||||||
|
fulfillment_status: string;
|
||||||
|
payment_status: string;
|
||||||
|
anticipated_pickup_date: string | null;
|
||||||
|
subtotal: number;
|
||||||
|
deposit_required: number;
|
||||||
|
deposit_paid: number;
|
||||||
|
balance_due: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
invoice_number: string | null;
|
||||||
|
assigned_employee_id: string | null;
|
||||||
|
company_name: string;
|
||||||
|
contact_name: string | null;
|
||||||
|
customer_email: string;
|
||||||
|
customer_phone: string | null;
|
||||||
|
items: Array<{
|
||||||
|
id: string;
|
||||||
|
product_name: string;
|
||||||
|
quantity: number;
|
||||||
|
unit_price: number;
|
||||||
|
line_total: number;
|
||||||
|
}>;
|
||||||
|
fulfilled_at: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WholesaleCustomer = {
|
||||||
|
id: string;
|
||||||
|
user_id: string | null;
|
||||||
|
company_name: string;
|
||||||
|
contact_name: string | null;
|
||||||
|
email: string;
|
||||||
|
phone: string | null;
|
||||||
|
account_status: string;
|
||||||
|
credit_limit: number;
|
||||||
|
deposits_enabled: boolean;
|
||||||
|
deposit_threshold: number | null;
|
||||||
|
deposit_percentage: number | null;
|
||||||
|
order_email: string | null;
|
||||||
|
invoice_email: string | null;
|
||||||
|
admin_notes: string | null;
|
||||||
|
role: string;
|
||||||
|
created_at: string;
|
||||||
|
deleted_at: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WholesaleProduct = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
unit_type: string;
|
||||||
|
unit_type_custom: string | null;
|
||||||
|
availability: string;
|
||||||
|
qty_available: number;
|
||||||
|
season_start: string | null;
|
||||||
|
season_end: string | null;
|
||||||
|
price_tiers: Array<{ min_qty: number; max_qty: number; price: number }>;
|
||||||
|
hp_sku: string | null;
|
||||||
|
hp_item_id: string | null;
|
||||||
|
handling_instructions: string | null;
|
||||||
|
storage_warning: string | null;
|
||||||
|
loading_notes: string | null;
|
||||||
|
product_label: string | null;
|
||||||
|
pack_style: string | null;
|
||||||
|
container_type: string | null;
|
||||||
|
container_size_code: string | null;
|
||||||
|
units_per_container: number | null;
|
||||||
|
default_pickup_location: string | null;
|
||||||
|
created_at: string;
|
||||||
|
deleted_at: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NotificationRecipient = {
|
||||||
|
email: string;
|
||||||
|
name?: string;
|
||||||
|
active: boolean;
|
||||||
|
notification_types?: (
|
||||||
|
| "order_confirmation"
|
||||||
|
| "deposit_received"
|
||||||
|
| "order_fulfilled"
|
||||||
|
| "price_sheet"
|
||||||
|
| "unclaimed_pickup"
|
||||||
|
)[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WholesaleSettings = {
|
||||||
|
id: string;
|
||||||
|
brand_id: string;
|
||||||
|
portal_page_id: string | null;
|
||||||
|
price_sheet_page_id: string | null;
|
||||||
|
require_approval: boolean;
|
||||||
|
min_order_amount: number | null;
|
||||||
|
online_payment_enabled: boolean;
|
||||||
|
wholesale_enabled: boolean;
|
||||||
|
square_sync_enabled: boolean;
|
||||||
|
pickup_location: string;
|
||||||
|
fob_location: string;
|
||||||
|
from_email: string;
|
||||||
|
invoice_business_name: string;
|
||||||
|
invoice_business_address: string | null;
|
||||||
|
invoice_business_phone: string | null;
|
||||||
|
invoice_business_email: string | null;
|
||||||
|
invoice_business_website: string | null;
|
||||||
|
notification_email: string | null;
|
||||||
|
notification_recipients: NotificationRecipient[];
|
||||||
|
last_invoice_number: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WholesaleDashboardStats = {
|
||||||
|
open_orders: number;
|
||||||
|
pickup_today: number;
|
||||||
|
past_due: number;
|
||||||
|
total_unpaid: number;
|
||||||
|
awaiting_deposit: number;
|
||||||
|
fulfilled_today: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WholesaleNotification = {
|
||||||
|
id: string;
|
||||||
|
type: "order_confirmation" | "deposit_received" | "order_fulfilled";
|
||||||
|
email_to: string;
|
||||||
|
email_cc: string | null;
|
||||||
|
subject: string;
|
||||||
|
body_html: string | null;
|
||||||
|
body_text: string | null;
|
||||||
|
brand_id: string;
|
||||||
|
customer_id: string;
|
||||||
|
order_id: string | null;
|
||||||
|
status: string;
|
||||||
|
invoice_business_name: string | null;
|
||||||
|
invoice_business_email: string | null;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WebhookSettings = {
|
||||||
|
id: string;
|
||||||
|
brand_id: string;
|
||||||
|
url: string;
|
||||||
|
secret: string;
|
||||||
|
enabled: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { pool } from "@/lib/db";
|
||||||
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import type { WebhookSettings } from "./types";
|
||||||
|
|
||||||
|
export async function getWebhookSettings(brandId: string): Promise<WebhookSettings | null> {
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query<WebhookSettings>(
|
||||||
|
"SELECT * FROM wholesale_webhook_settings WHERE brand_id = $1 LIMIT 1",
|
||||||
|
[brandId]
|
||||||
|
);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveWebhookSettings(params: {
|
||||||
|
brandId: string;
|
||||||
|
url?: string;
|
||||||
|
secret?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
}): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
if (!adminUser.can_manage_orders) return { success: false, error: "Not authorized" };
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pool.query(
|
||||||
|
"SELECT upsert_wholesale_webhook_settings($1::jsonb)",
|
||||||
|
[
|
||||||
|
JSON.stringify({
|
||||||
|
brand_id: params.brandId,
|
||||||
|
url: params.url ?? "",
|
||||||
|
secret: params.secret ?? "",
|
||||||
|
enabled: params.enabled ?? false,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
return { success: true };
|
||||||
|
} catch {
|
||||||
|
return { success: false, error: "Failed to save webhook settings" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function enqueueWholesaleWebhook(
|
||||||
|
eventType: "order_created" | "order_fulfilled" | "deposit_recorded" | "order_paid",
|
||||||
|
orderId: string | null = null,
|
||||||
|
payload: Record<string, unknown> | null = null,
|
||||||
|
brandId?: string
|
||||||
|
): Promise<{ success: boolean; logId?: string }> {
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query<{ id: string }>(
|
||||||
|
"SELECT enqueue_wholesale_webhook($1, $2, $3::jsonb, $4)",
|
||||||
|
[eventType, orderId, payload ? JSON.stringify(payload) : null, brandId ?? null]
|
||||||
|
);
|
||||||
|
const data = Array.isArray(rows) ? rows[0] : rows;
|
||||||
|
return { success: true, logId: data?.id };
|
||||||
|
} catch {
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRecentWebhookActivity(brandId: string, limit = 10): Promise<Array<{
|
||||||
|
id: string;
|
||||||
|
event_type: string;
|
||||||
|
order_id: string | null;
|
||||||
|
status: string;
|
||||||
|
attempts: number;
|
||||||
|
created_at: string;
|
||||||
|
response: string | null;
|
||||||
|
}>> {
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query<{
|
||||||
|
id: string;
|
||||||
|
event_type: string;
|
||||||
|
order_id: string | null;
|
||||||
|
status: string;
|
||||||
|
attempts: number;
|
||||||
|
created_at: string;
|
||||||
|
response: string | null;
|
||||||
|
}>(
|
||||||
|
"SELECT * FROM wholesale_sync_log WHERE brand_id = $1 ORDER BY created_at DESC LIMIT $2",
|
||||||
|
[brandId, limit]
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
import AdminOrdersPanel from "@/components/admin/AdminOrdersPanel";
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
|
||||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
|
||||||
import { getAdminOrders } from "@/actions/orders";
|
|
||||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
|
||||||
import { PageHeader } from "@/components/admin/design-system";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { supabase } from "@/lib/supabase";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export default async function AdminOrdersPage() {
|
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
|
|
||||||
if (!adminUser) return <AdminAccessDenied />;
|
|
||||||
|
|
||||||
if (!adminUser.can_manage_orders) {
|
|
||||||
redirect("/admin/pickup");
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeBrandId = await getActiveBrandId(adminUser);
|
|
||||||
// Platform admin can browse all brands' orders; everyone else must have a brand
|
|
||||||
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
|
||||||
return <AdminAccessDenied message="You don't have access to any brand." />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { orders, stops } = await getAdminOrders();
|
|
||||||
|
|
||||||
const brandStops = activeBrandId
|
|
||||||
? stops.filter((s) => s.brand_id === activeBrandId)
|
|
||||||
: stops;
|
|
||||||
|
|
||||||
const brandOrders = activeBrandId
|
|
||||||
? orders.filter(
|
|
||||||
(o) =>
|
|
||||||
o.stops && brandStops.some((s) => s.id === o.stop_id)
|
|
||||||
)
|
|
||||||
: orders;
|
|
||||||
|
|
||||||
// Fetch active products for this brand (for admin "New Order" item picker)
|
|
||||||
let brandProducts: Array<{ id: string; name: string; price: number; type?: string | null; active?: boolean }> = [];
|
|
||||||
try {
|
|
||||||
let prodQuery = supabase
|
|
||||||
.from("products")
|
|
||||||
.select("id, name, price, type, active")
|
|
||||||
.eq("active", true)
|
|
||||||
.is("deleted_at", null)
|
|
||||||
.order("name")
|
|
||||||
.limit(200);
|
|
||||||
|
|
||||||
if (activeBrandId) {
|
|
||||||
prodQuery = prodQuery.eq("brand_id", activeBrandId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data: prods } = await prodQuery;
|
|
||||||
brandProducts = (prods ?? []).map((p) => ({
|
|
||||||
id: String(p.id),
|
|
||||||
name: String(p.name ?? ""),
|
|
||||||
price: Number(p.price),
|
|
||||||
type: p.type as string | null ?? null,
|
|
||||||
active: Boolean(p.active ?? true),
|
|
||||||
}));
|
|
||||||
} catch {
|
|
||||||
// non-fatal for the orders list
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen" style={{ backgroundColor: "var(--admin-bg)" }}>
|
|
||||||
<div className="px-4 sm:px-6 md:px-8 pt-4 sm:pt-6">
|
|
||||||
<p className="ha-eyebrow mb-2">Operations</p>
|
|
||||||
<PageHeader
|
|
||||||
title="Orders"
|
|
||||||
subtitle="View, filter, and manage customer orders."
|
|
||||||
icon={
|
|
||||||
<svg className="h-5 w-5 sm:h-6 sm:w-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z"/>
|
|
||||||
<path d="M3 6h18"/>
|
|
||||||
<path d="M16 10a4 4 0 0 1-8 0"/>
|
|
||||||
</svg>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="px-4 sm:px-6 md:px-8 py-4 sm:py-6">
|
|
||||||
<div
|
|
||||||
className="rounded-2xl border overflow-hidden"
|
|
||||||
style={{
|
|
||||||
borderColor: "var(--admin-border)",
|
|
||||||
backgroundColor: "var(--admin-card-bg)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AdminOrdersPanel
|
|
||||||
initialOrders={brandOrders}
|
|
||||||
initialStops={brandStops}
|
|
||||||
initialProducts={brandProducts}
|
|
||||||
brandId={activeBrandId}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -1,158 +0,0 @@
|
|||||||
import { supabase } from "@/lib/supabase";
|
|
||||||
import ProductEditForm from "@/components/admin/ProductEditForm";
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
|
||||||
import { PageHeader, AdminBadge } from "@/components/admin/design-system";
|
|
||||||
import { Package as PackageIcon } from "lucide-react";
|
|
||||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
type ProductDetailPageProps = {
|
|
||||||
params: Promise<{
|
|
||||||
id: string;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface Product {
|
|
||||||
id: string;
|
|
||||||
brand_id: string;
|
|
||||||
name: string;
|
|
||||||
description: string | null;
|
|
||||||
price: number;
|
|
||||||
active: boolean;
|
|
||||||
type: string | null;
|
|
||||||
image_url: string | null;
|
|
||||||
is_taxable: boolean;
|
|
||||||
pickup_type: string | null;
|
|
||||||
brands?: { name: string; slug: string };
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Brand {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
slug: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function ProductDetailPage({ params }: ProductDetailPageProps) {
|
|
||||||
const { id } = await params;
|
|
||||||
|
|
||||||
const [{ data: product, error }, { data: brands }] = await Promise.all([
|
|
||||||
supabase.from("products").select("*, brands(name, slug)").eq("id", id).single() as unknown as { data: Product | null; error: { message: string } | null },
|
|
||||||
supabase.from("brands").select("id, name, slug") as unknown as { data: Brand[] | null },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
|
|
||||||
if (!adminUser) return <AdminAccessDenied />;
|
|
||||||
|
|
||||||
if (!adminUser.can_manage_products) redirect("/admin/pickup");
|
|
||||||
|
|
||||||
if (adminUser?.brand_id && product?.brand_id !== adminUser.brand_id) {
|
|
||||||
return <AdminAccessDenied />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error || !product) {
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen bg-[var(--admin-bg)] px-4 sm:px-6 md:px-8 py-6 sm:py-8">
|
|
||||||
<div className="mx-auto max-w-4xl">
|
|
||||||
<h1 className="text-3xl font-bold text-[var(--admin-danger)]">Product not found</h1>
|
|
||||||
<pre className="mt-4 rounded-xl bg-[var(--admin-card-bg)] border border-[var(--admin-border)] p-4 text-sm text-[var(--admin-text-muted)]">
|
|
||||||
{error?.message ?? "Product not found"}
|
|
||||||
</pre>
|
|
||||||
<Link
|
|
||||||
href="/admin/products"
|
|
||||||
className="mt-4 inline-block text-sm text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)]"
|
|
||||||
>
|
|
||||||
← Back to Products
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen bg-[var(--admin-bg)] px-4 sm:px-6 md:px-8 py-6 sm:py-8">
|
|
||||||
<div className="mx-auto max-w-4xl">
|
|
||||||
<div className="ha-eyebrow mb-2">Operations</div>
|
|
||||||
<PageHeader
|
|
||||||
icon={<PackageIcon className="h-5 w-5" strokeWidth={1.75} />}
|
|
||||||
title="Edit product"
|
|
||||||
subtitle="Update product details, pricing, and availability."
|
|
||||||
actions={
|
|
||||||
<AdminBadge tone={product.active ? "success" : "neutral"} dot>
|
|
||||||
{product.active ? "Active" : "Inactive"}
|
|
||||||
</AdminBadge>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<Link
|
|
||||||
href="/admin/products"
|
|
||||||
className="text-sm text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] transition-colors"
|
|
||||||
>
|
|
||||||
← Back to Products
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-2xl bg-[var(--admin-card-bg)] p-8 shadow-sm border border-[var(--admin-border)]">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-semibold uppercase tracking-wide text-[var(--admin-text-muted)]">
|
|
||||||
{product.brands?.name}
|
|
||||||
</p>
|
|
||||||
<h1 className="mt-2 text-3xl font-bold text-[var(--admin-text-primary)]">
|
|
||||||
{product.name}
|
|
||||||
</h1>
|
|
||||||
<p className="mt-2 text-lg text-[var(--admin-text-secondary)]">
|
|
||||||
{product.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 grid grid-cols-2 gap-6">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-[var(--admin-text-muted)]">Price</p>
|
|
||||||
<p
|
|
||||||
className="mt-1 text-2xl font-bold text-[var(--admin-text-primary)]"
|
|
||||||
style={{ fontFamily: "var(--font-fragment-mono)", fontVariantNumeric: "tabular-nums" }}
|
|
||||||
>
|
|
||||||
${Number(product.price).toFixed(2)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-[var(--admin-text-muted)]">Type</p>
|
|
||||||
<p className="mt-1 text-lg font-semibold text-[var(--admin-text-primary)]">
|
|
||||||
{product.type}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 rounded-2xl bg-[var(--admin-card-bg)] p-8 shadow-sm border border-[var(--admin-border)]">
|
|
||||||
<h2 className="text-2xl font-bold text-[var(--admin-text-primary)]">Edit Product</h2>
|
|
||||||
<p className="mt-1 text-[var(--admin-text-muted)]">
|
|
||||||
Update product details, pricing, and availability.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<ProductEditForm
|
|
||||||
product={{
|
|
||||||
id: product.id,
|
|
||||||
name: product.name,
|
|
||||||
description: product.description ?? "",
|
|
||||||
price: Number(product.price),
|
|
||||||
type: product.type ?? "pickup",
|
|
||||||
active: product.active,
|
|
||||||
brand_id: product.brand_id,
|
|
||||||
image_url: product.image_url ?? "",
|
|
||||||
is_taxable: product.is_taxable,
|
|
||||||
pickup_type: product.pickup_type ?? "pickup",
|
|
||||||
}}
|
|
||||||
brands={brands ?? []}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { supabase } from "@/lib/supabase";
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
|
||||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
|
||||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
|
||||||
import ReportsDashboard from "@/components/admin/ReportsDashboard";
|
|
||||||
import { PageHeader } from "@/components/admin/design-system";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
|
|
||||||
export default async function ReportsPage() {
|
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) return <AdminAccessDenied />;
|
|
||||||
if (!adminUser.can_manage_reports) redirect("/admin/pickup");
|
|
||||||
|
|
||||||
const isPlatformAdmin = adminUser.role === "platform_admin";
|
|
||||||
|
|
||||||
const { data: brands } = await supabase
|
|
||||||
.from("brands")
|
|
||||||
.select("id, name")
|
|
||||||
.order("name") as unknown as { data: { id: string; name: string }[] | null };
|
|
||||||
|
|
||||||
const initialBrandId = isPlatformAdmin
|
|
||||||
? null
|
|
||||||
: await getActiveBrandId(adminUser);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen px-6 py-10" style={{ backgroundColor: "var(--admin-bg)" }}>
|
|
||||||
<div className="mx-auto max-w-7xl">
|
|
||||||
<PageHeader
|
|
||||||
title="Reports"
|
|
||||||
subtitle="Operational visibility across orders, fulfillment, contacts, and campaigns."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ReportsDashboard
|
|
||||||
brands={brands ?? []}
|
|
||||||
initialBrandId={initialBrandId}
|
|
||||||
isPlatformAdmin={isPlatformAdmin}
|
|
||||||
brandId={adminUser.brand_id}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
import { supabase } from "@/lib/supabase";
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
|
||||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
|
||||||
import { getBillingOverview } from "@/actions/billing/billing-overview";
|
|
||||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
|
||||||
import BillingClientPage from "./BillingClientPage";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
params: Promise<{ brandId?: string }>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function BillingPage({ params }: Props) {
|
|
||||||
const { brandId: brandIdParam } = await params;
|
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) return <AdminAccessDenied />;
|
|
||||||
|
|
||||||
const activeBrandId = await getActiveBrandId(adminUser, brandIdParam);
|
|
||||||
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
|
||||||
return <AdminAccessDenied message="You don't have access to that brand." />;
|
|
||||||
}
|
|
||||||
const effectiveBrandId = activeBrandId ?? "";
|
|
||||||
const isPlatformAdmin = adminUser.role === "platform_admin";
|
|
||||||
|
|
||||||
let resolvedBrandId = effectiveBrandId;
|
|
||||||
if (isPlatformAdmin && !resolvedBrandId) {
|
|
||||||
const { data: firstBrand } = await supabase
|
|
||||||
.from("brands")
|
|
||||||
.select("id")
|
|
||||||
.limit(1)
|
|
||||||
.single();
|
|
||||||
if (firstBrand?.id) {
|
|
||||||
resolvedBrandId = String(firstBrand.id);
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen bg-[var(--admin-bg)] px-6 py-12">
|
|
||||||
<div className="mx-auto max-w-6xl">
|
|
||||||
<nav className="flex items-center gap-2 text-xs text-[var(--admin-text-muted)] mb-6">
|
|
||||||
<a href="/admin" className="hover:text-[var(--admin-text-primary)] transition-colors">Admin</a>
|
|
||||||
<span>/</span>
|
|
||||||
<span className="text-[var(--admin-text-primary)]">Billing</span>
|
|
||||||
</nav>
|
|
||||||
<div className="rounded-2xl bg-white shadow-md ring-1 ring-[var(--admin-border)] p-8 text-center">
|
|
||||||
<h1 className="text-2xl font-bold text-[var(--admin-text-primary)]">No Brands Found</h1>
|
|
||||||
<p className="mt-2 text-[var(--admin-text-muted)]">Create a brand in the database before accessing billing settings.</p>
|
|
||||||
<a href="/admin" className="mt-4 inline-block rounded-xl bg-[var(--admin-accent)] hover:bg-[var(--admin-accent-hover)] px-6 py-3 text-sm font-medium text-white transition-colors">
|
|
||||||
Back to Admin
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!resolvedBrandId) return <AdminAccessDenied />;
|
|
||||||
|
|
||||||
// Single source of truth for everything the billing client needs.
|
|
||||||
const overviewRes = await getBillingOverview(resolvedBrandId);
|
|
||||||
if (!overviewRes.success || !overviewRes.data) {
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen bg-[var(--admin-bg)] px-6 py-12">
|
|
||||||
<div className="mx-auto max-w-6xl">
|
|
||||||
<div className="rounded-2xl bg-white shadow-md ring-1 ring-[var(--admin-border)] p-8 text-center">
|
|
||||||
<h1 className="text-2xl font-bold text-[var(--admin-text-primary)]">Billing Unavailable</h1>
|
|
||||||
<p className="mt-2 text-[var(--admin-text-muted)]">{overviewRes.error ?? "Could not load billing information."}</p>
|
|
||||||
<a href="/admin" className="mt-4 inline-block rounded-xl bg-[var(--admin-accent)] hover:bg-[var(--admin-accent-hover)] px-6 py-3 text-sm font-medium text-white transition-colors">
|
|
||||||
Back to Admin
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const overview = overviewRes.data;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen bg-[var(--admin-bg)]">
|
|
||||||
{/* Platform billing header */}
|
|
||||||
<div className="bg-[var(--admin-bg-subtle)] border-b border-[var(--admin-border)] px-6 py-3">
|
|
||||||
<div className="mx-auto max-w-6xl">
|
|
||||||
<p className="text-xs text-[var(--admin-text-muted)]">
|
|
||||||
<span className="font-medium text-[var(--admin-text-primary)]">Route Commerce Platform Billing</span>
|
|
||||||
{" — "}Invoiced by Cielo Hermosa, LLC · Manage your platform subscription and add-ons.
|
|
||||||
{" "}Questions? <a href="mailto:billing@cielohermosa.com" className="text-[var(--admin-accent)] hover:text-[var(--admin-accent-hover)] underline transition-colors">billing@cielohermosa.com</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mx-auto max-w-6xl px-6 py-10">
|
|
||||||
{/* Breadcrumb */}
|
|
||||||
<nav className="flex items-center gap-2 text-xs text-[var(--admin-text-muted)] mb-6">
|
|
||||||
<a href="/admin" className="hover:text-[var(--admin-text-primary)] transition-colors">Admin</a>
|
|
||||||
<span>/</span>
|
|
||||||
<a href="/admin/settings" className="hover:text-[var(--admin-text-primary)] transition-colors">Settings</a>
|
|
||||||
<span>/</span>
|
|
||||||
<span className="text-[var(--admin-text-primary)]">Billing</span>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div className="mb-8">
|
|
||||||
<h1 className="text-3xl font-bold text-[var(--admin-text-primary)]">Billing & Subscription</h1>
|
|
||||||
<p className="mt-1 text-[var(--admin-text-muted)]">
|
|
||||||
Manage your Route Commerce subscription for {overview.brandName ?? "your brand"}.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<BillingClientPage overview={overview} />
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
import { redirect } from "next/navigation";
|
|
||||||
import { supabase } from "@/lib/supabase";
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
|
||||||
import IntegrationsClientPage from "./IntegrationsClientPage";
|
|
||||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
|
||||||
|
|
||||||
export const metadata = {
|
|
||||||
title: "Integrations - Route Commerce Admin",
|
|
||||||
description: "Configure integrations for AI, email, SMS, and payments",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function IntegrationsPage() {
|
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) return <AdminAccessDenied />;
|
|
||||||
|
|
||||||
const isPlatformAdmin = adminUser.role === "platform_admin";
|
|
||||||
const brandId = adminUser.brand_id ?? "";
|
|
||||||
|
|
||||||
// Platform admins: fetch all brands for the picker
|
|
||||||
const brands = isPlatformAdmin
|
|
||||||
? ((await supabase.from("brands").select("id, name").order("name")) as unknown as { data: { id: string; name: string }[] }).data ?? []
|
|
||||||
: [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen bg-[var(--admin-bg)]">
|
|
||||||
<div className="px-4 sm:px-6 md:px-8 pt-4 sm:pt-6 pb-10">
|
|
||||||
{/* Breadcrumb */}
|
|
||||||
<nav className="flex items-center gap-2 text-xs text-[var(--admin-text-muted)] mb-6">
|
|
||||||
<a href="/admin" className="hover:text-[var(--admin-text-primary)] transition-colors">Admin</a>
|
|
||||||
<span>/</span>
|
|
||||||
<a href="/admin/settings" className="hover:text-[var(--admin-text-primary)] transition-colors">Settings</a>
|
|
||||||
<span>/</span>
|
|
||||||
<span className="text-[var(--admin-text-primary)]">Integrations</span>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* Page Header */}
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-[var(--admin-accent)] text-white">
|
|
||||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl sm:text-3xl font-bold text-[var(--admin-text-primary)]">Integrations</h1>
|
|
||||||
<p className="text-sm text-[var(--admin-text-muted)]">
|
|
||||||
Connect AI, email, SMS, and payment providers to power your operations.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<IntegrationsClientPage
|
|
||||||
brandId={brandId}
|
|
||||||
brands={brands}
|
|
||||||
isPlatformAdmin={isPlatformAdmin}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import { redirect } from "next/navigation";
|
|
||||||
import { supabase } from "@/lib/supabase";
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
|
||||||
import { getShippingSettings } from "@/actions/shipping/settings";
|
|
||||||
import ShippingSettingsForm from "@/components/admin/ShippingSettingsForm";
|
|
||||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
|
||||||
|
|
||||||
export default async function ShippingSettingsPage() {
|
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) return <AdminAccessDenied />;
|
|
||||||
if (!adminUser.can_manage_orders || adminUser.role === "store_employee") {
|
|
||||||
redirect("/admin/pickup");
|
|
||||||
}
|
|
||||||
|
|
||||||
const isPlatformAdmin = adminUser.role === "platform_admin";
|
|
||||||
const brandId = adminUser.brand_id ?? "";
|
|
||||||
|
|
||||||
// Platform admins: fetch all brands for the picker
|
|
||||||
const brands = isPlatformAdmin
|
|
||||||
? ((await supabase.from("brands").select("id, name").order("name")) as unknown as { data: { id: string; name: string }[] }).data ?? []
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const effectiveBrandId = brandId || (brands[0]?.id ?? "");
|
|
||||||
|
|
||||||
const result = await getShippingSettings(effectiveBrandId);
|
|
||||||
const settings = result.success ? result.settings : null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen px-6 py-10" style={{ backgroundColor: "var(--admin-bg)" }}>
|
|
||||||
<div className="mx-auto max-w-2xl">
|
|
||||||
{/* Breadcrumb */}
|
|
||||||
<nav className="flex items-center gap-2 text-xs mb-6" style={{ color: "var(--admin-text-muted)" }}>
|
|
||||||
<a href="/admin" className="hover:text-[var(--admin-text-primary)] transition-colors">Admin</a>
|
|
||||||
<span>/</span>
|
|
||||||
<span style={{ color: "var(--admin-text-primary)" }}>Shipping</span>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div className="mb-8">
|
|
||||||
<div className="flex items-center gap-3 mb-2">
|
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl" style={{ backgroundColor: "var(--admin-accent)", color: "white" }}>
|
|
||||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 18.75a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h6m-9 0H3.375a1.125 1.125 0 01-1.125-1.125V14.25m17.25 4.5a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h1.125c.621 0 1.129-.504 1.09-1.124a17.902 17.902 0 00-3.213-9.193 2.056 2.056 0 00-1.58-.86H14.25m-2.25 0h2.25m0 0v-.375c0-.621-.504-1.125-1.125-1.125H15m-1.5-3l1.5 0l.75 0v-.375c0-.621-.504-1.125-1.125-1.125H15m0 0v-.375c0-.621-.504-1.125-1.125-1.125H12" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h1 className="text-3xl font-bold" style={{ color: "var(--admin-text-primary)" }}>Shipping Settings</h1>
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-sm" style={{ color: "var(--admin-text-muted)" }}>
|
|
||||||
Configure FedEx integration for shipping fresh produce — sweet corn, onions, and more.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-2xl border p-6 shadow-lg" style={{ backgroundColor: "white", borderColor: "var(--admin-border)" }}>
|
|
||||||
<ShippingSettingsForm
|
|
||||||
settings={settings}
|
|
||||||
brandId={effectiveBrandId}
|
|
||||||
brands={brands}
|
|
||||||
isPlatformAdmin={isPlatformAdmin}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
"use server";
|
|
||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
|
||||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
|
||||||
import { supabase } from "@/lib/supabase";
|
|
||||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
|
||||||
import TaxDashboard from "@/components/admin/TaxDashboard";
|
|
||||||
import { PageHeader } from "@/components/admin/design-system";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
params: Promise<{ brandId?: string }>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function TaxesPage({ params }: Props) {
|
|
||||||
const { brandId: brandIdParam } = await params;
|
|
||||||
const adminUser = await getAdminUser();
|
|
||||||
if (!adminUser) return <AdminAccessDenied />;
|
|
||||||
|
|
||||||
const activeBrandId = await getActiveBrandId(adminUser, brandIdParam);
|
|
||||||
if (!activeBrandId && adminUser.role !== "platform_admin") {
|
|
||||||
return <AdminAccessDenied message="You don't have access to that brand." />;
|
|
||||||
}
|
|
||||||
const effectiveBrandId = activeBrandId ?? "";
|
|
||||||
const isPlatformAdmin = adminUser.role === "platform_admin";
|
|
||||||
|
|
||||||
let resolvedBrandId = effectiveBrandId;
|
|
||||||
if (isPlatformAdmin && !resolvedBrandId) {
|
|
||||||
const { data: firstBrand } = await supabase
|
|
||||||
.from("brands")
|
|
||||||
.select("id")
|
|
||||||
.limit(1)
|
|
||||||
.single() as unknown as { data: { id: string } | null };
|
|
||||||
if (firstBrand?.id) {
|
|
||||||
resolvedBrandId = String(firstBrand.id);
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen px-6 py-12" style={{ backgroundColor: "var(--admin-bg)" }}>
|
|
||||||
<div className="mx-auto max-w-6xl">
|
|
||||||
<div className="rounded-2xl p-8 text-center border" style={{
|
|
||||||
backgroundColor: "white",
|
|
||||||
borderColor: "var(--admin-border)"
|
|
||||||
}}>
|
|
||||||
<h1 className="text-2xl font-bold" style={{ color: "var(--admin-text-primary)" }}>No Brands Found</h1>
|
|
||||||
<p className="mt-2" style={{ color: "var(--admin-text-muted)" }}>Create a brand in the database first.</p>
|
|
||||||
<a href="/admin" className="mt-4 inline-block rounded-xl px-6 py-3 text-sm font-medium border transition-colors"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--admin-bg-subtle)",
|
|
||||||
borderColor: "var(--admin-border)",
|
|
||||||
color: "var(--admin-text-primary)"
|
|
||||||
}}>
|
|
||||||
Back to Admin
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!resolvedBrandId) return <AdminAccessDenied />;
|
|
||||||
|
|
||||||
const { data: brands } = await supabase
|
|
||||||
.from("brands")
|
|
||||||
.select("id, name")
|
|
||||||
.order("name");
|
|
||||||
|
|
||||||
const allBrands = (brands ?? []) as Array<{ id: string; name: string }>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen" style={{ backgroundColor: "var(--admin-bg)" }}>
|
|
||||||
<div className="mx-auto max-w-6xl px-6 py-10">
|
|
||||||
<PageHeader
|
|
||||||
title="Tax Dashboard"
|
|
||||||
subtitle="Sales tax collected on orders shipped to nexus states."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TaxDashboard
|
|
||||||
brands={allBrands}
|
|
||||||
initialBrandId={resolvedBrandId}
|
|
||||||
isPlatformAdmin={isPlatformAdmin}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -1,36 +1,44 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { PDFDocument, rgb, StandardFonts } from "pdf-lib";
|
import { PDFDocument, rgb, StandardFonts } from "pdf-lib";
|
||||||
import { supabase } from "@/lib/supabase";
|
import { pool } from "@/lib/db";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const brandSlug = "indian-river-direct";
|
const brandSlug = "indian-river-direct";
|
||||||
|
|
||||||
const { data: brand } = await supabase
|
const { rows: brandRows } = await pool.query<{ id: string; name: string }>(
|
||||||
.from("brands")
|
`SELECT id, name FROM brands WHERE slug = $1 LIMIT 1`,
|
||||||
.select("id, name")
|
[brandSlug],
|
||||||
.eq("slug", brandSlug)
|
);
|
||||||
.single() as unknown as { data: { id: string; name: string } | null };
|
const brand = brandRows[0];
|
||||||
|
|
||||||
if (!brand?.id) {
|
if (!brand?.id) {
|
||||||
return new NextResponse("Brand not found", { status: 404 });
|
return new NextResponse("Brand not found", { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: stops } = await supabase
|
const { rows: stops } = await pool.query<{ city: string; state: string; date: string; time: string; location: string }>(
|
||||||
.from("stops")
|
`SELECT city, state, date::text AS date, time, location
|
||||||
.select("*")
|
FROM stops
|
||||||
.eq("brand_id", brand.id)
|
WHERE brand_id = $1 AND is_public = true AND status = 'active'
|
||||||
.eq("active", true)
|
ORDER BY date ASC, time ASC`,
|
||||||
.order("date", { ascending: true }) as unknown as { data: { city: string; state: string; date: string; time: string; location: string }[] | null };
|
[brand.id],
|
||||||
|
);
|
||||||
|
|
||||||
const { data: settings } = await supabase
|
const { rows: settingsRows } = await pool.query<{
|
||||||
.from("brand_settings")
|
logo_url: string | null;
|
||||||
.select("logo_url, email, phone, schedule_pdf_notes")
|
email: string | null;
|
||||||
.eq("brand_id", brand.id)
|
phone: string | null;
|
||||||
.single() as unknown as { data: { logo_url: string | null; email: string | null; phone: string | null; schedule_pdf_notes: string | null } | null };
|
schedule_pdf_notes: string | null;
|
||||||
|
}>(
|
||||||
|
`SELECT logo_url, email, phone, schedule_pdf_notes
|
||||||
|
FROM brand_settings
|
||||||
|
WHERE brand_id = $1
|
||||||
|
LIMIT 1`,
|
||||||
|
[brand.id],
|
||||||
|
);
|
||||||
|
const settings = settingsRows[0] ?? null;
|
||||||
|
|
||||||
const pdfBytes = await buildProfessionalSchedulePdf({
|
const pdfBytes = await buildProfessionalSchedulePdf({
|
||||||
brandName: brand.name,
|
brandName: brand.name,
|
||||||
stops: stops ?? [],
|
stops: stops,
|
||||||
logoUrl: settings?.logo_url ?? null,
|
logoUrl: settings?.logo_url ?? null,
|
||||||
contactEmail: settings?.email ?? null,
|
contactEmail: settings?.email ?? null,
|
||||||
contactPhone: settings?.phone ?? null,
|
contactPhone: settings?.phone ?? null,
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { pool } from "@/lib/db";
|
||||||
|
|
||||||
|
function escapeAttr(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const form = await request.formData();
|
||||||
|
const code = String(form.get("code") ?? "");
|
||||||
|
const state = String(form.get("state") ?? "");
|
||||||
|
const error = String(form.get("error") ?? "");
|
||||||
|
const originHeader = request.headers.get("origin");
|
||||||
|
const baseUrl = originHeader ?? new URL(request.url).origin;
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return NextResponse.redirect(
|
||||||
|
new URL(
|
||||||
|
`/admin/settings/payments?error=square_oauth_denied&reason=${encodeURIComponent(error)}`,
|
||||||
|
baseUrl,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!code || !state) {
|
||||||
|
return NextResponse.redirect(
|
||||||
|
new URL("/admin/settings/payments?error=square_oauth_missing_params", baseUrl),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let brandId: string | null = null;
|
||||||
|
try {
|
||||||
|
const decoded = JSON.parse(Buffer.from(state, "base64").toString("utf-8"));
|
||||||
|
brandId = decoded?.brandId ?? null;
|
||||||
|
} catch {
|
||||||
|
return NextResponse.redirect(
|
||||||
|
new URL("/admin/settings/payments?error=square_oauth_invalid_state", baseUrl),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!brandId) {
|
||||||
|
return NextResponse.redirect(
|
||||||
|
new URL("/admin/settings/payments?error=square_oauth_missing_state", baseUrl),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const appId = process.env.NEXT_PUBLIC_SQUARE_APP_ID;
|
||||||
|
const appSecret = process.env.SQUARE_APP_SECRET;
|
||||||
|
const env = process.env.SQUARE_ENVIRONMENT ?? "sandbox";
|
||||||
|
if (!appId || !appSecret) {
|
||||||
|
return NextResponse.redirect(
|
||||||
|
new URL("/admin/settings/payments?error=square_credentials_not_configured", baseUrl),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenUrl =
|
||||||
|
env === "production"
|
||||||
|
? "https://connect.squareup.com/v2/oauth2/token"
|
||||||
|
: "https://connect.squareupsandbox.com/v2/oauth2/token";
|
||||||
|
const redirectUri = `${baseUrl}/api/square/oauth/callback`;
|
||||||
|
|
||||||
|
const tokenResponse = await fetch(tokenUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Square-Version": "2025-01-16",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
client_id: appId,
|
||||||
|
client_secret: appSecret,
|
||||||
|
code,
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const tokenData = await tokenResponse.json();
|
||||||
|
|
||||||
|
const accessToken: string | null = tokenData?.access_token ?? null;
|
||||||
|
const locationId: string | null = tokenData?.location_id ?? null;
|
||||||
|
if (!tokenResponse.ok || !accessToken) {
|
||||||
|
return NextResponse.redirect(
|
||||||
|
new URL("/admin/settings/payments?error=square_token_exchange_failed", baseUrl),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
"SELECT upsert_payment_settings($1, $2, $3, $4, $5, $6, $7, $8, $9)",
|
||||||
|
[
|
||||||
|
brandId,
|
||||||
|
"square",
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
accessToken,
|
||||||
|
locationId,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.redirect(
|
||||||
|
new URL("/admin/settings/payments?square_connected=true", baseUrl),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
const baseUrl = request.headers.get("origin") ?? new URL(request.url).origin;
|
||||||
|
return NextResponse.redirect(
|
||||||
|
new URL("/admin/settings/payments?error=square_token_save_error", baseUrl),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
// GET on /complete is not supported; redirect to settings.
|
||||||
|
return NextResponse.redirect(new URL("/admin/settings/payments"));
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import { savePaymentSettings } from "@/actions/payments";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const form = await request.formData();
|
||||||
|
const code = String(form.get("code") ?? "");
|
||||||
|
const error = String(form.get("error") ?? "");
|
||||||
|
const state = String(form.get("state") ?? "");
|
||||||
|
const baseUrl = request.headers.get("origin") ?? new URL(request.url).origin;
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return NextResponse.redirect(
|
||||||
|
new URL(`/admin/settings/payments?stripe_error=${encodeURIComponent(error)}`, baseUrl),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!code) {
|
||||||
|
return NextResponse.redirect(
|
||||||
|
new URL("/admin/settings/payments?stripe_error=no_code", baseUrl),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser || !adminUser.brand_id) {
|
||||||
|
return NextResponse.redirect(new URL("/admin/settings/payments?error=unauthorized", baseUrl));
|
||||||
|
}
|
||||||
|
let brandId = adminUser.brand_id;
|
||||||
|
if (state) {
|
||||||
|
try {
|
||||||
|
const decoded = JSON.parse(Buffer.from(state, "base64").toString());
|
||||||
|
if (decoded.brandId) brandId = decoded.brandId;
|
||||||
|
} catch {
|
||||||
|
// ignore malformed state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientId = process.env.STRIPE_CLIENT_ID;
|
||||||
|
const clientSecret = process.env.STRIPE_CLIENT_SECRET;
|
||||||
|
if (!clientId || !clientSecret) {
|
||||||
|
return NextResponse.redirect(
|
||||||
|
new URL("/admin/settings/payments?stripe_error=oauth_not_configured", baseUrl),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenResponse = await fetch("https://connect.stripe.com/oauth/token", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code,
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const tokenData = await tokenResponse.json();
|
||||||
|
|
||||||
|
if (tokenData?.error) {
|
||||||
|
return NextResponse.redirect(
|
||||||
|
new URL(
|
||||||
|
`/admin/settings/payments?stripe_error=${encodeURIComponent(tokenData.error_description || tokenData.error)}`,
|
||||||
|
baseUrl,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripeUserId: string | undefined = tokenData.stripe_user_id;
|
||||||
|
const accessToken: string | undefined = tokenData.access_token;
|
||||||
|
const publishableKey: string | undefined = tokenData.stripe_publishable_key;
|
||||||
|
|
||||||
|
const result = await savePaymentSettings({
|
||||||
|
brandId,
|
||||||
|
provider: "stripe",
|
||||||
|
stripePublishableKey: publishableKey,
|
||||||
|
stripeSecretKey: accessToken,
|
||||||
|
stripeUserId,
|
||||||
|
});
|
||||||
|
if (result.success) {
|
||||||
|
return NextResponse.redirect(
|
||||||
|
new URL("/admin/settings/payments?stripe_connected=true", baseUrl),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return NextResponse.redirect(
|
||||||
|
new URL(
|
||||||
|
`/admin/settings/payments?stripe_error=${encodeURIComponent(result.error || "Failed to save")}`,
|
||||||
|
baseUrl,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
const baseUrl = request.headers.get("origin") ?? new URL(request.url).origin;
|
||||||
|
return NextResponse.redirect(
|
||||||
|
new URL(
|
||||||
|
`/admin/settings/payments?stripe_error=${encodeURIComponent("Failed to complete OAuth")}`,
|
||||||
|
baseUrl,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return NextResponse.redirect(new URL("/admin/settings/payments"));
|
||||||
|
}
|
||||||
@@ -1,44 +1,44 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { PDFDocument, rgb, StandardFonts } from "pdf-lib";
|
import { PDFDocument, rgb, StandardFonts } from "pdf-lib";
|
||||||
import { supabase } from "@/lib/supabase";
|
import { pool } from "@/lib/db";
|
||||||
|
|
||||||
type Settings = {
|
|
||||||
logo_url: string | null;
|
|
||||||
contact_email: string | null;
|
|
||||||
contact_phone: string | null;
|
|
||||||
schedule_pdf_notes: string | null;
|
|
||||||
brand_name: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const brandSlug = "tuxedo";
|
const brandSlug = "tuxedo";
|
||||||
|
|
||||||
const { data: brand } = await supabase
|
const { rows: brandRows } = await pool.query<{ id: string; name: string }>(
|
||||||
.from("brands")
|
`SELECT id, name FROM brands WHERE slug = $1 LIMIT 1`,
|
||||||
.select("id, name")
|
[brandSlug],
|
||||||
.eq("slug", brandSlug)
|
);
|
||||||
.single() as unknown as { data: { id: string; name: string } | null };
|
const brand = brandRows[0];
|
||||||
|
|
||||||
if (!brand?.id) {
|
if (!brand?.id) {
|
||||||
return new NextResponse("Brand not found", { status: 404 });
|
return new NextResponse("Brand not found", { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: stops } = await supabase
|
const { rows: stops } = await pool.query<{ city: string; state: string; date: string; time: string; location: string }>(
|
||||||
.from("stops")
|
`SELECT city, state, date::text AS date, time, location
|
||||||
.select("city, state, date, time, location")
|
FROM stops
|
||||||
.eq("brand_id", brand.id)
|
WHERE brand_id = $1 AND is_public = true AND status = 'active'
|
||||||
.eq("active", true)
|
ORDER BY date ASC, time ASC`,
|
||||||
.order("date", { ascending: true }) as unknown as { data: { city: string; state: string; date: string; time: string; location: string }[] | null };
|
[brand.id],
|
||||||
|
);
|
||||||
|
|
||||||
const { data: settings } = await supabase
|
const { rows: settingsRows } = await pool.query<{
|
||||||
.from("brand_settings")
|
logo_url: string | null;
|
||||||
.select("logo_url, email, phone, schedule_pdf_notes")
|
email: string | null;
|
||||||
.eq("brand_id", brand.id)
|
phone: string | null;
|
||||||
.single() as unknown as { data: { logo_url: string | null; email: string | null; phone: string | null; schedule_pdf_notes: string | null } | null };
|
schedule_pdf_notes: string | null;
|
||||||
|
}>(
|
||||||
|
`SELECT logo_url, email, phone, schedule_pdf_notes
|
||||||
|
FROM brand_settings
|
||||||
|
WHERE brand_id = $1
|
||||||
|
LIMIT 1`,
|
||||||
|
[brand.id],
|
||||||
|
);
|
||||||
|
const settings = settingsRows[0] ?? null;
|
||||||
|
|
||||||
const pdfBytes = await buildProfessionalSchedulePdf({
|
const pdfBytes = await buildProfessionalSchedulePdf({
|
||||||
brandName: brand.name,
|
brandName: brand.name,
|
||||||
stops: stops ?? [],
|
stops: stops,
|
||||||
logoUrl: settings?.logo_url ?? null,
|
logoUrl: settings?.logo_url ?? null,
|
||||||
contactEmail: settings?.email ?? null,
|
contactEmail: settings?.email ?? null,
|
||||||
contactPhone: settings?.phone ?? null,
|
contactPhone: settings?.phone ?? null,
|
||||||
|
|||||||
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -5,14 +5,11 @@ import { motion } from "framer-motion";
|
|||||||
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 { supabase } from "@/lib/supabase";
|
import { getBrandSettingsPublic } from "@/actions/brand-settings";
|
||||||
|
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 = {
|
||||||
@@ -34,17 +31,22 @@ export default function IndianRiverContactPage() {
|
|||||||
const [contactPhone, setContactPhone] = useState<string | null>(null);
|
const [contactPhone, setContactPhone] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
supabase.from("brands").select("id").eq("slug", "indian-river-direct").single().then(({ data: brand }) => {
|
Promise.all([
|
||||||
if (!brand?.id) return;
|
getStorefrontWholesaleSettings("indian-river-direct"),
|
||||||
supabase.from("wholesale_settings")
|
getBrandSettingsPublic("indian-river-direct"),
|
||||||
.select("invoice_business_name, invoice_business_address, invoice_business_phone, invoice_business_email, invoice_business_website")
|
]).then(([wholesale, brandSettings]) => {
|
||||||
.eq("brand_id", brand.id).single().then(({ data }) => setBrandSettings((data as unknown as BrandSettings) ?? null));
|
setBrandSettings(wholesale as BrandSettings | null);
|
||||||
supabase.from("brand_settings").select("logo_url, custom_footer_text, email, phone")
|
if (brandSettings.success && brandSettings.settings) {
|
||||||
.eq("brand_id", brand.id).single().then(({ data: s }) => {
|
const s = brandSettings.settings;
|
||||||
if (s) { setLogoUrl((s as { logo_url: string | null }).logo_url ?? null); setCustomFooterText((s as { custom_footer_text: string | null }).custom_footer_text ?? null); setContactEmail((s as { email: string | null }).email ?? null); setContactPhone((s as { phone: string | null }).phone ?? null); }
|
setLogoUrl(s.logo_url ?? null);
|
||||||
});
|
setCustomFooterText(s.custom_footer_text ?? null);
|
||||||
try { import("@/actions/admin-user").then(({ getCurrentAdminUser }) => { getCurrentAdminUser().then((u: unknown) => setIsAdmin(!!u)); }); } catch { /* not logged in */ }
|
setContactEmail(s.email ?? null);
|
||||||
|
setContactPhone(s.phone ?? null);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
import("@/actions/admin-user").then(({ getCurrentAdminUser }) => {
|
||||||
|
getCurrentAdminUser().then((u: unknown) => setIsAdmin(!!u));
|
||||||
|
}).catch(() => { /* not logged in */ });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function handleSubmit(e: React.FormEvent) {
|
function handleSubmit(e: React.FormEvent) {
|
||||||
@@ -84,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" />
|
||||||
},
|
},
|
||||||
@@ -96,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
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { motion } from "framer-motion";
|
|||||||
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 { supabase } from "@/lib/supabase";
|
import { getBrandSettingsPublic } from "@/actions/brand-settings";
|
||||||
|
|
||||||
type FAQCategory = {
|
type FAQCategory = {
|
||||||
category: string;
|
category: string;
|
||||||
@@ -136,10 +136,10 @@ export default function IndianRiverFAQPage() {
|
|||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
supabase.from("brand_settings").select("phone").eq("brand_id",
|
getBrandSettingsPublic("indian-river-direct").then((result) => {
|
||||||
"b1cb7a96-d82b-40b1-80b1-d6dd26c56e28"
|
if (result.success && result.settings?.phone) {
|
||||||
).single().then(({ data }) => {
|
setFaqCategories(buildFaqCategories(result.settings.phone));
|
||||||
if (data && (data as { phone: string | null }).phone) setFaqCategories(buildFaqCategories((data as { phone: string | null }).phone!));
|
}
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ 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 WhyIndianRiverDirect from "@/components/storefront/WhyIndianRiverDirect";
|
import WhyIndianRiverDirect from "@/components/storefront/WhyIndianRiverDirect";
|
||||||
import { supabase } from "@/lib/supabase";
|
|
||||||
import { getBrandSettingsPublic } from "@/actions/brand-settings";
|
import { getBrandSettingsPublic } from "@/actions/brand-settings";
|
||||||
|
import { getStorefrontData } from "@/actions/storefront";
|
||||||
|
|
||||||
type Brand = { id: string; name: string; slug: string };
|
type Brand = { id: string; name: string; slug: string };
|
||||||
type Stop = { id: string; city: string; state: string; date: string; time: string; location: string; slug: string; brand_id: string };
|
type Stop = { id: string; city: string; state: string; date: string; time: string; location: string; slug: string; brand_id: string };
|
||||||
@@ -85,12 +85,12 @@ export default function IndianRiverDirectPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function load() {
|
async function load() {
|
||||||
const slug = "indian-river-direct";
|
const slug = "indian-river-direct";
|
||||||
const [brandResult, settingsResult] = await Promise.all([
|
const [storefront, settingsResult] = await Promise.all([
|
||||||
supabase.from("brands").select("*").eq("slug", slug).single(),
|
getStorefrontData(slug),
|
||||||
getBrandSettingsPublic(slug),
|
getBrandSettingsPublic(slug),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const brandData = brandResult.data as Brand | null;
|
const brandData = storefront.brand as Brand | null;
|
||||||
setBrand(brandData);
|
setBrand(brandData);
|
||||||
|
|
||||||
if (settingsResult.success && settingsResult.settings) {
|
if (settingsResult.success && settingsResult.settings) {
|
||||||
@@ -110,12 +110,8 @@ export default function IndianRiverDirectPage() {
|
|||||||
} catch { /* not logged in */ }
|
} catch { /* not logged in */ }
|
||||||
|
|
||||||
if (brandData?.id) {
|
if (brandData?.id) {
|
||||||
const [{ data: stopsData }, { data: productsData }] = await Promise.all([
|
setStops(storefront.stops as unknown as Stop[]);
|
||||||
supabase.from("stops").select("*").eq("brand_id", brandData.id).eq("active", true) as unknown as { data: Stop[] | null },
|
setProducts(storefront.products as unknown as Product[]);
|
||||||
supabase.from("products").select("*").eq("brand_id", brandData.id).eq("active", true) as unknown as { data: Product[] | null },
|
|
||||||
]);
|
|
||||||
setStops(stopsData ?? []);
|
|
||||||
setProducts(productsData ?? []);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
load();
|
load();
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
+6
-21
@@ -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 { supabase } from "@/lib/supabase";
|
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,26 +53,11 @@ export default function StopPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function load() {
|
async function load() {
|
||||||
const { data: stopData } = await supabase
|
const result = await getStorefrontStopById(slug);
|
||||||
.from("stops")
|
if (!result) return;
|
||||||
.select("*")
|
|
||||||
.eq("slug", slug)
|
|
||||||
.eq("active", true)
|
|
||||||
.single() as unknown as { data: Stop | null };
|
|
||||||
|
|
||||||
if (!stopData) return;
|
setStop(result.stop as unknown as Stop);
|
||||||
|
setProducts(result.products as unknown as Product[]);
|
||||||
setStop(stopData);
|
|
||||||
const isIndian = slug.includes("indian");
|
|
||||||
setBrandSlug(isIndian ? "indian-river-direct" : "tuxedo");
|
|
||||||
setBrandAccent(isIndian ? "blue" : "green");
|
|
||||||
|
|
||||||
const { data: productsData } = await supabase
|
|
||||||
.from("products")
|
|
||||||
.select("*")
|
|
||||||
.eq("brand_id", stopData.brand_id)
|
|
||||||
.eq("active", true) as unknown as { data: Product[] | null };
|
|
||||||
setProducts(productsData ?? []);
|
|
||||||
}
|
}
|
||||||
load();
|
load();
|
||||||
}, [slug]);
|
}, [slug]);
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { supabase } from "@/lib/supabase";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export default async function TestPage() {
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from("brands")
|
|
||||||
.select("*");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen bg-slate-50 p-10">
|
|
||||||
<h1 className="text-3xl font-bold">
|
|
||||||
Supabase Test
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<pre className="mt-8 rounded-xl bg-black p-6 text-sm text-green-400">
|
|
||||||
{JSON.stringify({ data, error }, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -5,14 +5,10 @@ import Link from "next/link";
|
|||||||
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 { supabase } from "@/lib/supabase";
|
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 = {
|
||||||
@@ -29,13 +25,8 @@ export default function TuxedoContactPage() {
|
|||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
getStorefrontWholesaleSettings("tuxedo")
|
||||||
supabase
|
.then((data) => setBrandSettings(data as BrandSettings | null));
|
||||||
.from("wholesale_settings")
|
|
||||||
.select("invoice_business_name, invoice_business_address, invoice_business_phone, invoice_business_email, invoice_business_website")
|
|
||||||
.eq("brand_id", TUXEDO_BRAND_ID)
|
|
||||||
.single()
|
|
||||||
.then(({ data }) => setBrandSettings((data as unknown as BrandSettings) ?? null));
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function handleSubmit(e: React.FormEvent) {
|
function handleSubmit(e: React.FormEvent) {
|
||||||
@@ -77,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">
|
||||||
@@ -101,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>
|
||||||
|
|||||||
+6
-10
@@ -12,8 +12,8 @@ import StorefrontHeader from "@/components/storefront/StorefrontHeader";
|
|||||||
import StorefrontFooter from "@/components/storefront/StorefrontFooter";
|
import StorefrontFooter from "@/components/storefront/StorefrontFooter";
|
||||||
import BrandStylesProvider from "@/components/storefront/BrandStylesProvider";
|
import BrandStylesProvider from "@/components/storefront/BrandStylesProvider";
|
||||||
import { ScrollReveal, ParallaxLayer, FadeOnScroll } from "@/components/ui/ScrollAnimations";
|
import { ScrollReveal, ParallaxLayer, FadeOnScroll } from "@/components/ui/ScrollAnimations";
|
||||||
import { supabase } from "@/lib/supabase";
|
|
||||||
import { getBrandSettingsPublic } from "@/actions/brand-settings";
|
import { getBrandSettingsPublic } from "@/actions/brand-settings";
|
||||||
|
import { getStorefrontData } from "@/actions/storefront";
|
||||||
import { gsap } from "gsap";
|
import { gsap } from "gsap";
|
||||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||||
|
|
||||||
@@ -448,12 +448,12 @@ export default function TuxedoPage() {
|
|||||||
async function load() {
|
async function load() {
|
||||||
const slug = "tuxedo";
|
const slug = "tuxedo";
|
||||||
|
|
||||||
const [brandResult, settingsResult] = await Promise.all([
|
const [storefront, settingsResult] = await Promise.all([
|
||||||
supabase.from("brands").select("*").eq("slug", slug).single(),
|
getStorefrontData(slug),
|
||||||
getBrandSettingsPublic(slug),
|
getBrandSettingsPublic(slug),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const brandData = brandResult.data as Brand | null;
|
const brandData = storefront.brand as Brand | null;
|
||||||
setBrand(brandData);
|
setBrand(brandData);
|
||||||
|
|
||||||
if (settingsResult.success && settingsResult.settings) {
|
if (settingsResult.success && settingsResult.settings) {
|
||||||
@@ -484,12 +484,8 @@ export default function TuxedoPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (brandData?.id) {
|
if (brandData?.id) {
|
||||||
const [{ data: stopsData }, { data: productsData }] = await Promise.all([
|
setStops(storefront.stops as unknown as Stop[]);
|
||||||
supabase.from("stops").select("*").eq("brand_id", brandData.id).eq("active", true) as unknown as { data: Stop[] | null },
|
setProducts(storefront.products as unknown as Product[]);
|
||||||
supabase.from("products").select("*").eq("brand_id", brandData.id).eq("active", true) as unknown as { data: Product[] | null },
|
|
||||||
]);
|
|
||||||
setStops(stopsData ?? []);
|
|
||||||
setProducts(productsData ?? []);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
load();
|
load();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Fraunces, JetBrains_Mono } from "next/font/google";
|
import { Fraunces, JetBrains_Mono } from "next/font/google";
|
||||||
import { supabase } from "@/lib/supabase";
|
|
||||||
import { getBrandSettingsPublic } from "@/actions/brand-settings";
|
import { getBrandSettingsPublic } from "@/actions/brand-settings";
|
||||||
|
import { getStorefrontBrandBySlug } from "@/actions/storefront";
|
||||||
import SweetCornProductPage from "@/components/storefront/SweetCornProductPage";
|
import SweetCornProductPage from "@/components/storefront/SweetCornProductPage";
|
||||||
|
|
||||||
const fraunces = Fraunces({
|
const fraunces = Fraunces({
|
||||||
@@ -76,14 +76,13 @@ export default async function SweetCornBoxPage() {
|
|||||||
let logoUrlDark: string | null = null;
|
let logoUrlDark: string | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [brandRes, settingsRes] = await Promise.all([
|
const [brand, settingsRes] = await Promise.all([
|
||||||
supabase.from("brands").select("id, name").eq("slug", BRAND_SLUG).single(),
|
getStorefrontBrandBySlug(BRAND_SLUG),
|
||||||
getBrandSettingsPublic(BRAND_SLUG),
|
getBrandSettingsPublic(BRAND_SLUG),
|
||||||
]);
|
]);
|
||||||
if (brandRes.data) {
|
if (brand) {
|
||||||
const b = brandRes.data as Brand;
|
brandId = brand.id;
|
||||||
brandId = b.id;
|
brandName = brand.name;
|
||||||
brandName = b.name;
|
|
||||||
}
|
}
|
||||||
if (settingsRes.success && settingsRes.settings) {
|
if (settingsRes.success && settingsRes.settings) {
|
||||||
const s = settingsRes.settings as Settings;
|
const s = settingsRes.settings as Settings;
|
||||||
|
|||||||
@@ -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 { supabase } from "@/lib/supabase";
|
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,26 +52,11 @@ export default function StopPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function load() {
|
async function load() {
|
||||||
const { data: stopData } = await supabase
|
const result = await getStorefrontStopById(slug);
|
||||||
.from("stops")
|
if (!result) return;
|
||||||
.select("*")
|
|
||||||
.eq("slug", slug)
|
|
||||||
.eq("active", true)
|
|
||||||
.single() as unknown as { data: Stop | null };
|
|
||||||
|
|
||||||
if (!stopData) return;
|
setStop(result.stop as unknown as Stop);
|
||||||
|
setProducts(result.products as unknown as Product[]);
|
||||||
setStop(stopData);
|
|
||||||
const isIndian = slug.includes("indian");
|
|
||||||
setBrandSlug(isIndian ? "indian-river-direct" : "tuxedo");
|
|
||||||
setBrandAccent(isIndian ? "blue" : "green");
|
|
||||||
|
|
||||||
const { data: productsData } = await supabase
|
|
||||||
.from("products")
|
|
||||||
.select("*")
|
|
||||||
.eq("brand_id", stopData.brand_id)
|
|
||||||
.eq("active", true) as unknown as { data: Product[] | null };
|
|
||||||
setProducts(productsData ?? []);
|
|
||||||
}
|
}
|
||||||
load();
|
load();
|
||||||
}, [slug]);
|
}, [slug]);
|
||||||
@@ -5,6 +5,7 @@ import { formatDate } from "@/lib/format-date";
|
|||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { getWholesaleCustomerByUser, submitWholesaleOrder, getWholesaleProducts, getWholesaleCustomerOrders, getWholesaleCustomerPricing, getWholesaleCustomer, type WholesaleProduct, type WholesaleCustomerOrder, type WholesalePricingOverride } from "@/actions/wholesale-register";
|
import { getWholesaleCustomerByUser, submitWholesaleOrder, getWholesaleProducts, getWholesaleCustomerOrders, getWholesaleCustomerPricing, getWholesaleCustomer, type WholesaleProduct, type WholesaleCustomerOrder, type WholesalePricingOverride } from "@/actions/wholesale-register";
|
||||||
import { getWholesaleSettings, enqueueWholesaleNotification } from "@/actions/wholesale";
|
import { getWholesaleSettings, enqueueWholesaleNotification } from "@/actions/wholesale";
|
||||||
|
import { getWholesalePortalConfig, getStorefrontBrandName } from "@/actions/storefront";
|
||||||
|
|
||||||
type CartItem = {
|
type CartItem = {
|
||||||
product: WholesaleProduct;
|
product: WholesaleProduct;
|
||||||
@@ -281,12 +282,7 @@ export default function WholesalePortalPage() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { supabase } = await import("@/lib/supabase");
|
const ws = await getWholesalePortalConfig(cust.brand_id);
|
||||||
const { data: ws } = await supabase
|
|
||||||
.from("wholesale_settings")
|
|
||||||
.select("wholesale_enabled, online_payment_enabled")
|
|
||||||
.eq("brand_id", cust.brand_id)
|
|
||||||
.single() as unknown as { data: { wholesale_enabled: boolean; online_payment_enabled: boolean } | null };
|
|
||||||
setOnlinePaymentEnabled(ws?.online_payment_enabled ?? false);
|
setOnlinePaymentEnabled(ws?.online_payment_enabled ?? false);
|
||||||
setCustomer(cust);
|
setCustomer(cust);
|
||||||
setPreviewMode(true);
|
setPreviewMode(true);
|
||||||
@@ -327,12 +323,7 @@ export default function WholesalePortalPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { supabase } = await import("@/lib/supabase");
|
const ws = await getWholesalePortalConfig(cust.brand_id);
|
||||||
const { data: ws } = await supabase
|
|
||||||
.from("wholesale_settings")
|
|
||||||
.select("wholesale_enabled, online_payment_enabled")
|
|
||||||
.eq("brand_id", cust.brand_id)
|
|
||||||
.single() as unknown as { data: { wholesale_enabled: boolean; online_payment_enabled: boolean } | null };
|
|
||||||
if (ws && ws.wholesale_enabled === false) {
|
if (ws && ws.wholesale_enabled === false) {
|
||||||
router.push("/wholesale/login?error=portal_disabled");
|
router.push("/wholesale/login?error=portal_disabled");
|
||||||
return;
|
return;
|
||||||
@@ -349,13 +340,8 @@ export default function WholesalePortalPage() {
|
|||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
async function loadBrandName(brandId: string) {
|
async function loadBrandName(brandId: string) {
|
||||||
const { supabase } = await import("@/lib/supabase");
|
const name = await getStorefrontBrandName(brandId);
|
||||||
const { data: brand } = await supabase
|
if (name) setBrandName(name);
|
||||||
.from("brands")
|
|
||||||
.select("name")
|
|
||||||
.eq("id", brandId)
|
|
||||||
.single() as unknown as { data: { name: string } | null };
|
|
||||||
if (brand) setBrandName(brand.name);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadProducts(brandId: string) {
|
async function loadProducts(brandId: string) {
|
||||||
|
|||||||
@@ -48,12 +48,8 @@ export default function WholesaleRegisterPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function checkEnabled(brandId: string) {
|
async function checkEnabled(brandId: string) {
|
||||||
const { supabase } = await import("@/lib/supabase");
|
const { getWholesalePortalConfig } = await import("@/actions/storefront");
|
||||||
const { data: ws } = await supabase
|
const ws = await getWholesalePortalConfig(brandId);
|
||||||
.from("wholesale_settings")
|
|
||||||
.select("wholesale_enabled")
|
|
||||||
.eq("brand_id", brandId)
|
|
||||||
.single();
|
|
||||||
if (ws && ws.wholesale_enabled === false) {
|
if (ws && ws.wholesale_enabled === false) {
|
||||||
setPortalDisabled(true);
|
setPortalDisabled(true);
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -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
|
||||||
|
|||||||
@@ -24,13 +24,9 @@ export default function SquareSyncWidget({ brandId }: Props) {
|
|||||||
const [queueCount, setQueueCount] = useState(0);
|
const [queueCount, setQueueCount] = useState(0);
|
||||||
|
|
||||||
const checkQueueCount = useCallback(async () => {
|
const checkQueueCount = useCallback(async () => {
|
||||||
const { supabase } = await import("@/lib/supabase");
|
const { getSquareQueueCount } = await import("@/actions/square-sync-ui");
|
||||||
const result = await supabase
|
const count = await getSquareQueueCount(brandId);
|
||||||
.from("square_sync_queue")
|
setQueueCount(count);
|
||||||
.select("*", { count: "exact", head: true })
|
|
||||||
.eq("brand_id", brandId)
|
|
||||||
.in("status", ["pending"]) as unknown as { count: number | null };
|
|
||||||
setQueueCount(result.count ?? 0);
|
|
||||||
}, [brandId]);
|
}, [brandId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
/**
|
|
||||||
* Service-layer admin user creation. Hits the `admin_users` table directly
|
|
||||||
* via the shared pg pool. Returns the inserted row (or existing row if the
|
|
||||||
* user was already provisioned).
|
|
||||||
*/
|
|
||||||
export async function createAdminUser(
|
|
||||||
userId: string,
|
|
||||||
role: string,
|
|
||||||
brandId: string | null
|
|
||||||
): Promise<Record<string, unknown> | null> {
|
|
||||||
const { pool } = await import("@/lib/db");
|
|
||||||
const body = {
|
|
||||||
user_id: userId,
|
|
||||||
role,
|
|
||||||
brand_id: brandId,
|
|
||||||
active: true,
|
|
||||||
can_manage_products: role === "platform_admin",
|
|
||||||
can_manage_stops: role === "platform_admin",
|
|
||||||
can_manage_orders: true,
|
|
||||||
can_manage_pickup: role !== "store_employee",
|
|
||||||
can_manage_messages: role === "platform_admin",
|
|
||||||
can_manage_refunds: role === "platform_admin",
|
|
||||||
can_manage_users: role === "platform_admin",
|
|
||||||
can_manage_water_log: role === "platform_admin",
|
|
||||||
can_manage_reports: role === "platform_admin",
|
|
||||||
can_manage_settings: role === "platform_admin",
|
|
||||||
must_change_password: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { rows } = await pool.query<Record<string, unknown>>(
|
|
||||||
`INSERT INTO admin_users
|
|
||||||
(user_id, role, brand_id, active,
|
|
||||||
can_manage_products, can_manage_stops, can_manage_orders,
|
|
||||||
can_manage_pickup, can_manage_messages, can_manage_refunds,
|
|
||||||
can_manage_users, can_manage_water_log, can_manage_reports,
|
|
||||||
can_manage_settings, must_change_password)
|
|
||||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
|
|
||||||
ON CONFLICT (user_id) DO UPDATE
|
|
||||||
SET role = EXCLUDED.role,
|
|
||||||
brand_id = EXCLUDED.brand_id,
|
|
||||||
active = EXCLUDED.active,
|
|
||||||
can_manage_products = EXCLUDED.can_manage_products,
|
|
||||||
can_manage_stops = EXCLUDED.can_manage_stops,
|
|
||||||
can_manage_orders = EXCLUDED.can_manage_orders,
|
|
||||||
can_manage_pickup = EXCLUDED.can_manage_pickup,
|
|
||||||
can_manage_messages = EXCLUDED.can_manage_messages,
|
|
||||||
can_manage_refunds = EXCLUDED.can_manage_refunds,
|
|
||||||
can_manage_users = EXCLUDED.can_manage_users,
|
|
||||||
can_manage_water_log = EXCLUDED.can_manage_water_log,
|
|
||||||
can_manage_reports = EXCLUDED.can_manage_reports,
|
|
||||||
can_manage_settings = EXCLUDED.can_manage_settings
|
|
||||||
RETURNING *`,
|
|
||||||
[
|
|
||||||
body.user_id,
|
|
||||||
body.role,
|
|
||||||
body.brand_id,
|
|
||||||
body.active,
|
|
||||||
body.can_manage_products,
|
|
||||||
body.can_manage_stops,
|
|
||||||
body.can_manage_orders,
|
|
||||||
body.can_manage_pickup,
|
|
||||||
body.can_manage_messages,
|
|
||||||
body.can_manage_refunds,
|
|
||||||
body.can_manage_users,
|
|
||||||
body.can_manage_water_log,
|
|
||||||
body.can_manage_reports,
|
|
||||||
body.can_manage_settings,
|
|
||||||
body.must_change_password,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
return rows[0] ?? null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
|
import type { AdminUser } from "@/lib/admin-permissions-types";
|
||||||
|
|
||||||
|
export type AuthGuardResult =
|
||||||
|
| { authorized: true; adminUser: AdminUser }
|
||||||
|
| { authorized: false; adminUser: null };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Require an authenticated admin user. Returns a discriminated result so
|
||||||
|
* callers can branch without throwing. Use this in API route handlers that
|
||||||
|
* need a uniform "is this request authenticated?" check before proceeding.
|
||||||
|
*/
|
||||||
|
export async function requireAdminUser(): Promise<AuthGuardResult> {
|
||||||
|
const adminUser = await getAdminUser();
|
||||||
|
if (!adminUser) {
|
||||||
|
return { authorized: false, adminUser: null };
|
||||||
|
}
|
||||||
|
return { authorized: true, adminUser };
|
||||||
|
}
|
||||||
+3
-3
@@ -11,9 +11,9 @@
|
|||||||
* const { rows } = await query<MyRow>("SELECT * FROM my_table WHERE id = $1", [id]);
|
* const { rows } = await query<MyRow>("SELECT * FROM my_table WHERE id = $1", [id]);
|
||||||
*
|
*
|
||||||
* Configuration:
|
* Configuration:
|
||||||
* - DATABASE_URL (required) — full Postgres connection string. Same env var
|
* - DATABASE_URL (required) — full Postgres connection string. Used by
|
||||||
* is used by `supabase/push-migrations.js` and any external migration
|
* `scripts/migrate.js` (and any external migration tooling). Format:
|
||||||
* tooling. Format: `postgres://user:pass@host:port/dbname`.
|
* `postgres://user:pass@host:port/dbname`.
|
||||||
*
|
*
|
||||||
* Notes:
|
* Notes:
|
||||||
* - This module is server-only. It must never be imported from a Client
|
* - This module is server-only. It must never be imported from a Client
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// FedEx OAuth token cache.
|
||||||
|
// This module intentionally lives OUTSIDE any "use server" file. The
|
||||||
|
// `react-doctor/server-no-mutable-module-state` rule flags module-scope
|
||||||
|
// mutable state inside "use server" files because it can leak per-user
|
||||||
|
// state across requests. The FedEx token cache is NOT per-user state —
|
||||||
|
// it's a service-credential cache shared across all requests, which is
|
||||||
|
// the desired behavior. Co-locating the cache in a non-server file keeps
|
||||||
|
// the rule satisfied while preserving the cross-request token reuse that
|
||||||
|
// FedEx's rate-limited OAuth endpoint requires.
|
||||||
|
|
||||||
|
type FedExAuthToken = {
|
||||||
|
accessToken: string;
|
||||||
|
expiresAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FEDEX_BASE_URL = "https://apis.fedex.com";
|
||||||
|
const FEDEX_SANDBOX_URL = "https://apis-sandbox.fedex.com";
|
||||||
|
|
||||||
|
export function getFedExApiBaseUrl(useProduction: boolean): string {
|
||||||
|
return useProduction ? FEDEX_BASE_URL : FEDEX_SANDBOX_URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buffer before expiry to refresh proactively
|
||||||
|
const EXPIRY_BUFFER_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
let cachedToken: FedExAuthToken | null = null;
|
||||||
|
|
||||||
|
export async function getFedExAuthToken(settings: {
|
||||||
|
fedexApiKey: string;
|
||||||
|
fedexApiSecret: string;
|
||||||
|
useProduction: boolean;
|
||||||
|
}): Promise<{ accessToken: string } | { error: string }> {
|
||||||
|
const base = settings.useProduction ? FEDEX_BASE_URL : FEDEX_SANDBOX_URL;
|
||||||
|
|
||||||
|
if (cachedToken && Date.now() < cachedToken.expiresAt - EXPIRY_BUFFER_MS) {
|
||||||
|
return { accessToken: cachedToken.accessToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
const credentials = Buffer.from(
|
||||||
|
`${settings.fedexApiKey}:${settings.fedexApiSecret}`
|
||||||
|
).toString("base64");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${base}/oauth/token`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
Authorization: `Basic ${credentials}`,
|
||||||
|
},
|
||||||
|
body: "grant_type=client_credentials",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
return { error: `FedEx auth failed: ${text}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
access_token: string;
|
||||||
|
expires_in: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
cachedToken = {
|
||||||
|
accessToken: data.access_token,
|
||||||
|
expiresAt: Date.now() + data.expires_in * 1000,
|
||||||
|
};
|
||||||
|
|
||||||
|
return { accessToken: data.access_token };
|
||||||
|
} catch (err) {
|
||||||
|
return { error: `FedEx auth network error: ${(err as Error).message}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test-only helper to reset the cache between unit tests.
|
||||||
|
export function __resetFedExTokenCacheForTests(): void {
|
||||||
|
cachedToken = null;
|
||||||
|
}
|
||||||
@@ -1,372 +0,0 @@
|
|||||||
/**
|
|
||||||
* Compatibility shim that preserves the historical Supabase client
|
|
||||||
* query-builder API surface (`.from().select()...`) for the legacy
|
|
||||||
* public-storefront and admin pages that still call into it. The SaaS
|
|
||||||
* rebuild no longer uses Supabase as a backend — server actions and
|
|
||||||
* API routes connect directly to Postgres via `pg` / `withDb` from
|
|
||||||
* `@/db/client`. This shim exists so the build keeps passing while
|
|
||||||
* the remaining legacy call sites are migrated to Drizzle / raw `pg`
|
|
||||||
* queries.
|
|
||||||
*
|
|
||||||
* IMPORTANT: This shim does NOT talk to a real database. It returns
|
|
||||||
* empty result sets. Legacy call sites that need real data must be
|
|
||||||
* rewritten against `pool` / `withDb` / `withBrand`.
|
|
||||||
*
|
|
||||||
* The query-builder API surface supported here is intentionally narrow:
|
|
||||||
* - .from(table).select(cols?).eq(col, val).eq(...).is(col, null).
|
|
||||||
* order(col, opts?).limit(n).range(min, max).single() → returns
|
|
||||||
* `{ data, error }` like the Supabase client did.
|
|
||||||
* - .from(table).insert(payload).select().single() for legacy inserts
|
|
||||||
* - .from(table).upsert(payload)
|
|
||||||
* - .from(table).update(payload).eq(col, val)
|
|
||||||
* - .from(table).delete().eq(col, val)
|
|
||||||
* - .rpc(fnName, params) — returns `{ data, error }` (data is null)
|
|
||||||
* - .auth.{getSession, getUser, signInWithPassword, signOut,
|
|
||||||
* updateUser, onAuthStateChange} — all return null / no-ops
|
|
||||||
* - .storage.from(bucket).{upload, download, remove, list}
|
|
||||||
* - .channel().on().subscribe()
|
|
||||||
*
|
|
||||||
* If a call site needs more than that, migrate the call site.
|
|
||||||
*/
|
|
||||||
|
|
||||||
type FilterOp = "eq" | "neq" | "gt" | "gte" | "lt" | "lte" | "is" | "like" | "ilike" | "in";
|
|
||||||
type Filter = { column: string; value: unknown; op: FilterOp };
|
|
||||||
|
|
||||||
// Result types for the mock query builder
|
|
||||||
interface MockQueryResult<T> {
|
|
||||||
data: T[] | null;
|
|
||||||
error: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MockSingleResult<T> {
|
|
||||||
data: T | null;
|
|
||||||
error: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
class MockQueryBuilder<T extends Record<string, unknown> = Record<string, unknown>> {
|
|
||||||
private tableName: string;
|
|
||||||
private filters: Filter[] = [];
|
|
||||||
private selectColumns: string = "*";
|
|
||||||
private orderColumn?: string;
|
|
||||||
private orderDirection?: "asc" | "desc";
|
|
||||||
private limitValue?: number;
|
|
||||||
private rangeMin?: number;
|
|
||||||
private rangeMax?: number;
|
|
||||||
private mode: "select" | "update" | "delete" = "select";
|
|
||||||
private mutationData: Record<string, unknown> | null = null;
|
|
||||||
|
|
||||||
constructor(tableName: string) {
|
|
||||||
this.tableName = tableName;
|
|
||||||
// No mock data - return empty results
|
|
||||||
}
|
|
||||||
|
|
||||||
select(columns: string = "*", _opts?: Record<string, unknown>) {
|
|
||||||
this.selectColumns = columns;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
eq(column: string, value: unknown) {
|
|
||||||
this.filters.push({ column, value, op: "eq" });
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
neq(column: string, value: unknown) {
|
|
||||||
this.filters.push({ column, value, op: "neq" });
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
gt(column: string, value: unknown) {
|
|
||||||
this.filters.push({ column, value, op: "gt" });
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
gte(column: string, value: unknown) {
|
|
||||||
this.filters.push({ column, value, op: "gte" });
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
lt(column: string, value: unknown) {
|
|
||||||
this.filters.push({ column, value, op: "lt" });
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
lte(column: string, value: unknown) {
|
|
||||||
this.filters.push({ column, value, op: "lte" });
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
is(column: string, value: unknown) {
|
|
||||||
this.filters.push({ column, value, op: "is" });
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
like(column: string, value: string) {
|
|
||||||
this.filters.push({ column, value, op: "like" });
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
ilike(column: string, value: string) {
|
|
||||||
this.filters.push({ column, value, op: "ilike" });
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
in(column: string, values: unknown[]) {
|
|
||||||
this.filters.push({ column, value: values, op: "in" });
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
order(column: string, options?: { ascending?: boolean; nullsFirst?: boolean }) {
|
|
||||||
this.orderColumn = column;
|
|
||||||
this.orderDirection = options?.ascending === false ? "desc" : "asc";
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
limit(count: number) {
|
|
||||||
this.limitValue = count;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
range(min: number, max: number) {
|
|
||||||
this.rangeMin = min;
|
|
||||||
this.rangeMax = max;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The legacy Supabase client returns a thenable from .single() so
|
|
||||||
// callers can write `.single().then(({ data, error }) => ...)` as
|
|
||||||
// well as `const { data, error } = await ....single()`. We return a
|
|
||||||
// proper Promise<MockSingleResult<T>> so destructured binding
|
|
||||||
// patterns in callers work under `--strict` (no implicit `any`).
|
|
||||||
single(): Promise<MockSingleResult<T>> {
|
|
||||||
return Promise.resolve(this.executeSingle());
|
|
||||||
}
|
|
||||||
|
|
||||||
maybeSingle(): Promise<MockSingleResult<T>> {
|
|
||||||
return Promise.resolve(this.executeSingle());
|
|
||||||
}
|
|
||||||
|
|
||||||
then<TResult1 = MockQueryResult<T>, TResult2 = never>(
|
|
||||||
onfulfilled?: ((value: MockQueryResult<T>) => TResult1 | PromiseLike<TResult1>) | null,
|
|
||||||
_onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
|
|
||||||
): PromiseLike<TResult1 | TResult2> {
|
|
||||||
const result = this.execute();
|
|
||||||
return Promise.resolve(onfulfilled ? onfulfilled(result) : (result as unknown as TResult1));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert / update / delete mutators — these are mostly used by legacy
|
|
||||||
// auth flows and the AI preferences action. We capture the data and
|
|
||||||
// short-circuit to a successful no-op response so the call sites
|
|
||||||
// don't blow up. Real writes must go through server actions.
|
|
||||||
executeSingle(): MockSingleResult<T> {
|
|
||||||
const result = this.execute();
|
|
||||||
if (Array.isArray(result.data) && result.data.length > 0) {
|
|
||||||
return { data: result.data[0] as T, error: null };
|
|
||||||
}
|
|
||||||
return { data: null, error: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
execute(): MockQueryResult<T> {
|
|
||||||
if (this.mode === "update" || this.mode === "delete") {
|
|
||||||
return this.runMutation();
|
|
||||||
}
|
|
||||||
// No mock data - return empty array
|
|
||||||
let filtered: unknown[] = [];
|
|
||||||
|
|
||||||
for (const filter of this.filters) {
|
|
||||||
filtered = filtered.filter((row) => {
|
|
||||||
const r = row as Record<string, unknown>;
|
|
||||||
const rowValue = r[filter.column];
|
|
||||||
switch (filter.op) {
|
|
||||||
case "eq":
|
|
||||||
return rowValue === filter.value;
|
|
||||||
case "neq":
|
|
||||||
return rowValue !== filter.value;
|
|
||||||
case "gt":
|
|
||||||
return (rowValue as number) > (filter.value as number);
|
|
||||||
case "gte":
|
|
||||||
return (rowValue as number) >= (filter.value as number);
|
|
||||||
case "lt":
|
|
||||||
return (rowValue as number) < (filter.value as number);
|
|
||||||
case "lte":
|
|
||||||
return (rowValue as number) <= (filter.value as number);
|
|
||||||
case "is":
|
|
||||||
if (filter.value === null) return rowValue === null;
|
|
||||||
if (filter.value === undefined) return rowValue === undefined;
|
|
||||||
return rowValue === filter.value;
|
|
||||||
case "like":
|
|
||||||
return (
|
|
||||||
typeof rowValue === "string" &&
|
|
||||||
rowValue.includes((filter.value as string).replace(/%/g, ""))
|
|
||||||
);
|
|
||||||
case "ilike":
|
|
||||||
return (
|
|
||||||
typeof rowValue === "string" &&
|
|
||||||
rowValue
|
|
||||||
.toLowerCase()
|
|
||||||
.includes((filter.value as string).replace(/%/g, "").toLowerCase())
|
|
||||||
);
|
|
||||||
case "in":
|
|
||||||
return Array.isArray(filter.value) && filter.value.includes(rowValue);
|
|
||||||
default:
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.orderColumn) {
|
|
||||||
filtered.sort((a: unknown, b: unknown) => {
|
|
||||||
const aVal = (a as Record<string, unknown>)[this.orderColumn!] as string | number;
|
|
||||||
const bVal = (b as Record<string, unknown>)[this.orderColumn!] as string | number;
|
|
||||||
if (aVal < bVal) return this.orderDirection === "desc" ? 1 : -1;
|
|
||||||
if (aVal > bVal) return this.orderDirection === "desc" ? -1 : 1;
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.rangeMin !== undefined && this.rangeMax !== undefined) {
|
|
||||||
filtered = filtered.slice(this.rangeMin, this.rangeMax + 1);
|
|
||||||
} else if (this.limitValue !== undefined) {
|
|
||||||
filtered = filtered.slice(0, this.limitValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { data: filtered as T[], error: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
private runMutation(): MockQueryResult<T> {
|
|
||||||
if (this.mode === "delete") {
|
|
||||||
return { data: null, error: null };
|
|
||||||
}
|
|
||||||
if (this.mutationData) {
|
|
||||||
const items = [this.mutationData];
|
|
||||||
const returning = items.map((item, i) => ({
|
|
||||||
...item,
|
|
||||||
id: (item as Record<string, unknown>).id ?? `generated-${Date.now()}-${i}`,
|
|
||||||
}));
|
|
||||||
return { data: returning as unknown as T[], error: null };
|
|
||||||
}
|
|
||||||
return { data: null, error: null };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MockMutationResult<T> {
|
|
||||||
data: T[] | null;
|
|
||||||
error: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
class MockMutationBuilder<T extends Record<string, unknown> = Record<string, unknown>> {
|
|
||||||
private tableName: string;
|
|
||||||
private data: unknown;
|
|
||||||
|
|
||||||
constructor(tableName: string, data: unknown) {
|
|
||||||
this.tableName = tableName;
|
|
||||||
this.data = data;
|
|
||||||
}
|
|
||||||
|
|
||||||
select() {
|
|
||||||
return new MockQueryBuilder<T>(this.tableName);
|
|
||||||
}
|
|
||||||
|
|
||||||
eq() {
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
then<TResult1 = MockMutationResult<T>, TResult2 = never>(
|
|
||||||
onfulfilled?: ((value: MockMutationResult<T>) => TResult1 | PromiseLike<TResult1>) | null,
|
|
||||||
): PromiseLike<TResult1 | TResult2> {
|
|
||||||
const result: MockMutationResult<T> = {
|
|
||||||
data: Array.isArray(this.data)
|
|
||||||
? this.data.map((item: Record<string, unknown>, i: number) => ({
|
|
||||||
...item,
|
|
||||||
id: item?.id ?? `generated-${Date.now()}-${i}`,
|
|
||||||
})) as unknown as T[]
|
|
||||||
: this.data
|
|
||||||
? [{ ...(this.data as Record<string, unknown>), id: (this.data as Record<string, unknown>).id ?? `generated-${Date.now()}` }] as unknown as T[]
|
|
||||||
: null,
|
|
||||||
error: null,
|
|
||||||
};
|
|
||||||
return Promise.resolve(onfulfilled ? onfulfilled(result) : (result as unknown as TResult1));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class MockDeleteBuilder {
|
|
||||||
eq(_column: string, _value: unknown) {
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
in(_column: string, _values: unknown[]) {
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
then(resolve: (value: unknown) => void) {
|
|
||||||
resolve({ data: null, error: null });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class MockStorageBuilder {
|
|
||||||
from(_bucket: string) {
|
|
||||||
return {
|
|
||||||
upload: async (_path: string, _file: unknown) => ({ data: { path: _path }, error: null }),
|
|
||||||
download: async (_path: string) => ({ data: new Blob(), error: null }),
|
|
||||||
remove: async (_paths: string[]) => ({ data: { paths: _paths }, error: null }),
|
|
||||||
list: async () => ({ data: [], error: null }),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auth credential types
|
|
||||||
interface SignInCredentials {
|
|
||||||
email?: string;
|
|
||||||
password?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UserAttributes {
|
|
||||||
email?: string;
|
|
||||||
data?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createMockClient() {
|
|
||||||
// The query builder is mutable, so we can't simply return a class
|
|
||||||
// instance + spread mutation methods. The cleanest way to support
|
|
||||||
// both `.from(t).select(...)...` (read) and `.from(t).update(d).eq(...)`
|
|
||||||
// (write) in a single chain is to delegate everything to one object
|
|
||||||
// and inspect `this.mode` lazily. We build that object via
|
|
||||||
// `Object.assign` to keep the TypeScript inference happy.
|
|
||||||
function makeFrom<T extends Record<string, unknown> = Record<string, unknown>>(table: string) {
|
|
||||||
const qb = new MockQueryBuilder<T>(table);
|
|
||||||
return Object.assign(qb, {
|
|
||||||
insert: (data: Record<string, unknown>) => new MockMutationBuilder<T>(table, data),
|
|
||||||
update: (data: Record<string, unknown>) => new MockMutationBuilder<T>(table, data),
|
|
||||||
upsert: (data: Record<string, unknown>) => new MockMutationBuilder<T>(table, data),
|
|
||||||
delete: () => new MockDeleteBuilder(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
from: makeFrom,
|
|
||||||
storage: new MockStorageBuilder(),
|
|
||||||
auth: {
|
|
||||||
getSession: async () => ({ data: { session: null }, error: null }),
|
|
||||||
getUser: async () => ({ data: { user: null }, error: null }),
|
|
||||||
signInWithPassword: async (_creds: SignInCredentials) => ({
|
|
||||||
data: { user: null, session: null },
|
|
||||||
error: null,
|
|
||||||
}),
|
|
||||||
signOut: async () => ({ error: null }),
|
|
||||||
updateUser: async (_attrs: UserAttributes): Promise<{ data: { user: null }; error: null }> => ({
|
|
||||||
data: { user: null },
|
|
||||||
error: null,
|
|
||||||
}),
|
|
||||||
onAuthStateChange: () => ({
|
|
||||||
data: { subscription: { unsubscribe: () => {} } },
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
rpc: async (_name: string, _params?: Record<string, unknown>): Promise<{ data: null; error: null }> => ({
|
|
||||||
data: null,
|
|
||||||
error: null,
|
|
||||||
}),
|
|
||||||
channel: () => ({
|
|
||||||
on: () => ({ subscribe: () => ({}) }),
|
|
||||||
subscribe: () => ({}),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const supabase = createMockClient();
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
# Supabase
|
|
||||||
.branches
|
|
||||||
.temp
|
|
||||||
|
|
||||||
# dotenvx
|
|
||||||
.env.keys
|
|
||||||
.env.local
|
|
||||||
.env.*.local
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
-- ============================================================================
|
|
||||||
-- Grok Fix Prompt: admin_create_stop (copy-paste ready for Supabase SQL Editor)
|
|
||||||
-- ============================================================================
|
|
||||||
-- This is the exact "Best Practice Version" from the fix prompt, with
|
|
||||||
-- additional comments. Run this directly in the Supabase SQL Editor
|
|
||||||
-- (https://supabase.com/dashboard/project/wnzkhezyhnfzhkhiflrp/sql) if the
|
|
||||||
-- migration push cannot be used.
|
|
||||||
--
|
|
||||||
-- After running, PostgREST may need a schema reload:
|
|
||||||
-- NOTIFY pgrst, 'reload schema';
|
|
||||||
--
|
|
||||||
-- Then test "Add New Stop" again.
|
|
||||||
--
|
|
||||||
-- The calling code (JS/TS) is in:
|
|
||||||
-- src/actions/stops/create-stop.ts (the fetch to /rest/v1/rpc/admin_create_stop)
|
|
||||||
-- src/components/admin/AddStopModal.tsx
|
|
||||||
-- src/components/admin/NewStopForm.tsx
|
|
||||||
-- (all go through the server action createStop which builds the p_* args)
|
|
||||||
-- ============================================================================
|
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION public.admin_create_stop(
|
|
||||||
p_active boolean,
|
|
||||||
p_address text,
|
|
||||||
p_brand_id uuid,
|
|
||||||
p_city text,
|
|
||||||
p_cutoff_time time,
|
|
||||||
p_date date,
|
|
||||||
p_location text,
|
|
||||||
p_state text,
|
|
||||||
p_time time,
|
|
||||||
p_zip text
|
|
||||||
)
|
|
||||||
RETURNS jsonb
|
|
||||||
LANGUAGE plpgsql
|
|
||||||
SECURITY DEFINER
|
|
||||||
SET search_path = public
|
|
||||||
AS $$
|
|
||||||
DECLARE
|
|
||||||
new_stop_id uuid;
|
|
||||||
BEGIN
|
|
||||||
INSERT INTO stops (
|
|
||||||
active,
|
|
||||||
address,
|
|
||||||
brand_id,
|
|
||||||
city,
|
|
||||||
cutoff_time,
|
|
||||||
date,
|
|
||||||
location,
|
|
||||||
state,
|
|
||||||
time,
|
|
||||||
zip
|
|
||||||
)
|
|
||||||
VALUES (
|
|
||||||
p_active,
|
|
||||||
p_address,
|
|
||||||
p_brand_id,
|
|
||||||
p_city,
|
|
||||||
p_cutoff_time,
|
|
||||||
p_date,
|
|
||||||
p_location,
|
|
||||||
p_state,
|
|
||||||
p_time,
|
|
||||||
p_zip
|
|
||||||
)
|
|
||||||
RETURNING id INTO new_stop_id;
|
|
||||||
|
|
||||||
RETURN jsonb_build_object(
|
|
||||||
'success', true,
|
|
||||||
'stop_id', new_stop_id,
|
|
||||||
'message', 'Stop created successfully'
|
|
||||||
);
|
|
||||||
EXCEPTION WHEN OTHERS THEN
|
|
||||||
RETURN jsonb_build_object(
|
|
||||||
'success', false,
|
|
||||||
'error', SQLERRM
|
|
||||||
);
|
|
||||||
END;
|
|
||||||
$$;
|
|
||||||
|
|
||||||
-- Important: grant so PostgREST (anon key from server action) can invoke it.
|
|
||||||
GRANT EXECUTE ON FUNCTION public.admin_create_stop(
|
|
||||||
boolean, text, uuid, text, time, date, text, text, time, text
|
|
||||||
) TO anon, authenticated, service_role;
|
|
||||||
|
|
||||||
-- Reload cache so the function is immediately visible (avoids PGRST202).
|
|
||||||
NOTIFY pgrst, 'reload schema';
|
|
||||||
|
|
||||||
-- NOTE: The above simple version may fail on INSERT because the stops table
|
|
||||||
-- requires "slug" and "status" (NOT NULL, no defaults in all cases).
|
|
||||||
-- Prefer the enhanced version in migrations/202_fix_admin_create_stop.sql
|
|
||||||
-- which includes slug generation and status='draft' while using a compatible
|
|
||||||
-- signature and the success/stop_id return shape.
|
|
||||||
@@ -1,408 +0,0 @@
|
|||||||
# For detailed configuration reference documentation, visit:
|
|
||||||
# https://supabase.com/docs/guides/local-development/cli/config
|
|
||||||
# A string used to distinguish different Supabase projects on the same host. Defaults to the
|
|
||||||
# working directory name when running `supabase init`.
|
|
||||||
project_id = "route_commerce-main"
|
|
||||||
|
|
||||||
[api]
|
|
||||||
enabled = true
|
|
||||||
# Port to use for the API URL.
|
|
||||||
port = 54321
|
|
||||||
# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API
|
|
||||||
# endpoints. `public` and `graphql_public` schemas are included by default.
|
|
||||||
schemas = ["public", "graphql_public"]
|
|
||||||
# Extra schemas to add to the search_path of every request.
|
|
||||||
extra_search_path = ["public", "extensions"]
|
|
||||||
# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size
|
|
||||||
# for accidental or malicious requests.
|
|
||||||
max_rows = 1000
|
|
||||||
|
|
||||||
[api.tls]
|
|
||||||
# Enable HTTPS endpoints locally using a self-signed certificate.
|
|
||||||
enabled = false
|
|
||||||
# Paths to self-signed certificate pair.
|
|
||||||
# cert_path = "../certs/my-cert.pem"
|
|
||||||
# key_path = "../certs/my-key.pem"
|
|
||||||
|
|
||||||
[db]
|
|
||||||
# Port to use for the local database URL.
|
|
||||||
port = 54322
|
|
||||||
# Port used by db diff command to initialize the shadow database.
|
|
||||||
shadow_port = 54320
|
|
||||||
# Maximum amount of time to wait for health check when starting the local database.
|
|
||||||
health_timeout = "2m"
|
|
||||||
# The database major version to use. This has to be the same as your remote database's. Run `SHOW
|
|
||||||
# server_version;` on the remote database to check.
|
|
||||||
major_version = 17
|
|
||||||
|
|
||||||
[db.pooler]
|
|
||||||
enabled = false
|
|
||||||
# Port to use for the local connection pooler.
|
|
||||||
port = 54329
|
|
||||||
# Specifies when a server connection can be reused by other clients.
|
|
||||||
# Configure one of the supported pooler modes: `transaction`, `session`.
|
|
||||||
pool_mode = "transaction"
|
|
||||||
# How many server connections to allow per user/database pair.
|
|
||||||
default_pool_size = 20
|
|
||||||
# Maximum number of client connections allowed.
|
|
||||||
max_client_conn = 100
|
|
||||||
|
|
||||||
# [db.vault]
|
|
||||||
# secret_key = "env(SECRET_VALUE)"
|
|
||||||
|
|
||||||
[db.migrations]
|
|
||||||
# If disabled, migrations will be skipped during a db push or reset.
|
|
||||||
enabled = true
|
|
||||||
# Specifies an ordered list of schema files that describe your database.
|
|
||||||
# Supports glob patterns relative to supabase directory: "./schemas/*.sql"
|
|
||||||
schema_paths = []
|
|
||||||
|
|
||||||
[db.seed]
|
|
||||||
# If enabled, seeds the database after migrations during a db reset.
|
|
||||||
enabled = true
|
|
||||||
# Specifies an ordered list of seed files to load during db reset.
|
|
||||||
# Supports glob patterns relative to supabase directory: "./seeds/*.sql"
|
|
||||||
sql_paths = ["./seed.sql"]
|
|
||||||
|
|
||||||
[db.network_restrictions]
|
|
||||||
# Enable management of network restrictions.
|
|
||||||
enabled = false
|
|
||||||
# List of IPv4 CIDR blocks allowed to connect to the database.
|
|
||||||
# Defaults to allow all IPv4 connections. Set empty array to block all IPs.
|
|
||||||
allowed_cidrs = ["0.0.0.0/0"]
|
|
||||||
# List of IPv6 CIDR blocks allowed to connect to the database.
|
|
||||||
# Defaults to allow all IPv6 connections. Set empty array to block all IPs.
|
|
||||||
allowed_cidrs_v6 = ["::/0"]
|
|
||||||
|
|
||||||
# Uncomment to reject non-secure connections to the database.
|
|
||||||
# [db.ssl_enforcement]
|
|
||||||
# enabled = true
|
|
||||||
|
|
||||||
[realtime]
|
|
||||||
enabled = true
|
|
||||||
# Bind realtime via either IPv4 or IPv6. (default: IPv4)
|
|
||||||
# ip_version = "IPv6"
|
|
||||||
# The maximum length in bytes of HTTP request headers. (default: 4096)
|
|
||||||
# max_header_length = 4096
|
|
||||||
|
|
||||||
[studio]
|
|
||||||
enabled = true
|
|
||||||
# Port to use for Supabase Studio.
|
|
||||||
port = 54323
|
|
||||||
# External URL of the API server that frontend connects to.
|
|
||||||
api_url = "http://127.0.0.1"
|
|
||||||
# OpenAI API Key to use for Supabase AI in the Supabase Studio.
|
|
||||||
openai_api_key = "env(OPENAI_API_KEY)"
|
|
||||||
|
|
||||||
# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they
|
|
||||||
# are monitored, and you can view the emails that would have been sent from the web interface.
|
|
||||||
[inbucket]
|
|
||||||
enabled = true
|
|
||||||
# Port to use for the email testing server web interface.
|
|
||||||
port = 54324
|
|
||||||
# Uncomment to expose additional ports for testing user applications that send emails.
|
|
||||||
# smtp_port = 54325
|
|
||||||
# pop3_port = 54326
|
|
||||||
# admin_email = "admin@email.com"
|
|
||||||
# sender_name = "Admin"
|
|
||||||
|
|
||||||
[storage]
|
|
||||||
enabled = true
|
|
||||||
# The maximum file size allowed (e.g. "5MB", "500KB").
|
|
||||||
file_size_limit = "50MiB"
|
|
||||||
|
|
||||||
# Uncomment to configure local storage buckets
|
|
||||||
# [storage.buckets.images]
|
|
||||||
# public = false
|
|
||||||
# file_size_limit = "50MiB"
|
|
||||||
# allowed_mime_types = ["image/png", "image/jpeg"]
|
|
||||||
# objects_path = "./images"
|
|
||||||
|
|
||||||
# Allow connections via S3 compatible clients
|
|
||||||
[storage.s3_protocol]
|
|
||||||
enabled = true
|
|
||||||
|
|
||||||
# Image transformation API is available to Supabase Pro plan.
|
|
||||||
# [storage.image_transformation]
|
|
||||||
# enabled = true
|
|
||||||
|
|
||||||
# Store analytical data in S3 for running ETL jobs over Iceberg Catalog
|
|
||||||
# This feature is only available on the hosted platform.
|
|
||||||
[storage.analytics]
|
|
||||||
enabled = false
|
|
||||||
max_namespaces = 5
|
|
||||||
max_tables = 10
|
|
||||||
max_catalogs = 2
|
|
||||||
|
|
||||||
# Analytics Buckets is available to Supabase Pro plan.
|
|
||||||
# [storage.analytics.buckets.my-warehouse]
|
|
||||||
|
|
||||||
# Store vector embeddings in S3 for large and durable datasets
|
|
||||||
# This feature is only available on the hosted platform.
|
|
||||||
[storage.vector]
|
|
||||||
enabled = false
|
|
||||||
max_buckets = 10
|
|
||||||
max_indexes = 5
|
|
||||||
|
|
||||||
# Vector Buckets is available to Supabase Pro plan.
|
|
||||||
# [storage.vector.buckets.documents-openai]
|
|
||||||
|
|
||||||
[auth]
|
|
||||||
enabled = true
|
|
||||||
# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
|
|
||||||
# in emails.
|
|
||||||
site_url = "http://127.0.0.1:3000"
|
|
||||||
# The public URL that Auth serves on. Defaults to the API external URL with `/auth/v1` appended.
|
|
||||||
# external_url = ""
|
|
||||||
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
|
|
||||||
additional_redirect_urls = ["https://127.0.0.1:3000"]
|
|
||||||
# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
|
|
||||||
jwt_expiry = 3600
|
|
||||||
# JWT issuer URL. If not set, defaults to auth.external_url.
|
|
||||||
# jwt_issuer = ""
|
|
||||||
# Path to JWT signing key. DO NOT commit your signing keys file to git.
|
|
||||||
# signing_keys_path = "./signing_keys.json"
|
|
||||||
# If disabled, the refresh token will never expire.
|
|
||||||
enable_refresh_token_rotation = true
|
|
||||||
# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds.
|
|
||||||
# Requires enable_refresh_token_rotation = true.
|
|
||||||
refresh_token_reuse_interval = 10
|
|
||||||
# Allow/disallow new user signups to your project.
|
|
||||||
enable_signup = true
|
|
||||||
# Allow/disallow anonymous sign-ins to your project.
|
|
||||||
enable_anonymous_sign_ins = false
|
|
||||||
# Allow/disallow testing manual linking of accounts
|
|
||||||
enable_manual_linking = false
|
|
||||||
# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more.
|
|
||||||
minimum_password_length = 6
|
|
||||||
# Passwords that do not meet the following requirements will be rejected as weak. Supported values
|
|
||||||
# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols`
|
|
||||||
password_requirements = ""
|
|
||||||
|
|
||||||
# Configure passkey sign-ins.
|
|
||||||
# [auth.passkey]
|
|
||||||
# enabled = false
|
|
||||||
|
|
||||||
# Configure WebAuthn relying party settings (required when passkey is enabled).
|
|
||||||
# [auth.webauthn]
|
|
||||||
# rp_display_name = "Supabase"
|
|
||||||
# rp_id = "localhost"
|
|
||||||
# rp_origins = ["http://127.0.0.1:3000"]
|
|
||||||
|
|
||||||
[auth.rate_limit]
|
|
||||||
# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled.
|
|
||||||
email_sent = 2
|
|
||||||
# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled.
|
|
||||||
sms_sent = 30
|
|
||||||
# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true.
|
|
||||||
anonymous_users = 30
|
|
||||||
# Number of sessions that can be refreshed in a 5 minute interval per IP address.
|
|
||||||
token_refresh = 150
|
|
||||||
# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users).
|
|
||||||
sign_in_sign_ups = 30
|
|
||||||
# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address.
|
|
||||||
token_verifications = 30
|
|
||||||
# Number of Web3 logins that can be made in a 5 minute interval per IP address.
|
|
||||||
web3 = 30
|
|
||||||
|
|
||||||
# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`.
|
|
||||||
# [auth.captcha]
|
|
||||||
# enabled = true
|
|
||||||
# provider = "hcaptcha"
|
|
||||||
# secret = ""
|
|
||||||
|
|
||||||
[auth.email]
|
|
||||||
# Allow/disallow new user signups via email to your project.
|
|
||||||
enable_signup = true
|
|
||||||
# If enabled, a user will be required to confirm any email change on both the old, and new email
|
|
||||||
# addresses. If disabled, only the new email is required to confirm.
|
|
||||||
double_confirm_changes = true
|
|
||||||
# If enabled, users need to confirm their email address before signing in.
|
|
||||||
enable_confirmations = false
|
|
||||||
# If enabled, users will need to reauthenticate or have logged in recently to change their password.
|
|
||||||
secure_password_change = false
|
|
||||||
# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email.
|
|
||||||
max_frequency = "1s"
|
|
||||||
# Number of characters used in the email OTP.
|
|
||||||
otp_length = 6
|
|
||||||
# Number of seconds before the email OTP expires (defaults to 1 hour).
|
|
||||||
otp_expiry = 3600
|
|
||||||
|
|
||||||
# Use a production-ready SMTP server
|
|
||||||
# [auth.email.smtp]
|
|
||||||
# enabled = true
|
|
||||||
# host = "smtp.sendgrid.net"
|
|
||||||
# port = 587
|
|
||||||
# user = "apikey"
|
|
||||||
# pass = "env(SENDGRID_API_KEY)"
|
|
||||||
# admin_email = "admin@email.com"
|
|
||||||
# sender_name = "Admin"
|
|
||||||
|
|
||||||
# Uncomment to customize email template
|
|
||||||
# [auth.email.template.invite]
|
|
||||||
# subject = "You have been invited"
|
|
||||||
# content_path = "./supabase/templates/invite.html"
|
|
||||||
|
|
||||||
# Uncomment to customize notification email template
|
|
||||||
# [auth.email.notification.password_changed]
|
|
||||||
# enabled = true
|
|
||||||
# subject = "Your password has been changed"
|
|
||||||
# content_path = "./templates/password_changed_notification.html"
|
|
||||||
|
|
||||||
[auth.sms]
|
|
||||||
# Allow/disallow new user signups via SMS to your project.
|
|
||||||
enable_signup = false
|
|
||||||
# If enabled, users need to confirm their phone number before signing in.
|
|
||||||
enable_confirmations = false
|
|
||||||
# Template for sending OTP to users
|
|
||||||
template = "Your code is {{ .Code }}"
|
|
||||||
# Controls the minimum amount of time that must pass before sending another sms otp.
|
|
||||||
max_frequency = "5s"
|
|
||||||
|
|
||||||
# Use pre-defined map of phone number to OTP for testing.
|
|
||||||
# [auth.sms.test_otp]
|
|
||||||
# 4152127777 = "123456"
|
|
||||||
|
|
||||||
# Configure logged in session timeouts.
|
|
||||||
# [auth.sessions]
|
|
||||||
# Force log out after the specified duration.
|
|
||||||
# timebox = "24h"
|
|
||||||
# Force log out if the user has been inactive longer than the specified duration.
|
|
||||||
# inactivity_timeout = "8h"
|
|
||||||
|
|
||||||
# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object.
|
|
||||||
# [auth.hook.before_user_created]
|
|
||||||
# enabled = true
|
|
||||||
# uri = "pg-functions://postgres/auth/before-user-created-hook"
|
|
||||||
|
|
||||||
# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used.
|
|
||||||
# [auth.hook.custom_access_token]
|
|
||||||
# enabled = true
|
|
||||||
# uri = "pg-functions://<database>/<schema>/<hook_name>"
|
|
||||||
|
|
||||||
# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`.
|
|
||||||
[auth.sms.twilio]
|
|
||||||
enabled = false
|
|
||||||
account_sid = ""
|
|
||||||
message_service_sid = ""
|
|
||||||
# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead:
|
|
||||||
auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)"
|
|
||||||
|
|
||||||
# Multi-factor-authentication is available to Supabase Pro plan.
|
|
||||||
[auth.mfa]
|
|
||||||
# Control how many MFA factors can be enrolled at once per user.
|
|
||||||
max_enrolled_factors = 10
|
|
||||||
|
|
||||||
# Control MFA via App Authenticator (TOTP)
|
|
||||||
[auth.mfa.totp]
|
|
||||||
enroll_enabled = false
|
|
||||||
verify_enabled = false
|
|
||||||
|
|
||||||
# Configure MFA via Phone Messaging
|
|
||||||
[auth.mfa.phone]
|
|
||||||
enroll_enabled = false
|
|
||||||
verify_enabled = false
|
|
||||||
otp_length = 6
|
|
||||||
template = "Your code is {{ .Code }}"
|
|
||||||
max_frequency = "5s"
|
|
||||||
|
|
||||||
# Configure MFA via WebAuthn
|
|
||||||
# [auth.mfa.web_authn]
|
|
||||||
# enroll_enabled = true
|
|
||||||
# verify_enabled = true
|
|
||||||
|
|
||||||
# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`,
|
|
||||||
# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`,
|
|
||||||
# `twitter`, `x`, `slack`, `spotify`, `workos`, `zoom`.
|
|
||||||
[auth.external.apple]
|
|
||||||
enabled = false
|
|
||||||
client_id = ""
|
|
||||||
# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead:
|
|
||||||
secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)"
|
|
||||||
# Overrides the default auth callback URL derived from auth.external_url.
|
|
||||||
redirect_uri = ""
|
|
||||||
# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure,
|
|
||||||
# or any other third-party OIDC providers.
|
|
||||||
url = ""
|
|
||||||
# If enabled, the nonce check will be skipped. Required for local sign in with Google auth.
|
|
||||||
skip_nonce_check = false
|
|
||||||
# If enabled, it will allow the user to successfully authenticate when the provider does not return an email address.
|
|
||||||
email_optional = false
|
|
||||||
|
|
||||||
# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard.
|
|
||||||
# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting.
|
|
||||||
[auth.web3.solana]
|
|
||||||
enabled = false
|
|
||||||
|
|
||||||
# Use Firebase Auth as a third-party provider alongside Supabase Auth.
|
|
||||||
[auth.third_party.firebase]
|
|
||||||
enabled = false
|
|
||||||
# project_id = "my-firebase-project"
|
|
||||||
|
|
||||||
# Use Auth0 as a third-party provider alongside Supabase Auth.
|
|
||||||
[auth.third_party.auth0]
|
|
||||||
enabled = false
|
|
||||||
# tenant = "my-auth0-tenant"
|
|
||||||
# tenant_region = "us"
|
|
||||||
|
|
||||||
# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth.
|
|
||||||
[auth.third_party.aws_cognito]
|
|
||||||
enabled = false
|
|
||||||
# user_pool_id = "my-user-pool-id"
|
|
||||||
# user_pool_region = "us-east-1"
|
|
||||||
|
|
||||||
# Use Clerk as a third-party provider alongside Supabase Auth.
|
|
||||||
[auth.third_party.clerk]
|
|
||||||
enabled = false
|
|
||||||
# Obtain from https://clerk.com/setup/supabase
|
|
||||||
# domain = "example.clerk.accounts.dev"
|
|
||||||
|
|
||||||
# OAuth server configuration
|
|
||||||
[auth.oauth_server]
|
|
||||||
# Enable OAuth server functionality
|
|
||||||
enabled = false
|
|
||||||
# Path for OAuth consent flow UI
|
|
||||||
authorization_url_path = "/oauth/consent"
|
|
||||||
# Allow dynamic client registration
|
|
||||||
allow_dynamic_registration = false
|
|
||||||
|
|
||||||
[edge_runtime]
|
|
||||||
enabled = true
|
|
||||||
# Supported request policies: `oneshot`, `per_worker`.
|
|
||||||
# `per_worker` (default) — enables hot reload during local development.
|
|
||||||
# `oneshot` — fallback mode if hot reload causes issues (e.g. in large repos or with symlinks).
|
|
||||||
policy = "per_worker"
|
|
||||||
# Port to attach the Chrome inspector for debugging edge functions.
|
|
||||||
inspector_port = 8083
|
|
||||||
# The Deno major version to use.
|
|
||||||
deno_version = 2
|
|
||||||
|
|
||||||
# [edge_runtime.secrets]
|
|
||||||
# secret_key = "env(SECRET_VALUE)"
|
|
||||||
|
|
||||||
[analytics]
|
|
||||||
enabled = true
|
|
||||||
port = 54327
|
|
||||||
# Configure one of the supported backends: `postgres`, `bigquery`.
|
|
||||||
backend = "postgres"
|
|
||||||
|
|
||||||
# Experimental features may be deprecated any time
|
|
||||||
[experimental]
|
|
||||||
# Configures Postgres storage engine to use OrioleDB (S3)
|
|
||||||
orioledb_version = ""
|
|
||||||
# Configures S3 bucket URL, eg. <bucket_name>.s3-<region>.amazonaws.com
|
|
||||||
s3_host = "env(S3_HOST)"
|
|
||||||
# Configures S3 bucket region, eg. us-east-1
|
|
||||||
s3_region = "env(S3_REGION)"
|
|
||||||
# Configures AWS_ACCESS_KEY_ID for S3 bucket
|
|
||||||
s3_access_key = "env(S3_ACCESS_KEY)"
|
|
||||||
# Configures AWS_SECRET_ACCESS_KEY for S3 bucket
|
|
||||||
s3_secret_key = "env(S3_SECRET_KEY)"
|
|
||||||
|
|
||||||
# [experimental.pgdelta]
|
|
||||||
# When enabled, pg-delta becomes the active engine for supported schema flows.
|
|
||||||
# enabled = false
|
|
||||||
# Directory under `supabase/` where declarative files are written.
|
|
||||||
# declarative_schema_path = "./database"
|
|
||||||
# JSON string passed through to pg-delta SQL formatting.
|
|
||||||
# format_options = "{\"keywordCase\":\"upper\",\"indent\":2,\"maxWidth\":80,\"commaStyle\":\"trailing\"}"
|
|
||||||
@@ -1,201 +0,0 @@
|
|||||||
-- ============================================================
|
|
||||||
-- Checkout Hardening Migration
|
|
||||||
-- ============================================================
|
|
||||||
-- 1. Add idempotency_key column to orders (UUID, unique)
|
|
||||||
-- 2. Index on idempotency_key for fast idempotency checks
|
|
||||||
-- 3. RPC function for atomic order + order_items creation
|
|
||||||
-- 4. Returns JSONB — no post-checkout SELECT needed
|
|
||||||
|
|
||||||
-- 1. Idempotency key column
|
|
||||||
ALTER TABLE orders ADD COLUMN IF NOT EXISTS idempotency_key UUID UNIQUE;
|
|
||||||
|
|
||||||
-- 1b. Add fulfillment column to order_items for mixed pickup/ship orders
|
|
||||||
ALTER TABLE order_items ADD COLUMN IF NOT EXISTS fulfillment TEXT DEFAULT 'pickup';
|
|
||||||
|
|
||||||
-- 2. Index for idempotency lookups
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_orders_idempotency_key ON orders(idempotency_key);
|
|
||||||
|
|
||||||
-- 3. Atomic checkout RPC function
|
|
||||||
-- Validates stop, products, quantities, pickup eligibility
|
|
||||||
-- Computes subtotal server-side from current DB prices
|
|
||||||
-- Returns JSONB with order + items — caller never needs a separate SELECT
|
|
||||||
CREATE OR REPLACE FUNCTION create_order_with_items(
|
|
||||||
p_idempotency_key UUID,
|
|
||||||
p_customer_name TEXT,
|
|
||||||
p_customer_email TEXT,
|
|
||||||
p_customer_phone TEXT,
|
|
||||||
p_stop_id UUID,
|
|
||||||
p_items JSONB -- [{id: uuid, quantity: int, fulfillment: text}]
|
|
||||||
)
|
|
||||||
RETURNS JSONB
|
|
||||||
LANGUAGE plpgsql
|
|
||||||
SECURITY DEFINER
|
|
||||||
AS $$
|
|
||||||
DECLARE
|
|
||||||
v_order_id UUID;
|
|
||||||
v_item JSONB;
|
|
||||||
v_product_id UUID;
|
|
||||||
v_quantity INT;
|
|
||||||
v_fulfillment TEXT;
|
|
||||||
v_product_price NUMERIC;
|
|
||||||
v_computed_total NUMERIC := 0;
|
|
||||||
v_stop_active BOOLEAN;
|
|
||||||
v_stop_brand_id UUID;
|
|
||||||
v_stop_city TEXT;
|
|
||||||
v_stop_state TEXT;
|
|
||||||
v_stop_date TEXT;
|
|
||||||
v_stop_time TEXT;
|
|
||||||
v_stop_location TEXT;
|
|
||||||
v_product_active BOOLEAN;
|
|
||||||
v_product_brand_id UUID;
|
|
||||||
v_product_name TEXT;
|
|
||||||
v_is_pickup BOOLEAN;
|
|
||||||
v_order JSONB;
|
|
||||||
v_order_items JSONB := '[]'::JSONB;
|
|
||||||
BEGIN
|
|
||||||
-- ── Idempotency guard ──────────────────────────────────────
|
|
||||||
-- If this key was already used, return the existing order + items.
|
|
||||||
-- Safe for retries: the order is real, the user just didn't see the success page.
|
|
||||||
SELECT jsonb_build_object(
|
|
||||||
'id', o.id,
|
|
||||||
'customer_name', o.customer_name,
|
|
||||||
'customer_email', o.customer_name,
|
|
||||||
'customer_phone', o.customer_phone,
|
|
||||||
'subtotal', o.subtotal,
|
|
||||||
'status', o.status,
|
|
||||||
'stop_id', o.stop_id,
|
|
||||||
'stop_city', s.city,
|
|
||||||
'stop_state', s.state,
|
|
||||||
'stop_date', s.date,
|
|
||||||
'stop_time', s.time,
|
|
||||||
'stop_location', s.location,
|
|
||||||
'items', COALESCE((
|
|
||||||
SELECT jsonb_agg(jsonb_build_object(
|
|
||||||
'product_id', oi.product_id,
|
|
||||||
'product_name', p.name,
|
|
||||||
'quantity', oi.quantity,
|
|
||||||
'price', oi.price,
|
|
||||||
'fulfillment', oi.fulfillment
|
|
||||||
))
|
|
||||||
FROM order_items oi
|
|
||||||
JOIN products p ON oi.product_id = p.id
|
|
||||||
WHERE oi.order_id = o.id
|
|
||||||
), '[]'::JSONB)
|
|
||||||
)
|
|
||||||
INTO v_order
|
|
||||||
FROM orders o
|
|
||||||
JOIN stops s ON o.stop_id = s.id
|
|
||||||
WHERE o.idempotency_key = p_idempotency_key
|
|
||||||
LIMIT 1;
|
|
||||||
|
|
||||||
IF v_order IS NOT NULL THEN
|
|
||||||
RETURN v_order;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
-- ── Validate stop ──────────────────────────────────────────
|
|
||||||
SELECT active, brand_id, city, state, date, time, location
|
|
||||||
INTO v_stop_active, v_stop_brand_id, v_stop_city, v_stop_state, v_stop_date, v_stop_time, v_stop_location
|
|
||||||
FROM stops
|
|
||||||
WHERE id = p_stop_id;
|
|
||||||
|
|
||||||
IF v_stop_brand_id IS NULL THEN
|
|
||||||
RAISE EXCEPTION 'Stop not found: %', p_stop_id;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
IF NOT v_stop_active THEN
|
|
||||||
RAISE EXCEPTION 'Stop is not active: %', p_stop_id;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
-- ── Validate items and compute subtotal ────────────────────
|
|
||||||
FOR v_item IN SELECT * FROM jsonb_array_elements(p_items)
|
|
||||||
LOOP
|
|
||||||
v_product_id := (v_item->>'id')::UUID;
|
|
||||||
v_quantity := (v_item->>'quantity')::INT;
|
|
||||||
v_fulfillment := v_item->>'fulfillment';
|
|
||||||
v_is_pickup := (v_fulfillment = 'pickup');
|
|
||||||
|
|
||||||
SELECT active, brand_id, price, name
|
|
||||||
INTO v_product_active, v_product_brand_id, v_product_price, v_product_name
|
|
||||||
FROM products
|
|
||||||
WHERE id = v_product_id;
|
|
||||||
|
|
||||||
IF v_product_brand_id IS NULL THEN
|
|
||||||
RAISE EXCEPTION 'Product not found: %', v_product_id;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
IF v_product_brand_id != v_stop_brand_id THEN
|
|
||||||
RAISE EXCEPTION 'Product % does not belong to the stop brand', v_product_id;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
IF NOT v_product_active THEN
|
|
||||||
RAISE EXCEPTION 'Product is not active: %', v_product_id;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
IF v_quantity < 1 THEN
|
|
||||||
RAISE EXCEPTION 'Quantity must be at least 1 for product: %', v_product_id;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
IF v_is_pickup THEN
|
|
||||||
PERFORM 1
|
|
||||||
FROM product_stops
|
|
||||||
WHERE product_id = v_product_id AND stop_id = p_stop_id
|
|
||||||
LIMIT 1;
|
|
||||||
|
|
||||||
IF NOT FOUND THEN
|
|
||||||
RAISE EXCEPTION 'Pickup product % is not available at stop %', v_product_id, p_stop_id;
|
|
||||||
END IF;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
v_computed_total := v_computed_total + (v_product_price * v_quantity);
|
|
||||||
END LOOP;
|
|
||||||
|
|
||||||
-- ── Create order ────────────────────────────────────────────
|
|
||||||
INSERT INTO orders (
|
|
||||||
idempotency_key, customer_name, customer_email, customer_phone,
|
|
||||||
stop_id, subtotal, status
|
|
||||||
) VALUES (
|
|
||||||
p_idempotency_key, p_customer_name, p_customer_email, p_customer_phone,
|
|
||||||
p_stop_id, v_computed_total, 'pending'
|
|
||||||
)
|
|
||||||
RETURNING id INTO v_order_id;
|
|
||||||
|
|
||||||
-- ── Insert order items and build return value ───────────────
|
|
||||||
FOR v_item IN SELECT * FROM jsonb_array_elements(p_items)
|
|
||||||
LOOP
|
|
||||||
v_product_id := (v_item->>'id')::UUID;
|
|
||||||
v_quantity := (v_item->>'quantity')::INT;
|
|
||||||
v_fulfillment := v_item->>'fulfillment';
|
|
||||||
|
|
||||||
SELECT price, name INTO v_product_price, v_product_name FROM products WHERE id = v_product_id;
|
|
||||||
|
|
||||||
INSERT INTO order_items (order_id, product_id, quantity, fulfillment, price)
|
|
||||||
VALUES (v_order_id, v_product_id, v_quantity, v_fulfillment, v_product_price);
|
|
||||||
|
|
||||||
-- Append to items array
|
|
||||||
v_order_items := v_order_items || jsonb_build_object(
|
|
||||||
'product_id', v_product_id,
|
|
||||||
'product_name', v_product_name,
|
|
||||||
'quantity', v_quantity,
|
|
||||||
'price', v_product_price,
|
|
||||||
'fulfillment', v_fulfillment
|
|
||||||
);
|
|
||||||
END LOOP;
|
|
||||||
|
|
||||||
-- ── Build and return full order object ──────────────────────
|
|
||||||
RETURN jsonb_build_object(
|
|
||||||
'id', v_order_id,
|
|
||||||
'customer_name', p_customer_name,
|
|
||||||
'customer_email', p_customer_email,
|
|
||||||
'customer_phone', p_customer_phone,
|
|
||||||
'subtotal', v_computed_total,
|
|
||||||
'status', 'pending',
|
|
||||||
'stop_id', p_stop_id,
|
|
||||||
'stop_city', v_stop_city,
|
|
||||||
'stop_state', v_stop_state,
|
|
||||||
'stop_date', v_stop_date,
|
|
||||||
'stop_time', v_stop_time,
|
|
||||||
'stop_location', v_stop_location,
|
|
||||||
'items', v_order_items
|
|
||||||
);
|
|
||||||
END;
|
|
||||||
$$;
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user