@fleetbase/fleetops-engine 0.6.68 → 0.6.70

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 (294) hide show
  1. package/RELEASE.md +44 -9
  2. package/addon/components/admin/telematics-settings.hbs +143 -0
  3. package/addon/components/admin/telematics-settings.js +145 -0
  4. package/addon/components/contact/form.hbs +13 -2
  5. package/addon/components/customer/details.hbs +17 -17
  6. package/addon/components/customer/form.hbs +13 -2
  7. package/addon/components/customer/form.js +0 -56
  8. package/addon/components/driver/details.hbs +13 -19
  9. package/addon/components/driver/form.hbs +20 -35
  10. package/addon/components/driver/form.js +0 -58
  11. package/addon/components/fuel-integration/hub.hbs +1 -1
  12. package/addon/components/layout/fleet-ops-sidebar.js +37 -17
  13. package/addon/components/map/leaflet-live-map.hbs +4 -242
  14. package/addon/components/map/leaflet-live-map.js +2 -1
  15. package/addon/components/map/marker-card/driver.hbs +57 -0
  16. package/addon/components/map/marker-card/driver.js +3 -0
  17. package/addon/components/map/marker-card/vehicle.hbs +78 -0
  18. package/addon/components/map/marker-card/vehicle.js +3 -0
  19. package/addon/components/modals/driver-form.hbs +2 -2
  20. package/addon/components/modals/reset-profile-credentials.hbs +23 -0
  21. package/addon/components/modals/reset-profile-credentials.js +70 -0
  22. package/addon/components/profile-identity-hint.hbs +13 -0
  23. package/addon/components/profile-identity-hint.js +49 -0
  24. package/addon/components/telematic/hub.hbs +1 -1
  25. package/addon/components/widget/live-fleet.hbs +8 -40
  26. package/addon/controllers/analytics/reports/index.js +5 -0
  27. package/addon/controllers/connectivity/devices/index.js +4 -0
  28. package/addon/controllers/connectivity/fuel-providers/index.js +6 -4
  29. package/addon/controllers/connectivity/sensors/index.js +4 -0
  30. package/addon/controllers/connectivity/telematics/index.js +4 -0
  31. package/addon/controllers/maintenance/equipment/index.js +11 -4
  32. package/addon/controllers/maintenance/inspection-forms/index.js +2 -2
  33. package/addon/controllers/maintenance/inspection-submissions/index.js +8 -2
  34. package/addon/controllers/maintenance/maintenances/index.js +4 -0
  35. package/addon/controllers/maintenance/parts/index.js +11 -4
  36. package/addon/controllers/maintenance/schedules/index.js +17 -4
  37. package/addon/controllers/maintenance/work-orders/index.js +11 -4
  38. package/addon/controllers/management/contacts/index.js +4 -0
  39. package/addon/controllers/management/drivers/index.js +8 -0
  40. package/addon/controllers/management/fleets/index.js +4 -0
  41. package/addon/controllers/management/fuel-reports/index.js +4 -0
  42. package/addon/controllers/management/fuel-transactions/index.js +9 -7
  43. package/addon/controllers/management/issues/index.js +4 -0
  44. package/addon/controllers/management/places/index.js +4 -0
  45. package/addon/controllers/management/vehicles/index.js +4 -0
  46. package/addon/controllers/management/vendors/index.js +4 -0
  47. package/addon/controllers/operations/orders/index/details.js +30 -1
  48. package/addon/controllers/operations/orders/index.js +7 -0
  49. package/addon/controllers/operations/service-rates/index.js +3 -0
  50. package/addon/controllers/settings/index.js +13 -0
  51. package/addon/controllers/settings/telematics.js +113 -0
  52. package/addon/extension.js +42 -8
  53. package/addon/routes/analytics/index.js +14 -1
  54. package/addon/routes/analytics/reports/index/details.js +1 -1
  55. package/addon/routes/analytics/reports/index/edit.js +1 -1
  56. package/addon/routes/analytics/reports/index/new.js +14 -1
  57. package/addon/routes/analytics/reports.js +14 -1
  58. package/addon/routes/connectivity/devices/index/new.js +14 -1
  59. package/addon/routes/connectivity/devices.js +14 -1
  60. package/addon/routes/connectivity/events.js +14 -1
  61. package/addon/routes/connectivity/fuel-providers/details/transactions.js +12 -0
  62. package/addon/routes/connectivity/fuel-providers/details.js +9 -0
  63. package/addon/routes/connectivity/fuel-providers/edit.js +9 -0
  64. package/addon/routes/connectivity/fuel-providers/new.js +14 -1
  65. package/addon/routes/connectivity/fuel-providers.js +14 -1
  66. package/addon/routes/connectivity/sensors/index/new.js +14 -1
  67. package/addon/routes/connectivity/sensors.js +14 -1
  68. package/addon/routes/connectivity/telematics/new.js +14 -1
  69. package/addon/routes/connectivity/telematics.js +14 -1
  70. package/addon/routes/connectivity/tracking.js +14 -1
  71. package/addon/routes/maintenance/equipment/index/details.js +2 -2
  72. package/addon/routes/maintenance/equipment/index/edit.js +2 -2
  73. package/addon/routes/maintenance/equipment/index/new.js +14 -1
  74. package/addon/routes/maintenance/equipment.js +14 -1
  75. package/addon/routes/maintenance/index.js +14 -1
  76. package/addon/routes/maintenance/inspection-forms/index/details.js +10 -1
  77. package/addon/routes/maintenance/inspection-forms/index/edit.js +10 -1
  78. package/addon/routes/maintenance/inspection-forms/index/new.js +14 -1
  79. package/addon/routes/maintenance/inspection-forms.js +14 -1
  80. package/addon/routes/maintenance/inspection-submissions/index/details.js +10 -1
  81. package/addon/routes/maintenance/inspection-submissions/index/edit.js +10 -1
  82. package/addon/routes/maintenance/inspection-submissions/index/new.js +14 -1
  83. package/addon/routes/maintenance/inspection-submissions.js +14 -1
  84. package/addon/routes/maintenance/maintenances/index/details.js +2 -2
  85. package/addon/routes/maintenance/maintenances/index/edit.js +2 -2
  86. package/addon/routes/maintenance/maintenances/index/new.js +14 -1
  87. package/addon/routes/maintenance/maintenances.js +14 -1
  88. package/addon/routes/maintenance/parts/index/details.js +2 -2
  89. package/addon/routes/maintenance/parts/index/edit.js +2 -2
  90. package/addon/routes/maintenance/parts/index/new.js +14 -1
  91. package/addon/routes/maintenance/parts.js +14 -1
  92. package/addon/routes/maintenance/schedules/index/details.js +2 -2
  93. package/addon/routes/maintenance/schedules/index/edit.js +2 -2
  94. package/addon/routes/maintenance/schedules/index/new.js +15 -1
  95. package/addon/routes/maintenance/schedules.js +15 -1
  96. package/addon/routes/maintenance/work-orders/index/details.js +2 -2
  97. package/addon/routes/maintenance/work-orders/index/edit.js +2 -2
  98. package/addon/routes/maintenance/work-orders/index/new.js +14 -1
  99. package/addon/routes/maintenance/work-orders.js +14 -1
  100. package/addon/routes/management/fuel-transactions/index/details.js +9 -0
  101. package/addon/routes/management/fuel-transactions.js +14 -1
  102. package/addon/routes/management/index.js +13 -0
  103. package/addon/routes/management/trailers.js +16 -0
  104. package/addon/routes/management/vendors/integrated/details.js +14 -1
  105. package/addon/routes/management/vendors/integrated/edit.js +14 -1
  106. package/addon/routes/management/vendors/integrated/new.js +14 -1
  107. package/addon/routes/management/vendors/integrated.js +14 -1
  108. package/addon/routes/operations/orders.js +40 -1
  109. package/addon/routes/operations/routes/index/details.js +14 -1
  110. package/addon/routes/operations/routes/index/new.js +13 -0
  111. package/addon/routes/operations/routes.js +14 -1
  112. package/addon/routes/operations/service-rates/index/new.js +11 -0
  113. package/addon/routes/operations/service-rates.js +14 -1
  114. package/addon/routes/settings/avatars.js +14 -1
  115. package/addon/routes/settings/custom-fields.js +14 -1
  116. package/addon/routes/settings/map.js +14 -1
  117. package/addon/routes/settings/notifications.js +11 -0
  118. package/addon/routes/settings/orchestrator.js +1 -1
  119. package/addon/routes/settings/payments/index.js +1 -1
  120. package/addon/routes/settings/routing.js +14 -1
  121. package/addon/routes/settings/scheduling.js +14 -1
  122. package/addon/routes/settings/telematics.js +21 -0
  123. package/addon/routes/virtual.js +11 -0
  124. package/addon/routes.js +1 -0
  125. package/addon/services/contact-actions.js +50 -89
  126. package/addon/services/driver-actions.js +98 -0
  127. package/addon/templates/settings/telematics.hbs +70 -0
  128. package/addon/utils/profile-login-actions.js +172 -0
  129. package/app/components/admin/telematics-settings.js +1 -0
  130. package/app/components/{modals/reset-customer-credentials.js → map/marker-card/driver.js} +1 -1
  131. package/app/components/map/marker-card/vehicle.js +1 -0
  132. package/app/components/modals/reset-profile-credentials.js +1 -0
  133. package/app/components/profile-identity-hint.js +1 -0
  134. package/app/controllers/settings/telematics.js +1 -0
  135. package/app/routes/settings/telematics.js +1 -0
  136. package/app/templates/settings/telematics.js +1 -0
  137. package/composer.json +2 -2
  138. package/docs/AFAQY.md +1 -1
  139. package/docs/TELEMATICS_QUEUES.md +1 -1
  140. package/docs/TELEMETRY_ARCHITECTURE.md +72 -1
  141. package/extension.json +1 -1
  142. package/package.json +3 -3
  143. package/scripts/pest-bootstrap.php +12 -0
  144. package/server/config/telemetry.php +12 -0
  145. package/server/migrations/2026_09_21_000001_convert_profile_only_users_to_managed_accounts.php +132 -0
  146. package/server/migrations/2026_09_23_000001_add_retention_index_to_device_events.php +40 -0
  147. package/server/migrations/2026_09_23_000002_add_retention_index_to_positions.php +40 -0
  148. package/server/migrations/2026_09_23_000003_add_retention_index_to_telematic_sync_runs.php +40 -0
  149. package/server/resources/views/mail/driver-credentials.blade.php +21 -0
  150. package/server/src/Auth/Schemas/FleetOps.php +104 -5
  151. package/server/src/Console/Commands/DrainTelematicInbox.php +3 -13
  152. package/server/src/Console/Commands/FixCustomerCompanies.php +1 -1
  153. package/server/src/Console/Commands/FixDriverCompanies.php +1 -1
  154. package/server/src/Console/Commands/ProcessOperationalAlerts.php +3 -3
  155. package/server/src/Console/Commands/PruneTelematicsData.php +318 -0
  156. package/server/src/Exceptions/ProfileIdentityConflictException.php +34 -0
  157. package/server/src/Http/Controllers/Api/v1/CustomerController.php +14 -0
  158. package/server/src/Http/Controllers/Api/v1/DriverController.php +56 -39
  159. package/server/src/Http/Controllers/Api/v1/OrchestrationController.php +9 -0
  160. package/server/src/Http/Controllers/FleetOpsController.php +21 -0
  161. package/server/src/Http/Controllers/Internal/v1/AnalyticsController.php +11 -0
  162. package/server/src/Http/Controllers/Internal/v1/ContactController.php +16 -17
  163. package/server/src/Http/Controllers/Internal/v1/CustomerController.php +54 -4
  164. package/server/src/Http/Controllers/Internal/v1/DeviceController.php +13 -0
  165. package/server/src/Http/Controllers/Internal/v1/DeviceEventController.php +11 -0
  166. package/server/src/Http/Controllers/Internal/v1/DriverController.php +204 -162
  167. package/server/src/Http/Controllers/Internal/v1/FleetOpsLookupController.php +47 -0
  168. package/server/src/Http/Controllers/Internal/v1/FuelProviderConnectionController.php +13 -0
  169. package/server/src/Http/Controllers/Internal/v1/FuelProviderTransactionController.php +15 -0
  170. package/server/src/Http/Controllers/Internal/v1/FuelReportController.php +9 -3
  171. package/server/src/Http/Controllers/Internal/v1/InspectionFormController.php +15 -0
  172. package/server/src/Http/Controllers/Internal/v1/LiveController.php +16 -0
  173. package/server/src/Http/Controllers/Internal/v1/MaintenanceController.php +15 -0
  174. package/server/src/Http/Controllers/Internal/v1/MaintenanceScheduleController.php +15 -0
  175. package/server/src/Http/Controllers/Internal/v1/ManifestController.php +15 -0
  176. package/server/src/Http/Controllers/Internal/v1/MetricsController.php +11 -0
  177. package/server/src/Http/Controllers/Internal/v1/NavigatorController.php +42 -1
  178. package/server/src/Http/Controllers/Internal/v1/OrchestrationController.php +12 -0
  179. package/server/src/Http/Controllers/Internal/v1/OrderController.php +178 -27
  180. package/server/src/Http/Controllers/Internal/v1/PaymentController.php +13 -0
  181. package/server/src/Http/Controllers/Internal/v1/RadarController.php +19 -0
  182. package/server/src/Http/Controllers/Internal/v1/ServiceAreaController.php +9 -3
  183. package/server/src/Http/Controllers/Internal/v1/SettingController.php +465 -14
  184. package/server/src/Http/Controllers/Internal/v1/TelematicController.php +21 -0
  185. package/server/src/Http/Controllers/Internal/v1/TrailerController.php +21 -0
  186. package/server/src/Http/Controllers/Internal/v1/VehicleController.php +21 -0
  187. package/server/src/Http/Controllers/Internal/v1/VendorController.php +17 -0
  188. package/server/src/Http/Controllers/Internal/v1/WorkOrderController.php +11 -0
  189. package/server/src/Http/Requests/CreateDriverRequest.php +6 -2
  190. package/server/src/Http/Requests/Internal/CreateDriverRequest.php +9 -18
  191. package/server/src/Http/Resources/v1/Contact.php +3 -0
  192. package/server/src/Http/Resources/v1/Driver.php +3 -0
  193. package/server/src/Jobs/DispatchTelematicsRetentionJobs.php +70 -0
  194. package/server/src/Jobs/PruneTelematicsDataJob.php +74 -0
  195. package/server/src/Listeners/NotifyDriverOnShiftChange.php +3 -3
  196. package/server/src/Mail/CustomerCredentialsMail.php +1 -1
  197. package/server/src/Mail/DriverCredentialsMail.php +55 -0
  198. package/server/src/Models/Contact.php +55 -90
  199. package/server/src/Models/Sensor.php +3 -5
  200. package/server/src/Observers/ContactObserver.php +2 -22
  201. package/server/src/Observers/DriverObserver.php +5 -6
  202. package/server/src/Orchestration/Engines/VroomOrchestrationEngine.php +9 -1
  203. package/server/src/Providers/FleetOpsServiceProvider.php +18 -0
  204. package/server/src/Support/Ai/Capabilities/AbstractFleetOpsAICapability.php +62 -5
  205. package/server/src/Support/Ai/Capabilities/AssetStatusCapability.php +24 -5
  206. package/server/src/Support/Ai/Capabilities/CreateOrderPreviewCapability.php +6 -2
  207. package/server/src/Support/Ai/Capabilities/OrderInsightsCapability.php +40 -7
  208. package/server/src/Support/Ai/Capabilities/SearchResourcesCapability.php +86 -24
  209. package/server/src/Support/Ai/FleetOpsAiConsoleCommands.php +122 -0
  210. package/server/src/Support/Ai/Tools/CreateOrderTool.php +86 -0
  211. package/server/src/Support/Ai/Tools/ImportOrdersTool.php +53 -0
  212. package/server/src/Support/Ai/Tools/OptimizeOrderRouteTool.php +70 -0
  213. package/server/src/Support/Ai/Tools/SearchResourcesTool.php +73 -0
  214. package/server/src/Support/Analytics/LiveFleet.php +18 -2
  215. package/server/src/Support/Authorization.php +103 -0
  216. package/server/src/Support/Database/TableIndexes.php +66 -0
  217. package/server/src/Support/OrderTracker.php +2 -2
  218. package/server/src/Support/ProfileAccountManager.php +405 -0
  219. package/server/src/Support/Reporting/FleetOpsReportSchema.php +476 -116
  220. package/server/src/Support/Telematics/Retention/RetentionPolicy.php +300 -0
  221. package/server/src/Support/Telematics/Retention/TelemetryActivity.php +40 -0
  222. package/server/src/Support/Telematics/TelematicService.php +12 -2
  223. package/server/src/Support/Telematics/Telemetry/Ingestor.php +4 -2
  224. package/server/src/Tracking/TrackingIntelligenceService.php +1 -1
  225. package/server/src/Tracking/TrackingOptions.php +2 -2
  226. package/server/src/Traits/AuthorizesMethods.php +37 -0
  227. package/server/src/routes.php +14 -1
  228. package/server/tests/AiOperationalQueryCapabilityTest.php +1 -0
  229. package/server/tests/AnalyticsRoutesTest.php +29 -2
  230. package/server/tests/ApiCustomerControllerContractsTest.php +21 -0
  231. package/server/tests/ApiDriverControllerContractsTest.php +102 -20
  232. package/server/tests/CommandContractsTest.php +4 -4
  233. package/server/tests/ControllerHelperContractsTest.php +3 -11
  234. package/server/tests/Feature/Http/AfaqyRealtimeIngestionTest.php +21 -1
  235. package/server/tests/Feature/Http/Api/DriverControllerAuthFlowsTest.php +36 -0
  236. package/server/tests/Feature/Http/Api/DriverControllerSessionHelpersTest.php +19 -6
  237. package/server/tests/Feature/Http/Api/ProfileOnlyUserConversionMigrationTest.php +201 -0
  238. package/server/tests/Feature/Http/Api/TelematicsRetentionIndexMigrationTest.php +115 -0
  239. package/server/tests/Feature/Http/Internal/ContactControllerEndpointsTest.php +6 -4
  240. package/server/tests/Feature/Http/Internal/CustomerControllerCredentialsTest.php +6 -2
  241. package/server/tests/Feature/Http/Internal/DriverControllerContractsTest.php +23 -0
  242. package/server/tests/Feature/Http/Internal/DriverControllerCreateRecordTest.php +42 -17
  243. package/server/tests/Feature/Http/Internal/DriverControllerExistingUserAdoptionTest.php +168 -277
  244. package/server/tests/Feature/Http/Internal/DriverControllerLoginManagementTest.php +716 -0
  245. package/server/tests/Feature/Http/Internal/OrderControllerContractsTest.php +10 -3
  246. package/server/tests/Feature/Http/Internal/OrderControllerImportFromFilesTest.php +18 -0
  247. package/server/tests/Feature/Http/Internal/OrderControllerTenantScopingTest.php +411 -0
  248. package/server/tests/Feature/Http/Internal/OrderControllerUpstreamNotFoundTest.php +8 -20
  249. package/server/tests/Feature/Http/Internal/SettingControllerHelpersTest.php +71 -0
  250. package/server/tests/Feature/Http/Internal/TelematicsStorageDeadlinesTest.php +119 -0
  251. package/server/tests/InternalCustomerControllerContractsTest.php +32 -1
  252. package/server/tests/InternalImportExportControllerContractsTest.php +5 -8
  253. package/server/tests/ModelAccessorContractsTest.php +82 -20
  254. package/server/tests/NavigatorControllerContractsTest.php +36 -4
  255. package/server/tests/NotificationAndMailContractsTest.php +31 -0
  256. package/server/tests/ObserverContractsTest.php +28 -3
  257. package/server/tests/OrchestratorConsumableApiTest.php +42 -0
  258. package/server/tests/ProviderContractsTest.php +10 -0
  259. package/server/tests/ReportSchemaContractsTest.php +120 -6
  260. package/server/tests/RequestContractsTest.php +19 -11
  261. package/server/tests/SettingControllerContractsTest.php +429 -18
  262. package/server/tests/SmallControllerContractsTest.php +8 -5
  263. package/server/tests/Support/PruneTelematicsDataProbe.php +55 -0
  264. package/server/tests/SupportJobAndAiCoverageTest.php +3 -3
  265. package/server/tests/TelematicControllerContractsTest.php +22 -4
  266. package/server/tests/Unit/Console/Commands/ProcessOperationalAlertsCommandTest.php +2 -2
  267. package/server/tests/Unit/Console/PruneTelematicsDataCommandTest.php +353 -0
  268. package/server/tests/Unit/Jobs/DispatchTelematicsRetentionJobsTest.php +114 -0
  269. package/server/tests/Unit/Jobs/PruneTelematicsDataJobTest.php +60 -0
  270. package/server/tests/Unit/Models/ContactCustomerUserTest.php +90 -14
  271. package/server/tests/Unit/Models/ContactTest.php +48 -5
  272. package/server/tests/Unit/Models/SensorTest.php +2 -2
  273. package/server/tests/Unit/Models/SmallModelSurfacesTest.php +31 -32
  274. package/server/tests/Unit/Observers/ObserverListenerSeamsTest.php +7 -2
  275. package/server/tests/Unit/Orchestration/VroomEngineTransportAndSettingsTest.php +20 -0
  276. package/server/tests/Unit/Providers/FleetOpsAiRegistrationTest.php +15 -2
  277. package/server/tests/Unit/Support/Ai/AssetStatusCapabilityQueriesTest.php +42 -0
  278. package/server/tests/Unit/Support/Ai/Capabilities/OrderInsightsCapabilityTest.php +19 -1
  279. package/server/tests/Unit/Support/Ai/Capabilities/SearchResourcesCapabilityTest.php +85 -1
  280. package/server/tests/Unit/Support/Ai/FleetOpsAiConsoleCommandsTest.php +31 -0
  281. package/server/tests/Unit/Support/Ai/Tools/FleetOpsAiToolsTest.php +246 -0
  282. package/server/tests/Unit/Support/AiCapabilityPermissionsTest.php +3 -3
  283. package/server/tests/Unit/Support/Analytics/LiveFleetTest.php +6 -0
  284. package/server/tests/Unit/Support/AuthorizationTest.php +142 -0
  285. package/server/tests/Unit/Support/MaintainableAndOperationalAlertsTest.php +12 -1
  286. package/server/tests/Unit/Support/MoreQuerySeamsTest.php +11 -2
  287. package/server/tests/Unit/Support/ProfileAccountManagerTest.php +643 -0
  288. package/server/tests/Unit/Support/Telematics/Retention/RetentionPolicyTest.php +190 -0
  289. package/server/tests/Unit/Support/Telematics/Retention/TelemetryActivityTest.php +68 -0
  290. package/server/tests/Unit/Tracking/TrackingOptionsCompanySettingsTest.php +109 -0
  291. package/translations/en-us.yaml +85 -2
  292. package/translations/uk-ua.yaml +3475 -0
  293. package/addon/components/modals/reset-customer-credentials.hbs +0 -18
  294. package/addon/components/modals/reset-customer-credentials.js +0 -55
