@fleetbase/fleetops-engine 0.6.68 → 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 (100) hide show
  1. package/RELEASE.md +11 -9
  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/modals/driver-form.hbs +2 -2
  10. package/addon/components/modals/reset-profile-credentials.hbs +23 -0
  11. package/addon/components/modals/reset-profile-credentials.js +70 -0
  12. package/addon/components/profile-identity-hint.hbs +13 -0
  13. package/addon/components/profile-identity-hint.js +49 -0
  14. package/addon/controllers/management/drivers/index.js +4 -0
  15. package/addon/extension.js +4 -2
  16. package/addon/services/contact-actions.js +50 -89
  17. package/addon/services/driver-actions.js +98 -0
  18. package/addon/utils/profile-login-actions.js +172 -0
  19. package/app/components/modals/{reset-customer-credentials.js → reset-profile-credentials.js} +1 -1
  20. package/app/components/profile-identity-hint.js +1 -0
  21. package/composer.json +1 -1
  22. package/extension.json +1 -1
  23. package/package.json +3 -3
  24. package/scripts/pest-bootstrap.php +12 -0
  25. package/server/migrations/2026_09_21_000001_convert_profile_only_users_to_managed_accounts.php +132 -0
  26. package/server/resources/views/mail/driver-credentials.blade.php +21 -0
  27. package/server/src/Console/Commands/FixCustomerCompanies.php +1 -1
  28. package/server/src/Console/Commands/FixDriverCompanies.php +1 -1
  29. package/server/src/Exceptions/ProfileIdentityConflictException.php +34 -0
  30. package/server/src/Http/Controllers/Api/v1/CustomerController.php +14 -0
  31. package/server/src/Http/Controllers/Api/v1/DriverController.php +56 -39
  32. package/server/src/Http/Controllers/Internal/v1/ContactController.php +5 -17
  33. package/server/src/Http/Controllers/Internal/v1/CustomerController.php +40 -4
  34. package/server/src/Http/Controllers/Internal/v1/DriverController.php +182 -162
  35. package/server/src/Http/Controllers/Internal/v1/FleetOpsLookupController.php +47 -0
  36. package/server/src/Http/Controllers/Internal/v1/OrderController.php +156 -27
  37. package/server/src/Http/Requests/CreateDriverRequest.php +6 -2
  38. package/server/src/Http/Requests/Internal/CreateDriverRequest.php +9 -18
  39. package/server/src/Http/Resources/v1/Contact.php +3 -0
  40. package/server/src/Http/Resources/v1/Driver.php +3 -0
  41. package/server/src/Mail/DriverCredentialsMail.php +55 -0
  42. package/server/src/Models/Contact.php +55 -90
  43. package/server/src/Models/Sensor.php +3 -5
  44. package/server/src/Observers/ContactObserver.php +2 -22
  45. package/server/src/Observers/DriverObserver.php +5 -6
  46. package/server/src/Providers/FleetOpsServiceProvider.php +15 -0
  47. package/server/src/Support/Ai/Capabilities/AbstractFleetOpsAICapability.php +62 -5
  48. package/server/src/Support/Ai/Capabilities/AssetStatusCapability.php +24 -5
  49. package/server/src/Support/Ai/Capabilities/CreateOrderPreviewCapability.php +6 -2
  50. package/server/src/Support/Ai/Capabilities/OrderInsightsCapability.php +40 -7
  51. package/server/src/Support/Ai/Capabilities/SearchResourcesCapability.php +86 -24
  52. package/server/src/Support/Ai/FleetOpsAiConsoleCommands.php +122 -0
  53. package/server/src/Support/Ai/Tools/CreateOrderTool.php +86 -0
  54. package/server/src/Support/Ai/Tools/ImportOrdersTool.php +53 -0
  55. package/server/src/Support/Ai/Tools/OptimizeOrderRouteTool.php +70 -0
  56. package/server/src/Support/Ai/Tools/SearchResourcesTool.php +73 -0
  57. package/server/src/Support/ProfileAccountManager.php +405 -0
  58. package/server/src/routes.php +5 -0
  59. package/server/tests/AiOperationalQueryCapabilityTest.php +1 -0
  60. package/server/tests/ApiCustomerControllerContractsTest.php +21 -0
  61. package/server/tests/ApiDriverControllerContractsTest.php +102 -20
  62. package/server/tests/CommandContractsTest.php +4 -4
  63. package/server/tests/ControllerHelperContractsTest.php +3 -11
  64. package/server/tests/Feature/Http/AfaqyRealtimeIngestionTest.php +5 -0
  65. package/server/tests/Feature/Http/Api/DriverControllerAuthFlowsTest.php +36 -0
  66. package/server/tests/Feature/Http/Api/DriverControllerSessionHelpersTest.php +19 -6
  67. package/server/tests/Feature/Http/Api/ProfileOnlyUserConversionMigrationTest.php +201 -0
  68. package/server/tests/Feature/Http/Internal/ContactControllerEndpointsTest.php +6 -4
  69. package/server/tests/Feature/Http/Internal/CustomerControllerCredentialsTest.php +6 -2
  70. package/server/tests/Feature/Http/Internal/DriverControllerContractsTest.php +23 -0
  71. package/server/tests/Feature/Http/Internal/DriverControllerCreateRecordTest.php +42 -17
  72. package/server/tests/Feature/Http/Internal/DriverControllerExistingUserAdoptionTest.php +168 -277
  73. package/server/tests/Feature/Http/Internal/DriverControllerLoginManagementTest.php +716 -0
  74. package/server/tests/Feature/Http/Internal/OrderControllerContractsTest.php +10 -3
  75. package/server/tests/Feature/Http/Internal/OrderControllerImportFromFilesTest.php +18 -0
  76. package/server/tests/Feature/Http/Internal/OrderControllerTenantScopingTest.php +411 -0
  77. package/server/tests/Feature/Http/Internal/OrderControllerUpstreamNotFoundTest.php +8 -20
  78. package/server/tests/InternalCustomerControllerContractsTest.php +32 -1
  79. package/server/tests/ModelAccessorContractsTest.php +82 -20
  80. package/server/tests/ObserverContractsTest.php +27 -2
  81. package/server/tests/RequestContractsTest.php +19 -11
  82. package/server/tests/SupportJobAndAiCoverageTest.php +3 -3
  83. package/server/tests/TelematicControllerContractsTest.php +22 -4
  84. package/server/tests/Unit/Models/ContactCustomerUserTest.php +90 -14
  85. package/server/tests/Unit/Models/ContactTest.php +48 -5
  86. package/server/tests/Unit/Models/SensorTest.php +2 -2
  87. package/server/tests/Unit/Models/SmallModelSurfacesTest.php +31 -32
  88. package/server/tests/Unit/Observers/ObserverListenerSeamsTest.php +7 -2
  89. package/server/tests/Unit/Providers/FleetOpsAiRegistrationTest.php +15 -2
  90. package/server/tests/Unit/Support/Ai/AssetStatusCapabilityQueriesTest.php +42 -0
  91. package/server/tests/Unit/Support/Ai/Capabilities/OrderInsightsCapabilityTest.php +19 -1
  92. package/server/tests/Unit/Support/Ai/Capabilities/SearchResourcesCapabilityTest.php +85 -1
  93. package/server/tests/Unit/Support/Ai/FleetOpsAiConsoleCommandsTest.php +31 -0
  94. package/server/tests/Unit/Support/Ai/Tools/FleetOpsAiToolsTest.php +246 -0
  95. package/server/tests/Unit/Support/AiCapabilityPermissionsTest.php +3 -3
  96. package/server/tests/Unit/Support/ProfileAccountManagerTest.php +643 -0
  97. package/translations/en-us.yaml +37 -2
  98. package/translations/uk-ua.yaml +3475 -0
  99. package/addon/components/modals/reset-customer-credentials.hbs +0 -18
  100. package/addon/components/modals/reset-customer-credentials.js +0 -55
