@fleetbase/fleetops-engine 0.6.71 → 0.6.72

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 (378) hide show
  1. package/RELEASE.md +15 -18
  2. package/addon/components/admin/tracking-page-settings.hbs +56 -0
  3. package/addon/components/admin/tracking-page-settings.js +67 -0
  4. package/addon/components/device/form.hbs +2 -2
  5. package/addon/components/driver/details.hbs +1 -1
  6. package/addon/components/driver/form.hbs +1 -1
  7. package/addon/components/equipment/form.hbs +1 -1
  8. package/addon/components/layout/fleet-ops-sidebar.js +11 -3
  9. package/addon/components/maintenance/form.hbs +3 -3
  10. package/addon/components/map/google-live-map.js +2 -2
  11. package/addon/components/map/leaflet-live-map.js +45 -32
  12. package/addon/components/orchestrator/phase-builder.js +2 -1
  13. package/addon/components/orchestrator-workbench.hbs +7 -1
  14. package/addon/components/orchestrator-workbench.js +16 -2
  15. package/addon/components/order/form/route.hbs +1 -1
  16. package/addon/components/order/form/route.js +13 -1
  17. package/addon/components/service-rate/details.hbs +1 -1
  18. package/addon/components/service-rate/form.hbs +1 -1
  19. package/addon/components/tracking-page/full-view.hbs +276 -0
  20. package/addon/components/tracking-page/full-view.js +474 -0
  21. package/addon/components/tracking-page/locale-selector.js +30 -0
  22. package/addon/components/tracking-page/map.hbs +44 -0
  23. package/addon/components/tracking-page/map.js +328 -0
  24. package/addon/components/tracking-page/order-list.hbs +35 -0
  25. package/addon/components/tracking-page/public-view.hbs +60 -0
  26. package/addon/components/tracking-page/settings-preview.hbs +21 -0
  27. package/addon/components/tracking-page/settings-preview.js +23 -0
  28. package/addon/components/tracking-page/verify-panel.hbs +109 -0
  29. package/addon/components/tracking-page.hbs +142 -0
  30. package/addon/components/tracking-page.js +564 -0
  31. package/addon/components/trailer/form.hbs +2 -2
  32. package/addon/components/vehicle/form.hbs +3 -3
  33. package/addon/components/warranty/form.hbs +2 -2
  34. package/addon/components/widget/live-fleet.js +54 -6
  35. package/addon/components/widget/proof-of-delivery.hbs +49 -0
  36. package/addon/components/widget/proof-of-delivery.js +62 -0
  37. package/addon/components/work-order/form.hbs +3 -3
  38. package/addon/controllers/operations/orders/index/details.js +19 -3
  39. package/addon/controllers/operations/scheduler/index.js +45 -14
  40. package/addon/controllers/settings/index.js +6 -0
  41. package/addon/controllers/settings/map.js +75 -0
  42. package/addon/controllers/settings/orchestrator.js +8 -6
  43. package/addon/controllers/settings/tracking-page.js +212 -0
  44. package/addon/extension.js +65 -1
  45. package/addon/helpers/parcel-box-image.js +16 -0
  46. package/addon/routes/operations/orders/index/new.js +5 -4
  47. package/addon/routes/operations/scheduler/index.js +1 -1
  48. package/addon/routes/settings/tracking-page.js +31 -0
  49. package/addon/routes.js +1 -0
  50. package/addon/services/location.js +69 -21
  51. package/addon/services/map-settings.js +28 -0
  52. package/addon/services/order-allocation.js +1 -1
  53. package/addon/styles/fleetops-engine.css +1391 -0
  54. package/addon/templates/settings/map.hbs +46 -0
  55. package/addon/templates/settings/tracking-page.hbs +318 -0
  56. package/addon/utils/identity-cell-resource.js +2 -56
  57. package/addon/utils/map-default-view.js +132 -0
  58. package/addon/utils/placeholder-images.js +4 -69
  59. package/addon/utils/resource-descriptors/assets.js +2 -57
  60. package/addon/utils/resource-descriptors/helpers.js +23 -122
  61. package/addon/utils/resource-descriptors/operations.js +2 -61
  62. package/addon/utils/resource-descriptors/people.js +3 -142
  63. package/addon/utils/resource-descriptors/shared.js +18 -0
  64. package/addon/utils/tracking-live-source.js +123 -0
  65. package/addon/utils/tracking-page-theme.js +123 -0
  66. package/app/components/admin/tracking-page-settings.js +1 -0
  67. package/app/components/tracking-page/full-view.js +1 -0
  68. package/app/components/tracking-page/locale-selector.js +1 -0
  69. package/app/components/tracking-page/map.js +1 -0
  70. package/app/components/tracking-page/order-list.js +1 -0
  71. package/app/components/tracking-page/public-view.js +1 -0
  72. package/app/components/tracking-page/settings-preview.js +1 -0
  73. package/app/components/tracking-page/verify-panel.js +1 -0
  74. package/app/components/tracking-page.js +1 -0
  75. package/app/components/widget/proof-of-delivery.js +1 -0
  76. package/app/controllers/settings/tracking-page.js +1 -0
  77. package/app/helpers/parcel-box-image.js +1 -0
  78. package/app/routes/settings/tracking-page.js +1 -0
  79. package/app/templates/settings/tracking-page.js +1 -0
  80. package/app/utils/map-default-view.js +1 -0
  81. package/app/utils/tracking-live-source.js +1 -0
  82. package/app/utils/tracking-page-theme.js +1 -0
  83. package/composer.json +2 -2
  84. package/extension.json +1 -1
  85. package/package.json +4 -4
  86. package/scripts/pest-bootstrap.php +4 -0
  87. package/server/config/fleetops.php +4 -0
  88. package/server/migrations/2026_10_08_000001_create_tracking_sessions_tables.php +51 -0
  89. package/server/migrations/2026_10_10_000001_add_trashed_index_to_device_events.php +40 -0
  90. package/server/migrations/2026_10_10_000002_add_trashed_index_to_positions.php +40 -0
  91. package/server/migrations/2026_10_12_000001_add_deleted_at_to_tracking_sessions_tables.php +32 -0
  92. package/server/resources/views/labels/default.php +2 -2
  93. package/server/resources/views/labels/entity-label.php +2 -2
  94. package/server/resources/views/labels/waypoint-label.php +2 -2
  95. package/server/src/Auth/Schemas/FleetOps.php +5 -0
  96. package/server/src/Console/Commands/ProcessOperationalAlerts.php +1 -0
  97. package/server/src/Console/Commands/PruneTelematicsData.php +101 -26
  98. package/server/src/Events/OrderChanged.php +257 -0
  99. package/server/src/Events/OrderDriverUnassigned.php +68 -0
  100. package/server/src/Exceptions/OrderEditLockedException.php +32 -0
  101. package/server/src/Expansions/CompanyExpansion.php +3 -2
  102. package/server/src/Expansions/UserExpansion.php +12 -10
  103. package/server/src/Http/Controllers/Api/v1/CustomerController.php +108 -18
  104. package/server/src/Http/Controllers/Api/v1/DriverController.php +95 -10
  105. package/server/src/Http/Controllers/Api/v1/EntityController.php +66 -0
  106. package/server/src/Http/Controllers/Api/v1/FleetController.php +10 -4
  107. package/server/src/Http/Controllers/Api/v1/OrderController.php +139 -36
  108. package/server/src/Http/Controllers/Api/v1/PayloadController.php +76 -8
  109. package/server/src/Http/Controllers/Api/v1/PlaceController.php +3 -3
  110. package/server/src/Http/Controllers/Api/v1/TrackingNumberController.php +6 -32
  111. package/server/src/Http/Controllers/Api/v1/VehicleController.php +5 -0
  112. package/server/src/Http/Controllers/Internal/v1/ContactController.php +3 -0
  113. package/server/src/Http/Controllers/Internal/v1/DriverController.php +10 -0
  114. package/server/src/Http/Controllers/Internal/v1/FleetController.php +16 -2
  115. package/server/src/Http/Controllers/Internal/v1/InspectionSubmissionController.php +13 -8
  116. package/server/src/Http/Controllers/Internal/v1/IssueController.php +2 -0
  117. package/server/src/Http/Controllers/Internal/v1/MaintenanceController.php +4 -2
  118. package/server/src/Http/Controllers/Internal/v1/OrchestrationController.php +93 -11
  119. package/server/src/Http/Controllers/Internal/v1/OrderController.php +84 -24
  120. package/server/src/Http/Controllers/Internal/v1/PositionController.php +22 -0
  121. package/server/src/Http/Controllers/Internal/v1/ProofController.php +55 -4
  122. package/server/src/Http/Controllers/Internal/v1/SettingController.php +303 -10
  123. package/server/src/Http/Controllers/Internal/v1/TrailerController.php +1 -0
  124. package/server/src/Http/Controllers/Internal/v1/VehicleController.php +1 -0
  125. package/server/src/Http/Controllers/Public/TrackingController.php +518 -0
  126. package/server/src/Http/Filter/ContactFilter.php +1 -1
  127. package/server/src/Http/Filter/InspectionFormFilter.php +23 -0
  128. package/server/src/Http/Filter/InspectionSubmissionFilter.php +23 -0
  129. package/server/src/Http/Filter/MaintenanceFilter.php +23 -0
  130. package/server/src/Http/Filter/OrderFilter.php +39 -4
  131. package/server/src/Http/Filter/ProofFilter.php +18 -0
  132. package/server/src/Http/Filter/RouteFilter.php +18 -0
  133. package/server/src/Http/Filter/TelematicFilter.php +23 -0
  134. package/server/src/Http/Filter/WarrantyFilter.php +23 -0
  135. package/server/src/Http/Requests/CancelOrderRequest.php +2 -1
  136. package/server/src/Http/Resources/v1/Concerns/ResolvesPublicRelationFields.php +14 -1
  137. package/server/src/Http/Resources/v1/Index/Customer.php +18 -0
  138. package/server/src/Http/Resources/v1/Index/Order.php +11 -2
  139. package/server/src/Http/Resources/v1/PublicOrderTracking.php +153 -0
  140. package/server/src/Http/Resources/v1/TrackingNumber.php +8 -34
  141. package/server/src/Http/Resources/v1/Zone.php +31 -23
  142. package/server/src/Jobs/FinalizeApiOrderCreation.php +3 -1
  143. package/server/src/Jobs/ProcessAllocationJob.php +16 -6
  144. package/server/src/Jobs/PublishTrackingUpdate.php +50 -0
  145. package/server/src/Listeners/HandleGeofenceEntered.php +31 -15
  146. package/server/src/Listeners/HandleOrderCanceled.php +9 -1
  147. package/server/src/Listeners/HandleUserRemovedFromCompany.php +2 -1
  148. package/server/src/Listeners/PublishTrackingUpdates.php +27 -0
  149. package/server/src/Mail/CustomerCredentialsMail.php +4 -2
  150. package/server/src/Models/AssetConnection.php +3 -0
  151. package/server/src/Models/Contact.php +3 -0
  152. package/server/src/Models/Device.php +28 -0
  153. package/server/src/Models/DeviceEvent.php +7 -0
  154. package/server/src/Models/Driver.php +20 -7
  155. package/server/src/Models/Entity.php +12 -4
  156. package/server/src/Models/Fleet.php +11 -32
  157. package/server/src/Models/FleetDriver.php +3 -0
  158. package/server/src/Models/FleetVehicle.php +3 -0
  159. package/server/src/Models/FuelReport.php +5 -13
  160. package/server/src/Models/Issue.php +4 -16
  161. package/server/src/Models/Order.php +200 -9
  162. package/server/src/Models/Payload.php +227 -35
  163. package/server/src/Models/Place.php +7 -1
  164. package/server/src/Models/ServiceArea.php +7 -0
  165. package/server/src/Models/ServiceRate.php +7 -17
  166. package/server/src/Models/TrackingNumber.php +101 -2
  167. package/server/src/Models/TrackingSession.php +33 -0
  168. package/server/src/Models/TrackingSessionGrant.php +22 -0
  169. package/server/src/Models/TrackingStatus.php +6 -0
  170. package/server/src/Models/Vehicle.php +21 -2
  171. package/server/src/Models/Vendor.php +7 -0
  172. package/server/src/Models/Waypoint.php +13 -4
  173. package/server/src/Notifications/OrderAssigned.php +2 -1
  174. package/server/src/Notifications/OrderCanceled.php +7 -1
  175. package/server/src/Notifications/OrderChanged.php +87 -0
  176. package/server/src/Notifications/OrderCompleted.php +2 -1
  177. package/server/src/Notifications/OrderDispatchFailed.php +2 -1
  178. package/server/src/Notifications/OrderDispatched.php +2 -1
  179. package/server/src/Notifications/OrderFailed.php +2 -1
  180. package/server/src/Notifications/OrderUnassigned.php +66 -0
  181. package/server/src/Notifications/WaypointCompleted.php +2 -1
  182. package/server/src/Observers/CompanyUserObserver.php +8 -7
  183. package/server/src/Observers/DriverObserver.php +6 -1
  184. package/server/src/Observers/OrderObserver.php +141 -1
  185. package/server/src/Observers/TrackingNumberObserver.php +18 -6
  186. package/server/src/Observers/UserObserver.php +2 -1
  187. package/server/src/Observers/VehicleObserver.php +8 -3
  188. package/server/src/Orchestration/Engines/RouteSequencingEngine.php +21 -3
  189. package/server/src/Orchestration/Engines/VroomOrchestrationEngine.php +38 -8
  190. package/server/src/Orchestration/OrchestrationEngineRegistry.php +61 -1
  191. package/server/src/Orchestration/Support/OrchestratorSettings.php +61 -0
  192. package/server/src/Orchestration/Support/RouteMetrics.php +165 -0
  193. package/server/src/Providers/EventServiceProvider.php +11 -0
  194. package/server/src/Providers/FleetOpsServiceProvider.php +13 -0
  195. package/server/src/Support/ApiCacheThrottle.php +76 -0
  196. package/server/src/Support/LiveOrderQuery.php +3 -1
  197. package/server/src/Support/OrderEdit.php +365 -0
  198. package/server/src/Support/OrderEditPolicy.php +292 -0
  199. package/server/src/Support/ProfileAccountManager.php +54 -1
  200. package/server/src/Support/ResolvesOrderServiceStops.php +5 -0
  201. package/server/src/Support/SocketChannels.php +346 -0
  202. package/server/src/Support/Telematics/TelematicService.php +7 -2
  203. package/server/src/Support/Telematics/Telemetry/Inbox.php +8 -1
  204. package/server/src/Support/TrackingChannel.php +55 -0
  205. package/server/src/Support/TrackingCode.php +242 -0
  206. package/server/src/Support/TrackingPage/TrackingAccess.php +356 -0
  207. package/server/src/Support/TrackingPage/TrackingPageConfig.php +264 -0
  208. package/server/src/Support/TrackingPage/TrackingRecipient.php +111 -0
  209. package/server/src/Support/TrackingPage/TrackingResolver.php +201 -0
  210. package/server/src/Support/TrackingPage/TrackingTarget.php +40 -0
  211. package/server/src/Support/TrackingPage/TrackingView.php +543 -0
  212. package/server/src/Support/TrackingPublisher.php +261 -0
  213. package/server/src/Support/TrackingScope.php +182 -0
  214. package/server/src/Support/TrackingUpdate.php +402 -0
  215. package/server/src/Support/Utils.php +15 -3
  216. package/server/src/Traits/HasTrackingNumber.php +2 -0
  217. package/server/src/routes.php +39 -7
  218. package/server/tests/ApiCustomerControllerContractsTest.php +54 -7
  219. package/server/tests/ApiDriverPasswordContractsTest.php +25 -21
  220. package/server/tests/ApiEntityControllerContractsTest.php +84 -0
  221. package/server/tests/ApiOrderControllerContractsTest.php +245 -0
  222. package/server/tests/ApiPayloadControllerContractsTest.php +121 -0
  223. package/server/tests/ApiPlaceControllerContractsTest.php +1 -1
  224. package/server/tests/ApiTrackingNumberControllerContractsTest.php +6 -9
  225. package/server/tests/CompactResourceSerializationTest.php +23 -59
  226. package/server/tests/ControllerFilterContractsTest.php +1 -1
  227. package/server/tests/CustomerContactIdentitySafetyTest.php +1 -1
  228. package/server/tests/EventContractsTest.php +26 -60
  229. package/server/tests/ExpansionContractsTest.php +15 -2
  230. package/server/tests/Feature/Http/Api/CustomerControllerHelperSeamsTest.php +29 -3
  231. package/server/tests/Feature/Http/Api/DriverControllerAuthFlowsTest.php +23 -0
  232. package/server/tests/Feature/Http/Api/OrderControllerNearbyFiltersTest.php +16 -16
  233. package/server/tests/Feature/Http/Api/OrderControllerQueryFiltersTest.php +41 -14
  234. package/server/tests/Feature/Http/Api/TrackingNumberControllerHelpersTest.php +57 -22
  235. package/server/tests/Feature/Http/Internal/DriverControllerCreateRecordTest.php +23 -9
  236. package/server/tests/Feature/Http/Internal/OrchestrationControllerContractsTest.php +171 -1
  237. package/server/tests/Feature/Http/Internal/OrderControllerContractsTest.php +110 -14
  238. package/server/tests/Feature/Http/Internal/OrderControllerHelpersTest.php +7 -5
  239. package/server/tests/Feature/Http/Internal/PositionControllerReplayChannelTest.php +135 -0
  240. package/server/tests/Feature/Http/Internal/ProofControllerContractsTest.php +3 -3
  241. package/server/tests/Feature/Http/Internal/ProofControllerRecentTest.php +170 -0
  242. package/server/tests/Feature/Http/Public/TrackingControllerTest.php +495 -0
  243. package/server/tests/IndexCustomerPhotoTest.php +151 -0
  244. package/server/tests/ModelAccessorContractsTest.php +4 -3
  245. package/server/tests/NotificationAndMailContractsTest.php +5 -8
  246. package/server/tests/ObserverContractsTest.php +72 -10
  247. package/server/tests/OrderActivityFlowRegressionTest.php +3 -2
  248. package/server/tests/OrderFilterExecutionTest.php +167 -2
  249. package/server/tests/ProcessAllocationJobContractsTest.php +20 -0
  250. package/server/tests/PublicOrderTrackingResourceTest.php +221 -0
  251. package/server/tests/QueueJobContractsTest.php +2 -1
  252. package/server/tests/RequestContractsTest.php +1 -1
  253. package/server/tests/RouteRegistrationExecutionTest.php +26 -0
  254. package/server/tests/RouteSequencingEngineTest.php +17 -0
  255. package/server/tests/SettingControllerContractsTest.php +110 -0
  256. package/server/tests/Support/TrackingPageDatabase.php +200 -0
  257. package/server/tests/TenantScopingTest.php +206 -0
  258. package/server/tests/TrackingPageSettingsTest.php +343 -0
  259. package/server/tests/Unit/Console/PruneTelematicsDataCommandTest.php +51 -0
  260. package/server/tests/Unit/Http/Filter/ContactFilterAddressTest.php +61 -0
  261. package/server/tests/Unit/Http/Resources/IndexOrderResourceTest.php +30 -0
  262. package/server/tests/Unit/Jobs/FinalizeApiOrderCreationDispatchGuardTest.php +266 -0
  263. package/server/tests/Unit/Listeners/HandleGeofenceEnteredTest.php +182 -22
  264. package/server/tests/Unit/Listeners/HandleUserRemovedFromCompanyTest.php +5 -0
  265. package/server/tests/Unit/Models/DriverAssignVehicleTest.php +81 -0
  266. package/server/tests/Unit/Models/FleetTest.php +4 -3
  267. package/server/tests/Unit/Models/OrderCancelReasonTest.php +143 -0
  268. package/server/tests/Unit/Models/OrderPayloadConfigAndDistanceTest.php +60 -0
  269. package/server/tests/Unit/Models/OrderPayloadRouteTest.php +4 -2
  270. package/server/tests/Unit/Models/PayloadTest.php +18 -4
  271. package/server/tests/Unit/Models/ServiceRatePersistenceAndServicabilityTest.php +2 -2
  272. package/server/tests/Unit/Models/TrackingNumberTest.php +67 -6
  273. package/server/tests/Unit/Models/VehicleTest.php +16 -2
  274. package/server/tests/Unit/Models/WaypointTest.php +4 -3
  275. package/server/tests/Unit/Notifications/OrderChangeNotificationsTest.php +59 -0
  276. package/server/tests/Unit/Notifications/TerminalOrderNotificationsTest.php +3 -3
  277. package/server/tests/Unit/Observers/DriverDeletionCleanupTest.php +158 -0
  278. package/server/tests/Unit/Observers/FleetOpsCompanyUserAndServiceAreaObserversTest.php +20 -0
  279. package/server/tests/Unit/Observers/ObserverListenerSeamsTest.php +3 -3
  280. package/server/tests/Unit/Observers/OrderAssignmentObserverTest.php +100 -0
  281. package/server/tests/Unit/Observers/OrderDeleteCascadeTest.php +213 -0
  282. package/server/tests/Unit/Observers/VehicleObserverDeleteTest.php +67 -0
  283. package/server/tests/Unit/Orchestration/EngineSelectionAndRouteMetricsTest.php +336 -0
  284. package/server/tests/Unit/Orchestration/Engines/VroomOrchestrationEngineLifecycleTest.php +8 -1
  285. package/server/tests/Unit/Support/ApiCacheDependenciesTest.php +347 -0
  286. package/server/tests/Unit/Support/OrderEditPolicyTest.php +156 -0
  287. package/server/tests/Unit/Support/OrderOperationsTest.php +514 -0
  288. package/server/tests/Unit/Support/OsrmProofAndIssueFilterTest.php +4 -3
  289. package/server/tests/Unit/Support/ProfileAccountManagerTest.php +141 -0
  290. package/server/tests/Unit/Support/QuerySeamsTest.php +5 -1
  291. package/server/tests/Unit/Support/SocketChannelsTest.php +396 -0
  292. package/server/tests/Unit/Support/TrackingChannelTest.php +232 -0
  293. package/server/tests/Unit/Support/TrackingCodeTest.php +186 -0
  294. package/server/tests/Unit/Support/TrackingPage/TrackingAccessTest.php +200 -0
  295. package/server/tests/Unit/Support/TrackingPage/TrackingRecipientTest.php +51 -0
  296. package/server/tests/Unit/Support/TrackingPage/TrackingResolverTest.php +181 -0
  297. package/server/tests/Unit/Support/TrackingPage/TrackingViewTest.php +479 -0
  298. package/server/tests/Unit/Support/TrackingPublisherTest.php +425 -0
  299. package/server/tests/Unit/Support/TrackingUpdateTest.php +363 -0
  300. package/server/tests/Unit/Support/UtilsAdditionalTest.php +9 -0
  301. package/server/tests/Unit/Support/UtilsGeoMatrixAndVendorTest.php +25 -0
  302. package/server/tests/VehicleResourceContractsTest.php +46 -0
  303. package/server/tests/VroomOrchestrationEngineTest.php +34 -1
  304. package/server/tests/ZoneGeofenceSettingsResourceTest.php +121 -0
  305. package/translations/ar-ae.yml +274 -0
  306. package/translations/bg-bg.yaml +274 -0
  307. package/translations/en-us.yaml +287 -0
  308. package/translations/es-pa.yaml +274 -0
  309. package/translations/fr-fr.yaml +274 -0
  310. package/translations/mn-mn.yaml +274 -0
  311. package/translations/pt-br.yaml +274 -0
  312. package/translations/ru-ru.yaml +276 -2
  313. package/translations/uk-ua.yaml +275 -1
  314. package/translations/vi-vn.yaml +274 -0
  315. package/addon/components/cell/contact-identity.hbs +0 -1
  316. package/addon/components/cell/customer-identity.hbs +0 -1
  317. package/addon/components/cell/driver-identity.hbs +0 -1
  318. package/addon/components/cell/fleet-identity.hbs +0 -1
  319. package/addon/components/cell/order-identity.hbs +0 -1
  320. package/addon/components/cell/place-identity.hbs +0 -1
  321. package/addon/components/cell/vehicle-identity.hbs +0 -1
  322. package/addon/components/cell/vendor-identity.hbs +0 -1
  323. package/addon/components/contact/pill.hbs +0 -24
  324. package/addon/components/contact/summary.hbs +0 -1
  325. package/addon/components/customer/pill.hbs +0 -24
  326. package/addon/components/customer/summary.hbs +0 -1
  327. package/addon/components/driver/pill.hbs +0 -24
  328. package/addon/components/driver/summary.hbs +0 -1
  329. package/addon/components/fleet/pill.hbs +0 -24
  330. package/addon/components/fleet/summary.hbs +0 -1
  331. package/addon/components/order/pill.hbs +0 -24
  332. package/addon/components/order/summary.hbs +0 -1
  333. package/addon/components/place/pill.hbs +0 -24
  334. package/addon/components/place/summary.hbs +0 -1
  335. package/addon/components/select-option/contact.hbs +0 -12
  336. package/addon/components/select-option/customer.hbs +0 -12
  337. package/addon/components/select-option/driver.hbs +0 -12
  338. package/addon/components/select-option/fleet.hbs +0 -12
  339. package/addon/components/select-option/order.hbs +0 -12
  340. package/addon/components/select-option/place.hbs +0 -12
  341. package/addon/components/select-option/vehicle.hbs +0 -12
  342. package/addon/components/select-option/vendor.hbs +0 -12
  343. package/addon/components/vehicle/pill.hbs +0 -24
  344. package/addon/components/vehicle/summary.hbs +0 -1
  345. package/addon/components/vendor/pill.hbs +0 -24
  346. package/addon/components/vendor/summary.hbs +0 -1
  347. package/app/components/cell/contact-identity.js +0 -1
  348. package/app/components/cell/customer-identity.js +0 -1
  349. package/app/components/cell/driver-identity.js +0 -1
  350. package/app/components/cell/fleet-identity.js +0 -1
  351. package/app/components/cell/order-identity.js +0 -1
  352. package/app/components/cell/place-identity.js +0 -1
  353. package/app/components/cell/vehicle-identity.js +0 -1
  354. package/app/components/cell/vendor-identity.js +0 -1
  355. package/app/components/contact/pill.js +0 -1
  356. package/app/components/contact/summary.js +0 -1
  357. package/app/components/customer/pill.js +0 -1
  358. package/app/components/customer/summary.js +0 -1
  359. package/app/components/driver/pill.js +0 -1
  360. package/app/components/driver/summary.js +0 -1
  361. package/app/components/fleet/pill.js +0 -1
  362. package/app/components/fleet/summary.js +0 -1
  363. package/app/components/order/pill.js +0 -1
  364. package/app/components/order/summary.js +0 -1
  365. package/app/components/place/pill.js +0 -1
  366. package/app/components/place/summary.js +0 -1
  367. package/app/components/select-option/contact.js +0 -1
  368. package/app/components/select-option/customer.js +0 -1
  369. package/app/components/select-option/driver.js +0 -1
  370. package/app/components/select-option/fleet.js +0 -1
  371. package/app/components/select-option/order.js +0 -1
  372. package/app/components/select-option/place.js +0 -1
  373. package/app/components/select-option/vehicle.js +0 -1
  374. package/app/components/select-option/vendor.js +0 -1
  375. package/app/components/vehicle/pill.js +0 -1
  376. package/app/components/vehicle/summary.js +0 -1
  377. package/app/components/vendor/pill.js +0 -1
  378. package/app/components/vendor/summary.js +0 -1
