@fleetbase/fleetops-engine 0.6.60 → 0.6.62

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 (109) hide show
  1. package/RELEASE.md +21 -17
  2. package/addon/components/ai/route-preview-map.js +1 -5
  3. package/addon/components/customer/orders.js +2 -1
  4. package/addon/components/driver/card.hbs +49 -0
  5. package/addon/components/driver/card.js +30 -0
  6. package/addon/components/map/leaflet-live-map.js +4 -19
  7. package/addon/components/modals/place-details.hbs +1 -1
  8. package/addon/components/modals/point-map.hbs +1 -1
  9. package/addon/components/orchestrator-workbench.js +2 -1
  10. package/addon/components/order/form/route.js +3 -0
  11. package/addon/components/order-tracking-lookup.js +2 -1
  12. package/addon/components/place/details.hbs +1 -1
  13. package/addon/components/positions-replay.js +4 -1
  14. package/addon/components/service-area/details.hbs +1 -1
  15. package/addon/components/widget/live-fleet.js +7 -7
  16. package/addon/components/zone/details.hbs +1 -1
  17. package/addon/controllers/management/drivers/index.js +22 -1
  18. package/addon/controllers/settings/map.js +16 -0
  19. package/addon/helpers/leaflet-tile-url.js +19 -0
  20. package/addon/services/map-settings.js +26 -1
  21. package/addon/templates/management/drivers/index.hbs +67 -33
  22. package/addon/templates/settings/map.hbs +10 -0
  23. package/addon/utils/leaflet-tile-url.js +31 -0
  24. package/addon/utils/prepare-place-for-save.js +13 -0
  25. package/app/components/driver/card.js +1 -0
  26. package/app/helpers/leaflet-tile-url.js +1 -0
  27. package/app/utils/leaflet-tile-url.js +1 -0
  28. package/composer.json +1 -1
  29. package/extension.json +1 -1
  30. package/index.js +11 -0
  31. package/package.json +2 -1
  32. package/server/migrations/2026_09_05_000001_add_unique_indexes_to_fleet_membership_pivots.php +129 -0
  33. package/server/src/Exceptions/PublicRelationNotFoundException.php +47 -0
  34. package/server/src/Http/Controllers/Api/v1/Concerns/ResolvesFleetOpsApiResources.php +42 -7
  35. package/server/src/Http/Controllers/Api/v1/Concerns/ResolvesPublicExpansions.php +187 -0
  36. package/server/src/Http/Controllers/Api/v1/DriverController.php +312 -59
  37. package/server/src/Http/Controllers/Api/v1/EntityController.php +1 -1
  38. package/server/src/Http/Controllers/Api/v1/FleetController.php +370 -33
  39. package/server/src/Http/Controllers/Api/v1/FuelReportController.php +1 -1
  40. package/server/src/Http/Controllers/Api/v1/IssueController.php +1 -1
  41. package/server/src/Http/Controllers/Api/v1/ManifestController.php +250 -0
  42. package/server/src/Http/Controllers/Api/v1/OrderController.php +1 -1
  43. package/server/src/Http/Controllers/Api/v1/ServiceAreaController.php +1 -1
  44. package/server/src/Http/Controllers/Api/v1/TrackingStatusController.php +1 -1
  45. package/server/src/Http/Controllers/Api/v1/VehicleController.php +139 -34
  46. package/server/src/Http/Controllers/Api/v1/ZoneController.php +1 -1
  47. package/server/src/Http/Controllers/Internal/v1/SettingController.php +22 -0
  48. package/server/src/Http/Filter/Concerns/ResolvesPublicRelationUuids.php +54 -0
  49. package/server/src/Http/Filter/DriverFilter.php +71 -15
  50. package/server/src/Http/Filter/FleetFilter.php +32 -33
  51. package/server/src/Http/Filter/FuelReportFilter.php +29 -0
  52. package/server/src/Http/Filter/IssueFilter.php +29 -0
  53. package/server/src/Http/Filter/VehicleFilter.php +57 -19
  54. package/server/src/Http/Requests/Concerns/ScopesPublicRelationRules.php +48 -0
  55. package/server/src/Http/Requests/CreateDriverRequest.php +100 -9
  56. package/server/src/Http/Requests/CreateFleetRequest.php +30 -2
  57. package/server/src/Http/Requests/CreateServiceRateRequest.php +7 -2
  58. package/server/src/Http/Requests/CreateVehicleRequest.php +150 -26
  59. package/server/src/Http/Resources/v1/Concerns/ResolvesPublicRelationFields.php +110 -0
  60. package/server/src/Http/Resources/v1/Driver.php +22 -2
  61. package/server/src/Http/Resources/v1/Fleet.php +38 -25
  62. package/server/src/Http/Resources/v1/Manifest.php +52 -0
  63. package/server/src/Http/Resources/v1/ManifestStop.php +47 -0
  64. package/server/src/Http/Resources/v1/OrderConfig.php +51 -4
  65. package/server/src/Http/Resources/v1/Vehicle.php +46 -4
  66. package/server/src/Models/Driver.php +2 -2
  67. package/server/src/Models/Place.php +1 -1
  68. package/server/src/routes.php +29 -0
  69. package/server/tests/ApiDriverControllerContractsTest.php +317 -13
  70. package/server/tests/ApiDriverPasswordContractsTest.php +457 -0
  71. package/server/tests/ApiEntityControllerContractsTest.php +1 -1
  72. package/server/tests/ApiEquipmentControllerContractsTest.php +2 -2
  73. package/server/tests/ApiFleetControllerContractsTest.php +354 -21
  74. package/server/tests/ApiFuelReportControllerContractsTest.php +1 -1
  75. package/server/tests/ApiIssueControllerContractsTest.php +1 -1
  76. package/server/tests/ApiManifestControllerContractsTest.php +599 -0
  77. package/server/tests/ApiOrderControllerContractsTest.php +1 -1
  78. package/server/tests/ApiPartControllerContractsTest.php +2 -2
  79. package/server/tests/ApiServiceAreaZoneControllerContractsTest.php +2 -2
  80. package/server/tests/ApiTrackingStatusControllerContractsTest.php +1 -1
  81. package/server/tests/ApiVehicleControllerContractsTest.php +210 -8
  82. package/server/tests/CompactResourceSerializationTest.php +13 -7
  83. package/server/tests/ControllerFilterContractsTest.php +132 -13
  84. package/server/tests/ControllerHelperContractsTest.php +6 -3
  85. package/server/tests/DriverFilterExecutionTest.php +130 -6
  86. package/server/tests/ExceptionContractsTest.php +15 -0
  87. package/server/tests/Feature/Http/Api/DeviceControllerContractsTest.php +1 -1
  88. package/server/tests/Feature/Http/Api/DriverControllerSessionHelpersTest.php +61 -0
  89. package/server/tests/Feature/Http/Api/FleetMembershipMigrationTest.php +140 -0
  90. package/server/tests/Feature/Http/Api/FleetMembershipTest.php +333 -0
  91. package/server/tests/Feature/Http/Api/FleetPublicContractTest.php +439 -0
  92. package/server/tests/Feature/Http/Api/FuelTransactionControllerContractsTest.php +2 -2
  93. package/server/tests/Feature/Http/Api/NullRelationshipInputTest.php +191 -0
  94. package/server/tests/Feature/Http/Api/PublicIdentifierFilterTest.php +293 -0
  95. package/server/tests/Feature/Http/Api/SmallApiControllerHelpersTest.php +7 -1
  96. package/server/tests/Feature/Http/Api/VehicleControllerTrackingTest.php +3 -1
  97. package/server/tests/Feature/Http/Api/VehiclePublicContractTest.php +454 -0
  98. package/server/tests/Feature/Http/Api/WorkOrderControllerContractsTest.php +1 -1
  99. package/server/tests/Feature/Http/Internal/PlaceControllerTest.php +45 -0
  100. package/server/tests/FleetHierarchyResourceTest.php +56 -8
  101. package/server/tests/RequestContractsTest.php +105 -7
  102. package/server/tests/SettingControllerContractsTest.php +33 -0
  103. package/server/tests/Unit/Http/Filter/FuelReportFilterTest.php +135 -0
  104. package/server/tests/Unit/Http/Filter/IssueFilterTest.php +43 -0
  105. package/server/tests/Unit/Http/Resources/FleetResourceTest.php +50 -6
  106. package/server/tests/Unit/Http/Resources/ManifestResourceTest.php +129 -0
  107. package/server/tests/Unit/Http/Resources/OrderConfigResourceTest.php +91 -0
  108. package/server/tests/VehicleControllerHelperContractsTest.php +1 -1
  109. package/translations/en-us.yaml +4 -0
