@fleetbase/fleetops-engine 0.6.67 → 0.6.69

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/RELEASE.md +11 -15
  2. package/addon/components/contact/form.hbs +13 -2
  3. package/addon/components/customer/details.hbs +17 -17
  4. package/addon/components/customer/form.hbs +13 -2
  5. package/addon/components/customer/form.js +0 -56
  6. package/addon/components/driver/details.hbs +13 -19
  7. package/addon/components/driver/form.hbs +20 -35
  8. package/addon/components/driver/form.js +0 -58
  9. package/addon/components/fuel-integration/form.hbs +2 -2
  10. package/addon/components/fuel-integration/form.js +8 -0
  11. package/addon/components/fuel-integration/hub.js +1 -1
  12. package/addon/components/modals/driver-form.hbs +2 -2
  13. package/addon/components/modals/reset-profile-credentials.hbs +23 -0
  14. package/addon/components/modals/reset-profile-credentials.js +70 -0
  15. package/addon/components/profile-identity-hint.hbs +13 -0
  16. package/addon/components/profile-identity-hint.js +49 -0
  17. package/addon/controllers/management/drivers/index.js +4 -0
  18. package/addon/extension.js +4 -2
  19. package/addon/services/contact-actions.js +50 -89
  20. package/addon/services/driver-actions.js +98 -0
  21. package/addon/templates/connectivity/fuel-providers/details/index.hbs +1 -1
  22. package/addon/utils/fuel-transaction.js +2 -0
  23. package/addon/utils/profile-login-actions.js +172 -0
  24. package/app/components/modals/{reset-customer-credentials.js → reset-profile-credentials.js} +1 -1
  25. package/app/components/profile-identity-hint.js +1 -0
  26. package/composer.json +1 -1
  27. package/docs/SAFEE.md +5 -0
  28. package/docs/TELEMATICS_QUEUES.md +59 -8
  29. package/extension.json +1 -1
  30. package/package.json +3 -3
  31. package/scripts/pest-bootstrap.php +12 -0
  32. package/server/config/fuel-providers.php +53 -0
  33. package/server/migrations/2026_09_21_000001_convert_profile_only_users_to_managed_accounts.php +132 -0
  34. package/server/resources/views/mail/driver-credentials.blade.php +21 -0
  35. package/server/src/Console/Commands/FixCustomerCompanies.php +1 -1
  36. package/server/src/Console/Commands/FixDriverCompanies.php +1 -1
  37. package/server/src/Exceptions/ProfileIdentityConflictException.php +34 -0
  38. package/server/src/Http/Controllers/Api/v1/CustomerController.php +14 -0
  39. package/server/src/Http/Controllers/Api/v1/DriverController.php +56 -39
  40. package/server/src/Http/Controllers/Internal/v1/ContactController.php +5 -17
  41. package/server/src/Http/Controllers/Internal/v1/CustomerController.php +40 -4
  42. package/server/src/Http/Controllers/Internal/v1/DriverController.php +182 -162
  43. package/server/src/Http/Controllers/Internal/v1/FleetOpsLookupController.php +47 -0
  44. package/server/src/Http/Controllers/Internal/v1/OrderController.php +156 -27
  45. package/server/src/Http/Requests/CreateDriverRequest.php +6 -2
  46. package/server/src/Http/Requests/Internal/CreateDriverRequest.php +9 -18
  47. package/server/src/Http/Resources/v1/Contact.php +3 -0
  48. package/server/src/Http/Resources/v1/Driver.php +3 -0
  49. package/server/src/Jobs/PollTelematicTelemetry.php +36 -0
  50. package/server/src/Jobs/SyncTelematicDevicesJob.php +2 -2
  51. package/server/src/Mail/DriverCredentialsMail.php +55 -0
  52. package/server/src/Models/Contact.php +55 -90
  53. package/server/src/Models/Sensor.php +3 -5
  54. package/server/src/Observers/ContactObserver.php +2 -22
  55. package/server/src/Observers/DriverObserver.php +5 -6
  56. package/server/src/Providers/FleetOpsServiceProvider.php +15 -0
  57. package/server/src/Support/Ai/Capabilities/AbstractFleetOpsAICapability.php +62 -5
  58. package/server/src/Support/Ai/Capabilities/AssetStatusCapability.php +24 -5
  59. package/server/src/Support/Ai/Capabilities/CreateOrderPreviewCapability.php +6 -2
  60. package/server/src/Support/Ai/Capabilities/OrderInsightsCapability.php +40 -7
  61. package/server/src/Support/Ai/Capabilities/SearchResourcesCapability.php +86 -24
  62. package/server/src/Support/Ai/FleetOpsAiConsoleCommands.php +122 -0
  63. package/server/src/Support/Ai/Tools/CreateOrderTool.php +86 -0
  64. package/server/src/Support/Ai/Tools/ImportOrdersTool.php +53 -0
  65. package/server/src/Support/Ai/Tools/OptimizeOrderRouteTool.php +70 -0
  66. package/server/src/Support/Ai/Tools/SearchResourcesTool.php +73 -0
  67. package/server/src/Support/FuelProviders/Providers/SascoFuelProvider.php +272 -0
  68. package/server/src/Support/ProfileAccountManager.php +405 -0
  69. package/server/src/Support/Telematics/TelematicService.php +6 -4
  70. package/server/src/Support/Telematics/Telemetry/Inbox.php +27 -9
  71. package/server/src/routes.php +5 -0
  72. package/server/tests/AiOperationalQueryCapabilityTest.php +1 -0
  73. package/server/tests/ApiCustomerControllerContractsTest.php +21 -0
  74. package/server/tests/ApiDriverControllerContractsTest.php +102 -20
  75. package/server/tests/CommandContractsTest.php +4 -4
  76. package/server/tests/ControllerHelperContractsTest.php +3 -11
  77. package/server/tests/Feature/Http/AfaqyRealtimeIngestionTest.php +5 -0
  78. package/server/tests/Feature/Http/Api/DriverControllerAuthFlowsTest.php +36 -0
  79. package/server/tests/Feature/Http/Api/DriverControllerSessionHelpersTest.php +19 -6
  80. package/server/tests/Feature/Http/Api/ProfileOnlyUserConversionMigrationTest.php +201 -0
  81. package/server/tests/Feature/Http/Internal/ContactControllerEndpointsTest.php +6 -4
  82. package/server/tests/Feature/Http/Internal/CustomerControllerCredentialsTest.php +6 -2
  83. package/server/tests/Feature/Http/Internal/DriverControllerContractsTest.php +23 -0
  84. package/server/tests/Feature/Http/Internal/DriverControllerCreateRecordTest.php +42 -17
  85. package/server/tests/Feature/Http/Internal/DriverControllerExistingUserAdoptionTest.php +168 -277
  86. package/server/tests/Feature/Http/Internal/DriverControllerLoginManagementTest.php +716 -0
  87. package/server/tests/Feature/Http/Internal/OrderControllerContractsTest.php +10 -3
  88. package/server/tests/Feature/Http/Internal/OrderControllerImportFromFilesTest.php +18 -0
  89. package/server/tests/Feature/Http/Internal/OrderControllerTenantScopingTest.php +411 -0
  90. package/server/tests/Feature/Http/Internal/OrderControllerUpstreamNotFoundTest.php +8 -20
  91. package/server/tests/Feature/Http/ManualTelemetryPollingTest.php +88 -4
  92. package/server/tests/InternalCustomerControllerContractsTest.php +32 -1
  93. package/server/tests/ModelAccessorContractsTest.php +82 -20
  94. package/server/tests/ObserverContractsTest.php +27 -2
  95. package/server/tests/RequestContractsTest.php +19 -11
  96. package/server/tests/SupportJobAndAiCoverageTest.php +3 -3
  97. package/server/tests/TelematicControllerContractsTest.php +22 -4
  98. package/server/tests/Unit/Models/ContactCustomerUserTest.php +90 -14
  99. package/server/tests/Unit/Models/ContactTest.php +48 -5
  100. package/server/tests/Unit/Models/SensorTest.php +2 -2
  101. package/server/tests/Unit/Models/SmallModelSurfacesTest.php +31 -32
  102. package/server/tests/Unit/Observers/ObserverListenerSeamsTest.php +7 -2
  103. package/server/tests/Unit/Providers/FleetOpsAiRegistrationTest.php +15 -2
  104. package/server/tests/Unit/Support/Ai/AssetStatusCapabilityQueriesTest.php +42 -0
  105. package/server/tests/Unit/Support/Ai/Capabilities/OrderInsightsCapabilityTest.php +19 -1
  106. package/server/tests/Unit/Support/Ai/Capabilities/SearchResourcesCapabilityTest.php +85 -1
  107. package/server/tests/Unit/Support/Ai/FleetOpsAiConsoleCommandsTest.php +31 -0
  108. package/server/tests/Unit/Support/Ai/Tools/FleetOpsAiToolsTest.php +246 -0
  109. package/server/tests/Unit/Support/AiCapabilityPermissionsTest.php +3 -3
  110. package/server/tests/Unit/Support/ProfileAccountManagerTest.php +643 -0
  111. package/server/tests/Unit/Support/SascoFuelProviderTest.php +338 -0
  112. package/translations/en-us.yaml +37 -2
  113. package/translations/uk-ua.yaml +3475 -0
  114. package/addon/components/modals/reset-customer-credentials.hbs +0 -18
  115. package/addon/components/modals/reset-customer-credentials.js +0 -55
