@fleetbase/fleetops-engine 0.6.69 → 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 (209) hide show
  1. package/RELEASE.md +41 -8
  2. package/addon/components/admin/telematics-settings.hbs +143 -0
  3. package/addon/components/admin/telematics-settings.js +145 -0
  4. package/addon/components/fuel-integration/hub.hbs +1 -1
  5. package/addon/components/layout/fleet-ops-sidebar.js +37 -17
  6. package/addon/components/map/leaflet-live-map.hbs +4 -242
  7. package/addon/components/map/leaflet-live-map.js +2 -1
  8. package/addon/components/map/marker-card/driver.hbs +57 -0
  9. package/addon/components/map/marker-card/driver.js +3 -0
  10. package/addon/components/map/marker-card/vehicle.hbs +78 -0
  11. package/addon/components/map/marker-card/vehicle.js +3 -0
  12. package/addon/components/telematic/hub.hbs +1 -1
  13. package/addon/components/widget/live-fleet.hbs +8 -40
  14. package/addon/controllers/analytics/reports/index.js +5 -0
  15. package/addon/controllers/connectivity/devices/index.js +4 -0
  16. package/addon/controllers/connectivity/fuel-providers/index.js +6 -4
  17. package/addon/controllers/connectivity/sensors/index.js +4 -0
  18. package/addon/controllers/connectivity/telematics/index.js +4 -0
  19. package/addon/controllers/maintenance/equipment/index.js +11 -4
  20. package/addon/controllers/maintenance/inspection-forms/index.js +2 -2
  21. package/addon/controllers/maintenance/inspection-submissions/index.js +8 -2
  22. package/addon/controllers/maintenance/maintenances/index.js +4 -0
  23. package/addon/controllers/maintenance/parts/index.js +11 -4
  24. package/addon/controllers/maintenance/schedules/index.js +17 -4
  25. package/addon/controllers/maintenance/work-orders/index.js +11 -4
  26. package/addon/controllers/management/contacts/index.js +4 -0
  27. package/addon/controllers/management/drivers/index.js +4 -0
  28. package/addon/controllers/management/fleets/index.js +4 -0
  29. package/addon/controllers/management/fuel-reports/index.js +4 -0
  30. package/addon/controllers/management/fuel-transactions/index.js +9 -7
  31. package/addon/controllers/management/issues/index.js +4 -0
  32. package/addon/controllers/management/places/index.js +4 -0
  33. package/addon/controllers/management/vehicles/index.js +4 -0
  34. package/addon/controllers/management/vendors/index.js +4 -0
  35. package/addon/controllers/operations/orders/index/details.js +30 -1
  36. package/addon/controllers/operations/orders/index.js +7 -0
  37. package/addon/controllers/operations/service-rates/index.js +3 -0
  38. package/addon/controllers/settings/index.js +13 -0
  39. package/addon/controllers/settings/telematics.js +113 -0
  40. package/addon/extension.js +38 -6
  41. package/addon/routes/analytics/index.js +14 -1
  42. package/addon/routes/analytics/reports/index/details.js +1 -1
  43. package/addon/routes/analytics/reports/index/edit.js +1 -1
  44. package/addon/routes/analytics/reports/index/new.js +14 -1
  45. package/addon/routes/analytics/reports.js +14 -1
  46. package/addon/routes/connectivity/devices/index/new.js +14 -1
  47. package/addon/routes/connectivity/devices.js +14 -1
  48. package/addon/routes/connectivity/events.js +14 -1
  49. package/addon/routes/connectivity/fuel-providers/details/transactions.js +12 -0
  50. package/addon/routes/connectivity/fuel-providers/details.js +9 -0
  51. package/addon/routes/connectivity/fuel-providers/edit.js +9 -0
  52. package/addon/routes/connectivity/fuel-providers/new.js +14 -1
  53. package/addon/routes/connectivity/fuel-providers.js +14 -1
  54. package/addon/routes/connectivity/sensors/index/new.js +14 -1
  55. package/addon/routes/connectivity/sensors.js +14 -1
  56. package/addon/routes/connectivity/telematics/new.js +14 -1
  57. package/addon/routes/connectivity/telematics.js +14 -1
  58. package/addon/routes/connectivity/tracking.js +14 -1
  59. package/addon/routes/maintenance/equipment/index/details.js +2 -2
  60. package/addon/routes/maintenance/equipment/index/edit.js +2 -2
  61. package/addon/routes/maintenance/equipment/index/new.js +14 -1
  62. package/addon/routes/maintenance/equipment.js +14 -1
  63. package/addon/routes/maintenance/index.js +14 -1
  64. package/addon/routes/maintenance/inspection-forms/index/details.js +10 -1
  65. package/addon/routes/maintenance/inspection-forms/index/edit.js +10 -1
  66. package/addon/routes/maintenance/inspection-forms/index/new.js +14 -1
  67. package/addon/routes/maintenance/inspection-forms.js +14 -1
  68. package/addon/routes/maintenance/inspection-submissions/index/details.js +10 -1
  69. package/addon/routes/maintenance/inspection-submissions/index/edit.js +10 -1
  70. package/addon/routes/maintenance/inspection-submissions/index/new.js +14 -1
  71. package/addon/routes/maintenance/inspection-submissions.js +14 -1
  72. package/addon/routes/maintenance/maintenances/index/details.js +2 -2
  73. package/addon/routes/maintenance/maintenances/index/edit.js +2 -2
  74. package/addon/routes/maintenance/maintenances/index/new.js +14 -1
  75. package/addon/routes/maintenance/maintenances.js +14 -1
  76. package/addon/routes/maintenance/parts/index/details.js +2 -2
  77. package/addon/routes/maintenance/parts/index/edit.js +2 -2
  78. package/addon/routes/maintenance/parts/index/new.js +14 -1
  79. package/addon/routes/maintenance/parts.js +14 -1
  80. package/addon/routes/maintenance/schedules/index/details.js +2 -2
  81. package/addon/routes/maintenance/schedules/index/edit.js +2 -2
  82. package/addon/routes/maintenance/schedules/index/new.js +15 -1
  83. package/addon/routes/maintenance/schedules.js +15 -1
  84. package/addon/routes/maintenance/work-orders/index/details.js +2 -2
  85. package/addon/routes/maintenance/work-orders/index/edit.js +2 -2
  86. package/addon/routes/maintenance/work-orders/index/new.js +14 -1
  87. package/addon/routes/maintenance/work-orders.js +14 -1
  88. package/addon/routes/management/fuel-transactions/index/details.js +9 -0
  89. package/addon/routes/management/fuel-transactions.js +14 -1
  90. package/addon/routes/management/index.js +13 -0
  91. package/addon/routes/management/trailers.js +16 -0
  92. package/addon/routes/management/vendors/integrated/details.js +14 -1
  93. package/addon/routes/management/vendors/integrated/edit.js +14 -1
  94. package/addon/routes/management/vendors/integrated/new.js +14 -1
  95. package/addon/routes/management/vendors/integrated.js +14 -1
  96. package/addon/routes/operations/orders.js +40 -1
  97. package/addon/routes/operations/routes/index/details.js +14 -1
  98. package/addon/routes/operations/routes/index/new.js +13 -0
  99. package/addon/routes/operations/routes.js +14 -1
  100. package/addon/routes/operations/service-rates/index/new.js +11 -0
  101. package/addon/routes/operations/service-rates.js +14 -1
  102. package/addon/routes/settings/avatars.js +14 -1
  103. package/addon/routes/settings/custom-fields.js +14 -1
  104. package/addon/routes/settings/map.js +14 -1
  105. package/addon/routes/settings/notifications.js +11 -0
  106. package/addon/routes/settings/orchestrator.js +1 -1
  107. package/addon/routes/settings/payments/index.js +1 -1
  108. package/addon/routes/settings/routing.js +14 -1
  109. package/addon/routes/settings/scheduling.js +14 -1
  110. package/addon/routes/settings/telematics.js +21 -0
  111. package/addon/routes/virtual.js +11 -0
  112. package/addon/routes.js +1 -0
  113. package/addon/templates/settings/telematics.hbs +70 -0
  114. package/app/components/admin/telematics-settings.js +1 -0
  115. package/app/components/map/marker-card/driver.js +1 -0
  116. package/app/components/map/marker-card/vehicle.js +1 -0
  117. package/app/controllers/settings/telematics.js +1 -0
  118. package/app/routes/settings/telematics.js +1 -0
  119. package/app/templates/settings/telematics.js +1 -0
  120. package/composer.json +2 -2
  121. package/docs/AFAQY.md +1 -1
  122. package/docs/TELEMATICS_QUEUES.md +1 -1
  123. package/docs/TELEMETRY_ARCHITECTURE.md +72 -1
  124. package/extension.json +1 -1
  125. package/package.json +1 -1
  126. package/server/config/telemetry.php +12 -0
  127. package/server/migrations/2026_09_23_000001_add_retention_index_to_device_events.php +40 -0
  128. package/server/migrations/2026_09_23_000002_add_retention_index_to_positions.php +40 -0
  129. package/server/migrations/2026_09_23_000003_add_retention_index_to_telematic_sync_runs.php +40 -0
  130. package/server/src/Auth/Schemas/FleetOps.php +104 -5
  131. package/server/src/Console/Commands/DrainTelematicInbox.php +3 -13
  132. package/server/src/Console/Commands/ProcessOperationalAlerts.php +3 -3
  133. package/server/src/Console/Commands/PruneTelematicsData.php +318 -0
  134. package/server/src/Http/Controllers/Api/v1/OrchestrationController.php +9 -0
  135. package/server/src/Http/Controllers/FleetOpsController.php +21 -0
  136. package/server/src/Http/Controllers/Internal/v1/AnalyticsController.php +11 -0
  137. package/server/src/Http/Controllers/Internal/v1/ContactController.php +11 -0
  138. package/server/src/Http/Controllers/Internal/v1/CustomerController.php +14 -0
  139. package/server/src/Http/Controllers/Internal/v1/DeviceController.php +13 -0
  140. package/server/src/Http/Controllers/Internal/v1/DeviceEventController.php +11 -0
  141. package/server/src/Http/Controllers/Internal/v1/DriverController.php +22 -0
  142. package/server/src/Http/Controllers/Internal/v1/FuelProviderConnectionController.php +13 -0
  143. package/server/src/Http/Controllers/Internal/v1/FuelProviderTransactionController.php +15 -0
  144. package/server/src/Http/Controllers/Internal/v1/FuelReportController.php +9 -3
  145. package/server/src/Http/Controllers/Internal/v1/InspectionFormController.php +15 -0
  146. package/server/src/Http/Controllers/Internal/v1/LiveController.php +16 -0
  147. package/server/src/Http/Controllers/Internal/v1/MaintenanceController.php +15 -0
  148. package/server/src/Http/Controllers/Internal/v1/MaintenanceScheduleController.php +15 -0
  149. package/server/src/Http/Controllers/Internal/v1/ManifestController.php +15 -0
  150. package/server/src/Http/Controllers/Internal/v1/MetricsController.php +11 -0
  151. package/server/src/Http/Controllers/Internal/v1/NavigatorController.php +42 -1
  152. package/server/src/Http/Controllers/Internal/v1/OrchestrationController.php +12 -0
  153. package/server/src/Http/Controllers/Internal/v1/OrderController.php +22 -0
  154. package/server/src/Http/Controllers/Internal/v1/PaymentController.php +13 -0
  155. package/server/src/Http/Controllers/Internal/v1/RadarController.php +19 -0
  156. package/server/src/Http/Controllers/Internal/v1/ServiceAreaController.php +9 -3
  157. package/server/src/Http/Controllers/Internal/v1/SettingController.php +465 -14
  158. package/server/src/Http/Controllers/Internal/v1/TelematicController.php +21 -0
  159. package/server/src/Http/Controllers/Internal/v1/TrailerController.php +21 -0
  160. package/server/src/Http/Controllers/Internal/v1/VehicleController.php +21 -0
  161. package/server/src/Http/Controllers/Internal/v1/VendorController.php +17 -0
  162. package/server/src/Http/Controllers/Internal/v1/WorkOrderController.php +11 -0
  163. package/server/src/Jobs/DispatchTelematicsRetentionJobs.php +70 -0
  164. package/server/src/Jobs/PruneTelematicsDataJob.php +74 -0
  165. package/server/src/Listeners/NotifyDriverOnShiftChange.php +3 -3
  166. package/server/src/Mail/CustomerCredentialsMail.php +1 -1
  167. package/server/src/Orchestration/Engines/VroomOrchestrationEngine.php +9 -1
  168. package/server/src/Providers/FleetOpsServiceProvider.php +3 -0
  169. package/server/src/Support/Analytics/LiveFleet.php +18 -2
  170. package/server/src/Support/Authorization.php +103 -0
  171. package/server/src/Support/Database/TableIndexes.php +66 -0
  172. package/server/src/Support/OrderTracker.php +2 -2
  173. package/server/src/Support/Reporting/FleetOpsReportSchema.php +476 -116
  174. package/server/src/Support/Telematics/Retention/RetentionPolicy.php +300 -0
  175. package/server/src/Support/Telematics/Retention/TelemetryActivity.php +40 -0
  176. package/server/src/Support/Telematics/TelematicService.php +12 -2
  177. package/server/src/Support/Telematics/Telemetry/Ingestor.php +4 -2
  178. package/server/src/Tracking/TrackingIntelligenceService.php +1 -1
  179. package/server/src/Tracking/TrackingOptions.php +2 -2
  180. package/server/src/Traits/AuthorizesMethods.php +37 -0
  181. package/server/src/routes.php +9 -1
  182. package/server/tests/AnalyticsRoutesTest.php +29 -2
  183. package/server/tests/Feature/Http/AfaqyRealtimeIngestionTest.php +16 -1
  184. package/server/tests/Feature/Http/Api/TelematicsRetentionIndexMigrationTest.php +115 -0
  185. package/server/tests/Feature/Http/Internal/SettingControllerHelpersTest.php +71 -0
  186. package/server/tests/Feature/Http/Internal/TelematicsStorageDeadlinesTest.php +119 -0
  187. package/server/tests/InternalImportExportControllerContractsTest.php +5 -8
  188. package/server/tests/NavigatorControllerContractsTest.php +36 -4
  189. package/server/tests/NotificationAndMailContractsTest.php +31 -0
  190. package/server/tests/ObserverContractsTest.php +1 -1
  191. package/server/tests/OrchestratorConsumableApiTest.php +42 -0
  192. package/server/tests/ProviderContractsTest.php +10 -0
  193. package/server/tests/ReportSchemaContractsTest.php +120 -6
  194. package/server/tests/SettingControllerContractsTest.php +429 -18
  195. package/server/tests/SmallControllerContractsTest.php +8 -5
  196. package/server/tests/Support/PruneTelematicsDataProbe.php +55 -0
  197. package/server/tests/Unit/Console/Commands/ProcessOperationalAlertsCommandTest.php +2 -2
  198. package/server/tests/Unit/Console/PruneTelematicsDataCommandTest.php +353 -0
  199. package/server/tests/Unit/Jobs/DispatchTelematicsRetentionJobsTest.php +114 -0
  200. package/server/tests/Unit/Jobs/PruneTelematicsDataJobTest.php +60 -0
  201. package/server/tests/Unit/Orchestration/VroomEngineTransportAndSettingsTest.php +20 -0
  202. package/server/tests/Unit/Support/Analytics/LiveFleetTest.php +6 -0
  203. package/server/tests/Unit/Support/AuthorizationTest.php +142 -0
  204. package/server/tests/Unit/Support/MaintainableAndOperationalAlertsTest.php +12 -1
  205. package/server/tests/Unit/Support/MoreQuerySeamsTest.php +11 -2
  206. package/server/tests/Unit/Support/Telematics/Retention/RetentionPolicyTest.php +190 -0
  207. package/server/tests/Unit/Support/Telematics/Retention/TelemetryActivityTest.php +68 -0
  208. package/server/tests/Unit/Tracking/TrackingOptionsCompanySettingsTest.php +109 -0
  209. package/translations/en-us.yaml +48 -0
