diff --git a/apps/cms/graphql/fragments/WebhookProduct.graphql b/apps/cms/graphql/fragments/WebhookProduct.graphql new file mode 100644 index 0000000..c437c7c --- /dev/null +++ b/apps/cms/graphql/fragments/WebhookProduct.graphql @@ -0,0 +1,36 @@ +fragment WebhookProduct on Product { + id + name + slug + media { + url + } + channelListings { + id + channel { + id + slug + } + isPublished + } + variants { + id + name + sku + channelListings { + id + channel { + id + slug + } + price { + amount + currency + } + } + metadata { + key + value + } + } +} diff --git a/apps/cms/src/pages/api/manifest.ts b/apps/cms/src/pages/api/manifest.ts index 5a2a765..ceafdb4 100644 --- a/apps/cms/src/pages/api/manifest.ts +++ b/apps/cms/src/pages/api/manifest.ts @@ -5,6 +5,7 @@ import packageJson from "../../../package.json"; import { productVariantUpdatedWebhook } from "./webhooks/product-variant-updated"; import { productVariantCreatedWebhook } from "./webhooks/product-variant-created"; import { productVariantDeletedWebhook } from "./webhooks/product-variant-deleted"; +import { productUpdatedWebhook } from "./webhooks/product-updated"; export default createManifestHandler({ async manifestFactory(context) { @@ -19,6 +20,7 @@ export default createManifestHandler({ productVariantCreatedWebhook.getWebhookManifest(context.appBaseUrl), productVariantUpdatedWebhook.getWebhookManifest(context.appBaseUrl), productVariantDeletedWebhook.getWebhookManifest(context.appBaseUrl), + productUpdatedWebhook.getWebhookManifest(context.appBaseUrl), ], extensions: [], author: "Saleor Commerce", diff --git a/apps/cms/src/pages/api/webhooks/product-updated.ts b/apps/cms/src/pages/api/webhooks/product-updated.ts new file mode 100644 index 0000000..8bd32e1 --- /dev/null +++ b/apps/cms/src/pages/api/webhooks/product-updated.ts @@ -0,0 +1,127 @@ +import { NextWebhookApiHandler, SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next"; +import { gql } from "urql"; +import { + ProductUpdatedWebhookPayloadFragment, + UntypedWebhookProductFragmentDoc, +} from "../../../../generated/graphql"; +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 { logger as pinoLogger } from "../../../lib/logger"; +import { createClient } from "../../../lib/graphql"; +import { fetchProductVariantMetadata } from "../../../lib/metadata"; + +export const config = { + api: { + bodyParser: false, + }, +}; + +export const ProductUpdatedWebhookPayload = gql` + ${UntypedWebhookProductFragmentDoc} + fragment ProductUpdatedWebhookPayload on ProductUpdated { + product { + ...WebhookProduct + } + } +`; + +export const ProductUpdatedSubscription = gql` + ${ProductUpdatedWebhookPayload} + subscription ProductUpdated { + event { + ...ProductUpdatedWebhookPayload + } + } +`; + +export const productUpdatedWebhook = new SaleorAsyncWebhook({ + name: "Cms-hub product updated webhook", + webhookPath: "api/webhooks/product-updated", + event: "PRODUCT_UPDATED", + apl: saleorApp.apl, + query: ProductUpdatedSubscription, +}); + +export const handler: NextWebhookApiHandler = async ( + req, + res, + context +) => { + const { product } = context.payload; + const { saleorApiUrl, token } = context.authData; + + const logger = pinoLogger.child({ + product, + }); + logger.debug("Called webhook PRODUCT_UPDATED"); + + if (!product) { + return res.status(500).json({ + errors: [ + "No product product data payload provided. Cannot process product variants syncronisation in CMS providers.", + ], + }); + } + + const client = createClient(saleorApiUrl, async () => ({ + token: token, + })); + + const allCMSErrors: string[] = []; + + product.variants?.forEach(async (variant) => { + const { variants: _, ...productFields } = product; + const productVariant = { + product: productFields, + ...variant, + }; + + const productVariantChannels = getChannelsSlugsFromSaleorItem(productVariant); + const productMetadata = await fetchProductVariantMetadata(client, productVariant.id); + const productVariantCmsKeys = getCmsKeysFromSaleorItem({ metadata: productMetadata }); + const cmsOperations = await createCmsOperations({ + context, + client, + productVariantChannels: productVariantChannels, + productVariantCmsKeys: productVariantCmsKeys, + }); + // Do not touch product variants which are not created or should be deleted. + // These operations should and will be performed by PRODUCT_VARIANT_CREATED and PRODUCT_VARIANT_DELETED webhooks. + // Otherwise we will end up with duplicated product variants in CMS providers! (or failed variant delete operations). + const cmsUpdateOperations = cmsOperations.filter( + (operation) => operation.operationType === "updateProduct" + ); + + const { + cmsProviderInstanceProductVariantIdsToCreate, + cmsProviderInstanceProductVariantIdsToDelete, + cmsErrors, + } = await executeCmsOperations({ + cmsOperations: cmsUpdateOperations, + productVariant, + }); + + allCMSErrors.push(...cmsErrors); + + await executeMetadataUpdate({ + context, + productVariant, + cmsProviderInstanceIdsToCreate: cmsProviderInstanceProductVariantIdsToCreate, + cmsProviderInstanceIdsToDelete: cmsProviderInstanceProductVariantIdsToDelete, + }); + }); + + if (!allCMSErrors.length) { + return res.status(200).end(); + } else { + return res.status(500).json({ errors: allCMSErrors }); + } +}; + +export default productUpdatedWebhook.createHandler(handler);