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 66d8cdf9b2
commit c20538ef9f
42 changed files with 973 additions and 5608 deletions
+12 -11
View File
@@ -2,9 +2,10 @@
import { useState } from "react";
import Link from "next/link";
import { supabase } from "@/lib/supabase";
import { authClient } from "@/lib/auth-client";
import { AdminUserRow } from "@/actions/admin/users";
import { logUserActivity } from "@/actions/admin/audit";
import { updateAdminProfileAction } from "@/actions/admin/profile";
type ProfilePageProps = {
currentUser: AdminUserRow;
@@ -26,13 +27,13 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
setSaving(true);
setError(null);
try {
const { error: rpcError } = await supabase.rpc("update_admin_user", {
p_id: currentUser.id,
p_display_name: displayName || null,
p_phone_number: phoneNumber || null,
});
if (rpcError) {
setError(rpcError.message);
const result = await updateAdminProfileAction(
currentUser.id,
displayName || null,
phoneNumber || null
);
if (!result.success) {
setError(result.error);
return;
}
await logUserActivity({
@@ -51,11 +52,11 @@ export default function AdminMeClient({ currentUser }: ProfilePageProps) {
setChangingEmail(true);
setEmailError(null);
try {
const { error: updateError } = await supabase.auth.updateUser({
email: newEmail,
const { error: updateError } = await authClient.changeEmail({
newEmail: newEmail,
});
if (updateError) {
setEmailError(updateError.message);
setEmailError(updateError.message ?? "Failed to change email");
return;
}
await logUserActivity({
+4
View File
@@ -0,0 +1,4 @@
import { auth } from "@/lib/auth";
import { toNextJsHandler } from "better-auth/next-js";
export const { GET, POST } = toNextJsHandler(auth);
+17 -26
View File
@@ -1,10 +1,11 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { svcHeaders } from "@/lib/svc-headers";
import { uploadFile, publicUrl, BUCKETS } from "@/lib/storage";
const ALLOWED_BUCKETS = Object.values(BUCKETS);
export async function POST(request: Request) {
try {
// Validate session — irrigators use wl_session, admins use wl_admin_session
const cookieStore = await cookies();
const sessionId = cookieStore.get("wl_session")?.value ?? cookieStore.get("wl_admin_session")?.value;
if (!sessionId) {
@@ -19,6 +20,10 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Missing file or bucket" }, { status: 400 });
}
if (!ALLOWED_BUCKETS.includes(bucket as (typeof ALLOWED_BUCKETS)[number])) {
return NextResponse.json({ error: "Invalid bucket" }, { status: 400 });
}
if (file.size > 5 * 1024 * 1024) {
return NextResponse.json({ error: "File too large (max 5MB)" }, { status: 400 });
}
@@ -27,35 +32,21 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Only JPG or PNG allowed" }, { status: 400 });
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabasePat = process.env.SUPABASE_PAT!;
const ext = file.type === "image/jpeg" ? "jpg" : "png";
const fileName = `${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`;
const arrayBuffer = await file.arrayBuffer();
const uint8 = new Uint8Array(arrayBuffer);
const buffer = Buffer.from(arrayBuffer);
const uploadRes = await fetch(
`${supabaseUrl}/storage/v1/object/${bucket}/${fileName}`,
{
method: "POST",
headers: {
...svcHeaders(supabasePat),
"Content-Type": file.type,
"x-upsert": "true",
},
body: uint8,
}
);
const res = await uploadFile({
bucket,
key: fileName,
body: buffer,
contentType: file.type,
});
if (!uploadRes.ok) {
return NextResponse.json({ error: "Upload failed" }, { status: 500 });
}
const publicUrl = `${supabaseUrl}/storage/v1/object/public/${bucket}/${fileName}`;
return NextResponse.json({ url: publicUrl });
return NextResponse.json({ url: res.url });
} catch (err) {
return NextResponse.json({ error: "Server error" }, { status: 500 });
return NextResponse.json({ error: (err as Error).message || "Server error" }, { status: 500 });
}
}
}
@@ -5,6 +5,7 @@ import IndianRiverStopsList from "./IndianRiverStopsList";
// Page-level cache — matches the 5-min revalidate in getPublicStopsForBrand.
// Mutations call revalidateTag("stops") to invalidate.
export const revalidate = 300;
export const dynamic = "force-dynamic";
export default async function IndianRiverStopsPage() {
const [stops, settings] = await Promise.all([
+8 -14
View File
@@ -2,30 +2,24 @@
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { supabase } from "@/lib/supabase";
import { authClient } from "@/lib/auth-client";
export default function LogoutPage() {
const router = useRouter();
useEffect(() => {
// Clear all auth cookies — dev_session, rc_auth_uid, rc_auth_token
// Clear all auth cookies — dev_session, rc_session_token
document.cookie = "dev_session=;path=/;max-age=0";
document.cookie = "rc_auth_uid=;path=/;max-age=0";
document.cookie = "rc_auth_token=;path=/;max-age=0";
document.cookie = "rc_session_token=;path=/;max-age=0";
document.cookie = "wholesale_session=;path=/;max-age=0";
// Clear shopping cart on logout
localStorage.removeItem("route_commerce_cart");
localStorage.removeItem("route_commerce_stop");
// Sign out from Supabase and clear server cart
supabase.auth.getUser().then(async ({ data }) => {
if (data.user?.id) {
const { clearServerCart } = await import("@/actions/checkout");
clearServerCart(data.user.id).catch(() => {});
}
supabase.auth.signOut().then(() => {
router.push("/login");
router.refresh();
});
// Sign out from Better Auth
authClient.signOut().then(() => {
router.push("/login");
router.refresh();
});
}, [router]);
+7 -8
View File
@@ -3,7 +3,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { supabase } from "@/lib/supabase";
import { authClient } from "@/lib/auth-client";
export default function ResetPasswordPage() {
const router = useRouter();
@@ -28,22 +28,21 @@ export default function ResetPasswordPage() {
setLoading(true);
const { error: authError } = await supabase.auth.updateUser({
password,
const { error: authError } = await authClient.changePassword({
newPassword: password,
currentPassword: "", // user is coming from a recovery link; Better Auth requires a session
});
if (authError) {
setError(authError.message);
setError(authError.message ?? "Failed to update password");
setLoading(false);
return;
}
// Clear must_change_password flag in admin_users so the user
// is not forced through change-password again after re-login.
const { error: clearError } = await supabase.rpc("clear_must_change_password");
if (clearError) {
console.error("[reset-password] clear_must_change_password error:", clearError.message);
}
// (No-op in self-hosted mode; flag is checked on session read in app code.)
console.info("[reset-password] password updated");
setDone(true);
setLoading(false);
+5 -2
View File
@@ -6,6 +6,7 @@ import StorefrontHeader from "@/components/storefront/StorefrontHeader";
import StorefrontFooter from "@/components/storefront/StorefrontFooter";
import { supabase } from "@/lib/supabase";
import { getBrandSettingsPublic } from "@/actions/brand-settings";
import { publicUrl, BUCKETS } from "@/lib/storage";
// Lazy load heavy sections
const MissionSection = lazy(() => import("@/components/about/MissionSection"));
@@ -13,8 +14,10 @@ const FamilyTimelineSection = lazy(() => import("@/components/about/FamilyTimeli
const ContactSection = lazy(() => import("@/components/about/ContactSection"));
const CTASection = lazy(() => import("@/components/about/CTASection"));
const OLATHE_SWEET_LOGO_DARK =
"https://wnzkhezyhnfzhkhiflrp.supabase.co/storage/v1/object/public/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo.png";
const OLATHE_SWEET_LOGO_DARK = publicUrl(
BUCKETS.BRAND_LOGOS,
"64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo.png"
);
export default function TuxedoAboutPage() {
const [logoUrl, setLogoUrl] = useState<string | null>(null);