diff --git a/.github/workflows/postman.yml b/.github/workflows/postman.yml
index 24e38fe88..d58143299 100644
--- a/.github/workflows/postman.yml
+++ b/.github/workflows/postman.yml
@@ -5,14 +5,9 @@ name: API Contract (Postman)
# fleetbase/fleetbase. Requires org secrets POSTMAN_API_KEY + _GITHUB_AUTH_TOKEN
# (inherited); no-ops until POSTMAN_API_KEY is set.
#
-# Deliberately unpinned. The reusable workflow defaults to booting fleetbase/fleetbase@main
-# against fleetbase/fleetbase-api:latest, so every release is picked up automatically and
-# there is no ref here to remember to bump. Each run records the image digest it actually
-# resolved in its job summary, so a result stays traceable. To reproduce an older run:
-#
-# with:
-# fleetbase-ref: v0.7.53
-# api-image: fleetbase/fleetbase-api:v0.7.53
+# Pin the workflow and host checkout together so branch installation resolves the
+# required published Core release before migrating and exercising this module.
+# The workflow records the API image digest and resolved dependencies for traceability.
on:
push:
@@ -25,8 +20,9 @@ permissions:
jobs:
contract:
- uses: fleetbase/fleetbase/.github/workflows/api-contract.yml@main
+ uses: fleetbase/fleetbase/.github/workflows/api-contract.yml@880c7392c67c93e3a65f01916d7a04036c098935
with:
+ fleetbase-ref: 880c7392c67c93e3a65f01916d7a04036c098935
collections: "Fleetbase API"
build-from-source: false
# Without this the run tests the version of fleetbase/fleetops-api baked into the
diff --git a/.github/workflows/server.yml b/.github/workflows/server.yml
index 84eb44839..e9699443b 100644
--- a/.github/workflows/server.yml
+++ b/.github/workflows/server.yml
@@ -14,7 +14,7 @@ jobs:
steps:
- name: Checkout code
- uses: actions/checkout@v2
+ uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
@@ -53,6 +53,7 @@ jobs:
run: php scripts/coverage-summary.php coverage/clover.xml --fail-under=100
- name: Upload Coverage Baseline
+ if: ${{ always() && hashFiles('coverage/clover.xml') != '' }}
uses: actions/upload-artifact@v4
with:
name: fleetops-coverage-clover
diff --git a/RELEASE.md b/RELEASE.md
index 32ebd138d..0c8854947 100644
--- a/RELEASE.md
+++ b/RELEASE.md
@@ -1,23 +1,56 @@
-> v0.6.69 ~ "Fleet-Ops tools for Fleetbase AI"
+> v0.6.70 ~ "Telematics data retention"
---
## What's New
-- **Fleet-Ops works with Fleetbase AI tool calling.** Creating orders from the AI prompt works again with the tool-calling assistant, and the assistant can also propose an optimized waypoint sequence for an order and explain what an order or resource import needs. Every change is a preview card the user confirms.
-- **Fleet-Ops console actions.** The assistant can offer to open Fleet-Ops pages and dialogs, such as **Operations › Orders** and **New Order**, as confirmation cards.
-- **Resource search for the assistant.** A `fleetops_search` tool finds orders, vehicles, drivers, work orders, maintenances, devices, sensors and telematics records by id, name, plate, VIN, email or phone.
+- **Telematics Data settings.** A new Fleet-Ops → Settings → Telematics Data page lets each organization decide how long telemetry is kept: device events, raw event payloads, positions, processed and quarantined delivery envelopes, and sync run diagnostics. Administrators set system-wide defaults from the admin console (Fleet-Ops Config → Telematics Data). A value of 0 keeps rows forever.
+- **Scheduled retention sweep.** `fleetops:prune-telematics-data` runs every fifteen minutes and applies each organization's policy in bounded batches, so a large backlog is drained gradually without stalling ingestion. Defaults: events 30 days, raw payloads stripped after 7 days, positions 90 days, processed deliveries 24 hours, quarantined deliveries 7 days, sync runs 7 days.
+- **Storage usage and on-demand cleanup.** The settings page shows rows, oldest row and estimated size per table for your organization, and "Run cleanup now" queues an immediate retention run.
---
## Fixes
-- AI resource search no longer fails on every call with `Unknown column 'sensor_type'`, and database errors are no longer passed to the model.
+- Device events no longer store the raw provider unit twice. `payload` keeps the raw unit; `meta` holds only the normalized block (speed, heading, odometer, ignition, fuel level, timing). New event rows are roughly half the size.
+- Telemetry-driven saves (device, event, position, vehicle, sensors) no longer write to the activity log on every poll. Organizations that want them can enable "Log telemetry activity" in Telematics Data settings. Manual edits are logged as before.
+- Delivery inbox and sync run retention moved from the drain command into the retention sweep; `fleetops:drain-telematic-inbox` now only recovers deliveries and finishes interrupted runs.
+
+---
+## Upgrade notes
+- New indexes on `device_events (company_uuid, created_at)`, `positions (company_uuid, created_at)` and `telematic_sync_runs (telematic_uuid, updated_at)`; on very large tables run `php artisan migrate` in a maintenance window.
+- Retention is enabled by default. Existing rows older than the defaults are removed over successive runs after upgrading; set the values to 0 before upgrading if you need to keep history indefinitely.
+- Deleting rows frees space inside the database files, not on disk. Run `OPTIMIZE TABLE` off-peak to return space to the operating system.
+- Run `php artisan fleetbase:create-permissions` to register the `telematics-settings` permission and the Telematics Settings Manager policy and role.
+
+---
+## Testing
+- Backend tests cover the retention policy layering and clamping, the prune command (per-company policies, compaction, inbox tables via connections, orphans, batch caps, dry runs, filters, locking), the settings and storage usage endpoints, the index migrations, the on-demand job, and the meta and activity-log changes to ingestion.
+- Ember tests cover the settings route guard, the settings controller, and the admin defaults component.
+> v0.6.70 ~ "Order reporting and a cleaner default dashboard"
+
+---
+## What's New
+- **Report on orders, their items and tracking.** The report builder can answer questions like "Which products sold the most this month?" and "What did orders total this month?".
+ - Orders expose their ID and Internal ID, and relationships for tracking (tracking number, region, latest status), Order Config, Customer Vendor, Created By, Purchase Rate › Service Quote and Payload Return.
+ - `Payload` exposes its items with name, SKU, price, dimensions, metadata and destination. Select an item column for one row per item, or group by one to summarise per product.
+ - Summary columns count orders distinctly, so they stay correct when item rows are selected. Distance, duration and transaction amount have totals and averages.
+ - `meta` has no fixed shape, so no column assumes a key in it. Read keys with a computed column, for example `CAST(JSON_UNQUOTE(JSON_EXTRACT(payload.entities.meta, '$.quantity')) AS DECIMAL(15,2))`.
+ - Column labels drop the table name ("Type", not "Order Type"). Distance and duration are labelled in meters and seconds, and money columns are marked "(minor units)".
+- **A cleaner default dashboard.** Fleet-Ops widgets declare their place on the default dashboard: Radar first, then Active Orders and Drivers Online, a full-width Live Fleet map, and Revenue Trend, Top Drivers and Maintenance side by side.
+- **Live Fleet widget cards match the live map.** Clicking a driver or vehicle on the widget map shows the same card as **Operations › Live Map**.
+
+---
+## Fixes
+- Custom field values on fuel reports and service areas are saved. They were dropped because the save hooks read the snake_case payload root, while the console sends `fuelReport` and `serviceArea`.
+- The report schema no longer references 13 columns and join keys that don't exist: driver name, email and phone and the driver's vehicle, the vehicle's driver, and the fuel report cost, odometer and date. Transaction summaries sum `transaction.amount`.
+- Every report table, and the item join, leaves soft-deleted rows out on core-api v1.6.64 and later.
---
## Dependencies
-- `@fleetbase/fleetops-data` upgraded to `^0.2.2`, which adds the read-only login state (`is_staff_linked`, `login_status`) to the driver and contact models.
-- `@fleetbase/ember-ui` upgraded to `^0.4.3`, which provides the `btn-auth` style the Track Order button on the login page uses.
+- Order items, JSON totals and soft deletes in reports need `fleetbase/core-api` v1.6.64. The schema still registers on older versions.
+- The report builder and the default dashboard order ship in `@fleetbase/ember-ui` v0.4.4.
---
## Testing
-- Unit tests cover the new AI tools and console commands, and the AI capability registration.
+- Every declared report column and join key was checked against the database schema, and order reports were run against real storefront orders on MySQL in strict mode.
+- Tests cover the report schema at 100% line coverage, the Live Fleet map cards and the default dashboard order.
---
## Need help?
diff --git a/addon/components/admin/telematics-settings.hbs b/addon/components/admin/telematics-settings.hbs
new file mode 100644
index 000000000..e415ce894
--- /dev/null
+++ b/addon/components/admin/telematics-settings.hbs
@@ -0,0 +1,143 @@
+
diff --git a/addon/components/admin/telematics-settings.js b/addon/components/admin/telematics-settings.js
new file mode 100644
index 000000000..f2d19d9ba
--- /dev/null
+++ b/addon/components/admin/telematics-settings.js
@@ -0,0 +1,145 @@
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { inject as service } from '@ember/service';
+import { action } from '@ember/object';
+import { task, timeout } from 'ember-concurrency';
+
+/**
+ * System-wide telematics policy, storage diagnostics and operational controls.
+ */
+export default class AdminTelematicsSettingsComponent extends Component {
+ @service fetch;
+ @service notifications;
+ @service intl;
+ @service modalsManager;
+ @tracked eventRetentionDays = 30;
+ @tracked maxEventRetentionDays = 0;
+ @tracked eventCompactAfterDays = 7;
+ @tracked positionRetentionDays = 90;
+ @tracked maxPositionRetentionDays = 0;
+ @tracked processedRetentionHours = 24;
+ @tracked quarantineRetentionDays = 7;
+ @tracked syncRunRetentionDays = 7;
+ @tracked logTelemetryActivity = false;
+ @tracked settingsLoaded = false;
+ @tracked usage = null;
+
+ constructor() {
+ super(...arguments);
+ this.loadSettings.perform();
+ this.loadUsage.perform();
+ }
+
+ @task({ drop: true }) *loadSettings() {
+ try {
+ const settings = yield this.fetch.get('fleet-ops/settings/admin-telematics-settings');
+ this.applySettings(settings);
+ this.settingsLoaded = true;
+ } catch (error) {
+ this.notifications.serverError(error);
+ }
+ }
+
+ @task({ drop: true }) *saveSettings() {
+ if (!this.settingsLoaded) {
+ return;
+ }
+
+ try {
+ const settings = yield this.fetch.post('fleet-ops/settings/admin-telematics-settings', this.settingsPayload);
+ this.applySettings(settings);
+ this.notifications.success('System telematics settings saved.');
+ } catch (error) {
+ this.notifications.serverError(error);
+ }
+ }
+
+ /** Load system-wide storage usage across all organizations. */
+ @task({ drop: true }) *loadUsage() {
+ const abortController = new AbortController();
+ const requestTimeout = setTimeout(() => abortController.abort(), 30000);
+
+ try {
+ // Pass the signal as request data; fetch.get options do not forward it.
+ const usage = yield this.fetch.request('fleet-ops/settings/telematics-storage-usage?include_payload_counts=0', 'GET', { signal: abortController.signal });
+ if (usage?.scope !== 'system') {
+ throw new Error(this.intl.t('settings.telematics.usage-unavailable'));
+ }
+ this.usage = usage;
+ } catch (error) {
+ this.notifications.serverError(abortController.signal.aborted ? new Error(this.intl.t('settings.telematics.usage-unavailable')) : error);
+ } finally {
+ clearTimeout(requestTimeout);
+ abortController.abort();
+ }
+ }
+
+ /** Queue cleanup for all organizations using the saved policy. */
+ @task({ drop: true }) *runCleanup() {
+ try {
+ const cleanup = yield this.fetch.post('fleet-ops/settings/telematics-retention/run');
+ if (cleanup?.scope !== 'system') {
+ throw new Error(this.intl.t('settings.telematics.cleanup-unavailable'));
+ }
+ this.notifications.success(this.intl.t('settings.telematics.cleanup-queued'));
+ yield timeout(5000);
+ yield this.loadUsage.perform();
+ } catch (error) {
+ this.notifications.serverError(error);
+ }
+ }
+
+ @action confirmRunCleanup() {
+ return this.modalsManager.confirm({
+ title: this.intl.t('settings.telematics.run-cleanup'),
+ body: this.intl.t('settings.telematics.run-cleanup-confirm'),
+ acceptButtonText: this.intl.t('settings.telematics.run-cleanup'),
+ acceptButtonIcon: 'broom',
+ onConfirm: () => this.runCleanup.perform(),
+ });
+ }
+
+ get usageRows() {
+ const tables = this.usage?.tables ?? {};
+
+ return ['device_events', 'positions', 'telematic_deliveries', 'telematic_sync_runs']
+ .filter((table) => tables[table])
+ .map((table) => ({ table, label: this.intl.t(`settings.telematics.tables.${table}`), ...tables[table] }));
+ }
+
+ get settingsPayload() {
+ return {
+ event_retention_days: this.toInteger(this.eventRetentionDays),
+ max_event_retention_days: this.maxEventRetentionDays,
+ event_compact_after_days: this.toInteger(this.eventCompactAfterDays),
+ position_retention_days: this.toInteger(this.positionRetentionDays),
+ max_position_retention_days: this.maxPositionRetentionDays,
+ processed_retention_hours: this.toInteger(this.processedRetentionHours),
+ quarantine_retention_days: this.toInteger(this.quarantineRetentionDays),
+ sync_run_retention_days: this.toInteger(this.syncRunRetentionDays),
+ log_telemetry_activity: Boolean(this.logTelemetryActivity),
+ };
+ }
+
+ applySettings(settings = {}) {
+ if (!Object.prototype.hasOwnProperty.call(settings ?? {}, 'max_event_retention_days') || !Object.prototype.hasOwnProperty.call(settings ?? {}, 'max_position_retention_days')) {
+ throw new Error(this.intl.t('settings.telematics.settings-unavailable'));
+ }
+
+ this.eventRetentionDays = settings.event_retention_days ?? this.eventRetentionDays;
+ this.maxEventRetentionDays = settings.max_event_retention_days ?? this.maxEventRetentionDays;
+ this.eventCompactAfterDays = settings.event_compact_after_days ?? this.eventCompactAfterDays;
+ this.positionRetentionDays = settings.position_retention_days ?? this.positionRetentionDays;
+ this.maxPositionRetentionDays = settings.max_position_retention_days ?? this.maxPositionRetentionDays;
+ this.processedRetentionHours = settings.processed_retention_hours ?? this.processedRetentionHours;
+ this.quarantineRetentionDays = settings.quarantine_retention_days ?? this.quarantineRetentionDays;
+ this.syncRunRetentionDays = settings.sync_run_retention_days ?? this.syncRunRetentionDays;
+ this.logTelemetryActivity = settings.log_telemetry_activity ?? this.logTelemetryActivity;
+ }
+
+ toInteger(value) {
+ const number = parseInt(value, 10);
+
+ return Number.isFinite(number) && number > 0 ? number : 0;
+ }
+}
diff --git a/addon/components/fuel-integration/hub.hbs b/addon/components/fuel-integration/hub.hbs
index 183657974..65ad63610 100644
--- a/addon/components/fuel-integration/hub.hbs
+++ b/addon/components/fuel-integration/hub.hbs
@@ -17,7 +17,7 @@
-
+
diff --git a/addon/components/layout/fleet-ops-sidebar.js b/addon/components/layout/fleet-ops-sidebar.js
index 0abf9056f..8a2e67bad 100644
--- a/addon/components/layout/fleet-ops-sidebar.js
+++ b/addon/components/layout/fleet-ops-sidebar.js
@@ -151,14 +151,14 @@ export default class LayoutFleetOpsSidebarComponent extends Component {
this.createItem('menu.contacts', 'address-book', 'management.contacts', 'fleet-ops list contact', 'fleet-ops see contact'),
this.createItem('menu.places', 'location-dot', 'management.places', 'fleet-ops list place', 'fleet-ops see place'),
this.createItem('menu.fuel-reports', 'gas-pump', 'management.fuel-reports', 'fleet-ops list fuel-report', 'fleet-ops see fuel-report'),
- this.createItem('menu.fuel-transactions', 'credit-card', 'management.fuel-transactions', 'fleet-ops list fuel-report', 'fleet-ops see fuel-report'),
+ this.createItem('menu.fuel-transactions', 'credit-card', 'management.fuel-transactions', 'fleet-ops list fuel-provider-transaction', 'fleet-ops see fuel-provider-transaction'),
this.createItem('menu.issues', 'triangle-exclamation', 'management.issues', 'fleet-ops list issue', 'fleet-ops see issue'),
]);
}
get maintenanceItems() {
return this.withRegistryItems('maintenance', [
- this.createHubItem('Maintenance Hub', 'wrench', 'maintenance.index', 'fleet-ops list maintenance-schedule', 'fleet-ops see maintenance-schedule', [
+ this.createHubItem('Maintenance Hub', 'wrench', 'maintenance.index', 'fleet-ops list work-order', 'fleet-ops see work-order', [
'maintenance hub',
'service readiness',
'maintenance control panel',
@@ -182,7 +182,9 @@ export default class LayoutFleetOpsSidebarComponent extends Component {
get connectivityItems() {
return this.withRegistryItems('connectivity', [
this.createHubItem(this.intl.t('menu.telematics'), 'satellite-dish', 'connectivity.telematics', 'fleet-ops list telematic', 'fleet-ops see telematic', ['connectivity hub']),
- this.createItem('menu.fuel-providers', 'gas-pump', 'connectivity.fuel-providers', 'fleet-ops list fuel-report', 'fleet-ops see fuel-report', ['fuel integrations']),
+ this.createItem('menu.fuel-providers', 'gas-pump', 'connectivity.fuel-providers', 'fleet-ops list fuel-provider-connection', 'fleet-ops see fuel-provider-connection', [
+ 'fuel integrations',
+ ]),
this.createItem('menu.devices', 'hard-drive', 'connectivity.devices', 'fleet-ops list device', 'fleet-ops see device'),
this.createItem('menu.sensors', 'temperature-full', 'connectivity.sensors', 'fleet-ops list sensor', 'fleet-ops see sensor'),
this.createItem('menu.events', 'stream', 'connectivity.events', 'fleet-ops list device-event', 'fleet-ops see device-event'),
@@ -191,18 +193,14 @@ export default class LayoutFleetOpsSidebarComponent extends Component {
get analyticsItems() {
return this.withRegistryItems('analytics', [
- this.createHubItem('Dashboard', 'chart-line', 'analytics.index', 'iam list report', 'fleet-ops see report', ['dashboard', 'fleetops dashboard', 'metrics']),
- this.createItem('menu.reports', 'file-import', 'analytics.reports', 'iam list report', 'fleet-ops see report'),
+ this.createHubItem('Dashboard', 'chart-line', 'analytics.index', 'fleet-ops view analytics', 'fleet-ops see analytics', ['dashboard', 'fleetops dashboard', 'metrics']),
+ this.createItem('menu.reports', 'file-import', 'analytics.reports', 'iam list report', 'iam see report'),
]);
}
get settingsItems() {
return this.withRegistryItems('settings', [
- this.createHubItem('Settings Hub', 'sliders', 'settings.index', 'fleet-ops view navigator-settings', 'fleet-ops see navigator-settings', [
- 'settings hub',
- 'configuration dashboard',
- 'setup focus',
- ]),
+ this.createHubItem('Settings Hub', 'sliders', 'settings.index', null, null, ['settings hub', 'configuration dashboard', 'setup focus']),
this.createItem('menu.navigator-app', 'location-arrow', 'settings.navigator-app', 'fleet-ops view navigator-settings', 'fleet-ops see navigator-settings'),
this.createItem('menu.map', 'map', 'settings.map', 'fleet-ops view map-settings', 'fleet-ops see map-settings'),
this.createItem('menu.payments', 'cash-register', 'settings.payments', 'fleet-ops view payments', 'fleet-ops see payments'),
@@ -210,8 +208,9 @@ export default class LayoutFleetOpsSidebarComponent extends Component {
this.createItem('menu.routing', 'route', 'settings.routing', 'fleet-ops view routing-settings', 'fleet-ops see routing-settings'),
this.createItem('menu.orchestrator', 'circle-nodes', 'settings.orchestrator', 'fleet-ops view routing-settings', 'fleet-ops see routing-settings'),
this.createItem('menu.scheduling', 'calendar-days', 'settings.scheduling', 'fleet-ops view scheduling-settings', 'fleet-ops see scheduling-settings'),
- this.createItem('menu.custom-fields', 'pen-to-square', 'settings.custom-fields', 'fleet-ops view custom-field', 'fleet-ops see custom-field'),
- this.createItem('menu.avatars', 'icons', 'settings.avatars', 'fleet-ops view avatar', 'fleet-ops see avatar'),
+ this.createItem('menu.telematics-settings', 'satellite-dish', 'settings.telematics', 'fleet-ops view telematics-settings', 'fleet-ops see telematics-settings'),
+ this.createItem('menu.custom-fields', 'pen-to-square', 'settings.custom-fields', 'fleet-ops list custom-field', 'fleet-ops see custom-field'),
+ this.createItem('menu.avatars', 'icons', 'settings.avatars', 'fleet-ops list avatar', 'fleet-ops see avatar'),
]);
}
@@ -265,18 +264,36 @@ export default class LayoutFleetOpsSidebarComponent extends Component {
}
createBranch({ id, label, icon, route, defaultRoute, requiresVisibleChildren = false, children, keywords = [] }) {
+ const visibleChildren = children.filter((item) => item.visible !== false);
+
return {
id,
label,
icon,
route: this.fullRoute(route),
- defaultRoute: this.fullRoute(defaultRoute),
+ defaultRoute: this.resolveDefaultRoute(this.fullRoute(defaultRoute), visibleChildren),
requiresVisibleChildren,
- children: children.filter((item) => item.visible !== false),
+ children: visibleChildren,
keywords,
};
}
+ /**
+ * Keep the branch's default route when the user may open it; otherwise fall back to the
+ * first child route they are permitted to open, so clicking the branch does not land on a
+ * route guard that bounces them back out.
+ */
+ resolveDefaultRoute(defaultRoute, children = []) {
+ const permitted = (item) => [item.visiblePermission, item.permission].every((permission) => !permission || this.abilities.can(permission));
+ const defaultChild = children.find((item) => item.route === defaultRoute);
+
+ if (!defaultChild || permitted(defaultChild)) {
+ return defaultRoute;
+ }
+
+ return children.find((item) => item.route && permitted(item))?.route ?? defaultRoute;
+ }
+
createItem(intl, icon, route, permission, ability, keywords = []) {
return {
priority: this.defaultPriorityForRoute(route),
@@ -290,7 +307,7 @@ export default class LayoutFleetOpsSidebarComponent extends Component {
};
}
- createHubItem(label, icon, route, _permission, _ability, keywords = []) {
+ createHubItem(label, icon, route, permission, ability, keywords = []) {
return {
pinnedFirst: true,
priority: this.defaultPriorityForRoute(route),
@@ -298,6 +315,8 @@ export default class LayoutFleetOpsSidebarComponent extends Component {
description: label,
icon,
route: this.fullRoute(route),
+ permission,
+ visiblePermission: ability,
isNavigationHub: true,
keywords: [label, route, ...keywords].filter(Boolean),
};
@@ -387,8 +406,9 @@ export default class LayoutFleetOpsSidebarComponent extends Component {
'settings.routing': 5,
'settings.orchestrator': 6,
'settings.scheduling': 7,
- 'settings.custom-fields': 8,
- 'settings.avatars': 9,
+ 'settings.telematics': 8,
+ 'settings.custom-fields': 9,
+ 'settings.avatars': 10,
};
return priorities[route] ?? 0;
diff --git a/addon/components/map/leaflet-live-map.hbs b/addon/components/map/leaflet-live-map.hbs
index a82b064b5..920918b44 100644
--- a/addon/components/map/leaflet-live-map.hbs
+++ b/addon/components/map/leaflet-live-map.hbs
@@ -83,108 +83,10 @@
as |marker|
>
-
-
-
-
{{driver.name}}
-
- {{n-a driver.meta.status_label (smart-humanize driver.status)}}
-
-
-
-
-
ID
-
{{n-a driver.public_id driver.id}}
-
-
-
Phone
-
{{n-a driver.phone}}
-
-
-
Vehicle
-
{{n-a driver.vehicle_name}}
-
-
-
Email
-
{{n-a driver.email}}
-
-
-
Order
-
{{n-a driver.meta.current_order_reference}}
-
-
-
Speed
-
{{n-a driver.meta.speed_label}}
-
-
-
Heading
-
{{n-a driver.meta.heading_label}}
-
-
-
Location
-
{{n-a
- driver.meta.location_coordinates
- (point-coordinates driver.location)
- }}
-
-
-
+
-
-
-
-
{{driver.name}}
-
- {{n-a driver.meta.status_label (smart-humanize driver.status)}}
-
-
-
-
-
ID
-
{{n-a driver.public_id driver.id}}
-
-
-
Phone
-
{{n-a driver.phone}}
-
-
-
Vehicle
-
{{n-a driver.vehicle_name}}
-
-
-
Email
-
{{n-a driver.email}}
-
-
-
Order
-
{{n-a driver.meta.current_order_reference}}
-
-
-
Speed
-
{{n-a driver.meta.speed_label}}
-
-
-
Heading
-
{{n-a driver.meta.heading_label}}
-
-
-
Location
-
{{n-a
- driver.meta.location_coordinates
- (point-coordinates driver.location)
- }}
-
-
-
+
{{/each}}
@@ -202,150 +104,10 @@
as |marker|
>
-
-
-
-
{{vehicle.displayName}}
-
- {{n-a vehicle.meta.status_label}}
-
-
-
-
-
Vehicle #
-
{{n-a
- vehicle.internal_id
- vehicle.public_id
- vehicle.plate_number
- vehicle.serial_number
- vehicle.vin
- }}
-
-
-
Driver
-
{{n-a vehicle.driver_name}}
-
-
-
{{t "resource.trailers"}}
-
- {{#each vehicle.trailers as |trailer|}}
-
-
- {{or trailer.displayName trailer.display_name trailer.name trailer.public_id}}
-
- {{else}}
- -
- {{/each}}
-
-
-
-
{{t "resource.devices"}}
-
- {{#each vehicle.devices as |device|}}
-
-
- {{or device.displayName device.display_name device.name device.device_id device.public_id}}
-
- {{else}}
- -
- {{/each}}
-
-
-
-
Order
-
{{n-a vehicle.meta.current_order_reference}}
-
-
-
Speed
-
{{n-a vehicle.meta.speed_label}}
-
-
-
Heading
-
{{n-a vehicle.meta.heading_label}}
-
-
-
Location
-
{{n-a vehicle.meta.location_coordinates}}
-
-
-
+
-
-
-
-
{{vehicle.displayName}}
-
- {{n-a vehicle.meta.status_label}}
-
-
-
-
-
Vehicle #
-
{{n-a
- vehicle.internal_id
- vehicle.public_id
- vehicle.plate_number
- vehicle.serial_number
- vehicle.vin
- }}
-
-
-
Driver
-
{{n-a vehicle.driver_name}}
-
-
-
{{t "resource.trailers"}}
-
- {{#each vehicle.trailers as |trailer|}}
-
-
- {{or trailer.displayName trailer.display_name trailer.name trailer.public_id}}
-
- {{else}}
- -
- {{/each}}
-
-
-
-
{{t "resource.devices"}}
-
- {{#each vehicle.devices as |device|}}
-
-
- {{or device.displayName device.display_name device.name device.device_id device.public_id}}
-
- {{else}}
- -
- {{/each}}
-
-
-
-
Order
-
{{n-a vehicle.meta.current_order_reference}}
-
-
-
Speed
-
{{n-a vehicle.meta.speed_label}}
-
-
-
Heading
-
{{n-a vehicle.meta.heading_label}}
-
-
-
Location
-
{{n-a vehicle.meta.location_coordinates}}
-
-
-
+
{{/each}}
diff --git a/addon/components/map/leaflet-live-map.js b/addon/components/map/leaflet-live-map.js
index ae7727f8a..ded9fcd65 100644
--- a/addon/components/map/leaflet-live-map.js
+++ b/addon/components/map/leaflet-live-map.js
@@ -298,7 +298,8 @@ export default class MapLeafletLiveMapComponent extends Component {
}
@task *loadResource(path, options = {}) {
- if (this.abilities.cannot(`fleet-ops list ${path}`)) return [];
+ // Permissions use the singular resource name (e.g. `fleet-ops list vehicle`), not the plural endpoint path.
+ if (this.abilities.cannot(`fleet-ops list ${singularize(path)}`)) return [];
if (path === 'service-areas') {
const serviceAreas = yield this.serviceAreaActions.loadAll.perform();
diff --git a/addon/components/map/marker-card/driver.hbs b/addon/components/map/marker-card/driver.hbs
new file mode 100644
index 000000000..ff15f7e6d
--- /dev/null
+++ b/addon/components/map/marker-card/driver.hbs
@@ -0,0 +1,57 @@
+{{!
+ The driver card the fleet-ops live map shows in a marker's popup and hover tooltip.
+ Shared with the dashboard's Live Fleet widget so both look the same. @driver is a driver
+ model or its index resource payload (the same attributes, plain JSON).
+ @variant: "popup" (default, a dark panel) or "tooltip" (inherits the tooltip's panel).
+}}
+
+
+
+
{{@driver.name}}
+
+ {{n-a @driver.meta.status_label (smart-humanize @driver.status)}}
+
+
+
+
+
ID
+
{{n-a @driver.public_id @driver.id}}
+
+
+
Phone
+
{{n-a @driver.phone}}
+
+
+
Vehicle
+
{{n-a @driver.vehicle_name}}
+
+
+
Email
+
{{n-a @driver.email}}
+
+
+
Order
+
{{n-a @driver.meta.current_order_reference}}
+
+
+
Speed
+
{{n-a @driver.meta.speed_label}}
+
+
+
Heading
+
{{n-a @driver.meta.heading_label}}
+
+
+
Location
+
{{n-a
+ @driver.meta.location_coordinates
+ (point-coordinates @driver.location)
+ }}
+
+
+
+
diff --git a/addon/components/map/marker-card/driver.js b/addon/components/map/marker-card/driver.js
new file mode 100644
index 000000000..fa8505356
--- /dev/null
+++ b/addon/components/map/marker-card/driver.js
@@ -0,0 +1,3 @@
+import templateOnly from '@ember/component/template-only';
+
+export default templateOnly();
diff --git a/addon/components/map/marker-card/vehicle.hbs b/addon/components/map/marker-card/vehicle.hbs
new file mode 100644
index 000000000..a6f5544cc
--- /dev/null
+++ b/addon/components/map/marker-card/vehicle.hbs
@@ -0,0 +1,78 @@
+{{!
+ The vehicle card the fleet-ops live map shows in a marker's popup and hover tooltip.
+ Shared with the dashboard's Live Fleet widget so both look the same. @vehicle is a vehicle
+ model or its index resource payload (the same attributes, plain JSON).
+ @variant: "popup" (default, a dark panel) or "tooltip" (inherits the tooltip's panel).
+}}
+
+
+
+
{{or @vehicle.displayName @vehicle.display_name @vehicle.name}}
+
+ {{n-a @vehicle.meta.status_label}}
+
+
+
+
+
Vehicle #
+
{{n-a
+ @vehicle.internal_id
+ @vehicle.public_id
+ @vehicle.plate_number
+ @vehicle.serial_number
+ @vehicle.vin
+ }}
+
+
+
Driver
+
{{n-a @vehicle.driver_name}}
+
+
+
{{t "resource.trailers"}}
+
+ {{#each @vehicle.trailers as |trailer|}}
+
+
+ {{or trailer.displayName trailer.display_name trailer.name trailer.public_id}}
+
+ {{else}}
+ -
+ {{/each}}
+
+
+
+
{{t "resource.devices"}}
+
+ {{#each @vehicle.devices as |device|}}
+
+
+ {{or device.displayName device.display_name device.name device.device_id device.public_id}}
+
+ {{else}}
+ -
+ {{/each}}
+
+
+
+
Order
+
{{n-a @vehicle.meta.current_order_reference}}
+
+
+
Speed
+
{{n-a @vehicle.meta.speed_label}}
+
+
+
Heading
+
{{n-a @vehicle.meta.heading_label}}
+
+
+
Location
+
{{n-a @vehicle.meta.location_coordinates}}
+
+
+
+
diff --git a/addon/components/map/marker-card/vehicle.js b/addon/components/map/marker-card/vehicle.js
new file mode 100644
index 000000000..fa8505356
--- /dev/null
+++ b/addon/components/map/marker-card/vehicle.js
@@ -0,0 +1,3 @@
+import templateOnly from '@ember/component/template-only';
+
+export default templateOnly();
diff --git a/addon/components/telematic/hub.hbs b/addon/components/telematic/hub.hbs
index ad094657c..a567a6c97 100644
--- a/addon/components/telematic/hub.hbs
+++ b/addon/components/telematic/hub.hbs
@@ -15,7 +15,7 @@
-
+
diff --git a/addon/components/widget/live-fleet.hbs b/addon/components/widget/live-fleet.hbs
index 82965680b..01ef5974d 100644
--- a/addon/components/widget/live-fleet.hbs
+++ b/addon/components/widget/live-fleet.hbs
@@ -28,7 +28,8 @@
{{#if this.load.isIdle}}
{{! Drivers — fleetops' rotating tracking-marker matches the operational
- live-map's look and animates on heading changes. }}
+ live-map's look and animates on heading changes. The popup and tooltip
+ are the live map's own cards, fed from each marker's index-resource card. }}
{{#each this.drivers as |driver|}}
-
-
-
-
-
-
-
{{or driver.name "Driver"}}
-
- {{#if driver.current_order_uuid}}
- On order
- {{else if driver.online}}
- Online
- {{else}}
- Offline
- {{/if}}
-
-
{{driver.lat}}, {{driver.lng}}
-
-
+
+
- {{or driver.name "Driver"}}
+
{{/each}}
@@ -77,27 +61,11 @@
@draggable={{false}}
as |marker|
>
-
-
-
-
-
-
-
{{or vehicle.name "Vehicle"}}
- {{#if vehicle.plate_number}}
-
{{vehicle.plate_number}}
- {{/if}}
- {{#if vehicle.driver_name}}
-
- {{vehicle.driver_name}}
-
- {{/if}}
-
{{vehicle.lat}}, {{vehicle.lng}}
-
-
+
+
- {{or vehicle.name "Vehicle"}}
+
{{/each}}
diff --git a/addon/controllers/analytics/reports/index.js b/addon/controllers/analytics/reports/index.js
index 489324109..56052e63c 100644
--- a/addon/controllers/analytics/reports/index.js
+++ b/addon/controllers/analytics/reports/index.js
@@ -29,6 +29,7 @@ export default class AnalyticsReportsIndexController extends Controller {
type: 'primary',
icon: 'plus',
onClick: this.reportActions.transition.create,
+ permission: 'iam create report',
},
];
}
@@ -39,6 +40,7 @@ export default class AnalyticsReportsIndexController extends Controller {
label: 'Delete selected...',
class: 'text-red-500',
fn: this.reportActions.bulkDelete,
+ permission: 'iam delete report',
},
];
}
@@ -81,10 +83,12 @@ export default class AnalyticsReportsIndexController extends Controller {
{
label: 'View report...',
fn: this.reportActions.transition.view,
+ permission: 'iam view report',
},
{
label: 'Edit report...',
fn: this.reportActions.transition.edit,
+ permission: 'iam update report',
},
{
separator: true,
@@ -92,6 +96,7 @@ export default class AnalyticsReportsIndexController extends Controller {
{
label: 'Delete report...',
fn: this.reportActions.delete,
+ permission: 'iam delete report',
},
],
sortable: false,
diff --git a/addon/controllers/connectivity/devices/index.js b/addon/controllers/connectivity/devices/index.js
index dcb7c2536..b971d6681 100644
--- a/addon/controllers/connectivity/devices/index.js
+++ b/addon/controllers/connectivity/devices/index.js
@@ -65,12 +65,14 @@ export default class ConnectivityDevicesIndexController extends Controller {
type: 'primary',
icon: 'plus',
onClick: this.deviceActions.transition.create,
+ permission: 'fleet-ops create device',
},
{
text: this.intl.t('common.import'),
type: 'magic',
icon: 'upload',
onClick: this.deviceActions.import,
+ permission: 'fleet-ops import device',
},
{
text: this.intl.t('common.export'),
@@ -78,6 +80,7 @@ export default class ConnectivityDevicesIndexController extends Controller {
iconClass: 'rotate-icon-45',
wrapperClass: 'hidden md:flex',
onClick: this.deviceActions.export,
+ permission: 'fleet-ops export device',
},
];
@@ -87,6 +90,7 @@ export default class ConnectivityDevicesIndexController extends Controller {
label: 'Delete selected...',
class: 'text-red-500',
fn: this.deviceActions.bulkDelete,
+ permission: 'fleet-ops delete device',
},
];
diff --git a/addon/controllers/connectivity/fuel-providers/index.js b/addon/controllers/connectivity/fuel-providers/index.js
index f0ef5a0c8..5b4ffe9fb 100644
--- a/addon/controllers/connectivity/fuel-providers/index.js
+++ b/addon/controllers/connectivity/fuel-providers/index.js
@@ -40,6 +40,7 @@ export default class ConnectivityFuelProvidersIndexController extends Controller
text: 'Connect Integration',
type: 'primary',
onClick: () => this.fuelIntegrationActions.transition.create(),
+ permission: 'fleet-ops create fuel-provider-connection',
},
];
}
@@ -51,6 +52,7 @@ export default class ConnectivityFuelProvidersIndexController extends Controller
{
label: `Sync ${selected.length} selected`,
fn: () => selected.forEach((connection) => this.syncConnection(connection)),
+ permission: 'fleet-ops sync fuel-provider-connection',
},
];
}
@@ -125,11 +127,11 @@ export default class ConnectivityFuelProvidersIndexController extends Controller
sticky: 'right',
width: 60,
actions: [
- { label: 'Open Integration', fn: this.openConnection },
- { label: 'Edit Settings', fn: this.editConnection },
+ { label: 'Open Integration', fn: this.openConnection, permission: 'fleet-ops view fuel-provider-connection' },
+ { label: 'Edit Settings', fn: this.editConnection, permission: 'fleet-ops update fuel-provider-connection' },
{ separator: true },
- { label: 'Test Connection', fn: this.testConnection },
- { label: 'Sync Transactions', fn: this.syncConnection },
+ { label: 'Test Connection', fn: this.testConnection, permission: 'fleet-ops update fuel-provider-connection' },
+ { label: 'Sync Transactions', fn: this.syncConnection, permission: 'fleet-ops sync fuel-provider-connection' },
],
sortable: false,
filterable: false,
diff --git a/addon/controllers/connectivity/sensors/index.js b/addon/controllers/connectivity/sensors/index.js
index 4ed9390a1..492a537d1 100644
--- a/addon/controllers/connectivity/sensors/index.js
+++ b/addon/controllers/connectivity/sensors/index.js
@@ -92,12 +92,14 @@ export default class ConnectivitySensorsIndexController extends Controller {
type: 'primary',
icon: 'plus',
onClick: this.sensorActions.transition.create,
+ permission: 'fleet-ops create sensor',
},
{
text: this.intl.t('common.import'),
type: 'magic',
icon: 'upload',
onClick: this.sensorActions.import,
+ permission: 'fleet-ops import sensor',
},
{
text: this.intl.t('common.export'),
@@ -105,6 +107,7 @@ export default class ConnectivitySensorsIndexController extends Controller {
iconClass: 'rotate-icon-45',
wrapperClass: 'hidden md:flex',
onClick: this.sensorActions.export,
+ permission: 'fleet-ops export sensor',
},
];
@@ -114,6 +117,7 @@ export default class ConnectivitySensorsIndexController extends Controller {
label: 'Delete selected...',
class: 'text-red-500',
fn: this.sensorActions.bulkDelete,
+ permission: 'fleet-ops delete sensor',
},
];
diff --git a/addon/controllers/connectivity/telematics/index.js b/addon/controllers/connectivity/telematics/index.js
index 37369823a..c44ba0556 100644
--- a/addon/controllers/connectivity/telematics/index.js
+++ b/addon/controllers/connectivity/telematics/index.js
@@ -38,12 +38,14 @@ export default class ConnectivityTelematicsIndexController extends Controller {
type: 'primary',
icon: 'plus',
onClick: this.telematicActions.transition.create,
+ permission: 'fleet-ops create telematic',
},
{
text: this.intl.t('common.import'),
type: 'magic',
icon: 'upload',
onClick: this.telematicActions.import,
+ permission: 'fleet-ops import telematic',
},
{
text: this.intl.t('common.export'),
@@ -51,6 +53,7 @@ export default class ConnectivityTelematicsIndexController extends Controller {
iconClass: 'rotate-icon-45',
wrapperClass: 'hidden md:flex',
onClick: this.telematicActions.export,
+ permission: 'fleet-ops export telematic',
},
];
@@ -60,6 +63,7 @@ export default class ConnectivityTelematicsIndexController extends Controller {
label: 'Delete selected...',
class: 'text-red-500',
fn: this.telematicActions.bulkDelete,
+ permission: 'fleet-ops delete telematic',
},
];
diff --git a/addon/controllers/maintenance/equipment/index.js b/addon/controllers/maintenance/equipment/index.js
index 7c020cfb7..d0a95dac0 100644
--- a/addon/controllers/maintenance/equipment/index.js
+++ b/addon/controllers/maintenance/equipment/index.js
@@ -46,14 +46,21 @@ export default class MaintenanceEquipmentIndexController extends Controller {
helpText: 'Change the layout',
},
{ icon: 'refresh', onClick: this.equipmentActions.refresh, helpText: this.intl.t('common.refresh') },
- { text: this.intl.t('common.new'), type: 'primary', icon: 'plus', onClick: this.equipmentActions.transition.create },
- { text: this.intl.t('common.import'), type: 'magic', icon: 'upload', onClick: this.equipmentActions.import },
- { text: this.intl.t('common.export'), icon: 'long-arrow-up', iconClass: 'rotate-icon-45', wrapperClass: 'hidden md:flex', onClick: this.equipmentActions.export },
+ { text: this.intl.t('common.new'), type: 'primary', icon: 'plus', onClick: this.equipmentActions.transition.create, permission: 'fleet-ops create equipment' },
+ { text: this.intl.t('common.import'), type: 'magic', icon: 'upload', onClick: this.equipmentActions.import, permission: 'fleet-ops import equipment' },
+ {
+ text: this.intl.t('common.export'),
+ icon: 'long-arrow-up',
+ iconClass: 'rotate-icon-45',
+ wrapperClass: 'hidden md:flex',
+ onClick: this.equipmentActions.export,
+ permission: 'fleet-ops export equipment',
+ },
];
}
get bulkActions() {
- return [{ label: 'Delete selected...', class: 'text-red-500', fn: this.equipmentActions.bulkDelete }];
+ return [{ label: 'Delete selected...', class: 'text-red-500', fn: this.equipmentActions.bulkDelete, permission: 'fleet-ops delete equipment' }];
}
get columns() {
diff --git a/addon/controllers/maintenance/inspection-forms/index.js b/addon/controllers/maintenance/inspection-forms/index.js
index 53d812903..f7fb1c0ff 100644
--- a/addon/controllers/maintenance/inspection-forms/index.js
+++ b/addon/controllers/maintenance/inspection-forms/index.js
@@ -17,12 +17,12 @@ export default class MaintenanceInspectionFormsIndexController extends Controlle
get actionButtons() {
return [
{ icon: 'refresh', onClick: this.inspectionFormActions.refresh, helpText: this.intl.t('common.refresh') },
- { text: this.intl.t('common.new'), type: 'primary', icon: 'plus', onClick: this.inspectionFormActions.transition.create },
+ { text: this.intl.t('common.new'), type: 'primary', icon: 'plus', onClick: this.inspectionFormActions.transition.create, permission: 'fleet-ops create inspection-form' },
];
}
get bulkActions() {
- return [{ label: 'Delete selected...', class: 'text-red-500', fn: this.inspectionFormActions.bulkDelete }];
+ return [{ label: 'Delete selected...', class: 'text-red-500', fn: this.inspectionFormActions.bulkDelete, permission: 'fleet-ops delete inspection-form' }];
}
get columns() {
diff --git a/addon/controllers/maintenance/inspection-submissions/index.js b/addon/controllers/maintenance/inspection-submissions/index.js
index 888898dc5..0cfa6bd70 100644
--- a/addon/controllers/maintenance/inspection-submissions/index.js
+++ b/addon/controllers/maintenance/inspection-submissions/index.js
@@ -23,12 +23,18 @@ export default class MaintenanceInspectionSubmissionsIndexController extends Con
get actionButtons() {
return [
{ icon: 'refresh', onClick: this.inspectionSubmissionActions.refresh, helpText: this.intl.t('common.refresh') },
- { text: this.intl.t('common.new'), type: 'primary', icon: 'plus', onClick: this.inspectionSubmissionActions.transition.create },
+ {
+ text: this.intl.t('common.new'),
+ type: 'primary',
+ icon: 'plus',
+ onClick: this.inspectionSubmissionActions.transition.create,
+ permission: 'fleet-ops create inspection-submission',
+ },
];
}
get bulkActions() {
- return [{ label: 'Delete selected...', class: 'text-red-500', fn: this.inspectionSubmissionActions.bulkDelete }];
+ return [{ label: 'Delete selected...', class: 'text-red-500', fn: this.inspectionSubmissionActions.bulkDelete, permission: 'fleet-ops delete inspection-submission' }];
}
get columns() {
diff --git a/addon/controllers/maintenance/maintenances/index.js b/addon/controllers/maintenance/maintenances/index.js
index 88d817048..a0d75c580 100644
--- a/addon/controllers/maintenance/maintenances/index.js
+++ b/addon/controllers/maintenance/maintenances/index.js
@@ -31,12 +31,14 @@ export default class MaintenanceMaintenancesIndexController extends Controller {
type: 'primary',
icon: 'plus',
onClick: this.maintenanceActions.transition.create,
+ permission: 'fleet-ops create maintenance',
},
{
text: this.intl.t('common.import'),
type: 'magic',
icon: 'upload',
onClick: this.maintenanceActions.import,
+ permission: 'fleet-ops import maintenance',
},
{
text: this.intl.t('common.export'),
@@ -44,6 +46,7 @@ export default class MaintenanceMaintenancesIndexController extends Controller {
iconClass: 'rotate-icon-45',
wrapperClass: 'hidden md:flex',
onClick: this.maintenanceActions.export,
+ permission: 'fleet-ops export maintenance',
},
];
}
@@ -55,6 +58,7 @@ export default class MaintenanceMaintenancesIndexController extends Controller {
label: 'Delete selected...',
class: 'text-red-500',
fn: this.maintenanceActions.bulkDelete,
+ permission: 'fleet-ops delete maintenance',
},
];
}
diff --git a/addon/controllers/maintenance/parts/index.js b/addon/controllers/maintenance/parts/index.js
index 3a44dfb5e..55bfc41c2 100644
--- a/addon/controllers/maintenance/parts/index.js
+++ b/addon/controllers/maintenance/parts/index.js
@@ -46,14 +46,21 @@ export default class MaintenancePartsIndexController extends Controller {
helpText: 'Change the layout',
},
{ icon: 'refresh', onClick: this.partActions.refresh, helpText: this.intl.t('common.refresh') },
- { text: this.intl.t('common.new'), type: 'primary', icon: 'plus', onClick: this.partActions.transition.create },
- { text: this.intl.t('common.import'), type: 'magic', icon: 'upload', onClick: this.partActions.import },
- { text: this.intl.t('common.export'), icon: 'long-arrow-up', iconClass: 'rotate-icon-45', wrapperClass: 'hidden md:flex', onClick: this.partActions.export },
+ { text: this.intl.t('common.new'), type: 'primary', icon: 'plus', onClick: this.partActions.transition.create, permission: 'fleet-ops create part' },
+ { text: this.intl.t('common.import'), type: 'magic', icon: 'upload', onClick: this.partActions.import, permission: 'fleet-ops import part' },
+ {
+ text: this.intl.t('common.export'),
+ icon: 'long-arrow-up',
+ iconClass: 'rotate-icon-45',
+ wrapperClass: 'hidden md:flex',
+ onClick: this.partActions.export,
+ permission: 'fleet-ops export part',
+ },
];
}
get bulkActions() {
- return [{ label: 'Delete selected...', class: 'text-red-500', fn: this.partActions.bulkDelete }];
+ return [{ label: 'Delete selected...', class: 'text-red-500', fn: this.partActions.bulkDelete, permission: 'fleet-ops delete part' }];
}
get columns() {
diff --git a/addon/controllers/maintenance/schedules/index.js b/addon/controllers/maintenance/schedules/index.js
index 094af86ef..16f4b098f 100644
--- a/addon/controllers/maintenance/schedules/index.js
+++ b/addon/controllers/maintenance/schedules/index.js
@@ -151,14 +151,27 @@ export default class MaintenanceSchedulesIndexController extends Controller {
helpText: this.intl.t('common.change-layout'),
},
{ icon: 'refresh', onClick: this.maintenanceScheduleActions.refresh, helpText: this.intl.t('common.refresh') },
- { text: this.intl.t('common.new'), type: 'primary', icon: 'plus', onClick: this.maintenanceScheduleActions.transition.create },
- { text: this.intl.t('common.import'), type: 'magic', icon: 'upload', onClick: this.maintenanceScheduleActions.import },
- { text: this.intl.t('common.export'), icon: 'long-arrow-up', iconClass: 'rotate-icon-45', wrapperClass: 'hidden md:flex', onClick: this.maintenanceScheduleActions.export },
+ {
+ text: this.intl.t('common.new'),
+ type: 'primary',
+ icon: 'plus',
+ onClick: this.maintenanceScheduleActions.transition.create,
+ permission: 'fleet-ops create maintenance-schedule',
+ },
+ { text: this.intl.t('common.import'), type: 'magic', icon: 'upload', onClick: this.maintenanceScheduleActions.import, permission: 'fleet-ops import maintenance-schedule' },
+ {
+ text: this.intl.t('common.export'),
+ icon: 'long-arrow-up',
+ iconClass: 'rotate-icon-45',
+ wrapperClass: 'hidden md:flex',
+ onClick: this.maintenanceScheduleActions.export,
+ permission: 'fleet-ops export maintenance-schedule',
+ },
];
}
get bulkActions() {
- return [{ label: 'Delete selected...', class: 'text-red-500', fn: this.maintenanceScheduleActions.bulkDelete }];
+ return [{ label: 'Delete selected...', class: 'text-red-500', fn: this.maintenanceScheduleActions.bulkDelete, permission: 'fleet-ops delete maintenance-schedule' }];
}
get columns() {
diff --git a/addon/controllers/maintenance/work-orders/index.js b/addon/controllers/maintenance/work-orders/index.js
index fbc789696..082cb8ead 100644
--- a/addon/controllers/maintenance/work-orders/index.js
+++ b/addon/controllers/maintenance/work-orders/index.js
@@ -21,14 +21,21 @@ export default class MaintenanceWorkOrdersIndexController extends Controller {
get actionButtons() {
return [
{ icon: 'refresh', onClick: this.workOrderActions.refresh, helpText: this.intl.t('common.refresh') },
- { text: this.intl.t('common.new'), type: 'primary', icon: 'plus', onClick: this.workOrderActions.transition.create },
- { text: this.intl.t('common.import'), type: 'magic', icon: 'upload', onClick: this.workOrderActions.import },
- { text: this.intl.t('common.export'), icon: 'long-arrow-up', iconClass: 'rotate-icon-45', wrapperClass: 'hidden md:flex', onClick: this.workOrderActions.export },
+ { text: this.intl.t('common.new'), type: 'primary', icon: 'plus', onClick: this.workOrderActions.transition.create, permission: 'fleet-ops create work-order' },
+ { text: this.intl.t('common.import'), type: 'magic', icon: 'upload', onClick: this.workOrderActions.import, permission: 'fleet-ops import work-order' },
+ {
+ text: this.intl.t('common.export'),
+ icon: 'long-arrow-up',
+ iconClass: 'rotate-icon-45',
+ wrapperClass: 'hidden md:flex',
+ onClick: this.workOrderActions.export,
+ permission: 'fleet-ops export work-order',
+ },
];
}
get bulkActions() {
- return [{ label: 'Delete selected...', class: 'text-red-500', fn: this.workOrderActions.bulkDelete }];
+ return [{ label: 'Delete selected...', class: 'text-red-500', fn: this.workOrderActions.bulkDelete, permission: 'fleet-ops delete work-order' }];
}
get columns() {
diff --git a/addon/controllers/management/contacts/index.js b/addon/controllers/management/contacts/index.js
index 0756dd08e..056a1a696 100644
--- a/addon/controllers/management/contacts/index.js
+++ b/addon/controllers/management/contacts/index.js
@@ -62,12 +62,14 @@ export default class ManagementContactsIndexController extends Controller {
type: 'primary',
icon: 'plus',
onClick: this.contactActions.transition.create,
+ permission: 'fleet-ops create contact',
},
{
text: this.intl.t('common.import'),
type: 'magic',
icon: 'upload',
onClick: this.contactActions.import,
+ permission: 'fleet-ops import contact',
},
{
text: this.intl.t('common.export'),
@@ -75,6 +77,7 @@ export default class ManagementContactsIndexController extends Controller {
iconClass: 'rotate-icon-45',
wrapperClass: 'hidden md:flex',
onClick: this.contactActions.export,
+ permission: 'fleet-ops export contact',
},
];
}
@@ -88,6 +91,7 @@ export default class ManagementContactsIndexController extends Controller {
label: this.intl.t('common.delete-selected-count', { count: selected.length }),
class: 'text-red-500',
fn: this.contactActions.bulkDelete,
+ permission: 'fleet-ops delete contact',
},
];
}
diff --git a/addon/controllers/management/drivers/index.js b/addon/controllers/management/drivers/index.js
index 9762122d6..30612981f 100644
--- a/addon/controllers/management/drivers/index.js
+++ b/addon/controllers/management/drivers/index.js
@@ -103,12 +103,14 @@ export default class ManagementDriversIndexController extends Controller {
type: 'primary',
icon: 'plus',
onClick: this.driverActions.transition.create,
+ permission: 'fleet-ops create driver',
},
{
text: this.intl.t('common.import'),
type: 'magic',
icon: 'upload',
onClick: this.driverActions.import,
+ permission: 'fleet-ops import driver',
},
{
text: this.intl.t('common.export'),
@@ -116,6 +118,7 @@ export default class ManagementDriversIndexController extends Controller {
iconClass: 'rotate-icon-45',
wrapperClass: 'hidden md:flex',
onClick: this.driverActions.export,
+ permission: 'fleet-ops export driver',
},
];
}
@@ -129,6 +132,7 @@ export default class ManagementDriversIndexController extends Controller {
label: this.intl.t('common.delete-selected-count', { count: selected.length }),
class: 'text-red-500',
fn: this.driverActions.bulkDelete,
+ permission: 'fleet-ops delete driver',
},
];
}
diff --git a/addon/controllers/management/fleets/index.js b/addon/controllers/management/fleets/index.js
index 910dbaea3..80f6d84ae 100644
--- a/addon/controllers/management/fleets/index.js
+++ b/addon/controllers/management/fleets/index.js
@@ -42,12 +42,14 @@ export default class ManagementFleetsIndexController extends Controller {
type: 'primary',
icon: 'plus',
onClick: this.fleetActions.transition.create,
+ permission: 'fleet-ops create fleet',
},
{
text: this.intl.t('common.import'),
type: 'magic',
icon: 'upload',
onClick: this.fleetActions.import,
+ permission: 'fleet-ops import fleet',
},
{
text: this.intl.t('common.export'),
@@ -55,6 +57,7 @@ export default class ManagementFleetsIndexController extends Controller {
iconClass: 'rotate-icon-45',
wrapperClass: 'hidden md:flex',
onClick: this.fleetActions.export,
+ permission: 'fleet-ops export fleet',
},
];
}
@@ -68,6 +71,7 @@ export default class ManagementFleetsIndexController extends Controller {
label: this.intl.t('common.delete-selected-count', { count: selected.length }),
class: 'text-red-500',
fn: this.fleetActions.bulkDelete,
+ permission: 'fleet-ops delete fleet',
},
];
}
diff --git a/addon/controllers/management/fuel-reports/index.js b/addon/controllers/management/fuel-reports/index.js
index 1374cfcaf..cdf74dfbe 100644
--- a/addon/controllers/management/fuel-reports/index.js
+++ b/addon/controllers/management/fuel-reports/index.js
@@ -56,12 +56,14 @@ export default class ManagementFuelReportsIndexController extends Controller {
type: 'primary',
icon: 'plus',
onClick: this.fuelReportActions.transition.create,
+ permission: 'fleet-ops create fuel-report',
},
{
text: this.intl.t('common.import'),
type: 'magic',
icon: 'upload',
onClick: this.fuelReportActions.import,
+ permission: 'fleet-ops import fuel-report',
},
{
text: this.intl.t('common.export'),
@@ -69,6 +71,7 @@ export default class ManagementFuelReportsIndexController extends Controller {
iconClass: 'rotate-icon-45',
wrapperClass: 'hidden md:flex',
onClick: this.fuelReportActions.export,
+ permission: 'fleet-ops export fuel-report',
},
];
}
@@ -82,6 +85,7 @@ export default class ManagementFuelReportsIndexController extends Controller {
label: this.intl.t('common.delete-selected-count', { count: selected.length }),
class: 'text-red-500',
fn: this.fuelReportActions.bulkDelete,
+ permission: 'fleet-ops delete fuel-report',
},
];
}
diff --git a/addon/controllers/management/fuel-transactions/index.js b/addon/controllers/management/fuel-transactions/index.js
index e6873351a..30e940e3e 100644
--- a/addon/controllers/management/fuel-transactions/index.js
+++ b/addon/controllers/management/fuel-transactions/index.js
@@ -71,6 +71,7 @@ export default class ManagementFuelTransactionsIndexController extends Controlle
icon: 'gas-pump',
text: 'Fuel Integrations',
onClick: () => this.hostRouter.transitionTo('console.fleet-ops.connectivity.fuel-providers.index'),
+ permission: 'fleet-ops list fuel-provider-connection',
},
];
}
@@ -86,6 +87,7 @@ export default class ManagementFuelTransactionsIndexController extends Controlle
{
label: `Reprocess ${selected.length} selected`,
fn: () => this.confirmAction('reprocess', selected),
+ permission: 'fleet-ops update fuel-provider-transaction',
},
];
}
@@ -196,14 +198,14 @@ export default class ManagementFuelTransactionsIndexController extends Controlle
sticky: 'right',
width: 60,
actions: [
- { label: 'Review Details', fn: this.openDetails },
- { label: 'Open Fuel Report', fn: this.openFuelReport, isVisible: (transaction) => Boolean(transaction.fuel_report_id) },
+ { label: 'Review Details', fn: this.openDetails, permission: 'fleet-ops view fuel-provider-transaction' },
+ { label: 'Open Fuel Report', fn: this.openFuelReport, isVisible: (transaction) => Boolean(transaction.fuel_report_id), permission: 'fleet-ops view fuel-report' },
{ separator: true },
- { label: 'Match to Vehicle', fn: this.matchVehicle },
- { label: 'Match to Order', fn: this.matchOrder },
- { label: 'Reprocess / Rematch', fn: (transaction) => this.confirmAction('reprocess', transaction) },
- { label: 'Ignore Transaction', fn: (transaction) => this.confirmAction('ignored', transaction) },
- { label: 'Mark Reviewed', fn: (transaction) => this.confirmAction('reviewed', transaction) },
+ { label: 'Match to Vehicle', fn: this.matchVehicle, permission: 'fleet-ops update fuel-provider-transaction' },
+ { label: 'Match to Order', fn: this.matchOrder, permission: 'fleet-ops update fuel-provider-transaction' },
+ { label: 'Reprocess / Rematch', fn: (transaction) => this.confirmAction('reprocess', transaction), permission: 'fleet-ops update fuel-provider-transaction' },
+ { label: 'Ignore Transaction', fn: (transaction) => this.confirmAction('ignored', transaction), permission: 'fleet-ops review fuel-provider-transaction' },
+ { label: 'Mark Reviewed', fn: (transaction) => this.confirmAction('reviewed', transaction), permission: 'fleet-ops review fuel-provider-transaction' },
],
sortable: false,
filterable: false,
diff --git a/addon/controllers/management/issues/index.js b/addon/controllers/management/issues/index.js
index 9c3cfaecd..5774b8865 100644
--- a/addon/controllers/management/issues/index.js
+++ b/addon/controllers/management/issues/index.js
@@ -56,12 +56,14 @@ export default class ManagementIssuesIndexController extends Controller {
type: 'primary',
icon: 'plus',
onClick: this.issueActions.transition.create,
+ permission: 'fleet-ops create issue',
},
{
text: this.intl.t('common.import'),
type: 'magic',
icon: 'upload',
onClick: this.issueActions.import,
+ permission: 'fleet-ops import issue',
},
{
text: this.intl.t('common.export'),
@@ -69,6 +71,7 @@ export default class ManagementIssuesIndexController extends Controller {
iconClass: 'rotate-icon-45',
wrapperClass: 'hidden md:flex',
onClick: this.issueActions.export,
+ permission: 'fleet-ops export issue',
},
];
}
@@ -82,6 +85,7 @@ export default class ManagementIssuesIndexController extends Controller {
label: this.intl.t('common.delete-selected-count', { count: selected.length }),
class: 'text-red-500',
fn: this.issueActions.bulkDelete,
+ permission: 'fleet-ops delete issue',
},
];
}
diff --git a/addon/controllers/management/places/index.js b/addon/controllers/management/places/index.js
index f4236ea31..6fdc416b4 100644
--- a/addon/controllers/management/places/index.js
+++ b/addon/controllers/management/places/index.js
@@ -34,12 +34,14 @@ export default class ManagementPlacesIndexController extends Controller {
type: 'primary',
icon: 'plus',
onClick: this.placeActions.transition.create,
+ permission: 'fleet-ops create place',
},
{
text: this.intl.t('common.import'),
type: 'magic',
icon: 'upload',
onClick: this.placeActions.import,
+ permission: 'fleet-ops import place',
},
{
text: this.intl.t('common.export'),
@@ -47,6 +49,7 @@ export default class ManagementPlacesIndexController extends Controller {
iconClass: 'rotate-icon-45',
wrapperClass: 'hidden md:flex',
onClick: this.placeActions.export,
+ permission: 'fleet-ops export place',
},
];
}
@@ -60,6 +63,7 @@ export default class ManagementPlacesIndexController extends Controller {
label: this.intl.t('common.delete-selected-count', { count: selected.length }),
class: 'text-red-500',
fn: this.placeActions.bulkDelete,
+ permission: 'fleet-ops delete place',
},
];
}
diff --git a/addon/controllers/management/vehicles/index.js b/addon/controllers/management/vehicles/index.js
index 1662a134a..1e63fbf4d 100644
--- a/addon/controllers/management/vehicles/index.js
+++ b/addon/controllers/management/vehicles/index.js
@@ -102,12 +102,14 @@ export default class ManagementVehiclesIndexController extends Controller {
type: 'primary',
icon: 'plus',
onClick: this.vehicleActions.transition.create,
+ permission: 'fleet-ops create vehicle',
},
{
text: this.intl.t('common.import'),
type: 'magic',
icon: 'upload',
onClick: this.vehicleActions.import,
+ permission: 'fleet-ops import vehicle',
},
{
text: this.intl.t('common.export'),
@@ -115,6 +117,7 @@ export default class ManagementVehiclesIndexController extends Controller {
iconClass: 'rotate-icon-45',
wrapperClass: 'hidden md:flex',
onClick: this.vehicleActions.export,
+ permission: 'fleet-ops export vehicle',
},
];
}
@@ -128,6 +131,7 @@ export default class ManagementVehiclesIndexController extends Controller {
label: this.intl.t('common.delete-selected-count', { count: selected.length }),
class: 'text-red-500',
fn: this.vehicleActions.bulkDelete,
+ permission: 'fleet-ops delete vehicle',
},
];
}
diff --git a/addon/controllers/management/vendors/index.js b/addon/controllers/management/vendors/index.js
index 03d941bbe..e76a713ee 100644
--- a/addon/controllers/management/vendors/index.js
+++ b/addon/controllers/management/vendors/index.js
@@ -83,12 +83,14 @@ export default class ManagementVendorsIndexController extends Controller {
type: 'primary',
icon: 'plus',
onClick: this.vendorActions.transition.create,
+ permission: 'fleet-ops create vendor',
},
{
text: this.intl.t('common.import'),
type: 'magic',
icon: 'upload',
onClick: this.vendorActions.import,
+ permission: 'fleet-ops import vendor',
},
{
text: this.intl.t('common.export'),
@@ -96,6 +98,7 @@ export default class ManagementVendorsIndexController extends Controller {
iconClass: 'rotate-icon-45',
wrapperClass: 'hidden md:flex',
onClick: this.vendorActions.export,
+ permission: 'fleet-ops export vendor',
},
];
}
@@ -109,6 +112,7 @@ export default class ManagementVendorsIndexController extends Controller {
label: this.intl.t('common.delete-selected-count', { count: selected.length }),
class: 'text-red-500',
fn: this.vendorActions.bulkDelete,
+ permission: 'fleet-ops delete vendor',
},
];
}
diff --git a/addon/controllers/operations/orders/index.js b/addon/controllers/operations/orders/index.js
index 5b40c7bc8..ab9563a89 100644
--- a/addon/controllers/operations/orders/index.js
+++ b/addon/controllers/operations/orders/index.js
@@ -86,6 +86,7 @@ export default class OperationsOrdersIndexController extends Controller {
type: 'primary',
icon: 'plus',
onClick: this.orderActions.transition.create,
+ permission: 'fleet-ops create order',
},
{
text: this.intl.t('common.export'),
@@ -93,12 +94,14 @@ export default class OperationsOrdersIndexController extends Controller {
iconClass: 'rotate-icon-45',
wrapperClass: 'hidden md:flex',
onClick: this.orderActions.export,
+ permission: 'fleet-ops export order',
},
{
text: this.intl.t('common.import'),
icon: 'file-import',
wrapperClass: 'hidden md:flex',
onClick: () => this.orderActions.importOrders({ onImportComplete: this.orderActions.refresh }),
+ permission: 'fleet-ops import order',
},
];
}
@@ -110,23 +113,27 @@ export default class OperationsOrdersIndexController extends Controller {
label: this.intl.t('common.cancel-resource', { resource: this.intl.t('resource.orders') }),
icon: 'ban',
fn: this.orderActions.bulkCancel,
+ permission: 'fleet-ops cancel order',
},
{
label: this.intl.t('common.delete-resource', { resource: this.intl.t('resource.orders') }),
icon: 'trash',
class: 'text-red-500',
fn: this.orderActions.bulkDelete,
+ permission: 'fleet-ops delete order',
},
{ separator: true },
{
label: this.intl.t('common.dispatch-orders'),
icon: 'rocket',
fn: this.orderActions.bulkDispatch,
+ permission: 'fleet-ops dispatch order',
},
{
label: this.intl.t('common.assign-drivers'),
icon: 'user-plus',
fn: this.orderActions.bulkAssignDriver,
+ permission: 'fleet-ops assign-driver-for order',
},
];
}
diff --git a/addon/controllers/operations/orders/index/details.js b/addon/controllers/operations/orders/index/details.js
index 188ec9256..333846cc9 100644
--- a/addon/controllers/operations/orders/index/details.js
+++ b/addon/controllers/operations/orders/index/details.js
@@ -17,6 +17,7 @@ export default class OperationsOrdersIndexDetailsController extends Controller {
@service hostRouter;
@service universe;
@service sidebar;
+ @service abilities;
@tracked routingControl;
@tracked routingCompleted = false;
@tracked realtimeOrderPublicId = null;
@@ -90,12 +91,14 @@ export default class OperationsOrdersIndexDetailsController extends Controller {
{
text: 'Edit details',
icon: 'pencil',
+ permission: 'fleet-ops update order',
disabled: this.model.status === 'canceled',
fn: () => this.orderActions.editOrderDetails(this.model),
},
{
text: 'Update activity',
icon: 'signal',
+ permission: 'fleet-ops update order',
disabled: this.model.status === 'canceled',
fn: () =>
this.orderActions.updateActivity(this.model, {
@@ -105,12 +108,14 @@ export default class OperationsOrdersIndexDetailsController extends Controller {
{
text: this.model.has_driver_assigned ? 'Unassign driver' : 'Assign driver',
icon: this.model.has_driver_assigned ? 'user-xmark' : 'edit',
+ permission: 'fleet-ops assign-driver-for order',
disabled: this.model.has_driver_assigned ? !this.model.hasActiveStatus || !this.model.driver_assigned : !this.model.hasActiveStatus,
fn: () => (this.model.has_driver_assigned ? this.orderActions.unassignDriver(this.model) : this.orderActions.assignDriver(this.model)),
},
{
text: 'View order label',
icon: 'file-invoice',
+ permission: 'fleet-ops view order',
fn: () => this.orderActions.viewLabel(this.model),
},
{
@@ -133,6 +138,7 @@ export default class OperationsOrdersIndexDetailsController extends Controller {
text: 'Cancel order',
icon: 'ban',
class: 'text-danger',
+ permission: 'fleet-ops cancel order',
disabled: this.model.status === 'canceled',
fn: () => this.orderActions.cancel(this.model),
},
@@ -140,6 +146,7 @@ export default class OperationsOrdersIndexDetailsController extends Controller {
text: 'Delete order',
icon: 'trash',
class: 'text-danger',
+ permission: 'fleet-ops delete order',
fn: () =>
this.orderActions.delete(this.model, {
taskOptions: {
@@ -151,7 +158,29 @@ export default class OperationsOrdersIndexDetailsController extends Controller {
},
].filter(Boolean),
},
- ];
+ ].map((actionButton) => ({ ...actionButton, items: this.permittedMenuItems(actionButton.items) }));
+ }
+
+ /**
+ * The panel header dropdown renders `items` as-is and ignores `permission`, so items the
+ * user is not permitted to use are removed here, along with any separators left dangling.
+ */
+ permittedMenuItems(items = []) {
+ const permitted = items.filter((item) => !item.permission || this.abilities.can(item.permission));
+ const result = permitted.reduce((list, item) => {
+ if (item.separator && (list.length === 0 || list[list.length - 1].separator)) {
+ return list;
+ }
+
+ list.push(item);
+ return list;
+ }, []);
+
+ if (result.length && result[result.length - 1].separator) {
+ result.pop();
+ }
+
+ return result;
}
@action handleActivityModalFinish(options) {
diff --git a/addon/controllers/operations/service-rates/index.js b/addon/controllers/operations/service-rates/index.js
index cdb91974f..52413130a 100644
--- a/addon/controllers/operations/service-rates/index.js
+++ b/addon/controllers/operations/service-rates/index.js
@@ -29,6 +29,7 @@ export default class OperationsServiceRatesIndexController extends Controller {
type: 'primary',
icon: 'plus',
onClick: this.serviceRateActions.transition.create,
+ permission: 'fleet-ops create service-rate',
},
{
text: this.intl.t('common.export'),
@@ -36,6 +37,7 @@ export default class OperationsServiceRatesIndexController extends Controller {
iconClass: 'rotate-icon-45',
wrapperClass: 'hidden md:flex',
onClick: this.serviceRateActions.export,
+ permission: 'fleet-ops export service-rate',
},
];
}
@@ -47,6 +49,7 @@ export default class OperationsServiceRatesIndexController extends Controller {
label: 'Delete selected...',
class: 'text-red-500',
fn: this.serviceRateActions.bulkDelete,
+ permission: 'fleet-ops delete service-rate',
},
];
}
diff --git a/addon/controllers/settings/index.js b/addon/controllers/settings/index.js
index 9ec9284b8..586754c08 100644
--- a/addon/controllers/settings/index.js
+++ b/addon/controllers/settings/index.js
@@ -28,6 +28,12 @@ export default class SettingsIndexController extends Controller {
description: 'Keep commerce, metadata, and visual conventions aligned.',
links: [
{ label: 'Payments', route: 'settings.payments', icon: 'cash-register', description: 'Payment setup for operational commerce workflows.' },
+ {
+ label: 'Telematics',
+ route: 'settings.telematics',
+ icon: 'satellite-dish',
+ description: 'Device event and position history preferences within the system retention policy.',
+ },
{ label: 'Custom Fields', route: 'settings.custom-fields', icon: 'pen-to-square', description: 'Operational metadata fields for Fleet-Ops records.' },
{ label: 'Avatars', route: 'settings.avatars', icon: 'icons', description: 'Visual assets for driver, vehicle, and map displays.' },
],
@@ -113,6 +119,13 @@ export default class SettingsIndexController extends Controller {
title: 'Scheduling settings',
description: 'Manage schedule templates and timing rules for planned work.',
},
+ {
+ label: 'Telematics',
+ icon: 'satellite-dish',
+ slug: 'fleet-ops/settings/telematics',
+ title: 'Telematics Settings',
+ description: 'Choose how long your organization keeps device events and position history.',
+ },
{
label: 'Custom Fields',
icon: 'pen-to-square',
diff --git a/addon/controllers/settings/telematics.js b/addon/controllers/settings/telematics.js
new file mode 100644
index 000000000..9700fe77c
--- /dev/null
+++ b/addon/controllers/settings/telematics.js
@@ -0,0 +1,113 @@
+import Controller from '@ember/controller';
+import { tracked } from '@glimmer/tracking';
+import { inject as service } from '@ember/service';
+import { action } from '@ember/object';
+import { task } from 'ember-concurrency';
+
+/** Organization history preferences, subject to the system retention policy. */
+export default class SettingsTelematicsController extends Controller {
+ @service fetch;
+ @service notifications;
+ @service intl;
+
+ @tracked eventRetentionDays = 30;
+ @tracked positionRetentionDays = 90;
+ @tracked useDefaultEventRetention = true;
+ @tracked useDefaultPositionRetention = true;
+ @tracked defaults = {};
+ @tracked policy = {};
+ @tracked settingsLoaded = false;
+
+ get eventRetentionMinimum() {
+ return this.policy.max_event_retention_days > 0 ? 1 : 0;
+ }
+
+ get eventRetentionMaximum() {
+ return this.policy.max_event_retention_days > 0 ? this.policy.max_event_retention_days : 3650;
+ }
+
+ get positionRetentionMinimum() {
+ return this.policy.max_position_retention_days > 0 ? 1 : 0;
+ }
+
+ get positionRetentionMaximum() {
+ return this.policy.max_position_retention_days > 0 ? this.policy.max_position_retention_days : 3650;
+ }
+
+ get eventRetentionPolicyHelp() {
+ return this.retentionPolicyHelp(this.policy.max_event_retention_days);
+ }
+
+ get positionRetentionPolicyHelp() {
+ return this.retentionPolicyHelp(this.policy.max_position_retention_days);
+ }
+
+ get settingsPayload() {
+ return {
+ event_retention_days: this.useDefaultEventRetention ? null : this.eventRetentionDays,
+ position_retention_days: this.useDefaultPositionRetention ? null : this.positionRetentionDays,
+ };
+ }
+
+ @task({ restartable: true }) *getSettings() {
+ this.settingsLoaded = false;
+
+ try {
+ const settings = yield this.fetch.get('fleet-ops/settings/telematics-settings');
+ this.applySettings(settings);
+ this.settingsLoaded = true;
+ } catch (error) {
+ this.notifications.serverError(error);
+ }
+ }
+
+ @task({ drop: true }) *saveSettings() {
+ if (!this.settingsLoaded) {
+ return;
+ }
+
+ try {
+ const settings = yield this.fetch.post('fleet-ops/settings/telematics-settings', this.settingsPayload);
+ this.applySettings(settings);
+ this.notifications.success(this.intl.t('settings.telematics.settings-saved'));
+ } catch (error) {
+ this.notifications.serverError(error);
+ }
+ }
+
+ @action setUseDefaultEventRetention(enabled) {
+ this.useDefaultEventRetention = enabled;
+ if (enabled) {
+ this.eventRetentionDays = this.defaults.event_retention_days ?? this.eventRetentionDays;
+ }
+ }
+
+ @action setUseDefaultPositionRetention(enabled) {
+ this.useDefaultPositionRetention = enabled;
+ if (enabled) {
+ this.positionRetentionDays = this.defaults.position_retention_days ?? this.positionRetentionDays;
+ }
+ }
+
+ applySettings(settings = {}) {
+ const has = (object, key) => Object.prototype.hasOwnProperty.call(object ?? {}, key);
+ const hasHistoryMetadata = ['event_retention_days', 'position_retention_days'].every(
+ (key) => has(settings?.preferences, key) && has(settings?.defaults, key) && has(settings?.policy, `max_${key}`)
+ );
+
+ if (!hasHistoryMetadata) {
+ throw new Error(this.intl.t('settings.telematics.settings-unavailable'));
+ }
+
+ this.eventRetentionDays = settings.event_retention_days ?? this.eventRetentionDays;
+ this.positionRetentionDays = settings.position_retention_days ?? this.positionRetentionDays;
+ this.useDefaultEventRetention = (settings.preferences?.event_retention_days ?? null) === null;
+ this.useDefaultPositionRetention = (settings.preferences?.position_retention_days ?? null) === null;
+ this.defaults = settings.defaults ?? this.defaults;
+ this.policy = settings.policy ?? this.policy;
+ }
+
+ retentionPolicyHelp(maximum) {
+ return maximum > 0 ? this.intl.t('settings.telematics.history-policy-maximum', { days: maximum }) : this.intl.t('settings.telematics.history-policy-unlimited');
+ }
+}
diff --git a/addon/extension.js b/addon/extension.js
index bcf5b4602..5aebbad9c 100644
--- a/addon/extension.js
+++ b/addon/extension.js
@@ -18,66 +18,77 @@ export default {
description: 'Everything across resources, maintenance and staffing that needs a decision today.',
icon: 'satellite-dish',
route: 'console.fleet-ops.management.index',
+ permission: 'fleet-ops list driver',
},
{
title: 'Orders',
description: 'Create, dispatch, and track delivery orders in real time.',
icon: 'boxes-stacked',
route: 'console.fleet-ops.operations.orders',
+ permission: 'fleet-ops list order',
},
{
title: 'Places',
description: 'Manage saved locations, addresses, and points of interest.',
icon: 'location-dot',
route: 'console.fleet-ops.management.places',
+ permission: 'fleet-ops list place',
},
{
title: 'Drivers',
description: 'Manage driver profiles, assignments, and live locations.',
icon: 'id-card',
route: 'console.fleet-ops.management.drivers',
+ permission: 'fleet-ops list driver',
},
{
title: 'Vehicles',
description: 'View and manage your vehicle fleet and telematics.',
icon: 'truck',
route: 'console.fleet-ops.management.vehicles',
+ permission: 'fleet-ops list vehicle',
},
{
title: intlService.t('menu.trailers'),
description: intlService.t('trailer.navigation-description'),
icon: 'trailer',
route: 'console.fleet-ops.management.trailers',
+ permission: 'fleet-ops list trailer',
},
{
title: 'Fleets',
description: 'Organise drivers and vehicles into operational fleets.',
icon: 'layer-group',
route: 'console.fleet-ops.management.fleets',
+ permission: 'fleet-ops list fleet',
},
{
title: 'Service Rates',
description: 'Configure pricing rules and service rate cards.',
icon: 'tags',
route: 'console.fleet-ops.operations.service-rates',
+ permission: 'fleet-ops list service-rate',
},
{
title: 'Devices',
description: 'Manage connected telematics devices and their sensor data.',
icon: 'microchip',
route: 'console.fleet-ops.connectivity.devices',
+ permission: 'fleet-ops list device',
},
{
title: 'Reports',
description: 'Generate and review operational analytics reports.',
icon: 'chart-bar',
route: 'console.fleet-ops.analytics.reports',
+ permission: 'iam list report',
},
{
title: 'Orchestrator',
description: 'Intelligently allocate and dispatch orders to available drivers.',
icon: 'diagram-project',
route: 'console.fleet-ops.operations.orchestrator',
+ permission: 'fleet-ops list order',
},
],
});
@@ -101,6 +112,11 @@ export default {
icon: 'location-arrow',
component: new ExtensionComponent('@fleetbase/fleetops-engine', 'admin/navigator-app'),
}),
+ new MenuItem({
+ title: 'Telematics',
+ icon: 'satellite-dish',
+ component: new ExtensionComponent('@fleetbase/fleetops-engine', 'admin/telematics-settings'),
+ }),
],
{
slug: 'fleet-ops',
@@ -161,6 +177,14 @@ export default {
},
registerWidgets(widgetService) {
+ // `order` places a default widget on the default dashboard, which mixes widgets from
+ // every extension; the grid fills rows first-fit, lowest order first (widgets without
+ // one follow). The default layout is:
+ // 10-40 KPI row: Radar, Revenue (ledger, 20), Active Orders, Drivers Online
+ // 41-44 Ledger KPI row: Expenses, Net Income, Outstanding AR, Overdue AR
+ // 50 Live Fleet Map, full width
+ // 60-80 Revenue Trend, Top Drivers, Maintenance Overview, a third of the width each
+ // 150-170 Ledger Recent Financial Activity + Cash Flow Summary | console Blog + GitHub
const widgets = [
// Legacy monolithic 13-tile widget — kept registered for one release as
// users have it pinned to existing dashboards. The new KPI tile widgets
@@ -186,7 +210,7 @@ export default {
component: new ExtensionComponent('@fleetbase/fleetops-engine', 'widget/kpi-earnings'),
grid_options: { w: 3, h: 4, minW: 3, minH: 4 },
category: 'KPI Tiles',
- default: true,
+ default: false,
}),
new Widget({
id: 'fleet-ops-kpi-aov-widget',
@@ -196,7 +220,7 @@ export default {
component: new ExtensionComponent('@fleetbase/fleetops-engine', 'widget/kpi-aov'),
grid_options: { w: 3, h: 4, minW: 3, minH: 4 },
category: 'KPI Tiles',
- default: true,
+ default: false,
}),
new Widget({
id: 'fleet-ops-kpi-distance-widget',
@@ -216,6 +240,7 @@ export default {
component: new ExtensionComponent('@fleetbase/fleetops-engine', 'widget/kpi-active-orders'),
grid_options: { w: 3, h: 4, minW: 3, minH: 4 },
category: 'KPI Tiles',
+ order: 30,
default: true,
}),
new Widget({
@@ -226,6 +251,7 @@ export default {
component: new ExtensionComponent('@fleetbase/fleetops-engine', 'widget/kpi-drivers-online'),
grid_options: { w: 3, h: 4, minW: 3, minH: 4 },
category: 'KPI Tiles',
+ order: 40,
default: true,
}),
new Widget({
@@ -236,6 +262,7 @@ export default {
component: new ExtensionComponent('@fleetbase/fleetops-engine', 'widget/radar'),
grid_options: { w: 3, h: 4, minW: 3, minH: 4 },
category: 'KPI Tiles',
+ order: 10,
default: true,
}),
new Widget({
@@ -266,8 +293,9 @@ export default {
description: 'Real-time driver positions and active routes.',
icon: 'map-location-dot',
component: new ExtensionComponent('@fleetbase/fleetops-engine', 'widget/live-fleet'),
- grid_options: { w: 8, h: 11, minW: 8, minH: 8 },
+ grid_options: { w: 12, h: 12, minW: 8, minH: 8 },
category: 'Maps',
+ order: 50,
default: true,
}),
new Widget({
@@ -276,8 +304,9 @@ export default {
description: 'Revenue over time with period comparison.',
icon: 'chart-line',
component: new ExtensionComponent('@fleetbase/fleetops-engine', 'widget/revenue-trend'),
- grid_options: { w: 4, h: 11, minW: 4, minH: 8 },
+ grid_options: { w: 4, h: 9, minW: 4, minH: 7 },
category: 'Analytics',
+ order: 60,
default: true,
}),
new Widget({
@@ -306,8 +335,9 @@ export default {
description: 'Driver leaderboard sortable by orders, on-time %, or distance.',
icon: 'medal',
component: new ExtensionComponent('@fleetbase/fleetops-engine', 'widget/top-drivers'),
- grid_options: { w: 6, h: 6, minW: 5, minH: 5 },
+ grid_options: { w: 4, h: 9, minW: 4, minH: 6 },
category: 'Analytics',
+ order: 70,
default: true,
}),
new Widget({
@@ -346,8 +376,9 @@ export default {
description: 'Overdue, scheduled, and YTD maintenance spend.',
icon: 'wrench',
component: new ExtensionComponent('@fleetbase/fleetops-engine', 'widget/maintenance-overview'),
- grid_options: { w: 6, h: 6, minW: 5, minH: 5 },
+ grid_options: { w: 4, h: 9, minW: 4, minH: 6 },
category: 'Analytics',
+ order: 80,
default: true,
}),
new Widget({
@@ -456,6 +487,7 @@ export default {
'fleet-ops:template:settings:routing',
'fleet-ops:template:settings:orchestrator',
'fleet-ops:component:admin:routing-settings',
+ 'fleet-ops:component:admin:telematics-settings',
]);
},
};
diff --git a/addon/routes.js b/addon/routes.js
index 3454717b5..9596e2885 100644
--- a/addon/routes.js
+++ b/addon/routes.js
@@ -323,6 +323,7 @@ export default buildRoutes(function () {
this.route('map');
this.route('orchestrator');
this.route('scheduling');
+ this.route('telematics');
this.route('payments', function () {
this.route('index', { path: '/' });
this.route('onboard');
diff --git a/addon/routes/analytics/index.js b/addon/routes/analytics/index.js
index 3d64374e9..7f2d927d2 100644
--- a/addon/routes/analytics/index.js
+++ b/addon/routes/analytics/index.js
@@ -1,3 +1,16 @@
import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
-export default class AnalyticsIndexRoute extends Route {}
+export default class AnalyticsIndexRoute extends Route {
+ @service notifications;
+ @service hostRouter;
+ @service abilities;
+ @service intl;
+
+ beforeModel() {
+ if (this.abilities.cannot('fleet-ops view analytics')) {
+ this.notifications.warning(this.intl.t('common.unauthorized-access'));
+ return this.hostRouter.transitionTo('console.fleet-ops');
+ }
+ }
+}
diff --git a/addon/routes/analytics/reports.js b/addon/routes/analytics/reports.js
index fdd1396cf..da2b0f72f 100644
--- a/addon/routes/analytics/reports.js
+++ b/addon/routes/analytics/reports.js
@@ -1,3 +1,16 @@
import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
-export default class AnalyticsReportsRoute extends Route {}
+export default class AnalyticsReportsRoute extends Route {
+ @service notifications;
+ @service hostRouter;
+ @service abilities;
+ @service intl;
+
+ beforeModel() {
+ if (this.abilities.cannot('iam list report')) {
+ this.notifications.warning(this.intl.t('common.unauthorized-access'));
+ return this.hostRouter.transitionTo('console.fleet-ops');
+ }
+ }
+}
diff --git a/addon/routes/analytics/reports/index/details.js b/addon/routes/analytics/reports/index/details.js
index aafa2b55a..d2d163626 100644
--- a/addon/routes/analytics/reports/index/details.js
+++ b/addon/routes/analytics/reports/index/details.js
@@ -17,7 +17,7 @@ export default class AnalyticsReportsIndexDetailsRoute extends Route {
}
beforeModel() {
- if (this.abilities.cannot('fleet-ops view report')) {
+ if (this.abilities.cannot('iam view report')) {
this.notifications.warning(this.intl.t('common.unauthorized-access'));
return this.hostRouter.transitionTo('console.fleet-ops.analytics.reports.index');
}
diff --git a/addon/routes/analytics/reports/index/edit.js b/addon/routes/analytics/reports/index/edit.js
index 4ca59d92a..99e510235 100644
--- a/addon/routes/analytics/reports/index/edit.js
+++ b/addon/routes/analytics/reports/index/edit.js
@@ -17,7 +17,7 @@ export default class AnalyticsReportsIndexEditRoute extends Route {
}
beforeModel() {
- if (this.abilities.cannot('fleet-ops update report')) {
+ if (this.abilities.cannot('iam update report')) {
this.notifications.warning(this.intl.t('common.unauthorized-access'));
return this.hostRouter.transitionTo('console.fleet-ops.analytics.reports.index');
}
diff --git a/addon/routes/analytics/reports/index/new.js b/addon/routes/analytics/reports/index/new.js
index 3bd42eb9d..88d4d9e1f 100644
--- a/addon/routes/analytics/reports/index/new.js
+++ b/addon/routes/analytics/reports/index/new.js
@@ -1,3 +1,16 @@
import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
-export default class AnalyticsReportsIndexNewRoute extends Route {}
+export default class AnalyticsReportsIndexNewRoute extends Route {
+ @service notifications;
+ @service hostRouter;
+ @service abilities;
+ @service intl;
+
+ beforeModel() {
+ if (this.abilities.cannot('iam create report')) {
+ this.notifications.warning(this.intl.t('common.unauthorized-access'));
+ return this.hostRouter.transitionTo('console.fleet-ops.analytics.reports.index');
+ }
+ }
+}
diff --git a/addon/routes/connectivity/devices.js b/addon/routes/connectivity/devices.js
index f5a216674..e1fe1539e 100644
--- a/addon/routes/connectivity/devices.js
+++ b/addon/routes/connectivity/devices.js
@@ -1,3 +1,16 @@
import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
-export default class ConnectivityDevicesRoute extends Route {}
+export default class ConnectivityDevicesRoute extends Route {
+ @service notifications;
+ @service hostRouter;
+ @service abilities;
+ @service intl;
+
+ beforeModel() {
+ if (this.abilities.cannot('fleet-ops list device')) {
+ this.notifications.warning(this.intl.t('common.unauthorized-access'));
+ return this.hostRouter.transitionTo('console.fleet-ops');
+ }
+ }
+}
diff --git a/addon/routes/connectivity/devices/index/new.js b/addon/routes/connectivity/devices/index/new.js
index 8c69bb213..450685ce7 100644
--- a/addon/routes/connectivity/devices/index/new.js
+++ b/addon/routes/connectivity/devices/index/new.js
@@ -1,3 +1,16 @@
import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
-export default class ConnectivityDevicesIndexNewRoute extends Route {}
+export default class ConnectivityDevicesIndexNewRoute extends Route {
+ @service notifications;
+ @service hostRouter;
+ @service abilities;
+ @service intl;
+
+ beforeModel() {
+ if (this.abilities.cannot('fleet-ops create device')) {
+ this.notifications.warning(this.intl.t('common.unauthorized-access'));
+ return this.hostRouter.transitionTo('console.fleet-ops.connectivity.devices.index');
+ }
+ }
+}
diff --git a/addon/routes/connectivity/events.js b/addon/routes/connectivity/events.js
index 4f6e2b6e1..aaf94573c 100644
--- a/addon/routes/connectivity/events.js
+++ b/addon/routes/connectivity/events.js
@@ -1,3 +1,16 @@
import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
-export default class ConnectivityEventsRoute extends Route {}
+export default class ConnectivityEventsRoute extends Route {
+ @service notifications;
+ @service hostRouter;
+ @service abilities;
+ @service intl;
+
+ beforeModel() {
+ if (this.abilities.cannot('fleet-ops list device-event')) {
+ this.notifications.warning(this.intl.t('common.unauthorized-access'));
+ return this.hostRouter.transitionTo('console.fleet-ops');
+ }
+ }
+}
diff --git a/addon/routes/connectivity/fuel-providers.js b/addon/routes/connectivity/fuel-providers.js
index 0d9c95fc7..95897b8f8 100644
--- a/addon/routes/connectivity/fuel-providers.js
+++ b/addon/routes/connectivity/fuel-providers.js
@@ -1,3 +1,16 @@
import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
-export default class ConnectivityFuelProvidersRoute extends Route {}
+export default class ConnectivityFuelProvidersRoute extends Route {
+ @service notifications;
+ @service hostRouter;
+ @service abilities;
+ @service intl;
+
+ beforeModel() {
+ if (this.abilities.cannot('fleet-ops list fuel-provider-connection')) {
+ this.notifications.warning(this.intl.t('common.unauthorized-access'));
+ return this.hostRouter.transitionTo('console.fleet-ops');
+ }
+ }
+}
diff --git a/addon/routes/connectivity/fuel-providers/details.js b/addon/routes/connectivity/fuel-providers/details.js
index 04be2351a..f974a05c8 100644
--- a/addon/routes/connectivity/fuel-providers/details.js
+++ b/addon/routes/connectivity/fuel-providers/details.js
@@ -6,6 +6,15 @@ export default class ConnectivityFuelProvidersIndexDetailsRoute extends Route {
@service store;
@service notifications;
@service hostRouter;
+ @service abilities;
+ @service intl;
+
+ beforeModel() {
+ if (this.abilities.cannot('fleet-ops view fuel-provider-connection')) {
+ this.notifications.warning(this.intl.t('common.unauthorized-access'));
+ return this.hostRouter.transitionTo('console.fleet-ops.connectivity.fuel-providers.index');
+ }
+ }
setupController(controller, model) {
super.setupController(controller, model);
diff --git a/addon/routes/connectivity/fuel-providers/details/transactions.js b/addon/routes/connectivity/fuel-providers/details/transactions.js
index 6a457461f..0addb38f7 100644
--- a/addon/routes/connectivity/fuel-providers/details/transactions.js
+++ b/addon/routes/connectivity/fuel-providers/details/transactions.js
@@ -4,6 +4,18 @@ import { action } from '@ember/object';
export default class FuelIntegrationTransactionsRoute extends Route {
@service store;
+ @service notifications;
+ @service hostRouter;
+ @service abilities;
+ @service intl;
+
+ beforeModel() {
+ if (this.abilities.cannot('fleet-ops list fuel-provider-transaction')) {
+ this.notifications.warning(this.intl.t('common.unauthorized-access'));
+ return this.hostRouter.transitionTo('console.fleet-ops.connectivity.fuel-providers.index');
+ }
+ }
+
queryParams = Object.fromEntries(['page', 'limit', 'sort', 'query', 'sync_status', 'vehicle', 'transaction_at'].map((key) => [key, { refreshModel: true }]));
model(params) {
const connection = this.modelFor('connectivity.fuel-providers.details');
diff --git a/addon/routes/connectivity/fuel-providers/edit.js b/addon/routes/connectivity/fuel-providers/edit.js
index 8d72929c4..478c81e75 100644
--- a/addon/routes/connectivity/fuel-providers/edit.js
+++ b/addon/routes/connectivity/fuel-providers/edit.js
@@ -6,6 +6,15 @@ export default class ConnectivityFuelProvidersIndexEditRoute extends Route {
@service store;
@service notifications;
@service hostRouter;
+ @service abilities;
+ @service intl;
+
+ beforeModel() {
+ if (this.abilities.cannot('fleet-ops update fuel-provider-connection')) {
+ this.notifications.warning(this.intl.t('common.unauthorized-access'));
+ return this.hostRouter.transitionTo('console.fleet-ops.connectivity.fuel-providers.index');
+ }
+ }
@action error(error) {
this.notifications.serverError(error);
diff --git a/addon/routes/connectivity/fuel-providers/new.js b/addon/routes/connectivity/fuel-providers/new.js
index b3605cce0..f20b9af67 100644
--- a/addon/routes/connectivity/fuel-providers/new.js
+++ b/addon/routes/connectivity/fuel-providers/new.js
@@ -1,3 +1,16 @@
import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
-export default class ConnectivityFuelProvidersIndexNewRoute extends Route {}
+export default class ConnectivityFuelProvidersIndexNewRoute extends Route {
+ @service notifications;
+ @service hostRouter;
+ @service abilities;
+ @service intl;
+
+ beforeModel() {
+ if (this.abilities.cannot('fleet-ops create fuel-provider-connection')) {
+ this.notifications.warning(this.intl.t('common.unauthorized-access'));
+ return this.hostRouter.transitionTo('console.fleet-ops.connectivity.fuel-providers.index');
+ }
+ }
+}
diff --git a/addon/routes/connectivity/sensors.js b/addon/routes/connectivity/sensors.js
index f3bc70a81..9bc86da27 100644
--- a/addon/routes/connectivity/sensors.js
+++ b/addon/routes/connectivity/sensors.js
@@ -1,3 +1,16 @@
import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
-export default class ConnectivitySensorsRoute extends Route {}
+export default class ConnectivitySensorsRoute extends Route {
+ @service notifications;
+ @service hostRouter;
+ @service abilities;
+ @service intl;
+
+ beforeModel() {
+ if (this.abilities.cannot('fleet-ops list sensor')) {
+ this.notifications.warning(this.intl.t('common.unauthorized-access'));
+ return this.hostRouter.transitionTo('console.fleet-ops');
+ }
+ }
+}
diff --git a/addon/routes/connectivity/sensors/index/new.js b/addon/routes/connectivity/sensors/index/new.js
index 836d72730..d23d3cafe 100644
--- a/addon/routes/connectivity/sensors/index/new.js
+++ b/addon/routes/connectivity/sensors/index/new.js
@@ -1,3 +1,16 @@
import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
-export default class ConnectivitySensorsIndexNewRoute extends Route {}
+export default class ConnectivitySensorsIndexNewRoute extends Route {
+ @service notifications;
+ @service hostRouter;
+ @service abilities;
+ @service intl;
+
+ beforeModel() {
+ if (this.abilities.cannot('fleet-ops create sensor')) {
+ this.notifications.warning(this.intl.t('common.unauthorized-access'));
+ return this.hostRouter.transitionTo('console.fleet-ops.connectivity.sensors.index');
+ }
+ }
+}
diff --git a/addon/routes/connectivity/telematics.js b/addon/routes/connectivity/telematics.js
index 68fd97c7c..39e33321e 100644
--- a/addon/routes/connectivity/telematics.js
+++ b/addon/routes/connectivity/telematics.js
@@ -1,3 +1,16 @@
import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
-export default class ConnectivityTelematicsRoute extends Route {}
+export default class ConnectivityTelematicsRoute extends Route {
+ @service notifications;
+ @service hostRouter;
+ @service abilities;
+ @service intl;
+
+ beforeModel() {
+ if (this.abilities.cannot('fleet-ops list telematic')) {
+ this.notifications.warning(this.intl.t('common.unauthorized-access'));
+ return this.hostRouter.transitionTo('console.fleet-ops');
+ }
+ }
+}
diff --git a/addon/routes/connectivity/telematics/new.js b/addon/routes/connectivity/telematics/new.js
index f993f7b19..0c6d68dd9 100644
--- a/addon/routes/connectivity/telematics/new.js
+++ b/addon/routes/connectivity/telematics/new.js
@@ -1,3 +1,16 @@
import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
-export default class ConnectivityTelematicsNewRoute extends Route {}
+export default class ConnectivityTelematicsNewRoute extends Route {
+ @service notifications;
+ @service hostRouter;
+ @service abilities;
+ @service intl;
+
+ beforeModel() {
+ if (this.abilities.cannot('fleet-ops create telematic')) {
+ this.notifications.warning(this.intl.t('common.unauthorized-access'));
+ return this.hostRouter.transitionTo('console.fleet-ops.connectivity.telematics.index');
+ }
+ }
+}
diff --git a/addon/routes/connectivity/tracking.js b/addon/routes/connectivity/tracking.js
index 81d44b954..de24cdf6f 100644
--- a/addon/routes/connectivity/tracking.js
+++ b/addon/routes/connectivity/tracking.js
@@ -1,3 +1,16 @@
import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
-export default class ConnectivityTrackingRoute extends Route {}
+export default class ConnectivityTrackingRoute extends Route {
+ @service notifications;
+ @service hostRouter;
+ @service abilities;
+ @service intl;
+
+ beforeModel() {
+ if (this.abilities.cannot('fleet-ops list vehicle')) {
+ this.notifications.warning(this.intl.t('common.unauthorized-access'));
+ return this.hostRouter.transitionTo('console.fleet-ops');
+ }
+ }
+}
diff --git a/addon/routes/maintenance/equipment.js b/addon/routes/maintenance/equipment.js
index 3a5bb687a..cec17bfb6 100644
--- a/addon/routes/maintenance/equipment.js
+++ b/addon/routes/maintenance/equipment.js
@@ -1,3 +1,16 @@
import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
-export default class MaintenanceEquipmentRoute extends Route {}
+export default class MaintenanceEquipmentRoute extends Route {
+ @service notifications;
+ @service hostRouter;
+ @service abilities;
+ @service intl;
+
+ beforeModel() {
+ if (this.abilities.cannot('fleet-ops list equipment')) {
+ this.notifications.warning(this.intl.t('common.unauthorized-access'));
+ return this.hostRouter.transitionTo('console.fleet-ops');
+ }
+ }
+}
diff --git a/addon/routes/maintenance/equipment/index/details.js b/addon/routes/maintenance/equipment/index/details.js
index 43035ca49..7f8efa9b7 100644
--- a/addon/routes/maintenance/equipment/index/details.js
+++ b/addon/routes/maintenance/equipment/index/details.js
@@ -12,14 +12,14 @@ export default class MaintenanceEquipmentIndexDetailsRoute extends Route {
@action error(error) {
this.notifications.serverError(error);
if (typeof error.message === 'string' && error.message.endsWith('not found')) {
- return this.hostRouter.transitionTo('maintenance.equipment.index');
+ return this.hostRouter.transitionTo('console.fleet-ops.maintenance.equipment.index');
}
}
beforeModel() {
if (this.abilities.cannot('fleet-ops view equipment')) {
this.notifications.warning(this.intl.t('common.unauthorized-access'));
- return this.hostRouter.transitionTo('maintenance.equipment.index');
+ return this.hostRouter.transitionTo('console.fleet-ops.maintenance.equipment.index');
}
}
diff --git a/addon/routes/maintenance/equipment/index/edit.js b/addon/routes/maintenance/equipment/index/edit.js
index 136ea947c..51e06996b 100644
--- a/addon/routes/maintenance/equipment/index/edit.js
+++ b/addon/routes/maintenance/equipment/index/edit.js
@@ -12,14 +12,14 @@ export default class MaintenanceEquipmentIndexEditRoute extends Route {
@action error(error) {
this.notifications.serverError(error);
if (typeof error.message === 'string' && error.message.endsWith('not found')) {
- return this.hostRouter.transitionTo('maintenance.equipment.index');
+ return this.hostRouter.transitionTo('console.fleet-ops.maintenance.equipment.index');
}
}
beforeModel() {
if (this.abilities.cannot('fleet-ops update equipment')) {
this.notifications.warning(this.intl.t('common.unauthorized-access'));
- return this.hostRouter.transitionTo('maintenance.equipment.index');
+ return this.hostRouter.transitionTo('console.fleet-ops.maintenance.equipment.index');
}
}
diff --git a/addon/routes/maintenance/equipment/index/new.js b/addon/routes/maintenance/equipment/index/new.js
index 965997967..aee969df0 100644
--- a/addon/routes/maintenance/equipment/index/new.js
+++ b/addon/routes/maintenance/equipment/index/new.js
@@ -1,3 +1,16 @@
import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
-export default class MaintenanceEquipmentIndexNewRoute extends Route {}
+export default class MaintenanceEquipmentIndexNewRoute extends Route {
+ @service notifications;
+ @service hostRouter;
+ @service abilities;
+ @service intl;
+
+ beforeModel() {
+ if (this.abilities.cannot('fleet-ops create equipment')) {
+ this.notifications.warning(this.intl.t('common.unauthorized-access'));
+ return this.hostRouter.transitionTo('console.fleet-ops.maintenance.equipment.index');
+ }
+ }
+}
diff --git a/addon/routes/maintenance/index.js b/addon/routes/maintenance/index.js
index 04e2df618..767e3e292 100644
--- a/addon/routes/maintenance/index.js
+++ b/addon/routes/maintenance/index.js
@@ -1,3 +1,16 @@
import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
-export default class MaintenanceIndexRoute extends Route {}
+export default class MaintenanceIndexRoute extends Route {
+ @service notifications;
+ @service hostRouter;
+ @service abilities;
+ @service intl;
+
+ beforeModel() {
+ if (this.abilities.cannot('fleet-ops list work-order')) {
+ this.notifications.warning(this.intl.t('common.unauthorized-access'));
+ return this.hostRouter.transitionTo('console.fleet-ops');
+ }
+ }
+}
diff --git a/addon/routes/maintenance/inspection-forms.js b/addon/routes/maintenance/inspection-forms.js
index 97a8c467a..b427120f6 100644
--- a/addon/routes/maintenance/inspection-forms.js
+++ b/addon/routes/maintenance/inspection-forms.js
@@ -1,3 +1,16 @@
import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
-export default class MaintenanceInspectionFormsRoute extends Route {}
+export default class MaintenanceInspectionFormsRoute extends Route {
+ @service notifications;
+ @service hostRouter;
+ @service abilities;
+ @service intl;
+
+ beforeModel() {
+ if (this.abilities.cannot('fleet-ops list inspection-form')) {
+ this.notifications.warning(this.intl.t('common.unauthorized-access'));
+ return this.hostRouter.transitionTo('console.fleet-ops');
+ }
+ }
+}
diff --git a/addon/routes/maintenance/inspection-forms/index/details.js b/addon/routes/maintenance/inspection-forms/index/details.js
index 202e83766..25f0e46b6 100644
--- a/addon/routes/maintenance/inspection-forms/index/details.js
+++ b/addon/routes/maintenance/inspection-forms/index/details.js
@@ -6,6 +6,15 @@ export default class MaintenanceInspectionFormsIndexDetailsRoute extends Route {
@service store;
@service hostRouter;
@service notifications;
+ @service abilities;
+ @service intl;
+
+ beforeModel() {
+ if (this.abilities.cannot('fleet-ops view inspection-form')) {
+ this.notifications.warning(this.intl.t('common.unauthorized-access'));
+ return this.hostRouter.transitionTo('console.fleet-ops.maintenance.inspection-forms.index');
+ }
+ }
model({ public_id }) {
return this.store.findRecord('inspection-form', public_id);
@@ -13,6 +22,6 @@ export default class MaintenanceInspectionFormsIndexDetailsRoute extends Route {
@action error(error) {
this.notifications.serverError(error);
- return this.hostRouter.transitionTo('maintenance.inspection-forms.index');
+ return this.hostRouter.transitionTo('console.fleet-ops.maintenance.inspection-forms.index');
}
}
diff --git a/addon/routes/maintenance/inspection-forms/index/edit.js b/addon/routes/maintenance/inspection-forms/index/edit.js
index f8b15d6dd..531d9819c 100644
--- a/addon/routes/maintenance/inspection-forms/index/edit.js
+++ b/addon/routes/maintenance/inspection-forms/index/edit.js
@@ -6,6 +6,15 @@ export default class MaintenanceInspectionFormsIndexEditRoute extends Route {
@service store;
@service hostRouter;
@service notifications;
+ @service abilities;
+ @service intl;
+
+ beforeModel() {
+ if (this.abilities.cannot('fleet-ops update inspection-form')) {
+ this.notifications.warning(this.intl.t('common.unauthorized-access'));
+ return this.hostRouter.transitionTo('console.fleet-ops.maintenance.inspection-forms.index');
+ }
+ }
model({ public_id }) {
return this.store.findRecord('inspection-form', public_id);
@@ -13,6 +22,6 @@ export default class MaintenanceInspectionFormsIndexEditRoute extends Route {
@action error(error) {
this.notifications.serverError(error);
- return this.hostRouter.transitionTo('maintenance.inspection-forms.index');
+ return this.hostRouter.transitionTo('console.fleet-ops.maintenance.inspection-forms.index');
}
}
diff --git a/addon/routes/maintenance/inspection-forms/index/new.js b/addon/routes/maintenance/inspection-forms/index/new.js
index 96a813892..b4867f549 100644
--- a/addon/routes/maintenance/inspection-forms/index/new.js
+++ b/addon/routes/maintenance/inspection-forms/index/new.js
@@ -1,3 +1,16 @@
import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
-export default class MaintenanceInspectionFormsIndexNewRoute extends Route {}
+export default class MaintenanceInspectionFormsIndexNewRoute extends Route {
+ @service notifications;
+ @service hostRouter;
+ @service abilities;
+ @service intl;
+
+ beforeModel() {
+ if (this.abilities.cannot('fleet-ops create inspection-form')) {
+ this.notifications.warning(this.intl.t('common.unauthorized-access'));
+ return this.hostRouter.transitionTo('console.fleet-ops.maintenance.inspection-forms.index');
+ }
+ }
+}
diff --git a/addon/routes/maintenance/inspection-submissions.js b/addon/routes/maintenance/inspection-submissions.js
index 73b235dce..028acfb78 100644
--- a/addon/routes/maintenance/inspection-submissions.js
+++ b/addon/routes/maintenance/inspection-submissions.js
@@ -1,3 +1,16 @@
import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
-export default class MaintenanceInspectionSubmissionsRoute extends Route {}
+export default class MaintenanceInspectionSubmissionsRoute extends Route {
+ @service notifications;
+ @service hostRouter;
+ @service abilities;
+ @service intl;
+
+ beforeModel() {
+ if (this.abilities.cannot('fleet-ops list inspection-submission')) {
+ this.notifications.warning(this.intl.t('common.unauthorized-access'));
+ return this.hostRouter.transitionTo('console.fleet-ops');
+ }
+ }
+}
diff --git a/addon/routes/maintenance/inspection-submissions/index/details.js b/addon/routes/maintenance/inspection-submissions/index/details.js
index ebb148013..faa1cddbd 100644
--- a/addon/routes/maintenance/inspection-submissions/index/details.js
+++ b/addon/routes/maintenance/inspection-submissions/index/details.js
@@ -6,6 +6,15 @@ export default class MaintenanceInspectionSubmissionsIndexDetailsRoute extends R
@service store;
@service hostRouter;
@service notifications;
+ @service abilities;
+ @service intl;
+
+ beforeModel() {
+ if (this.abilities.cannot('fleet-ops view inspection-submission')) {
+ this.notifications.warning(this.intl.t('common.unauthorized-access'));
+ return this.hostRouter.transitionTo('console.fleet-ops.maintenance.inspection-submissions.index');
+ }
+ }
model({ public_id }) {
return this.store.findRecord('inspection-submission', public_id);
@@ -13,6 +22,6 @@ export default class MaintenanceInspectionSubmissionsIndexDetailsRoute extends R
@action error(error) {
this.notifications.serverError(error);
- return this.hostRouter.transitionTo('maintenance.inspection-submissions.index');
+ return this.hostRouter.transitionTo('console.fleet-ops.maintenance.inspection-submissions.index');
}
}
diff --git a/addon/routes/maintenance/inspection-submissions/index/edit.js b/addon/routes/maintenance/inspection-submissions/index/edit.js
index 9e6f0bb2f..424b900eb 100644
--- a/addon/routes/maintenance/inspection-submissions/index/edit.js
+++ b/addon/routes/maintenance/inspection-submissions/index/edit.js
@@ -6,6 +6,15 @@ export default class MaintenanceInspectionSubmissionsIndexEditRoute extends Rout
@service store;
@service hostRouter;
@service notifications;
+ @service abilities;
+ @service intl;
+
+ beforeModel() {
+ if (this.abilities.cannot('fleet-ops update inspection-submission')) {
+ this.notifications.warning(this.intl.t('common.unauthorized-access'));
+ return this.hostRouter.transitionTo('console.fleet-ops.maintenance.inspection-submissions.index');
+ }
+ }
model({ public_id }) {
return this.store.findRecord('inspection-submission', public_id);
@@ -13,6 +22,6 @@ export default class MaintenanceInspectionSubmissionsIndexEditRoute extends Rout
@action error(error) {
this.notifications.serverError(error);
- return this.hostRouter.transitionTo('maintenance.inspection-submissions.index');
+ return this.hostRouter.transitionTo('console.fleet-ops.maintenance.inspection-submissions.index');
}
}
diff --git a/addon/routes/maintenance/inspection-submissions/index/new.js b/addon/routes/maintenance/inspection-submissions/index/new.js
index f5c57fe09..c27872ab3 100644
--- a/addon/routes/maintenance/inspection-submissions/index/new.js
+++ b/addon/routes/maintenance/inspection-submissions/index/new.js
@@ -1,3 +1,16 @@
import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
-export default class MaintenanceInspectionSubmissionsIndexNewRoute extends Route {}
+export default class MaintenanceInspectionSubmissionsIndexNewRoute extends Route {
+ @service notifications;
+ @service hostRouter;
+ @service abilities;
+ @service intl;
+
+ beforeModel() {
+ if (this.abilities.cannot('fleet-ops create inspection-submission')) {
+ this.notifications.warning(this.intl.t('common.unauthorized-access'));
+ return this.hostRouter.transitionTo('console.fleet-ops.maintenance.inspection-submissions.index');
+ }
+ }
+}
diff --git a/addon/routes/maintenance/maintenances.js b/addon/routes/maintenance/maintenances.js
index c38f607a0..67e4ade75 100644
--- a/addon/routes/maintenance/maintenances.js
+++ b/addon/routes/maintenance/maintenances.js
@@ -1,3 +1,16 @@
import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
-export default class MaintenanceMaintenancesRoute extends Route {}
+export default class MaintenanceMaintenancesRoute extends Route {
+ @service notifications;
+ @service hostRouter;
+ @service abilities;
+ @service intl;
+
+ beforeModel() {
+ if (this.abilities.cannot('fleet-ops list maintenance')) {
+ this.notifications.warning(this.intl.t('common.unauthorized-access'));
+ return this.hostRouter.transitionTo('console.fleet-ops');
+ }
+ }
+}
diff --git a/addon/routes/maintenance/maintenances/index/details.js b/addon/routes/maintenance/maintenances/index/details.js
index 564b2777a..8bdbd46bc 100644
--- a/addon/routes/maintenance/maintenances/index/details.js
+++ b/addon/routes/maintenance/maintenances/index/details.js
@@ -12,14 +12,14 @@ export default class MaintenanceMaintenancesIndexDetailsRoute extends Route {
@action error(error) {
this.notifications.serverError(error);
if (typeof error.message === 'string' && error.message.endsWith('not found')) {
- return this.hostRouter.transitionTo('maintenance.maintenances.index');
+ return this.hostRouter.transitionTo('console.fleet-ops.maintenance.maintenances.index');
}
}
beforeModel() {
if (this.abilities.cannot('fleet-ops view maintenance')) {
this.notifications.warning(this.intl.t('common.unauthorized-access'));
- return this.hostRouter.transitionTo('maintenance.maintenances.index');
+ return this.hostRouter.transitionTo('console.fleet-ops.maintenance.maintenances.index');
}
}
diff --git a/addon/routes/maintenance/maintenances/index/edit.js b/addon/routes/maintenance/maintenances/index/edit.js
index 5c81bd470..ec801d142 100644
--- a/addon/routes/maintenance/maintenances/index/edit.js
+++ b/addon/routes/maintenance/maintenances/index/edit.js
@@ -12,14 +12,14 @@ export default class MaintenanceMaintenancesIndexEditRoute extends Route {
@action error(error) {
this.notifications.serverError(error);
if (typeof error.message === 'string' && error.message.endsWith('not found')) {
- return this.hostRouter.transitionTo('maintenance.maintenances.index');
+ return this.hostRouter.transitionTo('console.fleet-ops.maintenance.maintenances.index');
}
}
beforeModel() {
if (this.abilities.cannot('fleet-ops update maintenance')) {
this.notifications.warning(this.intl.t('common.unauthorized-access'));
- return this.hostRouter.transitionTo('maintenance.maintenances.index');
+ return this.hostRouter.transitionTo('console.fleet-ops.maintenance.maintenances.index');
}
}
diff --git a/addon/routes/maintenance/maintenances/index/new.js b/addon/routes/maintenance/maintenances/index/new.js
index 8bcba94e0..5a5e18f24 100644
--- a/addon/routes/maintenance/maintenances/index/new.js
+++ b/addon/routes/maintenance/maintenances/index/new.js
@@ -1,3 +1,16 @@
import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
-export default class MaintenanceMaintenancesIndexNewRoute extends Route {}
+export default class MaintenanceMaintenancesIndexNewRoute extends Route {
+ @service notifications;
+ @service hostRouter;
+ @service abilities;
+ @service intl;
+
+ beforeModel() {
+ if (this.abilities.cannot('fleet-ops create maintenance')) {
+ this.notifications.warning(this.intl.t('common.unauthorized-access'));
+ return this.hostRouter.transitionTo('console.fleet-ops.maintenance.maintenances.index');
+ }
+ }
+}
diff --git a/addon/routes/maintenance/parts.js b/addon/routes/maintenance/parts.js
index 4d1a7132e..5850590f6 100644
--- a/addon/routes/maintenance/parts.js
+++ b/addon/routes/maintenance/parts.js
@@ -1,3 +1,16 @@
import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
-export default class MaintenancePartsRoute extends Route {}
+export default class MaintenancePartsRoute extends Route {
+ @service notifications;
+ @service hostRouter;
+ @service abilities;
+ @service intl;
+
+ beforeModel() {
+ if (this.abilities.cannot('fleet-ops list part')) {
+ this.notifications.warning(this.intl.t('common.unauthorized-access'));
+ return this.hostRouter.transitionTo('console.fleet-ops');
+ }
+ }
+}
diff --git a/addon/routes/maintenance/parts/index/details.js b/addon/routes/maintenance/parts/index/details.js
index 9b27d7a72..5a311fb5c 100644
--- a/addon/routes/maintenance/parts/index/details.js
+++ b/addon/routes/maintenance/parts/index/details.js
@@ -12,14 +12,14 @@ export default class MaintenancePartsIndexDetailsRoute extends Route {
@action error(error) {
this.notifications.serverError(error);
if (typeof error.message === 'string' && error.message.endsWith('not found')) {
- return this.hostRouter.transitionTo('maintenance.parts.index');
+ return this.hostRouter.transitionTo('console.fleet-ops.maintenance.parts.index');
}
}
beforeModel() {
if (this.abilities.cannot('fleet-ops view part')) {
this.notifications.warning(this.intl.t('common.unauthorized-access'));
- return this.hostRouter.transitionTo('maintenance.parts.index');
+ return this.hostRouter.transitionTo('console.fleet-ops.maintenance.parts.index');
}
}
diff --git a/addon/routes/maintenance/parts/index/edit.js b/addon/routes/maintenance/parts/index/edit.js
index 0ee51d4a5..18c9c8bbb 100644
--- a/addon/routes/maintenance/parts/index/edit.js
+++ b/addon/routes/maintenance/parts/index/edit.js
@@ -12,14 +12,14 @@ export default class MaintenancePartsIndexEditRoute extends Route {
@action error(error) {
this.notifications.serverError(error);
if (typeof error.message === 'string' && error.message.endsWith('not found')) {
- return this.hostRouter.transitionTo('maintenance.parts.index');
+ return this.hostRouter.transitionTo('console.fleet-ops.maintenance.parts.index');
}
}
beforeModel() {
if (this.abilities.cannot('fleet-ops update part')) {
this.notifications.warning(this.intl.t('common.unauthorized-access'));
- return this.hostRouter.transitionTo('maintenance.parts.index');
+ return this.hostRouter.transitionTo('console.fleet-ops.maintenance.parts.index');
}
}
diff --git a/addon/routes/maintenance/parts/index/new.js b/addon/routes/maintenance/parts/index/new.js
index 667dd94d8..022d147ee 100644
--- a/addon/routes/maintenance/parts/index/new.js
+++ b/addon/routes/maintenance/parts/index/new.js
@@ -1,3 +1,16 @@
import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
-export default class MaintenancePartsIndexNewRoute extends Route {}
+export default class MaintenancePartsIndexNewRoute extends Route {
+ @service notifications;
+ @service hostRouter;
+ @service abilities;
+ @service intl;
+
+ beforeModel() {
+ if (this.abilities.cannot('fleet-ops create part')) {
+ this.notifications.warning(this.intl.t('common.unauthorized-access'));
+ return this.hostRouter.transitionTo('console.fleet-ops.maintenance.parts.index');
+ }
+ }
+}
diff --git a/addon/routes/maintenance/schedules.js b/addon/routes/maintenance/schedules.js
index bcc5af8f2..4de37c319 100644
--- a/addon/routes/maintenance/schedules.js
+++ b/addon/routes/maintenance/schedules.js
@@ -1,2 +1,16 @@
import Route from '@ember/routing/route';
-export default class MaintenanceSchedulesRoute extends Route {}
+import { inject as service } from '@ember/service';
+
+export default class MaintenanceSchedulesRoute extends Route {
+ @service notifications;
+ @service hostRouter;
+ @service abilities;
+ @service intl;
+
+ beforeModel() {
+ if (this.abilities.cannot('fleet-ops list maintenance-schedule')) {
+ this.notifications.warning(this.intl.t('common.unauthorized-access'));
+ return this.hostRouter.transitionTo('console.fleet-ops');
+ }
+ }
+}
diff --git a/addon/routes/maintenance/schedules/index/details.js b/addon/routes/maintenance/schedules/index/details.js
index a222442e8..45048a427 100644
--- a/addon/routes/maintenance/schedules/index/details.js
+++ b/addon/routes/maintenance/schedules/index/details.js
@@ -10,13 +10,13 @@ export default class MaintenanceSchedulesIndexDetailsRoute extends Route {
@action error(error) {
this.notifications.serverError(error);
if (typeof error.message === 'string' && error.message.endsWith('not found')) {
- return this.hostRouter.transitionTo('maintenance.schedules.index');
+ return this.hostRouter.transitionTo('console.fleet-ops.maintenance.schedules.index');
}
}
beforeModel() {
if (this.abilities.cannot('fleet-ops view maintenance-schedule')) {
this.notifications.warning(this.intl.t('common.unauthorized-access'));
- return this.hostRouter.transitionTo('maintenance.schedules.index');
+ return this.hostRouter.transitionTo('console.fleet-ops.maintenance.schedules.index');
}
}
model({ public_id }) {
diff --git a/addon/routes/maintenance/schedules/index/edit.js b/addon/routes/maintenance/schedules/index/edit.js
index 65d160fdb..44023fc6e 100644
--- a/addon/routes/maintenance/schedules/index/edit.js
+++ b/addon/routes/maintenance/schedules/index/edit.js
@@ -10,13 +10,13 @@ export default class MaintenanceSchedulesIndexEditRoute extends Route {
@action error(error) {
this.notifications.serverError(error);
if (typeof error.message === 'string' && error.message.endsWith('not found')) {
- return this.hostRouter.transitionTo('maintenance.schedules.index');
+ return this.hostRouter.transitionTo('console.fleet-ops.maintenance.schedules.index');
}
}
beforeModel() {
if (this.abilities.cannot('fleet-ops update maintenance-schedule')) {
this.notifications.warning(this.intl.t('common.unauthorized-access'));
- return this.hostRouter.transitionTo('maintenance.schedules.index');
+ return this.hostRouter.transitionTo('console.fleet-ops.maintenance.schedules.index');
}
}
model({ public_id }) {
diff --git a/addon/routes/maintenance/schedules/index/new.js b/addon/routes/maintenance/schedules/index/new.js
index f0ce7286b..8ed97541c 100644
--- a/addon/routes/maintenance/schedules/index/new.js
+++ b/addon/routes/maintenance/schedules/index/new.js
@@ -1,2 +1,16 @@
import Route from '@ember/routing/route';
-export default class MaintenanceSchedulesIndexNewRoute extends Route {}
+import { inject as service } from '@ember/service';
+
+export default class MaintenanceSchedulesIndexNewRoute extends Route {
+ @service notifications;
+ @service hostRouter;
+ @service abilities;
+ @service intl;
+
+ beforeModel() {
+ if (this.abilities.cannot('fleet-ops create maintenance-schedule')) {
+ this.notifications.warning(this.intl.t('common.unauthorized-access'));
+ return this.hostRouter.transitionTo('console.fleet-ops.maintenance.schedules.index');
+ }
+ }
+}
diff --git a/addon/routes/maintenance/work-orders.js b/addon/routes/maintenance/work-orders.js
index 13587f950..b9e40da18 100644
--- a/addon/routes/maintenance/work-orders.js
+++ b/addon/routes/maintenance/work-orders.js
@@ -1,3 +1,16 @@
import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
-export default class MaintenanceWorkOrdersRoute extends Route {}
+export default class MaintenanceWorkOrdersRoute extends Route {
+ @service notifications;
+ @service hostRouter;
+ @service abilities;
+ @service intl;
+
+ beforeModel() {
+ if (this.abilities.cannot('fleet-ops list work-order')) {
+ this.notifications.warning(this.intl.t('common.unauthorized-access'));
+ return this.hostRouter.transitionTo('console.fleet-ops');
+ }
+ }
+}
diff --git a/addon/routes/maintenance/work-orders/index/details.js b/addon/routes/maintenance/work-orders/index/details.js
index 6cbee3e24..52738d3f2 100644
--- a/addon/routes/maintenance/work-orders/index/details.js
+++ b/addon/routes/maintenance/work-orders/index/details.js
@@ -12,14 +12,14 @@ export default class MaintenanceWorkOrdersIndexDetailsRoute extends Route {
@action error(error) {
this.notifications.serverError(error);
if (typeof error.message === 'string' && error.message.endsWith('not found')) {
- return this.hostRouter.transitionTo('maintenance.work-orders.index');
+ return this.hostRouter.transitionTo('console.fleet-ops.maintenance.work-orders.index');
}
}
beforeModel() {
if (this.abilities.cannot('fleet-ops view work-order')) {
this.notifications.warning(this.intl.t('common.unauthorized-access'));
- return this.hostRouter.transitionTo('maintenance.work-orders.index');
+ return this.hostRouter.transitionTo('console.fleet-ops.maintenance.work-orders.index');
}
}
diff --git a/addon/routes/maintenance/work-orders/index/edit.js b/addon/routes/maintenance/work-orders/index/edit.js
index b5a3c8899..1ccfe4bdd 100644
--- a/addon/routes/maintenance/work-orders/index/edit.js
+++ b/addon/routes/maintenance/work-orders/index/edit.js
@@ -12,14 +12,14 @@ export default class MaintenanceWorkOrdersIndexEditRoute extends Route {
@action error(error) {
this.notifications.serverError(error);
if (typeof error.message === 'string' && error.message.endsWith('not found')) {
- return this.hostRouter.transitionTo('maintenance.work-orders.index');
+ return this.hostRouter.transitionTo('console.fleet-ops.maintenance.work-orders.index');
}
}
beforeModel() {
if (this.abilities.cannot('fleet-ops update work-order')) {
this.notifications.warning(this.intl.t('common.unauthorized-access'));
- return this.hostRouter.transitionTo('maintenance.work-orders.index');
+ return this.hostRouter.transitionTo('console.fleet-ops.maintenance.work-orders.index');
}
}
diff --git a/addon/routes/maintenance/work-orders/index/new.js b/addon/routes/maintenance/work-orders/index/new.js
index 7757b0a10..fbe3da463 100644
--- a/addon/routes/maintenance/work-orders/index/new.js
+++ b/addon/routes/maintenance/work-orders/index/new.js
@@ -1,3 +1,16 @@
import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
-export default class MaintenanceWorkOrdersIndexNewRoute extends Route {}
+export default class MaintenanceWorkOrdersIndexNewRoute extends Route {
+ @service notifications;
+ @service hostRouter;
+ @service abilities;
+ @service intl;
+
+ beforeModel() {
+ if (this.abilities.cannot('fleet-ops create work-order')) {
+ this.notifications.warning(this.intl.t('common.unauthorized-access'));
+ return this.hostRouter.transitionTo('console.fleet-ops.maintenance.work-orders.index');
+ }
+ }
+}
diff --git a/addon/routes/management/fuel-transactions.js b/addon/routes/management/fuel-transactions.js
index 4f0de54b6..c463fb850 100644
--- a/addon/routes/management/fuel-transactions.js
+++ b/addon/routes/management/fuel-transactions.js
@@ -1,3 +1,16 @@
import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
-export default class ManagementFuelTransactionsRoute extends Route {}
+export default class ManagementFuelTransactionsRoute extends Route {
+ @service notifications;
+ @service hostRouter;
+ @service abilities;
+ @service intl;
+
+ beforeModel() {
+ if (this.abilities.cannot('fleet-ops list fuel-provider-transaction')) {
+ this.notifications.warning(this.intl.t('common.unauthorized-access'));
+ return this.hostRouter.transitionTo('console.fleet-ops');
+ }
+ }
+}
diff --git a/addon/routes/management/fuel-transactions/index/details.js b/addon/routes/management/fuel-transactions/index/details.js
index 05d202d42..b251e2e57 100644
--- a/addon/routes/management/fuel-transactions/index/details.js
+++ b/addon/routes/management/fuel-transactions/index/details.js
@@ -7,6 +7,15 @@ export default class ManagementFuelTransactionsIndexDetailsRoute extends Route {
@service store;
@service notifications;
@service hostRouter;
+ @service abilities;
+ @service intl;
+
+ beforeModel() {
+ if (this.abilities.cannot('fleet-ops view fuel-provider-transaction')) {
+ this.notifications.warning(this.intl.t('common.unauthorized-access'));
+ return this.hostRouter.transitionTo('console.fleet-ops.management.fuel-transactions.index');
+ }
+ }
@action error(error) {
this.notifications.serverError(error);
diff --git a/addon/routes/management/index.js b/addon/routes/management/index.js
index 0a4dcd62b..63f8af741 100644
--- a/addon/routes/management/index.js
+++ b/addon/routes/management/index.js
@@ -1,6 +1,19 @@
import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
export default class ManagementIndexRoute extends Route {
+ @service notifications;
+ @service hostRouter;
+ @service abilities;
+ @service intl;
+
+ beforeModel() {
+ if (this.abilities.cannot('fleet-ops list driver')) {
+ this.notifications.warning(this.intl.t('common.unauthorized-access'));
+ return this.hostRouter.transitionTo('console.fleet-ops');
+ }
+ }
+
queryParams = {
view: { refreshModel: false },
status: { refreshModel: false },
diff --git a/addon/routes/management/trailers.js b/addon/routes/management/trailers.js
new file mode 100644
index 000000000..0b4992833
--- /dev/null
+++ b/addon/routes/management/trailers.js
@@ -0,0 +1,16 @@
+import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
+
+export default class ManagementTrailersRoute extends Route {
+ @service notifications;
+ @service hostRouter;
+ @service abilities;
+ @service intl;
+
+ beforeModel() {
+ if (this.abilities.cannot('fleet-ops list trailer')) {
+ this.notifications.warning(this.intl.t('common.unauthorized-access'));
+ return this.hostRouter.transitionTo('console.fleet-ops');
+ }
+ }
+}
diff --git a/addon/routes/management/vendors/integrated.js b/addon/routes/management/vendors/integrated.js
index 0d90aa4ff..b86b8ac6d 100644
--- a/addon/routes/management/vendors/integrated.js
+++ b/addon/routes/management/vendors/integrated.js
@@ -1,3 +1,16 @@
import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
-export default class ManagementVendorsIntegratedRoute extends Route {}
+export default class ManagementVendorsIntegratedRoute extends Route {
+ @service notifications;
+ @service hostRouter;
+ @service abilities;
+ @service intl;
+
+ beforeModel() {
+ if (this.abilities.cannot('fleet-ops list integrated-vendor')) {
+ this.notifications.warning(this.intl.t('common.unauthorized-access'));
+ return this.hostRouter.transitionTo('console.fleet-ops.management.vendors.index');
+ }
+ }
+}
diff --git a/addon/routes/management/vendors/integrated/details.js b/addon/routes/management/vendors/integrated/details.js
index 2c1b8c9b6..e4cb44ae5 100644
--- a/addon/routes/management/vendors/integrated/details.js
+++ b/addon/routes/management/vendors/integrated/details.js
@@ -1,3 +1,16 @@
import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
-export default class ManagementVendorsIntegratedDetailsRoute extends Route {}
+export default class ManagementVendorsIntegratedDetailsRoute extends Route {
+ @service notifications;
+ @service hostRouter;
+ @service abilities;
+ @service intl;
+
+ beforeModel() {
+ if (this.abilities.cannot('fleet-ops view integrated-vendor')) {
+ this.notifications.warning(this.intl.t('common.unauthorized-access'));
+ return this.hostRouter.transitionTo('console.fleet-ops.management.vendors.index');
+ }
+ }
+}
diff --git a/addon/routes/management/vendors/integrated/edit.js b/addon/routes/management/vendors/integrated/edit.js
index 5167d56f4..5131805c8 100644
--- a/addon/routes/management/vendors/integrated/edit.js
+++ b/addon/routes/management/vendors/integrated/edit.js
@@ -1,3 +1,16 @@
import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
-export default class ManagementVendorsIntegratedEditRoute extends Route {}
+export default class ManagementVendorsIntegratedEditRoute extends Route {
+ @service notifications;
+ @service hostRouter;
+ @service abilities;
+ @service intl;
+
+ beforeModel() {
+ if (this.abilities.cannot('fleet-ops update integrated-vendor')) {
+ this.notifications.warning(this.intl.t('common.unauthorized-access'));
+ return this.hostRouter.transitionTo('console.fleet-ops.management.vendors.index');
+ }
+ }
+}
diff --git a/addon/routes/management/vendors/integrated/new.js b/addon/routes/management/vendors/integrated/new.js
index 1a9ea38c9..1c57c5cc8 100644
--- a/addon/routes/management/vendors/integrated/new.js
+++ b/addon/routes/management/vendors/integrated/new.js
@@ -1,3 +1,16 @@
import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
-export default class ManagementVendorsIntegratedNewRoute extends Route {}
+export default class ManagementVendorsIntegratedNewRoute extends Route {
+ @service notifications;
+ @service hostRouter;
+ @service abilities;
+ @service intl;
+
+ beforeModel() {
+ if (this.abilities.cannot('fleet-ops create integrated-vendor')) {
+ this.notifications.warning(this.intl.t('common.unauthorized-access'));
+ return this.hostRouter.transitionTo('console.fleet-ops.management.vendors.index');
+ }
+ }
+}
diff --git a/addon/routes/operations/orders.js b/addon/routes/operations/orders.js
index 4d5b88ed7..f4cc46d1e 100644
--- a/addon/routes/operations/orders.js
+++ b/addon/routes/operations/orders.js
@@ -1,3 +1,42 @@
import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
-export default class OperationsOrdersRoute extends Route {}
+/**
+ * Orders are the FleetOps landing route (`console.fleet-ops` resolves here), so a user
+ * without `fleet-ops list order` cannot be sent back to `console.fleet-ops` - that would
+ * loop. Instead they are forwarded to the first FleetOps area they are permitted to list,
+ * falling back to the console when there is none.
+ */
+const FALLBACK_ROUTES = [
+ ['fleet-ops list driver', 'console.fleet-ops.management.drivers'],
+ ['fleet-ops list vehicle', 'console.fleet-ops.management.vehicles'],
+ ['fleet-ops list trailer', 'console.fleet-ops.management.trailers'],
+ ['fleet-ops list fleet', 'console.fleet-ops.management.fleets'],
+ ['fleet-ops list place', 'console.fleet-ops.management.places'],
+ ['fleet-ops list contact', 'console.fleet-ops.management.contacts'],
+ ['fleet-ops list vendor', 'console.fleet-ops.management.vendors'],
+ ['fleet-ops list fuel-report', 'console.fleet-ops.management.fuel-reports'],
+ ['fleet-ops list issue', 'console.fleet-ops.management.issues'],
+ ['fleet-ops list work-order', 'console.fleet-ops.maintenance.work-orders'],
+ ['fleet-ops list device', 'console.fleet-ops.connectivity.devices'],
+ ['iam list report', 'console.fleet-ops.analytics.reports'],
+];
+
+export default class OperationsOrdersRoute extends Route {
+ @service notifications;
+ @service hostRouter;
+ @service abilities;
+ @service intl;
+
+ beforeModel() {
+ if (this.abilities.cannot('fleet-ops list order')) {
+ const fallback = FALLBACK_ROUTES.find(([permission]) => this.abilities.can(permission));
+ if (fallback) {
+ return this.hostRouter.transitionTo(fallback[1]);
+ }
+
+ this.notifications.warning(this.intl.t('common.unauthorized-access'));
+ return this.hostRouter.transitionTo('console');
+ }
+ }
+}
diff --git a/addon/routes/operations/routes.js b/addon/routes/operations/routes.js
index de8587326..bb6a52e38 100644
--- a/addon/routes/operations/routes.js
+++ b/addon/routes/operations/routes.js
@@ -1,3 +1,16 @@
import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
-export default class OperationsRoutesRoute extends Route {}
+export default class OperationsRoutesRoute extends Route {
+ @service notifications;
+ @service hostRouter;
+ @service abilities;
+ @service intl;
+
+ beforeModel() {
+ if (this.abilities.cannot('fleet-ops list route')) {
+ this.notifications.warning(this.intl.t('common.unauthorized-access'));
+ return this.hostRouter.transitionTo('console.fleet-ops');
+ }
+ }
+}
diff --git a/addon/routes/operations/routes/index/details.js b/addon/routes/operations/routes/index/details.js
index 9ede890da..bf504a0d2 100644
--- a/addon/routes/operations/routes/index/details.js
+++ b/addon/routes/operations/routes/index/details.js
@@ -1,3 +1,16 @@
import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
-export default class OperationsRoutesIndexDetailsRoute extends Route {}
+export default class OperationsRoutesIndexDetailsRoute extends Route {
+ @service notifications;
+ @service hostRouter;
+ @service abilities;
+ @service intl;
+
+ beforeModel() {
+ if (this.abilities.cannot('fleet-ops view route')) {
+ this.notifications.warning(this.intl.t('common.unauthorized-access'));
+ return this.hostRouter.transitionTo('console.fleet-ops.operations.routes.index');
+ }
+ }
+}
diff --git a/addon/routes/operations/routes/index/new.js b/addon/routes/operations/routes/index/new.js
index 3974d9c2c..b2ca429af 100644
--- a/addon/routes/operations/routes/index/new.js
+++ b/addon/routes/operations/routes/index/new.js
@@ -1,6 +1,19 @@
import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
export default class OperationsRoutesIndexNewRoute extends Route {
+ @service notifications;
+ @service hostRouter;
+ @service abilities;
+ @service intl;
+
+ beforeModel() {
+ if (this.abilities.cannot('fleet-ops create route')) {
+ this.notifications.warning(this.intl.t('common.unauthorized-access'));
+ return this.hostRouter.transitionTo('console.fleet-ops.operations.routes.index');
+ }
+ }
+
queryParams = {
selectedOrders: {
refreshModel: false,
diff --git a/addon/routes/operations/service-rates.js b/addon/routes/operations/service-rates.js
index 83628deb6..e46ea3081 100644
--- a/addon/routes/operations/service-rates.js
+++ b/addon/routes/operations/service-rates.js
@@ -1,3 +1,16 @@
import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
-export default class OperationsServiceRatesRoute extends Route {}
+export default class OperationsServiceRatesRoute extends Route {
+ @service notifications;
+ @service hostRouter;
+ @service abilities;
+ @service intl;
+
+ beforeModel() {
+ if (this.abilities.cannot('fleet-ops list service-rate')) {
+ this.notifications.warning(this.intl.t('common.unauthorized-access'));
+ return this.hostRouter.transitionTo('console.fleet-ops');
+ }
+ }
+}
diff --git a/addon/routes/operations/service-rates/index/new.js b/addon/routes/operations/service-rates/index/new.js
index 81ceee92f..0659118ce 100644
--- a/addon/routes/operations/service-rates/index/new.js
+++ b/addon/routes/operations/service-rates/index/new.js
@@ -3,6 +3,17 @@ import { inject as service } from '@ember/service';
export default class OperationsServiceRatesIndexNewRoute extends Route {
@service store;
+ @service notifications;
+ @service hostRouter;
+ @service abilities;
+ @service intl;
+
+ beforeModel() {
+ if (this.abilities.cannot('fleet-ops create service-rate')) {
+ this.notifications.warning(this.intl.t('common.unauthorized-access'));
+ return this.hostRouter.transitionTo('console.fleet-ops.operations.service-rates.index');
+ }
+ }
async setupController(controller) {
super.setupController(...arguments);
diff --git a/addon/routes/settings/avatars.js b/addon/routes/settings/avatars.js
index 22582c4e8..a6cef9606 100644
--- a/addon/routes/settings/avatars.js
+++ b/addon/routes/settings/avatars.js
@@ -1,3 +1,16 @@
import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
-export default class SettingsAvatarsRoute extends Route {}
+export default class SettingsAvatarsRoute extends Route {
+ @service notifications;
+ @service hostRouter;
+ @service abilities;
+ @service intl;
+
+ beforeModel() {
+ if (this.abilities.cannot('fleet-ops list avatar')) {
+ this.notifications.warning(this.intl.t('common.unauthorized-access'));
+ return this.hostRouter.transitionTo('console.fleet-ops');
+ }
+ }
+}
diff --git a/addon/routes/settings/custom-fields.js b/addon/routes/settings/custom-fields.js
index 9563a887e..a2bb3fcbc 100644
--- a/addon/routes/settings/custom-fields.js
+++ b/addon/routes/settings/custom-fields.js
@@ -1,3 +1,16 @@
import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
-export default class SettingsCustomFieldsRoute extends Route {}
+export default class SettingsCustomFieldsRoute extends Route {
+ @service notifications;
+ @service hostRouter;
+ @service abilities;
+ @service intl;
+
+ beforeModel() {
+ if (this.abilities.cannot('fleet-ops list custom-field')) {
+ this.notifications.warning(this.intl.t('common.unauthorized-access'));
+ return this.hostRouter.transitionTo('console.fleet-ops');
+ }
+ }
+}
diff --git a/addon/routes/settings/map.js b/addon/routes/settings/map.js
index 51f072976..6b1c6caaa 100644
--- a/addon/routes/settings/map.js
+++ b/addon/routes/settings/map.js
@@ -1,3 +1,16 @@
import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
-export default class SettingsMapRoute extends Route {}
+export default class SettingsMapRoute extends Route {
+ @service notifications;
+ @service hostRouter;
+ @service abilities;
+ @service intl;
+
+ beforeModel() {
+ if (this.abilities.cannot('fleet-ops view map-settings')) {
+ this.notifications.warning(this.intl.t('common.unauthorized-access'));
+ return this.hostRouter.transitionTo('console.fleet-ops');
+ }
+ }
+}
diff --git a/addon/routes/settings/notifications.js b/addon/routes/settings/notifications.js
index 9258c98f6..665c8a638 100644
--- a/addon/routes/settings/notifications.js
+++ b/addon/routes/settings/notifications.js
@@ -4,6 +4,17 @@ import { hash } from 'rsvp';
export default class SettingsNotificationsRoute extends Route {
@service fetch;
+ @service notifications;
+ @service hostRouter;
+ @service abilities;
+ @service intl;
+
+ beforeModel() {
+ if (this.abilities.cannot('fleet-ops view notification-settings')) {
+ this.notifications.warning(this.intl.t('common.unauthorized-access'));
+ return this.hostRouter.transitionTo('console.fleet-ops');
+ }
+ }
model() {
return hash({
diff --git a/addon/routes/settings/orchestrator.js b/addon/routes/settings/orchestrator.js
index f5109bd46..b6a474dd9 100644
--- a/addon/routes/settings/orchestrator.js
+++ b/addon/routes/settings/orchestrator.js
@@ -14,7 +14,7 @@ export default class SettingsOrchestratorRoute extends Route {
@service hostRouter;
beforeModel() {
- if (this.abilities.cannot('fleet-ops list order')) {
+ if (this.abilities.cannot('fleet-ops view routing-settings')) {
this.notifications.warning(this.intl.t('common.unauthorized-access'));
return this.hostRouter.transitionTo('console.fleet-ops');
}
diff --git a/addon/routes/settings/payments/index.js b/addon/routes/settings/payments/index.js
index bab0e134c..6925e7aa4 100644
--- a/addon/routes/settings/payments/index.js
+++ b/addon/routes/settings/payments/index.js
@@ -16,7 +16,7 @@ export default class SettingsPaymentsIndexRoute extends Route {
};
beforeModel() {
- if (this.abilities.cannot('fleet-ops list purchase-rate')) {
+ if (this.abilities.cannot('fleet-ops view payments')) {
this.notifications.warning(this.intl.t('common.unauthorized-access'));
return this.hostRouter.transitionTo('console.fleet-ops');
}
diff --git a/addon/routes/settings/routing.js b/addon/routes/settings/routing.js
index cd75821fb..bc5a75e59 100644
--- a/addon/routes/settings/routing.js
+++ b/addon/routes/settings/routing.js
@@ -1,3 +1,16 @@
import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
-export default class SettingsRoutingRoute extends Route {}
+export default class SettingsRoutingRoute extends Route {
+ @service notifications;
+ @service hostRouter;
+ @service abilities;
+ @service intl;
+
+ beforeModel() {
+ if (this.abilities.cannot('fleet-ops view routing-settings')) {
+ this.notifications.warning(this.intl.t('common.unauthorized-access'));
+ return this.hostRouter.transitionTo('console.fleet-ops');
+ }
+ }
+}
diff --git a/addon/routes/settings/scheduling.js b/addon/routes/settings/scheduling.js
index c6411b241..c64c6b68b 100644
--- a/addon/routes/settings/scheduling.js
+++ b/addon/routes/settings/scheduling.js
@@ -1,3 +1,16 @@
import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
-export default class SettingsSchedulingRoute extends Route {}
+export default class SettingsSchedulingRoute extends Route {
+ @service notifications;
+ @service hostRouter;
+ @service abilities;
+ @service intl;
+
+ beforeModel() {
+ if (this.abilities.cannot('fleet-ops view scheduling-settings')) {
+ this.notifications.warning(this.intl.t('common.unauthorized-access'));
+ return this.hostRouter.transitionTo('console.fleet-ops');
+ }
+ }
+}
diff --git a/addon/routes/settings/telematics.js b/addon/routes/settings/telematics.js
new file mode 100644
index 000000000..db07e66d8
--- /dev/null
+++ b/addon/routes/settings/telematics.js
@@ -0,0 +1,21 @@
+import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
+
+export default class SettingsTelematicsRoute extends Route {
+ @service notifications;
+ @service hostRouter;
+ @service abilities;
+ @service intl;
+
+ beforeModel() {
+ if (this.abilities.cannot('fleet-ops view telematics-settings')) {
+ this.notifications.warning(this.intl.t('common.unauthorized-access'));
+ return this.hostRouter.transitionTo('console.fleet-ops');
+ }
+ }
+
+ setupController(controller) {
+ super.setupController(...arguments);
+ controller.getSettings.perform();
+ }
+}
diff --git a/addon/routes/virtual.js b/addon/routes/virtual.js
index b79b4e462..bbd17220e 100644
--- a/addon/routes/virtual.js
+++ b/addon/routes/virtual.js
@@ -4,6 +4,10 @@ import { inject as service } from '@ember/service';
export default class VirtualRoute extends Route {
@service universe;
@service('universe/menu-service') menuService;
+ @service notifications;
+ @service hostRouter;
+ @service abilities;
+ @service intl;
queryParams = {
view: {
@@ -15,4 +19,11 @@ export default class VirtualRoute extends Route {
const view = this.universe.getViewFromTransition(transition);
return this.menuService.lookupMenuItem('engine:fleet-ops', slug, view, section);
}
+
+ afterModel(menuItem) {
+ if (menuItem?.permission && this.abilities.cannot(menuItem.permission)) {
+ this.notifications.warning(this.intl.t('common.unauthorized-access'));
+ return this.hostRouter.transitionTo('console.fleet-ops');
+ }
+ }
}
diff --git a/addon/templates/settings/telematics.hbs b/addon/templates/settings/telematics.hbs
new file mode 100644
index 000000000..e6bd60696
--- /dev/null
+++ b/addon/templates/settings/telematics.hbs
@@ -0,0 +1,70 @@
+
+
+
+
+
+
+
+ {{t "settings.telematics.intro"}}
+
+
+
+
+
+
+
+
+
+ {{#if this.settingsLoaded}}
+ {{this.eventRetentionPolicyHelp}}
+ {{/if}}
+
+
+
+
+
+
+
+
+
+ {{#if this.settingsLoaded}}
+ {{this.positionRetentionPolicyHelp}}
+ {{/if}}
+
+
+
+
+
diff --git a/app/components/admin/telematics-settings.js b/app/components/admin/telematics-settings.js
new file mode 100644
index 000000000..2ed694547
--- /dev/null
+++ b/app/components/admin/telematics-settings.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/admin/telematics-settings';
diff --git a/app/components/map/marker-card/driver.js b/app/components/map/marker-card/driver.js
new file mode 100644
index 000000000..233ee41cb
--- /dev/null
+++ b/app/components/map/marker-card/driver.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/map/marker-card/driver';
diff --git a/app/components/map/marker-card/vehicle.js b/app/components/map/marker-card/vehicle.js
new file mode 100644
index 000000000..524d8ad84
--- /dev/null
+++ b/app/components/map/marker-card/vehicle.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/map/marker-card/vehicle';
diff --git a/app/controllers/settings/telematics.js b/app/controllers/settings/telematics.js
new file mode 100644
index 000000000..2a8328922
--- /dev/null
+++ b/app/controllers/settings/telematics.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/controllers/settings/telematics';
diff --git a/app/routes/settings/telematics.js b/app/routes/settings/telematics.js
new file mode 100644
index 000000000..2c34721cc
--- /dev/null
+++ b/app/routes/settings/telematics.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/routes/settings/telematics';
diff --git a/app/templates/settings/telematics.js b/app/templates/settings/telematics.js
new file mode 100644
index 000000000..1e3bbaee3
--- /dev/null
+++ b/app/templates/settings/telematics.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/templates/settings/telematics';
diff --git a/composer.json b/composer.json
index 3907b8527..35656bb90 100644
--- a/composer.json
+++ b/composer.json
@@ -1,6 +1,6 @@
{
"name": "fleetbase/fleetops-api",
- "version": "0.6.69",
+ "version": "0.6.70",
"description": "Fleet & Transport Management Extension for Fleetbase",
"keywords": [
"fleetbase-extension",
@@ -25,7 +25,7 @@
"barryvdh/laravel-dompdf": "^3.1",
"brick/geo": "0.7.2",
"cknow/laravel-money": "^7.1",
- "fleetbase/core-api": "*",
+ "fleetbase/core-api": ">=1.6.65",
"geocoder-php/google-maps-places-provider": "^1.4",
"giggsey/libphonenumber-for-php": "^8.13",
"league/geotools": "^1.1.0",
diff --git a/docs/AFAQY.md b/docs/AFAQY.md
index dc68293b7..591ba27bd 100644
--- a/docs/AFAQY.md
+++ b/docs/AFAQY.md
@@ -57,7 +57,7 @@ Existing provider interfaces and vehicle/trailer event names remain compatible.
The public receiver remains `POST webhooks/telematics/afaqy?telematic=...&key=...` under the application's API prefix. A new `device.telemetry_updated` broadcast identifies the device; an open device panel reloads its authorized resource and refetches after socket reconnection. Company broadcast identity comes from the persisted asset, not a worker session. Map movement uses the existing vehicle/trailer events, rejects older source timestamps, and reloads visible telemetry assets after socket reconnection with at most five concurrent requests.
-Configure shared queues, retention, limits, and freshness under `telematics.telemetry` using `server/config/telemetry.php`. `TELEMATICS_BATCH_SIZE` controls units per ingestion delivery (default 100); `TELEMATICS_REQUEST_TIMEOUT_SECONDS` controls the bounded polling request timeout (default 45 seconds). AFAQY's adapter switches and optional queue overrides remain under `telematics.afaqy` in `server/config/afaqy.php`. Defaults: two MiB per webhook, 10,000 pending deliveries per connection, processed payload retention 24 hours, quarantine retention seven days, stale-position thresholds 120 seconds with ignition on and 600 seconds with ignition off/unknown. Each device's metadata records the effective threshold. Connection status categories remain unchanged.
+Configure shared queues, retention, limits, and freshness under `telematics.telemetry` using `server/config/telemetry.php`. `TELEMATICS_BATCH_SIZE` controls units per ingestion delivery (default 100); `TELEMATICS_REQUEST_TIMEOUT_SECONDS` controls the bounded polling request timeout (default 45 seconds). AFAQY's adapter switches and optional queue overrides remain under `telematics.afaqy` in `server/config/afaqy.php`. Defaults: two MiB per webhook, 10,000 pending deliveries per connection, stale-position thresholds 120 seconds with ignition on and 600 seconds with ignition off/unknown. Retention (processed payloads 24 hours, quarantined deliveries seven days, device events 30 days, positions 90 days) is applied by `fleetops:prune-telematics-data`. Administrators configure retention policy, logging, system storage diagnostics and system cleanup in Admin → Fleet-Ops Config → Telematics. Organizations choose only event and position history preferences within those limits in Fleet-Ops → Settings → Telematics; config values are the fallback. See `docs/TELEMETRY_ARCHITECTURE.md` § Retention. Each device's metadata records the effective threshold. Connection status categories remain unchanged.
## Incident verification
diff --git a/docs/TELEMATICS_QUEUES.md b/docs/TELEMATICS_QUEUES.md
index 0f9829c1f..5957dbda2 100644
--- a/docs/TELEMATICS_QUEUES.md
+++ b/docs/TELEMATICS_QUEUES.md
@@ -14,7 +14,7 @@ nothing changes: every job and broadcast stays on the same queue as before.
| Setting | Default | Work routed |
| --- | --- | --- |
| `TELEMATICS_POLL_QUEUE` | `default` | Scheduled and manual polls (`PollTelematicTelemetry`), legacy device discovery (`SyncTelematicDevicesJob`), and queued connection tests (`TestTelematicConnectionJob`). |
-| `TELEMATICS_INGESTION_QUEUE` | `default` | Applying polled or pushed positions (`ProcessTelematicDelivery`), including jobs re-queued by `fleetops:drain-telematic-inbox` and manual replays. |
+| `TELEMATICS_INGESTION_QUEUE` | `default` | Applying polled or pushed positions (`ProcessTelematicDelivery`), including jobs re-queued by `fleetops:drain-telematic-inbox` and manual replays, and on-demand retention runs (`PruneTelematicsDataJob`, queued by "Run system cleanup" in Admin → Fleet-Ops Config → Telematics). |
| `TELEMATICS_BROADCAST_QUEUE` | unset | Live-map broadcasts created by telematics ingestion: `DeviceTelemetryUpdated`, and `VehicleLocationChanged` / `TrailerLocationChanged` when a device is attached. When unset, broadcasts use the queue connection's default queue. |
`TELEMATICS_BROADCAST_QUEUE` affects only broadcasts that telematics ingestion
diff --git a/docs/TELEMETRY_ARCHITECTURE.md b/docs/TELEMETRY_ARCHITECTURE.md
index e10b064cb..80737af13 100644
--- a/docs/TELEMETRY_ARCHITECTURE.md
+++ b/docs/TELEMETRY_ARCHITECTURE.md
@@ -48,11 +48,82 @@ persistence. The performance requirement did not justify those dependencies.
## Tradeoffs
Three shared tables separate run-level diagnostics, high-volume delivery retention,
-and connection-level secrets with different lifetimes. Provider-specific protocol
+and connection-level secrets with different lifetimes (see Retention below). Provider-specific protocol
classes remain appropriate. Capability opt-in avoids silently changing every
existing integration's ingestion or webhook behavior. Production throughput and
real AFAQY payload verification remain separate deployment gates.
+## Retention
+
+Every poll writes one `device_events` row, one `positions` row and a delivery
+envelope per batch for each reporting device. Without a ceiling those tables grow
+until the disk fills, so retention is enforced by `fleetops:prune-telematics-data`
+(scheduled every fifteen minutes, `withoutOverlapping(14)`) using a per-company
+policy resolved by `Support\Telematics\Retention\RetentionPolicy`:
+
+| Layer | Source | Where it is edited |
+|---|---|---|
+| Package config | `server/config/telemetry.php` | deployment |
+| System policy | setting `fleet-ops.telematics-settings` | Admin console → Fleet-Ops Config → Telematics |
+| Company history preferences | `company..fleet-ops.telematics-settings` | Fleet-Ops → Settings → Telematics |
+
+Defaults: device events 30 days, raw payload/meta stripped ("compacted") after
+7 days, positions 90 days, processed deliveries 24 hours, quarantined deliveries
+7 days, sync runs 7 days. Organizations can choose only device event and position
+history retention, or inherit the system defaults. Administrators own raw payload,
+delivery, sync-run retention and telemetry activity logging.
+
+`max_event_retention_days` and `max_position_retention_days` constrain every
+organization's history, including inherited defaults. A maximum of `0` allows
+unlimited history; a history preference of `0` keeps records forever only while
+that maximum is unset. Setting a finite maximum replaces saved unlimited or longer
+preferences with that maximum. Missing preferences continue to inherit. Values
+are also clamped to `RetentionPolicy::LIMITS`.
+
+Admin storage diagnostics cover the entire system, including organizations the
+administrator does not belong to. System cleanup queues work for every
+organization plus orphaned records, with each organization's effective policy
+applied independently. Storage sizes include table data and indexes; database row
+estimates are labeled. Cleanup is asynchronous, so refreshing usage does not mean
+all queued work has finished.
+
+Manual system cleanup requires an asynchronous queue connection and a worker for
+`telematics.telemetry.ingestion_queue`. A synchronous or disabled queue is rejected
+instead of running every organization's cleanup inside the HTTP request.
+
+Deploy the frontend and backend together and reload Octane and queue workers after
+updating the backend. In linked development checkouts, Octane's watch paths must
+include `packages/fleetops/server`; watching only the host API directory leaves
+previously loaded controllers and policies running.
+
+Rules the sweep follows:
+
+- Hard deletes only. The models soft-delete, so `delete()` would grow the table;
+ rows already soft-deleted are purged regardless of age.
+- Bounded work: at most `--max-batches` (default 50) statements of `--batch-size`
+ (default 1000) rows per table per company per run. A capped run is normal; the
+ next tick continues. Operators can catch up faster off-peak with
+ `php artisan fleetops:prune-telematics-data --max-batches=500 --no-lock`.
+- Compaction keeps the event (type, severity, location, normalized `data`) and
+ nulls `payload` and `meta`, which hold the bulk of each row.
+- `telematic_deliveries` and `telematic_sync_runs` carry no company; they are
+ resolved through the company's connections (including trashed ones). In-flight
+ sync runs (`fetching`, `ingesting`) are recovered by the drain, never expired.
+- Rows whose company or connection no longer exists are swept with the system
+ defaults.
+- Migrations add `(company_uuid, created_at)` on `device_events` and `positions`
+ and `(telematic_uuid, updated_at)` on `telematic_sync_runs`; on multi-GB tables
+ run them in a maintenance window.
+- Deleting rows frees space inside InnoDB files, not on disk. Run
+ `OPTIMIZE TABLE device_events` (requires `innodb_file_per_table`) off-peak to
+ return it to the operating system.
+
+Two write-amplification fixes accompany the sweep: `device_events.meta` stores only
+the normalized block (the raw unit lives once, in `payload`), and telemetry-driven
+saves run inside `activity()->withoutLogs()` unless the system administrator enables
+"Log telemetry activity". `DrainTelematicInbox` now only recovers deliveries and
+finishes interrupted runs.
+
## Adding an adapter
Implement `TelemetryProviderInterface` alongside the existing provider contract.
diff --git a/extension.json b/extension.json
index 49ca67ca9..f08a7afde 100644
--- a/extension.json
+++ b/extension.json
@@ -1,6 +1,6 @@
{
"name": "Fleet-Ops",
- "version": "0.6.69",
+ "version": "0.6.70",
"description": "Fleet & Transport Management Extension for Fleetbase",
"repository": "https://github.com/fleetbase/fleetops",
"license": "AGPL-3.0-or-later",
diff --git a/package.json b/package.json
index 680462f58..81928af77 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@fleetbase/fleetops-engine",
- "version": "0.6.69",
+ "version": "0.6.70",
"description": "Fleet & Transport Management Extension for Fleetbase",
"fleetbase": {
"route": "fleet-ops"
diff --git a/server/config/telemetry.php b/server/config/telemetry.php
index 2997fec25..1a6cf8828 100644
--- a/server/config/telemetry.php
+++ b/server/config/telemetry.php
@@ -13,8 +13,20 @@
'batch_size' => env('TELEMATICS_BATCH_SIZE', 100),
'max_payload_bytes' => 2097152,
'max_pending_deliveries' => 10000,
+ // Administrators control infrastructure retention and activity logging.
+ // Companies may choose event/position history retention within the maximums.
+ // A maximum of 0 imposes no cap and permits unlimited history (retention 0).
'processed_retention_hours' => 24,
'quarantine_retention_days' => 7,
+ 'sync_run_retention_days' => 7,
+ 'event_retention_days' => 30,
+ 'event_compact_after_days' => 7,
+ 'position_retention_days' => 90,
+ 'max_event_retention_days' => 0,
+ 'max_position_retention_days' => 0,
+ 'log_telemetry_activity' => false,
+ 'prune_batch_size' => 1000,
+ 'prune_max_batches' => 50,
'stale_engine_on_seconds' => 120,
'stale_engine_off_seconds' => 600,
];
diff --git a/server/migrations/2026_09_23_000001_add_retention_index_to_device_events.php b/server/migrations/2026_09_23_000001_add_retention_index_to_device_events.php
new file mode 100644
index 000000000..c79dc41ed
--- /dev/null
+++ b/server/migrations/2026_09_23_000001_add_retention_index_to_device_events.php
@@ -0,0 +1,40 @@
+ $table->index(self::COLUMNS, self::INDEX));
+ }
+
+ public function down(): void
+ {
+ if (!Schema::hasTable(self::TABLE)) {
+ return;
+ }
+
+ if ((TableIndexes::for(self::TABLE)[self::INDEX] ?? null) === self::COLUMNS) {
+ Schema::table(self::TABLE, fn (Blueprint $table) => $table->dropIndex(self::INDEX));
+ }
+ }
+};
diff --git a/server/migrations/2026_09_23_000002_add_retention_index_to_positions.php b/server/migrations/2026_09_23_000002_add_retention_index_to_positions.php
new file mode 100644
index 000000000..6cc4564d7
--- /dev/null
+++ b/server/migrations/2026_09_23_000002_add_retention_index_to_positions.php
@@ -0,0 +1,40 @@
+ $table->index(self::COLUMNS, self::INDEX));
+ }
+
+ public function down(): void
+ {
+ if (!Schema::hasTable(self::TABLE)) {
+ return;
+ }
+
+ if ((TableIndexes::for(self::TABLE)[self::INDEX] ?? null) === self::COLUMNS) {
+ Schema::table(self::TABLE, fn (Blueprint $table) => $table->dropIndex(self::INDEX));
+ }
+ }
+};
diff --git a/server/migrations/2026_09_23_000003_add_retention_index_to_telematic_sync_runs.php b/server/migrations/2026_09_23_000003_add_retention_index_to_telematic_sync_runs.php
new file mode 100644
index 000000000..18ebc7fec
--- /dev/null
+++ b/server/migrations/2026_09_23_000003_add_retention_index_to_telematic_sync_runs.php
@@ -0,0 +1,40 @@
+ $table->index(self::COLUMNS, self::INDEX));
+ }
+
+ public function down(): void
+ {
+ if (!Schema::hasTable(self::TABLE)) {
+ return;
+ }
+
+ if ((TableIndexes::for(self::TABLE)[self::INDEX] ?? null) === self::COLUMNS) {
+ Schema::table(self::TABLE, fn (Blueprint $table) => $table->dropIndex(self::INDEX));
+ }
+ }
+};
diff --git a/server/src/Auth/Schemas/FleetOps.php b/server/src/Auth/Schemas/FleetOps.php
index 74bf863ae..8a652e2f0 100644
--- a/server/src/Auth/Schemas/FleetOps.php
+++ b/server/src/Auth/Schemas/FleetOps.php
@@ -43,7 +43,7 @@ class FleetOps
],
[
'name' => 'service-rate',
- 'actions' => ['import'],
+ 'actions' => ['import', 'export'],
],
[
'name' => 'zone',
@@ -141,6 +141,51 @@ class FleetOps
'name' => 'part',
'actions' => ['export', 'import'],
],
+ [
+ 'name' => 'maintenance-schedule',
+ 'actions' => ['export', 'import'],
+ ],
+ [
+ 'name' => 'warranty',
+ 'actions' => [],
+ ],
+ [
+ 'name' => 'device',
+ 'actions' => ['export', 'import'],
+ ],
+ [
+ 'name' => 'sensor',
+ 'actions' => ['export', 'import'],
+ ],
+ [
+ 'name' => 'device-event',
+ 'actions' => ['export'],
+ ],
+ [
+ 'name' => 'telematic',
+ 'actions' => ['export', 'import'],
+ ],
+ [
+ 'name' => 'fuel-provider-connection',
+ 'actions' => ['sync'],
+ ],
+ [
+ 'name' => 'fuel-provider-transaction',
+ 'actions' => ['review'],
+ ],
+ [
+ 'name' => 'fuel-provider-sync-run',
+ 'actions' => [],
+ ],
+ [
+ 'name' => 'purchase-rate',
+ 'actions' => [],
+ ],
+ [
+ 'name' => 'analytics', // dashboards, KPI metrics and the live fleet summary
+ 'actions' => [],
+ 'remove_actions' => ['create', 'update', 'delete', 'export'],
+ ],
[
'name' => 'custom-field',
'actions' => [],
@@ -151,26 +196,41 @@ class FleetOps
],
[
'name' => 'navigator-settings', // the navigator mobile app used by drivers
- 'action' => [],
+ 'actions' => [],
'remove_actions' => ['delete', 'export', 'list', 'create'],
],
[
'name' => 'payments',
- 'action' => ['onboard'],
+ 'actions' => ['onboard'],
'remove_actions' => ['delete', 'export', 'list', 'create'],
],
[
'name' => 'notification-settings',
- 'action' => [],
+ 'actions' => [],
'remove_actions' => ['export', 'create'],
],
[
'name' => 'routing-settings',
- 'action' => [],
+ 'actions' => [],
'remove_actions' => ['export', 'create'],
],
[
'name' => 'map-settings',
+ 'actions' => [],
+ 'remove_actions' => ['export', 'create'],
+ ],
+ [
+ 'name' => 'scheduling-settings',
+ 'actions' => [],
+ 'remove_actions' => ['delete', 'export', 'create'],
+ ],
+ [
+ 'name' => 'tracking-settings',
+ 'actions' => [],
+ 'remove_actions' => ['delete', 'export', 'create'],
+ ],
+ [
+ 'name' => 'telematics-settings', // organization device event and position history preferences
'action' => [],
'remove_actions' => ['export', 'create'],
],
@@ -187,6 +247,9 @@ class FleetOps
'see extension',
'* order',
'* route',
+ 'see analytics',
+ 'list analytics',
+ 'view analytics',
'see order-config',
'list order-config',
'view order-config',
@@ -230,6 +293,12 @@ class FleetOps
'* driver',
'* vehicle',
'* trailer',
+ '* device',
+ '* sensor',
+ '* telematic',
+ 'see device-event',
+ 'list device-event',
+ 'view device-event',
],
],
[
@@ -277,6 +346,7 @@ class FleetOps
'permissions' => [
'see extension',
'* service-rate',
+ '* purchase-rate',
'see order',
'list order',
'see service-area',
@@ -309,6 +379,8 @@ class FleetOps
'* inspection-submission',
'* equipment',
'* part',
+ '* maintenance-schedule',
+ '* warranty',
'* trailer',
'see vehicle',
'list vehicle',
@@ -336,6 +408,18 @@ class FleetOps
'* inspection-submission',
'* equipment',
'* part',
+ '* maintenance-schedule',
+ '* warranty',
+ '* device',
+ '* sensor',
+ '* device-event',
+ '* telematic',
+ '* fuel-provider-connection',
+ '* fuel-provider-transaction',
+ '* fuel-provider-sync-run',
+ 'see analytics',
+ 'list analytics',
+ 'view analytics',
'* trailer',
],
],
@@ -353,6 +437,14 @@ class FleetOps
'list vehicle',
],
],
+ [
+ 'name' => 'TelematicsSettingsManager',
+ 'description' => 'Policy for managing organization telematics history preferences.',
+ 'permissions' => [
+ 'see extension',
+ '* telematics-settings',
+ ],
+ ],
[
'name' => 'DriverOperations',
'description' => 'Policy for drivers to manage their assigned tasks and access necessary information.',
@@ -453,6 +545,13 @@ class FleetOps
'NavigatorSettingsManager',
],
],
+ [
+ 'name' => 'Telematics Settings Manager',
+ 'description' => 'Role responsible for managing organization telematics history preferences.',
+ 'policies' => [
+ 'TelematicsSettingsManager',
+ ],
+ ],
[
'name' => 'Driver',
'description' => 'Role for drivers with the necessary access to manage their daily tasks, including order management, and vehicle assignment.',
diff --git a/server/src/Console/Commands/DrainTelematicInbox.php b/server/src/Console/Commands/DrainTelematicInbox.php
index 023a8e6aa..3ff998c36 100644
--- a/server/src/Console/Commands/DrainTelematicInbox.php
+++ b/server/src/Console/Commands/DrainTelematicInbox.php
@@ -16,7 +16,7 @@
class DrainTelematicInbox extends Command
{
protected $signature = 'fleetops:drain-telematic-inbox';
- protected $description = 'Recover pending telematic deliveries and expire retained payloads.';
+ protected $description = 'Recover pending telematic deliveries and finish interrupted sync runs.';
public function handle(): int
{
@@ -58,24 +58,14 @@ public function handle(): int
try {
Queue::dispatch((new ProcessTelematicDelivery($row->uuid))->onQueue($optionsByConnection[$row->telematic_uuid]['ingestion_queue'] ?? 'default'));
} catch (\Throwable) {
- // A broker outage must not prevent retention cleanup; retry the next minute.
+ // A broker outage must not prevent run recovery; retry the next minute.
break;
}
}
- // Bound deletes so retention cleanup cannot monopolize the inbox table.
- foreach (['processed' => now()->subHours(config('telematics.telemetry.processed_retention_hours', 24)), 'quarantined' => now()->subDays(config('telematics.telemetry.quarantine_retention_days', 7))] as $status => $cutoff) {
- for ($batch = 0; $batch < 20; $batch++) {
- $ids = DB::table('telematic_deliveries')->where('status', $status)->where('updated_at', '<', $cutoff)->limit(1000)->pluck('uuid');
- if ($ids->isEmpty()) {
- break;
- }
- DB::table('telematic_deliveries')->whereIn('uuid', $ids)->delete();
- }
- }
+ // Retention for deliveries and sync runs is applied by fleetops:prune-telematics-data.
DB::table('telematic_sync_runs')->where('status', 'fetching')->where('updated_at', '<', now()->subMinutes(5))
->update(['status' => 'incomplete', 'error' => 'Worker interrupted; next scheduled sweep will recover.']);
DB::table('telematic_sync_runs')->where('status', 'ingesting')->get(['uuid'])->each(fn ($run) => Inbox::finishRun($run->uuid));
- DB::table('telematic_sync_runs')->where('updated_at', '<', now()->subDays(7))->delete();
return self::SUCCESS;
}
diff --git a/server/src/Console/Commands/ProcessOperationalAlerts.php b/server/src/Console/Commands/ProcessOperationalAlerts.php
index 18a255a99..c9ae5eab7 100644
--- a/server/src/Console/Commands/ProcessOperationalAlerts.php
+++ b/server/src/Console/Commands/ProcessOperationalAlerts.php
@@ -60,7 +60,7 @@ public function handle(): int
foreach ($orders as $order) {
session(['company' => $order->company_uuid]);
- $settings = $this->alertSettings();
+ $settings = $this->alertSettings($order);
if ($this->processLateDeparture($order, $settings, $dryRun)) {
$triggered++;
@@ -256,9 +256,9 @@ protected function minimumDistanceToRoute(Point $position, array $routePoints):
->min() ?? 0;
}
- protected function alertSettings(): array
+ protected function alertSettings(Order $order): array
{
- $settings = Setting::lookupCompany('tracking', []);
+ $settings = Setting::lookupForCompany($order->company_uuid, 'tracking', []);
$alerts = data_get($settings, 'alerts', []);
return [
diff --git a/server/src/Console/Commands/PruneTelematicsData.php b/server/src/Console/Commands/PruneTelematicsData.php
new file mode 100644
index 000000000..492003f11
--- /dev/null
+++ b/server/src/Console/Commands/PruneTelematicsData.php
@@ -0,0 +1,318 @@
+requestedTables();
+ if ($tables === null) {
+ $this->error('Unknown table. Valid tables: ' . implode(', ', self::TABLES) . '.');
+
+ return self::FAILURE;
+ }
+
+ $lock = null;
+ if (!$this->option('no-lock')) {
+ $lock = Cache::lock(self::LOCK, 840);
+ if (!$lock->get()) {
+ $this->warn('Another telematics prune run appears to be in progress.');
+
+ return self::SUCCESS;
+ }
+ }
+
+ try {
+ // Queue workers may have cached settings before an administrator saved a new policy.
+ RetentionPolicy::flush();
+ $this->batchSize = max(100, min(5000, (int) $this->option('batch-size') ?: 1000));
+ $this->maxBatches = max(1, (int) $this->option('max-batches') ?: 50);
+ $this->dryRun = (bool) $this->option('dry-run');
+
+ $only = ((string) $this->option('company')) ?: null;
+ $orphansOnly = (bool) $this->option('orphans-only');
+ if ($only && $orphansOnly) {
+ $this->error('Choose either --company or --orphans-only.');
+
+ return self::FAILURE;
+ }
+ $companies = $orphansOnly ? new Collection() : $this->companies($only);
+ if ($only && $companies->isEmpty()) {
+ $this->error(sprintf('Company [%s] was not found.', $only));
+
+ return self::FAILURE;
+ }
+
+ $totals = $this->emptyStats();
+ foreach ($companies as $company) {
+ $stats = $this->pruneCompany($company->uuid, $this->policyFor($company->uuid), $tables);
+ $totals = $this->mergeStats($totals, $stats);
+ $this->report($company->public_id ?: $company->uuid, $stats);
+ }
+ if (!$only) {
+ $stats = $this->pruneOrphans($this->policyFor(null), $tables);
+ $totals = $this->mergeStats($totals, $stats);
+ $this->report('orphaned', $stats);
+ }
+
+ $this->info(sprintf(
+ '%s: deleted=%d compacted=%d batches=%d%s',
+ $this->dryRun ? 'Telematics prune dry run' : 'Telematics prune complete',
+ $totals['deleted'],
+ $totals['compacted'],
+ $totals['batches'],
+ $totals['capped'] ? ' (capped; more rows remain for the next run)' : ''
+ ));
+
+ return self::SUCCESS;
+ } finally {
+ $lock?->release();
+ }
+ }
+
+ /**
+ * @return string[]|null null when an unknown table was requested
+ */
+ protected function requestedTables(): ?array
+ {
+ $requested = array_values(array_filter((array) $this->option('table')));
+ if ($requested === []) {
+ return self::TABLES;
+ }
+
+ return array_diff($requested, self::TABLES) === [] ? array_values(array_unique($requested)) : null;
+ }
+
+ protected function companies(?string $only): Collection
+ {
+ $query = DB::table('companies')->select(['id', 'uuid', 'public_id'])->orderBy('id');
+ if ($only) {
+ $query->where(fn ($where) => $where->where('uuid', $only)->orWhere('public_id', $only));
+ }
+
+ return $query->get();
+ }
+
+ protected function policyFor(?string $companyUuid): RetentionPolicy
+ {
+ return RetentionPolicy::forCompany($companyUuid);
+ }
+
+ protected function pruneCompany(string $companyUuid, RetentionPolicy $policy, array $tables): array
+ {
+ $rows = fn ($query) => $query->where('company_uuid', $companyUuid);
+
+ $telematics = null;
+ if (array_intersect(['telematic_deliveries', 'telematic_sync_runs'], $tables) !== []) {
+ // Inbox tables carry no company; trashed connections still prune under their company's policy.
+ $telematics = DB::table('telematics')->where('company_uuid', $companyUuid)->pluck('uuid')->all();
+ }
+ $inbox = $telematics ? fn ($query) => $query->whereIn('telematic_uuid', $telematics) : null;
+
+ return $this->prune($rows, $inbox, $policy, $tables);
+ }
+
+ /**
+ * Rows whose company or connection no longer exists fall back to the system defaults.
+ */
+ protected function pruneOrphans(RetentionPolicy $policy, array $tables): array
+ {
+ $rows = fn ($query) => $query->where(function ($where) {
+ $where->whereNull('company_uuid')->orWhereNotIn('company_uuid', DB::table('companies')->select('uuid'));
+ });
+ $inbox = fn ($query) => $query->whereNotExists(function ($connections) use ($query) {
+ $connections->selectRaw('1')
+ ->from('telematics')
+ ->join('companies', 'companies.uuid', '=', 'telematics.company_uuid')
+ ->whereColumn('telematics.uuid', $query->from . '.telematic_uuid');
+ });
+
+ return $this->prune($rows, $inbox, $policy, $tables);
+ }
+
+ protected function prune(\Closure $rows, ?\Closure $inbox, RetentionPolicy $policy, array $tables): array
+ {
+ $stats = $this->emptyStats();
+ if (in_array('device_events', $tables, true)) {
+ $stats['tables']['device_events'] = $this->pruneDeviceEvents($rows, $policy);
+ }
+ if (in_array('positions', $tables, true)) {
+ $stats['tables']['positions'] = $this->prunePositions($rows, $policy);
+ }
+ if ($inbox && in_array('telematic_deliveries', $tables, true)) {
+ $stats['tables']['telematic_deliveries'] = $this->pruneDeliveries($inbox, $policy);
+ }
+ if ($inbox && in_array('telematic_sync_runs', $tables, true)) {
+ $stats['tables']['telematic_sync_runs'] = $this->pruneSyncRuns($inbox, $policy);
+ }
+ foreach ($stats['tables'] as $table) {
+ $stats['deleted'] += $table['deleted'];
+ $stats['compacted'] += $table['compacted'];
+ $stats['batches'] += $table['batches'];
+ $stats['capped'] = $stats['capped'] || $table['capped'];
+ }
+
+ return $stats;
+ }
+
+ protected function pruneDeviceEvents(\Closure $rows, RetentionPolicy $policy): array
+ {
+ $stats = $this->emptyTableStats();
+ $cutoff = $policy->cutoff('event_retention_days');
+ // Soft-deleted events only grow the table; purge them regardless of age.
+ $this->deleteInBatches('device_events', 'id', fn ($query) => $rows($query)->whereNotNull('deleted_at'), $stats);
+ if ($cutoff) {
+ $this->deleteInBatches('device_events', 'id', fn ($query) => $rows($query)->where('created_at', '<', $cutoff->toDateTimeString()), $stats);
+ }
+ if ($policy->compactsEvents() && ($compactAt = $policy->cutoff('event_compact_after_days'))) {
+ // Keep the event (type, severity, location, normalized data) but drop the raw provider blobs.
+ // Rows past the delete cutoff are left to the delete sweep rather than rewritten first.
+ $this->updateInBatches(
+ 'device_events',
+ 'id',
+ function ($query) use ($rows, $cutoff, $compactAt) {
+ $query = $rows($query)->where('created_at', '<', $compactAt->toDateTimeString())->where(fn ($where) => $where->whereNotNull('payload')->orWhereNotNull('meta'));
+
+ return $cutoff ? $query->where('created_at', '>=', $cutoff->toDateTimeString()) : $query;
+ },
+ ['payload' => null, 'meta' => null],
+ $stats
+ );
+ }
+
+ return $stats;
+ }
+
+ protected function prunePositions(\Closure $rows, RetentionPolicy $policy): array
+ {
+ $stats = $this->emptyTableStats();
+ $this->deleteInBatches('positions', 'id', fn ($query) => $rows($query)->whereNotNull('deleted_at'), $stats);
+ if ($cutoff = $policy->cutoff('position_retention_days')) {
+ $this->deleteInBatches('positions', 'id', fn ($query) => $rows($query)->where('created_at', '<', $cutoff->toDateTimeString()), $stats);
+ }
+
+ return $stats;
+ }
+
+ protected function pruneDeliveries(\Closure $inbox, RetentionPolicy $policy): array
+ {
+ $stats = $this->emptyTableStats();
+ foreach (['processed' => 'processed_retention_hours', 'quarantined' => 'quarantine_retention_days'] as $status => $key) {
+ if ($cutoff = $policy->cutoff($key)) {
+ $this->deleteInBatches('telematic_deliveries', 'uuid', fn ($query) => $inbox($query)->where('status', $status)->where('updated_at', '<', $cutoff->toDateTimeString()), $stats);
+ }
+ }
+
+ return $stats;
+ }
+
+ protected function pruneSyncRuns(\Closure $inbox, RetentionPolicy $policy): array
+ {
+ $stats = $this->emptyTableStats();
+ if ($cutoff = $policy->cutoff('sync_run_retention_days')) {
+ // In-flight runs are recovered by the drain command, never expired here.
+ $this->deleteInBatches('telematic_sync_runs', 'uuid', fn ($query) => $inbox($query)->whereNotIn('status', ['fetching', 'ingesting'])->where('updated_at', '<', $cutoff->toDateTimeString()), $stats);
+ }
+
+ return $stats;
+ }
+
+ protected function deleteInBatches(string $table, string $key, \Closure $scope, array &$stats): void
+ {
+ if ($this->dryRun) {
+ $stats['deleted'] += $scope(DB::table($table))->count();
+
+ return;
+ }
+ for ($batch = 0; $batch < $this->maxBatches; $batch++) {
+ $ids = $scope(DB::table($table))->orderBy($key)->limit($this->batchSize)->pluck($key);
+ if ($ids->isEmpty()) {
+ return;
+ }
+ $stats['batches']++;
+ $stats['deleted'] += DB::table($table)->whereIn($key, $ids->all())->delete();
+ }
+ $stats['capped'] = true;
+ }
+
+ protected function updateInBatches(string $table, string $key, \Closure $scope, array $values, array &$stats): void
+ {
+ if ($this->dryRun) {
+ $stats['compacted'] += $scope(DB::table($table))->count();
+
+ return;
+ }
+ for ($batch = 0; $batch < $this->maxBatches; $batch++) {
+ $ids = $scope(DB::table($table))->orderBy($key)->limit($this->batchSize)->pluck($key);
+ if ($ids->isEmpty()) {
+ return;
+ }
+ $stats['batches']++;
+ $stats['compacted'] += DB::table($table)->whereIn($key, $ids->all())->update($values);
+ }
+ $stats['capped'] = true;
+ }
+
+ protected function report(string $label, array $stats): void
+ {
+ foreach ($stats['tables'] as $table => $counts) {
+ if (!$counts['deleted'] && !$counts['compacted'] && !$counts['capped']) {
+ continue;
+ }
+ $this->info(sprintf('%s %s: deleted=%d compacted=%d batches=%d%s', $label, $table, $counts['deleted'], $counts['compacted'], $counts['batches'], $counts['capped'] ? ' capped' : ''));
+ }
+ }
+
+ protected function emptyTableStats(): array
+ {
+ return ['deleted' => 0, 'compacted' => 0, 'batches' => 0, 'capped' => false];
+ }
+
+ protected function emptyStats(): array
+ {
+ return ['deleted' => 0, 'compacted' => 0, 'batches' => 0, 'capped' => false, 'tables' => []];
+ }
+
+ protected function mergeStats(array $totals, array $stats): array
+ {
+ $totals['deleted'] += $stats['deleted'];
+ $totals['compacted'] += $stats['compacted'];
+ $totals['batches'] += $stats['batches'];
+ $totals['capped'] = $totals['capped'] || $stats['capped'];
+
+ return $totals;
+ }
+}
diff --git a/server/src/Http/Controllers/Api/v1/OrchestrationController.php b/server/src/Http/Controllers/Api/v1/OrchestrationController.php
index f6f5b9c27..025e7afd2 100644
--- a/server/src/Http/Controllers/Api/v1/OrchestrationController.php
+++ b/server/src/Http/Controllers/Api/v1/OrchestrationController.php
@@ -3,6 +3,7 @@
namespace Fleetbase\FleetOps\Http\Controllers\Api\v1;
use Fleetbase\FleetOps\Http\Controllers\Internal\v1\OrchestrationController as InternalOrchestrationController;
+use Fleetbase\FleetOps\Orchestration\OrchestrationEngineRegistry;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -14,6 +15,14 @@
*/
class OrchestrationController extends InternalOrchestrationController
{
+ public function __construct(OrchestrationEngineRegistry $registry)
+ {
+ // The fleetbase.api route group authenticates API credentials and establishes
+ // their company scope. Console IAM middleware belongs to the internal
+ // workbench, not to these organization-credential endpoints.
+ $this->registry = $registry;
+ }
+
public function run(Request $request): JsonResponse
{
return $this->publicResponse(parent::run($request));
diff --git a/server/src/Http/Controllers/FleetOpsController.php b/server/src/Http/Controllers/FleetOpsController.php
index fadafc391..dbad90ef7 100644
--- a/server/src/Http/Controllers/FleetOpsController.php
+++ b/server/src/Http/Controllers/FleetOpsController.php
@@ -2,12 +2,33 @@
namespace Fleetbase\FleetOps\Http\Controllers;
+use Fleetbase\FleetOps\Traits\AuthorizesMethods;
use Fleetbase\Http\Controllers\FleetbaseController;
+use Illuminate\Database\Eloquent\Model;
class FleetOpsController extends FleetbaseController
{
+ use AuthorizesMethods;
+
/**
* The package namespace used to resolve from.
*/
public string $namespace = '\\Fleetbase\\FleetOps';
+
+ /**
+ * Explicit permissions for custom actions, keyed by controller method, e.g.
+ * `['bulkDispatch' => 'dispatch order']`. Those methods also carry
+ * `#[SkipAuthorizationCheck]` so AuthorizationGuard does not demand the
+ * permission it would otherwise infer from the HTTP verb.
+ *
+ * @var array
+ */
+ protected array $methodPermissions = [];
+
+ public function __construct(?Model $model = null, ?string $resource = null)
+ {
+ parent::__construct($model, $resource);
+
+ $this->authorizeMethods($this->methodPermissions);
+ }
}
diff --git a/server/src/Http/Controllers/Internal/v1/AnalyticsController.php b/server/src/Http/Controllers/Internal/v1/AnalyticsController.php
index 654be49ab..e88f7f6cd 100644
--- a/server/src/Http/Controllers/Internal/v1/AnalyticsController.php
+++ b/server/src/Http/Controllers/Internal/v1/AnalyticsController.php
@@ -14,6 +14,7 @@
use Fleetbase\FleetOps\Support\Analytics\OrdersByStatus;
use Fleetbase\FleetOps\Support\Analytics\RevenueTrend;
use Fleetbase\FleetOps\Support\Analytics\TopDrivers;
+use Fleetbase\FleetOps\Support\Authorization;
use Fleetbase\Http\Controllers\Controller;
use Illuminate\Http\Request;
@@ -28,6 +29,16 @@
*/
class AnalyticsController extends Controller
{
+ public function __construct()
+ {
+ // Not a resource controller, so AuthorizationGuard cannot resolve a permission for it.
+ $this->middleware(function ($request, $next) {
+ Authorization::authorize('view analytics');
+
+ return $next($request);
+ });
+ }
+
public function operationsPulse(Request $request)
{
return $this->run($request, OperationsPulse::class);
diff --git a/server/src/Http/Controllers/Internal/v1/ContactController.php b/server/src/Http/Controllers/Internal/v1/ContactController.php
index 402fd4eb9..f4b3346f2 100644
--- a/server/src/Http/Controllers/Internal/v1/ContactController.php
+++ b/server/src/Http/Controllers/Internal/v1/ContactController.php
@@ -2,6 +2,7 @@
namespace Fleetbase\FleetOps\Http\Controllers\Internal\v1;
+use Fleetbase\Attributes\SkipAuthorizationCheck;
use Fleetbase\FleetOps\Exports\ContactExport;
use Fleetbase\FleetOps\Http\Controllers\FleetOpsController;
use Fleetbase\FleetOps\Http\Resources\v1\Vendor as VendorResource;
@@ -26,6 +27,15 @@
class ContactController extends FleetOpsController
{
+ /**
+ * Permissions for methods AuthorizationGuard cannot map to a schema action (see FleetOpsController).
+ *
+ * @var array
+ */
+ protected array $methodPermissions = [
+ 'convertToVendor' => 'create vendor',
+ ];
+
/**
* The resource to query.
*
@@ -108,6 +118,7 @@ public function getAsCustomer($id)
]);
}
+ #[SkipAuthorizationCheck]
public function convertToVendor(Request $request, string $id)
{
$contact = $this->contactForVendorConversion($id);
diff --git a/server/src/Http/Controllers/Internal/v1/CustomerController.php b/server/src/Http/Controllers/Internal/v1/CustomerController.php
index e17f4c4fd..b20ed00b4 100644
--- a/server/src/Http/Controllers/Internal/v1/CustomerController.php
+++ b/server/src/Http/Controllers/Internal/v1/CustomerController.php
@@ -5,6 +5,7 @@
use Fleetbase\FleetOps\Mail\CustomerCredentialsMail;
use Fleetbase\FleetOps\Models\Contact;
use Fleetbase\FleetOps\Support\ProfileAccountManager;
+use Fleetbase\FleetOps\Traits\AuthorizesMethods;
use Fleetbase\Http\Controllers\Controller;
use Fleetbase\Models\User;
use Illuminate\Http\Request;
@@ -13,6 +14,19 @@
class CustomerController extends Controller
{
+ use AuthorizesMethods;
+
+ public function __construct()
+ {
+ $this->authorizeMethods([
+ 'createPortalLogin' => 'reset-credentials-for customer',
+ 'sendCredentials' => 'reset-credentials-for customer',
+ 'deactivatePortalLogin' => 'reset-credentials-for customer',
+ 'reactivatePortalLogin' => 'reset-credentials-for customer',
+ 'resetCredentials' => 'reset-credentials-for customer',
+ ]);
+ }
+
public function createPortalLogin(Request $request)
{
$customer = $this->resolveCustomer($request);
diff --git a/server/src/Http/Controllers/Internal/v1/DeviceController.php b/server/src/Http/Controllers/Internal/v1/DeviceController.php
index 09efcd960..ce969fbc2 100644
--- a/server/src/Http/Controllers/Internal/v1/DeviceController.php
+++ b/server/src/Http/Controllers/Internal/v1/DeviceController.php
@@ -2,6 +2,7 @@
namespace Fleetbase\FleetOps\Http\Controllers\Internal\v1;
+use Fleetbase\Attributes\SkipAuthorizationCheck;
use Fleetbase\FleetOps\Exceptions\DeviceAlreadyAttachedException;
use Fleetbase\FleetOps\Exports\DeviceExport;
use Fleetbase\FleetOps\Http\Controllers\FleetOpsController;
@@ -17,6 +18,16 @@
class DeviceController extends FleetOpsController
{
+ /**
+ * Permissions for methods AuthorizationGuard cannot map to a schema action (see FleetOpsController).
+ *
+ * @var array
+ */
+ protected array $methodPermissions = [
+ 'attach' => 'update device',
+ 'detach' => 'update device',
+ ];
+
/**
* The resource to query.
*
@@ -110,6 +121,7 @@ public static function onFindRecord($query, $request): void
/**
* Attach a device to a supported FleetOps resource.
*/
+ #[SkipAuthorizationCheck]
public function attach(Request $request, string $id): JsonResponse
{
$request->validate([
@@ -152,6 +164,7 @@ public function attach(Request $request, string $id): JsonResponse
/**
* Detach a device from its current FleetOps resource.
*/
+ #[SkipAuthorizationCheck]
public function detach(string $id): JsonResponse
{
$device = $this->resolveDevice($id);
diff --git a/server/src/Http/Controllers/Internal/v1/DeviceEventController.php b/server/src/Http/Controllers/Internal/v1/DeviceEventController.php
index 80b8d8467..8735cc042 100644
--- a/server/src/Http/Controllers/Internal/v1/DeviceEventController.php
+++ b/server/src/Http/Controllers/Internal/v1/DeviceEventController.php
@@ -2,6 +2,7 @@
namespace Fleetbase\FleetOps\Http\Controllers\Internal\v1;
+use Fleetbase\Attributes\SkipAuthorizationCheck;
use Fleetbase\FleetOps\Http\Controllers\FleetOpsController;
use Fleetbase\FleetOps\Models\DeviceEvent;
use Fleetbase\FleetOps\Support\Utils;
@@ -9,6 +10,15 @@
class DeviceEventController extends FleetOpsController
{
+ /**
+ * Permissions for methods AuthorizationGuard cannot map to a schema action (see FleetOpsController).
+ *
+ * @var array
+ */
+ protected array $methodPermissions = [
+ 'markProcessed' => 'update device-event',
+ ];
+
/**
* The resource to query.
*
@@ -51,6 +61,7 @@ public static function onQueryRecord($query, $request): void
}
}
+ #[SkipAuthorizationCheck]
public function markProcessed(string $id): JsonResponse
{
$deviceEvent = DeviceEvent::where('company_uuid', session('company'))
diff --git a/server/src/Http/Controllers/Internal/v1/DriverController.php b/server/src/Http/Controllers/Internal/v1/DriverController.php
index 26203e962..87d591cee 100644
--- a/server/src/Http/Controllers/Internal/v1/DriverController.php
+++ b/server/src/Http/Controllers/Internal/v1/DriverController.php
@@ -2,6 +2,7 @@
namespace Fleetbase\FleetOps\Http\Controllers\Internal\v1;
+use Fleetbase\Attributes\SkipAuthorizationCheck;
use Fleetbase\Exceptions\FleetbaseRequestValidationException;
use Fleetbase\FleetOps\Exceptions\ProfileIdentityConflictException;
use Fleetbase\FleetOps\Exports\DriverExport;
@@ -33,6 +34,20 @@
class DriverController extends FleetOpsController
{
use Traits\DriverSchedulingTrait;
+ /**
+ * Permissions for methods AuthorizationGuard cannot map to a schema action (see FleetOpsController).
+ *
+ * @var array
+ */
+ protected array $methodPermissions = [
+ 'sendCredentials' => 'update-user-for driver',
+ 'resetCredentials' => 'update-user-for driver',
+ 'deactivateLogin' => 'update-user-for driver',
+ 'reactivateLogin' => 'update-user-for driver',
+ 'unassignOrders' => 'assign-order-for driver',
+ 'unassignOrder' => 'assign-order-for driver',
+ 'unassignVehicle' => 'assign-vehicle-for driver',
+ ];
/**
* The resource to query.
*
@@ -200,6 +215,7 @@ function ($request, &$driver) {
*
* @return JsonResponse
*/
+ #[SkipAuthorizationCheck]
public function sendCredentials(string $id)
{
[$driver, $user, $error] = $this->resolveDriverLogin($id);
@@ -221,6 +237,7 @@ public function sendCredentials(string $id)
*
* @return JsonResponse
*/
+ #[SkipAuthorizationCheck]
public function resetCredentials(Request $request, string $id)
{
$password = $request->input('password');
@@ -255,6 +272,7 @@ public function resetCredentials(Request $request, string $id)
*
* @return JsonResponse
*/
+ #[SkipAuthorizationCheck]
public function deactivateLogin(string $id)
{
[$driver, $user, $error] = $this->resolveDriverLogin($id);
@@ -275,6 +293,7 @@ public function deactivateLogin(string $id)
*
* @return JsonResponse
*/
+ #[SkipAuthorizationCheck]
public function reactivateLogin(string $id)
{
[$driver, $user, $error] = $this->resolveDriverLogin($id);
@@ -430,6 +449,7 @@ public function assignedOrders(string $id): JsonResponse
]);
}
+ #[SkipAuthorizationCheck]
public function unassignOrders(Request $request, string $id): JsonResponse
{
$request->validate([
@@ -462,6 +482,7 @@ public function unassignOrders(Request $request, string $id): JsonResponse
]);
}
+ #[SkipAuthorizationCheck]
public function unassignOrder(string $id): JsonResponse
{
$driver = $this->findDriver($id);
@@ -498,6 +519,7 @@ public function assignVehicle(Request $request, string $id): JsonResponse
]);
}
+ #[SkipAuthorizationCheck]
public function unassignVehicle(string $id): JsonResponse
{
$driver = $this->findDriver($id);
diff --git a/server/src/Http/Controllers/Internal/v1/FuelProviderConnectionController.php b/server/src/Http/Controllers/Internal/v1/FuelProviderConnectionController.php
index af7684887..df99e25e3 100644
--- a/server/src/Http/Controllers/Internal/v1/FuelProviderConnectionController.php
+++ b/server/src/Http/Controllers/Internal/v1/FuelProviderConnectionController.php
@@ -2,6 +2,7 @@
namespace Fleetbase\FleetOps\Http\Controllers\Internal\v1;
+use Fleetbase\Attributes\SkipAuthorizationCheck;
use Fleetbase\FleetOps\Http\Controllers\FleetOpsController;
use Fleetbase\FleetOps\Jobs\SyncFuelProviderTransactionsJob;
use Fleetbase\FleetOps\Models\FuelProviderConnection;
@@ -13,6 +14,16 @@
class FuelProviderConnectionController extends FleetOpsController
{
+ /**
+ * Permissions for methods AuthorizationGuard cannot map to a schema action (see FleetOpsController).
+ *
+ * @var array
+ */
+ protected array $methodPermissions = [
+ 'testCredentials' => 'create fuel-provider-connection',
+ 'testConnection' => 'update fuel-provider-connection',
+ ];
+
public $resource = 'fuel_provider_connection';
public function __construct(protected FuelProviderService $fuelProviderService)
@@ -38,6 +49,7 @@ public function onBeforeUpdate(Request $request, FuelProviderConnection $connect
$this->normalizeConnectionInput($input, $connection);
}
+ #[SkipAuthorizationCheck]
public function testCredentials(Request $request, string $provider): JsonResponse
{
$request->validate([
@@ -54,6 +66,7 @@ public function testCredentials(Request $request, string $provider): JsonRespons
return response()->json($result, data_get($result, 'success') ? 200 : 422);
}
+ #[SkipAuthorizationCheck]
public function testConnection(Request $request, string $id): JsonResponse
{
$connection = $this->findConnection($id);
diff --git a/server/src/Http/Controllers/Internal/v1/FuelProviderTransactionController.php b/server/src/Http/Controllers/Internal/v1/FuelProviderTransactionController.php
index 926bd00f5..b219793ff 100644
--- a/server/src/Http/Controllers/Internal/v1/FuelProviderTransactionController.php
+++ b/server/src/Http/Controllers/Internal/v1/FuelProviderTransactionController.php
@@ -2,6 +2,7 @@
namespace Fleetbase\FleetOps\Http\Controllers\Internal\v1;
+use Fleetbase\Attributes\SkipAuthorizationCheck;
use Fleetbase\FleetOps\Http\Controllers\FleetOpsController;
use Fleetbase\FleetOps\Models\FuelProviderTransaction;
use Fleetbase\FleetOps\Support\FuelProviders\FuelProviderService;
@@ -10,6 +11,17 @@
class FuelProviderTransactionController extends FleetOpsController
{
+ /**
+ * Permissions for methods AuthorizationGuard cannot map to a schema action (see FleetOpsController).
+ *
+ * @var array
+ */
+ protected array $methodPermissions = [
+ 'matchVehicle' => 'update fuel-provider-transaction',
+ 'matchOrder' => 'update fuel-provider-transaction',
+ 'reprocess' => 'update fuel-provider-transaction',
+ ];
+
public $resource = 'fuel_provider_transaction';
public function __construct(protected FuelProviderService $fuelProviderService)
@@ -22,6 +34,7 @@ public static function onQueryRecord($query, $request): void
$query->with(['vehicle', 'driver', 'fuelReport']);
}
+ #[SkipAuthorizationCheck]
public function matchVehicle(Request $request, string $id): JsonResponse
{
$request->validate(['vehicle' => 'required|string']);
@@ -33,6 +46,7 @@ public function matchVehicle(Request $request, string $id): JsonResponse
]);
}
+ #[SkipAuthorizationCheck]
public function matchOrder(Request $request, string $id): JsonResponse
{
$request->validate(['order' => 'required|string']);
@@ -44,6 +58,7 @@ public function matchOrder(Request $request, string $id): JsonResponse
]);
}
+ #[SkipAuthorizationCheck]
public function reprocess(Request $request, string $id): JsonResponse
{
$transaction = $this->findTransaction($id);
diff --git a/server/src/Http/Controllers/Internal/v1/FuelReportController.php b/server/src/Http/Controllers/Internal/v1/FuelReportController.php
index 9ff460a31..c2e8db2bf 100644
--- a/server/src/Http/Controllers/Internal/v1/FuelReportController.php
+++ b/server/src/Http/Controllers/Internal/v1/FuelReportController.php
@@ -9,6 +9,7 @@
use Fleetbase\Http\Requests\ExportRequest;
use Fleetbase\Http\Requests\ImportRequest;
use Illuminate\Http\Request;
+use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Maatwebsite\Excel\Facades\Excel;
@@ -23,11 +24,16 @@ class FuelReportController extends FleetOpsController
/**
* Handle post save transactions.
+ *
+ * Reads the custom field values from the payload the base controller already extracted
+ * rather than from the raw request: Ember Data sends the resource under a camelCase root
+ * (`fuelReport`), so `$request->array('fuel_report.custom_field_values')` was always empty and no
+ * custom field value was ever persisted for this resource.
*/
- public function afterSave(Request $request, FuelReport $fuelReport)
+ public function afterSave(Request $request, FuelReport $fuelReport, array $input = [])
{
- $customFieldValues = $request->array('fuel_report.custom_field_values');
- if ($customFieldValues) {
+ $customFieldValues = Arr::get($input, 'custom_field_values');
+ if (is_array($customFieldValues) && $customFieldValues) {
$fuelReport->syncCustomFieldValues($customFieldValues);
}
}
diff --git a/server/src/Http/Controllers/Internal/v1/InspectionFormController.php b/server/src/Http/Controllers/Internal/v1/InspectionFormController.php
index b77373a82..f7766cbbd 100644
--- a/server/src/Http/Controllers/Internal/v1/InspectionFormController.php
+++ b/server/src/Http/Controllers/Internal/v1/InspectionFormController.php
@@ -2,6 +2,7 @@
namespace Fleetbase\FleetOps\Http\Controllers\Internal\v1;
+use Fleetbase\Attributes\SkipAuthorizationCheck;
use Fleetbase\FleetOps\Http\Controllers\FleetOpsController;
use Fleetbase\FleetOps\Http\Resources\v1\InspectionLink as InspectionLinkResource;
use Fleetbase\FleetOps\Models\Driver;
@@ -16,6 +17,17 @@
class InspectionFormController extends FleetOpsController
{
+ /**
+ * Permissions for methods AuthorizationGuard cannot map to a schema action (see FleetOpsController).
+ *
+ * @var array
+ */
+ protected array $methodPermissions = [
+ 'generateLink' => 'publish inspection-form',
+ 'revokeLink' => 'publish inspection-form',
+ 'sendPin' => 'publish inspection-form',
+ ];
+
/**
* The resource to query.
*
@@ -107,6 +119,7 @@ public function archive(string $id): JsonResponse
]);
}
+ #[SkipAuthorizationCheck]
public function generateLink(Request $request, string $id): JsonResponse
{
$form = $this->resolveForm($id)
@@ -215,6 +228,7 @@ public function links(Request $request, string $id): JsonResponse
}
/** Take a link out of use, leaving the record of it in the list. */
+ #[SkipAuthorizationCheck]
public function revokeLink(Request $request, string $id, string $linkId): JsonResponse
{
$form = $this->resolveForm($id)->firstOrFail();
@@ -243,6 +257,7 @@ public function revokeLink(Request $request, string $id, string $linkId): JsonRe
* A failed delivery answers 200 with `pin_delivery.sent` false and why, so
* the console shows it as a warning rather than an error.
*/
+ #[SkipAuthorizationCheck]
public function sendPin(Request $request, string $id, string $linkId): JsonResponse
{
$form = $this->resolveForm($id)->firstOrFail();
diff --git a/server/src/Http/Controllers/Internal/v1/LiveController.php b/server/src/Http/Controllers/Internal/v1/LiveController.php
index 1d8cdf7ac..690960baf 100644
--- a/server/src/Http/Controllers/Internal/v1/LiveController.php
+++ b/server/src/Http/Controllers/Internal/v1/LiveController.php
@@ -18,6 +18,7 @@
use Fleetbase\FleetOps\Support\LiveCacheService;
use Fleetbase\FleetOps\Support\LiveOrderQuery;
use Fleetbase\FleetOps\Support\Utils;
+use Fleetbase\FleetOps\Traits\AuthorizesMethods;
use Fleetbase\Http\Controllers\Controller;
use Illuminate\Http\Request;
@@ -26,6 +27,21 @@
*/
class LiveController extends Controller
{
+ use AuthorizesMethods;
+
+ public function __construct()
+ {
+ $this->authorizeMethods([
+ 'coordinates' => 'list order',
+ 'routes' => 'list order',
+ 'orders' => 'list order',
+ 'drivers' => 'list driver',
+ 'vehicles' => 'list vehicle',
+ 'operationsMonitor' => 'list order',
+ 'places' => 'list place',
+ ]);
+ }
+
protected const DEFAULT_VIEWPORT_LIMIT = 500;
protected const MAX_VIEWPORT_LIMIT = 1000;
protected const VIEWPORT_BOUNDS_PRECISION = 4;
diff --git a/server/src/Http/Controllers/Internal/v1/MaintenanceController.php b/server/src/Http/Controllers/Internal/v1/MaintenanceController.php
index e3153d45c..4205c2fe9 100644
--- a/server/src/Http/Controllers/Internal/v1/MaintenanceController.php
+++ b/server/src/Http/Controllers/Internal/v1/MaintenanceController.php
@@ -2,6 +2,7 @@
namespace Fleetbase\FleetOps\Http\Controllers\Internal\v1;
+use Fleetbase\Attributes\SkipAuthorizationCheck;
use Fleetbase\FleetOps\Exports\MaintenanceExport;
use Fleetbase\FleetOps\Http\Controllers\FleetOpsController;
use Fleetbase\FleetOps\Imports\MaintenanceImport;
@@ -15,6 +16,17 @@
class MaintenanceController extends FleetOpsController
{
+ /**
+ * Permissions for methods AuthorizationGuard cannot map to a schema action (see FleetOpsController).
+ *
+ * @var array
+ */
+ protected array $methodPermissions = [
+ 'addLineItem' => 'update maintenance',
+ 'updateLineItem' => 'update maintenance',
+ 'removeLineItem' => 'update maintenance',
+ ];
+
/**
* The resource to query.
*
@@ -67,6 +79,7 @@ public function onFindRecord($builder, $request): void
* Add a cost line item to a maintenance record.
* POST /maintenances/{id}/line-items.
*/
+ #[SkipAuthorizationCheck]
public function addLineItem(string $id, Request $request): JsonResponse
{
$maintenance = $this->findMaintenanceForLineItem($id);
@@ -89,6 +102,7 @@ public function addLineItem(string $id, Request $request): JsonResponse
* Update a cost line item on a maintenance record.
* PUT /maintenances/{id}/line-items/{index}.
*/
+ #[SkipAuthorizationCheck]
public function updateLineItem(string $id, int $index, Request $request): JsonResponse
{
$maintenance = $this->findMaintenanceForLineItem($id);
@@ -121,6 +135,7 @@ public function updateLineItem(string $id, int $index, Request $request): JsonRe
* Remove a cost line item from a maintenance record.
* DELETE /maintenances/{id}/line-items/{index}.
*/
+ #[SkipAuthorizationCheck]
public function removeLineItem(string $id, int $index): JsonResponse
{
$maintenance = $this->findMaintenanceForLineItem($id);
diff --git a/server/src/Http/Controllers/Internal/v1/MaintenanceScheduleController.php b/server/src/Http/Controllers/Internal/v1/MaintenanceScheduleController.php
index 15cfa292b..083ef8aad 100644
--- a/server/src/Http/Controllers/Internal/v1/MaintenanceScheduleController.php
+++ b/server/src/Http/Controllers/Internal/v1/MaintenanceScheduleController.php
@@ -2,6 +2,7 @@
namespace Fleetbase\FleetOps\Http\Controllers\Internal\v1;
+use Fleetbase\Attributes\SkipAuthorizationCheck;
use Fleetbase\FleetOps\Exports\MaintenanceScheduleExport;
use Fleetbase\FleetOps\Http\Controllers\FleetOpsController;
use Fleetbase\FleetOps\Imports\MaintenanceScheduleImport;
@@ -22,6 +23,17 @@
class MaintenanceScheduleController extends FleetOpsController
{
+ /**
+ * Permissions for methods AuthorizationGuard cannot map to a schema action (see FleetOpsController).
+ *
+ * @var array
+ */
+ protected array $methodPermissions = [
+ 'pause' => 'update maintenance-schedule',
+ 'resume' => 'update maintenance-schedule',
+ 'trigger' => 'update maintenance-schedule',
+ ];
+
/**
* The resource to query.
*
@@ -71,6 +83,7 @@ public function import(ImportRequest $request)
* Pause a maintenance schedule.
* POST /maintenance-schedules/{id}/pause.
*/
+ #[SkipAuthorizationCheck]
public function pause(string $id): JsonResponse
{
$schedule = $this->findSchedule($id);
@@ -88,6 +101,7 @@ public function pause(string $id): JsonResponse
* Resume a paused maintenance schedule.
* POST /maintenance-schedules/{id}/resume.
*/
+ #[SkipAuthorizationCheck]
public function resume(string $id): JsonResponse
{
$schedule = $this->findSchedule($id);
@@ -105,6 +119,7 @@ public function resume(string $id): JsonResponse
* Manually trigger a work order from a schedule.
* POST /maintenance-schedules/{id}/trigger.
*/
+ #[SkipAuthorizationCheck]
public function trigger(string $id, Request $request): JsonResponse
{
$schedule = $this->findSchedule($id);
diff --git a/server/src/Http/Controllers/Internal/v1/ManifestController.php b/server/src/Http/Controllers/Internal/v1/ManifestController.php
index 80f003605..42329337d 100644
--- a/server/src/Http/Controllers/Internal/v1/ManifestController.php
+++ b/server/src/Http/Controllers/Internal/v1/ManifestController.php
@@ -4,6 +4,7 @@
use Fleetbase\FleetOps\Models\Manifest;
use Fleetbase\FleetOps\Models\ManifestStop;
+use Fleetbase\FleetOps\Traits\AuthorizesMethods;
use Fleetbase\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -23,6 +24,20 @@
*/
class ManifestController extends Controller
{
+ use AuthorizesMethods;
+
+ public function __construct()
+ {
+ $this->authorizeMethods([
+ 'index' => 'list order',
+ 'show' => 'view order',
+ 'cancel' => 'cancel order',
+ 'destroy' => 'delete order',
+ 'showStop' => 'view order',
+ 'updateStop' => 'update order',
+ ]);
+ }
+
/**
* List manifests for the current company.
* Supports filtering by status, driver_id, vehicle_id, and scheduled_date.
diff --git a/server/src/Http/Controllers/Internal/v1/MetricsController.php b/server/src/Http/Controllers/Internal/v1/MetricsController.php
index f61a5e637..4d3b374fd 100644
--- a/server/src/Http/Controllers/Internal/v1/MetricsController.php
+++ b/server/src/Http/Controllers/Internal/v1/MetricsController.php
@@ -2,6 +2,7 @@
namespace Fleetbase\FleetOps\Http\Controllers\Internal\v1;
+use Fleetbase\FleetOps\Support\Authorization;
use Fleetbase\FleetOps\Support\Metrics;
use Fleetbase\FleetOps\Support\Metrics\Registry;
use Fleetbase\Http\Controllers\Controller;
@@ -10,6 +11,16 @@
class MetricsController extends Controller
{
+ public function __construct()
+ {
+ // Not a resource controller, so AuthorizationGuard cannot resolve a permission for it.
+ $this->middleware(function ($request, $next) {
+ Authorization::authorize('view analytics');
+
+ return $next($request);
+ });
+ }
+
/**
* Legacy bulk endpoint. Returns a flat map of slug → scalar value for the
* requested period. Preserved for backward compat; the dashboard widgets
diff --git a/server/src/Http/Controllers/Internal/v1/NavigatorController.php b/server/src/Http/Controllers/Internal/v1/NavigatorController.php
index 868f7b56a..2da2fdc5e 100644
--- a/server/src/Http/Controllers/Internal/v1/NavigatorController.php
+++ b/server/src/Http/Controllers/Internal/v1/NavigatorController.php
@@ -3,6 +3,7 @@
namespace Fleetbase\FleetOps\Http\Controllers\Internal\v1;
use Fleetbase\FleetOps\Support\Utils;
+use Fleetbase\FleetOps\Traits\AuthorizesMethods;
use Fleetbase\Http\Controllers\Controller;
use Fleetbase\Http\Resources\Organization;
use Fleetbase\Models\ApiCredential;
@@ -15,6 +16,19 @@
class NavigatorController extends Controller
{
+ use AuthorizesMethods;
+ /**
+ * How long a Navigator link issued from the console stays valid.
+ */
+ public const LINK_TTL_MINUTES = 30;
+
+ public function __construct()
+ {
+ $this->authorizeMethods([
+ 'getLinkAppUrl' => 'admin',
+ ]);
+ }
+
/**
* Redirects to the Fleetbase Navigator app using a deep link.
* Automatically detects the platform (iOS or Android) and uses the correct URI scheme.
@@ -23,6 +37,12 @@ class NavigatorController extends Controller
*/
public function linkApp(Request $request)
{
+ // This route is opened from a phone without a console session, so it is
+ // authorised by the short-lived signature issued by getLinkAppUrl().
+ if (!$this->hasValidLinkSignature($request)) {
+ return response()->error('This Navigator link is invalid or has expired. Generate a new one from the console.', 403);
+ }
+
$adminUser = $this->findAdminUser();
if (!$adminUser || !$adminUser->company) {
@@ -67,8 +87,13 @@ public function linkApp(Request $request)
*/
public function getLinkAppUrl()
{
+ $expires = now()->addMinutes(static::LINK_TTL_MINUTES)->getTimestamp();
+
return response()->json([
- 'linkUrl' => url('int/v1/fleet-ops/navigator/link-app'),
+ 'linkUrl' => url('int/v1/fleet-ops/navigator/link-app') . '?' . http_build_query([
+ 'expires' => $expires,
+ 'signature' => static::linkSignature($expires),
+ ]),
]);
}
@@ -189,4 +214,20 @@ protected function driverOnboardSettings(): mixed
{
return Setting::where('key', 'fleet-ops.driver-onboard')->value('value');
}
+
+ /**
+ * HMAC signature for a Navigator link expiring at the given unix timestamp.
+ */
+ public static function linkSignature(int $expires): string
+ {
+ return hash_hmac('sha256', 'fleet-ops.navigator.link-app|' . $expires, (string) config('app.key'));
+ }
+
+ protected function hasValidLinkSignature(Request $request): bool
+ {
+ $expires = (int) $request->query('expires');
+ $signature = (string) $request->query('signature');
+
+ return $signature !== '' && $expires >= time() && hash_equals(static::linkSignature($expires), $signature);
+ }
}
diff --git a/server/src/Http/Controllers/Internal/v1/OrchestrationController.php b/server/src/Http/Controllers/Internal/v1/OrchestrationController.php
index a4e011d47..965960a58 100644
--- a/server/src/Http/Controllers/Internal/v1/OrchestrationController.php
+++ b/server/src/Http/Controllers/Internal/v1/OrchestrationController.php
@@ -16,6 +16,7 @@
use Fleetbase\FleetOps\Orchestration\Engines\DriverAssignmentEngine;
use Fleetbase\FleetOps\Orchestration\Engines\RouteSequencingEngine;
use Fleetbase\FleetOps\Orchestration\OrchestrationEngineRegistry;
+use Fleetbase\FleetOps\Traits\AuthorizesMethods;
use Fleetbase\Http\Controllers\Controller;
use Fleetbase\Models\Setting;
use Illuminate\Http\JsonResponse;
@@ -39,8 +40,19 @@
*/
class OrchestrationController extends Controller
{
+ use AuthorizesMethods;
+
public function __construct(protected OrchestrationEngineRegistry $registry)
{
+ $this->authorizeMethods([
+ 'orders' => 'list order',
+ 'run' => 'optimize order',
+ 'preview' => 'list order',
+ 'commit' => 'assign-driver-for order',
+ 'engines' => 'list order',
+ 'orderConfigFields' => 'list order',
+ 'importOrders' => 'import order',
+ ]);
}
/**
diff --git a/server/src/Http/Controllers/Internal/v1/OrderController.php b/server/src/Http/Controllers/Internal/v1/OrderController.php
index 2751c2114..32b762c42 100644
--- a/server/src/Http/Controllers/Internal/v1/OrderController.php
+++ b/server/src/Http/Controllers/Internal/v1/OrderController.php
@@ -2,6 +2,7 @@
namespace Fleetbase\FleetOps\Http\Controllers\Internal\v1;
+use Fleetbase\Attributes\SkipAuthorizationCheck;
use Fleetbase\Exceptions\FleetbaseRequestValidationException;
use Fleetbase\FleetOps\Events\EntityActivityChanged;
use Fleetbase\FleetOps\Events\EntityCompleted;
@@ -53,6 +54,20 @@
class OrderController extends FleetOpsController
{
use ResolvesOrderServiceStops;
+ /**
+ * Permissions for methods AuthorizationGuard cannot map to a schema action (see FleetOpsController).
+ *
+ * @var array
+ */
+ protected array $methodPermissions = [
+ 'editOrderRoute' => 'update-route-for order',
+ 'importFromFiles' => 'import order',
+ 'bulkCancel' => 'cancel order',
+ 'bulkDispatch' => 'dispatch order',
+ 'bulkAssignDriver' => 'assign-driver-for order',
+ 'dispatchOrder' => 'dispatch order',
+ 'scheduleOrder' => 'schedule order',
+ ];
/**
* The resource to query.
@@ -267,6 +282,7 @@ protected function normalizeCustomerType(array &$input): void
*
* @return Response
*/
+ #[SkipAuthorizationCheck]
public function editOrderRoute(string $id, Request $request)
{
$pickup = $request->input('pickup');
@@ -337,6 +353,7 @@ public function editOrderRoute(string $id, Request $request)
*
* @return \Illuminate\Http\Response
*/
+ #[SkipAuthorizationCheck]
public function importFromFiles(Request $request)
{
$info = Utils::lookupIp();
@@ -409,6 +426,7 @@ public function importFromFiles(Request $request)
*
* @return \Illuminate\Http\Response
*/
+ #[SkipAuthorizationCheck]
public function bulkCancel(Request $request)
{
$request->validate([
@@ -454,6 +472,7 @@ public function bulkCancel(Request $request)
*
* @return \Illuminate\Http\Response
*/
+ #[SkipAuthorizationCheck]
public function bulkDispatch(BulkDispatchRequest $request)
{
/** @var Order */
@@ -502,6 +521,7 @@ public function bulkDispatch(BulkDispatchRequest $request)
*
* @return \Illuminate\Http\Response
*/
+ #[SkipAuthorizationCheck]
public function bulkAssignDriver(Request $request)
{
// Validate Inputs
@@ -578,6 +598,7 @@ public function cancel(CancelOrderRequest $request)
*
* @return \Illuminate\Http\Response
*/
+ #[SkipAuthorizationCheck]
public function dispatchOrder(Request $request)
{
/**
@@ -1906,6 +1927,7 @@ function ($query) use ($trackingNumber) {
*
* @return \Illuminate\Http\Response
*/
+ #[SkipAuthorizationCheck]
public function scheduleOrder(Request $request)
{
$orderId = $request->input('order');
diff --git a/server/src/Http/Controllers/Internal/v1/PaymentController.php b/server/src/Http/Controllers/Internal/v1/PaymentController.php
index 703bbb283..03ac407f3 100644
--- a/server/src/Http/Controllers/Internal/v1/PaymentController.php
+++ b/server/src/Http/Controllers/Internal/v1/PaymentController.php
@@ -4,6 +4,7 @@
use Fleetbase\FleetOps\Models\PurchaseRate;
use Fleetbase\FleetOps\Support\Payment;
+use Fleetbase\FleetOps\Traits\AuthorizesMethods;
use Fleetbase\Http\Controllers\Controller;
use Fleetbase\Http\Resources\FleetbaseResource;
use Fleetbase\Support\Auth;
@@ -12,6 +13,18 @@
class PaymentController extends Controller
{
+ use AuthorizesMethods;
+
+ public function __construct()
+ {
+ $this->authorizeMethods([
+ 'hasStripeConnectAccount' => 'view payments',
+ 'getStripeAccount' => 'onboard payments',
+ 'getStripeAccountSession' => 'onboard payments',
+ 'getCompanyReceivedPayments' => 'view payments',
+ ]);
+ }
+
/**
* Checks if the currently authenticated company has an associated Stripe Connect account.
*
diff --git a/server/src/Http/Controllers/Internal/v1/RadarController.php b/server/src/Http/Controllers/Internal/v1/RadarController.php
index a43e2d401..9cd1e7961 100644
--- a/server/src/Http/Controllers/Internal/v1/RadarController.php
+++ b/server/src/Http/Controllers/Internal/v1/RadarController.php
@@ -19,6 +19,7 @@
use Fleetbase\FleetOps\Support\Radar\RadarBriefing;
use Fleetbase\FleetOps\Support\Radar\RadarItemState;
use Fleetbase\FleetOps\Support\Radar\RadarRules;
+use Fleetbase\FleetOps\Traits\AuthorizesMethods;
use Fleetbase\Http\Controllers\Controller;
use Fleetbase\Models\Alert;
use Fleetbase\Models\CompanyUser;
@@ -42,6 +43,24 @@
*/
class RadarController extends Controller
{
+ use AuthorizesMethods;
+
+ public function __construct()
+ {
+ $this->authorizeMethods([
+ 'extendShift' => ['update driver', 'update vehicle'],
+ 'acknowledge' => ['update driver', 'update vehicle'],
+ 'snooze' => ['update driver', 'update vehicle'],
+ 'wake' => ['update driver', 'update vehicle'],
+ 'assign' => ['update driver', 'update vehicle'],
+ 'plan' => ['update driver', 'update vehicle'],
+ 'resolve' => ['update driver', 'update vehicle'],
+ 'bulk' => ['update driver', 'update vehicle'],
+ 'storeNotice' => ['update driver', 'update vehicle'],
+ 'destroyNotice' => ['update driver', 'update vehicle'],
+ ]);
+ }
+
public const RESOLVED_WINDOW_DAYS = 7;
public const SNOOZE_MAX_DAYS = 90;
diff --git a/server/src/Http/Controllers/Internal/v1/ServiceAreaController.php b/server/src/Http/Controllers/Internal/v1/ServiceAreaController.php
index 320a83265..36926e45a 100644
--- a/server/src/Http/Controllers/Internal/v1/ServiceAreaController.php
+++ b/server/src/Http/Controllers/Internal/v1/ServiceAreaController.php
@@ -7,6 +7,7 @@
use Fleetbase\FleetOps\Models\ServiceArea;
use Fleetbase\Http\Requests\ExportRequest;
use Illuminate\Http\Request;
+use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Maatwebsite\Excel\Facades\Excel;
@@ -21,11 +22,16 @@ class ServiceAreaController extends FleetOpsController
/**
* Handle post save transactions.
+ *
+ * Reads the custom field values from the payload the base controller already extracted
+ * rather than from the raw request: Ember Data sends the resource under a camelCase root
+ * (`serviceArea`), so `$request->array('service_area.custom_field_values')` was always empty and no
+ * custom field value was ever persisted for this resource.
*/
- public function afterSave(Request $request, ServiceArea $serviceArea)
+ public function afterSave(Request $request, ServiceArea $serviceArea, array $input = [])
{
- $customFieldValues = $request->array('service_area.custom_field_values');
- if ($customFieldValues) {
+ $customFieldValues = Arr::get($input, 'custom_field_values');
+ if (is_array($customFieldValues) && $customFieldValues) {
$serviceArea->syncCustomFieldValues($customFieldValues);
}
}
diff --git a/server/src/Http/Controllers/Internal/v1/SettingController.php b/server/src/Http/Controllers/Internal/v1/SettingController.php
index 3a1c568e5..296363a07 100644
--- a/server/src/Http/Controllers/Internal/v1/SettingController.php
+++ b/server/src/Http/Controllers/Internal/v1/SettingController.php
@@ -2,18 +2,54 @@
namespace Fleetbase\FleetOps\Http\Controllers\Internal\v1;
+use Fleetbase\FleetOps\Jobs\DispatchTelematicsRetentionJobs;
+use Fleetbase\FleetOps\Support\Telematics\Retention\RetentionPolicy;
+use Fleetbase\FleetOps\Support\Telematics\Telemetry\Queue;
use Fleetbase\FleetOps\Tracking\TrackingProviderRegistry;
+use Fleetbase\FleetOps\Traits\AuthorizesMethods;
use Fleetbase\Http\Controllers\Controller;
use Fleetbase\Models\Setting;
use Fleetbase\Support\Auth;
use Fleetbase\Support\NotificationRegistry;
+use Illuminate\Database\MySqlConnection;
+use Illuminate\Database\QueryException;
use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
/**
* Class SettingController.
*/
class SettingController extends Controller
{
+ use AuthorizesMethods;
+
+ public function __construct()
+ {
+ $this->authorizeMethods([
+ 'saveEntityEditingSettings' => 'update navigator-settings',
+ 'savedDriverOnboardSettings' => 'update navigator-settings',
+ 'saveCustomerEnabledOrderConfigs' => 'update order-config',
+ 'saveCustomerPortalPaymentConfig' => 'update payments',
+ 'saveNotificationSettings' => 'update notification-settings',
+ 'saveRoutingSettings' => 'update routing-settings',
+ 'saveTrackingSettings' => 'update tracking-settings',
+ 'getAdminTrackingSettings' => 'admin',
+ 'saveAdminTrackingSettings' => 'admin',
+ 'saveMapSettings' => 'update map-settings',
+ 'getAdminMapSettings' => 'admin',
+ 'saveAdminMapSettings' => 'admin',
+ 'saveSchedulingSettings' => 'update scheduling-settings',
+ 'saveOrchestratorSettings' => 'update routing-settings',
+ 'saveOrchestratorCardFields' => 'update routing-settings',
+ 'getAdminTelematicsSettings' => 'admin',
+ 'saveAdminTelematicsSettings' => 'admin',
+ 'getTelematicsSettings' => 'view telematics-settings',
+ 'saveTelematicsSettings' => 'update telematics-settings',
+ 'getTelematicsStorageUsage' => 'admin',
+ 'runTelematicsRetention' => 'admin',
+ ]);
+ }
+
/**
* Save entity editing settings.
*
@@ -21,37 +57,54 @@ class SettingController extends Controller
*/
public function saveEntityEditingSettings(Request $request)
{
- $entityEditingSettings = $request->input('entityEditingSettings', []);
+ // The setting is one platform-wide map keyed by order config id. Only this
+ // company's order configs may be written, and other companies' entries are kept.
+ $ownKeys = $this->companyOrderConfigKeys();
+ $incoming = array_intersect_key((array) $request->input('entityEditingSettings', []), array_flip($ownKeys));
+ $existing = (array) ($this->settingValue('fleet-ops.entity-editing-settings') ?? []);
+ $merged = array_merge(array_diff_key($existing, array_flip($ownKeys)), $incoming);
- // Save entity editing settings
- $this->configureSetting('fleet-ops.entity-editing-settings', $entityEditingSettings);
+ $this->configureSetting('fleet-ops.entity-editing-settings', $merged);
- return response()->json(['entityEditingSettings' => $entityEditingSettings]);
+ return response()->json(['entityEditingSettings' => $incoming]);
}
/**
- * Retrieve entity editing settings.
+ * Retrieve entity editing settings for this company's order configs.
*
* @return \Illuminate\Http\JsonResponse
*/
public function getEntityEditingSettings()
{
- $entityEditingSettings = $this->settingValue('fleet-ops.entity-editing-settings');
- if (!$entityEditingSettings) {
- $entityEditingSettings = [];
- }
+ $entityEditingSettings = (array) ($this->settingValue('fleet-ops.entity-editing-settings') ?? []);
+ $entityEditingSettings = array_intersect_key($entityEditingSettings, array_flip($this->companyOrderConfigKeys()));
return response()->json(['entityEditingSettings' => $entityEditingSettings]);
}
/**
- * Retrieve driver onboard settings.
+ * Ids (uuid and public id) of the session company's order configs.
+ */
+ protected function companyOrderConfigKeys(): array
+ {
+ return \Fleetbase\FleetOps\Models\OrderConfig::where('company_uuid', session('company'))
+ ->get(['uuid', 'public_id'])
+ ->flatMap(fn ($config) => array_filter([$config->uuid, $config->public_id]))
+ ->values()
+ ->all();
+ }
+
+ /**
+ * Retrieve driver onboard settings for the session company.
+ *
+ * The route still carries a company id for backwards compatibility, but only
+ * the session company's settings are ever returned.
*
* @return \Illuminate\Http\JsonResponse
*/
- public function getDriverOnboardSettings($companyId)
+ public function getDriverOnboardSettings($companyId = null)
{
- $driverOnboardSettings = $this->settingValue('fleet-ops.driver-onboard-settings.' . $companyId);
+ $driverOnboardSettings = $this->settingValue('fleet-ops.driver-onboard-settings.' . session('company'));
if (!$driverOnboardSettings) {
$driverOnboardSettings = [];
}
@@ -66,9 +119,10 @@ public function getDriverOnboardSettings($companyId)
*/
public function savedDriverOnboardSettings(Request $request)
{
- $driverOnboardSettings = $request->array('driverOnboardSettings', []);
+ $driverOnboardSettings = $request->array('driverOnboardSettings', []);
+ $driverOnboardSettings['companyId'] = session('company');
- if ($driverOnboardSettings['enableDriverOnboardFromApp'] == false) {
+ if (empty($driverOnboardSettings['enableDriverOnboardFromApp'])) {
$driverOnboardSettings['driverMustProvideOnboardDoucments'] = false;
$driverOnboardSettings['requiredOnboardDocuments'] = [];
$driverOnboardSettings['driverOnboardAppMethod'] = '';
@@ -628,6 +682,403 @@ protected function trackingProviderOptions(): array
})->values()->all();
}
+ /**
+ * Retrieve customer history preferences for the current company.
+ *
+ * Company values fall back to the system defaults (admin setting over
+ * package config); the response carries both so the UI can show them.
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function getTelematicsSettings()
+ {
+ if (!$this->currentCompany()) {
+ return response()->error('No company session.', 401);
+ }
+
+ return response()->json($this->telematicsCompanySettings((array) $this->lookupFromCompanySetting(RetentionPolicy::SETTING_KEY, [])));
+ }
+
+ /**
+ * Save customer history preferences within the administrator's policy.
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function saveTelematicsSettings(Request $request)
+ {
+ $company = $this->currentCompany();
+ if (!$company) {
+ return response()->error('No company session.', 401);
+ }
+
+ $request->validate([
+ 'event_retention_days' => ['nullable', 'integer', 'min:0', 'max:3650'],
+ 'position_retention_days' => ['nullable', 'integer', 'min:0', 'max:3650'],
+ ]);
+
+ $saved = $this->withTelematicsPolicyLock(function () use ($request) {
+ $preferences = RetentionPolicy::constrainCompanyPreferences($request->only(RetentionPolicy::HISTORY_KEYS), $this->telematicsDefaults());
+
+ return $this->configureCompanySetting(RetentionPolicy::SETTING_KEY, $preferences);
+ });
+ if ($saved === false) {
+ return response()->error('Unable to save telematics history preferences.', 500);
+ }
+ RetentionPolicy::flush($company->uuid);
+
+ return response()->json(array_merge($this->getTelematicsSettings()->getData(true), [
+ 'status' => 'ok',
+ 'message' => 'Telematics history preferences successfully saved.',
+ ]));
+ }
+
+ /**
+ * Retrieve the system-wide telematics retention defaults.
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function getAdminTelematicsSettings()
+ {
+ return response()->json(array_merge($this->telematicsDefaults(), [
+ 'limits' => RetentionPolicy::LIMITS,
+ ]));
+ }
+
+ /**
+ * Save the system-wide telematics retention defaults.
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function saveAdminTelematicsSettings(Request $request)
+ {
+ $request->validate([
+ 'max_event_retention_days' => ['sometimes', 'integer', 'min:0', 'max:3650'],
+ 'max_position_retention_days' => ['sometimes', 'integer', 'min:0', 'max:3650'],
+ ]);
+
+ try {
+ $settings = $this->withTelematicsPolicyLock(function () use ($request) {
+ $settings = RetentionPolicy::normalize($request->only(RetentionPolicy::keys()), $this->telematicsDefaults());
+ $this->configureSetting(RetentionPolicy::SETTING_KEY, $settings);
+
+ return $settings;
+ });
+ $this->reconcileTelematicsCompanyPreferences($settings);
+ } finally {
+ RetentionPolicy::flush();
+ }
+
+ return response()->json($this->getAdminTelematicsSettings()->getData(true));
+ }
+
+ /**
+ * Report system-wide telematics storage, including data without an owner.
+ *
+ * Use table statistics for MySQL/MariaDB instead of scanning every tenant's
+ * history. Oldest-row lookups and fallback counts have a database deadline.
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function getTelematicsStorageUsage()
+ {
+ $ageColumns = [
+ 'device_events' => 'created_at',
+ 'positions' => 'created_at',
+ 'telematic_deliveries' => 'received_at',
+ 'telematic_sync_runs' => 'created_at',
+ ];
+ $statistics = $this->storageTableStatistics(array_keys($ageColumns));
+ $allRows = fn ($query) => $query;
+ $tables = [];
+ foreach ($ageColumns as $table => $ageColumn) {
+ $tables[$table] = isset($statistics[$table])
+ ? array_merge($statistics[$table], ['oldest' => $this->tableOldest($table, $allRows, $ageColumn)])
+ : $this->tableUsage($table, $allRows, $ageColumn);
+ }
+
+ // Payload details require reading JSON-bearing rows, so they are opt-in
+ // and omitted if they cannot be counted within the database deadline.
+ if (request()->boolean('include_payload_counts', false)) {
+ try {
+ $rawPayloadRows = $this->tableCount('device_events', fn ($query) => $query->whereNotNull('payload'));
+ $compactAfter = (int) $this->telematicsDefaults()['event_compact_after_days'];
+ $compactableRows = $compactAfter > 0
+ ? $this->tableCount('device_events', fn ($query) => $query->whereNotNull('payload')->where('created_at', '<', now()->subDays($compactAfter)->toDateTimeString()))
+ : 0;
+
+ $tables['device_events']['raw_payload_rows'] = $rawPayloadRows;
+ $tables['device_events']['compactable_rows'] = $compactableRows;
+ } catch (QueryException $exception) {
+ if (!$this->isStorageUsageTimeout($exception)) {
+ throw $exception;
+ }
+ }
+ }
+
+ return response()->json([
+ 'tables' => $tables,
+ 'scope' => 'system',
+ 'generated_at' => now()->toISOString(),
+ ]);
+ }
+
+ /**
+ * Queue retention for all companies and orphaned data, independent of session company.
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function runTelematicsRetention()
+ {
+ $connection = (string) config('queue.default', 'sync');
+ $driver = config('queue.connections.' . $connection . '.driver', $connection);
+ if (in_array($driver, ['sync', 'null'], true)) {
+ return response()->error('System-wide telematics cleanup requires an asynchronous queue connection and a running queue worker.', 503);
+ }
+
+ $this->dispatchTelematicsPrune();
+
+ return response()->json([
+ 'status' => 'queued',
+ 'scope' => 'system',
+ 'message' => 'System-wide telematics cleanup has been queued.',
+ ], 202);
+ }
+
+ /**
+ * System-wide retention defaults: package config overridden by the admin setting.
+ */
+ protected function telematicsDefaults(): array
+ {
+ $config = array_intersect_key((array) config('telematics.telemetry', []), RetentionPolicy::FALLBACKS);
+
+ return RetentionPolicy::normalize((array) $this->lookupSetting(RetentionPolicy::SETTING_KEY, []), RetentionPolicy::normalize($config, RetentionPolicy::FALLBACKS));
+ }
+
+ /**
+ * Customer responses expose history choices and constraints, never internal
+ * ingestion, cleanup, storage or activity logging configuration.
+ */
+ protected function telematicsCompanySettings(array $stored, ?array $defaults = null): array
+ {
+ $defaults = $defaults ?? $this->telematicsDefaults();
+ $history = array_flip(RetentionPolicy::HISTORY_KEYS);
+ $preferences = RetentionPolicy::constrainCompanyPreferences($stored, $defaults);
+ $effective = RetentionPolicy::fromCompanyPreferences($preferences, $defaults)->toArray();
+ $inherited = RetentionPolicy::fromCompanyPreferences([], $defaults)->toArray();
+
+ return array_merge(array_intersect_key($effective, $history), [
+ 'preferences' => array_merge(array_fill_keys(RetentionPolicy::HISTORY_KEYS, null), $preferences),
+ 'defaults' => array_intersect_key($inherited, $history),
+ 'policy' => array_intersect_key($defaults, array_flip(array_values(RetentionPolicy::MAXIMUMS))),
+ 'limits' => array_intersect_key(RetentionPolicy::LIMITS, $history),
+ ]);
+ }
+
+ /**
+ * Serialize policy changes with a customer's policy lookup and preference
+ * write, so a request cannot save an old unlimited choice after a new cap.
+ * The empty row is only a lock anchor and keeps config fallbacks intact.
+ */
+ protected function withTelematicsPolicyLock(\Closure $callback): mixed
+ {
+ $setting = new Setting();
+ if (!$setting->newQuery()->where('key', RetentionPolicy::SETTING_KEY)->exists()) {
+ $setting->newQuery()->insertOrIgnore(['key' => RetentionPolicy::SETTING_KEY, 'value' => '[]']);
+ }
+
+ return $setting->getConnection()->transaction(function () use ($setting, $callback) {
+ $setting->newQuery()->where('key', RetentionPolicy::SETTING_KEY)->lockForUpdate()->firstOrFail();
+
+ return $callback();
+ }, 3);
+ }
+
+ /**
+ * A newly imposed maximum replaces existing unlimited or longer explicit
+ * choices for every organization. Keep inherited preferences absent so they
+ * continue following future system defaults. Process settings in bounded
+ * batches rather than loading every organization or its telemetry history.
+ */
+ protected function reconcileTelematicsCompanyPreferences(array $defaults): void
+ {
+ $maximums = array_intersect_key($defaults, array_flip(array_values(RetentionPolicy::MAXIMUMS)));
+ if (!array_filter($maximums, fn ($maximum) => $maximum > 0)) {
+ return;
+ }
+
+ Setting::query()
+ ->where('key', 'like', 'company.%.' . RetentionPolicy::SETTING_KEY)
+ ->select('id')
+ ->chunkById(200, function ($settings) {
+ foreach ($settings as $setting) {
+ // Lock policy then company, as customer saves do. Reread
+ // both so a later admin change or customer preference is
+ // never overwritten using an earlier batch's snapshot.
+ $this->withTelematicsPolicyLock(function () use ($setting) {
+ $current = Setting::query()->whereKey($setting->getKey())->lockForUpdate()->first();
+ if (!$current) {
+ return;
+ }
+
+ $stored = (array) $current->value;
+ $preferences = RetentionPolicy::companyPreferences($stored);
+ $constrained = RetentionPolicy::constrainCompanyPreferences($preferences, $this->telematicsDefaults());
+ if ($preferences === $constrained) {
+ return;
+ }
+
+ $current->value = array_replace($stored, $constrained);
+ $current->save();
+ });
+ }
+ });
+ }
+
+ protected function tableUsage(string $table, \Closure $scope, string $ageColumn): array
+ {
+ if (!in_array(DB::connection()->getDriverName(), ['mysql', 'mariadb'], true)) {
+ return [
+ 'rows' => $this->tableCount($table, $scope),
+ 'oldest' => $this->tableOldest($table, $scope, $ageColumn),
+ ];
+ }
+
+ $query = $scope(DB::table($table));
+ $query->selectRaw('COUNT(*) AS row_count, MIN(' . $query->getGrammar()->wrap($ageColumn) . ') AS oldest');
+
+ try {
+ $usage = $this->storageUsageSelect($query->toSql(), $query->getBindings())[0];
+
+ return [
+ 'rows' => (int) $usage->row_count,
+ 'oldest' => $usage->oldest === null ? null : (string) $usage->oldest,
+ ];
+ } catch (QueryException $exception) {
+ if (!$this->isStorageUsageTimeout($exception)) {
+ throw $exception;
+ }
+ }
+
+ // EXPLAIN does not execute the history scan. The caller's scope is
+ // preserved when metadata is unavailable and a count reaches its deadline.
+ $estimate = null;
+ $query = $scope(DB::table($table))->select($ageColumn);
+ try {
+ $plan = $this->storageUsageSelect($query->toSql(), $query->getBindings(), true)[0] ?? null;
+ if (isset($plan->rows)) {
+ $estimate = max(0, (int) round((float) $plan->rows * (float) ($plan->filtered ?? 100) / 100));
+ }
+ } catch (QueryException $exception) {
+ if (!$this->isStorageUsageTimeout($exception)) {
+ throw $exception;
+ }
+ }
+
+ return ['rows' => $estimate, 'oldest' => null, 'rows_estimated' => true];
+ }
+
+ protected function tableCount(string $table, \Closure $scope): int
+ {
+ $query = $scope(DB::table($table))->selectRaw('COUNT(*) AS row_count');
+ $usage = $this->storageUsageSelect($query->toSql(), $query->getBindings())[0];
+
+ return (int) $usage->row_count;
+ }
+
+ protected function tableOldest(string $table, \Closure $scope, string $column): ?string
+ {
+ $query = $scope(DB::table($table));
+ $query->selectRaw('MIN(' . $query->getGrammar()->wrap($column) . ') AS oldest');
+ try {
+ $oldest = $this->storageUsageSelect($query->toSql(), $query->getBindings())[0]->oldest ?? null;
+ } catch (QueryException $exception) {
+ if (!$this->isStorageUsageTimeout($exception)) {
+ throw $exception;
+ }
+
+ return null;
+ }
+
+ return $oldest ? (string) $oldest : null;
+ }
+
+ /**
+ * Bound each MySQL/MariaDB scan to one second without changing persistent
+ * connection settings. A browser abort alone leaves database work running.
+ */
+ protected function storageUsageSelect(string $sql, array $bindings = [], bool $explain = false): array
+ {
+ $connection = DB::connection();
+ $driver = $connection->getDriverName();
+ $isMariaDb = $driver === 'mariadb';
+
+ if ($connection instanceof MySqlConnection) {
+ $isMariaDb = stripos((string) $connection->getReadPdo()->getAttribute(\PDO::ATTR_SERVER_VERSION), 'MariaDB') !== false;
+ }
+
+ if ($isMariaDb) {
+ $sql = 'SET STATEMENT max_statement_time=1 FOR ' . ($explain ? 'EXPLAIN ' : '') . $sql;
+ } else {
+ if ($driver === 'mysql') {
+ $sql = preg_replace('/^select\b/i', 'SELECT /*+ MAX_EXECUTION_TIME(1000) */', $sql, 1);
+ }
+ if ($explain) {
+ $sql = 'EXPLAIN ' . $sql;
+ }
+ }
+
+ return $connection->select($sql, $bindings);
+ }
+
+ protected function isStorageUsageTimeout(QueryException $exception): bool
+ {
+ return in_array((int) ($exception->errorInfo[1] ?? 0), [3024, 1969], true);
+ }
+
+ /**
+ * Whole-table row estimates and allocated data/index bytes. Empty on other drivers.
+ *
+ * @return array>
+ */
+ protected function storageTableStatistics(array $tables): array
+ {
+ $connection = DB::connection();
+ if (!in_array($connection->getDriverName(), ['mysql', 'mariadb'], true)) {
+ return [];
+ }
+
+ $prefix = $connection->getTablePrefix();
+ $placeholders = implode(', ', array_fill(0, count($tables), '?'));
+ try {
+ $rows = $this->storageUsageSelect(
+ 'SELECT TABLE_NAME AS name, TABLE_ROWS AS row_count, DATA_LENGTH AS data_bytes, INDEX_LENGTH AS index_bytes FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN (' . $placeholders . ')',
+ array_map(fn ($table) => $prefix . $table, $tables)
+ );
+ } catch (QueryException $exception) {
+ if (!$this->isStorageUsageTimeout($exception)) {
+ throw $exception;
+ }
+
+ return [];
+ }
+
+ $statistics = [];
+ foreach ($rows as $row) {
+ $statistics[substr($row->name, strlen($prefix))] = [
+ 'rows' => $row->row_count === null ? null : (int) $row->row_count,
+ 'rows_estimated' => true,
+ 'estimated_bytes' => $row->data_bytes === null || $row->index_bytes === null ? null : (int) $row->data_bytes + (int) $row->index_bytes,
+ ];
+ }
+
+ return $statistics;
+ }
+
+ protected function dispatchTelematicsPrune(): void
+ {
+ Queue::dispatch(new DispatchTelematicsRetentionJobs());
+ }
+
protected function configureSetting(string $key, mixed $value): mixed
{
return Setting::configure($key, $value);
diff --git a/server/src/Http/Controllers/Internal/v1/TelematicController.php b/server/src/Http/Controllers/Internal/v1/TelematicController.php
index acb8dc5fa..4f056da5b 100644
--- a/server/src/Http/Controllers/Internal/v1/TelematicController.php
+++ b/server/src/Http/Controllers/Internal/v1/TelematicController.php
@@ -2,6 +2,7 @@
namespace Fleetbase\FleetOps\Http\Controllers\Internal\v1;
+use Fleetbase\Attributes\SkipAuthorizationCheck;
use Fleetbase\FleetOps\Exports\TelematicExport;
use Fleetbase\FleetOps\Http\Controllers\FleetOpsController;
use Fleetbase\FleetOps\Models\Telematic;
@@ -16,6 +17,20 @@
class TelematicController extends FleetOpsController
{
+ /**
+ * Permissions for methods AuthorizationGuard cannot map to a schema action (see FleetOpsController).
+ *
+ * @var array
+ */
+ protected array $methodPermissions = [
+ 'testConnection' => 'update telematic',
+ 'testCredentials' => 'create telematic',
+ 'discover' => 'update telematic',
+ 'linkDevice' => 'update telematic',
+ 'telemetryWebhook' => 'update telematic',
+ 'replayTelemetryDelivery' => 'update telematic',
+ ];
+
/**
* The resource to query.
*
@@ -71,6 +86,7 @@ public function providers(): JsonResponse
/**
* Test connection to provider.
*/
+ #[SkipAuthorizationCheck]
public function testConnection(Request $request, string $id): JsonResponse
{
$telematic = $this->findTelematic($id);
@@ -89,6 +105,7 @@ public function testConnection(Request $request, string $id): JsonResponse
/**
* Test connection to provider.
*/
+ #[SkipAuthorizationCheck]
public function testCredentials(Request $request, string $key): JsonResponse
{
$credentials = $request->array('credentials', []);
@@ -120,6 +137,7 @@ public function testCredentials(Request $request, string $key): JsonResponse
/**
* Discover devices from provider.
*/
+ #[SkipAuthorizationCheck]
public function discover(Request $request, string $id): JsonResponse
{
$telematic = $this->findTelematic($id);
@@ -181,6 +199,7 @@ public function logs(Request $request, string $id): JsonResponse
/**
* Link a device to a telematic.
*/
+ #[SkipAuthorizationCheck]
public function linkDevice(Request $request, string $id): JsonResponse
{
$telematic = $this->findTelematic($id);
@@ -199,6 +218,7 @@ public function linkDevice(Request $request, string $id): JsonResponse
], 201);
}
+ #[SkipAuthorizationCheck]
public function telemetryWebhook(Request $request, string $id): JsonResponse
{
$telematic = $this->findTelematic($id);
@@ -248,6 +268,7 @@ public function telemetryDiagnostics(string $id): JsonResponse
]);
}
+ #[SkipAuthorizationCheck]
public function replayTelemetryDelivery(string $id, string $delivery): JsonResponse
{
$telematic = $this->findTelematic($id);
diff --git a/server/src/Http/Controllers/Internal/v1/TrailerController.php b/server/src/Http/Controllers/Internal/v1/TrailerController.php
index 728cd1cc8..841ab10e8 100644
--- a/server/src/Http/Controllers/Internal/v1/TrailerController.php
+++ b/server/src/Http/Controllers/Internal/v1/TrailerController.php
@@ -2,6 +2,7 @@
namespace Fleetbase\FleetOps\Http\Controllers\Internal\v1;
+use Fleetbase\Attributes\SkipAuthorizationCheck;
use Fleetbase\FleetOps\Exceptions\DeviceAlreadyAttachedException;
use Fleetbase\FleetOps\Exports\TrailerExport;
use Fleetbase\FleetOps\Http\Controllers\FleetOpsController;
@@ -23,6 +24,20 @@
class TrailerController extends FleetOpsController
{
+ /**
+ * Permissions for methods AuthorizationGuard cannot map to a schema action (see FleetOpsController).
+ *
+ * @var array
+ */
+ protected array $methodPermissions = [
+ 'attach' => 'attach-vehicle-for trailer',
+ 'detach' => 'detach-vehicle-for trailer',
+ 'attachDevice' => 'attach-device-for trailer',
+ 'detachDevice' => 'detach-device-for trailer',
+ 'attachEquipment' => 'attach-equipment-for trailer',
+ 'detachEquipment' => 'detach-equipment-for trailer',
+ ];
+
/**
* The resource to query.
*
@@ -69,6 +84,7 @@ protected function deleteTrailerRecord($id, Request $request)
/**
* Attach the trailer to a vehicle. Re-attaching to the same vehicle is idempotent.
*/
+ #[SkipAuthorizationCheck]
public function attach(Request $request, string $id)
{
$request->validate(['vehicle' => ['required', 'string'], 'position' => ['nullable', 'integer', 'min:1']]);
@@ -134,6 +150,7 @@ public function attach(Request $request, string $id)
/**
* End the active towing connection. Detaching an unattached trailer is a no-op.
*/
+ #[SkipAuthorizationCheck]
public function detach(string $id)
{
$trailer = $this->resolveTrailer($id);
@@ -157,6 +174,7 @@ public function detach(string $id)
]);
}
+ #[SkipAuthorizationCheck]
public function attachDevice(Request $request, string $id)
{
$request->validate(['device' => ['required', 'string']]);
@@ -186,6 +204,7 @@ public function attachDevice(Request $request, string $id)
]);
}
+ #[SkipAuthorizationCheck]
public function detachDevice(Request $request, string $id)
{
$request->validate(['device' => ['required', 'string']]);
@@ -215,6 +234,7 @@ public function detachDevice(Request $request, string $id)
]);
}
+ #[SkipAuthorizationCheck]
public function attachEquipment(Request $request, string $id)
{
$request->validate(['equipment' => ['required', 'string']]);
@@ -242,6 +262,7 @@ public function attachEquipment(Request $request, string $id)
]);
}
+ #[SkipAuthorizationCheck]
public function detachEquipment(Request $request, string $id)
{
$request->validate(['equipment' => ['required', 'string']]);
diff --git a/server/src/Http/Controllers/Internal/v1/VehicleController.php b/server/src/Http/Controllers/Internal/v1/VehicleController.php
index 9678a9b3b..b8f113d93 100644
--- a/server/src/Http/Controllers/Internal/v1/VehicleController.php
+++ b/server/src/Http/Controllers/Internal/v1/VehicleController.php
@@ -2,6 +2,7 @@
namespace Fleetbase\FleetOps\Http\Controllers\Internal\v1;
+use Fleetbase\Attributes\SkipAuthorizationCheck;
use Fleetbase\FleetOps\Exceptions\DeviceAlreadyAttachedException;
use Fleetbase\FleetOps\Exports\VehicleExport;
use Fleetbase\FleetOps\Http\Controllers\FleetOpsController;
@@ -25,6 +26,20 @@
class VehicleController extends FleetOpsController
{
+ /**
+ * Permissions for methods AuthorizationGuard cannot map to a schema action (see FleetOpsController).
+ *
+ * @var array
+ */
+ protected array $methodPermissions = [
+ 'unassignDriver' => 'assign-driver-for vehicle',
+ 'unassignOrders' => 'update vehicle',
+ 'attachDevice' => 'update vehicle',
+ 'detachDevice' => 'update vehicle',
+ 'attachEquipment' => 'update vehicle',
+ 'detachEquipment' => 'update vehicle',
+ ];
+
/**
* The resource to query.
*
@@ -111,6 +126,7 @@ public function assignDriver(Request $request, string $id): JsonResponse
]);
}
+ #[SkipAuthorizationCheck]
public function unassignDriver(string $id): JsonResponse
{
$vehicle = $this->findVehicle($id);
@@ -143,6 +159,7 @@ public function assignedOrders(string $id): JsonResponse
]);
}
+ #[SkipAuthorizationCheck]
public function unassignOrders(Request $request, string $id): JsonResponse
{
$request->validate([
@@ -179,6 +196,7 @@ public function unassignOrders(Request $request, string $id): JsonResponse
]);
}
+ #[SkipAuthorizationCheck]
public function attachDevice(Request $request, string $id): JsonResponse
{
$request->validate(['device' => 'required|string']);
@@ -219,6 +237,7 @@ public function attachDevice(Request $request, string $id): JsonResponse
]);
}
+ #[SkipAuthorizationCheck]
public function detachDevice(Request $request, string $id): JsonResponse
{
$request->validate(['device' => 'required|string']);
@@ -265,6 +284,7 @@ public function detachDevice(Request $request, string $id): JsonResponse
* Attach equipment to the vehicle. Equipment can only be equipped to one asset at a
* time, so attaching moves it from any previous vehicle or trailer.
*/
+ #[SkipAuthorizationCheck]
public function attachEquipment(Request $request, string $id): JsonResponse
{
$request->validate(['equipment' => 'required|string']);
@@ -292,6 +312,7 @@ public function attachEquipment(Request $request, string $id): JsonResponse
]);
}
+ #[SkipAuthorizationCheck]
public function detachEquipment(Request $request, string $id): JsonResponse
{
$request->validate(['equipment' => 'required|string']);
diff --git a/server/src/Http/Controllers/Internal/v1/VendorController.php b/server/src/Http/Controllers/Internal/v1/VendorController.php
index a4e02a2ea..69c100da2 100644
--- a/server/src/Http/Controllers/Internal/v1/VendorController.php
+++ b/server/src/Http/Controllers/Internal/v1/VendorController.php
@@ -2,6 +2,7 @@
namespace Fleetbase\FleetOps\Http\Controllers\Internal\v1;
+use Fleetbase\Attributes\SkipAuthorizationCheck;
use Fleetbase\FleetOps\Exports\VendorExport;
use Fleetbase\FleetOps\Http\Controllers\FleetOpsController;
use Fleetbase\FleetOps\Http\Resources\v1\Contact as ContactResource;
@@ -19,6 +20,18 @@
class VendorController extends FleetOpsController
{
+ /**
+ * Permissions for methods AuthorizationGuard cannot map to a schema action (see FleetOpsController).
+ *
+ * @var array
+ */
+ protected array $methodPermissions = [
+ 'assignDriver' => 'update vendor',
+ 'removeDriver' => 'update vendor',
+ 'addVendorPersonnel' => 'update vendor',
+ 'removeVendorPersonnel' => 'update vendor',
+ ];
+
/**
* The resource to query.
*
@@ -128,6 +141,7 @@ public function import(ImportRequest $request)
*
* @return \Illuminate\Http\Response
*/
+ #[SkipAuthorizationCheck]
public function assignDriver(string $id, Request $request)
{
// Validate only param
@@ -160,6 +174,7 @@ public function assignDriver(string $id, Request $request)
*
* @return \Illuminate\Http\Response
*/
+ #[SkipAuthorizationCheck]
public function removeDriver(string $id, Request $request)
{
// Validate only param
@@ -197,6 +212,7 @@ public function vendorPersonnels(string $vendorId)
return response()->json(['personnels' => $personnels->values()]);
}
+ #[SkipAuthorizationCheck]
public function addVendorPersonnel(Request $request, string $vendorId)
{
$vendor = $this->findVendorByIdOrFail($vendorId);
@@ -220,6 +236,7 @@ public function addVendorPersonnel(Request $request, string $vendorId)
]);
}
+ #[SkipAuthorizationCheck]
public function removeVendorPersonnel(string $vendorId, string $contactId)
{
$vendor = $this->findVendorByIdOrFail($vendorId);
diff --git a/server/src/Http/Controllers/Internal/v1/WorkOrderController.php b/server/src/Http/Controllers/Internal/v1/WorkOrderController.php
index 51d917245..28d37e315 100644
--- a/server/src/Http/Controllers/Internal/v1/WorkOrderController.php
+++ b/server/src/Http/Controllers/Internal/v1/WorkOrderController.php
@@ -2,6 +2,7 @@
namespace Fleetbase\FleetOps\Http\Controllers\Internal\v1;
+use Fleetbase\Attributes\SkipAuthorizationCheck;
use Fleetbase\FleetOps\Exports\WorkOrderExport;
use Fleetbase\FleetOps\Http\Controllers\FleetOpsController;
use Fleetbase\FleetOps\Imports\WorkOrderImport;
@@ -16,6 +17,15 @@
class WorkOrderController extends FleetOpsController
{
+ /**
+ * Permissions for methods AuthorizationGuard cannot map to a schema action (see FleetOpsController).
+ *
+ * @var array
+ */
+ protected array $methodPermissions = [
+ 'sendEmail' => 'update work-order',
+ ];
+
/**
* The resource to query.
*
@@ -80,6 +90,7 @@ protected function importFile(WorkOrderImport $import, string $path, string $dis
* Send a work order email to the assigned vendor.
* POST /work-orders/{id}/send.
*/
+ #[SkipAuthorizationCheck]
public function sendEmail(string $id): JsonResponse
{
$workOrder = $this->workOrderForEmail($id);
diff --git a/server/src/Jobs/DispatchTelematicsRetentionJobs.php b/server/src/Jobs/DispatchTelematicsRetentionJobs.php
new file mode 100644
index 000000000..c8b44dfd2
--- /dev/null
+++ b/server/src/Jobs/DispatchTelematicsRetentionJobs.php
@@ -0,0 +1,70 @@
+onQueue(config('telematics.telemetry.ingestion_queue', 'default'));
+ }
+
+ public function uniqueId(): string
+ {
+ return 'after-company:' . $this->afterCompanyId;
+ }
+
+ public function handle(): void
+ {
+ $companies = $this->companies();
+ foreach ($companies as $company) {
+ $this->queueJob(new PruneTelematicsDataJob($company->uuid, $this->maxBatches));
+ }
+
+ if ($companies->count() === self::PAGE_SIZE) {
+ $this->queueJob(new self((int) $companies->last()->id, $this->maxBatches));
+
+ return;
+ }
+
+ $this->queueJob(new PruneTelematicsDataJob(null, $this->maxBatches, true));
+ }
+
+ protected function companies(): Collection
+ {
+ return DB::table('companies')
+ ->where('id', '>', $this->afterCompanyId)
+ ->orderBy('id')
+ ->limit(self::PAGE_SIZE)
+ ->get(['id', 'uuid']);
+ }
+
+ protected function queueJob(ShouldBeUnique $job): void
+ {
+ Queue::dispatch($job);
+ }
+}
diff --git a/server/src/Jobs/PruneTelematicsDataJob.php b/server/src/Jobs/PruneTelematicsDataJob.php
new file mode 100644
index 000000000..6b1b6b060
--- /dev/null
+++ b/server/src/Jobs/PruneTelematicsDataJob.php
@@ -0,0 +1,74 @@
+orphansOnly = $orphansOnly;
+ $this->onQueue(config('telematics.telemetry.ingestion_queue', 'default'));
+ }
+
+ public function uniqueId(): string
+ {
+ return $this->orphansOnly ? 'orphans' : ($this->companyUuid ?? 'all');
+ }
+
+ /**
+ * @return array
+ */
+ public function parameters(): array
+ {
+ $parameters = ['--no-lock' => true, '--max-batches' => $this->maxBatches];
+ if ($this->companyUuid) {
+ $parameters['--company'] = $this->companyUuid;
+ }
+ if ($this->orphansOnly) {
+ $parameters['--orphans-only'] = true;
+ }
+
+ return $parameters;
+ }
+
+ public function handle(): void
+ {
+ $command = $this->command();
+ $command->setLaravel(app());
+ $exitCode = $command->run(new ArrayInput($this->parameters()), new BufferedOutput());
+ if ($exitCode !== PruneTelematicsData::SUCCESS) {
+ throw new \RuntimeException('Telematics cleanup failed with exit code ' . $exitCode . '.');
+ }
+ }
+
+ protected function command(): PruneTelematicsData
+ {
+ return app(PruneTelematicsData::class);
+ }
+}
diff --git a/server/src/Listeners/NotifyDriverOnShiftChange.php b/server/src/Listeners/NotifyDriverOnShiftChange.php
index 145c7385b..c7fe03245 100644
--- a/server/src/Listeners/NotifyDriverOnShiftChange.php
+++ b/server/src/Listeners/NotifyDriverOnShiftChange.php
@@ -48,7 +48,7 @@ public function handle($event): void
}
// Check the company-level scheduling setting
- $settings = $this->getSchedulingSettings();
+ $settings = $this->getSchedulingSettings($schedule->company_uuid);
$shouldNotify = (bool) data_get($settings, 'notify_drivers_on_shift_change', false);
if (!$shouldNotify) {
return;
@@ -66,9 +66,9 @@ protected function getSchedule(ScheduleItem $scheduleItem): ?Schedule
return $scheduleItem->schedule()->with('subject')->first();
}
- protected function getSchedulingSettings(): array
+ protected function getSchedulingSettings(?string $companyUuid): array
{
- return Setting::lookupFromCompany('fleet-ops.scheduling-settings', []);
+ return Setting::lookupForCompany($companyUuid, 'fleet-ops.scheduling-settings', []);
}
protected function isCreatedEvent(object $event): bool
diff --git a/server/src/Mail/CustomerCredentialsMail.php b/server/src/Mail/CustomerCredentialsMail.php
index f3dbcdf56..af197524e 100644
--- a/server/src/Mail/CustomerCredentialsMail.php
+++ b/server/src/Mail/CustomerCredentialsMail.php
@@ -72,7 +72,7 @@ public function content(): Content
*/
private function getCustomerPortalAccessUrl(): string
{
- $customerPortalConfig = Setting::lookupFromCompany('customer-portal-config');
+ $customerPortalConfig = Setting::lookupForCompany($this->customer->company_uuid, 'customer-portal-config');
$accessUrlSlug = data_get($customerPortalConfig, 'accessUrlSlug', 'customer-portal');
return Utils::consoleUrl($accessUrlSlug ?: 'customer-portal');
diff --git a/server/src/Orchestration/Engines/VroomOrchestrationEngine.php b/server/src/Orchestration/Engines/VroomOrchestrationEngine.php
index 3fdebe4dc..ecb3e74b9 100644
--- a/server/src/Orchestration/Engines/VroomOrchestrationEngine.php
+++ b/server/src/Orchestration/Engines/VroomOrchestrationEngine.php
@@ -37,6 +37,12 @@
*/
class VroomOrchestrationEngine implements OrchestrationEngineInterface
{
+ /**
+ * The company whose VROOM settings apply to the current allocation run.
+ * Resolved from the orders so queued runs work without a company session.
+ */
+ protected ?string $companyUuid = null;
+
public function getName(): string
{
return 'VROOM';
@@ -59,6 +65,8 @@ public function getIdentifier(): string
*/
public function allocate(Collection $orders, Collection $vehicles, array $options = []): array
{
+ $this->companyUuid = data_get($orders->first(), 'company_uuid');
+
if (($options['allocation_strategy'] ?? null) === 'capacity_only') {
return $this->allocateCapacityOnly($orders, $vehicles, $options);
}
@@ -520,7 +528,7 @@ protected function resolveVroomEndpointMode(): string
protected function resolveVroomSetting(string $key, $default = null)
{
try {
- $organizationValue = data_get(Setting::lookupCompany('vroom', []), $key);
+ $organizationValue = data_get(Setting::lookupForCompany($this->companyUuid ?? session('company'), 'vroom', []), $key);
if ($this->hasConfiguredValue($organizationValue)) {
return $organizationValue;
}
diff --git a/server/src/Providers/FleetOpsServiceProvider.php b/server/src/Providers/FleetOpsServiceProvider.php
index 93b8394a9..bb91d464a 100644
--- a/server/src/Providers/FleetOpsServiceProvider.php
+++ b/server/src/Providers/FleetOpsServiceProvider.php
@@ -77,6 +77,7 @@ class FleetOpsServiceProvider extends CoreServiceProvider
\Fleetbase\FleetOps\Console\Commands\ProcessOperationalAlerts::class,
\Fleetbase\FleetOps\Console\Commands\SyncTelematics::class,
\Fleetbase\FleetOps\Console\Commands\DrainTelematicInbox::class,
+ \Fleetbase\FleetOps\Console\Commands\PruneTelematicsData::class,
];
/**
@@ -153,6 +154,8 @@ public function boot()
// leave the default 24-hour overlap lease blocking telemetry recovery.
$schedule->command('fleetops:sync-telematics')->everyMinute()->withoutOverlapping(2)->storeOutputInDb();
$schedule->command('fleetops:drain-telematic-inbox')->everyMinute()->withoutOverlapping(2);
+ // Bounded batches per company and table; a capped run simply continues on the next tick.
+ $schedule->command('fleetops:prune-telematics-data')->everyFifteenMinutes()->withoutOverlapping(14)->storeOutputInDb();
});
$this->registerNotifications();
$this->registerAiCapabilities();
diff --git a/server/src/Support/Analytics/LiveFleet.php b/server/src/Support/Analytics/LiveFleet.php
index 9cfbccca6..1eff6bd5d 100644
--- a/server/src/Support/Analytics/LiveFleet.php
+++ b/server/src/Support/Analytics/LiveFleet.php
@@ -2,10 +2,13 @@
namespace Fleetbase\FleetOps\Support\Analytics;
+use Fleetbase\FleetOps\Http\Resources\v1\Index\Driver as DriverIndexResource;
+use Fleetbase\FleetOps\Http\Resources\v1\Index\Vehicle as VehicleIndexResource;
use Fleetbase\FleetOps\Models\Driver;
use Fleetbase\FleetOps\Models\Order;
use Fleetbase\FleetOps\Models\Vehicle;
use Fleetbase\FleetOps\Support\Metrics\OrdersInProgressMetric;
+use Illuminate\Http\Resources\Json\JsonResource;
/**
* Real-time fleet snapshot used as the initial-state payload for Live Fleet Map.
@@ -23,7 +26,8 @@ public function get(): array
// Driver.name is a virtual attribute backed by users.name — eager-load the
// user so the accessor resolves cleanly without a per-record query.
- $drivers = Driver::with(['user:uuid,name,avatar_uuid'])
+ // The user and vehicle also feed each marker's card (see card()).
+ $drivers = Driver::with(['user', 'vehicle'])
->where('drivers.company_uuid', $companyUuid)
->whereNotNull('drivers.location')
->where(function ($q) {
@@ -33,7 +37,8 @@ public function get(): array
// Vehicles tracked independently of drivers (telematics-connected rigs may
// report position without an active driver session).
- $vehicles = Vehicle::where('company_uuid', $companyUuid)
+ $vehicles = Vehicle::with(['devices', 'driver', 'currentTrailers'])
+ ->where('company_uuid', $companyUuid)
->whereNotNull('location')
->get();
@@ -79,6 +84,7 @@ private function driverPayload(Driver $d): array
'lat' => $lat,
'lng' => $lng,
'updated_at' => $d->last_location_update_at,
+ 'card' => $this->card(new DriverIndexResource($d)),
];
}
@@ -98,9 +104,19 @@ private function vehiclePayload(Vehicle $v): array
'heading' => (float) ($v->heading ?? 0),
'lat' => $lat,
'lng' => $lng,
+ 'card' => $this->card(new VehicleIndexResource($v)),
];
}
+ /**
+ * What the operational live map shows in a marker's popup and tooltip: the same index
+ * resource its live endpoints return, so the dashboard widget renders identical cards.
+ */
+ private function card(JsonResource $resource): array
+ {
+ return $resource->resolve(request());
+ }
+
/**
* Pull [lat, lng] out of whatever shape the spatial accessor handed us.
* Supports Point objects, [lat, lng] arrays, and {lat, lng} hashes.
diff --git a/server/src/Support/Authorization.php b/server/src/Support/Authorization.php
new file mode 100644
index 000000000..9b8f22d9d
--- /dev/null
+++ b/server/src/Support/Authorization.php
@@ -0,0 +1,103 @@
+json(['errors' => ['User is not authorized to ' . $permissions[0]]], 401));
+ }
+
+ /**
+ * Abort with a 401 JSON response unless the current user is a platform administrator.
+ *
+ * @throws HttpResponseException
+ */
+ public static function authorizeAdmin(): void
+ {
+ $user = static::user();
+ if ($user && $user->isAdmin()) {
+ return;
+ }
+
+ throw new HttpResponseException(response()->json(['errors' => ['This action requires a system administrator']], 401));
+ }
+
+ public static function canAny(string ...$permissions): bool
+ {
+ $user = static::user();
+ if (!$user) {
+ return false;
+ }
+
+ if ($user->isAdmin()) {
+ return true;
+ }
+
+ foreach ($permissions as $permission) {
+ if (static::can($permission)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ public static function can(string $permission): bool
+ {
+ [$action, $resource] = explode(' ', $permission, 2);
+
+ // Same test as Auth::isResourceGuarded(): no permission rows for the resource yet.
+ if (!Permission::where('name', 'like', static::SERVICE . ' % ' . $resource)->exists()) {
+ return true;
+ }
+
+ try {
+ return Auth::can(static::SERVICE . " {$action} {$resource}");
+ } catch (\Throwable $e) {
+ return false;
+ }
+ }
+
+ protected static function user()
+ {
+ try {
+ return Auth::getUserFromSession();
+ } catch (\Throwable $e) {
+ return null;
+ }
+ }
+}
diff --git a/server/src/Support/Database/TableIndexes.php b/server/src/Support/Database/TableIndexes.php
new file mode 100644
index 000000000..fc5e5daf0
--- /dev/null
+++ b/server/src/Support/Database/TableIndexes.php
@@ -0,0 +1,66 @@
+ index name => ordered column names
+ */
+ public static function for(string $table): array
+ {
+ $connection = Schema::getConnection();
+ $prefixed = $connection->getTablePrefix() . $table;
+ $indexes = [];
+
+ if ($connection instanceof \Illuminate\Database\SQLiteConnection || $connection->getDriverName() === 'sqlite') {
+ $quoted = str_replace('"', '""', $prefixed);
+ foreach ($connection->select('PRAGMA index_list("' . $quoted . '")') as $index) {
+ $name = str_replace('"', '""', $index->name);
+ $columns = $connection->select('PRAGMA index_info("' . $name . '")');
+ usort($columns, fn ($a, $b) => $a->seqno <=> $b->seqno);
+ $indexes[$index->name] = array_column($columns, 'name');
+ }
+
+ return $indexes;
+ }
+
+ // @codeCoverageIgnoreStart
+ if ($connection->getDriverName() !== 'mysql') {
+ throw new \RuntimeException('Index inspection requires MySQL or SQLite.');
+ }
+
+ $quoted = str_replace('`', '``', $prefixed);
+ foreach ($connection->select('SHOW INDEX FROM `' . $quoted . '`') as $index) {
+ $indexes[$index->Key_name][(int) $index->Seq_in_index - 1] = $index->Column_name;
+ }
+ foreach ($indexes as &$columns) {
+ ksort($columns);
+ $columns = array_values($columns);
+ }
+
+ return $indexes;
+ // @codeCoverageIgnoreEnd
+ }
+
+ /**
+ * Whether any index already leads with exactly these columns, in order.
+ */
+ public static function covers(string $table, array $columns): bool
+ {
+ foreach (self::for($table) as $indexed) {
+ if (array_slice($indexed, 0, count($columns)) === $columns) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/server/src/Support/OrderTracker.php b/server/src/Support/OrderTracker.php
index 2aa5f4743..507ec96d0 100644
--- a/server/src/Support/OrderTracker.php
+++ b/server/src/Support/OrderTracker.php
@@ -14,11 +14,11 @@ public function __construct(protected Order $order)
public function eta(array $options = []): array
{
- return app(TrackingIntelligenceService::class)->eta($this->order, TrackingOptions::fromArray($options));
+ return app(TrackingIntelligenceService::class)->eta($this->order, TrackingOptions::fromArray($options, $this->order->company_uuid));
}
public function toArray(array $options = []): array
{
- return app(TrackingIntelligenceService::class)->track($this->order, TrackingOptions::fromArray($options));
+ return app(TrackingIntelligenceService::class)->track($this->order, TrackingOptions::fromArray($options, $this->order->company_uuid));
}
}
diff --git a/server/src/Support/Reporting/FleetOpsReportSchema.php b/server/src/Support/Reporting/FleetOpsReportSchema.php
index dc83928ff..2b4ff8acd 100644
--- a/server/src/Support/Reporting/FleetOpsReportSchema.php
+++ b/server/src/Support/Reporting/FleetOpsReportSchema.php
@@ -47,12 +47,20 @@ public function registerReportSchema(ReportSchemaRegistry $registry): void
/**
* Create the Orders table definition.
+ *
+ * Money is stored in the currency's smallest unit (e.g. cents). `meta` has no fixed shape
+ * (it holds whatever users, integrations and extensions put there), so no column assumes a
+ * key in it; read one with a computed column, e.g.
+ * `CAST(JSON_UNQUOTE(JSON_EXTRACT(meta, '$.total')) AS DECIMAL(15,2))`.
+ *
+ * Selecting line item columns (Payload Items) returns one row per item, so order-level
+ * sums over item rows repeat each order; count orders with Total Orders (a distinct count).
*/
protected function createOrdersTable(): Table
{
- return Table::make('orders')
+ return $this->softDeletes(Table::make('orders'))
->label('Orders')
- ->description('Delivery and service orders')
+ ->description('Delivery and service orders, with their payload items, tracking, assignment and payment')
->category('Operations')
->extension('fleet-ops')
->excludeColumns(['uuid', 'deleted_at']) // Hide foreign keys and system columns
@@ -60,7 +68,7 @@ protected function createOrdersTable(): Table
->cacheTtl(3600)
->columns([
Column::make('public_id', 'string')
- ->label('Order ID')
+ ->label('ID')
->description('Public order identifier')
->searchable()
->filterable()
@@ -94,8 +102,21 @@ protected function createOrdersTable(): Table
}),
Column::make('type', 'string')
- ->label('Order Type')
- ->description('Type of order service')
+ ->label('Type')
+ ->description('Type of order service, e.g. transport or storefront')
+ ->filterable()
+ ->sortable()
+ ->aggregatable(),
+
+ Column::make('customer_type', 'string')
+ ->label('Customer Kind')
+ ->description('Whether the customer is a contact or a vendor')
+ ->filterable()
+ ->aggregatable(),
+
+ Column::make('facilitator_type', 'string')
+ ->label('Facilitator Kind')
+ ->description('Whether the facilitator is a vendor or an integrated vendor')
->filterable()
->aggregatable(),
@@ -106,6 +127,12 @@ protected function createOrdersTable(): Table
->sortable()
->aggregatable(),
+ Column::make('dispatched', 'boolean')
+ ->label('Dispatched')
+ ->description('Whether the order has been dispatched')
+ ->filterable()
+ ->aggregatable(),
+
Column::make('dispatched_at', 'datetime')
->label('Dispatched At')
->description('When the order was dispatched')
@@ -113,6 +140,12 @@ protected function createOrdersTable(): Table
->sortable()
->aggregatable(),
+ Column::make('started', 'boolean')
+ ->label('Started')
+ ->description('Whether the order has been started')
+ ->filterable()
+ ->aggregatable(),
+
Column::make('started_at', 'datetime')
->label('Started At')
->description('When the order was started')
@@ -120,18 +153,29 @@ protected function createOrdersTable(): Table
->sortable()
->aggregatable(),
+ Column::make('time_window_start', 'datetime')
+ ->label('Time Window Start')
+ ->description('Earliest time the order may be serviced')
+ ->filterable()
+ ->sortable(),
+
+ Column::make('time_window_end', 'datetime')
+ ->label('Time Window End')
+ ->description('Latest time the order may be serviced')
+ ->filterable()
+ ->sortable(),
+
Column::make('distance', 'integer')
- ->label('Distance (km)')
- ->description('Total distance for the order')
+ ->label('Distance (m)')
+ ->description('Route distance for the order in meters')
+ ->filterable()
->aggregatable()
- ->sortable()
- ->transformer(function ($value) {
- return round($value * 0.621371, 2); // Convert km to miles
- }),
+ ->sortable(),
Column::make('time', 'integer')
- ->label('Duration (minutes)')
- ->description('Estimated duration in minutes')
+ ->label('Duration (s)')
+ ->description('Estimated route duration in seconds')
+ ->filterable()
->aggregatable()
->sortable(),
@@ -144,6 +188,12 @@ protected function createOrdersTable(): Table
return $value ? 'Yes' : 'No';
}),
+ Column::make('adhoc_distance', 'integer')
+ ->label('Ad Hoc Distance (m)')
+ ->description('Radius in meters used to offer an ad hoc order to drivers')
+ ->filterable()
+ ->sortable(),
+
Column::make('pod_required', 'boolean')
->label('POD Required')
->description('Whether proof of delivery is required')
@@ -153,6 +203,31 @@ protected function createOrdersTable(): Table
return $value ? 'Yes' : 'No';
}),
+ Column::make('pod_method', 'string')
+ ->label('POD Method')
+ ->description('Proof of delivery method, e.g. scan, signature or photo')
+ ->filterable()
+ ->aggregatable(),
+
+ Column::make('is_route_optimized', 'boolean')
+ ->label('Route Optimized')
+ ->description('Whether the route was optimized')
+ ->filterable()
+ ->aggregatable(),
+
+ Column::make('orchestrator_priority', 'integer')
+ ->label('Priority')
+ ->description('Dispatch priority used by the orchestrator')
+ ->filterable()
+ ->sortable()
+ ->aggregatable(),
+
+ Column::make('notes', 'string')
+ ->label('Notes')
+ ->description('Order notes')
+ ->searchable()
+ ->filterable(),
+
Column::make('created_at', 'datetime')
->label('Created At')
->description('When the order was created')
@@ -168,76 +243,156 @@ protected function createOrdersTable(): Table
Column::make('meta', 'json')
->label('Metadata')
- ->description('Order metadata and custom fields')
+ ->description('Order metadata and custom fields; its keys vary by order, so read one with a computed column, e.g. JSON_UNQUOTE(JSON_EXTRACT(meta, \'$.key\'))')
->searchable()
->filterable(),
])
->computedColumns([
- Column::count('total_orders', 'id')
+ // Distinct, so the count stays right when payload items are selected too.
+ Column::count('total_orders', 'DISTINCT id')
->label('Total Orders')
- ->description('Count of orders'),
+ ->description('Number of orders'),
+
+ Column::computed('completed_orders', "COUNT(DISTINCT CASE WHEN status = 'completed' THEN id END)", 'integer')
+ ->label('Completed Orders')
+ ->description('Number of completed orders'),
+
+ Column::computed('canceled_orders', "COUNT(DISTINCT CASE WHEN status = 'canceled' THEN id END)", 'integer')
+ ->label('Canceled Orders')
+ ->description('Number of canceled orders'),
Column::sum('total_distance', 'distance')
- ->label('Total Distance')
+ ->label('Total Distance (m)')
->description('Sum of all order distances'),
Column::avg('average_distance', 'distance')
- ->label('Average Distance')
+ ->label('Average Distance (m)')
->description('Average distance per order'),
Column::sum('total_time', 'time')
- ->label('Total Time')
+ ->label('Total Duration (s)')
->description('Sum of all order durations'),
Column::avg('average_time', 'time')
- ->label('Average Time')
+ ->label('Average Duration (s)')
->description('Average duration per order'),
- Column::sum('total_transaction_amount', 'amount')
- ->label('Total Transaction Amount')
- ->description('Sum of all transaction amounts'),
+ Column::sum('total_transaction_amount', 'transaction.amount')
+ ->label('Transaction Amount Sum (minor units)')
+ ->description('Sum of the orders\' transaction amounts'),
- Column::avg('average_transaction_amount', 'amount')
- ->label('Average Transaction Amount')
+ Column::avg('average_transaction_amount', 'transaction.amount')
+ ->label('Average Transaction Amount (minor units)')
->description('Average transaction amount per order'),
- Column::count('orders_with_transactions', 'transaction_uuid')
+ Column::count('orders_with_transactions', 'DISTINCT transaction_uuid')
->label('Orders with Transactions')
- ->description('Count of orders that have transactions'),
+ ->description('Number of orders that have a transaction'),
])
->relationships([
- // Auto-join relationships for seamless access
+ Relationship::hasAutoJoin('tracking_number', 'tracking_numbers')
+ ->label('Tracking')
+ ->description('The order\'s tracking number and its latest tracking status')
+ ->localKey('tracking_number_uuid')
+ ->foreignKey('uuid')
+ ->columns([
+ Column::make('tracking_number', 'string')->label('Number')->description('Tracking number'),
+ Column::make('public_id', 'string')->label('ID')->description('Tracking number record identifier'),
+ Column::make('region', 'string')->label('Region'),
+ ])
+ ->with([
+ Relationship::hasAutoJoin('status', 'tracking_statuses')
+ ->label('Tracking Status')
+ ->localKey('status_uuid')
+ ->foreignKey('uuid')
+ ->columns([
+ Column::make('status', 'string')->label('Tracking Status'),
+ Column::make('code', 'string')->label('Code'),
+ Column::make('details', 'string')->label('Details'),
+ Column::make('complete', 'boolean')->label('Complete'),
+ Column::make('city', 'string')->label('City'),
+ Column::make('province', 'string')->label('Province'),
+ Column::make('country', 'string')->label('Country'),
+ Column::make('created_at', 'datetime')->label('Updated At'),
+ ]),
+ ]),
+
+ Relationship::hasAutoJoin('order_config', 'order_configs')
+ ->label('Order Config')
+ ->description('The order configuration (order type) the order follows')
+ ->localKey('order_config_uuid')
+ ->foreignKey('uuid')
+ ->columns([
+ Column::make('name', 'string')->label('Name'),
+ Column::make('key', 'string')->label('Key'),
+ Column::make('namespace', 'string')->label('Namespace'),
+ ]),
+
Relationship::hasAutoJoin('payload', 'payloads')
->label('Payload')
->localKey('payload_uuid')
->foreignKey('uuid')
+ ->columns([
+ Column::make('public_id', 'string')->label('ID')->description('Payload identifier'),
+ Column::make('type', 'string')->label('Type'),
+ Column::make('payment_method', 'string')->label('Payment Method'),
+ Column::make('cod_amount', 'integer')->label('COD Amount (minor units)')->description('Cash on delivery amount in the currency\'s smallest unit'),
+ Column::make('cod_currency', 'string')->label('COD Currency'),
+ Column::make('cod_payment_method', 'string')->label('COD Payment Method'),
+ Column::make('provider', 'string')->label('Provider'),
+ ])
->with([
Relationship::hasAutoJoin('pickup', 'places')
->label('Pickup')
->localKey('pickup_uuid')
->foreignKey('uuid')
- ->columns([
- Column::make('name', 'string')->label('Name'),
- Column::make('street1', 'string')->label('Street'),
- Column::make('street2', 'string')->label('Street 2'),
- Column::make('city', 'string')->label('City'),
- Column::make('province', 'string')->label('Province'),
- Column::make('postal_code', 'string')->label('Postal Code'),
- Column::make('country', 'string')->label('Country'),
- ]),
+ ->columns($this->placeColumns()),
Relationship::hasAutoJoin('dropoff', 'places')
->label('Dropoff')
->localKey('dropoff_uuid')
->foreignKey('uuid')
+ ->columns($this->placeColumns()),
+
+ Relationship::hasAutoJoin('return', 'places')
+ ->label('Return')
+ ->localKey('return_uuid')
+ ->foreignKey('uuid')
+ ->columns($this->placeColumns()),
+
+ // One row per item: the goods, parcels or storefront products in the order.
+ $this->softDeletes(Relationship::hasAutoJoin('entities', 'entities'))
+ ->label('Item')
+ ->description('Items carried by the order (storefront products, parcels, goods); one row per item')
+ ->localKey('uuid')
+ ->foreignKey('payload_uuid')
->columns([
- Column::make('name', 'string')->label('Name'),
- Column::make('street1', 'string')->label('Street'),
- Column::make('street2', 'string')->label('Street 2'),
- Column::make('city', 'string')->label('City'),
- Column::make('province', 'string')->label('Province'),
- Column::make('postal_code', 'string')->label('Postal Code'),
- Column::make('country', 'string')->label('Country'),
+ Column::make('public_id', 'string')->label('ID')->description('Item identifier'),
+ Column::make('internal_id', 'string')->label('Internal ID')->description('Internal reference; the product ID for storefront items'),
+ Column::make('name', 'string')->label('Name')->aggregatable(),
+ Column::make('type', 'string')->label('Type')->aggregatable(),
+ Column::make('description', 'string')->label('Description'),
+ Column::make('sku', 'string')->label('SKU')->aggregatable(),
+ Column::make('currency', 'string')->label('Currency'),
+ Column::make('price', 'decimal')->label('Price (minor units)')->description('Unit price in the currency\'s smallest unit'),
+ Column::make('sale_price', 'decimal')->label('Sale Price (minor units)')->description('Unit sale price in the currency\'s smallest unit'),
+ Column::make('declared_value', 'integer')->label('Declared Value (minor units)'),
+ Column::make('weight', 'decimal')->label('Weight'),
+ Column::make('weight_unit', 'string')->label('Weight Unit'),
+ Column::make('length', 'decimal')->label('Length'),
+ Column::make('width', 'decimal')->label('Width'),
+ Column::make('height', 'decimal')->label('Height'),
+ Column::make('dimensions_unit', 'string')->label('Dimensions Unit'),
+ Column::make('barcode', 'string')->label('Barcode'),
+ Column::make('meta', 'json')->label('Metadata')->description('Item metadata; read a key with a computed column, e.g. JSON_EXTRACT(payload.entities.meta, \'$.quantity\')'),
+ Column::make('created_at', 'datetime')->label('Created At'),
+ ])
+ ->with([
+ Relationship::hasAutoJoin('destination', 'places')
+ ->label('Destination')
+ ->localKey('destination_uuid')
+ ->foreignKey('uuid')
+ ->columns($this->placeColumns()),
]),
]),
@@ -246,6 +401,8 @@ protected function createOrdersTable(): Table
->localKey('driver_assigned_uuid')
->foreignKey('uuid')
->columns([
+ Column::make('public_id', 'string')->label('ID')->description('Driver identifier'),
+ Column::make('internal_id', 'string')->label('Internal ID'),
Column::make('drivers_license_number', 'string')->label('License Number'),
Column::make('country', 'string')->label('Country'),
Column::make('city', 'string')->label('City'),
@@ -268,6 +425,9 @@ protected function createOrdersTable(): Table
->localKey('vehicle_assigned_uuid')
->foreignKey('uuid')
->columns([
+ Column::make('public_id', 'string')->label('ID')->description('Vehicle identifier'),
+ Column::make('internal_id', 'string')->label('Internal ID'),
+ Column::make('name', 'string')->label('Name'),
Column::make('make', 'string')->label('Make'),
Column::make('model', 'string')->label('Model'),
Column::make('year', 'integer')->label('Year'),
@@ -279,9 +439,27 @@ protected function createOrdersTable(): Table
Relationship::hasAutoJoin('customer', 'contacts')
->label('Customer')
+ ->description('The customer when it is a contact')
+ ->localKey('customer_uuid')
+ ->foreignKey('uuid')
+ ->columns([
+ Column::make('public_id', 'string')->label('ID')->description('Customer identifier'),
+ Column::make('internal_id', 'string')->label('Internal ID'),
+ Column::make('name', 'string')->label('Name'),
+ Column::make('title', 'string')->label('Title'),
+ Column::make('email', 'string')->label('Email'),
+ Column::make('phone', 'string')->label('Phone'),
+ Column::make('type', 'string')->label('Type'),
+ ]),
+
+ Relationship::hasAutoJoin('customer_vendor', 'vendors')
+ ->label('Customer Vendor')
+ ->description('The customer when it is a vendor')
->localKey('customer_uuid')
->foreignKey('uuid')
->columns([
+ Column::make('public_id', 'string')->label('ID')->description('Vendor identifier'),
+ Column::make('internal_id', 'string')->label('Internal ID'),
Column::make('name', 'string')->label('Name'),
Column::make('email', 'string')->label('Email'),
Column::make('phone', 'string')->label('Phone'),
@@ -289,16 +467,48 @@ protected function createOrdersTable(): Table
]),
Relationship::hasAutoJoin('facilitator', 'vendors')
- ->label('Faciliator')
+ ->label('Facilitator')
->localKey('facilitator_uuid')
->foreignKey('uuid')
->columns([
+ Column::make('public_id', 'string')->label('ID')->description('Facilitator identifier'),
+ Column::make('internal_id', 'string')->label('Internal ID'),
Column::make('name', 'string')->label('Name'),
Column::make('email', 'string')->label('Email'),
Column::make('phone', 'string')->label('Phone'),
Column::make('type', 'string')->label('Type'),
]),
+ Relationship::hasAutoJoin('created_by', 'users')
+ ->label('Created By')
+ ->localKey('created_by_uuid')
+ ->foreignKey('uuid')
+ ->columns([
+ Column::make('name', 'string')->label('Name'),
+ Column::make('email', 'string')->label('Email'),
+ ]),
+
+ Relationship::hasAutoJoin('purchase_rate', 'purchase_rates')
+ ->label('Purchase Rate')
+ ->description('The service quote purchased for the order')
+ ->localKey('purchase_rate_uuid')
+ ->foreignKey('uuid')
+ ->columns([
+ Column::make('public_id', 'string')->label('ID')->description('Purchase rate identifier'),
+ Column::make('status', 'string')->label('Status'),
+ ])
+ ->with([
+ Relationship::hasAutoJoin('service_quote', 'service_quotes')
+ ->label('Service Quote')
+ ->localKey('service_quote_uuid')
+ ->foreignKey('uuid')
+ ->columns([
+ Column::make('public_id', 'string')->label('ID')->description('Service quote identifier'),
+ Column::make('amount', 'integer')->label('Amount (minor units)')->description('Quoted delivery price in the currency\'s smallest unit'),
+ Column::make('currency', 'string')->label('Currency'),
+ ]),
+ ]),
+
Relationship::hasAutoJoin('transaction', 'transactions')
->label('Transaction')
->localKey('transaction_uuid')
@@ -312,7 +522,7 @@ protected function createOrdersTable(): Table
->sortable(),
Column::make('gateway_transaction_id', 'string')
- ->label('Gateway Transaction ID')
+ ->label('Gateway ID')
->description('Transaction ID from payment gateway')
->searchable()
->filterable()
@@ -324,15 +534,35 @@ protected function createOrdersTable(): Table
->filterable()
->aggregatable(),
+ Column::make('payment_method', 'string')
+ ->label('Payment Method')
+ ->description('Payment method used, e.g. card or cash')
+ ->filterable()
+ ->aggregatable(),
+
Column::make('amount', 'integer')
- ->label('Amount')
- ->description('Transaction amount (in cents)')
+ ->label('Amount (minor units)')
+ ->description('Transaction amount in the currency\'s smallest unit (e.g. cents)')
->aggregatable()
- ->sortable()
- ->transformer(function ($value) {
- // Convert cents to dollars with 2 decimal places
- return number_format($value / 100, 2);
- }),
+ ->sortable(),
+
+ Column::make('fee_amount', 'integer')
+ ->label('Fee Amount (minor units)')
+ ->description('Gateway fee in the currency\'s smallest unit')
+ ->aggregatable()
+ ->sortable(),
+
+ Column::make('tax_amount', 'integer')
+ ->label('Tax Amount (minor units)')
+ ->description('Tax in the currency\'s smallest unit')
+ ->aggregatable()
+ ->sortable(),
+
+ Column::make('net_amount', 'integer')
+ ->label('Net Amount (minor units)')
+ ->description('Amount after fees in the currency\'s smallest unit')
+ ->aggregatable()
+ ->sortable(),
Column::make('currency', 'string')
->label('Currency')
@@ -369,6 +599,12 @@ protected function createOrdersTable(): Table
return $labels[$value] ?? ucfirst($value);
}),
+ Column::make('settlement_status', 'string')
+ ->label('Settlement Status')
+ ->description('Whether the transaction has settled')
+ ->filterable()
+ ->aggregatable(),
+
Column::make('created_at', 'datetime')
->label('Transaction Date')
->description('When the transaction was created')
@@ -378,30 +614,48 @@ protected function createOrdersTable(): Table
])
->with([
// Nested relationship for transaction items
- Relationship::hasAutoJoin('items', 'transaction_items')
- ->label('Transaction Items')
+ $this->softDeletes(Relationship::hasAutoJoin('items', 'transaction_items'))
+ ->label('Transaction Item')
->localKey('uuid')
->foreignKey('transaction_uuid')
->columns([
- Column::make('amount', 'string')
- ->label('Item Amount')
+ Column::make('description', 'string')
+ ->label('Description')
+ ->description('Line item description')
+ ->searchable()
+ ->filterable(),
+
+ Column::make('quantity', 'integer')
+ ->label('Quantity')
+ ->description('Line item quantity')
+ ->aggregatable()
+ ->sortable(),
+
+ Column::make('unit_price', 'integer')
+ ->label('Unit Price (minor units)')
+ ->description('Line item unit price')
+ ->aggregatable()
+ ->sortable(),
+
+ Column::make('amount', 'integer')
+ ->label('Amount (minor units)')
->description('Line item amount')
->aggregatable()
->sortable(),
Column::make('currency', 'string')
- ->label('Item Currency')
+ ->label('Currency')
->description('Line item currency code')
->filterable(),
Column::make('details', 'string')
- ->label('Item Details')
+ ->label('Details')
->description('Detailed description of the line item')
->searchable()
->filterable(),
Column::make('code', 'string')
- ->label('Item Code')
+ ->label('Code')
->description('Item or SKU code')
->searchable()
->filterable()
@@ -416,7 +670,7 @@ protected function createOrdersTable(): Table
*/
protected function createDriversTable(): Table
{
- return Table::make('drivers')
+ return $this->softDeletes(Table::make('drivers'))
->label('Drivers')
->description('Fleet drivers and personnel')
->category('Personnel')
@@ -425,30 +679,28 @@ protected function createDriversTable(): Table
->maxRows(10000)
->columns([
Column::make('public_id', 'string')
- ->label('Driver ID')
+ ->label('ID')
->description('Public driver identifier')
->searchable()
->filterable()
->sortable(),
- Column::make('name', 'string')
- ->label('Name')
- ->description('Driver full name')
+ Column::make('internal_id', 'string')
+ ->label('Internal ID')
+ ->description('Internal driver reference')
->searchable()
->filterable()
->sortable(),
- Column::make('email', 'string')
- ->label('Email')
- ->description('Driver email address')
+ Column::make('drivers_license_number', 'string')
+ ->label('License Number')
+ ->description('Driver license number')
->searchable()
- ->filterable()
- ->sortable(),
+ ->filterable(),
- Column::make('phone', 'string')
- ->label('Phone')
- ->description('Driver phone number')
- ->searchable()
+ Column::make('license_expiry', 'date')
+ ->label('License Expiry')
+ ->description('When the driver license expires')
->filterable()
->sortable(),
@@ -477,24 +729,45 @@ protected function createDriversTable(): Table
return $value ? 'Yes' : 'No';
}),
+ Column::make('city', 'string')
+ ->label('City')
+ ->filterable()
+ ->aggregatable(),
+
+ Column::make('country', 'string')
+ ->label('Country')
+ ->filterable()
+ ->aggregatable(),
+
Column::make('created_at', 'datetime')
->label('Hired Date')
- ->description('When the driver was hired')
+ ->description('When the driver was added')
->filterable()
->sortable()
->aggregatable(),
])
->computedColumns([
- Column::count('total_drivers', 'id')
+ Column::count('total_drivers', 'DISTINCT id')
->label('Total Drivers')
->description('Count of drivers'),
])
->relationships([
- Relationship::hasAutoJoin('current_vehicle', 'vehicles')
+ Relationship::hasAutoJoin('user', 'users')
+ ->label('Driver')
+ ->localKey('user_uuid')
+ ->foreignKey('uuid')
+ ->columns([
+ Column::make('name', 'string')->label('Name'),
+ Column::make('email', 'string')->label('Email'),
+ Column::make('phone', 'string')->label('Phone'),
+ ]),
+
+ Relationship::hasAutoJoin('vehicle', 'vehicles')
->label('Vehicle')
- ->localKey('current_vehicle_uuid')
+ ->localKey('vehicle_uuid')
->foreignKey('uuid')
->columns([
+ Column::make('public_id', 'string')->label('Vehicle ID'),
Column::make('make', 'string')->label('Vehicle Make'),
Column::make('model', 'string')->label('Vehicle Model'),
Column::make('plate_number', 'string')->label('Plate Number'),
@@ -507,7 +780,7 @@ protected function createDriversTable(): Table
*/
protected function createVehiclesTable(): Table
{
- return Table::make('vehicles')
+ return $this->softDeletes(Table::make('vehicles'))
->label('Vehicles')
->description('Fleet vehicles and assets')
->category('Fleet')
@@ -516,12 +789,26 @@ protected function createVehiclesTable(): Table
->maxRows(10000)
->columns([
Column::make('public_id', 'string')
- ->label('Vehicle ID')
+ ->label('ID')
->description('Public vehicle identifier')
->searchable()
->filterable()
->sortable(),
+ Column::make('internal_id', 'string')
+ ->label('Internal ID')
+ ->description('Internal vehicle reference')
+ ->searchable()
+ ->filterable()
+ ->sortable(),
+
+ Column::make('name', 'string')
+ ->label('Name')
+ ->description('Vehicle name')
+ ->searchable()
+ ->filterable()
+ ->sortable(),
+
Column::make('make', 'string')
->label('Make')
->description('Vehicle manufacturer')
@@ -583,19 +870,30 @@ protected function createVehiclesTable(): Table
->aggregatable(),
])
->computedColumns([
- Column::count('total_vehicles', 'id')
+ Column::count('total_vehicles', 'DISTINCT id')
->label('Total Vehicles')
->description('Count of vehicles'),
])
->relationships([
- Relationship::hasAutoJoin('current_driver', 'drivers')
+ // A driver points at the vehicle they drive (drivers.vehicle_uuid).
+ $this->softDeletes(Relationship::hasAutoJoin('driver', 'drivers'))
->label('Driver')
- ->localKey('current_driver_uuid')
- ->foreignKey('uuid')
+ ->localKey('uuid')
+ ->foreignKey('vehicle_uuid')
->columns([
- Column::make('name', 'string')->label('Driver Name'),
- Column::make('email', 'string')->label('Driver Email'),
- Column::make('phone', 'string')->label('Driver Phone'),
+ Column::make('public_id', 'string')->label('Driver ID'),
+ Column::make('status', 'string')->label('Driver Status'),
+ ])
+ ->with([
+ Relationship::hasAutoJoin('user', 'users')
+ ->label('Driver')
+ ->localKey('user_uuid')
+ ->foreignKey('uuid')
+ ->columns([
+ Column::make('name', 'string')->label('Name'),
+ Column::make('email', 'string')->label('Email'),
+ Column::make('phone', 'string')->label('Phone'),
+ ]),
]),
]);
}
@@ -605,7 +903,7 @@ protected function createVehiclesTable(): Table
*/
protected function createAssetsTable(): Table
{
- return Table::make('assets')
+ return $this->softDeletes(Table::make('assets'))
->label('Trailers and Assets')
->description('Independently managed fleet assets; Trailer rows use asset_class trailer')
->category('Fleet')
@@ -613,7 +911,7 @@ protected function createAssetsTable(): Table
->excludeColumns(['uuid', 'company_uuid', 'deleted_at', 'meta', 'attributes'])
->maxRows(10000)
->columns([
- Column::make('public_id', 'string')->label('Asset ID')->searchable()->filterable()->sortable(),
+ Column::make('public_id', 'string')->label('ID')->searchable()->filterable()->sortable(),
Column::make('asset_class', 'string')->label('Asset Class')->filterable()->aggregatable(),
Column::make('name', 'string')->label('Name')->searchable()->filterable()->sortable(),
Column::make('code', 'string')->label('Code')->searchable()->filterable()->sortable(),
@@ -640,7 +938,7 @@ protected function createAssetsTable(): Table
*/
protected function createPlacesTable(): Table
{
- return Table::make('places')
+ return $this->softDeletes(Table::make('places'))
->label('Places')
->description('Locations and addresses')
->category('Geography')
@@ -649,7 +947,7 @@ protected function createPlacesTable(): Table
->maxRows(100000)
->columns([
Column::make('public_id', 'string')
- ->label('Place ID')
+ ->label('ID')
->description('Public place identifier')
->searchable()
->filterable()
@@ -706,7 +1004,7 @@ protected function createPlacesTable(): Table
*/
protected function createContactsTable(): Table
{
- return Table::make('contacts')
+ return $this->softDeletes(Table::make('contacts'))
->label('Contacts')
->description('Customer and vendor contacts')
->category('CRM')
@@ -715,7 +1013,7 @@ protected function createContactsTable(): Table
->maxRows(50000)
->columns([
Column::make('public_id', 'string')
- ->label('Contact ID')
+ ->label('ID')
->description('Public contact identifier')
->searchable()
->filterable()
@@ -762,7 +1060,7 @@ protected function createContactsTable(): Table
*/
protected function createVendorsTable(): Table
{
- return Table::make('vendors')
+ return $this->softDeletes(Table::make('vendors'))
->label('Vendors')
->description('Service providers and vendors')
->category('CRM')
@@ -771,7 +1069,7 @@ protected function createVendorsTable(): Table
->maxRows(10000)
->columns([
Column::make('public_id', 'string')
- ->label('Vendor ID')
+ ->label('ID')
->description('Public vendor identifier')
->searchable()
->filterable()
@@ -818,7 +1116,7 @@ protected function createVendorsTable(): Table
*/
protected function createFuelReportsTable(): Table
{
- return Table::make('fuel_reports')
+ return $this->softDeletes(Table::make('fuel_reports'))
->label('Fuel Reports')
->description('Vehicle fuel consumption reports')
->category('Operations')
@@ -827,24 +1125,30 @@ protected function createFuelReportsTable(): Table
->maxRows(100000)
->columns([
Column::make('public_id', 'string')
- ->label('Report ID')
+ ->label('ID')
->description('Public fuel report identifier')
->searchable()
->filterable()
->sortable(),
Column::make('volume', 'decimal')
- ->label('Volume (L)')
- ->description('Fuel volume in liters')
+ ->label('Volume')
+ ->description('Fuel volume, in the report\'s metric unit')
->aggregatable()
->sortable(),
- Column::make('odometer_reading', 'integer')
+ Column::make('metric_unit', 'string')
+ ->label('Volume Unit')
+ ->description('Unit of the fuel volume')
+ ->filterable()
+ ->aggregatable(),
+
+ Column::make('odometer', 'integer')
->label('Odometer Reading')
->description('Vehicle odometer reading')
->sortable(),
- Column::make('cost', 'decimal')
+ Column::make('amount', 'decimal')
->label('Cost')
->description('Fuel cost amount')
->aggregatable()
@@ -856,15 +1160,26 @@ protected function createFuelReportsTable(): Table
->filterable()
->aggregatable(),
- Column::make('report_date', 'date')
+ Column::make('status', 'string')
+ ->label('Status')
+ ->filterable()
+ ->aggregatable(),
+
+ Column::make('report', 'string')
+ ->label('Report')
+ ->description('Report notes')
+ ->searchable()
+ ->filterable(),
+
+ Column::make('created_at', 'datetime')
->label('Report Date')
- ->description('Date of fuel report')
+ ->description('When the fuel report was recorded')
->filterable()
->sortable()
->aggregatable(),
])
->computedColumns([
- Column::sum('total_fuel_cost', 'cost')
+ Column::sum('total_fuel_cost', 'amount')
->label('Total Fuel Cost')
->description('Sum of all fuel costs'),
@@ -872,7 +1187,7 @@ protected function createFuelReportsTable(): Table
->label('Total Fuel Volume')
->description('Sum of all fuel volumes'),
- Column::avg('average_fuel_cost', 'cost')
+ Column::avg('average_fuel_cost', 'amount')
->label('Average Fuel Cost')
->description('Average fuel cost per report'),
])
@@ -882,6 +1197,7 @@ protected function createFuelReportsTable(): Table
->localKey('vehicle_uuid')
->foreignKey('uuid')
->columns([
+ Column::make('public_id', 'string')->label('Vehicle ID'),
Column::make('make', 'string')->label('Vehicle Make'),
Column::make('model', 'string')->label('Vehicle Model'),
Column::make('plate_number', 'string')->label('Plate Number'),
@@ -892,8 +1208,17 @@ protected function createFuelReportsTable(): Table
->localKey('driver_uuid')
->foreignKey('uuid')
->columns([
- Column::make('name', 'string')->label('Driver Name'),
- Column::make('email', 'string')->label('Driver Email'),
+ Column::make('public_id', 'string')->label('Driver ID'),
+ ])
+ ->with([
+ Relationship::hasAutoJoin('user', 'users')
+ ->label('Driver')
+ ->localKey('user_uuid')
+ ->foreignKey('uuid')
+ ->columns([
+ Column::make('name', 'string')->label('Name'),
+ Column::make('email', 'string')->label('Email'),
+ ]),
]),
]);
}
@@ -903,7 +1228,7 @@ protected function createFuelReportsTable(): Table
*/
protected function createWorkOrdersTable(): Table
{
- return Table::make('work_orders')
+ return $this->softDeletes(Table::make('work_orders'))
->label('Work Orders')
->description('Maintenance work orders, assignments, budgets, and lifecycle status')
->category('Maintenance')
@@ -911,7 +1236,7 @@ protected function createWorkOrdersTable(): Table
->excludeColumns(['uuid', 'deleted_at', 'meta', 'checklist', 'cost_breakdown'])
->maxRows(100000)
->columns([
- Column::make('public_id', 'string')->label('Work Order ID')->searchable()->filterable()->sortable(),
+ Column::make('public_id', 'string')->label('ID')->searchable()->filterable()->sortable(),
Column::make('code', 'string')->label('Code')->searchable()->filterable()->sortable(),
Column::make('subject', 'string')->label('Subject')->searchable()->filterable()->sortable(),
Column::make('status', 'string')->label('Status')->filterable()->sortable()->aggregatable(),
@@ -938,7 +1263,7 @@ protected function createWorkOrdersTable(): Table
->localKey('target_uuid')
->foreignKey('uuid')
->columns([
- Column::make('public_id', 'string')->label('Vehicle ID'),
+ Column::make('public_id', 'string')->label('ID'),
Column::make('plate_number', 'string')->label('Plate Number'),
Column::make('make', 'string')->label('Make'),
Column::make('model', 'string')->label('Model'),
@@ -952,7 +1277,7 @@ protected function createWorkOrdersTable(): Table
*/
protected function createMaintenancesTable(): Table
{
- return Table::make('maintenances')
+ return $this->softDeletes(Table::make('maintenances'))
->label('Maintenance History')
->description('Completed and scheduled maintenance records with labor, parts, tax, and total cost')
->category('Maintenance')
@@ -960,7 +1285,7 @@ protected function createMaintenancesTable(): Table
->excludeColumns(['uuid', 'deleted_at', 'meta', 'line_items', 'attachments'])
->maxRows(100000)
->columns([
- Column::make('public_id', 'string')->label('Maintenance ID')->searchable()->filterable()->sortable(),
+ Column::make('public_id', 'string')->label('ID')->searchable()->filterable()->sortable(),
Column::make('type', 'string')->label('Type')->filterable()->sortable()->aggregatable(),
Column::make('status', 'string')->label('Status')->filterable()->sortable()->aggregatable(),
Column::make('priority', 'string')->label('Priority')->filterable()->sortable()->aggregatable(),
@@ -1013,7 +1338,7 @@ protected function createMaintenancesTable(): Table
*/
protected function createInspectionSubmissionsTable(): Table
{
- return Table::make('inspection_submissions')
+ return $this->softDeletes(Table::make('inspection_submissions'))
->label('Inspections')
->description('DVIR and inspection submissions, pass/fail status, and linked maintenance follow-up')
->category('Maintenance')
@@ -1021,7 +1346,7 @@ protected function createInspectionSubmissionsTable(): Table
->excludeColumns(['uuid', 'deleted_at', 'meta', 'location', 'signature', 'attachments'])
->maxRows(100000)
->columns([
- Column::make('public_id', 'string')->label('Inspection ID')->searchable()->filterable()->sortable(),
+ Column::make('public_id', 'string')->label('ID')->searchable()->filterable()->sortable(),
Column::make('type', 'string')->label('Type')->filterable()->sortable()->aggregatable(),
Column::make('status', 'string')->label('Status')->filterable()->sortable()->aggregatable(),
Column::make('result', 'string')->label('Result')->filterable()->sortable()->aggregatable(),
@@ -1046,8 +1371,8 @@ protected function createInspectionSubmissionsTable(): Table
->localKey('inspection_form_uuid')
->foreignKey('uuid')
->columns([
- Column::make('name', 'string')->label('Form Name'),
- Column::make('type', 'string')->label('Form Type'),
+ Column::make('name', 'string')->label('Name'),
+ Column::make('type', 'string')->label('Type'),
]),
Relationship::hasAutoJoin('vehicle', 'vehicles')
->label('Vehicle')
@@ -1070,4 +1395,39 @@ protected function createInspectionSubmissionsTable(): Table
]),
]);
}
+
+ /**
+ * Columns reported for a place (pickup, dropoff, return or item destination).
+ */
+ protected function placeColumns(): array
+ {
+ return [
+ Column::make('public_id', 'string')->label('ID'),
+ Column::make('name', 'string')->label('Name'),
+ Column::make('street1', 'string')->label('Street'),
+ Column::make('street2', 'string')->label('Street 2'),
+ Column::make('neighborhood', 'string')->label('Neighborhood'),
+ Column::make('district', 'string')->label('District'),
+ Column::make('city', 'string')->label('City'),
+ Column::make('province', 'string')->label('Province'),
+ Column::make('postal_code', 'string')->label('Postal Code'),
+ Column::make('country', 'string')->label('Country'),
+ Column::make('latitude', 'decimal')->label('Latitude'),
+ Column::make('longitude', 'decimal')->label('Longitude'),
+ ];
+ }
+
+ /**
+ * Leave soft-deleted rows out of a table or joined relationship, on Core API releases that support it.
+ *
+ * @template T of Table|Relationship
+ *
+ * @param T $schema
+ *
+ * @return T
+ */
+ protected function softDeletes(Table|Relationship $schema): Table|Relationship
+ {
+ return method_exists($schema, 'softDeletes') ? $schema->softDeletes() : $schema;
+ }
}
diff --git a/server/src/Support/Telematics/Retention/RetentionPolicy.php b/server/src/Support/Telematics/Retention/RetentionPolicy.php
new file mode 100644
index 000000000..7b1c77751
--- /dev/null
+++ b/server/src/Support/Telematics/Retention/RetentionPolicy.php
@@ -0,0 +1,300 @@
+ 'max_event_retention_days',
+ 'position_retention_days' => 'max_position_retention_days',
+ ];
+
+ /** @var array */
+ public const LIMITS = [
+ 'event_retention_days' => [1, 3650],
+ 'event_compact_after_days' => [1, 3650],
+ 'position_retention_days' => [1, 3650],
+ 'processed_retention_hours' => [1, 720],
+ 'quarantine_retention_days' => [1, 365],
+ 'sync_run_retention_days' => [1, 365],
+ 'max_event_retention_days' => [1, 3650],
+ 'max_position_retention_days' => [1, 3650],
+ ];
+
+ public const BOOLEANS = ['log_telemetry_activity'];
+
+ /** Used when neither config nor settings provide a value. */
+ public const FALLBACKS = [
+ 'event_retention_days' => 30,
+ 'event_compact_after_days' => 7,
+ 'position_retention_days' => 90,
+ 'processed_retention_hours' => 24,
+ 'quarantine_retention_days' => 7,
+ 'sync_run_retention_days' => 7,
+ // A separate, opt-in cap preserves existing history when upgrading.
+ 'max_event_retention_days' => 0,
+ 'max_position_retention_days' => 0,
+ 'log_telemetry_activity' => false,
+ ];
+
+ /**
+ * Test seam: fn (string $scope, string $key, mixed $default, ?string $companyUuid): mixed
+ * where $scope is `system` or `company`.
+ */
+ public static ?\Closure $settingsResolver = null;
+
+ /** @var array */
+ private static array $companyCache = [];
+
+ /** @var array{values: array, expires: int}|null */
+ private static ?array $defaultsCache = null;
+
+ private function __construct(private array $values)
+ {
+ }
+
+ /** @return string[] */
+ public static function keys(): array
+ {
+ return array_keys(self::FALLBACKS);
+ }
+
+ /**
+ * System-wide defaults: package config overridden by the admin setting.
+ */
+ public static function defaults(): array
+ {
+ if (self::$defaultsCache && self::$defaultsCache['expires'] > time()) {
+ return self::$defaultsCache['values'];
+ }
+
+ $config = array_intersect_key((array) config('telematics.telemetry', []), self::FALLBACKS);
+ $base = self::normalize($config, self::FALLBACKS);
+ $values = self::normalize(self::resolve('system', null), $base);
+
+ self::$defaultsCache = ['values' => $values, 'expires' => time() + self::CACHE_TTL_SECONDS];
+
+ return $values;
+ }
+
+ public static function forCompany(?string $companyUuid): self
+ {
+ if (!$companyUuid) {
+ return self::fromCompanyPreferences([]);
+ }
+
+ $cached = self::$companyCache[$companyUuid] ?? null;
+ if ($cached && $cached['expires'] > time()) {
+ return $cached['policy'];
+ }
+
+ $defaults = self::defaults();
+ $policy = self::fromCompanyPreferences(self::resolve('company', $companyUuid), $defaults);
+
+ // A policy must not extend the lifetime of system defaults it inherited.
+ self::$companyCache[$companyUuid] = ['policy' => $policy, 'expires' => self::$defaultsCache['expires']];
+
+ return $policy;
+ }
+
+ public static function fromArray(array $values, ?array $base = null): self
+ {
+ return new self(self::applyMaximums(self::normalize($values, $base ?? self::FALLBACKS)));
+ }
+
+ /**
+ * Keep only explicit, valid customer history preferences. Missing or null
+ * values remain absent so later changes to the system defaults are inherited.
+ */
+ public static function companyPreferences(array $input): array
+ {
+ $preferences = [];
+ foreach (self::HISTORY_KEYS as $key) {
+ $value = $input[$key] ?? null;
+ if ($value === null || $value === '' || is_bool($value) || filter_var($value, FILTER_VALIDATE_INT) === false) {
+ continue;
+ }
+
+ [$min, $max] = self::LIMITS[$key];
+ $value = (int) $value;
+ $preferences[$key] = $value === 0 ? 0 : max($min, min($max, $value));
+ }
+
+ return $preferences;
+ }
+
+ /**
+ * Legacy company overrides for ingestion, payloads and logging are ignored.
+ * Apply caps after inheritance so unlimited or overly long defaults cannot
+ * bypass an administrator's maximum either.
+ */
+ public static function fromCompanyPreferences(array $preferences, ?array $defaults = null): self
+ {
+ return self::fromArray(self::companyPreferences($preferences), $defaults ?? self::defaults());
+ }
+
+ /**
+ * Return the explicit history choices after applying the administrator's
+ * maximums. Inherited values stay absent, and an allowed zero stays explicit.
+ */
+ public static function constrainCompanyPreferences(array $preferences, ?array $defaults = null): array
+ {
+ $preferences = self::companyPreferences($preferences);
+ $effective = self::fromCompanyPreferences($preferences, $defaults)->toArray();
+
+ return array_intersect_key($effective, $preferences);
+ }
+
+ /**
+ * Clamp and cast user input, filling gaps from `$base`.
+ */
+ public static function normalize(array $input, array $base): array
+ {
+ $values = [];
+ foreach (self::LIMITS as $key => [$min, $max]) {
+ $value = (int) self::pick($input, $base, $key);
+ $values[$key] = $value === 0 ? 0 : max($min, min($max, $value));
+ }
+ foreach (self::BOOLEANS as $key) {
+ $values[$key] = filter_var(self::pick($input, $base, $key), FILTER_VALIDATE_BOOLEAN);
+ }
+
+ return $values;
+ }
+
+ public static function flush(?string $companyUuid = null): void
+ {
+ if ($companyUuid === null) {
+ self::$companyCache = [];
+ self::$defaultsCache = null;
+
+ return;
+ }
+
+ unset(self::$companyCache[$companyUuid]);
+ }
+
+ /**
+ * The point in time before which rows governed by `$key` are expired, or null when retention is off.
+ */
+ public function cutoff(string $key): ?Carbon
+ {
+ $value = (int) ($this->values[$key] ?? 0);
+ if ($value <= 0) {
+ return null;
+ }
+
+ return $key === 'processed_retention_hours' ? Carbon::now()->subHours($value) : Carbon::now()->subDays($value);
+ }
+
+ public function deletesEvents(): bool
+ {
+ return $this->values['event_retention_days'] > 0;
+ }
+
+ /**
+ * Compaction strips raw payloads from events that are kept but no longer need the provider blob.
+ * It is pointless when events are deleted before, or at the same age as, they would be compacted.
+ */
+ public function compactsEvents(): bool
+ {
+ $compactAfter = $this->values['event_compact_after_days'];
+ if ($compactAfter <= 0) {
+ return false;
+ }
+
+ return !$this->deletesEvents() || $compactAfter < $this->values['event_retention_days'];
+ }
+
+ public function logsTelemetryActivity(): bool
+ {
+ return (bool) $this->values['log_telemetry_activity'];
+ }
+
+ public function get(string $key): mixed
+ {
+ return $this->values[$key] ?? null;
+ }
+
+ public function toArray(): array
+ {
+ return $this->values;
+ }
+
+ private static function applyMaximums(array $values): array
+ {
+ foreach (self::MAXIMUMS as $key => $maximumKey) {
+ $maximum = $values[$maximumKey];
+ if ($maximum > 0 && ($values[$key] === 0 || $values[$key] > $maximum)) {
+ $values[$key] = $maximum;
+ }
+ }
+
+ return $values;
+ }
+
+ private static function pick(array $input, array $base, string $key): mixed
+ {
+ if (array_key_exists($key, $input) && $input[$key] !== null && $input[$key] !== '') {
+ return $input[$key];
+ }
+
+ return $base[$key] ?? self::FALLBACKS[$key];
+ }
+
+ /**
+ * Read the stored setting for a scope. Any failure (no settings table, no session store)
+ * falls back to the layer below so ingestion never depends on the settings store.
+ */
+ private static function resolve(string $scope, ?string $companyUuid): array
+ {
+ try {
+ if (self::$settingsResolver) {
+ $value = (self::$settingsResolver)($scope, self::SETTING_KEY, [], $companyUuid);
+ } elseif ($scope === 'system') {
+ $value = Setting::lookup(self::SETTING_KEY, []);
+ } else {
+ $value = self::lookupCompanySetting($companyUuid);
+ }
+ } catch (\Throwable) {
+ $value = [];
+ }
+
+ return is_array($value) ? $value : [];
+ }
+
+ /**
+ * `Setting::lookupCompany` reads the company from the session, which queue workers and
+ * console commands do not have; scope it for the lookup and restore whatever was there.
+ */
+ private static function lookupCompanySetting(string $companyUuid): mixed
+ {
+ $session = app('session');
+ $had = $session->has('company');
+ $previous = $session->get('company');
+ $session->put('company', $companyUuid);
+
+ try {
+ return Setting::lookupCompany(self::SETTING_KEY, []);
+ } finally {
+ $had ? $session->put('company', $previous) : $session->forget('company');
+ }
+ }
+}
diff --git a/server/src/Support/Telematics/Retention/TelemetryActivity.php b/server/src/Support/Telematics/Retention/TelemetryActivity.php
new file mode 100644
index 000000000..57072710d
--- /dev/null
+++ b/server/src/Support/Telematics/Retention/TelemetryActivity.php
@@ -0,0 +1,40 @@
+logsTelemetryActivity()) {
+ return $callback();
+ }
+
+ $logger = self::logger();
+ if (!$logger) {
+ return $callback();
+ }
+
+ return $logger->withoutLogs($callback);
+ }
+
+ /**
+ * The logger is optional: an environment without the activity log package
+ * bound (package tests, minimal consoles) must still ingest telemetry.
+ */
+ private static function logger(): ?ActivityLogger
+ {
+ try {
+ return app(ActivityLogger::class);
+ } catch (\Throwable) {
+ return null;
+ }
+ }
+}
diff --git a/server/src/Support/Telematics/TelematicService.php b/server/src/Support/Telematics/TelematicService.php
index 42015bf79..b1541f88d 100644
--- a/server/src/Support/Telematics/TelematicService.php
+++ b/server/src/Support/Telematics/TelematicService.php
@@ -239,6 +239,12 @@ public function ingestDeviceSnapshot(Telematic $telematic, TelematicProviderInte
if ($provider instanceof \Fleetbase\FleetOps\Contracts\TelemetryProviderInterface) {
return app(Telemetry\Ingestor::class)->ingest($telematic, $provider, $payload, $this);
}
+
+ return Retention\TelemetryActivity::run($telematic->company_uuid, fn () => $this->ingestLegacySnapshot($telematic, $provider, $payload));
+ }
+
+ protected function ingestLegacySnapshot(Telematic $telematic, TelematicProviderInterface $provider, array $payload): array
+ {
$device = $this->linkDevice($telematic, $provider->normalizeDevice($payload));
$event = null;
@@ -299,7 +305,9 @@ public function storeDeviceEvent(Telematic $telematic, array $eventData, ?Device
], fn ($value) => $value !== null);
$event->payload = $eventData['payload'] ?? $eventData['meta'] ?? $eventData;
$event->_key = $eventKey;
- $event->meta = array_merge($eventData['meta'] ?? [], [
+ // Providers hand over the raw unit in `meta`; it already lives in `payload`.
+ // Persist only the normalized block so each event is stored once, not twice.
+ $event->meta = array_filter([
'telematic_uuid' => $telematic->uuid,
'telematic_id' => $telematic->public_id,
'provider_event_id' => $event->ident,
@@ -310,7 +318,9 @@ public function storeDeviceEvent(Telematic $telematic, array $eventData, ?Device
'odometer' => $eventData['odometer'] ?? null,
'ignition' => $eventData['ignition'] ?? null,
'fuel_level' => $eventData['fuel_level'] ?? null,
- ]);
+ 'telemetry' => data_get($eventData, 'meta.telemetry'),
+ 'provider_status' => data_get($eventData, 'meta.provider_status'),
+ ], fn ($value) => $value !== null);
$location = $this->normalizeLocation($eventData['location'] ?? null);
if ($location) {
diff --git a/server/src/Support/Telematics/Telemetry/Ingestor.php b/server/src/Support/Telematics/Telemetry/Ingestor.php
index e09a6c6cd..9b790900d 100644
--- a/server/src/Support/Telematics/Telemetry/Ingestor.php
+++ b/server/src/Support/Telematics/Telemetry/Ingestor.php
@@ -7,6 +7,7 @@
use Fleetbase\FleetOps\Models\Device;
use Fleetbase\FleetOps\Models\DeviceEvent;
use Fleetbase\FleetOps\Models\Telematic;
+use Fleetbase\FleetOps\Support\Telematics\Retention\TelemetryActivity;
use Fleetbase\FleetOps\Support\Telematics\TelematicService;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
@@ -32,7 +33,8 @@ public function ingest(Telematic $telematic, TelemetryProviderInterface $provide
}
$key = 'telemetry:device:' . hash('sha256', $telematic->uuid . '|' . $id);
- return Cache::lock($key, 60)->block(5, fn () => DB::transaction(function () use ($telematic, $sample, $options, $service, $receivedAt, $source, $normalized, $event, $id) {
+ // Telemetry saves (device, event, position, vehicle, sensors) only reach the activity log when the company opts in.
+ 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) {
$device = Device::withoutGlobalScopes()->where('company_uuid', $telematic->company_uuid)->where('telematic_uuid', $telematic->uuid)->where('device_id', $id)->lockForUpdate()->first();
$current = data_get($device?->meta, 'telemetry.position_at') ?? data_get($device?->meta, 'last_update.occurred_at');
$valid = Sample::validPosition($event);
@@ -107,6 +109,6 @@ public function ingest(Telematic $telematic, TelemetryProviderInterface $provide
}
return ['device' => $device, 'event' => $stored, 'events' => $stored ? [$stored] : [], 'sensors' => $sensors, 'duplicate' => $duplicate, 'invalid_position' => !$valid];
- }, 3));
+ }, 3)));
}
}
diff --git a/server/src/Tracking/TrackingIntelligenceService.php b/server/src/Tracking/TrackingIntelligenceService.php
index f89cea241..414d9ea7d 100644
--- a/server/src/Tracking/TrackingIntelligenceService.php
+++ b/server/src/Tracking/TrackingIntelligenceService.php
@@ -18,7 +18,7 @@ public function __construct(
public function track(Order $order, array|TrackingOptions $options = []): array
{
- $options = $options instanceof TrackingOptions ? $options : TrackingOptions::fromArray($options);
+ $options = $options instanceof TrackingOptions ? $options : TrackingOptions::fromArray($options, $order->company_uuid);
$context = $this->contextBuilder->build($order, $options);
$cacheKey = $this->cacheKey($context, $options);
diff --git a/server/src/Tracking/TrackingOptions.php b/server/src/Tracking/TrackingOptions.php
index a216cd148..66cca97ac 100644
--- a/server/src/Tracking/TrackingOptions.php
+++ b/server/src/Tracking/TrackingOptions.php
@@ -19,7 +19,7 @@ public function __construct(
) {
}
- public static function fromArray(array $options = []): self
+ public static function fromArray(array $options = [], ?string $companyUuid = null): self
{
try {
$systemSettings = Setting::lookup('fleet-ops.tracking-settings', []);
@@ -30,7 +30,7 @@ public static function fromArray(array $options = []): self
$config = array_merge(config('fleetops.tracking', []), is_array($systemSettings) ? $systemSettings : []);
$companySettings = [];
try {
- $companySettings = Setting::lookupCompany('tracking', []);
+ $companySettings = Setting::lookupForCompany($companyUuid ?? session('company'), 'tracking', []);
} catch (\Throwable) {
$companySettings = [];
}
diff --git a/server/src/Traits/AuthorizesMethods.php b/server/src/Traits/AuthorizesMethods.php
new file mode 100644
index 000000000..ee4c24181
--- /dev/null
+++ b/server/src/Traits/AuthorizesMethods.php
@@ -0,0 +1,37 @@
+ $map method name => permission(s)
+ */
+ protected function authorizeMethods(array $map): void
+ {
+ foreach ($map as $method => $permissions) {
+ $this->middleware(function ($request, $next) use ($permissions) {
+ if ($permissions === 'admin') {
+ Authorization::authorizeAdmin();
+ } else {
+ Authorization::authorize(...(array) $permissions);
+ }
+
+ return $next($request);
+ })->only($method);
+ }
+ }
+}
diff --git a/server/src/routes.php b/server/src/routes.php
index 631bccc92..dd757f07c 100644
--- a/server/src/routes.php
+++ b/server/src/routes.php
@@ -405,8 +405,10 @@ function ($router) {
$router->group(
['prefix' => 'v1/fleet-ops/navigator', 'namespace' => 'v1'],
function ($router) {
- $router->get('get-link-app', 'NavigatorController@getLinkAppUrl');
+ // Opened from the Navigator app's QR scan without a session;
+ // NavigatorController::linkApp() requires a valid signature.
$router->get('link-app', 'NavigatorController@linkApp');
+ $router->get('get-link-app', 'NavigatorController@getLinkAppUrl')->middleware(['fleetbase.protected']);
}
);
@@ -814,6 +816,12 @@ function ($router) {
$router->post('orchestrator-settings', 'SettingController@saveOrchestratorSettings');
$router->get('orchestrator-card-fields', 'SettingController@getOrchestratorCardFields');
$router->post('orchestrator-card-fields', 'SettingController@saveOrchestratorCardFields');
+ $router->get('telematics-settings', 'SettingController@getTelematicsSettings');
+ $router->post('telematics-settings', 'SettingController@saveTelematicsSettings');
+ $router->get('admin-telematics-settings', 'SettingController@getAdminTelematicsSettings');
+ $router->post('admin-telematics-settings', 'SettingController@saveAdminTelematicsSettings');
+ $router->get('telematics-storage-usage', 'SettingController@getTelematicsStorageUsage');
+ $router->post('telematics-retention/run', 'SettingController@runTelematicsRetention');
}
);
$router->group(
diff --git a/server/tests/AnalyticsRoutesTest.php b/server/tests/AnalyticsRoutesTest.php
index 41048f9bf..07f243c14 100644
--- a/server/tests/AnalyticsRoutesTest.php
+++ b/server/tests/AnalyticsRoutesTest.php
@@ -17,6 +17,9 @@
use Fleetbase\FleetOps\Support\Analytics\TopDrivers;
use Fleetbase\LaravelMysqlSpatial\Types\Point;
use Fleetbase\Models\Company;
+use Illuminate\Database\ConnectionResolver;
+use Illuminate\Database\Eloquent\Model as EloquentModel;
+use Illuminate\Database\SQLiteConnection;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
@@ -273,6 +276,16 @@ function fleetOpsAnalyticsControllerRequest(array $input = []): Request
});
test('live fleet analytics serializes driver and vehicle map payloads', function () {
+ // The markers' cards look up the current order and last known position.
+ $connection = new SQLiteConnection(new PDO('sqlite::memory:'));
+ $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)');
+ $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)');
+ $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)');
+ $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)');
+ $resolver = new ConnectionResolver(['default' => $connection, 'mysql' => $connection]);
+ $resolver->setDefaultConnection('mysql');
+ EloquentModel::setConnectionResolver($resolver);
+
$analytics = new LiveFleet();
$driver = new TestFleetOpsLiveFleetDriver();
@@ -308,7 +321,21 @@ function fleetOpsAnalyticsControllerRequest(array $input = []): Request
$driverPayload->setAccessible(true);
$vehiclePayload->setAccessible(true);
- expect($driverPayload->invoke($analytics, $driver))->toBe([
+ $driverResult = $driverPayload->invoke($analytics, $driver);
+ $vehicleResult = $vehiclePayload->invoke($analytics, $vehicle);
+
+ // Each marker carries the live map's card: the same index resource the live endpoints return.
+ expect($driverResult['card'])->toBeArray()
+ ->and($driverResult['card'])->toHaveKeys(['name', 'status', 'phone', 'email', 'online', 'meta'])
+ ->and($driverResult['card']['meta'])->toHaveKeys(['status_label', 'speed_label', 'heading_label', 'location_coordinates', 'current_order_reference'])
+ ->and($driverResult['card']['name'])->toBe('Ada Driver')
+ ->and($vehicleResult['card'])->toHaveKeys(['display_name', 'driver_name', 'plate_number', 'online', 'meta'])
+ ->and($vehicleResult['card']['display_name'])->toBe('Van 7')
+ ->and($vehicleResult['card']['meta'])->toHaveKeys(['status_label', 'speed_label', 'heading_label', 'location_coordinates', 'current_order_reference']);
+
+ unset($driverResult['card'], $vehicleResult['card']);
+
+ expect($driverResult)->toBe([
'uuid' => 'driver-uuid',
'public_id' => 'driver-public',
'name' => 'Ada Driver',
@@ -320,7 +347,7 @@ function fleetOpsAnalyticsControllerRequest(array $input = []): Request
'lat' => 1.30,
'lng' => 103.80,
'updated_at' => '2026-01-01 10:00:00',
- ])->and($vehiclePayload->invoke($analytics, $vehicle))->toBe([
+ ])->and($vehicleResult)->toBe([
'uuid' => 'vehicle-uuid',
'public_id' => 'vehicle-public',
'name' => 'Van 7',
diff --git a/server/tests/Feature/Http/AfaqyRealtimeIngestionTest.php b/server/tests/Feature/Http/AfaqyRealtimeIngestionTest.php
index 5a56b5a40..6d7a5ad9e 100644
--- a/server/tests/Feature/Http/AfaqyRealtimeIngestionTest.php
+++ b/server/tests/Feature/Http/AfaqyRealtimeIngestionTest.php
@@ -3,6 +3,7 @@
require_once __DIR__ . '/../../Support/AfaqyTestCrypto.php';
require_once __DIR__ . '/../../Support/ExampleTelemetryProvider.php';
require_once __DIR__ . '/../../Support/TelemetryTestEnvironment.php';
+require_once __DIR__ . '/../../Support/PruneTelematicsDataProbe.php';
use Fleetbase\FleetOps\Http\Controllers\TelematicPositionWebhookController;
use Fleetbase\FleetOps\Jobs\ProcessTelematicDelivery;
@@ -123,6 +124,12 @@ function afaqyDbUnit(string $time = '2026-09-15 11:59:00', float $lat = 24.0): a
expect(DeviceEvent::withoutGlobalScopes()->count())->toBe(2);
expect(count($GLOBALS['afaqy_broadcasts']))->toBe(1);
expect($device->last_position->getLat())->toBe(24.0);
+ // The raw unit is stored once, in payload; meta carries only the normalized block.
+ $stored = DeviceEvent::withoutGlobalScopes()->orderBy('id')->first();
+ expect(data_get($stored->payload, '_id'))->toBe('unit-1')
+ ->and($stored->meta)->not->toHaveKeys(['_id', 'last_update'])
+ ->and(data_get($stored->meta, 'telemetry.position_at'))->toBe('2026-09-15T11:59:00.000000Z')
+ ->and(data_get($stored->meta, 'telematic_uuid'))->toBe('integration-1');
});
test('webhook authenticates before durable acceptance and worker quarantines unknown shapes', function () {
@@ -282,7 +289,7 @@ public function storeDeviceEvent(Telematic $telematic, array $eventData, ?Device
expect($elapsed)->toBeLessThan(60.0);
})->skip(getenv('AFAQY_RUN_LOAD_TESTS') !== '1', 'Opt-in local processing benchmark; production latency requires deployment testing.');
-test('inbox maintenance retains replayable payloads and cleans expired rows during a broker outage', function () {
+test('inbox recovery leaves retention to the prune command, which expires processed deliveries by policy', function () {
$telematic = afaqyDbFixture();
$inbox = new Inbox();
$pending = $inbox->accept($telematic, afaqyDbUnit(), 'poll');
@@ -297,9 +304,17 @@ public function storeDeviceEvent(Telematic $telematic, array $eventData, ?Device
'metadata' => ['telemetry' => ['durable_ingestion' => true]],
]));
expect((new Fleetbase\FleetOps\Console\Commands\DrainTelematicInbox())->handle())->toBe(0);
+ // The drain only recovers; nothing is expired until the retention sweep runs.
+ expect(DB::table('telematic_deliveries')->count())->toBe(3);
+
+ $prune = new PruneTelematicsDataProbe();
+ $prune->options['company'] = 'company-1';
+ $prune->companyRows = collect([(object) ['id' => 1, 'uuid' => 'company-1', 'public_id' => 'company_1']]);
+ expect($prune->handle())->toBe(0);
expect(DB::table('telematic_deliveries')->where('uuid', $processed)->exists())->toBeFalse();
expect(DB::table('telematic_deliveries')->where('uuid', $quarantined)->exists())->toBeTrue();
expect(DB::table('telematic_deliveries')->where('uuid', $pending)->value('status'))->toBe('pending');
+ expect($prune->messages)->toContain(['info', 'company_1 telematic_deliveries: deleted=1 compacted=0 batches=1']);
});
test('sensor values use source time and preserve units through partial and older snapshots', function () {
diff --git a/server/tests/Feature/Http/Api/TelematicsRetentionIndexMigrationTest.php b/server/tests/Feature/Http/Api/TelematicsRetentionIndexMigrationTest.php
new file mode 100644
index 000000000..e6027f553
--- /dev/null
+++ b/server/tests/Feature/Http/Api/TelematicsRetentionIndexMigrationTest.php
@@ -0,0 +1,115 @@
+ 'sqlite']);
+ Schema::swap($connection->getSchemaBuilder());
+
+ return $connection;
+}
+
+function fleetopsRetentionIndexMigrations(): array
+{
+ $migrations = [];
+ foreach (glob(dirname(__DIR__, 4) . '/migrations/2026_09_23_00000*_add_retention_index_to_*.php') as $path) {
+ $migrations[basename($path)] = fn () => require $path;
+ }
+
+ return $migrations;
+}
+
+test('retention index migrations add composite indexes once and roll them back', function () {
+ fleetopsRetentionIndexDatabase();
+ Schema::create('device_events', function ($table) {
+ $table->increments('id');
+ $table->uuid('company_uuid')->nullable();
+ $table->timestamps();
+ });
+ Schema::create('positions', function ($table) {
+ $table->increments('id');
+ $table->uuid('company_uuid')->nullable();
+ $table->timestamps();
+ });
+ Schema::create('telematic_sync_runs', function ($table) {
+ $table->uuid('uuid')->primary();
+ $table->uuid('telematic_uuid')->index();
+ $table->timestamps();
+ });
+
+ $expected = [
+ 'device_events' => ['device_events_company_created_at_index', ['company_uuid', 'created_at']],
+ 'positions' => ['positions_company_created_at_index', ['company_uuid', 'created_at']],
+ 'telematic_sync_runs' => ['telematic_sync_runs_telematic_updated_at_index', ['telematic_uuid', 'updated_at']],
+ ];
+ $migrations = fleetopsRetentionIndexMigrations();
+ expect($migrations)->toHaveCount(3);
+ foreach ($migrations as $load) {
+ $load()->up();
+ $load()->up();
+ }
+ foreach ($expected as $table => [$name, $columns]) {
+ expect(TableIndexes::for($table)[$name] ?? null)->toBe($columns)
+ ->and(TableIndexes::covers($table, $columns))->toBeTrue()
+ ->and(TableIndexes::covers($table, ['created_at']))->toBeFalse();
+ }
+ // The existing single-column index on telematic_uuid does not cover the composite.
+ expect(count(array_filter(TableIndexes::for('telematic_sync_runs'), fn ($columns) => $columns === ['telematic_uuid'])))->toBe(1);
+
+ foreach ($migrations as $load) {
+ $load()->down();
+ }
+ foreach ($expected as $table => [$name]) {
+ expect(TableIndexes::for($table))->not->toHaveKey($name);
+ }
+});
+
+test('retention index migrations skip missing tables, missing columns and equivalent indexes', function () {
+ fleetopsRetentionIndexDatabase();
+ $migrations = fleetopsRetentionIndexMigrations();
+
+ // No tables at all: nothing to do either way.
+ foreach ($migrations as $load) {
+ $load()->up();
+ $load()->down();
+ }
+
+ Schema::create('device_events', fn ($table) => $table->increments('id'));
+ Schema::create('positions', function ($table) {
+ $table->increments('id');
+ $table->uuid('company_uuid')->nullable();
+ $table->timestamps();
+ $table->index(['company_uuid', 'created_at', 'id'], 'positions_existing_company_created');
+ });
+ Schema::create('telematic_sync_runs', function ($table) {
+ $table->uuid('uuid')->primary();
+ $table->uuid('telematic_uuid');
+ $table->timestamps();
+ $table->index(['telematic_uuid', 'updated_at'], 'telematic_sync_runs_telematic_updated_at_index');
+ });
+ Schema::table('telematic_sync_runs', fn ($table) => $table->index('updated_at', 'unrelated_updated_at'));
+
+ foreach ($migrations as $load) {
+ $load()->up();
+ }
+ expect(TableIndexes::for('device_events'))->toBe([])
+ ->and(array_keys(TableIndexes::for('positions')))->toBe(['positions_existing_company_created'])
+ ->and(TableIndexes::for('telematic_sync_runs'))->toHaveKeys(['telematic_sync_runs_telematic_updated_at_index', 'unrelated_updated_at']);
+
+ // Rolling back never drops an index the migration did not create, even under the reserved name when its shape differs.
+ Schema::table('telematic_sync_runs', fn ($table) => $table->dropIndex('telematic_sync_runs_telematic_updated_at_index'));
+ Schema::table('telematic_sync_runs', fn ($table) => $table->index('updated_at', 'telematic_sync_runs_telematic_updated_at_index'));
+ foreach ($migrations as $load) {
+ $load()->down();
+ }
+ expect(array_keys(TableIndexes::for('positions')))->toBe(['positions_existing_company_created'])
+ ->and(TableIndexes::for('telematic_sync_runs')['telematic_sync_runs_telematic_updated_at_index'])->toBe(['updated_at']);
+});
diff --git a/server/tests/Feature/Http/Internal/SettingControllerHelpersTest.php b/server/tests/Feature/Http/Internal/SettingControllerHelpersTest.php
index 8556b0b4f..ccd6651ea 100644
--- a/server/tests/Feature/Http/Internal/SettingControllerHelpersTest.php
+++ b/server/tests/Feature/Http/Internal/SettingControllerHelpersTest.php
@@ -131,3 +131,74 @@ public function __call($method, $arguments)
->and($helper('notificationsByPackage', 'fleet-ops'))->toBeArray()
->and($helper('trackingProviders'))->toBeArray();
});
+
+test('history policy reconciliation clamps every organization and preserves inherited and internal settings', function () {
+ $connection = fleetopsSettingHelpersBoot();
+ $key = Fleetbase\FleetOps\Support\Telematics\Retention\RetentionPolicy::SETTING_KEY;
+ $controller = new class extends SettingController {
+ public ?Closure $beforeLock = null;
+
+ public function reconcile(array $defaults): void
+ {
+ $this->reconcileTelematicsCompanyPreferences($defaults);
+ }
+
+ public function lock(Closure $callback): mixed
+ {
+ return parent::withTelematicsPolicyLock($callback);
+ }
+
+ protected function withTelematicsPolicyLock(Closure $callback): mixed
+ {
+ if ($this->beforeLock) {
+ ($this->beforeLock)();
+ $this->beforeLock = null;
+ }
+
+ return parent::withTelematicsPolicyLock($callback);
+ }
+
+ protected function telematicsDefaults(): array
+ {
+ return ['max_event_retention_days' => 90, 'max_position_retention_days' => 360];
+ }
+ };
+ // The first write creates the lock anchor; subsequent writes reuse it.
+ expect($controller->lock(fn () => 'saved'))->toBe('saved');
+ expect($controller->lock(fn () => $connection->transactionLevel()))->toBe(1)
+ ->and($connection->table('settings')->where('key', $key)->count())->toBe(1);
+ foreach ([
+ 'a' => ['event_retention_days' => 0, 'position_retention_days' => 900, 'log_telemetry_activity' => true],
+ 'b' => ['event_retention_days' => 30],
+ 'c' => [],
+ ] as $company => $preferences) {
+ $connection->table('settings')->insert(['key' => "company.$company.$key", 'value' => json_encode($preferences)]);
+ }
+ $controller->reconcile(['max_event_retention_days' => 0, 'max_position_retention_days' => 0]);
+ $read = fn ($company) => json_decode($connection->table('settings')->where('key', "company.$company.$key")->value('value'), true);
+ expect($read('a')['event_retention_days'])->toBe(0);
+ $controller->reconcile(['max_event_retention_days' => 90, 'max_position_retention_days' => 360]);
+ expect($read('a'))->toBe(['event_retention_days' => 90, 'position_retention_days' => 360, 'log_telemetry_activity' => true])
+ ->and($read('b'))->toBe(['event_retention_days' => 30])->and($read('c'))->toBe([]);
+ // A tenant removed after the batch is read must not be recreated.
+ $controller->beforeLock = fn () => $connection->table('settings')->where('key', "company.a.$key")->delete();
+ $controller->reconcile(['max_event_retention_days' => 90]);
+ expect($connection->table('settings')->where('key', "company.a.$key")->exists())->toBeFalse();
+});
+
+test('company order configuration keys exclude other tenants and deleted configurations', function () {
+ $connection = fleetopsSettingHelpersBoot();
+ $connection->getSchemaBuilder()->create('order_configs', function ($table) {
+ $table->increments('id');
+ $table->string('uuid');
+ $table->string('public_id')->nullable();
+ $table->string('company_uuid');
+ $table->softDeletes();
+ });
+ $connection->table('order_configs')->insert([
+ ['uuid' => 'own', 'public_id' => 'own-public', 'company_uuid' => 'company-1', 'deleted_at' => null],
+ ['uuid' => 'other', 'public_id' => 'other-public', 'company_uuid' => 'company-2', 'deleted_at' => null],
+ ['uuid' => 'deleted', 'public_id' => null, 'company_uuid' => 'company-1', 'deleted_at' => '2026-01-01'],
+ ]);
+ expect((new ReflectionMethod(SettingController::class, 'companyOrderConfigKeys'))->invoke(new SettingController()))->toBe(['own', 'own-public']);
+});
diff --git a/server/tests/Feature/Http/Internal/TelematicsStorageDeadlinesTest.php b/server/tests/Feature/Http/Internal/TelematicsStorageDeadlinesTest.php
new file mode 100644
index 000000000..46cf39f51
--- /dev/null
+++ b/server/tests/Feature/Http/Internal/TelematicsStorageDeadlinesTest.php
@@ -0,0 +1,119 @@
+maria) extends PDO {
+ public function __construct(private bool $maria)
+ {
+ }
+
+ public function getAttribute(int $attribute): mixed
+ {
+ if ($attribute !== PDO::ATTR_SERVER_VERSION) {
+ throw new LogicException('Only the database server version should be read');
+ }
+
+ return $this->maria ? '10.11.8-MariaDB' : '8.0.40';
+ }
+ };
+ }
+
+ public function select($query, $bindings = [], $useReadPdo = true)
+ {
+ $this->queries[] = [$query, $bindings];
+ $response = array_shift($this->responses) ?? [];
+ if ($response instanceof Throwable) {
+ throw $response;
+ }
+
+ return $response;
+ }
+}
+
+function fleetopsStorageQueryError(int $code): QueryException
+{
+ $error = new PDOException('Database query failed');
+ $error->errorInfo = ['HY000', $code, 'Database query failed'];
+
+ return new QueryException('mysql', 'select history', [], $error);
+}
+
+beforeEach(function () {
+ $this->originalDb = app()->bound('db') ? app('db') : null;
+ $this->connection = new FleetOpsStorageDeadlineConnection(new PDO('sqlite::memory:'), '', '', ['driver' => 'mysql']);
+ $connection = $this->connection;
+ app()->instance('db', new class($connection) {
+ public function __construct(public $database)
+ {
+ }
+
+ public function connection($name = null)
+ {
+ return $this->database;
+ }
+
+ public function __call($method, $args)
+ {
+ return $this->database->{$method}(...$args);
+ }
+ });
+ DB::clearResolvedInstance('db');
+ $this->controller = new SettingController();
+ $this->invoke = fn ($method, ...$arguments) => (new ReflectionMethod(SettingController::class, $method))->invoke($this->controller, ...$arguments);
+ $this->scope = fn ($query) => $query->where('company_uuid', 'company-a');
+});
+
+afterEach(function () {
+ if ($this->originalDb) {
+ app()->instance('db', $this->originalDb);
+ } else {
+ app()->forgetInstance('db');
+ }
+ DB::clearResolvedInstance('db');
+});
+
+test('storage scans enforce driver specific deadlines while preserving bindings', function (bool $maria) {
+ $this->connection->maria = $maria;
+ $this->connection->responses = [[(object) ['row_count' => '4', 'oldest' => '2026-01-01']], [(object) ['rows' => 4]]];
+ expect(($this->invoke)('tableUsage', 'device_events', $this->scope, 'created_at'))->toBe(['rows' => 4, 'oldest' => '2026-01-01']);
+ ($this->invoke)('storageUsageSelect', 'select * from device_events where company_uuid = ?', ['company-a'], true);
+ expect($this->connection->queries[0][1])->toBe(['company-a'])
+ ->and($this->connection->queries[0][0])->toStartWith($maria ? 'SET STATEMENT max_statement_time=1 FOR select' : 'SELECT /*+ MAX_EXECUTION_TIME(1000) */')
+ ->and($this->connection->queries[1][0])->toStartWith($maria ? 'SET STATEMENT max_statement_time=1 FOR EXPLAIN select' : 'EXPLAIN SELECT /*+ MAX_EXECUTION_TIME(1000) */')
+ ->and($this->connection->queries[1][1])->toBe(['company-a']);
+})->with([false, true]);
+
+test('timed out counts use a scoped query plan estimate instead of another history scan', function () {
+ $this->connection->responses = [fleetopsStorageQueryError(3024), [(object) ['rows' => 101, 'filtered' => 25]]];
+ expect(($this->invoke)('tableUsage', 'device_events', $this->scope, 'created_at'))->toBe(['rows' => 25, 'oldest' => null, 'rows_estimated' => true])
+ ->and($this->connection->queries[1][0])->toStartWith('EXPLAIN SELECT')
+ ->and($this->connection->queries[1][1])->toBe(['company-a']);
+ $this->connection->responses = [fleetopsStorageQueryError(1969), fleetopsStorageQueryError(1969)];
+ expect(($this->invoke)('tableUsage', 'device_events', $this->scope, 'created_at'))->toBe(['rows' => null, 'oldest' => null, 'rows_estimated' => true]);
+});
+
+test('storage queries suppress only timeout errors and retain real database failures', function () {
+ foreach (['tableUsage', 'tableOldest'] as $method) {
+ $this->connection->responses = [fleetopsStorageQueryError(1146)];
+ expect(fn () => ($this->invoke)($method, 'device_events', $this->scope, 'created_at'))->toThrow(QueryException::class);
+ }
+ $this->connection->responses = [fleetopsStorageQueryError(3024), fleetopsStorageQueryError(1146)];
+ expect(fn () => ($this->invoke)('tableUsage', 'device_events', $this->scope, 'created_at'))->toThrow(QueryException::class);
+ $this->connection->responses = [fleetopsStorageQueryError(3024)];
+ expect(($this->invoke)('tableOldest', 'device_events', $this->scope, 'created_at'))->toBeNull();
+ $this->connection->responses = [fleetopsStorageQueryError(1969)];
+ expect(($this->invoke)('storageTableStatistics', ['device_events']))->toBe([]);
+ $this->connection->responses = [fleetopsStorageQueryError(1146)];
+ expect(fn () => ($this->invoke)('storageTableStatistics', ['device_events']))->toThrow(QueryException::class);
+});
diff --git a/server/tests/InternalImportExportControllerContractsTest.php b/server/tests/InternalImportExportControllerContractsTest.php
index 4994461e0..db8282592 100644
--- a/server/tests/InternalImportExportControllerContractsTest.php
+++ b/server/tests/InternalImportExportControllerContractsTest.php
@@ -269,14 +269,11 @@ function fleetopsExportSelections(object $export): array
$controller = new FleetOpsInternalFuelReportControllerProbe();
$fuelReport = new FleetOpsInternalFuelReportAfterSaveFake();
- $controller->afterSave(new Request([
- 'fuel_report' => [
- 'custom_field_values' => [
- ['key' => 'receipt_number', 'value' => 'R-100'],
- ],
- ],
- ]), $fuelReport);
- $controller->afterSave(new Request(['fuel_report' => ['custom_field_values' => []]]), $fuelReport);
+ $input = ['custom_field_values' => [['key' => 'receipt_number', 'value' => 'R-100']]];
+ $controller->afterSave(new Request(['fuelReport' => $input]), $fuelReport, $input);
+ $controller->afterSave(new Request(), $fuelReport, ['custom_field_values' => []]);
+ $controller->afterSave(new Request(), $fuelReport, ['custom_field_values' => 'invalid']);
+ $controller->afterSave(new Request(), $fuelReport);
expect($fuelReport->synced)->toBe([
[
diff --git a/server/tests/NavigatorControllerContractsTest.php b/server/tests/NavigatorControllerContractsTest.php
index 0b2060cf0..216804e17 100644
--- a/server/tests/NavigatorControllerContractsTest.php
+++ b/server/tests/NavigatorControllerContractsTest.php
@@ -137,17 +137,24 @@ function fleetopsNavigatorCredential(string $key = 'flb_live_key'): ApiCredentia
return $credential;
}
+function fleetopsNavigatorSignedQuery(): array
+{
+ $expires = time() + 600;
+
+ return ['expires' => $expires, 'signature' => NavigatorController::linkSignature($expires)];
+}
+
test('navigator controller builds android and ios app link redirects', function () {
$company = fleetopsNavigatorCompany();
$controller = new FleetOpsNavigatorControllerProbe();
$controller->adminUser = fleetopsNavigatorUser($company);
$controller->apiCredential = fleetopsNavigatorCredential('flb_live_navigator');
- $android = $controller->linkApp(Request::create('/navigator/link-app', 'GET', [], [], [], [
+ $android = $controller->linkApp(Request::create('/navigator/link-app', 'GET', fleetopsNavigatorSignedQuery(), [], [], [
'HTTP_USER_AGENT' => 'Mozilla/5.0 Android',
]))->getData(true);
- $ios = $controller->linkApp(Request::create('/navigator/link-app', 'GET', [], [], [], [
+ $ios = $controller->linkApp(Request::create('/navigator/link-app', 'GET', fleetopsNavigatorSignedQuery(), [], [], [
'HTTP_USER_AGENT' => 'Mozilla/5.0 iPhone',
]))->getData(true);
@@ -169,11 +176,32 @@ function fleetopsNavigatorCredential(string $key = 'flb_live_key'): ApiCredentia
$controller = new FleetOpsNavigatorControllerProbe();
$controller->adminUser = fleetopsNavigatorUser(null);
- $response = $controller->linkApp(new Request());
+ $response = $controller->linkApp(Request::create('/navigator/link-app', 'GET', fleetopsNavigatorSignedQuery()));
expect($response->getData(true))->toBe(['error' => 'Organization for linking not found.']);
});
+test('navigator controller refuses unsigned, tampered and expired app links before touching credentials', function () {
+ $company = fleetopsNavigatorCompany();
+ $controller = new FleetOpsNavigatorControllerProbe();
+ $controller->adminUser = fleetopsNavigatorUser($company);
+ $controller->apiCredential = fleetopsNavigatorCredential('flb_live_navigator');
+
+ $expired = time() - 60;
+ $requests = [
+ 'unsigned' => Request::create('/navigator/link-app', 'GET'),
+ 'tampered' => Request::create('/navigator/link-app', 'GET', ['signature' => 'forged'] + fleetopsNavigatorSignedQuery()),
+ 'expired' => Request::create('/navigator/link-app', 'GET', ['expires' => $expired, 'signature' => NavigatorController::linkSignature($expired)]),
+ ];
+
+ foreach ($requests as $request) {
+ $response = $controller->linkApp($request);
+ expect($response->getData(true))->toBe(['error' => 'This Navigator link is invalid or has expired. Generate a new one from the console.']);
+ }
+
+ expect($controller->credentialLookups)->toBe([]);
+});
+
test('navigator controller exposes link url settings and current organization token lookup branches', function () {
$controller = new FleetOpsNavigatorControllerProbe();
$controller->apiCredential = fleetopsNavigatorCredential('flb_test_key');
@@ -194,7 +222,11 @@ function fleetopsNavigatorCredential(string $key = 'flb_live_key'): ApiCredentia
]);
$missing = $controller->getCurrentOrganization($secretRequest);
- expect($linkUrl)->toBe(['linkUrl' => 'http://localhost/int/v1/fleet-ops/navigator/link-app'])
+ parse_str((string) parse_url($linkUrl['linkUrl'], PHP_URL_QUERY), $linkQuery);
+
+ expect($linkUrl['linkUrl'])->toStartWith('http://localhost/int/v1/fleet-ops/navigator/link-app?')
+ ->and($linkQuery['signature'])->toBe(NavigatorController::linkSignature((int) $linkQuery['expires']))
+ ->and((int) $linkQuery['expires'])->toBeGreaterThan(time())
->and($settings)->toBe(['enabled' => true, 'invite_code_required' => false])
->and($organization)->toBeInstanceOf(Organization::class)
->and($controller->credentialLookups)->toContain(
diff --git a/server/tests/NotificationAndMailContractsTest.php b/server/tests/NotificationAndMailContractsTest.php
index 1481b8783..8539b5620 100644
--- a/server/tests/NotificationAndMailContractsTest.php
+++ b/server/tests/NotificationAndMailContractsTest.php
@@ -675,3 +675,34 @@ public function track(Order $order, mixed $options): array
'offsetDays' => 7,
]);
});
+
+test('customer credentials mail reads the portal slug from the customer company regardless of session', function (?string $sessionCompany) {
+ app('config')->set('fleetbase.console.host', 'console.fleetbase.test');
+ app('config')->set('fleetbase.console.secure', true);
+ app('config')->set('fleetbase.console.subdomain', null);
+ app('session.store')->put('company', $sessionCompany);
+
+ $connection = new Illuminate\Database\SQLiteConnection(new PDO('sqlite::memory:'));
+ $resolver = new Illuminate\Database\ConnectionResolver(['default' => $connection, 'mysql' => $connection]);
+ $resolver->setDefaultConnection('mysql');
+ Illuminate\Database\Eloquent\Model::setConnectionResolver($resolver);
+ $connection->getSchemaBuilder()->create('settings', function ($blueprint) {
+ $blueprint->increments('id');
+ $blueprint->string('key')->nullable();
+ $blueprint->text('value')->nullable();
+ $blueprint->timestamps();
+ });
+ $connection->table('settings')->insert([
+ ['key' => 'company.company-portal.customer-portal-config', 'value' => json_encode(['accessUrlSlug' => 'acme-portal'])],
+ ['key' => 'company.other-company.customer-portal-config', 'value' => json_encode(['accessUrlSlug' => 'other-portal'])],
+ ]);
+
+ $customer = new FleetOpsNotificationContactFake();
+ $customer->userForTest = new User();
+ $customer->userForTest->setRawAttributes(['email' => 'customer@example.test'], true);
+ $customer->setRawAttributes(['company_uuid' => 'company-portal'], true);
+
+ $content = fleetOpsNotificationWithEnvironment(fn () => (new CustomerCredentialsMail('plain-secret', $customer))->content());
+
+ expect($content->with['customerPortalUrl'])->toBe('https://console.fleetbase.test/acme-portal');
+})->with([null, 'other-company']);
diff --git a/server/tests/ObserverContractsTest.php b/server/tests/ObserverContractsTest.php
index 38b097fa3..0a15bf65c 100644
--- a/server/tests/ObserverContractsTest.php
+++ b/server/tests/ObserverContractsTest.php
@@ -454,7 +454,7 @@ protected function getSchedule(ScheduleItem $scheduleItem): ?Schedule
return $this->schedule;
}
- protected function getSchedulingSettings(): array
+ protected function getSchedulingSettings(?string $companyUuid): array
{
return $this->settings;
}
diff --git a/server/tests/OrchestratorConsumableApiTest.php b/server/tests/OrchestratorConsumableApiTest.php
index c2638a460..6e283484c 100644
--- a/server/tests/OrchestratorConsumableApiTest.php
+++ b/server/tests/OrchestratorConsumableApiTest.php
@@ -2,6 +2,8 @@
use Fleetbase\FleetOps\Http\Controllers\Api\v1\OrchestrationController;
use Fleetbase\FleetOps\Orchestration\OrchestrationEngineRegistry;
+use Illuminate\Http\Exceptions\HttpResponseException;
+use Illuminate\Http\Request;
function sanitize_orchestrator_payload(array $payload): array
{
@@ -72,3 +74,43 @@ function assert_no_internal_orchestrator_identifiers(array $payload): void
expect($routes)->toContain("\$router->post('run', 'OrchestrationController@run');");
expect($routes)->toContain("\$router->post('commit', 'OrchestrationController@commit');");
});
+
+test('public orchestration uses API authentication without inheriting console IAM middleware', function () {
+ $registry = new OrchestrationEngineRegistry();
+ $public = new OrchestrationController($registry);
+ $internal = new Fleetbase\FleetOps\Http\Controllers\Internal\v1\OrchestrationController($registry);
+
+ expect($public->getMiddleware())->toBe([]);
+
+ // An API-key request has no console IAM role. The internal workbench must
+ // still refuse this actor; its middleware must not leak onto the API class.
+ app('session.store')->flush();
+ $request = Request::create('/int/v1/fleet-ops/orchestrator/run', 'POST');
+ app()->instance('request', $request);
+ foreach (['run', 'commit'] as $method) {
+ $guard = collect($internal->getMiddleware())->first(fn ($entry) => in_array($method, $entry['options']['only'], true));
+ expect($guard)->not->toBeNull();
+
+ try {
+ $guard['middleware']($request, fn () => throw new RuntimeException('Unauthorized request reached the workbench'));
+ test()->fail('The internal workbench must require a permitted user');
+ } catch (HttpResponseException $error) {
+ expect($error->getResponse()->getStatusCode())->toBe(401);
+ }
+ }
+});
+
+test('public orchestration resolves tenant scope from the authenticated session, never request input', function () {
+ $controller = new OrchestrationController(new OrchestrationEngineRegistry());
+ $company = new ReflectionMethod($controller, 'companyUuid');
+ $company->setAccessible(true);
+ $request = Request::create('/v1/orchestrator/run', 'POST', ['company_uuid' => 'other-company']);
+ app()->instance('request', $request);
+
+ session(['company' => 'credential-company']);
+ expect($company->invoke($controller))->toBe('credential-company');
+
+ session(['company' => 'next-credential-company']);
+ expect($company->invoke($controller))->toBe('next-credential-company');
+ app('session.store')->flush();
+});
diff --git a/server/tests/ProviderContractsTest.php b/server/tests/ProviderContractsTest.php
index 50857aa01..e181e93f5 100644
--- a/server/tests/ProviderContractsTest.php
+++ b/server/tests/ProviderContractsTest.php
@@ -115,6 +115,13 @@ public function everyTenMinutes(): self
return $this;
}
+ public function everyFifteenMinutes(): self
+ {
+ $this->methods[] = ['everyFifteenMinutes'];
+
+ return $this;
+ }
+
public function daily(): self
{
$this->methods[] = ['daily'];
@@ -277,11 +284,14 @@ protected function mergeConfigFrom($path, $key)
'fleetops:process-operational-alerts',
'fleetops:sync-telematics',
'fleetops:drain-telematic-inbox',
+ 'fleetops:prune-telematics-data',
])
->and($provider->schedule?->commands['fleetops:dispatch-orders']->methods)->toContain(['everyMinute'], ['withoutOverlapping'], ['storeOutputInDb'])
->and($provider->schedule?->commands['fleetops:update-estimations']->methods)->toContain(['everyTenMinutes'], ['withoutOverlapping'])
->and($provider->schedule?->commands['fleetops:sync-telematics']->expiresAt)->toBe(2)
->and($provider->schedule?->commands['fleetops:drain-telematic-inbox']->expiresAt)->toBe(2)
+ ->and($provider->schedule?->commands['fleetops:prune-telematics-data']->methods)->toContain(['everyFifteenMinutes'], ['withoutOverlapping'], ['storeOutputInDb'])
+ ->and($provider->schedule?->commands['fleetops:prune-telematics-data']->expiresAt)->toBe(14)
->and($orchestrationRegistry->has('vroom'))->toBeTrue()
->and($orchestrationRegistry->has('greedy'))->toBeTrue()
->and($orchestrationRegistry->has('capacity'))->toBeTrue()
diff --git a/server/tests/ReportSchemaContractsTest.php b/server/tests/ReportSchemaContractsTest.php
index b1465b71c..afdae61aa 100644
--- a/server/tests/ReportSchemaContractsTest.php
+++ b/server/tests/ReportSchemaContractsTest.php
@@ -119,9 +119,10 @@ function fleetOpsReportColumnFlag(Column $column, string $flag): bool
function fleetOpsReportColumnAggregate(Column $column): ?string
{
+ // The stand-in Column stores the aggregate name; the Core API one only flags it (a bool).
$aggregate = fleetOpsReportProperty($column, 'aggregate');
- if ($aggregate) {
+ if (is_string($aggregate) && $aggregate !== '') {
return $aggregate;
}
@@ -242,9 +243,10 @@ function fleetOpsReportRelationship(Table|Relationship $container, string $name)
->and(fleetOpsReportTableName(fleetOpsReportRelationship($orders, 'transaction')))->toBe('transactions');
expect(fleetOpsReportTableMeta($tables['drivers'], 'category'))->toBe('Personnel')
- ->and(fleetOpsReportTableName(fleetOpsReportRelationship($tables['drivers'], 'current_vehicle')))->toBe('vehicles')
+ ->and(fleetOpsReportTableName(fleetOpsReportRelationship($tables['drivers'], 'vehicle')))->toBe('vehicles')
+ ->and(fleetOpsReportTableName(fleetOpsReportRelationship($tables['drivers'], 'user')))->toBe('users')
->and(fleetOpsReportTableMeta($tables['vehicles'], 'category'))->toBe('Fleet')
- ->and(fleetOpsReportTableName(fleetOpsReportRelationship($tables['vehicles'], 'current_driver')))->toBe('drivers')
+ ->and(fleetOpsReportTableName(fleetOpsReportRelationship($tables['vehicles'], 'driver')))->toBe('drivers')
->and(fleetOpsReportTableMeta($tables['assets'], 'label'))->toBe('Trailers and Assets')
->and(fleetOpsReportTableMeta($tables['assets'], 'category'))->toBe('Fleet')
->and(fleetOpsReportColumnFlag(fleetOpsReportColumn($tables['assets'], 'asset_class'), 'filterable'))->toBeTrue()
@@ -265,7 +267,7 @@ function fleetOpsReportRelationship(Table|Relationship $container, string $name)
->and(fleetOpsReportTableName(fleetOpsReportRelationship($tables['inspection_submissions'], 'work_order')))->toBe('work_orders');
});
-test('fleetops report schema transformers normalize labels booleans distances and money', function () {
+test('fleetops report schema transformers normalize labels and booleans', function () {
$registry = new ReportSchemaRegistry();
(new FleetOpsReportSchema())->registerReportSchema($registry);
@@ -278,12 +280,124 @@ function fleetOpsReportRelationship(Table|Relationship $container, string $name)
expect(fleetOpsReportTransform(fleetOpsReportColumn($orders, 'status'), 'driver_assigned'))->toBe('Driver Assigned')
->and(fleetOpsReportTransform(fleetOpsReportColumn($orders, 'status'), 'custom_status'))->toBe('Custom_status')
- ->and(fleetOpsReportTransform(fleetOpsReportColumn($orders, 'distance'), 10))->toBe(6.21)
->and(fleetOpsReportTransform(fleetOpsReportColumn($orders, 'adhoc'), true))->toBe('Yes')
->and(fleetOpsReportTransform(fleetOpsReportColumn($orders, 'pod_required'), false))->toBe('No')
->and(fleetOpsReportTransform(fleetOpsReportColumn($drivers, 'status'), 'suspended'))->toBe('Suspended')
->and(fleetOpsReportTransform(fleetOpsReportColumn($drivers, 'online'), false))->toBe('No')
->and(fleetOpsReportTransform(fleetOpsReportColumn($vehicles, 'status'), 'out_of_service'))->toBe('Out of Service')
- ->and(fleetOpsReportTransform(fleetOpsReportColumn($transaction, 'amount'), 12345))->toBe('123.45')
->and(fleetOpsReportTransform(fleetOpsReportColumn($transaction, 'status'), 'refunded'))->toBe('Refunded');
});
+
+function fleetOpsReportColumnNames(Table|Relationship $container): array
+{
+ return array_map(fn (Column $column) => fleetOpsReportSchemaName($column), fleetOpsReportColumns($container));
+}
+
+function fleetOpsReportComputation(Column $column): ?string
+{
+ return fleetOpsReportCallOrProperty($column, 'getComputation', 'computation');
+}
+
+test('fleetops order report schema exposes identifiers tracking and items without assuming a meta shape', function () {
+ $registry = new ReportSchemaRegistry();
+
+ (new FleetOpsReportSchema())->registerReportSchema($registry);
+
+ $orders = fleetOpsReportTables($registry)['orders'];
+
+ // Order identifiers and the operational columns reports filter and group on.
+ expect(fleetOpsReportColumnNames($orders))->toContain(
+ 'public_id',
+ 'internal_id',
+ 'status',
+ 'type',
+ 'scheduled_at',
+ 'dispatched_at',
+ 'pod_method',
+ 'notes',
+ 'created_at',
+ 'meta',
+ );
+
+ // Columns of the table itself are not prefixed with the table's name ("Order ID" → "ID").
+ $label = fn (Column $column) => fleetOpsReportCallOrProperty($column, 'getLabel', 'label');
+ expect($label(fleetOpsReportColumn($orders, 'public_id')))->toBe('ID')
+ ->and($label(fleetOpsReportColumn($orders, 'type')))->toBe('Type');
+
+ expect(fleetOpsReportComputation(fleetOpsReportColumn($orders, 'total_orders')))->toBe('COUNT(DISTINCT id)')
+ ->and(fleetOpsReportComputation(fleetOpsReportColumn($orders, 'completed_orders')))->toBe("COUNT(DISTINCT CASE WHEN status = 'completed' THEN id END)")
+ ->and(fleetOpsReportComputation(fleetOpsReportColumn($orders, 'total_transaction_amount')))->toBe('SUM(transaction.amount)');
+
+ // meta has no fixed shape, so no declared column may read a key out of it; users read
+ // keys with computed columns instead.
+ $computations = array_map(fn (Column $column) => (string) fleetOpsReportComputation($column), [
+ ...fleetOpsReportColumns($orders),
+ ...fleetOpsReportComputedColumns($orders),
+ ...fleetOpsReportColumnsFromRelationships(fleetOpsReportRelationships($orders)),
+ ]);
+ expect(array_filter($computations, fn (string $computation) => str_contains($computation, 'JSON_') || str_contains($computation, 'meta')))->toBe([]);
+
+ $tracking = fleetOpsReportRelationship($orders, 'tracking_number');
+ expect(fleetOpsReportTableName($tracking))->toBe('tracking_numbers')
+ ->and(fleetOpsReportColumnNames($tracking))->toContain('tracking_number', 'public_id')
+ ->and(fleetOpsReportTableName(fleetOpsReportRelationship($tracking, 'status')))->toBe('tracking_statuses')
+ ->and(fleetOpsReportTableName(fleetOpsReportRelationship($orders, 'order_config')))->toBe('order_configs')
+ ->and(fleetOpsReportTableName(fleetOpsReportRelationship($orders, 'customer_vendor')))->toBe('vendors')
+ ->and(fleetOpsReportTableName(fleetOpsReportRelationship($orders, 'created_by')))->toBe('users')
+ ->and(fleetOpsReportTableName(fleetOpsReportRelationship(fleetOpsReportRelationship($orders, 'purchase_rate'), 'service_quote')))->toBe('service_quotes');
+
+ // Payload items: one row per entity, with the storefront quantity and line total from meta.
+ $payload = fleetOpsReportRelationship($orders, 'payload');
+ expect(fleetOpsReportColumnNames($payload))->toContain('public_id', 'type', 'cod_amount')
+ ->and(fleetOpsReportTableName(fleetOpsReportRelationship($payload, 'return')))->toBe('places');
+
+ $items = fleetOpsReportRelationship($payload, 'entities');
+ expect(fleetOpsReportTableName($items))->toBe('entities')
+ ->and(fleetOpsReportCallOrProperty($items, 'getLocalKey', 'localKey'))->toBe('uuid')
+ ->and(fleetOpsReportCallOrProperty($items, 'getForeignKey', 'foreignKey'))->toBe('payload_uuid')
+ ->and(fleetOpsReportColumnNames($items))->toContain('public_id', 'internal_id', 'name', 'sku', 'price', 'sale_price', 'meta')
+ ->and(fleetOpsReportColumnNames($items))->not->toContain('quantity', 'line_total', 'product_id')
+ ->and(fleetOpsReportTableName(fleetOpsReportRelationship($items, 'destination')))->toBe('places');
+
+ // Relationship identifiers
+ expect(fleetOpsReportColumnNames(fleetOpsReportRelationship($orders, 'driver_assigned')))->toContain('public_id')
+ ->and(fleetOpsReportColumnNames(fleetOpsReportRelationship($orders, 'vehicle_assigned')))->toContain('public_id')
+ ->and(fleetOpsReportColumnNames(fleetOpsReportRelationship($orders, 'customer')))->toContain('public_id')
+ ->and(fleetOpsReportColumnNames(fleetOpsReportRelationship($orders, 'facilitator')))->toContain('public_id');
+});
+
+test('fleetops report schema uses the columns the fuel report table actually has', function () {
+ $registry = new ReportSchemaRegistry();
+
+ (new FleetOpsReportSchema())->registerReportSchema($registry);
+
+ $tables = fleetOpsReportTables($registry);
+ $fuel = $tables['fuel_reports'];
+
+ expect(fleetOpsReportColumnNames($fuel))->toContain('amount', 'odometer', 'metric_unit', 'created_at')
+ ->not->toContain('cost', 'odometer_reading', 'report_date')
+ ->and(fleetOpsReportComputation(fleetOpsReportColumn($fuel, 'total_fuel_cost')))->toBe('SUM(amount)')
+ ->and(fleetOpsReportTableName(fleetOpsReportRelationship(fleetOpsReportRelationship($fuel, 'driver'), 'user')))->toBe('users')
+ ->and(fleetOpsReportColumnNames($tables['drivers']))->not->toContain('name', 'email', 'phone');
+});
+
+test('fleetops report schema leaves soft deleted rows out where the core api supports it', function () {
+ $registry = new ReportSchemaRegistry();
+
+ (new FleetOpsReportSchema())->registerReportSchema($registry);
+
+ $orders = fleetOpsReportTables($registry)['orders'];
+ $items = fleetOpsReportRelationship(fleetOpsReportRelationship($orders, 'payload'), 'entities');
+
+ if (!method_exists($orders, 'usesSoftDeletes')) {
+ // Older Core API: the schema still registers, without soft-delete filtering.
+ expect($orders)->toBeInstanceOf(Table::class);
+
+ return;
+ }
+
+ expect($orders->usesSoftDeletes())->toBeTrue()
+ ->and($items->usesSoftDeletes())->toBeTrue()
+ ->and(fleetOpsReportRelationship($orders, 'customer')->usesSoftDeletes())->toBeFalse()
+ ->and(fleetOpsReportColumn($orders, 'total_orders')->isAggregate())->toBeTrue();
+});
diff --git a/server/tests/SettingControllerContractsTest.php b/server/tests/SettingControllerContractsTest.php
index 1163a5f36..5989948c1 100644
--- a/server/tests/SettingControllerContractsTest.php
+++ b/server/tests/SettingControllerContractsTest.php
@@ -5,17 +5,19 @@
class FleetOpsSettingControllerProbe extends SettingController
{
- public array $configured = [];
- public array $configuredCompany = [];
- public array $settingValues = [];
- public array $settings = [];
- public array $companySettings = [];
- public array $lookupCompanySettings = [];
- public mixed $company = null;
- public array $notifiables = [];
- public array $notifications = [];
- public array $providers = [];
- public string $googleMapsApiKey = '';
+ public array $configured = [];
+ public array $configuredCompany = [];
+ public array $settingValues = [];
+ public array $orderConfigKeys = [];
+ public array $settings = [];
+ public array $companySettings = [];
+ public array $lookupCompanySettings = [];
+ public mixed $company = null;
+ public array $notifiables = [];
+ public array $notifications = [];
+ public array $providers = [];
+ public string $googleMapsApiKey = '';
+ public array $reconciledTelematicsDefaults = [];
protected function configureSetting(string $key, mixed $value): mixed
{
@@ -34,6 +36,11 @@ protected function configureCompanySetting(string $key, mixed $value): mixed
return null;
}
+ protected function companyOrderConfigKeys(): array
+ {
+ return $this->orderConfigKeys;
+ }
+
protected function settingValue(string $key): mixed
{
return $this->settingValues[$key] ?? null;
@@ -59,6 +66,16 @@ protected function currentCompany(): mixed
return $this->company;
}
+ protected function reconcileTelematicsCompanyPreferences(array $defaults): void
+ {
+ $this->reconciledTelematicsDefaults = $defaults;
+ }
+
+ protected function withTelematicsPolicyLock(Closure $callback): mixed
+ {
+ return $callback();
+ }
+
protected function notificationNotifiables(): array
{
return $this->notifiables;
@@ -78,6 +95,45 @@ protected function googleMapsApiKey(): string
{
return $this->googleMapsApiKey;
}
+
+ public array $counts = [];
+ public array $oldest = [];
+ public array $tableStatistics = [];
+ public array $countScopes = [];
+ public array $pruned = [];
+
+ protected function tableUsage(string $table, Closure $scope, string $ageColumn): array
+ {
+ // System storage must not add company or connection filters.
+ $scope(new stdClass());
+
+ return [
+ 'rows' => $this->tableCount($table, $scope),
+ 'oldest' => $this->tableOldest($table, $scope, $ageColumn),
+ ];
+ }
+
+ protected function tableCount(string $table, Closure $scope): int
+ {
+ $this->countScopes[] = $table;
+
+ return $this->counts[$table] ?? 0;
+ }
+
+ protected function tableOldest(string $table, Closure $scope, string $column): ?string
+ {
+ return $this->oldest[$table] ?? null;
+ }
+
+ protected function storageTableStatistics(array $tables): array
+ {
+ return $this->tableStatistics;
+ }
+
+ protected function dispatchTelematicsPrune(): void
+ {
+ $this->pruned[] = 'system';
+ }
}
class FleetOpsTrackingProviderOptionFake
@@ -107,16 +163,22 @@ function fleetopsJsonPayload(mixed $response): array
}
test('setting controller persists and returns basic company settings through configured keys', function () {
- $controller = new FleetOpsSettingControllerProbe();
-
- $entityPayload = fleetopsJsonPayload($controller->saveEntityEditingSettings(new Request([
- 'entityEditingSettings' => ['orders' => ['editable' => true]],
+ session(['company' => 'company-1']);
+ $controller = new FleetOpsSettingControllerProbe();
+ $controller->orderConfigKeys = ['orders'];
+
+ // The setting is one platform-wide map keyed by order config: another company's entry must survive,
+ // and this company cannot write keys for order configs it does not own.
+ $controller->settingValues['fleet-ops.entity-editing-settings'] = ['foreign-config' => ['editable' => true]];
+ $entityPayload = fleetopsJsonPayload($controller->saveEntityEditingSettings(new Request([
+ 'entityEditingSettings' => ['orders' => ['editable' => true], 'foreign-config' => ['editable' => false]],
])));
- $controller->settingValues['fleet-ops.entity-editing-settings'] = ['orders' => ['editable' => false]];
+ $controller->settingValues['fleet-ops.entity-editing-settings'] = ['orders' => ['editable' => false], 'foreign-config' => ['editable' => true]];
+ // A request-supplied company id is ignored in favour of the session company.
$disabledDriverPayload = fleetopsJsonPayload($controller->savedDriverOnboardSettings(new Request([
'driverOnboardSettings' => [
- 'companyId' => 'company-1',
+ 'companyId' => 'company-2',
'enableDriverOnboardFromApp' => false,
'driverMustProvideOnboardDoucments' => true,
'requiredOnboardDocuments' => ['license'],
@@ -137,7 +199,7 @@ function fleetopsJsonPayload(mixed $response): array
$controller->company = (object) ['stripe_connect_id' => 'acct_123'];
expect($entityPayload)->toBe(['entityEditingSettings' => ['orders' => ['editable' => true]]])
- ->and($controller->configured['fleet-ops.entity-editing-settings'])->toBe(['orders' => ['editable' => true]])
+ ->and($controller->configured['fleet-ops.entity-editing-settings'])->toBe(['foreign-config' => ['editable' => true], 'orders' => ['editable' => true]])
->and(fleetopsJsonPayload($controller->getEntityEditingSettings()))->toBe(['entityEditingSettings' => ['orders' => ['editable' => false]]])
->and(fleetopsJsonPayload((new FleetOpsSettingControllerProbe())->getEntityEditingSettings()))->toBe(['entityEditingSettings' => []])
->and($disabledDriverPayload['driverOnboardSettings'])->toMatchArray([
@@ -148,6 +210,7 @@ function fleetopsJsonPayload(mixed $response): array
'driverOnboardAppMethod' => '',
])
->and($controller->configured['fleet-ops.driver-onboard-settings.company-1'])->toBe($disabledDriverPayload['driverOnboardSettings'])
+ ->and($controller->configured)->not->toHaveKey('fleet-ops.driver-onboard-settings.company-2')
->and(fleetopsJsonPayload($controller->getDriverOnboardSettings('company-1')))->toBe(['driverOnboardSettings' => ['enableDriverOnboardFromApp' => true]])
->and(fleetopsJsonPayload((new FleetOpsSettingControllerProbe())->getDriverOnboardSettings('missing')))->toBe(['driverOnboardSettings' => []])
->and($enabledConfigs)->toBe(['order-express', 'order-freight'])
@@ -441,3 +504,351 @@ function fleetopsJsonPayload(mixed $response): array
expect($defaults['leafletTileUrl'])->toBe('')
->and($defaults['leafletDarkTileUrl'])->toBe('');
});
+
+test('telematics settings expose only customer history preferences within administrator limits', function () {
+ config(['telematics.telemetry' => ['event_retention_days' => 45, 'processed_retention_hours' => 48, 'log_telemetry_activity' => false, 'poll_queue' => 'default']]);
+ $controller = new FleetOpsSettingControllerProbe();
+ $controller->company = (object) ['uuid' => 'company-1'];
+ $controller->settings['fleet-ops.telematics-settings'] = ['position_retention_days' => 120, 'event_retention_days' => 99999, 'max_event_retention_days' => 60];
+ $controller->companySettings['fleet-ops.telematics-settings'] = ['event_compact_after_days' => 3, 'log_telemetry_activity' => '1'];
+
+ $settings = fleetopsJsonPayload($controller->getTelematicsSettings());
+ expect($settings['event_retention_days'])->toBe(60)
+ ->and($settings['position_retention_days'])->toBe(120)
+ ->and($settings)->not->toHaveKeys(['event_compact_after_days', 'processed_retention_hours', 'log_telemetry_activity'])
+ ->and($settings['preferences'])->toBe(['event_retention_days' => null, 'position_retention_days' => null])
+ ->and($settings['defaults'])->toBe(['event_retention_days' => 60, 'position_retention_days' => 120])
+ ->and($settings['policy'])->toBe(['max_event_retention_days' => 60, 'max_position_retention_days' => 0])
+ ->and($settings['limits'])->toBe(['event_retention_days' => [1, 3650], 'position_retention_days' => [1, 3650]]);
+
+ $saved = fleetopsJsonPayload($controller->saveTelematicsSettings(new Request([
+ 'event_retention_days' => '0',
+ 'event_compact_after_days' => -3,
+ 'processed_retention_hours' => 5000,
+ 'log_telemetry_activity' => 'false',
+ 'unexpected' => 'ignored',
+ ])));
+ expect($saved['status'])->toBe('ok')
+ ->and($saved['event_retention_days'])->toBe(60)
+ ->and($saved['position_retention_days'])->toBe(120)
+ ->and($saved['preferences'])->toBe(['event_retention_days' => 60, 'position_retention_days' => null])
+ ->and($saved)->not->toHaveKeys(['event_compact_after_days', 'processed_retention_hours', 'log_telemetry_activity'])
+ ->and($controller->configuredCompany['fleet-ops.telematics-settings'])->toBe(['event_retention_days' => 60]);
+
+ $admin = fleetopsJsonPayload($controller->getAdminTelematicsSettings());
+ expect($admin['event_retention_days'])->toBe(3650)
+ ->and($admin['limits'])->toHaveKeys(['sync_run_retention_days', 'max_event_retention_days', 'max_position_retention_days'])
+ ->and($admin['log_telemetry_activity'])->toBeFalse();
+
+ $savedAdmin = fleetopsJsonPayload($controller->saveAdminTelematicsSettings(new Request([
+ 'event_retention_days' => 60,
+ 'sync_run_retention_days' => 0,
+ 'max_event_retention_days' => 90,
+ 'max_position_retention_days' => 45,
+ 'log_telemetry_activity' => true,
+ ])));
+ expect($savedAdmin['event_retention_days'])->toBe(60)
+ ->and($savedAdmin['sync_run_retention_days'])->toBe(0)
+ ->and($savedAdmin['processed_retention_hours'])->toBe(48)
+ ->and($savedAdmin['max_event_retention_days'])->toBe(90)
+ ->and($savedAdmin['max_position_retention_days'])->toBe(45)
+ ->and($savedAdmin['log_telemetry_activity'])->toBeTrue()
+ ->and($controller->reconciledTelematicsDefaults['max_event_retention_days'])->toBe(90)
+ ->and($controller->reconciledTelematicsDefaults['max_position_retention_days'])->toBe(45)
+ ->and($controller->configured['fleet-ops.telematics-settings']['position_retention_days'])->toBe(120);
+
+ $partialAdmin = fleetopsJsonPayload($controller->saveAdminTelematicsSettings(new Request(['log_telemetry_activity' => false])));
+ expect($partialAdmin['max_event_retention_days'])->toBe(90)
+ ->and($partialAdmin['max_position_retention_days'])->toBe(45);
+
+ $inherited = fleetopsJsonPayload($controller->saveTelematicsSettings(new Request(['event_retention_days' => null, 'position_retention_days' => null])));
+ expect($inherited['event_retention_days'])->toBe(60)
+ ->and($inherited['position_retention_days'])->toBe(45)
+ ->and($inherited['preferences'])->toBe(['event_retention_days' => null, 'position_retention_days' => null])
+ ->and($controller->configuredCompany['fleet-ops.telematics-settings'])->toBe([]);
+ config(['telematics.telemetry' => []]);
+});
+
+test('explicit unlimited customer history stays distinct from inheritance and reads back with current maximums', function () {
+ $controller = new FleetOpsSettingControllerProbe();
+ $controller->company = (object) ['uuid' => 'company-1'];
+
+ $saved = fleetopsJsonPayload($controller->saveTelematicsSettings(new Request([
+ 'event_retention_days' => 0,
+ 'position_retention_days' => null,
+ ])));
+ expect($saved['event_retention_days'])->toBe(0)
+ ->and($saved['preferences'])->toBe(['event_retention_days' => 0, 'position_retention_days' => null])
+ ->and($controller->configuredCompany['fleet-ops.telematics-settings'])->toBe(['event_retention_days' => 0])
+ ->and(fleetopsJsonPayload($controller->getTelematicsSettings())['preferences'])->toBe($saved['preferences']);
+
+ $controller->settings['fleet-ops.telematics-settings'] = ['max_event_retention_days' => 90, 'max_position_retention_days' => 360];
+ $loaded = fleetopsJsonPayload($controller->getTelematicsSettings());
+ expect($loaded['event_retention_days'])->toBe(90)
+ ->and($loaded['preferences'])->toBe(['event_retention_days' => 90, 'position_retention_days' => null])
+ ->and($loaded['policy'])->toBe(['max_event_retention_days' => 90, 'max_position_retention_days' => 360]);
+});
+
+test('telematics customer settings require a company session', function () {
+ $controller = new FleetOpsSettingControllerProbe();
+ expect($controller->getTelematicsSettings()->getStatusCode())->toBe(401)
+ ->and($controller->saveTelematicsSettings(new Request())->getStatusCode())->toBe(401)
+ ->and($controller->configuredCompany)->toBe([])
+ ->and($controller->pruned)->toBe([]);
+});
+
+test('admin telematics storage and cleanup cover the system without a company session', function () {
+ $hadRequest = app()->bound('request');
+ $originalRequest = $hadRequest ? app('request') : null;
+ $originalQueueConfig = config('queue', []);
+ config(['queue.default' => 'database', 'queue.connections.database.driver' => 'database']);
+ app()->instance('request', new Request(['include_payload_counts' => true]));
+ Illuminate\Support\Carbon::setTestNow('2026-09-23 12:00:00 UTC');
+ try {
+ $controller = new FleetOpsSettingControllerProbe();
+ $controller->counts = ['device_events' => 500, 'positions' => 40, 'telematic_deliveries' => 6, 'telematic_sync_runs' => 3];
+ $controller->oldest = ['device_events' => '2026-08-01 00:00:00'];
+ $controller->tableStatistics = [
+ 'device_events' => ['rows' => 500, 'rows_estimated' => true, 'estimated_bytes' => 16000000],
+ 'positions' => ['rows' => 40, 'rows_estimated' => true, 'estimated_bytes' => 28000],
+ ];
+
+ $usage = fleetopsJsonPayload($controller->getTelematicsStorageUsage());
+ expect($usage['tables']['device_events'])->toBe([
+ 'rows' => 500,
+ 'rows_estimated' => true,
+ 'estimated_bytes' => 16000000,
+ 'oldest' => '2026-08-01 00:00:00',
+ 'raw_payload_rows' => 500,
+ 'compactable_rows' => 500,
+ ])
+ ->and($usage['tables']['positions'])->toBe(['rows' => 40, 'rows_estimated' => true, 'estimated_bytes' => 28000, 'oldest' => null])
+ ->and($usage['tables']['telematic_deliveries'])->toBe(['rows' => 6, 'oldest' => null])
+ ->and($usage['tables']['telematic_sync_runs']['rows'])->toBe(3)
+ ->and($usage['scope'])->toBe('system')
+ ->and($usage)->not->toHaveKey('company')
+ ->and($usage['generated_at'])->toBe('2026-09-23T12:00:00.000000Z')
+ ->and($controller->countScopes)->toBe(['telematic_deliveries', 'telematic_sync_runs', 'device_events', 'device_events']);
+
+ // Missing metadata uses bounded full-table counts, independent of any organization's connections.
+ $controller->tableStatistics = [];
+ $controller->settings['fleet-ops.telematics-settings'] = ['event_compact_after_days' => 0];
+ $controller->countScopes = [];
+ $usage = fleetopsJsonPayload($controller->getTelematicsStorageUsage());
+ expect($usage['tables']['telematic_deliveries'])->toBe(['rows' => 6, 'oldest' => null])
+ ->and($usage['tables']['telematic_sync_runs'])->toBe(['rows' => 3, 'oldest' => null])
+ ->and($usage['tables']['device_events']['compactable_rows'])->toBe(0)
+ ->and($controller->countScopes)->toBe(['device_events', 'positions', 'telematic_deliveries', 'telematic_sync_runs', 'device_events']);
+
+ $queued = $controller->runTelematicsRetention();
+ expect($queued->getStatusCode())->toBe(202)
+ ->and(fleetopsJsonPayload($queued)['status'])->toBe('queued')
+ ->and(fleetopsJsonPayload($queued)['scope'])->toBe('system')
+ ->and($controller->pruned)->toBe(['system']);
+ } finally {
+ if ($hadRequest) {
+ app()->instance('request', $originalRequest);
+ } else {
+ app()->forgetInstance('request');
+ }
+ config(['queue' => $originalQueueConfig]);
+ Illuminate\Support\Carbon::setTestNow();
+ }
+});
+
+test('system cleanup does not run an entire platform inline on a synchronous queue', function () {
+ $originalQueueConfig = config('queue', []);
+ try {
+ config(['queue.default' => 'sync', 'queue.connections.sync.driver' => 'sync']);
+ $controller = new FleetOpsSettingControllerProbe();
+ expect($controller->runTelematicsRetention()->getStatusCode())->toBe(503)
+ ->and($controller->pruned)->toBe([]);
+ } finally {
+ config(['queue' => $originalQueueConfig]);
+ }
+});
+
+test('telematics storage helpers query all rows and allocated table bytes from MySQL statistics', function () {
+ $connection = new class(new PDO('sqlite::memory:')) extends Illuminate\Database\SQLiteConnection {
+ public string $driver = 'sqlite';
+ public array $selects = [];
+
+ public function getDriverName()
+ {
+ return $this->driver;
+ }
+
+ public function select($query, $bindings = [], $useReadPdo = true)
+ {
+ if (str_contains($query, 'information_schema')) {
+ $this->selects[] = [$query, $bindings];
+
+ // information_schema reports prefixed table names.
+ return [(object) ['name' => $bindings[0], 'row_count' => '500', 'data_bytes' => '16000000', 'index_bytes' => '5000'], (object) ['name' => $bindings[1], 'row_count' => 40, 'data_bytes' => 28000, 'index_bytes' => 2000]];
+ }
+
+ return parent::select($query, $bindings, $useReadPdo);
+ }
+ };
+ $connection->setTablePrefix('');
+ $originalDb = app()->bound('db') ? app('db') : null;
+ app()->instance('db', new class($connection) {
+ public function __construct(public $connection)
+ {
+ }
+
+ public function connection($name = null)
+ {
+ return $this->connection;
+ }
+
+ public function __call($method, $arguments)
+ {
+ return $this->connection->{$method}(...$arguments);
+ }
+ });
+ Illuminate\Support\Facades\DB::clearResolvedInstance('db');
+ try {
+ $schema = $connection->getSchemaBuilder();
+ $schema->create('telematics', function ($table) {
+ $table->increments('id');
+ $table->string('uuid');
+ $table->string('company_uuid');
+ });
+ $schema->create('device_events', function ($table) {
+ $table->increments('id');
+ $table->string('company_uuid')->nullable();
+ $table->timestamp('created_at')->nullable();
+ });
+ $connection->table('telematics')->insert([['uuid' => 'tm-1', 'company_uuid' => 'company-1'], ['uuid' => 'tm-2', 'company_uuid' => 'company-2']]);
+ $connection->table('device_events')->insert([
+ ['company_uuid' => 'company-1', 'created_at' => '2026-08-01 00:00:00'],
+ ['company_uuid' => 'company-1', 'created_at' => '2026-09-01 00:00:00'],
+ ['company_uuid' => 'company-2', 'created_at' => '2026-07-01 00:00:00'],
+ ]);
+
+ $controller = new class extends SettingController {
+ public function usage(string $table, Closure $scope, string $column): array
+ {
+ return $this->tableUsage($table, $scope, $column);
+ }
+
+ public function statistics(array $tables): array
+ {
+ return $this->storageTableStatistics($tables);
+ }
+ };
+ expect($controller->usage('device_events', fn ($query) => $query, 'created_at'))->toBe(['rows' => 3, 'oldest' => '2026-07-01 00:00:00'])
+ ->and($controller->usage('device_events', fn ($query) => $query->where('company_uuid', 'none'), 'created_at'))->toBe(['rows' => 0, 'oldest' => null])
+ ->and($controller->statistics(['device_events', 'positions']))->toBe([]);
+
+ $connection->driver = 'mysql';
+ $connection->setTablePrefix('fb_');
+ expect($controller->statistics(['device_events', 'positions']))->toBe([
+ 'device_events' => ['rows' => 500, 'rows_estimated' => true, 'estimated_bytes' => 16005000],
+ 'positions' => ['rows' => 40, 'rows_estimated' => true, 'estimated_bytes' => 30000],
+ ])
+ ->and($connection->selects[0][1])->toBe(['fb_device_events', 'fb_positions'])
+ ->and($connection->selects[0][0])->toContain('TABLE_NAME IN (?, ?)');
+ } finally {
+ if ($originalDb) {
+ app()->instance('db', $originalDb);
+ } else {
+ app()->forgetInstance('db');
+ }
+ Illuminate\Support\Facades\DB::clearResolvedInstance('db');
+ }
+});
+
+test('admin telematics cleanup dispatches the system-wide fanout job', function () {
+ $controller = new class extends SettingController {
+ public function prune(): void
+ {
+ $this->dispatchTelematicsPrune();
+ }
+ };
+ $originalCache = Illuminate\Support\Facades\Cache::getFacadeRoot();
+ $originalCacheConfig = config('cache', []);
+ $dispatcherContract = Illuminate\Contracts\Bus\Dispatcher::class;
+ $originalDispatcher = app()->bound($dispatcherContract) ? app($dispatcherContract) : null;
+ try {
+ config(['cache.default' => 'array', 'cache.stores.array' => ['driver' => 'array']]);
+ Illuminate\Support\Facades\Cache::swap(new Illuminate\Cache\CacheManager(app()));
+ $dispatcher = new class(app()) extends Illuminate\Bus\Dispatcher {
+ public array $jobs = [];
+ public bool $fail = true;
+
+ public function dispatch($command)
+ {
+ if ($this->fail) {
+ throw new RuntimeException('Broker unavailable');
+ }
+ $this->jobs[] = $command;
+
+ return $command;
+ }
+ };
+ app()->instance($dispatcherContract, $dispatcher);
+
+ expect(fn () => $controller->prune())->toThrow(RuntimeException::class, 'Broker unavailable');
+ $dispatcher->fail = false;
+ $controller->prune();
+ $controller->prune();
+ expect($dispatcher->jobs)->toHaveCount(1)
+ ->and($dispatcher->jobs[0])->toBeInstanceOf(Fleetbase\FleetOps\Jobs\DispatchTelematicsRetentionJobs::class)
+ ->and($dispatcher->jobs[0]->afterCompanyId)->toBe(0);
+ } finally {
+ Illuminate\Support\Facades\Cache::swap($originalCache);
+ config(['cache' => $originalCacheConfig]);
+ if ($originalDispatcher) {
+ app()->instance($dispatcherContract, $originalDispatcher);
+ } else {
+ app()->forgetInstance($dispatcherContract);
+ }
+ }
+});
+
+test('customer history save reports a failed persistence operation', function () {
+ $controller = new class extends FleetOpsSettingControllerProbe {
+ protected function configureCompanySetting(string $key, mixed $value): mixed
+ {
+ return false;
+ }
+ };
+ $controller->company = (object) ['uuid' => 'company-a'];
+ $response = $controller->saveTelematicsSettings(Request::create('/', 'POST', ['event_retention_days' => 0]));
+ expect($response->getStatusCode())->toBe(500);
+});
+
+test('optional payload counts omit timeouts but surface other database errors', function () {
+ $hadRequest = app()->bound('request');
+ $original = $hadRequest ? app('request') : null;
+ app()->instance('request', Request::create('/', 'GET', ['include_payload_counts' => true]));
+ try {
+ $controller = new class extends FleetOpsSettingControllerProbe {
+ public int $errorCode = 3024;
+
+ protected function tableUsage(string $table, Closure $scope, string $ageColumn): array
+ {
+ return ['rows' => 5, 'oldest' => null];
+ }
+
+ protected function tableCount(string $table, Closure $scope): int
+ {
+ $error = new PDOException('Query failed');
+ $error->errorInfo = ['HY000', $this->errorCode, 'Query failed'];
+ throw new Illuminate\Database\QueryException('mysql', 'select payload', [], $error);
+ }
+ };
+ $data = $controller->getTelematicsStorageUsage()->getData(true);
+ expect($data['scope'])->toBe('system')->and($data['tables']['device_events'])->toBe(['rows' => 5, 'oldest' => null]);
+ $controller->errorCode = 1146;
+ expect(fn () => $controller->getTelematicsStorageUsage())->toThrow(Illuminate\Database\QueryException::class);
+ } finally {
+ if ($hadRequest) {
+ app()->instance('request', $original);
+ } else {
+ app()->forgetInstance('request');
+ }
+ }
+});
diff --git a/server/tests/SmallControllerContractsTest.php b/server/tests/SmallControllerContractsTest.php
index 682ba2c18..a55c96d93 100644
--- a/server/tests/SmallControllerContractsTest.php
+++ b/server/tests/SmallControllerContractsTest.php
@@ -383,11 +383,14 @@ function fleetopsSmallExportSelections(object $export): array
});
test('service area zone and getting started controllers expose small lifecycle contracts', function () {
- $serviceArea = new FleetOpsControllerCustomFieldModelFake();
- (new ServiceAreaController())->afterSave(new Request([
- 'service_area' => ['custom_field_values' => [['key' => 'area_code', 'value' => 'A1']]],
- ]), $serviceArea);
- (new ServiceAreaController())->afterSave(new Request(['service_area' => ['custom_field_values' => []]]), $serviceArea);
+ $serviceArea = new FleetOpsControllerCustomFieldModelFake();
+ $serviceAreaRequest = new Request([
+ 'serviceArea' => ['custom_field_values' => [['key' => 'area_code', 'value' => 'raw-request-value']]],
+ ]);
+ (new ServiceAreaController())->afterSave($serviceAreaRequest, $serviceArea, [
+ 'custom_field_values' => [['key' => 'area_code', 'value' => 'A1']],
+ ]);
+ (new ServiceAreaController())->afterSave($serviceAreaRequest, $serviceArea, ['custom_field_values' => []]);
$zone = new FleetOpsControllerZoneFake();
(new ZoneController())->afterSave(new Request([
diff --git a/server/tests/Support/PruneTelematicsDataProbe.php b/server/tests/Support/PruneTelematicsDataProbe.php
new file mode 100644
index 000000000..0e1b05d4d
--- /dev/null
+++ b/server/tests/Support/PruneTelematicsDataProbe.php
@@ -0,0 +1,55 @@
+ null, 'table' => [], 'batch-size' => 1000, 'max-batches' => 50, 'dry-run' => false, 'no-lock' => true];
+
+ /** company uuid ('' for the system defaults) => policy values passed to RetentionPolicy::fromArray */
+ public array $policies = [];
+
+ /** When set, replaces the companies table lookup. */
+ public ?Collection $companyRows = null;
+
+ public function option($key = null, $default = null)
+ {
+ return $this->options[$key] ?? $default;
+ }
+
+ public function info($string, $verbosity = null)
+ {
+ $this->messages[] = ['info', $string];
+ }
+
+ public function warn($string, $verbosity = null)
+ {
+ $this->messages[] = ['warn', $string];
+ }
+
+ public function error($string, $verbosity = null)
+ {
+ $this->messages[] = ['error', $string];
+ }
+
+ protected function policyFor(?string $companyUuid): RetentionPolicy
+ {
+ return RetentionPolicy::fromArray($this->policies[$companyUuid ?? ''] ?? $this->policies[''] ?? []);
+ }
+
+ protected function companies(?string $only): Collection
+ {
+ if ($this->companyRows === null) {
+ return parent::companies($only);
+ }
+
+ return $this->companyRows->filter(fn ($company) => !$only || $company->uuid === $only || $company->public_id === $only)->values();
+ }
+}
diff --git a/server/tests/Unit/Console/Commands/ProcessOperationalAlertsCommandTest.php b/server/tests/Unit/Console/Commands/ProcessOperationalAlertsCommandTest.php
index 5da25c5c0..b5a3a20e7 100644
--- a/server/tests/Unit/Console/Commands/ProcessOperationalAlertsCommandTest.php
+++ b/server/tests/Unit/Console/Commands/ProcessOperationalAlertsCommandTest.php
@@ -125,9 +125,9 @@ protected function ordersQuery(Carbon $cutoff)
return $this->query;
}
- protected function alertSettings(): array
+ protected function alertSettings(Order $order): array
{
- $this->settingsCalls[] = session('company');
+ $this->settingsCalls[] = $order->company_uuid;
return [
'late_departures' => ['enabled' => true],
diff --git a/server/tests/Unit/Console/PruneTelematicsDataCommandTest.php b/server/tests/Unit/Console/PruneTelematicsDataCommandTest.php
new file mode 100644
index 000000000..9a06a0c4a
--- /dev/null
+++ b/server/tests/Unit/Console/PruneTelematicsDataCommandTest.php
@@ -0,0 +1,353 @@
+ 'mysql', 'driver' => 'sqlite']);
+ $resolver = new ConnectionResolver(['default' => $connection, 'mysql' => $connection]);
+ $resolver->setDefaultConnection('mysql');
+ EloquentModel::setConnectionResolver($resolver);
+ app()->instance('db', new class($connection) {
+ public function __construct(public SQLiteConnection $c)
+ {
+ }
+
+ public function connection($name = null): SQLiteConnection
+ {
+ return $this->c;
+ }
+
+ public function __call($method, $arguments)
+ {
+ return $this->c->{$method}(...$arguments);
+ }
+ });
+ app()->instance('db.schema', $connection->getSchemaBuilder());
+ DB::clearResolvedInstance('db');
+ Schema::clearResolvedInstance('db.schema');
+
+ foreach ([
+ 'companies' => ['uuid', 'public_id', 'name'],
+ 'telematics' => ['uuid', 'public_id', 'company_uuid', 'provider', 'status'],
+ 'devices' => ['uuid', 'company_uuid', 'telematic_uuid', 'device_id'],
+ 'device_events' => ['uuid', 'company_uuid', 'device_uuid', 'event_type', 'payload', 'meta', 'data'],
+ 'positions' => ['uuid', 'company_uuid', 'subject_uuid', 'subject_type'],
+ ] as $table => $columns) {
+ Schema::create($table, function ($blueprint) use ($columns) {
+ $blueprint->increments('id');
+ foreach ($columns as $column) {
+ $blueprint->text($column)->nullable();
+ }
+ $blueprint->timestamps();
+ $blueprint->timestamp('deleted_at')->nullable();
+ });
+ }
+ (require __DIR__ . '/../../../migrations/2026_09_15_000001_create_telematic_telemetry_tables.php')->up();
+ foreach (glob(__DIR__ . '/../../../migrations/2026_09_23_00000*_add_retention_index_to_*.php') as $migration) {
+ (require $migration)->up();
+ }
+
+ Carbon::setTestNow('2026-09-23 12:00:00 UTC');
+ DB::table('companies')->insert([
+ ['id' => 1, 'uuid' => 'company-1', 'public_id' => 'company_one'],
+ ['id' => 2, 'uuid' => 'company-2', 'public_id' => 'company_two'],
+ ]);
+ DB::table('telematics')->insert([
+ ['uuid' => 'tm-1', 'company_uuid' => 'company-1', 'provider' => 'afaqy', 'status' => 'active', 'deleted_at' => null],
+ ['uuid' => 'tm-1-trashed', 'company_uuid' => 'company-1', 'provider' => 'afaqy', 'status' => 'disabled', 'deleted_at' => '2026-09-01 00:00:00'],
+ ['uuid' => 'tm-2', 'company_uuid' => 'company-2', 'provider' => 'safee', 'status' => 'active', 'deleted_at' => null],
+ ]);
+
+ return $connection;
+}
+
+function fleetopsPruneEvent(string $uuid, ?string $company, int $daysOld, array $extra = []): array
+{
+ $at = Carbon::now()->subDays($daysOld)->toDateTimeString();
+
+ return array_merge([
+ 'uuid' => $uuid, 'company_uuid' => $company, 'event_type' => 'telemetry_update',
+ 'payload' => '{"raw":true}', 'meta' => '{"speed":1}', 'data' => '{"speed":1}',
+ 'created_at' => $at, 'updated_at' => $at, 'deleted_at' => null,
+ ], $extra);
+}
+
+function fleetopsPruneDelivery(string $uuid, string $telematic, string $status, int $hoursOld): array
+{
+ $at = Carbon::now()->subHours($hoursOld)->toDateTimeString();
+
+ return [
+ 'uuid' => $uuid, 'telematic_uuid' => $telematic, 'source' => 'poll', 'status' => $status,
+ 'payload_hash' => str_repeat('a', 64), 'payload' => 'x', 'received_at' => $at, 'available_at' => $at,
+ 'created_at' => $at, 'updated_at' => $at,
+ ];
+}
+
+function fleetopsPruneRun(string $uuid, string $telematic, string $status, int $daysOld): array
+{
+ $at = Carbon::now()->subDays($daysOld)->toDateTimeString();
+
+ return ['uuid' => $uuid, 'telematic_uuid' => $telematic, 'status' => $status, 'created_at' => $at, 'updated_at' => $at];
+}
+
+afterEach(fn () => Carbon::setTestNow());
+
+test('orphan cleanup reaches missing connections and connections whose organization no longer exists', function () {
+ fleetopsPruneBoot();
+ DB::table('telematics')->insert(['uuid' => 'tm-orphan-company', 'company_uuid' => 'company-gone', 'provider' => 'afaqy', 'status' => 'disabled']);
+ DB::table('device_events')->insert([
+ fleetopsPruneEvent('e-unowned', null, 40),
+ fleetopsPruneEvent('e-owned', 'company-1', 40),
+ ]);
+ DB::table('telematic_deliveries')->insert([
+ fleetopsPruneDelivery('d-missing-connection', 'tm-gone', 'processed', 48),
+ fleetopsPruneDelivery('d-missing-company', 'tm-orphan-company', 'processed', 48),
+ fleetopsPruneDelivery('d-active-company', 'tm-1', 'processed', 48),
+ fleetopsPruneDelivery('d-orphan-pending', 'tm-gone', 'pending', 48),
+ ]);
+ DB::table('telematic_sync_runs')->insert([
+ fleetopsPruneRun('r-missing-connection', 'tm-gone', 'completed', 10),
+ fleetopsPruneRun('r-missing-company', 'tm-orphan-company', 'completed', 10),
+ fleetopsPruneRun('r-active-company', 'tm-1', 'completed', 10),
+ fleetopsPruneRun('r-orphan-in-flight', 'tm-gone', 'ingesting', 10),
+ ]);
+
+ $command = new PruneTelematicsDataProbe();
+ $command->options['orphans-only'] = true;
+ expect($command->handle())->toBe(PruneTelematicsData::SUCCESS)
+ ->and(DB::table('device_events')->pluck('uuid')->all())->toBe(['e-owned'])
+ ->and(DB::table('telematic_deliveries')->orderBy('uuid')->pluck('uuid')->all())->toBe(['d-active-company', 'd-orphan-pending'])
+ ->and(DB::table('telematic_sync_runs')->orderBy('uuid')->pluck('uuid')->all())->toBe(['r-active-company', 'r-orphan-in-flight']);
+});
+
+test('prune applies each company policy to events, positions and the inbox, and system defaults to orphans', function () {
+ fleetopsPruneBoot();
+ DB::table('device_events')->insert([
+ fleetopsPruneEvent('e-old', 'company-1', 40),
+ fleetopsPruneEvent('e-compact', 'company-1', 10),
+ fleetopsPruneEvent('e-compacted-already', 'company-1', 10, ['payload' => null, 'meta' => null]),
+ fleetopsPruneEvent('e-fresh', 'company-1', 1),
+ fleetopsPruneEvent('e-trashed', 'company-1', 1, ['deleted_at' => Carbon::now()->toDateTimeString()]),
+ fleetopsPruneEvent('e-forever', 'company-2', 400),
+ fleetopsPruneEvent('e-orphan-null', null, 40),
+ fleetopsPruneEvent('e-orphan-gone', 'company-gone', 40),
+ fleetopsPruneEvent('e-orphan-fresh', 'company-gone', 1),
+ ]);
+ DB::table('positions')->insert([
+ ['uuid' => 'p-old', 'company_uuid' => 'company-1', 'created_at' => Carbon::now()->subDays(100)->toDateTimeString(), 'deleted_at' => null],
+ ['uuid' => 'p-fresh', 'company_uuid' => 'company-1', 'created_at' => Carbon::now()->subDays(5)->toDateTimeString(), 'deleted_at' => null],
+ ['uuid' => 'p-trashed', 'company_uuid' => 'company-1', 'created_at' => Carbon::now()->toDateTimeString(), 'deleted_at' => Carbon::now()->toDateTimeString()],
+ ['uuid' => 'p-forever', 'company_uuid' => 'company-2', 'created_at' => Carbon::now()->subDays(1000)->toDateTimeString(), 'deleted_at' => null],
+ ]);
+ DB::table('telematic_deliveries')->insert([
+ fleetopsPruneDelivery('d-processed-old', 'tm-1', 'processed', 30),
+ fleetopsPruneDelivery('d-processed-trashed-connection', 'tm-1-trashed', 'processed', 30),
+ fleetopsPruneDelivery('d-processed-fresh', 'tm-1', 'processed', 2),
+ fleetopsPruneDelivery('d-pending-old', 'tm-1', 'pending', 200),
+ fleetopsPruneDelivery('d-quarantined-old', 'tm-1', 'quarantined', 24 * 8),
+ fleetopsPruneDelivery('d-quarantined-fresh', 'tm-1', 'quarantined', 24 * 2),
+ fleetopsPruneDelivery('d-company-2-long-hours', 'tm-2', 'processed', 30),
+ fleetopsPruneDelivery('d-orphan', 'tm-gone', 'processed', 30),
+ ]);
+ DB::table('telematic_sync_runs')->insert([
+ fleetopsPruneRun('r-old', 'tm-1', 'completed', 10),
+ fleetopsPruneRun('r-fresh', 'tm-1', 'completed', 2),
+ fleetopsPruneRun('r-fetching-old', 'tm-1', 'fetching', 10),
+ fleetopsPruneRun('r-ingesting-old', 'tm-1', 'ingesting', 10),
+ fleetopsPruneRun('r-orphan', 'tm-gone', 'partial', 10),
+ ]);
+
+ $command = new PruneTelematicsDataProbe();
+ $command->policies = [
+ 'company-1' => ['event_retention_days' => 30, 'event_compact_after_days' => 7, 'position_retention_days' => 90, 'processed_retention_hours' => 24, 'quarantine_retention_days' => 7, 'sync_run_retention_days' => 7],
+ 'company-2' => ['event_retention_days' => 0, 'event_compact_after_days' => 0, 'position_retention_days' => 0, 'processed_retention_hours' => 48],
+ '' => ['event_retention_days' => 30, 'processed_retention_hours' => 24, 'sync_run_retention_days' => 7],
+ ];
+
+ expect($command->handle())->toBe(0);
+
+ expect(DB::table('device_events')->orderBy('id')->pluck('uuid')->all())->toBe(['e-compact', 'e-compacted-already', 'e-fresh', 'e-forever', 'e-orphan-fresh']);
+ $compacted = DB::table('device_events')->where('uuid', 'e-compact')->first();
+ expect($compacted->payload)->toBeNull()->and($compacted->meta)->toBeNull()->and($compacted->data)->toBe('{"speed":1}');
+ expect(DB::table('device_events')->where('uuid', 'e-fresh')->value('payload'))->toBe('{"raw":true}');
+ expect(DB::table('positions')->orderBy('id')->pluck('uuid')->all())->toBe(['p-fresh', 'p-forever']);
+ expect(DB::table('telematic_deliveries')->orderBy('uuid')->pluck('uuid')->all())->toBe(['d-company-2-long-hours', 'd-pending-old', 'd-processed-fresh', 'd-quarantined-fresh']);
+ expect(DB::table('telematic_sync_runs')->orderBy('uuid')->pluck('uuid')->all())->toBe(['r-fetching-old', 'r-fresh', 'r-ingesting-old']);
+
+ expect($command->messages)->toContain(
+ ['info', 'company_one device_events: deleted=2 compacted=1 batches=3'],
+ ['info', 'company_one positions: deleted=2 compacted=0 batches=2'],
+ ['info', 'company_one telematic_deliveries: deleted=3 compacted=0 batches=2'],
+ ['info', 'company_one telematic_sync_runs: deleted=1 compacted=0 batches=1'],
+ ['info', 'orphaned device_events: deleted=2 compacted=0 batches=1'],
+ ['info', 'orphaned telematic_deliveries: deleted=1 compacted=0 batches=1'],
+ ['info', 'orphaned telematic_sync_runs: deleted=1 compacted=0 batches=1'],
+ ['info', 'Telematics prune complete: deleted=12 compacted=1 batches=11'],
+ );
+ // Company two kept everything, so it reports nothing.
+ expect(array_filter($command->messages, fn ($message) => str_starts_with($message[1], 'company_two')))->toBe([]);
+});
+
+test('prune bounds each sweep by batch count and reports a capped run', function () {
+ fleetopsPruneBoot();
+ $rows = [];
+ for ($i = 0; $i < 120; $i++) {
+ $rows[] = fleetopsPruneEvent('e-' . $i, 'company-1', 60);
+ }
+ DB::table('device_events')->insert($rows);
+
+ $command = new PruneTelematicsDataProbe();
+ $command->options['company'] = 'company_one';
+ $command->options['table'] = ['device_events'];
+ $command->options['batch-size'] = 10; // clamped up to the 100 row floor
+ $command->options['max-batches'] = 1;
+ $command->policies = ['company-1' => ['event_retention_days' => 30, 'event_compact_after_days' => 0]];
+
+ expect($command->handle())->toBe(0)
+ ->and(DB::table('device_events')->count())->toBe(20)
+ ->and($command->messages)->toContain(
+ ['info', 'company_one device_events: deleted=100 compacted=0 batches=1 capped'],
+ ['info', 'Telematics prune complete: deleted=100 compacted=0 batches=1 (capped; more rows remain for the next run)'],
+ );
+
+ // The next run finishes the backlog; an exact multiple of the batch size is still reported as capped.
+ $command->messages = [];
+ expect($command->handle())->toBe(0)->and(DB::table('device_events')->count())->toBe(0);
+ $command->options['max-batches'] = 50;
+ $command->messages = [];
+ expect($command->handle())->toBe(0)->and($command->messages)->toContain(['info', 'Telematics prune complete: deleted=0 compacted=0 batches=0']);
+
+ // Compaction is bounded the same way.
+ $rows = [];
+ for ($i = 0; $i < 120; $i++) {
+ $rows[] = fleetopsPruneEvent('c-' . $i, 'company-1', 10);
+ }
+ DB::table('device_events')->insert($rows);
+ $command->options['max-batches'] = 1;
+ $command->policies = ['company-1' => ['event_retention_days' => 30, 'event_compact_after_days' => 7]];
+ $command->messages = [];
+ expect($command->handle())->toBe(0)
+ ->and(DB::table('device_events')->whereNull('payload')->count())->toBe(100)
+ ->and($command->messages)->toContain(['info', 'company_one device_events: deleted=0 compacted=100 batches=1 capped']);
+});
+
+test('prune dry runs count without touching rows and honour table and company filters', function () {
+ fleetopsPruneBoot();
+ DB::table('device_events')->insert([
+ fleetopsPruneEvent('e-old-1', 'company-1', 40),
+ fleetopsPruneEvent('e-compact-1', 'company-1', 10),
+ fleetopsPruneEvent('e-old-2', 'company-2', 40),
+ ]);
+ DB::table('positions')->insert([['uuid' => 'p-old', 'company_uuid' => 'company-1', 'created_at' => Carbon::now()->subDays(100)->toDateTimeString()]]);
+
+ $command = new PruneTelematicsDataProbe();
+ $command->options['dry-run'] = true;
+ $command->options['company'] = 'company-1';
+ $command->options['table'] = ['device_events', 'positions', 'device_events'];
+ $command->policies = ['company-1' => ['event_retention_days' => 30, 'event_compact_after_days' => 7, 'position_retention_days' => 90]];
+
+ expect($command->handle())->toBe(0)
+ ->and(DB::table('device_events')->count())->toBe(3)
+ ->and(DB::table('positions')->count())->toBe(1)
+ ->and($command->messages)->toContain(
+ ['info', 'company_one device_events: deleted=1 compacted=1 batches=0'],
+ ['info', 'company_one positions: deleted=1 compacted=0 batches=0'],
+ ['info', 'Telematics prune dry run: deleted=2 compacted=1 batches=0'],
+ );
+
+ // Only the requested table is swept when not a dry run.
+ $command->options['dry-run'] = false;
+ $command->options['table'] = ['positions'];
+ $command->messages = [];
+ expect($command->handle())->toBe(0)
+ ->and(DB::table('device_events')->count())->toBe(3)
+ ->and(DB::table('positions')->count())->toBe(0);
+});
+
+test('prune rejects unknown tables and companies', function () {
+ fleetopsPruneBoot();
+ $command = new PruneTelematicsDataProbe();
+ $command->options['table'] = ['device_events', 'orders'];
+ expect($command->handle())->toBe(PruneTelematicsData::FAILURE)
+ ->and($command->messages)->toContain(['error', 'Unknown table. Valid tables: device_events, positions, telematic_deliveries, telematic_sync_runs.']);
+
+ $command = new PruneTelematicsDataProbe();
+ $command->options['company'] = 'company-missing';
+ expect($command->handle())->toBe(PruneTelematicsData::FAILURE)
+ ->and($command->messages)->toContain(['error', 'Company [company-missing] was not found.']);
+});
+
+test('prune takes a bounded lock and yields to a run already in progress', function () {
+ fleetopsPruneBoot();
+ $originalCache = Illuminate\Support\Facades\Cache::getFacadeRoot();
+ try {
+ $store = new class extends Illuminate\Cache\ArrayStore {
+ public array $requestedLocks = [];
+
+ public function lock($name, $seconds = 0, $owner = null)
+ {
+ $this->requestedLocks[] = [$name, $seconds];
+
+ return parent::lock($name, $seconds, $owner);
+ }
+ };
+ Illuminate\Support\Facades\Cache::swap(new Illuminate\Cache\Repository($store));
+
+ $command = new PruneTelematicsDataProbe();
+ $command->options['no-lock'] = false;
+ expect($command->handle())->toBe(0)
+ ->and($store->requestedLocks)->toBe([[PruneTelematicsData::LOCK, 840]])
+ ->and($store->locks)->toBe([]);
+
+ expect($store->lock(PruneTelematicsData::LOCK, 840)->get())->toBeTrue();
+ $command->messages = [];
+ expect($command->handle())->toBe(0)
+ ->and($command->messages)->toBe([['warn', 'Another telematics prune run appears to be in progress.']]);
+ } finally {
+ Illuminate\Support\Facades\Cache::swap($originalCache);
+ }
+});
+
+test('prune resolves the live company policy when none is injected', function () {
+ fleetopsPruneBoot();
+ Fleetbase\FleetOps\Support\Telematics\Retention\RetentionPolicy::flush();
+ Fleetbase\FleetOps\Support\Telematics\Retention\RetentionPolicy::$settingsResolver = fn () => ['event_retention_days' => 5];
+ try {
+ DB::table('device_events')->insert([fleetopsPruneEvent('e-old', 'company-1', 6)]);
+ $command = new class extends PruneTelematicsDataProbe {
+ protected function policyFor(?string $companyUuid): Fleetbase\FleetOps\Support\Telematics\Retention\RetentionPolicy
+ {
+ return PruneTelematicsData::policyFor($companyUuid);
+ }
+ };
+ expect($command->handle())->toBe(0)->and(DB::table('device_events')->count())->toBe(0);
+ } finally {
+ Fleetbase\FleetOps\Support\Telematics\Retention\RetentionPolicy::flush();
+ Fleetbase\FleetOps\Support\Telematics\Retention\RetentionPolicy::$settingsResolver = null;
+ }
+});
+
+test('prune rejects conflicting company and orphan scopes without deleting history', function () {
+ fleetopsPruneBoot();
+ DB::table('device_events')->insert([fleetopsPruneEvent('keep', 'company-1', 400)]);
+ $command = new PruneTelematicsDataProbe();
+ $command->options['company'] = 'company-1';
+ $command->options['orphans-only'] = true;
+ expect($command->handle())->toBe(PruneTelematicsData::FAILURE)
+ ->and($command->messages)->toContain(['error', 'Choose either --company or --orphans-only.'])
+ ->and(DB::table('device_events')->count())->toBe(1);
+});
diff --git a/server/tests/Unit/Jobs/DispatchTelematicsRetentionJobsTest.php b/server/tests/Unit/Jobs/DispatchTelematicsRetentionJobsTest.php
new file mode 100644
index 000000000..6dd2350d7
--- /dev/null
+++ b/server/tests/Unit/Jobs/DispatchTelematicsRetentionJobsTest.php
@@ -0,0 +1,114 @@
+companyRows);
+ }
+
+ protected function queueJob(ShouldBeUnique $job): void
+ {
+ $this->dispatched[] = $job;
+ }
+}
+
+test('system cleanup fans out a bounded page and continues from its last company', function () {
+ $job = new FleetOpsTelematicsRetentionDispatchProbe(200, 25);
+ $job->companyRows = array_map(fn ($id) => (object) ['id' => $id, 'uuid' => 'company-' . $id], range(201, 300));
+ $job->handle();
+
+ $prunes = array_slice($job->dispatched, 0, 100);
+ expect($prunes)->toHaveCount(100)
+ ->and(array_map(fn ($prune) => $prune->companyUuid, $prunes))->toBe(array_map(fn ($id) => 'company-' . $id, range(201, 300)));
+ foreach ($prunes as $prune) {
+ expect($prune)->toBeInstanceOf(PruneTelematicsDataJob::class)
+ ->and($prune->maxBatches)->toBe(25)
+ ->and($prune->orphansOnly)->toBeFalse();
+ }
+ $nextPage = $job->dispatched[100];
+ expect($nextPage)->toBeInstanceOf(DispatchTelematicsRetentionJobs::class)
+ ->and($nextPage->afterCompanyId)->toBe(300)
+ ->and($nextPage->maxBatches)->toBe(25)
+ ->and($nextPage->uniqueId())->not->toBe($job->uniqueId())
+ ->and($nextPage->queue)->toBe(config('telematics.telemetry.ingestion_queue', 'default'));
+});
+
+test('the final system cleanup page queues an orphan pass even when there are no companies', function () {
+ foreach ([[], [(object) ['id' => 1001, 'uuid' => 'company-1001']]] as $companies) {
+ $job = new FleetOpsTelematicsRetentionDispatchProbe(1000);
+ $job->companyRows = $companies;
+ $job->handle();
+
+ expect($job->dispatched)->toHaveCount(count($companies) + 1);
+ $orphans = $job->dispatched[count($companies)];
+ expect($orphans)->toBeInstanceOf(PruneTelematicsDataJob::class)
+ ->and($orphans->companyUuid)->toBeNull()
+ ->and($orphans->orphansOnly)->toBeTrue();
+ }
+});
+
+test('system cleanup reads all companies in bounded id order and dispatches through the queue', function () {
+ $originalDb = app()->bound('db') ? app('db') : null;
+ $originalCache = Illuminate\Support\Facades\Cache::getFacadeRoot();
+ $originalConfig = config('cache', []);
+ $contract = Illuminate\Contracts\Bus\Dispatcher::class;
+ $originalDispatcher = app()->bound($contract) ? app($contract) : null;
+ $connection = new Illuminate\Database\SQLiteConnection(new PDO('sqlite::memory:'));
+ $connection->getSchemaBuilder()->create('companies', function ($table) {
+ $table->integer('id');
+ $table->string('uuid');
+ });
+ foreach (array_reverse(range(1, 103)) as $id) {
+ $connection->table('companies')->insert(['id' => $id, 'uuid' => 'tenant-' . $id]);
+ }
+ app()->instance('db', $connection);
+ Illuminate\Support\Facades\DB::clearResolvedInstance('db');
+ config(['cache.default' => 'array', 'cache.stores.array' => ['driver' => 'array']]);
+ Illuminate\Support\Facades\Cache::swap(new Illuminate\Cache\CacheManager(app()));
+ $dispatcher = new class(app()) extends Illuminate\Bus\Dispatcher {
+ public array $jobs = [];
+
+ public function dispatch($command)
+ {
+ $this->jobs[] = $command;
+
+ return $command;
+ }
+ };
+ app()->instance($contract, $dispatcher);
+ try {
+ (new DispatchTelematicsRetentionJobs(1, 7))->handle();
+ expect($dispatcher->jobs)->toHaveCount(101)
+ ->and($dispatcher->jobs[0]->companyUuid)->toBe('tenant-2')
+ ->and($dispatcher->jobs[99]->companyUuid)->toBe('tenant-101')
+ ->and($dispatcher->jobs[100]->afterCompanyId)->toBe(101);
+ $dispatcher->jobs[100]->handle();
+ expect($dispatcher->jobs)->toHaveCount(104)
+ ->and($dispatcher->jobs[101]->companyUuid)->toBe('tenant-102')
+ ->and($dispatcher->jobs[102]->companyUuid)->toBe('tenant-103')
+ ->and($dispatcher->jobs[103]->orphansOnly)->toBeTrue();
+ } finally {
+ if ($originalDb) {
+ app()->instance('db', $originalDb);
+ } else {
+ app()->forgetInstance('db');
+ }
+ Illuminate\Support\Facades\DB::clearResolvedInstance('db');
+ Illuminate\Support\Facades\Cache::swap($originalCache);
+ config(['cache' => $originalConfig]);
+ if ($originalDispatcher) {
+ app()->instance($contract, $originalDispatcher);
+ } else {
+ app()->forgetInstance($contract);
+ }
+ }
+});
diff --git a/server/tests/Unit/Jobs/PruneTelematicsDataJobTest.php b/server/tests/Unit/Jobs/PruneTelematicsDataJobTest.php
new file mode 100644
index 000000000..b04b8903d
--- /dev/null
+++ b/server/tests/Unit/Jobs/PruneTelematicsDataJobTest.php
@@ -0,0 +1,60 @@
+runs[] = (string) $input;
+
+ return $this->exitCode;
+ }
+}
+
+test('prune job is unique per company and runs the command for that company without the process lock', function () {
+ $recorder = new FleetOpsPruneCommandRunRecorder();
+ app()->instance(PruneTelematicsData::class, $recorder);
+ try {
+ $job = new PruneTelematicsDataJob('company-1');
+ expect($job)->toBeInstanceOf(ShouldQueue::class)->toBeInstanceOf(ShouldBeUnique::class)
+ ->and($job->uniqueId())->toBe('company-1')
+ ->and($job->uniqueFor)->toBe(900)
+ ->and($job->tries)->toBe(1)
+ ->and($job->timeout)->toBeLessThan(90)
+ ->and($job->failOnTimeout)->toBeTrue()
+ ->and($job->queue)->toBe(config('telematics.telemetry.ingestion_queue', 'default'))
+ ->and($job->parameters())->toBe(['--no-lock' => true, '--max-batches' => 200, '--company' => 'company-1']);
+ $job->handle();
+
+ (new PruneTelematicsDataJob(null, 25))->handle();
+ $orphans = new PruneTelematicsDataJob(null, 25, true);
+ $orphans->handle();
+ expect((new PruneTelematicsDataJob())->uniqueId())->toBe('all')
+ ->and($orphans->uniqueId())->toBe('orphans')
+ ->and($orphans->parameters())->toBe(['--no-lock' => true, '--max-batches' => 25, '--orphans-only' => true])
+ ->and($recorder->runs)->toBe([
+ '--no-lock=1 --max-batches=200 --company=company-1',
+ '--no-lock=1 --max-batches=25',
+ '--no-lock=1 --max-batches=25 --orphans-only=1',
+ ])
+ ->and($recorder->getLaravel())->toBe(app());
+
+ $recorder->exitCode = PruneTelematicsData::FAILURE;
+ expect(fn () => $job->handle())->toThrow(RuntimeException::class, 'Telematics cleanup failed with exit code 1.');
+ } finally {
+ app()->forgetInstance(PruneTelematicsData::class);
+ }
+});
diff --git a/server/tests/Unit/Orchestration/VroomEngineTransportAndSettingsTest.php b/server/tests/Unit/Orchestration/VroomEngineTransportAndSettingsTest.php
index a65d75a31..40fefa5d8 100644
--- a/server/tests/Unit/Orchestration/VroomEngineTransportAndSettingsTest.php
+++ b/server/tests/Unit/Orchestration/VroomEngineTransportAndSettingsTest.php
@@ -251,3 +251,23 @@ function fleetopsVroomVehicle(string $publicId, Point $location): Vehicle
expect($call('resolveVroomBaseUri'))->toBeString()
->and($call('resolveVroomApiKey'))->toBeIn([null, '']);
});
+
+test('allocation resolves organization settings from the orders company regardless of session', function (?string $sessionCompany) {
+ $connection = fleetopsVroomBoot();
+ $connection->table('settings')->insert([
+ ['key' => 'company.company-2.vroom', 'value' => json_encode(['api_host' => 'https://company-two-vroom.test'])],
+ ['key' => 'company.company-1.vroom', 'value' => json_encode(['api_host' => 'https://company-one-vroom.test'])],
+ ]);
+
+ $order = fleetopsVroomOrder('order_vroom_queued', fleetopsVroomPlace('p-9', new Point(1.31, 103.81)), null);
+ $order->setAttribute('company_uuid', 'company-2');
+ $vehicle = fleetopsVroomVehicle('vehicle_vroom_queued', new Point(1.20, 103.70));
+
+ Http::fake(['*' => Http::response(['routes' => [], 'unassigned' => [], 'summary' => []], 200)]);
+
+ // Background allocation must not depend on an absent or unrelated session.
+ session(['company' => $sessionCompany]);
+ (new VroomOrchestrationEngine())->allocate(collect([$order]), collect([$vehicle]));
+
+ Http::assertSent(fn ($request) => str_starts_with($request->url(), 'https://company-two-vroom.test'));
+})->with([null, 'company-1']);
diff --git a/server/tests/Unit/Support/Analytics/LiveFleetTest.php b/server/tests/Unit/Support/Analytics/LiveFleetTest.php
index e40deac5d..b535f6778 100644
--- a/server/tests/Unit/Support/Analytics/LiveFleetTest.php
+++ b/server/tests/Unit/Support/Analytics/LiveFleetTest.php
@@ -38,6 +38,12 @@ function fleetopsLiveFleetUseInMemoryConnection(): SQLiteConnection
$connection->statement('create table drivers (uuid varchar(64) primary key, public_id varchar(64) null, user_uuid varchar(64) null, company_uuid varchar(64) null, vehicle_uuid varchar(64) null, current_job_uuid varchar(64) null, avatar_url varchar(255) null, location blob null, heading numeric null, online integer null, last_location_update_at datetime null, deleted_at datetime null, created_at datetime null, updated_at datetime null)');
$connection->statement('create table vehicles (uuid varchar(64) primary key, public_id varchar(64) null, company_uuid varchar(64) null, driver_uuid varchar(64) null, photo_uuid varchar(64) null, avatar_url varchar(255) null, name varchar(255) null, year integer null, make varchar(255) null, model varchar(255) null, trim varchar(255) null, plate_number varchar(64) null, location blob null, heading numeric null, online integer null, deleted_at datetime null, created_at datetime null, updated_at datetime null)');
$connection->statement('create table orders (uuid varchar(64) primary key, public_id varchar(64) null, internal_id varchar(64) null, company_uuid varchar(64) null, driver_assigned_uuid varchar(64) null, status varchar(64) null, tracking_number_uuid varchar(64) null, deleted_at datetime null, created_at datetime null, updated_at datetime null)');
+ // Tables the markers' index-resource cards read: devices, coupled trailers and positions.
+ $connection->statement('create table devices (uuid varchar(64) primary key, public_id varchar(64) null, company_uuid varchar(64) null, attachable_uuid varchar(64) null, attachable_type varchar(255) null, name varchar(255) null, device_id varchar(64) null, serial_number varchar(64) null, imei varchar(64) null, provider varchar(64) null, status varchar(64) null, online integer null, deleted_at datetime null, created_at datetime null, updated_at datetime null)');
+ $connection->statement('create table asset_connections (uuid varchar(64) primary key, company_uuid varchar(64) null, connector_uuid varchar(64) null, connector_type varchar(255) null, connected_uuid varchar(64) null, connected_type varchar(255) null, connected_at datetime null, disconnected_at datetime null, deleted_at datetime null, created_at datetime null, updated_at datetime null)');
+ $connection->statement('create table assets (uuid varchar(64) primary key, public_id varchar(64) null, company_uuid varchar(64) null, asset_class varchar(64) null, name varchar(255) null, code varchar(64) null, type varchar(64) null, status varchar(64) null, plate_number varchar(64) null, online integer null, deleted_at datetime null, created_at datetime null, updated_at datetime null)');
+ $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, coordinates blob null, deleted_at datetime null, created_at datetime null, updated_at datetime null)');
+ $connection->statement('create table tracking_numbers (uuid varchar(64) primary key, public_id varchar(64) null, company_uuid varchar(64) null, owner_uuid varchar(64) null, owner_type varchar(255) null, status_uuid varchar(64) null, tracking_number varchar(64) null, region varchar(64) null, deleted_at datetime null, created_at datetime null, updated_at datetime null)');
$connection->statement('create table files (uuid varchar(64) primary key, type varchar(64) null, url varchar(255) null, deleted_at datetime null, created_at datetime null, updated_at datetime null)');
$resolver = new ConnectionResolver([
diff --git a/server/tests/Unit/Support/AuthorizationTest.php b/server/tests/Unit/Support/AuthorizationTest.php
new file mode 100644
index 000000000..100d13d24
--- /dev/null
+++ b/server/tests/Unit/Support/AuthorizationTest.php
@@ -0,0 +1,142 @@
+permissionStoreUnavailable) {
+ throw new RuntimeException('Permission store unavailable');
+ }
+
+ return in_array($permission->name, $this->grantedPermissions, true);
+ }
+}
+
+beforeEach(function () {
+ $connection = new SQLiteConnection(new PDO('sqlite::memory:'));
+ $resolver = new ConnectionResolver(['mysql' => $connection]);
+ $resolver->setDefaultConnection('mysql');
+ Model::setConnectionResolver($resolver);
+ config()->set('auth.defaults.guard', 'sanctum');
+ config()->set('permission.table_names.permissions', 'permissions');
+ $connection->getSchemaBuilder()->create('permissions', function ($table) {
+ $table->string('id')->primary();
+ $table->string('name');
+ $table->string('guard_name')->default('sanctum');
+ });
+ $connection->table('permissions')->insert([
+ ['id' => 'optimize', 'name' => 'fleet-ops optimize order'],
+ ['id' => 'dispatch', 'name' => 'fleet-ops dispatch order'],
+ ['id' => 'resource', 'name' => 'fleet-ops * order'],
+ ['id' => 'service', 'name' => 'fleet-ops *'],
+ ]);
+ session(['user' => null]);
+ $this->user = new FleetOpsAuthorizationUser();
+ $this->user->setRawAttributes(['type' => 'user'], true);
+ $this->auth = new class($this->user) {
+ public bool $unavailable = false;
+
+ public function __construct(public ?User $currentUser)
+ {
+ }
+
+ public function user(): ?User
+ {
+ if ($this->unavailable) {
+ throw new RuntimeException('Session unavailable');
+ }
+
+ return $this->currentUser;
+ }
+ };
+ app()->instance('fleetops.authorization.test-auth', $this->auth);
+});
+
+test('operation authorization accepts exact and wildcard grants', function (string $grant) {
+ $this->user->grantedPermissions = [$grant];
+
+ expect(Authorization::canAny('optimize order'))->toBeTrue();
+ Authorization::authorize('optimize order');
+})->with(['fleet-ops optimize order', 'fleet-ops * order', 'fleet-ops *']);
+
+test('operation authorization accepts any requested permission and rejects missing grants', function () {
+ $this->user->grantedPermissions = ['fleet-ops dispatch order'];
+
+ expect(Authorization::canAny('optimize order'))->toBeFalse()
+ ->and(Authorization::canAny('optimize order', 'dispatch order'))->toBeTrue()
+ ->and(Authorization::canAny('list unseeded-resource'))->toBeTrue();
+ expect(fn () => Authorization::authorize('optimize order'))->toThrow(HttpResponseException::class);
+
+ $this->user->permissionStoreUnavailable = true;
+ expect(Authorization::canAny('dispatch order'))->toBeFalse();
+});
+
+test('platform administrators pass operations while system settings reject ordinary users', function () {
+ expect(fn () => Authorization::authorizeAdmin())->toThrow(HttpResponseException::class);
+ $this->user->setAttribute('type', 'admin');
+ expect(Authorization::canAny('optimize order'))->toBeTrue();
+ Authorization::authorizeAdmin();
+});
+
+test('authorization fails closed when the authenticated user is missing or cannot be read', function () {
+ $this->auth->currentUser = null;
+ expect(Authorization::canAny('optimize order'))->toBeFalse();
+ expect(fn () => Authorization::authorizeAdmin())->toThrow(HttpResponseException::class);
+ $this->auth->unavailable = true;
+ expect(Authorization::canAny('optimize order'))->toBeFalse();
+});
+
+test('controller middleware applies the mapped operation and administrator checks', function () {
+ $controller = new class extends Controller {
+ use AuthorizesMethods;
+
+ public function __construct()
+ {
+ $this->authorizeMethods(['optimize' => ['optimize order', 'dispatch order'], 'settings' => 'admin']);
+ }
+ };
+ $middleware = $controller->getMiddleware();
+ $request = Request::create('/');
+ $this->user->grantedPermissions = ['fleet-ops dispatch order'];
+
+ expect($middleware[0]['options']['only'])->toBe(['optimize'])
+ ->and($middleware[0]['middleware']($request, fn ($nextRequest) => $nextRequest))->toBe($request);
+ expect(fn () => $middleware[1]['middleware']($request, fn () => 'allowed'))->toThrow(HttpResponseException::class);
+ $this->user->setAttribute('type', 'admin');
+ expect($middleware[1]['middleware']($request, fn () => 'allowed'))->toBe('allowed');
+});
+
+test('analytics controllers enforce authorization before invoking their next middleware', function (string $class) {
+ $connection = Model::getConnectionResolver()->connection();
+ $connection->table('permissions')->insert(['id' => 'analytics', 'name' => 'fleet-ops view analytics']);
+ $controller = new $class();
+ $middleware = $controller->getMiddleware()[0]['middleware'];
+ $request = Request::create('/analytics');
+ expect(fn () => $middleware($request, fn () => 'private analytics'))->toThrow(HttpResponseException::class);
+ $this->user->grantedPermissions = ['fleet-ops view analytics'];
+ expect($middleware($request, fn ($forwarded) => $forwarded))->toBe($request);
+})->with([
+ Fleetbase\FleetOps\Http\Controllers\Internal\v1\AnalyticsController::class,
+ Fleetbase\FleetOps\Http\Controllers\Internal\v1\MetricsController::class,
+]);
diff --git a/server/tests/Unit/Support/MaintainableAndOperationalAlertsTest.php b/server/tests/Unit/Support/MaintainableAndOperationalAlertsTest.php
index b36e6d8b6..c7d58c2aa 100644
--- a/server/tests/Unit/Support/MaintainableAndOperationalAlertsTest.php
+++ b/server/tests/Unit/Support/MaintainableAndOperationalAlertsTest.php
@@ -184,10 +184,21 @@ function fleetopsMaintainableVehicle(array $attributes = []): Part
$order = Order::where('uuid', 'order-1')->withoutGlobalScopes()->first();
expect($probe->callHelper('latestPositionForOrder', $order))->toBeInstanceOf(Position::class);
- $settings = $probe->callHelper('alertSettings');
+ $settings = $probe->callHelper('alertSettings', $order);
expect($settings['late_departures']['enabled'])->toBeTrue()
->and($settings['route_deviations']['distance_threshold_meters'])->toBe(500)
->and($settings['prolonged_stoppages']['duration_threshold_minutes'])->toBe(30);
+
+ // Settings resolve from the order's company without a company session
+ $connection->table('settings')->insert([
+ 'key' => 'company.company-1.tracking',
+ 'value' => json_encode(['alerts' => ['late_departures' => ['enabled' => false], 'route_deviations' => ['distance_threshold_meters' => 750]]]),
+ ]);
+ session(['company' => null]);
+ $settings = $probe->callHelper('alertSettings', $order);
+ session(['company' => 'company-1']);
+ expect($settings['late_departures']['enabled'])->toBeFalse()
+ ->and($settings['route_deviations']['distance_threshold_meters'])->toBe(750);
});
test('route point collection normalizes pairs and measures distances', function () {
diff --git a/server/tests/Unit/Support/MoreQuerySeamsTest.php b/server/tests/Unit/Support/MoreQuerySeamsTest.php
index 59f90d6e2..c095ae011 100644
--- a/server/tests/Unit/Support/MoreQuerySeamsTest.php
+++ b/server/tests/Unit/Support/MoreQuerySeamsTest.php
@@ -384,13 +384,22 @@ public function get()
});
test('shift change listener reads company scheduling settings', function () {
- fleetopsMoreSeamBoot();
+ $connection = fleetopsMoreSeamBoot();
$class = Fleetbase\FleetOps\Listeners\NotifyDriverOnShiftChange::class;
$listener = (new ReflectionClass($class))->newInstanceWithoutConstructor();
// With nothing stored the lookup falls back to the empty default
- expect(fleetopsMoreSeamInvoke($listener, $class, 'getSchedulingSettings'))->toBe([]);
+ expect(fleetopsMoreSeamInvoke($listener, $class, 'getSchedulingSettings', ['company-seam-2']))->toBe([]);
+
+ // Queued listeners have no company session, so the schedule's company is used
+ $connection->table('settings')->insert(['key' => 'company.company-seam-3.fleet-ops.scheduling-settings', 'value' => json_encode(['notify_drivers_on_shift_change' => true])]);
+ session(['company' => null]);
+ $settings = fleetopsMoreSeamInvoke($listener, $class, 'getSchedulingSettings', ['company-seam-3']);
+ session(['company' => 'company-seam-2']);
+
+ expect($settings)->toBe(['notify_drivers_on_shift_change' => true])
+ ->and(fleetopsMoreSeamInvoke($listener, $class, 'getSchedulingSettings', [null]))->toBe([]);
});
test('order insights capability builds its own company-scoped order query', function () {
diff --git a/server/tests/Unit/Support/Telematics/Retention/RetentionPolicyTest.php b/server/tests/Unit/Support/Telematics/Retention/RetentionPolicyTest.php
new file mode 100644
index 000000000..ef1ad772b
--- /dev/null
+++ b/server/tests/Unit/Support/Telematics/Retention/RetentionPolicyTest.php
@@ -0,0 +1,190 @@
+toBe(RetentionPolicy::SETTING_KEY);
+
+ return $scope === 'system' ? $system : ($company[$companyUuid] ?? $default);
+ };
+}
+
+beforeEach(function () {
+ RetentionPolicy::flush();
+ RetentionPolicy::$settingsResolver = null;
+ config(['telematics.telemetry' => ['event_retention_days' => 45, 'processed_retention_hours' => 48, 'log_telemetry_activity' => true, 'poll_queue' => 'default']]);
+});
+
+afterEach(function () {
+ RetentionPolicy::flush();
+ RetentionPolicy::$settingsResolver = null;
+ Carbon::setTestNow();
+ config(['telematics.telemetry' => []]);
+});
+
+test('defaults come from package config, overridden by the admin setting, with everything clamped', function () {
+ fleetopsRetentionResolver([], []);
+ expect(RetentionPolicy::defaults())->toBe([
+ 'event_retention_days' => 45,
+ 'event_compact_after_days' => 7,
+ 'position_retention_days' => 90,
+ 'processed_retention_hours' => 48,
+ 'quarantine_retention_days' => 7,
+ 'sync_run_retention_days' => 7,
+ 'max_event_retention_days' => 0,
+ 'max_position_retention_days' => 0,
+ 'log_telemetry_activity' => true,
+ ]);
+
+ RetentionPolicy::flush();
+ fleetopsRetentionResolver([
+ 'event_retention_days' => 99999,
+ 'event_compact_after_days' => '',
+ 'position_retention_days' => 0,
+ 'quarantine_retention_days' => -5,
+ 'processed_retention_hours' => null,
+ 'log_telemetry_activity' => 'no',
+ 'ignored' => 'value',
+ ], []);
+ $defaults = RetentionPolicy::defaults();
+ expect($defaults['event_retention_days'])->toBe(3650)
+ ->and($defaults['event_compact_after_days'])->toBe(7)
+ ->and($defaults['position_retention_days'])->toBe(0)
+ ->and($defaults['quarantine_retention_days'])->toBe(1)
+ ->and($defaults['processed_retention_hours'])->toBe(48)
+ ->and($defaults['log_telemetry_activity'])->toBeFalse()
+ ->and($defaults)->not->toHaveKey('ignored')
+ ->and(RetentionPolicy::keys())->toBe(array_keys(RetentionPolicy::FALLBACKS));
+});
+
+test('company policies layer over the defaults and are cached until flushed', function () {
+ fleetopsRetentionResolver(['position_retention_days' => 30], ['company-1' => ['event_retention_days' => 10, 'log_telemetry_activity' => false]], $calls);
+
+ $policy = RetentionPolicy::forCompany('company-1');
+ expect($policy->get('event_retention_days'))->toBe(10)
+ ->and($policy->get('position_retention_days'))->toBe(30)
+ ->and($policy->get('processed_retention_hours'))->toBe(48)
+ ->and($policy->logsTelemetryActivity())->toBeTrue()
+ ->and($policy->toArray())->toHaveKeys(RetentionPolicy::keys())
+ ->and(RetentionPolicy::forCompany('company-1'))->toBe($policy)
+ ->and(RetentionPolicy::forCompany(null)->toArray())->toBe(RetentionPolicy::defaults())
+ ->and(RetentionPolicy::forCompany('company-2')->get('event_retention_days'))->toBe(45)
+ ->and(array_column($calls, 0))->toBe(['system', 'company', 'company']);
+
+ RetentionPolicy::flush('company-1');
+ RetentionPolicy::forCompany('company-1');
+ RetentionPolicy::forCompany('company-2');
+ expect(count($calls))->toBe(4);
+
+ RetentionPolicy::flush();
+ RetentionPolicy::forCompany('company-2');
+ expect(array_column($calls, 0))->toBe(['system', 'company', 'company', 'company', 'system', 'company']);
+});
+
+test('company history respects admin maximums and cannot override infrastructure or logging', function () {
+ fleetopsRetentionResolver([
+ 'event_retention_days' => 0,
+ 'position_retention_days' => 90,
+ 'max_event_retention_days' => 30,
+ 'max_position_retention_days' => 45,
+ 'log_telemetry_activity' => false,
+ ], [
+ 'company-1' => [
+ 'event_retention_days' => 0,
+ 'position_retention_days' => 120,
+ 'max_event_retention_days' => 0,
+ 'max_position_retention_days' => 0,
+ 'event_compact_after_days' => 0,
+ 'processed_retention_hours' => 0,
+ 'quarantine_retention_days' => 0,
+ 'sync_run_retention_days' => 0,
+ 'log_telemetry_activity' => true,
+ ],
+ 'company-2' => ['event_retention_days' => 5, 'position_retention_days' => 15],
+ ]);
+
+ $policy = RetentionPolicy::forCompany('company-1');
+ expect($policy->get('event_retention_days'))->toBe(30)
+ ->and($policy->get('position_retention_days'))->toBe(45)
+ ->and($policy->get('event_compact_after_days'))->toBe(7)
+ ->and($policy->get('processed_retention_hours'))->toBe(48)
+ ->and($policy->get('quarantine_retention_days'))->toBe(7)
+ ->and($policy->get('sync_run_retention_days'))->toBe(7)
+ ->and($policy->logsTelemetryActivity())->toBeFalse()
+ ->and(RetentionPolicy::forCompany('company-2')->get('event_retention_days'))->toBe(5)
+ ->and(RetentionPolicy::forCompany('company-2')->get('position_retention_days'))->toBe(15)
+ ->and(RetentionPolicy::forCompany('inherited')->get('event_retention_days'))->toBe(30)
+ ->and(RetentionPolicy::forCompany('inherited')->get('position_retention_days'))->toBe(45)
+ ->and(RetentionPolicy::forCompany(null)->get('event_retention_days'))->toBe(30);
+});
+
+test('unconfigured maximums preserve unlimited history and null preferences inherit defaults', function () {
+ fleetopsRetentionResolver([], ['company-1' => ['event_retention_days' => 0, 'position_retention_days' => null]]);
+
+ expect(RetentionPolicy::forCompany('company-1')->get('event_retention_days'))->toBe(0)
+ ->and(RetentionPolicy::forCompany('company-1')->get('position_retention_days'))->toBe(90)
+ ->and(RetentionPolicy::companyPreferences(['event_retention_days' => 'invalid', 'position_retention_days' => false]))->toBe([])
+ ->and(RetentionPolicy::companyPreferences(['event_retention_days' => null, 'position_retention_days' => '', 'log_telemetry_activity' => true]))->toBe([]);
+});
+
+test('constrained preferences keep zero explicit when allowed and replace it when an administrator imposes a maximum', function () {
+ $preferences = ['event_retention_days' => 0, 'position_retention_days' => null];
+ expect(RetentionPolicy::constrainCompanyPreferences($preferences, RetentionPolicy::FALLBACKS))->toBe(['event_retention_days' => 0]);
+
+ $defaults = array_replace(RetentionPolicy::FALLBACKS, ['max_event_retention_days' => 90, 'max_position_retention_days' => 360]);
+ $bounded = RetentionPolicy::constrainCompanyPreferences($preferences, $defaults);
+ expect($bounded)->toBe(['event_retention_days' => 90])
+ ->and(RetentionPolicy::constrainCompanyPreferences(['event_retention_days' => 30, 'position_retention_days' => 720], $defaults))->toBe(['event_retention_days' => 30, 'position_retention_days' => 360])
+ ->and(RetentionPolicy::constrainCompanyPreferences([], $defaults))->toBe([])
+ ->and(RetentionPolicy::constrainCompanyPreferences($bounded, RetentionPolicy::FALLBACKS))->toBe(['event_retention_days' => 90]);
+});
+
+test('cutoffs, compaction and deletion rules follow the clamped values', function () {
+ Carbon::setTestNow('2026-09-23 12:00:00 UTC');
+
+ $policy = RetentionPolicy::fromArray(['event_retention_days' => 10, 'event_compact_after_days' => 10, 'processed_retention_hours' => 6, 'position_retention_days' => 0]);
+ expect($policy->deletesEvents())->toBeTrue()
+ ->and($policy->compactsEvents())->toBeFalse()
+ ->and($policy->cutoff('processed_retention_hours')?->toDateTimeString())->toBe('2026-09-23 06:00:00')
+ ->and($policy->cutoff('event_retention_days')?->toDateTimeString())->toBe('2026-09-13 12:00:00')
+ ->and($policy->cutoff('position_retention_days'))->toBeNull()
+ ->and($policy->cutoff('unknown'))->toBeNull()
+ ->and($policy->get('unknown'))->toBeNull();
+
+ expect(RetentionPolicy::fromArray(['event_retention_days' => 10, 'event_compact_after_days' => 3])->compactsEvents())->toBeTrue()
+ ->and(RetentionPolicy::fromArray(['event_retention_days' => 0, 'event_compact_after_days' => 5])->compactsEvents())->toBeTrue()
+ ->and(RetentionPolicy::fromArray(['event_retention_days' => 0, 'event_compact_after_days' => 5])->deletesEvents())->toBeFalse()
+ ->and(RetentionPolicy::fromArray(['event_compact_after_days' => 0])->compactsEvents())->toBeFalse()
+ ->and(RetentionPolicy::fromArray([], ['event_retention_days' => 12])->get('event_retention_days'))->toBe(12)
+ ->and(RetentionPolicy::normalize(['log_telemetry_activity' => '1'], [])['log_telemetry_activity'])->toBeTrue();
+});
+
+test('a failing or malformed settings store falls back to the layer below', function () {
+ RetentionPolicy::$settingsResolver = fn () => throw new RuntimeException('settings unavailable');
+ expect(RetentionPolicy::forCompany('company-1')->get('event_retention_days'))->toBe(45);
+
+ RetentionPolicy::flush();
+ RetentionPolicy::$settingsResolver = fn () => 'not-an-array';
+ expect(RetentionPolicy::defaults()['processed_retention_hours'])->toBe(48);
+});
+
+test('company lookups scope the session for the setting store and restore it afterwards', function () {
+ // Without a resolver the policy reaches Setting, which has no database here and throws.
+ app('session')->put('company', 'previous-company');
+ expect(RetentionPolicy::forCompany('company-9')->get('event_retention_days'))->toBe(45)
+ ->and(app('session')->get('company'))->toBe('previous-company');
+
+ app('session')->forget('company');
+ RetentionPolicy::flush();
+ expect(RetentionPolicy::forCompany('company-9')->get('processed_retention_hours'))->toBe(48)
+ ->and(app('session')->has('company'))->toBeFalse();
+});
diff --git a/server/tests/Unit/Support/Telematics/Retention/TelemetryActivityTest.php b/server/tests/Unit/Support/Telematics/Retention/TelemetryActivityTest.php
new file mode 100644
index 000000000..ff1e69a05
--- /dev/null
+++ b/server/tests/Unit/Support/Telematics/Retention/TelemetryActivityTest.php
@@ -0,0 +1,68 @@
+suppressed++;
+
+ return $callback();
+ }
+ };
+ app()->instance(ActivityLogger::class, $logger);
+
+ return $logger;
+}
+
+beforeEach(function () {
+ RetentionPolicy::flush();
+ config(['telematics.telemetry' => []]);
+});
+
+afterEach(function () {
+ RetentionPolicy::flush();
+ RetentionPolicy::$settingsResolver = null;
+ app()->forgetInstance(ActivityLogger::class);
+});
+
+test('telemetry activity follows the system policy and ignores legacy company overrides', function () {
+ RetentionPolicy::$settingsResolver = fn (string $scope, string $key, mixed $default, ?string $company) => $company === 'verbose' ? ['log_telemetry_activity' => true] : [];
+ $logger = fleetopsTelemetryActivityLogger();
+
+ expect(TelemetryActivity::run('quiet', fn () => 'saved'))->toBe('saved')
+ ->and($logger->suppressed)->toBe(1)
+ ->and(TelemetryActivity::run('verbose', fn () => 'saved'))->toBe('saved')
+ ->and($logger->suppressed)->toBe(2)
+ ->and(TelemetryActivity::run(null, fn () => 'system'))->toBe('system')
+ ->and($logger->suppressed)->toBe(3);
+
+ RetentionPolicy::flush();
+ RetentionPolicy::$settingsResolver = fn (string $scope) => ['log_telemetry_activity' => $scope === 'system'];
+
+ expect(TelemetryActivity::run('quiet', fn () => 'logged'))->toBe('logged')
+ ->and(TelemetryActivity::run('verbose', fn () => 'logged'))->toBe('logged')
+ ->and(TelemetryActivity::run(null, fn () => 'system'))->toBe('system')
+ ->and($logger->suppressed)->toBe(3);
+});
+
+test('telemetry writes still run when the activity logger cannot be resolved', function () {
+ RetentionPolicy::$settingsResolver = fn () => [];
+ app()->forgetInstance(ActivityLogger::class);
+
+ expect(TelemetryActivity::run('company-1', fn () => 'unlogged'))->toBe('unlogged');
+});
diff --git a/server/tests/Unit/Tracking/TrackingOptionsCompanySettingsTest.php b/server/tests/Unit/Tracking/TrackingOptionsCompanySettingsTest.php
new file mode 100644
index 000000000..a064509c8
--- /dev/null
+++ b/server/tests/Unit/Tracking/TrackingOptionsCompanySettingsTest.php
@@ -0,0 +1,109 @@
+ $connection, 'mysql' => $connection]);
+ $resolver->setDefaultConnection('mysql');
+ EloquentModel::setConnectionResolver($resolver);
+
+ $connection->getSchemaBuilder()->create('settings', function ($blueprint) {
+ $blueprint->increments('id');
+ $blueprint->string('key')->nullable();
+ $blueprint->text('value')->nullable();
+ $blueprint->timestamps();
+ });
+
+ $connection->table('settings')->insert([
+ ['key' => 'company.company-a.tracking', 'value' => json_encode(['provider' => 'osrm', 'cache_ttl_seconds' => 15])],
+ ['key' => 'company.company-b.tracking', 'value' => json_encode(['provider' => 'calculated', 'cache_ttl_seconds' => 45])],
+ ]);
+
+ session(['company' => null]);
+
+ return $connection;
+}
+
+test('tracking options read the given company settings regardless of session', function (?string $sessionCompany) {
+ fleetopsTrackingOptionsBoot();
+ session(['company' => $sessionCompany]);
+
+ $options = TrackingOptions::fromArray([], 'company-a');
+
+ expect($options->provider)->toBe('osrm')
+ ->and($options->cacheTtlSeconds)->toBe(15);
+
+ // Explicit options still win over company settings
+ expect(TrackingOptions::fromArray(['provider' => 'google'], 'company-a')->provider)->toBe('google');
+})->with([null, 'company-b']);
+
+test('tracking options fall back to the session company when no company is given', function () {
+ fleetopsTrackingOptionsBoot();
+ session(['company' => 'company-b']);
+
+ $options = TrackingOptions::fromArray([]);
+ session(['company' => null]);
+
+ expect($options->provider)->toBe('calculated')
+ ->and($options->cacheTtlSeconds)->toBe(45);
+});
+
+test('order tracker builds options from the order company', function () {
+ fleetopsTrackingOptionsBoot();
+
+ $service = new class extends TrackingIntelligenceService {
+ public ?TrackingOptions $received = null;
+
+ public function __construct()
+ {
+ }
+
+ public function track(Order $order, array|TrackingOptions $options = []): array
+ {
+ $this->received = $options;
+
+ return [];
+ }
+ };
+ app()->instance(TrackingIntelligenceService::class, $service);
+
+ $order = new Order();
+ $order->setRawAttributes(['uuid' => 'order-tracking-options', 'company_uuid' => 'company-a'], true);
+
+ (new OrderTracker($order))->toArray();
+ app()->forgetInstance(TrackingIntelligenceService::class);
+
+ expect($service->received)->toBeInstanceOf(TrackingOptions::class)
+ ->and($service->received->provider)->toBe('osrm');
+});
+
+test('tracking remains available with configured defaults when the settings database is unavailable', function () {
+ $connection = fleetopsTrackingOptionsBoot();
+ $connection->getSchemaBuilder()->drop('settings');
+ $original = config('fleetops.tracking', []);
+ try {
+ config(['fleetops.tracking' => ['provider' => 'calculated', 'cache_ttl_seconds' => 17]]);
+ $options = TrackingOptions::fromArray([], 'uncached-unavailable-company');
+ expect($options->provider)->toBe('calculated')->and($options->cacheTtlSeconds)->toBe(17);
+ expect(TrackingOptions::fromArray(['provider' => 'osrm'], 'uncached-unavailable-company')->provider)->toBe('osrm');
+ } finally {
+ config(['fleetops.tracking' => $original]);
+ }
+});
diff --git a/tests/integration/components/admin/telematics-settings-test.js b/tests/integration/components/admin/telematics-settings-test.js
new file mode 100644
index 000000000..248f44b0e
--- /dev/null
+++ b/tests/integration/components/admin/telematics-settings-test.js
@@ -0,0 +1,54 @@
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'dummy/tests/helpers';
+import { render, click, fillIn } from '@ember/test-helpers';
+import { hbs } from 'ember-cli-htmlbars';
+import Service from '@ember/service';
+
+class FetchStubService extends Service {
+ posts = [];
+
+ get() {
+ return Promise.resolve({ event_retention_days: 60, position_retention_days: 0, log_telemetry_activity: false });
+ }
+
+ post(url, body) {
+ this.posts.push([url, body]);
+ return Promise.resolve(body);
+ }
+}
+
+class NotificationsStubService extends Service {
+ successes = [];
+
+ success(message) {
+ this.successes.push(message);
+ }
+
+ serverError() {}
+}
+
+module('Integration | Component | admin/telematics-settings', function (hooks) {
+ setupRenderingTest(hooks);
+
+ hooks.beforeEach(function () {
+ this.owner.register('service:fetch', FetchStubService);
+ this.owner.register('service:notifications', NotificationsStubService);
+ });
+
+ test('it loads system defaults and saves them', async function (assert) {
+ await render(hbs` `);
+
+ assert.dom('input[type="number"]').exists({ count: 6 });
+ assert.dom('input[type="number"]').hasValue('60');
+
+ await fillIn('input[type="number"]', '45');
+ await click('button.btn-primary');
+
+ const fetch = this.owner.lookup('service:fetch');
+ assert.strictEqual(fetch.posts.length, 1);
+ assert.strictEqual(fetch.posts[0][0], 'fleet-ops/settings/admin-telematics-settings');
+ assert.strictEqual(fetch.posts[0][1].event_retention_days, 45);
+ assert.strictEqual(fetch.posts[0][1].position_retention_days, 0);
+ assert.strictEqual(this.owner.lookup('service:notifications').successes.length, 1);
+ });
+});
diff --git a/tests/integration/components/map/marker-card/driver-test.js b/tests/integration/components/map/marker-card/driver-test.js
new file mode 100644
index 000000000..17d6a624c
--- /dev/null
+++ b/tests/integration/components/map/marker-card/driver-test.js
@@ -0,0 +1,35 @@
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'dummy/tests/helpers';
+import { render } from '@ember/test-helpers';
+import { hbs } from 'ember-cli-htmlbars';
+
+module('Integration | Component | map/marker-card/driver', function (hooks) {
+ setupRenderingTest(hooks);
+
+ test('it shows the driver card from an index-resource payload', async function (assert) {
+ this.set('driver', {
+ name: 'Ada Driver',
+ online: true,
+ status: 'active',
+ public_id: 'driver_public',
+ phone: '+6590000000',
+ vehicle_name: 'Van 7',
+ email: 'ada@example.test',
+ meta: { status_label: 'Active', current_order_reference: 'ORD-1', speed_label: '42 km/h', heading_label: '90 deg', location_coordinates: '1.3 103.8' },
+ });
+
+ await render(hbs` `);
+
+ for (const text of ['Ada Driver', 'Active', 'driver_public', '+6590000000', 'Van 7', 'ada@example.test', 'ORD-1', '42 km/h', '90 deg', '1.3 103.8']) {
+ assert.dom(this.element).containsText(text);
+ }
+ });
+
+ test('the tooltip variant drops the dark panel', async function (assert) {
+ this.set('driver', { name: 'Ada Driver', meta: {} });
+
+ await render(hbs` `);
+
+ assert.dom('div').doesNotHaveClass('bg-gray-900');
+ });
+});
diff --git a/tests/integration/components/map/marker-card/vehicle-test.js b/tests/integration/components/map/marker-card/vehicle-test.js
new file mode 100644
index 000000000..6bc4fd06d
--- /dev/null
+++ b/tests/integration/components/map/marker-card/vehicle-test.js
@@ -0,0 +1,47 @@
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'dummy/tests/helpers';
+import { render } from '@ember/test-helpers';
+import { hbs } from 'ember-cli-htmlbars';
+import { setupIntl } from 'ember-intl/test-support';
+
+// The live map passes a vehicle model; the dashboard's Live Fleet widget passes the same
+// index-resource payload as plain JSON. Both must render the same card.
+const VEHICLE = {
+ display_name: 'Van 7',
+ online: true,
+ status: 'in_service',
+ internal_id: 'V-7',
+ driver_name: 'Ada Driver',
+ trailers: [{ display_name: 'Reefer 2', online: true }],
+ devices: [],
+ meta: { status_label: 'In Service', current_order_reference: 'ORD-1', speed_label: '42 km/h', heading_label: '90 deg', location_coordinates: '1.3 103.8' },
+};
+
+module('Integration | Component | map/marker-card/vehicle', function (hooks) {
+ setupRenderingTest(hooks);
+ setupIntl(hooks, 'en-us');
+
+ test('it shows the vehicle card from an index-resource payload', async function (assert) {
+ this.set('vehicle', VEHICLE);
+
+ await render(hbs` `);
+
+ assert.dom(this.element).containsText('Van 7', 'the display name falls back to display_name');
+ assert.dom(this.element).containsText('In Service');
+ assert.dom(this.element).containsText('V-7');
+ assert.dom(this.element).containsText('Ada Driver');
+ assert.dom(this.element).containsText('Reefer 2');
+ assert.dom(this.element).containsText('ORD-1');
+ assert.dom(this.element).containsText('42 km/h');
+ assert.dom('div').hasClass('bg-gray-900', 'a popup card is its own dark panel');
+ });
+
+ test('a model display name wins and the tooltip variant drops the panel', async function (assert) {
+ this.set('vehicle', { ...VEHICLE, displayName: 'Van 7 (SBA1234Z)' });
+
+ await render(hbs` `);
+
+ assert.dom(this.element).containsText('Van 7 (SBA1234Z)');
+ assert.dom('div').doesNotHaveClass('bg-gray-900', 'the tooltip supplies its own panel');
+ });
+});
diff --git a/tests/unit/controllers/settings/telematics-test.js b/tests/unit/controllers/settings/telematics-test.js
new file mode 100644
index 000000000..e3227bbd5
--- /dev/null
+++ b/tests/unit/controllers/settings/telematics-test.js
@@ -0,0 +1,117 @@
+import { module, test } from 'qunit';
+import { setupTest } from 'dummy/tests/helpers';
+import { settled } from '@ember/test-helpers';
+import Service from '@ember/service';
+
+const SETTINGS = {
+ event_retention_days: 12,
+ event_compact_after_days: 12,
+ position_retention_days: 0,
+ processed_retention_hours: 6,
+ quarantine_retention_days: 3,
+ sync_run_retention_days: 2,
+ log_telemetry_activity: true,
+ defaults: { event_retention_days: 30 },
+ limits: { event_retention_days: [1, 3650] },
+};
+
+const USAGE = {
+ tables: {
+ device_events: { rows: 1200, oldest: '2026-08-01 00:00:00', raw_payload_rows: 300, compactable_rows: 100, avg_row_bytes: 32000, estimated_bytes: 38400000 },
+ positions: { rows: 40, oldest: null },
+ telematic_deliveries: { rows: 0, oldest: null },
+ },
+};
+
+class FetchStubService extends Service {
+ posts = [];
+
+ get(url) {
+ if (url === 'fleet-ops/settings/telematics-settings') {
+ return Promise.resolve(SETTINGS);
+ }
+ if (url === 'fleet-ops/settings/telematics-storage-usage') {
+ return Promise.resolve(USAGE);
+ }
+
+ return Promise.resolve({});
+ }
+
+ post(url, body) {
+ this.posts.push([url, body]);
+ return Promise.resolve({ ...body, status: 'ok' });
+ }
+}
+
+class NotificationsStubService extends Service {
+ successes = [];
+ errors = [];
+
+ success(message) {
+ this.successes.push(message);
+ }
+
+ serverError(error) {
+ this.errors.push(error);
+ }
+}
+
+class CurrentUserStubService extends Service {
+ isAdmin = false;
+}
+
+module('Unit | Controller | settings/telematics', function (hooks) {
+ setupTest(hooks);
+
+ hooks.beforeEach(function () {
+ this.owner.register('service:fetch', FetchStubService);
+ this.owner.register('service:notifications', NotificationsStubService);
+ this.owner.register('service:current-user', CurrentUserStubService);
+ });
+
+ test('it exists', function (assert) {
+ let controller = this.owner.lookup('controller:settings/telematics');
+ assert.ok(controller);
+ });
+
+ test('it loads settings and usage, flags ineffective compaction and saves integers', async function (assert) {
+ const controller = this.owner.lookup('controller:settings/telematics');
+ await settled();
+
+ assert.strictEqual(controller.eventRetentionDays, 12);
+ assert.strictEqual(controller.positionRetentionDays, 0);
+ assert.true(controller.logTelemetryActivity);
+ assert.deepEqual(controller.defaults, { event_retention_days: 30 });
+ assert.true(controller.compactionIneffective, 'compacting at the deletion age is pointless');
+ assert.false(controller.isAdmin);
+
+ assert.deepEqual(
+ controller.usageRows.map((row) => row.table),
+ ['device_events', 'positions', 'telematic_deliveries']
+ );
+ assert.strictEqual(controller.usageRows[0].estimated_bytes, 38400000);
+
+ controller.eventCompactAfterDays = '5';
+ controller.eventRetentionDays = '';
+ controller.processedRetentionHours = 'abc';
+ assert.false(controller.compactionIneffective);
+ await controller.saveSettings.perform();
+
+ const fetch = this.owner.lookup('service:fetch');
+ assert.deepEqual(fetch.posts, [
+ [
+ 'fleet-ops/settings/telematics-settings',
+ {
+ event_retention_days: 0,
+ event_compact_after_days: 5,
+ position_retention_days: 0,
+ processed_retention_hours: 0,
+ quarantine_retention_days: 3,
+ sync_run_retention_days: 2,
+ log_telemetry_activity: true,
+ },
+ ],
+ ]);
+ assert.strictEqual(this.owner.lookup('service:notifications').successes.length, 1);
+ });
+});
diff --git a/tests/unit/extension-test.js b/tests/unit/extension-test.js
index 99106b422..bb568a351 100644
--- a/tests/unit/extension-test.js
+++ b/tests/unit/extension-test.js
@@ -89,4 +89,40 @@ module('Unit | FleetOps extension', function () {
'default analytics widgets are available'
);
});
+
+ test('it lays out the default dashboard: KPI row, full-width map, then three panels', function (assert) {
+ let registered = [];
+ extension.registerWidgets({
+ registerDashboard() {},
+ registerWidgets(id, widgets) {
+ if (id === 'dashboard') registered = widgets.map((widget) => widget.toObject());
+ },
+ registerDefaultWidgets() {},
+ });
+
+ const byId = Object.fromEntries(registered.map((widget) => [widget.id, widget]));
+ const defaults = registered.filter((widget) => widget.default === true).sort((a, b) => a.order - b.order);
+
+ assert.deepEqual(
+ defaults.map((widget) => [widget.id, widget.order]),
+ [
+ ['fleet-ops-radar-widget', 10],
+ ['fleet-ops-kpi-active-orders-widget', 30],
+ ['fleet-ops-kpi-drivers-online-widget', 40],
+ ['fleet-ops-live-fleet-widget', 50],
+ ['fleet-ops-revenue-trend-widget', 60],
+ ['fleet-ops-top-drivers-widget', 70],
+ ['fleet-ops-maintenance-overview-widget', 80],
+ ],
+ 'every default widget has a place, leaving 20 for the ledger Revenue tile'
+ );
+ assert.strictEqual(byId['fleet-ops-live-fleet-widget'].grid_options.w, 12, 'the map spans the full width');
+ assert.deepEqual(
+ ['fleet-ops-revenue-trend-widget', 'fleet-ops-top-drivers-widget', 'fleet-ops-maintenance-overview-widget'].map((id) => byId[id].grid_options.w),
+ [4, 4, 4],
+ 'the panels share a row'
+ );
+ assert.false(byId['fleet-ops-kpi-earnings-widget'].default, 'Earnings is no longer a default');
+ assert.false(byId['fleet-ops-kpi-aov-widget'].default, 'Avg Order Value is no longer a default');
+ });
});
diff --git a/tests/unit/routes/settings/telematics-test.js b/tests/unit/routes/settings/telematics-test.js
new file mode 100644
index 000000000..6a055f10e
--- /dev/null
+++ b/tests/unit/routes/settings/telematics-test.js
@@ -0,0 +1,58 @@
+import { module, test } from 'qunit';
+import { setupTest } from 'dummy/tests/helpers';
+import Service from '@ember/service';
+
+class AbilitiesStubService extends Service {
+ allowed = true;
+
+ cannot() {
+ return !this.allowed;
+ }
+}
+
+class HostRouterStubService extends Service {
+ transitions = [];
+
+ transitionTo(route) {
+ this.transitions.push(route);
+ return route;
+ }
+}
+
+class NotificationsStubService extends Service {
+ warnings = [];
+
+ warning(message) {
+ this.warnings.push(message);
+ }
+}
+
+module('Unit | Route | settings/telematics', function (hooks) {
+ setupTest(hooks);
+
+ hooks.beforeEach(function () {
+ this.owner.register('service:abilities', AbilitiesStubService);
+ this.owner.register('service:host-router', HostRouterStubService);
+ this.owner.register('service:notifications', NotificationsStubService);
+ });
+
+ test('it exists', function (assert) {
+ let route = this.owner.lookup('route:settings/telematics');
+ assert.ok(route);
+ });
+
+ test('it lets permitted users through and redirects everyone else', function (assert) {
+ const route = this.owner.lookup('route:settings/telematics');
+ const abilities = this.owner.lookup('service:abilities');
+ const hostRouter = this.owner.lookup('service:host-router');
+ const notifications = this.owner.lookup('service:notifications');
+
+ assert.strictEqual(route.beforeModel(), undefined);
+ assert.deepEqual(hostRouter.transitions, []);
+
+ abilities.allowed = false;
+ assert.strictEqual(route.beforeModel(), 'console.fleet-ops');
+ assert.deepEqual(hostRouter.transitions, ['console.fleet-ops']);
+ assert.strictEqual(notifications.warnings.length, 1);
+ });
+});
diff --git a/translations/en-us.yaml b/translations/en-us.yaml
index 98e50bedd..ae122c471 100644
--- a/translations/en-us.yaml
+++ b/translations/en-us.yaml
@@ -114,6 +114,7 @@ menu:
maintenances: Maintenances
orchestrator: Orchestrator
orchestrator-settings: Orchestrator Settings
+ telematics-settings: Telematics
trailer:
navigation-description: Manage towed assets, towing connections, equipment, and telematics.
@@ -2723,6 +2724,53 @@ route-list:
more-waypoints: more waypoints
settings:
+ telematics:
+ telematics-settings: Telematics Settings
+ intro: Choose how long your organization keeps device events and position history within the limits set by your system administrator.
+ use-system-default: Use system default
+ history-policy-maximum: "Your administrator allows up to {days} days of history. Unlimited retention is not available."
+ history-policy-unlimited: Your administrator allows unlimited history. Set the retention period to 0 to keep history forever.
+ device-events: Device Events Retention
+ event-retention-days: Keep device events for (days)
+ event-retention-days-help: Events older than this are permanently deleted. Applies to telemetry updates and provider alerts alike.
+ event-compact-after-days: Strip raw payloads after (days)
+ event-compact-after-days-help: Events older than this keep their type, severity, location and normalized data, but the raw provider payload is removed. Raw payloads are the bulk of each event's size.
+ compaction-warning: Raw payloads are only stripped from events that are kept. Set this lower than the event retention or set it to 0 to disable compaction.
+ positions: Position History Retention
+ position-retention-days: Keep positions for (days)
+ position-retention-days-help: Vehicle and trailer position history older than this is permanently deleted. Route replay and history views only reach back this far.
+ inbox: Delivery Inbox & Sync Run Retention
+ processed-retention-hours: Keep processed deliveries for (hours)
+ processed-retention-hours-help: Raw provider batches are stored encrypted until processed and kept briefly for replay and diagnostics. Each batch can be close to a megabyte.
+ quarantine-retention-days: Keep quarantined deliveries for (days)
+ quarantine-retention-days-help: Batches that could not be processed stay available for inspection and replay for this long.
+ sync-run-retention-days: Keep sync run diagnostics for (days)
+ sync-run-retention-days-help: One sync run row is recorded per connection per poll. In-flight runs are never removed.
+ activity-logging: Telemetry Activity Logging
+ log-telemetry-activity: Log telemetry activity
+ log-telemetry-activity-help: Write telemetry-driven device, event and vehicle saves to the activity log. Off by default; every poll otherwise adds several activity rows per device.
+ storage-usage: System Telematics Storage Usage
+ table: Table
+ rows: Rows
+ estimated-rows: "{count} (estimated)"
+ estimated-size: Estimated size
+ oldest: Oldest row
+ not-available: n/a
+ raw-payload-rows: "{count} rows still carry raw payloads, {compactable} eligible for compaction now"
+ refresh: Refresh
+ run-cleanup: Run system cleanup
+ run-cleanup-confirm: This queues telematics cleanup across all organizations, using the saved system policy and each organization's effective history preferences. Expired records are permanently deleted and cannot be recovered. Continue?
+ cleanup-queued: Telematics cleanup has been queued across all organizations. Usage refreshes shortly; cleanup may still be running.
+ cleanup-unavailable: System-wide telematics cleanup could not be confirmed. Please try again.
+ usage-unavailable: Storage usage could not be loaded. Select Refresh to try again.
+ space-reclaim-note: Usage includes all telematics data across the system. Row counts marked as estimated use database statistics. Sizes include table data and indexes. Deleting records frees space for reuse within the database; physical disk usage may not decrease immediately.
+ settings-saved: Telematics settings saved.
+ settings-unavailable: Telematics settings could not be loaded. Please try again.
+ tables:
+ device_events: Device events
+ positions: Positions
+ telematic_deliveries: Delivery inbox
+ telematic_sync_runs: Sync runs
avatar-management: Avatar Management
custom-fields: Custom Fields
notifications: