Add the driver-facing inspections API, and cover the inspection platform (#267 rebased) - #319
Merged
Merged
Conversation
The inspection platform (#267) shipped a public, tokenised link and the console's internal CRUD, and nothing a driver's app could authenticate against: a driver who inspects the same truck every morning had no way to file a DVIR except a link minted for them each time. This adds the consumable surface under v1: GET /v1/inspection-forms published forms; vehicle=, type= GET /v1/inspection-forms/{id} one published form POST /v1/inspections file against a form GET /v1/inspections driver=, vehicle=, type=, result=, status= GET /v1/inspections/{id} GET /v1/vehicles/{id}/inspections Every lookup is scoped to the API key's company, and an unpublished form answers 404 rather than 403: to a driver a form that cannot be filled in does not exist. The submit body is exactly what the public link accepts, plus the form, driver and vehicle the link already knew. Both doors now go through one InspectionSubmitter, so they cannot drift on what a submission is. The app queues a submit while offline and replays it; a replay carrying the same Idempotency-Key answers with the submission the first attempt filed rather than filing twice. The platform has no idempotency middleware, so the key is kept on the submission's meta and looked up there. Photos are validated as base64 or a URL by a small Rule rather than a regex: a DVIR photo is easily a megabyte of base64, and a backtracking pattern over that only fails on a real handset. Coverage: the platform arrived with no tests, and the gate is 100% of server/src. The models, the public and internal controllers, the resources, the hub and report schema changes and the new API are covered against an in-memory SQLite database, as the trailer lifecycle tests are. Four tests on main asserted the old contract and are updated to the new one, kept exact: the maintenance hub leads its KPIs with failed inspections and adds an Inspections section and doc; a hub with no published form is told to create one; the report schema registers work orders, maintenance history and inspections; a closed work order records its maintenance as `completed`. The migration is re-stamped to today, content unchanged.
This was referenced Sep 9, 2026
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## release/v0.6.66 #319 +/- ##
=====================================================
Coverage 100.00% 100.00%
- Complexity 10274 10774 +500
=====================================================
Files 545 565 +20
Lines 39684 41545 +1861
=====================================================
+ Hits 39684 41545 +1861
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:
|
…, and translate the sidebar Both form components assigned to @resource in their constructors, which Glimmer refuses once the template has read the attribute in the same render pass ("You attempted to update items ... already used in the same computation"). The model's items / item_results are now the single source of truth, read through getters and replaced only from actions. Text fields update from the DOM event rather than passing the stale rendered value, and every control honours cannot-write. The sidebar items are translation keys, added to all nine locales.
Member
Author
|
Console fixes pushed (5e98475), after a first run against a live console:
Verified: |
The first cut stored a form as a JSON list of pass/fail items. That is a checklist. Fleetio parity — and the fliit module that already has it — wants groups of typed fields, built from the platform's own custom-field system, so an inspection is a form filled in rather than a list ticked. A form's groups are platform categories owned by the form; its fields are custom fields whose subject is the form, filed under `fleetops_inspection_form` so a field the console's generic panel adds to the form record is not mistaken for one a driver answers. Both come back as `grouped_fields`, which is what the app codes against. A submission answers through custom-field values. `inspection_item_results` stays: issues, work orders and the history are built from it, so every pass-fail value is mirrored into a result row keyed the way the app keys it. "Not applicable" is carried through as its own status rather than flattened into a pass. Photos and signatures arrive as base64 — the app is offline-first and cannot upload first — and are stored as platform files, leaving `file:<uuid>` as the value, which is the platform's convention. The submission resource answers `custom_field_values` with each field's identity beside its answer and every file reference resolved, plus the `files` the inspection carries. The rules a field insists on when it fails are checked before any photo is stored, so a refusal leaves nothing behind. The tests' in-memory schema gains `categories`, the real `custom_fields` columns and the file columns the projection reads.
The console writes a form's structure by posting the whole draft — the builder lays a form out before the record exists, so the structure has to arrive with the save that creates it. `InspectionFormController` takes it under `inspection_form.field_groups` (and fliit's `draft`, so a form authored there still saves) and prunes what the post no longer lists. `InspectionSubmissionController` accepts `custom_field_values` on the same terms as the driver API, so the console and the app write the same rows, and still accepts `item_results` for a legacy checklist. Two bugs found on the way. `Request::array()` is a core-api macro taking exactly one argument, so every `$request->array($a, $request->array($b))` in this controller silently discarded its fallback and the flat spelling never worked; both reads now go through a helper that tries each key. And a field written straight into the table by a seed carries no `component`, so the resource names one from the type. `InspectionExport` gives compliance the spreadsheet it asks for: one row per submission, defects named, follow-up and out-of-service called out, behind the usual `export` route. The migration folds a first cut's `items` into a "Checklist" group of pass-fail fields, skipping any form already built with fields so it can be run again safely.
Every new line, in the harness's own style: an in-memory database with the platform's `categories`, `custom_fields` and `custom_field_values` beside the inspection tables, and a real local filesystem so the file store can be watched turning a driver's base64 into a platform file and the resource resolving it back. What is pinned here is the contract the app codes against: `grouped_fields` with a field's id, type, options and meta; `custom_field_values` accepted by uuid or by name; a pass-fail answer read whatever shape it arrives in, "not applicable" kept as its own status; the comment and photo a field insists on refusing the whole set before any file is written; a meter and a boolean answered back as a number and a boolean rather than the strings the value column stores. One behaviour changed to match: a number value now leaves the resource as a number. The `custom_field_values` guard in the submission resource went — `withCustomFields()` has always loaded that relation, so it was dead.
The console side of the second cut. A form is built in `inspection-form/builder`: field groups with a grid size, fields added, edited, reordered and deleted through a FleetOps field editor that offers the inspection type list and, for `pass-fail`, the On fail section the driver app reads — default severity, whether a photo or a comment is required, whether the defect parks the truck, and the instructions the driver sees. A field is answered through `inspection-field/input`. `pass-fail`, `signature` and the inspection flavour of `file-upload` are FleetOps' own, as are `textarea`, `number` and `boolean` — the platform's type map has no component for those three. Everything else is handed to the platform's `custom-field/input`. The record gets Overview, Photos and Audit tabs; Photos is `ModelMultiFileUpload` against `inspection_photo`, Audit the platform's `ActivityLog`. The builder holds the structure as a draft of plain objects, so a form can be laid out before the record exists and the whole thing is posted with the save. It has to be posted separately: `inspection-form` and `inspection-submission` live in `@fleetbase/fleetops-data` and declare no attribute for the structure or the answers, so Ember Data drops both in both directions. Until that package gains them, the structure and the answers ride their own request to the internal endpoint, which is also the only spelling the server reads. Glimmer discipline throughout: nothing writes to `@resource` during render, text inputs update from the DOM event, and every control honours `cannot-write`.
Two things the console does that the driver app does not. It uploads a photo the moment it is picked and keeps the reference the upload answered with, which names the file by its public id — `file:file_…`, not `file:<uuid>`. That reference was passed through untouched, so nothing downstream could resolve it: the file was never claimed by the submission and the resource handed the raw string back. A `file:` reference that does not name a uuid is now looked up and rewritten to the one it does. And it can clear a field. `custom_field_values.value` is a NOT NULL column, so an answer that is nothing is not an answer — it deletes the row instead of writing a null the column will not take.
Three components kick a load off in the next runloop; if the panel closes first the task is performed on something already torn down. And the form the answering screen assigns writes its own uuid into the column, never the id the console addresses it by.
A severity that is not one of the four the field editor offers has no key, and asking for one puts "Missing translation" on the record. Fall back to the value itself.
`valueTypeFor` in the app's useInspections sends `datetime` for a date-time field and `date` for a date; the console said `date` for both. The server takes either, but the two clients should file the same row.
buildSubmission in the app's useInspections.ts emits both bodies at once, every pass-fail answer carrying not_applicable and unsafe, photos and the signature as bare base64. That exact shape now goes through the submitter in a test, so a change here that the app would not survive fails here first.
Same as the others: the details panels kick their load off in the next runloop, and closing the panel first would perform a task on a component already torn down.
The tokenised link built its checklist from `items`, which a form built in the new builder does not have — so the link showed an empty form and would not submit. It now falls back to the pass/fail fields of `grouped_fields`, keyed the way the server keys a derived result, so a link submission and an app submission name the same item. The other field types are still not offered there: a public link is a checklist for a contractor, not the app.
`InspectionFormSync` read every custom field subjected to the form, not just the ones filed under the inspection kind. The builder posts the whole form and prunes what the post no longer lists, so a custom field the console's generic panel had added to the form *record* was deleted by the next save of the structure. Both the read and the prune are now scoped.
The form's own details view asked for a translation of whatever severity a field carries, which for a field converted from a hand-written first-cut item can be anything. Same fallback as the record.
…d editor
Six things from the first real run of the builder:
- Settings rendered as a raw metadata editor, collapsed. They are three
known switches — two the server reads when a submission has failures, one
the driver app reads before it will submit — so they are checkboxes in an
unlabelled input group, and the panel opens.
- Type and status showed smart-humanize output ("Dvir"). They now come from
labelled option lists in fleet-ops-options, so DVIR reads as DVIR.
- Six inspection types were missing: pre-operational, post-operational,
safety inspection, maintenance inspection, damage assessment and annual
inspection.
- Every PowerSelect is wrapped in the fleetbase-model-select /
fleetbase-power-select / ember-model-select div so it renders as the rest
of the console does.
- The field editor opens as a right-side overlay over the form panel rather
than a modal, sized xs so the form stays visible beside it. It holds the
field in tracked state and writes through to the overlay's shared handle,
which is what the builder reads back on save.
- Frequency is dropped from the form. The column and the API field stay, but
nothing schedules an inspection from it, so the dropdown only asked the
author a question the product does not act on.
…sing focus Three from the second run: - Collapsing the Form Builder panel emptied the form. ContentPanel unrenders its body when closed, so the builder component was destroyed and the draft went with it; reopening rebuilt it from nothing. The draft now lives on the controller, which the collapse does not touch, and the builder renders @groups and reports changes through @onchange. The load is skipped when the controller already holds a structure, so reopening cannot overwrite unsaved edits with server state either. - Typing in a group's name or description lost focus after each keystroke. The iteration was unkeyed, so replacing the edited group object changed its identity and Glimmer rebuilt the input. Both loops are keyed on uuid, which every group and field is guaranteed. The two inputs also take their value once on insert rather than re-binding it, so a mid-word edit cannot move the caret to the end. - The settings checkbox description sat tight against its label; it now has mt-1.
The panel's Save was disabled for good. Without pojoResource the header falls through to `cannot-write @resource`, and the overlay carries a plain object rather than an Ember Data record, so the permission check had nothing to judge and denies by default. The field is a POJO, which is exactly what pojoResource is for. Required and editable now stack with spacing rather than sitting in a row.
Sharing one row with four buttons truncated every label to a few characters in a two- or three-column group — unreadable, and impossible to keep track of while building a form. The name takes its own line, with the required mark and the type pill beside it and the actions right-aligned underneath. The pill no longer truncates; the label is the only thing that gives way.
Five things reported while testing inspection forms in the console. Publish stayed in the header after a form was published, so the only thing pressing it could do was report that the form was already published. It now appears only while the form can be published, and as `type="success"`. Generate Link moves the other way: it needs a published form, so it appears at the same moment Publish leaves. A field's machine name came from `dasherize`, which rewrites spaces and underscores and leaves everything else alone — "Sidewall condition, offside rear" became `sidewall-condition,-offside-rear`. The name is an identifier: it travels as an item result's `item_key` and is what a report groups on. It is now a real slug, with anything that is not a letter or a digit acting as a separator. `frequency` is gone. It was inherited metadata: a column, an attribute and a filter that nothing scheduled an inspection from. It is removed from the v1 resource, the model's fillable and filter params, the report schema, the index column and query param, the details panel and the create defaults. The database column is deliberately left in place — the create migration has already run on live instances, and a dead nullable column is cheaper than editing a migration mid-test. A later migration can drop it. Type rendered through `smart-humanize`, so a form of type `dvir` read "Dvir". Option lists already carry the labels, so this adds a table cell that reads them: `table/cell/fleet-ops-option` takes the list name from the column's `optionsKey` and falls back to humanizing anything with no matching option, so a retired value is still legible. The index Type column and the details panel's type and status now use it. The Created column's own bug is in fleetops-data, where the model hands out a raw `Date` — fleetbase/fleetops-data#77 fixes that.
Three things reported while testing inspections in the console.
**The sheet.** An inspection form rendered as a bag of inputs: groups
were collapsible content panels, fields sat in a CSS grid, some had a
border and some did not, and failing a check grew that grid cell — which
stretched the row and left its neighbour floating in white space. It was
not a form anyone would want to work down.
It is now one sheet. Every field is a row of the same shape: what is
being checked on the left, the control on the right, a full-width
control under its label when it needs one. A failure opens its defect
block below its own row, so answering one line can never change the
shape of another. Groups are plain sections that are always open — a
panel that shuts invites an inspector not to read a line they have to
read — with the one number that matters in the header: what failed, or
what is still owed. A running total sits at the foot, and an unsafe-to-
operate answer is called out there. A failed row is marked down its own
edge so a long sheet can be scanned.
Pass / fail / not-applicable is one segmented control rather than three
loose buttons. Toggles get their own row like everything else. Number
fields have a placeholder and show their unit. All eleven field types
are rendered here now rather than five of them being handed to the
platform's `custom-field/input`, which brought its own label chrome and
made the sheet read as two interleaved forms.
One `inspection-sheet` component renders it everywhere — the console's
submission form, the read-only record, and the public link — so the
three cannot drift. The seeding and the `custom_field_values` payload
moved to `utils/inspection-answers` for the same reason.
Two modifiers replace `value={{...}}` on inputs that re-render on every
keystroke: `sync-value` writes a value in only while the field is not
being typed in, which is the focus bug from the form builder in another
guise, and `when-changed` runs something on change but not on insert.
**The public link opened a blank page.** The generated path was
`/inspection?…`, which matches the *authenticated* `console/:slug`
route. The public one is `/~/:slug`, a sibling of `console`, outside its
chrome and its auth gate — the same route ledger's invoice links use.
The path now carries the `~/`, and the page itself was rebuilt on the
shared sheet instead of the pass-fail-only checklist it had. Uploads are
off there: the file endpoint needs a session and a link does not have
one, so the row says where a photo can be added rather than offering a
button that would fail.
**A generated link existed only as a toast.** It went to the clipboard
and, once that was overwritten, there was no way to find out what had
been handed out, to whom, or whether it still worked. Links are now
listed — under the form's details and below the generate form — with the
URL to copy again, the vehicle and driver it was for, when it was made,
when it was last opened, whether it is active, expired, used or revoked,
and a way to revoke one.
That needs the link itself, which was stored only as a sha256. A new
migration adds an encrypted `token` column beside the hash; `token_hash`
is untouched and remains the unique index every public request resolves
through. A link is a capability URL — one published form, once, for one
vehicle, until it expires — not a credential, and being re-readable is
how share links behave. Links minted before this are listed without a
URL rather than pretending to have one.
Revoke always 404'd. The link resource sent `id` as `$this->id`, which on a Fleetbase model is the table's auto-increment column, while the revoke endpoint resolves a link by uuid or public id. So the list could show links and never act on one. It sends the public id now, and the lookup accepts a numeric id too so an already-open console still works. The links list was also stacking a gray-900 fill inside a gray-800 panel, which reads as a hole punched in the page. In dark the sunken surface is now the same gray-800, and separation comes from the border under a section header and a shadow on the card. The rows are tighter: they sit inside a panel that already pads. Putting the record's own details into a bare section went too far. Only the form's field groups needed to stop being collapsible panels — the details are a resource form like every other one in the console, so they are back in their content panel exactly as they were, keeping only `sync-value` on the two number inputs so the caret stays put mid-edit. The sheet itself now has an inset, so its section borders no longer land on the overlay panel's own edge and read as a double rule. How the field groups themselves should be laid out — whether the builder's grid survives — is still open, and nothing here presumes an answer.
…arkup Filing an inspection from the console failed: "Column 'total_items' cannot be null". The console's model serialises every attribute, and an explicit null in an insert overrides the column default instead of falling back to it. A saving hook now restores the defaults of the NOT NULL columns (type, status, total_items, failed_items) when they arrive null. The counts are worked out from the answers straight after, as before. The inspection loading states use the standard markup: a centred container, and a Spinner laid out as a row with its message.
Ember CI: stylelint wanted single-quoted font names and range media queries, and a duplicate .inspection-slot--add block merged into the first; prettier reflowed two sidebar menu items and one option string. PHP CI: generating a link now writes the PIN through the encrypted cast, and the inspection test harnesses had no encrypter bound, so link tests errored with "Target class [encrypter] does not exist". Both harnesses bind a reversible stand-in with the encrypt/decrypt methods Eloquent's cast calls. The public link test still submitted the first cut's flat item_results, which a link has refused since the file-reference hardening; the refusal aborts with an empty message, so it read as an unexplained error. It now checks that refusal, then files through the form's fields, and checks the typed name and PIN flag the submission records. The submitter rules test checked the rules through Laravel's validator, which this package does not install; it checks each answer key has a rule. php-cs-fixer ordered imports and aligned the new rules.
…outes
The recorder's verb methods returned nothing, so the route file's
->middleware('throttle:20,1,inspection-upload') on the public upload route
failed with "Call to a member function middleware() on null" and both tests
errored. They now return a recorded route that takes middleware, and any
other chained call is accepted.
fleetbaseRoutes() dropped the callback where resources declare their extra
routes, so none of those were recorded. It now replays it inside the
resource's prefix, the way the platform macro does.
The test also checks the public inspection routes, the public group's JSON
and rate-limit middleware, the upload limit, and the link send-pin route.
The coverage gate failed on the new link code: the PIN sender, its email, the JSON middleware, link assignment and send-pin, the public PIN checks and the photo upload had no tests. New tests cover each of them: - who a link's PIN goes to, why it cannot be sent, masking, the SMS text and the link's address - email and SMS delivery, the organisation's sender ID, a refused text and failures in transit, which are reported and not thrown - assigning a link, the PIN fields the console sees, refusals before anything is minted, an outsider refused, and sending again - listing and revoking links, by public or numeric id, with their states - a missing, wrong or right PIN, by header or field, and the lockout - a photo stored under the type its bytes show, a disk that refuses it, and the per-link cap - a single-use link taken by a concurrent submit, and a reusable one - the public routes answering in JSON Two changes to the code under test: the PIN sender resolves SmsService from the container, so a provider can be swapped; and the upload no longer re-saves the file's company, which File::create already sets from the link, so that branch could never run. The harness gains the helpers core would provide (report, event, the humanize macro), fakes for mail, SMS and the exception handler, and a container that answers environment() for Utils::consoleUrl().
The Postman contract's Submit an Inspection got a 302 from POST /v1/inspections, which the runner followed to a port nothing listens on and reported as ECONNREFUSED. A refused $request->validate() redirects any request that did not ask for JSON, and the collection does not send Accept: application/json. The v1 inspection-form and inspection routes, a vehicle's inspection history, and the console's inspection-form and submission routes now run behind ForceJsonResponse, as the public link routes already do, so a refusal is a 422 with its reasons. The route test checks each of them.
3 tasks
Filing an inspection with a failed item answered 500: issues.location is a spatial column with no default, and createIssueFromFailures() left it out, so MySQL refused the insert (1364, "field location does not have a default value"). Found by the PHP SDK's live contract run in fleetbase/fleetbase-php#15 against this branch. The issue now takes where the failure was reported: the submission's own coordinates, else the vehicle's last known position, else the driver's, and an empty point when nothing is known. Read directly rather than through Utils::getPointFromMixed(), which throws when it cannot resolve a point, and every one of those sources is routinely empty. No test caught it because the harnesses stubbed ST_GeomFromText to hand back the WKT it was given, so a stored point could not be read again. The stub now answers as MySQL does, a 4-byte SRID and the geometry's WKB, and the tests cover an issue taking the submission's coordinates, the vehicle's, and the empty point.
The submitter tests file a failed inspection, which now raises an issue carrying a location, and this harness had no ST_GeomFromText: the insert failed with "no such function". It stubs the same MySQL-shaped point the model and controller harnesses do.
Five things the record got wrong, from a review of a filed submission.
Badges: ember-ui styles a badge by its status name, and `danger` is not one
of them, so every chip using it — a failed result, an unsafe defect, a
critical severity — rendered grey. They use statuses ember-ui knows, and
the engine adds the three it has no entry for: passed, submitted, unsafe.
Read-back answers kept no shape: a cell was a bare label with its value
loose beneath, while a note or upload drew a bordered card, so one row read
as a card and the next as stray text. A read-only cell now takes the same
card, sized to its own answer, and a failed one keeps the defect's red. An
empty answer says "Unanswered" rather than showing a dash beside a band
that says it in words.
The header carried five buttons and pushed the title out of the overlay.
Editing keeps its button; create issue, create work order, resolve and
delete moved behind the ellipsis, as the vehicle panel does.
Item results restated the pass/fail answers the sheet already shows, so it
is kept only for a submission filed before typed fields, which has nothing
else to read.
The title names the form that was filled in — "{form} Submission" — rather
than repeating the record's id, which the overview already shows.
roncodes
marked this pull request as ready for review
September 14, 2026 05:51
A failed check rendered as a band, and a band spans the grid, so one answer ran the full width of the record while its neighbours sat in the columns the form was built with. A pass, meanwhile, was a plain grey card with a chip, which read as a different kind of thing entirely. Pass, fail and N/A are now one card shape, coloured by the answer: green for a pass, red for a fail, neutral for N/A, with the state's chip beside the label. A fail keeps everything it carried — severity, the unsafe flag, the comment and the photos — stacked beneath the head, inside the column the form gave it. Only a note, an upload or a signature still spans, which is the room those actually need. Pass tokens join the fail set in both themes, and the chip takes the answer's colour rather than always being red.
The green a passed check sat on was darker than the red of the fail beside it — #17231c against #241b1e — so a pass read as a near-black card rather than a green one. The fill moves up a step to #1c2e22 and the border with it, #1f3d2b to #2f5a3c, which keeps the same relationship to the fail card's edge. Light mode is untouched.
Create Issue, Create Work Order and Resolve fired on click: the first anyone knew of a new issue was a toast and an id. Each now opens a preview naming the failed checks it will be built from, worst severity first, how many are unsafe to operate, and when the work order falls due — and refuses to submit when nothing failed, which the server would otherwise only tell you after the request. The follow-up panel showed the bare uuid of whatever was created. It now shows the title, status and priority, and opens the issue or work order when clicked.
A submission raises one issue and one work order — the server has always enforced that, handing back what exists rather than making a second one. The console did not know, so Create Issue stayed on the menu and every further click reported success while creating nothing. Both are now offered only while there is something to raise, and Resolve only while the inspection is unresolved; what has been raised is linked from the Follow Up panel instead. Submit had the same shape in the table's row menu: on an already-filed inspection it recounted totals, changed nothing and still said "Inspection submitted", as often as you clicked it. It now asks first, and appears only on drafts. The form a submission was filled in from was a dead name; it opens the form.
`custom_field_values` describes the table the answers happen to live in. An integrator reading a DVIR should not have to know that: a form has `fields`, so a submission has `answers` to them, and each answer names its `field` rather than its `custom_field`. `value_type` goes with it. It says how a value is stored, which is the platform's business — the console still gets it, and `type` (the field's own type) is what tells a consumer how to read the value. Nothing breaks. `custom_field_values` is still accepted on submit and still answered on the internal namespace, so the console and older app builds carry on; error paths follow whichever spelling was sent. A field can now be named by public id as well as by uuid or name, which is what core-api#254 gives it — until that lands the public id falls back to the uuid, which is what it already was.
The rename left the form and the submission disagreeing. A form handed out a field's uuid as its id; a submission's answers named the field by its public id. Once custom fields have one, a consumer would read a form, submit against the id it gave them, read the submission back and find a different value for the same field. The form now answers the way its groups already did — the public id outside, the uuid on the console's own namespace — so the id a driver reads, the id a submit names a field by, and the id that comes back on the answers are one id. An item result carried `meta.custom_field_uuid`, which is the link back to the field it mirrors and the console's business; outside, `item_key` already names the field, so the uuid no longer goes out with it.
An answer's photos come back as something fetchable; the item results derived from those same answers handed back the raw `file:<uuid>` strings they are stored as — the last place the public body still showed an internal identifier. A flat `item_results` submit stores no file, and project() hands a value it cannot resolve straight back, so those bodies are unchanged.
applyCustomFieldValues() gained a parameter naming the key the answers arrived under, so a 422 points at the path the caller actually sent. It defaulted to the new spelling, which was wrong: every caller that does not pass it — the public link, the console, and the tests — is by definition one that sent the old one, and each was suddenly told its answers were refused at `answers.N.field`. The default is the old spelling; only a submit that received `answers` asks for the new one. The rule strings are asserted verbatim in one place, and gained the third body.
Two things from reviewing the console. The row menu's separator sat above four conditional actions and was not conditional itself. On a resolved inspection that had already raised its issue and work order, all four hid and the rule drew anyway — directly beneath the one above Delete, as a double line with nothing between them. It now appears only when something below it does. A vehicle's overlay had no way to see what had been inspected on it: the submissions existed, filed against that vehicle, reachable only from the Inspections screen. It has an Inspections tab now, built like the trailer's work orders tab — the record id, the form, the result and status, how many checks failed, when it was filed, and a way in. Filing is a driver's job, through the app or a public link, so nothing is created from here; the tab is a history. It queries `vehicle_uuid` rather than `vehicle`: the latter is neither a column nor searchable on this model, so it is dropped without a word.
`needs_review` is offered in the status field and counted by the hub, so a supervisor can set it — but neither ember-ui nor this engine had a class for it, and a badge without one renders as an unstyled chip. The vehicle's new Inspections tab shows status, which is what surfaced it; the record panel and the index table had the same gap. Amber, following the warning family ember-ui uses for "pending".
Three things from the screenshot review. The vehicle's Inspections tab put six columns in a 580px overlay. The form name wrapped onto three lines, the submitted date sat off the edge behind a scrollbar, and the id — uppercased, in full — dominated a row you could not otherwise read. It is one block per inspection now: the form it was filled in from, what it came to, when, how many checks failed, and the id small and out of the way. A draft has no result yet, so that badge is no longer drawn as an empty coloured dot. The link to the form had no styling at all — no CSS rule was ever written for it — so it read as plain text with an oversized icon beside it. It is a link now: blue, the icon small and spaced, the whole thing one target. And the records a submission names were dead text. `Resource::DetailLink` renders a linked resource as its identity — photo, name, the line that identifies it — and opens it; `Vehicle::DetailLink` and `Driver::DetailLink` say what a vehicle and a driver are. They open the context panel beside what you were reading, as the console does elsewhere, and `@mode="route"` asks for the full transition instead. Only the inspection submission uses them so far. Vehicle, driver and work-order details all name records the same dead way and are the obvious next callers.
Three of the vehicle's tabs were rendering a `<StatusBadge>` that does not exist — not in this engine, not in ember-ui. The cell drew nothing at all, so a work order's and a schedule's status were simply absent. `<Badge>` is what every other table uses. In block form: self-closing renders an empty chip when a record carries no status, which is the same thing the inspection tab was caught doing. Two of those tabs guarded their spinner on the results array rather than the task that fills it. An array has no `isRunning`, so the guard was always false: the spinner never appeared and the empty state showed while the query was still running, telling you there was nothing here before anything had been fetched. Swept the engine for both: no `<StatusBadge>` usages remain, and all nine spinner guards across the details tabs now name a real task.
The pills already do this job — photo, name, the line that identifies a record, and a click — so a second family for the same thing was the wrong call. `Resource::DetailLink` and its vehicle and driver wrappers are gone; the submission's vehicle and driver are pills, opening the context panel beside what you were reading. Two bugs in `vehicle/pill` had to go first. `@this.resource=` is not an argument Pill reads, so Pill received no resource at all: its name fallback, its online dot and the record handed to `@onClick` were empty — which this change depends on. The tooltip's first line invoked a path with an argument, rendering nothing. Pills carried no styling of their own and gave no sign they could be clicked. They now respond on hover, and only when given something to do. The form link was never blue: ember-ui styles `.field-info-container > .field-value`, two classes to the one on `.inspection-record__link`, so the panel's body colour won on specificity alone. Scoped to match rather than forced with !important.
The vehicle overlay answers what happened to one truck; a form had no way to answer what it had collected. Its panel now has a Submissions tab beside the overview, listing what was filed against it, newest first, and opening any of them. Every row shares the form, so naming it on each would be dead weight: the vehicle leads instead, with the driver, the time and any failure count beside it. Same blocks as the vehicle's history — it is the same object seen from the other side — so no new styling. The tab list moved onto the controller. It was declared inline in the template, which was fine for one tab and not for two. It filters on `inspection_form_uuid`, which is fillable and so binds the query; `inspection_form` alone would have been dropped in silence and listed every submission in the company.
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.
What this branch is
It started as #267 rebased onto
main, plus a driver-facing inspections API and tests for both. It has since grown into the whole inspections feature:feature/maintenance-platform-upgrade, "Add inspection maintenance platform foundation"), its commits unchanged. This PR supersedes Add inspection maintenance platform foundation #267, which can be closed once this is reviewed.v1, for the Navigator app.Opened as a draft, and now targets the
release/v0.6.66branch (#321), which merges intomainwhen the release is cut.Forms are groups of typed fields
Categoryrows withfor = custom_field_group; its fields areCustomFieldrows subjected to the form (for = fleetops_inspection_form). Eleven field types:pass-fail,input,textarea,number,select,radio-button,boolean,date-picker,date-time-input,file-upload,signature. Each group carries agrid_sizefor the console layout.custom_field_values, one per field.item_resultsare derived from the pass-fail answers. The flatitem_resultsbody is still accepted on the authenticated doors, for first-cut clients.file:<uuid>, the platform's own convention: base64 from the driver app is stored on submit, and a console upload is referenced as soon as it is picked.2026_09_10_000001_convert_inspection_form_items_to_field_groupsconverts first-cut forms.frequencyis retired from the model, the v1 resource, the report schema and the console; nothing scheduled an inspection from it. The database column is left in place deliberately: the create migration has already run on live instances, and a later migration can drop it.Driver API (
v1)Every lookup is scoped to the API key's company, and a form that is not published answers 404.
GET/v1/inspection-formsvehicle=returns forms for that vehicle or organisation-wide ones;type=;limit.GET/v1/inspection-forms/{id}grouped_fields.POST/v1/inspectionscustom_field_values(or first-cutitem_results),inspection_form,driver, optionalvehicleandstarted_at. Recordssubmitted_by_uuidas the driver's user. HonoursIdempotency-Key.GET/v1/inspectionsdriver,vehicle,type,result,status; newest first.GET/v1/inspections/{id}GET/v1/vehicles/{id}/inspectionsAnswers were dropped by validation, now fixed. The submitter's rules had no rule for an answer's
value, and Laravel'svalidate()drops an array's unruled keys once nested rules exist, so everycustom_field_valuesanswer reached the submitter empty, here and on the public link. A failed check carrying its photo was refused for having none. Found testing a public link;valueandcustom_field_uuidnow have rules, confirmed with Laravel's validator before and after, andInspectionSubmitterRulesTestcovers it.vehicleomitted on submit falls back to the driver's assigned vehicle.Idempotency-Keyis stored on the submission's meta and a replay for the same driver answers with the first submission; it is a read-then-write, so two replays in the same instant could still both file.Public inspection link
A link is minted from the console for one published form. It can be assigned to any user in the organisation and made for a driver and a vehicle; all three are optional and independent, because anyone in the organisation may complete an inspection. The recipient opens
/~/inspection?id=…&token=…, which the console's top-levelvirtualroute renders outside the console's chrome and auth gate.GET/public/inspections/forms/{id}?token=identitynaming the assignee, driver and vehicle. The driver's phone number is no longer included.POST/public/inspections/forms/{id}/filesPOST/public/inspections/forms/{id}/submitThese routes are mounted at the application root (
fleetops.api.routing.prefixis null), not underfleet-ops. Every one of them takes the link's PIN in anX-Inspection-Pinheader.They have no session, but their only consumer is the console's public inspection page, so they are treated as internal: they are not documented in the Postman collection, which covers the consumable API. Integrations file inspections through
POST /v1/inspections.2026_09_10_000002_add_token_to_inspection_links) so the console can list a link and copy it again; links minted before that migration are listed without a URL.ForceJsonResponsemiddleware. The platform's fetch service sendsAccept: */*, so a refused submission was answered with Laravel's redirect back to the page, a bare 302 the page could not read, instead of the reasons it was refused.random_int, kept as a bcrypt hash for checking and encrypted, like the token, so the console can show it again (2026_09_11_000001_add_assignee_and_pin_to_inspection_links). The public page asks for it before it shows anything, even the form's name. A missing or wrong PIN answers 403 withpin_requiredand, when wrong,attempts_left; the fifth wrong PIN locks the link (statuslocked, 403 withlocked: true). A wrong guess is counted with an atomic increment, and a right one clears the count. Links minted before this migration have no PIN and still open without one. The PIN travels in a header rather than the query string, to keep it out of URLs and access logs; apinfield is accepted too.Utils::consoleUrl. SMS goes through the platform'sSmsServicewith the organisation's alphanumeric sender, the way its verification texts do. Email is theInspectionLinkPinMailmailable, sent synchronously so the PIN never sits in a queue payload, with the PIN kept out of its subject line. A delivery that is impossible (no recipient, or no phone or email on file) is refused with 422 before the link is minted; one that fails in transit is reported back and does not undo the link.POST int/v1/inspection-forms/{id}/links/{linkId}/send-pinwithviasends it again.meta.completed_by_name, besidemeta.pin_verified. A submission filed from the console is now credited to the signed-in user; before, it was credited to nobody. The record's overview shows Submitted By, noting a public link, whether a PIN was checked, and a typed name that differs from the account.SelectOptionprimitive withSelectOption::User,::Driverand::Vehicle, which take the record as@option, so they serve as a PowerSelect@selectedItemComponentas well as inside an option block. They live in FleetOps for now, to move to ember-ui later.Security fixes
item_resultsbody, which stores photo URLs as given; each of these answers 422.photo.phpname must not decide the stored extension.Console
FleetbaseAttribution.[data-columns]rules on specificity, so authored two- and three-column groups stayed in two narrow columns on a phone. The foot's four tallies wrap two by two there, where "Outstanding" had run past the edge. Measured in a render harness at 375px and 768px: nothing overflows the sheet at either width. The public page has tighter padding on a phone, a PIN screen sized for a thumb with a numeric keypad and one-time-code autofill, and a submit bar that stacks with a full-width button.Companion PRs and merge order
frequencyattribute.v1driver inspection requests. The public link routes are deliberately left out.v1API.Suggested order: this and fleetops-data#77, then fleetbase#652, then postman#60. This PR's contract workflow runs the collection at
postman-ref: main, so the new requests are only exercised once postman#60 has merged, or for one run withpostman-ref: feat/driver-inspections.CI
v1routes, the vehicle history, the console's form and submission routes, and the public link routes. A refused request is a 422 with its reasons, where it used to be a redirect.issues.locationis a spatial column with no default, andcreateIssueFromFailures()left it out, so MySQL refused the insert (1364) and filing an inspection with a failed item answered 500. The issue now takes where the failure was reported: the submission's coordinates, else the vehicle's last known position, else the driver's, else an empty point. Found by the PHP SDK's live contract run in release: add inspections API in PHP SDK 1.4.0 fleetbase-php#15. No test caught it because the harnesses stubbedST_GeomFromTextto hand back the WKT it was given, so a stored point could never be read back; the stub now answers as MySQL does.text/plain, so the API received no fields; Send Submit an Inspection's rewritten body as JSON postman#61 sends it as JSON and has merged.Tests and coverage
For review
rawError. Without it a failed response carrying a stringerrorrejects as a bareErrorof that message, which droppedpin_required: the first cut showed the PIN prompt as an error with nowhere to type it.