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 { fragment OwnWebhook on Webhook {
id id
isActive isActive
name
asyncEvents { asyncEvents {
name name
eventType eventType

View file

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

View file

@ -14,7 +14,7 @@ fragment ProductVariantData on ProductVariant {
} }
} }
} }
quantityAvailable
channelListings { channelListings {
id id
channel { 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 { AlgoliaSearchProvider } from "../lib/algolia/algoliaSearchProvider";
import { useConfiguration } from "../lib/configuration"; import { useConfiguration } from "../lib/configuration";
import { Products, useQueryAllProducts } from "./useQueryAllProducts"; import { Products, useQueryAllProducts } from "./useQueryAllProducts";
import { useWebhooksStatus } from "../lib/useWebhooksStatus";
const BATCH_SIZE = 100; 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 Once the App is configured, you will be able to perform initial index of your existing
Saleor database. Saleor database.
</Text> </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> </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 { EventDeliveryStatusEnum } from "../../generated/graphql";
import { useWebhooksStatus } from "../lib/useWebhooksStatus"; import { useWebhooksStatus } from "../lib/useWebhooksStatus";
import { SemanticChip } from "@saleor/apps-ui"; import { SemanticChip } from "@saleor/apps-ui";
import { useWebhooksUpdateMutation } from "../lib/useWebhooksUpdate";
export const WebhooksStatus = () => { export const WebhooksStatus = () => {
const { data: webhooksData } = useWebhooksStatus(); const { data } = useWebhooksStatus();
const updateWebhooksMutation = useWebhooksUpdateMutation();
if (!webhooksData) { if (!data) {
return <Text>Loading...</Text>; return <Text>Loading...</Text>;
} }
const webhooksData = data.webhooks;
return ( return (
<Box> <Box>
<Accordion display={"grid"} gap={1.5}> <Accordion display={"grid"} gap={1.5}>
{webhooksData.map((webhook) => { {webhooksData.map((webhook) => {
const failedEventDeliveries = webhook.eventDeliveries?.edges?.filter( const failedEventDeliveries = webhook.eventDeliveries?.edges?.filter(
(e) => e.node.status === EventDeliveryStatusEnum.Failed (e) => e.node.status === EventDeliveryStatusEnum.Failed,
); );
const hasFailedDeliveries = failedEventDeliveries && failedEventDeliveries.length > 0; const hasFailedDeliveries = failedEventDeliveries && failedEventDeliveries.length > 0;
@ -87,6 +91,24 @@ export const WebhooksStatus = () => {
); );
})} })}
</Accordion> </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> </Box>
); );
}; };

View file

@ -4,7 +4,7 @@ import { useWebhooksStatus } from "../lib/useWebhooksStatus";
export const WebhooksStatusInstructions = () => { export const WebhooksStatusInstructions = () => {
const { data: webhooksData } = useWebhooksStatus(); const { data: webhooksData } = useWebhooksStatus();
if (webhooksData && webhooksData.some((w) => !w.isActive)) { if (webhooksData && webhooksData.webhooks.some((w) => !w.isActive)) {
return ( return (
<> <>
<Text as={"p"} marginBottom={1.5}> <Text as={"p"} marginBottom={1.5}>

View file

@ -1,10 +1,15 @@
import { Client, OperationResult } from "urql"; import { Client, OperationResult } from "urql";
import { import {
CreateWebhookDocument,
CreateWebhookMutationVariables,
DisableWebhookDocument, DisableWebhookDocument,
EnableWebhookDocument, EnableWebhookDocument,
FetchOwnWebhooksDocument, FetchOwnWebhooksDocument,
RemoveWebhookDocument,
WebhookEventTypeEnum,
} from "../../generated/graphql"; } from "../../generated/graphql";
import { createLogger } from "../lib/logger"; import { createLogger } from "../lib/logger";
import { appWebhooks } from "../../webhooks";
const logger = createLogger({ const logger = createLogger({
service: "WebhookActivityTogglerService", service: "WebhookActivityTogglerService",
@ -14,11 +19,19 @@ export interface IWebhooksActivityClient {
fetchAppWebhooksIDs(id: string): Promise<string[]>; fetchAppWebhooksIDs(id: string): Promise<string[]>;
disableSingleWebhook(id: string): Promise<void>; disableSingleWebhook(id: string): Promise<void>;
enableSingleWebhook(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 { export interface IWebhookActivityTogglerService {
disableOwnWebhooks(webhooksIdsParam?: string[]): Promise<void>; disableOwnWebhooks(webhooksIdsParam?: string[]): Promise<void>;
enableOwnWebhooks(): Promise<void>; enableOwnWebhooks(): Promise<void>;
recreateOwnWebhooks(args: IRecreateWebhooksArgs): Promise<void>;
} }
export class WebhooksActivityClient implements IWebhooksActivityClient { export class WebhooksActivityClient implements IWebhooksActivityClient {
@ -30,7 +43,7 @@ export class WebhooksActivityClient implements IWebhooksActivityClient {
{ {
error: r.error, error: r.error,
}, },
"Error disabling webhook" "Error disabling webhook",
); );
throw new Error("Error disabling webhook"); throw new Error("Error disabling webhook");
} }
@ -76,6 +89,32 @@ export class WebhooksActivityClient implements IWebhooksActivityClient {
return undefined; 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 { export class WebhookActivityTogglerService implements IWebhookActivityTogglerService {
@ -89,7 +128,7 @@ export class WebhookActivityTogglerService implements IWebhookActivityTogglerSer
private client: Pick<Client, "query" | "mutation">, private client: Pick<Client, "query" | "mutation">,
options?: { options?: {
WebhooksClient: IWebhooksActivityClient; WebhooksClient: IWebhooksActivityClient;
} },
) { ) {
this.webhooksClient = options?.WebhooksClient ?? new WebhooksActivityClient(this.client); 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))); 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( export function channelListingToAlgoliaIndexId(
channelListing: PartialChannelListing, channelListing: PartialChannelListing,
indexNamePrefix: string | undefined indexNamePrefix: string | undefined,
) { ) {
/** /**
* Index name should not start with . (dot) * 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 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 = { const document = {
objectID: productAndVariantToObjectID(variant), objectID: productAndVariantToObjectID(variant),
productId: product.id, productId: product.id,
@ -121,10 +125,12 @@ export function productAndVariantToAlgolia({
productName: product.name, productName: product.name,
variantName: variant.name, variantName: variant.name,
attributes, attributes,
media,
description: safeParseJson(product.description), description: safeParseJson(product.description),
slug: product.slug, slug: product.slug,
thumbnail: product.thumbnail?.url, thumbnail: product.thumbnail?.url,
grossPrice: listing?.price?.amount, grossPrice: listing?.price?.amount,
inStock,
categories: categoryHierarchicalFacets(variant), categories: categoryHierarchicalFacets(variant),
collections: product.collections?.map((collection) => collection.name) || [], collections: product.collections?.map((collection) => collection.name) || [],
metadata: metadataToAlgoliaAttribute(variant.product.metadata), 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 { useQuery } from "@tanstack/react-query";
import { useAuthenticatedFetch } from "@saleor/app-sdk/app-bridge"; import { useAuthenticatedFetch } from "@saleor/app-sdk/app-bridge";
import { useCallback } from "react"; import { useCallback } from "react";
import { OwnWebhookFragment } from "../../generated/graphql"; import { WebhooksStatusResponse } from "../pages/api/webhooks-status";
export const useWebhooksStatus = () => { export const useWebhooksStatus = () => {
const fetch: typeof window.fetch = useAuthenticatedFetch(); const fetch: typeof window.fetch = useAuthenticatedFetch();
@ -13,7 +13,7 @@ export const useWebhooksStatus = () => {
*/ */
}, []); }, []);
return useQuery<OwnWebhookFragment[]>({ return useQuery<WebhooksStatusResponse>({
queryKey: ["webhooks-status"], queryKey: ["webhooks-status"],
queryFn: fetchFn, 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 { AppManifest } from "@saleor/app-sdk/types";
import packageJson from "../../../package.json"; import packageJson from "../../../package.json";
import { webhookProductCreated } from "./webhooks/saleor/product_created"; import { appWebhooks } from "../../../webhooks";
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";
export default createManifestHandler({ export default createManifestHandler({
async manifestFactory({ appBaseUrl }) { async manifestFactory({ appBaseUrl }) {
@ -44,19 +39,7 @@ export default createManifestHandler({
supportUrl: "https://github.com/saleor/apps/discussions", supportUrl: "https://github.com/saleor/apps/discussions",
tokenTargetUrl: `${apiBaseURL}/api/register`, tokenTargetUrl: `${apiBaseURL}/api/register`,
version: packageJson.version, version: packageJson.version,
webhooks: [ webhooks: appWebhooks.map((w) => w.getWebhookManifest(apiBaseURL)),
/**
* 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),
],
}; };
return manifest; 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 { createProtectedHandler, NextProtectedApiHandler } from "@saleor/app-sdk/handlers/next";
import { saleorApp } from "../../../saleor-app"; import { saleorApp } from "../../../saleor-app";
import { FetchOwnWebhooksDocument } from "../../../generated/graphql"; import { FetchOwnWebhooksDocument, OwnWebhookFragment } from "../../../generated/graphql";
import { AlgoliaSearchProvider } from "../../lib/algolia/algoliaSearchProvider"; import { AlgoliaSearchProvider } from "../../lib/algolia/algoliaSearchProvider";
import { createSettingsManager } from "../../lib/metadata"; import { createSettingsManager } from "../../lib/metadata";
import { import {
@ -12,6 +12,7 @@ import { SettingsManager } from "@saleor/app-sdk/settings-manager";
import { SearchProvider } from "../../lib/searchProvider"; import { SearchProvider } from "../../lib/searchProvider";
import { createGraphQLClient } from "@saleor/apps-shared"; import { createGraphQLClient } from "@saleor/apps-shared";
import { Client } from "urql"; import { Client } from "urql";
import { isWebhookUpdateNeeded } from "../../lib/algolia/is-webhook-update-needed";
const logger = createLogger({ const logger = createLogger({
service: "webhooksStatusHandler", service: "webhooksStatusHandler",
@ -27,13 +28,18 @@ type FactoryProps = {
graphqlClientFactory: (saleorApiUrl: string, token: string) => Client; graphqlClientFactory: (saleorApiUrl: string, token: string) => Client;
}; };
export type WebhooksStatusResponse = {
webhooks: OwnWebhookFragment[];
isUpdateNeeded: boolean;
};
export const webhooksStatusHandlerFactory = export const webhooksStatusHandlerFactory =
({ ({
settingsManagerFactory, settingsManagerFactory,
webhookActivityTogglerFactory, webhookActivityTogglerFactory,
algoliaSearchProviderFactory, algoliaSearchProviderFactory,
graphqlClientFactory, graphqlClientFactory,
}: FactoryProps): NextProtectedApiHandler => }: FactoryProps): NextProtectedApiHandler<WebhooksStatusResponse> =>
async (req, res, { authData }) => { async (req, res, { authData }) => {
/** /**
* Initialize services * Initialize services
@ -93,7 +99,14 @@ export const webhooksStatusHandlerFactory =
return res.status(500).end(); 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) { } catch (e) {
console.error(e); console.error(e);
return res.status(500).end(); return res.status(500).end();
@ -114,5 +127,5 @@ export default createProtectedHandler(
}, },
}), }),
saleorApp.apl, 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,
];