Files
hub/app/Http/Controllers/ImageController.php
kelly 84e81272a5 feat: Product and inventory management system with media improvements
- Complete product and inventory management system
- Media storage service with proper path conventions
- Image handling with dynamic resizing
- Database migrations for inventory tracking
- Import tools for legacy data migration
- Documentation improvements

- InventoryItem, InventoryMovement, InventoryAlert models with hashid support
- Purchase order tracking on inventory alerts
- Inventory dashboard for sellers
- Stock level monitoring and notifications

- MediaStorageService enforcing consistent path conventions
- Image controller with dynamic resizing capabilities
- Migration tools for moving images to MinIO
- Proper slug-based paths (not IDs or hashids)

- ImportProductsFromRemote command
- ImportAlohaSales, ImportThunderBudBulk commands
- ExploreRemoteDatabase for schema inspection
- Legacy data migration utilities

- Product variations table
- Remote customer mappings
- Performance indexes for stats queries
- Social media fields for brands
- Module flags for businesses

- New migrations for inventory, hashids, performance indexes
- New services: MediaStorageService
- New middleware: EnsureBusinessHasModule, EnsureUserHasCapability
- Import commands for legacy data
- Inventory models and controllers
- Updated views for marketplace and seller areas
- Documentation reorganization (archived old docs)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-18 18:40:54 -07:00

208 lines
7.6 KiB
PHP

