@fleetbase/fleetops-engine 0.6.61 → 0.6.63

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 (68) hide show
  1. package/RELEASE.md +5 -14
  2. package/addon/controllers/management/drivers/index.js +4 -1
  3. package/addon/routes/management/fleets/index/details.js +1 -1
  4. package/addon/routes/management/fleets/index/edit.js +1 -1
  5. package/addon/routes/management/fleets/index.js +3 -1
  6. package/addon/services/map-settings.js +4 -1
  7. package/composer.json +1 -1
  8. package/extension.json +1 -1
  9. package/package.json +1 -1
  10. package/server/migrations/2026_09_05_000001_add_unique_indexes_to_fleet_membership_pivots.php +129 -0
  11. package/server/src/Exceptions/PublicRelationNotFoundException.php +47 -0
  12. package/server/src/Http/Controllers/Api/v1/Concerns/ResolvesFleetOpsApiResources.php +42 -7
  13. package/server/src/Http/Controllers/Api/v1/Concerns/ResolvesPublicExpansions.php +187 -0
  14. package/server/src/Http/Controllers/Api/v1/DriverController.php +93 -57
  15. package/server/src/Http/Controllers/Api/v1/EntityController.php +1 -1
  16. package/server/src/Http/Controllers/Api/v1/FleetController.php +370 -33
  17. package/server/src/Http/Controllers/Api/v1/FuelReportController.php +1 -1
  18. package/server/src/Http/Controllers/Api/v1/IssueController.php +1 -1
  19. package/server/src/Http/Controllers/Api/v1/OrderController.php +1 -1
  20. package/server/src/Http/Controllers/Api/v1/ServiceAreaController.php +1 -1
  21. package/server/src/Http/Controllers/Api/v1/TrackingStatusController.php +1 -1
  22. package/server/src/Http/Controllers/Api/v1/VehicleController.php +134 -35
  23. package/server/src/Http/Controllers/Api/v1/ZoneController.php +1 -1
  24. package/server/src/Http/Filter/Concerns/ResolvesPublicRelationUuids.php +54 -0
  25. package/server/src/Http/Filter/DriverFilter.php +71 -15
  26. package/server/src/Http/Filter/FleetFilter.php +32 -33
  27. package/server/src/Http/Filter/VehicleFilter.php +57 -19
  28. package/server/src/Http/Requests/Concerns/ScopesPublicRelationRules.php +48 -0
  29. package/server/src/Http/Requests/CreateDriverRequest.php +100 -9
  30. package/server/src/Http/Requests/CreateFleetRequest.php +30 -2
  31. package/server/src/Http/Requests/CreateServiceRateRequest.php +7 -2
  32. package/server/src/Http/Requests/CreateVehicleRequest.php +150 -31
  33. package/server/src/Http/Resources/v1/Concerns/ResolvesPublicRelationFields.php +110 -0
  34. package/server/src/Http/Resources/v1/Driver.php +22 -2
  35. package/server/src/Http/Resources/v1/Fleet.php +38 -25
  36. package/server/src/Http/Resources/v1/Vehicle.php +46 -4
  37. package/server/src/Models/Driver.php +2 -2
  38. package/server/src/routes.php +8 -0
  39. package/server/tests/ApiDriverControllerContractsTest.php +284 -13
  40. package/server/tests/ApiEntityControllerContractsTest.php +1 -1
  41. package/server/tests/ApiEquipmentControllerContractsTest.php +2 -2
  42. package/server/tests/ApiFleetControllerContractsTest.php +354 -21
  43. package/server/tests/ApiFuelReportControllerContractsTest.php +1 -1
  44. package/server/tests/ApiIssueControllerContractsTest.php +1 -1
  45. package/server/tests/ApiOrderControllerContractsTest.php +1 -1
  46. package/server/tests/ApiPartControllerContractsTest.php +2 -2
  47. package/server/tests/ApiServiceAreaZoneControllerContractsTest.php +2 -2
  48. package/server/tests/ApiTrackingStatusControllerContractsTest.php +1 -1
  49. package/server/tests/ApiVehicleControllerContractsTest.php +175 -8
  50. package/server/tests/ControllerFilterContractsTest.php +132 -13
  51. package/server/tests/ControllerHelperContractsTest.php +6 -3
  52. package/server/tests/DriverFilterExecutionTest.php +130 -6
  53. package/server/tests/ExceptionContractsTest.php +15 -0
  54. package/server/tests/Feature/Http/Api/DeviceControllerContractsTest.php +1 -1
  55. package/server/tests/Feature/Http/Api/FleetMembershipMigrationTest.php +140 -0
  56. package/server/tests/Feature/Http/Api/FleetMembershipTest.php +333 -0
  57. package/server/tests/Feature/Http/Api/FleetPublicContractTest.php +439 -0
  58. package/server/tests/Feature/Http/Api/FuelTransactionControllerContractsTest.php +2 -2
  59. package/server/tests/Feature/Http/Api/NullRelationshipInputTest.php +191 -0
  60. package/server/tests/Feature/Http/Api/PublicIdentifierFilterTest.php +293 -0
  61. package/server/tests/Feature/Http/Api/SmallApiControllerHelpersTest.php +7 -1
  62. package/server/tests/Feature/Http/Api/VehicleControllerTrackingTest.php +3 -1
  63. package/server/tests/Feature/Http/Api/VehiclePublicContractTest.php +454 -0
  64. package/server/tests/Feature/Http/Api/WorkOrderControllerContractsTest.php +1 -1
  65. package/server/tests/FleetHierarchyResourceTest.php +56 -8
  66. package/server/tests/RequestContractsTest.php +100 -7
  67. package/server/tests/Unit/Http/Resources/FleetResourceTest.php +50 -6
  68. package/server/tests/VehicleControllerHelperContractsTest.php +1 -1
