Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 36 additions & 13 deletions server/src/Http/Controllers/Internal/v1/OrderController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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).
*
Expand Down Expand Up @@ -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;
}

/**
Expand Down
153 changes: 153 additions & 0 deletions server/src/Http/Resources/v1/PublicOrderTracking.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
<?php

namespace Fleetbase\FleetOps\Http\Resources\v1;

use Fleetbase\FleetOps\Models\Order;
use Fleetbase\FleetOps\Support\Utils;
use Illuminate\Http\Resources\Json\JsonResource;

/**
* What the public Track Order page gets for a tracking number.
*
* The lookup has no session, so whoever holds the tracking number sees this.
* It carries only what `order-tracking-lookup` renders: status, the tracking
* timeline, ETA and progress, the stop coordinates the map routes between,
* and the item list. Notes, meta, internal ids, files, rates, and customer
* and facilitator details stay out. The driver's position is included only
* while the order is under way.
*
* It extends `JsonResource`, not `FleetbaseResource`, so transformers that
* other extensions register for resources cannot add fields to it. Records are
* keyed by public id, which Ember Data needs to build them, so the page never
* learns an internal uuid.
*/
class PublicOrderTracking extends JsonResource
{
/**
* Never wrapped, whatever another resource set the shared wrapper to.
*
* @var string|null
*/
public static $wrap;

/**
* Statuses after which the order is no longer under way.
*/
public const FINISHED_STATUSES = ['completed', 'canceled', 'cancelled'];

/**
* Transform the resource into an array.
*
* @param \Illuminate\Http\Request $request
*/
public function toArray($request): array
{
/** @var Order $order */
$order = $this->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')];
}
}
5 changes: 4 additions & 1 deletion server/src/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
);

Expand Down
30 changes: 19 additions & 11 deletions server/tests/Feature/Http/Internal/OrderControllerContractsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.',
Expand Down
Loading
Loading