diff --git a/RELEASE.md b/RELEASE.md index d30e45903..a32ec2d8d 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,15 +1,34 @@ -> v0.6.74 ~ "Settings-backed geocoding everywhere" +> v0.6.75 ~ "Road distance and ETAs from one routing engine, tenant-isolated profile accounts and App Review access for Navigator" --- ## Highlights -- **Fix: the console geocoder endpoints return results.** `int/v1/geocoder/query` and `int/v1/geocoder/reverse` returned `[]` for valid addresses whenever the Google Maps API key was set in the console instead of in the environment. This broke address lookup in the coordinates and autocomplete inputs, for example when editing a place. Both endpoints now use the same geocoder as `places/lookup`. (#408, fleetbase/fleetbase#723) -- **Fix: every other geocoding path uses the settings-backed key.** Place creation from an address or coordinates, place search, address-only place creation on the public API, and driver tracking locality all go through the same geocoder. Failed lookups are now logged instead of hidden. (#409) -- **Fix: `config/geocoder.php` passes the region rather than the locale to Google Maps, and uses the default cache store.** (#409) +- **Feature: one routing engine for order distance, ETAs and the map.** An order's stored distance and time, its live ETAs and remaining distance, and the route drawn on the map now all come from the organization's routing engine. Before this, order distance defaulted to a straight-line estimate at a flat ~50 km/h; a reported ~30 km run was stored as 25.0 km / 30 min and is now 32.1 km / 32 min. The engine is chosen in order: the organization's routing engine, then the admin default, then the platform's Valhalla server, then the straight-line estimate. An engine only counts if it is allowed and configured. A failed road lookup falls back to the straight-line estimate and is logged, instead of storing 0. Road results are cached for 24 hours. (#406) +- **Feature: routing provider controls in Admin → Fleet-Ops → Routing.** Administrators choose which providers organizations may use (Google, Valhalla, OSRM, VROOM), with per-organization exceptions. They also set the default routing and optimization engines, and whether server-side routing uses roads or only the straight-line estimate. Saved admin settings take precedence over environment variables. **Google is off until an administrator enables it**, for map routing, optimization, order distance and ETAs. The public OSRM demo server is never called by the server. The admin menu entry "Fleet-Ops Config" is now "Fleet-Ops". (#406) +- **Feature: the OSRM server is a setting, for the platform and per organization.** Administrators set it in Admin → Fleet-Ops → Routing → OSRM Server; `OSRM_HOST` is now only the starting value. Organizations that route or optimize with OSRM can use their own server under Fleet-Ops Settings → Routing → OSRM Server, like the Valhalla and VROOM overrides. An organization host must be a public address (`OSRM_ALLOW_PRIVATE_HOSTS=true` lifts that on self-hosted installs). Order distance, ETAs, tracking and the console's OSRM routes and route control all use the resolved server. The public demo server still only draws map routes. +- **Security: an organization's routing host can't point at a private address.** Order distance, ETAs and VROOM allocation ignore an organization's Valhalla or VROOM override whose host is loopback, a private or link-local network, cloud metadata or another non-public range, and use the system server instead. This also covers values saved before the Valhalla and VROOM extensions started rejecting them. (#417) +- **Security: a profile never links to another organization's login account.** Creating a driver, customer or contact with an email or phone that belongs to another organization's account linked that account and exposed its name, email and phone. The new organization could then reset its password or deactivate it. Only accounts that already belong to the organization, or belong to no organization, are reused now. The refusal does not say whose account it is. `POST /v1/drivers` also ignores a `company` that is not the API credential's own. Logins already shared by the old behaviour can only be managed by their holder. (#414) +- **Fix: orders can be edited with a partial payload.** `PUT /v1/orders/{id}` accepts a `payload` with only the parts that change, such as only entities or only a new dropoff. (#411) +- **Fix: addresses sent with coordinates keep their street.** A place given as a street address plus `latitude`/`longitude` was reverse-geocoded and stored as a different nearby address. The street and the coordinates are now kept as sent, without a geocoding call. (#411) +- **Feature: idempotent order creation.** `POST /v1/orders` accepts an `Idempotency-Key` header. A retry with the same key within 24 hours returns the order already created, with `Idempotent-Replayed: true`, and a concurrent duplicate gets `409`. (#412) +- **Fix: `scheduled_at` keeps its UTC offset.** `2026-10-21T11:00:00-07:00` is now stored as 18:00 UTC instead of 11:00, and `PUT /v1/orders/{id}/schedule` stores the UTC instant of a date and time in the company timezone. Rows written before this keep their old values. (#412) +- **Fix: service quotes.** The `currency` filter works again, stored quotes expire after 48 hours by default (`fleetops.service_quotes.ttl_minutes`), and `preview=1` prices a request without storing quote rows. (#412) +- **Fix: tracking numbers with a space can be looked up again.** Since v0.6.72, a number such as `AL 7796486490AE`, generated for a company whose name has a space in its first three letters, got "not found". Single inner spaces are accepted now. New numbers only use letters and digits: the company prefix skips spaces and punctuation ("Al Noor" gives `ALN`), and a region such as "New York" becomes `NEWYORK`. (#413, #416) +- **Feature: App Review access for Navigator.** Apple and Google reviewers can't receive the texted sign-in code. An organization can turn on **Settings → Navigator App → App Review**: reviewers sign in to its Navigator with the test number +1 202 555 0144 (a range kept for fiction, never texted) and a six-digit code the owner creates and enters in App Store Connect and Google Play Console. The code is stored hashed, shown once, and only works for that organization. The first sign-in adds an **App Reviewer** driver, which ad-hoc offers, nearest-driver assignment and the orchestrator always leave out. Built on core-api's App Review access; the `NAVIGATOR_*` review env settings still work on self-hosted installs. See `docs/NAVIGATOR_APP_REVIEW.md`. (#418) --- ## Upgrading -- No migrations or commands. +- **Requires fleetbase/core-api v1.6.74** (`composer.json` now requires `^1.6.74`), which adds App Review access and its `review_accesses` table: run `php artisan migrate`. +- **Routing defaults changed (#406).** Review **Admin → Fleet-Ops → Routing** after upgrading: + - The default routing engine is now **Valhalla** (it was OSRM). Without a Valhalla server (`VALHALLA_HOST`, or the Valhalla extension's settings), order distance and ETAs use the straight-line estimate. Self-hosters running their own OSRM can make it the default in the panel or with `ROUTING_DISPLAY_ENGINE=osrm`. + - **Google is off.** Turn it on in the panel or set `ROUTING_GOOGLE_ENABLED=true`. `DISTANCE_MATRIX_PROVIDER=google` also enables it until the panel is saved. + - Tracking follows the routing engine. `TRACKING_PROVIDER` and `TRACKING_PROVIDER_FALLBACKS` are no longer read, and saved tracking provider and fallback choices are ignored. + - `DISTANCE_MATRIX_PROVIDER` only seeds the admin defaults now; it no longer overrides order distance. + - New starting values, read until the panel is first saved: `ROUTING_GOOGLE_ENABLED`, `ROUTING_DISPLAY_ENGINE`, `ROUTING_OPTIMIZATION_ENGINE`, `ROUTING_SERVER_ROUTING`, plus `VALHALLA_HOST`, `VALHALLA_API_KEY`, `VALHALLA_COSTING` and `VALHALLA_TIMEOUT`. + - Existing orders: started, unfinished orders from the last 2 days are re-measured by `fleetops:update-estimations`; unstarted orders pick up road distance at their next ready or edit; completed and cancelled orders keep their stored values. No migration. +- **OSRM server.** Nothing to do: `OSRM_HOST` keeps working until a host is saved in Admin → Fleet-Ops → Routing. Organization OSRM servers are new and empty. +- **Private routing hosts (#417).** On self-hosted installs, an organization override pointing at a private host (for example `http://valhalla:8002`) now falls back to the system server. Set `VALHALLA_ALLOW_PRIVATE_HOSTS=true` or `VROOM_ALLOW_PRIVATE_HOSTS=true` to allow it. System-level private hosts are unaffected. +- **Fleetbase Cloud.** After deploying, set the Valhalla host to `https://valhalla.routing.fleetbase.io` with Cloud's gateway key, Google and OSRM off, and Valhalla as the default engine with road routing. Enter the gateway key only after fleetbase/valhalla v0.0.5 and fleetbase/vroom v0.0.5 ship (valhalla#9, vroom#8), because the extensions' settings endpoints return the key to any signed-in user until then. --- ## Need help? diff --git a/addon/components/admin/routing-settings.hbs b/addon/components/admin/routing-settings.hbs index 7ee7af45f..82ad05c3c 100644 --- a/addon/components/admin/routing-settings.hbs +++ b/addon/components/admin/routing-settings.hbs @@ -1,36 +1,123 @@
- Configure system-wide routing engine defaults here. Organizations can optionally override these values from their own routing settings. + Configure system-wide routing engine defaults here. Organizations can optionally override these values from their own routing settings, but only with providers that are switched on + below. - - + +

