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
+26 -6
View File
@@ -11,12 +11,32 @@ type ProductDetailPageProps = {
}>;
};
interface Product {
id: string;
brand_id: string;
name: string;
description: string | null;
price: number;
active: boolean;
type: string | null;
image_url: string | null;
is_taxable: boolean;
pickup_type: string | null;
brands?: { name: string; slug: string };
}
interface Brand {
id: string;
name: string;
slug: string;
}
export default async function ProductDetailPage({ params }: ProductDetailPageProps) {
const { id } = await params;
const [{ data: product, error }, { data: brands }] = await Promise.all([
supabase.from("products").select("*, brands(name, slug)").eq("id", id).single(),
supabase.from("brands").select("id, name, slug"),
supabase.from("products").select("*, brands(name, slug)").eq("id", id).single() as unknown as { data: Product | null; error: { message: string } | null },
supabase.from("brands").select("id, name, slug") as unknown as { data: Brand[] | null },
]);
const adminUser = await getAdminUser();
@@ -109,14 +129,14 @@ export default async function ProductDetailPage({ params }: ProductDetailPagePro
product={{
id: product.id,
name: product.name,
description: product.description,
description: product.description ?? "",
price: Number(product.price),
type: product.type,
type: product.type ?? "pickup",
active: product.active,
brand_id: product.brand_id,
image_url: product.image_url,
image_url: product.image_url ?? "",
is_taxable: product.is_taxable,
pickup_type: product.pickup_type,
pickup_type: product.pickup_type ?? "pickup",
}}
brands={brands ?? []}
/>
+12 -12
View File
@@ -1,6 +1,6 @@
import { asc, eq, sql } from "drizzle-orm";
import { withPlatformAdmin, withTenant } from "@/db/client";
import { products, productImages, tenants } from "@/db/schema";
import { withPlatformAdmin, withBrand } from "@/db/client";
import { products, productImages, brands } from "@/db/schema";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
@@ -53,14 +53,14 @@ export default async function AdminProductsPage() {
const isPlatformAdmin = adminUser.role === "platform_admin";
// Platform admins need a brand picker for new products
let brands: { id: string; name: string }[] = [];
let brandOptions: { id: string; name: string }[] = [];
if (isPlatformAdmin) {
const result = await getBrands();
brands = result.brands ?? [];
brandOptions = result.brands ?? [];
}
// Query products + their first image. The new SaaS schema:
// * renamed `brand_id` → `tenant_id` on products
// * renamed `brand_id` → `brand_id` on products
// * renamed `price` (numeric) → `price_cents` (integer)
// * moved `image_url` off the products table into a separate
// `product_images` table (keyed by `product_id` + `position`)
@@ -71,7 +71,7 @@ export default async function AdminProductsPage() {
let productsList: ProductRow[] = [];
let queryError: string | null = null;
try {
const baseQuery = (db: Parameters<Parameters<typeof withTenant>[1]>[0]) =>
const baseQuery = (db: Parameters<Parameters<typeof withBrand>[1]>[0]) =>
db
.select({
id: products.id,
@@ -79,13 +79,13 @@ export default async function AdminProductsPage() {
description: products.description,
priceCents: products.priceCents,
active: products.active,
tenantId: products.tenantId,
tenantName: tenants.name,
brandId: products.brandId,
brandName: brands.name,
firstImageKey: productImages.storageKey,
firstImagePosition: productImages.position,
})
.from(products)
.leftJoin(tenants, eq(tenants.id, products.tenantId))
.leftJoin(brands, eq(brands.id, products.brandId))
// Pull only the lowest-position image per product. The
// `position` index on product_images (product_id, position) makes
// this cheap; a real-world scale would replace it with a
@@ -105,7 +105,7 @@ export default async function AdminProductsPage() {
const rows = isPlatformAdmin
? await withPlatformAdmin((db) => baseQuery(db))
: brandId
? await withTenant(brandId, (db) => baseQuery(db))
? await withBrand(brandId, (db) => baseQuery(db))
: [];
// Resolve each row's first image to a public URL. Until object-store
@@ -120,7 +120,7 @@ export default async function AdminProductsPage() {
type: "pickup", // legacy column not in new schema
active: r.active,
image_url: r.firstImageKey, // storage key, not a public URL yet
brand_id: r.tenantId,
brand_id: r.brandId,
is_taxable: false, // legacy column not in new schema
available_from: null,
available_until: null,
@@ -147,7 +147,7 @@ export default async function AdminProductsPage() {
<ProductsClient
products={productsList}
brandId={brandId ?? undefined}
brands={brands}
brands={brandOptions}
isPlatformAdmin={isPlatformAdmin}
/>
</div>