@fleetbase/fleetops-engine 0.6.67 → 0.6.69

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/RELEASE.md +11 -15
  2. package/addon/components/contact/form.hbs +13 -2
  3. package/addon/components/customer/details.hbs +17 -17
  4. package/addon/components/customer/form.hbs +13 -2
  5. package/addon/components/customer/form.js +0 -56
  6. package/addon/components/driver/details.hbs +13 -19
  7. package/addon/components/driver/form.hbs +20 -35
  8. package/addon/components/driver/form.js +0 -58
  9. package/addon/components/fuel-integration/form.hbs +2 -2
  10. package/addon/components/fuel-integration/form.js +8 -0
  11. package/addon/components/fuel-integration/hub.js +1 -1
  12. package/addon/components/modals/driver-form.hbs +2 -2
  13. package/addon/components/modals/reset-profile-credentials.hbs +23 -0
  14. package/addon/components/modals/reset-profile-credentials.js +70 -0
  15. package/addon/components/profile-identity-hint.hbs +13 -0
  16. package/addon/components/profile-identity-hint.js +49 -0
  17. package/addon/controllers/management/drivers/index.js +4 -0
  18. package/addon/extension.js +4 -2
  19. package/addon/services/contact-actions.js +50 -89
  20. package/addon/services/driver-actions.js +98 -0
  21. package/addon/templates/connectivity/fuel-providers/details/index.hbs +1 -1
  22. package/addon/utils/fuel-transaction.js +2 -0
  23. package/addon/utils/profile-login-actions.js +172 -0
  24. package/app/components/modals/{reset-customer-credentials.js → reset-profile-credentials.js} +1 -1
  25. package/app/components/profile-identity-hint.js +1 -0
  26. package/composer.json +1 -1
  27. package/docs/SAFEE.md +5 -0
  28. package/docs/TELEMATICS_QUEUES.md +59 -8
  29. package/extension.json +1 -1
  30. package/package.json +3 -3
  31. package/scripts/pest-bootstrap.php +12 -0
  32. package/server/config/fuel-providers.php +53 -0
  33. package/server/migrations/2026_09_21_000001_convert_profile_only_users_to_managed_accounts.php +132 -0
  34. package/server/resources/views/mail/driver-credentials.blade.php +21 -0
  35. package/server/src/Console/Commands/FixCustomerCompanies.php +1 -1
  36. package/server/src/Console/Commands/FixDriverCompanies.php +1 -1
  37. package/server/src/Exceptions/ProfileIdentityConflictException.php +34 -0
  38. package/server/src/Http/Controllers/Api/v1/CustomerController.php +14 -0
  39. package/server/src/Http/Controllers/Api/v1/DriverController.php +56 -39
  40. package/server/src/Http/Controllers/Internal/v1/ContactController.php +5 -17
  41. package/server/src/Http/Controllers/Internal/v1/CustomerController.php +40 -4
  42. package/server/src/Http/Controllers/Internal/v1/DriverController.php +182 -162
  43. package/server/src/Http/Controllers/Internal/v1/FleetOpsLookupController.php +47 -0
  44. package/server/src/Http/Controllers/Internal/v1/OrderController.php +156 -27
  45. package/server/src/Http/Requests/CreateDriverRequest.php +6 -2
  46. package/server/src/Http/Requests/Internal/CreateDriverRequest.php +9 -18
  47. package/server/src/Http/Resources/v1/Contact.php +3 -0
  48. package/server/src/Http/Resources/v1/Driver.php +3 -0
  49. package/server/src/Jobs/PollTelematicTelemetry.php +36 -0
  50. package/server/src/Jobs/SyncTelematicDevicesJob.php +2 -2
  51. package/server/src/Mail/DriverCredentialsMail.php +55 -0
  52. package/server/src/Models/Contact.php +55 -90
  53. package/server/src/Models/Sensor.php +3 -5
  54. package/server/src/Observers/ContactObserver.php +2 -22
  55. package/server/src/Observers/DriverObserver.php +5 -6
  56. package/server/src/Providers/FleetOpsServiceProvider.php +15 -0
  57. package/server/src/Support/Ai/Capabilities/AbstractFleetOpsAICapability.php +62 -5
  58. package/server/src/Support/Ai/Capabilities/AssetStatusCapability.php +24 -5
  59. package/server/src/Support/Ai/Capabilities/CreateOrderPreviewCapability.php +6 -2
  60. package/server/src/Support/Ai/Capabilities/OrderInsightsCapability.php +40 -7
  61. package/server/src/Support/Ai/Capabilities/SearchResourcesCapability.php +86 -24
  62. package/server/src/Support/Ai/FleetOpsAiConsoleCommands.php +122 -0
  63. package/server/src/Support/Ai/Tools/CreateOrderTool.php +86 -0
  64. package/server/src/Support/Ai/Tools/ImportOrdersTool.php +53 -0
  65. package/server/src/Support/Ai/Tools/OptimizeOrderRouteTool.php +70 -0
  66. package/server/src/Support/Ai/Tools/SearchResourcesTool.php +73 -0
  67. package/server/src/Support/FuelProviders/Providers/SascoFuelProvider.php +272 -0
  68. package/server/src/Support/ProfileAccountManager.php +405 -0
  69. package/server/src/Support/Telematics/TelematicService.php +6 -4
  70. package/server/src/Support/Telematics/Telemetry/Inbox.php +27 -9
  71. package/server/src/routes.php +5 -0
  72. package/server/tests/AiOperationalQueryCapabilityTest.php +1 -0
  73. package/server/tests/ApiCustomerControllerContractsTest.php +21 -0
  74. package/server/tests/ApiDriverControllerContractsTest.php +102 -20
  75. package/server/tests/CommandContractsTest.php +4 -4
  76. package/server/tests/ControllerHelperContractsTest.php +3 -11
  77. package/server/tests/Feature/Http/AfaqyRealtimeIngestionTest.php +5 -0
  78. package/server/tests/Feature/Http/Api/DriverControllerAuthFlowsTest.php +36 -0
  79. package/server/tests/Feature/Http/Api/DriverControllerSessionHelpersTest.php +19 -6
  80. package/server/tests/Feature/Http/Api/ProfileOnlyUserConversionMigrationTest.php +201 -0
  81. package/server/tests/Feature/Http/Internal/ContactControllerEndpointsTest.php +6 -4
  82. package/server/tests/Feature/Http/Internal/CustomerControllerCredentialsTest.php +6 -2
  83. package/server/tests/Feature/Http/Internal/DriverControllerContractsTest.php +23 -0
  84. package/server/tests/Feature/Http/Internal/DriverControllerCreateRecordTest.php +42 -17
  85. package/server/tests/Feature/Http/Internal/DriverControllerExistingUserAdoptionTest.php +168 -277
  86. package/server/tests/Feature/Http/Internal/DriverControllerLoginManagementTest.php +716 -0
  87. package/server/tests/Feature/Http/Internal/OrderControllerContractsTest.php +10 -3
  88. package/server/tests/Feature/Http/Internal/OrderControllerImportFromFilesTest.php +18 -0
  89. package/server/tests/Feature/Http/Internal/OrderControllerTenantScopingTest.php +411 -0
  90. package/server/tests/Feature/Http/Internal/OrderControllerUpstreamNotFoundTest.php +8 -20
  91. package/server/tests/Feature/Http/ManualTelemetryPollingTest.php +88 -4
  92. package/server/tests/InternalCustomerControllerContractsTest.php +32 -1
  93. package/server/tests/ModelAccessorContractsTest.php +82 -20
  94. package/server/tests/ObserverContractsTest.php +27 -2
  95. package/server/tests/RequestContractsTest.php +19 -11
  96. package/server/tests/SupportJobAndAiCoverageTest.php +3 -3
  97. package/server/tests/TelematicControllerContractsTest.php +22 -4
  98. package/server/tests/Unit/Models/ContactCustomerUserTest.php +90 -14
  99. package/server/tests/Unit/Models/ContactTest.php +48 -5
  100. package/server/tests/Unit/Models/SensorTest.php +2 -2
  101. package/server/tests/Unit/Models/SmallModelSurfacesTest.php +31 -32
  102. package/server/tests/Unit/Observers/ObserverListenerSeamsTest.php +7 -2
  103. package/server/tests/Unit/Providers/FleetOpsAiRegistrationTest.php +15 -2
  104. package/server/tests/Unit/Support/Ai/AssetStatusCapabilityQueriesTest.php +42 -0
  105. package/server/tests/Unit/Support/Ai/Capabilities/OrderInsightsCapabilityTest.php +19 -1
  106. package/server/tests/Unit/Support/Ai/Capabilities/SearchResourcesCapabilityTest.php +85 -1
  107. package/server/tests/Unit/Support/Ai/FleetOpsAiConsoleCommandsTest.php +31 -0
  108. package/server/tests/Unit/Support/Ai/Tools/FleetOpsAiToolsTest.php +246 -0
  109. package/server/tests/Unit/Support/AiCapabilityPermissionsTest.php +3 -3
  110. package/server/tests/Unit/Support/ProfileAccountManagerTest.php +643 -0
  111. package/server/tests/Unit/Support/SascoFuelProviderTest.php +338 -0
  112. package/translations/en-us.yaml +37 -2
  113. package/translations/uk-ua.yaml +3475 -0
  114. package/addon/components/modals/reset-customer-credentials.hbs +0 -18
  115. package/addon/components/modals/reset-customer-credentials.js +0 -55