package/RELEASE.md CHANGED
@@ -1,23 +1,14 @@
1
- > v0.6.61 ~ "A grid view for drivers, a sequenceable order flow, and two silent API no-ops closed"
2
-
3
- ---
4
- ## Highlights
5
- Two API defects in this release were silent: both answered `200` and neither did what the caller asked. A vehicle odometer update was discarded, and a driver-scoped list came back unscoped. Alongside them, the order config flow now publishes enough of its shape to be sequenced, and Drivers Management gains a card layout.
6
-
7
- ---
8
- ## Features
9
- - **Drivers Management has a card view.** A layout toggle switches the index between the table and a card grid, and the choice is remembered across visits.
10
- - **The order config flow publishes its graph.** `activities`, `sequence` and `logic` now ride along with each activity, so a consumer can order the flow and offer a next step instead of rendering an unordered set. Transitions are normalised to a list of codes regardless of which of the two stored shapes a flow was authored in.
1
+ > v0.6.63 ~ "Fleet and driver relationship display fixes"
11
2
 
12
3
  ---
13
4
  ## Bug Fixes
14
- - **`PUT /v1/vehicles/{id}` silently discarded the odometer.** The field is fillable on the model but was missing from the controller's input projection, so a driver app recording mileage received a `200` and a correct-looking body while the reading was dropped. `odometer` and `odometer_unit` are now accepted and validated.
15
- - **Issue and fuel-report lists scoped by `driver_uuid` came back scoped by nothing.** The base filter silently ignores a query parameter it cannot match to a method, so the filter was dropped and the response was narrowed only by company — every driver's records, with no sign the request had been narrowed at all. `driver_uuid`, `driver_assigned` and `vehicle_uuid` are now recognised on both filters.
16
- - **Leaflet marker icons are served from the leaflet package** rather than resolving to a broken URL.
5
+ - **Driver vendor names display correctly.** The Drivers table now uses the `vendor_name` value included in the list response instead of reading an unloaded `vendor` relationship.
6
+ - **Fleet pages load hierarchy relationships correctly.** Internal Fleet-Ops requests now use the Fleet model's Eloquent relationship names, preventing the `RelationNotFoundException` raised when opening the Fleets route.
17
7
 
18
8
  ---
19
9
  ## Testing
20
- - Coverage held at 100% across the vehicle input projection, both driver-scoped filters, and the order config flow projection.
10
+ - Added a regression assertion for the vendor column used by the Drivers table.
11
+ - Added route coverage for the relationship names requested by the Fleet index, edit and details pages.
21
12
 
22
13
  ---
23
14
  ## Need help?
@@ -241,7 +241,10 @@ export default class ManagementDriversIndexController extends Controller {
241
241
  this.notifications.serverError(err);
242
242
  }
243
243
  },
244
- valuePath: 'vendor.name',
244
+ // Driver list responses already include the vendor_name accessor,
245
+ // while the vendor relationship itself is not eager loaded. Use
246
+ // the scalar so assigned vendors do not render as blank cells.
247
+ valuePath: 'vendor_name',
245
248
  modelNamePath: 'name',
246
249
  resizable: true,
247
250
  filterable: true,
@@ -24,6 +24,6 @@ export default class ManagementFleetsIndexDetailsRoute extends Route {
24
24
  }
25
25
 
26
26
  model({ public_id }) {
27
- return this.store.queryRecord('fleet', { public_id, single: true, with: ['parent_fleet', 'service_area', 'zone', 'subfleets', 'drivers', 'vehicles'] });
27
+ return this.store.queryRecord('fleet', { public_id, single: true, with: ['parentFleet', 'serviceArea', 'zone', 'subFleets', 'drivers', 'vehicles'] });
28
28
  }
29
29
  }
@@ -24,6 +24,6 @@ export default class ManagementFleetsIndexEditRoute extends Route {
24
24
  }
25
25
 
26
26
  model({ public_id }) {
27
- return this.store.queryRecord('fleet', { public_id, single: true, with: ['parent_fleet', 'service_area', 'zone'] });
27
+ return this.store.queryRecord('fleet', { public_id, single: true, with: ['parentFleet', 'serviceArea', 'zone'] });
28
28
  }
29
29
  }
@@ -24,6 +24,8 @@ export default class ManagementFleetsIndexRoute extends Route {
24
24
  };
25
25
 
26
26
  model(params) {
27
- return this.store.query('fleet', { ...params, with: ['parent_fleet', 'service_area', 'zone'] });
27
+ // Internal Fleet-Ops requests go directly to Eloquent's relation loader,
28
+ // so these must be the model method names rather than public API aliases.
29
+ return this.store.query('fleet', { ...params, with: ['parentFleet', 'serviceArea', 'zone'] });
28
30
  }
29
31
  }
