Skip to content

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

Draft
roncodes wants to merge 42 commits into
release/v0.6.66from
feature/inspections-driver-api
Draft

Add the driver-facing inspections API, and cover the inspection platform (#267 rebased)#319
roncodes wants to merge 42 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.
  • The Postman contract check waits on Send Submit an Inspection's rewritten body as JSON postman#61. "Submit an Inspection", merged in postman#60, sent its rebuilt body as text/plain, so the API received no fields. postman#61 sends it as JSON. The contract runs the collection at postman-ref: main, so this check goes green once v0.4.4 #61 merges.

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 (0d11583).
⚠️ 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     10755    +481     
=====================================================
  Files                    545       565     +20     
  Lines                  39684     41512   +1828     
=====================================================
+ Hits                   39684     41512   +1828     
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.
roncodes and others added 11 commits September 10, 2026 18:04
Two faults, both between the page and its data.

The page asked for `fleet-ops/public/inspections/forms/{id}` and got a
404 whose body was Laravel's "There is nothing to see here" — no route
matched. FleetOps mounts its API at the application root:
`fleetops.api.routing.prefix` is null, which is why its consumable
routes are `/v1/...` and its internal ones `/int/v1/...` rather than
sitting under an engine name the way ledger's do. The public routes
follow it, so the namespace is `public`. Verified against a running
instance: the endpoint now answers 200 with the form, its four groups
and the link's driver, vehicle and expiry.

Behind that sat a second fault that would have shown an empty sheet on
a page that finally loaded. A form's structure arrives in two shapes:
`field_groups`, whose fields come in a sibling `fields` array joined on
`category_uuid`, and `grouped_fields`, which nests them. The normalizer
preferred `field_groups` whenever it was present — but a public payload
omits `category_uuid`, so the join matched nothing and every group came
back empty. It now takes whichever shape actually produced fields, and
falls back to `field_groups` when neither did, so a form whose groups
are laid out but still empty keeps them in the builder.

Checked against the live payload for all four shapes: public, internal,
groups-without-fields, and nothing at all.
The author's `grid_size` survives — for the fields that stay compact.
A field that needs room leaves the grid entirely and becomes a
full-width band under it: a pass-fail that has failed and now owes a
severity, a comment and photos, or a note, upload or signature that
never fitted a column. That is what stops one answer changing the shape
of another, and it needed no compromise on the authoring intent: after
promotion there is no neighbouring cell left to stretch.

A group header now carries one dot per field in answer order — filled
for a pass, red for a failure, grey for not-applicable, hollow for
unanswered, amber for a required answer still missing or a failure
still owing its comment or photo. The right of the header says what is
outstanding, or how the group is laid out when nothing is. A dot is
neutral rather than green for an answered field that is not a check: a
toggle that is off has not passed anything.

Pass, fail and not-applicable are one segmented control, and so is
severity, whose chosen grade fills red — it is a grade of failure, not
a neutral pick. Unsafe to operate is a bordered pill, because it is the
gravest thing on the sheet. Photos are 60x44 slots with a dashed slot
inviting the next one, and a single upload or signature shows the same
slot beside its label rather than a bare filename.

The foot is four tallies and, when something is wrong, a banner that
names the field instead of counting it — the unsafe failure with its
severity, and the first required answer still missing — each with a
jump that scrolls to that field and holds it for a moment. Jumping is
by id rather than a held element, because a promoted field moves
between the grid and its band as the answer changes.

Layout is measured against the sheet, not the window: it renders in a
600px overlay panel on a wide screen and full width on a phone, so a
viewport media query would collapse the columns in exactly the wrong
places. Container queries drop 3 and 4 columns to 2 below 660px and to
one below 420px.

The same sheet renders in all three places it is read: the console's
submission form, the read-only record, and the public link. The link
page's own details and sign-off sections were rebuilt in the same
language.

Checked by rendering the real LGV Daily Pre-Trip payload from a running
instance through the templates' own markup and stylesheet, in both
themes, against the concept.
`fieldMarker`, `isPromoted` and `ROOMY_FIELD_TYPES` were never written
to `utils/inspection-answers`. A scripted edit that was meant to insert
them matched nothing and reported nothing, so the group component and
`summarize` both called a function that did not exist. Every sheet threw
on render: the public link, and the console submission form the moment a
form was chosen.

They exist now, and three checks were run against the real module rather
than a restatement of its rules — which is what let this through, since
the render harness reimplemented the logic instead of importing it:

- every export the sheet imports resolves, exercised end to end on a live
  LGV Daily Pre-Trip payload;
- every `this.x` in all fourteen inspection templates resolves to a
  member of its component;
- every translation key the inspection code names exists.
Seven things reported against the sheet.

Promotion moved a field to the end of its group, so failing a check
re-sorted the group and the fields after it jumped up past it. A field
that needs room now stays exactly where it was authored and spans the
full width of the grid instead. It is still alone on its row, so it
still cannot stretch a neighbour, and nothing moves.

A failure's own comment box, photo slots and segmented controls kept the
neutral border while everything around them turned red. They take the
failure's edge colour now, so the block reads as one thing.

The sheet was forcing a height and a font size onto every `.form-input`,
`.form-select` and power-select trigger, which made an input on this
screen shorter and tighter than the same input anywhere else in
Fleetbase. That is gone; controls keep the console's own sizing, and the
segmented controls match a real control's height rather than setting
their own.

Groups sit further apart, and the group block has padding on all four
sides — the last group used to touch the divider above the tallies, and
the public link's details and sign-off panels had no bottom padding at
all.

Every field is stacked now, label above control, whatever the column
count. A one-column group used to put the label beside the control,
which read as two different forms interleaved.

The "2 col · 1 promoted" line is gone. It described the layout to
someone who can see the layout. The header keeps only what is
outstanding, and only when something is.

Checked by rendering the real LGV Daily Pre-Trip payload through the
sheet's markup and stylesheet in both themes, and by re-running the
three checks: every template reference, every local import and every
translation key resolves.
A failed pass-fail used to widen into a full-width band in place. That
still re-flowed the group every time a check failed. The failure now
keeps its cell, and its severity, unsafe flag, comment and photos open in
a flyout anchored to it. The sheet's layout depends on the form alone;
answering a field can never change it.

Notes, uploads and signatures keep their full-width band, because the
form decides their size. The read-only record keeps each defect inline,
where it is the most important thing on the page.

How the flyout behaves:

- Choosing Fail opens it, with no second click, and focuses the comment.
- A caret points at the Fail button, and the field is ringed with an
  outline, which takes no space, so opening it moves nothing either.
- It closes on Done, its close button, Escape, or a press outside it and
  its field — never on blur, because focus leaves for the native photo
  picker. Escape and Done return focus to the Fail button.
- Closing is always safe: every answer is saved as it is typed. So Done
  still closes when a required comment or photo is missing, and the chip
  it leaves says what is owed in amber instead of trapping the inspector.
- Only one is open at a time. Switching an accidental Fail back to Pass
  keeps the comment and photos.
- On a sheet narrower than 520px — the public link on a phone — the same
  content comes up as a bottom sheet with a backdrop, mounted in the root
  wormhole so it is pinned to the screen rather than to the sheet.

Placement is done here rather than with the platform's Floating, which
positions once and never follows, and FleetOps does not declare
floating-ui. The flyout lives in a layer inside the sheet, so it scrolls
with its field for free and needs no scroll listener. It goes below the
field unless only the space above can hold it: whatever hangs below can
always be scrolled to, but a panel pushed above the start of the sheet
could not be reached at all.

It is revealed once, by the least scroll that brings it fully into view
with a margin, or its title if it is taller than the view. The reveal is
worked out from layout after the opening animation finishes: measured
mid-animation it was clamped against a briefly shorter scroll area and
stopped exactly the animated 4px short. An invisible 8px spacer below
the flyout gives that margin room at the very end of the page, since an
absolutely placed box extends the scroll area but its margin does not.

A defects tray at the foot of the sheet lists every failure with its
severity, field and evidence, and each row reopens that flyout. It
replaces the separate unsafe banner.

Verified by loading the shipped modifier and stylesheet into a real
browser and checking placement, focus, scrolling with the field,
dismissal, teardown, both sides of the flip rule, the reveal and its
margin, a flyout taller than the view, and the end of the page; and by
re-running the template-reference, local-import and translation sweeps.
Three things reported against the inspection console.

The failure flyout had lost most of its red. The panel was the sheet's
grey with a red edge, and the comment box stayed grey whatever the
flyout set. The cause was specificity: ember-ui styles every console
input with `body[data-theme='dark'] .fleetbase-console .form-input`,
which outranks `.inspection-flyout .form-input`, so the box always fell
back to the platform grey. The earlier attempt to redden the old band's
comment box lost the same way.

The flyout is now the failure throughout: a red-tinted panel and caret,
red dividers, a red-edged comment box with its own field colour, focus
ring and placeholder, red-hatched photo slots and a red-dashed slot for
the next photo, and the unsafe switch red whether it is on or off. The
comment selector is deliberately heavier than ember-ui's, and every
colour comes from the theme tokens. The light theme's failure fill is
now a pale red rather than transparent, so a failure stands out in both.

Verified in a browser against a reproduction of ember-ui's own rules,
loaded after the sheet's stylesheet so that any tie would go to them:
every computed colour matches, in both themes.

A link generated from the header did not appear in the details panel's
list until a reload. Only the list inside the generate modal was told a
link had been minted. The signal now lives on the form actions service,
so every open list — the modal's and the details panel's — reloads.

The public page's "Powered by" line is now ember-ui's
`FleetbaseAttribution`, which also honours the host's switch for
turning attribution off.
Uploads. A public inspection link could not take a photo or a
signature: the console uploads through the platform's file endpoint,
which needs a session, and a link has none. The link now has its own,
`POST /public/inspections/forms/{id}/files`. It checks the link exactly
as submitting does, takes images only, up to 10 MB, caps one link at 40
files, and tags every file with the link it came through. The public
page passes the sheet an uploader that posts there with the token, so
photo slots and signature fields work on a phone as they do in the
console.

A security fix this surfaced, in the existing code. When a submission
was saved it claimed every file its answers referenced, provided the
file was not attached to anything yet — with no check that the file
belonged to the same company. Looking a file up by public id was
unscoped too. A submitter who knew another company's unattached file id
could have it attached and then read its URL back from the submission.
Both lookups are now scoped to the submission's company, and a reference
that names no file of the company's is dropped instead of kept. A
submission through a public link goes further: it may reference only
files uploaded through that same link, may not use an outside URL as a
photo, and may not use the older flat checklist, which stores photo URLs
exactly as given. Any of those is refused with a 422 rather than
silently losing a photo it named.

The four gaps:

- Rate limits. The public routes sat outside every middleware group.
  They now allow 60 requests a minute per address, and uploads 20, each
  limiter with its own prefix so they do not share a counter.
- Single use had a race. The link was checked before the submission and
  marked used after it, so two submits at the same moment could both
  succeed. It is now claimed with one conditional update, inside the
  same transaction as the submission: the second submit gets a 409 and
  writes nothing, and a submission that fails to save gives the link
  back.
- No default expiry. A link with no expiry stayed valid until used or
  revoked. It now lasts 72 hours unless chosen otherwise, and an expiry
  in the past is refused. The generate modal shows the default and sends
  the chosen time as an instant — it used to send local time with no
  zone, which the server read in its own.
- The driver's phone number was in the public payload. It is gone; the
  name is enough for whoever holds the link to know who it is for.

Tests: the phone assertion is updated, two file-store expectations now
say a reference to an unknown file is dropped, and a new test covers
company and link scoping. None of the suite has been run.
The public upload endpoint validated a file's content but stored it
under the extension from the name the device sent. An image crafted to
also be valid PHP and uploaded as `photo.php` passes a content check,
and on a web-served local disk a `.php` file is an invitation to run
it. The platform's own uploader has the same pattern, but it sits
behind a signed-in session; a public endpoint cannot rely on that.

The extension now comes from the type the server detected in the bytes,
from a fixed map of the image types the endpoint accepts. The name the
device sent is still kept as the file's display name only.
A link can be assigned to any user in the organisation. The assignee, driver
and vehicle are each optional and independent.

Every new link carries a six-digit PIN: bcrypt-hashed for checking, and
encrypted so the console can show it again. The public page asks for it
before showing anything. A missing or wrong PIN answers 403 with
pin_required and attempts_left, and the fifth wrong PIN locks the link.
The PIN travels in an X-Inspection-Pin header, not the URL. Links minted
before this ask for none.

The PIN can be emailed or texted to the assignee, or else to the driver's
user, and resent from the link list. The message carries the PIN, never
the link. A delivery that cannot happen is refused before the link is
minted; one that fails in transit is reported and does not undo the link.

A public submission is credited to the assignee, or else the driver's
user, keeping the typed name and whether a PIN was checked. A console
submission is credited to the signed-in user. The record shows
Submitted By.

On a phone-width sheet every grid now stacks: the collapse rule had lost
to the [data-columns] rules on specificity. The tallies sit two by two,
and the public page has a thumb-sized PIN screen and a stacked submit
bar.
… PIN

The public page never showed its PIN screen. The fetch service rejects a
failed response carrying a string error as a bare Error of that message,
dropping pin_required, so the prompt appeared as an error with nowhere to
type. Public requests now use rawError and read the body.

Email and SMS stayed disabled in the generate modal after picking a user.
Its recipient getter read the plain form state natively, which is not
tracked; it reads through get() now.

The email and SMS now carry the link as well as the PIN, so the recipient
can open the inspection from the message. The email has an Open button and
the address written out.

The user, driver and vehicle selects show a photo, the name, and a line of
detail: email and phone, phone and email, or the plate (else VIN, serial
number or call sign). A SelectOption primitive and SelectOption::User,
::Driver and ::Vehicle take the record as @option, so they also serve as
a compact @selectedItemComponent.

Spinner containers no longer centre what the Spinner already centres.
The link list draws its own row padding, so the panel's body padding
indented it twice.
@roncodes
roncodes changed the base branch from main to release/v0.6.66 September 11, 2026 08:45
A public link submission came back as a 302. Two faults:

The submitter's rules had no rule for an answer's value. validate()
returns only ruled keys, and once nested rules exist an array's other keys
are dropped, so every custom_field_values answer reached the submitter
empty. A failed check that carried its photo was refused as having none.
The v1 driver endpoint uses the same rules and lost answers the same way.
value and custom_field_uuid now have rules, and a test runs the rules
through Laravel's validator.

That refusal reached the page as a redirect: the fetch service sends
Accept: */*, and Laravel answers a failed validation on a non-JSON request
by redirecting back. The public routes now run behind ForceJsonResponse,
and the page asks for JSON as well.
…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.
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