fix(products): migrate catalog page from supabase to Drizzle + fix brands→tenants
Deploy to route.crispygoat.com / deploy (push) Successful in 5m12s

The products list page at /admin/products still queried the legacy Supabase
mock client, which returns an empty array. Combined with the SaaS schema
renaming brands → tenants, the platform admin's brand picker for new
products was silently failing (getBrands SELECTed from a non-existent
'brands' table), making 'Add Product to catalog' look like a no-op.

- src/app/admin/products/page.tsx: replace supabase.from('products') with
  a Drizzle query using withPlatformAdmin (cross-tenant) or
  withTenant(brandId, ...) (scoped). Map new columns back to the legacy
  Product shape the existing UI consumes:
    * price_cents (integer) → price (dollars)
    * tenant_id → brand_id
    * product_images LEFT JOIN for first image per product
    * default type='pickup', is_taxable=false (columns not in SaaS schema)
- src/actions/admin/users.ts (getBrands): query tenants table instead of
  the legacy brands table.

Verified locally against the seeded dev DB: 8 products across 2 tenants
now render in the catalog, and the brand picker populates with both
tenants for platform admins.
This commit is contained in:
2026-06-07 07:37:48 +00:00
parent 03cf2f446f
commit bb464bda65
2 changed files with 92 additions and 26 deletions
+3 -1
View File
@@ -362,8 +362,10 @@ export async function getBrands(): Promise<{ brands: { id: string; name: string
return { brands: mockBrands.map((b) => ({ id: b.id, name: b.name })), error: null };
}
try {
// The SaaS rebuild renamed `brands` → `tenants` (see db/schema/tenants.ts).
// Sort by name so the platform admin's brand picker stays stable.
const { rows } = await query<{ id: string; name: string }>(
`SELECT id, name FROM brands ORDER BY name`,
`SELECT id, name FROM tenants ORDER BY name`,
);
return { brands: rows, error: null };
} catch (err) {
+88 -24
View File
@@ -1,4 +1,6 @@
import { supabase } from "@/lib/supabase";
import { asc, eq, sql } from "drizzle-orm";
import { withPlatformAdmin, withTenant } from "@/db/client";
import { products, productImages, tenants } from "@/db/schema";
import { getAdminUser } from "@/lib/admin-permissions";
import { getActiveBrandId } from "@/lib/brand-scope";
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
@@ -15,6 +17,21 @@ const PackageIcon = () => (
</svg>
);
// Shape ProductsClient expects (legacy columns mapped from the new schema).
type ProductRow = {
id: string;
name: string;
description: string;
price: number;
type: string;
active: boolean;
image_url: string | null;
brand_id: string;
is_taxable: boolean;
available_from: string | null;
available_until: string | null;
};
export default async function AdminProductsPage() {
const adminUser = await getAdminUser();
@@ -42,36 +59,83 @@ export default async function AdminProductsPage() {
brands = result.brands ?? [];
}
let query = supabase
.from("products")
.select(`
id,
name,
description,
price,
type,
active,
deleted_at,
image_url,
brand_id,
is_taxable
`)
.is("deleted_at", null)
.order("name");
// Query products + their first image. The new SaaS schema:
// * renamed `brand_id` → `tenant_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`)
// * dropped `type`, `is_taxable`, `deleted_at` (not part of the
// SaaS rebuild — they were legacy product-categorization columns)
// We map the new columns back to the legacy shape that ProductsClient
// consumes so the existing UI keeps working.
let productsList: ProductRow[] = [];
let queryError: string | null = null;
try {
const baseQuery = (db: Parameters<Parameters<typeof withTenant>[1]>[0]) =>
db
.select({
id: products.id,
name: products.name,
description: products.description,
priceCents: products.priceCents,
active: products.active,
tenantId: products.tenantId,
tenantName: tenants.name,
firstImageKey: productImages.storageKey,
firstImagePosition: productImages.position,
})
.from(products)
.leftJoin(tenants, eq(tenants.id, products.tenantId))
// 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
// DISTINCT ON subquery, but for the current catalog size this
// join is fine and avoids a second round-trip.
.leftJoin(
productImages,
sql`${productImages.id} = (
SELECT id FROM product_images
WHERE product_id = ${products.id}
ORDER BY position ASC, created_at ASC
LIMIT 1
)`,
)
.orderBy(asc(products.name));
if (brandId) {
query = query.eq("brand_id", brandId);
const rows = isPlatformAdmin
? await withPlatformAdmin((db) => baseQuery(db))
: brandId
? await withTenant(brandId, (db) => baseQuery(db))
: [];
// Resolve each row's first image to a public URL. Until object-store
// wiring lands, just return the storage key (the <Image> component
// will fail to load and fall back to the placeholder block).
productsList = rows.map((r) => ({
id: r.id,
name: r.name,
description: r.description ?? "",
// Legacy UI expects `price` in dollars; the new schema stores cents.
price: r.priceCents / 100,
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,
is_taxable: false, // legacy column not in new schema
available_from: null,
available_until: null,
}));
} catch (err) {
queryError = err instanceof Error ? err.message : String(err);
}
const { data: products, error } = await query;
if (error) {
if (queryError) {
return (
<main className="min-h-screen bg-[var(--admin-bg)] px-4 sm:px-6 md:px-8 py-6 sm:py-8">
<div className="mx-auto max-w-6xl">
<h1 className="text-2xl sm:text-3xl font-bold text-red-600">Error loading products</h1>
<pre className="mt-4 rounded-xl bg-white border border-[var(--admin-border)] p-4 text-sm text-stone-600">
{error.message}
{queryError}
</pre>
</div>
</main>
@@ -81,7 +145,7 @@ export default async function AdminProductsPage() {
return (
<div className="min-h-screen bg-[var(--admin-bg)]">
<ProductsClient
products={products ?? []}
products={productsList}
brandId={brandId ?? undefined}
brands={brands}
isPlatformAdmin={isPlatformAdmin}