@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
@@ -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
 
@@ -0,0 +1,201 @@
1
+ <?php
2
+
3
+ use Illuminate\Database\ConnectionResolver;
4
+ use Illuminate\Database\Eloquent\Model as EloquentModel;
5
+ use Illuminate\Database\SQLiteConnection;
6
+ use Illuminate\Support\Facades\DB;
7
+ use Illuminate\Support\Facades\Schema;
8
+
9
+ /**
10
+ * The migration that converts profile-only `user` accounts into managed
11
+ * driver/customer accounts, run up and down against in-memory SQLite.
12
+ *
13
+ * An account is converted only when it clearly is a profile login and nothing
14
+ * else; anything that looks like a team member is left alone, and down()
15
+ * reverts only the rows up() converted.
16
+ */
17
+ function fleetopsProfileConversionBoot(bool $withPolicies = true): SQLiteConnection
18
+ {
19
+ $connection = new SQLiteConnection(new PDO('sqlite::memory:'));
20
+ $resolver = new ConnectionResolver(['default' => $connection, 'mysql' => $connection]);
21
+ $resolver->setDefaultConnection('mysql');
22
+ EloquentModel::setConnectionResolver($resolver);
23
+
24
+ app()->instance('db', new class($connection) {
25
+ public function __construct(public SQLiteConnection $c)
26
+ {
27
+ }
28
+
29
+ public function connection($name = null): SQLiteConnection
30
+ {
31
+ return $this->c;
32
+ }
33
+
34
+ public function table($table, $as = null)
35
+ {
36
+ return $this->c->table($table, $as);
37
+ }
38
+
39
+ public function __call($method, $arguments)
40
+ {
41
+ return $this->c->{$method}(...$arguments);
42
+ }
43
+ });
44
+ app()->instance('db.schema', $connection->getSchemaBuilder());
45
+ DB::clearResolvedInstance('db');
46
+ Schema::clearResolvedInstance('db.schema');
47
+
48
+ $schema = $connection->getSchemaBuilder();
49
+ $tables = [
50
+ 'users' => ['uuid', 'type', 'meta'],
51
+ 'drivers' => ['uuid', 'user_uuid'],
52
+ 'contacts' => ['uuid', 'user_uuid', 'type'],
53
+ 'companies' => ['uuid', 'owner_uuid'],
54
+ 'company_users' => ['uuid', 'user_uuid', 'company_uuid'],
55
+ 'roles' => ['name'],
56
+ 'model_has_roles' => ['role_id', 'model_uuid'],
57
+ 'model_has_permissions' => ['permission_id', 'model_uuid'],
58
+ ];
59
+ if ($withPolicies) {
60
+ $tables['model_has_policies'] = ['policy_id', 'model_uuid'];
61
+ }
62
+ foreach ($tables as $table => $columns) {
63
+ $schema->create($table, function ($blueprint) use ($columns) {
64
+ $blueprint->increments('id');
65
+ foreach ($columns as $column) {
66
+ $blueprint->string($column)->nullable();
67
+ }
68
+ $blueprint->timestamp('deleted_at')->nullable();
69
+ });
70
+ }
71
+
72
+ foreach (['Driver', 'Fleet-Ops Customer', 'Administrator'] as $role) {
73
+ $connection->table('roles')->insert(['name' => $role]);
74
+ }
75
+
76
+ return $connection;
77
+ }
78
+
79
+ function fleetopsProfileConversionMigration(): object
80
+ {
81
+ return require dirname(__DIR__, 4) . '/migrations/2026_09_21_000001_convert_profile_only_users_to_managed_accounts.php';
82
+ }
83
+
84
+ /**
85
+ * Seed a user holding the given profile(s), roles on its company membership
86
+ * (or on the user itself), and optional direct permission or policy.
87
+ */
88
+ function fleetopsProfileConversionUser(SQLiteConnection $connection, string $uuid, array $options = []): void
89
+ {
90
+ $connection->table('users')->insert(['uuid' => $uuid, 'type' => $options['type'] ?? 'user', 'meta' => $options['meta'] ?? null, 'deleted_at' => $options['deleted_at'] ?? null]);
91
+ $connection->table('company_users')->insert(['uuid' => 'cu-' . $uuid, 'user_uuid' => $uuid, 'company_uuid' => 'company-1']);
92
+
93
+ if ($options['driver'] ?? false) {
94
+ $connection->table('drivers')->insert(['uuid' => 'driver-' . $uuid, 'user_uuid' => $uuid]);
95
+ }
96
+
97
+ if ($options['customer'] ?? false) {
98
+ $connection->table('contacts')->insert(['uuid' => 'contact-' . $uuid, 'user_uuid' => $uuid, 'type' => 'customer']);
99
+ }
100
+
101
+ foreach ($options['roles'] ?? [] as $role) {
102
+ $roleId = $connection->table('roles')->where('name', $role)->value('id');
103
+ $connection->table('model_has_roles')->insert(['role_id' => $roleId, 'model_uuid' => ($options['role_on_user'] ?? false) ? $uuid : 'cu-' . $uuid]);
104
+ }
105
+
106
+ if ($options['permission'] ?? false) {
107
+ $connection->table('model_has_permissions')->insert(['permission_id' => 1, 'model_uuid' => 'cu-' . $uuid]);
108
+ }
109
+
110
+ if ($options['policy'] ?? false) {
111
+ $connection->table('model_has_policies')->insert(['policy_id' => 1, 'model_uuid' => $uuid]);
112
+ }
113
+ }
114
+
115
+ function fleetopsProfileConversionTypes(SQLiteConnection $connection): array
116
+ {
117
+ return $connection->table('users')->orderBy('uuid')->pluck('type', 'uuid')->all();
118
+ }
119
+
120
+ test('up converts profile-only accounts and leaves anything that looks like a team member', function () {
121
+ $connection = fleetopsProfileConversionBoot();
122
+
123
+ fleetopsProfileConversionUser($connection, 'a-driver', ['driver' => true, 'roles' => ['Driver'], 'meta' => json_encode(['source' => 'console'])]);
124
+ fleetopsProfileConversionUser($connection, 'b-customer', ['customer' => true, 'roles' => ['Fleet-Ops Customer'], 'role_on_user' => true]);
125
+ fleetopsProfileConversionUser($connection, 'c-owner', ['driver' => true, 'roles' => ['Driver']]);
126
+ $connection->table('companies')->insert(['uuid' => 'company-1', 'owner_uuid' => 'c-owner']);
127
+ fleetopsProfileConversionUser($connection, 'd-extra-role', ['driver' => true, 'roles' => ['Driver', 'Administrator']]);
128
+ fleetopsProfileConversionUser($connection, 'e-permission', ['driver' => true, 'roles' => ['Driver'], 'permission' => true]);
129
+ fleetopsProfileConversionUser($connection, 'f-policy', ['customer' => true, 'roles' => ['Fleet-Ops Customer'], 'policy' => true]);
130
+ fleetopsProfileConversionUser($connection, 'g-both-profiles', ['driver' => true, 'customer' => true, 'roles' => ['Driver']]);
131
+ fleetopsProfileConversionUser($connection, 'h-no-roles', ['driver' => true]);
132
+ fleetopsProfileConversionUser($connection, 'i-wrong-role', ['customer' => true, 'roles' => ['Driver']]);
133
+ fleetopsProfileConversionUser($connection, 'j-no-profile', ['roles' => ['Driver']]);
134
+ fleetopsProfileConversionUser($connection, 'k-admin', ['type' => 'admin', 'driver' => true, 'roles' => ['Driver']]);
135
+ fleetopsProfileConversionUser($connection, 'l-deleted', ['driver' => true, 'roles' => ['Driver'], 'deleted_at' => '2026-01-01 00:00:00']);
136
+ fleetopsProfileConversionUser($connection, 'm-bad-meta', ['driver' => true, 'roles' => ['Driver'], 'meta' => 'not-json']);
137
+
138
+ fleetopsProfileConversionMigration()->up();
139
+
140
+ expect(fleetopsProfileConversionTypes($connection))->toBe([
141
+ 'a-driver' => 'driver',
142
+ 'b-customer' => 'customer',
143
+ 'c-owner' => 'user',
144
+ 'd-extra-role' => 'user',
145
+ 'e-permission' => 'user',
146
+ 'f-policy' => 'user',
147
+ 'g-both-profiles' => 'user',
148
+ 'h-no-roles' => 'user',
149
+ 'i-wrong-role' => 'user',
150
+ 'j-no-profile' => 'user',
151
+ 'k-admin' => 'admin',
152
+ 'l-deleted' => 'user',
153
+ 'm-bad-meta' => 'driver',
154
+ ])
155
+ // The previous type is recorded next to any existing meta
156
+ ->and(json_decode($connection->table('users')->where('uuid', 'a-driver')->value('meta'), true))->toBe(['source' => 'console', 'previous_type' => 'user'])
157
+ ->and(json_decode($connection->table('users')->where('uuid', 'm-bad-meta')->value('meta'), true))->toBe(['previous_type' => 'user']);
158
+ });
159
+
160
+ test('down reverts only the accounts up converted', function () {
161
+ $connection = fleetopsProfileConversionBoot();
162
+
163
+ fleetopsProfileConversionUser($connection, 'a-driver', ['driver' => true, 'roles' => ['Driver'], 'meta' => json_encode(['source' => 'console'])]);
164
+ fleetopsProfileConversionUser($connection, 'b-customer', ['customer' => true, 'roles' => ['Fleet-Ops Customer']]);
165
+ // Managed accounts created after the change carry no previous type
166
+ fleetopsProfileConversionUser($connection, 'c-native-driver', ['type' => 'driver', 'driver' => true, 'meta' => json_encode(['source' => 'app'])]);
167
+ // A previous type other than `user` is not this migration's
168
+ fleetopsProfileConversionUser($connection, 'd-other-previous', ['type' => 'customer', 'customer' => true, 'meta' => json_encode(['previous_type' => 'contact'])]);
169
+
170
+ $migration = fleetopsProfileConversionMigration();
171
+ $migration->up();
172
+ $migration->down();
173
+
174
+ expect(fleetopsProfileConversionTypes($connection))->toBe([
175
+ 'a-driver' => 'user',
176
+ 'b-customer' => 'user',
177
+ 'c-native-driver' => 'driver',
178
+ 'd-other-previous' => 'customer',
179
+ ])
180
+ ->and(json_decode($connection->table('users')->where('uuid', 'a-driver')->value('meta'), true))->toBe(['source' => 'console'])
181
+ ->and(json_decode($connection->table('users')->where('uuid', 'b-customer')->value('meta'), true))->toBe([])
182
+ ->and(json_decode($connection->table('users')->where('uuid', 'd-other-previous')->value('meta'), true))->toBe(['previous_type' => 'contact']);
183
+ });
184
+
185
+ test('up and down do nothing when the tables they read are missing', function () {
186
+ $connection = fleetopsProfileConversionBoot(false);
187
+ fleetopsProfileConversionUser($connection, 'a-driver', ['driver' => true, 'roles' => ['Driver']]);
188
+
189
+ $migration = fleetopsProfileConversionMigration();
190
+ $migration->up();
191
+
192
+ expect(fleetopsProfileConversionTypes($connection))->toBe(['a-driver' => 'user']);
193
+
194
+ $connection->table('users')->where('uuid', 'a-driver')->update(['type' => 'driver', 'meta' => json_encode(['previous_type' => 'user'])]);
195
+ $connection->getSchemaBuilder()->drop('users');
196
+
197
+ // Without a users table there is nothing to revert
198
+ $migration->down();
199
+
200
+ expect($connection->getSchemaBuilder()->hasTable('users'))->toBeFalse();
201
+ });
@@ -252,10 +252,12 @@ test('contact update guards types resolve users and portal seams persist', funct
252
252
  $probe->callProtected('assertContactInputIsValid', [$request, &$input, $contact]);
253
253
  })->toThrow(Exception::class);