@@ -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
 
@@ -814,6 +816,12 @@ Route::prefix(config('fleetops.api.routing.prefix'))->namespace('Fleetbase\Fleet
814
816
  $router->post('orchestrator-settings', 'SettingController@saveOrchestratorSettings');
815
817
  $router->get('orchestrator-card-fields', 'SettingController@getOrchestratorCardFields');
816
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');
817
825
  }
818
826
  );
819
827
  $router->group(
@@ -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',
@@ -3,6 +3,7 @@
3
3
  require_once __DIR__ . '/../../Support/AfaqyTestCrypto.php';
4
4
  require_once __DIR__ . '/../../Support/ExampleTelemetryProvider.php';
5
5
  require_once __DIR__ . '/../../Support/TelemetryTestEnvironment.php';
6
+ require_once __DIR__ . '/../../Support/PruneTelematicsDataProbe.php';
6
7
 
7
8
  use Fleetbase\FleetOps\Http\Controllers\TelematicPositionWebhookController;
8
9
  use Fleetbase\FleetOps\Jobs\ProcessTelematicDelivery;
@@ -123,6 +124,12 @@ test('database ingestion deduplicates poll and push and never regresses device s
123
124
  expect(DeviceEvent::withoutGlobalScopes()->count())->toBe(2);
124
125
  expect(count($GLOBALS['afaqy_broadcasts']))->toBe(1);
125
126
  expect($device->last_position->getLat())->toBe(24.0);
127
+ // The raw unit is stored once, in payload; meta carries only the normalized block.
128
+ $stored = DeviceEvent::withoutGlobalScopes()->orderBy('id')->first();
129
+ expect(data_get($stored->payload, '_id'))->toBe('unit-1')
130
+ ->and($stored->meta)->not->toHaveKeys(['_id', 'last_update'])
131
+ ->and(data_get($stored->meta, 'telemetry.position_at'))->toBe('2026-09-15T11:59:00.000000Z')
132
+ ->and(data_get($stored->meta, 'telematic_uuid'))->toBe('integration-1');
126
133
  });