@@ -41,10 +41,25 @@ class OrderConfig extends FleetbaseResource
41
41
  }
42
42
 
43
43
  /**
44
- * Project the flow JSON into an ordered list of activities, keeping only
45
- * the public-safe per-activity fields. `code` and `status` (the label)
46
- * are the contract; `complete`, `color`, `details`, `pod_method`, and
47
- * `require_pod` are useful UI hints that can ride along.
44
+ * Project the flow JSON into a list of activities, keeping the public-safe
45
+ * per-activity fields.
46
+ *
47
+ * `code` and `status` (the label) are the contract; `complete`, `color`,
48
+ * `details`, `pod_method` and `require_pod` are useful UI hints that ride
49
+ * along.
50
+ *
51
+ * `activities`, `sequence` and `logic` describe the flow's *shape*, and are
52
+ * published because without them the list cannot be sequenced. The stored
53
+ * flow is a directed graph — `activities` names the codes an activity can
54
+ * transition to, `sequence` orders activities reachable from the same
55
+ * parent, and `logic` gates availability — which is precisely what
56
+ * `OrderConfig::nextActivity()` walks server-side. Projecting them away
57
+ * left consumers with an unordered set: a client rendering progress from
58
+ * array position marks a dispatched order complete whenever `completed`
59
+ * happens to be declared earlier, and can offer no next step at all.
60
+ *
61
+ * These are descriptions of the configured workflow, not internal state, so
62
+ * there is nothing here a consumer of the config should not see.
48
63
  */
49
64
  protected function projectFlow(): array
50
65
  {
@@ -66,9 +81,41 @@ class OrderConfig extends FleetbaseResource
66
81
  'complete' => (bool) ($activity['complete'] ?? false),
67
82
  'pod_method' => $activity['pod_method'] ?? null,
68
83
  'require_pod' => (bool) ($activity['require_pod'] ?? false),
84
+ 'sequence' => isset($activity['sequence']) ? (int) $activity['sequence'] : null,
85
+ 'activities' => static::projectTransitions($activity),
86
+ 'logic' => $activity['logic'] ?? null,
69
87
  ];