@@ -0,0 +1,300 @@
1
+ <?php
2
+
3
+ namespace Fleetbase\FleetOps\Support\Telematics\Retention;
4
+
5
+ use Fleetbase\Models\Setting;
6
+ use Illuminate\Support\Carbon;
7
+
8
+ /**
9
+ * Resolves how long a company keeps telematics data.
10
+ *
11
+ * Administrators control retention policy and infrastructure settings. Companies
12
+ * may override event and position history retention within the system maximums.
13
+ * Zero means "keep forever" only when the administrator has not set a maximum.
14
+ */
15
+ final class RetentionPolicy
16
+ {
17
+ public const SETTING_KEY = 'fleet-ops.telematics-settings';
18
+
19
+ /** Cached policies are refreshed after this many seconds so saved settings reach long-lived workers. */
20
+ public const CACHE_TTL_SECONDS = 60;
21
+
22
+ public const HISTORY_KEYS = ['event_retention_days', 'position_retention_days'];
23
+
24
+ public const MAXIMUMS = [
25
+ 'event_retention_days' => 'max_event_retention_days',
26
+ 'position_retention_days' => 'max_position_retention_days',
27
+ ];
28
+
29
+ /** @var array<string, array{0: int, 1: int}> */
30
+ public const LIMITS = [
31
+ 'event_retention_days' => [1, 3650],
32
+ 'event_compact_after_days' => [1, 3650],
33
+ 'position_retention_days' => [1, 3650],
34
+ 'processed_retention_hours' => [1, 720],
35
+ 'quarantine_retention_days' => [1, 365],
36
+ 'sync_run_retention_days' => [1, 365],
37
+ 'max_event_retention_days' => [1, 3650],
38
+ 'max_position_retention_days' => [1, 3650],
39
+ ];
40
+
41
+ public const BOOLEANS = ['log_telemetry_activity'];
42
+
43
+ /** Used when neither config nor settings provide a value. */
44
+ public const FALLBACKS = [
45
+ 'event_retention_days' => 30,
46
+ 'event_compact_after_days' => 7,
47
+ 'position_retention_days' => 90,
48
+ 'processed_retention_hours' => 24,
49
+ 'quarantine_retention_days' => 7,
50
+ 'sync_run_retention_days' => 7,
51
+ // A separate, opt-in cap preserves existing history when upgrading.
52
+ 'max_event_retention_days' => 0,
53
+ 'max_position_retention_days' => 0,
54
+ 'log_telemetry_activity' => false,
55
+ ];
56
+
57
+ /**
58
+ * Test seam: fn (string $scope, string $key, mixed $default, ?string $companyUuid): mixed
59
+ * where $scope is `system` or `company`.
60
+ */
61
+ public static ?\Closure $settingsResolver = null;
62
+
63
+ /** @var array<string, array{policy: self, expires: int}> */
64
+ private static array $companyCache = [];
65
+
66
+ /** @var array{values: array, expires: int}|null */
67
+ private static ?array $defaultsCache = null;
68
+
69
+ private function __construct(private array $values)
70
+ {
71
+ }
72
+
73
+ /** @return string[] */
74
+ public static function keys(): array
75
+ {
76
+ return array_keys(self::FALLBACKS);
77
+ }
78
+
79
+ /**
80
+ * System-wide defaults: package config overridden by the admin setting.
81
+ */
82
+ public static function defaults(): array
83
+ {
84
+ if (self::$defaultsCache && self::$defaultsCache['expires'] > time()) {
85
+ return self::$defaultsCache['values'];
86
+ }
87
+
88
+ $config = array_intersect_key((array) config('telematics.telemetry', []), self::FALLBACKS);
89
+ $base = self::normalize($config, self::FALLBACKS);
90
+ $values = self::normalize(self::resolve('system', null), $base);
91
+
92
+ self::$defaultsCache = ['values' => $values, 'expires' => time() + self::CACHE_TTL_SECONDS];
93
+
94
+ return $values;
95
+ }
96
+
97
+ public static function forCompany(?string $companyUuid): self
98
+ {
99
+ if (!$companyUuid) {
100
+ return self::fromCompanyPreferences([]);
101
+ }
102
+
103
+ $cached = self::$companyCache[$companyUuid] ?? null;
104
+ if ($cached && $cached['expires'] > time()) {
105
+ return $cached['policy'];
106
+ }
107
+
108
+ $defaults = self::defaults();
109
+ $policy = self::fromCompanyPreferences(self::resolve('company', $companyUuid), $defaults);
110
+
111
+ // A policy must not extend the lifetime of system defaults it inherited.
112
+ self::$companyCache[$companyUuid] = ['policy' => $policy, 'expires' => self::$defaultsCache['expires']];
113
+
114
+ return $policy;
115
+ }
116
+
117
+ public static function fromArray(array $values, ?array $base = null): self
118
+ {
119
+ return new self(self::applyMaximums(self::normalize($values, $base ?? self::FALLBACKS)));
120
+ }
121
+
122
+ /**
123
+ * Keep only explicit, valid customer history preferences. Missing or null
124
+ * values remain absent so later changes to the system defaults are inherited.
125
+ */
126
+ public static function companyPreferences(array $input): array
127
+ {
128
+ $preferences = [];
129
+ foreach (self::HISTORY_KEYS as $key) {
130
+ $value = $input[$key] ?? null;
131
+ if ($value === null || $value === '' || is_bool($value) || filter_var($value, FILTER_VALIDATE_INT) === false) {
132
+ continue;
133
+ }
134
+
135
+ [$min, $max] = self::LIMITS[$key];
136
+ $value = (int) $value;
137
+ $preferences[$key] = $value === 0 ? 0 : max($min, min($max, $value));
138
+ }
139
+
140
+ return $preferences;
141
+ }
142
+
143
+ /**
144
+ * Legacy company overrides for ingestion, payloads and logging are ignored.
145
+ * Apply caps after inheritance so unlimited or overly long defaults cannot
146
+ * bypass an administrator's maximum either.
147
+ */
148
+ public static function fromCompanyPreferences(array $preferences, ?array $defaults = null): self
149
+ {
150
+ return self::fromArray(self::companyPreferences($preferences), $defaults ?? self::defaults());
151
+ }
152
+
153
+ /**
154
+ * Return the explicit history choices after applying the administrator's
155
+ * maximums. Inherited values stay absent, and an allowed zero stays explicit.
156
+ */
157
+ public static function constrainCompanyPreferences(array $preferences, ?array $defaults = null): array
158
+ {
159
+ $preferences = self::companyPreferences($preferences);
160
+ $effective = self::fromCompanyPreferences($preferences, $defaults)->toArray();
161
+
162
+ return array_intersect_key($effective, $preferences);
163
+ }
164
+
165
+ /**
166
+ * Clamp and cast user input, filling gaps from `$base`.
167
+ */
168
+ public static function normalize(array $input, array $base): array
169
+ {
170
+ $values = [];
171
+ foreach (self::LIMITS as $key => [$min, $max]) {
172
+ $value = (int) self::pick($input, $base, $key);
173
+ $values[$key] = $value === 0 ? 0 : max($min, min($max, $value));
174
+ }
175
+ foreach (self::BOOLEANS as $key) {
176
+ $values[$key] = filter_var(self::pick($input, $base, $key), FILTER_VALIDATE_BOOLEAN);
177
+ }
178
+
179
+ return $values;
180
+ }
181
+
182
+ public static function flush(?string $companyUuid = null): void
183
+ {
184
+ if ($companyUuid === null) {
185
+ self::$companyCache = [];
186
+ self::$defaultsCache = null;
187
+
188
+ return;
189
+ }
190
+
191
+ unset(self::$companyCache[$companyUuid]);
192
+ }
193
+
194
+ /**
195
+ * The point in time before which rows governed by `$key` are expired, or null when retention is off.
196
+ */
197
+ public function cutoff(string $key): ?Carbon
198
+ {
199
+ $value = (int) ($this->values[$key] ?? 0);
200
+ if ($value <= 0) {
201
+ return null;
202
+ }
203
+
204
+ return $key === 'processed_retention_hours' ? Carbon::now()->subHours($value) : Carbon::now()->subDays($value);
205
+ }
206
+
207
+ public function deletesEvents(): bool
208
+ {
209
+ return $this->values['event_retention_days'] > 0;
210
+ }
211
+
212
+ /**
213
+ * Compaction strips raw payloads from events that are kept but no longer need the provider blob.
214
+ * It is pointless when events are deleted before, or at the same age as, they would be compacted.
215
+ */
216
+ public function compactsEvents(): bool
217
+ {
218
+ $compactAfter = $this->values['event_compact_after_days'];
219
+ if ($compactAfter <= 0) {
220
+ return false;
221
+ }
222
+
223
+ return !$this->deletesEvents() || $compactAfter < $this->values['event_retention_days'];
224
+ }
225
+
226
+ public function logsTelemetryActivity(): bool
227
+ {
228
+ return (bool) $this->values['log_telemetry_activity'];
229
+ }
230
+
231
+ public function get(string $key): mixed
232
+ {
233
+ return $this->values[$key] ?? null;
234
+ }
235
+
236
+ public function toArray(): array
237
+ {
238
+ return $this->values;
239
+ }
240
+
241
+ private static function applyMaximums(array $values): array
242
+ {
243
+ foreach (self::MAXIMUMS as $key => $maximumKey) {
244
+ $maximum = $values[$maximumKey];
245
+ if ($maximum > 0 && ($values[$key] === 0 || $values[$key] > $maximum)) {
246
+ $values[$key] = $maximum;
247
+ }
248
+ }
249
+
250
+ return $values;
251
+ }
252
+
253
+ private static function pick(array $input, array $base, string $key): mixed
254
+ {
255
+ if (array_key_exists($key, $input) && $input[$key] !== null && $input[$key] !== '') {
256
+ return $input[$key];
257
+ }
258
+
259
+ return $base[$key] ?? self::FALLBACKS[$key];
260
+ }
261
+
262
+ /**
263
+ * Read the stored setting for a scope. Any failure (no settings table, no session store)
264
+ * falls back to the layer below so ingestion never depends on the settings store.
265
+ */
266
+ private static function resolve(string $scope, ?string $companyUuid): array
267
+ {
268
+ try {
269
+ if (self::$settingsResolver) {
270
+ $value = (self::$settingsResolver)($scope, self::SETTING_KEY, [], $companyUuid);
271
+ } elseif ($scope === 'system') {
272
+ $value = Setting::lookup(self::SETTING_KEY, []);
273
+ } else {
274
+ $value = self::lookupCompanySetting($companyUuid);
275
+ }
276
+ } catch (\Throwable) {
277
+ $value = [];
278
+ }
279
+
280
+ return is_array($value) ? $value : [];
281
+ }
282
+
283
+ /**
284
+ * `Setting::lookupCompany` reads the company from the session, which queue workers and
285
+ * console commands do not have; scope it for the lookup and restore whatever was there.
286
+ */
287
+ private static function lookupCompanySetting(string $companyUuid): mixed
288
+ {
289
+ $session = app('session');
290
+ $had = $session->has('company');
291
+ $previous = $session->get('company');
292
+ $session->put('company', $companyUuid);
293
+
294
+ try {
295
+ return Setting::lookupCompany(self::SETTING_KEY, []);
296
+ } finally {
297
+ $had ? $session->put('company', $previous) : $session->forget('company');
298
+ }
299
+ }
300
+ }
@@ -0,0 +1,40 @@
1
+ <?php
2
+
3
+ namespace Fleetbase\FleetOps\Support\Telematics\Retention;
4
+
5
+ use Spatie\Activitylog\ActivityLogger;
6
+
7
+ /**
8
+ * Runs telemetry-driven writes with activity logging suppressed unless the
9
+ * system administrator enabled it. Every poll saves the device, event, position and vehicle,
10
+ * which otherwise writes several activity rows per device per minute.
11
+ */
12
+ final class TelemetryActivity
13
+ {
14
+ public static function run(?string $companyUuid, \Closure $callback): mixed
15
+ {
16
+ if (RetentionPolicy::forCompany($companyUuid)->logsTelemetryActivity()) {
17
+ return $callback();
18
+ }
19
+
20
+ $logger = self::logger();
21
+ if (!$logger) {
22
+ return $callback();
23
+ }
24
+
25
+ return $logger->withoutLogs($callback);
26
+ }
27
+
28
+ /**
29
+ * The logger is optional: an environment without the activity log package
30
+ * bound (package tests, minimal consoles) must still ingest telemetry.
31
+ */
32
+ private static function logger(): ?ActivityLogger
33
+ {
34
+ try {
35
+ return app(ActivityLogger::class);
36
+ } catch (\Throwable) {
37
+ return null;
38
+ }
39
+ }
40
+ }
@@ -239,6 +239,12 @@ class TelematicService
239
239
  if ($provider instanceof \Fleetbase\FleetOps\Contracts\TelemetryProviderInterface) {
240
240
  return app(Telemetry\Ingestor::class)->ingest($telematic, $provider, $payload, $this);
241
241
  }
242
+
243
+ return Retention\TelemetryActivity::run($telematic->company_uuid, fn () => $this->ingestLegacySnapshot($telematic, $provider, $payload));
244
+ }
245
+
246
+ protected function ingestLegacySnapshot(Telematic $telematic, TelematicProviderInterface $provider, array $payload): array
247
+ {
242
248
  $device = $this->linkDevice($telematic, $provider->normalizeDevice($payload));
243
249
 
244
250
  $event = null;
@@ -299,7 +305,9 @@ class TelematicService
299
305
  ], fn ($value) => $value !== null);
300
306
  $event->payload = $eventData['payload'] ?? $eventData['meta'] ?? $eventData;
301
307
  $event->_key = $eventKey;
302
- $event->meta = array_merge($eventData['meta'] ?? [], [
308
+ // Providers hand over the raw unit in `meta`; it already lives in `payload`.
309
+ // Persist only the normalized block so each event is stored once, not twice.
310
+ $event->meta = array_filter([
303
311
  'telematic_uuid' => $telematic->uuid,
304
312
  'telematic_id' => $telematic->public_id,
305
313
  'provider_event_id' => $event->ident,
@@ -310,7 +318,9 @@ class TelematicService
310
318
  'odometer' => $eventData['odometer'] ?? null,
311
319
  'ignition' => $eventData['ignition'] ?? null,
312
320
  'fuel_level' => $eventData['fuel_level'] ?? null,
313
- ]);
321
+ 'telemetry' => data_get($eventData, 'meta.telemetry'),
322
+ 'provider_status' => data_get($eventData, 'meta.provider_status'),
323
+ ], fn ($value) => $value !== null);
314
324
 