@@ -51,28 +51,56 @@ no worker wait indefinitely.
51
51
 
52
52
  ### 1. Add telematics workers
53
53
 
54
- Add worker services with the same image, environment, and volumes as the existing
55
- `queue` service. Change only the command and healthcheck. Docker Compose example:
54
+ Each telematics worker must be an exact copy of the existing `queue` worker: the
55
+ same image and tag, the same `env_file`, `environment`, volumes and `depends_on`,
56
+ including every Compose override file that changes `queue`. Change only the command.
57
+ Workers running a different build, or missing `APP_KEY`, fail every telematics job.
58
+
59
+ Docker Compose example, assuming `queue` is defined in `docker-compose.yml` and any
60
+ image or environment overrides for it live in `docker-compose.override.yml`:
56
61
 
57
62
  ```yaml
63
+ # docker-compose.yml: copy the full `queue` service definition, then change the command.
58
64
  services:
59
65
  telematics-queue:
60
- image: fleetbase/fleetbase-api:latest
66
+ # image, env_file, environment, volumes, depends_on: identical to `queue`
61
67
  command: ["php", "artisan", "queue:work", "--queue=telematics", "--sleep=1", "--max-time=3600"]
62
68
  restart: unless-stopped
63
69
  deploy:
64
70
  replicas: 2
65
- # environment, volumes and depends_on: copy from the existing `queue` service
66
71
 
67
72
  telematics-broadcast-queue:
68
- image: fleetbase/fleetbase-api:latest
73
+ # image, env_file, environment, volumes, depends_on: identical to `queue`
69
74
  command: ["php", "artisan", "queue:work", "--queue=telematics-broadcasts", "--sleep=1", "--max-time=3600"]
70
75
  restart: unless-stopped
71
- # environment, volumes and depends_on: copy from the existing `queue` service
76
+ ```
77
+
78
+ ```yaml
79
+ # docker-compose.override.yml: repeat every override that applies to `queue`.
80
+ services:
81
+ telematics-queue:
82
+ image: "fleetbase-api:latest" # the same image as `queue`
83
+ telematics-broadcast-queue:
84
+ image: "fleetbase-api:latest"
72
85
  ```