254
254
 
255
- // User references resolve through uuid or public id
256
- expect($probe->callProtected('resolveUserUuid', ['user_contactseam']))->toBe('88888888-8888-4888-8888-888888888881')
257
- ->and($probe->callProtected('resolveUserUuid', ['88888888-8888-4888-8888-888888888881']))->toBe('88888888-8888-4888-8888-888888888881')
258
- ->and($probe->callProtected('resolveUserUuid', ['unknown-user']))->toBe('unknown-user');
255
+ // The login account is managed by the profile, so user references are dropped
256
+ $userInput = ['name' => 'Seam Contact', 'user_uuid' => '88888888-8888-4888-8888-888888888881', 'user' => 'user_contactseam'];
257
+ Closure::bind(function (array &$input) {
258
+ $this->resolveUserInput(Request::create('/x', 'PUT', []), $input);
259
+ }, $probe, ContactController::class)($userInput);
260
+ expect($userInput)->toBe(['name' => 'Seam Contact']);
259
261
 
260
262
  // Portal seams read users, mint passwords, and persist meta quietly
261
263
  expect($probe->callProtected('contactUser', [$contact])?->uuid)->toBe('88888888-8888-4888-8888-888888888881')
@@ -125,7 +125,9 @@ function fleetopsCustomerCredentialsBoot(): SQLiteConnection
125
125
  'contacts' => ['uuid', 'public_id', 'company_uuid', 'user_uuid', 'name', 'email', 'phone', 'type', 'title', 'meta', 'photo_uuid', 'place_uuid', 'slug', '_key'],
