fix(products): migrate catalog page from supabase to Drizzle + fix brands→tenants
Deploy to route.crispygoat.com / deploy (push) Successful in 5m12s
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:
@@ -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,11 +145,11 @@ 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}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user