Skip to content

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

Open
roncodes wants to merge 2 commits into
mainfrom
feature/public-fleet-resource-api
Open

Expand public Fleet, Vehicle, and Driver API contracts#311
roncodes wants to merge 2 commits into
mainfrom
feature/public-fleet-resource-api

Conversation

@roncodes

@roncodes roncodes commented Sep 4, 2026

Copy link
Copy Markdown
Member

Problem

The public v1 API exposed a small subset of what the Fleet, Vehicle and Driver
records can actually hold, and the gaps were silent rather than loud.

  • POST /v1/fleets accepted only name and service_area. Colour, task,
    status, zone, vendor and — most consequentially — parent_fleet were
    unreachable, so a fleet hierarchy could not be built through the API at all.
  • Fleet membership had no public endpoints. Vehicles and drivers could only be
    added to a fleet through the console's internal routes, which take uuids.
  • VehicleController copied 21 of the model's 99 fields out of a request. A
    caller sending weight, gvwr, purchased_at or engine_number received a
    200 and a response body that looked correct while the value was discarded.
  • DriverController used an except() blocklist, so anything nobody had
    thought to exclude reached Driver::create() intact — including auth_token,
    user_uuid and company_uuid. At the same time the blocklist dropped
    location, heading, altitude, speed and meta on every write.
  • Driver::$fillable listed 'meta,' — with a trailing comma inside the
    string — so meta was never mass assignable at all.
  • Relationship filters compared caller-supplied public IDs against uuid columns,
    so ?vendor=, ?fleet= and the fleet hierarchy filters could never match.
    FleetFilter::query() searched a user relation that Fleet does not have,
    and DriverFilter::phone() searched a phone relation that does not exist —
    both raise rather than filter.
  • Relationship inputs were validated with unscoped exists: rules, so another
    organization's public ID passed validation and was then resolved to nothing:
    the write was accepted and the relationship silently dropped.

Field-parity matrix

Every field on each model is classified. Legend:

  1. Public scalar input
  2. Public relationship input — accepted as a public ID, resolved to a uuid
    inside the authenticated company
  3. Server-managed / generated — intentionally excluded
  4. Sensitive / internal — intentionally excluded
  5. Legacy / deprecated — intentionally excluded

Fleet — 13 fillable columns

Field Class Public input Notes
name 1 name Required on create
color 1 color New
task 1 task New
status 1 status New. Not a closed enum on this model — the console offers active/disabled/decommissioned, and the importer and existing integrations write other values. Validated as a short string so the public API can express any state the console can.
service_area_uuid 2 service_area Raw column not accepted
zone_uuid 2 zone New. Raw column not accepted
vendor_uuid 2 vendor New. Raw column not accepted
parent_fleet_uuid 2 parent_fleet New. Raw column not accepted
image_uuid 2 photo New. Takes a file_... public ID, matching PartController
public_id 3 Generated
slug 3 Generated from name by HasSlug
company_uuid 4 Tenancy; taken from the authenticated session
_key 4 Internal

uuid is not fillable and is never accepted or returned publicly.

Vehicle — 99 fillable columns

Class 1 — public scalar input (90, all newly accepted unless marked):

name, description, make, model, model_type, year, trim, color,
type, class, internal_id, plate_number, vin, serial_number,
call_sign, fuel_card_number, odometer*, odometer_unit*,
odometer_at_purchase, measurement_system, fuel_type, fuel_volume_unit,
online*, status*, location*, heading*, altitude*, speed*,
transmission, body_type, body_sub_type, usage_type, ownership_type,
cargo_volume, passenger_volume, interior_volume, weight, width,
length, height, towing_capacity, payload_capacity*, seating_capacity,
ground_clearance, bed_length, fuel_capacity, financing_status,
loan_number_of_payments, loan_first_payment, loan_amount,
estimated_service_life_distance_unit, estimated_service_life_distance,
estimated_service_life_months, insurance_value, depreciation_rate,
current_value, acquisition_cost, currency, purchased_at,
lease_expires_at, emission_standard, dpf_equipped, scr_equipped,
gvwr, gcwr, engine_number, engine_model, engine_make, engine_family,
engine_configuration, engine_displacement, engine_size, horsepower,
horsepower_rpm, torque, torque_rpm, number_of_cylinders,
cylinder_arrangement, specs, details, notes, meta*, skills*,
payload_capacity_volume*, payload_capacity_pallets*,
payload_capacity_parcels*, max_tasks*, time_window_start*,
time_window_end*, return_to_depot*

* already accepted before this change.

Class 2 — public relationship input:

Column Public input Notes
vendor_uuid vendor Now company-scoped
category_uuid category New
warranty_uuid warranty New
photo_uuid photo New, takes a file_... public ID
driver Assigns through Driver::assignVehicle; now company-scoped

Classes 3–5 — intentionally excluded:

Column Class Reason
company_uuid 4 Tenancy; taken from the authenticated session
vendor_uuid, category_uuid, warranty_uuid, photo_uuid 4 Raw relation columns — a caller would be naming an internal uuid, and cross-company scoping could not be enforced
slug 3 Generated from year/make/model/trim/plate
vin_data 3 Written by the VIN decoder; applyAllDataFromVin() re-runs whenever vin changes
telematics 3 Written by telematics provider ingestion and webhooks. Accepting it publicly would let a caller overwrite provider-owned data. Returned in responses, not accepted as input.
avatar_url 4 Accepts an arbitrary URL, which the model stores and every consumer renders. Use photo with a file_... public ID instead.

Driver — 31 fillable columns, plus the linked user account

Class 1 — public scalar input:

internal_id, drivers_license_number, license_expiry, country,
currency, city, online, current_status, status, location
(and latitude/longitude), heading, bearing, altitude, speed,
meta, skills, max_travel_time, max_distance, time_window_start,
time_window_end

User-account fields (stored on users, not on drivers):
name (required on create), email, phone, timezone, and password on
create only.

Class 2 — public relationship input:

Column Public input
vehicle_uuid vehicle
vendor_uuid vendor
current_job_uuid job
users.avatar_uuid photo

Classes 3–5 — intentionally excluded:

Column Class Reason
auth_token 4 Authentication credential
signup_token_used 4 Authentication bookkeeping
user_uuid 4 Identity link, set by the controller from the user it creates
company_uuid 4 Tenancy; taken from the resolved company
vehicle_uuid, vendor_uuid, current_job_uuid 4 Raw relation columns
public_id, slug 3 Generated
_key 4 Internal
avatar_url 4 Same reason as Vehicle; use photo

password is accepted on create and deliberately not on update: changing a
password requires proving the old one and resetting it requires a code, neither
of which a general PUT can express. POST /v1/drivers/{id}/change-password,
forgot-password and reset-password remain the only ways to change it.

Fleet hierarchy contract

POST /v1/fleets
{
  "name": "Carpool",
  "color": "#2563EB",
  "task": "Employee transport",
  "status": "active",
  "parent_fleet": "fleet_parent123",
  "vendor": "vendor_123",
  "service_area": "service_area_123",
  "zone": "zone_123"
}
  • Omitting parent_fleet creates a root fleet.
  • Sending a parent's public ID creates a subfleet.
  • "parent_fleet": null on update promotes a subfleet back to a root fleet;
    every optional relationship clears the same way.
  • A fleet naming itself is rejected with 422.
  • A fleet moved beneath one of its own descendants is rejected with 422. The
    check walks upward from the proposed parent, so an arbitrarily deep cycle is
    caught, and a $seen set makes the walk terminate even against
    already-corrupt data.
  • A missing or cross-company parent, vendor, zone or service area is rejected.
    The two are answered identically, so a response cannot be used to probe
    whether another organization holds a given public ID.
  • Clients that send only name, or only name and service_area, behave
    exactly as before.

