diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ffec24b --- /dev/null +++ b/.env.example @@ -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) --- +# Server actions call `${NEXT_PUBLIC_API_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_API_URL=http://localhost:3001 +NEXT_PUBLIC_API_ANON_KEY=local-postgrest-anon-key +POSTGREST_SERVICE_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 +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= diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..8ee8a39 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -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" diff --git a/.gitignore b/.gitignore index 81d5216..910b49f 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/019e9554-37c8-7752-a9d5-0264371602a9/plan.md b/019e9554-37c8-7752-a9d5-0264371602a9/plan.md new file mode 100644 index 0000000..65827ff --- /dev/null +++ b/019e9554-37c8-7752-a9d5-0264371602a9/plan.md @@ -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//`, 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:@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. diff --git a/README.md b/README.md index f085eb6..423fe90 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/Tuxedo_Corn_2026_Tour_Schedule-3.xlsx b/Tuxedo_Corn_2026_Tour_Schedule-3.xlsx new file mode 100644 index 0000000..12d3273 Binary files /dev/null and b/Tuxedo_Corn_2026_Tour_Schedule-3.xlsx differ diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7b878b0 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/docs/SUPABASE_DUMP_GUIDE.md b/docs/SUPABASE_DUMP_GUIDE.md new file mode 100644 index 0000000..b6cf28f --- /dev/null +++ b/docs/SUPABASE_DUMP_GUIDE.md @@ -0,0 +1,312 @@ +# 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. + +## Post-restore: clean up test brands + +The captured production data includes 3 test brands with hardcoded +sequential UUIDs (`a1b2c3d4-…`, `b2c3d4e5-…`, `c3d4e5f6-…`) created +during the migration work. They have no related rows in any of the 38 +tables that FK to `brands.id`, so deletion is safe (CASCADE / NO ACTION +on zero rows is a no-op). + +```sql +BEGIN; + DELETE FROM brands + WHERE slug IN ('sunrise-farms', 'green-valley', 'orchard-fresh'); + -- expect DELETE 3 +COMMIT; +``` + +Verify the real brands remain: + +```sql +SELECT name, slug, plan_tier FROM brands ORDER BY created_at; +-- Tuxedo Corn | tuxedo | starter +-- Indian River Direct | indian-river-direct | starter +``` + +## Migrating Brand Assets (Supabase Storage → MinIO) + +Brand logos and other Storage files are NOT in the Postgres dump. The +storage layer is now MinIO (S3-compatible) instead of Supabase Storage. + +### Download assets from Supabase + +```bash +# Make sure the target buckets exist in MinIO (one-time) +mc mb --ignore-existing local/brand-logos local/videos \ + local/product-images local/contacts-imports \ + local/water-photos + +# Pull each asset via the public URL. Path is / after the +# /storage/v1/object/public/ prefix in the Supabase URL. +mkdir -p .data/assets +BRAND_ID="" +for fname in logo.png olathe-sweet-logo.png olathe-sweet-logo-dark.png; do + curl -sf -o ".data/assets/$fname" \ + "https://.supabase.co/storage/v1/object/public/brand-logos/$BRAND_ID/$fname" + mc cp ".data/assets/$fname" "local/brand-logos/$BRAND_ID/$fname" +done +``` + +### Point the DB at MinIO (portable /storage/... paths) + +After capture, the `brand_settings.logo_url` etc. values still point at +the Supabase URL. Replace the base with a relative `/storage/` path so +the Next.js rewrite in `next.config.ts` can route to whichever MinIO +endpoint is configured per environment (dev → `localhost:9000`, prod → +`storage.route.crispygoat.com`). + +```sql +UPDATE brand_settings +SET + logo_url = REPLACE(logo_url, 'https://.supabase.co/storage/v1/object/public', '/storage'), + logo_url_dark = REPLACE(logo_url_dark, 'https://.supabase.co/storage/v1/object/public', '/storage'), + olathe_sweet_logo_url = REPLACE(olathe_sweet_logo_url, 'https://.supabase.co/storage/v1/object/public', '/storage'), + olathe_sweet_logo_url_dark = REPLACE(olathe_sweet_logo_url_dark, 'https://.supabase.co/storage/v1/object/public', '/storage'), + hero_image_url = REPLACE(hero_image_url, 'https://.supabase.co/storage/v1/object/public', '/storage'); +``` + +### Why a rewrite instead of pointing at MinIO directly + +Next.js's image optimizer (`/_next/image?url=...`) refuses to fetch +upstream images whose hostname resolves to a private IP. Local MinIO is +on `127.0.0.1` / `::1`, so URLs like `http://localhost:9000/...` get +blocked: + +``` +⨯ upstream image http://localhost:9000/... resolved to private ip + ["::1","127.0.0.1"] +``` + +The rewrite in `next.config.ts` resolves `/storage/*` to the configured +MinIO base URL server-side, so the browser sees a same-origin URL and +the optimizer's private-IP check is bypassed: + +```ts +async rewrites() { + const storageBase = process.env.STORAGE_PUBLIC_URL || "http://localhost:9000"; + return [{ source: "/storage/:path*", destination: `${storageBase}/:path*` }]; +} +``` + +For production, set `STORAGE_PUBLIC_URL` to the public MinIO endpoint +(e.g., `https://storage.route.crispygoat.com`) and the same DB values +work without modification. + +### Hardcoded brand asset URLs in client components + +A few components used `publicUrl(BUCKETS.BRAND_LOGOS, ...)` to build a +MinIO URL at module load (used as a fallback before client-side data +loads). Switch these to `/storage/...` paths so the rewrite covers them: + +- `src/components/storefront/TuxedoVideoHero.tsx` — hero video + Olathe Sweet dark logo +- `src/components/time-tracking/TimeTrackingFieldClient.tsx` — field UI logo +- `src/app/tuxedo/about/page.tsx` — about page logo + +`src/lib/email-service.ts` should keep using `publicUrl(...)` because +Resend fetches the URL server-side from its own network — relative +paths won't work for emails. diff --git a/docs/superpowers/plans/2026-06-05-selfhost-migration-plan.md b/docs/superpowers/plans/2026-06-05-selfhost-migration-plan.md new file mode 100644 index 0000000..d24f060 --- /dev/null +++ b/docs/superpowers/plans/2026-06-05-selfhost-migration-plan.md @@ -0,0 +1,1662 @@ +# Supabase → Self-Hosted Migration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Move Route Commerce off Supabase's hosted platform onto a self-hosted stack (Postgres + PostgREST + MinIO + better-auth) while preserving all data and behavior. Consolidate the two in-flight branches into one coherent `selfhost/migrate` branch. + +**Architecture:** Merge `crispygoat/self-hosted-postgres` (infra: docker-compose, Gitea deploy, env) and `crispygoat/feat/better-auth` (app: better-auth + S3 SDK + admin-permissions rewrite). Capture full schema + data from live Supabase via `pg_dump`. Apply preflight + captured schema + 137 migrations + RLS disable to a local Postgres 16 container. Stand up MinIO + PostgREST alongside. Keep `supabase-js` client (it speaks PostgREST natively) — only swap the base URL. + +**Tech Stack:** Postgres 16 (Docker), PostgREST 12 (Docker), MinIO (Docker), better-auth (Node), Kysely + pg, AWS SDK for JavaScript v3, Next.js 16, Node 22. + +**Spec:** `docs/superpowers/specs/2026-06-05-selfhost-migration-design.md` + +**Working assumptions:** +- Working directory: `/home/coder/.grok/worktrees/kyle-route-commerce-main/2026-06-05-164dcb44` +- Supabase project ref: `wnzkhezyhnfzhkhiflrp` (per CLAUDE.md / MEMORY.md) +- Supabase DB password: in `.env.local` on the user's Mac (not committed); passed via env var to `pg_dump` +- Docker is installed locally; user can run `docker compose` +- Direct PG connection to Supabase works from the user's Mac (not from this dev box, per MEMORY.md) +- Local Postgres: `127.0.0.1:5432`; PostgREST: `127.0.0.1:3001`; MinIO API: `127.0.0.1:9000`; MinIO console: `127.0.0.1:9001` +- Local Postgres credentials: user `routecommerce`, password from local `.env` + +--- + +## Phase 0 — Branch setup and merge + +### Task 0.1: Create the migration branch + +**Files:** none (git only) + +- [ ] **Step 1: Fetch both source branches** + +```bash +cd /home/coder/.grok/worktrees/kyle-route-commerce-main/2026-06-05-164dcb44 +git fetch crispygoat +git fetch origin +``` + +Expected: `crispygoat/self-hosted-postgres` and `crispygoat/feat/better-auth` both updated. + +- [ ] **Step 2: Create and check out the new branch from main** + +```bash +git checkout -b selfhost/migrate main +``` + +Expected: `Switched to a new branch 'selfhost/migrate'`. + +- [ ] **Step 3: Verify branch state** + +```bash +git log --oneline -1 +``` + +Expected: `9374e63 docs(memory): record Codex review round 2 pass`. + +--- + +### Task 0.2: Cherry-pick the self-hosted-postgres branch + +**Files:** cherry-picked from `crispygoat/self-hosted-postgres` + +- [ ] **Step 1: Cherry-pick the 7 commits in chronological order** + +```bash +git cherry-pick 2de32b0 988310f 8ad8dbb fddb917 beac3e4 e8f2d76 367a562 +``` + +Expected: Either all commits apply cleanly, OR you see "CONFLICT" messages. + +- [ ] **Step 2: If conflicts appear, resolve them now** + +Expected conflicts: `.env.example` (will be merged with better-auth env in Task 0.3); `.gitea/workflows/deploy.yml` (updated in Task 0.5); `docker-compose.yml` (extended with PostgREST + MinIO in Task 0.4). + +For each conflict: +1. Open the file. +2. Resolve by taking the union of both sides, with section comments. +3. `git add `. +4. `git cherry-pick --continue`. + +- [ ] **Step 3: Verify all 7 commits applied** + +```bash +git log --oneline -8 +``` + +Expected: top 7 commits match the cherry-pick list, in order, with `9374e63` at position 8. + +- [ ] **Step 4: Verify docker-compose.yml exists and has the db service** + +```bash +grep -A2 "services:" docker-compose.yml +``` + +Expected: shows the `db:` service block (Postgres 16). + +--- + +### Task 0.3: Cherry-pick the feat/better-auth branch + +**Files:** cherry-picked from `crispygoat/feat/better-auth` + +- [ ] **Step 1: Cherry-pick the single commit** + +```bash +git cherry-pick 456b5b1 +``` + +Expected: Either applies cleanly, OR conflicts in `.env.example`, `docker-compose.yml`, or `.gitea/workflows/deploy.yml`. + +- [ ] **Step 2: Resolve conflicts if needed** + +Take the union of both sides. For `.env.example`, organize into sections: + +```bash +# ── Postgres (self-hosted) ── (from self-hosted-postgres) +# ── PostgREST ── (NEW — added in Task 0.4) +# ── better-auth ── (from feat/better-auth) +# ── MinIO ── (NEW — added in Task 0.4) +# ── Supabase env vars (legacy) ── (modified to point to local PostgREST) +``` + +For each conflict: open the file, resolve by union, `git add `, `git cherry-pick --continue`. + +- [ ] **Step 3: Verify the new files exist** + +```bash +ls -la src/lib/auth.ts src/lib/auth-client.ts src/lib/storage.ts \ + src/app/api/auth/\[...all\]/route.ts \ + supabase/migrations/000_preflight_supabase_compat.sql \ + supabase/migrations/200_better_auth_tables.sql +``` + +Expected: all 6 files exist. + +- [ ] **Step 4: Verify the deleted migrations are gone** + +```bash +ls supabase/migrations/BUNDLE_018_042.sql \ + supabase/migrations/087_brand_logos_bucket.sql \ + supabase/migrations/099_contact_imports_bucket.sql \ + supabase/migrations/145_create_product_images_bucket.sql 2>&1 +``` + +Expected: all 4 files report "No such file or directory". + +- [ ] **Step 5: Verify `src/lib/supabase/server.ts` is gone** + +```bash +ls src/lib/supabase/server.ts 2>&1 +``` + +Expected: "No such file or directory". + +--- + +### Task 0.4: Add PostgREST + MinIO services to docker-compose.yml + +**Files:** +- Modify: `docker-compose.yml` + +- [ ] **Step 1: Read current docker-compose.yml** + +```bash +cat docker-compose.yml +``` + +- [ ] **Step 2: Append the PostgREST + MinIO + minio_init services** + +Append to `docker-compose.yml` (before the existing `volumes:` section if present, else at end): + +```yaml + postgrest: + image: postgrest/postgrest:v12.2.3 + container_name: route_commerce_postgrest + restart: unless-stopped + env_file: [.env] + environment: + PGRST_SERVER_PORT: 3001 + PGRST_SERVER_HOST: 0.0.0.0 + PGRST_DB_URI: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} + PGRST_DB_ANON_ROLE: anon + PGRST_DB_SCHEMAS: public + PGRST_OPENAPI_MODE: disabled + PGRST_JWT_SECRET: ${POSTGREST_JWT_SECRET:-dev-secret-not-for-prod} + ports: + - "127.0.0.1:3001:3001" + depends_on: + db: + condition: service_healthy + + 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 + start_period: 10s + + minio_init: + image: minio/mc:latest + container_name: route_commerce_minio_init + depends_on: + minio: + condition: service_healthy + env_file: [.env] + entrypoint: ["/bin/sh", "-c"] + command: + - | + set -e + 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 + echo "MinIO buckets initialized" +``` + +- [ ] **Step 3: Add `minio_data` to the volumes section** + +Find the existing `volumes:` block and add `minio_data`: + +```yaml +volumes: + db_data: + driver: local + minio_data: + driver: local +``` + +- [ ] **Step 4: Verify the YAML parses** + +```bash +docker compose config --quiet +``` + +Expected: exit code 0, no output. + +- [ ] **Step 5: Commit** + +```bash +git add docker-compose.yml +git commit -m "feat(docker): add PostgREST, MinIO, and minio_init services" +``` + +--- + +### Task 0.5: Update Gitea deploy workflow to bring up Docker stack + +**Files:** +- Modify: `.gitea/workflows/deploy.yml` + +- [ ] **Step 1: Read current deploy.yml** + +```bash +cat .gitea/workflows/deploy.yml +``` + +- [ ] **Step 2: Add a "Start Docker stack" step before the existing "Deploy" step** + +Insert AFTER `npm run build` and BEFORE `Deploy`: + +```yaml + - 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 }} + DATABASE_URL: ${{ secrets.DATABASE_URL }} + run: | + cd /home/tyler/route-commerce + [ -f .env ] || cp .env.example .env + { + 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}" + echo "DATABASE_URL=${DATABASE_URL}" + } >> .env + docker compose up -d db postgrest minio minio_init + 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 +``` + +- [ ] **Step 3: Add a migration apply step** + +Insert AFTER `Start Docker stack` and BEFORE `Deploy`: + +```yaml + - name: Apply migrations + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + 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 +``` + +- [ ] **Step 4: Update the `Deploy` step's `env:` block** + +Add to the `Deploy` step's `env:` block (preserving the existing env entries): + +```yaml + 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 }} + 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 }} + 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 }} +``` + +- [ ] **Step 5: Update the `printf` block in the `Deploy` step** + +Replace the existing printf block with: + +```bash + { + 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 "DATABASE_URL=%s\n" "$DATABASE_URL" + 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 "MINIMAX_API_KEY=%s\n" "$MINIMAX_API_KEY" + printf "MINIMAX_BASE_URL=%s\n" "$MINIMAX_BASE_URL" + } > $APP_DIR/.env.production +``` + +- [ ] **Step 6: Validate YAML syntax** + +```bash +python3 -c "import yaml; yaml.safe_load(open('.gitea/workflows/deploy.yml'))" +``` + +Expected: exit code 0, no output. + +- [ ] **Step 7: Commit** + +```bash +git add .gitea/workflows/deploy.yml +git commit -m "feat(deploy): bring up Docker stack + apply migrations in Gitea workflow" +``` + +--- + +### Task 0.6: Fix useMockData to not trigger on non-Supabase URLs + +**Files:** +- Modify: `src/lib/supabase.ts:8` + +- [ ] **Step 1: Read the current line 8** + +```bash +sed -n '8p' src/lib/supabase.ts +``` + +Expected: +```ts +const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true" || !supabaseUrl || !supabaseUrl.includes("supabase.co"); +``` + +- [ ] **Step 2: Replace the line** + +Replace: +```ts +const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true" || !supabaseUrl || !supabaseUrl.includes("supabase.co"); +``` + +With: +```ts +const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true" || !supabaseUrl; +``` + +Rationale: with local PostgREST, the URL is `http://localhost:3001`. The previous check treated non-Supabase URLs as a "no backend" signal and forced mock mode. + +- [ ] **Step 3: Verify the change** + +```bash +sed -n '8p' src/lib/supabase.ts +``` + +Expected: +```ts +const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true" || !supabaseUrl; +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/lib/supabase.ts +git commit -m "fix(supabase): don't force mock mode for non-Supabase URLs" +``` + +--- + +### Task 0.7: Update package.json with new dependencies + +**Files:** +- Modify: `package.json` + +- [ ] **Step 1: Read current package.json** + +```bash +cat package.json +``` + +- [ ] **Step 2: Add dependencies (if not already added by the cherry-pick)** + +In the `dependencies` block, ensure these are present: + +```json + "@aws-sdk/client-s3": "^3.687.0", + "@aws-sdk/s3-request-presigner": "^3.687.0", + "better-auth": "^1.0.0", + "kysely": "^0.27.5", + "pg": "^8.13.1" +``` + +- [ ] **Step 3: Install** + +```bash +npm install +``` + +Expected: no errors. + +- [ ] **Step 4: Verify packages are installed** + +```bash +ls node_modules/better-auth node_modules/@aws-sdk/client-s3 node_modules/kysely node_modules/pg 2>&1 | head -10 +``` + +Expected: all 4 directories exist. + +- [ ] **Step 5: Commit** + +```bash +git add package.json package-lock.json +git commit -m "chore(deps): add better-auth, Kysely, pg, AWS SDK" +``` + +--- + +### Task 0.8: Verify the merged build + +**Files:** none (verification only) + +- [ ] **Step 1: Run type check** + +```bash +npx tsc --noEmit +``` + +Expected: exit code 0. If errors appear, fix inline (likely from feat/better-auth's `src/lib/admin-permissions.ts` rewrite). + +- [ ] **Step 2: Run lint** + +```bash +npm run lint +``` + +Expected: exit code 0. + +- [ ] **Step 3: Run build** + +```bash +npm run build +``` + +Expected: `✓ Compiled successfully`. + +- [ ] **Step 4: Commit any fixes** + +```bash +git add -A +git diff --cached --quiet || git commit -m "fix(build): address merge issues in selfhost/migrate branch" +``` + +--- + +## Phase A — Database schema capture and apply + +### Task A.1: Set up local Postgres via Docker + +**Files:** none (docker only) + +- [ ] **Step 1: Create local `.env` from `.env.example`** + +```bash +cp .env.example .env +``` + +- [ ] **Step 2: Generate strong passwords and write to `.env`** + +```bash +POSTGRES_PW=$(openssl rand -base64 24 | tr -d '/+=' | head -c 32) +MINIO_PW=$(openssl rand -base64 24 | tr -d '/+=' | head -c 32) +JWT_SECRET=$(openssl rand -base64 32 | tr -d '/+=' | head -c 48) +BETTER_AUTH_SECRET=$(openssl rand -base64 32 | tr -d '/+=' | head -c 48) +sed -i "s|change-me-strong-password|$POSTGRES_PW|g" .env +sed -i "s|change-me-minio-root|$MINIO_PW|g" .env +echo "POSTGREST_JWT_SECRET=$JWT_SECRET" >> .env +echo "BETTER_AUTH_SECRET=$BETTER_AUTH_SECRET" >> .env +``` + +- [ ] **Step 3: Verify `.env` is updated** + +```bash +grep -E "^POSTGRES_PASSWORD|^MINIO_ROOT_PASSWORD|^POSTGREST_JWT_SECRET|^BETTER_AUTH_SECRET" .env +``` + +Expected: 4 non-empty lines, no `change-me-...` placeholders. + +- [ ] **Step 4: Start the db container** + +```bash +docker compose up -d db +``` + +Expected: `Container route_commerce_db ... Started`. + +- [ ] **Step 5: Wait for Postgres** + +```bash +for i in $(seq 1 30); do + if docker compose exec -T db pg_isready -U routecommerce -d route_commerce > /dev/null 2>&1; then + echo "Postgres is ready" + break + fi + sleep 2 +done +``` + +Expected: `Postgres is ready` within 60 seconds. + +- [ ] **Step 6: Verify connectivity** + +```bash +docker compose exec -T db psql -U routecommerce -d route_commerce -c "SELECT version();" +``` + +Expected: `PostgreSQL 16.x ...`. + +- [ ] **Step 7: Do NOT commit `.env`** (gitignored). + +--- + +### Task A.2: Capture schema + data from live Supabase + +**Files:** +- Create: `supabase/captured_schema.sql` (committed to repo) +- Create: `supabase/captured_data.sql` (committed to repo) + +**NOTE:** This step runs on the user's Mac (direct PG blocked from this dev box per MEMORY.md). + +- [ ] **Step 1: Get the Supabase DB password** + +Ask the user for the Supabase DB password (it should be in `.env.local` as `SUPABASE_DB_PASSWORD` or `SUPABASE_SERVICE_ROLE_KEY`). + +- [ ] **Step 2: Run pg_dump with retry (per spec's error handling)** + +```bash +SUPABASE_PW="" +run_pg_dump_schema() { + pg_dump "postgresql://postgres:${SUPABASE_PW}@db.wnzkhezyhnfzhkhiflrp.supabase.co:5432/postgres" \ + --schema-only --no-owner --no-privileges \ + --schema=public \ + -f /tmp/captured_schema.sql +} +run_pg_dump_data() { + pg_dump "postgresql://postgres:${SUPABASE_PW}@db.wnzkhezyhnfzhkhiflrp.supabase.co:5432/postgres" \ + --data-only --no-owner --no-privileges \ + --schema=public \ + -f /tmp/captured_data.sql +} + +if ! run_pg_dump_schema; then + echo "First schema dump failed; retrying once..." + sleep 5 + if ! run_pg_dump_schema; then + echo "Schema dump failed twice; aborting. Do NOT proceed with a partial dump." + exit 1 + fi +fi + +if ! run_pg_dump_data; then + echo "First data dump failed; retrying once..." + sleep 5 + if ! run_pg_dump_data; then + echo "Data dump failed twice; aborting." + exit 1 + fi +fi +``` + +Expected: two files created, ~5-20MB schema + variable data. + +- [ ] **Step 3: Verify the dumps are non-trivial** + +```bash +wc -l /tmp/captured_schema.sql /tmp/captured_data.sql +``` + +Expected: thousands of lines each. + +- [ ] **Step 4: Copy the dumps into the repo** + +```bash +cp /tmp/captured_schema.sql supabase/captured_schema.sql +cp /tmp/captured_data.sql supabase/captured_data.sql +ls -la supabase/captured_*.sql +``` + +Expected: both files exist, > 1MB. + +- [ ] **Step 5: Sanity-check the schema dump** + +```bash +grep -c "CREATE TABLE" supabase/captured_schema.sql +grep -c "CREATE FUNCTION" supabase/captured_schema.sql +``` + +Expected: many `CREATE TABLE` (~60) and `CREATE FUNCTION` (~185) statements. + +- [ ] **Step 6: Commit the dumps** + +```bash +git add supabase/captured_schema.sql supabase/captured_data.sql +git commit -m "chore(supabase): capture schema + data dump from live Supabase" +``` + +--- + +### Task A.3: Apply the preflight migration + +**Files:** none (uses `000_preflight_supabase_compat.sql` already in repo) + +- [ ] **Step 1: Apply 000_preflight_supabase_compat.sql** + +```bash +docker compose cp supabase/migrations/000_preflight_supabase_compat.sql db:/tmp/000_preflight.sql +docker compose exec -T db psql -U routecommerce -d route_commerce -v ON_ERROR_STOP=1 -f /tmp/000_preflight.sql +``` + +Expected: no errors; `CREATE EXTENSION`, `CREATE ROLE`, `CREATE SCHEMA`, `CREATE OR REPLACE FUNCTION auth.uid()` succeed. + +- [ ] **Step 2: Verify the preflight created the expected objects** + +```bash +docker compose exec -T db psql -U routecommerce -d route_commerce -c "\dn" +docker compose exec -T db psql -U routecommerce -d route_commerce -c "SELECT rolname FROM pg_roles WHERE rolname IN ('anon', 'authenticated', 'service_role');" +docker compose exec -T db psql -U routecommerce -d route_commerce -c "SELECT proname FROM pg_proc WHERE pronamespace = 'auth'::regnamespace;" +``` + +Expected: `auth` schema exists; 3 roles exist; `uid()`, `role()`, `email()` functions exist. + +- [ ] **Step 3: Verify routecommerce can act as the stub roles** + +```bash +docker compose exec -T db psql -U routecommerce -d route_commerce -c "SET ROLE anon; SELECT current_user; RESET ROLE;" +``` + +Expected: `current_user` reports `anon`; `RESET ROLE` works. + +--- + +### Task A.4: Apply the captured schema to local Postgres + +**Files:** none + +- [ ] **Step 1: Apply the captured schema** + +```bash +docker compose cp supabase/captured_schema.sql db:/tmp/captured_schema.sql +docker compose exec -T db psql -U routecommerce -d route_commerce -v ON_ERROR_STOP=1 -f /tmp/captured_schema.sql 2>&1 | tee /tmp/captured_schema_apply.log +``` + +Expected: many `CREATE TABLE`, `CREATE FUNCTION` messages. Some may fail (e.g., `auth.uid()` redefinition — preflight stub gets replaced by the real Supabase body). That's OK if no critical errors block table creation. + +- [ ] **Step 2: Re-apply the preflight to restore the stub auth.uid()** + +```bash +docker compose cp supabase/migrations/000_preflight_supabase_compat.sql db:/tmp/000_preflight.sql +docker compose exec -T db psql -U routecommerce -d route_commerce -v ON_ERROR_STOP=1 -f /tmp/000_preflight.sql +``` + +Expected: `CREATE OR REPLACE FUNCTION` succeeds. + +- [ ] **Step 3: Verify tables exist** + +```bash +docker compose exec -T db psql -U routecommerce -d route_commerce -c "\dt" | wc -l +``` + +Expected: > 60 (60+ tables in public). + +- [ ] **Step 4: Verify SECURITY DEFINER functions exist** + +```bash +docker compose exec -T db psql -U routecommerce -d route_commerce -c "SELECT count(*) FROM pg_proc WHERE prosecdef = true;" +``` + +Expected: ~185. + +--- + +### Task A.5: Patch the 3 broken migrations + +**Files:** +- Modify: `supabase/migrations/006_water_log_rpcs_fixed.sql` +- Modify: `supabase/migrations/099_harvest_reach_segmentation.sql` +- Modify: `supabase/migrations/135_email_automation_rpcs.sql` + +- [ ] **Step 1: Patch 006 — replace `STATIC` with `STABLE`** + +```bash +sed -i 's/\bSTATIC\b/STABLE/g' supabase/migrations/006_water_log_rpcs_fixed.sql +grep -c "STABLE" supabase/migrations/006_water_log_rpcs_fixed.sql +``` + +Expected: 6+ occurrences of `STABLE`. + +- [ ] **Step 2: Patch 099 — quote the `time` column references** + +```bash +grep -n "time" supabase/migrations/099_harvest_reach_segmentation.sql +``` + +In the file, find references to the `time` column (a reserved word). Use `search_replace` to wrap in double quotes, matching the pattern from the existing 148 patch: + +```sql +RETURNS TABLE ( ..., "time" TEXT, ... ) +... +s."time", +``` + +- [ ] **Step 3: Patch 135 — add a default to `p_next_email_at` in `enroll_abandoned_cart`** + +```bash +grep -n "enroll_abandoned_cart" supabase/migrations/135_email_automation_rpcs.sql +``` + +Find the function signature. Add a default value to the `p_next_email_at` parameter: + +```sql +CREATE OR REPLACE FUNCTION public.enroll_abandoned_cart( + p_brand_id UUID, + p_cart_id UUID, + p_next_email_at TIMESTAMPTZ DEFAULT now() + interval '1 hour' +) +RETURNS ... +``` + +(If the function doesn't have `p_next_email_at`, find the actual signature and apply a similar fix.) + +- [ ] **Step 4: Commit the patches** + +```bash +git add supabase/migrations/006_water_log_rpcs_fixed.sql \ + supabase/migrations/099_harvest_reach_segmentation.sql \ + supabase/migrations/135_email_automation_rpcs.sql +git commit -m "fix(migrations): patch 3 broken migrations (006, 099, 135)" +``` + +--- + +### Task A.6: Apply all 137 migrations + +**Files:** +- Create: `scripts/apply-migrations.sh` (committed) + +- [ ] **Step 1: Create the migration apply script** + +Create `scripts/apply-migrations.sh`: + +```bash +#!/usr/bin/env bash +set -u +CONTAINER=route_commerce_db +DB=route_commerce +USER=routecommerce +LOG=/tmp/migration_apply.log +> $LOG + +for f in supabase/migrations/[0-9]*.sql; do + echo "Applying $f ..." | tee -a $LOG + docker compose cp "$f" "$CONTAINER:/tmp/$(basename $f)" + if docker compose exec -T "$CONTAINER" psql -U "$USER" -d "$DB" -v ON_ERROR_STOP=0 -q -f "/tmp/$(basename $f)" >> $LOG 2>&1; then + echo " OK" | tee -a $LOG + else + echo " FAIL (continuing)" | tee -a $LOG + fi +done + +echo "--- summary ---" +echo "Failures:" +grep -B1 "FAIL" $LOG | head -40 +``` + +Make it executable: + +```bash +chmod +x scripts/apply-migrations.sh +``` + +- [ ] **Step 2: Run the migration apply** + +```bash +./scripts/apply-migrations.sh 2>&1 | tail -40 +``` + +Expected: most migrations apply; some may fail (captured in log). The 3 patched ones (006, 099, 135) should succeed. + +- [ ] **Step 3: Verify which migrations failed** + +```bash +grep -B1 "FAIL" /tmp/migration_apply.log +``` + +Expected: a small list. If anything critical failed (e.g., `200_better_auth_tables.sql`), investigate. + +- [ ] **Step 4: Verify the better-auth tables exist** + +```bash +docker compose exec -T db psql -U routecommerce -d route_commerce -c "\dt" | grep -E '"user"|"session"|"account"|"verification"' +``` + +Expected: 4 tables. + +- [ ] **Step 5: Commit the apply script** + +```bash +git add scripts/apply-migrations.sh +git commit -m "chore(scripts): add apply-migrations.sh for local Postgres" +``` + +--- + +### Task A.7: Apply the data dump + +**Files:** none (uses `supabase/captured_data.sql` from Task A.2) + +- [ ] **Step 1: Apply the data dump** + +```bash +docker compose cp supabase/captured_data.sql db:/tmp/captured_data.sql +docker compose exec -T db psql -U routecommerce -d route_commerce -v ON_ERROR_STOP=0 -f /tmp/captured_data.sql 2>&1 | tail -20 +``` + +Expected: many `INSERT` statements. Some may fail (e.g., foreign key violations if migration order differs) — document in log. + +- [ ] **Step 2: Verify data fidelity (spot check)** + +```bash +docker compose exec -T db psql -U routecommerce -d route_commerce -c "SELECT count(*) FROM brands;" +docker compose exec -T db psql -U routecommerce -d route_commerce -c "SELECT count(*) FROM products;" +docker compose exec -T db psql -U routecommerce -d route_commerce -c "SELECT count(*) FROM orders;" +``` + +Expected: counts > 0 (or 0 if the corresponding table is empty). + +--- + +### Task A.8: Disable RLS on all public tables + +**Files:** none (SQL only) + +- [ ] **Step 1: Apply the RLS disable block** + +```bash +docker compose exec -T db psql -U routecommerce -d route_commerce -v ON_ERROR_STOP=0 <<'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 $$; +SQL +``` + +Expected: no errors; runs in a few seconds. + +- [ ] **Step 2: Verify RLS is disabled** + +```bash +docker compose exec -T db psql -U routecommerce -d route_commerce -c "SELECT count(*) FROM pg_tables WHERE schemaname = 'public' AND rowsecurity = true;" +``` + +Expected: 0 rows. + +--- + +## Phase B — MinIO setup and asset migration + +### Task B.1: Start MinIO and verify buckets + +**Files:** none + +- [ ] **Step 1: Start MinIO + minio_init** + +```bash +docker compose up -d minio minio_init +``` + +Expected: both containers start. + +- [ ] **Step 2: Wait for the init container to finish** + +```bash +docker compose wait minio_init +docker compose logs minio_init | tail -10 +``` + +Expected: `MinIO buckets initialized` in logs. + +- [ ] **Step 3: Verify all 5 buckets exist** + +```bash +docker compose exec -T minio mc alias set local http://localhost:9000 routecommerce "$MINIO_ROOT_PASSWORD" +docker compose exec -T minio mc ls local/ +``` + +Expected: 5 buckets: `brand-logos/`, `contacts-imports/`, `product-images/`, `videos/`, `water-photos/`. + +- [ ] **Step 4: Verify a bucket is publicly readable** + +```bash +docker compose exec -T minio mc anonymous get local/brand-logos +``` + +Expected: `download`. + +- [ ] **Step 5: Verify MinIO is accessible from the host** + +```bash +curl -I http://127.0.0.1:9000/minio/health/live +``` + +Expected: `HTTP/1.1 200 OK`. + +--- + +### Task B.2: Copy existing brand assets from Supabase Storage to MinIO + +**Files:** none + +- [ ] **Step 1: Find the Tuxedo hero video URL** + +```bash +grep -A2 "videos" src/components/storefront/TuxedoVideoHero.tsx | head -20 +``` + +The URL looks like: +``` +https://wnzkhezyhnfzhkhiflrp.supabase.co/storage/v1/object/public/videos/ +``` + +- [ ] **Step 2: Download the Tuxedo hero video** + +```bash +mkdir -p /tmp/minio-migration/videos +curl -o /tmp/minio-migration/videos/ "" +ls -la /tmp/minio-migration/videos/ +``` + +Expected: file downloaded, > 0 bytes. + +- [ ] **Step 3: Find the brand logo URLs in `email-service.ts`** + +```bash +grep -A1 "brand-logos" src/lib/email-service.ts | head -20 +``` + +Four hardcoded URLs are expected. + +- [ ] **Step 4: Download the logos** + +```bash +mkdir -p /tmp/minio-migration/brand-logos/ +for url in "" "" "" ""; do + fname=$(basename "$url") + curl -o "/tmp/minio-migration/brand-logos//$fname" "$url" +done +ls -la /tmp/minio-migration/brand-logos// +``` + +Expected: 4 files downloaded. + +- [ ] **Step 5: Find any other hardcoded Supabase storage URLs** + +```bash +grep -rE "wnzkhezyhnfzhkhiflrp\.supabase\.co/storage/v1/object/public" src/ --include="*.ts" --include="*.tsx" | head -20 +``` + +Download any URLs found to `/tmp/minio-migration/misc/`. + +- [ ] **Step 6: Upload the videos to MinIO** + +```bash +docker compose cp /tmp/minio-migration/videos minio:/tmp/videos +docker compose exec -T minio mc cp --recursive /tmp/videos/ local/videos/ +docker compose exec -T minio mc ls local/videos/ +``` + +Expected: video file(s) listed. + +- [ ] **Step 7: Upload the brand logos to MinIO** + +```bash +docker compose cp /tmp/minio-migration/brand-logos minio:/tmp/brand-logos +docker compose exec -T minio mc cp --recursive /tmp/brand-logos/ local/brand-logos/ +docker compose exec -T minio mc ls local/brand-logos/ --recursive +``` + +Expected: logo files listed. + +- [ ] **Step 8: Verify the assets are publicly accessible** + +```bash +curl -I http://127.0.0.1:9000/videos/ +curl -I http://127.0.0.1:9000/brand-logos// +``` + +Expected: `HTTP/1.1 200 OK`. + +- [ ] **Step 9: Clean up** + +```bash +rm -rf /tmp/minio-migration +``` + +--- + +## Phase C — PostgREST + end-to-end local verification + +### Task C.1: Start PostgREST and verify + +**Files:** none + +- [ ] **Step 1: Start the PostgREST service** + +```bash +docker compose up -d postgrest +``` + +Expected: `Container route_commerce_postgrest ... Started`. + +- [ ] **Step 2: Wait for PostgREST healthcheck** + +```bash +for i in $(seq 1 15); do + if curl -sf http://127.0.0.1:3001/ > /dev/null 2>&1; then + echo "PostgREST is up" + break + fi + sleep 2 +done +``` + +Expected: `PostgREST is up` within 30 seconds. + +- [ ] **Step 3: Smoke test: list brands** + +```bash +curl -s "http://127.0.0.1:3001/brands?limit=1" -H "apikey: local-anon" | head -c 500 +``` + +Expected: a JSON array. + +- [ ] **Step 4: Smoke test: call a SECURITY DEFINER RPC** + +```bash +curl -s -X POST "http://127.0.0.1:3001/rpc/get_public_stops_for_brand" \ + -H "Content-Type: application/json" \ + -H "apikey: local-anon" \ + -d '{"p_slug":"indian-river-direct"}' | head -c 500 +``` + +Expected: a JSON array (or `[]` if no data yet). + +- [ ] **Step 5: Check the PostgREST logs for errors** + +```bash +docker compose logs postgrest --tail=30 +``` + +Expected: no critical errors. + +--- + +### Task C.2: Set up local env and verify app build + +**Files:** +- Create: `.env.local` (gitignored) + +- [ ] **Step 1: Create `.env.local`** + +```bash +PG_PW=$(grep "^POSTGRES_PASSWORD=" .env | cut -d= -f2-) +BA_S=$(grep "^BETTER_AUTH_SECRET=" .env | cut -d= -f2-) +MINIO_PW=$(grep "^MINIO_ROOT_PASSWORD=" .env | cut -d= -f2-) +cat > .env.local <` placeholders. + +- [ ] **Step 3: Run type check** + +```bash +npx tsc --noEmit +``` + +Expected: exit code 0. + +- [ ] **Step 4: Run build** + +```bash +npm run build +``` + +Expected: `✓ Compiled successfully`. + +- [ ] **Step 5: Fix any errors** + +Common issues: +- Missing `pg` types → `npm install --save-dev @types/pg` +- better-auth import errors → `ls node_modules/better-auth` +- PostgREST call returning errors → check Task A.6 verification + +--- + +### Task C.3: Verify mock mode still works + +**Files:** none + +- [ ] **Step 1: Start the dev server with mock mode** + +```bash +NEXT_PUBLIC_USE_MOCK_DATA=true npm run dev +``` + +Expected: dev server starts on `http://localhost:3000`. + +- [ ] **Step 2: Visit the homepage** + +Open `http://localhost:3000` in a browser. Expected: page loads. + +- [ ] **Step 3: Set the dev session cookie** + +In the browser dev tools, set a cookie: +``` +Name: dev_session +Value: platform_admin +Domain: localhost +Path: / +``` + +- [ ] **Step 4: Reload `/admin`** + +Expected: admin dashboard loads with mock data. + +- [ ] **Step 5: Stop the dev server** + +```bash +# Ctrl+C in the terminal +``` + +--- + +### Task C.4: Verify dev mode bypass works against local Postgres + +**Files:** none + +- [ ] **Step 1: Start the dev server with the real DB** + +```bash +unset NEXT_PUBLIC_USE_MOCK_DATA +npm run dev +``` + +- [ ] **Step 2: Set the dev session cookie** + +Same as Task C.3 Step 3: `dev_session=platform_admin`. + +- [ ] **Step 3: Visit /admin** + +Expected: admin dashboard loads with real data from local Postgres. + +- [ ] **Step 4: Verify a known data point** + +Visit `/admin/brands` and confirm a known brand is listed. + +- [ ] **Step 5: Stop the dev server** + +```bash +# Ctrl+C +``` + +--- + +### Task C.5: Verify auth round-trip with better-auth + +**Files:** none + +- [ ] **Step 1: Start the dev server** + +```bash +npm run dev +``` + +- [ ] **Step 2: Sign up via better-auth API** + +```bash +curl -X POST http://localhost:3000/api/auth/sign-up/email \ + -H "Content-Type: application/json" \ + -d '{"email":"test@example.com","password":"testpassword123","name":"Test User"}' +``` + +Expected: 200 with a user object (or 400 if signup is disabled — adjust per the better-auth config). + +- [ ] **Step 3: Sign in** + +```bash +curl -X POST http://localhost:3000/api/auth/sign-in/email \ + -H "Content-Type: application/json" \ + -c /tmp/cookies.txt \ + -d '{"email":"test@example.com","password":"testpassword123"}' +``` + +Expected: 200; `/tmp/cookies.txt` contains `rc_session_token`. + +- [ ] **Step 4: Use the session to call a protected endpoint** + +```bash +curl -X GET http://localhost:3000/admin \ + -b /tmp/cookies.txt \ + -o /dev/null -w "%{http_code}\n" +``` + +Expected: 200 (or 307 redirect). + +- [ ] **Step 5: Verify the user was created** + +```bash +docker compose exec -T db psql -U routecommerce -d route_commerce -c "SELECT id, email, name FROM \"user\";" +``` + +Expected: row with `email = 'test@example.com'`. + +- [ ] **Step 6: Clean up** + +```bash +docker compose exec -T db psql -U routecommerce -d route_commerce -c "DELETE FROM \"user\" WHERE email = 'test@example.com';" +``` + +--- + +### Task C.6: Verify storage round-trip with MinIO + +**Files:** none + +- [ ] **Step 1: Start the dev server** + +```bash +npm run dev +``` + +- [ ] **Step 2: Sign in as a platform admin** + +Set `dev_session=platform_admin` cookie in the browser. + +- [ ] **Step 3: Upload a product image** + +Go to `/admin/products`. Edit a product. Upload a new product image (JPG/PNG). + +- [ ] **Step 4: Verify the image is in MinIO** + +```bash +docker compose exec -T minio mc ls local/product-images/ --recursive | head -10 +``` + +Expected: the uploaded image appears. + +- [ ] **Step 5: Verify the image is publicly accessible** + +```bash +IMG_KEY=$(docker compose exec -T minio mc ls local/product-images/ --recursive | head -1 | awk '{print $NF}') +curl -I "http://127.0.0.1:9000/product-images/$IMG_KEY" +``` + +Expected: `HTTP/1.1 200 OK`. + +- [ ] **Step 6: Verify the image renders on the storefront** + +Visit `/tuxedo/products/` (or `/indian-river-direct/products/`). The image should display. + +--- + +## Phase D — Production cutover + +### Task D.1: Set up prod server with Docker stack + +**Files:** none (server admin) + +- [ ] **Step 1: SSH to the prod server** + +```bash +ssh tyler@route.crispygoat.com +``` + +- [ ] **Step 2: Pull the latest code on `main`** + +```bash +cd /home/tyler/route-commerce +git pull origin main +``` + +Expected: latest `selfhost/migrate` branch commits (merge `selfhost/migrate` to `main` first if your flow requires it). + +- [ ] **Step 3: Verify Docker is installed** + +```bash +docker --version +docker compose version +``` + +Expected: `Docker version 24.x` and `Docker Compose version v2.x` (or `docker-compose version 1.x`). + +- [ ] **Step 4: Verify Gitea secrets are set** + +In `https://git.crispygoat.com/tyler/route-commerce/settings/secrets`, verify: + +- `POSTGRES_USER` = `routecommerce` +- `POSTGRES_PASSWORD` = (32+ char random) +- `POSTGRES_DB` = `route_commerce` +- `MINIO_ROOT_USER` = `routecommerce` +- `MINIO_ROOT_PASSWORD` = (32+ char random) +- `POSTGREST_JWT_SECRET` = (48+ char random) +- `DATABASE_URL` = `postgresql://routecommerce:@db:5432/route_commerce?schema=public` +- `BETTER_AUTH_SECRET` = (48+ char random) +- `BETTER_AUTH_URL` = `https://route.crispygoat.com` +- `NEXT_PUBLIC_BETTER_AUTH_URL` = `https://route.crispygoat.com` +- `STORAGE_ENDPOINT` = `http://minio:9000` +- `STORAGE_REGION` = `us-east-1` +- `STORAGE_ACCESS_KEY` = `routecommerce` +- `STORAGE_SECRET_KEY` = (same as `MINIO_ROOT_PASSWORD`) +- `STORAGE_BUCKET_PREFIX` = (empty) +- `NEXT_PUBLIC_STORAGE_BASE_URL` = `https://route.crispygoat.com/storage` (or your reverse proxy URL) +- `PGRST_SERVER_PORT` = `3001` +- `PGRST_DB_URI` = `postgresql://routecommerce:@db:5432/route_commerce` +- `PGRST_DB_ANON_ROLE` = `anon` +- `NEXT_PUBLIC_SUPABASE_URL` = `http://postgrest:3001` +- `NEXT_PUBLIC_SUPABASE_ANON_KEY` = (any string) +- `STRIPE_SECRET_KEY` = (existing) +- `RESEND_API_KEY` = (existing) +- `MINIMAX_API_KEY` = (existing) +- `MINIMAX_BASE_URL` = (existing) + +- [ ] **Step 5: Push to trigger the Gitea workflow** + +From the dev box: + +```bash +git push crispygoat selfhost/migrate:main +``` + +(Or merge `selfhost/migrate` to `main` first, then push `main`.) + +- [ ] **Step 6: Watch the Gitea Actions run** + +Open `https://git.crispygoat.com/tyler/route-commerce/actions`. Expected: `Start Docker stack` succeeds, `Apply migrations` runs through 137 migrations, `Deploy` restarts the app. + +--- + +### Task D.2: Verify prod is working + +**Files:** none + +- [ ] **Step 1: Visit the homepage** + +Open `https://route.crispygoat.com`. Expected: page loads. + +- [ ] **Step 2: Visit a brand storefront** + +Open `https://route.crispygoat.com/tuxedo` and `https://route.crispygoat.com/indian-river-direct`. Expected: pages load, brand logos display from MinIO. + +- [ ] **Step 3: Sign in to the admin** + +Open `https://route.crispygoat.com/login` and sign in with a known admin account. + +- [ ] **Step 4: Verify data is present** + +Navigate `/admin/orders`, `/admin/products`, `/admin/stops`, `/admin/communications`. Verify data is from the dump (or fresh if no dump was applied). + +- [ ] **Step 5: Upload a new product image** + +In `/admin/products`, upload a new image. Verify it lands in MinIO and renders on the storefront. + +- [ ] **Step 6: Check prod logs for errors** + +```bash +ssh tyler@route.crispygoat.com +pm2 logs route-commerce --lines 100 +docker compose logs db postgrest minio --tail=30 +``` + +Expected: no critical errors. + +--- + +### Task D.3: 24h monitoring + +**Files:** none + +- [ ] **Step 1: Monitor for 24 hours** + +Watch for: +- Error rates in the Next.js app +- Postgres connection issues +- MinIO disk usage +- PostgREST 5xx responses + +- [ ] **Step 2: Roll back if critical issues appear** + +If anything goes wrong: +1. `git revert` the merge commit on `main`. +2. `pm2 restart route-commerce`. +3. Investigate before re-attempting. + +- [ ] **Step 3: After 24h of stable operation, deactivate Supabase** + +In the Supabase dashboard: +1. Pause the project (or delete, if certain). +2. Cancel any active subscriptions. +3. Note the deactivation date in `MEMORY.md`. + +- [ ] **Step 4: Document the cutover in MEMORY.md** + +Append to `MEMORY.md`: + +```markdown +## Self-Hosted Cutover (YYYY-MM-DD) + +- Migrated from Supabase to self-hosted Postgres + PostgREST + MinIO + better-auth. +- Branch: `selfhost/migrate` (merged to `main`). +- Schema dump: `supabase/captured_schema.sql` (commit ). +- Data dump: `supabase/captured_data.sql` (commit ) — or "no data dump, fresh start" if not applied. +- 24h monitoring: passed. +- Supabase project: paused/deleted on YYYY-MM-DD. +``` + +- [ ] **Step 5: Final commit** + +```bash +git add MEMORY.md +git commit -m "docs(memory): record self-hosted cutover" +``` + +--- + +## Phase E — Final verification (after cutover) + +### Task E.1: Run Playwright E2E tests (if available) + +**Files:** +- Modify: `playwright.config.ts` (if needed for env) + +- [ ] **Step 1: Check if Playwright tests exist** + +```bash +ls tests/ 2>&1 | head -10 +cat playwright.config.ts | head -40 +``` + +- [ ] **Step 2: Update `playwright.config.ts` to use the local env (if needed)** + +```ts +webServer: { + command: 'npm run dev', + env: { + NEXT_PUBLIC_SUPABASE_URL: 'http://localhost:3001', + NEXT_PUBLIC_SUPABASE_ANON_KEY: 'local-anon', + DATABASE_URL: process.env.DATABASE_URL, + BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET, + NEXT_PUBLIC_STORAGE_BASE_URL: 'http://localhost:9000', + STORAGE_ENDPOINT: 'http://127.0.0.1:9000', + STORAGE_ACCESS_KEY: 'routecommerce', + STORAGE_SECRET_KEY: process.env.STORAGE_SECRET_KEY, + }, + url: 'http://localhost:3000', + timeout: 120_000, +}, +``` + +- [ ] **Step 3: Install Playwright browsers** + +```bash +npx playwright install --with-deps chromium +``` + +- [ ] **Step 4: Run the tests** + +```bash +npx playwright test +``` + +Expected: tests pass (or skip if no tests exist). If tests fail, document the failures but don't block the migration. + +- [ ] **Step 5: Note the result** + +```bash +echo "Playwright result: $(date)" >> docs/superpowers/migration-e2e-result.log +echo "Pass/Fail: " >> docs/superpowers/migration-e2e-result.log +``` + +--- + +### Task E.2: Final cross-check against spec's acceptance criteria + +**Files:** none + +- [ ] **Step 1: Verify the spec's acceptance criteria** + +```bash +git rev-parse --abbrev-ref HEAD +npx tsc --noEmit && echo "TSC OK" && npm run build && echo "BUILD OK" +``` + +Expected: `selfhost/migrate`, `TSC OK`, `BUILD OK`. + +- [ ] **Step 2: Check all 3 patched migrations applied** + +```bash +docker compose exec -T db psql -U routecommerce -d route_commerce -c "\df" | grep -E "verify_water_pin|enroll_abandoned_cart" | head -5 +``` + +Expected: function names from 006 and 135 visible. + +- [ ] **Step 3: Check supabase-js works against PostgREST** + +```bash +curl -s "http://127.0.0.1:3001/brands?limit=1" -H "apikey: local-anon" +``` + +Expected: 200 with JSON array. + +- [ ] **Step 4: Check better-auth tables exist** + +```bash +docker compose exec -T db psql -U routecommerce -d route_commerce -c "SELECT count(*) AS user_count FROM \"user\";" +``` + +Expected: a count. + +- [ ] **Step 5: Check MinIO buckets exist and are public** + +```bash +docker compose exec -T minio mc ls local/ | wc -l +``` + +Expected: 5. + +- [ ] **Step 6: Check env vars don't reference Supabase (except the legacy vars)** + +```bash +grep -rE "wnzkhezyhnfzhkhiflrp\.supabase\.co" src/ --include="*.ts" --include="*.tsx" | head -10 +``` + +Expected: only references in the old Supabase storage URLs (already migrated to MinIO). If any non-storage references remain, fix them. + +- [ ] **Step 7: Note the result in the spec** + +Add a status note to the spec's "Acceptance Criteria" section in `docs/superpowers/specs/2026-06-05-selfhost-migration-design.md` and commit. + +--- + +## Out of scope (deferred to follow-up plans) + +- Migrating user passwords from `auth.users` to `user.password` (better-auth). Users will need to reset passwords. +- Performance tuning (indexes, connection pooling, query optimization). +- Replacing `supabase-js` (still used for PostgREST RPC calls; works fine). +- Setting up a Caddy reverse proxy in front of MinIO (deferred; current setup exposes MinIO on port 9000 directly). +- Self-hosted email/SMS sending (Resend + Twilio still used as third-party services). +- Backup/restore strategy for the new Postgres (currently relies on Docker volume; consider adding `pg_dump` cron). diff --git a/docs/superpowers/specs/2026-06-05-selfhost-migration-design.md b/docs/superpowers/specs/2026-06-05-selfhost-migration-design.md new file mode 100644 index 0000000..735feb1 --- /dev/null +++ b/docs/superpowers/specs/2026-06-05-selfhost-migration-design.md @@ -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= +POSTGRES_DB=route_commerce +DATABASE_URL=postgresql://routecommerce:@db:5432/route_commerce?schema=public + +# ── PostgREST ── +PGRST_SERVER_PORT=3001 +PGRST_DB_URI=postgresql://routecommerce:@db:5432/route_commerce +PGRST_DB_ANON_ROLE=anon +PGRST_JWT_SECRET= # not used for auth; required by PostgREST + +# ── better-auth ── +BETTER_AUTH_SECRET= +BETTER_AUTH_URL=https://route.crispygoat.com +NEXT_PUBLIC_BETTER_AUTH_URL=https://route.crispygoat.com + +# ── MinIO ── +MINIO_ROOT_USER=routecommerce +MINIO_ROOT_PASSWORD= +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= +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= # 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:@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: "` → 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 `. + - `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. diff --git a/middleware.ts b/middleware.ts index 9b2287e..7708e94 100644 --- a/middleware.ts +++ b/middleware.ts @@ -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); @@ -61,4 +55,4 @@ export const config = { "/admin", "/login", ], -}; \ No newline at end of file +}; diff --git a/next.config.ts b/next.config.ts index 5b4263c..541dabe 100644 --- a/next.config.ts +++ b/next.config.ts @@ -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], @@ -88,8 +106,18 @@ const nextConfig: NextConfig = { // Rewrites for API proxy async rewrites() { + // Storage proxy: /storage/* -> MinIO at the same path + // Lets brand assets and product images use portable relative URLs + // (e.g. /storage/brand-logos//logo.png) in the DB, with Next.js + // proxying to whichever MinIO endpoint is configured for the environment. + // Avoids the next/image "upstream resolved to private ip" block on + // localhost MinIO by keeping the upstream fetch on the server side. + const storageBase = process.env.STORAGE_PUBLIC_URL || "http://localhost:9000"; return [ - // Add any necessary rewrites here + { + source: "/storage/:path*", + destination: `${storageBase}/:path*`, + }, ]; }, diff --git a/package.json b/package.json index f093578..af1e86a 100644 --- a/package.json +++ b/package.json @@ -15,17 +15,19 @@ }, "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", "@sentry/nextjs": "^10.55.0", - "@supabase/ssr": "^0.10.2", - "@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 +50,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", diff --git a/src/actions/admin/audit.ts b/src/actions/admin/audit.ts index 39465a2..2fc1006 100644 --- a/src/actions/admin/audit.ts +++ b/src/actions/admin/audit.ts @@ -1,15 +1,6 @@ "use server"; -import { createClient as createServiceClient } from "@supabase/supabase-js"; - -function getServiceClient() { - const roleKey = process.env.SUPABASE_SERVICE_ROLE_KEY; - if (!roleKey) throw new Error("SUPABASE_SERVICE_ROLE_KEY is not set"); - return createServiceClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - roleKey, - ); -} +import { svcRpc } from "@/lib/svc-fetch"; type AdminActionPayload = { action_type: "create" | "update" | "delete"; @@ -30,8 +21,7 @@ type UserActivityPayload = { export async function logAdminAction(payload: AdminActionPayload): Promise { try { - const service = getServiceClient(); - await service.rpc("log_admin_action", { + await svcRpc("log_admin_action", { p_payload: { action_type: payload.action_type, admin_id: payload.admin_id ?? null, @@ -48,8 +38,7 @@ export async function logAdminAction(payload: AdminActionPayload): Promise export async function logUserActivity(payload: UserActivityPayload): Promise { try { - const service = getServiceClient(); - await service.rpc("log_user_activity", { + await svcRpc("log_user_activity", { p_payload: { user_id: payload.user_id, activity_type: payload.activity_type, diff --git a/src/actions/admin/force-login.ts b/src/actions/admin/force-login.ts index e70dbec..71e757f 100644 --- a/src/actions/admin/force-login.ts +++ b/src/actions/admin/force-login.ts @@ -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 }; + } } diff --git a/src/actions/admin/password.ts b/src/actions/admin/password.ts index 24e317e..c15fd51 100644 --- a/src/actions/admin/password.ts +++ b/src/actions/admin/password.ts @@ -1,7 +1,7 @@ "use server"; import { cookies } from "next/headers"; -import { createClient as createServiceClient } from "@supabase/supabase-js"; +import { svcRpc } from "@/lib/svc-fetch"; export async function updatePasswordAction( newPassword: string @@ -15,16 +15,11 @@ export async function updatePasswordAction( return { error: "Not authenticated. Please log in again." }; } - const service = createServiceClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.SUPABASE_SERVICE_ROLE_KEY! - ); - - const { error } = await service.rpc("update_user_password", { + const { error } = await svcRpc("update_user_password", { p_user_id: uid, p_password: newPassword, }); if (error) return { error: error.message }; return {}; -} \ No newline at end of file +} diff --git a/src/actions/admin/profile.ts b/src/actions/admin/profile.ts new file mode 100644 index 0000000..ec1dcab --- /dev/null +++ b/src/actions/admin/profile.ts @@ -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 { + 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 }; + } +} diff --git a/src/actions/admin/reset-admin.ts b/src/actions/admin/reset-admin.ts index 3607240..0492e11 100644 --- a/src/actions/admin/reset-admin.ts +++ b/src/actions/admin/reset-admin.ts @@ -1,15 +1,6 @@ "use server"; -import { createClient as createServiceClient } from "@supabase/supabase-js"; - -function getServiceClient() { - const roleKey = process.env.SUPABASE_SERVICE_ROLE_KEY; - if (!roleKey) throw new Error("SUPABASE_SERVICE_ROLE_KEY not set"); - return createServiceClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - roleKey, - ); -} +import * as authAdmin from "@/lib/auth-admin"; export type ResetAdminPasswordResult = | { success: true; tempPassword: string } @@ -24,24 +15,19 @@ export async function resetAdminPassword( email: string, newPassword: string ): Promise { - const service = getServiceClient(); - // Look up auth user by email - const { data: authUsers, error: listError } = await service.auth.admin.listUsers(); - if (listError || !authUsers?.users) { - return { success: false, error: "Could not list users: " + listError?.message }; + const { data: users, error: listError } = await authAdmin.listUsers({ searchValue: email }); + if (listError) { + return { success: false, error: "Could not list users: " + listError.message }; } - const authUser = authUsers.users.find((u) => u.email?.toLowerCase() === email.toLowerCase()); + const authUser = (users ?? []).find((u) => u.email?.toLowerCase() === email.toLowerCase()); if (!authUser) { return { success: false, error: "No auth user found for that email address." }; } - // Update password via service role - const { error: updateError } = await service.auth.admin.updateUserById( - authUser.id, - { password: newPassword, email_confirm: true } - ); + // Update password via the better-auth admin wrapper (uses `update_user_password` RPC internally) + const { error: updateError } = await authAdmin.setUserPassword(authUser.id, newPassword); if (updateError) { return { success: false, error: updateError.message }; diff --git a/src/actions/admin/users.ts b/src/actions/admin/users.ts index af0c288..c607b30 100644 --- a/src/actions/admin/users.ts +++ b/src/actions/admin/users.ts @@ -1,14 +1,10 @@ "use server"; import { cookies, headers } from "next/headers"; -import { createServerClient } from "@supabase/ssr"; -import { NextRequest } from "next/server"; -import { NextResponse } from "next/server"; -import { createClient as createServiceClient } from "@supabase/supabase-js"; -import { supabase as publicSupabase } from "@/lib/supabase"; -import { getMockTableData, mockBrands } from "@/lib/mock-data"; - -const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true"; +import { api as publicApi } from "@/lib/api"; +import { auth } from "@/lib/auth"; +import { svcApi, svcRpc } from "@/lib/svc-fetch"; +import * as authAdmin from "@/lib/auth-admin"; export type AdminUserRow = { id: string; @@ -75,66 +71,44 @@ export type UpdateAdminUserInput = { phone_number?: string | null; }; -// ─── SSR client for authenticated requests ───────────────────────────────── +// ─── Auth helper: validate session via better-auth ────────────────────────── -async function getAuthClient() { - const cookieStore = await cookies(); +/** + * Read rc_auth_uid from the raw HTTP Cookie header. This is set by the + * Emergency Force Login flow and acts as a dev-mode bypass for normal auth. + */ +async function readRcAuthUid(): Promise { const headerStore = await headers(); - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; - const request = new NextRequest("http://localhost/admin", { headers: new Headers() }); - const response = NextResponse.next({ request }); - - // Read rc_auth_uid from the raw HTTP Cookie header (document.cookie sets - // cookies that arrive in the header but NOT in next/headers cookies()). const cookieHeader = headerStore.get("cookie") || ""; const allCookies = cookieHeader.split(";").map(c => c.trim()); const rcUidCookie = allCookies.find(c => c.startsWith("rc_auth_uid=")); - const rcAuthUid = rcUidCookie ? rcUidCookie.split("=")[1] : null; - - 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)); - }, - }, - }); - return { supabase, response, rcAuthUid }; + return rcUidCookie ? rcUidCookie.split("=")[1] : null; } +/** + * Authenticated RPC call. Validates the session via better-auth, then calls + * the named RPC using the service-role client. + * + * In development, the DEV_FORCE_UID cookie bypasses the auth check (the + * Emergency Force Login path is itself authenticated upstream). + */ async function callRpcWithAuth(fn: string, params: Record): Promise<{ data: T | null; error: string | null }> { - const { supabase, rcAuthUid } = await getAuthClient(); - - // Dev force-login UID bypasses Supabase auth entirely const DEV_FORCE_UID = "dev-user-00000000-0000-0000-0000-000000000001"; + const rcAuthUid = await readRcAuthUid(); if (rcAuthUid === DEV_FORCE_UID) { - return { data: null, error: null }; // let the action proceed without auth check + return { data: null, error: null }; // dev force-login bypass } - const { data: userData, error: userError } = await supabase.auth.getUser(); - if (userError || !userData.user) { + const headerStore = await headers(); + const session = await auth.api.getSession({ headers: headerStore }); + if (!session?.user) { return { data: null, error: "Not authenticated" }; } - const { data, error } = await supabase.rpc(fn, params as Record); - if (error) { /* RPC error handled silently */ } + + const { data, error } = await svcRpc(fn, params); return { data: data as T, error: error ? error.message : null }; } -// ─── Service role client (server-only, never exposed to browser) ─────────── - -function getServiceClient() { - const roleKey = process.env.SUPABASE_SERVICE_ROLE_KEY; - if (!roleKey) { - throw new Error("SUPABASE_SERVICE_ROLE_KEY is not set. Cannot use service role in dev path."); - } - return createServiceClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - roleKey, - ); -} - // ─── Dev-only path — uses service role to create auth user + admin_users ── async function devCreateAdminUser(input: CreateAdminUserInput): Promise<{ user: AdminUserRow | null; error: string | null }> { @@ -147,27 +121,21 @@ async function devCreateAdminUser(input: CreateAdminUserInput): Promise<{ user: return { user: null, error: "Not authenticated" }; } - const service = getServiceClient(); - - // Create auth user with the provided password - const { data: authUser, error: authError } = await service.auth.admin.createUser({ + // Create auth user with the provided password (better-auth signUpEmail) + const { data: authUser, error: authError } = await authAdmin.createUser({ email: input.email, password: input.password, - email_confirm: true, - user_metadata: { - display_name: input.display_name || input.email.split("@")[0], - phone_number: input.phone_number ?? null, - }, + name: input.display_name || input.email.split("@")[0], }); - if (authError || !authUser.user) { + if (authError || !authUser) { return { user: null, error: authError?.message ?? "Failed to create auth user" }; } - // Insert into admin_users - const { data: inserted, error: insertError } = await service + // Insert into admin_users (service role bypasses RLS) + const { data: inserted, error: insertError } = await svcApi .from("admin_users") .insert({ - user_id: authUser.user.id, + user_id: authUser.id, role: input.role, brand_id: input.brand_id, display_name: input.display_name || input.email.split("@")[0], @@ -185,11 +153,14 @@ async function devCreateAdminUser(input: CreateAdminUserInput): Promise<{ user: must_change_password: input.mustChangePassword ?? true, }) .select() - .single(); + .single() as { data: any; error: { message: string } | null }; if (insertError) { return { user: null, error: insertError.message }; } + if (!inserted) { + return { user: null, error: "Insert returned no row" }; + } // Send welcome email try { @@ -263,27 +234,24 @@ function mapUserRow(row: Record): AdminUserRow { // ─── Dev path helpers (service role, local only) ─────────────────────────── async function devListAdminUsers(callerUid?: string): Promise<{ users: AdminUserRow[]; error: string | null }> { - const service = getServiceClient(); - // Ensure caller has an admin_users record if (callerUid) { - const { data: existing } = await service + const { data: existing } = await svcApi .from("admin_users") .select("id") .eq("user_id", callerUid) - .maybeSingle(); + .maybeSingle() as { data: any }; if (!existing) { // auto-creating admin_users for uid - const { data: authData } = await service.auth.admin.listUsers(); - const authUser = authData?.users?.find((u) => u.id === callerUid); - const meta = (authUser as { user_metadata?: Record })?.user_metadata; - await service.from("admin_users").insert({ + const { data: listData } = await authAdmin.listUsers({ searchValue: undefined }); + const authUser = (listData ?? []).find((u: any) => u.id === callerUid); + await svcApi.from("admin_users").insert({ user_id: callerUid, role: "platform_admin", brand_id: null, - display_name: (meta?.display_name as string | null) ?? authUser?.email?.split("@")[0] ?? "Admin", - phone_number: (meta?.phone_number as string | null) ?? null, + display_name: authUser?.name ?? authUser?.email?.split("@")[0] ?? "Admin", + phone_number: null, can_manage_products: true, can_manage_stops: true, can_manage_orders: true, @@ -300,7 +268,7 @@ async function devListAdminUsers(callerUid?: string): Promise<{ users: AdminUser } // Fetch all admin_users rows (no RLS for service role) - const { data: adminRows, error: adminError } = await service + const { data: adminRows, error: adminError } = await svcApi .from("admin_users") .select(` id, user_id, role, brand_id, active, must_change_password, created_at, last_login, @@ -308,57 +276,30 @@ async function devListAdminUsers(callerUid?: string): Promise<{ users: AdminUser can_manage_messages, can_manage_refunds, can_manage_users, can_manage_water_log, can_manage_reports, brands (name) `) - .order("created_at", { ascending: false }); + .order("created_at", { ascending: false }) as { data: any; error: any }; if (adminError) return { users: [], error: adminError.message }; - // Fetch auth user details via service role admin API - const { data: authData, error: authError } = await service.auth.admin.listUsers(); + // Fetch auth user details via better-auth admin list + const { data: listData, error: authError } = await authAdmin.listUsers({ searchValue: undefined }); if (authError) return { users: [], error: authError.message }; - const authMap: Record = {}; - (authData?.users ?? []).forEach((u) => { - const user = u as { id: string; email?: string; user_metadata?: Record }; - authMap[user.id] = { - email: user.email ?? "", - display_name: (user.user_metadata?.display_name as string | null) ?? (user.user_metadata?.full_name as string | null) ?? null, - phone_number: (user.user_metadata?.phone_number as string | null) ?? null, - }; - }); - - const users: AdminUserRow[] = (adminRows ?? []).map((row) => { - const r = row as Record & { brands?: { name?: string } }; - const authInfo = authMap[String(r.user_id ?? "")] ?? { email: "", display_name: null, phone_number: null }; - return { - ...mapUserRow(r), - email: authInfo.email || "No Email", - display_name: authInfo.display_name ?? null, - phone_number: authInfo.phone_number ?? (r.phone_number as string | null) ?? null, - brand_name: r.brands?.name ?? null, - }; - }); - - return { users, error: null }; -} - -function buildUsersFromRows(adminRows: Record[], authUsers: { id: string; email?: string; user_metadata?: Record }[]): { users: AdminUserRow[]; error: string | null } { - const authMap: Record = {}; - (authUsers ?? []).forEach((u) => { + const authMap: Record = {}; + (listData ?? []).forEach((u: any) => { authMap[u.id] = { email: u.email ?? "", - display_name: (u.user_metadata?.display_name as string | null) ?? (u.user_metadata?.full_name as string | null) ?? null, - phone_number: (u.user_metadata?.phone_number as string | null) ?? null, + display_name: u.name ?? null, }; }); - const users: AdminUserRow[] = adminRows.map((row) => { + const users: AdminUserRow[] = (adminRows ?? []).map((row: any) => { const r = row as Record & { brands?: { name?: string } }; - const authInfo = authMap[String(r.user_id ?? "")] ?? { email: "", display_name: null, phone_number: null }; + const authInfo = authMap[String(r.user_id ?? "")] ?? { email: "", display_name: null }; return { ...mapUserRow(r), email: authInfo.email || "No Email", display_name: authInfo.display_name ?? null, - phone_number: authInfo.phone_number ?? (r.phone_number as string | null) ?? null, + phone_number: (r.phone_number as string | null) ?? null, brand_name: r.brands?.name ?? null, }; }); @@ -371,15 +312,6 @@ function buildUsersFromRows(adminRows: Record[], authUsers: { i const DEV_FORCE_UID = "dev-user-00000000-0000-0000-0000-000000000001"; export async function getAdminUsers(brandId?: string): Promise<{ users: AdminUserRow[]; error: string | null }> { - if (useMockData) { - const mockUsers = getMockTableData("users") as AdminUserRow[]; - let filteredUsers = mockUsers; - if (brandId) { - filteredUsers = mockUsers.filter(u => u.brand_id === brandId); - } - return { users: filteredUsers, error: null }; - } - const cookieStore = await cookies(); const headerStore = await headers(); const devSession = cookieStore.get("dev_session")?.value; @@ -390,7 +322,7 @@ export async function getAdminUsers(brandId?: string): Promise<{ users: AdminUse .find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null; // In development mode: ALL requests with a valid rc_auth_uid use the dev/service path. - // This includes real Supabase auth users — bypassing supabase.auth.getUser JWT validation. + // This includes real Supabase auth users — bypassing api.auth.getUser JWT validation. if (process.env.NODE_ENV !== "production" && rcAuthUid && rcAuthUid !== DEV_FORCE_UID) { return devListAdminUsers(rcAuthUid); } @@ -406,38 +338,8 @@ export async function getAdminUsers(brandId?: string): Promise<{ users: AdminUse // Production path: try authenticated RPC first, fall back to service role if not authenticated const result = await callRpcWithAuth("get_admin_users", { p_brand_id: brandId ?? null }); if (result.error === "Not authenticated" && rcAuthUid) { - // No Supabase session token in browser — use service role with rc_auth_uid - const service = getServiceClient(); - const { data: adminRows, error: adminError } = await service - .from("admin_users") - .select(`id, user_id, role, brand_id, active, must_change_password, created_at, last_login, - 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, - brands (name)`) - .order("created_at", { ascending: false }); - if (adminError) return { users: [], error: adminError.message }; - const { data: authData } = await service.auth.admin.listUsers(); - const authMap: Record = {}; - (authData?.users ?? []).forEach((u) => { - const user = u as { id: string; email?: string; user_metadata?: Record }; - authMap[user.id] = { - email: user.email ?? "", - display_name: (user.user_metadata?.display_name as string | null) ?? null, - phone_number: (user.user_metadata?.phone_number as string | null) ?? null, - }; - }); - const users: AdminUserRow[] = (adminRows ?? []).map((row) => { - const r = row as Record & { brands?: { name?: string } }; - const authInfo = authMap[String(r.user_id ?? "")] ?? { email: "", display_name: null, phone_number: null }; - return { - ...mapUserRow(r), - email: authInfo.email || "No Email", - display_name: authInfo.display_name ?? null, - phone_number: authInfo.phone_number ?? (r.phone_number as string | null) ?? null, - brand_name: r.brands?.name ?? null, - }; - }); - return { users, error: null }; + // No better-auth session token in browser — use service role via devListAdminUsers + return devListAdminUsers(rcAuthUid); } return { users: result.data ?? [], error: result.error }; } @@ -466,27 +368,21 @@ export async function createAdminUser(input: CreateAdminUserInput): Promise<{ us // Production path — use service role directly (bypasses Supabase JWT auth) // rc_auth_uid cookie proves the admin is logged in; service role creates the account if (rcAuthUid) { - const service = getServiceClient(); - - // Create auth user - const { data: authUser, error: authError } = await service.auth.admin.createUser({ + // Create auth user via better-auth + const { data: authUser, error: authError } = await authAdmin.createUser({ email: input.email, password: input.password, - email_confirm: true, - user_metadata: { - display_name: input.display_name || input.email.split("@")[0], - phone_number: input.phone_number ?? null, - }, + name: input.display_name || input.email.split("@")[0], }); - if (authError || !authUser.user) { + if (authError || !authUser) { return { user: null, error: authError?.message ?? "Failed to create auth user" }; } - // Insert into admin_users - const { data: inserted, error: insertError } = await service + // Insert into admin_users via service-role PostgREST + const { data: inserted, error: insertError } = await svcApi .from("admin_users") .insert({ - user_id: authUser.user.id, + user_id: authUser.id, role: input.role, brand_id: input.brand_id, display_name: input.display_name || input.email.split("@")[0], @@ -506,7 +402,9 @@ export async function createAdminUser(input: CreateAdminUserInput): Promise<{ us .select() .single(); - if (insertError) return { user: null, error: insertError.message }; + if (insertError || !inserted) { + return { user: null, error: insertError?.message ?? "Insert returned no row" }; + } // Send welcome email try { @@ -525,26 +423,26 @@ export async function createAdminUser(input: CreateAdminUserInput): Promise<{ us return { user: { - id: inserted.id, - user_id: inserted.user_id, + id: inserted.id ?? "", + user_id: inserted.user_id ?? authUser.id, display_name: inserted.display_name ?? input.display_name ?? input.email.split("@")[0], email: input.email, phone_number: inserted.phone_number ?? input.phone_number ?? null, - role: inserted.role, - brand_id: inserted.brand_id, + role: (inserted.role as AdminUserRow["role"]) ?? "store_employee", + brand_id: inserted.brand_id ?? null, brand_name: null, - can_manage_products: inserted.can_manage_products, - can_manage_stops: inserted.can_manage_stops, - can_manage_orders: inserted.can_manage_orders, - can_manage_pickup: inserted.can_manage_pickup, - can_manage_messages: inserted.can_manage_messages, - can_manage_refunds: inserted.can_manage_refunds, - can_manage_users: inserted.can_manage_users, - can_manage_water_log: inserted.can_manage_water_log, - can_manage_reports: inserted.can_manage_reports, - active: inserted.active, + can_manage_products: inserted.can_manage_products ?? false, + can_manage_stops: inserted.can_manage_stops ?? false, + can_manage_orders: inserted.can_manage_orders ?? false, + can_manage_pickup: inserted.can_manage_pickup ?? false, + can_manage_messages: inserted.can_manage_messages ?? false, + can_manage_refunds: inserted.can_manage_refunds ?? false, + can_manage_users: inserted.can_manage_users ?? false, + can_manage_water_log: inserted.can_manage_water_log ?? false, + can_manage_reports: inserted.can_manage_reports ?? false, + active: inserted.active ?? true, must_change_password: inserted.must_change_password ?? true, - created_at: inserted.created_at, + created_at: inserted.created_at ?? new Date().toISOString(), last_login: null, }, error: null, @@ -564,8 +462,7 @@ export async function updateAdminUser(input: UpdateAdminUserInput): Promise<{ us .find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null; // DEV_FORCE_UID bypass — Emergency Force Login sets this cookie directly if (rcAuthUid === DEV_FORCE_UID) { - const service = getServiceClient(); - const { data, error } = await service + const { data, error } = await svcApi .from("admin_users") .update({ role: input.role ?? undefined, @@ -586,8 +483,8 @@ export async function updateAdminUser(input: UpdateAdminUserInput): Promise<{ us .eq("id", input.id) .select() .single(); - if (error) return { user: null, error: error.message }; - return { user: mapUserRow(data), error: null }; + if (error || !data) return { user: null, error: error?.message ?? "Update returned no row" }; + return { user: mapUserRow(data as Record), error: null }; } const result = await callRpcWithAuth("update_admin_user", { @@ -613,20 +510,19 @@ export async function deleteAdminUser(id: string): Promise<{ success: boolean; e .find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null; // DEV_FORCE_UID bypass — Emergency Force Login sets this cookie directly if (rcAuthUid === DEV_FORCE_UID) { - const service = getServiceClient(); // Get user_id first - const { data: adminRow, error: fetchError } = await service + const { data: adminRow, error: fetchError } = await svcApi .from("admin_users") .select("user_id") .eq("id", id) .single(); if (fetchError) return { success: false, error: fetchError.message }; // Delete from admin_users - const { error: deleteError } = await service.from("admin_users").delete().eq("id", id); + const { error: deleteError } = await svcApi.from("admin_users").delete().eq("id", id); if (deleteError) return { success: false, error: deleteError.message }; - // Delete auth user + // Delete auth user via better-auth admin if (adminRow?.user_id) { - await service.auth.admin.deleteUser(adminRow.user_id); + await authAdmin.removeUser(adminRow.user_id); } return { success: true, error: null }; } @@ -643,30 +539,24 @@ export async function setMustChangePassword(userId: string): Promise<{ success: .find(c => c.startsWith("rc_auth_uid="))?.split("=")[1] ?? null; if (rcAuthUid === DEV_FORCE_UID || process.env.NODE_ENV !== "production") { - const service = getServiceClient(); - const { error } = await service.from("admin_users").update({ must_change_password: true }).eq("id", userId); + const { error } = await svcApi.from("admin_users").update({ must_change_password: true }).eq("id", userId); return { success: !error, error: error?.message ?? null }; } // Production path — use service role via direct update - const service = getServiceClient(); - const { error } = await service.from("admin_users").update({ must_change_password: true }).eq("id", userId); + const { error } = await svcApi.from("admin_users").update({ must_change_password: true }).eq("id", userId); return { success: !error, error: error?.message ?? null }; } export async function sendPasswordResetEmail(email: string): Promise<{ success: boolean; error: string | null }> { - const { error } = await publicSupabase.auth.resetPasswordForEmail(email, { + const { error } = await authAdmin.requestPasswordReset({ + email, redirectTo: `${process.env.NEXT_PUBLIC_BASE_URL ?? "http://localhost:3000"}/change-password`, }); return { success: !error, error: error?.message ?? null }; } export async function getBrands(): Promise<{ brands: { id: string; name: string }[]; error: string | null }> { - if (useMockData) { - const brands = mockBrands.map(b => ({ id: b.id, name: b.name })); - return { brands, error: null }; - } - - const { data, error } = await publicSupabase.from("brands").select("id, name").order("name"); + const { data, error } = await publicApi.from("brands").select("id, name").order("name"); return { brands: data ?? [], error: error?.message ?? null }; } \ No newline at end of file diff --git a/src/actions/ai/preferences.ts b/src/actions/ai/preferences.ts index 5d19b40..82182a4 100644 --- a/src/actions/ai/preferences.ts +++ b/src/actions/ai/preferences.ts @@ -1,6 +1,6 @@ "use server"; -import { supabase } from "@/lib/supabase"; +import { api } from "@/lib/api"; export type AIAuthConfig = { provider: string; @@ -18,7 +18,7 @@ export async function getAIPreferences(brandId: string): Promise<{ model?: string; max_tokens?: number; } | null> { - const { data, error } = await supabase + const { data, error } = await api .from("brand_ai_settings") .select("api_key, organization_id, base_url, model, max_tokens") .eq("brand_id", brandId) @@ -32,7 +32,7 @@ export async function saveAIPreferences( brandId: string, config: AIAuthConfig ): Promise<{ success: boolean; error?: string }> { - const { error } = await supabase + const { error } = await api .from("brand_ai_settings") .upsert({ brand_id: brandId, diff --git a/src/actions/analytics.ts b/src/actions/analytics.ts index b656b07..08387e8 100644 --- a/src/actions/analytics.ts +++ b/src/actions/analytics.ts @@ -3,8 +3,8 @@ import { getAdminUser } from "@/lib/admin-permissions"; import { svcHeaders } from "@/lib/svc-headers"; -const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; -const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; +const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!; +const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!; // ── Types ──────────────────────────────────────────────────────────────────── diff --git a/src/actions/audit.ts b/src/actions/audit.ts index 85ca00f..5389b67 100644 --- a/src/actions/audit.ts +++ b/src/actions/audit.ts @@ -27,8 +27,8 @@ type AuditResult = * Audit writes bypass RLS via the SECURITY DEFINER log_audit_event RPC function. */ export async function logAuditEvent(payload: AuditPayload): Promise { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!; + const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!; const adminUser = await getAdminUser(); diff --git a/src/actions/billing/billing-overview.ts b/src/actions/billing/billing-overview.ts index 9e12ff7..1d7994a 100644 --- a/src/actions/billing/billing-overview.ts +++ b/src/actions/billing/billing-overview.ts @@ -39,8 +39,8 @@ import { getAdminUser } from "@/lib/admin-permissions"; import { svcHeaders } from "@/lib/svc-headers"; import { ADDONS, PLAN_TIERS, type AddonKey, type PlanTierKey } from "@/lib/pricing"; -const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; -const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; +const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!; +const supabaseKey = process.env.POSTGREST_SERVICE_KEY!; export type BillingSubscriptionStatus = | "active" diff --git a/src/actions/billing/retail-checkout.ts b/src/actions/billing/retail-checkout.ts index 5708122..e82c07b 100644 --- a/src/actions/billing/retail-checkout.ts +++ b/src/actions/billing/retail-checkout.ts @@ -32,8 +32,8 @@ export async function createRetailStripeCheckoutSession( })); // Get brand name for Stripe metadata - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; + const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!; + const supabaseKey = process.env.POSTGREST_SERVICE_KEY!; let brandName = "Route Commerce"; try { const brandRes = await fetch( diff --git a/src/actions/billing/stripe-checkout.ts b/src/actions/billing/stripe-checkout.ts index 2a6b6f9..12a2c67 100644 --- a/src/actions/billing/stripe-checkout.ts +++ b/src/actions/billing/stripe-checkout.ts @@ -43,8 +43,8 @@ export async function createStripeCheckoutSession( const priceId = getPriceId(priceKey); if (!priceId) return { success: false, error: `No Stripe price configured for "${priceKey}". Set ${priceKey.toUpperCase()}_PRICE in your environment.` }; - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; + const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!; + const supabaseKey = process.env.POSTGREST_SERVICE_KEY!; // Get brand's Stripe customer ID const custRes = await fetch( @@ -123,8 +123,8 @@ export async function cancelAddonSubscription( const stripeKey = process.env.STRIPE_SECRET_KEY; if (!stripeKey) return { success: false, error: "Stripe not configured" }; - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; + const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!; + const supabaseKey = process.env.POSTGREST_SERVICE_KEY!; // Get active subscription for this brand const subRes = await fetch( diff --git a/src/actions/billing/stripe-portal.ts b/src/actions/billing/stripe-portal.ts index 7277ff0..65ea99e 100644 --- a/src/actions/billing/stripe-portal.ts +++ b/src/actions/billing/stripe-portal.ts @@ -13,8 +13,8 @@ export async function getStripeBillingPortalUrl(brandId: string): Promise<{ succ const stripeKey = process.env.STRIPE_SECRET_KEY; if (!stripeKey) return { success: false, error: "Stripe not configured" }; - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; + const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!; + const supabaseKey = process.env.POSTGREST_SERVICE_KEY!; // Get stripe_customer_id from brands table const custRes = await fetch( @@ -49,8 +49,8 @@ export async function updateBrandPlanTier(brandId: string, planTier: string): Pr return { success: false, error: "Invalid plan tier" }; } - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; + const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!; + const supabaseKey = process.env.POSTGREST_SERVICE_KEY!; const res = await fetch( `${supabaseUrl}/rest/v1/rpc/update_brand_plan_tier`, @@ -72,8 +72,8 @@ export async function updateBrandStripeCustomerId(brandId: string, stripeCustome return { success: false, error: "Not authorized" }; } - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; + const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!; + const supabaseKey = process.env.POSTGREST_SERVICE_KEY!; const res = await fetch( `${supabaseUrl}/rest/v1/rpc/update_brand_stripe_customer_id`, @@ -89,8 +89,8 @@ export async function updateBrandStripeCustomerId(brandId: string, stripeCustome } export async function getBrandPlanInfo(brandId: string): Promise<{ success: boolean; data?: any; error?: string }> { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; + const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!; + const supabaseKey = process.env.POSTGREST_SERVICE_KEY!; const res = await fetch( `${supabaseUrl}/rest/v1/rpc/get_brand_plan_info`, @@ -108,8 +108,8 @@ export async function getBrandPlanInfo(brandId: string): Promise<{ success: bool } export async function getEnabledAddons(brandId: string): Promise> { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; + const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!; + const supabaseKey = process.env.POSTGREST_SERVICE_KEY!; const res = await fetch( `${supabaseUrl}/rest/v1/rpc/get_brand_features`, @@ -128,8 +128,8 @@ export async function getEnabledAddons(brandId: string): Promise { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; + const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!; + const supabaseKey = process.env.POSTGREST_SERVICE_KEY!; const res = await fetch( `${supabaseUrl}/rest/v1/rpc/get_wholesale_orders`, diff --git a/src/actions/brand-settings.ts b/src/actions/brand-settings.ts index 939af8e..90e3046 100644 --- a/src/actions/brand-settings.ts +++ b/src/actions/brand-settings.ts @@ -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); - 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_API_URL!; + const supabaseKey = process.env.POSTGREST_SERVICE_KEY!; - // 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_API_URL!; + const supabaseKey = process.env.POSTGREST_SERVICE_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_API_URL!; + const supabaseKey = process.env.POSTGREST_SERVICE_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 = { @@ -252,8 +246,8 @@ export type SaveBrandSettingsResult = | { success: false; error: string }; export async function getBrandSettings(brandId: string): Promise { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!; + const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!; const response = await fetch( `${supabaseUrl}/rest/v1/rpc/get_brand_settings`, @@ -271,8 +265,8 @@ export async function getBrandSettings(brandId: string): Promise { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!; + const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!; const response = await fetch( `${supabaseUrl}/rest/v1/rpc/get_brand_settings_by_slug`, @@ -337,8 +331,8 @@ export async function saveBrandSettings(params: { return { success: false, error: "Not authorized for this brand" }; } - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!; + const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!; const response = await fetch( `${supabaseUrl}/rest/v1/rpc/upsert_brand_settings`, diff --git a/src/actions/checkout.ts b/src/actions/checkout.ts index fbf0d1b..b9eab6e 100644 --- a/src/actions/checkout.ts +++ b/src/actions/checkout.ts @@ -64,8 +64,8 @@ export async function createOrder( brandId?: string, shippingAddress?: ShippingAddress ): Promise { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!; + const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!; // ── Calculate tax if brand collects tax ───────────────────────────────── let taxAmount = 0; @@ -150,8 +150,8 @@ export async function createOrder( // ── Cart Persistence ────────────────────────────────────────────────────────── export async function getServerCart(userId: string): Promise { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!; + const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!; try { const response = await fetch( @@ -177,8 +177,8 @@ export async function mergeLocalCart( ): Promise<{ merged: CartItem[] }> { if (!localCart || localCart.length === 0) return { merged: [] }; - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!; + const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!; // Fetch server cart let serverCart: CartItem[] = []; @@ -243,8 +243,8 @@ export async function mergeLocalCart( } export async function clearServerCart(userId: string): Promise { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!; + const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!; try { await fetch( diff --git a/src/actions/communications/campaigns.ts b/src/actions/communications/campaigns.ts index d752956..0ba1a7e 100644 --- a/src/actions/communications/campaigns.ts +++ b/src/actions/communications/campaigns.ts @@ -59,8 +59,8 @@ export async function getCommunicationCampaigns(brandId?: string): Promise 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, }; } @@ -84,10 +66,9 @@ export async function processBucketImport( 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!; + const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!; + const supabaseKey = process.env.POSTGREST_SERVICE_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 = { @@ -160,4 +124,4 @@ export type ImportHistoryItem = { size: number; createdAt: string; url: string; -}; \ No newline at end of file +}; diff --git a/src/actions/communications/segments.ts b/src/actions/communications/segments.ts index db33382..b0f93eb 100644 --- a/src/actions/communications/segments.ts +++ b/src/actions/communications/segments.ts @@ -33,8 +33,8 @@ export async function getCommunicationSegments( return { success: false, error: "Not authorized" }; } - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!; + const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!; const response = await fetch( `${supabaseUrl}/rest/v1/rpc/get_communication_segments`, @@ -64,8 +64,8 @@ export async function upsertSegment(params: { return { success: false, error: "Not authorized" }; } - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!; + const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!; const response = await fetch( `${supabaseUrl}/rest/v1/rpc/upsert_communication_segment`, @@ -99,8 +99,8 @@ export async function deleteSegment( return { success: false, error: "Not authorized" }; } - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!; + const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!; const response = await fetch( `${supabaseUrl}/rest/v1/rpc/delete_communication_segment`, diff --git a/src/actions/communications/send.ts b/src/actions/communications/send.ts index 9fe1c48..bfc7746 100644 --- a/src/actions/communications/send.ts +++ b/src/actions/communications/send.ts @@ -21,8 +21,8 @@ export async function previewCampaignAudience( return null; } - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!; + const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!; const response = await fetch( `${supabaseUrl}/rest/v1/rpc/preview_campaign_audience`, @@ -86,8 +86,8 @@ export async function getMessageLogs(params: { return { success: false, error: "Not authorized to view these logs" }; } - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!; + const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!; const response = await fetch( `${supabaseUrl}/rest/v1/rpc/get_message_logs`, @@ -128,8 +128,8 @@ export async function sendCampaign(campaignId: string, brandId?: string): Promis return { success: false, error: "Not authorized to send this brand's campaigns" }; } - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!; + const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!; const response = await fetch( `${supabaseUrl}/rest/v1/rpc/send_campaign`, diff --git a/src/actions/communications/settings.ts b/src/actions/communications/settings.ts index 2626573..8a80ce7 100644 --- a/src/actions/communications/settings.ts +++ b/src/actions/communications/settings.ts @@ -24,8 +24,8 @@ export type UpsertSettingsResult = { }; export async function getCommunicationSettings(brandId: string): Promise { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!; + const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!; const response = await fetch( `${supabaseUrl}/rest/v1/rpc/get_communication_settings`, @@ -57,8 +57,8 @@ export async function upsertCommunicationSettings(params: { return { success: false, error: "Not authorized to modify this brand's communication settings" }; } - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!; + const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!; const response = await fetch( `${supabaseUrl}/rest/v1/rpc/upsert_communication_settings`, diff --git a/src/actions/communications/stop-blast.ts b/src/actions/communications/stop-blast.ts index fc4edcf..a1857f9 100644 --- a/src/actions/communications/stop-blast.ts +++ b/src/actions/communications/stop-blast.ts @@ -22,8 +22,8 @@ export async function sendStopBlast(params: { return { success: false, error: "Not authorized" }; } - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; + const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!; + const supabaseKey = process.env.POSTGREST_SERVICE_KEY!; const response = await fetch( `${supabaseUrl}/rest/v1/rpc/send_stop_blast`, diff --git a/src/actions/communications/templates.ts b/src/actions/communications/templates.ts index 0ee0651..d88eb0a 100644 --- a/src/actions/communications/templates.ts +++ b/src/actions/communications/templates.ts @@ -40,8 +40,8 @@ export async function getCommunicationTemplates(brandId?: string): Promise<{ suc return { success: true, templates: [] }; } - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!; + const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!; const response = await fetch( `${supabaseUrl}/rest/v1/rpc/get_communication_templates`, @@ -70,8 +70,8 @@ export async function upsertTemplate(params: { const adminUser = await getAdminUser(); if (!adminUser) return { success: false, error: "Not authenticated" }; - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + const supabaseUrl = process.env.NEXT_PUBLIC_API_URL!; + const supabaseKey = process.env.NEXT_PUBLIC_API_ANON_KEY!; const response = await fetch( `${supabaseUrl}/rest/v1/rpc/upsert_communication_template`, @@ -104,8 +104,8 @@ export async function getTemplateById(templateId: string): Promise