315
325
  $location = $this->normalizeLocation($eventData['location'] ?? null);
316
326
  if ($location) {
@@ -7,6 +7,7 @@ use Fleetbase\FleetOps\Events\DeviceTelemetryUpdated;
7
7
  use Fleetbase\FleetOps\Models\Device;
8
8
  use Fleetbase\FleetOps\Models\DeviceEvent;
9
9
  use Fleetbase\FleetOps\Models\Telematic;
10
+ use Fleetbase\FleetOps\Support\Telematics\Retention\TelemetryActivity;
10
11
  use Fleetbase\FleetOps\Support\Telematics\TelematicService;
11
12
  use Illuminate\Support\Carbon;
12
13
  use Illuminate\Support\Facades\Cache;
@@ -32,7 +33,8 @@ class Ingestor
32
33
  }
33
34
  $key = 'telemetry:device:' . hash('sha256', $telematic->uuid . '|' . $id);
34
35
 
35
- return Cache::lock($key, 60)->block(5, fn () => DB::transaction(function () use ($telematic, $sample, $options, $service, $receivedAt, $source, $normalized, $event, $id) {
36
+ // Telemetry saves (device, event, position, vehicle, sensors) only reach the activity log when the company opts in.
37
+ return Cache::lock($key, 60)->block(5, fn () => TelemetryActivity::run($telematic->company_uuid, fn () => DB::transaction(function () use ($telematic, $sample, $options, $service, $receivedAt, $source, $normalized, $event, $id) {
36
38
  $device = Device::withoutGlobalScopes()->where('company_uuid', $telematic->company_uuid)->where('telematic_uuid', $telematic->uuid)->where('device_id', $id)->lockForUpdate()->first();
37
39
  $current = data_get($device?->meta, 'telemetry.position_at') ?? data_get($device?->meta, 'last_update.occurred_at');
38
40
  $valid = Sample::validPosition($event);
@@ -107,6 +109,6 @@ class Ingestor
107
109
  }
108
110
 
109
111
  return ['device' => $device, 'event' => $stored, 'events' => $stored ? [$stored] : [], 'sensors' => $sensors, 'duplicate' => $duplicate, 'invalid_position' => !$valid];
110
- }, 3));
112
+ }, 3)));
111
113
  }