70
88
  }
71
89
 
72
90
  return $activities;
73
91
  }
92
+
93
+ /**
94
+ * The codes an activity can transition to, as a list of strings.
95
+ *
96
+ * Stored flows have been written both ways over time: a bare list of codes,
97
+ * and a list of objects carrying a `code`. Normalising here means consumers
98
+ * do not each have to guess which one they were handed.
99
+ */
100
+ protected static function projectTransitions(array $activity): array
101
+ {
102
+ $transitions = $activity['activities'] ?? [];
103
+ if (!is_array($transitions)) {
104
+ return [];
105
+ }
106
+
107
+ $codes = [];
108
+ foreach ($transitions as $transition) {
109
+ if (is_string($transition)) {
110
+ $codes[] = $transition;
111
+ continue;
112
+ }
113
+
114
+ if (is_array($transition) && isset($transition['code']) && is_string($transition['code'])) {
115
+ $codes[] = $transition['code'];
116
+ }
117
+ }
118
+
119
+ return $codes;
120
+ }
74
121
  }
@@ -2,12 +2,16 @@
2
2
 
3
3
  namespace Fleetbase\FleetOps\Http\Resources\v1;
4
4
 
5
+ use Fleetbase\FleetOps\Http\Resources\v1\Concerns\ResolvesPublicRelationFields;
5
6
  use Fleetbase\FleetOps\Support\Utils;
6
7
  use Fleetbase\Http\Resources\FleetbaseResource;
7
8
  use Fleetbase\Support\Http;
9
+ use Fleetbase\Support\Resolve;
8
10
 
9
11
  class Vehicle extends FleetbaseResource
