From 2f2de4ada66508e43b5e3bd1377724dbe7399afb Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 6 Oct 2026 14:37:22 +0800 Subject: [PATCH 1/2] feat(tracking-page): organization settings for the customer tracking page Add the settings backend for the customer tracking page refactor (PR 2): - TrackingPageConfig: the company config's shape, defaults and sanitizing (pages, branding, access, what verified visitors see), instance defaults for the generic page, slug rules with a reserved list, and accent contrast helpers (ink colour and WCAG ratio). - SettingController: get/save tracking-page-settings, a live slug check, and admin defaults. Saved slugs are indexed in fleet-ops.tracking-page-slugs.* so the public page resolves a company in one read; an enabled organization page refuses an invalid, reserved or taken slug. The read-back includes the slug status, the accent's ink and contrast, whether SMS is configured (Twilio counts only with credentials), and whether the customer portal is installed. - Routes under settings/, and a tracking-page-settings permission resource. --- server/src/Auth/Schemas/FleetOps.php | 5 + .../Internal/v1/SettingController.php | 196 ++++++++++ .../TrackingPage/TrackingPageConfig.php | 259 +++++++++++++ server/src/routes.php | 5 + server/tests/TrackingPageSettingsTest.php | 343 ++++++++++++++++++ 5 files changed, 808 insertions(+) create mode 100644 server/src/Support/TrackingPage/TrackingPageConfig.php create mode 100644 server/tests/TrackingPageSettingsTest.php diff --git a/server/src/Auth/Schemas/FleetOps.php b/server/src/Auth/Schemas/FleetOps.php index 8a652e2f0..247e1da84 100644 --- a/server/src/Auth/Schemas/FleetOps.php +++ b/server/src/Auth/Schemas/FleetOps.php @@ -229,6 +229,11 @@ class FleetOps 'actions' => [], 'remove_actions' => ['delete', 'export', 'create'], ], + [ + 'name' => 'tracking-page-settings', // the customer tracking page: branding, access and what verified visitors see + 'actions' => [], + 'remove_actions' => ['delete', 'export', 'create'], + ], [ 'name' => 'telematics-settings', // organization device event and position history preferences 'action' => [], diff --git a/server/src/Http/Controllers/Internal/v1/SettingController.php b/server/src/Http/Controllers/Internal/v1/SettingController.php index 296363a07..3699c4bf7 100644 --- a/server/src/Http/Controllers/Internal/v1/SettingController.php +++ b/server/src/Http/Controllers/Internal/v1/SettingController.php @@ -5,10 +5,13 @@ use Fleetbase\FleetOps\Jobs\DispatchTelematicsRetentionJobs; use Fleetbase\FleetOps\Support\Telematics\Retention\RetentionPolicy; use Fleetbase\FleetOps\Support\Telematics\Telemetry\Queue; +use Fleetbase\FleetOps\Support\TrackingPage\TrackingPageConfig; +use Fleetbase\FleetOps\Support\Utils; use Fleetbase\FleetOps\Tracking\TrackingProviderRegistry; use Fleetbase\FleetOps\Traits\AuthorizesMethods; use Fleetbase\Http\Controllers\Controller; use Fleetbase\Models\Setting; +use Fleetbase\Services\SmsService; use Fleetbase\Support\Auth; use Fleetbase\Support\NotificationRegistry; use Illuminate\Database\MySqlConnection; @@ -47,6 +50,11 @@ public function __construct() 'saveTelematicsSettings' => 'update telematics-settings', 'getTelematicsStorageUsage' => 'admin', 'runTelematicsRetention' => 'admin', + 'getTrackingPageSettings' => 'view tracking-page-settings', + 'saveTrackingPageSettings' => 'update tracking-page-settings', + 'validateTrackingPageSlug' => 'update tracking-page-settings', + 'getAdminTrackingPageSettings' => 'admin', + 'saveAdminTrackingPageSettings' => 'admin', ]); } @@ -1074,6 +1082,194 @@ protected function storageTableStatistics(array $tables): array return $statistics; } + /** + * The company's customer tracking page settings, with what the settings screen needs to + * explain them: whether the slug is usable, whether SMS can be sent, and whether the + * customer portal is there for sign-in. + * + * @return \Illuminate\Http\JsonResponse + */ + public function getTrackingPageSettings() + { + return response()->json($this->trackingPageView($this->trackingPageConfig())); + } + + /** + * Save the company's tracking page settings. + * + * The slug is indexed so the public page can find the company in one read. A slug that + * is invalid, reserved or taken can't be saved while the organization page is on; while + * it is off, the slug is kept but not indexed. + * + * @return \Illuminate\Http\JsonResponse + */ + public function saveTrackingPageSettings(Request $request) + { + $previous = $this->trackingPageConfig(); + $config = TrackingPageConfig::sanitize((array) $request->input('trackingPage', []), $this->trackingPageAdminConfig(), $this->trackingPageCompanyName()); + $slug = $config['org_page']['slug']; + $validation = $this->trackingPageSlugValidation($slug); + + if ($config['org_page']['enabled'] && !$validation['valid']) { + return response()->error($validation['message'], 422); + } + + if ($validation['valid']) { + $previousSlug = $previous['org_page']['slug']; + if ($previousSlug !== $slug && $this->trackingPageSlugOwner($previousSlug) === $this->trackingPageCompanyUuid()) { + $this->configureSetting(TrackingPageConfig::SLUG_INDEX_PREFIX . $previousSlug, null); + } + + $this->configureSetting(TrackingPageConfig::SLUG_INDEX_PREFIX . $slug, $this->trackingPageCompanyUuid()); + } + + $this->configureCompanySetting(TrackingPageConfig::SETTING_KEY, $config); + + return response()->json($this->trackingPageView($config)); + } + + /** + * Check a slug while it is typed. + * + * @return \Illuminate\Http\JsonResponse + */ + public function validateTrackingPageSlug(Request $request) + { + $slug = strtolower(trim((string) $request->input('slug', ''))); + + return response()->json(array_merge(['slug' => $slug], $this->trackingPageSlugValidation($slug))); + } + + /** + * Instance-wide defaults for the generic tracking page. + * + * @return \Illuminate\Http\JsonResponse + */ + public function getAdminTrackingPageSettings() + { + return response()->json($this->trackingPageAdminConfig()); + } + + /** + * Save the instance-wide defaults for the generic tracking page. + * + * @return \Illuminate\Http\JsonResponse + */ + public function saveAdminTrackingPageSettings(Request $request) + { + $config = TrackingPageConfig::sanitizeAdmin((array) $request->input('trackingPage', [])); + $this->configureSetting(TrackingPageConfig::ADMIN_SETTING_KEY, $config); + + return response()->json($config); + } + + protected function trackingPageConfig(): array + { + return TrackingPageConfig::sanitize( + (array) $this->lookupCompanySetting(TrackingPageConfig::SETTING_KEY, []), + $this->trackingPageAdminConfig(), + $this->trackingPageCompanyName() + ); + } + + protected function trackingPageAdminConfig(): array + { + return TrackingPageConfig::sanitizeAdmin((array) $this->lookupSetting(TrackingPageConfig::ADMIN_SETTING_KEY, [])); + } + + /** + * The saved config plus the read-only facts the settings screen shows beside it. + */ + protected function trackingPageView(array $config): array + { + $accent = $config['branding']['accent']; + + return array_merge($config, [ + 'slug_validation' => $this->trackingPageSlugValidation($config['org_page']['slug']), + 'accent_ink' => TrackingPageConfig::inkFor($accent), + 'accent_contrast' => TrackingPageConfig::contrastRatio($accent, TrackingPageConfig::inkFor($accent)), + 'sms_available' => $this->smsProviderConfigured(), + 'customer_portal_installed' => $this->customerPortalInstalled(), + 'admin' => $this->trackingPageAdminConfig(), + ]); + } + + /** + * @return array{valid: bool, code: string, message: string} + */ + protected function trackingPageSlugValidation(string $slug): array + { + $error = TrackingPageConfig::slugError($slug); + if ($error === 'invalid') { + return ['valid' => false, 'code' => 'invalid', 'message' => 'Use 3 to 40 lowercase letters, numbers and single hyphens, starting and ending with a letter or number.']; + } + + if ($error === 'reserved') { + return ['valid' => false, 'code' => 'reserved', 'message' => 'This address is reserved. Please choose another.']; + } + + $owner = $this->trackingPageSlugOwner($slug); + if ($owner !== null && $owner !== $this->trackingPageCompanyUuid()) { + return ['valid' => false, 'code' => 'taken', 'message' => 'Another organization already uses this address.']; + } + + return ['valid' => true, 'code' => 'available', 'message' => 'This address is available.']; + } + + protected function trackingPageSlugOwner(string $slug): ?string + { + $owner = $this->lookupSetting(TrackingPageConfig::SLUG_INDEX_PREFIX . $slug); + + return is_string($owner) && $owner !== '' ? $owner : null; + } + + protected function trackingPageCompanyUuid(): ?string + { + return data_get($this->currentCompany(), 'uuid'); + } + + protected function trackingPageCompanyName(): ?string + { + return data_get($this->currentCompany(), 'name'); + } + + /** + * Whether the instance can send SMS. Twilio is listed as always available, so it counts + * only with credentials; other providers report their own configuration. + */ + protected function smsProviderConfigured(): bool + { + if (filled(config('services.twilio.sid')) && filled(config('services.twilio.token'))) { + return true; + } + + return collect($this->smsProviders()) + ->except(SmsService::PROVIDER_TWILIO) + ->contains(fn ($provider) => (bool) data_get($provider, 'available')); + } + + protected function smsProviders(): array + { + // @codeCoverageIgnoreStart + // Reads the live SMS provider configuration; tests replace this seam. + return (new SmsService())->getAvailableProviders(); + // @codeCoverageIgnoreEnd + } + + protected function customerPortalInstalled(): bool + { + return collect($this->installedFleetbaseExtensions()) + ->contains(fn ($package) => data_get($package, 'name') === 'fleetbase/customer-portal-api'); + } + + protected function installedFleetbaseExtensions(): array + { + // @codeCoverageIgnoreStart + // Reads the installed composer packages; tests replace this seam. + return Utils::getInstalledFleetbaseExtensions(); + // @codeCoverageIgnoreEnd + } + protected function dispatchTelematicsPrune(): void { Queue::dispatch(new DispatchTelematicsRetentionJobs()); diff --git a/server/src/Support/TrackingPage/TrackingPageConfig.php b/server/src/Support/TrackingPage/TrackingPageConfig.php new file mode 100644 index 000000000..783965d07 --- /dev/null +++ b/server/src/Support/TrackingPage/TrackingPageConfig.php @@ -0,0 +1,259 @@ + [ + 'enabled' => static::bool(data_get($input, 'generic_page.enabled'), true), + ], + 'branding' => [ + 'display_name' => static::text(data_get($input, 'branding.display_name'), 80), + 'accent' => static::color(data_get($input, 'branding.accent')) ?? static::DEFAULT_ACCENT, + 'support_phone' => static::text(data_get($input, 'branding.support_phone'), 32), + 'support_email' => static::email(data_get($input, 'branding.support_email')), + 'website' => static::url(data_get($input, 'branding.website')), + 'powered_by' => static::bool(data_get($input, 'branding.powered_by'), true), + ], + ]; + } + + /** + * A company's config, with every key present and every value valid. + * + * @param array $admin the sanitized instance defaults + * @param string|null $companyName used for the default slug + */ + public static function sanitize(array $input, array $admin = [], ?string $companyName = null): array + { + $admin = static::sanitizeAdmin($admin); + $locale = static::oneOf(data_get($input, 'branding.default_locale'), static::LOCALES, 'en-us'); + + return [ + 'org_page' => [ + 'enabled' => static::bool(data_get($input, 'org_page.enabled'), false), + 'slug' => static::normalizeSlug(data_get($input, 'org_page.slug')) ?: static::slugify((string) $companyName), + ], + 'generic_page' => [ + 'allowed' => static::bool(data_get($input, 'generic_page.allowed'), true), + ], + 'links' => [ + 'target' => static::oneOf(data_get($input, 'links.target'), ['auto', 'org', 'generic'], 'auto'), + ], + 'branding' => [ + 'display_name' => static::text(data_get($input, 'branding.display_name'), 80), + 'logo_uuid' => static::text(data_get($input, 'branding.logo_uuid'), 64), + 'accent' => static::color(data_get($input, 'branding.accent')) ?? $admin['branding']['accent'], + 'accent_dark' => static::color(data_get($input, 'branding.accent_dark')), + 'support_phone' => static::text(data_get($input, 'branding.support_phone'), 32), + 'support_email' => static::email(data_get($input, 'branding.support_email')), + 'website' => static::url(data_get($input, 'branding.website')), + 'powered_by' => static::bool(data_get($input, 'branding.powered_by'), $admin['branding']['powered_by']), + 'theme' => static::oneOf(data_get($input, 'branding.theme'), ['system', 'light'], 'system'), + 'default_locale' => $locale, + 'locales' => static::locales(data_get($input, 'branding.locales'), $locale), + ], + 'access' => [ + 'public_status' => static::bool(data_get($input, 'access.public_status'), true), + 'channels' => [ + 'sms' => static::bool(data_get($input, 'access.channels.sms'), true), + 'email' => static::bool(data_get($input, 'access.channels.email'), true), + ], + 'session_hours' => static::clampInt(data_get($input, 'access.session_hours'), 1, 168, 24), + 'account_sign_in' => static::bool(data_get($input, 'access.account_sign_in'), true), + 'account_upsell' => static::bool(data_get($input, 'access.account_upsell'), true), + ], + 'visibility' => [ + 'map' => static::bool(data_get($input, 'visibility.map'), true), + 'driver_name' => static::bool(data_get($input, 'visibility.driver_name'), true), + 'vehicle' => static::bool(data_get($input, 'visibility.vehicle'), true), + 'driver_contact' => static::oneOf(data_get($input, 'visibility.driver_contact'), ['company', 'driver'], 'company'), + 'items' => static::bool(data_get($input, 'visibility.items'), true), + 'item_prices' => static::bool(data_get($input, 'visibility.item_prices'), false), + 'pod_photo' => static::bool(data_get($input, 'visibility.pod_photo'), true), + 'pod_signature' => static::bool(data_get($input, 'visibility.pod_signature'), true), + 'instructions_edit' => static::bool(data_get($input, 'visibility.instructions_edit'), false), + 'report_problem' => static::bool(data_get($input, 'visibility.report_problem'), true), + ], + ]; + } + + /** + * Why a slug can't be used, or null when its shape is fine. Availability is the caller's check. + * + * @return string|null `invalid` or `reserved` + */ + public static function slugError(?string $slug): ?string + { + if (!is_string($slug) || preg_match('/^[a-z0-9](?:[a-z0-9-]{1,38})[a-z0-9]$/', $slug) !== 1 || str_contains($slug, '--')) { + return 'invalid'; + } + + return in_array($slug, static::RESERVED_SLUGS, true) ? 'reserved' : null; + } + + /** + * A slug made from a company name: lowercase, hyphenated, 3 to 40 characters. + */ + public static function slugify(string $name): string + { + $slug = trim(Str::limit(Str::slug($name), 40, ''), '-'); + + if (strlen($slug) >= 3) { + return $slug; + } + + return $slug === '' ? 'company-tracking' : $slug . '-tracking'; + } + + /** + * The text colour readable on an accent: white when it reaches 4.5:1, otherwise dark. + */ + public static function inkFor(string $accent): string + { + return static::contrastRatio($accent, static::LIGHT_INK) >= 4.5 ? static::LIGHT_INK : static::DARK_INK; + } + + /** + * The WCAG contrast ratio of two `#RRGGBB` colours. + */ + public static function contrastRatio(string $a, string $b): float + { + $lighter = max(static::luminance($a), static::luminance($b)); + $darker = min(static::luminance($a), static::luminance($b)); + + return round(($lighter + 0.05) / ($darker + 0.05), 2); + } + + protected static function luminance(string $hex): float + { + $channels = array_map(function (string $pair) { + $value = hexdec($pair) / 255; + + return $value <= 0.03928 ? $value / 12.92 : (($value + 0.055) / 1.055) ** 2.4; + }, str_split(ltrim($hex, '#'), 2)); + + return 0.2126 * $channels[0] + 0.7152 * $channels[1] + 0.0722 * $channels[2]; + } + + protected static function normalizeSlug(mixed $value): string + { + return is_string($value) ? strtolower(trim($value)) : ''; + } + + protected static function bool(mixed $value, bool $default): bool + { + if (is_bool($value)) { + return $value; + } + + return filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? $default; + } + + protected static function text(mixed $value, int $max): ?string + { + if (!is_string($value) && !is_numeric($value)) { + return null; + } + + $value = trim((string) $value); + + return $value === '' ? null : mb_substr($value, 0, $max); + } + + protected static function email(mixed $value): ?string + { + $value = static::text($value, 191); + + return $value !== null && filter_var($value, FILTER_VALIDATE_EMAIL) ? $value : null; + } + + protected static function url(mixed $value): ?string + { + $value = static::text($value, 255); + + return $value !== null && preg_match('#^https?://#i', $value) === 1 && filter_var($value, FILTER_VALIDATE_URL) ? $value : null; + } + + /** + * `#RGB` or `#RRGGBB` as uppercase `#RRGGBB`, or null. + */ + protected static function color(mixed $value): ?string + { + if (!is_string($value) || preg_match('/^#?([0-9a-f]{3}|[0-9a-f]{6})$/i', trim($value), $match) !== 1) { + return null; + } + + $hex = $match[1]; + if (strlen($hex) === 3) { + $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2]; + } + + return '#' . strtoupper($hex); + } + + protected static function oneOf(mixed $value, array $allowed, string $default): string + { + return is_string($value) && in_array($value, $allowed, true) ? $value : $default; + } + + protected static function clampInt(mixed $value, int $min, int $max, int $default): int + { + if (!is_numeric($value)) { + return $default; + } + + return max($min, min($max, (int) $value)); + } + + /** + * The enabled locales, always including the default one; every locale when none are valid. + */ + protected static function locales(mixed $value, string $default): array + { + $locales = array_values(array_intersect(static::LOCALES, is_array($value) ? $value : [])); + if ($locales === []) { + return static::LOCALES; + } + + return in_array($default, $locales, true) ? $locales : array_merge([$default], $locales); + } +} diff --git a/server/src/routes.php b/server/src/routes.php index dd757f07c..791078a04 100644 --- a/server/src/routes.php +++ b/server/src/routes.php @@ -806,6 +806,11 @@ function ($router) { $router->post('tracking-settings', 'SettingController@saveTrackingSettings'); $router->get('admin-tracking-settings', 'SettingController@getAdminTrackingSettings'); $router->post('admin-tracking-settings', 'SettingController@saveAdminTrackingSettings'); + $router->get('tracking-page-settings', 'SettingController@getTrackingPageSettings'); + $router->post('tracking-page-settings', 'SettingController@saveTrackingPageSettings'); + $router->post('tracking-page-settings/validate-slug', 'SettingController@validateTrackingPageSlug'); + $router->get('admin-tracking-page-settings', 'SettingController@getAdminTrackingPageSettings'); + $router->post('admin-tracking-page-settings', 'SettingController@saveAdminTrackingPageSettings'); $router->get('map', 'SettingController@getMapSettings'); $router->post('map', 'SettingController@saveMapSettings'); $router->get('admin-map', 'SettingController@getAdminMapSettings'); diff --git a/server/tests/TrackingPageSettingsTest.php b/server/tests/TrackingPageSettingsTest.php new file mode 100644 index 000000000..9a027c73c --- /dev/null +++ b/server/tests/TrackingPageSettingsTest.php @@ -0,0 +1,343 @@ +configured[] = [$key, $value]; + $this->settings[$key] = $value; + + return null; + } + + protected function configureCompanySetting(string $key, mixed $value): mixed + { + $this->companySettings[$key] = $value; + + return null; + } + + protected function lookupSetting(string $key, mixed $defaultValue = null): mixed + { + return $this->settings[$key] ?? $defaultValue; + } + + protected function lookupCompanySetting(string $key, mixed $defaultValue = null): mixed + { + return $this->companySettings[$key] ?? $defaultValue; + } + + protected function currentCompany(): mixed + { + return $this->company; + } + + protected function smsProviders(): array + { + return $this->smsProviders; + } + + protected function installedFleetbaseExtensions(): array + { + return $this->extensions; + } +} + +function fleetopsTrackingPageProbe(): FleetOpsTrackingPageSettingProbe +{ + $probe = new FleetOpsTrackingPageSettingProbe(); + $probe->company = (object) ['uuid' => 'company-1', 'name' => 'Northwind Couriers']; + + config(['services.twilio.sid' => null, 'services.twilio.token' => null]); + + return $probe; +} + +function fleetopsTrackingPageRequest(array $trackingPage): Request +{ + return new Request(['trackingPage' => $trackingPage]); +} + +test('tracking page config fills every key with defaults for an empty company setting', function () { + $config = TrackingPageConfig::sanitize([], [], 'Northwind Couriers'); + + expect($config['org_page'])->toBe(['enabled' => false, 'slug' => 'northwind-couriers']) + ->and($config['generic_page'])->toBe(['allowed' => true]) + ->and($config['links'])->toBe(['target' => 'auto']) + ->and($config['branding'])->toBe([ + 'display_name' => null, + 'logo_uuid' => null, + 'accent' => '#1F5FA8', + 'accent_dark' => null, + 'support_phone' => null, + 'support_email' => null, + 'website' => null, + 'powered_by' => true, + 'theme' => 'system', + 'default_locale' => 'en-us', + 'locales' => TrackingPageConfig::LOCALES, + ]) + ->and($config['access'])->toBe([ + 'public_status' => true, + 'channels' => ['sms' => true, 'email' => true], + 'session_hours' => 24, + 'account_sign_in' => true, + 'account_upsell' => true, + ]) + ->and($config['visibility'])->toBe([ + 'map' => true, + 'driver_name' => true, + 'vehicle' => true, + 'driver_contact' => 'company', + 'items' => true, + 'item_prices' => false, + 'pod_photo' => true, + 'pod_signature' => true, + 'instructions_edit' => false, + 'report_problem' => true, + ]); +}); + +test('tracking page config keeps valid values and replaces invalid ones', function () { + $config = TrackingPageConfig::sanitize([ + 'org_page' => ['enabled' => 'true', 'slug' => ' Northwind '], + 'generic_page' => ['allowed' => '0'], + 'links' => ['target' => 'org'], + 'branding' => [ + 'display_name' => ' Northwind ', + 'logo_uuid' => 'file-uuid', + 'accent' => '0b6', + 'accent_dark' => '#5cc9be', + 'support_phone' => 5105550142, + 'support_email' => 'help@northwind.example', + 'website' => 'https://northwind.example', + 'powered_by' => false, + 'theme' => 'light', + 'default_locale' => 'de-de', + 'locales' => ['fr-fr', 'xx-xx'], + 'unknown' => 'dropped', + ], + 'access' => ['public_status' => 'no', 'channels' => ['sms' => 'maybe', 'email' => false], 'session_hours' => 500], + 'visibility' => ['driver_contact' => 'driver', 'item_prices' => 1, 'map' => 'off'], + 'extra' => ['dropped' => true], + ], ['branding' => ['accent' => '#123456', 'powered_by' => false]], 'Ignored'); + + expect($config['org_page'])->toBe(['enabled' => true, 'slug' => 'northwind']) + ->and($config['generic_page']['allowed'])->toBeFalse() + ->and($config['links']['target'])->toBe('org') + ->and($config['branding']['display_name'])->toBe('Northwind') + ->and($config['branding']['logo_uuid'])->toBe('file-uuid') + ->and($config['branding']['accent'])->toBe('#00BB66') + ->and($config['branding']['accent_dark'])->toBe('#5CC9BE') + ->and($config['branding']['support_phone'])->toBe('5105550142') + ->and($config['branding']['support_email'])->toBe('help@northwind.example') + ->and($config['branding']['website'])->toBe('https://northwind.example') + ->and($config['branding']['powered_by'])->toBeFalse() + ->and($config['branding']['theme'])->toBe('light') + ->and($config['branding']['default_locale'])->toBe('en-us') + ->and($config['branding']['locales'])->toBe(['en-us', 'fr-fr']) + ->and($config['branding'])->not->toHaveKey('unknown') + ->and($config)->not->toHaveKey('extra') + ->and($config['access']['public_status'])->toBeFalse() + ->and($config['access']['channels'])->toBe(['sms' => true, 'email' => false]) + ->and($config['access']['session_hours'])->toBe(168) + ->and($config['visibility']['driver_contact'])->toBe('driver') + ->and($config['visibility']['item_prices'])->toBeTrue() + ->and($config['visibility']['map'])->toBeFalse(); + + $fallback = TrackingPageConfig::sanitize([ + 'branding' => [ + 'accent' => 'teal', + 'accent_dark' => ['#000000'], + 'support_email' => 'not-an-email', + 'website' => 'ftp://northwind.example', + 'display_name' => ' ', + 'locales' => ['ar-ae', 'en-us'], + ], + 'access' => ['session_hours' => 'soon'], + ], ['branding' => ['accent' => '#123456', 'powered_by' => false]], 'N'); + + expect($fallback['org_page']['slug'])->toBe('n-tracking') + ->and($fallback['branding']['accent'])->toBe('#123456') + ->and($fallback['branding']['accent_dark'])->toBeNull() + ->and($fallback['branding']['support_email'])->toBeNull() + ->and($fallback['branding']['website'])->toBeNull() + ->and($fallback['branding']['display_name'])->toBeNull() + ->and($fallback['branding']['powered_by'])->toBeFalse() + ->and($fallback['branding']['locales'])->toBe(['en-us', 'ar-ae']) + ->and($fallback['access']['session_hours'])->toBe(24) + ->and(TrackingPageConfig::sanitize(['access' => ['session_hours' => 0]])['access']['session_hours'])->toBe(1) + ->and(TrackingPageConfig::sanitize([])['org_page']['slug'])->toBe('company-tracking'); +}); + +test('tracking page admin defaults are sanitized on their own', function () { + expect(TrackingPageConfig::sanitizeAdmin([]))->toBe([ + 'generic_page' => ['enabled' => true], + 'branding' => [ + 'display_name' => null, + 'accent' => '#1F5FA8', + 'support_phone' => null, + 'support_email' => null, + 'website' => null, + 'powered_by' => true, + ], + ])->and(TrackingPageConfig::sanitizeAdmin([ + 'generic_page' => ['enabled' => false], + 'branding' => ['display_name' => 'Fleetbase Cloud', 'accent' => '#abcdef', 'support_email' => 'ops@fleetbase.example'], + ]))->toBe([ + 'generic_page' => ['enabled' => false], + 'branding' => [ + 'display_name' => 'Fleetbase Cloud', + 'accent' => '#ABCDEF', + 'support_phone' => null, + 'support_email' => 'ops@fleetbase.example', + 'website' => null, + 'powered_by' => true, + ], + ]); +}); + +test('tracking page slugs are checked for shape and reserved words', function () { + expect(TrackingPageConfig::slugError('northwind'))->toBeNull() + ->and(TrackingPageConfig::slugError('north-wind-2'))->toBeNull() + ->and(TrackingPageConfig::slugError('ab'))->toBe('invalid') + ->and(TrackingPageConfig::slugError(str_repeat('a', 41)))->toBe('invalid') + ->and(TrackingPageConfig::slugError('-northwind'))->toBe('invalid') + ->and(TrackingPageConfig::slugError('north--wind'))->toBe('invalid') + ->and(TrackingPageConfig::slugError('North'))->toBe('invalid') + ->and(TrackingPageConfig::slugError(null))->toBe('invalid') + ->and(TrackingPageConfig::slugError('track'))->toBe('reserved') + ->and(TrackingPageConfig::slugError('customer-portal'))->toBe('reserved') + ->and(TrackingPageConfig::slugify(str_repeat('Northwind ', 8)))->toHaveLength(39) + ->and(TrackingPageConfig::slugify('Northwind Couriers, Inc.'))->toBe('northwind-couriers-inc'); +}); + +test('tracking page accents get a readable ink and a contrast ratio', function () { + expect(TrackingPageConfig::inkFor('#0B6E68'))->toBe('#FFFFFF') + ->and(TrackingPageConfig::inkFor('#5CC9BE'))->toBe('#14191A') + ->and(TrackingPageConfig::contrastRatio('#FFFFFF', '#000000'))->toBe(21.0) + ->and(TrackingPageConfig::contrastRatio('#1F5FA8', '#FFFFFF'))->toBeGreaterThan(4.5) + ->and(TrackingPageConfig::contrastRatio('#FFFFFF', '#FFFFFF'))->toBe(1.0); +}); + +test('tracking page settings read back with what the settings screen explains', function () { + $probe = fleetopsTrackingPageProbe(); + + $view = $probe->getTrackingPageSettings()->getData(true); + + expect($view['org_page'])->toBe(['enabled' => false, 'slug' => 'northwind-couriers']) + ->and($view['slug_validation'])->toBe(['valid' => true, 'code' => 'available', 'message' => 'This address is available.']) + ->and($view['accent_ink'])->toBe('#FFFFFF') + ->and($view['accent_contrast'])->toBeGreaterThan(4.5) + ->and($view['sms_available'])->toBeFalse() + ->and($view['customer_portal_installed'])->toBeFalse() + ->and($view['admin']['generic_page'])->toBe(['enabled' => true]); + + $probe->extensions = [['name' => 'fleetbase/customer-portal-api']]; + $probe->smsProviders = ['twilio' => ['available' => true], 'vonage' => ['available' => true]]; + + expect($probe->getTrackingPageSettings()->getData(true)) + ->toMatchArray(['sms_available' => true, 'customer_portal_installed' => true]); + + $probe->smsProviders = ['twilio' => ['available' => true]]; + expect($probe->getTrackingPageSettings()->getData(true)['sms_available'])->toBeFalse(); + + config(['services.twilio.sid' => 'AC123', 'services.twilio.token' => 'secret']); + expect($probe->getTrackingPageSettings()->getData(true)['sms_available'])->toBeTrue(); +}); + +test('saving tracking page settings indexes the slug and frees the old one', function () { + $probe = fleetopsTrackingPageProbe(); + + $saved = $probe->saveTrackingPageSettings(fleetopsTrackingPageRequest([ + 'org_page' => ['enabled' => true, 'slug' => 'northwind'], + 'branding' => ['accent' => '#0B6E68'], + ]))->getData(true); + + expect($saved['org_page'])->toBe(['enabled' => true, 'slug' => 'northwind']) + ->and($saved['branding']['accent'])->toBe('#0B6E68') + ->and($probe->companySettings[TrackingPageConfig::SETTING_KEY]['org_page']['slug'])->toBe('northwind') + ->and($probe->settings[TrackingPageConfig::SLUG_INDEX_PREFIX . 'northwind'])->toBe('company-1') + ->and($probe->configured)->toBe([[TrackingPageConfig::SLUG_INDEX_PREFIX . 'northwind', 'company-1']]); + + $probe->saveTrackingPageSettings(fleetopsTrackingPageRequest(['org_page' => ['enabled' => true, 'slug' => 'northwind-couriers']])); + + expect($probe->settings[TrackingPageConfig::SLUG_INDEX_PREFIX . 'northwind'])->toBeNull() + ->and($probe->settings[TrackingPageConfig::SLUG_INDEX_PREFIX . 'northwind-couriers'])->toBe('company-1'); + + // A previous slug that another company now holds is left alone. + $probe->settings[TrackingPageConfig::SLUG_INDEX_PREFIX . 'northwind-couriers'] = 'company-2'; + $probe->configured = []; + $probe->saveTrackingPageSettings(fleetopsTrackingPageRequest(['org_page' => ['enabled' => false, 'slug' => 'northwind-express']])); + + expect($probe->configured)->toBe([[TrackingPageConfig::SLUG_INDEX_PREFIX . 'northwind-express', 'company-1']]) + ->and($probe->settings[TrackingPageConfig::SLUG_INDEX_PREFIX . 'northwind-couriers'])->toBe('company-2'); +}); + +test('an enabled organization page refuses an unusable slug while a disabled one keeps it unindexed', function () { + $probe = fleetopsTrackingPageProbe(); + $probe->settings[TrackingPageConfig::SLUG_INDEX_PREFIX . 'taken-slug'] = 'company-2'; + + expect($probe->saveTrackingPageSettings(fleetopsTrackingPageRequest(['org_page' => ['enabled' => true, 'slug' => 'taken-slug']]))->getData(true)) + ->toBe(['error' => 'Another organization already uses this address.']) + ->and($probe->saveTrackingPageSettings(fleetopsTrackingPageRequest(['org_page' => ['enabled' => true, 'slug' => 'track']]))->getData(true)) + ->toBe(['error' => 'This address is reserved. Please choose another.']) + ->and($probe->saveTrackingPageSettings(fleetopsTrackingPageRequest(['org_page' => ['enabled' => true, 'slug' => 'a b']]))->getData(true)['error']) + ->toStartWith('Use 3 to 40 lowercase letters') + ->and($probe->companySettings)->toBe([]); + + $saved = $probe->saveTrackingPageSettings(fleetopsTrackingPageRequest(['org_page' => ['enabled' => false, 'slug' => 'taken-slug']]))->getData(true); + + expect($saved['org_page'])->toBe(['enabled' => false, 'slug' => 'taken-slug']) + ->and($saved['slug_validation']['code'])->toBe('taken') + ->and($probe->configured)->toBe([]) + ->and($probe->companySettings[TrackingPageConfig::SETTING_KEY]['org_page']['slug'])->toBe('taken-slug'); +}); + +test('tracking page slugs can be checked while typed', function () { + $probe = fleetopsTrackingPageProbe(); + $probe->settings[TrackingPageConfig::SLUG_INDEX_PREFIX . 'northwind'] = 'company-1'; + $probe->settings[TrackingPageConfig::SLUG_INDEX_PREFIX . 'harbor'] = 'company-2'; + + expect($probe->validateTrackingPageSlug(new Request(['slug' => ' Northwind ']))->getData(true)) + ->toBe(['slug' => 'northwind', 'valid' => true, 'code' => 'available', 'message' => 'This address is available.']) + ->and($probe->validateTrackingPageSlug(new Request(['slug' => 'harbor']))->getData(true)['code'])->toBe('taken') + ->and($probe->validateTrackingPageSlug(new Request(['slug' => 'admin']))->getData(true)['code'])->toBe('reserved') + ->and($probe->validateTrackingPageSlug(new Request())->getData(true)['code'])->toBe('invalid'); + + $probe->settings[TrackingPageConfig::SLUG_INDEX_PREFIX . 'empty-owner'] = ''; + expect($probe->validateTrackingPageSlug(new Request(['slug' => 'empty-owner']))->getData(true)['valid'])->toBeTrue(); +}); + +test('admin tracking page defaults are read and saved sanitized', function () { + $probe = fleetopsTrackingPageProbe(); + + expect($probe->getAdminTrackingPageSettings()->getData(true)['generic_page'])->toBe(['enabled' => true]); + + $saved = $probe->saveAdminTrackingPageSettings(fleetopsTrackingPageRequest([ + 'generic_page' => ['enabled' => false], + 'branding' => ['accent' => 'nope', 'website' => 'https://fleetbase.example'], + ]))->getData(true); + + expect($saved['generic_page'])->toBe(['enabled' => false]) + ->and($saved['branding']['accent'])->toBe('#1F5FA8') + ->and($saved['branding']['website'])->toBe('https://fleetbase.example') + ->and($probe->settings[TrackingPageConfig::ADMIN_SETTING_KEY])->toBe($saved) + ->and($probe->getAdminTrackingPageSettings()->getData(true))->toBe($saved) + ->and($probe->getTrackingPageSettings()->getData(true)['admin'])->toBe($saved); +}); From cab38a60400813af074e76e11b92756c8276964c Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 6 Oct 2026 14:49:35 +0800 Subject: [PATCH 2/2] fix(tracking-page): fall back to defaults for missing booleans filter_var(null, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) returns false, not null, so every unset toggle (generic page allowed, powered by, the visibility defaults) came out false instead of its default. --- server/src/Support/TrackingPage/TrackingPageConfig.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/server/src/Support/TrackingPage/TrackingPageConfig.php b/server/src/Support/TrackingPage/TrackingPageConfig.php index 783965d07..646c79de1 100644 --- a/server/src/Support/TrackingPage/TrackingPageConfig.php +++ b/server/src/Support/TrackingPage/TrackingPageConfig.php @@ -185,6 +185,11 @@ protected static function bool(mixed $value, bool $default): bool return $value; } + // filter_var() reads null as false, so a missing value must fall back explicitly. + if ($value === null || $value === '') { + return $default; + } + return filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? $default; }