feat(storage): MinIO object storage, Neon Auth, Supabase removal
Deploy to route.crispygoat.com / deploy (push) Failing after 3m1s
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:
@@ -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 ?? []}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user