diff --git a/.changeset/eight-oranges-appear.md b/.changeset/eight-oranges-appear.md new file mode 100644 index 0000000..b25937d --- /dev/null +++ b/.changeset/eight-oranges-appear.md @@ -0,0 +1,9 @@ +--- +"saleor-app-search": minor +--- + +Extended search model with two new fields: +- `inStock`: returns true if stock is available. To keep this up to date, the application subscribes to `ProductVariantOutOfStock` and `ProductVariantBackInStock` events +- `media`: array of objects containing URL and type of given file (video or image) + +To enable those features, open App configuration page and click on the `Update webhooks` button. To update existing data start a data import. diff --git a/apps/search/graphql/fragments/OwnWebhook.graphql b/apps/search/graphql/fragments/OwnWebhook.graphql index 6e9588f..77cbe57 100644 --- a/apps/search/graphql/fragments/OwnWebhook.graphql +++ b/apps/search/graphql/fragments/OwnWebhook.graphql @@ -1,6 +1,7 @@ fragment OwnWebhook on Webhook { id isActive + name asyncEvents { name eventType diff --git a/apps/search/graphql/fragments/ProductData.graphql b/apps/search/graphql/fragments/ProductData.graphql index 10e4b6b..8c4918c 100644 --- a/apps/search/graphql/fragments/ProductData.graphql +++ b/apps/search/graphql/fragments/ProductData.graphql @@ -24,6 +24,10 @@ fragment ProductData on Product { thumbnail { url } + media{ + url + type + } attributes { ...ProductAttributesData } diff --git a/apps/search/graphql/fragments/ProductVariantData.graphql b/apps/search/graphql/fragments/ProductVariantData.graphql index ad0d1ff..af738aa 100644 --- a/apps/search/graphql/fragments/ProductVariantData.graphql +++ b/apps/search/graphql/fragments/ProductVariantData.graphql @@ -14,7 +14,7 @@ fragment ProductVariantData on ProductVariant { } } } - + quantityAvailable channelListings { id channel { diff --git a/apps/search/graphql/mutations/CreateWebhook.graphql b/apps/search/graphql/mutations/CreateWebhook.graphql new file mode 100644 index 0000000..4963639 --- /dev/null +++ b/apps/search/graphql/mutations/CreateWebhook.graphql @@ -0,0 +1,9 @@ +mutation CreateWebhook($input: WebhookCreateInput!) { + webhookCreate(input:$input){ + errors{ + code + field + message + } + } +} diff --git a/apps/search/graphql/mutations/RemoveWebhook.graphql b/apps/search/graphql/mutations/RemoveWebhook.graphql new file mode 100644 index 0000000..1afd6ba --- /dev/null +++ b/apps/search/graphql/mutations/RemoveWebhook.graphql @@ -0,0 +1,8 @@ +mutation RemoveWebhook($id: ID!) { + webhookDelete(id: $id){ + errors{ + field + message + } + } +} diff --git a/apps/search/graphql/subscriptions/productVariantBackInStock.graphql b/apps/search/graphql/subscriptions/productVariantBackInStock.graphql new file mode 100644 index 0000000..94bfcab --- /dev/null +++ b/apps/search/graphql/subscriptions/productVariantBackInStock.graphql @@ -0,0 +1,16 @@ +subscription ProductVariantBackInStock { + event { + __typename + ... on ProductVariantBackInStock { + __typename + productVariant { + ...ProductVariantWebhookPayload + } + } + recipient { + webhooks { + id + } + } + } +} diff --git a/apps/search/graphql/subscriptions/productVariantOutOfStock.graphql b/apps/search/graphql/subscriptions/productVariantOutOfStock.graphql new file mode 100644 index 0000000..d44c3bf --- /dev/null +++ b/apps/search/graphql/subscriptions/productVariantOutOfStock.graphql @@ -0,0 +1,16 @@ +subscription ProductVariantOutOfStock { + event { + __typename + ... on ProductVariantOutOfStock { + __typename + productVariant { + ...ProductVariantWebhookPayload + } + } + recipient { + webhooks { + id + } + } + } +} diff --git a/apps/search/src/components/ImportProductsToAlgolia.tsx b/apps/search/src/components/ImportProductsToAlgolia.tsx index 4ab1179..34b36cd 100644 --- a/apps/search/src/components/ImportProductsToAlgolia.tsx +++ b/apps/search/src/components/ImportProductsToAlgolia.tsx @@ -3,7 +3,6 @@ import React, { useCallback, useEffect, useMemo, useState } from "react"; import { AlgoliaSearchProvider } from "../lib/algolia/algoliaSearchProvider"; import { useConfiguration } from "../lib/configuration"; import { Products, useQueryAllProducts } from "./useQueryAllProducts"; -import { useWebhooksStatus } from "../lib/useWebhooksStatus"; const BATCH_SIZE = 100; diff --git a/apps/search/src/components/MainInstructions.tsx b/apps/search/src/components/MainInstructions.tsx index 011e964..67c8780 100644 --- a/apps/search/src/components/MainInstructions.tsx +++ b/apps/search/src/components/MainInstructions.tsx @@ -14,33 +14,6 @@ export const MainInstructions = ({ children, ...props }: PropsWithBox<{}>) => { Once the App is configured, you will be able to perform initial index of your existing Saleor database. - - The app supports following{" "} - - events - {" "} - that will synchronize Algolia in the background: - - ); }; diff --git a/apps/search/src/components/WebhooksStatus.tsx b/apps/search/src/components/WebhooksStatus.tsx index 43d4db3..685acbf 100644 --- a/apps/search/src/components/WebhooksStatus.tsx +++ b/apps/search/src/components/WebhooksStatus.tsx @@ -1,21 +1,25 @@ -import { Accordion, Box, Text } from "@saleor/macaw-ui/next"; +import { Accordion, Box, Button, Text } from "@saleor/macaw-ui/next"; import { EventDeliveryStatusEnum } from "../../generated/graphql"; import { useWebhooksStatus } from "../lib/useWebhooksStatus"; import { SemanticChip } from "@saleor/apps-ui"; +import { useWebhooksUpdateMutation } from "../lib/useWebhooksUpdate"; export const WebhooksStatus = () => { - const { data: webhooksData } = useWebhooksStatus(); + const { data } = useWebhooksStatus(); + const updateWebhooksMutation = useWebhooksUpdateMutation(); - if (!webhooksData) { + if (!data) { return Loading...; } + const webhooksData = data.webhooks; + return ( {webhooksData.map((webhook) => { const failedEventDeliveries = webhook.eventDeliveries?.edges?.filter( - (e) => e.node.status === EventDeliveryStatusEnum.Failed + (e) => e.node.status === EventDeliveryStatusEnum.Failed, ); const hasFailedDeliveries = failedEventDeliveries && failedEventDeliveries.length > 0; @@ -87,6 +91,24 @@ export const WebhooksStatus = () => { ); })} + {data.isUpdateNeeded && ( + + + New app version installed. Update the webhooks and run product import again. + + + + )} ); }; diff --git a/apps/search/src/components/WebhooksStatusInstructions.tsx b/apps/search/src/components/WebhooksStatusInstructions.tsx index c0503d1..af54abe 100644 --- a/apps/search/src/components/WebhooksStatusInstructions.tsx +++ b/apps/search/src/components/WebhooksStatusInstructions.tsx @@ -4,7 +4,7 @@ import { useWebhooksStatus } from "../lib/useWebhooksStatus"; export const WebhooksStatusInstructions = () => { const { data: webhooksData } = useWebhooksStatus(); - if (webhooksData && webhooksData.some((w) => !w.isActive)) { + if (webhooksData && webhooksData.webhooks.some((w) => !w.isActive)) { return ( <> diff --git a/apps/search/src/domain/WebhookActivityToggler.service.ts b/apps/search/src/domain/WebhookActivityToggler.service.ts index bee0214..2312f07 100644 --- a/apps/search/src/domain/WebhookActivityToggler.service.ts +++ b/apps/search/src/domain/WebhookActivityToggler.service.ts @@ -1,10 +1,15 @@ import { Client, OperationResult } from "urql"; import { + CreateWebhookDocument, + CreateWebhookMutationVariables, DisableWebhookDocument, EnableWebhookDocument, FetchOwnWebhooksDocument, + RemoveWebhookDocument, + WebhookEventTypeEnum, } from "../../generated/graphql"; import { createLogger } from "../lib/logger"; +import { appWebhooks } from "../../webhooks"; const logger = createLogger({ service: "WebhookActivityTogglerService", @@ -14,11 +19,19 @@ export interface IWebhooksActivityClient { fetchAppWebhooksIDs(id: string): Promise; disableSingleWebhook(id: string): Promise; enableSingleWebhook(id: string): Promise; + removeSingleWebhook(id: string): Promise; + createWebhook(input: CreateWebhookMutationVariables["input"]): Promise; +} + +interface IRecreateWebhooksArgs { + baseUrl: string; + enableWebhooks: boolean; } export interface IWebhookActivityTogglerService { disableOwnWebhooks(webhooksIdsParam?: string[]): Promise; enableOwnWebhooks(): Promise; + recreateOwnWebhooks(args: IRecreateWebhooksArgs): Promise; } export class WebhooksActivityClient implements IWebhooksActivityClient { @@ -30,7 +43,7 @@ export class WebhooksActivityClient implements IWebhooksActivityClient { { error: r.error, }, - "Error disabling webhook" + "Error disabling webhook", ); throw new Error("Error disabling webhook"); } @@ -76,6 +89,32 @@ export class WebhooksActivityClient implements IWebhooksActivityClient { return undefined; }); } + + createWebhook(input: CreateWebhookMutationVariables["input"]): Promise { + return this.client + .mutation(CreateWebhookDocument, { + input, + }) + .toPromise() + .then((r) => { + this.handleOperationFailure(r); + + return undefined; + }); + } + + removeSingleWebhook(id: string): Promise { + return this.client + .mutation(RemoveWebhookDocument, { + id, + }) + .toPromise() + .then((r) => { + this.handleOperationFailure(r); + + return undefined; + }); + } } export class WebhookActivityTogglerService implements IWebhookActivityTogglerService { @@ -89,7 +128,7 @@ export class WebhookActivityTogglerService implements IWebhookActivityTogglerSer private client: Pick, options?: { WebhooksClient: IWebhooksActivityClient; - } + }, ) { this.webhooksClient = options?.WebhooksClient ?? new WebhooksActivityClient(this.client); } @@ -121,4 +160,30 @@ export class WebhookActivityTogglerService implements IWebhookActivityTogglerSer await Promise.all(webhooksIds.map((id) => this.webhooksClient.enableSingleWebhook(id))); } + + async recreateOwnWebhooks({ baseUrl, enableWebhooks }: IRecreateWebhooksArgs) { + const webhooksIds = await this.webhooksClient.fetchAppWebhooksIDs(this.ownAppId); + + if (!webhooksIds) { + throw new Error("Failed fetching webhooks"); + } + + logger.debug("Removing old webhooks"); + await Promise.all(webhooksIds.map((id) => this.webhooksClient.removeSingleWebhook(id))); + logger.debug("Creating new webhooks"); + await Promise.all( + appWebhooks.map((webhook) => { + const manifest = webhook.getWebhookManifest(baseUrl); + + return this.webhooksClient.createWebhook({ + events: manifest.asyncEvents as WebhookEventTypeEnum[], + targetUrl: manifest.targetUrl, + name: manifest.name, + query: manifest.query, + isActive: enableWebhooks, + }); + }), + ); + logger.debug("Done creating new webhooks"); + } } diff --git a/apps/search/src/lib/algolia/algoliaUtils.ts b/apps/search/src/lib/algolia/algoliaUtils.ts index d20b1c6..3b82525 100644 --- a/apps/search/src/lib/algolia/algoliaUtils.ts +++ b/apps/search/src/lib/algolia/algoliaUtils.ts @@ -15,7 +15,7 @@ type PartialChannelListing = { export function channelListingToAlgoliaIndexId( channelListing: PartialChannelListing, - indexNamePrefix: string | undefined + indexNamePrefix: string | undefined, ) { /** * Index name should not start with . (dot) @@ -113,6 +113,10 @@ export function productAndVariantToAlgolia({ const listing = variant.channelListings?.find((l) => l.channel.slug === channel); + const inStock = !!variant.quantityAvailable; + + const media = variant.product.media?.map((m) => ({ url: m.url, type: m.type })) || []; + const document = { objectID: productAndVariantToObjectID(variant), productId: product.id, @@ -121,10 +125,12 @@ export function productAndVariantToAlgolia({ productName: product.name, variantName: variant.name, attributes, + media, description: safeParseJson(product.description), slug: product.slug, thumbnail: product.thumbnail?.url, grossPrice: listing?.price?.amount, + inStock, categories: categoryHierarchicalFacets(variant), collections: product.collections?.map((collection) => collection.name) || [], metadata: metadataToAlgoliaAttribute(variant.product.metadata), diff --git a/apps/search/src/lib/algolia/is-configured.test.ts b/apps/search/src/lib/algolia/is-configured.test.ts new file mode 100644 index 0000000..2928327 --- /dev/null +++ b/apps/search/src/lib/algolia/is-configured.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { isConfigured } from "./is-configured"; + +describe("isConfigured", () => { + describe("Valid configurations", () => { + it("Returns true, when configuration has all fields filled", () => { + expect( + isConfigured({ + configuration: { appId: "appId", indexNamePrefix: "prefix", secretKey: "secretKey" }, + }), + ).toBe(true); + }); + it("Returns true, when optional index name is not set", () => { + expect( + isConfigured({ + configuration: { + appId: "appId", + indexNamePrefix: undefined, + secretKey: "secretKey", + }, + }), + ).toBe(true); + }); + }); + describe("Invalid configurations", () => { + it("Returns false, when empty configuration is used", () => { + expect(isConfigured({ configuration: undefined })).toBe(false); + }); + + it("Returns false, when app id is missing", () => { + expect( + isConfigured({ + configuration: { appId: undefined, indexNamePrefix: "prefix", secretKey: "secretKey" }, + }), + ).toBe(false); + }); + + it("Returns false, when app id is missing", () => { + expect( + isConfigured({ + configuration: { appId: "appId", indexNamePrefix: "prefix", secretKey: undefined }, + }), + ).toBe(false); + }); + }); +}); diff --git a/apps/search/src/lib/algolia/is-configured.ts b/apps/search/src/lib/algolia/is-configured.ts new file mode 100644 index 0000000..63776db --- /dev/null +++ b/apps/search/src/lib/algolia/is-configured.ts @@ -0,0 +1,13 @@ +import { AppConfigurationFields, AppConfigurationSchema } from "../../domain/configuration"; + +interface isConfiguredArgs { + configuration: Partial | undefined; +} + +// Checks if the app configuration is set up +export const isConfigured = ({ configuration }: isConfiguredArgs) => { + if (!configuration) { + return false; + } + return AppConfigurationSchema.safeParse(configuration).success; +}; diff --git a/apps/search/src/lib/algolia/is-webhook-update-needed.test.ts b/apps/search/src/lib/algolia/is-webhook-update-needed.test.ts new file mode 100644 index 0000000..d9f59ec --- /dev/null +++ b/apps/search/src/lib/algolia/is-webhook-update-needed.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { isWebhookUpdateNeeded } from "./is-webhook-update-needed"; + +describe("isWebhookUpdateNeeded", () => { + it("Returns false, when all webhooks are installed", () => { + expect( + isWebhookUpdateNeeded({ + existingWebhookNames: [ + "PRODUCT_CREATED webhook", + "PRODUCT_UPDATED webhook", + "PRODUCT_DELETED webhook", + "PRODUCT_VARIANT_CREATED webhook", + "PRODUCT_VARIANT_UPDATED webhook", + "PRODUCT_VARIANT_DELETED webhook", + "PRODUCT_VARIANT_BACK_IN_STOCK webhook", + "PRODUCT_VARIANT_OUT_OF_STOCK webhook", + ], + }), + ).toBe(false); + }); + + it("Returns true, when passed list of names is not contain all of the hooks", () => { + expect( + isWebhookUpdateNeeded({ + existingWebhookNames: [], + }), + ).toBe(true); + }); +}); diff --git a/apps/search/src/lib/algolia/is-webhook-update-needed.ts b/apps/search/src/lib/algolia/is-webhook-update-needed.ts new file mode 100644 index 0000000..e8a8724 --- /dev/null +++ b/apps/search/src/lib/algolia/is-webhook-update-needed.ts @@ -0,0 +1,11 @@ +import { appWebhooks } from "../../../webhooks"; + +interface isWebhookUpdateNeededArgs { + existingWebhookNames: string[]; +} + +export const isWebhookUpdateNeeded = ({ existingWebhookNames }: isWebhookUpdateNeededArgs) => { + const notInstalledWebhooks = appWebhooks.filter((w) => !existingWebhookNames.includes(w.name)); + + return !!notInstalledWebhooks.length; +}; diff --git a/apps/search/src/lib/getBaseUrl.ts b/apps/search/src/lib/getBaseUrl.ts new file mode 100644 index 0000000..cd52dcf --- /dev/null +++ b/apps/search/src/lib/getBaseUrl.ts @@ -0,0 +1,15 @@ +/** + * Extracts the app's url from headers from the response. + */ +export const getBaseUrl = (headers: { [name: string]: string | string[] | undefined }): string => { + const { host, "x-forwarded-proto": xForwardedProto = "http" } = headers; + + const xForwardedProtos = Array.isArray(xForwardedProto) + ? xForwardedProto.join(",") + : xForwardedProto; + const protocols = xForwardedProtos.split(","); + // prefer https over other protocols + const protocol = protocols.find((el) => el === "https") || protocols[0]; + + return `${protocol}://${host}`; +}; diff --git a/apps/search/src/lib/useWebhooksStatus.tsx b/apps/search/src/lib/useWebhooksStatus.tsx index b2f5ae2..80eda6f 100644 --- a/apps/search/src/lib/useWebhooksStatus.tsx +++ b/apps/search/src/lib/useWebhooksStatus.tsx @@ -1,7 +1,7 @@ import { useQuery } from "@tanstack/react-query"; import { useAuthenticatedFetch } from "@saleor/app-sdk/app-bridge"; import { useCallback } from "react"; -import { OwnWebhookFragment } from "../../generated/graphql"; +import { WebhooksStatusResponse } from "../pages/api/webhooks-status"; export const useWebhooksStatus = () => { const fetch: typeof window.fetch = useAuthenticatedFetch(); @@ -13,7 +13,7 @@ export const useWebhooksStatus = () => { */ }, []); - return useQuery({ + return useQuery({ queryKey: ["webhooks-status"], queryFn: fetchFn, }); diff --git a/apps/search/src/lib/useWebhooksUpdate.tsx b/apps/search/src/lib/useWebhooksUpdate.tsx new file mode 100644 index 0000000..bf16d8a --- /dev/null +++ b/apps/search/src/lib/useWebhooksUpdate.tsx @@ -0,0 +1,28 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useAuthenticatedFetch } from "@saleor/app-sdk/app-bridge"; +import { useCallback } from "react"; +import { useDashboardNotification } from "@saleor/apps-shared"; + +export const useWebhooksUpdateMutation = () => { + const fetch: typeof window.fetch = useAuthenticatedFetch(); + const { notifyError, notifySuccess } = useDashboardNotification(); + const queryClient = useQueryClient(); + + const mutationFn = useCallback(() => { + return fetch("/api/recreate-webhooks", { method: "POST" }).then((resp) => { + if (resp.ok) { + queryClient.invalidateQueries({ queryKey: ["webhooks-status"] }); + notifySuccess("Webhooks has been updated"); + } else { + notifyError("Webhooks update failed"); + } + }); + /** + * fetch from SDK is not wrapped with memo todo + */ + }, [fetch, notifyError, notifySuccess, queryClient]); + + return useMutation({ + mutationFn, + }); +}; diff --git a/apps/search/src/pages/api/manifest.ts b/apps/search/src/pages/api/manifest.ts index b4b8586..94d4b5e 100644 --- a/apps/search/src/pages/api/manifest.ts +++ b/apps/search/src/pages/api/manifest.ts @@ -2,12 +2,7 @@ import { createManifestHandler } from "@saleor/app-sdk/handlers/next"; import { AppManifest } from "@saleor/app-sdk/types"; import packageJson from "../../../package.json"; -import { webhookProductCreated } from "./webhooks/saleor/product_created"; -import { webhookProductDeleted } from "./webhooks/saleor/product_deleted"; -import { webhookProductUpdated } from "./webhooks/saleor/product_updated"; -import { webhookProductVariantCreated } from "./webhooks/saleor/product_variant_created"; -import { webhookProductVariantDeleted } from "./webhooks/saleor/product_variant_deleted"; -import { webhookProductVariantUpdated } from "./webhooks/saleor/product_variant_updated"; +import { appWebhooks } from "../../../webhooks"; export default createManifestHandler({ async manifestFactory({ appBaseUrl }) { @@ -44,19 +39,7 @@ export default createManifestHandler({ supportUrl: "https://github.com/saleor/apps/discussions", tokenTargetUrl: `${apiBaseURL}/api/register`, version: packageJson.version, - webhooks: [ - /** - * Configure webhooks here. They will be created in Saleor during installation - * Read more - * https://docs.saleor.io/docs/3.x/developer/api-reference/objects/webhook - */ - webhookProductCreated.getWebhookManifest(apiBaseURL), - webhookProductDeleted.getWebhookManifest(apiBaseURL), - webhookProductUpdated.getWebhookManifest(apiBaseURL), - webhookProductVariantCreated.getWebhookManifest(apiBaseURL), - webhookProductVariantDeleted.getWebhookManifest(apiBaseURL), - webhookProductVariantUpdated.getWebhookManifest(apiBaseURL), - ], + webhooks: appWebhooks.map((w) => w.getWebhookManifest(apiBaseURL)), }; return manifest; diff --git a/apps/search/src/pages/api/recreate-webhooks.ts b/apps/search/src/pages/api/recreate-webhooks.ts new file mode 100644 index 0000000..c7383df --- /dev/null +++ b/apps/search/src/pages/api/recreate-webhooks.ts @@ -0,0 +1,87 @@ +import { createProtectedHandler, NextProtectedApiHandler } from "@saleor/app-sdk/handlers/next"; +import { saleorApp } from "../../../saleor-app"; +import { createSettingsManager } from "../../lib/metadata"; +import { + IWebhookActivityTogglerService, + WebhookActivityTogglerService, +} from "../../domain/WebhookActivityToggler.service"; +import { createLogger } from "../../lib/logger"; +import { SettingsManager } from "@saleor/app-sdk/settings-manager"; +import { createGraphQLClient } from "@saleor/apps-shared"; +import { Client } from "urql"; +import { getBaseUrl } from "../../lib/getBaseUrl"; +import { isConfigured } from "../../lib/algolia/is-configured"; + +const logger = createLogger({ + service: "recreateWebhooksHandler", +}); + +/** + * Simple dependency injection - factory injects all services, in tests everything can be configured without mocks + */ +type FactoryProps = { + settingsManagerFactory: (client: Client) => SettingsManager; + webhookActivityTogglerFactory: (appId: string, client: Client) => IWebhookActivityTogglerService; + graphqlClientFactory: (saleorApiUrl: string, token: string) => Client; +}; + +export const recreateWebhooksHandlerFactory = + ({ + settingsManagerFactory, + webhookActivityTogglerFactory, + graphqlClientFactory, + }: FactoryProps): NextProtectedApiHandler => + async (req, res, { authData }) => { + if (req.method !== "POST") { + logger.debug("Request method is different than POST, returning 405"); + return res.status(405).end(); + } + + logger.debug("Fetching settings"); + const client = graphqlClientFactory(authData.saleorApiUrl, authData.token); + const webhooksToggler = webhookActivityTogglerFactory(authData.appId, client); + const settingsManager = settingsManagerFactory(client); + + const domain = new URL(authData.saleorApiUrl).host; + + const [secretKey, appId] = await Promise.all([ + settingsManager.get("secretKey", domain), + settingsManager.get("appId", domain), + ]); + + const settings = { secretKey, appId }; + + logger.debug(settings, "fetched settings"); + + const baseUrl = getBaseUrl(req.headers); + const enableWebhooks = isConfigured({ + configuration: { + appId: appId, + secretKey: secretKey, + }, + }); + + try { + logger.debug("Running webhooks recreation"); + await webhooksToggler.recreateOwnWebhooks({ baseUrl: baseUrl, enableWebhooks }); + logger.debug("Webhooks recreated"); + return res.status(200).end(); + } catch (e) { + logger.error(e); + return res.status(500).end(); + } + }; + +export default createProtectedHandler( + recreateWebhooksHandlerFactory({ + settingsManagerFactory: createSettingsManager, + webhookActivityTogglerFactory: function (appId, client) { + return new WebhookActivityTogglerService(appId, client); + }, + graphqlClientFactory(saleorApiUrl: string, token: string) { + return createGraphQLClient({ saleorApiUrl, token }); + }, + }), + saleorApp.apl, + [], +); diff --git a/apps/search/src/pages/api/webhooks-status.ts b/apps/search/src/pages/api/webhooks-status.ts index 73e53da..b2cdbc5 100644 --- a/apps/search/src/pages/api/webhooks-status.ts +++ b/apps/search/src/pages/api/webhooks-status.ts @@ -1,6 +1,6 @@ import { createProtectedHandler, NextProtectedApiHandler } from "@saleor/app-sdk/handlers/next"; import { saleorApp } from "../../../saleor-app"; -import { FetchOwnWebhooksDocument } from "../../../generated/graphql"; +import { FetchOwnWebhooksDocument, OwnWebhookFragment } from "../../../generated/graphql"; import { AlgoliaSearchProvider } from "../../lib/algolia/algoliaSearchProvider"; import { createSettingsManager } from "../../lib/metadata"; import { @@ -12,6 +12,7 @@ import { SettingsManager } from "@saleor/app-sdk/settings-manager"; import { SearchProvider } from "../../lib/searchProvider"; import { createGraphQLClient } from "@saleor/apps-shared"; import { Client } from "urql"; +import { isWebhookUpdateNeeded } from "../../lib/algolia/is-webhook-update-needed"; const logger = createLogger({ service: "webhooksStatusHandler", @@ -27,13 +28,18 @@ type FactoryProps = { graphqlClientFactory: (saleorApiUrl: string, token: string) => Client; }; +export type WebhooksStatusResponse = { + webhooks: OwnWebhookFragment[]; + isUpdateNeeded: boolean; +}; + export const webhooksStatusHandlerFactory = ({ settingsManagerFactory, webhookActivityTogglerFactory, algoliaSearchProviderFactory, graphqlClientFactory, - }: FactoryProps): NextProtectedApiHandler => + }: FactoryProps): NextProtectedApiHandler => async (req, res, { authData }) => { /** * Initialize services @@ -93,7 +99,14 @@ export const webhooksStatusHandlerFactory = return res.status(500).end(); } - return res.status(200).json(webhooks); + const isUpdateNeeded = isWebhookUpdateNeeded({ + existingWebhookNames: webhooks.map((w) => w.name), + }); + + return res.status(200).json({ + webhooks, + isUpdateNeeded, + }); } catch (e) { console.error(e); return res.status(500).end(); @@ -114,5 +127,5 @@ export default createProtectedHandler( }, }), saleorApp.apl, - [] + [], ); diff --git a/apps/search/src/pages/api/webhooks/saleor/product_variant_back_in_stock.ts b/apps/search/src/pages/api/webhooks/saleor/product_variant_back_in_stock.ts new file mode 100644 index 0000000..314b4fe --- /dev/null +++ b/apps/search/src/pages/api/webhooks/saleor/product_variant_back_in_stock.ts @@ -0,0 +1,91 @@ +import { NextWebhookApiHandler, SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next"; +import { createGraphQLClient } from "@saleor/apps-shared"; +import { + ProductVariantBackInStock, + ProductVariantBackInStockDocument, +} from "../../../../../generated/graphql"; +import { saleorApp } from "../../../../../saleor-app"; +import { WebhookActivityTogglerService } from "../../../../domain/WebhookActivityToggler.service"; +import { AlgoliaSearchProvider } from "../../../../lib/algolia/algoliaSearchProvider"; +import { getAlgoliaConfiguration } from "../../../../lib/algolia/getAlgoliaConfiguration"; +import { createLogger } from "../../../../lib/logger"; + +export const config = { + api: { + bodyParser: false, + }, +}; + +export const webhookProductVariantBackInStock = new SaleorAsyncWebhook({ + webhookPath: "api/webhooks/saleor/product_variant_back_in_stock", + event: "PRODUCT_VARIANT_BACK_IN_STOCK", + apl: saleorApp.apl, + query: ProductVariantBackInStockDocument, + /** + * Webhook is disabled by default. Will be enabled by the app when configuration succeeds + */ + isActive: false, +}); + +const logger = createLogger({ + service: "webhookProductVariantBackInStockWebhookHandler", +}); + +export const handler: NextWebhookApiHandler = async ( + req, + res, + context, +) => { + const { event, authData } = context; + + logger.debug( + `New event ${event} (${context.payload?.__typename}) from the ${authData.domain} domain has been received!`, + ); + + const { settings, errors } = await getAlgoliaConfiguration({ authData }); + + if (errors?.length || !settings) { + logger.warn("Aborting due to lack of settings"); + logger.debug(errors); + + return res.status(400).json({ + message: errors[0].message, + }); + } + + const searchProvider = new AlgoliaSearchProvider({ + appId: settings.appId, + apiKey: settings.secretKey, + indexNamePrefix: settings.indexNamePrefix, + }); + + const { productVariant } = context.payload; + + if (productVariant) { + try { + await searchProvider.updateProductVariant(productVariant); + } catch (e) { + logger.info(e, "Algolia updateProductVariant failed. Webhooks will be disabled"); + + const webhooksToggler = new WebhookActivityTogglerService( + authData.appId, + createGraphQLClient({ saleorApiUrl: authData.saleorApiUrl, token: authData.token }), + ); + + logger.trace("Will disable webhooks"); + + await webhooksToggler.disableOwnWebhooks( + context.payload.recipient?.webhooks?.map((w) => w.id), + ); + + logger.trace("Webhooks disabling operation finished"); + + return res.status(500).send("Operation failed, webhooks are disabled"); + } + } + + res.status(200).end(); + return; +}; + +export default webhookProductVariantBackInStock.createHandler(handler); diff --git a/apps/search/src/pages/api/webhooks/saleor/product_variant_out_of_stock.ts b/apps/search/src/pages/api/webhooks/saleor/product_variant_out_of_stock.ts new file mode 100644 index 0000000..f79cb77 --- /dev/null +++ b/apps/search/src/pages/api/webhooks/saleor/product_variant_out_of_stock.ts @@ -0,0 +1,91 @@ +import { NextWebhookApiHandler, SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next"; +import { createGraphQLClient } from "@saleor/apps-shared"; +import { + ProductVariantOutOfStock, + ProductVariantOutOfStockDocument, +} from "../../../../../generated/graphql"; +import { saleorApp } from "../../../../../saleor-app"; +import { WebhookActivityTogglerService } from "../../../../domain/WebhookActivityToggler.service"; +import { AlgoliaSearchProvider } from "../../../../lib/algolia/algoliaSearchProvider"; +import { getAlgoliaConfiguration } from "../../../../lib/algolia/getAlgoliaConfiguration"; +import { createLogger } from "../../../../lib/logger"; + +export const config = { + api: { + bodyParser: false, + }, +}; + +export const webhookProductVariantOutOfStock = new SaleorAsyncWebhook({ + webhookPath: "api/webhooks/saleor/product_variant_out_of_stock", + event: "PRODUCT_VARIANT_OUT_OF_STOCK", + apl: saleorApp.apl, + query: ProductVariantOutOfStockDocument, + /** + * Webhook is disabled by default. Will be enabled by the app when configuration succeeds + */ + isActive: false, +}); + +const logger = createLogger({ + service: "webhookProductVariantOutOfStockWebhookHandler", +}); + +export const handler: NextWebhookApiHandler = async ( + req, + res, + context, +) => { + const { event, authData } = context; + + logger.debug( + `New event ${event} (${context.payload?.__typename}) from the ${authData.domain} domain has been received!`, + ); + + const { settings, errors } = await getAlgoliaConfiguration({ authData }); + + if (errors?.length || !settings) { + logger.warn("Aborting due to lack of settings"); + logger.debug(errors); + + return res.status(400).json({ + message: errors[0].message, + }); + } + + const searchProvider = new AlgoliaSearchProvider({ + appId: settings.appId, + apiKey: settings.secretKey, + indexNamePrefix: settings.indexNamePrefix, + }); + + const { productVariant } = context.payload; + + if (productVariant) { + try { + await searchProvider.updateProductVariant(productVariant); + } catch (e) { + logger.info(e, "Algolia updateProductVariant failed. Webhooks will be disabled"); + + const webhooksToggler = new WebhookActivityTogglerService( + authData.appId, + createGraphQLClient({ saleorApiUrl: authData.saleorApiUrl, token: authData.token }), + ); + + logger.trace("Will disable webhooks"); + + await webhooksToggler.disableOwnWebhooks( + context.payload.recipient?.webhooks?.map((w) => w.id), + ); + + logger.trace("Webhooks disabling operation finished"); + + return res.status(500).send("Operation failed, webhooks are disabled"); + } + } + + res.status(200).end(); + return; +}; + +export default webhookProductVariantOutOfStock.createHandler(handler); diff --git a/apps/search/webhooks.ts b/apps/search/webhooks.ts new file mode 100644 index 0000000..1bf7a2a --- /dev/null +++ b/apps/search/webhooks.ts @@ -0,0 +1,19 @@ +import { webhookProductCreated } from "./src/pages/api/webhooks/saleor/product_created"; +import { webhookProductDeleted } from "./src/pages/api/webhooks/saleor/product_deleted"; +import { webhookProductUpdated } from "./src/pages/api/webhooks/saleor/product_updated"; +import { webhookProductVariantCreated } from "./src/pages/api/webhooks/saleor/product_variant_created"; +import { webhookProductVariantDeleted } from "./src/pages/api/webhooks/saleor/product_variant_deleted"; +import { webhookProductVariantUpdated } from "./src/pages/api/webhooks/saleor/product_variant_updated"; +import { webhookProductVariantOutOfStock } from "./src/pages/api/webhooks/saleor/product_variant_out_of_stock"; +import { webhookProductVariantBackInStock } from "./src/pages/api/webhooks/saleor/product_variant_back_in_stock"; + +export const appWebhooks = [ + webhookProductCreated, + webhookProductDeleted, + webhookProductUpdated, + webhookProductVariantCreated, + webhookProductVariantDeleted, + webhookProductVariantUpdated, + webhookProductVariantOutOfStock, + webhookProductVariantBackInStock, +];