diff --git a/src/actions/admin/users.ts b/src/actions/admin/users.ts index 6a3819d..711b86a 100644 --- a/src/actions/admin/users.ts +++ b/src/actions/admin/users.ts @@ -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) { diff --git a/src/app/admin/products/page.tsx b/src/app/admin/products/page.tsx index 9f889b8..527d041 100644 --- a/src/app/admin/products/page.tsx +++ b/src/app/admin/products/page.tsx @@ -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 = () => ( ); +// 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[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 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 (

Error loading products

-            {error.message}
+            {queryError}
           
@@ -81,11 +145,11 @@ export default async function AdminProductsPage() { return (
); -} \ No newline at end of file +}