@fleetbase/fleetops-engine 0.6.67 → 0.6.69

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 (115) hide show
  1. package/RELEASE.md +11 -15
  2. package/addon/components/contact/form.hbs +13 -2
  3. package/addon/components/customer/details.hbs +17 -17
  4. package/addon/components/customer/form.hbs +13 -2
  5. package/addon/components/customer/form.js +0 -56
  6. package/addon/components/driver/details.hbs +13 -19
  7. package/addon/components/driver/form.hbs +20 -35
  8. package/addon/components/driver/form.js +0 -58
  9. package/addon/components/fuel-integration/form.hbs +2 -2
  10. package/addon/components/fuel-integration/form.js +8 -0
  11. package/addon/components/fuel-integration/hub.js +1 -1
  12. package/addon/components/modals/driver-form.hbs +2 -2
  13. package/addon/components/modals/reset-profile-credentials.hbs +23 -0
  14. package/addon/components/modals/reset-profile-credentials.js +70 -0
  15. package/addon/components/profile-identity-hint.hbs +13 -0
  16. package/addon/components/profile-identity-hint.js +49 -0
  17. package/addon/controllers/management/drivers/index.js +4 -0
  18. package/addon/extension.js +4 -2
  19. package/addon/services/contact-actions.js +50 -89
  20. package/addon/services/driver-actions.js +98 -0
  21. package/addon/templates/connectivity/fuel-providers/details/index.hbs +1 -1
  22. package/addon/utils/fuel-transaction.js +2 -0
  23. package/addon/utils/profile-login-actions.js +172 -0
  24. package/app/components/modals/{reset-customer-credentials.js → reset-profile-credentials.js} +1 -1
  25. package/app/components/profile-identity-hint.js +1 -0
  26. package/composer.json +1 -1
  27. package/docs/SAFEE.md +5 -0
  28. package/docs/TELEMATICS_QUEUES.md +59 -8
  29. package/extension.json +1 -1
  30. package/package.json +3 -3
  31. package/scripts/pest-bootstrap.php +12 -0
  32. package/server/config/fuel-providers.php +53 -0
  33. package/server/migrations/2026_09_21_000001_convert_profile_only_users_to_managed_accounts.php +132 -0
  34. package/server/resources/views/mail/driver-credentials.blade.php +21 -0
  35. package/server/src/Console/Commands/FixCustomerCompanies.php +1 -1
  36. package/server/src/Console/Commands/FixDriverCompanies.php +1 -1
  37. package/server/src/Exceptions/ProfileIdentityConflictException.php +34 -0
  38. package/server/src/Http/Controllers/Api/v1/CustomerController.php +14 -0
  39. package/server/src/Http/Controllers/Api/v1/DriverController.php +56 -39
  40. package/server/src/Http/Controllers/Internal/v1/ContactController.php +5 -17
  41. package/server/src/Http/Controllers/Internal/v1/CustomerController.php +40 -4
  42. package/server/src/Http/Controllers/Internal/v1/DriverController.php +182 -162
  43. package/server/src/Http/Controllers/Internal/v1/FleetOpsLookupController.php +47 -0
  44. package/server/src/Http/Controllers/Internal/v1/OrderController.php +156 -27
  45. package/server/src/Http/Requests/CreateDriverRequest.php +6 -2
  46. package/server/src/Http/Requests/Internal/CreateDriverRequest.php +9 -18
  47. package/server/src/Http/Resources/v1/Contact.php +3 -0
  48. package/server/src/Http/Resources/v1/Driver.php +3 -0
  49. package/server/src/Jobs/PollTelematicTelemetry.php +36 -0
  50. package/server/src/Jobs/SyncTelematicDevicesJob.php +2 -2
  51. package/server/src/Mail/DriverCredentialsMail.php +55 -0
  52. package/server/src/Models/Contact.php +55 -90
  53. package/server/src/Models/Sensor.php +3 -5
  54. package/server/src/Observers/ContactObserver.php +2 -22
  55. package/server/src/Observers/DriverObserver.php +5 -6
  56. package/server/src/Providers/FleetOpsServiceProvider.php +15 -0
  57. package/server/src/Support/Ai/Capabilities/AbstractFleetOpsAICapability.php +62 -5
  58. package/server/src/Support/Ai/Capabilities/AssetStatusCapability.php +24 -5
  59. package/server/src/Support/Ai/Capabilities/CreateOrderPreviewCapability.php +6 -2
  60. package/server/src/Support/Ai/Capabilities/OrderInsightsCapability.php +40 -7
  61. package/server/src/Support/Ai/Capabilities/SearchResourcesCapability.php +86 -24
  62. package/server/src/Support/Ai/FleetOpsAiConsoleCommands.php +122 -0
  63. package/server/src/Support/Ai/Tools/CreateOrderTool.php +86 -0
  64. package/server/src/Support/Ai/Tools/ImportOrdersTool.php +53 -0
  65. package/server/src/Support/Ai/Tools/OptimizeOrderRouteTool.php +70 -0
  66. package/server/src/Support/Ai/Tools/SearchResourcesTool.php +73 -0
  67. package/server/src/Support/FuelProviders/Providers/SascoFuelProvider.php +272 -0
  68. package/server/src/Support/ProfileAccountManager.php +405 -0
  69. package/server/src/Support/Telematics/TelematicService.php +6 -4
  70. package/server/src/Support/Telematics/Telemetry/Inbox.php +27 -9
  71. package/server/src/routes.php +5 -0
  72. package/server/tests/AiOperationalQueryCapabilityTest.php +1 -0
  73. package/server/tests/ApiCustomerControllerContractsTest.php +21 -0
  74. package/server/tests/ApiDriverControllerContractsTest.php +102 -20
  75. package/server/tests/CommandContractsTest.php +4 -4
  76. package/server/tests/ControllerHelperContractsTest.php +3 -11
  77. package/server/tests/Feature/Http/AfaqyRealtimeIngestionTest.php +5 -0
  78. package/server/tests/Feature/Http/Api/DriverControllerAuthFlowsTest.php +36 -0
  79. package/server/tests/Feature/Http/Api/DriverControllerSessionHelpersTest.php +19 -6
  80. package/server/tests/Feature/Http/Api/ProfileOnlyUserConversionMigrationTest.php +201 -0
  81. package/server/tests/Feature/Http/Internal/ContactControllerEndpointsTest.php +6 -4
  82. package/server/tests/Feature/Http/Internal/CustomerControllerCredentialsTest.php +6 -2
  83. package/server/tests/Feature/Http/Internal/DriverControllerContractsTest.php +23 -0
  84. package/server/tests/Feature/Http/Internal/DriverControllerCreateRecordTest.php +42 -17
  85. package/server/tests/Feature/Http/Internal/DriverControllerExistingUserAdoptionTest.php +168 -277
  86. package/server/tests/Feature/Http/Internal/DriverControllerLoginManagementTest.php +716 -0
  87. package/server/tests/Feature/Http/Internal/OrderControllerContractsTest.php +10 -3
  88. package/server/tests/Feature/Http/Internal/OrderControllerImportFromFilesTest.php +18 -0
  89. package/server/tests/Feature/Http/Internal/OrderControllerTenantScopingTest.php +411 -0
  90. package/server/tests/Feature/Http/Internal/OrderControllerUpstreamNotFoundTest.php +8 -20
  91. package/server/tests/Feature/Http/ManualTelemetryPollingTest.php +88 -4
  92. package/server/tests/InternalCustomerControllerContractsTest.php +32 -1
  93. package/server/tests/ModelAccessorContractsTest.php +82 -20
  94. package/server/tests/ObserverContractsTest.php +27 -2
  95. package/server/tests/RequestContractsTest.php +19 -11
  96. package/server/tests/SupportJobAndAiCoverageTest.php +3 -3
  97. package/server/tests/TelematicControllerContractsTest.php +22 -4
  98. package/server/tests/Unit/Models/ContactCustomerUserTest.php +90 -14
  99. package/server/tests/Unit/Models/ContactTest.php +48 -5
  100. package/server/tests/Unit/Models/SensorTest.php +2 -2
  101. package/server/tests/Unit/Models/SmallModelSurfacesTest.php +31 -32
  102. package/server/tests/Unit/Observers/ObserverListenerSeamsTest.php +7 -2
  103. package/server/tests/Unit/Providers/FleetOpsAiRegistrationTest.php +15 -2
  104. package/server/tests/Unit/Support/Ai/AssetStatusCapabilityQueriesTest.php +42 -0
  105. package/server/tests/Unit/Support/Ai/Capabilities/OrderInsightsCapabilityTest.php +19 -1
  106. package/server/tests/Unit/Support/Ai/Capabilities/SearchResourcesCapabilityTest.php +85 -1
  107. package/server/tests/Unit/Support/Ai/FleetOpsAiConsoleCommandsTest.php +31 -0
  108. package/server/tests/Unit/Support/Ai/Tools/FleetOpsAiToolsTest.php +246 -0
  109. package/server/tests/Unit/Support/AiCapabilityPermissionsTest.php +3 -3
  110. package/server/tests/Unit/Support/ProfileAccountManagerTest.php +643 -0
  111. package/server/tests/Unit/Support/SascoFuelProviderTest.php +338 -0
  112. package/translations/en-us.yaml +37 -2
  113. package/translations/uk-ua.yaml +3475 -0
  114. package/addon/components/modals/reset-customer-credentials.hbs +0 -18
  115. package/addon/components/modals/reset-customer-credentials.js +0 -55
