getCustomerIdIfProvided(); if ($customerId === null) { return null; } if ($restrictToOwnCustomer && !$this->isOwnCustomerContext($customerId)) { $this->emitForbidden(['list_products']); } try { $customerObject = (new users_o())->getUserByCustomerNumber($customerId); if ($customerObject->exists()) { return $customerObject; } } catch (\Exception $e) { // Log the incident (new logs_o())->add('products', 'global', 3, 0, 'GET_CUSTOMER_FAILED', 'Failed to get customer with id ' . $customerId . '. Error: ' . $e->getMessage()); // Return null return null; } return null; } private function getCustomerIdIfProvided(): ?int { return $this->getOptionalPositiveIntParameter('customer_id'); } /** * Get the department id if the department_id parameter is provided (In the request 'department_id') * @return int|null */ private function getDepartmentIdIfProvided(): ?int { return $this->getOptionalPositiveIntParameter('department_id'); } private function getOptionalPositiveIntParameter(string $parameter): ?int { global $response; if (!self::isParametersSet([$parameter])) { return null; } $value = self::getParameter($parameter); if ($this->isNullLikeOptionalParameter($value)) { return null; } $parsed = null; if (is_int($value)) { $parsed = $value; } elseif (is_string($value) && preg_match('/^\d+$/', trim($value)) === 1) { $parsed = (int)trim($value); } else { $response->error('Invalid ' . $parameter, 400); } if ($parsed === null || $parsed <= 0) { $response->error('Invalid ' . $parameter, 400); } return $parsed; } private function routePositiveInt(string $name): int { global $response; $raw = $this->fromRoute($name); if (!is_string($raw) || !preg_match('/^[1-9][0-9]*$/', $raw)) { $response->error('Invalid route parameter', 400); } return (int)$raw; } private function isNullLikeOptionalParameter(mixed $value): bool { if ($value === null) { return true; } if (!is_string($value)) { return false; } return in_array(strtolower(trim($value)), ['', 'null', 'undefined'], true); } private function isCustomerBookingSession(bool $hasAuthenticatedUser, bool $hasCustomerPermission, bool $isSubuserSession): bool { return ($hasAuthenticatedUser && $hasCustomerPermission) || $isSubuserSession; } private function canReadProductList(bool $isCustomerBookingSession, bool $hasListProductsPermission): bool { return $isCustomerBookingSession || $hasListProductsPermission; } private function shouldRestrictCustomerBookingProducts(bool $isCustomerBookingSession, bool $hasListProductsPermission): bool { return $isCustomerBookingSession && !$hasListProductsPermission; } private function canUseCustomerBookingDepartmentPricing(bool $isCustomerBookingSession, bool $useFinalPrice, ?int $customerId): bool { if (!$isCustomerBookingSession || !$useFinalPrice) { return false; } return $customerId === null || $this->isOwnCustomerContext($customerId); } private function isBookingVisibleProduct(array $product): bool { return (bool)($product['display_in_booking_form'] ?? false); } private function filterProductsVisibleOnBookingForm(array $products): array { return array_values(array_filter($products, function ($product): bool { return is_array($product) && $this->isBookingVisibleProduct($product); })); } /** * Get the category (ID) if the category parameter is provided (In the request 'category') * @return int|null */ private function getCategoryIfProvided(): ?int { return $this->getOptionalPositiveIntParameter('category'); } /** * Get the product id if the id parameter is provided (In the request 'id') * @return int|null */ private function getProductIdIfProvided(): ?int { return $this->getOptionalPositiveIntParameter('id'); } /** * Parse the products price in the (optional) department pricing, with the (optional) customer discounts applied * @param array $products * @param users_o|null $customer * @param int|null $departmentId * @return array */ private function parseProductsPrice(array $products, ?users_o $customer, ?int $departmentId): array { // Check if the departmentId is set if ($departmentId) { // Apply the departments unique pricing $products = (new products_o())->applyDepartmentPricing($products, $departmentId, true); } // Check if the customer is set if ($customer !== null) { // Apply the customers unique discounts $products = (new products_o())->applyCustomerDiscounts($products, $customer, $departmentId); } else { $products = products_o::stripDepartmentPriceSources($products); } return $products; } /** * Parse the addons/options price in the (optional) department pricing, with the (optional) customer discounts applied * @param array $options * @param users_o|null $customer * @param int|null $departmentId * @return array */ private function parseOptionsPrice(array $options, ?users_o $customer, ?int $departmentId): array { foreach ($options as $key => $option) { // Get the options product $product = $option['product']; // Apply the department pricing and customer discounts to the product $product = self::parseProductsPrice([$product], $customer, $departmentId)[0]; // Update the option with the new product $options[$key]['product'] = $product; // Set the price of the option to the price of the product $options[$key]['price'] = (int)$product['price']; } return $options; } public function run(): void { $this->get('/products', function () { ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/products'); // Check if the user is logged in global $response; $permission_node = 'list_products'; $isProductDetailsRestricted = true; $auth = new authentication(); $user = $auth->get_user(); $subuser = $auth->get_subuser(); $hasAuthenticatedUser = $user !== false && $user !== null; $isSubuserSession = $subuser !== false; $hasCustomerPermission = $hasAuthenticatedUser ? $this->hasPermission('user') : false; $isCustomerBookingSession = $this->isCustomerBookingSession($hasAuthenticatedUser, $hasCustomerPermission, $isSubuserSession); $hasListProductsPermission = $hasAuthenticatedUser ? $this->hasPermission($permission_node) : false; if ($hasAuthenticatedUser || $isSubuserSession) { $isProductDetailsRestricted = false; if (!$this->canReadProductList($isCustomerBookingSession, $hasListProductsPermission)) { $this->emitForbidden([$permission_node]); } } // Set the user id to 0 if guest $responsibleUserId = $hasAuthenticatedUser ? (int)$user->id : 0; $parseProduct = function ($product, $isGuest, $onlyBookingVisible = false): array { $addons = (new product_options_o())->getProductOptions($product['id']); if ($onlyBookingVisible) { $addons = array_values(array_filter($addons, function ($option): bool { return (bool)($option['product']['display_in_booking_form'] ?? false); })); } $tmpProduct = [ 'id' => (int)$product['id'], 'name' => (string)$product['name'], 'description' => (string)$product['description'], 'price' => (int)$product['price'], 'subscription_allowed' => (bool)$product['subscription_allowed'], 'category' => (int)$product['category'], 'piktogram' => (string)$product['piktogram'], 'economic_product_id' => (int)$product['economic_product_id'], 'apply_category_discount' => (bool)$product['apply_category_discount'], 'requires_note' => \objects\products_o::productDataRequiresOrderItemNote($product), 'created_at' => (string)$product['created_at'], 'updated_at' => (string)$product['updated_at'], 'addons' => $addons, 'is_wash' => (bool)$product['is_wash'], 'display_in_booking_form' => (bool)$product['display_in_booking_form'], 'order_priority' => (int)$product['order_priority'], ]; /** Apply guest filters and redactions, if any */ if ($isGuest) { $tmpProductGuest = [ ...$tmpProduct, 'name' => $tmpProduct['display_in_booking_form'] ? $tmpProduct['name'] : 'Login to view', 'description' => '', 'price' => 0, 'economic_product_id' => 0, 'apply_category_discount' => false, 'requires_note' => false, 'addons' => $tmpProduct['display_in_booking_form'] ? array_map(function ($option) { if ($option['product']['display_in_booking_form'] === false) { $option['product']['name'] = 'Login to view'; $option['product']['description'] = ''; $option['product']['economic_product_id'] = 0; $option['product']['apply_category_discount'] = false; $option['product']['requires_note'] = false; $option['product']['restricted'] = true; } $option['price'] = 0; $option['product']['price'] = 0; $option['restricted'] = $option['product']['restricted'] ?? false; return $option; }, $tmpProduct['addons']) : [], 'restricted' => !$tmpProduct['display_in_booking_form'] ]; } return $isGuest ? $tmpProductGuest : $tmpProduct; }; // Check if the request was successful if ($hasAuthenticatedUser || $isSubuserSession || $isProductDetailsRestricted) { // Define the variables $restrictCustomerBookingProducts = $this->shouldRestrictCustomerBookingProducts($isCustomerBookingSession, $hasListProductsPermission); $useFinalPrice = self::isParametersSet(['final_price']) && self::getParameter('final_price') === 'true'; $customerId = $this->getCustomerIdIfProvided(); $customer = $this->getCustomerIfProvided($restrictCustomerBookingProducts); // This is only used if the customer_id parameter is provided $departmentId = $this->getDepartmentIdIfProvided(); // This is only used if the department_id parameter is provided if ( $departmentId !== null && !$restrictCustomerBookingProducts && !$this->canUseCustomerBookingDepartmentPricing($isCustomerBookingSession, $useFinalPrice, $customerId) ) { self::requireDepartmentAccess((string)$departmentId); } $category = $this->getCategoryIfProvided(); // This is only used if the category parameter is provided (ID of the category) $productId = $this->getProductIdIfProvided(); // This is only used if the id parameter is provided (ID of the product) // Check if the "final_price" parameter is set, and true. if ($useFinalPrice) { // Determine the products to return if ($category) { // Get products in the category $products = (new products_o())->listObjectsByCategory($category); // If the category is "6" exterior washes, include category "1" and "3" if ($category === 6) { $additionalProducts = (new products_o())->listObjectsByCategory(1); $products = array_merge($products, $additionalProducts); $additionalProducts = (new products_o())->listObjectsByCategory(3); $products = array_merge($products, $additionalProducts); } } elseif ($productId) { // Get the specific product $product = (new products_o())->select($productId); if ($product->exists()) { $products = [$product->asArray()]; } else { $products = []; } } else { // Get all products $products = (array)(new products_o())->listObjectsWithPaginationIfSet( function ($product) use ($isProductDetailsRestricted, $parseProduct) { return $parseProduct($product, $isProductDetailsRestricted); } ); } if ($restrictCustomerBookingProducts && !$productId) { $products = $this->filterProductsVisibleOnBookingForm($products); } // Return all products, with the department pricing and customer discounts applied //$response->success( // array_map(function ($product) { // return parseProduct($product); // }, self::parseProductsPrice($products, $customer, $departmentId)) //); $result = array_map(function ($product) use ($customer, $departmentId, $isProductDetailsRestricted, $restrictCustomerBookingProducts, $parseProduct) { $productArray = $parseProduct($product, $isProductDetailsRestricted, $restrictCustomerBookingProducts); // Get the price of the product with the department pricing and customer discounts applied $productArray = $parseProduct(self::parseProductsPrice([$productArray], $customer, $departmentId)[0], $isProductDetailsRestricted, $restrictCustomerBookingProducts); // Get the options for the product $productArray['addons'] = self::parseOptionsPrice($productArray['addons'], $customer, $departmentId); // Return the product with the updated price return $productArray; }, $products); // If the productId is set, return a single object instead of an array $response->success($productId && count($result) > 0 ? $result[0] : $result); } // Check if the id is set in the request (to get a specific product) if (self::isParametersSet(['id'])) { // Log the incident (new logs_o())->add('products', 'global', 1, $responsibleUserId, 'LIST_PRODUCTS', 'Successfully listed product with id ' . $response->getRequestParameter('id')); // Return the product $product = (new products_o())->select((int)self::getParameter('id'))->asArray(); if ($restrictCustomerBookingProducts && !$this->isBookingVisibleProduct($product)) { $response->success([]); } $response->success( $parseProduct( $product, $isProductDetailsRestricted, $restrictCustomerBookingProducts ) ); } // Check if the category is set in the request // Check if the category is set if ($category !== null) { // Log the incident (new logs_o())->add('products', 'global', 1, $responsibleUserId, 'LIST_PRODUCTS', 'Successfully listed products in category ' . $category); // Return the list of products $products = (new products_o())->listObjectsByCategory($category); if ($restrictCustomerBookingProducts) { $products = $this->filterProductsVisibleOnBookingForm($products); } // Check if the department_id is set if ($departmentId !== null) { // Apply the departments unique pricing $products = (new products_o())->applyDepartmentPricing((array)$products, $departmentId); } $response->success( array_map(function ($product) use ($isProductDetailsRestricted, $restrictCustomerBookingProducts, $parseProduct) { return $parseProduct($product, $isProductDetailsRestricted, $restrictCustomerBookingProducts); }, $products) ); } // Log the incident (new logs_o())->add('products', 'global', 1, $responsibleUserId, 'LIST_PRODUCTS', 'Successfully listed products'); // Check if the department_id is set if ($departmentId !== null) { // Get all product ids contained in a category attached to the department $departmentSpecificProducts = (new departments_o())->select($departmentId)->getAllProductInDepartmentCategories(); // Get the product ids as an array $departmentSpecificProductIds = array_map(function ($product) { return $product->id; }, $departmentSpecificProducts); $products = (array)(new products_o())->listObjectsWithPaginationIfSet( function ($product) use ($isProductDetailsRestricted, $restrictCustomerBookingProducts, $parseProduct) { // Only include products that are in the department specific product ids return $parseProduct($product, $isProductDetailsRestricted, $restrictCustomerBookingProducts); }, (new products_o())->forceRestrictFilters([ 'id' => $departmentSpecificProductIds, ]) ); if ($restrictCustomerBookingProducts) { $products = $this->filterProductsVisibleOnBookingForm($products); } // Return the list of products $response->success( (new products_o())->applyDepartmentPricing($products, $departmentId) ); } // Return the list of products $products = (new products_o())->listObjectsWithPaginationIfSet(function ($product) use ($isProductDetailsRestricted, $restrictCustomerBookingProducts, $parseProduct) { return $parseProduct($product, $isProductDetailsRestricted, $restrictCustomerBookingProducts); }); if ($restrictCustomerBookingProducts) { $products = $this->filterProductsVisibleOnBookingForm((array)$products); } $response->success($products); } else { // Log the incident (new logs_o())->add('products', 'global', 1, 0, 'LIST_PRODUCTS', 'No user found, or invalid session'); // Return an error $response->error('Invalid session', 400); } }, [ 'list_products' => 'List all products. Authenticated customer booking sessions may read booking-visible products without the permission.' ] ); $this->post('/products', function () { ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/products'); // Require the user to be logged in global $response; $this->requirePermission('add_product'); // Get the user object $user = (new authentication())->get_user(); // Check if the request was successful if ($user) { // Get the request data $name = $response->getRequestParameter('name'); $description = $response->getRequestParameter('description') ?? null; $price = $response->getRequestParameter('price'); $category = $response->getRequestParameter('category') ?? null; $piktogram = $response->getRequestParameter('piktogram') ?? null; $economicProductId = $response->getRequestParameter('economicProductId') ?? null; // Check if the required fields are set self::requireParameters( [ 'name', 'price' ] ); // Add the product (new products_o())->add( (string)$name, (string)$description ?? '', (int)$price, (int)$category ?? '', (int)$piktogram ?? null, (int)$economicProductId ?? null ); // Log the incident (new logs_o())->add('products', 'global', 1, $user->id, 'ADD_PRODUCT', 'Product name: ' . $name); // Return a success message $response->success(['message' => 'Product added successfully']); } else { // Log the incident (new logs_o())->add('products', 'global', 1, 0, 'ADD_PRODUCT', 'No user found, or invalid session'); // Return an error $response->error('Invalid session', 400); } }, [ 'add_product' => 'Add a product' ] ); $this->put('/products', function () { ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/products'); // Require the user to be logged in global $response; $this->requirePermission('edit_product'); // Get the user object $user = (new authentication())->get_user(); // Check if the request was successful if ($user) { // Check if the required fields are set self::requireParameters(['id']); $id = $response->getRequestParameter('id'); $product = (new products_o())->select((int)$id); // Check if the product exists if (!$product->exists()) { $response->error('Product not found', 404); } // Update the fields that are set if (self::isParametersSet(['name'])) { $product->name->set($response->getRequestParameter('name')); } if (self::isParametersSet(['description'])) { $product->description->set($response->getRequestParameter('description') ?? ''); } if (self::isParametersSet(['price'])) { $product->price->set($response->getRequestParameter('price')); } if (self::isParametersSet(['subscription_allowed'])) { $product->subscription_allowed->set($response->getRequestParameter('subscription_allowed') ? 1 : 0); } if (self::isParametersSet(['category'])) { $product->category->set($response->getRequestParameter('category') ?? ''); } if (self::isParametersSet(['piktogram'])) { $product->piktogram->set($response->getRequestParameter('piktogram') ?? ''); } if (self::isParametersSet(['economic_product_id'])) { $product->economic_product_id->set($response->getRequestParameter('economic_product_id') ?? ''); } if (self::isParametersSet(['apply_category_discount'])) { $product->apply_category_discount->set($response->getRequestParameter('apply_category_discount') ? 1 : 0); } if (self::isParametersSet(['requires_note'])) { $product->requires_note->set($response->getRequestParameter('requires_note') ? 1 : 0); } if (self::isParametersSet(['is_wash'])) { $product->is_wash->set($response->getRequestParameter('is_wash') ? 1 : 0); } if (self::isParametersSet(['display_in_booking_form'])) { $product->display_in_booking_form->set($response->getRequestParameter('display_in_booking_form') ? 1 : 0); } if (self::isParametersSet(['order_priority'])) { $product->order_priority->set((int)$response->getRequestParameter('order_priority') ?? ''); } (new logs_o())->add('products', 'global', 1, $user->id, 'EDIT_PRODUCT', 'Product id: ' . $id); // Return a success message $response->success(['message' => 'Product edited successfully']); } else { // Log the incident (new logs_o())->add('products', 'global', 1, 0, 'EDIT_PRODUCT', 'No user found, or invalid session'); // Return an error $response->error('Invalid session', 400); } }, [ 'edit_product' => 'Edit a product' ] ); // POST /products/:id/merge — merge a product into another. // Body: { target_id: int, reason?: string } // The source product is preserved (so historical order_items references remain valid), // but is marked as merged in the products table. Reads and new orders should follow // merged_into_product_id to the target. An audit row is written to product_merges. $this->post('/products/{id}/merge', function () { ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/products/{id}/merge'); global $response; $this->requirePermission('edit_product'); $user = (new authentication())->get_user(); if (!$user) { (new logs_o())->add('products', 'global', 1, 0, 'MERGE_PRODUCT', 'No user found, or invalid session'); $response->error('Invalid session', 400); } $sourceId = $this->routePositiveInt('id'); $targetId = (int)($response->getRequestParameter('target_id') ?? 0); if ($targetId <= 0) { $response->error('target_id is required and must be a positive integer', 400); } $reason = $response->getRequestParameter('reason'); if ($reason !== null && !is_string($reason)) { $response->error('reason must be a string', 400); } $source = (new products_o())->select($sourceId); if (!$source->exists()) { $response->error('Source product not found', 404); } try { $source->mergeInto($targetId, (int)$user->id, $reason); } catch (\RuntimeException $e) { (new logs_o())->add('products', 'global', 3, (int)$user->id, 'MERGE_PRODUCT_FAILED', "source={$sourceId} target={$targetId} error=" . $e->getMessage()); $response->error($e->getMessage(), 400); } (new logs_o())->add('products', 'global', 1, (int)$user->id, 'MERGE_PRODUCT', "source={$sourceId} target={$targetId}"); $response->success([ 'message' => 'Product merged successfully', 'source_product_id' => $sourceId, 'target_product_id' => $targetId, 'merged_into_product_id' => (int)$source->merged_into_product_id->value(), ]); }, [ 'edit_product' => 'Merge a product into another (preserves historical order references; new orders resolve to the target).' ] ); } }