@fleetbase/fleetops-engine 0.6.62 → 0.6.64

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 CHANGED
@@ -1,38 +1,13 @@
1
- > v0.6.62 ~ "The public Fleet, Vehicle and Driver APIs now expose what the records actually hold"
2
-
3
- ---
4
- ## Highlights
5
- The public v1 API exposed a small subset of what the Fleet, Vehicle and Driver records can hold, and the gaps were silent: a caller sending a field the controller did not copy received a `200` and a response body that looked correct while the value was discarded. This release closes that gap, adds fleet hierarchies and fleet membership to the public API, and makes it possible to record a driver who has no email address or phone number.
6
-
7
- ---
8
- ## Features
9
- - **Fleet create and update accept every safe field.** `name`, `color`, `task`, `status`, and the `service_area`, `zone`, `vendor` and `parent_fleet` relationships. Only `name` and `service_area` were reachable before, so a fleet hierarchy could not be built through the API at all. `"parent_fleet": null` clears a parent; a fleet may not be its own parent, nor sit beneath one of its own descendants.
10
- - **Fleet membership has public endpoints.** `POST` and `DELETE` on `/v1/fleets/{fleet}/vehicles/{vehicle}` and `/v1/fleets/{fleet}/drivers/{driver}`, all four taking public IDs and sharing one response shape. Assignment is idempotent and restores a soft-deleted membership rather than duplicating it; removal is a safe no-op and touches only the pivot, never the driver, the vehicle, the driver's current vehicle, or any other fleet.
11
- - **The Vehicle contract covers the whole record.** The input projection accepted 21 of the model's 99 fields; it now accepts all 90 safe ones — identity, odometer and measurement, body, capacity and dimensions, lifecycle and financing, regulatory and engine specifications, structured `specs`/`details`/`meta`, and orchestrator constraints — each with type-appropriate validation. `vendor`, `category`, `warranty` and `photo` resolve from public IDs.
12
- - **A driver can be recorded without credentials.** `email` and `phone` are optional on create; an operational record may legitimately have neither. Nothing is invented to fill the gap and no invitation is sent when there is nowhere to send one. Such a driver cannot sign in to Navigator until credentials are supplied.
13
- - **Relationships are readable back as public IDs.** A caller that writes `parent_fleet: "fleet_abc"` can now read the assignment back. Asking for a relation through `?with=` still returns the nested object it always did, and internal console responses are unchanged.
1
+ > v0.6.64 ~ "Order responses no longer fail when a vehicle is assigned"
14
2
 
15
3
  ---
16
4
  ## Bug Fixes
17
- - **`Driver::$fillable` listed `'meta,'`** — a trailing comma inside the string — so driver metadata was never mass assignable and never persisted through the public API.
18
- - **Driver input was a blocklist, not an allowlist.** Anything nobody had thought to exclude reached `Driver::create()` intact, including `auth_token`, `user_uuid` and `company_uuid`, while `location`, `heading`, `altitude`, `speed` and `meta` were dropped on every write.
19
- - **Driver photo upload wrote `photo_uuid` to `users`,** which has no such column. `User` guards mass assignment by fillable, so every photo uploaded through the public API was discarded without a word. It now writes `avatar_uuid`.
20
- - **A partial vehicle update took the vehicle offline.** The create-time `online` default was applied on update too, so a plate correction or an odometer reading silently reset it.
21
- - **Relationship filters could never match.** `?vendor=`, `?fleet=`, `?driver=` and the fleet hierarchy filters compared caller-supplied public IDs against uuid columns. `FleetFilter::query()` searched a `user` relation Fleet does not have, `DriverFilter::phone()` a `phone` relation that does not exist, and `FleetFilter::zone()` a `zone_uuid` column `zones` does not have — the last three raised rather than filtered.
22
- - **`VehicleFilter` had no `internal_id` filter,** which is the lookup an importer keys on to decide whether a vehicle already exists.
23
- - **Cross-company relationship IDs were accepted and silently dropped.** Relationship inputs were validated with unscoped `exists` rules, so another organization's public ID passed validation and then resolved to nothing. They are now scoped at validation and again at resolution, and a cross-company ID is answered exactly as a missing one — so a response cannot be used to probe another organization's data.
24
- - **The Fleet resource ran four `count()` queries per fleet on every public request** and discarded the results, because `when()` evaluates a plain value argument eagerly.
25
- - **The Fleet webhook payload gated `parent_fleet` on `serviceArea`,** so a subfleet with no service area never reported its parent.
5
+ - **Orders with an assigned vehicle serialize again when expansions are requested.** The Vehicle and Fleet resources read the request's `with` list and loaded it onto the model they wrap. That is correct on their own endpoints, where the controller has already allowlisted the list, but both resources are also rendered nested inside Order and Driver responses. There the same request carries the parent's expansions, so `GET /v1/orders?with=payload` on an order with a vehicle assigned failed during serialisation with `RelationNotFoundException: Call to undefined relationship [payload] on model Vehicle`. Navigator order fetches and the console scheduler both hit this. Nested resources now keep only the relations the wrapped model actually defines; direct endpoint behaviour is unchanged.
26
6
 