@@ -188,18 +188,20 @@ class TelematicService
188
188
  }
189
189
  $job = (new \Fleetbase\FleetOps\Jobs\PollTelematicTelemetry($connection->uuid, $jobId, $options))
190
190
  ->onQueue($settings['poll_queue'] ?? 'default');
191
- if (!Telemetry\Queue::dispatch($job)) {
191
+ $queued = Telemetry\Queue::dispatch($job);
192
+ if (!$queued) {
192
193
  $existing = data_get($connection->meta, 'last_sync_job_id');
193
- if ($existing && in_array(data_get($connection->meta, 'last_sync_result'), ['queued', 'running', 'retrying'], true)) {
194
+ if ($existing && in_array(data_get($connection->meta, 'last_sync_result'), Telemetry\Inbox::PENDING_REQUEST_RESULTS, true)) {
194
195
  return $existing;
195
196
  }
196
- throw ValidationException::withMessages(['telematic' => ['Telemetry synchronization is already queued or running.']]);
197
+ // A scheduled sweep holds this connection's poll lease. Record the request so
198
+ // the next sweep adopts and completes it, rather than rejecting the user.
197
199
  }
198
200
  $connection->status = 'synchronizing';
199
201
  $connection->meta = array_merge($connection->meta ?? [], [
200
202
  'last_sync_job_id' => $jobId, 'last_sync_run_uuid' => null, 'last_sync_run_job_id' => null,
201
203
  'last_sync_started_at' => now()->toDateTimeString(), 'last_sync_result' => 'queued',
202
- 'last_sync_phase' => 'queued', 'last_sync_error' => null, 'last_sync_failed_reason' => null,
204
+ 'last_sync_phase' => $queued ? 'queued' : 'waiting_for_sweep', 'last_sync_error' => null, 'last_sync_failed_reason' => null,
203
205
  ]);
204
206
  $connection->save();
205
207
 
@@ -10,6 +10,9 @@ use Illuminate\Support\Str;
10
10
 
11
11
  class Inbox
12
12
  {
13
+ /** Manual request states that a sweep may still finalize. */
14
+ public const PENDING_REQUEST_RESULTS = ['queued', 'running', 'retrying'];
15
+
13
16
  public function accept(Telematic $telematic, array $payload, string $source, ?string $run = null, ?string $receivedAt = null): string
14
17
  {
15
18
  $json = json_encode($payload, JSON_THROW_ON_ERROR);
@@ -64,17 +67,12 @@ class Inbox
64
67
  if ($status === 'incomplete') {
65
68
  return;
66
69
  }
67
- // A previous sweep may finish after another manual request was queued.
68
- // Only the run explicitly associated with that request may finalize it.
69
70
  $connection = Telematic::withoutGlobalScopes()->where('uuid', $run->telematic_uuid)->lockForUpdate()->first();
70
- if (!$connection || !self::enabled($connection)
71
- || data_get($connection->meta, 'last_sync_run_uuid') !== $id
72
- || !data_get($connection->meta, 'last_sync_run_job_id')
73
- || data_get($connection->meta, 'last_sync_run_job_id') !== data_get($connection->meta, 'last_sync_job_id')) {
71
+ if (!$connection || !self::enabled($connection)) {
74
72
  return;
75
73
  }
76
- $connection->status = $status === 'completed' ? 'active' : 'error';
77
- $connection->meta = array_merge($connection->meta ?? [], [
74
+ $meta = $connection->meta ?? [];
75
+ $result = [
78
76
  'last_sync_result' => $status === 'completed' ? 'success' : $status,
79
77
  'last_sync_phase' => $status, 'last_sync_progress_at' => now()->toDateTimeString(),
80
78
  'last_sync_fetched_total' => (int) $run->units, 'last_sync_page_count' => (int) $run->pages,
@@ -82,8 +80,28 @@ class Inbox
82
80
  'last_sync_failed_total' => (int) $failed,
83
81
  'last_sync_error' => $status === 'completed' ? null : ($run->error ?? 'Some units could not be applied; inspect telemetry diagnostics.'),
84
82
  $status === 'completed' ? 'last_sync_completed_at' : 'last_sync_failed_at' => now()->toDateTimeString(),
83
+ ];
84
+ // A previous sweep may finish after another manual request was queued.
85
+ // Only the run explicitly associated with that request may finalize it.
86
+ if (data_get($meta, 'last_sync_run_uuid') === $id && data_get($meta, 'last_sync_run_job_id')
87
+ && data_get($meta, 'last_sync_run_job_id') === data_get($meta, 'last_sync_job_id')) {
88
+ $connection->status = $status === 'completed' ? 'active' : 'error';
89
+ $connection->meta = array_merge($meta, $result);
90
+ $connection->save();
91
+
92
+ return;
93
+ }
94
+ // A completed scheduled sweep proves the connection is healthy, so it replaces a
95
+ // stale failure. It never finalizes or overrides a manual request still in flight,
96
+ // and a partial sweep leaves the last reported state alone.
97
+ if ($status !== 'completed' || in_array(data_get($meta, 'last_sync_result'), self::PENDING_REQUEST_RESULTS, true)) {
98
+ return;
99
+ }
100
+ // Written without model events: sweeps run every minute and the model logs every save.
101
+ DB::table('telematics')->where('uuid', $connection->uuid)->update([
102
+ 'status' => in_array($connection->status, ['error', 'synchronizing'], true) ? 'active' : $connection->status,
103
+ 'meta' => json_encode(array_merge($meta, $result), JSON_THROW_ON_ERROR),
85
104
  ]);
86
- $connection->save();
87
105
  });
88
106
  }
89
107
  }
@@ -444,6 +444,10 @@ Route::prefix(config('fleetops.api.routing.prefix'))->namespace('Fleetbase\Fleet
444
444
  $router->post('{id}/unassign-order', $controller('unassignOrder'));
445
445
  $router->post('{id}/assign-vehicle', $controller('assignVehicle'));
446
446
  $router->post('{id}/unassign-vehicle', $controller('unassignVehicle'));
447
+ $router->post('{id}/send-credentials', $controller('sendCredentials'));
448
+ $router->post('{id}/reset-credentials', $controller('resetCredentials'));
449
+ $router->post('{id}/deactivate-login', $controller('deactivateLogin'));
450
+ $router->post('{id}/reactivate-login', $controller('reactivateLogin'));
447
451
  $router->match(['get', 'post'], 'export', $controller('export'));
448
452
  $router->post('import', $controller('import'));
449
453
  // Driver scheduling endpoints
@@ -764,6 +768,7 @@ Route::prefix(config('fleetops.api.routing.prefix'))->namespace('Fleetbase\Fleet
764
768
  function ($router) {
765
769
  $router->get('customers', 'FleetOpsLookupController@polymorphs');
766
770
  $router->get('facilitators', 'FleetOpsLookupController@polymorphs');
771
+ $router->get('profile-identity', 'FleetOpsLookupController@profileIdentity');
767
772
  }
768
773
  );
769
774
  $router->group(
@@ -406,6 +406,7 @@ test('asset status capability exposes metadata and denied branches', function ()
406
406
  ->and($capability->label())->toBe('Fleet-Ops asset status')
407
407
  ->and($capability->description())->toContain('vehicle, device, sensor')
408
408
  ->and($capability->permissions())->toBe([
409
+ 'fleet-ops see driver',
409
410
  'fleet-ops see vehicle',
410
411
  'fleet-ops see device',
411
412
  'fleet-ops see sensor',
@@ -800,6 +800,27 @@ test('api customer controller authenticates and verifies customer codes', functi
800
800
  ]))))->toBe(['error' => 'Invalid verification code.']);
801
801
  });
802
802
 
803
+ test('api customer controller refuses deactivated customer logins', function () {
804
+ $controller = fleetopsApiCustomerController();
805
+ foreach (['activeUser', 'loginUser', 'verificationUser'] as $property) {
806
+ $controller->{$property}->setAttribute('status', 'inactive');
807
+ }
808
+
809
+ $responses = [
810
+ $controller->login(Request::create('/v1/customers/login', 'POST', ['identity' => 'jane@example.test', 'password' => 'password-secret'])),
811
+ $controller->loginWithPhone(Request::create('/v1/customers/login/phone', 'POST', ['phone' => '15551234567'])),
812
+ $controller->verifyCode(Request::create('/v1/customers/verify', 'POST', ['identity' => 'jane@example.test', 'code' => '123456'])),
813
+ ];
814
+
815
+ foreach ($responses as $response) {
816
+ expect($response->getStatusCode())->toBe(403)
817
+ ->and(fleetopsApiCustomerJson($response))->toBe(['error' => 'This customer login has been deactivated.']);
818
+ }
819
+
820
+ // No verification code is sent
821
+ expect($controller->smsVerifications)->toBe([]);
822
+ });
823
+
803
824
  test('api customer controller resets forgotten passwords', function () {
804
825
  $controller = fleetopsApiCustomerController();
805
826
  $forgot = $controller->forgotPassword(Request::create('/v1/customers/forgot-password', 'POST', [
@@ -30,6 +30,8 @@ class FleetOpsApiDriverControllerProbe extends DriverController
30
30
  public array $deviceCreates = [];
31
31
  public array $companyCalls = [];
32
32
  public array $createdUsers = [];
33
+ public array $resolvedAccounts = [];
34
+ public ?Throwable $accountConflict = null;
33
35
  public array $uuidLookups = [];
34
36
  public array $relationLookups = [];
35
37
  public array $relationCompanyScopes = [];
@@ -65,11 +67,17 @@ class FleetOpsApiDriverControllerProbe extends DriverController
65
67
  return $userDetails;
66
68
  }
67
69
 
68
- protected function createUser(array $userDetails): User
70
+ protected function resolveDriverAccount(string $companyUuid, array $userDetails): User
69
71
  {
70
- $this->createdUsers[] = $userDetails;
72
+ $this->resolvedAccounts[] = $companyUuid;
73
+ $this->createdUsers[] = $userDetails;
74
+
75
+ if ($this->accountConflict) {
76
+ throw $this->accountConflict;
77
+ }
78
+
71
79
  $this->user ??= new FleetOpsApiDriverUserFake();
72
- $this->user->setRawAttributes(array_merge(['uuid' => 'user-uuid'], $userDetails), true);
80
+ $this->user->setRawAttributes(array_merge(['uuid' => 'user-uuid', 'type' => 'driver'], $userDetails), true);
73
81
 
74
82
  return $this->user;
75
83
  }
@@ -282,7 +290,17 @@ class FleetOpsApiDriverUserFake extends User
282
290
  return true;
283
291
  }
284
292
 
285
- public function assignCompany(Company $company, string $role = 'Administrator'): User
293
+ public function save(array $options = []): bool
294
+ {
295
+ $this->updates[] = $this->getDirty();
296
+ $this->syncOriginal();
297
+
298
+ return true;
299
+ }
300
+
301
+ // Matches core-api 1.6.63+, where no role is granted unless one is given (a nullable
302
+ // role is also compatible with older core-api, whose parameter was a plain string).
303
+ public function assignCompany(Company $company, ?string $role = null): User
286
304
  {
287
305
  $this->assignedCompanies[] = $company->uuid;
288
306
 
@@ -317,6 +335,27 @@ class FleetOpsApiDriverUserFake extends User
317
335
  }
318
336
  }
319
337
 
338
+ /**
339
+ * ProfileAccountManager checks a changed email/phone against the users table.
340
+ */
341
+ function fleetopsApiDriverContractsUsersTable(array $rows = []): void
342
+ {
343
+ $connection = new Illuminate\Database\SQLiteConnection(new PDO('sqlite::memory:'));
344
+ $connection->getSchemaBuilder()->create('users', function ($table) {
345
+ $table->string('uuid')->nullable();
346
+ $table->string('type')->nullable();
347
+ $table->string('email')->nullable();
348
+ $table->string('phone')->nullable();
349
+ $table->timestamp('deleted_at')->nullable();
350
+ });
351
+ if ($rows) {
352
+ $connection->table('users')->insert($rows);
353
+ }
354
+ $resolver = new Illuminate\Database\ConnectionResolver(['default' => $connection, 'mysql' => $connection]);
355
+ $resolver->setDefaultConnection('mysql');
356
+ Illuminate\Database\Eloquent\Model::setConnectionResolver($resolver);
357
+ }
358
+
320
359
  class FleetOpsApiDriverVehicleFake extends Vehicle
321
360
  {
322
361
  public array $quietUpdates = [];
@@ -363,16 +402,15 @@ test('api driver controller creates drivers with user company assignment and rel
363
402
 
364
403
  expect($response)->toBe(['resource' => 'driver', 'driver' => $controller->driver])
365
404
  ->and($controller->companyCalls)->toBe([['request', 'company_public']])
405
+ // The login account is resolved by ProfileAccountManager in the driver's company
406
+ ->and($controller->resolvedAccounts)->toBe(['company-uuid'])
366
407
  ->and($controller->createdUsers[0])->toMatchArray([
367
- 'name' => 'Driver One',
368
- 'email' => 'driver@example.test',
369
- 'phone' => '+15551234567',
370
- 'company_uuid' => 'company-uuid',
371
- 'applied' => true,
408
+ 'name' => 'Driver One',
409
+ 'email' => 'driver@example.test',
410
+ 'phone' => '+15551234567',
411
+ 'password' => 'secret-password',
412
+ 'applied' => true,
372
413
  ])
373
- ->and($controller->user->assignedCompanies)->toBe(['company-uuid'])
374
- ->and($controller->user->assignedTypes)->toBe(['driver'])
375
- ->and($controller->user->assignedRoles)->toBe(['Driver'])
376
414
  ->and($controller->createdDrivers[0])->toMatchArray([
377
415
  'status' => 'available',
378
416
  'vehicle_uuid' => 'vehicle-uuid',
@@ -407,8 +445,10 @@ test('api driver controller reports missing company before creating drivers', fu
407
445
  });
408
446
 
409
447
  test('api driver controller updates drivers user details assignments location and photo', function () {
448
+ fleetopsApiDriverContractsUsersTable();
449
+
410
450
  $user = new FleetOpsApiDriverUserFake();
411
- $user->setRawAttributes(['uuid' => 'user-uuid'], true);
451
+ $user->setRawAttributes(['uuid' => 'user-uuid', 'type' => 'driver'], true);
412
452
 
413
453
  $driver = new FleetOpsApiDriverFake();
414
454
  $driver->setRawAttributes([
@@ -645,7 +685,7 @@ test('api driver controller refuses to set a password through a general update',
645
685
  * and now has its own endpoint that demands the current password.
646
686
  */
647
687
  $user = new FleetOpsApiDriverUserFake();
648
- $user->setRawAttributes(['uuid' => 'user-uuid'], true);
688
+ $user->setRawAttributes(['uuid' => 'user-uuid', 'type' => 'driver'], true);
649
689
  $driver = new FleetOpsApiDriverFake();
650
690
  $driver->setRawAttributes([
651
691
  'uuid' => 'driver-uuid',
@@ -759,11 +799,9 @@ test('api driver controller creates an operational driver with no email or phone
759
799
  'company_uuid' => 'company-uuid',
760
800
  'status' => 'available',
761
801
  ])
762
- // The Driver-to-User relationship, organization membership, user type
763
- // and role are all preserved for a credential-less driver.
764
- ->and($controller->user->assignedCompanies)->toBe(['company-uuid'])
765
- ->and($controller->user->assignedTypes)->toBe(['driver'])
766
- ->and($controller->user->assignedRoles)->toBe(['Driver']);
802
+ // The Driver-to-User relationship and organization membership are
803
+ // preserved for a credential-less driver.
804
+ ->and($controller->resolvedAccounts)->toBe(['company-uuid']);
767
805
  });
768
806
 
769
807
  test('api driver controller creates a driver with only one contact method', function () {
@@ -873,7 +911,7 @@ test('api driver controller copies timezone through to the linked user account',
873
911
  ]));
874
912
 
875
913
  $user = new FleetOpsApiDriverUserFake();
876
- $user->setRawAttributes(['uuid' => 'user-uuid'], true);
914
+ $user->setRawAttributes(['uuid' => 'user-uuid', 'type' => 'driver'], true);
877
915
 
878
916
  $driver = new FleetOpsApiDriverFake();
879
917
  $driver->setRawAttributes(['uuid' => 'driver-uuid', 'public_id' => 'driver_public', 'user_uuid' => 'user-uuid'], true);
@@ -905,3 +943,47 @@ test('api driver controller only expands relationships the public contract allow
905
943
  // name is dropped rather than reaching Eloquent, where it would be a 500.
906
944
  expect($request->input('with'))->toBe(['vehicle', 'currentJob']);
907
945
  });
946
+
947
+ test('api driver controller answers 422 when the email or phone belongs to another account', function () {
948
+ $create = new FleetOpsApiDriverControllerProbe();
949
+ $create->accountConflict = new Fleetbase\FleetOps\Exceptions\ProfileIdentityConflictException('This email is already in use by another account.');
950
+
951
+ expect($create->create(new CreateDriverRequest(['name' => 'Driver One', 'email' => 'taken@example.test'])))->toBe([
952
+ 'json' => ['error' => 'This email is already in use by another account.'],
953
+ 'status' => 422,
954
+ ])
955
+ ->and($create->createdDrivers)->toBe([]);
956
+
957
+ fleetopsApiDriverContractsUsersTable([['uuid' => 'other-user', 'type' => 'user', 'phone' => '+15550009999']]);
958
+
959
+ $user = new FleetOpsApiDriverUserFake();
960
+ $user->setRawAttributes(['uuid' => 'user-uuid', 'type' => 'driver', 'phone' => '+15550001111'], true);
961
+ $driver = new FleetOpsApiDriverFake();
962
+ $driver->setRawAttributes(['uuid' => 'driver-uuid', 'public_id' => 'driver_public', 'user_uuid' => 'user-uuid'], true);
963
+ $driver->userForTest = $user;
964
+
965
+ $update = new FleetOpsApiDriverControllerProbe();
966
+ $update->driver = $driver;
967
+
968
+ expect($update->update('driver_public', new UpdateDriverRequest(['phone' => '+1 555 000 9999'])))->toBe([
969
+ 'json' => ['error' => 'This phone number is already in use by another account.'],
970
+ 'status' => 422,
971
+ ])
972
+ ->and($user->updates)->toBe([])
973
+ ->and($driver->updates)->toBe([]);
974
+ });
975
+
976
+ test('api driver controller only renames a team member account linked to a driver', function () {
977
+ $staff = new FleetOpsApiDriverUserFake();
978
+ $staff->setRawAttributes(['uuid' => 'staff-uuid', 'type' => 'user', 'name' => 'Staff', 'email' => 'staff@example.test'], true);
979
+ $driver = new FleetOpsApiDriverFake();
980
+ $driver->setRawAttributes(['uuid' => 'driver-uuid', 'public_id' => 'driver_public', 'user_uuid' => 'staff-uuid'], true);
981
+ $driver->userForTest = $staff;
982
+
983
+ $update = new FleetOpsApiDriverControllerProbe();
984
+ $update->driver = $driver;
985
+ $update->update('driver_public', new UpdateDriverRequest(['name' => 'Staff Driver', 'email' => 'changed@example.test']));
986
+
987
+ expect($staff->updates)->toBe([['name' => 'Staff Driver']])
988
+ ->and($staff->email)->toBe('staff@example.test');
989
+ });
@@ -884,7 +884,7 @@ class FleetOpsUserCommandFake
884
884
  return true;
885
885
  }
886
886
 
887
- public function assignCompany(Company $company, string $role = 'Administrator'): self
887
+ public function assignCompany(Company $company, ?string $role = null): self
888
888
  {
889
889
  $this->assigned[] = [$company->uuid, $role];
890
890
 
@@ -2208,8 +2208,8 @@ test('fix customer companies command creates or links users and assigns missing
2208
2208
  ['email', FleetOpsCustomerCommandFake::class],
2209
2209
  ['phone', FleetOpsCustomerCommandFake::class],
2210
2210
  ])
2211
- ->and($createdUser->assigned)->toBe([['company-created', 'Administrator']])
2212
- ->and($existingUser->assigned)->toBe([['company-existing', 'Administrator']]);
2211
+ ->and($createdUser->assigned)->toBe([['company-created', 'Fleet-Ops Customer']])
2212
+ ->and($existingUser->assigned)->toBe([['company-existing', 'Fleet-Ops Customer']]);
2213
2213
  });
2214
2214
 
2215
2215
  test('fix driver companies command syncs assigned users and missing company assignments', function () {
@@ -2236,7 +2236,7 @@ test('fix driver companies command syncs assigned users and missing company assi
2236
2236
  ['email', Driver::class],
2237
2237
  ['phone', Driver::class],
2238
2238
  ])
2239
- ->and($user->assigned)->toBe([['driver-company', 'Administrator']])
2239
+ ->and($user->assigned)->toBe([['driver-company', 'Driver']])
2240
2240
  ->and($command->messages)->toContain(['line', 'Found driver Driver User (driver@example.test) which doesnt have correct company assignment.'])
2241
2241
  ->and($command->messages)->toContain(['line', 'Driver driver@example.test was assigned to company: Driver Company']);
2242
2242
  });
@@ -254,7 +254,6 @@ class FleetOpsZoneControllerProbe extends ZoneController
254
254
  class FleetOpsInternalContactControllerProbe extends InternalContactController
255
255
  {
256
256
  public array $installedPackages = [];
257
- public array $resolvedUsers = [];
258
257
  public array $sentCredentials = [];
259
258
  public array $savedMetas = [];
260
259
  public ?User $contactUser = null;
@@ -269,13 +268,6 @@ class FleetOpsInternalContactControllerProbe extends InternalContactController
269
268
  return $reflection->invoke($this, ...$arguments);
270
269
  }
271
270
 
272
- protected function resolveUserUuid(string $user): string
273
- {
274
- $this->resolvedUsers[] = $user;
275
-
276
- return 'resolved-' . $user;
277
- }
278
-
279
271
  protected function installedFleetbaseExtensions(): array
280
272
  {
281
273
  return $this->installedPackages;
@@ -1607,7 +1599,7 @@ test('internal contact controller create update and after save hooks protect cus
1607
1599
  ],
1608
1600
  ],
1609
1601
  ]);
1610
- $input = ['type' => 'contact', 'user' => ['id' => 'user_public']];
1602
+ $input = ['type' => 'contact', 'user' => ['id' => 'user_public'], 'user_uuid' => 'user-uuid'];
1611
1603
 
1612
1604
  $controller->onBeforeCreate($request, $input);
1613
1605
 
@@ -1623,8 +1615,8 @@ test('internal contact controller create update and after save hooks protect cus
1623
1615
 
1624
1616
  $controller->afterSave($request, $contact);
1625
1617
 
1626
- expect($input)->toBe(['type' => 'contact', 'user_uuid' => 'resolved-user_public'])
1627
- ->and($controller->resolvedUsers)->toBe(['user_public'])
1618
+ // The login account is managed by the profile, so a user can't be picked from the console
1619
+ expect($input)->toBe(['type' => 'contact'])
1628
1620
  ->and($contact->normalized)->toBeTrue()
1629
1621
  ->and($contact->syncedCustomFields)->toBe([
1630
1622
  ['key' => 'tier', 'value' => 'gold'],
@@ -596,6 +596,11 @@ test('inbox completion waits for pending deliveries and aggregates quarantined f
596
596
  DB::table('telematic_deliveries')->where('uuid', $id)->update(['status' => 'quarantined', 'applied' => 2, 'failed' => 1]);
597
597
  Inbox::finishRun('run-pending');
598
598
  expect((array) DB::table('telematic_sync_runs')->first())->toMatchArray(['status' => 'partial', 'applied' => 2, 'failed' => 1]);
599
+
600
+ // A finished run whose connection was removed completes without touching connection meta
601
+ DB::table('telematic_sync_runs')->insert(['uuid' => 'run-orphan', 'telematic_uuid' => 'missing-connection', 'status' => 'ingesting', 'created_at' => now(), 'updated_at' => now()]);
602
+ Inbox::finishRun('run-orphan');
603
+ expect(DB::table('telematic_sync_runs')->where('uuid', 'run-orphan')->value('status'))->not->toBe('ingesting');
599
604
  });
600
605
 
601
606
  test('telemetry diagnostics report setup receipt backlog and degraded delivery states', function () {
@@ -230,6 +230,42 @@ test('password login rejects bad credentials and issues driver tokens', function
230
230
  ->and($connection->table('personal_access_tokens')->value('name'))->toBe('driver-1');
231
231
  });
232
232
 
233
+ test('deactivated driver logins are refused on every sign-in path', function () {
234
+ $connection = fleetopsDriverAuthBoot();
235
+ $controller = new DriverController();
236
+ $connection->table('users')->where('uuid', 'user-1')->update(['status' => 'inactive']);
237
+ app('hash')->checks = true;
238
+
239
+ // An unknown identity fails authentication rather than erroring on a null user
240
+ $unknown = $controller->login(Request::create('/x', 'POST', ['identity' => 'ghost@example.test', 'password' => 'secret']));
241
+ expect($unknown->getStatusCode())->toBe(401);
242
+
243
+ $password = $controller->login(Request::create('/x', 'POST', ['identity' => 'driver@example.test', 'password' => 'secret']));
244
+
245
+ app()->instance('request', Request::create('/x', 'POST', ['phone' => '6591234567']));
246
+ $phone = $controller->loginWithPhone();
247
+
248
+ $connection->table('verification_codes')->insert(['uuid' => 'vc-1', 'subject_uuid' => 'user-1', 'code' => '424242', 'for' => 'driver_login']);
249
+ $code = $controller->verifyCode(Request::create('/x', 'POST', ['identity' => '6591234567', 'code' => '424242']));
250
+
251
+ foreach ([$password, $phone, $code] as $response) {
252
+ expect($response->getStatusCode())->toBe(403)
253
+ ->and($response->getData(true)['error'])->toBe(DriverController::DEACTIVATED_LOGIN_MESSAGE);
254
+ }
255
+
256
+ // Password recovery answers as if the identity were unknown
257
+ $forgot = $controller->forgotPassword(Request::create('/x', 'POST', ['identity' => 'driver@example.test']));
258
+ $reset = $controller->resetPassword(Request::create('/x', 'POST', ['identity' => 'driver@example.test', 'code' => '424242', 'password' => 'new-secret-123']));
259
+
260
+ expect($forgot->getData(true))->toBe(['status' => 'ok'])
261
+ ->and($reset->getStatusCode())->toBe(422)
262
+ ->and($reset->getData(true)['error'])->toBe('Invalid or expired reset code.');
263
+
264
+ expect(DriverController::isLoginDeactivated(User::where('uuid', 'user-1')->first()))->toBeTrue()
265
+ ->and($connection->table('personal_access_tokens')->count())->toBe(0)
266
+ ->and($connection->table('verification_codes')->count())->toBe(1);
267
+ });
268
+
233
269
  test('phone login falls back to email verification and errors without channels', function () {
234
270
  $connection = fleetopsDriverAuthBoot();
235
271
  $controller = new DriverController();
@@ -106,7 +106,7 @@ function fleetopsDriverSessionHelpersBoot(): SQLiteConnection
106
106
  $schema = $connection->getSchemaBuilder();
107
107
  app()->instance('db.schema', $schema);
108
108
  $tables = [
109
- 'users' => ['uuid', 'public_id', 'company_uuid', 'name', 'email', 'phone', 'status', 'type', 'username', 'password'],
109
+ 'users' => ['uuid', 'public_id', 'company_uuid', 'name', 'email', 'phone', 'status', 'type', 'username', 'password', 'timezone', 'slug', 'meta'],
110
110
  'drivers' => ['uuid', 'public_id', 'internal_id', 'company_uuid', 'user_uuid', 'vehicle_uuid', 'vendor_uuid', 'status', 'online', 'location', 'meta'],
111
111
  'companies' => ['uuid', 'public_id', 'name', 'status', 'owner_uuid'],
112
112
  'user_devices' => ['uuid', 'public_id', 'user_uuid', 'token', 'platform', 'status'],
@@ -189,22 +189,35 @@ test('user and driver persistence helpers write records', function () {
189
189
  $userDetails = $probe->callHelper('applyUserInfoFromRequest', Request::create('/x', 'POST', []), ['name' => 'Applicant']);
190
190
  expect($userDetails['name'])->toBe('Applicant');
191
191
 
192
- $user = $probe->callHelper('createUser', ['name' => 'New User']);
192
+ // A company that isn't in the table skips the organization membership seam,
193
+ // which needs spatie roles; the managed account itself is still created.
194
+ $missingCompany = '33333333-3333-4333-8333-333333333333';
195
+
196
+ $user = $probe->callHelper('resolveDriverAccount', $missingCompany, ['name' => 'New User']);
193
197
  expect($user)->toBeInstanceOf(User::class)
194
- ->and($connection->table('users')->where('name', 'New User')->count())->toBe(1);
198
+ ->and($connection->table('users')->where('name', 'New User')->count())->toBe(1)
199
+ ->and($connection->table('users')->where('name', 'New User')->value('type'))->toBe('driver')
200
+ ->and($connection->table('users')->where('name', 'New User')->value('status'))->toBe('active');
195
201
 
196
202
  /*
197
203
  * `password` is guarded on User, so mass assignment drops it without a
198
204
  * word. The create endpoint has always documented and validated one, so
199
- * unless the helper sets it explicitly a driver created through the API
200
- * can never sign in with the password chosen for them.
205
+ * unless it is set explicitly a driver created through the API can never
206
+ * sign in with the password chosen for them.
201
207
  */
202
- $withPassword = $probe->callHelper('createUser', ['name' => 'Password User', 'password' => 'seeded-password']);
208
+ $withPassword = $probe->callHelper('resolveDriverAccount', $missingCompany, ['name' => 'Password User', 'password' => 'seeded-password']);
203
209
  $stored = $connection->table('users')->where('name', 'Password User')->value('password');
204
210
  expect(Hash::check('seeded-password', $withPassword->password))->toBeTrue()
205
211
  ->and($stored)->not->toBeNull()
206
212
  ->and($stored)->not->toBe('seeded-password');
207
213
 
214
+ // A team member of the company with the email is linked instead of duplicated
215
+ $connection->table('users')->insert(['uuid' => 'staff-1', 'company_uuid' => '22222222-2222-4222-8222-222222222222', 'name' => 'Staff', 'email' => 'staff@example.test', 'type' => 'user']);
216
+ $linked = $probe->callHelper('resolveDriverAccount', '22222222-2222-4222-8222-222222222222', ['name' => 'Staff Driver', 'email' => 'STAFF@example.test']);
217
+ expect($linked->uuid)->toBe('staff-1')
218
+ ->and($connection->table('users')->where('email', 'staff@example.test')->count())->toBe(1)
219
+ ->and($connection->table('users')->where('uuid', 'staff-1')->value('type'))->toBe('user');
220
+
208
221
  $driver = $probe->callHelper('createDriver', ['company_uuid' => '22222222-2222-4222-8222-222222222222', 'user_uuid' => 'user-1']);
209
222
  expect($driver)->toBeInstanceOf(Driver::class);
210
223