Add the driver-facing inspections API, and cover the inspection platform (#267 rebased) - #319
Draft
roncodes wants to merge 42 commits into
Draft
Add the driver-facing inspections API, and cover the inspection platform (#267 rebased)#319roncodes wants to merge 42 commits into
roncodes wants to merge 42 commits into
Conversation
The inspection platform (#267) shipped a public, tokenised link and the console's internal CRUD, and nothing a driver's app could authenticate against: a driver who inspects the same truck every morning had no way to file a DVIR except a link minted for them each time. This adds the consumable surface under v1: GET /v1/inspection-forms published forms; vehicle=, type= GET /v1/inspection-forms/{id} one published form POST /v1/inspections file against a form GET /v1/inspections driver=, vehicle=, type=, result=, status= GET /v1/inspections/{id} GET /v1/vehicles/{id}/inspections Every lookup is scoped to the API key's company, and an unpublished form answers 404 rather than 403: to a driver a form that cannot be filled in does not exist. The submit body is exactly what the public link accepts, plus the form, driver and vehicle the link already knew. Both doors now go through one InspectionSubmitter, so they cannot drift on what a submission is. The app queues a submit while offline and replays it; a replay carrying the same Idempotency-Key answers with the submission the first attempt filed rather than filing twice. The platform has no idempotency middleware, so the key is kept on the submission's meta and looked up there. Photos are validated as base64 or a URL by a small Rule rather than a regex: a DVIR photo is easily a megabyte of base64, and a backtracking pattern over that only fails on a real handset. Coverage: the platform arrived with no tests, and the gate is 100% of server/src. The models, the public and internal controllers, the resources, the hub and report schema changes and the new API are covered against an in-memory SQLite database, as the trailer lifecycle tests are. Four tests on main asserted the old contract and are updated to the new one, kept exact: the maintenance hub leads its KPIs with failed inspections and adds an Inspections section and doc; a hub with no published form is told to create one; the report schema registers work orders, maintenance history and inspections; a closed work order records its maintenance as `completed`. The migration is re-stamped to today, content unchanged.
This was referenced Sep 9, 2026
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## release/v0.6.66 #319 +/- ##
=====================================================
Coverage 100.00% 100.00%
- Complexity 10274 10755 +481
=====================================================
Files 545 565 +20
Lines 39684 41512 +1828
=====================================================
+ Hits 39684 41512 +1828
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
…, and translate the sidebar Both form components assigned to @resource in their constructors, which Glimmer refuses once the template has read the attribute in the same render pass ("You attempted to update items ... already used in the same computation"). The model's items / item_results are now the single source of truth, read through getters and replaced only from actions. Text fields update from the DOM event rather than passing the stale rendered value, and every control honours cannot-write. The sidebar items are translation keys, added to all nine locales.
Member
Author
|
Console fixes pushed (5e98475), after a first run against a live console:
Verified: |
The first cut stored a form as a JSON list of pass/fail items. That is a checklist. Fleetio parity — and the fliit module that already has it — wants groups of typed fields, built from the platform's own custom-field system, so an inspection is a form filled in rather than a list ticked. A form's groups are platform categories owned by the form; its fields are custom fields whose subject is the form, filed under `fleetops_inspection_form` so a field the console's generic panel adds to the form record is not mistaken for one a driver answers. Both come back as `grouped_fields`, which is what the app codes against. A submission answers through custom-field values. `inspection_item_results` stays: issues, work orders and the history are built from it, so every pass-fail value is mirrored into a result row keyed the way the app keys it. "Not applicable" is carried through as its own status rather than flattened into a pass. Photos and signatures arrive as base64 — the app is offline-first and cannot upload first — and are stored as platform files, leaving `file:<uuid>` as the value, which is the platform's convention. The submission resource answers `custom_field_values` with each field's identity beside its answer and every file reference resolved, plus the `files` the inspection carries. The rules a field insists on when it fails are checked before any photo is stored, so a refusal leaves nothing behind. The tests' in-memory schema gains `categories`, the real `custom_fields` columns and the file columns the projection reads.
The console writes a form's structure by posting the whole draft — the builder lays a form out before the record exists, so the structure has to arrive with the save that creates it. `InspectionFormController` takes it under `inspection_form.field_groups` (and fliit's `draft`, so a form authored there still saves) and prunes what the post no longer lists. `InspectionSubmissionController` accepts `custom_field_values` on the same terms as the driver API, so the console and the app write the same rows, and still accepts `item_results` for a legacy checklist. Two bugs found on the way. `Request::array()` is a core-api macro taking exactly one argument, so every `$request->array($a, $request->array($b))` in this controller silently discarded its fallback and the flat spelling never worked; both reads now go through a helper that tries each key. And a field written straight into the table by a seed carries no `component`, so the resource names one from the type. `InspectionExport` gives compliance the spreadsheet it asks for: one row per submission, defects named, follow-up and out-of-service called out, behind the usual `export` route. The migration folds a first cut's `items` into a "Checklist" group of pass-fail fields, skipping any form already built with fields so it can be run again safely.
Every new line, in the harness's own style: an in-memory database with the platform's `categories`, `custom_fields` and `custom_field_values` beside the inspection tables, and a real local filesystem so the file store can be watched turning a driver's base64 into a platform file and the resource resolving it back. What is pinned here is the contract the app codes against: `grouped_fields` with a field's id, type, options and meta; `custom_field_values` accepted by uuid or by name; a pass-fail answer read whatever shape it arrives in, "not applicable" kept as its own status; the comment and photo a field insists on refusing the whole set before any file is written; a meter and a boolean answered back as a number and a boolean rather than the strings the value column stores. One behaviour changed to match: a number value now leaves the resource as a number. The `custom_field_values` guard in the submission resource went — `withCustomFields()` has always loaded that relation, so it was dead.
The console side of the second cut. A form is built in `inspection-form/builder`: field groups with a grid size, fields added, edited, reordered and deleted through a FleetOps field editor that offers the inspection type list and, for `pass-fail`, the On fail section the driver app reads — default severity, whether a photo or a comment is required, whether the defect parks the truck, and the instructions the driver sees. A field is answered through `inspection-field/input`. `pass-fail`, `signature` and the inspection flavour of `file-upload` are FleetOps' own, as are `textarea`, `number` and `boolean` — the platform's type map has no component for those three. Everything else is handed to the platform's `custom-field/input`. The record gets Overview, Photos and Audit tabs; Photos is `ModelMultiFileUpload` against `inspection_photo`, Audit the platform's `ActivityLog`. The builder holds the structure as a draft of plain objects, so a form can be laid out before the record exists and the whole thing is posted with the save. It has to be posted separately: `inspection-form` and `inspection-submission` live in `@fleetbase/fleetops-data` and declare no attribute for the structure or the answers, so Ember Data drops both in both directions. Until that package gains them, the structure and the answers ride their own request to the internal endpoint, which is also the only spelling the server reads. Glimmer discipline throughout: nothing writes to `@resource` during render, text inputs update from the DOM event, and every control honours `cannot-write`.
Two things the console does that the driver app does not. It uploads a photo the moment it is picked and keeps the reference the upload answered with, which names the file by its public id — `file:file_…`, not `file:<uuid>`. That reference was passed through untouched, so nothing downstream could resolve it: the file was never claimed by the submission and the resource handed the raw string back. A `file:` reference that does not name a uuid is now looked up and rewritten to the one it does. And it can clear a field. `custom_field_values.value` is a NOT NULL column, so an answer that is nothing is not an answer — it deletes the row instead of writing a null the column will not take.
Three components kick a load off in the next runloop; if the panel closes first the task is performed on something already torn down. And the form the answering screen assigns writes its own uuid into the column, never the id the console addresses it by.
A severity that is not one of the four the field editor offers has no key, and asking for one puts "Missing translation" on the record. Fall back to the value itself.
`valueTypeFor` in the app's useInspections sends `datetime` for a date-time field and `date` for a date; the console said `date` for both. The server takes either, but the two clients should file the same row.
buildSubmission in the app's useInspections.ts emits both bodies at once, every pass-fail answer carrying not_applicable and unsafe, photos and the signature as bare base64. That exact shape now goes through the submitter in a test, so a change here that the app would not survive fails here first.
Same as the others: the details panels kick their load off in the next runloop, and closing the panel first would perform a task on a component already torn down.
The tokenised link built its checklist from `items`, which a form built in the new builder does not have — so the link showed an empty form and would not submit. It now falls back to the pass/fail fields of `grouped_fields`, keyed the way the server keys a derived result, so a link submission and an app submission name the same item. The other field types are still not offered there: a public link is a checklist for a contractor, not the app.
`InspectionFormSync` read every custom field subjected to the form, not just the ones filed under the inspection kind. The builder posts the whole form and prunes what the post no longer lists, so a custom field the console's generic panel had added to the form *record* was deleted by the next save of the structure. Both the read and the prune are now scoped.
The form's own details view asked for a translation of whatever severity a field carries, which for a field converted from a hand-written first-cut item can be anything. Same fallback as the record.
…d editor
Six things from the first real run of the builder:
- Settings rendered as a raw metadata editor, collapsed. They are three
known switches — two the server reads when a submission has failures, one
the driver app reads before it will submit — so they are checkboxes in an
unlabelled input group, and the panel opens.
- Type and status showed smart-humanize output ("Dvir"). They now come from
labelled option lists in fleet-ops-options, so DVIR reads as DVIR.
- Six inspection types were missing: pre-operational, post-operational,
safety inspection, maintenance inspection, damage assessment and annual
inspection.
- Every PowerSelect is wrapped in the fleetbase-model-select /
fleetbase-power-select / ember-model-select div so it renders as the rest
of the console does.
- The field editor opens as a right-side overlay over the form panel rather
than a modal, sized xs so the form stays visible beside it. It holds the
field in tracked state and writes through to the overlay's shared handle,
which is what the builder reads back on save.
- Frequency is dropped from the form. The column and the API field stay, but
nothing schedules an inspection from it, so the dropdown only asked the
author a question the product does not act on.
…sing focus Three from the second run: - Collapsing the Form Builder panel emptied the form. ContentPanel unrenders its body when closed, so the builder component was destroyed and the draft went with it; reopening rebuilt it from nothing. The draft now lives on the controller, which the collapse does not touch, and the builder renders @groups and reports changes through @onchange. The load is skipped when the controller already holds a structure, so reopening cannot overwrite unsaved edits with server state either. - Typing in a group's name or description lost focus after each keystroke. The iteration was unkeyed, so replacing the edited group object changed its identity and Glimmer rebuilt the input. Both loops are keyed on uuid, which every group and field is guaranteed. The two inputs also take their value once on insert rather than re-binding it, so a mid-word edit cannot move the caret to the end. - The settings checkbox description sat tight against its label; it now has mt-1.
The panel's Save was disabled for good. Without pojoResource the header falls through to `cannot-write @resource`, and the overlay carries a plain object rather than an Ember Data record, so the permission check had nothing to judge and denies by default. The field is a POJO, which is exactly what pojoResource is for. Required and editable now stack with spacing rather than sitting in a row.
Sharing one row with four buttons truncated every label to a few characters in a two- or three-column group — unreadable, and impossible to keep track of while building a form. The name takes its own line, with the required mark and the type pill beside it and the actions right-aligned underneath. The pill no longer truncates; the label is the only thing that gives way.
Five things reported while testing inspection forms in the console. Publish stayed in the header after a form was published, so the only thing pressing it could do was report that the form was already published. It now appears only while the form can be published, and as `type="success"`. Generate Link moves the other way: it needs a published form, so it appears at the same moment Publish leaves. A field's machine name came from `dasherize`, which rewrites spaces and underscores and leaves everything else alone — "Sidewall condition, offside rear" became `sidewall-condition,-offside-rear`. The name is an identifier: it travels as an item result's `item_key` and is what a report groups on. It is now a real slug, with anything that is not a letter or a digit acting as a separator. `frequency` is gone. It was inherited metadata: a column, an attribute and a filter that nothing scheduled an inspection from. It is removed from the v1 resource, the model's fillable and filter params, the report schema, the index column and query param, the details panel and the create defaults. The database column is deliberately left in place — the create migration has already run on live instances, and a dead nullable column is cheaper than editing a migration mid-test. A later migration can drop it. Type rendered through `smart-humanize`, so a form of type `dvir` read "Dvir". Option lists already carry the labels, so this adds a table cell that reads them: `table/cell/fleet-ops-option` takes the list name from the column's `optionsKey` and falls back to humanizing anything with no matching option, so a retired value is still legible. The index Type column and the details panel's type and status now use it. The Created column's own bug is in fleetops-data, where the model hands out a raw `Date` — fleetbase/fleetops-data#77 fixes that.
Three things reported while testing inspections in the console.
**The sheet.** An inspection form rendered as a bag of inputs: groups
were collapsible content panels, fields sat in a CSS grid, some had a
border and some did not, and failing a check grew that grid cell — which
stretched the row and left its neighbour floating in white space. It was
not a form anyone would want to work down.
It is now one sheet. Every field is a row of the same shape: what is
being checked on the left, the control on the right, a full-width
control under its label when it needs one. A failure opens its defect
block below its own row, so answering one line can never change the
shape of another. Groups are plain sections that are always open — a
panel that shuts invites an inspector not to read a line they have to
read — with the one number that matters in the header: what failed, or
what is still owed. A running total sits at the foot, and an unsafe-to-
operate answer is called out there. A failed row is marked down its own
edge so a long sheet can be scanned.
Pass / fail / not-applicable is one segmented control rather than three
loose buttons. Toggles get their own row like everything else. Number
fields have a placeholder and show their unit. All eleven field types
are rendered here now rather than five of them being handed to the
platform's `custom-field/input`, which brought its own label chrome and
made the sheet read as two interleaved forms.
One `inspection-sheet` component renders it everywhere — the console's
submission form, the read-only record, and the public link — so the
three cannot drift. The seeding and the `custom_field_values` payload
moved to `utils/inspection-answers` for the same reason.
Two modifiers replace `value={{...}}` on inputs that re-render on every
keystroke: `sync-value` writes a value in only while the field is not
being typed in, which is the focus bug from the form builder in another
guise, and `when-changed` runs something on change but not on insert.
**The public link opened a blank page.** The generated path was
`/inspection?…`, which matches the *authenticated* `console/:slug`
route. The public one is `/~/:slug`, a sibling of `console`, outside its
chrome and its auth gate — the same route ledger's invoice links use.
The path now carries the `~/`, and the page itself was rebuilt on the
shared sheet instead of the pass-fail-only checklist it had. Uploads are
off there: the file endpoint needs a session and a link does not have
one, so the row says where a photo can be added rather than offering a
button that would fail.
**A generated link existed only as a toast.** It went to the clipboard
and, once that was overwritten, there was no way to find out what had
been handed out, to whom, or whether it still worked. Links are now
listed — under the form's details and below the generate form — with the
URL to copy again, the vehicle and driver it was for, when it was made,
when it was last opened, whether it is active, expired, used or revoked,
and a way to revoke one.
That needs the link itself, which was stored only as a sha256. A new
migration adds an encrypted `token` column beside the hash; `token_hash`
is untouched and remains the unique index every public request resolves
through. A link is a capability URL — one published form, once, for one
vehicle, until it expires — not a credential, and being re-readable is
how share links behave. Links minted before this are listed without a
URL rather than pretending to have one.
Revoke always 404'd. The link resource sent `id` as `$this->id`, which on a Fleetbase model is the table's auto-increment column, while the revoke endpoint resolves a link by uuid or public id. So the list could show links and never act on one. It sends the public id now, and the lookup accepts a numeric id too so an already-open console still works. The links list was also stacking a gray-900 fill inside a gray-800 panel, which reads as a hole punched in the page. In dark the sunken surface is now the same gray-800, and separation comes from the border under a section header and a shadow on the card. The rows are tighter: they sit inside a panel that already pads. Putting the record's own details into a bare section went too far. Only the form's field groups needed to stop being collapsible panels — the details are a resource form like every other one in the console, so they are back in their content panel exactly as they were, keeping only `sync-value` on the two number inputs so the caret stays put mid-edit. The sheet itself now has an inset, so its section borders no longer land on the overlay panel's own edge and read as a double rule. How the field groups themselves should be laid out — whether the builder's grid survives — is still open, and nothing here presumes an answer.
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.
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.
3 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this branch is
It started as #267 rebased onto
main, plus a driver-facing inspections API and tests for both. It has since grown into the whole inspections feature:feature/maintenance-platform-upgrade, "Add inspection maintenance platform foundation"), its commits unchanged. This PR supersedes Add inspection maintenance platform foundation #267, which can be closed once this is reviewed.v1, for the Navigator app.Opened as a draft, and now targets the
release/v0.6.66branch (#321), which merges intomainwhen the release is cut.Forms are groups of typed fields
Categoryrows withfor = custom_field_group; its fields areCustomFieldrows subjected to the form (for = fleetops_inspection_form). Eleven field types:pass-fail,input,textarea,number,select,radio-button,boolean,date-picker,date-time-input,file-upload,signature. Each group carries agrid_sizefor the console layout.custom_field_values, one per field.item_resultsare derived from the pass-fail answers. The flatitem_resultsbody is still accepted on the authenticated doors, for first-cut clients.file:<uuid>, the platform's own convention: base64 from the driver app is stored on submit, and a console upload is referenced as soon as it is picked.2026_09_10_000001_convert_inspection_form_items_to_field_groupsconverts first-cut forms.frequencyis retired from the model, the v1 resource, the report schema and the console; nothing scheduled an inspection from it. The database column is left in place deliberately: the create migration has already run on live instances, and a later migration can drop it.Driver API (
v1)Every lookup is scoped to the API key's company, and a form that is not published answers 404.
GET/v1/inspection-formsvehicle=returns forms for that vehicle or organisation-wide ones;type=;limit.GET/v1/inspection-forms/{id}grouped_fields.POST/v1/inspectionscustom_field_values(or first-cutitem_results),inspection_form,driver, optionalvehicleandstarted_at. Recordssubmitted_by_uuidas the driver's user. HonoursIdempotency-Key.GET/v1/inspectionsdriver,vehicle,type,result,status; newest first.GET/v1/inspections/{id}GET/v1/vehicles/{id}/inspectionsAnswers were dropped by validation, now fixed. The submitter's rules had no rule for an answer's
value, and Laravel'svalidate()drops an array's unruled keys once nested rules exist, so everycustom_field_valuesanswer reached the submitter empty, here and on the public link. A failed check carrying its photo was refused for having none. Found testing a public link;valueandcustom_field_uuidnow have rules, confirmed with Laravel's validator before and after, andInspectionSubmitterRulesTestcovers it.vehicleomitted on submit falls back to the driver's assigned vehicle.Idempotency-Keyis stored on the submission's meta and a replay for the same driver answers with the first submission; it is a read-then-write, so two replays in the same instant could still both file.Public inspection link
A link is minted from the console for one published form. It can be assigned to any user in the organisation and made for a driver and a vehicle; all three are optional and independent, because anyone in the organisation may complete an inspection. The recipient opens
/~/inspection?id=…&token=…, which the console's top-levelvirtualroute renders outside the console's chrome and auth gate.GET/public/inspections/forms/{id}?token=identitynaming the assignee, driver and vehicle. The driver's phone number is no longer included.POST/public/inspections/forms/{id}/filesPOST/public/inspections/forms/{id}/submitThese routes are mounted at the application root (
fleetops.api.routing.prefixis null), not underfleet-ops. Every one of them takes the link's PIN in anX-Inspection-Pinheader.They have no session, but their only consumer is the console's public inspection page, so they are treated as internal: they are not documented in the Postman collection, which covers the consumable API. Integrations file inspections through
POST /v1/inspections.2026_09_10_000002_add_token_to_inspection_links) so the console can list a link and copy it again; links minted before that migration are listed without a URL.ForceJsonResponsemiddleware. The platform's fetch service sendsAccept: */*, so a refused submission was answered with Laravel's redirect back to the page, a bare 302 the page could not read, instead of the reasons it was refused.random_int, kept as a bcrypt hash for checking and encrypted, like the token, so the console can show it again (2026_09_11_000001_add_assignee_and_pin_to_inspection_links). The public page asks for it before it shows anything, even the form's name. A missing or wrong PIN answers 403 withpin_requiredand, when wrong,attempts_left; the fifth wrong PIN locks the link (statuslocked, 403 withlocked: true). A wrong guess is counted with an atomic increment, and a right one clears the count. Links minted before this migration have no PIN and still open without one. The PIN travels in a header rather than the query string, to keep it out of URLs and access logs; apinfield is accepted too.Utils::consoleUrl. SMS goes through the platform'sSmsServicewith the organisation's alphanumeric sender, the way its verification texts do. Email is theInspectionLinkPinMailmailable, sent synchronously so the PIN never sits in a queue payload, with the PIN kept out of its subject line. A delivery that is impossible (no recipient, or no phone or email on file) is refused with 422 before the link is minted; one that fails in transit is reported back and does not undo the link.POST int/v1/inspection-forms/{id}/links/{linkId}/send-pinwithviasends it again.meta.completed_by_name, besidemeta.pin_verified. A submission filed from the console is now credited to the signed-in user; before, it was credited to nobody. The record's overview shows Submitted By, noting a public link, whether a PIN was checked, and a typed name that differs from the account.SelectOptionprimitive withSelectOption::User,::Driverand::Vehicle, which take the record as@option, so they serve as a PowerSelect@selectedItemComponentas well as inside an option block. They live in FleetOps for now, to move to ember-ui later.Security fixes
item_resultsbody, which stores photo URLs as given; each of these answers 422.photo.phpname must not decide the stored extension.Console
FleetbaseAttribution.[data-columns]rules on specificity, so authored two- and three-column groups stayed in two narrow columns on a phone. The foot's four tallies wrap two by two there, where "Outstanding" had run past the edge. Measured in a render harness at 375px and 768px: nothing overflows the sheet at either width. The public page has tighter padding on a phone, a PIN screen sized for a thumb with a numeric keypad and one-time-code autofill, and a submit bar that stacks with a full-width button.Companion PRs and merge order
frequencyattribute.v1driver inspection requests. The public link routes are deliberately left out.v1API.Suggested order: this and fleetops-data#77, then fleetbase#652, then postman#60. This PR's contract workflow runs the collection at
postman-ref: main, so the new requests are only exercised once postman#60 has merged, or for one run withpostman-ref: feat/driver-inspections.CI
v1routes, the vehicle history, the console's form and submission routes, and the public link routes. A refused request is a 422 with its reasons, where it used to be a redirect.text/plain, so the API received no fields. postman#61 sends it as JSON. The contract runs the collection atpostman-ref: main, so this check goes green once v0.4.4 #61 merges.Tests and coverage
For review
rawError. Without it a failed response carrying a stringerrorrejects as a bareErrorof that message, which droppedpin_required: the first cut showed the PIN prompt as an error with nowhere to type it.