27
7
  ---
28
8
  ## Testing
29
- - Coverage held at 100% on all three metrics — 34670/34670 statements, 4428/4428 methods, 530/530 classes.
30
- - New database-backed coverage of the fleet membership pivots: idempotent assignment, restore of a soft-deleted membership, repeated removal as a no-op, and preservation of the driver's vehicle and of unrelated fleet memberships.
31
- - Data-driven parity tests prove every newly exposed Fleet, Vehicle and Driver field is accepted and persisted, and that tenancy, authentication and generated columns still are not.
32
-
33
- ---
34
- ## Continuous Integration
35
- - The PHP, Ember and Postman contract workflows now run on pull requests targeting `release/v*`. `release.yml` learned that branch name in v0.6.61's follow-up, but the three check workflows did not — so a PR retargeted onto a release branch ran no checks at all.
9
+ - Added resource-level regressions for parent expansions reaching a nested Vehicle or Fleet resource, for string and empty `with` forms, and for resources wrapping plain data.
10
+ - Added end-to-end checks resolving a real vehicle and a real fleet against an order-shaped request.
36
11
 
37
12
  ---
38
13
  ## 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
  }
package/composer.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fleetbase/fleetops-api",
3
- "version": "0.6.62",
3
+ "version": "0.6.64",
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.62",
3
+ "version": "0.6.64",
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.62",
3
+ "version": "0.6.64",
4
4
  "description": "Fleet & Transport Management Extension for Fleetbase",
5
5
  "fleetbase": {
6
6
  "route": "fleet-ops"
@@ -22,8 +22,13 @@ trait ResolvesPublicRelationFields
22
22
  /**
23
23
  * The relations the caller asked for, as real Eloquent relation names.
24
24
  *
25
- * The controller has already normalised, mapped and allowlisted `with` /
26
- * `expand` by the time a resource runs, so this is a plain read.
25
+ * The controller normalises, maps and allowlists `with` / `expand` for the
26
+ * resource it returns. That guarantee does not travel: this resource is also
27
+ * rendered nested inside Order and Driver responses, where the request's
28
+ * `with` names the parent's relations (`payload`, `driverAssigned.vehicle`).
29
+ * A nested resource re-reads the same request, so only relations the wrapped
30
+ * model actually defines are kept — a parent's expansion must never reach
31
+ * this model's loader, where it raises RelationNotFoundException.
27
32
  *
28
33
  * @return array<int, string>
29
34
  */
@@ -39,7 +44,28 @@ trait ResolvesPublicRelationFields
39
44
  return [];
40
45
  }
41
46
 
42
- return array_values(array_filter(array_map('strval', $with), 'strlen'));
47
+ $with = array_values(array_filter(array_map('strval', $with), 'strlen'));
48
+
49
+ return array_values(array_filter($with, fn (string $relation): bool => $this->definesRelation($relation)));
50
+ }
51
+
52
+ /**
53
+ * Whether the wrapped model defines the root of a relation path.
54
+ *
55
+ * Only the first segment is checked: nested segments belong to the related
56
+ * model, and Eloquent validates those itself once the root exists.
57
+ */
58
+ private function definesRelation(string $relation): bool
59
+ {
60
+ $resource = $this->resource;
61
+
62
+ if (!is_object($resource) || !method_exists($resource, 'loadMissing')) {
63
+ return false;
64
+ }
65
+
66
+ $root = explode('.', $relation, 2)[0];
67
+
68
+ return $root !== '' && method_exists($resource, $root);
43
69
  }
44
70
 
