@fleetbase/fleetops-engine 0.6.59 → 0.6.61
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/RELEASE.md +25 -0
- package/addon/components/ai/route-preview-map.js +1 -5
- package/addon/components/customer/orders.js +2 -1
- package/addon/components/driver/card.hbs +49 -0
- package/addon/components/driver/card.js +30 -0
- package/addon/components/map/leaflet-live-map.js +4 -19
- package/addon/components/modals/place-details.hbs +1 -1
- package/addon/components/modals/point-map.hbs +1 -1
- package/addon/components/orchestrator-workbench.js +2 -1
- package/addon/components/order/form/route.js +3 -0
- package/addon/components/order-tracking-lookup.js +2 -1
- package/addon/components/place/details.hbs +1 -1
- package/addon/components/positions-replay.js +4 -1
- package/addon/components/service-area/details.hbs +1 -1
- package/addon/components/vehicle/details/schedules.hbs +2 -2
- package/addon/components/vehicle/details/schedules.js +7 -1
- package/addon/components/widget/live-fleet.js +7 -7
- package/addon/components/zone/details.hbs +1 -1
- package/addon/controllers/maintenance/schedules/index/edit.js +10 -1
- package/addon/controllers/management/drivers/index.js +22 -1
- package/addon/controllers/settings/map.js +16 -0
- package/addon/helpers/leaflet-tile-url.js +19 -0
- package/addon/services/map-settings.js +22 -0
- package/addon/services/vehicle-actions.js +4 -2
- package/addon/templates/management/drivers/index.hbs +67 -33
- package/addon/templates/settings/map.hbs +10 -0
- package/addon/utils/leaflet-tile-url.js +31 -0
- package/addon/utils/prepare-place-for-save.js +13 -0
- package/app/components/driver/card.js +1 -0
- package/app/helpers/leaflet-tile-url.js +1 -0
- package/app/utils/leaflet-tile-url.js +1 -0
- package/composer.json +1 -1
- package/extension.json +1 -1
- package/index.js +11 -0
- package/package.json +2 -1
- package/server/config/fleetops.php +44 -0
- package/server/config/geocoder.php +1 -1
- package/server/migrations/2026_08_08_000001_make_sku_and_provider_transaction_unique_indexes_soft_delete_aware.php +152 -0
- package/server/src/Http/Controllers/Api/v1/CustomerController.php +50 -4
- package/server/src/Http/Controllers/Api/v1/DriverController.php +277 -5
- package/server/src/Http/Controllers/Api/v1/GeofenceController.php +34 -2
- package/server/src/Http/Controllers/Api/v1/ManifestController.php +250 -0
- package/server/src/Http/Controllers/Api/v1/NavigatorController.php +14 -13
- package/server/src/Http/Controllers/Api/v1/TrackingNumberController.php +10 -1
- package/server/src/Http/Controllers/Api/v1/VehicleController.php +6 -0
- package/server/src/Http/Controllers/Concerns/ResolvesReviewAccountBypass.php +62 -0
- package/server/src/Http/Controllers/Internal/v1/DriverController.php +1 -1
- package/server/src/Http/Controllers/Internal/v1/SettingController.php +22 -0
- package/server/src/Http/Filter/FuelReportFilter.php +29 -0
- package/server/src/Http/Filter/IssueFilter.php +29 -0
- package/server/src/Http/Filter/MaintenanceScheduleFilter.php +40 -0
- package/server/src/Http/Requests/CreateCustomerRequest.php +9 -1
- package/server/src/Http/Requests/CreateFuelReportRequest.php +14 -3
- package/server/src/Http/Requests/CreateFuelTransactionRequest.php +64 -1
- package/server/src/Http/Requests/CreatePartRequest.php +39 -1
- package/server/src/Http/Requests/CreateVehicleRequest.php +10 -5
- package/server/src/Http/Resources/v1/Manifest.php +52 -0
- package/server/src/Http/Resources/v1/ManifestStop.php +47 -0
- package/server/src/Http/Resources/v1/OrderConfig.php +51 -4
- package/server/src/Http/Resources/v1/TrackingNumber.php +33 -0
- package/server/src/Models/FuelReport.php +21 -0
- package/server/src/Models/Place.php +1 -1
- package/server/src/Models/Sensor.php +24 -0
- package/server/src/Support/FuelProviders/FuelProviderService.php +10 -1
- package/server/src/routes.php +22 -1
- package/server/tests/ApiCustomerControllerContractsTest.php +168 -0
- package/server/tests/ApiDriverControllerContractsTest.php +65 -0
- package/server/tests/ApiDriverPasswordContractsTest.php +457 -0
- package/server/tests/ApiManifestControllerContractsTest.php +599 -0
- package/server/tests/ApiVehicleControllerContractsTest.php +35 -0
- package/server/tests/CompactResourceSerializationTest.php +99 -0
- package/server/tests/CustomerEndpointTest.php +4 -1
- package/server/tests/Feature/Http/Api/DriverControllerAuthFlowsTest.php +81 -2
- package/server/tests/Feature/Http/Api/DriverControllerSessionHelpersTest.php +61 -0
- package/server/tests/Feature/Http/Api/GeofenceControllerContractsTest.php +99 -2
- package/server/tests/Feature/Http/Api/TrackingNumberControllerHelpersTest.php +12 -6
- package/server/tests/Feature/Http/Internal/DriverControllerContractsTest.php +58 -0
- package/server/tests/Feature/Http/Internal/PlaceControllerTest.php +45 -0
- package/server/tests/FuelProviderTransactionControllerContractsTest.php +17 -0
- package/server/tests/RequestContractsTest.php +102 -5
- package/server/tests/SettingControllerContractsTest.php +33 -0
- package/server/tests/SmallControllerContractsTest.php +28 -4
- package/server/tests/Unit/Http/Filter/FuelReportFilterTest.php +135 -0
- package/server/tests/Unit/Http/Filter/IssueFilterTest.php +43 -0
- package/server/tests/Unit/Http/Filter/MaintenanceScheduleFilterTest.php +69 -0
- package/server/tests/Unit/Http/Requests/CreateFuelReportRequestTest.php +21 -0
- package/server/tests/Unit/Http/Resources/ManifestResourceTest.php +129 -0
- package/server/tests/Unit/Http/Resources/OrderConfigResourceTest.php +91 -0
- package/server/tests/Unit/Jobs/GeofenceDwellAndBulkNotifyTest.php +7 -0
- package/server/tests/Unit/Models/SensorLastPositionDefaultTest.php +130 -0
- package/translations/en-us.yaml +4 -0
|
@@ -1,33 +1,67 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
1
|
+
{{#if (eq this.layout "table")}}
|
|
2
|
+
<Layout::Resource::Tabular
|
|
3
|
+
@resource="driver"
|
|
4
|
+
@title={{t "resource.drivers"}}
|
|
5
|
+
@searchQuery={{this.query}}
|
|
6
|
+
@onSearch={{perform this.driverActions.controllerSearchTask this}}
|
|
7
|
+
@data={{@model}}
|
|
8
|
+
@columns={{this.columns}}
|
|
9
|
+
@page={{this.page}}
|
|
10
|
+
@onPageChange={{fn (mut this.page)}}
|
|
11
|
+
@setupTable={{fn (mut this.table)}}
|
|
12
|
+
@actionButtons={{this.actionButtons}}
|
|
13
|
+
@bulkActions={{this.bulkActions}}
|
|
14
|
+
@controller={{this}}
|
|
15
|
+
@emptyStateComponent={{component
|
|
16
|
+
"table/empty-state"
|
|
17
|
+
icon="id-card"
|
|
18
|
+
title="Add your first driver"
|
|
19
|
+
description="Drivers power dispatching, live fleet visibility, assignments, and delivery activity."
|
|
20
|
+
filteredTitle="No drivers match your search"
|
|
21
|
+
filteredDescription="Adjust the search or filters to find another driver."
|
|
22
|
+
primaryText="New driver"
|
|
23
|
+
primaryIcon="plus"
|
|
24
|
+
primaryAction=this.driverActions.transition.create
|
|
25
|
+
secondaryText="Import"
|
|
26
|
+
secondaryIcon="upload"
|
|
27
|
+
secondaryAction=this.driverActions.import
|
|
28
|
+
docsSlug="fleet-ops/resources/drivers/overview"
|
|
29
|
+
docsTitle="Drivers guide"
|
|
30
|
+
docsText="Driver guide"
|
|
31
|
+
docsSource="fleet-ops-empty-drivers"
|
|
32
|
+
}}
|
|
33
|
+
/>
|
|
34
|
+
{{else}}
|
|
35
|
+
<Layout::Section::Header @title={{t "resource.drivers"}} @actionsWrapperClass="space-x-1">
|
|
36
|
+
<Layout::Resource::TabularActions
|
|
37
|
+
@searchQuery={{this.query}}
|
|
38
|
+
@onSearch={{perform this.driverActions.controllerSearchTask this}}
|
|
39
|
+
@columns={{this.columns}}
|
|
40
|
+
@page={{this.page}}
|
|
41
|
+
@onPageChange={{fn (mut this.page)}}
|
|
42
|
+
@setupTable={{fn (mut this.table)}}
|
|
43
|
+
@actionButtons={{this.actionButtons}}
|
|
44
|
+
@bulkActions={{this.bulkActions}}
|
|
45
|
+
@controller={{this}}
|
|
46
|
+
@hideColumnsPicker={{true}}
|
|
47
|
+
/>
|
|
48
|
+
</Layout::Section::Header>
|
|
49
|
+
|
|
50
|
+
<Layout::Section::Body class="overflow-y-scroll-i h-full">
|
|
51
|
+
<Layout::Resource::CardsGrid
|
|
52
|
+
@resource="driver"
|
|
53
|
+
@data={{@model}}
|
|
54
|
+
@columns="4"
|
|
55
|
+
@showPagination={{true}}
|
|
56
|
+
@page={{this.page}}
|
|
57
|
+
@onPageChange={{fn (mut this.page)}}
|
|
58
|
+
@paginationMeta={{@model.meta}}
|
|
59
|
+
@containerClass="p-4"
|
|
60
|
+
class="h-full"
|
|
61
|
+
as |Item|
|
|
62
|
+
>
|
|
63
|
+
<Driver::Card @resource={{Item.model}} />
|
|
64
|
+
</Layout::Resource::CardsGrid>
|
|
65
|
+
</Layout::Section::Body>
|
|
66
|
+
{{/if}}
|
|
67
|
+
{{outlet}}
|
|
@@ -31,6 +31,16 @@
|
|
|
31
31
|
/>
|
|
32
32
|
</InputGroup>
|
|
33
33
|
|
|
34
|
+
{{#if this.isLeafletSelected}}
|
|
35
|
+
<InputGroup @name={{t "settings.map.leaflet-tile-url"}} @helpText={{t "settings.map.leaflet-tile-url-help-text"}}>
|
|
36
|
+
<Input @type="text" @value={{this.leafletTileUrl}} placeholder="https://tile.openstreetmap.org/{z}/{x}/{y}.png" class="w-full form-input" />
|
|
37
|
+
</InputGroup>
|
|
38
|
+
|
|
39
|
+
<InputGroup @name={{t "settings.map.leaflet-dark-tile-url"}} @helpText={{t "settings.map.leaflet-dark-tile-url-help-text"}}>
|
|
40
|
+
<Input @type="text" @value={{this.leafletDarkTileUrl}} placeholder="https://tile.openstreetmap.org/{z}/{x}/{y}.png" class="w-full form-input" />
|
|
41
|
+
</InputGroup>
|
|
42
|
+
{{/if}}
|
|
43
|
+
|
|
34
44
|
{{#if this.isGoogleMapsSelected}}
|
|
35
45
|
<InputGroup @name={{t "settings.map.google-maps-map-type"}} @helpText={{t "settings.map.google-maps-map-type-help-text"}}>
|
|
36
46
|
<Select
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default Leaflet raster tile sources.
|
|
3
|
+
*
|
|
4
|
+
* CARTO's `basemaps.cartocdn.com` raster tiles now require an API key and
|
|
5
|
+
* render an "API key required" watermark without one, so the default basemap
|
|
6
|
+
* is the keyless OpenStreetMap tile server. Operators can point Leaflet at any
|
|
7
|
+
* other XYZ provider (including a keyed CARTO URL) via Fleet-Ops map settings.
|
|
8
|
+
*/
|
|
9
|
+
export const DEFAULT_LEAFLET_TILE_URL = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
|
|
10
|
+
export const DEFAULT_LEAFLET_DARK_TILE_URL = DEFAULT_LEAFLET_TILE_URL;
|
|
11
|
+
export const DEFAULT_LEAFLET_TILE_ATTRIBUTION = '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Resolve the Leaflet tile URL for a theme from a map settings object.
|
|
15
|
+
*
|
|
16
|
+
* @param {Object} settings map settings, may contain `leafletTileUrl` and `leafletDarkTileUrl`
|
|
17
|
+
* @param {String} theme 'light' or 'dark'
|
|
18
|
+
* @return {String} the XYZ tile URL template to use
|
|
19
|
+
*/
|
|
20
|
+
export function getLeafletTileUrl(settings = {}, theme = 'light') {
|
|
21
|
+
const customLight = typeof settings.leafletTileUrl === 'string' ? settings.leafletTileUrl.trim() : '';
|
|
22
|
+
const customDark = typeof settings.leafletDarkTileUrl === 'string' ? settings.leafletDarkTileUrl.trim() : '';
|
|
23
|
+
|
|
24
|
+
if (theme === 'dark') {
|
|
25
|
+
return customDark || customLight || DEFAULT_LEAFLET_DARK_TILE_URL;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return customLight || DEFAULT_LEAFLET_TILE_URL;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export default getLeafletTileUrl;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export default function preparePlaceForSave(store, place) {
|
|
2
|
+
if (!place || place.isNew || place.public_id) {
|
|
3
|
+
return place;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const attributes = {};
|
|
7
|
+
|
|
8
|
+
place.constructor.eachAttribute((attribute) => {
|
|
9
|
+
attributes[attribute] = place[attribute];
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
return store.createRecord('place', attributes);
|
|
13
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from '@fleetbase/fleetops-engine/components/driver/card';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from '@fleetbase/fleetops-engine/helpers/leaflet-tile-url';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default, DEFAULT_LEAFLET_TILE_URL, DEFAULT_LEAFLET_DARK_TILE_URL, DEFAULT_LEAFLET_TILE_ATTRIBUTION, getLeafletTileUrl } from '@fleetbase/fleetops-engine/utils/leaflet-tile-url';
|
package/composer.json
CHANGED
package/extension.json
CHANGED
package/index.js
CHANGED
|
@@ -44,6 +44,15 @@ module.exports = buildEngine({
|
|
|
44
44
|
return trees;
|
|
45
45
|
},
|
|
46
46
|
|
|
47
|
+
treeForLeafletImages: function () {
|
|
48
|
+
const leafletImagesPath = path.join(this.pathBase('leaflet'), 'dist', 'images');
|
|
49
|
+
|
|
50
|
+
return new Funnel(leafletImagesPath, {
|
|
51
|
+
destDir: 'assets/images',
|
|
52
|
+
include: ['marker-icon.png', 'marker-icon-2x.png', 'marker-shadow.png'],
|
|
53
|
+
});
|
|
54
|
+
},
|
|
55
|
+
|
|
47
56
|
treeForJointJs: function () {
|
|
48
57
|
const trees = [];
|
|
49
58
|
|
|
@@ -70,12 +79,14 @@ module.exports = buildEngine({
|
|
|
70
79
|
|
|
71
80
|
mergeWithPublicTree: function (publicTree) {
|
|
72
81
|
const leafletTree = this.treeForLeaflet();
|
|
82
|
+
const leafletImagesTree = this.treeForLeafletImages();
|
|
73
83
|
const jointJsTree = this.treeForJointJs();
|
|
74
84
|
const assetsTree = [
|
|
75
85
|
new Funnel(path.join(__dirname, 'assets'), {
|
|
76
86
|
destDir: '',
|
|
77
87
|
}),
|
|
78
88
|
...leafletTree,
|
|
89
|
+
leafletImagesTree,
|
|
79
90
|
...jointJsTree,
|
|
80
91
|
];
|
|
81
92
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fleetbase/fleetops-engine",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.61",
|
|
4
4
|
"description": "Fleet & Transport Management Extension for Fleetbase",
|
|
5
5
|
"fleetbase": {
|
|
6
6
|
"route": "fleet-ops"
|
|
@@ -70,6 +70,7 @@
|
|
|
70
70
|
"ember-radio-button": "^3.0.0-beta.1",
|
|
71
71
|
"ember-tag-input": "^3.1.0",
|
|
72
72
|
"ember-wormhole": "^0.6.0",
|
|
73
|
+
"leaflet": "^1.9.4",
|
|
73
74
|
"leaflet-contextmenu": "^1.4.0",
|
|
74
75
|
"leaflet-draw": "^1.0.4",
|
|
75
76
|
"xlsx": "^0.18.5"
|
|
@@ -96,10 +96,54 @@ return [
|
|
|
96
96
|
|--------------------------------------------------------------------------
|
|
97
97
|
*/
|
|
98
98
|
'navigator' => [
|
|
99
|
+
/*
|
|
100
|
+
| App store reviewers cannot receive our SMS, so a fixed verification code has
|
|
101
|
+
| to keep working for them — including in production, which is where a review
|
|
102
|
+
| build is tested. Blocking the bypass outside production made review
|
|
103
|
+
| impossible; restricting it to named accounts makes it safe instead.
|
|
104
|
+
|
|
|
105
|
+
| Both values are required, and neither has a default. The code alone is not
|
|
106
|
+
| sufficient: it is only accepted for an identity listed in review_accounts, so
|
|
107
|
+
| a leaked code cannot be used to authenticate as an arbitrary driver.
|
|
108
|
+
|
|
|
109
|
+
| NAVIGATOR_BYPASS_VERIFICATION_CODE=<a secret, rotated code>
|
|
110
|
+
| NAVIGATOR_REVIEW_ACCOUNTS=+15555550100,apple-review@example.com
|
|
111
|
+
*/
|
|
99
112
|
'bypass_verification_code' => env('SMS_AUTH_BYPASS_CODE', env('NAVIGATOR_BYPASS_VERIFICATION_CODE')),
|
|
113
|
+
'review_accounts' => array_values(array_filter(array_map(
|
|
114
|
+
'trim',
|
|
115
|
+
explode(',', (string) env('NAVIGATOR_REVIEW_ACCOUNTS', ''))
|
|
116
|
+
))),
|
|
100
117
|
'app_identifier' => env('NAVIGATOR_APP_IDENTIFIER', 'io.fleetbase.navigator'),
|
|
101
118
|
],
|
|
102
119
|
|
|
120
|
+
/*
|
|
121
|
+
|--------------------------------------------------------------------------
|
|
122
|
+
| Customers
|
|
123
|
+
|--------------------------------------------------------------------------
|
|
124
|
+
|
|
|
125
|
+
| Testing-only verification-code bypass for the customer auth flows
|
|
126
|
+
| (POST /v1/customers, /customers/verify-code, /customers/reset-password).
|
|
127
|
+
| Intended for local development and staging QA, where signing up means
|
|
128
|
+
| waiting on a real email or paying for a real SMS.
|
|
129
|
+
|
|
|
130
|
+
| MUST be left unset in production. It is ignored outright when the app
|
|
131
|
+
| environment is `production`, and when unset no bypass is possible.
|
|
132
|
+
|
|
|
133
|
+
| Deliberately NOT wired to SMS_AUTH_BYPASS_CODE: that variable already
|
|
134
|
+
| gates operator console login and driver login, and reusing it here would
|
|
135
|
+
| make one leaked value unlock three different privilege tiers.
|
|
136
|
+
|
|
|
137
|
+
*/
|
|
138
|
+
'customers' => [
|
|
139
|
+
'verification_bypass_code' => env('FLEETOPS_CUSTOMER_VERIFICATION_BYPASS_CODE'),
|
|
140
|
+
// Identities the bypass code is accepted for. See the note on navigator below.
|
|
141
|
+
'review_accounts' => array_values(array_filter(array_map(
|
|
142
|
+
'trim',
|
|
143
|
+
explode(',', (string) env('FLEETOPS_CUSTOMER_REVIEW_ACCOUNTS', ''))
|
|
144
|
+
))),
|
|
145
|
+
],
|
|
146
|
+
|
|
103
147
|
/*
|
|
104
148
|
|--------------------------------------------------------------------------
|
|
105
149
|
| API Events
|
|
@@ -57,7 +57,7 @@ return [
|
|
|
57
57
|
'providers' => [
|
|
58
58
|
Chain::class => [
|
|
59
59
|
GoogleMaps::class => [
|
|
60
|
-
config('services.google_maps.locale', env('GOOGLE_MAPS_LOCALE', '
|
|
60
|
+
config('services.google_maps.locale', env('GOOGLE_MAPS_LOCALE', 'en')),
|
|
61
61
|
config('services.google_maps.api_key', env('GOOGLE_MAPS_API_KEY')),
|
|
62
62
|
]
|
|
63
63
|
],
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
use Illuminate\Database\Migrations\Migration;
|
|
4
|
+
use Illuminate\Database\Schema\Blueprint;
|
|
5
|
+
use Illuminate\Support\Facades\DB;
|
|
6
|
+
use Illuminate\Support\Facades\Schema;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Both `parts` and `fuel_provider_transactions` soft-delete, but their unique
|
|
10
|
+
* indexes were not soft-delete aware. A deleted row kept occupying the key, so
|
|
11
|
+
* re-creating a Part with a previously used SKU (or re-ingesting a fuel provider
|
|
12
|
+
* transaction that had been deleted) raised a UniqueConstraintViolationException.
|
|
13
|
+
* On the public v1 API that surfaced as an HTTP 500, and because there is no
|
|
14
|
+
* restore/force-delete route the key was unusable from then on.
|
|
15
|
+
*
|
|
16
|
+
* The fix follows the pattern already used for alrashed contract numbers in
|
|
17
|
+
* 2026_05_25_000001_make_contract_number_unique_for_active_contracts: a STORED
|
|
18
|
+
* generated column that is NULL for soft-deleted rows. MySQL permits any number
|
|
19
|
+
* of NULLs in a unique index, so tombstones stop colliding while live rows stay
|
|
20
|
+
* constrained.
|
|
21
|
+
*
|
|
22
|
+
* `fuel_provider_transactions` additionally gains `company_uuid` in the key. The
|
|
23
|
+
* old index was global, so one company's provider transaction id blocked — or,
|
|
24
|
+
* via FuelProviderService::ingestTransaction()'s updateOrCreate, silently
|
|
25
|
+
* overwrote — another company's. Provider transaction ids are only unique within
|
|
26
|
+
* the account they were issued for, so the key belongs per tenant.
|
|
27
|
+
*
|
|
28
|
+
* Both new indexes are strictly more permissive than the ones they replace, so no
|
|
29
|
+
* existing row can violate them and no backfill is required.
|
|
30
|
+
*/
|
|
31
|
+
return new class extends Migration {
|
|
32
|
+
/**
|
|
33
|
+
* Run the migrations.
|
|
34
|
+
*
|
|
35
|
+
* @return void
|
|
36
|
+
*/
|
|
37
|
+
public function up()
|
|
38
|
+
{
|
|
39
|
+
// ---- parts: (company_uuid, sku) -> (company_uuid, active_sku) ----
|
|
40
|
+
if (!Schema::hasColumn('parts', 'active_sku')) {
|
|
41
|
+
Schema::table('parts', function (Blueprint $table) {
|
|
42
|
+
$table->string('active_sku')->nullable()->storedAs('IF(`deleted_at` IS NULL, `sku`, NULL)');
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// The replacement index MUST be created before the old one is dropped:
|
|
47
|
+
// parts_company_uuid_sku_unique is currently the only index covering
|
|
48
|
+
// company_uuid, and parts_company_uuid_foreign depends on it. Adding an
|
|
49
|
+
// index that also leads with company_uuid lets it take over as the
|
|
50
|
+
// covering index, otherwise the drop below fails with errno 150.
|
|
51
|
+
if (!$this->indexExists('parts', 'parts_company_uuid_active_sku_unique')) {
|
|
52
|
+
Schema::table('parts', function (Blueprint $table) {
|
|
53
|
+
$table->unique(['company_uuid', 'active_sku'], 'parts_company_uuid_active_sku_unique');
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if ($this->indexExists('parts', 'parts_company_uuid_sku_unique')) {
|
|
58
|
+
Schema::table('parts', function (Blueprint $table) {
|
|
59
|
+
$table->dropUnique('parts_company_uuid_sku_unique');
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---- fuel_provider_transactions: (provider, provider_transaction_id)
|
|
64
|
+
// -> (company_uuid, provider, active_provider_transaction_id) ----
|
|
65
|
+
if (!Schema::hasColumn('fuel_provider_transactions', 'active_provider_transaction_id')) {
|
|
66
|
+
Schema::table('fuel_provider_transactions', function (Blueprint $table) {
|
|
67
|
+
$table->string('active_provider_transaction_id', 191)->nullable()->storedAs('IF(`deleted_at` IS NULL, `provider_transaction_id`, NULL)');
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (!$this->indexExists('fuel_provider_transactions', 'fuel_provider_txn_company_provider_unique')) {
|
|
72
|
+
Schema::table('fuel_provider_transactions', function (Blueprint $table) {
|
|
73
|
+
$table->unique(['company_uuid', 'provider', 'active_provider_transaction_id'], 'fuel_provider_txn_company_provider_unique');
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if ($this->indexExists('fuel_provider_transactions', 'fuel_provider_txn_provider_unique')) {
|
|
78
|
+
Schema::table('fuel_provider_transactions', function (Blueprint $table) {
|
|
79
|
+
$table->dropUnique('fuel_provider_txn_provider_unique');
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Reverse the migrations.
|
|
86
|
+
*
|
|
87
|
+
* Restoring the old global index can fail if rows now exist that only the new
|
|
88
|
+
* key permits (two tenants sharing a provider transaction id, or a live row
|
|
89
|
+
* whose SKU matches a soft-deleted one). That is inherent to reversing a
|
|
90
|
+
* relaxed constraint, so the old indexes are restored on a best-effort basis
|
|
91
|
+
* and the generated columns are dropped either way.
|
|
92
|
+
*
|
|
93
|
+
* @return void
|
|
94
|
+
*/
|
|
95
|
+
public function down()
|
|
96
|
+
{
|
|
97
|
+
if (!$this->indexExists('parts', 'parts_company_uuid_sku_unique')) {
|
|
98
|
+
try {
|
|
99
|
+
Schema::table('parts', function (Blueprint $table) {
|
|
100
|
+
$table->unique(['company_uuid', 'sku'], 'parts_company_uuid_sku_unique');
|
|
101
|
+
});
|
|
102
|
+
} catch (Throwable $e) {
|
|
103
|
+
// Duplicate SKUs the new index allowed now block the old one.
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if ($this->indexExists('parts', 'parts_company_uuid_active_sku_unique')) {
|
|
108
|
+
Schema::table('parts', function (Blueprint $table) {
|
|
109
|
+
$table->dropUnique('parts_company_uuid_active_sku_unique');
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (Schema::hasColumn('parts', 'active_sku')) {
|
|
114
|
+
Schema::table('parts', function (Blueprint $table) {
|
|
115
|
+
$table->dropColumn('active_sku');
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!$this->indexExists('fuel_provider_transactions', 'fuel_provider_txn_provider_unique')) {
|
|
120
|
+
try {
|
|
121
|
+
Schema::table('fuel_provider_transactions', function (Blueprint $table) {
|
|
122
|
+
$table->unique(['provider', 'provider_transaction_id'], 'fuel_provider_txn_provider_unique');
|
|
123
|
+
});
|
|
124
|
+
} catch (Throwable $e) {
|
|
125
|
+
// Cross-tenant duplicates the new index allows now block the old one.
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if ($this->indexExists('fuel_provider_transactions', 'fuel_provider_txn_company_provider_unique')) {
|
|
130
|
+
Schema::table('fuel_provider_transactions', function (Blueprint $table) {
|
|
131
|
+
$table->dropUnique('fuel_provider_txn_company_provider_unique');
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (Schema::hasColumn('fuel_provider_transactions', 'active_provider_transaction_id')) {
|
|
136
|
+
Schema::table('fuel_provider_transactions', function (Blueprint $table) {
|
|
137
|
+
$table->dropColumn('active_provider_transaction_id');
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private function indexExists(string $table, string $index): bool
|
|
143
|
+
{
|
|
144
|
+
$database = DB::connection()->getDatabaseName();
|
|
145
|
+
|
|
146
|
+
return DB::table('information_schema.statistics')
|
|
147
|
+
->where('table_schema', $database)
|
|
148
|
+
->where('table_name', $table)
|
|
149
|
+
->where('index_name', $index)
|
|
150
|
+
->exists();
|
|
151
|
+
}
|
|
152
|
+
};
|
|
@@ -19,6 +19,7 @@ use Fleetbase\FleetOps\Models\ServiceQuote;
|
|
|
19
19
|
use Fleetbase\FleetOps\Support\CustomerAuth;
|
|
20
20
|
use Fleetbase\FleetOps\Support\Utils;
|
|
21
21
|
use Fleetbase\Http\Controllers\Controller;
|
|
22
|
+
use Fleetbase\LaravelMysqlSpatial\Types\Point as SpatialPoint;
|
|
22
23
|
use Fleetbase\Models\File;
|
|
23
24
|
use Fleetbase\Models\User;
|
|
24
25
|
use Fleetbase\Models\UserDevice;
|
|
@@ -41,6 +42,8 @@ use Illuminate\Support\Str;
|
|
|
41
42
|
*/
|
|
42
43
|
class CustomerController extends Controller
|
|
43
44
|
{
|
|
45
|
+
use \Fleetbase\FleetOps\Http\Controllers\Concerns\ResolvesReviewAccountBypass;
|
|
46
|
+
|
|
44
47
|
/* ============================================================
|
|
45
48
|
| Public auth flows (API credential only, no Customer-Token)
|
|
46
49
|
* ============================================================ */
|
|
@@ -144,7 +147,7 @@ class CustomerController extends Controller
|
|
|
144
147
|
'for' => 'fleetops_create_customer',
|
|
145
148
|
'meta->identity' => $identity,
|
|
146
149
|
]);
|
|
147
|
-
if (!$verificationCode) {
|
|
150
|
+
if (!$verificationCode && !$this->verificationBypassMatches($identity, $code)) {
|
|
148
151
|
return response()->apiError('Invalid verification code provided.');
|
|
149
152
|
}
|
|
150
153
|
|
|
@@ -312,6 +315,14 @@ class CustomerController extends Controller
|
|
|
312
315
|
'company_uuid' => $companyUuid,
|
|
313
316
|
'owner_uuid' => $contact->uuid,
|
|
314
317
|
'owner_type' => get_class($contact),
|
|
318
|
+
// `places.location` is a NOT NULL POINT column with no database default,
|
|
319
|
+
// and $allowed above is address-only — a caller cannot supply coordinates
|
|
320
|
+
// here. Without this the insert fails with SQLSTATE[HY000] 1364 ("Field
|
|
321
|
+
// 'location' doesn't have a default value"), turning a documented signup
|
|
322
|
+
// payload into a 500. Point(0, 0) is the same placeholder the geocoding
|
|
323
|
+
// helpers fall back to when an address cannot be resolved — see
|
|
324
|
+
// Place::getGoogleAddressArray and Place::findExistingSharedPlace.
|
|
325
|
+
'location' => new SpatialPoint(0, 0),
|
|
315
326
|
],
|
|
316
327
|
$attributes,
|
|
317
328
|
));
|
|
@@ -417,7 +428,7 @@ class CustomerController extends Controller
|
|
|
417
428
|
'code' => $code,
|
|
418
429
|
'for' => $for,
|
|
419
430
|
]);
|
|
420
|
-
if (!$verificationCode) {
|
|
431
|
+
if (!$verificationCode && !$this->verificationBypassMatches($identity, $code)) {
|
|
421
432
|
return response()->apiError('Invalid verification code.');
|
|
422
433
|
}
|
|
423
434
|
|
|
@@ -503,7 +514,9 @@ class CustomerController extends Controller
|
|
|
503
514
|
'for' => 'fleetops_customer_password_reset',
|
|
504
515
|
'meta->identity' => $needle,
|
|
505
516
|
]);
|
|
506
|
-
|
|
517
|
+
// $needle, not $identity: the other verify paths normalise in place, so an
|
|
518
|
+
// allowlisted phone must be compared in the same normalised form here too.
|
|
519
|
+
if (!$verificationCode && !$this->verificationBypassMatches($needle, $code)) {
|
|
507
520
|
return response()->apiError('Invalid reset code.');
|
|
508
521
|
}
|
|
509
522
|
|
|
@@ -517,7 +530,10 @@ class CustomerController extends Controller
|
|
|
517
530
|
$user->save();
|
|
518
531
|
// Invalidate all existing sessions for this user after a password reset.
|
|
519
532
|
$this->deleteUserTokens($user);
|
|
520
|
-
|
|
533
|
+
// Null on the testing-bypass path — there is no row to consume.
|
|
534
|
+
if ($verificationCode) {
|
|
535
|
+
$verificationCode->delete();
|
|
536
|
+
}
|
|
521
537
|
|
|
522
538
|
return response()->json(['status' => 'ok']);
|
|
523
539
|
}
|
|
@@ -918,6 +934,36 @@ class CustomerController extends Controller
|
|
|
918
934
|
return VerificationCode::generateSmsVerificationFor($user, $for, $options);
|
|
919
935
|
}
|
|
920
936
|
|
|
937
|
+
/**
|
|
938
|
+
* Whether the supplied code matches the configured testing bypass code.
|
|
939
|
+
*
|
|
940
|
+
* Three conditions, all required, mirroring the console equivalent in
|
|
941
|
+
* Fleetbase\Http\Controllers\Internal\v1\AuthController::authenticateWithVerificationCode:
|
|
942
|
+
* a bypass code must actually be configured, the app must not be running in
|
|
943
|
+
* production, and the comparison is constant-time.
|
|
944
|
+
*
|
|
945
|
+
* Fails safe by default: config/app.php resolves `env` to `production` when
|
|
946
|
+
* neither APP_ENV nor ENVIRONMENT is set, so an unconfigured install cannot
|
|
947
|
+
* be bypassed even accidentally.
|
|
948
|
+
*
|
|
949
|
+
* `!== null && !== ''` rather than `!empty()` — `!empty('0')` is false, so a
|
|
950
|
+
* configured bypass code of "0" would otherwise be silently ignored.
|
|
951
|
+
*
|
|
952
|
+
* Kept out of verificationCodeExists()/findVerificationCode() on purpose:
|
|
953
|
+
* those two are test seams that the controller contract tests override, so a
|
|
954
|
+
* policy living inside them would be stubbed away exactly where it matters.
|
|
955
|
+
*/
|
|
956
|
+
protected function verificationBypassMatches(?string $identity, ?string $code): bool
|
|
957
|
+
{
|
|
958
|
+
return static::reviewAccountBypassMatches(
|
|
959
|
+
'fleetops.customers.verification_bypass_code',
|
|
960
|
+
'fleetops.customers.review_accounts',
|
|
961
|
+
$identity,
|
|
962
|
+
$code,
|
|
963
|
+
'fleetops-customer'
|
|
964
|
+
);
|
|
965
|
+
}
|
|
966
|
+
|
|
921
967
|
protected function verificationCodeExists(array $attributes): bool
|
|
922
968
|
{
|
|
923
969
|
return VerificationCode::where($attributes)->exists();
|