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
+33 -37
View File
@@ -1,4 +1,10 @@
import { cookies } from "next/headers";
import { cookies, headers } from "next/headers";
import { auth } from "@/lib/auth";
import { Pool } from "pg";
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
export type AdminUser = {
id: string;
@@ -33,54 +39,44 @@ export async function getAdminUser(): Promise<AdminUser | null> {
return buildDevAdmin(dev);
}
// ── Main auth: rc_auth_uid (new) or rc_uid (legacy) cookie set by /api/login
const uid = cookieStore.get("rc_auth_uid")?.value ?? cookieStore.get("rc_uid")?.value;
// ── Better Auth session check ───────────────────────────────────
const hdrs = await headers();
const session = await auth.api.getSession({ headers: hdrs });
if (!session?.user) return null;
const uid = session.user.id;
if (!uid) return null;
if (!process.env.SUPABASE_SERVICE_ROLE_KEY || !process.env.NEXT_PUBLIC_SUPABASE_URL) {
// Lookup admin_users by user_id
let adminUsers: unknown[] = [];
try {
const res = await pool.query(
`SELECT * FROM admin_users WHERE user_id = $1 LIMIT 1`,
[uid]
);
adminUsers = res.rows;
} catch (e) {
return null;
}
// Lookup admin_users by Supabase auth user id
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
let adminUsers: unknown[] = [];
try {
const res = await fetch(
`${supabaseUrl}/rest/v1/admin_users?user_id=eq.${uid}&limit=1`,
{ headers: { apikey: serviceKey, "Content-Type": "application/json" } }
);
if (res.ok) {
const data = await res.json().catch(() => []);
adminUsers = Array.isArray(data) ? data : [];
}
} catch (e) {
// fetch failed silently
}
// First login — auto-create platform_admin via SECURITY DEFINER RPC
// First login — auto-create platform_admin
if (adminUsers.length === 0) {
// Check if uid is a valid UUID before trying to insert
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!UUID_REGEX.test(uid)) return null;
try {
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/upsert_admin_user`,
{
method: "POST",
headers: { apikey: serviceKey, "Content-Type": "application/json", Prefer: "return=representation" },
body: JSON.stringify({ p_user_id: uid }),
}
const res = await pool.query(
`INSERT INTO admin_users (user_id, role, active)
VALUES ($1, 'platform_admin', true)
ON CONFLICT (user_id) DO UPDATE SET active = EXCLUDED.active
RETURNING *`,
[uid]
);
if (res.ok) {
const inserted = await res.json().catch(() => null);
if (inserted && inserted.length > 0) {
return buildAdminUser(inserted[0] as Record<string, unknown>);
}
if (res.rows.length > 0) {
return buildAdminUser(res.rows[0] as Record<string, unknown>);
}
} catch (e) {
// RPC failed silently
return null;
}
return null;
}
@@ -122,4 +118,4 @@ function buildAdminUser(r: Record<string, unknown>): AdminUser {
can_manage_messages: Boolean(r.can_manage_messages), can_manage_refunds: Boolean(r.can_manage_refunds),
can_manage_users: Boolean(r.can_manage_users), can_manage_water_log: Boolean(r.can_manage_water_log),
can_manage_reports: Boolean(r.can_manage_reports), can_manage_settings: Boolean(r.can_manage_settings) };
}
}
+9
View File
@@ -0,0 +1,9 @@
"use client";
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_BETTER_AUTH_URL || "http://localhost:3000",
});
export const { signIn, signOut, signUp, useSession } = authClient;
+47
View File
@@ -0,0 +1,47 @@
import { betterAuth } from "better-auth/minimal";
import { Kysely, PostgresDialect } from "kysely";
import { Pool } from "pg";
import { nextCookies } from "better-auth/next-js";
import { admin as adminPlugin } from "better-auth/plugins";
import { randomUUID } from "crypto";
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
// Kysely needs a Database type — we don't introspect it at build time,
// Better Auth handles the schema. Use a permissive type.
const db = new Kysely<unknown>({
dialect: new PostgresDialect({ pool }),
});
export const auth = betterAuth({
database: {
db,
type: "postgres",
},
emailAndPassword: {
enabled: true,
autoSignIn: true,
minPasswordLength: 8,
},
baseURL: process.env.BETTER_AUTH_URL || "http://localhost:3000",
secret: process.env.BETTER_AUTH_SECRET,
appName: "Route Commerce",
session: {
expiresIn: 60 * 60 * 24 * 30, // 30 days
updateAge: 60 * 60 * 24, // refresh once per day
},
advanced: {
generateId: () => randomUUID(),
cookiePrefix: "rc",
},
plugins: [nextCookies(), adminPlugin()],
});
export type Session = typeof auth.$Infer.Session;
+10 -4
View File
@@ -9,9 +9,15 @@
* FROM_EMAIL — e.g. "Tuxedo Corn <no-reply@tuxedocorn.com>"
*/
import { publicUrl, BUCKETS } from "@/lib/storage";
const FROM_EMAIL = process.env.FROM_EMAIL ?? "Tuxedo Corn <no-reply@tuxedocorn.com>";
const RESEND_API_KEY = process.env.RESEND_API_KEY ?? "";
const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
const OLATHE_SWEET_LOGO = publicUrl(BUCKETS.BRAND_LOGOS, `${TUXEDO_BRAND_ID}/olathe-sweet-logo.png`);
const TUXEDO_LOGO = publicUrl(BUCKETS.BRAND_LOGOS, `${TUXEDO_BRAND_ID}/logo.png`);
// ─────────────────────────────────────────────────────────────────────────────
// Shared email send function
// ─────────────────────────────────────────────────────────────────────────────
@@ -118,7 +124,7 @@ export async function sendOrderReceiptEmail(data: OrderReceiptData): Promise<boo
<!-- Header -->
<div style="background: #1c1917; border-radius: 16px; padding: 32px; text-align: center; margin-bottom: 24px;">
<img src="https://wnzkhezyhnfzhkhiflrp.supabase.co/storage/v1/object/public/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo.png"
<img src="${OLATHE_SWEET_LOGO}"
alt="Olathe Sweet" style="height: 40px; width: auto; object-fit: contain; margin-bottom: 16px; filter: brightness(0) invert(1); opacity: 0.9;" />
<h1 style="margin: 0; font-size: 28px; font-weight: 800; color: #ffffff; letter-spacing: -0.02em;">Order Confirmed</h1>
<p style="margin: 8px 0 0; font-size: 14px; color: rgba(255,255,255,0.6);">Order #${data.orderId.slice(0, 8).toUpperCase()}</p>
@@ -167,7 +173,7 @@ export async function sendOrderReceiptEmail(data: OrderReceiptData): Promise<boo
<!-- Olathe Sweet callout -->
<div style="background: #fafaf9; border-radius: 10px; padding: 16px 20px; margin-top: 24px; display: flex; align-items: center; gap: 12px;">
<img src="https://wnzkhezyhnfzhkhiflrp.supabase.co/storage/v1/object/public/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo.png"
<img src="${OLATHE_SWEET_LOGO}"
alt="Olathe Sweet" style="height: 28px; width: auto; object-fit: contain; opacity: 0.7; flex-shrink: 0;" />
<p style="margin: 0; font-size: 12px; color: #78716c; line-height: 1.5;">
Grown in Olathe, Colorado — Non-GMO · Hand-Picked · Farm-Direct
@@ -269,7 +275,7 @@ export async function sendWelcomeEmail(data: WelcomeEmailData): Promise<boolean>
<!-- Header -->
<div style="background: #1c1917; border-radius: 16px; padding: 32px; text-align: center; margin-bottom: 24px;">
<img src="https://wnzkhezyhnfzhkhiflrp.supabase.co/storage/v1/object/public/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/logo.png"
<img src="${TUXEDO_LOGO}"
alt="Tuxedo Corn" style="height: 36px; width: auto; object-fit: contain; margin-bottom: 16px;" />
<h1 style="margin: 0; font-size: 26px; font-weight: 800; color: #ffffff; letter-spacing: -0.02em;">Welcome, ${data.name.split(" ")[0]}</h1>
<p style="margin: 8px 0 0; font-size: 14px; color: rgba(255,255,255,0.6);">Your ${roleLabel} account is ready</p>
@@ -350,7 +356,7 @@ export async function sendPasswordResetEmail(data: PasswordResetData): Promise<b
<body style="margin: 0; padding: 0; background: #fafaf9; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #1c1917;">
<div style="max-width: 600px; margin: 0 auto; padding: 32px 16px;">
<div style="background: #1c1917; border-radius: 16px; padding: 32px; text-align: center; margin-bottom: 24px;">
<img src="https://wnzkhezyhnfzhkhiflrp.supabase.co/storage/v1/object/public/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/logo.png"
<img src="${TUXEDO_LOGO}"
alt="Tuxedo Corn" style="height: 32px; width: auto; object-fit: contain; margin-bottom: 16px;" />
<h1 style="margin: 0; font-size: 24px; font-weight: 800; color: #ffffff;">Reset Your Password</h1>
</div>
+88
View File
@@ -0,0 +1,88 @@
import { S3Client, PutObjectCommand, DeleteObjectCommand, ListObjectsV2Command } from "@aws-sdk/client-s3";
import { randomUUID } from "crypto";
// ── Buckets ────────────────────────────────────────────────────────
export const BUCKETS = {
BRAND_LOGOS: "brand-logos",
PRODUCT_IMAGES: "product-images",
CONTACTS_IMPORTS: "contacts-imports",
VIDEOS: "videos",
WATER_PHOTOS: "water-photos",
} as const;
export type BucketName = (typeof BUCKETS)[keyof typeof BUCKETS];
// ── S3 client (MinIO is S3-compatible) ─────────────────────────────
const region = process.env.STORAGE_REGION || "us-east-1";
const endpoint = process.env.STORAGE_ENDPOINT || "http://127.0.0.1:9000";
const publicBaseUrl = process.env.NEXT_PUBLIC_STORAGE_BASE_URL || endpoint;
export const s3 = new S3Client({
region,
endpoint,
forcePathStyle: true, // MinIO requires path-style
credentials: {
accessKeyId: process.env.STORAGE_ACCESS_KEY || "",
secretAccessKey: process.env.STORAGE_SECRET_KEY || "",
},
});
const prefix = process.env.STORAGE_BUCKET_PREFIX || "";
// ── Helpers ────────────────────────────────────────────────────────
export function publicUrl(bucket: BucketName | string, key: string): string {
const fullKey = prefix ? `${prefix}/${key}` : key;
return `${publicBaseUrl}/${bucket}/${fullKey}`;
}
export type UploadInput = {
bucket: BucketName | string;
key: string;
body: Buffer | Uint8Array | string;
contentType?: string;
};
export async function uploadFile(input: UploadInput): Promise<{ url: string }> {
const fullKey = prefix ? `${prefix}/${input.key}` : input.key;
await s3.send(
new PutObjectCommand({
Bucket: input.bucket,
Key: fullKey,
Body: input.body,
ContentType: input.contentType,
})
);
return { url: publicUrl(input.bucket, input.key) };
}
export async function deleteFile(bucket: BucketName | string, key: string): Promise<void> {
const fullKey = prefix ? `${prefix}/${key}` : key;
await s3.send(new DeleteObjectCommand({ Bucket: bucket, Key: fullKey }));
}
export async function listFiles(
bucket: BucketName | string,
prefix_?: string
): Promise<{ key: string; size: number; lastModified: Date }[]> {
const fullPrefix = prefix ? `${prefix}/${prefix_ || ""}` : prefix_ || undefined;
const res = await s3.send(
new ListObjectsV2Command({
Bucket: bucket,
Prefix: fullPrefix,
})
);
return (res.Contents || []).map((obj) => ({
key: obj.Key || "",
size: obj.Size || 0,
lastModified: obj.LastModified || new Date(0),
}));
}
// ── Key builders (single source of truth) ──────────────────────────
export const storageKeys = {
brandLogo: (brandId: string, name: string) => `${brandId}/${name}`,
productImage: (productId: string, ext: string) =>
`products/${productId}/${randomUUID()}.${ext}`,
contactsImport: (brandId: string, name: string) =>
`${brandId}/${Date.now()}-${name}`,
};
-25
View File
@@ -1,25 +0,0 @@
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
export function createClient(request: NextRequest) {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const response = NextResponse.next({ request });
return createServerClient(supabaseUrl, supabaseAnonKey, {
cookies: {
getAll() {
return request.cookies.getAll();
},
setAll(cookiesToSet, headers) {
cookiesToSet.forEach(({ name, value, options }) => {
response.cookies.set(name, value, options);
});
Object.entries(headers).forEach(([key, value]) => {
response.headers.set(key, value);
});
},
},
});
}