127
134
 
128
135
  test('webhook authenticates before durable acceptance and worker quarantines unknown shapes', function () {
@@ -282,7 +289,7 @@ test('five thousand units and a duplicate reconciliation sweep fit the local pro
282
289
  expect($elapsed)->toBeLessThan(60.0);
283
290
  })->skip(getenv('AFAQY_RUN_LOAD_TESTS') !== '1', 'Opt-in local processing benchmark; production latency requires deployment testing.');
284
291
 
285
- test('inbox maintenance retains replayable payloads and cleans expired rows during a broker outage', function () {
292
+ test('inbox recovery leaves retention to the prune command, which expires processed deliveries by policy', function () {
286
293
  $telematic = afaqyDbFixture();
287
294
  $inbox = new Inbox();
288
295
  $pending = $inbox->accept($telematic, afaqyDbUnit(), 'poll');
@@ -297,9 +304,17 @@ test('inbox maintenance retains replayable payloads and cleans expired rows duri
297
304
  'metadata' => ['telemetry' => ['durable_ingestion' => true]],
298
305
  ]));
299
306
  expect((new Fleetbase\FleetOps\Console\Commands\DrainTelematicInbox())->handle())->toBe(0);
307
+ // The drain only recovers; nothing is expired until the retention sweep runs.
308
+ expect(DB::table('telematic_deliveries')->count())->toBe(3);
309
+
310
+ $prune = new PruneTelematicsDataProbe();
311
+ $prune->options['company'] = 'company-1';
312
+ $prune->companyRows = collect([(object) ['id' => 1, 'uuid' => 'company-1', 'public_id' => 'company_1']]);
313
+ expect($prune->handle())->toBe(0);
300
314
  expect(DB::table('telematic_deliveries')->where('uuid', $processed)->exists())->toBeFalse();
301
315
  expect(DB::table('telematic_deliveries')->where('uuid', $quarantined)->exists())->toBeTrue();
302
316
  expect(DB::table('telematic_deliveries')->where('uuid', $pending)->value('status'))->toBe('pending');
317
+ expect($prune->messages)->toContain(['info', 'company_1 telematic_deliveries: deleted=1 compacted=0 batches=1']);
303
318
  });
304
319
 
305
320
  test('sensor values use source time and preserve units through partial and older snapshots', function () {