From 94c33fac73004a8a4a95c98b2cb4e9baf3e2b5f3 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sun, 6 Sep 2026 13:57:28 +0800 Subject: [PATCH] fix: restore fleet relations and driver vendors --- RELEASE.md | 34 ++--------- addon/controllers/management/drivers/index.js | 5 +- addon/routes/management/fleets/index.js | 4 +- .../routes/management/fleets/index/details.js | 2 +- addon/routes/management/fleets/index/edit.js | 2 +- composer.json | 2 +- extension.json | 2 +- package.json | 2 +- .../management/identity-columns-test.js | 2 + .../fleets/relation-expansions-test.js | 56 +++++++++++++++++++ 10 files changed, 75 insertions(+), 36 deletions(-) create mode 100644 tests/unit/routes/management/fleets/relation-expansions-test.js diff --git a/RELEASE.md b/RELEASE.md index ea0acfc74..21cf5fda7 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,38 +1,14 @@ -> v0.6.62 ~ "The public Fleet, Vehicle and Driver APIs now expose what the records actually hold" - ---- -## Highlights -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. - ---- -## Features -- **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. -- **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. -- **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. -- **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. -- **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. +> v0.6.63 ~ "Fleet and driver relationship display fixes" --- ## Bug Fixes -- **`Driver::$fillable` listed `'meta,'`** — a trailing comma inside the string — so driver metadata was never mass assignable and never persisted through the public API. -- **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. -- **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`. -- **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. -- **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. -- **`VehicleFilter` had no `internal_id` filter,** which is the lookup an importer keys on to decide whether a vehicle already exists. -- **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. -- **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. -- **The Fleet webhook payload gated `parent_fleet` on `serviceArea`,** so a subfleet with no service area never reported its parent. +- **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. +- **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. --- ## Testing -- Coverage held at 100% on all three metrics — 34670/34670 statements, 4428/4428 methods, 530/530 classes. -- 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. -- 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. - ---- -## Continuous Integration -- 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. +- Added a regression assertion for the vendor column used by the Drivers table. +- Added route coverage for the relationship names requested by the Fleet index, edit and details pages. --- ## Need help? diff --git a/addon/controllers/management/drivers/index.js b/addon/controllers/management/drivers/index.js index d84f525ea..8dd3dd97b 100644 --- a/addon/controllers/management/drivers/index.js +++ b/addon/controllers/management/drivers/index.js @@ -241,7 +241,10 @@ export default class ManagementDriversIndexController extends Controller { this.notifications.serverError(err); } }, - valuePath: 'vendor.name', + // Driver list responses already include the vendor_name accessor, + // while the vendor relationship itself is not eager loaded. Use + // the scalar so assigned vendors do not render as blank cells. + valuePath: 'vendor_name', modelNamePath: 'name', resizable: true, filterable: true, diff --git a/addon/routes/management/fleets/index.js b/addon/routes/management/fleets/index.js index 2cdc0eb9c..8f55c123b 100644 --- a/addon/routes/management/fleets/index.js +++ b/addon/routes/management/fleets/index.js @@ -24,6 +24,8 @@ export default class ManagementFleetsIndexRoute extends Route { }; model(params) { - return this.store.query('fleet', { ...params, with: ['parent_fleet', 'service_area', 'zone'] }); + // Internal Fleet-Ops requests go directly to Eloquent's relation loader, + // so these must be the model method names rather than public API aliases. + return this.store.query('fleet', { ...params, with: ['parentFleet', 'serviceArea', 'zone'] }); } } diff --git a/addon/routes/management/fleets/index/details.js b/addon/routes/management/fleets/index/details.js index 39c83547f..ae4681686 100644 --- a/addon/routes/management/fleets/index/details.js +++ b/addon/routes/management/fleets/index/details.js @@ -24,6 +24,6 @@ export default class ManagementFleetsIndexDetailsRoute extends Route { } model({ public_id }) { - return this.store.queryRecord('fleet', { public_id, single: true, with: ['parent_fleet', 'service_area', 'zone', 'subfleets', 'drivers', 'vehicles'] }); + return this.store.queryRecord('fleet', { public_id, single: true, with: ['parentFleet', 'serviceArea', 'zone', 'subFleets', 'drivers', 'vehicles'] }); } } diff --git a/addon/routes/management/fleets/index/edit.js b/addon/routes/management/fleets/index/edit.js index 95d78361c..1ea34fff2 100644 --- a/addon/routes/management/fleets/index/edit.js +++ b/addon/routes/management/fleets/index/edit.js @@ -24,6 +24,6 @@ export default class ManagementFleetsIndexEditRoute extends Route { } model({ public_id }) { - return this.store.queryRecord('fleet', { public_id, single: true, with: ['parent_fleet', 'service_area', 'zone'] }); + return this.store.queryRecord('fleet', { public_id, single: true, with: ['parentFleet', 'serviceArea', 'zone'] }); } } diff --git a/composer.json b/composer.json index 6cceca1b9..ef806c4a0 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "fleetbase/fleetops-api", - "version": "0.6.62", + "version": "0.6.63", "description": "Fleet & Transport Management Extension for Fleetbase", "keywords": [ "fleetbase-extension", diff --git a/extension.json b/extension.json index f2834e929..6acca462b 100644 --- a/extension.json +++ b/extension.json @@ -1,6 +1,6 @@ { "name": "Fleet-Ops", - "version": "0.6.62", + "version": "0.6.63", "description": "Fleet & Transport Management Extension for Fleetbase", "repository": "https://github.com/fleetbase/fleetops", "license": "AGPL-3.0-or-later", diff --git a/package.json b/package.json index 09d2a5345..e0b882919 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fleetbase/fleetops-engine", - "version": "0.6.62", + "version": "0.6.63", "description": "Fleet & Transport Management Extension for Fleetbase", "fleetbase": { "route": "fleet-ops" diff --git a/tests/unit/controllers/management/identity-columns-test.js b/tests/unit/controllers/management/identity-columns-test.js index 1d3556d98..674660bdf 100644 --- a/tests/unit/controllers/management/identity-columns-test.js +++ b/tests/unit/controllers/management/identity-columns-test.js @@ -100,6 +100,7 @@ module('Unit | Controller | management identity columns', function (hooks) { const controller = this.owner.lookup('controller:management/drivers/index'); const labels = controller.columns.slice(0, 5).map((column) => column.label); const vehicleColumn = controller.columns.find((column) => column.label === 'column.vehicle'); + const vendorColumn = controller.columns.find((column) => column.label === 'column.vendor'); const vehicle = { id: 'vehicle_1', displayName: 'Truck 1' }; assert.deepEqual(labels, ['column.name', 'column.id', 'column.phone', 'column.license', 'column.vehicle']); @@ -108,6 +109,7 @@ module('Unit | Controller | management identity columns', function (hooks) { assert.strictEqual(vehicleColumn.cellComponent, 'cell/vehicle-identity'); assert.true(vehicleColumn.compact); assert.strictEqual(vehicleColumn.showStatusBadge, true); + assert.strictEqual(vendorColumn.valuePath, 'vendor_name', 'vendor renders from the accessor returned by the driver list endpoint'); await vehicleColumn.action({ loadResource: () => vehicle }); diff --git a/tests/unit/routes/management/fleets/relation-expansions-test.js b/tests/unit/routes/management/fleets/relation-expansions-test.js new file mode 100644 index 000000000..8a1f6abff --- /dev/null +++ b/tests/unit/routes/management/fleets/relation-expansions-test.js @@ -0,0 +1,56 @@ +import Service from '@ember/service'; +import FleetsIndexRoute from '@fleetbase/fleetops-engine/routes/management/fleets/index'; +import FleetEditRoute from '@fleetbase/fleetops-engine/routes/management/fleets/index/edit'; +import FleetDetailsRoute from '@fleetbase/fleetops-engine/routes/management/fleets/index/details'; +import { module, test } from 'qunit'; +import { setupTest } from 'dummy/tests/helpers'; + +class StoreStub extends Service { + calls = []; + + query(modelName, params) { + this.calls.push({ method: 'query', modelName, params }); + return []; + } + + queryRecord(modelName, params) { + this.calls.push({ method: 'queryRecord', modelName, params }); + return {}; + } +} + +class EmptyServiceStub extends Service {} + +module('Unit | Route | management/fleets relation expansions', function (hooks) { + setupTest(hooks); + + hooks.beforeEach(function () { + this.owner.register('service:store', StoreStub); + this.owner.register('service:abilities', EmptyServiceStub); + this.owner.register('service:hostRouter', EmptyServiceStub); + this.owner.register('service:intl', EmptyServiceStub); + this.owner.register('service:notifications', EmptyServiceStub); + this.owner.register('route:test-fleets-index', FleetsIndexRoute); + this.owner.register('route:test-fleet-edit', FleetEditRoute); + this.owner.register('route:test-fleet-details', FleetDetailsRoute); + }); + + test('fleet routes request real Eloquent relationship names', function (assert) { + const indexRoute = this.owner.lookup('route:test-fleets-index'); + const editRoute = this.owner.lookup('route:test-fleet-edit'); + const detailsRoute = this.owner.lookup('route:test-fleet-details'); + const store = this.owner.lookup('service:store'); + + indexRoute.model({ page: 1 }); + editRoute.model({ public_id: 'fleet_parent' }); + detailsRoute.model({ public_id: 'fleet_parent' }); + + assert.deepEqual(store.calls[0].params.with, ['parentFleet', 'serviceArea', 'zone'], 'index route uses Eloquent relation names'); + assert.deepEqual(store.calls[1].params.with, ['parentFleet', 'serviceArea', 'zone'], 'edit route uses Eloquent relation names'); + assert.deepEqual( + store.calls[2].params.with, + ['parentFleet', 'serviceArea', 'zone', 'subFleets', 'drivers', 'vehicles'], + 'details route uses Eloquent relation names including subFleets' + ); + }); +});