@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.
Files changed (91) hide show
  1. package/RELEASE.md +25 -0
  2. package/addon/components/ai/route-preview-map.js +1 -5
  3. package/addon/components/customer/orders.js +2 -1
  4. package/addon/components/driver/card.hbs +49 -0
  5. package/addon/components/driver/card.js +30 -0
  6. package/addon/components/map/leaflet-live-map.js +4 -19
  7. package/addon/components/modals/place-details.hbs +1 -1
  8. package/addon/components/modals/point-map.hbs +1 -1
  9. package/addon/components/orchestrator-workbench.js +2 -1
  10. package/addon/components/order/form/route.js +3 -0
  11. package/addon/components/order-tracking-lookup.js +2 -1
  12. package/addon/components/place/details.hbs +1 -1
  13. package/addon/components/positions-replay.js +4 -1
  14. package/addon/components/service-area/details.hbs +1 -1
  15. package/addon/components/vehicle/details/schedules.hbs +2 -2
  16. package/addon/components/vehicle/details/schedules.js +7 -1
  17. package/addon/components/widget/live-fleet.js +7 -7
  18. package/addon/components/zone/details.hbs +1 -1
  19. package/addon/controllers/maintenance/schedules/index/edit.js +10 -1
  20. package/addon/controllers/management/drivers/index.js +22 -1
  21. package/addon/controllers/settings/map.js +16 -0
  22. package/addon/helpers/leaflet-tile-url.js +19 -0
  23. package/addon/services/map-settings.js +22 -0
  24. package/addon/services/vehicle-actions.js +4 -2
  25. package/addon/templates/management/drivers/index.hbs +67 -33
  26. package/addon/templates/settings/map.hbs +10 -0
  27. package/addon/utils/leaflet-tile-url.js +31 -0
  28. package/addon/utils/prepare-place-for-save.js +13 -0
  29. package/app/components/driver/card.js +1 -0
  30. package/app/helpers/leaflet-tile-url.js +1 -0
  31. package/app/utils/leaflet-tile-url.js +1 -0
  32. package/composer.json +1 -1
  33. package/extension.json +1 -1
  34. package/index.js +11 -0
  35. package/package.json +2 -1
  36. package/server/config/fleetops.php +44 -0
  37. package/server/config/geocoder.php +1 -1
  38. package/server/migrations/2026_08_08_000001_make_sku_and_provider_transaction_unique_indexes_soft_delete_aware.php +152 -0
  39. package/server/src/Http/Controllers/Api/v1/CustomerController.php +50 -4
  40. package/server/src/Http/Controllers/Api/v1/DriverController.php +277 -5
  41. package/server/src/Http/Controllers/Api/v1/GeofenceController.php +34 -2
  42. package/server/src/Http/Controllers/Api/v1/ManifestController.php +250 -0
  43. package/server/src/Http/Controllers/Api/v1/NavigatorController.php +14 -13
  44. package/server/src/Http/Controllers/Api/v1/TrackingNumberController.php +10 -1
  45. package/server/src/Http/Controllers/Api/v1/VehicleController.php +6 -0
  46. package/server/src/Http/Controllers/Concerns/ResolvesReviewAccountBypass.php +62 -0
  47. package/server/src/Http/Controllers/Internal/v1/DriverController.php +1 -1
  48. package/server/src/Http/Controllers/Internal/v1/SettingController.php +22 -0
  49. package/server/src/Http/Filter/FuelReportFilter.php +29 -0
  50. package/server/src/Http/Filter/IssueFilter.php +29 -0
  51. package/server/src/Http/Filter/MaintenanceScheduleFilter.php +40 -0
  52. package/server/src/Http/Requests/CreateCustomerRequest.php +9 -1
  53. package/server/src/Http/Requests/CreateFuelReportRequest.php +14 -3
  54. package/server/src/Http/Requests/CreateFuelTransactionRequest.php +64 -1
  55. package/server/src/Http/Requests/CreatePartRequest.php +39 -1
  56. package/server/src/Http/Requests/CreateVehicleRequest.php +10 -5
  57. package/server/src/Http/Resources/v1/Manifest.php +52 -0
  58. package/server/src/Http/Resources/v1/ManifestStop.php +47 -0
  59. package/server/src/Http/Resources/v1/OrderConfig.php +51 -4
  60. package/server/src/Http/Resources/v1/TrackingNumber.php +33 -0
  61. package/server/src/Models/FuelReport.php +21 -0
  62. package/server/src/Models/Place.php +1 -1
  63. package/server/src/Models/Sensor.php +24 -0
  64. package/server/src/Support/FuelProviders/FuelProviderService.php +10 -1
  65. package/server/src/routes.php +22 -1
  66. package/server/tests/ApiCustomerControllerContractsTest.php +168 -0
  67. package/server/tests/ApiDriverControllerContractsTest.php +65 -0
  68. package/server/tests/ApiDriverPasswordContractsTest.php +457 -0
  69. package/server/tests/ApiManifestControllerContractsTest.php +599 -0
  70. package/server/tests/ApiVehicleControllerContractsTest.php +35 -0
  71. package/server/tests/CompactResourceSerializationTest.php +99 -0
  72. package/server/tests/CustomerEndpointTest.php +4 -1
  73. package/server/tests/Feature/Http/Api/DriverControllerAuthFlowsTest.php +81 -2
  74. package/server/tests/Feature/Http/Api/DriverControllerSessionHelpersTest.php +61 -0
  75. package/server/tests/Feature/Http/Api/GeofenceControllerContractsTest.php +99 -2
  76. package/server/tests/Feature/Http/Api/TrackingNumberControllerHelpersTest.php +12 -6
  77. package/server/tests/Feature/Http/Internal/DriverControllerContractsTest.php +58 -0
  78. package/server/tests/Feature/Http/Internal/PlaceControllerTest.php +45 -0
  79. package/server/tests/FuelProviderTransactionControllerContractsTest.php +17 -0
  80. package/server/tests/RequestContractsTest.php +102 -5
  81. package/server/tests/SettingControllerContractsTest.php +33 -0
  82. package/server/tests/SmallControllerContractsTest.php +28 -4
  83. package/server/tests/Unit/Http/Filter/FuelReportFilterTest.php +135 -0
  84. package/server/tests/Unit/Http/Filter/IssueFilterTest.php +43 -0
  85. package/server/tests/Unit/Http/Filter/MaintenanceScheduleFilterTest.php +69 -0
  86. package/server/tests/Unit/Http/Requests/CreateFuelReportRequestTest.php +21 -0
  87. package/server/tests/Unit/Http/Resources/ManifestResourceTest.php +129 -0
  88. package/server/tests/Unit/Http/Resources/OrderConfigResourceTest.php +91 -0
  89. package/server/tests/Unit/Jobs/GeofenceDwellAndBulkNotifyTest.php +7 -0
  90. package/server/tests/Unit/Models/SensorLastPositionDefaultTest.php +130 -0
  91. package/translations/en-us.yaml +4 -0