45
71
  /**
@@ -24,7 +24,9 @@ class Fleet extends FleetbaseResource
24
24
  // dropped anything outside the public allowlist, so they are safe to
25
25
  // hand to the loader. `subfleets` used to arrive here spelled exactly
26
26
  // that and reach `load('subfleets')`, which is not the relation —
27
- // the documented expansion raised instead of resolving.
27
+ // the documented expansion raised instead of resolving. Relations the
28
+ // fleet does not define are dropped as well: nested under another
29
+ // response the request's `with` describes the parent, not the fleet.
28
30
  $with = $this->requestedRelations($request);
29
31
 
30
32
  if ($with !== []) {
@@ -452,3 +452,26 @@ test('an unsupported expansion on retrieve cannot reach eloquent', function () {
452
452
  ->and($payload)->not->toHaveKey('not_a_relation')
453
453
  ->and($request->input('with'))->toBe(['vendor']);
454
454
  });
455
+
456
+ test('a vehicle nested in an order response ignores the order expansions', function () {
457
+ fleetopsVehicleContractBoot();
458
+
459
+ $controller = new VehicleController();
460
+ $createRequest = fleetopsVehicleContractRequest(CreateVehicleRequest::class, 'POST', ['make' => 'Ford', 'driver' => 'driver_contract1']);
461
+ $created = $controller->create($createRequest)->resolve($createRequest);
462
+ $vehicle = Vehicle::where('public_id', $created['id'])->firstOrFail();
463
+
464
+ // An order fetch expands the order's own relations. The Order resource nests
465
+ // the assigned vehicle as a full Vehicle resource, which re-reads the same
466
+ // request: `payload` is not a vehicle relation and used to reach load(),
467
+ // answering the whole order request with a RelationNotFoundException.
468
+ $orderRequest = fleetopsVehicleContractRequest(Request::class, 'GET', ['with' => ['payload', 'driverAssigned.vehicle', 'driver']], 'v1/orders');
469
+ $payload = (new Fleetbase\FleetOps\Http\Resources\v1\Vehicle($vehicle))->resolve($orderRequest);
470
+
471
+ expect($payload['id'])->toBe($created['id'])
472
+ ->and($payload)->not->toHaveKey('payload')
473
+ ->and($vehicle->relationLoaded('payload'))->toBeFalse()
474
+ // The vehicle's own expansion in the same list still resolves.
475
+ ->and($payload['driver'])->toBeObject()
476
+ ->and($payload['driver_id'])->toBe('driver_contract1');
477
+ });
@@ -144,3 +144,20 @@ test('fleet resource accepts the explicit nested expansion spelling', function (
144
144
  expect($subFleet->relationLoaded('drivers'))->toBeTrue()
145
145
  ->and($subFleet->relationLoaded('vehicles'))->toBeFalse();
146
146
  });
147
+
148
+ test('fleet resource ignores expansions that describe a parent resource', function () {
149
+ fleetopsFleetResourceBoot();
150
+
151
+ $fleet = Fleet::where('uuid', 'fleet-parent-1')->first();
152
+
153
+ // Rendered inside another response, the request's `with` is the parent's
154
+ // list. `payload` is not a fleet relation and used to raise from load();
155
+ // the fleet's own relation in the same list still loads.
156
+ $request = Request::create('/v1/orders', 'GET', ['with' => ['payload', 'drivers']]);
157
+ $resolved = (new FleetResource($fleet))->resolve($request);
158
+
159
+ expect($resolved['name'])->toBe('Parent Fleet')
160
+ ->and($resolved)->not->toHaveKey('payload')
161
+ ->and($fleet->relationLoaded('payload'))->toBeFalse()
162
+ ->and($fleet->relationLoaded('drivers'))->toBeTrue();
163
+ });
@@ -311,3 +311,42 @@ test('vehicle without driver resource omits driver relationships and serializes
311
311
  ])
312
312
  ->and($webhook)->not->toHaveKey('driver');
313
313
  });
314
+
315
+ /**
316
+ * Resolve the request's `with` list the way a nested resource does.
317
+ *
318
+ * @return array<int, string>
319
+ */
320
+ function fleetopsVehicleResourceRequestedRelations(mixed $resource, array $query): array
321
+ {
322
+ $request = Request::create('/api/v1/fleet-ops/orders', 'GET', $query);
323
+ $method = new ReflectionMethod(VehicleResource::class, 'requestedRelations');
324
+ $method->setAccessible(true);
325
+
326
+ return $method->invoke(new VehicleResource($resource), $request);
327
+ }
328
+
329
+ test('requested relations keep only what the wrapped model defines', function () {
330
+ $vehicle = new class(['uuid' => 'vehicle-uuid']) extends FleetOpsVehicleResourceFixture {
331
+ public function driver(): void
332
+ {
333
+ }
334
+ };
335
+
336
+ // Nested under an Order, the request's `with` describes the order:
337
+ // `payload` and `driverAssigned.vehicle` are not vehicle relations and must
338
+ // never reach this model's loader. The vehicle's own relations still pass,
339
+ // including a nested path whose root the vehicle defines.
340
+ expect(fleetopsVehicleResourceRequestedRelations($vehicle, ['with' => ['payload', 'driverAssigned.vehicle', 'driver', 'driver.user', '']]))->toBe(['driver', 'driver.user'])
341
+ ->and(fleetopsVehicleResourceRequestedRelations($vehicle, ['with' => 'payload,driver']))->toBe(['driver'])
342
+ ->and(fleetopsVehicleResourceRequestedRelations($vehicle, ['with' => ['.driver']]))->toBe([])
343
+ ->and(fleetopsVehicleResourceRequestedRelations($vehicle, []))->toBe([]);
344
+ });
345
+
346
+ test('requested relations are empty when the resource cannot load any', function () {
347
+ // Webhook fixtures and compact serializers hand the resource plain data;
348
+ // there is no loader to feed, so nothing is requested from it.
349
+ expect(fleetopsVehicleResourceRequestedRelations(['uuid' => 'vehicle-uuid'], ['with' => ['driver']]))->toBe([])
350
+ ->and(fleetopsVehicleResourceRequestedRelations((object) ['uuid' => 'vehicle-uuid'], ['with' => ['driver']]))->toBe([])
351
+ ->and(fleetopsVehicleResourceRequestedRelations(fleetopsVehicleResourceFixture(), ['with' => ['driver']]))->toBe([]);
352
+ });