From 5518e674c0845614ee57af97eaf8f1cee1339c2d Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 6 Oct 2026 13:06:09 +0800 Subject: [PATCH 001/193] fix(reviews): upload review photos without a public ACL The S3 media bucket enforces bucket-owner object ownership and rejects PUTs that carry an ACL, so put(..., 'public') returned false and no photo was stored. --- .../src/Http/Controllers/v1/ReviewController.php | 4 ++-- .../ReviewAndOrderControllerContractsTest.php | 14 +++++++++++--- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/server/src/Http/Controllers/v1/ReviewController.php b/server/src/Http/Controllers/v1/ReviewController.php index dd51ba78..7a8ce79a 100644 --- a/server/src/Http/Controllers/v1/ReviewController.php +++ b/server/src/Http/Controllers/v1/ReviewController.php @@ -258,8 +258,8 @@ public function create(CreateReviewRequest $request) $extension = File::getExtensionFromMimeType($mimeType); $bucketPath = 'hyperstore/' . $about->public_id . '/review-photos/' . $review->uuid . '/' . File::randomFileName($extension); - // upload file to path - $upload = Storage::disk($disk)->put($bucketPath, base64_decode($data), 'public'); + // upload file to path; no 'public' ACL, the S3 media bucket is private and rejects ACLs + $upload = Storage::disk($disk)->put($bucketPath, base64_decode($data)); // create the file $uploadedFiles->push(File::create([ diff --git a/server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php b/server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php index 14c86195..611b28c8 100644 --- a/server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php +++ b/server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php @@ -774,17 +774,22 @@ function createReviewControllerSchema(): void 'uuid' => 'owned_review_uuid', 'public_id' => 'review_owned', ]); - Illuminate\Support\Facades\Storage::swap(new class { + $reviewStorage = new class { public function disk(string $disk): self { return $this; } - public function put(string $path, string $contents, string $visibility): bool + public array $writes = []; + + public function put(string $path, string $contents, mixed $options = []): bool { + $this->writes[] = [$path, $options]; + return true; } - }); + }; + Illuminate\Support\Facades\Storage::swap($reviewStorage); session(['storefront_key' => 'store_key']); $withPhoto = $controller->create( Fleetbase\Storefront\Http\Requests\CreateReviewRequest::create('/reviews', 'POST', [ @@ -820,6 +825,9 @@ public function put(string $path, string $contents, string $visibility): bool ->and($photo->bucket)->toBe('review-bucket') ->and($photo->file_size)->toBe(strlen('image-bytes')) ->and($photo->type)->toBe('storefront_review_upload') + // review photos are written without a 'public' visibility/ACL + ->and($reviewStorage->writes)->toHaveCount(1) + ->and($reviewStorage->writes[0][1])->toBe([]) ->and($deleted->resource->uuid)->toBe('owned_review_uuid') ->and($connection->table('reviews')->where('uuid', 'owned_review_uuid')->value('deleted_at'))->not->toBeNull(); }); From b5a436eed07696f119c7ef2ca8c003e8ba8b6d1d Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 6 Oct 2026 14:50:41 +0800 Subject: [PATCH 002/193] feat(socket-auth): storefront customer and checkout socket tokens, channel resolvers - POST storefront/v1/customers/socket-token mints a customer principal (store key + Customer-Token); 404 while socket auth is disabled, 401 without a customer of the storefront's company. - Checkout initialization responses carry socket_token (checkout kind, scp limited to checkout.{public_id}) when socket auth is enabled; absent otherwise. - Register storefront and checkout channel resolvers with core-api's SocketChannelRegistry. - QPay capture publishes {checkout, status, order, error} on checkout.{public_id} instead of the raw payment row; a publish failure no longer fails the callback. - Require fleetbase/core-api ^1.6.69. --- composer.json | 2 +- .../Controllers/v1/CheckoutController.php | 83 ++++- .../Controllers/v1/CustomerController.php | 32 ++ .../Providers/StorefrontServiceProvider.php | 12 + server/src/Support/StorefrontSocket.php | 180 +++++++++ server/src/routes.php | 1 + .../CheckoutBoundaryContractsTest.php | 269 +++++++++++++- .../StorefrontServiceProviderTest.php | 17 + .../Unit/Routes/StorefrontRoutesTest.php | 1 + .../Unit/Support/StorefrontSocketTest.php | 350 ++++++++++++++++++ 10 files changed, 937 insertions(+), 10 deletions(-) create mode 100644 server/src/Support/StorefrontSocket.php create mode 100644 server/tests/Unit/Support/StorefrontSocketTest.php diff --git a/composer.json b/composer.json index 267085d0..f386e3db 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,7 @@ ], "require": { "php": "^8.0", - "fleetbase/core-api": "*", + "fleetbase/core-api": "^1.6.69", "fleetbase/fleetops-api": "*", "geocoder-php/google-maps-places-provider": "^1.4", "laravel-notification-channels/apn": "^5.0", diff --git a/server/src/Http/Controllers/v1/CheckoutController.php b/server/src/Http/Controllers/v1/CheckoutController.php index df5aeddb..1aa9443c 100644 --- a/server/src/Http/Controllers/v1/CheckoutController.php +++ b/server/src/Http/Controllers/v1/CheckoutController.php @@ -32,6 +32,7 @@ use Fleetbase\Storefront\Promotions\PromotionUnavailableException; use Fleetbase\Storefront\Support\QPay; use Fleetbase\Storefront\Support\Storefront; +use Fleetbase\Storefront\Support\StorefrontSocket; use Fleetbase\Storefront\Support\StripeUtils; use Fleetbase\Support\SocketCluster\SocketClusterService; use Illuminate\Http\JsonResponse; @@ -385,7 +386,7 @@ public static function initializeCashCheckout(Contact $customer, Gateway $gatewa // GET /checkouts/status needs BOTH, and only initializeQPayCheckout was returning // the id — so a cash or card client could never reach its own checkout's status. // The checkout is discarded instead if one of its promotions ran out meanwhile. - return static::reservePromotions($checkout, $checkoutOptions, $customer) ?? response()->json([ + return static::reservePromotions($checkout, $checkoutOptions, $customer) ?? static::checkoutResponse($checkout, [ 'checkout' => $checkout->public_id, 'token' => $checkout->token, ]); @@ -486,7 +487,7 @@ public static function initializeStripeCheckout(Contact $customer, Gateway $gate // See initializeCheckout: `checkout` is the chkt_* public id GET /checkouts/status // requires alongside the token, and nothing but the QPay path used to return it. - return static::reservePromotions($checkout, $checkoutOptions, $customer) ?? response()->json([ + return static::reservePromotions($checkout, $checkoutOptions, $customer) ?? static::checkoutResponse($checkout, [ 'paymentIntent' => $paymentIntent->id, 'clientSecret' => $paymentIntent->client_secret, 'ephemeralKey' => $ephemeralKey->secret, @@ -718,7 +719,7 @@ public function updateStripePaymentIntent(Request $request) // Return JSON response with updated PaymentIntent and ephemeral key. `checkout` is // the chkt_* public id GET /checkouts/status requires alongside the token. - return static::reservePromotions($checkout, $checkoutOptions, $customer) ?? response()->json([ + return static::reservePromotions($checkout, $checkoutOptions, $customer) ?? static::checkoutResponse($checkout, [ 'paymentIntent' => $paymentIntent->id, 'clientSecret' => $paymentIntent->client_secret, 'ephemeralKey' => $ephemeralKey->secret, @@ -842,7 +843,7 @@ public static function initializeQPayCheckout(Contact $customer, Gateway $gatewa // Update checkout with invoice id $checkout->updateOption('qpay_invoice_id', data_get($invoice, 'invoice_id')); - return static::reservePromotions($checkout, $checkoutOptions, $customer) ?? response()->json([ + return static::reservePromotions($checkout, $checkoutOptions, $customer) ?? static::checkoutResponse($checkout, [ 'invoice' => $invoice, 'checkout' => $checkout->public_id, 'token' => $checkout->token, @@ -861,7 +862,7 @@ public static function initializeQPayCheckout(Contact $customer, Gateway $gatewa * response for either success or error scenarios. * - Initializes a QPay instance with the gateway configuration and sets the authentication token. * - Retrieves the invoice ID from the checkout options and performs a payment check using QPay's API. - * - Publishes the payment data or error response to the SocketCluster channel. + * - Publishes the checkout status, its order and any error to the checkout's realtime channel. * * Depending on the 'respond' flag from the request, the method returns a JSON response * or completes the processing without returning data. @@ -925,7 +926,7 @@ public function captureQPayCallback(Request $request) ]; } - SocketClusterService::publish('checkout.' . $checkout->public_id, $data); + static::publishCheckoutUpdate($checkout, $testScenario === 'success', $data['error']); return $shouldRespond ? response()->json($data) : response()->json(); } @@ -978,7 +979,7 @@ public function captureQPayCallback(Request $request) 'error' => null, ]; - SocketClusterService::publish('checkout.' . $checkout->public_id, $data); + static::publishCheckoutUpdate($checkout, true); return $shouldRespond ? response()->json($data) : response()->json(); } @@ -2143,6 +2144,74 @@ protected static function promotionCodesFor(Cart $cart, Request $request): array return array_values(array_unique(array_filter(array_merge((array) $codes, $cart->getPromotionCodes()), 'is_string'))); } + /** + * The JSON response for an initialized checkout. + * + * When realtime socket authentication is enabled it carries `socket_token`: a + * `checkout` token whose scope is exactly this checkout's channel, so a client + * (a guest included) can listen for its own payment confirmation. The field is + * absent while socket authentication is disabled. + */ + protected static function checkoutResponse(Checkout $checkout, array $data): JsonResponse + { + $socketToken = StorefrontSocket::checkoutToken($checkout); + if ($socketToken) { + $data['socket_token'] = $socketToken; + } + + return response()->json($data); + } + + /** + * Publishes a checkout's progress on its realtime channel. + * + * The payload is what a storefront client acts on — the checkout, its status, the + * order once one exists (serialized exactly as GET checkouts/status returns it) and + * any error — never the raw gateway payment record. A publish failure is logged and + * swallowed: by now the payment is recorded, and clients still recover the outcome + * through GET checkouts/status. + * + * @return array|null the published payload, or null when publishing failed + */ + protected static function publishCheckoutUpdate(Checkout $checkout, bool $paid, ?array $error = null): ?array + { + try { + // A failed payment carries no order, so a client never completes on an error event. + $order = !$error && $checkout->order_uuid ? Order::where('uuid', $checkout->order_uuid)->first() : null; + $status = 'pending'; + if ($error) { + $status = 'failed'; + } elseif ($order) { + $status = 'completed'; + } elseif ($paid) { + $status = 'paid'; + } + + $data = [ + 'checkout' => $checkout->public_id, + 'status' => $status, + 'order' => $order ? static::checkoutChannelOrder($order) : null, + 'error' => $error, + ]; + + SocketClusterService::publish(StorefrontSocket::checkoutChannel($checkout), $data); + + return $data; + } catch (\Throwable $e) { + Log::warning('[CHECKOUT SOCKET PUBLISH FAILED]: ' . $e->getMessage(), ['checkout' => $checkout->public_id]); + + return null; + } + } + + /** + * Serializes a checkout's order for its realtime channel, as GET checkouts/status does. + */ + protected static function checkoutChannelOrder(Order $order): array + { + return json_decode(json_encode(new OrderResource($order)), true); + } + /** * Reserve a new checkout's promotions, discarding the checkout if one ran out meanwhile. */ diff --git a/server/src/Http/Controllers/v1/CustomerController.php b/server/src/Http/Controllers/v1/CustomerController.php index 1aa827e4..8e47c447 100644 --- a/server/src/Http/Controllers/v1/CustomerController.php +++ b/server/src/Http/Controllers/v1/CustomerController.php @@ -22,6 +22,8 @@ use Fleetbase\Storefront\Http\Resources\Customer; use Fleetbase\Storefront\Push\StorefrontPushChannel; use Fleetbase\Storefront\Support\Storefront; +use Fleetbase\Storefront\Support\StorefrontSocket; +use Fleetbase\Support\SocketCluster\SocketToken; use Fleetbase\Support\Utils; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\Request; @@ -127,6 +129,36 @@ public function unregisterDevice(Request $request) ]); } + /** + * Mints a realtime socket token for the signed-in customer. + * + * POST storefront/v1/customers/socket-token — authenticated like every other + * customer endpoint: the storefront key plus a Customer-Token header. The token + * is a `customer` principal scoped to the store or network the key belongs to; + * the socket server only lets it subscribe to channels the customer owns. + * Returns 404 while socket authentication is not configured on this instance. + */ + public function socketToken(Request $request) + { + if (!SocketToken::enabled()) { + return response()->apiError('Not found.', 404); + } + + $customer = Storefront::getCustomerFromToken(); + if (!$customer) { + return response()->apiError('Not authorized to create a socket token for customer.', 401); + } + + // A customer's token is only honoured by the storefront whose company the + // customer belongs to, so a token minted against another company's key is refused. + $storefront = Storefront::about(); + if (!$storefront || $storefront->company_uuid !== $customer->company_uuid) { + return response()->apiError('Not authorized to create a socket token for customer.', 401); + } + + return response()->json(SocketToken::issue(StorefrontSocket::customerPrincipal($customer, $storefront))); + } + /** * Newer core-api versions add push metadata columns to user_devices. * diff --git a/server/src/Providers/StorefrontServiceProvider.php b/server/src/Providers/StorefrontServiceProvider.php index 8fd668d9..79b25d48 100644 --- a/server/src/Providers/StorefrontServiceProvider.php +++ b/server/src/Providers/StorefrontServiceProvider.php @@ -4,6 +4,8 @@ use Fleetbase\FleetOps\Providers\FleetOpsServiceProvider; use Fleetbase\Providers\CoreServiceProvider; +use Fleetbase\Storefront\Support\StorefrontSocket; +use Fleetbase\Support\SocketCluster\SocketChannelRegistry; // These dependency guards are only reachable before Composer can load this provider. // The test runtime necessarily has both parent providers loaded, so the throw paths cannot execute. @@ -111,5 +113,15 @@ public function boot() $this->mergeConfigFrom(__DIR__ . '/../../config/database.connections.php', 'database.connections'); $this->mergeConfigFrom(__DIR__ . '/../../config/storefront.php', 'storefront'); $this->mergeConfigFrom(__DIR__ . '/../../config/api.php', 'storefront.api'); + $this->registerStorefrontSocketChannels(); + } + + /** + * Registers the realtime channel prefixes storefront owns (`storefront`, `checkout`) + * with core-api's socket channel registry, so the socket server can authorize them. + */ + public function registerStorefrontSocketChannels(): void + { + StorefrontSocket::registerChannels($this->app->make(SocketChannelRegistry::class)); } } diff --git a/server/src/Support/StorefrontSocket.php b/server/src/Support/StorefrontSocket.php new file mode 100644 index 00000000..f8d1b66a --- /dev/null +++ b/server/src/Support/StorefrontSocket.php @@ -0,0 +1,180 @@ + 'customer', + 'sub' => $customer->uuid, + 'cid' => $storefront->company_uuid, + 'cpid' => static::companyPublicId($storefront->company_uuid), + 'env' => static::ENV, + 'ids' => [$customer->uuid, $customer->public_id], + 'adm' => false, + 'scp' => null, + 'sid' => $storefront->uuid, + ]); + } + + /** + * Builds the `checkout` principal: it may only ever subscribe to its own checkout channel. + */ + public static function checkoutPrincipal(Checkout $checkout): SocketPrincipal + { + return SocketPrincipal::fromClaims([ + 'kind' => 'checkout', + 'sub' => $checkout->uuid, + 'cid' => $checkout->company_uuid, + 'cpid' => static::companyPublicId($checkout->company_uuid), + 'env' => static::ENV, + 'ids' => [$checkout->uuid, $checkout->public_id], + 'adm' => false, + 'scp' => [static::checkoutChannel($checkout)], + 'sid' => $checkout->store_uuid ?? $checkout->network_uuid, + ]); + } + + /** + * Mints the socket token returned with an initialized checkout, or null while socket auth is disabled. + */ + public static function checkoutToken(Checkout $checkout): ?array + { + if (!SocketToken::enabled()) { + return null; + } + + return SocketToken::issue(static::checkoutPrincipal($checkout)); + } + + /** + * The channel checkout progress is published on. + */ + public static function checkoutChannel(Checkout $checkout): string + { + return 'checkout.' . $checkout->public_id; + } + + /** + * Registers storefront's channel resolvers with core-api's socket channel registry. + * + * @param \Fleetbase\Support\SocketCluster\SocketChannelRegistry $registry + */ + public static function registerChannels($registry): void + { + $registry->register('storefront', \Closure::fromCallable([static::class, 'authorizeStorefront'])); + $registry->register('checkout', \Closure::fromCallable([static::class, 'authorizeCheckout'])); + } + + /** + * `storefront.{id}` — id is a store or network uuid, public id or key. + * + * Console users and API credentials may subscribe to their own company's + * storefronts; a customer only to the storefront their token was minted for. + */ + public static function authorizeStorefront(SocketPrincipal $principal, string $id, string $channel): bool + { + $storefront = static::findStorefront($id); + + if (!$storefront || !$storefront->company_uuid || $storefront->company_uuid !== $principal->cid) { + return false; + } + + if ($principal->isCompanyScoped()) { + return true; + } + + if ($principal->kind === 'customer') { + return $principal->sid !== null && $storefront->uuid === $principal->sid; + } + + return false; + } + + /** + * `checkout.{id}` — id is a checkout uuid or public id. + * + * Console users and API credentials may subscribe to their own company's + * checkouts; a customer only to checkouts they own. A `checkout` principal never + * reaches here: its `scp` already limits it to its own channel. + */ + public static function authorizeCheckout(SocketPrincipal $principal, string $id, string $channel): bool + { + $checkout = Checkout::select(['uuid', 'public_id', 'company_uuid', 'owner_uuid']) + ->where(function ($query) use ($id) { + $query->where('uuid', $id)->orWhere('public_id', $id); + }) + ->first(); + + if (!$checkout || !$checkout->company_uuid || $checkout->company_uuid !== $principal->cid) { + return false; + } + + if ($principal->isCompanyScoped()) { + return true; + } + + if ($principal->kind === 'customer') { + return $checkout->owner_uuid !== null && $checkout->owner_uuid === $principal->sub; + } + + return false; + } + + /** + * Finds a store, then a network, by uuid, public id or key. + */ + protected static function findStorefront(string $id): Store|Network|null + { + foreach ([Store::class, Network::class] as $model) { + $storefront = $model::select(['uuid', 'company_uuid']) + ->where(function ($query) use ($id) { + $query->where('uuid', $id)->orWhere('public_id', $id)->orWhere('key', $id); + }) + ->first(); + + if ($storefront) { + return $storefront; + } + } + + return null; + } + + protected static function companyPublicId(?string $companyUuid): ?string + { + if (!$companyUuid) { + return null; + } + + return Company::where('uuid', $companyUuid)->value('public_id'); + } +} diff --git a/server/src/routes.php b/server/src/routes.php index 7099a53f..5cec3919 100644 --- a/server/src/routes.php +++ b/server/src/routes.php @@ -135,6 +135,7 @@ function ($router) { $router->get('/', 'CustomerController@query'); $router->post('register-device', 'CustomerController@registerDevice'); $router->post('unregister-device', 'CustomerController@unregisterDevice'); + $router->post('socket-token', 'CustomerController@socketToken'); $router->get('places', 'CustomerController@places'); $router->get('orders', 'CustomerController@orders'); $router->get('{id}', 'CustomerController@find'); diff --git a/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php b/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php index 9113ec35..7d02eee8 100644 --- a/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php +++ b/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php @@ -468,6 +468,34 @@ protected function createOrderFromCheckout($checkout, $transactionDetails, $note } } +class CheckoutChannelPayloadStub extends TestableCheckoutController +{ + public static bool $failSerialization = false; + + protected static function checkoutChannelOrder(Fleetbase\FleetOps\Models\Order $order): array + { + if (static::$failSerialization) { + throw new RuntimeException('Order serialization failed'); + } + + return ['id' => $order->public_id]; + } + + public static function initializedCheckoutResponse(Checkout $checkout, array $data) + { + return static::checkoutResponse($checkout, $data); + } +} + +function enableCheckoutSocketAuth(bool $enabled = true): void +{ + config(['broadcasting.connections.socketcluster.auth_key' => $enabled ? 'checkout-socket-test-key-0123456789abcdef' : null]); +} + +afterEach(function () { + enableCheckoutSocketAuth(false); +}); + function createCheckoutBoundarySchema(): void { $connection = Model::getConnectionResolver()->connection('mysql'); @@ -2794,7 +2822,8 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode CheckoutQPayStub::$sandboxUsed = false; CheckoutQPayStub::$authenticated = false; Fleetbase\Support\SocketCluster\SocketClusterService::$published = []; - $controller = new TestableCheckoutController(); + CheckoutChannelPayloadStub::$failSerialization = false; + $controller = new CheckoutChannelPayloadStub(); $missingInvoice = $controller->captureQPayCallback(Request::create('/checkout/qpay', 'POST', [ 'checkout' => 'checkout_abcdefgh', @@ -2857,7 +2886,243 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode ->and($sandboxError->getData(true)['error']['error'])->toBe('PAYMENT_NOT_PAID') ->and(CheckoutQPayStub::$sandboxUsed)->toBeTrue() ->and(CheckoutQPayStub::$authenticated)->toBeTrue() - ->and(Fleetbase\Support\SocketCluster\SocketClusterService::$published)->toHaveCount(3); + ->and(Fleetbase\Support\SocketCluster\SocketClusterService::$published)->toBe([ + // The realtime payload carries what a storefront client acts on — never the raw payment row. + ['checkout.checkout_abcdefgh', [ + 'checkout' => 'checkout_abcdefgh', + 'status' => 'completed', + 'order' => ['id' => 'order_abcdefgh'], + 'error' => null, + ]], + ['checkout.checkout_abcdefgh', [ + 'checkout' => 'checkout_abcdefgh', + 'status' => 'completed', + 'order' => ['id' => 'order_abcdefgh'], + 'error' => null, + ]], + ['checkout.checkout_abcdefgh', [ + 'checkout' => 'checkout_abcdefgh', + 'status' => 'failed', + 'order' => null, + 'error' => [ + 'error' => 'PAYMENT_NOT_PAID', + 'message' => 'Payment has not been paid!', + ], + ]], + ]); +}); + +test('qpay callback publishes paid before an order exists and survives a failed publish', function () { + createCheckoutBoundarySchema(); + $connection = Model::getConnectionResolver()->connection('mysql'); + $connection->table('gateways')->insert([ + 'uuid' => 'qpay_gateway_uuid', + 'code' => 'qpay', + 'owner_uuid' => 'store_uuid', + 'type' => 'qpay', + 'sandbox' => true, + 'callback_url' => 'https://storefront.test/qpay', + 'config' => json_encode(['username' => 'merchant', 'password' => 'secret']), + ]); + $connection->table('orders')->insert([ + 'uuid' => 'order_uuid', + 'public_id' => 'order_abcdefgh', + ]); + $connection->table('checkouts')->insert([ + [ + 'uuid' => 'checkout_pending_uuid', + 'public_id' => 'checkout_pending', + 'gateway_uuid' => 'qpay_gateway_uuid', + 'order_uuid' => null, + 'options' => '{}', + 'token' => 'checkout-token-pending', + ], + [ + 'uuid' => 'checkout_ordered_uuid', + 'public_id' => 'checkout_ordered', + 'gateway_uuid' => 'qpay_gateway_uuid', + 'order_uuid' => 'order_uuid', + 'options' => '{}', + 'token' => 'checkout-token-ordered', + ], + ]); + Fleetbase\Support\SocketCluster\SocketClusterService::$published = []; + CheckoutChannelPayloadStub::$failSerialization = false; + $controller = new CheckoutChannelPayloadStub(); + + $paidWithoutOrder = $controller->captureQPayCallback(Request::create('/checkout/qpay', 'POST', [ + 'checkout' => 'checkout_pending', + 'respond' => true, + 'test' => 'success', + ])); + CheckoutChannelPayloadStub::$failSerialization = true; + $publishFailed = $controller->captureQPayCallback(Request::create('/checkout/qpay', 'POST', [ + 'checkout' => 'checkout_ordered', + 'respond' => true, + 'test' => 'success', + ])); + CheckoutChannelPayloadStub::$failSerialization = false; + + expect($paidWithoutOrder->getData(true)['payment']['payment_status'])->toBe('PAID') + // A failed publish never turns a recorded payment into an error response. + ->and($publishFailed->getStatusCode())->toBe(200) + ->and($publishFailed->getData(true)['payment']['payment_status'])->toBe('PAID') + ->and(Fleetbase\Support\SocketCluster\SocketClusterService::$published)->toBe([ + ['checkout.checkout_pending', [ + 'checkout' => 'checkout_pending', + 'status' => 'paid', + 'order' => null, + 'error' => null, + ]], + ]); +}); + +test('qpay callback publishes the order serialized as checkout status returns it', function () { + createCheckoutCaptureExecutionSchema(); + $connection = Model::getConnectionResolver()->connection('mysql'); + $connection->table('gateways')->insert([ + 'uuid' => 'qpay_gateway_uuid', + 'code' => 'qpay', + 'owner_uuid' => 'store_uuid', + 'type' => 'qpay', + 'sandbox' => true, + 'callback_url' => 'https://storefront.test/qpay', + 'config' => json_encode(['username' => 'merchant', 'password' => 'secret']), + ]); + $connection->table('orders')->insert([ + 'uuid' => 'status_order_uuid', + 'public_id' => 'order_status', + ]); + $connection->table('checkouts')->insert([ + 'uuid' => 'checkout_uuid', + 'public_id' => 'checkout_abcdefgh', + 'gateway_uuid' => 'qpay_gateway_uuid', + 'order_uuid' => 'status_order_uuid', + 'options' => json_encode(['qpay_invoice_id' => 'invoice_checkout']), + 'token' => 'checkout-token', + 'captured' => true, + ]); + session(['storefront_key' => null]); + CheckoutQPayStub::$failure = null; + CheckoutQPayStub::$paymentCheckResult = (object) [ + 'count' => 1, + 'rows' => [ + (object) [ + 'payment_id' => 'payment_checkout', + 'payment_status' => 'PAID', + 'payment_amount' => 2500, + 'payment_wallet' => 'QPay', + ], + ], + ]; + TestableCheckoutController::$statusFallbackOrder = null; + TestableCheckoutController::$statusFallbackFailure = null; + Fleetbase\Support\SocketCluster\SocketClusterService::$published = []; + + (new TestableCheckoutController())->captureQPayCallback(Request::create('/checkout/qpay', 'POST', [ + 'checkout' => 'checkout_abcdefgh', + ])); + $published = Fleetbase\Support\SocketCluster\SocketClusterService::$published; + + expect($published)->toHaveCount(1) + ->and($published[0][0])->toBe('checkout.checkout_abcdefgh') + ->and(array_keys($published[0][1]))->toBe(['checkout', 'status', 'order', 'error']) + ->and($published[0][1]['status'])->toBe('completed') + ->and($published[0][1]['order']['id'])->toBe('order_status') + ->and($published[0][1]['error'])->toBeNull(); +}); + +test('initialized checkouts carry a checkout-scoped socket token only while socket auth is enabled', function () { + createCheckoutBoundarySchema(); + $schema = Model::getConnectionResolver()->connection('mysql')->getSchemaBuilder(); + $schema->dropIfExists('companies'); + $schema->create('companies', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + Model::getConnectionResolver()->connection('mysql')->table('companies')->insert([ + 'uuid' => 'company_uuid', + 'public_id' => 'company_public', + ]); + session([ + 'company' => 'company_uuid', + 'storefront_store' => 'store_uuid', + 'storefront_network' => null, + ]); + $initialize = function () { + $cart = new Cart(); + $cart->forceFill([ + 'uuid' => 'cart_uuid', + 'currency' => 'USD', + 'items' => [['id' => 'line_one', 'quantity' => 1, 'subtotal' => 1000]], + 'events' => [], + ]); + $customer = new Fleetbase\Storefront\Models\Customer(); + $customer->forceFill(['uuid' => 'customer_uuid']); + $gateway = Gateway::cash(); + $gateway->forceFill(['uuid' => 'gateway_uuid']); + + return CheckoutController::initializeCashCheckout( + $customer, + $gateway, + null, + $cart, + (object) ['is_pickup' => true, 'tip' => false, 'delivery_tip' => false], + Request::create('/checkout') + ); + }; + + $disabled = $initialize(); + enableCheckoutSocketAuth(); + $enabled = $initialize(); + $checkout = Checkout::where('public_id', $enabled->getData(true)['checkout'])->firstOrFail(); + $socket = $enabled->getData(true)['socket_token']; + $claims = Fleetbase\Support\SocketCluster\SocketToken::verify($socket['token']); + + expect(array_keys($disabled->getData(true)))->toBe(['checkout', 'token']) + ->and(array_keys($enabled->getData(true)))->toBe(['checkout', 'token', 'socket_token']) + ->and(array_keys($socket))->toBe(['token', 'expires_in', 'expires_at']) + ->and($claims->kind)->toBe('checkout') + ->and($claims->sub)->toBe($checkout->uuid) + ->and($claims->ids)->toBe([$checkout->uuid, $checkout->public_id]) + ->and($claims->scp)->toBe(['checkout.' . $checkout->public_id]) + ->and($claims->cid)->toBe('company_uuid') + ->and($claims->cpid)->toBe('company_public') + ->and($claims->sid)->toBe('store_uuid') + ->and($claims->env)->toBe('live'); +}); + +test('checkout response helper adds the socket token beside the existing fields', function () { + createCheckoutBoundarySchema(); + $schema = Model::getConnectionResolver()->connection('mysql')->getSchemaBuilder(); + $schema->dropIfExists('companies'); + $schema->create('companies', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + Model::getConnectionResolver()->connection('mysql')->table('checkouts')->insert([ + 'uuid' => 'checkout_uuid', + 'public_id' => 'checkout_network', + 'company_uuid' => 'company_uuid', + 'network_uuid' => 'network_uuid', + 'token' => 'checkout-token', + ]); + $checkout = Checkout::where('uuid', 'checkout_uuid')->firstOrFail(); + enableCheckoutSocketAuth(); + + $data = CheckoutChannelPayloadStub::initializedCheckoutResponse($checkout, ['invoice' => ['invoice_id' => 'inv'], 'checkout' => 'checkout_network', 'token' => 'checkout-token'])->getData(true); + $claims = Fleetbase\Support\SocketCluster\SocketToken::verify($data['socket_token']['token']); + + expect($data['invoice'])->toBe(['invoice_id' => 'inv']) + ->and($claims->scp)->toBe(['checkout.checkout_network']) + ->and($claims->sid)->toBe('network_uuid') + ->and($claims->cpid)->toBeNull(); }); test('single and multiple order capture reject invalid checkout tokens safely', function () { diff --git a/server/tests/Unit/Providers/StorefrontServiceProviderTest.php b/server/tests/Unit/Providers/StorefrontServiceProviderTest.php index d71c6aac..485d25c6 100644 --- a/server/tests/Unit/Providers/StorefrontServiceProviderTest.php +++ b/server/tests/Unit/Providers/StorefrontServiceProviderTest.php @@ -100,6 +100,11 @@ protected function mergeConfigFrom($path, $key) { $this->calls[] = $key; } + + public function registerStorefrontSocketChannels(): void + { + $this->calls[] = 'socket-channels'; + } }; $provider->boot(); @@ -119,5 +124,17 @@ protected function mergeConfigFrom($path, $key) 'database.connections', 'storefront', 'storefront.api', + 'socket-channels', ]); }); + +test('storefront provider registers its socket channel resolvers with the core registry', function () { + $app = new Fleetbase\TestSupport\ApplicationContainer(); + $registry = new Fleetbase\Support\SocketCluster\SocketChannelRegistry(); + $app->instance(Fleetbase\Support\SocketCluster\SocketChannelRegistry::class, $registry); + + (new StorefrontServiceProvider($app))->registerStorefrontSocketChannels(); + + expect($registry->resolve('storefront'))->toBeInstanceOf(Closure::class) + ->and($registry->resolve('checkout'))->toBeInstanceOf(Closure::class); +}); diff --git a/server/tests/Unit/Routes/StorefrontRoutesTest.php b/server/tests/Unit/Routes/StorefrontRoutesTest.php index 8b8f4452..b6a0dc2f 100644 --- a/server/tests/Unit/Routes/StorefrontRoutesTest.php +++ b/server/tests/Unit/Routes/StorefrontRoutesTest.php @@ -92,6 +92,7 @@ private function record(string $method, string $uri, mixed $action): self ['GET', 'about', 'StoreController@about'], ['POST', '/', 'ProductController@create'], ['POST', 'receipt', 'OrderController@getReceipt'], + ['POST', 'socket-token', 'CustomerController@socketToken'], ['POST', 'send-push-notification', 'ActionController@sendPushNotification'], ['FLEETBASE', 'orders', null], ['FLEETBASE', 'products', null], diff --git a/server/tests/Unit/Support/StorefrontSocketTest.php b/server/tests/Unit/Support/StorefrontSocketTest.php new file mode 100644 index 00000000..aa7ec375 --- /dev/null +++ b/server/tests/Unit/Support/StorefrontSocketTest.php @@ -0,0 +1,350 @@ + $enabled ? 'storefront-socket-test-key-0123456789abcdef' : null]); +} + +function storefrontSocketSchema(): void +{ + $connection = Model::getConnectionResolver()->connection('mysql'); + $schema = $connection->getSchemaBuilder(); + + foreach (['companies', 'contacts', 'personal_access_tokens', 'stores', 'networks', 'checkouts'] as $table) { + $schema->dropIfExists($table); + } + + $schema->create('companies', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('contacts', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('user_uuid')->nullable(); + $table->string('type')->nullable(); + $table->string('name')->nullable(); + $table->text('meta')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('personal_access_tokens', function ($table) { + $table->increments('id'); + $table->string('tokenable_type')->nullable(); + $table->string('tokenable_id')->nullable(); + $table->string('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamps(); + }); + foreach (['stores', 'networks'] as $storefrontTable) { + $schema->create($storefrontTable, function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('backdrop_uuid')->nullable(); + $table->string('logo_uuid')->nullable(); + $table->string('order_config_uuid')->nullable(); + $table->string('key')->nullable(); + $table->string('name')->nullable(); + $table->text('description')->nullable(); + $table->text('translations')->nullable(); + $table->string('website')->nullable(); + $table->string('facebook')->nullable(); + $table->string('instagram')->nullable(); + $table->string('twitter')->nullable(); + $table->string('email')->nullable(); + $table->string('phone')->nullable(); + $table->text('tags')->nullable(); + $table->string('currency')->nullable(); + $table->string('timezone')->nullable(); + $table->string('pod_method')->nullable(); + $table->text('options')->nullable(); + $table->text('alertable')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + } + $schema->create('checkouts', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('store_uuid')->nullable(); + $table->string('network_uuid')->nullable(); + $table->string('owner_uuid')->nullable(); + $table->string('owner_type')->nullable(); + $table->text('options')->nullable(); + $table->string('token')->nullable(); + $table->string('order_uuid')->nullable(); + $table->boolean('captured')->default(false); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + + $connection->table('companies')->insert([ + ['uuid' => 'company-a', 'public_id' => 'company_aaaaaaa'], + ['uuid' => 'company-b', 'public_id' => 'company_bbbbbbb'], + ]); + $connection->table('stores')->insert([ + ['uuid' => 'store-a', 'public_id' => 'store_aaaaaaa', 'company_uuid' => 'company-a', 'key' => 'store_key_a'], + ['uuid' => 'store-a2', 'public_id' => 'store_aaaaaa2', 'company_uuid' => 'company-a', 'key' => 'store_key_a2'], + ['uuid' => 'store-b', 'public_id' => 'store_bbbbbbb', 'company_uuid' => 'company-b', 'key' => 'store_key_b'], + ['uuid' => 'store-orphan', 'public_id' => 'store_orphan1', 'company_uuid' => null, 'key' => 'store_key_orphan'], + ]); + $connection->table('networks')->insert([ + ['uuid' => 'network-a', 'public_id' => 'network_aaaaaaa', 'company_uuid' => 'company-a', 'key' => 'network_key_a'], + ]); + $connection->table('contacts')->insert([ + ['uuid' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'public_id' => 'contact_aaaaaaa', 'company_uuid' => 'company-a', 'type' => 'customer'], + ['uuid' => 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', 'public_id' => 'contact_bbbbbbb', 'company_uuid' => 'company-b', 'type' => 'customer'], + ]); + $connection->table('checkouts')->insert([ + ['uuid' => 'checkout-a', 'public_id' => 'chkt_aaaaaaa', 'company_uuid' => 'company-a', 'store_uuid' => 'store-a', 'network_uuid' => null, 'owner_uuid' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'token' => 'checkout_a'], + ['uuid' => 'checkout-guest', 'public_id' => 'chkt_guest01', 'company_uuid' => 'company-a', 'store_uuid' => null, 'network_uuid' => 'network-a', 'owner_uuid' => null, 'token' => 'checkout_g'], + ['uuid' => 'checkout-b', 'public_id' => 'chkt_bbbbbbb', 'company_uuid' => 'company-b', 'store_uuid' => 'store-b', 'network_uuid' => null, 'owner_uuid' => 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', 'token' => 'checkout_b'], + ['uuid' => 'checkout-orphan', 'public_id' => 'chkt_orphan1', 'company_uuid' => null, 'store_uuid' => null, 'network_uuid' => null, 'owner_uuid' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'token' => 'checkout_o'], + ]); + + foreach (['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' => 'customer-secret-a', 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' => 'customer-secret-b'] as $contact => $secret) { + $connection->table('personal_access_tokens')->insert([ + 'name' => $contact, + 'token' => hash('sha256', $secret), + 'abilities' => '["*"]', + 'created_at' => now(), + 'updated_at' => now(), + ]); + } +} + +function storefrontSocketTokenRequest(?string $storefrontKey, ?string $customerToken): Request +{ + $request = Request::create('/storefront/v1/customers/socket-token', 'POST'); + $request->setLaravelSession(new SessionStore('storefront-socket-test', new ArraySessionHandler(120))); + if ($customerToken) { + $request->headers->set('Customer-Token', $customerToken); + } + app()->instance('request', $request); + session(['storefront_key' => $storefrontKey]); + + return $request; +} + +function storefrontSocketPrincipal(string $kind, array $claims = []): SocketPrincipal +{ + return SocketPrincipal::fromClaims(array_merge(['kind' => $kind, 'sub' => $kind . '-subject', 'cid' => 'company-a'], $claims)); +} + +beforeEach(function () { + storefrontSocketSchema(); + storefrontSocketEnable(false); +}); + +afterEach(function () { + storefrontSocketEnable(false); + session(['storefront_key' => null]); +}); + +test('customer socket token is not found while socket auth is disabled', function () { + $response = (new CustomerController())->socketToken(storefrontSocketTokenRequest('store_key_a', 'customer-secret-a')); + + expect($response->getStatusCode())->toBe(404) + ->and($response->getData(true))->toBe(['error' => 'Not found.']); +}); + +test('customer socket token requires an authenticated customer of the storefront company', function () { + storefrontSocketEnable(); + $controller = new CustomerController(); + + $noCustomer = $controller->socketToken(storefrontSocketTokenRequest('store_key_a', null)); + $unknownCustomer = $controller->socketToken(storefrontSocketTokenRequest('store_key_a', 'not-a-real-token')); + $noStorefront = $controller->socketToken(storefrontSocketTokenRequest(null, 'customer-secret-a')); + $otherCompany = $controller->socketToken(storefrontSocketTokenRequest('store_key_b', 'customer-secret-a')); + + foreach ([$noCustomer, $unknownCustomer, $noStorefront, $otherCompany] as $response) { + expect($response->getStatusCode())->toBe(401) + ->and($response->getData(true))->toBe(['error' => 'Not authorized to create a socket token for customer.']); + } +}); + +test('customer socket token mints a customer principal scoped to the store', function () { + storefrontSocketEnable(); + + $response = (new CustomerController())->socketToken(storefrontSocketTokenRequest('store_key_a', 'customer-secret-a')); + $body = $response->getData(true); + $claims = SocketToken::verify($body['token']); + + expect($response->getStatusCode())->toBe(200) + ->and(array_keys($body))->toBe(['token', 'expires_in', 'expires_at']) + ->and($body['expires_in'])->toBe(900) + ->and($claims)->toBeInstanceOf(SocketPrincipal::class) + ->and($claims->kind)->toBe('customer') + ->and($claims->sub)->toBe('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa') + ->and($claims->ids)->toBe(['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'contact_aaaaaaa']) + ->and($claims->cid)->toBe('company-a') + ->and($claims->cpid)->toBe('company_aaaaaaa') + ->and($claims->sid)->toBe('store-a') + ->and($claims->env)->toBe('live') + ->and($claims->adm)->toBeFalse() + ->and($claims->scp)->toBeNull(); +}); + +test('customer socket token minted with a network key is scoped to the network', function () { + storefrontSocketEnable(); + + $response = (new CustomerController())->socketToken(storefrontSocketTokenRequest('network_key_a', 'customer-secret-a')); + $claims = SocketToken::verify($response->getData(true)['token']); + + expect($claims->kind)->toBe('customer') + ->and($claims->sid)->toBe('network-a') + ->and($claims->cid)->toBe('company-a'); +}); + +test('checkout principal is limited by scope to its own checkout channel', function () { + $storeCheckout = Checkout::where('uuid', 'checkout-a')->firstOrFail(); + $networkCheckout = Checkout::where('uuid', 'checkout-guest')->firstOrFail(); + $orphanCheckout = Checkout::where('uuid', 'checkout-orphan')->firstOrFail(); + + $store = StorefrontSocket::checkoutPrincipal($storeCheckout); + $network = StorefrontSocket::checkoutPrincipal($networkCheckout); + $orphan = StorefrontSocket::checkoutPrincipal($orphanCheckout); + + expect($store->kind)->toBe('checkout') + ->and($store->sub)->toBe('checkout-a') + ->and($store->ids)->toBe(['checkout-a', 'chkt_aaaaaaa']) + ->and($store->cid)->toBe('company-a') + ->and($store->cpid)->toBe('company_aaaaaaa') + ->and($store->scp)->toBe(['checkout.chkt_aaaaaaa']) + ->and($store->sid)->toBe('store-a') + ->and($store->env)->toBe('live') + ->and($network->sid)->toBe('network-a') + ->and($network->scp)->toBe(['checkout.chkt_guest01']) + ->and($orphan->cid)->toBeNull() + ->and($orphan->cpid)->toBeNull() + ->and(StorefrontSocket::checkoutChannel($storeCheckout))->toBe('checkout.chkt_aaaaaaa'); +}); + +test('checkout socket token is only minted while socket auth is enabled', function () { + $checkout = Checkout::where('uuid', 'checkout-guest')->firstOrFail(); + + $disabled = StorefrontSocket::checkoutToken($checkout); + storefrontSocketEnable(); + $enabled = StorefrontSocket::checkoutToken($checkout); + $claims = SocketToken::verify($enabled['token']); + + expect($disabled)->toBeNull() + ->and(array_keys($enabled))->toBe(['token', 'expires_in', 'expires_at']) + ->and($claims->kind)->toBe('checkout') + ->and($claims->sub)->toBe('checkout-guest') + ->and($claims->scp)->toBe(['checkout.chkt_guest01']) + ->and($claims->cid)->toBe('company-a'); +}); + +test('storefront channels resolve stores and networks by uuid public id or key within the company', function () { + $user = storefrontSocketPrincipal('user'); + $api = storefrontSocketPrincipal('api'); + + expect(StorefrontSocket::authorizeStorefront($user, 'store-a', 'storefront.store-a'))->toBeTrue() + ->and(StorefrontSocket::authorizeStorefront($user, 'store_aaaaaaa', 'storefront.store_aaaaaaa'))->toBeTrue() + ->and(StorefrontSocket::authorizeStorefront($user, 'store_key_a', 'storefront.store_key_a'))->toBeTrue() + ->and(StorefrontSocket::authorizeStorefront($api, 'network_aaaaaaa', 'storefront.network_aaaaaaa'))->toBeTrue() + ->and(StorefrontSocket::authorizeStorefront($user, 'store_bbbbbbb', 'storefront.store_bbbbbbb'))->toBeFalse() + ->and(StorefrontSocket::authorizeStorefront($user, 'store_orphan1', 'storefront.store_orphan1'))->toBeFalse() + ->and(StorefrontSocket::authorizeStorefront($user, 'store_missing', 'storefront.store_missing'))->toBeFalse(); +}); + +test('storefront channels let a customer subscribe only to the storefront their token names', function () { + $customer = storefrontSocketPrincipal('customer', ['sub' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'sid' => 'store-a']); + $networkMember = storefrontSocketPrincipal('customer', ['sub' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'sid' => 'network-a']); + $unscoped = storefrontSocketPrincipal('customer', ['sub' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa']); + $foreign = storefrontSocketPrincipal('customer', ['sub' => 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', 'cid' => 'company-b', 'sid' => 'store-a']); + $driver = storefrontSocketPrincipal('driver', ['sid' => 'store-a']); + + expect(StorefrontSocket::authorizeStorefront($customer, 'store_aaaaaaa', 'storefront.store_aaaaaaa'))->toBeTrue() + ->and(StorefrontSocket::authorizeStorefront($customer, 'store_aaaaaa2', 'storefront.store_aaaaaa2'))->toBeFalse() + ->and(StorefrontSocket::authorizeStorefront($networkMember, 'network-a', 'storefront.network-a'))->toBeTrue() + ->and(StorefrontSocket::authorizeStorefront($networkMember, 'store-a', 'storefront.store-a'))->toBeFalse() + ->and(StorefrontSocket::authorizeStorefront($unscoped, 'store-a', 'storefront.store-a'))->toBeFalse() + ->and(StorefrontSocket::authorizeStorefront($foreign, 'store-a', 'storefront.store-a'))->toBeFalse() + ->and(StorefrontSocket::authorizeStorefront($driver, 'store-a', 'storefront.store-a'))->toBeFalse(); +}); + +test('checkout channels allow the company and the owning customer only', function () { + $user = storefrontSocketPrincipal('user'); + $api = storefrontSocketPrincipal('api'); + $owner = storefrontSocketPrincipal('customer', ['sub' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'sid' => 'store-a']); + $stranger = storefrontSocketPrincipal('customer', ['sub' => 'contact-other', 'sid' => 'store-a']); + $foreign = storefrontSocketPrincipal('customer', ['sub' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'cid' => 'company-b', 'sid' => 'store-b']); + $checkout = storefrontSocketPrincipal('checkout', ['sub' => 'checkout-a', 'scp' => ['checkout.chkt_aaaaaaa']]); + $driver = storefrontSocketPrincipal('driver'); + + expect(StorefrontSocket::authorizeCheckout($user, 'chkt_aaaaaaa', 'checkout.chkt_aaaaaaa'))->toBeTrue() + ->and(StorefrontSocket::authorizeCheckout($api, 'checkout-a', 'checkout.checkout-a'))->toBeTrue() + ->and(StorefrontSocket::authorizeCheckout($user, 'chkt_bbbbbbb', 'checkout.chkt_bbbbbbb'))->toBeFalse() + ->and(StorefrontSocket::authorizeCheckout($user, 'chkt_orphan1', 'checkout.chkt_orphan1'))->toBeFalse() + ->and(StorefrontSocket::authorizeCheckout($user, 'chkt_missing', 'checkout.chkt_missing'))->toBeFalse() + ->and(StorefrontSocket::authorizeCheckout($owner, 'chkt_aaaaaaa', 'checkout.chkt_aaaaaaa'))->toBeTrue() + ->and(StorefrontSocket::authorizeCheckout($owner, 'chkt_guest01', 'checkout.chkt_guest01'))->toBeFalse() + ->and(StorefrontSocket::authorizeCheckout($stranger, 'chkt_aaaaaaa', 'checkout.chkt_aaaaaaa'))->toBeFalse() + ->and(StorefrontSocket::authorizeCheckout($foreign, 'chkt_aaaaaaa', 'checkout.chkt_aaaaaaa'))->toBeFalse() + ->and(StorefrontSocket::authorizeCheckout($checkout, 'chkt_aaaaaaa', 'checkout.chkt_aaaaaaa'))->toBeFalse() + ->and(StorefrontSocket::authorizeCheckout($driver, 'chkt_aaaaaaa', 'checkout.chkt_aaaaaaa'))->toBeFalse(); +}); + +test('storefront registers its storefront and checkout channel resolvers', function () { + $registry = new SocketChannelRegistry(); + + StorefrontSocket::registerChannels($registry); + + $storefront = $registry->resolve('storefront'); + $checkout = $registry->resolve('checkout'); + $customer = storefrontSocketPrincipal('customer', ['sub' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'sid' => 'store-a']); + + expect($storefront)->toBeInstanceOf(Closure::class) + ->and($checkout)->toBeInstanceOf(Closure::class) + ->and($storefront($customer, 'store-a', 'storefront.store-a'))->toBeTrue() + ->and($checkout($customer, 'chkt_aaaaaaa', 'checkout.chkt_aaaaaaa'))->toBeTrue() + ->and($checkout($customer, 'chkt_bbbbbbb', 'checkout.chkt_bbbbbbb'))->toBeFalse(); +}); + +test('customer principal is built from the customer and the storefront of the key', function () { + $customer = Contact::where('uuid', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa')->firstOrFail(); + $store = Store::where('uuid', 'store-a')->firstOrFail(); + $network = Network::where('uuid', 'network-a')->firstOrFail(); + + $forStore = StorefrontSocket::customerPrincipal($customer, $store); + $forNetwork = StorefrontSocket::customerPrincipal($customer, $network); + + expect($forStore->toClaims())->toBe([ + 'kind' => 'customer', + 'sub' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + 'cid' => 'company-a', + 'cpid' => 'company_aaaaaaa', + 'env' => 'live', + 'ids' => ['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'contact_aaaaaaa'], + 'adm' => false, + 'sid' => 'store-a', + ])->and($forNetwork->sid)->toBe('network-a'); +}); From a38e23428cd759371f598344a68e0e65c10dcf1f Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 6 Oct 2026 16:57:41 +0800 Subject: [PATCH 003/193] perf: speed up network and console store listings Listing a network's stores ran several queries per store and loaded the whole network for distance sorting, so a page of stores could take minutes. - Store resource: build category, networks, locations and media only when the field is requested (when() evaluated its value argument for every store) - Preload the review average with withAvg() on the public and console store queries; the rating accessor uses it instead of one AVG query per store - Console store list eager-loads logo and backdrop, and networks when the network category is requested - Network category lookup reuses eager-loaded networks and resolves each network id once instead of once per store - Ignore missing or unparseable locations instead of measuring from (0, 0): no in-memory sort of every store, and maximum_distance no longer filters out every store when no location is sent - Index reviews.subject_uuid --- ...dd_subject_uuid_index_to_reviews_table.php | 47 ++++++++++++ .../src/Http/Controllers/StoreController.php | 14 ++++ .../Http/Controllers/v1/NetworkController.php | 29 ++++++- server/src/Http/Resources/Store.php | 9 ++- server/src/Models/Store.php | 41 ++++++++-- .../NetworkApiControllerContractsTest.php | 75 +++++++++++++++++++ .../Controllers/StoreQueryContractsTest.php | 22 ++++++ .../tests/Unit/Models/StoreBehaviorTest.php | 62 +++++++++++++++ 8 files changed, 287 insertions(+), 12 deletions(-) create mode 100644 server/migrations/2026_10_06_000000_add_subject_uuid_index_to_reviews_table.php create mode 100644 server/tests/Unit/Http/Controllers/StoreQueryContractsTest.php diff --git a/server/migrations/2026_10_06_000000_add_subject_uuid_index_to_reviews_table.php b/server/migrations/2026_10_06_000000_add_subject_uuid_index_to_reviews_table.php new file mode 100644 index 00000000..3be53b54 --- /dev/null +++ b/server/migrations/2026_10_06_000000_add_subject_uuid_index_to_reviews_table.php @@ -0,0 +1,47 @@ +indexExists('reviews', 'reviews_subject_uuid_index')) { + return; + } + + Schema::connection(config('storefront.connection.db'))->table('reviews', function (Blueprint $table) { + $table->index('subject_uuid'); + }); + } + + public function down(): void + { + if (!$this->indexExists('reviews', 'reviews_subject_uuid_index')) { + return; + } + + Schema::connection(config('storefront.connection.db'))->table('reviews', function (Blueprint $table) { + $table->dropIndex(['subject_uuid']); + }); + } + + protected function indexExists(string $table, string $index): bool + { + try { + $indexes = Schema::connection(config('storefront.connection.db')) + ->getConnection() + ->getDoctrineSchemaManager() + ->listTableIndexes($table); + + return isset($indexes[$index]); + } catch (Throwable $e) { + return false; + } + } +}; diff --git a/server/src/Http/Controllers/StoreController.php b/server/src/Http/Controllers/StoreController.php index 0b67731e..91c65728 100644 --- a/server/src/Http/Controllers/StoreController.php +++ b/server/src/Http/Controllers/StoreController.php @@ -3,6 +3,7 @@ namespace Fleetbase\Storefront\Http\Controllers; use Fleetbase\Storefront\Models\Store; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\Request; class StoreController extends StorefrontController @@ -14,6 +15,19 @@ class StoreController extends StorefrontController */ public $resource = 'store'; + /** + * Eager-load what the store resource always serializes, so listing stores + * does not run logo, backdrop and rating queries for every store. + */ + public function onQueryRecord(Builder $builder, Request $request) + { + $builder->with(['logo', 'backdrop'])->withAvg('reviews', 'rating'); + + if ($request->filled('network') && ($request->has('with_category') || $request->inArray('with', 'category'))) { + $builder->with('networks'); + } + } + public function allStores(Request $request) { $stores = Store::select(['uuid', 'name', 'description', 'created_at']) diff --git a/server/src/Http/Controllers/v1/NetworkController.php b/server/src/Http/Controllers/v1/NetworkController.php index d6514f73..8db88e6e 100644 --- a/server/src/Http/Controllers/v1/NetworkController.php +++ b/server/src/Http/Controllers/v1/NetworkController.php @@ -14,6 +14,28 @@ class NetworkController extends Controller { + /** + * The point to measure store distances from, or null when the request has no usable location. + * + * The coordinate parser falls back to (0, 0) for missing or invalid input. Treating that as a + * location made every store's distance be measured from the Gulf of Guinea, filtered out every + * store when maximum_distance was set, and loaded the whole network for in-memory sorting. + */ + protected static function resolveReferencePoint($location): ?Point + { + if (blank($location)) { + return null; + } + + $point = Utils::getPointFromCoordinates($location); + + if ((float) $point->getLat() === 0.0 && (float) $point->getLng() === 0.0) { + return null; + } + + return $point; + } + /** * Returns all stores within the network. * @@ -34,7 +56,7 @@ public function stores(Request $request) $location = $request->input('location'); $maxDistance = $request->input('maximum_distance', null); $exclude = $request->input('exclude', []); - $coordinates = Utils::getPointFromCoordinates($location); + $coordinates = static::resolveReferencePoint($location); $requiresDistancePostProcessing = $coordinates instanceof Point && ($sort === 'nearest' || is_numeric($maxDistance)); if (is_string($tagged)) { @@ -52,6 +74,7 @@ public function stores(Request $request) /** @var \Illuminate\Database\Query\Builder $query */ $query = Store::select('*') ->with(['logo', 'backdrop', 'media', 'locations.place']) + ->withAvg('reviews', 'rating') ->whereHas('locations') ->whereHas('networks', function ($q) use ($request) { $q->where('network_uuid', session('storefront_network')); @@ -114,10 +137,10 @@ public function stores(Request $request) switch ($sort) { case 'highest_rated': - $query->withAvg('reviews', 'rating')->orderByDesc('reviews_avg_rating'); + $query->orderByDesc('reviews_avg_rating'); break; case 'lowest_rated': - $query->withAvg('reviews', 'rating')->orderBy('reviews_avg_rating'); + $query->orderBy('reviews_avg_rating'); break; case 'newest': $query->orderByDesc('created_at'); diff --git a/server/src/Http/Resources/Store.php b/server/src/Http/Resources/Store.php index f515a413..8faac538 100644 --- a/server/src/Http/Resources/Store.php +++ b/server/src/Http/Resources/Store.php @@ -50,10 +50,11 @@ public function toArray($request) 'alertable' => $this->alertable, 'is_network' => false, 'is_store' => true, - 'category' => $this->when($request->filled('network') && ($request->has('with_category') || $request->inArray('with', 'category')), new Category($this->getNetworkCategoryUsingId($request->input('network')))), - 'networks' => $this->when($request->boolean('with_networks') || $request->inArray('with', 'networks'), Network::collection($this->networks)), - 'locations' => $this->when($request->boolean('with_locations') || $request->inArray('with', 'locations'), $this->locations->mapInto(StoreLocation::class)), - 'media' => $this->when($request->boolean('with_media') || $request->inArray('with', 'media'), Media::collection($this->media)), + // Closures, so relations and lookups only run when the field is requested. + 'category' => $this->when($request->filled('network') && ($request->has('with_category') || $request->inArray('with', 'category')), fn () => new Category($this->getNetworkCategoryUsingId($request->input('network')))), + 'networks' => $this->when($request->boolean('with_networks') || $request->inArray('with', 'networks'), fn () => Network::collection($this->networks)), + 'locations' => $this->when($request->boolean('with_locations') || $request->inArray('with', 'locations'), fn () => $this->locations->mapInto(StoreLocation::class)), + 'media' => $this->when($request->boolean('with_media') || $request->inArray('with', 'media'), fn () => Media::collection($this->media)), 'slug' => $this->slug, 'created_at' => $this->created_at, 'updated_at' => $this->updated_at, diff --git a/server/src/Models/Store.php b/server/src/Models/Store.php index 4c5a3c2d..1dce9319 100644 --- a/server/src/Models/Store.php +++ b/server/src/Models/Store.php @@ -340,6 +340,14 @@ public function getBackdropUrlAttribute() */ public function getRatingAttribute() { + // Use the average preloaded with withAvg('reviews', 'rating') when present, so + // listing stores does not run one aggregate query per store. + if (array_key_exists('reviews_avg_rating', $this->attributes)) { + $average = $this->attributes['reviews_avg_rating']; + + return is_numeric($average) ? $average + 0 : 0; + } + return $this->reviews()->avg('rating') ?? 0; } @@ -356,13 +364,33 @@ public function getNetworkCategoryUsingId(?string $id) return null; } - $network = Network::where('uuid', $id)->orWhere('public_id', $id)->first(); + // A network's uuid never changes, so resolve each id once instead of once per store. + if (!array_key_exists($id, static::$networkUuidsById)) { + static::$networkUuidsById[$id] = Network::where('uuid', $id)->orWhere('public_id', $id)->value('uuid'); + } - if (!$network instanceof Network) { + $networkUuid = static::$networkUuidsById[$id]; + + if (!$networkUuid) { return null; } - return $this->getNetworkCategory($network); + return $this->getNetworkCategory((new Network())->forceFill(['uuid' => $networkUuid])); + } + + /** + * Network uuids resolved by uuid or public id. + * + * @var array + */ + protected static array $networkUuidsById = []; + + /** + * Forget network ids resolved by getNetworkCategoryUsingId(). + */ + public static function flushResolvedNetworkIds(): void + { + static::$networkUuidsById = []; } /** @@ -374,8 +402,11 @@ public function getNetworkCategoryUsingId(?string $id) */ public function getNetworkCategory(Network $network) { - // Find the relationship between this store and the given network - $networkRelation = $this->networks()->where('networks.uuid', $network->uuid)->first(); + // Find the relationship between this store and the given network, using the + // eager-loaded networks when available. + $networkRelation = $this->relationLoaded('networks') + ? $this->networks->firstWhere('uuid', $network->uuid) + : $this->networks()->where('networks.uuid', $network->uuid)->first(); // Check if the relationship exists if ($networkRelation) { diff --git a/server/tests/Unit/Http/Controllers/NetworkApiControllerContractsTest.php b/server/tests/Unit/Http/Controllers/NetworkApiControllerContractsTest.php index 60591b7d..c147c9c4 100644 --- a/server/tests/Unit/Http/Controllers/NetworkApiControllerContractsTest.php +++ b/server/tests/Unit/Http/Controllers/NetworkApiControllerContractsTest.php @@ -613,3 +613,78 @@ function createNetworkApiControllerSchema(): void ->and($missingLocations->getStatusCode())->toBe(400) ->and($missingTags->getStatusCode())->toBe(400); }); + +test('network stores endpoint ignores distance filters without a usable location', function () { + createNetworkApiControllerSchema(); + $connection = Model::getConnectionResolver()->connection('mysql'); + $connection->table('networks')->insert(['uuid' => 'network_uuid']); + $connection->table('stores')->insert([ + ['uuid' => 'store_one_uuid', 'public_id' => 'store_one', 'company_uuid' => 'company_uuid', 'name' => 'One', 'created_at' => '2026-01-01 00:00:00'], + ['uuid' => 'store_two_uuid', 'public_id' => 'store_two', 'company_uuid' => 'company_uuid', 'name' => 'Two', 'created_at' => '2026-02-01 00:00:00'], + ]); + $connection->table('network_stores')->insert([ + ['network_uuid' => 'network_uuid', 'store_uuid' => 'store_one_uuid'], + ['network_uuid' => 'network_uuid', 'store_uuid' => 'store_two_uuid'], + ]); + $connection->table('store_locations')->insert([ + ['uuid' => 'location_one_uuid', 'store_uuid' => 'store_one_uuid'], + ['uuid' => 'location_two_uuid', 'store_uuid' => 'store_two_uuid'], + ]); + session([ + 'company' => 'company_uuid', + 'storefront_store' => null, + 'storefront_network' => 'network_uuid', + ]); + $controller = new NetworkController(); + + // Without a location there is nothing to measure from, so the distance filter no longer + // removes every store and limit/offset stay in SQL. + $withoutLocation = $controller->stores(Request::create('/network/stores', 'GET', [ + 'sort' => 'oldest', + 'maximum_distance' => 1000, + 'limit' => 1, + 'offset' => 1, + ])); + // The coordinate parser's (0, 0) fallback is treated the same as no location. + $nullIsland = $controller->stores(Request::create('/network/stores', 'GET', [ + 'sort' => 'nearest', + 'location' => 'not-a-location', + ])); + + expect($withoutLocation->resource->pluck('uuid')->all())->toBe(['store_two_uuid']) + ->and($nullIsland->resource->pluck('uuid')->sort()->values()->all())->toBe(['store_one_uuid', 'store_two_uuid']) + ->and($nullIsland->resource->first()->distance)->toBeNull(); +}); + +test('network stores endpoint preloads each store rating in the store query', function () { + createNetworkApiControllerSchema(); + $connection = Model::getConnectionResolver()->connection('mysql'); + $connection->table('networks')->insert(['uuid' => 'network_uuid']); + $connection->table('stores')->insert([ + ['uuid' => 'store_rated_uuid', 'public_id' => 'store_rated', 'company_uuid' => 'company_uuid', 'name' => 'Rated'], + ['uuid' => 'store_new_uuid', 'public_id' => 'store_new', 'company_uuid' => 'company_uuid', 'name' => 'New'], + ]); + $connection->table('network_stores')->insert([ + ['network_uuid' => 'network_uuid', 'store_uuid' => 'store_rated_uuid'], + ['network_uuid' => 'network_uuid', 'store_uuid' => 'store_new_uuid'], + ]); + $connection->table('store_locations')->insert([ + ['uuid' => 'location_rated_uuid', 'store_uuid' => 'store_rated_uuid'], + ['uuid' => 'location_new_uuid', 'store_uuid' => 'store_new_uuid'], + ]); + $connection->table('reviews')->insert([ + ['uuid' => 'review_a', 'subject_uuid' => 'store_rated_uuid', 'rating' => 5], + ['uuid' => 'review_b', 'subject_uuid' => 'store_rated_uuid', 'rating' => 4], + ]); + session([ + 'company' => 'company_uuid', + 'storefront_store' => null, + 'storefront_network' => 'network_uuid', + ]); + + $stores = (new NetworkController())->stores(Request::create('/network/stores', 'GET'))->resource->keyBy('uuid'); + + expect(array_key_exists('reviews_avg_rating', $stores['store_rated_uuid']->getAttributes()))->toBeTrue() + ->and($stores['store_rated_uuid']->rating)->toBe(4.5) + ->and($stores['store_new_uuid']->rating)->toBe(0); +}); diff --git a/server/tests/Unit/Http/Controllers/StoreQueryContractsTest.php b/server/tests/Unit/Http/Controllers/StoreQueryContractsTest.php new file mode 100644 index 00000000..92da6136 --- /dev/null +++ b/server/tests/Unit/Http/Controllers/StoreQueryContractsTest.php @@ -0,0 +1,22 @@ +onQueryRecord($plain, Request::create('/int/v1/stores', 'GET')); + + $withCategory = Store::query(); + $controller->onQueryRecord($withCategory, Request::create('/int/v1/stores', 'GET', [ + 'network' => 'network_uuid', + 'with_category' => 1, + ])); + + expect(array_keys($plain->getEagerLoads()))->toBe(['logo', 'backdrop']) + ->and($plain->toSql())->toContain('reviews_avg_rating') + ->and(array_keys($withCategory->getEagerLoads()))->toBe(['logo', 'backdrop', 'networks']); +}); diff --git a/server/tests/Unit/Models/StoreBehaviorTest.php b/server/tests/Unit/Models/StoreBehaviorTest.php index b8e473a8..d329f1b9 100644 --- a/server/tests/Unit/Models/StoreBehaviorTest.php +++ b/server/tests/Unit/Models/StoreBehaviorTest.php @@ -423,3 +423,65 @@ function createStoreBehaviorSchema(): void expect(fn () => $missing->getOrderConfig()) ->toThrow(RuntimeException::class, 'No default OrderConfig is configured.'); }); + +test('store rating uses a preloaded review average without querying reviews', function () { + // No reviews table: any aggregate query would throw. + Model::getConnectionResolver()->connection('mysql')->getSchemaBuilder()->dropIfExists('reviews'); + + $decimal = new Store(); + $decimal->setRawAttributes(['uuid' => 'store_uuid', 'reviews_avg_rating' => '4.5000']); + $whole = new Store(); + $whole->setRawAttributes(['uuid' => 'store_uuid', 'reviews_avg_rating' => 4]); + $unrated = new Store(); + $unrated->setRawAttributes(['uuid' => 'store_uuid', 'reviews_avg_rating' => null]); + + expect($decimal->rating)->toBe(4.5) + ->and($whole->rating)->toBe(4) + ->and($unrated->rating)->toBe(0); +}); + +test('store network category uses eager loaded networks', function () { + createStoreBehaviorSchema(); + $connection = Capsule::connection('mysql'); + $connection->table('categories')->insert(['uuid' => 'category_uuid', 'name' => 'Electronics']); + + $member = new Network(); + $member->forceFill(['uuid' => 'network_uuid']); + $member->setRelation('pivot', (object) ['category_uuid' => 'category_uuid']); + $other = new Network(); + $other->forceFill(['uuid' => 'other_network_uuid']); + + $store = new Store(); + $store->forceFill(['uuid' => 'store_uuid']); + $store->setRelation('networks', new Illuminate\Database\Eloquent\Collection([$member])); + + // network_stores is empty, so a match can only come from the loaded relation. + expect($store->getNetworkCategory($member)?->uuid)->toBe('category_uuid') + ->and($store->getNetworkCategory($other))->toBeNull(); +}); + +test('store resolves each network id once', function () { + createStoreBehaviorSchema(); + Store::flushResolvedNetworkIds(); + $connection = Capsule::connection('mysql'); + $connection->table('stores')->insert(['uuid' => 'store_uuid', 'name' => 'Store']); + $connection->table('networks')->insert(['uuid' => 'network_uuid', 'public_id' => 'network_public', 'name' => 'Network']); + $connection->table('categories')->insert(['uuid' => 'category_uuid', 'name' => 'Flowers']); + $connection->table('network_stores')->insert([ + 'network_uuid' => 'network_uuid', + 'store_uuid' => 'store_uuid', + 'category_uuid' => 'category_uuid', + ]); + $store = Store::where('uuid', 'store_uuid')->firstOrFail(); + + expect($store->getNetworkCategoryUsingId('network_public')?->uuid)->toBe('category_uuid'); + + // The id is not looked up again: it still resolves after the public id changes. + $connection->table('networks')->update(['public_id' => 'network_renamed']); + expect($store->getNetworkCategoryUsingId('network_public')?->uuid)->toBe('category_uuid'); + + Store::flushResolvedNetworkIds(); + expect($store->getNetworkCategoryUsingId('network_public'))->toBeNull(); + + Store::flushResolvedNetworkIds(); +}); From 6b02d96cde298477a07bf4760999a5bd3938b0b4 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 6 Oct 2026 19:48:17 +0800 Subject: [PATCH 004/193] feat(reviews): verified-purchase reviews, eligibility endpoint and own-review delete - Customers can review a store or product only for a completed order placed with that store or containing that product, once per order per subject. Reviews record the order (new reviews.order_uuid). - GET reviews/eligibility tells the app whether the signed-in customer can review a subject, and why not. - DELETE reviews/{id} now routes to delete() instead of find(), so customers can remove their own reviews. - Ratings must be whole stars from 1 to 5; content is limited to 2000 characters and uploads to four image or video files. - The review resource names the subject by public id instead of the internal row id, and adds subject_type, verified and is_mine. --- ...000000_add_order_uuid_to_reviews_table.php | 41 +++ .../Http/Controllers/v1/ReviewController.php | 30 ++ .../src/Http/Requests/CreateReviewRequest.php | 13 +- server/src/Http/Resources/Review.php | 44 ++- server/src/Models/Review.php | 14 +- server/src/Support/ReviewEligibility.php | 136 +++++++++ server/src/routes.php | 3 +- .../ReviewAndOrderControllerContractsTest.php | 56 +++- .../Http/Requests/RequestContractsTest.php | 15 +- .../Resources/SimpleResourceContractsTest.php | 22 +- .../Unit/Routes/StorefrontRoutesTest.php | 4 +- .../Unit/Support/ReviewEligibilityTest.php | 289 ++++++++++++++++++ 12 files changed, 633 insertions(+), 34 deletions(-) create mode 100644 server/migrations/2026_10_07_000000_add_order_uuid_to_reviews_table.php create mode 100644 server/src/Support/ReviewEligibility.php create mode 100644 server/tests/Unit/Support/ReviewEligibilityTest.php diff --git a/server/migrations/2026_10_07_000000_add_order_uuid_to_reviews_table.php b/server/migrations/2026_10_07_000000_add_order_uuid_to_reviews_table.php new file mode 100644 index 00000000..6306a00f --- /dev/null +++ b/server/migrations/2026_10_07_000000_add_order_uuid_to_reviews_table.php @@ -0,0 +1,41 @@ +hasColumn('reviews', 'order_uuid')) { + return; + } + + Schema::connection($connection)->table('reviews', function (Blueprint $table) { + $table->char('order_uuid', 36)->nullable()->after('customer_uuid'); + $table->index(['customer_uuid', 'subject_uuid', 'order_uuid'], 'reviews_customer_subject_order_index'); + }); + } + + public function down(): void + { + $connection = config('storefront.connection.db'); + + if (!Schema::connection($connection)->hasColumn('reviews', 'order_uuid')) { + return; + } + + Schema::connection($connection)->table('reviews', function (Blueprint $table) { + $table->dropIndex('reviews_customer_subject_order_index'); + $table->dropColumn('order_uuid'); + }); + } +}; diff --git a/server/src/Http/Controllers/v1/ReviewController.php b/server/src/Http/Controllers/v1/ReviewController.php index dd51ba78..8c242db8 100644 --- a/server/src/Http/Controllers/v1/ReviewController.php +++ b/server/src/Http/Controllers/v1/ReviewController.php @@ -11,6 +11,7 @@ use Fleetbase\Storefront\Models\Product; use Fleetbase\Storefront\Models\Review; use Fleetbase\Storefront\Models\Store; +use Fleetbase\Storefront\Support\ReviewEligibility; use Fleetbase\Storefront\Support\Storefront; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\Request; @@ -216,6 +217,29 @@ public function find($id) return new StorefrontReview($review); } + /** + * Whether the signed-in customer can review a store or product, and why not. + * + * The app calls this before showing "Write a review", so a customer who has not completed + * an order, or has already reviewed every completed one, sees an explanation instead. + */ + public function eligibility(Request $request) + { + $subjectId = $request->input('subject'); + if (!is_string($subjectId) || $subjectId === '') { + return response()->error('A subject is required.'); + } + + $subject = Utils::resolveSubject($subjectId); + if (!$subject || !$this->subjectBelongsToContext($subject)) { + return response()->error('Invalid subject for review'); + } + + $eligibility = ReviewEligibility::check(Storefront::getCustomerFromToken(), $subject, $request->input('order')); + + return response()->json($eligibility->toArray()); + } + /** * Create a review. * @@ -238,9 +262,15 @@ public function create(CreateReviewRequest $request) return response()->error('Invalid subject for review'); } + $eligibility = ReviewEligibility::check($customer, $subject, $request->input('order')); + if (!$eligibility->allowed) { + return response()->json(['error' => $eligibility->message(), 'reason' => $eligibility->reason], 403); + } + $review = Review::create([ 'created_by_uuid' => $customer->user_uuid, 'customer_uuid' => $customer->uuid, + 'order_uuid' => $eligibility->order->uuid, 'subject_uuid' => $subject->uuid, 'subject_type' => Utils::getMutationType($subject), 'rating' => $request->input('rating'), diff --git a/server/src/Http/Requests/CreateReviewRequest.php b/server/src/Http/Requests/CreateReviewRequest.php index 470ab7bf..596a09c7 100644 --- a/server/src/Http/Requests/CreateReviewRequest.php +++ b/server/src/Http/Requests/CreateReviewRequest.php @@ -31,11 +31,14 @@ public function rules() // — as a 500, before the controller's own "Invalid subject for review" guard // could run. That guard was unreachable for the commonest way to get it wrong. return [ - 'subject' => 'required|string', - 'rating' => 'required|numeric', - 'content' => 'required', - 'files' => 'sometimes|array', - 'rejected' => 'sometimes|boolean', + 'subject' => 'required|string', + 'order' => 'sometimes|nullable|string', + 'rating' => 'required|integer|between:1,5', + 'content' => 'required|string|max:2000', + 'files' => 'sometimes|array|max:4', + 'files.*.data' => 'required_with:files|string', + 'files.*.type' => 'required_with:files|string|starts_with:image/,video/', + 'rejected' => 'sometimes|boolean', ]; } } diff --git a/server/src/Http/Resources/Review.php b/server/src/Http/Resources/Review.php index 240b212b..734e50b6 100644 --- a/server/src/Http/Resources/Review.php +++ b/server/src/Http/Resources/Review.php @@ -3,6 +3,8 @@ namespace Fleetbase\Storefront\Http\Resources; use Fleetbase\Http\Resources\FleetbaseResource; +use Fleetbase\Storefront\Models\Product; +use Fleetbase\Storefront\Support\Storefront; use Fleetbase\Support\Http; class Review extends FleetbaseResource @@ -17,17 +19,21 @@ class Review extends FleetbaseResource public function toArray($request) { return [ - 'id' => $this->when(Http::isInternalRequest(), $this->id, $this->public_id), - 'uuid' => $this->when(Http::isInternalRequest(), $this->uuid), - 'public_id' => $this->when(Http::isInternalRequest(), $this->public_id), - 'subject_id' => $this->subject->id, - 'rating' => $this->rating, - 'content' => $this->content, - 'customer' => new ReviewCustomer($this->customer), - 'slug' => $this->slug, - 'photos' => $this->mapPhotos($this->photos), - 'created_at' => $this->created_at, - 'updated_at' => $this->updated_at, + 'id' => $this->when(Http::isInternalRequest(), $this->id, $this->public_id), + 'uuid' => $this->when(Http::isInternalRequest(), $this->uuid), + 'public_id' => $this->when(Http::isInternalRequest(), $this->public_id), + 'subject_id' => $this->when(Http::isInternalRequest(), $this->subject?->id, $this->subject?->public_id), + 'subject_type' => $this->subject instanceof Product ? 'product' : 'store', + 'order_uuid' => $this->when(Http::isInternalRequest(), $this->order_uuid), + 'verified' => !empty($this->order_uuid), + 'is_mine' => $this->customer_uuid !== null && $this->customer_uuid === static::viewerCustomerUuid(), + 'rating' => $this->rating, + 'content' => $this->content, + 'customer' => new ReviewCustomer($this->customer), + 'slug' => $this->slug, + 'photos' => $this->mapPhotos($this->photos), + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, ]; } @@ -43,4 +49,20 @@ public function mapPhotos($photos = []) ]; }); } + + /** + * The uuid of the customer making the request, looked up once per request so that a + * page of reviews does not resolve the customer token for every review. + */ + public static function viewerCustomerUuid(): ?string + { + $request = request(); + $key = 'storefront.review_viewer_uuid'; + + if (!$request->attributes->has($key)) { + $request->attributes->set($key, Http::isInternalRequest() ? null : Storefront::getCustomerFromToken()?->uuid); + } + + return $request->attributes->get($key); + } } diff --git a/server/src/Models/Review.php b/server/src/Models/Review.php index 178efbd9..7ec470cd 100644 --- a/server/src/Models/Review.php +++ b/server/src/Models/Review.php @@ -2,6 +2,7 @@ namespace Fleetbase\Storefront\Models; +use Fleetbase\FleetOps\Models\Order; use Fleetbase\Models\File; use Fleetbase\Models\User; use Fleetbase\Traits\HasApiModelBehavior; @@ -45,6 +46,7 @@ class Review extends StorefrontModel protected $fillable = [ 'created_by_uuid', 'customer_uuid', + 'order_uuid', 'subject_uuid', 'subject_type', 'rating', @@ -57,7 +59,9 @@ class Review extends StorefrontModel * * @var array */ - protected $casts = []; + protected $casts = [ + 'rating' => 'integer', + ]; /** * Dynamic attributes that are appended to object. @@ -89,6 +93,14 @@ public function customer() return $this->setConnection(config('fleetbase.connection.db'))->belongsTo(Customer::class); } + /** + * The completed order this review was written for. + */ + public function order() + { + return $this->belongsTo(Order::class, 'order_uuid', 'uuid'); + } + /** * @return \Illuminate\Database\Eloquent\Relations\HasMany */ diff --git a/server/src/Support/ReviewEligibility.php b/server/src/Support/ReviewEligibility.php new file mode 100644 index 00000000..7c7fd966 --- /dev/null +++ b/server/src/Support/ReviewEligibility.php @@ -0,0 +1,136 @@ + + */ + public const MESSAGES = [ + self::SIGN_IN_REQUIRED => 'Sign in to write a review.', + self::UNSUPPORTED_SUBJECT => 'Only stores and products can be reviewed.', + self::NO_COMPLETED_ORDER => 'You can review this after an order with it is completed.', + self::ALREADY_REVIEWED => 'You have already reviewed this for your completed orders.', + ]; + + public function __construct( + public bool $allowed, + public ?string $reason = null, + public ?Order $order = null, + public ?Review $review = null, + ) { + } + + /** + * Check whether the customer can review the subject, optionally for one specific order. + * + * When no order is given, the most recent completed order that has not been reviewed for + * this subject is used. + */ + public static function check(?Contact $customer, mixed $subject, ?string $orderId = null): self + { + if (!$customer) { + return new self(false, self::SIGN_IN_REQUIRED); + } + + $query = static::completedOrdersFor($customer, $subject); + if (!$query) { + return new self(false, self::UNSUPPORTED_SUBJECT); + } + + if ($orderId) { + $query->where(fn ($orderQuery) => $orderQuery->where('public_id', $orderId)->orWhere('uuid', $orderId)); + } + + $orders = $query->orderByDesc('created_at')->limit(static::ORDER_LOOKBACK)->get(); + if ($orders->isEmpty()) { + return new self(false, self::NO_COMPLETED_ORDER); + } + + $reviews = Review::where('customer_uuid', $customer->uuid) + ->where('subject_uuid', $subject->uuid) + ->whereIn('order_uuid', $orders->pluck('uuid')) + ->get() + ->keyBy('order_uuid'); + + $unreviewed = $orders->first(fn ($order) => !$reviews->has($order->uuid)); + if (!$unreviewed) { + $latest = $orders->first(); + + return new self(false, self::ALREADY_REVIEWED, $latest, $reviews->get($latest->uuid)); + } + + return new self(true, null, $unreviewed); + } + + /** + * The customer's completed orders that include the subject, or null when the subject + * cannot be reviewed. + */ + public static function completedOrdersFor(Contact $customer, mixed $subject): ?Builder + { + $query = Order::query() + ->where('customer_uuid', $customer->uuid) + ->whereIn('status', static::COMPLETED_STATUSES); + + if ($subject instanceof Store) { + return $query->where('meta->storefront_id', $subject->public_id); + } + + if ($subject instanceof Product) { + return $query->whereIn( + 'payload_uuid', + Entity::query()->select('payload_uuid')->where('internal_id', $subject->public_id)->whereNotNull('payload_uuid') + ); + } + + return null; + } + + public function message(): ?string + { + return $this->reason ? static::MESSAGES[$this->reason] : null; + } + + /** + * @return array{can_review: bool, reason: ?string, message: ?string, order: ?string, review: ?string} + */ + public function toArray(): array + { + return [ + 'can_review' => $this->allowed, + 'reason' => $this->reason, + 'message' => $this->message(), + 'order' => $this->order?->public_id, + 'review' => $this->review?->public_id, + ]; + } +} diff --git a/server/src/routes.php b/server/src/routes.php index 7099a53f..65685ecd 100644 --- a/server/src/routes.php +++ b/server/src/routes.php @@ -88,9 +88,10 @@ function ($router) { $router->group(['prefix' => 'reviews'], function () use ($router) { $router->get('/', 'ReviewController@query'); $router->get('count', 'ReviewController@count'); + $router->get('eligibility', 'ReviewController@eligibility'); $router->get('{id}', 'ReviewController@find'); $router->post('/', 'ReviewController@create'); - $router->delete('{id}', 'ReviewController@find'); + $router->delete('{id}', 'ReviewController@delete'); }); // storefront/v1/orders diff --git a/server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php b/server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php index 14c86195..d823acaf 100644 --- a/server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php +++ b/server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php @@ -281,6 +281,33 @@ public function unassignCurrentOrder(): void } } +function createReviewOrderSchema(): void +{ + $schema = Model::getConnectionResolver()->connection('mysql')->getSchemaBuilder(); + $schema->dropIfExists('orders'); + $schema->dropIfExists('entities'); + $schema->create('orders', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('customer_uuid')->nullable(); + $table->string('payload_uuid')->nullable(); + $table->string('status')->nullable(); + $table->text('meta')->nullable(); + $table->timestamp('created_at')->nullable(); + $table->timestamp('updated_at')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('entities', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('payload_uuid')->nullable(); + $table->string('internal_id')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); +} + function createReviewControllerSchema(): void { $schema = Model::getConnectionResolver()->connection('mysql')->getSchemaBuilder(); @@ -292,6 +319,7 @@ function createReviewControllerSchema(): void $table->string('public_id')->nullable(); $table->string('created_by_uuid')->nullable(); $table->string('customer_uuid')->nullable(); + $table->string('order_uuid')->nullable(); $table->string('subject_uuid')->nullable(); $table->string('subject_type')->nullable(); $table->integer('rating')->nullable(); @@ -637,9 +665,10 @@ function createReviewControllerSchema(): void createReviewControllerSchema(); $connection = Model::getConnectionResolver()->connection('mysql'); $schema = $connection->getSchemaBuilder(); - foreach (['personal_access_tokens', 'files', 'contacts', 'stores'] as $table) { + foreach (['personal_access_tokens', 'files', 'contacts', 'stores', 'orders', 'entities'] as $table) { $schema->dropIfExists($table); } + createReviewOrderSchema(); $schema->create('personal_access_tokens', function ($table) { $table->increments('id'); $table->string('tokenable_type')->nullable(); @@ -728,6 +757,14 @@ function createReviewControllerSchema(): void ['uuid' => 'product_uuid', 'public_id' => 'product_abcdefgh', 'store_uuid' => 'store_uuid'], ['uuid' => 'foreign_product_uuid', 'public_id' => 'product_foreign', 'store_uuid' => 'foreign_store_uuid'], ]); + // Two completed orders from the store and one containing the product: each order can be + // reviewed once per subject, newest first. + $connection->table('orders')->insert([ + ['uuid' => 'older_store_order_uuid', 'public_id' => 'order_older', 'customer_uuid' => $customerUuid, 'status' => 'completed', 'payload_uuid' => null, 'meta' => json_encode(['storefront_id' => 'store_abcdefgh']), 'created_at' => now()->subDays(3)], + ['uuid' => 'newer_store_order_uuid', 'public_id' => 'order_newer', 'customer_uuid' => $customerUuid, 'status' => 'completed', 'payload_uuid' => null, 'meta' => json_encode(['storefront_id' => 'store_abcdefgh']), 'created_at' => now()->subDay()], + ['uuid' => 'product_order_uuid', 'public_id' => 'order_product', 'customer_uuid' => $customerUuid, 'status' => 'completed', 'payload_uuid' => 'payload_uuid', 'meta' => json_encode(['storefront_id' => 'other_store']), 'created_at' => now()->subDays(2)], + ]); + $connection->table('entities')->insert(['uuid' => 'entity_uuid', 'payload_uuid' => 'payload_uuid', 'internal_id' => 'product_abcdefgh']); $boundRequest = Request::create('/reviews'); $boundRequest->headers->set('Customer-Token', 'review-customer-secret'); $boundRequest->setLaravelSession(new Illuminate\Session\Store( @@ -801,7 +838,14 @@ public function put(string $path, string $contents, string $visibility): bool ], ]) ); - $photo = $connection->table('files')->first(); + $photo = $connection->table('files')->first(); + $alreadyReviewed = $controller->create( + Fleetbase\Storefront\Http\Requests\CreateReviewRequest::create('/reviews', 'POST', [ + 'subject' => 'store_abcdefgh', + 'rating' => 3, + 'content' => 'Third review', + ]) + ); session(['storefront_store' => 'store_uuid', 'storefront_network' => null]); $deleted = $controller->delete('review_owned'); @@ -814,6 +858,14 @@ public function put(string $path, string $contents, string $visibility): bool ->and($review->subject_uuid)->toBe('store_uuid') ->and($review->rating)->toBe(5) ->and($review->content)->toBe('Excellent service') + ->and($review->order_uuid)->toBe('newer_store_order_uuid') + ->and($createdProduct->resource->order_uuid)->toBe('product_order_uuid') + ->and($withPhoto->resource->order_uuid)->toBe('older_store_order_uuid') + ->and($alreadyReviewed->getStatusCode())->toBe(403) + ->and($alreadyReviewed->getData(true))->toBe([ + 'error' => 'You have already reviewed this for your completed orders.', + 'reason' => 'already_reviewed', + ]) ->and($withPhoto->resource->files)->toHaveCount(1) ->and($photo->subject_uuid)->toBe($withPhoto->resource->uuid) ->and($photo->content_type)->toBe('image/png') diff --git a/server/tests/Unit/Http/Requests/RequestContractsTest.php b/server/tests/Unit/Http/Requests/RequestContractsTest.php index d8840dd9..c9ad3752 100644 --- a/server/tests/Unit/Http/Requests/RequestContractsTest.php +++ b/server/tests/Unit/Http/Requests/RequestContractsTest.php @@ -152,12 +152,17 @@ // `subject` is required: the controller resolves it with Utils::resolveSubject(), // whose parameter is a non-nullable string, so omitting it threw a TypeError as a // 500 before the controller's own "Invalid subject for review" guard could run. + // Ratings are whole stars from 1 to 5, and a review carries at most four photos or + // videos. `order` optionally names the completed order being reviewed. ->and($reviewRules)->toBe([ - 'subject' => 'required|string', - 'rating' => 'required|numeric', - 'content' => 'required', - 'files' => 'sometimes|array', - 'rejected' => 'sometimes|boolean', + 'subject' => 'required|string', + 'order' => 'sometimes|nullable|string', + 'rating' => 'required|integer|between:1,5', + 'content' => 'required|string|max:2000', + 'files' => 'sometimes|array|max:4', + 'files.*.data' => 'required_with:files|string', + 'files.*.type' => 'required_with:files|string|starts_with:image/,video/', + 'rejected' => 'sometimes|boolean', ])->and($verificationRules)->toBe([ 'mode' => 'required|in:email,sms', 'identity' => 'required', diff --git a/server/tests/Unit/Http/Resources/SimpleResourceContractsTest.php b/server/tests/Unit/Http/Resources/SimpleResourceContractsTest.php index 3d55ff3a..749f9cf2 100644 --- a/server/tests/Unit/Http/Resources/SimpleResourceContractsTest.php +++ b/server/tests/Unit/Http/Resources/SimpleResourceContractsTest.php @@ -158,27 +158,33 @@ public function uri(): string 'name' => 'Ada Lovelace', ]); $review = storefrontResourceModel([ - 'id' => 4, - 'uuid' => 'review_uuid', - 'public_id' => 'review_123', + 'id' => 4, + 'uuid' => 'review_uuid', + 'public_id' => 'review_123', + 'customer_uuid' => 'customer_uuid', + 'order_uuid' => 'order_uuid', 'rating' => 5, 'content' => 'Excellent service', 'slug' => 'excellent-service', 'created_at' => '2026-01-01', 'updated_at' => '2026-01-02', ], [ - 'subject' => storefrontResourceModel(['id' => 99]), + 'subject' => storefrontResourceModel(['id' => 99, 'public_id' => 'store_123']), 'customer' => $customer, 'photos' => collect([$photo]), ]); $reviewData = (new ReviewResource($review))->toArray(setSimpleStorefrontResourceRoute('v1/storefront/reviews')); + // Public responses name the subject by its public id, never the internal row id. expect($reviewData)->toMatchArray([ - 'id' => 'review_123', - 'subject_id' => 99, - 'rating' => 5, - 'content' => 'Excellent service', + 'id' => 'review_123', + 'subject_id' => 'store_123', + 'subject_type' => 'store', + 'verified' => true, + 'is_mine' => false, + 'rating' => 5, + 'content' => 'Excellent service', ])->and($reviewData['photos'][0])->toBe([ 'id' => 'file_123', 'filename' => 'receipt.jpg', diff --git a/server/tests/Unit/Routes/StorefrontRoutesTest.php b/server/tests/Unit/Routes/StorefrontRoutesTest.php index 8b8f4452..46cc19cf 100644 --- a/server/tests/Unit/Routes/StorefrontRoutesTest.php +++ b/server/tests/Unit/Routes/StorefrontRoutesTest.php @@ -96,5 +96,7 @@ private function record(string $method, string $uri, mixed $action): self ['FLEETBASE', 'orders', null], ['FLEETBASE', 'products', null], ['GET', '/', 'MetricsController@all'], - )->and(count($router->routes))->toBeGreaterThan(50); + ['GET', 'eligibility', 'ReviewController@eligibility'], + ['DELETE', '{id}', 'ReviewController@delete'], + )->and($router->routes)->not->toContain(['DELETE', '{id}', 'ReviewController@find'])->and(count($router->routes))->toBeGreaterThan(50); }); diff --git a/server/tests/Unit/Support/ReviewEligibilityTest.php b/server/tests/Unit/Support/ReviewEligibilityTest.php new file mode 100644 index 00000000..7f365af4 --- /dev/null +++ b/server/tests/Unit/Support/ReviewEligibilityTest.php @@ -0,0 +1,289 @@ +connection('mysql')->getSchemaBuilder(); + foreach (['reviews', 'orders', 'entities', 'products', 'stores', 'contacts', 'personal_access_tokens'] as $table) { + $schema->dropIfExists($table); + } + $schema->create('reviews', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('created_by_uuid')->nullable(); + $table->string('customer_uuid')->nullable(); + $table->string('order_uuid')->nullable(); + $table->string('subject_uuid')->nullable(); + $table->string('subject_type')->nullable(); + $table->integer('rating')->nullable(); + $table->text('content')->nullable(); + $table->boolean('rejected')->default(false); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('orders', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('customer_uuid')->nullable(); + $table->string('payload_uuid')->nullable(); + $table->string('status')->nullable(); + $table->text('meta')->nullable(); + $table->timestamp('created_at')->nullable(); + $table->timestamp('updated_at')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('entities', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('payload_uuid')->nullable(); + $table->string('internal_id')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('products', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('store_uuid')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('stores', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('name')->nullable(); + $table->text('options')->nullable(); + $table->text('translations')->nullable(); + $table->text('tags')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('contacts', function ($table) { + $table->increments('id'); + $table->string('uuid'); + $table->string('public_id')->nullable(); + $table->string('user_uuid')->nullable(); + $table->string('type')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('personal_access_tokens', function ($table) { + $table->increments('id'); + $table->string('tokenable_type')->nullable(); + $table->integer('tokenable_id')->nullable(); + $table->string('name'); + $table->string('token'); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamps(); + }); + + $connection = Model::getConnectionResolver()->connection('mysql'); + $connection->table('stores')->insert(['uuid' => 'store_uuid', 'public_id' => 'store_abcdefgh', 'company_uuid' => 'company_uuid', 'name' => 'Bloom']); + $connection->table('products')->insert(['uuid' => 'product_uuid', 'public_id' => 'product_abcdefgh', 'store_uuid' => 'store_uuid']); + $connection->table('contacts')->insert(['uuid' => ELIGIBILITY_CUSTOMER_UUID, 'public_id' => 'contact_eligible', 'user_uuid' => 'user_uuid', 'type' => 'customer']); + $connection->table('orders')->insert([ + // Completed orders for the store, newest last so ordering is exercised. + ['uuid' => 'store_order_old', 'public_id' => 'order_old', 'customer_uuid' => ELIGIBILITY_CUSTOMER_UUID, 'status' => 'completed', 'payload_uuid' => 'payload_old', 'meta' => json_encode(['storefront_id' => 'store_abcdefgh']), 'created_at' => now()->subDays(5)], + ['uuid' => 'store_order_new', 'public_id' => 'order_new', 'customer_uuid' => ELIGIBILITY_CUSTOMER_UUID, 'status' => 'completed', 'payload_uuid' => 'payload_new', 'meta' => json_encode(['storefront_id' => 'store_abcdefgh']), 'created_at' => now()->subDay()], + // Not eligible: still in progress, another store, another customer. + ['uuid' => 'store_order_active', 'public_id' => 'order_active', 'customer_uuid' => ELIGIBILITY_CUSTOMER_UUID, 'status' => 'dispatched', 'payload_uuid' => 'payload_active', 'meta' => json_encode(['storefront_id' => 'store_abcdefgh']), 'created_at' => now()], + ['uuid' => 'other_store_order', 'public_id' => 'order_other_store', 'customer_uuid' => ELIGIBILITY_CUSTOMER_UUID, 'status' => 'completed', 'payload_uuid' => null, 'meta' => json_encode(['storefront_id' => 'store_other']), 'created_at' => now()], + ['uuid' => 'other_customer_order', 'public_id' => 'order_other_customer', 'customer_uuid' => 'someone_else', 'status' => 'completed', 'payload_uuid' => null, 'meta' => json_encode(['storefront_id' => 'store_abcdefgh']), 'created_at' => now()], + ]); + // Only the newest store order contains the product. + $connection->table('entities')->insert([ + ['uuid' => 'entity_new', 'payload_uuid' => 'payload_new', 'internal_id' => 'product_abcdefgh'], + ['uuid' => 'entity_active', 'payload_uuid' => 'payload_active', 'internal_id' => 'product_abcdefgh'], + ['uuid' => 'entity_orphan', 'payload_uuid' => null, 'internal_id' => 'product_abcdefgh'], + ]); +} + +function eligibilityCustomer(): Contact +{ + return Contact::where('uuid', ELIGIBILITY_CUSTOMER_UUID)->first(); +} + +function bindEligibilityRequest(?string $customerToken = null, array $query = []): Request +{ + $request = Request::create('/reviews/eligibility', 'GET', $query); + if ($customerToken) { + $request->headers->set('Customer-Token', $customerToken); + } + $request->setLaravelSession(new Illuminate\Session\Store('review-eligibility', new Illuminate\Session\ArraySessionHandler(120))); + app()->instance('request', $request); + session(['company' => 'company_uuid', 'storefront_key' => 'store_key', 'storefront_store' => 'store_uuid', 'storefront_network' => null]); + + return $request; +} + +function issueEligibilityToken(): string +{ + Model::getConnectionResolver()->connection('mysql')->table('personal_access_tokens')->insert([ + 'name' => ELIGIBILITY_CUSTOMER_UUID, + 'token' => hash('sha256', 'eligibility-secret'), + 'abilities' => '["*"]', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + return 'eligibility-secret'; +} + +test('review eligibility requires a signed-in customer and a store or product subject', function () { + createReviewEligibilitySchema(); + $store = Store::where('uuid', 'store_uuid')->first(); + + $signedOut = ReviewEligibility::check(null, $store); + $unsupported = ReviewEligibility::check(eligibilityCustomer(), new stdClass()); + + expect($signedOut->allowed)->toBeFalse() + ->and($signedOut->toArray())->toBe([ + 'can_review' => false, + 'reason' => 'sign_in_required', + 'message' => 'Sign in to write a review.', + 'order' => null, + 'review' => null, + ]) + ->and($unsupported->reason)->toBe('unsupported_subject') + ->and($unsupported->message())->toBe('Only stores and products can be reviewed.') + ->and(ReviewEligibility::completedOrdersFor(eligibilityCustomer(), new stdClass()))->toBeNull(); +}); + +test('store reviews use the newest completed order from that store that has not been reviewed', function () { + createReviewEligibilitySchema(); + $store = Store::where('uuid', 'store_uuid')->first(); + $customer = eligibilityCustomer(); + + $first = ReviewEligibility::check($customer, $store); + Review::create(['customer_uuid' => $customer->uuid, 'subject_uuid' => $store->uuid, 'order_uuid' => 'store_order_new', 'rating' => 5, 'content' => 'Great']); + $second = ReviewEligibility::check($customer, $store); + Review::create(['customer_uuid' => $customer->uuid, 'subject_uuid' => $store->uuid, 'order_uuid' => 'store_order_old', 'rating' => 4, 'content' => 'Good']); + $done = ReviewEligibility::check($customer, $store); + + expect($first->allowed)->toBeTrue() + ->and($first->reason)->toBeNull() + ->and($first->message())->toBeNull() + ->and($first->order->uuid)->toBe('store_order_new') + ->and($second->order->uuid)->toBe('store_order_old') + ->and($done->allowed)->toBeFalse() + ->and($done->reason)->toBe('already_reviewed') + ->and($done->order->public_id)->toBe('order_new') + ->and($done->review->order_uuid)->toBe('store_order_new') + ->and($done->toArray()['order'])->toBe('order_new'); +}); + +test('product reviews need a completed order containing the product and can target one order', function () { + createReviewEligibilitySchema(); + $product = Product::where('uuid', 'product_uuid')->first(); + $store = Store::where('uuid', 'store_uuid')->first(); + $customer = eligibilityCustomer(); + + $product_ = ReviewEligibility::check($customer, $product); + $byPublic = ReviewEligibility::check($customer, $store, 'order_old'); + $byUuid = ReviewEligibility::check($customer, $store, 'store_order_old'); + $notMine = ReviewEligibility::check($customer, $store, 'order_other_customer'); + $missing = ReviewEligibility::check($customer, $product, 'order_old'); + + expect($product_->allowed)->toBeTrue() + ->and($product_->order->uuid)->toBe('store_order_new') + ->and($byPublic->order->uuid)->toBe('store_order_old') + ->and($byUuid->order->uuid)->toBe('store_order_old') + ->and($notMine->reason)->toBe('no_completed_order') + ->and($missing->allowed)->toBeFalse() + ->and($missing->message())->toBe('You can review this after an order with it is completed.'); +}); + +test('review eligibility endpoint validates the subject and reports the signed-in customer state', function () { + createReviewEligibilitySchema(); + $controller = new ReviewController(); + + bindEligibilityRequest(); + $missing = $controller->eligibility(request()); + bindEligibilityRequest(null, ['subject' => 'store_missing']); + $invalid = $controller->eligibility(request()); + bindEligibilityRequest(null, ['subject' => 'store_abcdefgh']); + $signedOut = $controller->eligibility(request()); + $token = issueEligibilityToken(); + bindEligibilityRequest($token, ['subject' => 'store_abcdefgh']); + $eligible = $controller->eligibility(request()); + bindEligibilityRequest($token, ['subject' => 'product_abcdefgh', 'order' => 'order_old']); + $wrongOrder = $controller->eligibility(request()); + + expect($missing->getData(true))->toBe(['error' => 'A subject is required.']) + ->and($invalid->getData(true))->toBe(['error' => 'Invalid subject for review']) + ->and($signedOut->getData(true)['reason'])->toBe('sign_in_required') + ->and($eligible->getData(true))->toBe([ + 'can_review' => true, + 'reason' => null, + 'message' => null, + 'order' => 'order_new', + 'review' => null, + ]) + ->and($wrongOrder->getData(true)['reason'])->toBe('no_completed_order'); +}); + +test('creating a review without a completed order is refused with the reason', function () { + createReviewEligibilitySchema(); + Model::getConnectionResolver()->connection('mysql')->table('orders')->update(['status' => 'canceled']); + bindEligibilityRequest(issueEligibilityToken()); + + $response = (new ReviewController())->create(CreateReviewRequest::create('/reviews', 'POST', [ + 'subject' => 'store_abcdefgh', + 'rating' => 5, + 'content' => 'Never received it', + ])); + + expect($response->getStatusCode())->toBe(403) + ->and($response->getData(true))->toBe([ + 'error' => 'You can review this after an order with it is completed.', + 'reason' => 'no_completed_order', + ]) + ->and(Review::query()->count())->toBe(0); +}); + +test('review resources mark the viewer own verified product reviews and keep internal ids internal', function () { + createReviewEligibilitySchema(); + $product = Product::where('uuid', 'product_uuid')->first(); + $mine = new Review(['customer_uuid' => ELIGIBILITY_CUSTOMER_UUID, 'subject_uuid' => 'product_uuid', 'rating' => 4, 'content' => 'Nice']); + $mine->setRelation('subject', $product)->setRelation('customer', null)->setRelation('photos', collect()); + $other = new Review(['customer_uuid' => null, 'order_uuid' => null, 'rating' => 2, 'content' => 'Meh']); + $other->setRelation('subject', null)->setRelation('customer', null)->setRelation('photos', collect()); + + bindEligibilityRequest(issueEligibilityToken()); + $publicMine = (new ReviewResource($mine))->toArray(request()); + $publicOther = (new ReviewResource($other))->toArray(request()); + // The viewer is resolved once per request. + Model::getConnectionResolver()->connection('mysql')->table('personal_access_tokens')->delete(); + $cached = ReviewResource::viewerCustomerUuid(); + + expect($publicMine['subject_id'])->toBe('product_abcdefgh') + ->and($publicMine['subject_type'])->toBe('product') + ->and($publicMine['verified'])->toBeFalse() + ->and($publicMine['is_mine'])->toBeTrue() + ->and($publicOther['subject_id'])->toBeNull() + ->and($publicOther['is_mine'])->toBeFalse() + ->and($cached)->toBe(ELIGIBILITY_CUSTOMER_UUID); +}); + +test('reviews belong to the completed order they were written for', function () { + $relation = (new Review())->order(); + + expect($relation)->toBeInstanceOf(Illuminate\Database\Eloquent\Relations\BelongsTo::class) + ->and($relation->getRelated())->toBeInstanceOf(Fleetbase\FleetOps\Models\Order::class) + ->and($relation->getForeignKeyName())->toBe('order_uuid') + ->and($relation->getOwnerKeyName())->toBe('uuid'); +}); From a406dd26411017f04aca6ae0a954b4b89cefbb4e Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 6 Oct 2026 19:53:48 +0800 Subject: [PATCH 005/193] feat(promotions): store attribution, shareable code and availability on public promotions - Public promotions name the store or network running them (owner: type, id, name, logo_url) and list applies_to targets by public id instead of uuid. - Code promotions show their code publicly when they have one reusable, unassigned, unexpired code; single-use batch codes are never exposed. - Every promotion reports availability (live, scheduled, ended) and, when scheduled, next_starts_at from its date range and weekly hours. - GET promotions?include=scheduled also lists promotions outside their weekly hours or before their start; GET promotions/{id} opens scheduled and ended promotions so links from notifications keep working. --- docs/promotions.md | 7 + .../Controllers/v1/PromotionController.php | 27 ++- server/src/Http/Resources/Promotion.php | 6 +- server/src/Models/Promotion.php | 155 ++++++++++++++++ .../Promotions/PublicPromotionDetailsTest.php | 171 ++++++++++++++++++ 5 files changed, 359 insertions(+), 7 deletions(-) create mode 100644 server/tests/Unit/Promotions/PublicPromotionDetailsTest.php diff --git a/docs/promotions.md b/docs/promotions.md index c2264460..738b8a17 100644 --- a/docs/promotions.md +++ b/docs/promotions.md @@ -16,6 +16,13 @@ specific code disables the batch count, prefix, and length fields because they d not apply to that operation. The code list shows loading and failure states, with a retry action if it cannot load. +The customer app shows the code of a public code promotion only when the promotion +has a reusable code that is active, not assigned to a customer and not expired +(for example one specific code entered with **Manage codes**). Batches of +single-use codes are never shown; send them to customers with a campaign instead. +Public promotions with weekly hours stay visible in the app outside those hours, +marked with when they next start. + ## Campaigns Enter an internal campaign name, notification title, and message. Choose an diff --git a/server/src/Http/Controllers/v1/PromotionController.php b/server/src/Http/Controllers/v1/PromotionController.php index d9d106f0..0b4ef1fe 100644 --- a/server/src/Http/Controllers/v1/PromotionController.php +++ b/server/src/Http/Controllers/v1/PromotionController.php @@ -18,25 +18,41 @@ class PromotionController extends Controller /** * Live, public promotions of the storefront, and in a network of its member stores. * - * Query params: `store` (a store public id, to only list that store's promotions in a network). + * Query params: + * - `store`: a store public id, to only list that store's promotions in a network. + * - `include=scheduled`: also list active promotions that are outside their weekly hours or + * have not started yet, so the app can show "starts again at 2 pm". Each promotion's + * `availability` says which it is. */ public function query(Request $request) { + $listed = $request->input('include') === 'scheduled' + ? [Promotion::AVAILABILITY_LIVE, Promotion::AVAILABILITY_SCHEDULED] + : [Promotion::AVAILABILITY_LIVE]; + $promotions = $this->publicPromotions($request->input('store')) + ->where('status', Promotion::STATUS_ACTIVE) ->orderByDesc('priority') ->orderBy('ends_at') ->get() - ->filter(fn (Promotion $promotion) => $promotion->isLiveAt()) + ->filter(fn (Promotion $promotion) => in_array($promotion->availabilityAt(), $listed, true)) ->values(); return PromotionResource::collection($promotions); } + /** + * One public promotion. Scheduled and ended promotions are returned too, so a link from a + * notification still opens and can say when the offer runs or that it is over. + */ public function find(string $id) { - $promotion = $this->publicPromotions()->where('public_id', $id)->first(); + $promotion = $this->publicPromotions() + ->whereIn('status', [Promotion::STATUS_ACTIVE, Promotion::STATUS_ENDED]) + ->where('public_id', $id) + ->first(); - if (!$promotion || !$promotion->isLiveAt()) { + if (!$promotion || $promotion->availabilityAt() === Promotion::AVAILABILITY_INACTIVE) { return response()->apiError('Promotion not found.', 404); } @@ -58,8 +74,7 @@ protected function publicPromotions(?string $storeId = null): Builder return Promotion::query() ->whereIn('owner_uuid', array_filter($owners)) - ->where('status', Promotion::STATUS_ACTIVE) ->where('is_public', true) - ->with('image'); + ->with(['image', 'owner', 'codes']); } } diff --git a/server/src/Http/Resources/Promotion.php b/server/src/Http/Resources/Promotion.php index e3d44d69..2fd8f71f 100644 --- a/server/src/Http/Resources/Promotion.php +++ b/server/src/Http/Resources/Promotion.php @@ -36,7 +36,11 @@ public function toArray($request) 'currency' => $this->currency, 'min_subtotal' => $this->min_subtotal, 'min_items' => $this->min_items, - 'applies_to' => $this->applies_to, + 'owner' => $this->when(!$internal, fn () => $this->ownerSummary()), + 'code' => $this->when(!$internal, fn () => $this->shareableCode()), + 'availability' => $this->availabilityAt(), + 'next_starts_at' => $this->nextLiveAt(), + 'applies_to' => $internal ? $this->applies_to : $this->publicAppliesTo(), 'bogo_config' => $this->bogo_config, 'first_order_only' => $this->first_order_only, 'usage_limit' => $this->when($internal, $this->usage_limit), diff --git a/server/src/Models/Promotion.php b/server/src/Models/Promotion.php index 996d4b03..15335c13 100644 --- a/server/src/Models/Promotion.php +++ b/server/src/Models/Promotion.php @@ -5,6 +5,7 @@ use Fleetbase\Casts\Json; use Fleetbase\Casts\PolymorphicType; use Fleetbase\FleetOps\Support\Utils; +use Fleetbase\Models\Category; use Fleetbase\Models\File; use Fleetbase\Traits\HasApiModelBehavior; use Fleetbase\Traits\HasPublicId; @@ -36,6 +37,15 @@ class Promotion extends StorefrontModel public const STATUSES = [self::STATUS_DRAFT, self::STATUS_ACTIVE, self::STATUS_PAUSED, self::STATUS_ENDED]; + /** + * Customer-facing availability: running now, running later (outside its weekly hours or + * before its start date), over, or not offered at all (draft or paused). + */ + public const AVAILABILITY_LIVE = 'live'; + public const AVAILABILITY_SCHEDULED = 'scheduled'; + public const AVAILABILITY_ENDED = 'ended'; + public const AVAILABILITY_INACTIVE = 'inactive'; + protected $publicIdType = 'promotion'; protected $table = 'promotions'; @@ -165,6 +175,151 @@ public function isLiveAt(?Carbon $moment = null): bool return false; } + /** + * Customer-facing availability at the given moment. + */ + public function availabilityAt(?Carbon $moment = null): string + { + $moment ??= Carbon::now(); + + if ($this->status === self::STATUS_ENDED || ($this->ends_at && $moment->gte($this->ends_at))) { + return self::AVAILABILITY_ENDED; + } + + if ($this->status !== self::STATUS_ACTIVE) { + return self::AVAILABILITY_INACTIVE; + } + + return $this->isLiveAt($moment) ? self::AVAILABILITY_LIVE : self::AVAILABILITY_SCHEDULED; + } + + /** + * When a scheduled promotion next starts applying, looking up to a week ahead; null when it + * is live now, has ended, is not active, or has no start inside that week. + */ + public function nextLiveAt(?Carbon $moment = null): ?Carbon + { + $moment ??= Carbon::now(); + + if ($this->availabilityAt($moment) !== self::AVAILABILITY_SCHEDULED) { + return null; + } + + $timezone = $this->timezone ?: config('app.timezone', 'UTC'); + $from = ($this->starts_at && $this->starts_at->gt($moment) ? $this->starts_at : $moment)->copy()->setTimezone($timezone); + $windows = array_values(array_filter((array) ($this->schedule ?? []), 'is_array')); + if (empty($windows)) { + $windows = [['days' => [1, 2, 3, 4, 5, 6, 7], 'start' => '00:00', 'end' => '24:00']]; + } + + $next = null; + for ($offset = -1; $offset <= 7; $offset++) { + $day = $from->copy()->startOfDay()->addDays($offset); + foreach ($windows as $window) { + if (!in_array($day->dayOfWeekIso, array_map('intval', (array) ($window['days'] ?? [1, 2, 3, 4, 5, 6, 7])), true)) { + continue; + } + + $start = static::minutesOfDay($window['start'] ?? '00:00'); + $end = static::minutesOfDay($window['end'] ?? '24:00'); + $opens = $day->copy()->addMinutes($start); + $shuts = $day->copy()->addMinutes($end <= $start ? $end + 1440 : $end); + + $candidate = $opens->lt($from) ? $from->copy() : $opens; + if ($candidate->gte($shuts) || ($this->ends_at && $candidate->gte($this->ends_at))) { + continue; + } + + if (!$next || $candidate->lt($next)) { + $next = $candidate; + } + } + } + + return $next?->setTimezone(config('app.timezone', 'UTC')); + } + + /** + * The code a customer can type for a code-triggered promotion: an active code that is not + * assigned to one customer, has not expired and can be used more than once. Batches of + * single-use codes are handed out individually (for example by a campaign), so they are + * never shown publicly. + */ + public function shareableCode(?Carbon $moment = null): ?string + { + if ($this->trigger !== self::TRIGGER_CODE) { + return null; + } + + $moment ??= Carbon::now(); + $codes = $this->relationLoaded('codes') ? $this->codes : $this->codes()->get(); + + $code = $codes + ->filter(fn (PromotionCode $code) => $code->status === PromotionCode::STATUS_ACTIVE + && empty($code->customer_uuid) + && (!$code->expires_at || $moment->lt($code->expires_at)) + && ($code->usage_limit === null || $code->usage_limit > 1)) + ->sortByDesc('created_at') + ->first(); + + return $code?->code; + } + + /** + * The store or network running the promotion, as customers see it. + * + * @return array{type: string, id: ?string, name: ?string, logo_url: ?string}|null + */ + public function ownerSummary(): ?array + { + $owner = $this->owner; + if (!$owner instanceof Store && !$owner instanceof Network) { + return null; + } + + return [ + 'type' => $owner instanceof Store ? 'store' : 'network', + 'id' => $owner->public_id, + 'name' => $owner->name, + 'logo_url' => data_get($owner, 'logo.url'), + ]; + } + + /** + * `applies_to` with every product, category and store named by its public id, so the app + * can link "Shop the offer" to them. Ids that no longer resolve are dropped. + * + * @return array> + */ + public function publicAppliesTo(): array + { + $appliesTo = (array) ($this->applies_to ?? []); + $models = [ + 'products' => Product::class, + 'exclude_products' => Product::class, + 'stores' => Store::class, + 'categories' => Category::class, + 'exclude_categories' => Category::class, + ]; + + $public = []; + foreach ($models as $key => $model) { + $ids = array_values(array_filter((array) ($appliesTo[$key] ?? []), 'is_string')); + if (empty($ids)) { + continue; + } + + $public[$key] = $model::query() + ->where(fn ($query) => $query->whereIn('uuid', $ids)->orWhereIn('public_id', $ids)) + ->pluck('public_id') + ->filter() + ->values() + ->all(); + } + + return $public; + } + protected static function minutesOfDay(string $time): int { [$hours, $minutes] = array_pad(array_map('intval', explode(':', $time)), 2, 0); diff --git a/server/tests/Unit/Promotions/PublicPromotionDetailsTest.php b/server/tests/Unit/Promotions/PublicPromotionDetailsTest.php new file mode 100644 index 00000000..4dfefbb4 --- /dev/null +++ b/server/tests/Unit/Promotions/PublicPromotionDetailsTest.php @@ -0,0 +1,171 @@ + [1, 2, 3, 4, 5], 'start' => '14:00', 'end' => '17:00']]; + +function publicPromotionRequest(array $input = [], bool $internal = false): Request +{ + $request = Request::create('/', 'GET', $input); + $request->setLaravelSession(new Illuminate\Session\Store('promotion-details', new Illuminate\Session\ArraySessionHandler(120))); + if ($internal) { + $request->setRouteResolver(fn () => new class { + public array $action = []; + + public function uri(): string + { + return 'int/v1/storefront/promotions'; + } + }); + } + app()->instance('request', $request); + + return $request; +} + +beforeEach(function () { + createPromotionSchema(); + seedPromotionStores(); + session(['storefront_key' => 'store_key_a', 'storefront_store' => 'store_a_uuid', 'storefront_network' => null, 'company' => 'company_uuid']); + Carbon::setTestNow(Carbon::parse(PROMO_MONDAY_10AM, 'UTC')); +}); + +afterEach(function () { + Carbon::setTestNow(); +}); + +test('promotion availability distinguishes live, scheduled, ended and inactive promotions', function () { + expect(makePromotion()->availabilityAt())->toBe('live') + ->and(makePromotion(['schedule' => WEEKDAY_AFTERNOONS, 'timezone' => 'UTC'])->availabilityAt())->toBe('scheduled') + ->and(makePromotion(['starts_at' => now()->addDay()])->availabilityAt())->toBe('scheduled') + ->and(makePromotion(['status' => Promotion::STATUS_ENDED])->availabilityAt())->toBe('ended') + ->and(makePromotion(['ends_at' => now()->subMinute()])->availabilityAt())->toBe('ended') + ->and(makePromotion(['status' => Promotion::STATUS_DRAFT])->availabilityAt())->toBe('inactive') + ->and(makePromotion(['status' => Promotion::STATUS_PAUSED])->availabilityAt(now()))->toBe('inactive'); +}); + +test('scheduled promotions report when they next start', function () { + $afternoons = makePromotion(['schedule' => WEEKDAY_AFTERNOONS, 'timezone' => 'UTC']); + $friday = Carbon::parse('2026-10-09 18:00:00', 'UTC'); + $starting = makePromotion(['starts_at' => Carbon::parse('2026-10-20 09:00:00', 'UTC')]); + $overnight = makePromotion([ + 'schedule' => [['days' => [1], 'start' => '22:00', 'end' => '02:00']], + 'timezone' => 'UTC', + 'starts_at' => Carbon::parse('2026-10-05 23:00:00', 'UTC'), + ]); + // Singapore is UTC+8: 14:00 local on Monday is 06:00 UTC, already passed at 10:00 UTC, + // so the next start is Tuesday 06:00 UTC. + $singapore = makePromotion(['schedule' => WEEKDAY_AFTERNOONS, 'timezone' => 'Asia/Singapore']); + $endsFirst = makePromotion(['schedule' => WEEKDAY_AFTERNOONS, 'timezone' => 'UTC', 'ends_at' => Carbon::parse('2026-10-05 12:00:00', 'UTC')]); + $noDays = makePromotion(['schedule' => [['days' => [], 'start' => '14:00', 'end' => '17:00']], 'timezone' => 'UTC']); + + expect(makePromotion()->nextLiveAt())->toBeNull() + ->and($afternoons->nextLiveAt()->toDateTimeString())->toBe('2026-10-05 14:00:00') + ->and($afternoons->nextLiveAt($friday)->toDateTimeString())->toBe('2026-10-12 14:00:00') + ->and($starting->nextLiveAt()->toDateTimeString())->toBe('2026-10-20 09:00:00') + ->and($overnight->nextLiveAt()->toDateTimeString())->toBe('2026-10-05 23:00:00') + ->and($singapore->nextLiveAt()->toDateTimeString())->toBe('2026-10-06 06:00:00') + ->and($endsFirst->nextLiveAt())->toBeNull() + ->and($noDays->nextLiveAt())->toBeNull(); +}); + +test('only one active, unassigned, unexpired, reusable code is shared publicly', function () { + $automatic = makePromotion(); + $coded = makePromotion(['trigger' => Promotion::TRIGGER_CODE]); + makePromotionCode($coded, 'SINGLE', ['usage_limit' => 1]); + makePromotionCode($coded, 'MINE', ['customer_uuid' => 'customer_uuid']); + makePromotionCode($coded, 'OLDCODE', ['expires_at' => now()->subDay()]); + makePromotionCode($coded, 'OFF', ['status' => 'disabled']); + makePromotionCode($coded, 'BLOOM10', ['usage_limit' => 100, 'created_at' => now()->subDay()]); + makePromotionCode($coded, 'BLOOMNEW', ['usage_limit' => null, 'created_at' => now()]); + $batchOnly = makePromotion(['trigger' => Promotion::TRIGGER_CODE]); + makePromotionCode($batchOnly, 'BATCH1', ['usage_limit' => 1]); + + expect($automatic->shareableCode())->toBeNull() + ->and($coded->shareableCode())->toBe('BLOOMNEW') + ->and($coded->load('codes')->shareableCode())->toBe('BLOOMNEW') + ->and($batchOnly->shareableCode())->toBeNull(); +}); + +test('promotions describe their owner and name their targets by public id', function () { + $schema = promotionDb()->getSchemaBuilder(); + $schema->dropIfExists('categories'); + $schema->create('categories', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + promotionDb()->table('categories')->insert(['uuid' => 'category_uuid', 'public_id' => 'category_flowers']); + promotionDb()->table('products')->insert(['uuid' => 'product_1_uuid', 'public_id' => 'product_1', 'store_uuid' => 'store_a_uuid']); + + $storeOwned = makePromotion(['applies_to' => [ + 'products' => ['product_1_uuid', 'product_gone'], + 'exclude_products' => [], + 'stores' => ['store_b'], + 'categories' => ['category_uuid', 42], + 'exclude_categories' => ['category_missing'], + ]]); + $networkOwned = makePromotion(['owner_uuid' => 'network_uuid', 'owner_type' => Network::class]); + $orphaned = makePromotion(['owner_uuid' => 'missing_store']); + + expect($storeOwned->ownerSummary())->toBe(['type' => 'store', 'id' => 'store_a', 'name' => 'Store A', 'logo_url' => null]) + ->and($networkOwned->ownerSummary())->toBe(['type' => 'network', 'id' => 'network_m', 'name' => 'Market', 'logo_url' => null]) + ->and($orphaned->ownerSummary())->toBeNull() + ->and($storeOwned->publicAppliesTo())->toBe([ + 'products' => ['product_1'], + 'stores' => ['store_b'], + 'categories' => ['category_flowers'], + 'exclude_categories' => [], + ]) + ->and($networkOwned->publicAppliesTo())->toBe([]); +}); + +test('public promotion listings can include scheduled promotions and details open for scheduled and ended ones', function () { + $live = makePromotion(['name' => 'Live']); + $scheduled = makePromotion(['name' => 'Afternoons', 'schedule' => WEEKDAY_AFTERNOONS, 'timezone' => 'UTC']); + $ended = makePromotion(['name' => 'Ended', 'status' => Promotion::STATUS_ENDED]); + $paused = makePromotion(['name' => 'Paused', 'status' => Promotion::STATUS_PAUSED]); + $draft = makePromotion(['name' => 'Draft', 'status' => Promotion::STATUS_DRAFT]); + $controller = new PromotionController(); + + $default = collect($controller->query(publicPromotionRequest())->resolve())->pluck('name')->all(); + $withLater = collect($controller->query(publicPromotionRequest(['include' => 'scheduled']))->resolve()); + + expect($default)->toBe(['Live']) + ->and($withLater->pluck('name')->all())->toBe(['Live', 'Afternoons']) + ->and($withLater->firstWhere('name', 'Afternoons')['availability'])->toBe('scheduled') + ->and($withLater->firstWhere('name', 'Afternoons')['next_starts_at']->toDateTimeString())->toBe('2026-10-05 14:00:00') + ->and($controller->find($scheduled->public_id)->resolve()['availability'])->toBe('scheduled') + ->and($controller->find($ended->public_id)->resolve()['availability'])->toBe('ended') + ->and($controller->find($live->public_id)->resolve()['availability'])->toBe('live') + ->and($controller->find($paused->public_id)->getStatusCode())->toBe(404) + ->and($controller->find($draft->public_id)->getStatusCode())->toBe(404); +}); + +test('public promotion resources add owner and shareable code, internal ones keep raw targets', function () { + $promotion = makePromotion(['trigger' => Promotion::TRIGGER_CODE, 'applies_to' => ['stores' => ['store_a_uuid']]]); + makePromotionCode($promotion, 'SHARE', ['usage_limit' => null]); + + $public = (new PromotionResource($promotion))->resolve(publicPromotionRequest()); + $internal = (new PromotionResource($promotion))->resolve(publicPromotionRequest([], true)); + + expect($public['owner'])->toBe(['type' => 'store', 'id' => 'store_a', 'name' => 'Store A', 'logo_url' => null]) + ->and($public['code'])->toBe('SHARE') + ->and($public['availability'])->toBe('live') + ->and($public['next_starts_at'])->toBeNull() + ->and($public['applies_to'])->toBe(['stores' => ['store_a']]) + ->and($internal)->not->toHaveKey('owner') + ->and($internal)->not->toHaveKey('code') + ->and($internal['applies_to'])->toBe(['stores' => ['store_a_uuid']]); +}); From fb6ecf7c8c7df2c7252f6eacf08a5838476d1492 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 6 Oct 2026 20:09:15 +0800 Subject: [PATCH 006/193] feat(chat): customer chat with the driver delivering their order - storefront/v1/orders/{id}/chat: show (starts the chat), messages (cursor pagination), send (text and up to four photos) and read (receipts), for the signed-in customer's own orders in this storefront. - The chat is a core chat channel tagged with the order in its meta, so the driver sees it in Navigator; participants are kept to the customer and the currently assigned driver, and it is started when a driver is assigned. - Customers can read but not send once the order is completed, canceled or expired. - Driver messages are pushed to the customer through storefront push, the inbox and the customer's broadcast channel. - Customer socket tokens include the customer's user uuid so they can subscribe to chat_channel.{uuid}, which core authorizes by participant. --- .../Controllers/v1/OrderChatController.php | 245 ++++++ .../Requests/SendOrderChatMessageRequest.php | 33 + server/src/Http/Resources/OrderChat.php | 56 ++ .../src/Http/Resources/OrderChatMessage.php | 67 ++ .../Listeners/HandleOrderDriverAssigned.php | 9 + .../StorefrontOrderChatMessage.php | 96 +++ server/src/Observers/ChatMessageObserver.php | 37 + .../Providers/StorefrontServiceProvider.php | 1 + server/src/Support/OrderChat.php | 137 +++ server/src/Support/StorefrontSocket.php | 4 +- server/src/routes.php | 6 +- .../Controllers/OrderChatControllerTest.php | 795 ++++++++++++++++++ .../Unit/Routes/StorefrontRoutesTest.php | 4 + .../Unit/Support/StorefrontSocketTest.php | 15 +- 14 files changed, 1498 insertions(+), 7 deletions(-) create mode 100644 server/src/Http/Controllers/v1/OrderChatController.php create mode 100644 server/src/Http/Requests/SendOrderChatMessageRequest.php create mode 100644 server/src/Http/Resources/OrderChat.php create mode 100644 server/src/Http/Resources/OrderChatMessage.php create mode 100644 server/src/Notifications/StorefrontOrderChatMessage.php create mode 100644 server/src/Observers/ChatMessageObserver.php create mode 100644 server/src/Support/OrderChat.php create mode 100644 server/tests/Unit/Http/Controllers/OrderChatControllerTest.php diff --git a/server/src/Http/Controllers/v1/OrderChatController.php b/server/src/Http/Controllers/v1/OrderChatController.php new file mode 100644 index 00000000..e306c93b --- /dev/null +++ b/server/src/Http/Controllers/v1/OrderChatController.php @@ -0,0 +1,245 @@ +resolveContext($id); + if ($context instanceof JsonResponse) { + return $context; + } + [$order, $customer, $channel] = $context; + + $customerUserUuid = $customer->user_uuid; + $participant = OrderChat::participantFor($channel, $customerUserUuid); + $messages = $this->messageQuery($channel)->limit(static::PAGE_SIZE)->get()->reverse()->values(); + $unread = $participant ? $channel->getUnreadMessagesForParticipant($participant)->count() : 0; + + $channel->load(['participants.user']); + + return new OrderChatResource($channel, $order, $customerUserUuid, $messages, $unread); + } + + /** + * Older messages, newest first in the query and returned oldest first. + * + * Query params: `before` (a message id) and `limit` (up to 100). + */ + public function messages(Request $request, string $id) + { + $context = $this->resolveContext($id); + if ($context instanceof JsonResponse) { + return $context; + } + [, $customer, $channel] = $context; + + $limit = min(max((int) $request->input('limit', static::PAGE_SIZE), 1), static::MAX_PAGE_SIZE); + $query = $this->messageQuery($channel); + + if ($request->filled('before')) { + $before = ChatMessage::where('chat_channel_uuid', $channel->uuid)->where('public_id', $request->input('before'))->first(); + if (!$before) { + return response()->apiError('Message not found.', 404); + } + $query->where(fn ($older) => $older->where('created_at', '<', $before->created_at) + ->orWhere(fn ($sameTime) => $sameTime->where('created_at', $before->created_at)->where('id', '<', $before->id))); + } + + $messages = $query->limit($limit)->get()->reverse()->values(); + + return response()->json(['messages' => OrderChatMessage::list($messages, $customer->user_uuid)]); + } + + /** + * Send a message as the customer. Closed once the order is completed or canceled. + */ + public function send(SendOrderChatMessageRequest $request, string $id) + { + $context = $this->resolveContext($id); + if ($context instanceof JsonResponse) { + return $context; + } + [$order, $customer, $channel] = $context; + + if (OrderChat::isClosed($order)) { + return response()->json(['error' => 'This chat closed when the order finished.', 'reason' => 'chat_closed'], 423); + } + + $sender = OrderChat::participantFor($channel, $customer->user_uuid); + if (!$sender) { + return response()->apiError('You are not part of this chat.', 403); + } + + $message = ChatMessage::create([ + 'company_uuid' => $channel->company_uuid, + 'chat_channel_uuid' => $channel->uuid, + 'sender_uuid' => $sender->uuid, + 'content' => (string) $request->input('content', ''), + ]); + + foreach ((array) $request->input('files', []) as $upload) { + $this->attachPhoto($message, $sender, $customer, $upload); + } + + $message->load(['sender.user', 'attachments.file', 'receipts']); + $message->notifyParticipants(); + + return response()->json(['message' => (new OrderChatMessage($message, $customer->user_uuid))->resolve()]); + } + + /** + * Mark every message from the driver as read by the customer. + */ + public function read(string $id) + { + $context = $this->resolveContext($id); + if ($context instanceof JsonResponse) { + return $context; + } + [, $customer, $channel] = $context; + + $participant = OrderChat::participantFor($channel, $customer->user_uuid); + if (!$participant) { + return response()->apiError('You are not part of this chat.', 403); + } + + $unread = $channel->getUnreadMessagesForParticipant($participant); + foreach ($unread as $message) { + ChatReceipt::create([ + 'company_uuid' => $channel->company_uuid, + 'chat_message_uuid' => $message->uuid, + 'participant_uuid' => $participant->uuid, + ]); + } + + return response()->json(['read' => $unread->count()]); + } + + /** + * Resolve the signed-in customer, their order in this storefront and its chat. + * + * @return array{0: Order, 1: Contact, 2: ChatChannel}|JsonResponse + */ + protected function resolveContext(string $id): array|JsonResponse + { + $customer = Storefront::getCustomerFromToken(); + if (!$customer) { + return response()->apiError('Customer is not authenticated.', 401); + } + + $order = Order::where('public_id', $id)->with(['customer', 'driverAssigned'])->first(); + if (!$order || !$this->belongsToStorefront($order)) { + return response()->apiError('Order not found.', 404); + } + + if ($order->customer_uuid !== $customer->uuid) { + return response()->apiError('Not authorized to view this order.', 403); + } + + // Once the order is finished the chat stays readable, but is not started any more. + $channel = OrderChat::isClosed($order) ? OrderChat::find($order) : OrderChat::open($order); + if (!$channel) { + return response()->json([ + 'error' => 'You can message your driver once one is assigned to this order.', + 'reason' => 'driver_not_assigned', + ], 409); + } + + return [$order, $customer, $channel]; + } + + /** + * Whether the order was placed through the storefront the request is made with. + */ + protected function belongsToStorefront(Order $order): bool + { + $about = Storefront::about(); + if (!$about) { + return false; + } + + $storefrontId = $about->is_network ? $order->getMeta('storefront_network_id') : $order->getMeta('storefront_id'); + + return $storefrontId === $about->public_id; + } + + protected function messageQuery(ChatChannel $channel): Builder + { + return ChatMessage::where('chat_channel_uuid', $channel->uuid) + ->with(['sender.user', 'attachments.file', 'receipts']) + ->orderByDesc('created_at') + ->orderByDesc('id'); + } + + /** + * Store a base64 photo and attach it to the message. Written without a public ACL: the + * media bucket is private and file URLs are served through the File model. + * + * @param array{data?: string, type?: string} $upload + */ + protected function attachPhoto(ChatMessage $message, ChatParticipant $sender, Contact $customer, array $upload): void + { + $disk = config('filesystems.default'); + $bucket = config('filesystems.disks.' . $disk . '.bucket', config('filesystems.disks.s3.bucket')); + $data = base64_decode((string) ($upload['data'] ?? '')); + $mimeType = (string) ($upload['type'] ?? 'image/jpeg'); + $extension = File::getExtensionFromMimeType($mimeType); + $path = 'hyperstore/' . data_get(Storefront::about(), 'public_id') . '/order-chat/' . $message->chat_channel_uuid . '/' . File::randomFileName($extension); + + Storage::disk($disk)->put($path, $data); + + $file = File::create([ + 'company_uuid' => $message->company_uuid, + 'uploader_uuid' => $customer->user_uuid, + 'subject_uuid' => $message->uuid, + 'subject_type' => ChatMessage::class, + 'disk' => $disk, + 'original_filename' => basename($path), + 'content_type' => $mimeType, + 'path' => $path, + 'bucket' => $bucket, + 'type' => 'storefront_chat_upload', + 'file_size' => strlen($data), + ]); + + ChatAttachment::create([ + 'company_uuid' => $message->company_uuid, + 'chat_channel_uuid' => $message->chat_channel_uuid, + 'chat_message_uuid' => $message->uuid, + 'sender_uuid' => $sender->uuid, + 'file_uuid' => $file->uuid, + ]); + } +} diff --git a/server/src/Http/Requests/SendOrderChatMessageRequest.php b/server/src/Http/Requests/SendOrderChatMessageRequest.php new file mode 100644 index 00000000..edc53322 --- /dev/null +++ b/server/src/Http/Requests/SendOrderChatMessageRequest.php @@ -0,0 +1,33 @@ + 'required_without:files|nullable|string|max:2000', + 'files' => 'required_without:content|array|max:4', + 'files.*.data' => 'required_with:files|string', + 'files.*.type' => 'required_with:files|string|starts_with:image/', + ]; + } +} diff --git a/server/src/Http/Resources/OrderChat.php b/server/src/Http/Resources/OrderChat.php new file mode 100644 index 00000000..16171f4e --- /dev/null +++ b/server/src/Http/Resources/OrderChat.php @@ -0,0 +1,56 @@ + $messages latest messages, oldest first + */ + public function __construct(ChatChannel $channel, public Order $order, public ?string $customerUserUuid, public iterable $messages = [], public int $unreadCount = 0) + { + parent::__construct($channel); + } + + /** + * Transform the resource into an array. + * + * @param \Illuminate\Http\Request $request + * + * @return array + */ + public function toArray($request) + { + $closed = OrderChatSupport::isClosed($this->order); + $me = $this->participants->firstWhere('user_uuid', $this->customerUserUuid); + + return [ + 'id' => $this->public_id, + 'channel' => 'chat_channel.' . $this->uuid, + 'order' => $this->order->public_id, + 'status' => $closed ? 'closed' : 'open', + 'me' => data_get($me, 'public_id'), + 'participants' => $this->participants->map(fn ($participant) => [ + 'id' => $participant->public_id, + 'role' => $participant->user_uuid === $this->customerUserUuid ? 'customer' : 'driver', + 'name' => data_get($participant, 'user.name'), + // Null without an uploaded avatar, so the app shows initials instead of a placeholder. + 'avatar_url' => data_get($participant, 'user.avatar_uuid') ? data_get($participant, 'user.avatar_url') : null, + 'is_online' => (bool) $participant->is_online, + ])->values()->all(), + 'messages' => OrderChatMessage::list($this->messages, $this->customerUserUuid), + 'unread_count' => $this->unreadCount, + ]; + } +} diff --git a/server/src/Http/Resources/OrderChatMessage.php b/server/src/Http/Resources/OrderChatMessage.php new file mode 100644 index 00000000..7c0e5e44 --- /dev/null +++ b/server/src/Http/Resources/OrderChatMessage.php @@ -0,0 +1,67 @@ +sender; + $senderUser = data_get($sender, 'user_uuid'); + $isMine = $senderUser !== null && $senderUser === $this->customerUserUuid; + + return [ + 'id' => $this->public_id, + 'content' => $this->content, + 'sender' => [ + 'id' => data_get($sender, 'public_id'), + 'role' => $isMine ? 'customer' : 'driver', + 'name' => data_get($sender, 'user.name'), + ], + 'is_mine' => $isMine, + 'attachments' => $this->attachments->map(fn ($attachment) => [ + 'id' => data_get($attachment, 'file.public_id'), + 'url' => data_get($attachment, 'file.url'), + 'type' => data_get($attachment, 'file.content_type'), + ])->values()->all(), + // Read by someone other than the sender. + 'read' => $this->receipts->contains(fn ($receipt) => $receipt->participant_uuid !== $this->sender_uuid), + 'created_at' => $this->created_at, + ]; + } + + /** + * @param iterable $messages + * + * @return array + */ + public static function list(iterable $messages, ?string $customerUserUuid): array + { + $items = []; + foreach ($messages as $message) { + $items[] = (new static($message, $customerUserUuid))->resolve(); + } + + return $items; + } +} diff --git a/server/src/Listeners/HandleOrderDriverAssigned.php b/server/src/Listeners/HandleOrderDriverAssigned.php index 6585e0f7..7687978d 100644 --- a/server/src/Listeners/HandleOrderDriverAssigned.php +++ b/server/src/Listeners/HandleOrderDriverAssigned.php @@ -5,6 +5,8 @@ use Fleetbase\FleetOps\Events\OrderDriverAssigned; use Fleetbase\FleetOps\Models\Order; use Fleetbase\Storefront\Notifications\StorefrontOrderDriverAssigned; +use Fleetbase\Storefront\Support\OrderChat; +use Illuminate\Contracts\Debug\ExceptionHandler; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Queue\InteractsWithQueue; @@ -36,6 +38,13 @@ public function handle(OrderDriverAssigned $event) if ($order->customer) { $order->customer->notify(new StorefrontOrderDriverAssigned($order)); } + + // Start the order chat so it is waiting in the driver's chat list. + try { + OrderChat::open($order); + } catch (\Throwable $e) { + app(ExceptionHandler::class)->report($e); + } } } } diff --git a/server/src/Notifications/StorefrontOrderChatMessage.php b/server/src/Notifications/StorefrontOrderChatMessage.php new file mode 100644 index 00000000..753060a1 --- /dev/null +++ b/server/src/Notifications/StorefrontOrderChatMessage.php @@ -0,0 +1,96 @@ +message, 'sender.user.name') ?: 'Your driver'; + + return $name . ' sent a message'; + } + + public function body(): string + { + $content = trim((string) $this->message->content); + + return $content !== '' ? $content : 'Sent a photo'; + } + + public function toPush($notifiable): ?PushMessage + { + return PushMessage::create($this->title(), $this->body(), $this->payload())->analyticsLabel('storefront_order_chat'); + } + + public function pushStorefronts(): array + { + return PushCredentialResolver::storefrontsForOrder($this->order); + } + + public function toArray($notifiable): array + { + return [ + 'title' => $this->title(), + 'body' => $this->body(), + 'subject' => $this->title(), + 'message' => $this->body(), + ...$this->payload(), + ]; + } + + public function toBroadcast($notifiable): BroadcastMessage + { + return new BroadcastMessage(CustomerNotificationPresenter::present($this->toArray($notifiable), static::class)); + } + + public function broadcastType(): string + { + return 'order_chat_message'; + } + + /** + * @return array + */ + protected function payload(): array + { + return array_filter([ + 'type' => 'order_chat_message', + 'order' => $this->order->uuid, + 'order_id' => $this->order->public_id, + 'chat_id' => data_get($this->message, 'chatChannel.public_id'), + 'message_id' => $this->message->public_id, + 'store_id' => $this->order->getMeta('storefront_id'), + 'network_id' => $this->order->getMeta('storefront_network_id'), + ], fn ($value) => $value !== null); + } +} diff --git a/server/src/Observers/ChatMessageObserver.php b/server/src/Observers/ChatMessageObserver.php new file mode 100644 index 00000000..024bab77 --- /dev/null +++ b/server/src/Observers/ChatMessageObserver.php @@ -0,0 +1,37 @@ +chatChannel; + if (!$channel || !data_get($channel->meta, OrderChat::META_ORDER_UUID)) { + return; + } + + $order = OrderChat::orderFor($channel); + if (!$order) { + return; + } + + $customer = $order->customer; + $sender = $message->sender; + if (!$customer || !$sender || $sender->user_uuid === data_get($customer, 'user_uuid')) { + return; + } + + $customer->notify(new StorefrontOrderChatMessage($message, $order)); + } +} diff --git a/server/src/Providers/StorefrontServiceProvider.php b/server/src/Providers/StorefrontServiceProvider.php index 79b25d48..b8b23369 100644 --- a/server/src/Providers/StorefrontServiceProvider.php +++ b/server/src/Providers/StorefrontServiceProvider.php @@ -35,6 +35,7 @@ class StorefrontServiceProvider extends CoreServiceProvider \Fleetbase\Storefront\Models\Catalog::class => \Fleetbase\Storefront\Observers\CatalogObserver::class, \Fleetbase\Storefront\Models\FoodTruck::class => \Fleetbase\Storefront\Observers\FoodTruckObserver::class, \Fleetbase\Models\Company::class => \Fleetbase\Storefront\Observers\CompanyObserver::class, + \Fleetbase\Models\ChatMessage::class => \Fleetbase\Storefront\Observers\ChatMessageObserver::class, ]; /** diff --git a/server/src/Support/OrderChat.php b/server/src/Support/OrderChat.php new file mode 100644 index 00000000..c204b090 --- /dev/null +++ b/server/src/Support/OrderChat.php @@ -0,0 +1,137 @@ +company_uuid) + ->where('meta->' . static::META_ORDER_UUID, $order->uuid) + ->first(); + } + + /** + * The order's chat, started when needed, with the customer and the assigned driver as its + * only participants. Null when either side has no user account yet, typically because no + * driver has been assigned. + */ + public static function open(Order $order): ?ChatChannel + { + $customerUserUuid = static::customerUserUuid($order); + $driverUserUuid = static::driverUserUuid($order); + if (!$customerUserUuid || !$driverUserUuid) { + return null; + } + + $channel = static::find($order); + if (!$channel) { + // Creating the channel adds its creator, the customer, as the first participant. + $channel = ChatChannel::create([ + 'company_uuid' => $order->company_uuid, + 'created_by_uuid' => $customerUserUuid, + 'name' => 'Order ' . $order->public_id, + 'meta' => [ + static::META_ORDER_UUID => $order->uuid, + static::META_ORDER_ID => $order->public_id, + ], + ]); + } + + static::syncParticipants($channel, [$customerUserUuid, $driverUserUuid]); + + return $channel; + } + + /** + * Whether the customer can still send messages. + */ + public static function isClosed(Order $order): bool + { + return in_array($order->status, static::CLOSED_STATUSES, true); + } + + /** + * The channel participant for a user, if they take part. + */ + public static function participantFor(ChatChannel $channel, ?string $userUuid): ?ChatParticipant + { + if (!$userUuid) { + return null; + } + + return ChatParticipant::where('chat_channel_uuid', $channel->uuid)->where('user_uuid', $userUuid)->first(); + } + + /** + * The order a chat channel belongs to, when it is an order chat. + */ + public static function orderFor(ChatChannel $channel): ?Order + { + $orderUuid = data_get($channel->meta, static::META_ORDER_UUID); + + return $orderUuid ? Order::where('uuid', $orderUuid)->first() : null; + } + + public static function customerUserUuid(Order $order): ?string + { + return data_get($order->customer, 'user_uuid'); + } + + public static function driverUserUuid(Order $order): ?string + { + return data_get($order->driverAssigned, 'user_uuid'); + } + + /** + * Add missing participants and remove anyone else, such as a driver the order was taken + * away from, so a reassigned order's chat moves to the new driver. + * + * @param array $userUuids + */ + protected static function syncParticipants(ChatChannel $channel, array $userUuids): void + { + $existing = ChatParticipant::where('chat_channel_uuid', $channel->uuid)->get(); + + foreach ($existing as $participant) { + if (!in_array($participant->user_uuid, $userUuids, true)) { + $participant->delete(); + } + } + + $present = $existing->pluck('user_uuid')->all(); + foreach ($userUuids as $userUuid) { + if (!in_array($userUuid, $present, true)) { + ChatParticipant::create([ + 'company_uuid' => $channel->company_uuid, + 'chat_channel_uuid' => $channel->uuid, + 'user_uuid' => $userUuid, + ]); + } + } + } +} diff --git a/server/src/Support/StorefrontSocket.php b/server/src/Support/StorefrontSocket.php index f8d1b66a..288b9c74 100644 --- a/server/src/Support/StorefrontSocket.php +++ b/server/src/Support/StorefrontSocket.php @@ -38,7 +38,9 @@ public static function customerPrincipal(Contact $customer, Store|Network $store 'cid' => $storefront->company_uuid, 'cpid' => static::companyPublicId($storefront->company_uuid), 'env' => static::ENV, - 'ids' => [$customer->uuid, $customer->public_id], + // The user uuid lets the customer join chats they take part in, such as their + // order chat with the driver: core authorizes chat channels by participant user. + 'ids' => array_values(array_filter([$customer->uuid, $customer->public_id, $customer->user_uuid])), 'adm' => false, 'scp' => null, 'sid' => $storefront->uuid, diff --git a/server/src/routes.php b/server/src/routes.php index 5cec3919..379ffc0e 100644 --- a/server/src/routes.php +++ b/server/src/routes.php @@ -97,6 +97,10 @@ function ($router) { $router->group(['prefix' => 'orders'], function () use ($router) { $router->put('picked-up', 'OrderController@completeOrderPickup'); $router->post('receipt', 'OrderController@getReceipt'); + $router->get('{id}/chat', 'OrderChatController@show'); + $router->get('{id}/chat/messages', 'OrderChatController@messages'); + $router->post('{id}/chat/messages', 'OrderChatController@send'); + $router->post('{id}/chat/read', 'OrderChatController@read'); }); // storefront/v1/promotions @@ -104,7 +108,7 @@ function ($router) { $router->get('/', 'PromotionController@query'); $router->get('{id}', 'PromotionController@find'); }); - + // storefront/v1/notifications $router->group(['prefix' => 'notifications'], function () use ($router) { $router->get('/', 'NotificationController@query'); diff --git a/server/tests/Unit/Http/Controllers/OrderChatControllerTest.php b/server/tests/Unit/Http/Controllers/OrderChatControllerTest.php new file mode 100644 index 00000000..3db3b4b8 --- /dev/null +++ b/server/tests/Unit/Http/Controllers/OrderChatControllerTest.php @@ -0,0 +1,795 @@ +writes[] = [$path, $contents, $options]; + + return true; + } + + public function url($path) + { + return 'https://cdn.test/' . $path; + } + + public function exists($path) + { + return false; + } + + public function get($path) + { + return null; + } + + public function readStream($path) + { + return null; + } + + public function writeStream($path, $resource, array $options = []) + { + return false; + } + + public function getVisibility($path) + { + return 'private'; + } + + public function setVisibility($path, $visibility) + { + return false; + } + + public function prepend($path, $data) + { + return false; + } + + public function append($path, $data) + { + return false; + } + + public function delete($paths) + { + return false; + } + + public function copy($from, $to) + { + return false; + } + + public function move($from, $to) + { + return false; + } + + public function size($path) + { + return 0; + } + + public function lastModified($path) + { + return 0; + } + + public function files($directory = null, $recursive = false) + { + return []; + } + + public function allFiles($directory = null) + { + return []; + } + + public function directories($directory = null, $recursive = false) + { + return []; + } + + public function allDirectories($directory = null) + { + return []; + } + + public function makeDirectory($path) + { + return false; + } + + public function deleteDirectory($directory) + { + return false; + } +} + +function orderChatDb(): Illuminate\Database\Connection +{ + return Model::getConnectionResolver()->connection('mysql'); +} + +function createOrderChatSchema(): void +{ + $schema = orderChatDb()->getSchemaBuilder(); + foreach (['stores', 'networks', 'contacts', 'personal_access_tokens', 'users', 'drivers', 'orders', 'chat_channels', 'chat_participants', 'chat_messages', 'chat_attachments', 'chat_receipts', 'chat_logs', 'files'] as $table) { + $schema->dropIfExists($table); + } + foreach (['stores', 'networks'] as $storefrontTable) { + $schema->create($storefrontTable, function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('key')->nullable(); + $table->string('name')->nullable(); + $table->text('options')->nullable(); + $table->text('translations')->nullable(); + $table->text('tags')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + } + $schema->create('contacts', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('user_uuid')->nullable(); + $table->string('type')->nullable(); + $table->string('name')->nullable(); + $table->text('meta')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('personal_access_tokens', function ($table) { + $table->increments('id'); + $table->string('tokenable_type')->nullable(); + $table->string('tokenable_id')->nullable(); + $table->string('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamps(); + }); + $schema->create('users', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('avatar_uuid')->nullable(); + $table->string('name')->nullable(); + $table->string('email')->nullable(); + $table->string('phone')->nullable(); + $table->string('type')->nullable(); + $table->string('status')->nullable(); + $table->timestamp('last_login')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('drivers', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('user_uuid')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('orders', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('customer_uuid')->nullable(); + $table->string('customer_type')->nullable(); + $table->string('driver_assigned_uuid')->nullable(); + $table->string('status')->nullable(); + $table->text('meta')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('chat_channels', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('created_by_uuid')->nullable(); + $table->string('name')->nullable(); + $table->string('slug')->nullable(); + $table->text('meta')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('chat_participants', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('chat_channel_uuid')->nullable(); + $table->string('user_uuid')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('chat_messages', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('chat_channel_uuid')->nullable(); + $table->string('sender_uuid')->nullable(); + $table->text('content')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('chat_attachments', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('chat_channel_uuid')->nullable(); + $table->string('chat_message_uuid')->nullable(); + $table->string('sender_uuid')->nullable(); + $table->string('file_uuid')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('chat_receipts', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('chat_message_uuid')->nullable(); + $table->string('participant_uuid')->nullable(); + $table->timestamp('read_at')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('chat_logs', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('chat_channel_uuid')->nullable(); + $table->string('initiator_uuid')->nullable(); + $table->string('event_type')->nullable(); + $table->text('content')->nullable(); + $table->text('subjects')->nullable(); + $table->text('meta')->nullable(); + $table->string('status')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('files', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('uploader_uuid')->nullable(); + $table->string('subject_uuid')->nullable(); + $table->string('subject_type')->nullable(); + $table->string('name')->nullable(); + $table->string('original_filename')->nullable(); + $table->string('slug')->nullable(); + $table->string('disk')->nullable(); + $table->string('extension')->nullable(); + $table->string('content_type')->nullable(); + $table->string('path')->nullable(); + $table->string('bucket')->nullable(); + $table->string('type')->nullable(); + $table->integer('file_size')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + + $db = orderChatDb(); + $db->table('stores')->insert(['uuid' => 'store_uuid', 'public_id' => 'store_bloom', 'company_uuid' => 'company_uuid', 'key' => 'store_key', 'name' => 'Bloom']); + $db->table('networks')->insert(['uuid' => 'network_uuid', 'public_id' => 'network_market', 'company_uuid' => 'company_uuid', 'key' => 'network_key', 'name' => 'Market']); + $db->table('users')->insert([ + ['uuid' => 'customer_user_uuid', 'public_id' => 'user_customer', 'company_uuid' => 'company_uuid', 'name' => 'Mei Tan'], + ['uuid' => 'driver_user_uuid', 'public_id' => 'user_driver', 'company_uuid' => 'company_uuid', 'name' => 'Ravi K.'], + ['uuid' => 'second_driver_user_uuid', 'public_id' => 'user_driver_2', 'company_uuid' => 'company_uuid', 'name' => 'Arun S.'], + ]); + $db->table('contacts')->insert([ + ['uuid' => CHAT_CUSTOMER_UUID, 'public_id' => 'contact_mei', 'company_uuid' => 'company_uuid', 'user_uuid' => 'customer_user_uuid', 'type' => 'customer', 'name' => 'Mei Tan'], + ['uuid' => 'other_customer_uuid', 'public_id' => 'contact_other', 'company_uuid' => 'company_uuid', 'user_uuid' => 'other_user_uuid', 'type' => 'customer', 'name' => 'Other'], + ]); + $db->table('drivers')->insert([ + ['uuid' => 'driver_uuid', 'public_id' => 'driver_ravi', 'company_uuid' => 'company_uuid', 'user_uuid' => 'driver_user_uuid'], + ['uuid' => 'second_driver_uuid', 'public_id' => 'driver_arun', 'company_uuid' => 'company_uuid', 'user_uuid' => 'second_driver_user_uuid'], + ]); + $order = fn (string $publicId, array $overrides = []) => array_merge([ + 'uuid' => $publicId . '_uuid', + 'public_id' => $publicId, + 'company_uuid' => 'company_uuid', + 'customer_uuid' => CHAT_CUSTOMER_UUID, + 'customer_type' => Contact::class, + 'driver_assigned_uuid' => 'driver_uuid', + 'status' => 'dispatched', + 'meta' => json_encode(['storefront_id' => 'store_bloom']), + ], $overrides); + $db->table('orders')->insert([ + $order('order_active'), + $order('order_waiting', ['driver_assigned_uuid' => null]), + $order('order_done', ['status' => 'completed']), + $order('order_done_never_chatted', ['status' => 'completed']), + $order('order_other_customer', ['customer_uuid' => 'other_customer_uuid']), + $order('order_other_store', ['meta' => json_encode(['storefront_id' => 'store_elsewhere'])]), + $order('order_network', ['meta' => json_encode(['storefront_id' => 'store_bloom', 'storefront_network_id' => 'network_market'])]), + ]); + $db->table('personal_access_tokens')->insert([ + 'name' => CHAT_CUSTOMER_UUID, + 'token' => hash('sha256', 'chat-secret'), + 'abilities' => '["*"]', + 'created_at' => now(), + 'updated_at' => now(), + ]); +} + +function bindOrderChatRequest(?string $token = 'chat-secret', string $storefrontKey = 'store_key', array $input = [], string $method = 'GET', string $requestClass = Request::class): Request +{ + $request = $requestClass::create('/orders/chat', $method, $input); + if ($token) { + $request->headers->set('Customer-Token', $token); + } + $request->setLaravelSession(new Illuminate\Session\Store('order-chat', new Illuminate\Session\ArraySessionHandler(120))); + app()->instance('request', $request); + session(['company' => 'company_uuid', 'storefront_key' => $storefrontKey, 'storefront_store' => null, 'storefront_network' => null]); + + return $request; +} + +function orderChatData($response): array +{ + return $response->resolve(request()); +} + +function driverSays(ChatChannel $channel, string $content, string $at, string $driverUserUuid = 'driver_user_uuid'): ChatMessage +{ + $sender = ChatParticipant::where('chat_channel_uuid', $channel->uuid)->where('user_uuid', $driverUserUuid)->first(); + + $message = ChatMessage::create(['company_uuid' => 'company_uuid', 'chat_channel_uuid' => $channel->uuid, 'sender_uuid' => $sender->uuid, 'content' => $content]); + orderChatDb()->table('chat_messages')->where('uuid', $message->uuid)->update(['created_at' => $at]); + + return $message->fresh(); +} + +/** + * Records notifications instead of sending them. + */ +function fakeChatNotifications(): object +{ + $dispatcher = new class implements Illuminate\Contracts\Notifications\Dispatcher { + public array $sent = []; + + public function send($notifiables, $notification) + { + $this->sendNow($notifiables, $notification); + } + + public function sendNow($notifiables, $notification, ?array $channels = null) + { + foreach (is_iterable($notifiables) && !$notifiables instanceof Model ? $notifiables : [$notifiables] as $notifiable) { + $this->sent[] = [$notifiable, $notification]; + } + } + + public function sentTo(string $uuid, string $class): array + { + return array_values(array_map(fn ($pair) => $pair[1], array_filter($this->sent, fn ($pair) => data_get($pair[0], 'uuid') === $uuid && $pair[1] instanceof $class))); + } + }; + app()->instance(Illuminate\Contracts\Notifications\Dispatcher::class, $dispatcher); + Illuminate\Support\Facades\Notification::clearResolvedInstance(Illuminate\Contracts\Notifications\Dispatcher::class); + + return $dispatcher; +} + +beforeEach(function () { + createOrderChatSchema(); + // Model events generate uuids and public ids, and run ChatChannel's creator hook. + Model::setEventDispatcher(new Illuminate\Events\Dispatcher(app())); + Model::clearBootedModels(); + app()->instance('responsecache', new class { + public function clear(): void + { + } + }); + Spatie\ResponseCache\Facades\ResponseCache::clearResolvedInstance('responsecache'); + // Core macros and a disabled activity log, as the framework would provide them. + if (!Illuminate\Support\Str::hasMacro('humanize')) { + Illuminate\Support\Str::macro('humanize', (new Fleetbase\Expansions\Str())->humanize()); + } + app()->instance(Spatie\Activitylog\ActivityLogStatus::class, new Spatie\Activitylog\ActivityLogStatus(new Illuminate\Config\Repository(['activitylog' => ['enabled' => false]]))); + $this->notifications = fakeChatNotifications(); +}); + +afterEach(function () { + Model::unsetEventDispatcher(); + Model::clearBootedModels(); + app()->offsetUnset(Illuminate\Contracts\Notifications\Dispatcher::class); +}); + +test('order chat requires a signed-in customer and their own order in this storefront', function () { + $controller = new OrderChatController(); + + bindOrderChatRequest(null); + $signedOut = $controller->show('order_active'); + bindOrderChatRequest(); + $missing = $controller->show('order_missing'); + $otherStore = $controller->show('order_other_store'); + $notMine = $controller->show('order_other_customer'); + bindOrderChatRequest('chat-secret', 'unknown_key'); + $noStorefront = $controller->show('order_active'); + + expect($signedOut->getStatusCode())->toBe(401) + ->and($missing->getStatusCode())->toBe(404) + ->and($otherStore->getStatusCode())->toBe(404) + ->and($notMine->getStatusCode())->toBe(403) + ->and($noStorefront->getStatusCode())->toBe(404); +}); + +test('order chat waits for a driver and stays closed to new chats after the order finishes', function () { + bindOrderChatRequest(); + $controller = new OrderChatController(); + + $waiting = $controller->show('order_waiting'); + $finished = $controller->show('order_done_never_chatted'); + + expect($waiting->getStatusCode())->toBe(409) + ->and($waiting->getData(true))->toBe([ + 'error' => 'You can message your driver once one is assigned to this order.', + 'reason' => 'driver_not_assigned', + ]) + ->and($finished->getStatusCode())->toBe(409) + ->and(ChatChannel::query()->count())->toBe(0); +}); + +test('opening an order chat creates one channel with the customer and driver', function () { + bindOrderChatRequest(); + $controller = new OrderChatController(); + + $first = orderChatData($controller->show('order_active')); + $second = orderChatData($controller->show('order_active')); + $channel = ChatChannel::query()->first(); + + expect(ChatChannel::query()->count())->toBe(1) + ->and($channel->name)->toBe('Order order_active') + ->and(data_get($channel->meta, 'storefront_order_uuid'))->toBe('order_active_uuid') + ->and(data_get($channel->meta, 'storefront_order_id'))->toBe('order_active') + ->and($first['id'])->toBe($channel->public_id) + ->and($first['channel'])->toBe('chat_channel.' . $channel->uuid) + ->and($first['order'])->toBe('order_active') + ->and($first['status'])->toBe('open') + ->and($first['me'])->toBe(OrderChat::participantFor($channel, 'customer_user_uuid')->public_id) + ->and(collect($first['participants'])->pluck('role', 'name')->all())->toBe(['Mei Tan' => 'customer', 'Ravi K.' => 'driver']) + ->and($first['participants'][0]['is_online'])->toBeFalse() + ->and($first['participants'][0]['avatar_url'])->toBeNull() + ->and($first['messages'])->toBe([]) + ->and($first['unread_count'])->toBe(0) + ->and($second['id'])->toBe($first['id']); +}); + +test('network apps open chats for orders placed through the network', function () { + bindOrderChatRequest('chat-secret', 'network_key'); + + $response = orderChatData((new OrderChatController())->show('order_network')); + + expect($response['order'])->toBe('order_network'); +}); + +test('customers send text and photos, and only driver messages are pushed to the customer', function () { + $storage = new OrderChatFakeDisk(); + Illuminate\Support\Facades\Storage::swap($storage); + config(['filesystems.default' => 'public', 'filesystems.disks.public.bucket' => 'media-bucket']); + $controller = new OrderChatController(); + bindOrderChatRequest(); + $controller->show('order_active'); + $channel = ChatChannel::query()->first(); + + $request = bindOrderChatRequest('chat-secret', 'store_key', [ + 'content' => 'Tower B, unit 08-112', + 'files' => [['data' => base64_encode('photo-bytes'), 'type' => 'image/png']], + ], 'POST', SendOrderChatMessageRequest::class); + $sent = $controller->send($request, 'order_active')->getData(true)['message']; + $file = orderChatDb()->table('files')->first(); + + $driverMessage = driverSays($channel, '', now()->toDateTimeString()); + + expect($sent['content'])->toBe('Tower B, unit 08-112') + ->and($sent['is_mine'])->toBeTrue() + ->and($sent['sender']['role'])->toBe('customer') + ->and($sent['sender']['name'])->toBe('Mei Tan') + ->and($sent['read'])->toBeFalse() + ->and($sent['attachments'])->toHaveCount(1) + ->and($sent['attachments'][0]['type'])->toBe('image/png') + ->and($sent['attachments'][0]['url'])->toStartWith('https://cdn.test/hyperstore/store_bloom/order-chat/' . $channel->uuid . '/') + ->and($file->disk)->toBe('public') + ->and($file->bucket)->toBe('media-bucket') + ->and($file->type)->toBe('storefront_chat_upload') + ->and($file->file_size)->toBe(strlen('photo-bytes')) + ->and($file->subject_type)->toBe(ChatMessage::class) + ->and($storage->writes[0][1])->toBe('photo-bytes') + // chat photos are written without a public ACL + ->and($storage->writes[0][2])->toBe([]); + + // The customer's own message is not pushed back to them; the driver's is (here the + // observer is called directly, since this harness has no model event dispatcher). + (new ChatMessageObserver())->created($driverMessage); + $pushed = $this->notifications->sentTo(CHAT_CUSTOMER_UUID, StorefrontOrderChatMessage::class); + $driverNotified = $this->notifications->sentTo('driver_user_uuid', Fleetbase\Notifications\ChatMessageReceived::class); + + expect($pushed)->toHaveCount(1) + ->and($pushed[0]->message->uuid)->toBe($driverMessage->uuid) + ->and($driverNotified)->toHaveCount(1); +}); + +test('messages page backwards with a stable cursor and read receipts clear the unread count', function () { + $controller = new OrderChatController(); + bindOrderChatRequest(); + $controller->show('order_active'); + $channel = ChatChannel::query()->first(); + $first = driverSays($channel, 'Picked up your flowers', '2026-10-06 13:51:00'); + $second = driverSays($channel, 'At the lobby', '2026-10-06 14:00:00'); + $third = driverSays($channel, 'Which tower?', '2026-10-06 14:00:00'); + + $opened = orderChatData($controller->show('order_active')); + $page = $controller->messages(bindOrderChatRequest('chat-secret', 'store_key', ['before' => $third->public_id, 'limit' => 1]), 'order_active')->getData(true); + $earlier = $controller->messages(bindOrderChatRequest('chat-secret', 'store_key', ['before' => $second->public_id]), 'order_active')->getData(true); + $unknown = $controller->messages(bindOrderChatRequest('chat-secret', 'store_key', ['before' => 'chat_message_missing']), 'order_active'); + $all = $controller->messages(bindOrderChatRequest('chat-secret', 'store_key', ['limit' => 500]), 'order_active')->getData(true); + bindOrderChatRequest(); + $read = $controller->read('order_active')->getData(true); + $after = orderChatData($controller->show('order_active')); + $again = $controller->read('order_active')->getData(true); + + expect(collect($opened['messages'])->pluck('content')->all())->toBe(['Picked up your flowers', 'At the lobby', 'Which tower?']) + ->and($opened['messages'][0]['is_mine'])->toBeFalse() + ->and($opened['messages'][0]['sender']['role'])->toBe('driver') + ->and($opened['unread_count'])->toBe(3) + ->and(collect($page['messages'])->pluck('id')->all())->toBe([$second->public_id]) + ->and(collect($earlier['messages'])->pluck('id')->all())->toBe([$first->public_id]) + ->and($unknown->getStatusCode())->toBe(404) + ->and($all['messages'])->toHaveCount(3) + ->and($read)->toBe(['read' => 3]) + ->and($after['unread_count'])->toBe(0) + ->and($after['messages'][2]['read'])->toBeTrue() + ->and($again)->toBe(['read' => 0]); +}); + +test('reassigning the order moves the chat to the new driver', function () { + $controller = new OrderChatController(); + bindOrderChatRequest(); + $controller->show('order_active'); + orderChatDb()->table('orders')->where('public_id', 'order_active')->update(['driver_assigned_uuid' => 'second_driver_uuid']); + + $response = orderChatData($controller->show('order_active')); + + expect(collect($response['participants'])->pluck('name')->sort()->values()->all())->toBe(['Arun S.', 'Mei Tan']) + ->and(ChatChannel::query()->count())->toBe(1); +}); + +test('finished orders keep their chat readable but refuse new messages', function () { + $controller = new OrderChatController(); + bindOrderChatRequest(); + $controller->show('order_active'); + orderChatDb()->table('orders')->where('public_id', 'order_active')->update(['status' => 'completed']); + + $shown = orderChatData($controller->show('order_active')); + $request = bindOrderChatRequest('chat-secret', 'store_key', ['content' => 'Thanks!'], 'POST', SendOrderChatMessageRequest::class); + $sent = $controller->send($request, 'order_active'); + + expect($shown['status'])->toBe('closed') + ->and($sent->getStatusCode())->toBe(423) + ->and($sent->getData(true))->toBe(['error' => 'This chat closed when the order finished.', 'reason' => 'chat_closed']) + ->and(ChatMessage::query()->count())->toBe(0); +}); + +test('customers removed from a finished order chat cannot mark messages read until it is reopened', function () { + $controller = new OrderChatController(); + bindOrderChatRequest(); + $controller->show('order_active'); + $channel = ChatChannel::query()->first(); + // A channel whose customer participant is gone (for example removed by an operator) and + // that is not re-synced because the order has finished. + OrderChat::participantFor($channel, 'customer_user_uuid')->delete(); + orderChatDb()->table('orders')->where('public_id', 'order_active')->update(['status' => 'canceled']); + $read = $controller->read('order_active'); + orderChatDb()->table('orders')->where('public_id', 'order_active')->update(['status' => 'dispatched']); + $channel->refresh(); + + expect($read->getStatusCode())->toBe(403) + ->and(OrderChat::participantFor($channel, null))->toBeNull(); + + // Re-opening the chat for an active order restores the customer. + bindOrderChatRequest(); + $controller->show('order_active'); + expect(OrderChat::participantFor($channel, 'customer_user_uuid'))->not->toBeNull(); +}); + +test('a customer missing from an open chat cannot send messages', function () { + $controller = new class extends OrderChatController { + protected function resolveContext(string $id): array|Illuminate\Http\JsonResponse + { + $context = parent::resolveContext($id); + OrderChat::participantFor($context[2], 'customer_user_uuid')?->delete(); + + return $context; + } + }; + $request = bindOrderChatRequest('chat-secret', 'store_key', ['content' => 'Hi'], 'POST', SendOrderChatMessageRequest::class); + + $sent = $controller->send($request, 'order_active'); + $shown = orderChatData($controller->show('order_active')); + + expect($sent->getStatusCode())->toBe(403) + ->and($shown['unread_count'])->toBe(0) + ->and($shown['me'])->toBeNull(); +}); + +test('every chat action reports a missing context the same way', function () { + $controller = new OrderChatController(); + bindOrderChatRequest(null); + + expect($controller->messages(request(), 'order_active')->getStatusCode())->toBe(401) + ->and($controller->read('order_active')->getStatusCode())->toBe(401) + ->and($controller->send(SendOrderChatMessageRequest::create('/', 'POST', ['content' => 'x']), 'order_active')->getStatusCode())->toBe(401); +}); + +test('the chat observer ignores non-order chats, missing orders and customers, and customer messages', function () { + $observer = new ChatMessageObserver(); + $plain = ChatChannel::create(['company_uuid' => 'company_uuid', 'created_by_uuid' => 'driver_user_uuid', 'name' => 'Dispatch']); + $orphan = ChatChannel::create(['company_uuid' => 'company_uuid', 'created_by_uuid' => 'driver_user_uuid', 'name' => 'Old order', 'meta' => ['storefront_order_uuid' => 'gone_uuid']]); + $sender = ChatParticipant::where('chat_channel_uuid', $plain->uuid)->first(); + + $observer->created(new ChatMessage(['chat_channel_uuid' => 'missing_channel'])); + $observer->created(new ChatMessage(['chat_channel_uuid' => $plain->uuid, 'sender_uuid' => $sender->uuid])); + $observer->created(new ChatMessage(['chat_channel_uuid' => $orphan->uuid, 'sender_uuid' => $sender->uuid])); + + $order = Order::where('public_id', 'order_active')->first(); + $chat = OrderChat::open($order); + $customerParticipant = OrderChat::participantFor($chat, 'customer_user_uuid'); + $observer->created(new ChatMessage(['chat_channel_uuid' => $chat->uuid, 'sender_uuid' => $customerParticipant->uuid])); + $observer->created(new ChatMessage(['chat_channel_uuid' => $chat->uuid, 'sender_uuid' => 'missing_participant'])); + orderChatDb()->table('orders')->where('public_id', 'order_active')->update(['customer_uuid' => 'nobody']); + $observer->created(new ChatMessage(['chat_channel_uuid' => $chat->uuid, 'sender_uuid' => $customerParticipant->uuid])); + + expect($this->notifications->sent)->toBe([]); +}); + +test('order chat helpers find the order and refuse to open without accounts', function () { + $order = Order::where('public_id', 'order_active')->first(); + $chat = OrderChat::open($order); + orderChatDb()->table('contacts')->where('uuid', CHAT_CUSTOMER_UUID)->update(['user_uuid' => null]); + $guest = Order::where('public_id', 'order_active')->first(); + + expect(OrderChat::orderFor($chat)->uuid)->toBe('order_active_uuid') + ->and(OrderChat::orderFor(new ChatChannel()))->toBeNull() + ->and(OrderChat::find($order)->uuid)->toBe($chat->uuid) + ->and(OrderChat::open($guest))->toBeNull() + ->and(OrderChat::isClosed($order))->toBeFalse(); +}); + +test('chat notifications describe the driver message for push, inbox and sockets', function () { + $order = Order::where('public_id', 'order_network')->first(); + $chat = OrderChat::open($order); + $message = driverSays($chat, 'At the lobby', now()->toDateTimeString()); + $photo = driverSays($chat, ' ', now()->toDateTimeString()); + $anonymous = new ChatMessage(['content' => 'Hi']); + + $notification = new StorefrontOrderChatMessage($message->load(['sender.user', 'chatChannel']), $order); + $array = $notification->toArray(null); + $push = $notification->toPush(null); + + expect($notification->via(null))->toBe([Fleetbase\Storefront\Push\StorefrontPushChannel::class, 'database', Fleetbase\Storefront\Notifications\Channels\SafeBroadcastChannel::class]) + ->and($array)->toMatchArray([ + 'title' => 'Ravi K. sent a message', + 'body' => 'At the lobby', + 'type' => 'order_chat_message', + 'order_id' => 'order_network', + 'chat_id' => $chat->public_id, + 'message_id' => $message->public_id, + 'store_id' => 'store_bloom', + 'network_id' => 'network_market', + ]) + ->and($push)->toBeInstanceOf(Fleetbase\Storefront\Push\PushMessage::class) + ->and($notification->broadcastType())->toBe('order_chat_message') + ->and($notification->toBroadcast(null))->toBeInstanceOf(Illuminate\Notifications\Messages\BroadcastMessage::class) + ->and($notification->pushStorefronts())->toBeArray() + ->and((new StorefrontOrderChatMessage($photo, $order))->body())->toBe('Sent a photo') + ->and((new StorefrontOrderChatMessage($anonymous, $order))->title())->toBe('Your driver sent a message'); +}); + +test('send message requests need text or photos', function () { + session(['storefront_key' => 'store_key']); + $rules = (new SendOrderChatMessageRequest())->rules(); + session(['storefront_key' => null]); + + expect($rules)->toBe([ + 'content' => 'required_without:files|nullable|string|max:2000', + 'files' => 'required_without:content|array|max:4', + 'files.*.data' => 'required_with:files|string', + 'files.*.type' => 'required_with:files|string|starts_with:image/', + ]) + ->and((new SendOrderChatMessageRequest())->authorize())->toBeFalse(); +}); + +test('assigning a driver to a storefront order starts its chat', function () { + $event = (new ReflectionClass(Fleetbase\FleetOps\Events\OrderDriverAssigned::class))->newInstanceWithoutConstructor(); + $event->modelUuid = 'order_active_uuid'; + $event->modelClassNamespace = Order::class; + + (new Fleetbase\Storefront\Listeners\HandleOrderDriverAssigned())->handle($event); + + expect(ChatChannel::query()->count())->toBe(1) + ->and($this->notifications->sentTo(CHAT_CUSTOMER_UUID, Fleetbase\Storefront\Notifications\StorefrontOrderDriverAssigned::class))->toHaveCount(1); +}); + +test('a failure to start the chat does not stop the driver assignment notification', function () { + orderChatDb()->getSchemaBuilder()->drop('chat_channels'); + $reported = []; + app()->instance(Illuminate\Contracts\Debug\ExceptionHandler::class, new class($reported) implements Illuminate\Contracts\Debug\ExceptionHandler { + public function __construct(public array &$reported) + { + } + + public function report(Throwable $e) + { + $this->reported[] = $e; + } + + public function shouldReport(Throwable $e) + { + return true; + } + + public function render($request, Throwable $e) + { + throw $e; + } + + public function renderForConsole($output, Throwable $e) + { + } + }); + $event = (new ReflectionClass(Fleetbase\FleetOps\Events\OrderDriverAssigned::class))->newInstanceWithoutConstructor(); + $event->modelUuid = 'order_active_uuid'; + $event->modelClassNamespace = Order::class; + + (new Fleetbase\Storefront\Listeners\HandleOrderDriverAssigned())->handle($event); + app()->offsetUnset(Illuminate\Contracts\Debug\ExceptionHandler::class); + + expect($reported)->toHaveCount(1) + ->and($this->notifications->sentTo(CHAT_CUSTOMER_UUID, Fleetbase\Storefront\Notifications\StorefrontOrderDriverAssigned::class))->toHaveCount(1); +}); diff --git a/server/tests/Unit/Routes/StorefrontRoutesTest.php b/server/tests/Unit/Routes/StorefrontRoutesTest.php index b6a0dc2f..1fe112a3 100644 --- a/server/tests/Unit/Routes/StorefrontRoutesTest.php +++ b/server/tests/Unit/Routes/StorefrontRoutesTest.php @@ -97,5 +97,9 @@ private function record(string $method, string $uri, mixed $action): self ['FLEETBASE', 'orders', null], ['FLEETBASE', 'products', null], ['GET', '/', 'MetricsController@all'], + ['GET', '{id}/chat', 'OrderChatController@show'], + ['GET', '{id}/chat/messages', 'OrderChatController@messages'], + ['POST', '{id}/chat/messages', 'OrderChatController@send'], + ['POST', '{id}/chat/read', 'OrderChatController@read'], )->and(count($router->routes))->toBeGreaterThan(50); }); diff --git a/server/tests/Unit/Support/StorefrontSocketTest.php b/server/tests/Unit/Support/StorefrontSocketTest.php index aa7ec375..e0e606a8 100644 --- a/server/tests/Unit/Support/StorefrontSocketTest.php +++ b/server/tests/Unit/Support/StorefrontSocketTest.php @@ -118,8 +118,8 @@ function storefrontSocketSchema(): void ['uuid' => 'network-a', 'public_id' => 'network_aaaaaaa', 'company_uuid' => 'company-a', 'key' => 'network_key_a'], ]); $connection->table('contacts')->insert([ - ['uuid' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'public_id' => 'contact_aaaaaaa', 'company_uuid' => 'company-a', 'type' => 'customer'], - ['uuid' => 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', 'public_id' => 'contact_bbbbbbb', 'company_uuid' => 'company-b', 'type' => 'customer'], + ['uuid' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'public_id' => 'contact_aaaaaaa', 'company_uuid' => 'company-a', 'user_uuid' => 'user-a', 'type' => 'customer'], + ['uuid' => 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', 'public_id' => 'contact_bbbbbbb', 'company_uuid' => 'company-b', 'user_uuid' => null, 'type' => 'customer'], ]); $connection->table('checkouts')->insert([ ['uuid' => 'checkout-a', 'public_id' => 'chkt_aaaaaaa', 'company_uuid' => 'company-a', 'store_uuid' => 'store-a', 'network_uuid' => null, 'owner_uuid' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'token' => 'checkout_a'], @@ -202,7 +202,9 @@ function storefrontSocketPrincipal(string $kind, array $claims = []): SocketPrin ->and($claims)->toBeInstanceOf(SocketPrincipal::class) ->and($claims->kind)->toBe('customer') ->and($claims->sub)->toBe('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa') - ->and($claims->ids)->toBe(['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'contact_aaaaaaa']) + // The user uuid lets the customer join chats they take part in (core authorizes + // chat channels by participant user). + ->and($claims->ids)->toBe(['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'contact_aaaaaaa', 'user-a']) ->and($claims->cid)->toBe('company-a') ->and($claims->cpid)->toBe('company_aaaaaaa') ->and($claims->sid)->toBe('store-a') @@ -343,8 +345,11 @@ function storefrontSocketPrincipal(string $kind, array $claims = []): SocketPrin 'cid' => 'company-a', 'cpid' => 'company_aaaaaaa', 'env' => 'live', - 'ids' => ['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'contact_aaaaaaa'], + 'ids' => ['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'contact_aaaaaaa', 'user-a'], 'adm' => false, 'sid' => 'store-a', - ])->and($forNetwork->sid)->toBe('network-a'); + ])->and($forNetwork->sid)->toBe('network-a') + // A customer without a user account has only its contact ids. + ->and(StorefrontSocket::customerPrincipal(Contact::where('uuid', 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb')->firstOrFail(), $store)->ids) + ->toBe(['bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', 'contact_bbbbbbb']); }); From 4f5ba15ab77e7694cbf831524255e8358b194106 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 6 Oct 2026 21:48:25 +0800 Subject: [PATCH 007/193] feat(products): expose add-on category is_required and max_selectable on public product payloads --- server/src/Http/Resources/Product.php | 4 +++ .../Http/Resources/ProductResourceTest.php | 32 ++++++++++++++++--- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/server/src/Http/Resources/Product.php b/server/src/Http/Resources/Product.php index db403dbb..efc0a2d9 100644 --- a/server/src/Http/Resources/Product.php +++ b/server/src/Http/Resources/Product.php @@ -110,6 +110,8 @@ public function mapAddonCategories(Collection|array $addonCategories = []) 'id' => $addonCategory->id, 'name' => $addonCategory->name, 'excluded_addons' => $addonCategory->excluded_addons, + 'is_required' => (bool) $addonCategory->is_required, + 'max_selectable' => $addonCategory->max_selectable, 'category' => $addonCategory->category, 'created_at' => $addonCategory->created_at, 'updated_at' => $addonCategory->updated_at, @@ -121,6 +123,8 @@ public function mapAddonCategories(Collection|array $addonCategories = []) 'name' => data_get($addonCategory, 'name'), 'description' => data_get($addonCategory, 'category.description'), 'excluded_addons' => $addonCategory->excluded_addons, + 'is_required' => (bool) $addonCategory->is_required, + 'max_selectable' => $addonCategory->max_selectable ? (int) $addonCategory->max_selectable : null, 'addons' => $this->mapProductAddons($addons, $addonCategory->excluded_addons), ]; diff --git a/server/tests/Unit/Http/Resources/ProductResourceTest.php b/server/tests/Unit/Http/Resources/ProductResourceTest.php index 8d87e8cc..68d576f6 100644 --- a/server/tests/Unit/Http/Resources/ProductResourceTest.php +++ b/server/tests/Unit/Http/Resources/ProductResourceTest.php @@ -53,6 +53,8 @@ function storefrontProductResourceFixture(): ProductModel 'category_uuid' => 'addon_category_uuid', 'name' => 'Packaging', 'excluded_addons' => [], + 'is_required' => 1, + 'max_selectable' => '2', 'category' => (object) [ 'public_id' => 'addon_category_123', 'description'=> 'Packaging choices', @@ -155,9 +157,11 @@ function storefrontProductResourceFixture(): ProductModel 'files', 'type', ])->and($data['addon_categories'][0])->toMatchArray([ - 'id' => 'addon_category_123', - 'name' => 'Packaging', - 'description' => 'Packaging choices', + 'id' => 'addon_category_123', + 'name' => 'Packaging', + 'description' => 'Packaging choices', + 'is_required' => true, + 'max_selectable' => 2, ])->and($data['addon_categories'][0]['addons'][0])->toMatchArray([ 'id' => 'addon_123', 'name' => 'Gift wrap', @@ -192,7 +196,9 @@ function storefrontProductResourceFixture(): ProductModel 'uuid' => 'product_addon_category_uuid', 'product_uuid' => 'product_uuid', 'category_uuid' => 'addon_category_uuid', - 'public_id' => 'addon_category_123', + 'public_id' => 'addon_category_123', + 'is_required' => true, + 'max_selectable' => '2', ])->and($data['addon_categories'][0])->not->toHaveKey('addons') ->and($data['variants'][0])->toMatchArray([ 'id' => 40, @@ -244,3 +250,21 @@ function storefrontProductResourceFixture(): ProductModel 'name' => 'Gift wrap', ]); }); + +test('public add-on categories report optional unlimited choices when limits are unset', function () { + setStorefrontResourceRoute('v1/storefront/products'); + + $category = (object) [ + 'name' => 'Extras', + 'excluded_addons' => null, + 'is_required' => null, + 'max_selectable' => null, + 'category' => (object) ['public_id' => 'addon_category_456', 'description' => null, 'addons' => collect()], + ]; + + expect((new ProductResource((object) []))->mapAddonCategories([$category])->first())->toMatchArray([ + 'id' => 'addon_category_456', + 'is_required' => false, + 'max_selectable' => null, + ]); +}); From 2aeffa60360384d475c2f957eb55cf2212d5a4b4 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 6 Oct 2026 21:48:55 +0800 Subject: [PATCH 008/193] style: align test fixture keys --- server/tests/Unit/Http/Resources/ProductResourceTest.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/tests/Unit/Http/Resources/ProductResourceTest.php b/server/tests/Unit/Http/Resources/ProductResourceTest.php index 68d576f6..29300c13 100644 --- a/server/tests/Unit/Http/Resources/ProductResourceTest.php +++ b/server/tests/Unit/Http/Resources/ProductResourceTest.php @@ -192,10 +192,10 @@ function storefrontProductResourceFixture(): ProductModel ])->and($data)->toHaveKey('files') ->and($data)->not->toHaveKeys(['images', 'videos']) ->and($data['addon_categories'][0])->toMatchArray([ - 'id' => 20, - 'uuid' => 'product_addon_category_uuid', - 'product_uuid' => 'product_uuid', - 'category_uuid' => 'addon_category_uuid', + 'id' => 20, + 'uuid' => 'product_addon_category_uuid', + 'product_uuid' => 'product_uuid', + 'category_uuid' => 'addon_category_uuid', 'public_id' => 'addon_category_123', 'is_required' => true, 'max_selectable' => '2', From 868dfa29aec7acf7c998764f684c9fc1298aba8d Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 7 Oct 2026 00:06:30 +0800 Subject: [PATCH 009/193] fix(reviews): show reviewers as first name and last initial, without contact details --- server/src/Http/Resources/ReviewCustomer.php | 27 ++++++++++++++++--- .../RemainingResourceContractsTest.php | 21 +++++++++++++-- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/server/src/Http/Resources/ReviewCustomer.php b/server/src/Http/Resources/ReviewCustomer.php index 590cd748..b8dc6143 100644 --- a/server/src/Http/Resources/ReviewCustomer.php +++ b/server/src/Http/Resources/ReviewCustomer.php @@ -17,13 +17,17 @@ class ReviewCustomer extends FleetbaseResource */ public function toArray($request) { + $internal = Http::isInternalRequest(); + + // Reviews are public: customers see each other's first name and last initial only, + // never their contact details. return [ 'id' => $this->when(Http::isInternalRequest(), $this->id, Str::replaceFirst('contact', 'customer', $this->public_id)), 'uuid' => $this->when(Http::isInternalRequest(), $this->uuid), 'public_id' => $this->when(Http::isInternalRequest(), $this->public_id), - 'name' => $this->name, - 'email' => $this->email, - 'phone' => $this->phone, + 'name' => $internal ? $this->name : static::publicName($this->name), + 'email' => $this->when($internal, $this->email), + 'phone' => $this->when($internal, $this->phone), 'photo_url' => $this->photo_url, 'reviews_count' => $this->resource->reviews()->count(), 'uploads_count' => $this->resource->reviewUploads()->count(), @@ -32,4 +36,21 @@ public function toArray($request) 'updated_at' => $this->updated_at, ]; } + + /** + * "Ada B." from "Ada Buyer"; a single name is shown as is. + */ + public static function publicName(?string $name): ?string + { + $parts = preg_split('/\s+/u', trim((string) $name), -1, PREG_SPLIT_NO_EMPTY); + if (!$parts) { + return null; + } + + if (count($parts) === 1) { + return $parts[0]; + } + + return $parts[0] . ' ' . mb_strtoupper(mb_substr(end($parts), 0, 1)) . '.'; + } } diff --git a/server/tests/Unit/Http/Resources/RemainingResourceContractsTest.php b/server/tests/Unit/Http/Resources/RemainingResourceContractsTest.php index f81cfcd8..d83b7258 100644 --- a/server/tests/Unit/Http/Resources/RemainingResourceContractsTest.php +++ b/server/tests/Unit/Http/Resources/RemainingResourceContractsTest.php @@ -374,10 +374,27 @@ public function count(): int expect($data)->toMatchArray([ 'id' => 'customer_public', - 'name' => 'Ada Buyer', + 'name' => 'Ada B.', 'reviews_count' => 4, 'uploads_count' => 6, - ])->and($data)->not->toHaveKeys(['uuid', 'public_id']); + ])->and($data)->not->toHaveKeys(['uuid', 'public_id', 'email', 'phone']); + + $internal = (new ReviewCustomerResource($customer)) + ->resolve(setSimpleStorefrontResourceRoute('int/v1/storefront/review-customers')); + + expect($internal)->toMatchArray([ + 'name' => 'Ada Buyer', + 'email' => 'ada@example.test', + 'phone' => '+15550100', + ]); +}); + +test('review customers are named by first name and last initial in public', function () { + expect(ReviewCustomerResource::publicName(' ada van buyer '))->toBe('ada B.') + ->and(ReviewCustomerResource::publicName('Ada'))->toBe('Ada') + ->and(ReviewCustomerResource::publicName('Ölga ölund'))->toBe('Ölga Ö.') + ->and(ReviewCustomerResource::publicName(' '))->toBeNull() + ->and(ReviewCustomerResource::publicName(null))->toBeNull(); }); test('store hour and location resources preserve customer-facing scheduling shapes', function () { From da400f274d3274acabd736d1c58a35095ff34d2d Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 7 Oct 2026 01:05:48 +0800 Subject: [PATCH 010/193] fix(chat): advertise the chat.{id} socket channel core broadcasts messages on --- server/src/Http/Resources/OrderChat.php | 3 ++- server/tests/Unit/Http/Controllers/OrderChatControllerTest.php | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/server/src/Http/Resources/OrderChat.php b/server/src/Http/Resources/OrderChat.php index 16171f4e..e04d164e 100644 --- a/server/src/Http/Resources/OrderChat.php +++ b/server/src/Http/Resources/OrderChat.php @@ -37,7 +37,8 @@ public function toArray($request) return [ 'id' => $this->public_id, - 'channel' => 'chat_channel.' . $this->uuid, + // Core broadcasts chat messages on `chat.{public_id}` (and `chat.{uuid}`). + 'channel' => 'chat.' . $this->public_id, 'order' => $this->order->public_id, 'status' => $closed ? 'closed' : 'open', 'me' => data_get($me, 'public_id'), diff --git a/server/tests/Unit/Http/Controllers/OrderChatControllerTest.php b/server/tests/Unit/Http/Controllers/OrderChatControllerTest.php index 3db3b4b8..9b206316 100644 --- a/server/tests/Unit/Http/Controllers/OrderChatControllerTest.php +++ b/server/tests/Unit/Http/Controllers/OrderChatControllerTest.php @@ -489,7 +489,7 @@ public function clear(): void ->and(data_get($channel->meta, 'storefront_order_uuid'))->toBe('order_active_uuid') ->and(data_get($channel->meta, 'storefront_order_id'))->toBe('order_active') ->and($first['id'])->toBe($channel->public_id) - ->and($first['channel'])->toBe('chat_channel.' . $channel->uuid) + ->and($first['channel'])->toBe('chat.' . $channel->public_id) ->and($first['order'])->toBe('order_active') ->and($first['status'])->toBe('open') ->and($first['me'])->toBe(OrderChat::participantFor($channel, 'customer_user_uuid')->public_id) From 919c310b109d70ae74f4dd863cb7e33ca44b4bfe Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 7 Oct 2026 03:12:35 +0800 Subject: [PATCH 011/193] chore(release): v0.4.25 --- RELEASE.md | 15 +++++++++------ composer.json | 2 +- extension.json | 2 +- package.json | 2 +- 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index d938d0d4..7b7bcf30 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,16 +1,19 @@ -> v0.4.24 ~ "Extensions can add columns, actions and buttons to storefront tables and panels" +> v0.4.25 ~ "Verified reviews, order chat with the driver, richer public promotions and faster store listings" --- ## Highlights -- **Resource view registries.** Extensions can add the following through `storefront::table:` and `storefront::details:`: - - columns, row actions, bulk actions and toolbar buttons on the orders, customers, promotions, campaigns, segments and network tables; - - buttons and menu items on the order and customer panels. -- **Orders use the standard table layout.** +- **Verified-purchase reviews.** Reviews can be tied to a delivered order and are marked verified. A new eligibility endpoint tells the app whether a customer can review a store, and customers can delete their own reviews. Reviewer email and phone are no longer exposed publicly; public reviews show a short name such as "Ada B.". +- **Chat with the driver.** Customers can chat with the driver delivering their order: get the chat, page through messages, send text and photos, and mark messages read. New messages are broadcast on the chat's `chat.{id}` channel. +- **Public promotions.** Public promotion payloads now include the owning store, the shareable code, availability and the next start time. +- **Product options.** Public product payloads now include each add-on category's `is_required` and `max_selectable`. +- **Faster store listings.** Network and Console store listings are quicker. +- **Private review photos.** Review photos upload without a public ACL, so private buckets work. --- ## Upgrading -Needs fleetbase/ember-core v0.3.25 and fleetbase/ember-ui v0.4.5. +- Run migrations. This release adds an index on `reviews.subject_uuid` and an `order_uuid` column on `reviews`. +- Order chat needs storefront socket authentication and fleetbase/core-api v1.6.69 or later. --- ## Need help? diff --git a/composer.json b/composer.json index 267085d0..8dd05a4f 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "fleetbase/storefront-api", - "version": "0.4.24", + "version": "0.4.25", "description": "Headless Commerce & Marketplace Extension for Fleetbase", "keywords": [ "fleetbase-extension", diff --git a/extension.json b/extension.json index ab0097b4..592c0c87 100644 --- a/extension.json +++ b/extension.json @@ -1,6 +1,6 @@ { "name": "Storefront", - "version": "0.4.24", + "version": "0.4.25", "description": "Headless Commerce & Marketplace Extension for Fleetbase", "repository": "https://github.com/fleetbase/storefront", "license": "AGPL-3.0-or-later", diff --git a/package.json b/package.json index 275c0ec6..43ea033e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fleetbase/storefront-engine", - "version": "0.4.24", + "version": "0.4.25", "description": "Headless Commerce & Marketplace Extension for Fleetbase", "fleetbase": { "route": "storefront", From af474596f7b17d0832768207be25b2e1efc810a7 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 8 Oct 2026 13:27:58 +0800 Subject: [PATCH 012/193] feat(seeders): service stores, promotions, segments and campaigns in the testing seeders - Network seeder: a "Home & Wellness" category and two service stores tagged `services` (Lion City Cleaning, Tiong Bahru Wellness) with bookable products that carry a duration (`meta.duration`, 45 to 360 minutes), options, add-ons and a sale price. - Promotions for the network and individual stores, covering every type (percentage, fixed amount, free delivery, buy 2 get 1) and state (live, weekly schedule, weekends only, code only, first order only, upcoming, ended, draft), with codes WELCOME15, FIRSTCLEAN, MARKET5 and VIPPREVIEW. They target categories, products or stores. - Customer segments (loyal, recent, lapsed, never ordered, push enabled) and sent, scheduled and draft campaigns that deep-link to a promotion, store or product. Scheduled campaigns are due days ahead, so the scheduler doesn't send them straight away. - Single-store seeder: a smaller set of promotions, segments and campaigns. - Re-running purges seeded campaigns, promotions with their codes and redemptions, and segments. - Fix: re-running failed with "A customer with this email already exists". Both seeders used the same customer emails and phones in one company, and Fleet-Ops allows one customer profile per account there. The store seeder's customers now use `+market` emails and `+65 9101` phones. --- .../Concerns/SeedsStorefrontFixtures.php | 214 +++++++++++++++++- server/seeders/Testing/NetworkSeeder.php | 132 ++++++++++- server/seeders/Testing/StoreSeeder.php | 43 +++- 3 files changed, 378 insertions(+), 11 deletions(-) diff --git a/server/seeders/Testing/Concerns/SeedsStorefrontFixtures.php b/server/seeders/Testing/Concerns/SeedsStorefrontFixtures.php index f4b5abe1..5272338c 100644 --- a/server/seeders/Testing/Concerns/SeedsStorefrontFixtures.php +++ b/server/seeders/Testing/Concerns/SeedsStorefrontFixtures.php @@ -15,11 +15,13 @@ use Fleetbase\Models\Transaction; use Fleetbase\Models\TransactionItem; use Fleetbase\Storefront\Models\AddonCategory; +use Fleetbase\Storefront\Models\Campaign; use Fleetbase\Storefront\Models\Cart; use Fleetbase\Storefront\Models\Catalog; use Fleetbase\Storefront\Models\CatalogCategory; use Fleetbase\Storefront\Models\CatalogProduct; use Fleetbase\Storefront\Models\Checkout; +use Fleetbase\Storefront\Models\CustomerSegment; use Fleetbase\Storefront\Models\Gateway; use Fleetbase\Storefront\Models\Network; use Fleetbase\Storefront\Models\NetworkStore; @@ -29,6 +31,8 @@ use Fleetbase\Storefront\Models\ProductStatus; use Fleetbase\Storefront\Models\ProductVariant; use Fleetbase\Storefront\Models\ProductVariantOption; +use Fleetbase\Storefront\Models\Promotion; +use Fleetbase\Storefront\Models\PromotionCode; use Fleetbase\Storefront\Models\Review; use Fleetbase\Storefront\Models\Store; use Fleetbase\Storefront\Models\StoreHour; @@ -62,6 +66,7 @@ * 'categories' => ['produce' => ['name' => 'Fresh Produce', 'description' => '...']], * 'addon_categories' => ['gift' => ['name' => ..., 'description' => ..., 'max_selectable' => 2, 'is_required' => false, 'addons' => [['Gift Wrap', 'desc', 350]]]], * 'products' => ['orchard-box' => ['name' => ..., 'description' => ..., 'price' => 2850, 'category' => 'produce', 'tags' => [], 'recommended' => true, + * 'is_service' => false, 'is_bookable' => false, 'meta' => ['duration' => 60], // services: duration in minutes * 'variants' => [['name' => 'Box Size', 'required' => true, 'multiselect' => false, 'options' => [['Small', 0], ['Family', 1200]]]], * 'addon_categories' => ['gift']]], * 'catalog' => ['name' => ..., 'description' => ..., 'categories' => ['Fresh Picks' => ['orchard-box', 'market-veg']]], @@ -97,10 +102,18 @@ protected function purgeStorefrontFixtures(): void $orderUuids = $this->seededUuids(Order::class); $transactionUuids = $this->seededUuids(Transaction::class); $customerUuids = $this->seededUuids(Contact::class); + $promotionUuids = $this->seededUuids(Promotion::class); $storeLocationUuids = Schema::connection($this->storefrontConnection())->hasTable('store_locations') ? DB::connection($this->storefrontConnection())->table('store_locations')->whereIn('store_uuid', $storeUuids)->pluck('uuid')->all() : []; + // Marketing: campaigns point at segments and promotions; codes and redemptions at promotions + $this->purgeModel(Campaign::class); + $this->deleteFrom($this->storefrontConnection(), 'promotion_redemptions', fn ($query) => $query->whereIn('promotion_uuid', $promotionUuids)); + $this->deleteFrom($this->storefrontConnection(), 'promotion_codes', fn ($query) => $query->whereIn('promotion_uuid', $promotionUuids)->orWhereIn('meta->seed', $this->seedNames())); + $this->purgeModel(Promotion::class); + $this->purgeModel(CustomerSegment::class); + // Orders, payments and logistics records $this->purgeSeededLedgerJournals($orderUuids); $this->deleteFrom($this->fleetbaseConnection(), 'transaction_items', fn ($query) => $query->whereIn('transaction_uuid', $transactionUuids)->orWhereIn('meta->seed', $this->seedNames())); @@ -177,7 +190,7 @@ protected function purgeSeededLedgerJournals(array $orderUuids): void * a location with opening hours, product categories, products with variants and * addons, a published catalog and (optionally) a Stripe gateway. * - * @return array{store: Store, location: StoreLocation|null, place: Place|null, products: array, gateway: Gateway|null} + * @return array{store: Store, location: StoreLocation|null, place: Place|null, products: array, categories: array, gateway: Gateway|null} */ protected function seedStore(Company $company, array $definition): array { @@ -248,11 +261,12 @@ protected function seedStore(Company $company, array $definition): array } return [ - 'store' => $store, - 'location' => $location, - 'place' => $place, - 'products' => $products, - 'gateway' => $gateway, + 'store' => $store, + 'location' => $location, + 'place' => $place, + 'products' => $products, + 'categories' => $categories, + 'gateway' => $gateway, ]; } @@ -369,7 +383,7 @@ protected function createProduct(Company $company, Store $store, string $storeKe 'name' => $product['name'], 'description' => $product['description'] ?? null, 'tags' => $product['tags'] ?? [], - 'meta' => $this->meta('product:' . $storeKey . ':' . $productKey), + 'meta' => $this->meta('product:' . $storeKey . ':' . $productKey, $product['meta'] ?? []), 'sku' => $product['sku'] ?? 'SF-' . Str::upper(Str::slug($storeKey . '-' . $productKey)), 'price' => (int) $product['price'], 'currency' => $product['currency'] ?? $store->currency, @@ -493,6 +507,178 @@ protected function createCatalog(Company $company, Store $store, string $storeKe return $model; } + /* + |-------------------------------------------------------------------------- + | Marketing + |-------------------------------------------------------------------------- + */ + + /** + * Seed promotions (with their codes), customer segments and campaigns for a store or + * network. Product and category references are ":" + * against the seeded store bundles; dates are relative to now: + * + * [ + * 'promotions' => ['welcome' => ['name' => ..., 'type' => 'percentage', 'value' => 15, 'trigger' => 'code', 'code' => 'WELCOME15', + * 'starts_in_days' => -30, 'ends_in_days' => 30, 'applies_to' => ['products' => ['store:product']], ...]], + * 'segments' => ['loyal' => ['name' => ..., 'description' => ..., 'rules' => ['min_orders' => 3]]], + * 'campaigns' => ['launch' => ['name' => ..., 'title' => ..., 'body' => ..., 'status' => 'sent', 'segment' => 'loyal', 'promotion' => 'welcome', + * 'action' => ['type' => 'promotion'], 'sent_days_ago' => 2]], + * ] + * + * @param array, categories: array}> $bundles + * + * @return array{promotions: array, segments: array, campaigns: array} + */ + protected function seedMarketing(Company $company, Store|Network $owner, string $ownerKey, array $definition, array $bundles): array + { + $ownerType = $owner instanceof Network ? 'storefront:network' : 'storefront:store'; + $refs = $this->marketingReferences($bundles); + + $promotions = []; + foreach ($definition['promotions'] ?? [] as $key => $promotion) { + $promotions[$key] = $this->createPromotion($company, $owner, $ownerType, $ownerKey, $key, $promotion, $refs); + } + + $segments = []; + foreach ($definition['segments'] ?? [] as $key => $segment) { + $segments[$key] = $this->createRecord(CustomerSegment::class, [ + 'company_uuid' => $company->uuid, + 'created_by_uuid' => session('user'), + 'owner_uuid' => $owner->uuid, + 'owner_type' => $ownerType, + 'name' => $segment['name'], + 'description' => $segment['description'] ?? null, + 'rules' => $segment['rules'] ?? [], + 'meta' => $this->meta('segment:' . $ownerKey . ':' . $key), + ]); + } + + $campaigns = []; + foreach ($definition['campaigns'] ?? [] as $key => $campaign) { + $campaigns[$key] = $this->createCampaign($company, $owner, $ownerType, $ownerKey, $key, $campaign, $promotions, $segments, $refs); + } + + return ['promotions' => $promotions, 'segments' => $segments, 'campaigns' => $campaigns]; + } + + /** + * Seeded stores, products and categories by reference ("" and ":"). + * + * @return array{stores: array, products: array, categories: array} + */ + protected function marketingReferences(array $bundles): array + { + $refs = ['stores' => [], 'products' => [], 'categories' => []]; + foreach ($bundles as $storeKey => $bundle) { + $refs['stores'][$storeKey] = $bundle['store']; + foreach ($bundle['products'] ?? [] as $key => $product) { + $refs['products'][$storeKey . ':' . $key] = $product; + } + foreach ($bundle['categories'] ?? [] as $key => $category) { + $refs['categories'][$storeKey . ':' . $key] = $category; + } + } + + return $refs; + } + + protected function createPromotion(Company $company, Store|Network $owner, string $ownerType, string $ownerKey, string $key, array $promotion, array $refs): Promotion + { + $uuids = fn (string $kind, array $keys) => array_values(array_filter(array_map(fn ($ref) => ($refs[$kind][$ref] ?? null)?->uuid, $keys))); + + $appliesTo = []; + foreach (['products' => 'products', 'categories' => 'categories', 'stores' => 'stores', 'exclude_products' => 'products', 'exclude_categories' => 'categories'] as $field => $kind) { + if (!empty($promotion['applies_to'][$field])) { + $appliesTo[$field] = $uuids($kind, (array) $promotion['applies_to'][$field]); + } + } + + $record = $this->createRecord(Promotion::class, [ + 'company_uuid' => $company->uuid, + 'created_by_uuid' => session('user'), + 'owner_uuid' => $owner->uuid, + 'owner_type' => $ownerType, + 'name' => $promotion['name'], + 'description' => $promotion['description'] ?? null, + 'status' => $promotion['status'] ?? Promotion::STATUS_ACTIVE, + 'trigger' => isset($promotion['code']) ? Promotion::TRIGGER_CODE : Promotion::TRIGGER_AUTOMATIC, + 'type' => $promotion['type'], + 'value' => $promotion['value'] ?? null, + 'max_discount_amount' => $promotion['max_discount_amount'] ?? null, + 'currency' => $promotion['currency'] ?? $owner->currency ?? 'USD', + 'min_subtotal' => $promotion['min_subtotal'] ?? null, + 'min_items' => $promotion['min_items'] ?? null, + 'applies_to' => $appliesTo ?: null, + 'bogo_config' => $promotion['bogo_config'] ?? null, + 'first_order_only' => $promotion['first_order_only'] ?? false, + 'usage_limit' => $promotion['usage_limit'] ?? null, + 'usage_limit_per_customer' => $promotion['usage_limit_per_customer'] ?? null, + 'budget_amount' => $promotion['budget_amount'] ?? null, + 'stackable' => $promotion['stackable'] ?? false, + 'priority' => $promotion['priority'] ?? 0, + 'is_public' => $promotion['is_public'] ?? true, + 'starts_at' => isset($promotion['starts_in_days']) ? now()->addDays($promotion['starts_in_days'])->startOfDay() : null, + 'ends_at' => isset($promotion['ends_in_days']) ? now()->addDays($promotion['ends_in_days'])->endOfDay() : null, + 'schedule' => $promotion['schedule'] ?? null, + 'timezone' => $promotion['timezone'] ?? $owner->timezone ?? null, + 'meta' => $this->meta('promotion:' . $ownerKey . ':' . $key), + ]); + + if (isset($promotion['code'])) { + $this->createRecord(PromotionCode::class, [ + 'company_uuid' => $company->uuid, + 'promotion_uuid' => $record->uuid, + 'code' => $promotion['code'], + 'status' => 'active', + 'meta' => $this->meta('promotion-code:' . $ownerKey . ':' . $key), + ]); + } + + return $record; + } + + /** + * A campaign in any state. Sent campaigns get `sent_at` and stats; scheduled ones are + * due `send_in_days` from now, far enough out that the scheduler leaves them alone. + */ + protected function createCampaign(Company $company, Store|Network $owner, string $ownerType, string $ownerKey, string $key, array $campaign, array $promotions, array $segments, array $refs): Campaign + { + $status = $campaign['status'] ?? Campaign::STATUS_DRAFT; + $promotion = isset($campaign['promotion']) ? ($promotions[$campaign['promotion']] ?? null) : null; + $action = $campaign['action'] ?? null; + if ($action) { + $target = match ($action['type'] ?? null) { + 'promotion' => $promotion, + 'store' => $refs['stores'][$action['ref'] ?? ''] ?? null, + 'product' => $refs['products'][$action['ref'] ?? ''] ?? null, + default => null, + }; + $action = array_filter(['type' => $action['type'], 'id' => $target?->public_id, 'url' => $action['url'] ?? null]); + } + $sentAt = $status === Campaign::STATUS_SENT ? now()->subDays($campaign['sent_days_ago'] ?? 1)->setTime(10, 0) : null; + + return $this->createRecord(Campaign::class, [ + 'company_uuid' => $company->uuid, + 'created_by_uuid' => session('user'), + 'owner_uuid' => $owner->uuid, + 'owner_type' => $ownerType, + 'segment_uuid' => isset($campaign['segment']) ? ($segments[$campaign['segment']] ?? null)?->uuid : null, + 'promotion_uuid' => $promotion?->uuid, + 'name' => $campaign['name'], + 'status' => $status, + 'channels' => $campaign['channels'] ?? [Campaign::CHANNEL_PUSH, Campaign::CHANNEL_INBOX], + 'title' => $campaign['title'], + 'body' => $campaign['body'], + 'action' => $action ?: null, + 'send_at' => $status === Campaign::STATUS_SCHEDULED ? now()->addDays($campaign['send_in_days'] ?? 7)->setTime(10, 0) : $sentAt, + 'started_at' => $sentAt, + 'sent_at' => $sentAt, + 'stats' => $status === Campaign::STATUS_SENT ? ($campaign['stats'] ?? ['targeted' => 0, 'batches' => 0]) : null, + 'meta' => $this->meta('campaign:' . $ownerKey . ':' . $key), + ]); + } + /* |-------------------------------------------------------------------------- | Networks @@ -561,7 +747,19 @@ protected function addStoreToNetwork(Network $network, Store $store, ?Category $ */ protected function seedCustomers(Company $company, array $fixtures): array { - return array_values(array_map(fn (array $fixture) => $this->createCustomer($company, ...$fixture), $fixtures)); + return array_values(array_map(fn (array $fixture) => $this->createCustomer($company, ...$this->customerIdentity($fixture)), $fixtures)); + } + + /** + * The [name, email, phone] a seeder gives one of the shared customer fixtures. + * + * Fleet-Ops allows one customer profile per account (matched by email or phone) in a + * company, and both seeders run in the same company, so a seeder sharing the fixtures + * must give its customers identities of their own. + */ + protected function customerIdentity(array $fixture): array + { + return $fixture; } protected function createCustomer(Company $company, string $name, string $email, string $phone): Contact diff --git a/server/seeders/Testing/NetworkSeeder.php b/server/seeders/Testing/NetworkSeeder.php index 88d0516f..fefdba66 100644 --- a/server/seeders/Testing/NetworkSeeder.php +++ b/server/seeders/Testing/NetworkSeeder.php @@ -13,10 +13,16 @@ * Produces a network with an order config and a sandbox Stripe gateway, network * store categories, and several member stores each with their own location, product * categories, products, catalog and (for some) their own Stripe gateway. One store is - * left without a network category to exercise the `without_category` filters. Shared + * left without a network category to exercise the `without_category` filters. Two + * service stores (tagged `services`) sell bookable services with durations. Shared * customers place orders across every store so network-level dashboards, order * listings and analytics have data. * + * Marketing: network and store promotions covering every type (percentage, fixed + * amount, free delivery, buy X get Y) and state (live, happy-hour schedule, code only, + * first order only, upcoming, ended, draft), customer segments, and sent, scheduled and + * draft campaigns. + * * Re-running the seeder purges and recreates its own fixtures only. * * php artisan db:seed --class="Fleetbase\\Storefront\\Seeders\\Testing\\NetworkSeeder" @@ -65,10 +71,21 @@ public function run(): void $orders = $this->seedStoreActivity($company, $bundle, $customers, $network, $definition['order_count'] ?? 12); $orderCount += count($orders); - $bundles[] = $bundle; + $bundles[$definition['key']] = $bundle; + } + + $marketing = $this->seedMarketing($company, $network, static::NETWORK_KEY, $this->marketingDefinition(), $bundles); + foreach ($this->storeMarketingDefinitions() as $storeKey => $definition) { + if (isset($bundles[$storeKey])) { + $storeMarketing = $this->seedMarketing($company, $bundles[$storeKey]['store'], $storeKey, $definition, $bundles); + foreach ($storeMarketing as $kind => $records) { + $marketing[$kind] = array_merge($marketing[$kind], array_values($records)); + } + } } $this->command?->info(sprintf('Seeded Storefront testing network for company %s with %d stores and %d orders.', $company->public_id, count($bundles), $orderCount)); + $this->command?->info(sprintf(' Marketing: %d promotions, %d segments, %d campaigns.', count($marketing['promotions']), count($marketing['segments']), count($marketing['campaigns']))); $this->reportStorefront($network, $gateway, ' '); foreach ($bundles as $bundle) { $this->reportStorefront($bundle['store'], $bundle['gateway'], ' '); @@ -107,6 +124,7 @@ protected function networkCategories(): array 'groceries' => ['name' => 'Groceries', 'description' => 'Supermarkets and fresh food.', 'icon' => 'basket-shopping', 'icon_color' => '#16a34a', 'tags' => ['food', 'fresh']], 'restaurants' => ['name' => 'Restaurants', 'description' => 'Prepared meals for delivery and pickup.', 'icon' => 'utensils', 'icon_color' => '#ea580c', 'tags' => ['food', 'meals']], 'health' => ['name' => 'Health & Beauty', 'description' => 'Pharmacies and personal care.', 'icon' => 'heart-pulse', 'icon_color' => '#2563eb', 'tags' => ['pharmacy']], + 'services' => ['name' => 'Home & Wellness', 'description' => 'Book cleaners, stylists and therapists.', 'icon' => 'calendar-check', 'icon_color' => '#7c3aed', 'tags' => ['services']], ]; } @@ -247,6 +265,116 @@ protected function storeDefinitions(): array ], 'catalog' => ['name' => 'Marina Flowers Catalog', 'categories' => ['Bouquets' => ['rose-bouquet', 'mixed-bouquet']]], ], + [ + 'key' => 'lion-city-cleaning', + 'network_category' => 'services', + 'order_count' => 5, + 'name' => 'Lion City Cleaning', + 'description' => 'Home cleaning by vetted cleaners. Book a date and start time.', + 'email' => 'cleaning@example.test', + 'phone' => '+65 6100 0306', + 'tags' => ['services', 'cleaning', 'home'], + 'gateway' => 'stripe', + 'location' => ['name' => 'Lion City Cleaning', 'street1' => '7 Raffles Place', 'city' => 'Singapore', 'postal_code' => '048616', 'country' => 'SG', 'lat' => 1.2840, 'lng' => 103.8515], + 'hours' => ['start' => '08:00', 'end' => '20:00', 'closed' => ['sunday']], + 'categories' => [ + 'cleaning' => ['name' => 'Home Cleaning', 'description' => 'Regular and deep cleans.'], + 'specialty' => ['name' => 'Specialty', 'description' => 'Appliances, windows and upholstery.'], + ], + 'addon_categories' => [ + 'extras' => ['name' => 'Extras', 'description' => 'Add to your clean.', 'max_selectable' => 3, 'is_required' => false, 'addons' => [['Inside the Fridge', 'Empty, wipe and restock.', 1500], ['Inside the Oven', 'Degrease racks and walls.', 2000], ['Ironing (1 hour)', 'Shirts and linens.', 2500]]], + ], + 'products' => [ + 'standard-clean' => ['name' => 'Standard Home Clean', 'description' => 'Dusting, floors, kitchen and bathrooms.', 'price' => 6500, 'category' => 'cleaning', 'tags' => ['cleaning'], 'recommended' => true, 'is_service' => true, 'is_bookable' => true, 'can_pickup' => false, 'meta' => ['duration' => 180], 'variants' => [['name' => 'Home Size', 'required' => true, 'multiselect' => false, 'options' => [['Studio / 1 bedroom', 0], ['2 bedrooms', 2000], ['3+ bedrooms', 4000]]]], 'addon_categories' => ['extras']], + 'deep-clean' => ['name' => 'Deep Clean', 'description' => 'Top to bottom, including inside cabinets.', 'price' => 14500, 'category' => 'cleaning', 'tags' => ['cleaning'], 'is_service' => true, 'is_bookable' => true, 'can_pickup' => false, 'meta' => ['duration' => 360], 'addon_categories' => ['extras']], + 'aircon-service' => ['name' => 'Aircon Servicing', 'description' => 'Filter wash and coil check, per unit.', 'price' => 3500, 'category' => 'specialty', 'tags' => ['aircon'], 'is_service' => true, 'is_bookable' => true, 'can_pickup' => false, 'meta' => ['duration' => 45]], + 'sofa-clean' => ['name' => 'Sofa Shampoo', 'description' => 'Fabric sofa, up to 3 seats.', 'price' => 8900, 'sale_price' => 7500, 'category' => 'specialty', 'tags' => ['upholstery'], 'is_service' => true, 'is_bookable' => true, 'can_pickup' => false, 'meta' => ['duration' => 90]], + ], + 'catalog' => ['name' => 'Lion City Cleaning Services', 'categories' => ['Home Cleaning' => ['standard-clean', 'deep-clean'], 'Specialty' => ['aircon-service', 'sofa-clean']]], + ], + [ + 'key' => 'tiong-bahru-wellness', + 'network_category' => 'services', + 'order_count' => 5, + 'name' => 'Tiong Bahru Wellness', + 'description' => 'Hair, nails and massage. Book a time at the studio.', + 'email' => 'wellness@example.test', + 'phone' => '+65 6100 0307', + 'tags' => ['services', 'salon', 'spa'], + 'gateway' => null, + 'location' => ['name' => 'Tiong Bahru Wellness', 'street1' => '55 Tiong Bahru Road', 'city' => 'Singapore', 'postal_code' => '160055', 'country' => 'SG', 'lat' => 1.2860, 'lng' => 103.8302], + 'hours' => ['start' => '10:00', 'end' => '21:00', 'closed' => ['monday']], + 'categories' => [ + 'hair' => ['name' => 'Hair', 'description' => 'Cuts and colour.'], + 'massage' => ['name' => 'Massage', 'description' => 'Relaxation and deep tissue.'], + 'nails' => ['name' => 'Nails', 'description' => 'Manicures and pedicures.'], + ], + 'products' => [ + 'haircut' => ['name' => 'Haircut & Blow Dry', 'description' => 'Consultation, wash, cut and style.', 'price' => 4800, 'category' => 'hair', 'tags' => ['hair'], 'recommended' => true, 'is_service' => true, 'is_bookable' => true, 'meta' => ['duration' => 60], 'variants' => [['name' => 'Stylist', 'required' => true, 'multiselect' => false, 'options' => [['Stylist', 0], ['Senior Stylist', 1500]]]]], + 'hair-colour' => ['name' => 'Full Colour', 'description' => 'Single process colour with gloss.', 'price' => 12000, 'category' => 'hair', 'tags' => ['colour'], 'is_service' => true, 'is_bookable' => true, 'meta' => ['duration' => 120]], + 'swedish-60' => ['name' => 'Swedish Massage 60 min', 'description' => 'Full body, light to medium pressure.', 'price' => 9800, 'category' => 'massage', 'tags' => ['massage'], 'recommended' => true, 'is_service' => true, 'is_bookable' => true, 'meta' => ['duration' => 60]], + 'deep-tissue-90' => ['name' => 'Deep Tissue Massage 90 min', 'description' => 'Firm pressure for tight muscles.', 'price' => 14800, 'category' => 'massage', 'tags' => ['massage'], 'is_service' => true, 'is_bookable' => true, 'meta' => ['duration' => 90]], + 'gel-manicure' => ['name' => 'Gel Manicure', 'description' => 'Shape, cuticle care and gel colour.', 'price' => 4500, 'category' => 'nails', 'tags' => ['nails'], 'is_service' => true, 'is_bookable' => true, 'meta' => ['duration' => 45]], + ], + 'catalog' => ['name' => 'Tiong Bahru Wellness Menu', 'categories' => ['Hair' => ['haircut', 'hair-colour'], 'Massage' => ['swedish-60', 'deep-tissue-90'], 'Nails' => ['gel-manicure']]], + ], + ]; + } + + /** + * Network-wide promotions, segments and campaigns (owned by the network). + */ + protected function marketingDefinition(): array + { + return [ + 'promotions' => [ + 'welcome' => ['name' => 'Welcome to Fleetbase Marketplace', 'description' => '15% off your first order, up to $10.', 'type' => 'percentage', 'value' => 15, 'max_discount_amount' => 1000, 'min_subtotal' => 2000, 'first_order_only' => true, 'code' => 'WELCOME15', 'usage_limit_per_customer' => 1, 'starts_in_days' => -30, 'ends_in_days' => 60], + 'free-delivery' => ['name' => 'Free delivery over $40', 'description' => 'Delivery is on us when your basket is $40 or more.', 'type' => 'free_delivery', 'min_subtotal' => 4000, 'starts_in_days' => -14, 'ends_in_days' => 30, 'priority' => 5], + 'weekend-five' => ['name' => '$5 off weekend orders', 'description' => 'Every Saturday and Sunday, $5 off orders of $25 or more.', 'type' => 'fixed_amount', 'value' => 500, 'min_subtotal' => 2500, 'schedule' => [['days' => [6, 7], 'start' => '00:00', 'end' => '23:59']], 'starts_in_days' => -7, 'ends_in_days' => 45], + 'services-launch' => ['name' => 'Home & Wellness launch', 'description' => '20% off any booked service.', 'type' => 'percentage', 'value' => 20, 'max_discount_amount' => 3000, 'applies_to' => ['stores' => ['lion-city-cleaning', 'tiong-bahru-wellness']], 'starts_in_days' => 3, 'ends_in_days' => 33], + 'spring-sale' => ['name' => 'Spring sale', 'description' => '10% off everything (ended).', 'type' => 'percentage', 'value' => 10, 'starts_in_days' => -40, 'ends_in_days' => -10], + 'vip-preview' => ['name' => 'VIP preview (draft)', 'description' => 'Not published yet.', 'type' => 'percentage', 'value' => 25, 'status' => 'draft', 'is_public' => false, 'code' => 'VIPPREVIEW'], + ], + 'segments' => [ + 'loyal' => ['name' => 'Loyal customers', 'description' => 'Three or more orders.', 'rules' => ['min_orders' => 3]], + 'recent' => ['name' => 'Ordered this month', 'description' => 'At least one order in the last 30 days.', 'rules' => ['ordered_within_days' => 30]], + 'lapsed' => ['name' => 'Lapsed customers', 'description' => 'Ordered before, but not in the last 14 days.', 'rules' => ['not_ordered_within_days' => 14]], + 'never' => ['name' => 'Never ordered', 'description' => 'Signed up but no orders yet.', 'rules' => ['max_orders' => 0]], + 'push-ready' => ['name' => 'Push enabled', 'description' => 'Customers with a registered push device.', 'rules' => ['has_push_device' => true]], + ], + 'campaigns' => [ + 'welcome-sent' => ['name' => 'Welcome offer', 'title' => '15% off your first order', 'body' => 'Use WELCOME15 at checkout. Ends soon.', 'status' => 'sent', 'segment' => 'never', 'promotion' => 'welcome', 'action' => ['type' => 'promotion'], 'sent_days_ago' => 3, 'stats' => ['targeted' => 0, 'batches' => 0]], + 'delivery-sent' => ['name' => 'Free delivery week', 'title' => 'Free delivery over $40', 'body' => 'Stock up this week, delivery is on us.', 'status' => 'sent', 'segment' => 'loyal', 'promotion' => 'free-delivery', 'action' => ['type' => 'promotion'], 'sent_days_ago' => 1, 'stats' => ['targeted' => 12, 'batches' => 1]], + 'services-soon' => ['name' => 'Home & Wellness launch', 'title' => 'Book a clean or a massage', 'body' => 'New on Fleetbase Marketplace: 20% off booked services.', 'status' => 'scheduled', 'segment' => 'recent', 'promotion' => 'services-launch', 'action' => ['type' => 'store', 'ref' => 'lion-city-cleaning'], 'send_in_days' => 14], + 'winback-draft' => ['name' => 'We miss you (draft)', 'title' => 'Come back for $5 off', 'body' => 'Here is $5 off your next weekend order.', 'status' => 'draft', 'segment' => 'lapsed', 'promotion' => 'weekend-five'], + ], + ]; + } + + /** + * Promotions and campaigns owned by individual member stores, by store key. + */ + protected function storeMarketingDefinitions(): array + { + return [ + 'orchard-grocers' => [ + 'promotions' => [ + 'bakery-happy-hour' => ['name' => 'Bakery happy hour', 'description' => '30% off bakery, weekdays 5 to 8 pm.', 'type' => 'percentage', 'value' => 30, 'applies_to' => ['categories' => ['orchard-grocers:bakery']], 'schedule' => [['days' => [1, 2, 3, 4, 5], 'start' => '17:00', 'end' => '20:00']], 'starts_in_days' => -10, 'ends_in_days' => 50], + ], + 'campaigns' => [ + 'bakery-sent' => ['name' => 'Bakery happy hour', 'title' => '30% off fresh bread after 5', 'body' => 'Sourdough and croissants, weekdays 5 to 8 pm.', 'status' => 'sent', 'promotion' => 'bakery-happy-hour', 'action' => ['type' => 'product', 'ref' => 'orchard-grocers:sourdough'], 'sent_days_ago' => 2, 'stats' => ['targeted' => 8, 'batches' => 1]], + ], + ], + 'rochor-noodle-house' => [ + 'promotions' => [ + 'dumpling-bogo' => ['name' => 'Buy 2 dumplings, get 1 free', 'description' => 'On any dumpling plate.', 'type' => 'bogo', 'bogo_config' => ['buy_quantity' => 2, 'get_quantity' => 1, 'discount_percent' => 100], 'applies_to' => ['products' => ['rochor-noodle-house:pork-dumplings', 'rochor-noodle-house:veg-dumplings']], 'starts_in_days' => -5, 'ends_in_days' => 25], + ], + ], + 'lion-city-cleaning' => [ + 'promotions' => [ + 'first-clean' => ['name' => '$20 off your first clean', 'description' => 'For new customers, on any home clean.', 'type' => 'fixed_amount', 'value' => 2000, 'min_subtotal' => 6000, 'first_order_only' => true, 'code' => 'FIRSTCLEAN', 'applies_to' => ['categories' => ['lion-city-cleaning:cleaning']], 'starts_in_days' => -20, 'ends_in_days' => 70], + ], + ], ]; } } diff --git a/server/seeders/Testing/StoreSeeder.php b/server/seeders/Testing/StoreSeeder.php index f2f18fff..3433cd84 100644 --- a/server/seeders/Testing/StoreSeeder.php +++ b/server/seeders/Testing/StoreSeeder.php @@ -12,7 +12,8 @@ * Produces a store with an order config, a store location with opening hours, * a sandbox Stripe gateway, product categories, products with variants and addons, * a published catalog, customers, an open cart, a pending checkout, a month of - * captured orders (cash and Stripe, delivery and pickup) and reviews. + * captured orders (cash and Stripe, delivery and pickup), reviews, and promotions, + * customer segments and campaigns in several states. * * Re-running the seeder purges and recreates its own fixtures only. * @@ -45,8 +46,10 @@ public function run(): void $bundle = $this->seedStore($company, $this->storeDefinition()); $customers = $this->seedCustomers($company, $this->customerFixtures()); $orders = $this->seedStoreActivity($company, $bundle, $customers, null, 30); + $marketing = $this->seedMarketing($company, $bundle['store'], static::STORE_KEY, $this->marketingDefinition(), [static::STORE_KEY => $bundle]); $this->command?->info(sprintf('Seeded Storefront testing store for company %s with %d products and %d orders.', $company->public_id, count($bundle['products']), count($orders))); + $this->command?->info(sprintf(' Marketing: %d promotions, %d segments, %d campaigns.', count($marketing['promotions']), count($marketing['segments']), count($marketing['campaigns']))); $this->reportStorefront($bundle['store'], $bundle['gateway'], ' '); } @@ -55,6 +58,18 @@ public function purgeSeedData(): void $this->purgeStorefrontFixtures(); } + /** + * The network seeder uses the same customer fixtures in the same company, so this + * store's customers get their own emails (`ava.chen+market@example.test`) and phones + * (`+65 9101 …`). + */ + protected function customerIdentity(array $fixture): array + { + [$name, $email, $phone] = $fixture; + + return [$name, str_replace('@', '+market@', $email), str_replace('+65 9100 ', '+65 9101 ', $phone)]; + } + protected function storeDefinition(): array { return [ @@ -214,4 +229,30 @@ protected function storeDefinition(): array ], ]; } + + /** + * The store's own promotions, segments and campaigns. + */ + protected function marketingDefinition(): array + { + $key = static::STORE_KEY; + + return [ + 'promotions' => [ + 'coffee-hour' => ['name' => 'Coffee hour', 'description' => '20% off beverages, every morning 7 to 10.', 'type' => 'percentage', 'value' => 20, 'applies_to' => ['categories' => [$key . ':beverages']], 'schedule' => [['days' => [1, 2, 3, 4, 5, 6, 7], 'start' => '07:00', 'end' => '10:00']], 'starts_in_days' => -14, 'ends_in_days' => 60], + 'market-five' => ['name' => '$5 off $30', 'description' => 'Use MARKET5 on orders of $30 or more.', 'type' => 'fixed_amount', 'value' => 500, 'min_subtotal' => 3000, 'code' => 'MARKET5', 'usage_limit_per_customer' => 3, 'starts_in_days' => -7, 'ends_in_days' => 30], + 'free-delivery' => ['name' => 'Free delivery for first orders', 'description' => 'Your first delivery is free.', 'type' => 'free_delivery', 'first_order_only' => true, 'starts_in_days' => -30, 'ends_in_days' => 90], + 'harvest' => ['name' => 'Harvest week', 'description' => '15% off fresh produce (coming soon).', 'type' => 'percentage', 'value' => 15, 'applies_to' => ['categories' => [$key . ':produce']], 'starts_in_days' => 5, 'ends_in_days' => 12], + ], + 'segments' => [ + 'regulars' => ['name' => 'Regulars', 'description' => 'Five or more orders.', 'rules' => ['min_orders' => 5]], + 'lapsed' => ['name' => 'Lapsed', 'description' => 'No order in the last 21 days.', 'rules' => ['not_ordered_within_days' => 21]], + ], + 'campaigns' => [ + 'coffee-sent' => ['name' => 'Coffee hour', 'title' => '20% off coffee before 10', 'body' => 'Cold brew and lattes, every morning.', 'status' => 'sent', 'segment' => 'regulars', 'promotion' => 'coffee-hour', 'action' => ['type' => 'promotion'], 'sent_days_ago' => 2, 'stats' => ['targeted' => 6, 'batches' => 1]], + 'harvest-soon' => ['name' => 'Harvest week', 'title' => 'Harvest week starts soon', 'body' => '15% off fresh produce all week.', 'status' => 'scheduled', 'promotion' => 'harvest', 'action' => ['type' => 'promotion'], 'send_in_days' => 5], + 'winback-draft' => ['name' => 'Win back (draft)', 'title' => '$5 off your next order', 'body' => 'Use MARKET5 on orders of $30 or more.', 'status' => 'draft', 'segment' => 'lapsed', 'promotion' => 'market-five'], + ], + ]; + } } From aa0dda6df442fc35764b7638c02d8f0b72ce973d Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 8 Oct 2026 15:30:08 +0800 Subject: [PATCH 013/193] fix(reviews, stripe): review sort direction, Stripe key errors - Reviews sorted ascending whatever was asked: applySort set sort_direction, which core's query builder never reads. It now sends the columns core understands ("-rating", "-created_at"), with the newest review first on equal ratings, so "highest" and "newest" come back in the right order. - Payment methods: creating the Stripe customer ran outside the try/catch, so a gateway whose key Stripe refuses (such as the seeded placeholder key) threw and came back as a bare 401 "Unauthenticated.". It now returns the Stripe error as a 400, like the other Stripe failures there. --- .../Controllers/v1/CustomerController.php | 22 ++++++++++--------- .../Http/Controllers/v1/ReviewController.php | 10 +++++---- .../CustomerControllerContractsTest.php | 8 +++++++ .../ReviewAndOrderControllerContractsTest.php | 16 +++++++------- 4 files changed, 34 insertions(+), 22 deletions(-) diff --git a/server/src/Http/Controllers/v1/CustomerController.php b/server/src/Http/Controllers/v1/CustomerController.php index 8e47c447..6cc9cf7c 100644 --- a/server/src/Http/Controllers/v1/CustomerController.php +++ b/server/src/Http/Controllers/v1/CustomerController.php @@ -1062,12 +1062,13 @@ public function getStripeEphemeralKey(Request $request) \Stripe\Stripe::setApiKey($gateway->config->secret_key); - // Ensure customer has a stripe_id - if ($customer->missingMeta('stripe_id')) { - Storefront::createStripeCustomerForContact($customer); - } - try { + // Ensure customer has a stripe_id. Inside the try: Stripe refusing the gateway's key + // must come back as an error, not an uncaught exception rendered as 401. + if ($customer->missingMeta('stripe_id')) { + Storefront::createStripeCustomerForContact($customer); + } + // Create Ephemeral Key $ephemeralKey = \Stripe\EphemeralKey::create( ['customer' => $customer->getMeta('stripe_id')], @@ -1097,12 +1098,13 @@ public function getStripeSetupIntent(Request $request) \Stripe\Stripe::setApiKey($gateway->config->secret_key); - // Ensure customer has a stripe_id - if ($customer->missingMeta('stripe_id')) { - Storefront::createStripeCustomerForContact($customer); - } - try { + // Ensure customer has a stripe_id. Inside the try: Stripe refusing the gateway's key + // must come back as an error, not an uncaught exception rendered as 401. + if ($customer->missingMeta('stripe_id')) { + Storefront::createStripeCustomerForContact($customer); + } + // Create SetupIntent $setupIntent = \Stripe\SetupIntent::create([ 'customer' => $customer->getMeta('stripe_id'), diff --git a/server/src/Http/Controllers/v1/ReviewController.php b/server/src/Http/Controllers/v1/ReviewController.php index 94beab9b..e33b7ebd 100644 --- a/server/src/Http/Controllers/v1/ReviewController.php +++ b/server/src/Http/Controllers/v1/ReviewController.php @@ -129,28 +129,30 @@ public function query(Request $request) public function applySort($request, $sort) { if ($sort) { + // Core's query builder reads the direction from a "-" prefix on each column (it has no + // sort_direction parameter). Equal ratings list the newest review first. switch ($sort) { case 'highest': case 'highest rated': - $request->merge(['sort' => 'rating', 'sort_direction' => 'desc']); + $request->merge(['sort' => ['-rating', '-created_at']]); break; case 'lowest': case 'lowest rated': - $request->merge(['sort' => 'rating', 'sort_direction' => 'asc']); + $request->merge(['sort' => ['rating', '-created_at']]); break; case 'newest': case 'newest first': - $request->merge(['sort' => 'created_at', 'sort_direction' => 'desc']); + $request->merge(['sort' => ['-created_at']]); break; case 'oldest': case 'oldest first': - $request->merge(['sort' => 'created_at', 'sort_direction' => 'asc']); + $request->merge(['sort' => ['created_at']]); break; diff --git a/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php b/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php index 4fa4ff91..f8f19d7e 100644 --- a/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php +++ b/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php @@ -452,6 +452,10 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode }); $failedEphemeralKey = $controller->getStripeEphemeralKey(Request::create('/customer/stripe/key')); $failedSetupIntent = $controller->getStripeSetupIntent(Request::create('/customer/stripe/setup')); + // Creating the Stripe customer fails too (e.g. the gateway's key is refused): an error, not an exception. + $connection->table('contacts')->where('uuid', $customerUuid)->update(['meta' => '{}']); + $failedCreateEphemeralKey = $controller->getStripeEphemeralKey(Request::create('/customer/stripe/key')); + $failedCreateSetupIntent = $controller->getStripeSetupIntent(Request::create('/customer/stripe/setup')); $connection->table('gateways')->delete(); $missingEphemeralGateway = $controller->getStripeEphemeralKey(Request::create('/customer/stripe/key')); $missingSetupGateway = $controller->getStripeSetupIntent(Request::create('/customer/stripe/setup')); @@ -559,6 +563,10 @@ public function captureException(Throwable $exception): void ->and($createdSetupIntent->getData(true)['customerId'])->toBe('cus_created_customer') ->and($failedEphemeralKey->getData(true))->toBe(['error' => 'Stripe customer endpoint unavailable']) ->and($failedSetupIntent->getData(true))->toBe(['error' => 'Stripe customer endpoint unavailable']) + ->and($failedCreateEphemeralKey->getStatusCode())->toBe(400) + ->and($failedCreateEphemeralKey->getData(true))->toBe(['error' => 'Stripe customer endpoint unavailable']) + ->and($failedCreateSetupIntent->getStatusCode())->toBe(400) + ->and($failedCreateSetupIntent->getData(true))->toBe(['error' => 'Stripe customer endpoint unavailable']) ->and($missingEphemeralGateway->getData(true))->toBe(['error' => 'Stripe not setup.']) ->and($missingSetupGateway->getData(true))->toBe(['error' => 'Stripe not setup.']) ->and($closureStart->getData(true))->toBe(['error' => 'Customer user account not found.']) diff --git a/server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php b/server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php index 0f55ec0e..8d513886 100644 --- a/server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php +++ b/server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php @@ -337,7 +337,7 @@ function createReviewControllerSchema(): void }); } -test('review sort aliases map to stable API sort fields and directions', function (string $sort, ?array $expected) { +test('review sort aliases map to the sort columns core understands, with "-" for descending', function (string $sort, ?array $expected) { $request = Request::create('/reviews'); (new ReviewController())->applySort($request, $sort); @@ -345,13 +345,14 @@ function createReviewControllerSchema(): void if ($expected === null) { expect($request->has('sort'))->toBeFalse(); } else { - expect($request->only(['sort', 'sort_direction']))->toBe($expected); + expect($request->input('sort'))->toBe($expected) + ->and($request->has('sort_direction'))->toBeFalse(); } })->with([ - 'highest' => ['highest rated', ['sort' => 'rating', 'sort_direction' => 'desc']], - 'lowest' => ['lowest', ['sort' => 'rating', 'sort_direction' => 'asc']], - 'newest' => ['newest first', ['sort' => 'created_at', 'sort_direction' => 'desc']], - 'oldest' => ['oldest', ['sort' => 'created_at', 'sort_direction' => 'asc']], + 'highest' => ['highest rated', ['-rating', '-created_at']], + 'lowest' => ['lowest', ['rating', '-created_at']], + 'newest' => ['newest first', ['-created_at']], + 'oldest' => ['oldest', ['created_at']], 'unknown' => ['featured', null], ]); @@ -446,8 +447,7 @@ function createReviewControllerSchema(): void $resource = (new ReviewController())->query($request); - expect($request->input('sort'))->toBe('rating') - ->and($request->input('sort_direction'))->toBe('desc') + expect($request->input('sort'))->toBe(['-rating', '-created_at']) ->and($resource->resource)->toHaveCount(1) ->and($resource->resource->first()->uuid)->toBe('review_two_uuid'); }); From 986843a96795c887b5042900ebda60cab6c9b3ad Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 8 Oct 2026 15:30:09 +0800 Subject: [PATCH 014/193] fix(seeders): keep store and network keys when re-seeding Re-seeding purged and recreated the network and stores, and the models generate a new key on every create, so each run changed the storefront key and forced an app rebuild. The seeder now remembers the uuid, public id and key of every store and network it created before purging, recreates them with the same uuid, and writes the old public id and key back after the insert. New ones get new keys as before. --- .../Concerns/SeedsStorefrontFixtures.php | 65 ++++++++++++++++++- 1 file changed, 63 insertions(+), 2 deletions(-) diff --git a/server/seeders/Testing/Concerns/SeedsStorefrontFixtures.php b/server/seeders/Testing/Concerns/SeedsStorefrontFixtures.php index 5272338c..5239ee97 100644 --- a/server/seeders/Testing/Concerns/SeedsStorefrontFixtures.php +++ b/server/seeders/Testing/Concerns/SeedsStorefrontFixtures.php @@ -39,6 +39,7 @@ use Fleetbase\Storefront\Models\StoreLocation; use Fleetbase\Storefront\Support\Storefront; use Fleetbase\Support\Utils; +use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Arr; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; @@ -79,6 +80,15 @@ trait SeedsStorefrontFixtures /** @var array dropoff place per seeded customer uuid */ protected array $customerPlaces = []; + /** + * uuid, public id and key of the stores and networks this seeder created before, by + * class and seed id, so re-seeding keeps them. Apps are built against a storefront key, + * so a new key would need an app rebuild. + * + * @var array> + */ + protected array $seededIdentities = []; + /* |-------------------------------------------------------------------------- | Purging @@ -91,6 +101,8 @@ trait SeedsStorefrontFixtures */ protected function purgeStorefrontFixtures(): void { + $this->rememberSeededIdentities(); + $storeUuids = $this->seededUuids(Store::class); $networkUuids = $this->seededUuids(Network::class); $productUuids = $this->seededUuids(Product::class); @@ -160,6 +172,55 @@ protected function purgeStorefrontFixtures(): void $this->customerPlaces = []; } + /** + * Record the identity of every store and network this seeder created, before purging. + */ + protected function rememberSeededIdentities(): void + { + $this->seededIdentities = []; + + foreach ([Store::class => 'meta', Network::class => 'options'] as $modelClass => $column) { + foreach ($this->seededQuery($modelClass)->get() as $model) { + $tag = (array) $model->{$column}; + $seedId = $tag['seed_id'] ?? null; + if (!$seedId || ($tag['seed'] ?? null) !== $this->seedName()) { + continue; + } + + $this->seededIdentities[$modelClass][$seedId] = [ + 'uuid' => $model->uuid, + 'public_id' => $model->public_id, + 'key' => $model->key, + ]; + } + } + } + + /** + * Create a store or network, keeping the uuid, public id and key it had when this + * seeder last created it. The models generate a new key on every create, so the + * remembered key and public id are written back straight after the insert. + */ + protected function createWithSeededIdentity(string $modelClass, string $seedId, array $attributes): Model + { + $previous = $this->seededIdentities[$modelClass][$seedId] ?? null; + if ($previous) { + $attributes['uuid'] = $previous['uuid']; + } + + $model = $this->createRecord($modelClass, $attributes); + + if ($previous) { + $identity = array_filter(['public_id' => $previous['public_id'], 'key' => $previous['key']]); + if ($identity) { + $model->getConnection()->table($model->getTable())->where('uuid', $model->uuid)->update($identity); + $model->forceFill($identity)->syncOriginal(); + } + } + + return $model; + } + protected function purgeSeededLedgerJournals(array $orderUuids): void { if (!Schema::connection($this->fleetbaseConnection())->hasTable('ledger_journals')) { @@ -197,7 +258,7 @@ protected function seedStore(Company $company, array $definition): array $storeKey = $definition['key']; $seedId = 'store:' . $storeKey; - $store = $this->createRecord(Store::class, [ + $store = $this->createWithSeededIdentity(Store::class, $seedId, [ 'company_uuid' => $company->uuid, 'created_by_uuid' => session('user'), 'order_config_uuid' => Storefront::getOrderConfig($company)->uuid, @@ -687,7 +748,7 @@ protected function createCampaign(Company $company, Store|Network $owner, string protected function createNetwork(Company $company, array $definition): Network { - $network = $this->createRecord(Network::class, [ + $network = $this->createWithSeededIdentity(Network::class, 'network:' . $definition['key'], [ 'company_uuid' => $company->uuid, 'created_by_uuid' => session('user'), 'order_config_uuid' => Storefront::getOrderConfig($company)->uuid, From 78f4017ffda9131e4712cd3ed25f6fa9c156243f Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 8 Oct 2026 15:44:03 +0800 Subject: [PATCH 015/193] fix(seeders): seed a storefront delivery rate and keep real Stripe keys - No service rate had the storefront service type, so every delivery quote failed with "No service rates available!" and checkout showed delivery as unavailable for any address. Both seeders now create a "Storefront Delivery" rate (base fee plus a per-km fee, no service area) for the storefront order config, purged and recreated like their other fixtures. - Re-seeding replaced the seeded Stripe gateways' keys with placeholders. A gateway now keeps the real keys it already had (set in the console), unless SEED_STRIPE_SECRET_KEY / SEED_STRIPE_PUBLISHABLE_KEY are given. --- .../Concerns/SeedsStorefrontFixtures.php | 71 +++++++++++++++++-- server/seeders/Testing/NetworkSeeder.php | 1 + server/seeders/Testing/StoreSeeder.php | 1 + 3 files changed, 68 insertions(+), 5 deletions(-) diff --git a/server/seeders/Testing/Concerns/SeedsStorefrontFixtures.php b/server/seeders/Testing/Concerns/SeedsStorefrontFixtures.php index 5239ee97..b0cc09dd 100644 --- a/server/seeders/Testing/Concerns/SeedsStorefrontFixtures.php +++ b/server/seeders/Testing/Concerns/SeedsStorefrontFixtures.php @@ -8,6 +8,7 @@ use Fleetbase\FleetOps\Models\Payload; use Fleetbase\FleetOps\Models\Place; use Fleetbase\FleetOps\Models\ServiceQuote; +use Fleetbase\FleetOps\Models\ServiceRate; use Fleetbase\FleetOps\Support\Utils as FleetOpsUtils; use Fleetbase\LaravelMysqlSpatial\Types\Point; use Fleetbase\Models\Category; @@ -89,6 +90,14 @@ trait SeedsStorefrontFixtures */ protected array $seededIdentities = []; + /** + * Config (Stripe keys) of the gateways this seeder created before, by seed id, so real + * keys set on a seeded gateway survive re-seeding. + * + * @var array + */ + protected array $seededGatewayConfigs = []; + /* |-------------------------------------------------------------------------- | Purging @@ -158,8 +167,9 @@ protected function purgeStorefrontFixtures(): void $this->purgeModel(AddonCategory::class); $this->purgeModel(Category::class); - // Payments + // Payments and delivery pricing $this->purgeModel(Gateway::class); + $this->purgeModel(ServiceRate::class); $this->deleteFrom($this->storefrontConnection(), 'payment_methods', fn ($query) => $query->whereIn('owner_uuid', $customerUuids)); // Stores, locations and networks @@ -194,6 +204,14 @@ protected function rememberSeededIdentities(): void ]; } } + + $this->seededGatewayConfigs = []; + foreach ($this->seededQuery(Gateway::class)->get() as $gateway) { + $tag = (array) $gateway->meta; + if (($tag['seed'] ?? null) === $this->seedName() && !empty($tag['seed_id'])) { + $this->seededGatewayConfigs[$tag['seed_id']] = (array) $gateway->config; + } + } } /** @@ -389,6 +407,52 @@ protected function createStoreLocation(Company $company, Store $store, string $s * are stored so the gateway is selectable and the "missing secret" checkout path * can be exercised. */ + /** + * Stripe keys for a seeded gateway: SEED_STRIPE_* when set, else the real keys already on + * this gateway from an earlier run (set in the console), else placeholders. + */ + protected function stripeGatewayConfig(string $seedId): array + { + $placeholder = ['secret_key' => 'sk_test_storefront_seed_placeholder', 'publishable_key' => 'pk_test_storefront_seed_placeholder']; + if (env('SEED_STRIPE_SECRET_KEY')) { + return ['secret_key' => env('SEED_STRIPE_SECRET_KEY'), 'publishable_key' => env('SEED_STRIPE_PUBLISHABLE_KEY') ?: $placeholder['publishable_key']]; + } + + $previous = $this->seededGatewayConfigs[$seedId] ?? []; + $secret = $previous['secret_key'] ?? null; + if (is_string($secret) && $secret !== '' && $secret !== $placeholder['secret_key']) { + return $previous; + } + + return $placeholder; + } + + /** + * A storefront delivery rate for the company, without a service area so every address + * gets a quote: a base fee plus a fee per kilometre, in the seeded stores' currency. + */ + protected function createDeliveryServiceRate(Company $company, string $seedId, string $currency = 'USD'): ServiceRate + { + $orderConfig = Storefront::getOrderConfig($company); + + return $this->createRecord(ServiceRate::class, [ + '_key' => $this->fixtureKey($seedId), + 'company_uuid' => $company->uuid, + 'created_by_uuid' => session('user'), + 'order_config_uuid' => $orderConfig?->uuid, + 'service_name' => 'Storefront Delivery', + 'service_type' => data_get($orderConfig, 'key', 'storefront'), + 'rate_calculation_method' => 'per_meter', + 'base_fee' => 299, + 'per_meter_flat_rate_fee' => 60, + 'per_meter_unit' => 'km', + 'currency' => $currency, + 'duration_terms' => 'Delivered within the hour', + 'estimated_days' => 0, + 'has_cod_fee' => false, + ]); + } + protected function createStripeGateway(Company $company, Store|Network $owner, string $ownerType, string $seedId): Gateway { return $this->createRecord(Gateway::class, [ @@ -401,10 +465,7 @@ protected function createStripeGateway(Company $company, Store|Network $owner, s 'code' => 'stripe', 'type' => 'stripe', 'sandbox' => true, - 'config' => [ - 'secret_key' => env('SEED_STRIPE_SECRET_KEY') ?: 'sk_test_storefront_seed_placeholder', - 'publishable_key' => env('SEED_STRIPE_PUBLISHABLE_KEY') ?: 'pk_test_storefront_seed_placeholder', - ], + 'config' => $this->stripeGatewayConfig($seedId), 'return_url' => null, 'callback_url' => null, 'meta' => $this->meta($seedId), diff --git a/server/seeders/Testing/NetworkSeeder.php b/server/seeders/Testing/NetworkSeeder.php index fefdba66..9a025e40 100644 --- a/server/seeders/Testing/NetworkSeeder.php +++ b/server/seeders/Testing/NetworkSeeder.php @@ -52,6 +52,7 @@ public function run(): void $this->withoutForeignKeyConstraints(fn () => $this->purgeSeedData()); $network = $this->createNetwork($company, $this->networkDefinition()); + $this->createDeliveryServiceRate($company, 'service-rate:' . static::NETWORK_KEY); $gateway = $this->createStripeGateway($company, $network, 'storefront:network', 'gateway:' . static::NETWORK_KEY . ':stripe'); $categories = []; diff --git a/server/seeders/Testing/StoreSeeder.php b/server/seeders/Testing/StoreSeeder.php index 3433cd84..ab3710c6 100644 --- a/server/seeders/Testing/StoreSeeder.php +++ b/server/seeders/Testing/StoreSeeder.php @@ -44,6 +44,7 @@ public function run(): void $this->withoutForeignKeyConstraints(fn () => $this->purgeSeedData()); $bundle = $this->seedStore($company, $this->storeDefinition()); + $this->createDeliveryServiceRate($company, 'service-rate:' . static::STORE_KEY); $customers = $this->seedCustomers($company, $this->customerFixtures()); $orders = $this->seedStoreActivity($company, $bundle, $customers, null, 30); $marketing = $this->seedMarketing($company, $bundle['store'], static::STORE_KEY, $this->marketingDefinition(), [static::STORE_KEY => $bundle]); From 7df89a92415af9bca949f38e4b99dd1fe0984ed8 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 8 Oct 2026 16:27:34 +0800 Subject: [PATCH 016/193] fix(networks): save network payment gateway changes through the gateway modal --- .../networks/index/network/index.js | 21 +++++---- .../networks/index/network/index.hbs | 45 ++++++++++++++----- 2 files changed, 43 insertions(+), 23 deletions(-) diff --git a/addon/controllers/networks/index/network/index.js b/addon/controllers/networks/index/network/index.js index 5902ddd9..e44bed45 100644 --- a/addon/controllers/networks/index/network/index.js +++ b/addon/controllers/networks/index/network/index.js @@ -152,13 +152,17 @@ export default class NetworksIndexNetworkIndexController extends Controller { this.editGateway(gateway, { title: this.intl.t('storefront.networks.index.network.index.create-new-payment-gateway'), acceptButtonText: this.intl.t('storefront.networks.index.network.index.save-gateway'), - confirm: (modal) => { + confirm: async (modal) => { modal.startLoading(); - return gateway.save().then((gateway) => { + try { + await gateway.save(); this.notifications.success(this.intl.t('storefront.networks.index.network.index.new-gateway-add-network')); this.gateways.pushObject(gateway); - }); + } catch (error) { + modal.stopLoading(); + this.notifications.serverError(error); + } }, decline: (modal) => { gateway.destroyRecord(); @@ -180,14 +184,9 @@ export default class NetworksIndexNetworkIndexController extends Controller { options = {}; } - if (!options.confirm) { - options.confirm = (modal) => { - modal.startLoading(); - - return gateway.save().then(() => { - this.notifications.success(this.intl.t('storefront.networks.index.network.index.payment-gateway-changes-success')); - }); - }; + // The settings controller's modal saves the gateway and reports errors; only the message differs. + if (!options.successNotification) { + options.successNotification = this.intl.t('storefront.networks.index.network.index.payment-gateway-changes-success'); } return this.gatewaysController.editGateway(gateway, options); diff --git a/addon/templates/networks/index/network/index.hbs b/addon/templates/networks/index/network/index.hbs index b5997801..925f9791 100644 --- a/addon/templates/networks/index/network/index.hbs +++ b/addon/templates/networks/index/network/index.hbs @@ -334,9 +334,11 @@ {{#each this.gateways as |gateway|}} + {{! Read-only summary; changes are made in the gateway modal, which saves them. }}
@@ -367,23 +371,40 @@ {{#each-in gateway.config as |key value|}} {{#if (is-bool-value value)}}
- +
{{else}} - - + + {{/if}} {{/each-in}}
-
-
{{/each}} From 174589e3fa1ec68b05e58c2f754f8b25fd81d2c4 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 8 Oct 2026 17:22:19 +0800 Subject: [PATCH 017/193] fix(seeders): seed the Singapore test stores and network in SGD The test stores, network, products, promotions, seeded orders and the seeded delivery rate now use Singapore dollars. Service rates only quote carts in their own currency, so the delivery rate follows the seeded owner's currency. --- .../Concerns/SeedsStorefrontFixtures.php | 20 ++++++++++++++----- server/seeders/Testing/NetworkSeeder.php | 4 ++-- server/seeders/Testing/StoreSeeder.php | 4 ++-- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/server/seeders/Testing/Concerns/SeedsStorefrontFixtures.php b/server/seeders/Testing/Concerns/SeedsStorefrontFixtures.php index b0cc09dd..0c3e4e82 100644 --- a/server/seeders/Testing/Concerns/SeedsStorefrontFixtures.php +++ b/server/seeders/Testing/Concerns/SeedsStorefrontFixtures.php @@ -60,7 +60,7 @@ * 'description' => '...', * 'email' => '...', 'phone' => '...', 'website' => '...', * 'tags' => ['groceries'], - * 'currency' => 'USD', 'timezone' => 'Asia/Singapore', 'pod_method' => 'scan', + * 'currency' => 'SGD', 'timezone' => 'Asia/Singapore', 'pod_method' => 'scan', * 'options' => ['auto_accept_orders' => false, ...], * 'gateway' => 'stripe' | null, * 'location' => ['name' => ..., 'street1' => ..., 'city' => ..., 'country' => 'SG', 'postal_code' => ..., 'lat' => 1.28, 'lng' => 103.85], @@ -287,7 +287,7 @@ protected function seedStore(Company $company, array $definition): array 'phone' => $definition['phone'] ?? null, 'website' => $definition['website'] ?? null, 'tags' => $definition['tags'] ?? [], - 'currency' => $definition['currency'] ?? 'USD', + 'currency' => $definition['currency'] ?? $this->seedCurrency(), 'timezone' => $definition['timezone'] ?? 'Asia/Singapore', 'pod_method' => $definition['pod_method'] ?? 'scan', 'options' => array_merge([ @@ -427,12 +427,22 @@ protected function stripeGatewayConfig(string $seedId): array return $placeholder; } + /** + * The test stores and network are in Singapore, so everything they sell, quote and + * charge is in Singapore dollars. Rates only quote carts in their own currency. + */ + protected function seedCurrency(): string + { + return 'SGD'; + } + /** * A storefront delivery rate for the company, without a service area so every address * gets a quote: a base fee plus a fee per kilometre, in the seeded stores' currency. */ - protected function createDeliveryServiceRate(Company $company, string $seedId, string $currency = 'USD'): ServiceRate + protected function createDeliveryServiceRate(Company $company, string $seedId, ?string $currency = null): ServiceRate { + $currency ??= $this->seedCurrency(); $orderConfig = Storefront::getOrderConfig($company); return $this->createRecord(ServiceRate::class, [ @@ -728,7 +738,7 @@ protected function createPromotion(Company $company, Store|Network $owner, strin 'type' => $promotion['type'], 'value' => $promotion['value'] ?? null, 'max_discount_amount' => $promotion['max_discount_amount'] ?? null, - 'currency' => $promotion['currency'] ?? $owner->currency ?? 'USD', + 'currency' => $promotion['currency'] ?? $owner->currency ?? $this->seedCurrency(), 'min_subtotal' => $promotion['min_subtotal'] ?? null, 'min_items' => $promotion['min_items'] ?? null, 'applies_to' => $appliesTo ?: null, @@ -820,7 +830,7 @@ protected function createNetwork(Company $company, array $definition): Network 'phone' => $definition['phone'] ?? null, 'website' => $definition['website'] ?? null, 'tags' => $definition['tags'] ?? [], - 'currency' => $definition['currency'] ?? 'USD', + 'currency' => $definition['currency'] ?? $this->seedCurrency(), 'timezone' => $definition['timezone'] ?? 'Asia/Singapore', 'pod_method' => $definition['pod_method'] ?? 'scan', 'options' => array_merge([ diff --git a/server/seeders/Testing/NetworkSeeder.php b/server/seeders/Testing/NetworkSeeder.php index 9a025e40..b70e4518 100644 --- a/server/seeders/Testing/NetworkSeeder.php +++ b/server/seeders/Testing/NetworkSeeder.php @@ -52,7 +52,7 @@ public function run(): void $this->withoutForeignKeyConstraints(fn () => $this->purgeSeedData()); $network = $this->createNetwork($company, $this->networkDefinition()); - $this->createDeliveryServiceRate($company, 'service-rate:' . static::NETWORK_KEY); + $this->createDeliveryServiceRate($company, 'service-rate:' . static::NETWORK_KEY, $network->currency); $gateway = $this->createStripeGateway($company, $network, 'storefront:network', 'gateway:' . static::NETWORK_KEY . ':stripe'); $categories = []; @@ -108,7 +108,7 @@ protected function networkDefinition(): array 'phone' => '+65 6100 0200', 'website' => 'https://example.test/fleetbase-marketplace', 'tags' => ['marketplace', 'testing'], - 'currency' => 'USD', + 'currency' => $this->seedCurrency(), 'timezone' => 'Asia/Singapore', 'pod_method' => 'scan', 'options' => [ diff --git a/server/seeders/Testing/StoreSeeder.php b/server/seeders/Testing/StoreSeeder.php index ab3710c6..cb9dfe9a 100644 --- a/server/seeders/Testing/StoreSeeder.php +++ b/server/seeders/Testing/StoreSeeder.php @@ -44,7 +44,7 @@ public function run(): void $this->withoutForeignKeyConstraints(fn () => $this->purgeSeedData()); $bundle = $this->seedStore($company, $this->storeDefinition()); - $this->createDeliveryServiceRate($company, 'service-rate:' . static::STORE_KEY); + $this->createDeliveryServiceRate($company, 'service-rate:' . static::STORE_KEY, $bundle['store']->currency); $customers = $this->seedCustomers($company, $this->customerFixtures()); $orders = $this->seedStoreActivity($company, $bundle, $customers, null, 30); $marketing = $this->seedMarketing($company, $bundle['store'], static::STORE_KEY, $this->marketingDefinition(), [static::STORE_KEY => $bundle]); @@ -81,7 +81,7 @@ protected function storeDefinition(): array 'phone' => '+65 6100 0100', 'website' => 'https://example.test/fleetbase-market', 'tags' => ['groceries', 'local', 'testing'], - 'currency' => 'USD', + 'currency' => $this->seedCurrency(), 'timezone' => 'Asia/Singapore', 'pod_method' => 'scan', 'gateway' => 'stripe', From 244364879eb9377f4a0d63b0740a7fdf8730a964 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 8 Oct 2026 18:27:20 +0800 Subject: [PATCH 018/193] fix(checkout): keep the checkout linked to its Stripe PaymentIntent when it is updated Choosing another payment method calls stripe-update-payment-intent, which updated the PaymentIntent and then created a new checkout without its PaymentIntent id. The app captured with that checkout's token and capture refused it ('Stripe PaymentIntent is not linked to this checkout'). The checkout that started the PaymentIntent is now updated in place (a PaymentIntent belongs to one checkout), and its promotion reservations are released before being reserved again at the new price. A PaymentIntent on another customer's checkout is refused. With no existing checkout, the new one is linked to the PaymentIntent. --- .../Controllers/v1/CheckoutController.php | 20 ++++++++-- .../src/Promotions/PromotionRedemptions.php | 11 ++++++ .../CheckoutBoundaryContractsTest.php | 38 ++++++++++++++++++- .../Unit/Promotions/PromotionCheckoutTest.php | 16 ++++++++ 4 files changed, 81 insertions(+), 4 deletions(-) diff --git a/server/src/Http/Controllers/v1/CheckoutController.php b/server/src/Http/Controllers/v1/CheckoutController.php index 1aa9443c..5f8eeba3 100644 --- a/server/src/Http/Controllers/v1/CheckoutController.php +++ b/server/src/Http/Controllers/v1/CheckoutController.php @@ -700,8 +700,7 @@ public function updateStripePaymentIntent(Request $request) return response()->apiError('Failed to create ephemeral key: ' . $e->getMessage()); } - // Create a new checkout token - $checkout = Checkout::create([ + $attributes = [ 'company_uuid' => session('company'), 'store_uuid' => session('storefront_store'), 'network_uuid' => session('storefront_network'), @@ -715,7 +714,22 @@ public function updateStripePaymentIntent(Request $request) 'is_pickup' => $isPickup, 'options' => $checkoutOptions, 'cart_state' => $cart->toArray(), - ]); + ]; + + // Capture verifies the payment against the checkout's PaymentIntent, and a + // PaymentIntent belongs to one checkout, so update the checkout that started it + // rather than adding an unlinked one. + $checkout = Checkout::where('stripe_payment_intent_id', $paymentIntent->id)->first(); + if ($checkout && $checkout->owner_uuid !== $customer->uuid) { + return response()->apiError('PaymentIntent belongs to another checkout.', 422); + } + if ($checkout) { + // Its promotion uses are reserved again below at the new price. + PromotionRedemptions::releaseFor($checkout); + $checkout->update($attributes); + } else { + $checkout = Checkout::create([...$attributes, 'stripe_payment_intent_id' => $paymentIntent->id]); + } // Return JSON response with updated PaymentIntent and ephemeral key. `checkout` is // the chkt_* public id GET /checkouts/status requires alongside the token. diff --git a/server/src/Promotions/PromotionRedemptions.php b/server/src/Promotions/PromotionRedemptions.php index e0be01d1..ec3f07ba 100644 --- a/server/src/Promotions/PromotionRedemptions.php +++ b/server/src/Promotions/PromotionRedemptions.php @@ -90,6 +90,17 @@ public static function redeem(Checkout $checkout, Order $order): void } } + /** + * Release a checkout's reserved promotion uses, so the checkout can be priced and + * reserved again (the customer changed how they pay before capturing). + */ + public static function releaseFor(Checkout $checkout): int + { + return PromotionRedemption::where('checkout_uuid', $checkout->uuid) + ->where('status', PromotionRedemption::STATUS_RESERVED) + ->update(['status' => PromotionRedemption::STATUS_RELEASED]); + } + /** * Release reservations of checkouts that were never captured. */ diff --git a/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php b/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php index 7d02eee8..196807f5 100644 --- a/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php +++ b/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php @@ -501,7 +501,7 @@ function createCheckoutBoundarySchema(): void $connection = Model::getConnectionResolver()->connection('mysql'); $schema = $connection->getSchemaBuilder(); - foreach (['carts', 'gateways', 'contacts', 'service_quotes', 'integrated_vendors', 'checkouts', 'networks', 'stores', 'orders'] as $table) { + foreach (['carts', 'gateways', 'contacts', 'service_quotes', 'integrated_vendors', 'checkouts', 'promotion_redemptions', 'networks', 'stores', 'orders'] as $table) { $schema->dropIfExists($table); } @@ -591,6 +591,14 @@ function createCheckoutBoundarySchema(): void $table->timestamps(); $table->timestamp('deleted_at')->nullable(); }); + $schema->create('promotion_redemptions', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('promotion_uuid')->nullable(); + $table->string('checkout_uuid')->nullable(); + $table->string('status')->default('reserved'); + $table->timestamps(); + }); $schema->create('stores', function ($table) { $table->increments('id'); $table->string('uuid')->nullable(); @@ -2482,7 +2490,35 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode 'PUT', $input )); + $checkout = Checkout::query()->first(); + + // Updating the same PaymentIntent again (another payment method chosen) updates that + // checkout instead of adding one capture couldn't verify. + $again = $controller->updateStripePaymentIntent(Request::create( + '/checkout/stripe-update', + 'PUT', + [...$input, 'pickup' => false] + )); + $againData = $again->getData(true); + + // Another customer's checkout keeps its PaymentIntent. + $connection->table('checkouts')->where('uuid', $checkout->uuid)->update(['owner_uuid' => 'other_customer_uuid']); + $foreign = $controller->updateStripePaymentIntent(Request::create( + '/checkout/stripe-update', + 'PUT', + $input + )); + $connection->table('checkouts')->where('uuid', $checkout->uuid)->update(['owner_uuid' => 'customer_uuid', 'is_pickup' => true]); Stripe\ApiRequestor::setHttpClient(new Stripe\HttpClient\CurlClient()); + + expect(Checkout::query()->count())->toBe(1) + ->and($againData['checkout'])->toBe($checkout->public_id) + ->and($againData['token'])->toBe($checkout->token) + ->and($checkout->stripe_payment_intent_id)->toBe('pi_checkout') + ->and(Checkout::query()->first()->stripe_payment_intent_id)->toBe('pi_checkout') + ->and($foreign->getStatusCode())->toBe(422) + ->and($foreign->getData(true))->toBe(['error' => 'PaymentIntent belongs to another checkout.']); + $checkout = Checkout::query()->first(); $meta = json_decode($connection->table('contacts')->where('uuid', 'customer_uuid')->value('meta'), true); $updatedData = $updated->getData(true); diff --git a/server/tests/Unit/Promotions/PromotionCheckoutTest.php b/server/tests/Unit/Promotions/PromotionCheckoutTest.php index 85b6c15e..e64d962c 100644 --- a/server/tests/Unit/Promotions/PromotionCheckoutTest.php +++ b/server/tests/Unit/Promotions/PromotionCheckoutTest.php @@ -206,6 +206,22 @@ function promotionCart(array $items, ?string $codes = null): Cart ]); }); +test("a checkout's own reservations can be released before it is reserved again", function () { + $promotion = makePromotion(); + promotionDb()->table('promotion_redemptions')->insert([ + ['uuid' => 'mine', 'promotion_uuid' => $promotion->uuid, 'checkout_uuid' => 'checkout_uuid', 'status' => 'reserved', 'created_at' => now()], + ['uuid' => 'used', 'promotion_uuid' => $promotion->uuid, 'checkout_uuid' => 'checkout_uuid', 'status' => 'redeemed', 'created_at' => now()], + ['uuid' => 'theirs', 'promotion_uuid' => $promotion->uuid, 'checkout_uuid' => 'other_checkout_uuid', 'status' => 'reserved', 'created_at' => now()], + ]); + + expect(PromotionRedemptions::releaseFor(promotionCheckout()))->toBe(1) + ->and(promotionDb()->table('promotion_redemptions')->orderBy('uuid')->pluck('status', 'uuid')->all())->toBe([ + 'mine' => 'released', + 'theirs' => 'reserved', + 'used' => 'redeemed', + ]); +}); + test('stale reservations are released by the scheduled command', function () { $promotion = makePromotion(); promotionDb()->table('promotion_redemptions')->insert([ From eb267af51a71f0ab31e2845dd8548b0bee0976e0 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 8 Oct 2026 18:36:18 +0800 Subject: [PATCH 019/193] fix(seeders): write Singapore dollar amounts as S$ in promotion and campaign text The seeded stores and network sell in SGD, but promotion names, descriptions and campaign copy wrote amounts as "$40", while the app formats the amounts it calculates as "S$40". --- server/seeders/Testing/NetworkSeeder.php | 12 ++++++------ server/seeders/Testing/StoreSeeder.php | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/server/seeders/Testing/NetworkSeeder.php b/server/seeders/Testing/NetworkSeeder.php index b70e4518..c88f3b9f 100644 --- a/server/seeders/Testing/NetworkSeeder.php +++ b/server/seeders/Testing/NetworkSeeder.php @@ -329,9 +329,9 @@ protected function marketingDefinition(): array { return [ 'promotions' => [ - 'welcome' => ['name' => 'Welcome to Fleetbase Marketplace', 'description' => '15% off your first order, up to $10.', 'type' => 'percentage', 'value' => 15, 'max_discount_amount' => 1000, 'min_subtotal' => 2000, 'first_order_only' => true, 'code' => 'WELCOME15', 'usage_limit_per_customer' => 1, 'starts_in_days' => -30, 'ends_in_days' => 60], - 'free-delivery' => ['name' => 'Free delivery over $40', 'description' => 'Delivery is on us when your basket is $40 or more.', 'type' => 'free_delivery', 'min_subtotal' => 4000, 'starts_in_days' => -14, 'ends_in_days' => 30, 'priority' => 5], - 'weekend-five' => ['name' => '$5 off weekend orders', 'description' => 'Every Saturday and Sunday, $5 off orders of $25 or more.', 'type' => 'fixed_amount', 'value' => 500, 'min_subtotal' => 2500, 'schedule' => [['days' => [6, 7], 'start' => '00:00', 'end' => '23:59']], 'starts_in_days' => -7, 'ends_in_days' => 45], + 'welcome' => ['name' => 'Welcome to Fleetbase Marketplace', 'description' => '15% off your first order, up to S$10.', 'type' => 'percentage', 'value' => 15, 'max_discount_amount' => 1000, 'min_subtotal' => 2000, 'first_order_only' => true, 'code' => 'WELCOME15', 'usage_limit_per_customer' => 1, 'starts_in_days' => -30, 'ends_in_days' => 60], + 'free-delivery' => ['name' => 'Free delivery over S$40', 'description' => 'Delivery is on us when your basket is S$40 or more.', 'type' => 'free_delivery', 'min_subtotal' => 4000, 'starts_in_days' => -14, 'ends_in_days' => 30, 'priority' => 5], + 'weekend-five' => ['name' => 'S$5 off weekend orders', 'description' => 'Every Saturday and Sunday, S$5 off orders of S$25 or more.', 'type' => 'fixed_amount', 'value' => 500, 'min_subtotal' => 2500, 'schedule' => [['days' => [6, 7], 'start' => '00:00', 'end' => '23:59']], 'starts_in_days' => -7, 'ends_in_days' => 45], 'services-launch' => ['name' => 'Home & Wellness launch', 'description' => '20% off any booked service.', 'type' => 'percentage', 'value' => 20, 'max_discount_amount' => 3000, 'applies_to' => ['stores' => ['lion-city-cleaning', 'tiong-bahru-wellness']], 'starts_in_days' => 3, 'ends_in_days' => 33], 'spring-sale' => ['name' => 'Spring sale', 'description' => '10% off everything (ended).', 'type' => 'percentage', 'value' => 10, 'starts_in_days' => -40, 'ends_in_days' => -10], 'vip-preview' => ['name' => 'VIP preview (draft)', 'description' => 'Not published yet.', 'type' => 'percentage', 'value' => 25, 'status' => 'draft', 'is_public' => false, 'code' => 'VIPPREVIEW'], @@ -345,9 +345,9 @@ protected function marketingDefinition(): array ], 'campaigns' => [ 'welcome-sent' => ['name' => 'Welcome offer', 'title' => '15% off your first order', 'body' => 'Use WELCOME15 at checkout. Ends soon.', 'status' => 'sent', 'segment' => 'never', 'promotion' => 'welcome', 'action' => ['type' => 'promotion'], 'sent_days_ago' => 3, 'stats' => ['targeted' => 0, 'batches' => 0]], - 'delivery-sent' => ['name' => 'Free delivery week', 'title' => 'Free delivery over $40', 'body' => 'Stock up this week, delivery is on us.', 'status' => 'sent', 'segment' => 'loyal', 'promotion' => 'free-delivery', 'action' => ['type' => 'promotion'], 'sent_days_ago' => 1, 'stats' => ['targeted' => 12, 'batches' => 1]], + 'delivery-sent' => ['name' => 'Free delivery week', 'title' => 'Free delivery over S$40', 'body' => 'Stock up this week, delivery is on us.', 'status' => 'sent', 'segment' => 'loyal', 'promotion' => 'free-delivery', 'action' => ['type' => 'promotion'], 'sent_days_ago' => 1, 'stats' => ['targeted' => 12, 'batches' => 1]], 'services-soon' => ['name' => 'Home & Wellness launch', 'title' => 'Book a clean or a massage', 'body' => 'New on Fleetbase Marketplace: 20% off booked services.', 'status' => 'scheduled', 'segment' => 'recent', 'promotion' => 'services-launch', 'action' => ['type' => 'store', 'ref' => 'lion-city-cleaning'], 'send_in_days' => 14], - 'winback-draft' => ['name' => 'We miss you (draft)', 'title' => 'Come back for $5 off', 'body' => 'Here is $5 off your next weekend order.', 'status' => 'draft', 'segment' => 'lapsed', 'promotion' => 'weekend-five'], + 'winback-draft' => ['name' => 'We miss you (draft)', 'title' => 'Come back for S$5 off', 'body' => 'Here is S$5 off your next weekend order.', 'status' => 'draft', 'segment' => 'lapsed', 'promotion' => 'weekend-five'], ], ]; } @@ -373,7 +373,7 @@ protected function storeMarketingDefinitions(): array ], 'lion-city-cleaning' => [ 'promotions' => [ - 'first-clean' => ['name' => '$20 off your first clean', 'description' => 'For new customers, on any home clean.', 'type' => 'fixed_amount', 'value' => 2000, 'min_subtotal' => 6000, 'first_order_only' => true, 'code' => 'FIRSTCLEAN', 'applies_to' => ['categories' => ['lion-city-cleaning:cleaning']], 'starts_in_days' => -20, 'ends_in_days' => 70], + 'first-clean' => ['name' => 'S$20 off your first clean', 'description' => 'For new customers, on any home clean.', 'type' => 'fixed_amount', 'value' => 2000, 'min_subtotal' => 6000, 'first_order_only' => true, 'code' => 'FIRSTCLEAN', 'applies_to' => ['categories' => ['lion-city-cleaning:cleaning']], 'starts_in_days' => -20, 'ends_in_days' => 70], ], ], ]; diff --git a/server/seeders/Testing/StoreSeeder.php b/server/seeders/Testing/StoreSeeder.php index cb9dfe9a..90b89208 100644 --- a/server/seeders/Testing/StoreSeeder.php +++ b/server/seeders/Testing/StoreSeeder.php @@ -241,7 +241,7 @@ protected function marketingDefinition(): array return [ 'promotions' => [ 'coffee-hour' => ['name' => 'Coffee hour', 'description' => '20% off beverages, every morning 7 to 10.', 'type' => 'percentage', 'value' => 20, 'applies_to' => ['categories' => [$key . ':beverages']], 'schedule' => [['days' => [1, 2, 3, 4, 5, 6, 7], 'start' => '07:00', 'end' => '10:00']], 'starts_in_days' => -14, 'ends_in_days' => 60], - 'market-five' => ['name' => '$5 off $30', 'description' => 'Use MARKET5 on orders of $30 or more.', 'type' => 'fixed_amount', 'value' => 500, 'min_subtotal' => 3000, 'code' => 'MARKET5', 'usage_limit_per_customer' => 3, 'starts_in_days' => -7, 'ends_in_days' => 30], + 'market-five' => ['name' => 'S$5 off S$30', 'description' => 'Use MARKET5 on orders of S$30 or more.', 'type' => 'fixed_amount', 'value' => 500, 'min_subtotal' => 3000, 'code' => 'MARKET5', 'usage_limit_per_customer' => 3, 'starts_in_days' => -7, 'ends_in_days' => 30], 'free-delivery' => ['name' => 'Free delivery for first orders', 'description' => 'Your first delivery is free.', 'type' => 'free_delivery', 'first_order_only' => true, 'starts_in_days' => -30, 'ends_in_days' => 90], 'harvest' => ['name' => 'Harvest week', 'description' => '15% off fresh produce (coming soon).', 'type' => 'percentage', 'value' => 15, 'applies_to' => ['categories' => [$key . ':produce']], 'starts_in_days' => 5, 'ends_in_days' => 12], ], @@ -252,7 +252,7 @@ protected function marketingDefinition(): array 'campaigns' => [ 'coffee-sent' => ['name' => 'Coffee hour', 'title' => '20% off coffee before 10', 'body' => 'Cold brew and lattes, every morning.', 'status' => 'sent', 'segment' => 'regulars', 'promotion' => 'coffee-hour', 'action' => ['type' => 'promotion'], 'sent_days_ago' => 2, 'stats' => ['targeted' => 6, 'batches' => 1]], 'harvest-soon' => ['name' => 'Harvest week', 'title' => 'Harvest week starts soon', 'body' => '15% off fresh produce all week.', 'status' => 'scheduled', 'promotion' => 'harvest', 'action' => ['type' => 'promotion'], 'send_in_days' => 5], - 'winback-draft' => ['name' => 'Win back (draft)', 'title' => '$5 off your next order', 'body' => 'Use MARKET5 on orders of $30 or more.', 'status' => 'draft', 'segment' => 'lapsed', 'promotion' => 'market-five'], + 'winback-draft' => ['name' => 'Win back (draft)', 'title' => 'S$5 off your next order', 'body' => 'Use MARKET5 on orders of S$30 or more.', 'status' => 'draft', 'segment' => 'lapsed', 'promotion' => 'market-five'], ], ]; } From 17184b4d7811a4e739521f576df8fdd5905816f9 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 8 Oct 2026 20:44:43 +0800 Subject: [PATCH 020/193] feat(orders): customer order activity flow from the order's own config GET storefront/v1/orders/{id}/activity-flow returns the signed-in customer's order as a line of steps from its order config, so apps can show custom flows (e.g. Created > Dispatched > Started > Enroute > Completed) instead of guessing from status codes. Steps are the activities the order has been through (tracking history, first time each was reached), its current activity, then the path it is expected to take. At a branch, Fleet-Ops' own activity conditions decide: an activity whose conditions this order meets (pickup's 'ready for pickup') wins over an unconditional one, cancellation branches are never projected, and a looping flow is capped. Unknown statuses still appear, labelled from tracking history. Labels and details come from the config, with {storefront.name}-style placeholders filled. --- .../Http/Controllers/v1/OrderController.php | 29 +++ server/src/Support/OrderActivityFlow.php | 198 ++++++++++++++++++ server/src/routes.php | 1 + .../ReviewAndOrderControllerContractsTest.php | 117 +++++++++++ .../Unit/Routes/StorefrontRoutesTest.php | 1 + .../Unit/Support/OrderActivityFlowTest.php | 116 ++++++++++ 6 files changed, 462 insertions(+) create mode 100644 server/src/Support/OrderActivityFlow.php create mode 100644 server/tests/Unit/Support/OrderActivityFlowTest.php diff --git a/server/src/Http/Controllers/v1/OrderController.php b/server/src/Http/Controllers/v1/OrderController.php index 09119489..75746311 100644 --- a/server/src/Http/Controllers/v1/OrderController.php +++ b/server/src/Http/Controllers/v1/OrderController.php @@ -5,6 +5,7 @@ use Fleetbase\FleetOps\Models\Order; use Fleetbase\Http\Controllers\Controller; use Fleetbase\Storefront\Models\Checkout; +use Fleetbase\Storefront\Support\OrderActivityFlow; use Fleetbase\Storefront\Support\QPay; use Fleetbase\Storefront\Support\Storefront; use Illuminate\Http\JsonResponse; @@ -57,6 +58,34 @@ protected function updateOrderStatus(Order $order, string $status) return $order->updateStatus($status); } + /** + * The customer's order as a line of steps from its own order config: where it has been, + * where it is, and where it is expected to go (see OrderActivityFlow). + * + * GET storefront/v1/orders/{id}/activity-flow + */ + public function getActivityFlow(string $id) + { + $customer = Storefront::getCustomerFromToken(); + if (!$customer) { + return response()->apiError('Customer is not authenticated.'); + } + + $order = Order::where('public_id', $id) + ->whereNull('deleted_at') + ->first(); + + if (!$order) { + return response()->apiError('No order found.', 404); + } + + if ($order->customer_uuid !== $customer->uuid) { + return response()->apiError('Not authorized to view this order.', 403); + } + + return response()->json(OrderActivityFlow::forOrder($order)); + } + /** * Get receipt for an order based on the payment method type. * diff --git a/server/src/Support/OrderActivityFlow.php b/server/src/Support/OrderActivityFlow.php new file mode 100644 index 00000000..cc4ba0cf --- /dev/null +++ b/server/src/Support/OrderActivityFlow.php @@ -0,0 +1,198 @@ +getConnectionName()); + $config = $order->order_config_uuid ? $db->table('order_configs')->where('uuid', $order->order_config_uuid)->first(['public_id', 'key', 'name', 'flow']) : null; + $flow = $config ? (json_decode((string) $config->flow, true) ?: []) : []; + $history = $order->tracking_number_uuid + ? $db->table('tracking_statuses') + ->where('tracking_number_uuid', $order->tracking_number_uuid) + ->whereNull('deleted_at') + ->orderBy('created_at') + ->get(['code', 'status', 'created_at']) + ->map(fn ($status) => ['code' => $status->code, 'label' => $status->status, 'at' => $status->created_at ? Carbon::parse($status->created_at)->toIso8601String() : null]) + ->all() + : []; + $tokens = ['storefront' => ['name' => $order->getMeta('storefront')]]; + $passes = function (array $activity) use ($order, $flow): bool { + try { + return (new Activity($activity, $flow))->passes($order); + } catch (\Throwable $e) { + return false; + } + }; + + return [ + 'order' => $order->public_id, + 'order_config' => $config ? ['id' => $config->public_id, 'key' => $config->key, 'name' => $config->name] : null, + ...static::resolve($flow, $order->status, $history, $passes, $order->created_at ? Carbon::parse($order->created_at)->toIso8601String() : null, $tokens), + ]; + } + + /** + * @param array $flow the order config's activities, keyed by code + * @param string|null $status the order's current status code + * @param array $history tracking statuses, oldest first: ['code', 'label', 'at'] + * @param callable $passes whether an activity's conditions hold for the order + * @param string|null $createdAt when the order was placed + * @param array $tokens values for `{placeholders}` in activity details + */ + public static function resolve(array $flow, ?string $status, array $history, callable $passes, ?string $createdAt = null, array $tokens = []): array + { + $activities = []; + foreach ($flow as $key => $activity) { + if (is_array($activity)) { + $code = static::code($activity['code'] ?? $key); + $activities[$code] = $activity; + } + } + + // The path so far: each activity once, in the order it was first reached. + $path = []; + $reached = []; + foreach ($history as $entry) { + $code = static::code($entry['code'] ?? null); + if ($code === '' || isset($reached[$code])) { + continue; + } + $reached[$code] = $entry['at'] ?? null; + $path[] = ['code' => $code, 'label' => $entry['label'] ?? null]; + } + if (isset($activities['created']) && !isset($reached['created'])) { + array_unshift($path, ['code' => 'created', 'label' => null]); + $reached['created'] = $createdAt; + } + + $current = static::code($status); + if ($current !== '' && !isset($reached[$current])) { + $path[] = ['code' => $current, 'label' => null]; + $reached[$current] = null; + } + $currentIndex = $current === '' ? count($path) - 1 : array_search($current, array_column($path, 'code'), true); + + $canceled = $current !== '' && static::isCancellation($current, $activities[$current] ?? []); + $completed = !$canceled && static::isComplete($activities[$current] ?? []); + + // Where the order is expected to go next. + $upcoming = []; + if (!$canceled && !$completed) { + $visited = array_flip(array_column($path, 'code')); + $cursor = $activities[$current] ?? null; + while ($cursor && count($upcoming) < static::MAX_PROJECTED) { + $next = static::nextActivity($cursor, $activities, $visited, $passes); + if (!$next) { + break; + } + $code = static::code($next['code']); + $visited[$code] = true; + $upcoming[] = ['code' => $code, 'label' => null]; + $cursor = static::isComplete($next) ? null : $next; + } + } + + $steps = []; + foreach (array_merge($path, $upcoming) as $index => $step) { + $activity = $activities[$step['code']] ?? []; + $state = $index < $currentIndex ? 'done' : ($index === $currentIndex ? ($completed ? 'done' : 'current') : 'upcoming'); + $steps[] = [ + 'code' => $step['code'], + 'label' => $activity['status'] ?? $step['label'] ?? static::humanize($step['code']), + 'details' => isset($activity['details']) ? static::fill((string) $activity['details'], $tokens) : null, + 'state' => $state, + 'reached_at' => $state === 'upcoming' ? null : ($reached[$step['code']] ?? null), + 'complete' => static::isComplete($activity), + ]; + } + + return ['status' => $current ?: null, 'canceled' => $canceled, 'completed' => $completed, 'steps' => $steps]; + } + + /** + * The activity this order is expected to take after `$activity`: among the following + * activities it hasn't been through, not cancellations, whose conditions pass, one + * with conditions (it was chosen for this kind of order) before one without. + */ + protected static function nextActivity(array $activity, array $activities, array $visited, callable $passes): ?array + { + $candidates = []; + foreach ((array) ($activity['activities'] ?? []) as $nextCode) { + $code = static::code($nextCode); + $next = $activities[$code] ?? null; + if (!$next || isset($visited[$code]) || static::isCancellation($code, $next) || !$passes($next)) { + continue; + } + $candidates[] = $next; + } + + foreach ($candidates as $candidate) { + if (!empty($candidate['logic'])) { + return $candidate; + } + } + + return $candidates[0] ?? null; + } + + protected static function isCancellation(string $code, array $activity): bool + { + return in_array($code, ['canceled', 'cancelled', 'order_canceled'], true) || in_array('order.canceled', (array) ($activity['events'] ?? []), true); + } + + protected static function isComplete(array $activity): bool + { + return filter_var($activity['complete'] ?? false, FILTER_VALIDATE_BOOLEAN); + } + + protected static function code(mixed $code): string + { + return strtolower(trim((string) $code)); + } + + protected static function humanize(string $code): string + { + return ucfirst(str_replace('_', ' ', $code)); + } + + /** Fill `{storefront.name}`-style placeholders; ones without a value are dropped. */ + protected static function fill(string $text, array $tokens): string + { + $filled = preg_replace_callback('/\{([a-z0-9_.]+)\}/i', function ($match) use ($tokens) { + $value = data_get($tokens, $match[1]); + + return is_scalar($value) ? (string) $value : ''; + }, $text); + + return trim(preg_replace('/\s{2,}/', ' ', $filled)); + } +} diff --git a/server/src/routes.php b/server/src/routes.php index ef9c5ed8..92d8f71a 100644 --- a/server/src/routes.php +++ b/server/src/routes.php @@ -98,6 +98,7 @@ function ($router) { $router->group(['prefix' => 'orders'], function () use ($router) { $router->put('picked-up', 'OrderController@completeOrderPickup'); $router->post('receipt', 'OrderController@getReceipt'); + $router->get('{id}/activity-flow', 'OrderController@getActivityFlow'); $router->get('{id}/chat', 'OrderChatController@show'); $router->get('{id}/chat/messages', 'OrderChatController@messages'); $router->post('{id}/chat/messages', 'OrderChatController@send'); diff --git a/server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php b/server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php index 8d513886..2baa9dcd 100644 --- a/server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php +++ b/server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php @@ -1412,3 +1412,120 @@ public function put(string $path, string $contents, mixed $options = []): bool 'order' => 'order_uuid', ]))['status'])->toBe('canceled'); }); + +test('a customer gets their own order as steps from its order config', function () { + $connection = Model::getConnectionResolver()->connection('mysql'); + $schema = $connection->getSchemaBuilder(); + foreach (['personal_access_tokens', 'contacts', 'orders', 'order_configs', 'tracking_statuses'] as $table) { + $schema->dropIfExists($table); + } + $schema->create('personal_access_tokens', function ($table) { + $table->increments('id'); + $table->string('tokenable_type')->nullable(); + $table->integer('tokenable_id')->nullable(); + $table->string('name'); + $table->string('token'); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamps(); + }); + $schema->create('contacts', function ($table) { + $table->increments('id'); + $table->string('uuid'); + $table->string('public_id')->nullable(); + $table->string('type')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('orders', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('customer_uuid')->nullable(); + $table->string('order_config_uuid')->nullable(); + $table->string('tracking_number_uuid')->nullable(); + $table->string('status')->nullable(); + $table->text('meta')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('order_configs', function ($table) { + $table->increments('id'); + $table->string('uuid'); + $table->string('public_id')->nullable(); + $table->string('key')->nullable(); + $table->string('name')->nullable(); + $table->text('flow')->nullable(); + }); + $schema->create('tracking_statuses', function ($table) { + $table->increments('id'); + $table->string('tracking_number_uuid')->nullable(); + $table->string('code')->nullable(); + $table->string('status')->nullable(); + $table->timestamp('created_at')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + $connection->table('contacts')->insert(['uuid' => '11111111-1111-4111-8111-111111111111', 'public_id' => 'contact_customer', 'type' => 'customer']); + $connection->table('personal_access_tokens')->insert([ + 'name' => '11111111-1111-4111-8111-111111111111', + 'token' => hash('sha256', 'customer-secret'), + 'abilities' => '["*"]', + 'created_at' => now(), + 'updated_at' => now(), + ]); + // A branch whose condition can't be evaluated is skipped rather than failing the request. + $connection->table('order_configs')->insert([ + 'uuid' => 'config_uuid', + 'public_id' => 'order_config_olimax', + 'key' => 'olimax', + 'name' => 'Oli Max delivery', + 'flow' => json_encode([ + 'created' => ['code' => 'created', 'status' => 'Created', 'activities' => ['dispatched']], + 'dispatched' => ['code' => 'dispatched', 'status' => 'Dispatched', 'details' => 'Sent from {storefront.name}', 'activities' => ['unsupported', 'started']], + 'unsupported' => ['code' => 'unsupported', 'status' => 'Unsupported', 'logic' => [['type' => 'not-a-logic-type', 'conditions' => []]]], + 'started' => ['code' => 'started', 'status' => 'Started', 'activities' => ['completed']], + 'completed' => ['code' => 'completed', 'status' => 'Completed', 'complete' => true], + ]), + ]); + $connection->table('orders')->insert([ + ['uuid' => 'mine_uuid', 'public_id' => 'order_mine', 'customer_uuid' => '11111111-1111-4111-8111-111111111111', 'order_config_uuid' => 'config_uuid', 'tracking_number_uuid' => 'tracking_uuid', 'status' => 'dispatched', 'meta' => json_encode(['storefront' => 'Oli Max']), 'created_at' => '2026-10-08 10:00:00', 'updated_at' => now(), 'deleted_at' => null], + ['uuid' => 'bare_uuid', 'public_id' => 'order_bare', 'customer_uuid' => '11111111-1111-4111-8111-111111111111', 'order_config_uuid' => null, 'tracking_number_uuid' => null, 'status' => 'created', 'meta' => '{}', 'created_at' => null, 'updated_at' => null, 'deleted_at' => null], + ['uuid' => 'theirs_uuid', 'public_id' => 'order_theirs', 'customer_uuid' => '22222222-2222-4222-8222-222222222222', 'order_config_uuid' => null, 'tracking_number_uuid' => null, 'status' => 'created', 'meta' => '{}', 'created_at' => null, 'updated_at' => null, 'deleted_at' => null], + ]); + $connection->table('tracking_statuses')->insert([ + ['tracking_number_uuid' => 'tracking_uuid', 'code' => 'CREATED', 'status' => 'Created', 'created_at' => '2026-10-08 10:00:00', 'deleted_at' => null], + ['tracking_number_uuid' => 'tracking_uuid', 'code' => 'DISPATCHED', 'status' => 'Dispatched', 'created_at' => '2026-10-08 10:05:00', 'deleted_at' => null], + ['tracking_number_uuid' => 'tracking_uuid', 'code' => 'REMOVED', 'status' => 'Removed', 'created_at' => '2026-10-08 10:06:00', 'deleted_at' => now()], + ['tracking_number_uuid' => 'tracking_uuid', 'code' => 'NO_TIME', 'status' => 'No time', 'created_at' => null, 'deleted_at' => null], + ]); + + $signedOut = Request::create('/orders'); + $signedOut->setLaravelSession(new Illuminate\Session\Store('activity-flow-signed-out', new Illuminate\Session\ArraySessionHandler(120))); + app()->instance('request', $signedOut); + $unauthenticated = (new OrderController())->getActivityFlow('order_mine'); + + $boundRequest = Request::create('/orders'); + $boundRequest->headers->set('Customer-Token', 'customer-secret'); + $boundRequest->setLaravelSession(new Illuminate\Session\Store('activity-flow-test', new Illuminate\Session\ArraySessionHandler(120))); + app()->instance('request', $boundRequest); + $controller = new OrderController(); + + $missing = $controller->getActivityFlow('order_missing'); + $unauthorized = $controller->getActivityFlow('order_theirs'); + $flow = $controller->getActivityFlow('order_mine')->getData(true); + $bare = $controller->getActivityFlow('order_bare')->getData(true); + + expect($unauthenticated->getData(true))->toBe(['error' => 'Customer is not authenticated.']) + ->and($missing->getStatusCode())->toBe(404) + ->and($unauthorized->getStatusCode())->toBe(403) + ->and($unauthorized->getData(true))->toBe(['error' => 'Not authorized to view this order.']) + ->and($flow['order'])->toBe('order_mine') + ->and($flow['order_config'])->toBe(['id' => 'order_config_olimax', 'key' => 'olimax', 'name' => 'Oli Max delivery']) + // History without a time sorts first; deleted history is left out. + ->and(array_map(fn ($step) => $step['code'] . ':' . $step['state'], $flow['steps']))->toBe(['no_time:done', 'created:done', 'dispatched:current', 'started:upcoming', 'completed:upcoming']) + ->and($flow['steps'][2]['details'])->toBe('Sent from Oli Max') + ->and($flow['steps'][2]['reached_at'])->toStartWith('2026-10-08T10:05:00') + ->and($flow['steps'][0]['reached_at'])->toBeNull() + ->and($bare['order_config'])->toBeNull() + ->and(array_map(fn ($step) => $step['code'] . ':' . $step['state'], $bare['steps']))->toBe(['created:current']); +}); diff --git a/server/tests/Unit/Routes/StorefrontRoutesTest.php b/server/tests/Unit/Routes/StorefrontRoutesTest.php index f1690eb0..80cf5f9b 100644 --- a/server/tests/Unit/Routes/StorefrontRoutesTest.php +++ b/server/tests/Unit/Routes/StorefrontRoutesTest.php @@ -92,6 +92,7 @@ private function record(string $method, string $uri, mixed $action): self ['GET', 'about', 'StoreController@about'], ['POST', '/', 'ProductController@create'], ['POST', 'receipt', 'OrderController@getReceipt'], + ['GET', '{id}/activity-flow', 'OrderController@getActivityFlow'], ['POST', 'socket-token', 'CustomerController@socketToken'], ['POST', 'send-push-notification', 'ActionController@sendPushNotification'], ['FLEETBASE', 'orders', null], diff --git a/server/tests/Unit/Support/OrderActivityFlowTest.php b/server/tests/Unit/Support/OrderActivityFlowTest.php new file mode 100644 index 00000000..5010b109 --- /dev/null +++ b/server/tests/Unit/Support/OrderActivityFlowTest.php @@ -0,0 +1,116 @@ + 'if', 'conditions' => [['field' => 'meta.is_pickup', 'operator' => 'equal', 'value' => 'true']]]]; + + return [ + 'created' => ['code' => 'created', 'status' => 'Order Created', 'activities' => ['started', 'canceled']], + 'started' => ['code' => 'started', 'status' => 'Order Started', 'activities' => ['canceled', 'preparing']], + 'preparing' => ['code' => 'preparing', 'status' => 'Order is being prepared', 'details' => '{storefront.name} is preparing your order {unknown}', 'activities' => ['driver_enroute_to_store', 'pickup_ready']], + 'driver_enroute_to_store' => ['code' => 'driver_enroute_to_store', 'status' => 'Driver en-route', 'activities' => ['driver_picked_up']], + 'driver_picked_up' => ['code' => 'driver_picked_up', 'status' => 'Driver picked up', 'activities' => ['driver_enroute']], + 'driver_enroute' => ['code' => 'driver_enroute', 'status' => 'Driver en-route to you', 'activities' => ['completed']], + 'completed' => ['code' => 'completed', 'status' => 'Order completed', 'complete' => true], + 'pickup_ready' => ['code' => 'pickup_ready', 'status' => 'Ready for pickup', 'logic' => $pickupOnly, 'activities' => ['picked_up']], + 'picked_up' => ['code' => 'picked_up', 'status' => 'Picked up', 'complete' => 'true'], + 'canceled' => ['code' => 'canceled', 'status' => 'Order canceled', 'events' => ['order.canceled']], + 'not-an-activity' => 'ignored', + ]; +} + +function flowCodes(array $result): array +{ + return array_map(fn ($step) => $step['code'] . ':' . $step['state'], $result['steps']); +} + +test('a delivery order follows the unconditional branch and stops at the completing activity', function () { + $history = [ + ['code' => 'CREATED', 'label' => 'Order Created', 'at' => '2026-10-08T10:00:00+00:00'], + ['code' => 'started', 'label' => 'Order Started', 'at' => '2026-10-08T10:01:00+00:00'], + ['code' => 'started', 'label' => 'Order Started', 'at' => '2026-10-08T10:02:00+00:00'], + ['code' => '', 'label' => 'Blank'], + ['code' => 'preparing', 'label' => 'Order is being prepared', 'at' => '2026-10-08T10:03:00+00:00'], + ]; + // Only pickup_ready has conditions, and a delivery order fails them. + $result = OrderActivityFlow::resolve(storefrontFlow(), 'preparing', $history, fn ($activity) => empty($activity['logic']), null, ['storefront' => ['name' => 'Orchard Grocers']]); + + expect(flowCodes($result))->toBe([ + 'created:done', 'started:done', 'preparing:current', + 'driver_enroute_to_store:upcoming', 'driver_picked_up:upcoming', 'driver_enroute:upcoming', 'completed:upcoming', + ]) + ->and($result['status'])->toBe('preparing') + ->and($result['canceled'])->toBeFalse() + ->and($result['completed'])->toBeFalse() + ->and($result['steps'][0]['reached_at'])->toBe('2026-10-08T10:00:00+00:00') + ->and($result['steps'][1]['reached_at'])->toBe('2026-10-08T10:01:00+00:00') + ->and($result['steps'][2]['details'])->toBe('Orchard Grocers is preparing your order') + ->and($result['steps'][3]['reached_at'])->toBeNull() + ->and($result['steps'][6]['complete'])->toBeTrue(); +}); + +test('a pickup order takes the branch whose conditions it meets', function () { + $result = OrderActivityFlow::resolve(storefrontFlow(), 'preparing', [], fn () => true, '2026-10-08T09:59:00+00:00'); + + expect(flowCodes($result))->toBe(['created:done', 'preparing:current', 'pickup_ready:upcoming', 'picked_up:upcoming']) + ->and($result['steps'][0]['reached_at'])->toBe('2026-10-08T09:59:00+00:00') + ->and($result['steps'][1]['details'])->toBe('is preparing your order'); +}); + +test('a custom linear flow shows its own steps and labels', function () { + $flow = [ + 'created' => ['status' => 'Created', 'activities' => ['dispatched']], + 'dispatched' => ['status' => 'Dispatched', 'activities' => ['started']], + 'started' => ['status' => 'Started', 'activities' => ['enroute']], + 'enroute' => ['status' => 'Enroute', 'activities' => ['completed']], + 'completed' => ['status' => 'Completed', 'complete' => true], + ]; + $result = OrderActivityFlow::resolve($flow, 'started', [['code' => 'created', 'label' => 'Created'], ['code' => 'dispatched', 'label' => 'Dispatched']], fn () => true); + + expect(flowCodes($result))->toBe(['created:done', 'dispatched:done', 'started:current', 'enroute:upcoming', 'completed:upcoming']) + ->and(array_column($result['steps'], 'label'))->toBe(['Created', 'Dispatched', 'Started', 'Enroute', 'Completed']); +}); + +test('completed and canceled orders project nothing further', function () { + $completed = OrderActivityFlow::resolve(storefrontFlow(), 'completed', [['code' => 'created'], ['code' => 'completed']], fn () => true); + $canceled = OrderActivityFlow::resolve(storefrontFlow(), 'canceled', [['code' => 'created'], ['code' => 'started']], fn () => true); + + expect(flowCodes($completed))->toBe(['created:done', 'completed:done']) + ->and($completed['completed'])->toBeTrue() + ->and(flowCodes($canceled))->toBe(['created:done', 'started:done', 'canceled:current']) + ->and($canceled['canceled'])->toBeTrue() + ->and($canceled['completed'])->toBeFalse(); +}); + +test('a status the flow does not know is still shown, with its tracking label or a readable code', function () { + $tracked = OrderActivityFlow::resolve(storefrontFlow(), 'at_warehouse', [['code' => 'created'], ['code' => 'at_warehouse', 'label' => 'At Warehouse']], fn () => true); + $untracked = OrderActivityFlow::resolve([], 'at_warehouse', [], fn () => true); + $empty = OrderActivityFlow::resolve([], null, [], fn () => true); + $historyOnly = OrderActivityFlow::resolve([], null, [['code' => 'created', 'label' => 'Created']], fn () => true); + + expect(flowCodes($tracked))->toBe(['created:done', 'at_warehouse:current']) + ->and($tracked['steps'][1]['label'])->toBe('At Warehouse') + ->and($untracked['steps'][0]['label'])->toBe('At warehouse') + ->and($empty)->toBe(['status' => null, 'canceled' => false, 'completed' => false, 'steps' => []]) + ->and(flowCodes($historyOnly))->toBe(['created:current']); +}); + +test('projection skips cancellations, visited activities and failing branches, and stops at a limit', function () { + $loop = [ + 'created' => ['activities' => ['canceled', 'missing', 'created', 'step_0']], + 'canceled' => ['activities' => []], + 'cancel_event' => ['events' => ['order.canceled']], + ]; + for ($i = 0; $i < 30; $i++) { + $loop["step_$i"] = ['activities' => ['cancel_event', 'step_' . ($i + 1)]]; + } + $result = OrderActivityFlow::resolve($loop, 'created', [], fn () => true); + $blocked = OrderActivityFlow::resolve(['created' => ['activities' => ['next']], 'next' => []], 'created', [], fn () => false); + + expect($result['steps'])->toHaveLength(1 + OrderActivityFlow::MAX_PROJECTED) + ->and($result['steps'][1]['code'])->toBe('step_0') + ->and(flowCodes($blocked))->toBe(['created:current']); +}); From e00f4ea4a688864e85d91211ae0011024cc14f4f Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Thu, 8 Oct 2026 22:26:54 +0800 Subject: [PATCH 021/193] fix(seeders): write store hour days capitalised, as the console does Seeded store hours used "monday", while the console writes "Monday" and clients such as the storefront SDK match on that, so the app's store info screen crashed on seeded stores. --- server/seeders/Testing/Concerns/SeedsStorefrontFixtures.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/seeders/Testing/Concerns/SeedsStorefrontFixtures.php b/server/seeders/Testing/Concerns/SeedsStorefrontFixtures.php index 0c3e4e82..0b468718 100644 --- a/server/seeders/Testing/Concerns/SeedsStorefrontFixtures.php +++ b/server/seeders/Testing/Concerns/SeedsStorefrontFixtures.php @@ -390,7 +390,8 @@ protected function createStoreLocation(Company $company, Store $store, string $s $this->createRecord(StoreHour::class, [ 'store_location_uuid' => $storeLocation->uuid, - 'day_of_week' => $day, + // Capitalised like the console writes them ("Monday"); clients match on it. + 'day_of_week' => ucfirst($day), 'start' => $start, 'end' => $end, ]); From 14fea8af0a5502e1e7b07edeeb29c09cde892601 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 9 Oct 2026 10:47:06 +0800 Subject: [PATCH 022/193] fix(orders): console order view loads items, barcode and the promotions applied --- .../order/details/commerce-summary.hbs | 11 +++++ .../order/details/commerce-summary.js | 24 +++++++++- addon/services/storefront.js | 11 +++-- server/src/Http/Resources/Index/Order.php | 3 ++ server/src/Http/Resources/Order.php | 46 ++++++++++++++++++- 5 files changed, 88 insertions(+), 7 deletions(-) diff --git a/addon/components/storefront/order/details/commerce-summary.hbs b/addon/components/storefront/order/details/commerce-summary.hbs index 4904e39b..b1218291 100644 --- a/addon/components/storefront/order/details/commerce-summary.hbs +++ b/addon/components/storefront/order/details/commerce-summary.hbs @@ -56,6 +56,17 @@ {{#unless @resource.meta.is_pickup}}
Delivery fee{{format-currency @resource.meta.delivery_fee @resource.meta.currency}}
{{/unless}} + {{#each this.promotions as |promotion|}} +
+ + {{promotion.name}}{{#if promotion.code}} · {{promotion.code}}{{/if}} + + -{{format-currency promotion.amount @resource.meta.currency}} +
+ {{/each}} + {{#if this.unattributedDiscount}} +
Discount-{{format-currency this.unattributedDiscount @resource.meta.currency}}
+ {{/if}} {{#if @resource.meta.tip}}
Tip{{get-tip-amount @resource.meta.tip @resource.meta.subtotal @resource.meta.currency}}
{{/if}} diff --git a/addon/components/storefront/order/details/commerce-summary.js b/addon/components/storefront/order/details/commerce-summary.js index 882ecda7..9e1b1419 100644 --- a/addon/components/storefront/order/details/commerce-summary.js +++ b/addon/components/storefront/order/details/commerce-summary.js @@ -1,3 +1,25 @@ import Component from '@glimmer/component'; -export default class StorefrontOrderDetailsCommerceSummaryComponent extends Component {} +export default class StorefrontOrderDetailsCommerceSummaryComponent extends Component { + /** Each promotion applied at checkout, with what it took off the order (items and delivery). */ + get promotions() { + const promotions = this.args.resource?.meta?.promotions; + if (!Array.isArray(promotions)) { + return []; + } + + return promotions.map((promotion) => ({ + name: promotion.name || promotion.code || 'Promotion', + code: promotion.code, + isFreeDelivery: promotion.type === 'free_delivery', + amount: (Number(promotion.amount) || 0) + (Number(promotion.delivery_amount) || 0), + })); + } + + /** The order's discount when no promotion breakdown was recorded. */ + get unattributedDiscount() { + const discount = Number(this.args.resource?.meta?.discount) || 0; + + return this.promotions.length === 0 && discount > 0 ? discount : 0; + } +} diff --git a/addon/services/storefront.js b/addon/services/storefront.js index 3ce5d258..a13fbf9a 100644 --- a/addon/services/storefront.js +++ b/addon/services/storefront.js @@ -96,11 +96,12 @@ export default class StorefrontService extends Service.extend(Evented) { * @param {Object} store - The store associated with the order. */ async alertIncomingOrder(orderId, store) { - const order = await this.store.queryRecord('order', { - public_id: orderId, - single: true, - with: ['customer', 'payload', 'trackingNumber'], - }); + // Through Storefront's own order endpoint: it shapes the customer and items so the console can load them. + const order = await this.fetch.get( + `orders/${orderId}`, + { storefront: store?.public_id ?? this.getActiveStore('public_id') }, + { namespace: 'storefront/int/v1', normalizeToEmberData: true, normalizeModelType: 'order' } + ); this.playAlert(); this.trigger('order.incoming', order, store); diff --git a/server/src/Http/Resources/Index/Order.php b/server/src/Http/Resources/Index/Order.php index f2907654..b6bce693 100644 --- a/server/src/Http/Resources/Index/Order.php +++ b/server/src/Http/Resources/Index/Order.php @@ -40,6 +40,9 @@ private function storefrontOrderMeta(): array 'tip', 'delivery_tip', 'total', + 'discount', + 'promotions', + 'payment_status', 'currency', 'gateway', 'is_pickup', diff --git a/server/src/Http/Resources/Order.php b/server/src/Http/Resources/Order.php index e6b04a8b..97487bad 100644 --- a/server/src/Http/Resources/Order.php +++ b/server/src/Http/Resources/Order.php @@ -14,7 +14,7 @@ class Order extends FleetOpsOrderResource */ public function toArray($request): array { - $data = parent::toArray($request); + $data = $this->withoutPlaceTypes(json_decode(json_encode($this->filter(parent::toArray($request))), true) ?? []); $data['customer_name'] = $this->customer_name; $data['transaction_amount'] = $this->transaction_amount; $data['meta'] = $this->storefrontOrderMeta(); @@ -49,6 +49,9 @@ private function storefrontOrderMeta(): array 'tip', 'delivery_tip', 'total', + 'discount', + 'promotions', + 'payment_status', 'currency', 'gateway', 'is_pickup', @@ -69,6 +72,47 @@ private function storefrontOrderMeta(): array return $meta; } + /** + * A customer's places carry their kind in `type` (apartment, house, office...). + * The console reads `type` on an embedded record as its model name, so the + * customer copies in this order (its own and each item's) leave it out. + */ + private function withoutPlaceTypes(array $data): array + { + $strip = function ($customer) { + if (!is_array($customer)) { + return $customer; + } + if (is_array($customer['place'] ?? null)) { + unset($customer['place']['type']); + } + if (is_array($customer['places'] ?? null)) { + $customer['places'] = array_map(function ($place) { + if (is_array($place)) { + unset($place['type']); + } + + return $place; + }, $customer['places']); + } + + return $customer; + }; + + if (isset($data['customer'])) { + $data['customer'] = $strip($data['customer']); + } + if (is_array(data_get($data, 'payload.entities'))) { + foreach ($data['payload']['entities'] as $index => $entity) { + if (is_array($entity) && isset($entity['customer'])) { + $data['payload']['entities'][$index]['customer'] = $strip($entity['customer']); + } + } + } + + return $data; + } + private function normalizeMeta($meta): array { if ($meta instanceof Arrayable) { From bfcfa7f3c80217012f76ec2401a5b2f690387b6e Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 9 Oct 2026 11:18:16 +0800 Subject: [PATCH 023/193] fix(orders): order data leaves out the customer's saved places; one driver-assigned notice per driver --- server/src/Http/Resources/Order.php | 27 ++++++------------- .../Listeners/HandleOrderDriverAssigned.php | 6 ++++- 2 files changed, 13 insertions(+), 20 deletions(-) diff --git a/server/src/Http/Resources/Order.php b/server/src/Http/Resources/Order.php index 97487bad..aade2cfe 100644 --- a/server/src/Http/Resources/Order.php +++ b/server/src/Http/Resources/Order.php @@ -14,7 +14,7 @@ class Order extends FleetOpsOrderResource */ public function toArray($request): array { - $data = $this->withoutPlaceTypes(json_decode(json_encode($this->filter(parent::toArray($request))), true) ?? []); + $data = $this->withoutCustomerPlaces(json_decode(json_encode($this->filter(parent::toArray($request))), true) ?? []); $data['customer_name'] = $this->customer_name; $data['transaction_amount'] = $this->transaction_amount; $data['meta'] = $this->storefrontOrderMeta(); @@ -73,27 +73,16 @@ private function storefrontOrderMeta(): array } /** - * A customer's places carry their kind in `type` (apartment, house, office...). - * The console reads `type` on an embedded record as its model name, so the - * customer copies in this order (its own and each item's) leave it out. + * A customer's places carry their kind in `type` (apartment, house, office...), which the + * console reads as a model name, and without it they cannot be identified at all. The + * customer copies in this order (its own and each item's) leave their places out. */ - private function withoutPlaceTypes(array $data): array + private function withoutCustomerPlaces(array $data): array { + // The order views don't use the customer's address book, so it is left out entirely. $strip = function ($customer) { - if (!is_array($customer)) { - return $customer; - } - if (is_array($customer['place'] ?? null)) { - unset($customer['place']['type']); - } - if (is_array($customer['places'] ?? null)) { - $customer['places'] = array_map(function ($place) { - if (is_array($place)) { - unset($place['type']); - } - - return $place; - }, $customer['places']); + if (is_array($customer)) { + unset($customer['place'], $customer['places']); } return $customer; diff --git a/server/src/Listeners/HandleOrderDriverAssigned.php b/server/src/Listeners/HandleOrderDriverAssigned.php index 7687978d..640b969e 100644 --- a/server/src/Listeners/HandleOrderDriverAssigned.php +++ b/server/src/Listeners/HandleOrderDriverAssigned.php @@ -9,6 +9,7 @@ use Illuminate\Contracts\Debug\ExceptionHandler; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Queue\InteractsWithQueue; +use Illuminate\Support\Facades\Cache; class HandleOrderDriverAssigned implements ShouldQueue { @@ -35,7 +36,10 @@ public function handle(OrderDriverAssigned $event) if ($order->hasMeta('storefront_id')) { $order->load(['customer']); - if ($order->customer) { + // Assigning and dispatching can both raise this event for the same driver; the + // customer hears about each driver once (a reassignment is a new driver). + $onceKey = 'storefront:driver-assigned:' . $order->uuid . ':' . ($order->driver_assigned_uuid ?? 'none'); + if ($order->customer && Cache::add($onceKey, true, now()->addDay())) { $order->customer->notify(new StorefrontOrderDriverAssigned($order)); } From 1b2a65735f3445cea1d62276a97ab17c3d40b876 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 9 Oct 2026 11:37:13 +0800 Subject: [PATCH 024/193] revert(orders): order data keeps the customer's places as the API returns them; the console serializers handle place types --- addon/services/storefront.js | 11 +++++----- server/src/Http/Resources/Order.php | 32 +---------------------------- 2 files changed, 6 insertions(+), 37 deletions(-) diff --git a/addon/services/storefront.js b/addon/services/storefront.js index a13fbf9a..3ce5d258 100644 --- a/addon/services/storefront.js +++ b/addon/services/storefront.js @@ -96,12 +96,11 @@ export default class StorefrontService extends Service.extend(Evented) { * @param {Object} store - The store associated with the order. */ async alertIncomingOrder(orderId, store) { - // Through Storefront's own order endpoint: it shapes the customer and items so the console can load them. - const order = await this.fetch.get( - `orders/${orderId}`, - { storefront: store?.public_id ?? this.getActiveStore('public_id') }, - { namespace: 'storefront/int/v1', normalizeToEmberData: true, normalizeModelType: 'order' } - ); + const order = await this.store.queryRecord('order', { + public_id: orderId, + single: true, + with: ['customer', 'payload', 'trackingNumber'], + }); this.playAlert(); this.trigger('order.incoming', order, store); diff --git a/server/src/Http/Resources/Order.php b/server/src/Http/Resources/Order.php index aade2cfe..5e785c59 100644 --- a/server/src/Http/Resources/Order.php +++ b/server/src/Http/Resources/Order.php @@ -14,7 +14,7 @@ class Order extends FleetOpsOrderResource */ public function toArray($request): array { - $data = $this->withoutCustomerPlaces(json_decode(json_encode($this->filter(parent::toArray($request))), true) ?? []); + $data = parent::toArray($request); $data['customer_name'] = $this->customer_name; $data['transaction_amount'] = $this->transaction_amount; $data['meta'] = $this->storefrontOrderMeta(); @@ -72,36 +72,6 @@ private function storefrontOrderMeta(): array return $meta; } - /** - * A customer's places carry their kind in `type` (apartment, house, office...), which the - * console reads as a model name, and without it they cannot be identified at all. The - * customer copies in this order (its own and each item's) leave their places out. - */ - private function withoutCustomerPlaces(array $data): array - { - // The order views don't use the customer's address book, so it is left out entirely. - $strip = function ($customer) { - if (is_array($customer)) { - unset($customer['place'], $customer['places']); - } - - return $customer; - }; - - if (isset($data['customer'])) { - $data['customer'] = $strip($data['customer']); - } - if (is_array(data_get($data, 'payload.entities'))) { - foreach ($data['payload']['entities'] as $index => $entity) { - if (is_array($entity) && isset($entity['customer'])) { - $data['payload']['entities'][$index]['customer'] = $strip($entity['customer']); - } - } - } - - return $data; - } - private function normalizeMeta($meta): array { if ($meta instanceof Arrayable) { From c4b66dcfb1cfaceb98ef6af264ac26cb63ca891b Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 9 Oct 2026 13:22:42 +0800 Subject: [PATCH 025/193] feat(notifications): customers are notified of each order step without its own notification --- .../Notifications/StorefrontOrderActivity.php | 22 +++++++ .../src/Observers/OrderActivityObserver.php | 63 +++++++++++++++++++ .../Providers/StorefrontServiceProvider.php | 1 + 3 files changed, 86 insertions(+) create mode 100644 server/src/Notifications/StorefrontOrderActivity.php create mode 100644 server/src/Observers/OrderActivityObserver.php diff --git a/server/src/Notifications/StorefrontOrderActivity.php b/server/src/Notifications/StorefrontOrderActivity.php new file mode 100644 index 00000000..2b840c31 --- /dev/null +++ b/server/src/Notifications/StorefrontOrderActivity.php @@ -0,0 +1,22 @@ +subject = $label; + $this->body = $details ?: 'Your order from ' . $this->storefront->name . ' has been updated: ' . $label . '.'; + $this->status = 'order_activity'; + } +} diff --git a/server/src/Observers/OrderActivityObserver.php b/server/src/Observers/OrderActivityObserver.php new file mode 100644 index 00000000..f7dcc434 --- /dev/null +++ b/server/src/Observers/OrderActivityObserver.php @@ -0,0 +1,63 @@ +wasChanged('status') || !$order->hasMeta('storefront_id')) { + return; + } + + $status = (string) $order->status; + if ($status === '' || in_array($status, static::NOTIFIED_ELSEWHERE, true)) { + return; + } + + $orderUuid = $order->uuid; + + // After the change is committed, so the step's tracking status is recorded too. + dispatch(function () use ($orderUuid, $status) { + OrderActivityObserver::notify($orderUuid, $status); + })->afterCommit(); + } + + public static function notify(string $orderUuid, string $status): void + { + // An activity update can save the order more than once; each step is told once. + if (!Cache::add('storefront:order-activity:' . $orderUuid . ':' . $status, true, now()->addDay())) { + return; + } + + try { + $order = Order::where('uuid', $orderUuid)->with(['customer'])->first(); + if (!$order || !$order->customer || $order->status !== $status) { + return; + } + + $flow = OrderActivityFlow::forOrder($order); + $step = collect($flow['steps'] ?? [])->firstWhere('code', $status); + $label = data_get($step, 'label') ?: ucfirst(str_replace('_', ' ', $status)); + + $order->customer->notify(new StorefrontOrderActivity($order, $label, data_get($step, 'details') ?: null)); + } catch (\Throwable $e) { + app(ExceptionHandler::class)->report($e); + } + } +} diff --git a/server/src/Providers/StorefrontServiceProvider.php b/server/src/Providers/StorefrontServiceProvider.php index b8b23369..937912b0 100644 --- a/server/src/Providers/StorefrontServiceProvider.php +++ b/server/src/Providers/StorefrontServiceProvider.php @@ -36,6 +36,7 @@ class StorefrontServiceProvider extends CoreServiceProvider \Fleetbase\Storefront\Models\FoodTruck::class => \Fleetbase\Storefront\Observers\FoodTruckObserver::class, \Fleetbase\Models\Company::class => \Fleetbase\Storefront\Observers\CompanyObserver::class, \Fleetbase\Models\ChatMessage::class => \Fleetbase\Storefront\Observers\ChatMessageObserver::class, + \Fleetbase\FleetOps\Models\Order::class => \Fleetbase\Storefront\Observers\OrderActivityObserver::class, ]; /** From 7c9bab347b5e2cfd3e3f2d7fd613e3cd11b0f6af Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 9 Oct 2026 14:06:34 +0800 Subject: [PATCH 026/193] feat(bookings): service bookings follow their own order flow, with products brought to the earliest appointment --- addon/services/storefront-order-workflow.js | 23 +++- .../src/Http/Controllers/OrderController.php | 19 ++- .../Controllers/v1/CheckoutController.php | 68 ++++++++++ server/src/Http/Resources/Index/Order.php | 3 + server/src/Http/Resources/Order.php | 3 + server/src/Listeners/HandleOrderCompleted.php | 6 + .../src/Listeners/HandleOrderDispatched.php | 6 + server/src/Listeners/HandleOrderStarted.php | 6 + .../StorefrontOrderDriverAssigned.php | 10 +- .../src/Observers/OrderActivityObserver.php | 8 +- server/src/Support/Storefront.php | 125 +++++++++++++++++- 11 files changed, 267 insertions(+), 10 deletions(-) diff --git a/addon/services/storefront-order-workflow.js b/addon/services/storefront-order-workflow.js index cae4f70b..e012ee5a 100644 --- a/addon/services/storefront-order-workflow.js +++ b/addon/services/storefront-order-workflow.js @@ -19,6 +19,11 @@ export default class StorefrontOrderWorkflowService extends Service { return toBoolean(order?.meta?.is_pickup) === true; } + /** A service booking (at the customer's address, or at the store when it is a pickup). */ + isBookingOrder(order) { + return toBoolean(order?.meta?.is_booking) === true; + } + isDefaultStorefrontConfig(order) { const orderConfig = order?.order_config; @@ -89,6 +94,12 @@ export default class StorefrontOrderWorkflowService extends Service { } if (status === 'accepted') { + // An at-store booking starts when the customer comes in; a home visit is dispatched to the provider. + if (this.isBookingOrder(order) && this.isPickupOrder(order)) { + const flow = order?.order_config?.flow ?? {}; + return [this.descriptorFor('update_activity', order, flow.in_progress ?? { code: 'in_progress', key: 'in_progress', status: 'Service in progress' })]; + } + return [this.descriptorFor('mark_ready', order)]; } @@ -101,6 +112,10 @@ export default class StorefrontOrderWorkflowService extends Service { } if (['completed', 'picked_up'].includes(code)) { + if (this.isBookingOrder(order)) { + return this.descriptorFor('mark_completed', order); + } + return this.descriptorFor(this.isPickupOrder(order) ? 'mark_picked_up' : 'mark_completed', order); } @@ -114,12 +129,16 @@ export default class StorefrontOrderWorkflowService extends Service { case 'accept': return { action, - text: 'Accept order', + text: this.isBookingOrder(order) ? 'Confirm booking' : 'Accept order', icon: 'check', type: 'success', }; case 'mark_ready': + if (this.isBookingOrder(order)) { + return { action, text: 'Dispatch provider', icon: 'paper-plane', type: 'magic' }; + } + return { action, text: 'Mark as Ready', @@ -138,7 +157,7 @@ export default class StorefrontOrderWorkflowService extends Service { case 'mark_completed': return { action, - text: 'Complete order', + text: this.isBookingOrder(order) ? 'Complete booking' : 'Complete order', icon: 'check', type: 'success', }; diff --git a/server/src/Http/Controllers/OrderController.php b/server/src/Http/Controllers/OrderController.php index 09a6d51c..f1627968 100644 --- a/server/src/Http/Controllers/OrderController.php +++ b/server/src/Http/Controllers/OrderController.php @@ -76,6 +76,11 @@ protected function createAcceptedActivity($orderConfig) protected function notifyOrderAccepted(Order $order): void { + // A booking's confirmation is announced as its activity ("Booking confirmed"). + if (Storefront::isBookingOrder($order)) { + return; + } + $order->customer->notify(new StorefrontOrderAccepted($order)); } @@ -172,6 +177,13 @@ public function markOrderAsReady(Request $request) // Patch order config $this->patchOrderConfig($order); + // A booking at the store starts when the customer comes in; nobody is dispatched. + if (Storefront::isBookingOrder($order) && $order->isMeta('is_pickup')) { + $order->updateStatus('in_progress'); + + return $this->orderResponse($order); + } + if ($order->isMeta('is_pickup')) { $order->updateStatus('pickup_ready'); @@ -188,9 +200,12 @@ public function markOrderAsReady(Request $request) $order->assignDriver($driver); } - // Dispatch the order and move Storefront delivery orders into preparation. + // Dispatch the order and move Storefront delivery orders into preparation. A booking + // is dispatched to its provider; there is nothing to prepare. $order->dispatchWithActivity(); - $order->updateStatus('preparing'); + if (!Storefront::isBookingOrder($order)) { + $order->updateStatus('preparing'); + } return $this->orderResponse($order); } diff --git a/server/src/Http/Controllers/v1/CheckoutController.php b/server/src/Http/Controllers/v1/CheckoutController.php index 5f8eeba3..446b0353 100644 --- a/server/src/Http/Controllers/v1/CheckoutController.php +++ b/server/src/Http/Controllers/v1/CheckoutController.php @@ -23,6 +23,7 @@ use Fleetbase\Storefront\Models\Gateway; use Fleetbase\Storefront\Models\Network; use Fleetbase\Storefront\Models\Product; +use Illuminate\Support\Carbon; use Fleetbase\Storefront\Models\Store; use Fleetbase\Storefront\Models\StoreLocation; use Fleetbase\Storefront\Promotions\PromotionContext; @@ -1125,6 +1126,66 @@ protected function createOrderFromCheckout($checkout, $transactionDetails, $note * * @return void */ + /** + * The booking an order's items make, if any: a cart with a booked service (a bookable + * product with a chosen time) is a booking order. Products in it come with the earliest + * appointment, so the order follows the booking flow and carries that appointment's time. + * + * The time is kept in the order meta (`booking_at`), not `scheduled_at`: Fleet-Ops + * dispatches scheduled orders by themselves on the day, before the store confirms. + */ + protected static function bookingFor(iterable $cartItems): ?array + { + $items = collect($cartItems); + $productIds = $items->map(fn ($item) => data_get($item, 'product_id'))->filter()->unique()->values(); + $bookable = $productIds->isEmpty() ? collect() : Product::whereIn('public_id', $productIds)->where('is_bookable', true)->pluck('public_id'); + + $times = $items + ->filter(fn ($item) => data_get($item, 'scheduled_at') || $bookable->contains(data_get($item, 'product_id'))) + ->map(fn ($item) => data_get($item, 'scheduled_at')) + ->filter() + ->map(function ($at) { + try { + return Carbon::parse($at); + } catch (\Throwable $e) { + return null; + } + }) + ->filter() + ->sort(); + + $hasService = $items->contains(fn ($item) => data_get($item, 'scheduled_at') || $bookable->contains(data_get($item, 'product_id'))); + if (!$hasService) { + return null; + } + + $first = $times->first(); + + return [ + 'is_booking' => true, + 'booking_at' => $first ? $first->toIso8601String() : null, + 'booking_has_items' => $items->contains(fn ($item) => !data_get($item, 'scheduled_at') && !$bookable->contains(data_get($item, 'product_id'))), + ]; + } + + /** + * Mark an order as a booking (meta and the company's booking order config) when its items make one. + */ + protected static function applyBooking(iterable $cartItems, array $orderMeta, array $orderInput): array + { + $booking = static::bookingFor($cartItems); + if (!$booking) { + return [$orderMeta, $orderInput]; + } + + $config = Storefront::getBookingOrderConfig($orderInput['company_uuid'] ?? null); + if ($config) { + $orderInput['order_config_uuid'] = $config->uuid; + } + + return [array_merge($orderMeta, $booking), $orderInput]; + } + private function processCartItem($cartItem, $payload, $customer) { $product = Product::where('public_id', $cartItem->product_id)->first(); @@ -1426,6 +1487,10 @@ public function captureOrder(CaptureOrderRequest $request) 'notes' => $notes, ]; + // A booked service makes this a booking order, with its own flow. + [$bookingMeta, $orderInput] = static::applyBooking($cart->items ?? [], $orderInput['meta'], $orderInput); + $orderInput['meta'] = $bookingMeta; + // if it's integrated vendor order apply to meta if ($integratedVendorOrder) { $orderMeta['integrated_vendor'] = $serviceQuote->integratedVendor->public_id; @@ -1785,6 +1850,9 @@ public function captureMultipleOrders(CaptureOrderRequest $request) $orderInput['facilitator_type'] = Utils::getModelClassName('integrated_vendors'); } + // A booked service makes this store's order a booking order, with its own flow. + [$orderMeta, $orderInput] = static::applyBooking($cartItems, $orderMeta, $orderInput); + // set meta to order input last $orderInput['meta'] = $orderMeta; diff --git a/server/src/Http/Resources/Index/Order.php b/server/src/Http/Resources/Index/Order.php index b6bce693..75ef05b9 100644 --- a/server/src/Http/Resources/Index/Order.php +++ b/server/src/Http/Resources/Index/Order.php @@ -46,6 +46,9 @@ private function storefrontOrderMeta(): array 'currency', 'gateway', 'is_pickup', + 'is_booking', + 'booking_at', + 'booking_has_items', 'is_master_order', 'related_orders', 'master_order_id', diff --git a/server/src/Http/Resources/Order.php b/server/src/Http/Resources/Order.php index 5e785c59..81b4a02a 100644 --- a/server/src/Http/Resources/Order.php +++ b/server/src/Http/Resources/Order.php @@ -55,6 +55,9 @@ private function storefrontOrderMeta(): array 'currency', 'gateway', 'is_pickup', + 'is_booking', + 'booking_at', + 'booking_has_items', 'is_master_order', 'related_orders', 'master_order_id', diff --git a/server/src/Listeners/HandleOrderCompleted.php b/server/src/Listeners/HandleOrderCompleted.php index 43113c3b..9b622f85 100644 --- a/server/src/Listeners/HandleOrderCompleted.php +++ b/server/src/Listeners/HandleOrderCompleted.php @@ -2,6 +2,7 @@ namespace Fleetbase\Storefront\Listeners; +use Fleetbase\Storefront\Support\Storefront; use Fleetbase\FleetOps\Events\OrderCompleted; use Fleetbase\Storefront\Notifications\StorefrontOrderCompleted; use Illuminate\Contracts\Queue\ShouldQueue; @@ -23,6 +24,11 @@ public function handle(OrderCompleted $event) /** @var \Fleetbase\FleetOps\Models\Order $order */ $order = $event->getModelRecord(); + // Bookings are told about each step by the order activity observer, in booking terms. + if (Storefront::isBookingOrder($order)) { + return; + } + // if storefront order notify customer driver has been addigned if ($order->hasMeta('storefront_id')) { $order->load(['customer']); diff --git a/server/src/Listeners/HandleOrderDispatched.php b/server/src/Listeners/HandleOrderDispatched.php index b84ec3d5..967c459c 100644 --- a/server/src/Listeners/HandleOrderDispatched.php +++ b/server/src/Listeners/HandleOrderDispatched.php @@ -2,6 +2,7 @@ namespace Fleetbase\Storefront\Listeners; +use Fleetbase\Storefront\Support\Storefront; use Fleetbase\FleetOps\Events\OrderDispatched; use Fleetbase\Storefront\Notifications\StorefrontOrderReadyForPickup; use Illuminate\Contracts\Queue\ShouldQueue; @@ -23,6 +24,11 @@ public function handle(OrderDispatched $event) /** @var \Fleetbase\FleetOps\Models\Order $order */ $order = $event->getModelRecord(); + // Bookings are told about each step by the order activity observer, in booking terms. + if (Storefront::isBookingOrder($order)) { + return; + } + // notufy customer order is ready for pickup if ($order->isMeta('is_pickup')) { $order->load(['customer']); diff --git a/server/src/Listeners/HandleOrderStarted.php b/server/src/Listeners/HandleOrderStarted.php index d7000b34..cfd8875d 100644 --- a/server/src/Listeners/HandleOrderStarted.php +++ b/server/src/Listeners/HandleOrderStarted.php @@ -2,6 +2,7 @@ namespace Fleetbase\Storefront\Listeners; +use Fleetbase\Storefront\Support\Storefront; use Fleetbase\FleetOps\Events\OrderStarted; use Fleetbase\Storefront\Notifications\StorefrontOrderEnroute; @@ -19,6 +20,11 @@ public function handle(OrderStarted $event) /** @var \Fleetbase\FleetOps\Models\Order $order */ $order = $event->getModelRecord(); + // Bookings are told about each step by the order activity observer, in booking terms. + if (Storefront::isBookingOrder($order)) { + return; + } + // if storefront order / notify customer driver has started and is en-route if ($order->hasMeta('storefront_id')) { $order->load(['customer']); diff --git a/server/src/Notifications/StorefrontOrderDriverAssigned.php b/server/src/Notifications/StorefrontOrderDriverAssigned.php index 9f89deed..469e2ff4 100644 --- a/server/src/Notifications/StorefrontOrderDriverAssigned.php +++ b/server/src/Notifications/StorefrontOrderDriverAssigned.php @@ -4,6 +4,7 @@ use Fleetbase\FleetOps\Models\Driver; use Fleetbase\FleetOps\Models\Order; +use Fleetbase\Storefront\Support\Storefront; class StorefrontOrderDriverAssigned extends StorefrontOrderNotification { @@ -22,8 +23,13 @@ public function __construct(Order $order) parent::__construct($order); $this->driver = $order->driverAssigned; - $this->subject = 'Your driver is ' . $this->driver->name; - $this->body = 'A driver has been assigned to your order from ' . $this->storefront->name . ', they are currently en-route for pickup'; + if (Storefront::isBookingOrder($order)) { + $this->subject = 'Your provider is ' . $this->driver->name; + $this->body = $this->driver->name . ' will be your provider for your booking with ' . $this->storefront->name . '.'; + } else { + $this->subject = 'Your driver is ' . $this->driver->name; + $this->body = 'A driver has been assigned to your order from ' . $this->storefront->name . ', they are currently en-route for pickup'; + } $this->status = 'order_driver_assigned'; } } diff --git a/server/src/Observers/OrderActivityObserver.php b/server/src/Observers/OrderActivityObserver.php index f7dcc434..b3b80c31 100644 --- a/server/src/Observers/OrderActivityObserver.php +++ b/server/src/Observers/OrderActivityObserver.php @@ -5,6 +5,7 @@ use Fleetbase\FleetOps\Models\Order; use Fleetbase\Storefront\Notifications\StorefrontOrderActivity; use Fleetbase\Storefront\Support\OrderActivityFlow; +use Fleetbase\Storefront\Support\Storefront; use Illuminate\Contracts\Debug\ExceptionHandler; use Illuminate\Support\Facades\Cache; @@ -25,8 +26,11 @@ public function updated(Order $order): void return; } - $status = (string) $order->status; - if ($status === '' || in_array($status, static::NOTIFIED_ELSEWHERE, true)) { + // Bookings have no dedicated step notifications (those speak of deliveries), so every + // step after the request is announced here. + $status = (string) $order->status; + $skipped = Storefront::isBookingOrder($order) ? ['created', 'pending'] : static::NOTIFIED_ELSEWHERE; + if ($status === '' || in_array($status, $skipped, true)) { return; } diff --git a/server/src/Support/Storefront.php b/server/src/Support/Storefront.php index 98cf68fe..06fe8ca4 100644 --- a/server/src/Support/Storefront.php +++ b/server/src/Support/Storefront.php @@ -33,6 +33,12 @@ class Storefront private const CONFIG_NS = 'system:order-config:storefront'; /** @var array In-memory cache keyed by company UUID */ private static array $configCache = []; + /** @var string */ + public const BOOKING_CONFIG_KEY = 'storefront_booking'; + /** @var string */ + public const BOOKING_CONFIG_NS = 'system:order-config:storefront-booking'; + /** @var array In-memory cache of booking configs keyed by company UUID */ + private static array $bookingConfigCache = []; /** * Returns current store or network based on session `storefront_key` @@ -574,6 +580,108 @@ public static function createStorefrontConfig(Company|string $company): OrderCon ); } + /** + * Whether an order is a service booking: it holds at least one booked service, and any + * products in it come with the (earliest) appointment. + */ + public static function isBookingOrder($order): bool + { + return $order instanceof Order && $order->isMeta('is_booking'); + } + + /** + * Get (or lazily create) the Storefront booking OrderConfig for a company. Bookings have + * their own steps (confirmed, provider on the way, in progress...) instead of delivery's, + * and a config of their own so the delivery config, which may be customised, is untouched. + */ + public static function getBookingOrderConfig(Company|string|null $company): ?OrderConfig + { + $companyUuid = static::resolveCompanyUuid($company); + if (!$companyUuid) { + return null; + } + + if (isset(static::$bookingConfigCache[$companyUuid])) { + return static::$bookingConfigCache[$companyUuid]; + } + + $config = OrderConfig::where(['company_uuid' => $companyUuid, 'key' => self::BOOKING_CONFIG_KEY, 'namespace' => self::BOOKING_CONFIG_NS])->first() + ?? DB::transaction(fn () => static::createBookingConfig($companyUuid)); + + return static::$bookingConfigCache[$companyUuid] = $config; + } + + /** + * Create (or retrieve) the Storefront booking config for a company. + * + * At the customer's address: requested, confirmed, provider assigned, on the way, arrived, + * in progress, completed. At the store (a pickup booking) the customer comes in, so it goes + * from confirmed straight to in progress: only "provider assigned" is conditional, so it is + * the branch taken when the booking is at the customer's address. + */ + public static function createBookingConfig(Company|string $company): OrderConfig + { + $companyUuid = $company instanceof Company ? $company->uuid : $company; + $atCustomer = [['type' => 'not', 'conditions' => [['field' => 'meta.is_pickup', 'operator' => 'equal', 'value' => 'true']]]]; + + $flow = [ + 'created' => static::bookingActivity('created', 'Booking requested', '{storefront.name} will confirm your booking shortly.', ['accepted', 'canceled']), + 'accepted' => static::bookingActivity('accepted', 'Booking confirmed', '{storefront.name} has confirmed your booking.', ['dispatched', 'in_progress', 'canceled']), + 'dispatched' => static::bookingActivity('dispatched', 'Provider assigned', 'Your provider has been assigned and will set off in time for your appointment.', ['started'], $atCustomer), + 'started' => static::bookingActivity('started', 'Provider on the way', 'Your provider is on the way to you.', ['provider_arrived']), + 'provider_arrived' => static::bookingActivity('provider_arrived', 'Provider arrived', 'Your provider has arrived.', ['in_progress']), + 'in_progress' => static::bookingActivity('in_progress', 'Service in progress', 'Your booking with {storefront.name} is under way.', ['completed']), + 'completed' => static::bookingActivity('completed', 'Booking completed', 'Your booking with {storefront.name} is complete.', [], [], ['complete' => true]), + 'canceled' => static::bookingActivity('canceled', 'Booking canceled', 'Your booking was canceled.', [], [], ['events' => ['order.canceled']]), + ]; + + return OrderConfig::firstOrCreate( + [ + 'company_uuid' => $companyUuid, + 'key' => self::BOOKING_CONFIG_KEY, + 'namespace' => self::BOOKING_CONFIG_NS, + ], + [ + 'name' => 'Storefront Booking', + 'key' => self::BOOKING_CONFIG_KEY, + 'namespace' => self::BOOKING_CONFIG_NS, + 'description' => 'Storefront order configuration for booked services, at the customer\'s address or at the store', + 'core_service' => 1, + 'status' => 'private', + 'version' => '0.0.1', + 'tags' => ['storefront', 'booking', 'services'], + 'entities' => [], + 'meta' => [], + 'flow' => $flow, + ] + ); + } + + /** + * One activity of the booking flow, in the same shape as the delivery flow's. + */ + protected static function bookingActivity(string $code, string $status, string $details, array $activities, array $logic = [], array $overrides = []): array + { + return array_merge([ + 'key' => $code, + 'code' => $code, + 'color' => '#1f2937', + 'logic' => $logic, + 'events' => [], + 'status' => $status, + 'actions' => [], + 'details' => $details, + 'options' => [], + 'complete' => false, + 'entities' => [], + 'sequence' => 0, + 'activities' => $activities, + 'internalId' => (string) Str::uuid(), + 'pod_method' => 'scan', + 'require_pod' => false, + ], $overrides); + } + /** * Normalize a Company|UUID|null to a UUID string (or null if unresolved). */ @@ -592,6 +700,12 @@ protected static function resolveCompanyUuid(Company|string|null $company): ?str public static function createAcceptedActivity(?OrderConfig $orderConfig = null): Activity { + // A config with its own acceptance step (e.g. bookings: "Booking confirmed") uses it. + $flow = $orderConfig ? $orderConfig->flow : null; + if (is_array($flow) && is_array($flow['accepted'] ?? null)) { + return new Activity($flow['accepted'], $orderConfig->activities()->toArray()); + } + return new Activity([ 'key' => 'accepted', 'code' => 'accepted', @@ -633,9 +747,11 @@ public static function autoAcceptOrder(Order $order) return response()->error('Unable to accept order.'); } - // Notify customer order was accepted + // Notify customer order was accepted (a booking's confirmation is sent as its activity) try { - $order->customer->notify(new StorefrontOrderAccepted($order)); + if (!static::isBookingOrder($order)) { + $order->customer->notify(new StorefrontOrderAccepted($order)); + } } catch (\Throwable $e) { Log::error('[Storefront] was unable to notify the customer that their order was accepted.', ['order' => $order->public_id, 'error' => $e->getMessage()]); } @@ -648,6 +764,11 @@ public static function autoDispatchOrder(Order $order, bool $adhoc = true) // Patch order config Storefront::patchOrderConfig($order); + // An at-store booking has nobody to dispatch: it starts when the customer comes in. + if (static::isBookingOrder($order) && $order->isMeta('is_pickup')) { + return $order; + } + if ($order->isMeta('is_pickup')) { $order->updateStatus('pickup_ready'); From b9165c86309de4f171e5bbb7865d96666e29bbe6 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 9 Oct 2026 15:39:20 +0800 Subject: [PATCH 027/193] fix(orders): console can move orders on a custom flow to their next activity --- server/src/routes.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/src/routes.php b/server/src/routes.php index 92d8f71a..779a3765 100644 --- a/server/src/routes.php +++ b/server/src/routes.php @@ -223,6 +223,8 @@ function ($router, $controller) { $router->post('completed', $controller('markOrderAsCompleted')); $router->patch('cancel', $controller('rejectOrder')); $router->post('unassign-driver', $controller('unassignDriver')); + // Moves an order on a custom flow (e.g. a booking) to its next activity. + $router->patch('update-activity/{id}', $controller('updateActivity')); } ); $router->fleetbaseRoutes( From 6c5e71c6d3f19b48f7faf468abdf23820c0ec3a3 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 9 Oct 2026 16:44:57 +0800 Subject: [PATCH 028/193] fix(network): multi-store delivery quotes measure the real route; network review setting is enforced --- .../Http/Controllers/v1/ReviewController.php | 34 ++++++++++++++++++ .../Controllers/v1/ServiceQuoteController.php | 35 +++++++++++++++++-- 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/server/src/Http/Controllers/v1/ReviewController.php b/server/src/Http/Controllers/v1/ReviewController.php index e33b7ebd..dbce143a 100644 --- a/server/src/Http/Controllers/v1/ReviewController.php +++ b/server/src/Http/Controllers/v1/ReviewController.php @@ -8,6 +8,7 @@ use Fleetbase\Models\File; use Fleetbase\Storefront\Http\Requests\CreateReviewRequest; use Fleetbase\Storefront\Http\Resources\Review as StorefrontReview; +use Fleetbase\Storefront\Models\Network; use Fleetbase\Storefront\Models\Product; use Fleetbase\Storefront\Models\Review; use Fleetbase\Storefront\Models\Store; @@ -71,6 +72,23 @@ protected function subjectBelongsToContext($subject): bool return false; } + /** + * Whether reviews are switched off for this storefront. A network's setting covers all of + * its stores and reviews are only on when it is switched on; a single store's app keeps + * reviews unless the store switches them off. + */ + protected static function reviewsDisabled(): bool + { + $about = Storefront::about(); + if (!$about) { + return false; + } + + $enabled = data_get($about->options, 'reviews_enabled'); + + return $about instanceof Network ? $enabled !== true : $enabled === false; + } + /** * Query for Storefront Review resources. * @@ -78,6 +96,10 @@ protected function subjectBelongsToContext($subject): bool */ public function query(Request $request) { + if (static::reviewsDisabled()) { + return StorefrontReview::collection([]); + } + $results = []; $limit = $request->input('limit', false); $offset = $request->input('offset', false); @@ -173,6 +195,10 @@ public function count(Request $request) $counts = []; $range = range(1, 5); + if (static::reviewsDisabled()) { + return response()->json(array_fill_keys($range, 0)); + } + if (session('storefront_store')) { foreach ($range as $rating) { $counts[$rating] = Review::where(['subject_uuid' => session('storefront_store'), 'rating' => $rating])->count(); @@ -237,6 +263,10 @@ public function eligibility(Request $request) return response()->error('Invalid subject for review'); } + if (static::reviewsDisabled()) { + return response()->json(['can_review' => false, 'reason' => 'reviews_disabled', 'message' => 'Reviews are turned off.', 'order' => null, 'review' => null]); + } + $eligibility = ReviewEligibility::check(Storefront::getCustomerFromToken(), $subject, $request->input('order')); return response()->json($eligibility->toArray()); @@ -258,6 +288,10 @@ public function create(CreateReviewRequest $request) return response()->error('Not authorized to create reviews'); } + if (static::reviewsDisabled()) { + return response()->json(['error' => 'Reviews are turned off.', 'reason' => 'reviews_disabled'], 403); + } + $subject = Utils::resolveSubject($request->input('subject')); if (!$subject || !$this->subjectBelongsToContext($subject)) { diff --git a/server/src/Http/Controllers/v1/ServiceQuoteController.php b/server/src/Http/Controllers/v1/ServiceQuoteController.php index cee388a0..4e791897 100644 --- a/server/src/Http/Controllers/v1/ServiceQuoteController.php +++ b/server/src/Http/Controllers/v1/ServiceQuoteController.php @@ -10,6 +10,7 @@ use Fleetbase\FleetOps\Models\ServiceQuoteItem; use Fleetbase\FleetOps\Models\ServiceRate; use Fleetbase\FleetOps\Models\Vehicle; +use Fleetbase\FleetOps\Support\DistanceMatrix; use Fleetbase\FleetOps\Support\Utils; use Fleetbase\Http\Controllers\Controller; use Fleetbase\Storefront\Http\Requests\GetServiceQuoteFromCart; @@ -423,11 +424,41 @@ protected function getDrivingMatrix(Place $origin, Place $destination): object } /** - * Resolve the multi-origin distance matrix used to quote a network order. + * Resolve the distance and time of a network order's route: the driver collects from each + * store, then delivers. Each leg is measured like a single store's (`getDrivingMatrix`) and + * the legs are added up. + * + * The stores are visited farthest from the customer first, then each nearest next, so the + * route ends close to the customer. (`Utils::distanceMatrix` cannot be used here: it joins + * the stores into one origin string, which the straight-line provider misreads and Google + * answers for the first store only.) */ protected function getNetworkDistanceMatrix($origins, Place $destination): object { - return Utils::distanceMatrix($origins, [$destination]); + $remaining = collect($origins)->filter()->values(); + if ($remaining->isEmpty()) { + return new DistanceMatrix(0, 0); + } + + $straightLine = fn ($from, $to) => (float) Utils::getPreliminaryDistanceMatrix($from, $to)->distance; + + $current = $remaining->sortByDesc(fn ($place) => $straightLine($place, $destination))->first(); + $remaining = $remaining->reject(fn ($place) => $place === $current)->values(); + $distance = 0; + $time = 0; + + while ($remaining->isNotEmpty()) { + $next = $remaining->sortBy(fn ($place) => $straightLine($current, $place))->first(); + $leg = $this->getDrivingMatrix($current, $next); + $distance += (float) $leg->distance; + $time += (float) $leg->time; + $current = $next; + $remaining = $remaining->reject(fn ($place) => $place === $next)->values(); + } + + $leg = $this->getDrivingMatrix($current, $destination); + + return new DistanceMatrix($distance + (float) $leg->distance, $time + (float) $leg->time); } /** From c8af4136d48d229f80333f240c56e003645620f3 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 9 Oct 2026 17:23:53 +0800 Subject: [PATCH 029/193] feat(network): networks can split store tips across the stores of a multi-store order; otherwise the tip goes to the network --- .../networks/index/network/index.hbs | 10 ++++ .../Controllers/v1/CheckoutController.php | 51 ++++++++++++++++++- translations/ar-ae.yaml | 1 + translations/bg-bg.yaml | 1 + translations/en-us.yaml | 1 + translations/es-es.yaml | 1 + translations/fr-fr.yaml | 1 + translations/mn-mn.yaml | 1 + translations/pt-br.yaml | 1 + translations/ru-ru.yaml | 1 + translations/uk-ua.yaml | 1 + translations/vi-vn.yaml | 1 + translations/zh-cn.yaml | 1 + 13 files changed, 71 insertions(+), 1 deletion(-) diff --git a/addon/templates/networks/index/network/index.hbs b/addon/templates/networks/index/network/index.hbs index 925f9791..4666f10b 100644 --- a/addon/templates/networks/index/network/index.hbs +++ b/addon/templates/networks/index/network/index.hbs @@ -268,6 +268,16 @@ }} + {{#if (and @model.options.tips_enabled @model.options.multi_cart_enabled)}} + {{! Off: a multi-store order's store tip goes to the network. On: it is split across the stores by their share of the order. }} +
+ + {{t + "storefront.networks.index.network.index.general-network-settings-form.config-switches.split-tips-across-stores" + }} + +
+ {{/if}}
{{t diff --git a/server/src/Http/Controllers/v1/CheckoutController.php b/server/src/Http/Controllers/v1/CheckoutController.php index 446b0353..e2fc5f43 100644 --- a/server/src/Http/Controllers/v1/CheckoutController.php +++ b/server/src/Http/Controllers/v1/CheckoutController.php @@ -1778,6 +1778,20 @@ public function captureMultipleOrders(CaptureOrderRequest $request) }); $destination = Place::createFromMixed($destination); + // The store tip goes to the network that runs the app, unless the network splits it + // across the stores, each by its share of the order. + $splitTips = $checkout->hasOption('tip') && $about->isOption('split_tips_across_stores'); + $tipShares = []; + if ($splitTips) { + $storeSubtotals = $origins + ->map(fn ($pickup) => Storefront::getStoreFromLocation($pickup->uuid)) + ->filter() + ->unique('public_id') + ->mapWithKeys(fn ($store) => [$store->public_id => (int) $cart->getSubtotalForStore($store)]) + ->all(); + $tipShares = static::splitByShare((int) static::calculateTipAmount($checkout->getOption('tip'), $cart->subtotal), $storeSubtotals); + } + $multipleOrders = []; foreach ($origins as $pickup) { @@ -1815,7 +1829,8 @@ public function captureMultipleOrders(CaptureOrderRequest $request) 'checkout_id' => $checkout->public_id, 'subtotal' => $subtotal, 'delivery_fee' => 0, - 'tip' => 0, + 'tip' => $tipShares[$store->public_id] ?? 0, + 'tip_recipient' => $splitTips ? 'store' : 'network', 'delivery_tip' => 0, 'discount' => $storeDiscount, 'total' => $subtotal - $storeDiscount, @@ -1907,6 +1922,7 @@ public function captureMultipleOrders(CaptureOrderRequest $request) 'subtotal' => Utils::numbersOnly($cart->subtotal), 'delivery_fee' => $checkout->is_pickup ? 0 : Utils::numbersOnly($serviceQuote->amount), 'tip' => $checkout->getOption('tip'), + 'tip_recipient' => $splitTips ? 'stores' : 'network', 'delivery_tip' => $checkout->getOption('delivery_tip'), 'discount' => $promotions->discount(), 'promotions' => $promotions->toPublicArray()['applied'], @@ -2310,6 +2326,39 @@ protected static function reservePromotions(Checkout $checkout, $checkoutOptions return null; } + /** + * Split an amount across stores in proportion to their subtotals. Shares are whole cents + * that add up to the amount; the cents left over by rounding go to the largest shares. + * + * @param array $subtotals store id => subtotal + * + * @return array store id => share + */ + protected static function splitByShare(int $amount, array $subtotals): array + { + $total = array_sum($subtotals); + if ($amount <= 0 || $total <= 0) { + return array_map(fn () => 0, $subtotals); + } + + $shares = []; + foreach ($subtotals as $storeId => $subtotal) { + $shares[$storeId] = intdiv($amount * $subtotal, $total); + } + + $left = $amount - array_sum($shares); + arsort($subtotals); + foreach (array_keys($subtotals) as $storeId) { + if ($left <= 0) { + break; + } + $shares[$storeId]++; + $left--; + } + + return $shares; + } + private static function calculateTipAmount($tip, $subtotal) { $tipAmount = 0; diff --git a/translations/ar-ae.yaml b/translations/ar-ae.yaml index b19c5612..fb433bfc 100644 --- a/translations/ar-ae.yaml +++ b/translations/ar-ae.yaml @@ -404,6 +404,7 @@ storefront: enable-tips: تفعيل الإكراميات enable-delivery-tips: تفعيل إكراميات التوصيل enable-multi-cart-checkout: تفعيل الدفع لعربات متعددة + split-tips-across-stores: "تقسيم الإكراميات بين المتاجر في الطلبات متعددة المتاجر" enable-user-reviews: تفعيل تقييمات المستخدمين api-panel: panel-title: واجهة برمجة التطبيقات diff --git a/translations/bg-bg.yaml b/translations/bg-bg.yaml index eebf92e4..ec596968 100644 --- a/translations/bg-bg.yaml +++ b/translations/bg-bg.yaml @@ -422,6 +422,7 @@ storefront: enable-tips: Активиране на бакшиши enable-delivery-tips: Активиране на бакшиши за доставка enable-multi-cart-checkout: Активиране на плащане с няколко колички + split-tips-across-stores: "Разпределяне на бакшишите между магазините при поръчки от няколко магазина" enable-user-reviews: Активиране на потребителски отзиви api-panel: panel-title: API diff --git a/translations/en-us.yaml b/translations/en-us.yaml index 4995aeb3..6ed1d6bd 100644 --- a/translations/en-us.yaml +++ b/translations/en-us.yaml @@ -394,6 +394,7 @@ storefront: enable-tips: Enable tips enable-delivery-tips: Enable delivery tips enable-multi-cart-checkout: Enable multi-cart checkout + split-tips-across-stores: "Split store tips across stores in multi-store orders" enable-user-reviews: Enable user reviews api-panel: panel-title: API diff --git a/translations/es-es.yaml b/translations/es-es.yaml index 62bb3713..f120758d 100644 --- a/translations/es-es.yaml +++ b/translations/es-es.yaml @@ -420,6 +420,7 @@ storefront: enable-tips: Habilitar propinas enable-delivery-tips: Habilitar propinas para entrega enable-multi-cart-checkout: Habilitar pago de múltiples carritos + split-tips-across-stores: "Repartir las propinas entre las tiendas en pedidos de varias tiendas" enable-user-reviews: Habilitar reseñas de usuarios api-panel: panel-title: API diff --git a/translations/fr-fr.yaml b/translations/fr-fr.yaml index 75d2880f..3fa3a392 100644 --- a/translations/fr-fr.yaml +++ b/translations/fr-fr.yaml @@ -433,6 +433,7 @@ storefront: enable-tips: Activer les pourboires enable-delivery-tips: Activer les pourboires de livraison enable-multi-cart-checkout: Activer le paiement multi-panier + split-tips-across-stores: "Répartir les pourboires entre les boutiques dans les commandes multi-boutiques" enable-user-reviews: Activer les avis utilisateurs api-panel: panel-title: API diff --git a/translations/mn-mn.yaml b/translations/mn-mn.yaml index 38aa4ffb..d7cdff31 100644 --- a/translations/mn-mn.yaml +++ b/translations/mn-mn.yaml @@ -414,6 +414,7 @@ storefront: enable-tips: Зөвлөмжийг идэвхжүүлэх enable-delivery-tips: Хүргэлтийн зөвлөмжийг идэвхжүүлэх enable-multi-cart-checkout: Олон сагснаас төлбөр хийхийг идэвхжүүлэх + split-tips-across-stores: "Олон дэлгүүрийн захиалгад цайны мөнгийг дэлгүүрүүдэд хуваах" enable-user-reviews: Хэрэглэгчийн үнэлгээг идэвхжүүлэх api-panel: panel-title: API diff --git a/translations/pt-br.yaml b/translations/pt-br.yaml index 84bd755e..e4d2dc60 100644 --- a/translations/pt-br.yaml +++ b/translations/pt-br.yaml @@ -421,6 +421,7 @@ storefront: enable-tips: Ativar gorjetas enable-delivery-tips: Ativar gorjetas para entrega enable-multi-cart-checkout: Ativar checkout para múltiplos carrinhos + split-tips-across-stores: "Dividir as gorjetas entre as lojas em pedidos de várias lojas" enable-user-reviews: Ativar avaliações de usuários api-panel: panel-title: API diff --git a/translations/ru-ru.yaml b/translations/ru-ru.yaml index df34f2ae..20a565f1 100644 --- a/translations/ru-ru.yaml +++ b/translations/ru-ru.yaml @@ -417,6 +417,7 @@ storefront: enable-tips: Включить чаевые enable-delivery-tips: Включить чаевые за доставку enable-multi-cart-checkout: Включить оформление заказа из нескольких + split-tips-across-stores: "Распределять чаевые между магазинами в заказах из нескольких магазинов" корзин enable-user-reviews: Включить отзывы пользователей api-panel: diff --git a/translations/uk-ua.yaml b/translations/uk-ua.yaml index 38fe4995..848cd75d 100644 --- a/translations/uk-ua.yaml +++ b/translations/uk-ua.yaml @@ -386,6 +386,7 @@ storefront: enable-tips: Увімкнути чайові enable-delivery-tips: Увімкнути чайові кур'єру enable-multi-cart-checkout: Увімкнути оформлення з кількох кошиків + split-tips-across-stores: "Розподіляти чайові між магазинами в замовленнях із кількох магазинів" enable-user-reviews: Увімкнути відгуки користувачів api-panel: panel-title: API diff --git a/translations/vi-vn.yaml b/translations/vi-vn.yaml index e4d5d1a0..effb1087 100644 --- a/translations/vi-vn.yaml +++ b/translations/vi-vn.yaml @@ -417,6 +417,7 @@ storefront: enable-tips: Bật tiền boa enable-delivery-tips: Bật tiền boa cho giao hàng enable-multi-cart-checkout: Bật thanh toán nhiều giỏ hàng + split-tips-across-stores: "Chia tiền tip cho các cửa hàng trong đơn hàng nhiều cửa hàng" enable-user-reviews: Bật đánh giá người dùng api-panel: panel-title: API diff --git a/translations/zh-cn.yaml b/translations/zh-cn.yaml index 28fcab63..ac31946e 100644 --- a/translations/zh-cn.yaml +++ b/translations/zh-cn.yaml @@ -382,6 +382,7 @@ storefront: enable-tips: 启用小费 enable-delivery-tips: 启用配送小费 enable-multi-cart-checkout: 启用多购物车结账 + split-tips-across-stores: "在多店铺订单中将小费分配给各店铺" enable-user-reviews: 启用用户评价 api-panel: panel-title: API From 02f9ff8618507a7e4dc428e1c1e423526d9c8b98 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 9 Oct 2026 17:34:18 +0800 Subject: [PATCH 030/193] fix(i18n): keep the Russian multi-cart label on its own lines so the translations build again c8af413 inserted the split-tips label between the two lines of the enable-multi-cart-checkout value, which js-yaml reads as a bad mapping entry and the engine build fails. --- translations/ru-ru.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/translations/ru-ru.yaml b/translations/ru-ru.yaml index 20a565f1..d815e0f5 100644 --- a/translations/ru-ru.yaml +++ b/translations/ru-ru.yaml @@ -417,8 +417,8 @@ storefront: enable-tips: Включить чаевые enable-delivery-tips: Включить чаевые за доставку enable-multi-cart-checkout: Включить оформление заказа из нескольких - split-tips-across-stores: "Распределять чаевые между магазинами в заказах из нескольких магазинов" корзин + split-tips-across-stores: "Распределять чаевые между магазинами в заказах из нескольких магазинов" enable-user-reviews: Включить отзывы пользователей api-panel: panel-title: API From c833b5c8ad29481058d7d5418b3b22d40f3c8d63 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 9 Oct 2026 17:46:29 +0800 Subject: [PATCH 031/193] feat(identity): drivers, customers, vehicles and places render through the shared identity cells, pills and select options The orders, network orders, customers and network customers tables use table/cell/identity typed for the shared fleetops-data descriptors, reading the loaded relation or an identity stub named after the row, so a row shows the right tile and name before the relation loads. Clicking a customer opens the storefront customer page; a driver or place opens its FleetOps panel, loading the engine only then. The order details, fulfillment and customer panels, the incoming-order modal, the orders and customers widgets and the food-truck cards use Customer::Pill, Driver::Pill and Vehicle::Pill in place of hand-rolled avatar blocks, whose styles are removed. Driver, vehicle, customer, user and category selects render SelectOption rows with compact selected items. The fleetops-data range moves to ^0.2.3, the version the workspace links. --- addon/components/modals/assign-driver.hbs | 3 +- addon/components/modals/create-food-truck.hbs | 11 +-- .../modals/create-network-category.hbs | 2 +- addon/components/modals/import-products.hbs | 2 +- addon/components/modals/incoming-order.hbs | 24 ++----- .../modals/order-ready-assign-driver.hbs | 3 +- .../storefront/order/details/customer.hbs | 16 ++--- .../storefront/order/details/detail.hbs | 18 +---- .../storefront/order/details/fulfillment.hbs | 13 +--- addon/components/widget/customers.hbs | 11 +-- addon/components/widget/orders.hbs | 20 +++--- addon/controllers/customers/index.js | 3 +- .../networks/index/network/customers.js | 3 +- .../networks/index/network/orders.js | 38 ++++++++-- addon/controllers/orders/index.js | 38 ++++++++-- addon/styles/storefront-engine.css | 41 ----------- addon/templates/food-trucks/index.hbs | 7 +- .../networks/index/network/index.hbs | 3 +- .../promotions/push-notifications.hbs | 19 +---- addon/templates/settings/index.hbs | 3 +- package.json | 2 +- .../components/shared-identity-test.js | 71 +++++++++++++++++++ .../storefront/order/details-test.js | 26 +++++++ .../components/widget/customers-test.js | 2 +- .../unit/controllers/customers/index-test.js | 9 +++ .../networks/index/network/customers-test.js | 10 ++- .../networks/index/network/orders-test.js | 21 +++++- tests/unit/controllers/orders/index-test.js | 66 +++++++++++++++++ 28 files changed, 312 insertions(+), 173 deletions(-) create mode 100644 tests/integration/components/shared-identity-test.js diff --git a/addon/components/modals/assign-driver.hbs b/addon/components/modals/assign-driver.hbs index 02f6de6a..88b0947d 100644 --- a/addon/components/modals/assign-driver.hbs +++ b/addon/components/modals/assign-driver.hbs @@ -16,6 +16,7 @@ - {{model.name}} +
diff --git a/addon/components/modals/create-food-truck.hbs b/addon/components/modals/create-food-truck.hbs index 41b9a9e9..3332b6b6 100644 --- a/addon/components/modals/create-food-truck.hbs +++ b/addon/components/modals/create-food-truck.hbs @@ -3,6 +3,7 @@ -
-
- -
-
-
{{model.display_name}}
-
{{model.plate_number}}
-
-
+
diff --git a/addon/components/modals/create-network-category.hbs b/addon/components/modals/create-network-category.hbs index 0a489992..832f5419 100644 --- a/addon/components/modals/create-network-category.hbs +++ b/addon/components/modals/create-network-category.hbs @@ -12,7 +12,7 @@ @onChange={{@options.setParentCategory}} as |model| > - {{model.name}} + - {{model.name}} + diff --git a/addon/components/modals/incoming-order.hbs b/addon/components/modals/incoming-order.hbs index 9accc241..fba4a8f3 100644 --- a/addon/components/modals/incoming-order.hbs +++ b/addon/components/modals/incoming-order.hbs @@ -34,16 +34,7 @@
{{t "storefront.component.modals.incoming-order.assigned"}}
{{#if @options.order.driver_assigned.id}} -
- {{@options.order.driver_assigned.name}} -
-
{{n-a @options.order.driver_assigned.displayName}}
-
{{n-a - @options.order.driver_assigned.phone - (t "storefront.component.modals.incoming-order.no-phone") - }}
-
-
+ {{else}}
{{t "storefront.component.modals.incoming-order.not-assigned"}}
@@ -72,16 +63,9 @@
{{t "storefront.common.customer"}}
-
-
- {{@options.order.customer.name}} -
-
-
{{@options.order.customer.name}}
-
{{@options.order.customer.email}}
-
{{@options.order.customer.phone}}
-
-
+ +
{{n-a @options.order.customer.email}}
+
{{n-a @options.order.customer.phone}}
{{#unless @options.order.meta.is_pickup}}
{{t "storefront.component.modals.incoming-order.address"}}
diff --git a/addon/components/modals/order-ready-assign-driver.hbs b/addon/components/modals/order-ready-assign-driver.hbs index 83e23bc6..9e313645 100644 --- a/addon/components/modals/order-ready-assign-driver.hbs +++ b/addon/components/modals/order-ready-assign-driver.hbs @@ -29,6 +29,7 @@ - {{model.name}} + {{/unless}} diff --git a/addon/components/storefront/order/details/customer.hbs b/addon/components/storefront/order/details/customer.hbs index d0eb04a3..59957374 100644 --- a/addon/components/storefront/order/details/customer.hbs +++ b/addon/components/storefront/order/details/customer.hbs @@ -1,17 +1,9 @@
-
- {{@resource.customer.name}} -
-
{{n-a @resource.customer.name "No customer"}}
-
{{n-a @resource.customer.email "No email"}}
-
{{n-a @resource.customer.phone "No phone"}}
-
+ +
+
Email{{n-a @resource.customer.email "No email"}}
+
Phone{{n-a @resource.customer.phone "No phone"}}
{{#unless @resource.meta.is_pickup}} diff --git a/addon/components/storefront/order/details/detail.hbs b/addon/components/storefront/order/details/detail.hbs index ca92fadc..9ffc644b 100644 --- a/addon/components/storefront/order/details/detail.hbs +++ b/addon/components/storefront/order/details/detail.hbs @@ -3,27 +3,13 @@
Customer
-
- {{@resource.customer.name}} -
-
{{n-a @resource.customer.name "No customer"}}
-
{{n-a (or @resource.customer.phone @resource.customer.email) "No contact details"}}
-
-
+
Driver
-
- {{@resource.driver_assigned.name}} -
-
- {{n-a (or @resource.driver_assigned.displayName @resource.driver_assigned.name) "No driver assigned"}} -
-
{{n-a @resource.driver_assigned.phone "No phone"}}
-
-
+
diff --git a/addon/components/storefront/order/details/fulfillment.hbs b/addon/components/storefront/order/details/fulfillment.hbs index 508ca07d..36e5b262 100644 --- a/addon/components/storefront/order/details/fulfillment.hbs +++ b/addon/components/storefront/order/details/fulfillment.hbs @@ -3,18 +3,7 @@
Driver
-
- {{@resource.driver_assigned.name}} -
-
{{n-a (or @resource.driver_assigned.displayName @resource.driver_assigned.name) "No driver assigned"}}
-
{{n-a @resource.driver_assigned.phone "No phone"}}
-
-
+
Schedule
diff --git a/addon/components/widget/customers.hbs b/addon/components/widget/customers.hbs index f9268eb5..afb75753 100644 --- a/addon/components/widget/customers.hbs +++ b/addon/components/widget/customers.hbs @@ -15,10 +15,8 @@ {{#each this.customers as |customer|}}
- {{customer.name}}
- {{n-a customer.name}} -
{{customer.public_id}}
+
{{n-a customer.orders 0}}
@@ -32,7 +30,7 @@ {{/each}}
{{else}} -
+
@@ -48,10 +46,7 @@ diff --git a/addon/components/widget/orders.hbs b/addon/components/widget/orders.hbs index 3ea48856..d256ad12 100644 --- a/addon/components/widget/orders.hbs +++ b/addon/components/widget/orders.hbs @@ -26,14 +26,12 @@
- {{n-a order.customer.name}} - - {{#if order.meta.is_pickup}} - {{t "storefront.component.widget.orders.pickup-order"}} - {{else}} - {{n-a order.driver_assigned.name}} - {{/if}} - + + {{#if order.meta.is_pickup}} + {{t "storefront.component.widget.orders.pickup-order"}} + {{else}} + + {{/if}}
@@ -46,7 +44,7 @@ {{/each}}
{{else}} -
+
{{customer.public_id}} -
- {{customer.name}} - {{n-a customer.name}} -
+
{{n-a customer.phone}} {{n-a customer.email}}
@@ -68,14 +66,14 @@ - + diff --git a/addon/controllers/customers/index.js b/addon/controllers/customers/index.js index 6242a732..72ffa14e 100644 --- a/addon/controllers/customers/index.js +++ b/addon/controllers/customers/index.js @@ -123,7 +123,8 @@ export default class CustomersIndexController extends BaseController { label: this.intl.t('storefront.common.name'), valuePath: 'name', width: '15%', - cellComponent: 'table/cell/media-name', + cellComponent: 'table/cell/identity', + resourceType: 'customer', action: this.viewCustomer, resizable: true, sortable: true, diff --git a/addon/controllers/networks/index/network/customers.js b/addon/controllers/networks/index/network/customers.js index 26776546..6d8f8dfc 100644 --- a/addon/controllers/networks/index/network/customers.js +++ b/addon/controllers/networks/index/network/customers.js @@ -15,7 +15,8 @@ export default class NetworksIndexNetworkCustomersController extends CustomersIn label: this.intl.t('storefront.common.name'), valuePath: 'name', width: '25%', - cellComponent: 'table/cell/media-name', + cellComponent: 'table/cell/identity', + resourceType: 'customer', action: this.viewCustomer, resizable: true, sortable: true, diff --git a/addon/controllers/networks/index/network/orders.js b/addon/controllers/networks/index/network/orders.js index eccfe065..2411a5d0 100644 --- a/addon/controllers/networks/index/network/orders.js +++ b/addon/controllers/networks/index/network/orders.js @@ -1,4 +1,6 @@ import BaseController from '@fleetbase/storefront-engine/controllers/base-controller'; +import { relationValue } from '@fleetbase/ember-ui/utils/resource-registry'; +import { buildIdentityStub } from '@fleetbase/fleetops-data/utils/identity-stub'; import { inject as service } from '@ember/service'; import { tracked, cached } from '@glimmer/tracking'; import { isBlank } from '@ember/utils'; @@ -11,6 +13,7 @@ export default class NetworksIndexNetworkOrdersController extends BaseController * * @var {Service} */ + @service store; @service notifications; /** @@ -101,7 +104,11 @@ export default class NetworksIndexNetworkOrdersController extends BaseController id: 'customer-name', label: this.intl.t('storefront.orders.index.customer'), valuePath: 'customer.name', - cellComponent: 'table/cell/base', + cellComponent: 'table/cell/identity', + resourceType: 'customer', + resourcePath: (order) => relationValue(order, 'customer') ?? buildIdentityStub(order, { type: order.customer_type ?? 'customer', nameKey: 'customer_name' }), + action: this.viewCustomer, + emptyText: 'No customer', width: '125px', resizable: true, sortable: true, @@ -116,7 +123,9 @@ export default class NetworksIndexNetworkOrdersController extends BaseController id: 'pickup-name', label: this.intl.t('storefront.common.pickup'), valuePath: 'pickupName', - cellComponent: 'table/cell/base', + cellComponent: 'table/cell/identity', + resourceType: 'place', + resourcePath: (order) => relationValue(relationValue(order, 'payload'), 'pickup') ?? buildIdentityStub(order, { type: 'place', nameKey: 'pickupName' }), width: '160px', resizable: true, sortable: true, @@ -130,7 +139,9 @@ export default class NetworksIndexNetworkOrdersController extends BaseController id: 'dropoff-name', label: this.intl.t('storefront.common.dropoff'), valuePath: 'dropoffName', - cellComponent: 'table/cell/base', + cellComponent: 'table/cell/identity', + resourceType: 'place', + resourcePath: (order) => relationValue(relationValue(order, 'payload'), 'dropoff') ?? buildIdentityStub(order, { type: 'place', nameKey: 'dropoffName' }), width: '160px', resizable: true, sortable: true, @@ -185,9 +196,13 @@ export default class NetworksIndexNetworkOrdersController extends BaseController { id: 'driver-assigned', label: this.intl.t('storefront.orders.index.driver-assigned'), - cellComponent: 'table/cell/driver-name', + cellComponent: 'table/cell/identity', + resourceType: 'driver', valuePath: 'driver_assigned', - modelPath: 'driver_assigned', + resourcePath: (order) => + relationValue(order, 'driver_assigned') ?? + buildIdentityStub(order, { type: 'driver', load: () => (order.driver_assigned_uuid ? this.store.findRecord('driver', order.driver_assigned_uuid) : null) }), + emptyText: 'No driver assigned', width: '170px', resizable: true, sortable: true, @@ -339,6 +354,19 @@ export default class NetworksIndexNetworkOrdersController extends BaseController this.query = value; } + /** + * Opens the storefront customer behind an order. A stub that only carries + * the customer's name has no page to open. + * @param {Object} customer + */ + @action viewCustomer(customer) { + if (!customer?.public_id) { + return; + } + + return this.transitionToRoute('customers.index.view', customer.public_id); + } + @action viewOrder(order) { return this.storefrontOrderActions.viewOrder(order); } diff --git a/addon/controllers/orders/index.js b/addon/controllers/orders/index.js index e5575279..1e77eb19 100644 --- a/addon/controllers/orders/index.js +++ b/addon/controllers/orders/index.js @@ -1,4 +1,6 @@ import BaseController from '@fleetbase/storefront-engine/controllers/base-controller'; +import { relationValue } from '@fleetbase/ember-ui/utils/resource-registry'; +import { buildIdentityStub } from '@fleetbase/fleetops-data/utils/identity-stub'; import { tracked } from '@glimmer/tracking'; import { action, get } from '@ember/object'; import { inject as service } from '@ember/service'; @@ -7,6 +9,7 @@ import { isArray } from '@ember/array'; import { timeout, task } from 'ember-concurrency'; export default class OrdersIndexController extends BaseController { + @service store; @service notifications; @service intl; @service modalsManager; @@ -131,7 +134,11 @@ export default class OrdersIndexController extends BaseController { id: 'customer-name', label: this.intl.t('storefront.orders.index.customer'), valuePath: 'customer.name', - cellComponent: 'table/cell/base', + cellComponent: 'table/cell/identity', + resourceType: 'customer', + resourcePath: (order) => relationValue(order, 'customer') ?? buildIdentityStub(order, { type: order.customer_type ?? 'customer', nameKey: 'customer_name' }), + action: this.viewCustomer, + emptyText: 'No customer', width: '100px', resizable: true, sortable: true, @@ -157,7 +164,9 @@ export default class OrdersIndexController extends BaseController { id: 'pickup-name', label: this.intl.t('storefront.common.pickup'), valuePath: 'pickupName', - cellComponent: 'table/cell/base', + cellComponent: 'table/cell/identity', + resourceType: 'place', + resourcePath: (order) => relationValue(relationValue(order, 'payload'), 'pickup') ?? buildIdentityStub(order, { type: 'place', nameKey: 'pickupName' }), width: '150px', resizable: true, sortable: true, @@ -171,7 +180,9 @@ export default class OrdersIndexController extends BaseController { id: 'dropoff-name', label: this.intl.t('storefront.common.dropoff'), valuePath: 'dropoffName', - cellComponent: 'table/cell/base', + cellComponent: 'table/cell/identity', + resourceType: 'place', + resourcePath: (order) => relationValue(relationValue(order, 'payload'), 'dropoff') ?? buildIdentityStub(order, { type: 'place', nameKey: 'dropoffName' }), width: '150px', resizable: true, sortable: true, @@ -184,9 +195,13 @@ export default class OrdersIndexController extends BaseController { { id: 'driver-assigned', label: this.intl.t('storefront.orders.index.driver-assigned'), - cellComponent: 'table/cell/driver-name', + cellComponent: 'table/cell/identity', + resourceType: 'driver', valuePath: 'driver_assigned', - modelPath: 'driver_assigned', + resourcePath: (order) => + relationValue(order, 'driver_assigned') ?? + buildIdentityStub(order, { type: 'driver', load: () => (order.driver_assigned_uuid ? this.store.findRecord('driver', order.driver_assigned_uuid) : null) }), + emptyText: 'No driver assigned', width: '150px', resizable: true, sortable: true, @@ -388,6 +403,19 @@ export default class OrdersIndexController extends BaseController { this.query = value; } + /** + * Opens the storefront customer behind an order. A stub that only carries + * the customer's name has no page to open. + * @param {Object} customer + */ + @action viewCustomer(customer) { + if (!customer?.public_id) { + return; + } + + return this.transitionToRoute('customers.index.view', customer.public_id); + } + @action viewOrder(order) { return this.transitionToRoute('orders.index.view', order); } diff --git a/addon/styles/storefront-engine.css b/addon/styles/storefront-engine.css index 1b4350cb..9034af9d 100644 --- a/addon/styles/storefront-engine.css +++ b/addon/styles/storefront-engine.css @@ -1912,22 +1912,6 @@ body[data-theme='dark'] .storefront-widget-count { background-color: #1d4ed8; } -.storefront-customer-cell { - display: flex; - min-width: 0; - align-items: center; - gap: 0.65rem; -} - -.storefront-customer-avatar { - width: 2rem; - height: 2rem; - flex: 0 0 auto; - border-radius: 0.35rem; - object-fit: cover; - box-shadow: 0 1px 2px rgb(15 23 42 / 16%); -} - .storefront-customer-name { display: block; overflow: hidden; @@ -2244,24 +2228,12 @@ body[data-theme='dark'] .storefront-order-totals strong { gap: 0.75rem; } -.storefront-order-person--compact { - align-items: flex-start; -} - .storefront-order-store { display: flex; align-items: flex-start; gap: 0.9rem; } -.storefront-order-person__avatar { - width: 2.75rem; - height: 2.75rem; - flex: 0 0 auto; - border-radius: 0.5rem; - object-fit: cover; -} - .storefront-order-person__name { overflow: hidden; color: #111827; @@ -2275,19 +2247,6 @@ body[data-theme='dark'] .storefront-order-person__name { color: #f8fafc; } -.storefront-order-person__meta { - overflow: hidden; - color: #64748b; - font-size: 0.75rem; - font-weight: 600; - text-overflow: ellipsis; - white-space: nowrap; -} - -body[data-theme='dark'] .storefront-order-person__meta { - color: #94a3b8; -} - .storefront-order-facts, .storefront-order-totals { display: flex; diff --git a/addon/templates/food-trucks/index.hbs b/addon/templates/food-trucks/index.hbs index b707b1dd..61cf0250 100644 --- a/addon/templates/food-trucks/index.hbs +++ b/addon/templates/food-trucks/index.hbs @@ -9,11 +9,8 @@ {{#each @model as |foodTruck|}}
-
- -
-
-
{{foodTruck.vehicle.display_name}}
+
+
{{n-a foodTruck.service_area.name}}
diff --git a/addon/templates/networks/index/network/index.hbs b/addon/templates/networks/index/network/index.hbs index 4666f10b..e0d28ae5 100644 --- a/addon/templates/networks/index/network/index.hbs +++ b/addon/templates/networks/index/network/index.hbs @@ -139,6 +139,7 @@ - {{model.name}} +
diff --git a/addon/templates/promotions/push-notifications.hbs b/addon/templates/promotions/push-notifications.hbs index b74e9994..35f04270 100644 --- a/addon/templates/promotions/push-notifications.hbs +++ b/addon/templates/promotions/push-notifications.hbs @@ -37,6 +37,7 @@ -
-
- -
-
-
{{model.name}}
-
{{n-a model.email}}
-
{{n-a model.phone}}
-
-
+
{{/unless}} diff --git a/addon/templates/settings/index.hbs b/addon/templates/settings/index.hbs index 581dec48..3e3ee253 100644 --- a/addon/templates/settings/index.hbs +++ b/addon/templates/settings/index.hbs @@ -217,6 +217,7 @@ - {{model.name}} +
diff --git a/package.json b/package.json index 43ea033e..344a57db 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "@babel/core": "^7.23.2", "@fleetbase/ember-core": "^0.3.25", "@fleetbase/ember-ui": "^0.4.5", - "@fleetbase/fleetops-data": "^0.1.37", + "@fleetbase/fleetops-data": "^0.2.3", "@fortawesome/ember-fontawesome": "^2.0.0", "@fortawesome/fontawesome-svg-core": "6.4.0", "@fortawesome/free-brands-svg-icons": "6.4.0", diff --git a/tests/integration/components/shared-identity-test.js b/tests/integration/components/shared-identity-test.js new file mode 100644 index 00000000..00bf135c --- /dev/null +++ b/tests/integration/components/shared-identity-test.js @@ -0,0 +1,71 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import { initialize } from '@fleetbase/fleetops-data/instance-initializers/register-shared-resource-descriptors'; + +/** + * Storefront never loads the FleetOps engine to show a driver, customer or + * place: the descriptors and wrappers come from fleetops-data and the + * generic components from ember-ui, both of which this engine bundles. + */ +module('Integration | Component | shared identity components', function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + initialize(this.owner); + }); + + test('a driver identity cell renders the name, status dot and vehicle badge from the shared descriptor', async function (assert) { + this.set('row', { driver_assigned: { resourceType: 'driver', name: 'Ada Driver', status: 'available', vehicle_name: 'Truck 10' } }); + this.set('column', { resourceType: 'driver', resourcePath: 'driver_assigned', popover: false }); + + await render(hbs``); + + assert.dom('[data-test-identity-label]').hasText('Ada Driver'); + assert.dom('[data-test-resource-identity-status-dot]').hasClass('text-green-500'); + assert.dom('[data-test-resource-identity-meta-badge][data-badge-key="vehicle"]').hasText('Truck 10'); + }); + + test('an empty relation renders the column empty text instead of a cell', async function (assert) { + this.set('row', {}); + this.set('column', { resourceType: 'driver', resourcePath: 'driver_assigned', emptyText: 'No driver assigned' }); + + await render(hbs``); + + assert.dom('[data-test-identity-empty-text]').hasText('No driver assigned'); + assert.dom('[data-test-identity-cell]').doesNotExist(); + }); + + test('the customer, driver, place and vehicle pills and select options resolve inside storefront', async function (assert) { + this.set('customer', { resourceType: 'customer', name: 'Ava Chen', customer_type: 'fleet-ops:contact' }); + this.set('driver', { resourceType: 'driver', name: 'Ada Driver', phone: '+15550100' }); + this.set('place', { resourceType: 'place', name: 'Depot', city: 'Singapore', country: 'SG' }); + this.set('vehicle', { resourceType: 'vehicle', displayName: 'Truck 10', plate_number: 'TRK-10' }); + + await render(hbs` +
+
+
+
+
+ `); + + assert.dom('[data-test-customer] [data-test-resource-pill-title]').hasText('Ava Chen'); + assert.dom('[data-test-customer] [data-test-resource-pill-subtitle]').hasText('Customer'); + assert.dom('[data-test-driver] [data-test-resource-pill-title]').hasText('Ada Driver'); + assert.dom('[data-test-driver] [data-test-resource-pill-subtitle]').hasText('+15550100'); + assert.dom('[data-test-place] [data-test-resource-pill-title]').hasText('Depot'); + assert.dom('[data-test-place] [data-test-resource-pill-subtitle]').hasText('Singapore, SG'); + assert.dom('[data-test-vehicle] [data-test-select-option-title]').hasText('Truck 10'); + assert.dom('[data-test-vehicle] .select-option').hasClass('select-option--compact'); + assert.dom('[data-test-driver-option] [data-test-select-option-title]').hasText('Ada Driver'); + }); + + test('a pill with no record shows its fallback title and does not open anything', async function (assert) { + await render(hbs``); + + assert.dom('[data-test-resource-pill-title]').hasText('No driver assigned'); + assert.dom('[data-test-resource-pill] a[href]').doesNotExist(); + }); +}); diff --git a/tests/integration/components/storefront/order/details-test.js b/tests/integration/components/storefront/order/details-test.js index 1d392fca..981b8944 100644 --- a/tests/integration/components/storefront/order/details-test.js +++ b/tests/integration/components/storefront/order/details-test.js @@ -2,10 +2,15 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; import { render } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +import { initialize } from '@fleetbase/fleetops-data/instance-initializers/register-shared-resource-descriptors'; module('Integration | Component | storefront/order/details', function (hooks) { setupRenderingTest(hooks); + hooks.beforeEach(function () { + initialize(this.owner); + }); + test('it renders dedicated storefront order detail panels', async function (assert) { this.set('order', { public_id: 'order_test', @@ -58,9 +63,30 @@ module('Integration | Component | storefront/order/details', function (hooks) { assert.dom('.next-content-panel-title-container').hasTextContaining('Route'); assert.dom('.next-content-panel-title-container').hasTextContaining('Metadata'); assert.dom('.storefront-order-person__name').hasTextContaining('Tasty Store'); + assert.dom('[data-test-order-driver-pill] [data-test-resource-pill-title]').hasText('No driver assigned', 'an unassigned driver is a static pill with the fallback title'); + assert.dom('[data-test-order-customer-pill] [data-test-resource-pill-title]').hasText('No customer'); assert.dom('.storefront-order-item__image').exists(); assert.dom('.storefront-order-item__price').exists(); assert.dom().doesNotContainText('Delivery Address'); assert.dom().doesNotContainText('Pickup Address'); }); + + test('a loaded customer and driver render as their pills', async function (assert) { + this.set('order', { + public_id: 'order_test', + meta: { total: 10, currency: 'USD', is_pickup: true }, + customer: { id: 'contact_1', resourceType: 'customer', name: 'Ava Chen', email: 'ava@example.test', phone: '+15550201' }, + driver_assigned: { id: 'driver_1', resourceType: 'driver', name: 'Ada Driver', phone: '+15550100' }, + payload: { pickup: {}, dropoff: {}, entities: [] }, + tracking_number: {}, + tracking_statuses: [], + files: [], + }); + + await render(hbs``); + + assert.dom('[data-test-order-customer-pill] [data-test-resource-pill-title]').hasText('Ava Chen'); + assert.dom('[data-test-order-driver-pill] [data-test-resource-pill-title]').hasText('Ada Driver'); + assert.dom('[data-test-order-driver-pill] [data-test-resource-pill-subtitle]').hasText('+15550100'); + }); }); diff --git a/tests/integration/components/widget/customers-test.js b/tests/integration/components/widget/customers-test.js index 9d51d239..4aa03639 100644 --- a/tests/integration/components/widget/customers-test.js +++ b/tests/integration/components/widget/customers-test.js @@ -57,7 +57,7 @@ module('Integration | Component | widget/customers', function (hooks) { assert.dom('.storefront-widget-count').hasText('1'); assert.dom('.storefront-customers-table').exists(); assert.dom('.storefront-customer-id').hasText('contact_1'); - assert.dom('.storefront-customers-table').includesText('Ava Chen'); + assert.dom('.storefront-customers-table [data-test-resource-pill-title]').hasText('Ava Chen', 'the customer renders as its pill'); assert.dom('.storefront-customers-table').includesText('ava.chen@example.test'); }); }); diff --git a/tests/unit/controllers/customers/index-test.js b/tests/unit/controllers/customers/index-test.js index 1acfcb49..d8189724 100644 --- a/tests/unit/controllers/customers/index-test.js +++ b/tests/unit/controllers/customers/index-test.js @@ -17,4 +17,13 @@ module('Unit | Controller | customers/index', function (hooks) { assert.true(nameColumn.sticky, 'name column is sticky'); assert.strictEqual(actionColumn.sticky, 'right', 'action column is sticky on the right'); }); + + test('the name column is a customer identity cell that opens the storefront customer', function (assert) { + let controller = this.owner.lookup('controller:customers/index'); + let [nameColumn] = controller.columns; + + assert.strictEqual(nameColumn.cellComponent, 'table/cell/identity'); + assert.strictEqual(nameColumn.resourceType, 'customer'); + assert.strictEqual(nameColumn.action, controller.viewCustomer); + }); }); diff --git a/tests/unit/controllers/networks/index/network/customers-test.js b/tests/unit/controllers/networks/index/network/customers-test.js index 844ec516..bd90c9fb 100644 --- a/tests/unit/controllers/networks/index/network/customers-test.js +++ b/tests/unit/controllers/networks/index/network/customers-test.js @@ -4,9 +4,17 @@ import { setupTest } from 'dummy/tests/helpers'; module('Unit | Controller | networks/index/network/customers', function (hooks) { setupTest(hooks); - // TODO: Replace this with your real tests. test('it exists', function (assert) { let controller = this.owner.lookup('controller:networks/index/network/customers'); assert.ok(controller); }); + + test('the name column is a customer identity cell that opens the customer panel', function (assert) { + let controller = this.owner.lookup('controller:networks/index/network/customers'); + let [nameColumn] = controller.columns; + + assert.strictEqual(nameColumn.cellComponent, 'table/cell/identity'); + assert.strictEqual(nameColumn.resourceType, 'customer'); + assert.strictEqual(nameColumn.action, controller.viewCustomer); + }); }); diff --git a/tests/unit/controllers/networks/index/network/orders-test.js b/tests/unit/controllers/networks/index/network/orders-test.js index 59ef20f3..141a91cc 100644 --- a/tests/unit/controllers/networks/index/network/orders-test.js +++ b/tests/unit/controllers/networks/index/network/orders-test.js @@ -4,9 +4,28 @@ import { setupTest } from 'dummy/tests/helpers'; module('Unit | Controller | networks/index/network/orders', function (hooks) { setupTest(hooks); - // TODO: Replace this with your real tests. test('it exists', function (assert) { let controller = this.owner.lookup('controller:networks/index/network/orders'); assert.ok(controller); }); + + test('customer, pickup, dropoff and driver are identity cells typed for the shared descriptors', function (assert) { + const controller = this.owner.lookup('controller:networks/index/network/orders'); + const byId = Object.fromEntries(controller.columns.map((column) => [column.id, column])); + + for (const [id, resourceType] of [ + ['customer-name', 'customer'], + ['pickup-name', 'place'], + ['dropoff-name', 'place'], + ['driver-assigned', 'driver'], + ]) { + assert.strictEqual(byId[id].cellComponent, 'table/cell/identity', `${id} is an identity cell`); + assert.strictEqual(byId[id].resourceType, resourceType, `${id} is a ${resourceType}`); + assert.strictEqual(typeof byId[id].resourcePath, 'function', `${id} resolves its resource`); + } + + const driver = { id: 'driver_1' }; + assert.strictEqual(byId['driver-assigned'].resourcePath({ driver_assigned: driver }), driver); + assert.strictEqual(byId['driver-assigned'].resourcePath({ driver_name: 'Bob' }).name, 'Bob'); + }); }); diff --git a/tests/unit/controllers/orders/index-test.js b/tests/unit/controllers/orders/index-test.js index b471c95b..6c9ca5d4 100644 --- a/tests/unit/controllers/orders/index-test.js +++ b/tests/unit/controllers/orders/index-test.js @@ -1,5 +1,6 @@ import { module, test } from 'qunit'; import { setupTest } from 'dummy/tests/helpers'; +import Service from '@ember/service'; module('Unit | Controller | orders/index', function (hooks) { setupTest(hooks); @@ -17,4 +18,69 @@ module('Unit | Controller | orders/index', function (hooks) { assert.true(idColumn.sticky, 'id column is sticky'); assert.strictEqual(actionColumn.sticky, 'right', 'action column is sticky on the right'); }); + + test('customer, pickup, dropoff and driver are identity cells typed for the shared descriptors', function (assert) { + const controller = this.owner.lookup('controller:orders/index'); + const byId = Object.fromEntries(controller.columns.map((column) => [column.id, column])); + + assert.strictEqual(byId['customer-name'].cellComponent, 'table/cell/identity'); + assert.strictEqual(byId['customer-name'].resourceType, 'customer'); + assert.strictEqual(byId['pickup-name'].cellComponent, 'table/cell/identity'); + assert.strictEqual(byId['pickup-name'].resourceType, 'place'); + assert.strictEqual(byId['dropoff-name'].cellComponent, 'table/cell/identity'); + assert.strictEqual(byId['dropoff-name'].resourceType, 'place'); + assert.strictEqual(byId['driver-assigned'].cellComponent, 'table/cell/identity'); + assert.strictEqual(byId['driver-assigned'].resourceType, 'driver'); + assert.strictEqual(byId['driver-assigned'].emptyText, 'No driver assigned'); + }); + + test('identity cells read the loaded relation, or a stub named after the row', async function (assert) { + const controller = this.owner.lookup('controller:orders/index'); + const byId = Object.fromEntries(controller.columns.map((column) => [column.id, column])); + const customer = { id: 'contact_1', name: 'Ada' }; + const driver = { id: 'driver_1', name: 'Bob' }; + const pickup = { id: 'place_1', name: 'Depot' }; + + assert.strictEqual(byId['customer-name'].resourcePath({ customer }), customer); + assert.strictEqual(byId['driver-assigned'].resourcePath({ driver_assigned: driver }), driver); + assert.strictEqual(byId['pickup-name'].resourcePath({ payload: { pickup } }), pickup); + + const customerStub = byId['customer-name'].resourcePath({ customer_name: 'Ada', customer_type: 'fleet-ops:contact' }); + assert.strictEqual(customerStub.name, 'Ada'); + assert.strictEqual(customerStub.resourceType, 'fleet-ops:contact'); + assert.true(customerStub.isIdentityStub); + + const placeStub = byId['dropoff-name'].resourcePath({ dropoffName: '1 Main St' }); + assert.strictEqual(placeStub.name, '1 Main St'); + assert.strictEqual(placeStub.resourceType, 'place'); + + const found = { id: 'driver_9' }; + const findRecord = (modelName, id) => (modelName === 'driver' && id === 'driver_9' ? Promise.resolve(found) : Promise.resolve(null)); + controller.store = { findRecord }; + const driverStub = byId['driver-assigned'].resourcePath({ driver_name: 'Bob', driver_assigned_uuid: 'driver_9' }); + assert.strictEqual(driverStub.name, 'Bob'); + assert.strictEqual(await driverStub.loadResource(), found, 'the stub loads the driver by uuid on demand'); + assert.strictEqual(await byId['driver-assigned'].resourcePath({ driver_name: 'Bob' }).loadResource(), null, 'no uuid, nothing to load'); + + assert.strictEqual(byId['driver-assigned'].resourcePath({}), null, 'no driver at all renders the empty text'); + }); + + test('clicking a customer opens the storefront customer page, and a name-only stub opens nothing', function (assert) { + const transitions = []; + this.owner.register( + 'service:hostRouter', + class extends Service { + transitionTo(...args) { + transitions.push(args); + } + } + ); + const controller = this.owner.lookup('controller:orders/index'); + + controller.viewCustomer({ public_id: 'contact_1', name: 'Ada' }); + controller.viewCustomer({ name: 'Ada', isIdentityStub: true }); + controller.viewCustomer(null); + + assert.deepEqual(transitions, [['console.storefront.customers.index.view', 'contact_1']]); + }); }); From e96ed4b73251dcf7c66d61de7c0678c4f2851ff6 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 9 Oct 2026 17:54:15 +0800 Subject: [PATCH 032/193] fix(checkout): multi-store orders can be placed; stores are found before anything is created and a failed attempt is undone --- .../Controllers/v1/CheckoutController.php | 508 ++++++++++-------- server/src/Support/Storefront.php | 14 +- 2 files changed, 281 insertions(+), 241 deletions(-) diff --git a/server/src/Http/Controllers/v1/CheckoutController.php b/server/src/Http/Controllers/v1/CheckoutController.php index e2fc5f43..8a405d87 100644 --- a/server/src/Http/Controllers/v1/CheckoutController.php +++ b/server/src/Http/Controllers/v1/CheckoutController.php @@ -1688,6 +1688,14 @@ public function captureMultipleOrders(CaptureOrderRequest $request) $integratedVendorOrder = $vendorResult['order']; } + // Find each pickup's store before anything is created: an order that cannot be built + // fails here, without a payment record or half the orders left behind. + $originPlaces = collect($origins)->map(fn ($publicId) => Place::createFromMixed($publicId))->values(); + $originStores = $originPlaces->map(fn ($pickup) => $pickup instanceof Place ? Storefront::getStoreFromLocation($pickup->uuid) : null)->values(); + if ($originPlaces->isEmpty() || $originStores->contains(fn ($store) => !$store)) { + return response()->apiError('A store in this order could not be found, so it was not placed. Your payment has not been used.'); + } + // setup transaction meta $transactionMeta = [ 'storefront_network' => $about->name, @@ -1695,145 +1703,238 @@ public function captureMultipleOrders(CaptureOrderRequest $request) ...$transactionDetails, ]; - // create transactions for cart - $transaction = Transaction::create([ - 'company_uuid' => session('company'), - 'customer_uuid' => $customer->uuid, - 'customer_type' => Utils::getMutationType('fleet-ops:contact'), - 'gateway_transaction_id' => Utils::or($transactionDetails, ['id', 'transaction_id']) ?? Transaction::generateNumber(), - 'gateway' => $gateway->code, - 'gateway_uuid' => $gateway->uuid, - 'amount' => $amount, - 'currency' => $currency, - 'description' => 'Storefront network order', - 'type' => 'storefront', - 'status' => Transaction::STATUS_SUCCESS, - 'settlement_status' => Transaction::SETTLEMENT_STATUS_PAID, - 'settled_at' => now(), - 'settled_amount' => $amount, - 'settled_currency' => $currency, - 'meta' => $transactionMeta, - ]); + $transaction = null; + $multipleOrders = []; - // create transaction items - foreach ($cart->items as $cartItem) { - $store = Storefront::findAbout($cartItem->store_id); + try { + // create transactions for cart + $transaction = Transaction::create([ + 'company_uuid' => session('company'), + 'customer_uuid' => $customer->uuid, + 'customer_type' => Utils::getMutationType('fleet-ops:contact'), + 'gateway_transaction_id' => Utils::or($transactionDetails, ['id', 'transaction_id']) ?? Transaction::generateNumber(), + 'gateway' => $gateway->code, + 'gateway_uuid' => $gateway->uuid, + 'amount' => $amount, + 'currency' => $currency, + 'description' => 'Storefront network order', + 'type' => 'storefront', + 'status' => Transaction::STATUS_SUCCESS, + 'settlement_status' => Transaction::SETTLEMENT_STATUS_PAID, + 'settled_at' => now(), + 'settled_amount' => $amount, + 'settled_currency' => $currency, + 'meta' => $transactionMeta, + ]); - TransactionItem::create([ - 'transaction_uuid' => $transaction->uuid, - 'amount' => $cartItem->subtotal, - 'currency' => $checkout->currency, - 'details' => Storefront::getFullDescriptionFromCartItem($cartItem), - 'code' => 'product', - 'meta' => [ + // create transaction items + foreach ($cart->items as $cartItem) { + $store = Storefront::findAbout($cartItem->store_id); + + TransactionItem::create([ + 'transaction_uuid' => $transaction->uuid, + 'amount' => $cartItem->subtotal, + 'currency' => $checkout->currency, + 'details' => Storefront::getFullDescriptionFromCartItem($cartItem), + 'code' => 'product', + 'meta' => [ + 'storefront_network' => $about->name, + 'storefront_network_id' => $about->public_id, + 'storefront' => $store->name ?? null, + 'storefront_id' => $store->public_id ?? null, + ], + ]); + } + + // create transaction item for service quote + if (!$checkout->is_pickup) { + TransactionItem::create([ + 'transaction_uuid' => $transaction->uuid, + 'amount' => $serviceQuote->amount, + 'currency' => $serviceQuote->currency, + 'details' => 'Delivery fee', + 'code' => 'delivery_fee', + ]); + } + + // if tip create transaction item for tip + if ($checkout->hasOption('tip')) { + TransactionItem::create([ + 'transaction_uuid' => $transaction->uuid, + 'amount' => static::calculateTipAmount($checkout->getOption('tip'), $cart->subtotal), + 'currency' => $checkout->currency, + 'details' => 'Tip', + 'code' => 'tip', + ]); + } + + // if delivery tip create transaction item for tip + if ($checkout->hasOption('delivery_tip')) { + TransactionItem::create([ + 'transaction_uuid' => $transaction->uuid, + 'amount' => static::calculateTipAmount($checkout->getOption('delivery_tip'), $cart->subtotal), + 'currency' => $checkout->currency, + 'details' => 'Delivery Tip', + 'code' => 'delivery_tip', + ]); + } + + // if promotions were applied create a (credit) transaction item for the discount + $promotions = PromotionResult::fromArray(data_get($checkout->options, 'promotions')); + $discountAllocations = $promotions->allocationsByStore(); + static::createDiscountTransactionItem($transaction, $promotions, $checkout->currency); + + // payload pickups (resolved above) and the destination as places + $origins = $originPlaces; + $destination = Place::createFromMixed($destination); + + // The store tip goes to the network that runs the app, unless the network splits it + // across the stores, each by its share of the order. + $splitTips = $checkout->hasOption('tip') && $about->isOption('split_tips_across_stores'); + $tipShares = []; + if ($splitTips) { + $storeSubtotals = $originStores + ->unique('public_id') + ->mapWithKeys(fn ($store) => [$store->public_id => (int) $cart->getSubtotalForStore($store)]) + ->all(); + $tipShares = static::splitByShare((int) static::calculateTipAmount($checkout->getOption('tip'), $cart->subtotal), $storeSubtotals); + } + + $multipleOrders = []; + + foreach ($origins as $index => $pickup) { + $store = $originStores[$index]; + + // create payload + $payload = Payload::create([ + 'company_uuid' => $store->company_uuid, + 'pickup_uuid' => $pickup instanceof Place ? $pickup->uuid : null, + 'dropoff_uuid' => $destination instanceof Place ? $destination->uuid : null, + 'return_uuid' => $pickup instanceof Place ? $pickup->uuid : null, + 'payment_method' => $gateway->type, + 'type' => 'storefront', + ]); + + // get cart items from this store + $cartItems = $cart->getItemsForStore($store); + + // create entities + foreach ($cartItems as $cartItem) { + $this->processCartItem($cartItem, $payload, $customer); + } + + // get order subtotal and this store's share of the item discount + $subtotal = $cart->getSubtotalForStore($store); + $storeDiscount = min((int) ($discountAllocations[$store->public_id] ?? 0), (int) $subtotal); + + // prepare order meta + $orderMeta = [ + 'is_master_order' => false, + 'storefront' => $store->name, + 'storefront_id' => $store->public_id, 'storefront_network' => $about->name, 'storefront_network_id' => $about->public_id, - 'storefront' => $store->name ?? null, - 'storefront_id' => $store->public_id ?? null, - ], - ]); - } + 'checkout_id' => $checkout->public_id, + 'subtotal' => $subtotal, + 'delivery_fee' => 0, + 'tip' => $tipShares[$store->public_id] ?? 0, + 'tip_recipient' => $splitTips ? 'store' : 'network', + 'delivery_tip' => 0, + 'discount' => $storeDiscount, + 'total' => $subtotal - $storeDiscount, + 'currency' => $currency, + 'gateway' => $gateway->type, + 'require_pod' => $about->getOption('require_pod'), + 'pod_method' => $about->pod_method, + 'is_pickup' => $checkout->is_pickup, + ...$transactionDetails, + ]; - // create transaction item for service quote - if (!$checkout->is_pickup) { - TransactionItem::create([ - 'transaction_uuid' => $transaction->uuid, - 'amount' => $serviceQuote->amount, - 'currency' => $serviceQuote->currency, - 'details' => 'Delivery fee', - 'code' => 'delivery_fee', - ]); - } + // prepare order input + $orderInput = [ + 'company_uuid' => $store->company_uuid, + 'payload_uuid' => $payload->uuid, + 'customer_uuid' => $customer->uuid, + 'customer_type' => Utils::getMutationType('fleet-ops:contact'), + 'transaction_uuid' => $transaction->uuid, + 'order_config_uuid' => $store->getOrderConfigId(), + 'adhoc' => $about->isOption('auto_dispatch'), + 'type' => 'storefront', + 'status' => 'created', + 'notes' => $notes, + ]; - // if tip create transaction item for tip - if ($checkout->hasOption('tip')) { - TransactionItem::create([ - 'transaction_uuid' => $transaction->uuid, - 'amount' => static::calculateTipAmount($checkout->getOption('tip'), $cart->subtotal), - 'currency' => $checkout->currency, - 'details' => 'Tip', - 'code' => 'tip', - ]); - } + // if it's integrated vendor order apply to meta + if ($integratedVendorOrder) { + $orderMeta['integrated_vendor'] = $serviceQuote->integratedVendor->public_id; + $orderMeta['integrated_vendor_order'] = $integratedVendorOrder; + // order input + $orderInput['facilitator_uuid'] = $serviceQuote->integratedVendor->uuid; + $orderInput['facilitator_type'] = Utils::getModelClassName('integrated_vendors'); + } - // if delivery tip create transaction item for tip - if ($checkout->hasOption('delivery_tip')) { - TransactionItem::create([ - 'transaction_uuid' => $transaction->uuid, - 'amount' => static::calculateTipAmount($checkout->getOption('delivery_tip'), $cart->subtotal), - 'currency' => $checkout->currency, - 'details' => 'Delivery Tip', - 'code' => 'delivery_tip', - ]); - } + // A booked service makes this store's order a booking order, with its own flow. + [$orderMeta, $orderInput] = static::applyBooking($cartItems, $orderMeta, $orderInput); - // if promotions were applied create a (credit) transaction item for the discount - $promotions = PromotionResult::fromArray(data_get($checkout->options, 'promotions')); - $discountAllocations = $promotions->allocationsByStore(); - static::createDiscountTransactionItem($transaction, $promotions, $checkout->currency); + // set meta to order input last + $orderInput['meta'] = $orderMeta; - // convert payload destinations to Place - $origins = collect($origins)->map(function ($publicId) { - return Place::createFromMixed($publicId); - }); - $destination = Place::createFromMixed($destination); + // create order + $multipleOrders[] = $order = Order::create($orderInput); - // The store tip goes to the network that runs the app, unless the network splits it - // across the stores, each by its share of the order. - $splitTips = $checkout->hasOption('tip') && $about->isOption('split_tips_across_stores'); - $tipShares = []; - if ($splitTips) { - $storeSubtotals = $origins - ->map(fn ($pickup) => Storefront::getStoreFromLocation($pickup->uuid)) - ->filter() - ->unique('public_id') - ->mapWithKeys(fn ($store) => [$store->public_id => (int) $cart->getSubtotalForStore($store)]) - ->all(); - $tipShares = static::splitByShare((int) static::calculateTipAmount($checkout->getOption('tip'), $cart->subtotal), $storeSubtotals); - } + // set driving distance and time + $order->setPreliminaryDistanceAndTime(); - $multipleOrders = []; + // purchase service quote + $order->purchaseQuote($serviceQuote->uuid, $transactionDetails); + + // if order is auto accepted update status + if ($store->isOption('auto_accept_orders')) { + $this->autoAcceptOrder($order); + if ($store->isOption('auto_dispatch')) { + $this->autoDispatchOrder($order); + } + } + + // notify order creation + Storefront::alertNewOrder($order); + } - foreach ($origins as $pickup) { - $store = Storefront::getStoreFromLocation($pickup->uuid); + // convert origin to Place + $origin = Place::createFromMixed($origin); - // create payload + // create master payload $payload = Payload::create([ - 'company_uuid' => $store->company_uuid, - 'pickup_uuid' => $pickup instanceof Place ? $pickup->uuid : null, + 'company_uuid' => session('company'), + 'pickup_uuid' => $origin instanceof Place ? $origin->uuid : null, 'dropoff_uuid' => $destination instanceof Place ? $destination->uuid : null, - 'return_uuid' => $pickup instanceof Place ? $pickup->uuid : null, + 'return_uuid' => $origin instanceof Place ? $origin->uuid : null, 'payment_method' => $gateway->type, 'type' => 'storefront', - ]); - - // get cart items from this store - $cartItems = $cart->getItemsForStore($store); + ])->setWaypoints($waypoints); // create entities - foreach ($cartItems as $cartItem) { + foreach ($cart->items as $cartItem) { $this->processCartItem($cartItem, $payload, $customer); } - // get order subtotal and this store's share of the item discount - $subtotal = $cart->getSubtotalForStore($store); - $storeDiscount = min((int) ($discountAllocations[$store->public_id] ?? 0), (int) $subtotal); - - // prepare order meta - $orderMeta = [ - 'is_master_order' => false, - 'storefront' => $store->name, - 'storefront_id' => $store->public_id, + // prepare master order meta + $masterOrderMeta = [ + 'is_master_order' => true, + 'related_orders' => collect($multipleOrders)->pluck('public_id')->toArray(), + 'storefront' => $about->name, + 'storefront_id' => $about->public_id, 'storefront_network' => $about->name, 'storefront_network_id' => $about->public_id, 'checkout_id' => $checkout->public_id, - 'subtotal' => $subtotal, - 'delivery_fee' => 0, - 'tip' => $tipShares[$store->public_id] ?? 0, - 'tip_recipient' => $splitTips ? 'store' : 'network', - 'delivery_tip' => 0, - 'discount' => $storeDiscount, - 'total' => $subtotal - $storeDiscount, + 'subtotal' => Utils::numbersOnly($cart->subtotal), + 'delivery_fee' => $checkout->is_pickup ? 0 : Utils::numbersOnly($serviceQuote->amount), + 'tip' => $checkout->getOption('tip'), + 'tip_recipient' => $splitTips ? 'stores' : 'network', + 'delivery_tip' => $checkout->getOption('delivery_tip'), + 'discount' => $promotions->discount(), + 'promotions' => $promotions->toPublicArray()['applied'], + 'total' => Utils::numbersOnly($amount), 'currency' => $currency, 'gateway' => $gateway->type, 'require_pod' => $about->getOption('require_pod'), @@ -1842,37 +1943,44 @@ public function captureMultipleOrders(CaptureOrderRequest $request) ...$transactionDetails, ]; - // prepare order input - $orderInput = [ - 'company_uuid' => $store->company_uuid, + // prepare master order input + $masterOrderInput = [ + 'company_uuid' => session('company'), 'payload_uuid' => $payload->uuid, 'customer_uuid' => $customer->uuid, 'customer_type' => Utils::getMutationType('fleet-ops:contact'), 'transaction_uuid' => $transaction->uuid, - 'order_config_uuid' => $store->getOrderConfigId(), + 'order_config_uuid' => $about->getOrderConfigId(), 'adhoc' => $about->isOption('auto_dispatch'), 'type' => 'storefront', 'status' => 'created', - 'notes' => $notes, ]; // if it's integrated vendor order apply to meta if ($integratedVendorOrder) { - $orderMeta['integrated_vendor'] = $serviceQuote->integratedVendor->public_id; - $orderMeta['integrated_vendor_order'] = $integratedVendorOrder; + $masterOrderMeta['integrated_vendor'] = $serviceQuote->integratedVendor->public_id; + $masterOrderMeta['integrated_vendor_order'] = $integratedVendorOrder; // order input - $orderInput['facilitator_uuid'] = $serviceQuote->integratedVendor->uuid; - $orderInput['facilitator_type'] = Utils::getModelClassName('integrated_vendors'); + $masterOrderInput['facilitator_uuid'] = $serviceQuote->integratedVendor->uuid; + $masterOrderInput['facilitator_type'] = Utils::getModelClassName('integrated_vendors'); } - // A booked service makes this store's order a booking order, with its own flow. - [$orderMeta, $orderInput] = static::applyBooking($cartItems, $orderMeta, $orderInput); + // finally apply meta to master order + $masterOrderInput['meta'] = $masterOrderMeta; + + // create master order + $order = Order::create($masterOrderInput); - // set meta to order input last - $orderInput['meta'] = $orderMeta; + // record the promotions as used by this checkout's master order + PromotionRedemptions::redeem($checkout, $order); - // create order - $multipleOrders[] = $order = Order::create($orderInput); + // update child orders with master order id in meta + foreach ($multipleOrders as $childOrder) { + $childOrder->updateMeta('master_order_id', $order->public_id); + } + + // notify driver if assigned + $order->notifyDriverAssigned(); // set driving distance and time $order->setPreliminaryDistanceAndTime(); @@ -1880,120 +1988,48 @@ public function captureMultipleOrders(CaptureOrderRequest $request) // purchase service quote $order->purchaseQuote($serviceQuote->uuid, $transactionDetails); - // if order is auto accepted update status - if ($store->isOption('auto_accept_orders')) { - $this->autoAcceptOrder($order); - if ($store->isOption('auto_dispatch')) { - $this->autoDispatchOrder($order); - } - } - - // notify order creation - Storefront::alertNewOrder($order); - } + // dispatch if flagged true + $order->firstDispatch(); - // convert origin to Place - $origin = Place::createFromMixed($origin); + // update the cart with the checkout + $checkout->checkedout(); - // create master payload - $payload = Payload::create([ - 'company_uuid' => session('company'), - 'pickup_uuid' => $origin instanceof Place ? $origin->uuid : null, - 'dropoff_uuid' => $destination instanceof Place ? $destination->uuid : null, - 'return_uuid' => $origin instanceof Place ? $origin->uuid : null, - 'payment_method' => $gateway->type, - 'type' => 'storefront', - ])->setWaypoints($waypoints); - - // create entities - foreach ($cart->items as $cartItem) { - $this->processCartItem($cartItem, $payload, $customer); - } - - // prepare master order meta - $masterOrderMeta = [ - 'is_master_order' => true, - 'related_orders' => collect($multipleOrders)->pluck('public_id')->toArray(), - 'storefront' => $about->name, - 'storefront_id' => $about->public_id, - 'storefront_network' => $about->name, - 'storefront_network_id' => $about->public_id, - 'checkout_id' => $checkout->public_id, - 'subtotal' => Utils::numbersOnly($cart->subtotal), - 'delivery_fee' => $checkout->is_pickup ? 0 : Utils::numbersOnly($serviceQuote->amount), - 'tip' => $checkout->getOption('tip'), - 'tip_recipient' => $splitTips ? 'stores' : 'network', - 'delivery_tip' => $checkout->getOption('delivery_tip'), - 'discount' => $promotions->discount(), - 'promotions' => $promotions->toPublicArray()['applied'], - 'total' => Utils::numbersOnly($amount), - 'currency' => $currency, - 'gateway' => $gateway->type, - 'require_pod' => $about->getOption('require_pod'), - 'pod_method' => $about->pod_method, - 'is_pickup' => $checkout->is_pickup, - ...$transactionDetails, - ]; + // update checkout token + $checkout->update([ + 'order_uuid' => $order->uuid, + // 'store_uuid' => $about->uuid, + 'captured' => true, + ]); - // prepare master order input - $masterOrderInput = [ - 'company_uuid' => session('company'), - 'payload_uuid' => $payload->uuid, - 'customer_uuid' => $customer->uuid, - 'customer_type' => Utils::getMutationType('fleet-ops:contact'), - 'transaction_uuid' => $transaction->uuid, - 'order_config_uuid' => $about->getOrderConfigId(), - 'adhoc' => $about->isOption('auto_dispatch'), - 'type' => 'storefront', - 'status' => 'created', - ]; + return new OrderResource($order); + } catch (\Throwable $e) { + // Undo this attempt's records so a retry ("Finish placing order") starts clean + // instead of leaving another payment record with no order. + static::discardFailedCapture($transaction, $multipleOrders); - // if it's integrated vendor order apply to meta - if ($integratedVendorOrder) { - $masterOrderMeta['integrated_vendor'] = $serviceQuote->integratedVendor->public_id; - $masterOrderMeta['integrated_vendor_order'] = $integratedVendorOrder; - // order input - $masterOrderInput['facilitator_uuid'] = $serviceQuote->integratedVendor->uuid; - $masterOrderInput['facilitator_type'] = Utils::getModelClassName('integrated_vendors'); + throw $e; } + } - // finally apply meta to master order - $masterOrderInput['meta'] = $masterOrderMeta; - - // create master order - $order = Order::create($masterOrderInput); - - // record the promotions as used by this checkout's master order - PromotionRedemptions::redeem($checkout, $order); - - // update child orders with master order id in meta - foreach ($multipleOrders as $childOrder) { - $childOrder->updateMeta('master_order_id', $order->public_id); + /** + * Remove what a failed capture attempt created: its transaction (with its line items) and + * any of the store orders already made. Nothing else refers to them yet. + */ + protected static function discardFailedCapture(?Transaction $transaction, array $orders = []): void + { + try { + foreach ($orders as $order) { + if ($order instanceof Order) { + $order->delete(); + } + } + if ($transaction) { + TransactionItem::where('transaction_uuid', $transaction->uuid)->delete(); + $transaction->delete(); + } + } catch (\Throwable $cleanup) { + Log::error('[Storefront] Unable to discard a failed order capture.', ['transaction' => $transaction?->public_id, 'error' => $cleanup->getMessage()]); } - - // notify driver if assigned - $order->notifyDriverAssigned(); - - // set driving distance and time - $order->setPreliminaryDistanceAndTime(); - - // purchase service quote - $order->purchaseQuote($serviceQuote->uuid, $transactionDetails); - - // dispatch if flagged true - $order->firstDispatch(); - - // update the cart with the checkout - $checkout->checkedout(); - - // update checkout token - $checkout->update([ - 'order_uuid' => $order->uuid, - // 'store_uuid' => $about->uuid, - 'captured' => true, - ]); - - return new OrderResource($order); } public function afterCheckout(Request $request) diff --git a/server/src/Support/Storefront.php b/server/src/Support/Storefront.php index 06fe8ca4..a75c050d 100644 --- a/server/src/Support/Storefront.php +++ b/server/src/Support/Storefront.php @@ -108,11 +108,15 @@ public static function getStoreFromLocation(string $id, $columns = [], $with = [ $columns = array_merge(['uuid', 'public_id', 'company_uuid', 'backdrop_uuid', 'logo_uuid', 'order_config_uuid', 'name', 'description', 'translations', 'website', 'facebook', 'instagram', 'twitter', 'email', 'phone', 'tags', 'currency', 'timezone', 'pod_method', 'options'], $columns); } - return Store::select($columns)->with($with)->whereHas('locations', function ($q) use ($id) { - $q->where('place_uuid', $id); - $q->orWhereHas('place', function ($q) use ($id) { - $q->where('public_id', $id); - }); + // Places live in the Fleet-Ops database and store locations in Storefront's, so the place + // is resolved on its own (a join across the two databases fails) before the store lookup. + $placeUuid = Str::isUuid($id) ? $id : \Fleetbase\FleetOps\Models\Place::where('public_id', $id)->value('uuid'); + if (!$placeUuid) { + return null; + } + + return Store::select($columns)->with($with)->whereHas('locations', function ($q) use ($placeUuid) { + $q->where('place_uuid', $placeUuid); })->first(); } From babef37792c2bb70e7f72d612d6869cafc00f01d Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 9 Oct 2026 18:00:42 +0800 Subject: [PATCH 033/193] perf(notifications): order, new-order and chat notifications are sent from the queue, not during checkout --- server/src/Notifications/StorefrontOrderChatMessage.php | 5 ++++- server/src/Notifications/StorefrontOrderCreated.php | 5 ++++- server/src/Notifications/StorefrontOrderNotification.php | 5 ++++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/server/src/Notifications/StorefrontOrderChatMessage.php b/server/src/Notifications/StorefrontOrderChatMessage.php index 753060a1..a4d37073 100644 --- a/server/src/Notifications/StorefrontOrderChatMessage.php +++ b/server/src/Notifications/StorefrontOrderChatMessage.php @@ -11,6 +11,8 @@ use Fleetbase\Storefront\Push\StorefrontPushChannel; use Fleetbase\Storefront\Support\CustomerNotificationPresenter; use Illuminate\Bus\Queueable; +use Illuminate\Queue\SerializesModels; +use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Notifications\Messages\BroadcastMessage; use Illuminate\Notifications\Notification; @@ -20,9 +22,10 @@ * Messages about an order in progress always reach the customer: they are not promotional * and do not depend on the order update preference. */ -class StorefrontOrderChatMessage extends Notification implements SendsPushNotification +class StorefrontOrderChatMessage extends Notification implements SendsPushNotification, ShouldQueue { use Queueable; + use SerializesModels; public function __construct(public ChatMessage $message, public Order $order) { diff --git a/server/src/Notifications/StorefrontOrderCreated.php b/server/src/Notifications/StorefrontOrderCreated.php index 06b05192..1dd5439d 100644 --- a/server/src/Notifications/StorefrontOrderCreated.php +++ b/server/src/Notifications/StorefrontOrderCreated.php @@ -8,14 +8,17 @@ use Fleetbase\Storefront\Support\Storefront; use Fleetbase\Support\Utils; use Illuminate\Bus\Queueable; +use Illuminate\Queue\SerializesModels; +use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Notification; use NotificationChannels\Twilio\TwilioChannel; use NotificationChannels\Twilio\TwilioSmsMessage; -class StorefrontOrderCreated extends Notification +class StorefrontOrderCreated extends Notification implements ShouldQueue { use Queueable; + use SerializesModels; /** * The order instance this notification is for. diff --git a/server/src/Notifications/StorefrontOrderNotification.php b/server/src/Notifications/StorefrontOrderNotification.php index 22e2f6dd..d254e7ca 100644 --- a/server/src/Notifications/StorefrontOrderNotification.php +++ b/server/src/Notifications/StorefrontOrderNotification.php @@ -14,6 +14,8 @@ use Fleetbase\Storefront\Support\NotificationPreferences; use Fleetbase\Storefront\Support\Storefront; use Illuminate\Bus\Queueable; +use Illuminate\Queue\SerializesModels; +use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Notifications\Messages\BroadcastMessage; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Notification; @@ -21,9 +23,10 @@ /** * Base class for order lifecycle notifications sent to storefront customers. */ -abstract class StorefrontOrderNotification extends Notification implements SendsPushNotification +abstract class StorefrontOrderNotification extends Notification implements SendsPushNotification, ShouldQueue { use Queueable; + use SerializesModels; /** * The order instance this notification is for. From 7aaf8e4e73a8171779b6039d5e9537641d94600d Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 9 Oct 2026 20:08:37 +0800 Subject: [PATCH 034/193] feat(orders): a multi-store order is listed once, with its breakdown by store --- .../Controllers/v1/CustomerController.php | 8 +- .../Http/Controllers/v1/OrderController.php | 75 +++++++++++++++++++ server/src/routes.php | 1 + 3 files changed, 79 insertions(+), 5 deletions(-) diff --git a/server/src/Http/Controllers/v1/CustomerController.php b/server/src/Http/Controllers/v1/CustomerController.php index 6cc9cf7c..01ea14f2 100644 --- a/server/src/Http/Controllers/v1/CustomerController.php +++ b/server/src/Http/Controllers/v1/CustomerController.php @@ -185,12 +185,10 @@ public function orders(Request $request) $results = Order::queryWithRequest($request, function (&$query) use ($customer) { $query->where('customer_uuid', $customer->uuid)->whereNull('deleted_at')->withoutGlobalScopes(); - // dont query any master orders if its a network + // A multi-store checkout is one order to the customer: list its master order (which + // shows each store's part) and leave out the stores' own orders. if (session('storefront_network')) { - $query->where(function ($q) { - $q->where('meta->is_master_order', false); - $q->orWhere('meta', 'not like', '%related_orders%'); - }); + $query->whereNull('meta->master_order_id'); } }, true); diff --git a/server/src/Http/Controllers/v1/OrderController.php b/server/src/Http/Controllers/v1/OrderController.php index 75746311..9dcfd7d9 100644 --- a/server/src/Http/Controllers/v1/OrderController.php +++ b/server/src/Http/Controllers/v1/OrderController.php @@ -5,6 +5,7 @@ use Fleetbase\FleetOps\Models\Order; use Fleetbase\Http\Controllers\Controller; use Fleetbase\Storefront\Models\Checkout; +use Fleetbase\Storefront\Models\Store; use Fleetbase\Storefront\Support\OrderActivityFlow; use Fleetbase\Storefront\Support\QPay; use Fleetbase\Storefront\Support\Storefront; @@ -58,6 +59,80 @@ protected function updateOrderStatus(Order $order, string $status) return $order->updateStatus($status); } + /** + * The stores a multi-store (network) order was placed with: each store's own order, its + * progress, the items it is preparing and its subtotal. The customer's order is the one + * delivery that brings them together; this is its breakdown by store. Empty for an order + * from a single store. + */ + public function getStores(string $id) + { + $customer = Storefront::getCustomerFromToken(); + if (!$customer) { + return response()->apiError('Customer is not authenticated.'); + } + + $order = Order::where('public_id', $id)->whereNull('deleted_at')->first(); + if (!$order) { + return response()->apiError('No order found.', 404); + } + + if ($order->customer_uuid !== $customer->uuid) { + return response()->apiError('Not authorized to view this order.', 403); + } + + $related = array_values(array_filter((array) $order->getMeta('related_orders', []))); + if (!$order->getMeta('is_master_order') || empty($related)) { + return response()->json(['stores' => []]); + } + + $children = Order::whereIn('public_id', $related) + ->where('customer_uuid', $customer->uuid) + ->whereNull('deleted_at') + ->with(['payload.pickup', 'payload.entities']) + ->get() + ->sortBy(fn ($child) => array_search($child->public_id, $related)) + ->values(); + + $storeIds = $children->map(fn ($child) => $child->getMeta('storefront_id'))->filter()->unique()->values(); + $stores = Store::whereIn('public_id', $storeIds)->get()->keyBy('public_id'); + + $sections = $children->map(function (Order $child) use ($stores) { + $store = $stores->get($child->getMeta('storefront_id')); + $pickup = data_get($child, 'payload.pickup'); + $flow = OrderActivityFlow::forOrder($child); + $step = collect($flow['steps'] ?? [])->firstWhere('state', 'current') ?? collect($flow['steps'] ?? [])->last(); + + return [ + 'order' => $child->public_id, + 'status' => $child->status, + 'label' => data_get($step, 'label'), + 'store' => [ + 'id' => $store?->public_id ?? $child->getMeta('storefront_id'), + 'name' => $store?->name ?? $child->getMeta('storefront'), + 'logo_url' => $store?->logo_url, + 'phone' => $store?->phone, + 'address' => $pickup ? ($pickup->address ?? null) : null, + ], + 'items' => collect(data_get($child, 'payload.entities', []))->map(fn ($entity) => [ + 'id' => $entity->public_id, + 'name' => $entity->name, + 'quantity' => (int) (data_get($entity, 'meta.quantity') ?: 1), + 'subtotal' => (int) data_get($entity, 'meta.subtotal', 0), + 'variants' => data_get($entity, 'meta.variants', []), + 'addons' => data_get($entity, 'meta.addons', []), + 'image_url' => data_get($entity, 'meta.image_url') ?? $entity->photo_url ?? null, + ])->values(), + 'subtotal' => (int) $child->getMeta('subtotal', 0), + 'discount' => (int) $child->getMeta('discount', 0), + 'tip' => (int) $child->getMeta('tip', 0), + 'currency' => $child->getMeta('currency'), + ]; + })->values(); + + return response()->json(['stores' => $sections]); + } + /** * The customer's order as a line of steps from its own order config: where it has been, * where it is, and where it is expected to go (see OrderActivityFlow). diff --git a/server/src/routes.php b/server/src/routes.php index 779a3765..0f39a4d6 100644 --- a/server/src/routes.php +++ b/server/src/routes.php @@ -99,6 +99,7 @@ function ($router) { $router->put('picked-up', 'OrderController@completeOrderPickup'); $router->post('receipt', 'OrderController@getReceipt'); $router->get('{id}/activity-flow', 'OrderController@getActivityFlow'); + $router->get('{id}/stores', 'OrderController@getStores'); $router->get('{id}/chat', 'OrderChatController@show'); $router->get('{id}/chat/messages', 'OrderChatController@messages'); $router->post('{id}/chat/messages', 'OrderChatController@send'); From 8c5d6e84f4ca06ca571787c5eb6ba41d338dd432 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 9 Oct 2026 20:12:14 +0800 Subject: [PATCH 035/193] feat(checkout): a multi-store order records the names of its stores --- server/src/Http/Controllers/v1/CheckoutController.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/src/Http/Controllers/v1/CheckoutController.php b/server/src/Http/Controllers/v1/CheckoutController.php index 8a405d87..1276293c 100644 --- a/server/src/Http/Controllers/v1/CheckoutController.php +++ b/server/src/Http/Controllers/v1/CheckoutController.php @@ -1922,6 +1922,8 @@ public function captureMultipleOrders(CaptureOrderRequest $request) $masterOrderMeta = [ 'is_master_order' => true, 'related_orders' => collect($multipleOrders)->pluck('public_id')->toArray(), + // the stores this order brings together, for lists that show it as one order + 'store_names' => $originStores->pluck('name')->filter()->unique()->values()->all(), 'storefront' => $about->name, 'storefront_id' => $about->public_id, 'storefront_network' => $about->name, From b89a946d8d7a68185b896bb600e6a14cd2399e21 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 9 Oct 2026 20:56:21 +0800 Subject: [PATCH 036/193] test: make the engine's own test suite bootable The dummy app never ran: ember-classic-decorator (unused) crashes on Ember 5, lazy loading kept the engine's modules out of the test bundle, and the host services, config and intl data the panels need were missing. Mirrors what fleetops already does: eager loading when `ember test` runs from this package, an inert stand-in for the console's extensions module, the API config the core adapter reads at module scope, and the legacy built-in components the engine's templates still use. The dummy intl service ignores missing locale data, since headless Chrome has no number formats for every bundled locale, and the order details and network controller tests stub the host router, fetch and app cache the way the widget tests already do. --- index.js | 8 +- package.json | 4 +- pnpm-lock.yaml | 84 +++++++++++-------- tests/dummy/app/services/intl.js | 18 ++++ tests/dummy/config/environment.js | 14 ++++ .../components/shared-identity-test.js | 4 +- .../storefront/order/details-test.js | 53 ++++++++++-- .../components/widget/customers-test.js | 8 ++ tests/test-helper.js | 11 +++ .../unit/controllers/customers/index-test.js | 2 + .../networks/index/network/customers-test.js | 8 ++ .../networks/index/network/orders-test.js | 8 ++ tests/unit/controllers/orders/index-test.js | 2 + 13 files changed, 174 insertions(+), 50 deletions(-) create mode 100644 tests/dummy/app/services/intl.js diff --git a/index.js b/index.js index 005d7714..10a66128 100644 --- a/index.js +++ b/index.js @@ -8,8 +8,14 @@ const path = require('path'); module.exports = buildEngine({ name, + // Lazy loading keeps the engine out of the host bundle, but it also keeps the + // engine's modules out of the dummy app that `ember test` builds, so every test + // importing `@fleetbase/storefront-engine/*` fails to load. Eager loading is + // scoped to `ember test` run from this package, so host apps consuming the + // engine, including their own test builds, keep lazy loading. Addon index + // files are evaluated before ember-cli assigns EMBER_ENV, hence the argv check. lazyLoading: { - enabled: true, + enabled: !(process.argv.includes('test') && process.cwd() === __dirname), }, isDevelopingAddon() { diff --git a/package.json b/package.json index 344a57db..33417fed 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "@babel/core": "^7.23.2", "@fleetbase/ember-core": "^0.3.25", "@fleetbase/ember-ui": "^0.4.5", - "@fleetbase/fleetops-data": "^0.2.3", + "@fleetbase/fleetops-data": "^0.2.2", "@fortawesome/ember-fontawesome": "^2.0.0", "@fortawesome/fontawesome-svg-core": "6.4.0", "@fortawesome/free-brands-svg-icons": "6.4.0", @@ -61,6 +61,7 @@ "devDependencies": { "@babel/eslint-parser": "^7.22.15", "@babel/plugin-proposal-decorators": "^7.23.2", + "@ember/legacy-built-in-components": "^0.4.2", "@ember/optional-features": "^2.0.0", "@ember/test-helpers": "^3.2.0", "@embroider/test-setup": "^3.0.2", @@ -71,7 +72,6 @@ "broccoli-funnel": "^3.0.8", "broccoli-merge-trees": "^4.2.0", "concurrently": "^8.2.2", - "ember-classic-decorator": "^3.0.0", "ember-cli": "~5.4.1", "ember-cli-clean-css": "^3.0.0", "ember-cli-dependency-checker": "^3.3.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 676fd872..091b4870 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,8 +18,8 @@ importers: specifier: ^0.4.5 version: 0.4.5(fd4e3593913b6af1971bbb09f229d016) '@fleetbase/fleetops-data': - specifier: ^0.1.37 - version: 0.1.37(d08c013ce78c1181b04d076f9e3ef4de) + specifier: ^0.2.2 + version: 0.2.2(d08c013ce78c1181b04d076f9e3ef4de) '@fortawesome/ember-fontawesome': specifier: ^2.0.0 version: 2.0.0(@babel/core@7.29.7(supports-color@8.1.1))(ember-source@5.4.1(@babel/core@7.29.7(supports-color@8.1.1))(@glimmer/component@1.1.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(rsvp@4.8.5)(supports-color@8.1.1)(webpack@5.107.2(clean-css@5.3.3)(postcss@8.5.15)(uglify-js@3.19.3)))(rollup@2.80.0)(supports-color@8.1.1)(webpack@5.107.2(clean-css@5.3.3)(postcss@8.5.15)(uglify-js@3.19.3)) @@ -57,6 +57,9 @@ importers: '@babel/plugin-proposal-decorators': specifier: ^7.23.2 version: 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + '@ember/legacy-built-in-components': + specifier: ^0.4.2 + version: 0.4.2(@babel/core@7.29.7(supports-color@8.1.1))(ember-source@5.4.1(@babel/core@7.29.7(supports-color@8.1.1))(@glimmer/component@1.1.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(rsvp@4.8.5)(supports-color@8.1.1)(webpack@5.107.2(clean-css@5.3.3)(postcss@8.5.15)(uglify-js@3.19.3)))(supports-color@8.1.1) '@ember/optional-features': specifier: ^2.0.0 version: 2.3.0(supports-color@8.1.1) @@ -87,9 +90,6 @@ importers: concurrently: specifier: ^8.2.2 version: 8.2.2 - ember-classic-decorator: - specifier: ^3.0.0 - version: 3.0.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) ember-cli: specifier: ~5.4.1 version: 5.4.2(@babel/core@7.29.7(supports-color@8.1.1))(@types/node@25.9.1)(debug@4.4.3(supports-color@8.1.1))(handlebars@4.7.9)(supports-color@8.1.1)(underscore@1.13.8) @@ -116,7 +116,7 @@ importers: version: 4.12.8(@babel/core@7.29.7(supports-color@8.1.1))(@ember/string@3.1.1(supports-color@8.1.1))(@glimmer/tracking@1.1.2)(ember-source@5.4.1(@babel/core@7.29.7(supports-color@8.1.1))(@glimmer/component@1.1.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(rsvp@4.8.5)(supports-color@8.1.1)(webpack@5.107.2(clean-css@5.3.3)(postcss@8.5.15)(uglify-js@3.19.3)))(supports-color@8.1.1)(webpack@5.107.2(clean-css@5.3.3)(postcss@8.5.15)(uglify-js@3.19.3)) ember-engines: specifier: ^0.9.0 - version: 0.9.0(@babel/core@7.29.7(supports-color@8.1.1))(ember-source@5.4.1(@babel/core@7.29.7(supports-color@8.1.1))(@glimmer/component@1.1.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(rsvp@4.8.5)(supports-color@8.1.1)(webpack@5.107.2(clean-css@5.3.3)(postcss@8.5.15)(uglify-js@3.19.3)))(supports-color@8.1.1) + version: 0.9.0(@babel/core@7.29.7(supports-color@8.1.1))(@ember/legacy-built-in-components@0.4.2(@babel/core@7.29.7(supports-color@8.1.1))(ember-source@5.4.1(@babel/core@7.29.7(supports-color@8.1.1))(@glimmer/component@1.1.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(rsvp@4.8.5)(supports-color@8.1.1)(webpack@5.107.2(clean-css@5.3.3)(postcss@8.5.15)(uglify-js@3.19.3)))(supports-color@8.1.1))(ember-source@5.4.1(@babel/core@7.29.7(supports-color@8.1.1))(@glimmer/component@1.1.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(rsvp@4.8.5)(supports-color@8.1.1)(webpack@5.107.2(clean-css@5.3.3)(postcss@8.5.15)(uglify-js@3.19.3)))(supports-color@8.1.1) ember-load-initializers: specifier: ^2.1.2 version: 2.1.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) @@ -1147,6 +1147,12 @@ packages: '@ember/edition-utils@1.2.0': resolution: {integrity: sha512-VmVq/8saCaPdesQmftPqbFtxJWrzxNGSQ+e8x8LLe3Hjm36pJ04Q8LeORGZkAeOhldoUX9seLGmSaHeXkIqoog==} + '@ember/legacy-built-in-components@0.4.2': + resolution: {integrity: sha512-rJulbyVQIVe1zEDQDqAQHechHy44DsS2qxO24+NmU/AYxwPFSzWC/OZNCDFSfLU+Y5BVd/00qjxF0pu7Nk+TNA==} + engines: {node: 12.* || 14.* || >= 16} + peerDependencies: + ember-source: '*' + '@ember/optional-features@2.3.0': resolution: {integrity: sha512-+M8CkPledQEaDbfIlwlq6Phgpm5jdT3a6WVDJk7b/zadw5xAJkuQKVK7DgR0SFgHGiWlyn6a8AU5p2mCA706RA==} engines: {node: 10.* || 12.* || >= 14} @@ -1283,14 +1289,14 @@ packages: resolution: {integrity: sha512-C/+OvMpH5fCKYY5v+CteR6Tx6IAI33nfvOrmo7mFGDnyKDsjplsVVgWpsecLG94fJvpuVVI+CFvaiH50GUM3Mg==} engines: {node: '>= 18'} - '@fleetbase/fleetops-data@0.1.37': - resolution: {integrity: sha512-VGVWaGlwQAqHWA9/A80jKAjoDkAM7U9Ve1UVmKkCTlolqUd5/k/Z66rVC0iFUijj8qzkMCdahdh3mBPQ6x71WA==} - engines: {node: '>= 18'} - '@fleetbase/fleetops-data@0.1.40': resolution: {integrity: sha512-bp4tWaS2cpnitjtmH9hm3T7milwSqgt4j3T/KA8cvsFpbg7p303ADb5CNml5RsribgdBcRYtMsLEFYeYnU9dpg==} engines: {node: '>= 18'} + '@fleetbase/fleetops-data@0.2.2': + resolution: {integrity: sha512-HDgcGpJAk8q+sSmV070nCh02o4S0kuFCvwrf+Ltb+IrUkQ2RhDL/w8gdrqM65HrgbgWxQnslIeqzKfF6pV4/DA==} + engines: {node: '>= 18'} + '@fleetbase/intl-lint@0.0.1': resolution: {integrity: sha512-LkjxJr15hSiGmqh3JwixcpjmkhXNieNAEgQUVv1Duo50jTr/D5WXEyOaeI8wuVnVFhT+FS/DMqm403DgSKlsEg==} hasBin: true @@ -3140,6 +3146,7 @@ packages: concat-stream@1.6.2: resolution: {integrity: sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==} + engines: {'0': node >= 0.8} concurrently@8.2.2: resolution: {integrity: sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==} @@ -3750,10 +3757,6 @@ packages: ember-resolver: '>= 8.0.0' ember-source: ^3.28.0 || ^4.0.0 || >=5.0.0 - ember-classic-decorator@3.0.1: - resolution: {integrity: sha512-IoocHPX1cY93Sma7VeDGbF0M1+TkBWDJLus+RD01902eD+KHEL2+diwM+iayCyO+1/xaUzdzNGh35J+i+TCxoA==} - engines: {node: 12.* || 14.* || >= 16} - ember-cli-babel-plugin-helpers@1.1.1: resolution: {integrity: sha512-sKvOiPNHr5F/60NLd7SFzMpYPte/nnGkq/tMIfXejfKHIhaiIkYFqX8Z9UFTKWLLn+V7NOaby6niNPZUdvKCRw==} engines: {node: 6.* || 8.* || >= 10.*} @@ -9866,6 +9869,18 @@ snapshots: '@ember/edition-utils@1.2.0': {} + '@ember/legacy-built-in-components@0.4.2(@babel/core@7.29.7(supports-color@8.1.1))(ember-source@5.4.1(@babel/core@7.29.7(supports-color@8.1.1))(@glimmer/component@1.1.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(rsvp@4.8.5)(supports-color@8.1.1)(webpack@5.107.2(clean-css@5.3.3)(postcss@8.5.15)(uglify-js@3.19.3)))(supports-color@8.1.1)': + dependencies: + '@embroider/macros': 1.20.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) + ember-cli-babel: 7.26.11(supports-color@8.1.1) + ember-cli-htmlbars: 5.7.2(supports-color@8.1.1) + ember-cli-typescript: 4.2.1(supports-color@8.1.1) + ember-source: 5.4.1(@babel/core@7.29.7(supports-color@8.1.1))(@glimmer/component@1.1.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(rsvp@4.8.5)(supports-color@8.1.1)(webpack@5.107.2(clean-css@5.3.3)(postcss@8.5.15)(uglify-js@3.19.3)) + transitivePeerDependencies: + - '@babel/core' + - '@glint/template' + - supports-color + '@ember/optional-features@2.3.0(supports-color@8.1.1)': dependencies: chalk: 4.1.2 @@ -9953,7 +9968,7 @@ snapshots: find-up: 5.0.0 lodash: 4.18.1 resolve: 1.22.12 - semver: 7.8.1 + semver: 7.8.5 transitivePeerDependencies: - '@babel/core' - supports-color @@ -10001,10 +10016,10 @@ snapshots: js-string-escape: 1.0.1 lodash: 4.18.1 minimatch: 3.1.5 - pkg-entry-points: 1.1.1 + pkg-entry-points: 1.1.2 resolve-package-path: 4.0.3 resolve.exports: 2.0.3 - semver: 7.8.1 + semver: 7.8.5 typescript-memoize: 1.1.1 transitivePeerDependencies: - supports-color @@ -10249,6 +10264,7 @@ snapshots: - ember-cli-mirage - ember-resolver - ember-source + - encoding - eslint - miragejs - postcss @@ -10263,7 +10279,7 @@ snapshots: - webpack-command - yaml - '@fleetbase/fleetops-data@0.1.37(d08c013ce78c1181b04d076f9e3ef4de)': + '@fleetbase/fleetops-data@0.1.40(d08c013ce78c1181b04d076f9e3ef4de)': dependencies: '@babel/core': 7.29.7(supports-color@8.1.1) '@fleetbase/ember-core': 0.3.25(d08c013ce78c1181b04d076f9e3ef4de) @@ -10284,10 +10300,10 @@ snapshots: - utf-8-validate - webpack - '@fleetbase/fleetops-data@0.1.40(d08c013ce78c1181b04d076f9e3ef4de)': + '@fleetbase/fleetops-data@0.2.2(d08c013ce78c1181b04d076f9e3ef4de)': dependencies: '@babel/core': 7.29.7(supports-color@8.1.1) - '@fleetbase/ember-core': 0.3.24(d08c013ce78c1181b04d076f9e3ef4de) + '@fleetbase/ember-core': 0.3.25(d08c013ce78c1181b04d076f9e3ef4de) date-fns: 2.30.0 ember-cli-babel: 8.3.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) ember-cli-htmlbars: 6.3.0(supports-color@8.1.1) @@ -10298,6 +10314,7 @@ snapshots: - bufferutil - ember-resolver - ember-source + - encoding - eslint - supports-color - typescript @@ -11055,7 +11072,7 @@ snapshots: '@types/glob@9.0.0': dependencies: - glob: 8.1.0 + glob: 13.0.6 '@types/http-errors@2.0.5': {} @@ -11080,7 +11097,7 @@ snapshots: '@types/minimatch@6.0.0': dependencies: - minimatch: 7.4.9 + minimatch: 10.2.5 '@types/minimist@1.2.5': {} @@ -11586,7 +11603,7 @@ snapshots: babel-generator: 6.26.1 babel-helpers: 6.24.1(supports-color@8.1.1) babel-messages: 6.23.0 - babel-register: 6.26.0 + babel-register: 6.26.0(supports-color@8.1.1) babel-runtime: 6.26.0 babel-template: 6.26.0(supports-color@8.1.1) babel-traverse: 6.26.0(supports-color@8.1.1) @@ -11742,7 +11759,7 @@ snapshots: babel-plugin-syntax-dynamic-import@6.18.0: {} - babel-register@6.26.0: + babel-register@6.26.0(supports-color@8.1.1): dependencies: babel-core: 6.26.3(supports-color@8.1.1) babel-runtime: 6.26.0 @@ -11751,6 +11768,8 @@ snapshots: lodash: 4.18.1 mkdirp: 0.5.6 source-map-support: 0.4.18 + transitivePeerDependencies: + - supports-color babel-runtime@6.26.0: dependencies: @@ -13512,17 +13531,6 @@ snapshots: - '@babel/core' - supports-color - ember-classic-decorator@3.0.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1): - dependencies: - '@embroider/macros': 1.20.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) - babel-plugin-filter-imports: 4.0.0 - ember-cli-babel: 7.26.11(supports-color@8.1.1) - ember-cli-htmlbars: 6.3.0(supports-color@8.1.1) - transitivePeerDependencies: - - '@babel/core' - - '@glint/template' - - supports-color - ember-cli-babel-plugin-helpers@1.1.1: {} ember-cli-babel@7.26.11(supports-color@8.1.1): @@ -13655,7 +13663,7 @@ snapshots: hash-for-dep: 1.5.2(supports-color@8.1.1) heimdalljs-logger: 0.1.10(supports-color@8.1.1) json-stable-stringify: 1.3.0 - semver: 7.8.1 + semver: 7.8.5 silent-error: 1.1.1(supports-color@8.1.1) strip-bom: 4.0.0 walk-sync: 2.2.0 @@ -14173,7 +14181,7 @@ snapshots: transitivePeerDependencies: - supports-color - ember-engines@0.9.0(@babel/core@7.29.7(supports-color@8.1.1))(ember-source@5.4.1(@babel/core@7.29.7(supports-color@8.1.1))(@glimmer/component@1.1.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(rsvp@4.8.5)(supports-color@8.1.1)(webpack@5.107.2(clean-css@5.3.3)(postcss@8.5.15)(uglify-js@3.19.3)))(supports-color@8.1.1): + ember-engines@0.9.0(@babel/core@7.29.7(supports-color@8.1.1))(@ember/legacy-built-in-components@0.4.2(@babel/core@7.29.7(supports-color@8.1.1))(ember-source@5.4.1(@babel/core@7.29.7(supports-color@8.1.1))(@glimmer/component@1.1.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(rsvp@4.8.5)(supports-color@8.1.1)(webpack@5.107.2(clean-css@5.3.3)(postcss@8.5.15)(uglify-js@3.19.3)))(supports-color@8.1.1))(ember-source@5.4.1(@babel/core@7.29.7(supports-color@8.1.1))(@glimmer/component@1.1.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(rsvp@4.8.5)(supports-color@8.1.1)(webpack@5.107.2(clean-css@5.3.3)(postcss@8.5.15)(uglify-js@3.19.3)))(supports-color@8.1.1): dependencies: '@embroider/macros': 1.20.3(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) amd-name-resolver: 1.3.1 @@ -14193,6 +14201,8 @@ snapshots: ember-cli-version-checker: 5.1.2(supports-color@8.1.1) ember-source: 5.4.1(@babel/core@7.29.7(supports-color@8.1.1))(@glimmer/component@1.1.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(rsvp@4.8.5)(supports-color@8.1.1)(webpack@5.107.2(clean-css@5.3.3)(postcss@8.5.15)(uglify-js@3.19.3)) lodash: 4.18.1 + optionalDependencies: + '@ember/legacy-built-in-components': 0.4.2(@babel/core@7.29.7(supports-color@8.1.1))(ember-source@5.4.1(@babel/core@7.29.7(supports-color@8.1.1))(@glimmer/component@1.1.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(rsvp@4.8.5)(supports-color@8.1.1)(webpack@5.107.2(clean-css@5.3.3)(postcss@8.5.15)(uglify-js@3.19.3)))(supports-color@8.1.1) transitivePeerDependencies: - '@babel/core' - '@glint/template' diff --git a/tests/dummy/app/services/intl.js b/tests/dummy/app/services/intl.js new file mode 100644 index 00000000..90cd3308 --- /dev/null +++ b/tests/dummy/app/services/intl.js @@ -0,0 +1,18 @@ +import IntlService from 'ember-intl/services/intl'; + +/** + * ember-intl builds a formatter for every bundled locale when the service is + * created. Headless Chrome ships no number-format data for some of them + * (Mongolian among them), which ember-intl's default handler turns into a thrown + * error inside whichever test first touches intl. Missing locale data and + * missing translations are not what any test here checks, so both are ignored. + */ +export default class DummyIntlService extends IntlService { + onIntlError(error) { + if (error?.code === 'MISSING_DATA' || error?.code === 'MISSING_TRANSLATION') { + return; + } + + super.onIntlError(error); + } +} diff --git a/tests/dummy/config/environment.js b/tests/dummy/config/environment.js index 61f3a09d..12eb0fdb 100644 --- a/tests/dummy/config/environment.js +++ b/tests/dummy/config/environment.js @@ -14,6 +14,20 @@ module.exports = function (environment) { }, }, + // `@fleetbase/ember-core`'s application adapter reads `config.API.host` at + + // module scope, so the dummy application must declare it before any + + // adapter module can even be evaluated. The values are deliberately + + // obviously-fake: nothing in the suite performs a real request. + + API: { + host: 'https://api.fleetbase.test', + + namespace: 'int/v1', + }, + APP: { // Here you can pass flags/options to your application instance // when it is created diff --git a/tests/integration/components/shared-identity-test.js b/tests/integration/components/shared-identity-test.js index 00bf135c..aed5eba7 100644 --- a/tests/integration/components/shared-identity-test.js +++ b/tests/integration/components/shared-identity-test.js @@ -17,7 +17,7 @@ module('Integration | Component | shared identity components', function (hooks) }); test('a driver identity cell renders the name, status dot and vehicle badge from the shared descriptor', async function (assert) { - this.set('row', { driver_assigned: { resourceType: 'driver', name: 'Ada Driver', status: 'available', vehicle_name: 'Truck 10' } }); + this.set('row', { driver_assigned: { resourceType: 'driver', name: 'Ada Driver', status: 'available', online: true, vehicle_name: 'Truck 10' } }); this.set('column', { resourceType: 'driver', resourcePath: 'driver_assigned', popover: false }); await render(hbs``); @@ -52,7 +52,7 @@ module('Integration | Component | shared identity components', function (hooks) `); assert.dom('[data-test-customer] [data-test-resource-pill-title]').hasText('Ava Chen'); - assert.dom('[data-test-customer] [data-test-resource-pill-subtitle]').hasText('Customer'); + assert.dom('[data-test-customer] [data-test-resource-pill-subtitle]').hasText('Contact', 'the polymorphic type reads as a person would say it'); assert.dom('[data-test-driver] [data-test-resource-pill-title]').hasText('Ada Driver'); assert.dom('[data-test-driver] [data-test-resource-pill-subtitle]').hasText('+15550100'); assert.dom('[data-test-place] [data-test-resource-pill-title]').hasText('Depot'); diff --git a/tests/integration/components/storefront/order/details-test.js b/tests/integration/components/storefront/order/details-test.js index 981b8944..185ebc4a 100644 --- a/tests/integration/components/storefront/order/details-test.js +++ b/tests/integration/components/storefront/order/details-test.js @@ -1,13 +1,49 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; -import { render } from '@ember/test-helpers'; +import { setupIntl } from 'ember-intl/test-support'; +import { render, findAll } from '@ember/test-helpers'; +import Service from '@ember/service'; import { hbs } from 'ember-cli-htmlbars'; import { initialize } from '@fleetbase/fleetops-data/instance-initializers/register-shared-resource-descriptors'; module('Integration | Component | storefront/order/details', function (hooks) { setupRenderingTest(hooks); + setupIntl(hooks, 'en-us'); hooks.beforeEach(function () { + // The activity, route and tracking panels fetch on insert and the dummy app has + // no API. A request that never answers leaves each task pending, and ember-concurrency + // cancels it with its component, so nothing writes to a torn-down panel. + this.owner.register( + 'service:fetch', + class extends Service { + get() { + return new Promise(() => {}); + } + + post() { + return new Promise(() => {}); + } + } + ); + // The app cache keeps a proxy that outlives a test's owner; the activity panel reads + // it while initialising, so give it an inert cache like the widget tests do. + this.owner.register( + 'service:app-cache', + class extends Service { + get() { + return undefined; + } + + set() {} + + setEmberData() {} + + getEmberData() { + return []; + } + } + ); initialize(this.owner); }); @@ -55,13 +91,14 @@ module('Integration | Component | storefront/order/details', function (hooks) { await render(hbs``); - assert.dom('.next-content-panel-title-container').hasTextContaining('Activity'); - assert.dom('.next-content-panel-title-container').hasTextContaining('Store'); - assert.dom('.next-content-panel-title-container').hasTextContaining('Order'); - assert.dom('.next-content-panel-title-container').hasTextContaining('Details'); - assert.dom('.next-content-panel-title-container').hasTextContaining('Customer Insights'); - assert.dom('.next-content-panel-title-container').hasTextContaining('Route'); - assert.dom('.next-content-panel-title-container').hasTextContaining('Metadata'); + const titles = findAll('.next-content-panel-title-container').map((element) => element.textContent.trim()); + + for (const title of ['Activity', 'Store', 'Order', 'Details', 'Customer Insights', 'Route', 'Metadata']) { + assert.ok( + titles.some((text) => text.includes(title)), + `a panel titled ${title} renders` + ); + } assert.dom('.storefront-order-person__name').hasTextContaining('Tasty Store'); assert.dom('[data-test-order-driver-pill] [data-test-resource-pill-title]').hasText('No driver assigned', 'an unassigned driver is a static pill with the fallback title'); assert.dom('[data-test-order-customer-pill] [data-test-resource-pill-title]').hasText('No customer'); diff --git a/tests/integration/components/widget/customers-test.js b/tests/integration/components/widget/customers-test.js index 4aa03639..9a9f20b2 100644 --- a/tests/integration/components/widget/customers-test.js +++ b/tests/integration/components/widget/customers-test.js @@ -26,6 +26,14 @@ class FetchStubService extends Service { } class IntlStubService extends Service { + exists() { + return false; + } + + onLocaleChanged() { + return () => {}; + } + t(key) { if (key === 'storefront.component.widget.customers.widget-title') { return 'Recent Customers'; diff --git a/tests/test-helper.js b/tests/test-helper.js index 4efd6e58..d355f8d0 100644 --- a/tests/test-helper.js +++ b/tests/test-helper.js @@ -5,6 +5,17 @@ import { setApplication } from '@ember/test-helpers'; import { setup } from 'qunit-dom'; import { start } from 'ember-qunit'; +// The universe extension manager imports a module the console host generates at build +// time. The engine's dummy app has no host, so provide an inert stand-in before boot. +if (typeof window.define === 'function' && !window.requirejs?.entries?.['@fleetbase/console/extensions']) { + window.define('@fleetbase/console/extensions', ['exports'], function (exports) { + exports.getExtensionLoader = function () { + return null; + }; + exports.default = {}; + }); +} + setApplication(Application.create(config.APP)); setup(QUnit.assert); diff --git a/tests/unit/controllers/customers/index-test.js b/tests/unit/controllers/customers/index-test.js index d8189724..d29291a8 100644 --- a/tests/unit/controllers/customers/index-test.js +++ b/tests/unit/controllers/customers/index-test.js @@ -1,8 +1,10 @@ import { module, test } from 'qunit'; import { setupTest } from 'dummy/tests/helpers'; +import { setupIntl } from 'ember-intl/test-support'; module('Unit | Controller | customers/index', function (hooks) { setupTest(hooks); + setupIntl(hooks, 'en-us'); test('it exists', function (assert) { let controller = this.owner.lookup('controller:customers/index'); diff --git a/tests/unit/controllers/networks/index/network/customers-test.js b/tests/unit/controllers/networks/index/network/customers-test.js index bd90c9fb..b86fd691 100644 --- a/tests/unit/controllers/networks/index/network/customers-test.js +++ b/tests/unit/controllers/networks/index/network/customers-test.js @@ -1,8 +1,16 @@ import { module, test } from 'qunit'; import { setupTest } from 'dummy/tests/helpers'; +import { setupIntl } from 'ember-intl/test-support'; +import Service from '@ember/service'; module('Unit | Controller | networks/index/network/customers', function (hooks) { setupTest(hooks); + setupIntl(hooks, 'en-us'); + + hooks.beforeEach(function () { + // The engine's dummy app has no console host to provide the host router. + this.owner.register('service:hostRouter', class extends Service {}); + }); test('it exists', function (assert) { let controller = this.owner.lookup('controller:networks/index/network/customers'); diff --git a/tests/unit/controllers/networks/index/network/orders-test.js b/tests/unit/controllers/networks/index/network/orders-test.js index 141a91cc..a6b26e5d 100644 --- a/tests/unit/controllers/networks/index/network/orders-test.js +++ b/tests/unit/controllers/networks/index/network/orders-test.js @@ -1,8 +1,16 @@ import { module, test } from 'qunit'; import { setupTest } from 'dummy/tests/helpers'; +import { setupIntl } from 'ember-intl/test-support'; +import Service from '@ember/service'; module('Unit | Controller | networks/index/network/orders', function (hooks) { setupTest(hooks); + setupIntl(hooks, 'en-us'); + + hooks.beforeEach(function () { + // The engine's dummy app has no console host to provide the host router. + this.owner.register('service:hostRouter', class extends Service {}); + }); test('it exists', function (assert) { let controller = this.owner.lookup('controller:networks/index/network/orders'); diff --git a/tests/unit/controllers/orders/index-test.js b/tests/unit/controllers/orders/index-test.js index 6c9ca5d4..5a2748a1 100644 --- a/tests/unit/controllers/orders/index-test.js +++ b/tests/unit/controllers/orders/index-test.js @@ -1,9 +1,11 @@ import { module, test } from 'qunit'; import { setupTest } from 'dummy/tests/helpers'; +import { setupIntl } from 'ember-intl/test-support'; import Service from '@ember/service'; module('Unit | Controller | orders/index', function (hooks) { setupTest(hooks); + setupIntl(hooks, 'en-us'); test('it exists', function (assert) { let controller = this.owner.lookup('controller:orders/index'); From 3c6ad104cdd805ad89dd676b382b540a1df689bb Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sat, 10 Oct 2026 12:03:38 +0800 Subject: [PATCH 037/193] feat(food-trucks): networks show their member stores' trucks; a truck is only found by its own storefront --- .../Controllers/v1/FoodTruckController.php | 56 ++++++++++++++----- server/src/Http/Resources/FoodTruck.php | 8 +++ 2 files changed, 51 insertions(+), 13 deletions(-) diff --git a/server/src/Http/Controllers/v1/FoodTruckController.php b/server/src/Http/Controllers/v1/FoodTruckController.php index be35a803..4f07070b 100644 --- a/server/src/Http/Controllers/v1/FoodTruckController.php +++ b/server/src/Http/Controllers/v1/FoodTruckController.php @@ -5,6 +5,7 @@ use Fleetbase\Http\Controllers\Controller; use Fleetbase\Storefront\Http\Resources\FoodTruck as FoodTruckResource; use Fleetbase\Storefront\Models\FoodTruck; +use Fleetbase\Storefront\Models\Network; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\Request; @@ -17,25 +18,47 @@ class FoodTruckController extends Controller */ public function query(Request $request) { - $limit = $request->input('limit', false); - $offset = $request->input('offset', false); - $results = []; + $limit = $request->input('limit', false); + $offset = $request->input('offset', false); + $storeIds = static::storeUuidsForStorefront(); + if (empty($storeIds)) { + return FoodTruckResource::collection([]); + } + + $results = FoodTruck::queryWithRequestCached($request, function (&$query) use ($limit, $offset, $storeIds) { + $query->whereIn('store_uuid', $storeIds)->with(['store', 'vehicle', 'zone', 'serviceArea', 'catalogs']); + + if ($limit) { + $query->limit($limit); + } + + if ($offset) { + $query->offset($offset); + } + }); + return FoodTruckResource::collection($results); + } + + /** + * The stores whose food trucks this storefront shows: a store's own, or in a network + * every member store's, so a network can run trucks alongside its stores. + * + * @return array + */ + protected static function storeUuidsForStorefront(): array + { if (session('storefront_store')) { - $results = FoodTruck::queryWithRequestCached($request, function (&$query) use ($limit, $offset) { - $query->where('store_uuid', session('storefront_store')); + return [session('storefront_store')]; + } - if ($limit) { - $query->limit($limit); - } + if (session('storefront_network')) { + $network = Network::where('uuid', session('storefront_network'))->first(); - if ($offset) { - $query->offset($offset); - } - }); + return $network ? $network->stores()->pluck('stores.uuid')->all() : []; } - return FoodTruckResource::collection($results); + return []; } /** @@ -52,6 +75,13 @@ public function find($id) return response()->error('Food Truck resource not found.'); } + // Only a truck of this storefront (its own, or a network member store's). + if (!in_array($foodTruck->store_uuid, static::storeUuidsForStorefront(), true)) { + return response()->error('Food Truck resource not found.', 404); + } + + $foodTruck->loadMissing(['store', 'vehicle', 'zone', 'serviceArea', 'catalogs']); + // response the product resource return new FoodTruckResource($foodTruck); } diff --git a/server/src/Http/Resources/FoodTruck.php b/server/src/Http/Resources/FoodTruck.php index 942e3624..028be3e6 100644 --- a/server/src/Http/Resources/FoodTruck.php +++ b/server/src/Http/Resources/FoodTruck.php @@ -36,6 +36,14 @@ public function toArray($request) 'location' => $this->vehicle ? $this->vehicle->location : null, 'online' => $this->vehicle ? $this->vehicle->online : false, 'status' => $this->status, + // The store the truck belongs to, so a network can name it and order from it. + 'store' => $this->whenLoaded('store', fn () => $this->store ? [ + 'id' => $this->store->public_id, + 'name' => $this->store->name, + 'logo_url' => $this->store->logo_url, + 'currency' => $this->store->currency, + 'options' => $this->store->options, + ] : null), 'created_at' => $this->created_at, 'updated_at' => $this->updated_at, ]; From d72c44b5f62f9376c518bec914b5884107cd5e7f Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sat, 10 Oct 2026 12:03:43 +0800 Subject: [PATCH 038/193] feat(food-trucks): networks show their member stores' trucks; a truck is only found by its own storefront --- .../Controllers/v1/FoodTruckController.php | 56 ++++++++++++++----- server/src/Http/Resources/FoodTruck.php | 8 +++ 2 files changed, 51 insertions(+), 13 deletions(-) diff --git a/server/src/Http/Controllers/v1/FoodTruckController.php b/server/src/Http/Controllers/v1/FoodTruckController.php index be35a803..4f07070b 100644 --- a/server/src/Http/Controllers/v1/FoodTruckController.php +++ b/server/src/Http/Controllers/v1/FoodTruckController.php @@ -5,6 +5,7 @@ use Fleetbase\Http\Controllers\Controller; use Fleetbase\Storefront\Http\Resources\FoodTruck as FoodTruckResource; use Fleetbase\Storefront\Models\FoodTruck; +use Fleetbase\Storefront\Models\Network; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\Request; @@ -17,25 +18,47 @@ class FoodTruckController extends Controller */ public function query(Request $request) { - $limit = $request->input('limit', false); - $offset = $request->input('offset', false); - $results = []; + $limit = $request->input('limit', false); + $offset = $request->input('offset', false); + $storeIds = static::storeUuidsForStorefront(); + if (empty($storeIds)) { + return FoodTruckResource::collection([]); + } + + $results = FoodTruck::queryWithRequestCached($request, function (&$query) use ($limit, $offset, $storeIds) { + $query->whereIn('store_uuid', $storeIds)->with(['store', 'vehicle', 'zone', 'serviceArea', 'catalogs']); + + if ($limit) { + $query->limit($limit); + } + + if ($offset) { + $query->offset($offset); + } + }); + return FoodTruckResource::collection($results); + } + + /** + * The stores whose food trucks this storefront shows: a store's own, or in a network + * every member store's, so a network can run trucks alongside its stores. + * + * @return array + */ + protected static function storeUuidsForStorefront(): array + { if (session('storefront_store')) { - $results = FoodTruck::queryWithRequestCached($request, function (&$query) use ($limit, $offset) { - $query->where('store_uuid', session('storefront_store')); + return [session('storefront_store')]; + } - if ($limit) { - $query->limit($limit); - } + if (session('storefront_network')) { + $network = Network::where('uuid', session('storefront_network'))->first(); - if ($offset) { - $query->offset($offset); - } - }); + return $network ? $network->stores()->pluck('stores.uuid')->all() : []; } - return FoodTruckResource::collection($results); + return []; } /** @@ -52,6 +75,13 @@ public function find($id) return response()->error('Food Truck resource not found.'); } + // Only a truck of this storefront (its own, or a network member store's). + if (!in_array($foodTruck->store_uuid, static::storeUuidsForStorefront(), true)) { + return response()->error('Food Truck resource not found.', 404); + } + + $foodTruck->loadMissing(['store', 'vehicle', 'zone', 'serviceArea', 'catalogs']); + // response the product resource return new FoodTruckResource($foodTruck); } diff --git a/server/src/Http/Resources/FoodTruck.php b/server/src/Http/Resources/FoodTruck.php index 942e3624..028be3e6 100644 --- a/server/src/Http/Resources/FoodTruck.php +++ b/server/src/Http/Resources/FoodTruck.php @@ -36,6 +36,14 @@ public function toArray($request) 'location' => $this->vehicle ? $this->vehicle->location : null, 'online' => $this->vehicle ? $this->vehicle->online : false, 'status' => $this->status, + // The store the truck belongs to, so a network can name it and order from it. + 'store' => $this->whenLoaded('store', fn () => $this->store ? [ + 'id' => $this->store->public_id, + 'name' => $this->store->name, + 'logo_url' => $this->store->logo_url, + 'currency' => $this->store->currency, + 'options' => $this->store->options, + ] : null), 'created_at' => $this->created_at, 'updated_at' => $this->updated_at, ]; From e60a7066048f28aa5f12a8c3d08a6511854afb81 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sat, 10 Oct 2026 12:30:24 +0800 Subject: [PATCH 039/193] feat(cards): catalogs, trucks and networks render on the shared resource card Catalog, truck and network index pages use Layout::Resource::CardsGrid with one card each on Layout::Resource::Card, following the Fleet-Ops anatomy: identity and subtitle in the header, media or facts in the body, status, icon actions and meta in the footer, with registry slots at each end. A catalog card shows its categories with product counts, its hours as a seven-day strip (the new Storefront::HoursStrip) and its product total; a draft is dashed. A truck card is a Vehicle::Pill with the online dot, the vehicle photo, where it serves, and a presence badge that says serving, offline, inactive, or online with no catalog. A network card shows the logo on its backdrop, the online state, the currency and member count. "Food trucks" reads "Trucks" in the UI; the food-truck model, routes and permissions keep their names. The catalogs and trucks pages gain the search the header already offered. --- addon/components/modals/create-food-truck.hbs | 12 +- addon/components/storefront/catalog/card.hbs | 53 +++++ addon/components/storefront/catalog/card.js | 48 ++++ addon/components/storefront/hours-strip.hbs | 8 + addon/components/storefront/hours-strip.js | 34 +++ addon/components/storefront/network/card.hbs | 44 ++++ addon/components/storefront/network/card.js | 29 +++ addon/components/storefront/truck/card.hbs | 49 ++++ addon/components/storefront/truck/card.js | 74 ++++++ addon/controllers/application.js | 2 +- addon/controllers/catalogs/index.js | 17 +- addon/controllers/food-trucks/index.js | 27 ++- addon/models/food-truck.js | 2 + addon/styles/storefront-engine.css | 225 ++++++++++++++++++ addon/templates/catalogs/index.hbs | 57 ++--- addon/templates/food-trucks/index.hbs | 59 ++--- addon/templates/networks/index.hbs | 91 ++----- app/components/storefront/catalog/card.js | 1 + app/components/storefront/hours-strip.js | 1 + app/components/storefront/network/card.js | 1 + app/components/storefront/truck/card.js | 1 + .../storefront/catalog/card-test.js | 53 +++++ .../components/storefront/hours-strip-test.js | 33 +++ .../storefront/network/card-test.js | 48 ++++ .../components/storefront/truck/card-test.js | 59 +++++ translations/en-us.yaml | 32 ++- 26 files changed, 888 insertions(+), 172 deletions(-) create mode 100644 addon/components/storefront/catalog/card.hbs create mode 100644 addon/components/storefront/catalog/card.js create mode 100644 addon/components/storefront/hours-strip.hbs create mode 100644 addon/components/storefront/hours-strip.js create mode 100644 addon/components/storefront/network/card.hbs create mode 100644 addon/components/storefront/network/card.js create mode 100644 addon/components/storefront/truck/card.hbs create mode 100644 addon/components/storefront/truck/card.js create mode 100644 app/components/storefront/catalog/card.js create mode 100644 app/components/storefront/hours-strip.js create mode 100644 app/components/storefront/network/card.js create mode 100644 app/components/storefront/truck/card.js create mode 100644 tests/integration/components/storefront/catalog/card-test.js create mode 100644 tests/integration/components/storefront/hours-strip-test.js create mode 100644 tests/integration/components/storefront/network/card-test.js create mode 100644 tests/integration/components/storefront/truck/card-test.js diff --git a/addon/components/modals/create-food-truck.hbs b/addon/components/modals/create-food-truck.hbs index 3332b6b6..cfc3e543 100644 --- a/addon/components/modals/create-food-truck.hbs +++ b/addon/components/modals/create-food-truck.hbs @@ -1,11 +1,11 @@ + {{/each}} + {{#if @fields.custom.length}} +
{{t "storefront.products.form.translations.custom-fields"}}
+ {{#each @fields.custom as |field|}} +
+ {{field.key}} + + {{#unless @disabled}} +
+ {{/each}} + {{/if}} + {{#unless @disabled}} +
+ +
+ {{/unless}} +

{{t "storefront.products.form.translations.help"}}

+
+ {{else}} +

{{t "storefront.products.form.translations.none"}}

+ {{/if}} +
diff --git a/addon/components/storefront/product/form/variants.hbs b/addon/components/storefront/product/form/variants.hbs index 7dd1ab2e..b66fbc34 100644 --- a/addon/components/storefront/product/form/variants.hbs +++ b/addon/components/storefront/product/form/variants.hbs @@ -1,31 +1,58 @@ -{{! Each variant is a compact block: its name and rules, then one row per option. }} -
+{{! Each variant is a group: its name and rules, then its options as a small table, Add option under it. }} +
{{#each @product.variants as |variant|}} -
-
-
+
+
+
{{n-a variant.name}} - {{#if variant.is_required}}{{/if}} - {{#if variant.is_multiselect}}{{/if}} + + {{if variant.is_required (t "storefront.products.form.variants.required") (t "storefront.products.form.variants.optional")}} + · + {{if variant.is_multiselect (t "storefront.products.form.variants.multiple") (t "storefront.products.form.variants.pick-one")}} + · + {{t "storefront.products.form.variants.options-count" count=variant.options.length}} +
-
-
- {{#each variant.options as |variantOption index|}} -
- - - -
+ {{#if variant.options.length}} +
{{format-currency order.meta.total order.meta.currency}}{{n-a order.customer.name}} {{#if order.meta.is_pickup}} {{t "storefront.component.widget.orders.pickup-order" }} {{else}} - {{n-a order.driver_assigned.name}} + {{/if}}
+ + + + + + + + + + {{#each variant.options as |variantOption index|}} + + + + + + + {{/each}} + +
{{t "storefront.products.form.variants.option"}}{{t "storefront.common.description"}}{{t "storefront.products.form.variants.extra-cost"}}{{t "common.remove"}}
{{else}} -
{{t "storefront.products.form.fields.no-options"}}
- {{/each}} +
{{t "storefront.products.form.fields.no-options"}}
+ {{/if}} + {{#if (can "storefront create product-variant-option")}} + + {{/if}}
{{else}} -
{{t "storefront.products.form.variants.none"}}
+
+ +
{{t "storefront.products.form.variants.none"}}
+
{{/each}}
diff --git a/addon/components/storefront/product/form/videos.hbs b/addon/components/storefront/product/form/videos.hbs index c9bfcc96..24b7f615 100644 --- a/addon/components/storefront/product/form/videos.hbs +++ b/addon/components/storefront/product/form/videos.hbs @@ -1,5 +1,40 @@ -
- - - +{{! YouTube links shown in the product gallery: one row per link, its thumbnail once the link is valid. }} +
+
+ {{t "storefront.products.form.fields.video-urls"}} + {{#unless @disabled}} + + {{/unless}} +
+ {{#each @rows as |row|}} +
+ + {{#if row.thumbnail}} + + {{else}} + + {{/if}} + + + {{#unless @disabled}} +
+ {{#if (and row.url (not row.isValid))}} +

{{t "storefront.products.form.videos.invalid"}}

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

{{t "storefront.products.form.fields.video-urls-help"}}

+ {{/each}}
diff --git a/addon/styles/storefront-engine.css b/addon/styles/storefront-engine.css index e57d401c..4271ddf8 100644 --- a/addon/styles/storefront-engine.css +++ b/addon/styles/storefront-engine.css @@ -9807,3 +9807,456 @@ body[data-theme='dark'] .storefront-table--rows tbody tr.is-clickable:hover td { flex-basis: 7.5rem; height: 7.5rem; } + +/* Schedule manager: the week as compact rows (day, slots as chips, Add hours) in one box */ +.storefront-schedule { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.storefront-schedule__empty { + margin: 0; + color: #6b7280; + font-size: 0.8125rem; +} + +body[data-theme='dark'] .storefront-schedule__empty { + color: #9ca3af; +} + +.storefront-schedule__week { + overflow: hidden; + border: 1px solid #e5e7eb; + border-radius: 0.375rem; +} + +body[data-theme='dark'] .storefront-schedule__week { + border-color: #374151; +} + +.storefront-schedule__day { + display: grid; + grid-template-columns: 7rem minmax(0, 1fr) auto; + gap: 0.75rem; + align-items: center; + min-height: 2.5rem; + padding: 0.375rem 0.75rem; +} + +.storefront-schedule__day + .storefront-schedule__day { + border-top: 1px solid #f3f4f6; +} + +body[data-theme='dark'] .storefront-schedule__day + .storefront-schedule__day { + border-color: var(--sf-hairline); +} + +.storefront-schedule__name { + color: #374151; + font-size: 0.8125rem; + font-weight: 500; +} + +body[data-theme='dark'] .storefront-schedule__name { + color: #e5e7eb; +} + +.storefront-schedule__slots { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; + min-width: 0; +} + +.storefront-schedule__slot { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.125rem 0.375rem 0.125rem 0.5rem; + border-radius: 9999px; + color: #1e40af; + font-size: 0.75rem; + font-variant-numeric: tabular-nums; + background-color: #dbeafe; +} + +body[data-theme='dark'] .storefront-schedule__slot { + color: #bfdbfe; + background-color: rgb(30 64 175 / 45%); +} + +.storefront-schedule__remove { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1rem; + height: 1rem; + border-radius: 9999px; + opacity: 0.7; +} + +.storefront-schedule__remove:hover { + opacity: 1; + background-color: rgb(0 0 0 / 10%); +} + +.storefront-schedule__none { + color: #9ca3af; + font-size: 0.75rem; +} + +.storefront-schedule__add { + display: inline-flex; + align-items: center; + gap: 0.375rem; + color: #2563eb; + font-size: 0.75rem; + font-weight: 500; + white-space: nowrap; +} + +.storefront-schedule__add:hover { + text-decoration: underline; +} + +body[data-theme='dark'] .storefront-schedule__add { + color: #93c5fd; +} + +/* Product form: an inline empty state sits in the section, not centred in a tall box */ +.storefront-panel-empty.storefront-panel-empty--inline { + padding: 1rem; + border: 1px dashed #d1d5db; + border-radius: 0.375rem; +} + +body[data-theme='dark'] .storefront-panel-empty.storefront-panel-empty--inline { + border-color: #374151; +} + +/* Product form: video links as rows with a thumbnail */ +.storefront-product-videos { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-top: 0.75rem; +} + +.storefront-product-videos__head { + display: flex; + align-items: center; + justify-content: space-between; +} + +.storefront-product-videos__row { + display: grid; + grid-template-columns: 4rem minmax(0, 1fr) auto; + gap: 0.5rem; + align-items: center; +} + +.storefront-product-videos__thumb { + display: flex; + align-items: center; + justify-content: center; + width: 4rem; + height: 2.25rem; + overflow: hidden; + border-radius: 0.25rem; + color: #9ca3af; + background-color: #f3f4f6; +} + +body[data-theme='dark'] .storefront-product-videos__thumb { + background-color: #111827; +} + +.storefront-product-videos__thumb img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.storefront-product-videos__row.is-invalid .form-input { + border-color: #f59e0b; +} + +.storefront-product-videos__error { + margin: -0.25rem 0 0 4.5rem; + color: #b45309; + font-size: 0.75rem; +} + +body[data-theme='dark'] .storefront-product-videos__error { + color: #fcd34d; +} + +/* Product form: variants and add-on groups share one group anatomy */ +.storefront-option-groups { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.storefront-option-group { + overflow: hidden; + border: 1px solid #e5e7eb; + border-radius: 0.375rem; +} + +body[data-theme='dark'] .storefront-option-group { + border-color: #374151; +} + +.storefront-option-group__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.5rem 0.75rem; + background-color: #f9fafb; +} + +body[data-theme='dark'] .storefront-option-group__head { + background-color: var(--sf-raised); +} + +.storefront-option-group__title { + display: flex; + flex-direction: column; + gap: 0.125rem; + min-width: 0; + font-size: 0.875rem; +} + +.storefront-option-group__meta { + color: #6b7280; + font-size: 0.75rem; +} + +body[data-theme='dark'] .storefront-option-group__meta { + color: #9ca3af; +} + +.storefront-option-group__actions { + display: flex; + flex: 0 0 auto; + gap: 0.25rem; +} + +.storefront-option-group__table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; +} + +.storefront-option-group__table th { + padding: 0.375rem 0.75rem; + color: #6b7280; + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.04em; + text-align: left; + text-transform: uppercase; +} + +body[data-theme='dark'] .storefront-option-group__table th { + color: #9ca3af; +} + +.storefront-option-group__table td { + padding: 0.25rem 0.75rem; + border-top: 1px solid #f3f4f6; + vertical-align: middle; +} + +body[data-theme='dark'] .storefront-option-group__table td { + border-color: var(--sf-hairline); +} + +.storefront-option-group__cost { + width: 9rem; +} + +.storefront-option-group__remove { + width: 2.75rem; + text-align: right; +} + +.storefront-option-group__empty { + padding: 0.75rem; + color: #6b7280; + font-size: 0.8125rem; +} + +body[data-theme='dark'] .storefront-option-group__empty { + color: #9ca3af; +} + +.storefront-option-group__add { + margin: 0.25rem 0.75rem 0.625rem; +} + +.storefront-option-group__addon { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto auto; + gap: 0.75rem; + align-items: center; + padding: 0.5rem 0.75rem; + border-top: 1px solid #f3f4f6; + cursor: pointer; +} + +body[data-theme='dark'] .storefront-option-group__addon { + border-color: var(--sf-hairline); +} + +.storefront-option-group__addon.is-hidden .storefront-option-group__addon-text, +.storefront-option-group__addon.is-hidden .storefront-option-group__addon-price { + opacity: 0.55; +} + +.storefront-option-group__addon-text { + display: flex; + flex-direction: column; + min-width: 0; +} + +.storefront-option-group__addon-name { + overflow: hidden; + font-size: 0.8125rem; + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; +} + +.storefront-option-group__addon-description { + overflow: hidden; + color: #6b7280; + font-size: 0.75rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +body[data-theme='dark'] .storefront-option-group__addon-description { + color: #9ca3af; +} + +.storefront-option-group__addon-price { + font-size: 0.8125rem; + font-variant-numeric: tabular-nums; +} + +/* Product form: translations as language chips over the fields of the chosen language */ +.storefront-product-translations { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.storefront-product-translations__languages { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.375rem; +} + +.storefront-product-translations__language { + display: inline-flex; + align-items: center; + overflow: hidden; + border: 1px solid #e5e7eb; + border-radius: 9999px; + font-size: 0.8125rem; +} + +body[data-theme='dark'] .storefront-product-translations__language { + border-color: #374151; +} + +.storefront-product-translations__language > button:first-child { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.1875rem 0.375rem 0.1875rem 0.75rem; +} + +.storefront-product-translations__language.is-active { + border-color: #2563eb; + color: #1d4ed8; + background-color: #eff6ff; +} + +body[data-theme='dark'] .storefront-product-translations__language.is-active { + border-color: #3b82f6; + color: #bfdbfe; + background-color: rgb(30 64 175 / 35%); +} + +.storefront-product-translations__code { + color: #9ca3af; + font-size: 0.6875rem; + text-transform: uppercase; +} + +.storefront-product-translations__remove { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.5rem; + height: 1.5rem; + margin-right: 0.125rem; + border-radius: 9999px; + opacity: 0.6; +} + +.storefront-product-translations__remove:hover { + opacity: 1; + background-color: rgb(0 0 0 / 8%); +} + +.storefront-product-translations__add .form-select { + min-width: 11rem; + height: 1.875rem; + padding-top: 0; + padding-bottom: 0; + font-size: 0.8125rem; +} + +.storefront-product-translations__fields { + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 0.75rem; + border: 1px solid #e5e7eb; + border-radius: 0.375rem; +} + +body[data-theme='dark'] .storefront-product-translations__fields { + border-color: #374151; +} + +.storefront-product-translations__custom { + display: grid; + grid-template-columns: 10rem minmax(0, 1fr) auto; + gap: 0.5rem; + align-items: center; +} + +.storefront-product-translations__custom--new { + grid-template-columns: minmax(0, 1fr) auto; +} + +.storefront-product-translations__key { + overflow: hidden; + color: #6b7280; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.75rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +body[data-theme='dark'] .storefront-product-translations__key { + color: #9ca3af; +} diff --git a/addon/templates/catalogs/index/edit.hbs b/addon/templates/catalogs/index/edit.hbs index 53d2928d..0dbf7976 100644 --- a/addon/templates/catalogs/index/edit.hbs +++ b/addon/templates/catalogs/index/edit.hbs @@ -160,7 +160,7 @@

{{t "storefront.catalogs.editor.hours-lede"}}

- +
{{else if (eq this.activeSection "served-by")}}
diff --git a/addon/templates/settings/locations.hbs b/addon/templates/settings/locations.hbs index 3e7b5114..b731912f 100644 --- a/addon/templates/settings/locations.hbs +++ b/addon/templates/settings/locations.hbs @@ -20,7 +20,7 @@

{{t "storefront.settings.locations.store-hour"}}

- + {{/each}}
diff --git a/translations/en-us.yaml b/translations/en-us.yaml index 14b65794..2938ed29 100644 --- a/translations/en-us.yaml +++ b/translations/en-us.yaml @@ -343,7 +343,8 @@ storefront: no-categories: No categories for this network create-category: Create a new category schedule-manager: - add-hours: Add Hours + add-hours: Add hours + no-hours: No hours add-new-hours-for-day: Add new hours for {day} new-hours-added-for-day: New hours added for {day} you-wish-to-remove-these-hour: Are you sure you wish to remove these hours? @@ -1323,13 +1324,16 @@ storefront: media: Images and videos variants: Variants addons: Add-ons + translations: Translations fields: name: Name + name-placeholder: What the product is called in the app, for example Margherita pizza description: Description description-placeholder: What the customer reads in the app category: Category category-placeholder: Select a category sku: SKU + sku-placeholder: For example PIZ-MARG-12 sku-help: Optional; shown on order lines and exports. tags: Tags tags-placeholder: Add tags @@ -1337,7 +1341,7 @@ storefront: price-help: What the customer pays for one unit. sale-price: Sale price sale-price-help: Charged instead of the price while the product is on sale. - video-placeholder: Enter a YouTube video URL + video-placeholder: https://www.youtube.com/watch?v=... video-urls: YouTube video URLs video-urls-help: Videos shown on the product in the app. no-options: No options in this variant. @@ -1349,7 +1353,8 @@ storefront: recommended: Recommended, pinned at the top of its category available: Available to order status-help: Drafts stay hidden from the app until they are published. - hours-hint: When the product can be ordered; empty means whenever the store is open. + hours-hint: When the product can be ordered. + hours-empty: No hours set, so the product can be ordered whenever the store is open. variants: hint: Required or optional choices such as size, each option adding to the price. new-option: "New {name} option" @@ -1359,13 +1364,38 @@ storefront: none: No variants. Add one for choices like size or flavour. option-name: Option name option-description: Option description + option: Option + extra-cost: Extra cost + optional: Optional + pick-one: Pick one + options-count: "{count, plural, =0 {no options} one {# option} other {# options}}" + edit: Edit variant + remove: Remove variant + videos: + add: Add video + invalid: Not a YouTube link; paste a watch, share or shorts link. addons: hint: Reusable add-on groups; untick an add-on to hide it for this product. no-description: No description group-empty: No add-ons in this group yet. none: No add-on groups attached. + offered: "{offered} of {total} offered" + hidden: Hidden + remove-group: Remove group more: More more-subtitle: "translations ({translations}), metadata ({metadata} keys)" + metadata: Metadata + metadata-subtitle: "{count, plural, =0 {no keys} one {# key} other {# keys}} for integrations" + translations: + hint: The name and description in other languages; the app shows the customer's language when it exists. + add-language: Add language + remove-language: "Remove {language}" + placeholder: Same as the original + custom-fields: Custom fields + new-field: New field key, for example ingredients + add-field: Add field + help: An empty field shows the original text in the app. + none: Not translated yet. Add a language to translate the name and description. needs-attention: "{count, plural, one {# field needs attention} other {# fields need attention}}" create: Create product creating: Creating the product... From 950e6ceabdcb208948e440676873ffd29f7b32b3 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 12 Oct 2026 16:57:01 +0800 Subject: [PATCH 176/193] fix(cards, catalogs): a long card badge ellipsizes before the buttons move; card buttons share one height; the catalog editor's actions header no longer overflows --- addon/styles/storefront-engine.css | 19 ++++++++++++++++++- addon/templates/catalogs/index/edit.hbs | 2 +- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/addon/styles/storefront-engine.css b/addon/styles/storefront-engine.css index 4271ddf8..9611e043 100644 --- a/addon/styles/storefront-engine.css +++ b/addon/styles/storefront-engine.css @@ -2915,10 +2915,27 @@ body[data-theme='dark'] .storefront-card__chip-count { .storefront-card__buttons { display: flex; + flex: 0 0 auto; align-items: center; gap: 0.25rem; } +/* A long status badge gives way (ellipsis) before the buttons do */ +.storefront-card__actions > .status-badge { + flex: 0 1 auto; + min-width: 0; + overflow: hidden; +} + +/* Side-by-side card buttons share one 26px height, the menu included */ +.storefront-card__buttons .btn, +.storefront-card__buttons .btn-wrapper > .btn, +.storefront-card__buttons .ember-basic-dropdown-trigger .btn-wrapper > .btn { + height: 1.625rem; + min-height: 0; + padding: 0 0.5rem; +} + .storefront-card__count { color: #4b5563; font-size: 0.75rem; @@ -7893,7 +7910,7 @@ body[data-theme='dark'] .storefront-catalog-product-table td { /* Card footers: a long status badge truncates instead of wrapping to a second line */ .storefront-card__actions .status-badge-inner-wrap { display: inline-block; - max-width: 13rem; + max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; diff --git a/addon/templates/catalogs/index/edit.hbs b/addon/templates/catalogs/index/edit.hbs index 0dbf7976..4455b542 100644 --- a/addon/templates/catalogs/index/edit.hbs +++ b/addon/templates/catalogs/index/edit.hbs @@ -108,7 +108,7 @@ {{t "storefront.catalogs.editor.columns.price"}} {{t "storefront.catalogs.editor.columns.availability"}} {{t "storefront.catalogs.editor.columns.stock"}} - {{t "storefront.catalogs.editor.columns.actions"}} + {{t "storefront.catalogs.editor.columns.actions"}} From 9c59e67f075c35becf12d7266095fd532aed75f7 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 12 Oct 2026 17:18:47 +0800 Subject: [PATCH 177/193] test: schedule manager rows, the order store profile and the customers widget identity cell --- .../components/schedule-manager-test.js | 16 +++++++++------- .../components/storefront/order/details-test.js | 4 ++-- .../components/widget/customers-test.js | 2 +- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/tests/integration/components/schedule-manager-test.js b/tests/integration/components/schedule-manager-test.js index 9563cc15..71ac2222 100644 --- a/tests/integration/components/schedule-manager-test.js +++ b/tests/integration/components/schedule-manager-test.js @@ -17,16 +17,18 @@ module('Integration | Component | schedule-manager', function (hooks) { await render(hbs``); - const panels = this.element.querySelectorAll('.next-content-panel'); - assert.strictEqual(panels.length, 7, 'one panel per weekday'); + const days = this.element.querySelectorAll('[data-test-schedule-day]'); + assert.strictEqual(days.length, 7, 'one row per weekday'); const byDay = {}; - panels.forEach((panel) => { - byDay[panel.querySelector('h4').textContent.trim()] = panel.querySelector('.next-content-panel-body').textContent; + days.forEach((day) => { + byDay[day.getAttribute('data-test-schedule-day')] = [...day.querySelectorAll('.storefront-schedule__slot')].map((slot) => slot.textContent.trim()); }); - assert.ok(byDay.Monday.includes('08:00 AM - 09:00 PM'), 'lowercase monday lands under Monday'); - assert.ok(byDay.Tuesday.includes('10:00 AM - 02:00 PM'), 'capitalised Tuesday lands under Tuesday'); - assert.notOk(byDay.Wednesday.trim(), 'days without hours stay empty'); + assert.deepEqual(byDay.Monday, ['08:00 AM - 09:00 PM'], 'lowercase monday lands under Monday'); + assert.deepEqual(byDay.Tuesday, ['10:00 AM - 02:00 PM'], 'capitalised Tuesday lands under Tuesday'); + assert.deepEqual(byDay.Wednesday, [], 'days without hours have no slots'); + assert.dom('[data-test-schedule-day="Monday"] .storefront-schedule__name').hasText('Monday', 'the day is named in the console language'); + assert.dom('[data-test-schedule-add="Monday"]').exists('each day offers Add hours'); }); }); diff --git a/tests/integration/components/storefront/order/details-test.js b/tests/integration/components/storefront/order/details-test.js index e9a63de0..154d5806 100644 --- a/tests/integration/components/storefront/order/details-test.js +++ b/tests/integration/components/storefront/order/details-test.js @@ -99,8 +99,8 @@ module('Integration | Component | storefront/order/details', function (hooks) { `a panel titled ${title} renders` ); } - // The store renders as the shared store pill, named from the order's meta until the record loads. - assert.dom('[data-test-order-store-pill]').hasTextContaining('Tasty Store'); + // The store renders as a store profile, named from the order's meta until the record loads. + assert.dom('[data-test-order-store-name]').hasText('Tasty Store'); assert.dom('[data-test-order-driver-pill] [data-test-resource-pill-title]').hasText('No driver assigned', 'an unassigned driver is a static pill with the fallback title'); assert.dom('[data-test-order-customer-pill] [data-test-resource-pill-title]').hasText('No customer'); assert.dom('.storefront-order-item__image').exists(); diff --git a/tests/integration/components/widget/customers-test.js b/tests/integration/components/widget/customers-test.js index e58c29d3..8e697b6d 100644 --- a/tests/integration/components/widget/customers-test.js +++ b/tests/integration/components/widget/customers-test.js @@ -69,7 +69,7 @@ module('Integration | Component | widget/customers', function (hooks) { assert.dom('.storefront-widget-count').hasText('1'); assert.dom('.storefront-customers-table').exists(); assert.dom('.storefront-customer-id').hasText('contact_1'); - assert.dom('.storefront-customers-table [data-test-resource-pill-title]').hasText('Ava Chen', 'the customer renders as its pill'); + assert.dom('.storefront-customers-table [data-test-identity-cell]').includesText('Ava Chen', 'the customer renders as the identity cell the orders page uses'); assert.dom('.storefront-customers-table').includesText('ava.chen@example.test'); }); }); From a062d03eb02431e640e33191dfcc671531f9b0be Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 12 Oct 2026 17:56:02 +0800 Subject: [PATCH 178/193] fix(store): order ids line up with their row; identity strip columns rebalanced; the products grid fits below its toolbar with room to scroll the last row clear; card footers sit on the card's bottom edge; card buttons share one line --- addon/controllers/orders/index.js | 2 +- addon/styles/storefront-engine.css | 42 +++++++++++++++++++++++++++--- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/addon/controllers/orders/index.js b/addon/controllers/orders/index.js index 3472576d..1f62ad0f 100644 --- a/addon/controllers/orders/index.js +++ b/addon/controllers/orders/index.js @@ -128,7 +128,7 @@ export default class OrdersIndexController extends BaseController { valuePath: 'public_id', width: '135px', cellComponent: 'click-to-copy', - cellClassNames: 'storefront-table-muted', + cellClassNames: 'storefront-table-cell-muted', resizable: true, sortable: true, filterable: true, diff --git a/addon/styles/storefront-engine.css b/addon/styles/storefront-engine.css index 9611e043..af76626b 100644 --- a/addon/styles/storefront-engine.css +++ b/addon/styles/storefront-engine.css @@ -45,12 +45,20 @@ body[data-theme='dark'] .storefront-products-page { } .storefront-products-viewport { + display: flex; + flex-direction: column; min-width: 0; min-height: 0; height: 100%; overflow: hidden; } +/* The toolbar keeps its height; the list takes what is left of the viewport, so its last row + scrolls into view instead of running under the window's edge */ +.storefront-products-viewport > .storefront-products-toolbar { + flex: 0 0 auto; +} + .storefront-product-sidebar { display: flex; min-height: 0; @@ -230,8 +238,8 @@ body[data-theme='dark'] .storefront-product-addon-empty { .storefront-product-workspace { display: flex; + flex: 1 1 auto; min-height: 0; - height: 100%; flex-direction: column; overflow: hidden; } @@ -283,7 +291,7 @@ body[data-theme='dark'] .storefront-product-addon-empty { align-content: start; gap: 0.85rem; overflow: auto; - padding: 0.75rem; + padding: 0.75rem 0.75rem 5.5rem; } .storefront-product-collection.is-empty { @@ -1615,6 +1623,15 @@ body[data-theme='dark'] .storefront-table-muted { color: #9ca3af; } +/* A muted table cell: colour only, so the cell keeps its table layout and vertical centring */ +.storefront-table-cell-muted { + color: #6b7280; +} + +body[data-theme='dark'] .storefront-table-cell-muted { + color: #9ca3af; +} + .storefront-table-actions { display: flex; align-items: center; @@ -2792,6 +2809,18 @@ body[data-theme='dark'] .storefront-order-empty-state { display: flex; flex-direction: column; min-width: 0; + height: 100%; +} + +/* Cards in a row stretch to the tallest; the body takes the slack so every footer sits on the + card's bottom edge */ +.storefront-card > .resource-card-body { + flex: 1 1 auto; +} + +.storefront-card > .resource-card-footer { + flex: 0 0 auto; + margin-top: auto; } .storefront-card__title { @@ -2920,6 +2949,13 @@ body[data-theme='dark'] .storefront-card__chip-count { gap: 0.25rem; } +/* Each button, wrapper or dropdown trigger centres on the same line */ +.storefront-card__buttons > *, +.storefront-card__buttons .ember-basic-dropdown-trigger { + display: inline-flex; + align-items: center; +} + /* A long status badge gives way (ellipsis) before the buttons do */ .storefront-card__actions > .status-badge { flex: 0 1 auto; @@ -8574,7 +8610,7 @@ body[data-theme='dark'] .storefront-empty-row--action:hover:not(:disabled) { /* The customer's name and phone get the widest cell */ .storefront-order-panel-heading .storefront-identity-strip { - grid-template-columns: minmax(0, 1.5fr) minmax(0, 1.1fr) minmax(0, 1.1fr) minmax(0, 0.8fr); + grid-template-columns: minmax(0, 1.2fr) minmax(0, 1.2fr) minmax(0, 1.2fr) minmax(0, 0.8fr); } /* Header actions: one 28px height for every button, the ellipsis padded like a button, tight gaps */ From a94605cff3dc80ac5dc274028983dfe7623fbf70 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 12 Oct 2026 18:35:56 +0800 Subject: [PATCH 179/193] fix(network orders): the store cell is one line; a multi-store checkout shows round store logos with the summary on hover (the network itself included); the order and actions columns stay put while scrolling; a reference shows its item count instead of the checkout id --- .../network/orders/cell/reference.hbs | 8 +-- .../storefront/network/orders/cell/stores.hbs | 22 ++++++-- .../storefront/network/orders/cell/stores.js | 17 ++++-- .../networks/index/network/orders.js | 7 ++- .../storefront/network/orders/cells-test.js | 53 +++++++++++++++++++ 5 files changed, 92 insertions(+), 15 deletions(-) create mode 100644 tests/integration/components/storefront/network/orders/cells-test.js diff --git a/addon/components/storefront/network/orders/cell/reference.hbs b/addon/components/storefront/network/orders/cell/reference.hbs index 39a98cf4..e2cf4adf 100644 --- a/addon/components/storefront/network/orders/cell/reference.hbs +++ b/addon/components/storefront/network/orders/cell/reference.hbs @@ -12,13 +12,7 @@ {{@row.public_id}} - {{#if @row.isChild}} - {{t "storefront.networks.orders.items-count" count=(or @row.payload.entities_count @row.item_count 0)}} - {{else if @row.meta.checkout_id}} - {{t "storefront.networks.orders.part-of" checkout=@row.meta.checkout_id}} - {{else}} - {{t "storefront.networks.orders.items-count" count=(or @row.payload.entities_count @row.item_count 0)}} - {{/if}} + {{t "storefront.networks.orders.items-count" count=(or @row.payload.entities_count @row.item_count 0)}} {{/if}} diff --git a/addon/components/storefront/network/orders/cell/stores.hbs b/addon/components/storefront/network/orders/cell/stores.hbs index 862f6633..8c86f6fd 100644 --- a/addon/components/storefront/network/orders/cell/stores.hbs +++ b/addon/components/storefront/network/orders/cell/stores.hbs @@ -1,12 +1,26 @@ -
+
{{#each this.visible as |entry|}} {{#if entry.store}} {{#if this.isStack}} - {{! A checkout group: one tile per store, names on hover, so the row stays one line. }} - {{entry.name}} + {{! A checkout across stores: one round logo per store, the name and summary on hover. }} + + {{entry.name}} + {{else}} - + {{/if}} + {{else if this.isStack}} + {{entry.initial}} {{else}} {{entry.name}} {{/if}} diff --git a/addon/components/storefront/network/orders/cell/stores.js b/addon/components/storefront/network/orders/cell/stores.js index d421269e..3340d3d8 100644 --- a/addon/components/storefront/network/orders/cell/stores.js +++ b/addon/components/storefront/network/orders/cell/stores.js @@ -4,7 +4,7 @@ import { orderStoreId, orderStoreName } from '../../../../../utils/order-groups' const MAX_VISIBLE = 4; /** - * The stores behind a row: one for an order, up to four tiles plus an overflow count for a checkout group. + * The stores behind a row: one pill for an order, up to four round logos plus an overflow count for a checkout group. */ export default class StorefrontNetworkOrdersCellStoresComponent extends Component { get entries() { @@ -13,10 +13,21 @@ export default class StorefrontNetworkOrdersCellStoresComponent extends Componen const ids = row?.isGroup ? (row.storeIds ?? []) : [orderStoreId(row)].filter(Boolean); const names = row?.isGroup ? (row.storeNames ?? []) : [orderStoreName(row)].filter(Boolean); - return ids.map((id, index) => ({ id, store: storesById[id] ?? null, name: storesById[id]?.name ?? names[index] ?? names[0] ?? id })); + return ids.map((id, index) => { + const name = storesById[id]?.name ?? names[index] ?? names[0] ?? id; + + return { + id, + store: storesById[id] ?? null, + name, + initial: String(name ?? '?') + .charAt(0) + .toUpperCase(), + }; + }); } - /** More than one store: tiles only, so a group row keeps a single line. */ + /** More than one store: round logos only, so the row keeps a single line. */ get isStack() { return this.entries.length > 1; } diff --git a/addon/controllers/networks/index/network/orders.js b/addon/controllers/networks/index/network/orders.js index ac01d2bf..9c56f173 100644 --- a/addon/controllers/networks/index/network/orders.js +++ b/addon/controllers/networks/index/network/orders.js @@ -64,12 +64,15 @@ export default class NetworksIndexNetworkOrdersController extends BaseController })); } + /** The network's stores by id, plus the network itself for orders placed against it directly. */ get storesById() { + const initial = this.network ? { [this.network.public_id]: this.network, [this.network.id]: this.network } : {}; + return this.stores.reduce((map, store) => { map[store.public_id] = store; map[store.id] = store; return map; - }, {}); + }, initial); } /** @@ -170,6 +173,7 @@ export default class NetworksIndexNetworkOrdersController extends BaseController width: '175px', resizable: true, sortable: false, + sticky: true, }, { id: 'customer', @@ -238,6 +242,7 @@ export default class NetworksIndexNetworkOrdersController extends BaseController width: '160px', sortable: false, filterable: false, + sticky: 'right', resizable: false, searchable: false, }, diff --git a/tests/integration/components/storefront/network/orders/cells-test.js b/tests/integration/components/storefront/network/orders/cells-test.js new file mode 100644 index 00000000..836e3b87 --- /dev/null +++ b/tests/integration/components/storefront/network/orders/cells-test.js @@ -0,0 +1,53 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { setupIntl } from 'ember-intl/test-support'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import { initialize } from '@fleetbase/storefront-engine/instance-initializers/register-storefront-resource-descriptors'; + +module('Integration | Component | storefront/network/orders cells', function (hooks) { + setupRenderingTest(hooks); + setupIntl(hooks, 'en-us'); + + hooks.beforeEach(function () { + initialize(this.owner); + this.set('storesById', { + store_a: { public_id: 'store_a', name: 'Rochor Noodle House', currency: 'SGD', logo_url: 'https://cdn.test/a.png' }, + store_b: { public_id: 'store_b', name: 'Orchard Grocers', currency: 'SGD', logo_url: 'https://cdn.test/b.png' }, + }); + }); + + test('one store reads as a single-line pill, without the currency underneath', async function (assert) { + this.set('row', { meta: { storefront_id: 'store_a', storefront: 'Rochor Noodle House' } }); + + await render(hbs``); + + assert.dom('[data-test-resource-pill-title]').hasText('Rochor Noodle House'); + assert.dom('[data-test-resource-pill-subtitle]').doesNotExist(); + assert.dom('[data-test-order-store-avatar]').doesNotExist(); + }); + + test('a checkout across stores shows one round logo per store, named for screen readers', async function (assert) { + this.set('row', { isGroup: true, storeIds: ['store_a', 'store_b', 'store_x'], storeNames: ['Rochor Noodle House', 'Orchard Grocers', 'Unknown Mart'] }); + + await render(hbs``); + + assert.dom('[data-test-order-stores]').hasClass('storefront-identity-stack--avatars'); + assert.dom('[data-test-order-store-avatar]').exists({ count: 2 }); + assert.dom('[data-test-order-store-avatar]').hasAttribute('aria-label', 'Rochor Noodle House'); + assert.dom('[data-test-pill-online-indicator]').doesNotExist(); + assert.dom('.storefront-identity-stack__initial').hasText('U', 'a store that is not loaded falls back to its initial'); + assert.dom('.storefront-identity-stack__initial').hasAttribute('title', 'Unknown Mart'); + }); + + test('an order reference shows its item count, not the checkout it belongs to', async function (assert) { + this.set('row', { public_id: 'order_1', item_count: 2, meta: { checkout_id: 'chkt_123' } }); + this.set('column', { onView() {}, onToggle() {} }); + + await render(hbs``); + + assert.dom('[data-test-order-reference]').includesText('order_1'); + assert.dom('[data-test-order-reference]').doesNotIncludeText('chkt_123'); + assert.dom('.storefront-order-ref__meta').hasText('2 items'); + }); +}); From 3160a75ff6333c8ee23080f914293a6e5b07281e Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 12 Oct 2026 18:35:56 +0800 Subject: [PATCH 180/193] feat(network): a store details page with its week, orders and customers, reached from the stores table, the overview cards and a customer's stores; overview cards four across with the store backdrop and logo; switching to a network lands on its overview without loading the networks list, which loads when shown; the overview skeleton also covers returning to the overview; new order store cards match the promotion choice cards; recent order rows show their hover; the category caret sits at the right --- addon/components/customer-panel/overview.hbs | 19 +- addon/components/customer-panel/overview.js | 18 +- .../storefront/network/overview-skeleton.hbs | 23 +++ .../storefront/network/overview-skeleton.js | 3 + addon/controllers/application.js | 10 +- addon/controllers/networks/index.js | 8 + .../networks/index/network/index.js | 4 + .../networks/index/network/store.js | 133 +++++++++++++ .../networks/index/network/stores.js | 28 +-- addon/routes.js | 1 + addon/routes/networks/index.js | 33 ++- addon/routes/networks/index/network/store.js | 31 +++ addon/styles/storefront-engine.css | 188 ++++++++++++++++-- addon/templates/networks/index.hbs | 8 +- addon/templates/networks/index/loading.hbs | 25 +-- .../networks/index/network/index-loading.hbs | 2 + .../networks/index/network/index.hbs | 11 +- .../networks/index/network/store.hbs | 126 ++++++++++++ addon/templates/networks/loading.hbs | 23 +-- .../storefront/network/overview-skeleton.js | 1 + .../Http/Controllers/NetworkController.php | 1 + tests/unit/controllers/application-test.js | 10 +- tests/unit/routes/networks/index-test.js | 33 ++- translations/en-us.yaml | 9 +- 24 files changed, 644 insertions(+), 104 deletions(-) create mode 100644 addon/components/storefront/network/overview-skeleton.hbs create mode 100644 addon/components/storefront/network/overview-skeleton.js create mode 100644 addon/controllers/networks/index/network/store.js create mode 100644 addon/routes/networks/index/network/store.js create mode 100644 addon/templates/networks/index/network/index-loading.hbs create mode 100644 addon/templates/networks/index/network/store.hbs create mode 100644 app/components/storefront/network/overview-skeleton.js diff --git a/addon/components/customer-panel/overview.hbs b/addon/components/customer-panel/overview.hbs index cef6e358..1f4cab6a 100644 --- a/addon/components/customer-panel/overview.hbs +++ b/addon/components/customer-panel/overview.hbs @@ -66,11 +66,20 @@ {{#if this.stores.length}}
{{#each this.stores as |store|}} -
- -
{{n-a store.name store.public_id}}
- {{t "storefront.customers.panel.orders-count" count=store.orders}} -
+ {{#if store.canOpen}} + + {{else}} +
+ +
{{n-a store.name store.public_id}}
+ {{t "storefront.customers.panel.orders-count" count=store.orders}} +
+ {{/if}} {{/each}}
{{else}} diff --git a/addon/components/customer-panel/overview.js b/addon/components/customer-panel/overview.js index 1bdecfe3..245e7ea1 100644 --- a/addon/components/customer-panel/overview.js +++ b/addon/components/customer-panel/overview.js @@ -14,6 +14,7 @@ export default class CustomerPanelOverviewComponent extends Component { @service storefrontOrderActions; @service notifications; @service hostRouter; + @service contextPanel; @tracked orders = []; constructor() { @@ -43,8 +44,11 @@ export default class CustomerPanelOverviewComponent extends Component { return this.places.map((place) => ({ place, isDefault: place.id === this.defaultPlaceId })); } + /** Where the customer ordered; in a network, each member store opens its details (an order placed on the network itself does not). */ get stores() { - return this.insights?.stores ?? []; + const canOpen = this.storefront.isNetworkContext; + + return (this.insights?.stores ?? []).map((store) => ({ ...store, canOpen: canOpen && String(store.public_id ?? '').startsWith('store_') })); } /** The segments whose rules this customer matches, from the insights endpoint. */ @@ -60,6 +64,18 @@ export default class CustomerPanelOverviewComponent extends Component { return this.hostRouter.transitionTo('console.storefront.promotions.segments'); } + /** In a network, a store the customer ordered from opens that store's details. */ + @action openStore(store) { + const network = this.storefront.activeNetwork; + + if (!network || !store?.public_id) { + return; + } + + this.contextPanel.clear(); + return this.hostRouter.transitionTo('console.storefront.networks.index.network.store', network.public_id, store.public_id); + } + get showStores() { return this.storefront.isNetworkContext || this.stores.length > 1; } diff --git a/addon/components/storefront/network/overview-skeleton.hbs b/addon/components/storefront/network/overview-skeleton.hbs new file mode 100644 index 00000000..c0138c35 --- /dev/null +++ b/addon/components/storefront/network/overview-skeleton.hbs @@ -0,0 +1,23 @@ +{{! The network overview while it loads: header, five KPI tiles, member cards four to a row, invitations and recent orders. }} + + +
+ + +
+ +
+
+ + +
+ +
+
+
+ + +
+ +
+
diff --git a/addon/components/storefront/network/overview-skeleton.js b/addon/components/storefront/network/overview-skeleton.js new file mode 100644 index 00000000..7eb58e4c --- /dev/null +++ b/addon/components/storefront/network/overview-skeleton.js @@ -0,0 +1,3 @@ +import templateOnlyComponent from '@ember/component/template-only'; + +export default templateOnlyComponent(); diff --git a/addon/controllers/application.js b/addon/controllers/application.js index 93300bfb..f7e7aef7 100644 --- a/addon/controllers/application.js +++ b/addon/controllers/application.js @@ -36,14 +36,14 @@ export default class ApplicationController extends Controller { get networkNavigationItems() { const publicId = this.activeNetwork.public_id; - const networkItem = (key, item) => { + const networkItem = (key, { alsoActiveOn = [], ...item }) => { const route = `console.storefront.networks.index.network.${key}`; return { id: `network:${publicId}:${key}`, ...item, // These routes share one dynamic segment, so route-name matching alone cannot tell networks apart. - activeWhen: () => this.hostRouter.isActive(route, publicId), + activeWhen: () => [route, ...alsoActiveOn.map((name) => `console.storefront.networks.index.network.${name}`)].some((name) => this.hostRouter.isActive(name, publicId)), onClick: () => this.hostRouter.transitionTo(route, publicId), }; }; @@ -63,6 +63,8 @@ export default class ApplicationController extends Controller { badge: this.networkPendingCount || null, permission: 'storefront view network', keywords: ['stores', 'members', 'invite', 'categories'], + // A store's details page sits under Stores. + alsoActiveOn: ['store'], }), networkItem('trucks', { label: this.intl.t('storefront.common.food-trucks'), @@ -282,8 +284,8 @@ export default class ApplicationController extends Controller { } @action switchActiveNetwork(network) { - // Entering the network route is what sets the network context. - return this.hostRouter.transitionTo('console.storefront.networks.index.network.stores', network.public_id); + // Entering the network route is what sets the network context; a switch lands on the overview. + return this.hostRouter.transitionTo('console.storefront.networks.index.network.index', network.public_id); } @action diff --git a/addon/controllers/networks/index.js b/addon/controllers/networks/index.js index c183e459..ce67a68d 100644 --- a/addon/controllers/networks/index.js +++ b/addon/controllers/networks/index.js @@ -7,6 +7,14 @@ import { isBlank } from '@ember/utils'; import { timeout, task } from 'ember-concurrency'; export default class NetworksIndexController extends BaseController { + /** + * Whether the networks list itself is on screen. A network page (and its loading state) only + * renders the outlet, so the list never flashes in while a network loads. + */ + get isListRoute() { + return this.hostRouter.currentRouteName === 'console.storefront.networks.index.index'; + } + /** * Inject the `NetworksIndexNetworkStoresController` controller * diff --git a/addon/controllers/networks/index/network/index.js b/addon/controllers/networks/index/network/index.js index 630b9557..ac76f72b 100644 --- a/addon/controllers/networks/index/network/index.js +++ b/addon/controllers/networks/index/network/index.js @@ -55,6 +55,10 @@ export default class NetworksIndexNetworkIndexController extends Controller { return this.hostRouter.transitionTo('console.storefront.networks.index.network.stores', this.network.public_id, { queryParams: { storeQuery: member.name } }); } + @action viewStoreDetails(member) { + return this.hostRouter.transitionTo('console.storefront.networks.index.network.store', this.network.public_id, member.public_id); + } + @action openMemberStore(store) { const application = getOwner(this).lookup('controller:application'); diff --git a/addon/controllers/networks/index/network/store.js b/addon/controllers/networks/index/network/store.js new file mode 100644 index 00000000..459eaad0 --- /dev/null +++ b/addon/controllers/networks/index/network/store.js @@ -0,0 +1,133 @@ +import Controller from '@ember/controller'; +import { inject as service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import { task } from 'ember-concurrency'; +import { getOwner } from '@ember/application'; + +/** + * A member store seen from its network: who it is, how it is doing in the network this week, + * and its orders and customers through the network. + */ +export default class NetworksIndexNetworkStoreController extends Controller { + @service fetch; + @service store; + @service intl; + @service hostRouter; + @service storefront; + @service storefrontOrderActions; + @service notifications; + @service contextPanel; + + @tracked tab = 'orders'; + @tracked member = null; + @tracked orders = []; + @tracked customers = []; + + get network() { + return this.storefront.activeNetwork; + } + + get tabs() { + return [ + { id: 'orders', label: this.intl.t('storefront.common.orders'), count: this.orders.meta?.total ?? this.orders.length, isActive: this.tab === 'orders' }, + { id: 'customers', label: this.intl.t('storefront.common.customers'), count: this.customers.meta?.total ?? this.customers.length, isActive: this.tab === 'customers' }, + ]; + } + + /** Category in the network · currency · id. */ + get subtitle() { + // The category comes with the membership, so it waits for the load rather than reading "No category". + const category = this.member ? (this.member.category ?? this.intl.t('storefront.networks.overview.no-category')) : null; + + return [category, this.model?.currency, this.model?.public_id].filter(Boolean).join(' · '); + } + + get currency() { + return this.model?.currency ?? this.network?.currency; + } + + /** The membership as the network sees it: category, status and this week's numbers. */ + get badge() { + const status = this.member?.membership_status ?? 'active'; + + if (status === 'suspended') { + return { status: 'danger', label: this.intl.t('storefront.networks.index.network.stores.suspended') }; + } + + if (status === 'pending') { + return { status: 'warning', label: this.intl.t('storefront.networks.index.network.stores.awaiting-approval') }; + } + + return this.model?.online ? { status: 'online', label: this.intl.t('storefront.common.online') } : { status: 'offline', label: this.intl.t('storefront.common.offline') }; + } + + get application() { + return getOwner(this).lookup('controller:application'); + } + + /** This organisation's own record of the store, when it runs it, so the console can switch into it. */ + get ownStore() { + const stores = this.application?.model?.stores; + const list = stores?.toArray?.() ?? Array.from(stores ?? []); + + return list.find((record) => record.public_id === this.model?.public_id) ?? null; + } + + @task({ restartable: true }) *load(storeRecord) { + const network = this.network; + this.member = null; + this.orders = []; + this.customers = []; + + if (!network || !storeRecord) { + return; + } + + const scope = { storefront: storeRecord.public_id, network: network.public_id }; + + try { + const [overview, ordersResponse, customers] = yield Promise.all([ + this.fetch.get(`networks/${network.id}/overview`, {}, { namespace: 'storefront/int/v1' }), + this.fetch.get('orders', { ...scope, limit: 25, sort: '-created_at' }, { namespace: 'storefront/int/v1' }), + this.fetch.get('customers', { ...scope, limit: 25, sort: '-created_at' }, { namespace: 'storefront/int/v1' }), + ]); + + this.member = (overview?.members ?? []).find((member) => member.public_id === storeRecord.public_id) ?? null; + + const orders = this.fetch.normalizeModel(ordersResponse, 'orders'); + orders.meta = ordersResponse?.meta; + this.orders = orders; + // The customer model has no order count, so each row keeps the count beside its record. + const rows = (customers?.customers ?? []).map((customer) => ({ record: this.store.push(this.store.normalize('customer', customer)), orders: customer.orders ?? 0 })); + rows.meta = customers?.meta ?? {}; + this.customers = rows; + } catch (error) { + this.notifications.serverError(error); + } + } + + @action selectTab(id) { + this.tab = id; + } + + @action viewOrder(order) { + return this.storefrontOrderActions.viewOrder(order, { onChange: () => this.load.perform(this.model) }); + } + + /** Opens the customer's panel here, as the network customers page does. */ + @action viewCustomer(customer) { + this.contextPanel.focus(customer, 'viewing'); + } + + @action backToStores() { + return this.hostRouter.transitionTo('console.storefront.networks.index.network.stores', this.network.public_id); + } + + /** Switches the console into this store's own context. */ + @action manageStore() { + if (this.ownStore) { + return this.application?.switchActiveStore?.(this.ownStore); + } + } +} diff --git a/addon/controllers/networks/index/network/stores.js b/addon/controllers/networks/index/network/stores.js index 20d21d11..b700f453 100644 --- a/addon/controllers/networks/index/network/stores.js +++ b/addon/controllers/networks/index/network/stores.js @@ -228,6 +228,15 @@ export default class NetworksIndexNetworkStoresController extends BaseController * * @var {Array} */ + /** A member's name opens its details inside the network; invitations have no store yet. */ + @action viewStoreDetails(row) { + if (!row || row.isInvitation || !row.public_id) { + return; + } + + return this.hostRouter.transitionTo('console.storefront.networks.index.network.store', this.network.public_id, row.public_id); + } + get columns() { return [ { @@ -238,6 +247,7 @@ export default class NetworksIndexNetworkStoresController extends BaseController resourceType: 'store', resourcePath: (row) => (row.isInvitation ? buildIdentityStub(row, { type: 'store', nameKey: 'email', icon: 'at' }) : row), popover: true, + action: this.viewStoreDetails, width: '160px', resizable: true, sortable: true, @@ -792,24 +802,6 @@ export default class NetworksIndexNetworkStoresController extends BaseController }); } - /** - * Displays a modal to view the details of a specified store. - * The modal includes the store's name and a "Done" button. - * - * @action - * @param {StoreModel} store - The store object whose details are to be viewed. - * @param {Object} [options={}] - Additional options for the modal. - */ - @action viewStoreDetails(store, options = {}) { - this.modalsManager.show('modals/store-details', { - title: this.intl.t('storefront.networks.index.network.stores.viewing-storefront', { storeName: store.name }), - acceptButtonText: this.intl.t('storefront.networks.index.network.stores.done'), - hideDeclineButton: true, - store, - ...options, - }); - } - /** * Displays a modal to edit a specified store's details. * Allows the user to make changes and confirm to save them. diff --git a/addon/routes.js b/addon/routes.js index a68ff09e..ba82017c 100644 --- a/addon/routes.js +++ b/addon/routes.js @@ -41,6 +41,7 @@ export default buildRoutes(function () { this.route('index', { path: '/' }, function () { this.route('network', { path: '/:public_id' }, function () { this.route('stores'); + this.route('store', { path: '/stores/:store_id' }); this.route('trucks'); this.route('customers'); this.route('orders', function () { diff --git a/addon/routes/networks/index.js b/addon/routes/networks/index.js index 4ca0a47e..0d292a31 100644 --- a/addon/routes/networks/index.js +++ b/addon/routes/networks/index.js @@ -1,6 +1,8 @@ import StorefrontListRoute from '@fleetbase/storefront-engine/utils/list-route'; import { inject as service } from '@ember/service'; +const LIST_ROUTE = 'console.storefront.networks.index.index'; + export default class NetworksIndexRoute extends StorefrontListRoute { @service store; @service intl; @@ -17,6 +19,16 @@ export default class NetworksIndexRoute extends StorefrontListRoute { updated_at: { refreshModel: true }, }; + constructor() { + super(...arguments); + this.hostRouter.on('routeDidChange', this, this.loadSkippedList); + } + + willDestroy() { + this.hostRouter.off('routeDidChange', this, this.loadSkippedList); + super.willDestroy(...arguments); + } + beforeModel() { if (this.abilities.cannot('storefront list network')) { this.notifications.warning(this.intl.t('common.unauthorized-access')); @@ -24,7 +36,26 @@ export default class NetworksIndexRoute extends StorefrontListRoute { } } - model(params) { + /** + * The networks list is only shown on its own page. Entering a network passes through this + * route too, so it skips the list (every network with its gateways and channels) then, which + * made switching into a network slow. + */ + model(params, transition) { + if (transition?.to?.name && transition.to.name !== LIST_ROUTE) { + this.skippedList = true; + return []; + } + + this.skippedList = false; return this.store.query('network', { with_gateways: 1, with_notification_channels: 1, ...params }); } + + /** Arriving on the list from inside a network: this route stayed active, so load the list now. */ + loadSkippedList(transition) { + if (this.skippedList && transition?.to?.name === LIST_ROUTE) { + this.skippedList = false; + this.refresh(); + } + } } diff --git a/addon/routes/networks/index/network/store.js b/addon/routes/networks/index/network/store.js new file mode 100644 index 00000000..effcb943 --- /dev/null +++ b/addon/routes/networks/index/network/store.js @@ -0,0 +1,31 @@ +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; + +/** + * A member store seen from its network: its numbers, orders and customers inside the network. + */ +export default class NetworksIndexNetworkStoreRoute extends Route { + @service store; + @service intl; + @service abilities; + @service hostRouter; + @service notifications; + + beforeModel() { + if (this.abilities.cannot('storefront view store')) { + this.notifications.warning(this.intl.t('common.unauthorized-access')); + return this.hostRouter.transitionTo('console.storefront'); + } + } + + model({ store_id }) { + return this.store.findRecord('store', store_id); + } + + setupController(controller, model) { + super.setupController(...arguments); + // Another store opens on its orders, not on the tab the last one was left on. + controller.tab = 'orders'; + controller.load.perform(model); + } +} diff --git a/addon/styles/storefront-engine.css b/addon/styles/storefront-engine.css index af76626b..dfeb11d0 100644 --- a/addon/styles/storefront-engine.css +++ b/addon/styles/storefront-engine.css @@ -3395,6 +3395,10 @@ body[data-theme='dark'] .storefront-network-page__meta { gap: 0.75rem; } +.storefront-stat-grid.storefront-stat-grid--4 { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + @media (width <= 1200px) { .storefront-stat-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -3497,6 +3501,28 @@ body[data-theme='dark'] .storefront-member-row--link { background-color: var(--sf-raised); } +/* A row that opens something reads as one: pointer, hover tint and a chevron */ +button.storefront-member-row--link { + width: 100%; + text-align: left; + cursor: pointer; + transition: background-color 120ms ease; +} + +button.storefront-member-row--link:hover { + background-color: #f3f4f6; +} + +body[data-theme='dark'] button.storefront-member-row--link:hover { + background-color: #2f3b4f; +} + +.storefront-member-row__chevron { + flex: 0 0 auto; + margin-left: 0.5rem; + color: #9ca3af; +} + .storefront-member-row__logo { width: 2rem; height: 2rem; @@ -3904,12 +3930,64 @@ body[data-theme='dark'] .storefront-order-driver__muted { gap: 0.25rem; } -.storefront-identity-stack--tiles { +.storefront-identity-stack--avatars { flex-wrap: nowrap; + gap: 0; } -.storefront-identity-stack--tiles .storefront-identity-stack__pill { - min-width: 0; +/* A multi-store row: overlapping round logos, the pill's text kept for screen readers only. */ +.storefront-identity-stack--avatars > * + * { + margin-left: -0.375rem; +} + +.storefront-identity-stack__avatar.fleetbase-pill, +.storefront-identity-stack__avatar { + flex: 0 0 auto; + gap: 0; + padding: 0; + border: 0; + background: transparent; +} + +.storefront-identity-stack__avatar .fleetbase-pill-body { + gap: 0; +} + +.storefront-identity-stack__avatar img { + width: 1.5rem; + height: 1.5rem; + object-fit: cover; + border-radius: 9999px; + box-shadow: 0 0 0 2px #fff; +} + +.storefront-identity-stack__avatar:hover { + position: relative; + z-index: 1; +} + +.storefront-identity-stack__initial { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.5rem; + height: 1.5rem; + border-radius: 9999px; + font-size: 0.6875rem; + font-weight: 600; + background-color: #e5e7eb; + color: #374151; + box-shadow: 0 0 0 2px #fff; +} + +body[data-theme='dark'] .storefront-identity-stack__avatar img, +body[data-theme='dark'] .storefront-identity-stack__initial { + box-shadow: 0 0 0 2px #111827; +} + +body[data-theme='dark'] .storefront-identity-stack__initial { + background-color: #374151; + color: #e5e7eb; } .storefront-identity-stack__text { @@ -3920,6 +3998,7 @@ body[data-theme='dark'] .storefront-order-driver__muted { } .storefront-identity-stack__overflow { + margin-left: 0.25rem; padding: 0 0.375rem; border-radius: 9999px; font-size: 0.6875rem; @@ -6149,13 +6228,14 @@ body[data-theme='dark'] .storefront-create-order__picked { grid-template-columns: repeat(auto-fill, minmax(min(14rem, 100%), 1fr)); } +/* Store, location and address cards share the promotion builder's choice card: quiet border, raised in dark, blue when picked. */ .storefront-create-order__place { display: flex; width: 100%; align-items: center; gap: 0.625rem; padding: 0.5rem 0.625rem; - border: 1px solid #d1d5db; + border: 1px solid #e5e7eb; border-radius: 0.375rem; text-align: left; font-size: 0.8125rem; @@ -6163,17 +6243,27 @@ body[data-theme='dark'] .storefront-create-order__picked { } body[data-theme='dark'] .storefront-create-order__place { + border-color: #374151; + background-color: #1f2937; +} + +.storefront-create-order__place:not(.is-static, .is-selected):hover { + border-color: #93c5fd; +} + +body[data-theme='dark'] .storefront-create-order__place:not(.is-static, .is-selected):hover { border-color: #4b5563; - background-color: #111827; + background-color: var(--sf-raised); } .storefront-create-order__place.is-selected { border-color: #2563eb; - box-shadow: 0 0 0 2px #bfdbfe; + background-color: #dbeafe; } body[data-theme='dark'] .storefront-create-order__place.is-selected { - box-shadow: 0 0 0 2px rgb(37 99 235 / 40%); + border-color: #3b82f6; + background-color: rgb(30 58 138 / 35%); } .storefront-create-order__tile { @@ -7317,12 +7407,59 @@ body[data-theme='dark'] .storefront-table__tile { gap: 0.875rem; } -@media (width <= 1279px) { +@media (width <= 1023px) { .storefront-member-cards { grid-template-columns: repeat(2, minmax(0, 1fr)); } } +/* Member card: the store's backdrop fills the media band with its logo on top */ +.storefront-member-card__identity { + display: block; + width: 100%; + min-width: 0; + text-align: left; +} + +.storefront-member-card__media.resource-card-body { + position: relative; + height: 7.5rem; + padding: 0; + background-color: #e5e7eb; + background-image: linear-gradient(135deg, #dbeafe, #ede9fe); + background-position: center; + background-size: cover; +} + +body[data-theme='dark'] .storefront-member-card__media.resource-card-body { + background-color: #111827; + background-image: linear-gradient(135deg, #1e3a8a, #312e81); +} + +.storefront-member-card__media-link { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + background: linear-gradient(0deg, rgb(0 0 0 / 25%), rgb(0 0 0 / 5%)); +} + +.storefront-member-card__logo { + width: 3.5rem; + height: 3.5rem; + border: 3px solid #fff; + border-radius: 0.75rem; + background-color: #fff; + object-fit: cover; + box-shadow: 0 4px 12px rgb(0 0 0 / 25%); +} + +body[data-theme='dark'] .storefront-member-card__logo { + border-color: var(--sf-surface); + background-color: var(--sf-surface); +} + @media (width <= 640px) { .storefront-member-cards { grid-template-columns: minmax(0, 1fr); @@ -7498,11 +7635,32 @@ body[data-theme='dark'] .storefront-filter-menu__group { color: #2563eb; } -/* Inline category picker in the Stores table */ -.storefront-category-cell__button { +/* Inline category picker in the Stores table: the button fills the cell, the caret on its right edge */ +.ember-basic-dropdown-trigger:has(.storefront-category-cell__button), +.btn-wrapper:has(.storefront-category-cell__button) { + display: flex; + width: 100%; +} + +.btn.storefront-category-cell__button { + display: flex; + justify-content: space-between; + width: 100%; max-width: 100%; } +.storefront-category-cell__button > span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.storefront-category-cell__button > svg, +.storefront-category-cell__button > .svg-inline--fa { + flex: 0 0 auto; + margin-left: auto; +} + .storefront-category-cell__button.is-empty { color: #6b7280; font-style: italic; @@ -9703,7 +9861,7 @@ body[data-theme='dark'] .storefront-panel-empty__help { } .storefront-customer-order-row:hover { - background-color: #f9fafb; + background-color: #f3f4f6; } body[data-theme='dark'] .storefront-customer-order-row:hover { @@ -9844,7 +10002,7 @@ body[data-theme='dark'] .storefront-check.is-radio.is-checked { } .storefront-table--rows tbody tr.is-clickable:hover td { - background-color: #f9fafb; + background-color: #f3f4f6; } body[data-theme='dark'] .storefront-table--rows tbody tr.is-clickable:hover td { @@ -9855,12 +10013,6 @@ body[data-theme='dark'] .storefront-table--rows tbody tr.is-clickable:hover td { max-width: 14rem; } -/* Network overview member cards: four to a row with a shorter logo band */ -.storefront-member-card .storefront-card__media-body { - flex-basis: 7.5rem; - height: 7.5rem; -} - /* Schedule manager: the week as compact rows (day, slots as chips, Add hours) in one box */ .storefront-schedule { display: flex; diff --git a/addon/templates/networks/index.hbs b/addon/templates/networks/index.hbs index 84698c40..ed7ab27c 100644 --- a/addon/templates/networks/index.hbs +++ b/addon/templates/networks/index.hbs @@ -1,6 +1,4 @@ -{{#if this.storefront.isNetworkContext}} - {{outlet}} -{{else}} +{{#if this.isListRoute}} - - {{row.member.name}} + +
@@ -60,6 +64,7 @@
+ {{t "storefront.networks.overview.view-store"}} {{t "storefront.networks.overview.view-on-stores"}} {{#if row.ownStore}} {{t "storefront.networks.overview.open-store"}} diff --git a/addon/templates/networks/index/network/store.hbs b/addon/templates/networks/index/network/store.hbs new file mode 100644 index 00000000..7d84f7c7 --- /dev/null +++ b/addon/templates/networks/index/network/store.hbs @@ -0,0 +1,126 @@ + + + {{/each}} +
+
+ {{#if this.load.isRunning}} + + {{else if (eq this.tab "orders")}} + + + + + + + + + + + + + {{#each this.orders as |order|}} + + + + + + + + + {{else}} + + {{/each}} + +
{{t "storefront.common.order"}}{{t "storefront.common.customer"}}{{t "storefront.networks.orders.columns.fulfillment"}}{{t "storefront.networks.orders.columns.placed"}}{{t "storefront.common.status"}}{{t "storefront.common.total"}}
{{or order.tracking order.public_id}}{{format-date-fns order.created_at "d MMM, HH:mm"}}{{format-currency order.meta.total order.meta.currency}}
{{t "storefront.networks.store-details.no-orders"}}
+ {{else}} + + + + + + + + + + + {{#each this.customers as |row|}} + + + + + + + {{else}} + + {{/each}} + +
{{t "storefront.common.name"}}{{t "storefront.common.email"}}{{t "storefront.common.phone"}}{{t "storefront.common.orders"}}
{{n-a row.record.email}}{{n-a row.record.phone}}{{row.orders}}
{{t "storefront.networks.store-details.no-customers"}}
+ {{/if}} +
+
+ diff --git a/addon/templates/networks/loading.hbs b/addon/templates/networks/loading.hbs index ad9209d9..1cbe3e93 100644 --- a/addon/templates/networks/loading.hbs +++ b/addon/templates/networks/loading.hbs @@ -1,23 +1,2 @@ {{! Entering the networks section lands on a network overview. }} - - -
- - -
- -
-
- - -
- -
-
-
- - -
- -
-
+ diff --git a/app/components/storefront/network/overview-skeleton.js b/app/components/storefront/network/overview-skeleton.js new file mode 100644 index 00000000..900faabb --- /dev/null +++ b/app/components/storefront/network/overview-skeleton.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/storefront-engine/components/storefront/network/overview-skeleton'; diff --git a/server/src/Http/Controllers/NetworkController.php b/server/src/Http/Controllers/NetworkController.php index fd32f8f6..214c543b 100644 --- a/server/src/Http/Controllers/NetworkController.php +++ b/server/src/Http/Controllers/NetworkController.php @@ -432,6 +432,7 @@ public function overview(string $id) 'public_id' => $store->public_id, 'name' => $store->name, 'logo_url' => $store->logo_url, + 'backdrop_url' => $store->backdrop_url, 'online' => (bool) $store->online, 'currency' => $store->currency, 'category_uuid' => $store->pivot->category_uuid ?? null, diff --git a/tests/unit/controllers/application-test.js b/tests/unit/controllers/application-test.js index de5565a3..35274589 100644 --- a/tests/unit/controllers/application-test.js +++ b/tests/unit/controllers/application-test.js @@ -292,17 +292,21 @@ module('Unit | Controller | application', function (hooks) { assert.true(items[2].activeWhen(), 'the trucks section is active on the network trucks route'); assert.false(items[1].activeWhen(), 'the stores section is not'); + hostRouter.isActive = (route, publicId) => route === 'console.storefront.networks.index.network.store' && publicId === 'network_123'; + assert.true(items[1].activeWhen(), "a member store's details page keeps the stores section active"); + assert.false('alsoActiveOn' in items[1], 'the extra routes stay out of the sidebar item'); + items[3].onClick(); assert.deepEqual(hostRouter.transitions, [['console.storefront.networks.index.network.orders', 'network_123']], 'sections transition with the network id'); }); - test('switching to a network enters its stores page', function (assert) { + test('switching to a network enters its overview', function (assert) { const controller = this.owner.lookup('controller:application'); const hostRouter = this.owner.lookup('service:host-router'); controller.switchActiveNetwork({ id: 'network_uuid', public_id: 'network_123' }); - assert.deepEqual(hostRouter.transitions, [['console.storefront.networks.index.network.stores', 'network_123']]); + assert.deepEqual(hostRouter.transitions, [['console.storefront.networks.index.network.index', 'network_123']]); }); test('switching to a store from a network lands on the store dashboard', async function (assert) { @@ -327,6 +331,6 @@ module('Unit | Controller | application', function (hooks) { assert.strictEqual(storefront.createNetworkCalls.length, 1, 'delegates to the storefront service'); storefront.createNetworkCalls[0].onSuccess({ id: 'network_uuid', public_id: 'network_123' }); - assert.deepEqual(hostRouter.transitions, [['console.storefront.networks.index.network.stores', 'network_123']]); + assert.deepEqual(hostRouter.transitions, [['console.storefront.networks.index.network.index', 'network_123']]); }); }); diff --git a/tests/unit/routes/networks/index-test.js b/tests/unit/routes/networks/index-test.js index 9d05d470..dcc5a24f 100644 --- a/tests/unit/routes/networks/index-test.js +++ b/tests/unit/routes/networks/index-test.js @@ -1,17 +1,48 @@ import { module, test } from 'qunit'; import { setupTest } from 'dummy/tests/helpers'; import Service from '@ember/service'; +import Evented from '@ember/object/evented'; module('Unit | Route | networks/index', function (hooks) { setupTest(hooks); hooks.beforeEach(function () { // The engine's dummy app has no console host to provide the host router. - this.owner.register('service:hostRouter', class extends Service {}); + this.owner.register('service:hostRouter', class extends Service.extend(Evented) {}); + this.owner.register( + 'service:store', + class extends Service { + queries = []; + query = (modelName, params) => { + this.queries.push([modelName, params]); + return []; + }; + } + ); }); test('it exists', function (assert) { let route = this.owner.lookup('route:networks/index'); assert.ok(route); }); + + test('entering a network skips the networks list, and arriving on the list loads it', function (assert) { + const route = this.owner.lookup('route:networks/index'); + const store = this.owner.lookup('service:store'); + const hostRouter = this.owner.lookup('service:hostRouter'); + let refreshed = 0; + route.refresh = () => refreshed++; + + assert.deepEqual(route.model({}, { to: { name: 'console.storefront.networks.index.network.index' } }), []); + assert.strictEqual(store.queries.length, 0, 'no networks query on the way into a network'); + + hostRouter.trigger('routeDidChange', { to: { name: 'console.storefront.networks.index.network.stores' } }); + assert.strictEqual(refreshed, 0, 'moving inside the network leaves the list alone'); + + hostRouter.trigger('routeDidChange', { to: { name: 'console.storefront.networks.index.index' } }); + assert.strictEqual(refreshed, 1, 'the list loads once it is shown'); + + route.model({ page: 2 }, { to: { name: 'console.storefront.networks.index.index' } }); + assert.deepEqual(store.queries[0], ['network', { with_gateways: 1, with_notification_channels: 1, page: 2 }]); + }); }); diff --git a/translations/en-us.yaml b/translations/en-us.yaml index 2938ed29..3e8f7b28 100644 --- a/translations/en-us.yaml +++ b/translations/en-us.yaml @@ -719,6 +719,12 @@ storefront: completed: Completed canceled: Canceled networks: + store-details: + back: Stores + revenue-share: "{share}% of network revenue" + through-network: Through the network + no-orders: No orders through the network yet. + no-customers: No customers through the network yet. card: stores-count: "{count, plural, =0 {No stores} one {# store} other {# stores}}" overview: @@ -730,7 +736,8 @@ storefront: manage-stores: Manage stores no-category: No category view-on-stores: View on the Stores page - open-store: Open this store's context + open-store: Manage this store + view-store: View store details online-count: "{count, plural, =0 {none online} one {# online} other {# online}}" invitations-hint: "pending, {declined} declined" orders-7d: Orders · 7 days From c595e6f75c9b0b9abab559effebc90ead1f10ea7 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 12 Oct 2026 18:58:50 +0800 Subject: [PATCH 181/193] fix(network orders): every order row is one line: the tracking number (order id on hover), store and driver as identity cells with a 20px image and the name only, the total without its tip line (tip on hover); a checkout's stores are evenly spaced 20px logos; the child branch follows the view, so Flat shows none --- .../storefront/network/orders/cell/driver.hbs | 11 +-- .../network/orders/cell/reference.hbs | 14 ++-- .../storefront/network/orders/cell/stores.hbs | 45 +++++------- .../storefront/network/orders/cell/stores.js | 12 +++- .../storefront/network/orders/cell/total.hbs | 15 ++-- .../storefront/network/orders/cell/total.js | 18 +++++ .../networks/index/network/orders.js | 11 ++- addon/styles/storefront-engine.css | 68 ++++++++----------- .../storefront/network/orders/cells-test.js | 51 +++++++++++--- 9 files changed, 143 insertions(+), 102 deletions(-) diff --git a/addon/components/storefront/network/orders/cell/driver.hbs b/addon/components/storefront/network/orders/cell/driver.hbs index 18595d37..f10ce121 100644 --- a/addon/components/storefront/network/orders/cell/driver.hbs +++ b/addon/components/storefront/network/orders/cell/driver.hbs @@ -1,9 +1,12 @@
{{#if this.driver}} - - {{#if (and @row.isGroup (gt @row.orderCount 1))}} - {{t "storefront.networks.orders.driver-shared" count=@row.orderCount}} - {{/if}} + {{! The identity cell: photo and name on one line; a shared driver says so on hover. }} + {{else if this.isPickup}} {{t "storefront.networks.orders.no-driver-needed"}} {{else if (gt this.driverCount 1)}} diff --git a/addon/components/storefront/network/orders/cell/reference.hbs b/addon/components/storefront/network/orders/cell/reference.hbs index e2cf4adf..09993c7b 100644 --- a/addon/components/storefront/network/orders/cell/reference.hbs +++ b/addon/components/storefront/network/orders/cell/reference.hbs @@ -1,4 +1,5 @@ -
+{{#let (if @column.isChild (@column.isChild @row) false) as |isChild|}} +
{{#if @row.isGroup}}
+{{/let}} diff --git a/addon/components/storefront/network/orders/cell/stores.hbs b/addon/components/storefront/network/orders/cell/stores.hbs index 8c86f6fd..dec2546a 100644 --- a/addon/components/storefront/network/orders/cell/stores.hbs +++ b/addon/components/storefront/network/orders/cell/stores.hbs @@ -1,33 +1,24 @@
- {{#each this.visible as |entry|}} - {{#if entry.store}} - {{#if this.isStack}} - {{! A checkout across stores: one round logo per store, the name and summary on hover. }} - - {{entry.name}} - - {{else}} - - {{/if}} - {{else if this.isStack}} - {{entry.initial}} - {{else}} - {{entry.name}} + {{#if this.isStack}} + {{! A checkout across stores: one round logo per store, its summary on hover. }} + {{#each this.visible as |entry|}} + + {{#if entry.store}} + + + {{else}} + {{entry.initial}} + {{/if}} + + {{/each}} + {{#if this.overflow}} + +{{this.overflow}} {{/if}} + {{else if this.single.store}} + + {{else if this.single}} + {{this.single.name}} {{else}} {{t "storefront.networks.orders.no-store"}} - {{/each}} - {{#if this.overflow}} - +{{this.overflow}} {{/if}}
diff --git a/addon/components/storefront/network/orders/cell/stores.js b/addon/components/storefront/network/orders/cell/stores.js index 3340d3d8..53837c5c 100644 --- a/addon/components/storefront/network/orders/cell/stores.js +++ b/addon/components/storefront/network/orders/cell/stores.js @@ -16,10 +16,15 @@ export default class StorefrontNetworkOrdersCellStoresComponent extends Componen return ids.map((id, index) => { const name = storesById[id]?.name ?? names[index] ?? names[0] ?? id; + const store = storesById[id] ?? null; + return { id, - store: storesById[id] ?? null, + store, name, + // An order placed on the network itself carries the network's id. + type: String(store?.public_id ?? id).startsWith('network_') ? 'network' : 'store', + logo: store?.logo_url ?? null, initial: String(name ?? '?') .charAt(0) .toUpperCase(), @@ -32,6 +37,11 @@ export default class StorefrontNetworkOrdersCellStoresComponent extends Componen return this.entries.length > 1; } + /** The one store behind a single order. */ + get single() { + return this.isStack ? null : (this.entries[0] ?? null); + } + get visible() { return this.entries.slice(0, MAX_VISIBLE); } diff --git a/addon/components/storefront/network/orders/cell/total.hbs b/addon/components/storefront/network/orders/cell/total.hbs index 7f44032e..e74fef97 100644 --- a/addon/components/storefront/network/orders/cell/total.hbs +++ b/addon/components/storefront/network/orders/cell/total.hbs @@ -1,14 +1,7 @@ -
+{{! One line for an order (its tip on hover); a checkout adds one line on how its tip splits. }} +
{{format-currency this.total this.currency}} - {{#if this.tip}} - - {{#if (eq this.tipLabel "split")}} - {{t "storefront.networks.orders.tip-split" tip=(format-currency this.tip this.currency) count=@row.orderCount}} - {{else if (eq this.tipLabel "shared")}} - {{t "storefront.networks.orders.tip-shared" tip=(format-currency this.tip this.currency)}} - {{else}} - {{t "storefront.networks.orders.tip-to-store" tip=(format-currency this.tip this.currency)}} - {{/if}} - + {{#if (and @row.isGroup this.tipText)}} + {{this.tipText}} {{/if}}
diff --git a/addon/components/storefront/network/orders/cell/total.js b/addon/components/storefront/network/orders/cell/total.js index b2798dc5..af7871b9 100644 --- a/addon/components/storefront/network/orders/cell/total.js +++ b/addon/components/storefront/network/orders/cell/total.js @@ -1,5 +1,7 @@ import Component from '@glimmer/component'; import { get } from '@ember/object'; +import { inject as service } from '@ember/service'; +import formatCurrency from '@fleetbase/ember-ui/utils/format-currency'; function number(value) { const parsed = parseFloat(value); @@ -8,6 +10,8 @@ function number(value) { } export default class StorefrontNetworkOrdersCellTotalComponent extends Component { + @service intl; + get total() { const { row } = this.args; @@ -39,4 +43,18 @@ export default class StorefrontNetworkOrdersCellTotalComponent extends Component return get(row, 'meta.checkout_id') ? 'shared' : 'store'; } + + /** The tip as a sentence: how a checkout splits it, or what an order's share is. */ + get tipText() { + const label = this.tipLabel; + + if (!label) { + return null; + } + + const tip = formatCurrency(this.tip, this.currency); + const keys = { split: 'tip-split', shared: 'tip-shared', network: 'tip-to-store', store: 'tip-to-store' }; + + return this.intl.t(`storefront.networks.orders.${keys[label]}`, { tip, count: this.args.row?.orderCount }); + } } diff --git a/addon/controllers/networks/index/network/orders.js b/addon/controllers/networks/index/network/orders.js index 9c56f173..a5e5ebc1 100644 --- a/addon/controllers/networks/index/network/orders.js +++ b/addon/controllers/networks/index/network/orders.js @@ -6,7 +6,7 @@ import { tracked, cached } from '@glimmer/tracking'; import { isBlank } from '@ember/utils'; import { timeout, task } from 'ember-concurrency'; import { action, get } from '@ember/object'; -import { groupOrdersByCheckout, STATUS_TABS } from '../../../../utils/order-groups'; +import { groupOrdersByCheckout, groupKeyOf, STATUS_TABS } from '../../../../utils/order-groups'; /** * Everything placed through the network, grouped by checkout: a multi-store purchase is one @@ -117,10 +117,16 @@ export default class NetworksIndexNetworkOrdersController extends BaseController const expanded = this.expandedGroups.has(row.id); row.expanded = expanded; - return expanded ? [row, ...row.orders.map((order) => Object.assign(order, { isChild: true, parentGroup: row.id }))] : [row]; + return expanded ? [row, ...row.orders] : [row]; }); } + /** + * An order sits under its checkout only while grouped and expanded. Read from tracked state + * at render, so switching to Flat redraws the row without the branch. + */ + isChildRow = (row) => Boolean(row && !row.isGroup && this.isGrouped && this.expandedGroups.has(groupKeyOf(row))); + @action toggleGroup(group) { const next = new Set(this.expandedGroups); @@ -170,6 +176,7 @@ export default class NetworksIndexNetworkOrdersController extends BaseController cellComponent: 'storefront/network/orders/cell/reference', onView: this.viewOrder, onToggle: this.toggleGroup, + isChild: this.isChildRow, width: '175px', resizable: true, sortable: false, diff --git a/addon/styles/storefront-engine.css b/addon/styles/storefront-engine.css index dfeb11d0..e86358d5 100644 --- a/addon/styles/storefront-engine.css +++ b/addon/styles/storefront-engine.css @@ -3926,67 +3926,57 @@ body[data-theme='dark'] .storefront-order-driver__muted { .storefront-identity-stack { display: flex; align-items: center; - flex-wrap: wrap; + flex-wrap: nowrap; gap: 0.25rem; + min-width: 0; } .storefront-identity-stack--avatars { flex-wrap: nowrap; - gap: 0; -} - -/* A multi-store row: overlapping round logos, the pill's text kept for screen readers only. */ -.storefront-identity-stack--avatars > * + * { - margin-left: -0.375rem; + gap: 0.25rem; } -.storefront-identity-stack__avatar.fleetbase-pill, +/* A multi-store row: one 20px round logo per store, the size of an identity cell's image, evenly spaced. */ .storefront-identity-stack__avatar { + display: inline-flex; flex: 0 0 auto; - gap: 0; - padding: 0; - border: 0; - background: transparent; -} - -.storefront-identity-stack__avatar .fleetbase-pill-body { - gap: 0; + align-items: center; + justify-content: center; + width: 1.25rem; + height: 1.25rem; + cursor: default; } -.storefront-identity-stack__avatar img { - width: 1.5rem; - height: 1.5rem; - object-fit: cover; +.storefront-identity-stack__logo { + width: 1.25rem; + height: 1.25rem; + border: 1px solid #e5e7eb; border-radius: 9999px; - box-shadow: 0 0 0 2px #fff; -} - -.storefront-identity-stack__avatar:hover { - position: relative; - z-index: 1; + object-fit: cover; + box-shadow: 0 1px 2px rgb(0 0 0 / 5%); } .storefront-identity-stack__initial { display: inline-flex; align-items: center; justify-content: center; - width: 1.5rem; - height: 1.5rem; + width: 1.25rem; + height: 1.25rem; + border: 1px solid #e5e7eb; border-radius: 9999px; - font-size: 0.6875rem; + font-size: 0.625rem; font-weight: 600; - background-color: #e5e7eb; + background-color: #f3f4f6; color: #374151; - box-shadow: 0 0 0 2px #fff; } -body[data-theme='dark'] .storefront-identity-stack__avatar img, +body[data-theme='dark'] .storefront-identity-stack__logo, body[data-theme='dark'] .storefront-identity-stack__initial { - box-shadow: 0 0 0 2px #111827; + border-color: #374151; } body[data-theme='dark'] .storefront-identity-stack__initial { - background-color: #374151; + background-color: #1f2937; color: #e5e7eb; } @@ -3998,7 +3988,6 @@ body[data-theme='dark'] .storefront-identity-stack__initial { } .storefront-identity-stack__overflow { - margin-left: 0.25rem; padding: 0 0.375rem; border-radius: 9999px; font-size: 0.6875rem; @@ -4038,8 +4027,8 @@ body[data-theme='dark'] .storefront-order-total__amount { .storefront-order-driver { display: flex; - flex-direction: column; - gap: 0.125rem; + align-items: center; + min-width: 0; } .storefront-order-actions { @@ -7734,8 +7723,11 @@ body[data-theme='dark'] .storefront-order-ref__toggle:hover { } .storefront-network-orders-table .storefront-order-total__tip { + overflow: hidden; font-size: 0.6875rem; - white-space: normal; + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; line-height: 1.2; } diff --git a/tests/integration/components/storefront/network/orders/cells-test.js b/tests/integration/components/storefront/network/orders/cells-test.js index 836e3b87..83d91c55 100644 --- a/tests/integration/components/storefront/network/orders/cells-test.js +++ b/tests/integration/components/storefront/network/orders/cells-test.js @@ -4,12 +4,14 @@ import { setupIntl } from 'ember-intl/test-support'; import { render } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; import { initialize } from '@fleetbase/storefront-engine/instance-initializers/register-storefront-resource-descriptors'; +import { initialize as initializeShared } from '@fleetbase/fleetops-data/instance-initializers/register-shared-resource-descriptors'; module('Integration | Component | storefront/network/orders cells', function (hooks) { setupRenderingTest(hooks); setupIntl(hooks, 'en-us'); hooks.beforeEach(function () { + initializeShared(this.owner); initialize(this.owner); this.set('storesById', { store_a: { public_id: 'store_a', name: 'Rochor Noodle House', currency: 'SGD', logo_url: 'https://cdn.test/a.png' }, @@ -17,13 +19,13 @@ module('Integration | Component | storefront/network/orders cells', function (ho }); }); - test('one store reads as a single-line pill, without the currency underneath', async function (assert) { + test('one store reads as a single-line identity cell, without the currency underneath', async function (assert) { this.set('row', { meta: { storefront_id: 'store_a', storefront: 'Rochor Noodle House' } }); await render(hbs``); - assert.dom('[data-test-resource-pill-title]').hasText('Rochor Noodle House'); - assert.dom('[data-test-resource-pill-subtitle]').doesNotExist(); + assert.dom('[data-test-order-store-identity] [data-test-identity-label]').hasText('Rochor Noodle House'); + assert.dom('[data-test-order-store-identity] [data-test-resource-identity-image]').exists(); assert.dom('[data-test-order-store-avatar]').doesNotExist(); }); @@ -33,21 +35,48 @@ module('Integration | Component | storefront/network/orders cells', function (ho await render(hbs``); assert.dom('[data-test-order-stores]').hasClass('storefront-identity-stack--avatars'); - assert.dom('[data-test-order-store-avatar]').exists({ count: 2 }); + assert.dom('[data-test-order-store-avatar]').exists({ count: 3 }, 'one evenly spaced slot per store'); assert.dom('[data-test-order-store-avatar]').hasAttribute('aria-label', 'Rochor Noodle House'); - assert.dom('[data-test-pill-online-indicator]').doesNotExist(); + assert.dom('[data-test-order-store-avatar] img').exists({ count: 2 }, 'the loaded stores show their logo'); assert.dom('.storefront-identity-stack__initial').hasText('U', 'a store that is not loaded falls back to its initial'); assert.dom('.storefront-identity-stack__initial').hasAttribute('title', 'Unknown Mart'); }); - test('an order reference shows its item count, not the checkout it belongs to', async function (assert) { - this.set('row', { public_id: 'order_1', item_count: 2, meta: { checkout_id: 'chkt_123' } }); - this.set('column', { onView() {}, onToggle() {} }); + test('an order reference is one line: the tracking number, with the order id on hover', async function (assert) { + this.set('row', { public_id: 'order_1', tracking: 'FLE123SG', item_count: 2, meta: { checkout_id: 'chkt_123' } }); + this.set('column', { onView() {}, onToggle() {}, isChild: () => false }); + + await render(hbs``); + + assert.dom('[data-test-order-tracking]').hasText('FLE123SG'); + assert.dom('[data-test-order-tracking]').hasAttribute('title', 'order_1'); + assert.dom('.storefront-order-ref__meta').doesNotExist('no item count or checkout under an order'); + assert.dom('.storefront-order-ref__branch').doesNotExist(); + }); + + test('an order under an expanded checkout draws the branch', async function (assert) { + this.set('row', { public_id: 'order_1', tracking: 'FLE123SG' }); + this.set('column', { onView() {}, onToggle() {}, isChild: () => true }); await render(hbs``); - assert.dom('[data-test-order-reference]').includesText('order_1'); - assert.dom('[data-test-order-reference]').doesNotIncludeText('chkt_123'); - assert.dom('.storefront-order-ref__meta').hasText('2 items'); + assert.dom('[data-test-order-reference]').hasClass('is-child'); + assert.dom('.storefront-order-ref__branch').exists(); + }); + + test('a single store and a driver are identity cells: a 20px image and one line of text', async function (assert) { + this.set('row', { meta: { storefront_id: 'store_a', storefront: 'Rochor Noodle House' } }); + this.set('driverColumn', {}); + this.set('driverRow', { driver_assigned: { resourceType: 'driver', name: 'Ava Driver', phone: '+6590000000', photo_url: 'https://cdn.test/d.png' } }); + + await render(hbs` + + + `); + + assert.dom('[data-test-order-store-identity] [data-test-identity-label]').hasText('Rochor Noodle House'); + assert.dom('[data-test-order-driver-identity] [data-test-identity-label]').hasText('Ava Driver'); + assert.dom('[data-test-order-driver]').doesNotIncludeText('+6590000000', 'no phone under the name'); + assert.dom('[data-test-resource-pill-subtitle]').doesNotExist(); }); }); From fed8eef93182b168d0afac9258496135f3765980 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 12 Oct 2026 19:18:59 +0800 Subject: [PATCH 182/193] fix(trucks): saved catalogs stay on the truck: the sync reloads the relation so the save answers with the new catalogs, and clears the cached truck lists since only the pivot changed; the card title opens the truck instead of the Fleet-Ops vehicle panel --- addon/components/storefront/truck/card.hbs | 12 +++- addon/templates/food-trucks/index.hbs | 2 +- .../networks/index/network/trucks.hbs | 2 +- server/src/Models/FoodTruck.php | 29 ++++++---- .../Unit/Models/FoodTruckCatalogsTest.php | 56 +++++++++++++++++++ 5 files changed, 88 insertions(+), 13 deletions(-) create mode 100644 server/tests/Unit/Models/FoodTruckCatalogsTest.php diff --git a/addon/components/storefront/truck/card.hbs b/addon/components/storefront/truck/card.hbs index 99f91f87..7955b370 100644 --- a/addon/components/storefront/truck/card.hbs +++ b/addon/components/storefront/truck/card.hbs @@ -3,7 +3,17 @@ - + {{! The title opens the truck; the vehicle opens from the menu (Open vehicle) or its summary. }} + diff --git a/addon/templates/food-trucks/index.hbs b/addon/templates/food-trucks/index.hbs index 5259601c..51982700 100644 --- a/addon/templates/food-trucks/index.hbs +++ b/addon/templates/food-trucks/index.hbs @@ -29,7 +29,7 @@ class="h-full" as |Item| > - + {{/if}} diff --git a/addon/templates/networks/index/network/trucks.hbs b/addon/templates/networks/index/network/trucks.hbs index 729cc553..02fcb354 100644 --- a/addon/templates/networks/index/network/trucks.hbs +++ b/addon/templates/networks/index/network/trucks.hbs @@ -29,7 +29,7 @@ class="h-full" as |Item| > - + {{/if}} diff --git a/server/src/Models/FoodTruck.php b/server/src/Models/FoodTruck.php index 97a572c5..9a65909e 100644 --- a/server/src/Models/FoodTruck.php +++ b/server/src/Models/FoodTruck.php @@ -182,19 +182,19 @@ public function setCatalogs(array $catalogs = []): FoodTruck ->values(); // Remove pivot rows for catalogs not in incoming list - $existingCatalogs - ->whereNotIn('uuid', $incomingUuids) - ->each(function (Catalog $catalog) { - CatalogSubject::where([ - 'catalog_uuid' => $catalog->uuid, - 'subject_uuid' => $this->uuid, - 'subject_type' => get_class($this), - ])->delete(); - }); + $removed = $existingCatalogs->whereNotIn('uuid', $incomingUuids); + $removed->each(function (Catalog $catalog) { + CatalogSubject::where([ + 'catalog_uuid' => $catalog->uuid, + 'subject_uuid' => $this->uuid, + 'subject_type' => get_class($this), + ])->delete(); + }); // Create or restore pivot rows for each incoming + $added = 0; foreach ($incomingUuids as $catalogUuid) { - CatalogSubject::firstOrCreate( + $subject = CatalogSubject::firstOrCreate( [ 'catalog_uuid' => $catalogUuid, 'subject_uuid' => $this->uuid, @@ -205,6 +205,15 @@ public function setCatalogs(array $catalogs = []): FoodTruck 'created_by_uuid' => session('user'), ] ); + $added += $subject->wasRecentlyCreated ? 1 : 0; + } + + // The relation was loaded before the sync; reload it so the saved truck answers with its new catalogs. + $this->load('catalogs'); + + // Only the pivot changed, so the truck fires no `updated` event; clear the cached truck lists here. + if (($removed->isNotEmpty() || $added > 0) && method_exists($this, 'invalidateApiCache')) { + $this->invalidateApiCache(); } return $this; diff --git a/server/tests/Unit/Models/FoodTruckCatalogsTest.php b/server/tests/Unit/Models/FoodTruckCatalogsTest.php new file mode 100644 index 00000000..77ecc28a --- /dev/null +++ b/server/tests/Unit/Models/FoodTruckCatalogsTest.php @@ -0,0 +1,56 @@ +connection('mysql'); + $schema = $connection->getSchemaBuilder(); + + foreach (['catalogs', 'catalog_subjects'] as $table) { + $schema->dropIfExists($table); + } + + $schema->create('catalogs', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('name')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + $schema->create('catalog_subjects', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('catalog_uuid')->nullable(); + $table->string('subject_uuid')->nullable(); + $table->string('subject_type')->nullable(); + $table->string('created_by_uuid')->nullable(); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + + $connection->table('catalogs')->insert([ + ['uuid' => '70000000-0000-4000-8000-000000000001', 'public_id' => 'catalog_breakfast', 'name' => 'Breakfast'], + ['uuid' => '70000000-0000-4000-8000-000000000002', 'public_id' => 'catalog_lunch', 'name' => 'Lunch'], + ]); +} + +test('syncing a truck\'s catalogs answers with the catalogs it now has, not the ones loaded before', function () { + createFoodTruckCatalogSchema(); + + $truck = new FoodTruck(); + $truck->forceFill(['uuid' => '60000000-0000-4000-8000-000000000001', 'company_uuid' => 'company_uuid']); + $truck->exists = true; + + $truck->setCatalogs(['70000000-0000-4000-8000-000000000001']); + expect($truck->catalogs->pluck('name')->all())->toBe(['Breakfast']); + + // The relation is loaded now; adding a catalog must not answer with the stale list. + $truck->setCatalogs([['uuid' => '70000000-0000-4000-8000-000000000001'], ['uuid' => '70000000-0000-4000-8000-000000000002']]); + expect($truck->catalogs->pluck('name')->sort()->values()->all())->toBe(['Breakfast', 'Lunch']); + + $truck->setCatalogs(['70000000-0000-4000-8000-000000000002']); + expect($truck->catalogs->pluck('name')->all())->toBe(['Lunch']); +}); From f5c7df9bb0f9539d31e7d4edb09bec202fdb6a3a Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 12 Oct 2026 19:36:42 +0800 Subject: [PATCH 183/193] feat(incoming order): the modal reads as a ticket: store, total and payment up top, the tracking number with status, fulfillment and timing, the route as stops, customer and driver side by side, the customer's note, the items and totals the order panel uses (now shared as line items, zero tips left out), and the label codes for handover; Accept is green; the truck card opens the truck without a page handler crashing it --- addon/components/modals/incoming-order.hbs | 344 ++++++------------ addon/components/modals/incoming-order.js | 98 +++++ .../order/details/commerce-summary.hbs | 72 +--- .../order/details/commerce-summary.js | 27 +- .../storefront/order/line-items.hbs | 72 ++++ .../components/storefront/order/line-items.js | 45 +++ addon/components/storefront/truck/card.hbs | 4 +- addon/components/storefront/truck/card.js | 5 + addon/services/storefront-order-actions.js | 2 +- addon/services/storefront.js | 3 +- addon/styles/storefront-engine.css | 303 +++++++++++++++ app/components/storefront/order/line-items.js | 1 + .../components/modals/incoming-order-test.js | 65 +++- .../storefront/order/line-items-test.js | 26 ++ .../components/storefront/truck/card-test.js | 14 +- translations/en-us.yaml | 9 + 16 files changed, 750 insertions(+), 340 deletions(-) create mode 100644 addon/components/modals/incoming-order.js create mode 100644 addon/components/storefront/order/line-items.hbs create mode 100644 addon/components/storefront/order/line-items.js create mode 100644 app/components/storefront/order/line-items.js create mode 100644 tests/integration/components/storefront/order/line-items-test.js diff --git a/addon/components/modals/incoming-order.hbs b/addon/components/modals/incoming-order.hbs index f06378d8..4a14c872 100644 --- a/addon/components/modals/incoming-order.hbs +++ b/addon/components/modals/incoming-order.hbs @@ -1,246 +1,128 @@ -
-
-
-
- {{@options.order.public_id}} -
-
-

{{or @options.order.tracking @options.order.public_id}}

- {{#if @options.order.tracking}}
{{@options.order.public_id}}
{{/if}} -
-
- -
-
-
-
-
-
-
{{t "storefront.common.store"}}
-
- {{@options.store.name}} -
-
{{@options.store.name}}
- -
+
+ {{! The ticket: the store it came in at, when, and what it came to. }} +
+
+ +
+
{{n-a this.store.name}}
+
+ {{t "storefront.component.modals.incoming-order.placed" ago=(n-a this.order.createdAgo)}} + · {{t "storefront.component.modals.incoming-order.items-count" count=this.itemCount}}
-
-
- {{#unless @options.order.meta.is_pickup}} -
-
-
{{t "storefront.component.modals.incoming-order.assigned"}}
-
- {{#if @options.order.driver_assigned.id}} - - {{else}} -
-
{{t "storefront.component.modals.incoming-order.not-assigned"}}
-
- {{/if}} - {{#if @options.assignDriver}} -
-
- {{/if}} -
-
-
- {{/unless}} -
-
-
{{t "storefront.common.customer"}}
-
- -
{{n-a @options.order.customer.email}}
-
{{n-a @options.order.customer.phone}}
- {{#unless @options.order.meta.is_pickup}} -
-
{{t "storefront.component.modals.incoming-order.address"}}
-
-
- -
-
- -
-
-
- {{/unless}} -
-
-
+
+
{{format-currency this.total this.currency}}
+
+ {{#if this.isCash}} + + {{t "storefront.common.cash"}} + {{else}} + + {{n-a (titleize this.paymentLabel) (t "storefront.component.modals.incoming-order.paid-in-app")}} + {{/if}}
-
-
- {{#if @options.order.meta.is_pickup}} -
{{t "storefront.common.pickup"}}
+
+ +
+
+
{{or this.order.tracking this.order.public_id}}
+ {{#if this.order.tracking}}
{{this.order.public_id}}
{{/if}} +
+
+ + + + + {{#if this.order.scheduled_at}} + {{t "storefront.component.modals.incoming-order.scheduled-for" date=(format-date-fns this.order.scheduled_at "d MMM, HH:mm")}} {{else}} -
{{t "storefront.common.delivery"}}
+ {{t "storefront.order.asap"}} {{/if}} -
- {{#if @options.order.meta.is_pickup}} -
- {{t "storefront.component.modals.incoming-order.pickup-order"}} -
-
- -
- -
-
- {{else if @options.order.payload.hasWaypoints}} - - {{else}} -
-
-
-
- -
-
-
- -
-
-
-
-
- -
-
-
- -
-
-
- {{/if}} -
-
+
-
-
-
-
{{t "storefront.component.modals.incoming-order.tracking"}}
-
({{@options.order.tracking}})
-
-
-
- {{@options.order.public_id}} -
-
- {{@options.order.public_id}} +
+ + {{! Where it goes: from the store location to the customer, or the counter it is collected from. }} +
+
{{if this.isPickup (t "storefront.common.pickup") (t "storefront.common.delivery")}}
+
    +
  1. + +
    +
    {{if this.isPickup (t "storefront.component.modals.incoming-order.collects-from") (t "storefront.component.modals.incoming-order.leaves-from")}}
    +
    + {{#if this.pickup.name}}{{this.pickup.name}} · {{/if}}{{n-a this.pickup.address}}
    -
-
-
-
-
-
{{t "storefront.component.modals.incoming-order.summary"}}
- {{#if @options.order.payload.cod_amount}} -
- - {{t "storefront.common.cash"}} -
- {{/if}} -
-
-
- {{#each @options.order.payload.entities as |entity|}} -
-
-
- {{entity.meta.quantity}}x -
-
-
-
- {{entity.name}} -
-
-

{{entity.name}}

-
-

{{entity.description}}

-
-
- {{#each entity.meta.variants as |variant|}} -
- {{variant.name}} -
- {{/each}} -
-
- {{#each entity.meta.addons as |addon|}} -
- + {{addon.name}} -
- {{/each}} -
-
-
-
- {{format-currency entity.meta.subtotal entity.currency}} -
-
- {{/each}} -
-
-
- {{t "storefront.component.modals.incoming-order.subtotal"}} - {{format-currency @options.order.meta.subtotal @options.order.meta.currency}} -
- {{#unless @options.order.meta.is_pickup}} -
- {{t "storefront.component.modals.incoming-order.fee"}} - {{format-currency @options.order.meta.delivery_fee @options.order.meta.currency}} -
- {{/unless}} - {{#if @options.order.meta.tip}} -
- {{t "storefront.component.modals.incoming-order.tip"}} - {{get-tip-amount @options.order.meta.tip @options.order.meta.subtotal @options.order.meta.currency}} -
- {{/if}} - {{#if @options.order.meta.delivery_tip}} -
- {{t "storefront.component.modals.incoming-order.delivery-tip"}} - {{get-tip-amount - @options.order.meta.delivery_tip - @options.order.meta.subtotal - @options.order.meta.currency - }} -
- {{/if}} -
-
-
- {{t "storefront.common.total"}} - {{format-currency @options.order.meta.total @options.order.meta.currency}} + + {{#unless this.isPickup}} +
  • + +
    +
    {{t "storefront.component.modals.incoming-order.deliver-to"}}
    +
    + {{#if this.dropoff.name}}{{this.dropoff.name}} · {{/if}}{{n-a this.dropoff.address}}
    -
  • -
    + + {{/unless}} + + + + {{! Who it is for, and who takes it there. }} +
    +
    +
    {{t "storefront.common.customer"}}
    + + {{#if this.customer.phone}}
    {{this.customer.phone}}
    {{/if}} + {{#if this.customer.email}}
    {{this.customer.email}}
    {{/if}}
    -
    +
    +
    {{t "storefront.common.driver"}}
    + {{#if this.isPickup}} +
    {{t "storefront.networks.orders.no-driver-needed"}}
    + {{else if this.driver}} + + {{else}} +
    {{t "storefront.component.modals.incoming-order.not-assigned"}}
    + {{/if}} + {{#if (and @options.assignDriver (not this.isPickup))}} +
    + + + {{#if this.order.notes}} +
    +
    {{t "storefront.component.modals.incoming-order.note"}}
    +

    {{this.order.notes}}

    +
    + {{/if}} + + {{! What is in it. }} +
    +
    {{t "storefront.component.modals.incoming-order.summary"}}
    + +
    + + {{! The label codes, for printing or scanning at handover. }} + {{#if (or this.qrCode this.barcode)}} +
    + {{#if this.qrCode}}{{t{{/if}} + {{#if this.barcode}}{{t{{/if}} +
    {{t "storefront.component.modals.incoming-order.scan-hint"}}
    +
    + {{/if}}
    - \ No newline at end of file + diff --git a/addon/components/modals/incoming-order.js b/addon/components/modals/incoming-order.js new file mode 100644 index 00000000..416b43c9 --- /dev/null +++ b/addon/components/modals/incoming-order.js @@ -0,0 +1,98 @@ +import Component from '@glimmer/component'; +import { get } from '@ember/object'; + +function number(value) { + const parsed = parseFloat(value); + + return Number.isFinite(parsed) ? parsed : 0; +} + +/** + * The incoming order, read as a ticket: who placed it at which store, where it goes, what is in + * it and what it came to, with accept and decline in the modal footer. + */ +export default class ModalsIncomingOrderComponent extends Component { + get order() { + return this.args.options?.order; + } + + get store() { + return this.args.options?.store; + } + + get meta() { + return this.order?.meta ?? {}; + } + + get isPickup() { + return Boolean(this.meta.is_pickup); + } + + get currency() { + return this.meta.currency ?? this.store?.currency; + } + + get total() { + return number(this.meta.total ?? this.order?.transaction_amount); + } + + get itemCount() { + const entities = get(this.order ?? {}, 'payload.entities'); + const list = entities?.toArray?.() ?? Array.from(entities ?? []); + + return list.reduce((sum, entity) => sum + (number(get(entity, 'meta.quantity')) || 1), 0); + } + + get isCash() { + return Boolean(get(this.order ?? {}, 'payload.cod_amount')) || this.meta.gateway === 'cash'; + } + + get paymentLabel() { + return this.isCash ? null : (this.meta.gateway ?? null); + } + + /** A place on the route as one line: its name, then its address when the name is not the address. */ + placeLine(place) { + if (!place) { + return { name: null, address: null }; + } + + const name = get(place, 'name'); + const address = get(place, 'address') ?? get(place, 'street1'); + + // Leave the name out when the address already starts with it ("T2" and "T2 - 26 Peace Avenue"). + const repeats = name && address && String(address).toLowerCase().startsWith(String(name).toLowerCase()); + + return { name: name && !repeats ? name : null, address: address ?? name ?? null }; + } + + get pickup() { + return this.placeLine(get(this.order ?? {}, 'payload.pickup')); + } + + get dropoff() { + return this.placeLine(get(this.order ?? {}, 'payload.dropoff')); + } + + get driver() { + const driver = get(this.order ?? {}, 'driver_assigned'); + + return get(driver ?? {}, 'id') ? driver : null; + } + + get customer() { + return get(this.order ?? {}, 'customer'); + } + + get qrCode() { + const code = get(this.order ?? {}, 'tracking_number.qr_code'); + + return code ? `data:image/png;base64,${code}` : null; + } + + get barcode() { + const code = get(this.order ?? {}, 'tracking_number.barcode'); + + return code ? `data:image/png;base64,${code}` : null; + } +} diff --git a/addon/components/storefront/order/details/commerce-summary.hbs b/addon/components/storefront/order/details/commerce-summary.hbs index a2bce9cd..9d51106e 100644 --- a/addon/components/storefront/order/details/commerce-summary.hbs +++ b/addon/components/storefront/order/details/commerce-summary.hbs @@ -8,75 +8,5 @@ @wrapperClass="bordered-top" ...attributes > -
    - {{#if @resource.payload.entities}} -
    - {{#each @resource.payload.entities as |entity|}} -
    -
    {{n-a entity.meta.quantity 1}}x
    - {{entity.name}} -
    -
    {{n-a entity.name (t "storefront.order.sections.unnamed-item")}}
    -
    {{n-a entity.description (t "storefront.order.sections.no-description")}}
    - {{#if entity.meta.scheduled_at}} -
    - {{t "storefront.order.sections.scheduled-at" date=(format-date-fns entity.meta.scheduled_at "d MMM yyyy HH:mm")}} -
    - {{/if}} - {{#if entity.meta.variants}} -
    - {{#each entity.meta.variants as |variant|}} - {{or variant.name variant.label variant.option}} - {{/each}} -
    - {{/if}} - {{#if entity.meta.addons}} -
    - {{#each entity.meta.addons as |addon|}} - + {{or addon.name addon.label}} - {{/each}} -
    - {{/if}} -
    -
    - {{format-currency entity.meta.subtotal (or entity.currency @resource.meta.currency)}} -
    -
    - {{/each}} -
    - {{else}} -
    - - {{t "storefront.order.sections.no-items"}} -
    - {{/if}} - -
    -
    {{t "storefront.order.sections.subtotal"}}{{format-currency @resource.meta.subtotal @resource.meta.currency}}
    - {{#unless @resource.meta.is_pickup}} -
    {{t "storefront.orders.create.delivery-fee"}}{{format-currency @resource.meta.delivery_fee @resource.meta.currency}}
    - {{/unless}} - {{#each this.promotions as |promotion|}} -
    - - {{promotion.name}}{{#if promotion.code}} · {{promotion.code}}{{/if}} - - -{{format-currency promotion.amount @resource.meta.currency}} -
    - {{/each}} - {{#if this.unattributedDiscount}} -
    {{t "storefront.order.sections.discount"}}-{{format-currency this.unattributedDiscount @resource.meta.currency}}
    - {{/if}} - {{#if @resource.meta.tip}} -
    {{t "storefront.order.sections.tip"}}{{get-tip-amount @resource.meta.tip @resource.meta.subtotal @resource.meta.currency}}
    - {{/if}} - {{#if @resource.meta.delivery_tip}} -
    {{t "storefront.order.sections.delivery-tip"}}{{get-tip-amount @resource.meta.delivery_tip @resource.meta.subtotal @resource.meta.currency}}
    - {{/if}} -
    - {{t "storefront.order.sections.total"}} - {{format-currency (or @resource.meta.total @resource.transaction_amount 0) @resource.meta.currency}} -
    -
    -
    + diff --git a/addon/components/storefront/order/details/commerce-summary.js b/addon/components/storefront/order/details/commerce-summary.js index 9e1b1419..e567a8a2 100644 --- a/addon/components/storefront/order/details/commerce-summary.js +++ b/addon/components/storefront/order/details/commerce-summary.js @@ -1,25 +1,4 @@ -import Component from '@glimmer/component'; +import templateOnlyComponent from '@ember/component/template-only'; -export default class StorefrontOrderDetailsCommerceSummaryComponent extends Component { - /** Each promotion applied at checkout, with what it took off the order (items and delivery). */ - get promotions() { - const promotions = this.args.resource?.meta?.promotions; - if (!Array.isArray(promotions)) { - return []; - } - - return promotions.map((promotion) => ({ - name: promotion.name || promotion.code || 'Promotion', - code: promotion.code, - isFreeDelivery: promotion.type === 'free_delivery', - amount: (Number(promotion.amount) || 0) + (Number(promotion.delivery_amount) || 0), - })); - } - - /** The order's discount when no promotion breakdown was recorded. */ - get unattributedDiscount() { - const discount = Number(this.args.resource?.meta?.discount) || 0; - - return this.promotions.length === 0 && discount > 0 ? discount : 0; - } -} +/** The order section of the panel: its items and totals, as the line items component draws them. */ +export default templateOnlyComponent(); diff --git a/addon/components/storefront/order/line-items.hbs b/addon/components/storefront/order/line-items.hbs new file mode 100644 index 00000000..e797a462 --- /dev/null +++ b/addon/components/storefront/order/line-items.hbs @@ -0,0 +1,72 @@ +{{! What was bought and what it came to: one row per item (quantity, photo, name, options, price), then the totals. }} +
    + {{#if this.order.payload.entities}} +
    + {{#each this.order.payload.entities as |entity|}} +
    +
    {{n-a entity.meta.quantity 1}}x
    + {{entity.name}} +
    +
    {{n-a entity.name (t "storefront.order.sections.unnamed-item")}}
    +
    {{n-a entity.description (t "storefront.order.sections.no-description")}}
    + {{#if entity.meta.scheduled_at}} +
    + {{t "storefront.order.sections.scheduled-at" date=(format-date-fns entity.meta.scheduled_at "d MMM yyyy HH:mm")}} +
    + {{/if}} + {{#if entity.meta.variants}} +
    + {{#each entity.meta.variants as |variant|}} + {{or variant.name variant.label variant.option}} + {{/each}} +
    + {{/if}} + {{#if entity.meta.addons}} +
    + {{#each entity.meta.addons as |addon|}} + + {{or addon.name addon.label}} + {{/each}} +
    + {{/if}} +
    +
    + {{format-currency entity.meta.subtotal (or entity.currency this.order.meta.currency)}} +
    +
    + {{/each}} +
    + {{else}} +
    + + {{t "storefront.order.sections.no-items"}} +
    + {{/if}} + +
    +
    {{t "storefront.order.sections.subtotal"}}{{format-currency this.order.meta.subtotal this.order.meta.currency}}
    + {{#unless this.order.meta.is_pickup}} +
    {{t "storefront.orders.create.delivery-fee"}}{{format-currency this.order.meta.delivery_fee this.order.meta.currency}}
    + {{/unless}} + {{#each this.promotions as |promotion|}} +
    + + {{promotion.name}}{{#if promotion.code}} · {{promotion.code}}{{/if}} + + -{{format-currency promotion.amount this.order.meta.currency}} +
    + {{/each}} + {{#if this.unattributedDiscount}} +
    {{t "storefront.order.sections.discount"}}-{{format-currency this.unattributedDiscount this.order.meta.currency}}
    + {{/if}} + {{#if this.hasTip}} +
    {{t "storefront.order.sections.tip"}}{{get-tip-amount this.order.meta.tip this.order.meta.subtotal this.order.meta.currency}}
    + {{/if}} + {{#if this.hasDeliveryTip}} +
    {{t "storefront.order.sections.delivery-tip"}}{{get-tip-amount this.order.meta.delivery_tip this.order.meta.subtotal this.order.meta.currency}}
    + {{/if}} +
    + {{t "storefront.order.sections.total"}} + {{format-currency (or this.order.meta.total this.order.transaction_amount 0) this.order.meta.currency}} +
    +
    +
    diff --git a/addon/components/storefront/order/line-items.js b/addon/components/storefront/order/line-items.js new file mode 100644 index 00000000..b1080da8 --- /dev/null +++ b/addon/components/storefront/order/line-items.js @@ -0,0 +1,45 @@ +import Component from '@glimmer/component'; + +function isAboveZero(value) { + return (parseFloat(String(value ?? '').replace('%', '')) || 0) > 0; +} + +/** + * An order's items and totals, shared by the order panel and the incoming order modal. + */ +export default class StorefrontOrderLineItemsComponent extends Component { + get order() { + return this.args.order ?? this.args.resource; + } + + /** Each promotion applied at checkout, with what it took off the order (items and delivery). */ + get promotions() { + const promotions = this.order?.meta?.promotions; + if (!Array.isArray(promotions)) { + return []; + } + + return promotions.map((promotion) => ({ + name: promotion.name || promotion.code || 'Promotion', + code: promotion.code, + isFreeDelivery: promotion.type === 'free_delivery', + amount: (Number(promotion.amount) || 0) + (Number(promotion.delivery_amount) || 0), + })); + } + + /** The order's discount when no promotion breakdown was recorded. */ + get unattributedDiscount() { + const discount = Number(this.order?.meta?.discount) || 0; + + return this.promotions.length === 0 && discount > 0 ? discount : 0; + } + + /** A tip line shows only when there is a tip: an amount or a percentage above zero. */ + get hasTip() { + return isAboveZero(this.order?.meta?.tip); + } + + get hasDeliveryTip() { + return isAboveZero(this.order?.meta?.delivery_tip); + } +} diff --git a/addon/components/storefront/truck/card.hbs b/addon/components/storefront/truck/card.hbs index 7955b370..11ab5b4e 100644 --- a/addon/components/storefront/truck/card.hbs +++ b/addon/components/storefront/truck/card.hbs @@ -9,8 +9,8 @@ @titleFallback={{t "storefront.trucks.card.no-vehicle"}} @subtitle={{or this.driverName (t "storefront.trucks.card.no-driver")}} @showOnlineIndicator={{true}} - @static={{not (or @onOpen this.vehicle.id)}} - @onClick={{if @onOpen (fn @onOpen this.truck)}} + @static={{not (or this.openTruck this.vehicle.id)}} + @onClick={{this.openTruck}} class="storefront-card__identity" data-test-truck-vehicle /> diff --git a/addon/components/storefront/truck/card.js b/addon/components/storefront/truck/card.js index 7ce1bf48..a100bfc8 100644 --- a/addon/components/storefront/truck/card.js +++ b/addon/components/storefront/truck/card.js @@ -10,6 +10,11 @@ import { catalogServingNow } from './panel'; * catalogs it carries. */ export default class StorefrontTruckCardComponent extends Component { + /** Opens the truck from its title, when the page gives a way to. */ + get openTruck() { + return typeof this.args.onOpen === 'function' ? () => this.args.onOpen(this.truck) : null; + } + get truck() { return this.args.truck ?? this.args.foodTruck ?? this.args.resource; } diff --git a/addon/services/storefront-order-actions.js b/addon/services/storefront-order-actions.js index e32b3f40..896e7a61 100644 --- a/addon/services/storefront-order-actions.js +++ b/addon/services/storefront-order-actions.js @@ -393,7 +393,7 @@ export default class StorefrontOrderActionsService extends Service { acceptButtonText: this.intl.t('storefront.component.widget.orders.accept-order'), acceptButtonScheme: 'success', acceptButtonIcon: 'check', - modalClass: 'scrollable-height-dialog', + modalClass: 'scrollable-height-dialog storefront-incoming-order-modal', order, store, assignDriver: async () => { diff --git a/addon/services/storefront.js b/addon/services/storefront.js index e134c4f0..859aeb6b 100644 --- a/addon/services/storefront.js +++ b/addon/services/storefront.js @@ -232,6 +232,7 @@ export default class StorefrontService extends Service.extend(Evented) { this.modalsManager.show('modals/incoming-order', { title: this.intl.t('storefront.service.storefront.new-incoming-order'), acceptButtonText: alreadyAccepted ? this.intl.t('common.ok') : this.intl.t('storefront.service.storefront.accept-order'), + acceptButtonScheme: alreadyAccepted ? 'primary' : 'success', acceptButtonIcon: 'check', acceptButtonIconPrefix: 'fas', hideDeclineButton: alreadyAccepted, @@ -239,7 +240,7 @@ export default class StorefrontService extends Service.extend(Evented) { declineButtonScheme: 'danger', closeButton: false, backdropClose: false, - modalClass: 'scrollable-height-dialog', + modalClass: 'scrollable-height-dialog storefront-incoming-order-modal', order, store, confirm: async (modal) => { diff --git a/addon/styles/storefront-engine.css b/addon/styles/storefront-engine.css index e86358d5..dcc8f4ac 100644 --- a/addon/styles/storefront-engine.css +++ b/addon/styles/storefront-engine.css @@ -10457,3 +10457,306 @@ body[data-theme='dark'] .storefront-product-translations__fields { body[data-theme='dark'] .storefront-product-translations__key { color: #9ca3af; } + +/* Incoming order modal (`storefront-incoming-order-modal`): a ticket read top to bottom (store and total, reference, route, people, items, label codes) */ +.storefront-incoming-order { + display: flex; + flex-direction: column; + gap: 0.875rem; + padding: 0.25rem 1.25rem 1.25rem; + font-size: 0.8125rem; + color: #374151; +} + +body[data-theme='dark'] .storefront-incoming-order { + color: #d1d5db; +} + +.storefront-incoming-order__hero, +.storefront-incoming-order__ref { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + min-width: 0; +} + +.storefront-incoming-order__store { + display: flex; + align-items: center; + gap: 0.75rem; + min-width: 0; +} + +.storefront-incoming-order__logo { + width: 2.5rem; + height: 2.5rem; + flex: 0 0 auto; + border: 1px solid #e5e7eb; + border-radius: 0.5rem; + object-fit: cover; +} + +body[data-theme='dark'] .storefront-incoming-order__logo { + border-color: #374151; +} + +.storefront-incoming-order__store-name { + overflow: hidden; + font-size: 0.9375rem; + font-weight: 600; + color: #111827; + text-overflow: ellipsis; + white-space: nowrap; +} + +body[data-theme='dark'] .storefront-incoming-order__store-name, +body[data-theme='dark'] .storefront-incoming-order__tracking, +body[data-theme='dark'] .storefront-incoming-order__total { + color: #f9fafb; +} + +.storefront-incoming-order__amount { + flex: 0 0 auto; + text-align: right; +} + +.storefront-incoming-order__total { + font-size: 1.25rem; + font-weight: 700; + line-height: 1.2; + color: #111827; +} + +.storefront-incoming-order__muted { + font-size: 0.75rem; + color: #6b7280; +} + +body[data-theme='dark'] .storefront-incoming-order__muted { + color: #9ca3af; +} + +.storefront-incoming-order__ref { + padding: 0.625rem 0.75rem; + border: 1px solid #e5e7eb; + border-radius: 0.5rem; + background-color: #f9fafb; +} + +body[data-theme='dark'] .storefront-incoming-order__ref { + border-color: #374151; + background-color: #1f2937; +} + +.storefront-incoming-order__tracking { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.9375rem; + font-weight: 700; + letter-spacing: 0.02em; + color: #111827; +} + +.storefront-incoming-order__chips { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 0.375rem; +} + +.storefront-incoming-order__chip { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0 0.5rem; + border-radius: 9999px; + font-size: 0.6875rem; + font-weight: 600; + line-height: 1.25rem; + background-color: #f3f4f6; + color: #374151; +} + +body[data-theme='dark'] .storefront-incoming-order__chip { + background-color: #374151; + color: #e5e7eb; +} + +.storefront-incoming-order__section { + padding-top: 0.875rem; + border-top: 1px solid #e5e7eb; +} + +body[data-theme='dark'] .storefront-incoming-order__section { + border-color: #374151; +} + +.storefront-incoming-order__label { + margin-bottom: 0.5rem; + font-size: 0.6875rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #6b7280; +} + +body[data-theme='dark'] .storefront-incoming-order__label { + color: #9ca3af; +} + +.storefront-incoming-order__route { + position: relative; + display: flex; + flex-direction: column; + gap: 0.625rem; + margin: 0; + padding: 0; + list-style: none; +} + +/* The line between the two stops */ +.storefront-incoming-order__route::before { + position: absolute; + top: 1.25rem; + bottom: 1.25rem; + left: 0.6875rem; + width: 1px; + background-color: #d1d5db; + content: ''; +} + +body[data-theme='dark'] .storefront-incoming-order__route::before { + background-color: #4b5563; +} + +.storefront-incoming-order__stop { + position: relative; + display: flex; + align-items: center; + gap: 0.625rem; + min-width: 0; +} + +.storefront-incoming-order__stop-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.375rem; + height: 1.375rem; + flex: 0 0 auto; + border-radius: 9999px; + background-color: #dbeafe; + color: #1d4ed8; +} + +.storefront-incoming-order__stop-icon--dropoff { + background-color: #fee2e2; + color: #b91c1c; +} + +body[data-theme='dark'] .storefront-incoming-order__stop-icon { + background-color: rgb(30 58 138 / 50%); + color: #93c5fd; +} + +body[data-theme='dark'] .storefront-incoming-order__stop-icon--dropoff { + background-color: rgb(127 29 29 / 50%); + color: #fca5a5; +} + +.storefront-incoming-order__stop-label { + font-size: 0.6875rem; + color: #6b7280; +} + +body[data-theme='dark'] .storefront-incoming-order__stop-label { + color: #9ca3af; +} + +.storefront-incoming-order__line { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.storefront-incoming-order__line strong { + font-weight: 600; + color: #111827; +} + +body[data-theme='dark'] .storefront-incoming-order__line strong { + color: #f3f4f6; +} + +.storefront-incoming-order__people { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; +} + +.storefront-incoming-order__contact { + display: flex; + align-items: center; + gap: 0.375rem; + margin-top: 0.375rem; + overflow: hidden; + font-size: 0.75rem; + text-overflow: ellipsis; + white-space: nowrap; + color: #6b7280; +} + +body[data-theme='dark'] .storefront-incoming-order__contact { + color: #9ca3af; +} + +.storefront-incoming-order__note { + margin: 0; + padding: 0.5rem 0.75rem; + border-left: 3px solid #f59e0b; + border-radius: 0.25rem; + background-color: #fffbeb; + white-space: pre-line; +} + +body[data-theme='dark'] .storefront-incoming-order__note { + background-color: rgb(120 53 15 / 25%); +} + +.storefront-incoming-order__codes { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.storefront-incoming-order__qr, +.storefront-incoming-order__barcode { + height: 3.5rem; + padding: 0.25rem; + border-radius: 0.375rem; + background-color: #fff; +} + +.storefront-incoming-order__qr { + width: 3.5rem; +} + +.storefront-incoming-order__barcode { + width: 10rem; +} + +@media (width <= 639px) { + .storefront-incoming-order__people { + grid-template-columns: minmax(0, 1fr); + } + + .storefront-incoming-order__ref { + flex-direction: column; + align-items: flex-start; + } + + .storefront-incoming-order__chips { + justify-content: flex-start; + } +} diff --git a/app/components/storefront/order/line-items.js b/app/components/storefront/order/line-items.js new file mode 100644 index 00000000..09c4da22 --- /dev/null +++ b/app/components/storefront/order/line-items.js @@ -0,0 +1 @@ +export { default } from '@fleetbase/storefront-engine/components/storefront/order/line-items'; diff --git a/tests/integration/components/modals/incoming-order-test.js b/tests/integration/components/modals/incoming-order-test.js index bc41da61..ea58897f 100644 --- a/tests/integration/components/modals/incoming-order-test.js +++ b/tests/integration/components/modals/incoming-order-test.js @@ -1,22 +1,69 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; +import { setupIntl } from 'ember-intl/test-support'; import { render } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; +function order(overrides = {}) { + return { + public_id: 'order_abc', + tracking: 'FLE123MN', + status: 'created', + notes: '', + createdAgo: '2 minutes ago', + customer: { id: 'c1', name: 'Bold Batbayar', phone: '+97699110001', email: 'bold@example.mn' }, + driver_assigned: null, + tracking_number: { qr_code: 'QR', barcode: 'BAR' }, + payload: { + pickup: { name: 'Zaisan', address: 'Zaisan, Ulaanbaatar' }, + dropoff: { name: 'T2', address: 'T2 - 26 Peace Avenue' }, + entities: [{ name: 'Milk tea', description: 'Hot', meta: { quantity: 2, subtotal: 4000 } }], + }, + meta: { currency: 'MNT', subtotal: 4000, delivery_fee: 1000, total: 5000, tip: '0', gateway: 'qpay' }, + ...overrides, + }; +} + module('Integration | Component | modals/incoming-order', function (hooks) { setupRenderingTest(hooks); + setupIntl(hooks, 'en-us'); + + test('a delivery reads as a ticket: store and total, tracking number, route, people, items and label codes', async function (assert) { + this.set('options', { title: 'New order', order: order(), store: { name: 'Khaan Buuz', currency: 'MNT' } }); - // Modals read their @options (network, store, catalog) in tasks on render; a bare render has nothing to assert. - test.skip('it renders', async function (assert) { - // Set any properties with this.set('myProperty', 'value'); - // Handle any actions with this.set('myAction', function(val) { ... }); + await render(hbs``); + + assert.dom('[data-test-incoming-order-store]').hasText('Khaan Buuz'); + assert.dom('[data-test-incoming-order-tracking]').hasText('FLE123MN'); + assert.dom('[data-test-incoming-order]').includesText('order_abc'); + assert.dom('[data-test-incoming-order]').includesText('2 items', 'the item count follows the quantities'); + assert.dom('[data-test-incoming-order-timing]').includesText('ASAP'); + assert.dom('[data-test-incoming-order-route] li').exists({ count: 2 }, 'store location and drop-off'); + assert.dom('[data-test-incoming-order-route]').includesText('T2 - 26 Peace Avenue'); + assert.dom('[data-test-incoming-order-route]').doesNotIncludeText('T2 ·', 'a name the address starts with is not repeated'); + assert.dom('[data-test-incoming-order]').includesText('+97699110001'); + assert.dom('[data-test-incoming-order]').includesText('No driver assigned'); + assert.dom('[data-test-incoming-order-codes] img').exists({ count: 2 }); + assert.dom('[data-test-incoming-order-note]').doesNotExist('no note, no section'); + assert.dom('[data-test-incoming-order]').doesNotIncludeText('Tip', 'a zero tip is left out'); + }); - await render(hbs``); + test('a pickup has one stop, needs no driver and shows the customer note', async function (assert) { + this.set('options', { + order: order({ + notes: 'Ring the bell', + meta: { currency: 'MNT', subtotal: 4000, total: 4000, is_pickup: true }, + payload: { pickup: { name: 'Counter', address: 'Bagatoiruu 12' }, entities: [] }, + }), + store: { name: 'Khaan Buuz' }, + }); - assert.dom(this.element).exists(); + await render(hbs``); - // Template block usage: - await render(hbs``); - assert.dom(this.element).exists(); + assert.dom('[data-test-incoming-order-route] li').exists({ count: 1 }); + assert.dom('[data-test-incoming-order-route]').includesText('Customer collects from'); + assert.dom('[data-test-incoming-order]').includesText('No driver needed'); + assert.dom('[data-test-incoming-order-assign-driver]').doesNotExist(); + assert.dom('[data-test-incoming-order-note]').hasText('Ring the bell'); }); }); diff --git a/tests/integration/components/storefront/order/line-items-test.js b/tests/integration/components/storefront/order/line-items-test.js new file mode 100644 index 00000000..16b86586 --- /dev/null +++ b/tests/integration/components/storefront/order/line-items-test.js @@ -0,0 +1,26 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { setupIntl } from 'ember-intl/test-support'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Component | storefront/order/line-items', function (hooks) { + setupRenderingTest(hooks); + setupIntl(hooks, 'en-us'); + + test('it lists the items and totals, with tips only when there is one', async function (assert) { + this.set('order', { + payload: { entities: [{ name: 'Buuz', meta: { quantity: 3, subtotal: 9000, addons: [{ name: 'Extra sauce' }] } }] }, + meta: { currency: 'MNT', subtotal: 9000, delivery_fee: 1000, total: 11000, tip: '10%', delivery_tip: 0, promotions: [{ name: 'Lunch', code: 'LUNCH', amount: 500 }] }, + }); + + await render(hbs``); + + assert.dom('.storefront-order-item').exists({ count: 1 }); + assert.dom('.storefront-order-item__qty').hasText('3x'); + assert.dom('.storefront-order-item').includesText('+ Extra sauce'); + assert.dom('.storefront-order-totals').includesText('Lunch'); + assert.dom('.storefront-order-totals').includesText('Tip', 'a percentage tip shows'); + assert.dom('.storefront-order-totals').doesNotIncludeText('Delivery tip', 'a zero delivery tip is left out'); + }); +}); diff --git a/tests/integration/components/storefront/truck/card-test.js b/tests/integration/components/storefront/truck/card-test.js index b70ac89f..57c6a197 100644 --- a/tests/integration/components/storefront/truck/card-test.js +++ b/tests/integration/components/storefront/truck/card-test.js @@ -1,7 +1,7 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; import { setupIntl } from 'ember-intl/test-support'; -import { render } from '@ember/test-helpers'; +import { render, click } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; import { initialize } from '@fleetbase/fleetops-data/instance-initializers/register-shared-resource-descriptors'; @@ -59,4 +59,16 @@ module('Integration | Component | storefront/truck/card', function (hooks) { assert.dom('[data-test-truck-area]').hasText('No service area'); assert.dom('[data-test-truck-vehicle] [data-test-resource-pill-title]').hasText('No vehicle assigned'); }); + + test('the title opens the truck, not the Fleet-Ops vehicle', async function (assert) { + const opened = []; + this.set('truck', { id: 't1', status: 'active', vehicle: { id: 'v1', display_name: 'Truck 10', online: true }, catalogs: [] }); + this.set('open', (truck) => opened.push(truck)); + + await render(hbs``); + await click('[data-test-truck-vehicle] [data-test-resource-pill-title]'); + + assert.strictEqual(opened.length, 1); + assert.strictEqual(opened[0], this.truck); + }); }); diff --git a/translations/en-us.yaml b/translations/en-us.yaml index 3e8f7b28..0a04f2a7 100644 --- a/translations/en-us.yaml +++ b/translations/en-us.yaml @@ -232,6 +232,15 @@ storefront: optionally-select-category: Optionally select a category to import products into select-category: Select Category incoming-order: + placed: Placed {ago} + items-count: "{count, plural, one {# item} other {# items}}" + paid-in-app: Paid in app + scheduled-for: For {date} + collects-from: Customer collects from + leaves-from: Leaves from + deliver-to: Deliver to + note: Note from the customer + scan-hint: Scan or print for handover assigned: Driver Assigned not-assigned: No driver assigned no-phone: No phone From d6fa8b46a621c9f101ad14a1e12661b53ebff7ee Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 12 Oct 2026 21:05:18 +0800 Subject: [PATCH 184/193] fix(order panel): the status sits under the tracking number with the network or store badge beside it --- .../storefront/order/panel-header.hbs | 27 ++++++++++--------- addon/styles/storefront-engine.css | 18 ++++++++----- 2 files changed, 26 insertions(+), 19 deletions(-) diff --git a/addon/components/storefront/order/panel-header.hbs b/addon/components/storefront/order/panel-header.hbs index b585bc2c..6b4c4279 100644 --- a/addon/components/storefront/order/panel-header.hbs +++ b/addon/components/storefront/order/panel-header.hbs @@ -7,7 +7,21 @@
    {{or @resource.tracking_number.tracking_number @resource.tracking @resource.public_id}} - +
    + {{! Under the tracking number: the status, then the network (or store) the order came through. }} +
    + + {{#if @resource.meta.storefront_network}} + + + {{@resource.meta.storefront_network}} + + {{else if @resource.meta.storefront}} + + + {{@resource.meta.storefront}} + + {{/if}}
    @@ -16,17 +30,6 @@
    - {{#if @resource.meta.storefront_network}} - - - {{@resource.meta.storefront_network}} - - {{else if @resource.meta.storefront}} - - - {{@resource.meta.storefront}} - - {{/if}} {{#if (or @resource.tracking_number.tracking_number @resource.tracking)}} {{@resource.public_id}} {{/if}} diff --git a/addon/styles/storefront-engine.css b/addon/styles/storefront-engine.css index dcc8f4ac..b5c7f325 100644 --- a/addon/styles/storefront-engine.css +++ b/addon/styles/storefront-engine.css @@ -1835,13 +1835,6 @@ body[data-theme='dark'] .storefront-order-panel-header { white-space: nowrap; } -.storefront-order-panel-heading__badges { - display: flex; - flex-direction: column; - align-items: flex-end; - gap: 0.25rem; -} - .storefront-order-panel-heading__fulfillment-badge .status-badge-inner-wrap { min-width: 4.5rem; justify-content: center; @@ -1902,6 +1895,17 @@ body[data-theme='dark'] .storefront-order-panel-heading__title { color: #f9fafb; } +/* The status and the network or store badge, on one line under the tracking number */ +.storefront-order-panel-heading__badges { + display: flex; + flex-wrap: nowrap; + align-items: center; + gap: 0.375rem; + min-width: 0; + margin-top: 0.25rem; + overflow: hidden; +} + .storefront-order-panel-heading__meta { display: flex; flex-wrap: nowrap; From 4c4cceb1d1fba30537af038120f5fbec1830c9d4 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 12 Oct 2026 21:09:18 +0800 Subject: [PATCH 185/193] fix(order panel): the header badges sit right under the tracking number --- addon/styles/storefront-engine.css | 1 - 1 file changed, 1 deletion(-) diff --git a/addon/styles/storefront-engine.css b/addon/styles/storefront-engine.css index b5c7f325..1c26d067 100644 --- a/addon/styles/storefront-engine.css +++ b/addon/styles/storefront-engine.css @@ -1902,7 +1902,6 @@ body[data-theme='dark'] .storefront-order-panel-heading__title { align-items: center; gap: 0.375rem; min-width: 0; - margin-top: 0.25rem; overflow: hidden; } From 28dbd5f19d283fc4a8327d425d97c0bd123c8c5b Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 12 Oct 2026 22:01:57 +0800 Subject: [PATCH 186/193] fix(api): the public product category filter works again (the console's uuid filter now applies to internal requests only); network stores_count is computed lazily and only for internal requests; order activity flows keyed by code keep their codes and count reached steps without timestamps --- server/src/Http/Filter/ProductFilter.php | 7 +++++++ server/src/Http/Resources/Network.php | 2 +- server/src/Support/OrderActivityFlow.php | 10 ++++++---- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/server/src/Http/Filter/ProductFilter.php b/server/src/Http/Filter/ProductFilter.php index 944071db..bff2a655 100644 --- a/server/src/Http/Filter/ProductFilter.php +++ b/server/src/Http/Filter/ProductFilter.php @@ -5,6 +5,7 @@ use Fleetbase\Storefront\Models\Product; use Fleetbase\Http\Filter\Filter; use Fleetbase\Models\Category; +use Fleetbase\Support\Http; class ProductFilter extends Filter { @@ -39,6 +40,12 @@ public function status(string|array $status) public function category(?string $category) { + // The console filters by category uuid; the public API's `category` is a public id, + // which the v1 ProductController resolves itself. + if (!Http::isInternalRequest($this->request)) { + return; + } + $this->builder->where('category_uuid', $category); } diff --git a/server/src/Http/Resources/Network.php b/server/src/Http/Resources/Network.php index 5e89ae74..51de09df 100644 --- a/server/src/Http/Resources/Network.php +++ b/server/src/Http/Resources/Network.php @@ -44,7 +44,7 @@ public function toArray($request) 'rating' => $this->rating, 'online' => $this->online, 'stores' => $this->when($request->boolean('with_stores') || $request->inArray('with', 'stores'), Store::collection($this->stores)), - 'stores_count' => $this->when(Http::isInternalRequest(), (int) ($this->stores_count ?? $this->stores()->count())), + 'stores_count' => $this->when(Http::isInternalRequest(), fn () => (int) ($this->stores_count ?? $this->stores()->count())), 'categories' => $this->when($request->boolean('with_categories') || $request->inArray('with', 'categories'), Category::collection($this->categories)), 'gateways' => $this->when($request->boolean('with_gateways') || $request->inArray('with', 'gateways'), Gateway::collection($this->gateways)), 'notification_channels' => $this->when($request->boolean('with_notification_channels') || $request->inArray('with', 'notification_channels'), NotificationChannel::collection($this->notificationChannels)), diff --git a/server/src/Support/OrderActivityFlow.php b/server/src/Support/OrderActivityFlow.php index cc4ba0cf..1aee79e9 100644 --- a/server/src/Support/OrderActivityFlow.php +++ b/server/src/Support/OrderActivityFlow.php @@ -74,7 +74,8 @@ public static function resolve(array $flow, ?string $status, array $history, cal foreach ($flow as $key => $activity) { if (is_array($activity)) { $code = static::code($activity['code'] ?? $key); - $activities[$code] = $activity; + // A flow keyed by code may leave `code` out of the activity itself. + $activities[$code] = $activity + ['code' => $code]; } } @@ -83,19 +84,20 @@ public static function resolve(array $flow, ?string $status, array $history, cal $reached = []; foreach ($history as $entry) { $code = static::code($entry['code'] ?? null); - if ($code === '' || isset($reached[$code])) { + // Reached activities may have no timestamp, so check for the key, not a value. + if ($code === '' || array_key_exists($code, $reached)) { continue; } $reached[$code] = $entry['at'] ?? null; $path[] = ['code' => $code, 'label' => $entry['label'] ?? null]; } - if (isset($activities['created']) && !isset($reached['created'])) { + if (isset($activities['created']) && !array_key_exists('created', $reached)) { array_unshift($path, ['code' => 'created', 'label' => null]); $reached['created'] = $createdAt; } $current = static::code($status); - if ($current !== '' && !isset($reached[$current])) { + if ($current !== '' && !array_key_exists($current, $reached)) { $path[] = ['code' => $current, 'label' => null]; $reached[$current] = null; } From 8ec445df252f0ffcadfe9c70a714c3fb40325c1f Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 12 Oct 2026 22:01:58 +0800 Subject: [PATCH 187/193] test: bring the PHP suite in line with the release: socket auth needs auth_enabled under core-api 1.6.69; QPay v2 callback replies, payment payloads and double publish; sandbox e-barimt invoice code; real UUIDs for store location places; schema fixtures for catalogs, invites meta, product availability and promotion redemption soft deletes; cache and session leaks between tests; review sort direction and the order index checkout id --- .../CheckoutBoundaryContractsTest.php | 80 ++++++++++++++---- .../Controllers/OrderChatControllerTest.php | 3 + .../ProductApiControllerContractsTest.php | 28 +++++++ .../ReviewAndOrderControllerContractsTest.php | 4 +- .../ServiceQuoteControllerContractsTest.php | 82 +++++++++++++++++-- .../SmallControllerContractsTest.php | 64 +++++++++++++-- .../Http/Resources/ProductResourceTest.php | 17 ++-- .../RemainingResourceContractsTest.php | 5 +- .../Resources/SimpleResourceContractsTest.php | 12 ++- .../FrameworkIntegrationContractsTest.php | 11 ++- server/tests/Unit/Models/CartTest.php | 3 + .../Unit/Support/StorefrontSocketTest.php | 5 +- 12 files changed, 272 insertions(+), 42 deletions(-) diff --git a/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php b/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php index 64a232dc..350c2f3e 100644 --- a/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php +++ b/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php @@ -492,7 +492,10 @@ public static function initializedCheckoutResponse(Checkout $checkout, array $da function enableCheckoutSocketAuth(bool $enabled = true): void { - config(['broadcasting.connections.socketcluster.auth_key' => $enabled ? 'checkout-socket-test-key-0123456789abcdef' : null]); + config([ + 'broadcasting.connections.socketcluster.auth_enabled' => $enabled, + 'broadcasting.connections.socketcluster.auth_key' => $enabled ? 'checkout-socket-test-key-0123456789abcdef' : null, + ]); } afterEach(function () { @@ -602,6 +605,7 @@ function createCheckoutBoundarySchema(): void $table->string('checkout_uuid')->nullable(); $table->string('status')->default('reserved'); $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); }); $schema->create('stores', function ($table) { $table->increments('id'); @@ -2157,7 +2161,7 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode ->and(CheckoutQPayStub::$sandboxUsed)->toBeTrue() ->and(CheckoutQPayStub::$authenticated)->toBeTrue() ->and(CheckoutQPayStub::$invoiceKind)->toBe('ebarimt') - ->and(CheckoutQPayStub::$invoiceArguments[0])->toBe('TEST_INVOICE') + ->and(CheckoutQPayStub::$invoiceArguments[0])->toBe('TEST_EB_INVOICE') ->and(CheckoutQPayStub::$invoiceArguments[7])->not->toBeEmpty(); $gateway->forceFill([ @@ -2833,9 +2837,14 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode createCheckoutBoundarySchema(); $controller = new CheckoutController(); - $missingId = $controller->captureQPayCallback(Request::create('/checkout/qpay', 'POST')); - $unknown = $controller->captureQPayCallback(Request::create('/checkout/qpay', 'POST', [ + // QPay itself is always answered with HTTP 200 and the body SUCCESS; `respond=1` returns the details as JSON. + $qpayReply = $controller->captureQPayCallback(Request::create('/checkout/qpay', 'POST')); + $missingId = $controller->captureQPayCallback(Request::create('/checkout/qpay', 'POST', [ + 'respond' => true, + ])); + $unknown = $controller->captureQPayCallback(Request::create('/checkout/qpay', 'POST', [ 'checkout' => 'checkout_missing', + 'respond' => true, ])); Model::getConnectionResolver()->connection('mysql')->table('checkouts')->insert([ 'public_id' => 'checkout_public', @@ -2843,8 +2852,12 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode ]); $missingGateway = $controller->captureQPayCallback(Request::create('/checkout/qpay', 'POST', [ 'checkout' => 'checkout_public', + 'respond' => true, ])); + expect($qpayReply->getStatusCode())->toBe(200) + ->and($qpayReply->getData(true))->toBe('SUCCESS'); + expect($missingId->getData(true))->toBe([ 'error' => 'CHECKOUT_ID_MISSING', 'checkout' => null, @@ -2944,39 +2957,63 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode 'test' => 'error', ])); + // The verified payment is recorded on the checkout once, as a summary of QPay's row. + $recordedPayment = (array) Checkout::where('uuid', 'checkout_uuid')->firstOrFail()->getOption('qpay_payment'); + + expect($recordedPayment)->toMatchArray([ + 'payment_id' => 'payment_checkout', + 'payment_status' => 'PAID', + 'payment_amount' => 2500, + 'payment_currency' => 'MNT', + 'payment_wallet' => 'QPay', + ])->and($recordedPayment)->toHaveKey('recorded_at'); + expect($missingInvoice->getData(true))->toBe([ 'error' => 'MISSING_INVOICE_ID', 'checkout' => 'checkout_abcdefgh', 'payment' => null, ])->and($notFound->getData(true))->toBe([ - 'error' => 'PAYMENT_NOTFOUND', + 'error' => 'PAYMENT_NOT_PAID', 'checkout' => 'checkout_abcdefgh', 'payment' => null, ])->and($paid->getData(true)['payment']['payment_id'])->toBe('payment_checkout') ->and($providerFailure->getData(true))->toBe(['error' => 'QPay unavailable']) - ->and($silentProviderFailure->getData(true))->toBe([]) + // QPay's own call is answered SUCCESS even when checking the payment failed. + ->and($silentProviderFailure->getData(true))->toBe('SUCCESS') ->and($sandboxSuccess->getData(true)['payment'])->not->toBeNull() ->and($sandboxError->getData(true)['error']['error'])->toBe('PAYMENT_NOT_PAID') ->and(CheckoutQPayStub::$sandboxUsed)->toBeTrue() ->and(CheckoutQPayStub::$authenticated)->toBeTrue() ->and(Fleetbase\Support\SocketCluster\SocketClusterService::$published)->toBe([ - // The realtime payload carries what a storefront client acts on — never the raw payment row. + // The realtime payload carries what a storefront client acts on: the recorded payment + // summary, never the raw payment row. A paid callback publishes as soon as the payment is + // verified and again once the order is created. ['checkout.checkout_abcdefgh', [ 'checkout' => 'checkout_abcdefgh', 'status' => 'completed', 'order' => ['id' => 'order_abcdefgh'], + 'payment' => $recordedPayment, 'error' => null, ]], ['checkout.checkout_abcdefgh', [ 'checkout' => 'checkout_abcdefgh', 'status' => 'completed', 'order' => ['id' => 'order_abcdefgh'], + 'payment' => $recordedPayment, + 'error' => null, + ]], + ['checkout.checkout_abcdefgh', [ + 'checkout' => 'checkout_abcdefgh', + 'status' => 'completed', + 'order' => ['id' => 'order_abcdefgh'], + 'payment' => $recordedPayment, 'error' => null, ]], ['checkout.checkout_abcdefgh', [ 'checkout' => 'checkout_abcdefgh', 'status' => 'failed', 'order' => null, + 'payment' => null, 'error' => [ 'error' => 'PAYMENT_NOT_PAID', 'message' => 'Payment has not been paid!', @@ -3045,6 +3082,7 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode 'checkout' => 'checkout_pending', 'status' => 'paid', 'order' => null, + 'payment' => null, 'error' => null, ]], ]); @@ -3097,12 +3135,17 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode ])); $published = Fleetbase\Support\SocketCluster\SocketClusterService::$published; - expect($published)->toHaveCount(1) - ->and($published[0][0])->toBe('checkout.checkout_abcdefgh') - ->and(array_keys($published[0][1]))->toBe(['checkout', 'status', 'order', 'error']) - ->and($published[0][1]['status'])->toBe('completed') - ->and($published[0][1]['order']['id'])->toBe('order_status') - ->and($published[0][1]['error'])->toBeNull(); + // Published as soon as the payment is verified, then again once the order is created. + expect($published)->toHaveCount(2); + + foreach ($published as [$channel, $payload]) { + expect($channel)->toBe('checkout.checkout_abcdefgh') + ->and(array_keys($payload))->toBe(['checkout', 'status', 'order', 'payment', 'error']) + ->and($payload['status'])->toBe('completed') + ->and($payload['order']['id'])->toBe('order_status') + ->and($payload['payment']['payment_id'])->toBe('payment_checkout') + ->and($payload['error'])->toBeNull(); + } }); test('initialized checkouts carry a checkout-scoped socket token only while socket auth is enabled', function () { @@ -3606,9 +3649,12 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode 'sku' => 'COFFEE-1', 'price' => 1000, ]); + // Store pickups are found by place uuid, which is a real UUID (see Storefront::getStoreFromLocation()). + $originUuid = '11111111-1111-4111-8111-111111111111'; + $originTwoUuid = '22222222-2222-4222-8222-222222222222'; $connection->table('places')->insert([ [ - 'uuid' => 'origin_uuid', + 'uuid' => $originUuid, 'public_id' => 'place_abcdefgh', 'company_uuid' => 'company_uuid', 'name' => 'Store pickup', @@ -3620,7 +3666,7 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode 'name' => 'Customer dropoff', ], [ - 'uuid' => 'origin_two_uuid', + 'uuid' => $originTwoUuid, 'public_id' => 'place_qrstuvwx', 'company_uuid' => 'company_uuid', 'name' => 'Second store pickup', @@ -3631,14 +3677,14 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode 'uuid' => 'store_location_uuid', 'public_id' => 'store_location_abcdefgh', 'store_uuid' => 'store_uuid', - 'place_uuid' => 'origin_uuid', + 'place_uuid' => $originUuid, 'name' => 'Main location', ], [ 'uuid' => 'store_location_two_uuid', 'public_id' => 'store_location_ijklmnop', 'store_uuid' => 'store_two_uuid', - 'place_uuid' => 'origin_two_uuid', + 'place_uuid' => $originTwoUuid, 'name' => 'Second location', ], ]); diff --git a/server/tests/Unit/Http/Controllers/OrderChatControllerTest.php b/server/tests/Unit/Http/Controllers/OrderChatControllerTest.php index 9b206316..c03416a0 100644 --- a/server/tests/Unit/Http/Controllers/OrderChatControllerTest.php +++ b/server/tests/Unit/Http/Controllers/OrderChatControllerTest.php @@ -433,6 +433,9 @@ public function clear(): void } app()->instance(Spatie\Activitylog\ActivityLogStatus::class, new Spatie\Activitylog\ActivityLogStatus(new Illuminate\Config\Repository(['activitylog' => ['enabled' => false]]))); $this->notifications = fakeChatNotifications(); + // The driver-assigned notice is sent once per order and driver, remembered in the + // cache, which the test app shares across tests. + Illuminate\Support\Facades\Cache::flush(); }); afterEach(function () { diff --git a/server/tests/Unit/Http/Controllers/ProductApiControllerContractsTest.php b/server/tests/Unit/Http/Controllers/ProductApiControllerContractsTest.php index d39b3b72..46ccec66 100644 --- a/server/tests/Unit/Http/Controllers/ProductApiControllerContractsTest.php +++ b/server/tests/Unit/Http/Controllers/ProductApiControllerContractsTest.php @@ -605,6 +605,34 @@ function productApiRequest(string $uri = '/products', string $method = 'GET', ar ->and($resource->resource->first()->uuid)->toBe('drink_uuid'); }); +test('product category filter takes a uuid on the console and leaves the public id to the public API', function () { + $filtered = function (string $uri) { + $request = productApiRequest('/' . $uri, 'GET', ['category' => 'category_drinks']); + $request->setRouteResolver(fn () => new class($uri) { + public array $action = []; + + public function __construct(private string $routeUri) + { + } + + public function uri(): string + { + return $this->routeUri; + } + }); + + return (new Fleetbase\Storefront\Http\Filter\ProductFilter($request))->apply((new Fleetbase\Storefront\Models\Product())->newQuery()); + }; + + $internal = $filtered('int/v1/storefront/products'); + $public = $filtered('v1/storefront/products'); + + expect($internal->toSql())->toContain('"category_uuid" = ?') + ->and($internal->getBindings())->toContain('category_drinks') + ->and($public->toSql())->not->toContain('"category_uuid" = ?') + ->and($public->getBindings())->not->toContain('category_drinks'); +}); + test('product update find and delete expose stable not-found responses', function () { createProductApiControllerSchema(); session(['company' => null]); diff --git a/server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php b/server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php index 8941e1e0..4b136d58 100644 --- a/server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php +++ b/server/tests/Unit/Http/Controllers/ReviewAndOrderControllerContractsTest.php @@ -447,9 +447,11 @@ function createReviewControllerSchema(): void $resource = (new ReviewController())->query($request); + // Highest first is review_two (5) then review_one (1); the other store's review (4) would + // sit between them if ownership were not applied, so offset 1 must land on review_one. expect($request->input('sort'))->toBe(['-rating', '-created_at']) ->and($resource->resource)->toHaveCount(1) - ->and($resource->resource->first()->uuid)->toBe('review_two_uuid'); + ->and($resource->resource->first()->uuid)->toBe('review_one_uuid'); }); test('network review listing and counts validate membership and apply pagination', function () { diff --git a/server/tests/Unit/Http/Controllers/ServiceQuoteControllerContractsTest.php b/server/tests/Unit/Http/Controllers/ServiceQuoteControllerContractsTest.php index 27c53bc2..6e653cff 100644 --- a/server/tests/Unit/Http/Controllers/ServiceQuoteControllerContractsTest.php +++ b/server/tests/Unit/Http/Controllers/ServiceQuoteControllerContractsTest.php @@ -212,6 +212,8 @@ function createServiceQuoteLookupSchema(): void $table->string('sku')->nullable(); $table->integer('price')->nullable(); $table->integer('sale_price')->nullable(); + $table->boolean('is_available')->default(true); + $table->string('status')->nullable(); $table->timestamp('deleted_at')->nullable(); }); $schema->create('files', function ($table) { @@ -283,6 +285,25 @@ function createServiceQuoteLookupSchema(): void }); } +/** A place location as MySQL returns it: SRID then little-endian WKB (x = longitude). */ +function serviceQuotePointWkb(float $latitude, float $longitude): string +{ + return pack('L', 0) . pack('CLdd', 1, 1, $longitude, $latitude); +} + +/** A product the cart lines below can order, so the quote's availability check passes. */ +function seedServiceQuoteProduct(): void +{ + Model::getConnectionResolver()->connection('mysql')->table('products')->insert([ + 'uuid' => 'product_uuid', + 'public_id' => 'product_public', + 'company_uuid' => 'company_uuid', + 'name' => 'Coffee', + 'currency' => 'USD', + 'price' => 900, + ]); +} + function seedServiceQuoteMarketplace(string $storeUuid = 'store_uuid', string $storePublicId = 'store_public'): void { $connection = Model::getConnectionResolver()->connection('mysql'); @@ -565,11 +586,21 @@ public function getPlaceFromId(string|array $id): ?Fleetbase\FleetOps\Models\Pla 'options' => '{}', ]); seedServiceQuoteMarketplace(); + seedServiceQuoteProduct(); $connection->table('store_locations')->insert([ - 'uuid' => 'location_uuid', - 'public_id' => 'store_location_public', - 'store_uuid' => 'store_uuid', - 'place_uuid' => 'origin_uuid', + [ + 'uuid' => 'location_uuid', + 'public_id' => 'store_location_public', + 'store_uuid' => 'store_uuid', + 'place_uuid' => 'origin_uuid', + ], + [ + // exists, but its store is not in the marketplace + 'uuid' => 'outside_location_uuid', + 'public_id' => 'store_location_outside', + 'store_uuid' => 'store_outside_uuid', + 'place_uuid' => 'origin_uuid', + ], ]); $connection->table('carts')->insert([ [ @@ -580,6 +611,7 @@ public function getPlaceFromId(string|array $id): ?Fleetbase\FleetOps\Models\Pla 'currency' => 'USD', 'items' => json_encode([ [ + 'product_id' => 'product_public', 'store_id' => 'store_public', 'store_location_id' => 'store_location_public', ], @@ -597,6 +629,7 @@ public function getPlaceFromId(string|array $id): ?Fleetbase\FleetOps\Models\Pla 'currency' => 'USD', 'items' => json_encode([ [ + 'product_id' => 'product_public', 'store_id' => 'store_public', 'store_location_id' => null, ], @@ -614,6 +647,9 @@ public function getPlaceFromId(string|array $id): ?Fleetbase\FleetOps\Models\Pla 'currency' => 'USD', 'items' => json_encode([ [ + 'id' => 'cart_item_missing', + 'product_id' => 'product_public', + 'name' => 'Coffee', 'store_id' => 'store_public', 'store_location_id' => 'store_location_missing', ], @@ -623,6 +659,24 @@ public function getPlaceFromId(string|array $id): ?Fleetbase\FleetOps\Models\Pla 'created_at' => now(), 'updated_at' => now(), ], + [ + 'uuid' => 'outside_location_cart_uuid', + 'public_id' => 'outside_location_cart_public', + 'company_uuid' => 'company_uuid', + 'unique_identifier' => 'outside-location-cart', + 'currency' => 'USD', + 'items' => json_encode([ + [ + 'product_id' => 'product_public', + 'store_id' => 'store_public', + 'store_location_id' => 'store_location_outside', + ], + ]), + 'events' => '[]', + 'expires_at' => now()->addHour(), + 'created_at' => now(), + 'updated_at' => now(), + ], [ 'uuid' => 'mismatched_location_cart_uuid', 'public_id' => 'mismatched_location_cart_public', @@ -631,6 +685,7 @@ public function getPlaceFromId(string|array $id): ?Fleetbase\FleetOps\Models\Pla 'currency' => 'USD', 'items' => json_encode([ [ + 'product_id' => 'product_public', 'store_id' => 'store_different', 'store_location_id' => 'store_location_public', ], @@ -680,6 +735,10 @@ public function getPlaceFromId(string|array $id): ?Fleetbase\FleetOps\Models\Pla 'destination' => 'place_destination', 'cart' => 'missing-location-cart', ])); + $outsideLocation = $controller->fromCartForNetwork(GetServiceQuoteFromCart::create('/quote', 'POST', [ + 'destination' => 'place_destination', + 'cart' => 'outside-location-cart', + ])); $mismatchedLocation = $controller->fromCartForNetwork(GetServiceQuoteFromCart::create('/quote', 'POST', [ 'destination' => 'place_destination', 'cart' => 'mismatched-location-cart', @@ -687,8 +746,15 @@ public function getPlaceFromId(string|array $id): ?Fleetbase\FleetOps\Models\Pla expect($default->getData(true))->toBe(['error' => 'Integrated vendor not found!']) ->and($explicit->getData(true))->toBe(['error' => 'Integrated vendor not found!']) + // a store location that no longer exists makes its line unavailable ->and($missingLocation->getStatusCode())->toBe(422) ->and($missingLocation->getData(true))->toBe([ + 'errors' => ['Some items in your cart are no longer available.'], + 'code' => 'cart_items_unavailable', + 'items' => [['id' => 'cart_item_missing', 'product_id' => 'product_public', 'name' => 'Coffee']], + ]) + ->and($outsideLocation->getStatusCode())->toBe(422) + ->and($outsideLocation->getData(true))->toBe([ 'error' => 'One or more store locations are unavailable for this marketplace.', ]) ->and($mismatchedLocation->getStatusCode())->toBe(422) @@ -701,6 +767,7 @@ public function getPlaceFromId(string|array $id): ?Fleetbase\FleetOps\Models\Pla createServiceQuoteLookupSchema(); $connection = Model::getConnectionResolver()->connection('mysql'); seedServiceQuoteMarketplace(); + seedServiceQuoteProduct(); $connection->table('places')->insert([ 'uuid' => 'origin_uuid', 'public_id' => 'place_origin', @@ -718,7 +785,7 @@ public function getPlaceFromId(string|array $id): ?Fleetbase\FleetOps\Models\Pla 'unique_identifier' => 'network-cart', 'currency' => 'USD', 'items' => json_encode([ - ['store_location_id' => 'store_location_public'], + ['product_id' => 'product_public', 'store_location_id' => 'store_location_public'], ]), 'events' => '[]', 'expires_at' => now()->addHour(), @@ -973,8 +1040,9 @@ public function getPlaceFromId(string|array $id): ?Fleetbase\FleetOps\Models\Pla $connection = Model::getConnectionResolver()->connection('mysql'); seedServiceQuoteMarketplace(); $connection->table('places')->insert([ - ['uuid' => 'place_one_uuid', 'public_id' => 'place_one'], - ['uuid' => 'place_two_uuid', 'public_id' => 'place_two'], + // The route is measured store to store, so the stores' places need coordinates. + ['uuid' => 'place_one_uuid', 'public_id' => 'place_one', 'location' => serviceQuotePointWkb(47.92, 106.91)], + ['uuid' => 'place_two_uuid', 'public_id' => 'place_two', 'location' => serviceQuotePointWkb(47.93, 106.93)], ]); $connection->table('store_locations')->insert([ [ diff --git a/server/tests/Unit/Http/Controllers/SmallControllerContractsTest.php b/server/tests/Unit/Http/Controllers/SmallControllerContractsTest.php index 4577e8cf..ab947dd5 100644 --- a/server/tests/Unit/Http/Controllers/SmallControllerContractsTest.php +++ b/server/tests/Unit/Http/Controllers/SmallControllerContractsTest.php @@ -232,7 +232,8 @@ public function __construct(public Company $company) }); test('public catalog and food truck queries are empty without a storefront store context', function () { - session(['storefront_store' => null]); + // A network context also scopes food trucks now, so neither may leak in from an earlier test. + session(['storefront_store' => null, 'storefront_network' => null]); $catalogs = (new CatalogController())->query(Request::create('/catalogs')); $foodTrucks = (new FoodTruckController())->query(Request::create('/food-trucks')); @@ -294,23 +295,61 @@ public function __construct(public Company $company) test('public food truck query and lookup return records for the active store', function () { $connection = Model::getConnectionResolver()->connection('mysql'); $schema = $connection->getSchemaBuilder(); - $schema->dropIfExists('food_trucks'); + foreach (['food_trucks', 'stores', 'catalogs', 'catalog_subjects'] as $tableName) { + $schema->dropIfExists($tableName); + } $schema->create('food_trucks', function ($table) { $table->increments('id'); $table->string('uuid'); $table->string('public_id')->nullable(); - $table->string('store_uuid'); + $table->string('store_uuid')->nullable(); + $table->string('network_uuid')->nullable(); $table->string('vehicle_uuid')->nullable(); $table->string('status')->nullable(); $table->timestamps(); $table->softDeletes(); }); + // Trucks are served with their store and the catalogs they sell. + $schema->create('stores', function ($table) { + $table->increments('id'); + $table->string('uuid'); + $table->string('public_id')->nullable(); + $table->string('name')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + $schema->create('catalogs', function ($table) { + $table->increments('id'); + $table->string('uuid'); + $table->string('public_id')->nullable(); + $table->string('name')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + $schema->create('catalog_subjects', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('catalog_uuid'); + $table->string('subject_uuid'); + $table->string('subject_type'); + $table->timestamps(); + $table->softDeletes(); + }); $connection->table('food_trucks')->insert([ ['uuid' => 'truck_first', 'public_id' => 'food_truck_first', 'store_uuid' => 'store_uuid', 'status' => 'online'], ['uuid' => 'truck_second', 'public_id' => 'food_truck_second', 'store_uuid' => 'store_uuid', 'status' => 'offline'], ['uuid' => 'truck_other', 'public_id' => 'food_truck_other', 'store_uuid' => 'other_store', 'status' => 'online'], ]); - session(['storefront_store' => 'store_uuid']); + $connection->table('stores')->insert([ + ['uuid' => 'store_uuid', 'public_id' => 'store_abcdefgh', 'name' => 'Taco Stand'], + ]); + $connection->table('catalogs')->insert([ + ['uuid' => 'catalog_lunch', 'public_id' => 'catalog_lunch', 'name' => 'Lunch'], + ]); + $connection->table('catalog_subjects')->insert([ + ['catalog_uuid' => 'catalog_lunch', 'subject_uuid' => 'truck_second', 'subject_type' => Fleetbase\Storefront\Models\FoodTruck::class], + ]); + session(['storefront_store' => 'store_uuid', 'storefront_network' => null]); $request = Request::create('/food-trucks', 'GET', ['limit' => 1, 'offset' => 1]); $request->setLaravelSession(request()->session()); $controller = new FoodTruckController(); @@ -320,6 +359,8 @@ public function __construct(public Company $company) expect($results->resource)->toHaveCount(1) ->and($results->resource->first()->uuid)->toBe('truck_second') + ->and($results->resource->first()->store->name)->toBe('Taco Stand') + ->and($results->resource->first()->catalogs->pluck('name')->all())->toBe(['Lunch']) ->and($found->resource->uuid)->toBe('truck_first'); }); @@ -413,7 +454,7 @@ public function __construct(public Company $company) $table->timestamp('deleted_at')->nullable(); }); } - session(['company' => 'company_uuid']); + session(['company' => 'company_uuid', 'storefront_store' => null, 'storefront_network' => null]); $response = (new StoreController())->lookup('missing_public_id'); @@ -899,6 +940,7 @@ public function get(string $key): ?string $table->string('reason')->nullable(); $table->string('uri')->nullable(); $table->string('code')->nullable(); + $table->text('meta')->nullable(); $table->timestamp('expires_at')->nullable(); $table->timestamps(); $table->softDeletes(); @@ -942,7 +984,17 @@ public function get(string $key): ?string $response = (new NetworkController())->sendInvites('network_uuid', $request); $invite = Invite::query()->firstOrFail(); - expect($response->getData(true))->toBe(['status' => 'ok']) + $data = $response->getData(true); + + expect($data['status'])->toBe('ok') + ->and($data['invitations'])->toHaveCount(1) + ->and($data['invitations'][0])->toMatchArray([ + 'email' => 'store@example.test', + 'status' => 'pending', + 'require_approval' => false, + 'via' => 'email', + ]) + ->and($invite->meta)->toBe(['status' => 'pending', 'require_approval' => false]) ->and($invite->company_uuid)->toBe('company_uuid') ->and($invite->created_by_uuid)->toBe('user_uuid') ->and($invite->subject_uuid)->toBe('network_uuid') diff --git a/server/tests/Unit/Http/Resources/ProductResourceTest.php b/server/tests/Unit/Http/Resources/ProductResourceTest.php index 29300c13..b05a8d5c 100644 --- a/server/tests/Unit/Http/Resources/ProductResourceTest.php +++ b/server/tests/Unit/Http/Resources/ProductResourceTest.php @@ -114,9 +114,12 @@ function storefrontProductResourceFixture(): ProductModel 'slug' => 'cold-brew-kit', 'translations' => ['mn' => ['name' => 'Cold Brew']], 'youtube_urls' => ['https://youtube.test/demo'], + // As selected by Product::catalogsCountQuery() on a console product list. + 'catalogs_count' => 2, 'created_at' => '2026-01-01', 'updated_at' => '2026-01-02', ]); + $product->setRelation('store', null); $product->setRelation('category', (object) ['public_id' => 'category_123']); $product->setRelation('primaryImage', $image); $product->setRelation('addonCategories', collect([$addonCategory])); @@ -156,6 +159,7 @@ function storefrontProductResourceFixture(): ProductModel 'store_uuid', 'files', 'type', + 'catalogs_count', ])->and($data['addon_categories'][0])->toMatchArray([ 'id' => 'addon_category_123', 'name' => 'Packaging', @@ -183,12 +187,13 @@ function storefrontProductResourceFixture(): ProductModel $data = (new ProductResource(storefrontProductResourceFixture()))->resolve($request); expect($data)->toMatchArray([ - 'id' => 10, - 'uuid' => 'product_uuid', - 'public_id' => 'product_123', - 'company_uuid' => 'company_uuid', - 'store_uuid' => 'store_uuid', - 'type' => 'product', + 'id' => 10, + 'uuid' => 'product_uuid', + 'public_id' => 'product_123', + 'company_uuid' => 'company_uuid', + 'store_uuid' => 'store_uuid', + 'catalogs_count' => 2, + 'type' => 'product', ])->and($data)->toHaveKey('files') ->and($data)->not->toHaveKeys(['images', 'videos']) ->and($data['addon_categories'][0])->toMatchArray([ diff --git a/server/tests/Unit/Http/Resources/RemainingResourceContractsTest.php b/server/tests/Unit/Http/Resources/RemainingResourceContractsTest.php index d83b7258..79212314 100644 --- a/server/tests/Unit/Http/Resources/RemainingResourceContractsTest.php +++ b/server/tests/Unit/Http/Resources/RemainingResourceContractsTest.php @@ -261,7 +261,9 @@ public function uri(): string ], 'storefront_network_id' => 'network_public', 'currency' => 'MNT', - 'checkout_id' => 'excluded_from_index', + // Kept on the index so the console can group a multi-store checkout's orders. + 'checkout_id' => 'checkout_public', + 'secret' => 'excluded_from_index', ]), ]); $indexResource = new IndexOrderResource($indexOrder); @@ -292,6 +294,7 @@ public function uri(): string ], 'storefront_network_id' => 'network_public', 'currency' => 'MNT', + 'checkout_id' => 'checkout_public', ])->and($detailNormalized)->toBe(['currency' => 'USD']) ->and($indexNormalized)->toBe(['currency' => 'MNT']); }); diff --git a/server/tests/Unit/Http/Resources/SimpleResourceContractsTest.php b/server/tests/Unit/Http/Resources/SimpleResourceContractsTest.php index 749f9cf2..b6052dcf 100644 --- a/server/tests/Unit/Http/Resources/SimpleResourceContractsTest.php +++ b/server/tests/Unit/Http/Resources/SimpleResourceContractsTest.php @@ -368,5 +368,15 @@ public function uri(): string 'is_network' => true, 'is_store' => false, ])->and($data)->toHaveKeys(['stores', 'categories']) - ->and($data)->not->toHaveKeys(['gateways', 'notification_channels', 'uuid']); + ->and($data)->not->toHaveKeys(['gateways', 'notification_channels', 'uuid', 'stores_count']); + + // The console reads the member store count, taken from a selected count when present. + $network->forceFill(['stores_count' => '3']); + $internal = (new NetworkResource($network))->resolve(setSimpleStorefrontResourceRoute('int/v1/storefront/networks')); + + expect($internal)->toMatchArray([ + 'id' => 7, + 'uuid' => 'network_uuid', + 'stores_count' => 3, + ]); }); diff --git a/server/tests/Unit/Integration/FrameworkIntegrationContractsTest.php b/server/tests/Unit/Integration/FrameworkIntegrationContractsTest.php index 4047da60..ab4b68e1 100644 --- a/server/tests/Unit/Integration/FrameworkIntegrationContractsTest.php +++ b/server/tests/Unit/Integration/FrameworkIntegrationContractsTest.php @@ -564,13 +564,20 @@ public function setAddonCategories(array $addonCategories = []): Product ]); $invite->setRelation('subject', $network); $invite->setRelation('createdBy', $sender); + $consoleConfig = config('fleetbase.console'); + config(['fleetbase.console' => ['host' => 'console.fleetbase.test', 'secure' => true]]); - $mail = (new StorefrontNetworkInvite($invite))->build(); + try { + $mail = (new StorefrontNetworkInvite($invite))->build(); + } finally { + config(['fleetbase.console' => $consoleConfig]); + } + // The link opens the storefront engine's join route (console.storefront.networks.join). expect($mail->invite)->toBe($invite) ->and($mail->network)->toBe($network) ->and($mail->sender)->toBe($sender) - ->and($mail->url)->toContain('join/network/invite-code') + ->and($mail->url)->toBe('https://console.fleetbase.test/storefront/networks/join/invite-code') ->and($mail->subject)->toBe('You have been invited to join Coffee Network!') ->and($mail->to[0]['address'])->toBe('merchant@example.test'); }); diff --git a/server/tests/Unit/Models/CartTest.php b/server/tests/Unit/Models/CartTest.php index 41d65754..9b4ce51f 100644 --- a/server/tests/Unit/Models/CartTest.php +++ b/server/tests/Unit/Models/CartTest.php @@ -20,6 +20,9 @@ public static function findProduct(string $id): ?Product function createCartLifecycleSchema(): void { + // These carts belong to no company; Cart::retrieve() scopes to the session's company, + // which an earlier test in the run may have left set. + session(['company' => null]); $schema = Capsule::schema('mysql'); $schema->dropIfExists('store_locations'); $schema->dropIfExists('stores'); diff --git a/server/tests/Unit/Support/StorefrontSocketTest.php b/server/tests/Unit/Support/StorefrontSocketTest.php index e0e606a8..fc2ff690 100644 --- a/server/tests/Unit/Support/StorefrontSocketTest.php +++ b/server/tests/Unit/Support/StorefrontSocketTest.php @@ -16,7 +16,10 @@ function storefrontSocketEnable(bool $enabled = true): void { - config(['broadcasting.connections.socketcluster.auth_key' => $enabled ? 'storefront-socket-test-key-0123456789abcdef' : null]); + config([ + 'broadcasting.connections.socketcluster.auth_enabled' => $enabled, + 'broadcasting.connections.socketcluster.auth_key' => $enabled ? 'storefront-socket-test-key-0123456789abcdef' : null, + ]); } function storefrontSocketSchema(): void From cbe9a4827cf5b8eaa4b11fa1508e0bbe47a2f3b7 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 12 Oct 2026 22:57:53 +0800 Subject: [PATCH 188/193] fix(throttle): the public Storefront API allows 120 requests a minute per device by default (was 600 in config, 500 as the middleware fallback); the fallback and config share one default, and the README documents it --- README.md | 4 ++-- server/config/storefront.php | 9 +++++++-- server/src/Http/Middleware/ThrottleRequests.php | 7 ++++++- .../Unit/Http/Middleware/ThrottleRequestsTest.php | 12 ++++++++++-- 4 files changed, 25 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index d36b49ef..13e45c53 100644 --- a/README.md +++ b/README.md @@ -238,8 +238,8 @@ Important environment variables include: | --- | --- | | `STOREFRONT_DB_CONNECTION` | Database connection name for Storefront models. | | `STOREFRONT_BYPASS_VERIFICATION_CODE` | Development bypass code for Storefront app verification flows. | -| `STOREFRONT_THROTTLE_REQUESTS_PER_MINUTE` | Public Storefront API request limit. | -| `STOREFRONT_THROTTLE_DECAY_MINUTES` | Public Storefront API throttle decay window. | +| `STOREFRONT_THROTTLE_REQUESTS_PER_MINUTE` | Public Storefront API requests allowed per device (store key + client IP) per window. Default `120`. | +| `STOREFRONT_THROTTLE_DECAY_MINUTES` | Public Storefront API throttle window in minutes. Default `1`. | For customer app configuration, see the [Storefront App configuration docs](https://www.fleetbase.io/docs/storefront/app/configuration). diff --git a/server/config/storefront.php b/server/config/storefront.php index 3f6b48f1..fadb70c8 100644 --- a/server/config/storefront.php +++ b/server/config/storefront.php @@ -71,9 +71,14 @@ |-------------------------------------------------------------------------- | Throttle/Rate-Limiting |-------------------------------------------------------------------------- + | + | Requests a single device (store key + client IP) may make to the public + | Storefront API per decay window. A customer app browsing, carting and + | checking out stays well under 120 a minute; raise it per deployment with + | STOREFRONT_THROTTLE_REQUESTS_PER_MINUTE if an integration needs more. */ 'throttle' => [ - 'max_attempts' => env('STOREFRONT_THROTTLE_REQUESTS_PER_MINUTE', 600), - 'decay_minutes' => env('STOREFRONT_THROTTLE_DECAY_MINUTES', 1), + 'max_attempts' => (int) env('STOREFRONT_THROTTLE_REQUESTS_PER_MINUTE', 120), + 'decay_minutes' => (int) env('STOREFRONT_THROTTLE_DECAY_MINUTES', 1), ], ]; diff --git a/server/src/Http/Middleware/ThrottleRequests.php b/server/src/Http/Middleware/ThrottleRequests.php index d4e6a9dd..d198b74f 100644 --- a/server/src/Http/Middleware/ThrottleRequests.php +++ b/server/src/Http/Middleware/ThrottleRequests.php @@ -6,9 +6,14 @@ class ThrottleRequests extends ThrottleRequestsMiddleware { + /** + * The limit when the package config is not loaded; keep in step with config/storefront.php. + */ + public const DEFAULT_MAX_ATTEMPTS = 120; + public function handle($request, \Closure $next, $maxAttempts = null, $decayMinutes = null, $prefix = '') { - $maxAttempts = config('storefront.throttle.max_attempts', 500); + $maxAttempts = config('storefront.throttle.max_attempts', static::DEFAULT_MAX_ATTEMPTS); $decayMinutes = config('storefront.throttle.decay_minutes', 1); return parent::handle($request, $next, $maxAttempts, $decayMinutes, $prefix); diff --git a/server/tests/Unit/Http/Middleware/ThrottleRequestsTest.php b/server/tests/Unit/Http/Middleware/ThrottleRequestsTest.php index 6f5f96ca..aa4997d1 100644 --- a/server/tests/Unit/Http/Middleware/ThrottleRequestsTest.php +++ b/server/tests/Unit/Http/Middleware/ThrottleRequestsTest.php @@ -23,8 +23,16 @@ public function getDomain(): string ); expect($response->getData(true))->toBe(['status' => 'ok']) - ->and($response->headers->get('X-RateLimit-Limit'))->toBe('500') - ->and($response->headers->get('X-RateLimit-Remaining'))->toBe('499'); + ->and($response->headers->get('X-RateLimit-Limit'))->toBe('120') + ->and($response->headers->get('X-RateLimit-Remaining'))->toBe('119'); +}); + +test('the package config and the middleware fallback agree on the default limit', function () { + // The config file calls env(), which this test app does not load, so read its default. + $config = file_get_contents(__DIR__ . '/../../../../config/storefront.php'); + + expect($config)->toContain("env('STOREFRONT_THROTTLE_REQUESTS_PER_MINUTE', " . ThrottleRequests::DEFAULT_MAX_ATTEMPTS . ')') + ->and($config)->toContain("env('STOREFRONT_THROTTLE_DECAY_MINUTES', 1)"); }); test('storefront throttle keeps stores and devices behind the same proxy in separate buckets', function () { From 80ed7ffab879606b1a606fc8b8da6d56b6bcdf1e Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 12 Oct 2026 23:01:13 +0800 Subject: [PATCH 189/193] fix(security): customers can only read their own record GET storefront/v1/customers and GET storefront/v1/customers/{id} were authorized by the storefront key alone. That key ships inside every app build, so anyone could list the company's customers (names, emails, phones) or read any of them by id. Both now require the Customer-Token: the list returns only the signed-in customer, and find returns 401 without a token and 403 for another customer. The app only calls find to refresh the signed-in customer, with that customer's token, so it keeps working. --- .../Controllers/v1/CustomerController.php | 25 +++++++- .../CustomerControllerContractsTest.php | 61 ++++++++++++++++++- 2 files changed, 81 insertions(+), 5 deletions(-) diff --git a/server/src/Http/Controllers/v1/CustomerController.php b/server/src/Http/Controllers/v1/CustomerController.php index 01ea14f2..121146dc 100644 --- a/server/src/Http/Controllers/v1/CustomerController.php +++ b/server/src/Http/Controllers/v1/CustomerController.php @@ -470,24 +470,39 @@ public function update($id, UpdateContactRequest $request) /** * Query for Storefront Customer resources. * + * The storefront key is public (it ships inside every app build), so it can't be what + * authorizes reading customers: this used to list every customer of the company to + * anyone holding it. Only the customer signed in with the Customer-Token is returned. + * * @return \Fleetbase\Http\Resources\Storefront\Customer */ public function query(Request $request) { - $results = Contact::queryWithRequestCached($request, function (&$query, $request) { - $query->where(['type' => 'customer', 'company_uuid' => session('company')]); + $customer = $this->authenticatedCustomerForRequest($request); + if (!$customer) { + return response()->apiError('Not authorized to view customers.', 401); + } + + $results = Contact::queryWithRequestCached($request, function (&$query) use ($customer) { + $query->where(['type' => 'customer', 'company_uuid' => session('company'), 'uuid' => $customer->uuid]); }); return Customer::collection($results); } /** - * Finds a single Storefront Product resources. + * Finds a single Storefront Customer resource: only the customer signed in with the + * Customer-Token. A storefront key alone used to read any customer by id. * * @return \Fleetbase\Http\Resources\Storefront\Customer */ public function find($id) { + $customer = $this->authenticatedCustomerForRequest(request()); + if (!$customer) { + return response()->apiError('Not authorized to view customer.', 401); + } + if (Str::startsWith($id, 'customer')) { $id = Str::replaceFirst('customer', 'contact', $id); } @@ -499,6 +514,10 @@ public function find($id) return response()->apiError('Customer resource not found.'); } + if ($contact->uuid !== $customer->uuid) { + return response()->apiError('Not authorized to view customer.', 403); + } + // response the customer resource return new Customer($contact); } diff --git a/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php b/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php index f8f19d7e..7c88a332 100644 --- a/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php +++ b/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php @@ -1987,7 +1987,7 @@ public function sendNow($notifiables, $notification) expect($response->getData(true))->toBe(['error' => 'Unable to verify code.']); }); -test('customer query scopes records to customer type and active company', function () { +test('customer query returns only the customer signed in with the customer token', function () { createCustomerControllerContactsSchema(); $connection = Model::getConnectionResolver()->connection('mysql'); $connection->table('contacts')->insert([ @@ -1997,6 +1997,12 @@ public function sendNow($notifiables, $notification) 'company_uuid' => 'company_uuid', 'type' => 'customer', ], + [ + 'uuid' => 'customer_two', + 'public_id' => 'contact_two', + 'company_uuid' => 'company_uuid', + 'type' => 'customer', + ], [ 'uuid' => 'vendor_one', 'public_id' => 'contact_vendor', @@ -2013,12 +2019,63 @@ public function sendNow($notifiables, $notification) session(['company' => 'company_uuid']); $request = bindUnauthenticatedCustomerRequest(); - $resource = (new CustomerController())->query($request); + $controller = new AuthenticatedCustomerControllerStub(); + $controller->authenticatedCustomer = Fleetbase\FleetOps\Models\Contact::where('uuid', 'customer_one')->firstOrFail(); + $resource = $controller->query($request); expect($resource->resource)->toHaveCount(1) ->and($resource->resource->first()->uuid)->toBe('customer_one'); }); +test('customer query refuses a storefront key without a customer token', function () { + createCustomerControllerContactsSchema(); + Model::getConnectionResolver()->connection('mysql')->table('contacts')->insert([ + 'uuid' => 'customer_one', + 'public_id' => 'contact_one', + 'company_uuid' => 'company_uuid', + 'type' => 'customer', + ]); + session(['company' => 'company_uuid']); + + $response = (new AuthenticatedCustomerControllerStub())->query(bindUnauthenticatedCustomerRequest()); + + expect($response->getStatusCode())->toBe(401) + ->and($response->getData(true))->toBe(['error' => 'Not authorized to view customers.']); +}); + +test('customer find refuses without a customer token and for another customer', function () { + createCustomerControllerContactsSchema(); + Model::getConnectionResolver()->connection('mysql')->table('contacts')->insert([ + [ + 'uuid' => 'customer_one', + 'public_id' => 'contact_one', + 'company_uuid' => 'company_uuid', + 'type' => 'customer', + ], + [ + 'uuid' => 'customer_two', + 'public_id' => 'contact_two', + 'company_uuid' => 'company_uuid', + 'type' => 'customer', + ], + ]); + session(['company' => 'company_uuid']); + $controller = new AuthenticatedCustomerControllerStub(); + + $anonymous = $controller->find('customer_one'); + + $controller->authenticatedCustomer = Fleetbase\FleetOps\Models\Contact::where('uuid', 'customer_one')->firstOrFail(); + $other = $controller->find('customer_two'); + $own = $controller->find('customer_one'); + + expect($anonymous->getStatusCode())->toBe(401) + ->and($anonymous->getData(true))->toBe(['error' => 'Not authorized to view customer.']) + ->and($other->getStatusCode())->toBe(403) + ->and($other->getData(true))->toBe(['error' => 'Not authorized to view customer.']) + ->and($own)->toBeInstanceOf(Fleetbase\Storefront\Http\Resources\Customer::class) + ->and($own->resource->uuid)->toBe('customer_one'); +}); + test('customer authentication seam resolves the token customer from the storefront request', function () { $probe = new BaseAuthenticatedCustomerProbe(); From a8db8e6fe3ce6e781e54ac612079b15fb781a529 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 13 Oct 2026 06:15:30 +0800 Subject: [PATCH 190/193] fix(security): a QPay checkout can't be captured until QPay has confirmed the payment POST checkouts/capture verified payment only for Stripe. A QPay checkout's token, which the customer's app receives from checkouts/before, was enough to create an order with no payment. Capture now refuses a QPay checkout with 402 unless a payment is recorded on it (options.qpay_payment). Only paidQPayPayment() records one: a /v2/payment/check row with payment_status PAID whose amount covers the invoice (QPay API v2). The QPay callback and the checkouts/status check record it before they capture, so paid checkouts still become orders. Cash and Stripe are unchanged. --- .../Controllers/v1/CheckoutController.php | 9 ++ .../CheckoutBoundaryContractsTest.php | 86 +++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/server/src/Http/Controllers/v1/CheckoutController.php b/server/src/Http/Controllers/v1/CheckoutController.php index dee6e4bd..a79ced4a 100644 --- a/server/src/Http/Controllers/v1/CheckoutController.php +++ b/server/src/Http/Controllers/v1/CheckoutController.php @@ -1386,6 +1386,15 @@ public function captureOrder(CaptureOrderRequest $request) $currency = $checkout->currency ?? $cart->getCurrency(); $store = $about; + // QPay: an order only for a payment QPay has confirmed. paidQPayPayment() records one + // on the checkout (options.qpay_payment) when QPay's /v2/payment/check returns a PAID + // row covering the invoice amount; the QPay callback and the status check both record + // it before they capture. A capture request carrying only the checkout token, which + // the customer's app holds, must not create an unpaid order. + if ($gateway && $gateway->isQPayGateway && !$checkout->getOption('qpay_payment')) { + return response()->apiError('QPay payment has not been completed.', 402); + } + if ($gateway && $gateway->isStripeGateway) { $stripeVerification = $this->verifyStripePaymentForCheckout($checkout, $gateway, $customer, (int) $amount, (string) $currency); if ($stripeVerification instanceof JsonResponse) { diff --git a/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php b/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php index 350c2f3e..7f097322 100644 --- a/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php +++ b/server/tests/Unit/Http/Controllers/CheckoutBoundaryContractsTest.php @@ -1942,6 +1942,92 @@ public function request($method, $absUrl, $headers, $params, $hasFile, $apiMode } }); +test('qpay capture creates no order until a verified qpay payment is recorded on the checkout', function () { + createCheckoutBoundarySchema(); + $connection = Model::getConnectionResolver()->connection('mysql'); + $connection->table('stores')->insert([ + 'uuid' => 'store_uuid', + 'public_id' => 'store_public', + 'company_uuid' => 'company_uuid', + 'key' => 'store_key', + 'name' => 'Test store', + 'currency' => 'MNT', + ]); + $connection->table('gateways')->insert([ + 'uuid' => 'qpay_gateway_uuid', + 'owner_uuid' => 'store_uuid', + 'code' => 'qpay', + 'type' => 'qpay', + 'sandbox' => true, + 'config' => json_encode(['username' => 'TEST_MERCHANT', 'password' => 'secret', 'invoice_code' => 'TEST_INVOICE']), + ]); + $connection->table('contacts')->insert([ + 'uuid' => 'customer_uuid', + 'public_id' => 'contact_public', + 'company_uuid' => 'company_uuid', + 'type' => 'customer', + ]); + $connection->table('carts')->insert([ + 'uuid' => 'cart_uuid', + 'public_id' => 'cart_public', + 'company_uuid' => 'company_uuid', + 'unique_identifier' => 'qpay-cart', + 'currency' => 'MNT', + 'items' => json_encode([ + ['id' => 'line_one', 'quantity' => 1, 'subtotal' => 25000], + ]), + 'events' => '[]', + 'expires_at' => now()->addHour(), + ]); + $connection->table('checkouts')->insert([ + 'uuid' => 'checkout_uuid', + 'public_id' => 'checkout_public', + 'token' => 'checkout_token', + 'cart_uuid' => 'cart_uuid', + 'gateway_uuid' => 'qpay_gateway_uuid', + 'owner_uuid' => 'customer_uuid', + 'owner_type' => Contact::class, + 'amount' => 25000, + 'currency' => 'MNT', + 'is_pickup' => true, + 'options' => json_encode(['is_pickup' => true]), + ]); + session([ + 'company' => 'company_uuid', + 'storefront_key' => 'store_key', + 'storefront_store' => 'store_uuid', + ]); + $controller = new CheckoutController(); + $request = fn () => CaptureOrderRequest::create('/checkout/capture', 'POST', [ + 'token' => 'checkout_token', + 'transactionDetails' => ['transaction_id' => 'untrusted_client_id', 'payment_status' => 'PAID'], + ]); + + // Only the checkout token, as the customer's app holds it: no payment recorded. + $unpaid = $controller->captureOrder($request()); + + // Recorded by the callback or status check once QPay confirmed the payment. + Checkout::where('uuid', 'checkout_uuid')->firstOrFail()->updateOption('qpay_payment', [ + 'payment_id' => 'qpay_payment_1', + 'payment_status' => 'PAID', + 'payment_amount' => '25000.00', + ]); + // Past the guard, capture goes on to build the order, which this schema doesn't fully + // support; anything after the guard (a response or an exception) shows it let the + // paid checkout through. + try { + $paid = $controller->captureOrder($request()); + } catch (Throwable $afterGuard) { + $paid = null; + } + session(['storefront_key' => null, 'storefront_store' => null]); + + expect($unpaid->getStatusCode())->toBe(402) + ->and($unpaid->getData(true))->toBe(['error' => 'QPay payment has not been completed.']) + ->and(Checkout::where('uuid', 'checkout_uuid')->firstOrFail()->order_uuid)->toBeNull() + ->and($paid instanceof Illuminate\Http\JsonResponse && $paid->getStatusCode() === 402)->toBeFalse(); +}); + test('stripe checkout retries missing customers and contains ephemeral-key and intent failures', function () { createCheckoutBoundarySchema(); $connection = Model::getConnectionResolver()->connection('mysql'); From fb6730e12868136b2c0fbec10249d9d2a0f6c102 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 13 Oct 2026 06:20:03 +0800 Subject: [PATCH 191/193] fix(network): suspended and pending members are hidden from every storefront API Network stores and products already listed active members only, but the rest of the public API still treated any membership as one: store lookup, locations and search, store-locations and tags, categories (store and with_stores), food trucks, promotions, reviews, service quotes and checkout validation. A suspended store's locations, tags, trucks, promotions and reviews kept showing, and a cart with its products could still be quoted and checked out. Store::scopeActiveInNetwork() is the one definition of an active member (network_stores status active, not deleted) and every one of those queries now uses it, or Network::activeStores(). Customer notification history keeps notifications from stores that were later suspended. --- .../Controllers/v1/CategoryController.php | 6 ++--- .../Controllers/v1/CheckoutController.php | 3 ++- .../Controllers/v1/FoodTruckController.php | 2 +- .../Http/Controllers/v1/NetworkController.php | 4 +-- .../Controllers/v1/PromotionController.php | 2 +- .../Http/Controllers/v1/ReviewController.php | 7 +++-- .../Controllers/v1/ServiceQuoteController.php | 4 +-- .../Http/Controllers/v1/StoreController.php | 6 ++--- server/src/Models/Product.php | 4 +-- server/src/Models/Store.php | 10 +++++++ .../NetworkApiControllerContractsTest.php | 26 +++++++++++++++++++ .../tests/Unit/Models/StoreBehaviorTest.php | 26 +++++++++++++++++++ 12 files changed, 80 insertions(+), 20 deletions(-) diff --git a/server/src/Http/Controllers/v1/CategoryController.php b/server/src/Http/Controllers/v1/CategoryController.php index 4d46be93..63d3aa8a 100644 --- a/server/src/Http/Controllers/v1/CategoryController.php +++ b/server/src/Http/Controllers/v1/CategoryController.php @@ -7,6 +7,7 @@ use Fleetbase\Storefront\Http\Resources\Category as StorefrontCategory; use Fleetbase\Storefront\Http\Resources\Product as ProductResource; use Fleetbase\Storefront\Models\Product; +use Fleetbase\Storefront\Models\NetworkStore; use Fleetbase\Storefront\Models\Store; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; @@ -66,9 +67,7 @@ public function query(Request $request) if ($request->filled('store')) { $store = Store::where([ 'public_id' => $request->input('store'), - ])->whereHas('networks', function ($q) { - $q->where('network_uuid', session('storefront_network')); - })->first(); + ])->activeInNetwork(session('storefront_network'))->first(); if ($store) { $results = Category::queryWithRequestCached($request, function (&$query) use ($store, $request) { @@ -132,6 +131,7 @@ public function query(Request $request) $categoryIds = $results->pluck('uuid'); $stores = Store::whereHas('networks', function ($q) use ($categoryIds) { $q->where('network_uuid', session('storefront_network')); + $q->where('network_stores.status', NetworkStore::STATUS_ACTIVE); $q->whereIn('category_uuid', $categoryIds); })->with(['networks' => function ($q) use ($categoryIds) { $q->where('network_uuid', session('storefront_network')); diff --git a/server/src/Http/Controllers/v1/CheckoutController.php b/server/src/Http/Controllers/v1/CheckoutController.php index a79ced4a..84997543 100644 --- a/server/src/Http/Controllers/v1/CheckoutController.php +++ b/server/src/Http/Controllers/v1/CheckoutController.php @@ -150,8 +150,9 @@ protected function validateMarketplaceCart(Cart $cart) return response()->apiError('Every marketplace cart item must identify its store.', 422); } + // Active members only: a suspended or pending store can't take orders through the network. $stores = Store::whereIn('public_id', $storeIds) - ->whereHas('networks', fn ($query) => $query->where('network_uuid', $networkUuid)) + ->activeInNetwork($networkUuid) ->get() ->keyBy('public_id'); diff --git a/server/src/Http/Controllers/v1/FoodTruckController.php b/server/src/Http/Controllers/v1/FoodTruckController.php index 6dec6d23..2a29b929 100644 --- a/server/src/Http/Controllers/v1/FoodTruckController.php +++ b/server/src/Http/Controllers/v1/FoodTruckController.php @@ -63,7 +63,7 @@ protected static function storeUuidsForStorefront(): array if (session('storefront_network')) { $network = Network::where('uuid', session('storefront_network'))->first(); - return $network ? $network->stores()->pluck('stores.uuid')->all() : []; + return $network ? $network->activeStores()->pluck('stores.uuid')->all() : []; } return []; diff --git a/server/src/Http/Controllers/v1/NetworkController.php b/server/src/Http/Controllers/v1/NetworkController.php index 62fc7433..45c2a8fe 100644 --- a/server/src/Http/Controllers/v1/NetworkController.php +++ b/server/src/Http/Controllers/v1/NetworkController.php @@ -246,7 +246,7 @@ public function storeLocations(Request $request) if ($storeUuid) { $q->where('uuid', $storeUuid); } else { - $q->whereHas('networks', fn ($networkQuery) => $networkQuery->where('network_uuid', $networkUuid)); + $q->activeInNetwork($networkUuid); } if (!empty($tagged)) { @@ -317,7 +317,7 @@ public function tags(Request $request) ->whereHas('locations') ->when($storeUuid, fn ($query) => $query->where('uuid', $storeUuid)) ->when($networkUuid, function ($query) use ($networkUuid) { - $query->whereHas('networks', fn ($networkQuery) => $networkQuery->where('network_uuid', $networkUuid)); + $query->activeInNetwork($networkUuid); }) ->get(); diff --git a/server/src/Http/Controllers/v1/PromotionController.php b/server/src/Http/Controllers/v1/PromotionController.php index 0b4ef1fe..c9840468 100644 --- a/server/src/Http/Controllers/v1/PromotionController.php +++ b/server/src/Http/Controllers/v1/PromotionController.php @@ -65,7 +65,7 @@ protected function publicPromotions(?string $storeId = null): Builder $owners = [$about?->uuid]; if ($about?->is_network) { - $stores = Store::whereHas('networks', fn ($query) => $query->where('network_uuid', $about->uuid)) + $stores = Store::activeInNetwork($about->uuid) ->when($storeId, fn ($query) => $query->where('public_id', $storeId)) ->pluck('uuid') ->all(); diff --git a/server/src/Http/Controllers/v1/ReviewController.php b/server/src/Http/Controllers/v1/ReviewController.php index dbce143a..3a12b07f 100644 --- a/server/src/Http/Controllers/v1/ReviewController.php +++ b/server/src/Http/Controllers/v1/ReviewController.php @@ -25,7 +25,7 @@ protected function resolveStoreForContext(string $id): ?Store return Store::where('public_id', $id) ->when(session('storefront_store'), fn ($query) => $query->where('uuid', session('storefront_store'))) ->when(session('storefront_network'), function ($query) { - $query->whereHas('networks', fn ($networkQuery) => $networkQuery->where('network_uuid', session('storefront_network'))); + $query->activeInNetwork(session('storefront_network')); }) ->first(); } @@ -43,8 +43,7 @@ protected function findScopedReview(string $id): ?Review }); }) ->when(session('storefront_network'), function ($query) { - $memberStoreUuids = Store::select('uuid') - ->whereHas('networks', fn ($networkQuery) => $networkQuery->where('network_uuid', session('storefront_network'))); + $memberStoreUuids = Store::select('uuid')->activeInNetwork(session('storefront_network')); $memberProductUuids = Product::select('uuid')->whereIn('store_uuid', clone $memberStoreUuids); $query->where(function ($subjectQuery) use ($memberStoreUuids, $memberProductUuids) { $subjectQuery->whereIn('subject_uuid', $memberStoreUuids) @@ -64,7 +63,7 @@ protected function subjectBelongsToContext($subject): bool return Product::where('uuid', $subject->uuid) ->when(session('storefront_store'), fn ($query) => $query->where('store_uuid', session('storefront_store'))) ->when(session('storefront_network'), function ($query) { - $query->whereHas('store.networks', fn ($networkQuery) => $networkQuery->where('network_uuid', session('storefront_network'))); + $query->whereHas('store', fn ($storeQuery) => $storeQuery->activeInNetwork(session('storefront_network'))); }) ->exists(); } diff --git a/server/src/Http/Controllers/v1/ServiceQuoteController.php b/server/src/Http/Controllers/v1/ServiceQuoteController.php index fd9857d5..ba12bb36 100644 --- a/server/src/Http/Controllers/v1/ServiceQuoteController.php +++ b/server/src/Http/Controllers/v1/ServiceQuoteController.php @@ -229,7 +229,7 @@ public function fromCartForNetwork(GetServiceQuoteFromCart $request) ->unique() ->values(); $defaultLocationIds = Store::whereIn('public_id', $defaultStoreIds) - ->whereHas('networks', fn ($query) => $query->where('network_uuid', session('storefront_network'))) + ->activeInNetwork(session('storefront_network')) ->with(['locations' => fn ($query) => $query->orderBy('id')]) ->get() ->mapWithKeys(fn ($store) => [$store->public_id => data_get($store, 'locations.0.public_id')]); @@ -252,7 +252,7 @@ public function fromCartForNetwork(GetServiceQuoteFromCart $request) } $storeLocations = StoreLocation::whereIn('public_id', $storeLocationIds) - ->whereHas('store.networks', fn ($query) => $query->where('network_uuid', session('storefront_network'))) + ->whereHas('store', fn ($query) => $query->activeInNetwork(session('storefront_network'))) ->with(['store', 'place']) ->get() ->sortBy(fn ($storeLocation) => $storeLocationIds->search($storeLocation->public_id)) diff --git a/server/src/Http/Controllers/v1/StoreController.php b/server/src/Http/Controllers/v1/StoreController.php index bc1ae560..c9738e10 100644 --- a/server/src/Http/Controllers/v1/StoreController.php +++ b/server/src/Http/Controllers/v1/StoreController.php @@ -53,7 +53,7 @@ public function lookup(?string $id) $store = Store::where('public_id', $id) ->when(session('storefront_network'), function ($query) { - $query->whereHas('networks', fn ($networkQuery) => $networkQuery->where('network_uuid', session('storefront_network'))); + $query->activeInNetwork(session('storefront_network')); }) ->when(session('storefront_store'), function ($query) { $activeStore = session('storefront_store'); @@ -91,7 +91,7 @@ public function locations(Request $request) $q->where('public_id', $request->input('store')); if (session('storefront_network')) { - $q->whereHas('networks', fn ($networkQuery) => $networkQuery->where('network_uuid', session('storefront_network'))); + $q->activeInNetwork(session('storefront_network')); } if (session('storefront_store')) { @@ -121,7 +121,7 @@ public function location(string $id, Request $request) $query->where('public_id', $storeId)->orWhere('uuid', $storeId); }) ->when(session('storefront_network'), function ($query) { - $query->whereHas('networks', fn ($networkQuery) => $networkQuery->where('network_uuid', session('storefront_network'))); + $query->activeInNetwork(session('storefront_network')); }) ->when(session('storefront_store'), function ($query) { $activeStore = session('storefront_store'); diff --git a/server/src/Models/Product.php b/server/src/Models/Product.php index 75921818..6d1ffce2 100644 --- a/server/src/Models/Product.php +++ b/server/src/Models/Product.php @@ -486,9 +486,7 @@ public static function findFromNetwork($search, $store = null, $limit = 20, $net $query->where('public_id', $store); } - $query->whereHas('networks', function ($networksQuery) use ($network) { - $networksQuery->where('network_uuid', $network); - }); + $query->activeInNetwork($network); }) ->search($search) ->whereIsAvailable(1) diff --git a/server/src/Models/Store.php b/server/src/Models/Store.php index 793d8c09..bcee8220 100644 --- a/server/src/Models/Store.php +++ b/server/src/Models/Store.php @@ -309,6 +309,16 @@ public function catalogs(): \Illuminate\Database\Eloquent\Relations\MorphToMany ); } + /** + * Stores that sell through the network: active members only. Suspended and pending + * (not yet approved) members stay in the console but are hidden from every storefront + * API, the same way `Network::activeStores()` hides them from the app and carts. + */ + public function scopeActiveInNetwork($query, ?string $networkUuid) + { + return $query->whereHas('networks', fn ($networkQuery) => $networkQuery->where('network_uuid', $networkUuid)->where('network_stores.status', NetworkStore::STATUS_ACTIVE)); + } + public function networks() { return $this->belongsToMany(Network::class, 'network_stores', 'store_uuid', 'network_uuid') diff --git a/server/tests/Unit/Http/Controllers/NetworkApiControllerContractsTest.php b/server/tests/Unit/Http/Controllers/NetworkApiControllerContractsTest.php index 2be29313..4c3a0182 100644 --- a/server/tests/Unit/Http/Controllers/NetworkApiControllerContractsTest.php +++ b/server/tests/Unit/Http/Controllers/NetworkApiControllerContractsTest.php @@ -720,3 +720,29 @@ function createNetworkApiControllerSchema(): void expect($resource->resource)->toHaveCount(1) ->and($resource->resource->first()->uuid)->toBe('active_uuid'); }); + +test('network tags come only from active members', function () { + createNetworkApiControllerSchema(); + $connection = Model::getConnectionResolver()->connection('mysql'); + $connection->table('stores')->insert([ + ['uuid' => 'active_uuid', 'tags' => json_encode(['food'])], + ['uuid' => 'suspended_uuid', 'tags' => json_encode(['suspended-only'])], + ['uuid' => 'pending_uuid', 'tags' => json_encode(['pending-only'])], + ]); + $connection->table('store_locations')->insert([ + ['uuid' => 'active_location', 'store_uuid' => 'active_uuid'], + ['uuid' => 'suspended_location', 'store_uuid' => 'suspended_uuid'], + ['uuid' => 'pending_location', 'store_uuid' => 'pending_uuid'], + ]); + $connection->table('networks')->insert(['uuid' => 'network_uuid']); + $connection->table('network_stores')->insert([ + ['network_uuid' => 'network_uuid', 'store_uuid' => 'active_uuid', 'status' => 'active'], + ['network_uuid' => 'network_uuid', 'store_uuid' => 'suspended_uuid', 'status' => 'suspended'], + ['network_uuid' => 'network_uuid', 'store_uuid' => 'pending_uuid', 'status' => 'pending'], + ]); + session(['storefront_network' => 'network_uuid', 'storefront_store' => null]); + + $response = (new NetworkController())->tags(Request::create('/network/tags')); + + expect($response->getData(true))->toBe(['food']); +}); diff --git a/server/tests/Unit/Models/StoreBehaviorTest.php b/server/tests/Unit/Models/StoreBehaviorTest.php index 0bfef9bc..7a8e9792 100644 --- a/server/tests/Unit/Models/StoreBehaviorTest.php +++ b/server/tests/Unit/Models/StoreBehaviorTest.php @@ -487,3 +487,29 @@ function createStoreBehaviorSchema(): void Store::flushResolvedNetworkIds(); }); + +test('active in network scope keeps only active members of that network', function () { + createStoreBehaviorSchema(); + $connection = Capsule::connection('mysql'); + $connection->table('stores')->insert([ + ['uuid' => 'active_uuid', 'name' => 'Active'], + ['uuid' => 'suspended_uuid', 'name' => 'Suspended'], + ['uuid' => 'pending_uuid', 'name' => 'Pending'], + ['uuid' => 'elsewhere_uuid', 'name' => 'Other network'], + ['uuid' => 'removed_uuid', 'name' => 'Removed'], + ]); + $connection->table('networks')->insert([ + ['uuid' => 'network_uuid', 'name' => 'Network'], + ['uuid' => 'other_network_uuid', 'name' => 'Other'], + ]); + $connection->table('network_stores')->insert([ + ['network_uuid' => 'network_uuid', 'store_uuid' => 'active_uuid', 'status' => 'active', 'deleted_at' => null], + ['network_uuid' => 'network_uuid', 'store_uuid' => 'suspended_uuid', 'status' => 'suspended', 'deleted_at' => null], + ['network_uuid' => 'network_uuid', 'store_uuid' => 'pending_uuid', 'status' => 'pending', 'deleted_at' => null], + ['network_uuid' => 'other_network_uuid', 'store_uuid' => 'elsewhere_uuid', 'status' => 'active', 'deleted_at' => null], + ['network_uuid' => 'network_uuid', 'store_uuid' => 'removed_uuid', 'status' => 'active', 'deleted_at' => now()], + ]); + + expect(Store::activeInNetwork('network_uuid')->pluck('uuid')->all())->toBe(['active_uuid']) + ->and(Store::activeInNetwork(null)->count())->toBe(0); +}); From 08926b68f8a6240aec2bc7681d740c2f03b18191 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 13 Oct 2026 12:36:45 +0800 Subject: [PATCH 192/193] feat(keys): rotate storefront store and network keys A leaked storefront key had no way to be replaced. Keys now come from a shared HasStorefrontKey trait using random_bytes (same store_/network_ + 32 hex format), and stores and networks can rotate them: - rotateKey() saves through the model so the API model cache invalidates; the old key fails SetStorefrontSession::isValidKey on the next request - POST storefront/int/v1/{stores,networks}/{id}/rotate-key, scoped to the session company and guarded by new `rotate-key` store/network permissions (re-run permission seeding on deploy) - storefront:rotate-key {id*} {--company=} {--force} for incident response: accepts uuids, public ids or current keys, resolves all targets before rotating, prints old and new keys - Console: "Rotate key" with a confirmation in the store and network API key panels; the new key is pushed into the record without dirtying it Tests cover key format, middleware rejection of the old key, the update event, endpoint company scoping, routes, permissions and the command. --- .../networks/index/network/settings.js | 11 ++ addon/controllers/settings/api.js | 10 +- addon/services/storefront.js | 35 ++++ addon/styles/storefront-engine.css | 15 ++ .../networks/index/network/settings.hbs | 4 + addon/templates/settings/api.hbs | 4 + server/src/Auth/Schemas/Storefront.php | 4 +- .../Console/Commands/RotateStorefrontKey.php | 118 ++++++++++++ .../Http/Controllers/NetworkController.php | 18 ++ .../src/Http/Controllers/StoreController.php | 18 ++ server/src/Models/Network.php | 17 +- server/src/Models/Store.php | 17 +- .../Providers/StorefrontServiceProvider.php | 1 + server/src/Traits/HasStorefrontKey.php | 41 +++++ server/src/routes.php | 8 +- .../Unit/Console/RotateStorefrontKeyTest.php | 144 +++++++++++++++ .../Unit/Models/StorefrontKeyRotationTest.php | 173 ++++++++++++++++++ .../Unit/Routes/StorefrontRoutesTest.php | 3 + translations/en-us.yaml | 7 + 19 files changed, 624 insertions(+), 24 deletions(-) create mode 100644 server/src/Console/Commands/RotateStorefrontKey.php create mode 100644 server/src/Traits/HasStorefrontKey.php create mode 100644 server/tests/Unit/Console/RotateStorefrontKeyTest.php create mode 100644 server/tests/Unit/Models/StorefrontKeyRotationTest.php diff --git a/addon/controllers/networks/index/network/settings.js b/addon/controllers/networks/index/network/settings.js index a0f83774..ea484dfa 100644 --- a/addon/controllers/networks/index/network/settings.js +++ b/addon/controllers/networks/index/network/settings.js @@ -56,6 +56,13 @@ export default class NetworksIndexNetworkSettingsController extends Controller { */ @service store; + /** + * Storefront service, which rotates the network key. + * + * @property {Service} storefront + */ + @service storefront; + /** * Proof of delivery methods. * @@ -185,6 +192,10 @@ export default class NetworksIndexNetworkSettingsController extends Controller { this.optionsRevision++; } + @action rotateKey() { + this.storefront.rotateKey(this.model); + } + @action async copyNetworkKey() { const key = this.model?.key; diff --git a/addon/controllers/settings/api.js b/addon/controllers/settings/api.js index c89b6fc6..479a9a13 100644 --- a/addon/controllers/settings/api.js +++ b/addon/controllers/settings/api.js @@ -1,3 +1,11 @@ import Controller from '@ember/controller'; +import { inject as service } from '@ember/service'; +import { action } from '@ember/object'; -export default class SettingsApiController extends Controller {} +export default class SettingsApiController extends Controller { + @service storefront; + + @action rotateKey() { + this.storefront.rotateKey(this.model); + } +} diff --git a/addon/services/storefront.js b/addon/services/storefront.js index 859aeb6b..3f643c01 100644 --- a/addon/services/storefront.js +++ b/addon/services/storefront.js @@ -524,4 +524,39 @@ export default class StorefrontService extends Service.extend(Evented) { }, }); } + + /** + * Issue a store or network a new storefront key after confirming that apps using the + * old key stop working at once. The new key is pushed into the record without making + * it dirty, so every place that shows the key updates and no save is pending. + */ + rotateKey(record) { + const type = record?.constructor?.modelName; + + if (!record?.id || !['store', 'network'].includes(type)) { + return; + } + + this.modalsManager.confirm({ + title: this.intl.t('storefront.settings.rotate-key.title', { type }), + body: this.intl.t('storefront.settings.rotate-key.body', { type, name: record.name }), + acceptButtonText: this.intl.t('storefront.settings.rotate-key.confirm'), + acceptButtonType: 'danger', + acceptButtonIcon: 'rotate', + confirm: async (modal) => { + modal.startLoading(); + + try { + const { key } = await this.fetch.post(`${type}s/${record.id}/rotate-key`, {}, { namespace: 'storefront/int/v1' }); + + this.store.push({ data: { id: record.id, type, attributes: { key } } }); + this.notifications.success(this.intl.t('storefront.settings.rotate-key.done', { type })); + modal.done(); + } catch (error) { + modal.stopLoading(); + this.notifications.serverError(error); + } + }, + }); + } } diff --git a/addon/styles/storefront-engine.css b/addon/styles/storefront-engine.css index 1c26d067..12753320 100644 --- a/addon/styles/storefront-engine.css +++ b/addon/styles/storefront-engine.css @@ -4473,6 +4473,21 @@ body[data-theme='dark'] .storefront-settings-section__lede { color: #6b7280; } +/* The rotate-key row under a storefront key: what rotating does, and the action. */ +.storefront-settings-key-rotation { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-top: 0.75rem; + font-size: 0.75rem; + color: #6b7280; +} + +body[data-theme='dark'] .storefront-settings-key-rotation { + color: #9ca3af; +} + .storefront-settings-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); diff --git a/addon/templates/networks/index/network/settings.hbs b/addon/templates/networks/index/network/settings.hbs index 7c3f765b..066b32cf 100644 --- a/addon/templates/networks/index/network/settings.hbs +++ b/addon/templates/networks/index/network/settings.hbs @@ -238,6 +238,10 @@ +
    +

    {{t "storefront.settings.rotate-key.help"}}

    +
    diff --git a/addon/templates/settings/api.hbs b/addon/templates/settings/api.hbs index 7513aaf3..0f4aca0e 100644 --- a/addon/templates/settings/api.hbs +++ b/addon/templates/settings/api.hbs @@ -5,6 +5,10 @@ +
    +

    {{t "storefront.settings.rotate-key.help"}}

    +
    diff --git a/server/src/Auth/Schemas/Storefront.php b/server/src/Auth/Schemas/Storefront.php index d732a1df..2da1ccff 100644 --- a/server/src/Auth/Schemas/Storefront.php +++ b/server/src/Auth/Schemas/Storefront.php @@ -73,7 +73,7 @@ class Storefront ], [ 'name' => 'network', - 'actions' => [], + 'actions' => ['rotate-key'], ], [ 'name' => 'network-store', @@ -85,7 +85,7 @@ class Storefront ], [ 'name' => 'store', - 'actions' => [], + 'actions' => ['rotate-key'], ], [ 'name' => 'store-location', diff --git a/server/src/Console/Commands/RotateStorefrontKey.php b/server/src/Console/Commands/RotateStorefrontKey.php new file mode 100644 index 00000000..ad2560ca --- /dev/null +++ b/server/src/Console/Commands/RotateStorefrontKey.php @@ -0,0 +1,118 @@ +argument('id'); + $companyId = $this->option('company'); + + if (empty($ids) && !$companyId) { + $this->error('Pass one or more store or network ids, or --company.'); + + return Command::INVALID; + } + + // Resolve everything before rotating anything, so a typo never leaves a partial rotation. + $targets = collect(); + foreach ($ids as $id) { + $target = $this->findStorefront($id); + + if (!$target) { + $this->error("No store or network found for '{$id}'."); + + return Command::FAILURE; + } + + $targets->push($target); + } + + if ($companyId) { + $company = Company::where('uuid', $companyId)->orWhere('public_id', $companyId)->first(); + + if (!$company) { + $this->error("Company '{$companyId}' not found."); + + return Command::FAILURE; + } + + $targets = $targets->merge($this->companyStorefronts($company->uuid)); + } + + $targets = $targets->unique(fn ($target) => get_class($target) . ':' . $target->uuid)->values(); + + if ($targets->isEmpty()) { + $this->warn('There are no stores or networks to rotate.'); + + return Command::SUCCESS; + } + + if (!$this->option('force') && !$this->confirm("Rotate {$targets->count()} storefront key(s)? Apps using the old keys stop working immediately.")) { + $this->warn('No keys were rotated.'); + + return Command::FAILURE; + } + + $rows = $targets->map(function ($target) { + $oldKey = $target->key; + $newKey = $target->rotateKey(); + + return [$target instanceof Network ? 'network' : 'store', $target->public_id, $target->name, $oldKey, $newKey]; + }); + + $this->table(['Type', 'ID', 'Name', 'Old key', 'New key'], $rows->all()); + $this->info("Rotated {$rows->count()} storefront key(s)."); + + return Command::SUCCESS; + } + + /** + * Find a store or network by uuid, public id or current key. + */ + protected function findStorefront(string $id): Store|Network|null + { + $match = fn ($query) => $query->where('uuid', $id)->orWhere('public_id', $id)->orWhere('key', $id); + + return Store::where($match)->first() ?? Network::where($match)->first(); + } + + /** + * Every store and network of a company. + */ + protected function companyStorefronts(string $companyUuid): Collection + { + return Store::where('company_uuid', $companyUuid)->get() + ->toBase() + ->merge(Network::where('company_uuid', $companyUuid)->get()); + } +} diff --git a/server/src/Http/Controllers/NetworkController.php b/server/src/Http/Controllers/NetworkController.php index 214c543b..67913be5 100644 --- a/server/src/Http/Controllers/NetworkController.php +++ b/server/src/Http/Controllers/NetworkController.php @@ -51,6 +51,24 @@ public function findNetwork(string $id) return response()->json($network); } + /** + * Issue the network a new storefront key. Apps using the old key stop working at once. + * + * @return \Illuminate\Http\Response + */ + public function rotateKey(string $id, Request $request) + { + $network = Network::where('company_uuid', $request->session()->get('company')) + ->where(fn ($query) => $query->where('uuid', $id)->orWhere('public_id', $id)) + ->first(); + + if (!$network) { + return response()->error('Network not found.', 404); + } + + return response()->json(['status' => 'ok', 'key' => $network->rotateKey()]); + } + /** * Add stores to a network. * diff --git a/server/src/Http/Controllers/StoreController.php b/server/src/Http/Controllers/StoreController.php index 91c65728..56f508c6 100644 --- a/server/src/Http/Controllers/StoreController.php +++ b/server/src/Http/Controllers/StoreController.php @@ -36,4 +36,22 @@ public function allStores(Request $request) return response()->json(['stores' => $stores]); } + + /** + * Issue the store a new storefront key. Apps using the old key stop working at once. + * + * @return \Illuminate\Http\Response + */ + public function rotateKey(string $id, Request $request) + { + $store = Store::where('company_uuid', $request->session()->get('company')) + ->where(fn ($query) => $query->where('uuid', $id)->orWhere('public_id', $id)) + ->first(); + + if (!$store) { + return response()->error('Store not found.', 404); + } + + return response()->json(['status' => 'ok', 'key' => $store->rotateKey()]); + } } diff --git a/server/src/Models/Network.php b/server/src/Models/Network.php index 6e4ae1b9..e444dc50 100644 --- a/server/src/Models/Network.php +++ b/server/src/Models/Network.php @@ -12,13 +12,13 @@ use Fleetbase\Models\Invite; use Fleetbase\Models\User; use Fleetbase\Storefront\Support\Storefront; +use Fleetbase\Storefront\Traits\HasStorefrontKey; use Fleetbase\Traits\HasApiModelBehavior; use Fleetbase\Traits\HasApiModelCache; use Fleetbase\Traits\HasOptionsAttributes; use Fleetbase\Traits\HasPublicid; use Fleetbase\Traits\HasUuid; use Fleetbase\Traits\Searchable; -use Illuminate\Support\Str; use Spatie\Sluggable\HasSlug; use Spatie\Sluggable\SlugOptions; @@ -30,6 +30,7 @@ class Network extends StorefrontModel use HasApiModelCache; use HasOptionsAttributes; use HasSlug; + use HasStorefrontKey; use Searchable; /** @@ -39,6 +40,11 @@ class Network extends StorefrontModel */ protected $publicIdType = 'network'; + /** + * The prefix of the storefront key, which SetStorefrontSession resolves the key by. + */ + public const KEY_PREFIX = 'network_'; + /** * The database table used by the model. * @@ -97,15 +103,6 @@ public function getSlugOptions(): SlugOptions ->saveSlugsTo('slug'); } - /** on boot generate key */ - public static function boot() - { - parent::boot(); - static::creating(function ($model) { - $model->key = 'network_' . md5(Str::random(14) . time()); - }); - } - /** * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ diff --git a/server/src/Models/Store.php b/server/src/Models/Store.php index bcee8220..5a5cb23e 100644 --- a/server/src/Models/Store.php +++ b/server/src/Models/Store.php @@ -11,6 +11,7 @@ use Fleetbase\Models\File; use Fleetbase\Models\User; use Fleetbase\Storefront\Support\Storefront; +use Fleetbase\Storefront\Traits\HasStorefrontKey; use Fleetbase\Support\Utils as FleetbaseUtils; use Fleetbase\Traits\HasApiModelBehavior; use Fleetbase\Traits\HasApiModelCache; @@ -20,7 +21,6 @@ use Fleetbase\Traits\HasUuid; use Fleetbase\Traits\Searchable; use Illuminate\Support\Carbon; -use Illuminate\Support\Str; use Spatie\Sluggable\HasSlug; use Spatie\Sluggable\SlugOptions; @@ -33,6 +33,7 @@ class Store extends StorefrontModel use HasOptionsAttributes; use HasMetaAttributes; use HasSlug; + use HasStorefrontKey; use Searchable; /** @@ -42,6 +43,11 @@ class Store extends StorefrontModel */ protected $publicIdType = 'store'; + /** + * The prefix of the storefront key, which SetStorefrontSession resolves the key by. + */ + public const KEY_PREFIX = 'store_'; + /** * The database table used by the model. * @@ -109,15 +115,6 @@ public function getSlugOptions(): SlugOptions ->saveSlugsTo('slug'); } - /** on boot generate key */ - public static function boot() - { - parent::boot(); - static::creating(function ($model) { - $model->key = 'store_' . md5(Str::random(14) . time()); - }); - } - /** * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ diff --git a/server/src/Providers/StorefrontServiceProvider.php b/server/src/Providers/StorefrontServiceProvider.php index 937912b0..1509a96a 100644 --- a/server/src/Providers/StorefrontServiceProvider.php +++ b/server/src/Providers/StorefrontServiceProvider.php @@ -69,6 +69,7 @@ class StorefrontServiceProvider extends CoreServiceProvider \Fleetbase\Storefront\Console\Commands\MigrateStripeSandboxCustomers::class, \Fleetbase\Storefront\Console\Commands\ReleasePromotionReservations::class, \Fleetbase\Storefront\Console\Commands\DispatchCampaigns::class, + \Fleetbase\Storefront\Console\Commands\RotateStorefrontKey::class, ]; /** diff --git a/server/src/Traits/HasStorefrontKey.php b/server/src/Traits/HasStorefrontKey.php new file mode 100644 index 00000000..028e4f14 --- /dev/null +++ b/server/src/Traits/HasStorefrontKey.php @@ -0,0 +1,41 @@ +key = static::generateKey(); + }); + } + + /** + * A new key: the prefix and 32 hex characters from a cryptographically secure source, + * the same shape the md5-based keys had so apps and the middleware see no difference. + */ + public static function generateKey(): string + { + return static::KEY_PREFIX . bin2hex(random_bytes(16)); + } + + /** + * Replace the key. Apps still using the old one are rejected from the next request. + * Saved through the model so the API model cache is invalidated with the update. + */ + public function rotateKey(): string + { + $this->key = static::generateKey(); + $this->save(); + + return $this->key; + } +} diff --git a/server/src/routes.php b/server/src/routes.php index 4f1666bd..c779c909 100644 --- a/server/src/routes.php +++ b/server/src/routes.php @@ -234,6 +234,7 @@ function ($router, $controller) { $router->fleetbaseRoutes( 'networks', function ($router, $controller) { + $router->post('{id}/rotate-key', $controller('rotateKey')); $router->delete('{id}/remove-category', $controller('deleteCategory')); $router->post('{id}/set-store-category', $controller('addStoreToCategory')); $router->post('{id}/remove-store-category', $controller('removeStoreCategory')); @@ -261,7 +262,12 @@ function ($router, $controller) { $router->get('{id}/insights', $controller('insights')); } ); - $router->fleetbaseRoutes('stores'); + $router->fleetbaseRoutes( + 'stores', + function ($router, $controller) { + $router->post('{id}/rotate-key', $controller('rotateKey')); + } + ); $router->fleetbaseRoutes('store-hours'); $router->fleetbaseRoutes('store-locations'); $router->fleetbaseRoutes( diff --git a/server/tests/Unit/Console/RotateStorefrontKeyTest.php b/server/tests/Unit/Console/RotateStorefrontKeyTest.php new file mode 100644 index 00000000..24f96a53 --- /dev/null +++ b/server/tests/Unit/Console/RotateStorefrontKeyTest.php @@ -0,0 +1,144 @@ +dropIfExists($table); + $schema->create($table, function ($blueprint) use ($table) { + $blueprint->increments('id'); + $blueprint->string('uuid')->nullable(); + $blueprint->string('public_id')->nullable(); + $blueprint->string('name')->nullable(); + + if ($table !== 'companies') { + $blueprint->string('company_uuid')->nullable(); + $blueprint->string('key')->nullable(); + } + + $blueprint->timestamps(); + $blueprint->softDeletes(); + }); + } + + $connection = Capsule::connection('mysql'); + $connection->table('companies')->insert([ + ['uuid' => 'company_uuid', 'public_id' => 'company_public', 'name' => 'Acme'], + ['uuid' => 'other_company_uuid', 'public_id' => 'company_other', 'name' => 'Other'], + ]); + $connection->table('stores')->insert([ + ['uuid' => 'store_a_uuid', 'public_id' => 'store_a', 'company_uuid' => 'company_uuid', 'key' => 'store_aaaa', 'name' => 'Store A'], + ['uuid' => 'store_b_uuid', 'public_id' => 'store_b', 'company_uuid' => 'company_uuid', 'key' => 'store_bbbb', 'name' => 'Store B'], + ['uuid' => 'store_c_uuid', 'public_id' => 'store_c', 'company_uuid' => 'other_company_uuid', 'key' => 'store_cccc', 'name' => 'Store C'], + ]); + $connection->table('networks')->insert([ + ['uuid' => 'network_a_uuid', 'public_id' => 'network_a', 'company_uuid' => 'company_uuid', 'key' => 'network_aaaa', 'name' => 'Network A'], + ['uuid' => 'network_c_uuid', 'public_id' => 'network_c', 'company_uuid' => 'other_company_uuid', 'key' => 'network_cccc', 'name' => 'Network C'], + ]); +} + +function runRotateKeyCommand(array $input): array +{ + $buffer = new BufferedOutput(); + $command = new RotateStorefrontKey(); + $command->setInput(new ArrayInput($input, $command->getDefinition())); + $command->setOutput(new OutputStyle(new ArrayInput([]), $buffer)); + + return [$command->handle(), $buffer->fetch()]; +} + +function storefrontKeys(): array +{ + $connection = Capsule::connection('mysql'); + + return $connection->table('stores')->pluck('key', 'public_id')->merge($connection->table('networks')->pluck('key', 'public_id'))->all(); +} + +test('the rotate key command rotates stores and networks by uuid, public id or current key and prints old and new keys', function () { + createRotateKeyCommandSchema(); + $before = storefrontKeys(); + + [$status, $output] = runRotateKeyCommand(['id' => ['store_a', 'store_b_uuid', 'network_aaaa'], '--force' => true]); + $after = storefrontKeys(); + + expect($status)->toBe(Command::SUCCESS) + ->and($after['store_a'])->toMatch('/^store_[0-9a-f]{32}$/') + ->and($after['store_b'])->toMatch('/^store_[0-9a-f]{32}$/') + ->and($after['network_a'])->toMatch('/^network_[0-9a-f]{32}$/') + ->and($after['store_c'])->toBe($before['store_c']) + ->and($after['network_c'])->toBe($before['network_c']) + ->and($output)->toContain('store_aaaa') + ->and($output)->toContain($after['store_a']) + ->and($output)->toContain('network_aaaa') + ->and($output)->toContain($after['network_a']) + ->and($output)->toContain('Rotated 3 storefront key(s).') + ->and((new SetStorefrontSession())->isValidKey('store_aaaa'))->toBeFalse(); +}); + +test('the rotate key command rotates every store and network of a company', function (string $company) { + createRotateKeyCommandSchema(); + $before = storefrontKeys(); + + [$status, $output] = runRotateKeyCommand(['--company' => $company, '--force' => true]); + $after = storefrontKeys(); + + expect($status)->toBe(Command::SUCCESS) + ->and($after['store_a'])->not->toBe($before['store_a']) + ->and($after['store_b'])->not->toBe($before['store_b']) + ->and($after['network_a'])->not->toBe($before['network_a']) + ->and($after['store_c'])->toBe($before['store_c']) + ->and($after['network_c'])->toBe($before['network_c']) + ->and($output)->toContain('Rotated 3 storefront key(s).'); +})->with(['company uuid' => 'company_uuid', 'company public id' => 'company_public']); + +test('the rotate key command rotates nothing when any id is unknown', function () { + createRotateKeyCommandSchema(); + $before = storefrontKeys(); + + [$status, $output] = runRotateKeyCommand(['id' => ['store_a', 'store_missing'], '--force' => true]); + + expect($status)->toBe(Command::FAILURE) + ->and($output)->toContain("No store or network found for 'store_missing'.") + ->and(storefrontKeys())->toBe($before); +}); + +test('the rotate key command rejects an unknown company and a call without targets', function () { + createRotateKeyCommandSchema(); + $before = storefrontKeys(); + + [$unknownStatus, $unknownOutput] = runRotateKeyCommand(['--company' => 'company_missing', '--force' => true]); + [$emptyStatus, $emptyOutput] = runRotateKeyCommand([]); + + expect($unknownStatus)->toBe(Command::FAILURE) + ->and($unknownOutput)->toContain("Company 'company_missing' not found.") + ->and($emptyStatus)->toBe(Command::INVALID) + ->and($emptyOutput)->toContain('Pass one or more store or network ids, or --company.') + ->and(storefrontKeys())->toBe($before); +}); + +test('the rotate key command rotates nothing when the confirmation is declined', function () { + createRotateKeyCommandSchema(); + $before = storefrontKeys(); + $buffer = new BufferedOutput(); + $command = new class extends RotateStorefrontKey { + public function confirm($question, $default = false) + { + return false; + } + }; + $command->setInput(new ArrayInput(['id' => ['store_a']], $command->getDefinition())); + $command->setOutput(new OutputStyle(new ArrayInput([]), $buffer)); + + expect($command->handle())->toBe(Command::FAILURE) + ->and($buffer->fetch())->toContain('No keys were rotated.') + ->and(storefrontKeys())->toBe($before); +}); diff --git a/server/tests/Unit/Models/StorefrontKeyRotationTest.php b/server/tests/Unit/Models/StorefrontKeyRotationTest.php new file mode 100644 index 00000000..95ef26bf --- /dev/null +++ b/server/tests/Unit/Models/StorefrontKeyRotationTest.php @@ -0,0 +1,173 @@ +dropIfExists($table); + $schema->create($table, function ($blueprint) { + $blueprint->increments('id'); + $blueprint->string('uuid')->nullable(); + $blueprint->string('public_id')->nullable(); + $blueprint->string('company_uuid')->nullable(); + $blueprint->string('key')->nullable(); + $blueprint->string('name')->nullable(); + $blueprint->string('slug')->nullable(); + $blueprint->string('currency')->nullable(); + $blueprint->timestamps(); + $blueprint->softDeletes(); + }); + } + + $connection = Capsule::connection('mysql'); + $connection->table('stores')->insert([ + ['uuid' => 'store_uuid', 'public_id' => 'store_public', 'company_uuid' => 'company_uuid', 'key' => 'store_' . md5('old'), 'name' => 'Corner Store', 'currency' => 'USD'], + ['uuid' => 'other_store_uuid', 'public_id' => 'store_other', 'company_uuid' => 'other_company_uuid', 'key' => 'store_' . md5('other'), 'name' => 'Other Store', 'currency' => 'USD'], + ]); + $connection->table('networks')->insert([ + ['uuid' => 'network_uuid', 'public_id' => 'network_public', 'company_uuid' => 'company_uuid', 'key' => 'network_' . md5('old'), 'name' => 'Night Market', 'currency' => 'MNT'], + ['uuid' => 'other_network_uuid', 'public_id' => 'network_other', 'company_uuid' => 'other_company_uuid', 'key' => 'network_' . md5('other'), 'name' => 'Other Market', 'currency' => 'MNT'], + ]); +} + +function storefrontKeyRequest(?string $company): Request +{ + $request = Request::create('/', 'POST'); + $request->setLaravelSession(new Illuminate\Session\Store('storefront-keys', new Illuminate\Session\ArraySessionHandler(120))); + $request->session()->put('company', $company); + + return $request; +} + +test('generated storefront keys carry the prefix and 32 random hex characters', function () { + $storeKeys = collect(range(1, 50))->map(fn () => Store::generateKey()); + $networkKeys = collect(range(1, 50))->map(fn () => Network::generateKey()); + + expect($storeKeys->every(fn ($key) => preg_match('/^store_[0-9a-f]{32}$/', $key) === 1))->toBeTrue() + ->and($networkKeys->every(fn ($key) => preg_match('/^network_[0-9a-f]{32}$/', $key) === 1))->toBeTrue() + ->and($storeKeys->unique()->count())->toBe(50) + ->and($networkKeys->unique()->count())->toBe(50); +}); + +test('new stores and networks get a generated key when created', function () { + createStorefrontKeySchema(); + Store::setEventDispatcher(new Dispatcher(app())); + Store::clearBootedModels(); + + try { + foreach ([new Store(), new Network()] as $model) { + (new ReflectionMethod($model, 'fireModelEvent'))->invoke($model, 'creating', false); + + expect($model->key)->toMatch('/^' . $model::KEY_PREFIX . '[0-9a-f]{32}$/'); + } + } finally { + Store::unsetEventDispatcher(); + Store::clearBootedModels(); + } +}); + +test('rotating a key saves a new valid key and the old key no longer passes the storefront middleware', function (string $class, string $uuid) { + createStorefrontKeySchema(); + $middleware = new SetStorefrontSession(); + $model = $class::where('uuid', $uuid)->first(); + $oldKey = $model->key; + + expect($middleware->isValidKey($oldKey))->toBeTrue(); + + $newKey = $model->rotateKey(); + + expect($newKey)->not->toBe($oldKey) + ->and($newKey)->toMatch('/^' . $class::KEY_PREFIX . '[0-9a-f]{32}$/') + ->and($class::where('uuid', $uuid)->value('key'))->toBe($newKey) + ->and($middleware->isValidKey($oldKey))->toBeFalse() + ->and($middleware->isValidKey($newKey))->toBeTrue(); +})->with([ + 'store' => [Store::class, 'store_uuid'], + 'network' => [Network::class, 'network_uuid'], +]); + +test('rotating a key fires the model update so the api model cache is invalidated', function () { + createStorefrontKeySchema(); + Store::setEventDispatcher(new Dispatcher(app())); + Store::clearBootedModels(); + app()->instance('responsecache', new class { + public function __call($method, $arguments) + { + return null; + } + }); + + try { + $updated = []; + Store::updated(function ($store) use (&$updated) { + $updated[] = $store->getChanges(); + }); + + $key = Store::where('uuid', 'store_uuid')->first()->rotateKey(); + + expect($updated)->toHaveCount(1) + ->and($updated[0]['key'])->toBe($key); + } finally { + Store::unsetEventDispatcher(); + Store::clearBootedModels(); + app()->offsetUnset('responsecache'); + } +}); + +test('the rotate key endpoints rotate a store or network of the current company and return the new key', function (string $controller, string $class, string $id) { + createStorefrontKeySchema(); + $oldKey = $class::where('public_id', $id)->value('key'); + $response = (new $controller())->rotateKey($id, storefrontKeyRequest('company_uuid')); + $data = $response->getData(true); + + expect($response->getStatusCode())->toBe(200) + ->and($data['status'])->toBe('ok') + ->and($data['key'])->not->toBe($oldKey) + ->and($data['key'])->toBe($class::where('public_id', $id)->value('key')); +})->with([ + 'store by public id' => [StoreController::class, Store::class, 'store_public'], + 'network by public id' => [NetworkController::class, Network::class, 'network_public'], +]); + +test('the rotate key endpoints accept uuids', function () { + createStorefrontKeySchema(); + + expect((new StoreController())->rotateKey('store_uuid', storefrontKeyRequest('company_uuid'))->getStatusCode())->toBe(200) + ->and((new NetworkController())->rotateKey('network_uuid', storefrontKeyRequest('company_uuid'))->getStatusCode())->toBe(200); +}); + +test('the rotate key endpoints never touch another company\'s store or network', function (string $controller, string $class, string $id, ?string $company) { + createStorefrontKeySchema(); + $oldKey = $class::where('public_id', $id)->value('key'); + $response = (new $controller())->rotateKey($id, storefrontKeyRequest($company)); + + expect($response->getStatusCode())->toBe(404) + ->and($class::where('public_id', $id)->value('key'))->toBe($oldKey); +})->with([ + 'store of another company' => [StoreController::class, Store::class, 'store_other', 'company_uuid'], + 'network of another company' => [NetworkController::class, Network::class, 'network_other', 'company_uuid'], + 'store without a company' => [StoreController::class, Store::class, 'store_public', null], + 'network without a company' => [NetworkController::class, Network::class, 'network_public', null], +]); + +test('rotating keys is a guarded storefront permission on stores and networks', function () { + $resources = collect((new StorefrontSchema())->resources)->keyBy('name'); + + // The authorization guard maps a controller method to a schema action by slug, so the + // rotateKey actions require `storefront rotate-key store` / `storefront rotate-key network`. + expect(Utils::slugify('rotateKey'))->toBe('rotate-key') + ->and($resources['store']['actions'])->toContain('rotate-key') + ->and($resources['network']['actions'])->toContain('rotate-key'); +}); diff --git a/server/tests/Unit/Routes/StorefrontRoutesTest.php b/server/tests/Unit/Routes/StorefrontRoutesTest.php index 80cf5f9b..fa3540fd 100644 --- a/server/tests/Unit/Routes/StorefrontRoutesTest.php +++ b/server/tests/Unit/Routes/StorefrontRoutesTest.php @@ -104,5 +104,8 @@ private function record(string $method, string $uri, mixed $action): self ['POST', '{id}/chat/read', 'OrderChatController@read'], ['GET', 'eligibility', 'ReviewController@eligibility'], ['DELETE', '{id}', 'ReviewController@delete'], + ['FLEETBASE', 'stores', null], + ['POST', '{id}/rotate-key', 'stores:rotateKey'], + ['POST', '{id}/rotate-key', 'networks:rotateKey'], )->and($router->routes)->not->toContain(['DELETE', '{id}', 'ReviewController@find'])->and(count($router->routes))->toBeGreaterThan(50); }); diff --git a/translations/en-us.yaml b/translations/en-us.yaml index 0a04f2a7..897e5fe5 100644 --- a/translations/en-us.yaml +++ b/translations/en-us.yaml @@ -1546,6 +1546,13 @@ storefront: split-tips: A multi-store order's store tip is divided by each store's share; off, the network keeps it. reviews-label: Customer reviews reviews: Customers can review products and stores after a completed order. + rotate-key: + button: Rotate key + help: Rotating issues a new key. Apps and integrations using the current key are rejected immediately until they are updated with the new one. + title: Rotate the {type} key? + body: '{name} gets a new key and the current key stops working immediately. Every app, website and integration using it is rejected until it is updated with the new key.' + confirm: Rotate key + done: The {type} key was rotated. Update your apps with the new key. save-bar: unsaved: "{count, plural, one {# unsaved change} other {# unsaved changes}}" saved: All changes saved From f8ffa7074c48ad7324bda0fe6ab5f14719acf9e7 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 13 Oct 2026 12:53:13 +0800 Subject: [PATCH 193/193] fix(customers): verify-code with for=storefront_create_customer validates instead of a 500 verifyCode() receives a plain Request from the router and passed it straight to create(), which requires a CreateCustomerRequest, so the call threw a TypeError before create() ran. The app never takes this path (it signs in with verify-code and creates accounts with POST customers), and the existing test passed a CreateCustomerRequest directly, so neither caught it. The branch now builds the form request from the incoming one and authorizes and validates it like POST customers: a bad code or a duplicate email is a 422. A new test sends a plain Request, the way HTTP does. --- .../Controllers/v1/CustomerController.php | 25 +++++++++- .../CustomerControllerContractsTest.php | 48 +++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/server/src/Http/Controllers/v1/CustomerController.php b/server/src/Http/Controllers/v1/CustomerController.php index 121146dc..002ad08b 100644 --- a/server/src/Http/Controllers/v1/CustomerController.php +++ b/server/src/Http/Controllers/v1/CustomerController.php @@ -929,6 +929,29 @@ public function loginWithGoogle(Request $request) * * @return \Fleetbase\Http\Resources\Storefront\Customer */ + /** + * The create-customer request for a verify-code call made with + * `for=storefront_create_customer`. Laravel resolves verifyCode()'s parameter as a plain + * Request, and create() requires a CreateCustomerRequest, so passing it on as-is threw a + * TypeError (500). This builds the form request from it and authorizes and validates it + * the way the router does for `POST customers`: a bad code or duplicate email is a 422. + */ + protected function createCustomerRequestFrom(Request $request): CreateCustomerRequest + { + if ($request instanceof CreateCustomerRequest) { + return $request; + } + + $createRequest = CreateCustomerRequest::createFrom($request); + $createRequest->setContainer(app()); + if (app()->bound('redirect')) { + $createRequest->setRedirector(app('redirect')); + } + $createRequest->validateResolved(); + + return $createRequest; + } + /** * Whether a verification code should be accepted as an app-review bypass. * @@ -977,7 +1000,7 @@ public function verifyCode(Request $request) $attrs = $request->input(['name', 'phone', 'email']); if ($for === 'storefront_create_customer') { - return $this->create($request); + return $this->create($this->createCustomerRequestFrom($request)); } // Without an identity there is nobody to verify. The lookup below would compile to diff --git a/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php b/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php index 7c88a332..57ad5fd6 100644 --- a/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php +++ b/server/tests/Unit/Http/Controllers/CustomerControllerContractsTest.php @@ -973,6 +973,54 @@ public function message(string $to, string $message, array $media = [], array $p ->and($missing->getData(true))->toBe(['error' => 'An identity is required to create a customer.']); }); +test('verify code for customer creation validates a plain http request instead of throwing a type error', function () { + createCustomerVerificationDeliverySchema(); + $connection = Model::getConnectionResolver()->connection('mysql'); + $schema = $connection->getSchemaBuilder(); + $schema->dropIfExists('contacts'); + $schema->create('contacts', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('email')->nullable(); + $table->string('phone')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + $connection->table('verification_codes')->insert([ + 'uuid' => 'verification_uuid', + 'code' => '123456', + 'for' => 'storefront_create_customer', + 'status' => 'pending', + 'meta' => json_encode(['identity' => 'buyer@example.test']), + 'created_at' => now(), + 'updated_at' => now(), + ]); + session(['company' => 'company_uuid', 'storefront_key' => 'store_key']); + + // The router hands verifyCode() a plain Request; this is what used to reach create(). + $validator = new Illuminate\Validation\Factory(new Illuminate\Translation\Translator(new Illuminate\Translation\ArrayLoader(), 'en'), app()); + $validator->setPresenceVerifier(new Illuminate\Validation\DatabasePresenceVerifier(Model::getConnectionResolver())); + app()->instance(Illuminate\Contracts\Validation\Factory::class, $validator); + app()->instance('validator', $validator); + + $refusal = null; + try { + (new CustomerController())->verifyCode(Request::create('/customer/verify-code', 'POST', [ + 'identity' => 'buyer@example.test', + 'code' => '999999', + 'for' => 'storefront_create_customer', + 'name' => 'Verified Buyer', + 'email' => 'buyer@example.test', + ])); + } catch (Illuminate\Validation\ValidationException $exception) { + $refusal = $exception; + } + + expect($refusal)->not->toBeNull() + ->and($refusal->errors())->toHaveKey('code') + ->and($refusal->getResponse()?->getStatusCode())->toBe(422); +}); + test('customer creation persists a verified storefront identity and issues an access token', function () { createCustomerVerificationDeliverySchema(); $connection = Model::getConnectionResolver()->connection('mysql');