Feat/better auth #3
@@ -0,0 +1,34 @@
|
|||||||
|
# --- Self-hosted Postgres (Docker) ---
|
||||||
|
POSTGRES_USER=routecommerce
|
||||||
|
POSTGRES_PASSWORD=lay7yqM3fHNvwpfjb4tvz7M3kimopyrSHQF24vsuGUM
|
||||||
|
POSTGRES_DB=route_commerce
|
||||||
|
# Used by the app and push-migrations.js to talk to the local DB
|
||||||
|
DATABASE_URL=postgresql://routecommerce:lay7yqM3fHNvwpfjb4tvz7M3kimopyrSHQF24vsuGUM@127.0.0.1:5432/route_commerce?schema=public
|
||||||
|
|
||||||
|
# --- PostgREST (REST API — replaces Supabase REST) ---
|
||||||
|
# Server actions call `${NEXT_PUBLIC_SUPABASE_URL}/rest/v1/rpc/...`
|
||||||
|
# Point that URL at the local PostgREST container. Anon key can be anything
|
||||||
|
# non-empty since we handle auth at the app layer (Better Auth + dev_session).
|
||||||
|
NEXT_PUBLIC_SUPABASE_URL=http://localhost:3001
|
||||||
|
NEXT_PUBLIC_SUPABASE_ANON_KEY=local-postgrest-anon-key
|
||||||
|
SUPABASE_SERVICE_ROLE_KEY=local-postgrest-service-key
|
||||||
|
|
||||||
|
# --- MinIO (S3-compatible object storage — replaces Supabase Storage) ---
|
||||||
|
MINIO_ROOT_USER=routecommerce
|
||||||
|
MINIO_ROOT_PASSWORD=miniochangeme123
|
||||||
|
STORAGE_ENDPOINT=http://localhost:9000
|
||||||
|
STORAGE_REGION=us-east-1
|
||||||
|
STORAGE_ACCESS_KEY=routecommerce
|
||||||
|
STORAGE_SECRET_KEY=miniochangeme123
|
||||||
|
STORAGE_BUCKET_PREFIX=
|
||||||
|
# Public base URL for image rendering in <img src=...>
|
||||||
|
NEXT_PUBLIC_STORAGE_BASE_URL=http://localhost:9000
|
||||||
|
|
||||||
|
# --- Better Auth ---
|
||||||
|
BETTER_AUTH_SECRET=change-me-32-char-random-string-here-pls-32chars
|
||||||
|
BETTER_AUTH_URL=http://localhost:3000
|
||||||
|
NEXT_PUBLIC_BETTER_AUTH_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# --- App secrets ---
|
||||||
|
MINIMAX_API_KEY=
|
||||||
|
MINIMAX_BASE_URL=
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
name: Deploy to route.crispygoat.com
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
|
env:
|
||||||
|
NODE_ENV: production
|
||||||
|
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
|
||||||
|
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
|
||||||
|
SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }}
|
||||||
|
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
|
||||||
|
MINIMAX_BASE_URL: ${{ secrets.MINIMAX_BASE_URL }}
|
||||||
|
|
||||||
|
- name: Deploy
|
||||||
|
env:
|
||||||
|
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
|
||||||
|
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
|
||||||
|
SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }}
|
||||||
|
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
|
||||||
|
MINIMAX_BASE_URL: ${{ secrets.MINIMAX_BASE_URL }}
|
||||||
|
run: |
|
||||||
|
APP_DIR=/home/tyler/route-commerce
|
||||||
|
mkdir -p $APP_DIR
|
||||||
|
|
||||||
|
# Write env file from secrets
|
||||||
|
printf "NEXT_PUBLIC_SUPABASE_URL=%s\n" "$NEXT_PUBLIC_SUPABASE_URL" > $APP_DIR/.env.production
|
||||||
|
printf "NEXT_PUBLIC_SUPABASE_ANON_KEY=%s\n" "$NEXT_PUBLIC_SUPABASE_ANON_KEY" >> $APP_DIR/.env.production
|
||||||
|
printf "SUPABASE_SERVICE_ROLE_KEY=%s\n" "$SUPABASE_SERVICE_ROLE_KEY" >> $APP_DIR/.env.production
|
||||||
|
printf "MINIMAX_API_KEY=%s\n" "$MINIMAX_API_KEY" >> $APP_DIR/.env.production
|
||||||
|
printf "MINIMAX_BASE_URL=%s\n" "$MINIMAX_BASE_URL" >> $APP_DIR/.env.production
|
||||||
|
|
||||||
|
# Copy build output and required files
|
||||||
|
rsync -a --delete .next/ $APP_DIR/.next/
|
||||||
|
rsync -a --delete public/ $APP_DIR/public/
|
||||||
|
cp package.json $APP_DIR/
|
||||||
|
cp next.config.ts $APP_DIR/ 2>/dev/null || cp next.config.js $APP_DIR/ 2>/dev/null || true
|
||||||
|
|
||||||
|
# Install production deps only
|
||||||
|
cd $APP_DIR
|
||||||
|
npm install --omit=dev
|
||||||
|
|
||||||
|
# Start or restart PM2 process
|
||||||
|
if pm2 describe route-commerce > /dev/null 2>&1; then
|
||||||
|
pm2 restart route-commerce
|
||||||
|
else
|
||||||
|
pm2 start npm --name route-commerce -- start -- -p 3100
|
||||||
|
pm2 save
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Deployed successfully"
|
||||||
+3
-1
@@ -41,4 +41,6 @@ supabase/.temp/
|
|||||||
|
|
||||||
# IDE / local config
|
# IDE / local config
|
||||||
.mcp.json
|
.mcp.json
|
||||||
.env*
|
|
||||||
|
# Docker / self-hosted Postgres
|
||||||
|
db_data/
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
# Self-Hosted Storage + Schema Plan
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The user is migrating Route Commerce from Supabase to a self-hosted stack:
|
||||||
|
- **Already done in this branch (`feat/better-auth`)**: Better Auth (auth), Docker Postgres, PostgREST, env var rewiring, ~10 source files updated to drop Supabase JS client.
|
||||||
|
- **What's broken right now**: The website is missing images (Supabase Storage URLs 404 because Supabase is going away), and the local Postgres can't apply the 137 migrations because the **base schema is missing** (the initial tables — `brands`, `orders`, `products`, `stops`, `admin_users` — were created in Supabase directly and never exported as a migration).
|
||||||
|
- **User constraint**: "no band-aids" — proper self-hosted replacement, not runtime patches.
|
||||||
|
- **User constraint**: "no data migration" — start with fresh empty DB; users re-upload assets.
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
Three independent workstreams, executed in order:
|
||||||
|
|
||||||
|
1. **Capture the base schema** from the still-live Supabase project using `pg_dump --schema-only` (recommended over `supabase db pull`, which has a broken migration history). Apply the captured schema + existing 137 migrations to the local self-hosted Postgres.
|
||||||
|
2. **Stand up MinIO** in Docker as the S3-compatible object store. Wire it to the app via the AWS SDK. MinIO's URL structure is clean: `http://minio:9000/<bucket>/<key>`, publicly readable per-bucket via bucket policy.
|
||||||
|
3. **Replace every Supabase Storage URL** in the codebase with MinIO URLs (one env var: `NEXT_PUBLIC_STORAGE_BASE_URL`).
|
||||||
|
|
||||||
|
Storage buckets in use (from explore agent):
|
||||||
|
| Bucket | Purpose | Current state |
|
||||||
|
|---|---|---|
|
||||||
|
| `brand-logos` | Brand logo images | Hardcoded in 8+ files via env-var URL |
|
||||||
|
| `product-images` | Product photos | `80aa01da-ab4b-44f8-b6e7-700552457e18` (Supabase bucket UUID) |
|
||||||
|
| `contacts-imports` | CSV contact imports | `a1b2c3d4-…` (Supabase bucket UUID) |
|
||||||
|
| `videos` | Tuxedo video hero | Hardcoded Supabase URL in `TuxedoVideoHero.tsx` |
|
||||||
|
| `water-photos` (dynamic) | Water log photos | API route, bucket name in form field |
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
### Phase A — Capture base schema and apply to local Postgres
|
||||||
|
|
||||||
|
**Why `pg_dump`, not `supabase db pull`**: The remote Supabase project's `supabase_migrations.schema_migrations` history is out of sync with the local files (that's the long error list the user got). `supabase db pull` requires that history to be in sync before it'll write a new initial migration. `pg_dump` reads the live catalog directly and ignores the migration history entirely.
|
||||||
|
|
||||||
|
**Steps**:
|
||||||
|
|
||||||
|
1. **Capture the schema** from the remote Supabase DB (run on the user's Mac where direct PG works — MEMORY.md confirms direct PG is blocked from this dev box, but their Mac can do it):
|
||||||
|
```bash
|
||||||
|
pg_dump "postgresql://postgres:<service-role-pw>@db.wnzkhezyhnfzhkhiflrp.supabase.co:5432/postgres" \
|
||||||
|
--schema-only --no-owner --no-privileges \
|
||||||
|
--schema=public \
|
||||||
|
-f supabase/captured_schema.sql
|
||||||
|
```
|
||||||
|
*Exclude `auth` and `storage` schemas* — we stub `auth` ourselves and use MinIO instead of Supabase Storage. Note: 185 SECURITY DEFINER functions reference `auth.uid()`; these will compile against the preflight stub but return NULL at runtime. Phase D addresses this.
|
||||||
|
|
||||||
|
2. **Delete files that don't belong** in the local apply order:
|
||||||
|
- `BUNDLE_018_042.sql` — concatenated duplicate
|
||||||
|
- `XXX_blog_tables.sql`, `XXX_launch_checklist.sql`, `XXX_roadmap_tables.sql`, `XXX_waitlist_waitlist.sql` — drafts (XXX convention)
|
||||||
|
- `099_contact_imports_bucket.sql` (the bucket-creation one — keep the RPCs though)
|
||||||
|
- Rename to disambiguate any number collisions (no current collision, but defensive)
|
||||||
|
|
||||||
|
3. **Patch the 4 broken migrations** identified by the explore agent (read the file first, then `search_replace`):
|
||||||
|
- `006_water_log_rpcs_fixed.sql`: replace `STATIC` with `STABLE` (6 occurrences)
|
||||||
|
- `087_brand_logos_bucket.sql`: convert `CREATE POLICY IF NOT EXISTS …` to `DROP POLICY IF EXISTS …; CREATE POLICY …;` (4 policies)
|
||||||
|
- `099_harvest_reach_segmentation.sql`: quote the `time` column references (matches the 148 patch)
|
||||||
|
- `135_email_automation_rpcs.sql`: reorder `enroll_abandoned_cart` params so defaults come last, or add a default to `p_next_email_at`
|
||||||
|
|
||||||
|
4. **Update `000_preflight_supabase_compat.sql`** (already in the branch):
|
||||||
|
- Add `CREATE EXTENSION IF NOT EXISTS pgcrypto;` at the top
|
||||||
|
- Remove the `storage.buckets` / `storage.objects` stub (MinIO replaces it; 087/145 storage policies will be deleted in step 2)
|
||||||
|
|
||||||
|
5. **Apply to local Postgres** (running on this dev box, port 5432):
|
||||||
|
```bash
|
||||||
|
PGPASSWORD='lay7yqM3fHNvwpfjb4tvz7M3kimopyrSHQF24vsuGUM' \
|
||||||
|
psql -h 127.0.0.1 -U routecommerce -d route_commerce -v ON_ERROR_STOP=1 -f supabase/migrations/000_preflight_supabase_compat.sql
|
||||||
|
|
||||||
|
PGPASSWORD='lay7yqM3fHNvwpfjb4tvz7M3kimopyrSHQF24vsuGUM' \
|
||||||
|
psql -h 127.0.0.1 -U routecommerce -d route_commerce -v ON_ERROR_STOP=1 -f supabase/captured_schema.sql
|
||||||
|
|
||||||
|
PGPASSWORD='lay7yqM3fHNvwpfjb4tvz7M3kimopyrSHQF24vsuGUM' \
|
||||||
|
for f in supabase/migrations/[0-9]*.sql; do
|
||||||
|
psql -h 127.0.0.1 -U routecommerce -d route_commerce -v ON_ERROR_STOP=1 -q -f "$f" || break
|
||||||
|
done
|
||||||
|
```
|
||||||
|
Expected: most migrations apply, some fail (drop those, log in plan). This is the test that the local Postgres is actually usable.
|
||||||
|
|
||||||
|
### Phase B — MinIO for object storage
|
||||||
|
|
||||||
|
**Add to `docker-compose.yml`** (new `minio` service + `minio_init` one-shot to create buckets via `mc`):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
minio:
|
||||||
|
image: minio/minio:latest
|
||||||
|
container_name: route_commerce_minio
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file: [.env]
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
|
||||||
|
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
volumes:
|
||||||
|
- minio_data:/data
|
||||||
|
ports: ["127.0.0.1:9000:9000", "127.0.0.1:9001:9001"]
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
minio_init:
|
||||||
|
image: minio/mc:latest
|
||||||
|
depends_on:
|
||||||
|
minio: { condition: service_healthy }
|
||||||
|
env_file: [.env]
|
||||||
|
entrypoint: ["/bin/sh", "-c"]
|
||||||
|
command:
|
||||||
|
- |
|
||||||
|
mc alias set local http://minio:9000 $${MINIO_ROOT_USER} $${MINIO_ROOT_PASSWORD}
|
||||||
|
for b in brand-logos product-images contacts-imports videos water-photos; do
|
||||||
|
mc mb --ignore-existing local/$${b}
|
||||||
|
mc anonymous set download local/$${b}
|
||||||
|
done
|
||||||
|
profiles: ["init"]
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
minio_data: { driver: local }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Add to `.env.example`**:
|
||||||
|
```
|
||||||
|
MINIO_ROOT_USER=routecommerce
|
||||||
|
MINIO_ROOT_PASSWORD=change-me-minio-root
|
||||||
|
NEXT_PUBLIC_STORAGE_BASE_URL=http://localhost:9000
|
||||||
|
STORAGE_ENDPOINT=http://localhost:9000
|
||||||
|
STORAGE_REGION=us-east-1
|
||||||
|
STORAGE_ACCESS_KEY=routecommerce
|
||||||
|
STORAGE_SECRET_KEY=change-me-minio-root
|
||||||
|
STORAGE_BUCKET_PREFIX=
|
||||||
|
```
|
||||||
|
|
||||||
|
**Install AWS SDK** (MinIO is S3-compatible):
|
||||||
|
```bash
|
||||||
|
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase C — Replace Supabase Storage calls with MinIO
|
||||||
|
|
||||||
|
**New server-side helper** `src/lib/storage.ts`:
|
||||||
|
- `s3Client` configured from env (uses `@aws-sdk/client-s3`)
|
||||||
|
- `uploadFile({ bucket, key, body, contentType })` — replaces `fetch(PUT .../storage/v1/object/{bucket}/{key})` patterns
|
||||||
|
- `deleteFile({ bucket, key })`
|
||||||
|
- `publicUrl(bucket, key)` — returns `${STORAGE_BASE_URL}/${bucket}/${key}`
|
||||||
|
- Bucket name constants exported as a single source of truth
|
||||||
|
|
||||||
|
**Files to modify** (from explore agent):
|
||||||
|
- `src/actions/brand-settings.ts` — 8 `fetch` PUTs to `/storage/v1/object/brand-logos/...`
|
||||||
|
- `src/actions/products/upload-image.ts` — 3 calls to `product-images` bucket
|
||||||
|
- `src/actions/communications/import-contacts.ts` — `contacts-imports` bucket (PUT + LIST + RPC)
|
||||||
|
- `src/app/api/water-photo-upload/route.ts` — dynamic bucket
|
||||||
|
- `src/components/storefront/TuxedoVideoHero.tsx` — hardcoded `https://wnzkhezyhnfzhkhiflrp.supabase.co/storage/v1/object/public/...` for videos and brand-logos
|
||||||
|
- `src/components/time-tracking/TimeTrackingFieldClient.tsx` — same hardcoded URL
|
||||||
|
- `src/lib/email-service.ts` — 4 occurrences of `wnzkhezyhnfzhkhiflrp.supabase.co/storage/v1/object/public/...`
|
||||||
|
- `src/app/tuxedo/about/page.tsx` — same hardcoded URL
|
||||||
|
|
||||||
|
For the **hardcoded Supabase URLs**: replace with `publicUrl(bucket, key)` or `${process.env.NEXT_PUBLIC_STORAGE_BASE_URL}/${bucket}/${key}`.
|
||||||
|
|
||||||
|
**Remove**:
|
||||||
|
- The `MockStorageBuilder` in `src/lib/supabase.ts:193-223` (no longer needed)
|
||||||
|
- `SUPABASE_PAT` env var from `.env.example` (only used by water-photo-upload)
|
||||||
|
|
||||||
|
### Phase D — Auth wiring (closes the loop)
|
||||||
|
|
||||||
|
The 185 `auth.uid()` references in the captured schema will return NULL without intervention. Two options:
|
||||||
|
|
||||||
|
- **Option 1 (recommended, matches existing pattern)**: Disable RLS on all public tables, rely on the SECURITY DEFINER RPCs (which the codebase already does, per CLAUDE.md). One-time SQL after `captured_schema.sql` applies: `DO $$ DECLARE r record; BEGIN FOR r IN SELECT tablename FROM pg_tables WHERE schemaname='public' LOOP EXECUTE 'ALTER TABLE public.' || quote_ident(r.tablename) || ' DISABLE ROW LEVEL SECURITY'; END LOOP; END $$;`
|
||||||
|
- **Option 2**: Wire PostgREST to set `request.jwt.claim.sub` from the Better Auth session cookie. More complex; deferred unless RLS proves necessary.
|
||||||
|
|
||||||
|
**Recommend Option 1** for now — it's consistent with the existing "brand scoping in server actions" pattern, and the SECURITY DEFINER functions still work because they execute with the function owner's privileges (no RLS blocking).
|
||||||
|
|
||||||
|
### Phase E — Verification
|
||||||
|
|
||||||
|
End-to-end test sequence (no more "trust me, it works"):
|
||||||
|
|
||||||
|
1. **DB schema applies cleanly**:
|
||||||
|
```bash
|
||||||
|
docker compose up -d db minio
|
||||||
|
PGPASSWORD=$POSTGRES_PASSWORD psql -h 127.0.0.1 -U routecommerce -d route_commerce -f 000_preflight.sql
|
||||||
|
PGPASSWORD=$POSTGRES_PASSWORD psql -h 127.0.0.1 -U routecommerce -d route_commerce -f captured_schema.sql
|
||||||
|
for f in supabase/migrations/[0-9]*.sql; do psql ... -f "$f" || echo "FAIL: $f"; done
|
||||||
|
```
|
||||||
|
Goal: all migrations apply (with the 4 patches from Phase A) OR the remaining failures are documented and skipped.
|
||||||
|
|
||||||
|
2. **PostgREST serves the schema**:
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:3001/brands?limit=1 -H "apikey: local-anon"
|
||||||
|
curl http://127.0.0.1:3001/rpc/get_public_stops_for_brand -X POST -H "Content-Type: application/json" -d '{"p_slug":"indian-river-direct"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **MinIO buckets are public**:
|
||||||
|
```bash
|
||||||
|
mc alias set local http://127.0.0.1:9000 $USER $PASS
|
||||||
|
mc ls local/
|
||||||
|
curl -I http://127.0.0.1:9000/brand-logos/test.png # should return 200 or 404, never 403
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **App build passes**:
|
||||||
|
```bash
|
||||||
|
DATABASE_URL=... NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:3001 \
|
||||||
|
NEXT_PUBLIC_STORAGE_BASE_URL=http://127.0.0.1:9000 \
|
||||||
|
BETTER_AUTH_SECRET=... npm run build
|
||||||
|
```
|
||||||
|
Goal: `✓ Compiled successfully` with no Supabase URL parse errors, no `auth.uid()` undefined, no missing bucket errors.
|
||||||
|
|
||||||
|
5. **Image display in the browser**:
|
||||||
|
- Start the dev server: `npm run dev`
|
||||||
|
- Sign in via Better Auth (mock or real)
|
||||||
|
- Upload a product image via admin UI → verify it lands in MinIO (`mc ls local/product-images/`)
|
||||||
|
- Visit `/indian-river-direct/products/[slug]` → verify the image renders with the MinIO URL
|
||||||
|
|
||||||
|
6. **Auth round-trip**:
|
||||||
|
- `POST /api/auth/sign-up/email` with test email/password → expect 200, user created in `better_auth_user` (or whatever Better Auth's table is — check `200_better_auth_tables.sql`)
|
||||||
|
- `POST /api/auth/sign-in/email` → expect session cookie
|
||||||
|
- Hit `/admin` with the cookie → expect 200, not redirect to `/login`
|
||||||
|
|
||||||
|
## Files to modify (summary)
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|---|---|
|
||||||
|
| `docker-compose.yml` | Add `minio` + `minio_init` services, `minio_data` volume |
|
||||||
|
| `.env.example` | Add MinIO + storage env vars, remove `SUPABASE_PAT` |
|
||||||
|
| `package.json` | Add `@aws-sdk/client-s3`, `@aws-sdk/s3-request-presigner` |
|
||||||
|
| `supabase/migrations/000_preflight_supabase_compat.sql` | Add `pgcrypto` extension, remove storage stub |
|
||||||
|
| `supabase/migrations/006_water_log_rpcs_fixed.sql` | `STATIC` → `STABLE` |
|
||||||
|
| `supabase/migrations/087_brand_logos_bucket.sql` | `CREATE POLICY IF NOT EXISTS` → `DROP + CREATE` |
|
||||||
|
| `supabase/migrations/099_harvest_reach_segmentation.sql` | Quote `time` column refs |
|
||||||
|
| `supabase/migrations/135_email_automation_rpcs.sql` | Reorder `enroll_abandoned_cart` params |
|
||||||
|
| `supabase/captured_schema.sql` | **NEW** — output of `pg_dump` from remote |
|
||||||
|
| `src/lib/storage.ts` | **NEW** — S3 client, upload/delete/publicUrl helpers, bucket constants |
|
||||||
|
| `src/actions/brand-settings.ts` | Replace 8 Supabase fetch PUTs with `uploadFile` |
|
||||||
|
| `src/actions/products/upload-image.ts` | Replace 3 calls with `uploadFile` |
|
||||||
|
| `src/actions/communications/import-contacts.ts` | Replace PUT + LIST with `uploadFile` / S3 ListObjectsV2 |
|
||||||
|
| `src/app/api/water-photo-upload/route.ts` | Replace Supabase call with `uploadFile` |
|
||||||
|
| `src/components/storefront/TuxedoVideoHero.tsx` | Replace hardcoded Supabase URL with `publicUrl()` |
|
||||||
|
| `src/components/time-tracking/TimeTrackingFieldClient.tsx` | Same |
|
||||||
|
| `src/lib/email-service.ts` | Same (4 occurrences) |
|
||||||
|
| `src/app/tuxedo/about/page.tsx` | Same |
|
||||||
|
| `src/lib/supabase.ts` | Remove `MockStorageBuilder` (lines 193-223) |
|
||||||
|
|
||||||
|
## Reuse from existing code
|
||||||
|
|
||||||
|
- `src/lib/supabase.ts` — the `supabase` JS client still exports query builders used by `src/lib/svc-headers.ts` and many server actions for non-auth RPCs. Keep it for now; it's PostgREST-compatible because both speak the PostgREST protocol.
|
||||||
|
- `src/lib/auth.ts` — Better Auth config, already wired in this branch. No changes needed.
|
||||||
|
- `src/lib/admin-permissions.ts` — already swapped to use Better Auth + direct `pg`. No changes needed.
|
||||||
|
- `src/lib/svc-headers.ts` — used to build Supabase REST headers. Stays as-is (the anon/service-role strings still work against PostgREST).
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- **Migration apply order**: even after patches, some migrations may reference tables that haven't been created yet due to deletion order. The captured `pg_dump` puts everything in dependency order, so applying it first should resolve most cross-references.
|
||||||
|
- **The 4 broken migrations may be load-bearing**: 087 (brand-logos RLS) is already removed in the cleanup step. 006 (water log RPCs) is critical for the `/admin/water-log` pages. 099 is critical for communications. 135 is critical for the email automation. If the patches don't work, the affected features will be broken — but the rest of the app should still build and run.
|
||||||
|
- **Existing user-uploaded images in Supabase will be unreachable**: the user said "no data migration", so this is expected. Admins will re-upload logos/product images. The Tuxedo video and Olathe logos (referenced in `email-service.ts` and `tuxedo/about/page.tsx`) are brand assets the user will need to copy over manually.
|
||||||
|
- **PostgREST connection pooling**: PostgREST opens ~10 connections to Postgres. The 137 migrations + `pg_dump` schema may reference `auth.uid()` inside SECURITY DEFINER functions, which will fail to plan if the `auth` schema is missing. The preflight stubs the function but the `pg_dump` will also try to create the same function (with the real Supabase body). If `pg_dump` includes a non-stub `auth.uid()` that conflicts with the preflight, apply `pg_dump` first, then the preflight.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Route Commerce
|
# Route Commerce
|
||||||
|
|
||||||
A multi-tenant B2B e-commerce platform for fresh produce wholesale distribution. Brands manage products, stops, orders, and wholesale customers from a single admin dashboard.
|
A multi-tenant B2B e-commerce platform for fresh produce wholesale distribution. Brands manage products, stops, orders, and wholesale customers from a single admin dashboard. 🚀
|
||||||
|
|
||||||
## What It Does
|
## What It Does
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,83 @@
|
|||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: route_commerce_db
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
volumes:
|
||||||
|
- db_data:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:5432:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
postgrest:
|
||||||
|
image: postgrest/postgrest:v12.2.3
|
||||||
|
container_name: route_commerce_postgrest
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
PGRST_DB_URI: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||||
|
PGRST_DB_SCHEMA: public
|
||||||
|
PGRST_DB_ANON_ROLE: ${POSTGRES_USER}
|
||||||
|
PGRST_SERVER_PORT: 3001
|
||||||
|
PGRST_SERVER_HOST: 0.0.0.0
|
||||||
|
PGRST_OPENAPI_MODE: disabled
|
||||||
|
PGRST_DB_EXTRA_SEARCH_PATH: public,extensions
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:3001:3001"
|
||||||
|
|
||||||
|
minio:
|
||||||
|
image: minio/minio:latest
|
||||||
|
container_name: route_commerce_minio
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
|
||||||
|
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
volumes:
|
||||||
|
- minio_data:/data
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:9000:9000"
|
||||||
|
- "127.0.0.1:9001:9001"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
minio_init:
|
||||||
|
image: minio/mc:latest
|
||||||
|
depends_on:
|
||||||
|
minio:
|
||||||
|
condition: service_healthy
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
entrypoint: ["/bin/sh", "-c"]
|
||||||
|
command:
|
||||||
|
- |
|
||||||
|
mc alias set local http://minio:9000 $${MINIO_ROOT_USER} $${MINIO_ROOT_PASSWORD}
|
||||||
|
for b in brand-logos product-images contacts-imports videos water-photos; do
|
||||||
|
mc mb --ignore-existing local/$${b}
|
||||||
|
mc anonymous set download local/$${b}
|
||||||
|
done
|
||||||
|
profiles: ["init"]
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
db_data:
|
||||||
|
driver: local
|
||||||
|
minio_data:
|
||||||
|
driver: local
|
||||||
+11
-17
@@ -1,4 +1,5 @@
|
|||||||
import { NextResponse, type NextRequest } from "next/server";
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
|
import { getSessionCookie } from "better-auth/cookies";
|
||||||
|
|
||||||
const DEV_UID = "dev-user-00000000-0000-0000-0000-000000000000";
|
const DEV_UID = "dev-user-00000000-0000-0000-0000-000000000000";
|
||||||
|
|
||||||
@@ -9,26 +10,23 @@ export async function middleware(request: NextRequest) {
|
|||||||
// Allow dev cookies via: document.cookie = "dev_session=platform_admin; path=/"
|
// Allow dev cookies via: document.cookie = "dev_session=platform_admin; path=/"
|
||||||
const devSession = request.cookies.get("dev_session")?.value;
|
const devSession = request.cookies.get("dev_session")?.value;
|
||||||
const isDevMode = devSession === "platform_admin" || devSession === "brand_admin" || devSession === "store_employee";
|
const isDevMode = devSession === "platform_admin" || devSession === "brand_admin" || devSession === "store_employee";
|
||||||
const rcAuthUid = request.cookies.get("rc_auth_uid")?.value;
|
|
||||||
|
|
||||||
let authUid: string | null = null;
|
// Better Auth sets cookie named "rc_session_token" by default (with cookiePrefix: "rc")
|
||||||
|
const sessionCookie = getSessionCookie(request);
|
||||||
|
const hasSession = Boolean(sessionCookie);
|
||||||
|
|
||||||
|
let authed = false;
|
||||||
if (isDevMode) {
|
if (isDevMode) {
|
||||||
// Dev session only valid in development
|
authed = true;
|
||||||
authUid = DEV_UID;
|
} else if (hasSession) {
|
||||||
} else if (rcAuthUid) {
|
authed = true;
|
||||||
// rc_auth_uid is set by /api/login — treat as authenticated
|
|
||||||
authUid = rcAuthUid;
|
|
||||||
}
|
}
|
||||||
// No rc_auth_uid in production → authUid stays null → redirect to /login
|
|
||||||
|
|
||||||
const isAdmin = request.nextUrl.pathname.startsWith("/admin");
|
const isAdmin = request.nextUrl.pathname.startsWith("/admin");
|
||||||
const isLogin = request.nextUrl.pathname === "/login";
|
const isLogin = request.nextUrl.pathname === "/login";
|
||||||
|
|
||||||
if (isAdmin && !authUid) {
|
if (isAdmin && !authed) {
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
// Auto-login for demo: no auth cookie present
|
||||||
// Auto-login for demo: no Supabase configured, no auth cookie present
|
|
||||||
if (!supabaseUrl || !supabaseUrl.includes("supabase.co")) {
|
|
||||||
const url = request.nextUrl.clone();
|
const url = request.nextUrl.clone();
|
||||||
url.pathname = "/admin";
|
url.pathname = "/admin";
|
||||||
url.searchParams.set("demo", "1");
|
url.searchParams.set("demo", "1");
|
||||||
@@ -41,12 +39,8 @@ export async function middleware(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
const url = request.nextUrl.clone();
|
|
||||||
url.pathname = "/login";
|
|
||||||
return NextResponse.redirect(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLogin && authUid) {
|
if (isLogin && authed) {
|
||||||
const url = request.nextUrl.clone();
|
const url = request.nextUrl.clone();
|
||||||
url.pathname = "/admin";
|
url.pathname = "/admin";
|
||||||
return NextResponse.redirect(url);
|
return NextResponse.redirect(url);
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.96.0",
|
"@anthropic-ai/sdk": "^0.96.0",
|
||||||
|
"@aws-sdk/client-s3": "^3.1062.0",
|
||||||
|
"@aws-sdk/s3-request-presigner": "^3.1062.0",
|
||||||
"@clerk/nextjs": "^7.4.2",
|
"@clerk/nextjs": "^7.4.2",
|
||||||
"@google/generative-ai": "^0.24.1",
|
"@google/generative-ai": "^0.24.1",
|
||||||
"@gsap/react": "^2.1.2",
|
"@gsap/react": "^2.1.2",
|
||||||
@@ -23,9 +25,11 @@
|
|||||||
"@supabase/supabase-js": "^2.105.3",
|
"@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",
|
||||||
|
"better-auth": "^1.6.14",
|
||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
"framer-motion": "^12.40.0",
|
"framer-motion": "^12.40.0",
|
||||||
"gsap": "^3.15.0",
|
"gsap": "^3.15.0",
|
||||||
|
"kysely": "^0.29.2",
|
||||||
"lucide-react": "^1.17.0",
|
"lucide-react": "^1.17.0",
|
||||||
"next": "^16.2.6",
|
"next": "^16.2.6",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
@@ -48,6 +52,7 @@
|
|||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/papaparse": "^5.5.2",
|
"@types/papaparse": "^5.5.2",
|
||||||
|
"@types/pg": "^8.20.0",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
|||||||
@@ -1,63 +1,41 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { createServerClient } from "@supabase/ssr";
|
import { Pool } from "pg";
|
||||||
import { cookies } from "next/headers";
|
import { randomUUID } from "crypto";
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
|
const pool = new Pool({
|
||||||
|
connectionString: process.env.DATABASE_URL,
|
||||||
|
});
|
||||||
|
|
||||||
const DEV_ADMIN_UID = "dev-user-00000000-0000-0000-0000-000000000001";
|
const DEV_ADMIN_UID = "dev-user-00000000-0000-0000-0000-000000000001";
|
||||||
|
|
||||||
export async function forceAdminLogin(): Promise<{ success: boolean; uid?: string; error?: string }> {
|
export async function forceAdminLogin(): Promise<{ success: boolean; uid?: string; error?: string }> {
|
||||||
const cookieStore = await cookies();
|
try {
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
|
||||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
|
||||||
|
|
||||||
const response = NextResponse.next();
|
|
||||||
const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {
|
|
||||||
cookies: {
|
|
||||||
getAll() { return cookieStore.getAll(); },
|
|
||||||
setAll(cookiesToSet, headers) {
|
|
||||||
cookiesToSet.forEach(({ name, value, options }) => {
|
|
||||||
response.cookies.set(name, value, options);
|
|
||||||
});
|
|
||||||
Object.entries(headers).forEach(([key, value]) => {
|
|
||||||
response.headers.set(key, value);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Upsert dev platform_admin record
|
// Upsert dev platform_admin record
|
||||||
const { data: existing } = await supabase
|
const res = await pool.query(
|
||||||
.from("admin_users")
|
`INSERT INTO admin_users (
|
||||||
.select("id, role")
|
user_id, brand_id, role, active,
|
||||||
.eq("user_id", DEV_ADMIN_UID)
|
can_manage_products, can_manage_stops, can_manage_orders, can_manage_pickup,
|
||||||
.single();
|
can_manage_messages, can_manage_refunds, can_manage_users, can_manage_water_log,
|
||||||
|
can_manage_reports, can_manage_settings, must_change_password
|
||||||
|
) VALUES (
|
||||||
|
$1, NULL, 'platform_admin', true,
|
||||||
|
true, true, true, true,
|
||||||
|
true, true, true, true,
|
||||||
|
true, true, false
|
||||||
|
)
|
||||||
|
ON CONFLICT (user_id) DO UPDATE SET active = EXCLUDED.active
|
||||||
|
RETURNING id, role`,
|
||||||
|
[DEV_ADMIN_UID]
|
||||||
|
);
|
||||||
|
|
||||||
if (!existing) {
|
if (res.rows.length === 0) {
|
||||||
const { error: insertError } = await supabase
|
return { success: false, error: "Failed to upsert dev admin" };
|
||||||
.from("admin_users")
|
|
||||||
.insert({
|
|
||||||
user_id: DEV_ADMIN_UID,
|
|
||||||
brand_id: null,
|
|
||||||
role: "platform_admin",
|
|
||||||
active: true,
|
|
||||||
can_manage_products: true,
|
|
||||||
can_manage_stops: true,
|
|
||||||
can_manage_orders: true,
|
|
||||||
can_manage_pickup: true,
|
|
||||||
can_manage_messages: true,
|
|
||||||
can_manage_refunds: true,
|
|
||||||
can_manage_users: true,
|
|
||||||
can_manage_water_log: true,
|
|
||||||
can_manage_reports: true,
|
|
||||||
can_manage_settings: true,
|
|
||||||
must_change_password: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (insertError) {
|
|
||||||
return { success: false, error: insertError.message };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true, uid: DEV_ADMIN_UID };
|
return { success: true, uid: DEV_ADMIN_UID };
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const message = e instanceof Error ? e.message : "Unknown error";
|
||||||
|
return { success: false, error: message };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { Pool } from "pg";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
const pool = new Pool({
|
||||||
|
connectionString: process.env.DATABASE_URL,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type UpdateAdminProfileResult =
|
||||||
|
| { success: true }
|
||||||
|
| { success: false; error: string };
|
||||||
|
|
||||||
|
export async function updateAdminProfileAction(
|
||||||
|
id: string,
|
||||||
|
displayName: string | null,
|
||||||
|
phoneNumber: string | null
|
||||||
|
): Promise<UpdateAdminProfileResult> {
|
||||||
|
try {
|
||||||
|
await pool.query("SELECT update_admin_user($1, $2, $3)", [
|
||||||
|
id,
|
||||||
|
displayName,
|
||||||
|
phoneNumber,
|
||||||
|
]);
|
||||||
|
revalidatePath("/admin/me");
|
||||||
|
return { success: true };
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const message = e instanceof Error ? e.message : "Failed to update profile";
|
||||||
|
return { success: false, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { svcHeaders } from "@/lib/svc-headers";
|
import { svcHeaders } from "@/lib/svc-headers";
|
||||||
|
import { uploadFile, BUCKETS, publicUrl, storageKeys } from "@/lib/storage";
|
||||||
|
|
||||||
export type UploadLogoResult =
|
export type UploadLogoResult =
|
||||||
| { success: true; logoUrl: string }
|
| { success: true; logoUrl: string }
|
||||||
@@ -30,31 +31,28 @@ export async function uploadBrandLogo(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ext = file.type === "image/svg+xml" ? "svg" : file.type.split("/")[1];
|
const ext = file.type === "image/svg+xml" ? "svg" : file.type.split("/")[1];
|
||||||
const path = isDark ? `logo-dark.${ext}` : `logo.${ext}`;
|
const fileName = isDark ? `logo-dark.${ext}` : `logo.${ext}`;
|
||||||
const storagePath = `brand-logos/${brandId}/${path}`;
|
const key = storageKeys.brandLogo(brandId, fileName);
|
||||||
|
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
const buffer = Buffer.from(arrayBuffer);
|
const buffer = Buffer.from(arrayBuffer);
|
||||||
|
|
||||||
|
let url: string;
|
||||||
|
try {
|
||||||
|
const res = await uploadFile({
|
||||||
|
bucket: BUCKETS.BRAND_LOGOS,
|
||||||
|
key,
|
||||||
|
body: buffer,
|
||||||
|
contentType: file.type,
|
||||||
|
});
|
||||||
|
url = res.url;
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: `Upload failed: ${(e as Error).message}` };
|
||||||
|
}
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||||
|
|
||||||
const uploadRes = await fetch(
|
|
||||||
`${supabaseUrl}/storage/v1/object/brand-logos/${storagePath}`,
|
|
||||||
{
|
|
||||||
method: "PUT",
|
|
||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": file.type, "x-upsert": "true" },
|
|
||||||
body: buffer,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!uploadRes.ok) {
|
|
||||||
return { success: false, error: `Upload failed: ${await uploadRes.text()}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
const publicUrl = `${supabaseUrl}/storage/v1/object/public/brand-logos/${storagePath}`;
|
|
||||||
|
|
||||||
// Save URL to brand_settings via RPC
|
|
||||||
const saveRes = await fetch(
|
const saveRes = await fetch(
|
||||||
`${supabaseUrl}/rest/v1/rpc/upsert_brand_settings`,
|
`${supabaseUrl}/rest/v1/rpc/upsert_brand_settings`,
|
||||||
{
|
{
|
||||||
@@ -62,7 +60,7 @@ export async function uploadBrandLogo(
|
|||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
p_brand_id: brandId,
|
p_brand_id: brandId,
|
||||||
[isDark ? "p_logo_url_dark" : "p_logo_url"]: publicUrl,
|
[isDark ? "p_logo_url_dark" : "p_logo_url"]: url,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -71,7 +69,7 @@ export async function uploadBrandLogo(
|
|||||||
return { success: false, error: "Upload succeeded but failed to save URL" };
|
return { success: false, error: "Upload succeeded but failed to save URL" };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true, logoUrl: publicUrl };
|
return { success: true, logoUrl: url };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function uploadOlatheSweetLogo(
|
export async function uploadOlatheSweetLogo(
|
||||||
@@ -96,29 +94,27 @@ export async function uploadOlatheSweetLogo(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ext = file.type === "image/svg+xml" ? "svg" : file.type.split("/")[1];
|
const ext = file.type === "image/svg+xml" ? "svg" : file.type.split("/")[1];
|
||||||
const storagePath = `brand-logos/${brandId}/olathe-sweet-logo.${ext}`;
|
const key = storageKeys.brandLogo(brandId, `olathe-sweet-logo.${ext}`);
|
||||||
|
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
const buffer = Buffer.from(arrayBuffer);
|
const buffer = Buffer.from(arrayBuffer);
|
||||||
|
|
||||||
|
let url: string;
|
||||||
|
try {
|
||||||
|
const res = await uploadFile({
|
||||||
|
bucket: BUCKETS.BRAND_LOGOS,
|
||||||
|
key,
|
||||||
|
body: buffer,
|
||||||
|
contentType: file.type,
|
||||||
|
});
|
||||||
|
url = res.url;
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: `Upload failed: ${(e as Error).message}` };
|
||||||
|
}
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||||
|
|
||||||
const uploadRes = await fetch(
|
|
||||||
`${supabaseUrl}/storage/v1/object/brand-logos/${storagePath}`,
|
|
||||||
{
|
|
||||||
method: "PUT",
|
|
||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": file.type, "x-upsert": "true" },
|
|
||||||
body: buffer,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!uploadRes.ok) {
|
|
||||||
return { success: false, error: `Upload failed: ${await uploadRes.text()}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
const publicUrl = `${supabaseUrl}/storage/v1/object/public/brand-logos/${storagePath}`;
|
|
||||||
|
|
||||||
const saveRes = await fetch(
|
const saveRes = await fetch(
|
||||||
`${supabaseUrl}/rest/v1/rpc/upsert_brand_settings`,
|
`${supabaseUrl}/rest/v1/rpc/upsert_brand_settings`,
|
||||||
{
|
{
|
||||||
@@ -126,7 +122,7 @@ export async function uploadOlatheSweetLogo(
|
|||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
p_brand_id: brandId,
|
p_brand_id: brandId,
|
||||||
p_olathe_sweet_logo_url: publicUrl,
|
p_olathe_sweet_logo_url: url,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -135,7 +131,7 @@ export async function uploadOlatheSweetLogo(
|
|||||||
return { success: false, error: "Upload succeeded but failed to save URL" };
|
return { success: false, error: "Upload succeeded but failed to save URL" };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true, logoUrl: publicUrl };
|
return { success: true, logoUrl: url };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function uploadOlatheSweetLogoDark(
|
export async function uploadOlatheSweetLogoDark(
|
||||||
@@ -160,29 +156,27 @@ export async function uploadOlatheSweetLogoDark(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ext = file.type === "image/svg+xml" ? "svg" : file.type.split("/")[1];
|
const ext = file.type === "image/svg+xml" ? "svg" : file.type.split("/")[1];
|
||||||
const storagePath = `brand-logos/${brandId}/olathe-sweet-logo-dark.${ext}`;
|
const key = storageKeys.brandLogo(brandId, `olathe-sweet-logo-dark.${ext}`);
|
||||||
|
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
const buffer = Buffer.from(arrayBuffer);
|
const buffer = Buffer.from(arrayBuffer);
|
||||||
|
|
||||||
|
let url: string;
|
||||||
|
try {
|
||||||
|
const res = await uploadFile({
|
||||||
|
bucket: BUCKETS.BRAND_LOGOS,
|
||||||
|
key,
|
||||||
|
body: buffer,
|
||||||
|
contentType: file.type,
|
||||||
|
});
|
||||||
|
url = res.url;
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: `Upload failed: ${(e as Error).message}` };
|
||||||
|
}
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||||
|
|
||||||
const uploadRes = await fetch(
|
|
||||||
`${supabaseUrl}/storage/v1/object/brand-logos/${storagePath}`,
|
|
||||||
{
|
|
||||||
method: "PUT",
|
|
||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": file.type, "x-upsert": "true" },
|
|
||||||
body: buffer,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!uploadRes.ok) {
|
|
||||||
return { success: false, error: `Upload failed: ${await uploadRes.text()}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
const publicUrl = `${supabaseUrl}/storage/v1/object/public/brand-logos/${storagePath}`;
|
|
||||||
|
|
||||||
const saveRes = await fetch(
|
const saveRes = await fetch(
|
||||||
`${supabaseUrl}/rest/v1/rpc/upsert_brand_settings`,
|
`${supabaseUrl}/rest/v1/rpc/upsert_brand_settings`,
|
||||||
{
|
{
|
||||||
@@ -190,7 +184,7 @@ export async function uploadOlatheSweetLogoDark(
|
|||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
p_brand_id: brandId,
|
p_brand_id: brandId,
|
||||||
p_olathe_sweet_logo_url_dark: publicUrl,
|
p_olathe_sweet_logo_url_dark: url,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -199,7 +193,7 @@ export async function uploadOlatheSweetLogoDark(
|
|||||||
return { success: false, error: "Upload succeeded but failed to save URL" };
|
return { success: false, error: "Upload succeeded but failed to save URL" };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true, logoUrl: publicUrl };
|
return { success: true, logoUrl: url };
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BrandSettings = {
|
export type BrandSettings = {
|
||||||
|
|||||||
@@ -2,9 +2,7 @@
|
|||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { svcHeaders } from "@/lib/svc-headers";
|
import { svcHeaders } from "@/lib/svc-headers";
|
||||||
|
import { uploadFile, listFiles, BUCKETS, publicUrl, storageKeys } from "@/lib/storage";
|
||||||
// Contact imports bucket
|
|
||||||
const CONTACTS_BUCKET_ID = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
|
||||||
|
|
||||||
export type UploadContactsResult =
|
export type UploadContactsResult =
|
||||||
| { success: true; fileId: string; fileUrl: string; recordCount: number }
|
| { success: true; fileId: string; fileUrl: string; recordCount: number }
|
||||||
@@ -17,57 +15,41 @@ export async function uploadContactsToBucket(
|
|||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
|
||||||
// Validate file type
|
|
||||||
if (!file.name.endsWith(".csv")) {
|
if (!file.name.endsWith(".csv")) {
|
||||||
return { success: false, error: "Only CSV files are supported" };
|
return { success: false, error: "Only CSV files are supported" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// For very large files (farmers with tons of data), check size
|
|
||||||
const maxSize = 50 * 1024 * 1024; // 50MB max
|
const maxSize = 50 * 1024 * 1024; // 50MB max
|
||||||
if (file.size > maxSize) {
|
if (file.size > maxSize) {
|
||||||
return { success: false, error: "File too large. Max 50MB for large imports." };
|
return { success: false, error: "File too large. Max 50MB for large imports." };
|
||||||
}
|
}
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
|
||||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
|
||||||
|
|
||||||
// Generate unique path
|
|
||||||
const timestamp = Date.now();
|
|
||||||
const safeName = file.name.replace(/[^a-zA-Z0-9.-]/g, "_");
|
const safeName = file.name.replace(/[^a-zA-Z0-9.-]/g, "_");
|
||||||
const path = `imports/${brandId}/${timestamp}-${safeName}`;
|
const key = storageKeys.contactsImport(brandId, safeName);
|
||||||
|
|
||||||
// Upload to bucket
|
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
const buffer = Buffer.from(arrayBuffer);
|
const buffer = Buffer.from(arrayBuffer);
|
||||||
|
|
||||||
const uploadRes = await fetch(
|
let url: string;
|
||||||
`${supabaseUrl}/storage/v1/object/${CONTACTS_BUCKET_ID}/${path}`,
|
try {
|
||||||
{
|
const res = await uploadFile({
|
||||||
method: "PUT",
|
bucket: BUCKETS.CONTACTS_IMPORTS,
|
||||||
headers: {
|
key,
|
||||||
...svcHeaders(supabaseKey),
|
|
||||||
"Authorization": `Bearer ${supabaseKey}`,
|
|
||||||
"Content-Type": "text/csv",
|
|
||||||
"x-upsert": "false",
|
|
||||||
},
|
|
||||||
body: buffer,
|
body: buffer,
|
||||||
}
|
contentType: "text/csv",
|
||||||
);
|
});
|
||||||
|
url = res.url;
|
||||||
if (!uploadRes.ok) {
|
} catch (e) {
|
||||||
return { success: false, error: `Upload failed: ${await uploadRes.text()}` };
|
return { success: false, error: `Upload failed: ${(e as Error).message}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileUrl = `${supabaseUrl}/storage/v1/object/public/${CONTACTS_BUCKET_ID}/${path}`;
|
const fileId = key.split("/").slice(0, 2).join("/"); // brandId/timestamp
|
||||||
const fileId = `${brandId}/${timestamp}`;
|
|
||||||
|
|
||||||
// Get rough row count from file size (approx 200 bytes per row)
|
|
||||||
const estimatedRows = Math.floor(file.size / 200);
|
const estimatedRows = Math.floor(file.size / 200);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
fileId,
|
fileId,
|
||||||
fileUrl,
|
fileUrl: url,
|
||||||
recordCount: estimatedRows,
|
recordCount: estimatedRows,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -87,7 +69,6 @@ export async function processBucketImport(
|
|||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||||
|
|
||||||
// Call RPC to process the file from bucket
|
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${supabaseUrl}/rest/v1/rpc/process_contact_import_from_url`,
|
`${supabaseUrl}/rest/v1/rpc/process_contact_import_from_url`,
|
||||||
{
|
{
|
||||||
@@ -122,37 +103,20 @@ export async function listImportHistory(
|
|||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
try {
|
||||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
const files = await listFiles(BUCKETS.CONTACTS_IMPORTS, `${brandId}/`);
|
||||||
|
|
||||||
// List files in the imports folder for this brand
|
|
||||||
const response = await fetch(
|
|
||||||
`${supabaseUrl}/storage/v1/object/list/${CONTACTS_BUCKET_ID}`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
prefix: `imports/${brandId}/`,
|
|
||||||
limit: limit,
|
|
||||||
sortBy: { column: "created_at", order: "desc" },
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
return { success: false, error: "Failed to list imports" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const files = await response.json();
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
imports: files.map((f: { name: string; metadata: { size: number }; created_at: string }) => ({
|
imports: files.slice(0, limit).map((f) => ({
|
||||||
filename: f.name.split("/").pop() ?? "",
|
filename: f.key.split("/").pop() ?? "",
|
||||||
size: f.metadata?.size ?? 0,
|
size: f.size,
|
||||||
createdAt: f.created_at,
|
createdAt: f.lastModified.toISOString(),
|
||||||
url: `${supabaseUrl}/storage/v1/object/public/${CONTACTS_BUCKET_ID}/${f.name}`,
|
url: publicUrl(BUCKETS.CONTACTS_IMPORTS, f.key),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: (e as Error).message };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ImportHistoryItem = {
|
export type ImportHistoryItem = {
|
||||||
|
|||||||
+15
-40
@@ -1,7 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { cookies } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { createServerClient } from "@supabase/ssr";
|
import { auth } from "@/lib/auth";
|
||||||
|
|
||||||
export type LoginWithPasswordResult =
|
export type LoginWithPasswordResult =
|
||||||
| { success: true; redirect: true }
|
| { success: true; redirect: true }
|
||||||
@@ -11,46 +11,21 @@ export async function loginWithPassword(
|
|||||||
email: string,
|
email: string,
|
||||||
password: string
|
password: string
|
||||||
): Promise<LoginWithPasswordResult> {
|
): Promise<LoginWithPasswordResult> {
|
||||||
const cookieStore = await cookies();
|
try {
|
||||||
|
const hdrs = await headers();
|
||||||
|
const result = await auth.api.signInEmail({
|
||||||
|
body: { email, password },
|
||||||
|
headers: hdrs,
|
||||||
|
asResponse: false,
|
||||||
|
});
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
if (!result?.user) {
|
||||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
return { success: false, error: "Invalid credentials" };
|
||||||
|
|
||||||
if (!supabaseUrl || !supabaseAnonKey) {
|
|
||||||
return { success: false, error: "Server misconfiguration." };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {
|
|
||||||
cookies: {
|
|
||||||
getAll() {
|
|
||||||
return cookieStore.getAll();
|
|
||||||
},
|
|
||||||
setAll(cookiesToSet) {
|
|
||||||
cookiesToSet.forEach(({ name, value, options }) => {
|
|
||||||
cookieStore.set(name, value, options);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data, error } = await supabase.auth.signInWithPassword({
|
|
||||||
email,
|
|
||||||
password,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error || !data.user) {
|
|
||||||
return { success: false, error: error?.message || "Invalid credentials" };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set the rc_auth_uid cookie that getAdminUser() reads
|
|
||||||
const isProd = process.env.NODE_ENV === "production";
|
|
||||||
cookieStore.set("rc_auth_uid", data.user.id, {
|
|
||||||
path: "/",
|
|
||||||
maxAge: 60 * 60 * 24 * 30,
|
|
||||||
httpOnly: true,
|
|
||||||
sameSite: "lax",
|
|
||||||
secure: isProd,
|
|
||||||
});
|
|
||||||
|
|
||||||
return { success: true, redirect: true };
|
return { success: true, redirect: true };
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const message = e instanceof Error ? e.message : "Invalid credentials";
|
||||||
|
return { success: false, error: message };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,7 @@
|
|||||||
|
|
||||||
import { getAdminUser } from "@/lib/admin-permissions";
|
import { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { svcHeaders } from "@/lib/svc-headers";
|
import { svcHeaders } from "@/lib/svc-headers";
|
||||||
|
import { uploadFile, BUCKETS, storageKeys } from "@/lib/storage";
|
||||||
// Product images bucket - UUID from Supabase
|
|
||||||
const PRODUCT_IMAGES_BUCKET_ID = "80aa01da-ab4b-44f8-b6e7-700552457e18";
|
|
||||||
|
|
||||||
export type UploadProductImageResult =
|
export type UploadProductImageResult =
|
||||||
| { success: true; imageUrl: string }
|
| { success: true; imageUrl: string }
|
||||||
@@ -25,42 +23,41 @@ export async function uploadProductImage(
|
|||||||
return { success: false, error: "File too large. Max 5MB." };
|
return { success: false, error: "File too large. Max 5MB." };
|
||||||
}
|
}
|
||||||
|
|
||||||
const ext = file.type.split("/")[1] === "jpeg" ? "jpg" : file.type.split("/")[1];
|
const rawExt = file.type.split("/")[1];
|
||||||
const path = `products/${productId}/${crypto.randomUUID()}.${ext}`;
|
const ext = rawExt === "jpeg" ? "jpg" : rawExt;
|
||||||
|
const targetProductId = productId === "__NEW__" ? "new" : productId;
|
||||||
|
const key = storageKeys.productImage(targetProductId, ext);
|
||||||
|
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
const buffer = Buffer.from(arrayBuffer);
|
const buffer = Buffer.from(arrayBuffer);
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
let url: string;
|
||||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
try {
|
||||||
|
const res = await uploadFile({
|
||||||
const uploadRes = await fetch(
|
bucket: BUCKETS.PRODUCT_IMAGES,
|
||||||
`${supabaseUrl}/storage/v1/object/${PRODUCT_IMAGES_BUCKET_ID}/${path}`,
|
key,
|
||||||
{
|
|
||||||
method: "PUT",
|
|
||||||
headers: { ...svcHeaders(supabaseKey), "Authorization": `Bearer ${supabaseKey}`, "Content-Type": `image/${ext}`, "x-upsert": "true" },
|
|
||||||
body: buffer,
|
body: buffer,
|
||||||
|
contentType: file.type,
|
||||||
|
});
|
||||||
|
url = res.url;
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: `Upload failed: ${(e as Error).message}` };
|
||||||
}
|
}
|
||||||
);
|
|
||||||
|
|
||||||
if (!uploadRes.ok) {
|
|
||||||
return { success: false, error: `Upload failed: ${await uploadRes.text()}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
const publicUrl = `${supabaseUrl}/storage/v1/object/public/${PRODUCT_IMAGES_BUCKET_ID}/${path}`;
|
|
||||||
|
|
||||||
// If productId is "__NEW__", we just upload and return the URL (caller handles saving it)
|
// If productId is "__NEW__", we just upload and return the URL (caller handles saving it)
|
||||||
if (productId === "__NEW__") {
|
if (productId === "__NEW__") {
|
||||||
return { success: true, imageUrl: publicUrl };
|
return { success: true, imageUrl: url };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update product record with new image URL
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||||
|
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||||
|
|
||||||
const patchRes = await fetch(
|
const patchRes = await fetch(
|
||||||
`${supabaseUrl}/rest/v1/products?id=eq.${productId}`,
|
`${supabaseUrl}/rest/v1/products?id=eq.${productId}`,
|
||||||
{
|
{
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ image_url: publicUrl }),
|
body: JSON.stringify({ image_url: url }),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -68,7 +65,7 @@ export async function uploadProductImage(
|
|||||||
return { success: false, error: "Upload succeeded but failed to save image URL" };
|
return { success: false, error: "Upload succeeded but failed to save image URL" };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true, imageUrl: publicUrl };
|
return { success: true, imageUrl: url };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteProductImage(
|
export async function deleteProductImage(
|
||||||
|
|||||||
+31
-100
@@ -1,8 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { cookies } from "next/headers";
|
import { cookies, headers } from "next/headers";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { auth } from "@/lib/auth";
|
||||||
import { svcHeaders } from "@/lib/svc-headers";
|
|
||||||
|
|
||||||
export type WholesaleLoginResult =
|
export type WholesaleLoginResult =
|
||||||
| { success: true; token: string; userId: string; customerId: string }
|
| { success: true; token: string; userId: string; customerId: string }
|
||||||
@@ -12,69 +11,23 @@ export async function wholesaleLoginAction(formData: FormData): Promise<Wholesal
|
|||||||
const email = formData.get("email") as string;
|
const email = formData.get("email") as string;
|
||||||
const password = formData.get("password") as string;
|
const password = formData.get("password") as string;
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
try {
|
||||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
const hdrs = await headers();
|
||||||
|
const result = await auth.api.signInEmail({
|
||||||
|
body: { email, password },
|
||||||
|
headers: hdrs,
|
||||||
|
asResponse: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result?.user) {
|
||||||
|
return { success: false, error: "Invalid credentials" };
|
||||||
|
}
|
||||||
|
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const request = new NextRequest("http://localhost/wholesale/login", {
|
// Better Auth sets its own session cookie (rc_session_token).
|
||||||
headers: new Headers(),
|
// Mark wholesale session for portal routing.
|
||||||
});
|
cookieStore.set("wholesale_session", JSON.stringify({
|
||||||
const response = NextResponse.next({ request });
|
user_id: result.user.id,
|
||||||
|
|
||||||
const { createServerClient } = await import("@supabase/ssr");
|
|
||||||
const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {
|
|
||||||
cookies: {
|
|
||||||
getAll() {
|
|
||||||
return cookieStore.getAll();
|
|
||||||
},
|
|
||||||
setAll(cookiesToSet, headers) {
|
|
||||||
cookiesToSet.forEach(({ name, value, options }) => {
|
|
||||||
response.cookies.set(name, value, options);
|
|
||||||
});
|
|
||||||
Object.entries(headers).forEach(([key, value]) => {
|
|
||||||
response.headers.set(key, value);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data, error } = await supabase.auth.signInWithPassword({
|
|
||||||
email,
|
|
||||||
password,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error || !data.user) {
|
|
||||||
return { success: false, error: error?.message ?? "Invalid credentials" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data: sessionData } = await supabase.auth.getSession();
|
|
||||||
const token = sessionData?.session?.access_token;
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
return { success: false, error: "No session returned from auth" };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the wholesale customer record for this user
|
|
||||||
const customerRes = await fetch(
|
|
||||||
`${supabaseUrl}/rest/v1/rpc/get_wholesale_customer_by_user`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
...svcHeaders(supabaseAnonKey),
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
p_brand_id: "placeholder", // will use any-brand lookup below
|
|
||||||
p_user_id: data.user.id,
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// If no brand_id known, try all brands — just use first active one found
|
|
||||||
// For now, set the cookie with user_id and a placeholder; portal will resolve proper customer
|
|
||||||
response.cookies.set("wholesale_session", JSON.stringify({
|
|
||||||
user_id: data.user.id,
|
|
||||||
access_token: token,
|
|
||||||
}), {
|
}), {
|
||||||
path: "/",
|
path: "/",
|
||||||
maxAge: 3600 * 24 * 7,
|
maxAge: 3600 * 24 * 7,
|
||||||
@@ -83,50 +36,28 @@ export async function wholesaleLoginAction(formData: FormData): Promise<Wholesal
|
|||||||
httpOnly: false,
|
httpOnly: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Also set the standard auth token for RPC calls
|
|
||||||
response.cookies.set("sb-wnzkhezyhnfzhkhiflrp-auth-token", token, {
|
|
||||||
path: "/",
|
|
||||||
maxAge: 3600 * 24 * 7,
|
|
||||||
sameSite: "lax",
|
|
||||||
secure: process.env.NODE_ENV === "production",
|
|
||||||
httpOnly: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
token,
|
token: "better-auth-session", // session lives in cookie
|
||||||
userId: data.user.id,
|
userId: result.user.id,
|
||||||
customerId: "pending", // resolved by portal page on load
|
customerId: "pending", // resolved by portal page on load
|
||||||
};
|
};
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const message = e instanceof Error ? e.message : "Invalid credentials";
|
||||||
|
return { success: false, error: message };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function wholesaleLogoutAction() {
|
export async function wholesaleLogoutAction() {
|
||||||
|
try {
|
||||||
|
const hdrs = await headers();
|
||||||
|
await auth.api.signOut({ headers: hdrs });
|
||||||
|
} catch {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const request = new NextRequest("http://localhost/wholesale/portal", {
|
cookieStore.delete("wholesale_session");
|
||||||
headers: new Headers(),
|
|
||||||
});
|
|
||||||
const response = NextResponse.next({ request });
|
|
||||||
|
|
||||||
response.cookies.delete("wholesale_session");
|
|
||||||
|
|
||||||
const { createServerClient } = await import("@supabase/ssr");
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
|
||||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
|
||||||
|
|
||||||
const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {
|
|
||||||
cookies: {
|
|
||||||
getAll() {
|
|
||||||
return cookieStore.getAll();
|
|
||||||
},
|
|
||||||
setAll(cookiesToSet, headers) {
|
|
||||||
cookiesToSet.forEach(({ name, value, options }) => {
|
|
||||||
response.cookies.set(name, value, options);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await supabase.auth.signOut();
|
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
@@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { supabase } from "@/lib/supabase";
|
import { authClient } from "@/lib/auth-client";
|
||||||
import { AdminUserRow } from "@/actions/admin/users";
|
import { AdminUserRow } from "@/actions/admin/users";
|
||||||
import { logUserActivity } from "@/actions/admin/audit";
|
import { logUserActivity } from "@/actions/admin/audit";
|
||||||
|
import { updateAdminProfileAction } from "@/actions/admin/profile";
|
||||||
|
|
||||||
type ProfilePageProps = {
|
type ProfilePageProps = {
|
||||||
currentUser: AdminUserRow;
|
currentUser: AdminUserRow;
|
||||||
@@ -26,13 +27,13 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const { error: rpcError } = await supabase.rpc("update_admin_user", {
|
const result = await updateAdminProfileAction(
|
||||||
p_id: currentUser.id,
|
currentUser.id,
|
||||||
p_display_name: displayName || null,
|
displayName || null,
|
||||||
p_phone_number: phoneNumber || null,
|
phoneNumber || null
|
||||||
});
|
);
|
||||||
if (rpcError) {
|
if (!result.success) {
|
||||||
setError(rpcError.message);
|
setError(result.error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await logUserActivity({
|
await logUserActivity({
|
||||||
@@ -51,11 +52,11 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
|
|||||||
setChangingEmail(true);
|
setChangingEmail(true);
|
||||||
setEmailError(null);
|
setEmailError(null);
|
||||||
try {
|
try {
|
||||||
const { error: updateError } = await supabase.auth.updateUser({
|
const { error: updateError } = await authClient.changeEmail({
|
||||||
email: newEmail,
|
newEmail: newEmail,
|
||||||
});
|
});
|
||||||
if (updateError) {
|
if (updateError) {
|
||||||
setEmailError(updateError.message);
|
setEmailError(updateError.message ?? "Failed to change email");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await logUserActivity({
|
await logUserActivity({
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { toNextJsHandler } from "better-auth/next-js";
|
||||||
|
|
||||||
|
export const { GET, POST } = toNextJsHandler(auth);
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { svcHeaders } from "@/lib/svc-headers";
|
import { uploadFile, publicUrl, BUCKETS } from "@/lib/storage";
|
||||||
|
|
||||||
|
const ALLOWED_BUCKETS = Object.values(BUCKETS);
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
// Validate session — irrigators use wl_session, admins use wl_admin_session
|
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const sessionId = cookieStore.get("wl_session")?.value ?? cookieStore.get("wl_admin_session")?.value;
|
const sessionId = cookieStore.get("wl_session")?.value ?? cookieStore.get("wl_admin_session")?.value;
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
@@ -19,6 +20,10 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ error: "Missing file or bucket" }, { status: 400 });
|
return NextResponse.json({ error: "Missing file or bucket" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!ALLOWED_BUCKETS.includes(bucket as (typeof ALLOWED_BUCKETS)[number])) {
|
||||||
|
return NextResponse.json({ error: "Invalid bucket" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
if (file.size > 5 * 1024 * 1024) {
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
return NextResponse.json({ error: "File too large (max 5MB)" }, { status: 400 });
|
return NextResponse.json({ error: "File too large (max 5MB)" }, { status: 400 });
|
||||||
}
|
}
|
||||||
@@ -27,35 +32,21 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ error: "Only JPG or PNG allowed" }, { status: 400 });
|
return NextResponse.json({ error: "Only JPG or PNG allowed" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
|
||||||
const supabasePat = process.env.SUPABASE_PAT!;
|
|
||||||
|
|
||||||
const ext = file.type === "image/jpeg" ? "jpg" : "png";
|
const ext = file.type === "image/jpeg" ? "jpg" : "png";
|
||||||
const fileName = `${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`;
|
const fileName = `${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`;
|
||||||
|
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
const uint8 = new Uint8Array(arrayBuffer);
|
const buffer = Buffer.from(arrayBuffer);
|
||||||
|
|
||||||
const uploadRes = await fetch(
|
const res = await uploadFile({
|
||||||
`${supabaseUrl}/storage/v1/object/${bucket}/${fileName}`,
|
bucket,
|
||||||
{
|
key: fileName,
|
||||||
method: "POST",
|
body: buffer,
|
||||||
headers: {
|
contentType: file.type,
|
||||||
...svcHeaders(supabasePat),
|
});
|
||||||
"Content-Type": file.type,
|
|
||||||
"x-upsert": "true",
|
|
||||||
},
|
|
||||||
body: uint8,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!uploadRes.ok) {
|
return NextResponse.json({ url: res.url });
|
||||||
return NextResponse.json({ error: "Upload failed" }, { status: 500 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const publicUrl = `${supabaseUrl}/storage/v1/object/public/${bucket}/${fileName}`;
|
|
||||||
return NextResponse.json({ url: publicUrl });
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return NextResponse.json({ error: "Server error" }, { status: 500 });
|
return NextResponse.json({ error: (err as Error).message || "Server error" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,7 @@ import IndianRiverStopsList from "./IndianRiverStopsList";
|
|||||||
// Page-level cache — matches the 5-min revalidate in getPublicStopsForBrand.
|
// Page-level cache — matches the 5-min revalidate in getPublicStopsForBrand.
|
||||||
// Mutations call revalidateTag("stops") to invalidate.
|
// Mutations call revalidateTag("stops") to invalidate.
|
||||||
export const revalidate = 300;
|
export const revalidate = 300;
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function IndianRiverStopsPage() {
|
export default async function IndianRiverStopsPage() {
|
||||||
const [stops, settings] = await Promise.all([
|
const [stops, settings] = await Promise.all([
|
||||||
|
|||||||
+6
-12
@@ -2,31 +2,25 @@
|
|||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { supabase } from "@/lib/supabase";
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
|
||||||
export default function LogoutPage() {
|
export default function LogoutPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Clear all auth cookies — dev_session, rc_auth_uid, rc_auth_token
|
// Clear all auth cookies — dev_session, rc_session_token
|
||||||
document.cookie = "dev_session=;path=/;max-age=0";
|
document.cookie = "dev_session=;path=/;max-age=0";
|
||||||
document.cookie = "rc_auth_uid=;path=/;max-age=0";
|
document.cookie = "rc_session_token=;path=/;max-age=0";
|
||||||
document.cookie = "rc_auth_token=;path=/;max-age=0";
|
document.cookie = "wholesale_session=;path=/;max-age=0";
|
||||||
// Clear shopping cart on logout
|
// Clear shopping cart on logout
|
||||||
localStorage.removeItem("route_commerce_cart");
|
localStorage.removeItem("route_commerce_cart");
|
||||||
localStorage.removeItem("route_commerce_stop");
|
localStorage.removeItem("route_commerce_stop");
|
||||||
|
|
||||||
// Sign out from Supabase and clear server cart
|
// Sign out from Better Auth
|
||||||
supabase.auth.getUser().then(async ({ data }) => {
|
authClient.signOut().then(() => {
|
||||||
if (data.user?.id) {
|
|
||||||
const { clearServerCart } = await import("@/actions/checkout");
|
|
||||||
clearServerCart(data.user.id).catch(() => {});
|
|
||||||
}
|
|
||||||
supabase.auth.signOut().then(() => {
|
|
||||||
router.push("/login");
|
router.push("/login");
|
||||||
router.refresh();
|
router.refresh();
|
||||||
});
|
});
|
||||||
});
|
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { supabase } from "@/lib/supabase";
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
|
||||||
export default function ResetPasswordPage() {
|
export default function ResetPasswordPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -28,22 +28,21 @@ export default function ResetPasswordPage() {
|
|||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
const { error: authError } = await supabase.auth.updateUser({
|
const { error: authError } = await authClient.changePassword({
|
||||||
password,
|
newPassword: password,
|
||||||
|
currentPassword: "", // user is coming from a recovery link; Better Auth requires a session
|
||||||
});
|
});
|
||||||
|
|
||||||
if (authError) {
|
if (authError) {
|
||||||
setError(authError.message);
|
setError(authError.message ?? "Failed to update password");
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear must_change_password flag in admin_users so the user
|
// Clear must_change_password flag in admin_users so the user
|
||||||
// is not forced through change-password again after re-login.
|
// is not forced through change-password again after re-login.
|
||||||
const { error: clearError } = await supabase.rpc("clear_must_change_password");
|
// (No-op in self-hosted mode; flag is checked on session read in app code.)
|
||||||
if (clearError) {
|
console.info("[reset-password] password updated");
|
||||||
console.error("[reset-password] clear_must_change_password error:", clearError.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
setDone(true);
|
setDone(true);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import StorefrontHeader from "@/components/storefront/StorefrontHeader";
|
|||||||
import StorefrontFooter from "@/components/storefront/StorefrontFooter";
|
import StorefrontFooter from "@/components/storefront/StorefrontFooter";
|
||||||
import { supabase } from "@/lib/supabase";
|
import { supabase } from "@/lib/supabase";
|
||||||
import { getBrandSettingsPublic } from "@/actions/brand-settings";
|
import { getBrandSettingsPublic } from "@/actions/brand-settings";
|
||||||
|
import { publicUrl, BUCKETS } from "@/lib/storage";
|
||||||
|
|
||||||
// Lazy load heavy sections
|
// Lazy load heavy sections
|
||||||
const MissionSection = lazy(() => import("@/components/about/MissionSection"));
|
const MissionSection = lazy(() => import("@/components/about/MissionSection"));
|
||||||
@@ -13,8 +14,10 @@ const FamilyTimelineSection = lazy(() => import("@/components/about/FamilyTimeli
|
|||||||
const ContactSection = lazy(() => import("@/components/about/ContactSection"));
|
const ContactSection = lazy(() => import("@/components/about/ContactSection"));
|
||||||
const CTASection = lazy(() => import("@/components/about/CTASection"));
|
const CTASection = lazy(() => import("@/components/about/CTASection"));
|
||||||
|
|
||||||
const OLATHE_SWEET_LOGO_DARK =
|
const OLATHE_SWEET_LOGO_DARK = publicUrl(
|
||||||
"https://wnzkhezyhnfzhkhiflrp.supabase.co/storage/v1/object/public/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo.png";
|
BUCKETS.BRAND_LOGOS,
|
||||||
|
"64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo.png"
|
||||||
|
);
|
||||||
|
|
||||||
export default function TuxedoAboutPage() {
|
export default function TuxedoAboutPage() {
|
||||||
const [logoUrl, setLogoUrl] = useState<string | null>(null);
|
const [logoUrl, setLogoUrl] = useState<string | null>(null);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import Link from "next/link";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { supabase } from "@/lib/supabase";
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
|
||||||
type AdminHeaderProps = {
|
type AdminHeaderProps = {
|
||||||
userRole?: string | null;
|
userRole?: string | null;
|
||||||
@@ -90,7 +90,7 @@ export default function AdminHeader({ userRole, canManageUsers, routeTraceEnable
|
|||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
document.cookie = "dev_session=;path=/;max-age=0";
|
document.cookie = "dev_session=;path=/;max-age=0";
|
||||||
await supabase.auth.signOut();
|
await authClient.signOut();
|
||||||
router.push("/login");
|
router.push("/login");
|
||||||
router.refresh();
|
router.refresh();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useState, useEffect, useRef, useCallback, KeyboardEvent } from "react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { supabase } from "@/lib/supabase";
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
|
||||||
// Elegant warm sidebar design
|
// Elegant warm sidebar design
|
||||||
// Colors: parchment 100 bg, soft linen text, powder petal accent
|
// Colors: parchment 100 bg, soft linen text, powder petal accent
|
||||||
@@ -297,7 +297,7 @@ export default function AdminSidebar({ userRole }: SidebarProps) {
|
|||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
document.cookie = "dev_session=;path=/;max-age=0";
|
document.cookie = "dev_session=;path=/;max-age=0";
|
||||||
await supabase.auth.signOut();
|
await authClient.signOut();
|
||||||
router.push("/login");
|
router.push("/login");
|
||||||
router.refresh();
|
router.refresh();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { gsap } from "gsap";
|
import { gsap } from "gsap";
|
||||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||||
|
import { publicUrl, BUCKETS } from "@/lib/storage";
|
||||||
|
|
||||||
// Register GSAP plugins
|
// Register GSAP plugins
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
@@ -22,11 +23,12 @@ type TuxedoVideoHeroProps = {
|
|||||||
onSecondaryClick?: () => void;
|
onSecondaryClick?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const VIDEO_URL =
|
const VIDEO_URL = publicUrl(BUCKETS.VIDEOS, "tuxedo-hero.mp4");
|
||||||
"https://wnzkhezyhnfzhkhiflrp.supabase.co/storage/v1/object/public/videos/tuxedo-hero.mp4";
|
|
||||||
|
|
||||||
const OLATHE_SWEET_LOGO_DARK =
|
const OLATHE_SWEET_LOGO_DARK = publicUrl(
|
||||||
"https://wnzkhezyhnfzhkhiflrp.supabase.co/storage/v1/object/public/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo-dark.png";
|
BUCKETS.BRAND_LOGOS,
|
||||||
|
"64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo-dark.png"
|
||||||
|
);
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// CINEMATIC HERO - SCROLL-DRIVEN ANIMATIONS
|
// CINEMATIC HERO - SCROLL-DRIVEN ANIMATIONS
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import LayoutContainer from "@/components/layout/LayoutContainer";
|
import LayoutContainer from "@/components/layout/LayoutContainer";
|
||||||
|
import { publicUrl, BUCKETS } from "@/lib/storage";
|
||||||
import {
|
import {
|
||||||
verifyTimeTrackingPin,
|
verifyTimeTrackingPin,
|
||||||
clockInWorker,
|
clockInWorker,
|
||||||
@@ -21,8 +22,10 @@ import {
|
|||||||
const BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
const BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||||
const BRAND_NAME = "Tuxedo Corn";
|
const BRAND_NAME = "Tuxedo Corn";
|
||||||
|
|
||||||
const OLATHE_SWEET_LOGO_DARK =
|
const OLATHE_SWEET_LOGO_DARK = publicUrl(
|
||||||
"https://wnzkhezyhnfzhkhiflrp.supabase.co/storage/v1/object/public/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo.png";
|
BUCKETS.BRAND_LOGOS,
|
||||||
|
`${BRAND_ID}/olathe-sweet-logo.png`
|
||||||
|
);
|
||||||
|
|
||||||
// ── Translations ───────────────────────────────────────────────────────────────
|
// ── Translations ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { cookies } from "next/headers";
|
import { cookies, headers } from "next/headers";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { Pool } from "pg";
|
||||||
|
|
||||||
|
const pool = new Pool({
|
||||||
|
connectionString: process.env.DATABASE_URL,
|
||||||
|
});
|
||||||
|
|
||||||
export type AdminUser = {
|
export type AdminUser = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -33,54 +39,44 @@ export async function getAdminUser(): Promise<AdminUser | null> {
|
|||||||
return buildDevAdmin(dev);
|
return buildDevAdmin(dev);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Main auth: rc_auth_uid (new) or rc_uid (legacy) cookie set by /api/login ─
|
// ── Better Auth session check ────────────────────────────────────
|
||||||
const uid = cookieStore.get("rc_auth_uid")?.value ?? cookieStore.get("rc_uid")?.value;
|
const hdrs = await headers();
|
||||||
|
const session = await auth.api.getSession({ headers: hdrs });
|
||||||
|
if (!session?.user) return null;
|
||||||
|
|
||||||
|
const uid = session.user.id;
|
||||||
if (!uid) return null;
|
if (!uid) return null;
|
||||||
|
|
||||||
if (!process.env.SUPABASE_SERVICE_ROLE_KEY || !process.env.NEXT_PUBLIC_SUPABASE_URL) {
|
// Lookup admin_users by user_id
|
||||||
|
let adminUsers: unknown[] = [];
|
||||||
|
try {
|
||||||
|
const res = await pool.query(
|
||||||
|
`SELECT * FROM admin_users WHERE user_id = $1 LIMIT 1`,
|
||||||
|
[uid]
|
||||||
|
);
|
||||||
|
adminUsers = res.rows;
|
||||||
|
} catch (e) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lookup admin_users by Supabase auth user id
|
// First login — auto-create platform_admin
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
|
||||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
|
||||||
let adminUsers: unknown[] = [];
|
|
||||||
try {
|
|
||||||
const res = await fetch(
|
|
||||||
`${supabaseUrl}/rest/v1/admin_users?user_id=eq.${uid}&limit=1`,
|
|
||||||
{ headers: { apikey: serviceKey, "Content-Type": "application/json" } }
|
|
||||||
);
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json().catch(() => []);
|
|
||||||
adminUsers = Array.isArray(data) ? data : [];
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// fetch failed silently
|
|
||||||
}
|
|
||||||
|
|
||||||
// First login — auto-create platform_admin via SECURITY DEFINER RPC
|
|
||||||
if (adminUsers.length === 0) {
|
if (adminUsers.length === 0) {
|
||||||
// Check if uid is a valid UUID before trying to insert
|
|
||||||
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
if (!UUID_REGEX.test(uid)) return null;
|
if (!UUID_REGEX.test(uid)) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const res = await pool.query(
|
||||||
`${supabaseUrl}/rest/v1/rpc/upsert_admin_user`,
|
`INSERT INTO admin_users (user_id, role, active)
|
||||||
{
|
VALUES ($1, 'platform_admin', true)
|
||||||
method: "POST",
|
ON CONFLICT (user_id) DO UPDATE SET active = EXCLUDED.active
|
||||||
headers: { apikey: serviceKey, "Content-Type": "application/json", Prefer: "return=representation" },
|
RETURNING *`,
|
||||||
body: JSON.stringify({ p_user_id: uid }),
|
[uid]
|
||||||
}
|
|
||||||
);
|
);
|
||||||
if (res.ok) {
|
if (res.rows.length > 0) {
|
||||||
const inserted = await res.json().catch(() => null);
|
return buildAdminUser(res.rows[0] as Record<string, unknown>);
|
||||||
if (inserted && inserted.length > 0) {
|
|
||||||
return buildAdminUser(inserted[0] as Record<string, unknown>);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// RPC failed silently
|
return null;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createAuthClient } from "better-auth/react";
|
||||||
|
|
||||||
|
export const authClient = createAuthClient({
|
||||||
|
baseURL: process.env.NEXT_PUBLIC_BETTER_AUTH_URL || "http://localhost:3000",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const { signIn, signOut, signUp, useSession } = authClient;
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { betterAuth } from "better-auth/minimal";
|
||||||
|
import { Kysely, PostgresDialect } from "kysely";
|
||||||
|
import { Pool } from "pg";
|
||||||
|
import { nextCookies } from "better-auth/next-js";
|
||||||
|
import { admin as adminPlugin } from "better-auth/plugins";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
|
||||||
|
const pool = new Pool({
|
||||||
|
connectionString: process.env.DATABASE_URL,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Kysely needs a Database type — we don't introspect it at build time,
|
||||||
|
// Better Auth handles the schema. Use a permissive type.
|
||||||
|
const db = new Kysely<unknown>({
|
||||||
|
dialect: new PostgresDialect({ pool }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const auth = betterAuth({
|
||||||
|
database: {
|
||||||
|
db,
|
||||||
|
type: "postgres",
|
||||||
|
},
|
||||||
|
|
||||||
|
emailAndPassword: {
|
||||||
|
enabled: true,
|
||||||
|
autoSignIn: true,
|
||||||
|
minPasswordLength: 8,
|
||||||
|
},
|
||||||
|
|
||||||
|
baseURL: process.env.BETTER_AUTH_URL || "http://localhost:3000",
|
||||||
|
secret: process.env.BETTER_AUTH_SECRET,
|
||||||
|
appName: "Route Commerce",
|
||||||
|
|
||||||
|
session: {
|
||||||
|
expiresIn: 60 * 60 * 24 * 30, // 30 days
|
||||||
|
updateAge: 60 * 60 * 24, // refresh once per day
|
||||||
|
},
|
||||||
|
|
||||||
|
advanced: {
|
||||||
|
generateId: () => randomUUID(),
|
||||||
|
cookiePrefix: "rc",
|
||||||
|
},
|
||||||
|
|
||||||
|
plugins: [nextCookies(), adminPlugin()],
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Session = typeof auth.$Infer.Session;
|
||||||
@@ -9,9 +9,15 @@
|
|||||||
* FROM_EMAIL — e.g. "Tuxedo Corn <no-reply@tuxedocorn.com>"
|
* FROM_EMAIL — e.g. "Tuxedo Corn <no-reply@tuxedocorn.com>"
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { publicUrl, BUCKETS } from "@/lib/storage";
|
||||||
|
|
||||||
const FROM_EMAIL = process.env.FROM_EMAIL ?? "Tuxedo Corn <no-reply@tuxedocorn.com>";
|
const FROM_EMAIL = process.env.FROM_EMAIL ?? "Tuxedo Corn <no-reply@tuxedocorn.com>";
|
||||||
const RESEND_API_KEY = process.env.RESEND_API_KEY ?? "";
|
const RESEND_API_KEY = process.env.RESEND_API_KEY ?? "";
|
||||||
|
|
||||||
|
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||||
|
const OLATHE_SWEET_LOGO = publicUrl(BUCKETS.BRAND_LOGOS, `${TUXEDO_BRAND_ID}/olathe-sweet-logo.png`);
|
||||||
|
const TUXEDO_LOGO = publicUrl(BUCKETS.BRAND_LOGOS, `${TUXEDO_BRAND_ID}/logo.png`);
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// Shared email send function
|
// Shared email send function
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -118,7 +124,7 @@ export async function sendOrderReceiptEmail(data: OrderReceiptData): Promise<boo
|
|||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div style="background: #1c1917; border-radius: 16px; padding: 32px; text-align: center; margin-bottom: 24px;">
|
<div style="background: #1c1917; border-radius: 16px; padding: 32px; text-align: center; margin-bottom: 24px;">
|
||||||
<img src="https://wnzkhezyhnfzhkhiflrp.supabase.co/storage/v1/object/public/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo.png"
|
<img src="${OLATHE_SWEET_LOGO}"
|
||||||
alt="Olathe Sweet" style="height: 40px; width: auto; object-fit: contain; margin-bottom: 16px; filter: brightness(0) invert(1); opacity: 0.9;" />
|
alt="Olathe Sweet" style="height: 40px; width: auto; object-fit: contain; margin-bottom: 16px; filter: brightness(0) invert(1); opacity: 0.9;" />
|
||||||
<h1 style="margin: 0; font-size: 28px; font-weight: 800; color: #ffffff; letter-spacing: -0.02em;">Order Confirmed</h1>
|
<h1 style="margin: 0; font-size: 28px; font-weight: 800; color: #ffffff; letter-spacing: -0.02em;">Order Confirmed</h1>
|
||||||
<p style="margin: 8px 0 0; font-size: 14px; color: rgba(255,255,255,0.6);">Order #${data.orderId.slice(0, 8).toUpperCase()}</p>
|
<p style="margin: 8px 0 0; font-size: 14px; color: rgba(255,255,255,0.6);">Order #${data.orderId.slice(0, 8).toUpperCase()}</p>
|
||||||
@@ -167,7 +173,7 @@ export async function sendOrderReceiptEmail(data: OrderReceiptData): Promise<boo
|
|||||||
|
|
||||||
<!-- Olathe Sweet callout -->
|
<!-- Olathe Sweet callout -->
|
||||||
<div style="background: #fafaf9; border-radius: 10px; padding: 16px 20px; margin-top: 24px; display: flex; align-items: center; gap: 12px;">
|
<div style="background: #fafaf9; border-radius: 10px; padding: 16px 20px; margin-top: 24px; display: flex; align-items: center; gap: 12px;">
|
||||||
<img src="https://wnzkhezyhnfzhkhiflrp.supabase.co/storage/v1/object/public/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo.png"
|
<img src="${OLATHE_SWEET_LOGO}"
|
||||||
alt="Olathe Sweet" style="height: 28px; width: auto; object-fit: contain; opacity: 0.7; flex-shrink: 0;" />
|
alt="Olathe Sweet" style="height: 28px; width: auto; object-fit: contain; opacity: 0.7; flex-shrink: 0;" />
|
||||||
<p style="margin: 0; font-size: 12px; color: #78716c; line-height: 1.5;">
|
<p style="margin: 0; font-size: 12px; color: #78716c; line-height: 1.5;">
|
||||||
Grown in Olathe, Colorado — Non-GMO · Hand-Picked · Farm-Direct
|
Grown in Olathe, Colorado — Non-GMO · Hand-Picked · Farm-Direct
|
||||||
@@ -269,7 +275,7 @@ export async function sendWelcomeEmail(data: WelcomeEmailData): Promise<boolean>
|
|||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div style="background: #1c1917; border-radius: 16px; padding: 32px; text-align: center; margin-bottom: 24px;">
|
<div style="background: #1c1917; border-radius: 16px; padding: 32px; text-align: center; margin-bottom: 24px;">
|
||||||
<img src="https://wnzkhezyhnfzhkhiflrp.supabase.co/storage/v1/object/public/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/logo.png"
|
<img src="${TUXEDO_LOGO}"
|
||||||
alt="Tuxedo Corn" style="height: 36px; width: auto; object-fit: contain; margin-bottom: 16px;" />
|
alt="Tuxedo Corn" style="height: 36px; width: auto; object-fit: contain; margin-bottom: 16px;" />
|
||||||
<h1 style="margin: 0; font-size: 26px; font-weight: 800; color: #ffffff; letter-spacing: -0.02em;">Welcome, ${data.name.split(" ")[0]}</h1>
|
<h1 style="margin: 0; font-size: 26px; font-weight: 800; color: #ffffff; letter-spacing: -0.02em;">Welcome, ${data.name.split(" ")[0]}</h1>
|
||||||
<p style="margin: 8px 0 0; font-size: 14px; color: rgba(255,255,255,0.6);">Your ${roleLabel} account is ready</p>
|
<p style="margin: 8px 0 0; font-size: 14px; color: rgba(255,255,255,0.6);">Your ${roleLabel} account is ready</p>
|
||||||
@@ -350,7 +356,7 @@ export async function sendPasswordResetEmail(data: PasswordResetData): Promise<b
|
|||||||
<body style="margin: 0; padding: 0; background: #fafaf9; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #1c1917;">
|
<body style="margin: 0; padding: 0; background: #fafaf9; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #1c1917;">
|
||||||
<div style="max-width: 600px; margin: 0 auto; padding: 32px 16px;">
|
<div style="max-width: 600px; margin: 0 auto; padding: 32px 16px;">
|
||||||
<div style="background: #1c1917; border-radius: 16px; padding: 32px; text-align: center; margin-bottom: 24px;">
|
<div style="background: #1c1917; border-radius: 16px; padding: 32px; text-align: center; margin-bottom: 24px;">
|
||||||
<img src="https://wnzkhezyhnfzhkhiflrp.supabase.co/storage/v1/object/public/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/logo.png"
|
<img src="${TUXEDO_LOGO}"
|
||||||
alt="Tuxedo Corn" style="height: 32px; width: auto; object-fit: contain; margin-bottom: 16px;" />
|
alt="Tuxedo Corn" style="height: 32px; width: auto; object-fit: contain; margin-bottom: 16px;" />
|
||||||
<h1 style="margin: 0; font-size: 24px; font-weight: 800; color: #ffffff;">Reset Your Password</h1>
|
<h1 style="margin: 0; font-size: 24px; font-weight: 800; color: #ffffff;">Reset Your Password</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { S3Client, PutObjectCommand, DeleteObjectCommand, ListObjectsV2Command } from "@aws-sdk/client-s3";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
|
||||||
|
// ── Buckets ────────────────────────────────────────────────────────
|
||||||
|
export const BUCKETS = {
|
||||||
|
BRAND_LOGOS: "brand-logos",
|
||||||
|
PRODUCT_IMAGES: "product-images",
|
||||||
|
CONTACTS_IMPORTS: "contacts-imports",
|
||||||
|
VIDEOS: "videos",
|
||||||
|
WATER_PHOTOS: "water-photos",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type BucketName = (typeof BUCKETS)[keyof typeof BUCKETS];
|
||||||
|
|
||||||
|
// ── S3 client (MinIO is S3-compatible) ─────────────────────────────
|
||||||
|
const region = process.env.STORAGE_REGION || "us-east-1";
|
||||||
|
const endpoint = process.env.STORAGE_ENDPOINT || "http://127.0.0.1:9000";
|
||||||
|
const publicBaseUrl = process.env.NEXT_PUBLIC_STORAGE_BASE_URL || endpoint;
|
||||||
|
|
||||||
|
export const s3 = new S3Client({
|
||||||
|
region,
|
||||||
|
endpoint,
|
||||||
|
forcePathStyle: true, // MinIO requires path-style
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: process.env.STORAGE_ACCESS_KEY || "",
|
||||||
|
secretAccessKey: process.env.STORAGE_SECRET_KEY || "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const prefix = process.env.STORAGE_BUCKET_PREFIX || "";
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────────────
|
||||||
|
export function publicUrl(bucket: BucketName | string, key: string): string {
|
||||||
|
const fullKey = prefix ? `${prefix}/${key}` : key;
|
||||||
|
return `${publicBaseUrl}/${bucket}/${fullKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UploadInput = {
|
||||||
|
bucket: BucketName | string;
|
||||||
|
key: string;
|
||||||
|
body: Buffer | Uint8Array | string;
|
||||||
|
contentType?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function uploadFile(input: UploadInput): Promise<{ url: string }> {
|
||||||
|
const fullKey = prefix ? `${prefix}/${input.key}` : input.key;
|
||||||
|
await s3.send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: input.bucket,
|
||||||
|
Key: fullKey,
|
||||||
|
Body: input.body,
|
||||||
|
ContentType: input.contentType,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return { url: publicUrl(input.bucket, input.key) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteFile(bucket: BucketName | string, key: string): Promise<void> {
|
||||||
|
const fullKey = prefix ? `${prefix}/${key}` : key;
|
||||||
|
await s3.send(new DeleteObjectCommand({ Bucket: bucket, Key: fullKey }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listFiles(
|
||||||
|
bucket: BucketName | string,
|
||||||
|
prefix_?: string
|
||||||
|
): Promise<{ key: string; size: number; lastModified: Date }[]> {
|
||||||
|
const fullPrefix = prefix ? `${prefix}/${prefix_ || ""}` : prefix_ || undefined;
|
||||||
|
const res = await s3.send(
|
||||||
|
new ListObjectsV2Command({
|
||||||
|
Bucket: bucket,
|
||||||
|
Prefix: fullPrefix,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return (res.Contents || []).map((obj) => ({
|
||||||
|
key: obj.Key || "",
|
||||||
|
size: obj.Size || 0,
|
||||||
|
lastModified: obj.LastModified || new Date(0),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Key builders (single source of truth) ──────────────────────────
|
||||||
|
export const storageKeys = {
|
||||||
|
brandLogo: (brandId: string, name: string) => `${brandId}/${name}`,
|
||||||
|
productImage: (productId: string, ext: string) =>
|
||||||
|
`products/${productId}/${randomUUID()}.${ext}`,
|
||||||
|
contactsImport: (brandId: string, name: string) =>
|
||||||
|
`${brandId}/${Date.now()}-${name}`,
|
||||||
|
};
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { createServerClient } from "@supabase/ssr";
|
|
||||||
import { NextResponse, type NextRequest } from "next/server";
|
|
||||||
|
|
||||||
export function createClient(request: NextRequest) {
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
|
||||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
|
||||||
|
|
||||||
const response = NextResponse.next({ request });
|
|
||||||
|
|
||||||
return createServerClient(supabaseUrl, supabaseAnonKey, {
|
|
||||||
cookies: {
|
|
||||||
getAll() {
|
|
||||||
return request.cookies.getAll();
|
|
||||||
},
|
|
||||||
setAll(cookiesToSet, headers) {
|
|
||||||
cookiesToSet.forEach(({ name, value, options }) => {
|
|
||||||
response.cookies.set(name, value, options);
|
|
||||||
});
|
|
||||||
Object.entries(headers).forEach(([key, value]) => {
|
|
||||||
response.headers.set(key, value);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
-- 000_preflight_supabase_compat.sql
|
||||||
|
-- Stubs out Supabase-specific schemas/roles/functions so the
|
||||||
|
-- Supabase-flavored migrations in this folder can apply to plain
|
||||||
|
-- Postgres + PostgREST. Run FIRST.
|
||||||
|
--
|
||||||
|
-- In a real Supabase deployment, these are created automatically by
|
||||||
|
-- the platform. Here we recreate the minimum surface area the rest
|
||||||
|
-- of the migrations depend on.
|
||||||
|
|
||||||
|
-- ── Extensions Supabase preinstalls ─────────────────────────────────
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
|
||||||
|
-- ── Roles Supabase normally provides ──────────────────────────────────
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN
|
||||||
|
CREATE ROLE anon NOLOGIN;
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN
|
||||||
|
CREATE ROLE authenticated NOLOGIN;
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'service_role') THEN
|
||||||
|
CREATE ROLE service_role NOLOGIN BYPASSRLS;
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- ── auth schema (Supabase Auth lives here; we just need uid() and friends)
|
||||||
|
CREATE SCHEMA IF NOT EXISTS auth;
|
||||||
|
|
||||||
|
-- auth.uid() — Supabase returns the user id from the JWT. In our self-hosted
|
||||||
|
-- setup the app can SET LOCAL "request.jwt.claim.sub" = '<uuid>' before
|
||||||
|
-- calling PostgREST. Default to NULL for unauthenticated requests.
|
||||||
|
CREATE OR REPLACE FUNCTION auth.uid()
|
||||||
|
RETURNS UUID
|
||||||
|
LANGUAGE sql
|
||||||
|
STABLE
|
||||||
|
AS $$
|
||||||
|
SELECT NULLIF(current_setting('request.jwt.claim.sub', true), '')::UUID
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION auth.role()
|
||||||
|
RETURNS TEXT
|
||||||
|
LANGUAGE sql
|
||||||
|
STABLE
|
||||||
|
AS $$
|
||||||
|
SELECT COALESCE(NULLIF(current_setting('request.jwt.claim.role', true), ''), 'anon')
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION auth.email()
|
||||||
|
RETURNS TEXT
|
||||||
|
LANGUAGE sql
|
||||||
|
STABLE
|
||||||
|
AS $$
|
||||||
|
SELECT NULLIF(current_setting('request.jwt.claim.email', true), '')
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- ── storage schema intentionally omitted ───────────────────────────
|
||||||
|
-- We're replacing Supabase Storage with MinIO. The storage migrations
|
||||||
|
-- (087, 099-contact-imports, 145) have been deleted from this folder.
|
||||||
|
|
||||||
|
-- Grant the stub roles access to the public schema so RPCs can be called as them
|
||||||
|
GRANT USAGE ON SCHEMA public TO anon, authenticated, service_role;
|
||||||
|
GRANT USAGE ON SCHEMA auth TO anon, authenticated, service_role;
|
||||||
|
|
||||||
|
-- Make sure the routecommerce user can act as these roles (PostgREST will
|
||||||
|
-- SET ROLE before executing requests, depending on the JWT).
|
||||||
|
GRANT anon TO routecommerce;
|
||||||
|
GRANT authenticated TO routecommerce;
|
||||||
|
GRANT service_role TO routecommerce;
|
||||||
@@ -25,7 +25,7 @@ DROP FUNCTION IF EXISTS public.get_water_entries(uuid);
|
|||||||
CREATE OR REPLACE FUNCTION public.verify_water_pin(p_brand_id uuid, p_pin text)
|
CREATE OR REPLACE FUNCTION public.verify_water_pin(p_brand_id uuid, p_pin text)
|
||||||
RETURNS jsonb
|
RETURNS jsonb
|
||||||
LANGUAGE plpgsql
|
LANGUAGE plpgsql
|
||||||
STATIC
|
STABLE
|
||||||
AS $$
|
AS $$
|
||||||
DECLARE
|
DECLARE
|
||||||
v_user jsonb;
|
v_user jsonb;
|
||||||
@@ -84,7 +84,7 @@ CREATE OR REPLACE FUNCTION public.create_water_user(
|
|||||||
)
|
)
|
||||||
RETURNS jsonb
|
RETURNS jsonb
|
||||||
LANGUAGE plpgsql
|
LANGUAGE plpgsql
|
||||||
STATIC
|
STABLE
|
||||||
AS $$
|
AS $$
|
||||||
DECLARE
|
DECLARE
|
||||||
v_brand_id uuid;
|
v_brand_id uuid;
|
||||||
@@ -125,7 +125,7 @@ COMMENT ON FUNCTION public.create_water_user IS
|
|||||||
CREATE OR REPLACE FUNCTION public.reset_water_user_pin(p_user_id uuid)
|
CREATE OR REPLACE FUNCTION public.reset_water_user_pin(p_user_id uuid)
|
||||||
RETURNS jsonb
|
RETURNS jsonb
|
||||||
LANGUAGE plpgsql
|
LANGUAGE plpgsql
|
||||||
STATIC
|
STABLE
|
||||||
AS $$
|
AS $$
|
||||||
DECLARE
|
DECLARE
|
||||||
v_raw_pin text;
|
v_raw_pin text;
|
||||||
@@ -160,7 +160,7 @@ CREATE OR REPLACE FUNCTION public.submit_water_entry(
|
|||||||
)
|
)
|
||||||
RETURNS jsonb
|
RETURNS jsonb
|
||||||
LANGUAGE plpgsql
|
LANGUAGE plpgsql
|
||||||
STATIC
|
STABLE
|
||||||
AS $$
|
AS $$
|
||||||
DECLARE
|
DECLARE
|
||||||
v_sess record;
|
v_sess record;
|
||||||
@@ -204,7 +204,7 @@ CREATE OR REPLACE FUNCTION public.update_water_user(
|
|||||||
)
|
)
|
||||||
RETURNS jsonb
|
RETURNS jsonb
|
||||||
LANGUAGE plpgsql
|
LANGUAGE plpgsql
|
||||||
STATIC
|
STABLE
|
||||||
AS $$
|
AS $$
|
||||||
BEGIN
|
BEGIN
|
||||||
UPDATE public.water_users
|
UPDATE public.water_users
|
||||||
@@ -226,7 +226,7 @@ $$;
|
|||||||
CREATE OR REPLACE FUNCTION public.get_water_entries(p_brand_id uuid, p_limit int DEFAULT 50)
|
CREATE OR REPLACE FUNCTION public.get_water_entries(p_brand_id uuid, p_limit int DEFAULT 50)
|
||||||
RETURNS jsonb
|
RETURNS jsonb
|
||||||
LANGUAGE plpgsql
|
LANGUAGE plpgsql
|
||||||
STATIC
|
STABLE
|
||||||
AS $$
|
AS $$
|
||||||
BEGIN
|
BEGIN
|
||||||
RETURN (
|
RETURN (
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
-- Migration 087: Brand Logos Storage Bucket
|
|
||||||
--
|
|
||||||
-- SETUP REQUIRED (one-time, run manually in Supabase dashboard):
|
|
||||||
-- 1. Go to Storage → New bucket
|
|
||||||
-- 2. Name: "brand-logos" | Public: true
|
|
||||||
-- 3. Allowed MIME types: image/png, image/jpeg, image/webp, image/svg+xml
|
|
||||||
-- 4. Max file size: 5MB
|
|
||||||
--
|
|
||||||
-- Files stored at: brand-logos/{brand_id}/logo.png, brand-logos/{brand_id}/logo-dark.png
|
|
||||||
|
|
||||||
-- RLS policies for brand-logos bucket
|
|
||||||
-- Brand admins can upload/update their own brand's logos
|
|
||||||
-- Everyone can read logos (public bucket)
|
|
||||||
|
|
||||||
CREATE POLICY IF NOT EXISTS "brand_admin_upload_logo"
|
|
||||||
ON storage.objects
|
|
||||||
FOR INSERT
|
|
||||||
WITH CHECK (
|
|
||||||
bucket_id = 'brand-logos'
|
|
||||||
AND (
|
|
||||||
-- Platform admin can upload to any brand
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1 FROM admin_users au
|
|
||||||
WHERE au.user_id = auth.uid() AND au.role = 'platform_admin'
|
|
||||||
)
|
|
||||||
OR
|
|
||||||
-- Brand admin can upload to their own brand's folder
|
|
||||||
(storage.foldername(name))[1] IN (
|
|
||||||
SELECT au.brand_id::text FROM admin_users au
|
|
||||||
WHERE au.user_id = auth.uid()
|
|
||||||
AND au.role = 'brand_admin'
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE POLICY IF NOT EXISTS "public_read_logo"
|
|
||||||
ON storage.objects
|
|
||||||
FOR SELECT
|
|
||||||
USING (bucket_id = 'brand-logos');
|
|
||||||
|
|
||||||
CREATE POLICY IF NOT EXISTS "brand_admin_update_logo"
|
|
||||||
ON storage.objects
|
|
||||||
FOR UPDATE
|
|
||||||
USING (
|
|
||||||
bucket_id = 'brand-logos'
|
|
||||||
AND (
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1 FROM admin_users au
|
|
||||||
WHERE au.user_id = auth.uid() AND au.role = 'platform_admin'
|
|
||||||
)
|
|
||||||
OR
|
|
||||||
(storage.foldername(name))[1] IN (
|
|
||||||
SELECT au.brand_id::text FROM admin_users au
|
|
||||||
WHERE au.user_id = auth.uid()
|
|
||||||
AND au.role = 'brand_admin'
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE POLICY IF NOT EXISTS "brand_admin_delete_logo"
|
|
||||||
ON storage.objects
|
|
||||||
FOR DELETE
|
|
||||||
USING (
|
|
||||||
bucket_id = 'brand-logos'
|
|
||||||
AND (
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1 FROM admin_users au
|
|
||||||
WHERE au.user_id = auth.uid() AND au.role = 'platform_admin'
|
|
||||||
)
|
|
||||||
OR
|
|
||||||
(storage.foldername(name))[1] IN (
|
|
||||||
SELECT au.brand_id::text FROM admin_users au
|
|
||||||
WHERE au.user_id = auth.uid()
|
|
||||||
AND au.role = 'brand_admin'
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
-- Create imports bucket if it doesn't exist
|
|
||||||
-- Note: Run this in Supabase dashboard or via CLI
|
|
||||||
-- supabase storage create contacts-imports --public
|
|
||||||
|
|
||||||
-- Create RPC function to process imports from bucket URL
|
|
||||||
CREATE OR REPLACE FUNCTION process_contact_import_from_url(
|
|
||||||
p_brand_id UUID,
|
|
||||||
p_file_url TEXT,
|
|
||||||
p_allow_opt_in_override BOOLEAN DEFAULT false
|
|
||||||
)
|
|
||||||
RETURNS JSONB
|
|
||||||
LANGUAGE plpgsql
|
|
||||||
SECURITY DEFINER
|
|
||||||
AS $$
|
|
||||||
DECLARE
|
|
||||||
v_result JSONB;
|
|
||||||
v_csv_text TEXT;
|
|
||||||
BEGIN
|
|
||||||
-- Fetch the CSV from the bucket URL
|
|
||||||
SELECT content INTO v_csv_text
|
|
||||||
FROM net.http_get(p_file_url);
|
|
||||||
|
|
||||||
-- Process the contacts (this would need to be implemented based on your CSV parsing logic)
|
|
||||||
-- For now, return a placeholder result
|
|
||||||
-- You would call your existing import logic here
|
|
||||||
|
|
||||||
v_result := jsonb_build_object(
|
|
||||||
'created', 0,
|
|
||||||
'updated', 0,
|
|
||||||
'skipped', 0,
|
|
||||||
'errors', 0
|
|
||||||
);
|
|
||||||
|
|
||||||
RETURN v_result;
|
|
||||||
END;
|
|
||||||
$$;
|
|
||||||
@@ -37,7 +37,7 @@ CREATE OR REPLACE FUNCTION enroll_abandoned_cart(
|
|||||||
p_cart_snapshot JSONB,
|
p_cart_snapshot JSONB,
|
||||||
p_brand_name TEXT,
|
p_brand_name TEXT,
|
||||||
p_locale TEXT DEFAULT 'en',
|
p_locale TEXT DEFAULT 'en',
|
||||||
p_next_email_at TIMESTAMPTZ
|
p_next_email_at TIMESTAMPTZ DEFAULT NULL
|
||||||
)
|
)
|
||||||
RETURNS UUID
|
RETURNS UUID
|
||||||
LANGUAGE plpgsql
|
LANGUAGE plpgsql
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
-- Create product-images bucket for product images (idempotent)
|
|
||||||
INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
|
|
||||||
SELECT 'product-images', 'product-images', true, 5242880, ARRAY['image/png', 'image/jpeg', 'image/webp']
|
|
||||||
WHERE NOT EXISTS (
|
|
||||||
SELECT 1 FROM storage.buckets WHERE id = 'product-images' OR name = 'product-images'
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Enable public access to product-images bucket (re-runnable)
|
|
||||||
DROP POLICY IF EXISTS "Public can view product-images" ON storage.objects;
|
|
||||||
CREATE POLICY "Public can view product-images"
|
|
||||||
ON storage.objects FOR SELECT
|
|
||||||
USING (bucket_id = 'product-images');
|
|
||||||
|
|
||||||
DROP POLICY IF EXISTS "Admins can upload product-images" ON storage.objects;
|
|
||||||
CREATE POLICY "Admins can upload product-images"
|
|
||||||
ON storage.objects FOR INSERT
|
|
||||||
WITH CHECK (bucket_id = 'product-images');
|
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
-- 200_better_auth_tables.sql
|
||||||
|
-- Better Auth core tables: user, session, account, verification
|
||||||
|
-- Replaces Supabase Auth for the self-hosted Postgres deployment.
|
||||||
|
-- user.id is UUID (matches admin_users.user_id FK).
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "user" (
|
||||||
|
"id" UUID PRIMARY KEY,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL UNIQUE,
|
||||||
|
"emailVerified" BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
"image" TEXT,
|
||||||
|
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
"updatedAt" TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "session" (
|
||||||
|
"id" UUID PRIMARY KEY,
|
||||||
|
"expiresAt" TIMESTAMPTZ NOT NULL,
|
||||||
|
"token" TEXT NOT NULL UNIQUE,
|
||||||
|
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
"updatedAt" TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
"ipAddress" TEXT,
|
||||||
|
"userAgent" TEXT,
|
||||||
|
"userId" UUID NOT NULL REFERENCES "user"("id") ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_session_userId" ON "session"("userId");
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "account" (
|
||||||
|
"id" UUID PRIMARY KEY,
|
||||||
|
"accountId" TEXT NOT NULL,
|
||||||
|
"providerId" TEXT NOT NULL,
|
||||||
|
"userId" UUID NOT NULL REFERENCES "user"("id") ON DELETE CASCADE,
|
||||||
|
"accessToken" TEXT,
|
||||||
|
"refreshToken" TEXT,
|
||||||
|
"idToken" TEXT,
|
||||||
|
"accessTokenExpiresAt" TIMESTAMPTZ,
|
||||||
|
"refreshTokenExpiresAt" TIMESTAMPTZ,
|
||||||
|
"scope" TEXT,
|
||||||
|
"password" TEXT,
|
||||||
|
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
"updatedAt" TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_account_userId" ON "account"("userId");
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_account_providerId" ON "account"("providerId");
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "verification" (
|
||||||
|
"id" UUID PRIMARY KEY,
|
||||||
|
"identifier" TEXT NOT NULL,
|
||||||
|
"value" TEXT NOT NULL,
|
||||||
|
"expiresAt" TIMESTAMPTZ NOT NULL,
|
||||||
|
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
"updatedAt" TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_verification_identifier" ON "verification"("identifier");
|
||||||
|
|
||||||
|
-- Better Auth does not need RLS on these tables — auth checks happen in app code.
|
||||||
|
-- Disable RLS so existing SECURITY DEFINER RPCs can still read user rows if needed.
|
||||||
|
ALTER TABLE "user" DISABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE "session" DISABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE "account" DISABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE "verification" DISABLE ROW LEVEL SECURITY;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,77 +0,0 @@
|
|||||||
-- Blog and Newsletter Tables
|
|
||||||
-- Migration for Route Commerce
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS blog_posts (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
slug TEXT UNIQUE NOT NULL,
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
excerpt TEXT,
|
|
||||||
content TEXT,
|
|
||||||
featured_image TEXT,
|
|
||||||
author_name TEXT NOT NULL,
|
|
||||||
author_avatar TEXT,
|
|
||||||
category TEXT DEFAULT 'General',
|
|
||||||
tags TEXT[],
|
|
||||||
published_at TIMESTAMPTZ,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
published BOOLEAN DEFAULT FALSE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS blog_resources (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
type TEXT DEFAULT 'guide' CHECK (type IN ('guide', 'case_study', 'webinar', 'template', 'checklist')),
|
|
||||||
url TEXT,
|
|
||||||
thumbnail TEXT,
|
|
||||||
downloads INTEGER DEFAULT 0,
|
|
||||||
featured BOOLEAN DEFAULT FALSE,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS newsletter_subscribers (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
email TEXT UNIQUE NOT NULL,
|
|
||||||
subscribed_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
status TEXT DEFAULT 'active' CHECK (status IN ('active', 'unsubscribed', 'bounced')),
|
|
||||||
source TEXT,
|
|
||||||
unsubscribed_at TIMESTAMPTZ
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Indexes
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_blog_slug ON blog_posts(slug);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_blog_published ON blog_posts(published);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_blog_category ON blog_posts(category);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_newsletter_email ON newsletter_subscribers(email);
|
|
||||||
|
|
||||||
-- Grant permissions
|
|
||||||
GRANT SELECT, INSERT ON blog_posts TO anon;
|
|
||||||
GRANT SELECT, INSERT ON blog_resources TO anon;
|
|
||||||
GRANT SELECT, INSERT ON newsletter_subscribers TO anon;
|
|
||||||
GRANT ALL ON blog_posts TO authenticated;
|
|
||||||
GRANT ALL ON blog_resources TO authenticated;
|
|
||||||
GRANT ALL ON newsletter_subscribers TO authenticated;
|
|
||||||
GRANT ALL ON blog_posts TO service_role;
|
|
||||||
GRANT ALL ON blog_resources TO service_role;
|
|
||||||
GRANT ALL ON newsletter_subscribers TO service_role;
|
|
||||||
|
|
||||||
-- Seed blog posts
|
|
||||||
INSERT INTO blog_posts (slug, title, excerpt, content, author_name, category, published_at, published) VALUES
|
|
||||||
('getting-started-with-route-commerce', 'Getting Started with Route Commerce', 'Learn how to set up your wholesale business on Route Commerce in under 10 minutes.', '## Getting Started
|
|
||||||
|
|
||||||
Welcome to Route Commerce! This guide will walk you through setting up your wholesale operation...', 'Team Route Commerce', 'Guides', NOW(), TRUE),
|
|
||||||
('maximize-produce-profitability', '5 Tips to Maximize Your Produce Profitability', 'Strategic pricing and inventory management can significantly boost your bottom line.', '## Introduction
|
|
||||||
|
|
||||||
Running a profitable produce operation requires more than just quality products...', 'Sarah Johnson', 'Tips', NOW(), TRUE),
|
|
||||||
('customer-communication-best-practices', 'Best Practices for Customer Communication', 'Keep your customers informed and engaged with these communication strategies.', '## Why Communication Matters
|
|
||||||
|
|
||||||
Clear, timely communication builds trust and reduces missed pickups...', 'Marcus Chen', 'Marketing', NOW(), TRUE)
|
|
||||||
ON CONFLICT DO NOTHING;
|
|
||||||
|
|
||||||
-- Seed resources
|
|
||||||
INSERT INTO blog_resources (title, description, type, downloads, featured) VALUES
|
|
||||||
('Wholesale Pricing Guide', 'Complete guide to setting profitable wholesale prices', 'guide', 342, TRUE),
|
|
||||||
('Order Management Checklist', 'Step-by-step checklist for order fulfillment', 'checklist', 256, FALSE),
|
|
||||||
('Customer Email Templates', 'Pre-written email templates for common scenarios', 'template', 189, FALSE)
|
|
||||||
ON CONFLICT DO NOTHING;
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
-- Launch Checklist Progress Table
|
|
||||||
-- Track user's launch preparation progress
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS launch_checklist_progress (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
item_id TEXT NOT NULL,
|
|
||||||
completed BOOLEAN DEFAULT FALSE,
|
|
||||||
completed_at TIMESTAMPTZ,
|
|
||||||
notes TEXT,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
UNIQUE(user_id, item_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Indexes
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_checklist_user ON launch_checklist_progress(user_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_checklist_completed ON launch_checklist_progress(completed) WHERE completed = TRUE;
|
|
||||||
|
|
||||||
-- Grant permissions
|
|
||||||
GRANT SELECT, INSERT, UPDATE ON launch_checklist_progress TO authenticated;
|
|
||||||
GRANT ALL ON launch_checklist_progress TO service_role;
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
-- Roadmap Tables for Feature Requests and Voting
|
|
||||||
-- Migration for Route Commerce
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS roadmap_items (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
status TEXT DEFAULT 'planned' CHECK (status IN ('planned', 'in_progress', 'shipped')),
|
|
||||||
category TEXT,
|
|
||||||
upvotes INTEGER DEFAULT 0,
|
|
||||||
created_by TEXT,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS roadmap_votes (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
item_id UUID REFERENCES roadmap_items(id) ON DELETE CASCADE,
|
|
||||||
visitor_id TEXT NOT NULL,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
UNIQUE(item_id, visitor_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Indexes
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_roadmap_status ON roadmap_items(status);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_roadmap_category ON roadmap_items(category);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_roadmap_upvotes ON roadmap_items(upvotes DESC);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_roadmap_votes_item ON roadmap_votes(item_id);
|
|
||||||
|
|
||||||
-- Grant permissions
|
|
||||||
GRANT SELECT ON roadmap_items TO anon;
|
|
||||||
GRANT SELECT, INSERT ON roadmap_votes TO anon;
|
|
||||||
GRANT ALL ON roadmap_items TO authenticated;
|
|
||||||
GRANT ALL ON roadmap_votes TO authenticated;
|
|
||||||
GRANT ALL ON roadmap_items TO service_role;
|
|
||||||
GRANT ALL ON roadmap_votes TO service_role;
|
|
||||||
|
|
||||||
-- Seed some initial items
|
|
||||||
INSERT INTO roadmap_items (title, description, status, category, upvotes) VALUES
|
|
||||||
('Mobile App (iOS & Android)', 'Native apps for field workers and delivery drivers', 'in_progress', 'Mobile', 234),
|
|
||||||
('SMS Campaigns', 'Text message marketing and notifications', 'planned', 'Communication', 98),
|
|
||||||
('Route Optimization', 'AI-powered route planning for deliveries', 'planned', 'Logistics', 167),
|
|
||||||
('Customer Loyalty Program', 'Points, rewards, and referral tracking', 'planned', 'Marketing', 112),
|
|
||||||
('POS Integration (Clover, Toast)', 'Additional POS system integrations', 'planned', 'Integrations', 76)
|
|
||||||
ON CONFLICT DO NOTHING;
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
-- Waitlist and Early Access Signup System
|
|
||||||
-- Migration for Route Commerce
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS waitlist (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
email TEXT UNIQUE NOT NULL,
|
|
||||||
name TEXT,
|
|
||||||
referral_source TEXT,
|
|
||||||
referred_by TEXT,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'converted', 'archived'))
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Index for email lookups
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_waitlist_email ON waitlist(email);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_waitlist_status ON waitlist(status);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_waitlist_created ON waitlist(created_at DESC);
|
|
||||||
|
|
||||||
-- Grant permissions
|
|
||||||
GRANT SELECT, INSERT ON waitlist TO anon;
|
|
||||||
GRANT SELECT, INSERT ON waitlist TO authenticated;
|
|
||||||
GRANT SELECT, INSERT ON waitlist TO service_role;
|
|
||||||
|
|
||||||
-- Comment on table
|
|
||||||
COMMENT ON TABLE waitlist IS 'Early access signup for Route Commerce platform';
|
|
||||||
COMMENT ON COLUMN waitlist.email IS 'Unique email address';
|
|
||||||
COMMENT ON COLUMN waitlist.name IS 'Optional full name';
|
|
||||||
COMMENT ON COLUMN waitlist.referral_source IS 'How they heard about us';
|
|
||||||
COMMENT ON COLUMN waitlist.referred_by IS 'Email of person who referred them';
|
|
||||||
COMMENT ON COLUMN waitlist.status IS 'pending, converted (signed up), archived';
|
|
||||||
Reference in New Issue
Block a user