126
126
  'users' => ['uuid', 'public_id', 'company_uuid', 'name', 'email', 'phone', 'password', 'type', 'status', 'timezone', 'slug', 'username', 'avatar_uuid', 'meta', '_key'],
127
127
  'companies' => ['uuid', 'public_id', 'name', 'country'],
128
- 'files' => ['uuid', 'public_id', 'company_uuid', 'subject_uuid', 'subject_type', 'name', 'path', 'disk', 'type', '_key'],
128
+ // session_status reads the user's membership of the session company
129
+ 'company_users' => ['uuid', 'company_uuid', 'user_uuid', 'status'],
130
+ 'files' => ['uuid', 'public_id', 'company_uuid', 'subject_uuid', 'subject_type', 'name', 'path', 'disk', 'type', '_key'],
129
131
  ];
130
132
  foreach ($tables as $table => $columns) {
131
133
  $schema->create($table, function ($blueprint) use ($columns) {
@@ -192,5 +194,7 @@ test('customer resolution and payload helpers expose user details', function ()
192
194
 
193
195
  $payload = $probe->callHelper('customerPayload', $customer);
194
196
  expect($payload['uuid'])->toBe('55555555-5555-4555-8555-555555555555')
195
- ->and($payload)->toHaveKey('user');
197
+ ->and($payload['user']['uuid'])->toBe('user-1')
198
+ ->and($payload['is_staff_linked'])->toBeFalse()
199
+ ->and($payload['login_status'])->toBe('active');
196
200
  });
@@ -1131,6 +1131,29 @@ test('internal driver controller verify code returns driver resource and handles
1131
1131
  ]);
1132
1132
  });
