From 9b72307e853b84240417cf009e67e4032d82aa65 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 18 Sep 2026 12:22:51 +0800 Subject: [PATCH 01/19] fix(ai): repair resource search, add AI tools and console commands Hotfixes found while auditing the AI extension against production logs, where `search_resources` failed on every call and leaked the SQL error to the provider: - `Sensor` and the resource search referenced `sensor_type`, a column dropped by an earlier migration. Use `type`. - Search each resource type in its own try/catch so one failure no longer takes down the whole search, and report it as an unavailable search instead. - Build search terms from reference-like tokens only: never run LIKE against `status`, `type` or `uuid`, and skip the search when nothing useful remains. - Convert order amount thresholds from dollars to minor units using the currency exponent, and expose the currency on insights. - Scope the AI capability queries with `applyDirectivesForPermissions` so answers stay within what the user may see. Adds the tool-calling surface the AI extension now drives: - `propose_create_order` and `fleetops_search` tools, registered when the AI extension is installed. - `FleetOpsAiConsoleCommands`: navigate, create, view and import commands for orders, drivers, vehicles, contacts, customers, places, fleets, issues, work orders, maintenances and the Fleet-Ops settings pages. The AI may only propose these; the user confirms and the server re-authorizes before anything runs. --- scripts/pest-bootstrap.php | 12 ++ server/src/Models/Sensor.php | 8 +- .../src/Providers/FleetOpsServiceProvider.php | 13 ++ .../AbstractFleetOpsAICapability.php | 67 +++++++- .../Ai/Capabilities/AssetStatusCapability.php | 29 +++- .../CreateOrderPreviewCapability.php | 8 +- .../Capabilities/OrderInsightsCapability.php | 47 ++++- .../SearchResourcesCapability.php | 110 +++++++++--- .../Support/Ai/FleetOpsAiConsoleCommands.php | 122 +++++++++++++ .../src/Support/Ai/Tools/CreateOrderTool.php | 86 ++++++++++ .../Support/Ai/Tools/SearchResourcesTool.php | 73 ++++++++ .../AiOperationalQueryCapabilityTest.php | 1 + server/tests/SupportJobAndAiCoverageTest.php | 6 +- server/tests/Unit/Models/SensorTest.php | 4 +- .../Providers/FleetOpsAiRegistrationTest.php | 15 +- .../Ai/AssetStatusCapabilityQueriesTest.php | 42 +++++ .../OrderInsightsCapabilityTest.php | 20 ++- .../SearchResourcesCapabilityTest.php | 86 +++++++++- .../Ai/FleetOpsAiConsoleCommandsTest.php | 31 ++++ .../Support/Ai/Tools/FleetOpsAiToolsTest.php | 161 ++++++++++++++++++ .../Support/AiCapabilityPermissionsTest.php | 6 +- 21 files changed, 887 insertions(+), 60 deletions(-) create mode 100644 server/src/Support/Ai/FleetOpsAiConsoleCommands.php create mode 100644 server/src/Support/Ai/Tools/CreateOrderTool.php create mode 100644 server/src/Support/Ai/Tools/SearchResourcesTool.php create mode 100644 server/tests/Unit/Support/Ai/FleetOpsAiConsoleCommandsTest.php create mode 100644 server/tests/Unit/Support/Ai/Tools/FleetOpsAiToolsTest.php diff --git a/scripts/pest-bootstrap.php b/scripts/pest-bootstrap.php index faa678b86..6ea5bf5ed 100644 --- a/scripts/pest-bootstrap.php +++ b/scripts/pest-bootstrap.php @@ -382,6 +382,18 @@ public static function dispatchNow(...$arguments) { \Fleetbase\TestSupport\Dispa eval('namespace Fleetbase\Ai\Contracts; interface AIActionCapabilityInterface {}'); } +if (!interface_exists('Fleetbase\Ai\Contracts\AIToolCapabilityInterface')) { + eval('namespace Fleetbase\Ai\Contracts; interface AIToolCapabilityInterface {}'); +} + +if (!class_exists('Fleetbase\Ai\Support\AiAudience')) { + eval('namespace Fleetbase\Ai\Support; class AiAudience { public function __construct(public bool $isSystemAdmin = false, public array $granted = []) {} public function can(string $permission): bool { return $this->isSystemAdmin || in_array($permission, $this->granted, true); } public function canAll(array $permissions): bool { foreach ($permissions as $permission) { if (!$this->can($permission)) { return false; } } return true; } }'); +} + +if (!class_exists('Fleetbase\Ai\Support\AiToolContext')) { + eval('namespace Fleetbase\Ai\Support; class AiToolContext { public array $actionPreviews = []; public array $uiActions = []; public function __construct(public $task, public $audience) {} public function addActionPreview($capability, array $preview): array { $preview = array_merge(["preview_id" => "preview-" . (count($this->actionPreviews) + 1), "key" => $capability->key()], $preview); $this->actionPreviews[] = $preview; return $preview; } }'); +} + if (!class_exists('Fleetbase\Ai\Models\AiTask')) { eval('namespace Fleetbase\Ai\Models; class AiTask { public function __construct(array $attributes = []) { foreach ($attributes as $key => $value) { $this->{$key} = $value; } } }'); } diff --git a/server/src/Models/Sensor.php b/server/src/Models/Sensor.php index 8b20638ba..c1589065d 100644 --- a/server/src/Models/Sensor.php +++ b/server/src/Models/Sensor.php @@ -75,7 +75,6 @@ class Sensor extends Model */ protected $filterParams = [ 'type', - 'sensor_type', 'status', 'device_uuid', 'serial_number', @@ -214,7 +213,7 @@ protected static function boot() public function getSlugOptions(): SlugOptions { return SlugOptions::create() - ->generateSlugsFrom(['name', 'sensor_type']) + ->generateSlugsFrom(['name', 'type']) ->saveSlugsTo('slug') ->doNotGenerateSlugsOnUpdate(); } @@ -232,7 +231,6 @@ public function getActivitylogOptions(): LogOptions 'sensorable_uuid', 'sensorable_type', 'type', - 'sensor_type', 'internal_id', 'name', 'unit', @@ -396,7 +394,7 @@ public function getLastReadingFormattedAttribute(): ?string */ public function scopeByType($query, string $type) { - return $query->where('sensor_type', $type); + return $query->where('type', $type); } /** @@ -493,7 +491,7 @@ protected function checkThresholdAndCreateAlert($value): void 'message' => $this->generateThresholdAlertMessage($value, $thresholdStatus), 'context' => [ 'sensor_name' => $this->name, - 'sensor_type' => $this->sensor_type, + 'sensor_type' => $this->type, 'value' => $value, 'unit' => $this->unit, 'threshold_status' => $thresholdStatus, diff --git a/server/src/Providers/FleetOpsServiceProvider.php b/server/src/Providers/FleetOpsServiceProvider.php index 7203a85c5..d02ea07cf 100644 --- a/server/src/Providers/FleetOpsServiceProvider.php +++ b/server/src/Providers/FleetOpsServiceProvider.php @@ -274,6 +274,12 @@ protected function registerAiCapabilities(): void }); } + if (Utils::classExists(\Fleetbase\Ai\Support\Commands\AiCommandRegistry::class)) { + $this->callAfterResolving(\Fleetbase\Ai\Support\Commands\AiCommandRegistry::class, function ($commands) { + $commands->registerMany(\Fleetbase\FleetOps\Support\Ai\FleetOpsAiConsoleCommands::all()); + }); + } + $this->callAfterResolving(\Fleetbase\Ai\Support\AiCapabilityRegistry::class, function (\Fleetbase\Ai\Support\AiCapabilityRegistry $registry) { $registry->register(new \Fleetbase\FleetOps\Support\Ai\Capabilities\SearchResourcesCapability()); $registry->register(new \Fleetbase\FleetOps\Support\Ai\Capabilities\OperationalQueryCapability()); @@ -284,6 +290,13 @@ protected function registerAiCapabilities(): void $registry->register(new \Fleetbase\FleetOps\Support\Ai\Capabilities\CreateOrderPreviewCapability()); $registry->register(new \Fleetbase\FleetOps\Support\Ai\Capabilities\OptimizeOrderRouteCapability()); $registry->register(new \Fleetbase\FleetOps\Support\Ai\Capabilities\ImportOrdersPreviewCapability()); + + // Tool-calling versions share their capability keys, so they replace the keyword-driven ones when + // the installed AI extension supports tools and leave older AI extensions unaffected. + if (interface_exists(\Fleetbase\Ai\Contracts\AIToolCapabilityInterface::class)) { + $registry->register(new \Fleetbase\FleetOps\Support\Ai\Tools\SearchResourcesTool()); + $registry->register(new \Fleetbase\FleetOps\Support\Ai\Tools\CreateOrderTool()); + } }); } } diff --git a/server/src/Support/Ai/Capabilities/AbstractFleetOpsAICapability.php b/server/src/Support/Ai/Capabilities/AbstractFleetOpsAICapability.php index a070a7f79..f9c86649d 100644 --- a/server/src/Support/Ai/Capabilities/AbstractFleetOpsAICapability.php +++ b/server/src/Support/Ai/Capabilities/AbstractFleetOpsAICapability.php @@ -11,6 +11,17 @@ abstract class AbstractFleetOpsAICapability extends AbstractAICapability implements AIContextCapabilityInterface { + /** + * Words that never identify a record and must not be used as search terms. + */ + protected const SEARCH_STOPWORDS = [ + 'the', 'and', 'for', 'with', 'from', 'this', 'that', 'what', 'where', 'when', 'which', 'who', 'how', 'many', 'much', + 'can', 'could', 'would', 'should', 'please', 'help', 'want', 'need', 'show', 'find', 'open', 'look', 'tell', 'about', + 'status', 'order', 'orders', 'vehicle', 'vehicles', 'driver', 'drivers', 'device', 'devices', 'sensor', 'sensors', + 'telematic', 'telematics', 'maintenance', 'maintenances', 'work', 'fleet', 'fleets', 'ops', 'create', 'new', 'all', + 'today', 'yesterday', 'tomorrow', 'week', 'month', 'fleetbase', 'import', 'template', 'download', 'dummy', 'test', + ]; + public function module(): string { return 'fleet-ops'; @@ -50,6 +61,18 @@ protected function can(string $permission): bool return Auth::can($permission); } + /** + * Applies the current user's IAM directives (record-level scoping) for a permission to a query. + */ + protected function scopeToPermission($query, string $permission) + { + if ($query instanceof Builder && Builder::hasGlobalMacro('applyDirectivesForPermissions')) { + return $query->applyDirectivesForPermissions($permission); + } + + return $query; + } + protected function canAll(array $permissions): bool { foreach ($permissions as $permission) { @@ -61,18 +84,52 @@ protected function canAll(array $permissions): bool return true; } + /** + * Extracts record-reference-like search terms from a prompt. + * + * Only terms that plausibly identify a record are kept: quoted phrases, tokens containing a digit + * (public ids, plates, tracking numbers), emails and other delimited identifiers, and capitalized + * names that are not the first word. Ordinary words are never used as LIKE terms, and an empty + * array is returned when the prompt does not reference a specific record. + */ protected function searchTerms(string $prompt): array { - preg_match_all('/[A-Z]{2,}[-_][A-Z0-9-_]+|[A-Za-z0-9][A-Za-z0-9-_]{2,}/', (string) $prompt, $matches); + $prompt = trim($prompt); + $terms = []; - $terms = collect($matches[0] ?? []) - ->reject(fn ($term) => in_array(Str::lower($term), ['find', 'show', 'open', 'order', 'orders', 'vehicle', 'vehicles', 'driver', 'drivers', 'work', 'status', 'about', 'fleet', 'ops'], true)) - ->unique() + preg_match_all('/"([^"]{2,64})"|\'([^\']{2,64})\'/u', $prompt, $quoted); + foreach (array_merge($quoted[1] ?? [], $quoted[2] ?? []) as $phrase) { + if (trim($phrase) !== '') { + $terms[] = trim($phrase); + } + } + + preg_match_all('/[\p{L}\p{N}][\p{L}\p{N}@._\-]*[\p{L}\p{N}]/u', $prompt, $matches, PREG_OFFSET_CAPTURE); + foreach ($matches[0] ?? [] as [$token, $offset]) { + if ($this->isReferenceTerm($token, $offset === 0)) { + $terms[] = $token; + } + } + + return collect($terms) + ->unique(fn ($term) => Str::lower($term)) ->take(6) ->values() ->all(); + } + + protected function isReferenceTerm(string $token, bool $isFirstWord = false): bool + { + if (mb_strlen($token) < 3 || in_array(Str::lower($token), static::SEARCH_STOPWORDS, true)) { + return false; + } + + // Public ids, plates, tracking numbers, emails, and upper-case codes such as ORDER-ABC. + if (preg_match('/\p{N}|[@_]/u', $token) || preg_match('/^[\p{Lu}\p{N}]+(?:-[\p{Lu}\p{N}]+)+$/u', $token)) { + return true; + } - return empty($terms) ? [trim((string) $prompt)] : $terms; + return !$isFirstWord && preg_match('/^\p{Lu}\p{Ll}+$/u', $token) === 1; } protected function whereLikeAny(Builder $builder, array $columns, array $terms): void diff --git a/server/src/Support/Ai/Capabilities/AssetStatusCapability.php b/server/src/Support/Ai/Capabilities/AssetStatusCapability.php index 8b105af02..c7e9956f7 100644 --- a/server/src/Support/Ai/Capabilities/AssetStatusCapability.php +++ b/server/src/Support/Ai/Capabilities/AssetStatusCapability.php @@ -28,7 +28,7 @@ public function description(): string public function permissions(): array { - return ['fleet-ops see vehicle', 'fleet-ops see device', 'fleet-ops see sensor', 'fleet-ops see telematic']; + return ['fleet-ops see driver', 'fleet-ops see vehicle', 'fleet-ops see device', 'fleet-ops see sensor', 'fleet-ops see telematic']; } public function resolve(AiTask $task): array @@ -92,27 +92,46 @@ protected function driverStatus(): array protected function totalForModel(string $modelClass): int { - return $modelClass::where('company_uuid', session('company'))->count(); + return $this->scopedQuery($modelClass)->count(); } protected function onlineCountForModel(string $modelClass): int { - return $modelClass::where('company_uuid', session('company'))->where('online', true)->count(); + return $this->scopedQuery($modelClass)->where('online', true)->count(); } protected function offlineCountForModel(string $modelClass): int { - return $modelClass::where('company_uuid', session('company'))->where(function ($query) { + return $this->scopedQuery($modelClass)->where(function ($query) { $query->where('online', false)->orWhereNull('online'); })->count(); } protected function countsByStatusForModel(string $modelClass): array { - return $modelClass::where('company_uuid', session('company')) + return $this->scopedQuery($modelClass) ->selectRaw('status, count(*) as aggregate') ->groupBy('status') ->pluck('aggregate', 'status') ->all(); } + + /** + * Company-scoped query with the user's IAM directives for listing the resource applied. + */ + protected function scopedQuery(string $modelClass) + { + $permission = match ($modelClass) { + Driver::class => 'fleet-ops list driver', + Vehicle::class => 'fleet-ops list vehicle', + Device::class => 'fleet-ops list device', + Sensor::class => 'fleet-ops list sensor', + Telematic::class => 'fleet-ops list telematic', + default => null, + }; + + $query = $modelClass::where('company_uuid', session('company')); + + return $permission ? $this->scopeToPermission($query, $permission) : $query; + } } diff --git a/server/src/Support/Ai/Capabilities/CreateOrderPreviewCapability.php b/server/src/Support/Ai/Capabilities/CreateOrderPreviewCapability.php index 18a740d76..c77fcd89f 100644 --- a/server/src/Support/Ai/Capabilities/CreateOrderPreviewCapability.php +++ b/server/src/Support/Ai/Capabilities/CreateOrderPreviewCapability.php @@ -222,8 +222,12 @@ protected function matchesPrompt(string $prompt): bool protected function buildDraft(AiTask $task, array $input = []): array { - $existing = (array) data_get($task->metadata, 'action_previews.0.draft', []); - $draft = array_replace_recursive($this->draftFromPrompt((string) $task->prompt), $existing, (array) data_get($input, 'draft', $input)); + // A tool call supplies structured fields, so the prompt is not re-parsed. A refresh edits the + // preview it was opened from, which the AI service passes as existing_draft. + $fromTool = data_get($input, 'source') === 'tool'; + $existing = (array) (data_get($input, 'existing_draft') ?? ($fromTool ? [] : data_get($task->metadata, 'action_previews.0.draft', []))); + $changes = (array) data_get($input, 'draft', Arr::except($input, ['source', 'existing_draft'])); + $draft = array_replace_recursive($this->draftFromPrompt($fromTool ? '' : (string) $task->prompt), $existing, $changes); $draft['dispatched'] = filter_var(data_get($draft, 'dispatched', false), FILTER_VALIDATE_BOOLEAN); $draft['payload'] = (array) data_get($draft, 'payload', []); diff --git a/server/src/Support/Ai/Capabilities/OrderInsightsCapability.php b/server/src/Support/Ai/Capabilities/OrderInsightsCapability.php index d266caf5b..231658113 100644 --- a/server/src/Support/Ai/Capabilities/OrderInsightsCapability.php +++ b/server/src/Support/Ai/Capabilities/OrderInsightsCapability.php @@ -34,17 +34,20 @@ public function resolve(AiTask $task): array return ['authorized' => false, 'message' => 'Current user cannot access Fleet-Ops orders.']; } - $prompt = $this->prompt($task); - $window = $this->dateWindow($prompt); - $amount = $this->amountThreshold($prompt); - $query = $this->orderQuery(session('company')); + $prompt = $this->prompt($task); + $window = $this->dateWindow($prompt); + $amount = $this->amountThreshold($prompt); + $currency = $this->currency(); + $minor = $amount !== null ? $this->toMinorUnits($amount, $currency) : null; + $query = $this->orderQuery(session('company')); if ($window) { $query->whereBetween('created_at', [$window['start'], $window['end']]); } - if ($amount !== null) { - $query->whereHas('transaction', fn ($transaction) => $transaction->where('amount', '>', $amount)); + if ($minor !== null) { + // Transaction amounts are stored in the currency's minor unit (e.g. cents). + $query->whereHas('transaction', fn ($transaction) => $transaction->where('amount', '>', $minor)); } $total = (clone $query)->count(); @@ -59,6 +62,7 @@ public function resolve(AiTask $task): array 'end' => $window['end']->toIso8601String(), ] : null, 'amount_threshold' => $amount, + 'amount_currency' => $amount !== null ? $currency : null, 'count' => $total, 'counts_by_status' => (clone $query) ->selectRaw('status, count(*) as aggregate') @@ -83,6 +87,35 @@ protected function amountThreshold(string $prompt): ?float return null; } + /** + * Converts a major-unit amount (e.g. 500.00) to the minor units transactions are stored in, + * honoring the currency's ISO-4217 exponent (JPY has none, BHD has three). + */ + protected function toMinorUnits(float $amount, string $currency): int + { + $exponent = 2; + + try { + if (class_exists(\Money\Currencies\ISOCurrencies::class)) { + $exponent = (new \Money\Currencies\ISOCurrencies())->subunitFor(new \Money\Currency(strtoupper($currency))); + } + } catch (\Throwable) { + $exponent = 2; + } + + return (int) round($amount * (10 ** $exponent)); + } + + /** + * @codeCoverageIgnore + */ + protected function currency(): string + { + $company = function_exists('session') ? \Fleetbase\Models\Company::where('uuid', session('company'))->first(['uuid', 'currency']) : null; + + return strtoupper((string) ($company?->currency ?: 'USD')); + } + protected function dateWindow(string $prompt): ?array { return $this->relativeDateResolver()->resolveWindow($prompt); @@ -90,7 +123,7 @@ protected function dateWindow(string $prompt): ?array protected function orderQuery(?string $companyUuid): mixed { - return Order::where('company_uuid', $companyUuid); + return $this->scopeToPermission(Order::where('company_uuid', $companyUuid), 'fleet-ops list order'); } protected function relativeDateResolver(): AiRelativeDateResolver diff --git a/server/src/Support/Ai/Capabilities/SearchResourcesCapability.php b/server/src/Support/Ai/Capabilities/SearchResourcesCapability.php index b63f00a01..1aed720db 100644 --- a/server/src/Support/Ai/Capabilities/SearchResourcesCapability.php +++ b/server/src/Support/Ai/Capabilities/SearchResourcesCapability.php @@ -48,19 +48,69 @@ public function resolve(AiTask $task): array $prompt = (string) $task->prompt; $terms = $this->searchTerms($prompt); - return [ + if (empty($terms)) { + return [ + 'query_terms' => [], + 'results' => [], + 'message' => 'The prompt did not reference a specific Fleet-Ops record, so no records were searched.', + ]; + } + + return $this->searchAll($terms); + } + + /** + * Search the given resource types (all when null) for the terms. A failing search is reported + * without discarding the others. + */ + protected function searchAll(array $terms, ?array $types = null): array + { + $results = []; + $failed = []; + $searches = [ + 'orders' => fn () => $this->orders($terms), + 'vehicles' => fn () => $this->vehicles($terms), + 'drivers' => fn () => $this->drivers($terms), + 'work_orders' => fn () => $this->workOrders($terms), + 'maintenances' => fn () => $this->maintenances($terms), + 'devices' => fn () => $this->devices($terms), + 'sensors' => fn () => $this->sensors($terms), + 'telematics' => fn () => $this->telematics($terms), + ]; + + if ($types !== null) { + $searches = array_intersect_key($searches, array_flip($types)); + } + + foreach ($searches as $resource => $search) { + try { + $results[$resource] = $search(); + } catch (\Throwable $e) { + $failed[] = $resource; + $this->reportSearchFailure($resource, $e); + } + } + + $payload = [ 'query_terms' => $terms, - 'results' => array_filter([ - 'orders' => $this->orders($terms), - 'vehicles' => $this->vehicles($terms), - 'drivers' => $this->drivers($terms), - 'work_orders' => $this->workOrders($terms), - 'maintenances' => $this->maintenances($terms), - 'devices' => $this->devices($terms), - 'sensors' => $this->sensors($terms), - 'telematics' => $this->telematics($terms), - ]), + 'results' => array_filter($results), ]; + + if (!empty($failed)) { + $payload['unavailable_search'] = $failed; + } + + return $payload; + } + + /** + * @codeCoverageIgnore + */ + protected function reportSearchFailure(string $resource, \Throwable $e): void + { + if (function_exists('report')) { + report($e); + } } protected function matchesPrompt(string $prompt): bool @@ -76,7 +126,7 @@ protected function orders(array $terms): array return $this->orderSearchQuery() ->where(function ($query) use ($terms) { - $this->whereLikeAny($query, ['public_id', 'internal_id', 'uuid', 'status', 'type'], $terms); + $this->whereLikeAny($query, ['public_id', 'internal_id'], $terms); $query->orWhereHas('trackingNumber', fn ($tracking) => $this->whereLikeAny($tracking, ['tracking_number', 'barcode'], $terms)); }) ->limit(5) @@ -127,7 +177,7 @@ protected function drivers(array $terms): array return $this->driverSearchQuery() ->where(function ($query) use ($terms) { - $this->whereLikeAny($query, ['public_id', 'uuid', 'drivers_license_number', 'status'], $terms); + $this->whereLikeAny($query, ['public_id', 'internal_id', 'drivers_license_number'], $terms); $query->orWhereHas('user', fn ($user) => $this->whereLikeAny($user, ['name', 'email', 'phone'], $terms)); }) ->limit(5) @@ -146,27 +196,27 @@ protected function drivers(array $terms): array protected function workOrders(array $terms): array { - return $this->generic(WorkOrder::class, 'fleet-ops see work-order', ['public_id', 'uuid', 'code', 'subject', 'status', 'priority'], 'console.fleet-ops.maintenance.work-orders.index.details', $terms); + return $this->generic(WorkOrder::class, 'fleet-ops see work-order', ['public_id', 'code', 'subject'], 'console.fleet-ops.maintenance.work-orders.index.details', $terms); } protected function maintenances(array $terms): array { - return $this->generic(Maintenance::class, 'fleet-ops see maintenance', ['public_id', 'uuid', 'status', 'type', 'summary', 'notes'], 'console.fleet-ops.maintenance.maintenances.index.details', $terms); + return $this->generic(Maintenance::class, 'fleet-ops see maintenance', ['public_id', 'summary', 'notes'], 'console.fleet-ops.maintenance.maintenances.index.details', $terms); } protected function devices(array $terms): array { - return $this->generic(Device::class, 'fleet-ops see device', ['public_id', 'uuid', 'name', 'device_id', 'imei', 'serial_number', 'status'], 'console.fleet-ops.connectivity.devices.index.details', $terms); + return $this->generic(Device::class, 'fleet-ops see device', ['public_id', 'name', 'device_id', 'imei', 'serial_number'], 'console.fleet-ops.connectivity.devices.index.details', $terms); } protected function sensors(array $terms): array { - return $this->generic(Sensor::class, 'fleet-ops see sensor', ['public_id', 'uuid', 'name', 'internal_id', 'serial_number', 'imei', 'type', 'sensor_type', 'status'], 'console.fleet-ops.connectivity.sensors.index.details', $terms); + return $this->generic(Sensor::class, 'fleet-ops see sensor', ['public_id', 'name', 'internal_id', 'serial_number', 'imei'], 'console.fleet-ops.connectivity.sensors.index.details', $terms); } protected function telematics(array $terms): array { - return $this->generic(Telematic::class, 'fleet-ops see telematic', ['public_id', 'uuid', 'name', 'provider', 'status'], 'console.fleet-ops.connectivity.telematics.details', $terms); + return $this->generic(Telematic::class, 'fleet-ops see telematic', ['public_id', 'name'], 'console.fleet-ops.connectivity.telematics.details', $terms); } protected function generic(string $modelClass, string $permission, array $columns, string $route, array $terms): array @@ -193,23 +243,35 @@ protected function generic(string $modelClass, string $permission, array $column protected function orderSearchQuery() { - return Order::with(['transaction', 'trackingNumber']) - ->where('company_uuid', session('company')); + return $this->scopeToPermission( + Order::with(['transaction', 'trackingNumber'])->where('company_uuid', session('company')), + 'fleet-ops list order' + ); } protected function vehicleSearchQuery() { - return Vehicle::where('company_uuid', session('company')); + return $this->scopeToPermission(Vehicle::where('company_uuid', session('company')), 'fleet-ops list vehicle'); } protected function driverSearchQuery() { - return Driver::with('user') - ->where('company_uuid', session('company')); + return $this->scopeToPermission(Driver::with('user')->where('company_uuid', session('company')), 'fleet-ops list driver'); } protected function genericSearchQuery(string $modelClass) { - return $modelClass::where('company_uuid', session('company')); + $permission = match ($modelClass) { + WorkOrder::class => 'fleet-ops list work-order', + Maintenance::class => 'fleet-ops list maintenance', + Device::class => 'fleet-ops list device', + Sensor::class => 'fleet-ops list sensor', + Telematic::class => 'fleet-ops list telematic', + default => null, + }; + + $query = $modelClass::where('company_uuid', session('company')); + + return $permission ? $this->scopeToPermission($query, $permission) : $query; } } diff --git a/server/src/Support/Ai/FleetOpsAiConsoleCommands.php b/server/src/Support/Ai/FleetOpsAiConsoleCommands.php new file mode 100644 index 000000000..68cdc910e --- /dev/null +++ b/server/src/Support/Ai/FleetOpsAiConsoleCommands.php @@ -0,0 +1,122 @@ + [singular, plural, list route, new route, detail route, permission resource, actions service, supports import, docs path, keywords] + 'orders' => ['order', 'Orders', 'operations.orders.index', 'operations.orders.index.new', 'operations.orders.index.details', 'order', 'order-actions', 'importOrders', 'operations/orders', ['dispatch', 'delivery', 'shipment', 'job']], + 'drivers' => ['driver', 'Drivers', 'management.drivers.index', 'management.drivers.index.new', 'management.drivers.index.details', 'driver', 'driver-actions', 'import', 'resources/drivers', ['courier', 'navigator']], + 'vehicles' => ['vehicle', 'Vehicles', 'management.vehicles.index', 'management.vehicles.index.new', 'management.vehicles.index.details', 'vehicle', 'vehicle-actions', 'import', 'resources/vehicles', ['truck', 'van', 'car', 'fleet']], + 'contacts' => ['contact', 'Contacts', 'management.contacts.index', 'management.contacts.index.new', 'management.contacts.index.details', 'contact', 'contact-actions', 'import', 'resources/contacts', ['person']], + 'customers' => ['customer', 'Customers', 'management.contacts.customers', 'management.contacts.customers.new', 'management.contacts.customers.details', 'contact', 'customer-actions', 'import', 'resources/contacts', ['client', 'contacts']], + 'places' => ['place', 'Places', 'management.places.index', 'management.places.index.new', 'management.places.index.details', 'place', 'place-actions', 'import', 'resources/places', ['address', 'location', 'depot', 'warehouse']], + 'fleets' => ['fleet', 'Fleets', 'management.fleets.index', 'management.fleets.index.new', 'management.fleets.index.details', 'fleet', 'fleet-actions', null, 'resources/fleets', ['team']], + 'issues' => ['issue', 'Issues', 'management.issues.index', 'management.issues.index.new', 'management.issues.index.details', 'issue', 'issue-actions', null, 'resources/issues', ['problem', 'incident']], + 'work_orders' => ['work order', 'Work Orders', 'maintenance.work-orders.index', 'maintenance.work-orders.index.new', 'maintenance.work-orders.index.details', 'work-order', 'work-order-actions', null, 'maintenance/work-orders', ['maintenance', 'repair']], + 'maintenances' => ['maintenance', 'Maintenances', 'maintenance.maintenances.index', 'maintenance.maintenances.index.new', 'maintenance.maintenances.index.details', 'maintenance', 'maintenance-actions', null, 'maintenance', ['service', 'repair']], + ]; + + $commands = []; + + foreach ($resources as $key => [$singular, $plural, $listRoute, $newRoute, $detailRoute, $permission, $service, $import, $docs, $keywords]) { + $base = [ + 'breadcrumb' => "Fleet-Ops › {$plural}", + 'keywords' => array_merge(['fleet-ops', $singular], $keywords), + 'docs_url' => static::DOCS . "/{$docs}", + 'module' => 'fleet-ops', + ]; + + $commands[] = $base + [ + 'id' => "fleet-ops.{$key}.open", + 'label' => "Open {$plural}", + 'description' => "Go to the Fleet-Ops {$plural} list.", + 'steps' => [['type' => 'navigate', 'route' => "console.fleet-ops.{$listRoute}"]], + 'permissions' => ["fleet-ops list {$permission}"], + ]; + + $commands[] = $base + [ + 'id' => "fleet-ops.{$key}.create", + 'label' => "Create {$singular}", + 'description' => "Open the new {$singular} form in Fleet-Ops.", + 'steps' => [['type' => 'navigate', 'route' => "console.fleet-ops.{$newRoute}"]], + 'permissions' => ["fleet-ops create {$permission}"], + 'keywords' => array_merge($base['keywords'], ['new', 'add', 'create']), + ]; + + $commands[] = $base + [ + 'id' => "fleet-ops.{$key}.view", + 'label' => "Open {$singular}", + 'description' => "Open a specific {$singular} by its public id.", + 'steps' => [['type' => 'navigate', 'route' => "console.fleet-ops.{$detailRoute}", 'models' => ['public_id']]], + 'permissions' => ["fleet-ops see {$permission}"], + 'params' => ['public_id' => ['type' => 'string', 'description' => "The {$singular} public id."]], + ]; + + if ($import) { + $commands[] = $base + [ + 'id' => "fleet-ops.{$key}.import", + 'label' => "Import {$plural}", + 'description' => "Go to {$plural} in Fleet-Ops and open the spreadsheet import, including the template download.", + 'steps' => [ + ['type' => 'navigate', 'route' => "console.fleet-ops.{$listRoute}"], + ['type' => 'service', 'engine' => static::ENGINE, 'service' => $service, 'method' => $import], + ], + 'permissions' => ["fleet-ops import {$permission}"], + 'keywords' => array_merge($base['keywords'], ['import', 'spreadsheet', 'csv', 'excel', 'xlsx', 'template', 'bulk', 'upload']), + ]; + } + } + + return $commands; + } + + public static function settings(): array + { + $settings = [ + 'map' => ['Map', 'map-settings', ['map', 'provider', 'google maps', 'leaflet', 'openstreetmap', 'tiles', 'view maps']], + 'navigator-app' => ['Navigator App', 'navigator-settings', ['navigator', 'driver app', 'mobile app', 'onboard']], + 'routing' => ['Routing', 'routing-settings', ['routing', 'route', 'optimization', 'osrm', 'distance']], + 'notifications' => ['Notifications', 'notification-settings', ['notification', 'alert', 'email', 'sms']], + 'custom-fields' => ['Custom Fields', 'custom-field', ['custom field', 'field', 'attribute']], + 'scheduling' => ['Scheduling', null, ['schedule', 'shift', 'calendar']], + ]; + + $commands = []; + + foreach ($settings as $route => [$label, $permission, $keywords]) { + $commands[] = [ + 'id' => 'fleet-ops.settings.' . str_replace('-', '_', $route) . '.open', + 'label' => "Open {$label} settings", + 'breadcrumb' => "Fleet-Ops › Settings › {$label}", + 'description' => "Go to the Fleet-Ops {$label} settings.", + 'steps' => [['type' => 'navigate', 'route' => "console.fleet-ops.settings.{$route}"]], + 'permissions' => $permission ? ["fleet-ops view {$permission}"] : [], + 'keywords' => array_merge(['fleet-ops', 'settings', 'configure'], $keywords), + 'docs_url' => static::DOCS . "/settings/{$route}", + 'module' => 'fleet-ops', + ]; + } + + return $commands; + } +} diff --git a/server/src/Support/Ai/Tools/CreateOrderTool.php b/server/src/Support/Ai/Tools/CreateOrderTool.php new file mode 100644 index 000000000..a7ca87412 --- /dev/null +++ b/server/src/Support/Ai/Tools/CreateOrderTool.php @@ -0,0 +1,86 @@ + 'object', + 'properties' => [ + 'pickup' => ['type' => 'string', 'description' => 'Pickup address or saved place name.'], + 'dropoff' => ['type' => 'string', 'description' => 'Dropoff address or saved place name.'], + 'scheduled_at' => ['type' => 'string', 'description' => 'Optional ISO 8601 date and time.'], + 'driver' => ['type' => 'string', 'description' => 'Optional driver name or public id.'], + 'vehicle' => ['type' => 'string', 'description' => 'Optional vehicle name, plate, or public id.'], + 'order_config' => ['type' => 'string', 'description' => 'Optional order configuration key, e.g. transport.'], + 'notes' => ['type' => 'string', 'description' => 'Optional notes for the order.'], + 'dispatch' => ['type' => 'boolean', 'description' => 'Dispatch immediately. Only true when the user asked for it.'], + ], + 'required' => ['pickup', 'dropoff'], + 'additionalProperties' => false, + ]; + } + + public function availableFor(AiToolContext $context): bool + { + return $context->audience->canAll($this->permissions()); + } + + public function invoke(AiTask $task, array $arguments, AiToolContext $context): array + { + $draft = array_filter([ + 'payload' => array_filter([ + 'pickup_query' => $this->argument($arguments, 'pickup'), + 'dropoff_query' => $this->argument($arguments, 'dropoff'), + ]), + 'scheduled_at' => $this->argument($arguments, 'scheduled_at'), + 'driver_query' => $this->argument($arguments, 'driver'), + 'vehicle_query' => $this->argument($arguments, 'vehicle'), + 'type' => $this->argument($arguments, 'order_config'), + 'notes' => $this->argument($arguments, 'notes'), + 'dispatched' => (bool) ($arguments['dispatch'] ?? false), + ], fn ($value) => $value !== null && $value !== []); + + $preview = $context->addActionPreview($this, $this->preview($task, ['source' => 'tool', 'draft' => $draft])); + + return [ + 'preview_id' => $preview['preview_id'], + 'ready' => $preview['ready'] ?? false, + 'missing_fields' => $preview['missing_fields'] ?? [], + 'fields' => $preview['fields'] ?? [], + 'status' => 'draft_shown_to_user', + 'message' => 'A draft order card is shown to the user. The order does not exist until the user reviews the card and clicks ' . ($preview['apply_label'] ?? 'Create order') . '.', + ]; + } + + protected function argument(array $arguments, string $key): ?string + { + $value = trim((string) ($arguments[$key] ?? '')); + + return $value === '' ? null : $value; + } +} diff --git a/server/src/Support/Ai/Tools/SearchResourcesTool.php b/server/src/Support/Ai/Tools/SearchResourcesTool.php new file mode 100644 index 000000000..df0c8109f --- /dev/null +++ b/server/src/Support/Ai/Tools/SearchResourcesTool.php @@ -0,0 +1,73 @@ + 'object', + 'properties' => [ + 'query' => ['type' => 'string', 'description' => 'The identifier or name to look for, e.g. "order_yhkejdnzgz", "SBA1234Z", or "Jane Doe".'], + 'types' => ['type' => 'array', 'items' => ['type' => 'string', 'enum' => static::TYPES], 'description' => 'Optional record types to search. Searches all types when omitted.'], + ], + 'required' => ['query'], + 'additionalProperties' => false, + ]; + } + + public function availableFor(AiToolContext $context): bool + { + foreach ($this->permissions() as $permission) { + if ($context->audience->can($permission)) { + return true; + } + } + + return false; + } + + public function invoke(AiTask $task, array $arguments, AiToolContext $context): array + { + $query = trim((string) ($arguments['query'] ?? '')); + + if (mb_strlen($query) < 2) { + return ['error' => 'Provide an identifier or name to search for.']; + } + + // The model sends an intentional query, so the whole phrase is searched along with any ids in it. + $terms = collect([Str::limit($query, 100, '')]) + ->merge($this->searchTerms($query)) + ->unique(fn ($term) => Str::lower($term)) + ->take(4) + ->values() + ->all(); + + $types = array_values(array_intersect((array) ($arguments['types'] ?? []), static::TYPES)); + + return $this->searchAll($terms, $types ?: null); + } +} diff --git a/server/tests/AiOperationalQueryCapabilityTest.php b/server/tests/AiOperationalQueryCapabilityTest.php index e1a13be45..40448d3cd 100644 --- a/server/tests/AiOperationalQueryCapabilityTest.php +++ b/server/tests/AiOperationalQueryCapabilityTest.php @@ -406,6 +406,7 @@ function fleetopsAssetStatusCapabilityProbe(array $permissions = []): FleetOpsAs ->and($capability->label())->toBe('Fleet-Ops asset status') ->and($capability->description())->toContain('vehicle, device, sensor') ->and($capability->permissions())->toBe([ + 'fleet-ops see driver', 'fleet-ops see vehicle', 'fleet-ops see device', 'fleet-ops see sensor', diff --git a/server/tests/SupportJobAndAiCoverageTest.php b/server/tests/SupportJobAndAiCoverageTest.php index df2223f35..cc42249f9 100644 --- a/server/tests/SupportJobAndAiCoverageTest.php +++ b/server/tests/SupportJobAndAiCoverageTest.php @@ -889,8 +889,8 @@ function fleetopsInvokeSyncTelematicJob(SyncTelematicDevicesJob $job, string $me ->and($capability->shouldResolve($task))->toBeTrue() ->and($capability->promptMatches('tell me about sensor SENSOR-1'))->toBeTrue() ->and($capability->promptMatches('compose a friendly email'))->toBeFalse() - ->and($capability->termsFor('Find order ORDER-123 for driver driver_456'))->toBe(['ORDER-123', 'for', 'driver_456']) - ->and($capability->termsFor('show order'))->toBe(['show order']) + ->and($capability->termsFor('Find order ORDER-123 for driver driver_456'))->toBe(['ORDER-123', 'driver_456']) + ->and($capability->termsFor('show order'))->toBe([]) ->and($capability->genericWhenDenied())->toBe([]) ->and($capability->allResourceBranchesWhenDenied(['needle']))->toBe([ 'orders' => [], @@ -903,7 +903,7 @@ function fleetopsInvokeSyncTelematicJob(SyncTelematicDevicesJob $job, string $me 'telematics' => [], ]) ->and($capability->resolve($task))->toBe([ - 'query_terms' => ['DRV-123', 'and', 'TRUCK_9'], + 'query_terms' => ['DRV-123', 'TRUCK_9'], 'results' => [], ]); }); diff --git a/server/tests/Unit/Models/SensorTest.php b/server/tests/Unit/Models/SensorTest.php index af3e3811b..068b678df 100644 --- a/server/tests/Unit/Models/SensorTest.php +++ b/server/tests/Unit/Models/SensorTest.php @@ -116,7 +116,7 @@ function fleetopsSensorModelUseInMemoryConnection(): SQLiteConnection $sensor = new Sensor(); - expect($sensor->getSlugOptions()->generateSlugFrom)->toBe(['name', 'sensor_type']) + expect($sensor->getSlugOptions()->generateSlugFrom)->toBe(['name', 'type']) ->and($sensor->getSlugOptions()->slugField)->toBe('slug') ->and($sensor->getActivitylogOptions()->logOnlyDirty)->toBeTrue() ->and($sensor->telematic())->toBeInstanceOf(BelongsTo::class) @@ -222,7 +222,7 @@ function fleetopsSensorModelUseInMemoryConnection(): SQLiteConnection ->and($sensor->scopeActive($query))->toBe($query) ->and($sensor->scopeWithRecentReadings($query, 30))->toBe($query) ->and($sensor->scopeOutOfThreshold($query))->toBe($query) - ->and($query->calls[0])->toBe(['where', ['sensor_type', 'temperature']]) + ->and($query->calls[0])->toBe(['where', ['type', 'temperature']]) ->and($query->calls[1])->toBe(['where', ['status', 'active']]) ->and($query->calls[2][0])->toBe('where') ->and($query->calls[2][1][0])->toBe('last_reading_at') diff --git a/server/tests/Unit/Providers/FleetOpsAiRegistrationTest.php b/server/tests/Unit/Providers/FleetOpsAiRegistrationTest.php index 363d4ae85..a6883f2db 100644 --- a/server/tests/Unit/Providers/FleetOpsAiRegistrationTest.php +++ b/server/tests/Unit/Providers/FleetOpsAiRegistrationTest.php @@ -4,6 +4,10 @@ eval('namespace Fleetbase\Ai\Support; class AiCapabilityRegistry { public array $registered = []; public function register($capability) { $this->registered[] = $capability; return $this; } }'); } +if (!class_exists('Fleetbase\Ai\Support\Commands\AiCommandRegistry', false)) { + eval('namespace Fleetbase\Ai\Support\Commands; class AiCommandRegistry { public array $registered = []; public function registerMany(array $commands) { $this->registered = array_merge($this->registered, $commands); return $this; } }'); +} + if (!class_exists('Fleetbase\Ai\Support\AiQueryRegistry', false)) { eval('namespace Fleetbase\Ai\Support; class AiQueryRegistry { public array $registered = []; public function register($resource = null) { $this->registered[] = $resource; return $this; } }'); } @@ -44,9 +48,16 @@ $capabilityRegistry = app(Fleetbase\Ai\Support\AiCapabilityRegistry::class); fwrite(STDERR, "\nDBG ai: " . json_encode([is_object($queryRegistry) ? get_class($queryRegistry) : gettype($queryRegistry), is_object($capabilityRegistry) ? get_class($capabilityRegistry) : gettype($capabilityRegistry), is_object($capabilityRegistry) ? count($capabilityRegistry->registered) : null]) . "\n"); - expect($capabilityRegistry->registered)->toHaveCount(9) + expect($capabilityRegistry->registered)->toHaveCount(11) ->and(collect($capabilityRegistry->registered)->map(fn ($capability) => get_class($capability))) - ->toContain(Fleetbase\FleetOps\Support\Ai\Capabilities\SearchResourcesCapability::class); + ->toContain( + Fleetbase\FleetOps\Support\Ai\Capabilities\SearchResourcesCapability::class, + Fleetbase\FleetOps\Support\Ai\Tools\SearchResourcesTool::class, + Fleetbase\FleetOps\Support\Ai\Tools\CreateOrderTool::class, + ); + + $commandRegistry = app(Fleetbase\Ai\Support\Commands\AiCommandRegistry::class); + expect(collect($commandRegistry->registered)->pluck('id'))->toContain('fleet-ops.orders.create', 'fleet-ops.settings.map.open'); // The query resources helper registers the fleet-ops queryables directly $freshQueryRegistry = new Fleetbase\Ai\Support\AiQueryRegistry(); diff --git a/server/tests/Unit/Support/Ai/AssetStatusCapabilityQueriesTest.php b/server/tests/Unit/Support/Ai/AssetStatusCapabilityQueriesTest.php index 08b9c56a0..6fe8eed4f 100644 --- a/server/tests/Unit/Support/Ai/AssetStatusCapabilityQueriesTest.php +++ b/server/tests/Unit/Support/Ai/AssetStatusCapabilityQueriesTest.php @@ -117,4 +117,46 @@ public function __call($method, $arguments) ->and($helper('vehicleSearchQuery')->count())->toBe(0) ->and($helper('driverSearchQuery')->count())->toBe(3) ->and($helper('genericSearchQuery', Fleetbase\FleetOps\Models\Contact::class)->count())->toBe(0); + + foreach ([Fleetbase\FleetOps\Models\WorkOrder::class, Fleetbase\FleetOps\Models\Maintenance::class, Fleetbase\FleetOps\Models\Device::class, Fleetbase\FleetOps\Models\Sensor::class, Fleetbase\FleetOps\Models\Telematic::class] as $modelClass) { + expect($helper('genericSearchQuery', $modelClass))->toBeInstanceOf(Illuminate\Database\Eloquent\Builder::class); + } +}); + +test('asset status scoped queries resolve a list permission for every supported model', function () { + fleetopsAssetStatusCapabilityBoot(); + + $capability = (new ReflectionClass(AssetStatusCapability::class))->newInstanceWithoutConstructor(); + $scoped = new ReflectionMethod(AssetStatusCapability::class, 'scopedQuery'); + $scoped->setAccessible(true); + + foreach ([Fleetbase\FleetOps\Models\Vehicle::class, Fleetbase\FleetOps\Models\Device::class, Fleetbase\FleetOps\Models\Sensor::class, Fleetbase\FleetOps\Models\Telematic::class, Fleetbase\FleetOps\Models\Contact::class] as $modelClass) { + expect($scoped->invoke($capability, $modelClass))->toBeInstanceOf(Illuminate\Database\Eloquent\Builder::class); + } +}); + +test('capability queries apply IAM directives for the list permission when the directive macro is available', function () { + fleetopsAssetStatusCapabilityBoot(); + + if (!Illuminate\Database\Eloquent\Builder::hasGlobalMacro('applyDirectivesForPermissions')) { + Illuminate\Database\Eloquent\Builder::macro('applyDirectivesForPermissions', function (string|array $names = []) { + $GLOBALS['fleetopsAppliedDirectivePermissions'][] = $names; + + return $this; + }); + } + + $GLOBALS['fleetopsAppliedDirectivePermissions'] = []; + + $capability = (new ReflectionClass(AssetStatusCapability::class))->newInstanceWithoutConstructor(); + $scope = new ReflectionMethod(AssetStatusCapability::class, 'scopeToPermission'); + $scope->setAccessible(true); + $plain = new stdClass(); + + expect($scope->invoke($capability, $plain, 'fleet-ops list driver'))->toBe($plain); + + $builder = Fleetbase\FleetOps\Models\Driver::query(); + + expect($scope->invoke($capability, $builder, 'fleet-ops list driver'))->toBe($builder) + ->and($GLOBALS['fleetopsAppliedDirectivePermissions'])->toContain('fleet-ops list driver'); }); diff --git a/server/tests/Unit/Support/Ai/Capabilities/OrderInsightsCapabilityTest.php b/server/tests/Unit/Support/Ai/Capabilities/OrderInsightsCapabilityTest.php index fcd291479..80fd077f6 100644 --- a/server/tests/Unit/Support/Ai/Capabilities/OrderInsightsCapabilityTest.php +++ b/server/tests/Unit/Support/Ai/Capabilities/OrderInsightsCapabilityTest.php @@ -123,6 +123,11 @@ protected function can(string $permission): bool return $this->allowed && $permission === 'fleet-ops see order'; } + protected function currency(): string + { + return 'USD'; + } + protected function orderQuery(?string $companyUuid): mixed { $this->companies[] = $companyUuid; @@ -183,6 +188,7 @@ function fleetopsOrderInsightsTask(string $prompt): AiTask 'authorized' => true, 'metric' => 'orders', 'amount_threshold' => 125.50, + 'amount_currency' => 'USD', 'count' => 7, 'counts_by_status' => ['completed' => 5, 'active' => 2], 'sample_order_ids' => ['order_1', 'order_2'], @@ -196,7 +202,7 @@ function fleetopsOrderInsightsTask(string $prompt): AiTask ->and($capability->companies)->toBe(['company-123']) ->and($capability->query->recordedCalls())->toContain( ['whereBetween', 'created_at', [$start, $end]], - ['whereHas', 'transaction', [['where', 'amount', '>', 125.50]]], + ['whereHas', 'transaction', [['where', 'amount', '>', 12550.0]]], ['count'], ['selectRaw', 'status, count(*) as aggregate'], ['groupBy', 'status'], @@ -224,3 +230,15 @@ function fleetopsOrderInsightsTask(string $prompt): AiTask ->and($capability->query->recordedCalls())->not->toContain(['whereBetween']) ->and($capability->query->recordedCalls())->not->toContain(['whereHas']); }); + +test('order insights converts major-unit thresholds using the currency exponent', function () { + $capability = new FleetOpsOrderInsightsCapabilityFake(); + $method = new ReflectionMethod(OrderInsightsCapability::class, 'toMinorUnits'); + $method->setAccessible(true); + + expect($method->invoke($capability, 500.0, 'USD'))->toBe(50000) + ->and($method->invoke($capability, 125.5, 'usd'))->toBe(12550) + ->and($method->invoke($capability, 1000.0, 'JPY'))->toBe(1000) + ->and($method->invoke($capability, 1.5, 'BHD'))->toBe(1500) + ->and($method->invoke($capability, 10.0, 'NOT-A-CURRENCY'))->toBe(1000); +}); diff --git a/server/tests/Unit/Support/Ai/Capabilities/SearchResourcesCapabilityTest.php b/server/tests/Unit/Support/Ai/Capabilities/SearchResourcesCapabilityTest.php index 1bedf0d84..62c0e0f7b 100644 --- a/server/tests/Unit/Support/Ai/Capabilities/SearchResourcesCapabilityTest.php +++ b/server/tests/Unit/Support/Ai/Capabilities/SearchResourcesCapabilityTest.php @@ -67,6 +67,17 @@ class FleetOpsSearchResourcesCapabilityProbe extends SearchResourcesCapability public array $queries = []; public array $appliedLikes = []; + public array $failing = []; + + public function exposeSearchTerms(string $prompt): array + { + return $this->searchTerms($prompt); + } + + protected function reportSearchFailure(string $resource, Throwable $e): void + { + } + public function exposePromptMatches(string $prompt): bool { return $this->matchesPrompt($prompt); @@ -103,6 +114,10 @@ protected function driverSearchQuery() protected function genericSearchQuery(string $modelClass) { + if ($modelClass === Sensor::class && in_array('sensors', $this->failing, true)) { + throw new RuntimeException("SQLSTATE[42S22]: Column not found: 1054 Unknown column 'sensor_type'"); + } + return match ($modelClass) { WorkOrder::class => $this->queries['work_orders'], Maintenance::class => $this->queries['maintenances'], @@ -197,7 +212,7 @@ function fleetopsSearchResourcesModel(string $class, array $attributes) $result = $capability->resolve(new AiTask(['prompt' => 'Find order ORDER-1 vehicle VEH-1 driver DRV-1 device DEV-1 sensor SNS-1 telematic TEL-1'])); expect($capability->exposePromptMatches('look up work order WO-1'))->toBeTrue() - ->and($result['query_terms'])->toBe(['ORDER-1', 'VEH-1', 'DRV-1', 'device', 'DEV-1', 'sensor']) + ->and($result['query_terms'])->toBe(['ORDER-1', 'VEH-1', 'DRV-1', 'DEV-1', 'SNS-1', 'TEL-1']) ->and($result['results'])->toHaveKeys(['orders', 'vehicles', 'drivers', 'work_orders', 'maintenances', 'devices', 'sensors', 'telematics']) ->and($result['results']['orders'][0])->toMatchArray([ 'id' => 'order_public', @@ -235,3 +250,72 @@ function fleetopsSearchResourcesModel(string $class, array $attributes) ->and($capability->queries['orders']->calls)->toContain(['limit', 5], ['get']) ->and($capability->appliedLikes)->not->toBeEmpty(); }); + +function fleetopsSearchResourcesEmptyQueries(): array +{ + return collect(['orders', 'vehicles', 'drivers', 'work_orders', 'maintenances', 'devices', 'sensors', 'telematics']) + ->mapWithKeys(fn ($key) => [$key => new FleetOpsSearchResourcesQueryFake()]) + ->all(); +} + +test('search terms ignore ordinary words from real production prompts', function () { + $capability = new FleetOpsSearchResourcesCapabilityProbe(); + + expect($capability->exposeSearchTerms('i want turn this into a platform where i add cisterns and on a different page BUSINESS leave they orders'))->toBe([]) + ->and($capability->exposeSearchTerms('Create a few dummy orders so we can test with it'))->toBe([]) + ->and($capability->exposeSearchTerms('download order import template'))->toBe([]) + ->and($capability->exposeSearchTerms('config driver app ยังไง'))->toBe([]) + ->and($capability->exposeSearchTerms('como activar un conductor'))->toBe([]); +}); + +test('search terms keep record references such as public ids, plates, emails, quoted and capitalized names', function () { + $capability = new FleetOpsSearchResourcesCapabilityProbe(); + + expect($capability->exposeSearchTerms('status of order order_yhkejdnzgz'))->toBe(['order_yhkejdnzgz']) + ->and($capability->exposeSearchTerms('vehicle plate SBA1234Z'))->toBe(['SBA1234Z']) + ->and($capability->exposeSearchTerms('driver with email ada@example.com'))->toBe(['ada@example.com']) + ->and($capability->exposeSearchTerms('where is driver Jane Doe right now'))->toBe(['Jane', 'Doe']) + ->and($capability->exposeSearchTerms('find "north depot" vehicles'))->toBe(['north depot']); +}); + +test('search resources skips querying when the prompt has no record reference', function () { + $capability = new FleetOpsSearchResourcesCapabilityProbe(); + $capability->allowedPermissions = $capability->permissions(); + $capability->queries = fleetopsSearchResourcesEmptyQueries(); + + $result = $capability->resolve(new AiTask(['prompt' => 'how do I create a new order'])); + + expect($result['query_terms'])->toBe([]) + ->and($result['results'])->toBe([]) + ->and($capability->appliedLikes)->toBe([]); +}); + +test('search resources never searches the dropped sensor_type column or status/uuid columns', function () { + $capability = new FleetOpsSearchResourcesCapabilityProbe(); + $capability->allowedPermissions = $capability->permissions(); + $capability->queries = fleetopsSearchResourcesEmptyQueries(); + + $capability->resolve(new AiTask(['prompt' => 'find SNS-1'])); + + $columns = collect($capability->appliedLikes)->flatMap(fn ($like) => $like[0])->unique()->all(); + + expect($columns)->not->toContain('sensor_type') + ->and($columns)->not->toContain('status') + ->and($columns)->not->toContain('uuid') + ->and($columns)->not->toContain('type'); +}); + +test('a failing resource search does not discard the other results or leak the error', function () { + $vehicle = fleetopsSearchResourcesModel(Vehicle::class, ['public_id' => 'vehicle_public', 'uuid' => 'vehicle-uuid', 'name' => 'Van 12']); + + $capability = new FleetOpsSearchResourcesCapabilityProbe(); + $capability->allowedPermissions = $capability->permissions(); + $capability->failing = ['sensors']; + $capability->queries = array_merge(fleetopsSearchResourcesEmptyQueries(), ['vehicles' => new FleetOpsSearchResourcesQueryFake([$vehicle])]); + + $result = $capability->resolve(new AiTask(['prompt' => 'find VEH-12'])); + + expect($result['results'])->toHaveKey('vehicles') + ->and($result['unavailable_search'])->toBe(['sensors']) + ->and(json_encode($result))->not->toContain('SQLSTATE'); +}); diff --git a/server/tests/Unit/Support/Ai/FleetOpsAiConsoleCommandsTest.php b/server/tests/Unit/Support/Ai/FleetOpsAiConsoleCommandsTest.php new file mode 100644 index 000000000..d29d700f4 --- /dev/null +++ b/server/tests/Unit/Support/Ai/FleetOpsAiConsoleCommandsTest.php @@ -0,0 +1,31 @@ +pluck('id')->duplicates())->toBeEmpty() + ->and($commands->every(fn ($command) => filled($command['label']) && filled($command['breadcrumb']) && filled($command['description']) && !empty($command['steps'])))->toBeTrue() + ->and($commands->every(fn ($command) => collect($command['steps'])->every(fn ($step) => $step['type'] === 'navigate' + ? str_starts_with($step['route'], 'console.fleet-ops.') + : $step['type'] === 'service' && $step['engine'] === FleetOpsAiConsoleCommands::ENGINE && preg_match('/^[a-zA-Z]+$/', $step['method']))))->toBeTrue() + ->and($commands->every(fn ($command) => ($command['audience'] ?? 'end_user') === 'end_user'))->toBeTrue(); +}); + +test('fleet-ops console commands cover create, import, view, and settings', function () { + $commands = collect(FleetOpsAiConsoleCommands::all())->keyBy('id'); + + expect($commands['fleet-ops.orders.create']['steps'])->toBe([['type' => 'navigate', 'route' => 'console.fleet-ops.operations.orders.index.new']]) + ->and($commands['fleet-ops.orders.create']['permissions'])->toBe(['fleet-ops create order']) + ->and($commands['fleet-ops.orders.import']['steps'][1])->toBe(['type' => 'service', 'engine' => '@fleetbase/fleetops-engine', 'service' => 'order-actions', 'method' => 'importOrders']) + ->and($commands['fleet-ops.customers.import']['steps'][1]['service'])->toBe('customer-actions') + ->and($commands['fleet-ops.customers.import']['permissions'])->toBe(['fleet-ops import contact']) + ->and($commands)->not->toHaveKey('fleet-ops.fleets.import') + ->and($commands['fleet-ops.drivers.view']['steps'][0]['models'])->toBe(['public_id']) + ->and($commands['fleet-ops.drivers.view']['params'])->toHaveKey('public_id') + ->and($commands['fleet-ops.settings.map.open']['steps'][0]['route'])->toBe('console.fleet-ops.settings.map') + ->and($commands['fleet-ops.settings.map.open']['permissions'])->toBe(['fleet-ops view map-settings']) + ->and($commands['fleet-ops.settings.map.open']['keywords'])->toContain('google maps', 'view maps') + ->and($commands['fleet-ops.settings.scheduling.open']['permissions'])->toBe([]); +}); diff --git a/server/tests/Unit/Support/Ai/Tools/FleetOpsAiToolsTest.php b/server/tests/Unit/Support/Ai/Tools/FleetOpsAiToolsTest.php new file mode 100644 index 000000000..4093b228e --- /dev/null +++ b/server/tests/Unit/Support/Ai/Tools/FleetOpsAiToolsTest.php @@ -0,0 +1,161 @@ +buildDraft($task, $input); + } + + protected function can(string $permission): bool + { + return true; + } + + protected function draftFromPrompt(string $prompt): array + { + return array_filter([ + 'order_config_uuid' => 'order-config-uuid', + 'type' => 'transport', + 'payload' => $prompt !== '' ? ['pickup_query' => 'Parsed from prompt'] : [], + 'dispatched' => false, + ], fn ($value) => $value !== []) + ['payload' => []]; + } + + protected function resolvePlace(?string $query): ?array + { + return $this->resolvedPlaces[$query] ?? null; + } + + protected function resolveOrderConfig(array $draft): ?OrderConfig + { + $config = new OrderConfig(); + $config->setRawAttributes(['uuid' => 'order-config-uuid', 'key' => 'transport', 'name' => 'Transport'], true); + + return $config; + } + + protected function resolveDriver(array $draft): ?Fleetbase\FleetOps\Models\Driver + { + return null; + } + + protected function resolveVehicle(array $draft): ?Fleetbase\FleetOps\Models\Vehicle + { + return null; + } +} + +class FleetOpsSearchResourcesToolProbe extends SearchResourcesTool +{ + public array $searched = []; + + protected function searchAll(array $terms, ?array $types = null): array + { + $this->searched[] = [$terms, $types]; + + return ['query_terms' => $terms, 'results' => []]; + } +} + +test('create order tool prepares one draft preview per call from structured fields without parsing the prompt', function () { + $tool = new FleetOpsCreateOrderToolProbe(); + $task = new AiTask(['prompt' => 'Create some dummy orders with made up data', 'metadata' => []]); + $context = new AiToolContext($task, new AiAudience(false, ['fleet-ops create order'])); + + $first = $tool->invoke($task, [ + 'pickup' => 'Kabelweg 57, Amsterdam', + 'dropoff' => 'Danzigerkade 15, Amsterdam', + 'scheduled_at' => '2026-09-16T09:30:00+02:00', + 'driver' => ' ', + 'notes' => 'Test order', + ], $context); + $second = $tool->invoke($task, ['pickup' => 'Diemerhof 32, Diemen', 'dropoff' => 'Herikerbergweg 238, Amsterdam', 'dispatch' => true], $context); + + expect($first['preview_id'])->toBe('preview-1') + ->and($second['preview_id'])->toBe('preview-2') + ->and($first['status'])->toBe('draft_shown_to_user') + ->and($first['message'])->toContain('does not exist until the user reviews the card') + ->and($context->actionPreviews)->toHaveCount(2) + ->and($context->actionPreviews[0]['draft']['payload']['pickup_query'])->toBe('Kabelweg 57, Amsterdam') + ->and($context->actionPreviews[0]['draft']['scheduled_at'])->toBe('2026-09-16T09:30:00+02:00') + ->and($context->actionPreviews[0]['draft']['notes'])->toBe('Test order') + ->and($context->actionPreviews[0]['draft'])->not->toHaveKey('driver_query') + ->and($context->actionPreviews[0]['draft']['dispatched'])->toBeFalse() + ->and($context->actionPreviews[1]['draft']['payload']['pickup_query'])->toBe('Diemerhof 32, Diemen') + ->and($context->actionPreviews[1]['draft']['dispatched'])->toBeTrue() + ->and(json_encode($context->actionPreviews))->not->toContain('Parsed from prompt') + ->and($tool->toolName())->toBe('propose_create_order') + ->and($tool->key())->toBe('fleet-ops.create_order') + ->and($tool->toolDescription())->toContain('NOT created by this tool') + ->and($tool->toolParameters()['required'])->toBe(['pickup', 'dropoff']) + ->and($tool->availableFor($context))->toBeTrue() + ->and($tool->availableFor(new AiToolContext($task, new AiAudience(false, []))))->toBeFalse(); +}); + +test('create order drafts refresh from the edited preview and still parse prompts in keyword mode', function () { + $tool = new FleetOpsCreateOrderToolProbe(); + $task = new AiTask(['prompt' => 'create order', 'metadata' => ['action_previews' => [['draft' => ['notes' => 'first preview']]]]]); + + $refreshed = $tool->exposeBuildDraft($task, ['existing_draft' => ['notes' => 'second preview'], 'draft' => ['dispatched' => true]]); + $keyword = $tool->exposeBuildDraft($task, ['dispatched' => false]); + + expect($refreshed['notes'])->toBe('second preview') + ->and($refreshed['dispatched'])->toBeTrue() + ->and($refreshed['payload']['pickup_query'])->toBe('Parsed from prompt') + ->and($keyword['notes'])->toBe('first preview') + ->and($keyword)->not->toHaveKey('existing_draft'); +}); + +test('search tool searches the requested phrase and identifiers for the chosen record types', function () { + $tool = new FleetOpsSearchResourcesToolProbe(); + $task = new AiTask(['prompt' => 'where is Jane']); + $context = new AiToolContext($task, new AiAudience(false, ['fleet-ops see driver'])); + + $tool->invoke($task, ['query' => 'Jane Doe', 'types' => ['drivers', 'pets']], $context); + $tool->invoke($task, ['query' => 'order order_yhkejdnzgz'], $context); + + expect($tool->searched[0])->toBe([['Jane Doe', 'Doe'], ['drivers']]) + ->and($tool->searched[1])->toBe([['order order_yhkejdnzgz', 'order_yhkejdnzgz'], null]) + ->and($tool->invoke($task, ['query' => ' x '], $context))->toBe(['error' => 'Provide an identifier or name to search for.']) + ->and($tool->toolName())->toBe('fleetops_search') + ->and($tool->key())->toBe('fleet-ops.search_resources') + ->and($tool->toolDescription())->toContain('particular record') + ->and($tool->toolParameters()['properties']['types']['items']['enum'])->toBe(SearchResourcesTool::TYPES) + ->and($tool->availableFor($context))->toBeTrue() + ->and($tool->availableFor(new AiToolContext($task, new AiAudience(false, []))))->toBeFalse(); +}); + +test('search tool narrows the real search to the requested types', function () { + $tool = new class extends SearchResourcesTool { + public array $called = []; + + protected function orders(array $terms): array + { + $this->called[] = 'orders'; + + return [['id' => 'order_1']]; + } + + protected function drivers(array $terms): array + { + $this->called[] = 'drivers'; + + return []; + } + }; + + $result = $tool->invoke(new AiTask(['prompt' => '']), ['query' => 'ORDER-1', 'types' => ['orders']], new AiToolContext(new AiTask(), new AiAudience(true))); + + expect($tool->called)->toBe(['orders']) + ->and($result['results'])->toBe(['orders' => [['id' => 'order_1']]]); +}); diff --git a/server/tests/Unit/Support/AiCapabilityPermissionsTest.php b/server/tests/Unit/Support/AiCapabilityPermissionsTest.php index b619610e6..61f6dc4a6 100644 --- a/server/tests/Unit/Support/AiCapabilityPermissionsTest.php +++ b/server/tests/Unit/Support/AiCapabilityPermissionsTest.php @@ -96,7 +96,7 @@ function fleetopsAiPermissionsHelper(string $method): ReflectionMethod ->and(fn () => fleetopsAiPermissionsHelper('canAll')->invoke($capability, ['fleet-ops list order']))->toThrow(TypeError::class); }); -test('search terms extract identifiers and fall back to the raw prompt', function () { +test('search terms extract identifiers and never fall back to the raw prompt', function () { fleetopsAiPermissionsBoot(); $capability = new OperationalQueryCapability(); $searchTerms = fleetopsAiPermissionsHelper('searchTerms'); @@ -105,8 +105,8 @@ function fleetopsAiPermissionsHelper(string $method): ReflectionMethod expect($terms)->toContain('TRK-12345', 'atlas99') ->and($terms)->not->toContain('find', 'order'); - // Stop-word-only prompts fall back to the trimmed prompt - expect($searchTerms->invoke($capability, 'find order'))->toBe(['find order']); + // Prompts without a record reference produce no search terms, so nothing is searched + expect($searchTerms->invoke($capability, 'find order'))->toBe([]); }); test('where like any matches across columns and terms', function () { From 0c79b2b3efdcb5a5f35a3f959393bcae456d1f25 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 18 Sep 2026 15:27:31 +0800 Subject: [PATCH 02/19] Scope internal order lifecycle lookups to the caller's company MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The internal OrderController resolved every order-lifecycle target straight from a caller-supplied identifier with no company constraint: Order::where('uuid', $uuid)->first() // cancel Order::findById($id) // dispatch, schedule, tracker Order::where('uuid', $uuid)->withoutGlobalScopes() // start Order::whereIn('uuid', $ids)->get() // bulk-cancel, bulk-dispatch Driver::whereUuid($uuid)->first() // bulk-assign-driver Order::whereIn('uuid', $uuids)->update([...]) // bulk-assign-driver These targets arrive as body/query params rather than bound route parameters, so nothing upstream narrows them: `fleetbase.protected` runs auth:sanctum plus AuthorizationGuard, which only checks that the caller holds the named RBAC capability — it never inspects which company the record belongs to. Any authenticated user with ordinary "manage orders" rights could therefore cancel, dispatch, start, schedule or bulk-reassign another organization's orders by supplying their uuid, which is not secret (tracking links, labels, webhooks). Generic CRUD on the same model was already safe because it carries its own explicit company_uuid clause; these hand-rolled lifecycle lookups did not. Every by-identifier lookup in this controller now goes through a single `scopedToCompany()` guard that adds the company_uuid constraint and fails the query closed when no company is in session. Beyond the lifecycle actions, the same guard now covers update-activity, next-activity, set-destination, capture-photo, edit-route, ping-driver, proofs, entity/proof subjects, tracking-number lookup and the import file lookup, which had the identical gap. Two behavioural notes: - `cancel()` now rejects an unresolvable order instead of dereferencing null. `exists:orders,uuid` on CancelOrderRequest is a global existence check, so a cross-tenant uuid passes validation and has to be refused in the controller. - `bulkAssignDriver()` resolves the ids through the scoped lookup first, so orders owned by another company are dropped before the update, and are neither counted in the response nor queued for driver notification. `findOrderById()` also takes the identifier as mixed and resolves anything that is not a non-empty string to null, since it is raw request input. nextActivity() no longer depends on core-api's findByIdOrFail() raising a catchable ModelNotFoundException, so its not-found branch is live regardless of the upstream release; the test documenting that dependency is updated. Tests: new OrderControllerTenantScopingTest covers, for every patched lookup, the owning-company hit, the cross-tenant miss (both the uuid and public_id arms, so a regrouped OR cannot regress), and the no-company fail-closed path, plus the endpoint-level refusals. OrderController.php is at 853/853 statements covered with no uncovered lines. --- .../Internal/v1/OrderController.php | 184 ++++++-- .../Internal/OrderControllerContractsTest.php | 13 +- .../OrderControllerImportFromFilesTest.php | 18 + .../OrderControllerTenantScopingTest.php | 394 ++++++++++++++++++ .../OrderControllerUpstreamNotFoundTest.php | 28 +- 5 files changed, 583 insertions(+), 54 deletions(-) create mode 100644 server/tests/Feature/Http/Internal/OrderControllerTenantScopingTest.php diff --git a/server/src/Http/Controllers/Internal/v1/OrderController.php b/server/src/Http/Controllers/Internal/v1/OrderController.php index e07c7bce9..1c4740c97 100644 --- a/server/src/Http/Controllers/Internal/v1/OrderController.php +++ b/server/src/Http/Controllers/Internal/v1/OrderController.php @@ -39,6 +39,7 @@ use Fleetbase\Models\Type; use Fleetbase\Support\Auth; use Fleetbase\Support\TemplateString; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Database\QueryException; use Illuminate\Http\Request; @@ -341,7 +342,8 @@ public function importFromFiles(Request $request) $info = Utils::lookupIp(); $disk = $request->input('disk', config('filesystems.default')); $files = $request->input('files'); - $files = File::whereIn('uuid', $files)->get(); + /** @var \Illuminate\Database\Eloquent\Collection $files */ + $files = $this->scopedToCompany(File::whereIn('uuid', $files))->get(); $country = $request->input('country', Utils::or($info, ['country_name', 'region'], 'Singapore')); $validFileTypes = ['csv', 'tsv', 'xls', 'xlsx']; @@ -513,7 +515,13 @@ public function bulkAssignDriver(Request $request) } // Prepare Order UUID Collection - $orderUuids = collect($data['ids'])->unique()->values(); + // + // Resolved through the company-scoped lookup so ids naming another + // organization's orders are dropped here rather than being assigned, + // counted in the response and queued for notification. + $orderUuids = $this->ordersByUuid(collect($data['ids'])->unique()->values()->all()) + ->pluck('uuid') + ->values(); // Bulk Update Inside A Transaction $this->runTransaction(function () use ($orderUuids, $driver): void { @@ -545,8 +553,14 @@ public function bulkAssignDriver(Request $request) */ public function cancel(CancelOrderRequest $request) { - /** @var Order */ + /** @var Order|null */ $order = $this->findOrderByUuid($request->input('order')); + if (!$order) { + // `exists:orders,uuid` on the form request is a global existence + // check, so a known uuid belonging to another organization reaches + // here and must be rejected rather than dereferenced. + return $this->errorResponse('No order found to cancel.'); + } $order->cancel(); @@ -599,9 +613,39 @@ public function dispatchOrder(Request $request) ); } + /** + * Constrain a tenant-owned lookup to the company the caller is acting for. + * + * The order-lifecycle actions on this controller receive their target as a + * caller-supplied identifier in the request body or query string rather than + * as a bound route parameter, so nothing upstream narrows these queries to + * the caller's tenant: `fleetbase.protected` only checks that the caller + * holds the named RBAC capability, never which company the record belongs to. + * + * A missing company session fails the query closed instead of letting it run + * unbounded across every tenant. + */ + protected function scopedToCompany(Builder $query): Builder + { + $companyUuid = $this->sessionCompany(); + if (!$companyUuid) { + $query->whereRaw('1 = 0'); + + return $query; + } + + return $query->where($query->getModel()->qualifyColumn('company_uuid'), $companyUuid); + } + + /** + * @return \Illuminate\Database\Eloquent\Collection + */ protected function ordersByUuid(array $ids) { - return Order::whereIn('uuid', $ids)->get(); + /** @var \Illuminate\Database\Eloquent\Collection $orders */ + $orders = $this->scopedToCompany(Order::whereIn('uuid', $ids))->get(); + + return $orders; } protected function trackingStatusExists(?string $trackingNumberUuid, string $code): bool @@ -611,7 +655,10 @@ protected function trackingStatusExists(?string $trackingNumberUuid, string $cod protected function findDriverByUuid(string $uuid): ?Driver { - return Driver::whereUuid($uuid)->first(); + /** @var Driver|null $driver */ + $driver = $this->scopedToCompany(Driver::whereUuid($uuid))->first(); + + return $driver; } protected function driverDisplayName(Driver $driver): string @@ -630,17 +677,47 @@ protected function validateBulkAssignDriverRequest(Request $request): array protected function findOrderByUuid(string $uuid): ?Order { - return Order::where('uuid', $uuid)->first(); + /** @var Order|null $order */ + $order = $this->scopedToCompany(Order::where('uuid', $uuid))->first(); + + return $order; } - protected function findOrderById(string $id, array $with = []): ?Order + /** + * Resolve an order by uuid or public_id, constrained to the caller's company. + * + * The identifier match is grouped so the company constraint applies to both + * arms — ungrouped it would read as `uuid = ? OR (public_id = ? AND + * company_uuid = ?)` and still resolve another organization's orders. + * + * The identifier is whatever the caller put in the request, so anything + * that is not a non-empty string resolves to no order rather than raising. + * + * @param array $with + */ + protected function findOrderById(mixed $id, array $with = []): ?Order { - return Order::findById($id, $with); + if (!is_string($id) || $id === '') { + return null; + } + + /** @var Order|null $order */ + $order = $this->scopedToCompany(Order::query()) + ->where(function (Builder $query) use ($id) { + $query->where('uuid', $id)->orWhere('public_id', $id); + }) + ->with($with) + ->first(); + + return $order; } protected function findOrderRouteForEdit(string $uuid): ?Order { - return Order::where('uuid', $uuid)->with(['payload'])->first(); + /** @var Order|null $order */ + $order = $this->scopedToCompany(Order::where('uuid', $uuid))->with(['payload'])->first(); + + return $order; } protected function orderResponse(Order $order): array @@ -650,7 +727,7 @@ protected function orderResponse(Order $order): array protected function assignDriverToOrders($orderUuids, Driver $driver): void { - Order::whereIn('uuid', $orderUuids)->update([ + $this->scopedToCompany(Order::whereIn('uuid', $orderUuids))->update([ 'driver_assigned_uuid' => $driver->uuid, 'updated_at' => now(), ]); @@ -739,17 +816,28 @@ public function start(Request $request) protected function findOrderForStart(?string $uuid): ?Order { - return Order::where('uuid', $uuid)->withoutGlobalScopes()->first(); + /** @var Order|null $order */ + $order = $this->scopedToCompany(Order::where('uuid', $uuid)->withoutGlobalScopes())->first(); + + return $order; } protected function findDriverForStart(?string $uuid): ?Driver { - return Driver::where('uuid', $uuid)->withoutGlobalScopes()->first(); + /** @var Driver|null $driver */ + $driver = $this->scopedToCompany(Driver::where('uuid', $uuid)->withoutGlobalScopes())->first(); + + return $driver; } protected function findPayloadForStart(?string $uuid): ?Payload { - return Payload::where('uuid', $uuid)->withoutGlobalScopes()->with(['waypoints', 'waypointMarkers', 'entities'])->first(); + /** @var Payload|null $payload */ + $payload = $this->scopedToCompany(Payload::where('uuid', $uuid)->withoutGlobalScopes()) + ->with(['waypoints', 'waypointMarkers', 'entities']) + ->first(); + + return $payload; } protected function dispatchDomainEvent(object $event): object @@ -771,7 +859,7 @@ protected function orderStartedEvent(Order $order): object */ public function updateActivity(string $id, Request $request) { - $order = Order::findById($id, [ + $order = $this->findOrderById($id, [ 'driverAssigned', 'payload.entities', 'payload.pickup', @@ -882,9 +970,8 @@ public function updateActivity(string $id, Request $request) */ public function nextActivity(string $id, Request $request) { - try { - $order = Order::findByIdOrFail($id); - } catch (ModelNotFoundException $e) { + $order = $this->findOrderById($id); + if (!$order) { return response()->error('No order found.'); } @@ -935,7 +1022,7 @@ public function nextActivity(string $id, Request $request) */ public function setDestination(string $id, string $placeId) { - $order = Order::findById($id, [ + $order = $this->findOrderById($id, [ 'payload.pickup', 'payload.dropoff', 'payload.return', @@ -1013,7 +1100,7 @@ function ($attribute, $value, $fail) { return response()->error($errorMessage, 422); } - $order = Order::findById($id, ['payload.pickup', 'payload.dropoff', 'payload.return', 'payload.waypoints', 'payload.waypointMarkers.place']); + $order = $this->findOrderById($id, ['payload.pickup', 'payload.dropoff', 'payload.return', 'payload.waypoints', 'payload.waypointMarkers.place']); if (!$order) { return response()->error('No order found.'); } @@ -1214,7 +1301,14 @@ protected function resolveProof($proof): ?Proof } if (is_string($proof)) { - return Proof::where('public_id', $proof)->orWhere('uuid', $proof)->first(); + /** @var Proof|null $resolved */ + $resolved = $this->scopedToCompany(Proof::query()) + ->where(function (Builder $query) use ($proof) { + $query->where('public_id', $proof)->orWhere('uuid', $proof); + }) + ->first(); + + return $resolved; } return null; @@ -1419,7 +1513,12 @@ protected function canPingDriver(): bool protected function findOrderForDriverPing(string $id): Order { - return Order::findByIdOrFail($id, ['driverAssigned']); + $order = $this->findOrderById($id, ['driverAssigned']); + if (!$order) { + throw new ModelNotFoundException(); + } + + return $order; } protected function sendDriverPing(Driver $driver, Order $order): void @@ -1692,7 +1791,10 @@ public function proofs(Request $request, string $id, ?string $subjectId = null) protected function findOrderForProofs(string $id): ?Order { - return Order::where('uuid', $id)->first(); + /** @var Order|null $order */ + $order = $this->scopedToCompany(Order::where('uuid', $id))->first(); + + return $order; } protected function findWaypointProofSubject(Order $order, string $subjectId): ?Waypoint @@ -1708,7 +1810,10 @@ protected function findWaypointProofSubject(Order $order, string $subjectId): ?W protected function findEntityProofSubject(string $subjectId): ?Entity { - return Entity::where('uuid', $subjectId)->withoutGlobalScopes()->first(); + /** @var Entity|null $entity */ + $entity = $this->scopedToCompany(Entity::where('uuid', $subjectId)->withoutGlobalScopes())->first(); + + return $entity; } protected function proofsForSubject(Order $order, Order|Waypoint|Entity $subject) @@ -1774,12 +1879,17 @@ public function lookup(Request $request) protected function findOrderByTrackingNumber(string $trackingNumber): ?Order { - return Order::whereHas( - 'trackingNumber', - function ($query) use ($trackingNumber) { - $query->where('tracking_number', $trackingNumber); - } + /** @var Order|null $order */ + $order = $this->scopedToCompany( + Order::whereHas( + 'trackingNumber', + function ($query) use ($trackingNumber) { + $query->where('tracking_number', $trackingNumber); + } + ) )->first(); + + return $order; } /** @@ -1824,13 +1934,25 @@ public function scheduleOrder(Request $request) protected function findOrderForSchedule(?string $id): ?Order { - return Order::findById($id); + return $this->findOrderById($id); } + /** + * Resolve a driver by uuid or public_id, constrained to the caller's company. + * + * The identifier match is grouped so the company constraint applies to both + * arms — ungrouped it would read as `uuid = ? OR (public_id = ? AND + * company_uuid = ?)` and still resolve another organization's drivers. + */ protected function findDriverForSchedule(string $id): ?Driver { - return Driver::where('uuid', $id) - ->orWhere('public_id', $id) + /** @var Driver|null $driver */ + $driver = $this->scopedToCompany(Driver::query()) + ->where(function (Builder $query) use ($id) { + $query->where('uuid', $id)->orWhere('public_id', $id); + }) ->first(); + + return $driver; } } diff --git a/server/tests/Feature/Http/Internal/OrderControllerContractsTest.php b/server/tests/Feature/Http/Internal/OrderControllerContractsTest.php index 400e68b11..6c815da8a 100644 --- a/server/tests/Feature/Http/Internal/OrderControllerContractsTest.php +++ b/server/tests/Feature/Http/Internal/OrderControllerContractsTest.php @@ -298,7 +298,7 @@ protected function findOrderByUuid(string $uuid): ?Order return $this->order; } - protected function findOrderById(string $id, array $with = []): ?Order + protected function findOrderById(mixed $id, array $with = []): ?Order { $this->order?->setAttribute('lookup_id', $id); $this->order?->setAttribute('lookup_with', $with); @@ -990,10 +990,15 @@ function fleetopsSuppressStrNullDeprecations(): Closure }); test('internal order controller bulk assign driver deduplicates orders and queues notifications', function () { - $controller = fleetopsInternalOrderLifecycleController(); $driverUuid = '11111111-1111-4111-8111-111111111111'; $orderA = '22222222-2222-4222-8222-222222222222'; $orderB = '33333333-3333-4333-8333-333333333333'; + // The ids are resolved through the company-scoped `ordersByUuid()` lookup, so + // only orders it returns are assigned, counted and notified. + $controller = fleetopsInternalOrderLifecycleController([ + fleetopsInternalOrderLifecycleOrder($orderA), + fleetopsInternalOrderLifecycleOrder($orderB), + ]); $response = $controller->bulkAssignDriver(fleetopsBulkActionRequest([ 'ids' => [$orderA, $orderA, $orderB], @@ -1011,7 +1016,9 @@ function fleetopsSuppressStrNullDeprecations(): Closure ->and($controller->assignedDriverUuid)->toBe($driverUuid) ->and($controller->bulkNotification)->toBe([[$orderA, $orderB], $driverUuid]); - $controller = fleetopsInternalOrderLifecycleController(); + $controller = fleetopsInternalOrderLifecycleController([ + fleetopsInternalOrderLifecycleOrder($orderA), + ]); $controller->bulkAssignDriver(fleetopsBulkActionRequest([ 'ids' => [$orderA], 'driver' => $driverUuid, diff --git a/server/tests/Feature/Http/Internal/OrderControllerImportFromFilesTest.php b/server/tests/Feature/Http/Internal/OrderControllerImportFromFilesTest.php index 48d7fa42a..cd4cba0b2 100644 --- a/server/tests/Feature/Http/Internal/OrderControllerImportFromFilesTest.php +++ b/server/tests/Feature/Http/Internal/OrderControllerImportFromFilesTest.php @@ -177,3 +177,21 @@ function fleetopsOrderImportFilesRequest(array $input): Request ])); expect($unreadable->getStatusCode())->toBeGreaterThanOrEqual(400); }); + +test('import ignores files uploaded by another company', function () { + $connection = fleetopsOrderImportFilesBoot(); + $connection->table('files')->insert(['uuid' => '33333333-3333-4333-8333-333333333333', 'public_id' => 'file_ordimport4', 'company_uuid' => 'company-2', 'path' => 'uploads/orders.xlsx', 'disk' => 'local']); + fleetopsOrderImportFilesExcelFake([[ + ['name' => 'Victim Stop', 'street1' => 'Victim Rd 1', 'city' => 'Singapore'], + ]]); + + // The file uuid is caller-supplied and nothing upstream checks who owns it, + // so an unscoped lookup here would read a rival company's spreadsheet. + $response = (new OrderController())->importFromFiles(fleetopsOrderImportFilesRequest([ + 'files' => ['33333333-3333-4333-8333-333333333333'], + ])); + + $data = $response->getData(true); + expect($data['places'])->toHaveCount(0) + ->and($data['entities'])->toHaveCount(0); +}); diff --git a/server/tests/Feature/Http/Internal/OrderControllerTenantScopingTest.php b/server/tests/Feature/Http/Internal/OrderControllerTenantScopingTest.php new file mode 100644 index 000000000..fbcc3d182 --- /dev/null +++ b/server/tests/Feature/Http/Internal/OrderControllerTenantScopingTest.php @@ -0,0 +1,394 @@ +{$method}(...$arguments); + } +} + +const FLEETOPS_TENANT_OWN_ORDER = '55555555-5555-4555-8555-555555555501'; +const FLEETOPS_TENANT_VICTIM_ORDER = '55555555-5555-4555-8555-555555555502'; +const FLEETOPS_TENANT_OWN_DRIVER = '55555555-5555-4555-8555-555555555511'; +const FLEETOPS_TENANT_VICTIM_DRIVER = '55555555-5555-4555-8555-555555555512'; + +function fleetopsOrderTenantBoot(): SQLiteConnection +{ + $connection = new SQLiteConnection(new PDO('sqlite::memory:')); + $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); + } + }); + Illuminate\Support\Facades\DB::clearResolvedInstance('db'); + + $schema = $connection->getSchemaBuilder(); + $tables = [ + 'orders' => ['uuid', 'public_id', 'company_uuid', 'payload_uuid', 'order_config_uuid', 'tracking_number_uuid', 'driver_assigned_uuid', 'status', 'type', 'adhoc', 'dispatched', 'started', 'scheduled_at', 'meta', '_key'], + 'payloads' => ['uuid', 'public_id', 'company_uuid', 'pickup_uuid', 'dropoff_uuid', 'current_waypoint_uuid', 'meta', 'type'], + 'places' => ['uuid', 'public_id', 'company_uuid', 'name', 'location'], + 'waypoints' => ['uuid', 'public_id', 'company_uuid', 'payload_uuid', 'place_uuid', 'tracking_number_uuid', 'order', 'type'], + 'entities' => ['uuid', 'public_id', 'company_uuid', 'payload_uuid', 'destination_uuid', 'tracking_number_uuid', 'name', 'type'], + 'drivers' => ['uuid', 'public_id', 'company_uuid', 'user_uuid', 'vehicle_uuid', 'status', 'online', 'location', 'current_job_uuid'], + 'users' => ['uuid', 'public_id', 'company_uuid', 'name', 'status', 'type'], + 'tracking_numbers' => ['uuid', 'public_id', 'company_uuid', 'tracking_number', 'owner_uuid', 'owner_type', '_key'], + 'tracking_statuses' => ['uuid', 'public_id', 'company_uuid', 'tracking_number_uuid', 'status', 'details', 'code', '_key'], + 'proofs' => ['uuid', 'public_id', 'company_uuid', 'order_uuid', 'subject_uuid', 'subject_type', 'file_uuid', 'remarks', 'raw_data', 'data'], + 'companies' => ['uuid', 'public_id', 'name', 'country'], + ]; + foreach ($tables as $table => $columns) { + $schema->create($table, function ($blueprint) use ($columns) { + $blueprint->increments('id'); + foreach ($columns as $column) { + $blueprint->string($column)->nullable(); + } + $blueprint->timestamps(); + $blueprint->timestamp('deleted_at')->nullable(); + }); + } + + session(['company' => 'company-1']); + + return $connection; +} + +/** + * Seeds one order/driver/payload per tenant. The two tenants' records are + * identical apart from `company_uuid`, so any lookup that resolves the + * `company-2` identifier is crossing the tenant boundary. + */ +function fleetopsOrderTenantSeed(SQLiteConnection $connection): void +{ + $connection->table('users')->insert([ + ['uuid' => 'user-own', 'company_uuid' => 'company-1', 'name' => 'Own Driver'], + ['uuid' => 'user-victim', 'company_uuid' => 'company-2', 'name' => 'Victim Driver'], + ]); + $connection->table('drivers')->insert([ + ['uuid' => FLEETOPS_TENANT_OWN_DRIVER, 'public_id' => 'driver_own1', 'company_uuid' => 'company-1', 'user_uuid' => 'user-own'], + ['uuid' => FLEETOPS_TENANT_VICTIM_DRIVER, 'public_id' => 'driver_victim1', 'company_uuid' => 'company-2', 'user_uuid' => 'user-victim'], + ]); + $connection->table('payloads')->insert([ + ['uuid' => 'payload-own', 'company_uuid' => 'company-1'], + ['uuid' => 'payload-victim', 'company_uuid' => 'company-2'], + ]); + $connection->table('tracking_numbers')->insert([ + ['uuid' => 'tn-own', 'company_uuid' => 'company-1', 'tracking_number' => 'FLB-OWN-1'], + ['uuid' => 'tn-victim', 'company_uuid' => 'company-2', 'tracking_number' => 'FLB-VICTIM-1'], + ]); + $connection->table('orders')->insert([ + [ + 'uuid' => FLEETOPS_TENANT_OWN_ORDER, + 'public_id' => 'order_own1', + 'company_uuid' => 'company-1', + 'payload_uuid' => 'payload-own', + 'tracking_number_uuid' => 'tn-own', + 'driver_assigned_uuid' => FLEETOPS_TENANT_OWN_DRIVER, + 'status' => 'created', + 'type' => 'transport', + ], + [ + 'uuid' => FLEETOPS_TENANT_VICTIM_ORDER, + 'public_id' => 'order_victim1', + 'company_uuid' => 'company-2', + 'payload_uuid' => 'payload-victim', + 'tracking_number_uuid' => 'tn-victim', + 'driver_assigned_uuid' => FLEETOPS_TENANT_VICTIM_DRIVER, + 'status' => 'created', + 'type' => 'transport', + ], + ]); + $connection->table('entities')->insert([ + ['uuid' => 'entity-own', 'company_uuid' => 'company-1', 'payload_uuid' => 'payload-own', 'name' => 'Own Parcel'], + ['uuid' => 'entity-victim', 'company_uuid' => 'company-2', 'payload_uuid' => 'payload-victim', 'name' => 'Victim Parcel'], + ]); + $connection->table('proofs')->insert([ + ['uuid' => 'proof-own', 'public_id' => 'proof_own1', 'company_uuid' => 'company-1', 'order_uuid' => FLEETOPS_TENANT_OWN_ORDER, 'subject_uuid' => FLEETOPS_TENANT_OWN_ORDER], + ['uuid' => 'proof-victim', 'public_id' => 'proof_victim1', 'company_uuid' => 'company-2', 'order_uuid' => FLEETOPS_TENANT_VICTIM_ORDER, 'subject_uuid' => FLEETOPS_TENANT_VICTIM_ORDER], + ]); +} + +test('order lookups resolve the callers own records', function () { + $connection = fleetopsOrderTenantBoot(); + fleetopsOrderTenantSeed($connection); + $probe = new FleetOpsInternalOrderTenantScopeProbe(); + + expect($probe->callHelper('ordersByUuid', [FLEETOPS_TENANT_OWN_ORDER])->pluck('uuid')->all())->toBe([FLEETOPS_TENANT_OWN_ORDER]) + ->and($probe->callHelper('findOrderByUuid', FLEETOPS_TENANT_OWN_ORDER)?->public_id)->toBe('order_own1') + ->and($probe->callHelper('findOrderById', FLEETOPS_TENANT_OWN_ORDER)?->public_id)->toBe('order_own1') + ->and($probe->callHelper('findOrderById', 'order_own1')?->uuid)->toBe(FLEETOPS_TENANT_OWN_ORDER) + ->and($probe->callHelper('findOrderRouteForEdit', FLEETOPS_TENANT_OWN_ORDER)?->uuid)->toBe(FLEETOPS_TENANT_OWN_ORDER) + ->and($probe->callHelper('findOrderForStart', FLEETOPS_TENANT_OWN_ORDER)?->uuid)->toBe(FLEETOPS_TENANT_OWN_ORDER) + ->and($probe->callHelper('findPayloadForStart', 'payload-own')?->uuid)->toBe('payload-own') + ->and($probe->callHelper('findOrderForSchedule', 'order_own1')?->uuid)->toBe(FLEETOPS_TENANT_OWN_ORDER) + ->and($probe->callHelper('findOrderForProofs', FLEETOPS_TENANT_OWN_ORDER)?->uuid)->toBe(FLEETOPS_TENANT_OWN_ORDER) + ->and($probe->callHelper('findOrderForDriverPing', 'order_own1')?->uuid)->toBe(FLEETOPS_TENANT_OWN_ORDER) + ->and($probe->callHelper('findOrderByTrackingNumber', 'FLB-OWN-1')?->uuid)->toBe(FLEETOPS_TENANT_OWN_ORDER) + ->and($probe->callHelper('findEntityProofSubject', 'entity-own')?->uuid)->toBe('entity-own') + ->and($probe->callHelper('resolveProof', 'proof_own1')?->uuid)->toBe('proof-own') + ->and($probe->callHelper('resolveProof', 'proof-own')?->uuid)->toBe('proof-own'); +}); + +test('driver lookups resolve the callers own drivers', function () { + $connection = fleetopsOrderTenantBoot(); + fleetopsOrderTenantSeed($connection); + $probe = new FleetOpsInternalOrderTenantScopeProbe(); + + expect($probe->callHelper('findDriverByUuid', FLEETOPS_TENANT_OWN_DRIVER)?->public_id)->toBe('driver_own1') + ->and($probe->callHelper('findDriverForStart', FLEETOPS_TENANT_OWN_DRIVER)?->public_id)->toBe('driver_own1') + ->and($probe->callHelper('findDriverForSchedule', FLEETOPS_TENANT_OWN_DRIVER)?->public_id)->toBe('driver_own1') + ->and($probe->callHelper('findDriverForSchedule', 'driver_own1')?->uuid)->toBe(FLEETOPS_TENANT_OWN_DRIVER); +}); + +test('order lookups refuse identifiers belonging to another company', function () { + $connection = fleetopsOrderTenantBoot(); + fleetopsOrderTenantSeed($connection); + $probe = new FleetOpsInternalOrderTenantScopeProbe(); + + // Both identifier arms are covered: an unguarded + // `where(uuid)->orWhere(public_id)->where(company_uuid)` chain would read as + // `uuid = ? OR (public_id = ? AND company_uuid = ?)` and still resolve the + // victim by uuid. + expect($probe->callHelper('ordersByUuid', [FLEETOPS_TENANT_VICTIM_ORDER]))->toHaveCount(0) + ->and($probe->callHelper('findOrderByUuid', FLEETOPS_TENANT_VICTIM_ORDER))->toBeNull() + ->and($probe->callHelper('findOrderById', FLEETOPS_TENANT_VICTIM_ORDER))->toBeNull() + ->and($probe->callHelper('findOrderById', 'order_victim1'))->toBeNull() + ->and($probe->callHelper('findOrderRouteForEdit', FLEETOPS_TENANT_VICTIM_ORDER))->toBeNull() + ->and($probe->callHelper('findOrderForStart', FLEETOPS_TENANT_VICTIM_ORDER))->toBeNull() + ->and($probe->callHelper('findPayloadForStart', 'payload-victim'))->toBeNull() + ->and($probe->callHelper('findOrderForSchedule', 'order_victim1'))->toBeNull() + ->and($probe->callHelper('findOrderForProofs', FLEETOPS_TENANT_VICTIM_ORDER))->toBeNull() + ->and($probe->callHelper('findOrderByTrackingNumber', 'FLB-VICTIM-1'))->toBeNull() + ->and($probe->callHelper('findEntityProofSubject', 'entity-victim'))->toBeNull() + ->and($probe->callHelper('resolveProof', 'proof_victim1'))->toBeNull() + ->and($probe->callHelper('resolveProof', 'proof-victim'))->toBeNull() + ->and($probe->callHelper('findDriverByUuid', FLEETOPS_TENANT_VICTIM_DRIVER))->toBeNull() + ->and($probe->callHelper('findDriverForStart', FLEETOPS_TENANT_VICTIM_DRIVER))->toBeNull() + ->and($probe->callHelper('findDriverForSchedule', FLEETOPS_TENANT_VICTIM_DRIVER))->toBeNull() + ->and($probe->callHelper('findDriverForSchedule', 'driver_victim1'))->toBeNull(); + + // The ping lookup reports a miss the same way it reports an unknown id, so + // the endpoint cannot be used to probe which ids exist in other tenants. + expect(fn () => $probe->callHelper('findOrderForDriverPing', 'order_victim1')) + ->toThrow(ModelNotFoundException::class); +}); + +test('order lookups fail closed when no company session is present', function () { + $connection = fleetopsOrderTenantBoot(); + fleetopsOrderTenantSeed($connection); + session(['company' => null]); + $probe = new FleetOpsInternalOrderTenantScopeProbe(); + + expect($probe->callHelper('ordersByUuid', [FLEETOPS_TENANT_OWN_ORDER, FLEETOPS_TENANT_VICTIM_ORDER]))->toHaveCount(0) + ->and($probe->callHelper('findOrderByUuid', FLEETOPS_TENANT_OWN_ORDER))->toBeNull() + ->and($probe->callHelper('findOrderById', 'order_own1'))->toBeNull() + ->and($probe->callHelper('findDriverByUuid', FLEETOPS_TENANT_OWN_DRIVER))->toBeNull() + ->and($probe->callHelper('findDriverForSchedule', 'driver_own1'))->toBeNull(); +}); + +test('order resolution by id rejects empty identifiers without querying', function () { + $connection = fleetopsOrderTenantBoot(); + fleetopsOrderTenantSeed($connection); + $probe = new FleetOpsInternalOrderTenantScopeProbe(); + + // The identifier is raw request input, so a non-string body value has to + // resolve to no order rather than raising out of the endpoint. + expect($probe->callHelper('findOrderById', null))->toBeNull() + ->and($probe->callHelper('findOrderById', ''))->toBeNull() + ->and($probe->callHelper('findOrderById', ['uuid' => FLEETOPS_TENANT_OWN_ORDER]))->toBeNull() + ->and($probe->callHelper('findOrderById', 42))->toBeNull() + ->and($probe->callHelper('findOrderForSchedule', null))->toBeNull(); +}); + +test('bulk driver assignment only touches orders the caller owns', function () { + $connection = fleetopsOrderTenantBoot(); + fleetopsOrderTenantSeed($connection); + $connection->table('orders')->update(['driver_assigned_uuid' => null]); + $controller = new OrderController(); + + $response = $controller->bulkAssignDriver(Request::create('/x', 'PATCH', [ + 'ids' => [FLEETOPS_TENANT_OWN_ORDER, FLEETOPS_TENANT_VICTIM_ORDER], + 'driver' => FLEETOPS_TENANT_OWN_DRIVER, + 'silent' => true, + ])); + + // The victim order is dropped before the update, so it is neither + // reassigned nor counted in the response. + expect($response->getData(true)['count'])->toBe(1) + ->and($connection->table('orders')->where('uuid', FLEETOPS_TENANT_OWN_ORDER)->value('driver_assigned_uuid'))->toBe(FLEETOPS_TENANT_OWN_DRIVER) + ->and($connection->table('orders')->where('uuid', FLEETOPS_TENANT_VICTIM_ORDER)->value('driver_assigned_uuid'))->toBeNull(); +}); + +test('bulk driver assignment refuses a driver from another company', function () { + $connection = fleetopsOrderTenantBoot(); + fleetopsOrderTenantSeed($connection); + $controller = new OrderController(); + + $response = $controller->bulkAssignDriver(Request::create('/x', 'PATCH', [ + 'ids' => [FLEETOPS_TENANT_OWN_ORDER], + 'driver' => FLEETOPS_TENANT_VICTIM_DRIVER, + 'silent' => true, + ])); + + expect($response->getData(true)['error'] ?? '')->toContain('Invalid driver selected'); +}); + +test('the bulk assignment update is itself scoped to the callers company', function () { + $connection = fleetopsOrderTenantBoot(); + fleetopsOrderTenantSeed($connection); + $connection->table('orders')->update(['driver_assigned_uuid' => null]); + $probe = new FleetOpsInternalOrderTenantScopeProbe(); + $driver = Driver::where('uuid', FLEETOPS_TENANT_OWN_DRIVER)->first(); + + // Called directly with a victim uuid, standing in for any future caller + // that reaches this seam without pre-filtering the ids. + $probe->callHelper('assignDriverToOrders', [FLEETOPS_TENANT_OWN_ORDER, FLEETOPS_TENANT_VICTIM_ORDER], $driver); + + expect($connection->table('orders')->where('uuid', FLEETOPS_TENANT_OWN_ORDER)->value('driver_assigned_uuid'))->toBe(FLEETOPS_TENANT_OWN_DRIVER) + ->and($connection->table('orders')->where('uuid', FLEETOPS_TENANT_VICTIM_ORDER)->value('driver_assigned_uuid'))->toBeNull(); +}); + +test('bulk cancel and bulk dispatch skip orders from another company', function () { + $connection = fleetopsOrderTenantBoot(); + fleetopsOrderTenantSeed($connection); + $controller = new OrderController(); + + $canceled = $controller->bulkCancel(Request::create('/x', 'PATCH', [ + 'ids' => [FLEETOPS_TENANT_VICTIM_ORDER], + ])); + expect($canceled->getData(true)['count'])->toBe(0) + ->and($connection->table('orders')->where('uuid', FLEETOPS_TENANT_VICTIM_ORDER)->value('status'))->toBe('created'); + + $dispatched = $controller->bulkDispatch(BulkDispatchRequest::create('/x', 'POST', [ + 'ids' => [FLEETOPS_TENANT_VICTIM_ORDER], + ])); + expect($dispatched->getData(true)['count'])->toBe(0) + ->and($connection->table('orders')->where('uuid', FLEETOPS_TENANT_VICTIM_ORDER)->value('dispatched'))->toBeNull(); +}); + +test('cancel rejects a known order uuid that belongs to another company', function () { + $connection = fleetopsOrderTenantBoot(); + fleetopsOrderTenantSeed($connection); + + // `exists:orders,uuid` on CancelOrderRequest is a global existence check, so + // this uuid passes validation and the controller itself has to refuse it. + $response = (new OrderController())->cancel( + CancelOrderRequest::create('/x', 'PATCH', ['order' => FLEETOPS_TENANT_VICTIM_ORDER]) + ); + + expect($response->getData(true)['error'] ?? '')->toContain('No order found to cancel') + ->and($connection->table('orders')->where('uuid', FLEETOPS_TENANT_VICTIM_ORDER)->value('status'))->toBe('created'); +}); + +test('dispatch start and schedule refuse another companys order', function () { + $connection = fleetopsOrderTenantBoot(); + fleetopsOrderTenantSeed($connection); + $controller = new OrderController(); + + $dispatched = $controller->dispatchOrder(Request::create('/x', 'PATCH', ['order' => FLEETOPS_TENANT_VICTIM_ORDER])); + expect($dispatched->getData(true)['error'] ?? '')->toContain('No order found to dispatch'); + + $started = $controller->start(Request::create('/x', 'PATCH', ['order' => FLEETOPS_TENANT_VICTIM_ORDER])); + expect($started->getData(true)['error'] ?? '')->toContain('Unable to find order to start'); + + $scheduled = $controller->scheduleOrder(Request::create('/x', 'PATCH', [ + 'order' => FLEETOPS_TENANT_VICTIM_ORDER, + 'scheduled_at' => '2026-01-01 09:00:00', + ])); + expect($scheduled->getData(true)['error'] ?? '')->toContain('No order found to schedule') + ->and($connection->table('orders')->where('uuid', FLEETOPS_TENANT_VICTIM_ORDER)->value('scheduled_at'))->toBeNull() + ->and($connection->table('drivers')->where('uuid', FLEETOPS_TENANT_VICTIM_DRIVER)->value('current_job_uuid'))->toBeNull(); +}); + +test('schedule ignores a driver from another company', function () { + $connection = fleetopsOrderTenantBoot(); + fleetopsOrderTenantSeed($connection); + $connection->table('orders')->where('uuid', FLEETOPS_TENANT_OWN_ORDER)->update(['driver_assigned_uuid' => null]); + + $response = (new OrderController())->scheduleOrder(Request::create('/x', 'PATCH', [ + 'order' => FLEETOPS_TENANT_OWN_ORDER, + 'driver_id' => 'driver_victim1', + ])); + + expect($response->getData(true)['status'])->toBe('OK') + ->and($connection->table('orders')->where('uuid', FLEETOPS_TENANT_OWN_ORDER)->value('driver_assigned_uuid'))->toBeNull(); +}); + +test('activity destination and next-activity endpoints refuse another companys order', function () { + $connection = fleetopsOrderTenantBoot(); + fleetopsOrderTenantSeed($connection); + $controller = new OrderController(); + + $nextActivity = $controller->nextActivity('order_victim1', Request::create('/x', 'GET')); + expect($nextActivity->getData(true))->toBe(['error' => 'No order found.']); + + $updateActivity = $controller->updateActivity(FLEETOPS_TENANT_VICTIM_ORDER, Request::create('/x', 'PATCH', ['activity' => []])); + expect($updateActivity->getData(true))->toBe(['error' => 'No order found.']); + + $setDestination = $controller->setDestination(FLEETOPS_TENANT_VICTIM_ORDER, 'place-victim'); + expect($setDestination->getData(true))->toBe(['error' => 'No order found.']); + + $trackerInfo = $controller->trackerInfo(Request::create('/x', 'GET'), FLEETOPS_TENANT_VICTIM_ORDER); + expect($trackerInfo->getData(true))->toBe(['error' => 'No order found.']); + + $waypointEtas = $controller->waypointEtas(Request::create('/x', 'GET'), FLEETOPS_TENANT_VICTIM_ORDER); + expect($waypointEtas->getData(true))->toBe(['error' => 'No order found.']); + + $editRoute = $controller->editOrderRoute(FLEETOPS_TENANT_VICTIM_ORDER, Request::create('/x', 'PATCH')); + expect($editRoute->getData(true)['error'] ?? '')->toContain('Unable to find order to update route for'); +}); + +test('proofs endpoint refuses another companys order', function () { + $connection = fleetopsOrderTenantBoot(); + fleetopsOrderTenantSeed($connection); + + $response = (new OrderController())->proofs(Request::create('/x', 'GET'), FLEETOPS_TENANT_VICTIM_ORDER); + + expect($response->getData(true)['error'] ?? '')->toContain('Unable to retrieve proof'); +}); diff --git a/server/tests/Feature/Http/Internal/OrderControllerUpstreamNotFoundTest.php b/server/tests/Feature/Http/Internal/OrderControllerUpstreamNotFoundTest.php index deed7491c..cd0cc3069 100644 --- a/server/tests/Feature/Http/Internal/OrderControllerUpstreamNotFoundTest.php +++ b/server/tests/Feature/Http/Internal/OrderControllerUpstreamNotFoundTest.php @@ -6,27 +6,15 @@ /** * Covers the not-found branch of Internal\v1\OrderController::nextActivity(). * - * --------------------------------------------------------------------------- - * THIS FILE IS EXPECTED TO FAIL until fleetbase/core-api 1.6.55 is released and - * pulled into server_vendor. Do not "fix" it by weakening the assertion. - * --------------------------------------------------------------------------- + * This branch used to depend on upstream behaviour that never fired: the + * controller wrapped `Order::findByIdOrFail($id)` in a + * `catch (ModelNotFoundException)`, but core-api's findByIdOrFail() raised a + * BadMethodCallException that escaped the catch and surfaced as a 500. * - * The controller wraps `Order::findByIdOrFail($id)` in a - * `catch (ModelNotFoundException)` that returns 'No order found.'. Today that - * catch never fires: core-api's Model::findByIdOrFail() calls a - * getModelNotFoundException() method that does not exist on Eloquent's builder, - * so a missing order raises BadMethodCallException, escapes the catch, and - * surfaces as a 500 instead of the intended error response. - * - * core-api#231 (branch dev-v1.6.55) replaces that with - * `throw (new ModelNotFoundException())->setModel(static::class, [$identifier]);` - * which makes this branch live. The assertion below states the post-fix - * contract deliberately — asserting today's BadMethodCallException would - * codify the defect instead of the intent. - * - * If CI must be green before that release lands, neutralise this file with a - * single `->skip('pending fleetbase/core-api 1.6.55')` on the test below rather - * than changing what it asserts. + * nextActivity() now resolves the order through the controller's own + * company-scoped `findOrderById()` and returns the error response on a null + * result, so the branch is live here regardless of the upstream release: an + * unknown id and an id belonging to another company are reported identically. */ function fleetopsUpstreamNotFoundBoot(): Illuminate\Database\SQLiteConnection { From e9977788be11d2c315df9d71a558b25e29cfb22f Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sun, 20 Sep 2026 20:21:10 +0800 Subject: [PATCH 03/19] fix(ai): expose route optimization and import requirements as tools Tool calling only reaches capabilities implementing `AIToolCapabilityInterface`, so the seven Fleet-Ops capabilities without a tool definition were unreachable once the AI extension defaulted to tools. Two of them had no replacement anywhere: route optimization and order import guidance simply stopped working. `OptimizeOrderRouteTool` wraps the existing preview capability so the model can propose a resequenced route for one order. As before, the proposal is a card the user confirms; nothing changes until they apply it. `ImportOrdersTool` reports the accepted file formats and required spreadsheet columns. It deliberately cannot read an uploaded file, and says so, so the model does not claim rows were imported. The remaining five are genuinely superseded by the core tools: `docs_help` by `search_docs`, `console_navigation` by `find_console_commands`, and `operational_query`, `order_insights` and `asset_status` by the generic record counting and listing tools. --- .../src/Providers/FleetOpsServiceProvider.php | 2 + .../src/Support/Ai/Tools/ImportOrdersTool.php | 53 ++++++++++++ .../Ai/Tools/OptimizeOrderRouteTool.php | 70 +++++++++++++++ .../Providers/FleetOpsAiRegistrationTest.php | 4 +- .../Support/Ai/Tools/FleetOpsAiToolsTest.php | 85 +++++++++++++++++++ 5 files changed, 213 insertions(+), 1 deletion(-) create mode 100644 server/src/Support/Ai/Tools/ImportOrdersTool.php create mode 100644 server/src/Support/Ai/Tools/OptimizeOrderRouteTool.php diff --git a/server/src/Providers/FleetOpsServiceProvider.php b/server/src/Providers/FleetOpsServiceProvider.php index d02ea07cf..93b8394a9 100644 --- a/server/src/Providers/FleetOpsServiceProvider.php +++ b/server/src/Providers/FleetOpsServiceProvider.php @@ -296,6 +296,8 @@ protected function registerAiCapabilities(): void if (interface_exists(\Fleetbase\Ai\Contracts\AIToolCapabilityInterface::class)) { $registry->register(new \Fleetbase\FleetOps\Support\Ai\Tools\SearchResourcesTool()); $registry->register(new \Fleetbase\FleetOps\Support\Ai\Tools\CreateOrderTool()); + $registry->register(new \Fleetbase\FleetOps\Support\Ai\Tools\OptimizeOrderRouteTool()); + $registry->register(new \Fleetbase\FleetOps\Support\Ai\Tools\ImportOrdersTool()); } }); } diff --git a/server/src/Support/Ai/Tools/ImportOrdersTool.php b/server/src/Support/Ai/Tools/ImportOrdersTool.php new file mode 100644 index 000000000..3bb89fdce --- /dev/null +++ b/server/src/Support/Ai/Tools/ImportOrdersTool.php @@ -0,0 +1,53 @@ + 'object', + 'properties' => new \stdClass(), + 'additionalProperties' => false, + ]; + } + + public function availableFor(AiToolContext $context): bool + { + return $context->audience->canAll($this->permissions()); + } + + public function invoke(AiTask $task, array $arguments, AiToolContext $context): array + { + $requirements = $this->resolve($task); + + return [ + 'accepted_sources' => $requirements['accepted_sources'] ?? [], + 'minimum_columns' => $requirements['minimum_columns'] ?? [], + 'can_import_here' => false, + 'message' => $requirements['message'] ?? 'Fleetbase AI cannot process uploaded spreadsheets.', + ]; + } +} diff --git a/server/src/Support/Ai/Tools/OptimizeOrderRouteTool.php b/server/src/Support/Ai/Tools/OptimizeOrderRouteTool.php new file mode 100644 index 000000000..b3722692e --- /dev/null +++ b/server/src/Support/Ai/Tools/OptimizeOrderRouteTool.php @@ -0,0 +1,70 @@ + 'object', + 'properties' => [ + 'order' => ['type' => 'string', 'description' => 'The order public id, internal id, or UUID, exactly as the user gave it.'], + ], + 'required' => ['order'], + 'additionalProperties' => false, + ]; + } + + public function availableFor(AiToolContext $context): bool + { + return $context->audience->canAll($this->permissions()); + } + + public function invoke(AiTask $task, array $arguments, AiToolContext $context): array + { + $order = trim((string) ($arguments['order'] ?? '')); + + if ($order === '') { + return ['error' => 'invalid_arguments', 'message' => 'An order id is required to optimize a route.']; + } + + // The capability resolves the order from the prompt text, so hand it a copy carrying just the + // identifier the model supplied. The copy is never saved. + $probe = clone $task; + $probe->prompt = $order; + + $preview = $context->addActionPreview($this, $this->preview($probe)); + + return [ + 'preview_id' => $preview['preview_id'], + 'ready' => $preview['ready'] ?? false, + 'missing_fields' => $preview['missing_fields'] ?? [], + 'fields' => $preview['fields'] ?? [], + 'status' => 'proposal_shown_to_user', + 'message' => $preview['message'] ?? 'A route proposal card is shown to the user.', + ]; + } +} diff --git a/server/tests/Unit/Providers/FleetOpsAiRegistrationTest.php b/server/tests/Unit/Providers/FleetOpsAiRegistrationTest.php index a6883f2db..b3364a1d5 100644 --- a/server/tests/Unit/Providers/FleetOpsAiRegistrationTest.php +++ b/server/tests/Unit/Providers/FleetOpsAiRegistrationTest.php @@ -48,12 +48,14 @@ $capabilityRegistry = app(Fleetbase\Ai\Support\AiCapabilityRegistry::class); fwrite(STDERR, "\nDBG ai: " . json_encode([is_object($queryRegistry) ? get_class($queryRegistry) : gettype($queryRegistry), is_object($capabilityRegistry) ? get_class($capabilityRegistry) : gettype($capabilityRegistry), is_object($capabilityRegistry) ? count($capabilityRegistry->registered) : null]) . "\n"); - expect($capabilityRegistry->registered)->toHaveCount(11) + expect($capabilityRegistry->registered)->toHaveCount(13) ->and(collect($capabilityRegistry->registered)->map(fn ($capability) => get_class($capability))) ->toContain( Fleetbase\FleetOps\Support\Ai\Capabilities\SearchResourcesCapability::class, Fleetbase\FleetOps\Support\Ai\Tools\SearchResourcesTool::class, Fleetbase\FleetOps\Support\Ai\Tools\CreateOrderTool::class, + Fleetbase\FleetOps\Support\Ai\Tools\OptimizeOrderRouteTool::class, + Fleetbase\FleetOps\Support\Ai\Tools\ImportOrdersTool::class, ); $commandRegistry = app(Fleetbase\Ai\Support\Commands\AiCommandRegistry::class); diff --git a/server/tests/Unit/Support/Ai/Tools/FleetOpsAiToolsTest.php b/server/tests/Unit/Support/Ai/Tools/FleetOpsAiToolsTest.php index 4093b228e..766b84930 100644 --- a/server/tests/Unit/Support/Ai/Tools/FleetOpsAiToolsTest.php +++ b/server/tests/Unit/Support/Ai/Tools/FleetOpsAiToolsTest.php @@ -159,3 +159,88 @@ protected function drivers(array $terms): array expect($tool->called)->toBe(['orders']) ->and($result['results'])->toBe(['orders' => [['id' => 'order_1']]]); }); + +class FleetOpsOptimizeOrderRouteToolProbe extends Fleetbase\FleetOps\Support\Ai\Tools\OptimizeOrderRouteTool +{ + public array $previewedPrompts = []; + + public function preview(AiTask $task, array $input = []): array + { + $this->previewedPrompts[] = $task->prompt; + + return [ + 'action' => $this->key(), + 'ready' => true, + 'message' => 'Fleetbase AI prepared an optimized waypoint sequence. Review it before applying.', + 'missing_fields' => [], + 'fields' => [['label' => 'Order', 'value' => 'order_abc']], + ]; + } +} + +test('optimize route tool proposes a reviewable preview for the order the model named', function () { + $tool = new FleetOpsOptimizeOrderRouteToolProbe(); + $task = new AiTask(['prompt' => 'can you optimise the route please', 'metadata' => []]); + $context = new AiToolContext($task, new AiAudience(false, ['fleet-ops optimize order', 'fleet-ops update-route-for order'])); + + $result = $tool->invoke($task, ['order' => 'order_abc'], $context); + + expect($result['preview_id'])->toBe('preview-1') + ->and($result['ready'])->toBeTrue() + ->and($result['status'])->toBe('proposal_shown_to_user') + ->and($result['fields'])->toBe([['label' => 'Order', 'value' => 'order_abc']]) + ->and($result['missing_fields'])->toBe([]) + // The capability finds the order in the prompt text, so the tool hands it the id the model gave. + ->and($tool->previewedPrompts)->toBe(['order_abc']) + ->and($task->prompt)->toBe('can you optimise the route please') + ->and($context->actionPreviews)->toHaveCount(1) + ->and($tool->toolName())->toBe('propose_optimize_order_route') + ->and($tool->key())->toBe('fleet-ops.optimize_order_route') + ->and($tool->toolDescription())->toContain('NOT changed by this tool') + ->and($tool->toolParameters()['required'])->toBe(['order']) + ->and($tool->availableFor($context))->toBeTrue() + ->and($tool->availableFor(new AiToolContext($task, new AiAudience(false, ['fleet-ops optimize order']))))->toBeFalse(); +}); + +test('optimize route tool refuses to build a preview without an order', function () { + $tool = new FleetOpsOptimizeOrderRouteToolProbe(); + $task = new AiTask(['prompt' => 'optimise a route', 'metadata' => []]); + $context = new AiToolContext($task, new AiAudience(false, ['fleet-ops optimize order', 'fleet-ops update-route-for order'])); + + expect($tool->invoke($task, ['order' => ' '], $context))->toBe([ + 'error' => 'invalid_arguments', + 'message' => 'An order id is required to optimize a route.', + ]) + ->and($tool->invoke($task, [], $context)['error'])->toBe('invalid_arguments') + ->and($context->actionPreviews)->toBe([]) + ->and($tool->previewedPrompts)->toBe([]); +}); + +class FleetOpsImportOrdersToolProbe extends Fleetbase\FleetOps\Support\Ai\Tools\ImportOrdersTool +{ + protected function can(string $permission): bool + { + return true; + } +} + +test('import tool reports what an import needs and never claims to have imported anything', function () { + $tool = new FleetOpsImportOrdersToolProbe(); + $task = new AiTask(['prompt' => 'can I import orders from a spreadsheet', 'metadata' => []]); + $context = new AiToolContext($task, new AiAudience(false, ['fleet-ops import order'])); + + $result = $tool->invoke($task, [], $context); + + expect($result['accepted_sources'])->toBe(['xlsx', 'csv']) + ->and($result['minimum_columns'])->toContain('pickup address or pickup place') + ->and($result['can_import_here'])->toBeFalse() + ->and($result['message'])->toContain('will not process uploaded spreadsheets') + ->and($context->actionPreviews)->toBe([]) + ->and($tool->toolName())->toBe('fleetops_import_requirements') + ->and($tool->key())->toBe('fleet-ops.import_orders_preview') + ->and($tool->toolDescription())->toContain('does NOT import anything') + ->and($tool->toolParameters()['properties'])->toBeInstanceOf(stdClass::class) + ->and(json_encode($tool->toolParameters()['properties']))->toBe('{}') + ->and($tool->availableFor($context))->toBeTrue() + ->and($tool->availableFor(new AiToolContext($task, new AiAudience(false, []))))->toBeFalse(); +}); From 46f0dbf34b062626bda2efcf9811b43d7603de43 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 21 Sep 2026 15:28:00 +0800 Subject: [PATCH 04/19] chore: open release v0.6.69 --- RELEASE.md | 15 ++++++--------- composer.json | 2 +- extension.json | 2 +- package.json | 2 +- 4 files changed, 9 insertions(+), 12 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index c39d64c9f..251a11a65 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,21 +1,18 @@ -> v0.6.68 ~ "Telematics sync status and SASCO fuel" +> v0.6.69 ~ "Fleet-Ops tools for Fleetbase AI" --- ## What's New -- **SASCO is a native fuel provider.** Connect a SASCO B2B account alongside PetroApp to import fuel transactions, with sandbox and production environments, amounts in SAR, driver name and phone, and receipt images. Transactions are matched to vehicles by plate. -- **"Sync Devices" works while telematics polling is running.** A manual sync requested while a scheduled sweep is queued or running is recorded and completed by the next sweep, instead of failing with "already queued or running". -- **Telematics connection status stays current.** Each completed scheduled sweep updates the connection status and last sync time, so a connection that has recovered no longer shows "Needs attention" or an old last sync date. +- **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. --- ## Fixes -- Manual telematics sync requests can no longer stay queued or failed indefinitely after their job is lost; the next scheduled sweep completes them. -- Fuel provider environment labels and sync run details no longer refer to PetroApp for other providers. +- AI resource search no longer fails on every call with `Unknown column 'sensor_type'`, and database errors are no longer passed to the model. --- ## Testing -- Backend tests cover manual syncs during scheduled sweeps, stale failure recovery, partial sweeps, and adoption of abandoned requests. -- SASCO provider tests cover login, token caching, re-login after a 401, connection tests, pagination limits, error handling and transaction normalization. -- `docs/TELEMATICS_QUEUES.md` now documents that dedicated telematics workers must share the queue worker's image and `APP_KEY`, with verification commands and troubleshooting. +- Unit tests cover the new AI tools and console commands, and the AI capability registration. --- ## Need help? diff --git a/composer.json b/composer.json index f586305df..3907b8527 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "fleetbase/fleetops-api", - "version": "0.6.68", + "version": "0.6.69", "description": "Fleet & Transport Management Extension for Fleetbase", "keywords": [ "fleetbase-extension", diff --git a/extension.json b/extension.json index 9d1f35126..49ca67ca9 100644 --- a/extension.json +++ b/extension.json @@ -1,6 +1,6 @@ { "name": "Fleet-Ops", - "version": "0.6.68", + "version": "0.6.69", "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 87b5ef586..a191e90d2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fleetbase/fleetops-engine", - "version": "0.6.68", + "version": "0.6.69", "description": "Fleet & Transport Management Extension for Fleetbase", "fleetbase": { "route": "fleet-ops" From b63e2440b2fddd02eb2fbb376d96b3524e0df17e Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 21 Sep 2026 16:08:44 +0800 Subject: [PATCH 05/19] chore(deps): upgrade @fleetbase/ember-core and @fleetbase/ember-ui to latest ember-core ^0.3.24 and ember-ui ^0.4.2, the latest published versions. --- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index a191e90d2..b8f78eacb 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "dependencies": { "@babel/core": "^7.23.2", "@fleetbase/ember-core": "^0.3.24", - "@fleetbase/ember-ui": "^0.4.1", + "@fleetbase/ember-ui": "^0.4.2", "@fleetbase/fleetops-data": "^0.2.1", "@fleetbase/leaflet-routing-machine": "^3.2.17", "@fortawesome/ember-fontawesome": "^2.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a021227f4..deb93526f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,8 +15,8 @@ importers: specifier: ^0.3.24 version: 0.3.24(@ember/string@3.1.1)(@ember/test-helpers@3.3.1(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(webpack@5.106.2(postcss@8.5.14)))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14))))(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(eslint@8.57.1)(webpack@5.106.2(postcss@8.5.14)) '@fleetbase/ember-ui': - specifier: ^0.4.1 - version: 0.4.1(@ember/test-helpers@3.3.1(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(webpack@5.106.2(postcss@8.5.14)))(@glimmer/component@1.1.2(@babel/core@7.29.0))(@glimmer/tracking@1.1.2)(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14))))(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(postcss@8.5.14)(rollup@2.80.0)(tracked-built-ins@3.4.0(@babel/core@7.29.0))(webpack@5.106.2(postcss@8.5.14))(yaml@2.9.0) + specifier: ^0.4.2 + version: 0.4.2(@ember/test-helpers@3.3.1(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(webpack@5.106.2(postcss@8.5.14)))(@glimmer/component@1.1.2(@babel/core@7.29.0))(@glimmer/tracking@1.1.2)(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14))))(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(postcss@8.5.14)(rollup@2.80.0)(tracked-built-ins@3.4.0(@babel/core@7.29.0))(webpack@5.106.2(postcss@8.5.14))(yaml@2.9.0) '@fleetbase/fleetops-data': specifier: ^0.2.1 version: 0.2.1(@ember/string@3.1.1)(@ember/test-helpers@3.3.1(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(webpack@5.106.2(postcss@8.5.14)))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14))))(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(eslint@8.57.1)(webpack@5.106.2(postcss@8.5.14)) @@ -1397,8 +1397,8 @@ packages: resolution: {integrity: sha512-WG32JlX4S75ofa34RQEMxFEqc20fTv2Qn3A5+NlqXtL0lfnCthdO+a1DrGqIIierthHl3mVTxhe+z63f8sM6EA==} engines: {node: '>= 18'} - '@fleetbase/ember-ui@0.4.1': - resolution: {integrity: sha512-7xiDohPBK14bRezKDrt6yICpOS1DMRkmEBGdspK6CLzCZz2kQ6Q8rgB0jXQtOw66HFWPiXsll9PgM4IDe3e4fQ==} + '@fleetbase/ember-ui@0.4.2': + resolution: {integrity: sha512-jRpu9fYIKis9QDvOr6Ih2zPbIrGhHa+f8tLzKJCl4bFtqtYMGlr0XNW/ckp4VLmoPmWPwJHmVbtvmwLeVoEmhQ==} engines: {node: '>= 18'} '@fleetbase/fleetops-data@0.2.1': @@ -10388,7 +10388,7 @@ snapshots: - utf-8-validate - webpack - '@fleetbase/ember-ui@0.4.1(@ember/test-helpers@3.3.1(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(webpack@5.106.2(postcss@8.5.14)))(@glimmer/component@1.1.2(@babel/core@7.29.0))(@glimmer/tracking@1.1.2)(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14))))(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(postcss@8.5.14)(rollup@2.80.0)(tracked-built-ins@3.4.0(@babel/core@7.29.0))(webpack@5.106.2(postcss@8.5.14))(yaml@2.9.0)': + '@fleetbase/ember-ui@0.4.2(@ember/test-helpers@3.3.1(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(webpack@5.106.2(postcss@8.5.14)))(@glimmer/component@1.1.2(@babel/core@7.29.0))(@glimmer/tracking@1.1.2)(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14))))(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(postcss@8.5.14)(rollup@2.80.0)(tracked-built-ins@3.4.0(@babel/core@7.29.0))(webpack@5.106.2(postcss@8.5.14))(yaml@2.9.0)': dependencies: '@babel/core': 7.29.0 '@ember/render-modifiers': 2.1.0(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14))) From 1765536fff932f3090150fe6cdf813b672fcb0f0 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 22 Sep 2026 00:51:09 +0800 Subject: [PATCH 06/19] feat: manage driver, customer and contact login accounts from the profile Driver, customer and contact login accounts are now fully managed on the server through their profile. The console never selects or creates a user for them. Server: - ProfileAccountManager resolves a profile's account: - a team member of the organization with the same email or phone is linked (staff-linked profile); - a managed account of the same type is reused; - otherwise a managed driver, contact or customer account is created, with its role and no organization invite; - it pushes the profile's name/email/phone to the account, and a staff account only takes the name; - on profile delete it deletes a managed account with no other profile, which frees its email and phone; - it sends credentials by email, or by SMS when there is no email. - Driver create/update (internal and public API) and Contact go through the manager. The internal API ignores user_uuid, and email/phone conflicts return 422. - New internal endpoints for drivers: send-credentials, reset-credentials, deactivate-login (also revokes app tokens) and reactivate-login. - New profile-identity lookup for the form hint. - Deactivated driver and customer logins are refused by the app login, SMS login, code verification and password reset endpoints. - Customer credential actions refuse staff-linked customers. Driver and contact resources expose is_staff_linked and login_status. - A migration converts existing `user` accounts that only hold the Driver or Fleet-Ops Customer role for their profile into managed accounts, and can be reverted. Console: - The driver form drops the user picker. It gets name, email and a PhoneInput, a hint when the email/phone belongs to a team member, and locked email/phone for staff-linked profiles. - Drivers get Reset Password, Send Credentials and Deactivate/Reactivate Login, like customers. - The shared login-action util and reset-profile-credentials modal are reused for drivers and customers. --- addon/components/contact/form.hbs | 15 +- addon/components/customer/form.hbs | 15 +- addon/components/customer/form.js | 56 -- addon/components/driver/form.hbs | 55 +- addon/components/driver/form.js | 58 -- addon/components/modals/driver-form.hbs | 4 +- .../modals/reset-customer-credentials.hbs | 18 - .../modals/reset-customer-credentials.js | 55 -- .../modals/reset-profile-credentials.hbs | 23 + .../modals/reset-profile-credentials.js | 70 ++ addon/components/profile-identity-hint.hbs | 13 + addon/components/profile-identity-hint.js | 49 ++ addon/controllers/management/drivers/index.js | 4 + addon/services/contact-actions.js | 139 ++-- addon/services/driver-actions.js | 98 +++ addon/utils/profile-login-actions.js | 172 +++++ ...ntials.js => reset-profile-credentials.js} | 2 +- app/components/profile-identity-hint.js | 1 + ...profile_only_users_to_managed_accounts.php | 138 ++++ .../views/mail/driver-credentials.blade.php | 21 + .../ProfileIdentityConflictException.php | 34 + .../Controllers/Api/v1/CustomerController.php | 14 + .../Controllers/Api/v1/DriverController.php | 95 ++- .../Internal/v1/ContactController.php | 22 +- .../Internal/v1/CustomerController.php | 44 +- .../Internal/v1/DriverController.php | 344 +++++---- .../Internal/v1/FleetOpsLookupController.php | 47 ++ .../src/Http/Requests/CreateDriverRequest.php | 8 +- .../Requests/Internal/CreateDriverRequest.php | 27 +- server/src/Http/Resources/v1/Contact.php | 3 + server/src/Http/Resources/v1/Driver.php | 3 + server/src/Mail/DriverCredentialsMail.php | 55 ++ server/src/Models/Contact.php | 145 ++-- server/src/Observers/ContactObserver.php | 24 +- server/src/Observers/DriverObserver.php | 11 +- server/src/Support/ProfileAccountManager.php | 405 ++++++++++ server/src/routes.php | 5 + .../ApiCustomerControllerContractsTest.php | 21 + .../ApiDriverControllerContractsTest.php | 118 ++- .../tests/ControllerHelperContractsTest.php | 14 +- .../Api/DriverControllerAuthFlowsTest.php | 36 + .../DriverControllerSessionHelpersTest.php | 25 +- ...ProfileOnlyUserConversionMigrationTest.php | 220 ++++++ .../ContactControllerEndpointsTest.php | 10 +- .../CustomerControllerCredentialsTest.php | 8 +- .../DriverControllerContractsTest.php | 23 + .../DriverControllerCreateRecordTest.php | 59 +- ...iverControllerExistingUserAdoptionTest.php | 494 ++++-------- .../DriverControllerLoginManagementTest.php | 716 ++++++++++++++++++ ...nternalCustomerControllerContractsTest.php | 33 +- server/tests/ModelAccessorContractsTest.php | 102 ++- server/tests/ObserverContractsTest.php | 29 +- server/tests/RequestContractsTest.php | 30 +- .../TelematicControllerContractsTest.php | 26 +- .../Unit/Models/ContactCustomerUserTest.php | 104 ++- server/tests/Unit/Models/ContactTest.php | 50 +- .../Unit/Models/SmallModelSurfacesTest.php | 63 +- .../Observers/ObserverListenerSeamsTest.php | 9 +- .../Support/ProfileAccountManagerTest.php | 643 ++++++++++++++++ .../components/customer/form-test.js | 18 +- .../modals/reset-customer-credentials-test.js | 26 - .../modals/reset-profile-credentials-test.js | 61 ++ tests/unit/services/driver-actions-test.js | 53 ++ translations/en-us.yaml | 36 +- 64 files changed, 4128 insertions(+), 1191 deletions(-) delete mode 100644 addon/components/modals/reset-customer-credentials.hbs delete mode 100644 addon/components/modals/reset-customer-credentials.js create mode 100644 addon/components/modals/reset-profile-credentials.hbs create mode 100644 addon/components/modals/reset-profile-credentials.js create mode 100644 addon/components/profile-identity-hint.hbs create mode 100644 addon/components/profile-identity-hint.js create mode 100644 addon/utils/profile-login-actions.js rename app/components/modals/{reset-customer-credentials.js => reset-profile-credentials.js} (64%) create mode 100644 app/components/profile-identity-hint.js create mode 100644 server/migrations/2026_09_21_000001_convert_profile_only_users_to_managed_accounts.php create mode 100644 server/resources/views/mail/driver-credentials.blade.php create mode 100644 server/src/Exceptions/ProfileIdentityConflictException.php create mode 100644 server/src/Mail/DriverCredentialsMail.php create mode 100644 server/src/Support/ProfileAccountManager.php create mode 100644 server/tests/Feature/Http/Api/ProfileOnlyUserConversionMigrationTest.php create mode 100644 server/tests/Feature/Http/Internal/DriverControllerLoginManagementTest.php create mode 100644 server/tests/Unit/Support/ProfileAccountManagerTest.php delete mode 100644 tests/integration/components/modals/reset-customer-credentials-test.js create mode 100644 tests/integration/components/modals/reset-profile-credentials-test.js diff --git a/addon/components/contact/form.hbs b/addon/components/contact/form.hbs index 3c9bf157b..40bfbc08d 100644 --- a/addon/components/contact/form.hbs +++ b/addon/components/contact/form.hbs @@ -30,11 +30,22 @@ - + - + + {{#if @resource.is_staff_linked}} +
{{t "profile-account.staff-linked-helper"}}
+ {{else if @resource.isNew}} + + {{/if}} diff --git a/addon/components/customer/form.hbs b/addon/components/customer/form.hbs index 94a7ef342..1909b192a 100644 --- a/addon/components/customer/form.hbs +++ b/addon/components/customer/form.hbs @@ -37,11 +37,22 @@ - + - + + {{#if @resource.is_staff_linked}} +
{{t "profile-account.staff-linked-helper"}}
+ {{else if @resource.isNew}} + + {{/if}} diff --git a/addon/components/customer/form.js b/addon/components/customer/form.js index 2ebe34e75..9046258ce 100644 --- a/addon/components/customer/form.js +++ b/addon/components/customer/form.js @@ -1,71 +1,15 @@ 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'; export default class CustomerFormComponent extends Component { @service customerActions; - @service store; @service fetch; @service currentUser; @service notifications; - @service modalsManager; @service('universe/extension-manager') extensionManager; - @tracked userAccountActionButtons = [ - { - icon: 'user-plus', - size: 'xs', - permission: 'iam create user', - onClick: async () => { - // Load IAM engine for user-form modal component - await this.extensionManager.ensureEngineLoaded('@fleetbase/iam-engine'); - - const user = this.store.createRecord('user', { - status: 'pending', - type: 'user', - }); - - this.modalsManager.show('modals/user-form', { - title: 'Create a new user', - user, - formPermission: 'iam create user', - uploadNewPhoto: (file) => { - this.fetch.uploadFile.perform( - file, - { - path: `uploads/${this.currentUser.companyId}/users/${user.slug}`, - subject_uui: user.id, - subject_type: 'user', - type: 'user_photo', - }, - (uploadedFile) => { - user.setProperties({ - avatar_uuid: uploadedFile.id, - avatar_url: uploadedFile.url, - avatar: uploadedFile, - }); - } - ); - }, - confirm: async (modal) => { - modal.startLoading(); - - try { - await user.save(); - this.notifications.success('New user created successfully!'); - modal.done(); - } catch (error) { - this.notifications.serverError(error); - modal.stopLoading(); - } - }, - }); - }, - }, - ]; - get showWelcomeEmailOption() { return this.args.resource?.isNew && this.extensionManager.isInstalled('@fleetbase/customer-portal-engine'); } diff --git a/addon/components/driver/form.hbs b/addon/components/driver/form.hbs index 870936552..1103fdcef 100644 --- a/addon/components/driver/form.hbs +++ b/addon/components/driver/form.hbs @@ -1,39 +1,4 @@
- -
- - - - - - {{#if @resource.user}} -
- - - - - - - - - -
- {{/if}} -
-
-
@@ -65,6 +30,26 @@
+ + + + + + + + + + {{#if @resource.is_staff_linked}} +
{{t "profile-account.staff-linked-helper"}}
+ {{else if @resource.isNew}} + + {{/if}} diff --git a/addon/components/driver/form.js b/addon/components/driver/form.js index 1bc13c38b..56dde37e9 100644 --- a/addon/components/driver/form.js +++ b/addon/components/driver/form.js @@ -3,67 +3,9 @@ import { inject as service } from '@ember/service'; import { task } from 'ember-concurrency'; export default class DriverFormComponent extends Component { - @service store; @service fetch; @service currentUser; @service notifications; - @service modalsManager; - @service('universe/extension-manager') extensionManager; - - get userAccountActionButtons() { - return [ - { - icon: 'user-plus', - size: 'xs', - permission: 'iam create user', - onClick: async () => { - // Load IAM engine for user-form modal component - await this.extensionManager.ensureEngineLoaded('@fleetbase/iam-engine'); - - const user = this.store.createRecord('user', { - status: 'pending', - type: 'user', - }); - - this.modalsManager.show('modals/user-form', { - title: 'Create a new user', - user, - formPermission: 'iam create user', - uploadNewPhoto: (file) => { - this.fetch.uploadFile.perform( - file, - { - path: `uploads/${this.currentUser.companyId}/users/${user.slug}`, - subject_uui: user.id, - subject_type: 'user', - type: 'user_photo', - }, - (uploadedFile) => { - user.setProperties({ - avatar_uuid: uploadedFile.id, - avatar_url: uploadedFile.url, - avatar: uploadedFile, - }); - } - ); - }, - confirm: async (modal) => { - modal.startLoading(); - - try { - await user.save(); - this.notifications.success('New user created successfully!'); - modal.done(); - } catch (error) { - this.notifications.serverError(error); - modal.stopLoading(); - } - }, - }); - }, - }, - ]; - } @task *handlePhotoUpload(file) { try { diff --git a/addon/components/modals/driver-form.hbs b/addon/components/modals/driver-form.hbs index bd9db1807..a079f248e 100644 --- a/addon/components/modals/driver-form.hbs +++ b/addon/components/modals/driver-form.hbs @@ -41,14 +41,14 @@ - +
- +
diff --git a/addon/components/modals/reset-customer-credentials.hbs b/addon/components/modals/reset-customer-credentials.hbs deleted file mode 100644 index 34614779e..000000000 --- a/addon/components/modals/reset-customer-credentials.hbs +++ /dev/null @@ -1,18 +0,0 @@ - -
-
- You are about to reset the password for - {{this.customer.name}} -
-
Please enter the new password and confirm it below. You have the option to send the new credentials to the customer via email by selecting the checkbox.
- -
- - -
-
- - - -
-
\ No newline at end of file diff --git a/addon/components/modals/reset-customer-credentials.js b/addon/components/modals/reset-customer-credentials.js deleted file mode 100644 index 92d6d70ad..000000000 --- a/addon/components/modals/reset-customer-credentials.js +++ /dev/null @@ -1,55 +0,0 @@ -import Component from '@glimmer/component'; -import { tracked } from '@glimmer/tracking'; -import { inject as service } from '@ember/service'; - -const INTERNAL_NAMESPACE = 'int/v1'; - -export default class ModalsResetCustomerCredentialsComponent extends Component { - @service fetch; - @service notifications; - @tracked options = {}; - @tracked password; - @tracked confirmPassword; - @tracked sendCredentials = true; - @tracked customer; - - constructor(owner, { options }) { - super(...arguments); - this.customer = options.customer; - this.options = options; - this.setupOptions(); - } - - setupOptions() { - this.options.title = 'Reset Customer Credentials'; - this.options.acceptButtonText = 'Reset Credentials'; - this.options.declineButtonHidden = true; - this.options.confirm = async (modal) => { - modal.startLoading(); - - try { - await this.fetch.post( - 'customers/reset-credentials', - { - customer: this.customer.id, - password: this.password, - password_confirmation: this.confirmPassword, - send_credentials: this.sendCredentials, - }, - { namespace: INTERNAL_NAMESPACE } - ); - - this.notifications.success('Customer password reset.'); - - if (typeof this.options.onPasswordResetComplete === 'function') { - this.options.onPasswordResetComplete(); - } - - modal.done(); - } catch (error) { - this.notifications.serverError(error); - modal.stopLoading(); - } - }; - } -} diff --git a/addon/components/modals/reset-profile-credentials.hbs b/addon/components/modals/reset-profile-credentials.hbs new file mode 100644 index 000000000..e6374e0dc --- /dev/null +++ b/addon/components/modals/reset-profile-credentials.hbs @@ -0,0 +1,23 @@ + +
+
+ {{t "profile-account.prompts.reset-password-heading" name=this.profile.name}} +
+
{{t "profile-account.prompts.reset-password-body"}}
+ +
+ + +
+
+ + + +
+
\ No newline at end of file diff --git a/addon/components/modals/reset-profile-credentials.js b/addon/components/modals/reset-profile-credentials.js new file mode 100644 index 000000000..2c8cbfc90 --- /dev/null +++ b/addon/components/modals/reset-profile-credentials.js @@ -0,0 +1,70 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { inject as service } from '@ember/service'; +import { INTERNAL_NAMESPACE, updateProfileFromResponse } from '../../utils/profile-login-actions'; + +/** + * Resets the login password of a driver, contact or customer profile. + * + * Options: + * - `profile`: the driver or contact record + * - `endpoint`: the reset credentials endpoint (internal namespace) + * - `payload`: extra body params, or a function returning them + * - `onPasswordResetComplete(response)`: optional callback + */ +export default class ModalsResetProfileCredentialsComponent extends Component { + @service fetch; + @service intl; + @service notifications; + @tracked options = {}; + @tracked password; + @tracked confirmPassword; + @tracked sendCredentials = true; + @tracked profile; + + constructor(owner, { options }) { + super(...arguments); + this.profile = options.profile; + this.options = options; + this.setupOptions(); + } + + get extraPayload() { + const { payload } = this.options; + return (typeof payload === 'function' ? payload(this.profile) : payload) ?? {}; + } + + setupOptions() { + this.options.title = this.options.title ?? this.intl.t('profile-account.prompts.reset-password-title'); + this.options.acceptButtonText = this.options.acceptButtonText ?? this.intl.t('profile-account.prompts.reset-password-accept'); + this.options.declineButtonHidden = true; + this.options.confirm = async (modal) => { + modal.startLoading(); + + try { + const response = await this.fetch.post( + this.options.endpoint, + { + ...this.extraPayload, + password: this.password, + password_confirmation: this.confirmPassword, + send_credentials: this.sendCredentials, + }, + { namespace: INTERNAL_NAMESPACE } + ); + + updateProfileFromResponse(this.profile, response); + this.notifications.success(this.intl.t('profile-account.prompts.reset-password-success')); + + if (typeof this.options.onPasswordResetComplete === 'function') { + this.options.onPasswordResetComplete(response); + } + + modal.done(); + } catch (error) { + this.notifications.serverError(error); + modal.stopLoading(); + } + }; + } +} diff --git a/addon/components/profile-identity-hint.hbs b/addon/components/profile-identity-hint.hbs new file mode 100644 index 000000000..34e46171d --- /dev/null +++ b/addon/components/profile-identity-hint.hbs @@ -0,0 +1,13 @@ +
+ {{#if this.conflict}} +
+ + {{this.conflict}} +
+ {{else if this.staff}} +
+ + {{t "profile-account.will-link-to-staff" name=(or this.staff.name this.staff.email this.staff.phone)}} +
+ {{/if}} +
\ No newline at end of file diff --git a/addon/components/profile-identity-hint.js b/addon/components/profile-identity-hint.js new file mode 100644 index 000000000..b07a39bb9 --- /dev/null +++ b/addon/components/profile-identity-hint.js @@ -0,0 +1,49 @@ +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'; + +/** + * Inline hint shown while creating a driver, contact or customer profile: + * tells the user when the entered email/phone belongs to an existing team + * member (the profile will be linked to that account) or cannot be used. + * + * Args: `@email`, `@phone`, `@type` ('driver' | 'customer' | 'contact'), + * `@ignore` (optional profile id). + */ +export default class ProfileIdentityHintComponent extends Component { + @service fetch; + @tracked staff = null; + @tracked conflict = null; + + @action lookupIdentity() { + this.lookup.perform(); + } + + @task({ restartable: true }) *lookup() { + const { email, phone, type, ignore } = this.args; + + if (!email && !phone) { + this.staff = null; + this.conflict = null; + return; + } + + yield timeout(400); + + const query = { type }; + if (email) query.email = email; + if (phone) query.phone = phone; + if (ignore) query.ignore = ignore; + + try { + const response = yield this.fetch.get('fleet-ops/lookup/profile-identity', query, { namespace: 'int/v1' }); + this.staff = response?.staff ?? null; + this.conflict = response?.conflict ?? null; + } catch { + this.staff = null; + this.conflict = null; + } + } +} diff --git a/addon/controllers/management/drivers/index.js b/addon/controllers/management/drivers/index.js index 70ff9d2db..9762122d6 100644 --- a/addon/controllers/management/drivers/index.js +++ b/addon/controllers/management/drivers/index.js @@ -358,6 +358,10 @@ export default class ManagementDriversIndexController extends Controller { { separator: true, }, + ...this.driverActions.accountRowActionItems(), + { + separator: true, + }, { label: this.intl.t('common.delete-resource', { resource: this.intl.t('resource.driver') }), icon: 'trash', diff --git a/addon/services/contact-actions.js b/addon/services/contact-actions.js index b76d2b3e7..7021bd5cc 100644 --- a/addon/services/contact-actions.js +++ b/addon/services/contact-actions.js @@ -1,9 +1,8 @@ import ResourceActionService from '@fleetbase/ember-core/services/resource-action'; import { inject as service } from '@ember/service'; -import { action, get } from '@ember/object'; +import { action } from '@ember/object'; import { PANEL_DEFAULTS, closePanelsThen, registeredPanelTabs } from '../utils/context-panel'; - -const INTERNAL_NAMESPACE = 'int/v1'; +import { confirmLoginAction, hasInactiveLogin, hasLinkedUser, hasManagedLogin, linkedUserStatus, updateProfileFromResponse } from '../utils/profile-login-actions'; export default class ContactActionsService extends ResourceActionService { @service fetch; @@ -124,17 +123,23 @@ export default class ContactActionsService extends ResourceActionService { } hasLinkedUser(contact) { - const user = contact?.user; - return Boolean(contact?.user_uuid || (user && (get(user, 'id') || get(user, 'uuid')))); + return hasLinkedUser(contact); + } + + /** + * True when the contact has a login account managed from Fleet-Ops. A + * contact owned by a staff member is managed from IAM instead. + */ + hasManagedLogin(contact) { + return hasManagedLogin(contact); } linkedUserStatus(contact) { - const user = contact?.user; - return (user ? (get(user, 'status') ?? get(user, 'session_status')) : null) ?? 'active'; + return linkedUserStatus(contact); } hasInactiveLogin(contact) { - return ['inactive', 'disabled', 'suspended'].includes(this.linkedUserStatus(contact)); + return hasInactiveLogin(contact); } isCustomerPortalInstalled() { @@ -156,7 +161,7 @@ export default class ContactActionsService extends ResourceActionService { } accountActionButton(contact, options = {}) { - if (!this.hasLinkedUser(contact)) { + if (!this.hasManagedLogin(contact)) { return null; } @@ -171,23 +176,23 @@ export default class ContactActionsService extends ResourceActionService { accountActionItems(contact, options = {}) { const actions = [ { - text: 'Reset Password', + text: this.intl.t('profile-account.actions.reset-password'), icon: 'key', fn: () => this.openResetPasswordModal(contact), }, { - text: 'Send Credentials', + text: this.intl.t('profile-account.actions.send-credentials'), icon: 'paper-plane', fn: () => this.confirmSendCredentials(contact), }, this.hasInactiveLogin(contact) ? { - text: 'Reactivate Login', + text: this.intl.t('profile-account.actions.reactivate-login'), icon: 'unlock', fn: () => this.confirmReactivatePortalLogin(contact), } : { - text: 'Deactivate Login', + text: this.intl.t('profile-account.actions.deactivate-login'), icon: 'lock', fn: () => this.confirmDeactivatePortalLogin(contact), class: 'text-red-500 hover:text-red-600', @@ -200,7 +205,7 @@ export default class ContactActionsService extends ResourceActionService { separator: true, }, { - text: 'Convert to Vendor', + text: this.intl.t('profile-account.actions.convert-to-vendor'), icon: 'building', fn: () => this.openConvertToVendorModal(contact, options), } @@ -212,42 +217,42 @@ export default class ContactActionsService extends ResourceActionService { accountRowActionItems(options = {}) { const hasCustomerPortal = this.isCustomerPortalInstalled(); - const hasLinkedUser = (contact) => this.hasLinkedUser(contact); + const hasManagedLogin = (contact) => this.hasManagedLogin(contact); const actions = [ { - label: 'Reset Password', + label: this.intl.t('profile-account.actions.reset-password'), icon: 'key', fn: (contact) => this.openResetPasswordModal(contact), - isVisible: hasLinkedUser, + isVisible: hasManagedLogin, }, { - label: 'Send Credentials', + label: this.intl.t('profile-account.actions.send-credentials'), icon: 'paper-plane', fn: (contact) => this.confirmSendCredentials(contact), - isVisible: hasLinkedUser, + isVisible: hasManagedLogin, }, { - label: 'Deactivate Login', + label: this.intl.t('profile-account.actions.deactivate-login'), icon: 'lock', class: 'text-red-500 hover:text-red-600', fn: (contact) => this.confirmDeactivatePortalLogin(contact), - isVisible: (contact) => hasLinkedUser(contact) && !this.hasInactiveLogin(contact), + isVisible: (contact) => hasManagedLogin(contact) && !this.hasInactiveLogin(contact), }, { - label: 'Reactivate Login', + label: this.intl.t('profile-account.actions.reactivate-login'), icon: 'unlock', fn: (contact) => this.confirmReactivatePortalLogin(contact), - isVisible: (contact) => hasLinkedUser(contact) && this.hasInactiveLogin(contact), + isVisible: (contact) => hasManagedLogin(contact) && this.hasInactiveLogin(contact), }, ]; if (hasCustomerPortal) { actions.push({ - label: 'Convert to Vendor', + label: this.intl.t('profile-account.actions.convert-to-vendor'), icon: 'building', fn: (contact) => this.openConvertToVendorModal(contact, options), - isVisible: hasLinkedUser, + isVisible: hasManagedLogin, }); } @@ -255,8 +260,10 @@ export default class ContactActionsService extends ResourceActionService { } openResetPasswordModal(contact) { - this.modalsManager.show('modals/reset-customer-credentials', { - customer: contact, + this.modalsManager.show('modals/reset-profile-credentials', { + profile: contact, + endpoint: 'customers/reset-credentials', + payload: (profile) => ({ customer: profile?.id }), }); } @@ -278,86 +285,40 @@ export default class ContactActionsService extends ResourceActionService { confirmSendCredentials(contact) { this.confirmLoginAction(contact, { - title: 'Send Portal Credentials', - body: 'Generate a new temporary password and email this contact their portal credentials.', - acceptButtonText: 'Send Credentials', + title: this.intl.t('profile-account.prompts.send-portal-credentials-title'), + body: this.intl.t('profile-account.prompts.send-portal-credentials-body'), + acceptButtonText: this.intl.t('profile-account.actions.send-credentials'), endpoint: 'customers/send-credentials', - successMessage: 'Portal credentials sent.', + successMessage: this.intl.t('profile-account.prompts.send-portal-credentials-success'), }); } confirmDeactivatePortalLogin(contact) { this.confirmLoginAction(contact, { - title: 'Deactivate Login', - body: 'Deactivate portal access for this contact. Their profile and history will be preserved.', - acceptButtonText: 'Deactivate Login', + title: this.intl.t('profile-account.actions.deactivate-login'), + body: this.intl.t('profile-account.prompts.deactivate-portal-login-body'), + acceptButtonText: this.intl.t('profile-account.actions.deactivate-login'), acceptButtonScheme: 'danger', endpoint: 'customers/deactivate-portal-login', - successMessage: 'Portal login deactivated.', + successMessage: this.intl.t('profile-account.prompts.deactivate-portal-login-success'), }); } confirmReactivatePortalLogin(contact) { this.confirmLoginAction(contact, { - title: 'Reactivate Login', - body: 'Reactivate portal access for this contact.', - acceptButtonText: 'Reactivate Login', + title: this.intl.t('profile-account.actions.reactivate-login'), + body: this.intl.t('profile-account.prompts.reactivate-portal-login-body'), + acceptButtonText: this.intl.t('profile-account.actions.reactivate-login'), endpoint: 'customers/reactivate-portal-login', - successMessage: 'Portal login reactivated.', + successMessage: this.intl.t('profile-account.prompts.reactivate-portal-login-success'), }); } - confirmLoginAction(contact, { title, body, acceptButtonText, acceptButtonScheme, endpoint, successMessage }) { - this.modalsManager.confirm({ - title, - body, - acceptButtonText, - acceptButtonScheme, - confirm: async (modal) => { - modal.startLoading(); - - try { - const response = await this.fetch.post(endpoint, { customer: contact?.id }, { namespace: INTERNAL_NAMESPACE }); - await this.updateContactFromResponse(contact, response); - this.notifications.success(successMessage); - modal.done(); - } catch (error) { - this.notifications.serverError(error); - modal.stopLoading(); - } - }, - }); + confirmLoginAction(contact, options = {}) { + return confirmLoginAction(this, contact, { payload: { customer: contact?.id }, ...options }); } - async updateContactFromResponse(contact, response) { - const payload = response.customer ?? response.contact; - - if (!payload || typeof contact?.setProperties !== 'function') { - return; - } - - contact.setProperties( - this.withoutIdentityFields({ - user_uuid: payload.user_uuid, - }) - ); - - if (payload.user) { - const user = await contact.user; - - if (typeof user?.setProperties === 'function') { - user.setProperties(this.withoutIdentityFields(payload.user)); - } - } - } - - withoutIdentityFields(payload = {}) { - const attributes = { ...payload }; - - delete attributes.id; - delete attributes.uuid; - delete attributes.public_id; - - return attributes; + updateContactFromResponse(contact, response) { + return updateProfileFromResponse(contact, response); } } diff --git a/addon/services/driver-actions.js b/addon/services/driver-actions.js index 5252f64cf..7a4692579 100644 --- a/addon/services/driver-actions.js +++ b/addon/services/driver-actions.js @@ -5,6 +5,7 @@ import { action } from '@ember/object'; import { isArray } from '@ember/array'; import { dasherize } from '@ember/string'; import { PANEL_DEFAULTS, closePanelsThen } from '../utils/context-panel'; +import { confirmLoginAction, hasInactiveLogin, hasManagedLogin } from '../utils/profile-login-actions'; export default class DriverActionsService extends ResourceActionService { @service('universe/menu-service') menuService; @@ -422,9 +423,106 @@ export default class DriverActionsService extends ResourceActionService { { separator: true }, { text: this.intl.t('driver.actions.locate-driver'), icon: 'location-dot', fn: () => this.locate(driver), permission: 'fleet-ops view driver' }, { text: this.intl.t('driver.actions.create-issue'), icon: 'triangle-exclamation', fn: () => this.createIssue(driver), permission: 'fleet-ops create issue' }, + ...this.accountMenuItems(driver), ]; } + /** + * The login account section of a driver's actions menu. Only offered when + * the driver has a login managed from Fleet-Ops; a staff-linked driver's + * login is managed from IAM. + */ + accountMenuItems(driver) { + if (!hasManagedLogin(driver)) { + return []; + } + + const items = this.accountRowActionItems() + .filter(({ isVisible }) => isVisible(driver)) + // eslint-disable-next-line no-unused-vars + .map(({ label, fn, isVisible, ...item }) => ({ ...item, text: label, fn: () => fn(driver) })); + + return [{ separator: true }, ...items]; + } + + /** + * Login account actions for the drivers table row menu. + */ + accountRowActionItems() { + const permission = 'fleet-ops update driver'; + + return [ + { + label: this.intl.t('profile-account.actions.reset-password'), + icon: 'key', + fn: (driver) => this.resetCredentials(driver), + permission, + isVisible: (driver) => hasManagedLogin(driver), + }, + { + label: this.intl.t('profile-account.actions.send-credentials'), + icon: 'paper-plane', + fn: (driver) => this.sendCredentials(driver), + permission, + isVisible: (driver) => hasManagedLogin(driver), + }, + { + label: this.intl.t('profile-account.actions.deactivate-login'), + icon: 'lock', + class: 'text-red-500 hover:text-red-600', + fn: (driver) => this.deactivateLogin(driver), + permission, + isVisible: (driver) => hasManagedLogin(driver) && !hasInactiveLogin(driver), + }, + { + label: this.intl.t('profile-account.actions.reactivate-login'), + icon: 'unlock', + fn: (driver) => this.reactivateLogin(driver), + permission, + isVisible: (driver) => hasManagedLogin(driver) && hasInactiveLogin(driver), + }, + ]; + } + + @action resetCredentials(driver, options = {}) { + return this.modalsManager.show('modals/reset-profile-credentials', { + profile: driver, + endpoint: `drivers/${driver.id}/reset-credentials`, + ...options, + }); + } + + @action sendCredentials(driver) { + return confirmLoginAction(this, driver, { + title: this.intl.t('profile-account.prompts.send-credentials-title'), + body: this.intl.t('profile-account.prompts.send-credentials-body', { name: driver.name }), + acceptButtonText: this.intl.t('profile-account.actions.send-credentials'), + endpoint: `drivers/${driver.id}/send-credentials`, + successMessage: this.intl.t('profile-account.prompts.send-credentials-success', { name: driver.name }), + }); + } + + @action deactivateLogin(driver) { + return confirmLoginAction(this, driver, { + title: this.intl.t('profile-account.actions.deactivate-login'), + body: this.intl.t('profile-account.prompts.deactivate-login-body', { name: driver.name }), + acceptButtonText: this.intl.t('profile-account.actions.deactivate-login'), + acceptButtonScheme: 'danger', + endpoint: `drivers/${driver.id}/deactivate-login`, + successMessage: this.intl.t('profile-account.prompts.deactivate-login-success', { name: driver.name }), + }); + } + + @action reactivateLogin(driver) { + return confirmLoginAction(this, driver, { + title: this.intl.t('profile-account.actions.reactivate-login'), + body: this.intl.t('profile-account.prompts.reactivate-login-body', { name: driver.name }), + acceptButtonText: this.intl.t('profile-account.actions.reactivate-login'), + endpoint: `drivers/${driver.id}/reactivate-login`, + successMessage: this.intl.t('profile-account.prompts.reactivate-login-success', { name: driver.name }), + }); + } + @action createIssue(driver) { return this.issueActions.modal.create({ driver, diff --git a/addon/utils/profile-login-actions.js b/addon/utils/profile-login-actions.js new file mode 100644 index 000000000..16b04e181 --- /dev/null +++ b/addon/utils/profile-login-actions.js @@ -0,0 +1,172 @@ +import { get } from '@ember/object'; + +/** + * Shared helpers for the login account behind a driver, contact or customer + * profile. The account itself is created, updated and removed by the server + * from the profile's name/email/phone; the console only offers the account + * actions (reset password, send credentials, deactivate/reactivate login). + * + * A profile owned by a staff member (`is_staff_linked`) has its login + * managed from IAM, so no account actions are offered for it here. + */ + +export const INTERNAL_NAMESPACE = 'int/v1'; +export const INACTIVE_LOGIN_STATUSES = ['inactive', 'disabled', 'suspended']; + +function read(subject, key) { + if (!subject) { + return undefined; + } + + try { + return get(subject, key); + } catch { + return subject[key]; + } +} + +/** + * The linked user record when it is already loaded, without triggering a + * fetch for an async relationship. + */ +export function loadedUser(profile) { + if (typeof profile?.belongsTo === 'function') { + try { + return profile.belongsTo('user').value(); + } catch { + return null; + } + } + + const user = read(profile, 'user'); + if (!user) { + return null; + } + + return typeof user.then === 'function' ? (read(user, 'content') ?? null) : user; +} + +export function isStaffLinked(profile) { + return Boolean(read(profile, 'is_staff_linked')); +} + +export function hasLinkedUser(profile) { + if (!profile) { + return false; + } + + if (read(profile, 'user_uuid') || read(profile, 'login_status')) { + return true; + } + + const user = loadedUser(profile); + return Boolean(user && (read(user, 'id') || read(user, 'uuid'))); +} + +/** + * True when the profile has a login account that is managed from Fleet-Ops, + * i.e. a linked user which is not a staff member's account. + */ +export function hasManagedLogin(profile) { + return hasLinkedUser(profile) && !isStaffLinked(profile); +} + +/** + * The login status, preferring the server-provided `login_status` over the + * embedded user's status. + */ +export function linkedUserStatus(profile) { + const loginStatus = read(profile, 'login_status'); + if (loginStatus) { + return loginStatus; + } + + const user = loadedUser(profile); + return (user ? (read(user, 'status') ?? read(user, 'session_status')) : null) ?? 'active'; +} + +export function hasInactiveLogin(profile) { + return INACTIVE_LOGIN_STATUSES.includes(linkedUserStatus(profile)); +} + +export function withoutIdentityFields(payload = {}) { + const attributes = { ...payload }; + + delete attributes.id; + delete attributes.uuid; + delete attributes.public_id; + + return attributes; +} + +/** + * Applies the profile payload returned by an account action endpoint + * (`{ driver: {...} }`, `{ customer: {...} }` or `{ contact: {...} }`). + */ +export function updateProfileFromResponse(profile, response = {}) { + const payload = response?.driver ?? response?.customer ?? response?.contact; + + if (!payload || typeof profile?.setProperties !== 'function') { + return; + } + + const attributes = {}; + if ('user_uuid' in payload) { + attributes.user_uuid = payload.user_uuid; + } + if ('is_staff_linked' in payload) { + attributes.is_staff_linked = Boolean(payload.is_staff_linked); + } + if ('login_status' in payload) { + attributes.login_status = payload.login_status; + } else if (payload.user && 'status' in payload.user) { + attributes.login_status = payload.user.status; + } + + profile.setProperties(attributes); + + if (payload.user) { + const user = loadedUser(profile); + + if (typeof user?.setProperties === 'function') { + user.setProperties(withoutIdentityFields(payload.user)); + } + } +} + +/** + * Opens a confirm modal which POSTs to an account action endpoint and applies + * the response to the profile. + * + * @param {Object} services { modalsManager, fetch, notifications } + * @param {Object} profile the driver or contact record + * @param {Object} options { title, body, acceptButtonText, acceptButtonScheme, endpoint, payload, successMessage, onSuccess } + */ +export function confirmLoginAction({ modalsManager, fetch, notifications }, profile, options = {}) { + const { title, body, acceptButtonText, acceptButtonScheme, endpoint, payload = {}, successMessage, onSuccess } = options; + + return modalsManager.confirm({ + title, + body, + acceptButtonText, + acceptButtonScheme, + confirm: async (modal) => { + modal.startLoading(); + + try { + const response = await fetch.post(endpoint, payload, { namespace: INTERNAL_NAMESPACE }); + updateProfileFromResponse(profile, response); + notifications.success(successMessage); + + if (typeof onSuccess === 'function') { + onSuccess(response); + } + + modal.done(); + } catch (error) { + notifications.serverError(error); + modal.stopLoading(); + } + }, + }); +} diff --git a/app/components/modals/reset-customer-credentials.js b/app/components/modals/reset-profile-credentials.js similarity index 64% rename from app/components/modals/reset-customer-credentials.js rename to app/components/modals/reset-profile-credentials.js index 7a4e86563..d27dc8eb4 100644 --- a/app/components/modals/reset-customer-credentials.js +++ b/app/components/modals/reset-profile-credentials.js @@ -1 +1 @@ -export { default } from '@fleetbase/fleetops-engine/components/modals/reset-customer-credentials'; +export { default } from '@fleetbase/fleetops-engine/components/modals/reset-profile-credentials'; diff --git a/app/components/profile-identity-hint.js b/app/components/profile-identity-hint.js new file mode 100644 index 000000000..0dd7930f7 --- /dev/null +++ b/app/components/profile-identity-hint.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-engine/components/profile-identity-hint'; diff --git a/server/migrations/2026_09_21_000001_convert_profile_only_users_to_managed_accounts.php b/server/migrations/2026_09_21_000001_convert_profile_only_users_to_managed_accounts.php new file mode 100644 index 000000000..8d4579d78 --- /dev/null +++ b/server/migrations/2026_09_21_000001_convert_profile_only_users_to_managed_accounts.php @@ -0,0 +1,138 @@ + 'Driver', + 'customer' => 'Fleet-Ops Customer', + ]; + + public function up(): void + { + if (!$this->hasRequiredTables()) { + return; + } + + $converted = ['driver' => 0, 'customer' => 0]; + + DB::table('users') + ->select(['uuid', 'meta']) + ->where('type', 'user') + ->whereNull('deleted_at') + ->where(function ($query) { + $query->whereExists(function ($query) { + $query->selectRaw(1)->from('drivers')->whereColumn('drivers.user_uuid', 'users.uuid')->whereNull('drivers.deleted_at'); + })->orWhereExists(function ($query) { + $query->selectRaw(1)->from('contacts')->whereColumn('contacts.user_uuid', 'users.uuid')->where('contacts.type', 'customer')->whereNull('contacts.deleted_at'); + }); + }) + ->whereNotExists(function ($query) { + $query->selectRaw(1)->from('companies')->whereColumn('companies.owner_uuid', 'users.uuid'); + }) + ->orderBy('uuid') + ->chunk(500, function ($users) use (&$converted) { + foreach ($users as $user) { + $type = $this->managedTypeFor($user->uuid); + if (!$type) { + continue; + } + + $meta = json_decode($user->meta ?? '', true); + $meta = is_array($meta) ? $meta : []; + $meta['previous_type'] = 'user'; + + DB::table('users')->where('uuid', $user->uuid)->update(['type' => $type, 'meta' => json_encode($meta)]); + $converted[$type]++; + } + }); + + Log::info('[fleetops] Converted profile-only user accounts to managed accounts.', $converted); + } + + public function down(): void + { + if (!Schema::hasTable('users')) { + return; + } + + DB::table('users') + ->select(['uuid', 'meta']) + ->whereIn('type', array_keys(self::PROFILE_ROLES)) + ->where('meta', 'like', '%"previous_type"%') + ->orderBy('uuid') + ->chunk(500, function ($users) { + foreach ($users as $user) { + $meta = json_decode($user->meta ?? '', true); + if (!is_array($meta) || ($meta['previous_type'] ?? null) !== 'user') { + continue; + } + + unset($meta['previous_type']); + DB::table('users')->where('uuid', $user->uuid)->update(['type' => 'user', 'meta' => json_encode($meta)]); + } + }); + } + + /** + * The managed type for the account, or null when it should stay a team member. + */ + private function managedTypeFor(string $userUuid): ?string + { + $hasDriver = DB::table('drivers')->where('user_uuid', $userUuid)->whereNull('deleted_at')->exists(); + $hasCustomer = DB::table('contacts')->where('user_uuid', $userUuid)->where('type', 'customer')->whereNull('deleted_at')->exists(); + + // An account holding both kinds of profile is a person the organization + // relates to in two ways; leave it for an administrator to decide. + if ($hasDriver === $hasCustomer) { + return null; + } + + $type = $hasDriver ? 'driver' : 'customer'; + $companyUserUuids = DB::table('company_users')->where('user_uuid', $userUuid)->whereNull('deleted_at')->pluck('uuid'); + $modelUuids = $companyUserUuids->push($userUuid)->all(); + + $roles = DB::table('model_has_roles') + ->join('roles', 'roles.id', '=', 'model_has_roles.role_id') + ->whereIn('model_has_roles.model_uuid', $modelUuids) + ->pluck('roles.name') + ->unique(); + + if ($roles->isEmpty() || $roles->contains(fn ($role) => $role !== self::PROFILE_ROLES[$type])) { + return null; + } + + $hasPermissions = DB::table('model_has_permissions')->whereIn('model_uuid', $modelUuids)->exists(); + $hasPolicies = DB::table('model_has_policies')->whereIn('model_uuid', $modelUuids)->exists(); + + return $hasPermissions || $hasPolicies ? null : $type; + } + + private function hasRequiredTables(): bool + { + foreach (['users', 'drivers', 'contacts', 'companies', 'company_users', 'roles', 'model_has_roles', 'model_has_permissions', 'model_has_policies'] as $table) { + if (!Schema::hasTable($table)) { + return false; + } + } + + return true; + } +}; diff --git a/server/resources/views/mail/driver-credentials.blade.php b/server/resources/views/mail/driver-credentials.blade.php new file mode 100644 index 000000000..3236da9aa --- /dev/null +++ b/server/resources/views/mail/driver-credentials.blade.php @@ -0,0 +1,21 @@ + +

+Your driver sign-in details +

+ +Hi {{ $user->name }}, +
+
+{{ $companyName }} has set up your driver account. Use these details to sign in to the driver app. +
+
+Your sign-in details +
+Login: {{ $identity }} +
+Temporary password: {{ $plaintextPassword }} +
+
+You can change your password after signing in. + +
diff --git a/server/src/Exceptions/ProfileIdentityConflictException.php b/server/src/Exceptions/ProfileIdentityConflictException.php new file mode 100644 index 000000000..ebfb51d8b --- /dev/null +++ b/server/src/Exceptions/ProfileIdentityConflictException.php @@ -0,0 +1,34 @@ +field = $field; + } + + public function getField(): string + { + return $this->field; + } + + /** + * The conflict keyed by field, in the shape of a validation error bag. + */ + public function getErrors(): array + { + return [$this->field => [$this->getMessage()]]; + } +} diff --git a/server/src/Http/Controllers/Api/v1/CustomerController.php b/server/src/Http/Controllers/Api/v1/CustomerController.php index 6a37525a6..204dcd85b 100644 --- a/server/src/Http/Controllers/Api/v1/CustomerController.php +++ b/server/src/Http/Controllers/Api/v1/CustomerController.php @@ -44,6 +44,8 @@ class CustomerController extends Controller { use \Fleetbase\FleetOps\Http\Controllers\Concerns\ResolvesReviewAccountBypass; + public const DEACTIVATED_LOGIN_MESSAGE = 'This customer login has been deactivated.'; + /* ============================================================ | Public auth flows (API credential only, no Customer-Token) * ============================================================ */ @@ -345,6 +347,10 @@ public function login(Request $request) return response()->apiError('Authentication failed using credentials provided.', 401); } + if ($user->status === 'inactive') { + return response()->apiError(static::DEACTIVATED_LOGIN_MESSAGE, 403); + } + $sessionCompany = $this->sessionCompany(); if (!$sessionCompany) { return response()->apiError('No company resolved from API credential.', 500); @@ -381,6 +387,10 @@ public function loginWithPhone(Request $request) return response()->apiError('No customer with this phone number found.'); } + if ($user->status === 'inactive') { + return response()->apiError(static::DEACTIVATED_LOGIN_MESSAGE, 403); + } + try { $this->generateSmsVerification($user, 'fleetops_customer_login', [ 'messageCallback' => fn ($verification) => 'Your ' . config('app.name') . ' verification code is ' . $verification->code, @@ -423,6 +433,10 @@ public function verifyCode(Request $request) return response()->apiError('Unable to verify code.'); } + if ($user->status === 'inactive') { + return response()->apiError(static::DEACTIVATED_LOGIN_MESSAGE, 403); + } + $verificationCode = $this->verificationCodeExists([ 'subject_uuid' => $user->uuid, 'code' => $code, diff --git a/server/src/Http/Controllers/Api/v1/DriverController.php b/server/src/Http/Controllers/Api/v1/DriverController.php index 8711e0e1a..1fbacbe1d 100644 --- a/server/src/Http/Controllers/Api/v1/DriverController.php +++ b/server/src/Http/Controllers/Api/v1/DriverController.php @@ -6,6 +6,7 @@ use Fleetbase\FleetOps\Events\GeofenceEntered; use Fleetbase\FleetOps\Events\GeofenceExited; use Fleetbase\FleetOps\Events\VehicleLocationChanged; +use Fleetbase\FleetOps\Exceptions\ProfileIdentityConflictException; use Fleetbase\FleetOps\Exceptions\PublicRelationNotFoundException; use Fleetbase\FleetOps\Http\Controllers\Api\v1\Concerns\ResolvesFleetOpsApiResources; use Fleetbase\FleetOps\Http\Controllers\Api\v1\Concerns\ResolvesPublicExpansions; @@ -22,6 +23,7 @@ use Fleetbase\FleetOps\Models\Vendor; use Fleetbase\FleetOps\Support\GeofenceIntersectionService; use Fleetbase\FleetOps\Support\OSRM; +use Fleetbase\FleetOps\Support\ProfileAccountManager; use Fleetbase\FleetOps\Support\Utils; use Fleetbase\Http\Controllers\Controller; use Fleetbase\Http\Requests\SwitchOrganizationRequest; @@ -47,6 +49,11 @@ class DriverController extends Controller use ResolvesFleetOpsApiResources; use ResolvesPublicExpansions; + /** + * Returned when a driver whose login was deactivated from the console tries to sign in. + */ + public const DEACTIVATED_LOGIN_MESSAGE = 'This driver login has been deactivated.'; + /** * Public expansion name => Eloquent relation name. * @@ -97,20 +104,14 @@ public function create(CreateDriverRequest $request) // Apply user infos $userDetails = $this->applyUserInfoFromRequest($request, $userDetails); - // Set company_uuid before creating user - $userDetails['company_uuid'] = $company->uuid; - - // create user account for driver - $user = $this->createUser($userDetails); - - // Assign company — the early return above guarantees $company is set - $user->assignCompany($company); - - // Set user type - $user->setUserType('driver'); - - // assign driver role - $user->assignSingleRole('Driver'); + // Resolve the driver's login account: a team member of the company with + // the email/phone is linked, otherwise a driver account is created and + // added to the company without an organization invite. + try { + $user = $this->resolveDriverAccount($company->uuid, $userDetails); + } catch (ProfileIdentityConflictException $exception) { + return $this->jsonResponse(['error' => $exception->getMessage()], 422); + } // set user id $input['user_uuid'] = $user->uuid; @@ -193,10 +194,11 @@ public function update($id, UpdateDriverRequest $request) // it. The driver's own record has no timezone column; the user's does. $userDetails = $request->only(['name', 'email', 'phone', 'timezone']); - // update driver user details - $driverUser = $driver->getUser(); - if ($driverUser) { - $driverUser->update($userDetails); + // update the driver's login account through its proxy fields + try { + ProfileAccountManager::syncProxyFields($driver->getUser(), $userDetails); + } catch (ProfileIdentityConflictException $exception) { + return $this->jsonResponse(['error' => $exception->getMessage()], 422); } // latitude / longitude @@ -519,10 +521,14 @@ function ($query) use ($identity) { )->whereHas('driver')->first(); // Check password to authenticate driver - if (!Hash::check($password, $user->password)) { + if (!$user || !is_string($user->password) || !Hash::check((string) $password, $user->password)) { return response()->apiError('Authentication failed using password provided.', 401); } + if (static::isLoginDeactivated($user)) { + return response()->apiError(static::DEACTIVATED_LOGIN_MESSAGE, 403); + } + // Get the user's company for this driver profile $company = static::getDriverCompanyFromUser($user); @@ -561,6 +567,10 @@ public function loginWithPhone() return response()->apiError('No driver with this phone # found.'); } + if (static::isLoginDeactivated($user)) { + return response()->apiError(static::DEACTIVATED_LOGIN_MESSAGE, 403); + } + // Get the user's company for this driver profile $company = static::getDriverCompanyFromUser($user); @@ -627,6 +637,10 @@ public function verifyCode(Request $request) return response()->apiError('Unable to verify code.'); } + if (static::isLoginDeactivated($user)) { + return response()->apiError(static::DEACTIVATED_LOGIN_MESSAGE, 403); + } + // find and verify code $verificationCode = VerificationCode::where(['subject_uuid' => $user->uuid, 'code' => $code, 'for' => $for])->exists(); if (!$verificationCode && !static::verificationBypassMatches($identity, $code)) { @@ -975,26 +989,29 @@ protected function applyUserInfoFromRequest(Request $request, array $userDetails return User::applyUserInfoFromRequest($request, $userDetails); } - protected function createUser(array $userDetails): User + /** + * Resolve the login account for a driver created through the API. + * + * @throws ProfileIdentityConflictException + */ + protected function resolveDriverAccount(string $companyUuid, array $userDetails): User { - /* - * `password` is guarded on User, so mass assignment drops it without a - * word. The create endpoint has always accepted and validated one, so a - * driver created through the API could never sign in with the password - * their operator chose for them. Set it after the fact, where the - * model's mutator hashes it. - */ - $password = $userDetails['password'] ?? null; - unset($userDetails['password']); - - $user = User::create($userDetails); - - if (is_string($password) && strlen($password)) { - $user->password = $password; - $user->save(); - } + return ProfileAccountManager::resolveForProfile( + $companyUuid, + 'driver', + $userDetails['name'] ?? null, + $userDetails['email'] ?? null, + $userDetails['phone'] ?? null, + array_merge(Arr::except($userDetails, ['name', 'email', 'phone']), ['status' => 'active']) + ); + } - return $user; + /** + * Whether the driver's login was deactivated from the console. + */ + public static function isLoginDeactivated(User $user): bool + { + return $user->status === 'inactive'; } protected function getUuid(array|string $table, array $where, array $options = []): mixed @@ -1313,7 +1330,7 @@ public function forgotPassword(Request $request) } $user = static::findDriverUserByIdentity($identity); - if (!$user) { + if (!$user || static::isLoginDeactivated($user)) { return response()->json(['status' => 'ok']); } @@ -1344,7 +1361,7 @@ public function resetPassword(Request $request) } $user = static::findDriverUserByIdentity($identity); - if (!$user) { + if (!$user || static::isLoginDeactivated($user)) { return response()->apiError('Invalid or expired reset code.', 422); } diff --git a/server/src/Http/Controllers/Internal/v1/ContactController.php b/server/src/Http/Controllers/Internal/v1/ContactController.php index d0a8cad03..402fd4eb9 100644 --- a/server/src/Http/Controllers/Internal/v1/ContactController.php +++ b/server/src/Http/Controllers/Internal/v1/ContactController.php @@ -265,25 +265,13 @@ protected function customerPortalIssuesForContact(Contact $contact) ->get(); } + /** + * The login account is managed by the contact profile, so a user can't be + * picked or swapped from the console. + */ private function resolveUserInput(Request $request, array &$input): void { - $user = data_get($input, 'user_uuid') ?? data_get($input, 'user') ?? $request->input('contact.user_uuid') ?? $request->input('contact.user'); - - if (is_array($user)) { - $user = data_get($user, 'uuid') ?? data_get($user, 'id'); - } - - if (!$user) { - return; - } - - $input['user_uuid'] = $this->resolveUserUuid($user); - unset($input['user']); - } - - protected function resolveUserUuid(string $user): string - { - return User::where('uuid', $user)->orWhere('public_id', $user)->value('uuid') ?? $user; + unset($input['user_uuid'], $input['user']); } protected function assertCustomerPortalCanSendWelcomeEmail(array $input): void diff --git a/server/src/Http/Controllers/Internal/v1/CustomerController.php b/server/src/Http/Controllers/Internal/v1/CustomerController.php index 411c261bd..e17f4c4fd 100644 --- a/server/src/Http/Controllers/Internal/v1/CustomerController.php +++ b/server/src/Http/Controllers/Internal/v1/CustomerController.php @@ -4,6 +4,7 @@ use Fleetbase\FleetOps\Mail\CustomerCredentialsMail; use Fleetbase\FleetOps\Models\Contact; +use Fleetbase\FleetOps\Support\ProfileAccountManager; use Fleetbase\Http\Controllers\Controller; use Fleetbase\Models\User; use Illuminate\Http\Request; @@ -21,6 +22,10 @@ public function createPortalLogin(Request $request) return response()->error('Unable to create customer portal login.'); } + if ($error = $this->staffLinkedError($user)) { + return $error; + } + if ($user->status !== 'active') { $user->activate(); } @@ -39,6 +44,10 @@ public function sendCredentials(Request $request) return response()->error('Unable to send customer portal credentials.'); } + if ($error = $this->staffLinkedError($user)) { + return $error; + } + $password = $this->randomPassword(); $user->changePassword($password); $this->sendCustomerCredentials($user, $password, $customer); @@ -57,6 +66,10 @@ public function deactivatePortalLogin(Request $request) return response()->error('Customer portal login not found.'); } + if ($error = $this->staffLinkedError($user)) { + return $error; + } + $user->deactivate(); return response()->json([ @@ -73,6 +86,10 @@ public function reactivatePortalLogin(Request $request) return response()->error('Customer portal login not found.'); } + if ($error = $this->staffLinkedError($user)) { + return $error; + } + $user->activate(); return response()->json([ @@ -149,6 +166,10 @@ public function resetCredentials(Request $request) return response()->error('Unable to reset customer credentials'); } + if ($error = $this->staffLinkedError($user)) { + return $error; + } + // Change password $user->changePassword($password); @@ -163,6 +184,19 @@ public function resetCredentials(Request $request) ]); } + /** + * A customer linked to a team member's account signs in with that account, + * which is managed in IAM. + */ + protected function staffLinkedError(User $user): ?\Illuminate\Http\JsonResponse + { + if (ProfileAccountManager::isStaffAccount($user)) { + return response()->error('This customer signs in with a team member account. Manage this login in IAM.', 422); + } + + return null; + } + protected function resolveCustomer(Request $request): Contact { $customerId = $request->input('customer'); @@ -213,10 +247,12 @@ protected function customerPayload(Contact $customer): array $customer->loadMissing('user'); return [ - 'id' => $customer->public_id, - 'uuid' => $customer->uuid, - 'user_uuid' => $customer->user_uuid, - 'user' => $customer->user ? [ + 'id' => $customer->public_id, + 'uuid' => $customer->uuid, + 'user_uuid' => $customer->user_uuid, + 'is_staff_linked' => ProfileAccountManager::isStaffAccount($customer->user), + 'login_status' => $customer->user?->status, + 'user' => $customer->user ? [ 'id' => $customer->user->public_id, 'uuid' => $customer->user->uuid, 'name' => $customer->user->name, diff --git a/server/src/Http/Controllers/Internal/v1/DriverController.php b/server/src/Http/Controllers/Internal/v1/DriverController.php index de272c6c1..26203e962 100644 --- a/server/src/Http/Controllers/Internal/v1/DriverController.php +++ b/server/src/Http/Controllers/Internal/v1/DriverController.php @@ -3,6 +3,7 @@ namespace Fleetbase\FleetOps\Http\Controllers\Internal\v1; use Fleetbase\Exceptions\FleetbaseRequestValidationException; +use Fleetbase\FleetOps\Exceptions\ProfileIdentityConflictException; use Fleetbase\FleetOps\Exports\DriverExport; use Fleetbase\FleetOps\Http\Controllers\Api\v1\DriverController as ApiDriverController; use Fleetbase\FleetOps\Http\Controllers\FleetOpsController; @@ -13,11 +14,11 @@ use Fleetbase\FleetOps\Models\Driver; use Fleetbase\FleetOps\Models\Order; use Fleetbase\FleetOps\Models\Vehicle; +use Fleetbase\FleetOps\Support\ProfileAccountManager; use Fleetbase\FleetOps\Support\Utils; use Fleetbase\Http\Requests\ExportRequest; use Fleetbase\Http\Requests\ImportRequest; use Fleetbase\LaravelMysqlSpatial\Types\Point; -use Fleetbase\Models\Invite; use Fleetbase\Models\User; use Fleetbase\Models\VerificationCode; use Fleetbase\Support\Auth; @@ -58,72 +59,6 @@ public function createRecord(Request $request) $validator = Validator::make($input, $rules); if ($validator->fails()) { - // here if the user exists already - // within organization: offer to create driver record - // outside organization: invite to join organization AS DRIVER - if ($validator->errors()->hasAny(['phone', 'email'])) { - // get existing user - $existingUser = null; - - // if values provided for user lookup - if (!empty($input['phone']) || !empty($input['email'])) { - $existingUserQuery = User::query(); - - if (!empty($input['phone']) && is_string($input['phone'])) { - $existingUserQuery->orWhere(function ($q) use ($input) { - $q->where('phone', $input['phone'])->whereNotNull('phone'); - }); - } - - if (!empty($input['email']) && is_string($input['email'])) { - $existingUserQuery->orWhere(function ($q) use ($input) { - $q->where('email', $input['email'])->whereNotNull('email'); - }); - } - - $existingUser = $existingUserQuery->first(); - } - - if ($existingUser) { - // if exists in organization create driver profile for user - $isOrganizationMember = $existingUser->companies()->where('companies.uuid', session('company'))->exists(); - - // Check if driver profile also already exists - $existingDriverProfile = Driver::where(['company_uuid' => session('company'), 'user_uuid' => $existingUser->uuid])->first(); - if ($existingDriverProfile) { - return ['driver' => new $this->resource($existingDriverProfile)]; - } - - // create driver profile for user - $input = collect($input) - ->except(['name', 'password', 'email', 'phone', 'meta', 'avatar_uuid', 'photo_uuid', 'status']) - ->filter() - ->toArray(); - - // Get current session company - $company = Auth::getCompany(); - $input['company_uuid'] = session('company', $company->uuid); - $input['user_uuid'] = $existingUser->uuid; - $input['slug'] = $existingUser->slug; - - // If no location provided set - if (empty($input['location'])) { - $input['location'] = new Point(0, 0); - } - - // create the profile - $driverProfile = Driver::create($input); - - // If not already a member of the company assign them to the company and send the user an invite - if (!$isOrganizationMember && $company) { - $existingUser->assignCompany($company); - } - - return ['driver' => new $this->resource($driverProfile)]; - } - } - - // check from validator object if phone or email is not unique return $createDriverRequest->responseWithErrors($validator); } @@ -134,104 +69,36 @@ function (&$request, &$input) { $input = collect($input); // Get current session company - $company = Auth::getCompany(); + $company = Auth::getCompany(); if (!$company) { throw new \Exception('Unable to create driver.'); } - if ($input->has('user_uuid')) { - $user = User::where('uuid', $input->get('user_uuid'))->first(); - - // Check if a driver profile already exists for this user in the current company - if ($user) { - $existingDriver = Driver::where(['user_uuid' => $user->uuid, 'company_uuid' => session('company')])->first(); - if ($existingDriver) { - throw new \Exception('This user account already belongs to a driver.'); - } - } - - // If user doesn't exist with provided UUID, create new user - if (!$user) { - $userInput = $input - ->only(['name', 'password', 'email', 'phone', 'status', 'avatar_uuid']) - ->filter() - ->toArray(); - - // handle `photo_uuid` - if (isset($input['photo_uuid']) && Str::isUuid($input['photo_uuid'])) { - $userInput['avatar_uuid'] = $input['photo_uuid']; - } - - // Make sure password is set - if (empty($userInput['password'])) { - $userInput['password'] = Str::random(14); - } - - // Set user company - $userInput['company_uuid'] = session('company', $company->uuid); - - // Apply user infos - $userInput = User::applyUserInfoFromRequest($request, $userInput); - - // Create user account - $user = User::create($userInput); - - // Set the user type to driver - $user->setType('driver'); - } elseif ($input->has('photo_uuid')) { - // Update existing user's avatar if photo provided - $user->update(['avatar_uuid' => $input->get('photo_uuid')]); - } - } else { - $userInput = $input - ->only(['name', 'password', 'email', 'phone', 'status', 'avatar_uuid']) - ->filter() - ->toArray(); - - // handle `photo_uuid` - if (isset($input['photo_uuid']) && Str::isUuid($input['photo_uuid'])) { - $userInput['avatar_uuid'] = $input['photo_uuid']; - } - - // Make sure password is set - if (empty($userInput['password'])) { - $userInput['password'] = Str::random(14); - } - - // Set user company - $userInput['company_uuid'] = session('company', $company->uuid); - - // Apply user infos - $userInput = User::applyUserInfoFromRequest($request, $userInput); - - // Create user account - $user = User::create($userInput); - - // Set the user type to driver - $user->setType('driver'); - } - - // if exists in organization create driver profile for user - $isOrganizationMember = $user->companies()->where('companies.uuid', session('company'))->exists(); + // The login account is managed by the driver profile: a team member + // with the email/phone is linked, otherwise a driver account is created. + $avatarUuid = Str::isUuid($input->get('photo_uuid')) ? $input->get('photo_uuid') : $input->get('avatar_uuid'); + $user = ProfileAccountManager::resolveForProfile( + $company->uuid, + 'driver', + $input->get('name'), + $input->get('email'), + $input->get('phone'), + User::applyUserInfoFromRequest($request, array_filter([ + 'password' => $input->get('password'), + 'status' => 'active', + 'avatar_uuid' => $avatarUuid, + ])) + ); // Prepare input $input = $input - ->except(['name', 'password', 'email', 'phone', 'meta', 'avatar_uuid', 'photo_uuid', 'status']) + ->except(['name', 'password', 'email', 'phone', 'meta', 'avatar_uuid', 'photo_uuid', 'status', 'user_uuid', 'user']) ->filter() ->toArray(); - // Assign user to company and send invite - if (!$isOrganizationMember && $company) { - $user->assignCompany($company); - } - - // Set user type as driver and set role to driver - if ($user->type === 'driver') { - $user->assignSingleRole('Driver'); - } - - $input['user_uuid'] = $user->uuid; - $input['slug'] = $user->slug; + $input['company_uuid'] = $company->uuid; + $input['user_uuid'] = $user->uuid; + $input['slug'] = $user->slug; // If no location provided set if (empty($input['location'])) { @@ -248,6 +115,8 @@ function ($request, &$driver) { ); return ['driver' => new $this->resource($record)]; + } catch (ProfileIdentityConflictException $e) { + return response()->error($e->getMessage(), 422, ['field' => $e->getField()]); } catch (QueryException $e) { return response()->error(env('DEBUG') ? $e->getMessage() : 'Error occurred while trying to create a ' . $this->resourceSingularlName); } catch (FleetbaseRequestValidationException $e) { @@ -287,18 +156,15 @@ public function updateRecord(Request $request, string $id) function (&$request, &$driver, &$input) { $driver->load(['user'])->guard(['user_uuid']); $input = collect($input); - $userInput = $input->only(['name', 'password', 'email', 'phone', 'avatar_uuid'])->reject(fn ($value) => $value === null)->toArray(); + $userInput = $input->only(['name', 'password', 'email', 'phone', 'avatar_uuid'])->reject(fn ($value, $key) => $value === null && !in_array($key, ['email', 'phone'], true))->toArray(); // handle `photo_uuid` if (isset($input['photo_uuid']) && Str::isUuid($input['photo_uuid'])) { $userInput['avatar_uuid'] = $input['photo_uuid']; } - $input = $input->except(['name', 'password', 'email', 'phone', 'meta', 'avatar_uuid', 'photo_uuid'])->toArray(); + $input = $input->except(['name', 'password', 'email', 'phone', 'meta', 'avatar_uuid', 'photo_uuid', 'user_uuid', 'user'])->toArray(); - // Update driver user details - $driverUser = $driver->getUser(); - if ($driverUser && !empty($userInput)) { - $driverUser->update($userInput); - } + // Update the driver's login account through its proxy fields + ProfileAccountManager::syncProxyFields($driver->getUser(), $userInput); // Flush cache $driver->flushAttributesCache(); @@ -318,6 +184,8 @@ function ($request, &$driver) { ); return ['driver' => new $this->resource($record)]; + } catch (ProfileIdentityConflictException $e) { + return response()->error($e->getMessage(), 422, ['field' => $e->getField()]); } catch (QueryException $e) { return response()->error(env('DEBUG') ? $e->getMessage() : 'Error occurred while trying to update a ' . $this->resourceSingularlName); } catch (FleetbaseRequestValidationException $e) { @@ -327,6 +195,150 @@ function ($request, &$driver) { } } + /** + * Generate a new password for the driver's login and send it to them. + * + * @return JsonResponse + */ + public function sendCredentials(string $id) + { + [$driver, $user, $error] = $this->resolveDriverLogin($id); + if ($error) { + return $error; + } + + try { + $sentVia = ProfileAccountManager::sendCredentials($driver, $user); + } catch (\Exception $e) { + return response()->error($e->getMessage()); + } + + return response()->json(['status' => 'ok', 'sent_via' => $sentVia, 'driver' => $this->driverLoginPayload($driver)]); + } + + /** + * Set a new password for the driver's login, optionally sending it to them. + * + * @return JsonResponse + */ + public function resetCredentials(Request $request, string $id) + { + $password = $request->input('password'); + if (!is_string($password) || strlen($password) < 8) { + return response()->error('Password must be at least 8 characters.'); + } + + if ($password !== $request->input('password_confirmation')) { + return response()->error('Passwords do not match.'); + } + + [$driver, $user, $error] = $this->resolveDriverLogin($id); + if ($error) { + return $error; + } + + $user->changePassword($password); + + try { + if ($request->boolean('send_credentials')) { + ProfileAccountManager::deliverCredentials($driver, $user, $password); + } + } catch (\Exception $e) { + return response()->error($e->getMessage()); + } + + return response()->json(['status' => 'ok', 'driver' => $this->driverLoginPayload($driver)]); + } + + /** + * Stop the driver from signing in to the driver app. + * + * @return JsonResponse + */ + public function deactivateLogin(string $id) + { + [$driver, $user, $error] = $this->resolveDriverLogin($id); + if ($error) { + return $error; + } + + $user->deactivate(); + + // Sign the driver out of the driver app + $user->tokens()->delete(); + + return response()->json(['status' => 'ok', 'driver' => $this->driverLoginPayload($driver)]); + } + + /** + * Allow the driver to sign in to the driver app again. + * + * @return JsonResponse + */ + public function reactivateLogin(string $id) + { + [$driver, $user, $error] = $this->resolveDriverLogin($id); + if ($error) { + return $error; + } + + $user->activate(); + + return response()->json(['status' => 'ok', 'driver' => $this->driverLoginPayload($driver)]); + } + + /** + * Find the driver and its managed login account. A driver linked to a team + * member's account has its login managed in IAM. + * + * @return array{0: ?Driver, 1: ?User, 2: ?JsonResponse} + */ + protected function resolveDriverLogin(string $id): array + { + $driver = Driver::where('company_uuid', session('company')) + ->where(function ($query) use ($id) { + $query->where('uuid', $id)->orWhere('public_id', $id); + }) + ->first(); + + if (!$driver) { + return [null, null, response()->error('Driver not found.', 404)]; + } + + $user = $driver->user_uuid ? User::where('uuid', $driver->user_uuid)->first() : null; + if (!$user) { + return [$driver, null, response()->error('This driver has no login account.')]; + } + + if (ProfileAccountManager::isStaffAccount($user)) { + return [$driver, $user, response()->error('This driver signs in with a team member account. Manage this login in IAM.', 422)]; + } + + return [$driver, $user, null]; + } + + protected function driverLoginPayload(Driver $driver): array + { + $user = User::where('uuid', $driver->user_uuid)->first(); + + return [ + 'id' => $driver->public_id, + 'uuid' => $driver->uuid, + 'user_uuid' => $driver->user_uuid, + 'is_staff_linked' => ProfileAccountManager::isStaffAccount($user), + 'login_status' => $user?->status, + 'user' => $user ? [ + 'id' => $user->public_id, + 'uuid' => $user->uuid, + 'name' => $user->name, + 'email' => $user->email, + 'phone' => $user->phone, + 'status' => $user->status, + 'session_status' => $user->session_status, + ] : null, + ]; + } + /** * Get all status options for an driver. * @@ -635,6 +647,10 @@ public function loginWithPhone() return response()->error('No driver with this phone # found.'); } + if (ApiDriverController::isLoginDeactivated($user)) { + return response()->error(ApiDriverController::DEACTIVATED_LOGIN_MESSAGE, 403); + } + // Generate verification token static::generateDriverLoginVerification($user); @@ -663,6 +679,10 @@ public function verifyCode(Request $request) return response()->error('Unable to verify code.'); } + if (ApiDriverController::isLoginDeactivated($user)) { + return response()->error(ApiDriverController::DEACTIVATED_LOGIN_MESSAGE, 403); + } + // Find and verify code $verificationCode = static::verificationCodeExists($user, $code, $for); if (!$verificationCode && !ApiDriverController::verificationBypassMatches($identity, $code)) { diff --git a/server/src/Http/Controllers/Internal/v1/FleetOpsLookupController.php b/server/src/Http/Controllers/Internal/v1/FleetOpsLookupController.php index 0f41d503a..ee0143ed3 100644 --- a/server/src/Http/Controllers/Internal/v1/FleetOpsLookupController.php +++ b/server/src/Http/Controllers/Internal/v1/FleetOpsLookupController.php @@ -3,16 +3,63 @@ namespace Fleetbase\FleetOps\Http\Controllers\Internal\v1; use Fleetbase\FleetOps\Models\Contact; +use Fleetbase\FleetOps\Models\Driver; use Fleetbase\FleetOps\Models\IntegratedVendor; use Fleetbase\FleetOps\Models\Vendor; +use Fleetbase\FleetOps\Support\ProfileAccountManager; use Fleetbase\FleetOps\Support\Utils; use Fleetbase\Http\Controllers\Controller; +use Fleetbase\Models\User; use Illuminate\Http\Request; use Illuminate\Support\Arr; use Illuminate\Support\Str; class FleetOpsLookupController extends Controller { + /** + * Describes what an email/phone entered on a driver, contact or customer + * form resolves to: the team member the profile would be linked to, or why + * the email/phone can't be used. + * + * @return \Illuminate\Http\JsonResponse + */ + public function profileIdentity(Request $request) + { + $type = $request->input('type', 'driver'); + if (!in_array($type, ProfileAccountManager::MANAGED_TYPES, true)) { + return response()->error('Invalid profile type.', 422); + } + + $currentUser = $this->currentProfileUser($type, $request->input('ignore')); + $lookup = ProfileAccountManager::lookup(session('company'), $type, $request->input('email'), $request->input('phone'), $currentUser); + $staff = $lookup['staff']; + + return response()->json([ + 'staff' => $staff ? ['uuid' => $staff->uuid, 'name' => $staff->name, 'email' => $staff->email, 'phone' => $staff->phone] : null, + 'conflict' => $lookup['conflict'], + ]); + } + + /** + * The account of the profile being edited, so its own email/phone isn't + * reported as taken. + */ + private function currentProfileUser(string $type, ?string $id): ?User + { + if (!$id) { + return null; + } + + $model = $type === 'driver' ? Driver::withoutGlobalScopes() : Contact::query(); + $profile = $model->where('company_uuid', session('company')) + ->where(function ($query) use ($id) { + $query->where('uuid', $id)->orWhere('public_id', $id); + }) + ->first(); + + return $profile?->user_uuid ? User::where('uuid', $profile->user_uuid)->first() : null; + } + /** * Returns a collection of polymorphic resources as JSON. * diff --git a/server/src/Http/Requests/CreateDriverRequest.php b/server/src/Http/Requests/CreateDriverRequest.php index 3bc95d57f..5f645b953 100644 --- a/server/src/Http/Requests/CreateDriverRequest.php +++ b/server/src/Http/Requests/CreateDriverRequest.php @@ -41,9 +41,13 @@ public function rules(): array * value later. Both are still validated and still unique when they * are supplied; a driver created without them simply cannot sign in * to Navigator until credentials are added. + * + * On create, availability is decided by ProfileAccountManager: a team + * member of the company with the email or phone is linked to the new + * driver instead of being rejected as a duplicate. */ - 'email' => ['nullable', Rule::when($this->filled('email'), ['email']), $this->uniqueAmongUsers()], - 'phone' => ['nullable', 'string', $this->uniqueAmongUsers()], + 'email' => ['nullable', Rule::when($this->filled('email'), ['email']), Rule::when(!$isCreating, [$this->uniqueAmongUsers()])], + 'phone' => ['nullable', 'string', Rule::when(!$isCreating, [$this->uniqueAmongUsers()])], 'password' => 'nullable|string', 'timezone' => 'nullable|string|max:64', diff --git a/server/src/Http/Requests/Internal/CreateDriverRequest.php b/server/src/Http/Requests/Internal/CreateDriverRequest.php index f8fe7e350..825244789 100644 --- a/server/src/Http/Requests/Internal/CreateDriverRequest.php +++ b/server/src/Http/Requests/Internal/CreateDriverRequest.php @@ -23,22 +23,17 @@ public function authorize(): bool */ public function rules(): array { - $isCreating = $this->isMethod('POST'); - $isCreatingWithUser = $this->filled('driver.user_uuid'); - $shouldValidateUserAttributes = $isCreating && !$isCreatingWithUser; + $isCreating = $this->isMethod('POST'); return [ - // Required fields for driver creation - 'name' => [Rule::requiredIf($shouldValidateUserAttributes), 'nullable', 'string', 'max:255'], - 'email' => [ - Rule::requiredIf($shouldValidateUserAttributes), - Rule::when($this->filled('email'), ['email']), - Rule::when($shouldValidateUserAttributes, [Rule::unique('users')->whereNull('deleted_at')]), - ], - 'phone' => [ - Rule::requiredIf($shouldValidateUserAttributes), - Rule::when($shouldValidateUserAttributes, [Rule::unique('users')->whereNull('deleted_at')]), - ], + // The driver profile manages its login account: name, email and phone + // are proxied to it. Email/phone availability is checked against the + // account the profile resolves to (see ProfileAccountManager), because a + // team member of the organization with the same email or phone is linked + // rather than rejected as a duplicate. + 'name' => [Rule::requiredIf($isCreating), 'nullable', 'string', 'max:255'], + 'email' => ['nullable', Rule::when($this->filled('email'), ['email'])], + 'phone' => ['nullable', 'string'], // Optional fields 'password' => 'nullable|string|min:8', @@ -84,11 +79,7 @@ public function messages(): array { return [ 'name.required' => 'Driver name is required.', - 'email.required' => 'Email address is required.', 'email.email' => 'Please provide a valid email address.', - 'email.unique' => 'This email address is already registered.', - 'phone.required' => 'Phone number is required.', - 'phone.unique' => 'This phone number is already registered.', 'password.min' => 'Password must be at least 8 characters.', ]; } diff --git a/server/src/Http/Resources/v1/Contact.php b/server/src/Http/Resources/v1/Contact.php index abd708b16..be4bc47b8 100644 --- a/server/src/Http/Resources/v1/Contact.php +++ b/server/src/Http/Resources/v1/Contact.php @@ -2,6 +2,7 @@ namespace Fleetbase\FleetOps\Http\Resources\v1; +use Fleetbase\FleetOps\Support\ProfileAccountManager; use Fleetbase\FleetOps\Support\Utils; use Fleetbase\Http\Resources\FleetbaseResource; use Fleetbase\Http\Resources\User; @@ -40,6 +41,8 @@ public function toArray($request) 'places' => $this->whenLoaded('places', fn () => Place::collection($this->places)->without('owner')), 'user' => $this->when(Http::isInternalRequest(), fn () => new User($this->user), fn () => $this->user ? $this->user->public_id : null), 'address' => $this->when(Http::isInternalRequest(), data_get($this, 'place.address')), + 'is_staff_linked' => $this->when(Http::isInternalRequest(), fn () => ProfileAccountManager::isStaffAccount($this->user)), + 'login_status' => $this->when(Http::isInternalRequest(), fn () => data_get($this, 'user.status')), 'address_street' => $this->when(Http::isInternalRequest(), data_get($this, 'place.street1')), 'type' => $this->type ?? null, 'customer_type' => $this->when(isset($this->customer_type), Utils::toEmberResourceType($this->customer_type)), diff --git a/server/src/Http/Resources/v1/Driver.php b/server/src/Http/Resources/v1/Driver.php index 0da9aeea4..0a83248a1 100644 --- a/server/src/Http/Resources/v1/Driver.php +++ b/server/src/Http/Resources/v1/Driver.php @@ -2,6 +2,7 @@ namespace Fleetbase\FleetOps\Http\Resources\v1; +use Fleetbase\FleetOps\Support\ProfileAccountManager; use Fleetbase\FleetOps\Support\Utils; use Fleetbase\Http\Resources\FleetbaseResource; use Fleetbase\Http\Resources\FleetbaseResourceCollection; @@ -32,6 +33,8 @@ public function toArray($request) 'public_id' => $this->when(Http::isInternalRequest(), $this->public_id), 'user' => $this->when(Http::isPublicRequest(), fn () => $this->user ? $this->user->public_id : null, new User($this->user)), 'internal_id' => $this->internal_id, + 'is_staff_linked' => $this->when(Http::isInternalRequest(), fn () => ProfileAccountManager::isStaffAccount($this->user)), + 'login_status' => $this->when(Http::isInternalRequest(), fn () => data_get($this, 'user.status')), 'company' => $this->when(Http::isPublicRequest(), fn () => $this->company ? $this->company->public_id : null), 'company_name' => $this->when(Http::isPublicRequest(), fn () => $this->company ? $this->company->name : null), 'name' => $this->name, diff --git a/server/src/Mail/DriverCredentialsMail.php b/server/src/Mail/DriverCredentialsMail.php new file mode 100644 index 000000000..a917e8f60 --- /dev/null +++ b/server/src/Mail/DriverCredentialsMail.php @@ -0,0 +1,55 @@ +driver->loadMissing('company'); + + return new Envelope( + subject: 'Your ' . ($this->driver->company?->name ?? config('app.name')) . ' driver sign-in details', + ); + } + + /** + * Get the message content definition. + */ + public function content(): Content + { + $this->driver->loadMissing('company'); + + return new Content( + markdown: 'fleetops::mail.driver-credentials', + with: [ + 'driver' => $this->driver, + 'user' => $this->user, + 'companyName' => $this->driver->company?->name ?? config('app.name'), + 'identity' => $this->user->email ?? $this->user->phone, + 'plaintextPassword' => $this->plaintextPassword, + ] + ); + } +} diff --git a/server/src/Models/Contact.php b/server/src/Models/Contact.php index 4a041e99d..b0b25a143 100644 --- a/server/src/Models/Contact.php +++ b/server/src/Models/Contact.php @@ -5,6 +5,7 @@ use Fleetbase\Casts\Json; use Fleetbase\FleetOps\Exceptions\CustomerUserConflictException; use Fleetbase\FleetOps\Exceptions\UserAlreadyExistsException; +use Fleetbase\FleetOps\Support\ProfileAccountManager; use Fleetbase\FleetOps\Support\Utils; use Fleetbase\Models\CompanyUser; use Fleetbase\Models\Model; @@ -157,9 +158,13 @@ public function anyUser(): BelongsTo|Builder return $this->belongsTo(User::class, 'user_uuid'); } + /** + * The contact's login account: a managed `contact`/`customer` account, or a + * team member's account linked by email or phone. + */ public function user(): BelongsTo|Builder { - return $this->belongsTo(User::class, 'user_uuid')->where('type', $this->type); + return $this->belongsTo(User::class, 'user_uuid'); } public function photo(): BelongsTo @@ -316,74 +321,16 @@ public static function createFromImport(array $row, bool $saveInstance = false): */ public static function createUserFromContact(Contact $contact, bool $sendInvite = false, bool $update = false): User { - // Check if user already exist with email or phone number - $existingUser = null; - if ($contact->email || $contact->phone) { - $existingUser = User::where(function ($query) use ($contact) { - $query->where('company_uuid', $contact->company_uuid) - ->orWhereHas('companyUsers', function ($query) use ($contact) { - $query->where('company_uuid', $contact->company_uuid); - }); - }) - ->where(function ($query) use ($contact) { - if ($contact->email) { - $query->where('email', $contact->email); - } - - if ($contact->phone) { - $method = $contact->email ? 'orWhere' : 'where'; - $query->{$method}('phone', Utils::formatPhoneNumber($contact->phone)); - } - }) - ->whereNull('deleted_at') - ->first(); - } - - if ($existingUser) { - if ($contact->isCustomer()) { - $contact->assertCustomerUserCanBeAssigned($existingUser); - } - - // Check if existing user belongs to another contact - $existingUserContact = Contact::where(['user_uuid' => $existingUser->uuid, 'company_uuid' => $contact->company_uuid])->whereHas('user')->first(); - if ($existingUserContact) { - throw new UserAlreadyExistsException('User already exists, try to assigning the user to this contact.', $existingUser); - } - // Assign the user to this contact instead - $contact->setAttribute('user_uuid', $existingUser->uuid); - if ($update) { - $contact->update(['user_uuid' => $existingUser->uuid]); - } - $contact->setRelation('user', $existingUser); - - return $existingUser; - } - - // Load company $contact->loadMissing('company'); - // Create the user record - $user = User::create([ - 'company_uuid' => $contact->company_uuid, - 'name' => $contact->name, - 'email' => $contact->email, - 'phone' => $contact->phone ? Utils::formatPhoneNumber($contact->phone) : null, - 'username' => Str::slug($contact->name . '_' . Str::random(4), '_'), - 'password' => Str::random(), - 'timezone' => $contact->company->timezone ?? date_default_timezone_get(), - 'status' => 'pending', - ]); - - // Set user type - $user->setType($contact->type); - - // Assign to company without triggering core organization invitations. - static::assignUserToContactCompany($contact, $user); - - // Assign customer role - if ($contact->isCustomer()) { - $user->assignSingleRole('Fleet-Ops Customer'); - } + $user = ProfileAccountManager::resolveForProfile( + $contact->company_uuid, + $contact->isCustomer() ? 'customer' : 'contact', + $contact->name, + $contact->email, + $contact->phone, + ['timezone' => $contact->company->timezone ?? null] + ); // Set user to contact $contact->setAttribute('user_uuid', $user->uuid); @@ -412,6 +359,13 @@ public function normalizeCustomerUser(?User $user = null, bool $quiet = false): $this->assertCustomerUserCanBeAssigned($user); + // A team member's account keeps its own organization role + if (ProfileAccountManager::isStaffAccount($user)) { + $this->setRelation('user', $user); + + return $user; + } + $this->loadMissing('company'); if ($this->company) { $companyUser = CompanyUser::firstOrCreate( @@ -511,7 +465,8 @@ public function assignUser(User $user, bool $sendInvite = false): self // Get the company user instance $companyUser = $user->getCompanyUser($this->company); - if ($companyUser) { + // A team member's account keeps its own organization role + if ($companyUser && ProfileAccountManager::isManagedAccount($user)) { $companyUser->assignSingleRole($this->isCustomer() ? 'Fleet-Ops Customer' : 'Fleet-Ops Contact'); } @@ -546,6 +501,11 @@ public function assertCustomerUserCanBeAssigned(User $user): void return; } + // A team member of the organization can hold a customer profile + if (ProfileAccountManager::isStaffAccount($user) && ProfileAccountManager::isCompanyMember($user, $this->company_uuid)) { + return; + } + throw new CustomerUserConflictException(static::customerUserConflictMessage($user), $user); } @@ -553,6 +513,10 @@ public static function customerUserConflictMessage(?User $user = null): string { $field = $user?->email ? 'email' : ($user?->phone ? 'phone number' : 'user account'); + if (ProfileAccountManager::isManagedAccount($user)) { + return 'This ' . $field . ' is already used by a ' . $user->type . ' and cannot be used for a customer account.'; + } + return 'This ' . $field . ' belongs to an existing staff user and cannot be used for a customer account.'; } @@ -591,32 +555,31 @@ private function getAssignedUserWithoutTypeScope(): ?User return User::where('uuid', $this->user_uuid)->first(); } + /** + * Push the contact's changed proxy fields (name, email, phone, timezone) to + * its login account. A team member's account only takes the name. + * + * @throws \Fleetbase\FleetOps\Exceptions\ProfileIdentityConflictException + */ public function syncWithUser(): bool { - $updates = []; - - if ($this->isDirty('name')) { - $updates['name'] = $this->name; - } - - if ($this->isDirty('email')) { - $updates['email'] = $this->email; + // A new contact's account was just resolved from these values + if (!$this->exists) { + return false; } - if ($this->isDirty('phone')) { - $updates['phone'] = $this->phone; - } - - if ($this->isDirty('timezone')) { - $updates['timezone'] = $this->timezone; + $updates = []; + foreach (['name', 'email', 'phone', 'timezone'] as $field) { + if ($this->isDirty($field)) { + $updates[$field] = $this->{$field}; + } } - $user = $this->getUser(); - if ($user) { - return $user->update($updates); + if (empty($updates)) { + return false; } - return false; + return ProfileAccountManager::syncProxyFields($this->getUser(), $updates); } /** @@ -638,12 +601,14 @@ public function createUser(bool $sendInvite = false): User */ public function deleteUser(): ?bool { - $this->loadMissing('user'); - if ($this->user && $this->user->type === $this->type) { - return $this->user->delete(); + $user = $this->getUser(); + if (!ProfileAccountManager::isManagedAccount($user)) { + return false; } - return false; + ProfileAccountManager::releaseForProfile($user, $this->company_uuid); + + return $user->trashed(); } public function getUser(): ?User diff --git a/server/src/Observers/ContactObserver.php b/server/src/Observers/ContactObserver.php index ebc2dafd1..18e0f78a8 100644 --- a/server/src/Observers/ContactObserver.php +++ b/server/src/Observers/ContactObserver.php @@ -3,7 +3,6 @@ namespace Fleetbase\FleetOps\Observers; use Fleetbase\FleetOps\Models\Contact; -use Fleetbase\Models\User; class ContactObserver { @@ -42,17 +41,8 @@ public function saving(Contact $contact) $contact->normalizeCustomerUser(); } - // Validate email is available to user - if (!empty($contact->email) && $contact->wasChanged('email') && $this->isEmailUnavailable($contact)) { - throw new \Exception('Email attempting to update for ' . $contact->type . ' is not available.'); - } - - // Validate phone is available to user - if (!empty($contact->phone) && $contact->wasChanged('phone') && $this->isPhoneUnavailable($contact)) { - throw new \Exception('Phone attempting to update for ' . $contact->type . ' is not available.'); - } - - // Sync updates from contact to user + // Sync updates from contact to its login account. This throws when the + // new email or phone is already used by another account. $contact->syncWithUser(); } @@ -66,14 +56,4 @@ public function deleted(Contact $contact) // Delete the assosciated user account $contact->deleteUser(); } - - private function isEmailUnavailable(Contact $contact) - { - return User::where('email', $contact->email)->whereNot('uuid', $contact->user_uuid)->exists() || Contact::where(['email' => $contact->email, 'company_uuid' => $contact->company_uuid])->whereNot('uuid', $contact->uuid)->exists(); - } - - private function isPhoneUnavailable(Contact $contact) - { - return User::where('phone', $contact->phone)->whereNot('uuid', $contact->user_uuid)->exists() || Contact::where(['phone' => $contact->phone, 'company_uuid' => $contact->company_uuid])->whereNot('uuid', $contact->uuid)->exists(); - } } diff --git a/server/src/Observers/DriverObserver.php b/server/src/Observers/DriverObserver.php index 2dbc1ff09..ba806dc83 100644 --- a/server/src/Observers/DriverObserver.php +++ b/server/src/Observers/DriverObserver.php @@ -5,6 +5,7 @@ use Fleetbase\FleetOps\Models\Driver; use Fleetbase\FleetOps\Models\Order; use Fleetbase\FleetOps\Support\LiveCacheService; +use Fleetbase\FleetOps\Support\ProfileAccountManager; use Fleetbase\LaravelMysqlSpatial\Types\Point; use Fleetbase\Models\User; @@ -64,11 +65,9 @@ public function deleted(Driver $driver) // Unassign them from any order they are assigned to $this->unassignOrders($driver); - // If the driver had a user account with the role driver and type user delete it - $user = $this->findDriverUser($driver); - if ($user && $user->hasRole('Driver')) { - $user->delete(); - } + // Delete the driver's managed login account, which frees its email and + // phone. A team member's account linked to the driver is left alone. + ProfileAccountManager::releaseForProfile($this->findDriverUser($driver), $driver->company_uuid); $this->invalidateLiveCache(); } @@ -85,6 +84,6 @@ protected function unassignOrders(Driver $driver): int protected function findDriverUser(Driver $driver): ?User { - return User::where(['uuid' => $driver->user_uuid, 'type' => 'user'])->first(); + return $driver->user_uuid ? User::where('uuid', $driver->user_uuid)->first() : null; } } diff --git a/server/src/Support/ProfileAccountManager.php b/server/src/Support/ProfileAccountManager.php new file mode 100644 index 000000000..521ea4157 --- /dev/null +++ b/server/src/Support/ProfileAccountManager.php @@ -0,0 +1,405 @@ + 'Driver', + 'contact' => 'Fleet-Ops Contact', + 'customer' => 'Fleet-Ops Customer', + ]; + + /** + * Whether the account is owned by a driver, contact or customer profile. + */ + public static function isManagedAccount(?object $user): bool + { + return $user !== null && in_array(data_get($user, 'type'), static::MANAGED_TYPES, true); + } + + /** + * Whether the account is a team member's account managed in IAM. + */ + public static function isStaffAccount(?object $user): bool + { + return $user !== null && !static::isManagedAccount($user); + } + + /** + * Find the active account holding the given email or phone. + */ + public static function findAccountByIdentity(?string $email, ?string $phone, ?string $ignoreUserUuid = null): ?User + { + $email = static::normalizeEmail($email); + $phone = static::normalizePhone($phone); + + if (!$email && !$phone) { + return null; + } + + return User::where(function ($query) use ($email, $phone) { + if ($email) { + $query->orWhere('email', $email); + } + + if ($phone) { + $query->orWhere('phone', $phone); + } + }) + ->when($ignoreUserUuid, fn ($query) => $query->where('uuid', '!=', $ignoreUserUuid)) + ->whereNull('deleted_at') + ->first(); + } + + /** + * Describe what would happen to an email/phone entered on a new or existing + * profile: `staff` is the team member the profile would link to, `conflict` + * is why the email/phone cannot be used. + * + * @return array{account: ?User, staff: ?User, conflict: ?string} + */ + public static function lookup(string $companyUuid, string $type, ?string $email, ?string $phone, ?User $currentUser = null): array + { + $result = ['account' => null, 'staff' => null, 'conflict' => null]; + $account = static::findAccountByIdentity($email, $phone, $currentUser?->uuid); + + if (!$account) { + return $result; + } + + $result['account'] = $account; + $field = static::matchedField($account, $email); + + // An existing profile can't swap its account for another one + if ($currentUser) { + $result['conflict'] = static::takenMessage($field); + + return $result; + } + + try { + static::assertAccountCanHoldProfile($account, $companyUuid, $type, $field); + } catch (ProfileIdentityConflictException $e) { + $result['conflict'] = $e->getMessage(); + + return $result; + } + + if (static::isStaffAccount($account)) { + $result['staff'] = $account; + } + + return $result; + } + + /** + * Resolve the account for a new profile. A staff member of the organization + * or a managed account of the same type with the email/phone is linked; + * otherwise a managed account is created. + * + * @param array $attributes extra user attributes: password, status, timezone, avatar_uuid, country, ip_address, meta + * + * @throws ProfileIdentityConflictException + */ + public static function resolveForProfile(string $companyUuid, string $type, ?string $name, ?string $email, ?string $phone, array $attributes = []): User + { + $account = static::findAccountByIdentity($email, $phone); + + if ($account) { + static::assertAccountCanHoldProfile($account, $companyUuid, $type, static::matchedField($account, $email)); + + if (static::isManagedAccount($account)) { + static::attachToCompany($account, $companyUuid, $type); + } + + return $account; + } + + return static::createManagedAccount($companyUuid, $type, $name, $email, $phone, $attributes); + } + + /** + * Create a managed account and add it to the organization without an invite. + */ + public static function createManagedAccount(string $companyUuid, string $type, ?string $name, ?string $email, ?string $phone, array $attributes = []): User + { + $company = Company::where('uuid', $companyUuid)->first(); + $password = $attributes['password'] ?? null; + $userData = array_filter([ + 'company_uuid' => $companyUuid, + 'name' => $name, + 'email' => static::normalizeEmail($email), + 'phone' => static::normalizePhone($phone), + 'username' => User::generateUsername($name ?: Str::random(6)), + 'timezone' => $attributes['timezone'] ?? $company?->timezone ?? date_default_timezone_get(), + 'status' => $attributes['status'] ?? 'pending', + 'avatar_uuid' => $attributes['avatar_uuid'] ?? null, + 'country' => $attributes['country'] ?? null, + 'ip_address' => $attributes['ip_address'] ?? null, + 'meta' => $attributes['meta'] ?? null, + ], fn ($value) => $value !== null); + + $user = new User($userData); + $user->password = $password ?: Str::random(16); + $user->save(); + $user->setType($type); + + static::attachToCompany($user, $companyUuid, $type); + + return $user; + } + + /** + * Push the profile's proxy fields to its account. A staff account only + * takes the name: its email and phone are its console identity. + * + * @throws ProfileIdentityConflictException + */ + public static function syncProxyFields(?User $user, array $attributes): bool + { + if (!$user) { + return false; + } + + $fields = static::isStaffAccount($user) ? ['name'] : ['name', 'email', 'phone', 'timezone', 'avatar_uuid', 'password']; + $input = array_intersect_key($attributes, array_flip($fields)); + + if (array_key_exists('email', $input)) { + $input['email'] = static::normalizeEmail($input['email']); + } + + if (array_key_exists('phone', $input)) { + $input['phone'] = static::normalizePhone($input['phone']); + } + + $input = array_filter($input, fn ($value, $key) => $value !== null || in_array($key, ['email', 'phone'], true), ARRAY_FILTER_USE_BOTH); + $input = array_filter($input, fn ($value, $key) => $key === 'password' || $user->{$key} !== $value, ARRAY_FILTER_USE_BOTH); + + if (empty($input)) { + return false; + } + + $changedEmail = array_key_exists('email', $input) ? $input['email'] : null; + $changedPhone = array_key_exists('phone', $input) ? $input['phone'] : null; + $taken = static::findAccountByIdentity($changedEmail, $changedPhone, $user->uuid); + if ($taken) { + throw new ProfileIdentityConflictException(static::takenMessage(static::matchedField($taken, $changedEmail)), static::matchedField($taken, $changedEmail)); + } + + if (isset($input['password'])) { + $user->password = $input['password']; + unset($input['password']); + } + + $user->fill($input); + + return $user->save(); + } + + /** + * Release a profile's account after the profile is deleted. A managed + * account with no other profile is deleted, which frees its email and + * phone; one still used by another organization's profile only leaves this + * organization. Staff accounts are never touched. + */ + public static function releaseForProfile(?User $user, ?string $companyUuid = null): void + { + if (!static::isManagedAccount($user) || $user->trashed()) { + return; + } + + $remainingProfiles = static::profileCompanies($user); + if ($remainingProfiles->isEmpty()) { + $user->delete(); + + return; + } + + if ($companyUuid && !$remainingProfiles->contains($companyUuid)) { + CompanyUser::where(['company_uuid' => $companyUuid, 'user_uuid' => $user->uuid])->delete(); + } + } + + /** + * Generate a new password for the account and send it to the profile. + * + * @return string how the credentials were sent: `email` or `sms` + */ + public static function sendCredentials(Driver|Contact $profile, User $user, ?string $password = null): string + { + $password ??= Str::random(12); + $user->changePassword($password); + + if ($user->status !== 'active') { + $user->activate(); + } + + return static::deliverCredentials($profile, $user, $password); + } + + /** + * Send already set credentials to the profile by email, or by SMS when the + * account has no email. + */ + public static function deliverCredentials(Driver|Contact $profile, User $user, string $password): string + { + if ($user->email) { + Mail::to($user)->send($profile instanceof Driver ? new DriverCredentialsMail($password, $profile, $user) : new CustomerCredentialsMail($password, $profile)); + + return 'email'; + } + + if ($user->phone) { + $profile->loadMissing('company'); + $companyName = $profile->company?->name ?? config('app.name'); + $identity = $user->phone; + $result = app(SmsService::class)->send($user->phone, "Your {$companyName} sign-in details. Login: {$identity} Password: {$password}"); + + if (is_array($result) && array_key_exists('success', $result) && !$result['success']) { + throw new \Exception('The credentials could not be texted: ' . ($result['error'] ?? $result['message'] ?? 'the SMS provider refused it.')); + } + + return 'sms'; + } + + throw new \Exception('This profile has no email or phone to send credentials to.'); + } + + /** + * Throw when the account can't hold the kind of profile being created. + * + * @throws ProfileIdentityConflictException + */ + public static function assertAccountCanHoldProfile(User $account, string $companyUuid, string $type, string $field = 'email'): void + { + if (static::isStaffAccount($account)) { + if (!static::isCompanyMember($account, $companyUuid)) { + throw new ProfileIdentityConflictException(static::takenMessage($field), $field); + } + } elseif ($account->type !== $type) { + throw new ProfileIdentityConflictException('This ' . static::fieldLabel($field) . ' is already used by a ' . $account->type . '.', $field); + } + + if (static::hasProfileInCompany($account, $companyUuid, $type)) { + throw new ProfileIdentityConflictException('A ' . $type . ' with this ' . static::fieldLabel($field) . ' already exists.', $field); + } + } + + public static function hasProfileInCompany(User $user, string $companyUuid, string $type): bool + { + if ($type === 'driver') { + return Driver::withoutGlobalScopes()->where(['user_uuid' => $user->uuid, 'company_uuid' => $companyUuid])->whereNull('deleted_at')->exists(); + } + + return Contact::withoutGlobalScopes()->where(['user_uuid' => $user->uuid, 'company_uuid' => $companyUuid])->whereNull('deleted_at')->exists(); + } + + /** + * The organizations the account still has a driver or contact profile in. + */ + public static function profileCompanies(User $user): \Illuminate\Support\Collection + { + $drivers = Driver::withoutGlobalScopes()->where('user_uuid', $user->uuid)->whereNull('deleted_at')->pluck('company_uuid'); + $contacts = Contact::withoutGlobalScopes()->where('user_uuid', $user->uuid)->whereNull('deleted_at')->pluck('company_uuid'); + + return $drivers->merge($contacts)->filter()->unique()->values(); + } + + public static function isCompanyMember(User $user, ?string $companyUuid): bool + { + if (!$companyUuid) { + return false; + } + + return $user->company_uuid === $companyUuid || CompanyUser::where(['company_uuid' => $companyUuid, 'user_uuid' => $user->uuid])->exists(); + } + + /** + * Add a managed account to the organization with its profile role, without + * sending an organization invite. + */ + public static function attachToCompany(User $user, string $companyUuid, string $type): ?CompanyUser + { + $company = Company::where('uuid', $companyUuid)->first(); + if (!$company) { + return null; + } + + $role = static::ROLES[$type] ?? static::ROLES['contact']; + $companyUser = CompanyUser::where(['company_uuid' => $companyUuid, 'user_uuid' => $user->uuid])->first(); + if (!$companyUser) { + $companyUser = $company->addUser($user, $role); + } elseif (static::isManagedAccount($user)) { + $companyUser->assignSingleRole($role); + } + + if (!$user->company_uuid) { + $user->forceFill(['company_uuid' => $companyUuid])->saveQuietly(); + } + + $user->setRelation('companyUser', $companyUser); + + return $companyUser; + } + + public static function normalizeEmail(?string $email): ?string + { + $email = is_string($email) ? strtolower(trim($email)) : null; + + return $email ?: null; + } + + public static function normalizePhone(?string $phone): ?string + { + $phone = is_string($phone) ? trim($phone) : null; + + return $phone ? Utils::formatPhoneNumber($phone) : null; + } + + private static function matchedField(User $account, ?string $email): string + { + $email = static::normalizeEmail($email); + + return $email && strtolower((string) $account->email) === $email ? 'email' : 'phone'; + } + + private static function fieldLabel(string $field): string + { + return $field === 'phone' ? 'phone number' : 'email'; + } + + private static function takenMessage(string $field): string + { + return 'This ' . static::fieldLabel($field) . ' is already in use by another account.'; + } +} diff --git a/server/src/routes.php b/server/src/routes.php index 05a7967a0..631bccc92 100644 --- a/server/src/routes.php +++ b/server/src/routes.php @@ -444,6 +444,10 @@ function ($router, $controller) { $router->post('{id}/unassign-order', $controller('unassignOrder')); $router->post('{id}/assign-vehicle', $controller('assignVehicle')); $router->post('{id}/unassign-vehicle', $controller('unassignVehicle')); + $router->post('{id}/send-credentials', $controller('sendCredentials')); + $router->post('{id}/reset-credentials', $controller('resetCredentials')); + $router->post('{id}/deactivate-login', $controller('deactivateLogin')); + $router->post('{id}/reactivate-login', $controller('reactivateLogin')); $router->match(['get', 'post'], 'export', $controller('export')); $router->post('import', $controller('import')); // Driver scheduling endpoints @@ -764,6 +768,7 @@ function () use ($router) { function ($router) { $router->get('customers', 'FleetOpsLookupController@polymorphs'); $router->get('facilitators', 'FleetOpsLookupController@polymorphs'); + $router->get('profile-identity', 'FleetOpsLookupController@profileIdentity'); } ); $router->group( diff --git a/server/tests/ApiCustomerControllerContractsTest.php b/server/tests/ApiCustomerControllerContractsTest.php index ea1b7202b..49131be51 100644 --- a/server/tests/ApiCustomerControllerContractsTest.php +++ b/server/tests/ApiCustomerControllerContractsTest.php @@ -800,6 +800,27 @@ function fleetopsApiCustomerJson($response): array ]))))->toBe(['error' => 'Invalid verification code.']); }); +test('api customer controller refuses deactivated customer logins', function () { + $controller = fleetopsApiCustomerController(); + foreach (['activeUser', 'loginUser', 'verificationUser'] as $property) { + $controller->{$property}->setAttribute('status', 'inactive'); + } + + $responses = [ + $controller->login(Request::create('/v1/customers/login', 'POST', ['identity' => 'jane@example.test', 'password' => 'password-secret'])), + $controller->loginWithPhone(Request::create('/v1/customers/login/phone', 'POST', ['phone' => '15551234567'])), + $controller->verifyCode(Request::create('/v1/customers/verify', 'POST', ['identity' => 'jane@example.test', 'code' => '123456'])), + ]; + + foreach ($responses as $response) { + expect($response->getStatusCode())->toBe(403) + ->and(fleetopsApiCustomerJson($response))->toBe(['error' => 'This customer login has been deactivated.']); + } + + // No verification code is sent + expect($controller->smsVerifications)->toBe([]); +}); + test('api customer controller resets forgotten passwords', function () { $controller = fleetopsApiCustomerController(); $forgot = $controller->forgotPassword(Request::create('/v1/customers/forgot-password', 'POST', [ diff --git a/server/tests/ApiDriverControllerContractsTest.php b/server/tests/ApiDriverControllerContractsTest.php index fa5e4dfa7..d2743ca72 100644 --- a/server/tests/ApiDriverControllerContractsTest.php +++ b/server/tests/ApiDriverControllerContractsTest.php @@ -30,6 +30,8 @@ class FleetOpsApiDriverControllerProbe extends DriverController public array $deviceCreates = []; public array $companyCalls = []; public array $createdUsers = []; + public array $resolvedAccounts = []; + public ?Throwable $accountConflict = null; public array $uuidLookups = []; public array $relationLookups = []; public array $relationCompanyScopes = []; @@ -65,11 +67,17 @@ protected function applyUserInfoFromRequest(Request $request, array $userDetails return $userDetails; } - protected function createUser(array $userDetails): User + protected function resolveDriverAccount(string $companyUuid, array $userDetails): User { - $this->createdUsers[] = $userDetails; + $this->resolvedAccounts[] = $companyUuid; + $this->createdUsers[] = $userDetails; + + if ($this->accountConflict) { + throw $this->accountConflict; + } + $this->user ??= new FleetOpsApiDriverUserFake(); - $this->user->setRawAttributes(array_merge(['uuid' => 'user-uuid'], $userDetails), true); + $this->user->setRawAttributes(array_merge(['uuid' => 'user-uuid', 'type' => 'driver'], $userDetails), true); return $this->user; } @@ -282,6 +290,14 @@ public function update(array $attributes = [], array $options = []): bool return true; } + public function save(array $options = []): bool + { + $this->updates[] = $this->getDirty(); + $this->syncOriginal(); + + return true; + } + public function assignCompany(Company $company, string $role = 'Administrator'): User { $this->assignedCompanies[] = $company->uuid; @@ -317,6 +333,27 @@ public function setPasswordAttribute($password): void } } +/** + * ProfileAccountManager checks a changed email/phone against the users table. + */ +function fleetopsApiDriverContractsUsersTable(array $rows = []): void +{ + $connection = new Illuminate\Database\SQLiteConnection(new PDO('sqlite::memory:')); + $connection->getSchemaBuilder()->create('users', function ($table) { + $table->string('uuid')->nullable(); + $table->string('type')->nullable(); + $table->string('email')->nullable(); + $table->string('phone')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + if ($rows) { + $connection->table('users')->insert($rows); + } + $resolver = new Illuminate\Database\ConnectionResolver(['default' => $connection, 'mysql' => $connection]); + $resolver->setDefaultConnection('mysql'); + Illuminate\Database\Eloquent\Model::setConnectionResolver($resolver); +} + class FleetOpsApiDriverVehicleFake extends Vehicle { public array $quietUpdates = []; @@ -363,16 +400,15 @@ public function or(array $keys, mixed $default = null): mixed expect($response)->toBe(['resource' => 'driver', 'driver' => $controller->driver]) ->and($controller->companyCalls)->toBe([['request', 'company_public']]) + // The login account is resolved by ProfileAccountManager in the driver's company + ->and($controller->resolvedAccounts)->toBe(['company-uuid']) ->and($controller->createdUsers[0])->toMatchArray([ - 'name' => 'Driver One', - 'email' => 'driver@example.test', - 'phone' => '+15551234567', - 'company_uuid' => 'company-uuid', - 'applied' => true, + 'name' => 'Driver One', + 'email' => 'driver@example.test', + 'phone' => '+15551234567', + 'password' => 'secret-password', + 'applied' => true, ]) - ->and($controller->user->assignedCompanies)->toBe(['company-uuid']) - ->and($controller->user->assignedTypes)->toBe(['driver']) - ->and($controller->user->assignedRoles)->toBe(['Driver']) ->and($controller->createdDrivers[0])->toMatchArray([ 'status' => 'available', 'vehicle_uuid' => 'vehicle-uuid', @@ -407,8 +443,10 @@ public function or(array $keys, mixed $default = null): mixed }); test('api driver controller updates drivers user details assignments location and photo', function () { + fleetopsApiDriverContractsUsersTable(); + $user = new FleetOpsApiDriverUserFake(); - $user->setRawAttributes(['uuid' => 'user-uuid'], true); + $user->setRawAttributes(['uuid' => 'user-uuid', 'type' => 'driver'], true); $driver = new FleetOpsApiDriverFake(); $driver->setRawAttributes([ @@ -645,7 +683,7 @@ public function or(array $keys, mixed $default = null): mixed * and now has its own endpoint that demands the current password. */ $user = new FleetOpsApiDriverUserFake(); - $user->setRawAttributes(['uuid' => 'user-uuid'], true); + $user->setRawAttributes(['uuid' => 'user-uuid', 'type' => 'driver'], true); $driver = new FleetOpsApiDriverFake(); $driver->setRawAttributes([ 'uuid' => 'driver-uuid', @@ -759,11 +797,9 @@ public function or(array $keys, mixed $default = null): mixed 'company_uuid' => 'company-uuid', 'status' => 'available', ]) - // The Driver-to-User relationship, organization membership, user type - // and role are all preserved for a credential-less driver. - ->and($controller->user->assignedCompanies)->toBe(['company-uuid']) - ->and($controller->user->assignedTypes)->toBe(['driver']) - ->and($controller->user->assignedRoles)->toBe(['Driver']); + // The Driver-to-User relationship and organization membership are + // preserved for a credential-less driver. + ->and($controller->resolvedAccounts)->toBe(['company-uuid']); }); test('api driver controller creates a driver with only one contact method', function () { @@ -873,7 +909,7 @@ public function or(array $keys, mixed $default = null): mixed ])); $user = new FleetOpsApiDriverUserFake(); - $user->setRawAttributes(['uuid' => 'user-uuid'], true); + $user->setRawAttributes(['uuid' => 'user-uuid', 'type' => 'driver'], true); $driver = new FleetOpsApiDriverFake(); $driver->setRawAttributes(['uuid' => 'driver-uuid', 'public_id' => 'driver_public', 'user_uuid' => 'user-uuid'], true); @@ -905,3 +941,47 @@ public function or(array $keys, mixed $default = null): mixed // name is dropped rather than reaching Eloquent, where it would be a 500. expect($request->input('with'))->toBe(['vehicle', 'currentJob']); }); + +test('api driver controller answers 422 when the email or phone belongs to another account', function () { + $create = new FleetOpsApiDriverControllerProbe(); + $create->accountConflict = new Fleetbase\FleetOps\Exceptions\ProfileIdentityConflictException('This email is already in use by another account.'); + + expect($create->create(new CreateDriverRequest(['name' => 'Driver One', 'email' => 'taken@example.test'])))->toBe([ + 'json' => ['error' => 'This email is already in use by another account.'], + 'status' => 422, + ]) + ->and($create->createdDrivers)->toBe([]); + + fleetopsApiDriverContractsUsersTable([['uuid' => 'other-user', 'type' => 'user', 'phone' => '+15550009999']]); + + $user = new FleetOpsApiDriverUserFake(); + $user->setRawAttributes(['uuid' => 'user-uuid', 'type' => 'driver', 'phone' => '+15550001111'], true); + $driver = new FleetOpsApiDriverFake(); + $driver->setRawAttributes(['uuid' => 'driver-uuid', 'public_id' => 'driver_public', 'user_uuid' => 'user-uuid'], true); + $driver->userForTest = $user; + + $update = new FleetOpsApiDriverControllerProbe(); + $update->driver = $driver; + + expect($update->update('driver_public', new UpdateDriverRequest(['phone' => '+1 555 000 9999'])))->toBe([ + 'json' => ['error' => 'This phone number is already in use by another account.'], + 'status' => 422, + ]) + ->and($user->updates)->toBe([]) + ->and($driver->updates)->toBe([]); +}); + +test('api driver controller only renames a team member account linked to a driver', function () { + $staff = new FleetOpsApiDriverUserFake(); + $staff->setRawAttributes(['uuid' => 'staff-uuid', 'type' => 'user', 'name' => 'Staff', 'email' => 'staff@example.test'], true); + $driver = new FleetOpsApiDriverFake(); + $driver->setRawAttributes(['uuid' => 'driver-uuid', 'public_id' => 'driver_public', 'user_uuid' => 'staff-uuid'], true); + $driver->userForTest = $staff; + + $update = new FleetOpsApiDriverControllerProbe(); + $update->driver = $driver; + $update->update('driver_public', new UpdateDriverRequest(['name' => 'Staff Driver', 'email' => 'changed@example.test'])); + + expect($staff->updates)->toBe([['name' => 'Staff Driver']]) + ->and($staff->email)->toBe('staff@example.test'); +}); diff --git a/server/tests/ControllerHelperContractsTest.php b/server/tests/ControllerHelperContractsTest.php index aefd4ed75..cc0cbcdaf 100644 --- a/server/tests/ControllerHelperContractsTest.php +++ b/server/tests/ControllerHelperContractsTest.php @@ -254,7 +254,6 @@ public function callHelper(string $method, mixed ...$arguments): mixed class FleetOpsInternalContactControllerProbe extends InternalContactController { public array $installedPackages = []; - public array $resolvedUsers = []; public array $sentCredentials = []; public array $savedMetas = []; public ?User $contactUser = null; @@ -269,13 +268,6 @@ public function callHelper(string $method, mixed ...$arguments): mixed return $reflection->invoke($this, ...$arguments); } - protected function resolveUserUuid(string $user): string - { - $this->resolvedUsers[] = $user; - - return 'resolved-' . $user; - } - protected function installedFleetbaseExtensions(): array { return $this->installedPackages; @@ -1607,7 +1599,7 @@ function fleetopsControllerStaticMethod(string $class, string $method): Reflecti ], ], ]); - $input = ['type' => 'contact', 'user' => ['id' => 'user_public']]; + $input = ['type' => 'contact', 'user' => ['id' => 'user_public'], 'user_uuid' => 'user-uuid']; $controller->onBeforeCreate($request, $input); @@ -1623,8 +1615,8 @@ function fleetopsControllerStaticMethod(string $class, string $method): Reflecti $controller->afterSave($request, $contact); - expect($input)->toBe(['type' => 'contact', 'user_uuid' => 'resolved-user_public']) - ->and($controller->resolvedUsers)->toBe(['user_public']) + // The login account is managed by the profile, so a user can't be picked from the console + expect($input)->toBe(['type' => 'contact']) ->and($contact->normalized)->toBeTrue() ->and($contact->syncedCustomFields)->toBe([ ['key' => 'tier', 'value' => 'gold'], diff --git a/server/tests/Feature/Http/Api/DriverControllerAuthFlowsTest.php b/server/tests/Feature/Http/Api/DriverControllerAuthFlowsTest.php index 6168cf7e9..d2bf127af 100644 --- a/server/tests/Feature/Http/Api/DriverControllerAuthFlowsTest.php +++ b/server/tests/Feature/Http/Api/DriverControllerAuthFlowsTest.php @@ -230,6 +230,42 @@ public function __call($method, $arguments) ->and($connection->table('personal_access_tokens')->value('name'))->toBe('driver-1'); }); +test('deactivated driver logins are refused on every sign-in path', function () { + $connection = fleetopsDriverAuthBoot(); + $controller = new DriverController(); + $connection->table('users')->where('uuid', 'user-1')->update(['status' => 'inactive']); + app('hash')->checks = true; + + // An unknown identity fails authentication rather than erroring on a null user + $unknown = $controller->login(Request::create('/x', 'POST', ['identity' => 'ghost@example.test', 'password' => 'secret'])); + expect($unknown->getStatusCode())->toBe(401); + + $password = $controller->login(Request::create('/x', 'POST', ['identity' => 'driver@example.test', 'password' => 'secret'])); + + app()->instance('request', Request::create('/x', 'POST', ['phone' => '6591234567'])); + $phone = $controller->loginWithPhone(); + + $connection->table('verification_codes')->insert(['uuid' => 'vc-1', 'subject_uuid' => 'user-1', 'code' => '424242', 'for' => 'driver_login']); + $code = $controller->verifyCode(Request::create('/x', 'POST', ['identity' => '6591234567', 'code' => '424242'])); + + foreach ([$password, $phone, $code] as $response) { + expect($response->getStatusCode())->toBe(403) + ->and($response->getData(true)['error'])->toBe(DriverController::DEACTIVATED_LOGIN_MESSAGE); + } + + // Password recovery answers as if the identity were unknown + $forgot = $controller->forgotPassword(Request::create('/x', 'POST', ['identity' => 'driver@example.test'])); + $reset = $controller->resetPassword(Request::create('/x', 'POST', ['identity' => 'driver@example.test', 'code' => '424242', 'password' => 'new-secret-123'])); + + expect($forgot->getData(true))->toBe(['status' => 'ok']) + ->and($reset->getStatusCode())->toBe(422) + ->and($reset->getData(true)['error'])->toBe('Invalid or expired reset code.'); + + expect(DriverController::isLoginDeactivated(User::where('uuid', 'user-1')->first()))->toBeTrue() + ->and($connection->table('personal_access_tokens')->count())->toBe(0) + ->and($connection->table('verification_codes')->count())->toBe(1); +}); + test('phone login falls back to email verification and errors without channels', function () { $connection = fleetopsDriverAuthBoot(); $controller = new DriverController(); diff --git a/server/tests/Feature/Http/Api/DriverControllerSessionHelpersTest.php b/server/tests/Feature/Http/Api/DriverControllerSessionHelpersTest.php index d7bfbfbe5..5a7fdcae9 100644 --- a/server/tests/Feature/Http/Api/DriverControllerSessionHelpersTest.php +++ b/server/tests/Feature/Http/Api/DriverControllerSessionHelpersTest.php @@ -106,7 +106,7 @@ public function needsRehash($hashedValue, array $options = []): bool $schema = $connection->getSchemaBuilder(); app()->instance('db.schema', $schema); $tables = [ - 'users' => ['uuid', 'public_id', 'company_uuid', 'name', 'email', 'phone', 'status', 'type', 'username', 'password'], + 'users' => ['uuid', 'public_id', 'company_uuid', 'name', 'email', 'phone', 'status', 'type', 'username', 'password', 'timezone', 'slug', 'meta'], 'drivers' => ['uuid', 'public_id', 'internal_id', 'company_uuid', 'user_uuid', 'vehicle_uuid', 'vendor_uuid', 'status', 'online', 'location', 'meta'], 'companies' => ['uuid', 'public_id', 'name', 'status', 'owner_uuid'], 'user_devices' => ['uuid', 'public_id', 'user_uuid', 'token', 'platform', 'status'], @@ -189,22 +189,35 @@ public function parameters() $userDetails = $probe->callHelper('applyUserInfoFromRequest', Request::create('/x', 'POST', []), ['name' => 'Applicant']); expect($userDetails['name'])->toBe('Applicant'); - $user = $probe->callHelper('createUser', ['name' => 'New User']); + // A company that isn't in the table skips the organization membership seam, + // which needs spatie roles; the managed account itself is still created. + $missingCompany = '33333333-3333-4333-8333-333333333333'; + + $user = $probe->callHelper('resolveDriverAccount', $missingCompany, ['name' => 'New User']); expect($user)->toBeInstanceOf(User::class) - ->and($connection->table('users')->where('name', 'New User')->count())->toBe(1); + ->and($connection->table('users')->where('name', 'New User')->count())->toBe(1) + ->and($connection->table('users')->where('name', 'New User')->value('type'))->toBe('driver') + ->and($connection->table('users')->where('name', 'New User')->value('status'))->toBe('active'); /* * `password` is guarded on User, so mass assignment drops it without a * word. The create endpoint has always documented and validated one, so - * unless the helper sets it explicitly a driver created through the API - * can never sign in with the password chosen for them. + * unless it is set explicitly a driver created through the API can never + * sign in with the password chosen for them. */ - $withPassword = $probe->callHelper('createUser', ['name' => 'Password User', 'password' => 'seeded-password']); + $withPassword = $probe->callHelper('resolveDriverAccount', $missingCompany, ['name' => 'Password User', 'password' => 'seeded-password']); $stored = $connection->table('users')->where('name', 'Password User')->value('password'); expect(Hash::check('seeded-password', $withPassword->password))->toBeTrue() ->and($stored)->not->toBeNull() ->and($stored)->not->toBe('seeded-password'); + // A team member of the company with the email is linked instead of duplicated + $connection->table('users')->insert(['uuid' => 'staff-1', 'company_uuid' => '22222222-2222-4222-8222-222222222222', 'name' => 'Staff', 'email' => 'staff@example.test', 'type' => 'user']); + $linked = $probe->callHelper('resolveDriverAccount', '22222222-2222-4222-8222-222222222222', ['name' => 'Staff Driver', 'email' => 'STAFF@example.test']); + expect($linked->uuid)->toBe('staff-1') + ->and($connection->table('users')->where('email', 'staff@example.test')->count())->toBe(1) + ->and($connection->table('users')->where('uuid', 'staff-1')->value('type'))->toBe('user'); + $driver = $probe->callHelper('createDriver', ['company_uuid' => '22222222-2222-4222-8222-222222222222', 'user_uuid' => 'user-1']); expect($driver)->toBeInstanceOf(Driver::class); diff --git a/server/tests/Feature/Http/Api/ProfileOnlyUserConversionMigrationTest.php b/server/tests/Feature/Http/Api/ProfileOnlyUserConversionMigrationTest.php new file mode 100644 index 000000000..9a69a6196 --- /dev/null +++ b/server/tests/Feature/Http/Api/ProfileOnlyUserConversionMigrationTest.php @@ -0,0 +1,220 @@ + $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 table($table, $as = null) + { + return $this->c->table($table, $as); + } + + public function __call($method, $arguments) + { + return $this->c->{$method}(...$arguments); + } + }); + app()->instance('db.schema', $connection->getSchemaBuilder()); + DB::clearResolvedInstance('db'); + Schema::clearResolvedInstance('db.schema'); + + $GLOBALS['fleetopsProfileConversionLogs'] = []; + app()->instance('log', new class { + public function info($message, array $context = []): void + { + $GLOBALS['fleetopsProfileConversionLogs'][] = [$message, $context]; + } + + public function __call($method, $arguments) + { + return null; + } + }); + Log::clearResolvedInstance('log'); + + $schema = $connection->getSchemaBuilder(); + $tables = [ + 'users' => ['uuid', 'type', 'meta'], + 'drivers' => ['uuid', 'user_uuid'], + 'contacts' => ['uuid', 'user_uuid', 'type'], + 'companies' => ['uuid', 'owner_uuid'], + 'company_users' => ['uuid', 'user_uuid', 'company_uuid'], + 'roles' => ['name'], + 'model_has_roles' => ['role_id', 'model_uuid'], + 'model_has_permissions' => ['permission_id', 'model_uuid'], + ]; + if ($withPolicies) { + $tables['model_has_policies'] = ['policy_id', 'model_uuid']; + } + foreach ($tables as $table => $columns) { + $schema->create($table, function ($blueprint) use ($columns) { + $blueprint->increments('id'); + foreach ($columns as $column) { + $blueprint->string($column)->nullable(); + } + $blueprint->timestamp('deleted_at')->nullable(); + }); + } + + foreach (['Driver', 'Fleet-Ops Customer', 'Administrator'] as $role) { + $connection->table('roles')->insert(['name' => $role]); + } + + return $connection; +} + +function fleetopsProfileConversionMigration(): object +{ + return require dirname(__DIR__, 4) . '/migrations/2026_09_21_000001_convert_profile_only_users_to_managed_accounts.php'; +} + +/** + * Seed a user holding the given profile(s), roles on its company membership + * (or on the user itself), and optional direct permission or policy. + */ +function fleetopsProfileConversionUser(SQLiteConnection $connection, string $uuid, array $options = []): void +{ + $connection->table('users')->insert(['uuid' => $uuid, 'type' => $options['type'] ?? 'user', 'meta' => $options['meta'] ?? null, 'deleted_at' => $options['deleted_at'] ?? null]); + $connection->table('company_users')->insert(['uuid' => 'cu-' . $uuid, 'user_uuid' => $uuid, 'company_uuid' => 'company-1']); + + if ($options['driver'] ?? false) { + $connection->table('drivers')->insert(['uuid' => 'driver-' . $uuid, 'user_uuid' => $uuid]); + } + + if ($options['customer'] ?? false) { + $connection->table('contacts')->insert(['uuid' => 'contact-' . $uuid, 'user_uuid' => $uuid, 'type' => 'customer']); + } + + foreach ($options['roles'] ?? [] as $role) { + $roleId = $connection->table('roles')->where('name', $role)->value('id'); + $connection->table('model_has_roles')->insert(['role_id' => $roleId, 'model_uuid' => ($options['role_on_user'] ?? false) ? $uuid : 'cu-' . $uuid]); + } + + if ($options['permission'] ?? false) { + $connection->table('model_has_permissions')->insert(['permission_id' => 1, 'model_uuid' => 'cu-' . $uuid]); + } + + if ($options['policy'] ?? false) { + $connection->table('model_has_policies')->insert(['policy_id' => 1, 'model_uuid' => $uuid]); + } +} + +function fleetopsProfileConversionTypes(SQLiteConnection $connection): array +{ + return $connection->table('users')->orderBy('uuid')->pluck('type', 'uuid')->all(); +} + +test('up converts profile-only accounts and leaves anything that looks like a team member', function () { + $connection = fleetopsProfileConversionBoot(); + + fleetopsProfileConversionUser($connection, 'a-driver', ['driver' => true, 'roles' => ['Driver'], 'meta' => json_encode(['source' => 'console'])]); + fleetopsProfileConversionUser($connection, 'b-customer', ['customer' => true, 'roles' => ['Fleet-Ops Customer'], 'role_on_user' => true]); + fleetopsProfileConversionUser($connection, 'c-owner', ['driver' => true, 'roles' => ['Driver']]); + $connection->table('companies')->insert(['uuid' => 'company-1', 'owner_uuid' => 'c-owner']); + fleetopsProfileConversionUser($connection, 'd-extra-role', ['driver' => true, 'roles' => ['Driver', 'Administrator']]); + fleetopsProfileConversionUser($connection, 'e-permission', ['driver' => true, 'roles' => ['Driver'], 'permission' => true]); + fleetopsProfileConversionUser($connection, 'f-policy', ['customer' => true, 'roles' => ['Fleet-Ops Customer'], 'policy' => true]); + fleetopsProfileConversionUser($connection, 'g-both-profiles', ['driver' => true, 'customer' => true, 'roles' => ['Driver']]); + fleetopsProfileConversionUser($connection, 'h-no-roles', ['driver' => true]); + fleetopsProfileConversionUser($connection, 'i-wrong-role', ['customer' => true, 'roles' => ['Driver']]); + fleetopsProfileConversionUser($connection, 'j-no-profile', ['roles' => ['Driver']]); + fleetopsProfileConversionUser($connection, 'k-admin', ['type' => 'admin', 'driver' => true, 'roles' => ['Driver']]); + fleetopsProfileConversionUser($connection, 'l-deleted', ['driver' => true, 'roles' => ['Driver'], 'deleted_at' => '2026-01-01 00:00:00']); + fleetopsProfileConversionUser($connection, 'm-bad-meta', ['driver' => true, 'roles' => ['Driver'], 'meta' => 'not-json']); + + fleetopsProfileConversionMigration()->up(); + + expect(fleetopsProfileConversionTypes($connection))->toBe([ + 'a-driver' => 'driver', + 'b-customer' => 'customer', + 'c-owner' => 'user', + 'd-extra-role' => 'user', + 'e-permission' => 'user', + 'f-policy' => 'user', + 'g-both-profiles' => 'user', + 'h-no-roles' => 'user', + 'i-wrong-role' => 'user', + 'j-no-profile' => 'user', + 'k-admin' => 'admin', + 'l-deleted' => 'user', + 'm-bad-meta' => 'driver', + ]) + // The previous type is recorded next to any existing meta + ->and(json_decode($connection->table('users')->where('uuid', 'a-driver')->value('meta'), true))->toBe(['source' => 'console', 'previous_type' => 'user']) + ->and(json_decode($connection->table('users')->where('uuid', 'm-bad-meta')->value('meta'), true))->toBe(['previous_type' => 'user']) + ->and($GLOBALS['fleetopsProfileConversionLogs'])->toBe([ + ['[fleetops] Converted profile-only user accounts to managed accounts.', ['driver' => 2, 'customer' => 1]], + ]); +}); + +test('down reverts only the accounts up converted', function () { + $connection = fleetopsProfileConversionBoot(); + + fleetopsProfileConversionUser($connection, 'a-driver', ['driver' => true, 'roles' => ['Driver'], 'meta' => json_encode(['source' => 'console'])]); + fleetopsProfileConversionUser($connection, 'b-customer', ['customer' => true, 'roles' => ['Fleet-Ops Customer']]); + // Managed accounts created after the change carry no previous type + fleetopsProfileConversionUser($connection, 'c-native-driver', ['type' => 'driver', 'driver' => true, 'meta' => json_encode(['source' => 'app'])]); + // A previous type other than `user` is not this migration's + fleetopsProfileConversionUser($connection, 'd-other-previous', ['type' => 'customer', 'customer' => true, 'meta' => json_encode(['previous_type' => 'contact'])]); + + $migration = fleetopsProfileConversionMigration(); + $migration->up(); + $migration->down(); + + expect(fleetopsProfileConversionTypes($connection))->toBe([ + 'a-driver' => 'user', + 'b-customer' => 'user', + 'c-native-driver' => 'driver', + 'd-other-previous' => 'customer', + ]) + ->and(json_decode($connection->table('users')->where('uuid', 'a-driver')->value('meta'), true))->toBe(['source' => 'console']) + ->and(json_decode($connection->table('users')->where('uuid', 'b-customer')->value('meta'), true))->toBe([]) + ->and(json_decode($connection->table('users')->where('uuid', 'd-other-previous')->value('meta'), true))->toBe(['previous_type' => 'contact']); +}); + +test('up and down do nothing when the tables they read are missing', function () { + $connection = fleetopsProfileConversionBoot(false); + fleetopsProfileConversionUser($connection, 'a-driver', ['driver' => true, 'roles' => ['Driver']]); + + $migration = fleetopsProfileConversionMigration(); + $migration->up(); + + expect(fleetopsProfileConversionTypes($connection))->toBe(['a-driver' => 'user']) + ->and($GLOBALS['fleetopsProfileConversionLogs'])->toBe([]); + + $connection->table('users')->where('uuid', 'a-driver')->update(['type' => 'driver', 'meta' => json_encode(['previous_type' => 'user'])]); + $connection->getSchemaBuilder()->drop('users'); + + // Without a users table there is nothing to revert + $migration->down(); + + expect($connection->getSchemaBuilder()->hasTable('users'))->toBeFalse(); +}); diff --git a/server/tests/Feature/Http/Internal/ContactControllerEndpointsTest.php b/server/tests/Feature/Http/Internal/ContactControllerEndpointsTest.php index 25a114c67..4b4e81925 100644 --- a/server/tests/Feature/Http/Internal/ContactControllerEndpointsTest.php +++ b/server/tests/Feature/Http/Internal/ContactControllerEndpointsTest.php @@ -252,10 +252,12 @@ public function __call($method, $arguments) $probe->callProtected('assertContactInputIsValid', [$request, &$input, $contact]); })->toThrow(Exception::class); - // User references resolve through uuid or public id - expect($probe->callProtected('resolveUserUuid', ['user_contactseam']))->toBe('88888888-8888-4888-8888-888888888881') - ->and($probe->callProtected('resolveUserUuid', ['88888888-8888-4888-8888-888888888881']))->toBe('88888888-8888-4888-8888-888888888881') - ->and($probe->callProtected('resolveUserUuid', ['unknown-user']))->toBe('unknown-user'); + // The login account is managed by the profile, so user references are dropped + $userInput = ['name' => 'Seam Contact', 'user_uuid' => '88888888-8888-4888-8888-888888888881', 'user' => 'user_contactseam']; + Closure::bind(function (array &$input) { + $this->resolveUserInput(Request::create('/x', 'PUT', []), $input); + }, $probe, ContactController::class)($userInput); + expect($userInput)->toBe(['name' => 'Seam Contact']); // Portal seams read users, mint passwords, and persist meta quietly expect($probe->callProtected('contactUser', [$contact])?->uuid)->toBe('88888888-8888-4888-8888-888888888881') diff --git a/server/tests/Feature/Http/Internal/CustomerControllerCredentialsTest.php b/server/tests/Feature/Http/Internal/CustomerControllerCredentialsTest.php index a71caed4a..82dc5f916 100644 --- a/server/tests/Feature/Http/Internal/CustomerControllerCredentialsTest.php +++ b/server/tests/Feature/Http/Internal/CustomerControllerCredentialsTest.php @@ -125,7 +125,9 @@ public function __call($method, $arguments) 'contacts' => ['uuid', 'public_id', 'company_uuid', 'user_uuid', 'name', 'email', 'phone', 'type', 'title', 'meta', 'photo_uuid', 'place_uuid', 'slug', '_key'], 'users' => ['uuid', 'public_id', 'company_uuid', 'name', 'email', 'phone', 'password', 'type', 'status', 'timezone', 'slug', 'username', 'avatar_uuid', 'meta', '_key'], 'companies' => ['uuid', 'public_id', 'name', 'country'], - 'files' => ['uuid', 'public_id', 'company_uuid', 'subject_uuid', 'subject_type', 'name', 'path', 'disk', 'type', '_key'], + // session_status reads the user's membership of the session company + 'company_users' => ['uuid', 'company_uuid', 'user_uuid', 'status'], + 'files' => ['uuid', 'public_id', 'company_uuid', 'subject_uuid', 'subject_type', 'name', 'path', 'disk', 'type', '_key'], ]; foreach ($tables as $table => $columns) { $schema->create($table, function ($blueprint) use ($columns) { @@ -192,5 +194,7 @@ public function __call($method, $arguments) $payload = $probe->callHelper('customerPayload', $customer); expect($payload['uuid'])->toBe('55555555-5555-4555-8555-555555555555') - ->and($payload)->toHaveKey('user'); + ->and($payload['user']['uuid'])->toBe('user-1') + ->and($payload['is_staff_linked'])->toBeFalse() + ->and($payload['login_status'])->toBe('active'); }); diff --git a/server/tests/Feature/Http/Internal/DriverControllerContractsTest.php b/server/tests/Feature/Http/Internal/DriverControllerContractsTest.php index 0d9cc86da..b502938bc 100644 --- a/server/tests/Feature/Http/Internal/DriverControllerContractsTest.php +++ b/server/tests/Feature/Http/Internal/DriverControllerContractsTest.php @@ -1131,6 +1131,29 @@ public function getCurrentOrder(): ?Order ]); }); +test('internal driver controller refuses phone login and code verification for deactivated drivers', function () { + FleetOpsInternalDriverAuthControllerProbe::resetProbe(); + app()->instance('request', Request::create('/', 'POST', ['phone' => '15551234567'])); + + $deactivated = fleetopsInternalDriverAuthUser(); + $deactivated->setAttribute('status', 'inactive'); + FleetOpsInternalDriverAuthControllerProbe::$loginUser = $deactivated; + FleetOpsInternalDriverAuthControllerProbe::$verificationUser = $deactivated; + FleetOpsInternalDriverAuthControllerProbe::$verificationExists = true; + + $controller = new FleetOpsInternalDriverAuthControllerProbe(); + $login = $controller->loginWithPhone(); + $verify = $controller->verifyCode(new Request(['identity' => '+15551234567', 'code' => '111111'])); + + expect($login->getStatusCode())->toBe(403) + ->and($login->getData(true))->toBe(['error' => 'This driver login has been deactivated.']) + ->and($verify->getStatusCode())->toBe(403) + ->and($verify->getData(true))->toBe(['error' => 'This driver login has been deactivated.']) + // No code is sent and no token is issued + ->and(FleetOpsInternalDriverAuthControllerProbe::$verifications)->toBe([]) + ->and(FleetOpsInternalDriverAuthControllerProbe::$tokens)->toBe([]); +}); + test('internal driver controller delegates login and create driver verification flows to api controller', function () { $request = new Request(['for' => 'create_driver']); diff --git a/server/tests/Feature/Http/Internal/DriverControllerCreateRecordTest.php b/server/tests/Feature/Http/Internal/DriverControllerCreateRecordTest.php index 141ce1e44..f527fe0fb 100644 --- a/server/tests/Feature/Http/Internal/DriverControllerCreateRecordTest.php +++ b/server/tests/Feature/Http/Internal/DriverControllerCreateRecordTest.php @@ -10,11 +10,12 @@ /** * Covers the internal DriverController createRecord and updateRecord flows * against an in-memory SQLite fixture. The creation closure executes end to - * end: company resolution, user creation with driver typing, existing-user - * adoption and conflict detection, organization membership checks, and the - * exception-to-error-response branches. Role assignment requires the full - * spatie permission boot unavailable in the harness, so flows settle in the - * documented error branch after the user record is persisted. + * end: company resolution, managed account creation with driver typing + * through ProfileAccountManager, and the exception-to-error-response + * branches. Role assignment requires the full spatie permission boot, which + * this fixture leaves out, so flows settle in the documented error branch + * after the user record is persisted (see + * DriverControllerExistingUserAdoptionTest for the full flow). */ if (!function_exists('Fleetbase\Support\session')) { eval('namespace Fleetbase\Support; function session($key = null, $default = null) { if ($key === null) { return new class { public function has($k) { return \session($k) !== null; } public function get($k, $d = null) { return \session($k, $d); } }; } return \session($key, $default); }'); @@ -101,6 +102,28 @@ public function validated() } }); Illuminate\Support\Facades\DB::clearResolvedInstance('db'); + app()->instance('hash', new class implements Illuminate\Contracts\Hashing\Hasher { + public function info($hashedValue): array + { + return []; + } + + public function make($value, array $options = []): string + { + return 'hashed:' . $value; + } + + public function check($value, $hashedValue, array $options = []): bool + { + return 'hashed:' . $value === $hashedValue; + } + + public function needsRehash($hashedValue, array $options = []): bool + { + return false; + } + }); + Illuminate\Support\Facades\Hash::clearResolvedInstance('hash'); $schema = $connection->getSchemaBuilder(); $tables = [ @@ -158,31 +181,33 @@ function fleetopsInternalDriverCreateRequest(array $driver): Request ->and($connection->table('company_users')->count())->toBeGreaterThanOrEqual(0); }); -test('create record rejects users that already belong to a driver', function () { +test('create record ignores a picked user account and resolves the login from the email', function () { $connection = fleetopsInternalDriverCreateBoot(); - $connection->table('users')->insert(['uuid' => '11111111-1111-4111-8111-111111111111', 'company_uuid' => 'company-1', 'name' => 'Existing', 'type' => 'driver']); - $connection->table('drivers')->insert(['uuid' => 'driver-1', 'company_uuid' => 'company-1', 'user_uuid' => '11111111-1111-4111-8111-111111111111']); + $connection->table('users')->insert(['uuid' => '11111111-1111-4111-8111-111111111111', 'company_uuid' => 'company-1', 'name' => 'Existing', 'email' => 'existing@example.com', 'type' => 'driver']); - $result = (new DriverController())->createRecord(fleetopsInternalDriverCreateRequest([ - 'name' => 'Existing', + (new DriverController())->createRecord(fleetopsInternalDriverCreateRequest([ + 'name' => 'Someone Else', + 'email' => 'someone@example.com', 'user_uuid' => '11111111-1111-4111-8111-111111111111', ])); - expect($result)->toBeInstanceOf(JsonResponse::class) - ->and($result->getData(true))->toBe(['error' => 'This user account already belongs to a driver.']); + // The login account is managed by the profile: a new driver account is + // created for the email rather than the picked account being taken over + expect($connection->table('users')->count())->toBe(2) + ->and($connection->table('users')->where('email', 'someone@example.com')->value('type'))->toBe('driver') + ->and($connection->table('users')->where('uuid', '11111111-1111-4111-8111-111111111111')->value('name'))->toBe('Existing'); }); -test('create record adopts an existing user and applies photo avatars', function () { +test('create record applies photo avatars to the new driver account', function () { $connection = fleetopsInternalDriverCreateBoot(); - $connection->table('users')->insert(['uuid' => '11111111-1111-4111-8111-111111111111', 'company_uuid' => 'company-1', 'name' => 'Adoptable', 'type' => 'driver']); $result = (new DriverController())->createRecord(fleetopsInternalDriverCreateRequest([ - 'name' => 'Adoptable', - 'user_uuid' => '11111111-1111-4111-8111-111111111111', + 'name' => 'Photo Driver', 'photo_uuid' => '22222222-2222-4222-8222-222222222222', ])); - // Avatar update applies before the harness role-assignment limitation. + // Avatar is set when the account is created, before the harness + // role-assignment limitation. expect($connection->table('users')->value('avatar_uuid'))->toBe('22222222-2222-4222-8222-222222222222') ->and($result)->toBeInstanceOf(JsonResponse::class) ->and($result->getData(true))->toHaveKey('error'); diff --git a/server/tests/Feature/Http/Internal/DriverControllerExistingUserAdoptionTest.php b/server/tests/Feature/Http/Internal/DriverControllerExistingUserAdoptionTest.php index 09b76933b..b8b8bfdb4 100644 --- a/server/tests/Feature/Http/Internal/DriverControllerExistingUserAdoptionTest.php +++ b/server/tests/Feature/Http/Internal/DriverControllerExistingUserAdoptionTest.php @@ -25,12 +25,13 @@ use Illuminate\Support\Str; /** - * Covers the internal DriverController createRecord unique-conflict branch - * against SQLite with a failing validator: adopting an existing - * organization member by creating a driver profile, returning the existing - * driver profile when one already exists, assigning non-members to the - * company, and falling through to the validation error response when the - * conflict is not a phone or email collision. + * Covers how the internal DriverController createRecord resolves the new + * driver's login account through ProfileAccountManager against SQLite with + * real spatie roles: creating a managed driver account without an invite, + * linking a staff member of the organization without touching their role, + * linking a managed driver account of another organization, rejecting an + * email/phone held by another organization's staff, another profile type or + * an existing driver of the organization, and returning validation failures. */ if (!function_exists('Fleetbase\Observers\event')) { eval('namespace Fleetbase\Observers; function event($event = null, $payload = []) { return []; }'); @@ -212,6 +213,28 @@ public function getMessageBag() } }); Illuminate\Support\Facades\DB::clearResolvedInstance('db'); + app()->instance('hash', new class implements Illuminate\Contracts\Hashing\Hasher { + public function info($hashedValue): array + { + return []; + } + + public function make($value, array $options = []): string + { + return 'hashed:' . $value; + } + + public function check($value, $hashedValue, array $options = []): bool + { + return 'hashed:' . $value === $hashedValue; + } + + public function needsRehash($hashedValue, array $options = []): bool + { + return false; + } + }); + Illuminate\Support\Facades\Hash::clearResolvedInstance('hash'); $schema = $connection->getSchemaBuilder(); $tables = [ @@ -327,66 +350,121 @@ function fleetopsDriverAdoptionRequest(array $driver): Request return Request::create('/int/v1/drivers', 'POST', ['driver' => $driver]); } -test('phone conflicts adopt existing organization members as drivers', function () { - $connection = fleetopsDriverAdoptionBoot(['phone' => ['The phone has already been taken.']]); - $connection->table('users')->insert(['uuid' => '11111111-1111-4111-8111-111111111111', 'company_uuid' => 'company-1', 'name' => 'Member', 'phone' => '+6591234567', 'slug' => 'member', 'type' => 'driver']); - $connection->table('company_users')->insert(['uuid' => 'cu-1', 'company_uuid' => 'company-1', 'user_uuid' => '11111111-1111-4111-8111-111111111111']); +function fleetopsDriverAdoptionRoles(SQLiteConnection $connection, string $userUuid): array +{ + return $connection->table('model_has_roles') + ->join('roles', 'roles.id', '=', 'model_has_roles.role_id') + ->join('company_users', 'company_users.uuid', '=', 'model_has_roles.model_uuid') + ->where('company_users.user_uuid', $userUuid) + ->pluck('roles.name') + ->unique() + ->values() + ->all(); +} + +test('a new driver gets a managed driver account added to the company without an invite', function () { + $connection = fleetopsDriverAdoptionBoot([]); $result = (new DriverController())->createRecord(fleetopsDriverAdoptionRequest([ - 'name' => 'Member', - 'phone' => '+6591234567', + 'name' => 'New Driver', + 'email' => 'New.Driver@Example.com', + 'phone' => '+65 9123-0000', + 'password' => 'chosen-secret', ])); + $user = $connection->table('users')->first(); + expect($result)->toBeArray() - ->and($result['driver']->resource->user_uuid)->toBe('11111111-1111-4111-8111-111111111111') - ->and($connection->table('drivers')->count())->toBe(1) - ->and($connection->table('drivers')->value('company_uuid'))->toBe('company-1'); + ->and($result['driver']->resource->user_uuid)->toBe($user->uuid) + ->and($user->type)->toBe('driver') + ->and($user->status)->toBe('active') + ->and($user->email)->toBe('new.driver@example.com') + ->and($user->phone)->toBe('+6591230000') + ->and($user->password)->toBe('hashed:chosen-secret') + ->and($connection->table('company_users')->where(['company_uuid' => 'company-1', 'user_uuid' => $user->uuid])->count())->toBe(1) + ->and(fleetopsDriverAdoptionRoles($connection, $user->uuid))->toBe(['Driver']) + ->and($connection->table('invites')->count())->toBe(0); }); -test('phone conflicts assign non members to the session company', function () { - $connection = fleetopsDriverAdoptionBoot(['phone' => ['The phone has already been taken.']]); - - // Same conflict as above, but with no company_users row the user is not yet - // an organization member, so adoption must also attach them to the company - $connection->table('users')->insert(['uuid' => '11111111-1111-4111-8111-111111111112', 'name' => 'Outsider', 'phone' => '+6591234568', 'slug' => 'outsider', 'type' => 'driver']); - - expect($connection->table('company_users')->count())->toBe(0); +test('a staff member of the organization is linked without changing their account or role', function () { + $connection = fleetopsDriverAdoptionBoot([]); + $connection->table('users')->insert(['uuid' => '11111111-1111-4111-8111-111111111111', 'company_uuid' => 'company-1', 'name' => 'Staff Member', 'email' => 'staff@example.com', 'phone' => '+6590001111', 'slug' => 'staff', 'type' => 'user']); $result = (new DriverController())->createRecord(fleetopsDriverAdoptionRequest([ - 'name' => 'Outsider', - 'phone' => '+6591234568', + 'name' => 'Different Name', + 'email' => 'staff@example.com', + 'phone' => '+6599998888', ])); + $staff = $connection->table('users')->where('uuid', '11111111-1111-4111-8111-111111111111')->first(); + expect($result)->toBeArray() - ->and($result['driver']->resource->user_uuid)->toBe('11111111-1111-4111-8111-111111111112') - ->and($connection->table('drivers')->count())->toBe(1) - // the assignCompany branch ran: the user is now a member of company-1 - ->and($connection->table('company_users')->where([ - 'company_uuid' => 'company-1', - 'user_uuid' => '11111111-1111-4111-8111-111111111112', - ])->count())->toBe(1); + ->and($result['driver']->resource->user_uuid)->toBe('11111111-1111-4111-8111-111111111111') + ->and($connection->table('users')->count())->toBe(1) + ->and($staff->type)->toBe('user') + ->and($staff->phone)->toBe('+6590001111') + ->and($staff->name)->toBe('Staff Member') + ->and($connection->table('model_has_roles')->count())->toBe(0) + ->and($connection->table('drivers')->value('company_uuid'))->toBe('company-1'); }); -test('email conflicts return the existing driver profile when present', function () { - $connection = fleetopsDriverAdoptionBoot(['email' => ['The email has already been taken.']]); - $connection->table('users')->insert(['uuid' => '11111111-1111-4111-8111-111111111111', 'company_uuid' => 'company-1', 'name' => 'Member', 'email' => 'member@example.com', 'type' => 'driver']); - $connection->table('drivers')->insert(['uuid' => 'driver-1', 'company_uuid' => 'company-1', 'user_uuid' => '11111111-1111-4111-8111-111111111111']); +test('a managed driver account of another organization is linked and joins the company', function () { + $connection = fleetopsDriverAdoptionBoot([]); + $connection->table('users')->insert(['uuid' => '11111111-1111-4111-8111-111111111112', 'company_uuid' => 'company-2', 'name' => 'Shared Driver', 'phone' => '+6591234568', 'slug' => 'shared', 'type' => 'driver']); + $connection->table('drivers')->insert(['uuid' => 'driver-elsewhere', 'company_uuid' => 'company-2', 'user_uuid' => '11111111-1111-4111-8111-111111111112']); $result = (new DriverController())->createRecord(fleetopsDriverAdoptionRequest([ - 'name' => 'Member', - 'email' => 'member@example.com', + 'name' => 'Shared Driver', + 'phone' => '+6591234568', ])); expect($result)->toBeArray() - ->and($result['driver']->resource->uuid)->toBe('driver-1') - ->and($connection->table('drivers')->count())->toBe(1); + ->and($result['driver']->resource->user_uuid)->toBe('11111111-1111-4111-8111-111111111112') + ->and($connection->table('drivers')->where('company_uuid', 'company-1')->count())->toBe(1) + ->and($connection->table('company_users')->where(['company_uuid' => 'company-1', 'user_uuid' => '11111111-1111-4111-8111-111111111112'])->count())->toBe(1) + ->and(fleetopsDriverAdoptionRoles($connection, '11111111-1111-4111-8111-111111111112'))->toBe(['Driver']) + ->and($connection->table('invites')->count())->toBe(0); }); -test('non phone or email conflicts fall through to the error response', function () { +test('an email or phone that cannot be linked is rejected with a 422', function (array $user, ?array $driver, array $input, string $message) { + $connection = fleetopsDriverAdoptionBoot([]); + $connection->table('users')->insert(array_merge(['uuid' => '11111111-1111-4111-8111-111111111111', 'name' => 'Existing'], $user)); + if ($driver) { + $connection->table('drivers')->insert($driver); + } + + $result = (new DriverController())->createRecord(fleetopsDriverAdoptionRequest(array_merge(['name' => 'New Driver'], $input))); + + expect($result)->toBeInstanceOf(Illuminate\Http\JsonResponse::class) + ->and($result->getStatusCode())->toBe(422) + ->and($result->getData(true)['error'])->toBe($message) + ->and($connection->table('drivers')->where('company_uuid', 'company-1')->where('user_uuid', '!=', '11111111-1111-4111-8111-111111111111')->count())->toBe(0) + ->and($connection->table('users')->count())->toBe(1); +})->with([ + 'staff of another organization' => [ + ['company_uuid' => 'company-2', 'email' => 'outsider@example.com', 'type' => 'admin'], + null, + ['email' => 'outsider@example.com'], + 'This email is already in use by another account.', + ], + 'a customer account' => [ + ['company_uuid' => 'company-1', 'phone' => '+6591112222', 'type' => 'customer'], + null, + ['phone' => '+6591112222'], + 'This phone number is already used by a customer.', + ], + 'an existing driver of the organization' => [ + ['company_uuid' => 'company-1', 'email' => 'member@example.com', 'type' => 'driver'], + ['uuid' => 'driver-1', 'company_uuid' => 'company-1', 'user_uuid' => '11111111-1111-4111-8111-111111111111'], + ['email' => 'member@example.com'], + 'A driver with this email already exists.', + ], +]); + +test('validation failures return the error response', function () { fleetopsDriverAdoptionBoot(['name' => ['The name field is required.']]); - // The error response seam raises the validation failure once the - // fall-through branch executes + // The error response seam raises the validation failure $failure = null; try { @@ -398,313 +476,65 @@ function fleetopsDriverAdoptionRequest(array $driver): Request expect($failure?->errors())->toBe(['name' => ['The name field is required.']]); }); -test('valid create requests build the driver user and profile', function () { - $connection = fleetopsDriverAdoptionBoot([]); - - putenv('DEBUG=1'); - $_ENV['DEBUG'] = $_SERVER['DEBUG'] = '1'; - $result = (new DriverController())->createRecord(fleetopsDriverAdoptionRequest([ - 'name' => 'Fresh Driver', - 'email' => 'fresh@example.com', - 'phone' => '+6590001111', - ])); - - expect($result)->toBeArray()->toHaveKey('driver') - ->and($connection->table('users')->where('email', 'fresh@example.com')->value('type'))->toBe('driver') - ->and($connection->table('drivers')->whereNotNull('user_uuid')->count())->toBe(1) - ->and($connection->table('model_has_roles')->count())->toBeGreaterThanOrEqual(1); -}); - -test('valid update requests refresh driver and user details', function () { - $connection = fleetopsDriverAdoptionBoot([]); - $connection->table('users')->insert(['uuid' => '11111111-1111-4111-8111-111111111111', 'company_uuid' => 'company-1', 'name' => 'Old Name', 'email' => 'old@example.com', 'type' => 'driver']); - $connection->table('drivers')->insert(['uuid' => '22222222-2222-4222-8222-222222222222', 'public_id' => 'driver_updone1', 'company_uuid' => 'company-1', 'user_uuid' => '11111111-1111-4111-8111-111111111111']); - - $request = Request::create('/int/v1/drivers/driver_updone1', 'PUT', ['driver' => [ - 'name' => 'New Name', - 'status' => 'active', - ]]); - $request->setRouteResolver(function () { - return new class { - public function getAction($key = null) - { - return $key === null ? ['controller' => DriverController::class . '@updateRecord'] : DriverController::class . '@updateRecord'; - } - - public function getActionMethod() - { - return 'updateRecord'; - } - - public function getName() - { - return 'internal.drivers.update'; - } - - public function uri() - { - return 'int/v1/drivers/{id}'; - } - - public function parameters() - { - return ['id' => 'driver_updone1']; - } - - public function parameter($name = null, $default = null) - { - return $name === 'id' ? 'driver_updone1' : $default; - } - }; - }); - - $result = (new DriverController())->updateRecord($request, 'driver_updone1'); - - expect($result)->toBeArray()->toHaveKey('driver') - ->and($connection->table('users')->value('name'))->toBe('New Name') - ->and($connection->table('drivers')->value('status'))->toBe('available'); -}); - -test('create with user uuids provisions users guards conflicts and companies', function () { - $connection = fleetopsDriverAdoptionBoot([]); - Illuminate\Support\Facades\Cache::clearResolvedInstance('cache'); - - // Unknown user uuids provision a fresh user with generated credentials - $created = (new DriverController())->createRecord(fleetopsDriverAdoptionRequest([ - 'user_uuid' => '99999999-9999-4999-8999-999999999901', - 'name' => 'Provisioned Driver', - 'email' => 'provisioned@example.com', - 'phone' => '+6591112222', - 'photo_uuid' => '99999999-9999-4999-8999-999999999902', - ])); - expect($created)->toBeArray()->toHaveKey('driver') - ->and($connection->table('users')->where('email', 'provisioned@example.com')->count())->toBe(1) - ->and($connection->table('users')->where('email', 'provisioned@example.com')->value('avatar_uuid'))->toBe('99999999-9999-4999-8999-999999999902'); - - // Users already holding a driver profile in the company are rejected - $existingUserUuid = $connection->table('users')->where('email', 'provisioned@example.com')->value('uuid'); - $conflict = (new DriverController())->createRecord(fleetopsDriverAdoptionRequest([ - 'user_uuid' => $existingUserUuid, - 'name' => 'Duplicate Driver', - ])); - expect($conflict->getData(true)['error'] ?? '')->toContain('already belongs'); - - // Without a session company driver creation fails outright - session(['company' => null]); - $noCompany = (new DriverController())->createRecord(fleetopsDriverAdoptionRequest([ - 'name' => 'Companyless Driver', - ])); - expect($noCompany->getData(true)['error'] ?? '')->toContain('Unable to create driver'); - session(['company' => 'company-1']); -}); - -test('auth can resolves real permissions for requests and ping guards', function () { - $connection = fleetopsDriverAdoptionBoot([]); - $connection->table('users')->insert(['uuid' => '77777777-7777-4777-8777-777777777701', 'public_id' => 'user_authcan1', 'company_uuid' => 'company-1', 'name' => 'Permitted User', 'type' => 'user']); - $connection->table('company_users')->insert(['uuid' => '77777777-7777-4777-8777-777777777702', 'company_uuid' => 'company-1', 'user_uuid' => '77777777-7777-4777-8777-777777777701', 'status' => 'active']); - $connection->table('permissions')->insert([ - ['name' => 'fleet-ops update driver', 'guard_name' => 'sanctum'], - ['name' => 'fleet-ops update order', 'guard_name' => 'sanctum'], - ['name' => 'fleet-ops create driver', 'guard_name' => 'sanctum'], - ]); - $companyUserMorph = (new Fleetbase\Models\CompanyUser())->getMorphClass(); - $permissionIds = $connection->table('permissions')->pluck('id', 'name'); - $connection->table('model_has_permissions')->insert([ - ['permission_id' => $permissionIds['fleet-ops update driver'], 'model_type' => $companyUserMorph, 'model_uuid' => '77777777-7777-4777-8777-777777777702'], - ['permission_id' => $permissionIds['fleet-ops update order'], 'model_type' => $companyUserMorph, 'model_uuid' => '77777777-7777-4777-8777-777777777702'], - ['permission_id' => $permissionIds['fleet-ops create driver'], 'model_type' => $companyUserMorph, 'model_uuid' => '77777777-7777-4777-8777-777777777702'], - ]); - session(['company' => 'company-1', 'user' => '77777777-7777-4777-8777-777777777701']); - - // Granted permissions authorize, missing permissions deny - expect(Fleetbase\Support\Auth::can('fleet-ops update driver'))->toBeTrue() - ->and(Fleetbase\Support\Auth::can('fleet-ops delete driver'))->toBeFalse(); - - // The update driver form request authorizes through the same gate - $updateRequest = new Fleetbase\FleetOps\Http\Requests\Internal\UpdateDriverRequest(); - expect($updateRequest->authorize())->toBeTrue(); - - // Create-driver is granted, create-order-config was never granted, so the - // two internal create requests resolve opposite ways through one gate - expect((new Fleetbase\FleetOps\Http\Requests\Internal\CreateDriverRequest())->authorize())->toBeTrue() - ->and((new Fleetbase\FleetOps\Http\Requests\Internal\CreateOrderConfigRequest())->authorize())->toBeFalse(); - - // Fleet action requests resolve their permission through the same gate - $fleetRequest = Fleetbase\FleetOps\Http\Requests\Internal\FleetActionRequest::create('/int/v1/fleets/assign-vehicle', 'POST'); - $canMethod = new ReflectionMethod(Fleetbase\FleetOps\Http\Requests\Internal\FleetActionRequest::class, 'can'); - $canMethod->setAccessible(true); - expect($canMethod->invoke($fleetRequest, 'fleet-ops update driver'))->toBeTrue() - ->and($canMethod->invoke($fleetRequest, 'fleet-ops assign-vehicle-for fleet'))->toBeFalse(); - - // Driver ping authorization resolves through the order permission - $orderController = new Fleetbase\FleetOps\Http\Controllers\Internal\v1\OrderController(); - $canPing = new ReflectionMethod($orderController, 'canPingDriver'); - $canPing->setAccessible(true); - expect($canPing->invoke($orderController))->toBeTrue(); - - // Creating an order is gated on its own permission, which was never granted - expect((new Fleetbase\FleetOps\Http\Requests\Internal\CreateOrderRequest())->authorize())->toBeFalse(); - - // Global search consults the same gate per result type. This user is not an - // admin and holds no `see` permissions, so every requested type is skipped - // and nothing is searched rather than leaking unpermitted records - $searchController = new Fleetbase\FleetOps\Http\Controllers\Internal\v1\SearchController(); - $searchResponse = $searchController->search(Request::create('/int/v1/search', 'GET', [ - 'query' => 'anything', - 'types' => 'orders,drivers', - ])); - - expect($searchResponse->getData(true))->toBe(['results' => []]); - - session(['user' => null]); -}); - -function fleetopsDriverAdoptionUpdateRequest(string $publicId, array $driver): Request +function fleetopsDriverAdoptionThrowingHasher(Throwable $exception): void { - $request = Request::create('/int/v1/drivers/' . $publicId, 'PUT', ['driver' => $driver]); - $request->setRouteResolver(function () use ($publicId) { - return new class($publicId) { - public function __construct(private string $publicId) - { - } - - public function getAction($key = null) - { - return $key === null ? ['controller' => DriverController::class . '@updateRecord'] : DriverController::class . '@updateRecord'; - } - - public function getActionMethod() - { - return 'updateRecord'; - } - - public function getName() - { - return 'internal.drivers.update'; - } + $GLOBALS['fleetopsDriverAdoptionHashException'] = $exception; + app()->instance('hash', new class implements Illuminate\Contracts\Hashing\Hasher { + public function info($hashedValue): array + { + return []; + } - public function uri() - { - return 'int/v1/drivers/{id}'; - } + public function make($value, array $options = []): string + { + throw $GLOBALS['fleetopsDriverAdoptionHashException']; + } - public function parameters() - { - return ['id' => $this->publicId]; - } + public function check($value, $hashedValue, array $options = []): bool + { + return false; + } - public function parameter($name = null, $default = null) - { - return $name === 'id' ? $this->publicId : $default; - } - }; + public function needsRehash($hashedValue, array $options = []): bool + { + return false; + } }); - - return $request; + Illuminate\Support\Facades\Hash::clearResolvedInstance('hash'); } -test('creation applies photo avatars and syncs custom field values', function () { - $connection = fleetopsDriverAdoptionBoot([]); - $connection->table('custom_fields')->insert(['uuid' => '33333333-3333-4333-8333-333333333301', 'company_uuid' => 'company-1', 'name' => 'badge_number', 'label' => 'Badge Number']); - - $result = (new DriverController())->createRecord(fleetopsDriverAdoptionRequest([ - 'name' => 'Photographed Driver', - 'email' => 'photographed@example.com', - 'phone' => '+6590002222', - 'photo_uuid' => '44444444-4444-4444-8444-444444444401', - 'custom_field_values' => [ - ['custom_field_uuid' => '33333333-3333-4333-8333-333333333301', 'value' => 'BADGE-7', 'value_type' => 'text'], - ], - ])); - - expect($result)->toBeArray()->toHaveKey('driver') - ->and($connection->table('users')->where('email', 'photographed@example.com')->value('avatar_uuid'))->toBe('44444444-4444-4444-8444-444444444401') - ->and($connection->table('custom_field_values')->where('value', 'BADGE-7')->count())->toBe(1); -}); - -test('creation maps query and validation failures onto error responses', function () { +test('create record reports a missing organization', function () { fleetopsDriverAdoptionBoot([]); + session(['company' => 'company-missing']); - // Query failures from the persistence pipeline report a generic message - $queryFailure = new DriverController(); - $queryFailure->model = new class extends Fleetbase\FleetOps\Models\Driver { - public function createRecordFromRequest($request, ?callable $onBefore = null, ?callable $onAfter = null, array $options = []) - { - throw new Illuminate\Database\QueryException('mysql', 'insert into drivers', [], new RuntimeException('constraint violation')); - } - }; - $queryResponse = $queryFailure->createRecord(fleetopsDriverAdoptionRequest(['name' => 'Query Failure'])); - expect($queryResponse->getData(true)['error'] ?? '')->not->toBeEmpty(); + $result = (new DriverController())->createRecord(fleetopsDriverAdoptionRequest(['name' => 'Nowhere Driver'])); - // Validation exceptions surface their individual error messages - $validationFailure = new DriverController(); - $validationFailure->model = new class extends Fleetbase\FleetOps\Models\Driver { - public function createRecordFromRequest($request, ?callable $onBefore = null, ?callable $onAfter = null, array $options = []) - { - throw new Fleetbase\Exceptions\FleetbaseRequestValidationException(['The name field is required.']); - } - }; - $validationResponse = $validationFailure->createRecord(fleetopsDriverAdoptionRequest(['name' => 'Validation Failure'])); - expect($validationResponse->getData(true)['error'])->toBe(['The name field is required.']); + expect($result->getData(true))->toBe(['error' => 'Unable to create driver.']); }); -test('updates apply photo avatars and sync custom field values', function () { +test('create record syncs custom field values onto the new driver', function () { $connection = fleetopsDriverAdoptionBoot([]); - $connection->table('users')->insert(['uuid' => '11111111-1111-4111-8111-111111111121', 'company_uuid' => 'company-1', 'name' => 'Photo Driver', 'email' => 'photodriver@example.com', 'type' => 'driver']); - $connection->table('drivers')->insert(['uuid' => '22222222-2222-4222-8222-222222222221', 'public_id' => 'driver_photoone', 'company_uuid' => 'company-1', 'user_uuid' => '11111111-1111-4111-8111-111111111121']); - $connection->table('custom_fields')->insert(['uuid' => '33333333-3333-4333-8333-333333333302', 'company_uuid' => 'company-1', 'name' => 'route_code', 'label' => 'Route Code']); - - $result = (new DriverController())->updateRecord(fleetopsDriverAdoptionUpdateRequest('driver_photoone', [ - 'name' => 'Photo Driver Updated', - 'photo_uuid' => '44444444-4444-4444-8444-444444444402', - 'custom_field_values' => [ - ['custom_field_uuid' => '33333333-3333-4333-8333-333333333302', 'value' => 'ROUTE-9', 'value_type' => 'text'], - ], - ]), 'driver_photoone'); - - expect($result)->toBeArray()->toHaveKey('driver') - ->and($connection->table('users')->where('uuid', '11111111-1111-4111-8111-111111111121')->value('avatar_uuid'))->toBe('44444444-4444-4444-8444-444444444402') - ->and($connection->table('custom_field_values')->where('value', 'ROUTE-9')->count())->toBe(1); -}); + $connection->table('custom_fields')->insert(['uuid' => 'dddddddd-dddd-4ddd-8ddd-dddddddddddd', 'company_uuid' => 'company-1', 'name' => 'badge', 'label' => 'Badge']); -test('updates map validation and generic failures onto error responses', function () { - fleetopsDriverAdoptionBoot([]); - - // Validation exceptions from the pipeline surface their error list - $validationFailure = new DriverController(); - $validationFailure->model = new class extends Fleetbase\FleetOps\Models\Driver { - public function updateRecordFromRequest(Request $request, $id, ?callable $onBefore = null, ?callable $onAfter = null, array $options = []) - { - throw new Fleetbase\Exceptions\FleetbaseRequestValidationException(['The status field is invalid.']); - } - }; - $validationResponse = $validationFailure->updateRecord(fleetopsDriverAdoptionUpdateRequest('driver_missing1', ['name' => 'Validation Failure']), 'driver_missing1'); - expect($validationResponse->getData(true)['error'])->toBe(['The status field is invalid.']); + $result = (new DriverController())->createRecord(Request::create('/int/v1/drivers', 'POST', ['driver' => [ + 'name' => 'Badged Driver', + 'custom_field_values' => [['custom_field_uuid' => 'dddddddd-dddd-4ddd-8ddd-dddddddddddd', 'value' => 'B-12']], + ]])); - // Any other failure reports its own message - $genericFailure = new DriverController(); - $genericFailure->model = new class extends Fleetbase\FleetOps\Models\Driver { - public function updateRecordFromRequest(Request $request, $id, ?callable $onBefore = null, ?callable $onAfter = null, array $options = []) - { - throw new Exception('Driver profile is locked.'); - } - }; - $genericResponse = $genericFailure->updateRecord(fleetopsDriverAdoptionUpdateRequest('driver_missing1', ['name' => 'Generic Failure']), 'driver_missing1'); - expect($genericResponse->getData(true)['error'] ?? '')->toBe('Driver profile is locked.'); + expect($result)->toBeArray() + ->and($connection->table('custom_field_values')->where('subject_uuid', $result['driver']->resource->uuid)->value('value'))->toBe('B-12'); }); -test('update rejects requests that fail validation before persistence', function () { - fleetopsDriverAdoptionBoot(['status' => ['The selected status is invalid.']]); +test('create record reports database and request validation failures', function () { + $connection = fleetopsDriverAdoptionBoot([]); + $connection->getSchemaBuilder()->drop('drivers'); - // The error-response seam raises the validation failure once the rejection - // branch executes, so the request never reaches persistence - $failure = null; + // The account is created before the driver row fails to insert + $queryFailure = (new DriverController())->createRecord(fleetopsDriverAdoptionRequest(['name' => 'Tableless Driver'])); + expect($queryFailure->getData(true)['error'])->toContain('drivers'); - try { - (new DriverController())->updateRecord(fleetopsDriverAdoptionUpdateRequest('driver_missing1', ['status' => 'bogus']), 'driver_missing1'); - } catch (Illuminate\Validation\ValidationException $exception) { - $failure = $exception; - } + fleetopsDriverAdoptionThrowingHasher(new Fleetbase\Exceptions\FleetbaseRequestValidationException(['password' => ['The password is too weak.']])); + $validationFailure = (new DriverController())->createRecord(fleetopsDriverAdoptionRequest(['name' => 'Weak Password', 'password' => 'weak-password'])); - expect($failure?->errors())->toBe(['status' => ['The selected status is invalid.']]); + expect($validationFailure->getData(true))->toBe(['error' => ['password' => ['The password is too weak.']]]); }); diff --git a/server/tests/Feature/Http/Internal/DriverControllerLoginManagementTest.php b/server/tests/Feature/Http/Internal/DriverControllerLoginManagementTest.php new file mode 100644 index 000000000..16bb6cd74 --- /dev/null +++ b/server/tests/Feature/Http/Internal/DriverControllerLoginManagementTest.php @@ -0,0 +1,716 @@ +rule; } }'); +} + +if (!Str::hasMacro('humanize')) { + Str::macro('humanize', fn ($value) => ucfirst(str_replace(['_', '-'], ' ', Str::snake((string) $value)))); +} + +if (!Request::hasMacro('getController')) { + Request::macro('getController', fn () => new DriverController()); +} + +if (!Request::hasMacro('or')) { + Request::macro('or', function (array $params = [], $default = null) { + foreach ($params as $param) { + if ($this->has($param)) { + return $this->input($param); + } + } + + return $default; + }); +} + +function fleetopsDriverLoginContainer(): void +{ + $current = Illuminate\Container\Container::getInstance(); + if (method_exists($current, 'hasDebugModeEnabled')) { + return; + } + + // Core record mutation surfaces exceptions through app()->hasDebugModeEnabled() + // and app()->environment(), which the harness container lacks + $replacement = new class extends Illuminate\Container\Container { + public function environment(...$environments) + { + if (empty($environments)) { + return 'testing'; + } + + $checks = is_array($environments[0]) ? $environments[0] : $environments; + + return in_array('testing', $checks, true); + } + + public function hasDebugModeEnabled() + { + return true; + } + }; + + foreach (['bindings', 'instances', 'aliases', 'abstractAliases', 'resolved', 'extenders', 'tags', 'contextual', 'scopedInstances', 'reboundCallbacks', 'globalBeforeResolvingCallbacks', 'globalResolvingCallbacks', 'globalAfterResolvingCallbacks', 'beforeResolvingCallbacks', 'resolvingCallbacks', 'afterResolvingCallbacks'] as $property) { + if (!property_exists(Illuminate\Container\Container::class, $property)) { + continue; + } + $reflection = new ReflectionProperty(Illuminate\Container\Container::class, $property); + $reflection->setAccessible(true); + if ($reflection->isInitialized($current)) { + $reflection->setValue($replacement, $reflection->getValue($current)); + } + } + + Illuminate\Container\Container::setInstance($replacement); + Illuminate\Support\Facades\Facade::setFacadeApplication($replacement); +} + +function fleetopsDriverLoginBoot(array $validatorErrors): SQLiteConnection +{ + fleetopsDriverLoginContainer(); + $pdo = new PDO('sqlite::memory:'); + $pdo->sqliteCreateFunction('ST_PointFromText', fn ($wkt, $srid = 0, $axisOrder = null) => $wkt); + $pdo->sqliteCreateFunction('ST_GeomFromText', fn ($wkt, $srid = 0, $axisOrder = null) => $wkt); + $connection = new SQLiteConnection($pdo); + $resolver = new ConnectionResolver(['default' => $connection, 'mysql' => $connection]); + $resolver->setDefaultConnection('mysql'); + EloquentModel::setConnectionResolver($resolver); + if (!EloquentModel::getEventDispatcher()) { + EloquentModel::setEventDispatcher(new Illuminate\Events\Dispatcher()); + } + 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()); + app()->instance('responsecache', new class { + public function __call($method, $arguments) + { + return null; + } + }); + $GLOBALS['fleetopsDriverLoginErrors'] = $validatorErrors; + app()->instance('validator', new class { + public function make($data = [], $rules = [], $messages = [], $attributes = []) + { + return new class implements Illuminate\Contracts\Validation\Validator { + public function fails() + { + return !empty($GLOBALS['fleetopsDriverLoginErrors']); + } + + public function errors() + { + return new MessageBag($GLOBALS['fleetopsDriverLoginErrors']); + } + + public function validated() + { + return []; + } + + public function validate() + { + return []; + } + + public function failed() + { + return array_keys($GLOBALS['fleetopsDriverLoginErrors']); + } + + public function sometimes($attribute, $rules, callable $callback) + { + return $this; + } + + public function after($callback) + { + return $this; + } + + public function getMessageBag() + { + return new MessageBag($GLOBALS['fleetopsDriverLoginErrors']); + } + }; + } + }); + Illuminate\Support\Facades\DB::clearResolvedInstance('db'); + app()->instance('hash', new class implements Illuminate\Contracts\Hashing\Hasher { + public function info($hashedValue): array + { + return []; + } + + public function make($value, array $options = []): string + { + return 'hashed:' . $value; + } + + public function check($value, $hashedValue, array $options = []): bool + { + return 'hashed:' . $value === $hashedValue; + } + + public function needsRehash($hashedValue, array $options = []): bool + { + return false; + } + }); + Illuminate\Support\Facades\Hash::clearResolvedInstance('hash'); + + $schema = $connection->getSchemaBuilder(); + $tables = [ + 'drivers' => ['uuid', 'public_id', 'internal_id', 'company_uuid', 'user_uuid', 'vehicle_uuid', 'vendor_uuid', 'current_job_uuid', 'auth_token', 'signup_token_used', 'avatar_url', 'drivers_license_number', 'license_expiry', 'location', 'heading', 'bearing', 'altitude', 'speed', 'currency', 'current_status', 'meta', 'location_updated_at', 'slug', 'status', 'country', 'city', 'online', '_key'], + 'users' => ['uuid', 'public_id', 'company_uuid', 'name', 'email', 'phone', 'password', 'status', 'type', 'username', 'avatar_uuid', 'slug', 'timezone', 'country', 'ip_address', 'meta', '_key'], + 'companies' => ['uuid', 'public_id', 'name', 'owner_uuid', 'timezone', 'options', 'status', '_key'], + 'company_users' => ['uuid', 'public_id', 'company_uuid', 'user_uuid', 'status', '_key'], + 'vehicles' => ['uuid', 'public_id', 'company_uuid', 'driver_uuid'], + 'custom_fields' => ['uuid', 'public_id', 'company_uuid', 'subject_uuid', 'subject_type', 'name', 'label'], + 'custom_field_values' => ['uuid', 'public_id', 'company_uuid', 'custom_field_uuid', 'subject_uuid', 'subject_type', 'value', 'value_type', '_key'], + 'settings' => ['key', 'value'], + 'permissions' => ['name', 'guard_name', 'service', 'description'], + 'roles' => ['uuid', 'public_id', 'name', 'guard_name', 'company_uuid', 'service', 'description', '_key'], + 'model_has_roles' => ['role_id', 'model_type', 'model_uuid'], + 'model_has_permissions' => ['permission_id', 'model_type', 'model_uuid'], + 'role_has_permissions' => ['permission_id', 'role_id'], + 'policies' => ['name', 'guard_name', 'company_uuid'], + 'directives' => ['uuid', 'public_id', 'company_uuid', 'permission_uuid', 'subject_type', 'subject_uuid', 'key', 'rules'], + 'files' => ['uuid', 'public_id', 'company_uuid', 'uploader_uuid', 'subject_uuid', 'subject_type', 'name', 'original_filename', 'extension', 'content_type', 'path', 'bucket', 'disk', 'size', 'type', 'meta', '_key'], + 'invites' => ['uuid', 'public_id', 'company_uuid', 'created_by_uuid', 'subject_uuid', 'subject_type', 'code', 'uri', 'protocol', 'recipients', 'reason', 'expires_at', '_key'], + 'notifications' => ['type', 'notifiable_type', 'notifiable_id', 'data', 'read_at'], + 'contacts' => ['uuid', 'public_id', 'company_uuid', 'user_uuid', 'name', 'email', 'phone', 'type'], + 'personal_access_tokens' => ['tokenable_type', 'tokenable_id', 'name', 'token', 'abilities', 'last_used_at', 'expires_at'], + ]; + foreach ($tables as $table => $columns) { + $schema->create($table, function ($blueprint) use ($columns) { + $blueprint->increments('id'); + foreach ($columns as $column) { + $blueprint->string($column)->nullable(); + } + $blueprint->timestamps(); + $blueprint->timestamp('deleted_at')->nullable(); + }); + } + + app()->instance('cache', new class { + public function tags($tags = null) + { + return $this; + } + + public function flush() + { + return true; + } + + public function remember($key, $ttl, $callback) + { + return $callback(); + } + + public function store($name = null) + { + return $this; + } + + public function __call($method, $arguments) + { + return null; + } + }); + Illuminate\Support\Facades\Cache::clearResolvedInstance('cache'); + app()->instance(Illuminate\Contracts\Notifications\Dispatcher::class, new class { + public array $sent = []; + + public function send($notifiables, $notification) + { + $this->sent[] = $notification; + } + + public function sendNow($notifiables, $notification, ?array $channels = null) + { + $this->sent[] = $notification; + } + + public function __call($method, $arguments) + { + return null; + } + }); + config()->set('cache.default', 'array'); + config()->set('cache.stores.array', ['driver' => 'array']); + config()->set('permission.cache.expiration_time', 60); + config()->set('permission.cache.key', 'spatie.permission.cache'); + config()->set('permission.cache.store', 'default'); + config()->set('permission.models.permission', Fleetbase\Models\Permission::class); + config()->set('permission.models.role', Fleetbase\Models\Role::class); + config()->set('permission.table_names', ['roles' => 'roles', 'permissions' => 'permissions', 'model_has_permissions' => 'model_has_permissions', 'model_has_roles' => 'model_has_roles', 'role_has_permissions' => 'role_has_permissions']); + config()->set('permission.column_names', ['role_pivot_key' => null, 'permission_pivot_key' => null, 'model_morph_key' => 'model_uuid', 'team_foreign_key' => 'team_id']); + config()->set('permission.teams', false); + config()->set('permission.events_enabled', false); + $cacheManager = new Illuminate\Cache\CacheManager(app()); + app()->instance(Illuminate\Cache\CacheManager::class, $cacheManager); + app()->instance(Spatie\Permission\PermissionRegistrar::class, new Spatie\Permission\PermissionRegistrar($cacheManager)); + config()->set('activitylog.enabled', false); + config()->set('activitylog.default_auth_driver', 'web'); + config()->set('auth.defaults.guard', 'web'); + config()->set('permission.models.role', Fleetbase\Models\Role::class); + app()->bind(Illuminate\Contracts\Config\Repository::class, fn () => config()); + + session(['company' => 'company-1']); + $connection->table('companies')->insert(['uuid' => 'company-1', 'public_id' => 'company_adopt1', 'name' => 'Acme']); + $connection->table('roles')->insert([ + ['uuid' => 'role-1', 'name' => 'Driver', 'guard_name' => 'web', 'company_uuid' => 'company-1'], + ['uuid' => 'role-2', 'name' => 'Driver', 'guard_name' => 'sanctum', 'company_uuid' => 'company-1'], + ['uuid' => 'role-3', 'name' => 'Administrator', 'guard_name' => 'web', 'company_uuid' => 'company-1'], + ['uuid' => 'role-4', 'name' => 'Administrator', 'guard_name' => 'sanctum', 'company_uuid' => 'company-1'], + ]); + + return $connection; +} + +class FleetOpsDriverLoginSmsFake extends SmsService +{ + public array $sent = []; + + public function __construct() + { + } + + public function send(string $to, string $text, array $options = [], ?string $provider = null): array + { + $this->sent[] = [$to, $text]; + + return ['success' => true]; + } +} + +function fleetopsDriverLoginFixture(): SQLiteConnection +{ + $connection = fleetopsDriverLoginBoot([]); + + Illuminate\Support\Facades\Mail::swap(new class { + public array $sent = []; + + public function to($users) + { + return $this; + } + + public function send($mailable) + { + $this->sent[] = $mailable; + + return null; + } + }); + app()->instance(SmsService::class, new FleetOpsDriverLoginSmsFake()); + + $users = [ + ['uuid' => '11111111-1111-4111-8111-111111111111', 'public_id' => 'user_managed', 'company_uuid' => 'company-1', 'name' => 'Managed Driver', 'email' => 'managed@example.com', 'phone' => '+6590000001', 'password' => 'hashed:old', 'status' => 'pending', 'type' => 'driver'], + ['uuid' => '22222222-2222-4222-8222-222222222222', 'public_id' => 'user_staff', 'company_uuid' => 'company-1', 'name' => 'Staff Driver', 'email' => 'staff@example.com', 'phone' => '+6590000002', 'password' => 'hashed:staff', 'status' => 'active', 'type' => 'user'], + ['uuid' => '33333333-3333-4333-8333-333333333333', 'public_id' => 'user_nocontact', 'company_uuid' => 'company-1', 'name' => 'Unreachable', 'status' => 'active', 'type' => 'driver'], + ]; + foreach ($users as $user) { + $connection->table('users')->insert($user); + } + $connection->table('company_users')->insert(['uuid' => 'cu-managed', 'company_uuid' => 'company-1', 'user_uuid' => '11111111-1111-4111-8111-111111111111', 'status' => 'pending']); + $drivers = [ + ['uuid' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1', 'public_id' => 'driver_managed', 'company_uuid' => 'company-1', 'user_uuid' => '11111111-1111-4111-8111-111111111111'], + ['uuid' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa2', 'public_id' => 'driver_staff', 'company_uuid' => 'company-1', 'user_uuid' => '22222222-2222-4222-8222-222222222222'], + ['uuid' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa3', 'public_id' => 'driver_nocontact', 'company_uuid' => 'company-1', 'user_uuid' => '33333333-3333-4333-8333-333333333333'], + ]; + foreach ($drivers as $driver) { + $connection->table('drivers')->insert($driver); + } + + return $connection; +} + +function fleetopsDriverLoginUser(SQLiteConnection $connection, string $uuid = '11111111-1111-4111-8111-111111111111'): object +{ + return $connection->table('users')->where('uuid', $uuid)->first(); +} + +test('send credentials sets a new password activates the login and emails it', function () { + $connection = fleetopsDriverLoginFixture(); + + $response = (new DriverController())->sendCredentials('driver_managed'); + $data = $response->getData(true); + $user = fleetopsDriverLoginUser($connection); + + expect($data['status'])->toBe('ok') + ->and($data['sent_via'])->toBe('email') + ->and($data['driver'])->toMatchArray([ + 'id' => 'driver_managed', + 'uuid' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1', + 'user_uuid' => '11111111-1111-4111-8111-111111111111', + 'is_staff_linked' => false, + 'login_status' => 'active', + ]) + ->and($data['driver']['user'])->toMatchArray([ + 'id' => 'user_managed', + 'email' => 'managed@example.com', + 'phone' => '+6590000001', + 'status' => 'active', + ]) + ->and($user->status)->toBe('active') + ->and($user->password)->not->toBe('hashed:old') + ->and(Illuminate\Support\Facades\Mail::getFacadeRoot()->sent[0])->toBeInstanceOf(Fleetbase\FleetOps\Mail\DriverCredentialsMail::class); + + // A driver with neither an email nor a phone can't be sent credentials + $unreachable = (new DriverController())->sendCredentials('driver_nocontact'); + expect($unreachable->getData(true))->toBe(['error' => 'This profile has no email or phone to send credentials to.']); +}); + +test('login management endpoints report missing drivers accounts and staff-linked logins', function () { + $connection = fleetopsDriverLoginFixture(); + // The driver scope keeps drivers whose account was deleted (the user + // relation includes trashed accounts), but such a driver has no login + $connection->table('users')->insert(['uuid' => '44444444-4444-4444-8444-444444444444', 'company_uuid' => 'company-1', 'type' => 'driver', 'deleted_at' => '2026-01-01 00:00:00']); + $connection->table('drivers')->insert(['uuid' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa4', 'public_id' => 'driver_orphan', 'company_uuid' => 'company-1', 'user_uuid' => '44444444-4444-4444-8444-444444444444']); + $controller = new DriverController(); + $reset = fn (string $id) => $controller->resetCredentials(Request::create('/x', 'POST', ['password' => 'long-enough', 'password_confirmation' => 'long-enough']), $id); + + foreach ([ + fn (string $id) => $controller->sendCredentials($id), + $reset, + fn (string $id) => $controller->deactivateLogin($id), + fn (string $id) => $controller->reactivateLogin($id), + ] as $endpoint) { + $missing = $endpoint('driver_unknown'); + $orphan = $endpoint('driver_orphan'); + $staff = $endpoint('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa2'); + + expect($missing->getStatusCode())->toBe(404) + ->and($missing->getData(true))->toBe(['error' => 'Driver not found.']) + ->and($orphan->getData(true))->toBe(['error' => 'This driver has no login account.']) + ->and($staff->getStatusCode())->toBe(422) + ->and($staff->getData(true))->toBe(['error' => 'This driver signs in with a team member account. Manage this login in IAM.']); + } + + // The team member's account is never touched + $staffUser = fleetopsDriverLoginUser($connection, '22222222-2222-4222-8222-222222222222'); + expect($staffUser->password)->toBe('hashed:staff') + ->and($staffUser->status)->toBe('active') + ->and(Illuminate\Support\Facades\Mail::getFacadeRoot()->sent)->toBe([]); +}); + +test('reset credentials validates the password and optionally sends it', function () { + $connection = fleetopsDriverLoginFixture(); + $controller = new DriverController(); + $request = fn (array $input) => Request::create('/x', 'POST', $input); + + expect($controller->resetCredentials($request(['password' => 'short', 'password_confirmation' => 'short']), 'driver_managed')->getData(true)) + ->toBe(['error' => 'Password must be at least 8 characters.']) + ->and($controller->resetCredentials($request([]), 'driver_managed')->getData(true)) + ->toBe(['error' => 'Password must be at least 8 characters.']) + ->and($controller->resetCredentials($request(['password' => 'long-enough', 'password_confirmation' => 'different']), 'driver_managed')->getData(true)) + ->toBe(['error' => 'Passwords do not match.']); + + $quiet = $controller->resetCredentials($request(['password' => 'quiet-secret', 'password_confirmation' => 'quiet-secret']), 'driver_managed'); + expect($quiet->getData(true)['status'])->toBe('ok') + ->and(fleetopsDriverLoginUser($connection)->password)->toBe('hashed:quiet-secret') + ->and(Illuminate\Support\Facades\Mail::getFacadeRoot()->sent)->toBe([]); + + $sent = $controller->resetCredentials($request(['password' => 'mailed-secret', 'password_confirmation' => 'mailed-secret', 'send_credentials' => true]), 'driver_managed'); + expect($sent->getData(true)['driver']['user_uuid'])->toBe('11111111-1111-4111-8111-111111111111') + ->and(fleetopsDriverLoginUser($connection)->password)->toBe('hashed:mailed-secret') + ->and(Illuminate\Support\Facades\Mail::getFacadeRoot()->sent)->toHaveCount(1); + + // The password is still changed when there's nowhere to send it + $unsendable = $controller->resetCredentials($request(['password' => 'kept-secret', 'password_confirmation' => 'kept-secret', 'send_credentials' => '1']), 'driver_nocontact'); + expect($unsendable->getData(true))->toBe(['error' => 'This profile has no email or phone to send credentials to.']) + ->and(fleetopsDriverLoginUser($connection, '33333333-3333-4333-8333-333333333333')->password)->toBe('hashed:kept-secret'); +}); + +test('deactivating a login signs the driver out and reactivating restores it', function () { + $connection = fleetopsDriverLoginFixture(); + $connection->table('users')->where('uuid', '11111111-1111-4111-8111-111111111111')->update(['status' => 'active']); + $connection->table('personal_access_tokens')->insert([ + 'tokenable_type' => Fleetbase\Models\User::class, + 'tokenable_id' => '11111111-1111-4111-8111-111111111111', + 'name' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1', + 'token' => 'token-hash', + ]); + $connection->table('personal_access_tokens')->insert([ + 'tokenable_type' => Fleetbase\Models\User::class, + 'tokenable_id' => '22222222-2222-4222-8222-222222222222', + 'name' => 'staff-session', + 'token' => 'staff-token-hash', + ]); + $controller = new DriverController(); + + $deactivated = $controller->deactivateLogin('driver_managed')->getData(true); + + expect($deactivated['status'])->toBe('ok') + ->and($deactivated['driver']['login_status'])->toBe('inactive') + ->and(fleetopsDriverLoginUser($connection)->status)->toBe('inactive') + ->and($connection->table('company_users')->where('uuid', 'cu-managed')->value('status'))->toBe('inactive') + // Only the driver's own tokens are revoked + ->and($connection->table('personal_access_tokens')->pluck('name')->all())->toBe(['staff-session']); + + $reactivated = $controller->reactivateLogin('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1')->getData(true); + + expect($reactivated['driver']['login_status'])->toBe('active') + ->and($reactivated['driver']['user']['session_status'])->toBe('active') + ->and(fleetopsDriverLoginUser($connection)->status)->toBe('active'); +}); + +function fleetopsDriverLoginUpdateRequest(string $id, array $driver): Request +{ + $request = Request::create('/int/v1/drivers/' . $id, 'PUT', ['driver' => $driver]); + $store = app('session.store'); + $store->put('company', 'company-1'); + $request->setLaravelSession($store); + $request->setRouteResolver(fn () => new class($id) { + public function __construct(private string $id) + { + } + + public function getAction($key = null) + { + return DriverController::class . '@updateRecord'; + } + + public function getActionMethod() + { + return 'updateRecord'; + } + + public function uri() + { + return 'int/v1/drivers/{id}'; + } + + public function getName() + { + return 'int.v1.drivers.update'; + } + + public function parameters() + { + return ['id' => $this->id]; + } + + public function parameter($key, $default = null) + { + return $key === 'id' ? $this->id : $default; + } + }); + app()->instance('request', $request); + + return $request; +} + +test('update record syncs proxy fields to a managed account and only the name to a team member', function () { + $connection = fleetopsDriverLoginFixture(); + $controller = new DriverController(); + + $managed = $controller->updateRecord(fleetopsDriverLoginUpdateRequest('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1', [ + 'name' => 'Renamed Driver', + 'email' => 'Renamed@Example.com', + 'phone' => null, + 'photo_uuid' => 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + 'user_uuid' => '22222222-2222-4222-8222-222222222222', + ]), 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1'); + + $user = fleetopsDriverLoginUser($connection); + + expect($managed)->toBeArray() + ->and($managed['driver']->resource->user_uuid)->toBe('11111111-1111-4111-8111-111111111111') + ->and($user->name)->toBe('Renamed Driver') + ->and($user->email)->toBe('renamed@example.com') + ->and($user->phone)->toBeNull() + ->and($user->avatar_uuid)->toBe('bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'); + + $staff = $controller->updateRecord(fleetopsDriverLoginUpdateRequest('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa2', [ + 'name' => 'Staff As Driver', + 'email' => 'changed@example.com', + ]), 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa2'); + + $staffUser = fleetopsDriverLoginUser($connection, '22222222-2222-4222-8222-222222222222'); + + expect($staff)->toBeArray() + ->and($staffUser->name)->toBe('Staff As Driver') + ->and($staffUser->email)->toBe('staff@example.com'); + + // Taking another account's email is refused + $conflict = $controller->updateRecord(fleetopsDriverLoginUpdateRequest('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1', [ + 'email' => 'staff@example.com', + ]), 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1'); + + expect($conflict->getStatusCode())->toBe(422) + ->and($conflict->getData(true))->toBe(['error' => 'This email is already in use by another account.']) + ->and(fleetopsDriverLoginUser($connection)->email)->toBe('renamed@example.com'); +}); + +function fleetopsDriverLoginLookup(array $input): array +{ + return (new FleetOpsLookupController())->profileIdentity(Request::create('/int/v1/fleet-ops/lookup/profile-identity', 'GET', $input))->getData(true); +} + +test('profile identity lookup reports the team member to link or the conflict', function () { + $connection = fleetopsDriverLoginFixture(); + $connection->table('users')->insert(['uuid' => '55555555-5555-4555-8555-555555555555', 'company_uuid' => 'company-1', 'name' => 'Office Staff', 'email' => 'office@example.com', 'phone' => '+6590000005', 'type' => 'admin']); + $connection->table('users')->insert(['uuid' => '66666666-6666-4666-8666-666666666666', 'company_uuid' => 'company-1', 'name' => 'Customer', 'email' => 'customer@example.com', 'type' => 'customer']); + $connection->table('contacts')->insert(['uuid' => 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', 'public_id' => 'contact_customer', 'company_uuid' => 'company-1', 'user_uuid' => '66666666-6666-4666-8666-666666666666', 'type' => 'customer']); + $connection->table('contacts')->insert(['uuid' => 'cccccccc-cccc-4ccc-8ccc-ccccccccccc2', 'public_id' => 'contact_nouser', 'company_uuid' => 'company-1', 'type' => 'contact']); + + expect((new FleetOpsLookupController())->profileIdentity(Request::create('/x', 'GET', ['type' => 'user']))->getStatusCode())->toBe(422) + ->and(fleetopsDriverLoginLookup(['type' => 'staff']))->toBe(['error' => 'Invalid profile type.']) + // Nothing entered, nothing found + ->and(fleetopsDriverLoginLookup([]))->toBe(['staff' => null, 'conflict' => null]) + // A new driver with a team member's email links to them + ->and(fleetopsDriverLoginLookup(['email' => 'office@example.com']))->toBe([ + 'staff' => ['uuid' => '55555555-5555-4555-8555-555555555555', 'name' => 'Office Staff', 'email' => 'office@example.com', 'phone' => '+6590000005'], + 'conflict' => null, + ]) + // A new driver can't take a customer's email + ->and(fleetopsDriverLoginLookup(['type' => 'driver', 'email' => 'customer@example.com']))->toBe(['staff' => null, 'conflict' => 'This email is already used by a customer.']) + // An existing driver's own email isn't taken, another account's is + ->and(fleetopsDriverLoginLookup(['ignore' => 'driver_managed', 'email' => 'managed@example.com']))->toBe(['staff' => null, 'conflict' => null]) + ->and(fleetopsDriverLoginLookup(['ignore' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1', 'phone' => '+6590000005']))->toBe(['staff' => null, 'conflict' => 'This phone number is already in use by another account.']) + // Customer profiles are looked up among contacts + ->and(fleetopsDriverLoginLookup(['type' => 'customer', 'ignore' => 'contact_customer', 'email' => 'customer@example.com']))->toBe(['staff' => null, 'conflict' => null]) + // A profile without an account, or an unknown profile id, is treated as a new profile + ->and(fleetopsDriverLoginLookup(['type' => 'contact', 'ignore' => 'contact_nouser', 'email' => 'customer@example.com']))->toBe(['staff' => null, 'conflict' => 'This email is already used by a customer.']) + ->and(fleetopsDriverLoginLookup(['type' => 'contact', 'ignore' => 'contact_unknown', 'email' => 'customer@example.com']))->toBe(['staff' => null, 'conflict' => 'This email is already used by a customer.']); +}); + +test('update record syncs custom field values and reports failures', function () { + $connection = fleetopsDriverLoginFixture(); + $connection->table('custom_fields')->insert(['uuid' => 'dddddddd-dddd-4ddd-8ddd-dddddddddddd', 'company_uuid' => 'company-1', 'name' => 'badge', 'label' => 'Badge']); + $controller = new DriverController(); + + $updated = $controller->updateRecord(fleetopsDriverLoginUpdateRequest('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1', [ + 'custom_field_values' => [['custom_field_uuid' => 'dddddddd-dddd-4ddd-8ddd-dddddddddddd', 'value' => 'B-12']], + ]), 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1'); + + expect($updated)->toBeArray() + ->and($connection->table('custom_field_values')->where('subject_uuid', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1')->value('value'))->toBe('B-12'); + + // An unknown driver surfaces the model's not-found error + $missing = $controller->updateRecord(fleetopsDriverLoginUpdateRequest('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa9', ['name' => 'Ghost']), 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa9'); + expect($missing->getData(true)['error'])->toContain('not found'); + + // A request validation failure raised while updating the account is returned + $GLOBALS['fleetopsDriverLoginHashException'] = new Fleetbase\Exceptions\FleetbaseRequestValidationException(['password' => ['The password is too weak.']]); + app()->instance('hash', new class implements Illuminate\Contracts\Hashing\Hasher { + public function info($hashedValue): array + { + return []; + } + + public function make($value, array $options = []): string + { + throw $GLOBALS['fleetopsDriverLoginHashException']; + } + + public function check($value, $hashedValue, array $options = []): bool + { + return false; + } + + public function needsRehash($hashedValue, array $options = []): bool + { + return false; + } + }); + Illuminate\Support\Facades\Hash::clearResolvedInstance('hash'); + + $weak = $controller->updateRecord(fleetopsDriverLoginUpdateRequest('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1', ['password' => 'weak-password']), 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1'); + expect($weak->getData(true))->toBe(['error' => ['password' => ['The password is too weak.']]]); +}); + +test('update record returns validation failures', function () { + fleetopsDriverLoginFixture(); + $GLOBALS['fleetopsDriverLoginErrors'] = ['email' => ['The email must be a valid email address.']]; + + $failure = null; + try { + (new DriverController())->updateRecord(fleetopsDriverLoginUpdateRequest('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1', ['email' => 'not-an-email']), 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1'); + } catch (Illuminate\Validation\ValidationException $exception) { + $failure = $exception; + } + + expect($failure?->errors())->toBe(['email' => ['The email must be a valid email address.']]); +}); diff --git a/server/tests/InternalCustomerControllerContractsTest.php b/server/tests/InternalCustomerControllerContractsTest.php index c958b07f2..59d370c1f 100644 --- a/server/tests/InternalCustomerControllerContractsTest.php +++ b/server/tests/InternalCustomerControllerContractsTest.php @@ -122,11 +122,12 @@ function fleetopsInternalCustomer(): FleetOpsInternalCustomerContactFake return $customer; } -function fleetopsInternalCustomerUser(string $status = 'active'): FleetOpsInternalCustomerUserFake +function fleetopsInternalCustomerUser(string $status = 'active', string $type = 'customer'): FleetOpsInternalCustomerUserFake { $user = new FleetOpsInternalCustomerUserFake(); $user->setRawAttributes([ 'uuid' => 'user-uuid', + 'type' => $type, 'public_id' => 'user_public', 'name' => 'Ada Customer', 'email' => 'ada@example.test', @@ -236,3 +237,33 @@ function fleetopsInternalCustomerJson(mixed $response): array 'password_confirmation' => 'one', ])))['error'])->toBe('Unable to reset customer credentials'); }); + +test('internal customer controller refuses login management for staff-linked customers', function () { + $staff = fleetopsInternalCustomerUser('active', 'user'); + $controller = fleetopsInternalCustomerController($staff); + $message = 'This customer signs in with a team member account. Manage this login in IAM.'; + $request = new Request(['customer' => 'customer_public']); + + $responses = [ + $controller->createPortalLogin($request), + $controller->sendCredentials($request), + $controller->deactivatePortalLogin($request), + $controller->reactivatePortalLogin($request), + $controller->resetCredentials(new Request([ + 'customer' => 'customer_public', + 'password' => 'chosen-secret', + 'password_confirmation' => 'chosen-secret', + ])), + ]; + + foreach ($responses as $response) { + expect($response->getStatusCode())->toBe(422) + ->and(fleetopsInternalCustomerJson($response)['error'])->toBe($message); + } + + // The team member's account is never touched + expect($staff->passwords)->toBe([]) + ->and($staff->activations)->toBe(0) + ->and($staff->deactivations)->toBe(0) + ->and($controller->sentCredentials)->toBe([]); +}); diff --git a/server/tests/ModelAccessorContractsTest.php b/server/tests/ModelAccessorContractsTest.php index a03d38b21..c40fff6cd 100644 --- a/server/tests/ModelAccessorContractsTest.php +++ b/server/tests/ModelAccessorContractsTest.php @@ -130,14 +130,51 @@ public function update(array $attributes = [], array $options = []): bool return true; } + public function save(array $options = []): bool + { + $this->updates[] = $this->getDirty(); + $this->syncOriginal(); + + return true; + } + public function delete() { $this->deleted = true; + $this->setAttribute('deleted_at', '2026-01-01 00:00:00'); return true; } } +function fleetopsModelAccessorsUseProfileAccountTables(): void +{ + $connection = new SQLiteConnection(new PDO('sqlite::memory:')); + $schema = $connection->getSchemaBuilder(); + $schema->create('users', function ($table) { + $table->string('uuid')->nullable(); + $table->string('type')->nullable(); + $table->string('email')->nullable(); + $table->string('phone')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + foreach (['drivers', 'contacts'] as $profileTable) { + $schema->create($profileTable, function ($table) { + $table->string('uuid')->nullable(); + $table->string('user_uuid')->nullable(); + $table->string('company_uuid')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + } + + $resolver = new ConnectionResolver([ + 'default' => $connection, + 'mysql' => $connection, + ]); + $resolver->setDefaultConnection('mysql'); + EloquentModel::setConnectionResolver($resolver); +} + class FleetOpsContactAccessorFake extends Contact { public array $loadedMissing = []; @@ -858,6 +895,8 @@ public function update(array $attributes = [], array $options = []): bool }); test('contact user sync lookup and deletion guards are stable', function () { + fleetopsModelAccessorsUseProfileAccountTables(); + $user = new FleetOpsContactSyncUserFake(); $user->setRawAttributes([ 'uuid' => 'user-uuid', @@ -868,22 +907,25 @@ public function update(array $attributes = [], array $options = []): bool ], true); $contact = new FleetOpsContactAccessorFake(); + $contact->exists = true; $contact->fakeUser = $user; $contact->setRawAttributes([ - 'uuid' => 'contact-uuid', - 'user_uuid' => 'not-a-uuid', - 'type' => 'customer', - 'name' => 'Old Name', - 'email' => 'old@example.com', - 'phone' => '+15550000000', - 'timezone' => 'UTC', + 'uuid' => 'contact-uuid', + 'company_uuid' => 'company-uuid', + 'user_uuid' => 'not-a-uuid', + 'type' => 'customer', + 'name' => 'Old Name', + 'email' => 'old@example.com', + 'phone' => '+15550000000', + 'timezone' => 'UTC', ], true); $contact->name = 'New Name'; - $contact->email = 'new@example.com'; - $contact->phone = '+15551112222'; + $contact->email = 'New@Example.com'; + $contact->phone = '+1 555-111-2222'; $contact->timezone = 'Asia/Singapore'; + // Proxy fields are normalized and pushed to the managed account expect($contact->syncWithUser())->toBeTrue() ->and($user->updates[0])->toBe([ 'name' => 'New Name', @@ -895,26 +937,46 @@ public function update(array $attributes = [], array $options = []): bool ->and($contact->hasUser())->toBeTrue() ->and($contact->doesntHaveUser())->toBeFalse(); - $deleteContact = new FleetOpsContactAccessorFake(); - $deleteContact->type = 'customer'; - $deleteContact->setRelation('user', $user); + // An unsaved contact's account was just resolved from these values + $newContact = new FleetOpsContactAccessorFake(); + $newContact->fakeUser = $user; + $newContact->name = 'Brand New'; + + // A saved contact with no proxy changes has nothing to sync + $cleanContact = new FleetOpsContactAccessorFake(); + $cleanContact->exists = true; + $cleanContact->fakeUser = $user; + $cleanContact->setRawAttributes(['name' => 'New Name', 'type' => 'customer'], true); + + expect($newContact->syncWithUser())->toBeFalse() + ->and($cleanContact->syncWithUser())->toBeFalse() + ->and($user->updates)->toHaveCount(1); + + // Deleting the only profile holding a managed account deletes the account + $deleteContact = new FleetOpsContactAccessorFake(); + $deleteContact->type = 'customer'; + $deleteContact->company_uuid = 'company-uuid'; + $deleteContact->fakeUser = $user; expect($deleteContact->deleteUser())->toBeTrue() - ->and($user->deleted)->toBeTrue() - ->and($deleteContact->loadedMissing)->toBe(['user']); + ->and($user->deleted)->toBeTrue(); - $mismatchedUser = new FleetOpsContactSyncUserFake(); - $mismatchedUser->setRawAttributes(['type' => 'admin'], true); + // A team member's account linked to the contact is never deleted + $staffUser = new FleetOpsContactSyncUserFake(); + $staffUser->setRawAttributes(['uuid' => 'staff-uuid', 'type' => 'admin'], true); - $mismatchedContact = new FleetOpsContactAccessorFake(); - $mismatchedContact->type = 'customer'; - $mismatchedContact->setRelation('user', $mismatchedUser); + $staffContact = new FleetOpsContactAccessorFake(); + $staffContact->type = 'customer'; + $staffContact->fakeUser = $staffUser; $emptyContact = new FleetOpsContactAccessorFake(); + $emptyContact->exists = true; $emptyContact->fakeUser = null; $emptyContact->setRawAttributes(['user_uuid' => 'not-a-uuid'], true); + $emptyContact->name = 'Changed'; - expect($mismatchedContact->deleteUser())->toBeFalse() + expect($staffContact->deleteUser())->toBeFalse() + ->and($staffUser->deleted)->toBeFalse() ->and($emptyContact->syncWithUser())->toBeFalse() ->and($emptyContact->getUser())->toBeNull() ->and($emptyContact->hasUser())->toBeFalse() diff --git a/server/tests/ObserverContractsTest.php b/server/tests/ObserverContractsTest.php index aef25a1de..38b097fa3 100644 --- a/server/tests/ObserverContractsTest.php +++ b/server/tests/ObserverContractsTest.php @@ -263,6 +263,7 @@ public function hasRole($roles, ?string $guard = null): bool public function delete() { $this->deleted = true; + $this->setAttribute('deleted_at', '2026-01-01 00:00:00'); return true; } @@ -1149,14 +1150,30 @@ protected function resolveOrder(PurchaseRate $purchaseRate): ?Order }); test('driver observer defaults location unassigns related records and deletes driver users', function () { + // releaseForProfile looks for the account's remaining driver/contact profiles + $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); + foreach (['drivers', 'contacts'] as $profileTable) { + $connection->getSchemaBuilder()->create($profileTable, function ($table) { + $table->string('uuid')->nullable(); + $table->string('user_uuid')->nullable(); + $table->string('company_uuid')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + } + $driver = new Driver(); $driver->setRawAttributes([ - 'uuid' => 'driver-uuid', - 'user_uuid' => 'user-uuid', + 'uuid' => 'driver-uuid', + 'user_uuid' => 'user-uuid', + 'company_uuid' => 'company-uuid', ], true); $observer = new FleetOpsDriverObserverProbe(); $user = new FleetOpsDriverObserverUserFake(); + $user->setRawAttributes(['uuid' => 'user-uuid', 'type' => 'driver'], true); $observer->user = $user; $observer->creating($driver); @@ -1175,6 +1192,14 @@ protected function resolveOrder(PurchaseRate $purchaseRate): ?Order ->and($observer->unassigned)->toBe(['driver-uuid']) ->and($user->deleted)->toBeTrue(); + // A team member's account linked to the driver is never deleted + $staff = new FleetOpsDriverObserverUserFake(); + $staff->setRawAttributes(['uuid' => 'staff-uuid', 'type' => 'user'], true); + $observer->user = $staff; + $observer->deleted($driver); + + expect($staff->deleted)->toBeFalse(); + $driverWithLocation = new Driver(); $location = new Fleetbase\LaravelMysqlSpatial\Types\Point(1.3, 103.8); $driverWithLocation->location = $location; diff --git a/server/tests/RequestContractsTest.php b/server/tests/RequestContractsTest.php index 9cda5fd89..86f7280eb 100644 --- a/server/tests/RequestContractsTest.php +++ b/server/tests/RequestContractsTest.php @@ -534,10 +534,13 @@ protected function canUpdateDriver(): bool $request = new InternalCreateDriverRequest(); $updateProbe = new FleetOpsInternalUpdateDriverRequestProbe(); + // Email/phone availability is decided by ProfileAccountManager (a team + // member of the company is linked), so they're optional and not unique here. expect(ruleStrings($createRules['name']))->toContain('required', 'nullable', 'string', 'max:255') - ->and(ruleStrings($createRules['email']))->toContain('required') - ->and(ruleStrings($createRules['phone']))->toContain('required') - ->and(ruleStrings($userRules['name']))->not->toContain('required') + ->and(ruleStrings($createRules['email']))->toBe(['nullable']) + ->and(ruleStrings($createRules['phone']))->toBe(['nullable', 'string']) + ->and(ruleStrings($userRules['name']))->toContain('required') + ->and(ruleStrings(InternalCreateDriverRequest::create('/fleetops-test', 'POST', ['email' => 'driver@example.test'])->rules()['email']))->toBe(['nullable', 'email']) ->and(ruleStrings($patchRules['name']))->not->toContain('required') ->and($updateProbe->authorize())->toBeFalse(); @@ -556,9 +559,10 @@ protected function canUpdateDriver(): bool ]) ->and($request->messages())->toMatchArray([ 'name.required' => 'Driver name is required.', - 'email.required' => 'Email address is required.', + 'email.email' => 'Please provide a valid email address.', 'password.min' => 'Password must be at least 8 characters.', - ]); + ]) + ->and($request->messages())->not->toHaveKeys(['email.required', 'email.unique', 'phone.required', 'phone.unique']); }); test('public create order request validates payload alternatives and pod methods', function () { @@ -929,12 +933,16 @@ public function parameter($key, $default = null) expect($request->authorize())->toBeTrue() ->and(ruleStrings($createRules['name']))->toContain('required') ->and(ruleStrings($patchRules['name']))->not->toContain('required') - // Contact details are optional but still validated and still unique: - // an operational driver record may legitimately have neither. - ->and(ruleStrings($createRules['email']))->toContain('nullable', 'email', 'unique:users') - ->and(ruleStrings($createRules['email']))->not->toContain('required') - ->and(ruleStrings($createRules['phone']))->toContain('nullable', 'unique:users') - ->and(ruleStrings($createRules['phone']))->not->toContain('required') + // Contact details are optional but still validated: an operational + // driver record may legitimately have neither. On create, availability + // is decided by ProfileAccountManager (a team member is linked); on + // update they must stay unique among users. + ->and(ruleStrings($createRules['email']))->toContain('nullable', 'email') + ->and(ruleStrings($createRules['email']))->not->toContain('required', 'unique:users') + ->and(ruleStrings($createRules['phone']))->toContain('nullable') + ->and(ruleStrings($createRules['phone']))->not->toContain('required', 'unique:users') + ->and(ruleStrings($patchRules['email']))->toContain('nullable', 'unique:users') + ->and(ruleStrings($patchRules['phone']))->toContain('nullable', 'string', 'unique:users') ->and($createRules['password'])->toBe('nullable|string') ->and($createRules['country'])->toBe('nullable|size:2') ->and(ruleStrings($createRules['vehicle']))->toContain('nullable', 'string', 'starts_with:vehicle_', 'exists:vehicles,public_id') diff --git a/server/tests/TelematicControllerContractsTest.php b/server/tests/TelematicControllerContractsTest.php index 699054534..aa0ceef45 100644 --- a/server/tests/TelematicControllerContractsTest.php +++ b/server/tests/TelematicControllerContractsTest.php @@ -159,6 +159,7 @@ public function getAttribute($key) $customer->setRelation('user', (object) [ 'uuid' => 'user-uuid', 'public_id' => 'user-public', + 'type' => 'customer', 'name' => 'Ada Customer', 'email' => 'ada@example.test', 'phone' => '+15551234567', @@ -168,10 +169,12 @@ public function getAttribute($key) ]); expect($controller->callHelper('customerPayload', $customer))->toBe([ - 'id' => 'customer-public', - 'uuid' => 'customer-uuid', - 'user_uuid' => 'user-uuid', - 'user' => [ + 'id' => 'customer-public', + 'uuid' => 'customer-uuid', + 'user_uuid' => 'user-uuid', + 'is_staff_linked' => false, + 'login_status' => 'active', + 'user' => [ 'id' => 'user-public', 'uuid' => 'user-uuid', 'name' => 'Ada Customer', @@ -182,4 +185,19 @@ public function getAttribute($key) 'avatar_url' => 'https://example.test/avatar.png', ], ]); + + // A customer linked to a team member's account is reported as staff-linked + $customer->setRelation('user', (object) ['uuid' => 'staff-uuid', 'public_id' => 'staff-public', 'type' => 'user', 'status' => 'inactive']); + $staffPayload = $controller->callHelper('customerPayload', $customer); + + expect($staffPayload['is_staff_linked'])->toBeTrue() + ->and($staffPayload['login_status'])->toBe('inactive'); + + // A customer without a login account is neither + $customer->setRelation('user', null); + $emptyPayload = $controller->callHelper('customerPayload', $customer); + + expect($emptyPayload['is_staff_linked'])->toBeFalse() + ->and($emptyPayload['login_status'])->toBeNull() + ->and($emptyPayload['user'])->toBeNull(); }); diff --git a/server/tests/Unit/Models/ContactCustomerUserTest.php b/server/tests/Unit/Models/ContactCustomerUserTest.php index d075d6847..8f2a2c562 100644 --- a/server/tests/Unit/Models/ContactCustomerUserTest.php +++ b/server/tests/Unit/Models/ContactCustomerUserTest.php @@ -9,7 +9,7 @@ } use Fleetbase\FleetOps\Exceptions\CustomerUserConflictException; -use Fleetbase\FleetOps\Exceptions\UserAlreadyExistsException; +use Fleetbase\FleetOps\Exceptions\ProfileIdentityConflictException; use Fleetbase\FleetOps\Models\Contact; use Fleetbase\Models\User; use Illuminate\Database\ConnectionResolver; @@ -75,9 +75,38 @@ public function __call($method, $arguments) }); Illuminate\Support\Facades\DB::clearResolvedInstance('db'); + app()->instance('hash', new class implements Illuminate\Contracts\Hashing\Hasher { + public function info($hashedValue): array + { + return []; + } + + public function make($value, array $options = []): string + { + return md5((string) $value); + } + + public function check($value, $hashedValue, array $options = []): bool + { + return md5((string) $value) === $hashedValue; + } + + public function needsRehash($hashedValue, array $options = []): bool + { + return false; + } + + public function verifyConfiguration($value): bool + { + return true; + } + }); + Illuminate\Support\Facades\Hash::clearResolvedInstance('hash'); + $schema = $connection->getSchemaBuilder(); $tables = [ 'contacts' => ['uuid', 'public_id', 'company_uuid', 'user_uuid', 'name', 'email', 'phone', 'type', 'title'], + 'drivers' => ['uuid', 'public_id', 'company_uuid', 'user_uuid'], 'users' => ['uuid', 'public_id', 'company_uuid', 'name', 'email', 'phone', 'username', 'password', 'timezone', 'status', 'type', 'slug', 'avatar_uuid', 'last_login'], 'companies' => ['uuid', 'public_id', 'name', 'timezone', 'owner_uuid'], 'company_users' => ['uuid', 'company_uuid', 'user_uuid', 'status'], @@ -188,24 +217,45 @@ function fleetopsContactCustomerUserContact(array $attributes = []): FleetOpsCon ->and($connection->table('users')->count())->toBe(1); }); -test('create user from contact rejects users already linked to another contact', function () { +test('create user from contact rejects an account that already holds a contact in the company', function () { $connection = fleetopsContactCustomerUserBoot(); - // The contact user() relation constrains users.type against the querying - // contact type, which is null in the static whereHas context — a null-typed - // user keeps the linked-contact subquery matchable. - $connection->table('users')->insert(['uuid' => 'user-9', 'company_uuid' => 'company-1', 'email' => 'taken@example.com', 'type' => null]); + $connection->table('users')->insert(['uuid' => 'user-9', 'company_uuid' => 'company-1', 'email' => 'taken@example.com', 'type' => 'contact']); $connection->table('contacts')->insert(['uuid' => 'contact-owner', 'company_uuid' => 'company-1', 'user_uuid' => 'user-9', 'name' => 'Owner', 'type' => 'contact']); $contact = fleetopsContactCustomerUserContact(['uuid' => 'contact-2', 'email' => 'taken@example.com']); - expect(fn () => Contact::createUserFromContact($contact))->toThrow(UserAlreadyExistsException::class); + expect(fn () => Contact::createUserFromContact($contact)) + ->toThrow(ProfileIdentityConflictException::class, 'A contact with this email already exists.'); }); -test('create user from contact rejects staff users for customer contacts', function () { +test('create user from contact rejects staff users of another organization for customer contacts', function () { $connection = fleetopsContactCustomerUserBoot(); - $connection->table('users')->insert(['uuid' => 'user-9', 'company_uuid' => 'company-1', 'email' => 'staff@example.com', 'type' => 'staff']); + $connection->table('users')->insert(['uuid' => 'user-9', 'company_uuid' => 'company-other', 'email' => 'staff@example.com', 'type' => 'user']); $contact = fleetopsContactCustomerUserContact(['type' => 'customer', 'email' => 'staff@example.com']); - expect(fn () => Contact::createUserFromContact($contact))->toThrow(CustomerUserConflictException::class); + expect(fn () => Contact::createUserFromContact($contact)) + ->toThrow(ProfileIdentityConflictException::class, 'This email is already in use by another account.'); +}); + +test('create user from contact links a staff member of the organization without changing their role', function () { + $connection = fleetopsContactCustomerUserBoot(); + $connection->table('users')->insert(['uuid' => 'user-9', 'company_uuid' => 'company-1', 'email' => 'staff@example.com', 'type' => 'user']); + $contact = fleetopsContactCustomerUserContact(['type' => 'customer', 'email' => 'Staff@Example.com']); + + $user = Contact::createUserFromContact($contact); + + expect($user->uuid)->toBe('user-9') + ->and($user->type)->toBe('user') + ->and($contact->user_uuid)->toBe('user-9') + ->and($connection->table('model_has_roles')->count())->toBe(0); +}); + +test('create user from contact rejects a managed account of another type', function () { + $connection = fleetopsContactCustomerUserBoot(); + $connection->table('users')->insert(['uuid' => 'user-9', 'company_uuid' => 'company-1', 'phone' => '+6591112222', 'type' => 'driver']); + $contact = fleetopsContactCustomerUserContact(['type' => 'customer', 'phone' => '+65 9111-2222']); + + expect(fn () => Contact::createUserFromContact($contact)) + ->toThrow(ProfileIdentityConflictException::class, 'This phone number is already used by a driver.'); }); test('create user from contact assigns the customer role and persists the link', function () { @@ -305,17 +355,27 @@ function fleetopsContactCustomerUserContact(array $attributes = []): FleetOpsCon test('assert customer identity is available detects staff conflicts', function () { $connection = fleetopsContactCustomerUserBoot(); - $connection->table('users')->insert(['uuid' => 'user-1', 'company_uuid' => 'company-1', 'email' => 'staff@example.com', 'type' => 'staff']); + $connection->table('users')->insert([ + ['uuid' => 'user-1', 'company_uuid' => 'company-1', 'email' => 'staff@example.com', 'type' => 'staff'], + ['uuid' => 'user-2', 'company_uuid' => 'company-1', 'email' => 'driver@example.com', 'type' => 'driver'], + ]); $nonCustomer = fleetopsContactCustomerUserContact(); $nonCustomer->assertCustomerIdentityIsAvailable(); - $conflicted = fleetopsContactCustomerUserContact(['type' => 'customer', 'email' => 'staff@example.com']); - expect(fn () => $conflicted->assertCustomerIdentityIsAvailable())->toThrow(CustomerUserConflictException::class); + // A staff member of the organization can hold a customer profile + $staffMember = fleetopsContactCustomerUserContact(['type' => 'customer', 'email' => 'staff@example.com']); + $staffMember->assertCustomerIdentityIsAvailable(); + + // A driver's managed account cannot + $conflicted = fleetopsContactCustomerUserContact(['type' => 'customer', 'email' => 'driver@example.com']); + expect(fn () => $conflicted->assertCustomerIdentityIsAvailable()) + ->toThrow(CustomerUserConflictException::class, 'This email is already used by a driver and cannot be used for a customer account.'); // An already-assigned user is checked ahead of the identity lookup, so a // contact whose email matches nothing still trips on its own assignment - $connection->table('users')->insert(['uuid' => '33333333-3333-4333-8333-333333333333', 'company_uuid' => 'company-1', 'email' => 'assigned@example.com', 'type' => 'staff']); + // (a staff account of another organization) + $connection->table('users')->insert(['uuid' => '33333333-3333-4333-8333-333333333333', 'company_uuid' => 'company-other', 'email' => 'assigned@example.com', 'type' => 'staff']); $assigned = fleetopsContactCustomerUserContact([ 'type' => 'customer', 'email' => 'unmatched@example.com', @@ -368,3 +428,19 @@ function fleetopsContactCustomerUserContact(array $attributes = []): FleetOpsCon expect($connection->table('company_users')->where('user_uuid', 'user-n1')->count())->toBe(1) ->and($connection->table('model_has_roles')->count())->toBeGreaterThanOrEqual(1); }); + +test('normalize customer user keeps a staff member of the organization untouched', function () { + $connection = fleetopsContactCustomerUserBoot(); + $connection->table('companies')->insert(['uuid' => 'company-1', 'name' => 'Acme']); + $connection->table('users')->insert(['uuid' => 'user-s1', 'company_uuid' => 'company-1', 'name' => 'Staff', 'email' => 'staff@example.com', 'type' => 'user', 'status' => 'active']); + + $contact = fleetopsContactCustomerUserContact(['type' => 'customer', 'user_uuid' => 'user-s1']); + $user = User::where('uuid', 'user-s1')->first(); + + expect($contact->normalizeCustomerUser($user))->toBe($user) + ->and($contact->getRelation('user'))->toBe($user) + // A team member's account keeps its own organization membership and role + ->and($connection->table('company_users')->count())->toBe(0) + ->and($connection->table('model_has_roles')->count())->toBe(0) + ->and($connection->table('users')->where('uuid', 'user-s1')->value('type'))->toBe('user'); +}); diff --git a/server/tests/Unit/Models/ContactTest.php b/server/tests/Unit/Models/ContactTest.php index 40d8738f6..1f8ff69f4 100644 --- a/server/tests/Unit/Models/ContactTest.php +++ b/server/tests/Unit/Models/ContactTest.php @@ -71,9 +71,18 @@ public function update(array $attributes = [], array $options = []): bool return true; } + public function save(array $options = []): bool + { + $this->updates[] = $this->getDirty(); + $this->syncOriginal(); + + return true; + } + public function delete() { $this->deleted = true; + $this->setAttribute('deleted_at', '2026-01-01 00:00:00'); return true; } @@ -180,6 +189,16 @@ public static function where($column, $operator = null, $value = null, $boolean } } +function fleetopsContactUnitUseProfileTables(): SQLiteConnection +{ + $connection = fleetopsContactUnitUseInMemoryConnection(); + $connection->statement('create table drivers (uuid varchar(64), user_uuid varchar(64) null, company_uuid varchar(64) null, deleted_at datetime null)'); + $connection->statement('create table contacts (uuid varchar(64), user_uuid varchar(64) null, company_uuid varchar(64) null, deleted_at datetime null)'); + $connection->statement('create table company_users (uuid varchar(64), user_uuid varchar(64) null, company_uuid varchar(64) null, created_at datetime null, updated_at datetime null, deleted_at datetime null)'); + + return $connection; +} + function fleetopsContactUnitUseInMemoryConnection(): SQLiteConnection { $connection = new SQLiteConnection(new PDO('sqlite::memory:')); @@ -496,6 +515,8 @@ function fleetopsContactUnitUseInMemoryConnection(): SQLiteConnection }); test('contact sync delete and user presence helpers use loaded user relations', function () { + fleetopsContactUnitUseProfileTables(); + $user = new FleetOpsContactUnitUserFake(); $user->setRawAttributes([ 'uuid' => 'user-uuid', @@ -521,6 +542,7 @@ function fleetopsContactUnitUseInMemoryConnection(): SQLiteConnection ]); $contact->setRelation('user', $user); $contact->fakeUser = $user; + $contact->exists = true; expect($contact->syncWithUser())->toBeTrue() ->and($user->updates)->toBe([[ @@ -543,20 +565,40 @@ function fleetopsContactUnitUseInMemoryConnection(): SQLiteConnection ->and($unlinked->doesntHaveUser())->toBeTrue(); }); -test('contact delete user ignores mismatched relation types', function () { +test('contact delete user leaves team member accounts alone', function () { $user = new FleetOpsContactUnitUserFake(); $user->setRawAttributes([ 'uuid' => 'user-uuid', - 'type' => 'customer', + 'type' => 'user', ], true); - $contact = new FleetOpsContactUnitFake(['type' => 'contact']); - $contact->setRelation('user', $user); + $contact = new FleetOpsContactUnitFake(['type' => 'customer']); + $contact->fakeUser = $user; expect($contact->deleteUser())->toBeFalse() ->and($user->deleted)->toBeFalse(); }); +test('contact delete user keeps a managed account still used by another organization', function () { + $connection = fleetopsContactUnitUseProfileTables(); + $connection->table('drivers')->insert(['uuid' => 'driver-elsewhere', 'user_uuid' => 'user-uuid', 'company_uuid' => 'other-company']); + $connection->table('company_users')->insert([ + ['uuid' => 'cu-1', 'user_uuid' => 'user-uuid', 'company_uuid' => 'company-uuid'], + ['uuid' => 'cu-2', 'user_uuid' => 'user-uuid', 'company_uuid' => 'other-company'], + ]); + + $user = new FleetOpsContactUnitUserFake(); + $user->setRawAttributes(['uuid' => 'user-uuid', 'type' => 'driver'], true); + + $contact = new FleetOpsContactUnitFake(['type' => 'contact', 'company_uuid' => 'company-uuid']); + $contact->fakeUser = $user; + + // The account only leaves this organization + expect($contact->deleteUser())->toBeFalse() + ->and($user->deleted)->toBeFalse() + ->and($connection->table('company_users')->whereNull('deleted_at')->pluck('company_uuid')->all())->toBe(['other-company']); +}); + test('contact real user helpers return loaded relations without database lookup', function () { $user = new FleetOpsContactUnitUserFake(); $user->setRawAttributes([ diff --git a/server/tests/Unit/Models/SmallModelSurfacesTest.php b/server/tests/Unit/Models/SmallModelSurfacesTest.php index 142f1f77f..6aecd0683 100644 --- a/server/tests/Unit/Models/SmallModelSurfacesTest.php +++ b/server/tests/Unit/Models/SmallModelSurfacesTest.php @@ -176,45 +176,51 @@ public function __call($method, $arguments) expect($customer->orders())->toBeInstanceOf(HasMany::class); }); -test('contact observer availability checks and deletion resolve against users', function () { +test('contact observer deletion releases the managed account and leaves team members alone', function () { $connection = fleetopsSmallModelBoot(); - $connection->table('users')->insert(['uuid' => 'user-2', 'company_uuid' => 'company-1', 'email' => 'taken@example.test', 'phone' => '+6512345678']); + config(['activitylog.enabled' => false]); + app()->bind(Illuminate\Contracts\Config\Repository::class, fn () => config()); + $connection->getSchemaBuilder()->create('drivers', function ($blueprint) { + $blueprint->increments('id'); + $blueprint->string('uuid')->nullable(); + $blueprint->string('company_uuid')->nullable(); + $blueprint->string('user_uuid')->nullable(); + $blueprint->timestamp('deleted_at')->nullable(); + }); + $connection->table('users')->insert([ + ['uuid' => '11111111-1111-4111-8111-111111111111', 'company_uuid' => 'company-1', 'type' => 'contact'], + ['uuid' => '22222222-2222-4222-8222-222222222222', 'company_uuid' => 'company-1', 'type' => 'user'], + ]); - $observer = new ContactObserver(); - $reflection = new ReflectionClass($observer); + $observer = new ContactObserver(); $contact = new Fleetbase\FleetOps\Models\Contact(); - $contact->setRawAttributes(['uuid' => 'contact-1', 'company_uuid' => 'company-1', 'user_uuid' => 'user-1', 'email' => 'taken@example.test', 'phone' => '+6512345678', 'type' => 'contact'], true); + $contact->setRawAttributes(['uuid' => 'contact-1', 'company_uuid' => 'company-1', 'user_uuid' => '11111111-1111-4111-8111-111111111111', 'type' => 'contact'], true); $contact->exists = true; - $emailCheck = $reflection->getMethod('isEmailUnavailable'); - $emailCheck->setAccessible(true); - expect($emailCheck->invoke($observer, $contact))->toBeTrue(); - - $phoneCheck = $reflection->getMethod('isPhoneUnavailable'); - $phoneCheck->setAccessible(true); - expect($phoneCheck->invoke($observer, $contact))->toBeTrue(); + $staffContact = new Fleetbase\FleetOps\Models\Contact(); + $staffContact->setRawAttributes(['uuid' => 'contact-2', 'company_uuid' => 'company-1', 'user_uuid' => '22222222-2222-4222-8222-222222222222', 'type' => 'contact'], true); + $staffContact->exists = true; - $contact->email = 'free@example.test'; - $contact->phone = '+6599999999'; - expect($emailCheck->invoke($observer, $contact))->toBeFalse() - ->and($phoneCheck->invoke($observer, $contact))->toBeFalse(); - - // Deletion removes the associated user account $observer->deleted($contact); - expect(true)->toBeTrue(); + $observer->deleted($staffContact); + + // The managed account had no other profile, so it is deleted + expect($connection->table('users')->where('uuid', '11111111-1111-4111-8111-111111111111')->value('deleted_at'))->not->toBeNull() + ->and($connection->table('users')->where('uuid', '22222222-2222-4222-8222-222222222222')->value('deleted_at'))->toBeNull(); }); test('contact observer rejects saves whose email or phone belongs to another account', function () { $connection = fleetopsSmallModelBoot(); - $connection->table('users')->insert(['uuid' => '22222222-2222-4222-8222-222222222222', 'company_uuid' => 'company-1', 'email' => 'taken@example.test', 'phone' => '+6512345678']); + $connection->table('users')->insert([ + ['uuid' => '11111111-1111-4111-8111-111111111111', 'company_uuid' => 'company-1', 'type' => 'contact', 'email' => 'free@example.test', 'phone' => '+6599999999'], + ['uuid' => '22222222-2222-4222-8222-222222222222', 'company_uuid' => 'company-1', 'type' => 'user', 'email' => 'taken@example.test', 'phone' => '+6512345678'], + ]); $observer = new ContactObserver(); - // The availability checks are private, so the guards can only be reached - // through saving() with a contact that genuinely collides on a real table. // hasUser() keys off a well-formed uuid, so a real one keeps saving() from - // detouring into account provisioning before it reaches the guards. + // detouring into account provisioning before it reaches the sync. $contact = new Fleetbase\FleetOps\Models\Contact(); $contact->setRawAttributes([ 'uuid' => 'contact-collide', @@ -226,23 +232,16 @@ public function __call($method, $arguments) ], true); $contact->exists = true; - // wasChanged() reads the post-save change set, which setRawAttributes does - // not populate — syncChanges promotes the pending edit into it. $contact->email = 'taken@example.test'; - $contact->syncChanges(); expect(fn () => $observer->saving($contact)) - ->toThrow(Exception::class, 'Email attempting to update for contact is not available.'); + ->toThrow(Fleetbase\FleetOps\Exceptions\ProfileIdentityConflictException::class, 'This email is already in use by another account.'); - // The phone guard sits behind the email one, so it only surfaces once the - // email is back to an available value - $contact->syncOriginal(); $contact->email = 'free@example.test'; $contact->phone = '+6512345678'; - $contact->syncChanges(); expect(fn () => $observer->saving($contact)) - ->toThrow(Exception::class, 'Phone attempting to update for contact is not available.'); + ->toThrow(Fleetbase\FleetOps\Exceptions\ProfileIdentityConflictException::class, 'This phone number is already in use by another account.'); }); test('tracking status request authorizes by session and rejects duplicates', function () { diff --git a/server/tests/Unit/Observers/ObserverListenerSeamsTest.php b/server/tests/Unit/Observers/ObserverListenerSeamsTest.php index febb8bee7..03223f571 100644 --- a/server/tests/Unit/Observers/ObserverListenerSeamsTest.php +++ b/server/tests/Unit/Observers/ObserverListenerSeamsTest.php @@ -98,11 +98,16 @@ function fleetopsObserverSeamInvoke(string $class, string $method, array $argume ->and($connection->table('orders')->where('uuid', 'order-seam-1')->value('driver_assigned_uuid'))->toBeNull() ->and($connection->table('orders')->where('uuid', 'order-seam-2')->value('driver_assigned_uuid'))->toBe('driver-other'); - // The linked account resolves only when it is still a plain user + // The linked account resolves whatever its type: releaseForProfile decides + // whether a managed account is deleted and leaves a team member alone expect(fleetopsObserverSeamInvoke(Fleetbase\FleetOps\Observers\DriverObserver::class, 'findDriverUser', [$driver])?->uuid)->toBe('user-seam-1'); + $managedDriver = new Driver(); + $managedDriver->setRawAttributes(['uuid' => 'driver-seam-2', 'user_uuid' => 'user-seam-2'], true); + expect(fleetopsObserverSeamInvoke(Fleetbase\FleetOps\Observers\DriverObserver::class, 'findDriverUser', [$managedDriver])?->uuid)->toBe('user-seam-2'); + $driverWithoutUser = new Driver(); - $driverWithoutUser->setRawAttributes(['uuid' => 'driver-seam-2', 'user_uuid' => 'user-seam-2'], true); + $driverWithoutUser->setRawAttributes(['uuid' => 'driver-seam-3', 'user_uuid' => null], true); expect(fleetopsObserverSeamInvoke(Fleetbase\FleetOps\Observers\DriverObserver::class, 'findDriverUser', [$driverWithoutUser]))->toBeNull(); }); diff --git a/server/tests/Unit/Support/ProfileAccountManagerTest.php b/server/tests/Unit/Support/ProfileAccountManagerTest.php new file mode 100644 index 000000000..9c1828f49 --- /dev/null +++ b/server/tests/Unit/Support/ProfileAccountManagerTest.php @@ -0,0 +1,643 @@ + str_replace('_', ' ', Illuminate\Support\Str::snake((string) $value))); +} + +use Fleetbase\FleetOps\Exceptions\ProfileIdentityConflictException; +use Fleetbase\FleetOps\Mail\CustomerCredentialsMail; +use Fleetbase\FleetOps\Mail\DriverCredentialsMail; +use Fleetbase\FleetOps\Models\Contact; +use Fleetbase\FleetOps\Models\Driver; +use Fleetbase\FleetOps\Support\ProfileAccountManager; +use Fleetbase\Models\CompanyUser; +use Fleetbase\Models\User; +use Fleetbase\Services\SmsService; +use Illuminate\Database\ConnectionResolver; +use Illuminate\Database\Eloquent\Model as EloquentModel; +use Illuminate\Database\SQLiteConnection; + +/** + * Covers ProfileAccountManager against SQLite with real spatie roles: account + * classification, identity lookups, linking a staff member or a managed + * account of the same type, creating managed accounts without invites, proxy + * field sync, releasing an account when its profile is deleted, and sending + * credentials by email or SMS. + */ +class FleetOpsProfileAccountSmsFake extends SmsService +{ + public array $sent = []; + public array $result = ['success' => true]; + + public function __construct() + { + } + + public function send(string $to, string $text, array $options = [], ?string $provider = null): array + { + $this->sent[] = [$to, $text]; + + return $this->result; + } +} + +function fleetopsProfileAccountBoot(): SQLiteConnection +{ + // Model uuid hooks bind to whichever dispatcher exists when the class boots + if (!EloquentModel::getEventDispatcher()) { + EloquentModel::setEventDispatcher(new Illuminate\Events\Dispatcher()); + } + + $connection = new SQLiteConnection(new PDO('sqlite::memory:')); + $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()); + Illuminate\Support\Facades\DB::clearResolvedInstance('db'); + app()->instance('responsecache', new class { + public function __call($method, $arguments) + { + return null; + } + }); + app()->instance('hash', new class implements Illuminate\Contracts\Hashing\Hasher { + public function info($hashedValue): array + { + return []; + } + + public function make($value, array $options = []): string + { + return 'hashed:' . $value; + } + + public function check($value, $hashedValue, array $options = []): bool + { + return 'hashed:' . $value === $hashedValue; + } + + public function needsRehash($hashedValue, array $options = []): bool + { + return false; + } + }); + Illuminate\Support\Facades\Hash::clearResolvedInstance('hash'); + + Illuminate\Support\Facades\Mail::swap(new class { + public array $sent = []; + public array $to = []; + + public function to($users) + { + $this->to[] = $users; + + return $this; + } + + public function send($mailable) + { + $this->sent[] = $mailable; + + return null; + } + }); + + $sms = new FleetOpsProfileAccountSmsFake(); + app()->instance(SmsService::class, $sms); + + $schema = $connection->getSchemaBuilder(); + $tables = [ + 'users' => ['uuid', 'public_id', 'company_uuid', 'name', 'email', 'phone', 'password', 'status', 'type', 'username', 'avatar_uuid', 'slug', 'timezone', 'country', 'ip_address', 'meta', 'last_login', '_key'], + 'drivers' => ['uuid', 'public_id', 'company_uuid', 'user_uuid'], + 'contacts' => ['uuid', 'public_id', 'company_uuid', 'user_uuid', 'name', 'type'], + 'companies' => ['uuid', 'public_id', 'name', 'owner_uuid', 'timezone'], + 'company_users' => ['uuid', 'public_id', 'company_uuid', 'user_uuid', 'status', '_key'], + 'permissions' => ['name', 'guard_name', 'service', 'description'], + 'roles' => ['uuid', 'public_id', 'name', 'guard_name', 'company_uuid', 'service', 'description', '_key'], + 'model_has_roles' => ['role_id', 'model_type', 'model_uuid'], + 'model_has_permissions' => ['permission_id', 'model_type', 'model_uuid'], + 'role_has_permissions' => ['permission_id', 'role_id'], + ]; + foreach ($tables as $table => $columns) { + $schema->create($table, function ($blueprint) use ($columns) { + $blueprint->increments('id'); + foreach ($columns as $column) { + $blueprint->string($column)->nullable(); + } + $blueprint->timestamps(); + $blueprint->timestamp('deleted_at')->nullable(); + }); + } + + app()->instance('cache', new class { + public function tags($tags = null) + { + return $this; + } + + public function flush() + { + return true; + } + + public function remember($key, $ttl, $callback) + { + return $callback(); + } + + public function store($name = null) + { + return $this; + } + + public function __call($method, $arguments) + { + return null; + } + }); + Illuminate\Support\Facades\Cache::clearResolvedInstance('cache'); + config()->set('cache.default', 'array'); + config()->set('cache.stores.array', ['driver' => 'array']); + config()->set('permission.cache.expiration_time', 60); + config()->set('permission.cache.key', 'spatie.permission.cache'); + config()->set('permission.cache.store', 'default'); + config()->set('permission.models.permission', Fleetbase\Models\Permission::class); + config()->set('permission.models.role', Fleetbase\Models\Role::class); + config()->set('permission.table_names', ['roles' => 'roles', 'permissions' => 'permissions', 'model_has_permissions' => 'model_has_permissions', 'model_has_roles' => 'model_has_roles', 'role_has_permissions' => 'role_has_permissions']); + config()->set('permission.column_names', ['role_pivot_key' => null, 'permission_pivot_key' => null, 'model_morph_key' => 'model_uuid', 'team_foreign_key' => 'team_id']); + config()->set('permission.teams', false); + config()->set('permission.events_enabled', false); + $cacheManager = new Illuminate\Cache\CacheManager(app()); + app()->instance(Illuminate\Cache\CacheManager::class, $cacheManager); + app()->instance(Spatie\Permission\PermissionRegistrar::class, new Spatie\Permission\PermissionRegistrar($cacheManager)); + config()->set('activitylog.enabled', false); + config()->set('activitylog.default_auth_driver', 'web'); + config()->set('auth.defaults.guard', 'web'); + config()->set('app.name', 'Fleetbase'); + app()->bind(Illuminate\Contracts\Config\Repository::class, fn () => config()); + + session(['company' => 'company-1']); + $connection->table('companies')->insert([ + ['uuid' => 'company-1', 'public_id' => 'company_one', 'name' => 'Acme', 'timezone' => 'Asia/Singapore'], + ['uuid' => 'company-2', 'public_id' => 'company_two', 'name' => 'Globex', 'timezone' => null], + ]); + $roles = []; + foreach (['company-1', 'company-2'] as $company) { + foreach (['Driver', 'Fleet-Ops Contact', 'Fleet-Ops Customer'] as $name) { + foreach (['web', 'sanctum'] as $guard) { + $roles[] = ['uuid' => $company . $name . $guard, 'name' => $name, 'guard_name' => $guard, 'company_uuid' => $company]; + } + } + } + $connection->table('roles')->insert($roles); + + return $connection; +} + +function fleetopsProfileAccountRoles(SQLiteConnection $connection, string $userUuid, string $companyUuid): array +{ + return $connection->table('model_has_roles') + ->join('roles', 'roles.id', '=', 'model_has_roles.role_id') + ->join('company_users', 'company_users.uuid', '=', 'model_has_roles.model_uuid') + ->where('company_users.user_uuid', $userUuid) + ->where('company_users.company_uuid', $companyUuid) + ->pluck('roles.name') + ->unique() + ->values() + ->all(); +} + +/** + * Insert rows one at a time: a multi-row insert takes its column list from the + * first row, so rows with different keys would land in the wrong columns. + */ +function fleetopsProfileAccountRows(SQLiteConnection $connection, string $table, array $rows): void +{ + foreach ($rows as $row) { + $connection->table($table)->insert($row); + } +} + +function fleetopsProfileAccountUser(array $attributes): User +{ + app('db')->connection()->table('users')->insert($attributes); + + return User::where('uuid', $attributes['uuid'])->first(); +} + +test('accounts are managed when typed driver contact or customer and staff otherwise', function () { + expect(ProfileAccountManager::isManagedAccount(null))->toBeFalse() + ->and(ProfileAccountManager::isStaffAccount(null))->toBeFalse() + ->and(ProfileAccountManager::isManagedAccount((object) ['type' => 'driver']))->toBeTrue() + ->and(ProfileAccountManager::isManagedAccount((object) ['type' => 'contact']))->toBeTrue() + ->and(ProfileAccountManager::isManagedAccount((object) ['type' => 'customer']))->toBeTrue() + ->and(ProfileAccountManager::isStaffAccount((object) ['type' => 'customer']))->toBeFalse() + ->and(ProfileAccountManager::isStaffAccount((object) ['type' => 'user']))->toBeTrue() + ->and(ProfileAccountManager::isStaffAccount((object) ['type' => 'admin']))->toBeTrue() + ->and(ProfileAccountManager::isStaffAccount((object) []))->toBeTrue() + ->and(ProfileAccountManager::normalizeEmail(' Ada@Example.COM '))->toBe('ada@example.com') + ->and(ProfileAccountManager::normalizeEmail(' '))->toBeNull() + ->and(ProfileAccountManager::normalizeEmail(null))->toBeNull() + ->and(ProfileAccountManager::normalizePhone(' +65 9123-4567 '))->toBe('+6591234567') + ->and(ProfileAccountManager::normalizePhone(''))->toBeNull() + ->and(ProfileAccountManager::normalizePhone(null))->toBeNull(); +}); + +test('identity lookups match email or phone and skip ignored and deleted accounts', function () { + $connection = fleetopsProfileAccountBoot(); + fleetopsProfileAccountRows($connection, 'users', [ + ['uuid' => 'user-email', 'email' => 'ada@example.com', 'type' => 'user'], + ['uuid' => 'user-phone', 'phone' => '+6591234567', 'type' => 'driver'], + ['uuid' => 'user-deleted', 'email' => 'gone@example.com', 'type' => 'driver', 'deleted_at' => '2026-01-01 00:00:00'], + ]); + + expect(ProfileAccountManager::findAccountByIdentity(null, ' '))->toBeNull() + ->and(ProfileAccountManager::findAccountByIdentity('ADA@example.com', null)?->uuid)->toBe('user-email') + ->and(ProfileAccountManager::findAccountByIdentity(null, '+65 9123 4567')?->uuid)->toBe('user-phone') + ->and(ProfileAccountManager::findAccountByIdentity('nobody@example.com', '+6591234567')?->uuid)->toBe('user-phone') + ->and(ProfileAccountManager::findAccountByIdentity('ada@example.com', null, 'user-email'))->toBeNull() + ->and(ProfileAccountManager::findAccountByIdentity('gone@example.com', null))->toBeNull(); +}); + +test('lookup reports the staff member to link or why the identity is unavailable', function () { + $connection = fleetopsProfileAccountBoot(); + fleetopsProfileAccountRows($connection, 'users', [ + ['uuid' => 'staff-1', 'company_uuid' => 'company-1', 'name' => 'Staff', 'email' => 'staff@example.com', 'type' => 'user'], + ['uuid' => 'staff-2', 'company_uuid' => 'company-2', 'email' => 'outsider@example.com', 'type' => 'admin'], + ['uuid' => 'driver-1', 'company_uuid' => 'company-2', 'phone' => '+6590001111', 'type' => 'driver'], + ['uuid' => 'current-1', 'company_uuid' => 'company-1', 'email' => 'me@example.com', 'type' => 'driver'], + ]); + $current = User::where('uuid', 'current-1')->first(); + + $nothing = ProfileAccountManager::lookup('company-1', 'driver', 'free@example.com', null); + $staff = ProfileAccountManager::lookup('company-1', 'driver', 'staff@example.com', null); + $outsider = ProfileAccountManager::lookup('company-1', 'driver', 'outsider@example.com', null); + $customer = ProfileAccountManager::lookup('company-1', 'customer', null, '+6590001111'); + $sameType = ProfileAccountManager::lookup('company-1', 'driver', null, '+6590001111'); + $taken = ProfileAccountManager::lookup('company-1', 'driver', 'staff@example.com', null, $current); + $own = ProfileAccountManager::lookup('company-1', 'driver', 'me@example.com', null, $current); + + expect($nothing)->toBe(['account' => null, 'staff' => null, 'conflict' => null]) + ->and($staff['staff']?->uuid)->toBe('staff-1') + ->and($staff['conflict'])->toBeNull() + ->and($outsider['staff'])->toBeNull() + ->and($outsider['conflict'])->toBe('This email is already in use by another account.') + ->and($customer['conflict'])->toBe('This phone number is already used by a driver.') + // A driver account of another organization is linked, not reported as staff + ->and($sameType['account']?->uuid)->toBe('driver-1') + ->and($sameType['staff'])->toBeNull() + ->and($sameType['conflict'])->toBeNull() + // An existing profile can't swap its account for another one + ->and($taken['conflict'])->toBe('This email is already in use by another account.') + ->and($taken['staff'])->toBeNull() + ->and($own['account'])->toBeNull(); +}); + +test('resolve for profile creates a managed account with the profile role and no invite', function () { + $connection = fleetopsProfileAccountBoot(); + + $driver = ProfileAccountManager::resolveForProfile('company-1', 'driver', 'Dana Driver', 'Dana@Example.com', '+65 9000 1111', [ + 'password' => 'chosen-secret', + 'status' => 'active', + 'avatar_uuid' => 'avatar-1', + 'country' => 'SG', + 'ip_address' => '127.0.0.1', + ]); + + $row = $connection->table('users')->where('uuid', $driver->uuid)->first(); + + expect($driver->uuid)->not->toBeNull() + ->and($row->type)->toBe('driver') + ->and($row->email)->toBe('dana@example.com') + ->and($row->phone)->toBe('+6590001111') + ->and($row->password)->toBe('hashed:chosen-secret') + ->and($row->status)->toBe('active') + ->and($row->timezone)->toBe('Asia/Singapore') + ->and($row->avatar_uuid)->toBe('avatar-1') + ->and($row->company_uuid)->toBe('company-1') + ->and(fleetopsProfileAccountRoles($connection, $driver->uuid, 'company-1'))->toBe(['Driver']) + ->and($driver->getRelation('companyUser'))->toBeInstanceOf(CompanyUser::class); + + // A nameless customer in a company without a timezone gets a generated + // username, a random password, the default status and the server timezone + $customer = ProfileAccountManager::resolveForProfile('company-2', 'customer', null, null, '+6590002222'); + $customerRow = $connection->table('users')->where('uuid', $customer->uuid)->first(); + + expect($customerRow->type)->toBe('customer') + ->and($customerRow->status)->toBe('pending') + ->and($customerRow->name)->toBeNull() + ->and($customerRow->username)->not->toBeEmpty() + ->and($customerRow->password)->toStartWith('hashed:') + ->and($customerRow->timezone)->toBe(date_default_timezone_get()) + ->and(fleetopsProfileAccountRoles($connection, $customer->uuid, 'company-2'))->toBe(['Fleet-Ops Customer']); + + // A contact of an organization missing from the table is created without membership + $contact = ProfileAccountManager::createManagedAccount('company-missing', 'contact', 'Carl Contact', 'carl@example.com', null, ['timezone' => 'UTC']); + + expect($connection->table('users')->where('uuid', $contact->uuid)->value('type'))->toBe('contact') + ->and($connection->table('users')->where('uuid', $contact->uuid)->value('timezone'))->toBe('UTC') + ->and($connection->table('company_users')->where('user_uuid', $contact->uuid)->count())->toBe(0); +}); + +test('resolve for profile links staff members and managed accounts of the same type', function () { + $connection = fleetopsProfileAccountBoot(); + fleetopsProfileAccountRows($connection, 'users', [ + ['uuid' => 'staff-1', 'company_uuid' => 'company-2', 'name' => 'Staff', 'email' => 'staff@example.com', 'type' => 'user'], + ['uuid' => 'driver-1', 'company_uuid' => 'company-2', 'phone' => '+6590001111', 'type' => 'driver'], + ['uuid' => 'driver-2', 'company_uuid' => null, 'phone' => '+6590003333', 'type' => 'driver'], + ]); + // The staff member belongs to company-1 through a membership row + $connection->table('company_users')->insert(['uuid' => 'cu-staff', 'company_uuid' => 'company-1', 'user_uuid' => 'staff-1', 'status' => 'active']); + + $staff = ProfileAccountManager::resolveForProfile('company-1', 'driver', 'Other Name', 'STAFF@example.com', null); + $driver = ProfileAccountManager::resolveForProfile('company-1', 'driver', 'Driver', null, '+6590001111'); + $orphan = ProfileAccountManager::resolveForProfile('company-1', 'driver', 'Orphan', null, '+6590003333'); + + expect($staff->uuid)->toBe('staff-1') + ->and($staff->type)->toBe('user') + ->and($connection->table('model_has_roles')->where('model_uuid', 'cu-staff')->count())->toBe(0) + ->and($driver->uuid)->toBe('driver-1') + ->and(fleetopsProfileAccountRoles($connection, 'driver-1', 'company-1'))->toBe(['Driver']) + // The account keeps its original organization + ->and($connection->table('users')->where('uuid', 'driver-1')->value('company_uuid'))->toBe('company-2') + // An account without an organization adopts this one + ->and($orphan->uuid)->toBe('driver-2') + ->and($connection->table('users')->where('uuid', 'driver-2')->value('company_uuid'))->toBe('company-1') + ->and($connection->table('users')->count())->toBe(3); +}); + +test('resolve for profile rejects accounts that cannot hold the profile', function () { + $connection = fleetopsProfileAccountBoot(); + fleetopsProfileAccountRows($connection, 'users', [ + ['uuid' => 'staff-2', 'company_uuid' => 'company-2', 'email' => 'outsider@example.com', 'type' => 'admin'], + ['uuid' => 'customer-1', 'company_uuid' => 'company-1', 'phone' => '+6590001111', 'type' => 'customer'], + ['uuid' => 'driver-1', 'company_uuid' => 'company-1', 'email' => 'driver@example.com', 'type' => 'driver'], + ['uuid' => 'contact-1', 'company_uuid' => 'company-1', 'email' => 'contact@example.com', 'type' => 'contact'], + ]); + $connection->table('drivers')->insert(['uuid' => 'd-1', 'company_uuid' => 'company-1', 'user_uuid' => 'driver-1']); + $connection->table('contacts')->insert(['uuid' => 'c-1', 'company_uuid' => 'company-1', 'user_uuid' => 'contact-1', 'type' => 'contact']); + + $conflict = function (callable $callback): ?ProfileIdentityConflictException { + try { + $callback(); + } catch (ProfileIdentityConflictException $exception) { + return $exception; + } + + return null; + }; + + $outsider = $conflict(fn () => ProfileAccountManager::resolveForProfile('company-1', 'driver', 'X', 'outsider@example.com', null)); + $wrong = $conflict(fn () => ProfileAccountManager::resolveForProfile('company-1', 'driver', 'X', 'nobody@example.com', '+6590001111')); + $driver = $conflict(fn () => ProfileAccountManager::resolveForProfile('company-1', 'driver', 'X', 'driver@example.com', null)); + $contact = $conflict(fn () => ProfileAccountManager::resolveForProfile('company-1', 'contact', 'X', 'contact@example.com', null)); + + expect($outsider?->getMessage())->toBe('This email is already in use by another account.') + ->and($outsider?->getField())->toBe('email') + ->and($outsider?->getErrors())->toBe(['email' => ['This email is already in use by another account.']]) + ->and($wrong?->getMessage())->toBe('This phone number is already used by a customer.') + ->and($wrong?->getField())->toBe('phone') + ->and($driver?->getMessage())->toBe('A driver with this email already exists.') + ->and($contact?->getMessage())->toBe('A contact with this email already exists.') + ->and(ProfileAccountManager::hasProfileInCompany(User::where('uuid', 'driver-1')->first(), 'company-2', 'driver'))->toBeFalse() + ->and(ProfileAccountManager::isCompanyMember(User::where('uuid', 'driver-1')->first(), null))->toBeFalse() + ->and(new ProfileIdentityConflictException('Taken'))->getField()->toBe('email'); +}); + +test('sync proxy fields pushes normalized changes to managed accounts and only the name to staff', function () { + $connection = fleetopsProfileAccountBoot(); + $connection->table('users')->insert(['uuid' => 'other-1', 'email' => 'taken@example.com', 'phone' => '+6599990000', 'type' => 'user']); + $managed = fleetopsProfileAccountUser(['uuid' => 'managed-1', 'name' => 'Old', 'email' => 'old@example.com', 'phone' => '+6590000000', 'timezone' => 'UTC', 'type' => 'driver']); + $staff = fleetopsProfileAccountUser(['uuid' => 'staff-1', 'name' => 'Staff', 'email' => 'staff@example.com', 'phone' => '+6591111111', 'type' => 'user']); + + expect(ProfileAccountManager::syncProxyFields(null, ['name' => 'Nobody']))->toBeFalse() + // Unchanged values and fields outside the proxy set are ignored + ->and(ProfileAccountManager::syncProxyFields($managed, ['name' => 'Old', 'email' => 'OLD@example.com', 'type' => 'admin', 'timezone' => null]))->toBeFalse(); + + expect(ProfileAccountManager::syncProxyFields($managed, [ + 'name' => 'New', + 'email' => ' New@Example.com ', + 'phone' => '+65 9000 0001', + 'timezone' => 'Asia/Singapore', + 'password' => 'new-secret', + ]))->toBeTrue(); + + $row = $connection->table('users')->where('uuid', 'managed-1')->first(); + expect($row->name)->toBe('New') + ->and($row->email)->toBe('new@example.com') + ->and($row->phone)->toBe('+6590000001') + ->and($row->timezone)->toBe('Asia/Singapore') + ->and($row->password)->toBe('hashed:new-secret'); + + // Clearing the phone is a change the account takes + expect(ProfileAccountManager::syncProxyFields($managed, ['phone' => null]))->toBeTrue() + ->and($connection->table('users')->where('uuid', 'managed-1')->value('phone'))->toBeNull(); + + // A staff account only takes the name + expect(ProfileAccountManager::syncProxyFields($staff, ['name' => 'Staff Driver', 'email' => 'changed@example.com', 'phone' => null]))->toBeTrue() + ->and($connection->table('users')->where('uuid', 'staff-1')->first()) + ->name->toBe('Staff Driver') + ->email->toBe('staff@example.com') + ->phone->toBe('+6591111111'); + + // Taking another account's email or phone is refused + $emailConflict = null; + try { + ProfileAccountManager::syncProxyFields($managed, ['email' => 'Taken@example.com']); + } catch (ProfileIdentityConflictException $exception) { + $emailConflict = $exception; + } + + $phoneConflict = null; + try { + ProfileAccountManager::syncProxyFields($managed, ['email' => 'new@example.com', 'phone' => '+6599990000']); + } catch (ProfileIdentityConflictException $exception) { + $phoneConflict = $exception; + } + + expect($emailConflict?->getMessage())->toBe('This email is already in use by another account.') + ->and($emailConflict?->getField())->toBe('email') + ->and($phoneConflict?->getMessage())->toBe('This phone number is already in use by another account.') + ->and($phoneConflict?->getField())->toBe('phone') + ->and($connection->table('users')->where('uuid', 'managed-1')->value('email'))->toBe('new@example.com'); +}); + +test('release for profile deletes an unused managed account and leaves staff alone', function () { + $connection = fleetopsProfileAccountBoot(); + config()->set('activitylog.enabled', false); + $lonely = fleetopsProfileAccountUser(['uuid' => 'lonely-1', 'type' => 'driver']); + $shared = fleetopsProfileAccountUser(['uuid' => 'shared-1', 'type' => 'contact']); + $staying = fleetopsProfileAccountUser(['uuid' => 'staying-1', 'type' => 'driver']); + $staff = fleetopsProfileAccountUser(['uuid' => 'staff-1', 'type' => 'user']); + $connection->table('contacts')->insert(['uuid' => 'c-2', 'company_uuid' => 'company-2', 'user_uuid' => 'shared-1', 'type' => 'contact']); + $connection->table('drivers')->insert(['uuid' => 'd-1', 'company_uuid' => 'company-1', 'user_uuid' => 'staying-1']); + $connection->table('company_users')->insert([ + ['uuid' => 'cu-1', 'company_uuid' => 'company-1', 'user_uuid' => 'shared-1'], + ['uuid' => 'cu-2', 'company_uuid' => 'company-2', 'user_uuid' => 'shared-1'], + ['uuid' => 'cu-3', 'company_uuid' => 'company-1', 'user_uuid' => 'staying-1'], + ]); + + ProfileAccountManager::releaseForProfile(null, 'company-1'); + ProfileAccountManager::releaseForProfile($staff, 'company-1'); + ProfileAccountManager::releaseForProfile($lonely, 'company-1'); + // Already deleted accounts are skipped + ProfileAccountManager::releaseForProfile($lonely, 'company-1'); + ProfileAccountManager::releaseForProfile($shared, 'company-1'); + ProfileAccountManager::releaseForProfile($staying, 'company-1'); + ProfileAccountManager::releaseForProfile($staying); + + expect($connection->table('users')->where('uuid', 'lonely-1')->value('deleted_at'))->not->toBeNull() + ->and($lonely->trashed())->toBeTrue() + ->and($connection->table('users')->where('uuid', 'staff-1')->value('deleted_at'))->toBeNull() + ->and($connection->table('users')->where('uuid', 'shared-1')->value('deleted_at'))->toBeNull() + // The shared account only leaves the organization it has no profile in + ->and($connection->table('company_users')->whereNull('deleted_at')->where('user_uuid', 'shared-1')->pluck('company_uuid')->all())->toBe(['company-2']) + ->and($connection->table('users')->where('uuid', 'staying-1')->value('deleted_at'))->toBeNull() + ->and($connection->table('company_users')->whereNull('deleted_at')->where('user_uuid', 'staying-1')->count())->toBe(1) + ->and(ProfileAccountManager::profileCompanies($shared)->all())->toBe(['company-2']); +}); + +test('attach to company adds membership once and only re-roles managed accounts', function () { + $connection = fleetopsProfileAccountBoot(); + $managed = fleetopsProfileAccountUser(['uuid' => 'managed-1', 'company_uuid' => 'company-2', 'type' => 'customer']); + $staff = fleetopsProfileAccountUser(['uuid' => 'staff-1', 'company_uuid' => 'company-1', 'type' => 'user']); + $connection->table('company_users')->insert(['uuid' => 'cu-staff', 'company_uuid' => 'company-1', 'user_uuid' => 'staff-1', 'status' => 'active']); + + expect(ProfileAccountManager::attachToCompany($managed, 'company-missing', 'customer'))->toBeNull(); + + $first = ProfileAccountManager::attachToCompany($managed, 'company-1', 'customer'); + $second = ProfileAccountManager::attachToCompany($managed, 'company-1', 'unknown-type'); + $staffMembership = ProfileAccountManager::attachToCompany($staff, 'company-1', 'driver'); + + expect($first->uuid)->toBe($second->uuid) + ->and($connection->table('company_users')->where('user_uuid', 'managed-1')->count())->toBe(1) + // An unknown type falls back to the contact role + ->and(fleetopsProfileAccountRoles($connection, 'managed-1', 'company-1'))->toBe(['Fleet-Ops Contact']) + ->and($staffMembership->uuid)->toBe('cu-staff') + ->and($connection->table('model_has_roles')->where('model_uuid', 'cu-staff')->count())->toBe(0) + ->and($connection->table('users')->where('uuid', 'managed-1')->value('company_uuid'))->toBe('company-2'); +}); + +test('send credentials resets the password activates the account and emails or texts it', function () { + $connection = fleetopsProfileAccountBoot(); + $mail = Illuminate\Support\Facades\Mail::getFacadeRoot(); + $sms = app(SmsService::class); + + $emailUser = fleetopsProfileAccountUser(['uuid' => 'email-1', 'company_uuid' => 'company-1', 'email' => 'driver@example.com', 'status' => 'pending', 'type' => 'driver']); + $driver = new Driver(); + $driver->setRawAttributes(['uuid' => 'd-1', 'company_uuid' => 'company-1', 'user_uuid' => 'email-1'], true); + $driver->setRelation('company', (object) ['name' => 'Acme']); + + expect(ProfileAccountManager::sendCredentials($driver, $emailUser, 'chosen-secret'))->toBe('email') + ->and($connection->table('users')->where('uuid', 'email-1')->value('password'))->toBe('hashed:chosen-secret') + ->and($connection->table('users')->where('uuid', 'email-1')->value('status'))->toBe('active') + ->and($mail->sent[0])->toBeInstanceOf(DriverCredentialsMail::class) + ->and($mail->to[0])->toBe($emailUser); + + // An active account is not re-activated; a random password is generated + $customerUser = fleetopsProfileAccountUser(['uuid' => 'email-2', 'company_uuid' => 'company-1', 'email' => 'customer@example.com', 'status' => 'active', 'type' => 'customer']); + $customer = new Contact(); + $customer->setRawAttributes(['uuid' => 'c-1', 'company_uuid' => 'company-1', 'type' => 'customer'], true); + + expect(ProfileAccountManager::sendCredentials($customer, $customerUser))->toBe('email') + ->and($connection->table('users')->where('uuid', 'email-2')->value('password'))->toStartWith('hashed:') + ->and($mail->sent[1])->toBeInstanceOf(CustomerCredentialsMail::class); + + // Without an email the credentials are texted, naming the organization + $phoneUser = fleetopsProfileAccountUser(['uuid' => 'phone-1', 'phone' => '+6590001111', 'status' => 'active', 'type' => 'driver']); + expect(ProfileAccountManager::deliverCredentials($driver, $phoneUser, 'texted-secret'))->toBe('sms') + ->and($sms->sent[0])->toBe(['+6590001111', 'Your Acme sign-in details. Login: +6590001111 Password: texted-secret']); + + // An organization-less profile falls back to the app name + $orphan = new Driver(); + $orphan->setRawAttributes(['uuid' => 'd-2'], true); + $orphan->setRelation('company', null); + ProfileAccountManager::deliverCredentials($orphan, $phoneUser, 'app-secret'); + expect($sms->sent[1][1])->toBe('Your Fleetbase sign-in details. Login: +6590001111 Password: app-secret'); + + // A refused text surfaces the provider's reason + $sms->result = ['success' => false, 'error' => 'Invalid number']; + expect(fn () => ProfileAccountManager::deliverCredentials($driver, $phoneUser, 'x')) + ->toThrow(Exception::class, 'The credentials could not be texted: Invalid number'); + + $sms->result = ['success' => false, 'message' => 'Quota exceeded']; + expect(fn () => ProfileAccountManager::deliverCredentials($driver, $phoneUser, 'x')) + ->toThrow(Exception::class, 'The credentials could not be texted: Quota exceeded'); + + $sms->result = ['success' => false]; + expect(fn () => ProfileAccountManager::deliverCredentials($driver, $phoneUser, 'x')) + ->toThrow(Exception::class, 'The credentials could not be texted: the SMS provider refused it.'); + + // A result without a success flag is treated as sent + $sms->result = []; + expect(ProfileAccountManager::deliverCredentials($driver, $phoneUser, 'x'))->toBe('sms'); + + $unreachable = fleetopsProfileAccountUser(['uuid' => 'none-1', 'status' => 'active', 'type' => 'driver']); + expect(fn () => ProfileAccountManager::deliverCredentials($driver, $unreachable, 'x')) + ->toThrow(Exception::class, 'This profile has no email or phone to send credentials to.'); +}); + +test('driver credentials mail names the organization and the sign-in identity', function () { + config()->set('app.name', 'Fleetbase'); + $user = new User(); + $user->setRawAttributes(['name' => 'Dana', 'email' => null, 'phone' => '+6590001111'], true); + + $driver = new Driver(); + $driver->setRelation('company', (object) ['name' => 'Acme']); + + $mail = new DriverCredentialsMail('plain-secret', $driver, $user); + $content = $mail->content(); + + expect($mail->envelope()->subject)->toBe('Your Acme driver sign-in details') + ->and($content->markdown)->toBe('fleetops::mail.driver-credentials') + ->and($content->with)->toMatchArray([ + 'driver' => $driver, + 'user' => $user, + 'companyName' => 'Acme', + 'identity' => '+6590001111', + 'plaintextPassword' => 'plain-secret', + ]); + + $user->setRawAttributes(['name' => 'Dana', 'email' => 'dana@example.com'], true); + $orphan = new Driver(); + $orphan->setRelation('company', null); + $orphanMail = new DriverCredentialsMail('plain-secret', $orphan, $user); + + expect($orphanMail->envelope()->subject)->toBe('Your Fleetbase driver sign-in details') + ->and($orphanMail->content()->with)->toMatchArray(['companyName' => 'Fleetbase', 'identity' => 'dana@example.com']); + + $view = file_get_contents(__DIR__ . '/../../../resources/views/mail/driver-credentials.blade.php'); + expect($view)->toContain('{{ $identity }}') + ->toContain('{{ $plaintextPassword }}') + ->toContain('{{ $companyName }}'); +}); diff --git a/tests/integration/components/customer/form-test.js b/tests/integration/components/customer/form-test.js index 907fee6e8..9e08661ef 100644 --- a/tests/integration/components/customer/form-test.js +++ b/tests/integration/components/customer/form-test.js @@ -49,14 +49,24 @@ module('Integration | Component | customer/form', function (hooks) { this.owner.register('component:model-select', setComponentTemplate(hbs`
`, ModelSelectStub)); }); - test('the user account selector only offers customer users without a contact', async function (assert) { - this.set('customer', makeResource({ isNew: true })); + test('it does not offer a user account selector', async function (assert) { + this.set('customer', makeResource({ isNew: false })); await render(hbs``); - const userSelect = modelSelectQueries.find((entry) => entry.modelName === 'user'); + assert.notOk( + modelSelectQueries.find((entry) => entry.modelName === 'user'), + 'login accounts are managed by the server' + ); + }); + + test('email and phone are read-only for a staff-linked customer', async function (assert) { + this.set('customer', makeResource({ isNew: false, is_staff_linked: true, email: 'staff@example.com' })); + + await render(hbs``); - assert.deepEqual(userSelect.query, { doesnt_have_contact: true, is_customer: true }); + assert.dom('input[placeholder="Email"]').isDisabled(); + assert.dom('[data-test-staff-linked-helper]').exists(); }); test('the welcome email option is offered when creating a customer with the portal installed', async function (assert) { diff --git a/tests/integration/components/modals/reset-customer-credentials-test.js b/tests/integration/components/modals/reset-customer-credentials-test.js deleted file mode 100644 index a1152f024..000000000 --- a/tests/integration/components/modals/reset-customer-credentials-test.js +++ /dev/null @@ -1,26 +0,0 @@ -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 | modals/reset-customer-credentials', function (hooks) { - setupRenderingTest(hooks); - - test('it renders', async function (assert) { - // Set any properties with this.set('myProperty', 'value'); - // Handle any actions with this.set('myAction', function(val) { ... }); - - await render(hbs``); - - assert.dom().hasText(''); - - // Template block usage: - await render(hbs` - - template block text - - `); - - assert.dom().hasText('template block text'); - }); -}); diff --git a/tests/integration/components/modals/reset-profile-credentials-test.js b/tests/integration/components/modals/reset-profile-credentials-test.js new file mode 100644 index 000000000..cb24840c7 --- /dev/null +++ b/tests/integration/components/modals/reset-profile-credentials-test.js @@ -0,0 +1,61 @@ +import Service from '@ember/service'; +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +let posted; + +class FetchStub extends Service { + post(endpoint, payload, options) { + posted = { endpoint, payload, options }; + return Promise.resolve({ status: 'ok', driver: { user_uuid: 'user-1', is_staff_linked: false, login_status: 'active' } }); + } +} + +class NotificationsStub extends Service { + success() {} + serverError() {} +} + +module('Integration | Component | modals/reset-profile-credentials', function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + posted = null; + this.owner.register('service:fetch', FetchStub); + this.owner.register('service:notifications', NotificationsStub); + }); + + test('it posts the new password with the configured endpoint and payload', async function (assert) { + const profile = { + id: 'driver-1', + name: 'Alex Driver', + setProperties(values) { + Object.assign(this, values); + }, + }; + + this.set('options', { + profile, + endpoint: 'drivers/driver-1/reset-credentials', + payload: (subject) => ({ subject: subject.id }), + }); + + await render(hbs``); + + const component = this.options; + await component.confirm({ + startLoading: () => assert.step('loading'), + stopLoading: () => assert.step('stopped'), + done: () => assert.step('done'), + }); + + assert.strictEqual(posted.endpoint, 'drivers/driver-1/reset-credentials'); + assert.strictEqual(posted.payload.subject, 'driver-1'); + assert.true(posted.payload.send_credentials, 'sends credentials by default'); + assert.deepEqual(posted.options, { namespace: 'int/v1' }); + assert.strictEqual(profile.login_status, 'active', 'applies the login status from the response'); + assert.verifySteps(['loading', 'done']); + }); +}); diff --git a/tests/unit/services/driver-actions-test.js b/tests/unit/services/driver-actions-test.js index aafdb64dc..adba110fb 100644 --- a/tests/unit/services/driver-actions-test.js +++ b/tests/unit/services/driver-actions-test.js @@ -128,4 +128,57 @@ module('Unit | Service | driver-actions', function (hooks) { await service.unassignOrders({ id: 'driver-1', name: 'Alex Driver' }); }); + + test('account actions are hidden without a managed login', function (assert) { + const service = this.owner.lookup('service:driver-actions'); + service.intl = { t: (key) => key }; + + assert.deepEqual(service.accountMenuItems({ id: 'driver-1', user_uuid: null }), [], 'no login account'); + assert.deepEqual(service.accountMenuItems({ id: 'driver-1', user_uuid: 'user-1', is_staff_linked: true }), [], 'staff-linked login is managed from IAM'); + }); + + test('account actions toggle deactivate/reactivate by login status', function (assert) { + const service = this.owner.lookup('service:driver-actions'); + service.intl = { t: (key) => key }; + + const active = service.accountMenuItems({ id: 'driver-1', user_uuid: 'user-1', login_status: 'active' }).map((item) => item.text); + assert.deepEqual(active, [undefined, 'profile-account.actions.reset-password', 'profile-account.actions.send-credentials', 'profile-account.actions.deactivate-login']); + + const inactive = service.accountMenuItems({ id: 'driver-1', user_uuid: 'user-1', login_status: 'inactive' }).map((item) => item.text); + assert.true(inactive.includes('profile-account.actions.reactivate-login')); + assert.false(inactive.includes('profile-account.actions.deactivate-login')); + }); + + test('deactivateLogin posts to the driver endpoint and applies the response', async function (assert) { + const service = this.owner.lookup('service:driver-actions'); + const driver = { + id: 'driver-1', + name: 'Alex Driver', + user_uuid: 'user-1', + login_status: 'active', + setProperties(values) { + Object.assign(this, values); + }, + }; + let confirmOptions; + + service.intl = { t: (key) => key }; + service.notifications = { success: () => assert.step('success'), serverError: () => assert.ok(false, 'unexpected call') }; + service.fetch = { + post: async (url, payload, options) => { + assert.strictEqual(url, 'drivers/driver-1/deactivate-login'); + assert.deepEqual(payload, {}); + assert.deepEqual(options, { namespace: 'int/v1' }); + + return { status: 'ok', driver: { id: 'driver-1', user_uuid: 'user-1', is_staff_linked: false, login_status: 'inactive' } }; + }, + }; + service.modalsManager = { confirm: (options) => (confirmOptions = options) }; + + service.deactivateLogin(driver); + await confirmOptions.confirm({ startLoading() {}, stopLoading() {}, done: () => assert.step('done') }); + + assert.strictEqual(driver.login_status, 'inactive'); + assert.verifySteps(['success', 'done']); + }); }); diff --git a/translations/en-us.yaml b/translations/en-us.yaml index 1996dca4c..52c01f38f 100644 --- a/translations/en-us.yaml +++ b/translations/en-us.yaml @@ -801,11 +801,9 @@ driver: fields: avatar: Avatar driver-details: Driver Details - select-user: Select User select-vehicle: Select Vehicle upload-new-photo: Upload new photo user-account: User Account - user-account-help-text: Each driver profile needs to be connected to a specific user account. This association is essential for handling the authentication process, ensuring that each driver can securely access and manage their profile. vendor: Vendor select-vendor: Select Vendor driver-license: Drivers License @@ -837,6 +835,40 @@ driver: unassign-vehicle-body: This removes {driverName} from {vehicleName}. Order assignments are not changed. unassign-vehicle-success: Unassigned {vehicleName} from {driverName}. issue-title: Issue reported for {driverName} + +profile-account: + staff-linked-helper: Linked to a team member account — manage email and phone from IAM. + will-link-to-staff: Will be linked to existing team member {name}. + actions: + reset-password: Reset Password + send-credentials: Send Credentials + deactivate-login: Deactivate Login + reactivate-login: Reactivate Login + convert-to-vendor: Convert to Vendor + prompts: + send-credentials-title: Send Login Credentials + send-credentials-body: Generate a new temporary password and send {name} their login credentials. + send-credentials-success: Login credentials sent to {name}. + deactivate-login-body: Deactivate the login for {name}. Their profile and history will be preserved. + deactivate-login-success: Login deactivated for {name}. + reactivate-login-body: Reactivate the login for {name}. + reactivate-login-success: Login reactivated for {name}. + send-portal-credentials-title: Send Portal Credentials + send-portal-credentials-body: Generate a new temporary password and email this contact their portal credentials. + send-portal-credentials-success: Portal credentials sent. + deactivate-portal-login-body: Deactivate portal access for this contact. Their profile and history will be preserved. + deactivate-portal-login-success: Portal login deactivated. + reactivate-portal-login-body: Reactivate portal access for this contact. + reactivate-portal-login-success: Portal login reactivated. + reset-password-title: Reset Login Credentials + reset-password-heading: You are about to reset the password for {name} + reset-password-body: Please enter the new password and confirm it below. You can optionally send the new credentials by selecting the checkbox. + reset-password-accept: Reset Credentials + reset-password-success: Password reset. + new-password: New Password + confirm-new-password: Confirm New Password + send-new-credentials: Send the new credentials to this account + device: attachment: title: Attached asset From 0055e88adddc9241b9401ee0a96ccaed704460a8 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 22 Sep 2026 01:13:23 +0800 Subject: [PATCH 07/19] test: restore request authorization coverage and cover orphaned telemetry runs The adoption test rewrite had dropped the unrelated 'auth can resolves real permissions' test, which was the only coverage for several request authorize() lines. This restores it, and adds an explicit case for finishing a sync run whose connection was removed. --- .../Http/AfaqyRealtimeIngestionTest.php | 5 ++ ...iverControllerExistingUserAdoptionTest.php | 61 +++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/server/tests/Feature/Http/AfaqyRealtimeIngestionTest.php b/server/tests/Feature/Http/AfaqyRealtimeIngestionTest.php index 3b1e8960b..5a56b5a40 100644 --- a/server/tests/Feature/Http/AfaqyRealtimeIngestionTest.php +++ b/server/tests/Feature/Http/AfaqyRealtimeIngestionTest.php @@ -596,6 +596,11 @@ public function normalizeTelemetrySnapshot(array $payload): array DB::table('telematic_deliveries')->where('uuid', $id)->update(['status' => 'quarantined', 'applied' => 2, 'failed' => 1]); Inbox::finishRun('run-pending'); expect((array) DB::table('telematic_sync_runs')->first())->toMatchArray(['status' => 'partial', 'applied' => 2, 'failed' => 1]); + + // A finished run whose connection was removed completes without touching connection meta + DB::table('telematic_sync_runs')->insert(['uuid' => 'run-orphan', 'telematic_uuid' => 'missing-connection', 'status' => 'ingesting', 'created_at' => now(), 'updated_at' => now()]); + Inbox::finishRun('run-orphan'); + expect(DB::table('telematic_sync_runs')->where('uuid', 'run-orphan')->value('status'))->not->toBe('ingesting'); }); test('telemetry diagnostics report setup receipt backlog and degraded delivery states', function () { diff --git a/server/tests/Feature/Http/Internal/DriverControllerExistingUserAdoptionTest.php b/server/tests/Feature/Http/Internal/DriverControllerExistingUserAdoptionTest.php index b8b8bfdb4..38aa20dd3 100644 --- a/server/tests/Feature/Http/Internal/DriverControllerExistingUserAdoptionTest.php +++ b/server/tests/Feature/Http/Internal/DriverControllerExistingUserAdoptionTest.php @@ -538,3 +538,64 @@ public function needsRehash($hashedValue, array $options = []): bool expect($validationFailure->getData(true))->toBe(['error' => ['password' => ['The password is too weak.']]]); }); + +test('auth can resolves real permissions for requests and ping guards', function () { + $connection = fleetopsDriverAdoptionBoot([]); + $connection->table('users')->insert(['uuid' => '77777777-7777-4777-8777-777777777701', 'public_id' => 'user_authcan1', 'company_uuid' => 'company-1', 'name' => 'Permitted User', 'type' => 'user']); + $connection->table('company_users')->insert(['uuid' => '77777777-7777-4777-8777-777777777702', 'company_uuid' => 'company-1', 'user_uuid' => '77777777-7777-4777-8777-777777777701', 'status' => 'active']); + $connection->table('permissions')->insert([ + ['name' => 'fleet-ops update driver', 'guard_name' => 'sanctum'], + ['name' => 'fleet-ops update order', 'guard_name' => 'sanctum'], + ['name' => 'fleet-ops create driver', 'guard_name' => 'sanctum'], + ]); + $companyUserMorph = (new Fleetbase\Models\CompanyUser())->getMorphClass(); + $permissionIds = $connection->table('permissions')->pluck('id', 'name'); + $connection->table('model_has_permissions')->insert([ + ['permission_id' => $permissionIds['fleet-ops update driver'], 'model_type' => $companyUserMorph, 'model_uuid' => '77777777-7777-4777-8777-777777777702'], + ['permission_id' => $permissionIds['fleet-ops update order'], 'model_type' => $companyUserMorph, 'model_uuid' => '77777777-7777-4777-8777-777777777702'], + ['permission_id' => $permissionIds['fleet-ops create driver'], 'model_type' => $companyUserMorph, 'model_uuid' => '77777777-7777-4777-8777-777777777702'], + ]); + session(['company' => 'company-1', 'user' => '77777777-7777-4777-8777-777777777701']); + + // Granted permissions authorize, missing permissions deny + expect(Fleetbase\Support\Auth::can('fleet-ops update driver'))->toBeTrue() + ->and(Fleetbase\Support\Auth::can('fleet-ops delete driver'))->toBeFalse(); + + // The update driver form request authorizes through the same gate + $updateRequest = new Fleetbase\FleetOps\Http\Requests\Internal\UpdateDriverRequest(); + expect($updateRequest->authorize())->toBeTrue(); + + // Create-driver is granted, create-order-config was never granted, so the + // two internal create requests resolve opposite ways through one gate + expect((new Fleetbase\FleetOps\Http\Requests\Internal\CreateDriverRequest())->authorize())->toBeTrue() + ->and((new Fleetbase\FleetOps\Http\Requests\Internal\CreateOrderConfigRequest())->authorize())->toBeFalse(); + + // Fleet action requests resolve their permission through the same gate + $fleetRequest = Fleetbase\FleetOps\Http\Requests\Internal\FleetActionRequest::create('/int/v1/fleets/assign-vehicle', 'POST'); + $canMethod = new ReflectionMethod(Fleetbase\FleetOps\Http\Requests\Internal\FleetActionRequest::class, 'can'); + $canMethod->setAccessible(true); + expect($canMethod->invoke($fleetRequest, 'fleet-ops update driver'))->toBeTrue() + ->and($canMethod->invoke($fleetRequest, 'fleet-ops assign-vehicle-for fleet'))->toBeFalse(); + + // Driver ping authorization resolves through the order permission + $orderController = new Fleetbase\FleetOps\Http\Controllers\Internal\v1\OrderController(); + $canPing = new ReflectionMethod($orderController, 'canPingDriver'); + $canPing->setAccessible(true); + expect($canPing->invoke($orderController))->toBeTrue(); + + // Creating an order is gated on its own permission, which was never granted + expect((new Fleetbase\FleetOps\Http\Requests\Internal\CreateOrderRequest())->authorize())->toBeFalse(); + + // Global search consults the same gate per result type. This user is not an + // admin and holds no `see` permissions, so every requested type is skipped + // and nothing is searched rather than leaking unpermitted records + $searchController = new Fleetbase\FleetOps\Http\Controllers\Internal\v1\SearchController(); + $searchResponse = $searchController->search(Request::create('/int/v1/search', 'GET', [ + 'query' => 'anything', + 'types' => 'orders,drivers', + ])); + + expect($searchResponse->getData(true))->toBe(['results' => []]); + + session(['user' => null]); +}); From e709e71a578bf1c70fe5b6b5436688e9d9d188f9 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 22 Sep 2026 01:14:41 +0800 Subject: [PATCH 08/19] fix: don't log from the profile account conversion migration Migrations can run as a user that can't write the application log. The Postman contract job failed on exactly that. --- ...profile_only_users_to_managed_accounts.php | 8 +------ ...ProfileOnlyUserConversionMigrationTest.php | 23 ++----------------- 2 files changed, 3 insertions(+), 28 deletions(-) diff --git a/server/migrations/2026_09_21_000001_convert_profile_only_users_to_managed_accounts.php b/server/migrations/2026_09_21_000001_convert_profile_only_users_to_managed_accounts.php index 8d4579d78..4381ebcd1 100644 --- a/server/migrations/2026_09_21_000001_convert_profile_only_users_to_managed_accounts.php +++ b/server/migrations/2026_09_21_000001_convert_profile_only_users_to_managed_accounts.php @@ -2,7 +2,6 @@ use Illuminate\Database\Migrations\Migration; use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Schema; /* @@ -31,8 +30,6 @@ public function up(): void return; } - $converted = ['driver' => 0, 'customer' => 0]; - DB::table('users') ->select(['uuid', 'meta']) ->where('type', 'user') @@ -48,7 +45,7 @@ public function up(): void $query->selectRaw(1)->from('companies')->whereColumn('companies.owner_uuid', 'users.uuid'); }) ->orderBy('uuid') - ->chunk(500, function ($users) use (&$converted) { + ->chunk(500, function ($users) { foreach ($users as $user) { $type = $this->managedTypeFor($user->uuid); if (!$type) { @@ -60,11 +57,8 @@ public function up(): void $meta['previous_type'] = 'user'; DB::table('users')->where('uuid', $user->uuid)->update(['type' => $type, 'meta' => json_encode($meta)]); - $converted[$type]++; } }); - - Log::info('[fleetops] Converted profile-only user accounts to managed accounts.', $converted); } public function down(): void diff --git a/server/tests/Feature/Http/Api/ProfileOnlyUserConversionMigrationTest.php b/server/tests/Feature/Http/Api/ProfileOnlyUserConversionMigrationTest.php index 9a69a6196..110354bdb 100644 --- a/server/tests/Feature/Http/Api/ProfileOnlyUserConversionMigrationTest.php +++ b/server/tests/Feature/Http/Api/ProfileOnlyUserConversionMigrationTest.php @@ -4,7 +4,6 @@ use Illuminate\Database\Eloquent\Model as EloquentModel; use Illuminate\Database\SQLiteConnection; use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Schema; /** @@ -46,20 +45,6 @@ public function __call($method, $arguments) DB::clearResolvedInstance('db'); Schema::clearResolvedInstance('db.schema'); - $GLOBALS['fleetopsProfileConversionLogs'] = []; - app()->instance('log', new class { - public function info($message, array $context = []): void - { - $GLOBALS['fleetopsProfileConversionLogs'][] = [$message, $context]; - } - - public function __call($method, $arguments) - { - return null; - } - }); - Log::clearResolvedInstance('log'); - $schema = $connection->getSchemaBuilder(); $tables = [ 'users' => ['uuid', 'type', 'meta'], @@ -169,10 +154,7 @@ function fleetopsProfileConversionTypes(SQLiteConnection $connection): array ]) // The previous type is recorded next to any existing meta ->and(json_decode($connection->table('users')->where('uuid', 'a-driver')->value('meta'), true))->toBe(['source' => 'console', 'previous_type' => 'user']) - ->and(json_decode($connection->table('users')->where('uuid', 'm-bad-meta')->value('meta'), true))->toBe(['previous_type' => 'user']) - ->and($GLOBALS['fleetopsProfileConversionLogs'])->toBe([ - ['[fleetops] Converted profile-only user accounts to managed accounts.', ['driver' => 2, 'customer' => 1]], - ]); + ->and(json_decode($connection->table('users')->where('uuid', 'm-bad-meta')->value('meta'), true))->toBe(['previous_type' => 'user']); }); test('down reverts only the accounts up converted', function () { @@ -207,8 +189,7 @@ function fleetopsProfileConversionTypes(SQLiteConnection $connection): array $migration = fleetopsProfileConversionMigration(); $migration->up(); - expect(fleetopsProfileConversionTypes($connection))->toBe(['a-driver' => 'user']) - ->and($GLOBALS['fleetopsProfileConversionLogs'])->toBe([]); + expect(fleetopsProfileConversionTypes($connection))->toBe(['a-driver' => 'user']); $connection->table('users')->where('uuid', 'a-driver')->update(['type' => 'driver', 'meta' => json_encode(['previous_type' => 'user'])]); $connection->getSchemaBuilder()->drop('users'); From 593f1e3098a48c3741d0c3664abc06e0bdeb268a Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 22 Sep 2026 12:21:44 +0800 Subject: [PATCH 09/19] fix: repair driver and customer memberships with their profile role FixDriverCompanies and FixCustomerCompanies called assignCompany() without a role. That used core's default, the Administrator role, so repairing a missing membership gave drivers and customers full organization access. They now pass Driver and Fleet-Ops Customer. Core no longer has a default role (fleetbase/core-api#266). --- server/src/Console/Commands/FixCustomerCompanies.php | 2 +- server/src/Console/Commands/FixDriverCompanies.php | 2 +- server/tests/CommandContractsTest.php | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/server/src/Console/Commands/FixCustomerCompanies.php b/server/src/Console/Commands/FixCustomerCompanies.php index bd3d55436..149176df0 100644 --- a/server/src/Console/Commands/FixCustomerCompanies.php +++ b/server/src/Console/Commands/FixCustomerCompanies.php @@ -68,7 +68,7 @@ public function handle() $this->line('Found user ' . $user->name . ' (' . $user->email . ') which doesnt have correct company assignment.'); $company = $this->companyByUuid($customer->company_uuid); if ($company) { - $user->assignCompany($company); + $user->assignCompany($company, 'Fleet-Ops Customer'); $this->line('User ' . $user->email . ' was assigned to company: ' . $company->name); } } diff --git a/server/src/Console/Commands/FixDriverCompanies.php b/server/src/Console/Commands/FixDriverCompanies.php index 182b7a546..279a8aa4b 100644 --- a/server/src/Console/Commands/FixDriverCompanies.php +++ b/server/src/Console/Commands/FixDriverCompanies.php @@ -49,7 +49,7 @@ public function handle() $this->line('Found driver ' . $user->name . ' (' . $user->email . ') which doesnt have correct company assignment.'); $company = $this->companyByUuid($driver->company_uuid); if ($company) { - $user->assignCompany($company); + $user->assignCompany($company, 'Driver'); $this->line('Driver ' . $user->email . ' was assigned to company: ' . $company->name); } } diff --git a/server/tests/CommandContractsTest.php b/server/tests/CommandContractsTest.php index efad87823..b9c808bbe 100644 --- a/server/tests/CommandContractsTest.php +++ b/server/tests/CommandContractsTest.php @@ -884,7 +884,7 @@ public function syncProperty(string $property, Model $model): bool return true; } - public function assignCompany(Company $company, string $role = 'Administrator'): self + public function assignCompany(Company $company, ?string $role = null): self { $this->assigned[] = [$company->uuid, $role]; @@ -2208,8 +2208,8 @@ public function getNearbyDriversForOrder(Order $order, Point $pickup, int $dista ['email', FleetOpsCustomerCommandFake::class], ['phone', FleetOpsCustomerCommandFake::class], ]) - ->and($createdUser->assigned)->toBe([['company-created', 'Administrator']]) - ->and($existingUser->assigned)->toBe([['company-existing', 'Administrator']]); + ->and($createdUser->assigned)->toBe([['company-created', 'Fleet-Ops Customer']]) + ->and($existingUser->assigned)->toBe([['company-existing', 'Fleet-Ops Customer']]); }); test('fix driver companies command syncs assigned users and missing company assignments', function () { @@ -2236,7 +2236,7 @@ public function getNearbyDriversForOrder(Order $order, Point $pickup, int $dista ['email', Driver::class], ['phone', Driver::class], ]) - ->and($user->assigned)->toBe([['driver-company', 'Administrator']]) + ->and($user->assigned)->toBe([['driver-company', 'Driver']]) ->and($command->messages)->toContain(['line', 'Found driver Driver User (driver@example.test) which doesnt have correct company assignment.']) ->and($command->messages)->toContain(['line', 'Driver driver@example.test was assigned to company: Driver Company']); }); From 873189797c158943e6f5642ca4a0c8689100c4ef Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 22 Sep 2026 13:38:41 +0800 Subject: [PATCH 10/19] style(login): use ember-ui's btn-auth for the login page button The button was a link-style button with its border and padding on the wrapper and a 50% fade on hover. It is now a default block button with btn-auth, so it matches the console's "Continue with ..." buttons, including their hover, in light and dark. Needs @fleetbase/ember-ui with btn-auth. With an older ember-ui it falls back to a plain default block button. --- addon/extension.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/addon/extension.js b/addon/extension.js index a1a05121f..bcf5b4602 100644 --- a/addon/extension.js +++ b/addon/extension.js @@ -115,8 +115,10 @@ export default { route: 'virtual', slug: 'track-order', icon: 'barcode', - type: 'link', - wrapperClass: 'btn-block py-1 border dark:border-gray-700 border-gray-200 hover:opacity-50', + type: 'default', + // btn-auth (ember-ui): the sign-in page's neutral button, matching the + // console's "Continue with ..." provider buttons, hover included. + wrapperClass: 'btn-block btn-auth', component: new ExtensionComponent('@fleetbase/fleetops-engine', 'order-tracking-lookup'), onClick: (menuItem) => { universe.transitionMenuItem('virtual', menuItem); From b9d4890c7a60c850a6c55255cab98374a66b6a0b Mon Sep 17 00:00:00 2001 From: ispdomnet <95652995+ispdomnet@users.noreply.github.com> Date: Tue, 22 Sep 2026 09:32:31 +0300 Subject: [PATCH 11/19] Add Ukrainian translation --- translations/uk-ua.yaml | 3471 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 3471 insertions(+) create mode 100644 translations/uk-ua.yaml diff --git a/translations/uk-ua.yaml b/translations/uk-ua.yaml new file mode 100644 index 000000000..21a479c81 --- /dev/null +++ b/translations/uk-ua.yaml @@ -0,0 +1,3471 @@ +fleet-ops: + extension-name: Fleet-Ops + +common: + last-modified: Останні зміни + "yes": "Так" + "no": "Ні" + proof-of-delivery: Підтвердження доставки + ad-hoc: Ad-Hoc (на вимогу) + bulk-dispatch: Масова відправка + dispatch-orders: Відправити замовлення + dispatch-order: Відправити замовлення + assign-driver: Призначити водія + unassign-driver: Скасувати призначення водія + assign-drivers: Призначити водіїв + no-driver-assigned: Водія не призначено + no-vehicle-assigned: ТЗ не призначено + details: Деталі + name: Назва + title: Заголовок + email: Електронна пошта + phone: Телефон + internal-id: Внутрішній ID + type: Тип + address: Адреса + create-order: Створити замовлення + coordinates: Координати + city: Місто + country: Країна + status: Статус + upload-new-photo: Завантажити нове фото + resource-actions: >- + Дії з {resource} + resource-location: >- + Розташування {resource} + search-countries: Пошук країн + select-resource-filter-by: Виберіть {resource} для фільтрації + table-view: Табличний вигляд + grid-view: Сітка + calendar-view: Календарний вигляд + change-layout: Змінити вигляд + livemap: Онлайн-карта + tracking: Відстеження + activity: Активність + eta: Очікуваний час прибуття (ETA) + position: Позиція + positions: Позиції + pos: POS + edit-address: Редагувати адресу + new-address: Нова адреса + select-address: Вибрати адресу + save: Зберегти + save-changes: Зберегти зміни + done: Готово + delete: Видалити + overview: Огляд + notes: Примітки + schedule: Розклад + optional: Необов'язково + unauthorized-access: У вас немає доступу до цього ресурсу. + online: В мережі + offline: Поза мережею + refresh: Оновити + loading-route: Завантаження маршруту... + hover-to-load-route: Завантаження попереднього перегляду маршруту... + unable-to-load-route: Не вдалося завантажити попередній перегляд маршруту. + +menu: + operations: Операції + dashboard: Панель керування + radar: Радар + orders: Замовлення + service-rates: Тарифи на послуги + scheduler: Планувальник + order-config: Конфігурація замовлень + resources: Ресурси + drivers: Водії + vehicles: Транспортні засоби + trailers: Причепи + fleets: Автопарки + vendors: Постачальники + contacts: Контакти + places: Місця + fuel-reports: Звіти про пальне + fuel-transactions: Транзакції пального + issues: Проблеми + maintenance: Технічне обслуговування + schedules: Графіки + work-orders: Заявки на роботу + inspection-forms: 'Форми інспекції' + inspections: 'Інспекції' + equipment: Обладнання + parts: Запчастини + maintenance-history: Історія ТО + connectivity: Підключення + telematics: Телематика + fuel-providers: Інтеграції пального + devices: Пристрої + sensors: Сенсори + events: Події + tracking: Відстеження + analytics: Аналітика + reports: Звіти + settings: Налаштування + navigator-app: Додаток Navigator + payments: Платежі + notifications: Сповіщення + routing: Маршрутизація + map: Карта + custom-fields: Настроювані поля + order-board: Дошка замовлень + avatars: Аватари + scheduling: Планування + maintenances: Технічні обслуговування + orchestrator: Оркестратор + orchestrator-settings: Налаштування оркестратора + +trailer: + navigation-description: Керування буксируваними активами, з'єднаннями, обладнанням та телематикою. + sections: + details: Деталі + identification: Ідентифікація причепа + classification: Класифікація та статус + registration: Реєстрація + location: Розташування + dimensions: Габарити та вантажопідйомність + measurement: Вимірювання та використання + dimensions-fields: "Габарити ({unit})" + weight: "Вага ({unit})" + running-gear: Осі, гальма та зчіпка + refrigeration: Охолодження + ownership: Власність та фінанси + finance: Фінансові деталі + connection: Зчіпний пристрій + telemetry: Розташування та телематика + notes: Примітки та історія + fields: + photo: Фото + id: ID + name: Назва + code: Код + description: Опис + type: Тип причепа + body-type: Тип кузова + color: Колір + status: Статус + category: Категорія + vin: VIN-код + plate-number: Номерний знак + serial-number: Серійний номер + make: Марка + model: Модель + year: Рік + vendor: Постачальник + warranty: Гарантія + vehicle: Тягач + attachment-state: Стан підключення + attached-at: Приєднано з + position: Позиція зчіпки + source: Джерело + connected-at: З'єднано о + disconnected-at: Від'єднано о + duration: Тривалість + connectivity: Підключення + last-online: Вересні в мережі + last-provider: Провайдер телеметрії + last-event-at: Остання подія телеметрії + coordinates: Координати + speed: Швидкість + heading: Курс + altitude: Висота + devices-count: Приєднані пристрої + equipment-count: Приєднане обладнання + measurement-system: Система вимірювання + odometer: "Одометр / лічильник маточини {unit}" + odometer-unit: Одиниця вимірювання одометра + length: "Довжина ({unit})" + width: "Ширина ({unit})" + height: "Висота ({unit})" + cargo-volume: "Об'єм вантажу ({unit})" + tare-weight: "Маса без навантаження ({unit})" + gvwr: "Повна маса ТЗ ({unit})" + payload-capacity: "Корисне навантаження ({unit})" + axle-count: Осі + tire-count: Шини + door-count: Двері + coupling-type: Тип зчіпки + brake-type: Тип гальм + abs: ABS + ebs: EBS + refrigerated: Рефрижератор + temperature-min: "Мінімальна температура ({unit})" + temperature-max: "Максимальна температура ({unit})" + reefer-hours: Мотогодини рефрижератора + ownership-type: Форма власності + financing-status: Фінансовий статус + currency: Валюта + acquisition-cost: Вартість придбання + current-value: Поточна вартість + insurance-value: Страхова вартість + depreciation-rate: Норма амортизації (% на рік) + purchased-at: Дата купівлі + lease-expires-at: Закінчення оренди/лізингу + notes: Примітки + created-at: Створено + updated-at: Оновлено + placeholders: + filter-vendor: Фільтр за постачальником + name: напр. Рефрижератор 12 + code: Внутрішній код автопарку, напр. TRL-0012 + description: Для чого використовується цей причіп + type: Виберіть тип причепа + status: Виберіть статус + body-type: напр. Закритий, шторний, платформа + color: напр. Білий + category: Виберіть категорію (необов'язково) + vin: 17-значний VIN-код + plate-number: Реєстраційний номер + serial-number: Серійний номер виробника + make: напр. Utility, Schmitz, Wabash + model: напр. 3000R + year: напр. 2024 + vendor: Виберіть постачальника (необов'язково) + warranty: Виберіть гарантію (необов'язково) + vehicle: Виберіть транспортний засіб + trailer: Виберіть причіп + equipment: Виберіть обладнання + filter-vehicle: Фільтр за тягачем + position: "1" + measurement-system: Виберіть систему вимірювання + odometer-unit: Виберіть одиницю + odometer: Поточні показники + length: "0.000" + width: "0.000" + height: "0.000" + cargo-volume: "0.000" + tare-weight: "0.000" + gvwr: "0.000" + payload-capacity: "0.000" + axle-count: напр. 2 + tire-count: напр. 8 + door-count: напр. 2 + coupling-type: Виберіть тип зчіпки + brake-type: Виберіть тип гальм + temperature-min: напр. -20 + temperature-max: naпр. 4 + reefer-hours: Години роботи рефрижератора + ownership-type: Виберіть форму власності + financing-status: напр. Виплачено, в лізингу до 2028 + depreciation-rate: напр. 10 + date: ДД-ММ-РРРР + notes: Внутрішні примітки про цей причіп + help: + photo: Завантажте фото цього причепа. + name: Назва, яка відображається у Fleet-Ops. Обов'язково. + code: Ваш власний код автопарку для цього причепа. + type: Тип визначає відповідні розділи, наприклад, охолодження для рефрижераторів. + status: Статус життєвого циклу. Підключення та зв'язок відстежуються окремо. + body-type: Довільний опис кузова для класифікації та пошуку. + category: Довільне групування, спільне з іншими ресурсами автопарку. + vin: Номер шасі або ідентифікаційний номер ТЗ, вибитий на причепі. + coordinates: Останнє відоме або домашнє місцерозташування. Пристрої телематики оновлюють це автоматично. + measurement-system: Визначає одиниці вимірювання для габаритів, ваги, об'єму та температури. + odometer: Показники одометра або лічильника маточини у вибраних одиницях. + cargo-volume: Корисний об'єм вантажного відсіку причепа. + tare-weight: Вага порожнього причепа. + gvwr: Повна дозволена маса ТЗ з навантаженням. + payload-capacity: Максимальна вага вантажу, яку може перевозити причіп. + coupling-type: Спосіб зчеплення причепа з тягачем. + abs: Встановлено антиблокувальну систему (ABS). + ebs: Встановлено електронну систему гальмування (EBS). + refrigerated: Увімкніть для причепів з контролем температури для запису налаштувань рефрижератора. + temperature: Діапазон робочих температур рефрижератора. + reefer-hours: Зафіксовані мотогодини холодильної установки. + ownership-type: Форма володіння причепом. Деталі лізингу та фінансування відображаються за потреби. + lease-expires-at: Дата закінчення договору оренди, лізингу або фінансування. + vendor: Постачальник або орендодавець, у якого було отримано причіп. + warranty: Гарантійна політика, що покриває цей причіп. + acquisition-cost: Ціна купівлі або капіталізована вартість у вибраній валюті. + depreciation-rate: Річна амортизація, що використовується для оцінки поточної вартості. + attach-vehicle: Причіп буде з'єднано з вибраним ТЗ. Причіп може бути приєднаний лише до одного ТЗ одночасно. + select-trailer: Відображаються лише від'єднані причепи. + select-equipment: Відображається лише непідключене обладнання. + position: Позиція причепа у зчіпці. Позиція 1 — одразу за тягачем; використовуйте 2 або більше для автопоїздів. + units: + length-metric: м + length-imperial: фути + weight-metric: кг + weight-imperial: фунти + volume-metric: м³ + volume-imperial: фути³ + temperature-metric: °C + temperature-imperial: °F + actions: + new: Новий причіп + locate: Показати на карті + attach-vehicle: Приєднати до ТЗ + detach-vehicle: Від'єднати від ТЗ + attach-device: Приєднати пристрій + attach-equipment: Приєднати обладнання + detach-equipment: Від'єднати обладнання + schedule-maintenance: Запланувати ТО + create-work-order: Створити заявку на роботу + log-maintenance: Зареєструвати ТО + prompts: + attach-vehicle-title: "Приєднати {trailerName} до транспортного засобу" + select-vehicle-warning: Виберіть транспортний засіб для приєднання цього причепа. + attach-vehicle-success: "{trailerName} приєднано до {vehicleName}." + detach-vehicle-title: "Від'єднати {trailerName}?" + detach-vehicle-body: "Це завершить поточне з'єднання між {trailerName} та {vehicleName}. З'єднання збережеться в історії причепа." + detach-vehicle-success: "{trailerName} від'єднано від {vehicleName}." + not-attached-warning: "{trailerName} не приєднано до жодного ТЗ." + attach-device-title: "Приєднати пристрій до {trailerName}" + select-device-warning: Виберіть пристрій для приєднання до цього причепа. + attach-device-success: Пристрій приєднано до {trailerName}. + attach-equipment-title: "Приєднати обладнання до {resourceName}" + select-equipment-warning: Виберіть обладнання для приєднання. + attach-equipment-success: "{equipmentName} приєднано до {resourceName}." + detach-equipment-title: "Від'єднати {equipmentName}?" + detach-equipment-body: "{equipmentName} більше не буде закріплено за {resourceName}." + detach-equipment-success: "{equipmentName} від'єднано від {resourceName}." + no-location-warning: "Для {trailerName} ще немає записаного розташування." + photo-upload-error: "Не вдалося завантажити фото: {message}" + empty: + title: Додайте свій перший причіп + description: Причепи відстежують зчеплення, приєднані пристрої та обладнання, оперативну телеметрію та ТО для ваших буксируваних активів. + filtered-title: Жоден причіп не відповідає вашим фільтрам + filtered-description: Змініть параметри пошуку або фільтри, щоб знайти інший причіп. + connections: Історія зчеплень відсутня + connections-description: Приєднайте цей причіп до транспортного засобу, щоб розпочати запис історії зчеплень. + equipment: Немає приєднаного обладнання + equipment-description: Приєднайте до цього причепа таке обладнання, як гидроборти, ремені або рефрижератори. + schedules: Немає графіків ТО + schedules-description: Створіть графік для планування періодичних інспекцій або обслуговування цього причепа. + work-orders: Немає заявок на роботу + work-orders-description: Створіть заявку на роботу для відстеження ремонту або обслуговування цього причепа. + maintenance: Історія ТО відсутня + maintenance-description: Зареєстроване технічне обслуговування для цього причепа відображатиметься тут. + docs: + title: Посібник з причепів + link: Відкрити посібник з причепів + tabs: + overview: Огляд + positions: Позиції + devices: Пристрої + equipment: Обладнання + connections: Історія зчеплень + schedules: Графіки + work-orders: Заявки на роботу + maintenance: Технічне обслуговування + columns: + name: Назва + id: ID + code: Код + type: Тип + plate-number: Номерний знак + status: Статус + vehicle: Тягач + attachment-state: Підключення + connectivity: Зв'язок + last-online: Востаннє в мережі + location: Розташування + devices: Пристрої + equipment: Обладнання + vin: VIN-код + serial-number: Серійний номер + make: Марка + model: Модель + year: Рік + vendor: Постачальник + ownership: Власність + axles: Осі + gvwr: Повна маса ТЗ + payload-capacity: Вантажопідйомність + length: Довжина + refrigerated: Рефрижератор + created-at: Створено + updated-at: Оновлено + filters: + created-between: Створено в період + updated-between: Оновлено в період + last-online-between: Востаннє в мережі в період + connections: + description: Кожен ТЗ, з яким був з'єднаний цей причіп, починаючи з найновіших. + ongoing: Триває + connected: З'єднано {date} + disconnected: Від'єднано {date} + still-attached: Досі приєднано + equipment: + description: Обладнання, що наразі встановлено на цей причіп. + telemetry: + no-location: Розташування не записано + maintenance: + interval: Інтервал + next-due: Наступне ТО + code: Код + subject: Тема + priority: Пріоритет + due: Термін + summary: Сводка + odometer: Одометр + total-cost: Загальна вартість + date: Дата + statuses: + available: Доступний + in_use: В використанні + maintenance: На обслуговуванні + out_of_service: Не експлуатується + retired: Списаний + status-descriptions: + available: Готовий до відправки або приєднання. + in_use: Наразі виконує завдання або приєднаний до ТЗ. + maintenance: Проходить обслуговування, інспекцію або ремонт. + out_of_service: Непридатний до використання до подальшого повідомлення. + retired: Остаточно виведений з активної експлуатації. + attachment: + attached: Приєднано + detached: Від'єднано + no-vehicle: Не приєднано до ТЗ + towed-by: Буксирується + position: Позиція {position} + connectivity: + online: В мережі + recently_offline: Нещодавно поза мережею + offline: Поза мережею + never_connected: Ніколи не підключався + measurement: + metric: Метрична + imperial: Імперська + measurement-descriptions: + metric: Метри, кілограми, кубічні метри та градуси Цельсія. + imperial: Фути, фунти, кубічні фути та градуси Фаренгейта. + odometer-units: + km: Кілометри + mi: Милі + coupling-types: + fifth_wheel: Седельно-зчіпний пристрій + pintle_hook: Буксирувальний гак + ball_hitch: Кульове зчеплення + gooseneck: "Гусяча шия" (Gooseneck) + drawbar: Дишло + other: Інше + brake-types: + air: Пневматичні гальма + electric: Електричні гальма + hydraulic_surge: Гідравлічні інерційні гальма + none: Без гальм + other: Інше + ownership-types: + owned: У власності + leased: В лізингу + financed: В кредиті + rented: В оренді + types: + dry_van: Сухий фургон + reefer: Рефрижератор + flatbed: Бортова платформа + step_deck: Низькорамний причіп (Step deck) + lowboy: Важкий низькорамник (Lowboy) + tanker: Цистерна + bulk: Самоскид/Самоскидний бункер + dump: Самоскид + chassis: Контейнеровозне шасі + curtain_side: Шторний причіп + car_carrier: Автовоз + livestock: Худобовоз + logging: Лісовоз + dolly: Причіп-підкочувальний візок (Dolly) + specialty: Спеціалізований + other: Інше + +resource: + asset: Актив + assets: Активи + trailer: Причіп + trailers: Причепи + activity: Активність + activities: Активності + contact: Контакт + contacts: Контакти + customer-contact: Контакт клієнта + customer-contacts: Контакти клієнтів + customer-vendor: Постачальник клієнта + customer-vendors: Постачальники клієнтів + customer: Клієнт + customers: Клієнти + custom-entity: Настроювана сутність + custom-entities: Настроювані сутності + device-event: Подія пристрою + device-events: Події пристроїв + device: Пристрій + devices: Пристрої + driver: Водій + drivers: Водії + entity: Сутність + entities: Сутності + equipment: Обладнання + equipments: Обладнання + facilitator-contact: Контакт посередника + facilitator-contacts: Контакти посередників + facilitator-customer: Клієнт посередника + facilitator-customers: Клієнти посередників + facilitator-integrated-vendor: Інтегрований постачальник посередника + facilitator-integrated-vendors: Інтегровані постачальники посередників + facilitator-vendor: Постачальник посередника + facilitator-vendors: Постачальники посередників + facilitator: Посередник + facilitators: Посередники + fleet-driver: Водій автопарку + fleet-drivers: Водії автопарку + fleet: Автопарк + fleets: Автопарки + fuel-report: Звіт про пальне + fuel-reports: Звіти про пальне + inspection: Інспекція + inspections: Інспекції + integrated-vendor: Інтегрований постачальник + integrated-vendors: Інтегровані постачальники + issue: Проблема + issues: Проблеми + maintenance: Технічне обслуговування + maintenances: Технічні обслуговування + maintenance-schedule: Графік ТО + maintenance-schedules: Графіки ТО + order-config: Конфігурація замовлень + order-configs: Конфігурації замовлень + order: Замовлення + orders: Замовлення + part: Запчастина + parts: Запчастини + payload: Вантаж + payloads: Вантажі + place: Місце + places: Місця + purchase-rate: Закупівельний тариф + purchase-rates: Закупівельні тарифи + route: Маршрут + routes: Маршрути + sensor: Сенсор + sensors: Сенсори + service-area: Зона обслуговування + service-areas: Зони обслуговування + service-quote-item: Позиція розрахунку вартості послуги + service-quote-items: Позиції розрахунку вартості послуг + service-quote: Розрахунок вартості послуги + service-quotes: Розрахунки вартості послуг + service-rate-fee: Збір за тарифом послуги + service-rate-fees: Збори за тарифами послуг + service-rate-parcel-fee: Збір за посилку за тарифом + service-rate-parcel-fees: Збори за посилки за тарифом + service-rate: Тариф послуги + service-rates: Тарифи послуг + telematic: Телематика + telematics: Телематика + tracking-number: Номер відстеження + tracking-numbers: Номери відстеження + tracking-status: Статус відстеження + tracking-statuses: Статуси відстеження + vehicle-device: Пристрій ТЗ + vehicle-devices: Пристрої ТЗ + vehicle: Транспортний засіб + vehicles: Транспортні засоби + vendor: Постачальник + vendors: Постачальники + warranty: Гарантія + warranties: Гарантії + waypoint: Точка маршруту + waypoints: Точки маршруту + work-order: Заявка на роботу + work-orders: Заявки на роботу + zone: Зона + zones: Зони + +order-status: + created: Створено + ready: Готово + dispatched: Відправлено + started: Розпочато + completed: Завершено + canceled: Скасовано + +column: + target: Ціль + address: Адреса + country: Країна + created-at: Створено + id: ID + internal-id: Внутрішній ID + name: Назва + phone: Телефон + route-type: Тип маршруту + status: Статус + type: Тип + updated-at: Оновлено + vehicle: ТЗ + vendor: Постачальник + created: Створено + email: Електронна пошта + title: Заголовок + updated: Оновлено + fleet: Автопарк + license: Ліцензія / Посвідчення + active-manpower: Активний персонал + manpower: Персонал + parent-fleet: Батьківський автопарк + service-area: Зона обслуговування + task: Завдання + zone: Зона + driver: Водій + edit-fuel: Редагувати пальне + odometer: Одометр + reporter: Заявник + view: Перегляд + volume: Об'єм + assignee: Виконавець + category: Категорія + priority: Пріоритет + city: Місто + neighborhood: Район + postal-code: Поштовий індекс + state: Область / Штат + make: Марка + model: Модель + plate-number: Номерний знак + year: Рік + website-url: URL-адреса вебсайту + created-by: Створив + customer: Клієнт + driver-assigned: Призначений водій + dropoff: Точка розвантаження + facilitator: Посередник + items: Позиції + payload: Вантаж + pickup: Точка завантаження + scheduled-at: Заплановано на + tracking: Відстеження + transaction: Транзакція + updated-by: Оновив + vehicle-assigned: Призначений ТЗ + service: Послуга + location: Розташування + coordinates: Координати + current-job: Поточна робота + last-seen: Востаннє помічено + place: Місце + subject: Тема + summary: Короткий зміст + total-cost: Загальна вартість + due-at: Термін виконання + serial-number: Серійний номер + manufacturer: Виробник + part-number: Номер деталі + quantity-on-hand: Кількість в наявності + quantity: Кількість + unit-cost: Вартість за одиницю + code: Код + interval: Інтервал + next-due: Наступний термін + asset: Актив + performed-by: Виконав + +map: + visibility-controls: + title: Елементи керування видимістю + hide-places: Приховати всі місця + show-places: Показати всі місця + driver-controls: Керування водіями + driver-visibility-controls: Керування видимістю водіїв + show-drivers: Показати всіх водіїв + hide-drivers: Приховати всіх водіїв + show-online-drivers: Показати лише водіїв в мережі + show-offline-drivers: Показати лише водіїв поза мережею + vehicle-controls: Керування ТЗ + vehicle-visibility-controls: Керування видимістю ТЗ + hide-vehicles: Приховати всі ТЗ + show-vehicles: Показати всі ТЗ + show-online-vehicles: Показати лише ТЗ в мережі + show-offline-vehicles: Показати лише ТЗ поза мережею + zones-panel: + title: Зони обслуговування + create-service: Створити нову зону обслуговування + show: Показати всі зони обслуговування + hide: Приховати всі зони обслуговування + action: Дії + focus: Фокус + focus-resource: >- + Фокус: {resource} + blur: Приховати зону обслуговування + blur-resource: >- + Приховати: {resource} + create-zone: Створити зону всередині + create-zone-inside: >- + Створити зону: {serviceAreaName} + edit-resource-boundaries: Редагувати межі {resource} + edit-boundaries: >- + Редагувати межі: {resource} + toolbar: + create: Створити замовлення + view-order: Переглянути замовлення + view-service-area: Переглянути зони обслуговування + visibility: Елементи керування видимістю + scope: Область видимості + +activity: + form: + code: Код + code-help-text: Введіть унікальний ідентифікатор для цієї активності. + completes-order: Завершує замовлення + details: Деталі + details-help-text: "Надайте детальний опис активності для розуміння кінцевим користувачем." + key: Ключ + key-help-text: '"Введіть ключ, що використовується для програмного прийняття рішень. Цей ключ може представляти одну або кілька активностей з однаковим ідентифікатором. Примітка: Цей ключ не повинен бути унікальним."' + pod-method-placeholder: Виберіть метод підтвердження доставки + require-pod: Потрібне підтвердження доставки + require-pod-help-text: Ця активність вимагає створення підтвердження перед застосуванням до замовлення. + select-pod-method: Метод підтвердження доставки + status: Статус + status-help-text: Вкажіть статус, який відображається кінцевому користувачу при спрацьовуванні цієї активності. + logic-builder: + activity-logic: Логіка активності + activity-logic-help-text: Визначте настроювані логічні умови, які визначають, коли активність стає доступною в робочому процесі. Ця функція дозволяє адаптувати процес на основі конкретних критеріїв. + activity-logic-add-logic-help-text: Створіть новий набір умов, які визначатимуть, коли активність повинна бути активована. Натисніть для створення нового логічного правила. + activity-logic-select-logic-type-help-text: Виберіть тип логіки для цієї умови. Виберіть 'If' для прямої умови, 'And'/'Or' для складених умов або 'Not' для заперечення умови. + activity-logic-add-condition-help-text: Додайте конкретну вимогу, яка має бути виконана в цьому наборі логіки. + field: Поле + value: Значення + add-logic: Додати логіку + types: + and: >- + Логічне ТА (AND): Істина, якщо всі умови істинні + or: >- + Логічне АБО (OR): Істина, якщо хоча б одна умова істинна + not: >- + Логічне НІ (NOT): Інвертує значення істинності умови + if: >- + Умовна логіка: Виконує наступну дію, якщо умова істинна + operators: + equal: Перевіряє рівність двох значень + not-equal: Перевіряє нерівність двох значень + greater-than: Перевіряє, чи значення більше за інше + less-than: Перевіряє, чи значення менше за інше + greater-than-or-equal: Перевіряє, чи значення більше або дорівнює іншому + less-than-or-equal: Перевіряє, чи значення менше або дорівнює іншому + exists: Перевіряє існування поля або значення + has: Перевіряє, чи має об'єкт певну властивість або чи містить колекція елемент + contains: Перевіряє, чи містить рядок інший рядок або чи включає масив елемент + begins-with: Перевіряє, чи починається рядок з вказаного підрядка + ends-with: Перевіряє, чи закінчується рядок вказаним підрядком + in: Перевіряє, чи знаходиться значення в заданій множині або діапазоні + not-in: Перевіряє, чи знаходиться значення поза заданою множиною або діапазоном + and: Логічне ТА, істина, якщо обидва операнди істинні + or: Логічне АБО, істина, якщо хоча б один операнд істинний + not: Логічне НІ, інвертує значення істинності + event-selector: + activity-events: Події активності + activity-events-select-to-fire: Виберіть події, які будуть активовані при виконанні цієї активності. + activity-events-select-events-info: Виберіть конкретну подію, яку ви хочете запустити у відповідь на цю активність. + select-event-to-add: Виберіть подію для додавання... + events: + order: + dispatched: Спрацьовує при успішній відправці замовлення. + failed: Спрацьовує, якщо замовлення зазнало невдачі через помилку або виключення. + canceled: Спрацьовує при скасуванні замовлення користувачем, водієм або системним процесом. + completed: Спрацьовує при завершенні замовлення водієм або системним процесом. + +contact: + fields: + contact-details: Контактні дані + upload-new-photo: Завантажити нове фото + +customer: + fields: + customer-details: Деталі клієнта + select-address: Вибрати адресу + new-address: Нова адреса + select-user: Вибрати користувача + send-welcome-email: Надіслати вітальний лист клієнтського порталу + send-welcome-email-help-text: Згенерувати тимчасовий пароль та надіслати клієнту електронну пошту з посиланням для входу. + upload-new-photo: Завантажити нове фото + user-account: Обліковий запис користувача + user-account-help-text: Кожен профіль клієнта має бути пов'язаний з конкретним обліковим записом користувача для обробки процесів автентифікації. + +custom-entity: + fields: + description: Опис + details: Деталі + entity-image: Зображення сутності + height: Висота + height-text: Текст висоти + length: Довжина + length-text: Текст довжини + measurements: Виміри + name: Назва + type: Тип + weight: Вага + weight-text: Текст ваги + width: Ширина + width-text: Текст ширини + +driver: + fields: + avatar: Аватар + driver-details: Деталі водія + select-user: Вибрати користувача + select-vehicle: Вибрати ТЗ + upload-new-photo: Завантажити нове фото + user-account: Обліковий запис користувача + user-account-help-text: Кожен профіль водія має бути пов'язаний з конкретним обліковим записом користувача для забезпечення безпечного доступу та автентифікації. + vendor: Постачальник + select-vendor: Вибрати постачальника + driver-license: Посвідчення водія + license-expiry: Термін дії посвідчення + no-vehicle-assigned: ТЗ не призначено + actions: + unassign-orders: Скасувати призначення замовлень + unassign-vehicle: Скасувати призначення ТЗ + create-issue: Створити проблему + assign-order: Призначити замовлення водію + assign-vehicle: Призначити ТЗ водію + locate-driver: Знайти водія на карті + + prompts: + assign-order-title: "Призначити замовлення водію {driverName}" + assign-order-success: Замовлення призначено водію {driverName}. + select-order-warning: "Виберіть замовлення для призначення." + assign-vehicle-title: "Призначити ТЗ водію {driverName}" + assign-vehicle-success: "ТЗ призначено водію {driverName}." + select-vehicle-warning: Виберіть ТЗ для призначення. + no-assigned-orders-warning: Для {driverName} не знайдено призначених замовлень. + select-orders-warning: Виберіть щонайменше одне замовлення для скасування призначення. + unassign-orders-title: Скасувати призначення замовлень від {driverName} + confirm-unassign-orders-title: Скасувати призначення {count} замовлень від {driverName}? + confirm-unassign-orders-body: "Це вилучить {driverName} з цих замовлень: {orders}." + unassign-orders-success: Скасовано призначення {driverName} з {count} замовлень. + no-vehicle-assigned-warning: За {driverName} не закріплено жодного ТЗ. + unassign-vehicle-title: Відв'язати {vehicleName} від {driverName}? + unassign-vehicle-body: Це відв'яже {driverName} від {vehicleName}. Призначення замовлень не зміняться. + unassign-vehicle-success: Відв'язано {vehicleName} від {driverName}. + issue-title: Повідомлено про проблему для {driverName} +device: + attachment: + title: Приєднаний актив + description: Контекст автопарку для телеметрії з цього пристрою. + open: Відкрити актив + asset: Приєднаний актив + synced-device: Синхронізований пристрій + asset-type: Тип активу + select-asset-type: Виберіть тип активу + locate: Знайти + change: Змінити актив + detach: Від'єднати + attached-device: Приєднаний пристрій + last-seen: Пристрій востаннє помічено + none-title: Немає приєднаного активу + none-message: Приєднайте цей пристрій до ТЗ або причепа, щоб телеметрія мала контекст автопарку. + latest-positions: Останні позиції + positions-description: Останні координати активу, отримані через це прив'язане пристрою. + full-history: Повна історія + no-positions: Немає останніх позицій + no-positions-message: Для цього приєднаного активу ще немає доступної історії останніх позицій. + attach-or-change: Приєднати або змінити актив + view-attached: Переглянути приєднаний актив + locate-attached: Знайти приєднаний актив на карті + unknown: Невідомий актив + fields: + title: Деталі пристрою + device-name: Назва пристрою + device-name-placeholder: Назва пристрою + device-type: Тип пристрою + device-type-placeholder: Виберіть тип пристрою + device-id: ID пристрою + device-id-placeholder: ID пристрою + device-provider: Провайдер пристрою + device-provider-placeholder: Провайдер пристрою + device-model: Модель пристрою + device-model-placeholder: Модель пристрою + manufacturer: Виробник + manufacturer-placeholder: Виробник + serial-number: Серійний номер + serial-number-placeholder: Серійний номер + device-location: Розташування пристрою + device-location-placeholder: Розташування пристрою + installation-date: Дата встановлення + installation-date-placeholder: Виберіть дату встановлення + last-maintenance-date: Дата останнього ТО + last-maintenance-date-placeholder: Виберіть дату останнього ТО + data-frequency: Частота передачі даних + data-frequency-placeholder: Частота передачі даних + status: Статус + status-placeholder: Виберіть статус + telematic: Телематика + telematic-placeholder: Виберіть телематику + warranty: Гарантія + warranty-placeholder: Виберіть гарантію + notes: Примітки + notes-placeholder: Примітки + + actions: + attach-device: Приєднати пристрій + attach-to-vehicle: Приєднати до ТЗ + detach-from-vehicle: Від'єднати від ТЗ + attach-to-asset: Приєднати до активу + prompts: + attach-to-vehicle-title: Приєднати пристрій до ТЗ + attach-device-to-vehicle-title: "Приєднати {deviceName} до ТЗ" + select-vehicle-warning: Виберіть ТЗ для приєднання цього пристрою. + attach-to-vehicle-success: Пристрій приєднано до ТЗ. + attach-to-asset-title: Приєднати пристрій до активу + select-asset-warning: Виберіть ТЗ або причіп для приєднання цього пристрою. + attach-to-asset-success: Пристрій приєднано до активу. + detach-from-asset-title: "Від'єднати {deviceName} від активу?" + detach-from-asset-body: "Це від'єднає {deviceName} від {assetName} і зупинить оновлення телеметрії активу, доки його не буде приєднано знову." + detach-from-asset-success: Пристрій від'єднано від активу. + not-attached-warning: "{deviceName} не приєднано до ТЗ." + detach-from-vehicle-title: "Від'єднати {deviceName} від ТЗ?" + detach-from-vehicle-body: "Це від'єднає {deviceName} від {vehicleName} і зупинить оновлення телеметрії ТЗ, доки його не буде приєднано знову." + detach-from-vehicle-success: Пристрій від'єднано від ТЗ. + select-device-to-attach-title: "Виберіть пристрій для приєднання до {resourceName}" + confirm-attach-device: "Підтвердити та приєднати пристрій" + attach-device-success: Пристрій успішно приєднано. + detach-from-resource-title: "Від'єднати {deviceName} від {resourceName}?" + detach-from-resource-body: "Це від'єднає {deviceName} від {resourceName} і зупинить оновлення телеметрії та подій для цього {resourceType}." + detach-from-resource-success: "Від'єднано {deviceName} від {resourceName}." + detach-telematic-device-title: "Від'єднати {deviceName} від ТЗ?" + detach-telematic-device-body: "{deviceName} залишиться синхронізованим від провайдера, але більше не буде прив'язаний до ТЗ." + manager-description: Телематичні пристрої, встановлені на цьому {resourceType}. Приєднайте пристрій, щоб почати отримувати дані про позиції та події. + no-devices-attached-title: Немає приєднаних пристроїв + no-devices-attached-body: "Натисніть 'Приєднати пристрій' вище, щоб почати отримувати телематичні дані для цього {resourceType}." +entity: + fields: + declare: Декларувати + declare-text: Текст декларації + description: Опис + description-text: Текст опису + height: Висота + height-text: Текст висоти + id-text: Текст ID + length: Довжина + length-text: Текст довжини + measurement-title: Назва вимірювання + name-text: Текст назви + price: Ціна + price-text: Текст ціни + price-title: Назва ціни + sale-price: Акційна ціна + sale-price-text: Текст акційної ціни + sku: Артикул (SKU) + sku-text: Текст артикулу + weight: Вага + weight-text: Текст ваги + width: Ширина + width-text: Текст ширини + +fleet: + fields: + fleet-details: Деталі автопарку + fleet-name: Назва автопарку + parent-fleet: Батьківський автопарк + select-parent-fleet: Виберіть батьківський автопарк для призначення + select-service: Виберіть зону обслуговування для призначення автопарку + select-status: Виберіть статус + select-status-fleet: Виберіть статус автопарку. + select-vendor: Виберіть постачальника для призначення автопарку + select-zone: Виберіть зону для призначення автопарку + service-title: Призначити до зони обслуговування + task-mission-title: Завдання / Місія + task-text: "Надайте опис основного завдання або місії цього автопарку, якщо застосовно." + zone-title: Призначити до зони + active-manpower: Активний персонал + task: Завдання + driver-listing: + search-driver: Пошук водіїв в автопарку + add-driver: Додати водія до автопарку + loading-message: Завантаження водіїв автопарку... + vehicle-listing: + search-vehicle: Пошук ТЗ в автопарку + add-vehicle: Додати ТЗ до автопарку + loading-message: Завантаження ТЗ автопарку... + actions: + assign-vehicle: Призначити ТЗ до автопарку + assign-driver: Призначити водія до автопарку + + prompts: + assign-driver-title: Призначити водія до {fleetName} + assign-vehicle-title: Призначити ТЗ до {fleetName} + select-driver-warning: Виберіть водія для призначення. + select-vehicle-warning: Виберіть ТЗ для призначення. + assign-driver-success: Водія призначено до {fleetName}. + assign-vehicle-success: ТЗ призначено до {fleetName}. +fuel-report: + fields: + details: Деталі звіту про пальне + reporter: Заявник + select-reporter: Вибрати заявника + fuel-cost: Вартість пального + odometer: Одометр + select-volume-help-text: Виберіть метричну одиницю та об'єм пального для звіту. + fuel-volume: Пальне / Об'єм + +issue: + fields: + title: Заголовок + add-tags: Додати теги + assigned-to: Призначено + category: Категорія проблеми + issue-report: Звіт про проблему + priority: Пріоритет проблеми + report: Звіт про проблему + reported-by: Повідомив + select-assign: Вибрати виконавця + select-category: Виберіть категорію проблеми + select-priority: Виберіть пріоритет проблеми + select-reporter: Виберіть заявника + select-status: Виберіть статус проблеми + select-type: Виберіть тип проблеми + tags: Теги проблеми + type: Тип проблеми + +order: + placeholders: + filter-internal-id: Введіть або скануйте внутрішні ID + card: + linked-order: Пов'язане замовлення + unknown-order: Невідоме замовлення + no-type: Без типу + pickup: Завантаження + dropoff: Розвантаження + return: Повернення + waypoints: Точки маршруту + additional-stop: додаткова зупинка + additional-stops: додаткові зупинки + no-pickup-address: Немає адреси завантаження + no-dropoff-address: Немає адреси розвантаження + no-route-details: Деталі маршруту недоступні. + no-order-linked: Жодне замовлення не пов'язане з цією проблемою. + fields: + ad-hoc: Ad-Hoc + ad-hoc-help-text: Вмикання Ad-Hoc дозволить замовленню інтелектуально надсилати сповіщення водіям поблизу місця завантаження, дозволяючи першому вільному водію прийняти замовлення. Це альтернатива ручному призначенню. + add-entity: Додати сутність + add-item-button: Додати позицію + add-item-order: Додати позицію до замовлення + add-waypoint: Додати точку маршруту + add-waypoint-help-text: 'Використовуйте кнопку `Додати точку маршруту` для додавання нових зупинок. Маршрутизатор Fleetbase автоматично оптимізує маршрут при додаванні або видаленні зупинок.' + adhoc-ping: Відстань сповіщення Ad-Hoc + adhoc-ping-help-text: Це налаштування визначає радіус Ad-Hoc сповіщення для цього замовлення. Якщо не заповнено, використовується значення за замовчуванням. + adhoc-ping-message: Відстань має бути в метрах + apply-service-rate: Застосувати тариф послуги + assign-driver: Призначити водія + assign-driver-help-text: Призначте водія, якому буде відправлено це замовлення. + assign-driver-placeholder: Вибрати водія + assign-vehicle: Призначити ТЗ + assign-vehicle-help-text: Призначте ТЗ, на якому буде виконано замовлення. + assign-vehicle-placeholder: Вибрати ТЗ + breakdown: Деталізація + customer: Клієнт + customer-help-text: "Опціонально призначте клієнта для цього замовлення. Якщо це новий клієнт, скористайтеся кнопкою 'Новий клієнт' вище." + customer-placeholder: Вибрати клієнта + dispatch: Відправити + dispatch-help-text: Увімкнення відправки запустить процес відправки замовлення одразу після створення. + documents-panel-title: 'Документи та файли' + dropoff: Точка розвантаження + edit-address: Редагувати адресу + edit-item: Редагувати позицію + facilitator: Посередник + facilitator-help-text: Опціонально призначте посередника (сторонній субпідрядник або служба). + facilitator-placeholder: Вибрати посередника + info-text: Виберіть розрахунок вартості послуги в реальному часі для цього замовлення. Після застосування розрахунку він стане придбаним тарифом. + internal-id: Внутрішній ID + internal-id-help-text: Використовуйте це поле для встановлення власного або внутрішнього ідентифікатора замовлення. + invalid-coordinates: 'Недійсні координати!' + items-drop: Позиції вивантажуються о + loading-message: Завантаження розрахунків вартості... + updating-service-quotes-message: Оновлення розрахунків вартості... + notes-placeholder: Введіть примітки до замовлення тут.... + notes-title: Примітки + optimize-route: Оптимізувати маршрут + optimize-route-help-text: Fleetbase автоматично оптимізує маршрут. + order-type: Тип замовлення + order-type-help-text: Вибір типу замовлення вкаже Fleetbase, які конфігурації замовлення використовувати. + order-type-placeholder: Виберіть тип замовлення + payload-entities: Вантаж / Сутності + pickup: Точка завантаження + proof-delivery: Підтвердження доставки + proof-delivery-help-text: Виберіть потрібний тип підтвердження доставки (QR-код або підпис). + proof-delivery-placeholder: Виберіть метод підтвердження доставки + refresh-button: Оновити + require-proof: Вимагати підтвердження доставки + require-proof-help-text: Увімкнення цієї опції зобов'яже водія надати підтвердження доставки. + return: Повернення + route: Маршрут + route-error: Помилка оптимізації маршруту. Перевірте введені дані та спробуйте знову. + route-label: "Кілька точок розвантаження" + schedule: Запланувати + schedule-help-text: Дата та час відправки замовлення. Якщо водія не призначено на момент відправки, вона зазнає невдачі. + select-destination: Виберіть пункт призначення + select-dropoff: Виберіть точку розвантаження + select-pickup: Виберіть точку завантаження + select-return: Виберіть точку повернення + select-waypoint: Виберіть точку маршруту + service: Послуга + select-service-rate: Виберіть тариф послуги + service-help-text: Виберіть визначений тариф для генерації розрахунків вартості в реальному часі. + service-quotes-help-text: Виберіть тариф для отримання розрахунків вартості. + service-type: Тип послуги + service-type-placeholder: Виберіть тип послуги + sku: Артикул (SKU) + activity: Активність + comments-title: Коментарі + date-dispatched: Дата відправки + date-scheduled: Запланована дата + date-started: Дата початку + driver: Водій + driver-assigned: Призначений водій + no-driver-assigned: Водія не призначено + get-label: Отримати етикетку + order-notes-updated: Примітки до замовлення оновлено. + payload: Вантаж + proof-of-delivery: Підтвердження доставки + purchase-rate-panel-title: Придбаний тариф + route-panel-title: Маршрут + save-order-note: Зберегти примітку до замовлення + tracking: Відстеження + tracking-number: Номер відстеження + vehicle-assigned: Призначений ТЗ + view-activity: Переглянути активність + no-order-activity: Немає активності за замовленням + waypoint-actions: Дії з точкою маршруту + documents-files: Документи та файли + order-metadata: Метадані замовлення + order-label: Маркування замовлення + waypoint-label: Маркування точки маршруту + entity-label: Маркування сутності + current-eta: Поточний очікуваний час (ETA) + ect: Очікуваний час завершення (ECT) + current-destination: Поточний пункт призначення + no-service-quotes: Немає розрахунків вартості послуг. + input-order-routes: Введіть маршрут замовлення для перегляду розрахунків. + service-quote-info: Виберіть розрахунок вартості в реальному часі для застосування до замовлення. + prompts: + update-details-success: 'Деталі замовлення {orderId} оновлено.' + route-error: Помилка оптимізації маршруту, перевірте введені дані маршруту та спробуйте знову. + route-update-success: 'Деталі маршруту замовлення {orderId} оновлено.' + unassign-driver-title: Ви дійсно бажаєте скасувати призначення водія ({driverName}) для цього замовлення? + unassign-driver-body: Після скасування призначення водій більше не матиме доступу до деталей цього замовлення. + unassign-driver-success: Водія успішно відв'язано від цього замовлення. + no-driver-assigned-error: За цим замовленням не закріплено водія. + failed-to-load-order-label: Не вдалося завантажити маркування замовлення. + failed-to-load-waypoint-label: Не вдалося завантажити маркування точки маршруту. + failed-to-load-entity-label: Не вдалося завантажити маркування сутності. + unable-to-add-entity: Не вдалося додати нову сутність до замовлення. + assign-driver-success: Водія ({driverName}) призначено до замовлення {orderId}. + cancel-title: Ви дійсно бажаєте скасувати це замовлення? + cancel-body: Після скасування замовлення запис залишиться видимим, але додавати активність до нього буде неможливо. + cancel-success: Замовлення {orderId} скасовано. + dispatch-title: Ви дійсно бажаєте відправити це замовлення? + dispatch-body: Після відправки замовлення призначений водій отримає сповіщення. + dispatch-success: Замовлення {orderId} відправлено. + order-bulk-search: Введіть ID замовлень або номери відстеження через кому для масового пошуку + actions: + create-new-customer: Створити нового клієнта + create-new-facilitator: Створити нового посередника + create-new-place: Створити нове місце + change-driver: Змінити водія + assign-driver: Призначити водія + update-activity: Оновити активність + reload-activity: Перезавантажити активність + view-activity-as-timeline: Переглянути як часову шкалу + view-activity-as-list: Переглянути як список + cancel-order: Скасувати замовлення + dispatch-order: Відправити замовлення + dispatch-orders: Відправити замовлення + assign-driver-to-orders: Призначити водія до замовлень + edit-order-details: Редагувати деталі замовлення + edit-order-route: Редагувати маршрут замовлення + edit-order-metadata: Редагувати метадані замовлення + update-order-activity: Оновити активність замовлення + submit-new-activity: Надіслати нову активність + add-entity: Додати сутність + unassign-driver-name: >- + Скасувати призначення водія: {driverName} + schedule-card: + destination: Пункт призначення + unassign-driver: Скасувати призначення водія? + unassign-text: Ви збираєтеся скасувати призначення водія для замовлення {orderId}. Натисніть "Продовжити" для підтвердження. + unassign-button: Продовжити та скасувати призначення водія + assign-driver: Призначити нового водія? + assign-text: Ви збираєтеся призначити нового водія ({driverName}) для замовлення {orderId}. Натисніть "Продовжити" для підтвердження. + assign-button: Продовжити та призначити водія + date: Запланована дата + date-created: Дата створення + driver-assigned: Водій + vehicle-assigned: Транспортний засіб + no-driver: Без водія + no-vehicle: Без ТЗ + select-driver: Вибрати водія + select-vehicle: Вибрати ТЗ + kanban: + status-updated: Активність замовлення оновлено до статусу {status}. + cannot-update-status: Не вдалося перемістити замовлення до статусу {status}. + +order-config-manager: + title: Конфігурація замовлень + message: Конфігурації замовлень дозволяють визначати динамічні параметри, які вказують Fleetbase, як мають виконуватися різні типи замовлень. + configuration: Конфігурація + new-order-config: Нова конфігурація замовлення + select-order-config: Вибрати конфігурацію замовлення + create-new-title: Створити нову конфігурацію замовлення + create-warning-message: Конфігурація замовлення вимагає назви + create-success-message: Нову конфігурацію замовлення успішно створено. + no-order-config-selected: Не вибрано конфігурацію замовлення. + saved-success-message: 'Конфігурацію {orderConfigName} збережено.' + cloned-success-message: Конфігурацію замовлення успішно клоновано. + enter-clone-name: Введіть назву клонованої конфігурації + no-config-name-warning-message: Назву конфігурації не введено. + delete-warning-message: Після видалення цієї конфігурації ви більше не зможете створювати замовлення з її використанням. Ви впевнені? + uninstall-order-config: Видалити конфігурацію + uninstall-order-config-success-message: Розширення {extensionName} видалено. + select-order-config-to-start: Виберіть або створіть конфігурацію замовлення для початку. + tabs: + details: Деталі + custom-fields: Настроювані поля + activity-flow: Потік активностей + entities: Сутності + details: + details: Деталі + name: Назва + description: Опис + tags: Теги + add-tags: Додати теги + key: Ключ + version: Версія + namespace: Простір імен (Namespace) + controls: Елементи керування + delete-config: Видалити конфігурацію + delete: + delete-title: Видалити цю конфігурацію замовлень? + delete-body-message: Після видалення цієї конфігурації замовлень її неможливо буде відновити, і всі налаштування будуть втрачені. + confirm-delete: Видалити + activity-flow: + save-activity-flow: Зберегти потік активностей + edit-activity-unique-code-warning: Код активності повинен бути унікальним! + custom-fields: + new-field-group: Нова група полів + new-custom-field: Створити нове настроюване поле + grid-size: Розмір сітки + delete-custom-field-prompt: + modal-title: Видалити це настроюване поле? + delete-body-message: Після видалення цього поля його відновлення буде неможливим. + confirm-delete: Видалити + delete-custom-field-group-prompt: + modal-title: Видалити цю групу полів? + delete-body-message: Після видалення цієї групи полів усі настроювані поля всередині неї будуть втрачені. + confirm-delete: Видалити + entities: + new-custom-entity: Нова настроювана сутність + name: Назва + description: Опис + type: Тип + length: Довжина + width: Ширина + height: Висота + weight: Вага + delete-custom-entity-title: Видалити цю настроювану сутність? + delete-custom-entity-body: Після видалення цієї сутності її відновлення буде неможливим. + confirm-delete: Підтвердити + types: + no-order-installed: Не встановлено жодної конфігурації замовлень! + you-have-no-configs: Наразі у вас немає встановлених конфігурацій замовлень. Перевірте розширення, щоб встановити конфігурацію для початку. + view-extensions: Переглянути розширення + +place: + fields: + details: Деталі місця + building: Будівля / Корпус + neighborhood: Район + postal-code: Поштовий індекс + security-access-code: Код доступу / Охорони + city: Місто + province: Область / Провінція + country: Країна + state: Штат / Область + street-1: Вулиця (лінія 1) + street-2: Вулиця (лінія 2) + coordinates: Координати + phone: Телефон + prompts: + vendor-assigned-success: Постачальнику призначено {placeName} як нове розташування. + actions: + locate-place: Знайти місце на карті + +service-area: + actions: + edit-boundary: Редагувати межу на карті + fields: + details: Деталі зони обслуговування + area-color: Колір заливки + area-color-help-text: Налаштуйте колір заливки для цієї зони обслуговування. + border-color: Колір межі + border-color-help-text: Налаштуйте колір межі для цієї зони обслуговування. + country: Країна + country-help-text: Опціонально вкажіть країну, у якій розташована або базується ця зона обслуговування. + name: Назва + name-help-text: Встановіть відображувану назву для зони обслуговування. + type: Тип + type-help-text: Виберіть визначення типу для цієї зони обслуговування. + +service-rate: + fields: + details: Деталі тарифу послуги + terms: Умови + add-drop-off-button: Додати діапазон розвантажень + additional-fee: Додатковий збір + algorithm-help-text: Визначте формулу для розрахунку цього тарифу. + algorithm-label: Алгоритм + algorithm-placeholder: >- + (( '{distance}' / 50 ) * .05 ) + (( '{time}' / 60 ) * .01) + base-fee-help-text: Встановіть базовий збір, який представляє мінімальну вартість цієї послуги. + base-fee-label: Базовий збір + calculation-method-help-text: Метод розрахунку комісії за накладений платіж (COD). + calculation-method-label: Метод розрахунку комісії COD + calculation-method-placeholder: Виберіть метод розрахунку комісії COD + cash-delivery-label: 'Увімкнути додатковий збір для замовлень з накладеним платежем?' + cash-delivery-title: Оплата при отриманні (COD) + custom-algorithm-info-message: "Ця опція призначена для визначення власного розрахунку збору за послугу зі змінними." + custom-algorithm-info-second-message: Зверніть увагу, що змінні повинні бути обгорнуті в поодинокі фігурні дужки. + custom-algorithm-title: Власний алгоритм + delivery-flat-fee: Фіксований збір за накладений платіж + delivery-flat-fee-help-text: Визначає фіксований збір, який додається в години пік. + deminsions-unit: Розмірність + distance: Відстань + distance-continue-message: >- + - відстань у метрах від маршруту замовлення. + distance-message: відстань + distance-unit: Одиниця відстані + distance-unit-help-text: Одиниця відстані може бути за кілометр або метр. + distance-unit-placeholder: Виберіть одиницю відстані. + duration-terms-help-text: Додайте додаткові умови обслуговування щодо тривалості для цього тарифу. + duration-terms-label: Умови тривалості + duration-terms-placeholder: Умови тривалості, якщо застосовно + estimated-days: Очікувані дні доставки + estimated-days-help-text: 'Очікувана кількість днів для цієї послуги. Для доставки в той самий день використовуйте `0`.' + example: Приклад + example-message: Якщо запит на замовлення має відстань 2200 метрів з ETA 30 хвилин. + example-second-message: Тоді наступна формула розрахує збір за послугу в розмірі $2.50, який буде додано до базового збору. + fee: Збір + fee-percentage: Відсоток комісії за накладений платіж + fee-percentage-help-text: Визначає відсотковий збір від проміжного підсумку тарифу послуги для накладеного платежу. + fixed-meter: Опції фіксованого метражу + fixed-meter-example-text: >- + Наприклад: За кожен 1 кілометр визначається фіксована плата. Якщо відстань замовлення 3300 метрів, фіксована плата за 3-й кілометр додається до суми базового збору. + fixed-meter-text: Ця опція визначає фіксовану плату за кілометр. + flat-fee-help-text: Визначає фіксований збір, який додається в години пік. + flat-fee-label: Фіксований збір у години пік + height-label: Висота + height-placeholder: 'Висота в {parcelFeeDimension}' + length-label: Довжина + length-placeholder: 'Довжина в {parcelFeeDimension}' + max: Макс. + max-drop: Макс. кількість точок розвантаження + maximum-distance: Максимальна відстань + maximum-distance-help-text: Максимальна відстань обслуговування. + min: Мін. + min-drop: Мін. кількість точок розвантаження + order-type-placeholder: Заповнювач типу замовлення + peak-hours-end-help-text: Визначає час закінчення дії збору за години пік. + peak-hours-end-label: Кінці годин пік + peak-hours-fee-help-text: Метод, що використовується для розрахунку збору в години пік. + peak-hours-fee-label: Метод розрахунку збору в години пік + peak-hours-fee-placeholder: Виберіть метод розрахунку збору в години пік + peak-hours-label: 'Увімкнути додатковий збір для замовлень, зроблених у визначені "години пік"?' + peak-hours-percentage-help-text: Визначає відсотковий збір від суми тарифу в години пік. + peak-hours-percentage-label: Відсоток збору в години пік + peak-hours-start-help-text: Визначає час початку дії збору за години пік. + peak-hours-start-label: Початок годин пік + peak-hours-title: Години пік + per-drop-off-example-text: >- + Наприклад: 1-5 розвантажень у замовленні коштуватимуть $x, 5-10 розвантажень коштуватимуть $x. Це буде додано до суми базового збору. + per-drop-off-text: Ця опція визначає фіксований збір за кожну точку розвантаження. + per-drop-off-title: Опції за точку розвантаження + per-meter: За метр + per-meter-example-text: >- + Наприклад: '{fee}' * '{distance}' + '{baseFee}' + per-meter-rate-fee: Фіксована ставка за метр + per-meter-rate-fee-help-text: Єдиний фіксований збір, який помножується на відстань. + per-meter-text: Ця опція дозволяє розраховувати послугу за кілометр або метр (фіксований збір множиться на відстань). + percel-fee-title: "Збори за посилки" + percentage-placeholder: Відсоток + rate-calculation-help-text: Метод, який використовується цією послугою для розрахунку тарифів при запиті. + rate-calculation-label: Метод розрахунку тарифу + rate-calculation-placeholder: Виберіть метод розрахунку тарифу + restrict-service-title: Обмежити послугу + select-unit-placeholder: Вибрати одиницю + select-order-type: Вибрати тип замовлення + service-area-help-text: Обмежити цю послугу запитами на замовлення, що надходять із зони обслуговування. + service-area-label: Зона обслуговування + service-area-placeholder: Обмежити зоною обслуговування + service-name: Назва послуги + service-name-help-text: Відображувана назва цієї послуги. + service-order-help-text: Виберіть конфігурацію замовлення, до якої цей тариф буде застосовуватись виключно. + service-order-label: Тип замовлення послуги + time-continue-message: >- + - очікуваний час прибуття (ETA) за маршрутом у секундах. + time-message: час + weight-label: Вага + weight-unit: Одиниця ваги + width-label: Ширина + width-placeholder: 'Ширина в {parcelFeeDimension}' + zone-help-text: Обмежити цю послугу запитами, що надходять з конкретної зони. + zone-label: Зона + zone-placeholder: Обмежити зоною + cod-fee-label: Ярлик комісії COD + +vehicle: + placeholders: + filter-internal-id: Введіть або скануйте внутрішні ID + filters: + has-trailer: Приєднано причіп + has-driver: Призначено водія + has-device: Приєднано пристрій + any: Будь-який + attached: Приєднано + not-attached: Не приєднано + assigned: Призначено + not-assigned: Не призначено + fields: + updated-at: Востаннє оновлено + avatar: Аватар + driver-assigned: Призначений водій + make: Марка + model: Модель + plate-number: Номерний знак + select-driver: Вибрати водія + upload-new-photo: Завантажити нове фото + vehicle-make: Марка ТЗ + vehicle-model: Модель ТЗ + vehicle-year: Рік випуску ТЗ + vin-number: VIN-код + year: Рік + actions: + attach-trailer: Приєднати причіп + attach-equipment: Приєднати обладнання + detach-equipment: Від'єднати обладнання + attach-device: Приєднати пристрій + unassign-orders: Скасувати призначення замовлень + create-issue: Створити проблему + locate-vehicle: Знайти ТЗ на карті + schedule-maintenance: Запланувати ТО + create-work-order: Створити заявку на роботу + log-maintenance: Зареєструвати ТО + empty: + trailers: Немає приєднаних причепів + trailers-description: Приєднайте відчеплений причіп до цього ТЗ, щоб розпочати зчеплення. + equipment: Немає приєднаного обладнання + equipment-description: Приєднайте до цього ТЗ таке обладнання, як гидроборти, інструменти або засоби безпеки. + inspections: Немає поданих інспекцій + inspections-description: Інспекції, подані водієм для цього ТЗ, відображатимуться тут. + trailers: + description: Причепи, наразі зчеплені з цим ТЗ. + equipment: + description: Обладнання, наразі встановлене на цей ТЗ. + + prompts: + attach-trailer-title: Приєднати причіп до {vehicleName} + select-trailer-warning: Виберіть причіп для приєднання до цього ТЗ. + attach-trailer-success: Приєднано {trailerName} до {vehicleName}. + attach-device-title: Приєднати пристрій до {vehicleName} + select-device-warning: Виберіть пристрій для приєднання. + attach-device-success: Пристрій приєднано до {vehicleName}. + no-assigned-orders-warning: Для {vehicleName} не знайдено призначених замовлень. + select-orders-warning: Виберіть щонайменше одне замовлення для скасування призначення. + unassign-orders-title: Скасувати призначення замовлень від {vehicleName} + confirm-unassign-orders-title: Скасувати призначення {count} замовлень від {vehicleName}? + confirm-unassign-orders-body: "Це вилучить {vehicleName} з цих замовлень: {orders}." + unassign-orders-success: Скасовано призначення {vehicleName} з {count} замовлень. + issue-title: Повідомлено про проблему для {vehicleName} +vendor: + fields: + setup-vendor: Налаштування постачальника + choose-vendor: Виберіть інтегрованого провайдера постачальника + email-help-text: Електронна пошта постачальника (може використовуватись для надсилання листів). + name-help-text: Назва постачальника (зазвичай назва компанії/бізнесу). + phone-text: >- + Телефон постачальника (може використовуватись для SMS або повідомлень). + website: URL-адреса вебсайту + edit-address: Редагувати адресу + new-address: Нова адреса + select-address: Вибрати адресу + select-vendor-status: Виберіть статус постачальника + select-vendor-type: Виберіть тип постачальника + title: Налаштування + vendor-details: Деталі постачальника + website-help-text: Вебсайт постачальника (опціонально для довідки). + +integrated-vendor: + fields: + provider: Провайдер + credentials: Облікові дані + credentials-configure-help-text: Опціонально вкажіть власний {param} для налаштування цього постачальника. + options: Опції + choose-vendor: Виберіть інтегрованого провайдера постачальника + select-integrated: Виберіть інтегрованого провайдера постачальника. + advanced-options: Додаткові параметри + cancel-credentials: Скасувати скидання облікових даних + sensitive-credentials: Чутливі облікові дані можна лише скинути; для оновлення необхідно ввести їх повторно. + reset-credentials: Натисніть тут для скидання облікових даних + sandbox: Песочниця (Sandbox) + webhook: URL-адреса Webhook + webhook-help-text: Інтегрованим постачальникам зазвичай це потрібно для отримання оновлень активності замовлення у Fleetbase. Змінюйте лише якщо впевнені в діях! + host: Власний хост + host-help-text: Опціонально вкажіть власний хост для цієї інтеграції. + namespace: Власний простір імен + namespace-help-text: Опціонально вкажіть власний простір імен або версію API. + +zone: + actions: + edit-boundary: Редагувати межу на карті + fields: + details: Деталі зони + border-color: Колір межі + customize-border: Налаштувати межу + customize-fill-color: Налаштувати колір заливки + description: Опис + name: Назва + name-help-text: Введіть назву цієї зони. + zone-color: Колір зони + +# Рефакторинг застарілих перекладів у процесі +activity-logic-builder: + activity-logic: Логіка активності + activity-logic-help-text: Визначте настроювані логічні умови, які визначають доступність активності у робочому процесі. + activity-logic-add-logic-help-text: Створіть новий набір умов для активації активності. + activity-logic-select-logic-type-help-text: Виберіть тип логіки (If, And/Or, Not). + activity-logic-add-condition-help-text: Додайте конкретну вимогу до цього набору логіки. + field: Поле + value: Значення + add-logic: Додати логіку + +activity-event-selector: + activity-events: Події активності + activity-events-select-to-fire: Виберіть події, які запускатимуться при цій активності. + activity-events-select-events-info: Виберіть конкретну подію для запуску. + select-event-to-add: Виберіть подію для додавання... + +custom-field-form-panel: + meta-col-span: 'Мета: охоплення колонок (Column-Span)' + col-span-size: Розмір охоплення колонок + custom-field-title-concat: 'Настроюване поле: ' + new-custom-field: Нове настроюване поле + close-new-custom-field: Скасувати зміни настроюваного поля + save-custom-field: Зберегти зміни + create-custom-field: Створити нове поле + field-name: Назва поля + field-label: Ярлик поля + field-type: Тип поля + select-field-type: Виберіть тип поля... + field-description: Опис поля + field-help-text: Текст підказки поля + field-default-value: Значення поля за замовчуванням + field-is-required: Поле є обов'язковим + field-is-editable: Поле доступне для редагування + field-options: Опції поля + field-validation-rules: Правила валідації поля + field-model-type: Тип моделі поля + field-select-model-type: Виберіть тип моделі + add-new-option: Додати нову опцію + +activity-form-panel: + title-concat: 'Активність: ' + new-activity-title: Створити нову активність + key: Ключ + code: Код + status: Статус + details: Деталі + proof-of-delivery: Підтвердження доставки + require-pod: Вимагає підтвердження доставки + require-pod-help-text: Ця активність вимагає створення підтвердження перед застосуванням. + select-pod-method: Метод підтвердження доставки + pod-method-placeholder: Виберіть метод підтвердження доставки + completes-order: Завершує замовлення + complete: Завершити + key-help: 'Введіть ключ для програмного прийняття рішень.' + code-help: Введіть унікальний ідентифікатор для цієї активності. + status-help: Вкажіть статус, що відображається користувачеві. + details-help: Надайте детальний опис активності. +custom-entity-issue-form-panel: + new-custom-entity: Нова настроювана сутність + name: Назва + description: Опис + details: Деталі + type: Тип + length: Довжина + width: Ширина + height: Висота + weight: Вага + entity-image: Зображення сутності + measurements: Виміри + +avatar-picker: + avatar: Аватар + select-map-avatar: Вибрати аватар для карти + select-avatar-rendering: Виберіть аватар для відображення на картах при геолокаційному відстеженні. + select-avatar: Вибрати аватар + select-for-preview: Вибрати для попереднього перегляду + +admin: + navigator-app: + title: Додаток Navigator + name: URL-адреса зв'язування екземпляра + message: Використовуйте цю URL-адресу для зв'язування офіційного додатка Navigator з екземпляром {companyName}. + settings: Налаштування сутності + select-input: Дозволити водієві оновлювати деталі сутності + driver-settings: Налаштування водія + description: Увімкнути самостійну реєстрацію водія + upload-document: Вимагати від водія завантаження документів + upload-body: Додати документи + document: Документ + avatar-management: + upload-avatar: Завантажити аватар + view-avatar: Переглянути аватар + vehicles: Транспортні засоби + drivers: Водії + places: Місця + +entity-field-editing-settings: + entitiy-field-editing-settings: Налаштування редагування полів сутності + entity-field-editing-settings-help-text: Ці налаштування визначають, які поля сутності водій може редагувати через додаток Navigator + select-order-config: Вибрати конфігурацію замовлення + select-order-config-help-text: Виберіть конфігурацію замовлення, до якої застосовуватимуться ці налаштування + enable-driver-to-edit-entity-fields: Дозволити водієві редагувати деталі сутності + enable-driver-to-edit-entity-fields-help-text: Вмикання дозволяє водієві змінювати вибрані нижче поля. + +driver-onboard-settings: + driver-onboard-settings: Налаштування реєстрації водіїв + enable-driver-onboard-from-app: Дозволити реєстрацію водіїв з додатка Navigator + enable-driver-onboard-from-app-help-text: Дозволяє водіям самостійно створювати обліковий запис у додатку Navigator. + select-onboard-method: Виберіть метод реєстрації водія + select-onboard-method-help-text: Визначає спосіб створення облікового запису (Запрошення або Кнопка в додатку). + require-driver-to-upload-onboard-documents: Вимагати завантаження документів при реєстрації + require-driver-to-upload-onboard-documents-help-text: Зобов'язує надати документи до створення облікового запису. + required-onboard-documents: Обов'язкові документи для реєстрації + required-onboard-documents-help-text: Введіть усі необхідні документи, використовуючи кнопку "Додати документ для реєстрації". + enter-document-name: Введіть назву документа + add-onboard-document: Додати документ для реєстрації + +cell: + driver-name: + not-assigned: Водія не призначено + +live-map-drawer: + driver-listing: + location: Розташування + current-job: Поточна робота + last-seen: Востаннє помічено + view-driver: Переглянути деталі водія... + edit-driver: Редагувати водія... + locate-driver: Знайти водія... + delete-driver: Видалити водія... + warning-message: Не вдалося знайти водія. + message: Фільтр водіїв за ключовим словом... + place-listing: + location: Розташування + place: Місце + view-place: Переглянути деталі місця... + edit-place: Редагувати місце... + locate-place: Знайти місце... + delete-place: Видалити місце... + warning-message: Не вдалося знайти місце. + message: Фільтр місць за ключовим словом... + vehicle-listing: + location: Розташування + last-seen: Востаннє помічено + view-vehicle: Переглянути деталі ТЗ... + edit-vehicle: Редагувати ТЗ... + locate-vehicle: Знайти ТЗ... + delete-vehicle: Видалити ТЗ... + warning-message: Не вдалося знайти ТЗ. + message: Фільтр ТЗ за ключовим словом... + +scheduler: + success-message: Замовлення {orderId} заплановано на {orderAt}. + info-message: Планування замовлення {orderId} скасовано. + title: Планувальник + unscheduled-orders: Незаплановані замовлення + scheduled-orders: Заплановані замовлення + unauthorized-to-schedule: У вас немає прав для планування замовлень. + scheduling-for: Планування для {orderId} + add-shift: Додати зміну + create-shift: Створити зміну + edit-shift: Редагувати зміну + delete-shift: Видалити зміну + delete-shift-confirm: Ви дійсно бажаєте видалити цю зміну? Цю дію неможливо скасувати. + shift-created: Зміну успішно створено. + shift-updated: Зміну успішно оновлено. + shift-title: Назва зміни + shift-title-placeholder: напр. Ранковий рейс доставки + shift-start: Початок зміни + shift-end: Кінець зміни + shift-notes-placeholder: Додаткові примітки про цю зміну + select-start-datetime: Виберіть дату та час початку + select-end-datetime: Виберіть дату та час закінчення + select-driver: Вибрати водія + upcoming-shifts: Майбутні зміни + no-upcoming-shifts: Майбутніх змін не заплановано. + hours-of-service: Робочий час (Hours of Service) + daily-driving-hours: Денний час водіння + weekly-hours: Тижневі години + compliance-status: Статус відповідності нормам + set-availability: Встановити доступність + availability-set: Доступність успішно збережено. + availability-time-off: Доступність та відпустки/відгули + no-availability-restrictions: Обмежень доступності не встановлено. + available: Доступний + unavailable: Недоступний + unavailable-time-off: Недоступний / Відпустка + availability-type: Тип доступності + request-time-off: Запросити відпустку / відгул + submit-request: Надіслати запит + time-off-requested: Запит на відгул надіслано. + delete-availability: Видалити доступність + delete-availability-confirm: Ви дійсно бажаєте вилучити цей запис доступності? + availability-deleted: Запис доступності вилучено. + start-date: Дата початку + end-date: Дата закінчення + select-start-date: Виберіть дату початку + select-end-date: Виберіть дату закінчення + reason: Причина + reason-placeholder: напр. Особисті справи + notes-placeholder: Додаткові примітки + shift: Зміна + add-shift-hint: Натисніть "Додати зміну", щоб запланувати нову зміну. + time-off-exceptions: Відгули та винятки + no-exceptions: Відгулів або винятків не зафіксовано. + exception-approved: Виняток схвалено. + exception-rejected: Виняток відхилено. + exception-deleted: Виняток вилучено. + delete-exception: Видалити виняток + delete-exception-confirm: Ви дійсно бажаєте вилучити цей виняток? + approve: Схвалити + reject: Відхилити + recurring-schedule-created: Періодичний графік створено, зміни матеріалізовано. + recurring-schedule: Періодичний графік + is-recurring: Повторюваний + recurrence-pattern: Шаблон повторення + recurrence-start-date: Починається з + recurrence-end-date: Закінчується (опціонально) + template-name: Назва шаблону + template-name-placeholder: напр. Стандартний тиждень + schedule-name: Назва графіка + schedule-name-placeholder: напр. Стандартний тиждень + one-off-shift: Разова зміна + one-off-shift-help: Увімкніть додавання однієї зміни на конкретну дату замість регулярного графіка. + template-color: Колір + shift-start-time: Час початку зміни + shift-end-time: Час закінчення зміни + break-start-time: Час початку перерви + break-end-time: Час закінчення перерви + day-monday: Понеділок + day-tuesday: Вівторок + day-wednesday: Середа + day-thursday: Четвер + day-friday: П'ятниця + day-saturday: Субота + day-sunday: Неділя + frequency-daily: Щодня + frequency-weekly: Щотижня + frequency-monthly: Щомісяця + today: Сьогодні + orders-tab: Графік замовлень + fleet-schedule-tab: Графік автопарку + break-start: Початок перерви + break-end: Кінець перерви + select-break-start: Виберіть час початку перерви + select-break-end: Виберіть час закінчення перерви + select-date: Виберіть дату + working-days: Робочі дні + recurrence-start: Починається з + recurrence-end: Закінчується + recurring-schedule-help: Визначте повторюваний шаблон змін за днями та часом. + shift-deleted: Зміну успішно видалено. + day-mon: Пн + day-tue: Вт + day-wed: Ср + day-thu: Чт + day-fri: Пт + day-sat: Сб + day-sun: Нд + view-day: День + view-week: Тиждень + undo: Скасувати останню дію + redo: Повторити останню дію + search-orders: Пошук замовлень... + select-all: Вибрати все + all-scheduled: Усі замовлення заплановано + not-scheduled: Не заплановано + expand-sidebar: Розгорнути бічну панель + collapse-sidebar: Згорнути бічну панель + bulk-assign: Призначити {count} замовлень + bulk-assign-title: Масове призначення {count} замовлень + bulk-assign-success: Успішно призначено {count} замовлень. + bulk-assign-order-count: '{count} замовлень вибрано для призначення' + bulk-assign-spacing-hint: Замовлення будуть заплановані послідовно, починаючи з вибраної дати й часу. + assign-to-driver: Призначити водію + assign-orders: Призначити замовлення + orders-to-assign: '{count} замовлень для призначення' + scheduled-date: Запланована дата + conflict-title: Виявлено конфлікт у графіку + conflict-description: '{orderTracking} перетинається з існуючим замовленням водія {driverName}.' + conflicting-orders: Конфліктні замовлення + conflict-resolution-prompt: Як ви бажаєте вирішити цей конфлікт? + auto-adjust: Автокоригування часу + assign-anyway: Все одно призначити + clear-selection: Очистити вибір +modals: + new-custom-field-group: + modal-title: Створити нову групу настроюваних полів + group-name: Назва групи + + assign-driver: + select-driver: Вибрати водія + select-assign: Вибрати водія для призначення + + clone-config-form: + name: Назва конфігурації + description: Опис конфігурації + + driver-assign-order: + order: Вибрати замовлення + assign-order: Вибрати замовлення для призначення + + driver-assign-vehicle: + vehicle: Вибрати ТЗ + assign-vehicle: Вибрати ТЗ для призначення + + driver-assign-vendor: + vendor: Вибрати постачальника + + driver-details: + job: Поточна робота + view: Переглянути на карті + + driver-form: + select-vendor: Вибрати постачальника + select-vehicle: Вибрати ТЗ + + edit-meta-form: + add: Додати метаполе + + entity-form: + name-text: Назва позиції або сутності. + id-text: Використовуйте це поле для власного або внутрішнього ідентифікатора. + sku: Артикул (SKU) + sku-text: Номер облікової одиниці (за наявності). + description: Опис + description-text: Додатковий опис сутності. + price-title: Ціноутворення та вартість + price: Ціна + price-text: Ціна сутності. + sale-price: Акційна ціна + sale-price-text: Знижена або акційна ціна сутності. + declare: Декларована вартість + declare-text: Декларована вартість сутності (корисно для страхування). + measurement-title: Габарити та вага + length: Довжина + length-text: Довжина сутності. + width: Ширина + width-text: Ширина сутності. + height: Висота + height-text: Висота сутності. + weight: Вага + weight-text: Вага сутності. + + entity-meta-field-prompt: + name: Ключ метаполя + + fleet-form: + title: Назва автопарку + assign: Призначити до зони обслуговування + assign-text: Виберіть зону обслуговування для призначення автопарку + zone: Призначити до зони + zone-text: Виберіть зону для призначення автопарку + task: Завдання / Місія + task-text: Опишіть основне завдання або місію цього автопарку. + select-task: Вибрати завдання + status-text: Виберіть статус автопарку. + select-status: Вибрати статус + + fuel-report-details: + fuel: Пальне / Об'єм + + fuel-report-form: + select-driver: Вибрати водія + select-vehicle: Вибрати ТЗ + volume: Об'єм + volume-text: Виберіть метричну одиницю та об'єм пального. + + group-details: + name: Назва групи + member: Учасники + address: Адреса + + group-form: + title: Назва групи + users-add: Вибрати користувачів для додавання до групи + search: Пошук та вибір користувачів для додавання до цієї групи. + select-user: Вибрати користувача для додавання + no-users: До групи не додано жодного користувача + + install-prompt: + message: Ви намагаєтеся встановити розширення {extensionName}. Бажаєте продовжити? + sub-message: Деякі розширення вимагають доступу до ваших ресурсів. + + issue-details: + assigned: Призначено + type: Тип проблеми + category: Категорія проблеми + + issue-form: + reported: Повідомив + select-reporter: Вибрати заявника + assigned: Призначено + select-assignee: Вибрати виконавця + select-driver: Вибрати водія + select-vehicle: Вибрати ТЗ + select-type: Вибрати тип + select-category: Вибрати категорію + type: Тип проблеми + type-text: Виберіть тип проблеми + category: Категорія проблеми + category-text: Виберіть категорію проблеми + report: Звіт про проблему + tags: Теги проблеми + add-tags: Додати теги + select-status: Виберіть статус проблеми + + map-layer-form: + layer-type: Тип шару + layer-type-text: Виберіть тип шару, який ви хочете створити. + select-layer-type: Виберіть тип шару... + service-area: Зона обслуговування + service-area-text: Виберіть зону обслуговування, до якої належить ця зона. + select-service-area: Виберіть зону обслуговування для призначення + set-name: Встановіть відображувану назву для {options}. + service-type: Тип зони обслуговування + service-type-text: Виберіть визначення типу для цієї зони обслуговування. + select-service-type: Виберіть тип зони обслуговування... + customize-border: Налаштуйте колір межі для цього {options}. + customize-fill-color: Налаштуйте колір заливки для цього {options}. + service-area-country: Країна зони обслуговування + service-area-country-text: Опціонально вкажіть країну розташування. + + new-order-config: + name: Назва конфігурації + description: Опис конфігурації + tags: Теги конфігурації + add-tags: Додати теги + + order-assign-driver: + select-driver: Вибрати водія + select-assign: Вибрати водія для призначення + + order-config-new-status: + new-status: Новий статус + status-text: Введіть новий статус активності для додавання до потоку + + order-event: + schedule: Запланувати + unschedule: Скасувати планування + + order-form: + id-text: Використовуйте це поле для власного або внутрішнього ідентифікатора замовлення. + schedule-text: Дата та час відправки замовлення. + customer: Клієнт + customer-text: Опціонально призначте клієнта замовлення. + select-customer: Вибрати клієнта + facilitator: Посередник + facilitator-text: Опціонально призначте посередника. + select-facilitator: Вибрати посередника + assign-driver: Призначити водія + assign-driver-text: Призначте водія для виконання замовлення. + select-driver: Вибрати водія + assign-vehicle: Призначити ТЗ + assign-vehicle-text: Призначте ТЗ для виконання замовлення. + select-vehicle: Вибрати ТЗ + + order-import: + loading-message: Обробка імпорту... + drop-upload: Перетягніть для завантаження + invalid: Недійсний + ready-upload: готово до завантаження. + upload-spreadsheets: Завантажити електронні таблиці + drag-drop: Перетягніть файли таблиць у цю область + button-text: або виберіть електронні таблиці для завантаження + spreadsheets: електронні таблиці + upload-queue: Черга завантаження + + order-label: + loading: Завантаження маркування... + + order-new-activity: + select-message: Виберіть статус активності для оновлення відстеження або введіть власний статус. + order-currently-message: Це замовлення наразі в статусі {options}, додаткова активність перезапише цей статус. + not-recommend-message: Fleetbase не рекомендує це робити, якщо ви не впевнені у своїх діях. + activity-options: Параметри активності + activity: активність + dispatch: Це запустить процес відправки замовлення. + status: Власний статус активності + details: Власні деталі активності + code: Власний код активності + fleetbase-message: Fleetbase не рекомендує це робити, якщо ви повністю не розумієте наслідки. + + order-route-form: + toggle: Переключити декілька точок розвантаження + optimize: Оптимізувати маршрут + optimize-text: Fleetbase автоматично оптимізує маршрут. + add: Додати точку маршруту + add-text: Скористайтеся кнопкою `Додати точку маршруту` для створення нових зупинок. + select-waypoint: Вибрати точку маршруту + pickup: Точка завантаження + edit-address: Редагувати адресу + select-pickup: Вибрати точку завантаження + invalid: Недійсні координати! + dropoff: Точка розвантаження + select-dropoff: Вибрати точку розвантаження + return: Точка повернення + select-return: Вибрати точку повернення + + place-assign-vendor: + select-vendor: Вибрати постачальника + + place-details: + view: Переглянути на карті + + policy-form: + name: Назва політики + name-text: Введіть назву для вашої політики + description: Опис політики + description-text: Введіть опис для вашої політики + permissions: Вибрати дозволи + + role-form: + name: Назва ролі + name-text: Введіть назву для цієї ролі + permissions: Вибрати дозволи + + select-payment-method: + card-ending: Картка, що закінчується на {method} + + service-area-form: + name: Назва зони обслуговування + name-text: Встановіть відображувану назву для зони обслуговування. + type: Тип зони обслуговування + type-text: Виберіть тип зони обслуговування. + select-type: Виберіть тип зони обслуговування... + border-color: Колір межі зони обслуговування + customize-border-color: Налаштуйте колір межі. + area-color: Колір зони обслуговування + customize-area-color: Налаштуйте колір заливки. + country: Країна зони обслуговування + country-text: Опціонально вкажіть країну. + + set-password: + message: Ласкаво просимо до Fleetbase! Встановіть новий пароль для продовження. + new-password: Новий пароль + confirm-password: Підтвердьте пароль + help-text: Введіть пароль довжиною не менше 8 символів. + + uninstall-prompt: + message: Ви намагаєтеся видалити розширення "{options}". Бажаєте продовжити? + sub-message: Усі конфігурації та персональні налаштування будуть втрачені. + + vehicle-details: + make: Марка + model: Модель + year: Рік + driver-assigned: Призначений водій + no-driver-assigned: Водія не призначено + vehicle-assigned: Призначений ТЗ + no-vehicle-assigned: ТЗ не призначено + avatar: Аватар + model-information: Інформація про модель + acceleration: Розгін 0-100 км/год + body: Кузов + doors: Двері + length: Довжина (мм) + seats: Місць + top-speed: Максимальна швидкість (км/год) + transmission: Тип трансмісії + weight: Вага (кг) + wheelbase: Колісна база (мм) + width: Ширина (мм) + engine-information: Інформація про двигун + engine-bore: Діаметр циліндра (мм) + cc: Об'єм (см³) + compression: Ступінь стиснення + cylinder: Циліндри + position: Розташування + power-ps: Потужність (к.с. / ps) + power-rpm: Потужність (об/хв) + stroke: Хід поршня (мм) + torque-nm: Крутний момент (Нм) + torque-rpm: Крутний момент (об/хв) + valves: Клапанів на циліндр + fuel-information: Інформація про пальне + fuel: Пальне + fuel-cap: Об'єм бака (л) + liters-city: Витрата на 100 км (місто) + liters-highway: Витрата на 100 км (траса) + liters-mixed: Витрата на 100 км (змішаний) + vehicle-form: + map-avatar: Вибрати аватар для карти + map-avatar-text: Виберіть аватар для відображення на картах при відстеженні. + select-avatar: Вибрати аватар + make: Марка ТЗ + model: Модель ТЗ + year: Рік випуску ТЗ + select-driver: Вибрати водія + body: Тип кузова ТЗ + doors: Кількість дверей + type: Тип приводу ТЗ + length: Довжина ТЗ (мм) + seats: Кількість місць + top-speed: Максимальна швидкість ТЗ (км/год) + transmission: Тип трансмісії ТЗ + weight: Вага ТЗ (кг) + wheelbase: Колісна база ТЗ (мм) + width: Ширина ТЗ (мм) + engine-bore: Діаметр циліндра (мм) + cc: Об'єм (см³) + compression: Ступінь стиснення двигуна + cylinder: Циліндри двигуна + position: Розташування двигуна + power-ps: Потужність двигуна (к.с.) + power-rpm: Потужність двигуна (об/хв) + stroke: Хід поршня (мм) + torque-nm: Крутний момент двигуна (Нм) + torque-rpm: Крутний момент двигуна (об/хв) + fuel-type: Тип пального + fuel-cap: Об'єм паливного бака (л) + + vendor-details: + sandbox: Песочниця (Sandbox) + host: Хост + namespace: Простір імен + + vendor-form: + choose-vendor: Вибрати інтегрованого постачальника + create-vendor: Створити власного постачальника + credentials: Облікові дані + options: Параметри + hide-advanced: Приховати додаткові + show-advanced: Показати додаткові + custom-host: Власний хост + host-text: Опціонально вкажіть власний хост. + custom-namespace: Власний простір імен + namespace-text: Опціонально вкажіть власний простір імен або версію API. + webhook: URL-адреса Webhook + webhook-text: Необхідно для отримання оновлень активності. Змінюйте обережно! + cancel-credentials: Скасувати скидання облікових даних + reset-message: Чутливі дані можна лише скинути шляхом повторного введення. + optionally: Опціонально вкажіть власний + configure-vendor: для налаштування цього постачальника. + select: Вибрати + sandbox: Песочниця (Sandbox) + name-text: Назва постачальника (зазвичай назва компанії). + email-text: Електронна пошта постачальника. + phone-text: Телефон постачальника. + website-text: Вебсайт постачальника. + select-address: Вибрати адресу + + zone-form: + name: Назва зони + name-text: Встановіть відображувану назву зони. + border-color: Колір межі зони + customize-border: Налаштуйте колір межі. + zone-color: Колір зони + customize-fill-color: Налаштуйте колір заливки. + optionally: Опціонально додайте опис зони. + description-zone: Опис зони + +radar: + title: Радар + eyebrow: Ресурси + subtitle: Усе за ресурсами, обслуговуванням, інспекціями та персоналом, що потребує рішення — по одному рядку на запис. + summary: "{open} відкрито · {snoozed} відкладено · остання синхронізація {time}" + search-placeholder: Фільтрувати елементи + reload: Перезавантажити + new-notice: Нове сповіщення + showing: "Відображається {count} з {total} · згруповано за терміном" + sources-warning: "Не вдалося завантажити деякі джерела: {sources}" + briefing: + title: Ранковий брифінг + score: Оцінка здоров'я автопарку + of-100: "/ 100" + vs-yesterday: "{delta} порівняно з вчора" + no-history: перше зчитування + generated: "Згенеровано {time} · джерела пов'язані" + yesterday: "Вчора: закрито {closed} прогалин, перенесено {rolled}" + decisions: Рішення, що чекають на вас + decisions-count: "{shown} з {total}" + nothing-to-decide: Немає рішень для прийняття. + confirm: Підтвердити + not-now: Не зараз + undo: Скасувати дію + wake-now: Відновити зараз + confirmed: "Підтверджено {time}" + snoozed-until: "Відкладено до {time}" + filter: Фільтр + collapse: Згорнути брифінг + expand: Показати брифінг + open-record: Відкрити запис + working: Обробка… + views: + list: Список + agenda: Порядок денний + agenda: + window-24h: Наступні 24 год + window-7d: 7 днів + overdue: Прострочено + later: Пізніше + anytime: Будь-коли + anytime-count: "{count} без дати" + no-events: Немає подій у цьому часовому вікні. + drag-hint: Перетягніть елемент на доріжку, щоб призначити йому час. + print: Друк передавальної відомості + see-all: "Переглянути всі {count} →" + planned: Заплановано + now: зараз + lanes: + shifts: Зміни + maintenance: Обслуговування + expiries: Завершення термінів + notices: Сповіщення + shift-states: + on_shift: на зміні + not_online: поза мережею + upcoming: майбутні + ended: завершено + summary: "{shifts} змін у вікні · {overdue} прострочено · {anytime} без дати" + handover: + title: Завершення зміни + ends-in: "через {minutes}" + orders-on-driver: "{count} активних замовлень все ще на водієві {name}" + finishes-after: завершується після закінчення зміни + suggested: Пропонована передача + on-shift-till: "на зміні до {time}" + away: "{distance} км звідси" + reassign: "Перепризначити {count} замовлень на водія {name}" + extend: Продовжити зміну на 1 год + snooze: Відкласти на 1 год + no-cover: Немає нікого на зміні для підміни; продовжте зміну або перепризначте вручну. + reassigned: "Замовлення переміщено до {name}." + extended: Зміну продовжено на одну годину. + loading: Пошук підміни… + tabs: + open: Відкриті + snoozed: Відкладені + resolved: Вирішені + pills: + overdue: Прострочено + due_week: Термін цього тижня + unassigned: Не призначено + issues: Відкриті проблеми + inspections: Інспекції + shifts: Зміни графіку + expiring: Загасає через 30д + low_stock: Низький запас + fuel: Незідставлене пальне + notices: Сповіщення + groups: + overdue: Прострочено + today: Сьогодні + week: Цього тижня + later: Пізніше + none: Без дати + rules: + maintenance_overdue: ТО + maintenance_due_soon: ТО + inspection_due: Інспекція + inspection_failed: Інспекція + inspection_unresolved: Інспекція + inspection_draft: Інспекція + inspection_link_pending: Інспекція + vehicle_inspection_failed: Інспекція + work_order_overdue: Заявка на роботу + work_order_blocked: Заявка на роботу + issue_open: Проблема + shift_late_start: Зміна + shift_no_vehicle: Зміна + shift_handover: Передача зміни + driver_without_vehicle: Не призначено + vehicle_without_driver: Простоює + vehicle_without_device: Пристрій + device_unattached: Пристрій + license_expiring: Посвідчення + lease_expiring: Лізинг + fuel_unmatched: Пальне + part_low_stock: Запчастини + notice: Сповіщення + actions: + acknowledge: Підтвердити + snooze: Відкласти + wake: Відновити зараз + assign: Призначити + unassign: Очистити власника + plan: Призначити час + unplan: Очистити час + resolve: Вирішити + open-record: Відкрити запис + assign-vehicle: Призначити ТЗ + assign-driver: Призначити водія + create-work-order: Створити заявку на роботу + create-issue: Створити проблему + mark-resolved: Позначити як вирішене + send-pin: Надіслати PIN + revoke-link: Відкликати посилання + revoke-links: "Відкликати {count, plural, one {# посилання} few {# посилання} other {# посилань}}" + match-vehicle: Зіставити з ТЗ + ignore: Ігнорувати + attach: Приєднати до ТЗ + call: Зателефонувати + cover-shift: Підмінити зміну + reassign: Перепризначити замовлення + extend-shift: Продовжити зміну на 1 год + select: Вибрати + deselect: Скасувати вибір + more: Більше + snooze: + 1h: 1 година + 4h: 4 години + tomorrow: Завтра вранці + next-week: Наступного тижня + pick: Вибрати дату… + pick-title: Відкласти до + pick-label: Відновити + state: + acknowledged-by: "Підтверджено {name} о {time}" + acknowledged: Підтверджено + snoozed-until: "Відкладено до {time}" + assigned-to: "Призначено {name}" + unassigned: Не призначено + planned-for: "Заплановано на {time}" + resolved-by: "Вирішено {name} о {time}" + resolved-auto: Закрито в записі + bulk: + selected: "Вибрано {count}" + clear: Очистити + summary: "{count} елементів" + keys: + move: перемістити + select: вибрати + acknowledge: підтвердити + snooze: відкласти + assign: призначити + open: відкрити запис + close: закрити + empty: + title: Нічого не потребує рішення. + snoozed-hint: "{count} відкладених елементів відновляться цього тижня." + nothing-snoozed: Нічого не відкладено. + wakes: "відновиться о {time}" + filtered-title: Нічого не відповідає цим фільтрам. + filtered-hint: Очистіть фільтр або пошук для перегляду деталей. + snoozed-title: Нічого не відкладено. + snoozed-tab-hint: Відкладені елементи повернуться до списку після закінчення заданого часу. + resolved-title: Нічого не вирішено за останні 7 днів. + resolved-hint: Елементи закриваються тут, коли змінюється запис або вирішується сповіщення. + saved-views: + title: Вигляди + save-current: Зберегти поточний + name-prompt: Назвіть цей вигляд + name-placeholder: напр. Термін цього тижня + saved: Вигляд збережено. + delete: Видалити вигляд + deleted: Вигляд вилучено. + defaults: + my-assignments: Мої призначення + shift-changes: Зміни графіку + due-this-week: Термін цього тижня + unmatched-fuel: Незіставине пальне + notice: + title: Нове сповіщення + message: Повідомлення + message-placeholder: Територія закрита в суботу на ремонт — перемістіть причепи до Пт 18:00 + severity: Рівень важливості + due: Граничний термін + scope: Застосовується до + scope-placeholder: Двір 3 · весь автопарк + create: Опублікувати сповіщення + created: Сповіщення опубліковано. + delete: Видалити сповіщення + deleted: Сповіщення видалено. + severities: + info: Інформація + warning: Попередження + critical: Критично + drawer: + failed-items: Елементи з помилками + state: Стан + subject: Запис + due: Термін + window: Зміна + details: Деталі + prompts: + select-vehicle: Виберіть ТЗ + select-user: Виберіть особу + assign-vehicle-title: "Призначити ТЗ для {name}" + assign-driver-title: "Призначити водія для {name}" + match-vehicle-title: Зіставити цю транзакцію з ТЗ + create-work-order-title: Створити заявку на роботу з цього графіка? + create-work-order-body: Заявка на роботу відкривається з параметрами за замовчуванням та датою виконання з графіка. + ignore-title: Ігнорувати цю транзакцію? + ignore-body: Вона залишить чергу незіставлених і не буде прив'язана до ТЗ. + attach-device-title: Приєднати цей пристрій до ТЗ + revoke-link-title: Відкликати це посилання інспекції? + send-pin-title: Надіслати PIN інспекції + send-pin-to: "Як {name} має отримати посилання та PIN?" + send-pin-body: Як надіслати посилання та PIN? + send-pin-unavailable: У отримувача посилання немає адреси електронної пошти або номера телефону. + revoke-link-body: Посилання негайно припинить працювати. + bulk-revoke-link-title: "Відкликати {count, plural, one {# посилання інспекції} few {# посилання інспекції} other {# посилань інспекції}}?" + bulk-revoke-link-body: Посилання негайно припинять працювати. + assign-user-title: "Хто власник цього?" + assign-user-help: Власник відображається в рядку та у вигляді 'Мої призначення'. + no-selection: Спочатку виберіть елемент. + toasts: + acknowledged: Підтверджено. + snoozed: "Відкладено до {time}." + woken: Повернуто до списку. + assigned: "Призначено {name}." + unassigned: Власника очищено. + planned: Час встановлено. + resolved: Вирішено. + work-order-created: Заявку на роботу створено. + vehicle-assigned: ТЗ призначено. + driver-assigned: Водія призначено. + matched: Транзакцію зіставлено. + ignored: Транзакцію проігноровано. + device-attached: Пристрій приєднано. + pin-sent: PIN надіслано. + link-revoked: Посилання відкликано. + links-revoked: "{count, plural, one {# посилання відкликано} few {# посилання відкликано} other {# посилань відкликано}}." + links-revoke-failed: "{count, plural, one {# посилання не вдалося відкликати} few {# посилання не вдалося відкликати} other {# посилань не вдалося відкликати}}." + bulk-done: "Оновлено елементів: {count}." +widget: + refresh: Оновити + radar: + title: Радар + description: Що потребує рішення в автопарку прямо зараз. + open: відкрито + overdue: прострочено + snoozed: відкладено + open-radar: Відкрити Радар + kpi-earnings: + title: Доходи + kpi-distance: + title: Пройдена відстань + kpi-aov: + title: Середній чек замовлення + kpi-active-orders: + title: Активні замовлення + kpi-drivers-online: + title: Водії в мережі + kpi-open-issues: + title: Відкриті проблеми + operations-pulse: + title: Пульс операцій + revenue-trend: + title: Тренди доходів + orders-by-status: + title: Обсяг замовлень за статусом + on-time-delivery: + title: Доставка вчасно + top-drivers: + title: Кращі водії + fuel-efficiency: + title: Вартість пального та ефективність + issues-insights: + title: Аналітика проблем + maintenance-overview: + title: Огляд технічного обслуговування + geofence-violations: + title: Порушення геозон + live-fleet: + title: Карта автопарку в реальному часі + get-started: + title: Початок роботи з Fleet-Ops + subtitle: Налаштуйте основні параметри та виконайте перший процес відправки. + completed: завершено + preview-title: Робочий процесс першого замовлення + recommended-features: + title: Рекомендовані функції для вас + subtitle: Досліджуйте інструменти, які допомагають новим операційним командам працювати ефективніше. + learn-more: Дiзнатися більше + fleet-ops-quickstart: + message: Давайте подивимося, що можна зробити для швидкого запуску операцій... + start-task: Розпочати завдання + key-metrics: + title: Метрики Fleet-Ops + earnings: Доходи + fuel-expenses: Витрати на пальне + traveled: Пройдена відстань + total-time: Загальний час + order-scheduled: Заплановані замовлення + order-completed: Завершені замовлення + order-progress: Замовлення в процесі + order-canceled: Скасовані замовлення + driver-online: Водії в мережі + customers: Клієнти + open-issue: Відкриті проблеми + closed-issues: Закриті проблеми + live-order-map: + loading: Завантаження замовлень у процесі виконання... + assigned: Призначено водія + no-order: Немає замовлень у процесі + message: Коли замовлення виконуються, вони відображаються у цьому віджеті з активністю в реальному часі. + recent-orders: + title: Останні замовлення + loading: Завантаження останніх замовлень... + no-orders: Немає замовлень на цьому тижні + message-part-1: На цьому тижні замовлень не було, розпочніть рух шляхом + message-part-2: створення нового замовлення + message-part-3: або дозвольте вашим клієнтам надсилати замовлення через API. + transactions: + title: Останні транзакції + loading: Завантаження останніх транзакцій... + no-transactions: Немає транзакцій на цьому тижні + message: Коли замовлення проходять із тарифами на послуги, Fleetbase відстежує транзакції автоматично. + +display-panel: + no-address: Немає адреси {type}! + no-address-message: Адреса відсутня! + +driver-card: + no-fleets: Немає автопарків + +global-search: + search: Пошук за ключовим словом... + +integrated-order-details: + order-id: ID замовлення + quotation-id: ID розрахунку + driver-id: ID водія + link: Посилання для поширення + price-breakdown: Деталізація ціни + metadata: Метадані + +geofence: + prompts: + use-draw-controls-create-service-area: Використовуйте елементи малювання праворуч для створення зони обслуговування. З'єднайте точки для збереження. + use-draw-controls-create-zone: Використовуйте елементи малювання праворуч для створення зони всередині зони обслуговування. З'єднайте точки для збереження. + no-layer-found-for-resource: Не знайдено шар для цього {resource}. + editing-enabled: Редагування увімкнено — відкоригуйте вершини та натисніть галочку редагування для застосування. + resource-boundaries-updated: >- + Межі {resource} успішно оновлено. + edit-canceled: Редагування скасовано. + failed-to-update-resource: Не вдалося оновлення {resource}. + +live-map: + show-coordinates: Показати координати + center-map: Центрувати карту тут + zoom-in: Наблизити + zoom-out: Віддалити + toggle-draw-controls: Переключити елементи малювання + hide-draw: Приховати елементи малювання + enable-draw: Увімкнути елементи малювання + create-new-service: Створити нову зону обслуговування + focus-service: 'Фокус на зоні обслуговування: {serviceName}' + view-driver: 'Переглянути водія: {driverName}' + edit-driver: 'Редагувати водія: {driverName}' + delete-driver: 'Видалити водія: {driverName}' + view-vehicle-for: 'Переглянути ТЗ для: {driverName}' + view-vehicle: 'Переглянути ТЗ: {vehicleName}' + edit-vehicle: 'Редагувати ТЗ: {vehicleName}' + delete-vehicle: 'Видалити ТЗ: {vehicleName}' + edit-zone: 'Редагувати зону: {zoneName}' + delete-zone: 'Видалити зону: {zoneName}' + assign-zone: 'Призначити автопарк до зони: {zoneName}' + blur-service: 'Приховати зону обслуговування: {serviceName}' + create-zone: 'Створити зону всередині: {serviceName}' + assign-fleet: 'Призначити автопарк до зони обслуговування: {serviceName}' + edit-service: 'Редагувати зону обслуговування: {serviceName}' + delete-service: 'Видалити зону обслуговування: {serviceName}' + service-area: Зона обслуговування + edit-boundaries: 'Редагувати межі: {resource}' + zone: Зона + +order-config: + create-new-title: Створити нову конфігурацію замовлень + warning-message: Конфігурація замовлень вимагає назви + success-message: Нову конфігурацію замовлення успішно створено. + no-order-warning: Не вибрано конфігурацію замовлень. + saved-success: 'Конфігурацію замовлення {orderName} збережено.' + enter-name-title: Введіть назву клонованої конфігурації + no-config-warning: Назву конфігурації не введено. + cloned-success: Конфігурацію замовлення успішно клоновано. + body: Після видалення цієї конфігурації замовлень ви більше не зможете створювати замовлення з її використанням. Ви впевнені? + uninstall-title: Видалити конфігурацію + uninstall-success: Розширення {extensionName} видалено. + activity-flow-editor: + add-status: Додати новий статус до потоку + no-status: Статус не введено. + overwrite: Це перезапише існуючий статус! + overwrite-text: Статус {statusName} вже існує в цьому потоці. Бажаєте перезаписати попередній статус {statusName}? + overwrite-button: Так, перезаписати! + remove: Видалити статус + remove-text: Ви дійсно бажаєте видалити цей статус? Усі пов'язані активності та логіку буде видалено разом зі статусом. + delete-button: Так, видалити + unable-warning: Статус {status} має бути в такій послідовності, зсув неможливий. + order-warning: Створений статус завжди має бути першим у послідовності потоку замовлення. + title: Потік активностей + message: Конфігурація потоку активностей дозволяє визначати різні типи статусів відстеження для цього типу замовлення. Також можна налаштувати логіку. + order-flow: Потік замовлення + waypoint-flow: Потік точок маршруту + new-activity: Нова активність + activity-details: Деталі активності + logic-stack: Стек логіки + add-logic: Додати логічну умову + select-field: Вибрати поле + select-operator: Вибрати оператор + enter-value: Введіть значення для порівняння + no-logic: Логічні умови не застосовані + details-editor: + success: Встановлено + key-text: Передайте цей ключ у тип замовлення для його конфігурації. + namespace-text: Унікальний простір імен для цього розширення. + id: ID розширення + id-text: Унікальний ID для цього розширення. + version: Версія розширення + version-text: Версія цього розширення. + clone: Клонувати конфігурацію + uninstall: Видалити розширення + delete: Видалити конфігурацію + entities-editor: + warning-message: Не введено назву ключа метаполя. + title: Сутності + message: Конфігурація сутностей дозволяє визначати типи сутностей, які мають бути доступні для вибору в цьому типі замовлення (використовуються як шаблони). + type: Тип сутності + add-meta: Додати метаполе + fields-editor: + text-field: Текстове поле + boolean: Логічне значення (Boolean) + boolean-text: Дозволяє користувачеві перемикати значення прапорцем (True/False) + dropdown: Выпадаючий список + dropdown-text: Дозволяє вибрати опцію з випадного списку + datetime: Вибір дати та часу + datetime-text: Дозволяє вибрати дату та час + port: Вибір порту + port-text: Дозволяє користувачеві вибрати порт + vessel: Вибір судна + vessel-text: Дозволяє користувачеві вибрати судно + warning-message: Назву групи не введено. + field-title: Метаполя + new-field: Нове налаштовуване поле + new-custom-field: Нова група налаштовуваних полів + field-text: Налаштування метаполів дозволить вам визначати власні поля вводу, які застосовуватимуться до замовлень. + custom-field-group: Група налаштовуваних полів + default-field: Поля за замовчуванням + custom-field: Налаштовуване поле + type: Тип поля + select-type: Оберіть тип поля + add-option: Додати варіант + select-message: Оберіть конфігурацію замовлення для зміни або натисніть "Нова конфігурація", щоб створити нову. + new-config: Нова конфігурація + loading-configuration: Завантаження ваших конфігурацій замовлень... + select-order: Оберіть конфігурацію замовлення + save-changes: Зберегти зміни + details: Деталі + fields: Налаштовувані поля + flow: Потік активності + entities: Сутності + +order-list-overlay: + search: Пошук замовлень... + selected: Обрано + assign: Призначити водієві... + cancel: Скасувати замовлення... + delete: Видалити замовлення... + dispatch: Диспетчеризувати замовлення... + actions: Дії + create-order: Створити нове замовлення... + create-fleet: Створити новий автопарк... + active-orders: Активні замовлення + unassigned-orders: Непризначені замовлення + +route-list: + expand: Натисніть, щоб розгорнути + collapse: Натисніть, щоб згорнути + more-waypoints: ще маршрутних точок + +settings: + avatar-management: Управління аватарками + custom-fields: Налаштовувані поля + notifications: + fleet-ops-notification-settings: Налаштування сповіщень Fleet-Ops + fleet-ops-notifications: Сповіщення Fleet-Ops + configure-notifications: Налаштувати сповіщення + select-notifiables: Оберіть отримувачів сповіщень + routing: + fleet-ops-routing-settings: Налаштування маршрутизації Fleet-Ops + fleet-ops-routing: Маршрутизація Fleet-Ops + configure-routing: Налаштувати маршрутизацію + select-routing-service: Оберіть сервіс маршрутизації + routing-service: Сервіс маршрутизації + routing-service-help-text: Оберіть сервіс, який відповідає за розрахунок і побудову маршрутів на карті. + select-routing-distance-unit: Оберіть одиницю вимірювання відстані + routing-distance-unit: Одиниця вимірювання відстані + routing-distance-unit-help-text: Одиниця, що використовується для розрахунку відстані та маршрутів. + map: + fleet-ops-map-settings: Налаштування карти Fleet-Ops + fleet-ops-map: Карта Fleet-Ops + configure-map: Налаштувати карту + map-provider: Провайдер карти + map-provider-help-text: Оберіть движок карти для відображення інтерактивної операційної карти. Leaflet використовує тайли OpenStreetMap і не потребує ключа API. Google Maps потребує дійсного ключа API Google Maps JavaScript. + select-map-provider: Оберіть провайдера карти + leaflet-tile-url: URL провайдера тайлів Leaflet + leaflet-tile-url-help-text: Необов'язковий шаблон URL для власних XYZ-тайлів Leaflet. Залиште порожнім для використання стандартних тайлів OpenStreetMap, які не потребують API-ключа. + leaflet-dark-tile-url: URL тайлів Leaflet для темного режиму + leaflet-dark-tile-url-help-text: Необов'язковий URL провайдера тайлів для темної теми консолі. Залиште порожнім для використання основної теми або стандартних тайлів OpenStreetMap. + google-maps-map-type: Тип карти + google-maps-map-type-help-text: Стиль карти за замовчуванням при активних Google Maps. + select-map-type: Оберіть тип карти + show-traffic-layer: Показувати шар трафіку + show-traffic-layer-help-text: Накладати актуальну інформацію про дорожній рух на карту Google Maps. + show-transit-layer: Показувати шар громадського транспорту + show-transit-layer-help-text: Накладати маршрути громадського транспорту на карту Google Maps. + settings-saved: Налаштування карти успішно збережено. + settings-save-failed: Не вдалося зберегти налаштування карти. Будь ласка, спробуйте ще раз. + navigator-app: + navigator-app-settings: Налаштування застосунку Навігатора + payments: + payments: Платежі + payment-settings: Налаштування платежів + account-not-setup-for-payments: Ваш обліковий запис ще не налаштовано для прийому платежів. + to-accept-payments: Щоб приймати та обробляти платежі, ви повинні пройти процес реєстрації через Stripe. + system-stripe-not-setup: Наразі система не може приймати або обробляти платежі. Зверніться до адміністратора. + loading-settings: Завантаження налаштувань платежів... + onboard: + header-title: Підключення платежів + title-completed: Реєстрацію успішно завершено! + title-incomplete: Пройдіть процес реєстрації, щоб приймати платежі. + subtitle-completed: Реєстрацію успішно завершено, тепер ви можете отримувати платежі від клієнтів та третіх сторін. + subtitle-incomplete: Цей процес необхідно пройти для можливості прийому оплати за замовлення. + button-continue: Продовжити + button-start: Розпочати реєстрацію зараз + button-in-progress: Реєстрація в процесі + scheduling: + scheduling-settings: Налаштування розкладу + materialisation: Матеріалізація + horizon-days: Горизонт розкладу (днів) + horizon-days-help: На скільки днів наперед генерувати зміни при застосуванні шаблону розкладу. + default-shift-duration: Тривалість зміни за замовчуванням (годин) + default-shift-duration-help: Тривалість зміни за замовчуванням, якщо час закінчення не вказано. + hours-of-service: Обмеження часу роботи (HOS) + hos-daily-limit: Денний ліміт водіння (годин) + hos-daily-limit-help: Максимальна кількість годин, яку водій може керувати авто за один день. + hos-weekly-limit: Тижневий ліміт водіння (годин) + hos-weekly-limit-help: Максимальна кількість годин водіння за ковзний 7-денний період. + behaviour: Поведінка + auto-activate-schedule: Автоактивація розкладу + auto-activate-schedule-help: Автоматично переводити розклад із стану Чернетка в Активний при створенні першої зміни. + notify-drivers: Сповіщати водіїв про зміни в розкладі + notify-drivers-help: Надсилати push-сповіщення водіям при створенні, оновленні або видаленні їхніх змін. + schedule-templates: Багаторазові шаблони розкладу + no-templates: Багаторазових шаблонів ще не створено. + new-template: Новий шаблон + edit-template: Редагувати шаблон + delete-template: Видалити шаблон + delete-template-confirm: Ви впевнені, що хочете видалити шаблон "{name}"? Це не вплине на існуючі розклади. + template-deleted: Шаблон успішно видалено. + settings-saved: Налаштування розкладу збережено. +maintenance-schedule: + actions: + trigger-now: Запустити робоче завдання зараз + pause: Призупинити розклад + resume: Відновити розклад +work-order: + actions: + view: Переглянути робоче завдання + edit: Редагувати робоче завдання + delete: Видалити робоче завдання +maintenance: + actions: + view: Переглянути запис техобслуговування + edit: Редагувати запис техобслуговування + delete: Видалити запис техобслуговування +equipment: + actions: + view: Переглянути обладнання + edit: Редагувати обладнання + delete: Видалити обладнання +part: + actions: + view: Переглянути деталь + edit: Редагувати деталь + delete: Видалити деталь + +orchestrator: + settings-title: Налаштування Оркестратора + engine-settings: Движок розподілу + active-engine: Активний движок розподілу + active-engine-help: Оберіть движок для призначення замовлень водіям та транспортним засобам. + auto-allocate-on-create: Авторозподіл при створенні замовлення + auto-allocate-on-create-label: Автоматично запускати розподіл при створенні нового замовлення. + auto-reallocate-on-complete: Автоповторний розподіл при завершенні доставки + auto-reallocate-on-complete-label: Повторно запускати розподіл після виконання доставки для заповнення вивільнених потужностей. + constraint-settings: Обмеження розподілу + max-travel-time: Макс. час у дорозі (секунди) + max-travel-time-help: Максимальний час у дорозі в секундах, який може бути призначений водієві. Вкажіть 0 для скасування обмеження. + balance-workload: Балансування навантаження + balance-workload-label: Рівномірно розподіляти замовлення між доступними водіями та ТЗ. + settings-saved: Налаштування Оркестратора збережено. + workbench-title: Робоча область Оркестратора + unassigned-orders: Непризначені замовлення + no-unassigned-orders: Непризначених замовлень немає — усе виконано! + run-allocation: Запустити Оркестратор + running: Виконується… + plan-ready: План готовий — перевірте та підтвердьте. + commit-plan: Застосувати план + discard-plan: Відхилити + committed: 'Оркестрацію застосовано — призначено {count} замовлень.' + committed-badge: План застосовано + overridden: Перевизначено + on-shift: На зміні + empty-state-title: План оркестрації ще відсутній + empty-state-body: Натисніть "Запустити Оркестратор", щоб згенерувати оптимізований план призначення для непризначених замовлень. + unassigned-warning: Деякі замовлення не вдалося призначити — недостатньо місткості ТЗ або немає водіїв на зміні. + # Панель інструментів та опції + run-orchestration: Запустити Оркестратор + options: Опції + mode: Режим + mode-allocate: Розподілити замовлення + mode-optimize: Оптимізувати маршрути + engine: Движок + allocation-strategy: Стратегія розподілу + allocation-strategy-route-aware: Маршрут + Місткість + allocation-strategy-capacity-only: Тільки місткість + vehicle-packing: Завантаження ТЗ + vehicle-packing-minimize-vehicles: Мінімізувати кількість ТЗ + vehicle-packing-balanced: Збалансоване + respect-skills: Враховувати навички + respect-capacity: Враховувати місткість + return-to-depot: Повернення в депо + # Стан плану + unassigned-count: 'Непризначено:' + clear: Очистити + # Пул замовлень + search-orders: Пошук замовлень… + filter-all: Усі + filter-scheduled: Заплановані + filter-urgent: Термінові + filter-today: На сьогодні + filter-unassigned: Непризначені + filter-imported: Імпортовані + advanced-filters: Розширені фільтри + filter-country: Країна + filter-type: Тип замовлення + filter-status: Статус + filter-date: Запланована дата + filter-country-placeholder: Усі країни + filter-type-placeholder: Усі типи + filter-status-placeholder: Усі статуси + filter-date-placeholder: Будь-яка дата + clear-filters: Очистити фільтри + no-address: Без адреси + selected: обрано + clear-selection: Очистити + # Перемикачі панелей + show-orders: Показати пул замовлень + hide-orders: Сховати пул замовлень + show-drivers: Показати ресурси + hide-drivers: Сховати ресурси + # Панель водіїв / ресурсів + available-drivers: Доступні водії + available-vehicles: Доступні ТЗ + no-available-drivers: Наразі немає доступних водіїв. + no-available-vehicles: Наразі немає доступних ТЗ. + no-search-results: За вашим запитом нічого не знайдено. + search-drivers: Пошук водіїв... + search-vehicles: Пошук ТЗ... + no-position: Без геопозиції + filter-online: Онлайн + filter-offline: Офлайн + filter-on-shift: На завданні + filter-on-job: На завданні + filter-active: Активні + filter-no-driver: Без водія + proposed-routes: Запропоновані маршрути + stops: зупинок + route: Маршрут + drivers-selected: обрано водіїв + vehicles-selected: обрано ТЗ + drop-orders-here: Перетягніть замовлення сюди + # Модальне вікно імпорту + import-orders: Імпорт замовлень + import-step-upload: Завантаження + import-step-map: Зіставлення колонок + import-step-preview: Попередній перегляд та імпорт + drop-file-here: Перетягніть ваш CSV або Excel файл сюди + or: або + browse-file: Оглянути файл + accepted-formats: 'Підтримуються: .csv, .xlsx, .xls' + remove-file: Видалити файл + download-template: Завантажити шаблон імпорту + download-template-desc: Використовуйте наш шаблон для коректного імпорту даних. + download: Завантажити + next: Далі + back: Назад + map-columns-title: Зіставлення колонок + map-columns-desc: 'Зіставте колонки у вашій таблиці' + col-target-field: Цільове поле + col-your-spreadsheet: Ваша таблиця + mapped: зіставлено + mapping-required-hint: 'Поле "Вулиця розвантаження 1" є обов’язковим для продовження.' + select-column: '— пропустити —' + data-preview: 'Перегляд перших 3 замовлень:' + preview-import: Попередній перегляд імпорту + preview-desc: '{count} замовлень готово до імпорту' + errors: помилок + all-rows-valid: Усі рядки коректні + validation-warning: '{count} замовлень мають помилки і будуть пропущені.' + import-confirm: Підтвердити імпорт + importing: 'Імпортування…' + col-pickup: Завантаження + col-dropoff: Розвантаження + col-scheduled: Заплановано + col-weight: Вага + col-customer: Клієнт + col-status: Статус + col-preview-ref: № / ID + col-preview-type: Тип + col-preview-facilitator: Посередник / Експедитор + col-preview-vehicle: ТЗ + col-preview-driver: Водій + col-preview-entities: Товари + col-preview-multi-stop: '{count} зупинок' + col-preview-pickup-dropoff: Завантаження та розвантаження + col-preview-pickup-dropoff-stops: 'Завантаження і розвантаження + {count} зупинок' + error: Помилка + valid: Коректно + import-success: Успішно імпортовано {count} замовлень. + no-valid-rows: Немає коректних замовлень для імпорту. + invalid-file-type: 'Будь ласка, завантажте файл у форматі .csv, .xlsx або .xls.' + parse-error: Не вдалося розібрати файл. Перевірте його формат. + read-error: Не вдалося прочитати файл. + empty-file: Файл порожній. + missing-dropoff: Поле "Вулиця розвантаження 1" є обов’язковим. + stops-count: '{count} зупинок' + # Поля картки замовлення + pickup: Завантаження + dropoff: Розвантаження + scheduled: Заплановано + customer: Клієнт + driver-assigned: Водія призначено + vehicle-assigned: ТЗ призначено + no-vehicle-assigned: ТЗ не призначено + no-driver-assigned: Водія не призначено + created: Створено + # Конструктор фаз + phases: Фази + phases-description: Складіть багатокроковий прогон оркестрації. Кожна фаза послідовно запускає певний режим. + add-phase: Додати фазу + no-phases: Фази не налаштовані. Додайте фазу, щоб розпочати. + phase-n: 'Фаза {n}' + phase-label: Назва фази + save-phase: Зберегти фазу + select-phase-to-edit: Оберіть фазу для редагування її налаштувань. + run-phases: 'Запустити {count} фаз(и)' + include-order-statuses: Включити статуси замовлень + constraints: Обмеження + auto-commit: Автоматично застосовувати після фази + # Режими розподілу + mode-assign-vehicles: Призначити ТЗ + mode-assign-drivers: Призначити водіїв + mode-optimize-routes: Оптимізувати маршрути + # Статуси замовлень для фільтра фаз + status-created: Створено + status-dispatched: Диспетчеризовано + status-started: Розпочато + # Налаштування полів карток + card-fields: Поля карток + card-fields-description: Налаштуйте, які поля відображатимуться на картках замовлень у робочій області. Поля згруповані за конфігурацією замовлень. + card-fields-saved: Поля карток замовлень збережено. + save-card-fields: Зберегти поля карток + standard-fields: Стандартні поля + standard-fields-description: Ці поля відображаються на кожній картці замовлення незалежно від його типу. + config-fields-description: 'Налаштовувані поля для замовлень типу {name}.' + meta-fields: Метаполя + meta-fields-description: Довільні ключі метаданих, що передаються інтеграціями. + no-custom-fields: Для цієї конфігурації замовлень не визначено налаштовуваних полів. + field-tracking: Трек-номер + field-status: Статус + field-scheduled-at: Заплановано на + field-customer: Клієнт + field-type: Тип + field-notes: Примітки + field-priority: Пріоритет + field-dropoff: Адреса розвантаження + field-pickup: Адреса завантаження + # Фільтри панелі ресурсів + filter-available: Доступні + # Переглядач плану + vehicle: ТЗ + expand-all: Розгорнути все + tab-routes: Маршрути + tab-timeline: Хронологія + collapse-all: Згорнути все + collapse-route: Згорнути маршрут + expand-route: Розгорнути маршрут + no-plan: Запустити оркестратор, щоб побачити запропонований план. + unassigned-description: Ці замовлення не вдалося призначити. Вони залишаться в черзі для повторного розподілу. + unassigned: непризначені + # Загальні доповнення + vehicles: Транспортні засоби + drivers: Водії + orders: Замовлення + cancel: Скасувати + no-vehicles: ТЗ не знайдено. + no-drivers: Водіїв не знайдено. + no-orders: Замовлень не знайдено. + expand-panel: Розгорнути панель + collapse-panel: Згорнути панель + # Стан помилки виконання + run-failed-title: Призначень не повернуто + try-again: Спробувати знову + change-resources: Змінити ресурси + no-assignments-returned: Оркестратор не зміг призначити жодного замовлення. Перевірте місткість ТЗ та наявність водіїв на зміні. + # Ярлики списку зупинок + stop-type-pickup: Завантаження + stop-type-dropoff: Розвантаження + pod-required: Потрібне підтвердження доставки (POD) + no-pod: Без POD + +inspection: + form: + overview: Огляд + submissions: Подані інспекції + no-vehicle: Без ТЗ + no-submissions: Ще нічого не подано + no-submissions-description: Інспекції, пройдені за цією формою, з'являться тут. + details: Деталі форми + name: Назва + name-placeholder: Щоденний огляд ТЗ + type: Тип + status: Статус + description: Опис + description-placeholder: Що охоплює ця інспекція + builder: Конструктор форм + fields: Поля + published: Опубліковано + structure: Структура форми + loading-structure: Завантаження структури форми... + no-structure: Ця форма ще не має груп полів. + settings: Налаштування + type-placeholder: Оберіть тип інспекції + status-placeholder: Оберіть статус + setting-create-issue: Створювати запис про несправність при виявленні дефекту + setting-create-issue-help: Непройдена інспекція реєструє несправність для ТЗ. + setting-create-work-order: Відкривати робоче завдання при виявленні дефекту + setting-create-work-order-help: Непройдені пункти стають чеклістом робочого завдання для майстерні. Потрібно увімкнути модуль майстерень. + setting-require-signature: Вимагати підпис водія + setting-require-signature-help: Застосунок водія не відправить форму без підпису. + legacy-checklist: Застарілий чекліст + legacy-checklist-help: Початковий чекліст, збережений тільки для читання. + create: Створити форму інспекції + created: Форму інспекції створено. + updated: Форму інспекції оновлено. + builder: + help: Згрупуйте елементи перевірки, потім додайте поле для кожного з них. + loading: Завантаження конструктора форм... + new-group: Нова група полів + new-field: Нове поле + edit-field: 'Редагувати поле: {label}' + save-field: Зберегти поле + untitled-group: Група без назви + untitled-field: Поле без назви + group-name: Назва групи + group-name-placeholder: Зовнішній огляд + group-description: Опис групи + group-description-placeholder: Що перевіряється в цій групі + grid-size: Колонки + move-up: Перемістити вгору + move-down: Перемістити вниз + no-fields: У цій групі ще немає полів. + empty-title: Немає груп полів + empty-description: Додайте першу групу полів, щоб розпочати створення форми. + delete: Видалити + delete-group-title: Видалити цю групу полів? + delete-group-body: Видалення цієї групи видалить усі поля всередині неї. Це дію неможливо скасувати після збереження форми. + delete-field-title: Видалити це поле? + delete-field-body: Видалення цього поля також видалить усі відповіді, зареєстровані за ним. + field: + label: Ярлик поля + label-placeholder: Стоп-сигнали + name: Системне ім'я поля + name-help: Системне ім'я, за яким інспекція посилається на це поле. + name-placeholder: brake-lights + type: Тип поля + description: Опис поля + description-placeholder: Що перевіряти і як виглядає успішна перевірка + help-text: Текст підказки + help-text-placeholder: Відображається поруч із полем під час заповнення + required: Поле є обов'язковим + editable: Поле можна редагувати + options: Варіанти відповідей + no-options: Варіантів ще немає. + option-placeholder: Додати варіант + add-option: Додати + unit: Одиниця вимірювання + unit-help: Відображається поруч із числом, наприклад, км або годин. + unit-placeholder: км + odometer-role: Це показник одометра + odometer-role-help: Відповідь копіюється в колонку одометра самої інспекції. + on-fail: При невідповідності (помилці) + on-fail-help: Що означає негативна відповідь і що повинен надати інспектор. + default-severity: Критичність за замовчуванням + require-photo-on-fail: Вимагати фото при помилці + require-comment-on-fail: Вимагати коментар при помилці + unsafe-on-fail: Позначати ТЗ як небезпечний для експлуатації при помилці + unsafe-on-fail-help: Помилкова відповідь за замовчуванням позначається як небезпечна. + instructions: Інструкції + instructions-placeholder: Як це перевірити (інструкція для водія) + column-span: Ширина колонки + field-type: + pass-fail: Пройдено / Не пройдено + input: Текст + textarea: Багаторядковий текст + number: Число + select: Вибір зі списку + radio-button: Перемикачі (Radio) + boolean: Так / Ні + date-picker: Дата + date-time-input: Дата та час + file-upload: Фото або файл + signature: Підпис + severity: + low: Низька + medium: Середня + high: Висока + critical: Критична + answer: + pass: Пройдено + fail: Не пройдено + not-applicable: Н/З (Не застосовується) + unanswered: Без відповіді + severity: Критичність + unsafe: Небезпечно для експлуатації + comments: Коментарі + comments-placeholder: Що не так і що потрібно зробити + photos: Фотографії + add-photo: Додати фото + upload-photo: Завантажити фото + no-photo: Фото не прикріплено. + upload-signature: Завантажити підпис + no-signature: Підпис відсутній. + select-placeholder: Оберіть відповідь + note-placeholder: Додати примітку + text-placeholder: Введіть відповідь + no-options: У цьому полі немає варіантів для вибору. + uploads-unavailable: Можна додати з веб-консолі або застосунку водія. + comments-required: Опишіть несправність та необхідні дії + comment-required: Коментар обов'язковий при помилці + photo-required: Фото обов'язкове при помилці + comment-and-photo-required: Коментар + фото обов'язкові при помилці + record: + overview: Огляд + inspection: Інспекція + details: Деталі інспекції + form: Форма + select-form: Оберіть форму інспекції + status: Статус + result: Результат + vehicle: ТЗ + select-vehicle: Оберіть ТЗ + driver: Водій + select-driver: Оберіть водія + odometer: Одометр + odometer-placeholder: Поточний одометр + engine-hours: Мотогодини + engine-hours-placeholder: Поточні мотогодини + answers: Відповіді + loading-form: Завантаження форми інспекції... + loading-answers: Завантаження відповідей... + form-has-no-fields: Ця форма ще не має полів. + summary: Підсумок + failed-of: '{failed} не пройдено з {total} перевірок' + metadata: Метадані + submitted: Подано + submitted-by: Подано користувачем + submission-title: "Результат інспекції: {form}" + due: Термін виконання + create-issue: Створити запис про несправність + create-work-order: Створити робоче завдання + resolve: Вирішити (закрити) + via-link: Через публічне посилання + via-link-pin: Через публічне посилання з PIN-підтвердженням + signed-as: 'Підписано як "{name}"' + name-unverified: Ім'я введено вручну (неавторизований обліковий запис) + resolved: Вирішено + item-results: Результати за пунктами + no-item-results: Результатів за пунктами не записано. + follow-up: Подальші дії + open-form: Відкрити цю форму інспекції + failed: Не пройдено + failed-count: "{count, plural, one {# не пройдено} few {# не пройдено} many {# не пройдено} other {# не пройдено}}" + linked-inspections: "{count, plural, one {Пов'язана інспекція} few {Пов'язані інспекції} many {Пов'язаних інспекцій} other {Пов'язані інспекції}}" + linked-issue: Пов'язана несправність + linked-work-order: Пов'язане робоче завдання + photos: Фотографії + loading-photos: Завантаження фотографій... + audit: Аудит + create: Створити інспекцію + saved: Інспекцію збережено. + updated: Інспекцію оновлено. + form-help: Опублікована форма, на основі якої заповнюється ця інспекція. + choose-a-form: Оберіть форму інспекції, щоб розпочати. + group-has-no-fields: У цьому розділі немає пунктів перевірки. + outstanding: залишилося відповісти + section-outstanding: "{count, plural, one {# залишилося} few {# залишилося} many {# залишилося} other {# залишилося}}" + outstanding-field: "Поле {label} є обов’язковим для заповнення перед відправкою" + review: "Перегляд →" + jump-to: "Перейти до →" + follow-up: + create-issue-title: Створити запис про несправність на основі цієї інспекції? + create-issue-summary: Запис про несправність створюється для ТЗ та водія зі списком непройдених перевірок та найвищим рівнем критичності серед них. + create-issue-accept: Створити запис про несправність + create-work-order-title: Відкрити робоче завдання на основі цієї інспекції? + create-work-order-summary: Робоче завдання відкривається для ТЗ із чеклістом із непройдених перевірок. + create-work-order-accept: Створити робоче завдання + resolve-title: Закрити (вирішити) цю інспекцію? + resolve-summary: Інспекція позначається як вирішена із фіксацією часу. Створені несправності або робочі завдання залишаться відкритими. + resolve-accept: Вирішити інспекцію + from-these: "{count, plural, one {З цієї непройденої перевірки} few {З цих # непройдених перевірок} many {З цих # непройдених перевірок} other {З цих # непройдених перевірок}}" + unsafe-note: "{count, plural, one {# перевірку позначено як небезпечну для експлуатації} few {# перевірки позначено як небезпечні для експлуатації} many {# перевірок позначено як небезпечні для експлуатації} other {# перевірок позначено як небезпечні для експлуатації}}" + due-note: "Термін: {due}, на основі найвищого рівня критичності." + nothing-failed: У цій інспекції немає помилок, тому немає підстав для створення завдань. + submit-title: Відправити цю інспекцію? + submit-summary: Це реєструє інспекцію, фіксує час подачі та підраховує підсумки. Дію неможливо скасувати. + submit-accept: Відправити інспекцію + cancel: Скасувати + link: + public-links: Публічні посилання + existing: Згенеровані посилання + modal-help: "Згенерувати одноразове посилання для форми {form}. Той, хто відкриє його, вводить PIN-код і заповнює форму без входу в систему." + loading: Завантаження посилань... + none: Для цієї форми ще не генерувалися посилання. + load-failed: Не вдалося завантажити ці посилання. + copy: Копіювати + copied: Посилання на інспекцію скопійовано. + revoke: Відкликати + revoked: Посилання на інспекцію відкликано. + generated: Згенеровано + generated-toast: Посилання на інспекцію згенеровано. + publish-first: Опублікуйте форму інспекції перед генерацією публічного посилання. + expires: Закінчується + no-expiry: Без терміну дії + used: Використано + viewed: Востаннє відкрито + never-opened: Ніколи не відкривалося + unassigned: Будь-хто з посиланням та PIN-кодом + url-not-kept: Це посилання було створено до впровадження збереження URL, тому його адресу неможливо відобразити знову. + state-active: Активне + state-expired: Прострочене + state-used: Використано + state-revoked: Відкликано + state-locked: Заблоковано + expires-at: Дійсне до + expires-help: Посилання дійсні протягом 72 годин після створення, якщо не вказано інший час. + assign-to: Призначити + assign-to-help: Необов'язково. Будь-хто у вашій організації може пройти інспекцію; подана інспекція буде зарахована йому. + select-assignee: Оберіть користувача + pin: PIN-код + pin-delivery: Надіслати посилання та PIN-код + pin-delivery-none: Не надсилати, я поділюся сам(а) + pin-delivery-email: Електронною поштою + pin-delivery-sms: SMS-повідомленням + pin-delivery-help: "Надіслано користувачеві {name} із посиланням для відкриття та PIN-кодом." + pin-delivery-no-recipient: Призначте посилання комусь або оберіть водія, щоб надіслати. В іншому разі поділіться посиланням та PIN-кодом самостійно. + generated-title: Посилання згенеровано + generated-help: Посилання скопійовано в буфер обміну. Для доступу також знадобиться PIN-код. + copied-pin: PIN-код скопійовано. + email-pin: Надіслати посилання на Email + text-pin: Надіслати посилання через SMS + pin-sent-email: "Посилання та PIN-код надіслано на email {to}." + pin-sent-sms: "Посилання та PIN-код надіслано через SMS на номер {to}." + pin-not-sent: "Посилання та PIN-код не надіслано: {reason}" + pin-sent-by: "{via, select, email {Надіслано на email} sms {Надіслано через SMS} other {Надіслано}}" + wrong-pins: "{count, plural, one {# невірна спроба PIN} few {# невірні спроби PIN} many {# невірних спроб PIN} other {# невірних спроб PIN}}" + no-pin: Без PIN-коду. Це посилання було згенеровано до введення PIN-кодів. + public: + submitted-title: Інспекцію відправлено + submitted-body: Дякуємо. Цю інспекцію зареєстровано, а посилання тепер закрите. + unavailable-title: Ця інспекція недоступна + sign-off: Підписати + your-name: Ваше ім'я + your-name-help: Записується в цій інспекції як ім'я особи, що її заповнила. + your-name-placeholder: Повне ім'я + for: Для + pin-title: Введіть ваш PIN-код + pin-help: Введіть 6-значний PIN-код, який ви отримали для цієї інспекції. + pin-label: PIN-код + pin-continue: Продовжити + pin-wrong: "Невірний PIN-код. Залишилося спроб: {count} до блокування посилання." + submit: Відправити інспекцію + blocked-required: "{count, plural, one {Відсутня # обов'язкова відповідь} few {Відсутні # обов'язкові відповіді} many {Відсутня # обов'язкових відповідей} other {Відсутні обов'язкові відповіді}}" + blocked-defects: "{count, plural, one {# дефект потребує коментаря або фото} few {# дефекти потребують коментаря або фото} many {# дефектів потребують коментаря або фото} other {# дефектів потребують коментаря або фото}}" + flyout: + title: "Дефект: {label}" + done: Готово + close: Закрити + defect: + edit: Редагувати дефект + photos: "{count, plural, one {# фотографія} few {# фотографії} many {# фотографій} other {# фотографій}}" + comment: Коментар + needs-comment: Потрібен коментар + needs-photo: Потрібне фото + needs-both: Потрібні коментар та фото + no-evidence: Коментарі та фото відсутні + tray: + title: Дефекти + review: Переглянути +select-option: + vehicle: + vin: VIN-код + serial_number: Серійний номер + call_sign: Позивний +resource-summary: + facts: + address: Адреса + altitude: Висота + amount: Сума / Кількість + arrived: Прибув + assignee: Виконавець / Призначена особа + attached-to: Прикріплено до + attachment: Вкладення + axles: Осі + base-fee: Базовий тариф + body-coupling: Кузов / Зчіпка + calculation: Розрахунок + category: Категорія + cod: Оплата при доставці (COD) + code: Код + comments: Коментарі + completion: Завершення + connected: Підключено + coordinates: Координати + core-service: Основна послуга + countries: Країни + country: Країна + coverage: Покриття + created: Створено + customer: Клієнт + declared-value: Оголошена вартість + description: Опис + destination: Пункт призначення + details: Деталі + device: Пристрій + dimensions: Габарити + disconnected: Відключено + distance: Відстань + driver: Водій + drivers: Водії + dropoff: Розвантаження + due: Термін + dwell: Час простою + email: Email + entities: Сутності / Товари + environment: Середовище + equipped-to: Оснащено для + eta: Розрахунковий час прибуття (ETA) + event-type: Тип події + expires: Закінчується + failed-items: Невдалі пункти + fallback: Резервний варіант + fee: Збір / Комісія + fees: Збори / Комісії + firmware: Прошивка + fitted-to: Встановлено на + fleet: Автопарк + form: Форма + fuel-report: Звіт по пальному + heading: Напрямок руху (курс) + host: Хост + imei: IMEI + installed: Встановлено + interval: Інтервал + items: Позиції / Товари + key: Ключ + last-error: Остання помилка + last-online: Востаннє в мережі + last-reading: Останній показник + last-seen: Востаннє помічено + last-status: Останній статус + last-synced: Востаннє синхронізовано + last-value: Останнє значення + licence: Ліцензія / Посвідчення + location: Локація + maintainable: Підлягає обслуговуванню + make-model: Марка / модель + model: Модель + namespace: Простір імен + next-due: Наступний термін + next-occurrence: Наступне настання + occurred: Сталося + odometer: Одометр + order: Замовлення + order-config: Конфігурація замовлення + performed-by: Виконано + period: Період + phone: Телефон + pickup: Завантаження + place: Місце + plate: Номерний знак / VIN + policy: Політика + priority: Пріоритет + progress: Прогрес + provider: Провайдер + published: Опубліковано + purchase-price: Ціна покупки + quantity: Кількість + reason: Причина + recorded: Записано + region: Регіон + relationship: Зв'язок / Відносини + reporter: Хто повідомив + request: Запит + route: Маршрут + sandbox: Пісочниця (Sandbox) + schedule: Розклад + scheduled: Заплановано + sequence: Послідовність + serial: Серійний номер + service-area: Зона обслуговування + service-quote: Розрахунок вартості послуги + service-rate: Тариф послуги + service-types: Типи послуг + severity: Критичність + sku: Артикул (SKU) + speed: Швидкість + speed-limit: Обмеження швидкості + station: Станція + status: Статус + subject: Тема + submitted: Подано + submitted-by: Подано користувачем + target: Ціль + task: Завдання + telematic: Телематика + threshold: Поріг + timezone: Часовий пояс + title: Заголовок + total-cost: Загальна вартість + tracking: Відстеження + trailer: Причіп + transaction-at: Транзакція о + triggers: Тригери + type: Тип + unit-cost: Вартість за одиницю + user: Користувач + vehicle: Транспортний засіб + vehicles: Транспортні засоби + vendor: Постачальник + version: Версія + volume: Об'єм + warranty: Гарантія + waypoints: Маршрутні точки + website: Вебсайт + weight: Вага + window: Вікно (часове) + work-order: Робоче завдання + zone: Зона + zones: Зони \ No newline at end of file From ed0f683cff3f38e5c28cf53353c63efda6f4d435 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 22 Sep 2026 16:54:04 +0800 Subject: [PATCH 12/19] Fix ukranian translations yaml --- translations/uk-ua.yaml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/translations/uk-ua.yaml b/translations/uk-ua.yaml index 21a479c81..53d66cf83 100644 --- a/translations/uk-ua.yaml +++ b/translations/uk-ua.yaml @@ -437,7 +437,8 @@ trailer: fifth_wheel: Седельно-зчіпний пристрій pintle_hook: Буксирувальний гак ball_hitch: Кульове зчеплення - gooseneck: "Гусяча шия" (Gooseneck) + gooseneck: >- + "Гусяча шия" (Gooseneck) drawbar: Дишло other: Інше brake-types: @@ -2644,7 +2645,7 @@ order-config: dropdown-text: Дозволяє вибрати опцію з випадного списку datetime: Вибір дати та часу datetime-text: Дозволяє вибрати дату та час - port: Вибір порту + port: Вибір порту port-text: Дозволяє користувачеві вибрати порт vessel: Вибір судна vessel-text: Дозволяє користувачеві вибрати судно From cbafa27eff5033059c3e74250da67c20ab98683b Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 22 Sep 2026 18:11:57 +0800 Subject: [PATCH 13/19] fix(details): drop the user account panel from customer and driver details A managed account is the profile itself, so the separate User Account panel only repeated the name, email and phone. - Customer: the details panel labelled the phone field Email; it now says Phone. A new Addresses panel lists every place the customer owns as a place pill and marks the primary one. - Driver: name, email and phone move into the one details panel, which is now titled Details. --- addon/components/customer/details.hbs | 34 +++++++++++++-------------- addon/components/driver/details.hbs | 9 ++----- translations/en-us.yaml | 3 +++ translations/uk-ua.yaml | 3 +++ 4 files changed, 25 insertions(+), 24 deletions(-) diff --git a/addon/components/customer/details.hbs b/addon/components/customer/details.hbs index 57dfe59da..68dfc7a38 100644 --- a/addon/components/customer/details.hbs +++ b/addon/components/customer/details.hbs @@ -1,20 +1,4 @@
- -
-
-
{{t "common.name"}}
-
{{n-a @resource.name}}
-
-
-
{{t "common.email"}}
- {{n-a @resource.email}} -
-
-
{{t "common.phone"}}
- {{n-a @resource.phone}} -
-
-
@@ -30,7 +14,7 @@ {{n-a @resource.email}}
-
{{t "common.email"}}
+
{{t "common.phone"}}
{{n-a @resource.phone}}
@@ -47,5 +31,21 @@
+ + {{#if @resource.places.length}} +
+ {{#each @resource.places as |place|}} +
+ + {{#if (eq place.id @resource.place_uuid)}} + {{t "customer.fields.primary-address"}} + {{/if}} +
+ {{/each}} +
+ {{else}} +
{{t "customer.fields.no-addresses"}}
+ {{/if}} +
diff --git a/addon/components/driver/details.hbs b/addon/components/driver/details.hbs index 0df91effc..9ca09d394 100644 --- a/addon/components/driver/details.hbs +++ b/addon/components/driver/details.hbs @@ -1,7 +1,7 @@
- +
-
+
{{t "common.name"}}
{{n-a @resource.name}}
@@ -13,11 +13,6 @@
{{t "common.phone"}}
{{n-a @resource.phone}}
-
-
- - -
{{t "common.id"}}
{{n-a @resource.public_id}} diff --git a/translations/en-us.yaml b/translations/en-us.yaml index 52c01f38f..98e50bedd 100644 --- a/translations/en-us.yaml +++ b/translations/en-us.yaml @@ -770,6 +770,9 @@ contact: customer: fields: + addresses: Addresses + no-addresses: This customer has no saved addresses. + primary-address: Primary customer-details: Customer Details select-address: Select Address new-address: New Adddress diff --git a/translations/uk-ua.yaml b/translations/uk-ua.yaml index 53d66cf83..86363b30f 100644 --- a/translations/uk-ua.yaml +++ b/translations/uk-ua.yaml @@ -771,6 +771,9 @@ contact: customer: fields: + addresses: Адреси + no-addresses: У цього клієнта немає збережених адрес. + primary-address: Основна customer-details: Деталі клієнта select-address: Вибрати адресу new-address: Нова адреса From e0670e814a79acb8b914a4250e28c72a9d26f3f0 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 22 Sep 2026 18:38:07 +0800 Subject: [PATCH 14/19] fix(details): lay out driver details in a three-column grid The details panel declared two columns but spanned three, which left a gap after the name. Rows are now: name, email, phone / ID, internal ID, driver's license / license expiry / vehicle, vendor / city, country / coordinates. --- addon/components/driver/details.hbs | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/addon/components/driver/details.hbs b/addon/components/driver/details.hbs index 9ca09d394..5177389df 100644 --- a/addon/components/driver/details.hbs +++ b/addon/components/driver/details.hbs @@ -1,7 +1,7 @@
-
-
+
+
{{t "common.name"}}
{{n-a @resource.name}}
@@ -25,28 +25,27 @@
{{t "driver.fields.driver-license"}}
{{n-a @resource.drivers_license_number}}
-
+
{{t "driver.fields.license-expiry"}}
{{n-a (format-date-fns @resource.license_expiry)}}
-
{{t "resource.vendor"}}
+
{{t "resource.vehicle"}}
- +
-
-
{{t "resource.vehicle"}}
+
+
{{t "resource.vendor"}}
- +
-
{{t "common.city"}}
{{n-a @resource.city}}
-
+
{{t "common.country"}}
From 74ccca5c9a8be065613fec4f81ba0f06598f1556 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 22 Sep 2026 19:11:30 +0800 Subject: [PATCH 15/19] fix(orders): let the public Track Order lookup find orders again `GET int/v1/fleet-ops/lookup` backs the public Track Order page. It has no session: recipients look up an order by its tracking number, and the tracking number is the credential. #331 routed its findOrderByTrackingNumber() through scopedToCompany(), which with no session company matches nothing, so every lookup from that page failed ("No order found using tracking number provided"). The lookup is unscoped again, as it is on main (v0.6.68), with a docblock saying why. The company scoping #331 added to the order lifecycle lookups (cancel, dispatch, start, schedule, proofs, pings and so on) is unchanged. The tenant-scoping tests no longer expect the tracking lookup to be scoped. A new test checks that it finds an order by tracking number with no session and with another company's session, and returns nothing for an unknown number. --- .../Internal/v1/OrderController.php | 21 ++++++++++++------- .../OrderControllerTenantScopingTest.php | 21 +++++++++++++++++-- 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/server/src/Http/Controllers/Internal/v1/OrderController.php b/server/src/Http/Controllers/Internal/v1/OrderController.php index 1c4740c97..2751c2114 100644 --- a/server/src/Http/Controllers/Internal/v1/OrderController.php +++ b/server/src/Http/Controllers/Internal/v1/OrderController.php @@ -1877,16 +1877,23 @@ public function lookup(Request $request) return new OrderResource($order); } + /** + * Find the order a tracking number belongs to, for the public Track Order page. + * + * Deliberately NOT company-scoped, unlike the lifecycle lookups above. The + * `fleet-ops/lookup` route is public: the tracking page is used by recipients who + * have no session, and the tracking number itself is what grants access. Scoping + * it to the session company made every lookup from that page fail, because there + * is no company to scope to. + */ protected function findOrderByTrackingNumber(string $trackingNumber): ?Order { /** @var Order|null $order */ - $order = $this->scopedToCompany( - Order::whereHas( - 'trackingNumber', - function ($query) use ($trackingNumber) { - $query->where('tracking_number', $trackingNumber); - } - ) + $order = Order::whereHas( + 'trackingNumber', + function ($query) use ($trackingNumber) { + $query->where('tracking_number', $trackingNumber); + } )->first(); return $order; diff --git a/server/tests/Feature/Http/Internal/OrderControllerTenantScopingTest.php b/server/tests/Feature/Http/Internal/OrderControllerTenantScopingTest.php index fbcc3d182..0b639c61f 100644 --- a/server/tests/Feature/Http/Internal/OrderControllerTenantScopingTest.php +++ b/server/tests/Feature/Http/Internal/OrderControllerTenantScopingTest.php @@ -169,7 +169,6 @@ function fleetopsOrderTenantSeed(SQLiteConnection $connection): void ->and($probe->callHelper('findOrderForSchedule', 'order_own1')?->uuid)->toBe(FLEETOPS_TENANT_OWN_ORDER) ->and($probe->callHelper('findOrderForProofs', FLEETOPS_TENANT_OWN_ORDER)?->uuid)->toBe(FLEETOPS_TENANT_OWN_ORDER) ->and($probe->callHelper('findOrderForDriverPing', 'order_own1')?->uuid)->toBe(FLEETOPS_TENANT_OWN_ORDER) - ->and($probe->callHelper('findOrderByTrackingNumber', 'FLB-OWN-1')?->uuid)->toBe(FLEETOPS_TENANT_OWN_ORDER) ->and($probe->callHelper('findEntityProofSubject', 'entity-own')?->uuid)->toBe('entity-own') ->and($probe->callHelper('resolveProof', 'proof_own1')?->uuid)->toBe('proof-own') ->and($probe->callHelper('resolveProof', 'proof-own')?->uuid)->toBe('proof-own'); @@ -204,7 +203,6 @@ function fleetopsOrderTenantSeed(SQLiteConnection $connection): void ->and($probe->callHelper('findPayloadForStart', 'payload-victim'))->toBeNull() ->and($probe->callHelper('findOrderForSchedule', 'order_victim1'))->toBeNull() ->and($probe->callHelper('findOrderForProofs', FLEETOPS_TENANT_VICTIM_ORDER))->toBeNull() - ->and($probe->callHelper('findOrderByTrackingNumber', 'FLB-VICTIM-1'))->toBeNull() ->and($probe->callHelper('findEntityProofSubject', 'entity-victim'))->toBeNull() ->and($probe->callHelper('resolveProof', 'proof_victim1'))->toBeNull() ->and($probe->callHelper('resolveProof', 'proof-victim'))->toBeNull() @@ -219,6 +217,25 @@ function fleetopsOrderTenantSeed(SQLiteConnection $connection): void ->toThrow(ModelNotFoundException::class); }); +test('the public tracking lookup finds an order by its tracking number alone', function () { + $connection = fleetopsOrderTenantBoot(); + fleetopsOrderTenantSeed($connection); + $probe = new FleetOpsInternalOrderTenantScopeProbe(); + + // `fleet-ops/lookup` backs the public Track Order page: recipients have no session + // and the tracking number is the credential. It must work without a company, and + // for whichever company owns the order. + session(['company' => null]); + + expect($probe->callHelper('findOrderByTrackingNumber', 'FLB-OWN-1')?->uuid)->toBe(FLEETOPS_TENANT_OWN_ORDER) + ->and($probe->callHelper('findOrderByTrackingNumber', 'FLB-VICTIM-1')?->uuid)->toBe(FLEETOPS_TENANT_VICTIM_ORDER) + ->and($probe->callHelper('findOrderByTrackingNumber', 'FLB-UNKNOWN'))->toBeNull(); + + session(['company' => 'company-1']); + + expect($probe->callHelper('findOrderByTrackingNumber', 'FLB-VICTIM-1')?->uuid)->toBe(FLEETOPS_TENANT_VICTIM_ORDER); +}); + test('order lookups fail closed when no company session is present', function () { $connection = fleetopsOrderTenantBoot(); fleetopsOrderTenantSeed($connection); From c86b057ede2fe37c53c5d0ffbe689e90a5f45cfd Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 22 Sep 2026 19:13:02 +0800 Subject: [PATCH 16/19] small fix to driver details grid --- addon/components/driver/details.hbs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/addon/components/driver/details.hbs b/addon/components/driver/details.hbs index 5177389df..3c7e25d4f 100644 --- a/addon/components/driver/details.hbs +++ b/addon/components/driver/details.hbs @@ -1,18 +1,18 @@
-
+
{{t "common.name"}}
{{n-a @resource.name}}
-
-
{{t "common.email"}}
- {{n-a @resource.email}} -
{{t "common.phone"}}
{{n-a @resource.phone}}
+
+
{{t "common.email"}}
+ {{n-a @resource.email}} +
{{t "common.id"}}
{{n-a @resource.public_id}} From 8473933d5c747b71215f7a4e3cb7a2cf17604f02 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 22 Sep 2026 23:55:30 +0800 Subject: [PATCH 17/19] chore(deps): upgrade @fleetbase/fleetops-data to ^0.2.2 fleetops-data 0.2.2 is published. It adds the read-only managed login state (is_staff_linked, login_status) to the driver and contact models, which the driver and contact login management in this release uses. Regenerates pnpm-lock.yaml, and notes the upgrade in RELEASE.md. --- RELEASE.md | 4 ++++ package.json | 2 +- pnpm-lock.yaml | 12 +++++++----- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index 251a11a65..908fcb708 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -10,6 +10,10 @@ ## 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. +--- +## 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. + --- ## Testing - Unit tests cover the new AI tools and console commands, and the AI capability registration. diff --git a/package.json b/package.json index b8f78eacb..4901187bd 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "@babel/core": "^7.23.2", "@fleetbase/ember-core": "^0.3.24", "@fleetbase/ember-ui": "^0.4.2", - "@fleetbase/fleetops-data": "^0.2.1", + "@fleetbase/fleetops-data": "^0.2.2", "@fleetbase/leaflet-routing-machine": "^3.2.17", "@fortawesome/ember-fontawesome": "^2.0.0", "@fortawesome/fontawesome-svg-core": "6.4.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index deb93526f..2371395ca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,8 +18,8 @@ importers: specifier: ^0.4.2 version: 0.4.2(@ember/test-helpers@3.3.1(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(webpack@5.106.2(postcss@8.5.14)))(@glimmer/component@1.1.2(@babel/core@7.29.0))(@glimmer/tracking@1.1.2)(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14))))(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(postcss@8.5.14)(rollup@2.80.0)(tracked-built-ins@3.4.0(@babel/core@7.29.0))(webpack@5.106.2(postcss@8.5.14))(yaml@2.9.0) '@fleetbase/fleetops-data': - specifier: ^0.2.1 - version: 0.2.1(@ember/string@3.1.1)(@ember/test-helpers@3.3.1(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(webpack@5.106.2(postcss@8.5.14)))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14))))(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(eslint@8.57.1)(webpack@5.106.2(postcss@8.5.14)) + specifier: ^0.2.2 + version: 0.2.2(@ember/string@3.1.1)(@ember/test-helpers@3.3.1(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(webpack@5.106.2(postcss@8.5.14)))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14))))(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(eslint@8.57.1)(webpack@5.106.2(postcss@8.5.14)) '@fleetbase/leaflet-routing-machine': specifier: ^3.2.17 version: 3.2.17 @@ -1401,8 +1401,8 @@ packages: resolution: {integrity: sha512-jRpu9fYIKis9QDvOr6Ih2zPbIrGhHa+f8tLzKJCl4bFtqtYMGlr0XNW/ckp4VLmoPmWPwJHmVbtvmwLeVoEmhQ==} engines: {node: '>= 18'} - '@fleetbase/fleetops-data@0.2.1': - resolution: {integrity: sha512-zkRSSDu209DGUyxKSyiLZ5ozt9jNNCE/v6FBMpIjOfiwpaMWUa8wnPxWtzIguCfTUd7wQopXOKXtE9GPn5/h2A==} + '@fleetbase/fleetops-data@0.2.2': + resolution: {integrity: sha512-HDgcGpJAk8q+sSmV070nCh02o4S0kuFCvwrf+Ltb+IrUkQ2RhDL/w8gdrqM65HrgbgWxQnslIeqzKfF6pV4/DA==} engines: {node: '>= 18'} '@fleetbase/intl-lint@0.0.1': @@ -2200,6 +2200,7 @@ packages: '@xmldom/xmldom@0.9.10': resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} engines: {node: '>=14.6'} + deprecated: this version has critical issues, please update to the latest version '@xtuc/ieee754@1.2.0': resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} @@ -3091,6 +3092,7 @@ packages: charm@1.0.2: resolution: {integrity: sha512-wqW3VdPnlSWT4eRiYX+hcs+C6ViBPUWk1qTCd+37qw9kEm/a5n2qcyQDMBWvSYKN/ctqZzeXNQaeBjOetJJUkw==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. chart.js@4.5.1: resolution: {integrity: sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==} @@ -10494,7 +10496,7 @@ snapshots: - webpack-command - yaml - '@fleetbase/fleetops-data@0.2.1(@ember/string@3.1.1)(@ember/test-helpers@3.3.1(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(webpack@5.106.2(postcss@8.5.14)))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14))))(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(eslint@8.57.1)(webpack@5.106.2(postcss@8.5.14))': + '@fleetbase/fleetops-data@0.2.2(@ember/string@3.1.1)(@ember/test-helpers@3.3.1(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(webpack@5.106.2(postcss@8.5.14)))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14))))(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(eslint@8.57.1)(webpack@5.106.2(postcss@8.5.14))': dependencies: '@babel/core': 7.29.0 '@fleetbase/ember-core': 0.3.24(@ember/string@3.1.1)(@ember/test-helpers@3.3.1(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(webpack@5.106.2(postcss@8.5.14)))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14))))(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(eslint@8.57.1)(webpack@5.106.2(postcss@8.5.14)) From d813efb13965946f89b60747030a78d0a0a7a00a Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 23 Sep 2026 00:17:02 +0800 Subject: [PATCH 18/19] chore(deps): upgrade @fleetbase/ember-ui to ^0.4.3 ember-ui 0.4.3 is published. It provides the btn-auth style the Track Order login button (#339) uses, so that button no longer falls back to a plain default button. pnpm-lock.yaml regenerated, and the v0.6.69 notes updated. --- RELEASE.md | 1 + package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index 908fcb708..32ebd138d 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -13,6 +13,7 @@ --- ## 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. --- ## Testing diff --git a/package.json b/package.json index 4901187bd..680462f58 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "dependencies": { "@babel/core": "^7.23.2", "@fleetbase/ember-core": "^0.3.24", - "@fleetbase/ember-ui": "^0.4.2", + "@fleetbase/ember-ui": "^0.4.3", "@fleetbase/fleetops-data": "^0.2.2", "@fleetbase/leaflet-routing-machine": "^3.2.17", "@fortawesome/ember-fontawesome": "^2.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2371395ca..dd3786c74 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,8 +15,8 @@ importers: specifier: ^0.3.24 version: 0.3.24(@ember/string@3.1.1)(@ember/test-helpers@3.3.1(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(webpack@5.106.2(postcss@8.5.14)))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14))))(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(eslint@8.57.1)(webpack@5.106.2(postcss@8.5.14)) '@fleetbase/ember-ui': - specifier: ^0.4.2 - version: 0.4.2(@ember/test-helpers@3.3.1(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(webpack@5.106.2(postcss@8.5.14)))(@glimmer/component@1.1.2(@babel/core@7.29.0))(@glimmer/tracking@1.1.2)(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14))))(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(postcss@8.5.14)(rollup@2.80.0)(tracked-built-ins@3.4.0(@babel/core@7.29.0))(webpack@5.106.2(postcss@8.5.14))(yaml@2.9.0) + specifier: ^0.4.3 + version: 0.4.3(@ember/test-helpers@3.3.1(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(webpack@5.106.2(postcss@8.5.14)))(@glimmer/component@1.1.2(@babel/core@7.29.0))(@glimmer/tracking@1.1.2)(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14))))(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(postcss@8.5.14)(rollup@2.80.0)(tracked-built-ins@3.4.0(@babel/core@7.29.0))(webpack@5.106.2(postcss@8.5.14))(yaml@2.9.0) '@fleetbase/fleetops-data': specifier: ^0.2.2 version: 0.2.2(@ember/string@3.1.1)(@ember/test-helpers@3.3.1(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(webpack@5.106.2(postcss@8.5.14)))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14))))(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(eslint@8.57.1)(webpack@5.106.2(postcss@8.5.14)) @@ -1397,8 +1397,8 @@ packages: resolution: {integrity: sha512-WG32JlX4S75ofa34RQEMxFEqc20fTv2Qn3A5+NlqXtL0lfnCthdO+a1DrGqIIierthHl3mVTxhe+z63f8sM6EA==} engines: {node: '>= 18'} - '@fleetbase/ember-ui@0.4.2': - resolution: {integrity: sha512-jRpu9fYIKis9QDvOr6Ih2zPbIrGhHa+f8tLzKJCl4bFtqtYMGlr0XNW/ckp4VLmoPmWPwJHmVbtvmwLeVoEmhQ==} + '@fleetbase/ember-ui@0.4.3': + resolution: {integrity: sha512-A8JgpgtfomGl0a2D0eZFSlqMThLzPaEnGyeA2PK7zDWpmshHKTNwRX0hm9MWGLgbtlULQyfQBRetM0X9XSauwA==} engines: {node: '>= 18'} '@fleetbase/fleetops-data@0.2.2': @@ -10390,7 +10390,7 @@ snapshots: - utf-8-validate - webpack - '@fleetbase/ember-ui@0.4.2(@ember/test-helpers@3.3.1(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(webpack@5.106.2(postcss@8.5.14)))(@glimmer/component@1.1.2(@babel/core@7.29.0))(@glimmer/tracking@1.1.2)(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14))))(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(postcss@8.5.14)(rollup@2.80.0)(tracked-built-ins@3.4.0(@babel/core@7.29.0))(webpack@5.106.2(postcss@8.5.14))(yaml@2.9.0)': + '@fleetbase/ember-ui@0.4.3(@ember/test-helpers@3.3.1(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(webpack@5.106.2(postcss@8.5.14)))(@glimmer/component@1.1.2(@babel/core@7.29.0))(@glimmer/tracking@1.1.2)(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14))))(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(postcss@8.5.14)(rollup@2.80.0)(tracked-built-ins@3.4.0(@babel/core@7.29.0))(webpack@5.106.2(postcss@8.5.14))(yaml@2.9.0)': dependencies: '@babel/core': 7.29.0 '@ember/render-modifiers': 2.1.0(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14))) From 70dd1027d7dfec60e111f40ef526fd2e1d50c63a Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 23 Sep 2026 00:22:47 +0800 Subject: [PATCH 19/19] test: match the test fakes to core-api 1.6.63's role signatures core-api 1.6.63 (published) no longer grants Administrator by default: User::assignCompany() and Company::addUser() now take ?string $role = null. Two test fakes still overrode them with string $role = 'Administrator'. PHP rejects an override that narrows a parameter, so PHP CI died with a fatal error while loading ApiDriverControllerContractsTest. Both fakes now take ?string $role = null. A nullable role is also compatible with older core-api, whose parameter was a plain string. The source always passes an explicit role, so no assertion changes. --- server/tests/ApiDriverControllerContractsTest.php | 4 +++- server/tests/Unit/Models/ContactTest.php | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/server/tests/ApiDriverControllerContractsTest.php b/server/tests/ApiDriverControllerContractsTest.php index d2743ca72..27f52381e 100644 --- a/server/tests/ApiDriverControllerContractsTest.php +++ b/server/tests/ApiDriverControllerContractsTest.php @@ -298,7 +298,9 @@ public function save(array $options = []): bool return true; } - public function assignCompany(Company $company, string $role = 'Administrator'): User + // Matches core-api 1.6.63+, where no role is granted unless one is given (a nullable + // role is also compatible with older core-api, whose parameter was a plain string). + public function assignCompany(Company $company, ?string $role = null): User { $this->assignedCompanies[] = $company->uuid; diff --git a/server/tests/Unit/Models/ContactTest.php b/server/tests/Unit/Models/ContactTest.php index 1f8ff69f4..9c3fa0e89 100644 --- a/server/tests/Unit/Models/ContactTest.php +++ b/server/tests/Unit/Models/ContactTest.php @@ -25,7 +25,8 @@ class FleetOpsContactUnitCompanyFake extends Company public array $addUserCalls = []; public ?FleetOpsContactUnitCompanyUserFake $companyUser = null; - public function addUser(User $user, string $role = 'Administrator', string $status = 'active'): CompanyUser + // Matches core-api 1.6.63+, where no role is granted unless one is given. + public function addUser(User $user, ?string $role = null, string $status = 'active'): CompanyUser { $this->addUserCalls[] = [$user, $role, $status];