Files
route-commerce/019e9554-37c8-7752-a9d5-0264371602a9/plan.md
T
tyler c20538ef9f Add MinIO storage + replace Supabase Storage with S3 SDK
- New: src/lib/storage.ts — S3-compatible client, uploadFile/deleteFile/listFiles/publicUrl helpers, bucket constants
- New: docker-compose adds minio + minio_init services
- Replaced Supabase fetch PUTs in:
  - src/actions/brand-settings.ts (3 uploaders)
  - src/actions/products/upload-image.ts
  - src/actions/communications/import-contacts.ts
  - src/app/api/water-photo-upload/route.ts
- Replaced hardcoded Supabase URLs in:
  - src/components/storefront/TuxedoVideoHero.tsx
  - src/components/time-tracking/TimeTrackingFieldClient.tsx
  - src/app/tuxedo/about/page.tsx
  - src/lib/email-service.ts (4 occurrences)
- Added @aws-sdk/client-s3 + @aws-sdk/s3-request-presigner
- .env.example adds MinIO + storage vars
- Migration cleanup: deleted BUNDLE_018_042, 4 XXX drafts, 087/145/099-contact storage migrations
- Migration patches: 006 STATIC→STABLE, 135 param reordering
- Preflight: added pgcrypto extension, removed storage stub
- Verified: MinIO upload/list/delete round-trip works against local instance
2026-06-05 15:17:21 +00:00

15 KiB

Self-Hosted Storage + Schema Plan

Context

The user is migrating Route Commerce from Supabase to a self-hosted stack:

  • Already done in this branch (feat/better-auth): Better Auth (auth), Docker Postgres, PostgREST, env var rewiring, ~10 source files updated to drop Supabase JS client.
  • What's broken right now: The website is missing images (Supabase Storage URLs 404 because Supabase is going away), and the local Postgres can't apply the 137 migrations because the base schema is missing (the initial tables — brands, orders, products, stops, admin_users — were created in Supabase directly and never exported as a migration).
  • User constraint: "no band-aids" — proper self-hosted replacement, not runtime patches.
  • User constraint: "no data migration" — start with fresh empty DB; users re-upload assets.

Approach

Three independent workstreams, executed in order:

  1. Capture the base schema from the still-live Supabase project using pg_dump --schema-only (recommended over supabase db pull, which has a broken migration history). Apply the captured schema + existing 137 migrations to the local self-hosted Postgres.
  2. Stand up MinIO in Docker as the S3-compatible object store. Wire it to the app via the AWS SDK. MinIO's URL structure is clean: http://minio:9000/<bucket>/<key>, publicly readable per-bucket via bucket policy.
  3. Replace every Supabase Storage URL in the codebase with MinIO URLs (one env var: NEXT_PUBLIC_STORAGE_BASE_URL).

Storage buckets in use (from explore agent):

Bucket Purpose Current state
brand-logos Brand logo images Hardcoded in 8+ files via env-var URL
product-images Product photos 80aa01da-ab4b-44f8-b6e7-700552457e18 (Supabase bucket UUID)
contacts-imports CSV contact imports a1b2c3d4-… (Supabase bucket UUID)
videos Tuxedo video hero Hardcoded Supabase URL in TuxedoVideoHero.tsx
water-photos (dynamic) Water log photos API route, bucket name in form field

Implementation

Phase A — Capture base schema and apply to local Postgres

Why pg_dump, not supabase db pull: The remote Supabase project's supabase_migrations.schema_migrations history is out of sync with the local files (that's the long error list the user got). supabase db pull requires that history to be in sync before it'll write a new initial migration. pg_dump reads the live catalog directly and ignores the migration history entirely.

Steps:

  1. Capture the schema from the remote Supabase DB (run on the user's Mac where direct PG works — MEMORY.md confirms direct PG is blocked from this dev box, but their Mac can do it):

    pg_dump "postgresql://postgres:<service-role-pw>@db.wnzkhezyhnfzhkhiflrp.supabase.co:5432/postgres" \
      --schema-only --no-owner --no-privileges \
      --schema=public \
      -f supabase/captured_schema.sql
    

    Exclude auth and storage schemas — we stub auth ourselves and use MinIO instead of Supabase Storage. Note: 185 SECURITY DEFINER functions reference auth.uid(); these will compile against the preflight stub but return NULL at runtime. Phase D addresses this.

  2. Delete files that don't belong in the local apply order:

    • BUNDLE_018_042.sql — concatenated duplicate
    • XXX_blog_tables.sql, XXX_launch_checklist.sql, XXX_roadmap_tables.sql, XXX_waitlist_waitlist.sql — drafts (XXX convention)
    • 099_contact_imports_bucket.sql (the bucket-creation one — keep the RPCs though)
    • Rename to disambiguate any number collisions (no current collision, but defensive)
  3. Patch the 4 broken migrations identified by the explore agent (read the file first, then search_replace):

    • 006_water_log_rpcs_fixed.sql: replace STATIC with STABLE (6 occurrences)
    • 087_brand_logos_bucket.sql: convert CREATE POLICY IF NOT EXISTS … to DROP POLICY IF EXISTS …; CREATE POLICY …; (4 policies)
    • 099_harvest_reach_segmentation.sql: quote the time column references (matches the 148 patch)
    • 135_email_automation_rpcs.sql: reorder enroll_abandoned_cart params so defaults come last, or add a default to p_next_email_at
  4. Update 000_preflight_supabase_compat.sql (already in the branch):

    • Add CREATE EXTENSION IF NOT EXISTS pgcrypto; at the top
    • Remove the storage.buckets / storage.objects stub (MinIO replaces it; 087/145 storage policies will be deleted in step 2)
  5. Apply to local Postgres (running on this dev box, port 5432):

    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):

  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):

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.tscontacts-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:

    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:

    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:

    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:

    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 STATICSTABLE
supabase/migrations/087_brand_logos_bucket.sql CREATE POLICY IF NOT EXISTSDROP + 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.