@@ -130,14 +130,51 @@ class FleetOpsContactSyncUserFake extends Fleetbase\Models\User
130
130
  return true;
131
131
  }
132
132
 
133
+ public function save(array $options = []): bool
134
+ {
135
+ $this->updates[] = $this->getDirty();
136
+ $this->syncOriginal();
137
+
138
+ return true;
139
+ }
140
+
133
141
  public function delete()
134
142
  {
135
143
  $this->deleted = true;
144
+ $this->setAttribute('deleted_at', '2026-01-01 00:00:00');
136
145
 
137
146
  return true;
138
147
  }
139
148
  }
140
149
 
150
+ function fleetopsModelAccessorsUseProfileAccountTables(): void
151
+ {
152
+ $connection = new SQLiteConnection(new PDO('sqlite::memory:'));
153
+ $schema = $connection->getSchemaBuilder();
154
+ $schema->create('users', function ($table) {
155
+ $table->string('uuid')->nullable();
156
+ $table->string('type')->nullable();
157
+ $table->string('email')->nullable();
158
+ $table->string('phone')->nullable();
159
+ $table->timestamp('deleted_at')->nullable();
160
+ });
161
+ foreach (['drivers', 'contacts'] as $profileTable) {
162
+ $schema->create($profileTable, function ($table) {
163
+ $table->string('uuid')->nullable();
164
+ $table->string('user_uuid')->nullable();
165
+ $table->string('company_uuid')->nullable();
166
+ $table->timestamp('deleted_at')->nullable();
167
+ });
168
+ }
169
+
170
+ $resolver = new ConnectionResolver([
171
+ 'default' => $connection,
172
+ 'mysql' => $connection,
173
+ ]);
174
+ $resolver->setDefaultConnection('mysql');
175
+ EloquentModel::setConnectionResolver($resolver);
176
+ }
177
+
141
178
  class FleetOpsContactAccessorFake extends Contact
142
179
  {
143
180
  public array $loadedMissing = [];
@@ -858,6 +895,8 @@ test('contact accessors imports notifications and customer identity helpers are
858
895
  });
859
896
 