112
114
  }
@@ -18,7 +18,7 @@ class TrackingIntelligenceService
18
18
 
19
19
  public function track(Order $order, array|TrackingOptions $options = []): array
20
20
  {
21
- $options = $options instanceof TrackingOptions ? $options : TrackingOptions::fromArray($options);
21
+ $options = $options instanceof TrackingOptions ? $options : TrackingOptions::fromArray($options, $order->company_uuid);
22
22
  $context = $this->contextBuilder->build($order, $options);
23
23
  $cacheKey = $this->cacheKey($context, $options);
24
24
 
@@ -19,7 +19,7 @@ class TrackingOptions
19
19
  ) {
20
20
  }
21
21
 
22
- public static function fromArray(array $options = []): self
22
+ public static function fromArray(array $options = [], ?string $companyUuid = null): self
23
23
  {
24
24
  try {
25
25
  $systemSettings = Setting::lookup('fleet-ops.tracking-settings', []);
@@ -30,7 +30,7 @@ class TrackingOptions
30
30
  $config = array_merge(config('fleetops.tracking', []), is_array($systemSettings) ? $systemSettings : []);
31
31
  $companySettings = [];
32
32
  try {
33
- $companySettings = Setting::lookupCompany('tracking', []);
33
+ $companySettings = Setting::lookupForCompany($companyUuid ?? session('company'), 'tracking', []);
34
34
  } catch (\Throwable) {
35
35
  $companySettings = [];
36
36
  }
@@ -0,0 +1,37 @@
1
+ <?php
2
+
3
+ namespace Fleetbase\FleetOps\Traits;
4
+
5
+ use Fleetbase\FleetOps\Support\Authorization;
6
+
7
+ /**
8
+ * Registers controller middleware that enforces an explicit permission per method.
9
+ *
10
+ * Used for endpoints AuthorizationGuard cannot resolve on its own: methods on
11
+ * resource controllers whose names do not match a schema action (these also carry
12
+ * `#[SkipAuthorizationCheck]`) and every method of controllers that are not
13
+ * resource controllers.
14
+ *
15
+ * Map values are one permission in short form (`dispatch order`), a list of
16
+ * permissions of which the user needs any one, or `admin` for system administrators.
17
+ */
18
+ trait AuthorizesMethods
19
+ {
20
+ /**
21
+ * @param array<string, string|string[]> $map method name => permission(s)
22
+ */
23
+ protected function authorizeMethods(array $map): void
24
+ {
25
+ foreach ($map as $method => $permissions) {
26
+ $this->middleware(function ($request, $next) use ($permissions) {
27
+ if ($permissions === 'admin') {
28
+ Authorization::authorizeAdmin();
29
+ } else {
30
+ Authorization::authorize(...(array) $permissions);
31
+ }
32
+
33
+ return $next($request);
34
+ })->only($method);
35
+ }
36
+ }
37
+ }
@@ -405,8 +405,10 @@ Route::prefix(config('fleetops.api.routing.prefix'))->namespace('Fleetbase\Fleet
405
405
  $router->group(
406
406
  ['prefix' => 'v1/fleet-ops/navigator', 'namespace' => 'v1'],
407
407
  function ($router) {
408
- $router->get('get-link-app', 'NavigatorController@getLinkAppUrl');
408
+ // Opened from the Navigator app's QR scan without a session;
409
+ // NavigatorController::linkApp() requires a valid signature.
409
410
  $router->get('link-app', 'NavigatorController@linkApp');
411
+ $router->get('get-link-app', 'NavigatorController@getLinkAppUrl')->middleware(['fleetbase.protected']);
410
412
  }
411
413
  );
412
414
 
@@ -444,6 +446,10 @@ Route::prefix(config('fleetops.api.routing.prefix'))->namespace('Fleetbase\Fleet
444
446
  $router->post('{id}/unassign-order', $controller('unassignOrder'));
445
447
  $router->post('{id}/assign-vehicle', $controller('assignVehicle'));
446
448
  $router->post('{id}/unassign-vehicle', $controller('unassignVehicle'));
449
+ $router->post('{id}/send-credentials', $controller('sendCredentials'));
450
+ $router->post('{id}/reset-credentials', $controller('resetCredentials'));
451
+ $router->post('{id}/deactivate-login', $controller('deactivateLogin'));
452
+ $router->post('{id}/reactivate-login', $controller('reactivateLogin'));
447
453
  $router->match(['get', 'post'], 'export', $controller('export'));
448
454
  $router->post('import', $controller('import'));
449
455
  // Driver scheduling endpoints
@@ -764,6 +770,7 @@ Route::prefix(config('fleetops.api.routing.prefix'))->namespace('Fleetbase\Fleet
764
770
  function ($router) {
765
771
  $router->get('customers', 'FleetOpsLookupController@polymorphs');
766
772
  $router->get('facilitators', 'FleetOpsLookupController@polymorphs');
773
+ $router->get('profile-identity', 'FleetOpsLookupController@profileIdentity');
767
774
  }
768
775
  );
769
776
  $router->group(
@@ -809,6 +816,12 @@ Route::prefix(config('fleetops.api.routing.prefix'))->namespace('Fleetbase\Fleet
809
816
  $router->post('orchestrator-settings', 'SettingController@saveOrchestratorSettings');
810
817
  $router->get('orchestrator-card-fields', 'SettingController@getOrchestratorCardFields');
811
818
  $router->post('orchestrator-card-fields', 'SettingController@saveOrchestratorCardFields');
819
+ $router->get('telematics-settings', 'SettingController@getTelematicsSettings');
820
+ $router->post('telematics-settings', 'SettingController@saveTelematicsSettings');
821
+ $router->get('admin-telematics-settings', 'SettingController@getAdminTelematicsSettings');
822
+ $router->post('admin-telematics-settings', 'SettingController@saveAdminTelematicsSettings');
823
+ $router->get('telematics-storage-usage', 'SettingController@getTelematicsStorageUsage');
824
+ $router->post('telematics-retention/run', 'SettingController@runTelematicsRetention');
812
825
  }
813
826
  );
814
827
  $router->group(
@@ -406,6 +406,7 @@ test('asset status capability exposes metadata and denied branches', function ()
406
406
  ->and($capability->label())->toBe('Fleet-Ops asset status')
407
407
  ->and($capability->description())->toContain('vehicle, device, sensor')
408
408
  ->and($capability->permissions())->toBe([
409
+ 'fleet-ops see driver',
409
410
  'fleet-ops see vehicle',
410
411
  'fleet-ops see device',
411
412
  'fleet-ops see sensor',
@@ -17,6 +17,9 @@ use Fleetbase\FleetOps\Support\Analytics\RevenueTrend;
17
17
  use Fleetbase\FleetOps\Support\Analytics\TopDrivers;
18
18
  use Fleetbase\LaravelMysqlSpatial\Types\Point;
19
19
  use Fleetbase\Models\Company;
20
+ use Illuminate\Database\ConnectionResolver;
21
+ use Illuminate\Database\Eloquent\Model as EloquentModel;
22
+ use Illuminate\Database\SQLiteConnection;
20
23
  use Illuminate\Http\Request;
21
24
  use Illuminate\Support\Carbon;
22
25
 
@@ -273,6 +276,16 @@ test('analytics base applies company currency and explicit date ranges', functio
273
276
  });
274
277
 
275
278
  test('live fleet analytics serializes driver and vehicle map payloads', function () {
279
+ // The markers' cards look up the current order and last known position.
280
+ $connection = new SQLiteConnection(new PDO('sqlite::memory:'));
281
+ $connection->statement('create table orders (uuid varchar(64) primary key, public_id varchar(64) null, company_uuid varchar(64) null, driver_assigned_uuid varchar(64) null, vehicle_assigned_uuid varchar(64) null, tracking_number_uuid varchar(64) null, status varchar(64) null, deleted_at datetime null, created_at datetime null, updated_at datetime null)');
282
+ $connection->statement('create table drivers (uuid varchar(64) primary key, public_id varchar(64) null, company_uuid varchar(64) null, user_uuid varchar(64) null, vehicle_uuid varchar(64) null, current_job_uuid varchar(64) null, deleted_at datetime null, created_at datetime null, updated_at datetime null)');
283
+ $connection->statement('create table users (uuid varchar(64) primary key, company_uuid varchar(64) null, name varchar(255) null, deleted_at datetime null, created_at datetime null, updated_at datetime null)');
284
+ $connection->statement('create table positions (uuid varchar(64) primary key, company_uuid varchar(64) null, subject_uuid varchar(64) null, subject_type varchar(255) null, order_uuid varchar(64) null, speed numeric null, heading numeric null, deleted_at datetime null, created_at datetime null, updated_at datetime null)');
285
+ $resolver = new ConnectionResolver(['default' => $connection, 'mysql' => $connection]);
286
+ $resolver->setDefaultConnection('mysql');
287
+ EloquentModel::setConnectionResolver($resolver);
288
+
276
289
  $analytics = new LiveFleet();
277
290
 
278
291
  $driver = new TestFleetOpsLiveFleetDriver();
@@ -308,7 +321,21 @@ test('live fleet analytics serializes driver and vehicle map payloads', function
308
321
  $driverPayload->setAccessible(true);
309
322
  $vehiclePayload->setAccessible(true);
310
323
 
311
- expect($driverPayload->invoke($analytics, $driver))->toBe([
324
+ $driverResult = $driverPayload->invoke($analytics, $driver);
325
+ $vehicleResult = $vehiclePayload->invoke($analytics, $vehicle);
326
+
327
+ // Each marker carries the live map's card: the same index resource the live endpoints return.
328
+ expect($driverResult['card'])->toBeArray()
329
+ ->and($driverResult['card'])->toHaveKeys(['name', 'status', 'phone', 'email', 'online', 'meta'])
330
+ ->and($driverResult['card']['meta'])->toHaveKeys(['status_label', 'speed_label', 'heading_label', 'location_coordinates', 'current_order_reference'])
331
+ ->and($driverResult['card']['name'])->toBe('Ada Driver')
332
+ ->and($vehicleResult['card'])->toHaveKeys(['display_name', 'driver_name', 'plate_number', 'online', 'meta'])
333
+ ->and($vehicleResult['card']['display_name'])->toBe('Van 7')
334
+ ->and($vehicleResult['card']['meta'])->toHaveKeys(['status_label', 'speed_label', 'heading_label', 'location_coordinates', 'current_order_reference']);
335
+
336
+ unset($driverResult['card'], $vehicleResult['card']);
337
+
338
+ expect($driverResult)->toBe([
312
339
  'uuid' => 'driver-uuid',
313
340
  'public_id' => 'driver-public',
314
341
  'name' => 'Ada Driver',
@@ -320,7 +347,7 @@ test('live fleet analytics serializes driver and vehicle map payloads', function
320
347
  'lat' => 1.30,
321
348
  'lng' => 103.80,
322
349
  'updated_at' => '2026-01-01 10:00:00',
323
- ])->and($vehiclePayload->invoke($analytics, $vehicle))->toBe([
350
+ ])->and($vehicleResult)->toBe([
324
351
  'uuid' => 'vehicle-uuid',
325
352
  'public_id' => 'vehicle-public',
326
353
  'name' => 'Van 7',
@@ -800,6 +800,27 @@ test('api customer controller authenticates and verifies customer codes', functi
800
800
  ]))))->toBe(['error' => 'Invalid verification code.']);
801
801
  });
802
802
 
803
+ test('api customer controller refuses deactivated customer logins', function () {
804
+ $controller = fleetopsApiCustomerController();
805
+ foreach (['activeUser', 'loginUser', 'verificationUser'] as $property) {
806
+ $controller->{$property}->setAttribute('status', 'inactive');
807
+ }
808
+
809
+ $responses = [
810
+ $controller->login(Request::create('/v1/customers/login', 'POST', ['identity' => 'jane@example.test', 'password' => 'password-secret'])),
811
+ $controller->loginWithPhone(Request::create('/v1/customers/login/phone', 'POST', ['phone' => '15551234567'])),
812
+ $controller->verifyCode(Request::create('/v1/customers/verify', 'POST', ['identity' => 'jane@example.test', 'code' => '123456'])),
813
+ ];
814
+
815
+ foreach ($responses as $response) {
816
+ expect($response->getStatusCode())->toBe(403)
817
+ ->and(fleetopsApiCustomerJson($response))->toBe(['error' => 'This customer login has been deactivated.']);
818
+ }
819
+
820
+ // No verification code is sent
821
+ expect($controller->smsVerifications)->toBe([]);
822
+ });
823
+
803
824
  test('api customer controller resets forgotten passwords', function () {
804
825
  $controller = fleetopsApiCustomerController();
805
826
  $forgot = $controller->forgotPassword(Request::create('/v1/customers/forgot-password', 'POST', [