10
12
  {
13
+ use ResolvesPublicRelationFields;
14
+
11
15
  /**
12
16
  * Transform the resource into an array.
13
17
  *
@@ -17,6 +21,12 @@ class Vehicle extends FleetbaseResource
17
21
  */
18
22
  public function toArray($request)
19
23
  {
24
+ $with = $this->requestedRelations($request);
25
+
26
+ if ($with !== []) {
27
+ $this->loadMissing($with);
28
+ }
29
+
20
30
  return $this->withCustomFields([
21
31
  // Identity
22
32
  'id' => $this->when(Http::isInternalRequest(), $this->id, $this->public_id),
@@ -39,10 +49,24 @@ class Vehicle extends FleetbaseResource
39
49
  'description' => $this->description,
40
50
  'driver_name' => $this->when(Http::isInternalRequest(), $this->driver_name),
41
51
  'vendor_name' => $this->when(Http::isInternalRequest(), $this->vendor_name),
42
- 'assigned_orders_count' => $this->when(Http::isInternalRequest(), $this->assignedOrdersCount()),
43
- 'current_order_reference'=> $this->when(Http::isInternalRequest(), $this->currentOrderReference()),
44
- // Relationships
45
- 'driver' => $this->whenLoaded('driver', fn () => new Driver($this->driver)),
52
+ 'assigned_orders_count' => $this->when(Http::isInternalRequest(), fn () => $this->assignedOrdersCount()),
53
+ 'current_order_reference'=> $this->when(Http::isInternalRequest(), fn () => $this->currentOrderReference()),
54
+ // Relationships. The identifier is additive and always present so a
55
+ // write can be read back; the object beside it keeps the shape it
56
+ // has always had — absent unless loaded, an object when it is.
57
+ 'driver_id' => $this->publicIdForRelation('driver', null),
58
+ 'vendor_id' => $this->publicIdForRelation('vendor', 'vendor_uuid'),
59
+ 'category_id' => $this->publicIdForRelation('category', 'category_uuid'),
60
+ 'warranty_id' => $this->publicIdForRelation('warranty', 'warranty_uuid'),
61
+ 'photo_id' => $this->publicIdForRelation('photo', 'photo_uuid'),
62
+ 'driver' => $this->publicRelationObject('driver', $with, fn () => new Driver($this->driver)),
63
+ 'vendor' => $this->publicRelationObject('vendor', $with, fn () => new Vendor($this->vendor)),
64
+ // Resolved through the repository's own resource resolver rather than
65
+ // a hardcoded class: Category and File live in core, and Warranty has
66
+ // no v1 resource of its own.
67
+ 'category' => $this->publicRelationObject('category', $with, fn () => Resolve::httpResourceForModel($this->category)),
68
+ 'warranty' => $this->publicRelationObject('warranty', $with, fn () => Resolve::httpResourceForModel($this->warranty)),
69
+ 'photo' => $this->publicRelationObject('photo', $with, fn () => Resolve::httpResourceForModel($this->photo)),
46
70
  'devices' => $this->whenLoaded('devices', fn () => $this->devices),
47
71
  // Vehicle identification
48
72
  'make' => $this->make,
@@ -139,6 +163,15 @@ class Vehicle extends FleetbaseResource
139
163
  'altitude' => (int) data_get($this, 'altitude', 0),
140
164
  'speed' => (int) data_get($this, 'speed', 0),
141
165
  'telematics' => data_get($this, 'telematics'),
166
+ // Orchestrator constraints
167
+ 'skills' => data_get($this, 'skills'),
168
+ 'payload_capacity_volume' => $this->payload_capacity_volume,
169
+ 'payload_capacity_pallets' => $this->payload_capacity_pallets,
170
+ 'payload_capacity_parcels' => $this->payload_capacity_parcels,
171
+ 'max_tasks' => $this->max_tasks,
172
+ 'time_window_start' => $this->time_window_start,
173
+ 'time_window_end' => $this->time_window_end,
174
+ 'return_to_depot' => $this->return_to_depot,
142
175
  // Notes & meta
143
176
  'notes' => $this->notes,
144
177
  'meta' => data_get($this, 'meta', Utils::createObject()),
@@ -273,6 +306,15 @@ class Vehicle extends FleetbaseResource
273
306
  'vin_data' => data_get($this, 'vin_data', Utils::createObject()),
274
307
  'specs' => data_get($this, 'specs', Utils::createObject()),
275
308
  'details' => data_get($this, 'details', Utils::createObject()),
309
+ // Orchestrator constraints
310
+ 'skills' => data_get($this, 'skills'),
311
+ 'payload_capacity_volume' => $this->payload_capacity_volume,
312
+ 'payload_capacity_pallets' => $this->payload_capacity_pallets,
313
+ 'payload_capacity_parcels' => $this->payload_capacity_parcels,
314
+ 'max_tasks' => $this->max_tasks,
315
+ 'time_window_start' => $this->time_window_start,
316
+ 'time_window_end' => $this->time_window_end,
317
+ 'return_to_depot' => $this->return_to_depot,
276
318
  // Notes & meta
277
319
  'notes' => $this->notes,
278
320
  'meta' => data_get($this, 'meta', Utils::createObject()),
@@ -110,7 +110,7 @@ class Driver extends Model
110
110
  'current_status',
111
111
  'slug',
112
112
  'status',
113
- 'meta,',
113
+ 'meta',
114
114
  // Orchestrator
115
115
  'skills',
116
116
  'max_travel_time',
@@ -255,7 +255,7 @@ class Driver extends Model
255
255
  */
256
256
  public function user()
257
257
  {
258
- return $this->belongsTo(User::class)->select(['uuid', 'company_uuid', 'public_id', 'avatar_uuid', 'name', 'phone', 'email', 'type', 'status', 'last_login'])->without(['driver'])->withTrashed();
258
+ return $this->belongsTo(User::class)->select(['uuid', 'company_uuid', 'public_id', 'avatar_uuid', 'name', 'phone', 'email', 'timezone', 'type', 'status', 'last_login'])->without(['driver'])->withTrashed();
259
259
  }
260
260
 
261
261
  /**
@@ -129,7 +129,7 @@ class Place extends Model
129
129
  *
130
130
  * @var array
131
131
  */
132
- protected $filterParams = ['vendor', 'contact', 'vendor_uuid', 'vendor_name'];
132
+ protected $filterParams = ['vendor', 'contact', 'vendor_uuid', 'vendor_name', 'avatar_value', 'eta'];
133
133
 
134
134
  /**
135
135
  * @return \Illuminate\Database\Eloquent\Relations\MorphTo
@@ -28,6 +28,13 @@ Route::prefix(config('fleetops.api.routing.prefix'))->namespace('Fleetbase\Fleet
28
28
  $router->post('login-with-sms', 'DriverController@loginWithPhone');
29
29
  $router->post('verify-code', 'DriverController@verifyCode');
30
30
  $router->post('login', 'DriverController@login');
31
+ // Password management. Deliberately separate from the driver
32
+ // update: changing a password requires proving the old one, and
33
+ // resetting it requires a code, neither of which a general PUT
34
+ // can express.
35
+ $router->post('forgot-password', 'DriverController@forgotPassword');
36
+ $router->post('reset-password', 'DriverController@resetPassword');
37
+ $router->post('{id}/change-password', 'DriverController@changePassword');
31
38
  $router->post('{id}/simulate', 'DriverController@simulate');
32
39
  $router->match(['put', 'patch', 'post'], '{id}/track', 'DriverController@track');
33
40
  $router->post('{id}/register-device', 'DriverController@registerDevice');
@@ -37,6 +44,8 @@ Route::prefix(config('fleetops.api.routing.prefix'))->namespace('Fleetbase\Fleet
37
44
  $router->get('/', 'DriverController@query');
38
45
  $router->get('{id}', 'DriverController@find');
39
46
  $router->get('{id}/organizations', 'DriverController@listOrganizations');
47
+ // A driver's own routes for the day.
48
+ $router->get('{id}/manifests', 'ManifestController@forDriver');
40
49
  $router->get('{id}/current-organization', 'DriverController@currentOrganization');
41
50
  $router->put('{id}', 'DriverController@update');
42
51
  $router->delete('{id}', 'DriverController@delete');
@@ -185,6 +194,18 @@ Route::prefix(config('fleetops.api.routing.prefix'))->namespace('Fleetbase\Fleet
185
194
  $router->delete('{id}', 'OrderController@delete');
186
195
  $router->get('{id}/editable-entity-fields', 'OrderController@getEditableEntityFields');
187
196
  });
197
+ // manifests — a driver's route. Read and run only: creating,
198
+ // cancelling and deleting a manifest is dispatch work and stays on
199
+ // the internal namespace.
200
+ $router->group(['prefix' => 'manifests'], function () use ($router) {
201
+ $router->get('{id}', 'ManifestController@show');
202
+ $router->post('{id}/optimize', 'ManifestController@optimize');
203
+ });
204
+ $router->group(['prefix' => 'manifest-stops'], function () use ($router) {
205
+ $router->patch('{id}', 'ManifestController@updateStop');
206
+ $router->post('{id}', 'ManifestController@updateStop');
207
+ });
208
+
188
209
  // entities routes
189
210
  $router->group(['prefix' => 'entities'], function () use ($router) {
190
211
  $router->post('/', 'EntityController@create');
@@ -281,6 +302,14 @@ Route::prefix(config('fleetops.api.routing.prefix'))->namespace('Fleetbase\Fleet
281
302
  $router->group(['prefix' => 'fleets'], function () use ($router) {
282
303
  $router->post('/', 'FleetController@create');
283
304
  $router->get('/', 'FleetController@query');
305
+ // Membership routes are declared ahead of the `{id}` routes so a
306
+ // literal `vehicles`/`drivers` segment can never be swallowed by
307
+ // the single-parameter patterns below. Both parameters are public
308
+ // ids; no internal uuid is ever accepted or returned here.
309
+ $router->post('{id}/vehicles/{vehicle}', 'FleetController@assignVehicle');
310
+ $router->delete('{id}/vehicles/{vehicle}', 'FleetController@removeVehicle');
311
+ $router->post('{id}/drivers/{driver}', 'FleetController@assignDriver');
312
+ $router->delete('{id}/drivers/{driver}', 'FleetController@removeDriver');
284
313
  $router->get('{id}', 'FleetController@find');
285
314
  $router->put('{id}', 'FleetController@update');
286
315
  $router->delete('{id}', 'FleetController@delete');
@@ -4,7 +4,9 @@ use Fleetbase\FleetOps\Http\Controllers\Api\v1\DriverController;
4
4
  use Fleetbase\FleetOps\Http\Requests\CreateDriverRequest;
5
5
  use Fleetbase\FleetOps\Http\Requests\UpdateDriverRequest;
6
6
  use Fleetbase\FleetOps\Models\Driver;
7
+ use Fleetbase\FleetOps\Models\Order;
7
8
  use Fleetbase\FleetOps\Models\Vehicle;
9
+ use Fleetbase\FleetOps\Models\Vendor;
8
10
  use Fleetbase\LaravelMysqlSpatial\Types\Point;
9
11
  use Fleetbase\Models\Company;
10
12
  use Fleetbase\Models\User;
@@ -29,6 +31,9 @@ class FleetOpsApiDriverControllerProbe extends DriverController
29
31
  public array $companyCalls = [];
30
32
  public array $createdUsers = [];
31
33
  public array $uuidLookups = [];
34
+ public array $relationLookups = [];
35
+ public array $relationCompanyScopes = [];
36
+ public array $unresolvable = [];
32
37
  public array $pointInputs = [];
33
38
  public array $createdDrivers = [];
34
39
  public array $resolvedFiles = [];
@@ -76,6 +81,33 @@ class FleetOpsApiDriverControllerProbe extends DriverController
76
81
  return $table . '-uuid';
77
82
  }
78
83
 
84
+ /**
85
+ * Stand in for the company-scoped public-id lookup.
86
+ *
87
+ * Anything listed in `$unresolvable` behaves as a cross-company or missing
88
+ * identifier does in production.
89
+ */
90
+ protected function resolveUuid(string $modelClass, ?string $id, ?string $companyUuid = null): ?string
91
+ {
92
+ if (empty($id)) {
93
+ return null;
94
+ }
95
+
96
+ $this->relationLookups[] = [$modelClass, $id];
97
+ $this->relationCompanyScopes[] = $companyUuid;
98
+
99
+ if (in_array($id, $this->unresolvable, true)) {
100
+ throw (new ModelNotFoundException())->setModel($modelClass, $id);
101
+ }
102
+
103
+ return strtolower(class_basename($modelClass)) . '-uuid';
104
+ }
105
+
106
+ public function inputForTest(Request $request): array
107
+ {
108
+ return $this->driverInputFromRequest($request);
109
+ }
110
+
79
111
  protected function pointFromCoordinates(array $coordinates): Point
80
112
  {
81
113
  $this->pointInputs[] = $coordinates;
@@ -343,21 +375,23 @@ test('api driver controller creates drivers with user company assignment and rel
343
375
  ->and($controller->user->assignedRoles)->toBe(['Driver'])
344
376
  ->and($controller->createdDrivers[0])->toMatchArray([
345
377
  'status' => 'available',
346
- 'vehicle_uuid' => 'vehicles-uuid',
347
- 'vendor_uuid' => 'vendors-uuid',
348
- 'current_job_uuid' => 'orders-uuid',
378
+ 'vehicle_uuid' => 'vehicle-uuid',
379
+ 'vendor_uuid' => 'vendor-uuid',
380
+ 'current_job_uuid' => 'order-uuid',
349
381
  'online' => 0,
350
382
  'user_uuid' => 'user-uuid',
351
383
  'company_uuid' => 'company-uuid',
352
384
  ])
353
385
  ->and($controller->createdDrivers[0]['location'])->toBeInstanceOf(Point::class)
354
- ->and($controller->uuidLookups)->toContain(
355
- ['vehicles', ['public_id' => 'vehicle_public', 'company_uuid' => 'company-uuid'], []],
356
- ['vendors', ['public_id' => 'vendor_public', 'company_uuid' => 'company-uuid'], []],
357
- ['orders', ['public_id' => 'order_public', 'company_uuid' => 'company-uuid'], []]
358
- )
386
+ ->and($controller->relationLookups)->toBe([
387
+ [Vehicle::class, 'vehicle_public'],
388
+ [Vendor::class, 'vendor_public'],
389
+ [Order::class, 'order_public'],
390
+ ])
359
391
  ->and($controller->resolvedFiles)->toBe([['file_public', 'uploads/company-uuid/drivers']])
360
- ->and($controller->user->updates)->toContain(['photo_uuid' => 'photo-file-uuid'])
392
+ // `photo_uuid` is not a column on users; the avatar lives in `avatar_uuid`
393
+ // and the old key was silently dropped by mass assignment.
394
+ ->and($controller->user->updates)->toContain(['avatar_uuid' => 'photo-file-uuid'])
361
395
  ->and($controller->driver->loaded)->toContain(['user', 'vehicle', 'vendor', 'currentJob']);
362
396
  });
363
397
 
@@ -411,14 +445,14 @@ test('api driver controller updates drivers user details assignments location an
411
445
  ])
412
446
  ->and($driver->updates[0])->toMatchArray([
413
447
  'status' => 'busy',
414
- 'vehicle_uuid' => 'vehicles-uuid',
415
- 'vendor_uuid' => 'vendors-uuid',
416
- 'current_job_uuid' => 'orders-uuid',
448
+ 'vehicle_uuid' => 'vehicle-uuid',
449
+ 'vendor_uuid' => 'vendor-uuid',
450
+ 'current_job_uuid' => 'order-uuid',
417
451
  ])
418
452
  ->and($driver->updates[0]['location'])->toBeInstanceOf(Point::class)
419
453
  ->and($driver->flushedForTest)->toBeTrue()
420
454
  ->and($controller->resolvedFiles)->toBe([['photo-input', 'uploads/session-company-uuid/drivers']])
421
- ->and($user->updates)->toContain(['photo_uuid' => 'photo-file-uuid'])
455
+ ->and($user->updates)->toContain(['avatar_uuid' => 'photo-file-uuid'])
422
456
  ->and($driver->loaded)->toContain(['user', 'vehicle', 'vendor', 'currentJob']);
423
457
  });
424
458
 
@@ -601,3 +635,273 @@ test('api driver controller reports missing driver branches', function () {
601
635
  'status' => 404,
602
636
  ]);
603
637
  });