860
897
  test('contact user sync lookup and deletion guards are stable', function () {
898
+ fleetopsModelAccessorsUseProfileAccountTables();
899
+
861
900
  $user = new FleetOpsContactSyncUserFake();
862
901
  $user->setRawAttributes([
863
902
  'uuid' => 'user-uuid',
@@ -868,22 +907,25 @@ test('contact user sync lookup and deletion guards are stable', function () {
868
907
  ], true);
869
908
 
870
909
  $contact = new FleetOpsContactAccessorFake();
910
+ $contact->exists = true;
871
911
  $contact->fakeUser = $user;
872
912
  $contact->setRawAttributes([
873
- 'uuid' => 'contact-uuid',
874
- 'user_uuid' => 'not-a-uuid',
875
- 'type' => 'customer',
876
- 'name' => 'Old Name',
877
- 'email' => 'old@example.com',
878
- 'phone' => '+15550000000',
879
- 'timezone' => 'UTC',
913
+ 'uuid' => 'contact-uuid',
914
+ 'company_uuid' => 'company-uuid',
915
+ 'user_uuid' => 'not-a-uuid',
916
+ 'type' => 'customer',
917
+ 'name' => 'Old Name',
918
+ 'email' => 'old@example.com',
919
+ 'phone' => '+15550000000',
920
+ 'timezone' => 'UTC',
880
921
  ], true);
881
922
 
882
923
  $contact->name = 'New Name';
883
- $contact->email = 'new@example.com';
884
- $contact->phone = '+15551112222';
924
+ $contact->email = 'New@Example.com';
925
+ $contact->phone = '+1 555-111-2222';
885
926
  $contact->timezone = 'Asia/Singapore';
886
927
 
928
+ // Proxy fields are normalized and pushed to the managed account
887
929
  expect($contact->syncWithUser())->toBeTrue()
888
930
  ->and($user->updates[0])->toBe([
889
931
  'name' => 'New Name',
@@ -895,26 +937,46 @@ test('contact user sync lookup and deletion guards are stable', function () {
895
937
  ->and($contact->hasUser())->toBeTrue()
896
938
  ->and($contact->doesntHaveUser())->toBeFalse();
897
939
 
898
- $deleteContact = new FleetOpsContactAccessorFake();
899
- $deleteContact->type = 'customer';
900
- $deleteContact->setRelation('user', $user);
940
+ // An unsaved contact's account was just resolved from these values
941
+ $newContact = new FleetOpsContactAccessorFake();
942
+ $newContact->fakeUser = $user;
943
+ $newContact->name = 'Brand New';
944
+
945
+ // A saved contact with no proxy changes has nothing to sync
946
+ $cleanContact = new FleetOpsContactAccessorFake();
947
+ $cleanContact->exists = true;
948
+ $cleanContact->fakeUser = $user;
949
+ $cleanContact->setRawAttributes(['name' => 'New Name', 'type' => 'customer'], true);
950
+
951
+ expect($newContact->syncWithUser())->toBeFalse()
952
+ ->and($cleanContact->syncWithUser())->toBeFalse()
953
+ ->and($user->updates)->toHaveCount(1);
954
+
955
+ // Deleting the only profile holding a managed account deletes the account
956
+ $deleteContact = new FleetOpsContactAccessorFake();
957
+ $deleteContact->type = 'customer';
958
+ $deleteContact->company_uuid = 'company-uuid';
959
+ $deleteContact->fakeUser = $user;
901
960
 
902
961
  expect($deleteContact->deleteUser())->toBeTrue()
903
- ->and($user->deleted)->toBeTrue()
904
- ->and($deleteContact->loadedMissing)->toBe(['user']);
962
+ ->and($user->deleted)->toBeTrue();
905
963
 
906
- $mismatchedUser = new FleetOpsContactSyncUserFake();
907
- $mismatchedUser->setRawAttributes(['type' => 'admin'], true);
964
+ // A team member's account linked to the contact is never deleted
965
+ $staffUser = new FleetOpsContactSyncUserFake();
966
+ $staffUser->setRawAttributes(['uuid' => 'staff-uuid', 'type' => 'admin'], true);
908
967
 
909
- $mismatchedContact = new FleetOpsContactAccessorFake();
910
- $mismatchedContact->type = 'customer';
911
- $mismatchedContact->setRelation('user', $mismatchedUser);
968
+ $staffContact = new FleetOpsContactAccessorFake();
969
+ $staffContact->type = 'customer';
970
+ $staffContact->fakeUser = $staffUser;
912
971
 
913
972
  $emptyContact = new FleetOpsContactAccessorFake();
973
+ $emptyContact->exists = true;
914
974
  $emptyContact->fakeUser = null;
915
975
  $emptyContact->setRawAttributes(['user_uuid' => 'not-a-uuid'], true);
976
+ $emptyContact->name = 'Changed';
916
977
 
917
- expect($mismatchedContact->deleteUser())->toBeFalse()
978
+ expect($staffContact->deleteUser())->toBeFalse()
979
+ ->and($staffUser->deleted)->toBeFalse()
918
980
  ->and($emptyContact->syncWithUser())->toBeFalse()
919
981
  ->and($emptyContact->getUser())->toBeNull()
920
982
  ->and($emptyContact->hasUser())->toBeFalse()
@@ -263,6 +263,7 @@ class FleetOpsDriverObserverUserFake extends User
263
263
  public function delete()
264
264
  {
265
265
  $this->deleted = true;
266
+ $this->setAttribute('deleted_at', '2026-01-01 00:00:00');
266
267
 
267
268
  return true;
268
269
  }
@@ -1149,14 +1150,30 @@ test('contact observer prevents changing existing customer contact type', functi
1149
1150
  });
1150
1151
 
1151
1152
  test('driver observer defaults location unassigns related records and deletes driver users', function () {
1153
+ // releaseForProfile looks for the account's remaining driver/contact profiles
1154
+ $connection = new Illuminate\Database\SQLiteConnection(new PDO('sqlite::memory:'));
1155
+ $resolver = new Illuminate\Database\ConnectionResolver(['default' => $connection, 'mysql' => $connection]);
1156
+ $resolver->setDefaultConnection('mysql');
1157
+ Illuminate\Database\Eloquent\Model::setConnectionResolver($resolver);
1158
+ foreach (['drivers', 'contacts'] as $profileTable) {
1159
+ $connection->getSchemaBuilder()->create($profileTable, function ($table) {
1160
+ $table->string('uuid')->nullable();
1161
+ $table->string('user_uuid')->nullable();
1162
+ $table->string('company_uuid')->nullable();
1163
+ $table->timestamp('deleted_at')->nullable();
1164
+ });
1165
+ }
1166
+
1152
1167
  $driver = new Driver();
1153
1168
  $driver->setRawAttributes([
1154
- 'uuid' => 'driver-uuid',
1155
- 'user_uuid' => 'user-uuid',
1169
+ 'uuid' => 'driver-uuid',
1170
+ 'user_uuid' => 'user-uuid',
1171
+ 'company_uuid' => 'company-uuid',
1156
1172
  ], true);
1157
1173
 
1158
1174
  $observer = new FleetOpsDriverObserverProbe();
1159
1175
  $user = new FleetOpsDriverObserverUserFake();
1176
+ $user->setRawAttributes(['uuid' => 'user-uuid', 'type' => 'driver'], true);
1160
1177
 
1161
1178
  $observer->user = $user;
1162
1179
  $observer->creating($driver);
@@ -1175,6 +1192,14 @@ test('driver observer defaults location unassigns related records and deletes dr
1175
1192
  ->and($observer->unassigned)->toBe(['driver-uuid'])
1176
1193
  ->and($user->deleted)->toBeTrue();
1177
1194
 
1195
+ // A team member's account linked to the driver is never deleted
1196
+ $staff = new FleetOpsDriverObserverUserFake();
1197
+ $staff->setRawAttributes(['uuid' => 'staff-uuid', 'type' => 'user'], true);
1198
+ $observer->user = $staff;
1199
+ $observer->deleted($driver);
1200
+
1201
+ expect($staff->deleted)->toBeFalse();
1202
+
1178
1203
  $driverWithLocation = new Driver();
1179
1204
  $location = new Fleetbase\LaravelMysqlSpatial\Types\Point(1.3, 103.8);
1180
1205
  $driverWithLocation->location = $location;
@@ -534,10 +534,13 @@ namespace {
534
534
  $request = new InternalCreateDriverRequest();
535
535
  $updateProbe = new FleetOpsInternalUpdateDriverRequestProbe();
536
536
 
537
+ // Email/phone availability is decided by ProfileAccountManager (a team
538
+ // member of the company is linked), so they're optional and not unique here.
537
539
  expect(ruleStrings($createRules['name']))->toContain('required', 'nullable', 'string', 'max:255')
538
- ->and(ruleStrings($createRules['email']))->toContain('required')
539
- ->and(ruleStrings($createRules['phone']))->toContain('required')
540
- ->and(ruleStrings($userRules['name']))->not->toContain('required')
540
+ ->and(ruleStrings($createRules['email']))->toBe(['nullable'])
541
+ ->and(ruleStrings($createRules['phone']))->toBe(['nullable', 'string'])
542
+ ->and(ruleStrings($userRules['name']))->toContain('required')
543
+ ->and(ruleStrings(InternalCreateDriverRequest::create('/fleetops-test', 'POST', ['email' => 'driver@example.test'])->rules()['email']))->toBe(['nullable', 'email'])
541
544
  ->and(ruleStrings($patchRules['name']))->not->toContain('required')
542
545
  ->and($updateProbe->authorize())->toBeFalse();
543
546
 
@@ -556,9 +559,10 @@ namespace {
556
559
  ])
557
560
  ->and($request->messages())->toMatchArray([
558
561
  'name.required' => 'Driver name is required.',
559
- 'email.required' => 'Email address is required.',
562
+ 'email.email' => 'Please provide a valid email address.',
560
563
  'password.min' => 'Password must be at least 8 characters.',
561
- ]);
564
+ ])
565
+ ->and($request->messages())->not->toHaveKeys(['email.required', 'email.unique', 'phone.required', 'phone.unique']);
562
566
  });
563
567
 
564
568
  test('public create order request validates payload alternatives and pod methods', function () {
@@ -929,12 +933,16 @@ namespace {
929
933
  expect($request->authorize())->toBeTrue()
930
934
  ->and(ruleStrings($createRules['name']))->toContain('required')
931
935
  ->and(ruleStrings($patchRules['name']))->not->toContain('required')
932
- // Contact details are optional but still validated and still unique:
933
- // an operational driver record may legitimately have neither.
934
- ->and(ruleStrings($createRules['email']))->toContain('nullable', 'email', 'unique:users')
935
- ->and(ruleStrings($createRules['email']))->not->toContain('required')
936
- ->and(ruleStrings($createRules['phone']))->toContain('nullable', 'unique:users')
937
- ->and(ruleStrings($createRules['phone']))->not->toContain('required')
936
+ // Contact details are optional but still validated: an operational
937
+ // driver record may legitimately have neither. On create, availability
938
+ // is decided by ProfileAccountManager (a team member is linked); on
939
+ // update they must stay unique among users.
940
+ ->and(ruleStrings($createRules['email']))->toContain('nullable', 'email')
941
+ ->and(ruleStrings($createRules['email']))->not->toContain('required', 'unique:users')
942
+ ->and(ruleStrings($createRules['phone']))->toContain('nullable')
943
+ ->and(ruleStrings($createRules['phone']))->not->toContain('required', 'unique:users')
944
+ ->and(ruleStrings($patchRules['email']))->toContain('nullable', 'unique:users')
945
+ ->and(ruleStrings($patchRules['phone']))->toContain('nullable', 'string', 'unique:users')
938
946
  ->and($createRules['password'])->toBe('nullable|string')
939
947
  ->and($createRules['country'])->toBe('nullable|size:2')
940
948
  ->and(ruleStrings($createRules['vehicle']))->toContain('nullable', 'string', 'starts_with:vehicle_', 'exists:vehicles,public_id')
@@ -889,8 +889,8 @@ test('search resources capability metadata prompts terms and denied branches are
889
889
  ->and($capability->shouldResolve($task))->toBeTrue()
890
890
  ->and($capability->promptMatches('tell me about sensor SENSOR-1'))->toBeTrue()
891
891
  ->and($capability->promptMatches('compose a friendly email'))->toBeFalse()
892
- ->and($capability->termsFor('Find order ORDER-123 for driver driver_456'))->toBe(['ORDER-123', 'for', 'driver_456'])
893
- ->and($capability->termsFor('show order'))->toBe(['show order'])
892
+ ->and($capability->termsFor('Find order ORDER-123 for driver driver_456'))->toBe(['ORDER-123', 'driver_456'])
893
+ ->and($capability->termsFor('show order'))->toBe([])
894
894
  ->and($capability->genericWhenDenied())->toBe([])
895
895
  ->and($capability->allResourceBranchesWhenDenied(['needle']))->toBe([
896
896
  'orders' => [],
@@ -903,7 +903,7 @@ test('search resources capability metadata prompts terms and denied branches are
903
903
  'telematics' => [],
904
904
  ])
905
905
  ->and($capability->resolve($task))->toBe([
906
- 'query_terms' => ['DRV-123', 'and', 'TRUCK_9'],
906
+ 'query_terms' => ['DRV-123', 'TRUCK_9'],
907
907
  'results' => [],
908
908
  ]);
909
909
  });
@@ -159,6 +159,7 @@ test('internal customer controller serializes portal login payloads', function (
159
159
  $customer->setRelation('user', (object) [
160
160
  'uuid' => 'user-uuid',
161
161
  'public_id' => 'user-public',
162
+ 'type' => 'customer',
162
163
  'name' => 'Ada Customer',
163
164
  'email' => 'ada@example.test',
164
165
  'phone' => '+15551234567',
@@ -168,10 +169,12 @@ test('internal customer controller serializes portal login payloads', function (
168
169
  ]);
169
170
 
170
171
  expect($controller->callHelper('customerPayload', $customer))->toBe([
171
- 'id' => 'customer-public',
172
- 'uuid' => 'customer-uuid',
173
- 'user_uuid' => 'user-uuid',
174
- 'user' => [
172
+ 'id' => 'customer-public',
173
+ 'uuid' => 'customer-uuid',
174
+ 'user_uuid' => 'user-uuid',
175
+ 'is_staff_linked' => false,
176
+ 'login_status' => 'active',
177
+ 'user' => [
175
178
  'id' => 'user-public',
176
179
  'uuid' => 'user-uuid',
177
180
  'name' => 'Ada Customer',
@@ -182,4 +185,19 @@ test('internal customer controller serializes portal login payloads', function (
182
185
  'avatar_url' => 'https://example.test/avatar.png',
183
186
  ],
184
187
  ]);
188
+
189
+ // A customer linked to a team member's account is reported as staff-linked
190
+ $customer->setRelation('user', (object) ['uuid' => 'staff-uuid', 'public_id' => 'staff-public', 'type' => 'user', 'status' => 'inactive']);
191
+ $staffPayload = $controller->callHelper('customerPayload', $customer);
192
+
193
+ expect($staffPayload['is_staff_linked'])->toBeTrue()
194
+ ->and($staffPayload['login_status'])->toBe('inactive');
195
+
196
+ // A customer without a login account is neither
197
+ $customer->setRelation('user', null);
198
+ $emptyPayload = $controller->callHelper('customerPayload', $customer);
199
+
200
+ expect($emptyPayload['is_staff_linked'])->toBeFalse()
201
+ ->and($emptyPayload['login_status'])->toBeNull()
202
+ ->and($emptyPayload['user'])->toBeNull();
185
203
  });
@@ -9,7 +9,7 @@ if (!Illuminate\Support\Str::hasMacro('humanize')) {
9
9
  }
10
10
 
11
11
  use Fleetbase\FleetOps\Exceptions\CustomerUserConflictException;
12
- use Fleetbase\FleetOps\Exceptions\UserAlreadyExistsException;
12
+ use Fleetbase\FleetOps\Exceptions\ProfileIdentityConflictException;
13
13
  use Fleetbase\FleetOps\Models\Contact;
14
14
  use Fleetbase\Models\User;
15
15
  use Illuminate\Database\ConnectionResolver;
@@ -75,9 +75,38 @@ function fleetopsContactCustomerUserBoot(): SQLiteConnection
75
75
  });
76
76
  Illuminate\Support\Facades\DB::clearResolvedInstance('db');
77
77
 
78
+ app()->instance('hash', new class implements Illuminate\Contracts\Hashing\Hasher {
79
+ public function info($hashedValue): array
80
+ {
81
+ return [];
82
+ }
83
+
84
+ public function make($value, array $options = []): string
85
+ {
86
+ return md5((string) $value);
87
+ }
88
+
89
+ public function check($value, $hashedValue, array $options = []): bool
90
+ {
91
+ return md5((string) $value) === $hashedValue;
92
+ }
93
+
94
+ public function needsRehash($hashedValue, array $options = []): bool
95
+ {
96
+ return false;
97
+ }
98
+
99
+ public function verifyConfiguration($value): bool
100
+ {
101
+ return true;
102
+ }
103
+ });
104
+ Illuminate\Support\Facades\Hash::clearResolvedInstance('hash');
105
+
78
106
  $schema = $connection->getSchemaBuilder();
79
107
  $tables = [
80
108
  'contacts' => ['uuid', 'public_id', 'company_uuid', 'user_uuid', 'name', 'email', 'phone', 'type', 'title'],
109
+ 'drivers' => ['uuid', 'public_id', 'company_uuid', 'user_uuid'],
81
110
  'users' => ['uuid', 'public_id', 'company_uuid', 'name', 'email', 'phone', 'username', 'password', 'timezone', 'status', 'type', 'slug', 'avatar_uuid', 'last_login'],
82
111
  'companies' => ['uuid', 'public_id', 'name', 'timezone', 'owner_uuid'],
83
112
  'company_users' => ['uuid', 'company_uuid', 'user_uuid', 'status'],
@@ -188,24 +217,45 @@ test('create user from contact adopts an existing matching user', function () {
188
217
  ->and($connection->table('users')->count())->toBe(1);
189
218
  });
190
219
 
191
- test('create user from contact rejects users already linked to another contact', function () {
220
+ test('create user from contact rejects an account that already holds a contact in the company', function () {
192
221
  $connection = fleetopsContactCustomerUserBoot();
193
- // The contact user() relation constrains users.type against the querying
194
- // contact type, which is null in the static whereHas context — a null-typed
195
- // user keeps the linked-contact subquery matchable.
196
- $connection->table('users')->insert(['uuid' => 'user-9', 'company_uuid' => 'company-1', 'email' => 'taken@example.com', 'type' => null]);
222
+ $connection->table('users')->insert(['uuid' => 'user-9', 'company_uuid' => 'company-1', 'email' => 'taken@example.com', 'type' => 'contact']);
197
223
  $connection->table('contacts')->insert(['uuid' => 'contact-owner', 'company_uuid' => 'company-1', 'user_uuid' => 'user-9', 'name' => 'Owner', 'type' => 'contact']);
198
224
  $contact = fleetopsContactCustomerUserContact(['uuid' => 'contact-2', 'email' => 'taken@example.com']);
199
225
 
200
- expect(fn () => Contact::createUserFromContact($contact))->toThrow(UserAlreadyExistsException::class);
226
+ expect(fn () => Contact::createUserFromContact($contact))
227
+ ->toThrow(ProfileIdentityConflictException::class, 'A contact with this email already exists.');
201
228
  });
202
229
 
203
- test('create user from contact rejects staff users for customer contacts', function () {
230
+ test('create user from contact rejects staff users of another organization for customer contacts', function () {
204
231
  $connection = fleetopsContactCustomerUserBoot();
205
- $connection->table('users')->insert(['uuid' => 'user-9', 'company_uuid' => 'company-1', 'email' => 'staff@example.com', 'type' => 'staff']);
232
+ $connection->table('users')->insert(['uuid' => 'user-9', 'company_uuid' => 'company-other', 'email' => 'staff@example.com', 'type' => 'user']);
206
233
  $contact = fleetopsContactCustomerUserContact(['type' => 'customer', 'email' => 'staff@example.com']);
207
234
 
208
- expect(fn () => Contact::createUserFromContact($contact))->toThrow(CustomerUserConflictException::class);
235
+ expect(fn () => Contact::createUserFromContact($contact))
236
+ ->toThrow(ProfileIdentityConflictException::class, 'This email is already in use by another account.');
237
+ });
238
+
239
+ test('create user from contact links a staff member of the organization without changing their role', function () {
240
+ $connection = fleetopsContactCustomerUserBoot();
241
+ $connection->table('users')->insert(['uuid' => 'user-9', 'company_uuid' => 'company-1', 'email' => 'staff@example.com', 'type' => 'user']);
242
+ $contact = fleetopsContactCustomerUserContact(['type' => 'customer', 'email' => 'Staff@Example.com']);
243
+
244
+ $user = Contact::createUserFromContact($contact);
245
+
246
+ expect($user->uuid)->toBe('user-9')
247
+ ->and($user->type)->toBe('user')
248
+ ->and($contact->user_uuid)->toBe('user-9')
249
+ ->and($connection->table('model_has_roles')->count())->toBe(0);
250
+ });
251
+
252
+ test('create user from contact rejects a managed account of another type', function () {
253
+ $connection = fleetopsContactCustomerUserBoot();
254
+ $connection->table('users')->insert(['uuid' => 'user-9', 'company_uuid' => 'company-1', 'phone' => '+6591112222', 'type' => 'driver']);
255
+ $contact = fleetopsContactCustomerUserContact(['type' => 'customer', 'phone' => '+65 9111-2222']);
256
+
257
+ expect(fn () => Contact::createUserFromContact($contact))
258
+ ->toThrow(ProfileIdentityConflictException::class, 'This phone number is already used by a driver.');
209
259
  });
210
260
 
211
261
  test('create user from contact assigns the customer role and persists the link', function () {
@@ -305,17 +355,27 @@ test('identity lookup helpers resolve users by email phone and uuid', function (
305
355
 
306
356
  test('assert customer identity is available detects staff conflicts', function () {
307
357
  $connection = fleetopsContactCustomerUserBoot();
308
- $connection->table('users')->insert(['uuid' => 'user-1', 'company_uuid' => 'company-1', 'email' => 'staff@example.com', 'type' => 'staff']);
358
+ $connection->table('users')->insert([
359
+ ['uuid' => 'user-1', 'company_uuid' => 'company-1', 'email' => 'staff@example.com', 'type' => 'staff'],
360
+ ['uuid' => 'user-2', 'company_uuid' => 'company-1', 'email' => 'driver@example.com', 'type' => 'driver'],
361
+ ]);
309
362
 
310
363
  $nonCustomer = fleetopsContactCustomerUserContact();
311
364
  $nonCustomer->assertCustomerIdentityIsAvailable();
312
365
 
313
- $conflicted = fleetopsContactCustomerUserContact(['type' => 'customer', 'email' => 'staff@example.com']);
314
- expect(fn () => $conflicted->assertCustomerIdentityIsAvailable())->toThrow(CustomerUserConflictException::class);
366
+ // A staff member of the organization can hold a customer profile
367
+ $staffMember = fleetopsContactCustomerUserContact(['type' => 'customer', 'email' => 'staff@example.com']);
368
+ $staffMember->assertCustomerIdentityIsAvailable();
369
+
370
+ // A driver's managed account cannot
371
+ $conflicted = fleetopsContactCustomerUserContact(['type' => 'customer', 'email' => 'driver@example.com']);
372
+ expect(fn () => $conflicted->assertCustomerIdentityIsAvailable())
373
+ ->toThrow(CustomerUserConflictException::class, 'This email is already used by a driver and cannot be used for a customer account.');
315
374
 
316
375
  // An already-assigned user is checked ahead of the identity lookup, so a
317
376
  // contact whose email matches nothing still trips on its own assignment
318
- $connection->table('users')->insert(['uuid' => '33333333-3333-4333-8333-333333333333', 'company_uuid' => 'company-1', 'email' => 'assigned@example.com', 'type' => 'staff']);
377
+ // (a staff account of another organization)
378
+ $connection->table('users')->insert(['uuid' => '33333333-3333-4333-8333-333333333333', 'company_uuid' => 'company-other', 'email' => 'assigned@example.com', 'type' => 'staff']);
319
379
  $assigned = fleetopsContactCustomerUserContact([
320
380
  'type' => 'customer',
321
381
  'email' => 'unmatched@example.com',
@@ -368,3 +428,19 @@ test('normalize customer user creates company membership and assigns the custome
368
428
  expect($connection->table('company_users')->where('user_uuid', 'user-n1')->count())->toBe(1)
369
429
  ->and($connection->table('model_has_roles')->count())->toBeGreaterThanOrEqual(1);
370
430
  });
431
+
432
+ test('normalize customer user keeps a staff member of the organization untouched', function () {
433
+ $connection = fleetopsContactCustomerUserBoot();
434
+ $connection->table('companies')->insert(['uuid' => 'company-1', 'name' => 'Acme']);
435
+ $connection->table('users')->insert(['uuid' => 'user-s1', 'company_uuid' => 'company-1', 'name' => 'Staff', 'email' => 'staff@example.com', 'type' => 'user', 'status' => 'active']);
436
+
437
+ $contact = fleetopsContactCustomerUserContact(['type' => 'customer', 'user_uuid' => 'user-s1']);
438
+ $user = User::where('uuid', 'user-s1')->first();
439
+
440
+ expect($contact->normalizeCustomerUser($user))->toBe($user)
441
+ ->and($contact->getRelation('user'))->toBe($user)
442
+ // A team member's account keeps its own organization membership and role
443
+ ->and($connection->table('company_users')->count())->toBe(0)
444
+ ->and($connection->table('model_has_roles')->count())->toBe(0)
445
+ ->and($connection->table('users')->where('uuid', 'user-s1')->value('type'))->toBe('user');
446
+ });
@@ -25,7 +25,8 @@ class FleetOpsContactUnitCompanyFake extends Company
25
25
  public array $addUserCalls = [];
26
26
  public ?FleetOpsContactUnitCompanyUserFake $companyUser = null;
27
27
 
28
- public function addUser(User $user, string $role = 'Administrator', string $status = 'active'): CompanyUser
28
+ // Matches core-api 1.6.63+, where no role is granted unless one is given.
29
+ public function addUser(User $user, ?string $role = null, string $status = 'active'): CompanyUser
29
30
  {
30
31
  $this->addUserCalls[] = [$user, $role, $status];
31
32
 
@@ -71,9 +72,18 @@ class FleetOpsContactUnitUserFake extends User
71
72
  return true;
72
73
  }
73
74
 
75
+ public function save(array $options = []): bool
76
+ {
77
+ $this->updates[] = $this->getDirty();
78
+ $this->syncOriginal();
79
+
80
+ return true;
81
+ }
82
+
74
83
  public function delete()
75
84
  {
76
85
  $this->deleted = true;
86
+ $this->setAttribute('deleted_at', '2026-01-01 00:00:00');
77
87
 
78
88
  return true;
79
89
  }
@@ -180,6 +190,16 @@ class FleetOpsContactUnitAssignableFake extends FleetOpsContactUnitFake
180
190
  }
181
191
  }
182
192
 
193
+ function fleetopsContactUnitUseProfileTables(): SQLiteConnection
194
+ {
195
+ $connection = fleetopsContactUnitUseInMemoryConnection();
196
+ $connection->statement('create table drivers (uuid varchar(64), user_uuid varchar(64) null, company_uuid varchar(64) null, deleted_at datetime null)');
197
+ $connection->statement('create table contacts (uuid varchar(64), user_uuid varchar(64) null, company_uuid varchar(64) null, deleted_at datetime null)');
198
+ $connection->statement('create table company_users (uuid varchar(64), user_uuid varchar(64) null, company_uuid varchar(64) null, created_at datetime null, updated_at datetime null, deleted_at datetime null)');
199
+
200
+ return $connection;
201
+ }
202
+
183
203
  function fleetopsContactUnitUseInMemoryConnection(): SQLiteConnection
184
204
  {
185
205
  $connection = new SQLiteConnection(new PDO('sqlite::memory:'));
@@ -496,6 +516,8 @@ test('contact user conflict helpers guard staff users and allow customers', func
496
516
  });
497
517
 
498
518
  test('contact sync delete and user presence helpers use loaded user relations', function () {
519
+ fleetopsContactUnitUseProfileTables();
520
+
499
521
  $user = new FleetOpsContactUnitUserFake();
500
522
  $user->setRawAttributes([
501
523
  'uuid' => 'user-uuid',
@@ -521,6 +543,7 @@ test('contact sync delete and user presence helpers use loaded user relations',
521
543
  ]);
522
544
  $contact->setRelation('user', $user);
523
545
  $contact->fakeUser = $user;
546
+ $contact->exists = true;
524
547
 
525
548
  expect($contact->syncWithUser())->toBeTrue()
526
549
  ->and($user->updates)->toBe([[
@@ -543,20 +566,40 @@ test('contact sync delete and user presence helpers use loaded user relations',
543
566
  ->and($unlinked->doesntHaveUser())->toBeTrue();
544
567
  });
545
568
 
546
- test('contact delete user ignores mismatched relation types', function () {
569
+ test('contact delete user leaves team member accounts alone', function () {
547
570
  $user = new FleetOpsContactUnitUserFake();
548
571
  $user->setRawAttributes([
549
572
  'uuid' => 'user-uuid',
550
- 'type' => 'customer',
573
+ 'type' => 'user',
551
574
  ], true);
552
575
 
553
- $contact = new FleetOpsContactUnitFake(['type' => 'contact']);
554
- $contact->setRelation('user', $user);
576
+ $contact = new FleetOpsContactUnitFake(['type' => 'customer']);
577
+ $contact->fakeUser = $user;
555
578
 
556
579
  expect($contact->deleteUser())->toBeFalse()
557
580
  ->and($user->deleted)->toBeFalse();
558
581
  });
559
582
 
583
+ test('contact delete user keeps a managed account still used by another organization', function () {
584
+ $connection = fleetopsContactUnitUseProfileTables();
585
+ $connection->table('drivers')->insert(['uuid' => 'driver-elsewhere', 'user_uuid' => 'user-uuid', 'company_uuid' => 'other-company']);
586
+ $connection->table('company_users')->insert([
587
+ ['uuid' => 'cu-1', 'user_uuid' => 'user-uuid', 'company_uuid' => 'company-uuid'],
588
+ ['uuid' => 'cu-2', 'user_uuid' => 'user-uuid', 'company_uuid' => 'other-company'],
589
+ ]);
590
+
591
+ $user = new FleetOpsContactUnitUserFake();
592
+ $user->setRawAttributes(['uuid' => 'user-uuid', 'type' => 'driver'], true);
593
+
594
+ $contact = new FleetOpsContactUnitFake(['type' => 'contact', 'company_uuid' => 'company-uuid']);
595
+ $contact->fakeUser = $user;
596
+
597
+ // The account only leaves this organization
598
+ expect($contact->deleteUser())->toBeFalse()
599
+ ->and($user->deleted)->toBeFalse()
600
+ ->and($connection->table('company_users')->whereNull('deleted_at')->pluck('company_uuid')->all())->toBe(['other-company']);
601
+ });
602
+
560
603
  test('contact real user helpers return loaded relations without database lookup', function () {
561
604
  $user = new FleetOpsContactUnitUserFake();
562
605
  $user->setRawAttributes([
@@ -116,7 +116,7 @@ test('sensor model exposes relation builders and logging options', function () {
116
116
 
117
117
  $sensor = new Sensor();
118
118
 
119
- expect($sensor->getSlugOptions()->generateSlugFrom)->toBe(['name', 'sensor_type'])
119
+ expect($sensor->getSlugOptions()->generateSlugFrom)->toBe(['name', 'type'])
120
120
  ->and($sensor->getSlugOptions()->slugField)->toBe('slug')
121
121
  ->and($sensor->getActivitylogOptions()->logOnlyDirty)->toBeTrue()
122
122
  ->and($sensor->telematic())->toBeInstanceOf(BelongsTo::class)
@@ -222,7 +222,7 @@ test('sensor scopes write expected query constraints', function () {
222
222
  ->and($sensor->scopeActive($query))->toBe($query)
223
223
  ->and($sensor->scopeWithRecentReadings($query, 30))->toBe($query)
224
224
  ->and($sensor->scopeOutOfThreshold($query))->toBe($query)
225
- ->and($query->calls[0])->toBe(['where', ['sensor_type', 'temperature']])
225
+ ->and($query->calls[0])->toBe(['where', ['type', 'temperature']])
226
226
  ->and($query->calls[1])->toBe(['where', ['status', 'active']])
227
227
  ->and($query->calls[2][0])->toBe('where')
228
228
  ->and($query->calls[2][1][0])->toBe('last_reading_at')