+ Choose which providers organizations may use for map routing, route optimization, order distance and tracking. A provider switched off here is never called, even if an + organization selected it; they fall back to the defaults below. Changes apply on every server within 30 seconds. +

+
+ {{#each this.providerAccessRows as |provider|}} +
+
+
{{provider.label}}
+ {{#if provider.billed}} +
Billed per request to the platform's Google Maps key.
+ {{/if}} +
+ +
+ {{/each}} +
+
+ + + + + + + + + +

+ Switch a provider on for specific organizations only, for example Google for a customer who pays for it. +

+
+ {{#each this.providerExceptionRows as |row|}} +
+
{{row.exception.company_name}}
+
+ + {{provider.label}} + +
+
+ {{else}} +
No exceptions. Every organization follows Provider Access.
+ {{/each}} +
+ + + {{company.name}} + + +
+ + +

+ Live ETAs, remaining distance and route lines come from each organization's routing engine. These settings only tune their behaviour. +

+
@@ -80,4 +168,4 @@ @isLoading={{or this.saveSettings.isRunning this.loadSettings.isRunning}} @disabled={{this.saveSettings.isRunning}} /> - + \ No newline at end of file diff --git a/addon/components/admin/routing-settings.js b/addon/components/admin/routing-settings.js index 106ba740e..6c31b37da 100644 --- a/addon/components/admin/routing-settings.js +++ b/addon/components/admin/routing-settings.js @@ -7,19 +7,31 @@ import { task } from 'ember-concurrency'; export default class AdminRoutingSettingsComponent extends Component { @service fetch; @service notifications; - @tracked trackingProvider = 'google_routes'; - @tracked trackingFallbacks = ['osrm', 'calculated']; + @service routeEngine; + @service routeOptimization; + @tracked providerAccess = { google: false, valhalla: true, osrm: true, vroom: true }; + @tracked availableProviders = [ + { key: 'google', label: 'Google' }, + { key: 'valhalla', label: 'Valhalla' }, + { key: 'osrm', label: 'OSRM' }, + { key: 'vroom', label: 'VROOM' }, + ]; + @tracked defaultDisplayEngine = 'valhalla'; + @tracked defaultOptimizationEngine = 'valhalla'; + @tracked providerExceptions = []; + @tracked serverRouting = 'road'; + serverRoutingOptions = [ + { value: 'road', label: 'Road distance from the routing engine' }, + { value: 'straight_line', label: 'Straight-line estimate (no routing calls)' }, + ]; @tracked trackingTrafficEnabled = true; @tracked trackingCacheTtlSeconds = 60; @tracked trackingRouteCacheTtlSeconds = 600; @tracked trackingStaleLocationThresholdSeconds = 300; @tracked trackingDefaultVehicleSpeedKph = 35; @tracked showTrackingAdvancedSettings = false; - @tracked trackingProviderOptions = [ - { value: 'google_routes', label: 'Google Routes' }, - { value: 'osrm', label: 'OSRM' }, - { value: 'calculated', label: 'Calculated' }, - ]; + @tracked osrmHost = ''; + @tracked osrmEnvironmentHost = null; constructor() { super(...arguments); @@ -28,15 +40,18 @@ export default class AdminRoutingSettingsComponent extends Component { @task *loadSettings() { try { + const policy = yield this.fetch.get('fleet-ops/settings/admin-routing-providers'); + this.applyProviderPolicy(policy); + + const osrm = yield this.fetch.get('fleet-ops/settings/admin-osrm-settings'); + this.applyOsrmSettings(osrm); + const settings = yield this.fetch.get('fleet-ops/settings/admin-tracking-settings'); - this.trackingProvider = settings.provider ?? 'google_routes'; - this.trackingFallbacks = this.normalizeFallbacks(settings.fallbacks); this.trackingTrafficEnabled = settings.traffic_enabled ?? true; this.trackingCacheTtlSeconds = settings.cache_ttl_seconds ?? 60; this.trackingRouteCacheTtlSeconds = settings.route_cache_ttl_seconds ?? 600; this.trackingStaleLocationThresholdSeconds = settings.stale_location_threshold_seconds ?? 300; this.trackingDefaultVehicleSpeedKph = settings.default_vehicle_speed_kph ?? 35; - this.trackingProviderOptions = this.normalizeProviderOptions(settings.providers ?? this.trackingProviderOptions); } catch (error) { this.notifications.serverError(error); } @@ -44,78 +59,115 @@ export default class AdminRoutingSettingsComponent extends Component { @task *saveSettings() { try { + const policy = yield this.fetch.post('fleet-ops/settings/admin-routing-providers', this.providerPolicyPayload); + this.applyProviderPolicy(policy); + const osrm = yield this.fetch.post('fleet-ops/settings/admin-osrm-settings', { api_host: this.osrmHost }); + this.applyOsrmSettings(osrm); yield this.fetch.post('fleet-ops/settings/admin-tracking-settings', this.trackingSettingsPayload); - this.notifications.success('Tracking defaults saved.'); + this.notifications.success('Routing settings saved.'); } catch (error) { this.notifications.serverError(error); } } - get trackingSettingsPayload() { + applyOsrmSettings(settings = {}) { + this.osrmHost = settings.api_host ?? ''; + this.osrmEnvironmentHost = settings.environment_host ?? null; + } + + get osrmHelpText() { + const environment = this.osrmEnvironmentHost ? ` Empty uses OSRM_HOST (${this.osrmEnvironmentHost}).` : ''; + + return `The OSRM server organizations use unless they set their own. The public demo server (router.project-osrm.org) only draws map routes; order distance and ETAs never use it.${environment}`; + } + + applyProviderPolicy(policy = {}) { + this.providerAccess = { ...this.providerAccess, ...(policy.providers ?? {}) }; + this.availableProviders = policy.available_providers ?? this.availableProviders; + this.defaultDisplayEngine = policy.defaults?.display_engine ?? this.defaultDisplayEngine; + this.defaultOptimizationEngine = policy.defaults?.optimization_engine ?? this.defaultOptimizationEngine; + this.serverRouting = policy.server_routing ?? this.serverRouting; + this.providerExceptions = (policy.exceptions ?? []).map((exception) => ({ + company_uuid: exception.company_uuid, + company_name: exception.company_name ?? exception.company_uuid, + providers: [...(exception.providers ?? [])], + })); + } + + get providerPolicyPayload() { return { - provider: this.trackingProvider, - fallbacks: this.normalizeFallbacks(this.trackingFallbacks), - traffic_enabled: this.trackingTrafficEnabled, - cache_ttl_seconds: Number(this.trackingCacheTtlSeconds) || 60, - route_cache_ttl_seconds: Number(this.trackingRouteCacheTtlSeconds) || 600, - stale_location_threshold_seconds: Number(this.trackingStaleLocationThresholdSeconds) || 300, - default_vehicle_speed_kph: Number(this.trackingDefaultVehicleSpeedKph) || 35, + providers: { ...this.providerAccess }, + defaults: { + display_engine: this.defaultDisplayEngine, + optimization_engine: this.defaultOptimizationEngine, + }, + server_routing: this.serverRouting, + exceptions: this.providerExceptions + .filter((exception) => exception.company_uuid && exception.providers.length > 0) + .map(({ company_uuid, company_name, providers }) => ({ company_uuid, company_name, providers })), }; } - normalizeFallbacks(fallbacks) { - if (Array.isArray(fallbacks)) { - return fallbacks - .map((fallback) => this.optionValue(fallback)) - .map((fallback) => String(fallback).trim()) - .filter(Boolean); - } + get providerAccessRows() { + return this.availableProviders.map((provider) => ({ + ...provider, + enabled: this.providerAccess[provider.key] === true, + billed: provider.key === 'google', + })); + } - return String(fallbacks ?? '') - .split(',') - .map((fallback) => fallback.trim()) - .filter(Boolean); + /** + * Providers that are switched off and can therefore be granted to single organizations. + */ + get exceptionProviderOptions() { + return this.availableProviders.filter((provider) => this.providerAccess[provider.key] !== true); } - get selectedTrackingFallbackOptions() { - const selected = new Set(this.normalizeFallbacks(this.trackingFallbacks)); + get displayEngineOptions() { + return this.routeEngine.allServices; + } - return this.trackingProviderOptions.filter((option) => selected.has(this.optionValue(option))); + get optimizationEngineOptions() { + return this.routeOptimization.allServices; } - normalizeProviderOptions(options = []) { - return options.map((option) => { - const value = this.optionValue(option); - const label = option?.label ?? option?.name ?? this.providerLabel(value); + get providerExceptionRows() { + return this.providerExceptions.map((exception) => ({ + exception, + selected: this.availableProviders.filter((provider) => exception.providers.includes(provider.key)), + })); + } - return { - ...option, - key: option?.key ?? value, - name: option?.name ?? label, - value, - label, - }; - }); + @action toggleProvider(key, enabled) { + this.providerAccess = { ...this.providerAccess, [key]: enabled }; } - providerLabel(value) { - if (value === 'osrm') { - return 'OSRM'; + @action addProviderException(company) { + const companyUuid = company?.uuid ?? company?.id; + if (!companyUuid || this.providerExceptions.some((exception) => exception.company_uuid === companyUuid)) { + return; } - return String(value ?? '') - .replace(/[_-]+/g, ' ') - .replace(/\s+/g, ' ') - .trim() - .replace(/\b\w/g, (character) => character.toUpperCase()); + this.providerExceptions = [...this.providerExceptions, { company_uuid: companyUuid, company_name: company.name, providers: [] }]; + } + + @action setExceptionProviders(exception, providers) { + const keys = providers.map((provider) => provider.key); + this.providerExceptions = this.providerExceptions.map((item) => (item === exception ? { ...item, providers: keys } : item)); } - optionValue(option) { - return typeof option === 'object' && option !== null ? (option.value ?? option.key) : option; + @action removeProviderException(exception) { + this.providerExceptions = this.providerExceptions.filter((item) => item !== exception); } - @action setTrackingFallbacks(options) { - this.trackingFallbacks = this.normalizeFallbacks(options); + get trackingSettingsPayload() { + return { + traffic_enabled: this.trackingTrafficEnabled, + cache_ttl_seconds: Number(this.trackingCacheTtlSeconds) || 60, + route_cache_ttl_seconds: Number(this.trackingRouteCacheTtlSeconds) || 600, + stale_location_threshold_seconds: Number(this.trackingStaleLocationThresholdSeconds) || 300, + default_vehicle_speed_kph: Number(this.trackingDefaultVehicleSpeedKph) || 35, + }; } @action toggleTrackingAdvancedSettings() { diff --git a/addon/components/customer/create-order-form.js b/addon/components/customer/create-order-form.js index 5efea77ac..ded15c7f1 100644 --- a/addon/components/customer/create-order-form.js +++ b/addon/components/customer/create-order-form.js @@ -8,7 +8,6 @@ import { later } from '@ember/runloop'; import { debug } from '@ember/debug'; import { task, timeout } from 'ember-concurrency'; import { OSRMv1, Control as RoutingControl } from '@fleetbase/leaflet-routing-machine'; -import getRoutingHost from '@fleetbase/ember-core/utils/get-routing-host'; import engineService from '@fleetbase/ember-core/decorators/engine-service'; import registerHelper from '@fleetbase/ember-core/utils/register-helper'; import WaypointLabelHelper from '../../helpers/waypoint-label'; @@ -22,6 +21,7 @@ export default class CustomerCreateOrderFormComponent extends Component { @engineService('@fleetbase/fleetops-engine') contextPanel; @service store; @service currentUser; + @service osrm; @service notifications; @service modalsManager; @service customerSession; @@ -359,7 +359,7 @@ export default class CustomerCreateOrderFormComponent extends Component { this.notifications.warning(this.intl.t('fleet-ops.operations.orders.index.new.no-route-warning')); } - const routingHost = getRoutingHost(payload, waypoints); + const routingHost = this.osrm.routingHost(payload, waypoints); const router = new OSRMv1({ serviceUrl: `${routingHost}/route/v1`, profile: 'driving', diff --git a/addon/components/customer/orders.js b/addon/components/customer/orders.js index 1de736c0b..1d9c4a316 100644 --- a/addon/components/customer/orders.js +++ b/addon/components/customer/orders.js @@ -255,7 +255,7 @@ export default class CustomerOrdersComponent extends Component { debug(`Leaflet Map Error: ${error.message}`); } - const routingService = this.currentUser.getOption('routing', { router: 'osrm' }).router; + const routingService = this.currentUser.getOption('routing', { router: 'valhalla' }).router; const { router, formatter } = this.leafletRoutingControl.get(routingService); this.routeControl = new RoutingControl({ diff --git a/addon/components/navigator-app-review-settings.hbs b/addon/components/navigator-app-review-settings.hbs new file mode 100644 index 000000000..4802c6a7f --- /dev/null +++ b/addon/components/navigator-app-review-settings.hbs @@ -0,0 +1,75 @@ +{{! App Review access for the Navigator app: the test number and code Apple and Google reviewers sign in with. }} + +
+
+

{{t "navigator-app-review-settings.lede"}}

+ {{#if this.status}} + + {{/if}} +
+ + {{#if this.load.isRunning}} +
+ {{else if this.status}} + + {{this.status.phone_display}} + + + {{#if this.code}} + {{this.code}} +

{{t "navigator-app-review-settings.code-new"}}

+ {{else if this.status.has_code}} +
••••••
+

{{t "navigator-app-review-settings.code-hidden" date=this.codeCreatedAt}}

+ {{else}} +

{{t "navigator-app-review-settings.code-none"}}

+ {{/if}} +
+ + + +
+ {{#if this.enabled}} +
+ {{/if}} +
+
\ No newline at end of file diff --git a/addon/components/navigator-app-review-settings.js b/addon/components/navigator-app-review-settings.js new file mode 100644 index 000000000..77baddf90 --- /dev/null +++ b/addon/components/navigator-app-review-settings.js @@ -0,0 +1,99 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; +import { task } from 'ember-concurrency'; + +/** + * App Review access for the organization's Navigator app: lets Apple and Google reviewers sign + * in with a fixed test number (never texted) and a code the owner gives the app store. Core + * (`int/v1/review-access/navigator`) keeps the code hashed, so it is shown only in the + * response that created it. + */ +export default class NavigatorAppReviewSettingsComponent extends Component { + @service fetch; + @service intl; + @service currentUser; + @service notifications; + @service modalsManager; + + @tracked status = null; + @tracked code = null; + + permission = 'fleet-ops update navigator-settings'; + + constructor() { + super(...arguments); + this.load.perform(); + } + + get endpoint() { + return `review-access/navigator/${this.currentUser.companyId}`; + } + + get enabled() { + return this.status?.enabled === true; + } + + get codeCreatedAt() { + const at = this.status?.code_generated_at; + return at ? new Date(at).toLocaleString() : null; + } + + @task *load() { + if (!this.currentUser.companyId) return; + + try { + const { review_access } = yield this.fetch.get(this.endpoint, {}, { namespace: 'int/v1' }); + this.status = review_access; + } catch (error) { + this.notifications.serverError(error); + } + } + + @task *save(payload, message) { + try { + const { review_access } = yield this.fetch.put(this.endpoint, payload, { namespace: 'int/v1' }); + const { code, ...status } = review_access; + this.status = status; + if (code) this.code = code; + if (!status.enabled) this.code = null; + this.notifications.success(message); + } catch (error) { + this.notifications.serverError(error); + } + } + + @action turnOn() { + this.save.perform({ enabled: true }, this.intl.t('navigator-app-review-settings.turned-on')); + } + + @action turnOff() { + this.modalsManager.confirm({ + title: this.intl.t('navigator-app-review-settings.turn-off-title'), + body: this.intl.t('navigator-app-review-settings.turn-off-body'), + acceptButtonText: this.intl.t('navigator-app-review-settings.turn-off'), + acceptButtonType: 'danger', + confirm: async (modal) => { + modal.startLoading(); + await this.save.perform({ enabled: false }, this.intl.t('navigator-app-review-settings.turned-off')); + modal.done(); + }, + }); + } + + @action regenerate() { + this.modalsManager.confirm({ + title: this.intl.t('navigator-app-review-settings.regenerate-title'), + body: this.intl.t('navigator-app-review-settings.regenerate-body'), + acceptButtonText: this.intl.t('navigator-app-review-settings.regenerate'), + acceptButtonType: 'danger', + acceptButtonIcon: 'rotate', + confirm: async (modal) => { + modal.startLoading(); + await this.save.perform({ regenerate_code: true }, this.intl.t('navigator-app-review-settings.regenerated')); + modal.done(); + }, + }); + } +} diff --git a/addon/controllers/settings/routing.js b/addon/controllers/settings/routing.js index 4a8a89fcd..0355300e3 100644 --- a/addon/controllers/settings/routing.js +++ b/addon/controllers/settings/routing.js @@ -10,11 +10,9 @@ export default class SettingsRoutingController extends Controller { @service currentUser; @service routeEngine; @service routeOptimization; - @tracked displayEngine = 'osrm'; - @tracked optimizationEngine = 'osrm'; + @tracked displayEngine = 'valhalla'; + @tracked optimizationEngine = 'valhalla'; @tracked routingUnit = 'km'; - @tracked trackingProvider = 'google_routes'; - @tracked trackingFallbacks = ['osrm', 'calculated']; @tracked trackingTrafficEnabled = true; @tracked trackingCacheTtlSeconds = 60; @tracked trackingRouteCacheTtlSeconds = 600; @@ -27,17 +25,24 @@ export default class SettingsRoutingController extends Controller { @tracked prolongedStoppageAlertsEnabled = true; @tracked prolongedStoppageDurationThresholdMinutes = 30; @tracked showTrackingAdvancedSettings = false; - @tracked trackingProviderOptions = [ - { value: 'google_routes', label: 'Google Routes' }, - { value: 'osrm', label: 'OSRM' }, - { value: 'calculated', label: 'Calculated' }, - ]; + @tracked osrmUseOwnServer = false; + @tracked osrmHost = ''; + @tracked osrmSystemHost = null; @tracked routingUnitOptions = [ { label: 'Kilometers', value: 'km' }, { label: 'Miles', value: 'mi' }, ]; saveTasks = new Map(); + /** Whether the organization routes or optimizes with OSRM, so its own OSRM server applies. */ + get usesOsrm() { + return this.displayEngine === 'osrm' || this.optimizationEngine === 'osrm'; + } + + get osrmSystemHelpText() { + return this.osrmSystemHost ? `Otherwise this organization uses ${this.osrmSystemHost}.` : 'Otherwise this organization uses the platform OSRM server.'; + } + get hasEngineOverrides() { return ['vroom', 'valhalla'].includes(this.displayEngine) || ['vroom', 'valhalla'].includes(this.optimizationEngine); } @@ -61,14 +66,13 @@ export default class SettingsRoutingController extends Controller { }); const trackingSettings = this.trackingSettingsPayload; yield this.fetch.post('fleet-ops/settings/tracking-settings', trackingSettings); + if (this.usesOsrm) { + yield this.fetch.post('fleet-ops/settings/osrm-settings', { api_host: this.osrmUseOwnServer ? this.osrmHost : null }); + } yield this.performAdditionalSaveTasks(); - // Save in local memory too - this.currentUser.setOption('routing', { - router: this.displayEngine, - routing_display_engine: this.displayEngine, - routing_optimization_engine: this.optimizationEngine, - unit: this.routingUnit, - }); + // Refresh the routing settings in memory: they carry the resolved OSRM server the map uses + const routingSettings = yield this.fetch.get('fleet-ops/settings/routing-settings'); + this.currentUser.setOption('routing', routingSettings); this.currentUser.setOption('tracking', trackingSettings); this.notifications.success('Routing and tracking settings saved.'); } catch (error) { @@ -84,12 +88,11 @@ export default class SettingsRoutingController extends Controller { @task *getSettings() { try { const { router, display_engine, optimization_engine, unit } = yield this.fetch.get('fleet-ops/settings/routing-settings'); - this.displayEngine = display_engine ?? router ?? 'osrm'; - this.optimizationEngine = optimization_engine ?? display_engine ?? router ?? 'osrm'; + this.displayEngine = display_engine ?? router ?? 'valhalla'; + this.optimizationEngine = optimization_engine ?? display_engine ?? router ?? 'valhalla'; this.routingUnit = unit; + yield this.loadOsrmSettings.perform(); const trackingSettings = yield this.fetch.get('fleet-ops/settings/tracking-settings'); - this.trackingProvider = trackingSettings.provider ?? 'google_routes'; - this.trackingFallbacks = this.normalizeFallbacks(trackingSettings.fallbacks); this.trackingTrafficEnabled = trackingSettings.traffic_enabled ?? true; this.trackingCacheTtlSeconds = trackingSettings.cache_ttl_seconds ?? 60; this.trackingRouteCacheTtlSeconds = trackingSettings.route_cache_ttl_seconds ?? 600; @@ -101,22 +104,24 @@ export default class SettingsRoutingController extends Controller { this.routeDeviationDistanceThresholdMeters = trackingSettings.alerts?.route_deviations?.distance_threshold_meters ?? 500; this.prolongedStoppageAlertsEnabled = trackingSettings.alerts?.prolonged_stoppages?.enabled ?? true; this.prolongedStoppageDurationThresholdMinutes = trackingSettings.alerts?.prolonged_stoppages?.duration_threshold_minutes ?? 30; - this.trackingProviderOptions = this.normalizeProviderOptions(trackingSettings.providers ?? this.trackingProviderOptions); } catch (error) { this.notifications.serverError(error); } } - get selectedTrackingFallbackOptions() { - const selected = new Set(this.normalizeFallbacks(this.trackingFallbacks)); - - return this.trackingProviderOptions.filter((option) => selected.has(this.optionValue(option))); + @task *loadOsrmSettings() { + try { + const { api_host, system_host } = yield this.fetch.get('fleet-ops/settings/osrm-settings'); + this.osrmHost = api_host ?? ''; + this.osrmUseOwnServer = Boolean(api_host); + this.osrmSystemHost = system_host ?? null; + } catch { + // Without the permission or the endpoint, the OSRM panel keeps its defaults. + } } get trackingSettingsPayload() { return { - provider: this.trackingProvider, - fallbacks: this.normalizeFallbacks(this.trackingFallbacks), traffic_enabled: this.trackingTrafficEnabled, cache_ttl_seconds: Number(this.trackingCacheTtlSeconds) || 60, route_cache_ttl_seconds: Number(this.trackingRouteCacheTtlSeconds) || 600, @@ -139,55 +144,6 @@ export default class SettingsRoutingController extends Controller { }; } - normalizeFallbacks(fallbacks) { - if (Array.isArray(fallbacks)) { - return fallbacks - .map((fallback) => this.optionValue(fallback)) - .map((fallback) => String(fallback).trim()) - .filter(Boolean); - } - - return String(fallbacks ?? '') - .split(',') - .map((fallback) => fallback.trim()) - .filter(Boolean); - } - - normalizeProviderOptions(options = []) { - return options.map((option) => { - const value = this.optionValue(option); - const label = option?.label ?? option?.name ?? this.providerLabel(value); - - return { - ...option, - key: option?.key ?? value, - name: option?.name ?? label, - value, - label, - }; - }); - } - - providerLabel(value) { - if (value === 'osrm') { - return 'OSRM'; - } - - return String(value ?? '') - .replace(/[_-]+/g, ' ') - .replace(/\s+/g, ' ') - .trim() - .replace(/\b\w/g, (character) => character.toUpperCase()); - } - - optionValue(option) { - return typeof option === 'object' && option !== null ? (option.value ?? option.key) : option; - } - - @action setTrackingFallbacks(options) { - this.trackingFallbacks = this.normalizeFallbacks(options); - } - @action toggleTrackingAdvancedSettings() { this.showTrackingAdvancedSettings = !this.showTrackingAdvancedSettings; } diff --git a/addon/extension.js b/addon/extension.js index a4bd25358..44350d9c8 100644 --- a/addon/extension.js +++ b/addon/extension.js @@ -95,7 +95,7 @@ export default { // Register admin sections menuService.registerAdminMenuPanel( - 'Fleet-Ops Config', + 'Fleet-Ops', [ new MenuItem({ title: 'Routing', diff --git a/addon/instance-initializers/register-osrm.js b/addon/instance-initializers/register-osrm.js index 9947f44d1..8986fdc4d 100644 --- a/addon/instance-initializers/register-osrm.js +++ b/addon/instance-initializers/register-osrm.js @@ -1,4 +1,3 @@ -import getRoutingHost from '@fleetbase/ember-core/utils/get-routing-host'; import { RoutingControl } from '../services/leaflet-routing-control'; import { OSRMv1 } from '@fleetbase/leaflet-routing-machine'; @@ -17,16 +16,19 @@ export function initialize(owner) { // Register OSRM as Routing Controler const leafletRoutingControl = owner.lookup('service:leaflet-routing-control'); - if (leafletRoutingControl) { - const routingHost = getRoutingHost(); + if (leafletRoutingControl && osrm) { + const router = new OSRMv1({ profile: 'driving' }); + // Resolved per request: the routing settings (and the organization's or the + // admin's OSRM server) load after this initializer runs. + Object.defineProperty(router.options, 'serviceUrl', { + get: () => `${osrm.routingHost()}/route/v1`, + configurable: true, + }); leafletRoutingControl.register( 'osrm', new RoutingControl({ name: 'OSRM', - router: new OSRMv1({ - serviceUrl: `${routingHost}/route/v1`, - profile: 'driving', - }), + router, }) ); } diff --git a/addon/services/leaflet-routing-control.js b/addon/services/leaflet-routing-control.js index f6df907f9..958366667 100644 --- a/addon/services/leaflet-routing-control.js +++ b/addon/services/leaflet-routing-control.js @@ -1,6 +1,7 @@ import Service, { inject as service } from '@ember/service'; import { tracked } from '@glimmer/tracking'; import { underscore } from '@ember/string'; +import isRoutingProviderAllowed from '../utils/routing-providers'; export class RoutingControlRegistry { @tracked routers = {}; @@ -51,17 +52,21 @@ export default class LeafletRoutingControlService extends Service { get(name) { name = name ?? this.getRouter(); + // A switched-off engine is never used, even when named explicitly + if (!isRoutingProviderAllowed(name, this.currentUser.getOption('routing', {})?.allowed_providers)) { + name = this.getRouter(); + } let router = this.registry.routers[underscore(name)]; if (!router) { - // Fallback to OSRM default router - router = this.registry.routers.osrm; + // Fall back to Valhalla (the platform's routing engine), then OSRM + router = this.registry.routers.valhalla ?? this.registry.routers.osrm; } return router; } - getRouter(fallback = 'osrm') { - const routingSettings = this.currentUser.getOption('routing', { router: fallback ?? 'osrm' }); + getRouter(fallback = 'valhalla') { + const routingSettings = this.currentUser.getOption('routing', { router: fallback ?? 'valhalla' }); return routingSettings.router; } diff --git a/addon/services/osrm.js b/addon/services/osrm.js index 2b96b5cb7..28c0348b5 100644 --- a/addon/services/osrm.js +++ b/addon/services/osrm.js @@ -1,3 +1,4 @@ +import { inject as service } from '@ember/service'; import RouteOptimizationInterfaceService from './route-optimization-interface'; import { isArray } from '@ember/array'; import getRoutingHost from '@fleetbase/ember-core/utils/get-routing-host'; @@ -5,8 +6,23 @@ import polyline from '@fleetbase/ember-core/utils/polyline'; import { debug } from '@ember/debug'; export default class OsrmService extends RouteOptimizationInterfaceService { + @service currentUser; name = 'OSRM'; + /** + * The OSRM server to call from the console: the organization's own or the + * admin's (Fleet-Ops routing settings carry the resolved host), otherwise + * the console build's OSRM_HOST, with its per-country servers. + */ + routingHost(payload, waypoints) { + const routing = this.currentUser?.getOption?.('routing') ?? {}; + if (routing.osrm_host && ['organization', 'system'].includes(routing.osrm_host_source)) { + return routing.osrm_host; + } + + return getRoutingHost(payload, waypoints); + } + async computeRoute(waypoints = [], options = {}) { const normalizedWaypoints = (isArray(waypoints) ? waypoints : []) .map(([lat, lng]) => [Number(lat), Number(lng)]) @@ -16,7 +32,7 @@ export default class OsrmService extends RouteOptimizationInterfaceService { throw new Error('At least 2 waypoints are required to compute a route.'); } - const routingHost = getRoutingHost(options.payload, options.waypoints); + const routingHost = this.routingHost(options.payload, options.waypoints); const coordStr = normalizedWaypoints.map(([lat, lng]) => `${lng},${lat}`).join(';'); const response = await fetch(`${routingHost}/route/v1/driving/${coordStr}?overview=full&geometries=polyline&steps=true`); @@ -55,7 +71,7 @@ export default class OsrmService extends RouteOptimizationInterfaceService { const source = 'first'; const destination = preserveEndpoints ? 'last' : 'any'; const roundtrip = false; // don’t loop back - const routingHost = getRoutingHost(payload, waypoints); + const routingHost = this.routingHost(payload, waypoints); try { const result = await this.fetch.routing(coordinates, { source, destination, roundtrip }, { host: routingHost, ...options }); diff --git a/addon/services/route-engine.js b/addon/services/route-engine.js index 70abc75b7..9dc865559 100644 --- a/addon/services/route-engine.js +++ b/addon/services/route-engine.js @@ -1,6 +1,7 @@ import Service, { inject as service } from '@ember/service'; import { tracked } from '@glimmer/tracking'; import { underscore } from '@ember/string'; +import isRoutingProviderAllowed from '../utils/routing-providers'; export class RouteEngineRegistry { @tracked engines = {}; @@ -11,7 +12,17 @@ export default class RouteEngineService extends Service { @service currentUser; registry = this.#initializeRegistry(); + /** + * Display engines the organization may choose: registered ones the administrator has not switched off. + */ get availableServices() { + return this.allServices.filter(({ key }) => this.isAllowed(key)); + } + + /** + * Every registered display engine, for admin screens that set platform defaults. + */ + get allServices() { return Object.entries(this.registry.engines) .filter(([, registration]) => registration?.capabilities?.display === true) .map(([key, registration]) => ({ @@ -20,6 +31,10 @@ export default class RouteEngineService extends Service { })); } + isAllowed(name) { + return isRoutingProviderAllowed(name, this.currentUser.getOption('routing', {})?.allowed_providers); + } + register(name, engine, capabilities = {}) { const key = underscore(name); const normalizedCapabilities = { @@ -47,19 +62,21 @@ export default class RouteEngineService extends Service { } get(name) { - const key = underscore(name ?? this.getDisplayEngine()); - const registration = this.registry.engines[key] ?? this.registry.engines.osrm; + // A switched-off engine is never used for map routing, even when named explicitly + const requested = name ?? this.getDisplayEngine(); + const key = underscore(this.isAllowed(requested) ? requested : this.getDisplayEngine()); + const registration = this.registry.engines[key] ?? this.registry.engines.valhalla ?? this.registry.engines.osrm; return registration?.engine ?? null; } - getDisplayEngine(fallback = 'osrm') { + getDisplayEngine(fallback = 'valhalla') { const routingSettings = this.currentUser.getOption('routing', {}); return routingSettings.routing_display_engine ?? routingSettings.router ?? fallback; } getOptimizationEngine(fallback = null) { const routingSettings = this.currentUser.getOption('routing', {}); - return routingSettings.routing_optimization_engine ?? routingSettings.router ?? this.getDisplayEngine(fallback ?? 'osrm'); + return routingSettings.routing_optimization_engine ?? routingSettings.router ?? this.getDisplayEngine(fallback ?? 'valhalla'); } async compute(name, waypoints, options = {}) { diff --git a/addon/services/route-optimization.js b/addon/services/route-optimization.js index bfca3fd31..183ece426 100644 --- a/addon/services/route-optimization.js +++ b/addon/services/route-optimization.js @@ -1,6 +1,7 @@ import Service, { inject as service } from '@ember/service'; import { tracked } from '@glimmer/tracking'; import { underscore } from '@ember/string'; +import isRoutingProviderAllowed from '../utils/routing-providers'; export class RouteOptimizationRegistry { @tracked engines = {}; @@ -8,19 +9,34 @@ export class RouteOptimizationRegistry { export default class RouteOptimizationService extends Service { @service universe; + @service currentUser; registry = this.#initializeRegistry(); get availableEngines() { - return Object.keys(this.registry.engines).map(underscore); + return this.availableServices.map(({ key }) => underscore(key)); } + /** + * Optimization engines the organization may choose: registered ones the administrator has not switched off. + */ get availableServices() { + return this.allServices.filter(({ key }) => this.isAllowed(key)); + } + + /** + * Every registered optimization engine, for admin screens that set platform defaults. + */ + get allServices() { return Object.entries(this.registry.engines).map(([key, engine]) => ({ key, name: engine.name ?? key, })); } + isAllowed(name) { + return isRoutingProviderAllowed(name, this.currentUser.getOption('routing', {})?.allowed_providers); + } + register(name, engine) { if (typeof engine.optimize !== 'function') { throw new Error(`Cannot register "${name}": missing optimize()`); @@ -40,6 +56,10 @@ export default class RouteOptimizationService extends Service { } optimize(name, params = {}, options = {}) { + if (!this.isAllowed(name)) { + return Promise.reject(new Error(`Route optimization engine "${name}" has been switched off by the administrator`)); + } + let engine = this.registry.engines[underscore(name)]; if (!engine) { return Promise.reject(new Error(`No route optimization engine registered as "${name}"`)); @@ -48,6 +68,10 @@ export default class RouteOptimizationService extends Service { } handler(name, context, data) { + if (!this.isAllowed(name)) { + throw new Error(`Route optimization engine "${name}" has been switched off by the administrator`); + } + let engine = this.registry.engines[underscore(name)]; if (!engine) { throw new Error(`No route optimization engine registered as "${name}"`); diff --git a/addon/templates/settings/navigator-app.hbs b/addon/templates/settings/navigator-app.hbs index c1f486519..037b9f569 100644 --- a/addon/templates/settings/navigator-app.hbs +++ b/addon/templates/settings/navigator-app.hbs @@ -5,6 +5,7 @@
+
diff --git a/addon/templates/settings/routing.hbs b/addon/templates/settings/routing.hbs index 0c08c6998..ae6ec4973 100644 --- a/addon/templates/settings/routing.hbs +++ b/addon/templates/settings/routing.hbs @@ -48,34 +48,23 @@ /> + {{#if this.usesOsrm}} + + + + + {{#if this.osrmUseOwnServer}} + + + + {{/if}} + + {{/if}} - -