@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
@@ -0,0 +1,187 @@
1
+ <?php
2
+
3
+ namespace Fleetbase\FleetOps\Http\Controllers\Api\v1\Concerns;
4
+
5
+ use Illuminate\Http\Request;
6
+
7
+ /**
8
+ * Normalises the public `with` / `expand` parameters into an allowlisted set of
9
+ * real Eloquent relation names, and writes the result back onto the request.
10
+ *
11
+ * Core's `HasApiModelBehavior` reads `with`/`expand` itself and hands the value
12
+ * straight to `$result->load(...)`, with no allowlist and no mapping. Two things
13
+ * follow from that. A caller can name any string and reach `load()`, and an
14
+ * unknown one raises `RelationNotFoundException` — a 500 for a typo. And the
15
+ * public name is not always the relation name: `?with=subfleets` normalises to
16
+ * `subfleets`, while the relation is `subFleets`, so the documented expansion
17
+ * has never worked.
18
+ *
19
+ * Rewriting the request once, up front, fixes both at the only point where the
20
+ * query builder and the resource are guaranteed to agree on the list.
21
+ */
22
+ trait ResolvesPublicExpansions
23
+ {
24
+ /**
25
+ * Resolve the requested expansions and rewrite the request with them.
26
+ *
27
+ * @param array<string, string> $allowed public name => Eloquent relation name
28
+ *
29
+ * @return array<int, string> the resolved relation names, in request order
30
+ */
31
+ protected function applyPublicExpansions(Request $request, array $allowed): array
32
+ {
33
+ $resolved = $this->resolvePublicExpansions($request, $allowed);
34
+
35
+ // Both keys are rewritten: Core reads `with` or `expand`, whichever is
36
+ // present, so leaving the other one holding raw input would put the
37
+ // unmapped name back in front of load().
38
+ $request->merge(['with' => $resolved]);
39
+
40
+ if ($request->exists('expand')) {
41
+ $request->merge(['expand' => $resolved]);
42
+ }
43
+
44
+ return $resolved;
45
+ }
46
+
47
+ /**
48
+ * @param array<string, string> $allowed
49
+ *
50
+ * @return array<int, string>
51
+ */
52
+ protected function resolvePublicExpansions(Request $request, array $allowed): array
53
+ {
54
+ $requested = $this->publicExpansionInput($request);
55
+ $resolved = [];
56
+
57
+ foreach ($requested as $relation) {
58
+ $mapped = $this->mapPublicExpansion($relation, $allowed);
59
+
60
+ // An unsupported expansion is ignored rather than rejected. A 422
61
+ // would turn a harmless unknown name into a failed request for
62
+ // every generated client that sends a relation this version does
63
+ // not have yet, and the alternative — passing it through — is a
64
+ // 500 from Eloquent.
65
+ if ($mapped !== null && !in_array($mapped, $resolved, true)) {
66
+ $resolved[] = $mapped;
67
+ }
68
+ }
69
+
70
+ return $resolved;
71
+ }
72
+
73
+ /**
74
+ * Every accepted input shape flattened to one list of trimmed, non-empty paths.
75
+ *
76
+ * `?with=vendor`, `?with[]=vendor`, `?with=vendor,driver` and the `expand`
77
+ * alias of each all arrive here and leave identical.
78
+ *
79
+ * @return array<int, string>
80
+ */
81
+ protected function publicExpansionInput(Request $request): array
82
+ {
83
+ $raw = $request->input('with');
84
+
85
+ if ($raw === null || $raw === '' || $raw === []) {
86
+ $raw = $request->input('expand');
87
+ }
88
+
89
+ if ($raw === null) {
90
+ return [];
91
+ }
92
+
93
+ $values = [];
94
+ foreach (is_array($raw) ? $raw : [$raw] as $entry) {
95
+ if (is_array($entry)) {
96
+ continue;
97
+ }
98
+
99
+ foreach (explode(',', (string) $entry) as $part) {
100
+ $part = trim($part);
101
+
102
+ if ($part !== '') {
103
+ $values[] = $part;
104
+ }
105
+ }
106
+ }
107
+
108
+ return $values;
109
+ }
110
+
111
+ /**
112
+ * Map one public path onto its Eloquent relation path, or null if unsupported.
113
+ *
114
+ * Each dotted segment is resolved against the allowlist for the level it sits
115
+ * at, so `subfleets.drivers` is checked as `subfleets` then as `drivers` —
116
+ * a nested path cannot smuggle in a relation the top level would refuse.
117
+ *
118
+ * @param array<string, string> $allowed
119
+ */
120
+ protected function mapPublicExpansion(string $relation, array $allowed): ?string
121
+ {
122
+ $segments = explode('.', $relation);
123
+ $mapped = [];
124
+
125
+ foreach ($segments as $index => $segment) {
126
+ $segment = trim($segment);
127
+
128
+ if ($segment === '') {
129
+ return null;
130
+ }
131
+
132
+ $candidates = $index === 0 ? $allowed : $this->nestedExpansionAllowList();
133
+ $key = $this->matchExpansionKey($segment, $candidates);
134
+
135
+ if ($key === null) {
136
+ return null;
137
+ }
138
+
139
+ $mapped[] = $candidates[$key];
140
+ }
141
+
142
+ return implode('.', $mapped);
143
+ }
144
+
145
+ /**
146
+ * Relations reachable underneath an already-allowed relation.
147
+ *
148
+ * Deliberately shallow. Subfleets carry drivers and vehicles, which is the
149
+ * documented nested case; nothing here re-opens the tree, so
150
+ * `subfleets.subfleets` is refused and an expansion cannot recurse.
151
+ *
152
+ * @return array<string, string>
153
+ */
154
+ protected function nestedExpansionAllowList(): array
155
+ {
156
+ return [
157
+ 'drivers' => 'drivers',
158
+ 'vehicles' => 'vehicles',
159
+ ];
160
+ }
161
+
162
+ /**
163
+ * Match a requested segment against the allowlist, spelling-insensitively.
164
+ *
165
+ * `service_area`, `serviceArea` and `ServiceArea` all name the same relation,
166
+ * and so do `subfleets`, `subFleets` and `sub_fleets`. Comparing on
167
+ * case-folded, separator-stripped forms accepts every spelling a client or a
168
+ * generated SDK might produce without widening what is actually allowed.
169
+ *
170
+ * @param array<string, string> $candidates
171
+ */
172
+ private function matchExpansionKey(string $segment, array $candidates): ?string
173
+ {
174
+ foreach ($candidates as $key => $relation) {
175
+ if ($this->expansionLookupKey($key) === $this->expansionLookupKey($segment)) {
176
+ return $key;
177
+ }
178
+ }
179
+
180
+ return null;
181
+ }
182
+
183
+ private function expansionLookupKey(string $value): string
184
+ {
185
+ return strtolower(str_replace(['_', '-'], '', $value));
186
+ }
187
+ }
@@ -6,6 +6,9 @@ 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\PublicRelationNotFoundException;
10
+ use Fleetbase\FleetOps\Http\Controllers\Api\v1\Concerns\ResolvesFleetOpsApiResources;
11
+ use Fleetbase\FleetOps\Http\Controllers\Api\v1\Concerns\ResolvesPublicExpansions;
9
12
  use Fleetbase\FleetOps\Http\Requests\CreateDriverRequest;
