@fleetbase/fleetops-engine 0.6.60 → 0.6.61

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 (57) hide show
  1. package/RELEASE.md +9 -20
  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 +22 -0
  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/src/Http/Controllers/Api/v1/DriverController.php +220 -3
  33. package/server/src/Http/Controllers/Api/v1/ManifestController.php +250 -0
  34. package/server/src/Http/Controllers/Api/v1/VehicleController.php +6 -0
  35. package/server/src/Http/Controllers/Internal/v1/SettingController.php +22 -0
  36. package/server/src/Http/Filter/FuelReportFilter.php +29 -0
  37. package/server/src/Http/Filter/IssueFilter.php +29 -0
  38. package/server/src/Http/Requests/CreateVehicleRequest.php +10 -5
  39. package/server/src/Http/Resources/v1/Manifest.php +52 -0
  40. package/server/src/Http/Resources/v1/ManifestStop.php +47 -0
  41. package/server/src/Http/Resources/v1/OrderConfig.php +51 -4
  42. package/server/src/Models/Place.php +1 -1
  43. package/server/src/routes.php +21 -0
  44. package/server/tests/ApiDriverControllerContractsTest.php +33 -0
  45. package/server/tests/ApiDriverPasswordContractsTest.php +457 -0
  46. package/server/tests/ApiManifestControllerContractsTest.php +599 -0
  47. package/server/tests/ApiVehicleControllerContractsTest.php +35 -0
  48. package/server/tests/CompactResourceSerializationTest.php +13 -7
  49. package/server/tests/Feature/Http/Api/DriverControllerSessionHelpersTest.php +61 -0
  50. package/server/tests/Feature/Http/Internal/PlaceControllerTest.php +45 -0
  51. package/server/tests/RequestContractsTest.php +5 -0
  52. package/server/tests/SettingControllerContractsTest.php +33 -0
  53. package/server/tests/Unit/Http/Filter/FuelReportFilterTest.php +135 -0
  54. package/server/tests/Unit/Http/Filter/IssueFilterTest.php +43 -0
  55. package/server/tests/Unit/Http/Resources/ManifestResourceTest.php +129 -0
  56. package/server/tests/Unit/Http/Resources/OrderConfigResourceTest.php +91 -0
  57. package/translations/en-us.yaml +4 -0