@@ -39,6 +39,7 @@ use Fleetbase\Models\File;
39
39
  use Fleetbase\Models\Type;
40
40
  use Fleetbase\Support\Auth;
41
41
  use Fleetbase\Support\TemplateString;
42
+ use Illuminate\Database\Eloquent\Builder;
42
43
  use Illuminate\Database\Eloquent\ModelNotFoundException;
43
44
  use Illuminate\Database\QueryException;
44
45
  use Illuminate\Http\Request;
@@ -341,7 +342,8 @@ class OrderController extends FleetOpsController
341
342
  $info = Utils::lookupIp();
342
343
  $disk = $request->input('disk', config('filesystems.default'));
343
344
  $files = $request->input('files');
344
- $files = File::whereIn('uuid', $files)->get();
345
+ /** @var \Illuminate\Database\Eloquent\Collection<int, File> $files */
346
+ $files = $this->scopedToCompany(File::whereIn('uuid', $files))->get();
345
347
  $country = $request->input('country', Utils::or($info, ['country_name', 'region'], 'Singapore'));
346
348
 
347
349
  $validFileTypes = ['csv', 'tsv', 'xls', 'xlsx'];
@@ -513,7 +515,13 @@ class OrderController extends FleetOpsController
513
515
  }
514
516
 
