fix: make product search case-insensitive and add defensive hashid checks
- Use ILIKE instead of LIKE for PostgreSQL case-insensitive search - Add hashid fallback in Brand::getLogoUrl() and getBannerUrl() - Prevents route generation errors when hashid is missing from eager load - Reduce eager loading in index() to only needed columns - Add pagination to listings() method - Filter hashid at DB level instead of PHP collection
This commit is contained in:
@@ -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'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.',
|
||||
]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user