diff --git a/composer.json b/composer.json index 1700913..3c7e912 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "fleetbase/core-api", - "version": "1.6.30", + "version": "1.6.31", "description": "Core Framework and Resources for Fleetbase API", "keywords": [ "fleetbase", diff --git a/config/sms.php b/config/sms.php new file mode 100644 index 0000000..10aef1e --- /dev/null +++ b/config/sms.php @@ -0,0 +1,72 @@ + env('SMS_DEFAULT_PROVIDER', 'twilio'), + + /* + |-------------------------------------------------------------------------- + | SMS Provider Routing Rules + |-------------------------------------------------------------------------- + | + | Define routing rules based on phone number prefixes. When a phone number + | matches a prefix, it will be routed to the specified provider. + | + | Format: 'prefix' => 'provider' + | + | Example: + | '+976' => 'callpro', // Mongolia numbers route to CallPro + | '+1' => 'twilio', // USA/Canada numbers route to Twilio + | + */ + + 'routing_rules' => [ + '+976' => 'callpro', // Mongolia + ], + + /* + |-------------------------------------------------------------------------- + | Throw On Error + |-------------------------------------------------------------------------- + | + | When set to true, SMS sending failures will throw exceptions. When false, + | errors will be logged but the application will continue execution. + | + | This is useful for non-critical SMS notifications where you don't want + | to interrupt the user flow if SMS delivery fails. + | + */ + + 'throw_on_error' => env('SMS_THROW_ON_ERROR', false), + + /* + |-------------------------------------------------------------------------- + | Provider Configurations + |-------------------------------------------------------------------------- + | + | Here you may configure the SMS providers used by your application. + | Each provider has its own configuration options. + | + */ + + 'providers' => [ + 'twilio' => [ + 'enabled' => env('TWILIO_ENABLED', true), + ], + + 'callpro' => [ + 'enabled' => env('CALLPRO_ENABLED', true), + ], + ], +]; diff --git a/src/Http/Controllers/Internal/v1/AuthController.php b/src/Http/Controllers/Internal/v1/AuthController.php index 1e573c8..0e837a8 100644 --- a/src/Http/Controllers/Internal/v1/AuthController.php +++ b/src/Http/Controllers/Internal/v1/AuthController.php @@ -115,10 +115,11 @@ public function session(Request $request) } $sessionData = [ - 'token' => $request->bearerToken(), - 'user' => $user->uuid, - 'verified' => $user->isVerified(), - 'type' => $user->getType(), + 'token' => $request->bearerToken(), + 'user' => $user->uuid, + 'verified' => $user->isVerified(), + 'type' => $user->getType(), + 'last_modified' => $user->updated_at, ]; if (session()->has('impersonator')) { @@ -132,7 +133,15 @@ 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 + // Generate an etag + $etag = sha1(json_encode($session)); + + return response() + ->json($session) + ->setEtag($etag) + ->setLastModified($session['last_modified']) + ->header('Cache-Control', 'private, no-cache, must-revalidate') + ->header('X-Cache-Hit', 'false'); } /** @@ -661,6 +670,7 @@ public function getUserOrganizations(Request $request) */ $etagPayload = $companies->map(function ($company) { $ownerTimestamp = $company->owner?->updated_at?->timestamp ?? 0; + return $company->uuid . ':' . $company->updated_at->timestamp . ':' . $ownerTimestamp; })->join('|'); diff --git a/src/Http/Controllers/Internal/v1/FileController.php b/src/Http/Controllers/Internal/v1/FileController.php index 6da3ed3..44627d8 100644 --- a/src/Http/Controllers/Internal/v1/FileController.php +++ b/src/Http/Controllers/Internal/v1/FileController.php @@ -312,15 +312,32 @@ public function uploadBase64(UploadBase64FileRequest $request) /** * Handle file download. * - * @return \Illuminate\Http\Response + * Supports both: + * - /download/{id} + * - /download?file={id} + * + * @param string|null $id File UUID from route parameter + * + * @return \Symfony\Component\HttpFoundation\StreamedResponse */ - public function download(?string $id, DownloadFileRequest $request) + public function download(DownloadFileRequest $request, ?string $id = null) { - $disk = $request->input('disk', config('filesystems.default')); - $file = File::where('uuid', $id)->first(); + // Resolve file ID from route or query string + $fileId = $id ?? $request->query('file'); + if (!$fileId) { + abort(400, 'Missing file identifier.'); + } + + $disk = $request->input('disk', config('filesystems.default')); + $file = File::where('uuid', $fileId)->firstOrFail(); + $filename = $file->original_filename ?: basename($file->path); + /** @var \Illuminate\Filesystem\FilesystemAdapter $filesystem */ $filesystem = Storage::disk($disk); - return $filesystem->download($file->path, $file->original_filename); + return $filesystem->download( + $file->path, + $filename + ); } } diff --git a/src/Http/Controllers/Internal/v1/UserController.php b/src/Http/Controllers/Internal/v1/UserController.php index 34887b7..02e1677 100644 --- a/src/Http/Controllers/Internal/v1/UserController.php +++ b/src/Http/Controllers/Internal/v1/UserController.php @@ -23,11 +23,11 @@ use Fleetbase\Models\User; use Fleetbase\Notifications\UserAcceptedCompanyInvite; use Fleetbase\Notifications\UserInvited; +use Fleetbase\Services\UserCacheService; use Fleetbase\Support\Auth; 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; @@ -181,7 +181,7 @@ public function current(Request $request) $etag = UserCacheService::generateETag($user); // Try to get from server cache - $companyId = session('company'); + $companyId = session('company'); $cachedData = UserCacheService::get($user->id, $companyId); if ($cachedData) { @@ -198,7 +198,7 @@ public function current(Request $request) $user->loadCompanyUser(); // Transform to resource - $userData = new $this->resource($user); + $userData = new $this->resource($user); $userArray = $userData->toArray($request); // Store in cache diff --git a/src/Http/Middleware/ValidateETag.php b/src/Http/Middleware/ValidateETag.php index 279a9c1..3c73cf5 100644 --- a/src/Http/Middleware/ValidateETag.php +++ b/src/Http/Middleware/ValidateETag.php @@ -2,7 +2,6 @@ namespace Fleetbase\Http\Middleware; -use Closure; use Illuminate\Http\Request; class ValidateETag @@ -13,13 +12,8 @@ class ValidateETag * This middleware checks if the client sent an If-None-Match header with an ETag. * If the ETag matches the response ETag, it returns a 304 Not Modified response, * allowing the browser to use its cached version. - * - * @param \Illuminate\Http\Request $request - * @param \Closure $next - * - * @return mixed */ - public function handle(Request $request, Closure $next) + public function handle(Request $request, \Closure $next) { // Handle the request and get response $response = $next($request); diff --git a/src/Http/Requests/Internal/DownloadFileRequest.php b/src/Http/Requests/Internal/DownloadFileRequest.php index da98fa1..999bd23 100644 --- a/src/Http/Requests/Internal/DownloadFileRequest.php +++ b/src/Http/Requests/Internal/DownloadFileRequest.php @@ -16,6 +16,20 @@ public function authorize() return $this->session()->has('user'); } + /** + * Prepare the data for validation. + * + * Ensures route parameters are available for validation rules. + */ + protected function prepareForValidation(): void + { + if ($this->route('id')) { + $this->merge([ + 'id' => $this->route('id'), + ]); + } + } + /** * Get the validation rules that apply to the request. * @@ -24,7 +38,9 @@ public function authorize() public function rules() { return [ - 'id' => ['required', 'string', 'exists:files,uuid'], + 'file' => ['required_without:id', 'uuid', 'exists:files,uuid'], + 'id' => ['required_without:file', 'uuid', 'exists:files,uuid'], + 'disk' => ['sometimes', 'string'], ]; } @@ -36,8 +52,20 @@ public function rules() public function messages() { return [ - 'id.required' => 'Please provide a file ID.', + // Missing identifier + 'id.required_without' => 'Please provide a file identifier.', + 'file.required_without' => 'Please provide a file identifier.', + + // Invalid format + 'id.uuid' => 'The file identifier must be a valid UUID.', + 'file.uuid' => 'The file identifier must be a valid UUID.', + + // File not found 'id.exists' => 'The requested file does not exist.', + 'file.exists' => 'The requested file does not exist.', + + // Disk override + 'disk.string' => 'The storage disk must be a valid string.', ]; } } diff --git a/src/Models/VerificationCode.php b/src/Models/VerificationCode.php index a13ab1c..c0d52fb 100644 --- a/src/Models/VerificationCode.php +++ b/src/Models/VerificationCode.php @@ -4,6 +4,7 @@ use Fleetbase\Casts\Json; use Fleetbase\Mail\VerificationMail; +use Fleetbase\Services\SmsService; use Fleetbase\Support\Utils; use Fleetbase\Traits\Expirable; use Fleetbase\Traits\HasMetaAttributes; @@ -180,9 +181,10 @@ public static function generateSmsVerificationFor($subject, $for = 'phone_verifi $message = $messageCallback($verificationCode); } - // Twilio params - $twilioParams = []; + // SMS service options + $smsOptions = []; + // Check for company-specific sender ID $companyUuid = data_get($options, 'company_uuid') ?? session('company') ?? data_get($subject, 'company_uuid'); if ($companyUuid) { $company = Company::select(['uuid', 'options'])->find($companyUuid); @@ -192,13 +194,33 @@ public static function generateSmsVerificationFor($subject, $for = 'phone_verifi $senderId = $company->getOption('alpha_numeric_sender_id'); if ($enabled && !empty($senderId)) { - $twilioParams['from'] = $senderId; + // Alphanumeric sender IDs are Twilio-specific + // Do NOT set in $smsOptions['from'] as it would be passed to all providers + $smsOptions['twilioParams']['from'] = $senderId; } } } + // Allow explicit provider selection + $provider = data_get($options, 'provider'); + + // Send SMS using SmsService with automatic provider routing if ($subject->phone) { - Twilio::message($subject->phone, $message, [], $twilioParams); + try { + $smsService = new SmsService(); + $smsService->send($subject->phone, $message, $smsOptions, $provider); + } catch (\Throwable $e) { + // Log error but don't fail the verification code generation + \Illuminate\Support\Facades\Log::error('Failed to send SMS verification', [ + 'phone' => $subject->phone, + 'error' => $e->getMessage(), + ]); + + // Optionally rethrow based on configuration + if (config('sms.throw_on_error', false)) { + throw $e; + } + } } return $verificationCode; diff --git a/src/Observers/UserObserver.php b/src/Observers/UserObserver.php index 529d01b..820c994 100644 --- a/src/Observers/UserObserver.php +++ b/src/Observers/UserObserver.php @@ -11,10 +11,6 @@ class UserObserver { /** * Handle the User "updated" event. - * - * @param \Fleetbase\Models\User $user - * - * @return void */ public function updated(User $user): void { @@ -46,10 +42,6 @@ public function deleted(User $user) /** * Handle the User "restored" event. - * - * @param \Fleetbase\Models\User $user - * - * @return void */ public function restored(User $user): void { @@ -66,10 +58,6 @@ public function restored(User $user): void * 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 { diff --git a/src/Providers/CoreServiceProvider.php b/src/Providers/CoreServiceProvider.php index 159ddb2..e96895c 100644 --- a/src/Providers/CoreServiceProvider.php +++ b/src/Providers/CoreServiceProvider.php @@ -129,6 +129,7 @@ public function register() $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'); + $this->mergeConfigFrom(__DIR__ . '/../../config/sms.php', 'sms'); // setup report schema registry $this->app->singleton(ReportSchemaRegistry::class, function () { diff --git a/src/Services/CallProSmsService.php b/src/Services/CallProSmsService.php new file mode 100644 index 0000000..113c9c5 --- /dev/null +++ b/src/Services/CallProSmsService.php @@ -0,0 +1,188 @@ +apiKey = config('services.callpromn.api_key', ''); + $this->from = config('services.callpromn.from', ''); + $this->baseUrl = config('services.callpromn.base_url', 'https://api.messagepro.mn'); + + Log::info('CallProSmsService initialized', [ + 'base_url' => $this->baseUrl, + 'from' => $this->from, + ]); + } + + /** + * Send an SMS message (static convenience method). + * + * @param string $to Recipient phone number (8 digits) + * @param string $text Message text (max 160 characters) + * @param string|null $from Optional sender ID (8 characters), defaults to config + * + * @return array Response containing status and message ID + * + * @throws \Exception If API request fails + */ + public static function sendSms(string $to, string $text, ?string $from = null): array + { + $instance = new static(); + + return $instance->send($to, $text, $from); + } + + /** + * Send an SMS message. + * + * @param string $to Recipient phone number (8 digits) + * @param string $text Message text (max 160 characters) + * @param string|null $from Optional sender ID (8 characters), defaults to config + * + * @return array Response containing status and message ID + * + * @throws \Exception If API request fails + */ + public function send(string $to, string $text, ?string $from = null): array + { + $from = $from ?? $this->from; + + // Validate parameters + $this->validateParameters($to, $text, $from); + + try { + Log::info('Sending SMS via CallPro', [ + 'to' => $to, + 'from' => $from, + 'text' => substr($text, 0, 50) . (strlen($text) > 50 ? '...' : ''), + ]); + + $response = Http::withHeaders([ + 'x-api-key' => $this->apiKey, + ])->get("{$this->baseUrl}/send", [ + 'from' => $from, + 'to' => $to, + 'text' => $text, + ]); + + $statusCode = $response->status(); + $body = $response->json(); + + // Handle response based on status code + if ($statusCode === 200) { + Log::info('SMS sent successfully', [ + 'message_id' => $body['Message ID'] ?? null, + 'result' => $body['Result'] ?? null, + ]); + + return [ + 'success' => true, + 'message_id' => $body['Message ID'] ?? null, + 'result' => $body['Result'] ?? 'SUCCESS', + ]; + } + + // Handle error responses + $errorMessage = $this->getErrorMessage($statusCode); + + Log::error('SMS sending failed', [ + 'status_code' => $statusCode, + 'error' => $errorMessage, + 'response' => $body, + ]); + + return [ + 'success' => false, + 'error' => $errorMessage, + 'code' => $statusCode, + ]; + } catch (\Throwable $e) { + Log::error('SMS API request failed', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + throw new \Exception('Failed to send SMS: ' . $e->getMessage(), 0, $e); + } + } + + /** + * Validate SMS parameters. + * + * @throws \InvalidArgumentException If parameters are invalid + */ + protected function validateParameters(string $to, string $text, string $from): void + { + if (empty($this->apiKey)) { + throw new \InvalidArgumentException('CallPro API key is not configured'); + } + + if (strlen($from) !== 8 || !ctype_digit($from)) { + throw new \InvalidArgumentException('Sender ID (from) must be exactly 8 digits'); + } + + if (strlen($to) !== 8) { + throw new \InvalidArgumentException('Recipient phone number (to) must be exactly 8 characters'); + } + + if (strlen($text) > 160) { + throw new \InvalidArgumentException('Message text cannot exceed 160 characters'); + } + + if (empty($text)) { + throw new \InvalidArgumentException('Message text cannot be empty'); + } + } + + /** + * Get error message based on status code. + */ + protected function getErrorMessage(int $statusCode): string + { + return match ($statusCode) { + 402 => 'Invalid request parameters', + 403 => 'Invalid API key (x-api-key)', + 404 => 'Invalid sender ID or recipient phone number format', + 503 => 'API rate limit exceeded (max 5 requests per second)', + default => "API request failed with status code: {$statusCode}", + }; + } + + /** + * Check if the service is configured. + */ + public function isConfigured(): bool + { + return !empty($this->apiKey) && !empty($this->from); + } + + /** + * Get the configured sender ID. + */ + public function getFrom(): string + { + return $this->from; + } + + /** + * Get the API base URL. + */ + public function getBaseUrl(): string + { + return $this->baseUrl; + } +} diff --git a/src/Services/SmsService.php b/src/Services/SmsService.php new file mode 100644 index 0000000..afab319 --- /dev/null +++ b/src/Services/SmsService.php @@ -0,0 +1,319 @@ +defaultProvider = config('sms.default_provider', self::PROVIDER_TWILIO); + + // Load routing rules from config or use defaults + $this->routingRules = config('sms.routing_rules', [ + '+976' => self::PROVIDER_CALLPRO, // Mongolia numbers route to CallPro + ]); + + Log::info('SmsService initialized', [ + 'default_provider' => $this->defaultProvider, + 'routing_rules' => $this->routingRules, + ]); + } + + /** + * Send an SMS message with automatic provider selection. + * + * @param string $to Recipient phone number + * @param string $text Message text + * @param array $options Additional options (from, provider, twilioParams, etc.) + * @param string|null $provider Explicitly specify provider (overrides auto-routing) + * + * @return array Response containing status and provider information + * + * @throws \Exception If SMS sending fails + */ + public function send(string $to, string $text, array $options = [], ?string $provider = null): array + { + // Normalize phone number + $normalizedPhone = $this->normalizePhoneNumber($to); + + // Determine provider + $selectedProvider = $provider ?? $this->determineProvider($normalizedPhone); + + Log::info('Sending SMS', [ + 'to' => $to, + 'provider' => $selectedProvider, + 'text' => substr($text, 0, 50) . (strlen($text) > 50 ? '...' : ''), + ]); + + try { + $result = match ($selectedProvider) { + self::PROVIDER_CALLPRO => $this->sendViaCallPro($normalizedPhone, $text, $options), + self::PROVIDER_TWILIO => $this->sendViaTwilio($normalizedPhone, $text, $options), + default => throw new \InvalidArgumentException("Unsupported SMS provider: {$selectedProvider}"), + }; + + $result['provider'] = $selectedProvider; + + Log::info('SMS sent successfully', [ + 'provider' => $selectedProvider, + 'to' => $to, + ]); + + return $result; + } catch (\Throwable $e) { + Log::error('SMS sending failed', [ + 'provider' => $selectedProvider, + 'to' => $to, + 'error' => $e->getMessage(), + ]); + + throw $e; + } + } + + /** + * Send SMS via CallPro/MessagePro.mn. + * + * @param string $to Recipient phone number + * @param string $text Message text + * @param array $options Additional options + * + * @return array Response from CallPro service + * + * @throws \Exception If CallPro is not configured or sending fails + */ + protected function sendViaCallPro(string $to, string $text, array $options = []): array + { + $callProService = new CallProSmsService(); + + if (!$callProService->isConfigured()) { + Log::warning('CallPro not configured, falling back to Twilio'); + + return $this->sendViaTwilio($to, $text, $options); + } + + // Extract the last 8 digits for CallPro (Mongolia format) + $toNumber = $this->extractCallProNumber($to); + + // CallPro does NOT support alphanumeric sender IDs (Twilio-specific) + // Only pass 'from' if it's a valid 8-digit number, otherwise use CallPro default + $from = data_get($options, 'from'); + if ($from && (strlen($from) !== 8 || !ctype_digit($from))) { + Log::debug('Ignoring Twilio-specific sender ID for CallPro', [ + 'sender_id' => $from, + ]); + $from = null; // Let CallPro use its configured default + } + + return $callProService->send($toNumber, $text, $from); + } + + /** + * Send SMS via Twilio. + * + * @param string $to Recipient phone number + * @param string $text Message text + * @param array $options Additional options (twilioParams, from, etc.) + * + * @return array Response from Twilio service + * + * @throws \Exception If Twilio sending fails + */ + protected function sendViaTwilio(string $to, string $text, array $options = []): array + { + $twilioParams = data_get($options, 'twilioParams', []); + + // Support 'from' in options + if (isset($options['from']) && !isset($twilioParams['from'])) { + $twilioParams['from'] = $options['from']; + } + + try { + $response = Twilio::message($to, $text, [], $twilioParams); + + return [ + 'success' => true, + 'message_id' => $response->sid ?? null, + 'result' => 'SUCCESS', + 'response' => $response, + ]; + } catch (\Throwable $e) { + return [ + 'success' => false, + 'error' => $e->getMessage(), + 'code' => $e->getCode(), + ]; + } + } + + /** + * Determine which provider to use based on phone number. + * + * @param string $phoneNumber Normalized phone number + * + * @return string Provider identifier + */ + protected function determineProvider(string $phoneNumber): string + { + // Check routing rules + foreach ($this->routingRules as $prefix => $provider) { + if (Str::startsWith($phoneNumber, $prefix)) { + Log::debug('Phone number matched routing rule', [ + 'phone' => $phoneNumber, + 'prefix' => $prefix, + 'provider' => $provider, + ]); + + return $provider; + } + } + + // Return default provider + return $this->defaultProvider; + } + + /** + * Normalize phone number by removing formatting characters. + * + * Fleetbase already ensures all phone numbers are in E.164 format with + prefix, + * so we just need to strip out any formatting characters. + * + * @param string $phoneNumber Phone number (already in E.164 format) + * + * @return string Normalized phone number + */ + protected function normalizePhoneNumber(string $phoneNumber): string + { + // Remove all non-numeric characters except + + // Fleetbase already normalizes to E.164 format, so just clean formatting + return preg_replace('/[^0-9+]/', '', $phoneNumber); + } + + /** + * Extract 8-digit number for CallPro (Mongolia format). + * + * @param string $phoneNumber Full phone number + * + * @return string 8-digit number + */ + protected function extractCallProNumber(string $phoneNumber): string + { + // Remove + and country code, get last 8 digits + $digits = preg_replace('/[^0-9]/', '', $phoneNumber); + + return substr($digits, -8); + } + + /** + * Get available providers. + * + * @return array List of available providers + */ + public function getAvailableProviders(): array + { + $providers = [ + self::PROVIDER_TWILIO => [ + 'name' => 'Twilio', + 'available' => true, // Twilio is always available if configured + ], + ]; + + $callProService = new CallProSmsService(); + $providers[self::PROVIDER_CALLPRO] = [ + 'name' => 'CallPro/MessagePro.mn', + 'available' => $callProService->isConfigured(), + ]; + + return $providers; + } + + /** + * Add a routing rule for phone number prefix. + * + * @param string $prefix Phone number prefix (e.g., '+976') + * @param string $provider Provider identifier + */ + public function addRoutingRule(string $prefix, string $provider): void + { + $this->routingRules[$prefix] = $provider; + + Log::info('Routing rule added', [ + 'prefix' => $prefix, + 'provider' => $provider, + ]); + } + + /** + * Get current routing rules. + * + * @return array Routing rules + */ + public function getRoutingRules(): array + { + return $this->routingRules; + } + + /** + * Set default provider. + * + * @param string $provider Provider identifier + */ + public function setDefaultProvider(string $provider): void + { + $this->defaultProvider = $provider; + + Log::info('Default provider changed', ['provider' => $provider]); + } + + /** + * Get default provider. + * + * @return string Provider identifier + */ + public function getDefaultProvider(): string + { + return $this->defaultProvider; + } + + /** + * Static convenience method to send SMS. + * + * @param string $to Recipient phone number + * @param string $text Message text + * @param array $options Additional options + * @param string|null $provider Explicitly specify provider + * + * @return array Response containing status and provider information + * + * @throws \Exception If SMS sending fails + */ + public static function sendSms(string $to, string $text, array $options = [], ?string $provider = null): array + { + $instance = new static(); + + return $instance->send($to, $text, $options, $provider); + } +} diff --git a/src/Services/UserCacheService.php b/src/Services/UserCacheService.php index 9019673..b9c3846 100644 --- a/src/Services/UserCacheService.php +++ b/src/Services/UserCacheService.php @@ -27,9 +27,6 @@ class UserCacheService * Generate cache key for a user and company. * * @param int|string $userId - * @param string $companyId - * - * @return string */ public static function getCacheKey($userId, string $companyId): string { @@ -40,9 +37,6 @@ public static function getCacheKey($userId, string $companyId): string * Get cached user data. * * @param int|string $userId - * @param string $companyId - * - * @return array|null */ public static function get($userId, string $companyId): ?array { @@ -75,11 +69,6 @@ public static function get($userId, string $companyId): ?array * 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 { @@ -110,10 +99,6 @@ public static function put($userId, string $companyId, array $data, ?int $ttl = /** * Invalidate cache for a specific user. - * - * @param User $user - * - * @return void */ public static function invalidateUser(User $user): void { @@ -157,9 +142,6 @@ public static function invalidateUser(User $user): void * 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 { @@ -184,16 +166,12 @@ public static function invalidate($userId, string $companyId): void /** * 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; + $pattern = self::CACHE_PREFIX . '*:' . $companyId; $cacheKeys = Cache::getRedis()->keys($pattern); foreach ($cacheKeys as $key) { @@ -216,10 +194,6 @@ public static function invalidateCompany(string $companyId): void /** * Generate ETag for a user. - * - * @param User $user - * - * @return string */ public static function generateETag(User $user): string { @@ -228,8 +202,6 @@ public static function generateETag(User $user): string /** * Get browser cache TTL. - * - * @return int */ public static function getBrowserCacheTTL(): int { @@ -238,8 +210,6 @@ public static function getBrowserCacheTTL(): int /** * Get server cache TTL. - * - * @return int */ public static function getServerCacheTTL(): int { @@ -248,8 +218,6 @@ public static function getServerCacheTTL(): int /** * Check if caching is enabled. - * - * @return bool */ public static function isEnabled(): bool { @@ -258,8 +226,6 @@ public static function isEnabled(): bool /** * Clear all user current caches. - * - * @return void */ public static function flush(): void { diff --git a/src/Support/QueryOptimizer.php b/src/Support/QueryOptimizer.php index bef6b1c..a2c37a7 100644 --- a/src/Support/QueryOptimizer.php +++ b/src/Support/QueryOptimizer.php @@ -42,6 +42,14 @@ public static function removeDuplicateWheres(SpatialQueryBuilder|EloquentBuilder return $query; } + // Check for usage of raw queries as not able relaibly map bindings + foreach ($wheres as $w) { + if (($w['type'] ?? null) === 'Raw') { + // Can't reliably map bindings for Raw clauses, avoid breaking queries + return $query; + } + } + // Build a list of where clauses with their associated bindings $whereClauses = static::buildWhereClauseList($wheres, $bindings); @@ -52,6 +60,22 @@ public static function removeDuplicateWheres(SpatialQueryBuilder|EloquentBuilder $uniqueWheres = array_column($uniqueClauses, 'where'); $uniqueBindings = static::extractBindings($uniqueClauses); + // Validate bindings + $expected = 0; + foreach ($uniqueWheres as $w) { + $expected += static::getBindingCount($w); + } + + if ($expected !== count($uniqueBindings)) { + Log::warning('QueryOptimizer: binding mismatch, aborting optimization', [ + 'expected' => $expected, + 'actual' => count($uniqueBindings), + 'sql' => $baseQuery->toSql(), + ]); + + return $query; + } + // Validate that we haven't broken anything if (!static::validateOptimization($wheres, $bindings, $uniqueWheres, $uniqueBindings)) { // If validation fails, return original query unchanged @@ -142,8 +166,18 @@ protected static function getBindingCount(array $where): int case 'Between': case 'NotBetween': - // Between uses 2 bindings - return 2; + // Between may contain Expressions (no bindings for those) + $values = $where['values'] ?? []; + + $count = 0; + foreach ($values as $v) { + if (!$v instanceof Expression) { + $count++; + } + } + + // Fallback: if values array isn't present, assume 2 (Laravel default) + return $count > 0 ? $count : 2; case 'Nested': // Nested queries have their own bindings diff --git a/src/Traits/HasApiModelBehavior.php b/src/Traits/HasApiModelBehavior.php index 0cef2a8..8b63d20 100644 --- a/src/Traits/HasApiModelBehavior.php +++ b/src/Traits/HasApiModelBehavior.php @@ -784,9 +784,6 @@ public function searchBuilder(Request $request, $columns = ['*']) $builder = $this->withCounts($request, $builder); } - // PERFORMANCE OPTIMIZATION: Apply query optimizer to remove duplicate where clauses - $builder = $this->optimizeQuery($builder); - return $builder; } diff --git a/src/Traits/HasApiModelCache.php b/src/Traits/HasApiModelCache.php index 873113a..69f8abe 100644 --- a/src/Traits/HasApiModelCache.php +++ b/src/Traits/HasApiModelCache.php @@ -3,6 +3,7 @@ namespace Fleetbase\Traits; use Fleetbase\Support\ApiModelCache; +use Fleetbase\Support\Utils; use Illuminate\Http\Request; /** @@ -96,6 +97,9 @@ protected function queryFromRequestWithoutCache(Request $request, ?\Closure $que $queryCallback($builder, $request); } + /* debug */ + // Utils::sqlDump($builder); + if (\Fleetbase\Support\Http::isInternalRequest($request)) { return $builder->fastPaginate($limit, $columns); } @@ -258,6 +262,24 @@ public function invalidateQueryCache(Request $request, array $additionalParams = ApiModelCache::invalidateQueryCache($this, $request, $additionalParams); } + /** + * Manually invalidate API caches when model events are bypassed. + * + * This method should be called after operations that modify or delete + * records without triggering Eloquent model events (e.g. bulk deletes, + * bulk updates, raw queries, or maintenance scripts). + * + * It performs a table-level cache invalidation, clearing all related + * model, relationship, and query caches and incrementing the internal + * query version counter to prevent stale reads. + * + * @param string|null $companyUuid Optional company UUID for scoped invalidation + */ + public static function invalidateApiCacheManually(?string $companyUuid = null): void + { + ApiModelCache::invalidateModelCache(new static(), $companyUuid); + } + /** * Warm up cache for common queries. */ diff --git a/src/routes.php b/src/routes.php index 9b2d251..ce002e3 100644 --- a/src/routes.php +++ b/src/routes.php @@ -275,9 +275,9 @@ function ($router, $controller) { $router->fleetbaseRoutes( 'files', function ($router, $controller) { + $router->get('download/{id?}', $controller('download')); $router->post('upload', $controller('upload')); $router->post('uploadBase64', $controller('upload-base64')); - $router->get('download/{id}', $controller('download')); } ); $router->fleetbaseRoutes('transactions');