- 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>
208 lines
7.6 KiB
PHP
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);
|
|
}
|
|
}
|