Skip to content

Add the driver-facing inspections API, and cover the inspection platform (#267 rebased) - #319

Merged
roncodes merged 59 commits into
release/v0.6.66from
feature/inspections-driver-api
Sep 14, 2026
Merged

Add the driver-facing inspections API, and cover the inspection platform (#267 rebased)#319
roncodes merged 59 commits into
release/v0.6.66from
feature/inspections-driver-api

Conversation

@roncodes

@roncodes roncodes commented Sep 9, 2026

Copy link
Copy Markdown
Member

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:

  1. Add inspection maintenance platform foundation #267 rebased (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.
  2. A driver-facing inspections API under v1, for the Navigator app.
  3. A second cut of the data model: a form is groups of typed fields built on the platform's custom-field system, not a flat checklist.
  4. The console rebuilt: a form builder, and one inspection sheet used everywhere a form is answered or read back.
  5. The public inspection link made usable and hardened: it renders outside the console, takes photos, and is rate limited, single-use by default, expiring and revocable.
  6. Links for anyone in the organisation, behind a PIN: a link can be assigned to any user, carries a six-digit PIN, and can be emailed or texted to them with the PIN; the submission records who it came from.

Opened as a draft, and now targets the release/v0.6.66 branch (#321), which merges into main when the release is cut.

Forms are groups of typed fields

  • A form's groups are Category rows with for = custom_field_group; its fields are CustomField rows 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 a grid_size for the console layout.
  • A filled-in form arrives as custom_field_values, one per field. item_results are derived from the pass-fail answers. The flat item_results body is still accepted on the authenticated doors, for first-cut clients.
  • A photo or signature is stored as a platform file and referenced as 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_groups converts first-cut forms.
  • frequency is 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.

Method Path Behaviour
GET /v1/inspection-forms Published forms only. vehicle= returns forms for that vehicle or organisation-wide ones; type=; limit.
GET /v1/inspection-forms/{id} One published form with its grouped_fields.
POST /v1/inspections custom_field_values (or first-cut item_results), inspection_form, driver, optional vehicle and started_at. Records submitted_by_uuid as the driver's user. Honours Idempotency-Key.
GET /v1/inspections Filters driver, vehicle, type, result, status; newest first.
GET /v1/inspections/{id} One submission with its answers, item results, issue and work order.
GET /v1/vehicles/{id}/inspections The vehicle's history.

Answers were dropped by validation, now fixed. The submitter's rules had no rule for an answer's value, and Laravel's validate() drops an array's unruled keys once nested rules exist, so every custom_field_values answer 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; value and custom_field_uuid now have rules, confirmed with Laravel's validator before and after, and InspectionSubmitterRulesTest covers it.

vehicle omitted on submit falls back to the driver's assigned vehicle. Idempotency-Key is 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-level virtual route renders outside the console's chrome and auth gate.

Method Path Behaviour
GET /public/inspections/forms/{id}?token= The form, and an identity naming the assignee, driver and vehicle. The driver's phone number is no longer included.
POST /public/inspections/forms/{id}/files New. Uploads a photo or signature through the link.
POST /public/inspections/forms/{id}/submit Files the inspection.

These routes are mounted at the application root (fleetops.api.routing.prefix is null), not under fleet-ops. Every one of them takes the link's PIN in an X-Inspection-Pin header.

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.

  • Token: 64 random characters, looked up by its sha256 hash. It is also kept encrypted at rest (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.
  • Rate limits: 60 requests a minute per address on every public route, 20 a minute on uploads, each with its own limiter.
  • Always JSON: the public routes run behind a ForceJsonResponse middleware. The platform's fetch service sends Accept: */*, 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.
  • Single use, the default, is claimed with one conditional update inside the submission's transaction. A second submit at the same moment answers 409 and writes nothing; a submission that fails to save gives the link back.
  • Expiry: 72 hours unless chosen; an expiry in the past is refused. The generate modal shows the default and sends the chosen time with its timezone.
  • Uploads: images only, up to 10 MB, 40 per link. The stored extension comes from the type detected in the bytes, never from the device's file name. Each file is tagged with the link it came through.
  • PIN: every link minted now carries a six-digit PIN from 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 with pin_required and, when wrong, attempts_left; the fifth wrong PIN locks the link (status locked, 403 with locked: 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; a pin field is accepted too.
  • Delivery: the generate modal can send the link and its PIN by email or SMS to the user the link is assigned to, or failing that the driver's account, so they can open the inspection straight from the message. The email has an "Open the inspection" button, with the address written out beneath it, and the PIN to enter. The PIN still stops anyone holding only the link, such as one copied or forwarded on its own; the message itself is enough to open it, and says not to forward it. The link is built on the console's host with Utils::consoleUrl. SMS goes through the platform's SmsService with the organisation's alphanumeric sender, the way its verification texts do. Email is the InspectionLinkPinMail mailable, 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-pin with via sends it again.
  • Who submitted: a public submission is credited to the link's assignee, or else the driver's user, and keeps the name typed at sign-off in meta.completed_by_name, beside meta.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.
  • Console: the generate modal and the form's details panel both list the form's links, with the URL to copy, who and what each is for, when it was made and last opened, and whether it is active, expired, used, revoked or locked. Each active link shows its PIN to copy, Email link and Text link when the recipient can receive them, when the PIN was last sent, and how many wrong PINs have been tried. Links can be revoked. The modal has Assign to (any user), the optional driver and vehicle, the expiry, and Send the link and PIN (don't send, email, or text), and it shows the new link and PIN once they are generated.
  • Select options: the modal's user, driver and vehicle selects show a photo with the name over a line of detail: a user's email and phone, a driver's phone and email, and a vehicle's plate, or else its VIN, serial number or call sign, labelled. The closed select shows the same on one line. They are built on a new SelectOption primitive with SelectOption::User, ::Driver and ::Vehicle, which take the record as @option, so they serve as a PowerSelect @selectedItemComponent as well as inside an option block. They live in FleetOps for now, to move to ember-ui later.

Security fixes

  • File references were not scoped to a company. Saving a submission claimed every unattached file its answers referenced, and a lookup by public id was unscoped, so a submitter who knew another company's file id could attach it and read its URL back from the submission. Both are now scoped to the submission's company, and a reference to no file of the company's is dropped. A submission through a public link may reference only files uploaded through that same link, may not use an outside URL as a photo, and may not use the flat item_results body, which stores photo URLs as given; each of these answers 422.
  • Upload extension: see above. A content check passes an image that is also valid PHP, so the device's photo.php name must not decide the stored extension.

Console

  • Form builder: groups with a grid size, typed fields edited in a right-side panel, form settings (issue and work order on failure, signature required), and option labels rather than raw values in tables and details.
  • Inspection sheet: fields sit in the author's grid; a note, upload or signature spans the grid in place; a failed check keeps its cell and opens its severity, unsafe flag, comment and photos in a flyout anchored to it, or a bottom sheet on a narrow screen, so no answer changes the layout. Group headers show a status dot per field; the foot has tallies and a defects tray. The same sheet renders the console's submission form, the read-only record and the public page.
  • The public page uses ember-ui's FleetbaseAttribution.
  • Phones and tablets: a phone-width sheet (420px or less) now stacks every grid into one column. The rule had lost to the [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

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 with postman-ref: feat/driver-inspections.

CI

  • Ember CI passes. Stylelint wanted single-quoted font names, range media queries and one duplicate selector merged, and prettier reflowed two sidebar menu items and one option string.
  • PHP CI passes, including the 100% coverage gate:
    • the inspection test harnesses gained an encrypter, for the encrypted token and PIN columns;
    • the public-link submit test was brought up to date with the form-fields body;
    • the route registration test now replays resource route callbacks and chained middleware;
    • new tests cover the PIN, delivery, send-pin, upload and JSON responses.
  • Every inspection route answers in JSON: the v1 routes, 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.
  • A failed inspection could not raise its issue. issues.location is a spatial column with no default, and createIssueFromFailures() 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 stubbed ST_GeomFromText to hand back the WKT it was given, so a stored point could never be read back; the stub now answers as MySQL does.
  • The live Postman contract passes, exercising a driver submit end to end for the first time. "Submit an Inspection", merged in postman#60, had sent its rebuilt body as 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

  • First cut: measured locally under PCOV with CI's own sequence, 443 test files and 2,600 tests passed, line coverage 99.99%. The three lines short are in files this branch does not touch.
  • Since the second cut: the suite has not been re-run, and the 100% coverage gate has not been checked. Tests were updated where behaviour deliberately changed (the public identity, file-reference handling, the link columns and path, the assignee and PIN columns) and two were added: company and link file scoping, and the PIN's check, attempt count and lockout. PIN delivery, the send-pin endpoint and the public PIN responses have no tests yet. The new upload endpoint, the atomic claim and the second-cut console-facing PHP have little or no coverage yet, so expect the gate to fail until tests are added.

For review

  • Keeping the link token encrypted at rest, so links can be re-read, is a deliberate trade: a link is a capability URL for one form, not a credential.
  • Files uploaded through a link that is never submitted are not cleaned up.
  • A PIN is six digits, so a link locks after five of a million possibilities have been tried, on top of the per-address rate limit. A locked link cannot be unlocked; a new one is generated instead.
  • The PIN is kept encrypted beside its hash so the console can show it and send it again, the same trade as the link token.
  • No real email or SMS was sent while building this; delivery has only been checked for syntax.
  • The public page requests with the fetch service's rawError. Without it a failed response carrying a string error rejects as a bare Error of that message, which dropped pin_required: the first cut showed the PIN prompt as an error with nowhere to type it.

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

codecov Bot commented Sep 9, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (2980fb3) to head (87742d3).
⚠️ Report is 3 commits behind head on release/v0.6.66.

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

…, 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.
@roncodes

Copy link
Copy Markdown
Member Author

Console fixes pushed (5e98475), after a first run against a live console:

  • Both form components wrote to the model during render. inspection-form/form and inspection-submission/form assigned @resource.items / item_results (and the counts) in their constructors, which Glimmer refuses once the template has read the attribute in the same pass — the "already used previously in the same computation" assertion on opening New. The model attribute is now the single source of truth, read through getters and replaced only from actions.
  • Text fields never updated. {{on "input" (fn this.updateItem index "label" item.label)}} passed the value as rendered, so typing set the old value back. Inputs now hand the DOM event to the updater. Every control also honours cannot-write.
  • Sidebar showed "Missing translation". The two items were literal strings where createItem expects a key; they are menu.inspection-forms / menu.inspections, added to all nine locales.

Verified: ember-template-lint and eslint clean on the changed files; every locale still parses. Not yet re-run in a browser — the checkout with a console build is the reviewer's.

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.
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
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.
@roncodes
roncodes merged commit 60a9cff into release/v0.6.66 Sep 14, 2026
8 checks passed
@roncodes
roncodes deleted the feature/inspections-driver-api branch September 14, 2026 11:35
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