Search: Add media and stock flag (#874)

* Add media and stock flag

* Add UI for updating the queries

* Add update info to changelog
This commit is contained in:
Krzysztof Wolski 2023-08-16 14:08:07 +02:00 committed by GitHub
parent 3002354c5e
commit 69fe973121
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 616 additions and 62 deletions

View file

@ -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.

View file

@ -1,6 +1,7 @@
fragment OwnWebhook on Webhook {
id
isActive
name
asyncEvents {
name
eventType

View file

@ -24,6 +24,10 @@ fragment ProductData on Product {
thumbnail {
url
}
media{
url
type
}
attributes {
...ProductAttributesData
}

View file

@ -14,7 +14,7 @@ fragment ProductVariantData on ProductVariant {
}
}
}
quantityAvailable
channelListings {
id
channel {

View file

@ -0,0 +1,9 @@
mutation CreateWebhook($input: WebhookCreateInput!) {
webhookCreate(input:$input){
errors{
code
field
message
}
}
}

View file

@ -0,0 +1,8 @@
mutation RemoveWebhook($id: ID!) {
webhookDelete(id: $id){
errors{
field
message
}
}
}

View file

@ -0,0 +1,16 @@
subscription ProductVariantBackInStock {
event {
__typename
... on ProductVariantBackInStock {
__typename
productVariant {
...ProductVariantWebhookPayload
}
}
recipient {
webhooks {
id
}
}
}
}

View file

@ -0,0 +1,16 @@
subscription ProductVariantOutOfStock {
event {
__typename
... on ProductVariantOutOfStock {
__typename
productVariant {
...ProductVariantWebhookPayload
}
}
recipient {
webhooks {
id
}
}
}
}

View file

@ -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;

View file

@ -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.
</Text>
<Text as="p">
The app supports following{" "}
<TextLink href={SALEOR_EVENTS_DOCS_URL} newTab>
events
</TextLink>{" "}
that will synchronize Algolia in the background:
</Text>
<ul>
<li>
<code>- PRODUCT_CREATED</code>
</li>
<li>
<code>- PRODUCT_UPDATED</code>
</li>
<li>
<code>- PRODUCT_DELETED</code>
</li>
<li>
<code>- PRODUCT_VARIANT_CREATED</code>
</li>
<li>
<code>- PRODUCT_VARIANT_UPDATED</code>
</li>
<li>
<code>- PRODUCT_VARIANT_DELETED</code>
</li>
</ul>
</Box>
);
};

View file

@ -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 <Text>Loading...</Text>;
}
const webhooksData = data.webhooks;
return (
<Box>
<Accordion display={"grid"} gap={1.5}>
{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 = () => {
);
})}
</Accordion>
{data.isUpdateNeeded && (
<Box
borderTopStyle="solid"
borderWidth={1}
borderColor="neutralDefault"
display="flex"
gap={4}
flexDirection="row"
justifyContent="flex-end"
alignItems="center"
paddingTop={4}
>
<Text variant="caption">
New app version installed. Update the webhooks and run product import again.
</Text>
<Button onClick={() => updateWebhooksMutation.mutate()}>Update webhooks</Button>
</Box>
)}
</Box>
);
};

View file

@ -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 (
<>
<Text as={"p"} marginBottom={1.5}>

View file

@ -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<string[]>;
disableSingleWebhook(id: string): Promise<void>;
enableSingleWebhook(id: string): Promise<void>;
removeSingleWebhook(id: string): Promise<void>;
createWebhook(input: CreateWebhookMutationVariables["input"]): Promise<void>;
}
interface IRecreateWebhooksArgs {
baseUrl: string;
enableWebhooks: boolean;
}
export interface IWebhookActivityTogglerService {
disableOwnWebhooks(webhooksIdsParam?: string[]): Promise<void>;
enableOwnWebhooks(): Promise<void>;
recreateOwnWebhooks(args: IRecreateWebhooksArgs): Promise<void>;
}
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<void> {
return this.client
.mutation(CreateWebhookDocument, {
input,
})
.toPromise()
.then((r) => {
this.handleOperationFailure(r);
return undefined;
});
}
removeSingleWebhook(id: string): Promise<void> {
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<Client, "query" | "mutation">,
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");
}
}

View file

@ -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),

View file

@ -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);
});
});
});

View file

@ -0,0 +1,13 @@
import { AppConfigurationFields, AppConfigurationSchema } from "../../domain/configuration";
interface isConfiguredArgs {
configuration: Partial<AppConfigurationFields> | undefined;
}
// Checks if the app configuration is set up
export const isConfigured = ({ configuration }: isConfiguredArgs) => {
if (!configuration) {
return false;
}
return AppConfigurationSchema.safeParse(configuration).success;
};

View file

@ -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);
});
});

View file

@ -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;
};

View file

@ -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}`;
};

View file

@ -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<OwnWebhookFragment[]>({
return useQuery<WebhooksStatusResponse>({
queryKey: ["webhooks-status"],
queryFn: fetchFn,
});

View file

@ -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,
});
};

View file

@ -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;

View file

@ -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,
[],
);

View file

@ -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<WebhooksStatusResponse> =>
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,
[]
[],
);

View file

@ -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<ProductVariantBackInStock>({
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<ProductVariantBackInStock> = 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);

View file

@ -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<ProductVariantOutOfStock>({
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<ProductVariantOutOfStock> = 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);

19
apps/search/webhooks.ts Normal file
View file

@ -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,
];