From 5891d0bd53f29fe4d20b1f86de55f1db0378ec9c Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 6 Oct 2026 12:37:47 +0800 Subject: [PATCH 01/87] fix(tracking): throttle the public order lookup and return a minimal resource The public Track Order page's GET int/v1/fleet-ops/lookup had no session, no rate limit, and answered with the full OrderResource: notes, meta, internal ids, files (possible POD photos and signatures), purchase rate, and live driver location, to anyone holding a tracking number. - Rate limit it per address on its own limiter (throttle:30,1,tracking-lookup), as the public inspection routes do. - Answer with PublicOrderTracking, which carries only what order-tracking-lookup renders: tracking number, status, timeline, ETA and progress, stop coordinates for the route map, and the item list. Records are keyed by public id, so no internal uuid reaches the page. The driver position is included only while the order is started and unfinished. It extends JsonResource so registered resource transformers cannot add fields. - Missing, malformed and unknown tracking numbers get one identical error, and malformed input never reaches the query. --- .../Internal/v1/OrderController.php | 49 ++-- .../Http/Resources/v1/PublicOrderTracking.php | 153 ++++++++++++ server/src/routes.php | 5 +- .../Internal/OrderControllerContractsTest.php | 30 ++- .../tests/PublicOrderTrackingResourceTest.php | 221 ++++++++++++++++++ .../tests/RouteRegistrationExecutionTest.php | 6 + 6 files changed, 439 insertions(+), 25 deletions(-) create mode 100644 server/src/Http/Resources/v1/PublicOrderTracking.php create mode 100644 server/tests/PublicOrderTrackingResourceTest.php diff --git a/server/src/Http/Controllers/Internal/v1/OrderController.php b/server/src/Http/Controllers/Internal/v1/OrderController.php index 4ad6b05d3..1375bc8d3 100644 --- a/server/src/Http/Controllers/Internal/v1/OrderController.php +++ b/server/src/Http/Controllers/Internal/v1/OrderController.php @@ -19,6 +19,7 @@ use Fleetbase\FleetOps\Http\Resources\v1\Index\Order as OrderIndexResource; use Fleetbase\FleetOps\Http\Resources\v1\Order as OrderResource; use Fleetbase\FleetOps\Http\Resources\v1\Proof as ProofResource; +use Fleetbase\FleetOps\Http\Resources\v1\PublicOrderTracking; use Fleetbase\FleetOps\Imports\OrdersImport; use Fleetbase\FleetOps\Jobs\FinalizeInternalOrderCreation; use Fleetbase\FleetOps\Jobs\NotifyBulkAssignedDriver; @@ -54,6 +55,12 @@ class OrderController extends FleetOpsController { use ResolvesOrderServiceStops; + + /** + * The one answer the public tracking lookup gives for any number it cannot resolve. + */ + public const TRACKING_LOOKUP_ERROR = 'No order found using tracking number provided.'; + /** * Permissions for methods AuthorizationGuard cannot map to a schema action (see FleetOpsController). * @@ -1905,27 +1912,43 @@ public function getDefaultOrderConfig() return response()->json(OrderConfig::default()); } + /** + * Look up an order for the public Track Order page. + * + * Answers with `PublicOrderTracking`, never the full order resource: the + * tracking number is the only credential here. A missing, malformed or + * unknown tracking number all get the same error, so a caller cannot tell + * which one they hit. + */ public function lookup(Request $request) { - $trackingNumber = $request->input('tracking'); - if (!$trackingNumber) { - return response()->error('No tracking number provided for lookup.'); - } - - $order = $this->findOrderByTrackingNumber($trackingNumber); + $trackingNumber = $this->normalizeTrackingLookup($request->input('tracking')); + $order = $trackingNumber ? $this->findOrderByTrackingNumber($trackingNumber) : null; if (!$order) { - return response()->error('No order found using tracking number provided.'); + return response()->error(static::TRACKING_LOOKUP_ERROR); } - // load required relations - $order->loadMissing(['trackingNumber', 'payload', 'trackingStatuses']); - - // load tracker data + $order->loadMissing(['trackingNumber', 'trackingStatuses', 'payload.pickup', 'payload.dropoff', 'payload.waypoints', 'payload.entities']); $order->tracker_data = $order->tracker()->toArray(); - $order->eta = $order->tracker()->eta(); - return new OrderResource($order); + return new PublicOrderTracking($order); + } + + /** + * The tracking number to look up, or null when the input cannot be one: + * not a string, blank, too long, or outside the characters tracking + * numbers use. + */ + protected function normalizeTrackingLookup($input): ?string + { + if (!is_string($input)) { + return null; + } + + $input = trim($input); + + return preg_match('/^[A-Za-z0-9][A-Za-z0-9._\-]{0,99}$/', $input) === 1 ? $input : null; } /** diff --git a/server/src/Http/Resources/v1/PublicOrderTracking.php b/server/src/Http/Resources/v1/PublicOrderTracking.php new file mode 100644 index 000000000..250013661 --- /dev/null +++ b/server/src/Http/Resources/v1/PublicOrderTracking.php @@ -0,0 +1,153 @@ +resource; + $trackingNumber = $order->trackingNumber; + $tracker = is_array($order->tracker_data) ? $order->tracker_data : []; + + return [ + 'uuid' => $order->public_id, + 'tracking' => data_get($trackingNumber, 'tracking_number'), + 'status' => $order->status, + 'has_driver_assigned' => (bool) $order->has_driver_assigned, + 'tracking_number' => $trackingNumber ? [ + 'uuid' => $trackingNumber->public_id, + 'tracking_number' => $trackingNumber->tracking_number, + ] : null, + 'tracking_statuses' => collect($order->trackingStatuses)->map(fn ($status) => [ + 'uuid' => $status->public_id, + 'status' => $status->status, + 'details' => $status->details, + 'created_at' => $status->created_at, + ])->values()->all(), + 'payload' => $this->payload($order->payload), + 'tracker_data' => $this->trackerData($tracker, static::isInProgress($order)), + 'created_at' => $order->created_at, + ]; + } + + /** + * Whether the order is under way: started, and not yet completed or canceled. + */ + public static function isInProgress(Order $order): bool + { + return (bool) $order->started && !in_array(strtolower((string) $order->status), static::FINISHED_STATUSES, true); + } + + /** + * The stops the map routes between and the items the page lists. + */ + protected function payload($payload): ?array + { + if (!$payload) { + return null; + } + + return [ + 'uuid' => $payload->public_id, + 'pickup' => $this->place($payload->pickup), + 'dropoff' => $this->place($payload->dropoff), + 'waypoints' => collect($payload->waypoints)->map(fn ($place) => $this->place($place))->values()->all(), + 'entities' => collect($payload->entities)->map(fn ($entity) => [ + 'uuid' => $entity->public_id, + 'name' => $entity->name, + 'description' => $entity->description, + 'tracking' => data_get($entity, 'tracking'), + 'price' => $entity->price, + 'currency' => $entity->currency, + 'photo_url' => data_get($entity, 'photo_url'), + ])->values()->all(), + ]; + } + + /** + * A stop as the map needs it: its coordinates, with no address or contact. + */ + protected function place($place): ?array + { + if (!$place) { + return null; + } + + return [ + 'uuid' => $place->public_id, + 'location' => Utils::castPoint($place->location), + ]; + } + + /** + * The tracker fields the page reads; the driver's position only while under way. + */ + protected function trackerData(array $tracker, bool $inProgress): array + { + return [ + 'driver' => [ + 'location' => $inProgress ? data_get($tracker, 'driver.location') : null, + ], + 'progress' => [ + 'percentage' => data_get($tracker, 'progress.percentage'), + 'completed_stops' => data_get($tracker, 'progress.completed_stops'), + ], + 'eta' => [ + 'active_stop_seconds' => data_get($tracker, 'eta.active_stop_seconds'), + 'completion_at' => data_get($tracker, 'eta.completion_at'), + ], + 'active_stop' => $this->stop(data_get($tracker, 'active_stop')), + 'next_stop' => $this->stop(data_get($tracker, 'next_stop')), + ]; + } + + /** + * A stop the page names under "Current/Next Destination". + */ + protected function stop($stop): ?array + { + if (!is_array($stop)) { + return null; + } + + return ['address' => data_get($stop, 'address')]; + } +} diff --git a/server/src/routes.php b/server/src/routes.php index dd757f07c..e2e3577c1 100644 --- a/server/src/routes.php +++ b/server/src/routes.php @@ -398,7 +398,10 @@ function ($router) { $router->group( ['prefix' => 'v1/fleet-ops', 'namespace' => 'v1'], function ($router) { - $router->get('lookup', 'OrderController@lookup'); + // The public Track Order page: no session, the tracking number is + // the credential. Rate limited per address so numbers cannot be + // enumerated, on a limiter of its own. + $router->get('lookup', 'OrderController@lookup')->middleware('throttle:30,1,tracking-lookup'); } ); diff --git a/server/tests/Feature/Http/Internal/OrderControllerContractsTest.php b/server/tests/Feature/Http/Internal/OrderControllerContractsTest.php index 6c815da8a..a92601055 100644 --- a/server/tests/Feature/Http/Internal/OrderControllerContractsTest.php +++ b/server/tests/Feature/Http/Internal/OrderControllerContractsTest.php @@ -4,6 +4,7 @@ use Fleetbase\FleetOps\Http\Controllers\Internal\v1\OrderController; use Fleetbase\FleetOps\Http\Requests\BulkDispatchRequest; use Fleetbase\FleetOps\Http\Requests\CancelOrderRequest; +use Fleetbase\FleetOps\Http\Resources\v1\PublicOrderTracking; use Fleetbase\FleetOps\Models\Driver; use Fleetbase\FleetOps\Models\Entity; use Fleetbase\FleetOps\Models\Order; @@ -68,6 +69,7 @@ class FleetOpsInternalOrderLifecycleControllerProbe extends OrderController public Collection $proofResults; public array $exportDownloads = []; public ?FleetOpsInternalOrderLifecycleOrderFake $trackingOrder = null; + public array $trackingLookups = []; public ?FleetOpsInternalOrderLifecycleOrderFake $scheduleOrder = null; public ?FleetOpsInternalOrderLifecycleDriverFake $scheduleDriver = null; @@ -218,6 +220,7 @@ protected function downloadOrderExport(array $selections, string $fileName) protected function findOrderByTrackingNumber(string $trackingNumber): ?Order { + $this->trackingLookups[] = $trackingNumber; $this->trackingOrder?->setAttribute('tracking_lookup', $trackingNumber); return $this->trackingOrder; @@ -1549,24 +1552,29 @@ function fleetopsSuppressStrNullDeprecations(): Closure ->and($export['selections'])->toBe(['order-one', 'order-two']) ->and($controller->exportDownloads[0][0])->toBe(['order-one', 'order-two']); - expect($controller->lookup(new Request())->getData(true))->toBe([ - 'error' => 'No tracking number provided for lookup.', - ]) - ->and($controller->lookup(new Request(['tracking' => 'TN-404']))->getData(true))->toBe([ - 'error' => 'No order found using tracking number provided.', - ]); + // Missing, malformed and unknown tracking numbers all get the one answer, + // and malformed input never reaches the query. + $notFound = ['error' => OrderController::TRACKING_LOOKUP_ERROR]; + foreach ([[], ['tracking' => ''], ['tracking' => ' '], ['tracking' => ['TN-1']], ['tracking' => '../etc/passwd'], ['tracking' => "TN-1' OR 1=1"], ['tracking' => str_repeat('A', 101)], ['tracking' => '-TN']] as $input) { + expect($controller->lookup(new Request($input))->getData(true))->toBe($notFound); + } + expect($controller->trackingLookups)->toBe([]); + + expect($controller->lookup(new Request(['tracking' => 'TN-404']))->getData(true))->toBe($notFound) + ->and($controller->trackingLookups)->toBe(['TN-404']); $trackedOrder = fleetopsInternalOrderLifecycleOrder('tracked-order'); $trackedOrder->trackerForTest = new FleetOpsInternalOrderLifecycleTrackerFake($trackedOrder); $controller->trackingOrder = $trackedOrder; - $lookup = $controller->lookup(new Request(['tracking' => 'TN-100'])); + $lookup = $controller->lookup(new Request(['tracking' => ' TN-100.a_b '])); - expect($lookup->resource)->toBe($trackedOrder) - ->and($trackedOrder->tracking_lookup)->toBe('TN-100') - ->and($trackedOrder->loadedMissing)->toBe([['trackingNumber', 'payload', 'trackingStatuses']]) + expect($lookup)->toBeInstanceOf(PublicOrderTracking::class) + ->and($lookup->resource)->toBe($trackedOrder) + ->and($trackedOrder->tracking_lookup)->toBe('TN-100.a_b') + ->and($trackedOrder->loadedMissing)->toBe([['trackingNumber', 'trackingStatuses', 'payload.pickup', 'payload.dropoff', 'payload.waypoints', 'payload.entities']]) ->and($trackedOrder->tracker_data)->toBe(['tracker' => 'info', 'options' => []]) - ->and($trackedOrder->eta)->toBe(['eta' => [['stop' => 'dropoff']], 'options' => []]); + ->and($trackedOrder->trackerForTest->etaOptions)->toBe([]); expect($controller->scheduleOrder(new Request(['order' => 'missing-order']))->getData(true))->toBe([ 'error' => 'No order found to schedule.', diff --git a/server/tests/PublicOrderTrackingResourceTest.php b/server/tests/PublicOrderTrackingResourceTest.php new file mode 100644 index 000000000..2f127d221 --- /dev/null +++ b/server/tests/PublicOrderTrackingResourceTest.php @@ -0,0 +1,221 @@ +setRawAttributes(array_merge([ + 'uuid' => 'order-internal-uuid', + 'public_id' => 'order_public', + 'internal_id' => 'INT-0042', + 'company_uuid' => 'company-uuid', + 'customer_uuid' => 'customer-uuid', + 'facilitator_uuid' => 'facilitator-uuid', + 'driver_assigned_uuid' => 'driver-uuid', + 'purchase_rate_uuid' => 'rate-uuid', + 'notes' => 'Gate code 1234', + 'meta' => '{"secret":"value"}', + 'status' => 'started', + 'started' => true, + 'created_at' => Carbon::parse('2026-10-01 09:00:00'), + ], $attributes), true); + + $order->setRelation('trackingNumber', (object) [ + 'uuid' => 'tracking-internal-uuid', + 'public_id' => 'track_public', + 'tracking_number' => 'FLB1234567890SG', + 'qr_code' => 'qr-data', + 'barcode' => 'barcode-data', + ]); + $order->setRelation('trackingStatuses', collect([ + (object) [ + 'uuid' => 'status-internal-uuid', + 'public_id' => 'status_public', + 'status' => 'Order Created', + 'details' => 'New order created.', + 'code' => 'created', + 'location' => new Point(1.3, 103.8), + 'created_at' => Carbon::parse('2026-10-01 09:00:00'), + ], + ])); + $order->setRelation('payload', (object) [ + 'uuid' => 'payload-internal-uuid', + 'public_id' => 'payload_public', + 'meta' => ['secret' => 'value'], + 'cod_amount' => '100', + 'pickup' => (object) [ + 'uuid' => 'pickup-internal-uuid', + 'public_id' => 'place_pickup', + 'name' => 'Sender House', + 'street1' => '1 Sender Road', + 'phone' => '+6500000000', + 'location' => new Point(1.30, 103.80), + ], + 'dropoff' => (object) [ + 'uuid' => 'dropoff-internal-uuid', + 'public_id' => 'place_dropoff', + 'name' => 'Recipient House', + 'street1' => '2 Recipient Road', + 'location' => new Point(1.35, 103.85), + ], + 'waypoints' => [], + 'entities' => collect([ + (object) [ + 'uuid' => 'entity-internal-uuid', + 'public_id' => 'entity_public', + 'internal_id' => 'ENT-INT', + 'customer_uuid' => 'customer-uuid', + 'name' => 'Parcel', + 'description' => 'A box', + 'tracking' => 'FLB9999999999SG', + 'price' => '1500', + 'currency' => 'SGD', + 'photo_url' => 'https://example.test/parcel.png', + 'meta' => ['secret' => 'value'], + ], + ]), + ]); + + $order->setAttribute('tracker_data', array_merge([ + 'provider' => 'internal', + 'driver' => [ + 'location' => ['type' => 'Point', 'coordinates' => [103.82, 1.32]], + 'location_age_seconds' => 12, + 'online' => true, + ], + 'progress' => ['percentage' => 40, 'completed_stops' => 1, 'total_stops' => 2], + 'stops' => [['uuid' => 'stop-uuid', 'address' => '1 Sender Road']], + 'active_stop' => ['uuid' => 'stop-uuid', 'address' => '2 Recipient Road', 'name' => 'Recipient House', 'latitude' => 1.35], + 'next_stop' => null, + 'route' => ['polyline' => 'abc'], + 'eta' => ['active_stop_seconds' => 600, 'completion_seconds' => 600, 'completion_at' => '2026-10-01T09:30:00.000Z'], + 'insights' => ['is_delayed' => false], + ], $tracker)); + + return $order; +} + +test('public order tracking resource carries only what the track order page renders', function () { + $data = (new PublicOrderTracking(fleetopsPublicTrackingOrder()))->toArray(new Request()); + + expect(array_keys($data))->toBe([ + 'uuid', 'tracking', 'status', 'has_driver_assigned', 'tracking_number', + 'tracking_statuses', 'payload', 'tracker_data', 'created_at', + ]) + ->and($data['uuid'])->toBe('order_public') + ->and($data['tracking'])->toBe('FLB1234567890SG') + ->and($data['status'])->toBe('started') + ->and($data['has_driver_assigned'])->toBeTrue() + ->and($data['tracking_number'])->toBe(['uuid' => 'track_public', 'tracking_number' => 'FLB1234567890SG']) + ->and($data['created_at']->toDateTimeString())->toBe('2026-10-01 09:00:00'); + + expect($data['tracking_statuses'])->toHaveCount(1) + ->and(array_keys($data['tracking_statuses'][0]))->toBe(['uuid', 'status', 'details', 'created_at']) + ->and($data['tracking_statuses'][0]['uuid'])->toBe('status_public') + ->and($data['tracking_statuses'][0]['status'])->toBe('Order Created') + ->and($data['tracking_statuses'][0]['details'])->toBe('New order created.'); + + // Stops are coordinates only: the map routes between them, nothing names them. + $payload = $data['payload']; + expect(array_keys($payload))->toBe(['uuid', 'pickup', 'dropoff', 'waypoints', 'entities']) + ->and($payload['uuid'])->toBe('payload_public') + ->and(array_keys($payload['pickup']))->toBe(['uuid', 'location']) + ->and($payload['pickup']['uuid'])->toBe('place_pickup') + ->and($payload['pickup']['location']->getLat())->toBe(1.30) + ->and($payload['dropoff']['location']->getLng())->toBe(103.85) + ->and($payload['waypoints'])->toBe([]) + ->and($payload['entities'])->toBe([[ + 'uuid' => 'entity_public', + 'name' => 'Parcel', + 'description' => 'A box', + 'tracking' => 'FLB9999999999SG', + 'price' => '1500', + 'currency' => 'SGD', + 'photo_url' => 'https://example.test/parcel.png', + ]]); + + expect($data['tracker_data'])->toBe([ + 'driver' => ['location' => ['type' => 'Point', 'coordinates' => [103.82, 1.32]]], + 'progress' => ['percentage' => 40, 'completed_stops' => 1], + 'eta' => ['active_stop_seconds' => 600, 'completion_at' => '2026-10-01T09:30:00.000Z'], + 'active_stop' => ['address' => '2 Recipient Road'], + 'next_stop' => null, + ]); + + // Nothing internal reaches the page, at any depth. + $json = json_encode($data); + foreach (['order-internal-uuid', 'INT-0042', 'company-uuid', 'customer-uuid', 'facilitator-uuid', 'driver-uuid', 'rate-uuid', 'Gate code', 'secret', 'qr-data', 'barcode-data', 'Sender House', '1 Sender Road', '+6500000000', 'ENT-INT', 'abc', 'internal'] as $leak) { + expect($json)->not->toContain($leak); + } +}); + +test('public order tracking resource drops the driver position unless the order is under way', function (array $attributes) { + $data = (new PublicOrderTracking(fleetopsPublicTrackingOrder($attributes)))->toArray(new Request()); + + expect($data['tracker_data']['driver'])->toBe(['location' => null]) + ->and($data['tracker_data']['progress']['percentage'])->toBe(40); +})->with([ + 'not started yet' => [['status' => 'dispatched', 'started' => false]], + 'completed' => [['status' => 'completed', 'started' => true]], + 'canceled' => [['status' => 'canceled', 'started' => true]], + 'cancelled' => [['status' => 'CANCELLED', 'started' => true]], +]); + +test('public order tracking resource reports in progress only for started unfinished orders', function () { + expect(PublicOrderTracking::isInProgress(fleetopsPublicTrackingOrder()))->toBeTrue() + ->and(PublicOrderTracking::isInProgress(fleetopsPublicTrackingOrder(['started' => false])))->toBeFalse() + ->and(PublicOrderTracking::isInProgress(fleetopsPublicTrackingOrder(['status' => 'completed'])))->toBeFalse(); +}); + +test('public order tracking resource tolerates missing relations and tracker data', function () { + $order = new Order(); + $order->setRawAttributes(['public_id' => 'order_bare', 'status' => 'created'], true); + $order->setRelation('trackingNumber', null); + $order->setRelation('trackingStatuses', collect()); + $order->setRelation('payload', null); + + $data = (new PublicOrderTracking($order))->toArray(new Request()); + + expect($data['uuid'])->toBe('order_bare') + ->and($data['tracking'])->toBeNull() + ->and($data['has_driver_assigned'])->toBeFalse() + ->and($data['tracking_number'])->toBeNull() + ->and($data['tracking_statuses'])->toBe([]) + ->and($data['payload'])->toBeNull() + ->and($data['tracker_data'])->toBe([ + 'driver' => ['location' => null], + 'progress' => ['percentage' => null, 'completed_stops' => null], + 'eta' => ['active_stop_seconds' => null, 'completion_at' => null], + 'active_stop' => null, + 'next_stop' => null, + ]); + + // A payload with stops removed still serializes, leaving the gaps empty. + $order->setRelation('payload', (object) [ + 'public_id' => 'payload_bare', + 'pickup' => null, + 'dropoff' => null, + 'waypoints' => collect([(object) ['public_id' => 'place_waypoint', 'location' => new Point(1.4, 103.9)]]), + 'entities' => [], + ]); + $payload = (new PublicOrderTracking($order))->toArray(new Request())['payload']; + + expect($payload['pickup'])->toBeNull() + ->and($payload['dropoff'])->toBeNull() + ->and($payload['waypoints'][0]['uuid'])->toBe('place_waypoint') + ->and($payload['waypoints'][0]['location']->getLat())->toBe(1.4) + ->and($payload['entities'])->toBe([]); +}); + +test('public order tracking resource is never wrapped', function () { + expect(PublicOrderTracking::$wrap)->toBeNull(); +}); diff --git a/server/tests/RouteRegistrationExecutionTest.php b/server/tests/RouteRegistrationExecutionTest.php index 803c50f7b..491558d9e 100644 --- a/server/tests/RouteRegistrationExecutionTest.php +++ b/server/tests/RouteRegistrationExecutionTest.php @@ -223,6 +223,12 @@ function fleetOpsRecordedRoutes(): FleetOpsRouteRecorder // Uploads through a link have a tighter limit of their own, on top of the group's. $upload = collect($recorder->routes)->firstWhere('uri', 'public/inspections/forms/{id}/files'); expect($upload['middleware'] ?? [])->toBe(['throttle:20,1,inspection-upload']); + + // The public tracking lookup has no session either, so it is rate limited + // per address, on a limiter of its own. + $lookup = collect($recorder->routes)->first(fn (array $route) => $route['action'] === 'OrderController@lookup'); + expect($lookup)->not->toBeNull() + ->and($lookup['middleware'] ?? [])->toBe(['throttle:30,1,tracking-lookup']); }); test('fleetops route file wires route groups with expected middleware and namespaces', function () { From 413aa47d96cd060fcb01d62690cf793942caf307 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 6 Oct 2026 13:06:04 +0800 Subject: [PATCH 02/87] fix(files): stop writing signatures with a public ACL; re-sign stored avatar URLs - ProofController::storeSignature no longer passes 'public': the media bucket enforces bucket-owner ownership, rejects ACL'd PUTs, and put() silently returned false (no signature objects written since 2024-11-25). - Vehicle/Driver/Place avatar_url accessors re-sign legacy absolute bucket URLs via core-api File::signStoredUrl when available. --- .../Http/Controllers/Internal/v1/ProofController.php | 12 ++++++++---- server/src/Models/Driver.php | 3 ++- server/src/Models/Place.php | 3 ++- server/src/Models/Vehicle.php | 3 ++- server/src/Support/Utils.php | 11 +++++++++++ .../Http/Internal/ProofControllerContractsTest.php | 6 +++--- .../Unit/Support/OsrmProofAndIssueFilterTest.php | 7 ++++--- server/tests/Unit/Support/UtilsAdditionalTest.php | 9 +++++++++ 8 files changed, 41 insertions(+), 13 deletions(-) diff --git a/server/src/Http/Controllers/Internal/v1/ProofController.php b/server/src/Http/Controllers/Internal/v1/ProofController.php index eab7bd779..186d73495 100644 --- a/server/src/Http/Controllers/Internal/v1/ProofController.php +++ b/server/src/Http/Controllers/Internal/v1/ProofController.php @@ -83,8 +83,8 @@ public function captureSignature(string $publicId, Request $request) // set the signature storage path $path = $this->signatureStoragePath($proof); - // upload signature - $this->storeSignature($path, base64_decode($signature), 'public'); + // upload signature (private object; File::url serves it through a signed URL) + $this->storeSignature($path, base64_decode($signature)); // create file record for upload $file = $this->createSignatureFile($path, $signature, $proof); @@ -121,9 +121,13 @@ protected function createProof(array $attributes): Proof return Proof::create($attributes); } - protected function storeSignature(string $path, string|false $contents, string $visibility): void + /** + * Writes without an ACL: the media bucket enforces bucket-owner object ownership, so a + * 'public' visibility (public-read ACL) makes S3 reject the PUT and put() return false. + */ + protected function storeSignature(string $path, string|false $contents): void { - Storage::disk('s3')->put($path, $contents, $visibility); + Storage::disk('s3')->put($path, $contents); } /** diff --git a/server/src/Models/Driver.php b/server/src/Models/Driver.php index 7eef1ac1c..c796257fa 100644 --- a/server/src/Models/Driver.php +++ b/server/src/Models/Driver.php @@ -431,7 +431,8 @@ public function getAvatarUrlAttribute($value): ?string return static::getAvatar($value); } - return $value; + // legacy rows hold an absolute URL; re-sign it if it points into the private media bucket + return Utils::signStoredFileUrl($value); } /** diff --git a/server/src/Models/Place.php b/server/src/Models/Place.php index 4b3a94c97..898259e5f 100644 --- a/server/src/Models/Place.php +++ b/server/src/Models/Place.php @@ -187,7 +187,8 @@ public function getAvatarUrlAttribute($value) return static::getAvatar($value); } - return $value; + // legacy rows hold an absolute URL; re-sign it if it points into the private media bucket + return Utils::signStoredFileUrl($value); } /** diff --git a/server/src/Models/Vehicle.php b/server/src/Models/Vehicle.php index 0fb69577a..250b440d9 100644 --- a/server/src/Models/Vehicle.php +++ b/server/src/Models/Vehicle.php @@ -525,7 +525,8 @@ public function getAvatarUrlAttribute($value) return static::getAvatar($value); } - return $value; + // legacy rows hold an absolute URL; re-sign it if it points into the private media bucket + return Utils::signStoredFileUrl($value); } /** diff --git a/server/src/Support/Utils.php b/server/src/Support/Utils.php index 8671742e1..b7037d3ab 100644 --- a/server/src/Support/Utils.php +++ b/server/src/Support/Utils.php @@ -1603,4 +1603,15 @@ public static function fixPhone(?string $phone): ?string return $phone; } + + /** + * Re-sign a URL stored as a string (e.g. a legacy `avatar_url` value) when it points into the + * private S3 media bucket, so it keeps working once the bucket stops allowing public reads. + * Other values pass through unchanged. Falls back to the raw value on core-api releases that + * predate File::signStoredUrl(). + */ + public static function signStoredFileUrl(?string $url): ?string + { + return method_exists(\Fleetbase\Models\File::class, 'signStoredUrl') ? \Fleetbase\Models\File::signStoredUrl($url) : $url; + } } diff --git a/server/tests/Feature/Http/Internal/ProofControllerContractsTest.php b/server/tests/Feature/Http/Internal/ProofControllerContractsTest.php index 5cdc1cf5d..dac559930 100644 --- a/server/tests/Feature/Http/Internal/ProofControllerContractsTest.php +++ b/server/tests/Feature/Http/Internal/ProofControllerContractsTest.php @@ -43,9 +43,9 @@ protected function createProof(array $attributes): Proof return $proof; } - protected function storeSignature(string $path, string|false $contents, string $visibility): void + protected function storeSignature(string $path, string|false $contents): void { - $this->storedSignatures[] = [$path, $contents, $visibility]; + $this->storedSignatures[] = [$path, $contents]; } protected function createSignatureFile(string $path, string $signature, Proof $proof): Fleetbase\Models\File @@ -206,7 +206,7 @@ public function setKey($model, $type = null): Fleetbase\Models\File 'raw_data' => $signature, ]) ->and($controller->storedSignatures)->toBe([ - ['uploads/company-uuid/signatures/proof-public-1.png', 'signature-bytes', 'public'], + ['uploads/company-uuid/signatures/proof-public-1.png', 'signature-bytes'], ]) ->and($controller->createdFiles)->toBe([ ['uploads/company-uuid/signatures/proof-public-1.png', $signature, 'proof-public-1'], diff --git a/server/tests/Unit/Support/OsrmProofAndIssueFilterTest.php b/server/tests/Unit/Support/OsrmProofAndIssueFilterTest.php index 3c311fa00..20304b1f8 100644 --- a/server/tests/Unit/Support/OsrmProofAndIssueFilterTest.php +++ b/server/tests/Unit/Support/OsrmProofAndIssueFilterTest.php @@ -119,7 +119,7 @@ public function disk($disk = null) public function put($path, $contents, $options = []) { - $this->writes[] = [$path]; + $this->writes[] = [$path, $options]; return true; } @@ -200,8 +200,9 @@ public function put($path, $contents, $options = []) expect($proof)->toBeInstanceOf(Proof::class) ->and($connection->table('proofs')->count())->toBe(1); - $probe->callHelper('storeSignature', 'signatures/proof.png', 'binary', 'public'); - expect($GLOBALS['fleetopsProofStorageFake']->writes)->toHaveCount(1); + $probe->callHelper('storeSignature', 'signatures/proof.png', 'binary'); + // Signatures are written without a visibility/ACL option; the media bucket rejects ACLs. + expect($GLOBALS['fleetopsProofStorageFake']->writes)->toBe([['signatures/proof.png', []]]); expect($probe->callHelper('jsonResponse', ['status' => 'ok'])->getData(true))->toBe(['status' => 'ok']) ->and($probe->callHelper('errorResponse', 'nope')->getData(true)['error'])->toBe('nope') diff --git a/server/tests/Unit/Support/UtilsAdditionalTest.php b/server/tests/Unit/Support/UtilsAdditionalTest.php index ca8931829..8f5e10f35 100644 --- a/server/tests/Unit/Support/UtilsAdditionalTest.php +++ b/server/tests/Unit/Support/UtilsAdditionalTest.php @@ -70,6 +70,15 @@ function fleetopsUtilsAdditionalPolygon(): Polygon ]); } +test('stored file url signing passes through values that are not absolute urls', function () { + // Absolute URLs into the media bucket are re-signed by core-api File::signStoredUrl (covered there); + // everything else, including avatar option keys and empty values, must come back untouched. + expect(Utils::signStoredFileUrl(null))->toBeNull() + ->and(Utils::signStoredFileUrl(''))->toBe('') + ->and(Utils::signStoredFileUrl('mini_bus'))->toBe('mini_bus') + ->and(Utils::signStoredFileUrl('custom-avatars/vehicles/c/van.png'))->toBe('custom-avatars/vehicles/c/van.png'); +}); + test('company transaction currency prefers organization currency and falls back to usd', function () { $company = new Company(); $company->setRawAttributes([ From 922b4ad7a840f304f58c7cd018edf925fa83a713 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 6 Oct 2026 14:30:34 +0800 Subject: [PATCH 03/87] fix(mail): link customer credentials to /customer-access/{slug} The customer portal resolves a company's access slug only under /customer-access/{slug}; the credentials email linked /{slug}, which the console does not route. Without a configured slug the link still points at the portal's own /customer-portal sign-in. --- server/src/Mail/CustomerCredentialsMail.php | 6 ++++-- server/tests/NotificationAndMailContractsTest.php | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/server/src/Mail/CustomerCredentialsMail.php b/server/src/Mail/CustomerCredentialsMail.php index af197524e..331fd564f 100644 --- a/server/src/Mail/CustomerCredentialsMail.php +++ b/server/src/Mail/CustomerCredentialsMail.php @@ -73,8 +73,10 @@ public function content(): Content private function getCustomerPortalAccessUrl(): string { $customerPortalConfig = Setting::lookupForCompany($this->customer->company_uuid, 'customer-portal-config'); - $accessUrlSlug = data_get($customerPortalConfig, 'accessUrlSlug', 'customer-portal'); + $accessUrlSlug = data_get($customerPortalConfig, 'accessUrlSlug'); - return Utils::consoleUrl($accessUrlSlug ?: 'customer-portal'); + // The portal resolves a company's access slug under /customer-access/{slug}; + // without one, send the customer to the portal's own sign-in route. + return Utils::consoleUrl($accessUrlSlug ? 'customer-access/' . $accessUrlSlug : 'customer-portal'); } } diff --git a/server/tests/NotificationAndMailContractsTest.php b/server/tests/NotificationAndMailContractsTest.php index 8539b5620..0e3e01016 100644 --- a/server/tests/NotificationAndMailContractsTest.php +++ b/server/tests/NotificationAndMailContractsTest.php @@ -704,5 +704,5 @@ public function track(Order $order, mixed $options): array $content = fleetOpsNotificationWithEnvironment(fn () => (new CustomerCredentialsMail('plain-secret', $customer))->content()); - expect($content->with['customerPortalUrl'])->toBe('https://console.fleetbase.test/acme-portal'); + expect($content->with['customerPortalUrl'])->toBe('https://console.fleetbase.test/customer-access/acme-portal'); })->with([null, 'other-company']); From 2f2de4ada66508e43b5e3bd1377724dbe7399afb Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 6 Oct 2026 14:37:22 +0800 Subject: [PATCH 04/87] feat(tracking-page): organization settings for the customer tracking page Add the settings backend for the customer tracking page refactor (PR 2): - TrackingPageConfig: the company config's shape, defaults and sanitizing (pages, branding, access, what verified visitors see), instance defaults for the generic page, slug rules with a reserved list, and accent contrast helpers (ink colour and WCAG ratio). - SettingController: get/save tracking-page-settings, a live slug check, and admin defaults. Saved slugs are indexed in fleet-ops.tracking-page-slugs.* so the public page resolves a company in one read; an enabled organization page refuses an invalid, reserved or taken slug. The read-back includes the slug status, the accent's ink and contrast, whether SMS is configured (Twilio counts only with credentials), and whether the customer portal is installed. - Routes under settings/, and a tracking-page-settings permission resource. --- server/src/Auth/Schemas/FleetOps.php | 5 + .../Internal/v1/SettingController.php | 196 ++++++++++ .../TrackingPage/TrackingPageConfig.php | 259 +++++++++++++ server/src/routes.php | 5 + server/tests/TrackingPageSettingsTest.php | 343 ++++++++++++++++++ 5 files changed, 808 insertions(+) create mode 100644 server/src/Support/TrackingPage/TrackingPageConfig.php create mode 100644 server/tests/TrackingPageSettingsTest.php diff --git a/server/src/Auth/Schemas/FleetOps.php b/server/src/Auth/Schemas/FleetOps.php index 8a652e2f0..247e1da84 100644 --- a/server/src/Auth/Schemas/FleetOps.php +++ b/server/src/Auth/Schemas/FleetOps.php @@ -229,6 +229,11 @@ class FleetOps 'actions' => [], 'remove_actions' => ['delete', 'export', 'create'], ], + [ + 'name' => 'tracking-page-settings', // the customer tracking page: branding, access and what verified visitors see + 'actions' => [], + 'remove_actions' => ['delete', 'export', 'create'], + ], [ 'name' => 'telematics-settings', // organization device event and position history preferences 'action' => [], diff --git a/server/src/Http/Controllers/Internal/v1/SettingController.php b/server/src/Http/Controllers/Internal/v1/SettingController.php index 296363a07..3699c4bf7 100644 --- a/server/src/Http/Controllers/Internal/v1/SettingController.php +++ b/server/src/Http/Controllers/Internal/v1/SettingController.php @@ -5,10 +5,13 @@ use Fleetbase\FleetOps\Jobs\DispatchTelematicsRetentionJobs; use Fleetbase\FleetOps\Support\Telematics\Retention\RetentionPolicy; use Fleetbase\FleetOps\Support\Telematics\Telemetry\Queue; +use Fleetbase\FleetOps\Support\TrackingPage\TrackingPageConfig; +use Fleetbase\FleetOps\Support\Utils; use Fleetbase\FleetOps\Tracking\TrackingProviderRegistry; use Fleetbase\FleetOps\Traits\AuthorizesMethods; use Fleetbase\Http\Controllers\Controller; use Fleetbase\Models\Setting; +use Fleetbase\Services\SmsService; use Fleetbase\Support\Auth; use Fleetbase\Support\NotificationRegistry; use Illuminate\Database\MySqlConnection; @@ -47,6 +50,11 @@ public function __construct() 'saveTelematicsSettings' => 'update telematics-settings', 'getTelematicsStorageUsage' => 'admin', 'runTelematicsRetention' => 'admin', + 'getTrackingPageSettings' => 'view tracking-page-settings', + 'saveTrackingPageSettings' => 'update tracking-page-settings', + 'validateTrackingPageSlug' => 'update tracking-page-settings', + 'getAdminTrackingPageSettings' => 'admin', + 'saveAdminTrackingPageSettings' => 'admin', ]); } @@ -1074,6 +1082,194 @@ protected function storageTableStatistics(array $tables): array return $statistics; } + /** + * The company's customer tracking page settings, with what the settings screen needs to + * explain them: whether the slug is usable, whether SMS can be sent, and whether the + * customer portal is there for sign-in. + * + * @return \Illuminate\Http\JsonResponse + */ + public function getTrackingPageSettings() + { + return response()->json($this->trackingPageView($this->trackingPageConfig())); + } + + /** + * Save the company's tracking page settings. + * + * The slug is indexed so the public page can find the company in one read. A slug that + * is invalid, reserved or taken can't be saved while the organization page is on; while + * it is off, the slug is kept but not indexed. + * + * @return \Illuminate\Http\JsonResponse + */ + public function saveTrackingPageSettings(Request $request) + { + $previous = $this->trackingPageConfig(); + $config = TrackingPageConfig::sanitize((array) $request->input('trackingPage', []), $this->trackingPageAdminConfig(), $this->trackingPageCompanyName()); + $slug = $config['org_page']['slug']; + $validation = $this->trackingPageSlugValidation($slug); + + if ($config['org_page']['enabled'] && !$validation['valid']) { + return response()->error($validation['message'], 422); + } + + if ($validation['valid']) { + $previousSlug = $previous['org_page']['slug']; + if ($previousSlug !== $slug && $this->trackingPageSlugOwner($previousSlug) === $this->trackingPageCompanyUuid()) { + $this->configureSetting(TrackingPageConfig::SLUG_INDEX_PREFIX . $previousSlug, null); + } + + $this->configureSetting(TrackingPageConfig::SLUG_INDEX_PREFIX . $slug, $this->trackingPageCompanyUuid()); + } + + $this->configureCompanySetting(TrackingPageConfig::SETTING_KEY, $config); + + return response()->json($this->trackingPageView($config)); + } + + /** + * Check a slug while it is typed. + * + * @return \Illuminate\Http\JsonResponse + */ + public function validateTrackingPageSlug(Request $request) + { + $slug = strtolower(trim((string) $request->input('slug', ''))); + + return response()->json(array_merge(['slug' => $slug], $this->trackingPageSlugValidation($slug))); + } + + /** + * Instance-wide defaults for the generic tracking page. + * + * @return \Illuminate\Http\JsonResponse + */ + public function getAdminTrackingPageSettings() + { + return response()->json($this->trackingPageAdminConfig()); + } + + /** + * Save the instance-wide defaults for the generic tracking page. + * + * @return \Illuminate\Http\JsonResponse + */ + public function saveAdminTrackingPageSettings(Request $request) + { + $config = TrackingPageConfig::sanitizeAdmin((array) $request->input('trackingPage', [])); + $this->configureSetting(TrackingPageConfig::ADMIN_SETTING_KEY, $config); + + return response()->json($config); + } + + protected function trackingPageConfig(): array + { + return TrackingPageConfig::sanitize( + (array) $this->lookupCompanySetting(TrackingPageConfig::SETTING_KEY, []), + $this->trackingPageAdminConfig(), + $this->trackingPageCompanyName() + ); + } + + protected function trackingPageAdminConfig(): array + { + return TrackingPageConfig::sanitizeAdmin((array) $this->lookupSetting(TrackingPageConfig::ADMIN_SETTING_KEY, [])); + } + + /** + * The saved config plus the read-only facts the settings screen shows beside it. + */ + protected function trackingPageView(array $config): array + { + $accent = $config['branding']['accent']; + + return array_merge($config, [ + 'slug_validation' => $this->trackingPageSlugValidation($config['org_page']['slug']), + 'accent_ink' => TrackingPageConfig::inkFor($accent), + 'accent_contrast' => TrackingPageConfig::contrastRatio($accent, TrackingPageConfig::inkFor($accent)), + 'sms_available' => $this->smsProviderConfigured(), + 'customer_portal_installed' => $this->customerPortalInstalled(), + 'admin' => $this->trackingPageAdminConfig(), + ]); + } + + /** + * @return array{valid: bool, code: string, message: string} + */ + protected function trackingPageSlugValidation(string $slug): array + { + $error = TrackingPageConfig::slugError($slug); + if ($error === 'invalid') { + return ['valid' => false, 'code' => 'invalid', 'message' => 'Use 3 to 40 lowercase letters, numbers and single hyphens, starting and ending with a letter or number.']; + } + + if ($error === 'reserved') { + return ['valid' => false, 'code' => 'reserved', 'message' => 'This address is reserved. Please choose another.']; + } + + $owner = $this->trackingPageSlugOwner($slug); + if ($owner !== null && $owner !== $this->trackingPageCompanyUuid()) { + return ['valid' => false, 'code' => 'taken', 'message' => 'Another organization already uses this address.']; + } + + return ['valid' => true, 'code' => 'available', 'message' => 'This address is available.']; + } + + protected function trackingPageSlugOwner(string $slug): ?string + { + $owner = $this->lookupSetting(TrackingPageConfig::SLUG_INDEX_PREFIX . $slug); + + return is_string($owner) && $owner !== '' ? $owner : null; + } + + protected function trackingPageCompanyUuid(): ?string + { + return data_get($this->currentCompany(), 'uuid'); + } + + protected function trackingPageCompanyName(): ?string + { + return data_get($this->currentCompany(), 'name'); + } + + /** + * Whether the instance can send SMS. Twilio is listed as always available, so it counts + * only with credentials; other providers report their own configuration. + */ + protected function smsProviderConfigured(): bool + { + if (filled(config('services.twilio.sid')) && filled(config('services.twilio.token'))) { + return true; + } + + return collect($this->smsProviders()) + ->except(SmsService::PROVIDER_TWILIO) + ->contains(fn ($provider) => (bool) data_get($provider, 'available')); + } + + protected function smsProviders(): array + { + // @codeCoverageIgnoreStart + // Reads the live SMS provider configuration; tests replace this seam. + return (new SmsService())->getAvailableProviders(); + // @codeCoverageIgnoreEnd + } + + protected function customerPortalInstalled(): bool + { + return collect($this->installedFleetbaseExtensions()) + ->contains(fn ($package) => data_get($package, 'name') === 'fleetbase/customer-portal-api'); + } + + protected function installedFleetbaseExtensions(): array + { + // @codeCoverageIgnoreStart + // Reads the installed composer packages; tests replace this seam. + return Utils::getInstalledFleetbaseExtensions(); + // @codeCoverageIgnoreEnd + } + protected function dispatchTelematicsPrune(): void { Queue::dispatch(new DispatchTelematicsRetentionJobs()); diff --git a/server/src/Support/TrackingPage/TrackingPageConfig.php b/server/src/Support/TrackingPage/TrackingPageConfig.php new file mode 100644 index 000000000..783965d07 --- /dev/null +++ b/server/src/Support/TrackingPage/TrackingPageConfig.php @@ -0,0 +1,259 @@ + [ + 'enabled' => static::bool(data_get($input, 'generic_page.enabled'), true), + ], + 'branding' => [ + 'display_name' => static::text(data_get($input, 'branding.display_name'), 80), + 'accent' => static::color(data_get($input, 'branding.accent')) ?? static::DEFAULT_ACCENT, + 'support_phone' => static::text(data_get($input, 'branding.support_phone'), 32), + 'support_email' => static::email(data_get($input, 'branding.support_email')), + 'website' => static::url(data_get($input, 'branding.website')), + 'powered_by' => static::bool(data_get($input, 'branding.powered_by'), true), + ], + ]; + } + + /** + * A company's config, with every key present and every value valid. + * + * @param array $admin the sanitized instance defaults + * @param string|null $companyName used for the default slug + */ + public static function sanitize(array $input, array $admin = [], ?string $companyName = null): array + { + $admin = static::sanitizeAdmin($admin); + $locale = static::oneOf(data_get($input, 'branding.default_locale'), static::LOCALES, 'en-us'); + + return [ + 'org_page' => [ + 'enabled' => static::bool(data_get($input, 'org_page.enabled'), false), + 'slug' => static::normalizeSlug(data_get($input, 'org_page.slug')) ?: static::slugify((string) $companyName), + ], + 'generic_page' => [ + 'allowed' => static::bool(data_get($input, 'generic_page.allowed'), true), + ], + 'links' => [ + 'target' => static::oneOf(data_get($input, 'links.target'), ['auto', 'org', 'generic'], 'auto'), + ], + 'branding' => [ + 'display_name' => static::text(data_get($input, 'branding.display_name'), 80), + 'logo_uuid' => static::text(data_get($input, 'branding.logo_uuid'), 64), + 'accent' => static::color(data_get($input, 'branding.accent')) ?? $admin['branding']['accent'], + 'accent_dark' => static::color(data_get($input, 'branding.accent_dark')), + 'support_phone' => static::text(data_get($input, 'branding.support_phone'), 32), + 'support_email' => static::email(data_get($input, 'branding.support_email')), + 'website' => static::url(data_get($input, 'branding.website')), + 'powered_by' => static::bool(data_get($input, 'branding.powered_by'), $admin['branding']['powered_by']), + 'theme' => static::oneOf(data_get($input, 'branding.theme'), ['system', 'light'], 'system'), + 'default_locale' => $locale, + 'locales' => static::locales(data_get($input, 'branding.locales'), $locale), + ], + 'access' => [ + 'public_status' => static::bool(data_get($input, 'access.public_status'), true), + 'channels' => [ + 'sms' => static::bool(data_get($input, 'access.channels.sms'), true), + 'email' => static::bool(data_get($input, 'access.channels.email'), true), + ], + 'session_hours' => static::clampInt(data_get($input, 'access.session_hours'), 1, 168, 24), + 'account_sign_in' => static::bool(data_get($input, 'access.account_sign_in'), true), + 'account_upsell' => static::bool(data_get($input, 'access.account_upsell'), true), + ], + 'visibility' => [ + 'map' => static::bool(data_get($input, 'visibility.map'), true), + 'driver_name' => static::bool(data_get($input, 'visibility.driver_name'), true), + 'vehicle' => static::bool(data_get($input, 'visibility.vehicle'), true), + 'driver_contact' => static::oneOf(data_get($input, 'visibility.driver_contact'), ['company', 'driver'], 'company'), + 'items' => static::bool(data_get($input, 'visibility.items'), true), + 'item_prices' => static::bool(data_get($input, 'visibility.item_prices'), false), + 'pod_photo' => static::bool(data_get($input, 'visibility.pod_photo'), true), + 'pod_signature' => static::bool(data_get($input, 'visibility.pod_signature'), true), + 'instructions_edit' => static::bool(data_get($input, 'visibility.instructions_edit'), false), + 'report_problem' => static::bool(data_get($input, 'visibility.report_problem'), true), + ], + ]; + } + + /** + * Why a slug can't be used, or null when its shape is fine. Availability is the caller's check. + * + * @return string|null `invalid` or `reserved` + */ + public static function slugError(?string $slug): ?string + { + if (!is_string($slug) || preg_match('/^[a-z0-9](?:[a-z0-9-]{1,38})[a-z0-9]$/', $slug) !== 1 || str_contains($slug, '--')) { + return 'invalid'; + } + + return in_array($slug, static::RESERVED_SLUGS, true) ? 'reserved' : null; + } + + /** + * A slug made from a company name: lowercase, hyphenated, 3 to 40 characters. + */ + public static function slugify(string $name): string + { + $slug = trim(Str::limit(Str::slug($name), 40, ''), '-'); + + if (strlen($slug) >= 3) { + return $slug; + } + + return $slug === '' ? 'company-tracking' : $slug . '-tracking'; + } + + /** + * The text colour readable on an accent: white when it reaches 4.5:1, otherwise dark. + */ + public static function inkFor(string $accent): string + { + return static::contrastRatio($accent, static::LIGHT_INK) >= 4.5 ? static::LIGHT_INK : static::DARK_INK; + } + + /** + * The WCAG contrast ratio of two `#RRGGBB` colours. + */ + public static function contrastRatio(string $a, string $b): float + { + $lighter = max(static::luminance($a), static::luminance($b)); + $darker = min(static::luminance($a), static::luminance($b)); + + return round(($lighter + 0.05) / ($darker + 0.05), 2); + } + + protected static function luminance(string $hex): float + { + $channels = array_map(function (string $pair) { + $value = hexdec($pair) / 255; + + return $value <= 0.03928 ? $value / 12.92 : (($value + 0.055) / 1.055) ** 2.4; + }, str_split(ltrim($hex, '#'), 2)); + + return 0.2126 * $channels[0] + 0.7152 * $channels[1] + 0.0722 * $channels[2]; + } + + protected static function normalizeSlug(mixed $value): string + { + return is_string($value) ? strtolower(trim($value)) : ''; + } + + protected static function bool(mixed $value, bool $default): bool + { + if (is_bool($value)) { + return $value; + } + + return filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? $default; + } + + protected static function text(mixed $value, int $max): ?string + { + if (!is_string($value) && !is_numeric($value)) { + return null; + } + + $value = trim((string) $value); + + return $value === '' ? null : mb_substr($value, 0, $max); + } + + protected static function email(mixed $value): ?string + { + $value = static::text($value, 191); + + return $value !== null && filter_var($value, FILTER_VALIDATE_EMAIL) ? $value : null; + } + + protected static function url(mixed $value): ?string + { + $value = static::text($value, 255); + + return $value !== null && preg_match('#^https?://#i', $value) === 1 && filter_var($value, FILTER_VALIDATE_URL) ? $value : null; + } + + /** + * `#RGB` or `#RRGGBB` as uppercase `#RRGGBB`, or null. + */ + protected static function color(mixed $value): ?string + { + if (!is_string($value) || preg_match('/^#?([0-9a-f]{3}|[0-9a-f]{6})$/i', trim($value), $match) !== 1) { + return null; + } + + $hex = $match[1]; + if (strlen($hex) === 3) { + $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2]; + } + + return '#' . strtoupper($hex); + } + + protected static function oneOf(mixed $value, array $allowed, string $default): string + { + return is_string($value) && in_array($value, $allowed, true) ? $value : $default; + } + + protected static function clampInt(mixed $value, int $min, int $max, int $default): int + { + if (!is_numeric($value)) { + return $default; + } + + return max($min, min($max, (int) $value)); + } + + /** + * The enabled locales, always including the default one; every locale when none are valid. + */ + protected static function locales(mixed $value, string $default): array + { + $locales = array_values(array_intersect(static::LOCALES, is_array($value) ? $value : [])); + if ($locales === []) { + return static::LOCALES; + } + + return in_array($default, $locales, true) ? $locales : array_merge([$default], $locales); + } +} diff --git a/server/src/routes.php b/server/src/routes.php index dd757f07c..791078a04 100644 --- a/server/src/routes.php +++ b/server/src/routes.php @@ -806,6 +806,11 @@ function ($router) { $router->post('tracking-settings', 'SettingController@saveTrackingSettings'); $router->get('admin-tracking-settings', 'SettingController@getAdminTrackingSettings'); $router->post('admin-tracking-settings', 'SettingController@saveAdminTrackingSettings'); + $router->get('tracking-page-settings', 'SettingController@getTrackingPageSettings'); + $router->post('tracking-page-settings', 'SettingController@saveTrackingPageSettings'); + $router->post('tracking-page-settings/validate-slug', 'SettingController@validateTrackingPageSlug'); + $router->get('admin-tracking-page-settings', 'SettingController@getAdminTrackingPageSettings'); + $router->post('admin-tracking-page-settings', 'SettingController@saveAdminTrackingPageSettings'); $router->get('map', 'SettingController@getMapSettings'); $router->post('map', 'SettingController@saveMapSettings'); $router->get('admin-map', 'SettingController@getAdminMapSettings'); diff --git a/server/tests/TrackingPageSettingsTest.php b/server/tests/TrackingPageSettingsTest.php new file mode 100644 index 000000000..9a027c73c --- /dev/null +++ b/server/tests/TrackingPageSettingsTest.php @@ -0,0 +1,343 @@ +configured[] = [$key, $value]; + $this->settings[$key] = $value; + + return null; + } + + protected function configureCompanySetting(string $key, mixed $value): mixed + { + $this->companySettings[$key] = $value; + + return null; + } + + protected function lookupSetting(string $key, mixed $defaultValue = null): mixed + { + return $this->settings[$key] ?? $defaultValue; + } + + protected function lookupCompanySetting(string $key, mixed $defaultValue = null): mixed + { + return $this->companySettings[$key] ?? $defaultValue; + } + + protected function currentCompany(): mixed + { + return $this->company; + } + + protected function smsProviders(): array + { + return $this->smsProviders; + } + + protected function installedFleetbaseExtensions(): array + { + return $this->extensions; + } +} + +function fleetopsTrackingPageProbe(): FleetOpsTrackingPageSettingProbe +{ + $probe = new FleetOpsTrackingPageSettingProbe(); + $probe->company = (object) ['uuid' => 'company-1', 'name' => 'Northwind Couriers']; + + config(['services.twilio.sid' => null, 'services.twilio.token' => null]); + + return $probe; +} + +function fleetopsTrackingPageRequest(array $trackingPage): Request +{ + return new Request(['trackingPage' => $trackingPage]); +} + +test('tracking page config fills every key with defaults for an empty company setting', function () { + $config = TrackingPageConfig::sanitize([], [], 'Northwind Couriers'); + + expect($config['org_page'])->toBe(['enabled' => false, 'slug' => 'northwind-couriers']) + ->and($config['generic_page'])->toBe(['allowed' => true]) + ->and($config['links'])->toBe(['target' => 'auto']) + ->and($config['branding'])->toBe([ + 'display_name' => null, + 'logo_uuid' => null, + 'accent' => '#1F5FA8', + 'accent_dark' => null, + 'support_phone' => null, + 'support_email' => null, + 'website' => null, + 'powered_by' => true, + 'theme' => 'system', + 'default_locale' => 'en-us', + 'locales' => TrackingPageConfig::LOCALES, + ]) + ->and($config['access'])->toBe([ + 'public_status' => true, + 'channels' => ['sms' => true, 'email' => true], + 'session_hours' => 24, + 'account_sign_in' => true, + 'account_upsell' => true, + ]) + ->and($config['visibility'])->toBe([ + 'map' => true, + 'driver_name' => true, + 'vehicle' => true, + 'driver_contact' => 'company', + 'items' => true, + 'item_prices' => false, + 'pod_photo' => true, + 'pod_signature' => true, + 'instructions_edit' => false, + 'report_problem' => true, + ]); +}); + +test('tracking page config keeps valid values and replaces invalid ones', function () { + $config = TrackingPageConfig::sanitize([ + 'org_page' => ['enabled' => 'true', 'slug' => ' Northwind '], + 'generic_page' => ['allowed' => '0'], + 'links' => ['target' => 'org'], + 'branding' => [ + 'display_name' => ' Northwind ', + 'logo_uuid' => 'file-uuid', + 'accent' => '0b6', + 'accent_dark' => '#5cc9be', + 'support_phone' => 5105550142, + 'support_email' => 'help@northwind.example', + 'website' => 'https://northwind.example', + 'powered_by' => false, + 'theme' => 'light', + 'default_locale' => 'de-de', + 'locales' => ['fr-fr', 'xx-xx'], + 'unknown' => 'dropped', + ], + 'access' => ['public_status' => 'no', 'channels' => ['sms' => 'maybe', 'email' => false], 'session_hours' => 500], + 'visibility' => ['driver_contact' => 'driver', 'item_prices' => 1, 'map' => 'off'], + 'extra' => ['dropped' => true], + ], ['branding' => ['accent' => '#123456', 'powered_by' => false]], 'Ignored'); + + expect($config['org_page'])->toBe(['enabled' => true, 'slug' => 'northwind']) + ->and($config['generic_page']['allowed'])->toBeFalse() + ->and($config['links']['target'])->toBe('org') + ->and($config['branding']['display_name'])->toBe('Northwind') + ->and($config['branding']['logo_uuid'])->toBe('file-uuid') + ->and($config['branding']['accent'])->toBe('#00BB66') + ->and($config['branding']['accent_dark'])->toBe('#5CC9BE') + ->and($config['branding']['support_phone'])->toBe('5105550142') + ->and($config['branding']['support_email'])->toBe('help@northwind.example') + ->and($config['branding']['website'])->toBe('https://northwind.example') + ->and($config['branding']['powered_by'])->toBeFalse() + ->and($config['branding']['theme'])->toBe('light') + ->and($config['branding']['default_locale'])->toBe('en-us') + ->and($config['branding']['locales'])->toBe(['en-us', 'fr-fr']) + ->and($config['branding'])->not->toHaveKey('unknown') + ->and($config)->not->toHaveKey('extra') + ->and($config['access']['public_status'])->toBeFalse() + ->and($config['access']['channels'])->toBe(['sms' => true, 'email' => false]) + ->and($config['access']['session_hours'])->toBe(168) + ->and($config['visibility']['driver_contact'])->toBe('driver') + ->and($config['visibility']['item_prices'])->toBeTrue() + ->and($config['visibility']['map'])->toBeFalse(); + + $fallback = TrackingPageConfig::sanitize([ + 'branding' => [ + 'accent' => 'teal', + 'accent_dark' => ['#000000'], + 'support_email' => 'not-an-email', + 'website' => 'ftp://northwind.example', + 'display_name' => ' ', + 'locales' => ['ar-ae', 'en-us'], + ], + 'access' => ['session_hours' => 'soon'], + ], ['branding' => ['accent' => '#123456', 'powered_by' => false]], 'N'); + + expect($fallback['org_page']['slug'])->toBe('n-tracking') + ->and($fallback['branding']['accent'])->toBe('#123456') + ->and($fallback['branding']['accent_dark'])->toBeNull() + ->and($fallback['branding']['support_email'])->toBeNull() + ->and($fallback['branding']['website'])->toBeNull() + ->and($fallback['branding']['display_name'])->toBeNull() + ->and($fallback['branding']['powered_by'])->toBeFalse() + ->and($fallback['branding']['locales'])->toBe(['en-us', 'ar-ae']) + ->and($fallback['access']['session_hours'])->toBe(24) + ->and(TrackingPageConfig::sanitize(['access' => ['session_hours' => 0]])['access']['session_hours'])->toBe(1) + ->and(TrackingPageConfig::sanitize([])['org_page']['slug'])->toBe('company-tracking'); +}); + +test('tracking page admin defaults are sanitized on their own', function () { + expect(TrackingPageConfig::sanitizeAdmin([]))->toBe([ + 'generic_page' => ['enabled' => true], + 'branding' => [ + 'display_name' => null, + 'accent' => '#1F5FA8', + 'support_phone' => null, + 'support_email' => null, + 'website' => null, + 'powered_by' => true, + ], + ])->and(TrackingPageConfig::sanitizeAdmin([ + 'generic_page' => ['enabled' => false], + 'branding' => ['display_name' => 'Fleetbase Cloud', 'accent' => '#abcdef', 'support_email' => 'ops@fleetbase.example'], + ]))->toBe([ + 'generic_page' => ['enabled' => false], + 'branding' => [ + 'display_name' => 'Fleetbase Cloud', + 'accent' => '#ABCDEF', + 'support_phone' => null, + 'support_email' => 'ops@fleetbase.example', + 'website' => null, + 'powered_by' => true, + ], + ]); +}); + +test('tracking page slugs are checked for shape and reserved words', function () { + expect(TrackingPageConfig::slugError('northwind'))->toBeNull() + ->and(TrackingPageConfig::slugError('north-wind-2'))->toBeNull() + ->and(TrackingPageConfig::slugError('ab'))->toBe('invalid') + ->and(TrackingPageConfig::slugError(str_repeat('a', 41)))->toBe('invalid') + ->and(TrackingPageConfig::slugError('-northwind'))->toBe('invalid') + ->and(TrackingPageConfig::slugError('north--wind'))->toBe('invalid') + ->and(TrackingPageConfig::slugError('North'))->toBe('invalid') + ->and(TrackingPageConfig::slugError(null))->toBe('invalid') + ->and(TrackingPageConfig::slugError('track'))->toBe('reserved') + ->and(TrackingPageConfig::slugError('customer-portal'))->toBe('reserved') + ->and(TrackingPageConfig::slugify(str_repeat('Northwind ', 8)))->toHaveLength(39) + ->and(TrackingPageConfig::slugify('Northwind Couriers, Inc.'))->toBe('northwind-couriers-inc'); +}); + +test('tracking page accents get a readable ink and a contrast ratio', function () { + expect(TrackingPageConfig::inkFor('#0B6E68'))->toBe('#FFFFFF') + ->and(TrackingPageConfig::inkFor('#5CC9BE'))->toBe('#14191A') + ->and(TrackingPageConfig::contrastRatio('#FFFFFF', '#000000'))->toBe(21.0) + ->and(TrackingPageConfig::contrastRatio('#1F5FA8', '#FFFFFF'))->toBeGreaterThan(4.5) + ->and(TrackingPageConfig::contrastRatio('#FFFFFF', '#FFFFFF'))->toBe(1.0); +}); + +test('tracking page settings read back with what the settings screen explains', function () { + $probe = fleetopsTrackingPageProbe(); + + $view = $probe->getTrackingPageSettings()->getData(true); + + expect($view['org_page'])->toBe(['enabled' => false, 'slug' => 'northwind-couriers']) + ->and($view['slug_validation'])->toBe(['valid' => true, 'code' => 'available', 'message' => 'This address is available.']) + ->and($view['accent_ink'])->toBe('#FFFFFF') + ->and($view['accent_contrast'])->toBeGreaterThan(4.5) + ->and($view['sms_available'])->toBeFalse() + ->and($view['customer_portal_installed'])->toBeFalse() + ->and($view['admin']['generic_page'])->toBe(['enabled' => true]); + + $probe->extensions = [['name' => 'fleetbase/customer-portal-api']]; + $probe->smsProviders = ['twilio' => ['available' => true], 'vonage' => ['available' => true]]; + + expect($probe->getTrackingPageSettings()->getData(true)) + ->toMatchArray(['sms_available' => true, 'customer_portal_installed' => true]); + + $probe->smsProviders = ['twilio' => ['available' => true]]; + expect($probe->getTrackingPageSettings()->getData(true)['sms_available'])->toBeFalse(); + + config(['services.twilio.sid' => 'AC123', 'services.twilio.token' => 'secret']); + expect($probe->getTrackingPageSettings()->getData(true)['sms_available'])->toBeTrue(); +}); + +test('saving tracking page settings indexes the slug and frees the old one', function () { + $probe = fleetopsTrackingPageProbe(); + + $saved = $probe->saveTrackingPageSettings(fleetopsTrackingPageRequest([ + 'org_page' => ['enabled' => true, 'slug' => 'northwind'], + 'branding' => ['accent' => '#0B6E68'], + ]))->getData(true); + + expect($saved['org_page'])->toBe(['enabled' => true, 'slug' => 'northwind']) + ->and($saved['branding']['accent'])->toBe('#0B6E68') + ->and($probe->companySettings[TrackingPageConfig::SETTING_KEY]['org_page']['slug'])->toBe('northwind') + ->and($probe->settings[TrackingPageConfig::SLUG_INDEX_PREFIX . 'northwind'])->toBe('company-1') + ->and($probe->configured)->toBe([[TrackingPageConfig::SLUG_INDEX_PREFIX . 'northwind', 'company-1']]); + + $probe->saveTrackingPageSettings(fleetopsTrackingPageRequest(['org_page' => ['enabled' => true, 'slug' => 'northwind-couriers']])); + + expect($probe->settings[TrackingPageConfig::SLUG_INDEX_PREFIX . 'northwind'])->toBeNull() + ->and($probe->settings[TrackingPageConfig::SLUG_INDEX_PREFIX . 'northwind-couriers'])->toBe('company-1'); + + // A previous slug that another company now holds is left alone. + $probe->settings[TrackingPageConfig::SLUG_INDEX_PREFIX . 'northwind-couriers'] = 'company-2'; + $probe->configured = []; + $probe->saveTrackingPageSettings(fleetopsTrackingPageRequest(['org_page' => ['enabled' => false, 'slug' => 'northwind-express']])); + + expect($probe->configured)->toBe([[TrackingPageConfig::SLUG_INDEX_PREFIX . 'northwind-express', 'company-1']]) + ->and($probe->settings[TrackingPageConfig::SLUG_INDEX_PREFIX . 'northwind-couriers'])->toBe('company-2'); +}); + +test('an enabled organization page refuses an unusable slug while a disabled one keeps it unindexed', function () { + $probe = fleetopsTrackingPageProbe(); + $probe->settings[TrackingPageConfig::SLUG_INDEX_PREFIX . 'taken-slug'] = 'company-2'; + + expect($probe->saveTrackingPageSettings(fleetopsTrackingPageRequest(['org_page' => ['enabled' => true, 'slug' => 'taken-slug']]))->getData(true)) + ->toBe(['error' => 'Another organization already uses this address.']) + ->and($probe->saveTrackingPageSettings(fleetopsTrackingPageRequest(['org_page' => ['enabled' => true, 'slug' => 'track']]))->getData(true)) + ->toBe(['error' => 'This address is reserved. Please choose another.']) + ->and($probe->saveTrackingPageSettings(fleetopsTrackingPageRequest(['org_page' => ['enabled' => true, 'slug' => 'a b']]))->getData(true)['error']) + ->toStartWith('Use 3 to 40 lowercase letters') + ->and($probe->companySettings)->toBe([]); + + $saved = $probe->saveTrackingPageSettings(fleetopsTrackingPageRequest(['org_page' => ['enabled' => false, 'slug' => 'taken-slug']]))->getData(true); + + expect($saved['org_page'])->toBe(['enabled' => false, 'slug' => 'taken-slug']) + ->and($saved['slug_validation']['code'])->toBe('taken') + ->and($probe->configured)->toBe([]) + ->and($probe->companySettings[TrackingPageConfig::SETTING_KEY]['org_page']['slug'])->toBe('taken-slug'); +}); + +test('tracking page slugs can be checked while typed', function () { + $probe = fleetopsTrackingPageProbe(); + $probe->settings[TrackingPageConfig::SLUG_INDEX_PREFIX . 'northwind'] = 'company-1'; + $probe->settings[TrackingPageConfig::SLUG_INDEX_PREFIX . 'harbor'] = 'company-2'; + + expect($probe->validateTrackingPageSlug(new Request(['slug' => ' Northwind ']))->getData(true)) + ->toBe(['slug' => 'northwind', 'valid' => true, 'code' => 'available', 'message' => 'This address is available.']) + ->and($probe->validateTrackingPageSlug(new Request(['slug' => 'harbor']))->getData(true)['code'])->toBe('taken') + ->and($probe->validateTrackingPageSlug(new Request(['slug' => 'admin']))->getData(true)['code'])->toBe('reserved') + ->and($probe->validateTrackingPageSlug(new Request())->getData(true)['code'])->toBe('invalid'); + + $probe->settings[TrackingPageConfig::SLUG_INDEX_PREFIX . 'empty-owner'] = ''; + expect($probe->validateTrackingPageSlug(new Request(['slug' => 'empty-owner']))->getData(true)['valid'])->toBeTrue(); +}); + +test('admin tracking page defaults are read and saved sanitized', function () { + $probe = fleetopsTrackingPageProbe(); + + expect($probe->getAdminTrackingPageSettings()->getData(true)['generic_page'])->toBe(['enabled' => true]); + + $saved = $probe->saveAdminTrackingPageSettings(fleetopsTrackingPageRequest([ + 'generic_page' => ['enabled' => false], + 'branding' => ['accent' => 'nope', 'website' => 'https://fleetbase.example'], + ]))->getData(true); + + expect($saved['generic_page'])->toBe(['enabled' => false]) + ->and($saved['branding']['accent'])->toBe('#1F5FA8') + ->and($saved['branding']['website'])->toBe('https://fleetbase.example') + ->and($probe->settings[TrackingPageConfig::ADMIN_SETTING_KEY])->toBe($saved) + ->and($probe->getAdminTrackingPageSettings()->getData(true))->toBe($saved) + ->and($probe->getTrackingPageSettings()->getData(true)['admin'])->toBe($saved); +}); From e3d8bc33e56d4977c10c4d6e9552269577d838fa Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 6 Oct 2026 14:38:26 +0800 Subject: [PATCH 05/87] feat(tracking): structured tracking-url QR codes and Code 128 barcodes QR codes now carry a versioned tracking url naming the tracking number and the owner's public id (https:///track-order?order=&r=&v=1) instead of the owner's bare uuid, so a phone camera opens the tracking page while scanners read the parameters. The barcode is now Code 128 with the bare tracking number, matching the text printed beneath it. capture-qr and tracking-numbers/from-qr resolve scans through one parser that accepts the new url, a bare tracking number or public id, and the bare owner uuid printed on labels already in circulation. from-qr is now scoped to the session's company and resolves waypoint and place owners too. qr_code_content is published in every environment: it no longer exposes a uuid. Labels print the QR as a fixed square instead of cropping it with object-fit. --- server/resources/views/labels/default.php | 4 +- .../resources/views/labels/entity-label.php | 4 +- .../resources/views/labels/waypoint-label.php | 4 +- .../Controllers/Api/v1/OrderController.php | 5 +- .../Api/v1/TrackingNumberController.php | 38 +-- .../src/Http/Resources/v1/TrackingNumber.php | 42 +--- server/src/Models/Entity.php | 9 +- server/src/Models/TrackingNumber.php | 98 +++++++- server/src/Models/Waypoint.php | 9 +- .../src/Observers/TrackingNumberObserver.php | 24 +- server/src/Support/TrackingCode.php | 218 ++++++++++++++++++ 11 files changed, 365 insertions(+), 90 deletions(-) create mode 100644 server/src/Support/TrackingCode.php diff --git a/server/resources/views/labels/default.php b/server/resources/views/labels/default.php index b79adaf1b..2214e277a 100755 --- a/server/resources/views/labels/default.php +++ b/server/resources/views/labels/default.php @@ -53,7 +53,7 @@
- +
name); ?>
@@ -185,7 +185,7 @@
- +
tracking_number : $order->trackingNumber->tracking_number)); ?>
diff --git a/server/resources/views/labels/entity-label.php b/server/resources/views/labels/entity-label.php index ac62da5f7..6f354d225 100644 --- a/server/resources/views/labels/entity-label.php +++ b/server/resources/views/labels/entity-label.php @@ -45,7 +45,7 @@
- +
name) ?> @@ -114,7 +114,7 @@
- +
tracking_number : $entity->public_id) ?>
diff --git a/server/resources/views/labels/waypoint-label.php b/server/resources/views/labels/waypoint-label.php index 70f9a9585..fd043d703 100644 --- a/server/resources/views/labels/waypoint-label.php +++ b/server/resources/views/labels/waypoint-label.php @@ -52,7 +52,7 @@
- +
name); ?> @@ -123,7 +123,7 @@
- +
tracking_number : $order->trackingNumber->tracking_number)); ?>
diff --git a/server/src/Http/Controllers/Api/v1/OrderController.php b/server/src/Http/Controllers/Api/v1/OrderController.php index bc4054d9a..bd99b72a2 100644 --- a/server/src/Http/Controllers/Api/v1/OrderController.php +++ b/server/src/Http/Controllers/Api/v1/OrderController.php @@ -25,6 +25,7 @@ use Fleetbase\FleetOps\Models\ServiceQuote; use Fleetbase\FleetOps\Models\Waypoint; use Fleetbase\FleetOps\Support\ResolvesOrderServiceStops; +use Fleetbase\FleetOps\Support\TrackingCode; use Fleetbase\FleetOps\Support\Utils; use Fleetbase\Http\Controllers\Controller; use Fleetbase\Http\Resources\Comment as CommentResource; @@ -1444,8 +1445,8 @@ public function captureQrScan(Request $request, string $id, ?string $subjectId = return $this->apiError('Unable to capture QR code data.'); } - // validate - if ($subject && $code === $subject->uuid) { + // validate: the scan must name this subject, in any code format still in circulation + if (TrackingCode::matches($code, $subject)) { // create verification proof $proof = $this->createProof([ 'company_uuid' => session('company'), diff --git a/server/src/Http/Controllers/Api/v1/TrackingNumberController.php b/server/src/Http/Controllers/Api/v1/TrackingNumberController.php index d79658fcc..2c969163f 100644 --- a/server/src/Http/Controllers/Api/v1/TrackingNumberController.php +++ b/server/src/Http/Controllers/Api/v1/TrackingNumberController.php @@ -127,8 +127,10 @@ public function fromQR(DecodeTrackingNumberQR $request) // validate request inputs $code = $request->input('code'); - // get the model of from the code - $model = $this->findQrModel(['entities', 'orders'], ['uuid' => $code]); + // the order, waypoint, entity or place the code points at, in any format still in + // circulation: a tracking url, a bare tracking number or public id, or the bare + // owner uuid printed on older labels + $model = $this->findQrModel($code); // if no model response with error if (!$model) { @@ -179,37 +181,9 @@ protected function deletedTrackingNumberResource(TrackingNumber $trackingNumber) return new DeletedResource($trackingNumber); } - protected function findQrModel(array $tables, array $where) + protected function findQrModel(string $code) { - // Hydrate through the eloquent models so the resolved record can be - // wrapped in its typed resource โ€” raw rows have no model class. - $modelMap = [ - 'entities' => \Fleetbase\FleetOps\Models\Entity::class, - 'orders' => \Fleetbase\FleetOps\Models\Order::class, - ]; - - foreach ($tables as $table) { - $modelClass = $modelMap[$table] ?? null; - if (!$modelClass) { - continue; - } - - $model = $modelClass::where($where)->first(); - if ($model) { - return $model; - } - } - - // No raw-row fallback. Utils::findModel() was called with the ARRAY of table names - // and, when nothing matched, passed that array to DB::table() โ€” which stringified - // it to the literal table name "Array" and threw SQLSTATE[42S02]. Every - // unresolvable code became a 500 instead of the 400 this method's caller already - // handles. - // - // The fallback could not have helped anyway: qrModelResource() serializes through - // a typed resource keyed on the model class, and a raw stdClass row has none. The - // mapped models above are the endpoint's whole contract. - return null; + return TrackingNumber::findOwnerByCode($code, session('company')); } protected function qrModelResource($model) diff --git a/server/src/Http/Resources/v1/TrackingNumber.php b/server/src/Http/Resources/v1/TrackingNumber.php index bed9b0d5d..2f5c62ef6 100644 --- a/server/src/Http/Resources/v1/TrackingNumber.php +++ b/server/src/Http/Resources/v1/TrackingNumber.php @@ -2,6 +2,7 @@ namespace Fleetbase\FleetOps\Http\Resources\v1; +use Fleetbase\FleetOps\Support\TrackingCode; use Fleetbase\FleetOps\Support\Utils; use Fleetbase\Http\Resources\FleetbaseResource; use Fleetbase\Support\Http; @@ -30,47 +31,20 @@ public function toArray($request) 'status' => $this->last_status, 'status_code' => $this->last_status_code, 'qr_code' => $this->qr_code, - // The raw text encoded inside qr_code, exposed ONLY in debug mode. - // - // qr_code is a base64 PNG generated from owner_uuid (TrackingNumber::newBarcode), - // and the endpoints that consume a scanned code match on that uuid. A client - // scans the image to obtain it; an automated contract run cannot, so debug - // builds publish the value beside the image rather than the API growing a - // decode endpoint or handing out uuids generally. - // - // `when()` omits the key entirely when false โ€” it is absent, not null โ€” so a - // production response is byte-identical to before. - 'qr_code_content' => $this->when(static::exposesQrCodeContent(), fn () => $this->owner_uuid), + // A value that scans as this tracking number: the QR content current labels + // print (see TrackingCode). It names the tracking number and the subject's + // public id, both already in this resource, so it is safe to publish, and lets + // clients that print their own labels, or cannot decode a PNG, use the code. + // Labels printed before it encode the owner uuid; resolvers accept both. + 'qr_code_content' => $this->when($this->tracking_number, fn () => TrackingCode::qrContent($this->tracking_number, Utils::get($this->owner, 'public_id'))), 'barcode' => $this->barcode, - 'url' => Utils::consoleUrl('track-order', ['order' => $this->tracking_number]), + 'url' => Utils::consoleUrl(TrackingCode::PATH, [TrackingCode::TRACKING_PARAM => $this->tracking_number]), 'type' => Utils::getTypeFromClassName($this->owner_type), 'updated_at' => $this->updated_at, 'created_at' => $this->created_at, ]; } - /** - * Whether the QR code's decoded content may be published. - * - * Debug mode only, and fails closed: any failure to determine the debug state โ€” no - * container, an application without the accessor โ€” answers false rather than - * defaulting to exposure. - */ - protected static function exposesQrCodeContent(): bool - { - try { - $app = app(); - - if (!is_object($app) || !method_exists($app, 'hasDebugModeEnabled')) { - return false; - } - - return (bool) $app->hasDebugModeEnabled(); - } catch (\Throwable $e) { - return false; - } - } - /** * Transform the resource into an webhook payload. * diff --git a/server/src/Models/Entity.php b/server/src/Models/Entity.php index 032e2950b..224d15ec0 100644 --- a/server/src/Models/Entity.php +++ b/server/src/Models/Entity.php @@ -471,10 +471,11 @@ public static function insertGetUuid($values = [], ?Payload $payload = null) if ($result && $payload) { // create tracking number for entity $trackingNumberId = TrackingNumber::insertGetUuid([ - 'owner_uuid' => $uuid, - 'owner_type' => Utils::getModelClassName('entity'), - 'region' => $payload->getPickupRegion(), - 'location' => Utils::parsePointToWkt($payload->getPickupLocation()), + 'owner_uuid' => $uuid, + 'owner_type' => Utils::getModelClassName('entity'), + 'owner_public_id' => $values['public_id'], + 'region' => $payload->getPickupRegion(), + 'location' => Utils::parsePointToWkt($payload->getPickupLocation()), ]); // set tracking number diff --git a/server/src/Models/TrackingNumber.php b/server/src/Models/TrackingNumber.php index e5b78defd..68ae9bd15 100644 --- a/server/src/Models/TrackingNumber.php +++ b/server/src/Models/TrackingNumber.php @@ -4,6 +4,7 @@ use Fleetbase\Casts\PolymorphicType; use Fleetbase\FleetOps\Casts\Point; +use Fleetbase\FleetOps\Support\TrackingCode; use Fleetbase\FleetOps\Support\Utils; use Fleetbase\LaravelMysqlSpatial\Eloquent\SpatialTrait; use Fleetbase\Models\Model; @@ -16,6 +17,7 @@ use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; +use Milon\Barcode\Facades\DNS1DFacade as DNS1D; use Milon\Barcode\Facades\DNS2DFacade as DNS2D; class TrackingNumber extends Model @@ -236,6 +238,74 @@ public static function findTrackingOrFail($id) throw (new \Illuminate\Database\Eloquent\ModelNotFoundException())->setModel(static::class, $id); } + /** + * The record a scanned code points at, within one company: the order, waypoint, + * entity or place that owns the tracking number. + * + * Accepts every format TrackingCode::parse() reads, including the bare owner uuid + * printed on labels before codes carried a url, and a bare tracking number or public + * id from a barcode or typed by hand. Without a company nothing resolves. + */ + public static function findOwnerByCode(?string $code, ?string $companyUuid): ?\Illuminate\Database\Eloquent\Model + { + if (empty($companyUuid)) { + return null; + } + + $parsed = TrackingCode::parse($code); + + if ($parsed['uuid']) { + return static::ownerOf(static::firstForCode('owner_uuid', $parsed['uuid'], $companyUuid)); + } + + $number = $parsed['tracking_number'] ?? $parsed['reference']; + if ($number) { + $owner = static::ownerOf(static::firstForCode('tracking_number', $number, $companyUuid)); + + // A url names its tracking number deliberately, so an unknown one is a miss, + // not a cue to fall back to the owner it also names, which must agree. + if ($owner || $parsed['tracking_number']) { + return $owner && (!$parsed['public_id'] || data_get($owner, 'public_id') === $parsed['public_id']) ? $owner : null; + } + } + + $publicId = $parsed['public_id'] ?? $parsed['reference']; + + return $publicId ? static::ownerOf(static::firstForOwnerPublicId($publicId, $companyUuid)) : null; + } + + protected static function firstForCode(string $column, string $value, string $companyUuid): ?self + { + return static::where('company_uuid', $companyUuid)->where($column, $value)->first(); + } + + protected static function firstForOwnerPublicId(string $publicId, string $companyUuid): ?self + { + // The public id's prefix names the owner's model, so only that table is searched. + $prefix = Str::contains($publicId, '_') ? Str::before($publicId, '_') : null; + $ownerClass = [ + 'order' => Order::class, + 'waypoint' => Waypoint::class, + 'entity' => Entity::class, + 'place' => Place::class, + ][$prefix] ?? null; + + if (!$ownerClass) { + return null; + } + + return static::where('company_uuid', $companyUuid) + // Rows inserted raw (waypoints, entities) store the class with a leading slash. + ->whereIn('owner_type', [$ownerClass, '\\' . $ownerClass]) + ->whereIn('owner_uuid', $ownerClass::withoutGlobalScopes()->select('uuid')->where('public_id', $publicId)) + ->first(); + } + + protected static function ownerOf(?self $trackingNumber): ?\Illuminate\Database\Eloquent\Model + { + return $trackingNumber?->owner; + } + public function updateOwnerStatus(?TrackingStatus $trackingStatus = null) { $trackingStatus = $trackingStatus ?? $this->load(['status'])->getRelationValue('status'); @@ -254,6 +324,10 @@ public function updateOwnerStatus(?TrackingStatus $trackingStatus = null) public static function insertGetUuid($values = [], ?Model $owner = null) { + // Read before the fillable filter below drops it. Callers that insert the owner + // with a raw query, and so have no model to pass, hand its public_id over here. + $ownerPublicId = $owner ? data_get($owner, 'public_id') : ($values['owner_public_id'] ?? null); + $instance = new static(); $fillable = $instance->getFillable(); $insertKeys = array_keys($values); @@ -276,8 +350,7 @@ public static function insertGetUuid($values = [], ?Model $owner = null) } $values['tracking_number'] = static::newTrackingNumber($values['region'] ?? 'SG'); - $values['qr_code'] = static::newBarcode((string) ($values['owner_uuid'] ?? ''), 'QRCODE'); - $values['barcode'] = static::newBarcode((string) ($values['owner_uuid'] ?? ''), 'PDF417'); + $values = array_merge($values, static::codeImages($values['tracking_number'], $ownerPublicId)); if (isset($values['meta']) && (is_object($values['meta']) || is_array($values['meta']))) { $values['meta'] = json_encode($values['meta']); @@ -339,8 +412,29 @@ protected static function newTrackingNumber(string $region): string return static::generateNumber($region); } + /** + * The base64 PNGs printed on a label: a QR code carrying the tracking url (see + * TrackingCode) and a Code 128 barcode carrying the bare tracking number. + * + * @return array{qr_code: string, barcode: string} + */ + public static function codeImages(string $trackingNumber, ?string $ownerPublicId = null): array + { + return [ + // Medium error correction: labels get scuffed, and the url fits comfortably. + 'qr_code' => static::newBarcode(TrackingCode::qrContent($trackingNumber, $ownerPublicId), 'QRCODE,M'), + 'barcode' => static::newBarcode(TrackingCode::barcodeContent($trackingNumber), 'C128'), + ]; + } + protected static function newBarcode(string $value, string $type): string { + if ($type === 'C128') { + // 2px modules, 60px tall: a typical tracking number renders ~330px wide, inside + // the label's 360px cap, so it prints at its own resolution. + return DNS1D::getBarcodePNG($value, $type, 2, 60); + } + return DNS2D::getBarcodePNG($value, $type); } diff --git a/server/src/Models/Waypoint.php b/server/src/Models/Waypoint.php index 35d611eb4..bfb895df3 100644 --- a/server/src/Models/Waypoint.php +++ b/server/src/Models/Waypoint.php @@ -221,10 +221,11 @@ public static function insertGetUuid($values = [], ?Payload $payload = null) if ($result && $payload) { // create tracking number for entity $trackingNumberId = static::createTrackingNumber([ - 'owner_uuid' => $uuid, - 'owner_type' => Utils::getModelClassName('waypoint'), - 'region' => $payload->getPickupRegion(), - 'location' => static::pickupLocationWkt($payload), + 'owner_uuid' => $uuid, + 'owner_type' => Utils::getModelClassName('waypoint'), + 'owner_public_id' => $values['public_id'], + 'region' => $payload->getPickupRegion(), + 'location' => static::pickupLocationWkt($payload), ]); // set tracking number diff --git a/server/src/Observers/TrackingNumberObserver.php b/server/src/Observers/TrackingNumberObserver.php index c90ec3088..2b249ebee 100644 --- a/server/src/Observers/TrackingNumberObserver.php +++ b/server/src/Observers/TrackingNumberObserver.php @@ -6,7 +6,6 @@ use Fleetbase\FleetOps\Models\TrackingStatus; use Fleetbase\LaravelMysqlSpatial\Types\Point; use Illuminate\Support\Str; -use Milon\Barcode\Facades\DNS2DFacade as DNS2D; class TrackingNumberObserver { @@ -17,10 +16,9 @@ class TrackingNumberObserver */ public function creating(TrackingNumber $trackingNumber) { - // generate a barcode annd qr code + // generate the tracking number, then the qr code and barcode printed from it $trackingNumber->tracking_number = $this->generateTrackingNumber($trackingNumber); - $trackingNumber->qr_code = $this->generateBarcode($trackingNumber->owner_uuid, 'QRCODE'); - $trackingNumber->barcode = $this->generateBarcode($trackingNumber->owner_uuid, 'PDF417'); + $trackingNumber->fill($this->generateCodeImages($trackingNumber->tracking_number, $this->ownerPublicId($trackingNumber))); } /** @@ -47,9 +45,23 @@ protected function generateTrackingNumber(TrackingNumber $trackingNumber): strin return TrackingNumber::generateNumber($trackingNumber->region); } - protected function generateBarcode(string $ownerUuid, string $type): string + /** + * @return array{qr_code: string, barcode: string} + */ + protected function generateCodeImages(string $trackingNumber, ?string $ownerPublicId): array { - return DNS2D::getBarcodePNG($ownerUuid, $type); + return TrackingNumber::codeImages($trackingNumber, $ownerPublicId); + } + + protected function ownerPublicId(TrackingNumber $trackingNumber): ?string + { + if (empty($trackingNumber->owner_uuid)) { + return null; + } + + $publicId = data_get($trackingNumber, 'owner.public_id'); + + return is_string($publicId) && $publicId !== '' ? $publicId : null; } protected function createTrackingStatus(array $attributes): TrackingStatus diff --git a/server/src/Support/TrackingCode.php b/server/src/Support/TrackingCode.php new file mode 100644 index 000000000..907a79c9c --- /dev/null +++ b/server/src/Support/TrackingCode.php @@ -0,0 +1,218 @@ +/track-order?order=&r=&v=1 + * + * Only the tracking number (printed on the label in plain text) and the owner's public + * id go in: no uuids, no addresses, no names, no company. The owner's type is its + * public_id prefix (order_, waypoint_, entity_, place_), so it is not repeated. Nothing + * is signed: a label can be photocopied whatever it carries, so authorization belongs + * to the endpoint that acts on a scan, not to the code. + * + * The linear barcode (Code 128) carries the bare tracking number, matching the text + * printed beneath it, because warehouse handhelds type what they scan into a field. + * + * Codes already on parcels encode the owner's bare uuid, and parse() keeps accepting + * them, alongside a bare tracking number or a bare public_id. + */ +class TrackingCode +{ + public const VERSION = 1; + + /** + * Where a phone camera lands. The public tracking page is being rebuilt; when its + * route changes, change these two and nothing else. parse() never depends on the + * host or the path, since printed labels outlive both. + */ + public const PATH = 'track-order'; + public const TRACKING_PARAM = 'order'; + + /** + * The QR content for a tracking number. + */ + public static function qrContent(string $trackingNumber, ?string $ownerPublicId = null): string + { + $query = [static::TRACKING_PARAM => $trackingNumber]; + + if (!empty($ownerPublicId)) { + $query['r'] = $ownerPublicId; + } + + $query['v'] = static::VERSION; + + return static::consoleBaseUrl() . '/' . static::PATH . '?' . http_build_query($query); + } + + /** + * The console origin, from the same config Utils::consoleUrl() reads. It does not + * call consoleUrl() because that infers http/https from the app environment: a code + * printed on a parcel should not change scheme with where it was generated, so it + * is https unless the console is explicitly configured otherwise. + */ + protected static function consoleBaseUrl(): string + { + $host = rtrim((string) config('fleetbase.console.host', ''), '/'); + + if (Str::startsWith($host, 'http')) { + return $host; + } + + $subdomain = config('fleetbase.console.subdomain'); + $scheme = config('fleetbase.console.secure', true) ? 'https://' : 'http://'; + + return $scheme . ($subdomain ? $subdomain . '.' : '') . $host; + } + + /** + * The linear barcode content for a tracking number. + */ + public static function barcodeContent(string $trackingNumber): string + { + return $trackingNumber; + } + + /** + * Break a scanned value into the identifiers it carries. + * + * - `uuid`: a legacy code, the owner's uuid. + * - `tracking_number` and `public_id`: from a URL code. + * - `reference`: a bare value that is either a tracking number (the barcode, or one + * typed by hand) or a public_id. Consumers try both. + * + * @return array{uuid: ?string, tracking_number: ?string, public_id: ?string, reference: ?string, version: ?int} + */ + public static function parse(?string $code): array + { + $parsed = [ + 'uuid' => null, + 'tracking_number' => null, + 'public_id' => null, + 'reference' => null, + 'version' => null, + ]; + + $code = trim((string) $code); + if ($code === '') { + return $parsed; + } + + if (preg_match('#^https?://#i', $code)) { + return array_merge($parsed, static::parseUrl($code)); + } + + if (Str::isUuid($code)) { + $parsed['uuid'] = $code; + + return $parsed; + } + + $parsed['reference'] = $code; + + return $parsed; + } + + /** + * Whether a scanned value identifies the given subject (an order, waypoint, entity or + * place). + */ + public static function matches(?string $code, $subject): bool + { + $subjectUuid = data_get($subject, 'uuid'); + if (!$subject || empty($subjectUuid)) { + return false; + } + + // Labels printed before v1 encode the subject's bare uuid; compare it exactly, as + // scans always have been. + if (trim((string) $code) === $subjectUuid) { + return true; + } + + $parsed = static::parse($code); + + if ($parsed['uuid']) { + return false; + } + + $publicId = data_get($subject, 'public_id'); + $trackingNumber = static::subjectTrackingNumber($subject); + + if ($parsed['reference']) { + return static::sameTrackingNumber($parsed['reference'], $trackingNumber) || static::samePublicId($parsed['reference'], $publicId); + } + + // A url code names its owner by public id, and that decides: an owner can hold more + // than one tracking number (the API issues extra ones), so the number is compared + // only when the code names no owner. + if ($parsed['public_id']) { + return static::samePublicId($parsed['public_id'], $publicId); + } + + return $parsed['tracking_number'] !== null && static::sameTrackingNumber($parsed['tracking_number'], $trackingNumber); + } + + /** + * @return array{tracking_number: ?string, public_id: ?string, version: ?int} + */ + protected static function parseUrl(string $url): array + { + $parts = parse_url($url); + $query = []; + parse_str(is_array($parts) ? ($parts['query'] ?? '') : '', $query); + + $trackingNumber = static::stringParam($query, static::TRACKING_PARAM) ?? static::stringParam($query, 'tn'); + + // A future route may carry the tracking number in the path (/track/). Only + // a path of two or more segments can, so /track-order itself is never mistaken + // for one. + if ($trackingNumber === null && is_array($parts) && isset($parts['path'])) { + $segments = array_values(array_filter(explode('/', $parts['path']), fn ($segment) => $segment !== '')); + if (count($segments) >= 2) { + $trackingNumber = rawurldecode(end($segments)); + } + } + + $version = static::stringParam($query, 'v'); + + return [ + 'tracking_number' => $trackingNumber, + 'public_id' => static::stringParam($query, 'r'), + 'version' => $version !== null && ctype_digit($version) ? (int) $version : null, + ]; + } + + protected static function stringParam(array $query, string $key): ?string + { + $value = $query[$key] ?? null; + + return is_string($value) && trim($value) !== '' ? trim($value) : null; + } + + protected static function subjectTrackingNumber($subject): ?string + { + $trackingNumber = data_get($subject, 'trackingNumber.tracking_number') ?? data_get($subject, 'tracking_number'); + + return is_string($trackingNumber) && $trackingNumber !== '' ? $trackingNumber : null; + } + + protected static function sameTrackingNumber(string $scanned, ?string $trackingNumber): bool + { + // The label prints the number upper-cased and handhelds may not preserve case. + return $trackingNumber !== null && strcasecmp($scanned, $trackingNumber) === 0; + } + + protected static function samePublicId(string $scanned, $publicId): bool + { + return is_string($publicId) && $publicId !== '' && $scanned === $publicId; + } +} From ccec8c9593e364ad64910bc2ad3ba48f1208bbb0 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 6 Oct 2026 14:40:05 +0800 Subject: [PATCH 06/87] test(tracking): cover tracking-url codes, Code 128 barcodes and the scan resolver - TrackingCode: generation, parsing of every format in circulation (legacy uuid, tracking url from any host, bare tracking number or public id, hostile urls), and subject matching for capture-qr - TrackingNumber::findOwnerByCode against SQLite: company scoping, disagreeing url identifiers, unknown numbers and unmapped prefixes - insert path, observer and resource expectations moved off the owner uuid --- .../tests/ApiOrderControllerContractsTest.php | 40 ++++++ ...iTrackingNumberControllerContractsTest.php | 15 +- .../CompactResourceSerializationTest.php | 80 +++-------- .../TrackingNumberControllerHelpersTest.php | 79 ++++++++--- server/tests/ObserverContractsTest.php | 72 +++++++++- .../tests/Unit/Models/TrackingNumberTest.php | 73 +++++++++- server/tests/Unit/Models/WaypointTest.php | 7 +- .../tests/Unit/Support/TrackingCodeTest.php | 132 ++++++++++++++++++ 8 files changed, 392 insertions(+), 106 deletions(-) create mode 100644 server/tests/Unit/Support/TrackingCodeTest.php diff --git a/server/tests/ApiOrderControllerContractsTest.php b/server/tests/ApiOrderControllerContractsTest.php index cd5c8a4ff..f25dd6d80 100644 --- a/server/tests/ApiOrderControllerContractsTest.php +++ b/server/tests/ApiOrderControllerContractsTest.php @@ -1028,3 +1028,43 @@ public function isString(string $key): bool 'status' => 400, ]); }); + +test('api order controller captures QR proof from tracking url codes and barcodes', function () { + session(['company' => 'company-uuid']); + + $subject = (object) [ + 'uuid' => 'subject-uuid', + 'public_id' => 'waypoint_subject', + 'trackingNumber' => (object) ['tracking_number' => 'ACM1234567890SG'], + ]; + + // The url printed by current labels, from any host, and the Code 128 barcode. + foreach ([ + 'https://console.fleetbase.test/track-order?order=ACM1234567890SG&r=waypoint_subject&v=1', + 'https://old-host.example/track-order?order=ACM1234567890SG&r=waypoint_subject&v=1', + 'ACM1234567890SG', + ] as $code) { + $controller = new FleetOpsApiOrderCrudControllerProbe(); + $controller->order = new FleetOpsApiOrderCrudFake(); + $controller->resolvedSubject = $subject; + + expect($controller->captureQrScan(new Request(['code' => $code]), 'order-public', 'waypoint_subject')['resource'])->toBe('proof') + ->and($controller->createdProofs[0]['subject_uuid'])->toBe('subject-uuid'); + } + + // Another parcel's label is refused, whether it names another owner or, naming none, + // another tracking number. + foreach ([ + 'https://console.fleetbase.test/track-order?order=ACM1234567890SG&r=waypoint_other&v=1', + 'https://console.fleetbase.test/track-order?order=ACM0000000000SG&v=1', + ] as $code) { + $controller = new FleetOpsApiOrderCrudControllerProbe(); + $controller->order = new FleetOpsApiOrderCrudFake(); + $controller->resolvedSubject = $subject; + + expect($controller->captureQrScan(new Request(['code' => $code]), 'order-public', 'waypoint_subject'))->toBe([ + 'apiError' => 'Unable to validate QR code data.', + 'status' => 400, + ])->and($controller->createdProofs)->toBe([]); + } +}); diff --git a/server/tests/ApiTrackingNumberControllerContractsTest.php b/server/tests/ApiTrackingNumberControllerContractsTest.php index 17a21e1a3..83b3e66bc 100644 --- a/server/tests/ApiTrackingNumberControllerContractsTest.php +++ b/server/tests/ApiTrackingNumberControllerContractsTest.php @@ -68,9 +68,9 @@ protected function deletedTrackingNumberResource(TrackingNumber $trackingNumber) return ['resource' => 'deleted-tracking-number', 'trackingNumber' => $trackingNumber]; } - protected function findQrModel(array $tables, array $where) + protected function findQrModel(string $code) { - $this->qrLookups[] = [$tables, $where]; + $this->qrLookups[] = $code; return $this->qrModel; } @@ -206,15 +206,12 @@ function fleetopsDecodeTrackingNumberQrRequest(array $input): DecodeTrackingNumb $controller = new FleetOpsApiTrackingNumberControllerProbe(); $controller->qrModel = $model; - $response = $controller->fromQR(fleetopsDecodeTrackingNumberQrRequest(['code' => 'order-uuid'])); + $code = 'https://console.fleetbase.test/track-order?order=ACM1234567890SG&r=order_public&v=1'; + $response = $controller->fromQR(fleetopsDecodeTrackingNumberQrRequest(['code' => $code])); + // The raw scanned value goes to the resolver, which understands every format. expect($response)->toBe(['resource' => 'qr-model', 'model' => $model]) - ->and($controller->qrLookups)->toBe([ - [ - ['entities', 'orders'], - ['uuid' => 'order-uuid'], - ], - ]); + ->and($controller->qrLookups)->toBe([$code]); $controller = new FleetOpsApiTrackingNumberControllerProbe(); diff --git a/server/tests/CompactResourceSerializationTest.php b/server/tests/CompactResourceSerializationTest.php index 2f628407d..fb1f87925 100644 --- a/server/tests/CompactResourceSerializationTest.php +++ b/server/tests/CompactResourceSerializationTest.php @@ -2739,8 +2739,8 @@ function fleetopsCompactResourceFixture(array $attributes = [], array $loaded = 'scheduled_at' => '2026-07-30 09:00:00', ]); - // The decoded QR content is a debug-only field and must never ride along on a webhook, - // which is a separate literal payload rather than a filtered toArray(). + // The webhook is a separate literal payload rather than a filtered toArray(), and its + // shape is unchanged: the QR content is not added to it. expect(array_key_exists('qr_code_content', (new TrackingNumberResource($trackingNumber))->toWebhookPayload()))->toBeFalse(); expect((new TrackingNumberResource($trackingNumber))->toWebhookPayload())->toMatchArray([ @@ -2790,14 +2790,15 @@ function fleetopsCompactResourceFixture(array $attributes = [], array $loaded = ]); }); -test('tracking number resource publishes the qr code content only in debug mode', function () { - // qr_code is a base64 PNG generated from owner_uuid, and the endpoints that consume a - // scanned code match on that uuid. Debug builds publish the value beside the image so - // an automated client can follow the flow without decoding a PNG; production must not. +test('tracking number resource publishes a scannable qr code content beside the image', function () { + // qr_code is a base64 PNG; qr_code_content is the text current labels encode in it, + // a tracking url naming the tracking number and the subject's public id. Both are in + // the resource already, so it is published in every environment. $trackingNumber = fleetopsCompactResourceFixture([ - 'tracking_number' => 'TN-DEBUG', - 'owner_uuid' => 'owner-uuid-under-the-qr', + 'tracking_number' => 'TN-SCAN', + 'owner_uuid' => 'owner-uuid-never-published', 'owner_type' => 'Fleetbase\\FleetOps\\Models\\Order', + 'owner' => (object) ['public_id' => 'order_public'], 'region' => 'sg', 'qr_code' => 'qr-data', 'barcode' => 'barcode-data', @@ -2811,70 +2812,31 @@ function fleetopsCompactResourceFixture(array $attributes = [], array $loaded = // cover the index resource and the webhook payload. $previousContainer = Illuminate\Container\Container::getInstance(); $container = new class extends Illuminate\Container\Container { - public bool $debugMode = false; - public function environment(...$environments) { return in_array('testing', $environments, true) || $environments === [] ? 'testing' : false; } - - // Stands in for the framework's Application::hasDebugModeEnabled(). - public function hasDebugModeEnabled() - { - return $this->debugMode; - } }; $container->instance('config', new Illuminate\Config\Repository([ 'fleetbase' => ['console' => ['host' => 'console.fleetbase.test', 'subdomain' => null, 'secure' => true]], ])); Illuminate\Container\Container::setInstance($container); - // Drive the REAL accessor through the container rather than overriding it, so the - // debug check itself is exercised and not just the resource's use of it. - $container->debugMode = true; - $withDebug = new TrackingNumberResource($trackingNumber); - try { - $debugPayload = $withDebug->resolve($request); - - $container->debugMode = false; - $productionPayload = (new TrackingNumberResource($trackingNumber))->resolve($request); + $payload = (new TrackingNumberResource($trackingNumber))->resolve($request); + $withoutNumber = (new TrackingNumberResource(fleetopsCompactResourceFixture([ + 'tracking_number' => null, + 'owner_type' => 'Fleetbase\\FleetOps\\Models\\Order', + 'owner' => null, + ])))->resolve($request); } finally { Illuminate\Container\Container::setInstance($previousContainer); } - // Present and exactly the value the QR was generated from โ€” not a public id. - expect($debugPayload['qr_code_content'])->toBe('owner-uuid-under-the-qr') - ->and($debugPayload['qr_code'])->toBe('qr-data') - // Absent, not null: `when()` drops the key entirely, so a production response is - // byte-identical to one from before the field existed. - ->and(array_key_exists('qr_code_content', $productionPayload))->toBeFalse() - ->and($productionPayload['qr_code'])->toBe('qr-data'); -}); - -test('tracking number resource fails closed when the debug state cannot be determined', function () { - // Any failure to resolve the debug state must answer false rather than defaulting to - // exposure. The real accessor is exercised here with no usable application bound. - $expose = new ReflectionMethod(TrackingNumberResource::class, 'exposesQrCodeContent'); - $expose->setAccessible(true); - - $previous = Illuminate\Container\Container::getInstance(); - - try { - // A container that is not an Application has no hasDebugModeEnabled(). - Illuminate\Container\Container::setInstance(new Illuminate\Container\Container()); - expect($expose->invoke(null))->toBeFalse(); - - // And an accessor that throws must also answer false rather than propagating โ€” - // a resource must not be able to fail serialization over a debug check. - Illuminate\Container\Container::setInstance(new class extends Illuminate\Container\Container { - public function hasDebugModeEnabled() - { - throw new RuntimeException('debug state unavailable'); - } - }); - expect($expose->invoke(null))->toBeFalse(); - } finally { - Illuminate\Container\Container::setInstance($previous); - } + expect($payload['qr_code_content'])->toBe('https://console.fleetbase.test/track-order?order=TN-SCAN&r=order_public&v=1') + ->and($payload['qr_code_content'])->not->toContain('owner-uuid') + ->and($payload['url'])->toBe('https://console.fleetbase.test/track-order?order=TN-SCAN') + ->and($payload['qr_code'])->toBe('qr-data') + // Nothing to scan without a tracking number: the key is absent, not null. + ->and(array_key_exists('qr_code_content', $withoutNumber))->toBeFalse(); }); diff --git a/server/tests/Feature/Http/Api/TrackingNumberControllerHelpersTest.php b/server/tests/Feature/Http/Api/TrackingNumberControllerHelpersTest.php index 80baaba8a..7b94621e9 100644 --- a/server/tests/Feature/Http/Api/TrackingNumberControllerHelpersTest.php +++ b/server/tests/Feature/Http/Api/TrackingNumberControllerHelpersTest.php @@ -133,28 +133,63 @@ public function __call($method, $arguments) ->and($helper('deletedTrackingNumberResource', $found))->toBeInstanceOf(Fleetbase\FleetOps\Http\Resources\v1\DeletedResource::class) ->and($helper('jsonResponse', ['ok' => true], 200))->toBeInstanceOf(Illuminate\Http\JsonResponse::class); - // Qr model resolution finds models and wraps them in fleet-ops v1 resources - $qrModel = $helper('findQrModel', ['entities'], ['public_id' => 'entity_tnhelper1']); - expect($qrModel)->not->toBeNull() - ->and($helper('qrModelResource', $qrModel))->toBeInstanceOf(Fleetbase\FleetOps\Http\Resources\v1\Entity::class); - - // A table with no eloquent mapping resolves to null rather than a raw row. - // - // The raw-row fallback was removed: it passed the ARRAY of table names to - // DB::table(), which stringified it to the literal table "Array" and threw - // SQLSTATE[42S02], so every unresolvable QR code answered 500 instead of the 400 the - // caller already handles. It could not have helped either way โ€” qrModelResource() - // serializes through a typed resource keyed on the model class, and a raw stdClass - // row has none. - $connection->table('tracking_statuses')->insert([ - 'uuid' => '44444444-4444-4444-8444-444444444403', - 'public_id' => 'tracking_status_tnhelper1', - 'code' => 'DISPATCHED', - 'status' => 'Dispatched', + // Qr model resolution goes through the shared resolver and wraps the owner in its + // fleet-ops v1 resource. + $connection->table('tracking_numbers')->insert([ + 'uuid' => '44444444-4444-4444-8444-444444444404', + 'public_id' => 'track_tnhelper2', + 'company_uuid' => 'company-1', + 'tracking_number' => 'FLB-HELPER-2', + 'owner_uuid' => '44444444-4444-4444-8444-444444444402', + 'owner_type' => Fleetbase\FleetOps\Models\Entity::class, + ]); + + $qrModel = $helper('findQrModel', 'https://console.fleetbase.test/track-order?order=FLB-HELPER-2&r=entity_tnhelper1&v=1'); + expect($qrModel)->toBeInstanceOf(Fleetbase\FleetOps\Models\Entity::class) + ->and($helper('qrModelResource', $qrModel))->toBeInstanceOf(Fleetbase\FleetOps\Http\Resources\v1\Entity::class) + ->and($helper('findQrModel', '{{qr_code}}'))->toBeNull(); +}); + +test('tracking number owners resolve from every code format within the company', function () { + $connection = fleetopsTrackingNumberHelpersBoot(); + $connection->table('orders')->insert(['uuid' => '55555555-5555-4555-8555-555555555501', 'public_id' => 'order_codes1', 'company_uuid' => 'company-1', 'status' => 'created']); + $connection->table('entities')->insert(['uuid' => '55555555-5555-4555-8555-555555555502', 'public_id' => 'entity_codes1', 'company_uuid' => 'company-1', 'name' => 'Parcel']); + $connection->table('orders')->insert(['uuid' => '55555555-5555-4555-8555-555555555503', 'public_id' => 'order_codes2', 'company_uuid' => 'company-2', 'status' => 'created']); + $connection->table('tracking_numbers')->insert([ + ['uuid' => 'tn-codes-1', 'public_id' => 'track_codes1', 'company_uuid' => 'company-1', 'tracking_number' => 'ACM1111111111SG', 'owner_uuid' => '55555555-5555-4555-8555-555555555501', 'owner_type' => Fleetbase\FleetOps\Models\Order::class], + // Entity::insertGetUuid() inserts raw, storing the owner class with a leading slash. + ['uuid' => 'tn-codes-2', 'public_id' => 'track_codes2', 'company_uuid' => 'company-1', 'tracking_number' => 'ACM2222222222SG', 'owner_uuid' => '55555555-5555-4555-8555-555555555502', 'owner_type' => '\\' . Fleetbase\FleetOps\Models\Entity::class], + ['uuid' => 'tn-codes-3', 'public_id' => 'track_codes3', 'company_uuid' => 'company-2', 'tracking_number' => 'OTH3333333333SG', 'owner_uuid' => '55555555-5555-4555-8555-555555555503', 'owner_type' => Fleetbase\FleetOps\Models\Order::class], ]); - expect($helper('findQrModel', ['tracking_statuses'], ['public_id' => 'tracking_status_tnhelper1']))->toBeNull() - // And a mapped table that simply matches nothing is also null, which is the path - // an unknown or unresolved code takes. - ->and($helper('findQrModel', ['entities', 'orders'], ['uuid' => '{{qr_code}}']))->toBeNull(); + $owner = fn (?string $code, ?string $company = 'company-1') => TrackingNumber::findOwnerByCode($code, $company)?->public_id; + + // Labels in circulation carry the owner's bare uuid. + expect($owner('55555555-5555-4555-8555-555555555501'))->toBe('order_codes1') + // Current labels carry a tracking url; the host is irrelevant. + ->and($owner('https://console.fleetbase.test/track-order?order=ACM1111111111SG&r=order_codes1&v=1'))->toBe('order_codes1') + ->and($owner('https://old-host.example/track-order?order=ACM2222222222SG&v=1'))->toBe('entity_codes1') + // The Code 128 barcode, or a value typed by hand: a tracking number or public id. + ->and($owner('ACM2222222222SG'))->toBe('entity_codes1') + ->and($owner('entity_codes1'))->toBe('entity_codes1') + ->and($owner('https://console.fleetbase.test/track-order?r=order_codes1'))->toBe('order_codes1'); + + // A url whose two identifiers disagree, or that names an unknown tracking number, is + // refused rather than resolved through the half that matches. + expect($owner('https://console.fleetbase.test/track-order?order=ACM1111111111SG&r=entity_codes1&v=1'))->toBeNull() + ->and($owner('https://console.fleetbase.test/track-order?order=ACM9999999999SG&r=order_codes1&v=1'))->toBeNull() + // Unknown values, unmapped prefixes, and values that are no public id at all. + ->and($owner('55555555-5555-4555-8555-555555555599'))->toBeNull() + ->and($owner('ACM9999999999SG'))->toBeNull() + ->and($owner('vehicle_codes1'))->toBeNull() + ->and($owner('order_missing'))->toBeNull() + ->and($owner(''))->toBeNull(); + + // Another company's parcel never resolves, in any format, and without a company + // nothing does. + expect($owner('55555555-5555-4555-8555-555555555503'))->toBeNull() + ->and($owner('OTH3333333333SG'))->toBeNull() + ->and($owner('order_codes2'))->toBeNull() + ->and($owner('order_codes2', 'company-2'))->toBe('order_codes2') + ->and($owner('55555555-5555-4555-8555-555555555501', null))->toBeNull(); }); diff --git a/server/tests/ObserverContractsTest.php b/server/tests/ObserverContractsTest.php index 0a15bf65c..b3f7e8f5b 100644 --- a/server/tests/ObserverContractsTest.php +++ b/server/tests/ObserverContractsTest.php @@ -397,11 +397,16 @@ protected function generateTrackingNumber(TrackingNumber $trackingNumber): strin return 'TN-' . $trackingNumber->region; } - protected function generateBarcode(string $ownerUuid, string $type): string + protected function generateCodeImages(string $trackingNumber, ?string $ownerPublicId): array { - $this->barcodes[] = [$ownerUuid, $type]; + $this->barcodes[] = [$trackingNumber, $ownerPublicId]; - return $type . '-png'; + return ['qr_code' => 'qr-png', 'barcode' => 'barcode-png']; + } + + protected function ownerPublicId(TrackingNumber $trackingNumber): ?string + { + return $trackingNumber->owner_uuid ? 'order_public' : null; } protected function createTrackingStatus(array $attributes): TrackingStatus @@ -1292,11 +1297,12 @@ protected function resolveOrder(PurchaseRate $purchaseRate): ?Order $observer->created($trackingNumber); expect($trackingNumber->tracking_number)->toBe('TN-sg') - ->and($trackingNumber->qr_code)->toBe('QRCODE-png') - ->and($trackingNumber->barcode)->toBe('PDF417-png') + ->and($trackingNumber->qr_code)->toBe('qr-png') + ->and($trackingNumber->barcode)->toBe('barcode-png') + // Codes are generated from the new tracking number and the owner's public id, + // never from the owner's uuid. ->and($observer->barcodes)->toBe([ - ['owner-uuid', 'QRCODE'], - ['owner-uuid', 'PDF417'], + ['TN-sg', 'order_public'], ]) ->and($observer->statuses[0])->toMatchArray([ 'company_uuid' => 'company-uuid', @@ -1309,6 +1315,58 @@ protected function resolveOrder(PurchaseRate $purchaseRate): ?Order ->and($trackingNumber->ownerStatuses)->toHaveCount(1); }); +test('tracking number observer reads the owner public id and renders codes through the model', function () { + $observer = new TrackingNumberObserver(); + $owner = new ReflectionMethod($observer, 'ownerPublicId'); + $images = new ReflectionMethod($observer, 'generateCodeImages'); + $owner->setAccessible(true); + $images->setAccessible(true); + + // No owner: nothing to look up. + expect($owner->invoke($observer, new TrackingNumber()))->toBeNull(); + + $order = new Order(); + $order->setRawAttributes(['uuid' => 'owner-uuid', 'public_id' => 'order_public'], true); + $trackingNumber = new TrackingNumber(); + $trackingNumber->setRawAttributes(['owner_uuid' => 'owner-uuid', 'owner_type' => Order::class], true); + $trackingNumber->setRelation('owner', $order); + + expect($owner->invoke($observer, $trackingNumber))->toBe('order_public'); + + // An owner without a public id yields none rather than an empty `r` parameter. + $order->setRawAttributes(['uuid' => 'owner-uuid', 'public_id' => ''], true); + expect($owner->invoke($observer, $trackingNumber))->toBeNull(); + + $previousConsole = config('fleetbase.console'); + config(['fleetbase.console' => ['host' => 'console.fleetbase.test', 'secure' => true]]); + foreach (['DNS2D', 'DNS1D'] as $facade) { + app()->instance($facade, new class($facade) { + public function __construct(private string $facade) + { + } + + public function getBarcodePNG($code, $type, ...$size) + { + return $this->facade . ':' . $type . ':' . $code; + } + }); + Illuminate\Support\Facades\Facade::clearResolvedInstance($facade); + } + + try { + expect($images->invoke($observer, 'TN-sg', 'order_public'))->toBe([ + 'qr_code' => 'DNS2D:QRCODE,M:https://console.fleetbase.test/track-order?order=TN-sg&r=order_public&v=1', + 'barcode' => 'DNS1D:C128:TN-sg', + ]); + } finally { + config(['fleetbase.console' => $previousConsole]); + foreach (['DNS2D', 'DNS1D'] as $facade) { + app()->forgetInstance($facade); + Illuminate\Support\Facades\Facade::clearResolvedInstance($facade); + } + } +}); + test('zone observer invalidates service area cache for lifecycle events and original service area', function () { $zone = new Zone(); $zone->setRawAttributes([ diff --git a/server/tests/Unit/Models/TrackingNumberTest.php b/server/tests/Unit/Models/TrackingNumberTest.php index 6bef10f36..3587e69ef 100644 --- a/server/tests/Unit/Models/TrackingNumberTest.php +++ b/server/tests/Unit/Models/TrackingNumberTest.php @@ -84,13 +84,21 @@ protected static function ownerHasStatusColumn(Fleetbase\Models\Model $owner): b beforeEach(function () { FleetOpsTrackingNumberInsertFake::resetInsertFake(); + + $this->previousConsoleConfig = config('fleetbase.console'); + config(['fleetbase.console' => ['host' => 'console.fleetbase.test', 'subdomain' => null, 'secure' => true]]); +}); + +afterEach(function () { + config(['fleetbase.console' => $this->previousConsoleConfig]); }); test('tracking number insert filters values and creates initial owner status', function () { $owner = new Order(); $owner->setRawAttributes([ - 'uuid' => 'order-uuid', - 'status' => 'pending', + 'uuid' => 'order-uuid', + 'public_id' => 'order_public', + 'status' => 'pending', ], true); $uuid = FleetOpsTrackingNumberInsertFake::insertGetUuid([ @@ -114,8 +122,10 @@ protected static function ownerHasStatusColumn(Fleetbase\Models\Model $owner): b 'owner_uuid' => 'order-uuid', 'owner_type' => Utils::getMutationType($owner), 'tracking_number' => 'TRACK-AE', - 'qr_code' => 'QRCODE:order-uuid', - 'barcode' => 'PDF417:order-uuid', + // The QR carries the tracking url naming the owner by public id, never its uuid; + // the barcode carries the bare tracking number printed beneath it. + 'qr_code' => 'QRCODE,M:https://console.fleetbase.test/track-order?order=TRACK-AE&r=order_public&v=1', + 'barcode' => 'C128:TRACK-AE', 'meta' => '{"source":"api"}', ])->and($inserted)->not->toHaveKey('not_allowed'); @@ -147,8 +157,8 @@ protected static function ownerHasStatusColumn(Fleetbase\Models\Model $owner): b ->and(FleetOpsTrackingNumberInsertFake::$insertedValues[0])->toMatchArray([ 'uuid' => 'tracking-uuid', 'tracking_number' => 'TRACK-SG', - 'qr_code' => 'QRCODE:', - 'barcode' => 'PDF417:', + 'qr_code' => 'QRCODE,M:https://console.fleetbase.test/track-order?order=TRACK-SG&v=1', + 'barcode' => 'C128:TRACK-SG', ]) ->and(FleetOpsTrackingNumberInsertFake::$createdStatuses)->toBe([]) ->and(FleetOpsTrackingNumberInsertFake::$statusUpdates)->toBe([]) @@ -197,3 +207,54 @@ public function __call($method, $arguments) ->and(TrackingNumber::findTrackingOrFail('track_findone1')->uuid)->toBe('tn-find-1') ->and(fn () => TrackingNumber::findTrackingOrFail('missing-number'))->toThrow(Illuminate\Database\Eloquent\ModelNotFoundException::class); }); + +test('tracking number insert takes the owner public id from callers without an owner model', function () { + // Waypoints and entities insert their owner with a raw query and pass only ids. + FleetOpsTrackingNumberInsertFake::insertGetUuid([ + 'owner_uuid' => 'waypoint-uuid', + 'owner_type' => 'Fleetbase\\FleetOps\\Models\\Waypoint', + 'owner_public_id' => 'waypoint_public', + 'region' => 'SG', + ]); + + $inserted = FleetOpsTrackingNumberInsertFake::$insertedValues[0]; + + expect($inserted['qr_code'])->toBe('QRCODE,M:https://console.fleetbase.test/track-order?order=TRACK-SG&r=waypoint_public&v=1') + // A helper key, not a column: the fillable filter keeps it out of the insert. + ->and($inserted)->not->toHaveKey('owner_public_id') + ->and($inserted['owner_uuid'])->toBe('waypoint-uuid'); +}); + +test('tracking number code images render a medium-correction QR and a Code 128 barcode', function () { + $calls = new ArrayObject(); + foreach (['DNS2D', 'DNS1D'] as $facade) { + app()->instance($facade, new class($facade, $calls) { + public function __construct(private string $facade, private ArrayObject $calls) + { + } + + public function getBarcodePNG(...$arguments) + { + $this->calls[] = array_merge([$this->facade], $arguments); + + return $this->facade . '-png'; + } + }); + Illuminate\Support\Facades\Facade::clearResolvedInstance($facade); + } + + try { + expect(TrackingNumber::codeImages('ACM1234567890SG', 'entity_public'))->toBe([ + 'qr_code' => 'DNS2D-png', + 'barcode' => 'DNS1D-png', + ])->and($calls->getArrayCopy())->toBe([ + ['DNS2D', 'https://console.fleetbase.test/track-order?order=ACM1234567890SG&r=entity_public&v=1', 'QRCODE,M'], + ['DNS1D', 'ACM1234567890SG', 'C128', 2, 60], + ]); + } finally { + foreach (['DNS2D', 'DNS1D'] as $facade) { + app()->forgetInstance($facade); + Illuminate\Support\Facades\Facade::clearResolvedInstance($facade); + } + } +}); diff --git a/server/tests/Unit/Models/WaypointTest.php b/server/tests/Unit/Models/WaypointTest.php index 0ee6722db..ec97ac515 100644 --- a/server/tests/Unit/Models/WaypointTest.php +++ b/server/tests/Unit/Models/WaypointTest.php @@ -218,9 +218,10 @@ public static function with($relations) ])->and($inserted)->not->toHaveKey('not_allowed') ->and(FleetOpsWaypointInsertFake::$createdTracking)->toHaveCount(1) ->and(FleetOpsWaypointInsertFake::$createdTracking[0])->toMatchArray([ - 'owner_uuid' => 'waypoint-uuid', - 'owner_type' => Utils::getModelClassName('waypoint'), - 'region' => 'AE', + 'owner_uuid' => 'waypoint-uuid', + 'owner_type' => Utils::getModelClassName('waypoint'), + 'owner_public_id' => FleetOpsWaypointInsertFake::$insertedValues[0]['public_id'], + 'region' => 'AE', ]) ->and(FleetOpsWaypointInsertFake::$createdTracking[0]['location'])->toBe('POINT(55.2708 25.2048)') ->and(FleetOpsWaypointInsertFake::$trackingNumberWrites)->toBe([ diff --git a/server/tests/Unit/Support/TrackingCodeTest.php b/server/tests/Unit/Support/TrackingCodeTest.php new file mode 100644 index 000000000..a62f65586 --- /dev/null +++ b/server/tests/Unit/Support/TrackingCodeTest.php @@ -0,0 +1,132 @@ +previousConsoleConfig = config('fleetbase.console'); + config(['fleetbase.console' => ['host' => 'console.fleetbase.test', 'subdomain' => null, 'secure' => true]]); +}); + +afterEach(function () { + config(['fleetbase.console' => $this->previousConsoleConfig]); +}); + +test('qr content is a versioned tracking url naming the owner by public id', function () { + expect(TrackingCode::qrContent('ACM1234567890SG', 'order_a1b2c3d')) + ->toBe('https://console.fleetbase.test/track-order?order=ACM1234567890SG&r=order_a1b2c3d&v=1') + // No owner: the tracking number alone still resolves. + ->and(TrackingCode::qrContent('ACM1234567890SG')) + ->toBe('https://console.fleetbase.test/track-order?order=ACM1234567890SG&v=1') + // Tracking numbers start with the company name, which may hold spaces. + ->and(TrackingCode::qrContent('A B1234567890SG', '')) + ->toBe('https://console.fleetbase.test/track-order?order=A+B1234567890SG&v=1'); +}); + +test('qr content follows the configured console origin but never the app environment', function () { + config(['fleetbase.console' => ['host' => 'fleetbase.test', 'subdomain' => 'console', 'secure' => false]]); + expect(TrackingCode::qrContent('TN1', 'order_x'))->toBe('http://console.fleetbase.test/track-order?order=TN1&r=order_x&v=1'); + + config(['fleetbase.console' => ['host' => 'https://tracking.example.test/', 'subdomain' => 'ignored']]); + expect(TrackingCode::qrContent('TN1'))->toBe('https://tracking.example.test/track-order?order=TN1&v=1'); + + // Unset `secure` defaults to https, whatever environment generated the label. + config(['fleetbase.console' => ['host' => 'console.fleetbase.test']]); + expect(TrackingCode::qrContent('TN1'))->toStartWith('https://console.fleetbase.test/'); +}); + +test('barcode content is the bare tracking number printed beneath it', function () { + expect(TrackingCode::barcodeContent('ACM1234567890SG'))->toBe('ACM1234567890SG'); +}); + +test('parse reads every code format in circulation', function () { + $empty = ['uuid' => null, 'tracking_number' => null, 'public_id' => null, 'reference' => null, 'version' => null]; + + // Generated codes round-trip. + expect(TrackingCode::parse(TrackingCode::qrContent('ACM1234567890SG', 'waypoint_a1b2c3d')))->toBe(array_merge($empty, [ + 'tracking_number' => 'ACM1234567890SG', + 'public_id' => 'waypoint_a1b2c3d', + 'version' => 1, + ])) + // Legacy labels encode the owner's bare uuid. + ->and(TrackingCode::parse(' 6f1c2a4e-8b3d-4c5f-9a7e-1d2c3b4a5f60 '))->toBe(array_merge($empty, [ + 'uuid' => '6f1c2a4e-8b3d-4c5f-9a7e-1d2c3b4a5f60', + ])) + // A barcode, or a value typed by hand, may be a tracking number or a public id. + ->and(TrackingCode::parse('ACM1234567890SG'))->toBe(array_merge($empty, ['reference' => 'ACM1234567890SG'])) + ->and(TrackingCode::parse(''))->toBe($empty) + ->and(TrackingCode::parse(null))->toBe($empty); +}); + +test('parse ignores host and path and tolerates hostile or partial urls', function () { + // Any host, a `tn` alias, and a tracking number carried in a deeper path. + expect(TrackingCode::parse('HTTPS://old-domain.example/track-order?tn=TN9&r=entity_e1&v=2'))->toMatchArray([ + 'tracking_number' => 'TN9', + 'public_id' => 'entity_e1', + 'version' => 2, + ]) + ->and(TrackingCode::parse('https://console.example/~/track/TN%2042?r=order_o1'))->toMatchArray([ + 'tracking_number' => 'TN 42', + 'public_id' => 'order_o1', + 'version' => null, + ]) + // The tracking page's own path is not mistaken for a tracking number. + ->and(TrackingCode::parse('https://console.example/track-order?r=order_o1'))->toMatchArray([ + 'tracking_number' => null, + 'public_id' => 'order_o1', + ]) + // Array or blank parameters and a non-numeric version are dropped, not trusted. + ->and(TrackingCode::parse('https://console.example/track-order?order[]=a&r=%20&v=1x'))->toMatchArray([ + 'tracking_number' => null, + 'public_id' => null, + 'version' => null, + ]) + // parse_url() rejects this outright. + ->and(TrackingCode::parse('http:///track-order'))->toMatchArray([ + 'tracking_number' => null, + 'public_id' => null, + 'version' => null, + ]); +}); + +test('matches accepts legacy and new codes for the scanned subject only', function () { + $order = (object) [ + 'uuid' => '6f1c2a4e-8b3d-4c5f-9a7e-1d2c3b4a5f60', + 'public_id' => 'order_a1b2c3d', + 'trackingNumber' => (object) ['tracking_number' => 'ACM1234567890SG'], + ]; + + expect(TrackingCode::matches('6f1c2a4e-8b3d-4c5f-9a7e-1d2c3b4a5f60', $order))->toBeTrue() + ->and(TrackingCode::matches('00000000-0000-4000-8000-000000000000', $order))->toBeFalse() + ->and(TrackingCode::matches(TrackingCode::qrContent('ACM1234567890SG', 'order_a1b2c3d'), $order))->toBeTrue() + // Handhelds may not preserve case. + ->and(TrackingCode::matches('acm1234567890sg', $order))->toBeTrue() + ->and(TrackingCode::matches('order_a1b2c3d', $order))->toBeTrue() + ->and(TrackingCode::matches('order_other', $order))->toBeFalse() + ->and(TrackingCode::matches(TrackingCode::qrContent('ACM1234567890SG'), $order))->toBeTrue() + ->and(TrackingCode::matches('https://console.example/track-order?r=order_a1b2c3d', $order))->toBeTrue() + // The owner named in the code decides: another owner is refused even with this + // subject's tracking number, while a second tracking number issued for this + // subject still names it and matches. + ->and(TrackingCode::matches(TrackingCode::qrContent('ACM1234567890SG', 'order_other'), $order))->toBeFalse() + ->and(TrackingCode::matches(TrackingCode::qrContent('ACM0000000000SG', 'order_a1b2c3d'), $order))->toBeTrue() + // Without an owner, the number must be this subject's. + ->and(TrackingCode::matches(TrackingCode::qrContent('ACM0000000000SG'), $order))->toBeFalse() + // A URL that names nothing matches nothing. + ->and(TrackingCode::matches('https://console.example/track-order', $order))->toBeFalse() + ->and(TrackingCode::matches('', $order))->toBeFalse() + ->and(TrackingCode::matches('order_a1b2c3d', null))->toBeFalse() + ->and(TrackingCode::matches('order_a1b2c3d', (object) ['public_id' => 'order_a1b2c3d']))->toBeFalse(); +}); + +test('matches decides a subject without its own tracking number by public id', function () { + // A service stop resolves to a place, which has no tracking number of its own. + $place = (object) ['uuid' => 'place-uuid', 'public_id' => 'place_p1']; + + expect(TrackingCode::matches(TrackingCode::qrContent('ACM1234567890SG', 'place_p1'), $place))->toBeTrue() + ->and(TrackingCode::matches(TrackingCode::qrContent('ACM1234567890SG'), $place))->toBeFalse() + ->and(TrackingCode::matches('ACM1234567890SG', $place))->toBeFalse() + ->and(TrackingCode::matches('place_p1', $place))->toBeTrue() + // A tracking number may also sit directly on the subject. + ->and(TrackingCode::matches('TN-FLAT', (object) ['uuid' => 'u', 'public_id' => '', 'tracking_number' => 'TN-FLAT']))->toBeTrue() + ->and(TrackingCode::matches('TN-FLAT', (object) ['uuid' => 'u', 'public_id' => null]))->toBeFalse(); +}); From 21924b0e90842d2e460302413420a840c6ebbc4d Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 6 Oct 2026 14:42:01 +0800 Subject: [PATCH 07/87] fix(tracking): refuse non-string scan codes with the existing 400 capture-qr reads `code` straight from the request. The navigator v3 branch sends the scanner's whole result object; the old strict comparison answered 400, and the typed matcher would have thrown. Non-strings now match nothing. --- server/src/Support/TrackingCode.php | 9 +++++---- server/tests/ApiOrderControllerContractsTest.php | 11 +++++++++++ server/tests/Unit/Support/TrackingCodeTest.php | 3 +++ 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/server/src/Support/TrackingCode.php b/server/src/Support/TrackingCode.php index 907a79c9c..f043a8a53 100644 --- a/server/src/Support/TrackingCode.php +++ b/server/src/Support/TrackingCode.php @@ -123,18 +123,19 @@ public static function parse(?string $code): array /** * Whether a scanned value identifies the given subject (an order, waypoint, entity or - * place). + * place). The value comes straight from a request, so anything but a string, such as + * a scanner library's whole result object, matches nothing. */ - public static function matches(?string $code, $subject): bool + public static function matches($code, $subject): bool { $subjectUuid = data_get($subject, 'uuid'); - if (!$subject || empty($subjectUuid)) { + if (!is_string($code) || !$subject || empty($subjectUuid)) { return false; } // Labels printed before v1 encode the subject's bare uuid; compare it exactly, as // scans always have been. - if (trim((string) $code) === $subjectUuid) { + if (trim($code) === $subjectUuid) { return true; } diff --git a/server/tests/ApiOrderControllerContractsTest.php b/server/tests/ApiOrderControllerContractsTest.php index f25dd6d80..d22c0394d 100644 --- a/server/tests/ApiOrderControllerContractsTest.php +++ b/server/tests/ApiOrderControllerContractsTest.php @@ -1052,6 +1052,17 @@ public function isString(string $key): bool ->and($controller->createdProofs[0]['subject_uuid'])->toBe('subject-uuid'); } + // A scanner's whole result object, rather than its value, is refused as before + // instead of failing the request. + $controller = new FleetOpsApiOrderCrudControllerProbe(); + $controller->order = new FleetOpsApiOrderCrudFake(); + $controller->resolvedSubject = $subject; + + expect($controller->captureQrScan(new Request(['code' => ['type' => 'qr', 'value' => 'ACM1234567890SG']]), 'order-public', 'waypoint_subject'))->toBe([ + 'apiError' => 'Unable to validate QR code data.', + 'status' => 400, + ]); + // Another parcel's label is refused, whether it names another owner or, naming none, // another tracking number. foreach ([ diff --git a/server/tests/Unit/Support/TrackingCodeTest.php b/server/tests/Unit/Support/TrackingCodeTest.php index a62f65586..deef45224 100644 --- a/server/tests/Unit/Support/TrackingCodeTest.php +++ b/server/tests/Unit/Support/TrackingCodeTest.php @@ -114,6 +114,9 @@ // A URL that names nothing matches nothing. ->and(TrackingCode::matches('https://console.example/track-order', $order))->toBeFalse() ->and(TrackingCode::matches('', $order))->toBeFalse() + // A request may carry a scanner's whole result object instead of its value. + ->and(TrackingCode::matches(['type' => 'qr', 'value' => 'order_a1b2c3d'], $order))->toBeFalse() + ->and(TrackingCode::matches(null, $order))->toBeFalse() ->and(TrackingCode::matches('order_a1b2c3d', null))->toBeFalse() ->and(TrackingCode::matches('order_a1b2c3d', (object) ['public_id' => 'order_a1b2c3d']))->toBeFalse(); }); From 16bacb35611ac78e1370093ecc2a22ac5ab425ba Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 6 Oct 2026 14:47:06 +0800 Subject: [PATCH 08/87] feat(tracking-page): settings screen for the customer tracking page PR 4 of the tracking pages refactor: FleetOps -> Settings -> Tracking Page. - Pages: own page on/off, its address under /t/ with a live availability check, copy and open links, shared-page opt-out, and which page emails and labels link to. - Branding: display name, accent with a live contrast check (the text colour on it is derived), an optional dark-mode accent, support contacts, Powered by Fleetbase, light/dark behaviour, default and enabled languages. - Access: status without verification, SMS and email codes (SMS disabled with a reason when the instance has no SMS provider), session length, and customer sign-in and account upsell when the customer portal is installed. - What verified customers see, with the privacy rules that always apply. - A live light and dark preview themed from the same tokens the page will use (utils/tracking-page-theme). - Admin panel 'Tracking Page' under Fleet-Ops Config for the generic page's instance defaults; sidebar item, settings hub card, and translation keys in all 10 locales (English text until translated). Settings load in the route's model hook, not a controller constructor. --- .../admin/tracking-page-settings.hbs | 56 ++++ .../admin/tracking-page-settings.js | 67 ++++ addon/components/layout/fleet-ops-sidebar.js | 8 +- .../tracking-page/settings-preview.hbs | 21 ++ .../tracking-page/settings-preview.js | 23 ++ addon/controllers/settings/index.js | 6 + addon/controllers/settings/tracking-page.js | 212 ++++++++++++ addon/extension.js | 5 + addon/routes.js | 1 + addon/routes/settings/tracking-page.js | 31 ++ addon/styles/fleetops-engine.css | 97 ++++++ addon/templates/settings/tracking-page.hbs | 311 ++++++++++++++++++ addon/utils/tracking-page-theme.js | 123 +++++++ .../admin/tracking-page-settings.js | 1 + .../tracking-page/settings-preview.js | 1 + app/controllers/settings/tracking-page.js | 1 + app/routes/settings/tracking-page.js | 1 + app/templates/settings/tracking-page.js | 1 + app/utils/tracking-page-theme.js | 1 + translations/ar-ae.yml | 88 +++++ translations/bg-bg.yaml | 88 +++++ translations/en-us.yaml | 88 +++++ translations/es-pa.yaml | 88 +++++ translations/fr-fr.yaml | 88 +++++ translations/mn-mn.yaml | 88 +++++ translations/pt-br.yaml | 88 +++++ translations/ru-ru.yaml | 88 +++++ translations/uk-ua.yaml | 90 ++++- translations/vi-vn.yaml | 88 +++++ 29 files changed, 1845 insertions(+), 4 deletions(-) create mode 100644 addon/components/admin/tracking-page-settings.hbs create mode 100644 addon/components/admin/tracking-page-settings.js create mode 100644 addon/components/tracking-page/settings-preview.hbs create mode 100644 addon/components/tracking-page/settings-preview.js create mode 100644 addon/controllers/settings/tracking-page.js create mode 100644 addon/routes/settings/tracking-page.js create mode 100644 addon/templates/settings/tracking-page.hbs create mode 100644 addon/utils/tracking-page-theme.js create mode 100644 app/components/admin/tracking-page-settings.js create mode 100644 app/components/tracking-page/settings-preview.js create mode 100644 app/controllers/settings/tracking-page.js create mode 100644 app/routes/settings/tracking-page.js create mode 100644 app/templates/settings/tracking-page.js create mode 100644 app/utils/tracking-page-theme.js diff --git a/addon/components/admin/tracking-page-settings.hbs b/addon/components/admin/tracking-page-settings.hbs new file mode 100644 index 000000000..452035bdd --- /dev/null +++ b/addon/components/admin/tracking-page-settings.hbs @@ -0,0 +1,56 @@ + +
+
+
+
Enable the generic tracking page
+
Serves /track for every organization that allows it. Organizations can also run their own page from their Fleet-Ops settings.
+
+ +
+
+ + +

Shown before a lookup. The logo comes from the instance branding.

+
+
+ +
+ + {{this.accent}} +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
Show "Powered by Fleetbase"
+
The default for organizations that haven't chosen.
+
+ +
+
+
+ + + + + {{#if this.page.sign_in}} +
{{t "tracking-page.page.or"}}
+ {{#if this.signedIn}} + + {{else}} + {{t "tracking-page.page.sign-in"}} + {{/if}} + {{/if}} + + {{else if (eq this.screen "public")}} + + {{else if (eq this.screen "verify")}} +
+ + +
+ {{else if (eq this.screen "list")}} + + {{else if (eq this.screen "track")}} + + {{/if}} + + +
+ {{#if (or this.company.support_phone this.company.support_email)}} +
+ {{t "tracking-page.page.help"}} + {{#if this.company.support_phone}}{{this.company.support_phone}}{{/if}} + {{#if (and this.company.support_phone this.company.support_email)}} ยท {{/if}} + {{#if this.company.support_email}}{{this.company.support_email}}{{/if}} +
+ {{/if}} + + {{#if this.company.powered_by}} +
{{t "tracking-page.page.powered-by"}}
+ {{/if}} +
+ +
+ {{#if this.toast}} +
+ +
+
{{this.toast.title}}
+ {{#if this.toast.text}}
{{this.toast.text}}
{{/if}} +
+ +
+ {{/if}} +
+
diff --git a/addon/components/tracking-page.js b/addon/components/tracking-page.js new file mode 100644 index 000000000..a17635f9d --- /dev/null +++ b/addon/components/tracking-page.js @@ -0,0 +1,539 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; +import { htmlSafe } from '@ember/template'; +import { task, timeout } from 'ember-concurrency'; +import trackingPageTheme from '../utils/tracking-page-theme'; +import TrackingLiveSource from '../utils/tracking-live-source'; + +/** + * The FleetOps API is mounted at the application root, so its public routes live under `public`. + * `rawError` makes a failed request reject with its JSON body, whose `code` or `error` drives the page. + */ +const REQUEST_OPTIONS = { namespace: 'public', rawError: true }; + +/** How many neutral misses in a row before the page suggests a short break. */ +const GENTLE_LIMIT_AFTER = 4; + +/** The code length the server issues. */ +export const CODE_LENGTH = 6; + +/** Each locale, labelled in its own language. */ +const LOCALE_LABELS = { + 'en-us': 'English', + 'ar-ae': 'ุงู„ุนุฑุจูŠุฉ', + 'bg-bg': 'ะ‘ัŠะปะณะฐั€ัะบะธ', + 'es-pa': 'Espaรฑol', + 'fr-fr': 'Franรงais', + 'mn-mn': 'ะœะพะฝะณะพะป', + 'pt-br': 'Portuguรชs', + 'ru-ru': 'ะ ัƒััะบะธะน', + 'uk-ua': 'ะฃะบั€ะฐั—ะฝััŒะบะฐ', + 'vi-vn': 'Tiแบฟng Viแป‡t', +}; + +/** + * The customer tracking page. + * + * Registered in the `auth:login` registry as `track` and `track-order`, so the console's + * public `virtual` route serves it at `/~/track` and `/~/track-order`: outside the console, + * signed in or not. `?order=` (or `?number=`) opens a tracking number and `?org=` a + * company's own page. + * + * A tracking number alone shows a coarse status. A one-time code to the customer's own + * contact details unlocks their stops on this device; a signed-in customer, or staff of + * the owning company, sees them straight away. Live updates poll the same endpoint. + */ +export default class TrackingPageComponent extends Component { + @service fetch; + @service intl; + @service urlSearchParams; + @service session; + + @tracked screen = 'loading'; + @tracked page = null; + @tracked data = null; + @tracked trackingInput = ''; + @tracked lookupError = null; + @tracked misses = 0; + @tracked liveState = 'live'; + @tracked toast = null; + @tracked orders = null; + @tracked prefersDark = false; + + /* verification */ + @tracked verifyStep = 'choose'; + @tracked channel = null; + @tracked masked = null; + @tracked code = ''; + @tracked attemptsLeft = 3; + @tracked cooldown = 0; + @tracked lockLeft = 0; + @tracked verifyMessage = null; + + live = null; + ticker = null; + + constructor() { + super(...arguments); + this.org = this.urlSearchParams.get('org') || null; + this.trackingInput = this.urlSearchParams.get('order') || this.urlSearchParams.get('number') || ''; + this.watchColorScheme(); + this.ticker = setInterval(() => this.tick(), 1000); + this.boot.perform(); + } + + willDestroy() { + super.willDestroy(...arguments); + clearInterval(this.ticker); + this.live?.stop(); + this.colorScheme?.removeEventListener?.('change', this.onColorScheme); + } + + /* โ”€โ”€ Derived state โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + + get company() { + return this.data?.company ?? this.page?.company ?? {}; + } + + get monogram() { + let monogram = ''; + for (const word of String(this.company.name ?? '').split(/\s+/)) { + if (word && monogram.length < 2) { + monogram += word[0].toUpperCase(); + } + } + + return monogram; + } + + get dark() { + return this.company.theme !== 'light' && this.prefersDark; + } + + get themeStyle() { + return htmlSafe(trackingPageTheme(this.company, this.dark)); + } + + get locale() { + return this.intl.primaryLocale ?? 'en-us'; + } + + get dir() { + return this.locale.startsWith('ar') ? 'rtl' : 'ltr'; + } + + get localeOptions() { + const enabled = this.company.locales?.length ? this.company.locales : Object.keys(LOCALE_LABELS); + + return enabled.map((code) => ({ code, label: LOCALE_LABELS[code] ?? code, selected: code === this.locale })); + } + + get number() { + return (this.data?.tracking_number ?? this.trackingInput ?? '').trim(); + } + + get isFull() { + return this.data?.level >= 1; + } + + get rateLimited() { + return this.misses >= GENTLE_LIMIT_AFTER; + } + + get channels() { + return this.data?.verify?.channels ?? []; + } + + get hasChannels() { + return this.channels.length > 0; + } + + get signInUrl() { + return `/customer-portal/auth/login?redirect=${encodeURIComponent(window.location.pathname + window.location.search)}`; + } + + get signedIn() { + return this.session.isAuthenticated === true; + } + + get cooldownText() { + return formatCountdown(this.cooldown); + } + + get lockText() { + return formatCountdown(this.lockLeft); + } + + get codeBoxes() { + const digits = this.code.split(''); + return Array.from({ length: CODE_LENGTH }, (_, index) => ({ + digit: digits[index] ?? '', + active: (this.verifyStep === 'sent' || this.verifyStep === 'wrong') && index === Math.min(digits.length, CODE_LENGTH - 1), + })); + } + + get selectedChannel() { + return this.channel ?? this.channels[0]?.type ?? null; + } + + get otherChannel() { + return this.channels.find((channel) => channel.type !== this.selectedChannel) ?? null; + } + + /* โ”€โ”€ Loading โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + + @task *boot() { + try { + this.page = yield this.fetch.get('track/config', this.query(), REQUEST_OPTIONS); + this.applyLocale(this.page?.company); + } catch { + this.screen = 'unavailable'; + return; + } + + if (this.trackingInput) { + yield this.lookup.perform(this.trackingInput, { quiet: true }); + } else { + this.screen = 'lookup'; + } + + if (this.signedIn && !this.data) { + this.loadOrders.perform(); + } + } + + @task({ restartable: true }) *lookup(number, { quiet = false } = {}) { + this.lookupError = null; + const value = String(number ?? '').trim(); + if (!value) { + this.lookupError = 'neutral'; + return; + } + + try { + const data = yield this.fetch.get(`track/${encodeURIComponent(value)}`, this.query(), REQUEST_OPTIONS); + this.misses = 0; + this.show(data); + this.urlSearchParams.addParamToCurrentUrl('order', data.tracking_number); + } catch (error) { + this.misses += 1; + this.lookupError = this.rateLimited ? 'rate' : 'neutral'; + this.screen = 'lookup'; + if (quiet) { + this.misses = Math.max(0, this.misses - 1); + } + return error; + } + } + + @task *refresh() { + return yield this.fetch.get(`track/${encodeURIComponent(this.number)}`, this.query(), REQUEST_OPTIONS); + } + + @task *loadOrders() { + try { + const response = yield this.fetch.get('track/account/orders', {}, REQUEST_OPTIONS); + this.orders = response?.orders ?? []; + if (!this.data) { + this.screen = 'list'; + } + } catch { + this.orders = null; + } + } + + show(data) { + const before = this.data; + this.data = data; + this.applyLocale(data?.company); + this.screen = data?.level >= 1 ? 'track' : 'public'; + + if (before && before.stage !== data.stage && data.level >= 1) { + this.notify(this.intl.t(`tracking-page.page.stage-toast-${data.stage}`), ''); + } + + if (!this.isFull) { + this.live?.stop(); + } else if (!this.live?.running) { + this.startLive(); + } + } + + startLive() { + this.live?.stop(); + + this.live = new TrackingLiveSource(() => this.refresh.perform(), { isActive: () => this.data?.stage === 'in_transit' }); + this.live.start(this.onLiveUpdate, this.onLiveState); + } + + onLiveUpdate = (data) => { + this.show(data); + }; + + onLiveState = (state) => { + this.liveState = state; + }; + + /* โ”€โ”€ Lookup โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + + @action updateTracking(event) { + this.trackingInput = event.target.value; + } + + @action submitLookup(event) { + event?.preventDefault?.(); + if (this.rateLimited) { + return; + } + + this.lookup.perform(this.trackingInput); + } + + @action lookupAnother() { + this.live?.stop(); + this.data = null; + this.trackingInput = ''; + this.lookupError = null; + this.urlSearchParams.removeParamFromCurrentUrl('order'); + this.screen = 'lookup'; + } + + @action openOrder(order) { + this.trackingInput = order.tracking_number; + this.lookup.perform(order.tracking_number); + } + + @action backToOrders() { + this.live?.stop(); + this.data = null; + this.urlSearchParams.removeParamFromCurrentUrl('order'); + this.screen = 'list'; + } + + /* โ”€โ”€ Verification โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + + @action startVerify() { + this.verifyStep = this.hasChannels ? 'choose' : 'none'; + this.channel = this.channels[0]?.type ?? null; + this.code = ''; + this.verifyMessage = null; + this.screen = 'verify'; + } + + @action cancelVerify() { + this.screen = 'public'; + } + + @action pickChannel(type) { + this.channel = type; + } + + @action switchChannel() { + if (this.otherChannel) { + this.channel = this.otherChannel.type; + this.sendCode.perform(); + } + } + + @task({ drop: true }) *sendCode() { + this.verifyStep = 'sending'; + this.code = ''; + this.verifyMessage = null; + + try { + const response = yield this.fetch.post(`track/${encodeURIComponent(this.number)}/codes${this.queryString()}`, { channel: this.selectedChannel }, REQUEST_OPTIONS); + this.masked = response.masked; + this.cooldown = response.resend_after ?? 30; + this.attemptsLeft = 3; + this.verifyStep = 'sent'; + } catch (error) { + this.handleVerifyError(error); + } + } + + @action resend() { + if (this.cooldown === 0) { + this.sendCode.perform(); + } + } + + @action updateCode(event) { + if (['locked', 'sending', 'success'].includes(this.verifyStep)) { + return; + } + + this.code = String(event.target.value ?? '').replace(/\D/g, '').slice(0, CODE_LENGTH); + if (this.verifyStep === 'wrong') { + this.verifyStep = 'sent'; + } + + if (this.code.length === CODE_LENGTH) { + this.verifyCode.perform(this.code); + } + } + + @task({ drop: true }) *verifyCode(code) { + try { + const data = yield this.fetch.post(`track/${encodeURIComponent(this.number)}/codes/verify${this.queryString()}`, { code }, REQUEST_OPTIONS); + this.verifyStep = 'success'; + yield timeout(700); + this.show(data); + this.notify(this.intl.t('tracking-page.page.verified-toast'), this.intl.t('tracking-page.page.verified-toast-help')); + } catch (error) { + this.code = ''; + this.handleVerifyError(error); + } + } + + handleVerifyError(error) { + const code = error?.error ?? error?.code; + if (code === 'invalid') { + this.attemptsLeft = error.attempts_left ?? 0; + this.verifyStep = 'wrong'; + } else if (code === 'paused') { + this.lockLeft = error.retry_after ?? 300; + this.verifyStep = 'locked'; + } else if (code === 'cooldown') { + this.cooldown = error.retry_after ?? 30; + this.verifyStep = 'sent'; + } else if (code === 'expired') { + this.verifyStep = 'renew'; + this.verifyMessage = 'expired'; + } else if (code === 'limited') { + this.verifyStep = 'renew'; + this.verifyMessage = 'limited'; + } else { + this.verifyStep = this.hasChannels ? 'choose' : 'none'; + this.verifyMessage = 'failed'; + } + } + + @task *signOut() { + try { + yield this.fetch.delete(`track/session${this.queryString()}`, {}, REQUEST_OPTIONS); + } catch { + // Signed out locally either way: the next lookup shows what the server allows. + } + + this.live?.stop(); + this.notify(this.intl.t('tracking-page.page.signed-out-toast'), ''); + yield this.lookup.perform(this.number); + } + + /* โ”€โ”€ Full view actions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + + @task *saveInstructions(text) { + try { + const data = yield this.fetch.put(`track/${encodeURIComponent(this.number)}/instructions${this.queryString()}`, { text }, REQUEST_OPTIONS); + this.show(data); + this.notify(this.intl.t('tracking-page.page.instructions-saved'), ''); + } catch { + this.notify(this.intl.t('tracking-page.page.action-failed'), ''); + } + } + + @task *reportProblem(message) { + try { + yield this.fetch.post(`track/${encodeURIComponent(this.number)}/report${this.queryString()}`, { message }, REQUEST_OPTIONS); + this.notify(this.intl.t('tracking-page.page.report-sent'), this.intl.t('tracking-page.page.report-sent-help', { name: this.company.name })); + return true; + } catch { + this.notify(this.intl.t('tracking-page.page.action-failed'), ''); + return false; + } + } + + @task *loadProof(id) { + return yield this.fetch.get(`track/${encodeURIComponent(this.number)}/proofs/${encodeURIComponent(id)}`, this.query(), REQUEST_OPTIONS); + } + + @action share() { + const url = `${window.location.origin}/~/track?order=${encodeURIComponent(this.number)}${this.org ? `&org=${encodeURIComponent(this.org)}` : ''}`; + try { + if (navigator.share) { + navigator.share({ url }).catch(() => {}); + } else { + navigator.clipboard?.writeText(url); + } + } catch { + // Copying is a convenience; the address bar still has the link. + } + + this.notify(this.intl.t('tracking-page.page.share-copied'), this.intl.t('tracking-page.page.share-copied-help')); + } + + /* โ”€โ”€ Page chrome โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + + @action changeLocale(event) { + this.intl.setLocale([event.target.value]); + } + + @action dismissToast() { + this.toast = null; + } + + notify(title, text) { + this.toast = { title, text, id: Date.now() }; + const id = this.toast.id; + setTimeout(() => { + if (this.toast?.id === id && !this.isDestroyed) { + this.toast = null; + } + }, 4500); + } + + tick() { + if (this.cooldown > 0) { + this.cooldown -= 1; + } + + if (this.verifyStep === 'locked') { + if (this.lockLeft > 1) { + this.lockLeft -= 1; + } else { + this.lockLeft = 0; + this.verifyStep = 'renew'; + this.verifyMessage = 'unlocked'; + } + } + } + + applyLocale(company) { + const preferred = this.urlSearchParams.get('lang') || company?.default_locale; + if (!this.localeApplied && preferred && company?.locales?.includes(preferred)) { + this.localeApplied = true; + this.intl.setLocale([preferred]); + } + } + + watchColorScheme() { + if (typeof window === 'undefined' || !window.matchMedia) { + return; + } + + this.colorScheme = window.matchMedia('(prefers-color-scheme: dark)'); + this.prefersDark = this.colorScheme.matches; + this.onColorScheme = (event) => { + this.prefersDark = event.matches; + }; + this.colorScheme.addEventListener?.('change', this.onColorScheme); + } + + query() { + return this.org ? { org: this.org } : {}; + } + + queryString() { + return this.org ? `?org=${encodeURIComponent(this.org)}` : ''; + } +} + +export function formatCountdown(seconds) { + const value = Math.max(0, Math.floor(seconds ?? 0)); + const minutes = Math.floor(value / 60); + const rest = value % 60; + + return `${minutes}:${rest < 10 ? '0' : ''}${rest}`; +} diff --git a/addon/components/tracking-page/full-view.hbs b/addon/components/tracking-page/full-view.hbs new file mode 100644 index 000000000..aeff45968 --- /dev/null +++ b/addon/components/tracking-page/full-view.hbs @@ -0,0 +1,258 @@ +
+
+ {{#if @fromList}} + + {{/if}} + {{this.viewerChip.label}} + {{#if this.canSignOut}} + + {{/if}} +
+ +
+ {{#if this.hasSeveralStops}} +
+ {{#each this.stopOptions as |option|}} + + {{/each}} +
+ {{/if}} + +
+
+

{{this.headline}}

+ {{#if this.subline}} +

{{this.subline}}

+ {{/if}} +
+
+ {{#if this.live}} + {{t "tracking-page.page.live"}} + {{/if}} + {{#if (eq @liveState "reconnecting")}} + {{t "tracking-page.page.reconnecting"}} + {{/if}} + {{#if this.timezoneNote}}{{this.timezoneNote}}{{/if}} +
+
+ +
    + {{#each this.steps as |step|}} +
  1. +
    + + {{#if step.isDone}}{{/if}} + {{#if step.isFailed}}{{/if}} + {{#if step.isStop}}{{/if}} + + {{#unless step.last}}{{/unless}} +
    + {{step.label}} +
  2. + {{/each}} +
+ + {{#if this.delayed}} +
+ + {{t "tracking-page.page.delayed-title"}} {{t "tracking-page.page.delayed-help"}} +
+ {{/if}} + + {{#if this.isMultiStop}} +
+

{{t "tracking-page.page.stop-of" position=this.stopPosition total=this.route.total_stops}}

+

{{t "tracking-page.page.stops-private" count=this.route.stops_before}}

+
+ {{/if}} + +
+
+ {{t "tracking-page.page.tracking-number"}} + {{@data.tracking_number}} +
+ {{t "tracking-page.page.copy"}} +
+
+ + {{#if @data.map}} + {{#if (or @data.location (gt this.stops.length 0))}} +
+ +
+ {{/if}} + {{else if (eq this.stage "delivered")}} +

{{t "tracking-page.page.map-hidden"}}

+ {{/if}} + +
+ {{#if (or this.etaCard this.driver)}} +
+ {{#if this.etaCard}} +
+

{{t "tracking-page.page.eta"}}

+
+ {{this.etaCard.time}} + {{#if this.etaCard.relative}}{{this.etaCard.relative}}{{/if}} +
+
+ {{#if this.etaCard.window}} +
{{t "tracking-page.page.window"}}
{{this.etaCard.window}}
+ {{/if}} +
{{t "tracking-page.page.before-yours"}}
{{t "tracking-page.page.stops-count" count=this.etaCard.stopsBefore}}
+
+ {{#if this.etaCard.low}} + {{t "tracking-page.page.estimate-may-change"}} + {{/if}} +
+ {{/if}} + {{#if this.driver}} +
+

{{t "tracking-page.page.driver"}}

+
+ +
+ {{#if this.driver.name}}{{this.driver.name}}{{/if}} + {{#if this.driver.vehicle}}{{this.driver.vehicle}}{{/if}} + + {{#if this.driver.online}}{{t "tracking-page.page.online"}}{{else}}{{t "tracking-page.page.location-paused"}}{{/if}} + +
+
+ {{#if this.contactPhone}} + +

{{t "tracking-page.page.contact-note" name=@company.name}}

+ {{/if}} +
+ {{/if}} +
+ {{/if}} + + {{#if (gt this.proofs.length 0)}} +
+

{{t "tracking-page.page.proof"}}

+
+ {{#each this.proofs as |proof|}} + {{#if proof.url}} + + {{else}} +
+ + {{t (concat "tracking-page.page.proof-" proof.type)}} +
+ {{/if}} + {{/each}} +
+
+ {{/if}} + + {{#if (or this.instructions.text this.instructions.editable)}} +
+
+

{{t "tracking-page.page.instructions"}}

+ {{#if (and this.instructions.editable (not this.editing))}} + + {{/if}} +
+ {{#if this.editing}} + + +
+ + +
+ {{else}} +

{{or this.instructions.text (t "tracking-page.page.no-instructions")}}

+ {{/if}} +
+ {{/if}} + +
+

{{t "tracking-page.page.updates"}}

+ {{#each this.timelineGroups as |group|}} +
{{group.day}}
+
    + {{#each group.entries as |entry|}} +
  1. + +
    + {{entry.status}} + {{entry.time}} +
    +
  2. + {{/each}} +
+ {{else}} +

{{t "tracking-page.page.no-updates"}}

+ {{/each}} + {{#if this.hasOlder}} + + {{/if}} +
+ + {{#if (gt this.items.length 0)}} +
+

{{t "tracking-page.page.items"}}

+ {{#each this.items as |item|}} +
+ +
+ {{or item.name (t "tracking-page.page.item")}} + {{#if item.description}}{{item.description}}{{/if}} + {{#if item.quantity}}{{t "tracking-page.page.quantity" count=item.quantity}}{{/if}} + {{#if item.tracking}}{{item.tracking}}{{/if}} +
+ {{#if item.price}}{{format-currency item.price item.currency}}{{/if}} +
+ {{/each}} +
+ {{/if}} + +
+ + {{#if this.canReport}} + + {{/if}} + {{#if this.contactEmail}} + {{t "tracking-page.page.contact" name=@company.name}} + {{/if}} +
+ {{#if this.reporting}} +
+ + +
+ + +
+
+ {{/if}} +
+ + {{#if this.lightbox}} + + {{/if}} +
diff --git a/addon/components/tracking-page/full-view.js b/addon/components/tracking-page/full-view.js new file mode 100644 index 000000000..53cff86c5 --- /dev/null +++ b/addon/components/tracking-page/full-view.js @@ -0,0 +1,445 @@ +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; + +/** + * Coarse stages, in order, and the step each one has reached. + */ +const STEPS = ['created', 'dispatched', 'started', 'enroute', 'delivered']; +const REACHED = { preparing: 0, scheduled: 0, dispatched: 1, in_transit: 3, delivered: 5 }; + +function step(key, state, label, last) { + return { + key, + state, + label, + last, + isDone: state === 'done', + isCurrent: state === 'current', + isFailed: state === 'failed', + isStop: state === 'stop', + }; +} + +/** + * One customer's view of their delivery: status, progress, live map, arrival, driver, + * updates, items and proof of delivery. Everything it shows has already been filtered to + * that customer's stops by the server. + */ +export default class TrackingPageFullViewComponent extends Component { + @service intl; + + @tracked showOlder = false; + @tracked editing = false; + @tracked draft = ''; + @tracked reporting = false; + @tracked reportText = ''; + @tracked proofUrls = {}; + @tracked lightbox = null; + @tracked selectedStopId = null; + + get data() { + return this.args.data ?? {}; + } + + get stage() { + return this.data.stage ?? 'preparing'; + } + + get delayed() { + return this.data.status?.delayed === true && this.stage === 'in_transit'; + } + + get tone() { + if (this.stage === 'issue' || this.delayed) { + return 'warn'; + } + + return this.stage === 'canceled' ? 'muted' : 'normal'; + } + + get viewerChip() { + const viewer = this.data.viewer; + if (viewer === 'staff') { + return { icon: 'user-shield', label: this.intl.t('tracking-page.page.chip-staff') }; + } + + if (viewer === 'account') { + return { icon: 'user', label: this.intl.t('tracking-page.page.chip-account') }; + } + + return { icon: 'shield-halved', label: this.intl.t('tracking-page.page.chip-verified') }; + } + + get canSignOut() { + return this.data.viewer === 'verified'; + } + + /* โ”€โ”€ Stops โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + + get stops() { + return this.data.stops ?? []; + } + + get hasSeveralStops() { + return this.stops.length > 1; + } + + get stopOptions() { + return this.stops.map((stop, index) => ({ + id: stop.id, + label: this.intl.t('tracking-page.page.your-stop', { number: index + 1 }), + selected: stop.id === this.currentStop?.id, + complete: stop.complete, + })); + } + + get currentStop() { + const chosen = this.stops.find((stop) => stop.id === (this.selectedStopId ?? this.data.focus_stop)); + + return chosen ?? this.stops.find((stop) => !stop.complete) ?? this.stops[this.stops.length - 1] ?? null; + } + + get route() { + return this.data.route ?? { total_stops: 0, completed_stops: 0, stops_before: 0 }; + } + + get isMultiStop() { + return this.route.total_stops > 2 && this.stage === 'in_transit'; + } + + get stopPosition() { + return this.route.completed_stops + this.route.stops_before + 1; + } + + @action selectStop(id) { + this.selectedStopId = id; + } + + /* โ”€โ”€ Headline โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + + get eta() { + return this.currentStop?.eta ?? this.data.eta ?? null; + } + + get window() { + const own = this.currentStop?.window; + if (own?.start || own?.end) { + return own; + } + + return this.data.time_window ?? {}; + } + + get windowText() { + const { start, end } = this.window; + if (start && end) { + return `${this.time(start)} โ€“ ${this.time(end)}`; + } + + return start ? this.time(start) : end ? this.time(end) : null; + } + + get headline() { + const t = (key, values) => this.intl.t(`tracking-page.page.${key}`, values); + switch (this.stage) { + case 'scheduled': + return t('headline-scheduled', { date: this.date(this.data.scheduled_at) }); + case 'dispatched': + return t('headline-dispatched'); + case 'in_transit': + if (this.delayed) { + return t('headline-delayed'); + } + + return this.eta?.at ? t('headline-arriving', { day: this.relativeDay(this.eta.at) }) : t('headline-in-transit'); + case 'delivered': + return t('headline-delivered'); + case 'issue': + return t('headline-issue'); + case 'canceled': + return t('headline-canceled'); + default: + return t('headline-preparing'); + } + } + + get subline() { + const t = (key, values) => this.intl.t(`tracking-page.page.${key}`, values); + if (this.stage === 'in_transit' && this.eta?.at) { + const relative = this.minutesUntil(this.eta.at); + const window = this.windowText ?? this.time(this.eta.at); + + return relative > 0 ? t('sub-arriving', { window, minutes: relative }) : window; + } + + if (this.stage === 'scheduled') { + return this.windowText ? t('sub-window', { window: this.windowText }) : null; + } + + if (this.stage === 'delivered') { + const delivered = this.timeline.find((entry) => entry.complete) ?? this.timeline[0]; + + return delivered?.created_at ? t('sub-delivered', { when: this.dateTime(delivered.created_at) }) : null; + } + + if (this.stage === 'issue' || this.stage === 'canceled') { + return this.timeline[0]?.status ?? null; + } + + return this.stage === 'dispatched' ? t('sub-dispatched') : t('sub-preparing'); + } + + get live() { + return this.stage === 'in_transit' && !!this.data.location && this.args.liveState === 'live' && !this.stale; + } + + get stale() { + const seenAt = this.data.location?.seen_at; + if (!seenAt) { + return false; + } + + return (Date.now() - new Date(seenAt).getTime()) / 1000 > (this.data.stale_after ?? 300); + } + + get staleText() { + const minutes = Math.max(1, Math.round((Date.now() - new Date(this.data.location.seen_at).getTime()) / 60000)); + + return this.intl.t('tracking-page.page.last-seen', { minutes }); + } + + get timezoneNote() { + try { + const format = new Intl.DateTimeFormat(this.locale, { timeZoneName: 'short' }); + const parts = format.formatToParts(new Date()); + const zone = parts.find((part) => part.type === 'timeZoneName')?.value; + + return zone ? this.intl.t('tracking-page.page.timezone', { zone }) : null; + } catch { + return null; + } + } + + /* โ”€โ”€ Stepper โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + + get steps() { + if (this.stage === 'canceled') { + return [step('created', 'done', this.intl.t('tracking-page.page.step-created'), false), step('canceled', 'stop', this.intl.t('tracking-page.page.step-canceled'), true)]; + } + + const reached = REACHED[this.stage] ?? 3; + + return STEPS.map((key, index) => { + let state = index < reached ? 'done' : index === reached ? 'current' : 'todo'; + let label = this.intl.t(`tracking-page.page.step-${key}`); + if (this.stage === 'issue' && key === 'enroute') { + state = 'failed'; + label = this.intl.t('tracking-page.page.step-issue'); + } + + return step(key, state, label, index === STEPS.length - 1); + }); + } + + /* โ”€โ”€ Cards โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + + get etaCard() { + if (this.stage !== 'in_transit' || !this.eta?.at) { + return null; + } + + return { + time: this.time(this.eta.at), + relative: this.minutesUntil(this.eta.at) > 0 ? this.intl.t('tracking-page.page.in-minutes', { minutes: this.minutesUntil(this.eta.at) }) : null, + window: this.windowText, + stopsBefore: this.route.stops_before, + low: this.delayed || ['low', 'medium'].includes(this.eta.confidence ?? this.data.status?.confidence), + }; + } + + get driver() { + return this.stage === 'in_transit' ? this.data.driver : null; + } + + get driverInitial() { + return (this.driver?.name ?? '?').charAt(0).toUpperCase(); + } + + get contactPhone() { + return this.args.company?.support_phone; + } + + get contactEmail() { + return this.args.company?.support_email; + } + + get timeline() { + return this.data.timeline ?? []; + } + + get timelineGroups() { + const groups = []; + this.timeline.forEach((entry, index) => { + const day = this.dayLabel(entry.created_at); + let group = groups[groups.length - 1]; + if (!group || group.day !== day) { + group = { day, entries: [] }; + groups.push(group); + } + + group.entries.push({ ...entry, time: this.time(entry.created_at), latest: index === 0 }); + }); + + return this.showOlder ? groups : groups.slice(0, 1); + } + + get olderCount() { + const first = this.timelineGroups[0]?.entries.length ?? 0; + + return Math.max(0, this.timeline.length - (this.showOlder ? this.timeline.length : first)); + } + + get hasOlder() { + return this.showOlder || this.olderCount > 0; + } + + @action toggleOlder() { + this.showOlder = !this.showOlder; + } + + get items() { + return this.data.items ?? []; + } + + get proofs() { + return (this.data.proofs ?? []).map((proof) => ({ ...proof, url: this.proofUrls[proof.id] ?? null })); + } + + @action async loadProofs() { + for (const proof of this.data.proofs ?? []) { + if (this.proofUrls[proof.id] || proof.type === 'scan') { + continue; + } + + try { + const response = await this.args.loadProof(proof.id); + this.proofUrls = { ...this.proofUrls, [proof.id]: response?.url ?? null }; + } catch { + // The proof is listed even when its image can't be fetched. + } + } + } + + @action openProof(proof) { + if (proof.url) { + this.lightbox = proof; + } + } + + @action closeProof() { + this.lightbox = null; + } + + @action focusElement(element) { + element.focus(); + } + + @action closeOnEscape(event) { + if (event.key === 'Escape') { + this.closeProof(); + } + } + + /* โ”€โ”€ Instructions and reports โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + + get instructions() { + return this.data.instructions ?? {}; + } + + @action editInstructions() { + this.draft = this.instructions.text ?? ''; + this.editing = true; + } + + @action updateDraft(event) { + this.draft = event.target.value; + } + + @action cancelInstructions() { + this.editing = false; + } + + @action async saveInstructions() { + await this.args.saveInstructions(this.draft); + this.editing = false; + } + + @action toggleReport() { + this.reporting = !this.reporting; + } + + @action updateReport(event) { + this.reportText = event.target.value; + } + + @action async sendReport() { + const sent = await this.args.reportProblem(this.reportText); + if (sent) { + this.reportText = ''; + this.reporting = false; + } + } + + get canReport() { + return this.data.actions?.report === true && !!this.contactEmail; + } + + /* โ”€โ”€ Formatting โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + + get locale() { + return this.intl.primaryLocale ?? 'en-us'; + } + + time(value) { + return value ? new Intl.DateTimeFormat(this.locale, { hour: 'numeric', minute: '2-digit' }).format(new Date(value)) : ''; + } + + date(value) { + return value ? new Intl.DateTimeFormat(this.locale, { weekday: 'short', day: 'numeric', month: 'short' }).format(new Date(value)) : ''; + } + + dateTime(value) { + return `${this.dayLabel(value)}, ${this.time(value)}`; + } + + dayLabel(value) { + if (!value) { + return ''; + } + + const day = new Date(value); + const today = new Date(); + const yesterday = new Date(today.getTime() - 86400000); + if (day.toDateString() === today.toDateString()) { + return this.intl.t('tracking-page.page.today'); + } + + if (day.toDateString() === yesterday.toDateString()) { + return this.intl.t('tracking-page.page.yesterday'); + } + + return this.date(value); + } + + relativeDay(value) { + const day = this.dayLabel(value); + + return day === this.intl.t('tracking-page.page.today') ? day.toLowerCase() : day; + } + + minutesUntil(value) { + return Math.round((new Date(value).getTime() - Date.now()) / 60000); + } +} diff --git a/addon/components/tracking-page/map.hbs b/addon/components/tracking-page/map.hbs new file mode 100644 index 000000000..d892939d9 --- /dev/null +++ b/addon/components/tracking-page/map.hbs @@ -0,0 +1,32 @@ + diff --git a/addon/components/tracking-page/map.js b/addon/components/tracking-page/map.js new file mode 100644 index 000000000..1f235122a --- /dev/null +++ b/addon/components/tracking-page/map.js @@ -0,0 +1,100 @@ +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { decodePolyline } from '../../utils/tracking-live-source'; + +/** + * The live map on the tracking page: the customer's stops, where the route starts, the + * route line when every stop is theirs, and the vehicle while it is on its way to them. + * It renders below the status text, so the page reads before the map loads. + */ +export default class TrackingPageMapComponent extends Component { + map = null; + + get data() { + return this.args.data ?? {}; + } + + get vehicle() { + const location = this.data.location; + + return location ? [location.latitude, location.longitude] : null; + } + + get stops() { + const stops = []; + for (const stop of this.data.stops ?? []) { + if (stop.place?.latitude && stop.place?.longitude) { + stops.push({ ...stop, location: [stop.place.latitude, stop.place.longitude] }); + } + } + + return stops; + } + + get hub() { + const hub = this.data.map?.hub; + + return hub ? [hub.latitude, hub.longitude] : null; + } + + get route() { + return decodePolyline(this.data.map?.polyline); + } + + get hasRoute() { + return this.route.length > 1; + } + + get points() { + const points = this.stops.map((stop) => stop.location); + if (this.vehicle) { + points.push(this.vehicle); + } + + if (this.hub && points.length < 2) { + points.push(this.hub); + } + + return points; + } + + get center() { + return this.vehicle ?? this.points[0] ?? [0, 0]; + } + + get vehicleIcon() { + return L.divIcon({ className: this.args.stale ? 'tp-map-vehicle tp-map-vehicle-stale' : 'tp-map-vehicle', iconSize: [28, 28], iconAnchor: [14, 14] }); + } + + get stopIcon() { + return L.divIcon({ className: 'tp-map-stop', iconSize: [22, 22], iconAnchor: [11, 22] }); + } + + get hubIcon() { + return L.divIcon({ className: 'tp-map-hub', iconSize: [16, 16], iconAnchor: [8, 8] }); + } + + @action setup({ target }) { + this.map = target; + this.fitRoute(); + } + + @action fitRoute() { + if (!this.map || this.points.length === 0) { + return; + } + + if (this.points.length === 1) { + this.map.setView(this.points[0], 14); + return; + } + + this.map.fitBounds(L.latLngBounds(this.points), { padding: [36, 36], maxZoom: 15 }); + } + + @action recenter() { + if (this.map && this.vehicle) { + this.map.setView(this.vehicle, 15, { animate: !this.args.reducedMotion }); + } + } +} diff --git a/addon/components/tracking-page/order-list.hbs b/addon/components/tracking-page/order-list.hbs new file mode 100644 index 000000000..ca2010559 --- /dev/null +++ b/addon/components/tracking-page/order-list.hbs @@ -0,0 +1,35 @@ +
+
+ {{t "tracking-page.page.chip-account"}} +

{{t "tracking-page.page.your-orders"}}

+
+ {{#if @isLoading}} +
+
+
+
+
+ {{else if @orders.length}} +
    + {{#each @orders as |order|}} +
  • + +
  • + {{/each}} +
+ {{else}} +
+ +
{{t "tracking-page.page.no-orders"}}
+
{{t "tracking-page.page.no-orders-help"}}
+
+ {{/if}} +
diff --git a/addon/components/tracking-page/public-view.hbs b/addon/components/tracking-page/public-view.hbs new file mode 100644 index 000000000..1c09d0d51 --- /dev/null +++ b/addon/components/tracking-page/public-view.hbs @@ -0,0 +1,60 @@ +
+ {{t "tracking-page.page.chip-limited"}} + +
+ {{#if @data.stage}} +
+
{{t "tracking-page.page.status"}}
+

{{t (concat "tracking-page.page.stage-" @data.stage)}}

+ {{#if @data.last_update_on}} +
{{t "tracking-page.page.last-update" date=@data.last_update_on}}
+ {{/if}} +
+ + {{else}} +

{{t "tracking-page.page.verify-to-see"}}

+ {{/if}} +
+
+ {{t "tracking-page.page.tracking-number"}} + {{@data.tracking_number}} +
+ {{t "tracking-page.page.copy"}} +
+
+ +
+ +
+

{{t "tracking-page.page.verify-title"}}

+

{{t "tracking-page.page.verify-help"}}

+
+
+ + {{#if @data.sign_in}} + {{t "tracking-page.page.sign-in-account"}} + {{/if}} +
+
+ +
+ {{#each (array "map" "arrival" "details") as |section|}} +
+ +
+ + {{t (concat "tracking-page.page.locked-" section)}} + +
+
+ {{/each}} +
+
diff --git a/addon/components/tracking-page/verify-panel.hbs b/addon/components/tracking-page/verify-panel.hbs new file mode 100644 index 000000000..857dbc393 --- /dev/null +++ b/addon/components/tracking-page/verify-panel.hbs @@ -0,0 +1,109 @@ +
+ + + {{#if (or (eq @step "choose") (eq @step "sending"))}} +
+

{{t "tracking-page.page.verify-title-short"}}

+

{{t "tracking-page.page.verify-choose-help"}}

+
+
+ {{t "tracking-page.page.send-by"}} + {{#each @channels as |channel|}} + + {{/each}} +
+ {{#if (eq @message "failed")}} + + {{/if}} + +

{{t "tracking-page.page.masked-note"}}

+ {{else if (eq @step "none")}} +
+

{{t "tracking-page.page.no-contact-title"}}

+

{{t "tracking-page.page.no-contact-help" name=@company.name}}

+
+ {{#if @company.support_phone}} + {{t "tracking-page.page.call" phone=@company.support_phone}} + {{/if}} + {{#if @company.support_email}} + {{@company.support_email}} + {{/if}} + {{else if (eq @step "renew")}} +
+

{{t "tracking-page.page.renew-title"}}

+

{{t (concat "tracking-page.page.renew-" (or @message "expired"))}}

+
+ + {{else}} +
+

+ {{#if (eq @step "success")}}{{t "tracking-page.page.verified-title"}}{{else if (eq @step "locked")}}{{t "tracking-page.page.locked-title"}}{{else}}{{t "tracking-page.page.enter-code"}}{{/if}} +

+

{{t (concat "tracking-page.page.code-sent-" @selectedChannel) masked=@masked}}

+
+
+ + + +
+
+ {{#if (eq @step "sent")}} +

{{t "tracking-page.page.code-expires"}}

+ {{else if (eq @step "wrong")}} +

{{t "tracking-page.page.code-wrong" count=@attemptsLeft}}

+ {{else if (eq @step "locked")}} +

{{t "tracking-page.page.code-locked" time=@lockText}}

+ {{else if (eq @step "success")}} +

{{t "tracking-page.page.code-success"}}

+ {{/if}} +
+ {{#if (or (eq @step "sent") (eq @step "wrong"))}} +
+ {{#if @cooldown}} + {{t "tracking-page.page.resend-in" time=@cooldownText}} + {{else}} + + {{/if}} + {{#if @otherChannel}} + + {{/if}} +
+ {{/if}} + {{/if}} + +

{{t "tracking-page.page.stay-verified" hours=(or @sessionHours 24)}}

+
diff --git a/addon/controllers/settings/tracking-page.js b/addon/controllers/settings/tracking-page.js index 840b71779..9741e0720 100644 --- a/addon/controllers/settings/tracking-page.js +++ b/addon/controllers/settings/tracking-page.js @@ -97,15 +97,15 @@ export default class SettingsTrackingPageController extends Controller { } get orgPageBase() { - return `${window.location.origin}/t/`; + return `${window.location.origin}/~/track?org=`; } get orgPageUrl() { - return `${window.location.origin}/t/${this.config?.org_page?.slug ?? ''}`; + return `${window.location.origin}/~/track?org=${encodeURIComponent(this.config?.org_page?.slug ?? '')}`; } get genericPageUrl() { - return `${window.location.origin}/track`; + return `${window.location.origin}/~/track`; } get slugIsValid() { diff --git a/addon/extension.js b/addon/extension.js index 2cc2515a1..3ff47b77f 100644 --- a/addon/extension.js +++ b/addon/extension.js @@ -132,16 +132,18 @@ export default { // inside the console. Signed-out visitors are sent on to the public route after login; // registering the page in the console too shows it to signed-in staff instead of an // empty console page. - menuService.registerMenuItem( - 'console', - new MenuItem({ - title: 'Track Order', - slug: 'track-order', - section: null, - view: null, - component: new ExtensionComponent('@fleetbase/fleetops-engine', 'order-tracking-lookup'), - }) - ); + for (const slug of ['track', 'track-order']) { + menuService.registerMenuItem( + 'console', + new MenuItem({ + title: 'Track Order', + slug, + section: null, + view: null, + component: new ExtensionComponent('@fleetbase/fleetops-engine', 'tracking-page'), + }) + ); + } // Register track order button menuService.registerMenuItem( @@ -155,7 +157,23 @@ export 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'), + component: new ExtensionComponent('@fleetbase/fleetops-engine', 'tracking-page'), + onClick: (menuItem) => { + universe.transitionMenuItem('virtual', menuItem); + }, + }) + ); + + // The customer tracking page at /~/track (the shared page, or ?org= for a company's own). + menuService.registerMenuItem( + 'auth:login', + new MenuItem({ + title: 'Track', + route: 'virtual', + slug: 'track', + type: 'link', + wrapperClass: 'hidden', + component: new ExtensionComponent('@fleetbase/fleetops-engine', 'tracking-page'), onClick: (menuItem) => { universe.transitionMenuItem('virtual', menuItem); }, diff --git a/addon/styles/fleetops-engine.css b/addon/styles/fleetops-engine.css index 1a4d23ee1..0e139a0b2 100644 --- a/addon/styles/fleetops-engine.css +++ b/addon/styles/fleetops-engine.css @@ -12526,3 +12526,1180 @@ body[data-theme='dark'] .resource-list-pill .fleetbase-pill-body .resource-pill- text-align: center; font-size: 0.6875rem; } + +/* Customer tracking page. Colours come from the --tp-* tokens the page sets from its company's branding. */ +.tp-root { + display: flex; + flex-direction: column; + min-height: 100vh; + background: var(--tp-bg); + color: var(--tp-text); + font-size: 1rem; + line-height: 1.5; +} + +.tp-root a { + color: var(--tp-accent); + font-weight: 600; +} + +.tp-root h1, +.tp-root h2, +.tp-root h3, +.tp-root p, +.tp-root dl, +.tp-root dd, +.tp-root figure, +.tp-root fieldset { + margin: 0; +} + +.tp-root fieldset { + border: 0; + padding: 0; +} + +.tp-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.75rem 1rem; + background: var(--tp-surface); + border-bottom: 1px solid var(--tp-border); +} + +.tp-brand { + display: flex; + align-items: center; + gap: 0.625rem; + min-width: 0; +} + +.tp-brand-logo { + height: 2rem; + max-width: 8rem; + object-fit: contain; +} + +.tp-brand-monogram { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + flex: none; + border-radius: 0.5rem; + background: var(--tp-accent); + color: var(--tp-ink); + font-size: 0.8125rem; + font-weight: 700; +} + +.tp-brand-name { + overflow: hidden; + font-weight: 700; + white-space: nowrap; + text-overflow: ellipsis; +} + +.tp-main { + flex: 1; + width: 100%; + max-width: 77.5rem; + margin: 0 auto; + box-sizing: border-box; + padding: 1.25rem 1rem; +} + +.tp-narrow { + width: 100%; + max-width: 30rem; + margin: 0 auto; +} + +.tp-wide { + width: 100%; + max-width: 55rem; + margin: 0 auto; +} + +.tp-stack { + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.tp-stack-sm { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.tp-stack-xs { + display: flex; + flex-direction: column; + gap: 0.25rem; + min-width: 0; +} + +.tp-row { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.tp-row-wrap { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem 1rem; +} + +.tp-row-between { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +.tp-row-end { + display: flex; + justify-content: flex-end; + gap: 0.5rem; +} + +.tp-baseline { + align-items: baseline; +} + +.tp-grow { + flex: 1 1 12rem; + min-width: 0; +} + +.tp-self-start { + align-self: flex-start; +} + +.tp-top-border { + padding-top: 0.75rem; + border-top: 1px solid var(--tp-border); +} + +.tp-h1 { + font-size: 1.875rem; + font-weight: 700; + line-height: 1.15; + letter-spacing: -0.02em; + text-wrap: balance; +} + +.tp-h2 { + font-size: 1.375rem; + font-weight: 700; + line-height: 1.2; +} + +.tp-h3 { + font-size: 1.0625rem; + font-weight: 700; +} + +.tp-headline { + font-size: 2rem; + font-weight: 700; + line-height: 1.08; + letter-spacing: -0.03em; + text-wrap: balance; +} + +.tp-subline { + font-size: 1.0625rem; + font-weight: 500; +} + +.tp-eyebrow { + color: var(--tp-muted); + font-size: 0.8125rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.tp-label { + font-size: 0.875rem; + font-weight: 600; +} + +.tp-small { + font-size: 0.875rem; +} + +.tp-muted { + color: var(--tp-muted); +} + +.tp-strong { + font-weight: 600; +} + +.tp-mono { + font-family: ui-monospace, sfmono-regular, menlo, monospace; + letter-spacing: 0.02em; +} + +.tp-accent-text { + color: var(--tp-accent); +} + +.tp-warn-text { + color: var(--tp-warn); +} + +.tp-sr-only { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); +} + +.tp-touch { + display: inline-flex; + align-items: center; + min-height: 2.75rem; +} + +.tp-card { + padding: 1.125rem; + border: 1px solid var(--tp-border); + border-radius: 1.125rem; + background: var(--tp-surface); +} + +.tp-card-tight { + padding: 0.75rem 0.875rem; + border-radius: 0.875rem; +} + +.tp-card-accent { + border: 2px solid var(--tp-accent); +} + +.tp-card-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(15rem, 100%), 1fr)); + gap: 0.75rem; +} + +.tp-input { + width: 100%; + min-height: 3.25rem; + box-sizing: border-box; + padding: 0 0.875rem; + border: 1.5px solid var(--tp-border); + border-radius: 0.75rem; + background: var(--tp-surface); + color: var(--tp-text); + font-size: 1rem; +} + +.tp-textarea { + padding: 0.75rem; + resize: vertical; +} + +.tp-select { + min-height: 2.25rem; + padding: 0 0.5rem; + border: 1px solid var(--tp-border); + border-radius: 0.5rem; + background: var(--tp-surface); + color: var(--tp-text); +} + +.tp-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + min-height: 3rem; + box-sizing: border-box; + padding: 0 1rem; + border: 1.5px solid var(--tp-border); + border-radius: 0.75rem; + background: var(--tp-surface); + color: var(--tp-text); + font-size: 1rem; + font-weight: 600; + text-align: center; + text-decoration: none; + cursor: pointer; +} + +.tp-root a.tp-button { + color: var(--tp-text); +} + +.tp-button:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.tp-button-primary, +.tp-root a.tp-button-primary { + border-color: var(--tp-accent); + background: var(--tp-accent); + color: var(--tp-ink); +} + +.tp-button-small { + min-height: 2.75rem; + padding: 0 0.75rem; + font-size: 0.875rem; +} + +.tp-link-button { + min-height: 2.75rem; + padding: 0 0.25rem; + border: 0; + background: transparent; + color: var(--tp-accent); + font-size: 0.9375rem; + font-weight: 600; + cursor: pointer; +} + +.tp-back { + display: inline-flex; + align-items: center; + align-self: flex-start; + gap: 0.375rem; + min-height: 2.75rem; + border: 0; + background: transparent; + color: var(--tp-text); + font-weight: 600; + cursor: pointer; +} + +.tp-chip { + display: inline-flex; + align-items: center; + align-self: flex-start; + gap: 0.375rem; + min-height: 2rem; + padding: 0 0.75rem; + border: 0; + border-radius: 999px; + background: var(--tp-surface2); + color: var(--tp-text); + font-size: 0.8125rem; + font-weight: 600; +} + +.tp-chip-accent, +.tp-chip-button { + background: var(--tp-accent-soft); + color: var(--tp-accent); +} + +.tp-chip-button { + align-self: center; + cursor: pointer; +} + +.tp-chip-warn { + background: var(--tp-warn-soft); + color: var(--tp-warn); +} + +.tp-notice { + display: flex; + gap: 0.625rem; + padding: 0.75rem 0.875rem; + border-radius: 0.75rem; + background: var(--tp-surface2); + font-size: 0.9375rem; +} + +.tp-notice-warn { + background: var(--tp-warn-soft); +} + +.tp-icon-badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 3rem; + height: 3rem; + flex: none; + border-radius: 0.875rem; + background: var(--tp-accent-soft); + color: var(--tp-accent); +} + +.tp-icon-badge-solid { + background: var(--tp-accent); + color: var(--tp-ink); +} + +.tp-icon-badge-warn { + background: var(--tp-warn-soft); + color: var(--tp-warn); +} + +.tp-icon-badge-soft { + width: 2.5rem; + height: 2.5rem; + border-radius: 0.75rem; +} + +.tp-divider { + display: flex; + align-items: center; + gap: 0.75rem; + color: var(--tp-muted); + font-size: 0.8125rem; +} + +.tp-divider::before, +.tp-divider::after { + content: ''; + flex: 1; + height: 1px; + background: var(--tp-border); +} + +.tp-skeleton { + border-radius: 0.5rem; + background: var(--tp-surface2); +} + +.tp-skeleton-title { + width: 55%; + height: 1.75rem; +} + +.tp-skeleton-line { + width: 80%; + height: 0.75rem; + margin-top: 0.75rem; +} + +.tp-skeleton-card { + height: 9rem; + margin-top: 1.25rem; + border-radius: 1.125rem; +} + +.tp-skeleton-row { + height: 4.5rem; + border-radius: 0.875rem; +} + +.tp-coarse { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.375rem; +} + +.tp-coarse-bar { + height: 0.375rem; + border-radius: 999px; + background: var(--tp-border); +} + +.tp-coarse-done { + background: var(--tp-accent); +} + +.tp-locked-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(13rem, 100%), 1fr)); + gap: 0.75rem; +} + +.tp-locked { + position: relative; + min-height: 9rem; + overflow: hidden; + border: 1px solid var(--tp-border); + border-radius: 1.125rem; + background: var(--tp-surface); +} + +.tp-locked-blur { + position: absolute; + inset: 0; + padding: 1.25rem; + filter: blur(6px); +} + +.tp-locked-overlay { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.5rem; + min-height: 9rem; + padding: 1rem; + text-align: center; +} + +.tp-option { + display: flex; + align-items: center; + gap: 0.875rem; + min-height: 4rem; + box-sizing: border-box; + padding: 0.75rem 1rem; + border: 1.5px solid var(--tp-border); + border-radius: 0.875rem; + background: var(--tp-surface); + cursor: pointer; +} + +.tp-option-selected { + border: 2px solid var(--tp-accent); + background: var(--tp-accent-soft); +} + +.tp-option input { + width: 1.25rem; + height: 1.25rem; + margin: 0; + accent-color: var(--tp-accent); +} + +.tp-otp { + position: relative; + max-width: 23.75rem; +} + +.tp-otp-input { + position: absolute; + inset: 0; + z-index: 1; + width: 100%; + height: 100%; + font-size: 1rem; + opacity: 0; + cursor: text; +} + +.tp-otp-boxes { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 0.5rem; +} + +.tp-otp-box { + display: flex; + align-items: center; + justify-content: center; + height: 3.625rem; + box-sizing: border-box; + border: 1.5px solid var(--tp-border); + border-radius: 0.75rem; + background: var(--tp-surface); + font-family: ui-monospace, sfmono-regular, menlo, monospace; + font-size: 1.5rem; + font-weight: 600; +} + +.tp-otp-box-active { + border: 2px solid var(--tp-accent); + box-shadow: 0 0 0 4px var(--tp-accent-soft); +} + +.tp-otp-box-wrong { + border: 2px solid var(--tp-warn); +} + +.tp-otp-box-success { + border-color: var(--tp-accent); + background: var(--tp-accent); + color: var(--tp-ink); +} + +.tp-otp-box-locked { + background: var(--tp-surface2); + color: var(--tp-muted); +} + +.tp-full { + display: grid; + grid-template-columns: minmax(0, 1fr); + grid-template-areas: 'chips' 'head' 'map' 'body'; + gap: 1.125rem; +} + +.tp-full-chips { + grid-area: chips; +} + +.tp-full-head { + display: flex; + flex-direction: column; + grid-area: head; + gap: 1.125rem; + min-width: 0; +} + +.tp-full-map { + grid-area: map; + min-width: 0; +} + +.tp-full-body { + display: flex; + flex-direction: column; + grid-area: body; + gap: 1rem; + min-width: 0; +} + +.tp-segmented { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + align-self: flex-start; + padding: 0.25rem; + border-radius: 0.75rem; + background: var(--tp-surface2); +} + +.tp-segment { + display: inline-flex; + align-items: center; + gap: 0.375rem; + min-height: 2.5rem; + padding: 0 1rem; + border: 0; + border-radius: 0.5625rem; + background: transparent; + color: var(--tp-muted); + font-weight: 600; + cursor: pointer; +} + +.tp-segment-selected { + background: var(--tp-surface); + color: var(--tp-text); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12); +} + +.tp-live { + display: inline-flex; + align-items: center; + gap: 0.375rem; + color: var(--tp-accent); + font-weight: 600; +} + +.tp-live-dot { + width: 0.5rem; + height: 0.5rem; + border-radius: 999px; + background: var(--tp-accent); + animation: tp-live-pulse 2s ease-in-out infinite; +} + +@keyframes tp-live-pulse { + 50% { + opacity: 0.35; + } +} + +.tp-steps { + display: flex; + margin: 0; + padding: 0; + list-style: none; +} + +.tp-step { + display: flex; + flex: 1 1 0; + flex-direction: column; + gap: 0.5rem; + min-width: 0; +} + +.tp-step-track { + display: flex; + align-items: center; +} + +.tp-step-dot { + display: flex; + align-items: center; + justify-content: center; + width: 1.625rem; + height: 1.625rem; + flex: none; + box-sizing: border-box; + border: 2px solid var(--tp-border); + border-radius: 999px; + background: var(--tp-surface); + font-size: 0.75rem; +} + +.tp-step-line { + flex: 1; + height: 3px; + margin-inline: 0.25rem; + border-radius: 2px; + background: var(--tp-border); +} + +.tp-step-done .tp-step-dot { + border-color: var(--tp-accent); + background: var(--tp-accent); + color: var(--tp-ink); +} + +.tp-step-done .tp-step-line { + background: var(--tp-accent); +} + +.tp-step-current .tp-step-dot { + border: 3px solid var(--tp-accent); +} + +.tp-step-failed .tp-step-dot { + border-color: var(--tp-warn); + background: var(--tp-warn); + color: var(--tp-surface); +} + +.tp-step-stop .tp-step-dot { + border-color: var(--tp-neutral); + background: var(--tp-neutral); + color: var(--tp-surface); +} + +.tp-step-label { + padding-inline-end: 0.375rem; + font-size: 0.8125rem; + font-weight: 600; + line-height: 1.25; + overflow-wrap: anywhere; + hyphens: auto; +} + +.tp-step-todo .tp-step-label { + color: var(--tp-muted); + font-weight: 500; +} + +.tp-step-failed .tp-step-label { + color: var(--tp-warn); +} + +.tp-map { + position: relative; + height: 17rem; + overflow: hidden; + border: 1px solid var(--tp-border); + border-radius: 1.25rem; +} + +.tp-map-canvas { + width: 100%; + height: 100%; +} + +.tp-map-chip { + position: absolute; + inset-block-start: 0.75rem; + inset-inline-start: 0.75rem; + z-index: 500; + display: flex; + align-items: center; + gap: 0.375rem; + min-height: 2rem; + padding: 0 0.75rem; + border-radius: 999px; + background: var(--tp-surface); + color: var(--tp-accent); + font-size: 0.8125rem; + font-weight: 600; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.16); +} + +.tp-map-chip-stale { + color: var(--tp-muted); +} + +.tp-map-controls { + position: absolute; + inset-block-start: 0.75rem; + inset-inline-end: 0.75rem; + z-index: 500; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.tp-map-button { + width: 2.75rem; + height: 2.75rem; + border: 0; + border-radius: 0.75rem; + background: var(--tp-surface); + color: var(--tp-text); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.16); + cursor: pointer; +} + +.tp-map-vehicle { + border: 3px solid var(--tp-surface); + border-radius: 999px; + background: var(--tp-accent); + box-shadow: 0 0 0 6px var(--tp-accent-soft); + transition: transform 1s linear; +} + +.tp-map-vehicle-stale { + background: var(--tp-neutral); + opacity: 0.5; +} + +.tp-map-stop { + border: 3px solid var(--tp-surface); + border-radius: 999px 999px 999px 0; + background: var(--tp-text); + transform: rotate(-45deg); +} + +.tp-map-hub { + border: 3px solid var(--tp-text); + border-radius: 999px; + background: var(--tp-surface); +} + +.tp-eta-time { + font-size: 2.25rem; + font-weight: 700; + line-height: 1; + letter-spacing: -0.025em; +} + +.tp-dl { + display: flex; + flex-direction: column; + gap: 0.5rem; + font-size: 0.9375rem; +} + +.tp-dl div { + display: flex; + justify-content: space-between; + gap: 0.75rem; +} + +.tp-dl dt { + color: var(--tp-muted); +} + +.tp-dl dd { + font-weight: 600; + text-align: end; +} + +.tp-avatar { + display: inline-flex; + align-items: center; + justify-content: center; + width: 3rem; + height: 3rem; + flex: none; + border-radius: 999px; + background: var(--tp-accent-soft); + color: var(--tp-accent); + font-size: 1.125rem; + font-weight: 700; +} + +.tp-proof-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.625rem; +} + +.tp-proof { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.375rem; + height: 9.5rem; + overflow: hidden; + padding: 0; + border: 1px solid var(--tp-border); + border-radius: 0.875rem; + background: var(--tp-surface2); + color: var(--tp-muted); + cursor: zoom-in; +} + +.tp-proof img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.tp-proof-empty { + cursor: default; +} + +.tp-timeline { + display: flex; + flex-direction: column; + margin: 0; + padding: 0; + list-style: none; +} + +.tp-timeline-day { + padding: 0.375rem 0 0.25rem; +} + +.tp-timeline-entry { + display: flex; + align-items: baseline; + gap: 0.75rem; + padding-bottom: 0.875rem; +} + +.tp-timeline-dot { + width: 0.75rem; + height: 0.75rem; + flex: none; + box-sizing: border-box; + border: 2px solid var(--tp-border); + border-radius: 999px; +} + +.tp-timeline-latest .tp-timeline-dot { + border-color: var(--tp-accent); + background: var(--tp-accent); +} + +.tp-item { + display: flex; + align-items: center; + gap: 0.875rem; + padding: 0.75rem 0; + border-top: 1px solid var(--tp-border); +} + +.tp-item:first-of-type { + border-top: 0; +} + +.tp-item-photo { + display: inline-flex; + align-items: center; + justify-content: center; + width: 3.5rem; + height: 3.5rem; + flex: none; + overflow: hidden; + border-radius: 0.75rem; + background: var(--tp-surface2); + color: var(--tp-muted); +} + +.tp-item-photo img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.tp-actions { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(10rem, 100%), 1fr)); + gap: 0.5rem; +} + +.tp-list { + display: flex; + flex-direction: column; + gap: 0.625rem; + margin: 0; + padding: 0; + list-style: none; +} + +.tp-list-row { + display: flex; + align-items: center; + gap: 0.875rem; + width: 100%; + min-height: 4.75rem; + box-sizing: border-box; + padding: 0.875rem 1rem; + border: 1px solid var(--tp-border); + border-radius: 0.875rem; + background: var(--tp-surface); + color: var(--tp-text); + text-align: start; + cursor: pointer; +} + +.tp-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + padding: 2.5rem 1.25rem; + border: 1.5px dashed var(--tp-border); + border-radius: 1rem; + text-align: center; +} + +.tp-footer { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.75rem; + padding: 1.5rem 1.25rem 2rem; + color: var(--tp-muted); + font-size: 0.8125rem; + text-align: center; +} + +.tp-footer-language { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.tp-toast-region { + position: fixed; + inset-inline: 1rem; + bottom: 1.25rem; + z-index: 1000; + display: flex; + justify-content: center; + pointer-events: none; +} + +.tp-toast { + display: flex; + align-items: flex-start; + gap: 0.75rem; + width: 100%; + max-width: 27.5rem; + padding: 0.875rem 1rem; + border-radius: 0.875rem; + background: var(--tp-text); + color: var(--tp-bg); + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.22); + pointer-events: auto; +} + +.tp-toast-title { + font-weight: 600; +} + +.tp-toast-text { + font-size: 0.875rem; + opacity: 0.85; +} + +.tp-toast-close { + margin-inline-start: auto; + border: 0; + background: transparent; + color: inherit; + cursor: pointer; +} + +.tp-lightbox { + position: fixed; + inset: 0; + z-index: 1100; + display: flex; + align-items: center; + justify-content: center; + padding: 1.25rem; + background: rgba(8, 12, 13, 0.8); +} + +.tp-lightbox-close { + position: absolute; + inset-block-start: 1rem; + inset-inline-end: 1rem; + width: 2.75rem; + height: 2.75rem; + border: 0; + border-radius: 999px; + background: var(--tp-surface); + color: var(--tp-text); + cursor: pointer; +} + +.tp-lightbox-figure { + display: flex; + flex-direction: column; + gap: 0.75rem; + max-width: 40rem; + color: var(--tp-ink); + text-align: center; +} + +.tp-lightbox-figure img { + max-width: 100%; + max-height: 75vh; + border-radius: 1rem; + background: var(--tp-surface); +} + +.tp-root[dir='rtl'] .tp-flip { + transform: scaleX(-1); +} + +@media (width >= 960px) { + .tp-main { + padding: 2rem; + } + + .tp-headline { + font-size: 2.75rem; + } + + .tp-full-with-map { + grid-template-columns: minmax(0, 1fr) minmax(0, 1.15fr); + grid-template-areas: 'chips chips' 'head map' 'body map'; + gap: 1.25rem 2rem; + align-items: start; + } + + .tp-full-with-map .tp-full-map { + position: sticky; + top: 1.5rem; + } + + .tp-full-with-map .tp-map { + height: 40rem; + } + + .tp-full:not(.tp-full-with-map) { + max-width: 47.5rem; + margin: 0 auto; + } +} + +@media (prefers-reduced-motion: reduce) { + .tp-live-dot, + .tp-map-vehicle { + animation: none; + transition: none; + } +} diff --git a/addon/utils/tracking-live-source.js b/addon/utils/tracking-live-source.js new file mode 100644 index 000000000..32c17d484 --- /dev/null +++ b/addon/utils/tracking-live-source.js @@ -0,0 +1,123 @@ +/** + * Keeps the public tracking page current. + * + * `start(onUpdate, onState)` calls `load()` on a schedule and hands each result to + * `onUpdate`; `onState` hears `live`, `reconnecting` or `paused`. It polls every 20 + * seconds while the delivery is moving and every 60 otherwise, pauses while the tab is + * hidden, and backs off up to 2 minutes when requests fail. A socket source can replace + * it later with the same two callbacks. + */ +export const ACTIVE_INTERVAL = 20000; +export const IDLE_INTERVAL = 60000; +export const MAX_BACKOFF = 120000; + +export default class TrackingLiveSource { + constructor(load, { isActive = () => true, documentRef = typeof document === 'undefined' ? null : document } = {}) { + this.load = load; + this.isActive = isActive; + this.document = documentRef; + this.timer = null; + this.failures = 0; + this.running = false; + this.onVisibility = () => this.visibilityChanged(); + } + + start(onUpdate, onState = () => {}) { + this.onUpdate = onUpdate; + this.onState = onState; + this.running = true; + this.document?.addEventListener('visibilitychange', this.onVisibility); + this.schedule(); + } + + stop() { + this.running = false; + clearTimeout(this.timer); + this.timer = null; + this.document?.removeEventListener('visibilitychange', this.onVisibility); + } + + get hidden() { + return this.document?.visibilityState === 'hidden'; + } + + get delay() { + if (this.failures > 0) { + return Math.min(MAX_BACKOFF, ACTIVE_INTERVAL * Math.pow(2, this.failures - 1)); + } + + return this.isActive() ? ACTIVE_INTERVAL : IDLE_INTERVAL; + } + + schedule() { + clearTimeout(this.timer); + if (!this.running || this.hidden) { + return; + } + + this.timer = setTimeout(() => this.tick(), this.delay); + } + + async tick() { + if (!this.running) { + return; + } + + try { + const result = await this.load(); + this.failures = 0; + this.onState('live'); + this.onUpdate(result); + } catch { + this.failures += 1; + this.onState('reconnecting'); + } + + this.schedule(); + } + + visibilityChanged() { + if (this.hidden) { + clearTimeout(this.timer); + this.onState?.('paused'); + return; + } + + this.tick(); + } +} + +/** + * An encoded polyline (precision 5, as Google Routes and OSRM return it) as [lat, lng] pairs. + */ +export function decodePolyline(encoded) { + if (typeof encoded !== 'string' || encoded === '') { + return []; + } + + const points = []; + let index = 0; + let lat = 0; + let lng = 0; + + while (index < encoded.length) { + const deltas = []; + for (let axis = 0; axis < 2; axis++) { + let result = 0; + let shift = 0; + let byte; + do { + byte = encoded.charCodeAt(index++) - 63; + result |= (byte & 0x1f) << shift; + shift += 5; + } while (byte >= 0x20 && index < encoded.length); + deltas.push(result & 1 ? ~(result >> 1) : result >> 1); + } + + lat += deltas[0]; + lng += deltas[1]; + points.push([lat / 1e5, lng / 1e5]); + } + + return points; +} diff --git a/app/components/tracking-page.js b/app/components/tracking-page.js new file mode 100644 index 000000000..d8a4008bb --- /dev/null +++ b/app/components/tracking-page.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-engine/components/tracking-page'; diff --git a/app/components/tracking-page/full-view.js b/app/components/tracking-page/full-view.js new file mode 100644 index 000000000..8d06863f6 --- /dev/null +++ b/app/components/tracking-page/full-view.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-engine/components/tracking-page/full-view'; diff --git a/app/components/tracking-page/map.js b/app/components/tracking-page/map.js new file mode 100644 index 000000000..d896cf896 --- /dev/null +++ b/app/components/tracking-page/map.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-engine/components/tracking-page/map'; diff --git a/app/components/tracking-page/order-list.js b/app/components/tracking-page/order-list.js new file mode 100644 index 000000000..ef5e5c7ef --- /dev/null +++ b/app/components/tracking-page/order-list.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-engine/components/tracking-page/order-list'; diff --git a/app/components/tracking-page/public-view.js b/app/components/tracking-page/public-view.js new file mode 100644 index 000000000..959bcb2c9 --- /dev/null +++ b/app/components/tracking-page/public-view.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-engine/components/tracking-page/public-view'; diff --git a/app/components/tracking-page/verify-panel.js b/app/components/tracking-page/verify-panel.js new file mode 100644 index 000000000..3cbb4e749 --- /dev/null +++ b/app/components/tracking-page/verify-panel.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/fleetops-engine/components/tracking-page/verify-panel'; diff --git a/app/utils/tracking-live-source.js b/app/utils/tracking-live-source.js new file mode 100644 index 000000000..d29f911a8 --- /dev/null +++ b/app/utils/tracking-live-source.js @@ -0,0 +1 @@ +export { default, decodePolyline, ACTIVE_INTERVAL, IDLE_INTERVAL, MAX_BACKOFF } from '@fleetbase/fleetops-engine/utils/tracking-live-source'; diff --git a/server/config/fleetops.php b/server/config/fleetops.php index 2ae3d6c09..798e4e351 100644 --- a/server/config/fleetops.php +++ b/server/config/fleetops.php @@ -88,6 +88,10 @@ 'route_cache_ttl_seconds' => env('TRACKING_ROUTE_CACHE_TTL_SECONDS', 600), 'stale_location_threshold_seconds' => env('TRACKING_STALE_LOCATION_THRESHOLD_SECONDS', 300), 'default_vehicle_speed_kph' => env('TRACKING_DEFAULT_VEHICLE_SPEED_KPH', 35), + // The public tracking page's device-session cookie. `lax` suits a console and API on + // the same site (console.example.com and api.example.com); use `none` (sent only over + // HTTPS) when they are on different domains. + 'cookie_same_site' => env('TRACKING_COOKIE_SAME_SITE', 'lax'), ], /* diff --git a/server/migrations/2026_10_08_000001_create_tracking_sessions_tables.php b/server/migrations/2026_10_08_000001_create_tracking_sessions_tables.php new file mode 100644 index 000000000..66b36fa21 --- /dev/null +++ b/server/migrations/2026_10_08_000001_create_tracking_sessions_tables.php @@ -0,0 +1,51 @@ +increments('id'); + $table->uuid('uuid')->unique(); + $table->string('token_hash', 64)->unique(); + $table->string('ip', 64)->nullable(); + $table->string('user_agent', 255)->nullable(); + $table->timestamp('last_seen_at')->nullable(); + $table->timestamp('expires_at')->nullable()->index(); + $table->timestamp('revoked_at')->nullable(); + $table->timestamps(); + }); + + Schema::create('tracking_session_grants', function (Blueprint $table) { + $table->increments('id'); + $table->uuid('uuid')->unique(); + $table->uuid('tracking_session_uuid')->index(); + $table->uuid('company_uuid')->nullable()->index(); + $table->uuid('order_uuid'); + $table->string('customer_type'); + $table->uuid('customer_uuid'); + $table->string('channel', 16)->nullable(); + $table->timestamp('expires_at')->nullable()->index(); + $table->timestamps(); + + $table->index(['tracking_session_uuid', 'order_uuid', 'customer_uuid'], 'tracking_session_grants_scope_index'); + }); + } + + public function down(): void + { + Schema::dropIfExists('tracking_session_grants'); + Schema::dropIfExists('tracking_sessions'); + } +}; diff --git a/server/src/Http/Controllers/Public/TrackingController.php b/server/src/Http/Controllers/Public/TrackingController.php new file mode 100644 index 000000000..eaba4c722 --- /dev/null +++ b/server/src/Http/Controllers/Public/TrackingController.php @@ -0,0 +1,517 @@ +resolver ??= new TrackingResolver(); + $this->access ??= new TrackingAccess(); + $this->view ??= new TrackingView(); + } + + /** + * The page's branding and what it offers, before any lookup. + */ + public function config(Request $request): JsonResponse + { + $slug = $this->slug($request); + if ($slug !== null) { + $companyUuid = $this->resolver->companyForSlug($slug); + if ($companyUuid === null) { + return $this->json(['error' => 'page_not_found'], 404); + } + + $config = $this->resolver->config($companyUuid); + + return $this->json([ + 'company' => $this->view->branding($config, $this->findCompany($companyUuid)), + 'org' => $slug, + 'sign_in' => $this->signInOffered($config), + 'session_hours' => $config['access']['session_hours'], + ]); + } + + $admin = $this->resolver->adminConfig(); + if (!$admin['generic_page']['enabled']) { + return $this->json(['error' => 'page_not_found'], 404); + } + + return $this->json([ + 'company' => $this->view->branding(['branding' => $admin['branding']], null, $this->instanceLogo()), + 'org' => null, + 'sign_in' => $this->customerPortalInstalled(), + 'session_hours' => 24, + ]); + } + + public function show(Request $request, string $number): JsonResponse + { + if (!$this->access->allowLookup($request)) { + return $this->notFound(); + } + + $target = $this->resolve($request, $number); + + return $target ? $this->json($this->payload($request, $target)) : $this->notFound(); + } + + public function sendCode(Request $request, string $number): JsonResponse + { + $target = $this->resolve($request, $number); + if (!$target) { + return $this->notFound(); + } + + $recipient = $this->recipientFor($target); + $channel = (string) $request->input('channel'); + $channels = collect($recipient->channels($target->config, $this->smsAvailable())); + $selected = $channels->firstWhere('type', $channel); + if (!$target->scope || !$selected) { + return $this->json(['error' => 'channel_unavailable'], 422); + } + + $claim = $this->access->claimSend($target->scope, $request); + if ($claim['status'] !== TrackingAccess::SEND_SENT) { + return $this->json(['error' => $claim['status'], 'retry_after' => $claim['retry_after']], $claim['status'] === TrackingAccess::SEND_PAUSED ? 423 : 429); + } + + $code = $this->access->issueCode($target->scope, $recipient->customer, $channel); + $this->deliver($channel, (string) $recipient->destination($channel), (string) $code->plainCode, $target); + + return $this->json([ + 'channel' => $channel, + 'masked' => $selected['masked'], + 'expires_in' => TrackingAccess::CODE_MINUTES * 60, + 'resend_after' => $claim['retry_after'], + ], 202); + } + + public function verifyCode(Request $request, string $number): JsonResponse + { + if (!$this->access->allowVerify($request)) { + return $this->json(['error' => 'limited', 'retry_after' => TrackingAccess::LOOKUP_WINDOW], 429); + } + + $target = $this->resolve($request, $number); + if (!$target || !$target->scope) { + return $this->notFound(); + } + + if ($paused = $this->access->pausedFor($target->scope)) { + return $this->json(['error' => 'paused', 'retry_after' => $paused], 423); + } + + $result = $this->access->checkCode($target->scope, preg_replace('/\D/', '', (string) $request->input('code'))); + + return match ($result['status']) { + VerificationCode::CHECK_VALID => $this->verified($request, $target, $result['channel'] ?? null), + VerificationCode::CHECK_LOCKED => $this->json(['error' => 'paused', 'retry_after' => TrackingAccess::PAUSE_SECONDS], 423), + VerificationCode::CHECK_EXPIRED => $this->json(['error' => 'expired'], 410), + default => $this->json(['error' => 'invalid', 'attempts_left' => $result['attempts_left']], 422), + }; + } + + /** + * Sign this device out: of one delivery with `?number=`, otherwise of every one. + */ + public function signOut(Request $request): JsonResponse + { + $number = $request->input('number'); + $target = $number ? $this->resolve($request, $number) : null; + $cookie = $this->access->revoke($request, $target?->scope); + + return $this->json(['status' => 'signed_out'], 200, $cookie); + } + + public function updateInstructions(Request $request, string $number): JsonResponse + { + $target = $this->resolve($request, $number); + $viewer = $target ? $this->viewerFor($request, $target) : null; + if (!$target || !$viewer || !$target->scope) { + return $this->notFound(); + } + + if (!data_get($target->config, 'visibility.instructions_edit', false)) { + return $this->json(['error' => 'not_allowed'], 403); + } + + $text = trim(mb_substr((string) $request->input('text', ''), 0, 500)); + $this->saveInstructions($target->order, $target->scope, $text); + + return $this->json($this->payload($request, $target)); + } + + public function proof(Request $request, string $number, string $id): JsonResponse + { + $target = $this->resolve($request, $number); + if (!$target || !$this->viewerFor($request, $target)) { + return $this->notFound(); + } + + $proof = $this->view->proofFor($target, $id); + if (!$proof) { + return $this->json(['error' => 'not_found'], 404); + } + + $url = data_get($proof, 'file.url') ?? (is_string($proof->raw_data) && $proof->raw_data !== '' ? $this->dataUrl($proof->raw_data) : null); + + return $url ? $this->json(['url' => $url, 'type' => TrackingView::proofType($proof)]) : $this->json(['error' => 'not_found'], 404); + } + + public function report(Request $request, string $number): JsonResponse + { + $target = $this->resolve($request, $number); + if (!$target || !$this->viewerFor($request, $target)) { + return $this->notFound(); + } + + $branding = $this->brandingFor($target); + $to = $branding['support_email']; + $message = trim(mb_substr((string) $request->input('message', ''), 0, 1000)); + if (!data_get($target->config, 'visibility.report_problem', true) || !$to || $message === '') { + return $this->json(['error' => 'not_allowed'], 422); + } + + if (!$this->access->allowReport($request, (string) $target->order->uuid)) { + return $this->json(['error' => 'limited'], 429); + } + + $this->sendReport($to, $target, $message); + + return $this->json(['status' => 'sent'], 202); + } + + public function socketToken(Request $request, string $number): JsonResponse + { + $target = $this->resolve($request, $number); + if (!$target || !$target->scope || !$this->viewerFor($request, $target) || !$this->socketsEnabled()) { + return $this->notFound(); + } + + return $this->json($this->socketTokenFor($target->scope)); + } + + /** + * A signed-in customer's orders, newest first. + */ + public function accountOrders(Request $request): JsonResponse + { + $contacts = $this->contactsFor($this->requestUser($request)); + if ($contacts === []) { + return $this->json(['error' => 'unauthenticated'], 401); + } + + return $this->json(['orders' => $this->ordersForContacts($contacts)]); + } + + protected function payload(Request $request, TrackingTarget $target): array + { + $branding = $this->brandingFor($target); + $viewer = $this->viewerFor($request, $target); + + if ($viewer) { + $expiresAt = $viewer === TrackingView::VIEWER_VERIFIED ? $this->access->grantExpiresAt($request, $target->scope) : null; + + return $this->view->fullView($target, $branding, $viewer, $expiresAt); + } + + $channels = $target->scope ? $this->recipientFor($target)->channels($target->config, $this->smsAvailable()) : []; + + return $this->view->publicView($target, $branding, $channels, $this->signInOffered($target->config)); + } + + protected function verified(Request $request, TrackingTarget $target, ?string $channel): JsonResponse + { + $hours = (int) data_get($target->config, 'access.session_hours', 24); + $cookie = $this->access->grant($request, $target->scope, $channel, $hours); + $data = $this->view->fullView($target, $this->brandingFor($target), TrackingView::VIEWER_VERIFIED, now()->addHours($hours)->toISOString()); + + return $this->json($data, 200, $cookie); + } + + /** + * Who is looking: staff of the owning company, a signed-in customer of this delivery, + * or a device verified for it; null for a visitor with only the tracking number. + */ + protected function viewerFor(Request $request, TrackingTarget $target): ?string + { + $user = $this->requestUser($request); + if ($user && data_get($user, 'type') !== 'customer' && data_get($user, 'company_uuid') === $target->companyUuid()) { + return TrackingView::VIEWER_STAFF; + } + + if ($user && $target->scope && in_array($target->scope->customer_uuid, $this->contactsFor($user), true)) { + return TrackingView::VIEWER_ACCOUNT; + } + + return $this->access->hasGrant($request, $target->scope) ? TrackingView::VIEWER_VERIFIED : null; + } + + protected function resolve(Request $request, mixed $number): ?TrackingTarget + { + return $this->resolver->resolve($number, $this->slug($request)); + } + + protected function slug(Request $request): ?string + { + $slug = $request->input('org'); + + return is_string($slug) && trim($slug) !== '' ? strtolower(trim($slug)) : null; + } + + protected function brandingFor(TrackingTarget $target): array + { + return $this->view->branding($target->config, $this->findCompany($target->companyUuid())); + } + + protected function signInOffered(array $config): bool + { + return (bool) data_get($config, 'access.account_sign_in', true) && $this->customerPortalInstalled(); + } + + protected function recipientFor(TrackingTarget $target): TrackingRecipient + { + return TrackingRecipient::for($target->scope, $this->fallbackPhone($target)); + } + + protected function fallbackPhone(TrackingTarget $target): ?string + { + return data_get($target->waypoint, 'place.phone') ?? data_get($target->order, 'payload.dropoff.phone'); + } + + protected function deliver(string $channel, string $destination, string $code, TrackingTarget $target): void + { + $name = $this->brandingFor($target)['name'] ?? 'Your delivery'; + $message = $code . ' is your ' . $name . ' delivery code. It expires in ' . TrackingAccess::CODE_MINUTES . ' minutes.'; + + if ($channel === TrackingRecipient::CHANNEL_SMS) { + $this->sendSms($destination, $message, $target); + + return; + } + + $this->sendEmail($destination, $code . ' is your ' . $name . ' delivery code', $message); + } + + protected function saveInstructions(Order $order, TrackingScope $scope, string $text): void + { + $instructions = (array) $order->getMeta('recipient_instructions', []); + $instructions[sha1($scope->key())] = $text; + $order->setMeta('recipient_instructions', $instructions); + $order->saveQuietly(); + } + + protected function dataUrl(string $raw): string + { + return str_starts_with($raw, 'data:') ? $raw : 'data:image/png;base64,' . $raw; + } + + protected function notFound(): JsonResponse + { + return $this->json(['error' => self::NOT_FOUND, 'code' => 'not_found'], 404); + } + + protected function json(array $data, int $status = 200, ?Cookie $cookie = null): JsonResponse + { + $response = new JsonResponse($data, $status); + if ($cookie) { + $response->headers->setCookie($cookie); + } + + return $response; + } + + /** + * Whether the instance can send SMS. Twilio is listed as always available, so it counts + * only with credentials; other providers report their own configuration. + */ + protected function smsAvailable(): bool + { + if (filled(config('services.twilio.sid')) && filled(config('services.twilio.token'))) { + return true; + } + + return collect($this->smsProviders()) + ->except(SmsService::PROVIDER_TWILIO) + ->contains(fn ($provider) => (bool) data_get($provider, 'available')); + } + + /** + * The contact uuids a signed-in customer user acts as. + * + * @return array + */ + protected function contactsFor(mixed $user): array + { + if (!$user || data_get($user, 'type') !== 'customer' || !data_get($user, 'uuid')) { + return []; + } + + return $this->contactUuidsForUser((string) data_get($user, 'uuid')); + } + + protected function contactUuidsForUser(string $userUuid): array + { + return Contact::withoutGlobalScopes()->where('user_uuid', $userUuid)->pluck('uuid')->all(); + } + + /** + * Each order a customer is on, with the tracking number that opens their part of it. + * + * @param array $contacts + */ + protected function ordersForContacts(array $contacts): array + { + $payloads = Waypoint::withoutGlobalScopes()->whereIn('customer_uuid', $contacts)->pluck('payload_uuid')->filter()->unique()->values()->all(); + $orders = Order::withoutGlobalScopes() + ->with(['trackingNumber']) + ->where(fn ($query) => $query->whereIn('customer_uuid', $contacts)->orWhereIn('payload_uuid', $payloads)) + ->latest() + ->limit(50) + ->get(); + + return $orders->map(function (Order $order) use ($contacts) { + $waypoint = in_array($order->customer_uuid, $contacts, true) ? null : Waypoint::withoutGlobalScopes()->with('trackingNumber')->where('payload_uuid', $order->payload_uuid)->whereIn('customer_uuid', $contacts)->first(); + $first = Entity::withoutGlobalScopes()->where('payload_uuid', $order->payload_uuid)->value('name'); + + return [ + 'tracking_number' => $waypoint ? data_get($waypoint, 'trackingNumber.tracking_number') : data_get($order, 'trackingNumber.tracking_number'), + 'title' => $first, + 'status' => $order->status, + 'stage' => $this->view->stage($order, null), + 'updated_at' => $order->updated_at ? $order->updated_at->toISOString() : null, + ]; + })->filter(fn ($row) => !empty($row['tracking_number']))->values()->all(); + } + + protected function findCompany(?string $companyUuid): mixed + { + return $companyUuid ? Company::where('uuid', $companyUuid)->first() : null; + } + + protected function sendSms(string $phone, string $message, TrackingTarget $target): void + { + $company = $this->findCompany($target->companyUuid()); + $options = []; + if (data_get($company, 'options.alpha_numeric_sender_id_enabled') && data_get($company, 'options.alpha_numeric_sender_id')) { + $options['twilioParams'] = ['from' => data_get($company, 'options.alpha_numeric_sender_id')]; + } + + $this->smsService()->send($phone, $message, $options); + } + + protected function sendEmail(string $email, string $subject, string $message): void + { + $this->mail($email, $subject, $message); + } + + protected function sendReport(string $to, TrackingTarget $target, string $message): void + { + $number = $target->trackingNumber->tracking_number; + + $this->mail($to, 'Problem reported for ' . $number, 'A customer reported a problem with delivery ' . $number . ":\n\n" . $message); + } + + protected function socketsEnabled(): bool + { + return TrackingPublisher::enabled(); + } + + // Environment seams: tests replace these. + + protected function socketTokenFor(TrackingScope $scope): array + { + // @codeCoverageIgnoreStart + // Signs with the instance's socket auth key. + return TrackingChannel::token($scope); + // @codeCoverageIgnoreEnd + } + + protected function mail(string $to, string $subject, string $body): void + { + // @codeCoverageIgnoreStart + Mail::raw($body, fn ($mail) => $mail->to($to)->subject($subject)); + // @codeCoverageIgnoreEnd + } + + protected function smsService(): SmsService + { + // @codeCoverageIgnoreStart + return app(SmsService::class); + // @codeCoverageIgnoreEnd + } + + protected function smsProviders(): array + { + // @codeCoverageIgnoreStart + return (new SmsService())->getAvailableProviders(); + // @codeCoverageIgnoreEnd + } + + protected function requestUser(Request $request): mixed + { + // @codeCoverageIgnoreStart + // A signed-in console or customer-portal user, from the bearer token the page sends. + try { + return auth('sanctum')->user(); + } catch (\Throwable) { + return null; + } + // @codeCoverageIgnoreEnd + } + + protected function customerPortalInstalled(): bool + { + // @codeCoverageIgnoreStart + return collect(Utils::getInstalledFleetbaseExtensions())->contains(fn ($package) => data_get($package, 'name') === 'fleetbase/customer-portal-api'); + // @codeCoverageIgnoreEnd + } + + protected function instanceLogo(): ?string + { + // @codeCoverageIgnoreStart + return data_get(Setting::getBranding(), 'logo_url'); + // @codeCoverageIgnoreEnd + } +} diff --git a/server/src/Http/Resources/v1/TrackingNumber.php b/server/src/Http/Resources/v1/TrackingNumber.php index 2f5c62ef6..6522ecd7f 100644 --- a/server/src/Http/Resources/v1/TrackingNumber.php +++ b/server/src/Http/Resources/v1/TrackingNumber.php @@ -38,7 +38,7 @@ public function toArray($request) // Labels printed before it encode the owner uuid; resolvers accept both. 'qr_code_content' => $this->when($this->tracking_number, fn () => TrackingCode::qrContent($this->tracking_number, Utils::get($this->owner, 'public_id'))), 'barcode' => $this->barcode, - 'url' => Utils::consoleUrl(TrackingCode::PATH, [TrackingCode::TRACKING_PARAM => $this->tracking_number]), + 'url' => TrackingCode::pageUrl($this->tracking_number, $this->company_uuid), 'type' => Utils::getTypeFromClassName($this->owner_type), 'updated_at' => $this->updated_at, 'created_at' => $this->created_at, diff --git a/server/src/Models/TrackingSession.php b/server/src/Models/TrackingSession.php new file mode 100644 index 000000000..d1d50f29e --- /dev/null +++ b/server/src/Models/TrackingSession.php @@ -0,0 +1,33 @@ + 'datetime', + 'expires_at' => 'datetime', + 'revoked_at' => 'datetime', + ]; + + protected $hidden = ['token_hash']; + + public function grants(): HasMany + { + return $this->hasMany(TrackingSessionGrant::class, 'tracking_session_uuid', 'uuid'); + } +} diff --git a/server/src/Models/TrackingSessionGrant.php b/server/src/Models/TrackingSessionGrant.php new file mode 100644 index 000000000..4f41f4390 --- /dev/null +++ b/server/src/Models/TrackingSessionGrant.php @@ -0,0 +1,22 @@ + 'datetime', + ]; +} diff --git a/server/src/Notifications/OrderAssigned.php b/server/src/Notifications/OrderAssigned.php index d1648d93a..797b0fd28 100644 --- a/server/src/Notifications/OrderAssigned.php +++ b/server/src/Notifications/OrderAssigned.php @@ -129,7 +129,7 @@ public function toMail($notifiable) $message->line('Dispatch is scheduled for ' . $this->order->scheduled_at); } - $message->action('Track Order', Utils::consoleUrl(TrackingCode::PATH, [TrackingCode::TRACKING_PARAM => $this->order->trackingNumber->tracking_number])); + $message->action('Track Order', TrackingCode::pageUrl($this->order->trackingNumber->tracking_number, data_get($this, 'order.company_uuid'))); return $message; } diff --git a/server/src/Notifications/OrderCanceled.php b/server/src/Notifications/OrderCanceled.php index 05153856f..a1827295b 100644 --- a/server/src/Notifications/OrderCanceled.php +++ b/server/src/Notifications/OrderCanceled.php @@ -139,7 +139,7 @@ public function toMail($notifiable) ->line($this->message) ->line($this->reason) ->line('No further action is necessary.') - ->action('Track Order', Utils::consoleUrl(TrackingCode::PATH, [TrackingCode::TRACKING_PARAM => $this->getTrackingNumber()])); + ->action('Track Order', TrackingCode::pageUrl($this->getTrackingNumber(), data_get($this, 'order.company_uuid'))); } /** diff --git a/server/src/Notifications/OrderCompleted.php b/server/src/Notifications/OrderCompleted.php index b3fc1736a..59428b8e1 100644 --- a/server/src/Notifications/OrderCompleted.php +++ b/server/src/Notifications/OrderCompleted.php @@ -132,7 +132,7 @@ public function toMail($notifiable) ->subject($this->title) ->line($this->message) ->line('No further action is necessary.') - ->action('Track Order', Utils::consoleUrl(TrackingCode::PATH, [TrackingCode::TRACKING_PARAM => $this->getTrackingNumber()])); + ->action('Track Order', TrackingCode::pageUrl($this->getTrackingNumber(), data_get($this, 'order.company_uuid'))); } /** diff --git a/server/src/Notifications/OrderDispatchFailed.php b/server/src/Notifications/OrderDispatchFailed.php index 3ba38457d..eddcb86f0 100644 --- a/server/src/Notifications/OrderDispatchFailed.php +++ b/server/src/Notifications/OrderDispatchFailed.php @@ -128,6 +128,6 @@ public function toMail($notifiable) return (new MailMessage()) ->subject($this->title) ->line($this->message) - ->action('Track Order', Utils::consoleUrl(TrackingCode::PATH, [TrackingCode::TRACKING_PARAM => $this->order->trackingNumber->tracking_number])); + ->action('Track Order', TrackingCode::pageUrl($this->order->trackingNumber->tracking_number, data_get($this, 'order.company_uuid'))); } } diff --git a/server/src/Notifications/OrderDispatched.php b/server/src/Notifications/OrderDispatched.php index c57eae2c8..c1db4717b 100644 --- a/server/src/Notifications/OrderDispatched.php +++ b/server/src/Notifications/OrderDispatched.php @@ -171,7 +171,7 @@ public function toMail($notifiable) return (new MailMessage()) ->subject($this->title) ->line($this->message) - ->action('Track Order', Utils::consoleUrl(TrackingCode::PATH, [TrackingCode::TRACKING_PARAM => $this->getTrackingNumber()])); + ->action('Track Order', TrackingCode::pageUrl($this->getTrackingNumber(), data_get($this, 'order.company_uuid'))); } /** diff --git a/server/src/Notifications/OrderFailed.php b/server/src/Notifications/OrderFailed.php index 010ca0100..705a5bd1d 100644 --- a/server/src/Notifications/OrderFailed.php +++ b/server/src/Notifications/OrderFailed.php @@ -139,7 +139,7 @@ public function toMail($notifiable) ->line($this->message) ->line($this->reason) ->line('No further action is necessary.') - ->action('Track Order', Utils::consoleUrl(TrackingCode::PATH, [TrackingCode::TRACKING_PARAM => $this->getTrackingNumber()])); + ->action('Track Order', TrackingCode::pageUrl($this->getTrackingNumber(), data_get($this, 'order.company_uuid'))); } /** diff --git a/server/src/Notifications/WaypointCompleted.php b/server/src/Notifications/WaypointCompleted.php index 98555072a..d95d5fea8 100644 --- a/server/src/Notifications/WaypointCompleted.php +++ b/server/src/Notifications/WaypointCompleted.php @@ -133,7 +133,7 @@ public function toMail($notifiable) ->subject($this->title) ->line($this->message) ->line('No further action is necessary.') - ->action('Track Order', Utils::consoleUrl(TrackingCode::PATH, [TrackingCode::TRACKING_PARAM => $this->waypoint->trackingNumber->tracking_number])); + ->action('Track Order', TrackingCode::pageUrl($this->waypoint->trackingNumber->tracking_number, data_get($this, 'waypoint.company_uuid'))); } /** diff --git a/server/src/Support/TrackingCode.php b/server/src/Support/TrackingCode.php index 53d5e7653..8fc6600b0 100644 --- a/server/src/Support/TrackingCode.php +++ b/server/src/Support/TrackingCode.php @@ -41,6 +41,27 @@ class TrackingCode public const PATH = '~/track-order'; public const TRACKING_PARAM = 'order'; + /** + * The tracking page link for emails, notifications and the API: the company's own page + * when its settings send links there, otherwise the shared page. + */ + public static function pageUrl(string $trackingNumber, ?string $companyUuid = null): string + { + $query = [static::TRACKING_PARAM => $trackingNumber]; + + try { + $slug = $companyUuid ? app(TrackingPage\TrackingResolver::class)->linkSlugFor($companyUuid) : null; + } catch (\Throwable) { + $slug = null; + } + + if ($slug) { + $query['org'] = $slug; + } + + return Utils::consoleUrl(static::PATH, $query); + } + /** * The QR content for a tracking number. */ diff --git a/server/src/Support/TrackingPage/TrackingAccess.php b/server/src/Support/TrackingPage/TrackingAccess.php new file mode 100644 index 000000000..f6872871a --- /dev/null +++ b/server/src/Support/TrackingPage/TrackingAccess.php @@ -0,0 +1,344 @@ +attempt('trk-lookup:' . $request->ip(), self::LOOKUPS_PER_IP, self::LOOKUP_WINDOW); + } + + /** + * Whether this address may check another code, counting it. + */ + public function allowVerify(Request $request): bool + { + return $this->attempt('trk-verify:' . $request->ip(), self::VERIFIES_PER_IP, self::LOOKUP_WINDOW); + } + + /** + * Whether this address may report another problem with this order, counting it. + */ + public function allowReport(Request $request, string $orderUuid): bool + { + return $this->attempt('trk-report:' . $orderUuid . ':' . $request->ip(), self::REPORTS_PER_HOUR, 3600); + } + + /** + * Whether a scope's codes are paused after too many wrong ones, and for how long. + */ + public function pausedFor(TrackingScope $scope): int + { + $key = $this->pauseKey($scope); + + return $this->limiter()->tooManyAttempts($key, 1) ? max(1, $this->limiter()->availableIn($key)) : 0; + } + + /** + * Check the limits for sending a code and, when they allow it, count the send. + * + * @return array{status: string, retry_after: int} + */ + public function claimSend(TrackingScope $scope, Request $request): array + { + if ($paused = $this->pausedFor($scope)) { + return ['status' => self::SEND_PAUSED, 'retry_after' => $paused]; + } + + $cooldown = 'trk-resend:' . $scope->key(); + if ($this->limiter()->tooManyAttempts($cooldown, 1)) { + return ['status' => self::SEND_COOLDOWN, 'retry_after' => max(1, $this->limiter()->availableIn($cooldown))]; + } + + foreach (['trk-send-scope:' . $scope->key() => self::SENDS_PER_SCOPE, 'trk-send-ip:' . $request->ip() => self::SENDS_PER_IP] as $key => $max) { + if ($this->limiter()->tooManyAttempts($key, $max)) { + return ['status' => self::SEND_LIMITED, 'retry_after' => max(1, $this->limiter()->availableIn($key))]; + } + } + + $this->limiter()->hit($cooldown, self::RESEND_SECONDS); + $this->limiter()->hit('trk-send-scope:' . $scope->key(), self::SEND_WINDOW); + $this->limiter()->hit('trk-send-ip:' . $request->ip(), self::SEND_WINDOW); + + return ['status' => self::SEND_SENT, 'retry_after' => self::RESEND_SECONDS]; + } + + /** + * A new code for a scope, voiding the scope's earlier unused ones. The plain code is on `plainCode`. + */ + public function issueCode(TrackingScope $scope, mixed $recipient, string $channel): VerificationCode + { + $this->voidCodes($scope); + + return $this->createCode($recipient, [ + 'expireAfter' => Carbon::now()->addMinutes(self::CODE_MINUTES), + 'meta' => [ + 'scope' => $scope->key(), + 'channel' => $channel, + 'company_uuid' => $scope->company_uuid, + ], + ]); + } + + /** + * Check a code for a scope. + * + * @return array{status: string, attempts_left?: int, channel?: string|null} + */ + public function checkCode(TrackingScope $scope, string $code): array + { + $verificationCode = $this->latestCode($scope); + if (!$verificationCode) { + return ['status' => VerificationCode::CHECK_EXPIRED]; + } + + $result = $verificationCode->check($code, self::MAX_ATTEMPTS); + if ($result === VerificationCode::CHECK_VALID) { + $channel = $verificationCode->getMeta('channel'); + $this->deleteCode($verificationCode); + + return ['status' => $result, 'channel' => $channel]; + } + + if ($result === VerificationCode::CHECK_LOCKED) { + $this->limiter()->hit($this->pauseKey($scope), self::PAUSE_SECONDS); + } + + return ['status' => $result, 'attempts_left' => $verificationCode->attemptsLeft(self::MAX_ATTEMPTS)]; + } + + /** + * The device session the request's cookie names, when it is live. + */ + public function session(Request $request): ?TrackingSession + { + $token = $request->cookie(self::COOKIE); + if (!is_string($token) || strlen($token) < 32) { + return null; + } + + $session = $this->findSession(hash('sha256', $token)); + if (!$session || $session->revoked_at || ($session->expires_at && $session->expires_at->isPast())) { + return null; + } + + return $session; + } + + /** + * Whether the request's device holds a live grant for this scope. + */ + public function hasGrant(Request $request, ?TrackingScope $scope): bool + { + if (!$scope) { + return false; + } + + $session = $this->session($request); + + return $session !== null && $this->findGrant($session, $scope) !== null; + } + + /** + * When the device's grant for this scope ends. + */ + public function grantExpiresAt(Request $request, TrackingScope $scope): ?string + { + $session = $this->session($request); + $grant = $session ? $this->findGrant($session, $scope) : null; + + return $grant?->expires_at?->toISOString(); + } + + /** + * Give the device a grant for a scope, starting a session when it has none. + * + * @return Cookie|null the cookie to set when a new session was started + */ + public function grant(Request $request, TrackingScope $scope, ?string $channel, int $hours): ?Cookie + { + $expiresAt = Carbon::now()->addHours($hours); + $session = $this->session($request); + $cookie = null; + + if (!$session) { + $token = Str::random(40); + $session = $this->createSession([ + 'token_hash' => hash('sha256', $token), + 'ip' => $request->ip(), + 'user_agent' => Str::limit((string) $request->userAgent(), 250, ''), + 'last_seen_at' => Carbon::now(), + 'expires_at' => $expiresAt, + ]); + $cookie = $this->cookie($token, $hours * 60); + } elseif (!$session->expires_at || $session->expires_at->lt($expiresAt)) { + $this->saveSession($session, ['expires_at' => $expiresAt, 'last_seen_at' => Carbon::now()]); + $cookie = $this->cookie((string) $request->cookie(self::COOKIE), $hours * 60); + } + + $this->saveGrant($session, $scope, $channel, $expiresAt); + + return $cookie; + } + + /** + * Sign the device out: of one scope, or of every scope when none is given. + * + * @return Cookie|null an expired cookie when the whole session ended + */ + public function revoke(Request $request, ?TrackingScope $scope = null): ?Cookie + { + $session = $this->session($request); + if (!$session) { + return $this->cookie('', -1); + } + + if ($scope) { + $this->deleteGrant($session, $scope); + + return null; + } + + $this->saveSession($session, ['revoked_at' => Carbon::now()]); + + return $this->cookie('', -1); + } + + public function cookie(string $value, int $minutes): Cookie + { + $sameSite = strtolower((string) config('fleetops.tracking.cookie_same_site', 'lax')); + $sameSite = in_array($sameSite, ['lax', 'strict', 'none'], true) ? $sameSite : 'lax'; + $secure = $sameSite === 'none' || (bool) config('session.secure', false); + + return Cookie::create(self::COOKIE, $value, $minutes < 0 ? 1 : Carbon::now()->addMinutes($minutes)->getTimestamp(), '/', config('session.domain'), $secure, true, false, $sameSite); + } + + protected function attempt(string $key, int $max, int $decay): bool + { + if ($this->limiter()->tooManyAttempts($key, $max)) { + return false; + } + + $this->limiter()->hit($key, $decay); + + return true; + } + + protected function pauseKey(TrackingScope $scope): string + { + return 'trk-pause:' . $scope->key(); + } + + protected function limiter(): RateLimiter + { + return $this->limiter ??= app(RateLimiter::class); + } + + protected function voidCodes(TrackingScope $scope): void + { + VerificationCode::withoutGlobalScope(ExpiryScope::class)->where('for', self::CODE_PURPOSE)->where('meta->scope', $scope->key())->delete(); + } + + protected function createCode(mixed $recipient, array $options): VerificationCode + { + return VerificationCode::issue($recipient, self::CODE_PURPOSE, $options); + } + + protected function latestCode(TrackingScope $scope): ?VerificationCode + { + return VerificationCode::withoutGlobalScope(ExpiryScope::class)->where('for', self::CODE_PURPOSE)->where('meta->scope', $scope->key())->latest()->first(); + } + + protected function deleteCode(VerificationCode $verificationCode): void + { + $verificationCode->delete(); + } + + protected function findSession(string $tokenHash): ?TrackingSession + { + return TrackingSession::where('token_hash', $tokenHash)->first(); + } + + protected function createSession(array $attributes): TrackingSession + { + return TrackingSession::create($attributes); + } + + protected function saveSession(TrackingSession $session, array $attributes): void + { + $session->forceFill($attributes)->save(); + } + + protected function findGrant(TrackingSession $session, TrackingScope $scope): ?TrackingSessionGrant + { + return TrackingSessionGrant::where('tracking_session_uuid', $session->uuid) + ->where('order_uuid', $scope->order_uuid) + ->where('customer_type', $scope->customer_type) + ->where('customer_uuid', $scope->customer_uuid) + ->where('expires_at', '>', Carbon::now()) + ->first(); + } + + protected function saveGrant(TrackingSession $session, TrackingScope $scope, ?string $channel, Carbon $expiresAt): void + { + TrackingSessionGrant::updateOrCreate( + ['tracking_session_uuid' => $session->uuid, 'order_uuid' => $scope->order_uuid, 'customer_type' => $scope->customer_type, 'customer_uuid' => $scope->customer_uuid], + ['company_uuid' => $scope->company_uuid, 'channel' => $channel, 'expires_at' => $expiresAt] + ); + } + + protected function deleteGrant(TrackingSession $session, TrackingScope $scope): void + { + TrackingSessionGrant::where('tracking_session_uuid', $session->uuid) + ->where('order_uuid', $scope->order_uuid) + ->where('customer_type', $scope->customer_type) + ->where('customer_uuid', $scope->customer_uuid) + ->delete(); + } +} diff --git a/server/src/Support/TrackingPage/TrackingRecipient.php b/server/src/Support/TrackingPage/TrackingRecipient.php new file mode 100644 index 000000000..b660f44aa --- /dev/null +++ b/server/src/Support/TrackingPage/TrackingRecipient.php @@ -0,0 +1,111 @@ +customer_type, Contact::class, true) => Contact::class, + is_a($scope->customer_type, Vendor::class, true) => Vendor::class, + default => null, + }; + + return $class ? $class::withoutGlobalScopes()->where('uuid', $scope->customer_uuid)->first() : null; + } + + /** + * The channels a code can go out on, given the company's settings and the instance. + * + * @return array + */ + public function channels(array $config, bool $smsAvailable): array + { + $channels = []; + if ($this->phone && $smsAvailable && data_get($config, 'access.channels.sms', true)) { + $channels[] = ['type' => self::CHANNEL_SMS, 'masked' => static::maskPhone($this->phone)]; + } + + if ($this->email && data_get($config, 'access.channels.email', true)) { + $channels[] = ['type' => self::CHANNEL_EMAIL, 'masked' => static::maskEmail($this->email)]; + } + + return $channels; + } + + public function destination(string $channel): ?string + { + return $channel === self::CHANNEL_SMS ? $this->phone : ($channel === self::CHANNEL_EMAIL ? $this->email : null); + } + + /** + * `โ€ขโ€ขโ€ขโ€ข โ€ขโ€ข 4821`: only the last four digits. + */ + public static function maskPhone(string $phone): string + { + $digits = preg_replace('/\D/', '', $phone); + + return 'โ€ขโ€ขโ€ขโ€ข โ€ขโ€ข ' . substr($digits, -4); + } + + /** + * `mโ€ขโ€ขโ€ข@gโ€ขโ€ขโ€ข.com`: the first letter of each part and the top-level domain. + */ + public static function maskEmail(string $email): string + { + [$local, $domain] = explode('@', $email, 2); + $tld = str_contains($domain, '.') ? substr($domain, strrpos($domain, '.')) : ''; + + return mb_substr($local, 0, 1) . 'โ€ขโ€ขโ€ข@' . mb_substr($domain, 0, 1) . 'โ€ขโ€ขโ€ข' . $tld; + } + + protected static function validEmail(mixed $value): ?string + { + $value = is_string($value) ? trim($value) : ''; + + return $value !== '' && filter_var($value, FILTER_VALIDATE_EMAIL) ? $value : null; + } + + protected static function validPhone(mixed $value): ?string + { + $value = is_string($value) ? trim($value) : ''; + + return strlen(preg_replace('/\D/', '', $value)) >= 6 ? $value : null; + } +} diff --git a/server/src/Support/TrackingPage/TrackingResolver.php b/server/src/Support/TrackingPage/TrackingResolver.php new file mode 100644 index 000000000..09b3091fd --- /dev/null +++ b/server/src/Support/TrackingPage/TrackingResolver.php @@ -0,0 +1,201 @@ +companyForSlug($slug); + if ($companyUuid === null) { + return null; + } + } + + $trackingNumber = $this->findTrackingNumber($number, $companyUuid); + $target = $trackingNumber ? $this->targetFor($trackingNumber) : null; + if ($target === null) { + return null; + } + + $config = $this->config($target->companyUuid()); + if ($slug === null && (!$config['generic_page']['allowed'] || !$this->adminConfig()['generic_page']['enabled'])) { + return null; + } + + if ($companyUuid !== null && $target->companyUuid() !== $companyUuid) { + return null; + } + + return $target->withConfig($config); + } + + /** + * The company whose enabled page lives at this slug. + */ + public function companyForSlug(?string $slug): ?string + { + $slug = strtolower(trim((string) $slug)); + if (TrackingPageConfig::slugError($slug) !== null) { + return null; + } + + $companyUuid = $this->lookupSetting(TrackingPageConfig::SLUG_INDEX_PREFIX . $slug); + if (!is_string($companyUuid) || $companyUuid === '') { + return null; + } + + $config = $this->config($companyUuid); + + return $config['org_page']['enabled'] && $config['org_page']['slug'] === $slug ? $companyUuid : null; + } + + /** + * The order and customer a tracking number opens, from whatever owns it. + */ + public function targetFor(TrackingNumber $trackingNumber): ?TrackingTarget + { + $owner = $this->ownerOf($trackingNumber); + + if ($owner instanceof Order) { + return new TrackingTarget($owner, TrackingScope::forOrder($owner), $trackingNumber); + } + + if ($owner instanceof Waypoint) { + $order = $this->orderForPayload($owner->payload_uuid); + + return $order ? new TrackingTarget($order, TrackingScope::forStop($order, $owner), $trackingNumber, TrackingTarget::KIND_STOP, $owner) : null; + } + + if ($owner instanceof Entity) { + $order = $this->orderForPayload($owner->payload_uuid); + if (!$order) { + return null; + } + + $waypoint = $this->waypointForEntity($owner); + + return new TrackingTarget($order, TrackingScope::forStop($order, $waypoint), $trackingNumber, TrackingTarget::KIND_ITEM, $waypoint); + } + + if ($owner instanceof Place) { + // A service stop's pickup or dropoff number: the stop belongs to the order's customer. + $order = $this->orderForPlaceTrackingNumber($trackingNumber->uuid); + + return $order ? new TrackingTarget($order, TrackingScope::forOrder($order), $trackingNumber, TrackingTarget::KIND_STOP) : null; + } + + return null; + } + + /** + * The slug to put in a company's tracking links, or null to link the shared page. + */ + public function linkSlugFor(string $companyUuid): ?string + { + $config = $this->config($companyUuid); + + return $config['links']['target'] !== 'generic' && $config['org_page']['enabled'] ? $config['org_page']['slug'] : null; + } + + public function config(?string $companyUuid): array + { + return TrackingPageConfig::sanitize( + (array) $this->lookupSetting('company.' . $companyUuid . '.' . TrackingPageConfig::SETTING_KEY, []), + $this->adminConfig() + ); + } + + public function adminConfig(): array + { + return TrackingPageConfig::sanitizeAdmin((array) $this->lookupSetting(TrackingPageConfig::ADMIN_SETTING_KEY, [])); + } + + protected function findTrackingNumber(string $number, ?string $companyUuid): ?TrackingNumber + { + $query = TrackingNumber::withoutGlobalScopes()->where('tracking_number', $number); + if ($companyUuid !== null) { + $query->where('company_uuid', $companyUuid); + } + + return $query->first(); + } + + protected function ownerOf(TrackingNumber $trackingNumber): mixed + { + return $trackingNumber->owner; + } + + protected function orderForPayload(?string $payloadUuid): ?Order + { + return $payloadUuid ? Order::withoutGlobalScopes()->where('payload_uuid', $payloadUuid)->first() : null; + } + + protected function waypointForEntity(Entity $entity): ?Waypoint + { + if (!$entity->payload_uuid || !$entity->destination_uuid) { + return null; + } + + return Waypoint::withoutGlobalScopes()->where('payload_uuid', $entity->payload_uuid)->where('place_uuid', $entity->destination_uuid)->first(); + } + + protected function orderForPlaceTrackingNumber(string $trackingNumberUuid): ?Order + { + $payloadUuid = Payload::withoutGlobalScopes() + ->where('pickup_tracking_number_uuid', $trackingNumberUuid) + ->orWhere('dropoff_tracking_number_uuid', $trackingNumberUuid) + ->value('uuid'); + + return $this->orderForPayload($payloadUuid); + } + + protected function lookupSetting(string $key, mixed $default = null): mixed + { + return Setting::lookup($key, $default); + } +} diff --git a/server/src/Support/TrackingPage/TrackingTarget.php b/server/src/Support/TrackingPage/TrackingTarget.php new file mode 100644 index 000000000..9effbe974 --- /dev/null +++ b/server/src/Support/TrackingPage/TrackingTarget.php @@ -0,0 +1,40 @@ +order, $this->scope, $this->trackingNumber, $this->kind, $this->waypoint, $config); + } + + public function companyUuid(): ?string + { + return $this->order->company_uuid; + } +} diff --git a/server/src/Support/TrackingPage/TrackingView.php b/server/src/Support/TrackingPage/TrackingView.php new file mode 100644 index 000000000..c2b9e4ea4 --- /dev/null +++ b/server/src/Support/TrackingPage/TrackingView.php @@ -0,0 +1,408 @@ + $branding['display_name'] ?? data_get($company, 'name'), + 'logo_url' => data_get($company, 'logo_uuid') ? data_get($company, 'logo_url') : $instanceLogo, + 'accent' => $accent, + 'accent_dark' => $branding['accent_dark'] ?? null, + 'ink' => TrackingPageConfig::inkFor($accent), + 'support_phone' => $branding['support_phone'] ?? data_get($company, 'phone'), + 'support_email' => $branding['support_email'] ?? null, + 'website' => $branding['website'] ?? data_get($company, 'website_url'), + 'powered_by' => (bool) ($branding['powered_by'] ?? true), + 'theme' => $branding['theme'] ?? 'system', + 'default_locale' => $branding['default_locale'] ?? 'en-us', + 'locales' => $branding['locales'] ?? TrackingPageConfig::LOCALES, + ]; + } + + /** + * Level 0: what a tracking number alone shows. + * + * @param array $channels the masked channels a code can go out on + */ + public function publicView(TrackingTarget $target, array $branding, array $channels, bool $signIn): array + { + $order = $target->order; + $public = (bool) data_get($target->config, 'access.public_status', true); + $update = $target->scope ? new TrackingUpdate($order, $target->scope, $this->stopsFor($order)) : null; + + return [ + 'level' => 0, + 'tracking_number' => $target->trackingNumber->tracking_number, + 'company' => $branding, + 'stage' => $public ? $this->stage($order, $update) : null, + 'last_update_on' => $public && $order->updated_at ? Carbon::parse($order->updated_at)->toDateString() : null, + 'verify' => ['channels' => $channels], + 'sign_in' => $signIn, + ]; + } + + /** + * Levels 1 and 2: one customer's view of the order. + */ + public function fullView(TrackingTarget $target, array $branding, string $viewer, ?string $expiresAt = null): array + { + $order = $target->order; + $config = $target->config; + $scope = $target->scope ?? new TrackingScope((string) $order->uuid, Order::class, (string) $order->uuid, $order); + $stops = $this->stopsFor($order); + $tracker = $this->trackerFor($order); + $update = $this->updateFor($order, $scope, $stops, $tracker); + $data = $update->toArray()['data']; + $own = $update->ownStops(); + $placeUuids = $own->map(fn (TrackingStop $stop) => $stop->place?->uuid)->filter()->values()->all(); + $entities = $this->entitiesFor($order, $placeUuids, $scope); + $showMap = (bool) data_get($config, 'visibility.map', true); + $location = $showMap ? $data['location'] : null; + $stopsView = $this->stops($own, collect($data['stops'])); + + return [ + 'level' => $viewer === self::VIEWER_ACCOUNT ? 2 : 1, + 'viewer' => $viewer, + 'tracking_number' => $target->trackingNumber->tracking_number, + 'company' => $branding, + 'stage' => $this->stage($order, $update), + 'status' => [ + 'code' => $data['order']['status'], + 'delayed' => (bool) data_get($tracker, 'insights.is_delayed', false), + 'confidence' => data_get($tracker, 'confidence'), + ], + 'scheduled_at' => $this->iso($order->scheduled_at), + 'time_window' => ['start' => $this->iso($order->time_window_start), 'end' => $this->iso($order->time_window_end)], + 'route' => $data['route'], + 'stops' => $stopsView, + 'focus_stop' => $target->waypoint?->public_id, + 'eta' => $this->eta($stopsView, $tracker, $update), + 'timeline' => $this->timeline($data), + 'driver' => $this->driver($data['driver'], $config), + 'location' => $location, + 'map' => $showMap ? $this->map($stops, $own, $tracker, $location) : null, + 'items' => data_get($config, 'visibility.items', true) ? $this->items($entities, $viewer === self::VIEWER_ACCOUNT && data_get($config, 'visibility.item_prices', false) && $scope->isOrderCustomer($order)) : [], + 'proofs' => $this->proofs($order, $own, $entities, $scope, $config), + 'instructions' => [ + 'text' => $this->instructionsFor($order, $scope), + 'editable' => (bool) data_get($config, 'visibility.instructions_edit', false), + ], + 'actions' => [ + 'report' => (bool) data_get($config, 'visibility.report_problem', true), + 'driver_contact' => data_get($config, 'visibility.driver_contact', 'company'), + ], + 'session' => ['expires_at' => $expiresAt], + 'stale_after' => $this->staleAfter($order), + ]; + } + + /** + * The coarse stage: preparing, scheduled, dispatched, in_transit, delivered, issue or canceled. + */ + public function stage(Order $order, ?TrackingUpdate $update): string + { + $status = strtolower((string) $order->status); + if ($status === 'canceled') { + return 'canceled'; + } + + if (in_array($status, self::ISSUE_CODES, true)) { + return 'issue'; + } + + $own = $update ? $update->ownStops()->filter(fn (TrackingStop $stop) => $stop->type !== 'pickup') : collect(); + if ($status === 'completed' || ($own->isNotEmpty() && $own->every(fn (TrackingStop $stop) => $stop->completed))) { + return 'delivered'; + } + + if ($update?->isEnRoute() || $order->started) { + return 'in_transit'; + } + + if ($order->dispatched) { + return 'dispatched'; + } + + return $order->scheduled_at && Carbon::parse($order->scheduled_at)->isFuture() ? 'scheduled' : 'preparing'; + } + + /** + * The customer's stops: where, when and their own status, never the pickup's place. + */ + protected function stops(Collection $own, Collection $data): array + { + $byId = $data->keyBy(fn ($stop) => $stop['sequence']); + + return $own->filter(fn (TrackingStop $stop) => $stop->type !== 'pickup')->map(function (TrackingStop $stop) use ($byId) { + $point = $stop->point(); + $entry = $byId->get($stop->sequence, []); + + return [ + 'id' => $stop->waypoint?->public_id ?? $stop->place?->public_id, + 'type' => $stop->type, + 'sequence' => $stop->sequence, + 'status' => $stop->status, + 'complete' => $stop->completed, + 'eta' => $entry['eta'] ?? null, + 'timeline' => $entry['timeline'] ?? [], + 'place' => [ + 'name' => $stop->place?->name, + 'address' => $stop->place?->address, + 'latitude' => $point ? round($point->getLat(), self::STOP_PRECISION) : null, + 'longitude' => $point ? round($point->getLng(), self::STOP_PRECISION) : null, + ], + 'window' => [ + 'start' => $this->iso($stop->waypoint?->time_window_start), + 'end' => $this->iso($stop->waypoint?->time_window_end), + ], + ]; + })->values()->all(); + } + + /** + * The arrival estimate for the customer's next stop. + */ + protected function eta(array $stops, array $tracker, TrackingUpdate $update): ?array + { + if (!$update->showsVehicle()) { + return null; + } + + foreach ($stops as $stop) { + if (!$stop['complete'] && !empty($stop['eta'])) { + return ['at' => $stop['eta']['at'], 'seconds' => $stop['eta']['seconds'], 'confidence' => data_get($tracker, 'confidence')]; + } + } + + $at = data_get($tracker, 'eta.completion_at'); + + return $at ? ['at' => $at, 'seconds' => data_get($tracker, 'eta.completion_seconds'), 'confidence' => data_get($tracker, 'confidence')] : null; + } + + /** + * The order's and the customer's stops' status entries, newest first. + */ + protected function timeline(array $data): array + { + $entries = collect($data['order']['timeline']); + foreach ($data['stops'] as $stop) { + $entries = $entries->merge($stop['timeline'] ?? []); + } + + return $entries + ->unique(fn ($entry) => ($entry['code'] ?? '') . '|' . ($entry['created_at'] ?? '') . '|' . ($entry['status'] ?? '')) + ->sortByDesc(fn ($entry) => $entry['created_at'] ?? '') + ->values() + ->all(); + } + + protected function driver(?array $driver, array $config): ?array + { + if (!$driver) { + return null; + } + + return [ + 'name' => data_get($config, 'visibility.driver_name', true) ? $driver['name'] : null, + 'vehicle' => data_get($config, 'visibility.vehicle', true) ? $driver['vehicle'] : null, + 'online' => $driver['online'], + ]; + } + + /** + * Where the route starts (to about a kilometre) and, when every stop is this + * customer's, the route line. On an order with other customers the line would trace + * their stops, so it is left out. + */ + protected function map(Collection $stops, Collection $own, array $tracker, ?array $location): array + { + $pickup = $stops->first(fn (TrackingStop $stop) => $stop->type === 'pickup'); + $point = $pickup?->point(); + $mine = $stops->every(fn (TrackingStop $stop) => $stop->type === 'pickup' || $own->contains(fn (TrackingStop $owned) => $owned === $stop)); + + return [ + 'hub' => $point ? ['latitude' => round($point->getLat(), self::HUB_PRECISION), 'longitude' => round($point->getLng(), self::HUB_PRECISION)] : null, + 'polyline' => $mine && $location ? data_get($tracker, 'route.polyline') : null, + ]; + } + + /** + * The customer's items: those going to their stops, and on an order with no stop + * customers, the order customer's. + */ + protected function items(Collection $entities, bool $prices): array + { + return $entities->map(fn (Entity $entity) => array_filter([ + 'id' => $entity->public_id, + 'name' => $entity->name, + 'description' => $entity->description, + 'quantity' => data_get($entity, 'meta.quantity'), + 'photo_url' => $entity->photo_uuid ? $entity->photo_url : null, + 'tracking' => data_get($entity, 'tracking'), + 'price' => $prices ? $entity->price : null, + 'currency' => $prices ? $entity->currency : null, + ], fn ($value) => $value !== null))->values()->all(); + } + + /** + * Proof of delivery for the customer's completed stops and items. + */ + protected function proofs(Order $order, Collection $own, Collection $entities, TrackingScope $scope, array $config): array + { + $subjects = $own->filter(fn (TrackingStop $stop) => $stop->completed)->map(fn (TrackingStop $stop) => $stop->waypoint?->uuid)->filter()->values()->all(); + $subjects = array_merge($subjects, $entities->map(fn (Entity $entity) => $entity->uuid)->all()); + if ($scope->isOrderCustomer($order) && strtolower((string) $order->status) === 'completed') { + $subjects[] = $order->uuid; + } + + return $this->proofsFor($subjects) + ->map(fn (Proof $proof) => ['id' => $proof->public_id, 'type' => static::proofType($proof), 'created_at' => $this->iso($proof->created_at)]) + ->filter(fn ($proof) => ($proof['type'] !== 'photo' || data_get($config, 'visibility.pod_photo', true)) && ($proof['type'] !== 'signature' || data_get($config, 'visibility.pod_signature', true))) + ->values() + ->all(); + } + + /** + * signature, photo or scan. + */ + public static function proofType(Proof $proof): string + { + $fileType = strtolower((string) data_get($proof, 'file.type')); + if ($fileType === 'signature' || (!empty($proof->raw_data) && $fileType === '')) { + return 'signature'; + } + + return $fileType === 'photo' || $proof->file_uuid ? 'photo' : 'scan'; + } + + /** + * Proofs the scope may open, by public id. + */ + public function proofFor(TrackingTarget $target, string $publicId): ?Proof + { + $view = $this->fullView($target, [], self::VIEWER_VERIFIED); + + if (!collect($view['proofs'])->contains(fn ($proof) => $proof['id'] === $publicId)) { + return null; + } + + return $this->findProof($publicId); + } + + protected function iso(mixed $value): ?string + { + return $value ? Carbon::parse($value)->toISOString() : null; + } + + protected function stopsFor(Order $order): Collection + { + return app(TrackingContextBuilder::class)->build($order, TrackingOptions::fromArray([], $order->company_uuid))->stops; + } + + protected function trackerFor(Order $order): array + { + if (in_array(strtolower((string) $order->status), TrackingUpdate::TERMINAL_STATUSES, true)) { + return []; + } + + try { + return app(TrackingIntelligenceService::class)->track($order); + } catch (\Throwable) { + return []; + } + } + + protected function updateFor(Order $order, TrackingScope $scope, Collection $stops, array $tracker): TrackingUpdate + { + return TrackingUpdate::for($order, $scope, $stops, $tracker); + } + + protected function staleAfter(Order $order): int + { + return TrackingOptions::fromArray([], $order->company_uuid)->staleLocationThresholdSeconds; + } + + /** + * @param array $placeUuids the customer's stop places + */ + protected function entitiesFor(Order $order, array $placeUuids, TrackingScope $scope): Collection + { + if (!$order->payload_uuid) { + return collect(); + } + + $orderCustomer = $scope->isOrderCustomer($order); + + return Entity::withoutGlobalScopes() + ->where('payload_uuid', $order->payload_uuid) + ->get() + ->filter(fn (Entity $entity) => $entity->destination_uuid ? in_array($entity->destination_uuid, $placeUuids, true) : $orderCustomer) + ->values(); + } + + protected function proofsFor(array $subjectUuids): Collection + { + return $subjectUuids === [] ? collect() : Proof::withoutGlobalScopes()->with('file')->whereIn('subject_uuid', $subjectUuids)->orderBy('created_at')->get(); + } + + protected function findProof(string $publicId): ?Proof + { + return Proof::withoutGlobalScopes()->with('file')->where('public_id', $publicId)->first(); + } + + protected function instructionsFor(Order $order, TrackingScope $scope): ?string + { + $instructions = data_get($order->getMeta('recipient_instructions', []), sha1($scope->key())); + + return is_string($instructions) && $instructions !== '' ? $instructions : null; + } +} diff --git a/server/src/routes.php b/server/src/routes.php index 7dcb3821c..9ee007feb 100644 --- a/server/src/routes.php +++ b/server/src/routes.php @@ -20,6 +20,23 @@ function ($router) { // limiter has its own prefix so the two do not share one counter. // Every answer is JSON, so a refused submission reaches the page with // its reasons instead of as a redirect back to it. + // The public customer tracking page. A tracking number alone gives a coarse status; + // a one-time code to the customer's own contact details unlocks their stops. Lookups + // are rate limited inside the controller, so a throttled lookup answers exactly like + // an unknown number; code sends and checks have limits of their own there too. + $router->prefix('public/track')->namespace('Public')->middleware([Fleetbase\FleetOps\Http\Middleware\ForceJsonResponse::class])->group(function ($router) { + $router->get('config', 'TrackingController@config'); + $router->get('account/orders', 'TrackingController@accountOrders'); + $router->delete('session', 'TrackingController@signOut'); + $router->get('{number}', 'TrackingController@show'); + $router->post('{number}/codes', 'TrackingController@sendCode'); + $router->post('{number}/codes/verify', 'TrackingController@verifyCode'); + $router->put('{number}/instructions', 'TrackingController@updateInstructions'); + $router->get('{number}/proofs/{id}', 'TrackingController@proof'); + $router->post('{number}/report', 'TrackingController@report'); + $router->get('{number}/socket-token', 'TrackingController@socketToken'); + }); + $router->prefix('public')->namespace('Public')->middleware([Fleetbase\FleetOps\Http\Middleware\ForceJsonResponse::class, 'throttle:60,1,inspection-public'])->group(function ($router) { $router->get('inspections/forms/{id}', 'PublicInspectionController@show'); $router->post('inspections/forms/{id}/submit', 'PublicInspectionController@submit'); diff --git a/server/tests/Feature/Http/Public/TrackingControllerTest.php b/server/tests/Feature/Http/Public/TrackingControllerTest.php new file mode 100644 index 000000000..623549f09 --- /dev/null +++ b/server/tests/Feature/Http/Public/TrackingControllerTest.php @@ -0,0 +1,495 @@ +resolved[] = [$input, $slug]; + + return $this->target; + } + + public function companyForSlug(?string $slug): ?string + { + return $this->slugCompany; + } + + public function config(?string $companyUuid): array + { + return TrackingPageConfig::sanitize($this->company); + } + + public function adminConfig(): array + { + return TrackingPageConfig::sanitizeAdmin($this->admin); + } +} + +class TrackingControllerAccessFake extends TrackingAccess +{ + public bool $lookup = true; + public bool $verify = true; + public bool $reportOk = true; + public int $paused = 0; + public array $claim = ['status' => self::SEND_SENT, 'retry_after' => 30]; + public array $check = ['status' => VerificationCode::CHECK_VALID, 'channel' => 'sms']; + public bool $granted = false; + public array $issued = []; + public array $grants = []; + public array $revoked = []; + + public function allowLookup(Request $request): bool + { + return $this->lookup; + } + + public function allowVerify(Request $request): bool + { + return $this->verify; + } + + public function allowReport(Request $request, string $orderUuid): bool + { + return $this->reportOk; + } + + public function pausedFor(TrackingScope $scope): int + { + return $this->paused; + } + + public function claimSend(TrackingScope $scope, Request $request): array + { + return $this->claim; + } + + public function issueCode(TrackingScope $scope, mixed $recipient, string $channel): VerificationCode + { + $this->issued[] = [$scope->key(), $recipient?->uuid, $channel]; + $code = new VerificationCode(); + $code->plainCode = '482193'; + + return $code; + } + + public function checkCode(TrackingScope $scope, string $code): array + { + return $this->check + ['code' => $code]; + } + + public function hasGrant(Request $request, ?TrackingScope $scope): bool + { + return $this->granted && $scope !== null; + } + + public function grantExpiresAt(Request $request, TrackingScope $scope): ?string + { + return '2026-10-09T09:00:00.000000Z'; + } + + public function grant(Request $request, TrackingScope $scope, ?string $channel, int $hours): ?Cookie + { + $this->grants[] = [$scope->key(), $channel, $hours]; + + return $this->cookie('token-value-token-value-token-value-xx', $hours * 60); + } + + public function revoke(Request $request, ?TrackingScope $scope = null): ?Cookie + { + $this->revoked[] = $scope?->key(); + + return $scope ? null : $this->cookie('', -1); + } +} + +class TrackingControllerViewFake extends TrackingView +{ + public ?Proof $proof = null; + public array $calls = []; + + public function publicView(TrackingTarget $target, array $branding, array $channels, bool $signIn): array + { + return ['level' => 0, 'branding' => $branding, 'channels' => $channels, 'sign_in' => $signIn]; + } + + public function fullView(TrackingTarget $target, array $branding, string $viewer, ?string $expiresAt = null): array + { + $this->calls[] = $viewer; + + return ['level' => $viewer === self::VIEWER_ACCOUNT ? 2 : 1, 'viewer' => $viewer, 'expires_at' => $expiresAt, 'company' => $branding]; + } + + public function proofFor(TrackingTarget $target, string $publicId): ?Proof + { + return $this->proof; + } +} + +class TrackingControllerProbe extends TrackingController +{ + public mixed $user = null; + public array $sms = []; + public array $mails = []; + public array $providers = []; + public bool $portal = true; + public bool $sockets = false; + public ?array $contactsOver = null; + + public function __construct(public TrackingControllerResolverFake $fakeResolver, public TrackingControllerAccessFake $fakeAccess, public TrackingControllerViewFake $fakeView) + { + parent::__construct($fakeResolver, $fakeAccess, $fakeView); + } + + protected function requestUser(Request $request): mixed + { + return $this->user; + } + + protected function smsProviders(): array + { + return $this->providers; + } + + protected function customerPortalInstalled(): bool + { + return $this->portal; + } + + protected function instanceLogo(): ?string + { + return 'https://instance.example/logo.png'; + } + + protected function smsService(): Fleetbase\Services\SmsService + { + $probe = $this; + + return new class($probe) extends Fleetbase\Services\SmsService { + public function __construct(private $probe) + { + } + + public function send(string $to, string $text, array $options = [], ?string $provider = null): array + { + $this->probe->sms[] = [$to, $text, $options]; + + return ['success' => true]; + } + }; + } + + protected function mail(string $to, string $subject, string $body): void + { + $this->mails[] = [$to, $subject, $body]; + } + + protected function socketsEnabled(): bool + { + return $this->sockets && parent::socketsEnabled() === false; + } + + protected function socketTokenFor(TrackingScope $scope): array + { + return ['token' => 'jwt', 'expires_in' => 1800, 'expires_at' => '2026-10-08T09:30:00Z']; + } +} + +class TrackingControllerOrder extends Order +{ + public bool $saved = false; + + public function saveQuietly(array $options = []) + { + $this->saved = true; + + return true; + } +} + +afterEach(function () { + EloquentModel::unsetConnectionResolver(); + config(['services.twilio.sid' => null, 'services.twilio.token' => null]); +}); + +function trackingController(): TrackingControllerProbe +{ + TrackingPageDatabase::boot(); + + return new TrackingControllerProbe(new TrackingControllerResolverFake(), new TrackingControllerAccessFake(), new TrackingControllerViewFake()); +} + +function trackingControllerTarget(array $config = [], bool $scoped = true, ?Waypoint $waypoint = null): TrackingTarget +{ + $order = new TrackingControllerOrder(); + $order->setRawAttributes(['uuid' => 'order-1', 'company_uuid' => 'co-1', 'customer_uuid' => 'contact-1', 'customer_type' => Contact::class, 'meta' => json_encode([])], true); + + $trackingNumber = new TrackingNumber(); + $trackingNumber->setRawAttributes(['uuid' => 'tn-1', 'tracking_number' => 'NOR1000000001US'], true); + + $scope = $scoped ? new TrackingScope('order-1', Contact::class, 'contact-1', $order) : null; + + return new TrackingTarget($order, $scope, $trackingNumber, TrackingTarget::KIND_ORDER, $waypoint, TrackingPageConfig::sanitize($config)); +} + +function trackingControllerRequest(array $input = []): Request +{ + return Request::create('/public/track/NOR1000000001US', 'GET', $input, [], [], ['REMOTE_ADDR' => '203.0.113.7']); +} + +test('the page config shows a company page or the shared page, and nothing for a page that is off', function () { + $controller = trackingController(); + TrackingPageDatabase::insert(Illuminate\Database\Eloquent\Model::getConnectionResolver()->connection(), 'companies', ['uuid' => 'co-1', 'name' => 'Northwind Couriers', 'phone' => '+15105550142']); + + expect($controller->config(trackingControllerRequest(['org' => 'northwind']))->getStatusCode())->toBe(404); + + $controller->fakeResolver->slugCompany = 'co-1'; + $controller->fakeResolver->company = ['access' => ['session_hours' => 12]]; + $page = $controller->config(trackingControllerRequest(['org' => ' Northwind ']))->getData(true); + expect($page['company']['name'])->toBe('Northwind Couriers') + ->and($page['org'])->toBe('northwind') + ->and($page['sign_in'])->toBeTrue() + ->and($page['session_hours'])->toBe(12); + + $shared = $controller->config(trackingControllerRequest(['org' => ' ']))->getData(true); + expect($shared['org'])->toBeNull() + ->and($shared['company']['logo_url'])->toBe('https://instance.example/logo.png') + ->and($shared['session_hours'])->toBe(24); + + $controller->fakeResolver->admin = ['generic_page' => ['enabled' => false]]; + expect($controller->config(trackingControllerRequest())->getStatusCode())->toBe(404); +}); + +test('lookups answer unknown and throttled numbers alike, and show what the viewer may see', function () { + $controller = trackingController(); + + $notFound = $controller->show(trackingControllerRequest(), 'NOR1000000001US'); + expect($notFound->getStatusCode())->toBe(404) + ->and($notFound->getData(true))->toBe(['error' => TrackingController::NOT_FOUND, 'code' => 'not_found']); + + $controller->fakeResolver->target = trackingControllerTarget(); + $controller->fakeAccess->lookup = false; + expect($controller->show(trackingControllerRequest(), 'NOR1000000001US')->getData(true))->toBe($notFound->getData(true)); + + // A tracking number alone: the public view, with the channels a code can use. + $controller->fakeAccess->lookup = true; + $controller->providers = ['vonage' => ['available' => true]]; + TrackingPageDatabase::insert(Illuminate\Database\Eloquent\Model::getConnectionResolver()->connection(), 'contacts', ['uuid' => 'contact-1', 'email' => 'maya@example.com', 'phone' => '+15105554821']); + $public = $controller->show(trackingControllerRequest(['org' => 'Northwind']), 'NOR1000000001US')->getData(true); + expect($public['level'])->toBe(0) + ->and(array_column($public['channels'], 'type'))->toBe(['sms', 'email']) + ->and($public['sign_in'])->toBeTrue() + ->and($controller->fakeResolver->resolved[1])->toBe(['NOR1000000001US', 'northwind']); + + $controller->fakeResolver->target = trackingControllerTarget(['access' => ['account_sign_in' => false]], false); + $unscoped = $controller->show(trackingControllerRequest(), 'NOR1000000001US')->getData(true); + expect($unscoped['channels'])->toBe([])->and($unscoped['sign_in'])->toBeFalse(); + + // A verified device, a signed-in customer and staff of the owning company see the full view. + $controller->fakeResolver->target = trackingControllerTarget(); + $controller->fakeAccess->granted = true; + expect($controller->show(trackingControllerRequest(), 'NOR1000000001US')->getData(true))->toMatchArray(['viewer' => 'verified', 'expires_at' => '2026-10-09T09:00:00.000000Z']); + + $controller->fakeAccess->granted = false; + $controller->user = (object) ['uuid' => 'user-1', 'type' => 'customer', 'company_uuid' => 'co-1']; + TrackingPageDatabase::insert(Illuminate\Database\Eloquent\Model::getConnectionResolver()->connection(), 'contacts', ['uuid' => 'contact-x', 'user_uuid' => 'user-1']); + expect($controller->show(trackingControllerRequest(), 'NOR1000000001US')->getData(true)['level'])->toBe(0); + + Illuminate\Database\Eloquent\Model::getConnectionResolver()->connection()->table('contacts')->where('uuid', 'contact-1')->update(['user_uuid' => 'user-1']); + expect($controller->show(trackingControllerRequest(), 'NOR1000000001US')->getData(true))->toMatchArray(['viewer' => 'account', 'level' => 2, 'expires_at' => null]); + + $controller->user = (object) ['uuid' => 'user-2', 'type' => 'user', 'company_uuid' => 'co-1']; + expect($controller->show(trackingControllerRequest(), 'NOR1000000001US')->getData(true)['viewer'])->toBe('staff'); + + // Staff of another company, or a user with no id, get the public view. + $controller->user = (object) ['uuid' => 'user-3', 'type' => 'user', 'company_uuid' => 'co-2']; + expect($controller->show(trackingControllerRequest(), 'NOR1000000001US')->getData(true)['level'])->toBe(0); + $controller->user = (object) ['type' => 'customer']; + expect($controller->show(trackingControllerRequest(), 'NOR1000000001US')->getData(true)['level'])->toBe(0); +}); + +test('codes go out on an available channel, within the limits', function () { + $controller = trackingController(); + $db = Illuminate\Database\Eloquent\Model::getConnectionResolver()->connection(); + TrackingPageDatabase::insert($db, 'contacts', ['uuid' => 'contact-1', 'email' => 'maya@example.com']); + TrackingPageDatabase::insert($db, 'companies', ['uuid' => 'co-1', 'name' => 'Northwind', 'options' => json_encode(['alpha_numeric_sender_id_enabled' => true, 'alpha_numeric_sender_id' => 'NORTHWIND'])]); + + expect($controller->sendCode(trackingControllerRequest(['channel' => 'email']), 'X')->getStatusCode())->toBe(404); + + $place = new Place(); + $place->setRawAttributes(['uuid' => 'place-1', 'phone' => '+15105554821'], true); + $waypoint = new Waypoint(); + $waypoint->setRelation('place', $place); + $controller->fakeResolver->target = trackingControllerTarget([], true, $waypoint); + + // SMS needs a provider; the stop's place phone stands in for the contact's missing one. + expect($controller->sendCode(trackingControllerRequest(['channel' => 'sms']), 'NOR1')->getData(true))->toBe(['error' => 'channel_unavailable']); + + config(['services.twilio.sid' => 'AC1', 'services.twilio.token' => 'secret']); + $sent = $controller->sendCode(trackingControllerRequest(['channel' => 'sms']), 'NOR1'); + expect($sent->getStatusCode())->toBe(202) + ->and($sent->getData(true))->toBe(['channel' => 'sms', 'masked' => 'โ€ขโ€ขโ€ขโ€ข โ€ขโ€ข 4821', 'expires_in' => 600, 'resend_after' => 30]) + ->and($controller->fakeAccess->issued[0])->toBe(['order-1:' . Contact::class . ':contact-1', 'contact-1', 'sms']) + ->and($controller->sms[0][0])->toBe('+15105554821') + ->and($controller->sms[0][1])->toBe('482193 is your Northwind delivery code. It expires in 10 minutes.') + ->and($controller->sms[0][2])->toBe(['twilioParams' => ['from' => 'NORTHWIND']]); + + $controller->sendCode(trackingControllerRequest(['channel' => 'email']), 'NOR1'); + expect($controller->mails[0])->toBe(['maya@example.com', '482193 is your Northwind delivery code', '482193 is your Northwind delivery code. It expires in 10 minutes.']); + + $controller->fakeAccess->claim = ['status' => TrackingAccess::SEND_COOLDOWN, 'retry_after' => 12]; + expect($controller->sendCode(trackingControllerRequest(['channel' => 'email']), 'NOR1')->getStatusCode())->toBe(429); + $controller->fakeAccess->claim = ['status' => TrackingAccess::SEND_PAUSED, 'retry_after' => 200]; + expect($controller->sendCode(trackingControllerRequest(['channel' => 'email']), 'NOR1')->getData(true))->toBe(['error' => 'paused', 'retry_after' => 200]); + + // A company with no name of its own falls back to a generic sender name, and no alphanumeric sender. + $db->table('companies')->delete(); + $controller->fakeAccess->claim = ['status' => TrackingAccess::SEND_SENT, 'retry_after' => 30]; + $controller->sendCode(trackingControllerRequest(['channel' => 'sms']), 'NOR1'); + expect($controller->sms[1][1])->toStartWith('482193 is your Your delivery delivery code') + ->and($controller->sms[1][2])->toBe([]); + + $controller->fakeResolver->target = trackingControllerTarget([], false); + expect($controller->sendCode(trackingControllerRequest(['channel' => 'email']), 'NOR1')->getStatusCode())->toBe(422); +}); + +test('checking a code verifies the device or says why it canโ€™t', function () { + $controller = trackingController(); + + $controller->fakeAccess->verify = false; + expect($controller->verifyCode(trackingControllerRequest(['code' => '482193']), 'NOR1')->getStatusCode())->toBe(429); + + $controller->fakeAccess->verify = true; + expect($controller->verifyCode(trackingControllerRequest(['code' => '482193']), 'NOR1')->getStatusCode())->toBe(404); + + $controller->fakeResolver->target = trackingControllerTarget(['access' => ['session_hours' => 6]]); + $verified = $controller->verifyCode(trackingControllerRequest(['code' => '48 21 93']), 'NOR1'); + expect($verified->getStatusCode())->toBe(200) + ->and($verified->getData(true)['viewer'])->toBe('verified') + ->and($verified->headers->getCookies()[0]->getName())->toBe(TrackingAccess::COOKIE) + ->and($controller->fakeAccess->grants[0])->toBe(['order-1:' . Contact::class . ':contact-1', 'sms', 6]); + + $controller->fakeAccess->check = ['status' => VerificationCode::CHECK_INVALID, 'attempts_left' => 2]; + expect($controller->verifyCode(trackingControllerRequest(['code' => '111111']), 'NOR1')->getData(true))->toBe(['error' => 'invalid', 'attempts_left' => 2]); + + $controller->fakeAccess->check = ['status' => VerificationCode::CHECK_LOCKED]; + expect($controller->verifyCode(trackingControllerRequest(['code' => '111111']), 'NOR1')->getData(true))->toBe(['error' => 'paused', 'retry_after' => TrackingAccess::PAUSE_SECONDS]); + + $controller->fakeAccess->check = ['status' => VerificationCode::CHECK_EXPIRED]; + expect($controller->verifyCode(trackingControllerRequest(['code' => '111111']), 'NOR1')->getStatusCode())->toBe(410); + + $controller->fakeAccess->paused = 120; + expect($controller->verifyCode(trackingControllerRequest(['code' => '111111']), 'NOR1')->getData(true))->toBe(['error' => 'paused', 'retry_after' => 120]); +}); + +test('signing out drops one delivery or the whole device', function () { + $controller = trackingController(); + $controller->fakeResolver->target = trackingControllerTarget(); + + $one = $controller->signOut(trackingControllerRequest(['number' => 'NOR1'])); + expect($one->getData(true))->toBe(['status' => 'signed_out']) + ->and($one->headers->getCookies())->toBe([]) + ->and($controller->fakeAccess->revoked[0])->toBe('order-1:' . Contact::class . ':contact-1'); + + $all = $controller->signOut(trackingControllerRequest()); + expect($all->headers->getCookies()[0]->getExpiresTime())->toBe(1) + ->and($controller->fakeAccess->revoked[1])->toBeNull(); +}); + +test('delivery instructions, proofs, reports and socket tokens need a verified viewer', function () { + $controller = trackingController(); + + foreach (['updateInstructions', 'report', 'socketToken'] as $method) { + expect($controller->{$method}(trackingControllerRequest(), 'NOR1')->getStatusCode())->toBe(404); + } + expect($controller->proof(trackingControllerRequest(), 'NOR1', 'proof_1')->getStatusCode())->toBe(404); + + $controller->fakeResolver->target = trackingControllerTarget(); + expect($controller->updateInstructions(trackingControllerRequest(), 'NOR1')->getStatusCode())->toBe(404); + + $controller->fakeAccess->granted = true; + + // Instructions + expect($controller->updateInstructions(trackingControllerRequest(['text' => 'Gate 4']), 'NOR1')->getStatusCode())->toBe(403); + $controller->fakeResolver->target = trackingControllerTarget(['visibility' => ['instructions_edit' => true]]); + $controller->updateInstructions(trackingControllerRequest(['text' => ' Leave with concierge ']), 'NOR1'); + $order = $controller->fakeResolver->target->order; + expect($order->saved)->toBeTrue() + ->and($order->getMeta('recipient_instructions'))->toBe([sha1('order-1:' . Contact::class . ':contact-1') => 'Leave with concierge']); + + // Proofs + expect($controller->proof(trackingControllerRequest(), 'NOR1', 'proof_1')->getStatusCode())->toBe(404); + $controller->fakeView->proof = new Proof(); + $controller->fakeView->proof->setRawAttributes(['raw_data' => 'iVBOR'], true); + $controller->fakeView->proof->setRelation('file', null); + expect($controller->proof(trackingControllerRequest(), 'NOR1', 'proof_1')->getData(true))->toBe(['url' => 'data:image/png;base64,iVBOR', 'type' => 'signature']); + $controller->fakeView->proof->setRawAttributes(['raw_data' => 'data:image/svg+xml;base64,PHN2Zz4='], true); + expect($controller->proof(trackingControllerRequest(), 'NOR1', 'proof_1')->getData(true)['url'])->toBe('data:image/svg+xml;base64,PHN2Zz4='); + $controller->fakeView->proof->setRawAttributes([], true); + expect($controller->proof(trackingControllerRequest(), 'NOR1', 'proof_1')->getStatusCode())->toBe(404); + $controller->fakeView->proof->setRelation('file', (object) ['url' => 'https://bucket.example/signed']); + expect($controller->proof(trackingControllerRequest(), 'NOR1', 'proof_1')->getData(true)['url'])->toBe('https://bucket.example/signed'); + + // Reports + expect($controller->report(trackingControllerRequest(['message' => 'Box damaged']), 'NOR1')->getStatusCode())->toBe(422); + $controller->fakeResolver->target = trackingControllerTarget(['branding' => ['support_email' => 'help@northwind.example']]); + expect($controller->report(trackingControllerRequest(['message' => ' ']), 'NOR1')->getStatusCode())->toBe(422); + $controller->fakeAccess->reportOk = false; + expect($controller->report(trackingControllerRequest(['message' => 'Box damaged']), 'NOR1')->getStatusCode())->toBe(429); + $controller->fakeAccess->reportOk = true; + expect($controller->report(trackingControllerRequest(['message' => 'Box damaged']), 'NOR1')->getStatusCode())->toBe(202) + ->and($controller->mails[0])->toBe(['help@northwind.example', 'Problem reported for NOR1000000001US', "A customer reported a problem with delivery NOR1000000001US:\n\nBox damaged"]); + + // Socket tokens, only when the instance publishes tracking channels. + expect($controller->socketToken(trackingControllerRequest(), 'NOR1')->getStatusCode())->toBe(404); + $controller->sockets = true; + expect($controller->socketToken(trackingControllerRequest(), 'NOR1')->getData(true))->toBe(['token' => 'jwt', 'expires_in' => 1800, 'expires_at' => '2026-10-08T09:30:00Z']); +}); + +test('a signed-in customer lists their orders with the number that opens their part', function () { + $controller = trackingController(); + $db = Illuminate\Database\Eloquent\Model::getConnectionResolver()->connection(); + + expect($controller->accountOrders(trackingControllerRequest())->getStatusCode())->toBe(401); + + $controller->user = (object) ['uuid' => 'user-1', 'type' => 'customer']; + TrackingPageDatabase::insert($db, 'contacts', ['uuid' => 'contact-1', 'user_uuid' => 'user-1']); + TrackingPageDatabase::insert($db, 'tracking_numbers', ['uuid' => 'tn-own', 'tracking_number' => 'NOR1']); + TrackingPageDatabase::insert($db, 'tracking_numbers', ['uuid' => 'tn-stop', 'tracking_number' => 'NOR2-STOP']); + TrackingPageDatabase::insert($db, 'orders', ['uuid' => 'order-own', 'customer_uuid' => 'contact-1', 'customer_type' => Contact::class, 'tracking_number_uuid' => 'tn-own', 'payload_uuid' => 'p-own', 'status' => 'completed', 'created_at' => '2026-10-07 09:00:00', 'updated_at' => '2026-10-07 10:00:00']); + TrackingPageDatabase::insert($db, 'orders', ['uuid' => 'order-stop', 'customer_uuid' => 'shipper', 'customer_type' => Contact::class, 'payload_uuid' => 'p-stop', 'status' => 'created', 'created_at' => '2026-10-08 09:00:00']); + TrackingPageDatabase::insert($db, 'orders', ['uuid' => 'order-none', 'customer_uuid' => 'contact-1', 'customer_type' => Contact::class, 'payload_uuid' => 'p-none', 'created_at' => '2026-10-06 09:00:00']); + TrackingPageDatabase::insert($db, 'waypoints', ['uuid' => 'wp-1', 'payload_uuid' => 'p-stop', 'customer_uuid' => 'contact-1', 'customer_type' => Contact::class, 'tracking_number_uuid' => 'tn-stop']); + TrackingPageDatabase::insert($db, 'entities', ['uuid' => 'e-1', 'payload_uuid' => 'p-own', 'name' => 'Ceramic lamp']); + + $orders = $controller->accountOrders(trackingControllerRequest())->getData(true)['orders']; + expect($orders)->toBe([ + ['tracking_number' => 'NOR2-STOP', 'title' => null, 'status' => 'created', 'stage' => 'preparing', 'updated_at' => null], + ['tracking_number' => 'NOR1', 'title' => 'Ceramic lamp', 'status' => 'completed', 'stage' => 'delivered', 'updated_at' => '2026-10-07T10:00:00.000000Z'], + ]); +}); diff --git a/server/tests/RouteRegistrationExecutionTest.php b/server/tests/RouteRegistrationExecutionTest.php index 491558d9e..11b6eda98 100644 --- a/server/tests/RouteRegistrationExecutionTest.php +++ b/server/tests/RouteRegistrationExecutionTest.php @@ -229,6 +229,26 @@ function fleetOpsRecordedRoutes(): FleetOpsRouteRecorder $lookup = collect($recorder->routes)->first(fn (array $route) => $route['action'] === 'OrderController@lookup'); expect($lookup)->not->toBeNull() ->and($lookup['middleware'] ?? [])->toBe(['throttle:30,1,tracking-lookup']); + + // The customer tracking page's public API answers in JSON and throttles inside the + // controller, so a throttled lookup looks exactly like an unknown number. + $tracking = collect($recorder->routes)->filter(fn (array $route) => str_starts_with($route['uri'], 'public/track/')); + expect($tracking->pluck('action', 'uri')->all())->toMatchArray([ + 'public/track/config' => 'TrackingController@config', + 'public/track/account/orders' => 'TrackingController@accountOrders', + 'public/track/session' => 'TrackingController@signOut', + 'public/track/{number}' => 'TrackingController@show', + 'public/track/{number}/codes' => 'TrackingController@sendCode', + 'public/track/{number}/codes/verify' => 'TrackingController@verifyCode', + 'public/track/{number}/instructions' => 'TrackingController@updateInstructions', + 'public/track/{number}/proofs/{id}' => 'TrackingController@proof', + 'public/track/{number}/report' => 'TrackingController@report', + 'public/track/{number}/socket-token' => 'TrackingController@socketToken', + ]); + foreach ($tracking as $route) { + expect($inGroup($route))->toBeTrue() + ->and($route['middleware'] ?? [])->toBe([]); + } }); test('fleetops route file wires route groups with expected middleware and namespaces', function () { diff --git a/server/tests/Support/TrackingPageDatabase.php b/server/tests/Support/TrackingPageDatabase.php new file mode 100644 index 000000000..7dcb25704 --- /dev/null +++ b/server/tests/Support/TrackingPageDatabase.php @@ -0,0 +1,197 @@ + $connection, 'mysql' => $connection, 'testing' => $connection]); + $resolver->setDefaultConnection('mysql'); + EloquentModel::setConnectionResolver($resolver); + EloquentModel::clearBootedModels(); + + $schema = $connection->getSchemaBuilder(); + $common = function ($table) { + $table->increments('id'); + $table->string('_key')->nullable(); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->text('meta')->nullable(); + $table->timestamp('created_at')->nullable(); + $table->timestamp('updated_at')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }; + + $schema->create('settings', function ($table) { + $table->increments('id'); + $table->string('key')->nullable(); + $table->text('value')->nullable(); + $table->timestamp('created_at')->nullable(); + $table->timestamp('updated_at')->nullable(); + }); + $schema->create('tracking_numbers', function ($table) use ($common) { + $common($table); + $table->string('tracking_number')->nullable(); + $table->string('owner_uuid')->nullable(); + $table->string('owner_type')->nullable(); + $table->string('region')->nullable(); + $table->string('status_uuid')->nullable(); + $table->text('qr_code')->nullable(); + $table->text('barcode')->nullable(); + }); + $schema->create('orders', function ($table) use ($common) { + $common($table); + $table->string('payload_uuid')->nullable(); + $table->string('customer_uuid')->nullable(); + $table->string('customer_type')->nullable(); + $table->string('tracking_number_uuid')->nullable(); + $table->string('driver_assigned_uuid')->nullable(); + $table->string('vehicle_assigned_uuid')->nullable(); + $table->string('status')->nullable(); + $table->boolean('dispatched')->nullable(); + $table->boolean('started')->nullable(); + $table->timestamp('scheduled_at')->nullable(); + $table->timestamp('started_at')->nullable(); + }); + $schema->create('payloads', function ($table) use ($common) { + $common($table); + $table->string('pickup_uuid')->nullable(); + $table->string('dropoff_uuid')->nullable(); + $table->string('pickup_tracking_number_uuid')->nullable(); + $table->string('dropoff_tracking_number_uuid')->nullable(); + }); + $schema->create('waypoints', function ($table) use ($common) { + $common($table); + $table->string('payload_uuid')->nullable(); + $table->string('place_uuid')->nullable(); + $table->string('customer_uuid')->nullable(); + $table->string('customer_type')->nullable(); + $table->string('tracking_number_uuid')->nullable(); + $table->integer('order')->nullable(); + }); + $schema->create('entities', function ($table) use ($common) { + $common($table); + $table->string('payload_uuid')->nullable(); + $table->string('destination_uuid')->nullable(); + $table->string('tracking_number_uuid')->nullable(); + $table->string('customer_uuid')->nullable(); + $table->string('customer_type')->nullable(); + $table->string('photo_uuid')->nullable(); + $table->string('name')->nullable(); + $table->string('description')->nullable(); + $table->string('price')->nullable(); + $table->string('currency')->nullable(); + }); + $schema->create('proofs', function ($table) use ($common) { + $common($table); + $table->string('order_uuid')->nullable(); + $table->string('subject_uuid')->nullable(); + $table->string('subject_type')->nullable(); + $table->string('file_uuid')->nullable(); + $table->text('raw_data')->nullable(); + $table->string('remarks')->nullable(); + $table->text('data')->nullable(); + }); + $schema->create('companies', function ($table) use ($common) { + $common($table); + $table->string('name')->nullable(); + $table->string('logo_uuid')->nullable(); + $table->string('phone')->nullable(); + $table->string('website_url')->nullable(); + $table->text('options')->nullable(); + }); + $schema->create('places', function ($table) use ($common) { + $common($table); + $table->string('name')->nullable(); + $table->string('street1')->nullable(); + $table->string('city')->nullable(); + $table->string('phone')->nullable(); + $table->text('location')->nullable(); + }); + $schema->create('files', function ($table) use ($common) { + $common($table); + $table->string('type')->nullable(); + $table->string('path')->nullable(); + $table->string('disk')->nullable(); + }); + $schema->create('contacts', function ($table) use ($common) { + $common($table); + $table->string('user_uuid')->nullable(); + $table->string('name')->nullable(); + $table->string('email')->nullable(); + $table->string('phone')->nullable(); + $table->string('type')->nullable(); + }); + $schema->create('vendors', function ($table) use ($common) { + $common($table); + $table->string('name')->nullable(); + $table->string('email')->nullable(); + $table->string('phone')->nullable(); + }); + $schema->create('tracking_statuses', function ($table) use ($common) { + $common($table); + $table->string('tracking_number_uuid')->nullable(); + $table->string('status')->nullable(); + $table->string('details')->nullable(); + $table->string('code')->nullable(); + $table->boolean('complete')->nullable(); + }); + $schema->create('verification_codes', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('subject_uuid')->nullable(); + $table->string('subject_type')->nullable(); + $table->string('code')->nullable(); + $table->string('for')->nullable(); + $table->text('meta')->nullable(); + $table->string('status')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamp('created_at')->nullable(); + $table->timestamp('updated_at')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('tracking_sessions', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('token_hash')->nullable(); + $table->string('ip')->nullable(); + $table->string('user_agent')->nullable(); + $table->timestamp('last_seen_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamp('revoked_at')->nullable(); + $table->timestamp('created_at')->nullable(); + $table->timestamp('updated_at')->nullable(); + }); + $schema->create('tracking_session_grants', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('tracking_session_uuid')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('order_uuid')->nullable(); + $table->string('customer_type')->nullable(); + $table->string('customer_uuid')->nullable(); + $table->string('channel')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamp('created_at')->nullable(); + $table->timestamp('updated_at')->nullable(); + }); + + return $connection; + } + + public static function insert(SQLiteConnection $connection, string $table, array $row): void + { + $connection->table($table)->insert($row); + } +} diff --git a/server/tests/Unit/Support/TrackingCodeTest.php b/server/tests/Unit/Support/TrackingCodeTest.php index 9057892a3..45e499165 100644 --- a/server/tests/Unit/Support/TrackingCodeTest.php +++ b/server/tests/Unit/Support/TrackingCodeTest.php @@ -144,3 +144,25 @@ 'version' => 1, ]); }); + +test('tracking page links name the company page when its settings send links there', function () { + app()->instance(Fleetbase\FleetOps\Support\TrackingPage\TrackingResolver::class, new class extends Fleetbase\FleetOps\Support\TrackingPage\TrackingResolver { + public function linkSlugFor(string $companyUuid): ?string + { + if ($companyUuid === 'broken') { + throw new RuntimeException('settings unavailable'); + } + + return $companyUuid === 'co-1' ? 'northwind' : null; + } + }); + + try { + expect(TrackingCode::pageUrl('NOR1', 'co-1'))->toEndWith('/~/track-order?order=NOR1&org=northwind') + ->and(TrackingCode::pageUrl('NOR1', 'co-2'))->toEndWith('/~/track-order?order=NOR1') + ->and(TrackingCode::pageUrl('NOR1', 'broken'))->toEndWith('/~/track-order?order=NOR1') + ->and(TrackingCode::pageUrl('NOR1'))->toEndWith('/~/track-order?order=NOR1'); + } finally { + app()->forgetInstance(Fleetbase\FleetOps\Support\TrackingPage\TrackingResolver::class); + } +}); diff --git a/server/tests/Unit/Support/TrackingPage/TrackingAccessTest.php b/server/tests/Unit/Support/TrackingPage/TrackingAccessTest.php new file mode 100644 index 000000000..9efa27b2b --- /dev/null +++ b/server/tests/Unit/Support/TrackingPage/TrackingAccessTest.php @@ -0,0 +1,177 @@ + 'base64:tracking-test-key', 'fleetops.tracking.cookie_same_site' => 'lax', 'session.secure' => false, 'session.domain' => null]); +}); + +afterEach(function () { + Carbon::setTestNow(); + EloquentModel::unsetConnectionResolver(); +}); + +function trackingAccess(): TrackingAccess +{ + return new TrackingAccess(new RateLimiter(new Repository(new ArrayStore()))); +} + +function trackingScope(string $customer = 'contact-1'): TrackingScope +{ + return new TrackingScope('order-1', Contact::class, $customer); +} + +function trackingRequest(array $cookies = [], string $ip = '203.0.113.7'): Request +{ + return Request::create('/public/track/NOR1', 'GET', [], $cookies, [], ['REMOTE_ADDR' => $ip, 'HTTP_USER_AGENT' => 'PestBrowser']); +} + +test('lookups, code checks and reports are limited per address', function () { + $access = trackingAccess(); + $request = trackingRequest(); + + foreach (range(1, TrackingAccess::LOOKUPS_PER_IP) as $attempt) { + expect($access->allowLookup($request))->toBeTrue(); + } + + expect($access->allowLookup($request))->toBeFalse() + ->and($access->allowLookup(trackingRequest([], '198.51.100.1')))->toBeTrue(); + + foreach (range(1, TrackingAccess::VERIFIES_PER_IP) as $attempt) { + $access->allowVerify($request); + } + expect($access->allowVerify($request))->toBeFalse(); + + foreach (range(1, TrackingAccess::REPORTS_PER_HOUR) as $attempt) { + expect($access->allowReport($request, 'order-1'))->toBeTrue(); + } + expect($access->allowReport($request, 'order-1'))->toBeFalse() + ->and($access->allowReport($request, 'order-2'))->toBeTrue(); +}); + +test('sending codes is limited by cooldown, per scope and per address', function () { + $access = trackingAccess(); + $request = trackingRequest(); + + expect($access->claimSend(trackingScope(), $request))->toBe(['status' => TrackingAccess::SEND_SENT, 'retry_after' => TrackingAccess::RESEND_SECONDS]) + ->and($access->claimSend(trackingScope(), $request)['status'])->toBe(TrackingAccess::SEND_COOLDOWN); + + // Three sends per scope in the window, even after each cooldown. + $limiter = new RateLimiter(new Repository(new ArrayStore())); + $access = new TrackingAccess($limiter); + foreach (range(1, TrackingAccess::SENDS_PER_SCOPE) as $send) { + $limiter->clear('trk-resend:' . trackingScope()->key()); + expect($access->claimSend(trackingScope(), trackingRequest([], '198.51.100.' . $send))['status'])->toBe(TrackingAccess::SEND_SENT); + } + $limiter->clear('trk-resend:' . trackingScope()->key()); + expect($access->claimSend(trackingScope(), $request)['status'])->toBe(TrackingAccess::SEND_LIMITED); + + // Five sends per address across scopes. + $access = trackingAccess(); + foreach (range(1, TrackingAccess::SENDS_PER_IP) as $send) { + $access->claimSend(trackingScope('contact-' . $send), $request); + } + expect($access->claimSend(trackingScope('contact-9'), $request)['status'])->toBe(TrackingAccess::SEND_LIMITED); +}); + +test('codes are issued hashed, checked, and pause the scope after three wrong ones', function () { + TrackingPageDatabase::boot(); + $access = trackingAccess(); + $scope = trackingScope(); + + $first = $access->issueCode($scope, null, 'sms'); + $second = $access->issueCode($scope, null, 'email'); + + expect(VerificationCode::withoutGlobalScopes()->count())->toBe(1) + ->and($second->plainCode)->toMatch('/^\d{6}$/') + ->and($second->code)->not->toBe($second->plainCode) + ->and($second->getMeta('scope'))->toBe($scope->key()) + ->and($second->getMeta('channel'))->toBe('email') + ->and($first->plainCode)->not->toBeNull(); + + $wrong = $second->plainCode === '111111' ? '222222' : '111111'; + expect($access->checkCode($scope, $wrong))->toBe(['status' => VerificationCode::CHECK_INVALID, 'attempts_left' => 2]) + ->and($access->pausedFor($scope))->toBe(0); + + expect($access->checkCode($scope, $second->plainCode))->toBe(['status' => VerificationCode::CHECK_VALID, 'channel' => 'email']) + ->and(VerificationCode::withoutGlobalScopes()->count())->toBe(0) + ->and($access->checkCode($scope, $second->plainCode))->toBe(['status' => VerificationCode::CHECK_EXPIRED]); + + $third = $access->issueCode($scope, null, 'sms'); + $wrong = $third->plainCode === '111111' ? '222222' : '111111'; + $access->checkCode($scope, $wrong); + $access->checkCode($scope, $wrong); + expect($access->checkCode($scope, $wrong))->toBe(['status' => VerificationCode::CHECK_LOCKED, 'attempts_left' => 0]) + ->and($access->pausedFor($scope))->toBeGreaterThan(0) + ->and($access->claimSend($scope, trackingRequest())['status'])->toBe(TrackingAccess::SEND_PAUSED); +}); + +test('a correct code gives the device a session and a grant for that customer only', function () { + TrackingPageDatabase::boot(); + Carbon::setTestNow(Carbon::parse('2026-10-08 09:00:00')); + $access = trackingAccess(); + $scope = trackingScope(); + + expect($access->session(trackingRequest()))->toBeNull() + ->and($access->session(trackingRequest([TrackingAccess::COOKIE => 'short'])))->toBeNull() + ->and($access->hasGrant(trackingRequest(), null))->toBeFalse(); + + $cookie = $access->grant(trackingRequest(), $scope, 'sms', 24); + $token = $cookie->getValue(); + $device = trackingRequest([TrackingAccess::COOKIE => $token]); + + expect($cookie->getName())->toBe(TrackingAccess::COOKIE) + ->and($cookie->isHttpOnly())->toBeTrue() + ->and($cookie->getSameSite())->toBe('lax') + ->and(TrackingSession::query()->value('token_hash'))->toBe(hash('sha256', $token)) + ->and(TrackingSession::query()->first()->grants()->count())->toBe(1) + ->and($access->hasGrant($device, $scope))->toBeTrue() + ->and($access->hasGrant($device, trackingScope('contact-2')))->toBeFalse() + ->and($access->grantExpiresAt($device, $scope))->toBe('2026-10-09T09:00:00.000000Z') + ->and($access->grantExpiresAt(trackingRequest(), $scope))->toBeNull(); + + // A second customer on the same device; a longer session extends the cookie, a shorter one doesn't. + expect($access->grant($device, trackingScope('contact-2'), 'email', 12))->toBeNull() + ->and($access->grant($device, trackingScope('contact-2'), 'email', 48)?->getValue())->toBe($token) + ->and(TrackingSessionGrant::query()->count())->toBe(2); + + // Signing out of one delivery keeps the others. + expect($access->revoke($device, $scope))->toBeNull() + ->and($access->hasGrant($device, $scope))->toBeFalse() + ->and($access->hasGrant($device, trackingScope('contact-2')))->toBeTrue(); + + $expired = $access->revoke($device); + expect($expired->getValue())->toBe('') + ->and($expired->getExpiresTime())->toBe(1) + ->and($access->session($device))->toBeNull() + ->and($access->revoke($device)->getExpiresTime())->toBe(1); + + // Expired sessions don't count. + $fresh = $access->grant(trackingRequest(), $scope, 'sms', 1); + Carbon::setTestNow(Carbon::parse('2026-10-08 11:00:00')); + expect($access->session(trackingRequest([TrackingAccess::COOKIE => $fresh->getValue()])))->toBeNull(); +}); + +test('the session cookie follows the same-site setting', function () { + $access = trackingAccess(); + + config(['fleetops.tracking.cookie_same_site' => 'none']); + $none = $access->cookie('value', 60); + expect($none->getSameSite())->toBe('none')->and($none->isSecure())->toBeTrue(); + + config(['fleetops.tracking.cookie_same_site' => 'bogus', 'session.secure' => true]); + $fallback = $access->cookie('value', 60); + expect($fallback->getSameSite())->toBe('lax')->and($fallback->isSecure())->toBeTrue(); +}); diff --git a/server/tests/Unit/Support/TrackingPage/TrackingRecipientTest.php b/server/tests/Unit/Support/TrackingPage/TrackingRecipientTest.php new file mode 100644 index 000000000..87ea6de36 --- /dev/null +++ b/server/tests/Unit/Support/TrackingPage/TrackingRecipientTest.php @@ -0,0 +1,51 @@ + 'contact-1', 'email' => 'maya@example.com', 'phone' => '+1 (510) 555-4821']); + TrackingPageDatabase::insert($db, 'vendors', ['uuid' => 'vendor-1', 'email' => 'not-an-email', 'phone' => '12']); + + $contact = TrackingRecipient::for(new TrackingScope('order-1', Contact::class, 'contact-1')); + expect($contact->customer?->uuid)->toBe('contact-1') + ->and($contact->email)->toBe('maya@example.com') + ->and($contact->phone)->toBe('+1 (510) 555-4821') + ->and($contact->destination(TrackingRecipient::CHANNEL_SMS))->toBe('+1 (510) 555-4821') + ->and($contact->destination(TrackingRecipient::CHANNEL_EMAIL))->toBe('maya@example.com') + ->and($contact->destination('fax'))->toBeNull(); + + // Unusable details are dropped, and the stop's place phone stands in for a missing one. + $vendor = TrackingRecipient::for(new TrackingScope('order-1', Vendor::class, 'vendor-1'), '+65 6555 1234'); + expect($vendor->customer?->uuid)->toBe('vendor-1') + ->and($vendor->email)->toBeNull() + ->and($vendor->phone)->toBe('+65 6555 1234'); + + $unknown = TrackingRecipient::for(new TrackingScope('order-1', Place::class, 'place-1')); + expect($unknown->customer)->toBeNull() + ->and($unknown->email)->toBeNull() + ->and($unknown->phone)->toBeNull() + ->and(TrackingRecipient::for(null)->customer)->toBeNull(); +}); + +test('channels follow the settings and the instance, with masked destinations', function () { + $recipient = new TrackingRecipient(null, 'maya.chen@gmail.com', '+15105554821'); + + expect($recipient->channels([], true))->toBe([ + ['type' => 'sms', 'masked' => 'โ€ขโ€ขโ€ขโ€ข โ€ขโ€ข 4821'], + ['type' => 'email', 'masked' => 'mโ€ขโ€ขโ€ข@gโ€ขโ€ขโ€ข.com'], + ]) + ->and($recipient->channels([], false))->toBe([['type' => 'email', 'masked' => 'mโ€ขโ€ขโ€ข@gโ€ขโ€ขโ€ข.com']]) + ->and($recipient->channels(['access' => ['channels' => ['sms' => false, 'email' => false]]], true))->toBe([]) + ->and(TrackingRecipient::maskEmail('ops@localhost'))->toBe('oโ€ขโ€ขโ€ข@lโ€ขโ€ขโ€ข'); +}); diff --git a/server/tests/Unit/Support/TrackingPage/TrackingResolverTest.php b/server/tests/Unit/Support/TrackingPage/TrackingResolverTest.php new file mode 100644 index 000000000..40f2ee855 --- /dev/null +++ b/server/tests/Unit/Support/TrackingPage/TrackingResolverTest.php @@ -0,0 +1,181 @@ + [ + ['uuid' => 'tn-order', 'company_uuid' => 'co-1', 'tracking_number' => 'NOR1000000001US', 'owner_uuid' => 'order-1', 'owner_type' => Order::class], + ['uuid' => 'tn-stop', 'company_uuid' => 'co-1', 'tracking_number' => 'NOR1000000002US', 'owner_uuid' => 'wp-1', 'owner_type' => Waypoint::class], + ['uuid' => 'tn-item', 'company_uuid' => 'co-1', 'tracking_number' => 'NOR1000000003US', 'owner_uuid' => 'entity-1', 'owner_type' => Entity::class], + ['uuid' => 'tn-loose-item', 'company_uuid' => 'co-1', 'tracking_number' => 'NOR1000000004US', 'owner_uuid' => 'entity-2', 'owner_type' => Entity::class], + ['uuid' => 'tn-dropoff', 'company_uuid' => 'co-1', 'tracking_number' => 'NOR1000000005US', 'owner_uuid' => 'place-dropoff', 'owner_type' => Place::class], + ['uuid' => 'tn-orphan-stop', 'company_uuid' => 'co-1', 'tracking_number' => 'NOR1000000006US', 'owner_uuid' => 'wp-orphan', 'owner_type' => Waypoint::class], + ['uuid' => 'tn-orphan-item', 'company_uuid' => 'co-1', 'tracking_number' => 'NOR1000000007US', 'owner_uuid' => 'entity-orphan', 'owner_type' => Entity::class], + ['uuid' => 'tn-orphan-place', 'company_uuid' => 'co-1', 'tracking_number' => 'NOR1000000008US', 'owner_uuid' => 'place-loose', 'owner_type' => Place::class], + ['uuid' => 'tn-missing', 'company_uuid' => 'co-1', 'tracking_number' => 'NOR1000000009US', 'owner_uuid' => 'gone', 'owner_type' => Order::class], + ['uuid' => 'tn-other', 'company_uuid' => 'co-2', 'tracking_number' => 'HAR2000000001US', 'owner_uuid' => 'order-2', 'owner_type' => Order::class], + ], + 'orders' => [ + ['uuid' => 'order-1', 'public_id' => 'order_one', 'company_uuid' => 'co-1', 'payload_uuid' => 'payload-1', 'customer_uuid' => 'contact-shipper', 'customer_type' => Contact::class, 'tracking_number_uuid' => 'tn-order'], + ['uuid' => 'order-2', 'public_id' => 'order_two', 'company_uuid' => 'co-2', 'payload_uuid' => 'payload-2', 'customer_uuid' => 'contact-other', 'customer_type' => Contact::class], + ], + 'places' => [ + ['uuid' => 'place-dropoff', 'name' => 'Front desk'], + ['uuid' => 'place-loose', 'name' => 'Loose'], + ], + 'payloads' => [ + ['uuid' => 'payload-1', 'dropoff_tracking_number_uuid' => 'tn-dropoff'], + ], + 'waypoints' => [ + ['uuid' => 'wp-1', 'public_id' => 'waypoint_one', 'payload_uuid' => 'payload-1', 'place_uuid' => 'place-a', 'customer_uuid' => 'contact-a', 'customer_type' => Contact::class], + ['uuid' => 'wp-orphan', 'payload_uuid' => 'payload-none', 'place_uuid' => 'place-x'], + ], + 'entities' => [ + ['uuid' => 'entity-1', 'payload_uuid' => 'payload-1', 'destination_uuid' => 'place-a'], + ['uuid' => 'entity-2', 'payload_uuid' => 'payload-1'], + ['uuid' => 'entity-orphan', 'payload_uuid' => 'payload-none'], + ], + ]; + + foreach ($rows as $table => $records) { + foreach ($records as $record) { + TrackingPageDatabase::insert($db, $table, $record); + } + } + + return $db; +} + +function trackingResolverSetting($db, string $key, mixed $value): void +{ + TrackingPageDatabase::insert($db, 'settings', ['key' => $key, 'value' => json_encode($value)]); +} + +test('tracking numbers are normalized and anything else is refused', function () { + expect(TrackingResolver::normalize(' NOR1000000001US '))->toBe('NOR1000000001US') + ->and(TrackingResolver::normalize('a.b_c-1'))->toBe('a.b_c-1') + ->and(TrackingResolver::normalize(['NOR1']))->toBeNull() + ->and(TrackingResolver::normalize(''))->toBeNull() + ->and(TrackingResolver::normalize('-NOR'))->toBeNull() + ->and(TrackingResolver::normalize("NOR1' OR 1=1"))->toBeNull() + ->and(TrackingResolver::normalize(str_repeat('A', 101)))->toBeNull() + ->and((new TrackingResolver())->resolve('../etc'))->toBeNull(); +}); + +test('each kind of tracking number opens its order for the right customer', function () { + trackingResolverFixture(); + $resolver = new TrackingResolver(); + + $order = $resolver->resolve('NOR1000000001US'); + expect($order)->toBeInstanceOf(TrackingTarget::class) + ->and($order->kind)->toBe(TrackingTarget::KIND_ORDER) + ->and($order->order->uuid)->toBe('order-1') + ->and($order->scope->customer_uuid)->toBe('contact-shipper') + ->and($order->companyUuid())->toBe('co-1') + ->and($order->config['generic_page']['allowed'])->toBeTrue(); + + $stop = $resolver->resolve('NOR1000000002US'); + expect($stop->kind)->toBe(TrackingTarget::KIND_STOP) + ->and($stop->waypoint->uuid)->toBe('wp-1') + ->and($stop->scope->customer_uuid)->toBe('contact-a'); + + $item = $resolver->resolve('NOR1000000003US'); + expect($item->kind)->toBe(TrackingTarget::KIND_ITEM) + ->and($item->waypoint->uuid)->toBe('wp-1') + ->and($item->scope->customer_uuid)->toBe('contact-a'); + + // An item with no destination stop belongs to the order's customer. + $loose = $resolver->resolve('NOR1000000004US'); + expect($loose->waypoint)->toBeNull() + ->and($loose->scope->customer_uuid)->toBe('contact-shipper'); + + $dropoff = $resolver->resolve('NOR1000000005US'); + expect($dropoff->kind)->toBe(TrackingTarget::KIND_STOP) + ->and($dropoff->scope->customer_uuid)->toBe('contact-shipper'); + + foreach (['NOR1000000006US', 'NOR1000000007US', 'NOR1000000008US', 'NOR1000000009US', 'UNKNOWN1'] as $number) { + expect($resolver->resolve($number))->toBeNull(); + } +}); + +test('companies that opted out of the shared page, or a disabled shared page, resolve to nothing', function () { + $db = trackingResolverFixture(); + $resolver = new TrackingResolver(); + + trackingResolverSetting($db, 'company.co-1.' . TrackingPageConfig::SETTING_KEY, ['generic_page' => ['allowed' => false]]); + expect($resolver->resolve('NOR1000000001US'))->toBeNull() + ->and($resolver->resolve('HAR2000000001US'))->not->toBeNull(); + + trackingResolverSetting($db, TrackingPageConfig::ADMIN_SETTING_KEY, ['generic_page' => ['enabled' => false]]); + expect($resolver->resolve('HAR2000000001US'))->toBeNull() + ->and($resolver->adminConfig()['generic_page']['enabled'])->toBeFalse(); +}); + +test('a company page finds only its own numbers, at its own enabled slug', function () { + $db = trackingResolverFixture(); + $resolver = new TrackingResolver(); + + expect($resolver->companyForSlug('NOT A SLUG'))->toBeNull() + ->and($resolver->companyForSlug('northwind'))->toBeNull(); + + trackingResolverSetting($db, TrackingPageConfig::SLUG_INDEX_PREFIX . 'northwind', ['company' => 'not-a-string']); + expect($resolver->companyForSlug('northwind'))->toBeNull(); + + $db->table('settings')->where('key', TrackingPageConfig::SLUG_INDEX_PREFIX . 'northwind')->update(['value' => json_encode('co-1')]); + // Indexed, but the page is off. + expect($resolver->companyForSlug('northwind'))->toBeNull() + ->and($resolver->linkSlugFor('co-1'))->toBeNull(); + + trackingResolverSetting($db, 'company.co-1.' . TrackingPageConfig::SETTING_KEY, ['org_page' => ['enabled' => true, 'slug' => 'northwind'], 'generic_page' => ['allowed' => false]]); + expect($resolver->companyForSlug(' Northwind '))->toBe('co-1') + ->and($resolver->linkSlugFor('co-1'))->toBe('northwind'); + + // The company page works even with the shared page turned off, and only for its numbers. + expect($resolver->resolve('NOR1000000001US', 'northwind')?->order->uuid)->toBe('order-1') + ->and($resolver->resolve('HAR2000000001US', 'northwind'))->toBeNull() + ->and($resolver->resolve('NOR1000000001US', 'harbor'))->toBeNull(); + + // An index entry left behind by a slug the company changed no longer resolves. + trackingResolverSetting($db, TrackingPageConfig::SLUG_INDEX_PREFIX . 'old-name', 'co-1'); + expect($resolver->companyForSlug('old-name'))->toBeNull(); + + $db->table('settings')->where('key', 'company.co-1.' . TrackingPageConfig::SETTING_KEY)->update(['value' => json_encode(['org_page' => ['enabled' => true, 'slug' => 'northwind'], 'links' => ['target' => 'generic']])]); + expect($resolver->linkSlugFor('co-1'))->toBeNull(); +}); + +test('a target owned by another company is refused on a company page', function () { + trackingResolverFixture(); + + $resolver = new class extends TrackingResolver { + public function companyForSlug(?string $slug): ?string + { + return 'co-2'; + } + + protected function findTrackingNumber(string $number, ?string $companyUuid): ?TrackingNumber + { + return parent::findTrackingNumber($number, null); + } + }; + + expect($resolver->resolve('NOR1000000001US', 'harbor'))->toBeNull() + ->and($resolver->targetFor(new TrackingNumber()))->toBeNull(); +}); diff --git a/server/tests/Unit/Support/TrackingPage/TrackingViewTest.php b/server/tests/Unit/Support/TrackingPage/TrackingViewTest.php new file mode 100644 index 000000000..cce239c75 --- /dev/null +++ b/server/tests/Unit/Support/TrackingPage/TrackingViewTest.php @@ -0,0 +1,417 @@ +attributes['street1'] ?? null; + } +} + +class TrackingViewDriver extends Driver +{ + protected $casts = []; + protected $appends = []; +} + +/** + * A TrackingView whose stops, tracker, items, proofs and instructions come from the test. + */ +class TrackingViewProbe extends TrackingView +{ + public Collection $stops; + public array $tracker = []; + public ?Collection $entities = null; + public ?Collection $proofRows = null; + public array $proofQueries = []; + + protected function stopsFor(Order $order): Collection + { + return $this->stops; + } + + protected function trackerFor(Order $order): array + { + return $this->tracker; + } + + protected function entitiesFor(Order $order, array $placeUuids, TrackingScope $scope): Collection + { + return $this->entities ?? parent::entitiesFor($order, $placeUuids, $scope); + } + + protected function proofsFor(array $subjectUuids): Collection + { + $this->proofQueries[] = $subjectUuids; + + return $this->proofRows ?? parent::proofsFor($subjectUuids); + } +} + +afterEach(function () { + Carbon::setTestNow(); + EloquentModel::unsetConnectionResolver(); +}); + +function trackingViewPlace(string $uuid, string $name, float $lat, float $lng): TrackingViewPlace +{ + $place = new TrackingViewPlace(); + $place->setRawAttributes(['uuid' => $uuid, 'public_id' => 'place_' . $uuid, 'name' => $name, 'street1' => $name . ' street', 'location' => new Point($lat, $lng)], true); + + return $place; +} + +function trackingViewWaypoint(string $uuid, ?string $customer, array $extra = []): Waypoint +{ + $waypoint = new Waypoint(); + $waypoint->setRawAttributes(array_merge(['uuid' => $uuid, 'public_id' => 'waypoint_' . $uuid, 'customer_uuid' => $customer, 'customer_type' => $customer ? Contact::class : null], $extra), true); + + return $waypoint; +} + +/** + * An order with a pickup and two stops: stop A for contact-a, stop B for contact-b. + */ +function trackingViewOrder(array $attributes = []): Order +{ + $order = new Order(); + $order->setRawAttributes(array_merge([ + 'uuid' => 'order-1', + 'company_uuid' => 'co-1', + 'payload_uuid' => 'payload-1', + 'customer_uuid' => 'contact-shipper', + 'customer_type' => Contact::class, + 'tracking_number_uuid' => 'tn-order', + 'status' => 'started', + 'started' => true, + 'dispatched' => true, + 'notes' => 'Gate code 1234', + 'meta' => json_encode(['secret' => 'internal']), + 'updated_at' => '2026-10-08 08:30:00', + ], $attributes), true); + + $driver = new TrackingViewDriver(); + $driver->setRawAttributes(['uuid' => 'driver-1', 'name' => 'Daniel Okafor', 'phone' => '+15550001111', 'online' => 1, 'heading' => 92, 'location' => new Point(1.30123456, 103.80123456), 'updated_at' => '2026-10-08 08:59:00'], true); + $order->setRelation('driverAssigned', $driver); + $order->setRelation('vehicleAssigned', null); + + return $order; +} + +function trackingViewStops(bool $aComplete = false): Collection +{ + return collect([ + new TrackingStop('place-pickup', 'place_pickup', 'pickup', 'completed', trackingViewPlace('pickup', 'Sender Warehouse', 1.2876, 103.8456), null, true, 0, null), + new TrackingStop('place-a', 'place_a', 'waypoint', $aComplete ? 'completed' : 'pending', trackingViewPlace('place-a', 'Maya Flat', 1.3333333, 103.7777777), trackingViewWaypoint('wp-a', 'contact-a', ['time_window_start' => '2026-10-08 14:10:00', 'time_window_end' => '2026-10-08 14:40:00']), $aComplete, 1, 'tn-a'), + new TrackingStop('place-b', 'place_b', 'waypoint', 'pending', trackingViewPlace('place-b', 'Other Customer House', 1.4, 103.9), trackingViewWaypoint('wp-b', 'contact-b'), false, 2, 'tn-b'), + ]); +} + +function trackingViewTarget(Order $order, ?TrackingScope $scope, array $config = [], ?Waypoint $waypoint = null): TrackingTarget +{ + $trackingNumber = new TrackingNumber(); + $trackingNumber->setRawAttributes(['uuid' => 'tn-a', 'tracking_number' => 'NOR1000000002US'], true); + + return new TrackingTarget($order, $scope, $trackingNumber, TrackingTarget::KIND_STOP, $waypoint, TrackingPageConfig::sanitize($config)); +} + +function trackingViewTracker(): array +{ + return [ + 'confidence' => 'high', + 'insights' => ['is_delayed' => true], + 'route' => [ + 'polyline' => '_p~iF~ps|U_ulLnnqC', + 'legs' => [ + ['stop' => ['sequence' => 1], 'eta_seconds' => 1500, 'eta_at' => '2026-10-08T14:24:00.000000Z'], + ['stop' => ['sequence' => 2], 'eta_seconds' => 2400, 'eta_at' => '2026-10-08T14:40:00.000000Z'], + ], + ], + 'eta' => ['completion_at' => '2026-10-08T15:00:00.000000Z', 'completion_seconds' => 3600], + ]; +} + +function trackingViewEntity(string $uuid, ?string $destination, array $extra = []): Entity +{ + $entity = new Entity(); + $entity->setRawAttributes(array_merge(['uuid' => $uuid, 'public_id' => 'entity_' . $uuid, 'name' => 'Item ' . $uuid, 'description' => 'Boxed', 'destination_uuid' => $destination, 'price' => '1500', 'currency' => 'SGD', 'meta' => json_encode(['quantity' => 2, 'secret' => 'x'])], $extra), true); + $entity->setRelation('trackingNumber', null); + + return $entity; +} + +test('branding falls back to the company, then the instance', function () { + $view = new TrackingView(); + $company = (object) ['name' => 'Northwind Couriers', 'logo_uuid' => 'logo-1', 'logo_url' => 'https://cdn.example/logo.png', 'phone' => '+15105550142', 'website_url' => 'https://northwind.example']; + + $branding = $view->branding(TrackingPageConfig::sanitize(['branding' => ['accent' => '#5CC9BE', 'support_email' => 'help@northwind.example']]), $company); + expect($branding)->toMatchArray([ + 'name' => 'Northwind Couriers', + 'logo_url' => 'https://cdn.example/logo.png', + 'accent' => '#5CC9BE', + 'ink' => '#14191A', + 'support_phone' => '+15105550142', + 'support_email' => 'help@northwind.example', + 'website' => 'https://northwind.example', + 'powered_by' => true, + ]); + + $instance = $view->branding(['branding' => ['display_name' => 'Fleetbase']], null, 'https://instance.example/logo.png'); + expect($instance['name'])->toBe('Fleetbase') + ->and($instance['logo_url'])->toBe('https://instance.example/logo.png') + ->and($instance['accent'])->toBe(TrackingPageConfig::DEFAULT_ACCENT) + ->and($instance['locales'])->toBe(TrackingPageConfig::LOCALES); +}); + +test('the coarse stage follows the order and the customer stops', function () { + $view = new TrackingViewProbe(); + $scopeA = new TrackingScope('order-1', Contact::class, 'contact-a'); + $update = fn (Order $order, bool $complete = false) => new Fleetbase\FleetOps\Support\TrackingUpdate($order, $scopeA, trackingViewStops($complete)); + + expect($view->stage(trackingViewOrder(['status' => 'canceled']), null))->toBe('canceled') + ->and($view->stage(trackingViewOrder(['status' => 'attempt_failed']), null))->toBe('issue') + ->and($view->stage(trackingViewOrder(['status' => 'completed']), null))->toBe('delivered') + ->and($view->stage(trackingViewOrder(), $update(trackingViewOrder(), true)))->toBe('delivered') + ->and($view->stage(trackingViewOrder(), $update(trackingViewOrder())))->toBe('in_transit') + ->and($view->stage(trackingViewOrder(['started' => false, 'status' => 'dispatched']), null))->toBe('dispatched') + ->and($view->stage(trackingViewOrder(['started' => false, 'dispatched' => false, 'status' => 'created', 'scheduled_at' => '2099-01-01 09:00:00']), null))->toBe('scheduled') + ->and($view->stage(trackingViewOrder(['started' => false, 'dispatched' => false, 'status' => 'created']), null))->toBe('preparing'); +}); + +test('a tracking number alone shows the stage, the last update and how to verify', function () { + $view = new TrackingViewProbe(); + $view->stops = trackingViewStops(); + $scopeA = new TrackingScope('order-1', Contact::class, 'contact-a'); + + $public = $view->publicView(trackingViewTarget(trackingViewOrder(), $scopeA), ['name' => 'Northwind'], [['type' => 'sms', 'masked' => 'โ€ขโ€ขโ€ขโ€ข โ€ขโ€ข 4821']], true); + expect($public)->toBe([ + 'level' => 0, + 'tracking_number' => 'NOR1000000002US', + 'company' => ['name' => 'Northwind'], + 'stage' => 'in_transit', + 'last_update_on' => '2026-10-08', + 'verify' => ['channels' => [['type' => 'sms', 'masked' => 'โ€ขโ€ขโ€ขโ€ข โ€ขโ€ข 4821']]], + 'sign_in' => true, + ]); + + $hidden = $view->publicView(trackingViewTarget(trackingViewOrder(), null, ['access' => ['public_status' => false]]), [], [], false); + expect($hidden['stage'])->toBeNull()->and($hidden['last_update_on'])->toBeNull(); +}); + +test('a verified customer sees only their own stop on an order with several customers', function () { + Carbon::setTestNow(Carbon::parse('2026-10-08 09:00:00')); + TrackingPageDatabase::boot(); + + $view = new TrackingViewProbe(); + $view->stops = trackingViewStops(); + $view->tracker = trackingViewTracker(); + $view->entities = collect([trackingViewEntity('a1', 'place-a')]); + $order = trackingViewOrder(['meta' => json_encode(['recipient_instructions' => [sha1('order-1:' . Contact::class . ':contact-a') => 'Leave with concierge']])]); + $scopeA = new TrackingScope('order-1', Contact::class, 'contact-a', $order); + + $data = $view->fullView(trackingViewTarget($order, $scopeA, ['visibility' => ['instructions_edit' => true]], $view->stops[1]->waypoint), ['name' => 'Northwind'], TrackingView::VIEWER_VERIFIED, '2026-10-09T09:00:00.000000Z'); + + expect($data['level'])->toBe(1) + ->and($data['viewer'])->toBe('verified') + ->and($data['stage'])->toBe('in_transit') + ->and($data['status'])->toBe(['code' => 'started', 'delayed' => true, 'confidence' => 'high']) + ->and($data['focus_stop'])->toBe('waypoint_wp-a') + ->and($data['route'])->toBe(['total_stops' => 3, 'completed_stops' => 1, 'stops_before' => 0]) + ->and($data['stops'])->toHaveCount(1) + ->and($data['stops'][0])->toMatchArray([ + 'id' => 'waypoint_wp-a', + 'sequence' => 1, + 'complete' => false, + 'place' => ['name' => 'Maya Flat', 'address' => 'Maya Flat street', 'latitude' => 1.33333, 'longitude' => 103.77778], + ]) + ->and($data['stops'][0]['eta']['at'])->toBe('2026-10-08T14:24:00.000000Z') + ->and($data['stops'][0]['window']['start'])->toBe('2026-10-08T14:10:00.000000Z') + ->and($data['eta'])->toBe(['at' => '2026-10-08T14:24:00.000000Z', 'seconds' => 1500, 'confidence' => 'high']) + ->and($data['driver'])->toBe(['name' => 'Daniel', 'vehicle' => null, 'online' => true]) + ->and($data['location']['latitude'])->toBe(1.3012) + ->and($data['map'])->toBe(['hub' => ['latitude' => 1.29, 'longitude' => 103.85], 'polyline' => null]) + ->and($data['items'])->toBe([['id' => 'entity_a1', 'name' => 'Item a1', 'description' => 'Boxed', 'quantity' => 2]]) + ->and($data['instructions'])->toBe(['text' => 'Leave with concierge', 'editable' => true]) + ->and($data['actions'])->toBe(['report' => true, 'driver_contact' => 'company']) + ->and($data['session'])->toBe(['expires_at' => '2026-10-09T09:00:00.000000Z']) + ->and($data['stale_after'])->toBe(300) + ->and($data['proofs'])->toBe([]); + + // Nothing of the other customer, the sender, the driver or the order internals. + $json = json_encode($data); + foreach (['Other Customer House', 'waypoint_wp-b', 'Sender Warehouse', 'Okafor', '+15550001111', 'Gate code', 'internal', 'order-1', 'contact-a', 'co-1', 'SGD'] as $leak) { + expect($json)->not->toContain($leak); + } +}); + +test('the route line, prices and proofs appear only when the customer may see them', function () { + Carbon::setTestNow(Carbon::parse('2026-10-08 09:00:00')); + TrackingPageDatabase::boot(); + + // Every stop belongs to the order customer: the route line is theirs to see. + $order = trackingViewOrder(['status' => 'completed']); + $scope = new TrackingScope('order-1', Contact::class, 'contact-shipper', $order); + $view = new TrackingViewProbe(); + $view->stops = collect([ + new TrackingStop('place-pickup', 'place_pickup', 'pickup', 'completed', trackingViewPlace('pickup', 'Sender', 1.28, 103.84), null, true, 0, null), + new TrackingStop('place-d', 'place_d', 'dropoff', 'completed', trackingViewPlace('place-d', 'Home', 1.35, 103.85), null, true, 1, null), + ]); + $view->tracker = trackingViewTracker(); + $view->entities = collect([trackingViewEntity('d1', null, ['photo_uuid' => 'photo-1'])]); + $signature = new Proof(); + $signature->setRawAttributes(['public_id' => 'proof_sig', 'raw_data' => 'iVBOR', 'created_at' => '2026-10-08 08:50:00'], true); + $photo = new Proof(); + $photo->setRawAttributes(['public_id' => 'proof_photo', 'file_uuid' => 'file-1', 'created_at' => '2026-10-08 08:51:00'], true); + $scan = new Proof(); + $scan->setRawAttributes(['public_id' => 'proof_scan', 'remarks' => 'Verified by QR Code Scan'], true); + $view->proofRows = collect([$signature, $photo, $scan]); + + $data = $view->fullView(trackingViewTarget($order, $scope, ['visibility' => ['item_prices' => true, 'pod_photo' => false]]), [], TrackingView::VIEWER_ACCOUNT); + + expect($data['level'])->toBe(2) + ->and($data['stage'])->toBe('delivered') + ->and($data['driver'])->toBeNull() + ->and($data['location'])->toBeNull() + ->and($data['eta'])->toBeNull() + ->and($data['map']['polyline'])->toBeNull() + ->and($data['items'][0])->toMatchArray(['price' => '1500', 'currency' => 'SGD']) + ->and($data['items'][0]['photo_url'])->toBeString() + ->and(collect($data['proofs'])->pluck('type')->all())->toBe(['signature', 'scan']) + ->and($view->proofQueries[0])->toBe(['d1', 'order-1']); + + // While under way, with nothing but this customer's stops, the line is drawn. + $order = trackingViewOrder(); + $view->stops = collect([ + new TrackingStop('place-pickup', 'place_pickup', 'pickup', 'completed', trackingViewPlace('pickup', 'Sender', 1.28, 103.84), null, true, 0, null), + new TrackingStop('place-d', 'place_d', 'dropoff', 'pending', trackingViewPlace('place-d', 'Home', 1.35, 103.85), null, false, 1, null), + ]); + $view->tracker = ['eta' => ['completion_at' => '2026-10-08T10:00:00.000000Z', 'completion_seconds' => 3600], 'route' => ['polyline' => 'abc']]; + $data = $view->fullView(trackingViewTarget($order, new TrackingScope('order-1', Contact::class, 'contact-shipper', $order)), [], TrackingView::VIEWER_STAFF); + + expect($data['map']['polyline'])->toBe('abc') + ->and($data['eta'])->toBe(['at' => '2026-10-08T10:00:00.000000Z', 'seconds' => 3600, 'confidence' => null]) + ->and($data['items'][0])->not->toHaveKey('price'); + + // The company can hide the map, the driver's name, the vehicle and the items. + $view->tracker = []; + $hidden = $view->fullView(trackingViewTarget($order, new TrackingScope('order-1', Contact::class, 'contact-shipper', $order), ['visibility' => ['map' => false, 'driver_name' => false, 'vehicle' => false, 'items' => false]]), [], TrackingView::VIEWER_VERIFIED); + expect($hidden['map'])->toBeNull() + ->and($hidden['location'])->toBeNull() + ->and($hidden['driver'])->toBe(['name' => null, 'vehicle' => null, 'online' => true]) + ->and($hidden['items'])->toBe([]) + ->and($hidden['eta'])->toBeNull(); + + // An order that names no customer still renders for staff, with no stops of anyone's. + $none = $view->fullView(trackingViewTarget(trackingViewOrder(['customer_uuid' => null, 'customer_type' => null]), null), [], TrackingView::VIEWER_STAFF); + expect($none['stops'])->toBe([])->and($none['instructions']['text'])->toBeNull(); +}); + +test('proof types and proofs a scope may open', function () { + TrackingPageDatabase::boot(); + $file = new Fleetbase\Models\File(); + $file->setRawAttributes(['type' => 'signature'], true); + $signed = new Proof(); + $signed->setRelation('file', $file); + + $view = new class extends TrackingViewProbe { + public ?Proof $found = null; + + protected function findProof(string $publicId): ?Proof + { + return $this->found; + } + }; + $view->stops = trackingViewStops(true); + $view->entities = collect(); + $proof = new Proof(); + $proof->setRawAttributes(['public_id' => 'proof_a', 'file_uuid' => 'file-a'], true); + $view->proofRows = collect([$proof]); + $view->found = $proof; + + $target = trackingViewTarget(trackingViewOrder(), new TrackingScope('order-1', Contact::class, 'contact-a')); + + expect(TrackingView::proofType($signed))->toBe('signature') + ->and($view->proofFor($target, 'proof_a'))->toBe($proof) + ->and($view->proofFor($target, 'proof_other'))->toBeNull() + ->and($view->proofQueries[0])->toBe(['wp-a']); +}); + +test('stops, the tracker, items and proofs come from their real sources', function () { + $db = TrackingPageDatabase::boot(); + TrackingPageDatabase::insert($db, 'entities', ['uuid' => 'e-a', 'payload_uuid' => 'payload-1', 'destination_uuid' => 'place-a', 'name' => 'Lamp']); + TrackingPageDatabase::insert($db, 'entities', ['uuid' => 'e-none', 'payload_uuid' => 'payload-1', 'name' => 'Shade']); + TrackingPageDatabase::insert($db, 'entities', ['uuid' => 'e-b', 'payload_uuid' => 'payload-1', 'destination_uuid' => 'place-b', 'name' => 'Chair']); + TrackingPageDatabase::insert($db, 'proofs', ['uuid' => 'p-1', 'public_id' => 'proof_one', 'subject_uuid' => 'wp-a', 'created_at' => '2026-10-08 09:00:00']); + + $context = new Fleetbase\FleetOps\Tracking\TrackingContext(trackingViewOrder(), null, null, null, null, collect(['stop']), collect(), collect(), null, null, null); + app()->instance(TrackingContextBuilder::class, new class($context) extends TrackingContextBuilder { + public function __construct(private $context) + { + } + + public function build(Order $order, Fleetbase\FleetOps\Tracking\TrackingOptions $options): Fleetbase\FleetOps\Tracking\TrackingContext + { + return $this->context; + } + }); + app()->instance(TrackingIntelligenceService::class, new class extends TrackingIntelligenceService { + public bool $fail = false; + + public function __construct() + { + } + + public function track(Order $order, array|Fleetbase\FleetOps\Tracking\TrackingOptions $options = []): array + { + if ($this->fail) { + throw new RuntimeException('provider down'); + } + + return ['provider' => 'fake']; + } + }); + + $view = new TrackingView(); + $call = fn (string $method, ...$args) => (fn () => $this->{$method}(...$args))->call($view); + $order = trackingViewOrder(); + $scopeA = new TrackingScope('order-1', Contact::class, 'contact-a', $order); + $shipper = new TrackingScope('order-1', Contact::class, 'contact-shipper', $order); + + expect($call('stopsFor', $order)->all())->toBe(['stop']) + ->and($call('trackerFor', $order))->toBe(['provider' => 'fake']) + ->and($call('trackerFor', trackingViewOrder(['status' => 'completed'])))->toBe([]); + + app(TrackingIntelligenceService::class)->fail = true; + expect($call('trackerFor', $order))->toBe([]) + ->and($call('entitiesFor', $order, ['place-a'], $scopeA)->pluck('uuid')->all())->toBe(['e-a']) + ->and($call('entitiesFor', $order, [], $shipper)->pluck('uuid')->all())->toBe(['e-none']) + ->and($call('entitiesFor', trackingViewOrder(['payload_uuid' => null]), [], $shipper)->all())->toBe([]) + ->and($call('proofsFor', ['wp-a'])->pluck('public_id')->all())->toBe(['proof_one']) + ->and($call('proofsFor', [])->all())->toBe([]) + ->and($call('findProof', 'proof_one')?->uuid)->toBe('p-1') + ->and($call('updateFor', $order, $scopeA, trackingViewStops(), [])->ownStops()->count())->toBe(1); +}); diff --git a/translations/ar-ae.yml b/translations/ar-ae.yml index 67da921fb..386903b9f 100644 --- a/translations/ar-ae.yml +++ b/translations/ar-ae.yml @@ -2120,3 +2120,176 @@ tracking-page: preview-sub: "2:10 to 2:40 pm" preview-button: "Verify it's you" preview-powered-by: "Powered by Fleetbase" + page: + track-another: "Track another" + unavailable-title: "Tracking isn't available here" + unavailable-help: "This tracking page isn't switched on. Use the link in your delivery email, or contact the company that sent your order." + lookup-title: "Track your delivery" + lookup-sub: "See where your parcel is and when it will arrive." + tracking-number: "Tracking number" + tracking-number-placeholder: "e.g. NOR4829301736US" + tracking-number-help: "Find it in your confirmation email or on the parcel label." + lookup-neutral: "We can't show tracking for that number. Check it matches your email or parcel label and try again." + lookup-rate: "That's a few tries in a row. Take a short break and try again in a few minutes." + looking-up: "Looking it up..." + track: "Track" + or: "or" + your-orders: "Your orders" + sign-in: "Sign in to see all your orders" + sign-in-account: "Sign in to your account" + back: "Back" + help: "Need help?" + language: "Language" + powered-by: "Powered by Fleetbase" + dismiss: "Dismiss" + chip-limited: "Limited view ยท Verify to see details" + chip-verified: "Verified on this device" + chip-account: "Signed in" + chip-staff: "Staff preview: what this customer sees" + status: "Status" + verify-to-see: "Verify to see your delivery" + last-update: "Last update: {date}" + copy: "Copy" + verify-title: "See your full delivery details" + verify-help: "For your privacy, the map, arrival time and delivery details unlock after a quick check. We'll send a 6-digit code to the phone or email on this order." + verify-button: "Verify it's you" + verify-unlock: "Verify to unlock" + locked-map: "Live map" + locked-arrival: "Arrival time and driver" + locked-details: "Updates, items and proof of delivery" + stage-preparing: "Being prepared" + stage-scheduled: "Scheduled" + stage-dispatched: "Driver assigned" + stage-in_transit: "In transit" + stage-delivered: "Delivered" + stage-issue: "Needs attention" + stage-canceled: "Canceled" + stage-toast-preparing: "Your order is being prepared" + stage-toast-scheduled: "Your delivery is scheduled" + stage-toast-dispatched: "A driver has been assigned" + stage-toast-in_transit: "Your order is on its way" + stage-toast-delivered: "Your order has been delivered" + stage-toast-issue: "There's an update on your delivery" + stage-toast-canceled: "Your order was canceled" + verify-title-short: "Verify it's you" + verify-choose-help: "To protect your delivery details, we'll send a 6-digit code to the contact details on this order." + send-by: "Send the code by" + channel-sms: "Text message" + channel-email: "Email" + send-failed: "We couldn't send a code just now. Please try again." + sending: "Sending code..." + send-code: "Send code" + masked-note: "We only ever show part of your number or email here." + no-contact-title: "We can't verify you here" + no-contact-help: "This order has no phone number or email we can send a code to. {name} can help you with your delivery." + call: "Call {phone}" + renew-title: "Get a new code" + renew-expired: "That code has expired. Codes last 10 minutes." + renew-unlocked: "You can try again now. We'll send a new code; the old one no longer works." + renew-limited: "You've asked for several codes. Please wait a few minutes before requesting another." + renew-failed: "Something went wrong. Request a new code to try again." + send-new-code: "Send a new code" + verified-title: "You're verified" + locked-title: "Codes paused" + enter-code: "Enter your code" + code-sent-sms: "We texted a 6-digit code to {masked}." + code-sent-email: "We emailed a 6-digit code to {masked}." + code-label: "6-digit code" + code-expires: "The code expires in 10 minutes." + code-wrong: "That code didn't match. {count, plural, one {# attempt} other {# attempts}} left." + code-locked: "Too many attempts. For your security, codes are paused. Try again in {time}." + code-success: "Verified. Opening your delivery..." + resend-in: "Resend code in {time}" + resend: "Resend code" + switch-to-sms: "Send by text instead" + switch-to-email: "Send by email instead" + stay-verified: "Once verified, this device stays verified for {hours} hours. You can sign out at any time." + verified-toast: "You're verified" + verified-toast-help: "This device stays verified for this delivery." + signed-out-toast: "Signed out on this device" + instructions-saved: "Delivery instructions saved" + action-failed: "That didn't work. Please try again." + report-sent: "Message sent" + report-sent-help: "{name} will get back to you." + share-copied: "Link copied" + share-copied-help: "Anyone you share it with sees a limited view until they verify themselves." + your-stop: "Stop {number}" + your-stops: "Your stops" + sign-out: "Not you? Sign out" + live: "Live" + reconnecting: "Reconnecting... live updates paused" + timezone: "Times in your time zone ({zone})" + progress: "Delivery progress" + step-created: "Created" + step-dispatched: "Dispatched" + step-started: "Picked up" + step-enroute: "En route" + step-delivered: "Delivered" + step-issue: "Attempt missed" + step-canceled: "Canceled" + headline-preparing: "Getting your order ready" + headline-scheduled: "Scheduled for {date}" + headline-dispatched: "A driver has been assigned" + headline-delayed: "Running a little late" + headline-arriving: "Arriving {day}" + headline-in-transit: "On its way" + headline-delivered: "Delivered" + headline-issue: "There's a problem with your delivery" + headline-canceled: "This order was canceled" + sub-arriving: "{window} ยท in about {minutes} min" + sub-window: "Delivery window {window}" + sub-delivered: "{when}" + sub-dispatched: "Live tracking starts as soon as they're on the way." + sub-preparing: "We'll let you know when it's on its way." + delayed-title: "Running late." + delayed-help: "We'll keep your arrival time up to date." + stop-of: "Your delivery is stop {position} of {total}" + stops-private: "{count, plural, =0 {You're next.} one {# stop to go before yours.} other {# stops to go before yours.}} Other stops stay private, so you only see your own." + map: "Map" + map-label: "Map of your delivery: the van on its way to your address." + map-hidden: "The map is hidden now your order is delivered. We stop sharing the driver's location at your door." + last-seen: "Last seen {minutes} min ago" + recenter: "Center the map on the van" + fit-route: "Show the whole route" + eta: "Estimated arrival" + in-minutes: "in about {minutes} min" + window: "Delivery window" + before-yours: "Before yours" + stops-count: "{count, plural, =0 {None} one {# stop} other {# stops}}" + estimate-may-change: "Estimate may change" + driver: "Your driver" + online: "Online" + location-paused: "Location paused" + call-short: "Call" + message: "Message" + contact-note: "Calls and messages go to the {name} support team." + proof: "Proof of delivery" + open-photo: "Open the delivery photo" + open-signature: "Open the signature" + open-scan: "Open the scan" + proof-photo: "Delivery photo" + proof-signature: "Signature" + proof-scan: "Scanned at the door" + instructions: "Delivery instructions" + edit: "Edit" + instructions-help: "Gate codes, where to leave it, or who can sign" + cancel: "Cancel" + save: "Save" + no-instructions: "No instructions yet." + updates: "Updates" + no-updates: "No updates yet." + show-fewer: "Show fewer updates" + show-earlier: "Show earlier updates ({count})" + today: "Today" + yesterday: "Yesterday" + items: "In this delivery" + item: "Item" + quantity: "Qty {count}" + share: "Share tracking" + report: "Report a problem" + contact: "Contact {name}" + report-label: "What's wrong with your delivery?" + send: "Send" + close: "Close" + no-orders: "No orders yet" + no-orders-help: "Deliveries for your account will show up here." diff --git a/translations/bg-bg.yaml b/translations/bg-bg.yaml index bc47638da..5f4d10ce0 100644 --- a/translations/bg-bg.yaml +++ b/translations/bg-bg.yaml @@ -2435,3 +2435,176 @@ tracking-page: preview-sub: "2:10 to 2:40 pm" preview-button: "Verify it's you" preview-powered-by: "Powered by Fleetbase" + page: + track-another: "Track another" + unavailable-title: "Tracking isn't available here" + unavailable-help: "This tracking page isn't switched on. Use the link in your delivery email, or contact the company that sent your order." + lookup-title: "Track your delivery" + lookup-sub: "See where your parcel is and when it will arrive." + tracking-number: "Tracking number" + tracking-number-placeholder: "e.g. NOR4829301736US" + tracking-number-help: "Find it in your confirmation email or on the parcel label." + lookup-neutral: "We can't show tracking for that number. Check it matches your email or parcel label and try again." + lookup-rate: "That's a few tries in a row. Take a short break and try again in a few minutes." + looking-up: "Looking it up..." + track: "Track" + or: "or" + your-orders: "Your orders" + sign-in: "Sign in to see all your orders" + sign-in-account: "Sign in to your account" + back: "Back" + help: "Need help?" + language: "Language" + powered-by: "Powered by Fleetbase" + dismiss: "Dismiss" + chip-limited: "Limited view ยท Verify to see details" + chip-verified: "Verified on this device" + chip-account: "Signed in" + chip-staff: "Staff preview: what this customer sees" + status: "Status" + verify-to-see: "Verify to see your delivery" + last-update: "Last update: {date}" + copy: "Copy" + verify-title: "See your full delivery details" + verify-help: "For your privacy, the map, arrival time and delivery details unlock after a quick check. We'll send a 6-digit code to the phone or email on this order." + verify-button: "Verify it's you" + verify-unlock: "Verify to unlock" + locked-map: "Live map" + locked-arrival: "Arrival time and driver" + locked-details: "Updates, items and proof of delivery" + stage-preparing: "Being prepared" + stage-scheduled: "Scheduled" + stage-dispatched: "Driver assigned" + stage-in_transit: "In transit" + stage-delivered: "Delivered" + stage-issue: "Needs attention" + stage-canceled: "Canceled" + stage-toast-preparing: "Your order is being prepared" + stage-toast-scheduled: "Your delivery is scheduled" + stage-toast-dispatched: "A driver has been assigned" + stage-toast-in_transit: "Your order is on its way" + stage-toast-delivered: "Your order has been delivered" + stage-toast-issue: "There's an update on your delivery" + stage-toast-canceled: "Your order was canceled" + verify-title-short: "Verify it's you" + verify-choose-help: "To protect your delivery details, we'll send a 6-digit code to the contact details on this order." + send-by: "Send the code by" + channel-sms: "Text message" + channel-email: "Email" + send-failed: "We couldn't send a code just now. Please try again." + sending: "Sending code..." + send-code: "Send code" + masked-note: "We only ever show part of your number or email here." + no-contact-title: "We can't verify you here" + no-contact-help: "This order has no phone number or email we can send a code to. {name} can help you with your delivery." + call: "Call {phone}" + renew-title: "Get a new code" + renew-expired: "That code has expired. Codes last 10 minutes." + renew-unlocked: "You can try again now. We'll send a new code; the old one no longer works." + renew-limited: "You've asked for several codes. Please wait a few minutes before requesting another." + renew-failed: "Something went wrong. Request a new code to try again." + send-new-code: "Send a new code" + verified-title: "You're verified" + locked-title: "Codes paused" + enter-code: "Enter your code" + code-sent-sms: "We texted a 6-digit code to {masked}." + code-sent-email: "We emailed a 6-digit code to {masked}." + code-label: "6-digit code" + code-expires: "The code expires in 10 minutes." + code-wrong: "That code didn't match. {count, plural, one {# attempt} other {# attempts}} left." + code-locked: "Too many attempts. For your security, codes are paused. Try again in {time}." + code-success: "Verified. Opening your delivery..." + resend-in: "Resend code in {time}" + resend: "Resend code" + switch-to-sms: "Send by text instead" + switch-to-email: "Send by email instead" + stay-verified: "Once verified, this device stays verified for {hours} hours. You can sign out at any time." + verified-toast: "You're verified" + verified-toast-help: "This device stays verified for this delivery." + signed-out-toast: "Signed out on this device" + instructions-saved: "Delivery instructions saved" + action-failed: "That didn't work. Please try again." + report-sent: "Message sent" + report-sent-help: "{name} will get back to you." + share-copied: "Link copied" + share-copied-help: "Anyone you share it with sees a limited view until they verify themselves." + your-stop: "Stop {number}" + your-stops: "Your stops" + sign-out: "Not you? Sign out" + live: "Live" + reconnecting: "Reconnecting... live updates paused" + timezone: "Times in your time zone ({zone})" + progress: "Delivery progress" + step-created: "Created" + step-dispatched: "Dispatched" + step-started: "Picked up" + step-enroute: "En route" + step-delivered: "Delivered" + step-issue: "Attempt missed" + step-canceled: "Canceled" + headline-preparing: "Getting your order ready" + headline-scheduled: "Scheduled for {date}" + headline-dispatched: "A driver has been assigned" + headline-delayed: "Running a little late" + headline-arriving: "Arriving {day}" + headline-in-transit: "On its way" + headline-delivered: "Delivered" + headline-issue: "There's a problem with your delivery" + headline-canceled: "This order was canceled" + sub-arriving: "{window} ยท in about {minutes} min" + sub-window: "Delivery window {window}" + sub-delivered: "{when}" + sub-dispatched: "Live tracking starts as soon as they're on the way." + sub-preparing: "We'll let you know when it's on its way." + delayed-title: "Running late." + delayed-help: "We'll keep your arrival time up to date." + stop-of: "Your delivery is stop {position} of {total}" + stops-private: "{count, plural, =0 {You're next.} one {# stop to go before yours.} other {# stops to go before yours.}} Other stops stay private, so you only see your own." + map: "Map" + map-label: "Map of your delivery: the van on its way to your address." + map-hidden: "The map is hidden now your order is delivered. We stop sharing the driver's location at your door." + last-seen: "Last seen {minutes} min ago" + recenter: "Center the map on the van" + fit-route: "Show the whole route" + eta: "Estimated arrival" + in-minutes: "in about {minutes} min" + window: "Delivery window" + before-yours: "Before yours" + stops-count: "{count, plural, =0 {None} one {# stop} other {# stops}}" + estimate-may-change: "Estimate may change" + driver: "Your driver" + online: "Online" + location-paused: "Location paused" + call-short: "Call" + message: "Message" + contact-note: "Calls and messages go to the {name} support team." + proof: "Proof of delivery" + open-photo: "Open the delivery photo" + open-signature: "Open the signature" + open-scan: "Open the scan" + proof-photo: "Delivery photo" + proof-signature: "Signature" + proof-scan: "Scanned at the door" + instructions: "Delivery instructions" + edit: "Edit" + instructions-help: "Gate codes, where to leave it, or who can sign" + cancel: "Cancel" + save: "Save" + no-instructions: "No instructions yet." + updates: "Updates" + no-updates: "No updates yet." + show-fewer: "Show fewer updates" + show-earlier: "Show earlier updates ({count})" + today: "Today" + yesterday: "Yesterday" + items: "In this delivery" + item: "Item" + quantity: "Qty {count}" + share: "Share tracking" + report: "Report a problem" + contact: "Contact {name}" + report-label: "What's wrong with your delivery?" + send: "Send" + close: "Close" + no-orders: "No orders yet" + no-orders-help: "Deliveries for your account will show up here." diff --git a/translations/en-us.yaml b/translations/en-us.yaml index 0a4b1b6bc..cf1043c0b 100644 --- a/translations/en-us.yaml +++ b/translations/en-us.yaml @@ -3700,3 +3700,176 @@ tracking-page: preview-sub: "2:10 to 2:40 pm" preview-button: "Verify it's you" preview-powered-by: "Powered by Fleetbase" + page: + track-another: "Track another" + unavailable-title: "Tracking isn't available here" + unavailable-help: "This tracking page isn't switched on. Use the link in your delivery email, or contact the company that sent your order." + lookup-title: "Track your delivery" + lookup-sub: "See where your parcel is and when it will arrive." + tracking-number: "Tracking number" + tracking-number-placeholder: "e.g. NOR4829301736US" + tracking-number-help: "Find it in your confirmation email or on the parcel label." + lookup-neutral: "We can't show tracking for that number. Check it matches your email or parcel label and try again." + lookup-rate: "That's a few tries in a row. Take a short break and try again in a few minutes." + looking-up: "Looking it up..." + track: "Track" + or: "or" + your-orders: "Your orders" + sign-in: "Sign in to see all your orders" + sign-in-account: "Sign in to your account" + back: "Back" + help: "Need help?" + language: "Language" + powered-by: "Powered by Fleetbase" + dismiss: "Dismiss" + chip-limited: "Limited view ยท Verify to see details" + chip-verified: "Verified on this device" + chip-account: "Signed in" + chip-staff: "Staff preview: what this customer sees" + status: "Status" + verify-to-see: "Verify to see your delivery" + last-update: "Last update: {date}" + copy: "Copy" + verify-title: "See your full delivery details" + verify-help: "For your privacy, the map, arrival time and delivery details unlock after a quick check. We'll send a 6-digit code to the phone or email on this order." + verify-button: "Verify it's you" + verify-unlock: "Verify to unlock" + locked-map: "Live map" + locked-arrival: "Arrival time and driver" + locked-details: "Updates, items and proof of delivery" + stage-preparing: "Being prepared" + stage-scheduled: "Scheduled" + stage-dispatched: "Driver assigned" + stage-in_transit: "In transit" + stage-delivered: "Delivered" + stage-issue: "Needs attention" + stage-canceled: "Canceled" + stage-toast-preparing: "Your order is being prepared" + stage-toast-scheduled: "Your delivery is scheduled" + stage-toast-dispatched: "A driver has been assigned" + stage-toast-in_transit: "Your order is on its way" + stage-toast-delivered: "Your order has been delivered" + stage-toast-issue: "There's an update on your delivery" + stage-toast-canceled: "Your order was canceled" + verify-title-short: "Verify it's you" + verify-choose-help: "To protect your delivery details, we'll send a 6-digit code to the contact details on this order." + send-by: "Send the code by" + channel-sms: "Text message" + channel-email: "Email" + send-failed: "We couldn't send a code just now. Please try again." + sending: "Sending code..." + send-code: "Send code" + masked-note: "We only ever show part of your number or email here." + no-contact-title: "We can't verify you here" + no-contact-help: "This order has no phone number or email we can send a code to. {name} can help you with your delivery." + call: "Call {phone}" + renew-title: "Get a new code" + renew-expired: "That code has expired. Codes last 10 minutes." + renew-unlocked: "You can try again now. We'll send a new code; the old one no longer works." + renew-limited: "You've asked for several codes. Please wait a few minutes before requesting another." + renew-failed: "Something went wrong. Request a new code to try again." + send-new-code: "Send a new code" + verified-title: "You're verified" + locked-title: "Codes paused" + enter-code: "Enter your code" + code-sent-sms: "We texted a 6-digit code to {masked}." + code-sent-email: "We emailed a 6-digit code to {masked}." + code-label: "6-digit code" + code-expires: "The code expires in 10 minutes." + code-wrong: "That code didn't match. {count, plural, one {# attempt} other {# attempts}} left." + code-locked: "Too many attempts. For your security, codes are paused. Try again in {time}." + code-success: "Verified. Opening your delivery..." + resend-in: "Resend code in {time}" + resend: "Resend code" + switch-to-sms: "Send by text instead" + switch-to-email: "Send by email instead" + stay-verified: "Once verified, this device stays verified for {hours} hours. You can sign out at any time." + verified-toast: "You're verified" + verified-toast-help: "This device stays verified for this delivery." + signed-out-toast: "Signed out on this device" + instructions-saved: "Delivery instructions saved" + action-failed: "That didn't work. Please try again." + report-sent: "Message sent" + report-sent-help: "{name} will get back to you." + share-copied: "Link copied" + share-copied-help: "Anyone you share it with sees a limited view until they verify themselves." + your-stop: "Stop {number}" + your-stops: "Your stops" + sign-out: "Not you? Sign out" + live: "Live" + reconnecting: "Reconnecting... live updates paused" + timezone: "Times in your time zone ({zone})" + progress: "Delivery progress" + step-created: "Created" + step-dispatched: "Dispatched" + step-started: "Picked up" + step-enroute: "En route" + step-delivered: "Delivered" + step-issue: "Attempt missed" + step-canceled: "Canceled" + headline-preparing: "Getting your order ready" + headline-scheduled: "Scheduled for {date}" + headline-dispatched: "A driver has been assigned" + headline-delayed: "Running a little late" + headline-arriving: "Arriving {day}" + headline-in-transit: "On its way" + headline-delivered: "Delivered" + headline-issue: "There's a problem with your delivery" + headline-canceled: "This order was canceled" + sub-arriving: "{window} ยท in about {minutes} min" + sub-window: "Delivery window {window}" + sub-delivered: "{when}" + sub-dispatched: "Live tracking starts as soon as they're on the way." + sub-preparing: "We'll let you know when it's on its way." + delayed-title: "Running late." + delayed-help: "We'll keep your arrival time up to date." + stop-of: "Your delivery is stop {position} of {total}" + stops-private: "{count, plural, =0 {You're next.} one {# stop to go before yours.} other {# stops to go before yours.}} Other stops stay private, so you only see your own." + map: "Map" + map-label: "Map of your delivery: the van on its way to your address." + map-hidden: "The map is hidden now your order is delivered. We stop sharing the driver's location at your door." + last-seen: "Last seen {minutes} min ago" + recenter: "Center the map on the van" + fit-route: "Show the whole route" + eta: "Estimated arrival" + in-minutes: "in about {minutes} min" + window: "Delivery window" + before-yours: "Before yours" + stops-count: "{count, plural, =0 {None} one {# stop} other {# stops}}" + estimate-may-change: "Estimate may change" + driver: "Your driver" + online: "Online" + location-paused: "Location paused" + call-short: "Call" + message: "Message" + contact-note: "Calls and messages go to the {name} support team." + proof: "Proof of delivery" + open-photo: "Open the delivery photo" + open-signature: "Open the signature" + open-scan: "Open the scan" + proof-photo: "Delivery photo" + proof-signature: "Signature" + proof-scan: "Scanned at the door" + instructions: "Delivery instructions" + edit: "Edit" + instructions-help: "Gate codes, where to leave it, or who can sign" + cancel: "Cancel" + save: "Save" + no-instructions: "No instructions yet." + updates: "Updates" + no-updates: "No updates yet." + show-fewer: "Show fewer updates" + show-earlier: "Show earlier updates ({count})" + today: "Today" + yesterday: "Yesterday" + items: "In this delivery" + item: "Item" + quantity: "Qty {count}" + share: "Share tracking" + report: "Report a problem" + contact: "Contact {name}" + report-label: "What's wrong with your delivery?" + send: "Send" + close: "Close" + no-orders: "No orders yet" + no-orders-help: "Deliveries for your account will show up here." diff --git a/translations/es-pa.yaml b/translations/es-pa.yaml index 82ae796e4..d61e99616 100644 --- a/translations/es-pa.yaml +++ b/translations/es-pa.yaml @@ -2314,3 +2314,176 @@ tracking-page: preview-sub: "2:10 to 2:40 pm" preview-button: "Verify it's you" preview-powered-by: "Powered by Fleetbase" + page: + track-another: "Track another" + unavailable-title: "Tracking isn't available here" + unavailable-help: "This tracking page isn't switched on. Use the link in your delivery email, or contact the company that sent your order." + lookup-title: "Track your delivery" + lookup-sub: "See where your parcel is and when it will arrive." + tracking-number: "Tracking number" + tracking-number-placeholder: "e.g. NOR4829301736US" + tracking-number-help: "Find it in your confirmation email or on the parcel label." + lookup-neutral: "We can't show tracking for that number. Check it matches your email or parcel label and try again." + lookup-rate: "That's a few tries in a row. Take a short break and try again in a few minutes." + looking-up: "Looking it up..." + track: "Track" + or: "or" + your-orders: "Your orders" + sign-in: "Sign in to see all your orders" + sign-in-account: "Sign in to your account" + back: "Back" + help: "Need help?" + language: "Language" + powered-by: "Powered by Fleetbase" + dismiss: "Dismiss" + chip-limited: "Limited view ยท Verify to see details" + chip-verified: "Verified on this device" + chip-account: "Signed in" + chip-staff: "Staff preview: what this customer sees" + status: "Status" + verify-to-see: "Verify to see your delivery" + last-update: "Last update: {date}" + copy: "Copy" + verify-title: "See your full delivery details" + verify-help: "For your privacy, the map, arrival time and delivery details unlock after a quick check. We'll send a 6-digit code to the phone or email on this order." + verify-button: "Verify it's you" + verify-unlock: "Verify to unlock" + locked-map: "Live map" + locked-arrival: "Arrival time and driver" + locked-details: "Updates, items and proof of delivery" + stage-preparing: "Being prepared" + stage-scheduled: "Scheduled" + stage-dispatched: "Driver assigned" + stage-in_transit: "In transit" + stage-delivered: "Delivered" + stage-issue: "Needs attention" + stage-canceled: "Canceled" + stage-toast-preparing: "Your order is being prepared" + stage-toast-scheduled: "Your delivery is scheduled" + stage-toast-dispatched: "A driver has been assigned" + stage-toast-in_transit: "Your order is on its way" + stage-toast-delivered: "Your order has been delivered" + stage-toast-issue: "There's an update on your delivery" + stage-toast-canceled: "Your order was canceled" + verify-title-short: "Verify it's you" + verify-choose-help: "To protect your delivery details, we'll send a 6-digit code to the contact details on this order." + send-by: "Send the code by" + channel-sms: "Text message" + channel-email: "Email" + send-failed: "We couldn't send a code just now. Please try again." + sending: "Sending code..." + send-code: "Send code" + masked-note: "We only ever show part of your number or email here." + no-contact-title: "We can't verify you here" + no-contact-help: "This order has no phone number or email we can send a code to. {name} can help you with your delivery." + call: "Call {phone}" + renew-title: "Get a new code" + renew-expired: "That code has expired. Codes last 10 minutes." + renew-unlocked: "You can try again now. We'll send a new code; the old one no longer works." + renew-limited: "You've asked for several codes. Please wait a few minutes before requesting another." + renew-failed: "Something went wrong. Request a new code to try again." + send-new-code: "Send a new code" + verified-title: "You're verified" + locked-title: "Codes paused" + enter-code: "Enter your code" + code-sent-sms: "We texted a 6-digit code to {masked}." + code-sent-email: "We emailed a 6-digit code to {masked}." + code-label: "6-digit code" + code-expires: "The code expires in 10 minutes." + code-wrong: "That code didn't match. {count, plural, one {# attempt} other {# attempts}} left." + code-locked: "Too many attempts. For your security, codes are paused. Try again in {time}." + code-success: "Verified. Opening your delivery..." + resend-in: "Resend code in {time}" + resend: "Resend code" + switch-to-sms: "Send by text instead" + switch-to-email: "Send by email instead" + stay-verified: "Once verified, this device stays verified for {hours} hours. You can sign out at any time." + verified-toast: "You're verified" + verified-toast-help: "This device stays verified for this delivery." + signed-out-toast: "Signed out on this device" + instructions-saved: "Delivery instructions saved" + action-failed: "That didn't work. Please try again." + report-sent: "Message sent" + report-sent-help: "{name} will get back to you." + share-copied: "Link copied" + share-copied-help: "Anyone you share it with sees a limited view until they verify themselves." + your-stop: "Stop {number}" + your-stops: "Your stops" + sign-out: "Not you? Sign out" + live: "Live" + reconnecting: "Reconnecting... live updates paused" + timezone: "Times in your time zone ({zone})" + progress: "Delivery progress" + step-created: "Created" + step-dispatched: "Dispatched" + step-started: "Picked up" + step-enroute: "En route" + step-delivered: "Delivered" + step-issue: "Attempt missed" + step-canceled: "Canceled" + headline-preparing: "Getting your order ready" + headline-scheduled: "Scheduled for {date}" + headline-dispatched: "A driver has been assigned" + headline-delayed: "Running a little late" + headline-arriving: "Arriving {day}" + headline-in-transit: "On its way" + headline-delivered: "Delivered" + headline-issue: "There's a problem with your delivery" + headline-canceled: "This order was canceled" + sub-arriving: "{window} ยท in about {minutes} min" + sub-window: "Delivery window {window}" + sub-delivered: "{when}" + sub-dispatched: "Live tracking starts as soon as they're on the way." + sub-preparing: "We'll let you know when it's on its way." + delayed-title: "Running late." + delayed-help: "We'll keep your arrival time up to date." + stop-of: "Your delivery is stop {position} of {total}" + stops-private: "{count, plural, =0 {You're next.} one {# stop to go before yours.} other {# stops to go before yours.}} Other stops stay private, so you only see your own." + map: "Map" + map-label: "Map of your delivery: the van on its way to your address." + map-hidden: "The map is hidden now your order is delivered. We stop sharing the driver's location at your door." + last-seen: "Last seen {minutes} min ago" + recenter: "Center the map on the van" + fit-route: "Show the whole route" + eta: "Estimated arrival" + in-minutes: "in about {minutes} min" + window: "Delivery window" + before-yours: "Before yours" + stops-count: "{count, plural, =0 {None} one {# stop} other {# stops}}" + estimate-may-change: "Estimate may change" + driver: "Your driver" + online: "Online" + location-paused: "Location paused" + call-short: "Call" + message: "Message" + contact-note: "Calls and messages go to the {name} support team." + proof: "Proof of delivery" + open-photo: "Open the delivery photo" + open-signature: "Open the signature" + open-scan: "Open the scan" + proof-photo: "Delivery photo" + proof-signature: "Signature" + proof-scan: "Scanned at the door" + instructions: "Delivery instructions" + edit: "Edit" + instructions-help: "Gate codes, where to leave it, or who can sign" + cancel: "Cancel" + save: "Save" + no-instructions: "No instructions yet." + updates: "Updates" + no-updates: "No updates yet." + show-fewer: "Show fewer updates" + show-earlier: "Show earlier updates ({count})" + today: "Today" + yesterday: "Yesterday" + items: "In this delivery" + item: "Item" + quantity: "Qty {count}" + share: "Share tracking" + report: "Report a problem" + contact: "Contact {name}" + report-label: "What's wrong with your delivery?" + send: "Send" + close: "Close" + no-orders: "No orders yet" + no-orders-help: "Deliveries for your account will show up here." diff --git a/translations/fr-fr.yaml b/translations/fr-fr.yaml index 23806d26d..897aa7ee6 100644 --- a/translations/fr-fr.yaml +++ b/translations/fr-fr.yaml @@ -2460,3 +2460,176 @@ tracking-page: preview-sub: "2:10 to 2:40 pm" preview-button: "Verify it's you" preview-powered-by: "Powered by Fleetbase" + page: + track-another: "Track another" + unavailable-title: "Tracking isn't available here" + unavailable-help: "This tracking page isn't switched on. Use the link in your delivery email, or contact the company that sent your order." + lookup-title: "Track your delivery" + lookup-sub: "See where your parcel is and when it will arrive." + tracking-number: "Tracking number" + tracking-number-placeholder: "e.g. NOR4829301736US" + tracking-number-help: "Find it in your confirmation email or on the parcel label." + lookup-neutral: "We can't show tracking for that number. Check it matches your email or parcel label and try again." + lookup-rate: "That's a few tries in a row. Take a short break and try again in a few minutes." + looking-up: "Looking it up..." + track: "Track" + or: "or" + your-orders: "Your orders" + sign-in: "Sign in to see all your orders" + sign-in-account: "Sign in to your account" + back: "Back" + help: "Need help?" + language: "Language" + powered-by: "Powered by Fleetbase" + dismiss: "Dismiss" + chip-limited: "Limited view ยท Verify to see details" + chip-verified: "Verified on this device" + chip-account: "Signed in" + chip-staff: "Staff preview: what this customer sees" + status: "Status" + verify-to-see: "Verify to see your delivery" + last-update: "Last update: {date}" + copy: "Copy" + verify-title: "See your full delivery details" + verify-help: "For your privacy, the map, arrival time and delivery details unlock after a quick check. We'll send a 6-digit code to the phone or email on this order." + verify-button: "Verify it's you" + verify-unlock: "Verify to unlock" + locked-map: "Live map" + locked-arrival: "Arrival time and driver" + locked-details: "Updates, items and proof of delivery" + stage-preparing: "Being prepared" + stage-scheduled: "Scheduled" + stage-dispatched: "Driver assigned" + stage-in_transit: "In transit" + stage-delivered: "Delivered" + stage-issue: "Needs attention" + stage-canceled: "Canceled" + stage-toast-preparing: "Your order is being prepared" + stage-toast-scheduled: "Your delivery is scheduled" + stage-toast-dispatched: "A driver has been assigned" + stage-toast-in_transit: "Your order is on its way" + stage-toast-delivered: "Your order has been delivered" + stage-toast-issue: "There's an update on your delivery" + stage-toast-canceled: "Your order was canceled" + verify-title-short: "Verify it's you" + verify-choose-help: "To protect your delivery details, we'll send a 6-digit code to the contact details on this order." + send-by: "Send the code by" + channel-sms: "Text message" + channel-email: "Email" + send-failed: "We couldn't send a code just now. Please try again." + sending: "Sending code..." + send-code: "Send code" + masked-note: "We only ever show part of your number or email here." + no-contact-title: "We can't verify you here" + no-contact-help: "This order has no phone number or email we can send a code to. {name} can help you with your delivery." + call: "Call {phone}" + renew-title: "Get a new code" + renew-expired: "That code has expired. Codes last 10 minutes." + renew-unlocked: "You can try again now. We'll send a new code; the old one no longer works." + renew-limited: "You've asked for several codes. Please wait a few minutes before requesting another." + renew-failed: "Something went wrong. Request a new code to try again." + send-new-code: "Send a new code" + verified-title: "You're verified" + locked-title: "Codes paused" + enter-code: "Enter your code" + code-sent-sms: "We texted a 6-digit code to {masked}." + code-sent-email: "We emailed a 6-digit code to {masked}." + code-label: "6-digit code" + code-expires: "The code expires in 10 minutes." + code-wrong: "That code didn't match. {count, plural, one {# attempt} other {# attempts}} left." + code-locked: "Too many attempts. For your security, codes are paused. Try again in {time}." + code-success: "Verified. Opening your delivery..." + resend-in: "Resend code in {time}" + resend: "Resend code" + switch-to-sms: "Send by text instead" + switch-to-email: "Send by email instead" + stay-verified: "Once verified, this device stays verified for {hours} hours. You can sign out at any time." + verified-toast: "You're verified" + verified-toast-help: "This device stays verified for this delivery." + signed-out-toast: "Signed out on this device" + instructions-saved: "Delivery instructions saved" + action-failed: "That didn't work. Please try again." + report-sent: "Message sent" + report-sent-help: "{name} will get back to you." + share-copied: "Link copied" + share-copied-help: "Anyone you share it with sees a limited view until they verify themselves." + your-stop: "Stop {number}" + your-stops: "Your stops" + sign-out: "Not you? Sign out" + live: "Live" + reconnecting: "Reconnecting... live updates paused" + timezone: "Times in your time zone ({zone})" + progress: "Delivery progress" + step-created: "Created" + step-dispatched: "Dispatched" + step-started: "Picked up" + step-enroute: "En route" + step-delivered: "Delivered" + step-issue: "Attempt missed" + step-canceled: "Canceled" + headline-preparing: "Getting your order ready" + headline-scheduled: "Scheduled for {date}" + headline-dispatched: "A driver has been assigned" + headline-delayed: "Running a little late" + headline-arriving: "Arriving {day}" + headline-in-transit: "On its way" + headline-delivered: "Delivered" + headline-issue: "There's a problem with your delivery" + headline-canceled: "This order was canceled" + sub-arriving: "{window} ยท in about {minutes} min" + sub-window: "Delivery window {window}" + sub-delivered: "{when}" + sub-dispatched: "Live tracking starts as soon as they're on the way." + sub-preparing: "We'll let you know when it's on its way." + delayed-title: "Running late." + delayed-help: "We'll keep your arrival time up to date." + stop-of: "Your delivery is stop {position} of {total}" + stops-private: "{count, plural, =0 {You're next.} one {# stop to go before yours.} other {# stops to go before yours.}} Other stops stay private, so you only see your own." + map: "Map" + map-label: "Map of your delivery: the van on its way to your address." + map-hidden: "The map is hidden now your order is delivered. We stop sharing the driver's location at your door." + last-seen: "Last seen {minutes} min ago" + recenter: "Center the map on the van" + fit-route: "Show the whole route" + eta: "Estimated arrival" + in-minutes: "in about {minutes} min" + window: "Delivery window" + before-yours: "Before yours" + stops-count: "{count, plural, =0 {None} one {# stop} other {# stops}}" + estimate-may-change: "Estimate may change" + driver: "Your driver" + online: "Online" + location-paused: "Location paused" + call-short: "Call" + message: "Message" + contact-note: "Calls and messages go to the {name} support team." + proof: "Proof of delivery" + open-photo: "Open the delivery photo" + open-signature: "Open the signature" + open-scan: "Open the scan" + proof-photo: "Delivery photo" + proof-signature: "Signature" + proof-scan: "Scanned at the door" + instructions: "Delivery instructions" + edit: "Edit" + instructions-help: "Gate codes, where to leave it, or who can sign" + cancel: "Cancel" + save: "Save" + no-instructions: "No instructions yet." + updates: "Updates" + no-updates: "No updates yet." + show-fewer: "Show fewer updates" + show-earlier: "Show earlier updates ({count})" + today: "Today" + yesterday: "Yesterday" + items: "In this delivery" + item: "Item" + quantity: "Qty {count}" + share: "Share tracking" + report: "Report a problem" + contact: "Contact {name}" + report-label: "What's wrong with your delivery?" + send: "Send" + close: "Close" + no-orders: "No orders yet" + no-orders-help: "Deliveries for your account will show up here." diff --git a/translations/mn-mn.yaml b/translations/mn-mn.yaml index c48782398..33136c5a4 100644 --- a/translations/mn-mn.yaml +++ b/translations/mn-mn.yaml @@ -2404,3 +2404,176 @@ tracking-page: preview-sub: "2:10 to 2:40 pm" preview-button: "Verify it's you" preview-powered-by: "Powered by Fleetbase" + page: + track-another: "Track another" + unavailable-title: "Tracking isn't available here" + unavailable-help: "This tracking page isn't switched on. Use the link in your delivery email, or contact the company that sent your order." + lookup-title: "Track your delivery" + lookup-sub: "See where your parcel is and when it will arrive." + tracking-number: "Tracking number" + tracking-number-placeholder: "e.g. NOR4829301736US" + tracking-number-help: "Find it in your confirmation email or on the parcel label." + lookup-neutral: "We can't show tracking for that number. Check it matches your email or parcel label and try again." + lookup-rate: "That's a few tries in a row. Take a short break and try again in a few minutes." + looking-up: "Looking it up..." + track: "Track" + or: "or" + your-orders: "Your orders" + sign-in: "Sign in to see all your orders" + sign-in-account: "Sign in to your account" + back: "Back" + help: "Need help?" + language: "Language" + powered-by: "Powered by Fleetbase" + dismiss: "Dismiss" + chip-limited: "Limited view ยท Verify to see details" + chip-verified: "Verified on this device" + chip-account: "Signed in" + chip-staff: "Staff preview: what this customer sees" + status: "Status" + verify-to-see: "Verify to see your delivery" + last-update: "Last update: {date}" + copy: "Copy" + verify-title: "See your full delivery details" + verify-help: "For your privacy, the map, arrival time and delivery details unlock after a quick check. We'll send a 6-digit code to the phone or email on this order." + verify-button: "Verify it's you" + verify-unlock: "Verify to unlock" + locked-map: "Live map" + locked-arrival: "Arrival time and driver" + locked-details: "Updates, items and proof of delivery" + stage-preparing: "Being prepared" + stage-scheduled: "Scheduled" + stage-dispatched: "Driver assigned" + stage-in_transit: "In transit" + stage-delivered: "Delivered" + stage-issue: "Needs attention" + stage-canceled: "Canceled" + stage-toast-preparing: "Your order is being prepared" + stage-toast-scheduled: "Your delivery is scheduled" + stage-toast-dispatched: "A driver has been assigned" + stage-toast-in_transit: "Your order is on its way" + stage-toast-delivered: "Your order has been delivered" + stage-toast-issue: "There's an update on your delivery" + stage-toast-canceled: "Your order was canceled" + verify-title-short: "Verify it's you" + verify-choose-help: "To protect your delivery details, we'll send a 6-digit code to the contact details on this order." + send-by: "Send the code by" + channel-sms: "Text message" + channel-email: "Email" + send-failed: "We couldn't send a code just now. Please try again." + sending: "Sending code..." + send-code: "Send code" + masked-note: "We only ever show part of your number or email here." + no-contact-title: "We can't verify you here" + no-contact-help: "This order has no phone number or email we can send a code to. {name} can help you with your delivery." + call: "Call {phone}" + renew-title: "Get a new code" + renew-expired: "That code has expired. Codes last 10 minutes." + renew-unlocked: "You can try again now. We'll send a new code; the old one no longer works." + renew-limited: "You've asked for several codes. Please wait a few minutes before requesting another." + renew-failed: "Something went wrong. Request a new code to try again." + send-new-code: "Send a new code" + verified-title: "You're verified" + locked-title: "Codes paused" + enter-code: "Enter your code" + code-sent-sms: "We texted a 6-digit code to {masked}." + code-sent-email: "We emailed a 6-digit code to {masked}." + code-label: "6-digit code" + code-expires: "The code expires in 10 minutes." + code-wrong: "That code didn't match. {count, plural, one {# attempt} other {# attempts}} left." + code-locked: "Too many attempts. For your security, codes are paused. Try again in {time}." + code-success: "Verified. Opening your delivery..." + resend-in: "Resend code in {time}" + resend: "Resend code" + switch-to-sms: "Send by text instead" + switch-to-email: "Send by email instead" + stay-verified: "Once verified, this device stays verified for {hours} hours. You can sign out at any time." + verified-toast: "You're verified" + verified-toast-help: "This device stays verified for this delivery." + signed-out-toast: "Signed out on this device" + instructions-saved: "Delivery instructions saved" + action-failed: "That didn't work. Please try again." + report-sent: "Message sent" + report-sent-help: "{name} will get back to you." + share-copied: "Link copied" + share-copied-help: "Anyone you share it with sees a limited view until they verify themselves." + your-stop: "Stop {number}" + your-stops: "Your stops" + sign-out: "Not you? Sign out" + live: "Live" + reconnecting: "Reconnecting... live updates paused" + timezone: "Times in your time zone ({zone})" + progress: "Delivery progress" + step-created: "Created" + step-dispatched: "Dispatched" + step-started: "Picked up" + step-enroute: "En route" + step-delivered: "Delivered" + step-issue: "Attempt missed" + step-canceled: "Canceled" + headline-preparing: "Getting your order ready" + headline-scheduled: "Scheduled for {date}" + headline-dispatched: "A driver has been assigned" + headline-delayed: "Running a little late" + headline-arriving: "Arriving {day}" + headline-in-transit: "On its way" + headline-delivered: "Delivered" + headline-issue: "There's a problem with your delivery" + headline-canceled: "This order was canceled" + sub-arriving: "{window} ยท in about {minutes} min" + sub-window: "Delivery window {window}" + sub-delivered: "{when}" + sub-dispatched: "Live tracking starts as soon as they're on the way." + sub-preparing: "We'll let you know when it's on its way." + delayed-title: "Running late." + delayed-help: "We'll keep your arrival time up to date." + stop-of: "Your delivery is stop {position} of {total}" + stops-private: "{count, plural, =0 {You're next.} one {# stop to go before yours.} other {# stops to go before yours.}} Other stops stay private, so you only see your own." + map: "Map" + map-label: "Map of your delivery: the van on its way to your address." + map-hidden: "The map is hidden now your order is delivered. We stop sharing the driver's location at your door." + last-seen: "Last seen {minutes} min ago" + recenter: "Center the map on the van" + fit-route: "Show the whole route" + eta: "Estimated arrival" + in-minutes: "in about {minutes} min" + window: "Delivery window" + before-yours: "Before yours" + stops-count: "{count, plural, =0 {None} one {# stop} other {# stops}}" + estimate-may-change: "Estimate may change" + driver: "Your driver" + online: "Online" + location-paused: "Location paused" + call-short: "Call" + message: "Message" + contact-note: "Calls and messages go to the {name} support team." + proof: "Proof of delivery" + open-photo: "Open the delivery photo" + open-signature: "Open the signature" + open-scan: "Open the scan" + proof-photo: "Delivery photo" + proof-signature: "Signature" + proof-scan: "Scanned at the door" + instructions: "Delivery instructions" + edit: "Edit" + instructions-help: "Gate codes, where to leave it, or who can sign" + cancel: "Cancel" + save: "Save" + no-instructions: "No instructions yet." + updates: "Updates" + no-updates: "No updates yet." + show-fewer: "Show fewer updates" + show-earlier: "Show earlier updates ({count})" + today: "Today" + yesterday: "Yesterday" + items: "In this delivery" + item: "Item" + quantity: "Qty {count}" + share: "Share tracking" + report: "Report a problem" + contact: "Contact {name}" + report-label: "What's wrong with your delivery?" + send: "Send" + close: "Close" + no-orders: "No orders yet" + no-orders-help: "Deliveries for your account will show up here." diff --git a/translations/pt-br.yaml b/translations/pt-br.yaml index ffedae57b..635d58296 100644 --- a/translations/pt-br.yaml +++ b/translations/pt-br.yaml @@ -2425,3 +2425,176 @@ tracking-page: preview-sub: "2:10 to 2:40 pm" preview-button: "Verify it's you" preview-powered-by: "Powered by Fleetbase" + page: + track-another: "Track another" + unavailable-title: "Tracking isn't available here" + unavailable-help: "This tracking page isn't switched on. Use the link in your delivery email, or contact the company that sent your order." + lookup-title: "Track your delivery" + lookup-sub: "See where your parcel is and when it will arrive." + tracking-number: "Tracking number" + tracking-number-placeholder: "e.g. NOR4829301736US" + tracking-number-help: "Find it in your confirmation email or on the parcel label." + lookup-neutral: "We can't show tracking for that number. Check it matches your email or parcel label and try again." + lookup-rate: "That's a few tries in a row. Take a short break and try again in a few minutes." + looking-up: "Looking it up..." + track: "Track" + or: "or" + your-orders: "Your orders" + sign-in: "Sign in to see all your orders" + sign-in-account: "Sign in to your account" + back: "Back" + help: "Need help?" + language: "Language" + powered-by: "Powered by Fleetbase" + dismiss: "Dismiss" + chip-limited: "Limited view ยท Verify to see details" + chip-verified: "Verified on this device" + chip-account: "Signed in" + chip-staff: "Staff preview: what this customer sees" + status: "Status" + verify-to-see: "Verify to see your delivery" + last-update: "Last update: {date}" + copy: "Copy" + verify-title: "See your full delivery details" + verify-help: "For your privacy, the map, arrival time and delivery details unlock after a quick check. We'll send a 6-digit code to the phone or email on this order." + verify-button: "Verify it's you" + verify-unlock: "Verify to unlock" + locked-map: "Live map" + locked-arrival: "Arrival time and driver" + locked-details: "Updates, items and proof of delivery" + stage-preparing: "Being prepared" + stage-scheduled: "Scheduled" + stage-dispatched: "Driver assigned" + stage-in_transit: "In transit" + stage-delivered: "Delivered" + stage-issue: "Needs attention" + stage-canceled: "Canceled" + stage-toast-preparing: "Your order is being prepared" + stage-toast-scheduled: "Your delivery is scheduled" + stage-toast-dispatched: "A driver has been assigned" + stage-toast-in_transit: "Your order is on its way" + stage-toast-delivered: "Your order has been delivered" + stage-toast-issue: "There's an update on your delivery" + stage-toast-canceled: "Your order was canceled" + verify-title-short: "Verify it's you" + verify-choose-help: "To protect your delivery details, we'll send a 6-digit code to the contact details on this order." + send-by: "Send the code by" + channel-sms: "Text message" + channel-email: "Email" + send-failed: "We couldn't send a code just now. Please try again." + sending: "Sending code..." + send-code: "Send code" + masked-note: "We only ever show part of your number or email here." + no-contact-title: "We can't verify you here" + no-contact-help: "This order has no phone number or email we can send a code to. {name} can help you with your delivery." + call: "Call {phone}" + renew-title: "Get a new code" + renew-expired: "That code has expired. Codes last 10 minutes." + renew-unlocked: "You can try again now. We'll send a new code; the old one no longer works." + renew-limited: "You've asked for several codes. Please wait a few minutes before requesting another." + renew-failed: "Something went wrong. Request a new code to try again." + send-new-code: "Send a new code" + verified-title: "You're verified" + locked-title: "Codes paused" + enter-code: "Enter your code" + code-sent-sms: "We texted a 6-digit code to {masked}." + code-sent-email: "We emailed a 6-digit code to {masked}." + code-label: "6-digit code" + code-expires: "The code expires in 10 minutes." + code-wrong: "That code didn't match. {count, plural, one {# attempt} other {# attempts}} left." + code-locked: "Too many attempts. For your security, codes are paused. Try again in {time}." + code-success: "Verified. Opening your delivery..." + resend-in: "Resend code in {time}" + resend: "Resend code" + switch-to-sms: "Send by text instead" + switch-to-email: "Send by email instead" + stay-verified: "Once verified, this device stays verified for {hours} hours. You can sign out at any time." + verified-toast: "You're verified" + verified-toast-help: "This device stays verified for this delivery." + signed-out-toast: "Signed out on this device" + instructions-saved: "Delivery instructions saved" + action-failed: "That didn't work. Please try again." + report-sent: "Message sent" + report-sent-help: "{name} will get back to you." + share-copied: "Link copied" + share-copied-help: "Anyone you share it with sees a limited view until they verify themselves." + your-stop: "Stop {number}" + your-stops: "Your stops" + sign-out: "Not you? Sign out" + live: "Live" + reconnecting: "Reconnecting... live updates paused" + timezone: "Times in your time zone ({zone})" + progress: "Delivery progress" + step-created: "Created" + step-dispatched: "Dispatched" + step-started: "Picked up" + step-enroute: "En route" + step-delivered: "Delivered" + step-issue: "Attempt missed" + step-canceled: "Canceled" + headline-preparing: "Getting your order ready" + headline-scheduled: "Scheduled for {date}" + headline-dispatched: "A driver has been assigned" + headline-delayed: "Running a little late" + headline-arriving: "Arriving {day}" + headline-in-transit: "On its way" + headline-delivered: "Delivered" + headline-issue: "There's a problem with your delivery" + headline-canceled: "This order was canceled" + sub-arriving: "{window} ยท in about {minutes} min" + sub-window: "Delivery window {window}" + sub-delivered: "{when}" + sub-dispatched: "Live tracking starts as soon as they're on the way." + sub-preparing: "We'll let you know when it's on its way." + delayed-title: "Running late." + delayed-help: "We'll keep your arrival time up to date." + stop-of: "Your delivery is stop {position} of {total}" + stops-private: "{count, plural, =0 {You're next.} one {# stop to go before yours.} other {# stops to go before yours.}} Other stops stay private, so you only see your own." + map: "Map" + map-label: "Map of your delivery: the van on its way to your address." + map-hidden: "The map is hidden now your order is delivered. We stop sharing the driver's location at your door." + last-seen: "Last seen {minutes} min ago" + recenter: "Center the map on the van" + fit-route: "Show the whole route" + eta: "Estimated arrival" + in-minutes: "in about {minutes} min" + window: "Delivery window" + before-yours: "Before yours" + stops-count: "{count, plural, =0 {None} one {# stop} other {# stops}}" + estimate-may-change: "Estimate may change" + driver: "Your driver" + online: "Online" + location-paused: "Location paused" + call-short: "Call" + message: "Message" + contact-note: "Calls and messages go to the {name} support team." + proof: "Proof of delivery" + open-photo: "Open the delivery photo" + open-signature: "Open the signature" + open-scan: "Open the scan" + proof-photo: "Delivery photo" + proof-signature: "Signature" + proof-scan: "Scanned at the door" + instructions: "Delivery instructions" + edit: "Edit" + instructions-help: "Gate codes, where to leave it, or who can sign" + cancel: "Cancel" + save: "Save" + no-instructions: "No instructions yet." + updates: "Updates" + no-updates: "No updates yet." + show-fewer: "Show fewer updates" + show-earlier: "Show earlier updates ({count})" + today: "Today" + yesterday: "Yesterday" + items: "In this delivery" + item: "Item" + quantity: "Qty {count}" + share: "Share tracking" + report: "Report a problem" + contact: "Contact {name}" + report-label: "What's wrong with your delivery?" + send: "Send" + close: "Close" + no-orders: "No orders yet" + no-orders-help: "Deliveries for your account will show up here." diff --git a/translations/ru-ru.yaml b/translations/ru-ru.yaml index 089e1773c..1edc015ff 100644 --- a/translations/ru-ru.yaml +++ b/translations/ru-ru.yaml @@ -2418,3 +2418,176 @@ tracking-page: preview-sub: "2:10 to 2:40 pm" preview-button: "Verify it's you" preview-powered-by: "Powered by Fleetbase" + page: + track-another: "Track another" + unavailable-title: "Tracking isn't available here" + unavailable-help: "This tracking page isn't switched on. Use the link in your delivery email, or contact the company that sent your order." + lookup-title: "Track your delivery" + lookup-sub: "See where your parcel is and when it will arrive." + tracking-number: "Tracking number" + tracking-number-placeholder: "e.g. NOR4829301736US" + tracking-number-help: "Find it in your confirmation email or on the parcel label." + lookup-neutral: "We can't show tracking for that number. Check it matches your email or parcel label and try again." + lookup-rate: "That's a few tries in a row. Take a short break and try again in a few minutes." + looking-up: "Looking it up..." + track: "Track" + or: "or" + your-orders: "Your orders" + sign-in: "Sign in to see all your orders" + sign-in-account: "Sign in to your account" + back: "Back" + help: "Need help?" + language: "Language" + powered-by: "Powered by Fleetbase" + dismiss: "Dismiss" + chip-limited: "Limited view ยท Verify to see details" + chip-verified: "Verified on this device" + chip-account: "Signed in" + chip-staff: "Staff preview: what this customer sees" + status: "Status" + verify-to-see: "Verify to see your delivery" + last-update: "Last update: {date}" + copy: "Copy" + verify-title: "See your full delivery details" + verify-help: "For your privacy, the map, arrival time and delivery details unlock after a quick check. We'll send a 6-digit code to the phone or email on this order." + verify-button: "Verify it's you" + verify-unlock: "Verify to unlock" + locked-map: "Live map" + locked-arrival: "Arrival time and driver" + locked-details: "Updates, items and proof of delivery" + stage-preparing: "Being prepared" + stage-scheduled: "Scheduled" + stage-dispatched: "Driver assigned" + stage-in_transit: "In transit" + stage-delivered: "Delivered" + stage-issue: "Needs attention" + stage-canceled: "Canceled" + stage-toast-preparing: "Your order is being prepared" + stage-toast-scheduled: "Your delivery is scheduled" + stage-toast-dispatched: "A driver has been assigned" + stage-toast-in_transit: "Your order is on its way" + stage-toast-delivered: "Your order has been delivered" + stage-toast-issue: "There's an update on your delivery" + stage-toast-canceled: "Your order was canceled" + verify-title-short: "Verify it's you" + verify-choose-help: "To protect your delivery details, we'll send a 6-digit code to the contact details on this order." + send-by: "Send the code by" + channel-sms: "Text message" + channel-email: "Email" + send-failed: "We couldn't send a code just now. Please try again." + sending: "Sending code..." + send-code: "Send code" + masked-note: "We only ever show part of your number or email here." + no-contact-title: "We can't verify you here" + no-contact-help: "This order has no phone number or email we can send a code to. {name} can help you with your delivery." + call: "Call {phone}" + renew-title: "Get a new code" + renew-expired: "That code has expired. Codes last 10 minutes." + renew-unlocked: "You can try again now. We'll send a new code; the old one no longer works." + renew-limited: "You've asked for several codes. Please wait a few minutes before requesting another." + renew-failed: "Something went wrong. Request a new code to try again." + send-new-code: "Send a new code" + verified-title: "You're verified" + locked-title: "Codes paused" + enter-code: "Enter your code" + code-sent-sms: "We texted a 6-digit code to {masked}." + code-sent-email: "We emailed a 6-digit code to {masked}." + code-label: "6-digit code" + code-expires: "The code expires in 10 minutes." + code-wrong: "That code didn't match. {count, plural, one {# attempt} other {# attempts}} left." + code-locked: "Too many attempts. For your security, codes are paused. Try again in {time}." + code-success: "Verified. Opening your delivery..." + resend-in: "Resend code in {time}" + resend: "Resend code" + switch-to-sms: "Send by text instead" + switch-to-email: "Send by email instead" + stay-verified: "Once verified, this device stays verified for {hours} hours. You can sign out at any time." + verified-toast: "You're verified" + verified-toast-help: "This device stays verified for this delivery." + signed-out-toast: "Signed out on this device" + instructions-saved: "Delivery instructions saved" + action-failed: "That didn't work. Please try again." + report-sent: "Message sent" + report-sent-help: "{name} will get back to you." + share-copied: "Link copied" + share-copied-help: "Anyone you share it with sees a limited view until they verify themselves." + your-stop: "Stop {number}" + your-stops: "Your stops" + sign-out: "Not you? Sign out" + live: "Live" + reconnecting: "Reconnecting... live updates paused" + timezone: "Times in your time zone ({zone})" + progress: "Delivery progress" + step-created: "Created" + step-dispatched: "Dispatched" + step-started: "Picked up" + step-enroute: "En route" + step-delivered: "Delivered" + step-issue: "Attempt missed" + step-canceled: "Canceled" + headline-preparing: "Getting your order ready" + headline-scheduled: "Scheduled for {date}" + headline-dispatched: "A driver has been assigned" + headline-delayed: "Running a little late" + headline-arriving: "Arriving {day}" + headline-in-transit: "On its way" + headline-delivered: "Delivered" + headline-issue: "There's a problem with your delivery" + headline-canceled: "This order was canceled" + sub-arriving: "{window} ยท in about {minutes} min" + sub-window: "Delivery window {window}" + sub-delivered: "{when}" + sub-dispatched: "Live tracking starts as soon as they're on the way." + sub-preparing: "We'll let you know when it's on its way." + delayed-title: "Running late." + delayed-help: "We'll keep your arrival time up to date." + stop-of: "Your delivery is stop {position} of {total}" + stops-private: "{count, plural, =0 {You're next.} one {# stop to go before yours.} other {# stops to go before yours.}} Other stops stay private, so you only see your own." + map: "Map" + map-label: "Map of your delivery: the van on its way to your address." + map-hidden: "The map is hidden now your order is delivered. We stop sharing the driver's location at your door." + last-seen: "Last seen {minutes} min ago" + recenter: "Center the map on the van" + fit-route: "Show the whole route" + eta: "Estimated arrival" + in-minutes: "in about {minutes} min" + window: "Delivery window" + before-yours: "Before yours" + stops-count: "{count, plural, =0 {None} one {# stop} other {# stops}}" + estimate-may-change: "Estimate may change" + driver: "Your driver" + online: "Online" + location-paused: "Location paused" + call-short: "Call" + message: "Message" + contact-note: "Calls and messages go to the {name} support team." + proof: "Proof of delivery" + open-photo: "Open the delivery photo" + open-signature: "Open the signature" + open-scan: "Open the scan" + proof-photo: "Delivery photo" + proof-signature: "Signature" + proof-scan: "Scanned at the door" + instructions: "Delivery instructions" + edit: "Edit" + instructions-help: "Gate codes, where to leave it, or who can sign" + cancel: "Cancel" + save: "Save" + no-instructions: "No instructions yet." + updates: "Updates" + no-updates: "No updates yet." + show-fewer: "Show fewer updates" + show-earlier: "Show earlier updates ({count})" + today: "Today" + yesterday: "Yesterday" + items: "In this delivery" + item: "Item" + quantity: "Qty {count}" + share: "Share tracking" + report: "Report a problem" + contact: "Contact {name}" + report-label: "What's wrong with your delivery?" + send: "Send" + close: "Close" + no-orders: "No orders yet" + no-orders-help: "Deliveries for your account will show up here." diff --git a/translations/uk-ua.yaml b/translations/uk-ua.yaml index e3985830d..bdf5bb4da 100644 --- a/translations/uk-ua.yaml +++ b/translations/uk-ua.yaml @@ -3561,3 +3561,176 @@ tracking-page: preview-sub: "2:10 to 2:40 pm" preview-button: "Verify it's you" preview-powered-by: "Powered by Fleetbase" + page: + track-another: "Track another" + unavailable-title: "Tracking isn't available here" + unavailable-help: "This tracking page isn't switched on. Use the link in your delivery email, or contact the company that sent your order." + lookup-title: "Track your delivery" + lookup-sub: "See where your parcel is and when it will arrive." + tracking-number: "Tracking number" + tracking-number-placeholder: "e.g. NOR4829301736US" + tracking-number-help: "Find it in your confirmation email or on the parcel label." + lookup-neutral: "We can't show tracking for that number. Check it matches your email or parcel label and try again." + lookup-rate: "That's a few tries in a row. Take a short break and try again in a few minutes." + looking-up: "Looking it up..." + track: "Track" + or: "or" + your-orders: "Your orders" + sign-in: "Sign in to see all your orders" + sign-in-account: "Sign in to your account" + back: "Back" + help: "Need help?" + language: "Language" + powered-by: "Powered by Fleetbase" + dismiss: "Dismiss" + chip-limited: "Limited view ยท Verify to see details" + chip-verified: "Verified on this device" + chip-account: "Signed in" + chip-staff: "Staff preview: what this customer sees" + status: "Status" + verify-to-see: "Verify to see your delivery" + last-update: "Last update: {date}" + copy: "Copy" + verify-title: "See your full delivery details" + verify-help: "For your privacy, the map, arrival time and delivery details unlock after a quick check. We'll send a 6-digit code to the phone or email on this order." + verify-button: "Verify it's you" + verify-unlock: "Verify to unlock" + locked-map: "Live map" + locked-arrival: "Arrival time and driver" + locked-details: "Updates, items and proof of delivery" + stage-preparing: "Being prepared" + stage-scheduled: "Scheduled" + stage-dispatched: "Driver assigned" + stage-in_transit: "In transit" + stage-delivered: "Delivered" + stage-issue: "Needs attention" + stage-canceled: "Canceled" + stage-toast-preparing: "Your order is being prepared" + stage-toast-scheduled: "Your delivery is scheduled" + stage-toast-dispatched: "A driver has been assigned" + stage-toast-in_transit: "Your order is on its way" + stage-toast-delivered: "Your order has been delivered" + stage-toast-issue: "There's an update on your delivery" + stage-toast-canceled: "Your order was canceled" + verify-title-short: "Verify it's you" + verify-choose-help: "To protect your delivery details, we'll send a 6-digit code to the contact details on this order." + send-by: "Send the code by" + channel-sms: "Text message" + channel-email: "Email" + send-failed: "We couldn't send a code just now. Please try again." + sending: "Sending code..." + send-code: "Send code" + masked-note: "We only ever show part of your number or email here." + no-contact-title: "We can't verify you here" + no-contact-help: "This order has no phone number or email we can send a code to. {name} can help you with your delivery." + call: "Call {phone}" + renew-title: "Get a new code" + renew-expired: "That code has expired. Codes last 10 minutes." + renew-unlocked: "You can try again now. We'll send a new code; the old one no longer works." + renew-limited: "You've asked for several codes. Please wait a few minutes before requesting another." + renew-failed: "Something went wrong. Request a new code to try again." + send-new-code: "Send a new code" + verified-title: "You're verified" + locked-title: "Codes paused" + enter-code: "Enter your code" + code-sent-sms: "We texted a 6-digit code to {masked}." + code-sent-email: "We emailed a 6-digit code to {masked}." + code-label: "6-digit code" + code-expires: "The code expires in 10 minutes." + code-wrong: "That code didn't match. {count, plural, one {# attempt} other {# attempts}} left." + code-locked: "Too many attempts. For your security, codes are paused. Try again in {time}." + code-success: "Verified. Opening your delivery..." + resend-in: "Resend code in {time}" + resend: "Resend code" + switch-to-sms: "Send by text instead" + switch-to-email: "Send by email instead" + stay-verified: "Once verified, this device stays verified for {hours} hours. You can sign out at any time." + verified-toast: "You're verified" + verified-toast-help: "This device stays verified for this delivery." + signed-out-toast: "Signed out on this device" + instructions-saved: "Delivery instructions saved" + action-failed: "That didn't work. Please try again." + report-sent: "Message sent" + report-sent-help: "{name} will get back to you." + share-copied: "Link copied" + share-copied-help: "Anyone you share it with sees a limited view until they verify themselves." + your-stop: "Stop {number}" + your-stops: "Your stops" + sign-out: "Not you? Sign out" + live: "Live" + reconnecting: "Reconnecting... live updates paused" + timezone: "Times in your time zone ({zone})" + progress: "Delivery progress" + step-created: "Created" + step-dispatched: "Dispatched" + step-started: "Picked up" + step-enroute: "En route" + step-delivered: "Delivered" + step-issue: "Attempt missed" + step-canceled: "Canceled" + headline-preparing: "Getting your order ready" + headline-scheduled: "Scheduled for {date}" + headline-dispatched: "A driver has been assigned" + headline-delayed: "Running a little late" + headline-arriving: "Arriving {day}" + headline-in-transit: "On its way" + headline-delivered: "Delivered" + headline-issue: "There's a problem with your delivery" + headline-canceled: "This order was canceled" + sub-arriving: "{window} ยท in about {minutes} min" + sub-window: "Delivery window {window}" + sub-delivered: "{when}" + sub-dispatched: "Live tracking starts as soon as they're on the way." + sub-preparing: "We'll let you know when it's on its way." + delayed-title: "Running late." + delayed-help: "We'll keep your arrival time up to date." + stop-of: "Your delivery is stop {position} of {total}" + stops-private: "{count, plural, =0 {You're next.} one {# stop to go before yours.} other {# stops to go before yours.}} Other stops stay private, so you only see your own." + map: "Map" + map-label: "Map of your delivery: the van on its way to your address." + map-hidden: "The map is hidden now your order is delivered. We stop sharing the driver's location at your door." + last-seen: "Last seen {minutes} min ago" + recenter: "Center the map on the van" + fit-route: "Show the whole route" + eta: "Estimated arrival" + in-minutes: "in about {minutes} min" + window: "Delivery window" + before-yours: "Before yours" + stops-count: "{count, plural, =0 {None} one {# stop} other {# stops}}" + estimate-may-change: "Estimate may change" + driver: "Your driver" + online: "Online" + location-paused: "Location paused" + call-short: "Call" + message: "Message" + contact-note: "Calls and messages go to the {name} support team." + proof: "Proof of delivery" + open-photo: "Open the delivery photo" + open-signature: "Open the signature" + open-scan: "Open the scan" + proof-photo: "Delivery photo" + proof-signature: "Signature" + proof-scan: "Scanned at the door" + instructions: "Delivery instructions" + edit: "Edit" + instructions-help: "Gate codes, where to leave it, or who can sign" + cancel: "Cancel" + save: "Save" + no-instructions: "No instructions yet." + updates: "Updates" + no-updates: "No updates yet." + show-fewer: "Show fewer updates" + show-earlier: "Show earlier updates ({count})" + today: "Today" + yesterday: "Yesterday" + items: "In this delivery" + item: "Item" + quantity: "Qty {count}" + share: "Share tracking" + report: "Report a problem" + contact: "Contact {name}" + report-label: "What's wrong with your delivery?" + send: "Send" + close: "Close" + no-orders: "No orders yet" + no-orders-help: "Deliveries for your account will show up here." diff --git a/translations/vi-vn.yaml b/translations/vi-vn.yaml index 16c0f1496..40c64a0ce 100644 --- a/translations/vi-vn.yaml +++ b/translations/vi-vn.yaml @@ -2234,3 +2234,176 @@ tracking-page: preview-sub: "2:10 to 2:40 pm" preview-button: "Verify it's you" preview-powered-by: "Powered by Fleetbase" + page: + track-another: "Track another" + unavailable-title: "Tracking isn't available here" + unavailable-help: "This tracking page isn't switched on. Use the link in your delivery email, or contact the company that sent your order." + lookup-title: "Track your delivery" + lookup-sub: "See where your parcel is and when it will arrive." + tracking-number: "Tracking number" + tracking-number-placeholder: "e.g. NOR4829301736US" + tracking-number-help: "Find it in your confirmation email or on the parcel label." + lookup-neutral: "We can't show tracking for that number. Check it matches your email or parcel label and try again." + lookup-rate: "That's a few tries in a row. Take a short break and try again in a few minutes." + looking-up: "Looking it up..." + track: "Track" + or: "or" + your-orders: "Your orders" + sign-in: "Sign in to see all your orders" + sign-in-account: "Sign in to your account" + back: "Back" + help: "Need help?" + language: "Language" + powered-by: "Powered by Fleetbase" + dismiss: "Dismiss" + chip-limited: "Limited view ยท Verify to see details" + chip-verified: "Verified on this device" + chip-account: "Signed in" + chip-staff: "Staff preview: what this customer sees" + status: "Status" + verify-to-see: "Verify to see your delivery" + last-update: "Last update: {date}" + copy: "Copy" + verify-title: "See your full delivery details" + verify-help: "For your privacy, the map, arrival time and delivery details unlock after a quick check. We'll send a 6-digit code to the phone or email on this order." + verify-button: "Verify it's you" + verify-unlock: "Verify to unlock" + locked-map: "Live map" + locked-arrival: "Arrival time and driver" + locked-details: "Updates, items and proof of delivery" + stage-preparing: "Being prepared" + stage-scheduled: "Scheduled" + stage-dispatched: "Driver assigned" + stage-in_transit: "In transit" + stage-delivered: "Delivered" + stage-issue: "Needs attention" + stage-canceled: "Canceled" + stage-toast-preparing: "Your order is being prepared" + stage-toast-scheduled: "Your delivery is scheduled" + stage-toast-dispatched: "A driver has been assigned" + stage-toast-in_transit: "Your order is on its way" + stage-toast-delivered: "Your order has been delivered" + stage-toast-issue: "There's an update on your delivery" + stage-toast-canceled: "Your order was canceled" + verify-title-short: "Verify it's you" + verify-choose-help: "To protect your delivery details, we'll send a 6-digit code to the contact details on this order." + send-by: "Send the code by" + channel-sms: "Text message" + channel-email: "Email" + send-failed: "We couldn't send a code just now. Please try again." + sending: "Sending code..." + send-code: "Send code" + masked-note: "We only ever show part of your number or email here." + no-contact-title: "We can't verify you here" + no-contact-help: "This order has no phone number or email we can send a code to. {name} can help you with your delivery." + call: "Call {phone}" + renew-title: "Get a new code" + renew-expired: "That code has expired. Codes last 10 minutes." + renew-unlocked: "You can try again now. We'll send a new code; the old one no longer works." + renew-limited: "You've asked for several codes. Please wait a few minutes before requesting another." + renew-failed: "Something went wrong. Request a new code to try again." + send-new-code: "Send a new code" + verified-title: "You're verified" + locked-title: "Codes paused" + enter-code: "Enter your code" + code-sent-sms: "We texted a 6-digit code to {masked}." + code-sent-email: "We emailed a 6-digit code to {masked}." + code-label: "6-digit code" + code-expires: "The code expires in 10 minutes." + code-wrong: "That code didn't match. {count, plural, one {# attempt} other {# attempts}} left." + code-locked: "Too many attempts. For your security, codes are paused. Try again in {time}." + code-success: "Verified. Opening your delivery..." + resend-in: "Resend code in {time}" + resend: "Resend code" + switch-to-sms: "Send by text instead" + switch-to-email: "Send by email instead" + stay-verified: "Once verified, this device stays verified for {hours} hours. You can sign out at any time." + verified-toast: "You're verified" + verified-toast-help: "This device stays verified for this delivery." + signed-out-toast: "Signed out on this device" + instructions-saved: "Delivery instructions saved" + action-failed: "That didn't work. Please try again." + report-sent: "Message sent" + report-sent-help: "{name} will get back to you." + share-copied: "Link copied" + share-copied-help: "Anyone you share it with sees a limited view until they verify themselves." + your-stop: "Stop {number}" + your-stops: "Your stops" + sign-out: "Not you? Sign out" + live: "Live" + reconnecting: "Reconnecting... live updates paused" + timezone: "Times in your time zone ({zone})" + progress: "Delivery progress" + step-created: "Created" + step-dispatched: "Dispatched" + step-started: "Picked up" + step-enroute: "En route" + step-delivered: "Delivered" + step-issue: "Attempt missed" + step-canceled: "Canceled" + headline-preparing: "Getting your order ready" + headline-scheduled: "Scheduled for {date}" + headline-dispatched: "A driver has been assigned" + headline-delayed: "Running a little late" + headline-arriving: "Arriving {day}" + headline-in-transit: "On its way" + headline-delivered: "Delivered" + headline-issue: "There's a problem with your delivery" + headline-canceled: "This order was canceled" + sub-arriving: "{window} ยท in about {minutes} min" + sub-window: "Delivery window {window}" + sub-delivered: "{when}" + sub-dispatched: "Live tracking starts as soon as they're on the way." + sub-preparing: "We'll let you know when it's on its way." + delayed-title: "Running late." + delayed-help: "We'll keep your arrival time up to date." + stop-of: "Your delivery is stop {position} of {total}" + stops-private: "{count, plural, =0 {You're next.} one {# stop to go before yours.} other {# stops to go before yours.}} Other stops stay private, so you only see your own." + map: "Map" + map-label: "Map of your delivery: the van on its way to your address." + map-hidden: "The map is hidden now your order is delivered. We stop sharing the driver's location at your door." + last-seen: "Last seen {minutes} min ago" + recenter: "Center the map on the van" + fit-route: "Show the whole route" + eta: "Estimated arrival" + in-minutes: "in about {minutes} min" + window: "Delivery window" + before-yours: "Before yours" + stops-count: "{count, plural, =0 {None} one {# stop} other {# stops}}" + estimate-may-change: "Estimate may change" + driver: "Your driver" + online: "Online" + location-paused: "Location paused" + call-short: "Call" + message: "Message" + contact-note: "Calls and messages go to the {name} support team." + proof: "Proof of delivery" + open-photo: "Open the delivery photo" + open-signature: "Open the signature" + open-scan: "Open the scan" + proof-photo: "Delivery photo" + proof-signature: "Signature" + proof-scan: "Scanned at the door" + instructions: "Delivery instructions" + edit: "Edit" + instructions-help: "Gate codes, where to leave it, or who can sign" + cancel: "Cancel" + save: "Save" + no-instructions: "No instructions yet." + updates: "Updates" + no-updates: "No updates yet." + show-fewer: "Show fewer updates" + show-earlier: "Show earlier updates ({count})" + today: "Today" + yesterday: "Yesterday" + items: "In this delivery" + item: "Item" + quantity: "Qty {count}" + share: "Share tracking" + report: "Report a problem" + contact: "Contact {name}" + report-label: "What's wrong with your delivery?" + send: "Send" + close: "Close" + no-orders: "No orders yet" + no-orders-help: "Deliveries for your account will show up here." From 1b87d8bad3045232d3d27565a16279877991c036 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 8 Oct 2026 14:39:05 +0800 Subject: [PATCH 19/87] fix(tracking-page): lint fixes for the tracking page - Wrap the code input's normalizing chain the way prettier wants. - Rename the locale loop's block param so it no longer shadows