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:
parent
3002354c5e
commit
69fe973121
27 changed files with 616 additions and 62 deletions
9
.changeset/eight-oranges-appear.md
Normal file
9
.changeset/eight-oranges-appear.md
Normal 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.
|
|
@ -1,6 +1,7 @@
|
|||
fragment OwnWebhook on Webhook {
|
||||
id
|
||||
isActive
|
||||
name
|
||||
asyncEvents {
|
||||
name
|
||||
eventType
|
||||
|
|
|
@ -24,6 +24,10 @@ fragment ProductData on Product {
|
|||
thumbnail {
|
||||
url
|
||||
}
|
||||
media{
|
||||
url
|
||||
type
|
||||
}
|
||||
attributes {
|
||||
...ProductAttributesData
|
||||
}
|
||||
|
|
|
@ -14,7 +14,7 @@ fragment ProductVariantData on ProductVariant {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
quantityAvailable
|
||||
channelListings {
|
||||
id
|
||||
channel {
|
||||
|
|
9
apps/search/graphql/mutations/CreateWebhook.graphql
Normal file
9
apps/search/graphql/mutations/CreateWebhook.graphql
Normal file
|
@ -0,0 +1,9 @@
|
|||
mutation CreateWebhook($input: WebhookCreateInput!) {
|
||||
webhookCreate(input:$input){
|
||||
errors{
|
||||
code
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
8
apps/search/graphql/mutations/RemoveWebhook.graphql
Normal file
8
apps/search/graphql/mutations/RemoveWebhook.graphql
Normal file
|
@ -0,0 +1,8 @@
|
|||
mutation RemoveWebhook($id: ID!) {
|
||||
webhookDelete(id: $id){
|
||||
errors{
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
subscription ProductVariantBackInStock {
|
||||
event {
|
||||
__typename
|
||||
... on ProductVariantBackInStock {
|
||||
__typename
|
||||
productVariant {
|
||||
...ProductVariantWebhookPayload
|
||||
}
|
||||
}
|
||||
recipient {
|
||||
webhooks {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
subscription ProductVariantOutOfStock {
|
||||
event {
|
||||
__typename
|
||||
... on ProductVariantOutOfStock {
|
||||
__typename
|
||||
productVariant {
|
||||
...ProductVariantWebhookPayload
|
||||
}
|
||||
}
|
||||
recipient {
|
||||
webhooks {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
|
|
46
apps/search/src/lib/algolia/is-configured.test.ts
Normal file
46
apps/search/src/lib/algolia/is-configured.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
13
apps/search/src/lib/algolia/is-configured.ts
Normal file
13
apps/search/src/lib/algolia/is-configured.ts
Normal 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;
|
||||
};
|
29
apps/search/src/lib/algolia/is-webhook-update-needed.test.ts
Normal file
29
apps/search/src/lib/algolia/is-webhook-update-needed.test.ts
Normal 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);
|
||||
});
|
||||
});
|
11
apps/search/src/lib/algolia/is-webhook-update-needed.ts
Normal file
11
apps/search/src/lib/algolia/is-webhook-update-needed.ts
Normal 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;
|
||||
};
|
15
apps/search/src/lib/getBaseUrl.ts
Normal file
15
apps/search/src/lib/getBaseUrl.ts
Normal 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}`;
|
||||
};
|
|
@ -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,
|
||||
});
|
||||
|
|
28
apps/search/src/lib/useWebhooksUpdate.tsx
Normal file
28
apps/search/src/lib/useWebhooksUpdate.tsx
Normal 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,
|
||||
});
|
||||
};
|
|
@ -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;
|
||||
|
|
87
apps/search/src/pages/api/recreate-webhooks.ts
Normal file
87
apps/search/src/pages/api/recreate-webhooks.ts
Normal 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,
|
||||
[],
|
||||
);
|
|
@ -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,
|
||||
[]
|
||||
[],
|
||||
);
|
||||
|
|
|
@ -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);
|
|
@ -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
19
apps/search/webhooks.ts
Normal 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,
|
||||
];
|
Loading…
Reference in a new issue