73
86
 
74
87
  Notes:
75
88
 
89
+ - **Use the same image as `queue`, not a separate `fleetbase/fleetbase-api:latest`.**
90
+ Docker does not download a newer `latest` when a copy already exists locally, so a
91
+ new service can start from an old cached image. If you do use a registry image,
92
+ pin a version tag and pull it for every API container together.
93
+ - **Every API container needs the same `APP_KEY`**: application, scheduler, the
94
+ default worker and the telematics workers. Provider credentials, token caches and
95
+ telematics deliveries are encrypted with it. If only the application service
96
+ receives it (for example through a mounted `.env` or an override), put it in a
97
+ shared `env_file` instead. Never generate a new key for a worker.
98
+ - **Check the merged configuration, including when using `extends`.** Run
99
+ `docker compose config` and compare the resolved `image`, `env_file` and
100
+ `environment` of `queue` and each telematics service before deploying.
101
+ - The `--queue` names must match the variables in step 2 exactly. A worker listening
102
+ on `telematics-broadcast` while broadcasts go to `telematics-broadcasts` receives
103
+ nothing.
76
104
  - Leave the existing `queue` service unchanged. `queue:work` without `--queue`
77
105
  processes the connection's default queue (`REDIS_QUEUE`, normally `default`).
78
106
  - Leave `--timeout` at its default (60 seconds) or set any value below the Redis
@@ -83,8 +111,6 @@ Notes:
83
111
  memory. `restart: unless-stopped` starts them again.
84
112
  - On Kubernetes, ECS, or another platform, create equivalent deployments with the
85
113
  same image, environment, and commands.
86
- - Every worker container needs the same `APP_KEY`, database, Redis, and broadcasting
87
- settings as the application. Deliveries are encrypted with `APP_KEY`.
88
114
 
89
115
  ### 2. Set the queue variables for this instance only
90
116
 
@@ -154,6 +180,19 @@ of 50 requests per second per account across all workers.
154
180
 
155
181
  ## Verification
156
182
 
183
+ First confirm every API container runs the same build and application key. Each line
184
+ must show the same key fingerprint (a hash, never the key) and Fleet-Ops version:
185
+
186
+ ```bash
187
+ for c in $(docker ps --format '{{.Names}}' | grep -E 'application|scheduler|queue'); do echo "== $c $(docker inspect --format '{{.Config.Image}}' $c)"; docker exec $c php artisan tinker --execute="echo config('app.key') ? substr(hash('sha256', config('app.key')), 0, 12) : 'MISSING', ' ', json_decode(file_get_contents('vendor/fleetbase/fleetops-api/composer.json'))->version, PHP_EOL;"; done
188
+ ```
189
+
190
+ Confirm Laravel loaded the queue names in the application and a telematics worker:
191
+
192
+ ```bash
193
+ php artisan config:show telematics.telemetry
194
+ ```
195
+
157
196
  Queue depth should stay close to zero between minute ticks:
158
197
 
