Skip to content

release: v0.6.62 - #312

Merged
roncodes merged 16 commits into
mainfrom
release/v0.6.62
Sep 6, 2026
Merged

release: v0.6.62#312
roncodes merged 16 commits into
mainfrom
release/v0.6.62

Conversation

@roncodes

@roncodes roncodes commented Sep 5, 2026

Copy link
Copy Markdown
Member

Release branch for v0.6.62. Versions bumped in composer.json, package.json and extension.json via flb version-bump --patch, and RELEASE.md written for the tag body.

Included

PR
#311 Expand public Fleet, Vehicle, and Driver API contracts

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/postman on its own.

Also in this branch

The three check workflows learned the release branch name.

v0.6.61's follow-up (#308) 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*. 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 on release/v*.

push stays limited to main, which is what keeps a release branch from running each workflow twice (the reason for ci avoid duplicate release branch runs in v0.6.61).

Release gate

The reusable release-tag.yml refuses the tag unless the branch name, the three manifests and RELEASE.md all agree. Checked against this branch:

ok   composer.json = 0.6.62
ok   package.json = 0.6.62
ok   extension.json = 0.6.62
ok   RELEASE.md first line names v0.6.62
ok   no template placeholder
ok   tag v0.6.62 does not exist yet

On merge, release.yml tags v0.6.62 on this PR's merge commit; create-release.yml and the publish jobs chain off push: 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 0
  • Coverage gate — 100.00% on all three metrics (34670/34670 statements, 4428/4428 methods, 530/530 classes)
  • PHP CI green on run 33869029161

Live contract run

The API Contract (Postman) job boots a stack with this branch's Fleet-Ops source overlaid and runs fleetbase/postman@main against it. On #311's head it passes:

requests    218 executed, 0 failed
assertions  221 executed, 0 failed

An earlier run of that job — and a run on main at the same time — failed with Error: Unable to find request or folder. Both were transient; the job has since passed on an unchanged collection.

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

codecov Bot commented Sep 5, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (825d060) to head (131b5a6).
⚠️ Report is 17 commits behind head on main.

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     
Flag Coverage Δ
backend 100.00% <100.00%> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

roncodes and others added 13 commits September 5, 2026 15:37
…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.
`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
Expand public Fleet, Vehicle, and Driver API contracts
…tings-load

Fix live fleet map settings render-time mutation
@roncodes
roncodes merged commit aea2f71 into main Sep 6, 2026
6 checks passed
@roncodes
roncodes deleted the release/v0.6.62 branch September 6, 2026 05:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant