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:
kelly
2025-12-08 15:11:06 -07:00
parent f173254700
commit b33ebac9bf
6 changed files with 67 additions and 24 deletions

View File

@@ -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'));
}
}

View File

@@ -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

View File

@@ -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();
}

View File

@@ -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';
}

View File

@@ -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,

View File

@@ -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.',
]);