From 14ac6144c070f6d0bae97b804f6e80c46c40a0fe Mon Sep 17 00:00:00 2001 From: Krzysztof Wolski Date: Fri, 24 Mar 2023 15:33:48 +0100 Subject: [PATCH] Update the sendgrid support (#321) * Update the sendgrid support * Add changeset --- .changeset/large-seals-study.md | 5 + .../ui/channels-configuration-tab.tsx | 13 +- .../event-handlers/send-event-messages.ts | 35 +- .../get-sendgrid-configuration.service.ts | 121 ++++- .../sendgrid-config-container.ts | 140 +++-- .../sendgrid-config-input-schema.ts | 71 ++- .../sendgrid/configuration/sendgrid-config.ts | 30 +- .../sendgrid-configuration.router.ts | 162 +++++- .../configuration/ui/fetch-templates.ts | 41 -- .../ui/sendgrid-configuration-form.tsx | 482 +++++++++--------- .../ui/sendgrid-configuration-tab.tsx | 208 +++++--- .../ui/sendgrid-event-configuration-form.tsx | 227 +++++++++ .../ui/sendgrid-templates-card.tsx | 135 +++++ .../modules/sendgrid/get-sendgrid-settings.ts | 54 -- .../src/modules/sendgrid/send-sendgrid.ts | 109 ++-- .../src/modules/sendgrid/sendgrid-api.ts | 78 +++ .../src/modules/sendgrid/urls.ts | 8 + .../ui/configuration-page-base-layout.tsx | 5 +- .../sendgrid/[[...configurationId]].tsx | 19 + .../[configurationId]/event/[eventType].tsx | 77 +++ 20 files changed, 1401 insertions(+), 619 deletions(-) create mode 100644 .changeset/large-seals-study.md delete mode 100644 apps/emails-and-messages/src/modules/sendgrid/configuration/ui/fetch-templates.ts create mode 100644 apps/emails-and-messages/src/modules/sendgrid/configuration/ui/sendgrid-event-configuration-form.tsx create mode 100644 apps/emails-and-messages/src/modules/sendgrid/configuration/ui/sendgrid-templates-card.tsx delete mode 100644 apps/emails-and-messages/src/modules/sendgrid/get-sendgrid-settings.ts create mode 100644 apps/emails-and-messages/src/modules/sendgrid/sendgrid-api.ts create mode 100644 apps/emails-and-messages/src/modules/sendgrid/urls.ts create mode 100644 apps/emails-and-messages/src/pages/configuration/sendgrid/[[...configurationId]].tsx create mode 100644 apps/emails-and-messages/src/pages/configuration/sendgrid/[configurationId]/event/[eventType].tsx diff --git a/.changeset/large-seals-study.md b/.changeset/large-seals-study.md new file mode 100644 index 0000000..53e5d5e --- /dev/null +++ b/.changeset/large-seals-study.md @@ -0,0 +1,5 @@ +--- +"saleor-app-emails-and-messages": minor +--- + +Enable Sendgrid support diff --git a/apps/emails-and-messages/src/modules/app-configuration/ui/channels-configuration-tab.tsx b/apps/emails-and-messages/src/modules/app-configuration/ui/channels-configuration-tab.tsx index b2b5027..7da16bf 100644 --- a/apps/emails-and-messages/src/modules/app-configuration/ui/channels-configuration-tab.tsx +++ b/apps/emails-and-messages/src/modules/app-configuration/ui/channels-configuration-tab.tsx @@ -59,18 +59,13 @@ export const ChannelsConfigurationTab = () => { }, [mjmlConfigurations]); const { data: sendgridConfigurations, isLoading: isSendgridQueryLoading } = - trpcClient.sendgridConfiguration.fetch.useQuery(); + trpcClient.sendgridConfiguration.getConfigurations.useQuery({}); const sendgridConfigurationsListData = useMemo(() => { - if (!sendgridConfigurations) { - return []; - } - const keys = Object.keys(sendgridConfigurations.availableConfigurations ?? {}) || []; - return ( - keys.map((key) => ({ - value: key, - label: sendgridConfigurations.availableConfigurations[key].configurationName, + sendgridConfigurations?.map((configuration) => ({ + value: configuration.id, + label: configuration.configurationName, })) ?? [] ); }, [sendgridConfigurations]); diff --git a/apps/emails-and-messages/src/modules/event-handlers/send-event-messages.ts b/apps/emails-and-messages/src/modules/event-handlers/send-event-messages.ts index 141cc94..cebc824 100644 --- a/apps/emails-and-messages/src/modules/event-handlers/send-event-messages.ts +++ b/apps/emails-and-messages/src/modules/event-handlers/send-event-messages.ts @@ -4,6 +4,7 @@ import { logger as pinoLogger } from "../../lib/logger"; import { AppConfigurationService } from "../app-configuration/get-app-configuration.service"; import { MjmlConfigurationService } from "../mjml/configuration/get-mjml-configuration.service"; import { sendMjml } from "../mjml/send-mjml"; +import { SendgridConfigurationService } from "../sendgrid/configuration/get-sendgrid-configuration.service"; import { sendSendgrid } from "../sendgrid/send-sendgrid"; import { MessageEventTypes } from "./message-event-types"; @@ -73,16 +74,30 @@ export const sendEventMessages = async ({ } } } - const sendgridStatus = await sendSendgrid({ - authData, - channel, - event, - payload, - recipientEmail, - }); - if (sendgridStatus?.errors.length) { - logger.error("Sending message with Sendgrid has failed"); - logger.error(sendgridStatus?.errors); + if (channelAppConfiguration.sendgridConfigurationId) { + logger.debug("Channel has assigned Sendgrid configuration"); + + const sendgridConfigurationService = new SendgridConfigurationService({ + apiClient: client, + saleorApiUrl: authData.saleorApiUrl, + }); + + const sendgridConfiguration = await sendgridConfigurationService.getConfiguration({ + id: channelAppConfiguration.sendgridConfigurationId, + }); + if (sendgridConfiguration) { + const sendgridStatus = await sendSendgrid({ + event, + payload, + recipientEmail, + sendgridConfiguration, + }); + + if (sendgridStatus?.errors.length) { + logger.error("Sendgrid errors"); + logger.error(sendgridStatus?.errors); + } + } } }; diff --git a/apps/emails-and-messages/src/modules/sendgrid/configuration/get-sendgrid-configuration.service.ts b/apps/emails-and-messages/src/modules/sendgrid/configuration/get-sendgrid-configuration.service.ts index d469f60..fd7b576 100644 --- a/apps/emails-and-messages/src/modules/sendgrid/configuration/get-sendgrid-configuration.service.ts +++ b/apps/emails-and-messages/src/modules/sendgrid/configuration/get-sendgrid-configuration.service.ts @@ -1,36 +1,107 @@ -import { PrivateMetadataSendgridConfigurator } from "./sendgrid-configurator"; +import { SendgridConfigurator, PrivateMetadataSendgridConfigurator } from "./sendgrid-configurator"; import { Client } from "urql"; import { logger as pinoLogger } from "../../../lib/logger"; +import { SendgridConfig, SendgridConfiguration } from "./sendgrid-config"; +import { FilterConfigurationsArgs, SendgridConfigContainer } from "./sendgrid-config-container"; import { createSettingsManager } from "../../../lib/metadata-manager"; -// todo test -export class GetSendgridConfigurationService { - constructor( - private settings: { - apiClient: Client; - saleorApiUrl: string; - } - ) {} +const logger = pinoLogger.child({ + service: "SendgridConfigurationService", +}); - async getConfiguration() { - const logger = pinoLogger.child({ - service: "GetSendgridConfigurationService", - saleorApiUrl: this.settings.saleorApiUrl, - }); +export class SendgridConfigurationService { + private configurationData?: SendgridConfig; + private metadataConfigurator: SendgridConfigurator; - const { saleorApiUrl, apiClient } = this.settings; - - const sendgridConfigurator = new PrivateMetadataSendgridConfigurator( - createSettingsManager(apiClient), - saleorApiUrl + constructor(args: { apiClient: Client; saleorApiUrl: string; initialData?: SendgridConfig }) { + this.metadataConfigurator = new PrivateMetadataSendgridConfigurator( + createSettingsManager(args.apiClient), + args.saleorApiUrl ); - const savedSendgridConfig = (await sendgridConfigurator.getConfig()) ?? null; - - logger.debug(savedSendgridConfig, "Retrieved sendgrid config from Metadata. Will return it"); - - if (savedSendgridConfig) { - return savedSendgridConfig; + if (args.initialData) { + this.configurationData = args.initialData; } } + + // Fetch configuration from Saleor API and cache it + private async pullConfiguration() { + logger.debug("Fetch configuration from Saleor API"); + + const config = await this.metadataConfigurator.getConfig(); + this.configurationData = config; + } + + // Push configuration to Saleor API + private async pushConfiguration() { + logger.debug("Push configuration to Saleor API"); + + await this.metadataConfigurator.setConfig(this.configurationData!); + } + + // Returns configuration from cache or fetches it from Saleor API + async getConfigurationRoot() { + logger.debug("Get configuration root"); + + if (this.configurationData) { + logger.debug("Using cached configuration"); + return this.configurationData; + } + + // No cached data, fetch it from Saleor API + await this.pullConfiguration(); + + if (!this.configurationData) { + logger.warn("No configuration found in Saleor API"); + return; + } + + return this.configurationData; + } + + // Saves configuration to Saleor API and cache it + async setConfigurationRoot(config: SendgridConfig) { + logger.debug("Set configuration root"); + + this.configurationData = config; + await this.pushConfiguration(); + } + + async getConfiguration({ id }: { id: string }) { + logger.debug("Get configuration"); + return SendgridConfigContainer.getConfiguration(await this.getConfigurationRoot())({ id }); + } + + async getConfigurations(filter?: FilterConfigurationsArgs) { + logger.debug("Get configuration"); + return SendgridConfigContainer.getConfigurations(await this.getConfigurationRoot())(filter); + } + + async createConfiguration(config: Omit) { + logger.debug("Create configuration"); + const updatedConfigurationRoot = SendgridConfigContainer.createConfiguration( + await this.getConfigurationRoot() + )(config); + await this.setConfigurationRoot(updatedConfigurationRoot); + + return updatedConfigurationRoot.configurations[ + updatedConfigurationRoot.configurations.length - 1 + ]; + } + + async updateConfiguration(config: SendgridConfiguration) { + logger.debug("Update configuration"); + const updatedConfigurationRoot = SendgridConfigContainer.updateConfiguration( + await this.getConfigurationRoot() + )(config); + this.setConfigurationRoot(updatedConfigurationRoot); + } + + async deleteConfiguration({ id }: { id: string }) { + logger.debug("Delete configuration"); + const updatedConfigurationRoot = SendgridConfigContainer.deleteConfiguration( + await this.getConfigurationRoot() + )({ id }); + this.setConfigurationRoot(updatedConfigurationRoot); + } } diff --git a/apps/emails-and-messages/src/modules/sendgrid/configuration/sendgrid-config-container.ts b/apps/emails-and-messages/src/modules/sendgrid/configuration/sendgrid-config-container.ts index 4ba4bd6..f9d90ed 100644 --- a/apps/emails-and-messages/src/modules/sendgrid/configuration/sendgrid-config-container.ts +++ b/apps/emails-and-messages/src/modules/sendgrid/configuration/sendgrid-config-container.ts @@ -1,60 +1,120 @@ -import { SendgridConfig as SendgridConfig, SendgridConfiguration } from "./sendgrid-config"; +import { messageEventTypes } from "../../event-handlers/message-event-types"; +import { + SendgridConfig as SendgridConfigurationRoot, + SendgridConfiguration, +} from "./sendgrid-config"; -export const getDefaultEmptySendgridConfiguration = (): SendgridConfiguration => { - const defaultConfig = { - active: false, +export const generateSendgridConfigurationId = () => Date.now().toString(); + +export const getDefaultEventsConfiguration = (): SendgridConfiguration["events"] => + messageEventTypes.map((eventType) => ({ + active: true, + eventType: eventType, + template: "", + })); + +export const getDefaultEmptyConfiguration = (): SendgridConfiguration => { + const defaultConfig: SendgridConfiguration = { + id: "", + active: true, configurationName: "", - sandboxMode: false, - senderName: "", - senderEmail: "", + senderName: undefined, + senderEmail: undefined, apiKey: "", - templateInvoiceSentSubject: "Invoice sent", - templateInvoiceSentTemplate: "", - templateOrderCancelledSubject: "Order Cancelled", - templateOrderCancelledTemplate: "", - templateOrderConfirmedSubject: "Order Confirmed", - templateOrderConfirmedTemplate: "", - templateOrderFullyPaidSubject: "Order Fully Paid", - templateOrderFullyPaidTemplate: "", - templateOrderCreatedSubject: "Order created", - templateOrderCreatedTemplate: "", - templateOrderFulfilledSubject: "Order fulfilled", - templateOrderFulfilledTemplate: "", + sandboxMode: false, + events: getDefaultEventsConfiguration(), }; return defaultConfig; }; -const getSendgridConfigurationById = - (sendgridConfig: SendgridConfig | null | undefined) => (configurationId?: string) => { - if (!configurationId?.length) { - return getDefaultEmptySendgridConfiguration(); +interface GetConfigurationArgs { + id: string; +} + +const getConfiguration = + (sendgridConfigRoot: SendgridConfigurationRoot | null | undefined) => + ({ id }: GetConfigurationArgs) => { + if (!sendgridConfigRoot || !sendgridConfigRoot.configurations) { + return; } - const existingConfig = sendgridConfig?.availableConfigurations[configurationId]; - if (!existingConfig) { - return getDefaultEmptySendgridConfiguration(); - } - return existingConfig; + + return sendgridConfigRoot.configurations.find((c) => c.id === id); }; -const setSendgridConfigurationById = - (sendgridConfig: SendgridConfig | null | undefined) => - (configurationId: string | undefined) => - (sendgridConfiguration: SendgridConfiguration) => { - const sendgridConfigNormalized = structuredClone(sendgridConfig) ?? { - availableConfigurations: {}, - }; +export interface FilterConfigurationsArgs { + ids?: string[]; + active?: boolean; +} + +const getConfigurations = + (sendgridConfigRoot: SendgridConfigurationRoot | null | undefined) => + (filter: FilterConfigurationsArgs | undefined): SendgridConfiguration[] => { + if (!sendgridConfigRoot || !sendgridConfigRoot.configurations) { + return []; + } + + let filtered = sendgridConfigRoot.configurations; + + if (filter?.ids?.length) { + filtered = filtered.filter((c) => filter?.ids?.includes(c.id)); + } + + if (filter?.active !== undefined) { + filtered = filtered.filter((c) => c.active === filter.active); + } + + return filtered; + }; + +const createConfiguration = + (sendgridConfigRoot: SendgridConfigurationRoot | null | undefined) => + (sendgridConfiguration: Omit) => { + const sendgridConfigNormalized = structuredClone(sendgridConfigRoot) ?? { configurations: [] }; // for creating a new configurations, the ID has to be generated - const id = configurationId || Date.now(); - sendgridConfigNormalized.availableConfigurations[id] ??= getDefaultEmptySendgridConfiguration(); + const newConfiguration = { + ...sendgridConfiguration, + id: generateSendgridConfigurationId(), + events: getDefaultEventsConfiguration(), + }; + sendgridConfigNormalized.configurations.push(newConfiguration); + return sendgridConfigNormalized; + }; - sendgridConfigNormalized.availableConfigurations[id] = sendgridConfiguration; +const updateConfiguration = + (sendgridConfig: SendgridConfigurationRoot | null | undefined) => + (sendgridConfiguration: SendgridConfiguration) => { + const sendgridConfigNormalized = structuredClone(sendgridConfig) ?? { configurations: [] }; + + const configurationIndex = sendgridConfigNormalized.configurations.findIndex( + (configuration) => configuration.id === sendgridConfiguration.id + ); + + sendgridConfigNormalized.configurations[configurationIndex] = sendgridConfiguration; + return sendgridConfigNormalized; + }; + +interface DeleteConfigurationArgs { + id: string; +} + +const deleteConfiguration = + (sendgridConfig: SendgridConfigurationRoot | null | undefined) => + ({ id }: DeleteConfigurationArgs) => { + const sendgridConfigNormalized = structuredClone(sendgridConfig) ?? { configurations: [] }; + + sendgridConfigNormalized.configurations = sendgridConfigNormalized.configurations.filter( + (configuration) => configuration.id !== id + ); return sendgridConfigNormalized; }; export const SendgridConfigContainer = { - getSendgridConfigurationById, - setSendgridConfigurationById, + createConfiguration, + getConfiguration, + updateConfiguration, + deleteConfiguration, + getConfigurations, }; diff --git a/apps/emails-and-messages/src/modules/sendgrid/configuration/sendgrid-config-input-schema.ts b/apps/emails-and-messages/src/modules/sendgrid/configuration/sendgrid-config-input-schema.ts index f9bb03d..10c5f20 100644 --- a/apps/emails-and-messages/src/modules/sendgrid/configuration/sendgrid-config-input-schema.ts +++ b/apps/emails-and-messages/src/modules/sendgrid/configuration/sendgrid-config-input-schema.ts @@ -1,26 +1,51 @@ import { z } from "zod"; +import { messageEventTypes } from "../../event-handlers/message-event-types"; -export const sendgridConfigInputSchema = z.object({ - availableConfigurations: z.record( - z.object({ - active: z.boolean(), - configurationName: z.string().min(1), - sandboxMode: z.boolean(), - senderName: z.string().min(0), - senderEmail: z.string().email(), - apiKey: z.string().min(0), - templateInvoiceSentSubject: z.string(), - templateInvoiceSentTemplate: z.string(), - templateOrderCancelledSubject: z.string(), - templateOrderCancelledTemplate: z.string(), - templateOrderConfirmedSubject: z.string(), - templateOrderConfirmedTemplate: z.string(), - templateOrderFullyPaidSubject: z.string(), - templateOrderFullyPaidTemplate: z.string(), - templateOrderCreatedSubject: z.string(), - templateOrderCreatedTemplate: z.string(), - templateOrderFulfilledSubject: z.string(), - templateOrderFulfilledTemplate: z.string(), - }) - ), +export const sendgridConfigurationEventObjectSchema = z.object({ + active: z.boolean(), + eventType: z.enum(messageEventTypes), + template: z.string().min(1), +}); + +export const sendgridConfigurationBaseObjectSchema = z.object({ + active: z.boolean(), + configurationName: z.string().min(1), + sandboxMode: z.boolean(), + apiKey: z.string().min(1), + senderName: z.string().min(1).optional(), + senderEmail: z.string().email().min(5).optional(), +}); + +export const sendgridCreateConfigurationSchema = sendgridConfigurationBaseObjectSchema.omit({ + senderEmail: true, + senderName: true, +}); +export const sendgridUpdateOrCreateConfigurationSchema = + sendgridConfigurationBaseObjectSchema.merge( + z.object({ + id: z.string().optional(), + }) + ); +export const sendgridGetConfigurationInputSchema = z.object({ + id: z.string(), +}); +export const sendgridDeleteConfigurationInputSchema = z.object({ + id: z.string(), +}); +export const sendgridGetConfigurationsInputSchema = z + .object({ + ids: z.array(z.string()).optional(), + active: z.boolean().optional(), + }) + .optional(); + +export const sendgridUpdateEventConfigurationInputSchema = z + .object({ + configurationId: z.string(), + }) + .merge(sendgridConfigurationEventObjectSchema); + +export const sendgridGetEventConfigurationInputSchema = z.object({ + configurationId: z.string(), + eventType: z.enum(messageEventTypes), }); diff --git a/apps/emails-and-messages/src/modules/sendgrid/configuration/sendgrid-config.ts b/apps/emails-and-messages/src/modules/sendgrid/configuration/sendgrid-config.ts index 94763b2..f7bfb33 100644 --- a/apps/emails-and-messages/src/modules/sendgrid/configuration/sendgrid-config.ts +++ b/apps/emails-and-messages/src/modules/sendgrid/configuration/sendgrid-config.ts @@ -1,26 +1,22 @@ +import { MessageEventTypes } from "../../event-handlers/message-event-types"; + +export interface SendgridEventConfiguration { + active: boolean; + eventType: MessageEventTypes; + template: string; +} + export interface SendgridConfiguration { + id: string; active: boolean; configurationName: string; sandboxMode: boolean; - senderName: string; - senderEmail: string; + senderName?: string; + senderEmail?: string; apiKey: string; - templateInvoiceSentSubject: string; - templateInvoiceSentTemplate: string; - templateOrderCancelledSubject: string; - templateOrderCancelledTemplate: string; - templateOrderConfirmedSubject: string; - templateOrderConfirmedTemplate: string; - templateOrderFullyPaidSubject: string; - templateOrderFullyPaidTemplate: string; - templateOrderCreatedSubject: string; - templateOrderCreatedTemplate: string; - templateOrderFulfilledSubject: string; - templateOrderFulfilledTemplate: string; + events: SendgridEventConfiguration[]; } -export type SendgridConfigurationsIdMap = Record; - export type SendgridConfig = { - availableConfigurations: SendgridConfigurationsIdMap; + configurations: SendgridConfiguration[]; }; 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 c86468e..99253e7 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 @@ -1,37 +1,159 @@ -import { PrivateMetadataSendgridConfigurator } from "./sendgrid-configurator"; import { logger as pinoLogger } from "../../../lib/logger"; -import { sendgridConfigInputSchema } from "./sendgrid-config-input-schema"; -import { GetSendgridConfigurationService } from "./get-sendgrid-configuration.service"; +import { + sendgridCreateConfigurationSchema, + sendgridDeleteConfigurationInputSchema, + sendgridGetConfigurationInputSchema, + sendgridGetConfigurationsInputSchema, + sendgridGetEventConfigurationInputSchema, + sendgridUpdateEventConfigurationInputSchema, + sendgridUpdateOrCreateConfigurationSchema, +} from "./sendgrid-config-input-schema"; +import { SendgridConfigurationService } from "./get-sendgrid-configuration.service"; import { router } from "../../trpc/trpc-server"; import { protectedClientProcedure } from "../../trpc/protected-client-procedure"; -import { createSettingsManager } from "../../../lib/metadata-manager"; +import { TRPCError } from "@trpc/server"; + +// Allow access only for the dashboard users and attaches the +// configuration service to the context +const protectedWithConfigurationService = protectedClientProcedure.use(({ next, ctx }) => + next({ + ctx: { + ...ctx, + configurationService: new SendgridConfigurationService({ + apiClient: ctx.apiClient, + saleorApiUrl: ctx.saleorApiUrl, + }), + }, + }) +); export const sendgridConfigurationRouter = router({ - fetch: protectedClientProcedure.query(async ({ ctx, input }) => { + fetch: protectedWithConfigurationService.query(async ({ ctx }) => { const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl }); - logger.debug("sendgridConfigurationRouter.fetch called"); - - return new GetSendgridConfigurationService({ - apiClient: ctx.apiClient, - saleorApiUrl: ctx.saleorApiUrl, - }).getConfiguration(); + return ctx.configurationService.getConfigurationRoot(); }), - setAndReplace: protectedClientProcedure + getConfiguration: protectedWithConfigurationService .meta({ requiredClientPermissions: ["MANAGE_APPS"] }) - .input(sendgridConfigInputSchema) + .input(sendgridGetConfigurationInputSchema) + .query(async ({ ctx, input }) => { + const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl }); + logger.debug(input, "sendgridConfigurationRouter.get called"); + return ctx.configurationService.getConfiguration(input); + }), + getConfigurations: protectedWithConfigurationService + .meta({ requiredClientPermissions: ["MANAGE_APPS"] }) + .input(sendgridGetConfigurationsInputSchema) + .query(async ({ ctx, input }) => { + const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl }); + logger.debug(input, "sendgridConfigurationRouter.getConfigurations called"); + return ctx.configurationService.getConfigurations(input); + }), + createConfiguration: protectedWithConfigurationService + .meta({ requiredClientPermissions: ["MANAGE_APPS"] }) + .input(sendgridCreateConfigurationSchema) + .mutation(async ({ ctx, input }) => { + const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl }); + logger.debug(input, "sendgridConfigurationRouter.create called"); + return await ctx.configurationService.createConfiguration(input); + }), + deleteConfiguration: protectedWithConfigurationService + .meta({ requiredClientPermissions: ["MANAGE_APPS"] }) + .input(sendgridDeleteConfigurationInputSchema) + .mutation(async ({ ctx, input }) => { + const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl }); + logger.debug(input, "sendgridConfigurationRouter.delete called"); + const existingConfiguration = await ctx.configurationService.getConfiguration(input); + if (!existingConfiguration) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Configuration not found", + }); + } + await ctx.configurationService.deleteConfiguration(input); + return null; + }), + updateOrCreateConfiguration: protectedWithConfigurationService + .meta({ requiredClientPermissions: ["MANAGE_APPS"] }) + .input(sendgridUpdateOrCreateConfigurationSchema) + .mutation(async ({ ctx, input }) => { + const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl }); + logger.debug(input, "sendgridConfigurationRouter.update or create called"); + + const { id } = input; + if (!id) { + return await ctx.configurationService.createConfiguration(input); + } else { + const existingConfiguration = await ctx.configurationService.getConfiguration({ id }); + if (!existingConfiguration) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Configuration not found", + }); + } + const configuration = { + id, + ...input, + events: existingConfiguration.events, + }; + await ctx.configurationService.updateConfiguration(configuration); + return configuration; + } + }), + getEventConfiguration: protectedWithConfigurationService + .meta({ requiredClientPermissions: ["MANAGE_APPS"] }) + .input(sendgridGetEventConfigurationInputSchema) + .query(async ({ ctx, input }) => { + const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl }); + + logger.debug(input, "sendgridConfigurationRouter.getEventConfiguration or create called"); + + const configuration = await ctx.configurationService.getConfiguration({ + id: input.configurationId, + }); + + if (!configuration) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Configuration not found", + }); + } + + const event = configuration.events.find((e) => e.eventType === input.eventType); + if (!event) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Event configuration not found", + }); + } + return event; + }), + updateEventConfiguration: protectedWithConfigurationService + .meta({ requiredClientPermissions: ["MANAGE_APPS"] }) + .input(sendgridUpdateEventConfigurationInputSchema) .mutation(async ({ ctx, input }) => { const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl }); - logger.debug(input, "sendgridConfigurationRouter.setAndReplace called with input"); + logger.debug(input, "sendgridConfigurationRouter.updateEventConfiguration or create called"); - const sendgridConfigurator = new PrivateMetadataSendgridConfigurator( - createSettingsManager(ctx.apiClient), - ctx.saleorApiUrl - ); + const configuration = await ctx.configurationService.getConfiguration({ + id: input.configurationId, + }); - await sendgridConfigurator.setConfig(input); + if (!configuration) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Configuration not found", + }); + } - return null; + const eventIndex = configuration.events.findIndex((e) => e.eventType === input.eventType); + configuration.events[eventIndex] = { + active: input.active, + eventType: input.eventType, + template: input.template, + }; + await ctx.configurationService.updateConfiguration(configuration); + return configuration; }), }); diff --git a/apps/emails-and-messages/src/modules/sendgrid/configuration/ui/fetch-templates.ts b/apps/emails-and-messages/src/modules/sendgrid/configuration/ui/fetch-templates.ts deleted file mode 100644 index 1c6ca95..0000000 --- a/apps/emails-and-messages/src/modules/sendgrid/configuration/ui/fetch-templates.ts +++ /dev/null @@ -1,41 +0,0 @@ -interface FetchTemplatesArgs { - apiKey?: string; -} - -export const fetchTemplates = - ({ apiKey }: FetchTemplatesArgs) => - async () => { - if (!apiKey) { - console.warn( - "The Sendgrid API key has not been set up yet. Skipping fetching available templates." - ); - return []; - } - const response = await fetch( - "https://api.sendgrid.com/v3/templates?generations=dynamic&page_size=18", - { - method: "GET", - headers: { - Authorization: `Bearer ${apiKey}`, - }, - } - ); - if (!response.ok) { - console.error("Could not fetch available Sendgrid templates"); - return []; - } - try { - const resJson = (await response.json()) as { - result?: { id: string; name: string }[]; - }; - const templates = - resJson.result?.map((r) => ({ - value: r.id, - label: r.name, - })) || []; - return templates; - } catch (e) { - console.error("Could not parse the response from Sendgrid", e); - return []; - } - }; diff --git a/apps/emails-and-messages/src/modules/sendgrid/configuration/ui/sendgrid-configuration-form.tsx b/apps/emails-and-messages/src/modules/sendgrid/configuration/ui/sendgrid-configuration-form.tsx index 569f9af..37cbb62 100644 --- a/apps/emails-and-messages/src/modules/sendgrid/configuration/ui/sendgrid-configuration-form.tsx +++ b/apps/emails-and-messages/src/modules/sendgrid/configuration/ui/sendgrid-configuration-form.tsx @@ -1,52 +1,146 @@ import { Controller, useForm } from "react-hook-form"; import { + Divider, FormControl, - FormControlLabel, InputLabel, - Switch, + MenuItem, + Select, TextField, TextFieldProps, Typography, } from "@material-ui/core"; -import { Button, makeStyles } from "@saleor/macaw-ui"; -import React, { useEffect } from "react"; +import { Button, makeStyles, SwitchSelector, SwitchSelectorButton } from "@saleor/macaw-ui"; +import React, { useEffect, useState } from "react"; import { SendgridConfiguration } from "../sendgrid-config"; -import { useQuery } from "@tanstack/react-query"; -import { TemplateSelectionField } from "./template-selection-field"; -import { fetchTemplates } from "./fetch-templates"; +import { trpcClient } from "../../../trpc/trpc-client"; +import { useAppBridge, actions } from "@saleor/app-sdk/app-bridge"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { fetchSenders } from "../../sendgrid-api"; -const useStyles = makeStyles({ +const useStyles = makeStyles((theme) => ({ field: { marginBottom: 20, }, - form: { - padding: 20, + editor: { + marginBottom: 20, }, -}); + preview: { + marginBottom: 20, + }, + sectionHeader: { + marginTop: 20, + }, +})); type Props = { - onSubmit(data: SendgridConfiguration): Promise; + onConfigurationSaved: () => void; initialData: SendgridConfiguration; configurationId?: string; }; export const SendgridConfigurationForm = (props: Props) => { - const { handleSubmit, control, reset } = useForm({ + const styles = useStyles(); + const { appBridge } = useAppBridge(); + const [senderId, setSenderId] = useState(undefined); + + const { handleSubmit, control, reset, setError, setValue } = useForm({ defaultValues: props.initialData, }); + const { data: sendersChoices, isLoading: isSendersChoicesLoading } = useQuery({ + queryKey: ["sendgridSenders"], + queryFn: fetchSenders({ apiKey: props.initialData.apiKey }), + enabled: !!props.initialData.apiKey?.length, + onSuccess(data) { + // we are not keeping senders ID in the database, so we need to find the ID of the sender + // configuration contains nickname and email set up in the Sendgrid account + if (data.length) { + const sender = data?.find((sender) => sender.from_email === props.initialData.senderEmail); + if (sender?.value) { + setSenderId(sender?.value); + } + } + }, + }); + + const queryClient = useQueryClient(); + + const { mutate: createOrUpdateConfiguration } = + trpcClient.sendgridConfiguration.updateOrCreateConfiguration.useMutation({ + onSuccess: async (data, variables) => { + await queryClient.cancelQueries({ + queryKey: ["sendgridConfiguration", "getConfigurations"], + }); + + // Optimistically update to the new value + queryClient.setQueryData>( + ["sendgridConfiguration", "getConfigurations", undefined], + (old) => { + if (old) { + const index = old.findIndex((c) => c.id === data.id); + // If thats an update, replace the old one + if (index !== -1) { + old[index] = data; + return [...old]; + } else { + return [...old, data]; + } + } else { + return [data]; + } + } + ); + + // Trigger refetch to make sure we have a fresh data + props.onConfigurationSaved(); + appBridge?.dispatch( + actions.Notification({ + title: "Configuration saved", + status: "success", + }) + ); + }, + onError(error) { + let isFieldErrorSet = false; + const fieldErrors = error.data?.zodError?.fieldErrors || {}; + for (const fieldName in fieldErrors) { + for (const message of fieldErrors[fieldName] || []) { + isFieldErrorSet = true; + setError(fieldName as keyof SendgridConfiguration, { + type: "manual", + message, + }); + } + } + const formErrors = error.data?.zodError?.formErrors || []; + const formErrorMessage = formErrors.length ? formErrors.join("\n") : undefined; + appBridge?.dispatch( + actions.Notification({ + title: "Could not save the configuration", + text: isFieldErrorSet ? "Submitted form contain errors" : "Error saving configuration", + apiMessage: formErrorMessage, + status: "error", + }) + ); + }, + }); + // when the configuration tab is changed, initialData change and form has to be updated useEffect(() => { reset(props.initialData); - }, [props.initialData, reset]); + }, [props.initialData, props.configurationId, reset]); - const { data: templateChoices, isLoading: isTemplateChoicesLoading } = useQuery({ - queryKey: ["sendgridTemplates"], - queryFn: fetchTemplates({ apiKey: props.initialData.apiKey }), - enabled: !!props.initialData?.apiKey.length, - }); - - const styles = useStyles(); + // fill sender email and name when sender is changed + useEffect(() => { + const sender = sendersChoices?.find((choice) => choice.value === senderId); + if (sender) { + setValue("senderName", sender.nickname); + setValue("senderEmail", sender.from_email); + } else { + setValue("senderName", undefined); + setValue("senderEmail", undefined); + } + }, [senderId, sendersChoices]); const CommonFieldProps: TextFieldProps = { className: styles.field, @@ -58,88 +152,80 @@ export const SendgridConfigurationForm = (props: Props) => { return (
{ - props.onSubmit(data); + createOrUpdateConfiguration({ + ...data, + }); })} - className={styles.form} > {isNewConfiguration ? ( - + Create a new configuration ) : ( - - Configuration {props.initialData?.configurationName} + + Configuration + {` ${props.initialData.configurationName} `} )} - { - return ( - onChange(val)} /> - } - label="Active" - /> - ); - }} - /> - { - return ( - onChange(val)} /> - } - label="Sandbox mode" - /> - ); - }} - /> + ( + render={({ field: { onChange, value }, fieldState: { error }, formState: { errors } }) => ( )} /> + ( - - )} - /> - ( - + name="active" + render={({ field: { value, name, onChange } }) => ( +
+ {/* TODO: fix types in the MacawUI */} + {/* @ts-ignore: MacawUI use wrong type for */} + + {[ + { label: "Active", value: true }, + { label: "Disabled", value: false }, + ].map((button) => ( + // @ts-ignore: MacawUI use wrong type for SwitchSelectorButton + onChange(button.value)} + activeTab={value.toString()} + key={button.label} + > + {button.label} + + ))} + +
)} /> + + + + + API configuration + + ( - - )} - /> - ( + render={({ field: { onChange, value }, fieldState: { error } }) => ( )} @@ -147,171 +233,107 @@ export const SendgridConfigurationForm = (props: Props) => { { - return ( - - Template for Order Created - - - ); - }} - /> - - ( - + name="sandboxMode" + render={({ field: { value, name, onChange } }) => ( +
+ {/* TODO: fix types in the MacawUI */} + {/* @ts-ignore: MacawUI use wrong type for */} + + {[ + { label: "Live", value: false }, + { label: "Sandbox", value: true }, + ].map((button) => ( + // @ts-ignore: MacawUI use wrong type for SwitchSelectorButton + onChange(button.value)} + activeTab={value.toString()} + key={button.label} + > + {button.label} + + ))} + +
)} /> - { - return ( - - Template for Order Fulfilled - + + {/* Sender can be chosen after the API key is saved in the configuration */} + {!isNewConfiguration && ( + <> + + Sender details + + + + Sender + + {!sendersChoices?.length && ( + + Please set up and verify senders in your Sendgrid dashboard. + + )} + + + ( + - - ); - }} - /> - - ( - - )} - /> - { - return ( - - Template for Order Confirmed - - - ); - }} - /> - - ( - ( + <> + + + )} /> - )} - /> - - { - return ( - - Template for Order Cancelled - - - ); - }} - /> - - ( - - )} - /> - - { - return ( - - Template for Order Fully Paid - - - ); - }} - /> - - ( - - )} - /> - - { - return ( - - Template for Invoice Sent - - - ); - }} - /> - + + )} diff --git a/apps/emails-and-messages/src/modules/sendgrid/configuration/ui/sendgrid-configuration-tab.tsx b/apps/emails-and-messages/src/modules/sendgrid/configuration/ui/sendgrid-configuration-tab.tsx index 9d75914..6db83c8 100644 --- a/apps/emails-and-messages/src/modules/sendgrid/configuration/ui/sendgrid-configuration-tab.tsx +++ b/apps/emails-and-messages/src/modules/sendgrid/configuration/ui/sendgrid-configuration-tab.tsx @@ -1,16 +1,18 @@ -import { CircularProgress, Paper } from "@material-ui/core"; -import React, { useEffect, useState } from "react"; -import { makeStyles } from "@saleor/macaw-ui"; -import { ConfigurationsList } from "../../../app-configuration/ui/configurations-list"; +import React from "react"; +import { IconButton, makeStyles } from "@saleor/macaw-ui"; import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge"; import { AppColumnsLayout } from "../../../ui/app-columns-layout"; import { trpcClient } from "../../../trpc/trpc-client"; -import { SendgridConfiguration } from "../sendgrid-config"; -import { - getDefaultEmptySendgridConfiguration, - SendgridConfigContainer, -} from "../sendgrid-config-container"; import { SendgridConfigurationForm } from "./sendgrid-configuration-form"; +import { getDefaultEmptyConfiguration } from "../sendgrid-config-container"; +import { NextRouter, useRouter } from "next/router"; +import SideMenu from "../../../app-configuration/ui/side-menu"; +import { SendgridConfiguration } from "../sendgrid-config"; +import { LoadingIndicator } from "../../../ui/loading-indicator"; +import { Add } from "@material-ui/icons"; +import { useQueryClient } from "@tanstack/react-query"; +import { sendgridUrls } from "../../urls"; +import { SendgridTemplatesCard } from "./sendgrid-templates-card"; const useStyles = makeStyles((theme) => { return { @@ -24,101 +26,149 @@ const useStyles = makeStyles((theme) => { display: "flex", flexDirection: "column", gap: 20, - }, - loaderContainer: { - margin: "50px auto", - display: "flex", - alignItems: "center", - justifyContent: "center", + maxWidth: 600, }, }; }); -type Configurations = { - name: string; - id: string; +interface SendgridConfigurationTabProps { + configurationId?: string; +} + +const navigateToFirstConfiguration = ( + router: NextRouter, + configurations?: SendgridConfiguration[] +) => { + if (!configurations || !configurations?.length) { + router.replace(sendgridUrls.configuration()); + return; + } + const firstConfigurationId = configurations[0]?.id; + if (firstConfigurationId) { + router.replace(sendgridUrls.configuration(firstConfigurationId)); + return; + } }; -export const SendgridConfigurationTab = () => { +export const SendgridConfigurationTab = ({ configurationId }: SendgridConfigurationTabProps) => { const styles = useStyles(); const { appBridge } = useAppBridge(); - const [configurationsListData, setConfigurationsListData] = useState([]); - const [activeConfigurationId, setActiveConfigurationId] = useState(); - const [initialData, setInitialData] = useState(); + const router = useRouter(); + const queryClient = useQueryClient(); const { - data: configurationData, - refetch: refetchConfig, - isLoading, - } = trpcClient.sendgridConfiguration.fetch.useQuery(undefined, { + data: configurations, + refetch: refetchConfigurations, + isLoading: configurationsIsLoading, + isFetching: configurationsIsFetching, + isRefetching: configurationsIsRefetching, + } = trpcClient.sendgridConfiguration.getConfigurations.useQuery(undefined, { onSuccess(data) { - if (!data.availableConfigurations) { - return; + if (!configurationId) { + console.log("no conf id! navigate to first"); + navigateToFirstConfiguration(router, data); } - const keys = Object.keys(data.availableConfigurations); - setConfigurationsListData( - keys.map((key) => ({ id: key, name: data.availableConfigurations[key].configurationName })) - ); - setActiveConfigurationId(keys[0]); }, }); - const { mutate, error: saveError } = trpcClient.sendgridConfiguration.setAndReplace.useMutation({ - onSuccess() { - refetchConfig(); - appBridge?.dispatch( - actions.Notification({ - title: "Success", - text: "Saved configuration", - status: "success", - }) - ); - }, - }); + const { mutate: deleteConfiguration } = + trpcClient.sendgridConfiguration.deleteConfiguration.useMutation({ + onError: (error) => { + appBridge?.dispatch( + actions.Notification({ + title: "Could not remove the configuration", + text: error.message, + status: "error", + }) + ); + }, + onSuccess: async (_data, variables) => { + await queryClient.cancelQueries({ + queryKey: ["sendgridConfiguration", "getConfigurations"], + }); + // remove value from the cache after the success + queryClient.setQueryData>( + ["sendgridConfiguration", "getConfigurations"], + (old) => { + if (old) { + const index = old.findIndex((c) => c.id === variables.id); + if (index !== -1) { + delete old[index]; + return [...old]; + } + } + } + ); - useEffect(() => { - setInitialData( - activeConfigurationId - ? SendgridConfigContainer.getSendgridConfigurationById(configurationData)( - activeConfigurationId - ) - : getDefaultEmptySendgridConfiguration() - ); - }, [activeConfigurationId, configurationData]); + // if we just deleted the configuration that was selected + // we have to update the URL + if (variables.id === configurationId) { + router.replace(sendgridUrls.configuration()); + } - if (isLoading) { - return ( -
- -
- ); + refetchConfigurations(); + appBridge?.dispatch( + actions.Notification({ + title: "Success", + text: "Removed successfully", + status: "success", + }) + ); + }, + }); + + if (configurationsIsLoading || configurationsIsFetching) { + return ; + } + + const configuration = configurations?.find((c) => c.id === configurationId?.toString()); + + if (configurationId && !configuration) { + return
Configuration not found
; } return ( - { + router.replace(sendgridUrls.configuration()); + }} + > + + + } + onClick={(id) => router.replace(sendgridUrls.configuration(id))} + onDelete={(id) => { + deleteConfiguration({ id }); + }} + items={configurations?.map((c) => ({ label: c.configurationName, id: c.id })) || []} />
- - {!!initialData && ( + {configurationsIsLoading || configurationsIsFetching ? ( + + ) : ( + <> { - const newConfig = - SendgridConfigContainer.setSendgridConfigurationById(configurationData)( - activeConfigurationId - )(data); - mutate(newConfig); - }} - initialData={initialData} - configurationId={activeConfigurationId} + onConfigurationSaved={() => refetchConfigurations()} + initialData={configuration || getDefaultEmptyConfiguration()} + configurationId={configurationId} /> - )} - {saveError && {saveError.message}} - + {!!configurationId && !!configuration && ( + { + refetchConfigurations(); + }} + /> + )} + + )}
); diff --git a/apps/emails-and-messages/src/modules/sendgrid/configuration/ui/sendgrid-event-configuration-form.tsx b/apps/emails-and-messages/src/modules/sendgrid/configuration/ui/sendgrid-event-configuration-form.tsx new file mode 100644 index 0000000..1d8e80a --- /dev/null +++ b/apps/emails-and-messages/src/modules/sendgrid/configuration/ui/sendgrid-event-configuration-form.tsx @@ -0,0 +1,227 @@ +import { Controller, useForm } from "react-hook-form"; +import { + FormControl, + Grid, + InputLabel, + MenuItem, + Select, + TextField, + TextFieldProps, + Typography, +} from "@material-ui/core"; +import { + BackSmallIcon, + Button, + IconButton, + makeStyles, + SwitchSelector, + SwitchSelectorButton, +} from "@saleor/macaw-ui"; +import React from "react"; +import { SendgridConfiguration, SendgridEventConfiguration } from "../sendgrid-config"; +import { + MessageEventTypes, + messageEventTypesLabels, +} from "../../../event-handlers/message-event-types"; +import { trpcClient } from "../../../trpc/trpc-client"; +import { useRouter } from "next/router"; +import { sendgridUrls } from "../../urls"; +import { useAppBridge, actions } from "@saleor/app-sdk/app-bridge"; +import { useQuery } from "@tanstack/react-query"; +import { fetchTemplates } from "../../sendgrid-api"; + +const useStyles = makeStyles((theme) => ({ + viewContainer: { + padding: theme.spacing(2), + }, + header: { + display: "flex", + justifyContent: "flex-start", + alignItems: "center", + gap: theme.spacing(2), + marginBottom: theme.spacing(2), + margin: "0 auto", + }, + previewHeader: { + display: "flex", + justifyContent: "space-between", + alignItems: "center", + gap: theme.spacing(1), + marginTop: theme.spacing(2), + marginBottom: theme.spacing(2), + }, + + field: { + marginBottom: theme.spacing(3), + }, + editor: { + marginBottom: theme.spacing(3), + }, + preview: { + marginBottom: theme.spacing(3), + }, + form: { + maxWidth: 800, + }, +})); + +type EventConfigurationFormProps = { + initialData: SendgridEventConfiguration; + configurationId: string; + eventType: MessageEventTypes; + configuration: SendgridConfiguration; +}; + +export const EventConfigurationForm = ({ + initialData, + configurationId, + eventType, + configuration, +}: EventConfigurationFormProps) => { + const router = useRouter(); + const { appBridge } = useAppBridge(); + const { handleSubmit, control, getValues, setError } = useForm({ + defaultValues: initialData, + }); + + const styles = useStyles(); + + const { data: templateChoices, isLoading: isTemplateChoicesLoading } = useQuery({ + queryKey: ["sendgridTemplates"], + queryFn: fetchTemplates({ apiKey: configuration.apiKey }), + enabled: !!configuration.apiKey?.length, + }); + + const CommonFieldProps: TextFieldProps = { + className: styles.field, + fullWidth: true, + }; + + const { mutate: updateEventConfiguration } = + trpcClient.sendgridConfiguration.updateEventConfiguration.useMutation({ + onSuccess: (data) => { + appBridge?.dispatch( + actions.Notification({ + title: "Configuration saved", + status: "success", + }) + ); + }, + onError: (error) => { + let isFieldErrorSet = false; + const fieldErrors = error.data?.zodError?.fieldErrors || {}; + for (const fieldName in fieldErrors) { + for (const message of fieldErrors[fieldName] || []) { + isFieldErrorSet = true; + setError(fieldName as keyof SendgridEventConfiguration, { + type: "manual", + message, + }); + } + } + const formErrors = error.data?.zodError?.formErrors || []; + const formErrorMessage = formErrors.length ? formErrors.join("\n") : undefined; + appBridge?.dispatch( + actions.Notification({ + title: "Could not save the configuration", + text: isFieldErrorSet ? "Submitted form contain errors" : "Error saving configuration", + apiMessage: formErrorMessage, + status: "error", + }) + ); + }, + }); + + return ( +
+
+ { + router.push(sendgridUrls.configuration(configurationId)); + }} + > + + + + {messageEventTypesLabels[eventType]} event configuration + +
+ + + { + updateEventConfiguration({ ...data, configurationId }); + })} + className={styles.form} + > + ( +
+ {/* TODO: fix types in the MacawUI */} + {/* @ts-ignore: MacawUI use wrong type for */} + + {[ + { label: "Active", value: true }, + { label: "Disabled", value: false }, + ].map((button) => ( + // @ts-ignore: MacawUI use wrong type for SwitchSelectorButton + onChange(button.value)} + activeTab={value.toString()} + key={button.label} + > + {button.label} + + ))} + +
+ )} + /> + + { + return ( + + Template + + {!templateChoices?.length && ( + + No templates found in your account. Visit Sendgrid dashboard and create one. + + )} + + ); + }} + /> + + + +
+
+
+ ); +}; diff --git a/apps/emails-and-messages/src/modules/sendgrid/configuration/ui/sendgrid-templates-card.tsx b/apps/emails-and-messages/src/modules/sendgrid/configuration/ui/sendgrid-templates-card.tsx new file mode 100644 index 0000000..4066db8 --- /dev/null +++ b/apps/emails-and-messages/src/modules/sendgrid/configuration/ui/sendgrid-templates-card.tsx @@ -0,0 +1,135 @@ +import { Divider, Paper, Typography } from "@material-ui/core"; +import React from "react"; +import { + EditIcon, + IconButton, + List, + ListHeader, + ListItem, + ListItemCell, + makeStyles, + SwitchSelector, + SwitchSelectorButton, +} from "@saleor/macaw-ui"; +import { useRouter } from "next/router"; +import { messageEventTypesLabels } from "../../../event-handlers/message-event-types"; +import { trpcClient } from "../../../trpc/trpc-client"; +import { useAppBridge, actions } from "@saleor/app-sdk/app-bridge"; +import { SendgridConfiguration } from "../sendgrid-config"; +import { sendgridUrls } from "../../urls"; + +const useStyles = makeStyles((theme) => { + return { + spaceBetween: { + display: "flex", + justifyContent: "space-between", + alignItems: "center", + }, + rowActions: { + display: "flex", + justifyContent: "flex-end", + gap: theme.spacing(1), + }, + tableRow: { + minHeight: "48px", + "&::after": { + display: "none", + }, + }, + }; +}); + +interface SendgridTemplatesCardProps { + configurationId: string; + configuration: SendgridConfiguration; + onEventChanged: () => void; +} + +export const SendgridTemplatesCard = ({ + configurationId, + configuration, + onEventChanged, +}: SendgridTemplatesCardProps) => { + const classes = useStyles(); + const router = useRouter(); + const { appBridge } = useAppBridge(); + + const { mutate: updateEventConfiguration } = + trpcClient.sendgridConfiguration.updateEventConfiguration.useMutation({ + onSuccess(_data, variables) { + onEventChanged(); + appBridge?.dispatch( + actions.Notification({ + title: variables.active ? "Event enabled" : "Event disabled", + status: "success", + }) + ); + }, + }); + + return ( + + + + Supported events and templates + + + + + {configuration.events.map((eventConfiguration) => ( + + + +
+ {messageEventTypesLabels[eventConfiguration.eventType]} +
+ {/* TODO: fix types in the MacawUI */} + {/* @ts-ignore: MacawUI use wrong type for */} + + {[ + { label: "Active", value: true }, + { label: "Disabled", value: false }, + ].map((button) => ( + // @ts-ignore: MacawUI use wrong type for SwitchSelectorButton + { + updateEventConfiguration({ + configurationId, + ...eventConfiguration, + active: button.value, + }); + }} + activeTab={eventConfiguration.active.toString()} + key={button.label} + > + {button.label} + + ))} + + { + event.stopPropagation(); + event.preventDefault(); + router.push( + sendgridUrls.eventConfiguration( + configurationId, + eventConfiguration.eventType + ) + ); + }} + > + + +
+
+
+
+ +
+ ))} +
+
+ ); +}; diff --git a/apps/emails-and-messages/src/modules/sendgrid/get-sendgrid-settings.ts b/apps/emails-and-messages/src/modules/sendgrid/get-sendgrid-settings.ts deleted file mode 100644 index 273dbbe..0000000 --- a/apps/emails-and-messages/src/modules/sendgrid/get-sendgrid-settings.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { AuthData } from "@saleor/app-sdk/APL"; -import { appRouter } from "../trpc/trpc-app-router"; -import { logger as pinoLogger } from "../../lib/logger"; - -interface GetSendgridSettingsArgs { - authData: AuthData; - channel: string; -} - -export const getSendgridSettings = async ({ authData, channel }: GetSendgridSettingsArgs) => { - const logger = pinoLogger.child({ - fn: "getMjmlSettings", - channel, - }); - const caller = appRouter.createCaller({ - appId: authData.appId, - saleorApiUrl: authData.saleorApiUrl, - token: authData.token, - ssr: true, - }); - - const sendgridConfigurations = await caller.sendgridConfiguration.fetch(); - const appConfigurations = await caller.appConfiguration.fetch(); - - const channelAppConfiguration = appConfigurations?.configurationsPerChannel[channel]; - if (!channelAppConfiguration) { - logger.warn("App has no configuration for this channel"); - return; - } - - if (!channelAppConfiguration.active) { - logger.warn("App configuration is not active for this channel"); - return; - } - - const sendgridConfigurationId = channelAppConfiguration.sendgridConfigurationId; - if (!sendgridConfigurationId?.length) { - logger.warn("Sendgrid configuration has not been chosen for this channel"); - return; - } - - const configuration = sendgridConfigurations?.availableConfigurations[sendgridConfigurationId]; - if (!configuration) { - logger.warn(`The Sendgrid configuration with id ${sendgridConfigurationId} does not exist`); - return; - } - - if (!configuration.active) { - logger.warn(`The Sendgrid configuration ${configuration.configurationName} is not active`); - return; - } - - return configuration; -}; diff --git a/apps/emails-and-messages/src/modules/sendgrid/send-sendgrid.ts b/apps/emails-and-messages/src/modules/sendgrid/send-sendgrid.ts index 22631e5..c692723 100644 --- a/apps/emails-and-messages/src/modules/sendgrid/send-sendgrid.ts +++ b/apps/emails-and-messages/src/modules/sendgrid/send-sendgrid.ts @@ -1,16 +1,13 @@ import { logger as pinoLogger } from "../../lib/logger"; -import { AuthData } from "@saleor/app-sdk/APL"; import { SendgridConfiguration } from "./configuration/sendgrid-config"; -import { getSendgridSettings } from "./get-sendgrid-settings"; import { MailService } from "@sendgrid/mail"; import { MessageEventTypes } from "../event-handlers/message-event-types"; interface SendSendgridArgs { - authData: AuthData; - channel: string; recipientEmail: string; event: MessageEventTypes; payload: any; + sendgridConfiguration: SendgridConfiguration; } export interface EmailServiceResponse { @@ -20,65 +17,55 @@ export interface EmailServiceResponse { }[]; } -const eventMapping = (event: SendSendgridArgs["event"], settings: SendgridConfiguration) => { - switch (event) { - case "ORDER_CREATED": - return { - templateId: settings.templateOrderCreatedTemplate, - subject: settings.templateOrderCreatedSubject || "Order created", - }; - case "ORDER_FULFILLED": - return { - templateId: settings.templateOrderFulfilledTemplate, - subject: settings.templateOrderFulfilledSubject || "Order fulfilled", - }; - case "ORDER_CONFIRMED": - return { - template: settings.templateOrderConfirmedTemplate, - subject: settings.templateOrderConfirmedSubject || "Order confirmed", - }; - case "ORDER_CANCELLED": - return { - template: settings.templateOrderCancelledTemplate, - subject: settings.templateOrderCancelledSubject || "Order cancelled", - }; - case "ORDER_FULLY_PAID": - return { - template: settings.templateOrderFullyPaidTemplate, - subject: settings.templateOrderFullyPaidSubject || "Order fully paid", - }; - case "INVOICE_SENT": - return { - template: settings.templateInvoiceSentTemplate, - subject: settings.templateInvoiceSentSubject || "Invoice sent", - }; - } -}; - export const sendSendgrid = async ({ - authData, - channel, payload, recipientEmail, event, + sendgridConfiguration, }: SendSendgridArgs) => { const logger = pinoLogger.child({ fn: "sendSendgrid", event, }); + if (!sendgridConfiguration.senderEmail) { + logger.debug("Sender email has not been specified, skipping"); + return { + errors: [ + { + message: "Sender email has not been set up", + }, + ], + }; + } - const settings = await getSendgridSettings({ authData, channel }); + const eventSettings = sendgridConfiguration.events.find((e) => e.eventType === event); + if (!eventSettings) { + logger.debug("No active settings for this event, skipping"); + return { + errors: [ + { + message: "No active settings for this event", + }, + ], + }; + } - if (!settings?.active) { - logger.debug("Sendgrid is not active, skipping"); - return; + if (!eventSettings.active) { + logger.debug("Event settings are not active, skipping"); + return { + errors: [ + { + message: "Event settings are not active", + }, + ], + }; } logger.debug("Sending an email using Sendgrid"); - const { templateId, subject } = eventMapping(event, settings); + const { template } = eventSettings; - if (!templateId) { + if (!template) { logger.error("No template defined in the settings"); return { errors: [{ message: `No template specified for the event ${event}` }], @@ -87,35 +74,21 @@ export const sendSendgrid = async ({ try { const mailService = new MailService(); - mailService.setApiKey(settings.apiKey); + mailService.setApiKey(sendgridConfiguration.apiKey); await mailService.send({ mailSettings: { sandboxMode: { - enable: settings.sandboxMode, + enable: sendgridConfiguration.sandboxMode, }, }, from: { - email: settings.senderEmail, + name: sendgridConfiguration.senderName, + email: sendgridConfiguration.senderEmail, }, - to: { - email: recipientEmail, - }, - personalizations: [ - { - from: { - email: settings.senderEmail, - }, - to: [ - { - email: recipientEmail, - }, - ], - subject, - dynamicTemplateData: payload, - }, - ], - templateId, + to: recipientEmail, + dynamicTemplateData: payload, + templateId: template, }); logger.debug("Email has been send"); } catch (error) { diff --git a/apps/emails-and-messages/src/modules/sendgrid/sendgrid-api.ts b/apps/emails-and-messages/src/modules/sendgrid/sendgrid-api.ts new file mode 100644 index 0000000..5f2d821 --- /dev/null +++ b/apps/emails-and-messages/src/modules/sendgrid/sendgrid-api.ts @@ -0,0 +1,78 @@ +interface FetchTemplatesArgs { + apiKey?: string; +} + +export const fetchTemplates = + ({ apiKey }: FetchTemplatesArgs) => + async () => { + if (!apiKey) { + console.warn( + "The Sendgrid API key has not been set up yet. Skipping fetching available templates." + ); + return []; + } + const response = await fetch( + "https://api.sendgrid.com/v3/templates?generations=dynamic&page_size=18", + { + method: "GET", + headers: { + Authorization: `Bearer ${apiKey}`, + }, + } + ); + if (!response.ok) { + console.error("Could not fetch available Sendgrid templates"); + return []; + } + try { + const resJson = (await response.json()) as { + result?: { id: string; name: string }[]; + }; + const templates = + resJson.result?.map((r) => ({ + value: r.id, + label: r.name, + })) || []; + return templates; + } catch (e) { + console.error("Could not parse the response from Sendgrid", e); + return []; + } + }; + +export const fetchSenders = + ({ apiKey }: FetchTemplatesArgs) => + async () => { + if (!apiKey) { + console.warn( + "The Sendgrid API key has not been set up yet. Skipping fetching available senders ." + ); + return []; + } + const response = await fetch("https://api.sendgrid.com/v3/verified_senders?page_size=18", { + method: "GET", + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }); + if (!response.ok) { + console.error("Could not fetch available Sendgrid senders"); + return []; + } + try { + const resJson = (await response.json()) as { + results?: { id: string; nickname: string; from_email: string }[]; + }; + const senders = + resJson.results?.map((r) => ({ + value: r.id, + label: `${r.nickname} (${r.from_email})`, + nickname: r.nickname, + from_email: r.from_email, + })) || []; + return senders; + } catch (e) { + console.error("Could not parse the response from Sendgrid", e); + return []; + } + }; diff --git a/apps/emails-and-messages/src/modules/sendgrid/urls.ts b/apps/emails-and-messages/src/modules/sendgrid/urls.ts new file mode 100644 index 0000000..81308f4 --- /dev/null +++ b/apps/emails-and-messages/src/modules/sendgrid/urls.ts @@ -0,0 +1,8 @@ +import { MessageEventTypes } from "../event-handlers/message-event-types"; + +export const sendgridUrls = { + configuration: (id?: string) => + !id ? "/configuration/sendgrid" : `/configuration/sendgrid/${id}`, + eventConfiguration: (id: string, event: MessageEventTypes) => + `/configuration/sendgrid/${id}/event/${event}`, +}; diff --git a/apps/emails-and-messages/src/modules/ui/configuration-page-base-layout.tsx b/apps/emails-and-messages/src/modules/ui/configuration-page-base-layout.tsx index 2bca2fd..06b9b19 100644 --- a/apps/emails-and-messages/src/modules/ui/configuration-page-base-layout.tsx +++ b/apps/emails-and-messages/src/modules/ui/configuration-page-base-layout.tsx @@ -24,9 +24,8 @@ export const ConfigurationPageBaseLayout = ({ children }: Props) => { { key: "mjml", label: "MJML", url: "/configuration/mjml" }, { key: "sendgrid", - label: "Sendgrid (Coming soon!)", + label: "Sendgrid", url: "/configuration/sendgrid", - disabled: true, }, ]; @@ -42,7 +41,7 @@ export const ConfigurationPageBaseLayout = ({ children }: Props) => {
{tabs.map((tab) => ( - + ))} {children} diff --git a/apps/emails-and-messages/src/pages/configuration/sendgrid/[[...configurationId]].tsx b/apps/emails-and-messages/src/pages/configuration/sendgrid/[[...configurationId]].tsx new file mode 100644 index 0000000..203aa0b --- /dev/null +++ b/apps/emails-and-messages/src/pages/configuration/sendgrid/[[...configurationId]].tsx @@ -0,0 +1,19 @@ +import { NextPage } from "next"; +import React from "react"; +import { useRouter } from "next/router"; +import { ConfigurationPageBaseLayout } from "../../../modules/ui/configuration-page-base-layout"; +import { SendgridConfigurationTab } from "../../../modules/sendgrid/configuration/ui/sendgrid-configuration-tab"; + +const SendgridConfigurationPage: NextPage = () => { + const router = useRouter(); + const configurationId = router.query.configurationId + ? router.query.configurationId[0] // optional routes are passed as an array + : undefined; + return ( + + + + ); +}; + +export default SendgridConfigurationPage; diff --git a/apps/emails-and-messages/src/pages/configuration/sendgrid/[configurationId]/event/[eventType].tsx b/apps/emails-and-messages/src/pages/configuration/sendgrid/[configurationId]/event/[eventType].tsx new file mode 100644 index 0000000..122c0e7 --- /dev/null +++ b/apps/emails-and-messages/src/pages/configuration/sendgrid/[configurationId]/event/[eventType].tsx @@ -0,0 +1,77 @@ +import { NextPage } from "next"; +import React from "react"; +import { useRouter } from "next/router"; +import { trpcClient } from "../../../../../modules/trpc/trpc-client"; + +import { parseMessageEventType } from "../../../../../modules/event-handlers/parse-message-event-type"; +import { ConfigurationPageBaseLayout } from "../../../../../modules/ui/configuration-page-base-layout"; +import { LoadingIndicator } from "../../../../../modules/ui/loading-indicator"; +import { EventConfigurationForm } from "../../../../../modules/sendgrid/configuration/ui/sendgrid-event-configuration-form"; + +const EventConfigurationPage: NextPage = () => { + const router = useRouter(); + + const configurationId = router.query.configurationId as string; + const eventTypeFromQuery = router.query.eventType as string | undefined; + const eventType = parseMessageEventType(eventTypeFromQuery); + + const { + data: eventConfiguration, + isError, + isFetched, + isLoading, + } = trpcClient.sendgridConfiguration.getEventConfiguration.useQuery( + { + configurationId, + // if event type is not valid, it calling the query will not be enabled + // so we can safely cast it + eventType: eventType!, + }, + { + enabled: !!configurationId && !!eventType, + } + ); + + const { data: configuration } = trpcClient.sendgridConfiguration.getConfiguration.useQuery( + { + id: configurationId, + }, + { + enabled: !!configurationId, + } + ); + + if (!eventType || !configurationId) { + return <>Error: no event type or configuration id; + } + if (isLoading) { + return ( + + + + ); + } + + if (isError) { + return ( + <> + Error: could not load the config: fetched: {isFetched} is error {isError} + + ); + } + if (!eventConfiguration || !configuration) { + return <>Error: no configuration with given id; + } + return ( + + + + ); +}; + +export default EventConfigurationPage;