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
This commit is contained in:
2026-06-05 02:33:05 +00:00
parent 367a5625f3
commit 456b5b1375
42 changed files with 973 additions and 5608 deletions
@@ -0,0 +1,71 @@
-- 000_preflight_supabase_compat.sql
-- Stubs out Supabase-specific schemas/roles/functions so the
-- Supabase-flavored migrations in this folder can apply to plain
-- Postgres + PostgREST. Run FIRST.
--
-- In a real Supabase deployment, these are created automatically by
-- the platform. Here we recreate the minimum surface area the rest
-- of the migrations depend on.
-- ── Extensions Supabase preinstalls ─────────────────────────────────
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- ── Roles Supabase normally provides ──────────────────────────────────
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN
CREATE ROLE anon NOLOGIN;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN
CREATE ROLE authenticated NOLOGIN;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'service_role') THEN
CREATE ROLE service_role NOLOGIN BYPASSRLS;
END IF;
END
$$;
-- ── auth schema (Supabase Auth lives here; we just need uid() and friends)
CREATE SCHEMA IF NOT EXISTS auth;
-- auth.uid() — Supabase returns the user id from the JWT. In our self-hosted
-- setup the app can SET LOCAL "request.jwt.claim.sub" = '<uuid>' before
-- calling PostgREST. Default to NULL for unauthenticated requests.
CREATE OR REPLACE FUNCTION auth.uid()
RETURNS UUID
LANGUAGE sql
STABLE
AS $$
SELECT NULLIF(current_setting('request.jwt.claim.sub', true), '')::UUID
$$;
CREATE OR REPLACE FUNCTION auth.role()
RETURNS TEXT
LANGUAGE sql
STABLE
AS $$
SELECT COALESCE(NULLIF(current_setting('request.jwt.claim.role', true), ''), 'anon')
$$;
CREATE OR REPLACE FUNCTION auth.email()
RETURNS TEXT
LANGUAGE sql
STABLE
AS $$
SELECT NULLIF(current_setting('request.jwt.claim.email', true), '')
$$;
-- ── storage schema intentionally omitted ───────────────────────────
-- We're replacing Supabase Storage with MinIO. The storage migrations
-- (087, 099-contact-imports, 145) have been deleted from this folder.
-- Grant the stub roles access to the public schema so RPCs can be called as them
GRANT USAGE ON SCHEMA public TO anon, authenticated, service_role;
GRANT USAGE ON SCHEMA auth TO anon, authenticated, service_role;
-- Make sure the routecommerce user can act as these roles (PostgREST will
-- SET ROLE before executing requests, depending on the JWT).
GRANT anon TO routecommerce;
GRANT authenticated TO routecommerce;
GRANT service_role TO routecommerce;
@@ -25,7 +25,7 @@ DROP FUNCTION IF EXISTS public.get_water_entries(uuid);
CREATE OR REPLACE FUNCTION public.verify_water_pin(p_brand_id uuid, p_pin text)
RETURNS jsonb
LANGUAGE plpgsql
STATIC
STABLE
AS $$
DECLARE
v_user jsonb;
@@ -84,7 +84,7 @@ CREATE OR REPLACE FUNCTION public.create_water_user(
)
RETURNS jsonb
LANGUAGE plpgsql
STATIC
STABLE
AS $$
DECLARE
v_brand_id uuid;
@@ -125,7 +125,7 @@ COMMENT ON FUNCTION public.create_water_user IS
CREATE OR REPLACE FUNCTION public.reset_water_user_pin(p_user_id uuid)
RETURNS jsonb
LANGUAGE plpgsql
STATIC
STABLE
AS $$
DECLARE
v_raw_pin text;
@@ -160,7 +160,7 @@ CREATE OR REPLACE FUNCTION public.submit_water_entry(
)
RETURNS jsonb
LANGUAGE plpgsql
STATIC
STABLE
AS $$
DECLARE
v_sess record;
@@ -204,7 +204,7 @@ CREATE OR REPLACE FUNCTION public.update_water_user(
)
RETURNS jsonb
LANGUAGE plpgsql
STATIC
STABLE
AS $$
BEGIN
UPDATE public.water_users
@@ -226,7 +226,7 @@ $$;
CREATE OR REPLACE FUNCTION public.get_water_entries(p_brand_id uuid, p_limit int DEFAULT 50)
RETURNS jsonb
LANGUAGE plpgsql
STATIC
STABLE
AS $$
BEGIN
RETURN (
@@ -1,77 +0,0 @@
-- Migration 087: Brand Logos Storage Bucket
--
-- SETUP REQUIRED (one-time, run manually in Supabase dashboard):
-- 1. Go to Storage → New bucket
-- 2. Name: "brand-logos" | Public: true
-- 3. Allowed MIME types: image/png, image/jpeg, image/webp, image/svg+xml
-- 4. Max file size: 5MB
--
-- Files stored at: brand-logos/{brand_id}/logo.png, brand-logos/{brand_id}/logo-dark.png
-- RLS policies for brand-logos bucket
-- Brand admins can upload/update their own brand's logos
-- Everyone can read logos (public bucket)
CREATE POLICY IF NOT EXISTS "brand_admin_upload_logo"
ON storage.objects
FOR INSERT
WITH CHECK (
bucket_id = 'brand-logos'
AND (
-- Platform admin can upload to any brand
EXISTS (
SELECT 1 FROM admin_users au
WHERE au.user_id = auth.uid() AND au.role = 'platform_admin'
)
OR
-- Brand admin can upload to their own brand's folder
(storage.foldername(name))[1] IN (
SELECT au.brand_id::text FROM admin_users au
WHERE au.user_id = auth.uid()
AND au.role = 'brand_admin'
)
)
);
CREATE POLICY IF NOT EXISTS "public_read_logo"
ON storage.objects
FOR SELECT
USING (bucket_id = 'brand-logos');
CREATE POLICY IF NOT EXISTS "brand_admin_update_logo"
ON storage.objects
FOR UPDATE
USING (
bucket_id = 'brand-logos'
AND (
EXISTS (
SELECT 1 FROM admin_users au
WHERE au.user_id = auth.uid() AND au.role = 'platform_admin'
)
OR
(storage.foldername(name))[1] IN (
SELECT au.brand_id::text FROM admin_users au
WHERE au.user_id = auth.uid()
AND au.role = 'brand_admin'
)
)
);
CREATE POLICY IF NOT EXISTS "brand_admin_delete_logo"
ON storage.objects
FOR DELETE
USING (
bucket_id = 'brand-logos'
AND (
EXISTS (
SELECT 1 FROM admin_users au
WHERE au.user_id = auth.uid() AND au.role = 'platform_admin'
)
OR
(storage.foldername(name))[1] IN (
SELECT au.brand_id::text FROM admin_users au
WHERE au.user_id = auth.uid()
AND au.role = 'brand_admin'
)
)
);
@@ -1,36 +0,0 @@
-- Create imports bucket if it doesn't exist
-- Note: Run this in Supabase dashboard or via CLI
-- supabase storage create contacts-imports --public
-- Create RPC function to process imports from bucket URL
CREATE OR REPLACE FUNCTION process_contact_import_from_url(
p_brand_id UUID,
p_file_url TEXT,
p_allow_opt_in_override BOOLEAN DEFAULT false
)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
v_result JSONB;
v_csv_text TEXT;
BEGIN
-- Fetch the CSV from the bucket URL
SELECT content INTO v_csv_text
FROM net.http_get(p_file_url);
-- Process the contacts (this would need to be implemented based on your CSV parsing logic)
-- For now, return a placeholder result
-- You would call your existing import logic here
v_result := jsonb_build_object(
'created', 0,
'updated', 0,
'skipped', 0,
'errors', 0
);
RETURN v_result;
END;
$$;
@@ -37,7 +37,7 @@ CREATE OR REPLACE FUNCTION enroll_abandoned_cart(
p_cart_snapshot JSONB,
p_brand_name TEXT,
p_locale TEXT DEFAULT 'en',
p_next_email_at TIMESTAMPTZ
p_next_email_at TIMESTAMPTZ DEFAULT NULL
)
RETURNS UUID
LANGUAGE plpgsql
@@ -1,17 +0,0 @@
-- Create product-images bucket for product images (idempotent)
INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
SELECT 'product-images', 'product-images', true, 5242880, ARRAY['image/png', 'image/jpeg', 'image/webp']
WHERE NOT EXISTS (
SELECT 1 FROM storage.buckets WHERE id = 'product-images' OR name = 'product-images'
);
-- Enable public access to product-images bucket (re-runnable)
DROP POLICY IF EXISTS "Public can view product-images" ON storage.objects;
CREATE POLICY "Public can view product-images"
ON storage.objects FOR SELECT
USING (bucket_id = 'product-images');
DROP POLICY IF EXISTS "Admins can upload product-images" ON storage.objects;
CREATE POLICY "Admins can upload product-images"
ON storage.objects FOR INSERT
WITH CHECK (bucket_id = 'product-images');
@@ -0,0 +1,61 @@
-- 200_better_auth_tables.sql
-- Better Auth core tables: user, session, account, verification
-- Replaces Supabase Auth for the self-hosted Postgres deployment.
-- user.id is UUID (matches admin_users.user_id FK).
CREATE TABLE IF NOT EXISTS "user" (
"id" UUID PRIMARY KEY,
"name" TEXT NOT NULL,
"email" TEXT NOT NULL UNIQUE,
"emailVerified" BOOLEAN NOT NULL DEFAULT FALSE,
"image" TEXT,
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS "session" (
"id" UUID PRIMARY KEY,
"expiresAt" TIMESTAMPTZ NOT NULL,
"token" TEXT NOT NULL UNIQUE,
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMPTZ NOT NULL DEFAULT now(),
"ipAddress" TEXT,
"userAgent" TEXT,
"userId" UUID NOT NULL REFERENCES "user"("id") ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS "idx_session_userId" ON "session"("userId");
CREATE TABLE IF NOT EXISTS "account" (
"id" UUID PRIMARY KEY,
"accountId" TEXT NOT NULL,
"providerId" TEXT NOT NULL,
"userId" UUID NOT NULL REFERENCES "user"("id") ON DELETE CASCADE,
"accessToken" TEXT,
"refreshToken" TEXT,
"idToken" TEXT,
"accessTokenExpiresAt" TIMESTAMPTZ,
"refreshTokenExpiresAt" TIMESTAMPTZ,
"scope" TEXT,
"password" TEXT,
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS "idx_account_userId" ON "account"("userId");
CREATE INDEX IF NOT EXISTS "idx_account_providerId" ON "account"("providerId");
CREATE TABLE IF NOT EXISTS "verification" (
"id" UUID PRIMARY KEY,
"identifier" TEXT NOT NULL,
"value" TEXT NOT NULL,
"expiresAt" TIMESTAMPTZ NOT NULL,
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS "idx_verification_identifier" ON "verification"("identifier");
-- Better Auth does not need RLS on these tables — auth checks happen in app code.
-- Disable RLS so existing SECURITY DEFINER RPCs can still read user rows if needed.
ALTER TABLE "user" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "session" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "account" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "verification" DISABLE ROW LEVEL SECURITY;
File diff suppressed because it is too large Load Diff
-77
View File
@@ -1,77 +0,0 @@
-- Blog and Newsletter Tables
-- Migration for Route Commerce
CREATE TABLE IF NOT EXISTS blog_posts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT UNIQUE NOT NULL,
title TEXT NOT NULL,
excerpt TEXT,
content TEXT,
featured_image TEXT,
author_name TEXT NOT NULL,
author_avatar TEXT,
category TEXT DEFAULT 'General',
tags TEXT[],
published_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
published BOOLEAN DEFAULT FALSE
);
CREATE TABLE IF NOT EXISTS blog_resources (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
description TEXT,
type TEXT DEFAULT 'guide' CHECK (type IN ('guide', 'case_study', 'webinar', 'template', 'checklist')),
url TEXT,
thumbnail TEXT,
downloads INTEGER DEFAULT 0,
featured BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS newsletter_subscribers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT UNIQUE NOT NULL,
subscribed_at TIMESTAMPTZ DEFAULT NOW(),
status TEXT DEFAULT 'active' CHECK (status IN ('active', 'unsubscribed', 'bounced')),
source TEXT,
unsubscribed_at TIMESTAMPTZ
);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_blog_slug ON blog_posts(slug);
CREATE INDEX IF NOT EXISTS idx_blog_published ON blog_posts(published);
CREATE INDEX IF NOT EXISTS idx_blog_category ON blog_posts(category);
CREATE INDEX IF NOT EXISTS idx_newsletter_email ON newsletter_subscribers(email);
-- Grant permissions
GRANT SELECT, INSERT ON blog_posts TO anon;
GRANT SELECT, INSERT ON blog_resources TO anon;
GRANT SELECT, INSERT ON newsletter_subscribers TO anon;
GRANT ALL ON blog_posts TO authenticated;
GRANT ALL ON blog_resources TO authenticated;
GRANT ALL ON newsletter_subscribers TO authenticated;
GRANT ALL ON blog_posts TO service_role;
GRANT ALL ON blog_resources TO service_role;
GRANT ALL ON newsletter_subscribers TO service_role;
-- Seed blog posts
INSERT INTO blog_posts (slug, title, excerpt, content, author_name, category, published_at, published) VALUES
('getting-started-with-route-commerce', 'Getting Started with Route Commerce', 'Learn how to set up your wholesale business on Route Commerce in under 10 minutes.', '## Getting Started
Welcome to Route Commerce! This guide will walk you through setting up your wholesale operation...', 'Team Route Commerce', 'Guides', NOW(), TRUE),
('maximize-produce-profitability', '5 Tips to Maximize Your Produce Profitability', 'Strategic pricing and inventory management can significantly boost your bottom line.', '## Introduction
Running a profitable produce operation requires more than just quality products...', 'Sarah Johnson', 'Tips', NOW(), TRUE),
('customer-communication-best-practices', 'Best Practices for Customer Communication', 'Keep your customers informed and engaged with these communication strategies.', '## Why Communication Matters
Clear, timely communication builds trust and reduces missed pickups...', 'Marcus Chen', 'Marketing', NOW(), TRUE)
ON CONFLICT DO NOTHING;
-- Seed resources
INSERT INTO blog_resources (title, description, type, downloads, featured) VALUES
('Wholesale Pricing Guide', 'Complete guide to setting profitable wholesale prices', 'guide', 342, TRUE),
('Order Management Checklist', 'Step-by-step checklist for order fulfillment', 'checklist', 256, FALSE),
('Customer Email Templates', 'Pre-written email templates for common scenarios', 'template', 189, FALSE)
ON CONFLICT DO NOTHING;
@@ -1,22 +0,0 @@
-- Launch Checklist Progress Table
-- Track user's launch preparation progress
CREATE TABLE IF NOT EXISTS launch_checklist_progress (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id TEXT NOT NULL,
item_id TEXT NOT NULL,
completed BOOLEAN DEFAULT FALSE,
completed_at TIMESTAMPTZ,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, item_id)
);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_checklist_user ON launch_checklist_progress(user_id);
CREATE INDEX IF NOT EXISTS idx_checklist_completed ON launch_checklist_progress(completed) WHERE completed = TRUE;
-- Grant permissions
GRANT SELECT, INSERT, UPDATE ON launch_checklist_progress TO authenticated;
GRANT ALL ON launch_checklist_progress TO service_role;
@@ -1,45 +0,0 @@
-- Roadmap Tables for Feature Requests and Voting
-- Migration for Route Commerce
CREATE TABLE IF NOT EXISTS roadmap_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
description TEXT,
status TEXT DEFAULT 'planned' CHECK (status IN ('planned', 'in_progress', 'shipped')),
category TEXT,
upvotes INTEGER DEFAULT 0,
created_by TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS roadmap_votes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
item_id UUID REFERENCES roadmap_items(id) ON DELETE CASCADE,
visitor_id TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(item_id, visitor_id)
);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_roadmap_status ON roadmap_items(status);
CREATE INDEX IF NOT EXISTS idx_roadmap_category ON roadmap_items(category);
CREATE INDEX IF NOT EXISTS idx_roadmap_upvotes ON roadmap_items(upvotes DESC);
CREATE INDEX IF NOT EXISTS idx_roadmap_votes_item ON roadmap_votes(item_id);
-- Grant permissions
GRANT SELECT ON roadmap_items TO anon;
GRANT SELECT, INSERT ON roadmap_votes TO anon;
GRANT ALL ON roadmap_items TO authenticated;
GRANT ALL ON roadmap_votes TO authenticated;
GRANT ALL ON roadmap_items TO service_role;
GRANT ALL ON roadmap_votes TO service_role;
-- Seed some initial items
INSERT INTO roadmap_items (title, description, status, category, upvotes) VALUES
('Mobile App (iOS & Android)', 'Native apps for field workers and delivery drivers', 'in_progress', 'Mobile', 234),
('SMS Campaigns', 'Text message marketing and notifications', 'planned', 'Communication', 98),
('Route Optimization', 'AI-powered route planning for deliveries', 'planned', 'Logistics', 167),
('Customer Loyalty Program', 'Points, rewards, and referral tracking', 'planned', 'Marketing', 112),
('POS Integration (Clover, Toast)', 'Additional POS system integrations', 'planned', 'Integrations', 76)
ON CONFLICT DO NOTHING;
@@ -1,30 +0,0 @@
-- Waitlist and Early Access Signup System
-- Migration for Route Commerce
CREATE TABLE IF NOT EXISTS waitlist (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT UNIQUE NOT NULL,
name TEXT,
referral_source TEXT,
referred_by TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'converted', 'archived'))
);
-- Index for email lookups
CREATE INDEX IF NOT EXISTS idx_waitlist_email ON waitlist(email);
CREATE INDEX IF NOT EXISTS idx_waitlist_status ON waitlist(status);
CREATE INDEX IF NOT EXISTS idx_waitlist_created ON waitlist(created_at DESC);
-- Grant permissions
GRANT SELECT, INSERT ON waitlist TO anon;
GRANT SELECT, INSERT ON waitlist TO authenticated;
GRANT SELECT, INSERT ON waitlist TO service_role;
-- Comment on table
COMMENT ON TABLE waitlist IS 'Early access signup for Route Commerce platform';
COMMENT ON COLUMN waitlist.email IS 'Unique email address';
COMMENT ON COLUMN waitlist.name IS 'Optional full name';
COMMENT ON COLUMN waitlist.referral_source IS 'How they heard about us';
COMMENT ON COLUMN waitlist.referred_by IS 'Email of person who referred them';
COMMENT ON COLUMN waitlist.status IS 'pending, converted (signed up), archived';