diff --git a/.changeset/two-dingos-notice.md b/.changeset/two-dingos-notice.md new file mode 100644 index 0000000..725409f --- /dev/null +++ b/.changeset/two-dingos-notice.md @@ -0,0 +1,7 @@ +--- +"saleor-app-emails-and-messages": minor +--- + +Webhooks are no longer created during the app registration. Instead, the app will subscribe events based on it's configuration, after change has been detected. + +This change does not have negative impact on existing app installations - webhooks will be removed during next change of the provider configuration. diff --git a/apps/emails-and-messages/src/lib/register-notify-webhook.ts b/apps/emails-and-messages/src/lib/register-notify-webhook.ts deleted file mode 100644 index 2f214b1..0000000 --- a/apps/emails-and-messages/src/lib/register-notify-webhook.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Client, gql } from "urql"; -import { WebhookCreateMutationDocument, WebhookEventTypeEnum } from "../../generated/graphql"; -import { notifyWebhook } from "../pages/api/webhooks/notify"; - -const webhookCreateMutation = gql` - mutation webhookCreateMutation($input: WebhookCreateInput!) { - webhookCreate(input: $input) { - webhook { - id - name - isActive - } - errors { - field - message - } - } - } -`; - -interface RegisterNotifyWebhookArgs { - client: Client; - baseUrl: string; -} - -export const registerNotifyWebhook = async ({ client, baseUrl }: RegisterNotifyWebhookArgs) => { - const manifest = notifyWebhook.getWebhookManifest(baseUrl); - - return await client - .mutation(WebhookCreateMutationDocument, { - input: { - name: manifest.name, - targetUrl: manifest.targetUrl, - events: [WebhookEventTypeEnum.NotifyUser], - isActive: true, - }, - }) - .toPromise(); -}; diff --git a/apps/emails-and-messages/src/modules/sendgrid/configuration/sendgrid-configuration.router.ts b/apps/emails-and-messages/src/modules/sendgrid/configuration/sendgrid-configuration.router.ts index 5c4231a..1b5dc7d 100644 --- a/apps/emails-and-messages/src/modules/sendgrid/configuration/sendgrid-configuration.router.ts +++ b/apps/emails-and-messages/src/modules/sendgrid/configuration/sendgrid-configuration.router.ts @@ -10,18 +10,13 @@ import { sendgridUpdateEventSchema, sendgridUpdateSenderSchema, } from "./sendgrid-config-input-schema"; -import { - SendgridConfigurationService, - SendgridConfigurationServiceError, -} from "./sendgrid-configuration.service"; +import { SendgridConfigurationServiceError } from "./sendgrid-configuration.service"; import { router } from "../../trpc/trpc-server"; -import { protectedClientProcedure } from "../../trpc/protected-client-procedure"; import { TRPCError } from "@trpc/server"; import { fetchSenders } from "../sendgrid-api"; import { updateChannelsInputSchema } from "../../channels/channel-configuration-schema"; -import { SendgridPrivateMetadataManager } from "./sendgrid-metadata-manager"; -import { createSettingsManager } from "../../../lib/metadata-manager"; import { sendgridDefaultEmptyConfigurations } from "./sendgrid-default-empty-configurations"; +import { protectedWithConfigurationServices } from "../../trpc/protected-client-procedure-with-services"; export const throwTrpcErrorFromConfigurationServiceError = ( error: SendgridConfigurationServiceError | unknown @@ -51,32 +46,14 @@ export const throwTrpcErrorFromConfigurationServiceError = ( }); }; -/* - * Allow access only for the dashboard users and attaches the - * configuration service to the context - */ -const protectedWithConfigurationService = protectedClientProcedure.use(({ next, ctx }) => - next({ - ctx: { - ...ctx, - sendgridConfigurationService: new SendgridConfigurationService({ - metadataManager: new SendgridPrivateMetadataManager( - createSettingsManager(ctx.apiClient, ctx.appId!), - ctx.saleorApiUrl - ), - }), - }, - }) -); - export const sendgridConfigurationRouter = router({ - fetch: protectedWithConfigurationService.query(async ({ ctx }) => { + fetch: protectedWithConfigurationServices.query(async ({ ctx }) => { const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl }); logger.debug("sendgridConfigurationRouter.fetch called"); return ctx.sendgridConfigurationService.getConfigurationRoot(); }), - getConfiguration: protectedWithConfigurationService + getConfiguration: protectedWithConfigurationServices .meta({ requiredClientPermissions: ["MANAGE_APPS"] }) .input(sendgridConfigurationIdInputSchema) .query(async ({ ctx, input }) => { @@ -89,7 +66,7 @@ export const sendgridConfigurationRouter = router({ throwTrpcErrorFromConfigurationServiceError(e); } }), - getConfigurations: protectedWithConfigurationService + getConfigurations: protectedWithConfigurationServices .meta({ requiredClientPermissions: ["MANAGE_APPS"] }) .input(sendgridGetConfigurationsInputSchema) .query(async ({ ctx, input }) => { @@ -102,8 +79,8 @@ export const sendgridConfigurationRouter = router({ throwTrpcErrorFromConfigurationServiceError(e); } }), - createConfiguration: protectedWithConfigurationService - .meta({ requiredClientPermissions: ["MANAGE_APPS"] }) + createConfiguration: protectedWithConfigurationServices + .meta({ requiredClientPermissions: ["MANAGE_APPS"], updateWebhooks: true }) .input(sendgridCreateConfigurationInputSchema) .mutation(async ({ ctx, input }) => { const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl }); @@ -116,8 +93,8 @@ export const sendgridConfigurationRouter = router({ return await ctx.sendgridConfigurationService.createConfiguration(newConfiguration); }), - deleteConfiguration: protectedWithConfigurationService - .meta({ requiredClientPermissions: ["MANAGE_APPS"] }) + deleteConfiguration: protectedWithConfigurationServices + .meta({ requiredClientPermissions: ["MANAGE_APPS"], updateWebhooks: true }) .input(sendgridConfigurationIdInputSchema) .mutation(async ({ ctx, input }) => { const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl }); @@ -130,7 +107,7 @@ export const sendgridConfigurationRouter = router({ throwTrpcErrorFromConfigurationServiceError(e); } }), - getEventConfiguration: protectedWithConfigurationService + getEventConfiguration: protectedWithConfigurationServices .meta({ requiredClientPermissions: ["MANAGE_APPS"] }) .input(sendgridGetEventConfigurationInputSchema) .query(async ({ ctx, input }) => { @@ -147,8 +124,8 @@ export const sendgridConfigurationRouter = router({ throwTrpcErrorFromConfigurationServiceError(e); } }), - updateBasicInformation: protectedWithConfigurationService - .meta({ requiredClientPermissions: ["MANAGE_APPS"] }) + updateBasicInformation: protectedWithConfigurationServices + .meta({ requiredClientPermissions: ["MANAGE_APPS"], updateWebhooks: true }) .input(sendgridUpdateBasicInformationSchema) .mutation(async ({ ctx, input }) => { const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl }); @@ -161,7 +138,7 @@ export const sendgridConfigurationRouter = router({ throwTrpcErrorFromConfigurationServiceError(e); } }), - updateApiConnection: protectedWithConfigurationService + updateApiConnection: protectedWithConfigurationServices .meta({ requiredClientPermissions: ["MANAGE_APPS"] }) .input(sendgridUpdateApiConnectionSchema) .mutation(async ({ ctx, input }) => { @@ -176,7 +153,7 @@ export const sendgridConfigurationRouter = router({ } }), - updateSender: protectedWithConfigurationService + updateSender: protectedWithConfigurationServices .meta({ requiredClientPermissions: ["MANAGE_APPS"] }) .input(sendgridUpdateSenderSchema) .mutation(async ({ ctx, input }) => { @@ -213,7 +190,7 @@ export const sendgridConfigurationRouter = router({ throwTrpcErrorFromConfigurationServiceError(e); } }), - updateChannels: protectedWithConfigurationService + updateChannels: protectedWithConfigurationServices .meta({ requiredClientPermissions: ["MANAGE_APPS"] }) .input(updateChannelsInputSchema) .mutation(async ({ ctx, input }) => { @@ -235,8 +212,8 @@ export const sendgridConfigurationRouter = router({ } }), - updateEvent: protectedWithConfigurationService - .meta({ requiredClientPermissions: ["MANAGE_APPS"] }) + updateEvent: protectedWithConfigurationServices + .meta({ requiredClientPermissions: ["MANAGE_APPS"], updateWebhooks: true }) .input(sendgridUpdateEventSchema) .mutation(async ({ ctx, input }) => { const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl }); @@ -256,8 +233,8 @@ export const sendgridConfigurationRouter = router({ } }), - updateEventArray: protectedWithConfigurationService - .meta({ requiredClientPermissions: ["MANAGE_APPS"] }) + updateEventArray: protectedWithConfigurationServices + .meta({ requiredClientPermissions: ["MANAGE_APPS"], updateWebhooks: true }) .input(sendgridUpdateEventArraySchema) .mutation(async ({ ctx, input }) => { const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl }); diff --git a/apps/emails-and-messages/src/modules/smtp/configuration/smtp-configuration.router.ts b/apps/emails-and-messages/src/modules/smtp/configuration/smtp-configuration.router.ts index e52ce22..0265595 100644 --- a/apps/emails-and-messages/src/modules/smtp/configuration/smtp-configuration.router.ts +++ b/apps/emails-and-messages/src/modules/smtp/configuration/smtp-configuration.router.ts @@ -1,10 +1,6 @@ import { createLogger } from "@saleor/apps-shared"; -import { - SmtpConfigurationService, - SmtpConfigurationServiceError, -} from "./smtp-configuration.service"; +import { SmtpConfigurationServiceError } from "./smtp-configuration.service"; import { router } from "../../trpc/trpc-server"; -import { protectedClientProcedure } from "../../trpc/protected-client-procedure"; import { z } from "zod"; import { compileMjml } from "../compile-mjml"; import Handlebars from "handlebars"; @@ -22,8 +18,7 @@ import { smtpUpdateSmtpSchema, } from "./smtp-config-input-schema"; import { updateChannelsInputSchema } from "../../channels/channel-configuration-schema"; -import { SmtpPrivateMetadataManager } from "./smtp-metadata-manager"; -import { createSettingsManager } from "../../../lib/metadata-manager"; +import { protectedWithConfigurationServices } from "../../trpc/protected-client-procedure-with-services"; import { smtpDefaultEmptyConfigurations } from "./smtp-default-empty-configurations"; export const throwTrpcErrorFromConfigurationServiceError = ( @@ -54,32 +49,14 @@ export const throwTrpcErrorFromConfigurationServiceError = ( }); }; -/* - * Allow access only for the dashboard users and attaches the - * configuration service to the context - */ -const protectedWithConfigurationService = protectedClientProcedure.use(({ next, ctx }) => - next({ - ctx: { - ...ctx, - smtpConfigurationService: new SmtpConfigurationService({ - metadataManager: new SmtpPrivateMetadataManager( - createSettingsManager(ctx.apiClient, ctx.appId!), - ctx.saleorApiUrl - ), - }), - }, - }) -); - export const smtpConfigurationRouter = router({ - fetch: protectedWithConfigurationService.query(async ({ ctx }) => { + fetch: protectedWithConfigurationServices.query(async ({ ctx }) => { const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl }); logger.debug("smtpConfigurationRouter.fetch called"); return ctx.smtpConfigurationService.getConfigurationRoot(); }), - getConfiguration: protectedWithConfigurationService + getConfiguration: protectedWithConfigurationServices .meta({ requiredClientPermissions: ["MANAGE_APPS"] }) .input(smtpConfigurationIdInputSchema) .query(async ({ ctx, input }) => { @@ -93,7 +70,7 @@ export const smtpConfigurationRouter = router({ throwTrpcErrorFromConfigurationServiceError(e); } }), - getConfigurations: protectedWithConfigurationService + getConfigurations: protectedWithConfigurationServices .meta({ requiredClientPermissions: ["MANAGE_APPS"] }) .input(smtpGetConfigurationsInputSchema) .query(async ({ ctx, input }) => { @@ -106,8 +83,8 @@ export const smtpConfigurationRouter = router({ throwTrpcErrorFromConfigurationServiceError(e); } }), - createConfiguration: protectedWithConfigurationService - .meta({ requiredClientPermissions: ["MANAGE_APPS"] }) + createConfiguration: protectedWithConfigurationServices + .meta({ requiredClientPermissions: ["MANAGE_APPS"], updateWebhooks: true }) .input(smtpCreateConfigurationInputSchema) .mutation(async ({ ctx, input }) => { const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl }); @@ -120,8 +97,8 @@ export const smtpConfigurationRouter = router({ return await ctx.smtpConfigurationService.createConfiguration(newConfiguration); }), - deleteConfiguration: protectedWithConfigurationService - .meta({ requiredClientPermissions: ["MANAGE_APPS"] }) + deleteConfiguration: protectedWithConfigurationServices + .meta({ requiredClientPermissions: ["MANAGE_APPS"], updateWebhooks: true }) .input(smtpConfigurationIdInputSchema) .mutation(async ({ ctx, input }) => { const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl }); @@ -134,7 +111,7 @@ export const smtpConfigurationRouter = router({ throwTrpcErrorFromConfigurationServiceError(e); } }), - getEventConfiguration: protectedWithConfigurationService + getEventConfiguration: protectedWithConfigurationServices .meta({ requiredClientPermissions: ["MANAGE_APPS"] }) .input(smtpGetEventConfigurationInputSchema) .query(async ({ ctx, input }) => { @@ -152,7 +129,7 @@ export const smtpConfigurationRouter = router({ } }), - renderTemplate: protectedWithConfigurationService + renderTemplate: protectedWithConfigurationServices .meta({ requiredClientPermissions: ["MANAGE_APPS"] }) .input( z.object({ @@ -196,8 +173,8 @@ export const smtpConfigurationRouter = router({ }; }), - updateBasicInformation: protectedWithConfigurationService - .meta({ requiredClientPermissions: ["MANAGE_APPS"] }) + updateBasicInformation: protectedWithConfigurationServices + .meta({ requiredClientPermissions: ["MANAGE_APPS"], updateWebhooks: true }) .input(smtpUpdateBasicInformationSchema) .mutation(async ({ ctx, input }) => { const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl }); @@ -211,8 +188,8 @@ export const smtpConfigurationRouter = router({ } }), - updateSmtp: protectedWithConfigurationService - .meta({ requiredClientPermissions: ["MANAGE_APPS"] }) + updateSmtp: protectedWithConfigurationServices + .meta({ requiredClientPermissions: ["MANAGE_APPS"], updateWebhooks: true }) .input(smtpUpdateSmtpSchema) .mutation(async ({ ctx, input }) => { const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl }); @@ -226,7 +203,7 @@ export const smtpConfigurationRouter = router({ } }), - updateSender: protectedWithConfigurationService + updateSender: protectedWithConfigurationServices .meta({ requiredClientPermissions: ["MANAGE_APPS"] }) .input(smtpUpdateSenderSchema) .mutation(async ({ ctx, input }) => { @@ -241,7 +218,7 @@ export const smtpConfigurationRouter = router({ } }), - updateChannels: protectedWithConfigurationService + updateChannels: protectedWithConfigurationServices .meta({ requiredClientPermissions: ["MANAGE_APPS"] }) .input(updateChannelsInputSchema) .mutation(async ({ ctx, input }) => { @@ -263,8 +240,8 @@ export const smtpConfigurationRouter = router({ } }), - updateEvent: protectedWithConfigurationService - .meta({ requiredClientPermissions: ["MANAGE_APPS"] }) + updateEvent: protectedWithConfigurationServices + .meta({ requiredClientPermissions: ["MANAGE_APPS"], updateWebhooks: true }) .input(smtpUpdateEventSchema) .mutation(async ({ ctx, input }) => { const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl }); @@ -283,8 +260,8 @@ export const smtpConfigurationRouter = router({ throwTrpcErrorFromConfigurationServiceError(e); } }), - updateEventActiveStatus: protectedWithConfigurationService - .meta({ requiredClientPermissions: ["MANAGE_APPS"] }) + updateEventActiveStatus: protectedWithConfigurationServices + .meta({ requiredClientPermissions: ["MANAGE_APPS"], updateWebhooks: true }) .input(smtpUpdateEventActiveStatusInputSchema) .mutation(async ({ ctx, input }) => { const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl }); @@ -302,8 +279,8 @@ export const smtpConfigurationRouter = router({ throwTrpcErrorFromConfigurationServiceError(e); } }), - updateEventArray: protectedWithConfigurationService - .meta({ requiredClientPermissions: ["MANAGE_APPS"] }) + updateEventArray: protectedWithConfigurationServices + .meta({ requiredClientPermissions: ["MANAGE_APPS"], updateWebhooks: true }) .input(smtpUpdateEventArraySchema) .mutation(async ({ ctx, input }) => { const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl }); diff --git a/apps/emails-and-messages/src/modules/trpc/protected-client-procedure-with-services.ts b/apps/emails-and-messages/src/modules/trpc/protected-client-procedure-with-services.ts new file mode 100644 index 0000000..f0bf857 --- /dev/null +++ b/apps/emails-and-messages/src/modules/trpc/protected-client-procedure-with-services.ts @@ -0,0 +1,57 @@ +import { createLogger } from "@saleor/apps-shared"; +import { createSettingsManager } from "../../lib/metadata-manager"; +import { SendgridConfigurationService } from "../sendgrid/configuration/sendgrid-configuration.service"; +import { SendgridPrivateMetadataManager } from "../sendgrid/configuration/sendgrid-metadata-manager"; +import { SmtpConfigurationService } from "../smtp/configuration/smtp-configuration.service"; +import { SmtpPrivateMetadataManager } from "../smtp/configuration/smtp-metadata-manager"; +import { syncWebhookStatus } from "../webhook-management/sync-webhook-status"; +import { protectedClientProcedure } from "./protected-client-procedure"; +import { WebhookManagementService } from "../webhook-management/webhook-management-service"; + +const logger = createLogger({ name: "protectedWithConfigurationServices middleware" }); + +/* + * Allow access only for the dashboard users and attaches the + * configuration service to the context. + * The services do not fetch data from the API unless they are used. + * If meta key updateWebhooks is set to true, additional calls to the API will be made + * to create or remove webhooks. + */ +export const protectedWithConfigurationServices = protectedClientProcedure.use( + async ({ next, ctx, meta }) => { + const smtpConfigurationService = new SmtpConfigurationService({ + metadataManager: new SmtpPrivateMetadataManager( + createSettingsManager(ctx.apiClient, ctx.appId!), + ctx.saleorApiUrl + ), + }); + + const sendgridConfigurationService = new SendgridConfigurationService({ + metadataManager: new SendgridPrivateMetadataManager( + createSettingsManager(ctx.apiClient, ctx.appId!), + ctx.saleorApiUrl + ), + }); + + const result = await next({ + ctx: { + smtpConfigurationService, + sendgridConfigurationService, + }, + }); + + if (meta?.updateWebhooks) { + logger.debug("Updating webhooks"); + + const webhookManagementService = new WebhookManagementService(ctx.baseUrl, ctx.apiClient); + + await syncWebhookStatus({ + sendgridConfigurationService, + smtpConfigurationService, + webhookManagementService, + }); + } + + return result; + } +); diff --git a/apps/emails-and-messages/src/modules/trpc/trpc-context.ts b/apps/emails-and-messages/src/modules/trpc/trpc-context.ts index 598ce20..d6920b6 100644 --- a/apps/emails-and-messages/src/modules/trpc/trpc-context.ts +++ b/apps/emails-and-messages/src/modules/trpc/trpc-context.ts @@ -1,13 +1,17 @@ import * as trpcNext from "@trpc/server/adapters/next"; import { SALEOR_AUTHORIZATION_BEARER_HEADER, SALEOR_API_URL_HEADER } from "@saleor/app-sdk/const"; import { inferAsyncReturnType } from "@trpc/server"; +import { getBaseUrl } from "../../lib/get-base-url"; export const createTrpcContext = async ({ res, req }: trpcNext.CreateNextContextOptions) => { + const baseUrl = getBaseUrl(req.headers); + return { token: req.headers[SALEOR_AUTHORIZATION_BEARER_HEADER] as string | undefined, saleorApiUrl: req.headers[SALEOR_API_URL_HEADER] as string | undefined, appId: undefined as undefined | string, ssr: undefined as undefined | boolean, + baseUrl, }; }; diff --git a/apps/emails-and-messages/src/modules/trpc/trpc-server.ts b/apps/emails-and-messages/src/modules/trpc/trpc-server.ts index 38a28a5..fc5be26 100644 --- a/apps/emails-and-messages/src/modules/trpc/trpc-server.ts +++ b/apps/emails-and-messages/src/modules/trpc/trpc-server.ts @@ -5,6 +5,7 @@ import { ZodError } from "zod"; interface Meta { requiredClientPermissions?: Permission[]; + updateWebhooks?: boolean; } const t = initTRPC diff --git a/apps/emails-and-messages/src/modules/webhook-management/api-operations.ts b/apps/emails-and-messages/src/modules/webhook-management/api-operations.ts new file mode 100644 index 0000000..8fdfa96 --- /dev/null +++ b/apps/emails-and-messages/src/modules/webhook-management/api-operations.ts @@ -0,0 +1,105 @@ +import { Client, gql } from "urql"; +import { + AppWebhooksDocument, + CreateAppWebhookDocument, + CreateAppWebhookMutationVariables, + DeleteAppWebhookDocument, +} from "../../../generated/graphql"; + +gql` + fragment WebhookDetails on Webhook { + id + name + asyncEvents { + name + eventType + } + isActive + } + + query AppWebhooks { + app { + webhooks { + ...WebhookDetails + } + } + } + + mutation CreateAppWebhook($input: WebhookCreateInput!) { + webhookCreate(input: $input) { + errors { + field + message + } + webhook { + ...WebhookDetails + } + } + } + + mutation DeleteAppWebhook($id: ID!) { + webhookDelete(id: $id) { + errors { + field + message + } + webhook { + ...WebhookDetails + } + } + } +`; + +export const fetchAppWebhooks = ({ client }: { client: Client }) => + client + .query(AppWebhooksDocument, {}) + .toPromise() + .then((response) => { + if (response.error) { + throw new Error(response.error.message); + } + + const appData = response.data?.app; + + if (!appData) { + throw new Error( + "App data not found in the response. The token can be invalid or the app has been uninstalled." + ); + } + return appData.webhooks || []; + }); + +export const createAppWebhook = ({ + client, + variables, +}: { + client: Client; + variables: CreateAppWebhookMutationVariables["input"]; +}) => + client + .mutation(CreateAppWebhookDocument, { input: variables }) + .toPromise() + .then((response) => { + if (response.error) { + throw new Error(response.error.message); + } + + console.log("create wh", response.data?.webhookCreate?.errors); + const webhookCreateData = response.data?.webhookCreate?.webhook; + + if (!webhookCreateData) { + throw new Error("Webhook Creation did not return any data nor error."); + } + return webhookCreateData; + }); + +export const deleteAppWebhook = ({ client, id }: { client: Client; id: string }) => + client + .mutation(DeleteAppWebhookDocument, { id }) + .toPromise() + .then((response) => { + if (response.error) { + throw new Error(response.error.message); + } + return; + }); diff --git a/apps/emails-and-messages/src/modules/webhook-management/get-webhook-statuses-from-configurations.test.ts b/apps/emails-and-messages/src/modules/webhook-management/get-webhook-statuses-from-configurations.test.ts new file mode 100644 index 0000000..079597b --- /dev/null +++ b/apps/emails-and-messages/src/modules/webhook-management/get-webhook-statuses-from-configurations.test.ts @@ -0,0 +1,287 @@ +import { expect, describe, it } from "vitest"; +import { SmtpConfiguration } from "../smtp/configuration/smtp-config-schema"; +import { getWebhookStatusesFromConfigurations } from "./get-webhook-statuses-from-configurations"; +import { SendgridConfiguration } from "../sendgrid/configuration/sendgrid-config-schema"; + +export const nonActiveSmtpConfiguration: SmtpConfiguration = { + id: "1685343953413npk9p", + active: false, + name: "Best name", + smtpHost: "smtpHost", + smtpPort: "1337", + encryption: "NONE", + channels: { + override: false, + channels: [], + mode: "restrict", + }, + events: [ + { + active: false, + eventType: "ORDER_CREATED", + template: "template", + subject: "Order {{ order.number }} has been created!!", + }, + { + active: false, + eventType: "ORDER_FULFILLED", + template: "template", + subject: "Order {{ order.number }} has been fulfilled", + }, + { + active: false, + eventType: "ORDER_CONFIRMED", + template: "template", + subject: "Order {{ order.number }} has been confirmed", + }, + { + active: false, + eventType: "ORDER_CANCELLED", + template: "template", + subject: "Order {{ order.number }} has been cancelled", + }, + { + active: false, + eventType: "ORDER_FULLY_PAID", + template: "template", + subject: "Order {{ order.number }} has been fully paid", + }, + { + active: false, + eventType: "INVOICE_SENT", + template: "template", + subject: "New invoice has been created", + }, + { + active: false, + eventType: "ACCOUNT_CONFIRMATION", + template: "template", + subject: "Account activation", + }, + { + active: false, + eventType: "ACCOUNT_PASSWORD_RESET", + template: "template", + subject: "Password reset request", + }, + { + active: false, + eventType: "ACCOUNT_CHANGE_EMAIL_REQUEST", + template: "template", + subject: "Email change request", + }, + { + active: false, + eventType: "ACCOUNT_CHANGE_EMAIL_CONFIRM", + template: "template", + subject: "Email change confirmation", + }, + { + active: false, + eventType: "ACCOUNT_DELETE", + template: "template", + subject: "Account deletion", + }, + ], + smtpUser: "John", + smtpPassword: "securepassword", + senderEmail: "no-reply@example.com", + senderName: "Sender Name", +}; + +const nonActiveSendgridConfiguration: SendgridConfiguration = { + id: "1685343953413npk9p", + active: false, + name: "Best name", + sandboxMode: false, + apiKey: "SG.123", + channels: { + override: false, + channels: [], + mode: "restrict", + }, + events: [ + { + active: false, + eventType: "ORDER_CREATED", + template: "1", + }, + { + active: false, + eventType: "ORDER_FULFILLED", + template: undefined, + }, + { + active: false, + eventType: "ORDER_CONFIRMED", + template: undefined, + }, + { + active: false, + eventType: "ORDER_CANCELLED", + template: undefined, + }, + { + active: false, + eventType: "ORDER_FULLY_PAID", + template: undefined, + }, + { + active: false, + eventType: "INVOICE_SENT", + template: undefined, + }, + { + active: false, + eventType: "ACCOUNT_CONFIRMATION", + template: undefined, + }, + { + active: false, + eventType: "ACCOUNT_PASSWORD_RESET", + template: undefined, + }, + { + active: false, + eventType: "ACCOUNT_CHANGE_EMAIL_REQUEST", + template: undefined, + }, + { + active: false, + eventType: "ACCOUNT_CHANGE_EMAIL_CONFIRM", + template: undefined, + }, + { + active: false, + eventType: "ACCOUNT_DELETE", + template: undefined, + }, + ], + sender: "1", + senderEmail: "no-reply@example.com", + senderName: "Sender Name", +}; + +describe("getWebhookStatusesFromConfigurations", function () { + it("Statuses should be set to false, when no configurations passed", async () => { + expect( + getWebhookStatusesFromConfigurations({ + smtpConfigurations: [], + sendgridConfigurations: [], + }) + ).toStrictEqual({ + invoiceSentWebhook: false, + notifyWebhook: false, + orderCancelledWebhook: false, + orderConfirmedWebhook: false, + orderFulfilledWebhook: false, + orderCreatedWebhook: false, + orderFullyPaidWebhook: false, + }); + }); + + it("Statuses should be set to false, when no active configurations passed", async () => { + expect( + getWebhookStatusesFromConfigurations({ + smtpConfigurations: [nonActiveSmtpConfiguration], + sendgridConfigurations: [nonActiveSendgridConfiguration], + }) + ).toStrictEqual({ + invoiceSentWebhook: false, + notifyWebhook: false, + orderCancelledWebhook: false, + orderConfirmedWebhook: false, + orderFulfilledWebhook: false, + orderCreatedWebhook: false, + orderFullyPaidWebhook: false, + }); + }); + + it("Statuses should be set to false, when configuration is not active even if events were activated", async () => { + const smtpConfiguration = { + ...nonActiveSmtpConfiguration, + events: nonActiveSmtpConfiguration.events.map((event) => ({ ...event, active: true })), + }; + + expect( + getWebhookStatusesFromConfigurations({ + smtpConfigurations: [smtpConfiguration], + sendgridConfigurations: [nonActiveSendgridConfiguration], + }) + ).toStrictEqual({ + invoiceSentWebhook: false, + notifyWebhook: false, + orderCancelledWebhook: false, + orderConfirmedWebhook: false, + orderFulfilledWebhook: false, + orderCreatedWebhook: false, + orderFullyPaidWebhook: false, + }); + }); + + it("Status of the event should be set to true, when at least one active configuration has activated it", async () => { + const smtpConfiguration: SmtpConfiguration = { + ...nonActiveSmtpConfiguration, + active: true, + events: [ + { + active: true, + eventType: "INVOICE_SENT", + subject: "", + template: "", + }, + ], + }; + + expect( + getWebhookStatusesFromConfigurations({ + smtpConfigurations: [nonActiveSmtpConfiguration, smtpConfiguration], + sendgridConfigurations: [nonActiveSendgridConfiguration], + }) + ).toStrictEqual({ + invoiceSentWebhook: true, + notifyWebhook: false, + orderCancelledWebhook: false, + orderConfirmedWebhook: false, + orderFulfilledWebhook: false, + orderCreatedWebhook: false, + orderFullyPaidWebhook: false, + }); + }); + + it("Status of the NOTIFY webhooks should be set to true, when at least one active configuration has activated one of its related events", async () => { + const smtpConfiguration: SmtpConfiguration = { + ...nonActiveSmtpConfiguration, + active: true, + events: [ + { + active: false, + eventType: "ACCOUNT_CHANGE_EMAIL_CONFIRM", + subject: "", + template: "", + }, + { + active: true, + eventType: "ACCOUNT_CHANGE_EMAIL_REQUEST", + subject: "", + template: "", + }, + ], + }; + + expect( + getWebhookStatusesFromConfigurations({ + smtpConfigurations: [nonActiveSmtpConfiguration, smtpConfiguration], + sendgridConfigurations: [nonActiveSendgridConfiguration], + }) + ).toStrictEqual({ + invoiceSentWebhook: false, + notifyWebhook: true, + orderCancelledWebhook: false, + orderConfirmedWebhook: false, + orderFulfilledWebhook: false, + orderCreatedWebhook: false, + orderFullyPaidWebhook: false, + }); + }); +}); diff --git a/apps/emails-and-messages/src/modules/webhook-management/get-webhook-statuses-from-configurations.ts b/apps/emails-and-messages/src/modules/webhook-management/get-webhook-statuses-from-configurations.ts new file mode 100644 index 0000000..55366a9 --- /dev/null +++ b/apps/emails-and-messages/src/modules/webhook-management/get-webhook-statuses-from-configurations.ts @@ -0,0 +1,54 @@ +import { SendgridConfiguration } from "../sendgrid/configuration/sendgrid-config-schema"; +import { SmtpConfiguration } from "../smtp/configuration/smtp-config-schema"; +import { AppWebhook, eventToWebhookMapping } from "./webhook-management-service"; + +/* + * Returns dictionary of webhook statuses based on passed configurations. + * Webhook is marked as active (true) if at least one event related event is marked as active. + */ +export const getWebhookStatusesFromConfigurations = ({ + smtpConfigurations, + sendgridConfigurations, +}: { + smtpConfigurations: SmtpConfiguration[]; + sendgridConfigurations: SendgridConfiguration[]; +}) => { + const statuses: Record = { + invoiceSentWebhook: false, + notifyWebhook: false, + orderCancelledWebhook: false, + orderConfirmedWebhook: false, + orderFulfilledWebhook: false, + orderCreatedWebhook: false, + orderFullyPaidWebhook: false, + }; + + smtpConfigurations.forEach(async (config) => { + if (!config.active) { + // Configuration has to be active to enable webhook + return; + } + config.events.forEach(async (event) => { + if (event.active) { + /* + * Mapping is mandatory since multiple events can be mapped to one webhook, + * as in case of NOTIFY + */ + statuses[eventToWebhookMapping[event.eventType]] = true; + } + }); + }); + + sendgridConfigurations.forEach(async (config) => { + if (!config.active) { + return; + } + config.events.forEach(async (event) => { + if (event.active) { + statuses[eventToWebhookMapping[event.eventType]] = true; + } + }); + }); + + return statuses; +}; diff --git a/apps/emails-and-messages/src/modules/webhook-management/sync-webhook-status.test.ts b/apps/emails-and-messages/src/modules/webhook-management/sync-webhook-status.test.ts new file mode 100644 index 0000000..afa9bb6 --- /dev/null +++ b/apps/emails-and-messages/src/modules/webhook-management/sync-webhook-status.test.ts @@ -0,0 +1,159 @@ +import { vi, expect, describe, it, afterEach } from "vitest"; +import { SettingsManager } from "@saleor/app-sdk/settings-manager"; +import { SmtpPrivateMetadataManager } from "../smtp/configuration/smtp-metadata-manager"; +import { SmtpConfigurationService } from "../smtp/configuration/smtp-configuration.service"; +import { syncWebhookStatus } from "./sync-webhook-status"; +import { SendgridPrivateMetadataManager } from "../sendgrid/configuration/sendgrid-metadata-manager"; +import { SendgridConfigurationService } from "../sendgrid/configuration/sendgrid-configuration.service"; +import { WebhookManagementService } from "./webhook-management-service"; +import { Client } from "urql"; +import * as statusesExports from "./get-webhook-statuses-from-configurations"; + +const mockSaleorApiUrl = "https://demo.saleor.io/graphql/"; + +describe("syncWebhookStatus", function () { + const createMockedClient = () => ({} as Client); + + const webhookManagementService = new WebhookManagementService( + mockSaleorApiUrl, + createMockedClient() + ); + + const createWebhookMock = vi + .spyOn(webhookManagementService, "createWebhook") + .mockImplementation((_) => Promise.resolve()); + + const deleteWebhookMock = vi + .spyOn(webhookManagementService, "deleteWebhook") + .mockImplementation((_) => Promise.resolve()); + + const smtpConfigurator = new SmtpPrivateMetadataManager( + null as unknown as SettingsManager, + mockSaleorApiUrl + ); + + const smtpConfigurationService = new SmtpConfigurationService({ + metadataManager: smtpConfigurator, + initialData: { + configurations: [], + }, + }); + + const sendgridConfigurator = new SendgridPrivateMetadataManager( + null as unknown as SettingsManager, + mockSaleorApiUrl + ); + + const sendgridConfigurationService = new SendgridConfigurationService({ + metadataManager: sendgridConfigurator, + initialData: { + configurations: [], + }, + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it("No webhook should be created or deleted, when both API and configurations don't use any", async () => { + vi.spyOn(statusesExports, "getWebhookStatusesFromConfigurations").mockReturnValue({ + invoiceSentWebhook: false, + notifyWebhook: false, + orderCancelledWebhook: false, + orderConfirmedWebhook: false, + orderFulfilledWebhook: false, + orderCreatedWebhook: false, + orderFullyPaidWebhook: false, + }); + + const getWebhooksStatusMock = vi + .spyOn(webhookManagementService, "getWebhooksStatus") + .mockResolvedValue({ + invoiceSentWebhook: false, + notifyWebhook: false, + orderCancelledWebhook: false, + orderConfirmedWebhook: false, + orderFulfilledWebhook: false, + orderCreatedWebhook: false, + orderFullyPaidWebhook: false, + }); + + await syncWebhookStatus({ + smtpConfigurationService, + sendgridConfigurationService, + webhookManagementService, + }); + + expect(getWebhooksStatusMock).toHaveBeenCalled(); + expect(deleteWebhookMock).not.toHaveBeenCalled(); + expect(createWebhookMock).not.toHaveBeenCalled(); + }); + + it("Webhooks should be deleted from API, when configurations no longer use them", async () => { + vi.spyOn(statusesExports, "getWebhookStatusesFromConfigurations").mockReturnValue({ + invoiceSentWebhook: false, + notifyWebhook: false, + orderCancelledWebhook: false, + orderConfirmedWebhook: false, + orderFulfilledWebhook: false, + orderCreatedWebhook: false, + orderFullyPaidWebhook: false, + }); + + const getWebhooksStatusMock = vi + .spyOn(webhookManagementService, "getWebhooksStatus") + .mockResolvedValue({ + invoiceSentWebhook: true, + notifyWebhook: true, + orderCancelledWebhook: false, + orderConfirmedWebhook: false, + orderFulfilledWebhook: false, + orderCreatedWebhook: false, + orderFullyPaidWebhook: false, + }); + + await syncWebhookStatus({ + smtpConfigurationService, + sendgridConfigurationService, + webhookManagementService, + }); + + expect(getWebhooksStatusMock).toHaveBeenCalled(); + expect(createWebhookMock).not.toHaveBeenCalled(); + expect(deleteWebhookMock).toHaveBeenCalledTimes(2); + }); + + it("Webhooks should be created using API, when new configurations use them", async () => { + vi.spyOn(statusesExports, "getWebhookStatusesFromConfigurations").mockReturnValue({ + invoiceSentWebhook: true, + notifyWebhook: true, + orderCancelledWebhook: false, + orderConfirmedWebhook: false, + orderFulfilledWebhook: false, + orderCreatedWebhook: false, + orderFullyPaidWebhook: false, + }); + + const getWebhooksStatusMock = vi + .spyOn(webhookManagementService, "getWebhooksStatus") + .mockResolvedValue({ + invoiceSentWebhook: false, + notifyWebhook: false, + orderCancelledWebhook: false, + orderConfirmedWebhook: false, + orderFulfilledWebhook: false, + orderCreatedWebhook: false, + orderFullyPaidWebhook: false, + }); + + await syncWebhookStatus({ + smtpConfigurationService, + sendgridConfigurationService, + webhookManagementService, + }); + + expect(getWebhooksStatusMock).toHaveBeenCalled(); + expect(createWebhookMock).toHaveBeenCalledTimes(2); + expect(deleteWebhookMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/emails-and-messages/src/modules/webhook-management/sync-webhook-status.ts b/apps/emails-and-messages/src/modules/webhook-management/sync-webhook-status.ts new file mode 100644 index 0000000..8736f46 --- /dev/null +++ b/apps/emails-and-messages/src/modules/webhook-management/sync-webhook-status.ts @@ -0,0 +1,60 @@ +import { SmtpConfigurationService } from "../smtp/configuration/smtp-configuration.service"; +import { SendgridConfigurationService } from "../sendgrid/configuration/sendgrid-configuration.service"; +import { WebhookManagementService, AppWebhook } from "./webhook-management-service"; +import { createLogger } from "@saleor/apps-shared"; +import { getWebhookStatusesFromConfigurations } from "./get-webhook-statuses-from-configurations"; + +const logger = createLogger({ name: "SyncWebhooksStatus" }); + +interface SyncWebhooksStatusArgs { + smtpConfigurationService: SmtpConfigurationService; + sendgridConfigurationService: SendgridConfigurationService; + webhookManagementService: WebhookManagementService; +} + +/** + * Checks active events in configurations and updates webhooks in the API if needed. + */ +export const syncWebhookStatus = async ({ + smtpConfigurationService, + sendgridConfigurationService, + webhookManagementService, +}: SyncWebhooksStatusArgs) => { + logger.debug("Pulling current webhook status from the API"); + const oldStatuses = await webhookManagementService.getWebhooksStatus(); + + logger.debug("Generate expected webhook status based on current configurations"); + + // API requests can be triggered if not cached yet + const [activeSmtpConfigurations, activeSendgridConfigurations] = await Promise.all([ + smtpConfigurationService.getConfigurations(), + sendgridConfigurationService.getConfigurations(), + ]); + + const newStatuses = getWebhookStatusesFromConfigurations({ + smtpConfigurations: activeSmtpConfigurations, + sendgridConfigurations: activeSendgridConfigurations, + }); + + logger.debug("Update webhooks in the API if needed"); + const apiMutationPromises = Object.keys(newStatuses).map(async (key) => { + const webhook = key as AppWebhook; + + if (newStatuses[webhook] === oldStatuses[webhook]) { + // Webhook status is already up to date + return; + } + + if (newStatuses[webhook]) { + logger.debug(`Creating webhook ${webhook}`); + return webhookManagementService.createWebhook({ webhook }); + } else { + logger.debug(`Deleting webhook ${webhook}`); + return webhookManagementService.deleteWebhook({ webhook }); + } + }); + + await Promise.all(apiMutationPromises); + + logger.debug("Webhooks status synchronized"); +}; diff --git a/apps/emails-and-messages/src/modules/webhook-management/webhook-management-service.test.ts b/apps/emails-and-messages/src/modules/webhook-management/webhook-management-service.test.ts new file mode 100644 index 0000000..9ff7367 --- /dev/null +++ b/apps/emails-and-messages/src/modules/webhook-management/webhook-management-service.test.ts @@ -0,0 +1,153 @@ +import { vi, expect, describe, it, afterEach } from "vitest"; +import { WebhookManagementService } from "./webhook-management-service"; +import { Client } from "urql"; +import * as operationExports from "./api-operations"; +import { WebhookEventTypeAsyncEnum } from "../../../generated/graphql"; +import { invoiceSentWebhook } from "../../pages/api/webhooks/invoice-sent"; +import { orderCancelledWebhook } from "../../pages/api/webhooks/order-cancelled"; + +const mockSaleorApiUrl = "https://demo.saleor.io/graphql/"; + +describe("WebhookManagementService", function () { + const mockedClient = {} as Client; + + afterEach(() => { + vi.resetAllMocks(); + }); + + it("API should be called, when getWebhooks is used", async () => { + const webhookManagementService = new WebhookManagementService( + "https://example.com", + mockedClient + ); + + const fetchAppWebhooksMock = vi.spyOn(operationExports, "fetchAppWebhooks").mockResolvedValue([ + { + asyncEvents: [{ eventType: WebhookEventTypeAsyncEnum.InvoiceSent, name: "Invoice sent" }], + id: "1", + isActive: true, + name: invoiceSentWebhook.name, + }, + ]); + + const webhookData = await webhookManagementService.getWebhooks(); + + expect(webhookData).toStrictEqual([ + { + asyncEvents: [{ eventType: WebhookEventTypeAsyncEnum.InvoiceSent, name: "Invoice sent" }], + id: "1", + isActive: true, + name: invoiceSentWebhook.name, + }, + ]); + expect(fetchAppWebhooksMock).toBeCalledTimes(1); + }); + + it("Webhook statuses should be active, when whists in API and active", async () => { + const webhookManagementService = new WebhookManagementService( + "https://example.com", + mockedClient + ); + + const fetchAppWebhooksMock = vi.spyOn(operationExports, "fetchAppWebhooks").mockResolvedValue([ + { + asyncEvents: [{ eventType: WebhookEventTypeAsyncEnum.InvoiceSent, name: "Invoice sent" }], + id: "1", + isActive: true, + name: invoiceSentWebhook.name, + }, + { + asyncEvents: [ + { eventType: WebhookEventTypeAsyncEnum.OrderCancelled, name: "Order cancelled" }, + ], + id: "2", + isActive: false, + name: orderCancelledWebhook.name, + }, + ]); + + const statuses = await webhookManagementService.getWebhooksStatus(); + + expect(statuses).toStrictEqual({ + invoiceSentWebhook: true, + notifyWebhook: false, + orderCancelledWebhook: false, + orderConfirmedWebhook: false, + orderCreatedWebhook: false, + orderFulfilledWebhook: false, + orderFullyPaidWebhook: false, + }); + expect(fetchAppWebhooksMock).toBeCalledTimes(1); + }); + + it("Webhook should be created using the API, when requested", async () => { + const webhookManagementService = new WebhookManagementService( + "https://example.com", + mockedClient + ); + + const createAppWebhookMock = vi.spyOn(operationExports, "createAppWebhook").mockResolvedValue({ + id: "1", + isActive: true, + name: invoiceSentWebhook.name, + asyncEvents: [{ eventType: WebhookEventTypeAsyncEnum.InvoiceSent, name: "Invoice sent" }], + }); + + await webhookManagementService.createWebhook({ + webhook: "invoiceSentWebhook", + }); + + expect(createAppWebhookMock).toBeCalledTimes(1); + + // Values are taken from webhook definition + expect(createAppWebhookMock).toBeCalledWith({ + client: mockedClient, + variables: { + asyncEvents: ["INVOICE_SENT"], + isActive: true, + name: invoiceSentWebhook.name, + targetUrl: "https://example.com/api/webhooks/invoice-sent", + query: + "subscription InvoiceSent { event { ...InvoiceSentWebhookPayload }}fragment InvoiceSentWebhookPayload on InvoiceSent { invoice { id message externalUrl url order { id } } order { ...OrderDetails }}fragment OrderDetails on Order { id number userEmail channel { slug } user { email firstName lastName } billingAddress { streetAddress1 city postalCode country { country } } shippingAddress { streetAddress1 city postalCode country { country } } lines { id productName variantName quantity thumbnail { url alt } unitPrice { gross { currency amount } } totalPrice { gross { currency amount } } } subtotal { gross { amount currency } } shippingPrice { gross { amount currency } } total { gross { amount currency } }}", + }, + }); + }); + + it("Webhook should be deleted using the API, when requested", async () => { + const webhookManagementService = new WebhookManagementService( + "https://example.com", + mockedClient + ); + + vi.spyOn(operationExports, "fetchAppWebhooks").mockResolvedValue([ + { + asyncEvents: [{ eventType: WebhookEventTypeAsyncEnum.InvoiceSent, name: "Invoice sent" }], + id: "1", + isActive: true, + name: invoiceSentWebhook.name, + }, + { + asyncEvents: [ + { eventType: WebhookEventTypeAsyncEnum.OrderCancelled, name: "Order cancelled" }, + ], + id: "2", + isActive: false, + name: orderCancelledWebhook.name, + }, + ]); + + const deleteAppWebhookMock = vi.spyOn(operationExports, "deleteAppWebhook").mockResolvedValue(); + + await webhookManagementService.deleteWebhook({ + webhook: "invoiceSentWebhook", + }); + + expect(deleteAppWebhookMock).toBeCalledTimes(1); + + // Values are taken from webhook definition + expect(deleteAppWebhookMock).toBeCalledWith({ + client: mockedClient, + id: "1", + }); + }); +}); diff --git a/apps/emails-and-messages/src/modules/webhook-management/webhook-management-service.ts b/apps/emails-and-messages/src/modules/webhook-management/webhook-management-service.ts new file mode 100644 index 0000000..9d763c7 --- /dev/null +++ b/apps/emails-and-messages/src/modules/webhook-management/webhook-management-service.ts @@ -0,0 +1,113 @@ +import { invoiceSentWebhook } from "../../pages/api/webhooks/invoice-sent"; +import { orderCancelledWebhook } from "../../pages/api/webhooks/order-cancelled"; +import { orderConfirmedWebhook } from "../../pages/api/webhooks/order-confirmed"; +import { orderCreatedWebhook } from "../../pages/api/webhooks/order-created"; +import { orderFulfilledWebhook } from "../../pages/api/webhooks/order-fulfilled"; +import { orderFullyPaidWebhook } from "../../pages/api/webhooks/order-fully-paid"; +import { Client } from "urql"; +import { createAppWebhook, deleteAppWebhook, fetchAppWebhooks } from "./api-operations"; +import { notifyWebhook } from "../../pages/api/webhooks/notify"; +import { MessageEventTypes } from "../event-handlers/message-event-types"; +import { createLogger } from "@saleor/apps-shared"; +import { WebhookEventTypeAsyncEnum } from "../../../generated/graphql"; + +export const AppWebhooks = { + orderCreatedWebhook, + orderFulfilledWebhook, + orderConfirmedWebhook, + orderCancelledWebhook, + orderFullyPaidWebhook, + invoiceSentWebhook, + notifyWebhook, +}; + +export type AppWebhook = keyof typeof AppWebhooks; + +export const eventToWebhookMapping: Record = { + ACCOUNT_CONFIRMATION: "notifyWebhook", + ACCOUNT_DELETE: "notifyWebhook", + ACCOUNT_PASSWORD_RESET: "notifyWebhook", + ACCOUNT_CHANGE_EMAIL_REQUEST: "notifyWebhook", + ACCOUNT_CHANGE_EMAIL_CONFIRM: "notifyWebhook", + INVOICE_SENT: "invoiceSentWebhook", + ORDER_CANCELLED: "orderCancelledWebhook", + ORDER_CONFIRMED: "orderConfirmedWebhook", + ORDER_CREATED: "orderCreatedWebhook", + ORDER_FULFILLED: "orderFulfilledWebhook", + ORDER_FULLY_PAID: "orderFullyPaidWebhook", +}; + +const logger = createLogger({ + name: "WebhookManagementService", +}); + +export class WebhookManagementService { + constructor(private appBaseUrl: string, private client: Client) {} + + // Returns list of webhooks registered for the App in the Saleor instance + public async getWebhooks() { + logger.debug("Fetching webhooks"); + return await fetchAppWebhooks({ client: this.client }); + } + + /** + * Returns a dictionary with webhooks status. + * Status equal to true means that webhook is created and active. + */ + public async getWebhooksStatus() { + logger.debug("Fetching webhooks status"); + const webhooks = await this.getWebhooks(); + + return Object.fromEntries( + Object.keys(AppWebhooks).map((webhook) => { + const webhookData = webhooks.find( + (w) => w.name === AppWebhooks[webhook as AppWebhook].name + ); + + return [webhook as AppWebhook, Boolean(webhookData?.isActive)]; + }) + ); + } + + public async createWebhook({ webhook }: { webhook: AppWebhook }) { + const webhookManifest = AppWebhooks[webhook].getWebhookManifest(this.appBaseUrl); + + const asyncWebhooks = webhookManifest.asyncEvents; + + if (!asyncWebhooks?.length) { + logger.warn(`Webhook ${webhook} has no async events`); + throw new Error("Only the webhooks with async events can be registered"); + } + + await createAppWebhook({ + client: this.client, + variables: { + asyncEvents: asyncWebhooks as WebhookEventTypeAsyncEnum[], + isActive: true, + name: webhookManifest.name, + targetUrl: webhookManifest.targetUrl, + // Override empty queries to handle NOTIFY webhook + query: webhookManifest.query === "{}" ? undefined : webhookManifest.query, + }, + }); + } + + public async deleteWebhook({ webhook }: { webhook: AppWebhook }): Promise { + logger.debug(`Deleting webhook ${webhook}`); + logger.debug(`Fetching existing webhooks`); + const webhookData = await this.getWebhooks(); + + const id = webhookData.find((w) => w.name === AppWebhooks[webhook].name)?.id; + + if (!id) { + logger.error(`Webhook ${AppWebhooks[webhook].name} was not registered yet`); + throw new Error(`Webhook ${AppWebhooks[webhook].name} was not registered yet`); + } + + logger.debug(`Running delete mutation`); + await deleteAppWebhook({ + client: this.client, + id, + }); + } +} diff --git a/apps/emails-and-messages/src/pages/api/manifest.ts b/apps/emails-and-messages/src/pages/api/manifest.ts index 99e9168..a074c39 100644 --- a/apps/emails-and-messages/src/pages/api/manifest.ts +++ b/apps/emails-and-messages/src/pages/api/manifest.ts @@ -2,12 +2,6 @@ import { createManifestHandler } from "@saleor/app-sdk/handlers/next"; import { AppManifest } from "@saleor/app-sdk/types"; import packageJson from "../../../package.json"; -import { orderCreatedWebhook } from "./webhooks/order-created"; -import { orderFulfilledWebhook } from "./webhooks/order-fulfilled"; -import { orderConfirmedWebhook } from "./webhooks/order-confirmed"; -import { orderCancelledWebhook } from "./webhooks/order-cancelled"; -import { orderFullyPaidWebhook } from "./webhooks/order-fully-paid"; -import { invoiceSentWebhook } from "./webhooks/invoice-sent"; export default createManifestHandler({ async manifestFactory(context) { @@ -18,14 +12,6 @@ export default createManifestHandler({ permissions: ["MANAGE_ORDERS", "MANAGE_USERS"], id: "saleor.app.emails-and-messages", version: packageJson.version, - webhooks: [ - orderCreatedWebhook.getWebhookManifest(context.appBaseUrl), - orderFulfilledWebhook.getWebhookManifest(context.appBaseUrl), - orderConfirmedWebhook.getWebhookManifest(context.appBaseUrl), - orderCancelledWebhook.getWebhookManifest(context.appBaseUrl), - orderFullyPaidWebhook.getWebhookManifest(context.appBaseUrl), - invoiceSentWebhook.getWebhookManifest(context.appBaseUrl), - ], extensions: [ /** * Optionally, extend Dashboard with custom UIs diff --git a/apps/emails-and-messages/src/pages/api/register.ts b/apps/emails-and-messages/src/pages/api/register.ts index 28fee18..382f71a 100644 --- a/apps/emails-and-messages/src/pages/api/register.ts +++ b/apps/emails-and-messages/src/pages/api/register.ts @@ -1,9 +1,6 @@ import { createAppRegisterHandler } from "@saleor/app-sdk/handlers/next"; import { saleorApp } from "../../saleor-app"; -import { logger, createGraphQLClient } from "@saleor/apps-shared"; -import { getBaseUrl } from "../../lib/get-base-url"; -import { registerNotifyWebhook } from "../../lib/register-notify-webhook"; const allowedUrlsPattern = process.env.ALLOWED_DOMAIN_PATTERN; @@ -24,19 +21,4 @@ export default createAppRegisterHandler({ return true; }, ], - onAuthAplSaved: async (request, ctx) => { - // Subscribe to Notify using the mutation since it does not use subscriptions and can't be subscribed via manifest - logger.debug("onAuthAplSaved executing"); - const baseUrl = getBaseUrl(request.headers); - const client = createGraphQLClient({ - saleorApiUrl: ctx.authData.saleorApiUrl, - token: ctx.authData.token, - }); - - await registerNotifyWebhook({ - client: client, - baseUrl: baseUrl, - }); - logger.debug("Webhook registered"); - }, });