@@ -18,7 +18,10 @@ export default class MapSettingsService extends Service {
18
18
  @service fetch;
19
19
  @tracked settings = { ...DEFAULT_SETTINGS };
20
20
  @tracked isLoaded = false;
21
- @tracked loadPromise = null;
21
+ // Internal request deduplication state is not rendered. Keeping this tracked
22
+ // makes load() read and then dirty the same tag when called during component
23
+ // construction, which Ember correctly rejects as a render-time mutation.
24
+ loadPromise = null;
22
25
 
23
26
  get mapProvider() {
24
27
  return this.settings.mapProvider ?? 'leaflet';
package/composer.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fleetbase/fleetops-api",
3
- "version": "0.6.61",
3
+ "version": "0.6.63",
4
4
  "description": "Fleet & Transport Management Extension for Fleetbase",
5
5
  "keywords": [
6
6
  "fleetbase-extension",
package/extension.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "Fleet-Ops",
3
- "version": "0.6.61",
3
+ "version": "0.6.63",
4
4
  "description": "Fleet & Transport Management Extension for Fleetbase",
5
5
  "repository": "https://github.com/fleetbase/fleetops",
6
6
  "license": "AGPL-3.0-or-later",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fleetbase/fleetops-engine",
3
- "version": "0.6.61",
3
+ "version": "0.6.63",
4
4
  "description": "Fleet & Transport Management Extension for Fleetbase",
5
5
  "fleetbase": {
6
6
  "route": "fleet-ops"
@@ -0,0 +1,129 @@
1
+ <?php
2
+
3
+ use Illuminate\Database\Migrations\Migration;
4
+ use Illuminate\Database\Schema\Blueprint;
5
+ use Illuminate\Support\Facades\DB;
6
+ use Illuminate\Support\Facades\Schema;
7
+
8
+ /**
9
+ * One logical membership per (fleet, vehicle) and (fleet, driver) pair, enforced
10
+ * by the database rather than by a read-then-write in the controller.
11
+ *
12
+ * The public membership endpoints are idempotent by checking for an existing
13
+ * pivot and creating one only if there is none. That holds for sequential calls
14
+ * and fails for concurrent ones: two requests can both find nothing and both
15
+ * insert, leaving a fleet with the same vehicle twice. An importer that retries
16
+ * a timed-out request is exactly the caller that produces this.
17
+ *
18
+ * The index deliberately covers soft-deleted rows too. Unlike the SKU and
19
+ * provider-transaction keys — where a tombstone must free the key, and does so
20
+ * through a generated column that is NULL when deleted — a removed membership
21
+ * here must keep occupying its key, because re-assigning restores that row
22
+ * rather than inserting a second one. Letting a tombstone free the key would
23
+ * reintroduce the duplicate it is meant to prevent.
24
+ *
25
+ * Rows with a NULL fleet_uuid or subject_uuid are left alone: MySQL permits any
26
+ * number of NULLs in a unique index, and such a row is orphaned data rather than
27
+ * a membership.
28
+ */
29
+ return new class extends Migration {
30
+ /**
31
+ * The pivots, and the column naming the member on each.
32
+ *
33
+ * @var array<string, array{0: string, 1: string}>
34
+ */
35
+ private array $pivots = [
36
+ 'fleet_vehicles' => ['vehicle_uuid', 'fleet_vehicles_fleet_vehicle_unique'],
37
+ 'fleet_drivers' => ['driver_uuid', 'fleet_drivers_fleet_driver_unique'],
38
+ ];
39
+
40
+ /**
41
+ * Run the migrations.
42
+ *
43
+ * @return void
44
+ */
45
+ public function up()
46
+ {
47
+ foreach ($this->pivots as $table => [$memberColumn, $indexName]) {
48
+ if (!Schema::hasTable($table)) {
49
+ continue;
50
+ }
51
+
52
+ $this->removeDuplicateMemberships($table, $memberColumn);
53
+
54
+ if (!$this->indexExists($table, $indexName)) {
55
+ Schema::table($table, function (Blueprint $blueprint) use ($memberColumn, $indexName) {
56
+ $blueprint->unique(['fleet_uuid', $memberColumn], $indexName);
57
+ });
58
+ }
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Reverse the migrations.
64
+ *
65
+ * @return void
66
+ */
67
+ public function down()
68
+ {
69
+ foreach ($this->pivots as $table => [$memberColumn, $indexName]) {
70
+ if (Schema::hasTable($table) && $this->indexExists($table, $indexName)) {
71
+ Schema::table($table, function (Blueprint $blueprint) use ($indexName) {
72
+ $blueprint->dropUnique($indexName);
73
+ });
74
+ }
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Collapse every duplicated pair down to the one row worth keeping.
80
+ *
81
+ * An active row wins over a tombstone, because that is the membership the
82
+ * fleet actually has today. Among equals the lowest id wins, so the outcome
83
+ * is deterministic and a re-run is a no-op. When every row for a pair is
84
+ * soft-deleted, one is still kept — removing them all would turn a later
85
+ * re-assignment into a new row and lose the original membership's history.
86
+ *
87
+ * Only redundant pivot rows are removed. No fleet, vehicle or driver is
88
+ * touched, and no surviving membership changes state.
89
+ */
90
+ private function removeDuplicateMemberships(string $table, string $memberColumn): void
91
+ {
92
+ $duplicatePairs = DB::table($table)
93
+ ->select('fleet_uuid', $memberColumn)
94
+ ->whereNotNull('fleet_uuid')
95
+ ->whereNotNull($memberColumn)
96
+ ->groupBy('fleet_uuid', $memberColumn)
97
+ ->havingRaw('COUNT(*) > 1')
98
+ ->get();
99
+
100
+ foreach ($duplicatePairs as $pair) {
101
+ $pair = (array) $pair;
102
+
103
+ $rows = DB::table($table)
104
+ ->where('fleet_uuid', $pair['fleet_uuid'])
105
+ ->where($memberColumn, $pair[$memberColumn])
106
+ // Active rows first, then oldest first.
107
+ ->orderByRaw('CASE WHEN deleted_at IS NULL THEN 0 ELSE 1 END')
108
+ ->orderBy('id')
109
+ ->pluck('id');
110
+
111
+ $redundant = $rows->slice(1)->values();
112
+
113
+ if ($redundant->isNotEmpty()) {
114
+ DB::table($table)->whereIn('id', $redundant->all())->delete();
115
+ }
116
+ }
117
+ }
118
+
119
+ private function indexExists(string $table, string $index): bool
120
+ {
121
+ $database = DB::connection()->getDatabaseName();
122
+
123
+ return DB::table('information_schema.statistics')
124
+ ->where('table_schema', $database)
125
+ ->where('table_name', $table)
126
+ ->where('index_name', $index)
127
+ ->exists();
128
+ }
129
+ };
@@ -0,0 +1,47 @@
1
+ <?php
2
+
3
+ namespace Fleetbase\FleetOps\Exceptions;
4
+
5
+ /**
6
+ * Thrown when a public relationship input — `vendor`, `parent_fleet`, `zone`,
7
+ * and friends — names a resource that does not exist inside the authenticated
8
+ * company.
9
+ *
10
+ * A cross-company identifier is deliberately indistinguishable from a missing
11
+ * one: both raise this, so the response cannot be used to probe whether some
12
+ * other organization holds a given public id.
13
+ */
14
+ class PublicRelationNotFoundException extends \Exception
15
+ {
16
+ /**
17
+ * The request key that failed to resolve, e.g. `parent_fleet`.
18
+ */
19
+ private string $relation;
20
+
21
+ /**
22
+ * The public identifier that was supplied for that key.
23
+ */
24
+ private ?string $identifier;
25
+
26
+ public function __construct(string $relation, ?string $identifier = null, ?\Throwable $previous = null)
27
+ {
28
+ $this->relation = $relation;
29
+ $this->identifier = $identifier;
30
+
31
+ parent::__construct(
32
+ sprintf('No %s resource found for the identifier provided.', str_replace('_', ' ', $relation)),
33
+ 0,
34
+ $previous
35
+ );
36
+ }
37
+
38
+ public function getRelation(): string
39
+ {
40
+ return $this->relation;
41
+ }
42
+
43
+ public function getIdentifier(): ?string
44
+ {
45
+ return $this->identifier;
46
+ }
47
+ }
@@ -2,6 +2,7 @@
2
2
 
3
3
  namespace Fleetbase\FleetOps\Http\Controllers\Api\v1\Concerns;
4
4
 
5
+ use Fleetbase\FleetOps\Exceptions\PublicRelationNotFoundException;
5
6
  use Fleetbase\FleetOps\Models\Contact;
6
7
  use Fleetbase\FleetOps\Models\Device;
7
8
  use Fleetbase\FleetOps\Models\Driver;
@@ -23,16 +24,20 @@ use Illuminate\Validation\ValidationException;
23
24
 
24
25
  trait ResolvesFleetOpsApiResources
25
26
  {
26
- protected function resolveUuid(string $modelClass, ?string $id): ?string
27
+ protected function resolveUuid(string $modelClass, ?string $id, ?string $companyUuid = null): ?string
27
28
  {
28
29
  if (empty($id)) {
29
30
  return null;
30
31
  }
31
32
 
32
- return $this->resolveModel($modelClass, $id)->uuid;
33
+ return $this->resolveModel($modelClass, $id, $companyUuid)->uuid;
33
34
  }
34
35
 
35
- protected function resolveModel(string $modelClass, string $id): Model
36
+ /**
37
+ * @param string|null $companyUuid the company to scope the lookup to; defaults
38
+ * to the session company
39
+ */
40
+ protected function resolveModel(string $modelClass, string $id, ?string $companyUuid = null): Model
36
41
  {
37
42
  $instance = new $modelClass();
38
43
  $query = $modelClass::query()->where(function ($query) use ($id, $instance) {
@@ -47,8 +52,10 @@ trait ResolvesFleetOpsApiResources
47
52
  }
48
53
  });
49
54
 
50
- if (session('company') && $this->modelHasColumn($instance, 'company_uuid')) {
51
- $query->where($instance->qualifyColumn('company_uuid'), session('company'));
55
+ $companyUuid = $companyUuid ?? session('company');
56
+
57
+ if ($companyUuid && $this->modelHasColumn($instance, 'company_uuid')) {
58
+ $query->where($instance->qualifyColumn('company_uuid'), $companyUuid);
52
59
  }
53
60
 
54
61
  $model = $query->first();
@@ -114,17 +121,45 @@ trait ResolvesFleetOpsApiResources
114
121
  return preg_match('/(^uuid$|_uuid$|Uuid$|UUID$)/', $key) === 1;
115
122
  }
116
123
 
117
- protected function applyPublicIdRelation(array &$input, string $requestKey, string $column, string $modelClass, $request): void
124
+ protected function applyPublicIdRelation(array &$input, string $requestKey, string $column, string $modelClass, $request, ?string $companyUuid = null): void
118
125
  {
119
126
  if (!$request->exists($requestKey)) {
120
127
  return;
121
128
  }
122
129
 
123
130
  $input[$column] = filled($request->input($requestKey))
124
- ? $this->resolveUuid($modelClass, $request->input($requestKey))
131
+ ? $this->resolveUuid($modelClass, $request->input($requestKey), $companyUuid)
125
132
  : null;
126
133
  }
127
134
 
135
+ /**
136
+ * Apply a set of public-ID relationship inputs in one pass.
137
+ *
138
+ * `$map` is keyed by the public request key and holds `[column, modelClass]`,
139
+ * e.g. `['parent_fleet' => ['parent_fleet_uuid', Fleet::class]]`. A key that is
140
+ * absent from the request is left untouched; a key sent empty clears the column.
141
+ *
142
+ * Resolution failures are rethrown as a PublicRelationNotFoundException so the
143
+ * caller can say which input was at fault rather than answering with a bare
144
+ * "not found" that names no field.
145
+ *
146
+ * @param array<string, array{0: string, 1: class-string}> $map
147
+ *
148
+ * @throws PublicRelationNotFoundException
149
+ */
150
+ protected function applyPublicIdRelations(array &$input, array $map, $request, ?string $companyUuid = null): void
151
+ {
152
+ foreach ($map as $requestKey => [$column, $modelClass]) {
153
+ try {
154
+ $this->applyPublicIdRelation($input, $requestKey, $column, $modelClass, $request, $companyUuid);
155
+ } catch (ModelNotFoundException $exception) {
156
+ $identifier = $request->input($requestKey);
157
+
158
+ throw new PublicRelationNotFoundException($requestKey, is_scalar($identifier) ? (string) $identifier : null, $exception);
159
+ }
160
+ }
161
+ }
162
+
128
163
  protected function allowedMorphTypes(): array
129
164
  {
130
165
  return [
@@ -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
+ }