@@ -0,0 +1,495 @@
1
+ <?php
2
+
3
+ use Fleetbase\FleetOps\Http\Controllers\Public\TrackingController;
4
+ use Fleetbase\FleetOps\Models\Contact;
5
+ use Fleetbase\FleetOps\Models\Entity;
6
+ use Fleetbase\FleetOps\Models\Order;
7
+ use Fleetbase\FleetOps\Models\Place;
8
+ use Fleetbase\FleetOps\Models\Proof;
9
+ use Fleetbase\FleetOps\Models\TrackingNumber;
10
+ use Fleetbase\FleetOps\Models\Waypoint;
11
+ use Fleetbase\FleetOps\Support\TrackingPage\TrackingAccess;
12
+ use Fleetbase\FleetOps\Support\TrackingPage\TrackingPageConfig;
13
+ use Fleetbase\FleetOps\Support\TrackingPage\TrackingResolver;
14
+ use Fleetbase\FleetOps\Support\TrackingPage\TrackingTarget;
15
+ use Fleetbase\FleetOps\Support\TrackingPage\TrackingView;
16
+ use Fleetbase\FleetOps\Support\TrackingScope;
17
+ use Fleetbase\Models\VerificationCode;
18
+ use Fleetbase\Tests\Support\TrackingPageDatabase;
19
+ use Illuminate\Database\Eloquent\Model as EloquentModel;
20
+ use Illuminate\Http\Request;
21
+ use Symfony\Component\HttpFoundation\Cookie;
22
+
23
+ class TrackingControllerResolverFake extends TrackingResolver
24
+ {
25
+ public ?TrackingTarget $target = null;
26
+ public ?string $slugCompany = null;
27
+ public array $company = [];
28
+ public array $admin = [];
29
+ public array $resolved = [];
30
+
31
+ public function resolve(mixed $input, ?string $slug = null): ?TrackingTarget
32
+ {
33
+ $this->resolved[] = [$input, $slug];
34
+
35
+ return $this->target;
36
+ }
37
+
38
+ public function companyForSlug(?string $slug): ?string
39
+ {
40
+ return $this->slugCompany;
41
+ }
42
+
43
+ public function config(?string $companyUuid): array
44
+ {
45
+ return TrackingPageConfig::sanitize($this->company);
46
+ }
47
+
48
+ public function adminConfig(): array
49
+ {
50
+ return TrackingPageConfig::sanitizeAdmin($this->admin);
51
+ }
52
+ }
53
+
54
+ class TrackingControllerAccessFake extends TrackingAccess
55
+ {
56
+ public bool $lookup = true;
57
+ public bool $verify = true;
58
+ public bool $reportOk = true;
59
+ public int $paused = 0;
60
+ public array $claim = ['status' => self::SEND_SENT, 'retry_after' => 30];
61
+ public array $check = ['status' => VerificationCode::CHECK_VALID, 'channel' => 'sms'];
62
+ public bool $granted = false;
63
+ public array $issued = [];
64
+ public array $grants = [];
65
+ public array $revoked = [];
66
+
67
+ public function allowLookup(Request $request): bool
68
+ {
69
+ return $this->lookup;
70
+ }
71
+
72
+ public function allowVerify(Request $request): bool
73
+ {
74
+ return $this->verify;
75
+ }
76
+
77
+ public function allowReport(Request $request, string $orderUuid): bool
78
+ {
79
+ return $this->reportOk;
80
+ }
81
+
82
+ public function pausedFor(TrackingScope $scope): int
83
+ {
84
+ return $this->paused;
85
+ }
86
+
87
+ public function claimSend(TrackingScope $scope, Request $request): array
88
+ {
89
+ return $this->claim;
90
+ }
91
+
92
+ public function issueCode(TrackingScope $scope, mixed $recipient, string $channel): VerificationCode
93
+ {
94
+ $this->issued[] = [$scope->key(), $recipient?->uuid, $channel];
95
+ $code = new VerificationCode();
96
+ $code->plainCode = '482193';
97
+
98
+ return $code;
99
+ }
100
+
101
+ public function checkCode(TrackingScope $scope, string $code): array
102
+ {
103
+ return $this->check + ['code' => $code];
104
+ }
105
+
106
+ public function hasGrant(Request $request, ?TrackingScope $scope): bool
107
+ {
108
+ return $this->granted && $scope !== null;
109
+ }
110
+
111
+ public function grantExpiresAt(Request $request, TrackingScope $scope): ?string
112
+ {
113
+ return '2026-10-09T09:00:00.000000Z';
114
+ }
115
+
116
+ public function grant(Request $request, TrackingScope $scope, ?string $channel, int $hours): ?Cookie
117
+ {
118
+ $this->grants[] = [$scope->key(), $channel, $hours];
119
+
120
+ return $this->cookie('token-value-token-value-token-value-xx', $hours * 60);
121
+ }
122
+
123
+ public function revoke(Request $request, ?TrackingScope $scope = null): ?Cookie
124
+ {
125
+ $this->revoked[] = $scope?->key();
126
+
127
+ return $scope ? null : $this->cookie('', -1);
128
+ }
129
+ }
130
+
131
+ class TrackingControllerViewFake extends TrackingView
132
+ {
133
+ public ?Proof $proof = null;
134
+ public array $calls = [];
135
+
136
+ public function publicView(TrackingTarget $target, array $branding, array $channels, bool $signIn): array
137
+ {
138
+ return ['level' => 0, 'branding' => $branding, 'channels' => $channels, 'sign_in' => $signIn];
139
+ }
140
+
141
+ public function fullView(TrackingTarget $target, array $branding, string $viewer, ?string $expiresAt = null): array
142
+ {
143
+ $this->calls[] = $viewer;
144
+
145
+ return ['level' => $viewer === self::VIEWER_ACCOUNT ? 2 : 1, 'viewer' => $viewer, 'expires_at' => $expiresAt, 'company' => $branding];
146
+ }
147
+
148
+ public function proofFor(TrackingTarget $target, string $publicId): ?Proof
149
+ {
150
+ return $this->proof;
151
+ }
152
+ }
153
+
154
+ class TrackingControllerProbe extends TrackingController
155
+ {
156
+ public mixed $user = null;
157
+ public array $sms = [];
158
+ public array $mails = [];
159
+ public array $providers = [];
160
+ public bool $portal = true;
161
+ public bool $sockets = false;
162
+ public ?array $contactsOver = null;
163
+
164
+ public function __construct(public TrackingControllerResolverFake $fakeResolver, public TrackingControllerAccessFake $fakeAccess, public TrackingControllerViewFake $fakeView)
165
+ {
166
+ parent::__construct($fakeResolver, $fakeAccess, $fakeView);
167
+ }
168
+
169
+ protected function requestUser(Request $request): mixed
170
+ {
171
+ return $this->user;
172
+ }
173
+
174
+ protected function smsProviders(): array
175
+ {
176
+ return $this->providers;
177
+ }
178
+
179
+ protected function customerPortalInstalled(): bool
180
+ {
181
+ return $this->portal;
182
+ }
183
+
184
+ protected function instanceLogo(): ?string
185
+ {
186
+ return 'https://instance.example/logo.png';
187
+ }
188
+
189
+ protected function smsService(): Fleetbase\Services\SmsService
190
+ {
191
+ $probe = $this;
192
+
193
+ return new class($probe) extends Fleetbase\Services\SmsService {
194
+ public function __construct(private $probe)
195
+ {
196
+ }
197
+
198
+ public function send(string $to, string $text, array $options = [], ?string $provider = null): array
199
+ {
200
+ $this->probe->sms[] = [$to, $text, $options];
201
+
202
+ return ['success' => true];
203
+ }
204
+ };
205
+ }
206
+
207
+ protected function mail(string $to, string $subject, string $body): void
208
+ {
209
+ $this->mails[] = [$to, $subject, $body];
210
+ }
211
+
212
+ protected function socketsEnabled(): bool
213
+ {
214
+ return $this->sockets && parent::socketsEnabled() === false;
215
+ }
216
+
217
+ protected function socketTokenFor(TrackingScope $scope): array
218
+ {
219
+ return ['token' => 'jwt', 'expires_in' => 1800, 'expires_at' => '2026-10-08T09:30:00Z'];
220
+ }
221
+ }
222
+
223
+ class TrackingControllerOrder extends Order
224
+ {
225
+ public bool $saved = false;
226
+
227
+ public function saveQuietly(array $options = [])
228
+ {
229
+ $this->saved = true;
230
+
231
+ return true;
232
+ }
233
+ }
234
+
235
+ afterEach(function () {
236
+ EloquentModel::unsetConnectionResolver();
237
+ config(['services.twilio.sid' => null, 'services.twilio.token' => null]);
238
+ });
239
+
240
+ function trackingController(): TrackingControllerProbe
241
+ {
242
+ TrackingPageDatabase::boot();
243
+
244
+ return new TrackingControllerProbe(new TrackingControllerResolverFake(), new TrackingControllerAccessFake(), new TrackingControllerViewFake());
245
+ }
246
+
247
+ function trackingControllerTarget(array $config = [], bool $scoped = true, ?Waypoint $waypoint = null): TrackingTarget
248
+ {
249
+ $order = new TrackingControllerOrder();
250
+ $order->setRawAttributes(['uuid' => 'order-1', 'company_uuid' => 'co-1', 'customer_uuid' => 'contact-1', 'customer_type' => Contact::class, 'meta' => json_encode([])], true);
251
+
252
+ $trackingNumber = new TrackingNumber();
253
+ $trackingNumber->setRawAttributes(['uuid' => 'tn-1', 'tracking_number' => 'NOR1000000001US'], true);
254
+
255
+ $scope = $scoped ? new TrackingScope('order-1', Contact::class, 'contact-1', $order) : null;
256
+
257
+ return new TrackingTarget($order, $scope, $trackingNumber, TrackingTarget::KIND_ORDER, $waypoint, TrackingPageConfig::sanitize($config));
258
+ }
259
+
260
+ function trackingControllerRequest(array $input = []): Request
261
+ {
262
+ return Request::create('/public/track/NOR1000000001US', 'GET', $input, [], [], ['REMOTE_ADDR' => '203.0.113.7']);
263
+ }
264
+
265
+ test('the page config shows a company page or the shared page, and nothing for a page that is off', function () {
266
+ $controller = trackingController();
267
+ TrackingPageDatabase::insert(Illuminate\Database\Eloquent\Model::getConnectionResolver()->connection(), 'companies', ['uuid' => 'co-1', 'name' => 'Northwind Couriers', 'phone' => '+15105550142']);
268
+
269
+ expect($controller->config(trackingControllerRequest(['org' => 'northwind']))->getStatusCode())->toBe(404);
270
+
271
+ $controller->fakeResolver->slugCompany = 'co-1';
272
+ $controller->fakeResolver->company = ['access' => ['session_hours' => 12]];
273
+ $page = $controller->config(trackingControllerRequest(['org' => ' Northwind ']))->getData(true);
274
+ expect($page['company']['name'])->toBe('Northwind Couriers')
275
+ ->and($page['org'])->toBe('northwind')
276
+ ->and($page['sign_in'])->toBeTrue()
277
+ ->and($page['session_hours'])->toBe(12);
278
+
279
+ $shared = $controller->config(trackingControllerRequest(['org' => ' ']))->getData(true);
280
+ expect($shared['org'])->toBeNull()
281
+ ->and($shared['company']['logo_url'])->toBe('https://instance.example/logo.png')
282
+ ->and($shared['session_hours'])->toBe(24);
283
+
284
+ $controller->fakeResolver->admin = ['generic_page' => ['enabled' => false]];
285
+ expect($controller->config(trackingControllerRequest())->getStatusCode())->toBe(404);
286
+ });
287
+
288
+ test('lookups answer unknown and throttled numbers alike, and show what the viewer may see', function () {
289
+ $controller = trackingController();
290
+
291
+ $notFound = $controller->show(trackingControllerRequest(), 'NOR1000000001US');
292
+ expect($notFound->getStatusCode())->toBe(404)
293
+ ->and($notFound->getData(true))->toBe(['error' => TrackingController::NOT_FOUND, 'code' => 'not_found']);
294
+
295
+ $controller->fakeResolver->target = trackingControllerTarget();
296
+ $controller->fakeAccess->lookup = false;
297
+ expect($controller->show(trackingControllerRequest(), 'NOR1000000001US')->getData(true))->toBe($notFound->getData(true));
298
+
299
+ // A tracking number alone: the public view, with the channels a code can use.
300
+ $controller->fakeAccess->lookup = true;
301
+ $controller->providers = ['vonage' => ['available' => true]];
302
+ TrackingPageDatabase::insert(Illuminate\Database\Eloquent\Model::getConnectionResolver()->connection(), 'contacts', ['uuid' => 'contact-1', 'email' => 'maya@example.com', 'phone' => '+15105554821']);
303
+ $public = $controller->show(trackingControllerRequest(['org' => 'Northwind']), 'NOR1000000001US')->getData(true);
304
+ expect($public['level'])->toBe(0)
305
+ ->and(array_column($public['channels'], 'type'))->toBe(['sms', 'email'])
306
+ ->and($public['sign_in'])->toBeTrue()
307
+ ->and($controller->fakeResolver->resolved[1])->toBe(['NOR1000000001US', 'northwind']);
308
+
309
+ $controller->fakeResolver->target = trackingControllerTarget(['access' => ['account_sign_in' => false]], false);
310
+ $unscoped = $controller->show(trackingControllerRequest(), 'NOR1000000001US')->getData(true);
311
+ expect($unscoped['channels'])->toBe([])->and($unscoped['sign_in'])->toBeFalse();
312
+
313
+ // A verified device, a signed-in customer and staff of the owning company see the full view.
314
+ $controller->fakeResolver->target = trackingControllerTarget();
315
+ $controller->fakeAccess->granted = true;
316
+ expect($controller->show(trackingControllerRequest(), 'NOR1000000001US')->getData(true))->toMatchArray(['viewer' => 'verified', 'expires_at' => '2026-10-09T09:00:00.000000Z']);
317
+
318
+ $controller->fakeAccess->granted = false;
319
+ $controller->user = (object) ['uuid' => 'user-1', 'type' => 'customer', 'company_uuid' => 'co-1'];
320
+ TrackingPageDatabase::insert(Illuminate\Database\Eloquent\Model::getConnectionResolver()->connection(), 'contacts', ['uuid' => 'contact-x', 'user_uuid' => 'user-1']);
321
+ expect($controller->show(trackingControllerRequest(), 'NOR1000000001US')->getData(true)['level'])->toBe(0);
322
+
323
+ Illuminate\Database\Eloquent\Model::getConnectionResolver()->connection()->table('contacts')->where('uuid', 'contact-1')->update(['user_uuid' => 'user-1']);
324
+ expect($controller->show(trackingControllerRequest(), 'NOR1000000001US')->getData(true))->toMatchArray(['viewer' => 'account', 'level' => 2, 'expires_at' => null]);
325
+
326
+ $controller->user = (object) ['uuid' => 'user-2', 'type' => 'user', 'company_uuid' => 'co-1'];
327
+ expect($controller->show(trackingControllerRequest(), 'NOR1000000001US')->getData(true)['viewer'])->toBe('staff');
328
+
329
+ // Staff of another company, or a user with no id, get the public view.
330
+ $controller->user = (object) ['uuid' => 'user-3', 'type' => 'user', 'company_uuid' => 'co-2'];
331
+ expect($controller->show(trackingControllerRequest(), 'NOR1000000001US')->getData(true)['level'])->toBe(0);
332
+ $controller->user = (object) ['type' => 'customer'];
333
+ expect($controller->show(trackingControllerRequest(), 'NOR1000000001US')->getData(true)['level'])->toBe(0);
334
+ });
335
+
336
+ test('codes go out on an available channel, within the limits', function () {
337
+ $controller = trackingController();
338
+ $db = Illuminate\Database\Eloquent\Model::getConnectionResolver()->connection();
339
+ TrackingPageDatabase::insert($db, 'contacts', ['uuid' => 'contact-1', 'email' => 'maya@example.com']);
340
+ TrackingPageDatabase::insert($db, 'companies', ['uuid' => 'co-1', 'name' => 'Northwind', 'options' => json_encode(['alpha_numeric_sender_id_enabled' => true, 'alpha_numeric_sender_id' => 'NORTHWIND'])]);
341
+
342
+ expect($controller->sendCode(trackingControllerRequest(['channel' => 'email']), 'X')->getStatusCode())->toBe(404);
343
+
344
+ $place = new Place();
345
+ $place->setRawAttributes(['uuid' => 'place-1', 'phone' => '+15105554821'], true);
346
+ $waypoint = new Waypoint();
347
+ $waypoint->setRelation('place', $place);
348
+ $controller->fakeResolver->target = trackingControllerTarget([], true, $waypoint);
349
+
350
+ // SMS needs a provider; the stop's place phone stands in for the contact's missing one.
351
+ expect($controller->sendCode(trackingControllerRequest(['channel' => 'sms']), 'NOR1')->getData(true))->toBe(['error' => 'channel_unavailable']);
352
+
353
+ config(['services.twilio.sid' => 'AC1', 'services.twilio.token' => 'secret']);
354
+ $sent = $controller->sendCode(trackingControllerRequest(['channel' => 'sms']), 'NOR1');
355
+ expect($sent->getStatusCode())->toBe(202)
356
+ ->and($sent->getData(true))->toBe(['channel' => 'sms', 'masked' => '•••• •• 4821', 'expires_in' => 600, 'resend_after' => 30])
357
+ ->and($controller->fakeAccess->issued[0])->toBe(['order-1:' . Contact::class . ':contact-1', 'contact-1', 'sms'])
358
+ ->and($controller->sms[0][0])->toBe('+15105554821')
359
+ ->and($controller->sms[0][1])->toBe('482193 is your Northwind delivery code. It expires in 10 minutes.')
360
+ ->and($controller->sms[0][2])->toBe(['twilioParams' => ['from' => 'NORTHWIND']]);
361
+
362
+ $controller->sendCode(trackingControllerRequest(['channel' => 'email']), 'NOR1');
363
+ expect($controller->mails[0])->toBe(['maya@example.com', '482193 is your Northwind delivery code', '482193 is your Northwind delivery code. It expires in 10 minutes.']);
364
+
365
+ $controller->fakeAccess->claim = ['status' => TrackingAccess::SEND_COOLDOWN, 'retry_after' => 12];
366
+ expect($controller->sendCode(trackingControllerRequest(['channel' => 'email']), 'NOR1')->getStatusCode())->toBe(429);
367
+ $controller->fakeAccess->claim = ['status' => TrackingAccess::SEND_PAUSED, 'retry_after' => 200];
368
+ expect($controller->sendCode(trackingControllerRequest(['channel' => 'email']), 'NOR1')->getData(true))->toBe(['error' => 'paused', 'retry_after' => 200]);
369
+
370
+ // A company with no name of its own falls back to a generic sender name, and no alphanumeric sender.
371
+ $db->table('companies')->delete();
372
+ $controller->fakeAccess->claim = ['status' => TrackingAccess::SEND_SENT, 'retry_after' => 30];
373
+ $controller->sendCode(trackingControllerRequest(['channel' => 'sms']), 'NOR1');
374
+ expect($controller->sms[1][1])->toStartWith('482193 is your Your delivery delivery code')
375
+ ->and($controller->sms[1][2])->toBe([]);
376
+
377
+ $controller->fakeResolver->target = trackingControllerTarget([], false);
378
+ expect($controller->sendCode(trackingControllerRequest(['channel' => 'email']), 'NOR1')->getStatusCode())->toBe(422);
379
+ });
380
+
381
+ test('checking a code verifies the device or says why it can’t', function () {
382
+ $controller = trackingController();
383
+
384
+ $controller->fakeAccess->verify = false;
385
+ expect($controller->verifyCode(trackingControllerRequest(['code' => '482193']), 'NOR1')->getStatusCode())->toBe(429);
386
+
387
+ $controller->fakeAccess->verify = true;
388
+ expect($controller->verifyCode(trackingControllerRequest(['code' => '482193']), 'NOR1')->getStatusCode())->toBe(404);
389
+
390
+ $controller->fakeResolver->target = trackingControllerTarget(['access' => ['session_hours' => 6]]);
391
+ $verified = $controller->verifyCode(trackingControllerRequest(['code' => '48 21 93']), 'NOR1');
392
+ expect($verified->getStatusCode())->toBe(200)
393
+ ->and($verified->getData(true)['viewer'])->toBe('verified')
394
+ ->and($verified->headers->getCookies()[0]->getName())->toBe(TrackingAccess::COOKIE)
395
+ ->and($controller->fakeAccess->grants[0])->toBe(['order-1:' . Contact::class . ':contact-1', 'sms', 6]);
396
+
397
+ $controller->fakeAccess->check = ['status' => VerificationCode::CHECK_INVALID, 'attempts_left' => 2];
398
+ expect($controller->verifyCode(trackingControllerRequest(['code' => '111111']), 'NOR1')->getData(true))->toBe(['error' => 'invalid', 'attempts_left' => 2]);
399
+
400
+ $controller->fakeAccess->check = ['status' => VerificationCode::CHECK_LOCKED];
401
+ expect($controller->verifyCode(trackingControllerRequest(['code' => '111111']), 'NOR1')->getData(true))->toBe(['error' => 'paused', 'retry_after' => TrackingAccess::PAUSE_SECONDS]);
402
+
403
+ $controller->fakeAccess->check = ['status' => VerificationCode::CHECK_EXPIRED];
404
+ expect($controller->verifyCode(trackingControllerRequest(['code' => '111111']), 'NOR1')->getStatusCode())->toBe(410);
405
+
406
+ $controller->fakeAccess->paused = 120;
407
+ expect($controller->verifyCode(trackingControllerRequest(['code' => '111111']), 'NOR1')->getData(true))->toBe(['error' => 'paused', 'retry_after' => 120]);
408
+ });
409
+
410
+ test('signing out drops one delivery or the whole device', function () {
411
+ $controller = trackingController();
412
+ $controller->fakeResolver->target = trackingControllerTarget();
413
+
414
+ $one = $controller->signOut(trackingControllerRequest(['number' => 'NOR1']));
415
+ expect($one->getData(true))->toBe(['status' => 'signed_out'])
416
+ ->and($one->headers->getCookies())->toBe([])
417
+ ->and($controller->fakeAccess->revoked[0])->toBe('order-1:' . Contact::class . ':contact-1');
418
+
419
+ $all = $controller->signOut(trackingControllerRequest());
420
+ expect($all->headers->getCookies()[0]->getExpiresTime())->toBe(1)
421
+ ->and($controller->fakeAccess->revoked[1])->toBeNull();
422
+ });
423
+
424
+ test('delivery instructions, proofs, reports and socket tokens need a verified viewer', function () {
425
+ $controller = trackingController();
426
+
427
+ foreach (['updateInstructions', 'report', 'socketToken'] as $method) {
428
+ expect($controller->{$method}(trackingControllerRequest(), 'NOR1')->getStatusCode())->toBe(404);
429
+ }
430
+ expect($controller->proof(trackingControllerRequest(), 'NOR1', 'proof_1')->getStatusCode())->toBe(404);
431
+
432
+ $controller->fakeResolver->target = trackingControllerTarget();
433
+ expect($controller->updateInstructions(trackingControllerRequest(), 'NOR1')->getStatusCode())->toBe(404);
434
+
435
+ $controller->fakeAccess->granted = true;
436
+
437
+ // Instructions
438
+ expect($controller->updateInstructions(trackingControllerRequest(['text' => 'Gate 4']), 'NOR1')->getStatusCode())->toBe(403);
439
+ $controller->fakeResolver->target = trackingControllerTarget(['visibility' => ['instructions_edit' => true]]);
440
+ $controller->updateInstructions(trackingControllerRequest(['text' => ' Leave with concierge ']), 'NOR1');
441
+ $order = $controller->fakeResolver->target->order;
442
+ expect($order->saved)->toBeTrue()
443
+ ->and($order->getMeta('recipient_instructions'))->toBe([sha1('order-1:' . Contact::class . ':contact-1') => 'Leave with concierge']);
444
+
445
+ // Proofs
446
+ expect($controller->proof(trackingControllerRequest(), 'NOR1', 'proof_1')->getStatusCode())->toBe(404);
447
+ $controller->fakeView->proof = new Proof();
448
+ $controller->fakeView->proof->setRawAttributes(['raw_data' => 'iVBOR'], true);
449
+ $controller->fakeView->proof->setRelation('file', null);
450
+ expect($controller->proof(trackingControllerRequest(), 'NOR1', 'proof_1')->getData(true))->toBe(['url' => 'data:image/png;base64,iVBOR', 'type' => 'signature']);
451
+ $controller->fakeView->proof->setRawAttributes(['raw_data' => 'data:image/svg+xml;base64,PHN2Zz4='], true);
452
+ expect($controller->proof(trackingControllerRequest(), 'NOR1', 'proof_1')->getData(true)['url'])->toBe('data:image/svg+xml;base64,PHN2Zz4=');
453
+ $controller->fakeView->proof->setRawAttributes([], true);
454
+ expect($controller->proof(trackingControllerRequest(), 'NOR1', 'proof_1')->getStatusCode())->toBe(404);
455
+ $controller->fakeView->proof->setRelation('file', (object) ['url' => 'https://bucket.example/signed']);
456
+ expect($controller->proof(trackingControllerRequest(), 'NOR1', 'proof_1')->getData(true)['url'])->toBe('https://bucket.example/signed');
457
+
458
+ // Reports
459
+ expect($controller->report(trackingControllerRequest(['message' => 'Box damaged']), 'NOR1')->getStatusCode())->toBe(422);
460
+ $controller->fakeResolver->target = trackingControllerTarget(['branding' => ['support_email' => 'help@northwind.example']]);
461
+ expect($controller->report(trackingControllerRequest(['message' => ' ']), 'NOR1')->getStatusCode())->toBe(422);
462
+ $controller->fakeAccess->reportOk = false;
463
+ expect($controller->report(trackingControllerRequest(['message' => 'Box damaged']), 'NOR1')->getStatusCode())->toBe(429);
464
+ $controller->fakeAccess->reportOk = true;
465
+ expect($controller->report(trackingControllerRequest(['message' => 'Box damaged']), 'NOR1')->getStatusCode())->toBe(202)
466
+ ->and($controller->mails[0])->toBe(['help@northwind.example', 'Problem reported for NOR1000000001US', "A customer reported a problem with delivery NOR1000000001US:\n\nBox damaged"]);
467
+
468
+ // Socket tokens, only when the instance publishes tracking channels.
469
+ expect($controller->socketToken(trackingControllerRequest(), 'NOR1')->getStatusCode())->toBe(404);
470
+ $controller->sockets = true;
471
+ expect($controller->socketToken(trackingControllerRequest(), 'NOR1')->getData(true))->toBe(['token' => 'jwt', 'expires_in' => 1800, 'expires_at' => '2026-10-08T09:30:00Z']);
472
+ });
473
+
474
+ test('a signed-in customer lists their orders with the number that opens their part', function () {
475
+ $controller = trackingController();
476
+ $db = Illuminate\Database\Eloquent\Model::getConnectionResolver()->connection();
477
+
478
+ expect($controller->accountOrders(trackingControllerRequest())->getStatusCode())->toBe(401);
479
+
480
+ $controller->user = (object) ['uuid' => 'user-1', 'type' => 'customer'];
481
+ TrackingPageDatabase::insert($db, 'contacts', ['uuid' => 'contact-1', 'user_uuid' => 'user-1']);
482
+ TrackingPageDatabase::insert($db, 'tracking_numbers', ['uuid' => 'tn-own', 'tracking_number' => 'NOR1']);
483
+ TrackingPageDatabase::insert($db, 'tracking_numbers', ['uuid' => 'tn-stop', 'tracking_number' => 'NOR2-STOP']);
484
+ TrackingPageDatabase::insert($db, 'orders', ['uuid' => 'order-own', 'customer_uuid' => 'contact-1', 'customer_type' => Contact::class, 'tracking_number_uuid' => 'tn-own', 'payload_uuid' => 'p-own', 'status' => 'completed', 'created_at' => '2026-10-07 09:00:00', 'updated_at' => '2026-10-07 10:00:00']);
485
+ TrackingPageDatabase::insert($db, 'orders', ['uuid' => 'order-stop', 'customer_uuid' => 'shipper', 'customer_type' => Contact::class, 'payload_uuid' => 'p-stop', 'status' => 'created', 'created_at' => '2026-10-08 09:00:00']);
486
+ TrackingPageDatabase::insert($db, 'orders', ['uuid' => 'order-none', 'customer_uuid' => 'contact-1', 'customer_type' => Contact::class, 'payload_uuid' => 'p-none', 'created_at' => '2026-10-06 09:00:00']);
487
+ TrackingPageDatabase::insert($db, 'waypoints', ['uuid' => 'wp-1', 'payload_uuid' => 'p-stop', 'customer_uuid' => 'contact-1', 'customer_type' => Contact::class, 'tracking_number_uuid' => 'tn-stop']);
488
+ TrackingPageDatabase::insert($db, 'entities', ['uuid' => 'e-1', 'payload_uuid' => 'p-own', 'name' => 'Ceramic lamp']);
489
+
490
+ $orders = $controller->accountOrders(trackingControllerRequest())->getData(true)['orders'];
491
+ expect($orders)->toBe([
492
+ ['tracking_number' => 'NOR2-STOP', 'title' => null, 'status' => 'created', 'stage' => 'preparing', 'updated_at' => null],
493
+ ['tracking_number' => 'NOR1', 'title' => 'Ceramic lamp', 'status' => 'completed', 'stage' => 'delivered', 'updated_at' => '2026-10-07T10:00:00.000000Z'],
494
+ ]);
495
+ });
@@ -0,0 +1,151 @@
1
+ <?php
2
+
3
+ use Fleetbase\FleetOps\Http\Filter\OrderFilter;
4
+ use Fleetbase\FleetOps\Http\Resources\v1\Index\Customer as IndexCustomerResource;
5
+ use Fleetbase\FleetOps\Models\Contact;
6
+ use Fleetbase\FleetOps\Models\Customer;
7
+ use Fleetbase\FleetOps\Models\Vendor;
8
+ use Fleetbase\FleetOps\Support\LiveOrderQuery;
9
+ use Illuminate\Database\Eloquent\Builder;
10
+ use Illuminate\Database\Eloquent\Relations\MorphTo;
11
+ use Illuminate\Http\Request;
12
+
13
+ class FleetOpsIndexCustomerPhotoMorphTo extends MorphTo
14
+ {
15
+ public array $morphWith = [];
16
+
17
+ public function __construct()
18
+ {
19
+ }
20
+
21
+ public function morphWith(array $with)
22
+ {
23
+ $this->morphWith = $with;
24
+
25
+ return $this;
26
+ }
27
+ }
28
+
29
+ class FleetOpsIndexCustomerPhotoBuilder extends Builder
30
+ {
31
+ public array $with = [];
32
+
33
+ public function __construct()
34
+ {
35
+ }
36
+
37
+ public function __call($method, $parameters)
38
+ {
39
+ return $this;
40
+ }
41
+
42
+ public function where($column, $operator = null, $value = null, $boolean = 'and')
43
+ {
44
+ return $this;
45
+ }
46
+
47
+ public function whereHas($relation, ?Closure $callback = null, $operator = '>=', $count = 1)
48
+ {
49
+ return $this;
50
+ }
51
+
52
+ public function whereNotIn($column, $values, $boolean = 'and')
53
+ {
54
+ return $this;
55
+ }
56
+
57
+ public function whereNull($columns, $boolean = 'and', $not = false)
58
+ {
59
+ return $this;
60
+ }
61
+
62
+ public function with($relations, $callback = null)
63
+ {
64
+ $this->with = array_merge($this->with, $relations);
65
+
66
+ return $this;
67
+ }
68
+ }
69
+
70
+ class FleetOpsIndexCustomerPhotoLiveQuery extends LiveOrderQuery
71
+ {
72
+ public static FleetOpsIndexCustomerPhotoBuilder $builder;
73
+
74
+ protected static function newOrderQuery(string $companyUuid): Builder
75
+ {
76
+ return static::$builder = new FleetOpsIndexCustomerPhotoBuilder();
77
+ }
78
+ }
79
+
80
+ function fleetopsIndexCustomerPhotoExpectedMorphWith(): array
81
+ {
82
+ return [
83
+ Contact::class => ['photo'],
84
+ Customer::class => ['photo'],
85
+ Vendor::class => ['logo'],
86
+ ];
87
+ }
88
+
89
+ function fleetopsIndexCustomerPhotoResolve(object $customer): array
90
+ {
91
+ return (new IndexCustomerResource($customer))->resolve(Request::create('/', 'GET'));
92
+ }
93
+
94
+ test('index customer resource sends a contact photo url', function () {
95
+ $contact = new Contact();
96
+ $contact->setRawAttributes(['name' => 'Fixture Contact']);
97
+ $contact->setRelation('photo', (object) ['url' => 'https://cdn.example.test/contact.png']);
98
+
99
+ expect(fleetopsIndexCustomerPhotoResolve($contact)['photo_url'])->toBe('https://cdn.example.test/contact.png');
100
+ });
101
+
102
+ test('index customer resource sends a vendor logo as the photo url', function () {
103
+ $vendor = new Vendor();
104
+ $vendor->setRawAttributes(['name' => 'Fixture Vendor']);
105
+ $vendor->setRelation('logo', (object) ['url' => 'https://cdn.example.test/vendor.png']);
106
+
107
+ expect(fleetopsIndexCustomerPhotoResolve($vendor)['photo_url'])->toBe('https://cdn.example.test/vendor.png');
108
+ });
109
+
110
+ test('index customer resource sends a null photo url for a customer type without one', function () {
111
+ $payload = fleetopsIndexCustomerPhotoResolve(new ArrayObject(['name' => 'Fixture'], ArrayObject::ARRAY_AS_PROPS));
112
+
113
+ expect($payload)->toHaveKey('photo_url')
114
+ ->and($payload['photo_url'])->toBeNull();
115
+ });
116
+
117
+ test('index customer eager loads the photo file for each customer type', function () {
118
+ $relation = new FleetOpsIndexCustomerPhotoMorphTo();
119
+
120
+ expect(IndexCustomerResource::withPhoto($relation))->toBe($relation)
121
+ ->and($relation->morphWith)->toBe(fleetopsIndexCustomerPhotoExpectedMorphWith());
122
+ });
123
+
124
+ test('live order query eager loads customer photos', function () {
125
+ FleetOpsIndexCustomerPhotoLiveQuery::make('company-uuid', ['with_relations' => true, 'apply_permissions' => false]);
126
+
127
+ $relation = new FleetOpsIndexCustomerPhotoMorphTo();
128
+ FleetOpsIndexCustomerPhotoLiveQuery::$builder->with['customer']($relation);
129
+
130
+ expect($relation->morphWith)->toBe(fleetopsIndexCustomerPhotoExpectedMorphWith());
131
+ });
132
+
133
+ test('internal order filter eager loads customer photos', function () {
134
+ $builder = new FleetOpsIndexCustomerPhotoBuilder();
135
+ $request = Request::create('/int/v1/orders', 'GET');
136
+ $session = app('session.store');
137
+ $session->put('company', 'company_test');
138
+ $request->setLaravelSession($session);
139
+
140
+ $filter = new OrderFilter($request);
141
+ $prop = (new ReflectionClass($filter))->getParentClass()->getProperty('builder');
142
+ $prop->setAccessible(true);
143
+ $prop->setValue($filter, $builder);
144
+
145
+ $filter->queryForInternal();
146
+
147
+ $relation = new FleetOpsIndexCustomerPhotoMorphTo();
148
+ $builder->with['customer']($relation);
149
+
150
+ expect($relation->morphWith)->toBe(fleetopsIndexCustomerPhotoExpectedMorphWith());
151
+ });
@@ -85,7 +85,8 @@ function fleetopsModelAccessorsUseInMemoryRelationConnection(): void
85
85
  EloquentModel::setConnectionResolver($resolver);
86
86
  }
87
87
 
88
- class FleetOpsCountingRelationFake
88
+ // Extends the relation the model method now declares, so the override stays compatible; the parent constructor is skipped on purpose.
89
+ class FleetOpsCountingRelationFake extends HasManyThrough
89
90
  {
90
91
  public function __construct(private int $total, private int $online)
91
92
  {
@@ -251,12 +252,12 @@ class FleetOpsDriverOrderAccessorFake extends Order
251
252
 
252
253
  class FleetOpsFleetAccessorFake extends Fleet
253
254
  {
254
- public function drivers()
255
+ public function drivers(): HasManyThrough
255
256
  {
256
257
  return new FleetOpsCountingRelationFake(5, 2);
257
258
  }
258
259
 
259
- public function vehicles()
260
+ public function vehicles(): HasManyThrough
260
261
  {
261
262
  return new FleetOpsCountingRelationFake(7, 3);
262
263
  }