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:
-
-
- -
-
- 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
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,
+];