Fleet membership endpoints

POST   /v1/fleets/{fleet}/vehicles/{vehicle}
DELETE /v1/fleets/{fleet}/vehicles/{vehicle}
POST   /v1/fleets/{fleet}/drivers/{driver}
DELETE /v1/fleets/{fleet}/drivers/{driver}

All four take public IDs and answer in one shape:

{ "fleet": "fleet_123", "vehicle": "vehicle_123", "assigned": true }
{ "fleet": "fleet_123", "driver": "driver_123", "assigned": true }
  • Both the fleet and the resource are resolved through findRecordOrFail, which
    is company-scoped, so a cross-company resource is unavailable rather than
    forbidden — the same 404 an id that does not exist gets.
  • Assignment is idempotent: firstOrNew over withTrashed() means a repeat
    creates no second pivot row, and a membership that was previously removed is
    restored rather than shadowed by a duplicate. Both pivots carry soft deletes,
    which is why this matters.
  • Removal is a documented safe no-op when repeated.
  • Removing a membership touches only the pivot: the driver and vehicle are not
    deleted, the driver's vehicle_uuid is untouched, and memberships of other
    fleets are unaffected.
  • Routes are declared before the {id} patterns so a literal vehicles or
    drivers segment can never be read as a fleet id.

Drivers without credentials

email and phone are now optional on create. Both are still validated for
format and uniqueness when supplied, and existing email-and-phone creation is
unchanged.

  • The users table already declares email and phone nullable with no unique
    constraint, so the schema supports this without a migration.
  • Nothing is invented to fill the gap. A generated address would sit in the
    tenant's user table looking real, could be mailed to, and would block the
    genuine value later.
  • No invitation is sent: User::sendInviteFromCompany() already returns early
    when there is no email address. A regression test pins that.
  • The Driver-to-User relationship, the organization membership, the driver
    user type and the Driver role are all created exactly as they are for a
    credentialed driver.
  • A driver created this way cannot sign in to Navigator until credentials are
    supplied.
    Add an email address or phone number with PUT /v1/drivers/{id}
    when one becomes available.

Public-ID and tenant-isolation guarantees

  • Relationship inputs are validated with exists rules scoped to
    company_uuid and deleted_at is null, then resolved again in the controller
    through the company-scoped resolveModel(). Validation and resolution both
    enforce the boundary.
  • ResolvesFleetOpsApiResources::resolveUuid()/resolveModel() take an
    optional company uuid. DriverController::create passes the company it
    resolved, because a create request may name a company explicitly and the
    relationships must be looked up in that company rather than in whatever the
    session holds.
  • Relationship filters resolve public and internal IDs to uuids through a shared
    ResolvesPublicRelationUuids trait, scoped to the session company. The raw
    uuid arm stays available to internal console requests only.
  • Public responses report relationships as public IDs. No *_uuid column
    appears in a public payload.

Filters corrected

Filter Before After
VehicleFilter::internalId absent matches internal_id
VehicleFilter::driver compared a public ID with uuid resolves public/internal ID, uuid for internal requests
VehicleFilter::fleet compared a public ID with fleet_uuid resolves the fleet
VehicleFilter::vendor bespoke inline lookup shared resolver
DriverFilter::facilitator / vendor compared a public ID with vendor_uuid resolves the vendor
DriverFilter::vehicle uuid branch on any request uuid only for internal; exact public/internal ID, then search
DriverFilter::fleet compared a public ID with fleet_uuid resolves the fleet
DriverFilter::phone whereHas('phone') — not a relation, raises searches the linked user
FleetFilter::query whereHas('user') — not a relation on Fleet, raises searches name, task, public_id
FleetFilter::serviceArea / zone / vendor compared a public ID with uuid, and zone against a zone_uuid column that zones does not have resolve to the uuid column
FleetFilter::parentFleet whereHas('parent_fleet') — wrong relation name resolves to parent_fleet_uuid

