@fleetbase/fleetops-engine 0.6.60 → 0.6.62

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 (109) hide show
  1. package/RELEASE.md +21 -17
  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/widget/live-fleet.js +7 -7
  16. package/addon/components/zone/details.hbs +1 -1
  17. package/addon/controllers/management/drivers/index.js +22 -1
  18. package/addon/controllers/settings/map.js +16 -0
  19. package/addon/helpers/leaflet-tile-url.js +19 -0
  20. package/addon/services/map-settings.js +26 -1
  21. package/addon/templates/management/drivers/index.hbs +67 -33
  22. package/addon/templates/settings/map.hbs +10 -0
  23. package/addon/utils/leaflet-tile-url.js +31 -0
  24. package/addon/utils/prepare-place-for-save.js +13 -0
  25. package/app/components/driver/card.js +1 -0
  26. package/app/helpers/leaflet-tile-url.js +1 -0
  27. package/app/utils/leaflet-tile-url.js +1 -0
  28. package/composer.json +1 -1
  29. package/extension.json +1 -1
  30. package/index.js +11 -0
  31. package/package.json +2 -1
  32. package/server/migrations/2026_09_05_000001_add_unique_indexes_to_fleet_membership_pivots.php +129 -0
  33. package/server/src/Exceptions/PublicRelationNotFoundException.php +47 -0
  34. package/server/src/Http/Controllers/Api/v1/Concerns/ResolvesFleetOpsApiResources.php +42 -7
  35. package/server/src/Http/Controllers/Api/v1/Concerns/ResolvesPublicExpansions.php +187 -0
  36. package/server/src/Http/Controllers/Api/v1/DriverController.php +312 -59
  37. package/server/src/Http/Controllers/Api/v1/EntityController.php +1 -1
  38. package/server/src/Http/Controllers/Api/v1/FleetController.php +370 -33
  39. package/server/src/Http/Controllers/Api/v1/FuelReportController.php +1 -1
  40. package/server/src/Http/Controllers/Api/v1/IssueController.php +1 -1
  41. package/server/src/Http/Controllers/Api/v1/ManifestController.php +250 -0
  42. package/server/src/Http/Controllers/Api/v1/OrderController.php +1 -1
  43. package/server/src/Http/Controllers/Api/v1/ServiceAreaController.php +1 -1
  44. package/server/src/Http/Controllers/Api/v1/TrackingStatusController.php +1 -1
  45. package/server/src/Http/Controllers/Api/v1/VehicleController.php +139 -34
  46. package/server/src/Http/Controllers/Api/v1/ZoneController.php +1 -1
  47. package/server/src/Http/Controllers/Internal/v1/SettingController.php +22 -0
  48. package/server/src/Http/Filter/Concerns/ResolvesPublicRelationUuids.php +54 -0
  49. package/server/src/Http/Filter/DriverFilter.php +71 -15
  50. package/server/src/Http/Filter/FleetFilter.php +32 -33
  51. package/server/src/Http/Filter/FuelReportFilter.php +29 -0
  52. package/server/src/Http/Filter/IssueFilter.php +29 -0
  53. package/server/src/Http/Filter/VehicleFilter.php +57 -19
  54. package/server/src/Http/Requests/Concerns/ScopesPublicRelationRules.php +48 -0
  55. package/server/src/Http/Requests/CreateDriverRequest.php +100 -9
  56. package/server/src/Http/Requests/CreateFleetRequest.php +30 -2
  57. package/server/src/Http/Requests/CreateServiceRateRequest.php +7 -2
  58. package/server/src/Http/Requests/CreateVehicleRequest.php +150 -26
  59. package/server/src/Http/Resources/v1/Concerns/ResolvesPublicRelationFields.php +110 -0
  60. package/server/src/Http/Resources/v1/Driver.php +22 -2
  61. package/server/src/Http/Resources/v1/Fleet.php +38 -25
  62. package/server/src/Http/Resources/v1/Manifest.php +52 -0
  63. package/server/src/Http/Resources/v1/ManifestStop.php +47 -0
  64. package/server/src/Http/Resources/v1/OrderConfig.php +51 -4
  65. package/server/src/Http/Resources/v1/Vehicle.php +46 -4
  66. package/server/src/Models/Driver.php +2 -2
  67. package/server/src/Models/Place.php +1 -1
  68. package/server/src/routes.php +29 -0
  69. package/server/tests/ApiDriverControllerContractsTest.php +317 -13
  70. package/server/tests/ApiDriverPasswordContractsTest.php +457 -0
  71. package/server/tests/ApiEntityControllerContractsTest.php +1 -1
  72. package/server/tests/ApiEquipmentControllerContractsTest.php +2 -2
  73. package/server/tests/ApiFleetControllerContractsTest.php +354 -21
  74. package/server/tests/ApiFuelReportControllerContractsTest.php +1 -1
  75. package/server/tests/ApiIssueControllerContractsTest.php +1 -1
  76. package/server/tests/ApiManifestControllerContractsTest.php +599 -0
  77. package/server/tests/ApiOrderControllerContractsTest.php +1 -1
  78. package/server/tests/ApiPartControllerContractsTest.php +2 -2
  79. package/server/tests/ApiServiceAreaZoneControllerContractsTest.php +2 -2
  80. package/server/tests/ApiTrackingStatusControllerContractsTest.php +1 -1
  81. package/server/tests/ApiVehicleControllerContractsTest.php +210 -8
  82. package/server/tests/CompactResourceSerializationTest.php +13 -7
  83. package/server/tests/ControllerFilterContractsTest.php +132 -13
  84. package/server/tests/ControllerHelperContractsTest.php +6 -3
  85. package/server/tests/DriverFilterExecutionTest.php +130 -6
  86. package/server/tests/ExceptionContractsTest.php +15 -0
  87. package/server/tests/Feature/Http/Api/DeviceControllerContractsTest.php +1 -1
  88. package/server/tests/Feature/Http/Api/DriverControllerSessionHelpersTest.php +61 -0
  89. package/server/tests/Feature/Http/Api/FleetMembershipMigrationTest.php +140 -0
  90. package/server/tests/Feature/Http/Api/FleetMembershipTest.php +333 -0
  91. package/server/tests/Feature/Http/Api/FleetPublicContractTest.php +439 -0
  92. package/server/tests/Feature/Http/Api/FuelTransactionControllerContractsTest.php +2 -2
  93. package/server/tests/Feature/Http/Api/NullRelationshipInputTest.php +191 -0
  94. package/server/tests/Feature/Http/Api/PublicIdentifierFilterTest.php +293 -0
  95. package/server/tests/Feature/Http/Api/SmallApiControllerHelpersTest.php +7 -1
  96. package/server/tests/Feature/Http/Api/VehicleControllerTrackingTest.php +3 -1
  97. package/server/tests/Feature/Http/Api/VehiclePublicContractTest.php +454 -0
  98. package/server/tests/Feature/Http/Api/WorkOrderControllerContractsTest.php +1 -1
  99. package/server/tests/Feature/Http/Internal/PlaceControllerTest.php +45 -0
  100. package/server/tests/FleetHierarchyResourceTest.php +56 -8
  101. package/server/tests/RequestContractsTest.php +105 -7
  102. package/server/tests/SettingControllerContractsTest.php +33 -0
  103. package/server/tests/Unit/Http/Filter/FuelReportFilterTest.php +135 -0
  104. package/server/tests/Unit/Http/Filter/IssueFilterTest.php +43 -0
  105. package/server/tests/Unit/Http/Resources/FleetResourceTest.php +50 -6
  106. package/server/tests/Unit/Http/Resources/ManifestResourceTest.php +129 -0
  107. package/server/tests/Unit/Http/Resources/OrderConfigResourceTest.php +91 -0
  108. package/server/tests/VehicleControllerHelperContractsTest.php +1 -1
  109. 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.60",
