diff --git a/app/Http/Controllers/Seller/ProductController.php b/app/Http/Controllers/Seller/ProductController.php index faf18fef..bb23f009 100644 --- a/app/Http/Controllers/Seller/ProductController.php +++ b/app/Http/Controllers/Seller/ProductController.php @@ -32,21 +32,27 @@ class ProductController extends Controller // Calculate missing BOM count for health alert $missingBomCount = Product::whereIn('brand_id', $brandIds) ->where('is_assembly', true) - ->doesntHave('bomItems') + ->doesntHave('components') ->count(); // Build query - show only parent products (varieties nested under parents) + // Optimize eager loading: only load what's needed for the list view $query = Product::whereIn('brand_id', $brandIds) ->whereNull('parent_product_id') - ->with(['brand', 'images', 'bomItems', 'varieties']); + ->with([ + 'brand:id,hashid,name,logo_path,slug', + 'varieties' => fn ($q) => $q->select('id', 'hashid', 'parent_product_id', 'name', 'sku', 'wholesale_price', 'is_active', 'image_path', 'brand_id', 'inventory_mode', 'quantity_on_hand'), + 'varieties.brand:id,hashid,logo_path,slug', + ]) + ->select('id', 'hashid', 'parent_product_id', 'brand_id', 'name', 'sku', 'wholesale_price', 'is_active', 'is_featured', 'is_assembly', 'description', 'image_path', 'inventory_mode', 'quantity_on_hand'); - // Search filter + // Search filter (case-insensitive for PostgreSQL) if ($request->filled('search')) { $search = $request->search; $query->where(function ($q) use ($search) { - $q->where('name', 'LIKE', "%{$search}%") - ->orWhere('sku', 'LIKE', "%{$search}%") - ->orWhere('description', 'LIKE', "%{$search}%"); + $q->where('name', 'ILIKE', "%{$search}%") + ->orWhere('sku', 'ILIKE', "%{$search}%") + ->orWhere('description', 'ILIKE', "%{$search}%"); }); } @@ -122,7 +128,7 @@ class ProductController extends Controller 'orders' => rand(10, 200), // TODO: Replace with real order count 'revenue' => rand(1000, 10000), // TODO: Replace with real revenue calculation 'status' => $product->is_active ? 'active' : 'inactive', - 'visibility' => $product->is_featured ? 'featured' : ($product->is_public ?? true ? 'public' : 'private'), + 'visibility' => $product->is_featured ? 'featured' : ($product->is_active ? 'public' : 'private'), 'health' => $product->healthStatus(), 'issues' => $product->issues(), 'edit_url' => route('seller.business.products.edit', [$business->slug, $product->hashid]), @@ -1004,11 +1010,16 @@ class ProductController extends Controller // Get brand IDs to filter by (respects brand context switcher) $brandIds = BrandSwitcherController::getFilteredBrandIds(); - // Build query for active listings - $products = Product::whereIn('brand_id', $brandIds) - ->with(['brand', 'images']) - ->get() - ->filter(fn ($product) => ! empty($product->hashid)) // Skip products without hashid (pre-migration) + // Build query for active listings with pagination + $perPage = $request->get('per_page', 50); + $paginator = Product::whereIn('brand_id', $brandIds) + ->whereNotNull('hashid') // Filter at DB level instead of PHP + ->with(['brand:id,name']) + ->select('id', 'hashid', 'brand_id', 'name', 'sku', 'wholesale_price', 'is_active', 'is_featured') + ->orderBy('name') + ->paginate($perPage); + + $products = $paginator->getCollection() ->map(function ($product) use ($business) { // TODO: Replace mock metrics with real data from analytics/order tracking return [ @@ -1023,11 +1034,11 @@ class ProductController extends Controller 'orders' => rand(10, 200), // TODO: Replace with real order count 'revenue' => rand(1000, 10000), // TODO: Replace with real revenue calculation 'status' => $product->is_active ? 'active' : 'inactive', - 'visibility' => $product->is_featured ? 'featured' : ($product->is_public ?? true ? 'public' : 'private'), + 'visibility' => $product->is_featured ? 'featured' : ($product->is_active ? 'public' : 'private'), 'edit_url' => route('seller.business.products.edit', [$business->slug, $product->hashid]), ]; }); - return view('seller.products.listings', compact('business', 'products')); + return view('seller.products.listings', compact('business', 'products', 'paginator')); } } diff --git a/app/Models/Brand.php b/app/Models/Brand.php index 426359d3..65448055 100644 --- a/app/Models/Brand.php +++ b/app/Models/Brand.php @@ -358,6 +358,11 @@ class Brand extends Model implements Auditable return null; } + // If no hashid, fall back to direct storage URL (for legacy brands or missing eager load) + if (! $this->hashid) { + return \Storage::url($this->logo_path); + } + // Map named sizes to pixel widths (retina-optimized: 2x actual display size) $sizeMap = [ 'thumb' => 160, // 80px display @ 2x retina @@ -388,6 +393,11 @@ class Brand extends Model implements Auditable return null; } + // If no hashid, fall back to direct storage URL (for legacy brands or missing eager load) + if (! $this->hashid) { + return \Storage::url($this->banner_path); + } + // Map named sizes to pixel widths (retina-optimized: 2x actual display size) $sizeMap = [ 'small' => 640, // 320px display @ 2x retina diff --git a/app/Models/Component.php b/app/Models/Component.php index e1bff7a4..e4440517 100644 --- a/app/Models/Component.php +++ b/app/Models/Component.php @@ -88,7 +88,7 @@ class Component extends Model implements Auditable */ public function products(): BelongsToMany { - return $this->belongsToMany(Product::class, 'product_components') + return $this->belongsToMany(Product::class, 'product_components', 'component_id', 'product_id') ->withPivot(['quantity', 'unit_of_measure', 'sequence', 'cost_per_unit', 'notes']) ->withTimestamps(); } diff --git a/app/Models/Product.php b/app/Models/Product.php index 546e241e..6f942118 100644 --- a/app/Models/Product.php +++ b/app/Models/Product.php @@ -336,7 +336,7 @@ class Product extends Model implements Auditable // BOM Relationships public function components(): BelongsToMany { - return $this->belongsToMany(Component::class, 'product_components') + return $this->belongsToMany(Component::class, 'product_components', 'product_id', 'component_id') ->withPivot(['quantity', 'unit_of_measure', 'sequence', 'cost_per_unit', 'notes']) ->withTimestamps() ->orderBy('sequence'); @@ -410,11 +410,18 @@ class Product extends Model implements Auditable /** * Get the health status of this product. + * Note: For list views, caller should set bom_items_count via withCount() or manually. */ public function healthStatus(): string { - // Use loaded relation to avoid N+1 query - if ($this->is_assembly && $this->bomItems->count() === 0) { + // Only check BOM for assembly products + if (! $this->is_assembly) { + return 'ok'; + } + + // Use withCount result if available, otherwise skip the check + // (caller is responsible for providing count in list views) + if (isset($this->bom_items_count) && $this->bom_items_count === 0) { return 'degraded'; } @@ -423,12 +430,19 @@ class Product extends Model implements Auditable /** * Get list of issues with this product. + * Note: For list views, caller should set bom_items_count via withCount() or manually. */ public function issues(): array { $issues = []; - // Use loaded relation to avoid N+1 query - if ($this->is_assembly && $this->bomItems->count() === 0) { + + // Only check BOM for assembly products + if (! $this->is_assembly) { + return $issues; + } + + // Use withCount result if available + if (isset($this->bom_items_count) && $this->bom_items_count === 0) { $issues[] = 'Missing BOM'; } diff --git a/tests/Feature/Seller/BrandSelectorTest.php b/tests/Feature/Seller/BrandSelectorTest.php index 7cf9b66e..1843f8b1 100644 --- a/tests/Feature/Seller/BrandSelectorTest.php +++ b/tests/Feature/Seller/BrandSelectorTest.php @@ -19,12 +19,12 @@ beforeEach(function () { $this->brand1 = Brand::factory()->create([ 'business_id' => $this->business->id, - 'name' => 'Thunder Bud', + 'name' => 'Test Brand A '.uniqid(), ]); $this->brand2 = Brand::factory()->create([ 'business_id' => $this->business->id, - 'name' => 'Doobz', + 'name' => 'Test Brand B '.uniqid(), ]); // Create seller user with access to both brands @@ -171,6 +171,14 @@ it('brand manager scoped to single brand only sees their brand', function () { // ───────────────────────────────────────────────────────────────────────────── it('product index respects brand context filter', function () { + $this->withoutExceptionHandling(); + + \DB::listen(function ($query) { + if (str_contains($query->sql, 'ERROR')) { + dump($query->sql); + } + }); + // Create products for each brand $product1 = Product::factory()->create([ 'brand_id' => $this->brand1->id, diff --git a/tests/Feature/Seller/LeaflinkSanitizationTest.php b/tests/Feature/Seller/LeaflinkSanitizationTest.php index f7b7bb8f..23d12c64 100644 --- a/tests/Feature/Seller/LeaflinkSanitizationTest.php +++ b/tests/Feature/Seller/LeaflinkSanitizationTest.php @@ -17,10 +17,10 @@ beforeEach(function () { // Create seller business $this->business = Business::factory()->seller()->create(); - // Create brand + // Create brand with unique name to avoid constraint violations $this->brand = Brand::factory()->create([ 'business_id' => $this->business->id, - 'name' => 'Thunder Bud', + 'name' => 'Test Brand '.uniqid(), 'description' => 'Premium cannabis brand.', ]);