diff --git a/README.md b/README.md index ffb02bb..98712d2 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # ACO SFCC Starter Kit -> [!Important] -> Installation of the custom ACO SFCC Cartridge is required: [int_adobe_commerce_optimizer](https://github.com/adobe-commerce/aco-sfcc-cartridges). +> [!Important] Installation of the custom ACO SFCC Cartridge is required: +> [int_adobe_commerce_optimizer](https://github.com/adobe-commerce/aco-sfcc-cartridges). ![Starter Kit Flow Diagram](./docs/images/diagram.png) @@ -131,6 +131,8 @@ Run the following command to deploy your starter kit to your Developer Console p aio app deploy ``` +> [!TIP] Run the `aio app deploy` command with `--force-build --force-deploy` flags to force a clean build. + #### Onboard Your Starter Kit Actions Run the following command to onboard the App Builder actions from your starter kit to your Developer Console project: @@ -168,6 +170,7 @@ Entities Syncronized: - Metadata - Products +- Categories - Price Books - Prices @@ -176,20 +179,41 @@ Entities Syncronized: This action retrieves recent changes that have been made in SFCC since the last full or delta sync action and synchonizes them with Commerce Optimizer. -By default, this action is scheduled to run every hour via the +By default, this action is scheduled to run every hour (`cron: 15 * * * *`) at 15 minutes past the hour using the [App Builder Cron](https://developer.adobe.com/app-builder/docs/resources/cron-jobs/lesson2) action configuration. +Please adjust this schedule to align with the +[SFCC job configuration](https://github.com/adobe-commerce/aco-sfcc-cartridges#configure-the-adobecommerceoptimizertrackedchanges-job) +and to best fit your catalog data update frequency in the [App Configuration File](./app.config.yaml). + +Example: + +```yaml +triggers: + everyHour: + feed: /whisk.system/alarms/alarm + inputs: + cron: 15 * * * * + trigger_payload: + type: sfcc.delta.sync + data: {} +rules: + everyHourRule: + trigger: everyHour + action: delta-backoffice/consumer +``` Location: `actions/delta` Entities Syncronized: - Products +- Categories - Price Books - Prices #### Price Book Sync -This action retrieves all price books in SFCC and syncronized them with Commerce Optimizer. +This action retrieves all price books in SFCC and syncronizes them with Commerce Optimizer. Location: `actions/price-book` @@ -204,6 +228,21 @@ Commerce Optimizer for each locale configured in the `SFCC_LOCALES_TO_SYNC` envi Location: `actions/metadata` +#### Categories Sync + +This action retrieves all categories in SFCC and syncronizes them with Commerce Optimizer. + +Location: `actions/category` + +Entities Syncronized: + +- Categories + +> [!NOTE] Only letters, numbers, and hyphens are allowed in ACO category slugs. + +While syncronizing, the SFCC category ids are sanitized to remove special characters. Example: +`parent_cat/child_cat_1/child_cat_2/child_cat_3` becomes `parentcat/childcat1/childcat2/childcat3` + #### Specific Products Sync This action retrieves product data for the provided SKUs (SFCC product IDs) from SFCC and synchronizes them with @@ -348,6 +387,14 @@ The `lastMetadataSyncRun` key tracks the last time a metadata sync was successfu - Example Value: `2025-07-24T00:13:45.341Z` - Type: ISO 8601 String +#### Last Categories Sync Run + +The `lastCategorySyncRun` key tracks the last time a category sync was successfully finished. + +- Key: `lastCategorySyncRun` +- Example Value: `2025-07-24T00:13:45.341Z` +- Type: ISO 8601 String + #### Last Specific Product Sync Run The `lastSpecificProductsSyncRun` key tracks the last time a specific product sync was successfully finished. diff --git a/actions/category/external/actions.config.yaml b/actions/category/external/actions.config.yaml new file mode 100644 index 0000000..bc3780a --- /dev/null +++ b/actions/category/external/actions.config.yaml @@ -0,0 +1,41 @@ +consumer: + function: consumer/index.js + web: 'no' + runtime: nodejs:22 + annotations: + require-adobe-auth: true + final: true + +sync: + function: sync/index.js + web: 'no' + runtime: nodejs:22 + limits: + timeout: 10800000 # 3 hours + inputs: + LOG_LEVEL: debug + ACO_TENANT_ID: $ACO_TENANT_ID + ACO_REGION: $ACO_REGION + ACO_ENVIRONMENT_TYPE: $ACO_ENVIRONMENT_TYPE + SFCC_API_BASE_URL: $SFCC_API_BASE_URL + SFCC_REALM_ID: $SFCC_REALM_ID + SFCC_INSTANCE_ID: $SFCC_INSTANCE_ID + SFCC_ORGANIZATION_ID: $SFCC_ORGANIZATION_ID + SFCC_AUTH_URL: $SFCC_AUTH_URL + SFCC_CLIENT_ID: $SFCC_CLIENT_ID + SFCC_CLIENT_SECRET: $SFCC_CLIENT_SECRET + SFCC_SITE_ID: $SFCC_SITE_ID + SFCC_LOCALES_TO_SYNC: $SFCC_LOCALES_TO_SYNC + OAUTH_ORG_ID: $OAUTH_ORG_ID + OAUTH_CLIENT_ID: $OAUTH_CLIENT_ID + OAUTH_CLIENT_SECRET: $OAUTH_CLIENT_SECRET + OAUTH_TECHNICAL_ACCOUNT_ID: $OAUTH_TECHNICAL_ACCOUNT_ID + OAUTH_TECHNICAL_ACCOUNT_EMAIL: $OAUTH_TECHNICAL_ACCOUNT_EMAIL + IO_MANAGEMENT_BASE_URL: $IO_MANAGEMENT_BASE_URL + IO_CONSUMER_ID: $IO_CONSUMER_ID + IO_PROJECT_ID: $IO_PROJECT_ID + IO_WORKSPACE_ID: $IO_WORKSPACE_ID + AIO_runtime_namespace: $AIO_RUNTIME_NAMESPACE + annotations: + require-adobe-auth: true + final: true diff --git a/actions/category/external/consumer/index.js b/actions/category/external/consumer/index.js new file mode 100644 index 0000000..e3df5df --- /dev/null +++ b/actions/category/external/consumer/index.js @@ -0,0 +1,75 @@ +/* + Copyright 2025 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 { Core } = require('@adobe/aio-sdk'); +const { stringParameters, checkMissingRequestInputs } = require('../../../utils'); +const { HTTP_INTERNAL_ERROR, HTTP_BAD_REQUEST } = require('../../../constants'); +const Openwhisk = require('../../../openwhisk'); +const { errorResponse, successResponse } = require('../../../responses'); +const { instrumentConsumer } = require('../../../telemetry'); + +/** + * Handles the incoming category sync event and routes it to the appropiate runtime action. + * + * @param {CategoryActions.ConsumerEnv} params - The environment parameters. + * @returns {Promise} The response of the action. + */ +const main = async params => { + const logger = Core.Logger('category-external-consumer', { + level: params.LOG_LEVEL || 'info', + }); + + try { + const openwhiskClient = new Openwhisk(params.API_HOST, params.API_AUTH); + + let activationId; + + logger.info('Start processing request'); + logger.debug(`Consumer main params: ${stringParameters(params)}`); + + // check for missing request input parameters and headers + const requiredParams = ['type', 'data']; + const errorMessage = checkMissingRequestInputs(params, requiredParams, []); + + if (errorMessage) { + logger.error(`Invalid request parameters: ${errorMessage}`); + return errorResponse(HTTP_BAD_REQUEST, `Invalid request parameters: ${errorMessage}`); + } + + logger.info(`Params type: ${params.type}`); + switch (params.type) { + case 'sfcc.category.sync': { + logger.info('Invoking category sync'); + activationId = await openwhiskClient.invokeAction('category-backoffice/sync', params.data); + break; + } + default: { + logger.error(`Event type not found: ${params.type}`); + return errorResponse(HTTP_BAD_REQUEST, `This case type is not supported: ${params.type}`); + } + } + + if (!activationId) { + const message = `Error invoking action: ${params.type}`; + logger.error(message); + return errorResponse(HTTP_INTERNAL_ERROR, message); + } + + logger.info(`Action ${params.type} invoked. Activation ID: ${activationId}`); + return successResponse(params.type, { activationId }); + } catch (error) { + logger.error(`Server error: ${error.message}`); + return errorResponse(HTTP_INTERNAL_ERROR, error.message); + } +}; + +module.exports.main = instrumentConsumer(main); diff --git a/actions/category/external/sync/index.js b/actions/category/external/sync/index.js new file mode 100644 index 0000000..efea3ec --- /dev/null +++ b/actions/category/external/sync/index.js @@ -0,0 +1,58 @@ +/* + Copyright 2025 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 { Core } = require('@adobe/aio-sdk'); +const stateLib = require('@adobe/aio-lib-state'); +const { AIO_STATE_MAX_TTL, AIO_STATE_KEY_LAST_CATEGORY_SYNC_RUN, HTTP_INTERNAL_ERROR } = require('../../../constants'); +const { actionSuccessResponse, actionErrorResponse, StarterKitActionError } = require('../../../responses'); +const { defineMain } = require('../../../telemetry'); +const { configureAcoClient, configureSalesforceApiOptions, syncAllCategories } = require('../../../../api'); + +const main = async params => { + const logger = Core.Logger('sync-categories', { + level: params.LOG_LEVEL || 'info', + }); + + try { + logger.info(`Starting category sync for siteId: ${params.SFCC_SITE_ID}`); + logger.debug('Initializing AIO state lib'); + const state = await stateLib.init(); + + /** @type {SalesforceApiOptions} */ + const salesforceApiOptions = configureSalesforceApiOptions(params); + /** @type {AcoClientOptions} */ + const acoClientOptions = configureAcoClient(params); + const localesToSync = params.SFCC_LOCALES_TO_SYNC.split(','); + + logger.info(`Syncing all categories for siteId: ${params.SFCC_SITE_ID}`); + await syncAllCategories( + salesforceApiOptions, + acoClientOptions, + params.SFCC_ORGANIZATION_ID, + params.SFCC_SITE_ID, + localesToSync, + logger, + ); + + state.put(AIO_STATE_KEY_LAST_CATEGORY_SYNC_RUN, new Date().toISOString(), { ttl: AIO_STATE_MAX_TTL }); + logger.info('Category sync completed successfully'); + return actionSuccessResponse('Category sync completed successfully'); + } catch (error) { + logger.error(`Error syncing categories to ACO: ${error}`); + if (error instanceof StarterKitActionError) { + return actionErrorResponse(error.status, error.message); + } + return actionErrorResponse(HTTP_INTERNAL_ERROR, error.message); + } +}; + +module.exports.main = defineMain(main); diff --git a/actions/constants.js b/actions/constants.js index 16f7578..8debd42 100644 --- a/actions/constants.js +++ b/actions/constants.js @@ -26,6 +26,7 @@ const AIO_STATE_KEY_LAST_SYNC = 'lastSyncTimestamp'; const AIO_STATE_KEY_LAST_FULL_SYNC_RUN = 'lastFullSyncRun'; const AIO_STATE_KEY_LAST_DELTA_SYNC_RUN = 'lastDeltaSyncRun'; const AIO_STATE_KEY_LAST_PRICE_BOOK_SYNC_RUN = 'lastPriceBookSyncRun'; +const AIO_STATE_KEY_LAST_CATEGORY_SYNC_RUN = 'lastCategorySyncRun'; const AIO_STATE_KEY_LAST_METADATA_SYNC_RUN = 'lastMetadataSyncRun'; const AIO_STATE_KEY_LAST_SPECIFIC_PRODUCTS_SYNC_RUN = 'lastSpecificProductsSyncRun'; const AIO_STATE_KEY_FULL_SYNC_IN_PROGRESS = 'fullSyncInProgress'; @@ -44,6 +45,7 @@ module.exports = { AIO_STATE_KEY_LAST_FULL_SYNC_RUN, AIO_STATE_KEY_LAST_DELTA_SYNC_RUN, AIO_STATE_KEY_LAST_PRICE_BOOK_SYNC_RUN, + AIO_STATE_KEY_LAST_CATEGORY_SYNC_RUN, AIO_STATE_KEY_LAST_METADATA_SYNC_RUN, AIO_STATE_KEY_LAST_SPECIFIC_PRODUCTS_SYNC_RUN, AIO_STATE_KEY_FULL_SYNC_IN_PROGRESS, diff --git a/actions/full/external/sync/index.js b/actions/full/external/sync/index.js index fa000f4..27caf41 100644 --- a/actions/full/external/sync/index.js +++ b/actions/full/external/sync/index.js @@ -24,30 +24,13 @@ const { configureAcoClient, configureSalesforceApiOptions, createSalesforceAdminHttpClient, - getSalesforceSiteCatalogId, + getSiteCatalogId, syncAllMetadata, syncAllPriceBooks, syncAllProducts, + syncAllCategories, } = require('../../../../api'); -const getSiteCatalogId = async (salesforceClient, salesforceOrgId, siteId, logger) => { - logger.debug(`Retrieving catalog id for site ${siteId} from Salesforce`); - const res = await getSalesforceSiteCatalogId(salesforceClient, salesforceOrgId, siteId); - if (!res.ok) { - if (res.status === 404) { - logger.error(`No catalog id is assigned to SFCC siteId: ${siteId}`); - } else { - logger.error(`Failed to retrieve catalog id from Salesforce: ${res.status} ${res.statusText}`); - } - throw new StarterKitActionError('Failed to retrieve catalog id from Salesforce', res.status, res.statusText); - } - /** @type {SalesforceSiteCatalogResponse} */ - const data = await res.json(); - const catalogId = data.id; - logger.debug(`Using catalog id ${catalogId} for site ${siteId}`); - return catalogId; -}; - const fullSyncSite = async (params, logger) => { /** @type {SalesforceApiOptions} */ const salesforceApiOptions = configureSalesforceApiOptions(params); @@ -68,6 +51,15 @@ const fullSyncSite = async (params, logger) => { logger, ); + await syncAllCategories( + salesforceApiOptions, + acoClientOptions, + params.SFCC_ORGANIZATION_ID, + params.SFCC_SITE_ID, + localesToSync, + logger, + ); + for (const locale of localesToSync) { await syncAllProducts( salesforceApiOptions, diff --git a/actions/spa/last-sync-timestamps/index.js b/actions/spa/last-sync-timestamps/index.js index dd12fbc..0066278 100644 --- a/actions/spa/last-sync-timestamps/index.js +++ b/actions/spa/last-sync-timestamps/index.js @@ -14,6 +14,7 @@ const { Core } = require('@adobe/aio-sdk'); const stateLib = require('@adobe/aio-lib-state'); const { AIO_STATE_KEY_LAST_FULL_SYNC_RUN, + AIO_STATE_KEY_LAST_CATEGORY_SYNC_RUN, AIO_STATE_KEY_LAST_DELTA_SYNC_RUN, AIO_STATE_KEY_LAST_PRICE_BOOK_SYNC_RUN, AIO_STATE_KEY_LAST_METADATA_SYNC_RUN, @@ -35,6 +36,7 @@ const main = async params => { const lastDeltaSyncRun = await state.get(AIO_STATE_KEY_LAST_DELTA_SYNC_RUN); const lastPriceBookSyncRun = await state.get(AIO_STATE_KEY_LAST_PRICE_BOOK_SYNC_RUN); const lastMetadataSyncRun = await state.get(AIO_STATE_KEY_LAST_METADATA_SYNC_RUN); + const lastCategorySyncRun = await state.get(AIO_STATE_KEY_LAST_CATEGORY_SYNC_RUN); const lastSpecificProductsSyncRun = await state.get(AIO_STATE_KEY_LAST_SPECIFIC_PRODUCTS_SYNC_RUN); const lastSyncTimestamps = { @@ -42,6 +44,7 @@ const main = async params => { lastDeltaSyncRun: lastDeltaSyncRun?.value, lastPriceBookSyncRun: lastPriceBookSyncRun?.value, lastMetadataSyncRun: lastMetadataSyncRun?.value, + lastCategorySyncRun: lastCategorySyncRun?.value, lastSpecificProductsSyncRun: lastSpecificProductsSyncRun?.value, }; return actionSuccessResponse('Last sync timestamps retrieved successfully', { diff --git a/api/categories.js b/api/categories.js new file mode 100644 index 0000000..024636e --- /dev/null +++ b/api/categories.js @@ -0,0 +1,153 @@ +/* + Copyright 2025 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. +*/ + +// eslint-disable-next-line no-unused-vars +const { Core } = require('@adobe/aio-sdk'); +const { createAcoClient } = require('./aco'); +const { getSiteCatalogId } = require('./helpers'); +const { createSalesforceAdminHttpClient, getSalesforceCategories } = require('./salesforce'); +const { StarterKitActionError } = require('../actions/responses'); +const { transformCategories } = require('../transformers'); + +const SALESFORCE_BATCH_SIZE = 50; +const ACO_BATCH_SIZE = 100; + +/** + * Syncs all Categories from Salesforce to ACO. + * + * @param {SalesforceApiOptions} salesforceApiOptions - Configuration options for the API client. + * @param {AcoClientOptions} acoClientOptions - Configuration options for the ACO client. + * @param {string} salesforceOrgId - The Salesforce organization ID. + * @param {string} siteId - The Salesforce site ID. + * @param {string[]} locales - The locales to sync. + * @param {ReturnType} logger - The logger instance. + * @throws {StarterKitActionError} If there's an error retrieving or syncing the product. + */ +const syncAllCategories = async (salesforceApiOptions, acoClientOptions, salesforceOrgId, siteId, locales, logger) => { + logger.info('Syncing all categories from Salesforce to ACO'); + logger.debug('Retrieving categories from Salesforce'); + const client = await createSalesforceAdminHttpClient(salesforceApiOptions); + const acoClient = createAcoClient(acoClientOptions); + + let totalCategories = 0; + let offset = 0; + let totalSyncedCategories = 0; + let batchNumber = 1; + let totalAccepted = 0; + let categoriesBuffer = []; + + do { + const catalogId = await getSiteCatalogId(client, salesforceOrgId, siteId, logger); + const res = await getSalesforceCategories(client, salesforceOrgId, catalogId, SALESFORCE_BATCH_SIZE, offset); + if (!res.ok) { + logger.error(`Failed to get categories from Salesforce: ${res.status} ${res.statusText}`); + throw new StarterKitActionError('Failed to get categories from Salesforce', res.status, res.statusText); + } + + /** @type {SalesforceCategoryResponse} */ + const salesforceResponse = await res.json(); + /** @type {SalesforceCategory[]} */ + let salesforceCategories = salesforceResponse.data; + // Remove the root category + salesforceCategories = salesforceCategories.filter(category => category.id !== 'root'); + const categoriesInBatch = salesforceCategories.length; + totalCategories = salesforceResponse.total; + + if (categoriesInBatch > 0) { + logger.debug(`Retrieved Salesforce batch ${batchNumber} containing ${categoriesInBatch} categories.`); + categoriesBuffer.push(...salesforceCategories); + totalSyncedCategories += categoriesInBatch; + + // Flush buffer when we have enough to create a full ACO batch + if (categoriesBuffer.length >= ACO_BATCH_SIZE) { + // Process complete batches + while (categoriesBuffer.length >= ACO_BATCH_SIZE) { + const batch = categoriesBuffer.splice(0, ACO_BATCH_SIZE); + // Sync this batch for each locale + for (const locale of locales) { + totalAccepted += await syncCategoriesBatch(acoClient, batch, locale, logger); + } + } + } + } + + offset += SALESFORCE_BATCH_SIZE; + batchNumber++; + } while (offset < totalCategories); + + // Flush any remaining categories in the buffer + if (categoriesBuffer.length > 0) { + for (const locale of locales) { + totalAccepted += await syncCategoriesBatch(acoClient, categoriesBuffer, locale, logger); + } + } + + logger.info( + `Categories sync completed. Total categories synced: ${totalSyncedCategories}, total accepted: ${totalAccepted}`, + ); +}; + +/** + * Syncs a batch of Categories from Salesforce to ACO. + * + * @param {import('@adobe-commerce/aco-ts-sdk').Client} acoClient - The ACO client + * @param {SalesforceCategory[]} salesforceCategories - The Salesforce categories + * @param {string} locale - The locale to sync + * @param {ReturnType} logger - The logger instance + * @returns {Promise} The number of categories accepted by ACO + * @throws {StarterKitActionError} If there's an error retrieving or syncing the product + */ +const syncCategoriesBatch = async (acoClient, salesforceCategories, locale, logger) => { + logger.debug(`[${locale}] Transforming ${salesforceCategories.length} categories`); + const acoCategories = transformCategories(salesforceCategories, locale); + + logger.debug(`[${locale}] Syncing batch of ${acoCategories.length} categories to ACO`); + const acoRes = await acoClient.createCategories(acoCategories); + logger.debug(`[${locale}] ACO categories response: ${JSON.stringify(acoRes)}`); + + if (!acoRes.ok) { + logger.error(`[${locale}] Failed to sync categories to ACO: ${acoRes.status} ${acoRes.statusText}`); + throw new StarterKitActionError('Failed to sync categories to ACO', acoRes.status, acoRes.statusText); + } + + return acoRes.data.acceptedCount || 0; +}; + +/** + * Deletes a batch of categories from ACO for a single locale. + * + * @param {import('@adobe-commerce/aco-ts-sdk').Client} acoClient - The ACO client + * @param {string[]} slugs - The category slugs to delete + * @param {string} locale - The locale to delete the categories from + * @param {ReturnType} logger - The logger instance + * @returns {Promise} The number of categories deleted by ACO + * @throws {StarterKitActionError} If there's an error deleting the categories + */ +const deleteCategories = async (acoClient, slugs, locale, logger) => { + const deletionObjects = slugs.map(slug => ({ slug, source: { locale } })); + + logger.debug(`[${locale}] Deleting batch of ${deletionObjects.length} categories from ACO`); + const acoRes = await acoClient.deleteCategories(deletionObjects); + + if (!acoRes.ok) { + logger.error(`[${locale}] Failed to delete categories from ACO: ${acoRes.status} ${acoRes.statusText}`); + throw new StarterKitActionError('Failed to delete categories from ACO', acoRes.status, acoRes.statusText); + } + + return acoRes.data.acceptedCount || 0; +}; + +module.exports = { + syncAllCategories, + syncCategoriesBatch, + deleteCategories, +}; diff --git a/api/delta.js b/api/delta.js index 6bdda6f..3df7b15 100644 --- a/api/delta.js +++ b/api/delta.js @@ -14,8 +14,10 @@ const { Core } = require('@adobe/aio-sdk'); const stateLib = require('@adobe/aio-lib-state'); const { createAcoClient } = require('./aco'); +const { getSiteCatalogId } = require('./helpers'); const { createSalesforceAdminHttpClient, + getSalesforceCategoryById, getSalesforcePriceBookById, getSalesforceProductByIds, getSalesforceTrackedChanges, @@ -23,6 +25,7 @@ const { const { syncPriceBooksBatch, deletePriceBooks } = require('./priceBooks'); const { syncPricesFromProducts, deletePrices } = require('./prices'); const { syncProductsBatch, deleteProducts } = require('./products'); +const { syncCategoriesBatch, deleteCategories } = require('./categories'); const { AIO_STATE_MAX_TTL, AIO_STATE_KEY_LAST_SYNC } = require('../actions/constants'); const { StarterKitActionError } = require('../actions/responses'); @@ -58,6 +61,7 @@ const syncAllChanges = async ( logger.debug(`Retrieving tracked changes from Salesforce`); const salesforceClient = await createSalesforceAdminHttpClient(salesforceApiOptions); const acoClient = createAcoClient(acoClientOptions); + const catalogId = await getSiteCatalogId(salesforceClient, salesforceOrgId, siteId, logger); let totalChanges = 0; let offset = 0; @@ -98,9 +102,10 @@ const syncAllChanges = async ( salesforceClient, salesforceOrgId, siteId, + catalogId, locales, - logger, data.data, + logger, ); Object.keys(acceptedByType).forEach(type => { @@ -128,13 +133,23 @@ const syncAllChanges = async ( * @param {import('ky').KyInstance} salesforceClient - The Salesforce HTTP client * @param {string} salesforceOrgId - The Salesforce organization ID * @param {string} siteId - The Salesforce site ID + * @param {string} catalogId - The Salesforce catalog ID * @param {string[]} locales - The locales to sync - * @param {ReturnType} logger - The logger instance * @param {SalesforceTrackedChanges[]} changes - The tracked changes from Salesforce + * @param {ReturnType} logger - The logger instance * @returns {Promise<{ product: number; priceBook: number; price: number }>} The number of items accepted by ACO by type * @throws {StarterKitActionError} If there's an error syncing the changes */ -const syncChangesBatch = async (acoClient, salesforceClient, salesforceOrgId, siteId, locales, logger, changes) => { +const syncChangesBatch = async ( + acoClient, + salesforceClient, + salesforceOrgId, + siteId, + catalogId, + locales, + changes, + logger, +) => { logger.debug(`Executing batch sync for ${changes.length} changes.`); const acceptedByType = { product: 0, priceBook: 0, price: 0 }; @@ -162,8 +177,8 @@ const syncChangesBatch = async (acoClient, salesforceClient, salesforceOrgId, si salesforceOrgId, siteId, locale, - logger, typeChanges, + logger, ); acceptedByType.product += accepted; } @@ -174,8 +189,8 @@ const syncChangesBatch = async (acoClient, salesforceClient, salesforceOrgId, si salesforceClient, salesforceOrgId, siteId, - logger, typeChanges, + logger, ); break; case 'price': @@ -185,8 +200,19 @@ const syncChangesBatch = async (acoClient, salesforceClient, salesforceOrgId, si salesforceOrgId, siteId, locales[0], // The price is the same for all locales + typeChanges, logger, + ); + break; + case 'category': + acceptedByType.category = await syncCategoryChanges( + acoClient, + salesforceClient, + salesforceOrgId, + catalogId, + locales, typeChanges, + logger, ); break; default: @@ -208,12 +234,12 @@ const syncChangesBatch = async (acoClient, salesforceClient, salesforceOrgId, si * @param {string} salesforceOrgId - The Salesforce organization ID * @param {string} siteId - The Salesforce site ID * @param {string} locale - The locale to sync - * @param {ReturnType} logger - The logger instance * @param {SalesforceTrackedChanges[]} changes - The product changes from Salesforce + * @param {ReturnType} logger - The logger instance * @returns {Promise} The number of products accepted by ACO * @throws {StarterKitActionError} If there's an error syncing the changes */ -const syncProductChanges = async (acoClient, salesforceClient, salesforceOrgId, siteId, locale, logger, changes) => { +const syncProductChanges = async (acoClient, salesforceClient, salesforceOrgId, siteId, locale, changes, logger) => { logger.debug(`[${locale}] Processing ${changes.length} product changes`); const productIdsToSync = changes.filter(change => !change.isDeleted).map(change => change.entityId); @@ -250,12 +276,12 @@ const syncProductChanges = async (acoClient, salesforceClient, salesforceOrgId, * @param {import('ky').KyInstance} salesforceClient - The Salesforce HTTP client * @param {string} salesforceOrgId - The Salesforce organization ID * @param {string} siteId - The Salesforce site ID - * @param {ReturnType} logger - The logger instance * @param {SalesforceTrackedChanges[]} changes - The price book changes from Salesforce + * @param {ReturnType} logger - The logger instance * @returns {Promise} The number of price books accepted by ACO * @throws {StarterKitActionError} If there's an error fetching or syncing the price book */ -const syncPriceBookChanges = async (acoClient, salesforceClient, salesforceOrgId, siteId, logger, changes) => { +const syncPriceBookChanges = async (acoClient, salesforceClient, salesforceOrgId, siteId, changes, logger) => { logger.debug(`Processing ${changes.length} price book changes`); const salesforcePriceBooks = []; const priceBooksToDelete = []; @@ -306,12 +332,12 @@ const syncPriceBookChanges = async (acoClient, salesforceClient, salesforceOrgId * @param {string} salesforceOrgId - The Salesforce organization ID * @param {string} siteId - The Salesforce site ID * @param {string} locale - The locale to sync. This can be any valid locale, as the price is the same for all locales. - * @param {ReturnType} logger - The logger instance * @param {SalesforceTrackedChanges[]} changes - The price changes from Salesforce + * @param {ReturnType} logger - The logger instance * @returns {Promise} The number of prices accepted by ACO * @throws {StarterKitActionError} If there's an error syncing the changes */ -const syncPriceChanges = async (acoClient, salesforceClient, salesforceOrgId, siteId, locale, logger, changes) => { +const syncPriceChanges = async (acoClient, salesforceClient, salesforceOrgId, siteId, locale, changes, logger) => { logger.debug(`Processing ${changes.length} price changes`); const productIdsToSync = changes.filter(change => !change.isDeleted).map(change => change.entityId); @@ -352,6 +378,80 @@ const syncPriceChanges = async (acoClient, salesforceClient, salesforceOrgId, si return totalAccepted; }; +/** + * Syncs category changes by fetching categories and processing them. + * + * @param {import('@adobe-commerce/aco-ts-sdk').Client} acoClient - The ACO client + * @param {import('ky').KyInstance} salesforceClient - The Salesforce HTTP client + * @param {string} salesforceOrgId - The Salesforce organization ID + * @param {string} catalogId - The Salesforce catalog ID + * @param {string[]} locales - The locales to sync + * @param {SalesforceTrackedChanges[]} changes - The category changes from Salesforce + * @param {ReturnType} logger - The logger instance + * @returns {Promise} The number of categories accepted by ACO + * @throws {StarterKitActionError} If there's an error syncing the changes + */ +const syncCategoryChanges = async ( + acoClient, + salesforceClient, + salesforceOrgId, + catalogId, + locales, + changes, + logger, +) => { + logger.debug(`Processing ${changes.length} category changes`); + const salesforceCategories = []; + const categoriesToDelete = []; + + let totalAccepted = 0; + for (const change of changes) { + try { + const res = await getSalesforceCategoryById(salesforceClient, salesforceOrgId, catalogId, change.entityId); + logger.debug(`Status: ${res.status}`); + if (!res.ok) { + logger.error(`Failed to fetch category ${change.entityId}: ${res.status} ${res.statusText}`); + throw new StarterKitActionError('Failed to fetch category', res.status, res.statusText); + } + + /** @type {SalesforceCategory} */ + const categoryData = await res.json(); + salesforceCategories.push(categoryData); + } catch (error) { + // Handle 404 errors (category was deleted) + if (error.response && error.response.status === 404) { + logger.warn(`Category ${change.entityId} not found: also deleting from ACO`); + categoriesToDelete.push(change.entityId); + continue; + } + // Re-throw other errors + throw error; + } + } + + // Sync categories in batches of ACO_BATCH_SIZE for each locale + for (let i = 0; i < salesforceCategories.length; i += ACO_BATCH_SIZE) { + const categoryBatch = salesforceCategories.slice(i, i + ACO_BATCH_SIZE); + for (const locale of locales) { + const accepted = await syncCategoriesBatch(acoClient, categoryBatch, locale, logger); + totalAccepted += accepted; + } + } + + // Delete categories in batches of ACO_BATCH_SIZE for each locale + if (categoriesToDelete.length > 0) { + for (let i = 0; i < categoriesToDelete.length; i += ACO_BATCH_SIZE) { + const deletionBatch = categoriesToDelete.slice(i, i + ACO_BATCH_SIZE); + for (const locale of locales) { + const deleted = await deleteCategories(acoClient, deletionBatch, locale, logger); + totalAccepted += deleted; + } + } + } + + return totalAccepted; +}; + module.exports = { syncAllChanges, syncChangesBatch, diff --git a/api/helpers.js b/api/helpers.js new file mode 100644 index 0000000..caa4568 --- /dev/null +++ b/api/helpers.js @@ -0,0 +1,47 @@ +/* + Copyright 2025 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. +*/ + +// eslint-disable-next-line no-unused-vars +const { Core } = require('@adobe/aio-sdk'); +const { StarterKitActionError } = require('../actions/responses'); +const { getSalesforceSiteCatalogId } = require('./salesforce'); + +/** + * Retrieves the catalog ID for a given site from Salesforce. + * + * @param {import('ky').KyInstance} salesforceClient - The Salesforce HTTP API client. + * @param {string} salesforceOrgId - The Salesforce organization ID. + * @param {string} siteId - The Salesforce site ID. + * @param {ReturnType} logger - The logger instance. + * @returns {Promise} The catalog ID. + */ +const getSiteCatalogId = async (salesforceClient, salesforceOrgId, siteId, logger) => { + logger.debug(`Retrieving catalog id for site ${siteId} from Salesforce`); + const res = await getSalesforceSiteCatalogId(salesforceClient, salesforceOrgId, siteId); + if (!res.ok) { + if (res.status === 404) { + logger.error(`No catalog id is assigned to SFCC siteId: ${siteId}`); + } else { + logger.error(`Failed to retrieve catalog id from Salesforce: ${res.status} ${res.statusText}`); + } + throw new StarterKitActionError('Failed to retrieve catalog id from Salesforce', res.status, res.statusText); + } + /** @type {SalesforceSiteCatalogResponse} */ + const data = await res.json(); + const catalogId = data.id; + logger.debug(`Using catalog id ${catalogId} for site ${siteId}`); + return catalogId; +}; + +module.exports = { + getSiteCatalogId, +}; diff --git a/api/index.js b/api/index.js index f53d738..8dfd396 100644 --- a/api/index.js +++ b/api/index.js @@ -12,7 +12,9 @@ module.exports = { ...require('./aco'), + ...require('./categories'), ...require('./delta'), + ...require('./helpers'), ...require('./metadata'), ...require('./priceBooks'), ...require('./prices'), diff --git a/api/salesforce.js b/api/salesforce.js index b1a8a43..22fa851 100644 --- a/api/salesforce.js +++ b/api/salesforce.js @@ -11,7 +11,7 @@ */ const getScopes = (realmId, instanceId) => - `SALESFORCE_COMMERCE_API:${realmId}_${instanceId} sfcc.products sfcc.products.rw c_aco`; + `SALESFORCE_COMMERCE_API:${realmId}_${instanceId} sfcc.catalogs sfcc.products c_aco`; /** * Configures the Salesforce API options from environment parameters. @@ -159,6 +159,40 @@ const getSalesforcePriceBookById = async (client, organizationId, siteId, priceB }); }; +/** + * Get categories from Salesforce. + * + * @param {import('ky').KyInstance} client - The Salesforce HTTP client. + * @param {string} organizationId - The Salesforce organization ID. + * @param {string} catalogId - The Salesforce catalog ID. + * @param {number} limit - The response page size. Max 1000. + * @param {number} offset - The response page offset for pagination. + * @returns {Promise} The response from the Salesforce API. + */ +const getSalesforceCategories = async (client, organizationId, catalogId, limit, offset) => { + return await client.get(`product/catalogs/v1/organizations/${organizationId}/catalogs/${catalogId}/categories`, { + searchParams: { + limit, + offset, + }, + }); +}; + +/** + * Get category by ID from Salesforce. + * + * @param {import('ky').KyInstance} client - The Salesforce HTTP client. + * @param {string} organizationId - The Salesforce organization ID. + * @param {string} catalogId - The Salesforce catalog ID. + * @param {string} categoryId - The Salesforce category ID. + * @returns {Promise} The response from the Salesforce API. + */ +const getSalesforceCategoryById = async (client, organizationId, catalogId, categoryId) => { + return await client.get( + `product/catalogs/v1/organizations/${organizationId}/catalogs/${catalogId}/categories/${categoryId}`, + ); +}; + /** * Get product by list of IDs from Salesforce via our custom endpoint. * @@ -238,6 +272,8 @@ module.exports = { createSalesforceAdminHttpClient, getSalesforcePriceBooks, getSalesforcePriceBookById, + getSalesforceCategories, + getSalesforceCategoryById, getSalesforceProductByIds, getSalesforceSiteCatalogId, getSalesforceTrackedChanges, diff --git a/app.config.yaml b/app.config.yaml index d6a75b7..f1b2b22 100644 --- a/app.config.yaml +++ b/app.config.yaml @@ -34,16 +34,21 @@ application: $include: ./actions/inputs.yaml actions: $include: ./actions/delta/external/actions.config.yaml - # Uncomment the following to run the delta-backoffice action on a schedule (ie. every hour) - # triggers: - # everyMin: - # feed: /whisk.system/alarms/interval - # inputs: - # minutes: 60 - # rules: - # everyMinRule: - # trigger: everyMin - # action: delta-backoffice/consumer + # The following configures the delta-backoffice action + # to run on a cron schedule (ie. every hour at :15 minutes). + # Adjust this schedule as needed. + triggers: + everyHour: + feed: /whisk.system/alarms/alarm + inputs: + cron: 15 * * * * + trigger_payload: + type: sfcc.delta.sync + data: {} + rules: + everyHourRule: + trigger: everyHour + action: delta-backoffice/consumer metadata-backoffice: license: Apache-2.0 inputs: @@ -62,3 +67,9 @@ application: $include: ./actions/inputs.yaml actions: $include: ./actions/price-book/external/actions.config.yaml + category-backoffice: + license: Apache-2.0 + inputs: + $include: ./actions/inputs.yaml + actions: + $include: ./actions/category/external/actions.config.yaml diff --git a/package-lock.json b/package-lock.json index 586d63c..707d2af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "aco-sfcc-starter-kit", - "version": "1.0.0", + "version": "1.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aco-sfcc-starter-kit", - "version": "1.0.0", + "version": "1.0.1", "license": "Apache-2.0", "dependencies": { - "@adobe-commerce/aco-ts-sdk": "^1.0.0", + "@adobe-commerce/aco-ts-sdk": "^1.1.0", "@adobe/aio-lib-ims": "^8.1.0", "@adobe/aio-lib-state": "^5.1.0", "@adobe/aio-sdk": "^6", @@ -78,9 +78,9 @@ } }, "node_modules/@adobe-commerce/aco-ts-sdk": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@adobe-commerce/aco-ts-sdk/-/aco-ts-sdk-1.0.0.tgz", - "integrity": "sha512-khXaM+mYb1OId68sYQqi7zPdLdgwIdwTCSwenfmccEOXGgCUDPcFqPjmfvQUfZ2e5cK0ELG7aW5f4AA3BqF5pg==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@adobe-commerce/aco-ts-sdk/-/aco-ts-sdk-1.1.0.tgz", + "integrity": "sha512-rpPwyYQ3FcU6IcuFuwtpC4CWkGP3ZtHpr8SuiEgggACkiYu8MrqWVOVBe7svROplIkNoh8O+wFqZ6ag1PzxHbA==", "dependencies": { "dotenv": "^16.4.7", "ky": "^1.8.1" @@ -7033,9 +7033,9 @@ } }, "node_modules/axios": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz", - "integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", + "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", diff --git a/package.json b/package.json index 9a65d4f..b1a189f 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,11 @@ { "name": "aco-sfcc-starter-kit", - "version": "1.0.0", + "version": "1.1.0", "author": "Adobe Inc.", "license": "Apache-2.0", "private": true, "dependencies": { - "@adobe-commerce/aco-ts-sdk": "^1.0.0", + "@adobe-commerce/aco-ts-sdk": "^1.1.0", "@adobe/aio-lib-ims": "^8.1.0", "@adobe/aio-lib-state": "^5.1.0", "@adobe/aio-sdk": "^6", diff --git a/scripts/onboarding/config/events.json b/scripts/onboarding/config/events.json index 669c038..af93c66 100644 --- a/scripts/onboarding/config/events.json +++ b/scripts/onboarding/config/events.json @@ -45,5 +45,14 @@ } } } + }, + "category": { + "backoffice": { + "sfcc.category.sync": { + "sampleEventTemplate": { + "data": {} + } + } + } } } diff --git a/scripts/onboarding/config/starter-kit-registrations.json b/scripts/onboarding/config/starter-kit-registrations.json index ed11762..ed2f8a6 100644 --- a/scripts/onboarding/config/starter-kit-registrations.json +++ b/scripts/onboarding/config/starter-kit-registrations.json @@ -3,5 +3,6 @@ "delta": ["backoffice"], "metadata": ["backoffice"], "product": ["backoffice"], - "priceBook": ["backoffice"] + "priceBook": ["backoffice"], + "category": ["backoffice"] } diff --git a/test/actions/category/external/consumer.test.js b/test/actions/category/external/consumer.test.js new file mode 100644 index 0000000..b12f7a1 --- /dev/null +++ b/test/actions/category/external/consumer.test.js @@ -0,0 +1,165 @@ +/* + Copyright 2025 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. +*/ + +jest.mock('@adobe/aio-sdk', () => ({ + Core: { + Logger: jest.fn(), + }, +})); + +jest.mock('../../../../actions/openwhisk'); +jest.mock('../../../../actions/telemetry', () => ({ + instrumentConsumer: jest.fn(fn => fn), + defineActionErrorResponse: jest.fn(fn => fn), + defineActionSuccessResponse: jest.fn(fn => fn), +})); + +const { Core } = require('@adobe/aio-sdk'); +const mockLoggerInstance = { info: jest.fn(), debug: jest.fn(), error: jest.fn() }; +Core.Logger.mockReturnValue(mockLoggerInstance); + +const Openwhisk = require('../../../../actions/openwhisk'); +const action = require('../../../../actions/category/external/consumer/index.js'); + +beforeEach(() => { + Core.Logger.mockClear(); + mockLoggerInstance.info.mockReset(); + mockLoggerInstance.debug.mockReset(); + mockLoggerInstance.error.mockReset(); + Openwhisk.mockClear(); +}); + +const fakeParams = { + API_HOST: 'fake-host', + API_AUTH: 'fake-auth', + LOG_LEVEL: 'info', +}; + +describe('category-external-consumer', () => { + test('main should be defined', () => { + expect(action.main).toBeInstanceOf(Function); + }); + + test('should set logger to use LOG_LEVEL param', async () => { + const mockOpenwhiskInstance = { + invokeAction: jest.fn().mockResolvedValue('fake-activation-id'), + }; + Openwhisk.mockReturnValue(mockOpenwhiskInstance); + + await action.main({ + ...fakeParams, + LOG_LEVEL: 'debug', + type: 'sfcc.category.sync', + data: { siteId: 'test-site' }, + }); + + expect(Core.Logger).toHaveBeenCalledWith('category-external-consumer', { level: 'debug' }); + }); + + test('should handle sfcc.category.sync event type and invoke sync action', async () => { + const mockOpenwhiskInstance = { + invokeAction: jest.fn().mockResolvedValue('activation-456'), + }; + Openwhisk.mockReturnValue(mockOpenwhiskInstance); + + const testData = { siteId: 'test-site-123' }; + const response = await action.main({ + ...fakeParams, + type: 'sfcc.category.sync', + data: testData, + }); + + expect(mockOpenwhiskInstance.invokeAction).toHaveBeenCalledWith('category-backoffice/sync', testData); + expect(response).toEqual({ + statusCode: 200, + body: { + type: 'sfcc.category.sync', + response: { activationId: 'activation-456' }, + }, + }); + }); + + test('should return 400 for missing required parameters', async () => { + const response = await action.main({ + ...fakeParams, + // missing type and data + }); + + expect(response).toEqual({ + error: { + statusCode: 400, + body: { error: "Invalid request parameters: missing parameter(s) 'type,data'" }, + }, + }); + expect(mockLoggerInstance.error).toHaveBeenCalledWith( + "Invalid request parameters: missing parameter(s) 'type,data'", + ); + }); + + test('should return 400 for unsupported event type', async () => { + const response = await action.main({ + ...fakeParams, + type: 'unsupported.event.type', + data: { some: 'data' }, + }); + + expect(response).toEqual({ + error: { + statusCode: 400, + body: { error: 'This case type is not supported: unsupported.event.type' }, + }, + }); + expect(mockLoggerInstance.error).toHaveBeenCalledWith('Event type not found: unsupported.event.type'); + }); + + test('should return 500 when action invocation fails', async () => { + const mockOpenwhiskInstance = { + invokeAction: jest.fn().mockResolvedValue(null), + }; + Openwhisk.mockReturnValue(mockOpenwhiskInstance); + + const response = await action.main({ + ...fakeParams, + type: 'sfcc.category.sync', + data: { siteId: 'test-site' }, + }); + + expect(response).toEqual({ + error: { + statusCode: 500, + body: { error: 'Error invoking action: sfcc.category.sync' }, + }, + }); + expect(mockLoggerInstance.error).toHaveBeenCalledWith('Error invoking action: sfcc.category.sync'); + }); + + test('should return 500 and log error when openwhisk client throws error', async () => { + const fakeError = new Error('Openwhisk connection failed'); + Openwhisk.mockImplementation(() => { + throw fakeError; + }); + + const response = await action.main({ + ...fakeParams, + type: 'sfcc.category.sync', + data: { siteId: 'test-site' }, + }); + + expect(response).toEqual({ + error: { + statusCode: 500, + body: { error: 'Openwhisk connection failed' }, + }, + }); + expect(mockLoggerInstance.error).toHaveBeenCalledWith('Server error: Openwhisk connection failed'); + }); +}); diff --git a/test/actions/category/external/sync.test.js b/test/actions/category/external/sync.test.js new file mode 100644 index 0000000..5212970 --- /dev/null +++ b/test/actions/category/external/sync.test.js @@ -0,0 +1,187 @@ +/* + Copyright 2025 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. +*/ + +jest.mock('@adobe/aio-sdk', () => ({ + Core: { + Logger: jest.fn(), + }, +})); + +jest.mock('@adobe/aio-lib-state', () => ({ + init: jest.fn(), +})); + +jest.mock('../../../../actions/telemetry', () => ({ + defineMain: jest.fn(fn => fn), + defineActionErrorResponse: jest.fn(fn => fn), + defineActionSuccessResponse: jest.fn(fn => fn), +})); + +jest.mock('../../../../api', () => ({ + configureAcoClient: jest.fn(), + configureSalesforceApiOptions: jest.fn(), + syncAllCategories: jest.fn(), +})); + +const { Core } = require('@adobe/aio-sdk'); +const stateLib = require('@adobe/aio-lib-state'); +const { configureAcoClient, configureSalesforceApiOptions, syncAllCategories } = require('../../../../api'); +const action = require('../../../../actions/category/external/sync/index.js'); + +const mockLoggerInstance = { info: jest.fn(), debug: jest.fn(), error: jest.fn() }; +Core.Logger.mockReturnValue(mockLoggerInstance); + +const mockState = { + put: jest.fn(), +}; +stateLib.init.mockResolvedValue(mockState); + +beforeEach(() => { + Core.Logger.mockClear(); + mockLoggerInstance.info.mockReset(); + mockLoggerInstance.debug.mockReset(); + mockLoggerInstance.error.mockReset(); + stateLib.init.mockClear(); + mockState.put.mockReset(); + configureAcoClient.mockReset(); + configureSalesforceApiOptions.mockReset(); + syncAllCategories.mockReset(); +}); + +const fakeParams = { + LOG_LEVEL: 'info', + SFCC_SITE_ID: 'test-site', + SFCC_ORGANIZATION_ID: 'test-org', + SFCC_LOCALES_TO_SYNC: 'en-US,fr-FR', +}; + +describe('category-external-sync', () => { + test('main should be defined', () => { + expect(action.main).toBeInstanceOf(Function); + }); + + test('should set logger to use LOG_LEVEL param', async () => { + configureSalesforceApiOptions.mockReturnValue({ some: 'sf-config' }); + configureAcoClient.mockReturnValue({ aco: 'config' }); + syncAllCategories.mockResolvedValue(); + + await action.main({ + ...fakeParams, + LOG_LEVEL: 'debug', + }); + + expect(Core.Logger).toHaveBeenCalledWith('sync-categories', { level: 'debug' }); + }); + + test('should successfully sync categories and return success response', async () => { + configureSalesforceApiOptions.mockReturnValue({ sf: 'options' }); + configureAcoClient.mockReturnValue({ aco: 'options' }); + syncAllCategories.mockResolvedValue(); + + const response = await action.main(fakeParams); + + expect(configureSalesforceApiOptions).toHaveBeenCalledWith(fakeParams); + expect(configureAcoClient).toHaveBeenCalledWith(fakeParams); + expect(syncAllCategories).toHaveBeenCalledWith( + { sf: 'options' }, + { aco: 'options' }, + 'test-org', + 'test-site', + ['en-US', 'fr-FR'], + mockLoggerInstance, + ); + + expect(mockState.put).toHaveBeenCalledWith('lastCategorySyncRun', expect.any(String), { ttl: 31536000 }); + + expect(response).toEqual({ + statusCode: 200, + body: { + success: true, + message: 'Category sync completed successfully', + data: undefined, + }, + }); + }); + + test('should return 500 when sync fails with generic error', async () => { + configureSalesforceApiOptions.mockReturnValue({ sf: 'options' }); + configureAcoClient.mockReturnValue({ aco: 'options' }); + syncAllCategories.mockRejectedValue(new Error('API connection failed')); + + const response = await action.main(fakeParams); + + expect(response).toEqual({ + statusCode: 500, + body: { + success: false, + error: 'API connection failed', + }, + }); + expect(mockLoggerInstance.error).toHaveBeenCalledWith( + 'Error syncing categories to ACO: Error: API connection failed', + ); + }); + + test('should handle StarterKitActionError with custom status', async () => { + configureSalesforceApiOptions.mockReturnValue({ sf: 'options' }); + configureAcoClient.mockReturnValue({ aco: 'options' }); + + // Import the actual StarterKitActionError class + const { StarterKitActionError } = require('../../../../actions/responses'); + const customError = new StarterKitActionError('Category validation failed', 422, 'Unprocessable Entity'); + syncAllCategories.mockRejectedValue(customError); + + const response = await action.main(fakeParams); + + expect(response).toEqual({ + statusCode: 422, + body: { + success: false, + error: 'Category validation failed', + }, + }); + }); + + test('should initialize state lib and log debug messages', async () => { + configureSalesforceApiOptions.mockReturnValue({ sf: 'options' }); + configureAcoClient.mockReturnValue({ aco: 'options' }); + syncAllCategories.mockResolvedValue(); + + await action.main(fakeParams); + + expect(stateLib.init).toHaveBeenCalled(); + expect(mockLoggerInstance.info).toHaveBeenCalledWith('Starting category sync for siteId: test-site'); + expect(mockLoggerInstance.debug).toHaveBeenCalledWith('Initializing AIO state lib'); + expect(mockLoggerInstance.info).toHaveBeenCalledWith('Syncing all categories for siteId: test-site'); + expect(mockLoggerInstance.info).toHaveBeenCalledWith('Category sync completed successfully'); + }); + + test('should parse locales correctly from comma-separated string', async () => { + configureSalesforceApiOptions.mockReturnValue({ sf: 'options' }); + configureAcoClient.mockReturnValue({ aco: 'options' }); + syncAllCategories.mockResolvedValue(); + + await action.main({ + ...fakeParams, + SFCC_LOCALES_TO_SYNC: 'en-US,fr-FR,de-DE', + }); + + expect(syncAllCategories).toHaveBeenCalledWith( + { sf: 'options' }, + { aco: 'options' }, + 'test-org', + 'test-site', + ['en-US', 'fr-FR', 'de-DE'], + mockLoggerInstance, + ); + }); +}); diff --git a/test/actions/full/external/sync.test.js b/test/actions/full/external/sync.test.js index 6281a7c..18aa57d 100644 --- a/test/actions/full/external/sync.test.js +++ b/test/actions/full/external/sync.test.js @@ -30,9 +30,10 @@ jest.mock('../../../../api', () => ({ configureAcoClient: jest.fn(), configureSalesforceApiOptions: jest.fn(), createSalesforceAdminHttpClient: jest.fn(), - getSalesforceSiteCatalogId: jest.fn(), + getSiteCatalogId: jest.fn(), syncAllMetadata: jest.fn(), syncAllPriceBooks: jest.fn(), + syncAllCategories: jest.fn(), syncAllProducts: jest.fn(), })); @@ -42,9 +43,10 @@ const { configureAcoClient, configureSalesforceApiOptions, createSalesforceAdminHttpClient, - getSalesforceSiteCatalogId, + getSiteCatalogId, syncAllMetadata, syncAllPriceBooks, + syncAllCategories, syncAllProducts, } = require('../../../../api'); const action = require('../../../../actions/full/external/sync/index.js'); @@ -69,9 +71,10 @@ beforeEach(() => { configureAcoClient.mockReset(); configureSalesforceApiOptions.mockReset(); createSalesforceAdminHttpClient.mockReset(); - getSalesforceSiteCatalogId.mockReset(); + getSiteCatalogId.mockReset(); syncAllMetadata.mockReset(); syncAllPriceBooks.mockReset(); + syncAllCategories.mockReset(); syncAllProducts.mockReset(); }); @@ -92,9 +95,10 @@ describe('full-external-sync', () => { configureSalesforceApiOptions.mockReturnValue({ sf: 'config' }); configureAcoClient.mockReturnValue({ aco: 'config' }); createSalesforceAdminHttpClient.mockResolvedValue({ sf: 'client' }); - getSalesforceSiteCatalogId.mockResolvedValue({ ok: true, json: () => Promise.resolve({ id: 'catalog-123' }) }); + getSiteCatalogId.mockResolvedValue('catalog-123'); syncAllMetadata.mockResolvedValue(); syncAllPriceBooks.mockResolvedValue(); + syncAllCategories.mockResolvedValue(); syncAllProducts.mockResolvedValue(); await action.main({ @@ -126,9 +130,10 @@ describe('full-external-sync', () => { configureSalesforceApiOptions.mockReturnValue({ sf: 'options' }); configureAcoClient.mockReturnValue({ aco: 'options' }); createSalesforceAdminHttpClient.mockResolvedValue({ sf: 'client' }); - getSalesforceSiteCatalogId.mockResolvedValue({ ok: true, json: () => Promise.resolve({ id: 'catalog-456' }) }); + getSiteCatalogId.mockResolvedValue('catalog-456'); syncAllMetadata.mockResolvedValue(); syncAllPriceBooks.mockResolvedValue(); + syncAllCategories.mockResolvedValue(); syncAllProducts.mockResolvedValue(); const response = await action.main(fakeParams); @@ -136,7 +141,7 @@ describe('full-external-sync', () => { expect(configureSalesforceApiOptions).toHaveBeenCalledWith(fakeParams); expect(configureAcoClient).toHaveBeenCalledWith(fakeParams); expect(createSalesforceAdminHttpClient).toHaveBeenCalledWith({ sf: 'options' }); - expect(getSalesforceSiteCatalogId).toHaveBeenCalledWith({ sf: 'client' }, 'test-org', 'test-site'); + expect(getSiteCatalogId).toHaveBeenCalledWith({ sf: 'client' }, 'test-org', 'test-site', expect.anything()); expect(syncAllMetadata).toHaveBeenCalledWith({ aco: 'options' }, ['en_US', 'fr_FR'], mockLoggerInstance); expect(syncAllPriceBooks).toHaveBeenCalledWith( @@ -146,6 +151,14 @@ describe('full-external-sync', () => { 'test-site', mockLoggerInstance, ); + expect(syncAllCategories).toHaveBeenCalledWith( + { sf: 'options' }, + { aco: 'options' }, + 'test-org', + 'test-site', + ['en_US', 'fr_FR'], + mockLoggerInstance, + ); // Should sync products for each locale expect(syncAllProducts).toHaveBeenCalledTimes(2); @@ -187,7 +200,10 @@ describe('full-external-sync', () => { configureSalesforceApiOptions.mockReturnValue({ sf: 'options' }); configureAcoClient.mockReturnValue({ aco: 'options' }); createSalesforceAdminHttpClient.mockResolvedValue({ sf: 'client' }); - getSalesforceSiteCatalogId.mockResolvedValue({ ok: false, status: 404, statusText: 'Not Found' }); + + const { StarterKitActionError } = require('../../../../actions/responses'); + const catalogError = new StarterKitActionError('Failed to retrieve catalog id from Salesforce', 404, 'Not Found'); + getSiteCatalogId.mockRejectedValue(catalogError); const response = await action.main(fakeParams); @@ -202,8 +218,10 @@ describe('full-external-sync', () => { configureSalesforceApiOptions.mockReturnValue({ sf: 'options' }); configureAcoClient.mockReturnValue({ aco: 'options' }); createSalesforceAdminHttpClient.mockResolvedValue({ sf: 'client' }); - getSalesforceSiteCatalogId.mockResolvedValue({ ok: true, json: () => Promise.resolve({ id: 'catalog-789' }) }); - syncAllMetadata.mockRejectedValue(new Error('Full sync failed')); + getSiteCatalogId.mockResolvedValue('catalog-789'); + syncAllMetadata.mockResolvedValue(); + syncAllPriceBooks.mockResolvedValue(); + syncAllCategories.mockRejectedValue(new Error('Category sync failed')); const response = await action.main(fakeParams); @@ -211,10 +229,12 @@ describe('full-external-sync', () => { statusCode: 500, body: { success: false, - error: 'Full sync failed', + error: 'Category sync failed', }, }); - expect(mockLoggerInstance.error).toHaveBeenCalledWith('Error executing full site sync: Error: Full sync failed'); + expect(mockLoggerInstance.error).toHaveBeenCalledWith( + 'Error executing full site sync: Error: Category sync failed', + ); expect(mockState.put).toHaveBeenCalledWith('fullSyncInProgress', 'false', { ttl: 31536000 }); }); @@ -223,11 +243,13 @@ describe('full-external-sync', () => { configureSalesforceApiOptions.mockReturnValue({ sf: 'options' }); configureAcoClient.mockReturnValue({ aco: 'options' }); createSalesforceAdminHttpClient.mockResolvedValue({ sf: 'client' }); - getSalesforceSiteCatalogId.mockResolvedValue({ ok: true, json: () => Promise.resolve({ id: 'catalog-999' }) }); + getSiteCatalogId.mockResolvedValue('catalog-999'); + syncAllMetadata.mockResolvedValue(); + syncAllPriceBooks.mockResolvedValue(); const { StarterKitActionError } = require('../../../../actions/responses'); - const customError = new StarterKitActionError('Full sync validation failed', 422, 'Unprocessable Entity'); - syncAllMetadata.mockRejectedValue(customError); + const customError = new StarterKitActionError('Category validation failed', 422, 'Unprocessable Entity'); + syncAllCategories.mockRejectedValue(customError); const response = await action.main(fakeParams); @@ -235,7 +257,7 @@ describe('full-external-sync', () => { statusCode: 422, body: { success: false, - error: 'Full sync validation failed', + error: 'Category validation failed', }, }); expect(mockState.put).toHaveBeenCalledWith('fullSyncInProgress', 'false', { ttl: 31536000 }); diff --git a/transformers/category.js b/transformers/category.js new file mode 100644 index 0000000..1cc604f --- /dev/null +++ b/transformers/category.js @@ -0,0 +1,49 @@ +/* + Copyright 2025 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. +*/ + +/** + * Transforms Salesforce categories to ACO category format. + * + * @param {SalesforceCategory[]} categories - The Salesforce categories. + * @param {string} locale - The locale to use for the category. + * @returns {import('@adobe-commerce/aco-ts-sdk').FeedCategory[]} Array of ACO category objects. + */ +const transformCategories = (categories, locale) => { + const acoCategories = []; + categories.forEach(category => { + // Skip the root category + if (category.id === 'root') { + return; + } + // Skip the first element in the paths array (catalog ID) and join the remaining path IDs with "/" + const slug = category.paths + .slice(1) + .map(path => path.id.replace(/[^a-zA-Z0-9-]/g, '')) + .join('/'); + + const name = category.name?.[locale] || category.name?.default || category.id; + + acoCategories.push({ + slug, + source: { + locale, + }, + name, + families: [], + }); + }); + return acoCategories; +}; + +module.exports = { + transformCategories, +}; diff --git a/transformers/index.js b/transformers/index.js index 8914a73..0706330 100644 --- a/transformers/index.js +++ b/transformers/index.js @@ -11,6 +11,7 @@ */ module.exports = { + ...require('./category'), ...require('./priceBook'), ...require('./product'), ...require('./price'), diff --git a/transformers/product.js b/transformers/product.js index 29d2b66..9078524 100644 --- a/transformers/product.js +++ b/transformers/product.js @@ -146,6 +146,48 @@ const buildAcoBundles = bundledProducts => { })); }; +/** + * Builds hierarchical category paths from parent-child relationships. + * + * @param {SalesforceCategory[]} categories - Array of category objects + * @returns {object[]} Array of route objects with hierarchical paths + */ +const transformCategoryRoutes = categories => { + if (!Array.isArray(categories) || categories.length === 0) return []; + + const categoryMap = new Map(); + categories.forEach(category => { + categoryMap.set(category.id, category); + }); + + const buildPath = (categoryId, visited = new Set()) => { + if (visited.has(categoryId)) return ''; + visited.add(categoryId); + + const category = categoryMap.get(categoryId); + if (!category) return ''; + + // If parent is root or doesn't exist, return just the category ID + if (!category.parentId || category.parentId === 'root') { + return category.id; + } + + // Recursively build the parent path + const parentPath = buildPath(category.parentId, visited); + return parentPath ? `${parentPath}/${category.id}` : category.id; + }; + + const paths = new Set(); + categories.forEach(category => { + const path = buildPath(category.id); + if (path) { + paths.add(path); + } + }); + + return Array.from(paths).map(path => ({ path })); +}; + /** * Transforms a Salesforce product to an ACO product. * @@ -189,6 +231,7 @@ const transformProduct = product => { ...transformCustomAttributes(product.customAttributes), ], images: transformImages(product.images), + routes: transformCategoryRoutes(product.categories), }; //add configurations @@ -197,15 +240,17 @@ const transformProduct = product => { } if (product.type === 'VARIANT') { - const masterSku = product.master.id; - acoProduct.links = [ - { - type: 'variant_of', - sku: masterSku, - }, - ]; - //attributes - acoProduct.attributes.push(...transformVariantValues(masterSku, product.variationValues)); + const masterSku = product?.master?.id; + if (masterSku) { + acoProduct.links = [ + { + type: 'variant_of', + sku: masterSku, + }, + ]; + //attributes + acoProduct.attributes.push(...transformVariantValues(masterSku, product.variationValues)); + } } // Handle parent bundle product (type BUNDLE) using correct bundles array structure @@ -221,7 +266,6 @@ const transformProduct = product => { })); } - // @ts-ignore return acoProduct; }; diff --git a/types/actions.d.ts b/types/actions.d.ts index 702ddae..08c6490 100644 --- a/types/actions.d.ts +++ b/types/actions.d.ts @@ -41,6 +41,19 @@ namespace PriceBookActions { }; } +namespace CategoryActions { + /** The specific environment parameters received by the `category/external/sync` action. */ + declare interface SyncEnv extends GlobalEnv { + data: {}; + } + + /** The specific environment parameters received by the `category/external/consumer` action. */ + declare type ConsumerEnv = GlobalEnv & { + type: 'sfcc.category.sync'; + data: {}; + }; +} + namespace FullSyncActions { /** The specific environment parameters received by the `full-sync/external/sync` action. */ declare interface SyncEnv extends GlobalEnv { diff --git a/types/salesforce.d.ts b/types/salesforce.d.ts index b61a923..58ff055 100644 --- a/types/salesforce.d.ts +++ b/types/salesforce.d.ts @@ -46,11 +46,10 @@ declare interface SalesforceProduct { searchable: boolean; searchableFlag: boolean; inStock: boolean; + categories: SalesforceCategory[]; prices: SalesforceProductPrice[]; images: SalesforceProductImage[]; customAttributes: SalesforceProductCustomAttributes[]; - creationDate: string; - lastModified: string; type: | 'SIMPLE' | 'MASTER' @@ -66,6 +65,8 @@ declare interface SalesforceProduct { master?: SalesforceProductMaster; bundles?: string[]; // Array of bundled product IDs for BUNDLE type bundledProducts?: BundledProduct[]; // Array of bundled products with details for a parent bundle product + creationDate: string; + lastModified: string; } declare interface SalesforceProductPrice { @@ -172,3 +173,28 @@ declare interface BundledProduct { name: string; quantity: number; } + +declare interface SalesforceCategory { + id: string; + name: SalesforceLocalizedName; + parentCategoryId: string; + catalogId: string; + paths: SalesforceCategoryPath[]; +} + +declare interface SalesforceLocalizedName { + default: string; + [locale: string]: string; +} + +declare interface SalesforceCategoryPath { + id: string; + name: SalesforceLocalizedName; +} + +declare interface SalesforceCategoryResponse { + limit: number; + offset: number; + total: number; + data: SalesforceCategory[]; +} diff --git a/web-src/src/components/sync-categories-card.jsx b/web-src/src/components/sync-categories-card.jsx new file mode 100644 index 0000000..b77bc78 --- /dev/null +++ b/web-src/src/components/sync-categories-card.jsx @@ -0,0 +1,75 @@ +/* + Copyright 2025 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. +*/ + +import React from 'react'; +import { View, Heading, Flex, Text, Divider, Button } from '@adobe/react-spectrum'; +import Sync from '@spectrum-icons/workflow/Sync'; +import DataUpload from '@spectrum-icons/workflow/DataUpload'; +import { StatusBadge } from './status-badge'; +import { useWebAction } from '../hooks/use-web-action'; + +const EXTERNAL_EVENT_TYPE = 'sfcc.category.sync'; +const INTERNAL_ACTION_NAME = 'spa/ingestion'; + +const buildEventPayload = () => ({ + json: { + data: { + event: EXTERNAL_EVENT_TYPE, + value: { + data: {}, + }, + }, + }, +}); + +export function SyncCategoriesCard({ lastSync }) { + const { status, lastRun, invokeAction } = useWebAction(INTERNAL_ACTION_NAME); + + const handleSync = () => { + invokeAction(buildEventPayload()); + }; + + return ( + + + + + + + Category Sync + + + Synchronize all categories from SFCC to your ACO instance. + + + + + + + + + + + + + ); +} diff --git a/web-src/src/pages/home.jsx b/web-src/src/pages/home.jsx index 7902177..f354b1c 100644 --- a/web-src/src/pages/home.jsx +++ b/web-src/src/pages/home.jsx @@ -21,6 +21,7 @@ import { PdpApiCard } from '../components/pdp-api-card'; import { SyncPredefinedMetadataCard } from '../components/sync-predefined-metadata-card'; import { SyncSpecificProductsCard } from '../components/sync-specific-products-card'; import { SyncPriceBooksCard } from '../components/sync-price-books-card'; +import { SyncCategoriesCard } from '../components/sync-categories-card'; import { SiteContextCard } from '../components/site-context'; import { DeltaSyncCard } from '../components/delta-sync-card'; import { useWebAction } from '../hooks/use-web-action'; @@ -66,6 +67,7 @@ export function Home() { +