@@ -328,6 +328,8 @@ class SettingController extends Controller
328
328
  {
329
329
  $defaults = [
330
330
  'mapProvider' => 'leaflet',
331
+ 'leafletTileUrl' => '',
332
+ 'leafletDarkTileUrl' => '',
331
333
  'googleMapsMapType' => 'roadmap',
332
334
  'showGoogleMapsTrafficLayer' => false,
333
335
  'showGoogleMapsTransitLayer' => false,
@@ -418,12 +420,32 @@ class SettingController extends Controller
418
420
  return [
419
421
  ...$settings,
420
422
  'mapProvider' => $mapProvider,
423
+ 'leafletTileUrl' => $this->sanitizeTileUrl(data_get($settings, 'leafletTileUrl', '')),
424
+ 'leafletDarkTileUrl' => $this->sanitizeTileUrl(data_get($settings, 'leafletDarkTileUrl', '')),
421
425
  'googleMapsMapType' => $googleMapsMapType,
422
426
  'showGoogleMapsTrafficLayer' => filter_var(data_get($settings, 'showGoogleMapsTrafficLayer', false), FILTER_VALIDATE_BOOLEAN),
423
427
  'showGoogleMapsTransitLayer' => filter_var(data_get($settings, 'showGoogleMapsTransitLayer', false), FILTER_VALIDATE_BOOLEAN),
424
428
  ];
425
429
  }
426
430
 
431
+ /**
432
+ * Sanitize a Leaflet XYZ tile URL template — only http(s) URLs are accepted,
433
+ * anything else is discarded so the frontend falls back to the default tiles.
434
+ */
435
+ protected function sanitizeTileUrl($url): string
436
+ {
437
+ if (!is_string($url)) {
438
+ return '';
439
+ }
440
+
441
+ $url = trim($url);
442
+ if ($url === '' || !preg_match('/^https?:\/\//i', $url)) {
443
+ return '';
444
+ }
445
+
446
+ return $url;
447
+ }
448
+
427
449
  /**
428
450
  * Retrieve driver scheduling settings for the current company.
429
451
  *
@@ -86,6 +86,35 @@ class FuelReportFilter extends Filter
86
86
  });
87
87
  }
88
88
 
89
+ /**
90
+ * Aliases for {@see driver()}.
91
+ *
92
+ * The base filter resolves a query parameter to a method of the same name
93
+ * and **silently ignores anything it cannot match**. So a client scoping a
94
+ * list with `driver_uuid` — the column's own name, and the name every other
95
+ * part of the payload uses — matched nothing, the filter was dropped, and
96
+ * the response came back scoped only by company: every driver's records,
97
+ * with a 200 and no indication that the request had been narrowed at all.
98
+ *
99
+ * For a driver app that is a disclosure, not a nuisance: a driver asking
100
+ * for their own fuel reports received the whole company's.
101
+ */
102
+ public function driverUuid(?string $driver)
103
+ {
104
+ $this->driver($driver);
105
+ }
106
+
107
+ public function driverAssigned(?string $driver)
108
+ {
109
+ $this->driver($driver);
110
+ }
111
+
112
+ /** Alias for {@see vehicle()}, for the same reason. */
113
+ public function vehicleUuid(?string $vehicle)
114
+ {
115
+ $this->vehicle($vehicle);
116
+ }
117
+
89
118
  public function vehicle(?string $vehicle)
90
119
  {
91
120
  $this->builder->whereHas('vehicle', function ($q) use ($vehicle) {
@@ -88,6 +88,35 @@ class IssueFilter extends Filter
88
88
  });
89
89
  }
90
90
 
91
+ /**
92
+ * Aliases for {@see driver()}.
93
+ *
94
+ * The base filter resolves a query parameter to a method of the same name
95
+ * and **silently ignores anything it cannot match**. So a client scoping a
96
+ * list with `driver_uuid` — the column's own name, and the name every other
97
+ * part of the payload uses — matched nothing, the filter was dropped, and
98
+ * the response came back scoped only by company: every driver's records,
99
+ * with a 200 and no indication that the request had been narrowed at all.
100
+ *
101
+ * For a driver app that is a disclosure, not a nuisance: a driver asking
102
+ * for their own fuel reports received the whole company's.
103
+ */
104
+ public function driverUuid(?string $driver)
105
+ {
106
+ $this->driver($driver);
107
+ }
108
+
109
+ public function driverAssigned(?string $driver)
110
+ {
111
+ $this->driver($driver);
112
+ }
113
+
114
+ /** Alias for {@see vehicle()}, for the same reason. */
115
+ public function vehicleUuid(?string $vehicle)
116
+ {
117
+ $this->vehicle($vehicle);
118
+ }
119
+
91
120
  public function vehicle(?string $vehicle)
92
121
  {
93
122
  $this->builder->whereHas('vehicle', function ($q) use ($vehicle) {
@@ -46,11 +46,16 @@ class CreateVehicleRequest extends FleetbaseRequest
46
46
  'decommissioned',
47
47
  ]),
48
48
  ],
49
- 'vendor' => 'nullable|exists:vendors,public_id',
50
- 'driver' => 'nullable|exists:drivers,public_id',
51
- 'location' => ['nullable', new ResolvablePoint()],
52
- 'latitude' => ['nullable', 'required_with:longitude'],
53
- 'longitude' => ['nullable', 'required_with:latitude'],
49
+ // Validated rather than merely accepted: the model casts odometer
50
+ // to an integer, so an unchecked string would be stored as 0 — a
51
+ // vehicle reporting zero miles rather than an error.
52
+ 'odometer' => 'nullable|numeric|min:0',
53
+ 'odometer_unit' => 'nullable|string|max:12',
54
+ 'vendor' => 'nullable|exists:vendors,public_id',
55
+ 'driver' => 'nullable|exists:drivers,public_id',
56
+ 'location' => ['nullable', new ResolvablePoint()],
57
+ 'latitude' => ['nullable', 'required_with:longitude'],
58
+ 'longitude' => ['nullable', 'required_with:latitude'],
54
59
  ];
55
60
  }
56
61
  }
@@ -0,0 +1,52 @@
1
+ <?php
2
+
3
+ namespace Fleetbase\FleetOps\Http\Resources\v1;
4
+
5
+ use Fleetbase\Http\Resources\FleetbaseResource;
6
+ use Fleetbase\Support\Http;
7
+
8
+ /**
9
+ * Public projection of a Manifest — a driver's route for a day.
10
+ *
11
+ * A manifest is order-agnostic: it is a sequence of stops, which may belong to
12
+ * several orders or to none the driver has ever seen as an order. That is why
13
+ * this exposes the stops directly rather than making a consumer assemble them
14
+ * from orders.
15
+ *
16
+ * Totals ride along because a driver wants to know what the day looks like
17
+ * before starting it, and computing them client-side from the stop list gives a
18
+ * different answer than the server's own.
19
+ */
20
+ class Manifest extends FleetbaseResource
21
+ {
22
+ /**
23
+ * Transform the resource into an array.
24
+ *
25
+ * @param \Illuminate\Http\Request $request
26
+ */
27
+ public function toArray($request): array
28
+ {
29
+ return [
30
+ 'id' => $this->when(Http::isInternalRequest(), $this->id, $this->public_id),
31
+ 'uuid' => $this->when(Http::isInternalRequest(), $this->uuid),
32
+ 'public_id' => $this->when(Http::isInternalRequest(), $this->public_id),
33
+ 'status' => $this->status,
34
+ 'scheduled_date' => $this->scheduled_date,
35
+ 'started_at' => $this->started_at,
36
+ 'completed_at' => $this->completed_at,
37
+ 'total_distance_m' => $this->total_distance_m,
38
+ 'total_duration_s' => $this->total_duration_s,
39
+ 'stop_count' => $this->stop_count,
40
+ 'completed_stops' => $this->completed_stops,
41
+ 'pending_stops' => $this->pending_stops,
42
+ 'driver_name' => $this->driver_name,
43
+ 'vehicle_name' => $this->vehicle_name,
44
+ 'notes' => $this->notes,
45
+ // Present only when the manifest was loaded with them, so a list
46
+ // endpoint stays a list.
47
+ 'stops' => ManifestStop::collection($this->whenLoaded('stops')),
48
+ 'updated_at' => $this->updated_at,
49
+ 'created_at' => $this->created_at,
50
+ ];
51
+ }
52
+ }
@@ -0,0 +1,47 @@
1
+ <?php
2
+
3
+ namespace Fleetbase\FleetOps\Http\Resources\v1;
4
+
5
+ use Fleetbase\Http\Resources\FleetbaseResource;
6
+ use Fleetbase\Support\Http;
7
+
8
+ /**
9
+ * Public projection of a ManifestStop.
10
+ *
11
+ * Carries the place inline rather than as an id. A driver app rendering a route
12
+ * needs a name and a coordinate for every stop, and making it resolve each one
13
+ * separately turns a route of twenty stops into twenty-one requests.
14
+ *
15
+ * `sequence` is the order to drive them in, and is the field a re-sequence
16
+ * rewrites.
17
+ */
18
+ class ManifestStop extends FleetbaseResource
19
+ {
20
+ /**
21
+ * Transform the resource into an array.
22
+ *
23
+ * @param \Illuminate\Http\Request $request
24
+ */
25
+ public function toArray($request): array
26
+ {
27
+ return [
28
+ 'id' => $this->when(Http::isInternalRequest(), $this->id, $this->public_id),
29
+ 'uuid' => $this->when(Http::isInternalRequest(), $this->uuid),
30
+ 'public_id' => $this->when(Http::isInternalRequest(), $this->public_id),
31
+ 'status' => $this->status,
32
+ 'sequence' => $this->sequence,
33
+ 'estimated_arrival' => $this->estimated_arrival,
34
+ 'actual_arrival' => $this->actual_arrival,
35
+ 'distance_from_prev_m' => $this->distance_from_prev_m,
36
+ 'duration_from_prev_s' => $this->duration_from_prev_s,
37
+ 'place' => new Place($this->whenLoaded('place')),
38
+ 'order' => $this->when($this->relationLoaded('order') && $this->order, fn () => [
39
+ 'id' => data_get($this->order, 'public_id'),
40
+ 'tracking_number' => data_get($this->order, 'trackingNumber.tracking_number'),
41
+ 'status' => data_get($this->order, 'status'),
42
+ ]),
43
+ 'updated_at' => $this->updated_at,
44
+ 'created_at' => $this->created_at,
45
+ ];
46
+ }
47
+ }
@@ -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
  }
@@ -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');
@@ -601,3 +601,36 @@ test('api driver controller reports missing driver branches', function () {
601
601
  'status' => 404,
602
602
  ]);
603
603
  });
604
+
605
+ test('api driver controller refuses to set a password through a general update', function () {
606
+ /*
607
+ * Regression for a takeover path: `update()` passed `password` straight
608
+ * through to the user, so anyone holding a driver's token — or an unlocked
609
+ * handset — could set a new one without proving they knew the old one, and
610
+ * the account was theirs. A password change is an authorisation decision
611
+ * and now has its own endpoint that demands the current password.
612
+ */
613
+ $user = new FleetOpsApiDriverUserFake();
614
+ $user->setRawAttributes(['uuid' => 'user-uuid'], true);
615
+ $driver = new FleetOpsApiDriverFake();
616
+ $driver->setRawAttributes([
617
+ 'uuid' => 'driver-uuid',
618
+ 'public_id' => 'driver_public',
619
+ 'user_uuid' => 'user-uuid',
620
+ ], true);
621
+ $driver->userForTest = $user;
622
+ $driver->setRelation('user', $user);
623
+
624
+ $controller = new FleetOpsApiDriverControllerProbe();
625
+ $controller->driver = $driver;
626
+ $controller->sessionCompanyUuid = 'session-company-uuid';
627
+
628
+ $controller->update('driver_public', new UpdateDriverRequest([
629
+ 'name' => 'Driver Updated',
630
+ 'password' => 'attacker-chosen-password',
631
+ ]));
632
+
633
+ expect($user->updates)->not->toBeEmpty()
634
+ ->and($user->updates[0])->not->toHaveKey('password')
635
+ ->and($user->updates[0])->toHaveKey('name');
636
+ });