3
+ "version": "0.6.62",
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.60",
3
+ "version": "0.6.62",
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.60",
3
+ "version": "0.6.62",
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"
@@ -0,0 +1,129 @@
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
+ * One logical membership per (fleet, vehicle) and (fleet, driver) pair, enforced
10
+ * by the database rather than by a read-then-write in the controller.
11
+ *
12
+ * The public membership endpoints are idempotent by checking for an existing
13
+ * pivot and creating one only if there is none. That holds for sequential calls
14
+ * and fails for concurrent ones: two requests can both find nothing and both
15
+ * insert, leaving a fleet with the same vehicle twice. An importer that retries
16
+ * a timed-out request is exactly the caller that produces this.
17
+ *
18
+ * The index deliberately covers soft-deleted rows too. Unlike the SKU and
19
+ * provider-transaction keys — where a tombstone must free the key, and does so
20
+ * through a generated column that is NULL when deleted — a removed membership
21
+ * here must keep occupying its key, because re-assigning restores that row
22
+ * rather than inserting a second one. Letting a tombstone free the key would
23
+ * reintroduce the duplicate it is meant to prevent.
24
+ *
25
+ * Rows with a NULL fleet_uuid or subject_uuid are left alone: MySQL permits any
26
+ * number of NULLs in a unique index, and such a row is orphaned data rather than
27
+ * a membership.
28
+ */
29
+ return new class extends Migration {
30
+ /**
31
+ * The pivots, and the column naming the member on each.
32
+ *
33
+ * @var array<string, array{0: string, 1: string}>
34
+ */
35
+ private array $pivots = [
36
+ 'fleet_vehicles' => ['vehicle_uuid', 'fleet_vehicles_fleet_vehicle_unique'],
37
+ 'fleet_drivers' => ['driver_uuid', 'fleet_drivers_fleet_driver_unique'],
38
+ ];
39
+
40
+ /**
41
+ * Run the migrations.
42
+ *
43
+ * @return void
44
+ */
45
+ public function up()
46
+ {
47
+ foreach ($this->pivots as $table => [$memberColumn, $indexName]) {
48
+ if (!Schema::hasTable($table)) {
49
+ continue;
50
+ }
51
+
52
+ $this->removeDuplicateMemberships($table, $memberColumn);
53
+
54
+ if (!$this->indexExists($table, $indexName)) {
55
+ Schema::table($table, function (Blueprint $blueprint) use ($memberColumn, $indexName) {
56
+ $blueprint->unique(['fleet_uuid', $memberColumn], $indexName);
57
+ });
58
+ }
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Reverse the migrations.
64
+ *
65
+ * @return void
66
+ */
67
+ public function down()
68
+ {
69
+ foreach ($this->pivots as $table => [$memberColumn, $indexName]) {
70
+ if (Schema::hasTable($table) && $this->indexExists($table, $indexName)) {
71
+ Schema::table($table, function (Blueprint $blueprint) use ($indexName) {
72
+ $blueprint->dropUnique($indexName);
73
+ });
74
+ }
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Collapse every duplicated pair down to the one row worth keeping.
80
+ *
81
+ * An active row wins over a tombstone, because that is the membership the
82
+ * fleet actually has today. Among equals the lowest id wins, so the outcome
83
+ * is deterministic and a re-run is a no-op. When every row for a pair is
84
+ * soft-deleted, one is still kept — removing them all would turn a later
85
+ * re-assignment into a new row and lose the original membership's history.
86
+ *
87
+ * Only redundant pivot rows are removed. No fleet, vehicle or driver is
88
+ * touched, and no surviving membership changes state.
89
+ */
90
+ private function removeDuplicateMemberships(string $table, string $memberColumn): void
91
+ {
92
+ $duplicatePairs = DB::table($table)
93
+ ->select('fleet_uuid', $memberColumn)
94
+ ->whereNotNull('fleet_uuid')
95
+ ->whereNotNull($memberColumn)
96
+ ->groupBy('fleet_uuid', $memberColumn)
97
+ ->havingRaw('COUNT(*) > 1')
98
+ ->get();
99
+
100
+ foreach ($duplicatePairs as $pair) {
101
+ $pair = (array) $pair;
102
+
103
+ $rows = DB::table($table)
104
+ ->where('fleet_uuid', $pair['fleet_uuid'])
105
+ ->where($memberColumn, $pair[$memberColumn])
106
+ // Active rows first, then oldest first.
107
+ ->orderByRaw('CASE WHEN deleted_at IS NULL THEN 0 ELSE 1 END')
108
+ ->orderBy('id')
109
+ ->pluck('id');
110
+
111
+ $redundant = $rows->slice(1)->values();
112
+
113
+ if ($redundant->isNotEmpty()) {
114
+ DB::table($table)->whereIn('id', $redundant->all())->delete();
115
+ }
116
+ }
117
+ }
118
+
119
+ private function indexExists(string $table, string $index): bool
120
+ {
121
+ $database = DB::connection()->getDatabaseName();
122
+
123
+ return DB::table('information_schema.statistics')
124
+ ->where('table_schema', $database)
125
+ ->where('table_name', $table)
126
+ ->where('index_name', $index)
127
+ ->exists();
128
+ }
129
+ };
@@ -0,0 +1,47 @@
1
+ <?php
2
+
3
+ namespace Fleetbase\FleetOps\Exceptions;
4
+
5
+ /**
6
+ * Thrown when a public relationship input — `vendor`, `parent_fleet`, `zone`,
7
+ * and friends — names a resource that does not exist inside the authenticated
8
+ * company.
9
+ *
10
+ * A cross-company identifier is deliberately indistinguishable from a missing
11
+ * one: both raise this, so the response cannot be used to probe whether some
12
+ * other organization holds a given public id.
13
+ */
14
+ class PublicRelationNotFoundException extends \Exception
15
+ {
16
+ /**
17
+ * The request key that failed to resolve, e.g. `parent_fleet`.
18
+ */
19
+ private string $relation;
20
+
21
+ /**
22
+ * The public identifier that was supplied for that key.
23
+ */
24
+ private ?string $identifier;
25
+
26
+ public function __construct(string $relation, ?string $identifier = null, ?\Throwable $previous = null)
27
+ {
28
+ $this->relation = $relation;
29
+ $this->identifier = $identifier;
30
+
31
+ parent::__construct(
32
+ sprintf('No %s resource found for the identifier provided.', str_replace('_', ' ', $relation)),
33
+ 0,
34
+ $previous
35
+ );
36
+ }
37
+
38
+ public function getRelation(): string
39
+ {
40
+ return $this->relation;
41
+ }
42
+
43
+ public function getIdentifier(): ?string
44
+ {
45
+ return $this->identifier;
46
+ }
47
+ }
@@ -2,6 +2,7 @@
2
2
 
3
3
  namespace Fleetbase\FleetOps\Http\Controllers\Api\v1\Concerns;
4
4
 
5
+ use Fleetbase\FleetOps\Exceptions\PublicRelationNotFoundException;
5
6
  use Fleetbase\FleetOps\Models\Contact;
6
7
  use Fleetbase\FleetOps\Models\Device;
7
8
  use Fleetbase\FleetOps\Models\Driver;
@@ -23,16 +24,20 @@ use Illuminate\Validation\ValidationException;
23
24
 
24
25
  trait ResolvesFleetOpsApiResources
25
26
  {
26
- protected function resolveUuid(string $modelClass, ?string $id): ?string
27
+ protected function resolveUuid(string $modelClass, ?string $id, ?string $companyUuid = null): ?string
27
28
  {
28
29
  if (empty($id)) {
29
30
  return null;
30
31
  }
31
32
 
32
- return $this->resolveModel($modelClass, $id)->uuid;
33
+ return $this->resolveModel($modelClass, $id, $companyUuid)->uuid;
33
34
  }
34
35
 
35
- protected function resolveModel(string $modelClass, string $id): Model
36
+ /**
37
+ * @param string|null $companyUuid the company to scope the lookup to; defaults
38
+ * to the session company
39
+ */
40
+ protected function resolveModel(string $modelClass, string $id, ?string $companyUuid = null): Model
36
41
  {
37
42
  $instance = new $modelClass();
38
43
  $query = $modelClass::query()->where(function ($query) use ($id, $instance) {
@@ -47,8 +52,10 @@ trait ResolvesFleetOpsApiResources
47
52
  }
48
53
  });
49
54
 
50
- if (session('company') && $this->modelHasColumn($instance, 'company_uuid')) {
51
- $query->where($instance->qualifyColumn('company_uuid'), session('company'));
55
+ $companyUuid = $companyUuid ?? session('company');
56
+
57
+ if ($companyUuid && $this->modelHasColumn($instance, 'company_uuid')) {
58
+ $query->where($instance->qualifyColumn('company_uuid'), $companyUuid);
52
59
  }
53
60
 
54
61
  $model = $query->first();
@@ -114,17 +121,45 @@ trait ResolvesFleetOpsApiResources
114
121
  return preg_match('/(^uuid$|_uuid$|Uuid$|UUID$)/', $key) === 1;
115
122
  }
116
123
 
117
- protected function applyPublicIdRelation(array &$input, string $requestKey, string $column, string $modelClass, $request): void
124
+ protected function applyPublicIdRelation(array &$input, string $requestKey, string $column, string $modelClass, $request, ?string $companyUuid = null): void
118
125
  {
119
126
  if (!$request->exists($requestKey)) {
120
127
  return;
121
128
  }
122
129
 
123
130
  $input[$column] = filled($request->input($requestKey))
124
- ? $this->resolveUuid($modelClass, $request->input($requestKey))
131
+ ? $this->resolveUuid($modelClass, $request->input($requestKey), $companyUuid)
125
132
  : null;
126
133
  }
127
134
 
135
+ /**
136
+ * Apply a set of public-ID relationship inputs in one pass.
137
+ *
138
+ * `$map` is keyed by the public request key and holds `[column, modelClass]`,
139
+ * e.g. `['parent_fleet' => ['parent_fleet_uuid', Fleet::class]]`. A key that is
140
+ * absent from the request is left untouched; a key sent empty clears the column.
141
+ *
142
+ * Resolution failures are rethrown as a PublicRelationNotFoundException so the
143
+ * caller can say which input was at fault rather than answering with a bare
144
+ * "not found" that names no field.
145
+ *
146
+ * @param array<string, array{0: string, 1: class-string}> $map
147
+ *
148
+ * @throws PublicRelationNotFoundException
149
+ */
150
+ protected function applyPublicIdRelations(array &$input, array $map, $request, ?string $companyUuid = null): void
151
+ {
152
+ foreach ($map as $requestKey => [$column, $modelClass]) {
153
+ try {
154
+ $this->applyPublicIdRelation($input, $requestKey, $column, $modelClass, $request, $companyUuid);
155
+ } catch (ModelNotFoundException $exception) {
156
+ $identifier = $request->input($requestKey);
157
+
158
+ throw new PublicRelationNotFoundException($requestKey, is_scalar($identifier) ? (string) $identifier : null, $exception);
159
+ }
160
+ }
161
+ }
162
+
128
163
  protected function allowedMorphTypes(): array
129
164
  {
130
165
  return [