10
13
  use Fleetbase\FleetOps\Http\Requests\DriverSimulationRequest;
11
14
  use Fleetbase\FleetOps\Http\Requests\UpdateDriverRequest;
@@ -16,6 +19,7 @@ use Fleetbase\FleetOps\Jobs\SimulateDrivingRoute;
16
19
  use Fleetbase\FleetOps\Models\Driver;
17
20
  use Fleetbase\FleetOps\Models\Order;
18
21
  use Fleetbase\FleetOps\Models\Vehicle;
22
+ use Fleetbase\FleetOps\Models\Vendor;
19
23
  use Fleetbase\FleetOps\Support\GeofenceIntersectionService;
20
24
  use Fleetbase\FleetOps\Support\OSRM;
21
25
  use Fleetbase\FleetOps\Support\Utils;
@@ -40,6 +44,23 @@ use Illuminate\Support\Str;
40
44
  class DriverController extends Controller
41
45
  {
42
46
  use \Fleetbase\FleetOps\Http\Controllers\Concerns\ResolvesReviewAccountBypass;
47
+ use ResolvesFleetOpsApiResources;
48
+ use ResolvesPublicExpansions;
49
+
50
+ /**
51
+ * Public expansion name => Eloquent relation name.
52
+ *
53
+ * `user` and `company` are deliberately absent. Both are already published
54
+ * as public-id strings, and Navigator interpolates `driver.user` directly
55
+ * into a socket channel name — expanding either would retype a released
56
+ * field and break it silently.
57
+ */
58
+ public const EXPANDABLE = [
59
+ 'vehicle' => 'vehicle',
60
+ 'vendor' => 'vendor',
61
+ 'current_job' => 'currentJob',
62
+ 'fleets' => 'fleets',
63
+ ];
43
64
 
44
65
  /**
45
66
  * Creates a new Fleetbase Driver resource.
@@ -48,11 +69,7 @@ class DriverController extends Controller
48
69
  */
49
70
  public function create(CreateDriverRequest $request)
50
71
  {
51
- // get request input
52
- $input = $request->except(['name', 'password', 'email', 'phone', 'location', 'altitude', 'heading', 'speed', 'meta']);
53
-
54
- // Add default status
55
- $input['status'] = $request->input('status', 'available');
72
+ $this->applyPublicExpansions($request, static::EXPANDABLE);
56
73
 
57
74
  // get user details for driver
58
75
  $userDetails = $request->only(['name', 'password', 'email', 'phone', 'timezone']);
@@ -65,6 +82,18 @@ class DriverController extends Controller
65
82
  return $this->apiError('Company not found.');
66
83
  }
67
84
 
85
+ // get request input. Relationship inputs resolve against the company the
86
+ // driver is being created in, which is not always the session company —
87
+ // a request may name one explicitly.
88
+ try {
89
+ $input = $this->driverInputFromRequest($request, $company->uuid);
90
+ } catch (PublicRelationNotFoundException $exception) {
91
+ return $this->jsonResponse(['error' => $exception->getMessage()], 404);
92
+ }
93
+
94
+ // Add default status
95
+ $input['status'] = $request->input('status', 'available');
96
+
68
97
  // Apply user infos
69
98
  $userDetails = $this->applyUserInfoFromRequest($request, $userDetails);
70
99
 
@@ -87,30 +116,6 @@ class DriverController extends Controller
87
116
  $input['user_uuid'] = $user->uuid;
88
117
  $input['company_uuid'] = $company->uuid; // Ensure correct company_uuid is set
89
118
 
90
- // vehicle assignment public_id -> uuid
91
- if ($request->has('vehicle')) {
92
- $input['vehicle_uuid'] = $this->getUuid('vehicles', [
93
- 'public_id' => $request->input('vehicle'),
94
- 'company_uuid' => $company->uuid, // Use $company->uuid instead of session
95
- ]);
96
- }
97
-
98
- // vendor assignment public_id -> uuid
99
- if ($request->has('vendor')) {
100
- $input['vendor_uuid'] = $this->getUuid('vendors', [
101
- 'public_id' => $request->input('vendor'),
102
- 'company_uuid' => $company->uuid, // Use $company->uuid instead of session
103
- ]);
104
- }
105
-
106
- // order|alias:job assignment public_id -> uuid
107
- if ($request->has('job')) {
108
- $input['current_job_uuid'] = $this->getUuid('orders', [
109
- 'public_id' => $request->input('job'),
110
- 'company_uuid' => $company->uuid, // Use $company->uuid instead of session
111
- ]);
112
- }
113
-
114
119
  // set default online
115
120
  if (!isset($input['online'])) {
116
121
  $input['online'] = 0;
@@ -130,7 +135,11 @@ class DriverController extends Controller
130
135
  $file = $this->resolveFile($request->input('photo'), $path);
131
136
 
132
137
  if ($file) {
133
- $user->update(['photo_uuid' => $file->uuid]);
138
+ // `photo_uuid` is not a column on users — the avatar lives in
139
+ // `avatar_uuid`, and User guards mass assignment by fillable, so
140
+ // the old key was dropped without a word and every driver photo
141
+ // uploaded through the public API was discarded.
142
+ $user->update(['avatar_uuid' => $file->uuid]);
134
143
  }
135
144
  }
136
145
 
@@ -151,6 +160,8 @@ class DriverController extends Controller
151
160
  */
152
161
  public function update($id, UpdateDriverRequest $request)
153
162
  {
163
+ $this->applyPublicExpansions($request, static::EXPANDABLE);
164
+
154
165
  // find for the driver
155
166
  try {
156
167
  $driver = $this->findDriver($id, ['user']);
@@ -164,10 +175,23 @@ class DriverController extends Controller
164
175
  }
165
176
 
166
177
  // get request input
167
- $input = $request->except(['name', 'password', 'email', 'phone', 'location', 'altitude', 'heading', 'speed', 'meta']);
178
+ try {
179
+ $input = $this->driverInputFromRequest($request);
180
+ } catch (PublicRelationNotFoundException $exception) {
181
+ return $this->jsonResponse(['error' => $exception->getMessage()], 404);
182
+ }
168
183
 
169
- // get user details for driver
170
- $userDetails = $request->only(['name', 'password', 'email', 'phone']);
184
+ /*
185
+ * Deliberately no `password` here. Setting one through a general update
186
+ * meant anyone holding a driver's token — or an unlocked handset —
187
+ * could take the account without proving they knew the existing
188
+ * password. Changing a password is its own operation with its own
189
+ * proof: see changePassword(), forgotPassword() and resetPassword().
190
+ */
191
+ // `timezone` is accepted and documented on update, but was never copied
192
+ // to the linked user — the request validated, answered 200, and dropped
193
+ // it. The driver's own record has no timezone column; the user's does.
194
+ $userDetails = $request->only(['name', 'email', 'phone', 'timezone']);
171
195
 
172
196
  // update driver user details
173
197
  $driverUser = $driver->getUser();
@@ -175,30 +199,6 @@ class DriverController extends Controller
175
199
  $driverUser->update($userDetails);
176
200
  }
177
201
 
178
- // vehicle assignment public_id -> uuid
179
- if ($request->has('vehicle')) {
180
- $input['vehicle_uuid'] = $this->getUuid('vehicles', [
181
- 'public_id' => $request->input('vehicle'),
182
- 'company_uuid' => $this->sessionCompany(),
183
- ]);
184
- }
185
-
186
- // vendor assignment public_id -> uuid
187
- if ($request->has('vendor')) {
188
- $input['vendor_uuid'] = $this->getUuid('vendors', [
189
- 'public_id' => $request->input('vendor'),
190
- 'company_uuid' => $this->sessionCompany(),
191
- ]);
192
- }
193
-
194
- // order|alias:job assignment public_id -> uuid
195
- if ($request->has('job')) {
196
- $input['current_job_uuid'] = $this->getUuid('orders', [
197
- 'public_id' => $request->input('job'),
198
- 'company_uuid' => $this->sessionCompany(),
199
- ]);
200
- }
201
-
202
202
  // latitude / longitude
203
203
  if ($request->has(['latitude', 'longitude'])) {
204
204
  $input['location'] = $this->pointFromCoordinates($request->only(['latitude', 'longitude']));
@@ -214,7 +214,7 @@ class DriverController extends Controller
214
214
  $file = $this->resolveFile($request->input('photo'), $path);
215
215
 
216
216
  if ($file) {
217
- $driver->user->update(['photo_uuid' => $file->uuid]);
217
+ $driver->user->update(['avatar_uuid' => $file->uuid]);
218
218
  }
219
219
  }
220
220
 
@@ -232,6 +232,8 @@ class DriverController extends Controller
232
232
  */
233
233
  public function query(Request $request)
234
234
  {
235
+ $this->applyPublicExpansions($request, static::EXPANDABLE);
236
+
235
237
  $results = $this->queryDrivers($request);
236
238
 
237
239
  return $this->driverResourceCollection($results);
@@ -246,6 +248,11 @@ class DriverController extends Controller
246
248
  */
247
249
  public function find($id)
248
250
  {
251
+ // Retrieve carries no Request parameter, so the container's is used —
252
+ // otherwise an unsupported `with` on this endpoint would reach Eloquent
253
+ // unmapped.
254
+ $this->applyPublicExpansions(request(), static::EXPANDABLE);
255
+
249
256
  // find for the driver
250
257
  try {
251
258
  $driver = $this->findDriver($id, ['user', 'vehicle', 'vendor', 'currentJob']);
@@ -913,6 +920,41 @@ class DriverController extends Controller
913
920
  return response()->json($route);
914
921
  }
915
922
 
923
+ /**
924
+ * The explicit public input allowlist for a driver.
925
+ *
926
+ * Replaces an `except()` blocklist: anything not named here — `user_uuid`,
927
+ * `company_uuid`, `auth_token`, `signup_token_used`, `public_id`, `uuid`,
928
+ * `_key`, the generated `slug`, and the raw `*_uuid` relation columns — used
929
+ * to reach `Driver::create()` intact simply because nobody had thought to
930
+ * exclude it. `location`, `heading`, `altitude`, `speed` and `meta` were on
931
+ * that blocklist and so were dropped on every write, which is why a driver's
932
+ * metadata never persisted through the public API.
933
+ *
934
+ * @throws PublicRelationNotFoundException
935
+ */
936
+ protected function driverInputFromRequest(Request $request, ?string $companyUuid = null): array
937
+ {
938
+ $input = $request->only([
939
+ // Identity
940
+ 'internal_id', 'drivers_license_number', 'license_expiry',
941
+ // Operational
942
+ 'country', 'currency', 'city', 'online', 'current_status', 'status',
943
+ 'location', 'heading', 'bearing', 'altitude', 'speed',
944
+ // Structured / orchestrator
945
+ 'meta', 'skills', 'max_travel_time', 'max_distance',
946
+ 'time_window_start', 'time_window_end',
947
+ ]);
948
+
949
+ $this->applyPublicIdRelations($input, [
950
+ 'vehicle' => ['vehicle_uuid', Vehicle::class],
951
+ 'vendor' => ['vendor_uuid', Vendor::class],
952
+ 'job' => ['current_job_uuid', Order::class],
953
+ ], $request, $companyUuid);
954
+
955
+ return $input;
956
+ }
957
+
916
958
  protected function companyFromRequest(Request $request): ?Company
917
959
  {
918
960
  return Auth::getCompanyFromRequest($request);
@@ -935,7 +977,24 @@ class DriverController extends Controller
935
977
 
936
978
  protected function createUser(array $userDetails): User
937
979
  {
938
- return User::create($userDetails);
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
+ }
996
+
997
+ return $user;
939
998
  }
940
999
 
941
1000
  protected function getUuid(array|string $table, array $where, array $options = []): mixed
@@ -1177,4 +1236,198 @@ class DriverController extends Controller
1177
1236
  }
1178
1237
  }
1179
1238
  }
1239
+
1240
+ /**
1241
+ * Change the authenticated driver's password, proving the current one.
1242
+ *
1243
+ * A password change is an authorisation decision, not an attribute update.
1244
+ * Requiring the existing password is what stops a borrowed handset or a
1245
+ * leaked token from becoming a permanent account takeover, and re-issuing
1246
+ * the caller's token afterwards is what stops every *other* session from
1247
+ * outliving the change.
1248
+ */
1249
+ public function changePassword(Request $request, string $id)
1250
+ {
1251
+ $current = $request->input('current_password');
1252
+ $password = $request->input('password');
1253
+
1254
+ if (!$current || !$password) {
1255
+ return response()->apiError('current_password and password are required.', 400);
1256
+ }
1257
+
1258
+ if (strlen($password) < 8) {
1259
+ return response()->apiError('Password must be at least 8 characters.', 400);
1260
+ }
1261
+
1262
+ if ($request->filled('password_confirmation') && $request->input('password_confirmation') !== $password) {
1263
+ return response()->apiError('Password confirmation does not match.', 422);
1264
+ }
1265
+
1266
+ try {
1267
+ $driver = $this->findDriver($id, ['user']);
1268
+ } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $exception) {
1269
+ return response()->apiError('Driver resource not found.', 404);
1270
+ }
1271
+
1272
+ $user = static::findDriverUserRecord($driver);
1273
+ if (!$user) {
1274
+ return response()->apiError('Driver has no user account.', 422);
1275
+ }
1276
+
1277
+ if (!static::passwordMatches($user, $current)) {
1278
+ // Same wording whether the password was wrong or the account is odd
1279
+ // — a change endpoint should not become an oracle.
1280
+ return response()->apiError('The current password is incorrect.', 422);
1281
+ }
1282
+
1283
+ $user->password = $password;
1284
+ $user->save();
1285
+
1286
+ /*
1287
+ * Every other session dies with the old password. The caller keeps
1288
+ * working by being handed a fresh token in the same response, so a
1289
+ * driver who changes their password mid-shift is not signed out of the
1290
+ * device in their hand.
1291
+ */
1292
+ $user->tokens()->delete();
1293
+ $token = $user->createToken($request->input('device_name', 'navigator'));
1294
+
1295
+ return response()->json([
1296
+ 'status' => 'ok',
1297
+ 'token' => $token->plainTextToken,
1298
+ ]);
1299
+ }
1300
+
1301
+ /**
1302
+ * Send a password reset code to a driver who cannot sign in.
1303
+ *
1304
+ * Answers identically whether or not the identity exists: a reset endpoint
1305
+ * that 404s on an unknown phone number is a way to enumerate a company's
1306
+ * drivers.
1307
+ */
1308
+ public function forgotPassword(Request $request)
1309
+ {
1310
+ $identity = $request->input('identity');
1311
+ if (!$identity) {
1312
+ return response()->apiError('Identity is required.', 400);
1313
+ }
1314
+
1315
+ $user = static::findDriverUserByIdentity($identity);
1316
+ if (!$user) {
1317
+ return response()->json(['status' => 'ok']);
1318
+ }
1319
+
1320
+ try {
1321
+ static::sendResetCode($user, $identity);
1322
+ } catch (\Throwable $e) {
1323
+ return response()->apiError(static::debugEnabled() ? $e->getMessage() : 'Unable to send reset code.');
1324
+ }
1325
+
1326
+ return response()->json(['status' => 'ok']);
1327
+ }
1328
+
1329
+ /**
1330
+ * Set a new password using a code sent by forgotPassword().
1331
+ */
1332
+ public function resetPassword(Request $request)
1333
+ {
1334
+ $identity = $request->input('identity');
1335
+ $code = $request->input('code');
1336
+ $password = $request->input('password');
1337
+
1338
+ if (!$identity || !$code || !$password) {
1339
+ return response()->apiError('identity, code, and password are required.', 400);
1340
+ }
1341
+
1342
+ if (strlen($password) < 8) {
1343
+ return response()->apiError('Password must be at least 8 characters.', 400);
1344
+ }
1345
+
1346
+ $user = static::findDriverUserByIdentity($identity);
1347
+ if (!$user) {
1348
+ return response()->apiError('Invalid or expired reset code.', 422);
1349
+ }
1350
+
1351
+ $verification = static::findResetCode($user, $code);
1352
+
1353
+ if (!$verification) {
1354
+ // One message for a wrong code, an expired code and an unknown
1355
+ // identity alike.
1356
+ return response()->apiError('Invalid or expired reset code.', 422);
1357
+ }
1358
+
1359
+ $user->password = $password;
1360
+ $user->save();
1361
+
1362
+ $verification->delete();
1363
+ $user->tokens()->delete();
1364
+
1365
+ return response()->json(['status' => 'ok']);
1366
+ }
1367
+
1368
+ /** Whether the application is in debug mode, so an error may be echoed. */
1369
+ protected static function debugEnabled(): bool
1370
+ {
1371
+ return app()->hasDebugModeEnabled();
1372
+ }
1373
+
1374
+ /**
1375
+ * Load a driver's user account with every column.
1376
+ *
1377
+ * The `user` relation selects a named subset of columns, and `password` is
1378
+ * not among them — reading it through the relation yields an empty string,
1379
+ * so a password comparison would fail for everyone regardless of what they
1380
+ * typed. Anything checking a password has to load the record itself.
1381
+ */
1382
+ protected static function findDriverUserRecord(Driver $driver): ?User
1383
+ {
1384
+ if (!$driver->user_uuid) {
1385
+ return null;
1386
+ }
1387
+
1388
+ return User::where('uuid', $driver->user_uuid)->first();
1389
+ }
1390
+
1391
+ /** Whether a plaintext password matches the stored hash. */
1392
+ protected static function passwordMatches(User $user, string $plain): bool
1393
+ {
1394
+ return Hash::check($plain, $user->password);
1395
+ }
1396
+
1397
+ /** The unexpired reset code for this user, if the one supplied matches. */
1398
+ protected static function findResetCode(User $user, string $code)
1399
+ {
1400
+ return VerificationCode::where([
1401
+ 'subject_uuid' => $user->uuid,
1402
+ 'code' => $code,
1403
+ 'for' => 'driver_password_reset',
1404
+ ])->where('expires_at', '>', now())->first();
1405
+ }
1406
+
1407
+ /** Sends the reset code by whichever channel the identity names. */
1408
+ protected static function sendResetCode(User $user, string $identity): void
1409
+ {
1410
+ // if/else rather than an early return: a `return` sitting after a call
1411
+ // that can throw is a line no test can reach.
1412
+ if (Utils::isEmail($identity)) {
1413
+ VerificationCode::generateEmailVerificationFor($user, 'driver_password_reset', [
1414
+ 'subject' => config('app.name') . ' password reset',
1415
+ 'messageCallback' => fn ($v) => 'Your ' . config('app.name') . ' password reset code is ' . $v->code,
1416
+ ]);
1417
+ } else {
1418
+ VerificationCode::generateSmsVerificationFor($user, 'driver_password_reset', [
1419
+ 'messageCallback' => fn ($v) => 'Your ' . config('app.name') . ' password reset code is ' . $v->code,
1420
+ ]);
1421
+ }
1422
+ }
1423
+
1424
+ /** Resolve a driver's user account from an email address or a phone number. */
1425
+ protected static function findDriverUserByIdentity(string $identity): ?User
1426
+ {
1427
+ $isEmail = Utils::isEmail($identity);
1428
+ $column = $isEmail ? 'email' : 'phone';
1429
+ $value = $isEmail ? $identity : static::phone($identity);
1430
+
1431
+ return User::whereHas('driver')->where($column, $value)->first();
1432
+ }
1180
1433
  }
@@ -271,7 +271,7 @@ class EntityController extends Controller
271
271
  return $value ? Utils::singularize($value) : null;
272
272
  }
273
273
 
274
- protected function findPayloadByPublicId(string $publicId): ?Payload
274
+ protected function findPayloadByPublicId(?string $publicId): ?Payload
275
275
  {
276
276
  return Payload::where('public_id', $publicId)->first();
277
277
  }