Defects fixed along the way

  • Driver::$fillable held 'meta,', so driver metadata could never be mass
    assigned. Corrected, with a regression test.
  • 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 dropped without a word. Now writes avatar_uuid.
  • VehicleController::update applied the create-time online default, so any
    partial update — a plate correction, an odometer reading — silently took the
    vehicle offline. The default is now create-only.
  • The Fleet resource evaluated four count() queries per fleet on every public
    request and then discarded the results, because when() evaluates a plain
    value argument eagerly. They are closures now.
  • The Fleet webhook payload gated parent_fleet on $this->serviceArea, so a
    subfleet with no service area never reported its parent.

Backward compatibility

  • Every previously accepted input is still accepted and behaves the same way.
    All nineteen vehicle statuses are preserved, including active, which the
    model continues to store as available.
  • Public responses gain fields; none are removed or renamed. Relationship keys
    that were previously absent on public requests (because nothing loaded them)
    are now present as public IDs. A relationship asked for through ?with= still
    returns the nested object it always did.
  • Internal console responses are untouched: Http::isInternalRequest() selects
    the previous whenLoaded shape.
  • Two tightenings are deliberate and are the point of the change: a cross-company
    relationship public ID is now rejected instead of being silently dropped, and
    driver input is an allowlist instead of a blocklist, so fields such as
    auth_token no longer reach the model.
  • ResolvesFleetOpsApiResources::resolveUuid(), resolveModel() and
    applyPublicIdRelation() gained a trailing optional ?string $companyUuid = null.
    Five unrelated test doubles that override them were updated to match; no
    behaviour changed.

Tests

Extended:

  • ApiFleetControllerContractsTest — full-field create, root vs subfleet, parent
    clearing, self-parent and descendant-cycle rejection, cross-company rejection
    for all four relationships, the input allowlist, and the membership response
    shape and 404s.
  • ApiVehicleControllerContractsTest — a data-driven parity test over all 90
    scalar inputs, relationship resolution, relationship clearing, cross-company
    rejection, and the excluded-column allowlist.
  • ApiDriverControllerContractsTest — parity over the driver's scalar inputs,
    the excluded-column allowlist, meta/location/telemetry persistence, creation
    with no credentials and with one contact method, cross-company rejection,
    relationship clearing, and company-scoped relationship resolution.
  • RequestContractsTest — company scoping recorded on every relationship rule,
    the vehicle status enum, per-type vehicle rules, and the optional
    email/phone contract.
  • ControllerFilterContractsTest, DriverFilterExecutionTest — the corrected
    relationship filters, asserted against resolved uuids on a real connection.

Added:

  • Feature/Http/Api/FleetMembershipTest — database-backed pivot semantics:
    idempotent assignment with no duplicate row, restore of a soft-deleted
    membership, repeated removal as a no-op, and preservation of the driver's
    vehicle and of unrelated fleet memberships.
  • Feature/Http/Api/FleetPublicContractTest — public field parity and public-ID
    relationships on the Fleet resource, the null-parent root case, the unchanged
    internal shape, ?with= still nesting, and route registration and ordering
    for the membership endpoints.

Validation

php scripts/pest-file-runner.php                                  # 434 files, exit 0
XDEBUG_MODE=coverage php scripts/coverage-file-runner.php \
    --coverage-clover=coverage/clover.xml                         # exit 0
php scripts/coverage-summary.php coverage/clover.xml --fail-under=100
composer test:lint                                                # see below
composer test:types                                               # see below

composer test:unit runs the same per-file Pest runner as the first command.

Coverage gate — 100% on all three metrics:

Line coverage:   100.00% (34670/34670 statements)
Method coverage: 100.00% (4428/4428 methods)
Class coverage:  100.00% (530/530 classes)

Lowest covered directories:
  100.00%  34670/34670  server/src