1133
1133
 
1134
+ test('internal driver controller refuses phone login and code verification for deactivated drivers', function () {
1135
+ FleetOpsInternalDriverAuthControllerProbe::resetProbe();
1136
+ app()->instance('request', Request::create('/', 'POST', ['phone' => '15551234567']));
1137
+
1138
+ $deactivated = fleetopsInternalDriverAuthUser();
1139
+ $deactivated->setAttribute('status', 'inactive');
1140
+ FleetOpsInternalDriverAuthControllerProbe::$loginUser = $deactivated;
1141
+ FleetOpsInternalDriverAuthControllerProbe::$verificationUser = $deactivated;
1142
+ FleetOpsInternalDriverAuthControllerProbe::$verificationExists = true;
1143
+
1144
+ $controller = new FleetOpsInternalDriverAuthControllerProbe();
1145
+ $login = $controller->loginWithPhone();
1146
+ $verify = $controller->verifyCode(new Request(['identity' => '+15551234567', 'code' => '111111']));
1147
+
1148
+ expect($login->getStatusCode())->toBe(403)
1149
+ ->and($login->getData(true))->toBe(['error' => 'This driver login has been deactivated.'])
1150
+ ->and($verify->getStatusCode())->toBe(403)
1151
+ ->and($verify->getData(true))->toBe(['error' => 'This driver login has been deactivated.'])
1152
+ // No code is sent and no token is issued
1153
+ ->and(FleetOpsInternalDriverAuthControllerProbe::$verifications)->toBe([])
1154
+ ->and(FleetOpsInternalDriverAuthControllerProbe::$tokens)->toBe([]);
1155
+ });
1156
+
1134
1157
  test('internal driver controller delegates login and create driver verification flows to api controller', function () {
1135
1158
  $request = new Request(['for' => 'create_driver']);
1136
1159
 
@@ -10,11 +10,12 @@ use Illuminate\Http\Request;
10
10
  /**
11
11
  * Covers the internal DriverController createRecord and updateRecord flows
12
12
  * against an in-memory SQLite fixture. The creation closure executes end to
13
- * end: company resolution, user creation with driver typing, existing-user
14
- * adoption and conflict detection, organization membership checks, and the
15
- * exception-to-error-response branches. Role assignment requires the full
16
- * spatie permission boot unavailable in the harness, so flows settle in the
17
- * documented error branch after the user record is persisted.
13
+ * end: company resolution, managed account creation with driver typing
14
+ * through ProfileAccountManager, and the exception-to-error-response
15
+ * branches. Role assignment requires the full spatie permission boot, which
16
+ * this fixture leaves out, so flows settle in the documented error branch
17
+ * after the user record is persisted (see
18
+ * DriverControllerExistingUserAdoptionTest for the full flow).
18
19
  */
19
20
  if (!function_exists('Fleetbase\Support\session')) {
20
21
  eval('namespace Fleetbase\Support; function session($key = null, $default = null) { if ($key === null) { return new class { public function has($k) { return \session($k) !== null; } public function get($k, $d = null) { return \session($k, $d); } }; } return \session($key, $default); }');
@@ -101,6 +102,28 @@ function fleetopsInternalDriverCreateBoot(): SQLiteConnection
101
102
  }
102
103
  });