515
517
  // Prepare Order UUID Collection
516
- $orderUuids = collect($data['ids'])->unique()->values();
518
+ //
519
+ // Resolved through the company-scoped lookup so ids naming another
520
+ // organization's orders are dropped here rather than being assigned,
521
+ // counted in the response and queued for notification.
522
+ $orderUuids = $this->ordersByUuid(collect($data['ids'])->unique()->values()->all())
523
+ ->pluck('uuid')
524
+ ->values();
517
525
 
518
526
  // Bulk Update Inside A Transaction
519
527
  $this->runTransaction(function () use ($orderUuids, $driver): void {
@@ -545,8 +553,14 @@ class OrderController extends FleetOpsController
545
553
  */
546
554
  public function cancel(CancelOrderRequest $request)
547
555
  {
548
- /** @var Order */
556
+ /** @var Order|null */
549
557
  $order = $this->findOrderByUuid($request->input('order'));
558
+ if (!$order) {
559
+ // `exists:orders,uuid` on the form request is a global existence
560
+ // check, so a known uuid belonging to another organization reaches
561
+ // here and must be rejected rather than dereferenced.
562
+ return $this->errorResponse('No order found to cancel.');
563
+ }
550
564
 
551
565
  $order->cancel();
552
566
 
@@ -599,9 +613,39 @@ class OrderController extends FleetOpsController
599
613
  );
600
614
  }
601
615
 
616
+ /**
617
+ * Constrain a tenant-owned lookup to the company the caller is acting for.
618
+ *
619
+ * The order-lifecycle actions on this controller receive their target as a
620
+ * caller-supplied identifier in the request body or query string rather than
621
+ * as a bound route parameter, so nothing upstream narrows these queries to
622
+ * the caller's tenant: `fleetbase.protected` only checks that the caller
623
+ * holds the named RBAC capability, never which company the record belongs to.
624
+ *
625
+ * A missing company session fails the query closed instead of letting it run
626
+ * unbounded across every tenant.
627
+ */
628
+ protected function scopedToCompany(Builder $query): Builder
629
+ {
630
+ $companyUuid = $this->sessionCompany();
631
+ if (!$companyUuid) {
632
+ $query->whereRaw('1 = 0');
633
+
634
+ return $query;
635
+ }
636
+
637
+ return $query->where($query->getModel()->qualifyColumn('company_uuid'), $companyUuid);
638
+ }
639
+
640
+ /**
641
+ * @return \Illuminate\Database\Eloquent\Collection<int, Order>
642
+ */
602
643
  protected function ordersByUuid(array $ids)
603
644
  {
604
- return Order::whereIn('uuid', $ids)->get();
645
+ /** @var \Illuminate\Database\Eloquent\Collection<int, Order> $orders */
646
+ $orders = $this->scopedToCompany(Order::whereIn('uuid', $ids))->get();
647
+
648
+ return $orders;
605
649
  }
606
650
 
607
651
  protected function trackingStatusExists(?string $trackingNumberUuid, string $code): bool
@@ -611,7 +655,10 @@ class OrderController extends FleetOpsController
611
655
 
612
656
  protected function findDriverByUuid(string $uuid): ?Driver
613
657
  {
614
- return Driver::whereUuid($uuid)->first();
658
+ /** @var Driver|null $driver */
659
+ $driver = $this->scopedToCompany(Driver::whereUuid($uuid))->first();
660
+
661
+ return $driver;
615
662
  }
616
663
 
617
664
  protected function driverDisplayName(Driver $driver): string
@@ -630,17 +677,47 @@ class OrderController extends FleetOpsController
630
677
 
631
678
  protected function findOrderByUuid(string $uuid): ?Order
632
679
  {
633
- return Order::where('uuid', $uuid)->first();
680
+ /** @var Order|null $order */
681
+ $order = $this->scopedToCompany(Order::where('uuid', $uuid))->first();
682
+
683
+ return $order;
634
684
  }
635
685
 
636
- protected function findOrderById(string $id, array $with = []): ?Order
686
+ /**
687
+ * Resolve an order by uuid or public_id, constrained to the caller's company.
688
+ *
689
+ * The identifier match is grouped so the company constraint applies to both
690
+ * arms — ungrouped it would read as `uuid = ? OR (public_id = ? AND
691
+ * company_uuid = ?)` and still resolve another organization's orders.
692
+ *
693
+ * The identifier is whatever the caller put in the request, so anything
694
+ * that is not a non-empty string resolves to no order rather than raising.
695
+ *
696
+ * @param array<int, string> $with
697
+ */
698
+ protected function findOrderById(mixed $id, array $with = []): ?Order
637
699
  {
638
- return Order::findById($id, $with);
700
+ if (!is_string($id) || $id === '') {
701
+ return null;
702
+ }
703
+
704
+ /** @var Order|null $order */
705
+ $order = $this->scopedToCompany(Order::query())
706
+ ->where(function (Builder $query) use ($id) {
707
+ $query->where('uuid', $id)->orWhere('public_id', $id);
708
+ })
709
+ ->with($with)
710
+ ->first();
711
+
712
+ return $order;
639
713
  }
640
714
 
641
715
  protected function findOrderRouteForEdit(string $uuid): ?Order
642
716
  {
643
- return Order::where('uuid', $uuid)->with(['payload'])->first();
717
+ /** @var Order|null $order */
718
+ $order = $this->scopedToCompany(Order::where('uuid', $uuid))->with(['payload'])->first();
719
+
720
+ return $order;
644
721
  }
645
722
 
646
723
  protected function orderResponse(Order $order): array
@@ -650,7 +727,7 @@ class OrderController extends FleetOpsController
650
727
 
651
728
  protected function assignDriverToOrders($orderUuids, Driver $driver): void
652
729
  {
653
- Order::whereIn('uuid', $orderUuids)->update([
730
+ $this->scopedToCompany(Order::whereIn('uuid', $orderUuids))->update([
654
731
  'driver_assigned_uuid' => $driver->uuid,
655
732
  'updated_at' => now(),
656
733
  ]);
@@ -739,17 +816,28 @@ class OrderController extends FleetOpsController
739
816
 
740
817
  protected function findOrderForStart(?string $uuid): ?Order
741
818
  {
742
- return Order::where('uuid', $uuid)->withoutGlobalScopes()->first();
819
+ /** @var Order|null $order */
820
+ $order = $this->scopedToCompany(Order::where('uuid', $uuid)->withoutGlobalScopes())->first();
821
+
822
+ return $order;
743
823
  }
744
824
 
745
825
  protected function findDriverForStart(?string $uuid): ?Driver
746
826
  {
747
- return Driver::where('uuid', $uuid)->withoutGlobalScopes()->first();
827
+ /** @var Driver|null $driver */
828
+ $driver = $this->scopedToCompany(Driver::where('uuid', $uuid)->withoutGlobalScopes())->first();
829
+
830
+ return $driver;
748
831
  }
749
832
 
750
833
  protected function findPayloadForStart(?string $uuid): ?Payload
751
834
  {
752
- return Payload::where('uuid', $uuid)->withoutGlobalScopes()->with(['waypoints', 'waypointMarkers', 'entities'])->first();
835
+ /** @var Payload|null $payload */
836
+ $payload = $this->scopedToCompany(Payload::where('uuid', $uuid)->withoutGlobalScopes())
837
+ ->with(['waypoints', 'waypointMarkers', 'entities'])
838
+ ->first();
839
+
840
+ return $payload;
753
841
  }
754
842
 
755
843
  protected function dispatchDomainEvent(object $event): object
@@ -771,7 +859,7 @@ class OrderController extends FleetOpsController
771
859
  */
772
860
  public function updateActivity(string $id, Request $request)
773
861
  {
774
- $order = Order::findById($id, [
862
+ $order = $this->findOrderById($id, [
775
863
  'driverAssigned',
776
864
  'payload.entities',
777
865
  'payload.pickup',
@@ -882,9 +970,8 @@ class OrderController extends FleetOpsController
882
970
  */
883
971
  public function nextActivity(string $id, Request $request)
884
972
  {
885
- try {
886
- $order = Order::findByIdOrFail($id);
887
- } catch (ModelNotFoundException $e) {
973
+ $order = $this->findOrderById($id);
974
+ if (!$order) {
888
975
  return response()->error('No order found.');
889
976
  }
890
977
 
@@ -935,7 +1022,7 @@ class OrderController extends FleetOpsController
935
1022
  */
936
1023
  public function setDestination(string $id, string $placeId)
937
1024
  {
938
- $order = Order::findById($id, [
1025
+ $order = $this->findOrderById($id, [
939
1026
  'payload.pickup',
940
1027
  'payload.dropoff',
941
1028
  'payload.return',
@@ -1013,7 +1100,7 @@ class OrderController extends FleetOpsController
1013
1100
  return response()->error($errorMessage, 422);
1014
1101
  }
1015
1102
 
1016
- $order = Order::findById($id, ['payload.pickup', 'payload.dropoff', 'payload.return', 'payload.waypoints', 'payload.waypointMarkers.place']);
1103
+ $order = $this->findOrderById($id, ['payload.pickup', 'payload.dropoff', 'payload.return', 'payload.waypoints', 'payload.waypointMarkers.place']);
1017
1104
  if (!$order) {
1018
1105
  return response()->error('No order found.');
1019
1106
  }
@@ -1214,7 +1301,14 @@ class OrderController extends FleetOpsController
1214
1301
  }
1215
1302
 
1216
1303
  if (is_string($proof)) {
1217
- return Proof::where('public_id', $proof)->orWhere('uuid', $proof)->first();
1304
+ /** @var Proof|null $resolved */
1305
+ $resolved = $this->scopedToCompany(Proof::query())
1306
+ ->where(function (Builder $query) use ($proof) {
1307
+ $query->where('public_id', $proof)->orWhere('uuid', $proof);
1308
+ })
1309
+ ->first();
1310
+
1311
+ return $resolved;
1218
1312
  }
1219
1313
 
1220
1314
  return null;
@@ -1419,7 +1513,12 @@ class OrderController extends FleetOpsController
1419
1513
 
1420
1514
  protected function findOrderForDriverPing(string $id): Order
1421
1515
  {
1422
- return Order::findByIdOrFail($id, ['driverAssigned']);
1516
+ $order = $this->findOrderById($id, ['driverAssigned']);
1517
+ if (!$order) {
1518
+ throw new ModelNotFoundException();
1519
+ }
1520
+
1521
+ return $order;
1423
1522
  }
1424
1523
 
1425
1524
  protected function sendDriverPing(Driver $driver, Order $order): void
@@ -1692,7 +1791,10 @@ class OrderController extends FleetOpsController
1692
1791
 
1693
1792
  protected function findOrderForProofs(string $id): ?Order
1694
1793
  {
1695
- return Order::where('uuid', $id)->first();
1794
+ /** @var Order|null $order */
1795
+ $order = $this->scopedToCompany(Order::where('uuid', $id))->first();
1796
+
1797
+ return $order;
1696
1798
  }
1697
1799
 
1698
1800
  protected function findWaypointProofSubject(Order $order, string $subjectId): ?Waypoint
@@ -1708,7 +1810,10 @@ class OrderController extends FleetOpsController
1708
1810
 
1709
1811
  protected function findEntityProofSubject(string $subjectId): ?Entity
1710
1812
  {
1711
- return Entity::where('uuid', $subjectId)->withoutGlobalScopes()->first();
1813
+ /** @var Entity|null $entity */
1814
+ $entity = $this->scopedToCompany(Entity::where('uuid', $subjectId)->withoutGlobalScopes())->first();
1815
+
1816
+ return $entity;
1712
1817
  }
1713
1818
 
1714
1819
  protected function proofsForSubject(Order $order, Order|Waypoint|Entity $subject)
@@ -1772,14 +1877,26 @@ class OrderController extends FleetOpsController
1772
1877
  return new OrderResource($order);
1773
1878
  }
1774
1879
 
1880
+ /**
1881
+ * Find the order a tracking number belongs to, for the public Track Order page.
1882
+ *
1883
+ * Deliberately NOT company-scoped, unlike the lifecycle lookups above. The
1884
+ * `fleet-ops/lookup` route is public: the tracking page is used by recipients who
1885
+ * have no session, and the tracking number itself is what grants access. Scoping
1886
+ * it to the session company made every lookup from that page fail, because there
1887
+ * is no company to scope to.
1888
+ */
1775
1889
  protected function findOrderByTrackingNumber(string $trackingNumber): ?Order
1776
1890
  {
1777
- return Order::whereHas(
1891
+ /** @var Order|null $order */
1892
+ $order = Order::whereHas(
1778
1893
  'trackingNumber',
1779
1894
  function ($query) use ($trackingNumber) {
1780
1895
  $query->where('tracking_number', $trackingNumber);
1781
1896
  }
1782
1897
  )->first();
1898
+
1899
+ return $order;
1783
1900
  }
1784
1901
 
1785
1902
  /**
@@ -1824,13 +1941,25 @@ class OrderController extends FleetOpsController
1824
1941
 
1825
1942
  protected function findOrderForSchedule(?string $id): ?Order
1826
1943
  {
1827
- return Order::findById($id);
1944
+ return $this->findOrderById($id);
1828
1945
  }
1829
1946
 
1947
+ /**
1948
+ * Resolve a driver by uuid or public_id, constrained to the caller's company.
1949
+ *
1950
+ * The identifier match is grouped so the company constraint applies to both
1951
+ * arms — ungrouped it would read as `uuid = ? OR (public_id = ? AND
1952
+ * company_uuid = ?)` and still resolve another organization's drivers.
1953
+ */
1830
1954
  protected function findDriverForSchedule(string $id): ?Driver
1831
1955
  {
1832
- return Driver::where('uuid', $id)
1833
- ->orWhere('public_id', $id)
1956
+ /** @var Driver|null $driver */
1957
+ $driver = $this->scopedToCompany(Driver::query())
1958
+ ->where(function (Builder $query) use ($id) {
1959
+ $query->where('uuid', $id)->orWhere('public_id', $id);
1960
+ })
1834
1961
  ->first();
1962
+
1963
+ return $driver;
1835
1964
  }
1836
1965
  }
@@ -41,9 +41,13 @@ class CreateDriverRequest extends FleetbaseRequest
41
41
  * value later. Both are still validated and still unique when they
42
42
  * are supplied; a driver created without them simply cannot sign in
43
43
  * to Navigator until credentials are added.
44
+ *
45
+ * On create, availability is decided by ProfileAccountManager: a team
46
+ * member of the company with the email or phone is linked to the new
47
+ * driver instead of being rejected as a duplicate.
44
48
  */
45
- 'email' => ['nullable', Rule::when($this->filled('email'), ['email']), $this->uniqueAmongUsers()],
46
- 'phone' => ['nullable', 'string', $this->uniqueAmongUsers()],
49
+ 'email' => ['nullable', Rule::when($this->filled('email'), ['email']), Rule::when(!$isCreating, [$this->uniqueAmongUsers()])],
50
+ 'phone' => ['nullable', 'string', Rule::when(!$isCreating, [$this->uniqueAmongUsers()])],
47
51
  'password' => 'nullable|string',
48
52
  'timezone' => 'nullable|string|max:64',
49
53
 
@@ -23,22 +23,17 @@ class CreateDriverRequest extends CreateDriverApiRequest
23
23
  */
24
24
  public function rules(): array
25
25
  {
26
- $isCreating = $this->isMethod('POST');
27
- $isCreatingWithUser = $this->filled('driver.user_uuid');
28
- $shouldValidateUserAttributes = $isCreating && !$isCreatingWithUser;
26
+ $isCreating = $this->isMethod('POST');
29
27
 
30
28
  return [
31
- // Required fields for driver creation
32
- 'name' => [Rule::requiredIf($shouldValidateUserAttributes), 'nullable', 'string', 'max:255'],
33
- 'email' => [
34
- Rule::requiredIf($shouldValidateUserAttributes),
35
- Rule::when($this->filled('email'), ['email']),
36
- Rule::when($shouldValidateUserAttributes, [Rule::unique('users')->whereNull('deleted_at')]),
37
- ],
38
- 'phone' => [
39
- Rule::requiredIf($shouldValidateUserAttributes),
40
- Rule::when($shouldValidateUserAttributes, [Rule::unique('users')->whereNull('deleted_at')]),
41
- ],
29
+ // The driver profile manages its login account: name, email and phone
30
+ // are proxied to it. Email/phone availability is checked against the
31
+ // account the profile resolves to (see ProfileAccountManager), because a
32
+ // team member of the organization with the same email or phone is linked
33
+ // rather than rejected as a duplicate.
34
+ 'name' => [Rule::requiredIf($isCreating), 'nullable', 'string', 'max:255'],
35
+ 'email' => ['nullable', Rule::when($this->filled('email'), ['email'])],
36
+ 'phone' => ['nullable', 'string'],
42
37
 
43
38
  // Optional fields
44
39
  'password' => 'nullable|string|min:8',
@@ -84,11 +79,7 @@ class CreateDriverRequest extends CreateDriverApiRequest
84
79
  {
85
80
  return [
86
81
  'name.required' => 'Driver name is required.',
87
- 'email.required' => 'Email address is required.',
88
82
  'email.email' => 'Please provide a valid email address.',
89
- 'email.unique' => 'This email address is already registered.',
90
- 'phone.required' => 'Phone number is required.',
91
- 'phone.unique' => 'This phone number is already registered.',
92
83
  'password.min' => 'Password must be at least 8 characters.',
93
84
  ];
94
85
  }
@@ -2,6 +2,7 @@
2
2
 
3
3
  namespace Fleetbase\FleetOps\Http\Resources\v1;
4
4
 
5
+ use Fleetbase\FleetOps\Support\ProfileAccountManager;
5
6
  use Fleetbase\FleetOps\Support\Utils;
6
7
  use Fleetbase\Http\Resources\FleetbaseResource;
7
8
  use Fleetbase\Http\Resources\User;
@@ -40,6 +41,8 @@ class Contact extends FleetbaseResource
40
41
  'places' => $this->whenLoaded('places', fn () => Place::collection($this->places)->without('owner')),
41
42
  'user' => $this->when(Http::isInternalRequest(), fn () => new User($this->user), fn () => $this->user ? $this->user->public_id : null),
42
43
  'address' => $this->when(Http::isInternalRequest(), data_get($this, 'place.address')),
44
+ 'is_staff_linked' => $this->when(Http::isInternalRequest(), fn () => ProfileAccountManager::isStaffAccount($this->user)),
45
+ 'login_status' => $this->when(Http::isInternalRequest(), fn () => data_get($this, 'user.status')),
43
46
  'address_street' => $this->when(Http::isInternalRequest(), data_get($this, 'place.street1')),
44
47
  'type' => $this->type ?? null,
45
48
  'customer_type' => $this->when(isset($this->customer_type), Utils::toEmberResourceType($this->customer_type)),
@@ -2,6 +2,7 @@
2
2
 
3
3
  namespace Fleetbase\FleetOps\Http\Resources\v1;
4
4
 
5
+ use Fleetbase\FleetOps\Support\ProfileAccountManager;
5
6
  use Fleetbase\FleetOps\Support\Utils;
6
7
  use Fleetbase\Http\Resources\FleetbaseResource;
7
8
  use Fleetbase\Http\Resources\FleetbaseResourceCollection;
@@ -32,6 +33,8 @@ class Driver extends FleetbaseResource
32
33
  'public_id' => $this->when(Http::isInternalRequest(), $this->public_id),
33
34
  'user' => $this->when(Http::isPublicRequest(), fn () => $this->user ? $this->user->public_id : null, new User($this->user)),
34
35
  'internal_id' => $this->internal_id,
36
+ 'is_staff_linked' => $this->when(Http::isInternalRequest(), fn () => ProfileAccountManager::isStaffAccount($this->user)),
37
+ 'login_status' => $this->when(Http::isInternalRequest(), fn () => data_get($this, 'user.status')),
35
38
  'company' => $this->when(Http::isPublicRequest(), fn () => $this->company ? $this->company->public_id : null),
36
39
  'company_name' => $this->when(Http::isPublicRequest(), fn () => $this->company ? $this->company->name : null),
37
40
  'name' => $this->name,
@@ -109,6 +109,7 @@ class PollTelematicTelemetry implements ShouldQueue, ShouldBeUnique
109
109
  try {
110
110
  $started = microtime(true);
111
111
  DB::table('telematic_sync_runs')->insert(['uuid' => $run, 'telematic_uuid' => $telematic->uuid, 'status' => 'fetching', 'created_at' => now(), 'updated_at' => now()]);
112
+ $this->adoptPendingRequest($run);
112
113
  $this->manualProgress([
113
114
  'last_sync_run_uuid' => $run, 'last_sync_run_job_id' => $this->manualJobId,
114
115
  'last_sync_result' => 'running', 'last_sync_phase' => 'fetching_inventory',
@@ -186,6 +187,41 @@ class PollTelematicTelemetry implements ShouldQueue, ShouldBeUnique
186
187
  return $remaining;
187
188
  }
188
189
 
190
+ /**
191
+ * Bind a manual request that has no active run to this scheduled sweep.
192
+ *
193
+ * Every poll job for a connection shares one unique lease, so while a scheduled sweep
194
+ * runs, the request's own job is either not queued (the request was recorded while
195
+ * the lease was held) or no longer exists (lost before or after an interrupted run).
196
+ */
197
+ private function adoptPendingRequest(string $run): void
198
+ {
199
+ if ($this->manualJobId) {
200
+ return;
201
+ }
202
+ DB::transaction(function () use ($run) {
203
+ $connection = Telematic::withoutGlobalScopes()->where('uuid', $this->telematicUuid)->lockForUpdate()->first();
204
+ $meta = $connection?->meta ?? [];
205
+ $request = data_get($meta, 'last_sync_job_id');
206
+ if (!$request || !in_array(data_get($meta, 'last_sync_result'), Inbox::PENDING_REQUEST_RESULTS, true)) {
207
+ return;
208
+ }
209
+ $bound = data_get($meta, 'last_sync_run_uuid');
210
+ if ($bound && DB::table('telematic_sync_runs')->where('uuid', $bound)->whereIn('status', ['fetching', 'ingesting'])->exists()) {
211
+ return;
212
+ }
213
+ $connection->status = 'synchronizing';
214
+ $connection->meta = array_merge($meta, [
215
+ 'last_sync_run_uuid' => $run, 'last_sync_run_job_id' => $request,
216
+ 'last_sync_result' => 'running', 'last_sync_phase' => 'fetching_inventory',
217
+ 'last_sync_fetched_total' => 0, 'last_sync_page_count' => 0,
218
+ 'last_sync_linked_total' => 0, 'last_sync_failed_total' => 0, 'last_sync_error' => null,
219
+ 'last_sync_progress_at' => now()->toDateTimeString(),
220
+ ]);
221
+ $connection->save();
222
+ });
223
+ }
224
+
189
225
  private function manualProgress(array $attributes): void
190
226
  {
191
227
  if (!$this->manualJobId) {
@@ -58,8 +58,8 @@ class SyncTelematicDevicesJob implements ShouldQueue
58
58
  try {
59
59
  $service->queueTelemetrySync($this->telematic, $this->options, $this->jobId);
60
60
  } catch (\Illuminate\Validation\ValidationException) {
61
- // Paused or already queued/running. Failing here would mark the connection
62
- // as errored and detach the active request's progress tracking.
61
+ // Polling was paused after this job was queued. Failing here would mark the
62
+ // connection as errored and detach any active request's progress tracking.
63
63
  Log::info('Skipped legacy telematics sync redirect.', ['telematic_uuid' => $this->telematic->uuid]);
64
64
  }
65
65
  }
@@ -0,0 +1,55 @@
1
+ <?php
2
+
3
+ namespace Fleetbase\FleetOps\Mail;
4
+
5
+ use Fleetbase\FleetOps\Models\Driver;
6
+ use Fleetbase\Models\User;
7
+ use Illuminate\Bus\Queueable;
8
+ use Illuminate\Mail\Mailable;
9
+ use Illuminate\Mail\Mailables\Content;
10
+ use Illuminate\Mail\Mailables\Envelope;
11
+ use Illuminate\Queue\SerializesModels;
12
+
13
+ class DriverCredentialsMail extends Mailable
14
+ {
15
+ use Queueable;
16
+ use SerializesModels;
17
+
18
+ /**
19
+ * Create a new message instance.
20
+ */
21
+ public function __construct(private string $plaintextPassword, private Driver $driver, private User $user)
22
+ {
23
+ }
24
+
25
+ /**
26
+ * Get the message envelope.
27
+ */
28
+ public function envelope(): Envelope
29
+ {
30
+ $this->driver->loadMissing('company');
31
+
32
+ return new Envelope(
33
+ subject: 'Your ' . ($this->driver->company?->name ?? config('app.name')) . ' driver sign-in details',
34
+ );
35
+ }
36
+
37
+ /**
38
+ * Get the message content definition.
39
+ */
40
+ public function content(): Content
41
+ {
42
+ $this->driver->loadMissing('company');
43
+
44
+ return new Content(
45
+ markdown: 'fleetops::mail.driver-credentials',
46
+ with: [
47
+ 'driver' => $this->driver,
48
+ 'user' => $this->user,
49
+ 'companyName' => $this->driver->company?->name ?? config('app.name'),
50
+ 'identity' => $this->user->email ?? $this->user->phone,
51
+ 'plaintextPassword' => $this->plaintextPassword,
52
+ ]
53
+ );
54
+ }
55
+ }