feat(storage): MinIO object storage, Neon Auth, Supabase removal
Deploy to route.crispygoat.com / deploy (push) Failing after 3m1s

- Add MinIO/S3-compatible storage client (src/lib/storage.ts) with uploadObject,
  deleteObject, presigned URL helpers, and BUCKETS constant
- Wire product images, brand logos, and water log photos to MinIO via the
  new storage client
- Migrate forgot-password to Neon Auth (remove Supabase /auth/v1/recover call)
- Migrate send-scheduled cron to direct Postgres + Resend (remove Supabase Edge
  Function proxy)
- Add logoUrl to email types (OrderReceipt, Welcome, PasswordReset) and pass
  brand_settings.logo_url from all call sites
- Update email templates to use dynamic logoUrl instead of hardcoded Supabase
  bucket URLs
- Remove hardcoded Supabase URLs from TuxedoVideoHero, TuxedoAboutPage,
  TimeTrackingFieldClient; use brand_settings props + local public/ fallback
- Download brand logos (3) and tuxedo-hero.mp4 (36MB) from Supabase bucket to
  public/ for local development
- Add MinIO env vars to .env.example (endpoint, access key, secret, buckets)
- Fix TimeTrackingFieldClient to destructure logoUrl and brandAccent props
- Fix admin/users.ts logoUrl type (null → undefined for optional string)
- Remove stale sb- cookie from wholesale-auth
- Migrate tuxedo/about page to remove supabase import and use pool query for
  wholesale_settings lookup
This commit is contained in:
openclaw
2026-06-09 11:32:17 -06:00
parent bb464bda65
commit 916ad39176
349 changed files with 6706 additions and 3343 deletions
+3 -25
View File
@@ -1,12 +1,9 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { getMockTableData } from "@/lib/mock-data";
import { withTenant } from "@/db/client";
import { withBrand } from "@/db/client";
import { products } from "@/db/schema";
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
export type CreateProductResult =
| { success: true; id: string }
| { success: false; error: string };
@@ -32,25 +29,6 @@ export async function createProduct(
return { success: false, error: "Not authorized for this brand" };
}
if (useMockData) {
const mockProducts = getMockTableData("products") as any[];
const newId = `mock-prod-${Date.now()}`;
const newProduct = {
id: newId,
name: data.name,
description: data.description,
price: data.price,
type: data.type,
is_active: data.active,
image_url: data.image_url ?? null,
is_taxable: data.is_taxable,
pickup_type: data.pickup_type ?? "scheduled_stop",
brand_id: brandId,
};
mockProducts.push(newProduct);
return { success: true, id: newId };
}
// The new schema stores products with `price_cents` (integer) and no `type`,
// `is_taxable`, `pickup_type`, or `image_url` column. `image_url` is now
// attached via the `product_images` table; `type` / `is_taxable` / `pickup_type`
@@ -62,11 +40,11 @@ export async function createProduct(
}
try {
const inserted = await withTenant(brandId, async (db) => {
const inserted = await withBrand(brandId, async (db) => {
const [row] = await db
.insert(products)
.values({
tenantId: brandId,
brandId: brandId,
name: data.name,
description: data.description ?? null,
priceCents,
+3 -10
View File
@@ -1,13 +1,10 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { getMockTableData } from "@/lib/mock-data";
import { withTenant } from "@/db/client";
import { withBrand } from "@/db/client";
import { products } from "@/db/schema";
import { and, eq } from "drizzle-orm";
const useMockData = process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true";
export type UpdateProductResult =
| { success: true }
| { success: false; error: string };
@@ -34,10 +31,6 @@ export async function updateProduct(
return { success: false, error: "Not authorized for this brand" };
}
if (useMockData) {
return { success: true };
}
// The new schema has `price_cents` (integer cents) and no `type`,
// `is_taxable`, `pickup_type`, or `image_url` column. `type` /
// `is_taxable` / `pickup_type` are dropped for the SaaS rebuild;
@@ -48,7 +41,7 @@ export async function updateProduct(
}
try {
await withTenant(brandId, (db) =>
await withBrand(brandId, (db) =>
db
.update(products)
.set({
@@ -58,7 +51,7 @@ export async function updateProduct(
active: data.active,
updatedAt: new Date(),
})
.where(and(eq(products.id, productId), eq(products.tenantId, brandId)))
.where(and(eq(products.id, productId), eq(products.brandId, brandId)))
);
return { success: true };
} catch (err) {
+14 -19
View File
@@ -1,20 +1,14 @@
"use server";
import { getAdminUser } from "@/lib/admin-permissions";
import { withTenant } from "@/db/client";
import { withBrand } from "@/db/client";
import { productImages } from "@/db/schema";
import { uploadObject, BUCKETS } from "@/lib/storage";
export type UploadProductImageResult =
| { success: true; imageUrl: string }
| { success: false; error: string };
// TODO(migration): product images in the new SaaS schema live in the
// `product_images` table (storage_key + position + alt_text) backed by
// the `files` table. Supabase Storage is gone, so we no longer upload
// to `/storage/v1/object/...` — that pathway is stubbed. Callers that
// upload images should write to the `files` table via an S3-compatible
// backend (still TODO). This stub persists the intended storage key as
// a record so the UI can continue to render an image URL placeholder.
export async function uploadProductImage(
productId: string,
file: File
@@ -33,16 +27,16 @@ export async function uploadProductImage(
const ext = file.type.split("/")[1] === "jpeg" ? "jpg" : file.type.split("/")[1];
const storageKey = `products/${productId}/${crypto.randomUUID()}.${ext}`;
// Without a configured object store, we cannot actually upload bytes.
// We still record the planned storage key in `product_images` so the
// schema-level FK + ordering are exercised. The actual upload will be
// re-introduced when the S3-compatible store lands.
if (productId === "__NEW__") {
return { success: false, error: "Object store not configured; cannot upload images yet" };
}
try {
await withTenant(adminUser.brand_id ?? "__missing__", (db) =>
const buffer = Buffer.from(await file.arrayBuffer());
const imageUrl = await uploadObject({
bucket: BUCKETS.PRODUCTS,
key: storageKey,
body: buffer,
contentType: file.type,
});
await withBrand(adminUser.brand_id ?? "__missing__", (db) =>
db.insert(productImages).values({
productId,
storageKey,
@@ -50,7 +44,8 @@ export async function uploadProductImage(
altText: null,
})
);
return { success: true, imageUrl: `/storage/${storageKey}` };
return { success: true, imageUrl };
} catch (err) {
return { success: false, error: `Upload failed: ${err instanceof Error ? err.message : String(err)}` };
}
@@ -66,7 +61,7 @@ export async function deleteProductImage(
// from `product_images` for this product. The legacy `image_url` PATCH
// pathway is gone (that column no longer exists on `products`).
try {
await withTenant(adminUser.brand_id ?? "__missing__", (db) =>
await withBrand(adminUser.brand_id ?? "__missing__", (db) =>
db.delete(productImages).where(eq(productImages.productId, productId))
);
return { success: true };