159
198
  ```bash
@@ -168,6 +207,18 @@ Also verify:
168
207
  on the telematics workers, and broadcast jobs only on the broadcast worker.
169
208
  - Other jobs on `default`, such as notifications and order events, start promptly
170
209
  again.
210
+ - The connection's status and "Last sync" update after each completed scheduled
211
+ sweep. Clicking "Sync Devices" while a sweep is queued or running records the
212
+ request, and the next sweep completes it.
213
+
214
+ ## Troubleshooting
215
+
216
+ | Symptom | Cause |
217
+ | --- | --- |
218
+ | Telematics worker logs show `MissingAppKeyException` | The worker has no `APP_KEY`. Give every API container the same key and recreate the workers. |
219
+ | A worker reports a different Fleet-Ops version | The worker uses a different or stale cached image. Use the `queue` service's image. |
220
+ | Broadcast jobs are never processed | The broadcast worker's `--queue` name does not match `TELEMATICS_BROADCAST_QUEUE`. |
221
+ | Scheduled sync commands report `Queued 0 telematics sync job(s)` for an enabled connection | A poll job failed on a worker that could not load it, leaving its one-hour uniqueness lock. It expires on its own; after fixing the workers you can release it with `Cache::lock('laravel_unique_job:Fleetbase\\FleetOps\\Jobs\\PollTelematicTelemetry' . $telematicUuid)->forceRelease()`. |
171
222
 
172
223
  ## Rollback
173
224
 
package/extension.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "Fleet-Ops",
3
- "version": "0.6.67",
3
+ "version": "0.6.69",
4
4
  "description": "Fleet & Transport Management Extension for Fleetbase",
5
5
  "repository": "https://github.com/fleetbase/fleetops",
6
6
  "license": "AGPL-3.0-or-later",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fleetbase/fleetops-engine",
3
- "version": "0.6.67",
3
+ "version": "0.6.69",
4
4
  "description": "Fleet & Transport Management Extension for Fleetbase",
5
5
  "fleetbase": {
6
6
  "route": "fleet-ops"
@@ -45,8 +45,8 @@
45
45
  "dependencies": {
46
46
  "@babel/core": "^7.23.2",
47
47
  "@fleetbase/ember-core": "^0.3.24",
48
- "@fleetbase/ember-ui": "^0.4.1",
49
- "@fleetbase/fleetops-data": "^0.2.1",
48
+ "@fleetbase/ember-ui": "^0.4.3",
49
+ "@fleetbase/fleetops-data": "^0.2.2",
50
50
  "@fleetbase/leaflet-routing-machine": "^3.2.17",
51
51
  "@fortawesome/ember-fontawesome": "^2.0.0",
52
52
  "@fortawesome/fontawesome-svg-core": "6.4.0",
@@ -382,6 +382,18 @@ if (!interface_exists('Fleetbase\Ai\Contracts\AIActionCapabilityInterface')) {
382
382
  eval('namespace Fleetbase\Ai\Contracts; interface AIActionCapabilityInterface {}');
383
383
  }
384
384
 
385
+ if (!interface_exists('Fleetbase\Ai\Contracts\AIToolCapabilityInterface')) {
386
+ eval('namespace Fleetbase\Ai\Contracts; interface AIToolCapabilityInterface {}');
387
+ }
388
+
389
+ if (!class_exists('Fleetbase\Ai\Support\AiAudience')) {
390
+ eval('namespace Fleetbase\Ai\Support; class AiAudience { public function __construct(public bool $isSystemAdmin = false, public array $granted = []) {} public function can(string $permission): bool { return $this->isSystemAdmin || in_array($permission, $this->granted, true); } public function canAll(array $permissions): bool { foreach ($permissions as $permission) { if (!$this->can($permission)) { return false; } } return true; } }');
391
+ }
392
+
393
+ if (!class_exists('Fleetbase\Ai\Support\AiToolContext')) {
394
+ eval('namespace Fleetbase\Ai\Support; class AiToolContext { public array $actionPreviews = []; public array $uiActions = []; public function __construct(public $task, public $audience) {} public function addActionPreview($capability, array $preview): array { $preview = array_merge(["preview_id" => "preview-" . (count($this->actionPreviews) + 1), "key" => $capability->key()], $preview); $this->actionPreviews[] = $preview; return $preview; } }');
395
+ }
396
+
385
397
  if (!class_exists('Fleetbase\Ai\Models\AiTask')) {
386
398
  eval('namespace Fleetbase\Ai\Models; class AiTask { public function __construct(array $attributes = []) { foreach ($attributes as $key => $value) { $this->{$key} = $value; } } }');
387
399
  }
@@ -55,9 +55,62 @@ return [
55
55
  'auth_type' => 'ws_sk_header',
56
56
  'supported_auth_types' => ['ws_sk_header', 'bearer_token'],
57
57
  'base_urls' => \Fleetbase\FleetOps\Support\FuelProviders\Providers\PetroAppFuelProvider::BASE_URLS,
58
+ 'environment_labels' => ['sandbox' => 'Sandbox (PetroApp staging)', 'production' => 'Production'],
58
59
  'pagination' => 'page',
59
60
  'default_currency' => 'SAR',
60
61
  ],
61
62
  ],
63
+ [
64
+ 'key' => 'sasco',
65
+ 'label' => 'SASCO',
66
+ 'type' => 'native',
67
+ 'category' => 'Fuel card integration',
68
+ 'icon' => 'gas-pump',
69
+ 'driver_class' => \Fleetbase\FleetOps\Support\FuelProviders\Providers\SascoFuelProvider::class,
70
+ 'description' => 'SASCO B2B fleet fuel transactions and vehicles.',
71
+ 'required_fields' => [
72
+ [
73
+ 'name' => 'username',
74
+ 'label' => 'B2B admin username',
75
+ 'type' => 'text',
76
+ 'help_text' => 'The SASCO B2B company admin login, usually an email address.',
77
+ 'required' => true,
78
+ ],
79
+ [
80
+ 'name' => 'password',
81
+ 'label' => 'B2B admin password',
82
+ 'type' => 'password',
83
+ 'help_text' => 'Used to request a short-lived access token from SASCO. Fleetbase re-authenticates automatically when it expires.',
84
+ 'required' => true,
85
+ ],
86
+ [
87
+ 'name' => 'base_url',
88
+ 'label' => 'Base URL override (optional)',
89
+ 'type' => 'url',
90
+ 'help_text' => 'Leave blank to use the selected environment. A custom URL overrides the environment selection.',
91
+ 'required' => false,
92
+ ],
93
+ ],
94
+ 'capabilities' => ['vehicles', 'transactions'],
95
+ 'sync_defaults' => [
96
+ 'window_days' => 7,
97
+ 'matching_order' => ['plate_number', 'internal_id', 'vin', 'serial_number', 'call_sign', 'fuel_card_number', 'trip_number'],
98
+ 'auto_create_fuel_reports' => true,
99
+ ],
100
+ 'setup_instructions' => [
101
+ 'Request B2B API access from SASCO and obtain the company admin username and password for QC/UAT and Production.',
102
+ 'Select Sandbox for the SASCO QC environment, enter the credentials, and run Test Connection.',
103
+ 'SASCO transactions carry the vehicle plate only, so make sure Fleetbase vehicle plate numbers use the letters-then-digits format (for example NXD 1240).',
104
+ 'Sync a small date window first to verify imported fuel transactions and vehicle matches.',
105
+ ],
106
+ 'metadata' => [
107
+ 'auth_type' => 'username_password',
108
+ 'supported_auth_types' => ['username_password'],
109
+ 'base_urls' => \Fleetbase\FleetOps\Support\FuelProviders\Providers\SascoFuelProvider::BASE_URLS,
110
+ 'environment_labels' => ['sandbox' => 'Sandbox (SASCO QC)', 'production' => 'Production'],
111
+ 'pagination' => 'current_page',
112
+ 'default_currency' => 'SAR',
113
+ ],
114
+ ],
62
115
  ],
63
116
  ];
@@ -0,0 +1,132 @@
1
+ <?php
2
+
3
+ use Illuminate\Database\Migrations\Migration;
4
+ use Illuminate\Support\Facades\DB;
5
+ use Illuminate\Support\Facades\Schema;
6
+
7
+ /*
8
+ * Driver and customer login accounts are now managed by their FleetOps
9
+ * profile (user type `driver` or `customer`) instead of in IAM.
10
+ *
11
+ * Before this change the driver form created plain `user` accounts holding only
12
+ * the Driver role, so these accounts show up in IAM and can sign in to the
13
+ * console. This converts them to managed accounts. An account is converted only
14
+ * when it is clearly a profile login and nothing else: it has a driver or
15
+ * customer profile, every organization role it holds is the profile's role, it
16
+ * has no direct permissions or policies, and it owns no organization. Anything
17
+ * else is left as a team member.
18
+ *
19
+ * The previous type is kept in `meta.previous_type` so down() can revert it.
20
+ */
21
+ return new class extends Migration {
22
+ private const PROFILE_ROLES = [
23
+ 'driver' => 'Driver',
24
+ 'customer' => 'Fleet-Ops Customer',
25
+ ];
26
+
27
+ public function up(): void
28
+ {
29
+ if (!$this->hasRequiredTables()) {
30
+ return;
31
+ }
32
+
33
+ DB::table('users')
34
+ ->select(['uuid', 'meta'])
35
+ ->where('type', 'user')
36
+ ->whereNull('deleted_at')
37
+ ->where(function ($query) {
38
+ $query->whereExists(function ($query) {
39
+ $query->selectRaw(1)->from('drivers')->whereColumn('drivers.user_uuid', 'users.uuid')->whereNull('drivers.deleted_at');
40
+ })->orWhereExists(function ($query) {
41
+ $query->selectRaw(1)->from('contacts')->whereColumn('contacts.user_uuid', 'users.uuid')->where('contacts.type', 'customer')->whereNull('contacts.deleted_at');
42
+ });
43
+ })
44
+ ->whereNotExists(function ($query) {
45
+ $query->selectRaw(1)->from('companies')->whereColumn('companies.owner_uuid', 'users.uuid');
46
+ })
47
+ ->orderBy('uuid')
48
+ ->chunk(500, function ($users) {
49
+ foreach ($users as $user) {
50
+ $type = $this->managedTypeFor($user->uuid);
51
+ if (!$type) {
52
+ continue;
53
+ }
54
+
55
+ $meta = json_decode($user->meta ?? '', true);
56
+ $meta = is_array($meta) ? $meta : [];
57
+ $meta['previous_type'] = 'user';
58
+
59
+ DB::table('users')->where('uuid', $user->uuid)->update(['type' => $type, 'meta' => json_encode($meta)]);
60
+ }
61
+ });
62
+ }
63
+
64
+ public function down(): void
65
+ {
66
+ if (!Schema::hasTable('users')) {
67
+ return;
68
+ }
69
+
70
+ DB::table('users')
71
+ ->select(['uuid', 'meta'])
72
+ ->whereIn('type', array_keys(self::PROFILE_ROLES))
73
+ ->where('meta', 'like', '%"previous_type"%')
74
+ ->orderBy('uuid')
75
+ ->chunk(500, function ($users) {
76
+ foreach ($users as $user) {
77
+ $meta = json_decode($user->meta ?? '', true);
78
+ if (!is_array($meta) || ($meta['previous_type'] ?? null) !== 'user') {
79
+ continue;
80
+ }
81
+
82
+ unset($meta['previous_type']);
83
+ DB::table('users')->where('uuid', $user->uuid)->update(['type' => 'user', 'meta' => json_encode($meta)]);
84
+ }
85
+ });
86
+ }
87
+
88
+ /**
89
+ * The managed type for the account, or null when it should stay a team member.
90
+ */
91
+ private function managedTypeFor(string $userUuid): ?string
92
+ {
93
+ $hasDriver = DB::table('drivers')->where('user_uuid', $userUuid)->whereNull('deleted_at')->exists();
94
+ $hasCustomer = DB::table('contacts')->where('user_uuid', $userUuid)->where('type', 'customer')->whereNull('deleted_at')->exists();
95
+
96
+ // An account holding both kinds of profile is a person the organization
97
+ // relates to in two ways; leave it for an administrator to decide.
98
+ if ($hasDriver === $hasCustomer) {
99
+ return null;
100
+ }
101
+
102
+ $type = $hasDriver ? 'driver' : 'customer';
103
+ $companyUserUuids = DB::table('company_users')->where('user_uuid', $userUuid)->whereNull('deleted_at')->pluck('uuid');
104
+ $modelUuids = $companyUserUuids->push($userUuid)->all();
105
+
106
+ $roles = DB::table('model_has_roles')
107
+ ->join('roles', 'roles.id', '=', 'model_has_roles.role_id')
108
+ ->whereIn('model_has_roles.model_uuid', $modelUuids)
109
+ ->pluck('roles.name')
110
+ ->unique();
111
+
112
+ if ($roles->isEmpty() || $roles->contains(fn ($role) => $role !== self::PROFILE_ROLES[$type])) {
113
+ return null;
114
+ }
115
+
116
+ $hasPermissions = DB::table('model_has_permissions')->whereIn('model_uuid', $modelUuids)->exists();
117
+ $hasPolicies = DB::table('model_has_policies')->whereIn('model_uuid', $modelUuids)->exists();
118
+
119
+ return $hasPermissions || $hasPolicies ? null : $type;
120
+ }
121
+
122
+ private function hasRequiredTables(): bool
123
+ {
124
+ foreach (['users', 'drivers', 'contacts', 'companies', 'company_users', 'roles', 'model_has_roles', 'model_has_permissions', 'model_has_policies'] as $table) {
125
+ if (!Schema::hasTable($table)) {
126
+ return false;
127
+ }
128
+ }
129
+
130
+ return true;
131
+ }
132
+ };
@@ -0,0 +1,21 @@
1
+ <x-mail-layout>
2
+ <h2 style="font-size: 18px; font-weight: 600;">
3
+ Your driver sign-in details
4
+ </h2>
5
+
6
+ Hi {{ $user->name }},
7
+ <br />
8
+ <br />
9
+ {{ $companyName }} has set up your driver account. Use these details to sign in to the driver app.
10
+ <br />
11
+ <br />
12
+ <strong>Your sign-in details</strong>
13
+ <br />
14
+ Login: {{ $identity }}
15
+ <br />
16
+ Temporary password: {{ $plaintextPassword }}
17
+ <br />
18
+ <br />
19
+ You can change your password after signing in.
20
+
21
+ </x-mail-layout>
@@ -68,7 +68,7 @@ class FixCustomerCompanies extends Command
68
68
  $this->line('Found user ' . $user->name . ' (' . $user->email . ') which doesnt have correct company assignment.');
69
69
  $company = $this->companyByUuid($customer->company_uuid);
70
70
  if ($company) {
71
- $user->assignCompany($company);
71
+ $user->assignCompany($company, 'Fleet-Ops Customer');
72
72
  $this->line('User ' . $user->email . ' was assigned to company: ' . $company->name);
73
73
  }
74
74
  }
@@ -49,7 +49,7 @@ class FixDriverCompanies extends Command
49
49
  $this->line('Found driver ' . $user->name . ' (' . $user->email . ') which doesnt have correct company assignment.');
50
50
  $company = $this->companyByUuid($driver->company_uuid);
51
51
  if ($company) {
52
- $user->assignCompany($company);
52
+ $user->assignCompany($company, 'Driver');
53
53
  $this->line('Driver ' . $user->email . ' was assigned to company: ' . $company->name);
54
54
  }
55
55
  }
@@ -0,0 +1,34 @@
1
+ <?php
2
+
3
+ namespace Fleetbase\FleetOps\Exceptions;
4
+
5
+ /**
6
+ * Thrown when a driver, contact or customer profile is given an email or phone
7
+ * that belongs to an account it cannot be linked to.
8
+ */
9
+ class ProfileIdentityConflictException extends \Exception
10
+ {
11
+ /**
12
+ * The profile field the conflict was found on (`email` or `phone`).
13
+ */
14
+ protected string $field;
15
+
16
+ public function __construct(string $message, string $field = 'email')
17
+ {
18
+ parent::__construct($message);
19
+ $this->field = $field;
20
+ }
21
+
22
+ public function getField(): string
23
+ {
24
+ return $this->field;
25
+ }
26
+
27
+ /**
28
+ * The conflict keyed by field, in the shape of a validation error bag.
29
+ */
30
+ public function getErrors(): array
31
+ {
32
+ return [$this->field => [$this->getMessage()]];
33
+ }
34
+ }
@@ -44,6 +44,8 @@ class CustomerController extends Controller
44
44
  {
45
45
  use \Fleetbase\FleetOps\Http\Controllers\Concerns\ResolvesReviewAccountBypass;
46
46
 
47
+ public const DEACTIVATED_LOGIN_MESSAGE = 'This customer login has been deactivated.';
48
+
47
49
  /* ============================================================
48
50
  | Public auth flows (API credential only, no Customer-Token)
49
51
  * ============================================================ */
@@ -345,6 +347,10 @@ class CustomerController extends Controller
345
347
  return response()->apiError('Authentication failed using credentials provided.', 401);
346
348
  }
347
349
 
350
+ if ($user->status === 'inactive') {
351
+ return response()->apiError(static::DEACTIVATED_LOGIN_MESSAGE, 403);
352
+ }
353
+
348
354
  $sessionCompany = $this->sessionCompany();
349
355
  if (!$sessionCompany) {
350
356
  return response()->apiError('No company resolved from API credential.', 500);
@@ -381,6 +387,10 @@ class CustomerController extends Controller
381
387
  return response()->apiError('No customer with this phone number found.');
382
388
  }
383
389
 
390
+ if ($user->status === 'inactive') {
391
+ return response()->apiError(static::DEACTIVATED_LOGIN_MESSAGE, 403);
392
+ }
393
+
384
394
  try {
385
395
  $this->generateSmsVerification($user, 'fleetops_customer_login', [
386
396
  'messageCallback' => fn ($verification) => 'Your ' . config('app.name') . ' verification code is ' . $verification->code,
@@ -423,6 +433,10 @@ class CustomerController extends Controller
423
433
  return response()->apiError('Unable to verify code.');
424
434
  }
425
435
 
436
+ if ($user->status === 'inactive') {
437
+ return response()->apiError(static::DEACTIVATED_LOGIN_MESSAGE, 403);
438
+ }
439
+
426
440
  $verificationCode = $this->verificationCodeExists([
427
441
  'subject_uuid' => $user->uuid,
428
442
  'code' => $code,
@@ -6,6 +6,7 @@ use Fleetbase\FleetOps\Events\DriverLocationChanged;
6
6
  use Fleetbase\FleetOps\Events\GeofenceEntered;
7
7
  use Fleetbase\FleetOps\Events\GeofenceExited;
8
8
  use Fleetbase\FleetOps\Events\VehicleLocationChanged;
9
+ use Fleetbase\FleetOps\Exceptions\ProfileIdentityConflictException;
9
10
  use Fleetbase\FleetOps\Exceptions\PublicRelationNotFoundException;
10
11
  use Fleetbase\FleetOps\Http\Controllers\Api\v1\Concerns\ResolvesFleetOpsApiResources;
11
12
  use Fleetbase\FleetOps\Http\Controllers\Api\v1\Concerns\ResolvesPublicExpansions;
@@ -22,6 +23,7 @@ use Fleetbase\FleetOps\Models\Vehicle;
22
23
  use Fleetbase\FleetOps\Models\Vendor;
23
24
  use Fleetbase\FleetOps\Support\GeofenceIntersectionService;
24
25
  use Fleetbase\FleetOps\Support\OSRM;
26
+ use Fleetbase\FleetOps\Support\ProfileAccountManager;
25
27
  use Fleetbase\FleetOps\Support\Utils;
26
28
  use Fleetbase\Http\Controllers\Controller;
27
29
  use Fleetbase\Http\Requests\SwitchOrganizationRequest;
@@ -47,6 +49,11 @@ class DriverController extends Controller
47
49
  use ResolvesFleetOpsApiResources;
48
50
  use ResolvesPublicExpansions;
49
51
 
52
+ /**
53
+ * Returned when a driver whose login was deactivated from the console tries to sign in.
54
+ */
55
+ public const DEACTIVATED_LOGIN_MESSAGE = 'This driver login has been deactivated.';
56
+
50
57
  /**
51
58
  * Public expansion name => Eloquent relation name.
52
59
  *
@@ -97,20 +104,14 @@ class DriverController extends Controller
97
104
  // Apply user infos
98
105
  $userDetails = $this->applyUserInfoFromRequest($request, $userDetails);
99
106
 
100
- // Set company_uuid before creating user
101
- $userDetails['company_uuid'] = $company->uuid;
102
-
103
- // create user account for driver
104
- $user = $this->createUser($userDetails);
105
-
106
- // Assign company — the early return above guarantees $company is set
107
- $user->assignCompany($company);
108
-
109
- // Set user type
110
- $user->setUserType('driver');
111
-
112
- // assign driver role
113
- $user->assignSingleRole('Driver');
107
+ // Resolve the driver's login account: a team member of the company with
108
+ // the email/phone is linked, otherwise a driver account is created and
109
+ // added to the company without an organization invite.
110
+ try {
111
+ $user = $this->resolveDriverAccount($company->uuid, $userDetails);
112
+ } catch (ProfileIdentityConflictException $exception) {
113
+ return $this->jsonResponse(['error' => $exception->getMessage()], 422);
114
+ }
114
115
 
115
116
  // set user id
116
117
  $input['user_uuid'] = $user->uuid;
@@ -193,10 +194,11 @@ class DriverController extends Controller
193
194
  // it. The driver's own record has no timezone column; the user's does.
194
195
  $userDetails = $request->only(['name', 'email', 'phone', 'timezone']);
195
196
 
196
- // update driver user details
197
- $driverUser = $driver->getUser();
198
- if ($driverUser) {
199
- $driverUser->update($userDetails);
197
+ // update the driver's login account through its proxy fields
198
+ try {
199
+ ProfileAccountManager::syncProxyFields($driver->getUser(), $userDetails);
200
+ } catch (ProfileIdentityConflictException $exception) {
201
+ return $this->jsonResponse(['error' => $exception->getMessage()], 422);
200
202
  }
201
203
 
202
204
  // latitude / longitude
@@ -519,10 +521,14 @@ class DriverController extends Controller
519
521
  )->whereHas('driver')->first();
520
522
 
521
523
  // Check password to authenticate driver
522
- if (!Hash::check($password, $user->password)) {
524
+ if (!$user || !is_string($user->password) || !Hash::check((string) $password, $user->password)) {
523
525
  return response()->apiError('Authentication failed using password provided.', 401);
524
526
  }
525
527
 
528
+ if (static::isLoginDeactivated($user)) {
529
+ return response()->apiError(static::DEACTIVATED_LOGIN_MESSAGE, 403);
530
+ }
531
+
526
532
  // Get the user's company for this driver profile
527
533
  $company = static::getDriverCompanyFromUser($user);
528
534
 
@@ -561,6 +567,10 @@ class DriverController extends Controller
561
567
  return response()->apiError('No driver with this phone # found.');
562
568
  }
563
569
 
570
+ if (static::isLoginDeactivated($user)) {
571
+ return response()->apiError(static::DEACTIVATED_LOGIN_MESSAGE, 403);
572
+ }
573
+
564
574
  // Get the user's company for this driver profile
565
575
  $company = static::getDriverCompanyFromUser($user);
566
576
 
@@ -627,6 +637,10 @@ class DriverController extends Controller
627
637
  return response()->apiError('Unable to verify code.');
628
638
  }
629
639
 
640
+ if (static::isLoginDeactivated($user)) {
641
+ return response()->apiError(static::DEACTIVATED_LOGIN_MESSAGE, 403);
642
+ }
643
+
630
644
  // find and verify code
631
645
  $verificationCode = VerificationCode::where(['subject_uuid' => $user->uuid, 'code' => $code, 'for' => $for])->exists();
632
646
  if (!$verificationCode && !static::verificationBypassMatches($identity, $code)) {
@@ -975,26 +989,29 @@ class DriverController extends Controller
975
989
  return User::applyUserInfoFromRequest($request, $userDetails);
976
990
  }
977
991
 
978
- protected function createUser(array $userDetails): User
992
+ /**
993
+ * Resolve the login account for a driver created through the API.
994
+ *
995
+ * @throws ProfileIdentityConflictException
996
+ */
997
+ protected function resolveDriverAccount(string $companyUuid, array $userDetails): User
979
998
  {
980
- /*
981
- * `password` is guarded on User, so mass assignment drops it without a
982
- * word. The create endpoint has always accepted and validated one, so a
983
- * driver created through the API could never sign in with the password
984
- * their operator chose for them. Set it after the fact, where the
985
- * model's mutator hashes it.
986
- */
987
- $password = $userDetails['password'] ?? null;
988
- unset($userDetails['password']);
989
-
990
- $user = User::create($userDetails);
991
-
992
- if (is_string($password) && strlen($password)) {
993
- $user->password = $password;
994
- $user->save();
995
- }
999
+ return ProfileAccountManager::resolveForProfile(
1000
+ $companyUuid,
1001
+ 'driver',
1002
+ $userDetails['name'] ?? null,
1003
+ $userDetails['email'] ?? null,
1004
+ $userDetails['phone'] ?? null,
1005
+ array_merge(Arr::except($userDetails, ['name', 'email', 'phone']), ['status' => 'active'])
1006
+ );
1007
+ }
996
1008
 
997
- return $user;
1009
+ /**
1010
+ * Whether the driver's login was deactivated from the console.
1011
+ */
1012
+ public static function isLoginDeactivated(User $user): bool
1013
+ {
1014
+ return $user->status === 'inactive';
998
1015
  }
999
1016
 
1000
1017
  protected function getUuid(array|string $table, array $where, array $options = []): mixed
@@ -1313,7 +1330,7 @@ class DriverController extends Controller
1313
1330
  }
1314
1331
 
1315
1332
  $user = static::findDriverUserByIdentity($identity);
1316
- if (!$user) {
1333
+ if (!$user || static::isLoginDeactivated($user)) {
1317
1334
  return response()->json(['status' => 'ok']);
1318
1335
  }
1319
1336
 
@@ -1344,7 +1361,7 @@ class DriverController extends Controller
1344
1361
  }
1345
1362
 
1346
1363
  $user = static::findDriverUserByIdentity($identity);
1347
- if (!$user) {
1364
+ if (!$user || static::isLoginDeactivated($user)) {
1348
1365
  return response()->apiError('Invalid or expired reset code.', 422);
1349
1366
  }
1350
1367