📧 Dynamic webhook management (#646)
* Make channels section expandable based on override setting * Revert "Make channels section expandable based on override setting" This reverts commit e107c5e990b4110156043ed494fb0054bd936654. * Add status component * Remove no longer used component * Remove no longer used component * Removed webhook creation during App installation * Extend tRPC meta to contain webhook sync flag * Add app baseUrl to the context * Webhook management service * Add changeset
This commit is contained in:
parent
ec68ed53a3
commit
82dfc3fa6f
16 changed files with 1042 additions and 159 deletions
7
.changeset/two-dingos-notice.md
Normal file
7
.changeset/two-dingos-notice.md
Normal file
|
@ -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.
|
|
@ -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();
|
||||
};
|
|
@ -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 });
|
||||
|
|
|
@ -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 });
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
);
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -5,6 +5,7 @@ import { ZodError } from "zod";
|
|||
|
||||
interface Meta {
|
||||
requiredClientPermissions?: Permission[];
|
||||
updateWebhooks?: boolean;
|
||||
}
|
||||
|
||||
const t = initTRPC
|
||||
|
|
|
@ -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;
|
||||
});
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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<AppWebhook, boolean> = {
|
||||
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;
|
||||
};
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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");
|
||||
};
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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<MessageEventTypes, AppWebhook> = {
|
||||
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<void> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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");
|
||||
},
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue