@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.
- package/RELEASE.md +21 -17
- package/addon/components/ai/route-preview-map.js +1 -5
- package/addon/components/customer/orders.js +2 -1
- package/addon/components/driver/card.hbs +49 -0
- package/addon/components/driver/card.js +30 -0
- package/addon/components/map/leaflet-live-map.js +4 -19
- package/addon/components/modals/place-details.hbs +1 -1
- package/addon/components/modals/point-map.hbs +1 -1
- package/addon/components/orchestrator-workbench.js +2 -1
- package/addon/components/order/form/route.js +3 -0
- package/addon/components/order-tracking-lookup.js +2 -1
- package/addon/components/place/details.hbs +1 -1
- package/addon/components/positions-replay.js +4 -1
- package/addon/components/service-area/details.hbs +1 -1
- package/addon/components/widget/live-fleet.js +7 -7
- package/addon/components/zone/details.hbs +1 -1
- package/addon/controllers/management/drivers/index.js +22 -1
- package/addon/controllers/settings/map.js +16 -0
- package/addon/helpers/leaflet-tile-url.js +19 -0
- package/addon/services/map-settings.js +26 -1
- package/addon/templates/management/drivers/index.hbs +67 -33
- package/addon/templates/settings/map.hbs +10 -0
- package/addon/utils/leaflet-tile-url.js +31 -0
- package/addon/utils/prepare-place-for-save.js +13 -0
- package/app/components/driver/card.js +1 -0
- package/app/helpers/leaflet-tile-url.js +1 -0
- package/app/utils/leaflet-tile-url.js +1 -0
- package/composer.json +1 -1
- package/extension.json +1 -1
- package/index.js +11 -0
- package/package.json +2 -1
- package/server/migrations/2026_09_05_000001_add_unique_indexes_to_fleet_membership_pivots.php +129 -0
- package/server/src/Exceptions/PublicRelationNotFoundException.php +47 -0
- package/server/src/Http/Controllers/Api/v1/Concerns/ResolvesFleetOpsApiResources.php +42 -7
- package/server/src/Http/Controllers/Api/v1/Concerns/ResolvesPublicExpansions.php +187 -0
- package/server/src/Http/Controllers/Api/v1/DriverController.php +312 -59
- package/server/src/Http/Controllers/Api/v1/EntityController.php +1 -1
- package/server/src/Http/Controllers/Api/v1/FleetController.php +370 -33
- package/server/src/Http/Controllers/Api/v1/FuelReportController.php +1 -1
- package/server/src/Http/Controllers/Api/v1/IssueController.php +1 -1
- package/server/src/Http/Controllers/Api/v1/ManifestController.php +250 -0
- package/server/src/Http/Controllers/Api/v1/OrderController.php +1 -1
- package/server/src/Http/Controllers/Api/v1/ServiceAreaController.php +1 -1
- package/server/src/Http/Controllers/Api/v1/TrackingStatusController.php +1 -1
- package/server/src/Http/Controllers/Api/v1/VehicleController.php +139 -34
- package/server/src/Http/Controllers/Api/v1/ZoneController.php +1 -1
- package/server/src/Http/Controllers/Internal/v1/SettingController.php +22 -0
- package/server/src/Http/Filter/Concerns/ResolvesPublicRelationUuids.php +54 -0
- package/server/src/Http/Filter/DriverFilter.php +71 -15
- package/server/src/Http/Filter/FleetFilter.php +32 -33
- package/server/src/Http/Filter/FuelReportFilter.php +29 -0
- package/server/src/Http/Filter/IssueFilter.php +29 -0
- package/server/src/Http/Filter/VehicleFilter.php +57 -19
- package/server/src/Http/Requests/Concerns/ScopesPublicRelationRules.php +48 -0
- package/server/src/Http/Requests/CreateDriverRequest.php +100 -9
- package/server/src/Http/Requests/CreateFleetRequest.php +30 -2
- package/server/src/Http/Requests/CreateServiceRateRequest.php +7 -2
- package/server/src/Http/Requests/CreateVehicleRequest.php +150 -26
- package/server/src/Http/Resources/v1/Concerns/ResolvesPublicRelationFields.php +110 -0
- package/server/src/Http/Resources/v1/Driver.php +22 -2
- package/server/src/Http/Resources/v1/Fleet.php +38 -25
- package/server/src/Http/Resources/v1/Manifest.php +52 -0
- package/server/src/Http/Resources/v1/ManifestStop.php +47 -0
- package/server/src/Http/Resources/v1/OrderConfig.php +51 -4
- package/server/src/Http/Resources/v1/Vehicle.php +46 -4
- package/server/src/Models/Driver.php +2 -2
- package/server/src/Models/Place.php +1 -1
- package/server/src/routes.php +29 -0
- package/server/tests/ApiDriverControllerContractsTest.php +317 -13
- package/server/tests/ApiDriverPasswordContractsTest.php +457 -0
- package/server/tests/ApiEntityControllerContractsTest.php +1 -1
- package/server/tests/ApiEquipmentControllerContractsTest.php +2 -2
- package/server/tests/ApiFleetControllerContractsTest.php +354 -21
- package/server/tests/ApiFuelReportControllerContractsTest.php +1 -1
- package/server/tests/ApiIssueControllerContractsTest.php +1 -1
- package/server/tests/ApiManifestControllerContractsTest.php +599 -0
- package/server/tests/ApiOrderControllerContractsTest.php +1 -1
- package/server/tests/ApiPartControllerContractsTest.php +2 -2
- package/server/tests/ApiServiceAreaZoneControllerContractsTest.php +2 -2
- package/server/tests/ApiTrackingStatusControllerContractsTest.php +1 -1
- package/server/tests/ApiVehicleControllerContractsTest.php +210 -8
- package/server/tests/CompactResourceSerializationTest.php +13 -7
- package/server/tests/ControllerFilterContractsTest.php +132 -13
- package/server/tests/ControllerHelperContractsTest.php +6 -3
- package/server/tests/DriverFilterExecutionTest.php +130 -6
- package/server/tests/ExceptionContractsTest.php +15 -0
- package/server/tests/Feature/Http/Api/DeviceControllerContractsTest.php +1 -1
- package/server/tests/Feature/Http/Api/DriverControllerSessionHelpersTest.php +61 -0
- package/server/tests/Feature/Http/Api/FleetMembershipMigrationTest.php +140 -0
- package/server/tests/Feature/Http/Api/FleetMembershipTest.php +333 -0
- package/server/tests/Feature/Http/Api/FleetPublicContractTest.php +439 -0
- package/server/tests/Feature/Http/Api/FuelTransactionControllerContractsTest.php +2 -2
- package/server/tests/Feature/Http/Api/NullRelationshipInputTest.php +191 -0
- package/server/tests/Feature/Http/Api/PublicIdentifierFilterTest.php +293 -0
- package/server/tests/Feature/Http/Api/SmallApiControllerHelpersTest.php +7 -1
- package/server/tests/Feature/Http/Api/VehicleControllerTrackingTest.php +3 -1
- package/server/tests/Feature/Http/Api/VehiclePublicContractTest.php +454 -0
- package/server/tests/Feature/Http/Api/WorkOrderControllerContractsTest.php +1 -1
- package/server/tests/Feature/Http/Internal/PlaceControllerTest.php +45 -0
- package/server/tests/FleetHierarchyResourceTest.php +56 -8
- package/server/tests/RequestContractsTest.php +105 -7
- package/server/tests/SettingControllerContractsTest.php +33 -0
- package/server/tests/Unit/Http/Filter/FuelReportFilterTest.php +135 -0
- package/server/tests/Unit/Http/Filter/IssueFilterTest.php +43 -0
- package/server/tests/Unit/Http/Resources/FleetResourceTest.php +50 -6
- package/server/tests/Unit/Http/Resources/ManifestResourceTest.php +129 -0
- package/server/tests/Unit/Http/Resources/OrderConfigResourceTest.php +91 -0
- package/server/tests/VehicleControllerHelperContractsTest.php +1 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
178
|
+
try {
|
|
179
|
+
$input = $this->driverInputFromRequest($request);
|
|
180
|
+
} catch (PublicRelationNotFoundException $exception) {
|
|
181
|
+
return $this->jsonResponse(['error' => $exception->getMessage()], 404);
|
|
182
|
+
}
|
|
168
183
|
|
|
169
|
-
|
|
170
|
-
|
|
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(['
|
|
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
|
-
|
|
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
|
}
|