diff --git a/.gitignore b/.gitignore index f95a4c00..ede8f014 100644 --- a/.gitignore +++ b/.gitignore @@ -43,4 +43,7 @@ logs # aem-commerce-prerender related *.env *.aio.json -.aem-commerce-prerender.json \ No newline at end of file +.aem-commerce-prerender.json + +# AI +.cursor/ diff --git a/README.md b/README.md index abe869fb..10b85ef4 100644 --- a/README.md +++ b/README.md @@ -68,10 +68,11 @@ For detailed setup instructions, see the [Step-by-Step Configuration](#step-by-s * `PRODUCTS_TEMPLATE`: The URL for the product template page (auto-populated by wizard). For localized sites with URLs like `https://main--site--org.aem.page/en-us/products/default`, you can use the `{locale}` token: `https://main--site--org.aem.page/{locale}/products/default` * `PRODUCT_PAGE_URL_FORMAT`: The URL pattern for product pages (auto-populated by wizard). Supports tokens: `{locale}`, `{urlKey}`, `{sku}`. Default pattern: `/{locale}/products/{urlKey}`. For live environments, consider using a different prefix like `/{locale}/products-prerendered/{urlKey}` for logical separation * `LOCALES`: Comma-separated list of locales (e.g., `en-us,en-gb,fr-fr`) or empty for non-localized sites + * `ACO_CATEGORY_FAMILIES`: *(Commerce Optimizer only)* Comma-separated list of Commerce Optimizer category family identifiers (e.g., `electronics,apparel`). When the product catalog contains 10,000 or fewer products, the system fetches all products for PDP pre-rendering in a single pass regardless of this setting. For catalogs exceeding 10,000 products, the system fetches the first 10,000 products from the catalog and additionally discovers all category slugs from the configured families to retrieve up to 10,000 products per category, deduplicating across both sets. If this setting is not configured, only the first 10,000 products will be pre-rendered. These category families also define which categories are included for PLP pre-rendering. * `AEM_ADMIN_API_AUTH_TOKEN`: Long-lived authentication token for AEM Admin API (valid for 1 year). During setup, the wizard will exchange your temporary 24-hour token from [admin.hlx.page](https://admin.hlx.page/) for this long-lived token automatically. You can modify the environment-specific variables by editing the `.env` file directly or by re-running the setup wizard with `npm run setup`. - 1. **After Setup Completion**: Once the setup process is complete, the following configurations will be automatically applied: + 2. **After Setup Completion**: Once the setup process is complete, the following configurations will be automatically applied: * **Site Context**: A Site Context will be created and stored in your localStorage. This serves as the authentication medium required to operate the [Storefront Prerender Management UI](https://prerender.aem-storefront.com) (you will be redirected to this address). @@ -87,9 +88,9 @@ For detailed setup instructions, see the [Step-by-Step Configuration](#step-by-s } } ``` - 1. [Customize the code](/docs/CUSTOMIZE.md) that contains the rendering logic according to your requirements, for [structured data](/actions/pdp-renderer/ldJson.js), [markup](/actions/pdp-renderer/render.js) and [templates](https://github.com/adobe-rnd/aem-commerce-prerender/tree/main/actions/pdp-renderer/templates) - 1. Deploy the solution with `npm run deploy` - 1. **Testing Actions Manually**: Before enabling automated triggers, verify that each action works correctly by invoking them manually: + 3. [Customize the code](/docs/CUSTOMIZE.md) that contains the rendering logic according to your requirements, for [structured data](/actions/pdp-renderer/ldJson.js), [markup](/actions/pdp-renderer/render.js) and [templates](https://github.com/adobe-rnd/aem-commerce-prerender/tree/main/actions/pdp-renderer/templates) + 4. Deploy the solution with `npm run deploy` + 5. **Testing Actions Manually**: Before enabling automated triggers, verify that each action works correctly by invoking them manually: ```bash # Fetch all products from Catalog Service and store them in default-products.json aio rt action invoke aem-commerce-ssg/fetch-all-products @@ -100,7 +101,7 @@ For detailed setup instructions, see the [Step-by-Step Configuration](#step-by-s # Clean up and unpublish deleted products aio rt action invoke aem-commerce-ssg/mark-up-clean-up ``` - 1. **Enable Automated Triggers**: Once you've confirmed that all actions work correctly, uncomment the triggers and rules sections in `app.config.yaml`: + 6. **Enable Automated Triggers**: Once you've confirmed that all actions work correctly, uncomment the triggers and rules sections in `app.config.yaml`: ```yaml triggers: productPollerTrigger: @@ -127,7 +128,7 @@ For detailed setup instructions, see the [Step-by-Step Configuration](#step-by-s action: "mark-up-clean-up" ``` Then redeploy the solution: `npm run deploy` - 1. **Management UI Overview**: Navigate to the [Storefront Prerender Management UI](https://prerender.aem-storefront.com) to monitor and manage your prerender deployment. The UI provides several tabs: + 7. **Management UI Overview**: Navigate to the [Storefront Prerender Management UI](https://prerender.aem-storefront.com) to monitor and manage your prerender deployment. The UI provides several tabs: * **Published Products** (`#/products`): Displays the list of products published on your store, as retrieved from your site's `published-products-index.json`. For sites with over a thousand products, use the pagination interface to navigate through results. The search functionality allows you to filter products on the current page. @@ -148,7 +149,7 @@ For detailed setup instructions, see the [Step-by-Step Configuration](#step-by-s * **Settings** (`#/settings`): Allows you to access and modify your personal context file. The context file contains information about the prerender app's namespace, authentication token, and the currently active Helix token. Editing the context file enables you to use the prerender UI to manage other App Builder applications. - 1. The system is now up and running. In the first cycle of operation, it will publish all products in the catalog. Subsequent runs will only process products that have changed. + 8. The system is now up and running. In the first cycle of operation, it will publish all products in the catalog. Subsequent runs will only process products that have changed. ### Management UI Setup diff --git a/actions/categories.js b/actions/categories.js new file mode 100644 index 00000000..a94c6277 --- /dev/null +++ b/actions/categories.js @@ -0,0 +1,126 @@ +/* + +Copyright 2026 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. + +*/ + +const { requestSaaS } = require("./utils"); +const { + CategoriesQuery, + CategoryTreeQuery, + CategoryTreeBySlugsQuery, +} = require("./queries"); + +const MAX_TREE_DEPTH = 3; + +/** + * Checks whether category families are configured (not the [null] default). + * + * @param {Array} families - The categoryFamilies array from runtime config. + * @returns {boolean} + */ +function hasFamilies(families) { + return Array.isArray(families) && families.length > 0 && families[0] !== null; +} + +/** + * Resolves all category slugs belonging to the given ACO category families. + * + * Uses BFS traversal of the categoryTree API: + * 1. Query each family's root categories and their immediate childrenSlugs. + * 2. Query those children (with depth) to retrieve their descendants. + * 3. Repeat until no unresolved childrenSlugs remain. + * + * Handles trees of arbitrary depth even when the API caps depth at + * MAX_TREE_DEPTH per call — each iteration advances up to that many levels. + * + * @param {Object} context - Request context (config, logger, headers, etc.). + * @param {string[]} families - ACO category family identifiers. + * @returns {Promise} Flat array of all unique category slugs. + */ +async function getCategorySlugsFromFamilies(context, families) { + console.debug("Getting category slugs from families:", families); + const allSlugs = new Set(); + + for (const family of families) { + console.debug("Getting category slugs from family:", family); + // Get root-level categories for this family + const firstLevel = await requestSaaS( + CategoryTreeQuery, + "getCategoryTree", + { family }, + context, + ); + + let pending = []; + for (const cat of firstLevel.data.categoryTree) { + allSlugs.add(cat.slug); + pending.push(...(cat.childrenSlugs || [])); + } + + // BFS: resolve children level by level until no new slugs remain + while (pending.length) { + // Mark pending as seen before querying to prevent re-processing + for (const slug of pending) allSlugs.add(slug); + + const childrenRes = await requestSaaS( + CategoryTreeBySlugsQuery, + "getCategoryTreeBySlugs", + { family, slugs: pending, depth: MAX_TREE_DEPTH }, + context, + ); + + // First pass: capture any descendant slugs included due to depth traversal + for (const cat of childrenRes.data.categoryTree) { + allSlugs.add(cat.slug); + } + + // Second pass: collect only new childrenSlugs for next iteration + pending = []; + for (const cat of childrenRes.data.categoryTree) { + for (const child of cat.childrenSlugs || []) { + if (!allSlugs.has(child)) pending.push(child); + } + } + } + } + console.debug("Category slugs resolved:", [...allSlugs]); + + return [...allSlugs]; +} + +/** + * Retrieves all ACCS categories grouped by level. + * + * Returns a sparse array indexed by category level so callers can iterate + * shallowest levels first (used for the early-exit optimization when + * fetching products by category). + * + * @param {Object} context - Request context (config, logger, headers, etc.). + * @returns {Promise} Sparse array where index N holds urlPath strings at level N. + */ +async function getCategories(context) { + const categoriesRes = await requestSaaS( + CategoriesQuery, + "getCategories", + {}, + context, + ); + const byLevel = []; + for (const { urlPath, level } of categoriesRes.data.categories) { + const idx = parseInt(level); + byLevel[idx] = byLevel[idx] || []; + byLevel[idx].push(urlPath); + } + return byLevel; +} + +module.exports = { getCategorySlugsFromFamilies, getCategories, hasFamilies }; diff --git a/actions/fetch-all-products/index.js b/actions/fetch-all-products/index.js index db48ef3f..acdcdacd 100644 --- a/actions/fetch-all-products/index.js +++ b/actions/fetch-all-products/index.js @@ -1,6 +1,6 @@ /* -Copyright 2025 Adobe. All rights reserved. +Copyright 2026 Adobe. All rights reserved. This file is licensed to you under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 @@ -12,129 +12,283 @@ governing permissions and limitations under the License. */ -const { CategoriesQuery, ProductCountQuery, ProductsQuery } = require('../queries'); -const { Core, Files } = require('@adobe/aio-sdk') -const { requestSaaS, FILE_PREFIX } = require('../utils'); -const { Timings } = require('../lib/benchmark'); -const { getRuntimeConfig } = require('../lib/runtimeConfig'); -const { handleActionError } = require('../lib/errorHandler'); - -async function getSkus(categoryPath, context) { - let productsResp = await requestSaaS(ProductsQuery, 'getProducts', { currentPage: 1, categoryPath }, context); - const products = [...productsResp.data.productSearch.items.map(({ productView }) => ( - { - urlKey: productView.urlKey, - sku: productView.sku - } - ))]; - let maxPage = productsResp.data.productSearch.page_info.total_pages; +const { Core, Files } = require("@adobe/aio-sdk"); +const { + getConfig, + getSiteType, + requestSaaS, + SITE_TYPES, + FILE_PREFIX, +} = require("../utils"); +const { ProductsQuery, ProductCountQuery } = require("../queries"); +const { Timings } = require("../lib/benchmark"); +const { getRuntimeConfig } = require("../lib/runtimeConfig"); +const { handleActionError } = require("../lib/errorHandler"); +const { + getCategorySlugsFromFamilies, + getCategories, + hasFamilies, +} = require("../categories"); + +const MAX_PRODUCTS_PER_CATEGORY = 10000; // page_size: 500 * 20 pages = 10000 products +const MAX_PAGES_FETCHED = 20; +const CONCURRENCY = 5; +const pLimitPromise = import("p-limit").then(({ default: pLimit }) => + pLimit(CONCURRENCY), +); + +const productMapper = ({ productView }) => ({ + urlKey: productView.urlKey, + sku: productView.sku, +}); + +/** + * Fetches all product SKUs and urlKeys for a single categoryPath via paginated productSearch. + * + * @param {string} categoryPath - Category path to filter by (empty string for all products). + * @param {Object} context - Request context (config, logger, headers, etc.). + * @returns {Promise>} Products in this category. + */ +async function getProductsByCategory(categoryPath, context) { + const limit = await pLimitPromise; + + const firstPage = await requestSaaS( + ProductsQuery, + "getProducts", + { currentPage: 1, categoryPath }, + context, + ); + const products = firstPage.data.productSearch.items.map(productMapper); + let maxPage = firstPage.data.productSearch.page_info.total_pages; + const totalCount = firstPage.data.productSearch.total_count; - if (maxPage > 20) { - console.warn(`Category ${categoryPath} has more than 10000 products.`); - maxPage = 20; + if (maxPage > MAX_PAGES_FETCHED) { + console.warn( + `Category ${categoryPath || "(root)"} contains ${totalCount} products, which is more than the maximum supported of ${MAX_PRODUCTS_PER_CATEGORY}. + Only the first ${MAX_PRODUCTS_PER_CATEGORY} products will be fetched for this category.`, + ); + maxPage = MAX_PAGES_FETCHED; } - for (let currentPage = 2; currentPage <= maxPage; currentPage++) { - productsResp = await requestSaaS(ProductsQuery, 'getProducts', { currentPage, categoryPath }, context); - products.push(...productsResp.data.productSearch.items.map(({ productView }) => ( - { - urlKey: productView.urlKey, - sku: productView.sku - } - ))); + const pages = Array.from({ length: maxPage - 1 }, (_, i) => i + 2); + const results = await Promise.all( + pages.map((page) => + limit(() => + requestSaaS( + ProductsQuery, + "getProducts", + { currentPage: page, categoryPath }, + context, + ), + ), + ), + ); + for (const pageRes of results) { + products.push(...pageRes.data.productSearch.items.map(productMapper)); } return products; } -async function getAllCategories(context) { - const categories = []; - const categoriesResp = await requestSaaS(CategoriesQuery, 'getCategories', {}, context); - const items = categoriesResp.data.categories; - for (const {urlPath, level, name} of items) { - const index = parseInt(level); - categories[index] = categories[index] || []; - categories[index].push({urlPath, name, level}); +/** + * Merges batch results into the deduplication map, keyed by SKU. + * + * @param {Map} productsBySku - Accumulator map. + * @param {Array>} batchResults - Arrays of products from parallel fetches. + */ +function collectProducts(productsBySku, batchResults) { + for (const products of batchResults) { + for (const product of products) { + productsBySku.set(product.sku, product); + } } - return categories; } -async function getAllSkus(context) { - const productCountResp = await requestSaaS(ProductCountQuery, 'getProductCount', { categoryPath: '' }, context); - const productCount = productCountResp.data.productSearch?.page_info?.total_pages; +/** + * Fetches the total product count for the catalog. + * + * @param {Object} context - Request context. + * @returns {Promise} Total number of pages (each page = 1 product at page_size 1). + */ +async function getProductCount(context) { + const countRes = await requestSaaS( + ProductCountQuery, + "getProductCount", + { categoryPath: "" }, + context, + ); + return countRes.data.productSearch?.page_info?.total_pages; +} - if (!productCount) { - throw new Error('Unknown product count.'); +/** + * Resolves all category slugs from configured ACO category families and + * fetches products for each slug. Deduplicates across categories since + * products may belong to multiple slugs. + * + * @param {Object} context - Request context. + * @param {string[]} categoryFamilies - Category family identifiers. + * @returns {Promise>} Deduplicated product list. + */ +async function getAllProductsByCategoryFamily(context, categoryFamilies) { + if (categoryFamilies.length === 0) { + throw new Error( + "Tried to retrieve products by category family, but no category families are configured.", + ); } - if (productCount <= 10000) { - // we can get everything from the default category - return getSkus('', context); + const slugs = await getCategorySlugsFromFamilies(context, categoryFamilies); + const productsBySku = new Map(); + + const slugBatchSize = 50; + for (let i = 0; i < slugs.length; i += slugBatchSize) { + const batch = slugs.slice(i, i + slugBatchSize); + const results = await Promise.all( + batch.map((slug) => getProductsByCategory(slug, context)), + ); + collectProducts(productsBySku, results); } - const products = new Set(); - // we have to traverse the category tree - const categories = await getAllCategories(context); - - outer: for (const category of categories) { - if (!category) continue; - while (category.length) { - const slice = category.splice(0, 50); - const fetchedProducts = await Promise.all(slice.map((category) => getSkus(category.urlPath, context))); - fetchedProducts.flatMap((skus) => skus).forEach((sku) => products.add(sku)); - if (products.size >= productCount) { - // break if we got all products already + return [...productsBySku.values()]; +} + +/** + * Discovers all product SKUs for an ACCS or PaaS storefront by category. + * Iterates categories shallowest-first in batches of 50 with an early exit + * once the expected product count is reached. + * + * @param {Object} context - Request context. + * @param {number} productCount - Total expected product count. + * @returns {Promise>} Deduplicated product list. + */ +async function getAllProductsByCategory(context, productCount) { + const productsBySku = new Map(); + const categories = await getCategories(context); + + outer: for (const levelGroup of categories) { + if (!levelGroup) continue; + while (levelGroup.length) { + const batch = levelGroup.splice(0, 50); + const results = await Promise.all( + batch.map((urlPath) => getProductsByCategory(urlPath, context)), + ); + collectProducts(productsBySku, results); + if (productsBySku.size >= productCount) { + // All products collected, break out of the outer loop break outer; } } } - if (products.size !== productCount) { - console.warn(`Expected ${productCount} products, but got ${products.size}.`); + if (productsBySku.size !== productCount) { + console.warn( + `Expected ${productCount} products, but got ${productsBySku.size}.`, + ); + } + + return [...productsBySku.values()]; +} + +/** + * Retrieves all product SKUs for a given site type. + * + * @param {string} siteType - One of SITE_TYPES.ACO or SITE_TYPES.ACCS. + * @param {Object} context - Request context. + * @param {string[]} categoryFamilies - Configured ACO category families. + * @returns {Promise>} + */ +async function getAllProducts(siteType, context, categoryFamilies) { + const productCount = await getProductCount(context); + + if (!productCount) { + throw new Error("Could not fetch product count from catalog."); + } + + if (productCount <= MAX_PRODUCTS_PER_CATEGORY) { + console.info( + `Catalog has less than ${MAX_PRODUCTS_PER_CATEGORY} products. Fetching all products from the default category.`, + ); + return getProductsByCategory("", context); + } + + if (siteType === SITE_TYPES.ACO) { + console.info( + `Fetching the first ${MAX_PRODUCTS_PER_CATEGORY} products from the catalog.`, + ); + const defaultProducts = await getProductsByCategory("", context); + if (!hasFamilies(categoryFamilies)) { + return defaultProducts; + } + console.info( + "Category families are configured. Fetching additional products by category family.", + ); + const familyProducts = await getAllProductsByCategoryFamily( + context, + categoryFamilies, + ); + const productsBySku = new Map(); + collectProducts(productsBySku, [defaultProducts, familyProducts]); + return [...productsBySku.values()]; } - return [...products]; + // ACCS or PaaS with > MAX_PRODUCTS_PER_CATEGORY products + return getAllProductsByCategory(context, productCount); } +/** + * App Builder action entry point. Discovers all product SKUs for each configured + * locale and writes them to a state file for the check-product-changes action. + * + * @param {Object} params - App Builder action parameters. + * @returns {Promise} Action response with status and timings. + */ async function main(params) { try { - // Resolve runtime config const cfg = getRuntimeConfig(params); - const logger = Core.Logger('main', { level: cfg.logLevel }); + const logger = Core.Logger("main", { level: cfg.logLevel }); - const sharedContext = { ...cfg, logger } + const sharedContext = { ...cfg, logger }; const results = await Promise.all( - cfg.locales.map(async (locale) => { - const context = { ...sharedContext }; - if (locale) { - context.locale = locale; - } - const timings = new Timings(); - const stateFilePrefix = locale || 'default'; - const allSkus = await getAllSkus(context); - timings.sample('getAllSkus'); - const filesLib = await Files.init(params.libInit || {}); - timings.sample('saveFile'); - const productsFileName = `${FILE_PREFIX}/${stateFilePrefix}-products.json`; - await filesLib.write(productsFileName, JSON.stringify(allSkus)); - return timings.measures; - }) + cfg.locales.map(async (locale) => { + const context = { ...sharedContext }; + if (locale) { + context.locale = locale; + } + const timings = new Timings(); + const stateFilePrefix = locale || "default"; + + const siteConfig = await getConfig(context); + const siteType = getSiteType(siteConfig); + const allProducts = await getAllProducts( + siteType, + context, + cfg.categoryFamilies, + ); + + timings.sample("getAllProducts"); + const filesLib = await Files.init(params.libInit || {}); + timings.sample("saveFile"); + const productsFileName = `${FILE_PREFIX}/${stateFilePrefix}-products.json`; + await filesLib.write(productsFileName, JSON.stringify(allProducts)); + console.debug( + `${allProducts.length} total products saved to ${productsFileName}`, + ); + return timings.measures; + }), ); return { statusCode: 200, - body: { status: 'completed', timings: results } + body: { status: "completed", timings: results }, }; } catch (error) { - // Handle errors and determine if job should fail - const logger = Core.Logger('main', { level: 'error' }); - - return handleActionError(error, { - logger, - actionName: 'Fetch all products' + const logger = Core.Logger("main", { level: "error" }); + + return handleActionError(error, { + logger, + actionName: "Fetch all products", }); } } -exports.main = main +exports.main = main; diff --git a/actions/lib/runtimeConfig.js b/actions/lib/runtimeConfig.js index f3ab4a4f..6066e98c 100644 --- a/actions/lib/runtimeConfig.js +++ b/actions/lib/runtimeConfig.js @@ -93,6 +93,14 @@ function getRuntimeConfig(params = {}, options = {}) { if (!localesArr.length) localesArr = [null]; } + // Normalize ACO_CATEGORY_FAMILIES + let categoryFamiliesArr = [null]; + if (Array.isArray(merged.ACO_CATEGORY_FAMILIES)) { + categoryFamiliesArr = merged.ACO_CATEGORY_FAMILIES.map(String).map(s => s.trim()).filter(Boolean); + } else if (typeof merged.ACO_CATEGORY_FAMILIES === 'string' && merged.ACO_CATEGORY_FAMILIES.trim()) { + categoryFamiliesArr = merged.ACO_CATEGORY_FAMILIES.split(',').map(s => s.trim()).filter(Boolean); + } + const cfg = { raw: { ...merged, LOCALES_ARRAY: localesArr }, org: ORG, @@ -107,7 +115,8 @@ function getRuntimeConfig(params = {}, options = {}) { configName: merged.CONFIG_NAME, configSheet: merged.CONFIG_SHEET, pathFormat: merged.PRODUCT_PAGE_URL_FORMAT, - locales: localesArr + locales: localesArr, + categoryFamilies: categoryFamiliesArr }; // URL sanity checks diff --git a/actions/pdp-renderer/render.js b/actions/pdp-renderer/render.js index 3f5c721c..32109c76 100644 --- a/actions/pdp-renderer/render.js +++ b/actions/pdp-renderer/render.js @@ -70,7 +70,7 @@ async function generateProductHtml(sku, urlKey, context) { return { title: sanitize(option.title, 'inline'), id: sanitize(option.id, 'no'), - required: sanitize(option.required, 'no'), + required: sanitize(String(option.required), 'no'), values: option.values }; }); diff --git a/actions/queries.js b/actions/queries.js index fa31ea2f..344137cd 100644 --- a/actions/queries.js +++ b/actions/queries.js @@ -237,25 +237,62 @@ const ProductsQuery = ` items { productView { urlKey - sku + sku } } page_info { current_page total_pages } + total_count + } + } +`; + +const CategoryTreeQuery = ` + query getCategoryTree($family: String!) { + categoryTree(family: $family) { + slug + name + level + metaTags { + title + description + keywords + } + images { + url + label + roles + customRoles + } + childrenSlugs + } + } +`; + +const CategoryTreeBySlugsQuery = ` + query getCategoryTreeBySlugs($family: String!, $slugs: [String!], $depth: Int!) { + categoryTree(family: $family, slugs: $slugs, depth: $depth) { + slug + name + level + parentSlug + childrenSlugs } } `; module.exports = { - ProductQuery, - ProductByUrlKeyQuery, - VariantsQuery, - GetAllSkusPaginatedQuery, - GetLastModifiedQuery, - CategoriesQuery, - ProductCountQuery, - ProductsQuery, - GetUrlKeyQuery -}; \ No newline at end of file + ProductQuery, + ProductByUrlKeyQuery, + VariantsQuery, + GetAllSkusPaginatedQuery, + GetLastModifiedQuery, + CategoriesQuery, + ProductCountQuery, + ProductsQuery, + GetUrlKeyQuery, + CategoryTreeQuery, + CategoryTreeBySlugsQuery, +}; diff --git a/actions/utils.js b/actions/utils.js index de983151..2be053ef 100644 --- a/actions/utils.js +++ b/actions/utils.js @@ -1,5 +1,5 @@ /* -Copyright 2025 Adobe. All rights reserved. +Copyright 2026 Adobe. All rights reserved. This file is licensed to you under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 @@ -11,8 +11,14 @@ governing permissions and limitations under the License. */ const deepmerge = require('@fastify/deepmerge')(); const helixSharedStringLib = require('@adobe/helix-shared-string'); +const { ERROR_CODES } = require('./lib/errorHandler'); const BATCH_SIZE = 50; +const SITE_TYPES = Object.freeze({ + ACO: 'aco', + ACCS: 'accs', +}); + /* This file exposes some common utilities for your actions */ const FILE_PREFIX = 'check-product-changes'; @@ -308,6 +314,66 @@ async function getConfig(context) { return context.config; } +/** + * Converts the keys of an object to lowercase. + * + * @param {Object} obj - The object to convert the keys of. + * @returns {Object} The object with the keys converted to lowercase. + */ +function lowercaseKeys(obj) { + if (!obj || typeof obj !== 'object') return {}; + return Object.fromEntries( + Object.entries(obj).map(([k, v]) => [k.toLowerCase(), v]), + ); +} + +/** + * Returns the Commerce Catalog Service headers based on the site type and configuration. + * + * @param {Object} config - The site configuration object returned by getConfig(). + * @returns {Object} The Commerce Catalog Service headers. + */ +function getCsHeaders(config) { + const siteType = getSiteType(config); + let csHeaders = {}; + + if (siteType === SITE_TYPES.ACO) { + const configHeaders = lowercaseKeys(config.headers?.cs); + const policyHeaders = Object.fromEntries( + Object.entries(configHeaders) + .filter(([key]) => key.startsWith('ac-policy-')), + ); + csHeaders = { + 'ac-view-id': configHeaders['ac-view-id'], + 'ac-price-book-id': configHeaders['ac-price-book-id'], + ...policyHeaders, + }; + } else { + if (config.__hasLegacyFormat) { + csHeaders = { + 'magento-customer-group': config['commerce.headers.cs.Magento-Customer-Group'] || config['commerce-customer-group'], + 'magento-environment-id': config['commerce.headers.cs.Magento-Environment-Id'] || config['commerce-environment-id'], + 'magento-store-code': config['commerce.headers.cs.Magento-Store-Code'] || config['commerce-store-code'], + 'magento-store-view-code': config['commerce.headers.cs.Magento-Store-View-Code'] || config['commerce-store-view-code'], + 'magento-website-code': config['commerce.headers.cs.Magento-Website-Code'] || config['commerce-website-code'], + 'x-api-key': config['commerce.headers.cs.x-api-key'] || config['commerce-x-api-key'], + }; + } else { + const configHeaders = lowercaseKeys(config.headers?.cs); + csHeaders = { + 'magento-customer-group': configHeaders['magento-customer-group'], + 'magento-environment-id': configHeaders['magento-environment-id'], + 'magento-store-code': configHeaders['magento-store-code'], + 'magento-store-view-code': configHeaders['magento-store-view-code'], + 'magento-website-code': configHeaders['magento-website-code'], + 'x-api-key': configHeaders['x-api-key'], + }; + } + } + + return csHeaders; +} + /** * Requests data from Commerce Catalog Service API. * @@ -324,26 +390,12 @@ async function requestSaaS(query, operationName, variables, context) { ... (await getConfig(context)), ...configOverrides }; + const headers = { 'Content-Type': 'application/json', 'origin': storeUrl, - ...(config.__hasLegacyFormat ? { - 'magento-customer-group': config['commerce.headers.cs.Magento-Customer-Group'] || config['commerce-customer-group'], - 'magento-environment-id': config['commerce.headers.cs.Magento-Environment-Id'] || config['commerce-environment-id'], - 'magento-store-code': config['commerce.headers.cs.Magento-Store-Code'] || config['commerce-store-code'], - 'magento-store-view-code': config['commerce.headers.cs.Magento-Store-View-Code'] || config['commerce-store-view-code'], - 'magento-website-code': config['commerce.headers.cs.Magento-Website-Code'] || config['commerce-website-code'], - 'x-api-key': config['commerce.headers.cs.x-api-key'] || config['commerce-x-api-key'], - } : { - 'magento-customer-group': config.headers?.cs?.['Magento-Customer-Group'], - 'magento-environment-id': config.headers?.cs?.['Magento-Environment-Id'], - 'magento-store-code': config.headers?.cs?.['Magento-Store-Code'], - 'magento-store-view-code': config.headers?.cs?.['Magento-Store-View-Code'], - 'magento-website-code': config.headers?.cs?.['Magento-Website-Code'], - 'x-api-key': config.headers?.cs?.['x-api-key'], - }), - // bypass LiveSearch cache - 'Magento-Is-Preview': true, + ...getCsHeaders(config), + 'Magento-Is-Preview': true, // bypass LiveSearch cache }; const method = 'POST'; @@ -366,11 +418,32 @@ async function requestSaaS(query, operationName, variables, context) { for (const error of response.errors) { logger.error(`Request '${operationName}' returned GraphQL error`, error); } + const err = new Error(`GraphQL request '${operationName}' failed`); + err.code = ERROR_CODES.PROCESSING_ERROR; + throw err; } return response; } +/** + * Determines whether a site is ACO or ACCS based on its config. + * @param {Object} config - The site configuration object returned by getConfig(). + * @returns {string} One of SITE_TYPES.ACO or SITE_TYPES.ACCS. + */ +function getSiteType(config) { + if (config['adobe-commerce-optimizer'] === true) { + return SITE_TYPES.ACO; + } + const csHeaders = config.headers?.cs; + if (csHeaders && Object.keys(csHeaders).some( + (key) => key.toLowerCase().startsWith('ac-') + )) { + return SITE_TYPES.ACO; + } + return SITE_TYPES.ACCS; +} + /** * Checks if a given string is a valid URL. * @@ -466,6 +539,8 @@ module.exports = { getDefaultStoreURL, formatMemoryUsage, requestPublishedProductsIndex, + getSiteType, + SITE_TYPES, FILE_PREFIX, PDP_FILE_EXT, STATE_FILE_EXT, diff --git a/app.config.yaml b/app.config.yaml index e5588a92..495756c1 100644 --- a/app.config.yaml +++ b/app.config.yaml @@ -16,6 +16,7 @@ application: CONFIG_NAME: "config" LOCALES: "${LOCALES}" SITE_TOKEN: "${SITE_TOKEN}" + ACO_CATEGORY_FAMILIES: "${ACO_CATEGORY_FAMILIES}" actions: pdp-renderer: function: "actions/pdp-renderer/index.js" @@ -80,4 +81,4 @@ application: # action: "fetch-all-products" # markUpCleanUpRule: # trigger: "markUpCleanUpTrigger" -# action: "mark-up-clean-up" \ No newline at end of file +# action: "mark-up-clean-up" diff --git a/test/utils.test.js b/test/utils.test.js index 379c8a8a..68127bd2 100644 --- a/test/utils.test.js +++ b/test/utils.test.js @@ -271,8 +271,8 @@ describe('request', () => { const operationName = 'TestOperation'; const variables = { var1: 'value1' }; - const response = await requestSaaS(query, operationName, variables, context); - expect(response).toEqual({ data: { result: 'success' }, errors: [graphqlError] }); + await expect(requestSaaS(query, operationName, variables, context)) + .rejects.toThrow("GraphQL request 'TestOperation' failed"); expect(context.logger.error).toHaveBeenCalledWith(`Request 'TestOperation' returned GraphQL error`, graphqlError); });