103
104
  Illuminate\Support\Facades\DB::clearResolvedInstance('db');
105
+ app()->instance('hash', new class implements Illuminate\Contracts\Hashing\Hasher {
106
+ public function info($hashedValue): array
107
+ {
108
+ return [];
109
+ }
110
+
111
+ public function make($value, array $options = []): string
112
+ {
113
+ return 'hashed:' . $value;
114
+ }
115
+
116
+ public function check($value, $hashedValue, array $options = []): bool
117
+ {
118
+ return 'hashed:' . $value === $hashedValue;
119
+ }
120
+
121
+ public function needsRehash($hashedValue, array $options = []): bool
122
+ {
123
+ return false;
124
+ }
125
+ });
126
+ Illuminate\Support\Facades\Hash::clearResolvedInstance('hash');
104
127
 
105
128
  $schema = $connection->getSchemaBuilder();
106
129
  $tables = [
@@ -158,31 +181,33 @@ test('create record provisions a driver-typed user before role assignment', func
158
181
  ->and($connection->table('company_users')->count())->toBeGreaterThanOrEqual(0);
159
182
  });
160
183
 
161
- test('create record rejects users that already belong to a driver', function () {
184
+ test('create record ignores a picked user account and resolves the login from the email', function () {
162
185
  $connection = fleetopsInternalDriverCreateBoot();
163
- $connection->table('users')->insert(['uuid' => '11111111-1111-4111-8111-111111111111', 'company_uuid' => 'company-1', 'name' => 'Existing', 'type' => 'driver']);
164
- $connection->table('drivers')->insert(['uuid' => 'driver-1', 'company_uuid' => 'company-1', 'user_uuid' => '11111111-1111-4111-8111-111111111111']);
186
+ $connection->table('users')->insert(['uuid' => '11111111-1111-4111-8111-111111111111', 'company_uuid' => 'company-1', 'name' => 'Existing', 'email' => 'existing@example.com', 'type' => 'driver']);
165
187
 
166
- $result = (new DriverController())->createRecord(fleetopsInternalDriverCreateRequest([
167
- 'name' => 'Existing',
188
+ (new DriverController())->createRecord(fleetopsInternalDriverCreateRequest([
189
+ 'name' => 'Someone Else',
190
+ 'email' => 'someone@example.com',
168
191
  'user_uuid' => '11111111-1111-4111-8111-111111111111',
169
192
  ]));
170
193
 
171
- expect($result)->toBeInstanceOf(JsonResponse::class)
172
- ->and($result->getData(true))->toBe(['error' => 'This user account already belongs to a driver.']);
194
+ // The login account is managed by the profile: a new driver account is
195
+ // created for the email rather than the picked account being taken over
196
+ expect($connection->table('users')->count())->toBe(2)
197
+ ->and($connection->table('users')->where('email', 'someone@example.com')->value('type'))->toBe('driver')
198
+ ->and($connection->table('users')->where('uuid', '11111111-1111-4111-8111-111111111111')->value('name'))->toBe('Existing');
173
199
  });
174
200
 
175
- test('create record adopts an existing user and applies photo avatars', function () {
201
+ test('create record applies photo avatars to the new driver account', function () {
176
202
  $connection = fleetopsInternalDriverCreateBoot();
177
- $connection->table('users')->insert(['uuid' => '11111111-1111-4111-8111-111111111111', 'company_uuid' => 'company-1', 'name' => 'Adoptable', 'type' => 'driver']);
178
203
 
179
204
  $result = (new DriverController())->createRecord(fleetopsInternalDriverCreateRequest([
180
- 'name' => 'Adoptable',
181
- 'user_uuid' => '11111111-1111-4111-8111-111111111111',
205
+ 'name' => 'Photo Driver',
182
206
  'photo_uuid' => '22222222-2222-4222-8222-222222222222',
183
207
  ]));
184
208
 
185
- // Avatar update applies before the harness role-assignment limitation.
209
+ // Avatar is set when the account is created, before the harness
210
+ // role-assignment limitation.
186
211
  expect($connection->table('users')->value('avatar_uuid'))->toBe('22222222-2222-4222-8222-222222222222')
187
212
  ->and($result)->toBeInstanceOf(JsonResponse::class)
188
213
  ->and($result->getData(true))->toHaveKey('error');