diff --git a/composer.json b/composer.json index 9d931bc2..d8b26ee6 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "fleetbase/core-api", - "version": "1.6.48", + "version": "1.6.49", "description": "Core Framework and Resources for Fleetbase API", "keywords": [ "fleetbase", diff --git a/seeders/Concerns/ResolvesSeedCompany.php b/seeders/Concerns/ResolvesSeedCompany.php index 4d552937..391f7355 100644 --- a/seeders/Concerns/ResolvesSeedCompany.php +++ b/seeders/Concerns/ResolvesSeedCompany.php @@ -8,7 +8,7 @@ trait ResolvesSeedCompany { protected function resolveSeedCompany(?string $fallbackUuidEnv = null, ?string $fallbackPublicIdEnv = null): ?Company { - $companyUuid = env('SEED_COMPANY_UUID') ?: ($fallbackUuidEnv ? env($fallbackUuidEnv) : null); + $companyUuid = env('SEED_COMPANY_UUID') ?: ($fallbackUuidEnv ? env($fallbackUuidEnv) : null); $companyPublicId = env('SEED_COMPANY_PUBLIC_ID') ?: ($fallbackPublicIdEnv ? env($fallbackPublicIdEnv) : null); if ($companyUuid) { diff --git a/src/Console/Commands/NotifyInstalled.php b/src/Console/Commands/NotifyInstalled.php new file mode 100644 index 00000000..8ebe507d --- /dev/null +++ b/src/Console/Commands/NotifyInstalled.php @@ -0,0 +1,65 @@ +option('channel') ?: 'fleetbase.install'; + $payload = [ + 'event' => 'fleetbase.installed', + 'installed' => true, + 'timestamp' => now()->toIso8601String(), + ]; + + try { + $socketClusterClient = new SocketClusterService(); + $sent = $socketClusterClient->send($channel, $payload); + + if (!$sent) { + $message = $socketClusterClient->error() ?: 'SocketCluster did not acknowledge the install notification.'; + $this->warn('Install notification was not sent: ' . $message); + Log::warning('Fleetbase install notification was not sent.', [ + 'channel' => $channel, + 'error' => $message, + ]); + + return 0; + } + + $this->info('Install notification sent.'); + } catch (\Throwable $e) { + $this->warn('Install notification failed: ' . $e->getMessage()); + Log::warning('Fleetbase install notification failed.', [ + 'channel' => $channel, + 'error' => $e->getMessage(), + ]); + } + + return 0; + } +} diff --git a/src/Http/Controllers/Internal/v1/AuthController.php b/src/Http/Controllers/Internal/v1/AuthController.php index ba9d8160..e35b9730 100644 --- a/src/Http/Controllers/Internal/v1/AuthController.php +++ b/src/Http/Controllers/Internal/v1/AuthController.php @@ -28,7 +28,6 @@ use Illuminate\Http\Request; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Cache; -use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Redis; use Illuminate\Support\Str; @@ -218,37 +217,9 @@ public function bootstrap(Request $request) ->distinct() ->get(); - // Get installer status (cached separately) - $installer = Cache::remember('installer_status', now()->addHour(), function () { - $shouldInstall = false; - $shouldOnboard = false; - - try { - DB::connection()->getPdo(); - if (DB::connection()->getDatabaseName()) { - if (\Illuminate\Support\Facades\Schema::hasTable('companies')) { - $shouldOnboard = !DB::table('companies')->exists(); - } else { - $shouldInstall = true; - } - } else { - $shouldInstall = true; - } - } catch (\Exception $e) { - $shouldInstall = true; - } - - return [ - 'shouldInstall' => $shouldInstall, - 'shouldOnboard' => $shouldOnboard, - 'defaultTheme' => \Fleetbase\Models\Setting::lookup('branding.default_theme', 'dark'), - ]; - }); - return [ 'session' => $session, 'organizations' => Organization::collection($organizations), - 'installer' => $installer, ]; }); diff --git a/src/Http/Controllers/Internal/v1/InstallerController.php b/src/Http/Controllers/Internal/v1/InstallerController.php deleted file mode 100644 index b7d1b7ab..00000000 --- a/src/Http/Controllers/Internal/v1/InstallerController.php +++ /dev/null @@ -1,135 +0,0 @@ -addHour(); // Cache for 1 hour - - // Try cache first - $status = Cache::remember($cacheKey, $cacheTTL, function () { - return $this->checkInstallationStatus(); - }); - - // Return with cache headers - return response()->json($status) - ->header('Cache-Control', 'private, max-age=3600') // 1 hour - ->header('X-Cache-Status', Cache::has($cacheKey) ? 'HIT' : 'MISS'); - } - - /** - * Check installation status. - */ - protected function checkInstallationStatus(): array - { - $shouldInstall = false; - $shouldOnboard = false; - $defaultTheme = 'dark'; // Default fallback - - try { - // Quick connection check - DB::connection()->getPdo(); - - if (!DB::connection()->getDatabaseName()) { - $shouldInstall = true; - } else { - // Use exists() instead of count() - much faster - if (Schema::hasTable('companies')) { - $shouldOnboard = !DB::table('companies')->exists(); - } else { - $shouldInstall = true; - } - - // Only lookup theme if not installing - if (!$shouldInstall) { - $defaultTheme = Setting::lookup('branding.default_theme', 'dark'); - } - } - } catch (\Exception $e) { - $shouldInstall = true; - } - - return [ - 'shouldInstall' => $shouldInstall, - 'shouldOnboard' => $shouldOnboard, - 'defaultTheme' => $defaultTheme, - ]; - } - - /** - * Clear installer cache (call after installation/onboarding). - * - * @return void - */ - public static function clearCache() - { - Cache::forget('installer_status'); - } - - public function createDatabase() - { - ini_set('memory_limit', '-1'); - ini_set('max_execution_time', 0); - - Artisan::call('mysql:createdb'); - - // Clear cache after database creation - static::clearCache(); - - return response()->json( - [ - 'status' => 'success', - ] - ); - } - - public function migrate() - { - ini_set('memory_limit', '-1'); - ini_set('max_execution_time', 0); - - Artisan::call('migrate', ['--force' => true]); - Artisan::call('sandbox:migrate'); - - // Clear cache after migration - static::clearCache(); - - return response()->json( - [ - 'status' => 'success', - ] - ); - } - - public function seed() - { - ini_set('memory_limit', '-1'); - ini_set('max_execution_time', 0); - - Artisan::call('fleetbase:seed'); - - // Clear cache after seeding - static::clearCache(); - - return response()->json( - [ - 'status' => 'success', - ] - ); - } -} diff --git a/src/Http/Middleware/EnsureFleetbaseConfigured.php b/src/Http/Middleware/EnsureFleetbaseConfigured.php new file mode 100644 index 00000000..c6b8a3bc --- /dev/null +++ b/src/Http/Middleware/EnsureFleetbaseConfigured.php @@ -0,0 +1,79 @@ + + */ + protected array $requiredTables = [ + 'settings', + 'users', + 'companies', + ]; + + protected static bool $configured = false; + + public function handle(Request $request, \Closure $next) + { + if (!$this->shouldCheck($request)) { + return $next($request); + } + + if (!$this->isConfigured()) { + return response()->json([ + 'error' => 'fleetbase_not_configured', + 'errors' => ['fleetbase_not_configured'], + 'message' => 'Fleetbase is not installed or configured. Complete setup from the CLI or application container, then reload the console.', + ], 503); + } + + return $next($request); + } + + protected function shouldCheck(Request $request): bool + { + if ($request->isMethod('OPTIONS')) { + return false; + } + + return $request->is('int/*') + || $request->is('*/int/*') + || $request->is('v1/*') + || $request->is('*/v1/*'); + } + + protected function isConfigured(): bool + { + if (static::$configured) { + return true; + } + + try { + DB::connection()->getPdo(); + + if (!DB::connection()->getDatabaseName()) { + return false; + } + + foreach ($this->requiredTables as $table) { + if (!Schema::hasTable($table)) { + return false; + } + } + + static::$configured = true; + + return true; + } catch (\Throwable $e) { + return false; + } + } +} diff --git a/src/Providers/CoreServiceProvider.php b/src/Providers/CoreServiceProvider.php index b04e2dd3..1b6e3cfc 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\EnsureFleetbaseConfigured::class, \Fleetbase\Http\Middleware\AttachCacheHeaders::class, ]; @@ -85,6 +86,7 @@ class CoreServiceProvider extends ServiceProvider \Fleetbase\Console\Commands\InitializeSandboxKeyColumn::class, \Fleetbase\Console\Commands\SyncSandbox::class, \Fleetbase\Console\Commands\CreatePermissions::class, + \Fleetbase\Console\Commands\NotifyInstalled::class, \Fleetbase\Console\Commands\FixUserCompanies::class, \Fleetbase\Console\Commands\PurgeApiLogs::class, \Fleetbase\Console\Commands\PurgeWebhookLogs::class, diff --git a/src/Services/CallProSmsService.php b/src/Services/CallProSmsService.php index 113c9c51..468e464d 100644 --- a/src/Services/CallProSmsService.php +++ b/src/Services/CallProSmsService.php @@ -20,7 +20,7 @@ public function __construct() { $this->apiKey = config('services.callpromn.api_key', ''); $this->from = config('services.callpromn.from', ''); - $this->baseUrl = config('services.callpromn.base_url', 'https://api.messagepro.mn'); + $this->baseUrl = config('services.callpromn.base_url', 'https://api-text.callpro.mn/v1/sms'); Log::info('CallProSmsService initialized', [ 'base_url' => $this->baseUrl, @@ -31,38 +31,46 @@ public function __construct() /** * 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 + * @param string $to Recipient phone number + * @param string $text Message text + * @param string|null $from Optional sender number, defaults to config + * @param array $options Optional CallPro parameters * * @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 + public static function sendSms(string $to, string $text, ?string $from = null, array $options = []): array { $instance = new static(); - return $instance->send($to, $text, $from); + return $instance->send($to, $text, $from, $options); } /** * 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 + * @param string $to Recipient phone number + * @param string $text Message text + * @param string|null $from Optional sender number, defaults to config + * @param array $options Optional CallPro parameters * * @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 + public function send(string $to, string $text, ?string $from = null, array $options = []): array { $from = $from ?? $this->from; - // Validate parameters $this->validateParameters($to, $text, $from); + $payload = array_filter([ + 'from' => $from, + 'to' => $to, + 'text' => $text, + 'brand' => data_get($options, 'brand'), + 'unique_id' => data_get($options, 'unique_id'), + ], static fn ($value) => $value !== null && $value !== ''); try { Log::info('Sending SMS via CallPro', [ @@ -73,31 +81,26 @@ public function send(string $to, string $text, ?string $from = null): array $response = Http::withHeaders([ 'x-api-key' => $this->apiKey, - ])->get("{$this->baseUrl}/send", [ - 'from' => $from, - 'to' => $to, - 'text' => $text, - ]); + ])->post("{$this->baseUrl}/send", $payload); $statusCode = $response->status(); $body = $response->json(); - // Handle response based on status code - if ($statusCode === 200) { + if ($statusCode === 200 && is_array($body) && isset($body['message_id'])) { Log::info('SMS sent successfully', [ - 'message_id' => $body['Message ID'] ?? null, - 'result' => $body['Result'] ?? null, + 'message_id' => $body['message_id'], + 'status' => $body['status'] ?? null, ]); return [ 'success' => true, - 'message_id' => $body['Message ID'] ?? null, - 'result' => $body['Result'] ?? 'SUCCESS', + 'message_id' => $body['message_id'], + 'result' => $body['status'] ?? 'queued', + 'status' => $body['status'] ?? 'queued', ]; } - // Handle error responses - $errorMessage = $this->getErrorMessage($statusCode); + $errorMessage = $this->getErrorMessage($statusCode, $body); Log::error('SMS sending failed', [ 'status_code' => $statusCode, @@ -131,16 +134,12 @@ protected function validateParameters(string $to, string $text, string $from): v 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 (!preg_match('/^\d{8}$/', $from)) { + throw new \InvalidArgumentException('Sender number (from) must be exactly 8 digits'); } - if (strlen($text) > 160) { - throw new \InvalidArgumentException('Message text cannot exceed 160 characters'); + if (!$this->isValidRecipientNumber($to)) { + throw new \InvalidArgumentException('Recipient phone number (to) must be an 8-digit, 976-prefixed, +976-prefixed, or international number'); } if (empty($text)) { @@ -151,17 +150,40 @@ protected function validateParameters(string $to, string $text, string $from): v /** * Get error message based on status code. */ - protected function getErrorMessage(int $statusCode): string + protected function getErrorMessage(int $statusCode, ?array $body = null): string { + if (is_array($body)) { + $error = data_get($body, 'error') ?? data_get($body, 'reason'); + if (is_string($error) && !empty($error)) { + return $error; + } + + $issues = data_get($body, 'issues'); + if (is_array($issues) && !empty($issues)) { + return json_encode($issues); + } + } + 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)', + 400 => 'Invalid request parameters', + 401 => 'Invalid or missing API key', + 402 => 'Payment not paid', + 403 => 'Blocked number', + 404 => 'Tenant or phone number not found', + 422 => 'Validation error', + 500 => 'CallPro server error', default => "API request failed with status code: {$statusCode}", }; } + /** + * Validate recipient number formats accepted by CallPro. + */ + protected function isValidRecipientNumber(string $to): bool + { + return (bool) preg_match('/^(?:\d{8}|976\d{8}|\+976\d{8}|\d{9,15})$/', $to); + } + /** * Check if the service is configured. */ diff --git a/src/Services/SmsService.php b/src/Services/SmsService.php index 1967325c..4e744218 100644 --- a/src/Services/SmsService.php +++ b/src/Services/SmsService.php @@ -115,7 +115,7 @@ protected function sendViaCallPro(string $to, string $text, array $options = []) return $this->sendViaTwilio($to, $text, $options); } - // Extract the last 8 digits for CallPro (Mongolia format) + // Keep Mongolia routing backward compatible while allowing documented international format. $toNumber = $this->extractCallProNumber($to); // CallPro does NOT support alphanumeric sender IDs (Twilio-specific) @@ -128,7 +128,12 @@ protected function sendViaCallPro(string $to, string $text, array $options = []) $from = null; // Let CallPro use its configured default } - return $callProService->send($toNumber, $text, $from); + $callProOptions = array_filter([ + 'brand' => data_get($options, 'brand'), + 'unique_id' => data_get($options, 'unique_id'), + ], static fn ($value) => $value !== null && $value !== ''); + + return $callProService->send($toNumber, $text, $from, $callProOptions); } /** @@ -223,10 +228,13 @@ protected function normalizePhoneNumber(string $phoneNumber): string */ protected function extractCallProNumber(string $phoneNumber): string { - // Remove + and country code, get last 8 digits $digits = preg_replace('/[^0-9]/', '', $phoneNumber); - return substr($digits, -8); + if (strlen($digits) === 11 && str_starts_with($digits, '976')) { + return substr($digits, -8); + } + + return $digits; } /** diff --git a/src/routes.php b/src/routes.php index 2b86680e..8e8b50d2 100644 --- a/src/routes.php +++ b/src/routes.php @@ -90,15 +90,6 @@ function ($router) { $router->prefix('v1')->namespace('v1')->group( function ($router) { $router->fleetbaseAuthRoutes(); - $router->group( - ['prefix' => 'installer', 'middleware' => [Fleetbase\Http\Middleware\ThrottleRequests::class]], - function ($router) { - $router->get('initialize', 'InstallerController@initialize'); - $router->post('createdb', 'InstallerController@createDatabase'); - $router->post('migrate', 'InstallerController@migrate'); - $router->post('seed', 'InstallerController@seed'); - } - ); $router->group( ['prefix' => 'onboard', 'middleware' => [Fleetbase\Http\Middleware\ThrottleRequests::class]], function ($router) { diff --git a/tests/Unit/CallProSmsServiceTest.php b/tests/Unit/CallProSmsServiceTest.php new file mode 100644 index 00000000..cfee0ae0 --- /dev/null +++ b/tests/Unit/CallProSmsServiceTest.php @@ -0,0 +1,156 @@ +make('config'); + + if ($key === null) { + return $config; + } + + return $config->get($key, $default); + } +} + +beforeEach(function () { + $app = new Container(); + + $app->instance('config', new Repository([ + 'services' => [ + 'callpromn' => [ + 'api_key' => 'callpro-api-key', + 'from' => '72001234', + 'base_url' => 'https://api-text.callpro.mn/v1/sms', + ], + ], + 'sms' => [ + 'default_provider' => SmsService::PROVIDER_TWILIO, + 'routing_rules' => [ + '+976' => SmsService::PROVIDER_CALLPRO, + ], + ], + ])); + $app->instance('log', new NullLogger()); + $app->instance(Factory::class, new Factory()); + + Container::setInstance($app); + Facade::setFacadeApplication($app); + Facade::clearResolvedInstances(); +}); + +test('callpro sms service sends renewed post payload with api key header', function () { + Http::fake([ + 'https://api-text.callpro.mn/v1/sms/send' => Http::response([ + 'status' => 'queued', + 'message_id' => '0195c03b-7f96-7f9f-8b71-4d7a930adf2f_1', + ], 200), + ]); + + $result = (new CallProSmsService())->send('99112233', 'Hello', null, [ + 'brand' => '42', + 'unique_id' => 'custom-prefix', + ]); + + expect($result)->toMatchArray([ + 'success' => true, + 'message_id' => '0195c03b-7f96-7f9f-8b71-4d7a930adf2f_1', + 'result' => 'queued', + 'status' => 'queued', + ]); + + Http::assertSent(function ($request) { + return $request->method() === 'POST' + && $request->url() === 'https://api-text.callpro.mn/v1/sms/send' + && $request->hasHeader('x-api-key', 'callpro-api-key') + && $request['from'] === '72001234' + && $request['to'] === '99112233' + && $request['text'] === 'Hello' + && $request['brand'] === '42' + && $request['unique_id'] === 'custom-prefix'; + }); +}); + +test('callpro sms service allows long segmented messages', function () { + Http::fake([ + 'https://api-text.callpro.mn/v1/sms/send' => Http::response([ + 'status' => 'queued', + 'message_id' => 'long-message-id', + ], 200), + ]); + + $result = (new CallProSmsService())->send('99112233', str_repeat('A', 320)); + + expect($result['success'])->toBeTrue() + ->and($result['message_id'])->toBe('long-message-id'); +}); + +test('callpro sms service accepts documented recipient number formats', function (string $phone) { + Http::fake([ + 'https://api-text.callpro.mn/v1/sms/send' => Http::response([ + 'status' => 'queued', + 'message_id' => 'message-id', + ], 200), + ]); + + $result = (new CallProSmsService())->send($phone, 'Hello'); + + expect($result['success'])->toBeTrue(); +})->with([ + '99112233', + '97699112233', + '+97699112233', + '15612767156', +]); + +test('callpro sms service returns renewed api error details', function () { + Http::fake([ + 'https://api-text.callpro.mn/v1/sms/send' => Http::response([ + 'error' => 'Unauthorized', + ], 401), + ]); + + $result = (new CallProSmsService())->send('99112233', 'Hello'); + + expect($result)->toMatchArray([ + 'success' => false, + 'error' => 'Unauthorized', + 'code' => 401, + ]); +}); + +test('sms service passes callpro options and ignores twilio sender ids', function () { + Http::fake([ + 'https://api-text.callpro.mn/v1/sms/send' => Http::response([ + 'status' => 'queued', + 'message_id' => 'message-id', + ], 200), + ]); + + $result = (new SmsService())->send('+97699112233', 'Hello', [ + 'from' => 'FLEETBASE', + 'brand' => '42', + 'unique_id' => 'verification-123', + ], SmsService::PROVIDER_CALLPRO); + + expect($result)->toMatchArray([ + 'success' => true, + 'provider' => SmsService::PROVIDER_CALLPRO, + ]); + + Http::assertSent(function ($request) { + return $request['from'] === '72001234' + && $request['to'] === '99112233' + && $request['brand'] === '42' + && $request['unique_id'] === 'verification-123'; + }); +});