release: v0.6.62 - #312
Merged
Merged
Conversation
The public v1 API exposed a small subset of what these records can hold, and the
gaps were silent rather than loud: 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.
Fleets
- Create and update accept name, color, task, status, and the service_area,
zone, vendor and parent_fleet relationships as public ids. 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; both answer 422.
- Four public membership endpoints, all taking public ids and sharing one
response shape:
POST|DELETE /v1/fleets/{fleet}/vehicles/{vehicle}
POST|DELETE /v1/fleets/{fleet}/drivers/{driver}
Assignment is idempotent and restores a soft-deleted membership rather than
duplicating it; removal is a safe no-op and touches only the pivot.
Vehicles
- The input projection covered 21 of the model's 99 fields; it now covers all 90
safe ones, with type-appropriate validation for each.
- vendor, category, warranty and photo resolve from public ids.
- The create-time `online` default no longer applies to updates, where it
silently took a vehicle offline on any partial write.
Drivers
- Replaces an except() blocklist with an explicit allowlist. Anything nobody had
thought to exclude — auth_token, user_uuid, company_uuid — reached
Driver::create() intact, while location, heading, altitude, speed and meta
were dropped on every write.
- email and phone are optional. An operational record may 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.
- Driver::$fillable held 'meta,' — a trailing comma inside the string — so meta
was never mass assignable.
- Driver photo upload wrote photo_uuid to users, which has no such column, so
every photo uploaded through the public API was dropped.
Tenant isolation
- Relationship inputs are validated with company-scoped exists rules and
resolved again through a company-scoped lookup. A cross-company public id is
answered exactly as a missing one, so a response cannot be used to probe
another organization's data.
- Relationship filters resolved public ids against uuid columns and so could
never match. 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.
- Public responses report relationships as public ids; no *_uuid column appears
in a public payload. Internal console responses keep their existing shape.
Validation: php scripts/pest-file-runner.php — 434 files, exit 0.
composer test:lint reports 4 files, all pre-existing on origin/main and none
touched here. composer test:types fails on a pre-existing 13,739-error baseline;
the four new source files report zero.
The coverage gate caught 17 statements the new code added but no test entered. Every one is now reached by a test that asserts the behaviour, not by a call made only to move the number. - CreateFleetRequest::attributes() — asserted in RequestContractsTest, which already pins the rest of the fleet request contract. - PublicRelationNotFoundException::getRelation()/getIdentifier() — covered in ExceptionContractsTest alongside the other FleetOps exceptions, including the null-identifier case. - ResolvesPublicRelationUuids' blank-identifier early return — a filter given an empty value must resolve to nothing without reaching the database. - DriverFilter's console uuid branch — Http::isInternalRequest() reads the resolved route's uri rather than the request path, so the branch needs a request with an internal route resolver to be reachable at all. The test now builds one, which is also what proves the branch is internal-only. - FleetController: the update path's cross-company relationship rejection (the create path was already covered), removeVehicle's and removeDriver's not-found answers, and the real bodies of findVehicle, findDriver, withPublicRelations and queryFleets — the last four exercised against SQLite in FleetPublicContractTest, which asserts that the lookups are company-scoped and that the query pipeline eager loads the relations the public resource reports as public ids. Local baseline: 100.00% on all three metrics — 34670/34670 statements, 4428/4428 methods, 530/530 classes. The statement total matches the figure CI reported exactly, so the 17 closed here are precisely the ones it flagged. php scripts/pest-file-runner.php: 434 files, exit 0.
Bumps composer.json, package.json and extension.json to 0.6.62 with `flb version-bump --patch`, and writes RELEASE.md for the tag body. Also teaches the three check workflows the release branch name. v0.6.61's follow-up taught release.yml to tag a `release/v*` branch, but server.yml, ember.yml and postman.yml still only ran on pull requests targeting `main` and `dev-v*` — so a PR retargeted onto this branch would run no PHP, Ember or contract checks at all, and a release branch nothing validates is worse than no release branch. `push` stays limited to `main`, which is what keeps a release branch from running everything twice.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #312 +/- ##
============================================
Coverage 100.00% 100.00%
- Complexity 9899 10003 +104
============================================
Files 526 531 +5
Lines 38163 38585 +422
============================================
+ Hits 38163 38585 +422
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
…eness
Corrects the response design from the first revision of this branch and closes
the remaining gaps.
Relationships are two keys, never one key with two types
- The previous revision returned a public id *under* the object key — so
`vendor` was an object when expanded and a string otherwise. The SDK stores
what the API returns verbatim (`Resource::$attributes = $attributes`, no
normalisation), so a property that changes type between calls breaks every
consumer that dereferences it. Navigator interpolates `driver.user` straight
into a socket channel name; an object there subscribes it to
`user.[object Object]` and quietly stops delivering messages.
- Each relationship now has an always-present `<name>_id` public identifier and,
separately, the nested `<name>` object with exactly the shape the base branch
returned: absent unless loaded, an object when loaded, never a string.
Fleet gains service_area_id, zone_id, vendor_id, parent_fleet_id, photo_id;
Vehicle gains driver_id, vendor_id, category_id, warranty_id, photo_id.
No key was removed from any resource — the diff against the base is additive
on all three.
- Driver keeps `user` and `company` as the public-id strings they have always
been, keeps `company_name`, and keeps `vehicle`, `vendor` and `current_job` as
objects on create, update and retrieve, which still load them without a
`with`.
Expansion
- `?with=vendor`, `?with[]=vendor`, `?with=vendor,driver` and the `expand` alias
all normalise to one list. Names are mapped through an explicit per-resource
allowlist, so nothing user-controlled reaches `load()` — Core hands `with`
straight to Eloquent, where an unknown name is a 500 for what is only a typo.
Unsupported names are ignored.
- `?with=subfleets` finally resolves. The public name and the relation differ
only in case, and because PHP method calls are case-insensitive the old
`load('subfleets')` stored a second copy of the relation under a mis-cased
key that `whenLoaded('subFleets')` could not see. Nested
`subfleets.drivers` / `subfleets.vehicles` work explicitly, and the implicit
nesting the released contract had is preserved.
Membership uniqueness is now the database's job
- A migration adds composite unique indexes on fleet_vehicles(fleet_uuid,
vehicle_uuid) and fleet_drivers(fleet_uuid, driver_uuid), collapsing existing
duplicates first: an active row wins over a tombstone, the lowest id wins
among equals, and a pair that is entirely soft-deleted keeps one restorable
row. Only redundant pivot rows are removed.
- Soft-deleted rows stay inside the key, deliberately — a removed membership is
restored on re-assignment rather than replaced, so its key must stay taken.
- The assignment path catches only the duplicate-key violation, adopts the
winner's row and answers successfully. Any other violation is rethrown rather
than reported as a successful assignment.
internal_id and public_id
- Exact on the public API, partial in the console, chosen by the resolved route.
An importer asking whether VEH-10 exists must not be told yes because VEH-100
does; the console's search box must keep finding it. VIN and plate number are
untouched.
Driver write/read parity
- `timezone` is copied to the linked user on update. It was accepted,
documented, answered 200 — and dropped, because the update copied only name,
email and phone and the driver record has no timezone column.
- Email and phone uniqueness is now enforced on update against every other live
user, ignoring the driver's own by uuid, so an unchanged value still succeeds.
Also fixed: `assignedOrdersCount()` and `currentOrderReference()` ran a query on
every public Vehicle and Driver response and discarded the result, because
`when()` evaluates a plain value argument eagerly.
Validation: php scripts/pest-file-runner.php — 437 files, exit 0. Coverage
100.00% on all three metrics (34784/34784 statements, 4441/4441 methods,
531/531 classes). SDK and Navigator contract compatibility verified against the
untouched checkouts; neither was modified.
Validates this branch against fleetbase/postman@0fcdff3, the commit that documents and asserts these endpoints. The collection on postman main predates every endpoint added here, so a run against it proves only that nothing already released regressed. Reverted by the next commit — release-bound code must not stay pinned to an unmerged branch.
The cross-repository contract run answered 500 for
`GET /v1/vehicles/{id}?with[]=vendor&with[]=not_a_relation`:
"Call to undefined relationship [not_a_relation]".
Both retrieve actions took the request as an optional parameter — Vehicle as
`?Request $request = null`, Driver not at all — and Laravel's controller
dispatcher skips resolving a type-hinted dependency that carries a default. It
arrived null, the expansions were never mapped or allowlisted, and the raw name
reached Eloquent. Retrieve is the endpoint most likely to be handed a stale
relation name, and it was the only one where the allowlist did not run.
Both now read the container's request. Covered by a test that drives the real
find() with an unsupported name and asserts the response is intact.
Still reverted before merge.
…n commit" This reverts commit a249e7d.
This reverts commit d303483.
`ConvertEmptyStringsToNull` turns `"driver": ""` into null before the
controller sees it, and `has('driver')` is still true afterwards — `has()`
reports that the key is present, not that it holds anything. A null therefore
reaches these lookups whenever a client serialises an unselected field as an
empty string.
That was harmless for years: the lookups were plain queries, `where('public_id',
null)` matched nothing, and the caller's `if ($driver)` skipped the assignment.
2c7de8f extracted those queries into helpers and typed them `string` while
making only the return nullable, so "no driver" became a TypeError. Note that
`vehicle`, assigned three lines below `driver` via `Utils::getUuid()`, never
broke — the regression belongs to the extraction, not to the pattern.
Widen the eight seams fed directly from request input. Every body is unchanged
and each was already null-safe, so this adds no statements and no new
behaviour: a widened parameter is strictly more permissive, and the only calls
that behave differently are the ones that used to crash.
The `has()`/`empty()` guards on update paths are deliberately untouched.
Switching them to `filled()` would read as the tidier fix and would silently
break clearing — `VehicleController::update` unassigns a driver on an empty
value, and `PUT {"vendor": ""}` must keep nulling `vendor_id`.
Also mark `service_area` and `zone` nullable on service rate creation. Both are
optional, so an empty one has to read as absent rather than draw "the selected
service area is invalid".
The regression test derives its seam list by scanning for
`$this->seam($request->input(...))` rather than pinning today's eight, so a
future extraction that types a new seam `string` fails at the commit that
introduces it. Exemptions have to name the call-site guard that excludes null,
and the scan asserts it actually matched something so it cannot pass vacuously.
…ip-inputs fix: treat an empty relationship as absent instead of raising
This reverts commit 5eb3095.
Expand public Fleet, Vehicle, and Driver API contracts
…tings-load Fix live fleet map settings render-time mutation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Release branch for v0.6.62. Versions bumped in
composer.json,package.jsonandextension.jsonviaflb version-bump --patch, andRELEASE.mdwritten for the tag body.Included
Retargeted onto this branch and merges here before this PR merges to
main.Its companion specification and contract-test PR is fleetbase/postman#59, which documents and tests the API changes shipping here. It merges in
fleetbase/postmanon its own.Also in this branch
The three check workflows learned the release branch name.
v0.6.61's follow-up (#308) taught
release.ymlto tag arelease/v*branch, butserver.yml,ember.ymlandpostman.ymlstill only ran on pull requests targetingmainanddev-v*. Retargeting #311 onto this branch would therefore have run no PHP CI, Ember CI or Postman contract check on it — the rename would have silently switched off every check on exactly the branch the convention exists to validate. All three now also trigger onrelease/v*.pushstays limited tomain, which is what keeps a release branch from running each workflow twice (the reason forci avoid duplicate release branch runsin v0.6.61).Release gate
The reusable
release-tag.ymlrefuses the tag unless the branch name, the three manifests andRELEASE.mdall agree. Checked against this branch:On merge,
release.ymltagsv0.6.62on this PR's merge commit;create-release.ymland the publish jobs chain offpush: tags: v*as they already do.Validation
Nothing in this branch is executable — three manifest versions, the release notes, and three workflow trigger lists. The work being released carries its own validation on #311:
php scripts/pest-file-runner.php— 434 files, exit 0Live contract run
The
API Contract (Postman)job boots a stack with this branch's Fleet-Ops source overlaid and runsfleetbase/postman@mainagainst it. On #311's head it passes:An earlier run of that job — and a run on
mainat the same time — failed withError: Unable to find request or folder. Both were transient; the job has since passed on an unchanged collection.