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
+31 -53
View File
@@ -1,63 +1,41 @@
"use server";
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
import { Pool } from "pg";
import { randomUUID } from "crypto";
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
const DEV_ADMIN_UID = "dev-user-00000000-0000-0000-0000-000000000001";
export async function forceAdminLogin(): Promise<{ success: boolean; uid?: string; error?: string }> {
const cookieStore = await cookies();
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
try {
// Upsert dev platform_admin record
const res = await pool.query(
`INSERT INTO admin_users (
user_id, brand_id, role, active,
can_manage_products, can_manage_stops, can_manage_orders, can_manage_pickup,
can_manage_messages, can_manage_refunds, can_manage_users, can_manage_water_log,
can_manage_reports, can_manage_settings, must_change_password
) VALUES (
$1, NULL, 'platform_admin', true,
true, true, true, true,
true, true, true, true,
true, true, false
)
ON CONFLICT (user_id) DO UPDATE SET active = EXCLUDED.active
RETURNING id, role`,
[DEV_ADMIN_UID]
);
const response = NextResponse.next();
const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {
cookies: {
getAll() { return cookieStore.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);
});
},
},
});
// Upsert dev platform_admin record
const { data: existing } = await supabase
.from("admin_users")
.select("id, role")
.eq("user_id", DEV_ADMIN_UID)
.single();
if (!existing) {
const { error: insertError } = await supabase
.from("admin_users")
.insert({
user_id: DEV_ADMIN_UID,
brand_id: null,
role: "platform_admin",
active: true,
can_manage_products: true,
can_manage_stops: true,
can_manage_orders: true,
can_manage_pickup: true,
can_manage_messages: true,
can_manage_refunds: true,
can_manage_users: true,
can_manage_water_log: true,
can_manage_reports: true,
can_manage_settings: true,
must_change_password: false,
});
if (insertError) {
return { success: false, error: insertError.message };
if (res.rows.length === 0) {
return { success: false, error: "Failed to upsert dev admin" };
}
}
return { success: true, uid: DEV_ADMIN_UID };
return { success: true, uid: DEV_ADMIN_UID };
} catch (e: unknown) {
const message = e instanceof Error ? e.message : "Unknown error";
return { success: false, error: message };
}
}
+31
View File
@@ -0,0 +1,31 @@
"use server";
import { Pool } from "pg";
import { revalidatePath } from "next/cache";
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
export type UpdateAdminProfileResult =
| { success: true }
| { success: false; error: string };
export async function updateAdminProfileAction(
id: string,
displayName: string | null,
phoneNumber: string | null
): Promise<UpdateAdminProfileResult> {
try {
await pool.query("SELECT update_admin_user($1, $2, $3)", [
id,
displayName,
phoneNumber,
]);
revalidatePath("/admin/me");
return { success: true };
} catch (e: unknown) {
const message = e instanceof Error ? e.message : "Failed to update profile";
return { success: false, error: message };
}
}
+48 -54
View File
@@ -2,6 +2,7 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers";
import { uploadFile, BUCKETS, publicUrl, storageKeys } from "@/lib/storage";
export type UploadLogoResult =
| { success: true; logoUrl: string }
@@ -30,31 +31,28 @@ export async function uploadBrandLogo(
}
const ext = file.type === "image/svg+xml" ? "svg" : file.type.split("/")[1];
const path = isDark ? `logo-dark.${ext}` : `logo.${ext}`;
const storagePath = `brand-logos/${brandId}/${path}`;
const fileName = isDark ? `logo-dark.${ext}` : `logo.${ext}`;
const key = storageKeys.brandLogo(brandId, fileName);
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
let url: string;
try {
const res = await uploadFile({
bucket: BUCKETS.BRAND_LOGOS,
key,
body: buffer,
contentType: file.type,
});
url = res.url;
} catch (e) {
return { success: false, error: `Upload failed: ${(e as Error).message}` };
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const uploadRes = await fetch(
`${supabaseUrl}/storage/v1/object/brand-logos/${storagePath}`,
{
method: "PUT",
headers: { ...svcHeaders(supabaseKey), "Content-Type": file.type, "x-upsert": "true" },
body: buffer,
}
);
if (!uploadRes.ok) {
return { success: false, error: `Upload failed: ${await uploadRes.text()}` };
}
const publicUrl = `${supabaseUrl}/storage/v1/object/public/brand-logos/${storagePath}`;
// Save URL to brand_settings via RPC
const saveRes = await fetch(
`${supabaseUrl}/rest/v1/rpc/upsert_brand_settings`,
{
@@ -62,7 +60,7 @@ export async function uploadBrandLogo(
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_brand_id: brandId,
[isDark ? "p_logo_url_dark" : "p_logo_url"]: publicUrl,
[isDark ? "p_logo_url_dark" : "p_logo_url"]: url,
}),
}
);
@@ -71,7 +69,7 @@ export async function uploadBrandLogo(
return { success: false, error: "Upload succeeded but failed to save URL" };
}
return { success: true, logoUrl: publicUrl };
return { success: true, logoUrl: url };
}
export async function uploadOlatheSweetLogo(
@@ -96,28 +94,26 @@ export async function uploadOlatheSweetLogo(
}
const ext = file.type === "image/svg+xml" ? "svg" : file.type.split("/")[1];
const storagePath = `brand-logos/${brandId}/olathe-sweet-logo.${ext}`;
const key = storageKeys.brandLogo(brandId, `olathe-sweet-logo.${ext}`);
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const uploadRes = await fetch(
`${supabaseUrl}/storage/v1/object/brand-logos/${storagePath}`,
{
method: "PUT",
headers: { ...svcHeaders(supabaseKey), "Content-Type": file.type, "x-upsert": "true" },
let url: string;
try {
const res = await uploadFile({
bucket: BUCKETS.BRAND_LOGOS,
key,
body: buffer,
}
);
if (!uploadRes.ok) {
return { success: false, error: `Upload failed: ${await uploadRes.text()}` };
contentType: file.type,
});
url = res.url;
} catch (e) {
return { success: false, error: `Upload failed: ${(e as Error).message}` };
}
const publicUrl = `${supabaseUrl}/storage/v1/object/public/brand-logos/${storagePath}`;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const saveRes = await fetch(
`${supabaseUrl}/rest/v1/rpc/upsert_brand_settings`,
@@ -126,7 +122,7 @@ export async function uploadOlatheSweetLogo(
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_brand_id: brandId,
p_olathe_sweet_logo_url: publicUrl,
p_olathe_sweet_logo_url: url,
}),
}
);
@@ -135,7 +131,7 @@ export async function uploadOlatheSweetLogo(
return { success: false, error: "Upload succeeded but failed to save URL" };
}
return { success: true, logoUrl: publicUrl };
return { success: true, logoUrl: url };
}
export async function uploadOlatheSweetLogoDark(
@@ -160,28 +156,26 @@ export async function uploadOlatheSweetLogoDark(
}
const ext = file.type === "image/svg+xml" ? "svg" : file.type.split("/")[1];
const storagePath = `brand-logos/${brandId}/olathe-sweet-logo-dark.${ext}`;
const key = storageKeys.brandLogo(brandId, `olathe-sweet-logo-dark.${ext}`);
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const uploadRes = await fetch(
`${supabaseUrl}/storage/v1/object/brand-logos/${storagePath}`,
{
method: "PUT",
headers: { ...svcHeaders(supabaseKey), "Content-Type": file.type, "x-upsert": "true" },
let url: string;
try {
const res = await uploadFile({
bucket: BUCKETS.BRAND_LOGOS,
key,
body: buffer,
}
);
if (!uploadRes.ok) {
return { success: false, error: `Upload failed: ${await uploadRes.text()}` };
contentType: file.type,
});
url = res.url;
} catch (e) {
return { success: false, error: `Upload failed: ${(e as Error).message}` };
}
const publicUrl = `${supabaseUrl}/storage/v1/object/public/brand-logos/${storagePath}`;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const saveRes = await fetch(
`${supabaseUrl}/rest/v1/rpc/upsert_brand_settings`,
@@ -190,7 +184,7 @@ export async function uploadOlatheSweetLogoDark(
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
p_brand_id: brandId,
p_olathe_sweet_logo_url_dark: publicUrl,
p_olathe_sweet_logo_url_dark: url,
}),
}
);
@@ -199,7 +193,7 @@ export async function uploadOlatheSweetLogoDark(
return { success: false, error: "Upload succeeded but failed to save URL" };
}
return { success: true, logoUrl: publicUrl };
return { success: true, logoUrl: url };
}
export type BrandSettings = {
+28 -64
View File
@@ -2,9 +2,7 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers";
// Contact imports bucket
const CONTACTS_BUCKET_ID = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
import { uploadFile, listFiles, BUCKETS, publicUrl, storageKeys } from "@/lib/storage";
export type UploadContactsResult =
| { success: true; fileId: string; fileUrl: string; recordCount: number }
@@ -17,57 +15,41 @@ export async function uploadContactsToBucket(
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
// Validate file type
if (!file.name.endsWith(".csv")) {
return { success: false, error: "Only CSV files are supported" };
}
// For very large files (farmers with tons of data), check size
const maxSize = 50 * 1024 * 1024; // 50MB max
if (file.size > maxSize) {
return { success: false, error: "File too large. Max 50MB for large imports." };
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// Generate unique path
const timestamp = Date.now();
const safeName = file.name.replace(/[^a-zA-Z0-9.-]/g, "_");
const path = `imports/${brandId}/${timestamp}-${safeName}`;
const key = storageKeys.contactsImport(brandId, safeName);
// Upload to bucket
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const uploadRes = await fetch(
`${supabaseUrl}/storage/v1/object/${CONTACTS_BUCKET_ID}/${path}`,
{
method: "PUT",
headers: {
...svcHeaders(supabaseKey),
"Authorization": `Bearer ${supabaseKey}`,
"Content-Type": "text/csv",
"x-upsert": "false",
},
let url: string;
try {
const res = await uploadFile({
bucket: BUCKETS.CONTACTS_IMPORTS,
key,
body: buffer,
}
);
if (!uploadRes.ok) {
return { success: false, error: `Upload failed: ${await uploadRes.text()}` };
contentType: "text/csv",
});
url = res.url;
} catch (e) {
return { success: false, error: `Upload failed: ${(e as Error).message}` };
}
const fileUrl = `${supabaseUrl}/storage/v1/object/public/${CONTACTS_BUCKET_ID}/${path}`;
const fileId = `${brandId}/${timestamp}`;
// Get rough row count from file size (approx 200 bytes per row)
const fileId = key.split("/").slice(0, 2).join("/"); // brandId/timestamp
const estimatedRows = Math.floor(file.size / 200);
return {
success: true,
fileId,
fileUrl,
fileUrl: url,
recordCount: estimatedRows,
};
}
@@ -87,7 +69,6 @@ export async function processBucketImport(
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// Call RPC to process the file from bucket
const response = await fetch(
`${supabaseUrl}/rest/v1/rpc/process_contact_import_from_url`,
{
@@ -122,37 +103,20 @@ export async function listImportHistory(
const adminUser = await getAdminUser();
if (!adminUser) return { success: false, error: "Not authenticated" };
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// List files in the imports folder for this brand
const response = await fetch(
`${supabaseUrl}/storage/v1/object/list/${CONTACTS_BUCKET_ID}`,
{
method: "POST",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({
prefix: `imports/${brandId}/`,
limit: limit,
sortBy: { column: "created_at", order: "desc" },
}),
}
);
if (!response.ok) {
return { success: false, error: "Failed to list imports" };
try {
const files = await listFiles(BUCKETS.CONTACTS_IMPORTS, `${brandId}/`);
return {
success: true,
imports: files.slice(0, limit).map((f) => ({
filename: f.key.split("/").pop() ?? "",
size: f.size,
createdAt: f.lastModified.toISOString(),
url: publicUrl(BUCKETS.CONTACTS_IMPORTS, f.key),
})),
};
} catch (e) {
return { success: false, error: (e as Error).message };
}
const files = await response.json();
return {
success: true,
imports: files.map((f: { name: string; metadata: { size: number }; created_at: string }) => ({
filename: f.name.split("/").pop() ?? "",
size: f.metadata?.size ?? 0,
createdAt: f.created_at,
url: `${supabaseUrl}/storage/v1/object/public/${CONTACTS_BUCKET_ID}/${f.name}`,
})),
};
}
export type ImportHistoryItem = {
@@ -160,4 +124,4 @@ export type ImportHistoryItem = {
size: number;
createdAt: string;
url: string;
};
};
+16 -41
View File
@@ -1,7 +1,7 @@
"use server";
import { cookies } from "next/headers";
import { createServerClient } from "@supabase/ssr";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
export type LoginWithPasswordResult =
| { success: true; redirect: true }
@@ -11,46 +11,21 @@ export async function loginWithPassword(
email: string,
password: string
): Promise<LoginWithPasswordResult> {
const cookieStore = await cookies();
try {
const hdrs = await headers();
const result = await auth.api.signInEmail({
body: { email, password },
headers: hdrs,
asResponse: false,
});
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!result?.user) {
return { success: false, error: "Invalid credentials" };
}
if (!supabaseUrl || !supabaseAnonKey) {
return { success: false, error: "Server misconfiguration." };
return { success: true, redirect: true };
} catch (e: unknown) {
const message = e instanceof Error ? e.message : "Invalid credentials";
return { success: false, error: message };
}
const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) => {
cookieStore.set(name, value, options);
});
},
},
});
const { data, error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error || !data.user) {
return { success: false, error: error?.message || "Invalid credentials" };
}
// Set the rc_auth_uid cookie that getAdminUser() reads
const isProd = process.env.NODE_ENV === "production";
cookieStore.set("rc_auth_uid", data.user.id, {
path: "/",
maxAge: 60 * 60 * 24 * 30,
httpOnly: true,
sameSite: "lax",
secure: isProd,
});
return { success: true, redirect: true };
}
+22 -25
View File
@@ -2,9 +2,7 @@
import { getAdminUser } from "@/lib/admin-permissions";
import { svcHeaders } from "@/lib/svc-headers";
// Product images bucket - UUID from Supabase
const PRODUCT_IMAGES_BUCKET_ID = "80aa01da-ab4b-44f8-b6e7-700552457e18";
import { uploadFile, BUCKETS, storageKeys } from "@/lib/storage";
export type UploadProductImageResult =
| { success: true; imageUrl: string }
@@ -25,42 +23,41 @@ export async function uploadProductImage(
return { success: false, error: "File too large. Max 5MB." };
}
const ext = file.type.split("/")[1] === "jpeg" ? "jpg" : file.type.split("/")[1];
const path = `products/${productId}/${crypto.randomUUID()}.${ext}`;
const rawExt = file.type.split("/")[1];
const ext = rawExt === "jpeg" ? "jpg" : rawExt;
const targetProductId = productId === "__NEW__" ? "new" : productId;
const key = storageKeys.productImage(targetProductId, ext);
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const uploadRes = await fetch(
`${supabaseUrl}/storage/v1/object/${PRODUCT_IMAGES_BUCKET_ID}/${path}`,
{
method: "PUT",
headers: { ...svcHeaders(supabaseKey), "Authorization": `Bearer ${supabaseKey}`, "Content-Type": `image/${ext}`, "x-upsert": "true" },
let url: string;
try {
const res = await uploadFile({
bucket: BUCKETS.PRODUCT_IMAGES,
key,
body: buffer,
}
);
if (!uploadRes.ok) {
return { success: false, error: `Upload failed: ${await uploadRes.text()}` };
contentType: file.type,
});
url = res.url;
} catch (e) {
return { success: false, error: `Upload failed: ${(e as Error).message}` };
}
const publicUrl = `${supabaseUrl}/storage/v1/object/public/${PRODUCT_IMAGES_BUCKET_ID}/${path}`;
// If productId is "__NEW__", we just upload and return the URL (caller handles saving it)
if (productId === "__NEW__") {
return { success: true, imageUrl: publicUrl };
return { success: true, imageUrl: url };
}
// Update product record with new image URL
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const patchRes = await fetch(
`${supabaseUrl}/rest/v1/products?id=eq.${productId}`,
{
method: "PATCH",
headers: { ...svcHeaders(supabaseKey), "Content-Type": "application/json" },
body: JSON.stringify({ image_url: publicUrl }),
body: JSON.stringify({ image_url: url }),
}
);
@@ -68,7 +65,7 @@ export async function uploadProductImage(
return { success: false, error: "Upload succeeded but failed to save image URL" };
}
return { success: true, imageUrl: publicUrl };
return { success: true, imageUrl: url };
}
export async function deleteProductImage(
@@ -94,4 +91,4 @@ export async function deleteProductImage(
}
return { success: true };
}
}
+42 -111
View File
@@ -1,8 +1,7 @@
"use server";
import { cookies } from "next/headers";
import { NextRequest, NextResponse } from "next/server";
import { svcHeaders } from "@/lib/svc-headers";
import { cookies, headers } from "next/headers";
import { auth } from "@/lib/auth";
export type WholesaleLoginResult =
| { success: true; token: string; userId: string; customerId: string }
@@ -12,121 +11,53 @@ export async function wholesaleLoginAction(formData: FormData): Promise<Wholesal
const email = formData.get("email") as string;
const password = formData.get("password") as string;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
try {
const hdrs = await headers();
const result = await auth.api.signInEmail({
body: { email, password },
headers: hdrs,
asResponse: false,
});
const cookieStore = await cookies();
const request = new NextRequest("http://localhost/wholesale/login", {
headers: new Headers(),
});
const response = NextResponse.next({ request });
const { createServerClient } = await import("@supabase/ssr");
const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {
cookies: {
getAll() {
return cookieStore.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);
});
},
},
});
const { data, error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error || !data.user) {
return { success: false, error: error?.message ?? "Invalid credentials" };
}
const { data: sessionData } = await supabase.auth.getSession();
const token = sessionData?.session?.access_token;
if (!token) {
return { success: false, error: "No session returned from auth" };
}
// Find the wholesale customer record for this user
const customerRes = await fetch(
`${supabaseUrl}/rest/v1/rpc/get_wholesale_customer_by_user`,
{
method: "POST",
headers: {
...svcHeaders(supabaseAnonKey),
"Content-Type": "application/json",
},
body: JSON.stringify({
p_brand_id: "placeholder", // will use any-brand lookup below
p_user_id: data.user.id,
}),
if (!result?.user) {
return { success: false, error: "Invalid credentials" };
}
);
// If no brand_id known, try all brands — just use first active one found
// For now, set the cookie with user_id and a placeholder; portal will resolve proper customer
response.cookies.set("wholesale_session", JSON.stringify({
user_id: data.user.id,
access_token: token,
}), {
path: "/",
maxAge: 3600 * 24 * 7,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
httpOnly: false,
});
const cookieStore = await cookies();
// Better Auth sets its own session cookie (rc_session_token).
// Mark wholesale session for portal routing.
cookieStore.set("wholesale_session", JSON.stringify({
user_id: result.user.id,
}), {
path: "/",
maxAge: 3600 * 24 * 7,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
httpOnly: false,
});
// Also set the standard auth token for RPC calls
response.cookies.set("sb-wnzkhezyhnfzhkhiflrp-auth-token", token, {
path: "/",
maxAge: 3600 * 24 * 7,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
httpOnly: false,
});
return {
success: true,
token,
userId: data.user.id,
customerId: "pending", // resolved by portal page on load
};
return {
success: true,
token: "better-auth-session", // session lives in cookie
userId: result.user.id,
customerId: "pending", // resolved by portal page on load
};
} catch (e: unknown) {
const message = e instanceof Error ? e.message : "Invalid credentials";
return { success: false, error: message };
}
}
export async function wholesaleLogoutAction() {
try {
const hdrs = await headers();
await auth.api.signOut({ headers: hdrs });
} catch {
// best-effort
}
const cookieStore = await cookies();
const request = new NextRequest("http://localhost/wholesale/portal", {
headers: new Headers(),
});
const response = NextResponse.next({ request });
response.cookies.delete("wholesale_session");
const { createServerClient } = await import("@supabase/ssr");
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet, headers) {
cookiesToSet.forEach(({ name, value, options }) => {
response.cookies.set(name, value, options);
});
},
},
});
await supabase.auth.signOut();
cookieStore.delete("wholesale_session");
return { success: true };
}
}
+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);
+2 -2
View File
@@ -9,7 +9,7 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import { usePathname } from "next/navigation";
import { useState, useEffect, useRef } from "react";
import { supabase } from "@/lib/supabase";
import { authClient } from "@/lib/auth-client";
type AdminHeaderProps = {
userRole?: string | null;
@@ -90,7 +90,7 @@ export default function AdminHeader({ userRole, canManageUsers, routeTraceEnable
async function handleLogout() {
document.cookie = "dev_session=;path=/;max-age=0";
await supabase.auth.signOut();
await authClient.signOut();
router.push("/login");
router.refresh();
}
+2 -2
View File
@@ -4,7 +4,7 @@ import { useState, useEffect, useRef, useCallback, KeyboardEvent } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useRouter } from "next/navigation";
import { supabase } from "@/lib/supabase";
import { authClient } from "@/lib/auth-client";
// Elegant warm sidebar design
// Colors: parchment 100 bg, soft linen text, powder petal accent
@@ -297,7 +297,7 @@ export default function AdminSidebar({ userRole }: SidebarProps) {
async function handleLogout() {
document.cookie = "dev_session=;path=/;max-age=0";
await supabase.auth.signOut();
await authClient.signOut();
router.push("/login");
router.refresh();
}
@@ -5,6 +5,7 @@ import { useEffect, useRef, useState } from "react";
import { motion } from "framer-motion";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { publicUrl, BUCKETS } from "@/lib/storage";
// Register GSAP plugins
if (typeof window !== "undefined") {
@@ -22,11 +23,12 @@ type TuxedoVideoHeroProps = {
onSecondaryClick?: () => void;
};
const VIDEO_URL =
"https://wnzkhezyhnfzhkhiflrp.supabase.co/storage/v1/object/public/videos/tuxedo-hero.mp4";
const VIDEO_URL = publicUrl(BUCKETS.VIDEOS, "tuxedo-hero.mp4");
const OLATHE_SWEET_LOGO_DARK =
"https://wnzkhezyhnfzhkhiflrp.supabase.co/storage/v1/object/public/brand-logos/64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo-dark.png";
const OLATHE_SWEET_LOGO_DARK = publicUrl(
BUCKETS.BRAND_LOGOS,
"64294306-5f42-463d-a5e8-2ad6c81a96de/olathe-sweet-logo-dark.png"
);
// ─────────────────────────────────────────────────────────────────────────────
// CINEMATIC HERO - SCROLL-DRIVEN ANIMATIONS
@@ -4,6 +4,7 @@
import Image from "next/image";
import { useState, useEffect, useRef } from "react";
import LayoutContainer from "@/components/layout/LayoutContainer";
import { publicUrl, BUCKETS } from "@/lib/storage";
import {
verifyTimeTrackingPin,
clockInWorker,
@@ -21,8 +22,10 @@ import {
const BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de";
const BRAND_NAME = "Tuxedo Corn";
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,
`${BRAND_ID}/olathe-sweet-logo.png`
);
// ── Translations ───────────────────────────────────────────────────────────────
+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);
});
},
},
});
}