diff --git a/SCHEDULING_MODULE.md b/SCHEDULING_MODULE.md deleted file mode 100644 index c8faf534..00000000 --- a/SCHEDULING_MODULE.md +++ /dev/null @@ -1,127 +0,0 @@ -# Core Scheduling Module - -This module provides a polymorphic, reusable scheduling system for the Fleetbase platform. - -## Features - -- **Polymorphic Architecture**: Schedule any entity type (drivers, vehicles, stores, warehouses, etc.) -- **Flexible Schedule Items**: Assign items to any assignee with any resource -- **Availability Management**: Track availability windows for any entity -- **Constraint System**: Pluggable constraint validation framework -- **Event-Driven**: Comprehensive event system for extensibility -- **Activity Logging**: All scheduling activities logged via Spatie Activity Log - -## Database Tables - -1. **schedules** - Master schedule records -2. **schedule_items** - Individual scheduled items/slots -3. **schedule_templates** - Reusable schedule patterns -4. **schedule_availability** - Availability tracking -5. **schedule_constraints** - Configurable scheduling rules - -## Models - -- `Schedule` - Main schedule model with polymorphic subject -- `ScheduleItem` - Schedule item with polymorphic assignee and resource -- `ScheduleTemplate` - Reusable template patterns -- `ScheduleAvailability` - Availability windows -- `ScheduleConstraint` - Constraint definitions - -## Services - -- `ScheduleService` - Core scheduling operations -- `AvailabilityService` - Availability management -- `ConstraintService` - Pluggable constraint validation - -## Events - -- `ScheduleCreated` -- `ScheduleUpdated` -- `ScheduleDeleted` -- `ScheduleItemCreated` -- `ScheduleItemUpdated` -- `ScheduleItemDeleted` -- `ScheduleItemAssigned` -- `ScheduleConstraintViolated` - -## API Endpoints - -All endpoints are available under `/int/v1/` prefix: - -- `/schedules` - Schedule CRUD operations -- `/schedule-items` - Schedule item CRUD operations -- `/schedule-templates` - Template CRUD operations -- `/schedule-availability` - Availability CRUD operations -- `/schedule-constraints` - Constraint CRUD operations - -## Extension Integration - -Extensions can integrate with the scheduling module by: - -1. **Registering Constraints**: Use `ConstraintService::register()` to add domain-specific constraints -2. **Listening to Events**: Subscribe to scheduling events to trigger extension-specific workflows -3. **Using the Meta Field**: Store extension-specific data in the `meta` JSON field - -### Example: FleetOps HOS Constraint - -```php -// In FleetOps ServiceProvider -public function boot() -{ - $constraintService = app(\Fleetbase\Services\Scheduling\ConstraintService::class); - $constraintService->register('driver', \Fleetbase\FleetOps\Constraints\HOSConstraint::class); -} -``` - -## Usage Examples - -### Creating a Schedule - -```php -$schedule = Schedule::create([ - 'company_uuid' => $company->uuid, - 'subject_type' => 'fleet', - 'subject_uuid' => $fleet->uuid, - 'name' => 'Weekly Driver Schedule', - 'start_date' => '2025-11-15', - 'end_date' => '2025-11-22', - 'timezone' => 'America/New_York', - 'status' => 'active', -]); -``` - -### Creating a Schedule Item - -```php -$item = ScheduleItem::create([ - 'schedule_uuid' => $schedule->uuid, - 'assignee_type' => 'driver', - 'assignee_uuid' => $driver->uuid, - 'resource_type' => 'vehicle', - 'resource_uuid' => $vehicle->uuid, - 'start_at' => '2025-11-15 08:00:00', - 'end_at' => '2025-11-15 17:00:00', - 'status' => 'confirmed', -]); -``` - -### Setting Availability - -```php -$availability = ScheduleAvailability::create([ - 'subject_type' => 'driver', - 'subject_uuid' => $driver->uuid, - 'start_at' => '2025-11-20 00:00:00', - 'end_at' => '2025-11-22 23:59:59', - 'is_available' => false, - 'reason' => 'vacation', -]); -``` - -## Future Enhancements - -- Optimization algorithms for automatic schedule generation -- RRULE processing for recurring patterns -- Conflict detection and resolution -- Capacity planning and load balancing -- Multi-timezone support improvements diff --git a/composer.json b/composer.json index 9d247283..81b7cba3 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "fleetbase/core-api", - "version": "1.6.28", + "version": "1.6.29", "description": "Core Framework and Resources for Fleetbase API", "keywords": [ "fleetbase", @@ -36,6 +36,7 @@ "illuminate/support": "^9.0|^10.0", "inkrot/php-compress-json": "^0.1.1", "innoge/laravel-msgraph-mail": "^1.4", + "intervention/image": "^3.11", "jdorn/sql-formatter": "^1.2", "laravel-notification-channels/apn": "^5.0", "laravel-notification-channels/fcm": "^4.1", diff --git a/config/api.php b/config/api.php index 5dc757ed..6a1c0b5c 100644 --- a/config/api.php +++ b/config/api.php @@ -2,7 +2,66 @@ return [ 'throttle' => [ + // Option 1: Global enable/disable toggle + // Set to false to disable throttling completely (useful for performance testing) + // Default: true (enabled) + // Example: THROTTLE_ENABLED=false + 'enabled' => env('THROTTLE_ENABLED', true), + + // Maximum number of requests allowed per decay period + // Default: 120 requests per minute + // Example: THROTTLE_REQUESTS_PER_MINUTE=120 'max_attempts' => env('THROTTLE_REQUESTS_PER_MINUTE', 120), + + // Time window in minutes for throttle decay + // Default: 1 minute + // Example: THROTTLE_DECAY_MINUTES=1 'decay_minutes' => env('THROTTLE_DECAY_MINUTES', 1), + + // Option 3: Unlimited API keys (for production testing) + // Comma-separated list of API keys that bypass throttling + // These keys can be used for performance testing in production + // Example: THROTTLE_UNLIMITED_API_KEYS=Bearer test_key_123,Bearer load_test_456 + 'unlimited_keys' => array_filter(explode(',', env('THROTTLE_UNLIMITED_API_KEYS', ''))), + ], + + 'cache' => [ + // Enable/disable API model caching + // Caching is enabled by default when HasApiModelCache trait is used + // Set to false to disable caching globally + // Default: true (enabled) + // Example: API_CACHE_ENABLED=false + 'enabled' => env('API_CACHE_ENABLED', true), + + // Cache TTL (Time To Live) in seconds + 'ttl' => [ + // Query result caching (list endpoints) + // Default: 300 seconds (5 minutes) + // Example: API_CACHE_QUERY_TTL=300 + 'query' => env('API_CACHE_QUERY_TTL', 300), + + // Model instance caching (single record endpoints) + // Default: 3600 seconds (1 hour) + // Example: API_CACHE_MODEL_TTL=3600 + 'model' => env('API_CACHE_MODEL_TTL', 3600), + + // Relationship caching + // Default: 1800 seconds (30 minutes) + // Example: API_CACHE_RELATIONSHIP_TTL=1800 + 'relationship' => env('API_CACHE_RELATIONSHIP_TTL', 1800), + ], + + // Cache driver (uses Laravel's cache configuration) + // Options: redis, memcached, database, file + // Default: uses config('cache.default') + 'driver' => env('API_CACHE_DRIVER', config('cache.default')), + + // Cache key prefix + // Default: 'fleetbase_api' + 'prefix' => env('API_CACHE_PREFIX', 'fleetbase_api'), + + // Debug mode - adds X-Cache-Key header to responses + // Default: false (only enabled when APP_DEBUG=true) + 'debug' => env('API_CACHE_DEBUG', false), ], ]; diff --git a/config/database.connections.php b/config/database.connections.php index 0c948361..e0851fff 100644 --- a/config/database.connections.php +++ b/config/database.connections.php @@ -19,7 +19,10 @@ $database = substr($url['path'], 1); } -$mysql_options = []; +$mysql_options = [ + PDO::ATTR_PERSISTENT => true, + PDO::ATTR_TIMEOUT => 5, +]; if (env('APP_ENV') === 'local') { $mysql_options[PDO::ATTR_EMULATE_PREPARES] = true; @@ -68,6 +71,9 @@ 'strict' => true, 'engine' => null, 'options' => $mysql_options, + 'pool' => [ + 'size' => env('DB_CONNECTION_POOL_SIZE', 25), + ], ], 'sandbox' => [ diff --git a/config/fleetbase.php b/config/fleetbase.php index 087a8941..b9027c95 100644 --- a/config/fleetbase.php +++ b/config/fleetbase.php @@ -34,5 +34,10 @@ 'icon_url' => 'https://flb-assets.s3.ap-southeast-1.amazonaws.com/static/fleetbase-icon.png' ], 'version' => env('FLEETBASE_VERSION', '0.7.1'), - 'instance_id' => env('FLEETBASE_INSTANCE_ID') ?? (file_exists(base_path('.fleetbase-id')) ? trim(file_get_contents(base_path('.fleetbase-id'))) : null) + 'instance_id' => env('FLEETBASE_INSTANCE_ID') ?? (file_exists(base_path('.fleetbase-id')) ? trim(file_get_contents(base_path('.fleetbase-id'))) : null), + 'user_cache' => [ + 'enabled' => env('USER_CACHE_ENABLED', true), + 'server_ttl' => (int) env('USER_CACHE_SERVER_TTL', 900), // 15 minutes + 'browser_ttl' => (int) env('USER_CACHE_BROWSER_TTL', 300), // 5 minutes + ] ]; diff --git a/config/image.php b/config/image.php new file mode 100644 index 00000000..170b0d4b --- /dev/null +++ b/config/image.php @@ -0,0 +1,145 @@ + env('IMAGE_DRIVER', extension_loaded('imagick') ? 'imagick' : 'gd'), + + /* + |-------------------------------------------------------------------------- + | Default Quality + |-------------------------------------------------------------------------- + | + | The default quality for image compression (1-100). + | Higher values mean better quality but larger file sizes. + | + | Recommended: 85 (good balance between quality and size) + | + */ + + 'default_quality' => env('IMAGE_DEFAULT_QUALITY', 85), + + /* + |-------------------------------------------------------------------------- + | Allow Upscaling + |-------------------------------------------------------------------------- + | + | Whether to allow upscaling small images to larger dimensions. + | When false, images smaller than the target size are left unchanged. + | + | Recommended: false (prevents quality loss from upscaling) + | + */ + + 'allow_upscale' => env('IMAGE_ALLOW_UPSCALE', false), + + /* + |-------------------------------------------------------------------------- + | Maximum Dimensions + |-------------------------------------------------------------------------- + | + | Safety limits for image dimensions to prevent memory exhaustion. + | + */ + + 'max_width' => env('IMAGE_MAX_WIDTH', 10000), + 'max_height' => env('IMAGE_MAX_HEIGHT', 10000), + + /* + |-------------------------------------------------------------------------- + | Resize Presets + |-------------------------------------------------------------------------- + | + | Predefined dimension presets for common use cases. + | These act as maximum dimensions (images won't be upscaled by default). + | + | Usage: resize=thumb, resize=sm, resize=md, etc. + | + */ + + 'presets' => [ + 'thumb' => [ + 'width' => 150, + 'height' => 150, + 'name' => 'Thumbnail', + ], + 'sm' => [ + 'width' => 320, + 'height' => 240, + 'name' => 'Small', + ], + 'md' => [ + 'width' => 640, + 'height' => 480, + 'name' => 'Medium', + ], + 'lg' => [ + 'width' => 1024, + 'height' => 768, + 'name' => 'Large', + ], + 'xl' => [ + 'width' => 1920, + 'height' => 1080, + 'name' => 'Extra Large', + ], + '2xl' => [ + 'width' => 2560, + 'height' => 1440, + 'name' => '2K', + ], + ], + + /* + |-------------------------------------------------------------------------- + | Supported Formats + |-------------------------------------------------------------------------- + | + | Image formats that can be used for conversion. + | + */ + + 'formats' => [ + 'jpg', + 'jpeg', + 'png', + 'webp', + 'gif', + 'bmp', + 'avif', + ], + + /* + |-------------------------------------------------------------------------- + | Resize Modes + |-------------------------------------------------------------------------- + | + | Available resize modes: + | + | - fit: Resize to fit within dimensions, maintain aspect ratio (default) + | - crop: Crop to exact dimensions, maintain aspect ratio + | - stretch: Stretch to exact dimensions, ignore aspect ratio + | - contain: Fit within dimensions with padding + | + */ + + 'modes' => [ + 'fit', + 'crop', + 'stretch', + 'contain', + ], +]; diff --git a/src/Casts/PolymorphicType.php b/src/Casts/PolymorphicType.php index f3fc5383..4f12f76b 100644 --- a/src/Casts/PolymorphicType.php +++ b/src/Casts/PolymorphicType.php @@ -30,7 +30,6 @@ public function set($model, $key, $value, $attributes) { // default $className is null $className = null; - if ($value) { $className = Utils::getMutationType($value); } diff --git a/src/Http/Controllers/Internal/v1/AuthController.php b/src/Http/Controllers/Internal/v1/AuthController.php index 12b4ff62..1e573c8a 100644 --- a/src/Http/Controllers/Internal/v1/AuthController.php +++ b/src/Http/Controllers/Internal/v1/AuthController.php @@ -132,8 +132,7 @@ public function session(Request $request) return response()->error('Session has expired.', 401, ['restore' => false]); } - return response()->json($session) - ->header('Cache-Control', 'private, max-age=300'); // 5 minutes + return response()->json($session)->header('Cache-Control', 'private, max-age=300'); // 5 minutes } /** @@ -647,8 +646,8 @@ public function getUserOrganizations(Request $request) ->whereNull('company_users.deleted_at') ->whereNotNull('companies.owner_uuid') ->with([ - 'owner:uuid,company_uuid,name,email', - 'owner.companyUser:uuid,user_uuid,company_uuid', + 'owner:uuid,company_uuid,name,email,updated_at', + 'owner.companyUser:uuid,user_uuid,company_uuid,updated_at', ]) ->distinct() ->get(); @@ -661,7 +660,8 @@ public function getUserOrganizations(Request $request) * - count of organizations */ $etagPayload = $companies->map(function ($company) { - return $company->uuid . ':' . $company->updated_at; + $ownerTimestamp = $company->owner?->updated_at?->timestamp ?? 0; + return $company->uuid . ':' . $company->updated_at->timestamp . ':' . $ownerTimestamp; })->join('|'); // Add count to ETag (if orgs added/removed) @@ -671,7 +671,7 @@ public function getUserOrganizations(Request $request) return Organization::collection($companies) ->response() ->setEtag($etag) - ->header('Cache-Control', 'private, max-age=1800, must-revalidate'); + ->header('Cache-Control', 'private, no-cache, must-revalidate'); } /** diff --git a/src/Http/Controllers/Internal/v1/FileController.php b/src/Http/Controllers/Internal/v1/FileController.php index 45e9ab07..6da3ed31 100644 --- a/src/Http/Controllers/Internal/v1/FileController.php +++ b/src/Http/Controllers/Internal/v1/FileController.php @@ -7,6 +7,7 @@ use Fleetbase\Http\Requests\Internal\UploadBase64FileRequest; use Fleetbase\Http\Requests\Internal\UploadFileRequest; use Fleetbase\Models\File; +use Fleetbase\Services\ImageService; use Fleetbase\Support\Utils; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; @@ -20,37 +21,127 @@ class FileController extends FleetbaseController */ public $resource = 'file'; + protected ImageService $imageService; + /** - * Handle file uploads. + * Create a new FileController instance. + */ + public function __construct(ImageService $imageService) + { + parent::__construct(); + $this->imageService = $imageService; + } + + /** + * Handle file uploads with optional image resizing. * * @return \Illuminate\Http\Response */ public function upload(UploadFileRequest $request) { - $disk = $request->input('disk', config('filesystems.default')); - $bucket = $request->input('bucket', config('filesystems.disks.' . $disk . '.bucket', config('filesystems.disks.s3.bucket'))); - $type = $request->input('type'); - $size = $request->input('file_size', $request->file->getSize()); - $path = $request->input('path', 'uploads'); + $disk = $request->input('disk', config('filesystems.default')); + $bucket = $request->input('bucket', config('filesystems.disks.' . $disk . '.bucket', config('filesystems.disks.s3.bucket'))); + $type = $request->input('type'); + $path = $request->input('path', 'uploads'); - // Generate a filename + // Image resize parameters + $resize = $request->input('resize'); + $resizeWidth = $request->input('resize_width'); + $resizeHeight = $request->input('resize_height'); + $resizeMode = $request->input('resize_mode', 'fit'); + $resizeQuality = $request->input('resize_quality'); + $resizeFormat = $request->input('resize_format'); + $resizeUpscale = $request->boolean('resize_upscale', false); + + // Generate filename $fileName = File::randomFileNameFromRequest($request); - // Upload the file to storage disk - try { - $path = $request->file->storeAs($path, $fileName, ['disk' => $disk]); - } catch (\Throwable $e) { - return response()->error($e->getMessage()); - } + // Check if image resizing is requested + $shouldResize = ($resize || $resizeWidth || $resizeHeight) && $this->imageService->isImage($request->file); - // If file upload failed - if ($path === false) { - return response()->error('File upload failed.'); + if ($shouldResize) { + // Resize image + try { + if ($resize) { + // Use preset + $resizedData = $this->imageService->resizePreset( + $request->file, + $resize, + $resizeMode, + $resizeQuality, + $resizeUpscale + ); + } else { + // Use explicit dimensions + $resizedData = $this->imageService->resize( + $request->file, + $resizeWidth, + $resizeHeight, + $resizeMode, + $resizeQuality, + $resizeFormat, + $resizeUpscale + ); + } + + // Update filename extension if format changed + if ($resizeFormat) { + $fileName = preg_replace('/\.[^.]+$/', '.' . $resizeFormat, $fileName); + } + + // Upload resized image + $fullPath = $path . '/' . $fileName; + $uploaded = Storage::disk($disk)->put($fullPath, $resizedData); + + if (!$uploaded) { + return response()->error('Failed to upload resized image.'); + } + + $storedPath = $fullPath; + $size = strlen($resizedData); + } catch (\Throwable $e) { + return response()->error('Image resize failed: ' . $e->getMessage()); + } + } else { + // Upload original file without resizing + $size = $request->input('file_size', $request->file->getSize()); + + try { + $storedPath = $request->file->storeAs($path, $fileName, ['disk' => $disk]); + } catch (\Throwable $e) { + return response()->error($e->getMessage()); + } + + if ($storedPath === false) { + return response()->error('File upload failed.'); + } } - // Create a file record + // Create file record try { - $file = File::createFromUpload($request->file, $path, $type, $size, $disk, $bucket); + $file = File::createFromUpload( + $request->file, + $storedPath, + $type, + $size, + $disk, + $bucket + ); + + // Store resize metadata + if ($shouldResize) { + $file->setMeta('resized', true); + $file->setMeta('resize_params', [ + 'preset' => $resize, + 'width' => $resizeWidth, + 'height' => $resizeHeight, + 'mode' => $resizeMode, + 'quality' => $resizeQuality, + 'format' => $resizeFormat, + 'upscale' => $resizeUpscale, + ]); + $file->save(); + } } catch (\Throwable $e) { return response()->error($e->getMessage()); } @@ -67,7 +158,7 @@ public function upload(UploadFileRequest $request) } /** - * Handle file upload of base64. + * Handle file upload of base64 with optional image resizing. * * @return \Illuminate\Http\Response */ @@ -83,8 +174,17 @@ public function uploadBase64(UploadBase64FileRequest $request) $subjectId = $request->input('subject_uuid'); $subjectType = $request->input('subject_type'); + // Image resize parameters + $resize = $request->input('resize'); + $resizeWidth = $request->input('resize_width'); + $resizeHeight = $request->input('resize_height'); + $resizeMode = $request->input('resize_mode', 'fit'); + $resizeQuality = $request->input('resize_quality'); + $resizeFormat = $request->input('resize_format'); + $resizeUpscale = $request->boolean('resize_upscale', false); + if (!$data) { - return response()->error('Oops! Looks like nodata was provided for upload.', 400); + return response()->error('Oops! Looks like no data was provided for upload.', 400); } // Correct $path for uploads @@ -92,23 +192,81 @@ public function uploadBase64(UploadBase64FileRequest $request) $path = str_replace('uploads/', '', $path); } - // Set the full file path + // Decode base64 + $decoded = base64_decode($data); + + // Check if resizing is requested and file is image + $shouldResize = ($resize || $resizeWidth || $resizeHeight) && str_starts_with($contentType, 'image/'); + + if ($shouldResize) { + try { + // Create temporary file for Intervention Image + $tempPath = tempnam(sys_get_temp_dir(), 'img_'); + file_put_contents($tempPath, $decoded); + + // Read and resize + $image = $this->imageService->manager->read($tempPath); + + if ($resize) { + $preset = $this->imageService->getPreset($resize); + if ($resizeUpscale) { + $image->scale($preset['width'], $preset['height']); + } else { + $image->scaleDown($preset['width'], $preset['height']); + } + } else { + switch ($resizeMode) { + case 'crop': + if ($resizeUpscale) { + $image->cover($resizeWidth, $resizeHeight); + } else { + $image->coverDown($resizeWidth, $resizeHeight); + } + break; + case 'fit': + default: + if ($resizeUpscale) { + $image->scale($resizeWidth, $resizeHeight); + } else { + $image->scaleDown($resizeWidth, $resizeHeight); + } + break; + } + } + + // Encode + $encoded = $resizeFormat + ? $image->toFormat($resizeFormat, $resizeQuality ?? 85) + : $image->encode(quality: $resizeQuality ?? 85); + + $decoded = $encoded->toString(); + + // Clean up temp file + unlink($tempPath); + + // Update filename if format changed + if ($resizeFormat) { + $fileName = preg_replace('/\.[^.]+$/', '.' . $resizeFormat, $fileName); + } + } catch (\Throwable $e) { + return response()->error('Image resize failed: ' . $e->getMessage()); + } + } + + // Upload to storage $fullPath = $path . '/' . $fileName; - $uploaded = false; - // Upload file to path try { - $uploaded = Storage::disk($disk)->put($fullPath, base64_decode($data)); + $uploaded = Storage::disk($disk)->put($fullPath, $decoded); } catch (\Throwable $e) { return response()->error($e->getMessage()); } - // If file upload failed - if ($uploaded === false) { + if (!$uploaded) { return response()->error('File upload failed.'); } - // Create file record for upload + // Create file record try { $file = File::create([ 'company_uuid' => session('company'), @@ -117,13 +275,28 @@ public function uploadBase64(UploadBase64FileRequest $request) 'subject_type' => Utils::getMutationType($subjectType), 'disk' => $disk, 'original_filename' => basename($fullPath), - 'extension' => 'png', + 'extension' => pathinfo($fullPath, PATHINFO_EXTENSION), 'content_type' => $contentType, 'path' => $fullPath, 'bucket' => $bucket, 'type' => $fileType, - 'size' => Utils::getBase64ImageSize($data), + 'size' => strlen($decoded), ]); + + // Store resize metadata + if ($shouldResize) { + $file->setMeta('resized', true); + $file->setMeta('resize_params', [ + 'preset' => $resize, + 'width' => $resizeWidth, + 'height' => $resizeHeight, + 'mode' => $resizeMode, + 'quality' => $resizeQuality, + 'format' => $resizeFormat, + 'upscale' => $resizeUpscale, + ]); + $file->save(); + } } catch (\Throwable $e) { return response()->error($e->getMessage()); } diff --git a/src/Http/Controllers/Internal/v1/UserController.php b/src/Http/Controllers/Internal/v1/UserController.php index 457a9a6b..34887b7e 100644 --- a/src/Http/Controllers/Internal/v1/UserController.php +++ b/src/Http/Controllers/Internal/v1/UserController.php @@ -27,6 +27,7 @@ use Fleetbase\Support\NotificationRegistry; use Fleetbase\Support\TwoFactorAuth; use Fleetbase\Support\Utils; +use Fleetbase\Services\UserCacheService; use Illuminate\Http\Request; use Illuminate\Support\Arr; use Illuminate\Support\Carbon; @@ -169,11 +170,46 @@ public function current(Request $request) return response()->error('No user session found', 401); } - return response()->json( - [ + // Check if caching is enabled + if (!UserCacheService::isEnabled()) { + return response()->json([ 'user' => new $this->resource($user), - ] - ); + ]); + } + + // Generate ETag for cache validation + $etag = UserCacheService::generateETag($user); + + // Try to get from server cache + $companyId = session('company'); + $cachedData = UserCacheService::get($user->id, $companyId); + + if ($cachedData) { + // Return cached data with cache headers + return response()->json(['user' => $cachedData]) + ->setEtag($etag) + ->setLastModified($user->updated_at) + ->header('Cache-Control', 'private, no-cache, must-revalidate') + ->header('X-Cache-Hit', 'true'); + } + + // Cache miss - load fresh data with eager loading + // Note: role, policies, permissions are accessors that use companyUser relationship + $user->loadCompanyUser(); + + // Transform to resource + $userData = new $this->resource($user); + $userArray = $userData->toArray($request); + + // Store in cache + UserCacheService::put($user->id, $companyId, $userArray); + + // Return with cache headers + return response()->json(['user' => $userArray]) + ->setEtag($etag) + ->setLastModified($user->updated_at) + ->header('Cache-Control', 'private, no-cache, must-revalidate') + ->header('X-Cache-Hit', 'false'); } /** diff --git a/src/Http/Filter/Filter.php b/src/Http/Filter/Filter.php index 2abd3eb3..af36f4ec 100644 --- a/src/Http/Filter/Filter.php +++ b/src/Http/Filter/Filter.php @@ -37,6 +37,39 @@ abstract class Filter */ protected $builder; + /** + * Cache for method existence checks to avoid repeated reflection. + * + * @var array + */ + protected $methodCache = []; + + /** + * Parameters to skip during filter application. + * These are handled elsewhere or are not filter parameters. + * + * @var array + */ + protected static $skipParams = [ + 'limit', + 'offset', + 'page', + 'sort', + 'order', + 'with', + 'expand', + 'without', + 'with_count', + 'without_relations', + ]; + + /** + * Cached range patterns for range filter detection. + * + * @var array|null + */ + protected static $rangePatterns; + /** * Initialize a new filter instance. * @@ -55,12 +88,20 @@ public function apply(Builder $builder): Builder { $this->builder = $builder; - foreach ($this->request->all() as $name => $value) { + // PERFORMANCE OPTIMIZATION: Filter out non-filter parameters early + // This avoids iterating through pagination, sorting, and relationship params + $filterParams = array_diff_key( + $this->request->all(), + array_flip(static::$skipParams) + ); + + foreach ($filterParams as $name => $value) { $this->applyFilter($name, $value); } $this->applyRangeFilters(); + // CRITICAL: Always apply queryForInternal/queryForPublic for data isolation if (Http::isInternalRequest($this->request) && method_exists($this, 'queryForInternal')) { call_user_func([$this, 'queryForInternal']); } @@ -75,34 +116,70 @@ public function apply(Builder $builder): Builder /** * Find dynamically named column filters and apply them. * + * PERFORMANCE OPTIMIZATIONS: + * - Early return for empty values + * - Cache method existence checks + * - Direct method calls instead of call_user_func_array + * * @param string $name * * @return void */ private function applyFilter($name, $value) { - $methodNames = [$name, Str::camel($name)]; + // PERFORMANCE OPTIMIZATION: Skip empty values early + if (empty($value)) { + return; + } - foreach ($methodNames as $methodName) { - // if query method value cannot be empty - if (empty($value)) { - continue; + // PERFORMANCE OPTIMIZATION: Check cache first to avoid repeated reflection + $cacheKey = $name; + if (!isset($this->methodCache[$cacheKey])) { + $methodNames = [$name, Str::camel($name)]; + $this->methodCache[$cacheKey] = null; + + foreach ($methodNames as $methodName) { + if (method_exists($this, $methodName)) { + $this->methodCache[$cacheKey] = $methodName; + break; + } } - if (method_exists($this, $methodName) || static::isExpansion($methodName)) { - call_user_func_array([$this, $methodName], [$value]); - break; + // Check if it's an expansion (only if method not found) + if (!$this->methodCache[$cacheKey] && static::isExpansion($name)) { + $this->methodCache[$cacheKey] = $name; } } + + // PERFORMANCE OPTIMIZATION: Direct method call instead of call_user_func_array + if ($this->methodCache[$cacheKey]) { + $this->{$this->methodCache[$cacheKey]}($value); + } } /** * Apply dynamically named range filters. * + * PERFORMANCE OPTIMIZATION: Early return if no range parameters detected + * * @return void */ private function applyRangeFilters() { + // PERFORMANCE OPTIMIZATION: Quick check if any range params exist + // This avoids expensive processing when no range filters are present + $hasRangeParams = false; + foreach (array_keys($this->request->all()) as $key) { + if (preg_match('/_(?:after|before|from|to|min|max|start|end|gte|lte|greater|less)$/', $key)) { + $hasRangeParams = true; + break; + } + } + + if (!$hasRangeParams) { + return; + } + $ranges = $this->getRangeFilterCallbacks(); if (!is_array($ranges)) { @@ -118,10 +195,17 @@ private function applyRangeFilters() /** * Find standard range filters methods. + * + * PERFORMANCE OPTIMIZATION: Initialize range patterns once */ private function getRangeFilterCallbacks(): array { - $ranges = ['after:before', 'from:to', 'min:max', 'start:end', 'gte:lte', 'greater:less']; + // PERFORMANCE OPTIMIZATION: Initialize patterns once as static + if (static::$rangePatterns === null) { + static::$rangePatterns = ['after:before', 'from:to', 'min:max', 'start:end', 'gte:lte', 'greater:less']; + } + + $ranges = static::$rangePatterns; $prepositions = Arr::flatten( array_map( diff --git a/src/Http/Middleware/AttachCacheHeaders.php b/src/Http/Middleware/AttachCacheHeaders.php new file mode 100644 index 00000000..309234ff --- /dev/null +++ b/src/Http/Middleware/AttachCacheHeaders.php @@ -0,0 +1,100 @@ +isApiRequest($request)) { + return $response; + } + + // Check if caching is enabled + if (!ApiModelCache::isCachingEnabled()) { + $response->headers->set('X-Cache-Status', 'DISABLED'); + + return $response; + } + + // Get cache status from ApiModelCache + $cacheStatus = ApiModelCache::getCacheStatus(); + $cacheKey = ApiModelCache::getCacheKey(); + + // Add cache status header + if ($cacheStatus) { + $response->headers->set('X-Cache-Status', $cacheStatus); + } else { + // No cache operation occurred (e.g., POST/PUT/DELETE requests) + $response->headers->set('X-Cache-Status', 'BYPASS'); + } + + // Add cache key header in debug mode + if ($this->isDebugMode() && $cacheKey) { + $response->headers->set('X-Cache-Key', $cacheKey); + } + + // Add cache info header + if ($cacheStatus === 'HIT' || $cacheStatus === 'MISS') { + $response->headers->set('X-Cache-Driver', config('cache.default')); + } + + // Reset cache status for next request + ApiModelCache::resetCacheStatus(); + + return $response; + } + + /** + * Check if the request is an API request. + * + * Uses Fleetbase's Http helper to determine if the request is either: + * - Internal request (int/v1/... - used by Fleetbase applications) + * - Public request (v1/... - used by end users for integrations/dev) + */ + protected function isApiRequest(Request $request): bool + { + // Check if it's an internal request (int/v1/...) + if (\Fleetbase\Support\Http::isInternalRequest($request)) { + return true; + } + + // Check if it's a public API request (v1/...) + if (\Fleetbase\Support\Http::isPublicRequest($request)) { + return true; + } + + // Fallback: check if request expects JSON + if ($request->expectsJson()) { + return true; + } + + return false; + } + + /** + * Check if debug mode is enabled. + */ + protected function isDebugMode(): bool + { + return config('app.debug', false) || config('api.cache.debug', false); + } +} diff --git a/src/Http/Middleware/ThrottleRequests.php b/src/Http/Middleware/ThrottleRequests.php index 08de80f4..3f32b83c 100644 --- a/src/Http/Middleware/ThrottleRequests.php +++ b/src/Http/Middleware/ThrottleRequests.php @@ -3,14 +3,126 @@ namespace Fleetbase\Http\Middleware; use Illuminate\Routing\Middleware\ThrottleRequests as ThrottleRequestsMiddleware; +use Illuminate\Support\Facades\Log; class ThrottleRequests extends ThrottleRequestsMiddleware { + /** + * Handle an incoming request. + * + * This middleware supports multiple bypass mechanisms: + * 1. Global disable via THROTTLE_ENABLED=false (for development/testing) + * 2. Unlimited API keys via THROTTLE_UNLIMITED_API_KEYS (for production testing) + * + * @param \Illuminate\Http\Request $request + * @param int|string $maxAttempts + * @param float|int $decayMinutes + * @param string $prefix + * + * @return \Symfony\Component\HttpFoundation\Response + */ public function handle($request, \Closure $next, $maxAttempts = null, $decayMinutes = null, $prefix = '') { + // Check if throttling is globally disabled via configuration + if (config('api.throttle.enabled', true) === false) { + // Log when throttling is disabled (for security monitoring) + if (app()->environment('production')) { + Log::warning('API throttling is DISABLED globally', [ + 'ip' => $request->ip(), + 'user_agent' => $request->userAgent(), + 'path' => $request->path(), + 'method' => $request->method(), + ]); + } + + return $next($request); + } + + // Check if request is using an unlimited/test API key + $apiKey = $this->extractApiKey($request); + if ($apiKey && $this->isUnlimitedApiKey($apiKey)) { + // Log usage of unlimited API key (for auditing) + Log::info('Request using unlimited API key', [ + 'api_key_prefix' => substr($apiKey, 0, 20) . '...', + 'ip' => $request->ip(), + 'path' => $request->path(), + 'method' => $request->method(), + ]); + + return $next($request); + } + + // Normal throttling: Get limits from configuration $maxAttempts = config('api.throttle.max_attempts', 90); $decayMinutes = config('api.throttle.decay_minutes', 1); return parent::handle($request, $next, $maxAttempts, $decayMinutes, $prefix); } + + /** + * Extract API key from the request. + * + * Supports multiple authentication methods: + * - Authorization header (Bearer token) + * - Basic auth + * - Query parameter + * + * @param \Illuminate\Http\Request $request + * + * @return string|null + */ + protected function extractApiKey($request) + { + // Try Authorization header (Bearer token) + $authorization = $request->header('Authorization'); + if ($authorization) { + return $authorization; + } + + // Try Basic Auth + $user = $request->getUser(); + if ($user) { + return 'Basic:' . $user; + } + + // Try query parameter (less secure, but supported) + $apiKey = $request->query('api_key'); + if ($apiKey) { + return 'Query:' . $apiKey; + } + + return null; + } + + /** + * Check if the given API key is in the unlimited keys list. + * + * @param string $apiKey + * + * @return bool + */ + protected function isUnlimitedApiKey($apiKey) + { + $unlimitedKeys = config('api.throttle.unlimited_keys', []); + + if (empty($unlimitedKeys)) { + return false; + } + + // Check for exact match + if (in_array($apiKey, $unlimitedKeys)) { + return true; + } + + // Check for Bearer token match (with or without "Bearer " prefix) + $cleanKey = str_replace('Bearer ', '', $apiKey); + foreach ($unlimitedKeys as $unlimitedKey) { + $cleanUnlimitedKey = str_replace('Bearer ', '', $unlimitedKey); + if ($cleanKey === $cleanUnlimitedKey) { + return true; + } + } + + return false; + } } diff --git a/src/Http/Middleware/ValidateETag.php b/src/Http/Middleware/ValidateETag.php new file mode 100644 index 00000000..279a9c13 --- /dev/null +++ b/src/Http/Middleware/ValidateETag.php @@ -0,0 +1,44 @@ +getEtag(); + if (!$responseETag) { + return $response; + } + + // Get client's If-None-Match header (can contain multiple ETags) + $clientETags = $request->getETags(); + + // Check if any of the client's ETags match the response ETag + if (in_array($responseETag, $clientETags)) { + // ETags match - return 304 Not Modified + $response->setNotModified(); + } + + return $response; + } +} diff --git a/src/Http/Requests/Internal/UploadBase64FileRequest.php b/src/Http/Requests/Internal/UploadBase64FileRequest.php index d57ca467..8109c2fb 100644 --- a/src/Http/Requests/Internal/UploadBase64FileRequest.php +++ b/src/Http/Requests/Internal/UploadBase64FileRequest.php @@ -30,6 +30,14 @@ public function rules() 'content_type' => ['nullable', 'string'], 'subject_uuid' => ['nullable', 'string'], 'subject_type' => ['nullable', 'string'], + // Image resize parameters + 'resize' => 'nullable|string|in:thumb,sm,md,lg,xl,2xl', + 'resize_width' => 'nullable|integer|min:1|max:10000', + 'resize_height' => 'nullable|integer|min:1|max:10000', + 'resize_mode' => 'nullable|string|in:fit,crop,stretch,contain', + 'resize_quality' => 'nullable|integer|min:1|max:100', + 'resize_format' => 'nullable|string|in:jpg,jpeg,png,webp,gif,bmp,avif', + 'resize_upscale' => 'nullable|boolean', ]; } @@ -41,8 +49,17 @@ public function rules() public function messages() { return [ - 'data.required' => 'Please provide a base64 encoded file.', - 'file_name.required' => 'Please provide a file name.', + 'data.required' => 'Please provide a base64 encoded file.', + 'file_name.required' => 'Please provide a file name.', + 'resize.in' => 'Invalid resize preset. Must be one of: thumb, sm, md, lg, xl, 2xl', + 'resize_mode.in' => 'Invalid resize mode. Must be one of: fit, crop, stretch, contain', + 'resize_quality.min' => 'Quality must be at least 1.', + 'resize_quality.max' => 'Quality must not exceed 100.', + 'resize_width.min' => 'Width must be at least 1 pixel.', + 'resize_width.max' => 'Width must not exceed 10000 pixels.', + 'resize_height.min' => 'Height must be at least 1 pixel.', + 'resize_height.max' => 'Height must not exceed 10000 pixels.', + 'resize_format.in' => 'Invalid format. Must be one of: jpg, jpeg, png, webp, gif, bmp, avif', ]; } } diff --git a/src/Http/Requests/Internal/UploadFileRequest.php b/src/Http/Requests/Internal/UploadFileRequest.php index c374005c..a58f0d72 100644 --- a/src/Http/Requests/Internal/UploadFileRequest.php +++ b/src/Http/Requests/Internal/UploadFileRequest.php @@ -68,6 +68,14 @@ public function rules() 'application/vnd.openxmlformats-officedocument.presentationml.presentation', ]), ], + // Image resize parameters + 'resize' => 'nullable|string|in:thumb,sm,md,lg,xl,2xl', + 'resize_width' => 'nullable|integer|min:1|max:10000', + 'resize_height' => 'nullable|integer|min:1|max:10000', + 'resize_mode' => 'nullable|string|in:fit,crop,stretch,contain', + 'resize_quality' => 'nullable|integer|min:1|max:100', + 'resize_format' => 'nullable|string|in:jpg,jpeg,png,webp,gif,bmp,avif', + 'resize_upscale' => 'nullable|boolean', ]; } @@ -79,10 +87,19 @@ public function rules() public function messages() { return [ - 'file.required' => 'Please select a file to upload.', - 'file.file' => 'The uploaded file is not valid.', - 'file.max' => 'The uploaded file exceeds the maximum file size allowed.', - 'file.mimes' => 'The uploaded file type is not allowed.', + 'file.required' => 'Please select a file to upload.', + 'file.file' => 'The uploaded file is not valid.', + 'file.max' => 'The uploaded file exceeds the maximum file size allowed.', + 'file.mimes' => 'The uploaded file type is not allowed.', + 'resize.in' => 'Invalid resize preset. Must be one of: thumb, sm, md, lg, xl, 2xl', + 'resize_mode.in' => 'Invalid resize mode. Must be one of: fit, crop, stretch, contain', + 'resize_quality.min' => 'Quality must be at least 1.', + 'resize_quality.max' => 'Quality must not exceed 100.', + 'resize_width.min' => 'Width must be at least 1 pixel.', + 'resize_width.max' => 'Width must not exceed 10000 pixels.', + 'resize_height.min' => 'Height must be at least 1 pixel.', + 'resize_height.max' => 'Height must not exceed 10000 pixels.', + 'resize_format.in' => 'Invalid format. Must be one of: jpg, jpeg, png, webp, gif, bmp, avif', ]; } } diff --git a/src/Models/ApiEvent.php b/src/Models/ApiEvent.php index 260820a2..9289a35f 100644 --- a/src/Models/ApiEvent.php +++ b/src/Models/ApiEvent.php @@ -5,6 +5,7 @@ use Fleetbase\Casts\Json; use Fleetbase\Traits\Filterable; use Fleetbase\Traits\HasApiModelBehavior; +use Fleetbase\Traits\HasApiModelCache; use Fleetbase\Traits\HasPublicId; use Fleetbase\Traits\HasUuid; use Fleetbase\Traits\Searchable; @@ -14,6 +15,7 @@ class ApiEvent extends Model use HasUuid; use HasPublicId; use HasApiModelBehavior; + use HasApiModelCache; use Searchable; use Filterable; diff --git a/src/Models/ApiRequestLog.php b/src/Models/ApiRequestLog.php index b2b1b457..e3e0f163 100644 --- a/src/Models/ApiRequestLog.php +++ b/src/Models/ApiRequestLog.php @@ -5,6 +5,7 @@ use Fleetbase\Casts\Json; use Fleetbase\Traits\Filterable; use Fleetbase\Traits\HasApiModelBehavior; +use Fleetbase\Traits\HasApiModelCache; use Fleetbase\Traits\HasPublicId; use Fleetbase\Traits\HasUuid; use Fleetbase\Traits\Searchable; @@ -14,6 +15,7 @@ class ApiRequestLog extends Model use HasUuid; use HasPublicId; use HasApiModelBehavior; + use HasApiModelCache; use Searchable; use Filterable; diff --git a/src/Models/Category.php b/src/Models/Category.php index bf4a6c63..87119048 100644 --- a/src/Models/Category.php +++ b/src/Models/Category.php @@ -5,6 +5,7 @@ use Fleetbase\Casts\Json; use Fleetbase\Support\Utils; use Fleetbase\Traits\HasApiModelBehavior; +use Fleetbase\Traits\HasApiModelCache; use Fleetbase\Traits\HasMetaAttributes; use Fleetbase\Traits\HasPublicId; use Fleetbase\Traits\HasUuid; @@ -17,6 +18,7 @@ class Category extends Model use HasUuid; use HasPublicId; use HasApiModelBehavior; + use HasApiModelCache; use HasSlug; use HasMetaAttributes; use Searchable; diff --git a/src/Models/Transaction.php b/src/Models/Transaction.php index f461a52a..b10e1699 100644 --- a/src/Models/Transaction.php +++ b/src/Models/Transaction.php @@ -5,6 +5,7 @@ use Fleetbase\Casts\Json; use Fleetbase\Casts\PolymorphicType; use Fleetbase\Traits\HasApiModelBehavior; +use Fleetbase\Traits\HasApiModelCache; use Fleetbase\Traits\HasMetaAttributes; use Fleetbase\Traits\HasPublicId; use Fleetbase\Traits\HasUuid; @@ -14,6 +15,7 @@ class Transaction extends Model use HasUuid; use HasPublicId; use HasApiModelBehavior; + use HasApiModelCache; use HasMetaAttributes; /** diff --git a/src/Models/User.php b/src/Models/User.php index 65c23a0a..deeb1da7 100644 --- a/src/Models/User.php +++ b/src/Models/User.php @@ -1329,6 +1329,9 @@ public function assignSingleRole($role): self if ($this->companyUser) { $this->companyUser->assignSingleRole($role); + // Invalidate user cache after role change + \Fleetbase\Services\UserCacheService::invalidateUser($this); + return $this; } diff --git a/src/Models/VerificationCode.php b/src/Models/VerificationCode.php index 02594b16..a13ab1c6 100644 --- a/src/Models/VerificationCode.php +++ b/src/Models/VerificationCode.php @@ -183,7 +183,8 @@ public static function generateSmsVerificationFor($subject, $for = 'phone_verifi // Twilio params $twilioParams = []; - if ($companyUuid = session('company')) { + $companyUuid = data_get($options, 'company_uuid') ?? session('company') ?? data_get($subject, 'company_uuid'); + if ($companyUuid) { $company = Company::select(['uuid', 'options'])->find($companyUuid); if ($company) { diff --git a/src/Models/WebhookRequestLog.php b/src/Models/WebhookRequestLog.php index e5861cda..73958290 100644 --- a/src/Models/WebhookRequestLog.php +++ b/src/Models/WebhookRequestLog.php @@ -5,6 +5,7 @@ use Fleetbase\Casts\Json; use Fleetbase\Traits\Filterable; use Fleetbase\Traits\HasApiModelBehavior; +use Fleetbase\Traits\HasApiModelCache; use Fleetbase\Traits\HasPublicId; use Fleetbase\Traits\HasUuid; use Fleetbase\Traits\Searchable; @@ -13,6 +14,7 @@ class WebhookRequestLog extends Model { use HasUuid; use HasApiModelBehavior; + use HasApiModelCache; use HasPublicId; use Searchable; use Filterable; diff --git a/src/Observers/UserObserver.php b/src/Observers/UserObserver.php index 958c6bad..529d01bd 100644 --- a/src/Observers/UserObserver.php +++ b/src/Observers/UserObserver.php @@ -4,9 +4,27 @@ use Fleetbase\Models\CompanyUser; use Fleetbase\Models\User; +use Fleetbase\Services\UserCacheService; +use Illuminate\Support\Facades\Cache; class UserObserver { + /** + * Handle the User "updated" event. + * + * @param \Fleetbase\Models\User $user + * + * @return void + */ + public function updated(User $user): void + { + // Invalidate user cache when user is updated + UserCacheService::invalidateUser($user); + + // Invalidate organizations cache (user might be an owner) + $this->invalidateOrganizationsCache($user); + } + /** * Handle the User "deleted" event. * @@ -14,9 +32,48 @@ class UserObserver */ public function deleted(User $user) { + // Invalidate user cache when user is deleted + UserCacheService::invalidateUser($user); + + // Invalidate organizations cache + $this->invalidateOrganizationsCache($user); + // remove company user records if (session('company')) { CompanyUser::where(['company_uuid' => session('company'), 'user_uuid' => $user->uuid])->delete(); } } + + /** + * Handle the User "restored" event. + * + * @param \Fleetbase\Models\User $user + * + * @return void + */ + public function restored(User $user): void + { + // Invalidate user cache when user is restored + UserCacheService::invalidateUser($user); + + // Invalidate organizations cache + $this->invalidateOrganizationsCache($user); + } + + /** + * Invalidate organizations cache for the user. + * + * This clears the cached organizations list which includes owner relationships. + * When a user updates their profile and they are an owner of organizations, + * the cached organization data needs to be refreshed to reflect the updated owner info. + * + * @param \Fleetbase\Models\User $user + * + * @return void + */ + private function invalidateOrganizationsCache(User $user): void + { + $cacheKey = "user_organizations_{$user->uuid}"; + Cache::forget($cacheKey); + } } diff --git a/src/Providers/CoreServiceProvider.php b/src/Providers/CoreServiceProvider.php index 3ba982a9..159ddb26 100644 --- a/src/Providers/CoreServiceProvider.php +++ b/src/Providers/CoreServiceProvider.php @@ -42,6 +42,7 @@ class CoreServiceProvider extends ServiceProvider \Fleetbase\Http\Middleware\RequestTimer::class, \Fleetbase\Http\Middleware\ResetJsonResourceWrap::class, \Fleetbase\Http\Middleware\MergeConfigFromSettings::class, + \Fleetbase\Http\Middleware\AttachCacheHeaders::class, ]; /** @@ -57,6 +58,7 @@ class CoreServiceProvider extends ServiceProvider \Fleetbase\Http\Middleware\SetupFleetbaseSession::class, \Fleetbase\Http\Middleware\AuthorizationGuard::class, \Fleetbase\Http\Middleware\TrackPresence::class, + \Fleetbase\Http\Middleware\ValidateETag::class, ], 'fleetbase.api' => [ \Fleetbase\Http\Middleware\ThrottleRequests::class, @@ -112,6 +114,7 @@ public function register() $this->mergeConfigFrom(__DIR__ . '/../../config/database.redis.php', 'database.redis'); $this->mergeConfigFrom(__DIR__ . '/../../config/broadcasting.connections.php', 'broadcasting.connections'); $this->mergeConfigFrom(__DIR__ . '/../../config/fleetbase.php', 'fleetbase'); + $this->mergeConfigFrom(__DIR__ . '/../../config/api.php', 'api'); $this->mergeConfigFrom(__DIR__ . '/../../config/auth.php', 'auth'); $this->mergeConfigFrom(__DIR__ . '/../../config/sanctum.php', 'sanctum'); $this->mergeConfigFrom(__DIR__ . '/../../config/twilio.php', 'twilio'); @@ -125,6 +128,7 @@ public function register() $this->mergeConfigFrom(__DIR__ . '/../../config/sentry.php', 'sentry'); $this->mergeConfigFrom(__DIR__ . '/../../config/laravel-mysql-s3-backup.php', 'laravel-mysql-s3-backup'); $this->mergeConfigFrom(__DIR__ . '/../../config/responsecache.php', 'responsecache'); + $this->mergeConfigFrom(__DIR__ . '/../../config/image.php', 'image'); // setup report schema registry $this->app->singleton(ReportSchemaRegistry::class, function () { diff --git a/src/Services/ImageService.php b/src/Services/ImageService.php new file mode 100644 index 00000000..cc05b487 --- /dev/null +++ b/src/Services/ImageService.php @@ -0,0 +1,271 @@ +manager = new ImageManager($driver); + + // Load configuration + $this->presets = config('image.presets', $this->getDefaultPresets()); + $this->defaultQuality = config('image.default_quality', 85); + $this->allowUpscale = config('image.allow_upscale', false); + + Log::info('ImageService initialized', [ + 'driver' => extension_loaded('imagick') ? 'imagick' : 'gd', + 'presets' => array_keys($this->presets), + 'default_quality' => $this->defaultQuality, + ]); + } + + /** + * Check if file is an image. + */ + public function isImage(UploadedFile $file): bool + { + $mimeType = $file->getMimeType(); + + return $mimeType && str_starts_with($mimeType, 'image/'); + } + + /** + * Get image dimensions. + */ + public function getDimensions(UploadedFile $file): array + { + try { + $image = $this->manager->read($file->getRealPath()); + + return [ + 'width' => $image->width(), + 'height' => $image->height(), + ]; + } catch (\Throwable $e) { + Log::error('Failed to get image dimensions', [ + 'file' => $file->getClientOriginalName(), + 'error' => $e->getMessage(), + ]); + + return ['width' => 0, 'height' => 0]; + } + } + + /** + * Resize image with smart behavior (never upscale by default). + */ + public function resize( + UploadedFile $file, + ?int $width = null, + ?int $height = null, + string $mode = 'fit', + ?int $quality = null, + ?string $format = null, + ?bool $allowUpscale = null, + ): string { + $quality = $quality ?? $this->defaultQuality; + $allowUpscale = $allowUpscale ?? $this->allowUpscale; + $originalExtension = $file->getClientOriginalExtension(); + + try { + $image = $this->manager->read($file->getRealPath()); + + // Get original dimensions + $originalWidth = $image->width(); + $originalHeight = $image->height(); + + Log::debug('Resizing image', [ + 'original' => "{$originalWidth}x{$originalHeight}", + 'target' => "{$width}x{$height}", + 'mode' => $mode, + 'upscale' => $allowUpscale, + ]); + + // Check if resize is needed + if (!$allowUpscale) { + // Don't resize if image is already smaller + if ($width && $height) { + if ($originalWidth <= $width && $originalHeight <= $height) { + Log::debug('Image already smaller than target, skipping resize'); + + return $this->encodeImage($image, $format, $quality, $originalExtension); + } + } elseif ($width && $originalWidth <= $width) { + Log::debug('Image width already smaller, skipping resize'); + + return $this->encodeImage($image, $format, $quality); + } elseif ($height && $originalHeight <= $height) { + Log::debug('Image height already smaller, skipping resize'); + + return $this->encodeImage($image, $format, $quality); + } + } + + // Apply resize based on mode + switch ($mode) { + case 'crop': + // Crop to exact dimensions + if ($allowUpscale) { + $image->cover($width, $height); + } else { + $image->coverDown($width, $height); + } + break; + + case 'stretch': + // Stretch to exact dimensions (ignores aspect ratio) + if ($allowUpscale) { + $image->resize($width, $height); + } else { + $image->scaleDown($width, $height); + } + break; + + case 'contain': + // Fit within dimensions with padding + if ($allowUpscale) { + $image->contain($width, $height); + } else { + $image->containDown($width, $height); + } + break; + + case 'fit': + default: + // Fit within dimensions, maintain aspect ratio + if ($allowUpscale) { + $image->scale($width, $height); + } else { + $image->scaleDown($width, $height); + } + break; + } + + $result = $this->encodeImage($image, $format, $quality, $originalExtension); + + Log::info('Image resized successfully', [ + 'final_size' => strlen($result) . ' bytes', + ]); + + return $result; + } catch (\Throwable $e) { + Log::error('Image resize failed', [ + 'file' => $file->getClientOriginalName(), + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + throw $e; + } + } + + /** + * Resize using preset (smart behavior). + */ + public function resizePreset( + UploadedFile $file, + string $preset, + string $mode = 'fit', + ?int $quality = null, + ?bool $allowUpscale = null, + ): string { + $dimensions = $this->presets[$preset] ?? $this->presets['md']; + + Log::debug('Using preset', [ + 'preset' => $preset, + 'dimensions' => $dimensions, + ]); + + return $this->resize( + $file, + $dimensions['width'], + $dimensions['height'], + $mode, + $quality, + null, + $allowUpscale + ); + } + + /** + * Get preset dimensions. + */ + public function getPreset(string $preset): ?array + { + return $this->presets[$preset] ?? null; + } + + /** + * Get all available presets. + */ + public function getPresets(): array + { + return $this->presets; + } + + /** + * Encode image to format. + */ + protected function encodeImage($image, ?string $format, int $quality, ?string $originalExtension = null): string + { + if ($format) { + Log::debug('Converting image format', ['format' => $format, 'quality' => $quality]); + + return $image->toFormat($format, $quality)->toString(); + } + + // Use original extension or default to jpg + $extension = $originalExtension ?? 'jpg'; + + switch (strtolower($extension)) { + case 'png': + return $image->toPng()->toString(); + case 'gif': + return $image->toGif()->toString(); + case 'webp': + return $image->toWebp($quality)->toString(); + case 'avif': + return $image->toAvif($quality)->toString(); + case 'bmp': + return $image->toBitmap()->toString(); + default: + return $image->toJpeg($quality)->toString(); + } + } + + /** + * Get default presets. + */ + protected function getDefaultPresets(): array + { + return [ + 'thumb' => ['width' => 150, 'height' => 150, 'name' => 'Thumbnail'], + 'sm' => ['width' => 320, 'height' => 240, 'name' => 'Small'], + 'md' => ['width' => 640, 'height' => 480, 'name' => 'Medium'], + 'lg' => ['width' => 1024, 'height' => 768, 'name' => 'Large'], + 'xl' => ['width' => 1920, 'height' => 1080, 'name' => 'Extra Large'], + '2xl' => ['width' => 2560, 'height' => 1440, 'name' => '2K'], + ]; + } +} diff --git a/src/Services/UserCacheService.php b/src/Services/UserCacheService.php new file mode 100644 index 00000000..90196738 --- /dev/null +++ b/src/Services/UserCacheService.php @@ -0,0 +1,285 @@ + $userId, + 'company_id' => $companyId, + 'cache_key' => $cacheKey, + ]); + } + + return $cached; + } catch (\Exception $e) { + Log::error('Failed to get user cache', [ + 'error' => $e->getMessage(), + 'user_id' => $userId, + 'company_id' => $companyId, + ]); + + return null; + } + } + + /** + * Store user data in cache. + * + * @param int|string $userId + * @param string $companyId + * @param array $data + * @param int|null $ttl + * + * @return bool + */ + public static function put($userId, string $companyId, array $data, ?int $ttl = null): bool + { + $cacheKey = self::getCacheKey($userId, $companyId); + $ttl = $ttl ?? self::CACHE_TTL; + + try { + Cache::put($cacheKey, $data, $ttl); + + Log::debug('User cache stored', [ + 'user_id' => $userId, + 'company_id' => $companyId, + 'cache_key' => $cacheKey, + 'ttl' => $ttl, + ]); + + return true; + } catch (\Exception $e) { + Log::error('Failed to store user cache', [ + 'error' => $e->getMessage(), + 'user_id' => $userId, + 'company_id' => $companyId, + ]); + + return false; + } + } + + /** + * Invalidate cache for a specific user. + * + * @param User $user + * + * @return void + */ + public static function invalidateUser(User $user): void + { + try { + // Get all companies the user belongs to + $companies = $user->companies()->pluck('companies.uuid')->toArray(); + + // Clear cache for each company + foreach ($companies as $companyId) { + $cacheKey = self::getCacheKey($user->id, $companyId); + Cache::forget($cacheKey); + + Log::debug('User cache invalidated', [ + 'user_id' => $user->id, + 'company_id' => $companyId, + 'cache_key' => $cacheKey, + ]); + } + + // Also clear for current session company if different + $sessionCompany = session('company'); + if ($sessionCompany && !in_array($sessionCompany, $companies)) { + $cacheKey = self::getCacheKey($user->id, $sessionCompany); + Cache::forget($cacheKey); + + Log::debug('User cache invalidated for session company', [ + 'user_id' => $user->id, + 'company_id' => $sessionCompany, + 'cache_key' => $cacheKey, + ]); + } + } catch (\Exception $e) { + Log::error('Failed to invalidate user cache', [ + 'error' => $e->getMessage(), + 'user_id' => $user->id, + ]); + } + } + + /** + * Invalidate cache for a specific user and company. + * + * @param int|string $userId + * @param string $companyId + * + * @return void + */ + public static function invalidate($userId, string $companyId): void + { + $cacheKey = self::getCacheKey($userId, $companyId); + + try { + Cache::forget($cacheKey); + + Log::debug('User cache invalidated', [ + 'user_id' => $userId, + 'company_id' => $companyId, + 'cache_key' => $cacheKey, + ]); + } catch (\Exception $e) { + Log::error('Failed to invalidate user cache', [ + 'error' => $e->getMessage(), + 'user_id' => $userId, + 'company_id' => $companyId, + ]); + } + } + + /** + * Invalidate all cache for a company. + * + * @param string $companyId + * + * @return void + */ + public static function invalidateCompany(string $companyId): void + { + try { + // Get all cache keys for this company + $pattern = self::CACHE_PREFIX . '*:' . $companyId; + $cacheKeys = Cache::getRedis()->keys($pattern); + + foreach ($cacheKeys as $key) { + // Remove the Redis prefix if present + $key = str_replace(config('database.redis.options.prefix', ''), '', $key); + Cache::forget($key); + } + + Log::debug('Company user cache invalidated', [ + 'company_id' => $companyId, + 'keys_count' => count($cacheKeys), + ]); + } catch (\Exception $e) { + Log::error('Failed to invalidate company cache', [ + 'error' => $e->getMessage(), + 'company_id' => $companyId, + ]); + } + } + + /** + * Generate ETag for a user. + * + * @param User $user + * + * @return string + */ + public static function generateETag(User $user): string + { + return '"user-' . $user->uuid . '-' . $user->updated_at->timestamp . '"'; + } + + /** + * Get browser cache TTL. + * + * @return int + */ + public static function getBrowserCacheTTL(): int + { + return (int) config('fleetbase.user_cache.browser_ttl', self::BROWSER_CACHE_TTL); + } + + /** + * Get server cache TTL. + * + * @return int + */ + public static function getServerCacheTTL(): int + { + return (int) config('fleetbase.user_cache.server_ttl', self::CACHE_TTL); + } + + /** + * Check if caching is enabled. + * + * @return bool + */ + public static function isEnabled(): bool + { + return (bool) config('fleetbase.user_cache.enabled', true); + } + + /** + * Clear all user current caches. + * + * @return void + */ + public static function flush(): void + { + try { + $pattern = self::CACHE_PREFIX . '*'; + $cacheKeys = Cache::getRedis()->keys($pattern); + + foreach ($cacheKeys as $key) { + // Remove the Redis prefix if present + $key = str_replace(config('database.redis.options.prefix', ''), '', $key); + Cache::forget($key); + } + + Log::info('All user current cache flushed', [ + 'keys_count' => count($cacheKeys), + ]); + } catch (\Exception $e) { + Log::error('Failed to flush user cache', [ + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/src/Support/ApiModelCache.php b/src/Support/ApiModelCache.php new file mode 100644 index 00000000..c9e4ef77 --- /dev/null +++ b/src/Support/ApiModelCache.php @@ -0,0 +1,510 @@ +getTable(); + $companyUuid = static::getCompanyUuid($request); + + // Get all relevant query parameters + $params = [ + 'limit' => $request->input('limit'), + 'offset' => $request->input('offset'), + 'page' => $request->input('page'), + 'sort' => $request->input('sort'), + 'order' => $request->input('order'), + 'query' => $request->input('query'), + 'search' => $request->input('search'), + 'filter' => $request->input('filter'), + 'with' => $request->input('with'), + 'expand' => $request->input('expand'), + 'columns' => $request->input('columns'), + ]; + + // Merge additional parameters + $params = array_merge($params, $additionalParams); + + // Remove null values and sort for consistent keys + $params = array_filter($params, fn ($value) => $value !== null); + ksort($params); + + // Generate hash of parameters + $paramsHash = md5(json_encode($params)); + + // Include company UUID for multi-tenancy + $companyPart = $companyUuid ? "company_{$companyUuid}" : 'no_company'; + + // Get query version for this table/company + // This ensures cache keys change after invalidation + $versionKey = "api_query_version:{$table}:{$companyUuid}"; + $version = Cache::get($versionKey, 1); // Default to version 1 + + // Add Redis hash tag {api_query} for Redis Cluster shard routing + // Include version to ensure cache keys change after writes + return "{api_query}:{$table}:{$companyPart}:v{$version}:{$paramsHash}"; + } + + /** + * Generate a cache key for a single model instance. + * + * @param string|int $id + */ + public static function generateModelCacheKey(Model $model, $id, array $with = []): string + { + $table = $model->getTable(); + $withHash = !empty($with) ? ':' . md5(json_encode($with)) : ''; + + // Add Redis hash tag {api_model} for Redis Cluster shard routing + return "{api_model}:{$table}:{$id}{$withHash}"; + } + + /** + * Generate a cache key for a relationship. + */ + public static function generateRelationshipCacheKey(Model $model, string $relationshipName): string + { + $table = $model->getTable(); + $id = $model->getKey(); + + // Add Redis hash tag {api_relation} for Redis Cluster shard routing + return "{api_relation}:{$table}:{$id}:{$relationshipName}"; + } + + /** + * Generate cache tags for a model. + */ + public static function generateCacheTags(Model $model, ?string $companyUuid = null, bool $includeQueryTag = false): array + { + $table = $model->getTable(); + + $tags = [ + 'api_cache', + "api_model:{$table}", + ]; + + // Add query-specific tag for collection/list caches + // This allows query caches to be invalidated separately from model caches + if ($includeQueryTag) { + $tags[] = "api_query:{$table}"; + } + + if ($companyUuid) { + $tags[] = "company:{$companyUuid}"; + } + + return $tags; + } + + /** + * Cache a query result. + */ + public static function cacheQueryResult(Model $model, Request $request, \Closure $callback, array $additionalParams = [], ?int $ttl = null) + { + // Check if caching is enabled + if (!static::isCachingEnabled()) { + $result = $callback(); + + return $result ?? collect([]); // Guard against null + } + + $cacheKey = static::generateQueryCacheKey($model, $request, $additionalParams); + $companyUuid = static::getCompanyUuid($request); + $tags = static::generateCacheTags($model, $companyUuid, true); // Include query tag + $ttl = $ttl ?? static::LIST_TTL; + + // FIX #3: Guard against read-after-invalidate in same request + // After invalidation, resetCacheStatus() sets $cacheStatus to null + // But we need a way to detect if invalidation happened in this request + // For now, we'll rely on tag flush working correctly + + try { + // Initialize cache status and key + static::$cacheStatus = null; + static::$cacheKey = $cacheKey; + + // Use atomic lock to prevent cache stampede + $lock = Cache::lock("lock:{$cacheKey}", 10); + $result = $lock->block(10, function () use ($tags, $cacheKey, $ttl, $callback) { + // Inside the lock, use remember() to check if cache exists + return Cache::tags($tags)->remember($cacheKey, $ttl, function () use ($callback, $cacheKey) { + // Callback only runs if cache is empty/expired + static::$cacheStatus = 'MISS'; + static::$cacheKey = $cacheKey; + + return $callback(); + }); + }); + + // If block() timed out (couldn't get lock in 10 seconds), result is null + // This is rare, but we need graceful fallback + if ($result === null) { + // Try to read from cache (might have been populated by another process) + $cached = Cache::tags($tags)->get($cacheKey); + if ($cached !== null) { + // Cache hit from fallback + $result = $cached; + static::$cacheStatus = 'HIT'; + } else { + // Cache miss - execute callback + $result = $callback(); + static::$cacheStatus = 'MISS'; + } + } + + // Default to HIT if MISS was never set (normal path through remember()) + static::$cacheStatus ??= 'HIT'; + + // FINAL GUARD: Ensure we never return null/false + return $result ?? collect([]); + } catch (\Exception $e) { + Log::warning('Cache error, falling back to direct query', [ + 'key' => $cacheKey, + 'error' => $e->getMessage(), + ]); + + // Exception means cache failed, so this is a MISS + static::$cacheStatus = 'MISS'; + $result = $callback(); + + // Guard against callback returning null/false + return $result ?? collect([]); + } + } + + /** + * Cache a model instance. + * + * @param string|int $id + */ + public static function cacheModel(Model $model, $id, \Closure $callback, array $with = [], ?int $ttl = null) + { + if (!static::isCachingEnabled()) { + return $callback(); + } + + $cacheKey = static::generateModelCacheKey($model, $id, $with); + $tags = static::generateCacheTags($model); + $ttl = $ttl ?? static::MODEL_TTL; + + try { + $isCached = Cache::tags($tags)->has($cacheKey); + + $result = Cache::tags($tags)->remember($cacheKey, $ttl, function () use ($callback, $cacheKey) { + static::$cacheStatus = 'MISS'; + static::$cacheKey = $cacheKey; + + return $callback(); + }); + + if ($isCached) { + static::$cacheStatus = 'HIT'; + static::$cacheKey = $cacheKey; + } + + return $result; + } catch (\Exception $e) { + Log::warning('Cache error, falling back to direct query', [ + 'key' => $cacheKey, + 'error' => $e->getMessage(), + ]); + static::$cacheStatus = 'ERROR'; + static::$cacheKey = $cacheKey; + + return $callback(); + } + } + + /** + * Cache a relationship result. + */ + public static function cacheRelationship(Model $model, string $relationshipName, \Closure $callback, ?int $ttl = null) + { + if (!static::isCachingEnabled()) { + return $callback(); + } + + $cacheKey = static::generateRelationshipCacheKey($model, $relationshipName); + $tags = static::generateCacheTags($model); + $ttl = $ttl ?? static::RELATIONSHIP_TTL; + + try { + $isCached = Cache::tags($tags)->has($cacheKey); + + $result = Cache::tags($tags)->remember($cacheKey, $ttl, function () use ($callback, $cacheKey) { + static::$cacheStatus = 'MISS'; + static::$cacheKey = $cacheKey; + + return $callback(); + }); + + if ($isCached) { + static::$cacheStatus = 'HIT'; + static::$cacheKey = $cacheKey; + } + + return $result; + } catch (\Exception $e) { + Log::warning('Cache error, falling back to direct query', [ + 'key' => $cacheKey, + 'error' => $e->getMessage(), + ]); + static::$cacheStatus = 'ERROR'; + static::$cacheKey = $cacheKey; + + return $callback(); + } + } + + /** + * Invalidate all caches for a model. + */ + public static function invalidateModelCache(Model $model, ?string $companyUuid = null): void + { + if (!static::isCachingEnabled()) { + return; + } + + // FIX #1: Reset request-level cache state + // This prevents Laravel from serving stale data from request memory + // after invalidation within the same request lifecycle + static::resetCacheStatus(); + + // FIX #4: Increment query version counter + // This ensures the cache key changes after invalidation + $table = $model->getTable(); + $versionKey = "api_query_version:{$table}:{$companyUuid}"; + Cache::increment($versionKey); + + // Generate tags for BOTH model and query caches + // Model caches: single-record lookups + // Query caches: collection/list endpoints + $modelTags = static::generateCacheTags($model, $companyUuid, false); + $queryTags = static::generateCacheTags($model, $companyUuid, true); + + try { + // Flush model-level caches (single records, relationships) + Cache::tags($modelTags)->flush(); + + // Flush query-level caches (collections, lists) + // This is CRITICAL - query caches must be explicitly flushed + Cache::tags($queryTags)->flush(); + } catch (\Exception $e) { + Log::error('Failed to invalidate cache', [ + 'model' => get_class($model), + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + } + } + + /** + * Invalidate cache for a specific query. + */ + public static function invalidateQueryCache(Model $model, Request $request, array $additionalParams = []): void + { + if (!static::isCachingEnabled()) { + return; + } + + $cacheKey = static::generateQueryCacheKey($model, $request, $additionalParams); + $companyUuid = static::getCompanyUuid($request); + $tags = static::generateCacheTags($model, $companyUuid, true); // Include query tag + + try { + Cache::tags($tags)->forget($cacheKey); + } catch (\Exception $e) { + Log::error('Failed to invalidate query cache', [ + 'key' => $cacheKey, + 'error' => $e->getMessage(), + ]); + } + } + + /** + * Invalidate all caches for a company. + */ + public static function invalidateCompanyCache(string $companyUuid): void + { + if (!static::isCachingEnabled()) { + return; + } + + try { + Cache::tags(["company:{$companyUuid}"])->flush(); + } catch (\Exception $e) { + Log::error('Failed to invalidate company cache', [ + 'company_uuid' => $companyUuid, + 'error' => $e->getMessage(), + ]); + } + } + + // flushRedisCacheByPattern() method removed - not safe for Redis Cluster + // Cache invalidation is now handled solely through Cache::tags()->flush() + + /** + * Check if caching is enabled. + */ + public static function isCachingEnabled(): bool + { + return config('api.cache.enabled', true); + } + + /** + * Get the cache TTL for query results. + */ + public static function getQueryTtl(): int + { + return config('api.cache.ttl.query', static::LIST_TTL); + } + + /** + * Get the cache TTL for model instances. + */ + public static function getModelTtl(): int + { + return config('api.cache.ttl.model', static::MODEL_TTL); + } + + /** + * Get the cache TTL for relationships. + */ + public static function getRelationshipTtl(): int + { + return config('api.cache.ttl.relationship', static::RELATIONSHIP_TTL); + } + + /** + * Extract company UUID from request. + */ + protected static function getCompanyUuid(Request $request): ?string + { + // Try to get from session + if ($request->session()->has('company')) { + return $request->session()->get('company'); + } + + // Try to get from authenticated user + $user = $request->user(); + if ($user && method_exists($user, 'company_uuid')) { + return $user->company_uuid; + } + + // Try to get from request input + return $request->input('company_uuid'); + } + + /** + * Warm up cache for a model. + */ + public static function warmCache(Model $model, Request $request, \Closure $callback): void + { + if (!static::isCachingEnabled()) { + return; + } + + try { + static::cacheQueryResult($model, $request, $callback); + } catch (\Exception $e) { + Log::error('Failed to warm up cache', [ + 'model' => get_class($model), + 'error' => $e->getMessage(), + ]); + } + } + + /** + * Get cache statistics. + */ + public static function getStats(): array + { + return [ + 'enabled' => static::isCachingEnabled(), + 'driver' => config('cache.default'), + 'ttl' => [ + 'query' => static::getQueryTtl(), + 'model' => static::getModelTtl(), + 'relationship' => static::getRelationshipTtl(), + ], + ]; + } + + /** + * Get cache status for current request. + * + * @return string|null 'HIT', 'MISS', 'ERROR', or null + */ + public static function getCacheStatus(): ?string + { + return static::$cacheStatus; + } + + /** + * Get cache key for current request. + */ + public static function getCacheKey(): ?string + { + return static::$cacheKey; + } + + /** + * Reset cache status (for testing). + */ + public static function resetCacheStatus(): void + { + static::$cacheStatus = null; + static::$cacheKey = null; + } +} diff --git a/src/Support/QueryOptimizer.php b/src/Support/QueryOptimizer.php index f051e57b..bef6b1c1 100644 --- a/src/Support/QueryOptimizer.php +++ b/src/Support/QueryOptimizer.php @@ -3,219 +3,355 @@ namespace Fleetbase\Support; use Fleetbase\LaravelMysqlSpatial\Eloquent\Builder as SpatialQueryBuilder; +use Illuminate\Database\Eloquent\Builder as EloquentBuilder; use Illuminate\Database\Query\Builder; use Illuminate\Database\Query\Expression; +use Illuminate\Support\Facades\Log; /** - * @TODO complete optimizer to remove duplicate where and joins from query builder. + * QueryOptimizer - Optimizes Eloquent query builders by removing duplicate where clauses. + * + * This implementation uses a robust approach that: + * - Tracks bindings alongside their where clauses + * - Safely handles all where clause types + * - Validates binding integrity before and after optimization + * - Falls back gracefully if optimization would break the query */ class QueryOptimizer { /** * Removes duplicate where clauses from the query builder while correctly handling bindings. * - * This method ensures that duplicate where clauses are removed from the query builder. - * It also correctly manages the bindings, particularly for nested queries and special clauses - * like 'Exists' and 'NotExists', by tracking the index of each binding and associating it - * with its respective where clause. Special attention is given to clauses that involve - * multiple values, such as 'In', 'NotIn', and 'Between', ensuring that each value is - * correctly processed. + * This method ensures that duplicate where clauses are removed from the query builder + * while maintaining the correct bindings. It uses a safe approach that validates + * binding integrity and falls back to the original query if optimization would break it. * - * @param Builder $query the query builder instance to optimize + * @param SpatialQueryBuilder|Builder $query the query builder instance to optimize * - * @return Builder the optimized query builder with unique where clauses + * @return SpatialQueryBuilder|Builder the optimized query builder with unique where clauses */ - public static function removeDuplicateWheres(SpatialQueryBuilder|Builder $query): SpatialQueryBuilder|Builder + public static function removeDuplicateWheres(SpatialQueryBuilder|EloquentBuilder|Builder $query): SpatialQueryBuilder|EloquentBuilder|Builder { - $wheres = $query->getQuery()->wheres; + try { + $baseQuery = $query->getQuery(); + $wheres = $baseQuery->wheres; + $bindings = $baseQuery->bindings['where'] ?? []; - // Track bindings separately to ensure they are not lost or mismatched - $bindings = $query->getQuery()->bindings['where']; - $uniqueBindings = []; - $processedBindings = []; - $index = 0; + // If no wheres or bindings, nothing to optimize + if (empty($wheres)) { + return $query; + } - // dump($wheres, $bindings); + // Build a list of where clauses with their associated bindings + $whereClauses = static::buildWhereClauseList($wheres, $bindings); - // Filter out duplicate where clauses - $uniqueWheres = collect($wheres)->unique(function ($where, $key) use (&$bindings, &$uniqueBindings, &$processedBindings, &$index) { - $normalized = static::normalizeWhereClause($where); - $decoded = static::decodeNormalized($normalized); + // Remove duplicates while preserving bindings + $uniqueClauses = static::removeDuplicates($whereClauses); - // Handle 'Exists' and 'NotExists' clauses by returning them as unique without duplication - if ($decoded['type'] === 'Exists' || $decoded['type'] === 'NotExists') { - // Check if has no wheres with values - $containsWhereWithValue = collect($decoded['wheres'])->contains(function ($decodedWhere) { - return isset($decodedWhere['value']) || isset($decodedWhere['values']); - }); + // Extract unique wheres and bindings + $uniqueWheres = array_column($uniqueClauses, 'where'); + $uniqueBindings = static::extractBindings($uniqueClauses); - if (!$containsWhereWithValue) { - $index++; + // Validate that we haven't broken anything + if (!static::validateOptimization($wheres, $bindings, $uniqueWheres, $uniqueBindings)) { + // If validation fails, return original query unchanged + Log::warning('QueryOptimizer: Validation failed, returning original query'); - return $normalized; - } + return $query; } - // Has nested where values - $isNested = in_array($decoded['type'], ['Exists', 'NotExists', 'Nested']); - - // Check if this normalized clause already exists - if (!isset($uniqueBindings[$normalized])) { - // If nested, ensure bindings remain for each nested clause - if ($isNested && is_array($decoded['wheres'])) { - foreach ($decoded['wheres'] as $i => $decodedWhere) { - $doesntHaveValue = !isset($decodedWhere['value']) && !isset($decodedWhere['values']); - if ($doesntHaveValue) { - continue; - } - - // If values store the $decodedWhere for each value - if (isset($decodedWhere['values'])) { - $decodedWhereValues = json_decode($decodedWhere['values']); - foreach ($decodedWhereValues as $decodedWhereValue) { - $decodedWhereKey = [...$decodedWhere, '_value' => $bindings[$index]]; - $uniqueBindings[json_encode($decodedWhereKey)] = $bindings[$index] ?? null; - $processedBindings[] = $bindings[$index] ?? null; - $index++; - } - continue; - } - - $uniqueBindings[json_encode($decodedWhere)] = $bindings[$index] ?? null; - $processedBindings[] = $bindings[$index] ?? null; - $index++; - } - } else { - // If it's unique, save the binding and mark it as processed - $uniqueBindings[$normalized] = $bindings[$index] ?? null; - $processedBindings[] = $bindings[$index] ?? null; - $index++; + // Apply the optimized wheres and bindings + $baseQuery->wheres = $uniqueWheres; + $baseQuery->bindings['where'] = $uniqueBindings; + + return $query; + } catch (\Exception $e) { + // If anything goes wrong, log and return original query + Log::error('QueryOptimizer: Exception during optimization', [ + 'message' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + return $query; + } + } + + /** + * Builds a list of where clauses with their associated bindings. + * + * This method iterates through the where clauses and associates each one + * with its corresponding bindings from the bindings array. + * + * @param array $wheres the where clauses from the query + * @param array $bindings the bindings from the query + * + * @return array an array of ['where' => $where, 'bindings' => [...], 'signature' => '...'] + */ + protected static function buildWhereClauseList(array $wheres, array $bindings): array + { + $whereClauses = []; + $bindingIndex = 0; + + foreach ($wheres as $where) { + $clauseBindings = []; + $bindingCount = static::getBindingCount($where); + + // Extract the bindings for this where clause + for ($i = 0; $i < $bindingCount; $i++) { + if (isset($bindings[$bindingIndex])) { + $clauseBindings[] = $bindings[$bindingIndex]; } + $bindingIndex++; } - return $normalized; - })->values()->all(); + // Create a unique signature for this where clause + $signature = static::createWhereSignature($where, $clauseBindings); + + $whereClauses[] = [ + 'where' => $where, + 'bindings' => $clauseBindings, + 'signature' => $signature, + ]; + } + + return $whereClauses; + } + + /** + * Determines how many bindings a where clause requires. + * + * @param array $where the where clause + * + * @return int the number of bindings required + */ + protected static function getBindingCount(array $where): int + { + $type = $where['type'] ?? 'Basic'; + + switch ($type) { + case 'Null': + case 'NotNull': + case 'Raw': + // These types don't use bindings (Raw might, but it's handled separately) + return 0; - // Get unique bindings - $uniqueBindings = array_filter(array_values($uniqueBindings)); + case 'In': + case 'NotIn': + // Count the number of values + return count($where['values'] ?? []); - // dd($uniqueWheres, $uniqueBindings); + case 'Between': + case 'NotBetween': + // Between uses 2 bindings + return 2; - // Reset the original wheres and replace them with the unique ones - $query->getQuery()->wheres = $uniqueWheres; + case 'Nested': + // Nested queries have their own bindings + if (isset($where['query']) && $where['query'] instanceof Builder) { + return count($where['query']->bindings['where'] ?? []); + } - // Replace the bindings with the unique ones - $query->getQuery()->bindings['where'] = $uniqueBindings; + return 0; - return $query; + case 'Exists': + case 'NotExists': + // Exists queries have their own bindings + if (isset($where['query']) && $where['query'] instanceof Builder) { + return count($where['query']->bindings['where'] ?? []); + } + + return 0; + + case 'Basic': + default: + // Basic where clauses use 1 binding (unless value is an Expression) + if (isset($where['value']) && $where['value'] instanceof Expression) { + return 0; + } + + return 1; + } } /** - * Normalizes a where clause to create a unique key for comparison. + * Creates a unique signature for a where clause including its bindings. * - * This method converts a where clause into a JSON string that serves as a unique identifier. - * It handles various types of where clauses, including nested queries, 'Exists', 'NotExists', - * and others, ensuring that each clause can be uniquely identified and compared. + * This signature is used to identify duplicate where clauses. * - * @param array $where the where clause to normalize + * @param array $where the where clause + * @param array $bindings the bindings for this where clause * - * @return string a JSON-encoded string that uniquely represents the where clause + * @return string a unique signature */ - protected static function normalizeWhereClause(array $where): string + protected static function createWhereSignature(array $where, array $bindings): string { - switch ($where['type']) { - case 'Nested': - // Recursively normalize the nested query - $nestedWheres = collect($where['query']->wheres)->map(function ($nestedWhere) { - return static::normalizeWhereClause($nestedWhere); - })->all(); + $type = $where['type'] ?? 'Basic'; - return json_encode([ - 'type' => $where['type'], - 'wheres' => $nestedWheres, - 'boolean' => $where['boolean'], - ]); + $signatureData = [ + 'type' => $type, + 'boolean' => $where['boolean'] ?? 'and', + ]; + switch ($type) { case 'Basic': - return json_encode([ - 'type' => $where['type'], - 'column' => $where['column'] ?? '', - 'operator'=> $where['operator'] ?? '=', - 'value' => $where['value'] instanceof Expression ? (string) $where['value'] : json_encode($where['value']), - 'boolean' => $where['boolean'] ?? 'and', - ]); + $signatureData['column'] = $where['column'] ?? ''; + $signatureData['operator'] = $where['operator'] ?? '='; + if ($where['value'] instanceof Expression) { + $signatureData['value'] = (string) $where['value']; + } else { + $signatureData['bindings'] = $bindings; + } + break; case 'In': case 'NotIn': - return json_encode([ - 'type' => $where['type'], - 'column' => $where['column'] ?? '', - 'values' => json_encode($where['values'] ?? []), - 'boolean' => $where['boolean'] ?? 'and', - ]); + $signatureData['column'] = $where['column'] ?? ''; + $signatureData['bindings'] = $bindings; + break; case 'Null': case 'NotNull': - return json_encode([ - 'type' => $where['type'], - 'column' => $where['column'] ?? '', - 'boolean' => $where['boolean'] ?? 'and', - ]); + $signatureData['column'] = $where['column'] ?? ''; + break; case 'Between': - return json_encode([ - 'type' => $where['type'], - 'column' => $where['column'] ?? '', - 'values' => json_encode($where['values'] ?? []), - 'boolean' => $where['boolean'] ?? 'and', - ]); + case 'NotBetween': + $signatureData['column'] = $where['column'] ?? ''; + $signatureData['bindings'] = $bindings; + break; + case 'Nested': case 'Exists': case 'NotExists': - // Recursively normalize the nested subquery within Exists/NotExists clauses - $subqueryWheres = collect($where['query']->wheres)->map(function ($subWhere) { - return static::normalizeWhereClause($subWhere); - })->all(); - - return json_encode([ - 'type' => $where['type'], - 'wheres' => $subqueryWheres, - 'boolean' => $where['boolean'] ?? 'and', - ]); + // For nested queries, include the nested where structure + if (isset($where['query']) && $where['query'] instanceof Builder) { + $nestedWheres = $where['query']->wheres ?? []; + $signatureData['nested'] = array_map(function ($nestedWhere) { + return static::normalizeWhereForSignature($nestedWhere); + }, $nestedWheres); + $signatureData['bindings'] = $bindings; + } + break; case 'Raw': - return json_encode([ - 'type' => $where['type'], - 'sql' => $where['sql'] ?? '', - 'boolean' => $where['boolean'] ?? 'and', - ]); + $signatureData['sql'] = $where['sql'] ?? ''; + break; default: - // Handle any other types of where clauses if necessary - return json_encode($where); + // For unknown types, include the entire where clause + $signatureData['where'] = $where; + $signatureData['bindings'] = $bindings; } + + return json_encode($signatureData); } /** - * Decodes a normalized where clause back into an array. + * Normalizes a where clause for signature creation. * - * This method takes a JSON-encoded where clause string and decodes it back into - * an associative array. It also handles decoding nested where clauses, ensuring - * that the structure is preserved for further processing. + * @param array $where the where clause to normalize * - * @param string $normalized the JSON-encoded where clause + * @return array the normalized where clause + */ + protected static function normalizeWhereForSignature(array $where): array + { + return [ + 'type' => $where['type'] ?? 'Basic', + 'column' => $where['column'] ?? null, + 'operator' => $where['operator'] ?? null, + 'boolean' => $where['boolean'] ?? 'and', + ]; + } + + /** + * Removes duplicate where clauses based on their signatures. + * + * @param array $whereClauses the list of where clauses with signatures * - * @return array the decoded where clause as an associative array + * @return array the unique where clauses */ - protected static function decodeNormalized(string $normalized): array + protected static function removeDuplicates(array $whereClauses): array { - $decoded = json_decode($normalized, true); - if (isset($decoded['wheres']) && is_array($decoded['wheres'])) { - $decoded['wheres'] = array_map(function ($whereJson) { - return json_decode($whereJson, true); - }, $decoded['wheres']); + $seen = []; + $unique = []; + + foreach ($whereClauses as $clause) { + $signature = $clause['signature']; + + if (!isset($seen[$signature])) { + $seen[$signature] = true; + $unique[] = $clause; + } + } + + return $unique; + } + + /** + * Extracts bindings from the unique where clauses. + * + * @param array $whereClauses the unique where clauses + * + * @return array the flattened bindings array + */ + protected static function extractBindings(array $whereClauses): array + { + $bindings = []; + + foreach ($whereClauses as $clause) { + foreach ($clause['bindings'] as $binding) { + $bindings[] = $binding; + } + } + + return $bindings; + } + + /** + * Validates that the optimization hasn't broken the query. + * + * This method performs basic sanity checks to ensure the optimized query + * is still valid. + * + * @param array $originalWheres the original where clauses + * @param array $originalBindings the original bindings + * @param array $uniqueWheres the optimized where clauses + * @param array $uniqueBindings the optimized bindings + * + * @return bool true if validation passes, false otherwise + */ + protected static function validateOptimization( + array $originalWheres, + array $originalBindings, + array $uniqueWheres, + array $uniqueBindings, + ): bool { + // The unique wheres should not be more than the original + if (count($uniqueWheres) > count($originalWheres)) { + return false; + } + + // The unique bindings should not be more than the original + if (count($uniqueBindings) > count($originalBindings)) { + return false; + } + + // If we removed all wheres, something went wrong + if (count($originalWheres) > 0 && count($uniqueWheres) === 0) { + return false; + } + + // Calculate expected binding count for unique wheres + $expectedBindingCount = 0; + foreach ($uniqueWheres as $where) { + $expectedBindingCount += static::getBindingCount($where); + } + + // The binding count should match + if ($expectedBindingCount !== count($uniqueBindings)) { + return false; } - return $decoded; + return true; } } diff --git a/src/Support/Utils.php b/src/Support/Utils.php index c09f92c0..e45a6b31 100644 --- a/src/Support/Utils.php +++ b/src/Support/Utils.php @@ -1005,7 +1005,20 @@ public static function notSet($target, $key) */ public static function isPublicId($string) { - return is_string($string) && Str::contains($string, ['_']) && strlen(explode('_', $string)[1]) === 7; + if (!is_string($string) || !Str::contains($string, ['_'])) { + return false; + } + + $parts = explode('_', $string); + if (count($parts) < 2) { + return false; + } + + $hash = $parts[1]; + + // Support both legacy (7 chars) and new (10 chars) public ID formats + // Hash should be alphanumeric and between 7-15 characters for future-proofing + return ctype_alnum($hash) && strlen($hash) >= 7 && strlen($hash) <= 15; } /** @@ -1773,11 +1786,17 @@ public static function chooseQueueConnection() } /** - * Converts a string or class name to an ember resource type \Fleetbase\FleetOps\Models\IntegratedVendor -> integrated-vendor. + * Converts a fully qualified class name to an ember resource type with namespace prefix. + * + * Examples: + * - \Fleetbase\FleetOps\Models\IntegratedVendor -> fleet-ops:integrated-vendor + * - \Fleetbase\Fliit\Models\Client -> fliit:client + * - fliit:client -> fliit:client (already short format, returned as-is) + * - SimpleClass -> simple-class (no namespace, just kebab-case) * - * @param string $className + * @param string $className The fully qualified class name or short type * - * @return string|null + * @return string|null The namespaced type string (package:type) or null if input is invalid */ public static function toEmberResourceType($className) { @@ -1785,10 +1804,34 @@ public static function toEmberResourceType($className) return null; } - $baseClassName = static::classBasename($className); - $emberResourceType = Str::snake($baseClassName, '-'); + // If it's already in short format (contains ':'), return as-is + if (Str::contains($className, ':')) { + return $className; + } + + // If it doesn't contain namespace separator, just convert to kebab-case + if (!Str::contains($className, '\\')) { + return Str::snake($className, '-'); + } + + // Extract package name from namespace + // e.g., "Fleetbase\\Fliit\\Models\\Client" -> "fliit:client" + // e.g., "Fleetbase\\FleetOps\\Models\\Vendor" -> "fleet-ops:vendor" + $parts = explode('\\', $className); + + // Get the base class name + $baseClassName = end($parts); + $baseType = Str::snake($baseClassName, '-'); + + // Get the package name (second part of namespace after Fleetbase) + if (count($parts) >= 3 && $parts[0] === 'Fleetbase') { + $packageName = Str::snake($parts[1], '-'); + + return $packageName . ':' . $baseType; + } - return $emberResourceType; + // Fallback to just the base type + return $baseType; } public static function dateRange($date) diff --git a/src/Traits/HasApiControllerBehavior.php b/src/Traits/HasApiControllerBehavior.php index 8afa87da..e290269a 100644 --- a/src/Traits/HasApiControllerBehavior.php +++ b/src/Traits/HasApiControllerBehavior.php @@ -55,6 +55,14 @@ trait HasApiControllerBehavior */ public $resource; + /** + * The lightweight API Resource for index/list views. + * When set, this resource will be used for queryRecord collections instead of $resource. + * + * @var \Fleetbase\Http\Resources\FleetbaseResource|null + */ + public $indexResource; + /** * The target Service the controller belongs to. * @@ -374,13 +382,16 @@ public function queryRecord(Request $request) return new $this->resource($data); } + // Use indexResource if set, otherwise fall back to resource + $resourceClass = $this->indexResource ?? $this->resource; + if (Http::isInternalRequest($request)) { - $this->resource::wrap($this->resourcePluralName); + $resourceClass::wrap($this->resourcePluralName); - return $this->resource::collection($data); + return $resourceClass::collection($data); } - return $this->resource::collection($data); + return $resourceClass::collection($data); } /** diff --git a/src/Traits/HasApiModelBehavior.php b/src/Traits/HasApiModelBehavior.php index 401ec3c0..1cc9ee56 100644 --- a/src/Traits/HasApiModelBehavior.php +++ b/src/Traits/HasApiModelBehavior.php @@ -18,6 +18,62 @@ */ trait HasApiModelBehavior { + /** + * Boot the HasApiModelBehavior trait. + * + * Registers model event listeners for automatic cache invalidation. + */ + public static function bootHasApiModelBehavior() + { + // Only set up cache invalidation if caching is enabled + if (!config('api.cache.enabled', true)) { + return; + } + + // Invalidate cache when model is created + static::created(function ($model) { + $model->invalidateApiCacheOnChange(); + }); + + // Invalidate cache when model is updated + static::updated(function ($model) { + $model->invalidateApiCacheOnChange(); + }); + + // Invalidate cache when model is deleted + static::deleted(function ($model) { + $model->invalidateApiCacheOnChange(); + }); + + // Invalidate cache when model is restored (soft deletes) + if (method_exists(static::class, 'restored')) { + static::restored(function ($model) { + $model->invalidateApiCacheOnChange(); + }); + } + } + + /** + * Invalidate API cache when model changes. + */ + protected function invalidateApiCacheOnChange(): void + { + if (!config('api.cache.enabled', true)) { + return; + } + + // Get company UUID if available + $companyUuid = null; + if (isset($this->company_uuid)) { + $companyUuid = $this->company_uuid; + } + + // Use ApiModelCache if available + if (class_exists('\Fleetbase\Support\ApiModelCache')) { + \Fleetbase\Support\ApiModelCache::invalidateModelCache($this, $companyUuid); + } + } + /** * The name of the database column used to store the public ID for this model. * @@ -104,6 +160,11 @@ public function searcheableFields() */ public function queryFromRequest(Request $request, ?\Closure $queryCallback = null) { + // Check if model has caching enabled via HasApiModelCache trait + if ($this->shouldUseCache()) { + return $this->queryFromRequestCached($request, $queryCallback); + } + $limit = $request->integer('limit', 30); $columns = $request->input('columns', ['*']); @@ -138,6 +199,28 @@ public function queryFromRequest(Request $request, ?\Closure $queryCallback = nu return static::mutateModelWithRequest($request, $result); } + /** + * Check if this model should use caching. + */ + protected function shouldUseCache(): bool + { + // Check if HasApiModelCache trait is used + $traits = class_uses_recursive(static::class); + $hasCacheTrait = isset($traits['Fleetbase\\Traits\\HasApiModelCache']); + + if (!$hasCacheTrait) { + return false; + } + + // Check if caching is disabled for this specific model + if (property_exists($this, 'disableApiCache') && $this->disableApiCache === true) { + return false; + } + + // Check if API caching is enabled globally + return config('api.cache.enabled', true); + } + /** * Static alias for queryFromRequest(). * @@ -190,11 +273,18 @@ public function createRecordFromRequest($request, ?callable $onBefore = null, ?c return $record; } - $builder = $this->where($this->getQualifiedKeyName(), $record->getKey()); - $builder = $this->withRelationships($request, $builder); - $builder = $this->withCounts($request, $builder); + // PERFORMANCE OPTIMIZATION: Use load() instead of re-querying the database + // This avoids an unnecessary second database query + $with = $request->or(['with', 'expand'], []); + if (!empty($with)) { + $record->load($with); + } - $record = $builder->first(); + // Load counts if requested + $withCount = $request->array('with_count', []); + if (!empty($withCount)) { + $record->loadCount($withCount); + } if (is_callable($onAfter)) { $after = $onAfter($request, $record, $input); @@ -267,20 +357,18 @@ public function updateRecordFromRequest(Request $request, $id, ?callable $onBefo return $record; } - $builder = $this->where( - function ($q) use ($id) { - $publicIdColumn = $this->getQualifiedPublicId(); - - $q->where($this->getQualifiedKeyName(), $id); - if ($this->isColumn($publicIdColumn)) { - $q->orWhere($publicIdColumn, $id); - } - } - ); - $builder = $this->withRelationships($request, $builder); - $builder = $this->withCounts($request, $builder); + // PERFORMANCE OPTIMIZATION: Use load() instead of re-querying the database + // This avoids an unnecessary second database query + $with = $request->or(['with', 'expand'], []); + if (!empty($with)) { + $record->load($with); + } - $record = $builder->first(); + // Load counts if requested + $withCount = $request->array('with_count', []); + if (!empty($withCount)) { + $record->loadCount($withCount); + } if (is_callable($onAfter)) { $after = $onAfter($request, $record, $input); @@ -657,14 +745,48 @@ public function searchRecordFromRequest(Request $request) public function searchBuilder(Request $request, $columns = ['*']) { $builder = self::query()->select($columns); - $builder = $this->buildSearchParams($request, $builder); - $builder = $this->applyFilters($request, $builder); - $builder = $this->applyCustomFilters($request, $builder); - $builder = $this->withRelationships($request, $builder); - $builder = $this->withCounts($request, $builder); - $builder = $this->applySorts($request, $builder); + + // PERFORMANCE OPTIMIZATION: Apply authorization directives FIRST to reduce dataset early $builder = $this->applyDirectivesToQuery($request, $builder); + // CRITICAL: Apply custom filters ALWAYS (handles queryForInternal/queryForPublic) + // This MUST run before the fast path check to ensure data isolation + $builder = $this->applyCustomFilters($request, $builder); + + // PERFORMANCE OPTIMIZATION: Check if this is a simple query (no filters, sorts, or relationships) + // This avoids unnecessary method calls for the most common case + $hasFilters = $request->has('filters') || count($request->except(['limit', 'offset', 'page', 'sort', 'order'])) > 0; + $hasSorts = $request->has('sort') || $request->has('order'); + $hasRelationships = $request->has('with') || $request->has('expand') || $request->has('without'); + $hasCounts = $request->has('with_count'); + + if (!$hasFilters && !$hasSorts && !$hasRelationships && !$hasCounts) { + // Fast path: no additional processing needed (custom filters already applied) + return $builder; + } + + // PERFORMANCE OPTIMIZATION: Only apply optimized filters if there are actual filter parameters + if ($hasFilters) { + $builder = $this->applyOptimizedFilters($request, $builder); + } + + // Only apply sorts if requested + if ($hasSorts) { + $builder = $this->applySorts($request, $builder); + } + + // Only eager-load relationships if requested + if ($hasRelationships) { + $builder = $this->withRelationships($request, $builder); + } + + if ($hasCounts) { + $builder = $this->withCounts($request, $builder); + } + + // PERFORMANCE OPTIMIZATION: Apply query optimizer to remove duplicate where clauses + $builder = $this->optimizeQuery($builder); + return $builder; } @@ -802,6 +924,66 @@ public function applyFilters(Request $request, $builder) return $builder; } + /** + * PERFORMANCE OPTIMIZATION: Optimized filter application that merges buildSearchParams and applyFilters logic. + * This method eliminates redundant iterations and string operations. + * + * Filters are only applied if the column is searchable (defined in searchableFields()). + * Custom filters defined in Filter classes take precedence over automatic filtering. + * + * @param Request $request The request object containing filter parameters + * @param \Illuminate\Database\Eloquent\Builder $builder The search query builder + * + * @return \Illuminate\Database\Eloquent\Builder The search query builder with filters applied + */ + protected function applyOptimizedFilters(Request $request, $builder) + { + // Extract only filter parameters (exclude pagination, sorting, relationships) + $filters = $request->except(['limit', 'offset', 'page', 'sort', 'order', 'with', 'expand', 'without', 'with_count']); + + if (empty($filters)) { + return $builder; + } + + $operators = $this->getQueryOperators(); + $operatorKeys = array_keys($operators); + + foreach ($filters as $key => $value) { + // Skip empty values (but allow '0' and 0) + if (empty($value) && $value !== '0' && $value !== 0) { + continue; + } + + // Skip if a custom filter method exists for this parameter + if ($this->prioritizedCustomColumnFilter($request, $builder, $key)) { + continue; + } + + // Determine the column name and operator type + $column = $key; + $opKey = '='; + $opType = '='; + + // Check if the parameter has an operator suffix (_in, _like, _gt, etc.) + foreach ($operatorKeys as $op_key) { + if (Str::endsWith(strtolower($key), strtolower($op_key))) { + $column = Str::replaceLast($op_key, '', $key); + $opKey = $op_key; + $opType = $operators[$op_key]; + break; + } + } + + // Only apply filters for searchable columns + // searchableFields() includes: fillable + primary key + timestamps + custom searchableColumns + if ($this->isFillable($column) || in_array($column, ['uuid', 'public_id']) || in_array($column, $this->searcheableFields())) { + $builder = $this->applyOperators($builder, $column, $opKey, $opType, $value); + } + } + + return $builder; + } + /** * Counts the records based on the request search parameters. * diff --git a/src/Traits/HasApiModelCache.php b/src/Traits/HasApiModelCache.php new file mode 100644 index 00000000..873113a2 --- /dev/null +++ b/src/Traits/HasApiModelCache.php @@ -0,0 +1,298 @@ +invalidateApiCache(); + }); + + // Invalidate cache when model is updated + static::updated(function ($model) { + $model->invalidateApiCache(); + }); + + // Invalidate cache when model is deleted + static::deleted(function ($model) { + $model->invalidateApiCache(); + }); + + // Invalidate cache when model is restored (soft deletes) + if (method_exists(static::class, 'restored')) { + static::restored(function ($model) { + $model->invalidateApiCache(); + }); + } + } + + /** + * Query from request with caching. + */ + public function queryFromRequestCached(Request $request, ?\Closure $queryCallback = null) + { + // Note: Caching checks are now handled in queryFromRequest() + // This method is called automatically when HasApiModelCache trait is present + + // Generate additional params from query callback + $additionalParams = []; + if ($queryCallback) { + // Mark that a callback is present, but don't hash it + // The callback's effect on results is already captured by request parameters + // Using spl_object_id() would create unique keys per request, preventing cache hits + $additionalParams['has_callback'] = true; + } + + return ApiModelCache::cacheQueryResult( + $this, + $request, + fn () => $this->queryFromRequestWithoutCache($request, $queryCallback), + $additionalParams, + ApiModelCache::getQueryTtl() + ); + } + + /** + * Query from request without caching (internal use). + * + * This method bypasses the cache check to avoid infinite recursion. + */ + protected function queryFromRequestWithoutCache(Request $request, ?\Closure $queryCallback = null) + { + $limit = $request->integer('limit', 30); + $columns = $request->input('columns', ['*']); + + /** + * @var \Illuminate\Database\Eloquent\Builder $builder + */ + $builder = $this->searchBuilder($request, $columns); + + if (intval($limit) > 0) { + $builder->limit($limit); + } elseif ($limit === -1) { + $limit = 999999999; + $builder->limit($limit); + } + + // if queryCallback is supplied + if (is_callable($queryCallback)) { + $queryCallback($builder, $request); + } + + if (\Fleetbase\Support\Http::isInternalRequest($request)) { + return $builder->fastPaginate($limit, $columns); + } + + // get the results + $result = $builder->get($columns); + + // mutate if mutation causing params present + return static::mutateModelWithRequest($request, $result); + } + + /** + * Static alias for queryFromRequestCached(). + */ + public static function queryWithRequestCached(Request $request, ?\Closure $queryCallback = null) + { + return (new static())->queryFromRequestCached($request, $queryCallback); + } + + /** + * Find a model by ID with caching. + * + * @return static|null + */ + public static function findCached($id, array $with = []) + { + if (!ApiModelCache::isCachingEnabled()) { + $model = static::find($id); + if ($model && !empty($with)) { + $model->load($with); + } + + return $model; + } + + return ApiModelCache::cacheModel( + new static(), + $id, + function () use ($id, $with) { + $model = static::find($id); + if ($model && !empty($with)) { + $model->load($with); + } + + return $model; + }, + $with, + ApiModelCache::getModelTtl() + ); + } + + /** + * Find a model by public ID with caching. + * + * @return static|null + */ + public static function findByPublicIdCached(string $publicId, array $with = []) + { + if (!ApiModelCache::isCachingEnabled()) { + $model = static::where('public_id', $publicId)->first(); + if ($model && !empty($with)) { + $model->load($with); + } + + return $model; + } + + return ApiModelCache::cacheModel( + new static(), + "public_id:{$publicId}", + function () use ($publicId, $with) { + $model = static::where('public_id', $publicId)->first(); + if ($model && !empty($with)) { + $model->load($with); + } + + return $model; + }, + $with, + ApiModelCache::getModelTtl() + ); + } + + /** + * Load a relationship with caching. + */ + public function loadCached(string $relationshipName) + { + if (!ApiModelCache::isCachingEnabled()) { + return $this->load($relationshipName); + } + + // Check if relationship is already loaded + if ($this->relationLoaded($relationshipName)) { + return $this; + } + + $cachedRelation = ApiModelCache::cacheRelationship( + $this, + $relationshipName, + fn () => $this->{$relationshipName}, + ApiModelCache::getRelationshipTtl() + ); + + // Set the relationship on the model + $this->setRelation($relationshipName, $cachedRelation); + + return $this; + } + + /** + * Load multiple relationships with caching. + * + * @param array|string $relationships + * + * @return $this + */ + public function loadMultipleCached($relationships) + { + if (!ApiModelCache::isCachingEnabled()) { + return $this->load($relationships); + } + + $relationships = is_string($relationships) ? func_get_args() : $relationships; + + foreach ($relationships as $relationship) { + $this->loadCached($relationship); + } + + return $this; + } + + /** + * Invalidate all caches for this model. + */ + public function invalidateApiCache(): void + { + if (!ApiModelCache::isCachingEnabled()) { + return; + } + + // Get company UUID if available + $companyUuid = null; + if (isset($this->company_uuid)) { + $companyUuid = $this->company_uuid; + } + + ApiModelCache::invalidateModelCache($this, $companyUuid); + } + + /** + * Invalidate cache for a specific query. + */ + public function invalidateQueryCache(Request $request, array $additionalParams = []): void + { + if (!ApiModelCache::isCachingEnabled()) { + return; + } + + ApiModelCache::invalidateQueryCache($this, $request, $additionalParams); + } + + /** + * Warm up cache for common queries. + */ + public static function warmUpCache(Request $request, ?\Closure $queryCallback = null): void + { + if (!ApiModelCache::isCachingEnabled()) { + return; + } + + $model = new static(); + ApiModelCache::warmCache( + $model, + $request, + fn () => $model->queryFromRequest($request, $queryCallback) + ); + } + + /** + * Check if caching is enabled for this model. + */ + public function isCachingEnabled(): bool + { + // Check if model has caching disabled + if (property_exists($this, 'disableApiCache') && $this->disableApiCache === true) { + return false; + } + + return ApiModelCache::isCachingEnabled(); + } + + /** + * Get cache statistics for this model. + */ + public static function getCacheStats(): array + { + return ApiModelCache::getStats(); + } +} diff --git a/src/Traits/HasPublicId.php b/src/Traits/HasPublicId.php index 440b11c6..736ac26f 100644 --- a/src/Traits/HasPublicId.php +++ b/src/Traits/HasPublicId.php @@ -25,33 +25,69 @@ function ($model) { } /** - * Generate a hashid. + * Generate a hashid with maximum uniqueness using cryptographically secure random numbers. * * @return string */ public static function getPublicId() { $sqids = new \Sqids\Sqids(); - $hashid = lcfirst($sqids->encode([time(), rand(), rand()])); - $hashid = substr($hashid, 0, 7); + + // Maximize uniqueness with multiple entropy sources + // CRITICAL: Use random_int() instead of rand() for cryptographic security + $hashid = lcfirst($sqids->encode([ + time(), // Current second + (int) (microtime(true) * 1000000), // Microseconds + getmypid(), // Process ID + random_int(0, PHP_INT_MAX), // Cryptographically secure random + random_int(0, PHP_INT_MAX), // Another secure random + random_int(0, PHP_INT_MAX), // Third secure random + crc32(uniqid('', true)), // Unique ID hash for extra entropy + ])); + + // 10 characters for better collision resistance + // 62^10 = 839 quadrillion combinations + $hashid = substr($hashid, 0, 10); return $hashid; } - public static function generatePublicId(?string $type = null): string + /** + * Generate a unique public ID with robust race condition protection. + * + * @param string|null $type The public ID type prefix + * @param int $attempt Current attempt number (for internal recursion tracking) + * + * @throws \RuntimeException If unable to generate unique ID after max attempts + */ + public static function generatePublicId(?string $type = null, int $attempt = 0): string { + // Prevent infinite loops + if ($attempt > 10) { + throw new \RuntimeException('Failed to generate unique public_id after 10 attempts. This indicates a serious collision issue.'); + } + $model = new static(); if (is_null($type)) { $type = static::getPublicIdType() ?? strtolower(Utils::classBasename($model)); } - $hashid = static::getPublicId(); - $exists = $model->where('public_id', 'like', '%' . $hashid . '%')->withTrashed()->exists(); + + $hashid = static::getPublicId(); + $publicId = $type . '_' . $hashid; + + // Check for existing public_id with exact match + // Use exists() for performance (doesn't load full model) + $exists = $model->where('public_id', $publicId)->withTrashed()->exists(); if ($exists) { - return static::generatePublicId($type); + // Exponential backoff: 2^attempt milliseconds + $backoffMs = pow(2, $attempt); + usleep($backoffMs * 1000); + + return static::generatePublicId($type, $attempt + 1); } - return $type . '_' . $hashid; + return $publicId; } /** diff --git a/test_query_optimizer.php b/test_query_optimizer.php new file mode 100644 index 00000000..0109a587 --- /dev/null +++ b/test_query_optimizer.php @@ -0,0 +1,263 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', +]); +$capsule->setAsGlobal(); +$capsule->bootEloquent(); + +// Create a test table +Capsule::schema()->create('test_table', function ($table) { + $table->increments('id'); + $table->string('name'); + $table->string('email'); + $table->string('status'); + $table->integer('age'); + $table->timestamps(); +}); + +// Define a test model +class TestModel extends Model +{ + protected $table = 'test_table'; + protected $guarded = []; +} + +echo "QueryOptimizer Test Suite\n"; +echo str_repeat("=", 80) . "\n\n"; + +$testsPassed = 0; +$testsFailed = 0; + +/** + * Test 1: Basic duplicate where clauses + */ +echo "Test 1: Basic duplicate where clauses\n"; +try { + $query = TestModel::query() + ->where('name', 'John') + ->where('status', 'active') + ->where('name', 'John'); // Duplicate + + $originalWhereCount = count($query->getQuery()->wheres); + $originalBindingCount = count($query->getQuery()->bindings['where']); + + $optimized = QueryOptimizer::removeDuplicateWheres($query); + + $newWhereCount = count($optimized->getQuery()->wheres); + $newBindingCount = count($optimized->getQuery()->bindings['where']); + + if ($newWhereCount === 2 && $newBindingCount === 2) { + echo "✓ PASSED: Removed 1 duplicate where clause\n"; + echo " Original: {$originalWhereCount} wheres, {$originalBindingCount} bindings\n"; + echo " Optimized: {$newWhereCount} wheres, {$newBindingCount} bindings\n"; + $testsPassed++; + } else { + echo "✗ FAILED: Expected 2 wheres and 2 bindings, got {$newWhereCount} wheres and {$newBindingCount} bindings\n"; + $testsFailed++; + } +} catch (\Exception $e) { + echo "✗ FAILED: Exception - " . $e->getMessage() . "\n"; + $testsFailed++; +} +echo "\n"; + +/** + * Test 2: WhereIn with duplicates + */ +echo "Test 2: WhereIn with duplicates\n"; +try { + $query = TestModel::query() + ->whereIn('status', ['active', 'pending']) + ->where('name', 'John') + ->whereIn('status', ['active', 'pending']); // Duplicate + + $originalWhereCount = count($query->getQuery()->wheres); + $originalBindingCount = count($query->getQuery()->bindings['where']); + + $optimized = QueryOptimizer::removeDuplicateWheres($query); + + $newWhereCount = count($optimized->getQuery()->wheres); + $newBindingCount = count($optimized->getQuery()->bindings['where']); + + if ($newWhereCount === 2 && $newBindingCount === 3) { + echo "✓ PASSED: Removed 1 duplicate whereIn clause\n"; + echo " Original: {$originalWhereCount} wheres, {$originalBindingCount} bindings\n"; + echo " Optimized: {$newWhereCount} wheres, {$newBindingCount} bindings\n"; + $testsPassed++; + } else { + echo "✗ FAILED: Expected 2 wheres and 3 bindings, got {$newWhereCount} wheres and {$newBindingCount} bindings\n"; + $testsFailed++; + } +} catch (\Exception $e) { + echo "✗ FAILED: Exception - " . $e->getMessage() . "\n"; + $testsFailed++; +} +echo "\n"; + +/** + * Test 3: No duplicates (should remain unchanged) + */ +echo "Test 3: No duplicates (should remain unchanged)\n"; +try { + $query = TestModel::query() + ->where('name', 'John') + ->where('status', 'active') + ->where('age', '>', 18); + + $originalWhereCount = count($query->getQuery()->wheres); + $originalBindingCount = count($query->getQuery()->bindings['where']); + + $optimized = QueryOptimizer::removeDuplicateWheres($query); + + $newWhereCount = count($optimized->getQuery()->wheres); + $newBindingCount = count($optimized->getQuery()->bindings['where']); + + if ($newWhereCount === 3 && $newBindingCount === 3) { + echo "✓ PASSED: Query unchanged (no duplicates)\n"; + echo " Original: {$originalWhereCount} wheres, {$originalBindingCount} bindings\n"; + echo " Optimized: {$newWhereCount} wheres, {$newBindingCount} bindings\n"; + $testsPassed++; + } else { + echo "✗ FAILED: Expected 3 wheres and 3 bindings, got {$newWhereCount} wheres and {$newBindingCount} bindings\n"; + $testsFailed++; + } +} catch (\Exception $e) { + echo "✗ FAILED: Exception - " . $e->getMessage() . "\n"; + $testsFailed++; +} +echo "\n"; + +/** + * Test 4: Multiple duplicates + */ +echo "Test 4: Multiple duplicates\n"; +try { + $query = TestModel::query() + ->where('name', 'John') + ->where('status', 'active') + ->where('name', 'John') // Duplicate 1 + ->where('age', '>', 18) + ->where('status', 'active') // Duplicate 2 + ->where('name', 'John'); // Duplicate 3 + + $originalWhereCount = count($query->getQuery()->wheres); + $originalBindingCount = count($query->getQuery()->bindings['where']); + + $optimized = QueryOptimizer::removeDuplicateWheres($query); + + $newWhereCount = count($optimized->getQuery()->wheres); + $newBindingCount = count($optimized->getQuery()->bindings['where']); + + if ($newWhereCount === 3 && $newBindingCount === 3) { + echo "✓ PASSED: Removed 3 duplicate where clauses\n"; + echo " Original: {$originalWhereCount} wheres, {$originalBindingCount} bindings\n"; + echo " Optimized: {$newWhereCount} wheres, {$newBindingCount} bindings\n"; + $testsPassed++; + } else { + echo "✗ FAILED: Expected 3 wheres and 3 bindings, got {$newWhereCount} wheres and {$newBindingCount} bindings\n"; + $testsFailed++; + } +} catch (\Exception $e) { + echo "✗ FAILED: Exception - " . $e->getMessage() . "\n"; + $testsFailed++; +} +echo "\n"; + +/** + * Test 5: Null checks + */ +echo "Test 5: Null checks\n"; +try { + $query = TestModel::query() + ->whereNull('deleted_at') + ->where('status', 'active') + ->whereNull('deleted_at'); // Duplicate + + $originalWhereCount = count($query->getQuery()->wheres); + $originalBindingCount = count($query->getQuery()->bindings['where']); + + $optimized = QueryOptimizer::removeDuplicateWheres($query); + + $newWhereCount = count($optimized->getQuery()->wheres); + $newBindingCount = count($optimized->getQuery()->bindings['where']); + + if ($newWhereCount === 2 && $newBindingCount === 1) { + echo "✓ PASSED: Removed 1 duplicate whereNull clause\n"; + echo " Original: {$originalWhereCount} wheres, {$originalBindingCount} bindings\n"; + echo " Optimized: {$newWhereCount} wheres, {$newBindingCount} bindings\n"; + $testsPassed++; + } else { + echo "✗ FAILED: Expected 2 wheres and 1 binding, got {$newWhereCount} wheres and {$newBindingCount} bindings\n"; + $testsFailed++; + } +} catch (\Exception $e) { + echo "✗ FAILED: Exception - " . $e->getMessage() . "\n"; + $testsFailed++; +} +echo "\n"; + +/** + * Test 6: Between clauses + */ +echo "Test 6: Between clauses\n"; +try { + $query = TestModel::query() + ->whereBetween('age', [18, 65]) + ->where('status', 'active') + ->whereBetween('age', [18, 65]); // Duplicate + + $originalWhereCount = count($query->getQuery()->wheres); + $originalBindingCount = count($query->getQuery()->bindings['where']); + + $optimized = QueryOptimizer::removeDuplicateWheres($query); + + $newWhereCount = count($optimized->getQuery()->wheres); + $newBindingCount = count($optimized->getQuery()->bindings['where']); + + if ($newWhereCount === 2 && $newBindingCount === 3) { + echo "✓ PASSED: Removed 1 duplicate whereBetween clause\n"; + echo " Original: {$originalWhereCount} wheres, {$originalBindingCount} bindings\n"; + echo " Optimized: {$newWhereCount} wheres, {$newBindingCount} bindings\n"; + $testsPassed++; + } else { + echo "✗ FAILED: Expected 2 wheres and 3 bindings, got {$newWhereCount} wheres and {$newBindingCount} bindings\n"; + $testsFailed++; + } +} catch (\Exception $e) { + echo "✗ FAILED: Exception - " . $e->getMessage() . "\n"; + $testsFailed++; +} +echo "\n"; + +// Summary +echo str_repeat("=", 80) . "\n"; +echo "Test Summary\n"; +echo str_repeat("=", 80) . "\n"; +echo "Passed: {$testsPassed}\n"; +echo "Failed: {$testsFailed}\n"; +echo "Total: " . ($testsPassed + $testsFailed) . "\n"; + +if ($testsFailed === 0) { + echo "\n✓ All tests passed!\n"; + exit(0); +} else { + echo "\n✗ Some tests failed.\n"; + exit(1); +}