From 40bed9931648d00082d2a9e2f7aec3ad1249b597 Mon Sep 17 00:00:00 2001 From: Lukasz Ostrowski Date: Sat, 29 Apr 2023 11:30:48 +0200 Subject: [PATCH] Webhooks optimization (#434) * Disable webhooks in manifest and create graphql operations * Add webhook enabler service * Add webhooks to subscription events * Disable webhooks when webhok operation fail * display webhooks status * Display failed deliveries * Use react query in webhooks-status, refetch it when configu updated * Extracted main instructons * Better webhook instructions * Optimize webhooks fetching from subscription * Test for WebhooksAcivityToggler * Changeset * Test for webhook status handler WIP * Test wip * Rewrite test --- .changeset/grumpy-suits-sing.md | 13 ++ .../graphql/fragments/OwnWebhook.graphql | 26 ++++ .../graphql/mutations/DisableWebhook.graphql | 11 ++ .../graphql/mutations/EnableWebhook.graphql | 11 ++ .../graphql/queries/FetchOwnWebhooks.graphql | 7 + .../subscriptions/productCreated.graphql | 5 + .../subscriptions/productDeleted.graphql | 5 + .../subscriptions/productUpdated.graphql | 5 + .../productVariantCreated.graphql | 5 + .../productVariantDeleted.graphql | 5 + .../productVariantUpdated.graphql | 5 + apps/search/package.json | 6 +- .../components/AlgoliaConfigurationForm.tsx | 11 +- .../components/ImportProductsToAlgolia.tsx | 13 +- .../src/components/MainInstructions.tsx | 63 +++++++++ apps/search/src/components/WebhooksStatus.tsx | 104 +++++++++++++++ .../components/WebhooksStatusInstructions.tsx | 22 +++ .../WebhookActivityToggler.service.test.ts | 65 +++++++++ .../domain/WebhookActivityToggler.service.ts | 124 +++++++++++++++++ .../src/lib/algolia/algoliaSearchProvider.ts | 9 +- apps/search/src/lib/graphql.ts | 6 +- apps/search/src/lib/metadata.ts | 17 +-- apps/search/src/lib/searchProvider.ts | 1 + apps/search/src/lib/useWebhooksStatus.tsx | 20 +++ apps/search/src/pages/api/configuration.ts | 37 +++++- apps/search/src/pages/api/webhooks-status.ts | 123 +++++++++++++++++ .../api/webhooks/saleor/product_created.ts | 42 +++++- .../api/webhooks/saleor/product_deleted.ts | 41 +++++- .../api/webhooks/saleor/product_updated.ts | 42 +++++- .../saleor/product_variant_created.ts | 41 +++++- .../saleor/product_variant_deleted.ts | 42 +++++- .../saleor/product_variant_updated.ts | 42 +++++- .../handlers/webhooks-status-handler.test.ts | 125 ++++++++++++++++++ .../configuration/configuration.view.tsx | 66 ++------- pnpm-lock.yaml | 3 + 35 files changed, 1048 insertions(+), 115 deletions(-) create mode 100644 .changeset/grumpy-suits-sing.md create mode 100644 apps/search/graphql/fragments/OwnWebhook.graphql create mode 100644 apps/search/graphql/mutations/DisableWebhook.graphql create mode 100644 apps/search/graphql/mutations/EnableWebhook.graphql create mode 100644 apps/search/graphql/queries/FetchOwnWebhooks.graphql create mode 100644 apps/search/src/components/MainInstructions.tsx create mode 100644 apps/search/src/components/WebhooksStatus.tsx create mode 100644 apps/search/src/components/WebhooksStatusInstructions.tsx create mode 100644 apps/search/src/domain/WebhookActivityToggler.service.test.ts create mode 100644 apps/search/src/domain/WebhookActivityToggler.service.ts create mode 100644 apps/search/src/lib/useWebhooksStatus.tsx create mode 100644 apps/search/src/pages/api/webhooks-status.ts create mode 100644 apps/search/src/test/handlers/webhooks-status-handler.test.ts diff --git a/.changeset/grumpy-suits-sing.md b/.changeset/grumpy-suits-sing.md new file mode 100644 index 0000000..27a9e95 --- /dev/null +++ b/.changeset/grumpy-suits-sing.md @@ -0,0 +1,13 @@ +--- +"saleor-app-search": minor +--- + +Added webhooks optimization feature. App will validate Algolia config in several places: +1. During config form submit +2. During loading frontend +3. During webhooks invocation + +If Algolia "ping" fails with 403, app will disable webhooks, assuming its misconfigured. + +Webhooks status is displayed in App configuration screen. If they are disabled, user can preview failed webhooks deliveries + diff --git a/apps/search/graphql/fragments/OwnWebhook.graphql b/apps/search/graphql/fragments/OwnWebhook.graphql new file mode 100644 index 0000000..6e9588f --- /dev/null +++ b/apps/search/graphql/fragments/OwnWebhook.graphql @@ -0,0 +1,26 @@ +fragment OwnWebhook on Webhook { + id + isActive + asyncEvents { + name + eventType + } + eventDeliveries(first: 100) { + edges { + node { + id + status + attempts(first: 100) { + edges { + node { + status + id + createdAt + responseStatusCode + } + } + } + } + } + } +} diff --git a/apps/search/graphql/mutations/DisableWebhook.graphql b/apps/search/graphql/mutations/DisableWebhook.graphql new file mode 100644 index 0000000..3ca9d16 --- /dev/null +++ b/apps/search/graphql/mutations/DisableWebhook.graphql @@ -0,0 +1,11 @@ +mutation DisableWebhook($id: ID!) { + webhookUpdate(id: $id, input: { isActive: false }) { + errors { + message + } + webhook { + id + isActive + } + } +} diff --git a/apps/search/graphql/mutations/EnableWebhook.graphql b/apps/search/graphql/mutations/EnableWebhook.graphql new file mode 100644 index 0000000..6da3975 --- /dev/null +++ b/apps/search/graphql/mutations/EnableWebhook.graphql @@ -0,0 +1,11 @@ +mutation EnableWebhook($id: ID!) { + webhookUpdate(id: $id, input: { isActive: true }) { + errors { + message + } + webhook { + id + isActive + } + } +} diff --git a/apps/search/graphql/queries/FetchOwnWebhooks.graphql b/apps/search/graphql/queries/FetchOwnWebhooks.graphql new file mode 100644 index 0000000..db7d199 --- /dev/null +++ b/apps/search/graphql/queries/FetchOwnWebhooks.graphql @@ -0,0 +1,7 @@ +query FetchOwnWebhooks($id: ID!) { + app(id: $id) { + webhooks { + ...OwnWebhook + } + } +} diff --git a/apps/search/graphql/subscriptions/productCreated.graphql b/apps/search/graphql/subscriptions/productCreated.graphql index 5f7fe4f..5785d9c 100644 --- a/apps/search/graphql/subscriptions/productCreated.graphql +++ b/apps/search/graphql/subscriptions/productCreated.graphql @@ -7,5 +7,10 @@ subscription ProductCreated { ...ProductWebhookPayload } } + recipient { + webhooks { + id + } + } } } diff --git a/apps/search/graphql/subscriptions/productDeleted.graphql b/apps/search/graphql/subscriptions/productDeleted.graphql index 1a8cea6..a339878 100644 --- a/apps/search/graphql/subscriptions/productDeleted.graphql +++ b/apps/search/graphql/subscriptions/productDeleted.graphql @@ -7,5 +7,10 @@ subscription ProductDeleted { ...ProductWebhookPayload } } + recipient { + webhooks { + id + } + } } } diff --git a/apps/search/graphql/subscriptions/productUpdated.graphql b/apps/search/graphql/subscriptions/productUpdated.graphql index c3f7528..3887d7f 100644 --- a/apps/search/graphql/subscriptions/productUpdated.graphql +++ b/apps/search/graphql/subscriptions/productUpdated.graphql @@ -7,5 +7,10 @@ subscription ProductUpdated { ...ProductWebhookPayload } } + recipient { + webhooks { + id + } + } } } diff --git a/apps/search/graphql/subscriptions/productVariantCreated.graphql b/apps/search/graphql/subscriptions/productVariantCreated.graphql index 3e4c23e..050f82a 100644 --- a/apps/search/graphql/subscriptions/productVariantCreated.graphql +++ b/apps/search/graphql/subscriptions/productVariantCreated.graphql @@ -7,5 +7,10 @@ subscription ProductVariantCreated { ...ProductVariantWebhookPayload } } + recipient { + webhooks { + id + } + } } } diff --git a/apps/search/graphql/subscriptions/productVariantDeleted.graphql b/apps/search/graphql/subscriptions/productVariantDeleted.graphql index 715b470..8fd2e73 100644 --- a/apps/search/graphql/subscriptions/productVariantDeleted.graphql +++ b/apps/search/graphql/subscriptions/productVariantDeleted.graphql @@ -7,5 +7,10 @@ subscription ProductVariantDeleted { ...ProductVariantWebhookPayload } } + recipient { + webhooks { + id + } + } } } diff --git a/apps/search/graphql/subscriptions/productVariantUpdated.graphql b/apps/search/graphql/subscriptions/productVariantUpdated.graphql index 16be24b..4374428 100644 --- a/apps/search/graphql/subscriptions/productVariantUpdated.graphql +++ b/apps/search/graphql/subscriptions/productVariantUpdated.graphql @@ -7,5 +7,10 @@ subscription ProductVariantUpdated { ...ProductVariantWebhookPayload } } + recipient { + webhooks { + id + } + } } } diff --git a/apps/search/package.json b/apps/search/package.json index ac6be89..5fd3226 100644 --- a/apps/search/package.json +++ b/apps/search/package.json @@ -9,7 +9,8 @@ "lint": "next lint", "lint:fix": "eslint --fix .", "fetch-schema": "curl https://raw.githubusercontent.com/saleor/saleor/${npm_package_saleor_schemaVersion}/saleor/graphql/schema.graphql > graphql/schema.graphql", - "generate": "graphql-codegen" + "generate": "graphql-codegen", + "test": "vitest" }, "saleor": { "schemaVersion": "3.7" @@ -60,6 +61,7 @@ "prettier": "^2.7.1", "typescript": "4.8.4", "vite": "^4.3.1", - "vitest": "^0.30.1" + "vitest": "^0.30.1", + "node-mocks-http": "^1.12.2" } } diff --git a/apps/search/src/components/AlgoliaConfigurationForm.tsx b/apps/search/src/components/AlgoliaConfigurationForm.tsx index 97e3a0b..dd46ebd 100644 --- a/apps/search/src/components/AlgoliaConfigurationForm.tsx +++ b/apps/search/src/components/AlgoliaConfigurationForm.tsx @@ -52,9 +52,14 @@ export const AlgoliaConfigurationForm = () => { }, { onSuccess: async () => { - reactQueryClient.refetchQueries({ - queryKey: ["configuration"], - }); + await Promise.all([ + reactQueryClient.refetchQueries({ + queryKey: ["configuration"], + }), + reactQueryClient.refetchQueries({ + queryKey: ["webhooks-status"], + }), + ]); notifySuccess("Configuration saved!"); }, onError: async (data: Error) => { diff --git a/apps/search/src/components/ImportProductsToAlgolia.tsx b/apps/search/src/components/ImportProductsToAlgolia.tsx index de741f5..44a54dd 100644 --- a/apps/search/src/components/ImportProductsToAlgolia.tsx +++ b/apps/search/src/components/ImportProductsToAlgolia.tsx @@ -3,10 +3,12 @@ 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; export const ImportProductsToAlgolia = () => { + const [algoliaConfigured, setAlgoliaConfigured] = useState(null); const [started, setStarted] = useState(false); const [currentProductIndex, setCurrentProductIndex] = useState(0); const [isAlgoliaImporting, setIsAlgoliaImporting] = useState(false); @@ -34,6 +36,15 @@ export const ImportProductsToAlgolia = () => { setStarted(true); }, []); + useEffect(() => { + if (searchProvider) { + searchProvider + .ping() + .then(() => setAlgoliaConfigured(true)) + .catch(() => setAlgoliaConfigured(false)); + } + }, [searchProvider]); + useEffect(() => { if (!searchProvider || isAlgoliaImporting || products.length <= currentProductIndex) { return; @@ -53,7 +64,7 @@ export const ImportProductsToAlgolia = () => { return ( - {searchProvider ? ( + {searchProvider && algoliaConfigured ? ( Importing products & variants diff --git a/apps/search/src/components/MainInstructions.tsx b/apps/search/src/components/MainInstructions.tsx new file mode 100644 index 0000000..42898f7 --- /dev/null +++ b/apps/search/src/components/MainInstructions.tsx @@ -0,0 +1,63 @@ +import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge"; +import { Box, PropsWithBox, Text } from "@saleor/macaw-ui/next"; + +const SALEOR_EVENTS_DOCS_URL = + "https://docs.saleor.io/docs/3.x/developer/extending/apps/asynchronous-webhooks#available-webhook-events"; + +export const MainInstructions = ({ children, ...props }: PropsWithBox<{}>) => { + const { appBridge } = useAppBridge(); + + return ( + + + To configure the App, fill in your Algolia settings to enable products indexing. + + + Once the App is configured, you will be able to perform initial index of your existing + Saleor database. + + + The app supports following{" "} + { + e.preventDefault(); + + /** + * TODO extract shared handler + */ + appBridge?.dispatch( + actions.Redirect({ + to: SALEOR_EVENTS_DOCS_URL, + newContext: true, + }) + ); + }} + href={SALEOR_EVENTS_DOCS_URL} + > + events + {" "} + that will synchronize Algolia in the background: + +
    +
  • + - PRODUCT_CREATED +
  • +
  • + - PRODUCT_UPDATED +
  • +
  • + - PRODUCT_DELETED +
  • +
  • + - PRODUCT_VARIANT_CREATED +
  • +
  • + - PRODUCT_VARIANT_UPDATED +
  • +
  • + - PRODUCT_VARIANT_DELETED +
  • +
+
+ ); +}; diff --git a/apps/search/src/components/WebhooksStatus.tsx b/apps/search/src/components/WebhooksStatus.tsx new file mode 100644 index 0000000..ab245c8 --- /dev/null +++ b/apps/search/src/components/WebhooksStatus.tsx @@ -0,0 +1,104 @@ +import { Accordion, Box, Chip, Text } from "@saleor/macaw-ui/next"; +import { EventDeliveryStatusEnum } from "../../generated/graphql"; +import { useWebhooksStatus } from "../lib/useWebhooksStatus"; + +export const WebhooksStatus = () => { + const { data: webhooksData } = useWebhooksStatus(); + + if (!webhooksData) { + return Loading...; + } + + return ( + + + {webhooksData.map((webhook) => { + const Trigger = webhook.isActive ? Box : Accordion.Item.Trigger; + + const failedEventDeliveries = webhook.eventDeliveries?.edges?.filter( + (e) => e.node.status === EventDeliveryStatusEnum.Failed + ); + + const hasFailedDeliveries = failedEventDeliveries && failedEventDeliveries.length > 0; + + return ( + + + + {webhook.asyncEvents[0].name} + + + {webhook.isActive ? "Active" : "Disabled"} + + + + + + + Delivery attempts + {!hasFailedDeliveries ? ( + + No failed deliveries + + ) : null} + + {webhook.eventDeliveries?.edges.map((delivery) => ( + + {delivery.node.attempts?.edges.map((attempt) => ( + + + Status:{" "} + {attempt.node.status} + + + HTTP + + {attempt.node.responseStatusCode} + + + + {new Date(attempt.node.createdAt).toLocaleString()} + + + ))} + + ))} + + + + + ); + })} + + + ); +}; diff --git a/apps/search/src/components/WebhooksStatusInstructions.tsx b/apps/search/src/components/WebhooksStatusInstructions.tsx new file mode 100644 index 0000000..dd4a72f --- /dev/null +++ b/apps/search/src/components/WebhooksStatusInstructions.tsx @@ -0,0 +1,22 @@ +import { Text } from "@saleor/macaw-ui/next"; +import { useWebhooksStatus } from "../lib/useWebhooksStatus"; + +export const WebhooksStatusInstructions = () => { + const { data: webhooksData } = useWebhooksStatus(); + + if (webhooksData && webhooksData.some((w) => !w.isActive)) { + return ( + <> + + Check status of registered webhooks. + + + Your webhooks were disabled. Most likely, your configuration is invalid. Please check your + credentials + + + ); + } + + return Check status of registered webhooks.; +}; diff --git a/apps/search/src/domain/WebhookActivityToggler.service.test.ts b/apps/search/src/domain/WebhookActivityToggler.service.test.ts new file mode 100644 index 0000000..304b174 --- /dev/null +++ b/apps/search/src/domain/WebhookActivityToggler.service.test.ts @@ -0,0 +1,65 @@ +import { Client } from "urql"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { IWebhooksActivityClient, WebhookActivityTogglerService } from "./WebhookActivityToggler.service"; + +describe("WebhookActivityTogglerService", function () { + let mockWebhooksClient: IWebhooksActivityClient; + let service: WebhookActivityTogglerService; + + beforeEach(() => { + mockWebhooksClient = { + enableSingleWebhook: vi.fn(), + disableSingleWebhook: vi.fn(), + fetchAppWebhooksIDs: vi.fn(), + }; + + service = new WebhookActivityTogglerService("ID", {} as Client, { + WebhooksClient: mockWebhooksClient, + }); + }); + + describe("disableOwnWebhooks", () => { + it("Calls disable single webhook procedures for each webhook in argument", async () => { + await service.disableOwnWebhooks(["w1", "w2", "w3"]); + + expect(mockWebhooksClient.fetchAppWebhooksIDs).not.toHaveBeenCalled(); + expect(mockWebhooksClient.disableSingleWebhook).toHaveBeenCalledTimes(3); + expect(mockWebhooksClient.disableSingleWebhook).toHaveBeenNthCalledWith(1, "w1"); + expect(mockWebhooksClient.disableSingleWebhook).toHaveBeenNthCalledWith(2, "w2"); + expect(mockWebhooksClient.disableSingleWebhook).toHaveBeenNthCalledWith(3, "w3"); + }); + + it("Calls disable single webhook procedures for each webhook fetched from API client", async () => { + vi.mocked(mockWebhooksClient.fetchAppWebhooksIDs).mockImplementationOnce(async () => [ + "w1", + "w2", + "w3", + ]); + + await service.disableOwnWebhooks(); + + expect(mockWebhooksClient.fetchAppWebhooksIDs).toHaveBeenCalled(); + expect(mockWebhooksClient.disableSingleWebhook).toHaveBeenCalledTimes(3); + expect(mockWebhooksClient.disableSingleWebhook).toHaveBeenNthCalledWith(1, "w1"); + expect(mockWebhooksClient.disableSingleWebhook).toHaveBeenNthCalledWith(2, "w2"); + expect(mockWebhooksClient.disableSingleWebhook).toHaveBeenNthCalledWith(3, "w3"); + }); + }); + describe("enableOwnWebhooks", () => { + it("Calls enable single webhooks procedures for each webhook fetched from API client", async () => { + vi.mocked(mockWebhooksClient.fetchAppWebhooksIDs).mockImplementationOnce(async () => [ + "w1", + "w2", + "w3", + ]); + + await service.enableOwnWebhooks(); + + expect(mockWebhooksClient.fetchAppWebhooksIDs).toHaveBeenCalled(); + expect(mockWebhooksClient.enableSingleWebhook).toHaveBeenCalledTimes(3); + expect(mockWebhooksClient.enableSingleWebhook).toHaveBeenNthCalledWith(1, "w1"); + expect(mockWebhooksClient.enableSingleWebhook).toHaveBeenNthCalledWith(2, "w2"); + expect(mockWebhooksClient.enableSingleWebhook).toHaveBeenNthCalledWith(3, "w3"); + }); + }); +}); diff --git a/apps/search/src/domain/WebhookActivityToggler.service.ts b/apps/search/src/domain/WebhookActivityToggler.service.ts new file mode 100644 index 0000000..bee0214 --- /dev/null +++ b/apps/search/src/domain/WebhookActivityToggler.service.ts @@ -0,0 +1,124 @@ +import { Client, OperationResult } from "urql"; +import { + DisableWebhookDocument, + EnableWebhookDocument, + FetchOwnWebhooksDocument, +} from "../../generated/graphql"; +import { createLogger } from "../lib/logger"; + +const logger = createLogger({ + service: "WebhookActivityTogglerService", +}); + +export interface IWebhooksActivityClient { + fetchAppWebhooksIDs(id: string): Promise; + disableSingleWebhook(id: string): Promise; + enableSingleWebhook(id: string): Promise; +} + +export interface IWebhookActivityTogglerService { + disableOwnWebhooks(webhooksIdsParam?: string[]): Promise; + enableOwnWebhooks(): Promise; +} + +export class WebhooksActivityClient implements IWebhooksActivityClient { + constructor(private client: Pick) {} + + private handleOperationFailure(r: OperationResult) { + if (r.error || !r.data) { + logger.error( + { + error: r.error, + }, + "Error disabling webhook" + ); + throw new Error("Error disabling webhook"); + } + } + + fetchAppWebhooksIDs(id: string) { + return this.client + .query(FetchOwnWebhooksDocument, { id }) + .toPromise() + .then((r) => { + this.handleOperationFailure(r); + + if (!r.data?.app?.webhooks) { + throw new Error("Webhooks not registered for app, something is wrong"); + } + + return r.data?.app?.webhooks?.map((w) => w.id); + }); + } + + disableSingleWebhook(id: string): Promise { + return this.client + .mutation(DisableWebhookDocument, { + id, + }) + .toPromise() + .then((r) => { + this.handleOperationFailure(r); + + return undefined; + }); + } + + enableSingleWebhook(id: string): Promise { + return this.client + .mutation(EnableWebhookDocument, { + id, + }) + .toPromise() + .then((r) => { + this.handleOperationFailure(r); + + return undefined; + }); + } +} + +export class WebhookActivityTogglerService implements IWebhookActivityTogglerService { + /** + * Extracted separate client for easier testing without touching graphQL + */ + private webhooksClient: IWebhooksActivityClient; + + constructor( + private ownAppId: string, + private client: Pick, + options?: { + WebhooksClient: IWebhooksActivityClient; + } + ) { + this.webhooksClient = options?.WebhooksClient ?? new WebhooksActivityClient(this.client); + } + + /** + * Disable webhooks with provided IDs. If not provided, it will fetch them from Saleor + */ + async disableOwnWebhooks(webhooksIdsParam?: string[]) { + const webhooksIds = + webhooksIdsParam ?? (await this.webhooksClient.fetchAppWebhooksIDs(this.ownAppId)); + + logger.info(webhooksIds, "Disabling own webhooks"); + + if (!webhooksIds) { + throw new Error("Failed fetching webhooks"); + } + + await Promise.all(webhooksIds.map((id) => this.webhooksClient.disableSingleWebhook(id))); + } + + async enableOwnWebhooks() { + const webhooksIds = await this.webhooksClient.fetchAppWebhooksIDs(this.ownAppId); + + logger.info(webhooksIds, "Enabling own webhooks"); + + if (!webhooksIds) { + throw new Error("Failed fetching webhooks"); + } + + await Promise.all(webhooksIds.map((id) => this.webhooksClient.enableSingleWebhook(id))); + } +} diff --git a/apps/search/src/lib/algolia/algoliaSearchProvider.ts b/apps/search/src/lib/algolia/algoliaSearchProvider.ts index 7a2376c..f7b162e 100644 --- a/apps/search/src/lib/algolia/algoliaSearchProvider.ts +++ b/apps/search/src/lib/algolia/algoliaSearchProvider.ts @@ -138,7 +138,14 @@ export class AlgoliaSearchProvider implements SearchProvider { } async ping() { - return this.#algolia.listIndices().then(() => undefined); + return this.#algolia + .listIndices() + .then(() => undefined) + .catch((r) => { + if (r.status === 403) { + throw new Error("Algolia responded with invalid credentials"); + } + }); } } diff --git a/apps/search/src/lib/graphql.ts b/apps/search/src/lib/graphql.ts index 957491f..cab2c53 100644 --- a/apps/search/src/lib/graphql.ts +++ b/apps/search/src/lib/graphql.ts @@ -1,10 +1,10 @@ import { initUrqlClient } from "next-urql"; import { AuthConfig, authExchange } from "@urql/exchange-auth"; import { - cacheExchange, + cacheExchange, Client, createClient as urqlCreateClient, dedupExchange, - fetchExchange, + fetchExchange } from "urql"; interface IAuthState { @@ -59,3 +59,5 @@ export const createClient = (url: string, getAuth: AuthConfig["getAu url, exchanges: getExchanges(getAuth), }); + +export type SimpleGraphqlClient = Pick; diff --git a/apps/search/src/lib/metadata.ts b/apps/search/src/lib/metadata.ts index a74c328..3225e47 100644 --- a/apps/search/src/lib/metadata.ts +++ b/apps/search/src/lib/metadata.ts @@ -1,12 +1,9 @@ -import { EncryptedMetadataManager, MetadataEntry } from "@saleor/app-sdk/settings-manager"; -import { Client } from "urql"; +import { EncryptedMetadataManager, MetadataEntry, SettingsManager } from "@saleor/app-sdk/settings-manager"; -import { - FetchAppDetailsDocument, - FetchAppDetailsQuery, - UpdateAppMetadataDocument, -} from "../../generated/graphql"; +import { FetchAppDetailsDocument, FetchAppDetailsQuery, UpdateAppMetadataDocument } from "../../generated/graphql"; import { settingsManagerSecretKey } from "../../saleor-app"; +import { SimpleGraphqlClient } from "./graphql"; + /* * Function is using urql graphql client to fetch all available metadata. @@ -14,7 +11,7 @@ import { settingsManagerSecretKey } from "../../saleor-app"; * which can be used by the manager. * Result of this query is cached by the manager. */ -export async function fetchAllMetadata(client: Client): Promise { +export async function fetchAllMetadata(client: SimpleGraphqlClient): Promise { const { error, data } = await client .query(FetchAppDetailsDocument, {}) .toPromise(); @@ -32,7 +29,7 @@ export async function fetchAllMetadata(client: Client): Promise * Before data are send, additional query for required App ID is made. * The manager will use updated entries returned by this mutation to update it's cache. */ -export async function mutateMetadata(client: Client, metadata: MetadataEntry[]) { +export async function mutateMetadata(client: SimpleGraphqlClient, metadata: MetadataEntry[]) { // to update the metadata, ID is required const { error: idQueryError, data: idQueryData } = await client .query(FetchAppDetailsDocument, {}) @@ -72,7 +69,7 @@ export async function mutateMetadata(client: Client, metadata: MetadataEntry[]) ); } -export const createSettingsManager = (client: Client) => { +export const createSettingsManager = (client: SimpleGraphqlClient): SettingsManager => { /* * EncryptedMetadataManager gives you interface to manipulate metadata and cache values in memory. * We recommend it for production, because all values are encrypted. diff --git a/apps/search/src/lib/searchProvider.ts b/apps/search/src/lib/searchProvider.ts index 12713a3..f51a85a 100644 --- a/apps/search/src/lib/searchProvider.ts +++ b/apps/search/src/lib/searchProvider.ts @@ -10,4 +10,5 @@ export interface SearchProvider { createProductVariant(productVariant: ProductVariantWebhookPayloadFragment): Promise; updateProductVariant(productVariant: ProductVariantWebhookPayloadFragment): Promise; deleteProductVariant(productId: ProductVariantWebhookPayloadFragment): Promise; + ping(): Promise; } diff --git a/apps/search/src/lib/useWebhooksStatus.tsx b/apps/search/src/lib/useWebhooksStatus.tsx new file mode 100644 index 0000000..10f780c --- /dev/null +++ b/apps/search/src/lib/useWebhooksStatus.tsx @@ -0,0 +1,20 @@ +import { useQuery } from "react-query"; +import { useAuthenticatedFetch } from "@saleor/app-sdk/app-bridge"; +import { useCallback } from "react"; +import { OwnWebhookFragment } from "../../generated/graphql"; + +export const useWebhooksStatus = () => { + const fetch: typeof window.fetch = useAuthenticatedFetch(); + + const fetchFn = useCallback(() => { + return fetch("/api/webhooks-status").then((resp) => resp.json()); + /** + * fetch from SDK is not wrapped with memo todo + */ + }, []); + + return useQuery({ + queryKey: "webhooks-status", + queryFn: fetchFn, + }); +}; diff --git a/apps/search/src/pages/api/configuration.ts b/apps/search/src/pages/api/configuration.ts index 1ade754..00dbcf3 100644 --- a/apps/search/src/pages/api/configuration.ts +++ b/apps/search/src/pages/api/configuration.ts @@ -8,6 +8,8 @@ import { saleorApp } from "../../../saleor-app"; import { createProtectedHandler, ProtectedHandlerContext } from "@saleor/app-sdk/handlers/next"; import { createLogger } from "../../lib/logger"; import { AppConfigurationFields } from "../../domain/configuration"; +import { AlgoliaSearchProvider } from "../../lib/algolia/algoliaSearchProvider"; +import { WebhookActivityTogglerService } from "../../domain/WebhookActivityToggler.service"; const logger = createLogger({ handler: "api/configuration", @@ -65,12 +67,37 @@ export const handler = async ( const { appId, secretKey, indexNamePrefix } = JSON.parse(req.body) as AppConfigurationFields; - await settings.set([ - { key: "secretKey", value: secretKey || "", domain }, - { key: "appId", value: appId || "", domain }, - { key: "indexNamePrefix", value: indexNamePrefix || "", domain }, - ]); + const algoliaClient = new AlgoliaSearchProvider({ + appId, + apiKey: secretKey, + indexNamePrefix: indexNamePrefix, + }); + + try { + logger.debug("Will ping Algolia"); + await algoliaClient.ping(); + + logger.debug("Algolia connection is ok. Will save settings"); + + await settings.set([ + { key: "secretKey", value: secretKey || "", domain }, + { key: "appId", value: appId || "", domain }, + { key: "indexNamePrefix", value: indexNamePrefix || "", domain }, + ]); + + logger.debug("Settings set"); + + const webhooksToggler = new WebhookActivityTogglerService(ctx.authData.appId, client); + + await webhooksToggler.enableOwnWebhooks(); + + logger.debug("Webhooks enabled"); + } catch (e) { + return res.status(400).end(); + } + await sendResponse(res, 200, settings, domain); + return; } logger.error("Method not supported"); diff --git a/apps/search/src/pages/api/webhooks-status.ts b/apps/search/src/pages/api/webhooks-status.ts new file mode 100644 index 0000000..966d339 --- /dev/null +++ b/apps/search/src/pages/api/webhooks-status.ts @@ -0,0 +1,123 @@ +import { createProtectedHandler, NextProtectedApiHandler } from "@saleor/app-sdk/handlers/next"; +import { saleorApp } from "../../../saleor-app"; +import { createClient, SimpleGraphqlClient } from "../../lib/graphql"; +import { FetchOwnWebhooksDocument } from "../../../generated/graphql"; +import { AlgoliaSearchProvider } from "../../lib/algolia/algoliaSearchProvider"; +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 { Client } from "urql"; +import { SearchProvider } from "../../lib/searchProvider"; + +const logger = createLogger({ + service: "webhooksStatusHandler", +}); + +/** + * Simple dependency injection - factory injects all services, in tests everything can be configured without mocks + */ +type FactoryProps = { + settingsManagerFactory: (client: SimpleGraphqlClient) => SettingsManager; + webhookActivityTogglerFactory: ( + appId: string, + client: SimpleGraphqlClient + ) => IWebhookActivityTogglerService; + algoliaSearchProviderFactory: (appId: string, apiKey: string) => Pick; + graphqlClientFactory: (saleorApiUrl: string, token: string) => SimpleGraphqlClient; +}; + +export const webhooksStatusHandlerFactory = + ({ + settingsManagerFactory, + webhookActivityTogglerFactory, + algoliaSearchProviderFactory, + graphqlClientFactory, + }: FactoryProps): NextProtectedApiHandler => + async (req, res, { authData }) => { + /** + * Initialize services + */ + 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"); + + /** + * If settings are incomplete, disable webhooks + * + * TODO Extract config operations to domain/ + */ + if (!settings.appId || !settings.secretKey) { + logger.debug("Settings not set, will disable webhooks"); + + await webhooksToggler.disableOwnWebhooks(); + } else { + /** + * Otherwise, if settings are set, check in Algolia if tokens are valid + */ + const algoliaService = algoliaSearchProviderFactory(settings.appId, settings.secretKey); + + try { + logger.debug("Settings set, will ping Algolia"); + + await algoliaService.ping(); + } catch (e) { + logger.debug("Algolia ping failed, will disable webhooks"); + /** + * If credentials are invalid, also disable webhooks + */ + await webhooksToggler.disableOwnWebhooks(); + } + } + + try { + logger.debug("Settings and Algolia are correct, will fetch Webhooks from Saleor"); + + const webhooks = await client + .query(FetchOwnWebhooksDocument, { id: authData.appId }) + .toPromise() + .then((r) => r.data?.app?.webhooks); + + if (!webhooks) { + return res.status(500).end(); + } + + return res.status(200).json(webhooks); + } catch (e) { + console.error(e); + return res.status(500).end(); + } + }; + +export default createProtectedHandler( + webhooksStatusHandlerFactory({ + settingsManagerFactory: createSettingsManager, + webhookActivityTogglerFactory: function (appId, client) { + return new WebhookActivityTogglerService(appId, client); + }, + algoliaSearchProviderFactory(appId, apiKey) { + return new AlgoliaSearchProvider({ appId, apiKey }); + }, + graphqlClientFactory(saleorApiUrl: string, token: string) { + return createClient(saleorApiUrl, async () => ({ + token, + })); + }, + }), + saleorApp.apl, + [] +); diff --git a/apps/search/src/pages/api/webhooks/saleor/product_created.ts b/apps/search/src/pages/api/webhooks/saleor/product_created.ts index 077da25..7a8b8a1 100644 --- a/apps/search/src/pages/api/webhooks/saleor/product_created.ts +++ b/apps/search/src/pages/api/webhooks/saleor/product_created.ts @@ -3,7 +3,9 @@ import { ProductCreated, ProductCreatedDocument } from "../../../../../generated import { saleorApp } from "../../../../../saleor-app"; import { AlgoliaSearchProvider } from "../../../../lib/algolia/algoliaSearchProvider"; import { getAlgoliaConfiguration } from "../../../../lib/algolia/getAlgoliaConfiguration"; -import { createDebug } from "../../../../lib/debug"; +import { WebhookActivityTogglerService } from "../../../../domain/WebhookActivityToggler.service"; +import { createClient } from "../../../../lib/graphql"; +import { createLogger } from "../../../../lib/logger"; export const config = { api: { @@ -16,22 +18,29 @@ export const webhookProductCreated = new SaleorAsyncWebhook({ event: "PRODUCT_CREATED", apl: saleorApp.apl, query: ProductCreatedDocument, + /** + * Webhook is disabled by default. Will be enabled by the app when configuration succeeds + */ + isActive: false, +}); + +const logger = createLogger({ + service: "webhookProductCreatedWebhookHandler", }); export const handler: NextWebhookApiHandler = async (req, res, context) => { - const debug = createDebug(`Webhook handler - ${webhookProductCreated.event}`); - const { event, authData } = context; - debug( + 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) { - debug("Aborting due to lack of settings"); - debug(errors); + logger.warn("Aborting due to lack of settings"); + logger.debug(errors); + return res.status(400).json({ message: errors[0].message, }); @@ -46,7 +55,26 @@ export const handler: NextWebhookApiHandler = async (req, res, c const { product } = context.payload; if (product) { - await searchProvider.createProduct(product); + try { + await searchProvider.createProduct(product); + } catch (e) { + logger.info(e, "Algolia createProduct failed. Webhooks will be disabled"); + + const webhooksToggler = new WebhookActivityTogglerService( + authData.appId, + createClient(authData.saleorApiUrl, async () => ({ 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; diff --git a/apps/search/src/pages/api/webhooks/saleor/product_deleted.ts b/apps/search/src/pages/api/webhooks/saleor/product_deleted.ts index 6a51a4a..a965c36 100644 --- a/apps/search/src/pages/api/webhooks/saleor/product_deleted.ts +++ b/apps/search/src/pages/api/webhooks/saleor/product_deleted.ts @@ -4,6 +4,9 @@ import { saleorApp } from "../../../../../saleor-app"; import { AlgoliaSearchProvider } from "../../../../lib/algolia/algoliaSearchProvider"; import { getAlgoliaConfiguration } from "../../../../lib/algolia/getAlgoliaConfiguration"; import { createDebug } from "../../../../lib/debug"; +import { WebhookActivityTogglerService } from "../../../../domain/WebhookActivityToggler.service"; +import { createClient } from "../../../../lib/graphql"; +import { createLogger } from "../../../../lib/logger"; export const config = { api: { @@ -16,22 +19,29 @@ export const webhookProductDeleted = new SaleorAsyncWebhook({ event: "PRODUCT_DELETED", apl: saleorApp.apl, query: ProductDeletedDocument, + /** + * Webhook is disabled by default. Will be enabled by the app when configuration succeeds + */ + isActive: false, +}); + +const logger = createLogger({ + service: "webhookProductDeletedWebhookHandler", }); export const handler: NextWebhookApiHandler = async (req, res, context) => { - const debug = createDebug(`Webhook handler - ${webhookProductDeleted.event}`); - const { event, authData } = context; - debug( + 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) { - debug("Aborting due to lack of settings"); - debug(errors); + logger.warn("Aborting due to lack of settings"); + logger.debug(errors); + return res.status(400).json({ message: errors[0].message, }); @@ -46,7 +56,26 @@ export const handler: NextWebhookApiHandler = async (req, res, c const { product } = context.payload; if (product) { - await searchProvider.deleteProduct(product); + try { + await searchProvider.deleteProduct(product); + } catch (e) { + logger.info(e, "Algolia deleteProduct failed. Webhooks will be disabled"); + + const webhooksToggler = new WebhookActivityTogglerService( + authData.appId, + createClient(authData.saleorApiUrl, async () => ({ 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; diff --git a/apps/search/src/pages/api/webhooks/saleor/product_updated.ts b/apps/search/src/pages/api/webhooks/saleor/product_updated.ts index 828e1a9..b323c2b 100644 --- a/apps/search/src/pages/api/webhooks/saleor/product_updated.ts +++ b/apps/search/src/pages/api/webhooks/saleor/product_updated.ts @@ -4,6 +4,9 @@ import { saleorApp } from "../../../../../saleor-app"; import { AlgoliaSearchProvider } from "../../../../lib/algolia/algoliaSearchProvider"; import { getAlgoliaConfiguration } from "../../../../lib/algolia/getAlgoliaConfiguration"; import { createDebug } from "../../../../lib/debug"; +import { WebhookActivityTogglerService } from "../../../../domain/WebhookActivityToggler.service"; +import { createClient } from "../../../../lib/graphql"; +import { createLogger } from "../../../../lib/logger"; export const config = { api: { @@ -16,22 +19,29 @@ export const webhookProductUpdated = new SaleorAsyncWebhook({ event: "PRODUCT_UPDATED", apl: saleorApp.apl, query: ProductUpdatedDocument, + /** + * Webhook is disabled by default. Will be enabled by the app when configuration succeeds + */ + isActive: false, +}); + +const logger = createLogger({ + service: "webhookProductUpdatedWebhookHandler", }); export const handler: NextWebhookApiHandler = async (req, res, context) => { - const debug = createDebug(`Webhook handler - ${webhookProductUpdated.event}`); - const { event, authData } = context; - debug( + 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) { - debug("Aborting due to lack of settings"); - debug(errors); + logger.warn("Aborting due to lack of settings"); + logger.debug(errors); + return res.status(400).json({ message: errors[0].message, }); @@ -46,8 +56,28 @@ export const handler: NextWebhookApiHandler = async (req, res, c const { product } = context.payload; if (product) { - await searchProvider.updateProduct(product); + try { + await searchProvider.updateProduct(product); + } catch (e) { + logger.info(e, "Algolia updateProduct failed. Webhooks will be disabled"); + + const webhooksToggler = new WebhookActivityTogglerService( + authData.appId, + createClient(authData.saleorApiUrl, async () => ({ 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; }; diff --git a/apps/search/src/pages/api/webhooks/saleor/product_variant_created.ts b/apps/search/src/pages/api/webhooks/saleor/product_variant_created.ts index 46edbfe..a569974 100644 --- a/apps/search/src/pages/api/webhooks/saleor/product_variant_created.ts +++ b/apps/search/src/pages/api/webhooks/saleor/product_variant_created.ts @@ -7,6 +7,9 @@ import { saleorApp } from "../../../../../saleor-app"; import { AlgoliaSearchProvider } from "../../../../lib/algolia/algoliaSearchProvider"; import { getAlgoliaConfiguration } from "../../../../lib/algolia/getAlgoliaConfiguration"; import { createDebug } from "../../../../lib/debug"; +import { createLogger } from "../../../../lib/logger"; +import { WebhookActivityTogglerService } from "../../../../domain/WebhookActivityToggler.service"; +import { createClient } from "../../../../lib/graphql"; export const config = { api: { @@ -19,22 +22,28 @@ export const webhookProductVariantCreated = new SaleorAsyncWebhook = async (req, res, context) => { - const debug = createDebug(`Webhook handler - ${webhookProductVariantCreated.event}`); - const { event, authData } = context; - debug( + 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) { - debug("Aborting due to lack of settings"); - debug(errors); + logger.warn("Aborting due to lack of settings"); + logger.debug(errors); return res.status(400).json({ message: errors[0].message, }); @@ -49,8 +58,28 @@ export const handler: NextWebhookApiHandler = async (req, const { productVariant } = context.payload; if (productVariant) { - await searchProvider.createProductVariant(productVariant); + try { + await searchProvider.createProductVariant(productVariant); + } catch (e) { + logger.info(e, "Algolia createProductVariant failed. Webhooks will be disabled"); + + const webhooksToggler = new WebhookActivityTogglerService( + authData.appId, + createClient(authData.saleorApiUrl, async () => ({ 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; }; diff --git a/apps/search/src/pages/api/webhooks/saleor/product_variant_deleted.ts b/apps/search/src/pages/api/webhooks/saleor/product_variant_deleted.ts index 65b10b2..58fbed6 100644 --- a/apps/search/src/pages/api/webhooks/saleor/product_variant_deleted.ts +++ b/apps/search/src/pages/api/webhooks/saleor/product_variant_deleted.ts @@ -7,6 +7,9 @@ import { saleorApp } from "../../../../../saleor-app"; import { AlgoliaSearchProvider } from "../../../../lib/algolia/algoliaSearchProvider"; import { getAlgoliaConfiguration } from "../../../../lib/algolia/getAlgoliaConfiguration"; import { createDebug } from "../../../../lib/debug"; +import { createLogger } from "../../../../lib/logger"; +import { WebhookActivityTogglerService } from "../../../../domain/WebhookActivityToggler.service"; +import { createClient } from "../../../../lib/graphql"; export const config = { api: { @@ -19,22 +22,29 @@ export const webhookProductVariantDeleted = new SaleorAsyncWebhook = async (req, res, context) => { - const debug = createDebug(`Webhook handler - ${webhookProductVariantDeleted.event}`); - const { event, authData } = context; - debug( + 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) { - debug("Aborting due to lack of settings"); - debug(errors); + logger.warn("Aborting due to lack of settings"); + logger.debug(errors); + return res.status(400).json({ message: errors[0].message, }); @@ -49,8 +59,28 @@ export const handler: NextWebhookApiHandler = async (req, const { productVariant } = context.payload; if (productVariant) { - await searchProvider.deleteProductVariant(productVariant); + try { + await searchProvider.deleteProductVariant(productVariant); + } catch (e) { + logger.info(e, "Algolia deleteProductVariant failed. Webhooks will be disabled"); + + const webhooksToggler = new WebhookActivityTogglerService( + authData.appId, + createClient(authData.saleorApiUrl, async () => ({ 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; }; diff --git a/apps/search/src/pages/api/webhooks/saleor/product_variant_updated.ts b/apps/search/src/pages/api/webhooks/saleor/product_variant_updated.ts index 611b839..14414d2 100644 --- a/apps/search/src/pages/api/webhooks/saleor/product_variant_updated.ts +++ b/apps/search/src/pages/api/webhooks/saleor/product_variant_updated.ts @@ -7,6 +7,9 @@ import { saleorApp } from "../../../../../saleor-app"; import { AlgoliaSearchProvider } from "../../../../lib/algolia/algoliaSearchProvider"; import { getAlgoliaConfiguration } from "../../../../lib/algolia/getAlgoliaConfiguration"; import { createDebug } from "../../../../lib/debug"; +import { createLogger } from "../../../../lib/logger"; +import { WebhookActivityTogglerService } from "../../../../domain/WebhookActivityToggler.service"; +import { createClient } from "../../../../lib/graphql"; export const config = { api: { @@ -19,22 +22,29 @@ export const webhookProductVariantUpdated = new SaleorAsyncWebhook = async (req, res, context) => { - const debug = createDebug(`Webhook handler - ${webhookProductVariantUpdated.event}`); - const { event, authData } = context; - debug( + 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) { - debug("Aborting due to lack of settings"); - debug(errors); + logger.warn("Aborting due to lack of settings"); + logger.debug(errors); + return res.status(400).json({ message: errors[0].message, }); @@ -49,8 +59,28 @@ export const handler: NextWebhookApiHandler = async (req, const { productVariant } = context.payload; if (productVariant) { - await searchProvider.updateProductVariant(productVariant); + try { + await searchProvider.updateProductVariant(productVariant); + } catch (e) { + logger.info(e, "Algolia updateProductVariant failed. Webhooks will be disabled"); + + const webhooksToggler = new WebhookActivityTogglerService( + authData.appId, + createClient(authData.saleorApiUrl, async () => ({ 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; }; diff --git a/apps/search/src/test/handlers/webhooks-status-handler.test.ts b/apps/search/src/test/handlers/webhooks-status-handler.test.ts new file mode 100644 index 0000000..f6968d3 --- /dev/null +++ b/apps/search/src/test/handlers/webhooks-status-handler.test.ts @@ -0,0 +1,125 @@ +import { beforeEach, describe, expect, it, Mock, vi } from "vitest"; +import { createMocks } from "node-mocks-http"; +import { webhooksStatusHandlerFactory } from "../../pages/api/webhooks-status"; +import { Client, OperationResult } from "urql"; +import { IWebhookActivityTogglerService } from "../../domain/WebhookActivityToggler.service"; +import { SearchProvider } from "../../lib/searchProvider"; +import { SettingsManager } from "@saleor/app-sdk/settings-manager"; +import { NextProtectedApiHandler } from "@saleor/app-sdk/handlers/next"; +import { + FetchOwnWebhooksQuery, + WebhookEventTypeAsyncEnum, + WebhookEventTypeEnum, +} from "../../../generated/graphql"; + +/** + * Context provided from ProtectedApiHandler to handler body + */ +const mockWebhookContext = { + authData: { + appId: "app-id", + domain: "domain.saleor.io", + token: "token", + saleorApiUrl: "https://domain.saleor.io/graphql", + }, + baseUrl: "localhost:3000", +}; + +const appWebhooksResponseData: Pick, "data"> = { + data: { + app: { + webhooks: [ + { + id: "w1", + isActive: true, + asyncEvents: [ + { eventType: WebhookEventTypeAsyncEnum.ProductCreated, name: "ProductCreated" }, + ], + eventDeliveries: { + edges: [], + }, + }, + ], + }, + }, +}; + +describe("webhooksStatusHandler", () => { + const client: Pick = { + query: vi.fn(), + mutation: vi.fn(), + }; + + const webhooksTogglerServiceMock: IWebhookActivityTogglerService = { + disableOwnWebhooks: vi.fn(), + enableOwnWebhooks: vi.fn(), + }; + + const algoliaSearchProviderMock: Pick = { + ping: vi.fn(), + }; + + const settingsManagerMock: SettingsManager = { + get: vi.fn(), + set: vi.fn(), + }; + + let handler: NextProtectedApiHandler; + + beforeEach(() => { + vi.resetAllMocks(); + + handler = webhooksStatusHandlerFactory({ + graphqlClientFactory: () => client, + webhookActivityTogglerFactory: () => webhooksTogglerServiceMock, + algoliaSearchProviderFactory: () => algoliaSearchProviderMock, + settingsManagerFactory: () => settingsManagerMock, + }); + + (client.query as Mock).mockImplementationOnce(() => { + return { + async toPromise() { + return appWebhooksResponseData; + }, + }; + }); + }); + + it("Disables webhooks if Algolia settings are not saved in Saleor Metadata", async function () { + const { req, res } = createMocks({}); + + await handler(req, res, mockWebhookContext); + + expect(webhooksTogglerServiceMock.disableOwnWebhooks).toHaveBeenCalled(); + expect(algoliaSearchProviderMock.ping).not.toHaveBeenCalled(); + expect(res._getStatusCode()).toBe(200); + }); + + it("Disables webhooks if Algolia credentials are invalid", async function () { + (settingsManagerMock.get as Mock).mockReturnValue("metadata-value"); + (algoliaSearchProviderMock.ping as Mock).mockImplementationOnce(async () => { + throw new Error(); + }); + + const { req, res } = createMocks({}); + + await handler(req, res, mockWebhookContext); + + expect(webhooksTogglerServiceMock.disableOwnWebhooks).toHaveBeenCalled(); + expect(algoliaSearchProviderMock.ping).toHaveBeenCalled(); + expect(res._getStatusCode()).toBe(200); + }); + + it("Returns webhooks if Algolia credentials are valid", async function () { + (settingsManagerMock.get as Mock).mockReturnValue("metadata-value"); + (algoliaSearchProviderMock.ping as Mock).mockImplementationOnce(async () => Promise.resolve()); + + const { req, res } = createMocks({}); + + await handler(req, res, mockWebhookContext); + + expect(webhooksTogglerServiceMock.disableOwnWebhooks).not.toHaveBeenCalled(); + expect(algoliaSearchProviderMock.ping).toHaveBeenCalled(); + expect(res._getStatusCode()).toBe(200); + }); +}); diff --git a/apps/search/src/views/configuration/configuration.view.tsx b/apps/search/src/views/configuration/configuration.view.tsx index 7ef187f..1703842 100644 --- a/apps/search/src/views/configuration/configuration.view.tsx +++ b/apps/search/src/views/configuration/configuration.view.tsx @@ -3,9 +3,9 @@ import { AppSection } from "../../components/AppSection"; import { AlgoliaConfigurationForm } from "../../components/AlgoliaConfigurationForm"; import { ImportProductsToAlgolia } from "../../components/ImportProductsToAlgolia"; import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge"; - -const SALEOR_EVENTS_DOCS_URL = - "https://docs.saleor.io/docs/3.x/developer/extending/apps/asynchronous-webhooks#available-webhook-events"; +import { WebhooksStatus } from "../../components/WebhooksStatus"; +import { MainInstructions } from "../../components/MainInstructions"; +import { WebhooksStatusInstructions } from "../../components/WebhooksStatusInstructions"; const ALGOLIA_DASHBOARD_TOKENS_URL = "https://www.algolia.com/account/api-keys/all"; @@ -17,59 +17,17 @@ export const ConfigurationView = () => { Configuration - - - To configure the App, fill in your Algolia settings to enable products indexing. - - - Once the App is configured, you will be able to perform initial index of your existing - Saleor database. - - - The app supports following{" "} - { - e.preventDefault(); - - /** - * TODO extract shared handler - */ - appBridge?.dispatch( - actions.Redirect({ - to: SALEOR_EVENTS_DOCS_URL, - newContext: true, - }) - ); - }} - href={SALEOR_EVENTS_DOCS_URL} - > - events - {" "} - that will synchronize Algolia in the background: - -
    -
  • - - PRODUCT_CREATED -
  • -
  • - - PRODUCT_UPDATED -
  • -
  • - - PRODUCT_DELETED -
  • -
  • - - PRODUCT_VARIANT_CREATED -
  • -
  • - - PRODUCT_VARIANT_UPDATED -
  • -
  • - - PRODUCT_VARIANT_DELETED -
  • -
-
+ } + mainContent={} + /> + + } sideContent={ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 34f3eea..05805ab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1333,6 +1333,9 @@ importers: eslint-config-saleor: specifier: workspace:* version: link:../../packages/eslint-config-saleor + node-mocks-http: + specifier: ^1.12.2 + version: 1.12.2 prettier: specifier: ^2.7.1 version: 2.8.3