From bec8d812e8ef3eee45fd2e1d24731301db5441da Mon Sep 17 00:00:00 2001 From: Dawid Date: Wed, 12 Apr 2023 16:10:32 +0200 Subject: [PATCH] Bulk product export to CMS providers (#351) * Add sync all channel products feature * Implement batch create and delete product variants CMSes sync methods * Fix pnpm-lock file * Update UI * Update imports * Add fetch rate limit to Contentful provider * Small refactor of functions * Update logging --- apps/cms/CONTRIBUTING.md | 2 +- apps/cms/docs/contentful.md | 1 + apps/cms/graphql/queries/ProductsData.graphql | 13 ++ apps/cms/package.json | 4 +- .../src/lib/cms/client/clients-execution.ts | 117 +++++++++++- .../src/lib/cms/client/clients-operations.ts | 6 +- apps/cms/src/lib/cms/client/index.ts | 2 +- .../src/lib/cms/client/metadata-execution.ts | 130 ++++++++++--- apps/cms/src/lib/cms/config/channels.ts | 1 + apps/cms/src/lib/cms/config/providers.ts | 7 + apps/cms/src/lib/cms/data-sync.ts | 16 ++ apps/cms/src/lib/cms/providers/contentful.ts | 176 +++++++++++++----- apps/cms/src/lib/cms/providers/datocms.ts | 98 ++++++---- apps/cms/src/lib/cms/providers/strapi.ts | 88 ++++++--- apps/cms/src/lib/cms/types.ts | 25 ++- apps/cms/src/lib/graphql.ts | 66 +++---- .../ui/channel-configuration-form.tsx | 135 ++++++++++---- .../channels/ui/channel-configuration.tsx | 12 +- .../src/modules/channels/ui/channels-list.tsx | 7 +- apps/cms/src/modules/channels/ui/channels.tsx | 48 ++++- .../modules/channels/ui/hooks/useChannels.ts | 55 +++++- .../channels/ui/hooks/useChannelsFetch.ts | 10 + apps/cms/src/modules/channels/ui/types.ts | 12 +- .../cms/hooks/useProductsVariantsSync.ts | 138 ++++++++++++++ .../modules/cms/hooks/useQueryAllProducts.tsx | 81 ++++++++ .../src/pages/api/sync-products-variants.ts | 169 +++++++++++++++++ .../src/pages/api/webhooks/product-updated.ts | 11 +- .../api/webhooks/product-variant-created.ts | 11 +- .../api/webhooks/product-variant-deleted.ts | 11 +- .../api/webhooks/product-variant-updated.ts | 11 +- 30 files changed, 1197 insertions(+), 266 deletions(-) create mode 100644 apps/cms/graphql/queries/ProductsData.graphql create mode 100644 apps/cms/src/lib/cms/data-sync.ts create mode 100644 apps/cms/src/modules/cms/hooks/useProductsVariantsSync.ts create mode 100644 apps/cms/src/modules/cms/hooks/useQueryAllProducts.tsx create mode 100644 apps/cms/src/pages/api/sync-products-variants.ts diff --git a/apps/cms/CONTRIBUTING.md b/apps/cms/CONTRIBUTING.md index 3bbc954..96c569d 100644 --- a/apps/cms/CONTRIBUTING.md +++ b/apps/cms/CONTRIBUTING.md @@ -81,7 +81,7 @@ const payloadOperations: CreateOperations = (config) => { } // This is where you write logic for all the supported operations (e.g. creating a product). This function runs only if the config was successfully validated. -export default createProvider(payloadOperations, payloadConfigSchema); // `createProvider` combines everything together. +export const payloadProvider = createProvider(payloadOperations, payloadConfigSchema); // `createProvider` combines everything together. ``` 5. Implement the operations: diff --git a/apps/cms/docs/contentful.md b/apps/cms/docs/contentful.md index 99978e1..fcc2c30 100644 --- a/apps/cms/docs/contentful.md +++ b/apps/cms/docs/contentful.md @@ -12,3 +12,4 @@ Here is the list of the tokens and instructions on how to obtain them: - `spaceId`: id of the Contentful space. To find it, go to _Settings -> General settings_ in the Contentful dashboard. - `contentId`: the id of the content model. To obtain it, go to _Content model_ and to the view of a single product in your Contentful dashboard. Your URL may look something like: "https://app.contentful.com/spaces/xxxx/content_types/product/fields". Then, look to the right side of the screen. You will find a copyable "CONTENT TYPE ID" box there. - `locale`: the localization code for your content. E.g.: `en-US`. +- `apiRequestsPerSecond`: API rate limits (API requests per second). The default is 7. Used in bulk products variants sync. Higher rate limits may speed up a little products variants bulk sync. Higher rate limit may apply depending on different Contentful plan, learn more at https://www.contentful.com/developers/docs/references/content-management-api/#/introduction/api-rate-limits. diff --git a/apps/cms/graphql/queries/ProductsData.graphql b/apps/cms/graphql/queries/ProductsData.graphql new file mode 100644 index 0000000..d29152b --- /dev/null +++ b/apps/cms/graphql/queries/ProductsData.graphql @@ -0,0 +1,13 @@ +query ProductsDataForImport($first: Int, $channel: String, $after: String) { + products(first: $first, channel: $channel, after: $after) { + pageInfo { + hasNextPage + endCursor + } + edges { + node { + ...WebhookProduct + } + } + } +} diff --git a/apps/cms/package.json b/apps/cms/package.json index 03faefc..e33fa5e 100644 --- a/apps/cms/package.json +++ b/apps/cms/package.json @@ -21,6 +21,7 @@ "@material-ui/icons": "^4.11.3", "@material-ui/lab": "4.0.0-alpha.61", "@saleor/app-sdk": "0.37.1", + "@saleor/apps-shared": "workspace:*", "@saleor/macaw-ui": "^0.6.7", "@sentry/nextjs": "^7.43.0", "@urql/exchange-auth": "^1.0.0", @@ -38,8 +39,7 @@ "usehooks-ts": "^2.9.1", "uuid": "^9.0.0", "vite": "^4.1.4", - "zod": "^3.19.1", - "@saleor/apps-shared": "workspace:*" + "zod": "^3.19.1" }, "devDependencies": { "@graphql-codegen/cli": "2.13.3", diff --git a/apps/cms/src/lib/cms/client/clients-execution.ts b/apps/cms/src/lib/cms/client/clients-execution.ts index ab0b70d..d71c903 100644 --- a/apps/cms/src/lib/cms/client/clients-execution.ts +++ b/apps/cms/src/lib/cms/client/clients-execution.ts @@ -2,7 +2,7 @@ import { ProductVariantUpdatedWebhookPayloadFragment, WebhookProductVariantFragment, } from "../../../../generated/graphql"; -import { CmsClientOperations } from "../types"; +import { CmsClientBatchOperations, CmsClientOperations, ProductResponseSuccess } from "../types"; import { getCmsIdFromSaleorItem } from "./metadata"; import { logger as pinoLogger } from "../../logger"; @@ -104,6 +104,121 @@ const executeCmsClientOperation = async ({ } }; +interface CmsClientBatchOperationResult { + createdCmsIds?: ProductResponseSuccess["data"][]; + deletedCmsIds?: ProductResponseSuccess["data"][]; + error?: string; +} + +export const executeCmsClientBatchOperation = async ({ + cmsClient, + productsVariants, + verifyIfProductVariantIsAvailableInOtherChannelEnabledForSelectedProviderInstance, +}: { + cmsClient: CmsClientBatchOperations; + productsVariants: WebhookProductVariantFragment[]; + /** + * Lookup function with purposely long name like in Java Spring ORM to verify condition against unintended deletion of product variant from CMS. + * On purpose passed as an argument, for inversion of control. + */ + verifyIfProductVariantIsAvailableInOtherChannelEnabledForSelectedProviderInstance: ( + productVariant: WebhookProductVariantFragment + ) => boolean; +}): Promise => { + const logger = pinoLogger.child({ cmsClient }); + logger.debug({ operations: cmsClient.operations }, "Execute CMS client operation called"); + + if (cmsClient.operationType === "createBatchProducts") { + const productsVariansToCreate = productsVariants.reduce( + (productsVariansToCreate, productVariant) => { + const cmsId = getCmsIdFromSaleorItem(productVariant, cmsClient.cmsProviderInstanceId); + + if (!cmsId) { + return [...productsVariansToCreate, productVariant]; + } + + return productsVariansToCreate; + }, + [] as WebhookProductVariantFragment[] + ); + + if (productsVariansToCreate.length) { + logger.debug("CMS creating batch items called"); + + try { + const createBatchProductsResponse = await cmsClient.operations.createBatchProducts({ + input: productsVariansToCreate.map((productVariant) => ({ + saleorId: productVariant.id, + sku: productVariant.sku, + name: productVariant.name, + image: productVariant.product.media?.[0]?.url ?? "", + productId: productVariant.product.id, + productName: productVariant.product.name, + productSlug: productVariant.product.slug, + channels: productVariant.channelListings?.map((cl) => cl.channel.slug) || [], + })), + }); + + return { + createdCmsIds: + createBatchProductsResponse + ?.filter((item) => item.ok && "data" in item) + .map((item) => (item as ProductResponseSuccess).data) || [], + }; + } catch (error) { + logger.error({ error }, "Error creating batch items"); + + return { + error: "Error creating batch items.", + }; + } + } + } + + if (cmsClient.operationType === "deleteBatchProducts") { + const CMSIdsToRemove = productsVariants.reduce((CMSIdsToRemove, productVariant) => { + const cmsId = getCmsIdFromSaleorItem(productVariant, cmsClient.cmsProviderInstanceId); + + const productVariantIsAvailableInOtherChannelEnabledForSelectedProviderInstance = + verifyIfProductVariantIsAvailableInOtherChannelEnabledForSelectedProviderInstance( + productVariant + ); + + if (cmsId && !productVariantIsAvailableInOtherChannelEnabledForSelectedProviderInstance) { + return [ + ...CMSIdsToRemove, + { + id: cmsId, + saleorId: productVariant.id, + }, + ]; + } + + return CMSIdsToRemove; + }, [] as ProductResponseSuccess["data"][]); + + if (CMSIdsToRemove.length) { + logger.debug("CMS removing batch items called"); + + try { + await cmsClient.operations.deleteBatchProducts({ + ids: CMSIdsToRemove.map((item) => item.id), + }); + + return { + deletedCmsIds: CMSIdsToRemove, + }; + } catch (error) { + logger.error({ error }, "Error removing batch items"); + + return { + error: "Error removing batch items.", + }; + } + } + } +}; + export const executeCmsOperations = async ({ cmsOperations, productVariant, diff --git a/apps/cms/src/lib/cms/client/clients-operations.ts b/apps/cms/src/lib/cms/client/clients-operations.ts index cf88712..f784f99 100644 --- a/apps/cms/src/lib/cms/client/clients-operations.ts +++ b/apps/cms/src/lib/cms/client/clients-operations.ts @@ -33,8 +33,10 @@ export const createCmsOperations = async ({ const settingsManager = createSettingsManager(client); - const channelsSettingsParsed = await getChannelsSettings(settingsManager); - const providerInstancesSettingsParsed = await getProviderInstancesSettings(settingsManager); + const [channelsSettingsParsed, providerInstancesSettingsParsed] = await Promise.all([ + getChannelsSettings(settingsManager), + getProviderInstancesSettings(settingsManager), + ]); const productVariantCmsProviderInstances = productVariantCmsKeys.map((cmsKey) => getCmsIdFromSaleorItemKey(cmsKey) diff --git a/apps/cms/src/lib/cms/client/index.ts b/apps/cms/src/lib/cms/client/index.ts index f27734f..e6733f0 100644 --- a/apps/cms/src/lib/cms/client/index.ts +++ b/apps/cms/src/lib/cms/client/index.ts @@ -1,3 +1,3 @@ export { createCmsOperations } from "./clients-operations"; export { executeCmsOperations } from "./clients-execution"; -export { executeMetadataUpdate } from "./metadata-execution"; +export { updateMetadata, batchUpdateMetadata } from "./metadata-execution"; diff --git a/apps/cms/src/lib/cms/client/metadata-execution.ts b/apps/cms/src/lib/cms/client/metadata-execution.ts index 193fc50..709562e 100644 --- a/apps/cms/src/lib/cms/client/metadata-execution.ts +++ b/apps/cms/src/lib/cms/client/metadata-execution.ts @@ -1,4 +1,5 @@ import { NextWebhookApiHandler } from "@saleor/app-sdk/handlers/next"; +import { Client } from "urql"; import { DeleteMetadataDocument, UpdateMetadataDocument, @@ -9,7 +10,45 @@ import { createCmsKeyForSaleorItem } from "./metadata"; type WebhookContext = Parameters["2"]; -export const executeMetadataUpdate = async ({ +export type MetadataRecord = Record; + +const executeMetadataUpdateMutation = async ({ + apiClient, + itemId, + cmsProviderInstanceIdsToCreate = {}, + cmsProviderInstanceIdsToDelete = {}, +}: { + apiClient: Client; + itemId: string; + cmsProviderInstanceIdsToCreate?: Record; + cmsProviderInstanceIdsToDelete?: Record; +}) => { + if (Object.keys(cmsProviderInstanceIdsToCreate).length) { + await apiClient + .mutation(UpdateMetadataDocument, { + id: itemId, + input: Object.entries(cmsProviderInstanceIdsToCreate).map( + ([cmsProviderInstanceId, cmsProductVariantId]) => ({ + key: createCmsKeyForSaleorItem(cmsProviderInstanceId), + value: cmsProductVariantId, + }) + ), + }) + .toPromise(); + } + if (Object.keys(cmsProviderInstanceIdsToDelete).length) { + await apiClient + .mutation(DeleteMetadataDocument, { + id: itemId, + keys: Object.entries(cmsProviderInstanceIdsToDelete).map(([cmsProviderInstanceId]) => + createCmsKeyForSaleorItem(cmsProviderInstanceId) + ), + }) + .toPromise(); + } +}; + +export const updateMetadata = async ({ context, productVariant, cmsProviderInstanceIdsToCreate, @@ -23,27 +62,70 @@ export const executeMetadataUpdate = async ({ const { token, saleorApiUrl } = context.authData; const apiClient = createClient(saleorApiUrl, async () => ({ token })); - if (Object.keys(cmsProviderInstanceIdsToCreate).length) { - await apiClient - .mutation(UpdateMetadataDocument, { - id: productVariant.id, - input: Object.entries(cmsProviderInstanceIdsToCreate).map( - ([cmsProviderInstanceId, cmsProductVariantId]) => ({ - key: createCmsKeyForSaleorItem(cmsProviderInstanceId), - value: cmsProductVariantId, - }) - ), - }) - .toPromise(); - } - if (Object.keys(cmsProviderInstanceIdsToDelete).length) { - await apiClient - .mutation(DeleteMetadataDocument, { - id: productVariant.id, - keys: Object.entries(cmsProviderInstanceIdsToDelete).map(([cmsProviderInstanceId]) => - createCmsKeyForSaleorItem(cmsProviderInstanceId) - ), - }) - .toPromise(); - } + await executeMetadataUpdateMutation({ + apiClient, + itemId: productVariant.id, + cmsProviderInstanceIdsToCreate, + cmsProviderInstanceIdsToDelete, + }); +}; + +type ItemMetadataRecord = { + id: string; + cmsProviderInstanceIds: MetadataRecord; +}; + +export const batchUpdateMetadata = async ({ + context, + variantCMSProviderInstanceIdsToCreate, + variantCMSProviderInstanceIdsToDelete, +}: { + context: Pick; + variantCMSProviderInstanceIdsToCreate: ItemMetadataRecord[]; + variantCMSProviderInstanceIdsToDelete: ItemMetadataRecord[]; +}) => { + const { token, saleorApiUrl } = context.authData; + const apiClient = createClient(saleorApiUrl, async () => ({ token })); + + const variantCMSProviderInstanceIdsToCreateMap = variantCMSProviderInstanceIdsToCreate.reduce( + (acc, { id, cmsProviderInstanceIds }) => ({ + ...acc, + [id]: { + ...(acc[id] || {}), + ...cmsProviderInstanceIds, + }, + }), + {} as Record + ); + const variantCMSProviderInstanceIdsToDeleteMap = variantCMSProviderInstanceIdsToDelete.reduce( + (acc, { id, cmsProviderInstanceIds }) => ({ + ...acc, + [id]: { + ...(acc[id] || {}), + ...cmsProviderInstanceIds, + }, + }), + {} as Record + ); + + const mutationsToExecute = [ + Object.entries(variantCMSProviderInstanceIdsToCreateMap).map( + ([itemId, cmsProviderInstanceIdsToCreate]) => + executeMetadataUpdateMutation({ + apiClient, + itemId, + cmsProviderInstanceIdsToCreate, + }) + ), + Object.entries(variantCMSProviderInstanceIdsToDeleteMap).map( + ([itemId, cmsProviderInstanceIdsToDelete]) => + executeMetadataUpdateMutation({ + apiClient, + itemId, + cmsProviderInstanceIdsToDelete, + }) + ), + ]; + + await Promise.all(mutationsToExecute); }; diff --git a/apps/cms/src/lib/cms/config/channels.ts b/apps/cms/src/lib/cms/config/channels.ts index 7b7ec1c..12dc03a 100644 --- a/apps/cms/src/lib/cms/config/channels.ts +++ b/apps/cms/src/lib/cms/config/channels.ts @@ -10,6 +10,7 @@ export type ChannelCommonSchema = z.infer; export const channelSchema = z .object({ enabledProviderInstances: z.array(z.string()), + requireSyncProviderInstances: z.array(z.string()).optional(), }) .merge(channelCommonSchema); diff --git a/apps/cms/src/lib/cms/config/providers.ts b/apps/cms/src/lib/cms/config/providers.ts index d097ae1..582cedf 100644 --- a/apps/cms/src/lib/cms/config/providers.ts +++ b/apps/cms/src/lib/cms/config/providers.ts @@ -67,6 +67,12 @@ export const providersConfig = { helpText: "Content management API URL of your Contentful project. If you leave this blank, default https://api.contentful.com will be used.", }, + { + name: "apiRequestsPerSecond", + label: "API requests per second", + helpText: + "API rate limits. Default 7. Used in bulk products variants sync. Higher rate limits may speed up a little products variants bulk sync. Higher rate limit may apply depending on different Contentful plan, learn more at https://www.contentful.com/developers/docs/references/content-management-api/#/introduction/api-rate-limits.", + }, ], }, strapi: { @@ -152,6 +158,7 @@ export const contentfulConfigSchema = z.object({ locale: z.string().min(1), contentId: z.string().min(1), baseUrl: z.string(), + apiRequestsPerSecond: z.string(), }); export const datocmsConfigSchema = z.object({ diff --git a/apps/cms/src/lib/cms/data-sync.ts b/apps/cms/src/lib/cms/data-sync.ts new file mode 100644 index 0000000..809431d --- /dev/null +++ b/apps/cms/src/lib/cms/data-sync.ts @@ -0,0 +1,16 @@ +export const fetchWithRateLimit = async ( + args: A[], + fun: (arg: A) => Promise, + requestPerSecondLimit: number +) => { + const delay = 1000 / requestPerSecondLimit; + const results: Promise[] = []; + + for (const arg of args) { + const result = fun(arg); + results.push(result); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + + return await Promise.all(results); +}; diff --git a/apps/cms/src/lib/cms/providers/contentful.ts b/apps/cms/src/lib/cms/providers/contentful.ts index b76537d..baaa1db 100644 --- a/apps/cms/src/lib/cms/providers/contentful.ts +++ b/apps/cms/src/lib/cms/providers/contentful.ts @@ -2,8 +2,9 @@ import { v4 as uuidv4 } from "uuid"; import { ContentfulConfig, contentfulConfigSchema } from "../config"; import { logger as pinoLogger } from "../../logger"; -import { CreateOperations, CreateProductResponse, ProductInput } from "../types"; +import { CreateOperations, ProductResponse, ProductInput } from "../types"; import { createProvider } from "./create"; +import { fetchWithRateLimit } from "../data-sync"; const contentfulFetch = (endpoint: string, config: ContentfulConfig, options?: RequestInit) => { const baseUrl = config.baseUrl || "https://api.contentful.com"; @@ -30,6 +31,8 @@ type ContentfulResponse = { id: string; version?: number; }; + statusCode: number; + input: ProductInput; }; const transformInputToBody = ({ @@ -64,7 +67,7 @@ const transformInputToBody = ({ return body; }; -const transformCreateProductResponse = (response: ContentfulResponse): CreateProductResponse => { +const transformCreateProductResponse = (response: ContentfulResponse): ProductResponse => { if (response.message) { return { ok: false, @@ -76,6 +79,7 @@ const transformCreateProductResponse = (response: ContentfulResponse): CreatePro ok: true, data: { id: response.sys.id, + saleorId: response.input.saleorId, }, }; }; @@ -93,66 +97,140 @@ const getEntryEndpoint = ({ const contentfulOperations: CreateOperations = (config) => { const logger = pinoLogger.child({ cms: "strapi" }); - const { environment, spaceId, contentId, locale } = config; + const { environment, spaceId, contentId, locale, apiRequestsPerSecond } = config; + + const requestPerSecondLimit = Number(apiRequestsPerSecond || 7); + + const createProductInCMS = async (input: ProductInput): Promise => { + // Contentful API does not auto generate resource ID during creation, it has to be provided. + const resourceId = uuidv4(); + const body = transformInputToBody({ input, locale }); + const endpoint = getEntryEndpoint({ + resourceId, + environment, + spaceId, + }); + const response = await contentfulFetch(endpoint, config, { + method: "PUT", + body: JSON.stringify(body), + headers: { + "X-Contentful-Content-Type": contentId, + }, + }); + logger.debug({ response }, "createProduct response"); + const json = await response.json(); + return { + ...json, + statusCode: response.status, + input, + }; + }; + + const updateProductInCMS = async (id: string, input: ProductInput) => { + const body = transformInputToBody({ input, locale }); + const endpoint = getEntryEndpoint({ + resourceId: id, + environment, + spaceId, + }); + + const getEntryResponse = await contentfulFetch(endpoint, config, { method: "GET" }); + logger.debug({ getEntryResponse }, "updateProduct getEntryResponse"); + const entry = await getEntryResponse.json(); + logger.debug({ entry }, "updateProduct entry"); + + const response = await contentfulFetch(endpoint, config, { + method: "PUT", + body: JSON.stringify(body), + headers: { + "X-Contentful-Version": entry.sys.version, + }, + }); + logger.debug({ response }, "updateProduct response"); + const json = await response.json(); + return { + ...json, + statusCode: response.status, + }; + }; + + const deleteProductInCMS = async (id: string) => { + const endpoint = getEntryEndpoint({ resourceId: id, environment, spaceId }); + + return await contentfulFetch(endpoint, config, { method: "DELETE" }); + }; + + const createBatchProductsInCMS = async (input: ProductInput[]) => { + // Contentful doesn't support batch creation of items, so we need to create them one by one + + // Take into account rate limit + const firstResults = await fetchWithRateLimit(input, createProductInCMS, requestPerSecondLimit); + const failedWithLimitResults = firstResults.filter((result) => result.statusCode === 429); + + // Retry with delay x2 if by any chance hit rate limit with HTTP 429 + let secondResults: ContentfulResponse[] = []; + if (failedWithLimitResults.length > 0) { + logger.debug("createBatchProductsInCMS retrying failed by rate limit with delay x2"); + secondResults = await fetchWithRateLimit( + failedWithLimitResults, + (result) => createProductInCMS(result.input), + requestPerSecondLimit / 2 + ); + } + + return [...firstResults.filter((result) => result.statusCode !== 429), ...secondResults]; + }; + + const deleteBatchProductsInCMS = async (ids: string[]) => { + // Contentful doesn't support batch deletion of items, so we need to delete them one by one + + // Take into account rate limit + const firstResults = await fetchWithRateLimit(ids, deleteProductInCMS, requestPerSecondLimit); + const failedWithLimitResults = firstResults.filter((result) => result.status === 429); + + // Retry with delay x2 if by any chance hit rate limit with HTTP 429 + let secondResults: Response[] = []; + if (failedWithLimitResults.length > 0) { + logger.debug("deleteBatchProductsInCMS retrying failed by rate limit with delay x2"); + secondResults = await fetchWithRateLimit( + failedWithLimitResults, + (result) => deleteProductInCMS(result.url), + requestPerSecondLimit / 2 + ); + } + + return [...firstResults.filter((result) => result.status !== 429), ...secondResults]; + }; return { - createProduct: async (params) => { - // Contentful API does not auto generate resource ID during creation, it has to be provided. - const resourceId = uuidv4(); - const body = transformInputToBody({ input: params.input, locale }); - const endpoint = getEntryEndpoint({ - resourceId, - environment, - spaceId, - }); - - const response = await contentfulFetch(endpoint, config, { - method: "PUT", - body: JSON.stringify(body), - headers: { - "X-Contentful-Content-Type": contentId, - }, - }); - logger.debug("createProduct response", { response }); - const result = await response.json(); - logger.debug("createProduct result", { result }); + createProduct: async ({ input }) => { + const result = await createProductInCMS(input); + logger.debug({ result }, "createProduct result"); return transformCreateProductResponse(result); }, updateProduct: async ({ id, input }) => { - const body = transformInputToBody({ input, locale }); - const endpoint = getEntryEndpoint({ - resourceId: id, - environment, - spaceId, - }); - - const getEntryResponse = await contentfulFetch(endpoint, config, { method: "GET" }); - logger.debug("updateProduct getEntryResponse", { getEntryResponse }); - const entry = await getEntryResponse.json(); - logger.debug("updateProduct entry", { entry }); - - const response = await contentfulFetch(endpoint, config, { - method: "PUT", - body: JSON.stringify(body), - headers: { - "X-Contentful-Version": entry.sys.version, - }, - }); - logger.debug("updateProduct response", { response }); - const result = await response.json(); - logger.debug("updateProduct result", { result }); + const result = await updateProductInCMS(id, input); + logger.debug({ result }, "updateProduct result"); return result; }, deleteProduct: async ({ id }) => { - const endpoint = getEntryEndpoint({ resourceId: id, environment, spaceId }); - - const response = await contentfulFetch(endpoint, config, { method: "DELETE" }); - logger.debug("deleteProduct response", { response }); + const response = await deleteProductInCMS(id); + logger.debug({ response }, "deleteProduct response"); return response; }, + createBatchProducts: async ({ input }) => { + const results = await createBatchProductsInCMS(input); + logger.debug({ results }, "createBatchProducts results"); + + return results.map((result) => transformCreateProductResponse(result)); + }, + deleteBatchProducts: async ({ ids }) => { + const results = await deleteBatchProductsInCMS(ids); + logger.debug({ results }, "deleteBatchProducts results"); + }, }; }; diff --git a/apps/cms/src/lib/cms/providers/datocms.ts b/apps/cms/src/lib/cms/providers/datocms.ts index 5bfaacf..78b1ed2 100644 --- a/apps/cms/src/lib/cms/providers/datocms.ts +++ b/apps/cms/src/lib/cms/providers/datocms.ts @@ -1,5 +1,5 @@ import { createProvider } from "./create"; -import { CreateOperations, CreateProductResponse } from "../types"; +import { CreateOperations, ProductInput, ProductResponse } from "../types"; import { logger as pinoLogger } from "../../logger"; import { ApiError, buildClient, SimpleSchemaTypes } from "@datocms/cma-client-node"; @@ -18,7 +18,7 @@ const datocmsClient = (config: DatocmsConfig, options?: RequestInit) => { }); }; -const transformResponseError = (error: unknown): CreateProductResponse => { +const transformResponseError = (error: unknown): ProductResponse => { if (error instanceof ApiError) { return { ok: false, @@ -32,11 +32,15 @@ const transformResponseError = (error: unknown): CreateProductResponse => { } }; -const transformResponseItem = (item: SimpleSchemaTypes.Item): CreateProductResponse => { +const transformResponseItem = ( + item: SimpleSchemaTypes.Item, + input: ProductInput +): ProductResponse => { return { ok: true, data: { id: item.id, + saleorId: input.saleorId, }, }; }; @@ -44,48 +48,76 @@ const transformResponseItem = (item: SimpleSchemaTypes.Item): CreateProductRespo const datocmsOperations: CreateOperations = (config) => { const logger = pinoLogger.child({ cms: "strapi" }); + const client = datocmsClient(config); + + const createProductInCMS = async (input: ProductInput) => + client.items.create({ + item_type: { + id: config.itemTypeId, + type: "item_type", + }, + saleor_id: input.saleorId, + name: input.name, + channels: JSON.stringify(input.channels), + product_id: input.productId, + product_name: input.productName, + product_slug: input.productSlug, + }); + + const updateProductInCMS = async (id: string, input: ProductInput) => + client.items.update(id, { + saleor_id: input.saleorId, + name: input.name, + channels: JSON.stringify(input.channels), + product_id: input.productId, + product_name: input.productName, + product_slug: input.productSlug, + }); + + const deleteProductInCMS = async (id: string) => client.items.destroy(id); + + const createBatchProductsInCMS = async (input: ProductInput[]) => + // DatoCMS doesn't support batch creation of items, so we need to create them one by one + Promise.all( + input.map(async (item) => ({ + id: await createProductInCMS(item), + input: item, + })) + ); + + const deleteBatchProductsInCMS = async (ids: string[]) => + client.items.bulkDestroy({ + items: ids.map((id) => ({ id, type: "item" })), + }); + return { createProduct: async ({ input }) => { - const client = datocmsClient(config); - try { - const item = await client.items.create({ - item_type: { - id: config.itemTypeId, - type: "item_type", - }, - saleor_id: input.saleorId, - name: input.name, - channels: JSON.stringify(input.channels), - product_id: input.productId, - product_name: input.productName, - product_slug: input.productSlug, - }); - logger.debug("createProduct response", { item }); + const item = await createProductInCMS(input); + logger.debug({ item }, "createProduct response"); - return transformResponseItem(item); + return transformResponseItem(item, input); } catch (error) { return transformResponseError(error); } }, updateProduct: async ({ id, input }) => { - const client = datocmsClient(config); - - const item = await client.items.update(id, { - saleor_id: input.saleorId, - name: input.name, - channels: JSON.stringify(input.channels), - product_id: input.productId, - product_name: input.productName, - product_slug: input.productSlug, - }); - logger.debug("updateProduct response", { item }); + const item = await updateProductInCMS(id, input); + logger.debug({ item }, "updateProduct response"); }, deleteProduct: async ({ id }) => { - const client = datocmsClient(config); + const item = await deleteProductInCMS(id); + logger.debug({ item }, "deleteProduct response"); + }, + createBatchProducts: async ({ input }) => { + const items = await createBatchProductsInCMS(input); + logger.debug({ items }, "createBatchProducts response"); - const item = await client.items.destroy(id); - logger.debug("deleteProduct response", { item }); + return items.map((item) => transformResponseItem(item.id, item.input)); + }, + deleteBatchProducts: async ({ ids }) => { + const items = await deleteBatchProductsInCMS(ids); + logger.debug({ items }, "deleteBatchProducts response"); }, }; }; diff --git a/apps/cms/src/lib/cms/providers/strapi.ts b/apps/cms/src/lib/cms/providers/strapi.ts index 8d82231..af70d74 100644 --- a/apps/cms/src/lib/cms/providers/strapi.ts +++ b/apps/cms/src/lib/cms/providers/strapi.ts @@ -1,5 +1,5 @@ import { StrapiConfig, strapiConfigSchema } from "../config"; -import { CmsOperations, CreateOperations, CreateProductResponse, ProductInput } from "../types"; +import { CreateOperations, ProductResponse, ProductInput } from "../types"; import { createProvider } from "./create"; import { logger as pinoLogger } from "../../logger"; @@ -20,7 +20,7 @@ type StrapiBody = { data: Record & { saleor_id: string }; }; -const transformInputToBody = ({ input }: { input: ProductInput }): StrapiBody => { +const transformInputToBody = (input: ProductInput): StrapiBody => { const body = { data: { saleor_id: input.saleorId, @@ -55,7 +55,10 @@ type StrapiResponse = error: null; }; -const transformCreateProductResponse = (response: StrapiResponse): CreateProductResponse => { +const transformCreateProductResponse = ( + response: StrapiResponse, + input: ProductInput +): ProductResponse => { if (response.error) { return { ok: false, @@ -67,47 +70,86 @@ const transformCreateProductResponse = (response: StrapiResponse): CreateProduct ok: true, data: { id: response.data.id, + saleorId: input.saleorId, }, }; }; type CreateStrapiOperations = CreateOperations; -export const strapiOperations: CreateStrapiOperations = (config): CmsOperations => { +export const strapiOperations: CreateStrapiOperations = (config) => { const logger = pinoLogger.child({ cms: "strapi" }); const { contentTypeId } = config; + const createProductInCMS = async (input: ProductInput): Promise => { + const body = transformInputToBody(input); + const response = await strapiFetch(`/${contentTypeId}`, config, { + method: "POST", + body: JSON.stringify(body), + }); + logger.debug({ response }, "createProduct response"); + return await response.json(); + }; + + const updateProductInCMS = async (id: string, input: ProductInput) => { + const body = transformInputToBody(input); + return await strapiFetch(`/${contentTypeId}/${id}`, config, { + method: "PUT", + body: JSON.stringify(body), + }); + }; + + const deleteProductInCMS = async (id: string) => { + return await strapiFetch(`/${contentTypeId}/${id}`, config, { method: "DELETE" }); + }; + + const createBatchProductsInCMS = async (input: ProductInput[]) => { + // Strapi doesn't support batch creation of items, so we need to create them one by one + return await Promise.all( + input.map(async (product) => ({ + response: await createProductInCMS(product), + input: product, + })) + ); + }; + + const deleteBatchProductsInCMS = async (ids: string[]) => { + // Strapi doesn't support batch deletion of items, so we need to delete them one by one + return await Promise.all(ids.map((id) => deleteProductInCMS(id))); + }; + return { - createProduct: async (params) => { - const body = transformInputToBody(params); - const response = await strapiFetch(`/${contentTypeId}`, config, { - method: "POST", - body: JSON.stringify(body), - }); - logger.debug("createProduct response", { response }); + createProduct: async ({ input }) => { + const result = await createProductInCMS(input); + logger.debug({ result }, "createProduct result"); - const result = await response.json(); - logger.debug("createProduct result", { result }); - - return transformCreateProductResponse(result); + return transformCreateProductResponse(result, input); }, updateProduct: async ({ id, input }) => { - const body = transformInputToBody({ input }); - const response = await strapiFetch(`/${contentTypeId}/${id}`, config, { - method: "PUT", - body: JSON.stringify(body), - }); - logger.debug("updateProduct response", { response }); + const response = await updateProductInCMS(id, input); + logger.debug({ response }, "updateProduct response"); return response; }, deleteProduct: async ({ id }) => { - const response = await strapiFetch(`/${contentTypeId}/${id}`, config, { method: "DELETE" }); - logger.debug("deleteProduct response", { response }); + const response = await deleteProductInCMS(id); + logger.debug({ response }, "deleteProduct response"); return response; }, + createBatchProducts: async ({ input }) => { + const results = await createBatchProductsInCMS(input); + logger.debug({ results }, "createBatchProducts results"); + + return results.map((result) => transformCreateProductResponse(result.response, result.input)); + }, + deleteBatchProducts: async ({ ids }) => { + const responses = await deleteBatchProductsInCMS(ids); + logger.debug({ responses }, "deleteBatchProducts responses"); + + return responses; + }, }; }; diff --git a/apps/cms/src/lib/cms/types.ts b/apps/cms/src/lib/cms/types.ts index 0c06776..336a291 100644 --- a/apps/cms/src/lib/cms/types.ts +++ b/apps/cms/src/lib/cms/types.ts @@ -11,24 +11,35 @@ export type ProductInput = Record & { image?: string; }; -export type CreateProductResponse = - | { ok: true; data: { id: string } } - | { ok: false; error: string }; +export type ProductResponseSuccess = { ok: true; data: { id: string; saleorId: string } }; +export type ProductResponseError = { ok: false; error: string }; +export type ProductResponse = ProductResponseSuccess | ProductResponseError; export type CmsOperations = { - getAllProducts?: () => Promise; getProduct?: ({ id }: { id: string }) => Promise; - createProduct: ({ input }: { input: ProductInput }) => Promise; + createProduct: ({ input }: { input: ProductInput }) => Promise; updateProduct: ({ id, input }: { id: string; input: ProductInput }) => Promise; deleteProduct: ({ id }: { id: string }) => Promise; }; +export type CmsBatchOperations = { + getAllProducts?: () => Promise; + createBatchProducts: ({ input }: { input: ProductInput[] }) => Promise; + deleteBatchProducts: ({ ids }: { ids: string[] }) => Promise; +}; + export type CmsClientOperations = { cmsProviderInstanceId: string; operations: CmsOperations; operationType: keyof CmsOperations; }; +export type CmsClientBatchOperations = { + cmsProviderInstanceId: string; + operations: CmsBatchOperations; + operationType: keyof CmsBatchOperations; +}; + export type GetProviderTokens = (typeof providersConfig)[TProviderName]["tokens"][number]; @@ -43,7 +54,9 @@ export type CreateProviderConfig & BaseConfig; -export type CreateOperations = (config: TConfig) => CmsOperations; +export type CreateOperations = ( + config: TConfig +) => CmsOperations & CmsBatchOperations; export type Provider = { create: CreateOperations; diff --git a/apps/cms/src/lib/graphql.ts b/apps/cms/src/lib/graphql.ts index 1448c8a..9805875 100644 --- a/apps/cms/src/lib/graphql.ts +++ b/apps/cms/src/lib/graphql.ts @@ -10,39 +10,41 @@ interface IAuthState { token: string; } +const getExchanges = (getAuth: AuthConfig["getAuth"]) => [ + dedupExchange, + cacheExchange, + authExchange({ + addAuthToOperation: ({ authState, operation }) => { + if (!authState || !authState?.token) { + return operation; + } + + const fetchOptions = + typeof operation.context.fetchOptions === "function" + ? operation.context.fetchOptions() + : operation.context.fetchOptions || {}; + + return { + ...operation, + context: { + ...operation.context, + fetchOptions: { + ...fetchOptions, + headers: { + ...fetchOptions.headers, + "Authorization-Bearer": authState.token, + }, + }, + }, + }; + }, + getAuth, + }), + fetchExchange, +]; + export const createClient = (url: string, getAuth: AuthConfig["getAuth"]) => urqlCreateClient({ url, - exchanges: [ - dedupExchange, - cacheExchange, - authExchange({ - addAuthToOperation: ({ authState, operation }) => { - if (!authState || !authState?.token) { - return operation; - } - - const fetchOptions = - typeof operation.context.fetchOptions === "function" - ? operation.context.fetchOptions() - : operation.context.fetchOptions || {}; - - return { - ...operation, - context: { - ...operation.context, - fetchOptions: { - ...fetchOptions, - headers: { - ...fetchOptions.headers, - "Authorization-Bearer": authState.token, - }, - }, - }, - }; - }, - getAuth, - }), - fetchExchange, - ], + exchanges: getExchanges(getAuth), }); diff --git a/apps/cms/src/modules/channels/ui/channel-configuration-form.tsx b/apps/cms/src/modules/channels/ui/channel-configuration-form.tsx index 62604fe..de838ef 100644 --- a/apps/cms/src/modules/channels/ui/channel-configuration-form.tsx +++ b/apps/cms/src/modules/channels/ui/channel-configuration-form.tsx @@ -9,6 +9,9 @@ import { ListItem, ListItemCell, makeStyles, + Notification, + Alert, + IconButton, } from "@saleor/macaw-ui"; import React from "react"; import { useForm } from "react-hook-form"; @@ -20,19 +23,30 @@ import { SingleProviderSchema, } from "../../../lib/cms/config"; import { ProviderIcon } from "../../provider-instances/ui/provider-icon"; +import { ChannelsLoading } from "./types"; const useStyles = makeStyles((theme) => { return { item: { height: "auto !important", display: "grid", - gridTemplateColumns: "1fr 80px", + gridTemplateColumns: "1fr 80px 80px", }, itemCell: { display: "flex", alignItems: "center", gap: theme.spacing(2), }, + itemCellCenter: { + display: "flex", + alignItems: "center", + justifyContent: "center", + gap: theme.spacing(2), + }, + itemCellProgress: { + padding: theme.spacing(0, 4), + gridColumn: "1/5", + }, footer: { display: "flex", justifyContent: "flex-end", @@ -48,8 +62,9 @@ const useStyles = makeStyles((theme) => { interface ChannelConfigurationFormProps { channel?: MergedChannelSchema | null; providerInstances: SingleProviderSchema[]; - loading: boolean; + loading: ChannelsLoading; onSubmit: (channel: SingleChannelSchema) => any; + onSync: (providerInstanceId: string) => any; } export const ChannelConfigurationForm = ({ @@ -57,6 +72,7 @@ export const ChannelConfigurationForm = ({ providerInstances, loading, onSubmit, + onSync, }: ChannelConfigurationFormProps) => { const styles = useStyles(); @@ -85,6 +101,9 @@ export const ChannelConfigurationForm = ({ resetField("enabledProviderInstances", { defaultValue: channel?.enabledProviderInstances || [], }); + resetField("requireSyncProviderInstances", { + defaultValue: channel?.requireSyncProviderInstances || [], + }); }, [channel, providerInstances]); const errors = formState.errors; @@ -97,56 +116,92 @@ export const ChannelConfigurationForm = ({ )} - + + CMS provider configuration - Active + Active + Sync - {providerInstances.map((providerInstance) => ( - - - - {providerInstance.name} - - - formOption === providerInstance.id - )} - onChange={(event: React.ChangeEvent) => { - const valueCopy = getValues("enabledProviderInstances") - ? [...getValues("enabledProviderInstances")] - : []; - if (event.target.checked) { - valueCopy.push(providerInstance.id); - } else { - const idx = valueCopy.findIndex( - (formOption) => formOption === providerInstance.id - ); - valueCopy.splice(idx, 1); + {providerInstances.map((providerInstance) => { + const enabledProviderInstances = watch("enabledProviderInstances"); + const requireSyncProviderInstances = watch("requireSyncProviderInstances"); + const isEnabled = enabledProviderInstances?.some( + (formOption) => formOption === providerInstance.id + ); + const requireSync = requireSyncProviderInstances?.some( + (formOption) => formOption === providerInstance.id + ); + + return ( + + + + {providerInstance.name} + + + ) => { + const valueCopy = getValues("enabledProviderInstances") + ? [...getValues("enabledProviderInstances")] + : []; + if (event.target.checked) { + valueCopy.push(providerInstance.id); + } else { + const idx = valueCopy.findIndex( + (formOption) => formOption === providerInstance.id + ); + valueCopy.splice(idx, 1); + } + resetField("enabledProviderInstances", { + defaultValue: valueCopy, + }); + }} + value={providerInstance.name} + component={(props) => } + /> + + + + + {loading.productsVariantsSync.syncingProviderInstanceId === providerInstance.id && ( + + Syncing products... + + + )} + + ); + })} {/* )} /> */} - diff --git a/apps/cms/src/modules/channels/ui/channel-configuration.tsx b/apps/cms/src/modules/channels/ui/channel-configuration.tsx index a686d2b..3e890a5 100644 --- a/apps/cms/src/modules/channels/ui/channel-configuration.tsx +++ b/apps/cms/src/modules/channels/ui/channel-configuration.tsx @@ -7,9 +7,10 @@ import { SingleChannelSchema, SingleProviderSchema, } from "../../../lib/cms/config"; -import { ChannelsErrors, ChannelsLoading } from "./types"; +import { ChannelsLoading } from "./types"; import { makeStyles } from "@saleor/macaw-ui"; import { AppTabNavButton } from "../../ui/app-tab-nav-button"; +import { ChannelsDataErrors } from "./hooks/useChannels"; const useStyles = makeStyles((theme) => ({ textCenter: { @@ -55,20 +56,22 @@ interface ChannelConfigurationProps { activeChannel?: MergedChannelSchema | null; providerInstances: SingleProviderSchema[]; saveChannel: (channel: SingleChannelSchema) => any; + syncChannelProviderInstance: (providerInstanceId: string) => any; loading: ChannelsLoading; - errors: ChannelsErrors; + errors: ChannelsDataErrors; } export const ChannelConfiguration = ({ activeChannel, providerInstances, saveChannel, + syncChannelProviderInstance, loading, errors, }: ChannelConfigurationProps) => { const styles = useStyles(); - if (loading.fetching || loading.saving) { + if (loading.channels.fetching || loading.channels.saving) { return ; } @@ -110,8 +113,9 @@ export const ChannelConfiguration = ({ ); diff --git a/apps/cms/src/modules/channels/ui/channels-list.tsx b/apps/cms/src/modules/channels/ui/channels-list.tsx index c384f39..3a03cc7 100644 --- a/apps/cms/src/modules/channels/ui/channels-list.tsx +++ b/apps/cms/src/modules/channels/ui/channels-list.tsx @@ -2,8 +2,9 @@ import { Skeleton } from "@material-ui/lab"; import { MergedChannelSchema } from "../../../lib/cms"; import { AppPaper } from "../../ui/app-paper"; -import { ChannelsErrors, ChannelsLoading } from "./types"; +import { ChannelsLoading } from "./types"; import { ChannelsSelect } from "./channels-select"; +import { ChannelsDataErrors } from "./hooks/useChannels"; const ChannelsListSkeleton = () => { return ( @@ -18,7 +19,7 @@ interface ChannelsListProps { activeChannel?: MergedChannelSchema | null; setActiveChannel: (channel: MergedChannelSchema | null) => void; loading: ChannelsLoading; - errors: ChannelsErrors; + errors: ChannelsDataErrors; } export const ChannelsList = ({ @@ -28,7 +29,7 @@ export const ChannelsList = ({ loading, errors, }: ChannelsListProps) => { - if (loading.fetching) { + if (loading.channels.fetching) { return ; } diff --git a/apps/cms/src/modules/channels/ui/channels.tsx b/apps/cms/src/modules/channels/ui/channels.tsx index 395a2c8..1619a17 100644 --- a/apps/cms/src/modules/channels/ui/channels.tsx +++ b/apps/cms/src/modules/channels/ui/channels.tsx @@ -1,11 +1,16 @@ +import { makeStyles } from "@saleor/macaw-ui"; import { useEffect, useState } from "react"; -import { MergedChannelSchema } from "../../../lib/cms/config"; +import { MergedChannelSchema, SingleChannelSchema } from "../../../lib/cms/config"; +import { + useProductsVariantsSync, + ProductsVariantsSyncOperation, +} from "../../cms/hooks/useProductsVariantsSync"; import { useProviderInstances } from "../../provider-instances/ui/hooks/useProviderInstances"; +import { AppTabs } from "../../ui/app-tabs"; import { ChannelConfiguration } from "./channel-configuration"; import { ChannelsList } from "./channels-list"; import { useChannels } from "./hooks/useChannels"; -import { AppTabs } from "../../ui/app-tabs"; -import { makeStyles } from "@saleor/macaw-ui"; +import { ChannelsLoading } from "./types"; const useStyles = makeStyles({ wrapper: { @@ -17,7 +22,7 @@ const useStyles = makeStyles({ export const Channels = () => { const styles = useStyles(); - const { channels, saveChannel, loading, errors } = useChannels(); + const { channels, saveChannel, loading: loadingChannels, errors } = useChannels(); const { providerInstances } = useProviderInstances(); const [activeChannelSlug, setActiveChannelSlug] = useState( @@ -36,6 +41,40 @@ export const Channels = () => { } }, [channels]); + const handleOnSyncCompleted = (providerInstanceId: string) => { + if (!activeChannel) { + return; + } + + saveChannel({ + ...activeChannel, + requireSyncProviderInstances: activeChannel.requireSyncProviderInstances?.filter( + (id) => id !== providerInstanceId + ), + }); + }; + + const { sync, loading: loadingProductsVariantsSync } = useProductsVariantsSync( + activeChannelSlug, + handleOnSyncCompleted + ); + + const handleSync = async (providerInstanceId: string) => { + if (!activeChannel) { + return; + } + + const operation: ProductsVariantsSyncOperation = + activeChannel.enabledProviderInstances.includes(providerInstanceId) ? "ADD" : "DELETE"; + + return sync(providerInstanceId, operation); + }; + + const loading: ChannelsLoading = { + channels: loadingChannels, + productsVariantsSync: loadingProductsVariantsSync, + }; + return ( <> @@ -52,6 +91,7 @@ export const Channels = () => { activeChannel={activeChannel} providerInstances={providerInstances} saveChannel={saveChannel} + syncChannelProviderInstance={handleSync} loading={loading} errors={errors} /> diff --git a/apps/cms/src/modules/channels/ui/hooks/useChannels.ts b/apps/cms/src/modules/channels/ui/hooks/useChannels.ts index c1e647a..c3358a2 100644 --- a/apps/cms/src/modules/channels/ui/hooks/useChannels.ts +++ b/apps/cms/src/modules/channels/ui/hooks/useChannels.ts @@ -1,10 +1,19 @@ import { useChannelsFetch } from "./useChannelsFetch"; import { MergedChannelSchema, SingleChannelSchema } from "../../../../lib/cms/config"; -import { ChannelsErrors, ChannelsLoading } from "../types"; import { useChannelsQuery } from "../../../../../generated/graphql"; import { useIsMounted } from "usehooks-ts"; import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge"; +export interface ChannelsDataLoading { + fetching: boolean; + saving: boolean; +} + +export interface ChannelsDataErrors { + fetching?: Error | null; + saving?: Error | null; +} + export const useChannels = () => { const { appBridge } = useAppBridge(); const isMounted = useIsMounted(); @@ -19,10 +28,33 @@ export const useChannels = () => { isFetching, } = useChannelsFetch(); - const saveChannel = (channelToSave: SingleChannelSchema) => { + const saveChannel = async (channelToSave: SingleChannelSchema) => { console.log("saveChannel", channelToSave); - saveChannelFetch(channelToSave).then(() => { + const currentlyEnabledProviderInstances = + settings?.[`${channelToSave.channelSlug}`]?.enabledProviderInstances || []; + const toEnableProviderInstances = channelToSave.enabledProviderInstances || []; + + const changedSyncProviderInstances = [ + ...currentlyEnabledProviderInstances.filter( + (instance) => !toEnableProviderInstances.includes(instance) + ), + ...toEnableProviderInstances.filter( + (instance) => !currentlyEnabledProviderInstances.includes(instance) + ), + ]; + + const fetchResult = await saveChannelFetch({ + ...channelToSave, + requireSyncProviderInstances: [ + ...(channelToSave.requireSyncProviderInstances || []), + ...changedSyncProviderInstances.filter( + (instance) => !(channelToSave.requireSyncProviderInstances || []).includes(instance) + ), + ], + }); + + if (fetchResult.success) { appBridge?.dispatch( actions.Notification({ title: "Success", @@ -30,15 +62,23 @@ export const useChannels = () => { text: "Configuration saved", }) ); - }); + } else { + appBridge?.dispatch( + actions.Notification({ + title: "Error", + status: "error", + text: "Error while saving configuration", + }) + ); + } }; - const loading: ChannelsLoading = { + const loading: ChannelsDataLoading = { fetching: isFetching || channelsQueryData.fetching, saving: isSaving, }; - const errors: ChannelsErrors = { + const errors: ChannelsDataErrors = { fetching: fetchingError ? Error(fetchingError) : null, saving: null, }; @@ -51,6 +91,9 @@ export const useChannels = () => { enabledProviderInstances: settings ? settings[`${channel.slug}`]?.enabledProviderInstances : [], + requireSyncProviderInstances: settings + ? settings[`${channel.slug}`]?.requireSyncProviderInstances + : [], channel: channel, } as MergedChannelSchema) ) || []; diff --git a/apps/cms/src/modules/channels/ui/hooks/useChannelsFetch.ts b/apps/cms/src/modules/channels/ui/hooks/useChannelsFetch.ts index 073c204..6cfef6e 100644 --- a/apps/cms/src/modules/channels/ui/hooks/useChannelsFetch.ts +++ b/apps/cms/src/modules/channels/ui/hooks/useChannelsFetch.ts @@ -64,9 +64,19 @@ export const useChannelsFetch = () => { console.log("saveSettings config", config); setConfig(config); + + return { + success: true, + }; + } else { + throw new Error(); } } catch (error) { console.log(error); + + return { + success: false, + }; } }; diff --git a/apps/cms/src/modules/channels/ui/types.ts b/apps/cms/src/modules/channels/ui/types.ts index dd3ae0a..1c3626a 100644 --- a/apps/cms/src/modules/channels/ui/types.ts +++ b/apps/cms/src/modules/channels/ui/types.ts @@ -1,9 +1,7 @@ -export interface ChannelsLoading { - fetching: boolean; - saving: boolean; -} +import { ProductsVariantsSyncLoading } from "../../cms/hooks/useProductsVariantsSync"; +import { ChannelsDataLoading } from "./hooks/useChannels"; -export interface ChannelsErrors { - fetching?: Error | null; - saving?: Error | null; +export interface ChannelsLoading { + channels: ChannelsDataLoading; + productsVariantsSync: ProductsVariantsSyncLoading; } diff --git a/apps/cms/src/modules/cms/hooks/useProductsVariantsSync.ts b/apps/cms/src/modules/cms/hooks/useProductsVariantsSync.ts new file mode 100644 index 0000000..a4131cd --- /dev/null +++ b/apps/cms/src/modules/cms/hooks/useProductsVariantsSync.ts @@ -0,0 +1,138 @@ +import { useAppBridge } from "@saleor/app-sdk/app-bridge"; +import { SALEOR_API_URL_HEADER, SALEOR_AUTHORIZATION_BEARER_HEADER } from "@saleor/app-sdk/const"; +import { useCallback, useEffect, useState } from "react"; +import { WebhookProductVariantFragment } from "../../../../generated/graphql"; +import { Products, useQueryAllProducts } from "./useQueryAllProducts"; + +export interface ProductsVariantsSyncLoading { + syncingProviderInstanceId?: string; + currentProductIndex?: number; + totalProductsCount?: number; +} + +export type ProductsVariantsSyncOperation = "ADD" | "DELETE"; + +const BATCH_SIZE = 100; + +interface UseProductsVariantsSyncHandlers { + sync: (providerInstanceId: string, operation: ProductsVariantsSyncOperation) => void; + loading: ProductsVariantsSyncLoading; +} + +export const useProductsVariantsSync = ( + channelSlug: string | null, + onSyncCompleted: (providerInstanceId: string) => void +): UseProductsVariantsSyncHandlers => { + const { appBridgeState } = useAppBridge(); + + const [startedProviderInstanceId, setStartedProviderInstanceId] = useState(); + const [startedOperation, setStartedOperation] = useState(); + const [currentProductIndex, setCurrentProductIndex] = useState(0); + const [isImporting, setIsImporting] = useState(false); + + const { products, fetchCompleted } = useQueryAllProducts(!startedProviderInstanceId, channelSlug); + + const sync = (providerInstanceId: string, operation: ProductsVariantsSyncOperation) => { + setStartedProviderInstanceId(providerInstanceId); + setStartedOperation(operation); + setCurrentProductIndex(0); + }; + + const syncFetch = async ( + providerInstanceId: string, + operation: ProductsVariantsSyncOperation, + productsBatch: Products + ) => { + const productsVariants = productsBatch.reduce((acc, product) => { + const variants = product.variants?.map((variant) => { + const { variants: _, ...productFields } = product; + return { + product: productFields, + ...variant, + }; + }); + + return variants ? [...acc, ...variants] : acc; + }, [] as WebhookProductVariantFragment[]); + + try { + const syncResponse = await fetch("/api/sync-products-variants", { + method: "POST", + headers: [ + ["content-type", "application/json"], + [SALEOR_API_URL_HEADER, appBridgeState?.saleorApiUrl!], + [SALEOR_AUTHORIZATION_BEARER_HEADER, appBridgeState?.token!], + ], + body: JSON.stringify({ + providerInstanceId, + productsVariants, + operation, + }), + }); + + const syncResult = await syncResponse.json(); + + return syncResult; + } catch (error) { + console.error("useProductsVariantsSync syncFetch error", error); + } + }; + + useEffect(() => { + if ( + products.length <= currentProductIndex && + fetchCompleted && + startedProviderInstanceId && + startedOperation + ) { + const completedProviderInstanceIdSync = startedProviderInstanceId; + + setStartedProviderInstanceId(undefined); + setStartedOperation(undefined); + setCurrentProductIndex(0); + + onSyncCompleted(completedProviderInstanceIdSync); + } + }, [products.length, currentProductIndex, fetchCompleted]); + + useEffect(() => { + if (!startedProviderInstanceId || !startedOperation) { + return; + } + if (products.length <= currentProductIndex) { + return; + } + if (isImporting) { + return; + } + (async () => { + setIsImporting(true); + const productsBatchStartIndex = currentProductIndex; + const productsBatchEndIndex = Math.min(currentProductIndex + BATCH_SIZE, products.length); + const productsBatch = products.slice(productsBatchStartIndex, productsBatchEndIndex); + + // temporary solution, cannot use directly backend methods without fetch, due to non-browser Node dependency, like await cmsProvider.updatedBatchProducts(productsBatch); + await syncFetch(startedProviderInstanceId, startedOperation, productsBatch); + + setIsImporting(false); + setCurrentProductIndex(productsBatchEndIndex); + })(); + }, [ + startedProviderInstanceId, + startedOperation, + currentProductIndex, + isImporting, + products.length, + ]); + + const loading: ProductsVariantsSyncLoading = { + syncingProviderInstanceId: startedProviderInstanceId, + currentProductIndex, + totalProductsCount: products.length, + }; + + return { + sync, + loading, + }; +}; diff --git a/apps/cms/src/modules/cms/hooks/useQueryAllProducts.tsx b/apps/cms/src/modules/cms/hooks/useQueryAllProducts.tsx new file mode 100644 index 0000000..fc0982e --- /dev/null +++ b/apps/cms/src/modules/cms/hooks/useQueryAllProducts.tsx @@ -0,0 +1,81 @@ +import { useAppBridge } from "@saleor/app-sdk/app-bridge"; +import { useEffect, useState } from "react"; +import { + ProductsDataForImportDocument, + ProductsDataForImportQuery, +} from "../../../../generated/graphql"; +import { createClient } from "../../../lib/graphql"; + +const PER_PAGE = 100; + +export type Products = NonNullable< + ProductsDataForImportQuery["products"] +>["edges"][number]["node"][]; + +export const useQueryAllProducts = (paused: boolean, channelSlug: string | null) => { + const { appBridgeState } = useAppBridge(); + const saleorApiUrl = appBridgeState?.saleorApiUrl!; + + const [products, setProducts] = useState([]); + const [fetchCompleted, setFetchCompleted] = useState(false); + + useEffect(() => { + if (paused) { + setProducts([]); + setFetchCompleted(false); + } + }, [paused]); + + useEffect(() => { + if (paused || !channelSlug || !appBridgeState?.token) { + return; + } + + const token = appBridgeState.token; + const client = createClient(saleorApiUrl, () => Promise.resolve({ token })); + + if (!client) { + return; + } + + const getProducts = async (channelSlug: string, cursor: string): Promise => { + const response = await client + .query( + ProductsDataForImportDocument, + { + after: cursor, + first: PER_PAGE, + channel: channelSlug!, + }, + { + requestPolicy: "network-only", // Invalidate products data, because it could contain legacy products variants metadata that indicates these products variants existance in CMS providers + } + ) + .toPromise(); + + const newProducts = response?.data?.products?.edges.map((e) => e.node) ?? []; + + if (newProducts.length > 0) { + setProducts((ps) => [...ps, ...newProducts]); + } + if ( + response?.data?.products?.pageInfo.hasNextPage && + response?.data?.products?.pageInfo.endCursor + ) { + // get next page of products + return getProducts(channelSlug, response.data.products?.pageInfo.endCursor); + } else { + setFetchCompleted(true); + } + }; + + (async () => { + await getProducts(channelSlug, ""); + })(); + }, [appBridgeState?.token, saleorApiUrl, paused, channelSlug]); + + return { + products, + fetchCompleted, + }; +}; diff --git a/apps/cms/src/pages/api/sync-products-variants.ts b/apps/cms/src/pages/api/sync-products-variants.ts new file mode 100644 index 0000000..9dc93aa --- /dev/null +++ b/apps/cms/src/pages/api/sync-products-variants.ts @@ -0,0 +1,169 @@ +import { createProtectedHandler, NextProtectedApiHandler } from "@saleor/app-sdk/handlers/next"; +import { NextApiRequest, NextApiResponse } from "next"; +import { WebhookProductVariantFragment } from "../../../generated/graphql"; +import { saleorApp } from "../../../saleor-app"; +import { executeCmsClientBatchOperation } from "../../lib/cms/client/clients-execution"; +import { getChannelsSettings, getProviderInstancesSettings } from "../../lib/cms/client/settings"; +import { providersSchemaSet } from "../../lib/cms/config/providers"; +import { cmsProviders, CMSProvider } from "../../lib/cms/providers"; +import { logger as pinoLogger } from "../../lib/logger"; +import { createClient } from "../../lib/graphql"; +import { createSettingsManager } from "../../lib/metadata"; +import { batchUpdateMetadata, MetadataRecord } from "../../lib/cms/client/metadata-execution"; +import { CmsBatchOperations } from "../../lib/cms/types"; + +export interface SyncProductsVariantsApiPayload { + channelSlug: string; + providerInstanceId: string; + productsVariants: WebhookProductVariantFragment[]; + operation: "ADD" | "DELETE"; +} + +export interface SyncProductsVariantsApiResponse { + success: boolean; + data?: { + createdCMSIds: MetadataRecord[]; + deletedCMSIds: MetadataRecord[]; + }; + error?: string; +} + +const handler: NextProtectedApiHandler = async ( + req: NextApiRequest, + res: NextApiResponse, + context +) => { + const { authData } = context; + + const logger = pinoLogger.child({ + endpoint: "sync-products-variants", + }); + logger.debug("Called endpoint sync-products-variants"); + + const client = createClient(authData.saleorApiUrl, async () => ({ + token: authData.token, + })); + + if (req.method !== "POST") { + return res.status(405).json({ + success: false, + }); + } + + // todo: change to zod validation + const { providerInstanceId, productsVariants, operation } = + req.body as SyncProductsVariantsApiPayload; + + if (!providerInstanceId) { + return res.status(400).json({ + success: false, + error: "The provider instance id is missing.", + }); + } + + if (!productsVariants || productsVariants?.length === 0) { + return res.status(400).json({ + success: false, + error: "The products variants are missing.", + }); + } + + if (!operation || (operation !== "ADD" && operation !== "DELETE")) { + return res.status(400).json({ + success: false, + error: "The operation is missing or invalid. Allowed operations: ADD, DELETE.", + }); + } + const operationType: keyof CmsBatchOperations = + operation === "ADD" ? "createBatchProducts" : "deleteBatchProducts"; + + const settingsManager = createSettingsManager(client); + const [providerInstancesSettingsParsed, channelsSettingsParsed] = await Promise.all([ + getProviderInstancesSettings(settingsManager), + getChannelsSettings(settingsManager), + ]); + const providerInstanceSettings = providerInstancesSettingsParsed[providerInstanceId]; + + const provider = cmsProviders[ + providerInstanceSettings.providerName as CMSProvider + ] as (typeof cmsProviders)[keyof typeof cmsProviders]; + const validation = + providersSchemaSet[providerInstanceSettings.providerName as CMSProvider].safeParse( + providerInstanceSettings + ); + + logger.debug({ provider }, "The provider instance settings provider."); + + if (!validation.success) { + // todo: use instead: throw new Error(validation.error.message); + // continue with other provider instances + logger.error( + { error: validation.error.message }, + "The provider instance settings validation failed." + ); + + return res.status(400).json({ + success: false, + }); + } + + const config = validation.data; + + logger.debug({ config }, "The provider instance settings validated config."); + + const enabledChannelsForSelectedProviderInstance = Object.entries(channelsSettingsParsed).reduce( + (enabledChannels, [channelSlug, channelSettingsParsed]) => { + if (channelSettingsParsed.enabledProviderInstances.includes(providerInstanceId)) { + return [...enabledChannels, channelSlug]; + } + return enabledChannels; + }, + [] as string[] + ); + + // todo: make it later a method of kinda ChannelsSettingsRepository instantiated only once + const verifyIfProductVariantIsAvailableInOtherChannelEnabledForSelectedProviderInstance = ( + productVariant: WebhookProductVariantFragment + ) => { + const variantAvailableChannels = productVariant.channelListings?.map((cl) => cl.channel.slug); + const isAvailable = variantAvailableChannels?.some((channel) => + enabledChannelsForSelectedProviderInstance.includes(channel) + ); + return !!isAvailable; + }; + + const syncResult = await executeCmsClientBatchOperation({ + cmsClient: { + cmsProviderInstanceId: providerInstanceId, + operationType, + operations: provider.create(config as any), + }, + productsVariants, + verifyIfProductVariantIsAvailableInOtherChannelEnabledForSelectedProviderInstance, + }); + + await batchUpdateMetadata({ + context, + variantCMSProviderInstanceIdsToCreate: + syncResult?.createdCmsIds?.map((cmsId) => ({ + id: cmsId.saleorId, + cmsProviderInstanceIds: { [providerInstanceId]: cmsId.id }, + })) || [], + variantCMSProviderInstanceIdsToDelete: + syncResult?.deletedCmsIds?.map((cmsId) => ({ + id: cmsId.saleorId, + cmsProviderInstanceIds: { [providerInstanceId]: cmsId.id }, + })) || [], + }); + + return res.status(200).json({ + success: true, + data: { + createdCMSIds: syncResult?.createdCmsIds || [], + deletedCMSIds: syncResult?.deletedCmsIds || [], + }, + error: syncResult?.error, + }); +}; + +export default createProtectedHandler(handler, saleorApp.apl, ["MANAGE_APPS"]); diff --git a/apps/cms/src/pages/api/webhooks/product-updated.ts b/apps/cms/src/pages/api/webhooks/product-updated.ts index 8bd32e1..2cd4bf2 100644 --- a/apps/cms/src/pages/api/webhooks/product-updated.ts +++ b/apps/cms/src/pages/api/webhooks/product-updated.ts @@ -7,11 +7,7 @@ import { import { saleorApp } from "../../../../saleor-app"; import { getCmsKeysFromSaleorItem } from "../../../lib/cms/client/metadata"; import { getChannelsSlugsFromSaleorItem } from "../../../lib/cms/client/channels"; -import { - createCmsOperations, - executeCmsOperations, - executeMetadataUpdate, -} from "../../../lib/cms/client"; +import { createCmsOperations, executeCmsOperations, updateMetadata } from "../../../lib/cms/client"; import { logger as pinoLogger } from "../../../lib/logger"; import { createClient } from "../../../lib/graphql"; import { fetchProductVariantMetadata } from "../../../lib/metadata"; @@ -109,7 +105,7 @@ export const handler: NextWebhookApiHandler