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:
@@ -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) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`,
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user