<?php
namespace App\Http\Controllers;
use App\Models\Brand;
use Illuminate\Support\Facades\Storage;
use Intervention\Image\Drivers\Gd\Driver;
use Intervention\Image\ImageManager;
/**
* Image Controller - Serves Brand Media from MinIO
*
* URL PATTERNS:
* =============
* - Logo: GET /images/brand-logo/{brand}/{width?}
* - Banner: GET /images/brand-banner/{brand}/{width?}
*
* Where:
* - {brand} = Brand hashid (e.g., "75pg7" for Aloha TymeMachine)
* - {width} = Optional thumbnail width in pixels (e.g., 160, 600, 1600)
*
* Examples:
* - /images/brand-logo/75pg7 → Original logo from MinIO
* - /images/brand-logo/75pg7/600 → 600px thumbnail (cached locally)
* - /images/brand-banner/75pg7/1344 → 1344px banner (cached locally)
*
* CRITICAL STORAGE RULES:
* =======================
*
* 1. ALWAYS use Storage (default disk) - NEVER Storage::disk('public')
* ✓ Storage::exists() → uses .env FILESYSTEM_DISK=minio
* ✗ Storage::disk('public')->exists() → bypasses .env, uses local disk
*
* 2. Brand assets MUST be on MinIO at:
* businesses/{business_slug}/brands/{brand_slug}/branding/{filename}
* Example: businesses/cannabrands/brands/thunder-bud/branding/logo.png
*
* 3. Thumbnails are cached to local disk for performance:
* storage/app/private/brands/cache/{brand_hashid}-{width}w.{ext}
*
* 4. Original images are fetched from MinIO and served directly:
* $contents = Storage::get($brand->logo_path); // Gets from MinIO
* return response($contents)->header('Content-Type', $mimeType);
*
* WHY THIS MATTERS:
* =================
* - MinIO is configured in .env as the default disk
* - All brand/product media lives on MinIO (S3-compatible storage)
* - Using Storage::disk('public') breaks images and violates architecture
* - This has caused multiple production issues - DO NOT change without review
*
* See: docs/architecture/MEDIA_STORAGE.md
*/
class ImageController extends Controller
{
/**
* Serve a brand logo at a specific size
* URL: /images/brand-logo/{brand}/{width?}
*/
public function brandLogo(Brand $brand, ?int $width = null)
{
if (! $brand->logo_path || ! Storage::exists($brand->logo_path)) {
abort(404);
}
// If no width specified, return original from storage
if (! $width) {
$contents = Storage::get($brand->logo_path);
$mimeType = Storage::mimeType($brand->logo_path);
return response($contents)->header('Content-Type', $mimeType);
}
// Map common widths to pre-generated sizes (retina-optimized)
$sizeNames = [
160 => 'thumb', // 2x retina for 80px display
600 => 'medium', // 2x retina for 300px display
1600 => 'large', // 2x retina for 800px display
];
// Check if cached dynamic thumbnail exists in local storage
$ext = pathinfo($brand->logo_path, PATHINFO_EXTENSION);
$thumbnailName = $brand->hashid.'-'.$width.'w.'.$ext;
$thumbnailPath = 'brands/cache/'.$thumbnailName;
if (! Storage::disk('local')->exists($thumbnailPath)) {
// Fetch original from default storage disk
$originalContents = Storage::get($brand->logo_path);
// Generate thumbnail on-the-fly
$manager = new ImageManager(new Driver);
$image = $manager->read($originalContents);
$image->scale(width: $width);
// Cache the thumbnail locally for performance
if (! Storage::disk('local')->exists('brands/cache')) {
Storage::disk('local')->makeDirectory('brands/cache');
}
// Save as PNG or JPEG based on original format
$encoded = $ext === 'png' ? $image->toPng() : $image->toJpeg(quality: 90);
Storage::disk('local')->put($thumbnailPath, $encoded);
}
$path = storage_path('app/private/'.$thumbnailPath);
return response()->file($path);
}
/**
* Serve a brand banner at a specific width
* URL: /images/brand-banner/{brand}/{width?}
*/
public function brandBanner(Brand $brand, ?int $width = null)
{
if (! $brand->banner_path || ! Storage::exists($brand->banner_path)) {
abort(404);
}
// If no width specified, return original from storage
if (! $width) {
$contents = Storage::get($brand->banner_path);
$mimeType = Storage::mimeType($brand->banner_path);
return response($contents)->header('Content-Type', $mimeType);
}
// Map common widths to pre-generated sizes (retina-optimized)
$sizeNames = [
1344 => 'medium', // 2x retina for 672px display
2560 => 'large', // 2x retina for 1280px display
];
// Check if cached dynamic thumbnail exists in local storage
$ext = pathinfo($brand->banner_path, PATHINFO_EXTENSION);
$thumbnailName = $brand->hashid.'-banner-'.$width.'w.'.$ext;
$thumbnailPath = 'brands/cache/'.$thumbnailName;
if (! Storage::disk('local')->exists($thumbnailPath)) {
// Fetch original from default storage disk (MinIO)
$originalContents = Storage::get($brand->banner_path);
// Generate thumbnail on-the-fly
$manager = new ImageManager(new Driver);
$image = $manager->read($originalContents);
$image->scale(width: $width);
// Cache the thumbnail locally for performance
if (! Storage::disk('local')->exists('brands/cache')) {
Storage::disk('local')->makeDirectory('brands/cache');
}
Storage::disk('local')->put($thumbnailPath, $image->toJpeg(quality: 90));
}
$path = storage_path('app/private/'.$thumbnailPath);
return response()->file($path);
}
/**
* Serve a product image at a specific width
* URL: /images/product/{product}/{width?}
*/
public function productImage(\App\Models\Product $product, ?int $width = null)
{
if (! $product->image_path || ! Storage::exists($product->image_path)) {
abort(404);
}
// If no width specified, return original from storage
if (! $width) {
$contents = Storage::get($product->image_path);
$mimeType = Storage::mimeType($product->image_path);
return response($contents)->header('Content-Type', $mimeType);
}
// Check if cached dynamic thumbnail exists in local storage
$ext = pathinfo($product->image_path, PATHINFO_EXTENSION);
$thumbnailName = $product->hashid.'-'.$width.'w.'.$ext;
$thumbnailPath = 'products/cache/'.$thumbnailName;
if (! Storage::disk('local')->exists($thumbnailPath)) {
// Fetch original from default storage disk (MinIO)
$originalContents = Storage::get($product->image_path);
// Generate thumbnail on-the-fly
$manager = new ImageManager(new Driver);
$image = $manager->read($originalContents);
$image->scale(width: $width);
// Cache the thumbnail locally for performance
if (! Storage::disk('local')->exists('products/cache')) {
Storage::disk('local')->makeDirectory('products/cache');
}
// Save as PNG or JPEG based on original format
$encoded = $ext === 'png' ? $image->toPng() : $image->toJpeg(quality: 90);
Storage::disk('local')->put($thumbnailPath, $encoded);
}
$path = storage_path('app/private/'.$thumbnailPath);
return response()->file($path);
}
}