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:
@@ -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 };
|
return { brands: mockBrands.map((b) => ({ id: b.id, name: b.name })), error: null };
|
||||||
}
|
}
|
||||||
try {
|
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 }>(
|
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 };
|
return { brands: rows, error: null };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -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 { getAdminUser } from "@/lib/admin-permissions";
|
||||||
import { getActiveBrandId } from "@/lib/brand-scope";
|
import { getActiveBrandId } from "@/lib/brand-scope";
|
||||||
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
import AdminAccessDenied from "@/components/admin/AdminAccessDenied";
|
||||||
@@ -15,6 +17,21 @@ const PackageIcon = () => (
|
|||||||
</svg>
|
</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() {
|
export default async function AdminProductsPage() {
|
||||||
const adminUser = await getAdminUser();
|
const adminUser = await getAdminUser();
|
||||||
|
|
||||||
@@ -42,36 +59,83 @@ export default async function AdminProductsPage() {
|
|||||||
brands = result.brands ?? [];
|
brands = result.brands ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
let query = supabase
|
// Query products + their first image. The new SaaS schema:
|
||||||
.from("products")
|
// * renamed `brand_id` → `tenant_id` on products
|
||||||
.select(`
|
// * renamed `price` (numeric) → `price_cents` (integer)
|
||||||
id,
|
// * moved `image_url` off the products table into a separate
|
||||||
name,
|
// `product_images` table (keyed by `product_id` + `position`)
|
||||||
description,
|
// * dropped `type`, `is_taxable`, `deleted_at` (not part of the
|
||||||
price,
|
// SaaS rebuild — they were legacy product-categorization columns)
|
||||||
type,
|
// We map the new columns back to the legacy shape that ProductsClient
|
||||||
active,
|
// consumes so the existing UI keeps working.
|
||||||
deleted_at,
|
let productsList: ProductRow[] = [];
|
||||||
image_url,
|
let queryError: string | null = null;
|
||||||
brand_id,
|
try {
|
||||||
is_taxable
|
const baseQuery = (db: Parameters<Parameters<typeof withTenant>[1]>[0]) =>
|
||||||
`)
|
db
|
||||||
.is("deleted_at", null)
|
.select({
|
||||||
.order("name");
|
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) {
|
const rows = isPlatformAdmin
|
||||||
query = query.eq("brand_id", brandId);
|
? 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 (queryError) {
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-[var(--admin-bg)] px-4 sm:px-6 md:px-8 py-6 sm:py-8">
|
<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">
|
<div className="mx-auto max-w-6xl">
|
||||||
<h1 className="text-2xl sm:text-3xl font-bold text-red-600">Error loading products</h1>
|
<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">
|
<pre className="mt-4 rounded-xl bg-white border border-[var(--admin-border)] p-4 text-sm text-stone-600">
|
||||||
{error.message}
|
{queryError}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
@@ -81,7 +145,7 @@ export default async function AdminProductsPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[var(--admin-bg)]">
|
<div className="min-h-screen bg-[var(--admin-bg)]">
|
||||||
<ProductsClient
|
<ProductsClient
|
||||||
products={products ?? []}
|
products={productsList}
|
||||||
brandId={brandId ?? undefined}
|
brandId={brandId ?? undefined}
|
||||||
brands={brands}
|
brands={brands}
|
||||||
isPlatformAdmin={isPlatformAdmin}
|
isPlatformAdmin={isPlatformAdmin}
|
||||||
|
|||||||
Reference in New Issue
Block a user