@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
@@ -0,0 +1,643 @@
1
+ <?php
2
+
3
+ if (!function_exists('Fleetbase\\Observers\\event')) {
4
+ eval('namespace Fleetbase\\Observers; function event($event = null, $payload = []) { return []; }');
5
+ }
6
+
7
+ if (!function_exists('Fleetbase\\FleetOps\\Observers\\event')) {
8
+ eval('namespace Fleetbase\\FleetOps\\Observers; function event($event = null, $payload = []) { return []; }');
9
+ }
10
+
11
+ if (!function_exists('__')) {
12
+ function __($key = null, $replace = [], $locale = null)
13
+ {
14
+ return $key;
15
+ }
16
+ }
17
+
18
+ if (!Illuminate\Support\Str::hasMacro('humanize')) {
19
+ Illuminate\Support\Str::macro('humanize', fn ($value, $uppercase = true) => str_replace('_', ' ', Illuminate\Support\Str::snake((string) $value)));
20
+ }
21
+
22
+ use Fleetbase\FleetOps\Exceptions\ProfileIdentityConflictException;
23
+ use Fleetbase\FleetOps\Mail\CustomerCredentialsMail;
24
+ use Fleetbase\FleetOps\Mail\DriverCredentialsMail;
25
+ use Fleetbase\FleetOps\Models\Contact;
26
+ use Fleetbase\FleetOps\Models\Driver;
27
+ use Fleetbase\FleetOps\Support\ProfileAccountManager;
28
+ use Fleetbase\Models\CompanyUser;
29
+ use Fleetbase\Models\User;
30
+ use Fleetbase\Services\SmsService;
31
+ use Illuminate\Database\ConnectionResolver;
32
+ use Illuminate\Database\Eloquent\Model as EloquentModel;
33
+ use Illuminate\Database\SQLiteConnection;
34
+
35
+ /**
36
+ * Covers ProfileAccountManager against SQLite with real spatie roles: account
37
+ * classification, identity lookups, linking a staff member or a managed
38
+ * account of the same type, creating managed accounts without invites, proxy
39
+ * field sync, releasing an account when its profile is deleted, and sending
40
+ * credentials by email or SMS.
41
+ */
42
+ class FleetOpsProfileAccountSmsFake extends SmsService
43
+ {
44
+ public array $sent = [];
45
+ public array $result = ['success' => true];
46
+
47
+ public function __construct()
48
+ {
49
+ }
50
+
51
+ public function send(string $to, string $text, array $options = [], ?string $provider = null): array
52
+ {
53
+ $this->sent[] = [$to, $text];
54
+
55
+ return $this->result;
56
+ }
57
+ }
58
+
59
+ function fleetopsProfileAccountBoot(): SQLiteConnection
60
+ {
61
+ // Model uuid hooks bind to whichever dispatcher exists when the class boots
62
+ if (!EloquentModel::getEventDispatcher()) {
63
+ EloquentModel::setEventDispatcher(new Illuminate\Events\Dispatcher());
64
+ }
65
+
66
+ $connection = new SQLiteConnection(new PDO('sqlite::memory:'));
67
+ $resolver = new ConnectionResolver(['default' => $connection, 'mysql' => $connection]);
68
+ $resolver->setDefaultConnection('mysql');
69
+ EloquentModel::setConnectionResolver($resolver);
70
+ app()->instance('db', new class($connection) {
71
+ public function __construct(public SQLiteConnection $c)
72
+ {
73
+ }
74
+
75
+ public function connection($name = null): SQLiteConnection
76
+ {
77
+ return $this->c;
78
+ }
79
+
80
+ public function __call($method, $arguments)
81
+ {
82
+ return $this->c->{$method}(...$arguments);
83
+ }
84
+ });
85
+ app()->instance('db.schema', $connection->getSchemaBuilder());
86
+ Illuminate\Support\Facades\DB::clearResolvedInstance('db');
87
+ app()->instance('responsecache', new class {
88
+ public function __call($method, $arguments)
89
+ {
90
+ return null;
91
+ }
92
+ });
93
+ app()->instance('hash', new class implements Illuminate\Contracts\Hashing\Hasher {
94
+ public function info($hashedValue): array
95
+ {
96
+ return [];
97
+ }
98
+
99
+ public function make($value, array $options = []): string
100
+ {
101
+ return 'hashed:' . $value;
102
+ }
103
+
104
+ public function check($value, $hashedValue, array $options = []): bool
105
+ {
106
+ return 'hashed:' . $value === $hashedValue;
107
+ }
108
+
109
+ public function needsRehash($hashedValue, array $options = []): bool
110
+ {
111
+ return false;
112
+ }
113
+ });
114
+ Illuminate\Support\Facades\Hash::clearResolvedInstance('hash');
115
+
116
+ Illuminate\Support\Facades\Mail::swap(new class {
117
+ public array $sent = [];
118
+ public array $to = [];
119
+
120
+ public function to($users)
121
+ {
122
+ $this->to[] = $users;
123
+
124
+ return $this;
125
+ }
126
+
127
+ public function send($mailable)
128
+ {
129
+ $this->sent[] = $mailable;
130
+
131
+ return null;
132
+ }
133
+ });
134
+
135
+ $sms = new FleetOpsProfileAccountSmsFake();
136
+ app()->instance(SmsService::class, $sms);
137
+
138
+ $schema = $connection->getSchemaBuilder();
139
+ $tables = [
140
+ 'users' => ['uuid', 'public_id', 'company_uuid', 'name', 'email', 'phone', 'password', 'status', 'type', 'username', 'avatar_uuid', 'slug', 'timezone', 'country', 'ip_address', 'meta', 'last_login', '_key'],
141
+ 'drivers' => ['uuid', 'public_id', 'company_uuid', 'user_uuid'],
142
+ 'contacts' => ['uuid', 'public_id', 'company_uuid', 'user_uuid', 'name', 'type'],
143
+ 'companies' => ['uuid', 'public_id', 'name', 'owner_uuid', 'timezone'],
144
+ 'company_users' => ['uuid', 'public_id', 'company_uuid', 'user_uuid', 'status', '_key'],
145
+ 'permissions' => ['name', 'guard_name', 'service', 'description'],
146
+ 'roles' => ['uuid', 'public_id', 'name', 'guard_name', 'company_uuid', 'service', 'description', '_key'],
147
+ 'model_has_roles' => ['role_id', 'model_type', 'model_uuid'],
148
+ 'model_has_permissions' => ['permission_id', 'model_type', 'model_uuid'],
149
+ 'role_has_permissions' => ['permission_id', 'role_id'],
150
+ ];
151
+ foreach ($tables as $table => $columns) {
152
+ $schema->create($table, function ($blueprint) use ($columns) {
153
+ $blueprint->increments('id');
154
+ foreach ($columns as $column) {
155
+ $blueprint->string($column)->nullable();
156
+ }
157
+ $blueprint->timestamps();
158
+ $blueprint->timestamp('deleted_at')->nullable();
159
+ });
160
+ }
161
+
162
+ app()->instance('cache', new class {
163
+ public function tags($tags = null)
164
+ {
165
+ return $this;
166
+ }
167
+
168
+ public function flush()
169
+ {
170
+ return true;
171
+ }
172
+
173
+ public function remember($key, $ttl, $callback)
174
+ {
175
+ return $callback();
176
+ }
177
+
178
+ public function store($name = null)
179
+ {
180
+ return $this;
181
+ }
182
+
183
+ public function __call($method, $arguments)
184
+ {
185
+ return null;
186
+ }
187
+ });
188
+ Illuminate\Support\Facades\Cache::clearResolvedInstance('cache');
189
+ config()->set('cache.default', 'array');
190
+ config()->set('cache.stores.array', ['driver' => 'array']);
191
+ config()->set('permission.cache.expiration_time', 60);
192
+ config()->set('permission.cache.key', 'spatie.permission.cache');
193
+ config()->set('permission.cache.store', 'default');
194
+ config()->set('permission.models.permission', Fleetbase\Models\Permission::class);
195
+ config()->set('permission.models.role', Fleetbase\Models\Role::class);
196
+ config()->set('permission.table_names', ['roles' => 'roles', 'permissions' => 'permissions', 'model_has_permissions' => 'model_has_permissions', 'model_has_roles' => 'model_has_roles', 'role_has_permissions' => 'role_has_permissions']);
197
+ config()->set('permission.column_names', ['role_pivot_key' => null, 'permission_pivot_key' => null, 'model_morph_key' => 'model_uuid', 'team_foreign_key' => 'team_id']);
198
+ config()->set('permission.teams', false);
199
+ config()->set('permission.events_enabled', false);
200
+ $cacheManager = new Illuminate\Cache\CacheManager(app());
201
+ app()->instance(Illuminate\Cache\CacheManager::class, $cacheManager);
202
+ app()->instance(Spatie\Permission\PermissionRegistrar::class, new Spatie\Permission\PermissionRegistrar($cacheManager));
203
+ config()->set('activitylog.enabled', false);
204
+ config()->set('activitylog.default_auth_driver', 'web');
205
+ config()->set('auth.defaults.guard', 'web');
206
+ config()->set('app.name', 'Fleetbase');
207
+ app()->bind(Illuminate\Contracts\Config\Repository::class, fn () => config());
208
+
209
+ session(['company' => 'company-1']);
210
+ $connection->table('companies')->insert([
211
+ ['uuid' => 'company-1', 'public_id' => 'company_one', 'name' => 'Acme', 'timezone' => 'Asia/Singapore'],
212
+ ['uuid' => 'company-2', 'public_id' => 'company_two', 'name' => 'Globex', 'timezone' => null],
213
+ ]);
214
+ $roles = [];
215
+ foreach (['company-1', 'company-2'] as $company) {
216
+ foreach (['Driver', 'Fleet-Ops Contact', 'Fleet-Ops Customer'] as $name) {
217
+ foreach (['web', 'sanctum'] as $guard) {
218
+ $roles[] = ['uuid' => $company . $name . $guard, 'name' => $name, 'guard_name' => $guard, 'company_uuid' => $company];
219
+ }
220
+ }
221
+ }
222
+ $connection->table('roles')->insert($roles);
223
+
224
+ return $connection;
225
+ }
226
+
227
+ function fleetopsProfileAccountRoles(SQLiteConnection $connection, string $userUuid, string $companyUuid): array
228
+ {
229
+ return $connection->table('model_has_roles')
230
+ ->join('roles', 'roles.id', '=', 'model_has_roles.role_id')
231
+ ->join('company_users', 'company_users.uuid', '=', 'model_has_roles.model_uuid')
232
+ ->where('company_users.user_uuid', $userUuid)
233
+ ->where('company_users.company_uuid', $companyUuid)
234
+ ->pluck('roles.name')
235
+ ->unique()
236
+ ->values()
237
+ ->all();
238
+ }
239
+
240
+ /**
241
+ * Insert rows one at a time: a multi-row insert takes its column list from the
242
+ * first row, so rows with different keys would land in the wrong columns.
243
+ */
244
+ function fleetopsProfileAccountRows(SQLiteConnection $connection, string $table, array $rows): void
245
+ {
246
+ foreach ($rows as $row) {
247
+ $connection->table($table)->insert($row);
248
+ }
249
+ }
250
+
251
+ function fleetopsProfileAccountUser(array $attributes): User
252
+ {
253
+ app('db')->connection()->table('users')->insert($attributes);
254
+
255
+ return User::where('uuid', $attributes['uuid'])->first();
256
+ }
257
+
258
+ test('accounts are managed when typed driver contact or customer and staff otherwise', function () {
259
+ expect(ProfileAccountManager::isManagedAccount(null))->toBeFalse()
260
+ ->and(ProfileAccountManager::isStaffAccount(null))->toBeFalse()
261
+ ->and(ProfileAccountManager::isManagedAccount((object) ['type' => 'driver']))->toBeTrue()
262
+ ->and(ProfileAccountManager::isManagedAccount((object) ['type' => 'contact']))->toBeTrue()
263
+ ->and(ProfileAccountManager::isManagedAccount((object) ['type' => 'customer']))->toBeTrue()
264
+ ->and(ProfileAccountManager::isStaffAccount((object) ['type' => 'customer']))->toBeFalse()
265
+ ->and(ProfileAccountManager::isStaffAccount((object) ['type' => 'user']))->toBeTrue()
266
+ ->and(ProfileAccountManager::isStaffAccount((object) ['type' => 'admin']))->toBeTrue()
267
+ ->and(ProfileAccountManager::isStaffAccount((object) []))->toBeTrue()
268
+ ->and(ProfileAccountManager::normalizeEmail(' Ada@Example.COM '))->toBe('ada@example.com')
269
+ ->and(ProfileAccountManager::normalizeEmail(' '))->toBeNull()
270
+ ->and(ProfileAccountManager::normalizeEmail(null))->toBeNull()
271
+ ->and(ProfileAccountManager::normalizePhone(' +65 9123-4567 '))->toBe('+6591234567')
272
+ ->and(ProfileAccountManager::normalizePhone(''))->toBeNull()
273
+ ->and(ProfileAccountManager::normalizePhone(null))->toBeNull();
274
+ });
275
+
276
+ test('identity lookups match email or phone and skip ignored and deleted accounts', function () {
277
+ $connection = fleetopsProfileAccountBoot();
278
+ fleetopsProfileAccountRows($connection, 'users', [
279
+ ['uuid' => 'user-email', 'email' => 'ada@example.com', 'type' => 'user'],
280
+ ['uuid' => 'user-phone', 'phone' => '+6591234567', 'type' => 'driver'],
281
+ ['uuid' => 'user-deleted', 'email' => 'gone@example.com', 'type' => 'driver', 'deleted_at' => '2026-01-01 00:00:00'],
282
+ ]);
283
+
284
+ expect(ProfileAccountManager::findAccountByIdentity(null, ' '))->toBeNull()
285
+ ->and(ProfileAccountManager::findAccountByIdentity('ADA@example.com', null)?->uuid)->toBe('user-email')
286
+ ->and(ProfileAccountManager::findAccountByIdentity(null, '+65 9123 4567')?->uuid)->toBe('user-phone')
287
+ ->and(ProfileAccountManager::findAccountByIdentity('nobody@example.com', '+6591234567')?->uuid)->toBe('user-phone')
288
+ ->and(ProfileAccountManager::findAccountByIdentity('ada@example.com', null, 'user-email'))->toBeNull()
289
+ ->and(ProfileAccountManager::findAccountByIdentity('gone@example.com', null))->toBeNull();
290
+ });
291
+
292
+ test('lookup reports the staff member to link or why the identity is unavailable', function () {
293
+ $connection = fleetopsProfileAccountBoot();
294
+ fleetopsProfileAccountRows($connection, 'users', [
295
+ ['uuid' => 'staff-1', 'company_uuid' => 'company-1', 'name' => 'Staff', 'email' => 'staff@example.com', 'type' => 'user'],
296
+ ['uuid' => 'staff-2', 'company_uuid' => 'company-2', 'email' => 'outsider@example.com', 'type' => 'admin'],
297
+ ['uuid' => 'driver-1', 'company_uuid' => 'company-2', 'phone' => '+6590001111', 'type' => 'driver'],
298
+ ['uuid' => 'current-1', 'company_uuid' => 'company-1', 'email' => 'me@example.com', 'type' => 'driver'],
299
+ ]);
300
+ $current = User::where('uuid', 'current-1')->first();
301
+
302
+ $nothing = ProfileAccountManager::lookup('company-1', 'driver', 'free@example.com', null);
303
+ $staff = ProfileAccountManager::lookup('company-1', 'driver', 'staff@example.com', null);
304
+ $outsider = ProfileAccountManager::lookup('company-1', 'driver', 'outsider@example.com', null);
305
+ $customer = ProfileAccountManager::lookup('company-1', 'customer', null, '+6590001111');
306
+ $sameType = ProfileAccountManager::lookup('company-1', 'driver', null, '+6590001111');
307
+ $taken = ProfileAccountManager::lookup('company-1', 'driver', 'staff@example.com', null, $current);
308
+ $own = ProfileAccountManager::lookup('company-1', 'driver', 'me@example.com', null, $current);
309
+
310
+ expect($nothing)->toBe(['account' => null, 'staff' => null, 'conflict' => null])
311
+ ->and($staff['staff']?->uuid)->toBe('staff-1')
312
+ ->and($staff['conflict'])->toBeNull()
313
+ ->and($outsider['staff'])->toBeNull()
314
+ ->and($outsider['conflict'])->toBe('This email is already in use by another account.')
315
+ ->and($customer['conflict'])->toBe('This phone number is already used by a driver.')
316
+ // A driver account of another organization is linked, not reported as staff
317
+ ->and($sameType['account']?->uuid)->toBe('driver-1')
318
+ ->and($sameType['staff'])->toBeNull()
319
+ ->and($sameType['conflict'])->toBeNull()
320
+ // An existing profile can't swap its account for another one
321
+ ->and($taken['conflict'])->toBe('This email is already in use by another account.')
322
+ ->and($taken['staff'])->toBeNull()
323
+ ->and($own['account'])->toBeNull();
324
+ });
325
+
326
+ test('resolve for profile creates a managed account with the profile role and no invite', function () {
327
+ $connection = fleetopsProfileAccountBoot();
328
+
329
+ $driver = ProfileAccountManager::resolveForProfile('company-1', 'driver', 'Dana Driver', 'Dana@Example.com', '+65 9000 1111', [
330
+ 'password' => 'chosen-secret',
331
+ 'status' => 'active',
332
+ 'avatar_uuid' => 'avatar-1',
333
+ 'country' => 'SG',
334
+ 'ip_address' => '127.0.0.1',
335
+ ]);
336
+
337
+ $row = $connection->table('users')->where('uuid', $driver->uuid)->first();
338
+
339
+ expect($driver->uuid)->not->toBeNull()
340
+ ->and($row->type)->toBe('driver')
341
+ ->and($row->email)->toBe('dana@example.com')
342
+ ->and($row->phone)->toBe('+6590001111')
343
+ ->and($row->password)->toBe('hashed:chosen-secret')
344
+ ->and($row->status)->toBe('active')
345
+ ->and($row->timezone)->toBe('Asia/Singapore')
346
+ ->and($row->avatar_uuid)->toBe('avatar-1')
347
+ ->and($row->company_uuid)->toBe('company-1')
348
+ ->and(fleetopsProfileAccountRoles($connection, $driver->uuid, 'company-1'))->toBe(['Driver'])
349
+ ->and($driver->getRelation('companyUser'))->toBeInstanceOf(CompanyUser::class);
350
+
351
+ // A nameless customer in a company without a timezone gets a generated
352
+ // username, a random password, the default status and the server timezone
353
+ $customer = ProfileAccountManager::resolveForProfile('company-2', 'customer', null, null, '+6590002222');
354
+ $customerRow = $connection->table('users')->where('uuid', $customer->uuid)->first();
355
+
356
+ expect($customerRow->type)->toBe('customer')
357
+ ->and($customerRow->status)->toBe('pending')
358
+ ->and($customerRow->name)->toBeNull()
359
+ ->and($customerRow->username)->not->toBeEmpty()
360
+ ->and($customerRow->password)->toStartWith('hashed:')
361
+ ->and($customerRow->timezone)->toBe(date_default_timezone_get())
362
+ ->and(fleetopsProfileAccountRoles($connection, $customer->uuid, 'company-2'))->toBe(['Fleet-Ops Customer']);
363
+
364
+ // A contact of an organization missing from the table is created without membership
365
+ $contact = ProfileAccountManager::createManagedAccount('company-missing', 'contact', 'Carl Contact', 'carl@example.com', null, ['timezone' => 'UTC']);
366
+
367
+ expect($connection->table('users')->where('uuid', $contact->uuid)->value('type'))->toBe('contact')
368
+ ->and($connection->table('users')->where('uuid', $contact->uuid)->value('timezone'))->toBe('UTC')
369
+ ->and($connection->table('company_users')->where('user_uuid', $contact->uuid)->count())->toBe(0);
370
+ });
371
+
372
+ test('resolve for profile links staff members and managed accounts of the same type', function () {
373
+ $connection = fleetopsProfileAccountBoot();
374
+ fleetopsProfileAccountRows($connection, 'users', [
375
+ ['uuid' => 'staff-1', 'company_uuid' => 'company-2', 'name' => 'Staff', 'email' => 'staff@example.com', 'type' => 'user'],
376
+ ['uuid' => 'driver-1', 'company_uuid' => 'company-2', 'phone' => '+6590001111', 'type' => 'driver'],
377
+ ['uuid' => 'driver-2', 'company_uuid' => null, 'phone' => '+6590003333', 'type' => 'driver'],
378
+ ]);
379
+ // The staff member belongs to company-1 through a membership row
380
+ $connection->table('company_users')->insert(['uuid' => 'cu-staff', 'company_uuid' => 'company-1', 'user_uuid' => 'staff-1', 'status' => 'active']);
381
+
382
+ $staff = ProfileAccountManager::resolveForProfile('company-1', 'driver', 'Other Name', 'STAFF@example.com', null);
383
+ $driver = ProfileAccountManager::resolveForProfile('company-1', 'driver', 'Driver', null, '+6590001111');
384
+ $orphan = ProfileAccountManager::resolveForProfile('company-1', 'driver', 'Orphan', null, '+6590003333');
385
+
386
+ expect($staff->uuid)->toBe('staff-1')
387
+ ->and($staff->type)->toBe('user')
388
+ ->and($connection->table('model_has_roles')->where('model_uuid', 'cu-staff')->count())->toBe(0)
389
+ ->and($driver->uuid)->toBe('driver-1')
390
+ ->and(fleetopsProfileAccountRoles($connection, 'driver-1', 'company-1'))->toBe(['Driver'])
391
+ // The account keeps its original organization
392
+ ->and($connection->table('users')->where('uuid', 'driver-1')->value('company_uuid'))->toBe('company-2')
393
+ // An account without an organization adopts this one
394
+ ->and($orphan->uuid)->toBe('driver-2')
395
+ ->and($connection->table('users')->where('uuid', 'driver-2')->value('company_uuid'))->toBe('company-1')
396
+ ->and($connection->table('users')->count())->toBe(3);
397
+ });
398
+
399
+ test('resolve for profile rejects accounts that cannot hold the profile', function () {
400
+ $connection = fleetopsProfileAccountBoot();
401
+ fleetopsProfileAccountRows($connection, 'users', [
402
+ ['uuid' => 'staff-2', 'company_uuid' => 'company-2', 'email' => 'outsider@example.com', 'type' => 'admin'],
403
+ ['uuid' => 'customer-1', 'company_uuid' => 'company-1', 'phone' => '+6590001111', 'type' => 'customer'],
404
+ ['uuid' => 'driver-1', 'company_uuid' => 'company-1', 'email' => 'driver@example.com', 'type' => 'driver'],
405
+ ['uuid' => 'contact-1', 'company_uuid' => 'company-1', 'email' => 'contact@example.com', 'type' => 'contact'],
406
+ ]);
407
+ $connection->table('drivers')->insert(['uuid' => 'd-1', 'company_uuid' => 'company-1', 'user_uuid' => 'driver-1']);
408
+ $connection->table('contacts')->insert(['uuid' => 'c-1', 'company_uuid' => 'company-1', 'user_uuid' => 'contact-1', 'type' => 'contact']);
409
+
410
+ $conflict = function (callable $callback): ?ProfileIdentityConflictException {
411
+ try {
412
+ $callback();
413
+ } catch (ProfileIdentityConflictException $exception) {
414
+ return $exception;
415
+ }
416
+
417
+ return null;
418
+ };
419
+
420
+ $outsider = $conflict(fn () => ProfileAccountManager::resolveForProfile('company-1', 'driver', 'X', 'outsider@example.com', null));
421
+ $wrong = $conflict(fn () => ProfileAccountManager::resolveForProfile('company-1', 'driver', 'X', 'nobody@example.com', '+6590001111'));
422
+ $driver = $conflict(fn () => ProfileAccountManager::resolveForProfile('company-1', 'driver', 'X', 'driver@example.com', null));
423
+ $contact = $conflict(fn () => ProfileAccountManager::resolveForProfile('company-1', 'contact', 'X', 'contact@example.com', null));
424
+
425
+ expect($outsider?->getMessage())->toBe('This email is already in use by another account.')
426
+ ->and($outsider?->getField())->toBe('email')
427
+ ->and($outsider?->getErrors())->toBe(['email' => ['This email is already in use by another account.']])
428
+ ->and($wrong?->getMessage())->toBe('This phone number is already used by a customer.')
429
+ ->and($wrong?->getField())->toBe('phone')
430
+ ->and($driver?->getMessage())->toBe('A driver with this email already exists.')
431
+ ->and($contact?->getMessage())->toBe('A contact with this email already exists.')
432
+ ->and(ProfileAccountManager::hasProfileInCompany(User::where('uuid', 'driver-1')->first(), 'company-2', 'driver'))->toBeFalse()
433
+ ->and(ProfileAccountManager::isCompanyMember(User::where('uuid', 'driver-1')->first(), null))->toBeFalse()
434
+ ->and(new ProfileIdentityConflictException('Taken'))->getField()->toBe('email');
435
+ });
436
+
437
+ test('sync proxy fields pushes normalized changes to managed accounts and only the name to staff', function () {
438
+ $connection = fleetopsProfileAccountBoot();
439
+ $connection->table('users')->insert(['uuid' => 'other-1', 'email' => 'taken@example.com', 'phone' => '+6599990000', 'type' => 'user']);
440
+ $managed = fleetopsProfileAccountUser(['uuid' => 'managed-1', 'name' => 'Old', 'email' => 'old@example.com', 'phone' => '+6590000000', 'timezone' => 'UTC', 'type' => 'driver']);
441
+ $staff = fleetopsProfileAccountUser(['uuid' => 'staff-1', 'name' => 'Staff', 'email' => 'staff@example.com', 'phone' => '+6591111111', 'type' => 'user']);
442
+
443
+ expect(ProfileAccountManager::syncProxyFields(null, ['name' => 'Nobody']))->toBeFalse()
444
+ // Unchanged values and fields outside the proxy set are ignored
445
+ ->and(ProfileAccountManager::syncProxyFields($managed, ['name' => 'Old', 'email' => 'OLD@example.com', 'type' => 'admin', 'timezone' => null]))->toBeFalse();
446
+
447
+ expect(ProfileAccountManager::syncProxyFields($managed, [
448
+ 'name' => 'New',
449
+ 'email' => ' New@Example.com ',
450
+ 'phone' => '+65 9000 0001',
451
+ 'timezone' => 'Asia/Singapore',
452
+ 'password' => 'new-secret',
453
+ ]))->toBeTrue();
454
+
455
+ $row = $connection->table('users')->where('uuid', 'managed-1')->first();
456
+ expect($row->name)->toBe('New')
457
+ ->and($row->email)->toBe('new@example.com')
458
+ ->and($row->phone)->toBe('+6590000001')
459
+ ->and($row->timezone)->toBe('Asia/Singapore')
460
+ ->and($row->password)->toBe('hashed:new-secret');
461
+
462
+ // Clearing the phone is a change the account takes
463
+ expect(ProfileAccountManager::syncProxyFields($managed, ['phone' => null]))->toBeTrue()
464
+ ->and($connection->table('users')->where('uuid', 'managed-1')->value('phone'))->toBeNull();
465
+
466
+ // A staff account only takes the name
467
+ expect(ProfileAccountManager::syncProxyFields($staff, ['name' => 'Staff Driver', 'email' => 'changed@example.com', 'phone' => null]))->toBeTrue()
468
+ ->and($connection->table('users')->where('uuid', 'staff-1')->first())
469
+ ->name->toBe('Staff Driver')
470
+ ->email->toBe('staff@example.com')
471
+ ->phone->toBe('+6591111111');
472
+
473
+ // Taking another account's email or phone is refused
474
+ $emailConflict = null;
475
+ try {
476
+ ProfileAccountManager::syncProxyFields($managed, ['email' => 'Taken@example.com']);
477
+ } catch (ProfileIdentityConflictException $exception) {
478
+ $emailConflict = $exception;
479
+ }
480
+
481
+ $phoneConflict = null;
482
+ try {
483
+ ProfileAccountManager::syncProxyFields($managed, ['email' => 'new@example.com', 'phone' => '+6599990000']);
484
+ } catch (ProfileIdentityConflictException $exception) {
485
+ $phoneConflict = $exception;
486
+ }
487
+
488
+ expect($emailConflict?->getMessage())->toBe('This email is already in use by another account.')
489
+ ->and($emailConflict?->getField())->toBe('email')
490
+ ->and($phoneConflict?->getMessage())->toBe('This phone number is already in use by another account.')
491
+ ->and($phoneConflict?->getField())->toBe('phone')
492
+ ->and($connection->table('users')->where('uuid', 'managed-1')->value('email'))->toBe('new@example.com');
493
+ });
494
+
495
+ test('release for profile deletes an unused managed account and leaves staff alone', function () {
496
+ $connection = fleetopsProfileAccountBoot();
497
+ config()->set('activitylog.enabled', false);
498
+ $lonely = fleetopsProfileAccountUser(['uuid' => 'lonely-1', 'type' => 'driver']);
499
+ $shared = fleetopsProfileAccountUser(['uuid' => 'shared-1', 'type' => 'contact']);
500
+ $staying = fleetopsProfileAccountUser(['uuid' => 'staying-1', 'type' => 'driver']);
501
+ $staff = fleetopsProfileAccountUser(['uuid' => 'staff-1', 'type' => 'user']);
502
+ $connection->table('contacts')->insert(['uuid' => 'c-2', 'company_uuid' => 'company-2', 'user_uuid' => 'shared-1', 'type' => 'contact']);
503
+ $connection->table('drivers')->insert(['uuid' => 'd-1', 'company_uuid' => 'company-1', 'user_uuid' => 'staying-1']);
504
+ $connection->table('company_users')->insert([
505
+ ['uuid' => 'cu-1', 'company_uuid' => 'company-1', 'user_uuid' => 'shared-1'],
506
+ ['uuid' => 'cu-2', 'company_uuid' => 'company-2', 'user_uuid' => 'shared-1'],
507
+ ['uuid' => 'cu-3', 'company_uuid' => 'company-1', 'user_uuid' => 'staying-1'],
508
+ ]);
509
+
510
+ ProfileAccountManager::releaseForProfile(null, 'company-1');
511
+ ProfileAccountManager::releaseForProfile($staff, 'company-1');
512
+ ProfileAccountManager::releaseForProfile($lonely, 'company-1');
513
+ // Already deleted accounts are skipped
514
+ ProfileAccountManager::releaseForProfile($lonely, 'company-1');
515
+ ProfileAccountManager::releaseForProfile($shared, 'company-1');
516
+ ProfileAccountManager::releaseForProfile($staying, 'company-1');
517
+ ProfileAccountManager::releaseForProfile($staying);
518
+
519
+ expect($connection->table('users')->where('uuid', 'lonely-1')->value('deleted_at'))->not->toBeNull()
520
+ ->and($lonely->trashed())->toBeTrue()
521
+ ->and($connection->table('users')->where('uuid', 'staff-1')->value('deleted_at'))->toBeNull()
522
+ ->and($connection->table('users')->where('uuid', 'shared-1')->value('deleted_at'))->toBeNull()
523
+ // The shared account only leaves the organization it has no profile in
524
+ ->and($connection->table('company_users')->whereNull('deleted_at')->where('user_uuid', 'shared-1')->pluck('company_uuid')->all())->toBe(['company-2'])
525
+ ->and($connection->table('users')->where('uuid', 'staying-1')->value('deleted_at'))->toBeNull()
526
+ ->and($connection->table('company_users')->whereNull('deleted_at')->where('user_uuid', 'staying-1')->count())->toBe(1)
527
+ ->and(ProfileAccountManager::profileCompanies($shared)->all())->toBe(['company-2']);
528
+ });
529
+
530
+ test('attach to company adds membership once and only re-roles managed accounts', function () {
531
+ $connection = fleetopsProfileAccountBoot();
532
+ $managed = fleetopsProfileAccountUser(['uuid' => 'managed-1', 'company_uuid' => 'company-2', 'type' => 'customer']);
533
+ $staff = fleetopsProfileAccountUser(['uuid' => 'staff-1', 'company_uuid' => 'company-1', 'type' => 'user']);
534
+ $connection->table('company_users')->insert(['uuid' => 'cu-staff', 'company_uuid' => 'company-1', 'user_uuid' => 'staff-1', 'status' => 'active']);
535
+
536
+ expect(ProfileAccountManager::attachToCompany($managed, 'company-missing', 'customer'))->toBeNull();
537
+
538
+ $first = ProfileAccountManager::attachToCompany($managed, 'company-1', 'customer');
539
+ $second = ProfileAccountManager::attachToCompany($managed, 'company-1', 'unknown-type');
540
+ $staffMembership = ProfileAccountManager::attachToCompany($staff, 'company-1', 'driver');
541
+
542
+ expect($first->uuid)->toBe($second->uuid)
543
+ ->and($connection->table('company_users')->where('user_uuid', 'managed-1')->count())->toBe(1)
544
+ // An unknown type falls back to the contact role
545
+ ->and(fleetopsProfileAccountRoles($connection, 'managed-1', 'company-1'))->toBe(['Fleet-Ops Contact'])
546
+ ->and($staffMembership->uuid)->toBe('cu-staff')
547
+ ->and($connection->table('model_has_roles')->where('model_uuid', 'cu-staff')->count())->toBe(0)
548
+ ->and($connection->table('users')->where('uuid', 'managed-1')->value('company_uuid'))->toBe('company-2');
549
+ });
550
+
551
+ test('send credentials resets the password activates the account and emails or texts it', function () {
552
+ $connection = fleetopsProfileAccountBoot();
553
+ $mail = Illuminate\Support\Facades\Mail::getFacadeRoot();
554
+ $sms = app(SmsService::class);
555
+
556
+ $emailUser = fleetopsProfileAccountUser(['uuid' => 'email-1', 'company_uuid' => 'company-1', 'email' => 'driver@example.com', 'status' => 'pending', 'type' => 'driver']);
557
+ $driver = new Driver();
558
+ $driver->setRawAttributes(['uuid' => 'd-1', 'company_uuid' => 'company-1', 'user_uuid' => 'email-1'], true);
559
+ $driver->setRelation('company', (object) ['name' => 'Acme']);
560
+
561
+ expect(ProfileAccountManager::sendCredentials($driver, $emailUser, 'chosen-secret'))->toBe('email')
562
+ ->and($connection->table('users')->where('uuid', 'email-1')->value('password'))->toBe('hashed:chosen-secret')
563
+ ->and($connection->table('users')->where('uuid', 'email-1')->value('status'))->toBe('active')
564
+ ->and($mail->sent[0])->toBeInstanceOf(DriverCredentialsMail::class)
565
+ ->and($mail->to[0])->toBe($emailUser);
566
+
567
+ // An active account is not re-activated; a random password is generated
568
+ $customerUser = fleetopsProfileAccountUser(['uuid' => 'email-2', 'company_uuid' => 'company-1', 'email' => 'customer@example.com', 'status' => 'active', 'type' => 'customer']);
569
+ $customer = new Contact();
570
+ $customer->setRawAttributes(['uuid' => 'c-1', 'company_uuid' => 'company-1', 'type' => 'customer'], true);
571
+
572
+ expect(ProfileAccountManager::sendCredentials($customer, $customerUser))->toBe('email')
573
+ ->and($connection->table('users')->where('uuid', 'email-2')->value('password'))->toStartWith('hashed:')
574
+ ->and($mail->sent[1])->toBeInstanceOf(CustomerCredentialsMail::class);
575
+
576
+ // Without an email the credentials are texted, naming the organization
577
+ $phoneUser = fleetopsProfileAccountUser(['uuid' => 'phone-1', 'phone' => '+6590001111', 'status' => 'active', 'type' => 'driver']);
578
+ expect(ProfileAccountManager::deliverCredentials($driver, $phoneUser, 'texted-secret'))->toBe('sms')
579
+ ->and($sms->sent[0])->toBe(['+6590001111', 'Your Acme sign-in details. Login: +6590001111 Password: texted-secret']);
580
+
581
+ // An organization-less profile falls back to the app name
582
+ $orphan = new Driver();
583
+ $orphan->setRawAttributes(['uuid' => 'd-2'], true);
584
+ $orphan->setRelation('company', null);
585
+ ProfileAccountManager::deliverCredentials($orphan, $phoneUser, 'app-secret');
586
+ expect($sms->sent[1][1])->toBe('Your Fleetbase sign-in details. Login: +6590001111 Password: app-secret');
587
+
588
+ // A refused text surfaces the provider's reason
589
+ $sms->result = ['success' => false, 'error' => 'Invalid number'];
590
+ expect(fn () => ProfileAccountManager::deliverCredentials($driver, $phoneUser, 'x'))
591
+ ->toThrow(Exception::class, 'The credentials could not be texted: Invalid number');
592
+
593
+ $sms->result = ['success' => false, 'message' => 'Quota exceeded'];
594
+ expect(fn () => ProfileAccountManager::deliverCredentials($driver, $phoneUser, 'x'))
595
+ ->toThrow(Exception::class, 'The credentials could not be texted: Quota exceeded');
596
+
597
+ $sms->result = ['success' => false];
598
+ expect(fn () => ProfileAccountManager::deliverCredentials($driver, $phoneUser, 'x'))
599
+ ->toThrow(Exception::class, 'The credentials could not be texted: the SMS provider refused it.');
600
+
601
+ // A result without a success flag is treated as sent
602
+ $sms->result = [];
603
+ expect(ProfileAccountManager::deliverCredentials($driver, $phoneUser, 'x'))->toBe('sms');
604
+
605
+ $unreachable = fleetopsProfileAccountUser(['uuid' => 'none-1', 'status' => 'active', 'type' => 'driver']);
606
+ expect(fn () => ProfileAccountManager::deliverCredentials($driver, $unreachable, 'x'))
607
+ ->toThrow(Exception::class, 'This profile has no email or phone to send credentials to.');
608
+ });
609
+
610
+ test('driver credentials mail names the organization and the sign-in identity', function () {
611
+ config()->set('app.name', 'Fleetbase');
612
+ $user = new User();
613
+ $user->setRawAttributes(['name' => 'Dana', 'email' => null, 'phone' => '+6590001111'], true);
614
+
615
+ $driver = new Driver();
616
+ $driver->setRelation('company', (object) ['name' => 'Acme']);
617
+
618
+ $mail = new DriverCredentialsMail('plain-secret', $driver, $user);
619
+ $content = $mail->content();
620
+
621
+ expect($mail->envelope()->subject)->toBe('Your Acme driver sign-in details')
622
+ ->and($content->markdown)->toBe('fleetops::mail.driver-credentials')
623
+ ->and($content->with)->toMatchArray([
624
+ 'driver' => $driver,
625
+ 'user' => $user,
626
+ 'companyName' => 'Acme',
627
+ 'identity' => '+6590001111',
628
+ 'plaintextPassword' => 'plain-secret',
629
+ ]);
630
+
631
+ $user->setRawAttributes(['name' => 'Dana', 'email' => 'dana@example.com'], true);
632
+ $orphan = new Driver();
633
+ $orphan->setRelation('company', null);
634
+ $orphanMail = new DriverCredentialsMail('plain-secret', $orphan, $user);
635
+
636
+ expect($orphanMail->envelope()->subject)->toBe('Your Fleetbase driver sign-in details')
637
+ ->and($orphanMail->content()->with)->toMatchArray(['companyName' => 'Fleetbase', 'identity' => 'dana@example.com']);
638
+
639
+ $view = file_get_contents(__DIR__ . '/../../../resources/views/mail/driver-credentials.blade.php');
640
+ expect($view)->toContain('{{ $identity }}')
641
+ ->toContain('{{ $plaintextPassword }}')
642
+ ->toContain('{{ $companyName }}');
643
+ });