Compare commits
18 Commits
main
...
cbc8958b55
| Author | SHA1 | Date | |
|---|---|---|---|
| cbc8958b55 | |||
| be226d3430 | |||
| b433c79677 | |||
| a266203c07 | |||
| 3f4145e800 | |||
| 553bfed070 | |||
| 452eef7606 | |||
| c20538ef9f | |||
| 66d8cdf9b2 | |||
| e41ce98b74 | |||
| 85db68ed70 | |||
| 4eb6b173e0 | |||
| 2635c0a99d | |||
| 3a9c6e3934 | |||
| d9dee2c926 | |||
| a15f2b7dcf | |||
| 2565c18cdd | |||
| e6a97ba9ab |
@@ -0,0 +1,34 @@
|
||||
# --- Self-hosted Postgres (Docker) ---
|
||||
POSTGRES_USER=routecommerce
|
||||
POSTGRES_PASSWORD=routecommerce_dev_password
|
||||
POSTGRES_DB=route_commerce
|
||||
# Used by the app and push-migrations.js to talk to the local DB
|
||||
DATABASE_URL=postgresql://routecommerce:routecommerce_dev_password@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=REPLACE_ME_WITH_OPENSSL_RAND_HEX_32
|
||||
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,183 @@
|
||||
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: Start Docker stack
|
||||
env:
|
||||
POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
|
||||
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
||||
POSTGRES_DB: ${{ secrets.POSTGRES_DB }}
|
||||
MINIO_ROOT_USER: ${{ secrets.MINIO_ROOT_USER }}
|
||||
MINIO_ROOT_PASSWORD: ${{ secrets.MINIO_ROOT_PASSWORD }}
|
||||
POSTGREST_JWT_SECRET: ${{ secrets.POSTGREST_JWT_SECRET }}
|
||||
run: |
|
||||
APP_DIR=/home/tyler/route-commerce
|
||||
mkdir -p $APP_DIR
|
||||
cd $APP_DIR
|
||||
[ -f .env ] || cp .env.example .env
|
||||
# Append production secrets to .env (overriding .env.example defaults)
|
||||
{
|
||||
echo "POSTGRES_USER=${POSTGRES_USER}"
|
||||
echo "POSTGRES_PASSWORD=${POSTGRES_PASSWORD}"
|
||||
echo "POSTGRES_DB=${POSTGRES_DB}"
|
||||
echo "MINIO_ROOT_USER=${MINIO_ROOT_USER}"
|
||||
echo "MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD}"
|
||||
echo "POSTGREST_JWT_SECRET=${POSTGREST_JWT_SECRET}"
|
||||
} >> .env
|
||||
docker compose up -d db postgrest minio minio_init
|
||||
# Wait for Postgres healthcheck
|
||||
for i in $(seq 1 30); do
|
||||
if docker compose exec -T db pg_isready -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" > /dev/null 2>&1; then
|
||||
echo "Postgres is ready"
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
- name: Apply migrations
|
||||
env:
|
||||
POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
|
||||
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
||||
POSTGRES_DB: ${{ secrets.POSTGRES_DB }}
|
||||
run: |
|
||||
cd /home/tyler/route-commerce
|
||||
PGPASSWORD="${POSTGRES_PASSWORD}" psql -h 127.0.0.1 -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -v ON_ERROR_STOP=0 -f supabase/migrations/000_preflight_supabase_compat.sql || true
|
||||
[ -f supabase/captured_schema.sql ] && PGPASSWORD="${POSTGRES_PASSWORD}" psql -h 127.0.0.1 -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -v ON_ERROR_STOP=0 -f supabase/captured_schema.sql || echo "captured_schema.sql not present, skipping"
|
||||
for f in supabase/migrations/[0-9]*.sql; do
|
||||
PGPASSWORD="${POSTGRES_PASSWORD}" psql -h 127.0.0.1 -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -v ON_ERROR_STOP=0 -q -f "$f" || echo "FAIL: $f"
|
||||
done
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Build
|
||||
env:
|
||||
NODE_ENV: production
|
||||
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||
BETTER_AUTH_SECRET: ${{ secrets.BETTER_AUTH_SECRET }}
|
||||
BETTER_AUTH_URL: ${{ secrets.BETTER_AUTH_URL }}
|
||||
NEXT_PUBLIC_BETTER_AUTH_URL: ${{ secrets.NEXT_PUBLIC_BETTER_AUTH_URL }}
|
||||
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
|
||||
NEXT_PUBLIC_STORAGE_BASE_URL: ${{ secrets.NEXT_PUBLIC_STORAGE_BASE_URL }}
|
||||
STORAGE_ENDPOINT: ${{ secrets.STORAGE_ENDPOINT }}
|
||||
STORAGE_REGION: ${{ secrets.STORAGE_REGION }}
|
||||
STORAGE_ACCESS_KEY: ${{ secrets.STORAGE_ACCESS_KEY }}
|
||||
STORAGE_SECRET_KEY: ${{ secrets.STORAGE_SECRET_KEY }}
|
||||
STORAGE_BUCKET_PREFIX: ${{ secrets.STORAGE_BUCKET_PREFIX }}
|
||||
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
|
||||
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}
|
||||
STRIPE_PUBLISHABLE_KEY: ${{ secrets.STRIPE_PUBLISHABLE_KEY }}
|
||||
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
|
||||
RESEND_WEBHOOK_SECRET: ${{ secrets.RESEND_WEBHOOK_SECRET }}
|
||||
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
|
||||
MINIMAX_BASE_URL: ${{ secrets.MINIMAX_BASE_URL }}
|
||||
FROM_EMAIL: ${{ secrets.FROM_EMAIL }}
|
||||
run: npm run build
|
||||
|
||||
- name: Deploy
|
||||
env:
|
||||
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||
POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
|
||||
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
||||
POSTGRES_DB: ${{ secrets.POSTGRES_DB }}
|
||||
MINIO_ROOT_USER: ${{ secrets.MINIO_ROOT_USER }}
|
||||
MINIO_ROOT_PASSWORD: ${{ secrets.MINIO_ROOT_PASSWORD }}
|
||||
POSTGREST_JWT_SECRET: ${{ secrets.POSTGREST_JWT_SECRET }}
|
||||
BETTER_AUTH_SECRET: ${{ secrets.BETTER_AUTH_SECRET }}
|
||||
BETTER_AUTH_URL: ${{ secrets.BETTER_AUTH_URL }}
|
||||
NEXT_PUBLIC_BETTER_AUTH_URL: ${{ secrets.NEXT_PUBLIC_BETTER_AUTH_URL }}
|
||||
STORAGE_ENDPOINT: ${{ secrets.STORAGE_ENDPOINT }}
|
||||
STORAGE_REGION: ${{ secrets.STORAGE_REGION }}
|
||||
STORAGE_ACCESS_KEY: ${{ secrets.STORAGE_ACCESS_KEY }}
|
||||
STORAGE_SECRET_KEY: ${{ secrets.STORAGE_SECRET_KEY }}
|
||||
STORAGE_BUCKET_PREFIX: ${{ secrets.STORAGE_BUCKET_PREFIX }}
|
||||
NEXT_PUBLIC_STORAGE_BASE_URL: ${{ secrets.NEXT_PUBLIC_STORAGE_BASE_URL }}
|
||||
PGRST_SERVER_PORT: ${{ secrets.PGRST_SERVER_PORT }}
|
||||
PGRST_DB_URI: ${{ secrets.PGRST_DB_URI }}
|
||||
PGRST_DB_ANON_ROLE: ${{ secrets.PGRST_DB_ANON_ROLE }}
|
||||
PGRST_JWT_SECRET: ${{ secrets.POSTGREST_JWT_SECRET }}
|
||||
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
|
||||
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
|
||||
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}
|
||||
STRIPE_PUBLISHABLE_KEY: ${{ secrets.STRIPE_PUBLISHABLE_KEY }}
|
||||
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
|
||||
RESEND_WEBHOOK_SECRET: ${{ secrets.RESEND_WEBHOOK_SECRET }}
|
||||
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
|
||||
MINIMAX_BASE_URL: ${{ secrets.MINIMAX_BASE_URL }}
|
||||
FROM_EMAIL: ${{ secrets.FROM_EMAIL }}
|
||||
run: |
|
||||
APP_DIR=/home/tyler/route-commerce
|
||||
mkdir -p $APP_DIR
|
||||
|
||||
# Write env file from secrets (preserves existing .env for docker compose)
|
||||
{
|
||||
printf "DATABASE_URL=%s\n" "$DATABASE_URL"
|
||||
printf "POSTGRES_USER=%s\n" "$POSTGRES_USER"
|
||||
printf "POSTGRES_PASSWORD=%s\n" "$POSTGRES_PASSWORD"
|
||||
printf "POSTGRES_DB=%s\n" "$POSTGRES_DB"
|
||||
printf "MINIO_ROOT_USER=%s\n" "$MINIO_ROOT_USER"
|
||||
printf "MINIO_ROOT_PASSWORD=%s\n" "$MINIO_ROOT_PASSWORD"
|
||||
printf "POSTGREST_JWT_SECRET=%s\n" "$POSTGREST_JWT_SECRET"
|
||||
printf "BETTER_AUTH_SECRET=%s\n" "$BETTER_AUTH_SECRET"
|
||||
printf "BETTER_AUTH_URL=%s\n" "$BETTER_AUTH_URL"
|
||||
printf "NEXT_PUBLIC_BETTER_AUTH_URL=%s\n" "$NEXT_PUBLIC_BETTER_AUTH_URL"
|
||||
printf "STORAGE_ENDPOINT=%s\n" "$STORAGE_ENDPOINT"
|
||||
printf "STORAGE_REGION=%s\n" "$STORAGE_REGION"
|
||||
printf "STORAGE_ACCESS_KEY=%s\n" "$STORAGE_ACCESS_KEY"
|
||||
printf "STORAGE_SECRET_KEY=%s\n" "$STORAGE_SECRET_KEY"
|
||||
printf "STORAGE_BUCKET_PREFIX=%s\n" "$STORAGE_BUCKET_PREFIX"
|
||||
printf "NEXT_PUBLIC_STORAGE_BASE_URL=%s\n" "$NEXT_PUBLIC_STORAGE_BASE_URL"
|
||||
printf "PGRST_SERVER_PORT=%s\n" "$PGRST_SERVER_PORT"
|
||||
printf "PGRST_DB_URI=%s\n" "$PGRST_DB_URI"
|
||||
printf "PGRST_DB_ANON_ROLE=%s\n" "$PGRST_DB_ANON_ROLE"
|
||||
printf "PGRST_JWT_SECRET=%s\n" "$POSTGREST_JWT_SECRET"
|
||||
printf "NEXT_PUBLIC_SUPABASE_URL=%s\n" "$NEXT_PUBLIC_SUPABASE_URL"
|
||||
printf "NEXT_PUBLIC_SUPABASE_ANON_KEY=%s\n" "$NEXT_PUBLIC_SUPABASE_ANON_KEY"
|
||||
printf "STRIPE_SECRET_KEY=%s\n" "$STRIPE_SECRET_KEY"
|
||||
printf "STRIPE_WEBHOOK_SECRET=%s\n" "$STRIPE_WEBHOOK_SECRET"
|
||||
printf "STRIPE_PUBLISHABLE_KEY=%s\n" "$STRIPE_PUBLISHABLE_KEY"
|
||||
printf "RESEND_API_KEY=%s\n" "$RESEND_API_KEY"
|
||||
printf "RESEND_WEBHOOK_SECRET=%s\n" "$RESEND_WEBHOOK_SECRET"
|
||||
printf "MINIMAX_API_KEY=%s\n" "$MINIMAX_API_KEY"
|
||||
printf "MINIMAX_BASE_URL=%s\n" "$MINIMAX_BASE_URL"
|
||||
printf "FROM_EMAIL=%s\n" "$FROM_EMAIL"
|
||||
} > $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 docker-compose.yml $APP_DIR/
|
||||
cp -r supabase/ $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"
|
||||
+17
-1
@@ -41,4 +41,20 @@ supabase/.temp/
|
||||
|
||||
# IDE / local config
|
||||
.mcp.json
|
||||
.env*
|
||||
|
||||
# Docker / self-hosted Postgres
|
||||
db_data/
|
||||
|
||||
# Captured Supabase data (re-pull from Supabase as needed)
|
||||
supabase/captured/captured_data.sql.gz
|
||||
supabase/captured/captured_schema.sql
|
||||
|
||||
# Local data and binaries
|
||||
.data/
|
||||
bin/postgrest
|
||||
bin/minio
|
||||
bin/mc
|
||||
bin/postgrest-proxy.js
|
||||
bin/postgrest.tar.xz
|
||||
# Captured Supabase dump (regenerate from Supabase with the guide in docs/SUPABASE_DUMP_GUIDE.md)
|
||||
supabase/captured/
|
||||
|
||||
@@ -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
|
||||
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
@@ -0,0 +1,205 @@
|
||||
# Supabase Dump & Restore Guide
|
||||
|
||||
This guide is the runbook for capturing the real Supabase schema and data
|
||||
and restoring it to a local Postgres database. It documents the exact
|
||||
steps that worked when the spend cap was removed on 2026-06-05.
|
||||
|
||||
## Connection (this works from the dev box)
|
||||
|
||||
The Supabase project `wnzkhezyhnfzhkhiflrp` (route-commerce) is hosted in
|
||||
**East US (North Virginia)**. The dev box has no IPv6, so we must use
|
||||
the **Supavisor pooler** (IPv4 only) — and it's on **`aws-1`**, not `aws-0`.
|
||||
|
||||
```bash
|
||||
# Working pooler URL (from supabase/.temp/pooler-url)
|
||||
postgresql://postgres.wnzkhezyhnfzhkhiflrp:YLKzP9jz2yqop7jr@aws-1-us-east-1.pooler.supabase.com:5432/postgres
|
||||
```
|
||||
|
||||
The `aws-0-us-east-1.pooler.supabase.com` hostname resolves over IPv4
|
||||
but Supavisor returns "tenant/user not found" because the project lives
|
||||
on `aws-1`. The correct region number is non-obvious — always check
|
||||
`supabase/.temp/pooler-url` for the actual endpoint.
|
||||
|
||||
The direct hostname `db.wnzkhezyhnfzhkhiflrp.supabase.co` only resolves
|
||||
over IPv6, which is unreachable from this dev box.
|
||||
|
||||
## pg_dump version
|
||||
|
||||
Supabase runs **PostgreSQL 17.6**. Local pg must be ≥ 17 to dump cleanly.
|
||||
The dev box had pg 16 by default — install pg 17 client:
|
||||
|
||||
```bash
|
||||
echo "deb [signed-by=/usr/share/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt/ noble-pgdg main" \
|
||||
| sudo tee /etc/apt/sources.list.d/pgdg.list
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --allow-unauthenticated postgresql-client-17
|
||||
# Use /usr/lib/postgresql/17/bin/pg_dump explicitly (or update PATH)
|
||||
```
|
||||
|
||||
## Capture Schema
|
||||
|
||||
```bash
|
||||
export PGPASSWORD="YLKzP9jz2yqop7jr"
|
||||
export PATH="/usr/lib/postgresql/17/bin:$PATH"
|
||||
|
||||
mkdir -p supabase/captured
|
||||
pg_dump \
|
||||
--host=aws-1-us-east-1.pooler.supabase.com \
|
||||
--port=5432 \
|
||||
--username=postgres.wnzkhezyhnfzhkhiflrp \
|
||||
--dbname=postgres \
|
||||
--schema-only \
|
||||
--no-owner \
|
||||
--no-privileges \
|
||||
--no-acl \
|
||||
--exclude-schema=auth \
|
||||
--exclude-schema=storage \
|
||||
--exclude-schema=realtime \
|
||||
--exclude-schema=supabase_functions \
|
||||
--exclude-schema=graphql \
|
||||
--exclude-schema=graphql_public \
|
||||
--exclude-schema=pgsodium \
|
||||
--exclude-schema=pgsodium_masks \
|
||||
--exclude-schema=extensions \
|
||||
--exclude-schema=pgbouncer \
|
||||
--exclude-schema=supabase_migrations \
|
||||
--exclude-schema=net \
|
||||
--exclude-schema=vault \
|
||||
--file=supabase/captured/captured_schema.sql
|
||||
```
|
||||
|
||||
Captured schema is ~540KB, 65 tables, 252 functions.
|
||||
|
||||
## Capture Data
|
||||
|
||||
```bash
|
||||
pg_dump \
|
||||
--host=aws-1-us-east-1.pooler.supabase.com \
|
||||
--port=5432 \
|
||||
--username=postgres.wnzkhezyhnfzhkhiflrp \
|
||||
--dbname=postgres \
|
||||
--data-only \
|
||||
--no-owner \
|
||||
--no-privileges \
|
||||
--no-acl \
|
||||
--disable-triggers \
|
||||
--exclude-schema=auth \
|
||||
--exclude-schema=storage \
|
||||
--exclude-schema=realtime \
|
||||
--exclude-schema=supabase_functions \
|
||||
--exclude-schema=graphql \
|
||||
--exclude-schema=graphql_public \
|
||||
--exclude-schema=pgsodium \
|
||||
--exclude-schema=pgsodium_masks \
|
||||
--exclude-schema=extensions \
|
||||
--exclude-schema=pgbouncer \
|
||||
--exclude-schema=supabase_migrations \
|
||||
--exclude-schema=net \
|
||||
--exclude-schema=vault \
|
||||
--file=supabase/captured/captured_data.sql
|
||||
```
|
||||
|
||||
Captured data is ~130KB for this project's current size.
|
||||
|
||||
## Restore to Local DB (PostgreSQL 16)
|
||||
|
||||
PostgreSQL 16 doesn't support `transaction_timeout` (added in PG 17) and
|
||||
doesn't have the `supabase_vault` extension. Pre-create the stub objects:
|
||||
|
||||
```bash
|
||||
export PGPASSWORD=routecommerce_dev_password
|
||||
psql -U routecommerce -h 127.0.0.1 -d route_commerce <<'SQL'
|
||||
DROP SCHEMA public CASCADE;
|
||||
CREATE SCHEMA public;
|
||||
GRANT ALL ON SCHEMA public TO routecommerce;
|
||||
|
||||
-- extensions schema (referenced by dump as extensions.uuid_generate_v4())
|
||||
CREATE SCHEMA extensions;
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp" SCHEMA extensions;
|
||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto" SCHEMA extensions;
|
||||
CREATE EXTENSION IF NOT EXISTS "pg_stat_statements" SCHEMA extensions;
|
||||
|
||||
-- Stub functions in extensions schema (real Supabase has pgcrypto.crypt etc.)
|
||||
CREATE OR REPLACE FUNCTION extensions.uuid_generate_v4()
|
||||
RETURNS uuid LANGUAGE sql AS $$ SELECT gen_random_uuid(); $$;
|
||||
CREATE OR REPLACE FUNCTION extensions.gen_salt(text)
|
||||
RETURNS text LANGUAGE sql AS $$ SELECT '$2a$06$' || repeat('A', 53); $$;
|
||||
CREATE OR REPLACE FUNCTION extensions.crypt(text, text)
|
||||
RETURNS text LANGUAGE sql AS $$ SELECT $1; $$;
|
||||
|
||||
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA extensions TO routecommerce;
|
||||
|
||||
-- auth stub (we excluded auth schema from dump, but RLS policies reference auth.uid())
|
||||
CREATE SCHEMA IF NOT EXISTS auth;
|
||||
CREATE TABLE IF NOT EXISTS auth.users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email TEXT,
|
||||
raw_user_meta_data JSONB,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
CREATE OR REPLACE FUNCTION auth.uid() RETURNS UUID
|
||||
LANGUAGE sql STABLE AS $$ SELECT NULL::UUID; $$;
|
||||
GRANT USAGE ON SCHEMA auth TO routecommerce;
|
||||
GRANT ALL ON auth.users TO routecommerce;
|
||||
GRANT EXECUTE ON FUNCTION auth.uid() TO routecommerce;
|
||||
|
||||
-- Drop Supabase-specific publications (we don't have realtime / vault)
|
||||
DROP PUBLICATION IF EXISTS supabase_realtime;
|
||||
DROP PUBLICATION IF EXISTS supabase_realtime_messages_publication;
|
||||
SQL
|
||||
```
|
||||
|
||||
Strip the PG17-only `SET transaction_timeout` line from the dump:
|
||||
|
||||
```bash
|
||||
sed -i 's/^SET transaction_timeout = 0;$/-- transaction_timeout requires PG17 (we are on PG16); skipped/' \
|
||||
supabase/captured/captured_schema.sql
|
||||
```
|
||||
|
||||
Apply schema and data (idempotent — re-runs are safe):
|
||||
|
||||
```bash
|
||||
psql -U routecommerce -h 127.0.0.1 -d route_commerce -v ON_ERROR_STOP=0 \
|
||||
-f supabase/captured/captured_schema.sql 2>&1 | tail -20
|
||||
|
||||
psql -U routecommerce -h 127.0.0.1 -d route_commerce -v ON_ERROR_STOP=0 \
|
||||
-f supabase/captured/captured_data.sql 2>&1 | tail -20
|
||||
```
|
||||
|
||||
Most errors on re-run are "already exists" — that's expected because the
|
||||
dump is idempotent for CREATE statements (we used `IF NOT EXISTS` where
|
||||
possible, but pg_dump doesn't add it for everything).
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
psql -U routecommerce -h 127.0.0.1 -d route_commerce -c "SELECT count(*) FROM pg_tables WHERE schemaname='public';"
|
||||
# Expect: 65
|
||||
|
||||
psql -U routecommerce -h 127.0.0.1 -d route_commerce -c "SELECT count(*) FROM pg_proc WHERE pronamespace=(SELECT oid FROM pg_namespace WHERE nspname='public');"
|
||||
# Expect: ~250+
|
||||
|
||||
psql -U routecommerce -h 127.0.0.1 -d route_commerce -c "SELECT name, slug, created_at FROM brands ORDER BY created_at;"
|
||||
# Expect 5 brands: Tuxedo Corn, Indian River Direct, Sunrise Farms, Green Valley Organics, Orchard Fresh
|
||||
```
|
||||
|
||||
## What Didn't Work (don't try these)
|
||||
|
||||
| Approach | Why it failed |
|
||||
|---|---|
|
||||
| `psql ... db.wnzkhezyhnfzhkhiflrp.supabase.co:5432` | Hostname is IPv6-only, dev box has no IPv6 |
|
||||
| `aws-0-us-east-1.pooler.supabase.com` | "tenant/user not found" — wrong region (project is on `aws-1`) |
|
||||
| `pg_dump 16.x` against PG 17 server | "server version mismatch" — fatal error |
|
||||
| `supabase db dump --linked` | Requires Docker (we don't have it) |
|
||||
|
||||
## Notes
|
||||
|
||||
- The captured data includes 3 test brands (Sunrise, Green Valley, Orchard)
|
||||
created on 2026-06-03 — these were test data the user added during the
|
||||
migration work, not real customers. They can be deleted safely.
|
||||
- Tuxedo Corn and Indian River Direct are the real production brands.
|
||||
- The schema dump is committed to git at `supabase/captured/captured_schema.sql.gz`
|
||||
for reproducibility. The data dump is gitignored (regenerate as needed).
|
||||
- After dump, the local PostgREST needs a schema cache reload — restart
|
||||
the postgrest process or it'll serve stale metadata for ~30 seconds.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,325 @@
|
||||
# Supabase → Self-Hosted Migration — Design Spec
|
||||
|
||||
**Date:** 2026-06-05
|
||||
**Status:** Approved
|
||||
**Author:** Brainstorm session with user
|
||||
**Target branch:** `selfhost/migrate` (to be created)
|
||||
**Source branches:** `crispygoat/self-hosted-postgres` (6 commits) + `crispygoat/feat/better-auth` (1 commit, contains a detailed `plan.md`)
|
||||
|
||||
## Overview
|
||||
|
||||
Move Route Commerce off Supabase's hosted platform onto a self-hosted stack while preserving all data and behavior. Replace Supabase Auth with `better-auth`, Supabase Storage with MinIO, and Supabase's hosted Postgres with a plain Postgres instance fronted by PostgREST (the same protocol `supabase-js` and `@supabase/ssr` already speak). Consolidate two diverging branches that have been tackling different layers of this migration independently.
|
||||
|
||||
## Goals
|
||||
|
||||
1. **No data loss.** Full schema + data dump from the live Supabase project, restored on the new self-hosted Postgres.
|
||||
2. **No band-aids.** Real self-hosted replacement, not runtime patches that paper over Supabase.
|
||||
3. **App behavior unchanged.** All 137 SECURITY DEFINER RPCs, server actions, and client flows keep working with minimal code change.
|
||||
4. **Single coherent path.** Merge the two in-flight branches into one, eliminate the divergence.
|
||||
5. **Local + remote parity.** `docker compose up` works on the dev box and on the prod server (route.crispygoat.com).
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Replacing `supabase-js` / `@supabase/ssr` libraries. They speak PostgREST; we keep using them.
|
||||
- Rewriting the 185 SECURITY DEFINER functions. They stay as-is; brand scoping is already in the function bodies / app layer.
|
||||
- Changing the Stripe, Resend, Square, or any other non-Supabase integrations.
|
||||
- Performance tuning of the new stack (separate follow-up).
|
||||
- Adopting realtime (not used in current code) or Supabase Edge Functions (not used).
|
||||
|
||||
## Constraints
|
||||
|
||||
- **App must keep building** (`npx tsc --noEmit && npm run build`) without Supabase env vars.
|
||||
- **Mock mode still works** for `NEXT_PUBLIC_USE_MOCK_DATA=true` deployments (demos, Netlify previews).
|
||||
- **Dev mode bypass** (`dev_session=platform_admin` cookie) must continue to function for local testing.
|
||||
- **No new external service dependencies** unless explicitly approved (i.e., we are not introducing Vercel Postgres, Neon, etc. — the stack is self-hosted).
|
||||
- **Migration is reversible**: the Supabase project remains live until cutover is verified.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ Next.js app (Node, port 3100 via PM2) │
|
||||
│ │
|
||||
│ ┌────────────┐ ┌────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ supabase-js│ │ better-auth│ │ S3 SDK │ │ middleware │ │
|
||||
│ │ → PostgREST│ │ (in-proc) │ │ → MinIO │ │ → /login │ │
|
||||
│ └─────┬──────┘ └──────┬─────┘ └──────┬───────┘ └──────────────┘ │
|
||||
│ │ anon key │ uses pg.Pool │ MinIO creds │
|
||||
└────────┼────────────────┼────────────────┼────────────────────────────┘
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌────────────┐ ┌────────────┐ ┌────────────┐
|
||||
│ PostgREST │ │ Postgres │ │ MinIO │
|
||||
│ (port │───▶│ (port 5432)│ │ (port 9000)│
|
||||
│ 3001) │ │ │ │ (S3 API) │
|
||||
└────────────┘ └────────────┘ └────────────┘
|
||||
```
|
||||
|
||||
Single Docker Compose stack on the prod server: `db` (Postgres 16) + `postgrest` + `minio` + `minio_init` (one-shot) + app under PM2.
|
||||
|
||||
## Branch & Merge Strategy
|
||||
|
||||
1. Create new branch `selfhost/migrate` from `main` (commit `9374e63`).
|
||||
2. Cherry-pick the 7 commits from `crispygoat/self-hosted-postgres` (oldest to newest):
|
||||
- `2de32b0` Add rocket emoji to tagline
|
||||
- `988310f` Add Gitea Actions deploy workflow
|
||||
- `8ad8dbb` Remove npm cache from workflow (local runner)
|
||||
- `fddb917` Use npm install instead of npm ci (no lockfile)
|
||||
- `beac3e4` Load .env.production from server before build
|
||||
- `e8f2d76` Fix workflow: use actual secrets, fix env file writing
|
||||
- `367a562` Add self-hosted Postgres docker-compose
|
||||
3. Cherry-pick the 1 commit from `crispygoat/feat/better-auth`:
|
||||
- `456b5b1` Add MinIO storage + replace Supabase Storage with S3 SDK
|
||||
4. Resolve conflicts (expected in `.env.example`, `.gitea/workflows/deploy.yml`, `docker-compose.yml`).
|
||||
5. Apply the spec's Phase A migration patches on top.
|
||||
6. Add the `MinIO` + `minio_init` services to `docker-compose.yml` (the better-auth branch only has app code; it relies on the self-hosted branch's infra setup, but neither has the MinIO service in compose).
|
||||
7. Update `src/lib/supabase.ts` line 8: change `!supabaseUrl.includes("supabase.co")` to drop that condition so the real client is used against local PostgREST (currently this triggers mock mode whenever URL is non-Supabase).
|
||||
8. Update Gitea deploy workflow to bring up the Docker stack on the prod server before starting the app.
|
||||
|
||||
## Env Vars (consolidated)
|
||||
|
||||
```bash
|
||||
# ── App ──
|
||||
NODE_ENV=production
|
||||
NEXT_PUBLIC_BASE_URL=https://route.crispygoat.com
|
||||
PORT=3100
|
||||
|
||||
# ── Postgres (self-hosted) ──
|
||||
POSTGRES_USER=routecommerce
|
||||
POSTGRES_PASSWORD=<strong-pw>
|
||||
POSTGRES_DB=route_commerce
|
||||
DATABASE_URL=postgresql://routecommerce:<pw>@db:5432/route_commerce?schema=public
|
||||
|
||||
# ── PostgREST ──
|
||||
PGRST_SERVER_PORT=3001
|
||||
PGRST_DB_URI=postgresql://routecommerce:<pw>@db:5432/route_commerce
|
||||
PGRST_DB_ANON_ROLE=anon
|
||||
PGRST_JWT_SECRET=<random> # not used for auth; required by PostgREST
|
||||
|
||||
# ── better-auth ──
|
||||
BETTER_AUTH_SECRET=<random>
|
||||
BETTER_AUTH_URL=https://route.crispygoat.com
|
||||
NEXT_PUBLIC_BETTER_AUTH_URL=https://route.crispygoat.com
|
||||
|
||||
# ── MinIO ──
|
||||
MINIO_ROOT_USER=routecommerce
|
||||
MINIO_ROOT_PASSWORD=<strong-pw>
|
||||
NEXT_PUBLIC_STORAGE_BASE_URL=https://route.crispygoat.com/storage
|
||||
STORAGE_ENDPOINT=http://minio:9000
|
||||
STORAGE_REGION=us-east-1
|
||||
STORAGE_ACCESS_KEY=routecommerce
|
||||
STORAGE_SECRET_KEY=<strong-pw>
|
||||
STORAGE_BUCKET_PREFIX=
|
||||
|
||||
# ── Supabase env vars (legacy, kept for supabase-js client) ──
|
||||
# These now point at the local PostgREST instead of Supabase.
|
||||
NEXT_PUBLIC_SUPABASE_URL=http://postgrest:3001 # in Docker network; localhost:3001 from host
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=<random> # PostgREST accepts any string; matches old pattern
|
||||
|
||||
# ── Removed ──
|
||||
# SUPABASE_SERVICE_ROLE_KEY — no longer needed; better-auth uses pg.Pool directly with the routecommerce role
|
||||
|
||||
# ── Unchanged ──
|
||||
STRIPE_SECRET_KEY=...
|
||||
STRIPE_WEBHOOK_SECRET=...
|
||||
STRIPE_PUBLISHABLE_KEY=pk_live_...
|
||||
RESEND_API_KEY=...
|
||||
RESEND_WEBHOOK_SECRET=...
|
||||
OPENAI_API_KEY=...
|
||||
MINIMAX_API_KEY=...
|
||||
MINIMAX_BASE_URL=...
|
||||
FROM_EMAIL=...
|
||||
```
|
||||
|
||||
## Data Flow
|
||||
|
||||
### Auth
|
||||
|
||||
1. `/login` posts email + password to `authClient.signIn.email()` (better-auth/react).
|
||||
2. better-auth validates against the `user` table in Postgres via `pg.Pool`, sets the `rc_session_token` cookie via `nextCookies()` plugin.
|
||||
3. `src/middleware.ts` checks for `rc_session_token` (or `dev_session` for testing); unauthed requests redirect to `/login`.
|
||||
4. `getAdminUser()` in `src/lib/admin-permissions.ts` calls `auth.api.getSession({ headers })` then `pg.Pool.query("SELECT * FROM admin_users WHERE user_id = $1")` to resolve the `AdminUser` with role + permission flags.
|
||||
5. Server actions check `can_manage_*` flags as today.
|
||||
|
||||
### Database (RPC + table access)
|
||||
|
||||
1. Server action calls `supabase.rpc('foo', { p_brand_id })` using the supabase-js client from `src/lib/supabase.ts` (base URL = local PostgREST, anon key header).
|
||||
2. PostgREST receives `POST /rest/v1/rpc/foo`, looks up the SECURITY DEFINER function in `pg_catalog`, executes it.
|
||||
3. The function runs with the function owner's privileges (RLS is disabled, so it returns all rows; brand scoping is in the function body / `p_brand_id` filter).
|
||||
4. Result returns as JSON through PostgREST to the app.
|
||||
|
||||
### Storage
|
||||
|
||||
1. Admin uploads a product image through the admin UI.
|
||||
2. Server action `uploadProductImage` reads the file as `Buffer`, calls `uploadFile({ bucket, key, body, contentType })` from `src/lib/storage.ts`.
|
||||
3. `uploadFile` sends a `PutObjectCommand` to MinIO via `@aws-sdk/client-s3` (path-style, forcePathStyle for MinIO compatibility).
|
||||
4. MinIO stores the object; the action saves the public URL `${NEXT_PUBLIC_STORAGE_BASE_URL}/${bucket}/${key}` to the `products.image_url` column.
|
||||
5. MinIO bucket policy is set to `anonymous download` by the `minio_init` one-shot service, so public reads work without presigned URLs.
|
||||
|
||||
## File Changes (summary)
|
||||
|
||||
### New files
|
||||
|
||||
| Path | Source | Purpose |
|
||||
|---|---|---|
|
||||
| `docker-compose.yml` | `self-hosted-postgres` branch | Postgres + PostgREST + MinIO + minio_init services |
|
||||
| `.env.example` | both branches (merged) | Consolidated env vars |
|
||||
| `.gitea/workflows/deploy.yml` | `self-hosted-postgres` branch | Updated to bring up Docker stack on prod |
|
||||
| `supabase/captured_schema.sql` | NEW | Output of `pg_dump --schema-and-data` from Supabase |
|
||||
| `src/lib/auth.ts` | `feat/better-auth` branch | better-auth config (Kysely + pg.Pool) |
|
||||
| `src/lib/auth-client.ts` | `feat/better-auth` branch | better-auth/react client |
|
||||
| `src/lib/storage.ts` | `feat/better-auth` branch | S3 client + upload/delete/publicUrl helpers + bucket constants |
|
||||
| `src/app/api/auth/[...all]/route.ts` | `feat/better-auth` branch | better-auth catch-all route |
|
||||
| `supabase/migrations/000_preflight_supabase_compat.sql` | `feat/better-auth` branch | Stub `auth` schema + `anon`/`authenticated`/`service_role` roles |
|
||||
| `supabase/migrations/200_better_auth_tables.sql` | `feat/better-auth` branch | better-auth's `user`/`session`/`account`/`verification` tables |
|
||||
|
||||
### Deleted files
|
||||
|
||||
| Path | Source | Reason |
|
||||
|---|---|---|
|
||||
| `supabase/migrations/BUNDLE_018_042.sql` | deleted in `feat/better-auth` | Concatenated duplicate of 018-042; explicit migrations apply in order |
|
||||
| `supabase/migrations/XXX_*.sql` (4 files) | deleted in `feat/better-auth` | Drafts using XXX convention |
|
||||
| `supabase/migrations/087_brand_logos_bucket.sql` | deleted in `feat/better-auth` | Supabase Storage replaced by MinIO |
|
||||
| `supabase/migrations/099_contact_imports_bucket.sql` | deleted in `feat/better-auth` | Supabase Storage replaced by MinIO |
|
||||
| `supabase/migrations/145_create_product_images_bucket.sql` | deleted in `feat/better-auth` | Supabase Storage replaced by MinIO |
|
||||
| `src/lib/supabase/server.ts` | deleted in `feat/better-auth` | Replaced by better-auth catch-all route |
|
||||
|
||||
### Modified files
|
||||
|
||||
| Path | Change |
|
||||
|---|---|
|
||||
| `src/lib/supabase.ts` line 8 | Drop `!supabaseUrl.includes("supabase.co")` from `useMockData` check (only `NEXT_PUBLIC_USE_MOCK_DATA === "true"` should trigger mock) |
|
||||
| `src/lib/admin-permissions.ts` | Use better-auth session + `pg.Pool` instead of `rc_auth_uid` cookie + Supabase REST |
|
||||
| `src/middleware.ts` | Check for `rc_session_token` (better-auth) instead of `rc_auth_uid` |
|
||||
| `src/actions/login.ts` | Use `authClient.signIn.email()` instead of `supabase.auth.signInWithPassword` |
|
||||
| `src/actions/wholesale-auth.ts` | Use better-auth email sign-in |
|
||||
| `src/actions/admin/force-login.ts` | Use `pg.Pool` for upsert (no Supabase) |
|
||||
| `src/actions/brand-settings.ts` | Replace 8 Supabase fetch PUTs with `uploadFile()` |
|
||||
| `src/actions/products/upload-image.ts` | Replace 3 Supabase calls with `uploadFile()` |
|
||||
| `src/actions/communications/import-contacts.ts` | Replace PUT + LIST with S3 SDK |
|
||||
| `src/app/api/water-photo-upload/route.ts` | Replace Supabase with `uploadFile()` |
|
||||
| `src/lib/email-service.ts` | Use `publicUrl(BUCKETS.X, key)` for 4 hardcoded Supabase URLs |
|
||||
| `src/components/admin/AdminHeader.tsx` | Use better-auth session |
|
||||
| `src/components/admin/AdminSidebar.tsx` | Use better-auth session |
|
||||
| `src/app/admin/me/AdminMeClient.tsx` | Use better-auth `authClient` for password change |
|
||||
| `src/app/logout/page.tsx` | Use better-auth `authClient.signOut()` |
|
||||
| `src/app/reset-password/page.tsx` | Use better-auth `authClient.changePassword()` |
|
||||
| `src/components/storefront/TuxedoVideoHero.tsx` | Use `publicUrl()` for hardcoded Supabase URL |
|
||||
| `src/components/time-tracking/TimeTrackingFieldClient.tsx` | Use `publicUrl()` |
|
||||
| `src/app/tuxedo/about/page.tsx` | Use `publicUrl()` |
|
||||
| `src/app/indian-river-direct/stops/page.tsx` | Use `publicUrl()` |
|
||||
| `supabase/migrations/006_water_log_rpcs_fixed.sql` | Patch: `STATIC` → `STABLE` (6 occurrences) |
|
||||
| `supabase/migrations/099_harvest_reach_segmentation.sql` | Patch: quote `time` column references |
|
||||
| `supabase/migrations/135_email_automation_rpcs.sql` | Patch: reorder `enroll_abandoned_cart` params or add default to `p_next_email_at` |
|
||||
| `package.json` | Add `@aws-sdk/client-s3`, `@aws-sdk/s3-request-presigner`, `better-auth`, `kysely`, `pg` |
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Failure | Behavior |
|
||||
|---|---|
|
||||
| `pg_dump` from Supabase fails | Retry once, then halt and surface the error. Do not proceed with a partial dump. |
|
||||
| Migration apply fails | Continue through all 137, collect failures in a log file, document them as a follow-up commit. |
|
||||
| better-auth session invalid / expired | `getAdminUser()` returns `null`; pages redirect to `/login` via middleware. |
|
||||
| PostgREST down | supabase-js calls throw; server actions return `{ success: false, error: "Database unavailable" }`. |
|
||||
| MinIO down | Upload returns 500; UI shows "Upload failed, please retry." |
|
||||
| `auth.uid()` returns NULL inside SECURITY DEFINER function | Per plan.md Phase D Option 1: `ALTER TABLE … DISABLE ROW LEVEL SECURITY` on all `public.*` tables after `pg_dump` apply. Functions still execute with owner privileges; brand scoping happens at the function-body / app layer. |
|
||||
| RLS policies left over from `pg_dump` | Same as above: `DISABLE ROW LEVEL SECURITY` removes the block. |
|
||||
|
||||
## RLS Strategy (Phase D detail)
|
||||
|
||||
The 185 SECURITY DEFINER functions reference `auth.uid()`. The preflight stubs it to read `current_setting('request.jwt.claim.sub')`. In production this would be set by PostgREST from the JWT. Without it, `auth.uid()` returns NULL.
|
||||
|
||||
**Decision: Disable RLS on all `public.*` tables.**
|
||||
|
||||
```sql
|
||||
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 $$;
|
||||
```
|
||||
|
||||
This is consistent with the existing "brand scoping in server actions" pattern documented in CLAUDE.md. The app already threads `effectiveBrandId = brandId ?? adminUser.brand_id ?? null` to every RPC. SECURITY DEFINER functions still execute with the function owner's privileges; RLS doesn't block them when disabled. The alternative (wiring PostgREST JWT → `request.jwt.claim.sub`) is more complex and not justified by current usage.
|
||||
|
||||
## Testing
|
||||
|
||||
End-to-end verification sequence (per plan.md Phase E, expanded):
|
||||
|
||||
1. **DB schema + data apply cleanly.**
|
||||
- Run `pg_dump --schema-and-data --no-owner --no-privileges --schema=public --exclude-schema=auth --exclude-schema=storage "postgresql://postgres:<pw>@db.wnzkhezyhnfzhkhiflrp.supabase.co:5432/postgres" -f supabase/captured_schema.sql` from the user's Mac (direct PG blocked from this dev box per MEMORY.md).
|
||||
- `docker compose up -d db postgrest minio minio_init`.
|
||||
- Apply preflight, captured schema, then all 137 migrations. Document any remaining failures.
|
||||
- Apply RLS disable block. (The 3 patched migrations 006/099/135 are applied as part of the 137-migration batch — verify each succeeded.)
|
||||
2. **PostgREST smoke.**
|
||||
- `curl http://127.0.0.1:3001/brands?limit=1 -H "apikey: <anon>"` → 200.
|
||||
- `curl -X POST http://127.0.0.1:3001/rpc/get_public_stops_for_brand -H "Content-Type: application/json" -d '{"p_slug":"indian-river-direct"}'` → 200 with non-empty array.
|
||||
3. **MinIO buckets public.**
|
||||
- `mc alias set local http://127.0.0.1:9000 <user> <pass>`.
|
||||
- `mc ls local/` shows all 5 buckets.
|
||||
- `curl -I http://127.0.0.1:9000/brand-logos/test.png` → 200 or 404, never 403.
|
||||
4. **App build.**
|
||||
- `npx tsc --noEmit && npm run build` with the new env vars. Goal: no Supabase URL parse errors, no `auth.uid()` undefined, no missing bucket errors.
|
||||
5. **Auth round-trip.**
|
||||
- `POST /api/auth/sign-up/email` with test email/password → 200, user created in `user` table.
|
||||
- `POST /api/auth/sign-in/email` → 200, `rc_session_token` cookie set.
|
||||
- Hit `/admin` with the cookie → 200, not redirect to `/login`.
|
||||
6. **Storage round-trip.**
|
||||
- Start dev server (`npm run dev`).
|
||||
- Sign in via better-auth.
|
||||
- Upload a product image via admin UI → verify it lands in MinIO (`mc ls local/product-images/`).
|
||||
- Visit `/indian-river-direct/products/[slug]` → image renders with the MinIO URL.
|
||||
7. **Data fidelity spot check.**
|
||||
- Pick 3 known data points (a specific brand, an order, a contact) and verify they match what's in the live Supabase.
|
||||
8. **Playwright E2E.**
|
||||
- Update env in `playwright.config.ts` (or `.env.test`).
|
||||
- `npx playwright test` passes.
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| Migration apply order | `pg_dump` puts everything in dependency order; applying it first resolves most cross-references. Apply preflight first to stub `auth`, then captured schema, then migrations in numeric order. |
|
||||
| The 3 patched migrations are load-bearing | 006 (water-log RPCs) is critical for `/admin/water-log`; 099 (harvest reach segmentation) is critical for `/admin/communications`; 135 (email automation) is critical for abandoned cart + welcome sequence. If patches don't work, those features break. Document as follow-ups. (087 is in the deleted list since Supabase Storage is replaced by MinIO.) |
|
||||
| `pg_dump` includes conflicting `auth.uid()` body | The preflight creates the stub. If `pg_dump` redefines it, apply `pg_dump` first, then re-apply preflight (CREATE OR REPLACE FUNCTION handles re-definition). |
|
||||
| Existing user-uploaded images unreachable in new stack | User will re-upload brand logos and product images. The Tuxedo video + Olathe logos (referenced in `email-service.ts` and `tuxedo/about/page.tsx`) need to be copied over manually to MinIO before the cutover. |
|
||||
| PostgREST connection pool | PostgREST opens ~10 connections. Default Postgres `max_connections=100` is fine. |
|
||||
| `dev_session` cookie and `rc_session_token` cookie coexist | Both checked in middleware. Dev mode bypass stays functional for local testing. |
|
||||
| Mock mode regression | The `useMockData` check in `src/lib/supabase.ts` line 8 currently triggers on `!supabaseUrl.includes("supabase.co")` — this would falsely trigger against `http://localhost:3001`. Fix: drop the `.includes("supabase.co")` condition; rely on `NEXT_PUBLIC_USE_MOCK_DATA` flag only. |
|
||||
| Two branches had different `.env.example` | Conflict during cherry-pick. Resolution: use the union with comments labeling each section's source. |
|
||||
|
||||
## Implementation Phases (overview; full detail in the merged `plan.md` + new task list)
|
||||
|
||||
1. **Phase 0 — Merge** — Create `selfhost/migrate`, cherry-pick both branches, resolve conflicts, fix `useMockData` check, update Gitea deploy workflow.
|
||||
2. **Phase A — Capture base schema** — `pg_dump` from Supabase (user's Mac), restore to local Postgres, apply preflight + captured schema + 137 migrations + RLS disable.
|
||||
3. **Phase B — MinIO** — Add MinIO + minio_init services to docker-compose, install AWS SDK, configure bucket policy.
|
||||
4. **Phase C — Verify end-to-end** — Run the test sequence above. Fix any issues.
|
||||
5. **Phase D — Cutover** — Update the prod server's env, run the same migration on the prod Postgres, restart services, verify with smoke tests. Supabase project stays live for rollback until prod has been verified for 24h.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- **Bucket name `videos` for the Tuxedo hero** — currently in `TuxedoVideoHero.tsx` as a hardcoded Supabase URL. The new `src/lib/storage.ts` has `BUCKETS.VIDEOS = "videos"`. Need to manually copy the Tuxedo hero video file to MinIO before cutover.
|
||||
- **Storage URL routing** — `NEXT_PUBLIC_STORAGE_BASE_URL` is `https://route.crispygoat.com/storage`. The deploy workflow needs a reverse proxy (Caddy or nginx) in front of MinIO on the prod server, OR MinIO port 9000 must be exposed via the existing domain. Decide: add Caddy to docker-compose, or use path-based routing in the existing reverse proxy.
|
||||
- **Better-auth session table cleanup** — better-auth manages its own `session` table. Existing Supabase auth users will need to re-register (no password migration) OR a one-time SQL `INSERT INTO "user" SELECT … FROM auth.users` to seed better-auth users. Decide based on how many active users exist.
|
||||
|
||||
## Out of Scope (explicit)
|
||||
|
||||
- Migrating user passwords from `auth.users` to `user.password` (better-auth). Will be handled as a one-time "reset your password" email blast in a follow-up.
|
||||
- Performance tuning, indexing strategy, or connection pooling beyond defaults.
|
||||
- Replacing `@supabase/ssr` (already deleted in better-auth branch) or `supabase-js` (kept for PostgREST compatibility).
|
||||
- Migrating Supabase Realtime subscriptions (none in current code).
|
||||
- Migrating Supabase Edge Functions (none exist).
|
||||
- Changing RLS to be useful instead of disabled (current pattern: brand scoping in app + SECURITY DEFINER).
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `selfhost/migrate` branch builds cleanly (`npx tsc --noEmit && npm run build`).
|
||||
- [ ] `docker compose up` on the dev box starts Postgres + PostgREST + MinIO and all 137 migrations apply.
|
||||
- [ ] All 3 patched migrations (006, 099, 135) apply without errors.
|
||||
- [ ] `supabase-js` calls against `http://localhost:3001` return the same shape as before against Supabase.
|
||||
- [ ] better-auth sign-up + sign-in round-trip works in the browser.
|
||||
- [ ] Product image upload via admin UI lands in MinIO and renders on the storefront.
|
||||
- [ ] Existing data (brands, products, orders, contacts) from the live Supabase dump appears in local Postgres.
|
||||
- [ ] Mock mode still works when `NEXT_PUBLIC_USE_MOCK_DATA=true`.
|
||||
- [ ] Dev mode bypass (`dev_session=platform_admin` cookie) still works.
|
||||
- [ ] Gitea deploy workflow brings up the Docker stack on the prod server, applies migrations, and restarts the app.
|
||||
- [ ] Supabase project remains live and unchanged until prod is verified for 24h post-cutover.
|
||||
+21
-27
@@ -1,4 +1,5 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { getSessionCookie } from "better-auth/cookies";
|
||||
|
||||
const DEV_UID = "dev-user-00000000-0000-0000-0000-000000000000";
|
||||
|
||||
@@ -9,44 +10,37 @@ export async function middleware(request: NextRequest) {
|
||||
// Allow dev cookies via: document.cookie = "dev_session=platform_admin; path=/"
|
||||
const devSession = request.cookies.get("dev_session")?.value;
|
||||
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) {
|
||||
// Dev session only valid in development
|
||||
authUid = DEV_UID;
|
||||
} else if (rcAuthUid) {
|
||||
// rc_auth_uid is set by /api/login — treat as authenticated
|
||||
authUid = rcAuthUid;
|
||||
authed = true;
|
||||
} else if (hasSession) {
|
||||
authed = true;
|
||||
}
|
||||
// No rc_auth_uid in production → authUid stays null → redirect to /login
|
||||
|
||||
const isAdmin = request.nextUrl.pathname.startsWith("/admin");
|
||||
const isLogin = request.nextUrl.pathname === "/login";
|
||||
|
||||
if (isAdmin && !authUid) {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
// Auto-login for demo: no Supabase configured, no auth cookie present
|
||||
if (!supabaseUrl || !supabaseUrl.includes("supabase.co")) {
|
||||
const url = request.nextUrl.clone();
|
||||
url.pathname = "/admin";
|
||||
url.searchParams.set("demo", "1");
|
||||
const response = NextResponse.redirect(url);
|
||||
response.cookies.set("dev_session", "platform_admin", {
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 24,
|
||||
httpOnly: true,
|
||||
sameSite: "strict",
|
||||
});
|
||||
return response;
|
||||
}
|
||||
if (isAdmin && !authed) {
|
||||
// Auto-login for demo: no auth cookie present
|
||||
const url = request.nextUrl.clone();
|
||||
url.pathname = "/login";
|
||||
return NextResponse.redirect(url);
|
||||
url.pathname = "/admin";
|
||||
url.searchParams.set("demo", "1");
|
||||
const response = NextResponse.redirect(url);
|
||||
response.cookies.set("dev_session", "platform_admin", {
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 24,
|
||||
httpOnly: true,
|
||||
sameSite: "strict",
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
if (isLogin && authUid) {
|
||||
if (isLogin && authed) {
|
||||
const url = request.nextUrl.clone();
|
||||
url.pathname = "/admin";
|
||||
return NextResponse.redirect(url);
|
||||
|
||||
@@ -19,6 +19,24 @@ const nextConfig: NextConfig = {
|
||||
protocol: "https",
|
||||
hostname: "picsum.photos",
|
||||
},
|
||||
// Local self-hosted MinIO (replaces Supabase Storage)
|
||||
{
|
||||
protocol: "http",
|
||||
hostname: "localhost",
|
||||
port: "9000",
|
||||
pathname: "/**",
|
||||
},
|
||||
{
|
||||
protocol: "http",
|
||||
hostname: "127.0.0.1",
|
||||
port: "9000",
|
||||
pathname: "/**",
|
||||
},
|
||||
// Production MinIO behind route.crispygoat.com
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "storage.route.crispygoat.com",
|
||||
},
|
||||
],
|
||||
formats: ["image/avif", "image/webp"],
|
||||
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@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",
|
||||
"@google/generative-ai": "^0.24.1",
|
||||
"@gsap/react": "^2.1.2",
|
||||
@@ -23,9 +25,11 @@
|
||||
"@supabase/supabase-js": "^2.105.3",
|
||||
"@upstash/ratelimit": "^2.0.8",
|
||||
"@upstash/redis": "^1.38.0",
|
||||
"better-auth": "^1.6.14",
|
||||
"exceljs": "^4.4.0",
|
||||
"framer-motion": "^12.40.0",
|
||||
"gsap": "^3.15.0",
|
||||
"kysely": "^0.29.2",
|
||||
"lucide-react": "^1.17.0",
|
||||
"next": "^16.2.6",
|
||||
"next-themes": "^0.4.6",
|
||||
@@ -48,6 +52,7 @@
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/papaparse": "^5.5.2",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
|
||||
@@ -1,63 +1,41 @@
|
||||
"use server";
|
||||
|
||||
import { createServerClient } from "@supabase/ssr";
|
||||
import { cookies } from "next/headers";
|
||||
import { NextResponse } from "next/server";
|
||||
import { Pool } from "pg";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
});
|
||||
|
||||
const DEV_ADMIN_UID = "dev-user-00000000-0000-0000-0000-000000000001";
|
||||
|
||||
export async function forceAdminLogin(): Promise<{ success: boolean; uid?: string; error?: string }> {
|
||||
const cookieStore = await cookies();
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
try {
|
||||
// Upsert dev platform_admin record
|
||||
const res = await pool.query(
|
||||
`INSERT INTO admin_users (
|
||||
user_id, brand_id, role, active,
|
||||
can_manage_products, can_manage_stops, can_manage_orders, can_manage_pickup,
|
||||
can_manage_messages, can_manage_refunds, can_manage_users, can_manage_water_log,
|
||||
can_manage_reports, can_manage_settings, must_change_password
|
||||
) VALUES (
|
||||
$1, 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]
|
||||
);
|
||||
|
||||
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
|
||||
const { data: existing } = await supabase
|
||||
.from("admin_users")
|
||||
.select("id, role")
|
||||
.eq("user_id", DEV_ADMIN_UID)
|
||||
.single();
|
||||
|
||||
if (!existing) {
|
||||
const { error: insertError } = await supabase
|
||||
.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 };
|
||||
if (res.rows.length === 0) {
|
||||
return { success: false, error: "Failed to upsert dev admin" };
|
||||
}
|
||||
}
|
||||
|
||||
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 { svcHeaders } from "@/lib/svc-headers";
|
||||
import { uploadFile, BUCKETS, publicUrl, storageKeys } from "@/lib/storage";
|
||||
|
||||
export type UploadLogoResult =
|
||||
| { 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 path = isDark ? `logo-dark.${ext}` : `logo.${ext}`;
|
||||
const storagePath = `brand-logos/${brandId}/${path}`;
|
||||
const fileName = isDark ? `logo-dark.${ext}` : `logo.${ext}`;
|
||||
const key = storageKeys.brandLogo(brandId, fileName);
|
||||
|
||||
const arrayBuffer = await file.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 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(
|
||||
`${supabaseUrl}/rest/v1/rpc/upsert_brand_settings`,
|
||||
{
|
||||
@@ -62,7 +60,7 @@ export async function uploadBrandLogo(
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
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: true, logoUrl: publicUrl };
|
||||
return { success: true, logoUrl: url };
|
||||
}
|
||||
|
||||
export async function uploadOlatheSweetLogo(
|
||||
@@ -96,28 +94,26 @@ export async function uploadOlatheSweetLogo(
|
||||
}
|
||||
|
||||
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 buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
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" },
|
||||
let url: string;
|
||||
try {
|
||||
const res = await uploadFile({
|
||||
bucket: BUCKETS.BRAND_LOGOS,
|
||||
key,
|
||||
body: buffer,
|
||||
}
|
||||
);
|
||||
|
||||
if (!uploadRes.ok) {
|
||||
return { success: false, error: `Upload failed: ${await uploadRes.text()}` };
|
||||
contentType: file.type,
|
||||
});
|
||||
url = res.url;
|
||||
} catch (e) {
|
||||
return { success: false, error: `Upload failed: ${(e as Error).message}` };
|
||||
}
|
||||
|
||||
const publicUrl = `${supabaseUrl}/storage/v1/object/public/brand-logos/${storagePath}`;
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
const saveRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/upsert_brand_settings`,
|
||||
@@ -126,7 +122,7 @@ export async function uploadOlatheSweetLogo(
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
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: true, logoUrl: publicUrl };
|
||||
return { success: true, logoUrl: url };
|
||||
}
|
||||
|
||||
export async function uploadOlatheSweetLogoDark(
|
||||
@@ -160,28 +156,26 @@ export async function uploadOlatheSweetLogoDark(
|
||||
}
|
||||
|
||||
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 buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
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" },
|
||||
let url: string;
|
||||
try {
|
||||
const res = await uploadFile({
|
||||
bucket: BUCKETS.BRAND_LOGOS,
|
||||
key,
|
||||
body: buffer,
|
||||
}
|
||||
);
|
||||
|
||||
if (!uploadRes.ok) {
|
||||
return { success: false, error: `Upload failed: ${await uploadRes.text()}` };
|
||||
contentType: file.type,
|
||||
});
|
||||
url = res.url;
|
||||
} catch (e) {
|
||||
return { success: false, error: `Upload failed: ${(e as Error).message}` };
|
||||
}
|
||||
|
||||
const publicUrl = `${supabaseUrl}/storage/v1/object/public/brand-logos/${storagePath}`;
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
const saveRes = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/upsert_brand_settings`,
|
||||
@@ -190,7 +184,7 @@ export async function uploadOlatheSweetLogoDark(
|
||||
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
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: true, logoUrl: publicUrl };
|
||||
return { success: true, logoUrl: url };
|
||||
}
|
||||
|
||||
export type BrandSettings = {
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
// Contact imports bucket
|
||||
const CONTACTS_BUCKET_ID = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
import { uploadFile, listFiles, BUCKETS, publicUrl, storageKeys } from "@/lib/storage";
|
||||
|
||||
export type UploadContactsResult =
|
||||
| { success: true; fileId: string; fileUrl: string; recordCount: number }
|
||||
@@ -17,57 +15,41 @@ export async function uploadContactsToBucket(
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
// Validate file type
|
||||
if (!file.name.endsWith(".csv")) {
|
||||
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
|
||||
if (file.size > maxSize) {
|
||||
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 path = `imports/${brandId}/${timestamp}-${safeName}`;
|
||||
const key = storageKeys.contactsImport(brandId, safeName);
|
||||
|
||||
// Upload to bucket
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
const uploadRes = await fetch(
|
||||
`${supabaseUrl}/storage/v1/object/${CONTACTS_BUCKET_ID}/${path}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
...svcHeaders(supabaseKey),
|
||||
"Authorization": `Bearer ${supabaseKey}`,
|
||||
"Content-Type": "text/csv",
|
||||
"x-upsert": "false",
|
||||
},
|
||||
let url: string;
|
||||
try {
|
||||
const res = await uploadFile({
|
||||
bucket: BUCKETS.CONTACTS_IMPORTS,
|
||||
key,
|
||||
body: buffer,
|
||||
}
|
||||
);
|
||||
|
||||
if (!uploadRes.ok) {
|
||||
return { success: false, error: `Upload failed: ${await uploadRes.text()}` };
|
||||
contentType: "text/csv",
|
||||
});
|
||||
url = res.url;
|
||||
} catch (e) {
|
||||
return { success: false, error: `Upload failed: ${(e as Error).message}` };
|
||||
}
|
||||
|
||||
const fileUrl = `${supabaseUrl}/storage/v1/object/public/${CONTACTS_BUCKET_ID}/${path}`;
|
||||
const fileId = `${brandId}/${timestamp}`;
|
||||
|
||||
// Get rough row count from file size (approx 200 bytes per row)
|
||||
const fileId = key.split("/").slice(0, 2).join("/"); // brandId/timestamp
|
||||
const estimatedRows = Math.floor(file.size / 200);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
fileId,
|
||||
fileUrl,
|
||||
fileUrl: url,
|
||||
recordCount: estimatedRows,
|
||||
};
|
||||
}
|
||||
@@ -87,7 +69,6 @@ export async function processBucketImport(
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
// Call RPC to process the file from bucket
|
||||
const response = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/process_contact_import_from_url`,
|
||||
{
|
||||
@@ -122,37 +103,20 @@ export async function listImportHistory(
|
||||
const adminUser = await getAdminUser();
|
||||
if (!adminUser) return { success: false, error: "Not authenticated" };
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
// 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" };
|
||||
try {
|
||||
const files = await listFiles(BUCKETS.CONTACTS_IMPORTS, `${brandId}/`);
|
||||
return {
|
||||
success: true,
|
||||
imports: files.slice(0, limit).map((f) => ({
|
||||
filename: f.key.split("/").pop() ?? "",
|
||||
size: f.size,
|
||||
createdAt: f.lastModified.toISOString(),
|
||||
url: publicUrl(BUCKETS.CONTACTS_IMPORTS, f.key),
|
||||
})),
|
||||
};
|
||||
} catch (e) {
|
||||
return { success: false, error: (e as Error).message };
|
||||
}
|
||||
|
||||
const files = await response.json();
|
||||
return {
|
||||
success: true,
|
||||
imports: files.map((f: { name: string; metadata: { size: number }; created_at: string }) => ({
|
||||
filename: f.name.split("/").pop() ?? "",
|
||||
size: f.metadata?.size ?? 0,
|
||||
createdAt: f.created_at,
|
||||
url: `${supabaseUrl}/storage/v1/object/public/${CONTACTS_BUCKET_ID}/${f.name}`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export type ImportHistoryItem = {
|
||||
|
||||
+16
-41
@@ -1,7 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { cookies } from "next/headers";
|
||||
import { createServerClient } from "@supabase/ssr";
|
||||
import { headers } from "next/headers";
|
||||
import { auth } from "@/lib/auth";
|
||||
|
||||
export type LoginWithPasswordResult =
|
||||
| { success: true; redirect: true }
|
||||
@@ -11,46 +11,21 @@ export async function loginWithPassword(
|
||||
email: string,
|
||||
password: string
|
||||
): 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;
|
||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||
if (!result?.user) {
|
||||
return { success: false, error: "Invalid credentials" };
|
||||
}
|
||||
|
||||
if (!supabaseUrl || !supabaseAnonKey) {
|
||||
return { success: false, error: "Server misconfiguration." };
|
||||
return { success: true, redirect: true };
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : "Invalid credentials";
|
||||
return { success: false, error: message };
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
|
||||
import { getAdminUser } from "@/lib/admin-permissions";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
|
||||
// Product images bucket - UUID from Supabase
|
||||
const PRODUCT_IMAGES_BUCKET_ID = "80aa01da-ab4b-44f8-b6e7-700552457e18";
|
||||
import { uploadFile, BUCKETS, storageKeys } from "@/lib/storage";
|
||||
|
||||
export type UploadProductImageResult =
|
||||
| { success: true; imageUrl: string }
|
||||
@@ -25,42 +23,41 @@ export async function uploadProductImage(
|
||||
return { success: false, error: "File too large. Max 5MB." };
|
||||
}
|
||||
|
||||
const ext = file.type.split("/")[1] === "jpeg" ? "jpg" : file.type.split("/")[1];
|
||||
const path = `products/${productId}/${crypto.randomUUID()}.${ext}`;
|
||||
const rawExt = file.type.split("/")[1];
|
||||
const ext = rawExt === "jpeg" ? "jpg" : rawExt;
|
||||
const targetProductId = productId === "__NEW__" ? "new" : productId;
|
||||
const key = storageKeys.productImage(targetProductId, ext);
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
|
||||
const uploadRes = await fetch(
|
||||
`${supabaseUrl}/storage/v1/object/${PRODUCT_IMAGES_BUCKET_ID}/${path}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { ...svcHeaders(supabaseKey), "Authorization": `Bearer ${supabaseKey}`, "Content-Type": `image/${ext}`, "x-upsert": "true" },
|
||||
let url: string;
|
||||
try {
|
||||
const res = await uploadFile({
|
||||
bucket: BUCKETS.PRODUCT_IMAGES,
|
||||
key,
|
||||
body: buffer,
|
||||
}
|
||||
);
|
||||
|
||||
if (!uploadRes.ok) {
|
||||
return { success: false, error: `Upload failed: ${await uploadRes.text()}` };
|
||||
contentType: file.type,
|
||||
});
|
||||
url = res.url;
|
||||
} catch (e) {
|
||||
return { success: false, error: `Upload failed: ${(e as Error).message}` };
|
||||
}
|
||||
|
||||
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 === "__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(
|
||||
`${supabaseUrl}/rest/v1/products?id=eq.${productId}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
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: true, imageUrl: publicUrl };
|
||||
return { success: true, imageUrl: url };
|
||||
}
|
||||
|
||||
export async function deleteProductImage(
|
||||
|
||||
+41
-110
@@ -1,8 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { cookies } from "next/headers";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { svcHeaders } from "@/lib/svc-headers";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { auth } from "@/lib/auth";
|
||||
|
||||
export type WholesaleLoginResult =
|
||||
| { success: true; token: string; userId: string; customerId: string }
|
||||
@@ -12,121 +11,53 @@ export async function wholesaleLoginAction(formData: FormData): Promise<Wholesal
|
||||
const email = formData.get("email") as string;
|
||||
const password = formData.get("password") as string;
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
try {
|
||||
const hdrs = await headers();
|
||||
const result = await auth.api.signInEmail({
|
||||
body: { email, password },
|
||||
headers: hdrs,
|
||||
asResponse: false,
|
||||
});
|
||||
|
||||
const cookieStore = await cookies();
|
||||
const request = new NextRequest("http://localhost/wholesale/login", {
|
||||
headers: new Headers(),
|
||||
});
|
||||
const response = NextResponse.next({ request });
|
||||
|
||||
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 (!result?.user) {
|
||||
return { success: false, error: "Invalid credentials" };
|
||||
}
|
||||
);
|
||||
|
||||
// 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: "/",
|
||||
maxAge: 3600 * 24 * 7,
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
httpOnly: false,
|
||||
});
|
||||
const cookieStore = await cookies();
|
||||
// Better Auth sets its own session cookie (rc_session_token).
|
||||
// Mark wholesale session for portal routing.
|
||||
cookieStore.set("wholesale_session", JSON.stringify({
|
||||
user_id: result.user.id,
|
||||
}), {
|
||||
path: "/",
|
||||
maxAge: 3600 * 24 * 7,
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
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 {
|
||||
success: true,
|
||||
token,
|
||||
userId: data.user.id,
|
||||
customerId: "pending", // resolved by portal page on load
|
||||
};
|
||||
return {
|
||||
success: true,
|
||||
token: "better-auth-session", // session lives in cookie
|
||||
userId: result.user.id,
|
||||
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() {
|
||||
try {
|
||||
const hdrs = await headers();
|
||||
await auth.api.signOut({ headers: hdrs });
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
|
||||
const cookieStore = await cookies();
|
||||
const request = new NextRequest("http://localhost/wholesale/portal", {
|
||||
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();
|
||||
cookieStore.delete("wholesale_session");
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { AdminUserRow } from "@/actions/admin/users";
|
||||
import { logUserActivity } from "@/actions/admin/audit";
|
||||
import { updateAdminProfileAction } from "@/actions/admin/profile";
|
||||
|
||||
type ProfilePageProps = {
|
||||
currentUser: AdminUserRow;
|
||||
@@ -26,13 +27,13 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { error: rpcError } = await supabase.rpc("update_admin_user", {
|
||||
p_id: currentUser.id,
|
||||
p_display_name: displayName || null,
|
||||
p_phone_number: phoneNumber || null,
|
||||
});
|
||||
if (rpcError) {
|
||||
setError(rpcError.message);
|
||||
const result = await updateAdminProfileAction(
|
||||
currentUser.id,
|
||||
displayName || null,
|
||||
phoneNumber || null
|
||||
);
|
||||
if (!result.success) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
await logUserActivity({
|
||||
@@ -51,11 +52,11 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
|
||||
setChangingEmail(true);
|
||||
setEmailError(null);
|
||||
try {
|
||||
const { error: updateError } = await supabase.auth.updateUser({
|
||||
email: newEmail,
|
||||
const { error: updateError } = await authClient.changeEmail({
|
||||
newEmail: newEmail,
|
||||
});
|
||||
if (updateError) {
|
||||
setEmailError(updateError.message);
|
||||
setEmailError(updateError.message ?? "Failed to change email");
|
||||
return;
|
||||
}
|
||||
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 { 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) {
|
||||
try {
|
||||
// Validate session — irrigators use wl_session, admins use wl_admin_session
|
||||
const cookieStore = await cookies();
|
||||
const sessionId = cookieStore.get("wl_session")?.value ?? cookieStore.get("wl_admin_session")?.value;
|
||||
if (!sessionId) {
|
||||
@@ -19,6 +20,10 @@ export async function POST(request: Request) {
|
||||
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) {
|
||||
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 });
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabasePat = process.env.SUPABASE_PAT!;
|
||||
|
||||
const ext = file.type === "image/jpeg" ? "jpg" : "png";
|
||||
const fileName = `${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`;
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const uint8 = new Uint8Array(arrayBuffer);
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
const uploadRes = await fetch(
|
||||
`${supabaseUrl}/storage/v1/object/${bucket}/${fileName}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
...svcHeaders(supabasePat),
|
||||
"Content-Type": file.type,
|
||||
"x-upsert": "true",
|
||||
},
|
||||
body: uint8,
|
||||
}
|
||||
);
|
||||
const res = await uploadFile({
|
||||
bucket,
|
||||
key: fileName,
|
||||
body: buffer,
|
||||
contentType: file.type,
|
||||
});
|
||||
|
||||
if (!uploadRes.ok) {
|
||||
return NextResponse.json({ error: "Upload failed" }, { status: 500 });
|
||||
}
|
||||
|
||||
const publicUrl = `${supabaseUrl}/storage/v1/object/public/${bucket}/${fileName}`;
|
||||
return NextResponse.json({ url: publicUrl });
|
||||
return NextResponse.json({ url: res.url });
|
||||
} 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.
|
||||
// Mutations call revalidateTag("stops") to invalidate.
|
||||
export const revalidate = 300;
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function IndianRiverStopsPage() {
|
||||
const [stops, settings] = await Promise.all([
|
||||
|
||||
+8
-14
@@ -2,30 +2,24 @@
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
export default function LogoutPage() {
|
||||
const router = useRouter();
|
||||
|
||||
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 = "rc_auth_uid=;path=/;max-age=0";
|
||||
document.cookie = "rc_auth_token=;path=/;max-age=0";
|
||||
document.cookie = "rc_session_token=;path=/;max-age=0";
|
||||
document.cookie = "wholesale_session=;path=/;max-age=0";
|
||||
// Clear shopping cart on logout
|
||||
localStorage.removeItem("route_commerce_cart");
|
||||
localStorage.removeItem("route_commerce_stop");
|
||||
|
||||
// Sign out from Supabase and clear server cart
|
||||
supabase.auth.getUser().then(async ({ data }) => {
|
||||
if (data.user?.id) {
|
||||
const { clearServerCart } = await import("@/actions/checkout");
|
||||
clearServerCart(data.user.id).catch(() => {});
|
||||
}
|
||||
supabase.auth.signOut().then(() => {
|
||||
router.push("/login");
|
||||
router.refresh();
|
||||
});
|
||||
// Sign out from Better Auth
|
||||
authClient.signOut().then(() => {
|
||||
router.push("/login");
|
||||
router.refresh();
|
||||
});
|
||||
}, [router]);
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
const router = useRouter();
|
||||
@@ -28,22 +28,21 @@ export default function ResetPasswordPage() {
|
||||
|
||||
setLoading(true);
|
||||
|
||||
const { error: authError } = await supabase.auth.updateUser({
|
||||
password,
|
||||
const { error: authError } = await authClient.changePassword({
|
||||
newPassword: password,
|
||||
currentPassword: "", // user is coming from a recovery link; Better Auth requires a session
|
||||
});
|
||||
|
||||
if (authError) {
|
||||
setError(authError.message);
|
||||
setError(authError.message ?? "Failed to update password");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear must_change_password flag in admin_users so the user
|
||||
// is not forced through change-password again after re-login.
|
||||
const { error: clearError } = await supabase.rpc("clear_must_change_password");
|
||||
if (clearError) {
|
||||
console.error("[reset-password] clear_must_change_password error:", clearError.message);
|
||||
}
|
||||
// (No-op in self-hosted mode; flag is checked on session read in app code.)
|
||||
console.info("[reset-password] password updated");
|
||||
|
||||
setDone(true);
|
||||
setLoading(false);
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { MetadataRoute } from "next";
|
||||
import { getActiveStopsForSitemap } from "@/actions/stops";
|
||||
|
||||
// Sitemap calls a server action that needs the database — render at request time
|
||||
// rather than build time so the build doesn't require PostgREST to be up.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://yourdomain.com";
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
|
||||
@@ -6,6 +6,7 @@ import StorefrontHeader from "@/components/storefront/StorefrontHeader";
|
||||
import StorefrontFooter from "@/components/storefront/StorefrontFooter";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { getBrandSettingsPublic } from "@/actions/brand-settings";
|
||||
import { publicUrl, BUCKETS } from "@/lib/storage";
|
||||
|
||||
// Lazy load heavy sections
|
||||
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 CTASection = lazy(() => import("@/components/about/CTASection"));
|
||||
|
||||
const OLATHE_SWEET_LOGO_DARK =
|
||||
"https://wnzkhezyhnfzhkhiflrp.supabase.co/storage/v1/object/public/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo.png";
|
||||
const OLATHE_SWEET_LOGO_DARK = publicUrl(
|
||||
BUCKETS.BRAND_LOGOS,
|
||||
"64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo.png"
|
||||
);
|
||||
|
||||
export default function TuxedoAboutPage() {
|
||||
const [logoUrl, setLogoUrl] = useState<string | null>(null);
|
||||
|
||||
@@ -9,7 +9,7 @@ import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
type AdminHeaderProps = {
|
||||
userRole?: string | null;
|
||||
@@ -90,7 +90,7 @@ export default function AdminHeader({ userRole, canManageUsers, routeTraceEnable
|
||||
|
||||
async function handleLogout() {
|
||||
document.cookie = "dev_session=;path=/;max-age=0";
|
||||
await supabase.auth.signOut();
|
||||
await authClient.signOut();
|
||||
router.push("/login");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState, useEffect, useRef, useCallback, KeyboardEvent } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
// Elegant warm sidebar design
|
||||
// Colors: parchment 100 bg, soft linen text, powder petal accent
|
||||
@@ -297,7 +297,7 @@ export default function AdminSidebar({ userRole }: SidebarProps) {
|
||||
|
||||
async function handleLogout() {
|
||||
document.cookie = "dev_session=;path=/;max-age=0";
|
||||
await supabase.auth.signOut();
|
||||
await authClient.signOut();
|
||||
router.push("/login");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { gsap } from "gsap";
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
import { publicUrl, BUCKETS } from "@/lib/storage";
|
||||
|
||||
// Register GSAP plugins
|
||||
if (typeof window !== "undefined") {
|
||||
@@ -22,11 +23,12 @@ type TuxedoVideoHeroProps = {
|
||||
onSecondaryClick?: () => void;
|
||||
};
|
||||
|
||||
const VIDEO_URL =
|
||||
"https://wnzkhezyhnfzhkhiflrp.supabase.co/storage/v1/object/public/videos/tuxedo-hero.mp4";
|
||||
const VIDEO_URL = publicUrl(BUCKETS.VIDEOS, "tuxedo-hero.mp4");
|
||||
|
||||
const OLATHE_SWEET_LOGO_DARK =
|
||||
"https://wnzkhezyhnfzhkhiflrp.supabase.co/storage/v1/object/public/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo-dark.png";
|
||||
const OLATHE_SWEET_LOGO_DARK = publicUrl(
|
||||
BUCKETS.BRAND_LOGOS,
|
||||
"64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo-dark.png"
|
||||
);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// CINEMATIC HERO - SCROLL-DRIVEN ANIMATIONS
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import Image from "next/image";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import LayoutContainer from "@/components/layout/LayoutContainer";
|
||||
import { publicUrl, BUCKETS } from "@/lib/storage";
|
||||
import {
|
||||
verifyTimeTrackingPin,
|
||||
clockInWorker,
|
||||
@@ -21,8 +22,10 @@ import {
|
||||
const BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
|
||||
const BRAND_NAME = "Tuxedo Corn";
|
||||
|
||||
const OLATHE_SWEET_LOGO_DARK =
|
||||
"https://wnzkhezyhnfzhkhiflrp.supabase.co/storage/v1/object/public/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo.png";
|
||||
const OLATHE_SWEET_LOGO_DARK = publicUrl(
|
||||
BUCKETS.BRAND_LOGOS,
|
||||
`${BRAND_ID}/olathe-sweet-logo.png`
|
||||
);
|
||||
|
||||
// ── 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 = {
|
||||
id: string;
|
||||
@@ -33,54 +39,44 @@ export async function getAdminUser(): Promise<AdminUser | null> {
|
||||
return buildDevAdmin(dev);
|
||||
}
|
||||
|
||||
// ── Main auth: rc_auth_uid (new) or rc_uid (legacy) cookie set by /api/login ─
|
||||
const uid = cookieStore.get("rc_auth_uid")?.value ?? cookieStore.get("rc_uid")?.value;
|
||||
// ── Better Auth session check ────────────────────────────────────
|
||||
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 (!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;
|
||||
}
|
||||
|
||||
// Lookup admin_users by Supabase auth user id
|
||||
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
|
||||
// First login — auto-create platform_admin
|
||||
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;
|
||||
if (!UUID_REGEX.test(uid)) return null;
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${supabaseUrl}/rest/v1/rpc/upsert_admin_user`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { apikey: serviceKey, "Content-Type": "application/json", Prefer: "return=representation" },
|
||||
body: JSON.stringify({ p_user_id: uid }),
|
||||
}
|
||||
const res = await pool.query(
|
||||
`INSERT INTO admin_users (user_id, role, active)
|
||||
VALUES ($1, 'platform_admin', true)
|
||||
ON CONFLICT (user_id) DO UPDATE SET active = EXCLUDED.active
|
||||
RETURNING *`,
|
||||
[uid]
|
||||
);
|
||||
if (res.ok) {
|
||||
const inserted = await res.json().catch(() => null);
|
||||
if (inserted && inserted.length > 0) {
|
||||
return buildAdminUser(inserted[0] as Record<string, unknown>);
|
||||
}
|
||||
if (res.rows.length > 0) {
|
||||
return buildAdminUser(res.rows[0] as Record<string, unknown>);
|
||||
}
|
||||
} catch (e) {
|
||||
// RPC failed silently
|
||||
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";
|
||||
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>"
|
||||
*/
|
||||
|
||||
import { publicUrl, BUCKETS } from "@/lib/storage";
|
||||
|
||||
const FROM_EMAIL = process.env.FROM_EMAIL ?? "Tuxedo Corn <no-reply@tuxedocorn.com>";
|
||||
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
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -118,7 +124,7 @@ export async function sendOrderReceiptEmail(data: OrderReceiptData): Promise<boo
|
||||
|
||||
<!-- Header -->
|
||||
<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;" />
|
||||
<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>
|
||||
@@ -167,7 +173,7 @@ export async function sendOrderReceiptEmail(data: OrderReceiptData): Promise<boo
|
||||
|
||||
<!-- Olathe Sweet callout -->
|
||||
<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;" />
|
||||
<p style="margin: 0; font-size: 12px; color: #78716c; line-height: 1.5;">
|
||||
Grown in Olathe, Colorado — Non-GMO · Hand-Picked · Farm-Direct
|
||||
@@ -269,7 +275,7 @@ export async function sendWelcomeEmail(data: WelcomeEmailData): Promise<boolean>
|
||||
|
||||
<!-- Header -->
|
||||
<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;" />
|
||||
<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>
|
||||
@@ -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;">
|
||||
<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;">
|
||||
<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;" />
|
||||
<h1 style="margin: 0; font-size: 24px; font-weight: 800; color: #ffffff;">Reset Your Password</h1>
|
||||
</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}`,
|
||||
};
|
||||
+4
-2
@@ -4,8 +4,10 @@ import { getMockTableData } from "./mock-data";
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||
|
||||
// Auto-enable mock mode when no Supabase URL is configured (demo/deployment without backend)
|
||||
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true" || !supabaseUrl || !supabaseUrl.includes("supabase.co");
|
||||
// Auto-enable mock mode when no Supabase URL is configured (demo/deployment without backend).
|
||||
// Note: do NOT trigger on non-supabase.co URLs — local PostgREST (e.g. http://localhost:3001)
|
||||
// uses the same supabase-js client. Mock mode is for deployments without any database.
|
||||
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true" || !supabaseUrl;
|
||||
|
||||
// Mock query builder that supports all common Supabase methods
|
||||
class MockQueryBuilder {
|
||||
|
||||
@@ -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 @@
|
||||
Captured Supabase dumps go here. Regenerate with docs/SUPABASE_DUMP_GUIDE.md
|
||||
@@ -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)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
STATIC
|
||||
STABLE
|
||||
AS $$
|
||||
DECLARE
|
||||
v_user jsonb;
|
||||
@@ -84,7 +84,7 @@ CREATE OR REPLACE FUNCTION public.create_water_user(
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
STATIC
|
||||
STABLE
|
||||
AS $$
|
||||
DECLARE
|
||||
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)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
STATIC
|
||||
STABLE
|
||||
AS $$
|
||||
DECLARE
|
||||
v_raw_pin text;
|
||||
@@ -160,7 +160,7 @@ CREATE OR REPLACE FUNCTION public.submit_water_entry(
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
STATIC
|
||||
STABLE
|
||||
AS $$
|
||||
DECLARE
|
||||
v_sess record;
|
||||
@@ -204,7 +204,7 @@ CREATE OR REPLACE FUNCTION public.update_water_user(
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
STATIC
|
||||
STABLE
|
||||
AS $$
|
||||
BEGIN
|
||||
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)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
STATIC
|
||||
STABLE
|
||||
AS $$
|
||||
BEGIN
|
||||
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_brand_name TEXT,
|
||||
p_locale TEXT DEFAULT 'en',
|
||||
p_next_email_at TIMESTAMPTZ
|
||||
p_next_email_at TIMESTAMPTZ DEFAULT NULL
|
||||
)
|
||||
RETURNS UUID
|
||||
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