638
+
639
+ test('api driver controller refuses to set a password through a general update', function () {
640
+ /*
641
+ * Regression for a takeover path: `update()` passed `password` straight
642
+ * through to the user, so anyone holding a driver's token — or an unlocked
643
+ * handset — could set a new one without proving they knew the old one, and
644
+ * the account was theirs. A password change is an authorisation decision
645
+ * and now has its own endpoint that demands the current password.
646
+ */
647
+ $user = new FleetOpsApiDriverUserFake();
648
+ $user->setRawAttributes(['uuid' => 'user-uuid'], true);
649
+ $driver = new FleetOpsApiDriverFake();
650
+ $driver->setRawAttributes([
651
+ 'uuid' => 'driver-uuid',
652
+ 'public_id' => 'driver_public',
653
+ 'user_uuid' => 'user-uuid',
654
+ ], true);
655
+ $driver->userForTest = $user;
656
+ $driver->setRelation('user', $user);
657
+
658
+ $controller = new FleetOpsApiDriverControllerProbe();
659
+ $controller->driver = $driver;
660
+ $controller->sessionCompanyUuid = 'session-company-uuid';
661
+
662
+ $controller->update('driver_public', new UpdateDriverRequest([
663
+ 'name' => 'Driver Updated',
664
+ 'password' => 'attacker-chosen-password',
665
+ ]));
666
+
667
+ expect($user->updates)->not->toBeEmpty()
668
+ ->and($user->updates[0])->not->toHaveKey('password')
669
+ ->and($user->updates[0])->toHaveKey('name');
670
+ });
671
+
672
+ test('api driver controller accepts every safe driver field the model exposes', function () {
673
+ $payload = [
674
+ // Identity
675
+ 'internal_id' => 'DRV-9001', 'drivers_license_number' => 'S1234567A',
676
+ 'license_expiry' => '2030-06-30',
677
+ // Operational
678
+ 'country' => 'SG', 'currency' => 'SGD', 'city' => 'Singapore', 'online' => true,
679
+ 'current_status' => 'on_break', 'status' => 'available',
680
+ 'heading' => 180, 'bearing' => 90, 'altitude' => 15, 'speed' => 42,
681
+ // Structured / orchestrator
682
+ 'meta' => ['depot' => 'north'], 'skills' => ['hazmat'],
683
+ 'max_travel_time' => 28800, 'max_distance' => 250000,
684
+ 'time_window_start' => '08:00', 'time_window_end' => '18:00',
685
+ ];
686
+
687
+ $controller = new FleetOpsApiDriverControllerProbe();
688
+ $input = $controller->inputForTest(new Request($payload));
689
+
690
+ $missing = array_values(array_diff(array_keys($payload), array_keys($input)));
691
+
692
+ expect($missing)->toBe([])
693
+ ->and($input)->toMatchArray($payload);
694
+ });
695
+
696
+ test('api driver controller input excludes authentication tenancy and generated columns', function () {
697
+ // The projection used to be an `except()` blocklist, so anything nobody had
698
+ // thought to name reached Driver::create() intact — including the auth token.
699
+ $controller = new FleetOpsApiDriverControllerProbe();
700
+
701
+ $input = $controller->inputForTest(new Request([
702
+ 'internal_id' => 'DRV-1',
703
+ 'auth_token' => 'forged',
704
+ 'signup_token_used' => true,
705
+ 'user_uuid' => 'forged',
706
+ 'company_uuid' => 'someone-elses-company',
707
+ 'vehicle_uuid' => 'forged',
708
+ 'vendor_uuid' => 'forged',
709
+ 'current_job_uuid' => 'forged',
710
+ '_key' => 'forged',
711
+ 'uuid' => 'forged',
712
+ 'public_id' => 'driver_forged',
713
+ 'slug' => 'forged',
714
+ 'avatar_url' => 'forged',
715
+ ]));
716
+
717
+ expect(array_keys($input))->toBe(['internal_id']);
718
+ });
719
+
720
+ test('api driver controller persists meta location and telemetry that the blocklist used to drop', function () {
721
+ // `location`, `heading`, `altitude`, `speed` and `meta` were all on the old
722
+ // `except()` list, so every write silently discarded them — and `meta` could
723
+ // not be mass assigned anyway because the model spelled it `meta,`.
724
+ $controller = new FleetOpsApiDriverControllerProbe();
725
+
726
+ $input = $controller->inputForTest(new Request([
727
+ 'meta' => ['badge' => 'A12'],
728
+ 'heading' => 270,
729
+ 'altitude' => 8,
730
+ 'speed' => 55,
731
+ ]));
732
+
733
+ expect($input)->toMatchArray([
734
+ 'meta' => ['badge' => 'A12'],
735
+ 'heading' => 270,
736
+ 'altitude' => 8,
737
+ 'speed' => 55,
738
+ ])->and(in_array('meta', (new Driver())->getFillable(), true))->toBeTrue();
739
+ });
740
+
741
+ test('api driver controller creates an operational driver with no email or phone', function () {
742
+ $controller = new FleetOpsApiDriverControllerProbe();
743
+
744
+ $response = $controller->create(new CreateDriverRequest([
745
+ 'name' => 'Yard Driver',
746
+ 'internal_id' => 'DRV-7788',
747
+ ]));
748
+
749
+ // No invented address, no invented number: the user record simply carries
750
+ // neither, which the users table permits. The driver cannot sign in to
751
+ // Navigator until credentials are supplied.
752
+ expect($response)->toBe(['resource' => 'driver', 'driver' => $controller->driver])
753
+ ->and($controller->createdUsers[0])->toMatchArray(['name' => 'Yard Driver'])
754
+ ->and($controller->createdUsers[0]['email'] ?? null)->toBeNull()
755
+ ->and($controller->createdUsers[0]['phone'] ?? null)->toBeNull()
756
+ ->and($controller->createdDrivers[0])->toMatchArray([
757
+ 'internal_id' => 'DRV-7788',
758
+ 'user_uuid' => 'user-uuid',
759
+ 'company_uuid' => 'company-uuid',
760
+ 'status' => 'available',
761
+ ])
762
+ // The Driver-to-User relationship, organization membership, user type
763
+ // and role are all preserved for a credential-less driver.
764
+ ->and($controller->user->assignedCompanies)->toBe(['company-uuid'])
765
+ ->and($controller->user->assignedTypes)->toBe(['driver'])
766
+ ->and($controller->user->assignedRoles)->toBe(['Driver']);
767
+ });
768
+
769
+ test('api driver controller creates a driver with only one contact method', function () {
770
+ $emailOnly = new FleetOpsApiDriverControllerProbe();
771
+ $emailOnly->create(new CreateDriverRequest([
772
+ 'name' => 'Email Only',
773
+ 'email' => 'email.only@example.test',
774
+ ]));
775
+
776
+ $phoneOnly = new FleetOpsApiDriverControllerProbe();
777
+ $phoneOnly->create(new CreateDriverRequest([
778
+ 'name' => 'Phone Only',
779
+ 'phone' => '+15550001111',
780
+ ]));
781
+
782
+ expect($emailOnly->createdUsers[0])->toMatchArray(['email' => 'email.only@example.test'])
783
+ ->and($emailOnly->createdUsers[0]['phone'] ?? null)->toBeNull()
784
+ ->and($phoneOnly->createdUsers[0])->toMatchArray(['phone' => '+15550001111'])
785
+ ->and($phoneOnly->createdUsers[0]['email'] ?? null)->toBeNull();
786
+ });
787
+
788
+ test('api driver controller rejects a relationship that belongs to another company', function () {
789
+ $controller = new FleetOpsApiDriverControllerProbe();
790
+ $controller->unresolvable = ['vehicle_other_company'];
791
+
792
+ $created = $controller->create(new CreateDriverRequest([
793
+ 'name' => 'Driver One',
794
+ 'vehicle' => 'vehicle_other_company',
795
+ ]));
796
+
797
+ $controller = new FleetOpsApiDriverControllerProbe();
798
+ $controller->unresolvable = ['vendor_other_company'];
799
+
800
+ $updated = $controller->update('driver_public', new UpdateDriverRequest([
801
+ 'vendor' => 'vendor_other_company',
802
+ ]));
803
+
804
+ expect($created)->toBe([
805
+ 'json' => ['error' => 'No vehicle resource found for the identifier provided.'],
806
+ 'status' => 404,
807
+ ])->and($updated)->toBe([
808
+ 'json' => ['error' => 'No vendor resource found for the identifier provided.'],
809
+ 'status' => 404,
810
+ ]);
811
+ });
812
+
813
+ test('api driver controller clears a relationship when the input is sent empty', function () {
814
+ $controller = new FleetOpsApiDriverControllerProbe();
815
+
816
+ $input = $controller->inputForTest(new Request(['vehicle' => null, 'vendor' => '', 'job' => null]));
817
+
818
+ expect($input)->toMatchArray([
819
+ 'vehicle_uuid' => null,
820
+ 'vendor_uuid' => null,
821
+ 'current_job_uuid' => null,
822
+ ])->and($controller->relationLookups)->toBe([]);
823
+ });
824
+
825
+ test('api driver controller resolves driver relationships inside the company the driver is created in', function () {
826
+ // A create request may name a company explicitly, and the relationships must
827
+ // be looked up in that company rather than in whatever the session holds.
828
+ $controller = new FleetOpsApiDriverControllerProbe();
829
+
830
+ $controller->create(new CreateDriverRequest([
831
+ 'company' => 'company_public',
832
+ 'name' => 'Driver One',
833
+ 'vehicle' => 'vehicle_public',
834
+ 'vendor' => 'vendor_public',
835
+ ]));
836
+
837
+ expect($controller->relationCompanyScopes)->toBe(['company-uuid', 'company-uuid']);
838
+ });
839
+
840
+ test('api driver controller keeps every relationship the base contract returned', function () {
841
+ // The SDK stores what the API returns verbatim, and Navigator interpolates
842
+ // `driver.user` straight into a socket channel name — an object there would
843
+ // subscribe it to `user.[object Object]` and quietly stop delivering
844
+ // messages. These endpoints have always loaded user, vehicle, vendor and
845
+ // currentJob, so they must keep doing it without a `with` parameter.
846
+ $controller = new FleetOpsApiDriverControllerProbe();
847
+
848
+ $controller->create(new CreateDriverRequest([
849
+ 'name' => 'Driver One',
850
+ 'vehicle' => 'vehicle_public',
851
+ 'vendor' => 'vendor_public',
852
+ 'job' => 'order_public',
853
+ ]));
854
+
855
+ expect($controller->driver->loaded)->toContain(['user', 'vehicle', 'vendor', 'currentJob']);
856
+
857
+ $updateController = new FleetOpsApiDriverControllerProbe();
858
+ $updateController->update('driver_public', new UpdateDriverRequest(['name' => 'Renamed']));
859
+
860
+ expect($updateController->driver->loaded)->toContain(['user', 'vehicle', 'vendor', 'currentJob'])
861
+ ->and($updateController->findCalls)->toContain(['driver_public', ['user']]);
862
+ });
863
+
864
+ test('api driver controller copies timezone through to the linked user account', function () {
865
+ // `timezone` is documented and accepted on both create and update, but the
866
+ // update only ever copied name, email and phone — so the request validated,
867
+ // answered 200, and dropped it. The driver has no timezone column; the
868
+ // linked user does.
869
+ $create = new FleetOpsApiDriverControllerProbe();
870
+ $create->create(new CreateDriverRequest([
871
+ 'name' => 'Driver One',
872
+ 'timezone' => 'Asia/Singapore',
873
+ ]));
874
+
875
+ $user = new FleetOpsApiDriverUserFake();
876
+ $user->setRawAttributes(['uuid' => 'user-uuid'], true);
877
+
878
+ $driver = new FleetOpsApiDriverFake();
879
+ $driver->setRawAttributes(['uuid' => 'driver-uuid', 'public_id' => 'driver_public', 'user_uuid' => 'user-uuid'], true);
880
+ $driver->userForTest = $user;
881
+ $driver->setRelation('user', $user);
882
+
883
+ $update = new FleetOpsApiDriverControllerProbe();
884
+ $update->driver = $driver;
885
+ $update->update('driver_public', new UpdateDriverRequest([
886
+ 'name' => 'Driver One',
887
+ 'timezone' => 'Europe/Amsterdam',
888
+ ]));
889
+
890
+ expect($create->createdUsers[0])->toMatchArray(['timezone' => 'Asia/Singapore'])
891
+ ->and($user->updates)->toContain([
892
+ 'name' => 'Driver One',
893
+ 'timezone' => 'Europe/Amsterdam',
894
+ ]);
895
+ });
896
+
897
+ test('api driver controller only expands relationships the public contract allows', function () {
898
+ $controller = new FleetOpsApiDriverControllerProbe();
899
+ $request = new CreateDriverRequest(['name' => 'Driver One', 'with' => ['vehicle', 'current_job', 'user', 'company', 'nope']]);
900
+
901
+ $controller->create($request);
902
+
903
+ // `user` and `company` are published as public-id strings. Expanding either
904
+ // would retype a released field, so neither is expandable at all; an unknown
905
+ // name is dropped rather than reaching Eloquent, where it would be a 500.
906
+ expect($request->input('with'))->toBe(['vehicle', 'currentJob']);
907
+ });