@@ -1,33 +1,67 @@
1
- <Layout::Resource::Tabular
2
- @resource="driver"
3
- @title={{t "resource.drivers"}}
4
- @searchQuery={{this.query}}
5
- @onSearch={{perform this.driverActions.controllerSearchTask this}}
6
- @data={{@model}}
7
- @columns={{this.columns}}
8
- @page={{this.page}}
9
- @onPageChange={{fn (mut this.page)}}
10
- @setupTable={{fn (mut this.table)}}
11
- @actionButtons={{this.actionButtons}}
12
- @bulkActions={{this.bulkActions}}
13
- @controller={{this}}
14
- @emptyStateComponent={{component
15
- "table/empty-state"
16
- icon="id-card"
17
- title="Add your first driver"
18
- description="Drivers power dispatching, live fleet visibility, assignments, and delivery activity."
19
- filteredTitle="No drivers match your search"
20
- filteredDescription="Adjust the search or filters to find another driver."
21
- primaryText="New driver"
22
- primaryIcon="plus"
23
- primaryAction=this.driverActions.transition.create
24
- secondaryText="Import"
25
- secondaryIcon="upload"
26
- secondaryAction=this.driverActions.import
27
- docsSlug="fleet-ops/resources/drivers/overview"
28
- docsTitle="Drivers guide"
29
- docsText="Driver guide"
30
- docsSource="fleet-ops-empty-drivers"
31
- }}
32
- />
33
- {{outlet}}
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 = '&copy; <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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fleetbase/fleetops-api",
3
- "version": "0.6.59",
3
+ "version": "0.6.61",
4
4
  "description": "Fleet & Transport Management Extension for Fleetbase",
5
5
  "keywords": [
6
6
  "fleetbase-extension",
package/extension.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "Fleet-Ops",
3
- "version": "0.6.59",
3
+ "version": "0.6.61",
4
4
  "description": "Fleet & Transport Management Extension for Fleetbase",
5
5
  "repository": "https://github.com/fleetbase/fleetops",
6
6
  "license": "AGPL-3.0-or-later",
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.59",
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', 'us')),
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
- if (!$verificationCode) {
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
- $verificationCode->delete();
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();