The first push of this branch left the gate at 99.95% (34653/34670): 17
statements the new code added that no test entered. The second commit closes
all 17 — the statement total matches CI's figure exactly, so those are precisely
the ones it flagged. Each is reached by a test that asserts the behaviour rather
than by a call made only to move the number; the commit message lists them.

composer test:lint reports 4 files, all pre-existing on origin/main and
none of them touched by this branch:

1) server/tests/ApiManifestControllerContractsTest.php
2) server/tests/Unit/Http/Resources/ManifestResourceTest.php
3) server/tests/Feature/Http/Api/GeofenceControllerContractsTest.php
4) server/src/Http/Controllers/Api/v1/GeofenceController.php

Verified pre-existing by running php-cs-fixer against the origin/main copy
of GeofenceController.php, which reports the same fixable file. Every file
this branch touches is clean.

composer test:types reports 13,739 errors at level: max. This is the
pre-existing baseline — the project has no PHPStan baseline file and no Laravel
extension, so every framework magic property, every session() and every
config() call is an error.

The four new source files report zero errors:

server/src/Exceptions/PublicRelationNotFoundException.php               0
server/src/Http/Filter/Concerns/ResolvesPublicRelationUuids.php         0
server/src/Http/Requests/Concerns/ScopesPublicRelationRules.php         0
server/src/Http/Resources/v1/Concerns/ResolvesPublicRelationFields.php  0

Per-file counts for the modified sources, origin/main against this branch:

FILE                                                                     main  branch
Http/Controllers/Api/v1/Concerns/ResolvesFleetOpsApiResources.php           0       0
Http/Controllers/Api/v1/DriverController.php                              257     243
Http/Controllers/Api/v1/FleetController.php                                33      54
Http/Controllers/Api/v1/VehicleController.php                              73      65
Http/Filter/DriverFilter.php                                               33      33
Http/Filter/FleetFilter.php                                                21      24
Http/Filter/VehicleFilter.php                                              28      31
Http/Requests/CreateDriverRequest.php                                      15      13
Http/Requests/CreateFleetRequest.php                                        4       5
Http/Requests/CreateVehicleRequest.php                                      4       4
Http/Resources/v1/Driver.php                                               68      78
Http/Resources/v1/Fleet.php                                                33      33
Http/Resources/v1/Vehicle.php                                             197     212
Models/Driver.php                                                         100     100
                                                                    ---------------
                                                                          866     895

The net +29 is entirely the same baseline noise scaling with the number of
fields and seam methods added — Access to an undefined property,
has no return type specified, Function session not found. Every one of the
new entries matches a shape already present in the same file on main.

Unrelated CI job

API Contract (Postman) / Postman contract against live API fails on this PR.
It fails identically on main — run
33866120783,
from the same day — with the same error:

Error: Unable to find request or folder. Please ensure the specified request or
folder exists in the collection.

The job boots a stack and runs fleetbase/postman@main; it resolves nothing
from this repository's diff, and the collection runs locally without that error.
Pre-existing and out of scope here.

Related

Documentation and contract tests: fleetbase/postman#59 — that PR
documents and tests this one.

If the fleetbase.io API reference is generated from fleetbase/postman, it
needs regeneration once both PRs land. No change was made to
fleetbase/fleetbase.io.

Confirmation

No customer-specific code, fixtures or data was added. Nothing here is specific
to any one importer, spreadsheet or customer: every change is a general
Fleet-Ops public API improvement. No credentials or API keys are included, and
no production configuration was changed.

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.
@codecov

codecov Bot commented Sep 4, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (a9131da) to head (86cc6c8).
⚠️ Report is 2 commits behind head on main.

Additional details and impacted files
@@             Coverage Diff              @@
##                main      #311    +/-   ##
============================================
  Coverage     100.00%   100.00%            
- Complexity      9899      9956    +57     
============================================
  Files            526       530     +4     
  Lines          38163     38457   +294     
============================================
+ Hits           38163     38457   +294     
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.

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