Update the sendgrid support (#321)

* Update the sendgrid support

* Add changeset
This commit is contained in:
Krzysztof Wolski 2023-03-24 15:33:48 +01:00 committed by GitHub
parent f58043f72b
commit 14ac6144c0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 1401 additions and 619 deletions

View file

@ -0,0 +1,5 @@
---
"saleor-app-emails-and-messages": minor
---
Enable Sendgrid support

View file

@ -59,18 +59,13 @@ export const ChannelsConfigurationTab = () => {
}, [mjmlConfigurations]); }, [mjmlConfigurations]);
const { data: sendgridConfigurations, isLoading: isSendgridQueryLoading } = const { data: sendgridConfigurations, isLoading: isSendgridQueryLoading } =
trpcClient.sendgridConfiguration.fetch.useQuery(); trpcClient.sendgridConfiguration.getConfigurations.useQuery({});
const sendgridConfigurationsListData = useMemo(() => { const sendgridConfigurationsListData = useMemo(() => {
if (!sendgridConfigurations) {
return [];
}
const keys = Object.keys(sendgridConfigurations.availableConfigurations ?? {}) || [];
return ( return (
keys.map((key) => ({ sendgridConfigurations?.map((configuration) => ({
value: key, value: configuration.id,
label: sendgridConfigurations.availableConfigurations[key].configurationName, label: configuration.configurationName,
})) ?? [] })) ?? []
); );
}, [sendgridConfigurations]); }, [sendgridConfigurations]);

View file

@ -4,6 +4,7 @@ import { logger as pinoLogger } from "../../lib/logger";
import { AppConfigurationService } from "../app-configuration/get-app-configuration.service"; import { AppConfigurationService } from "../app-configuration/get-app-configuration.service";
import { MjmlConfigurationService } from "../mjml/configuration/get-mjml-configuration.service"; import { MjmlConfigurationService } from "../mjml/configuration/get-mjml-configuration.service";
import { sendMjml } from "../mjml/send-mjml"; import { sendMjml } from "../mjml/send-mjml";
import { SendgridConfigurationService } from "../sendgrid/configuration/get-sendgrid-configuration.service";
import { sendSendgrid } from "../sendgrid/send-sendgrid"; import { sendSendgrid } from "../sendgrid/send-sendgrid";
import { MessageEventTypes } from "./message-event-types"; 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) { if (channelAppConfiguration.sendgridConfigurationId) {
logger.error("Sending message with Sendgrid has failed"); logger.debug("Channel has assigned Sendgrid configuration");
logger.error(sendgridStatus?.errors);
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);
}
}
} }
}; };

View file

@ -1,36 +1,107 @@
import { PrivateMetadataSendgridConfigurator } from "./sendgrid-configurator"; import { SendgridConfigurator, PrivateMetadataSendgridConfigurator } from "./sendgrid-configurator";
import { Client } from "urql"; import { Client } from "urql";
import { logger as pinoLogger } from "../../../lib/logger"; 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"; import { createSettingsManager } from "../../../lib/metadata-manager";
// todo test const logger = pinoLogger.child({
export class GetSendgridConfigurationService { service: "SendgridConfigurationService",
constructor( });
private settings: {
apiClient: Client;
saleorApiUrl: string;
}
) {}
async getConfiguration() { export class SendgridConfigurationService {
const logger = pinoLogger.child({ private configurationData?: SendgridConfig;
service: "GetSendgridConfigurationService", private metadataConfigurator: SendgridConfigurator;
saleorApiUrl: this.settings.saleorApiUrl,
});
const { saleorApiUrl, apiClient } = this.settings; constructor(args: { apiClient: Client; saleorApiUrl: string; initialData?: SendgridConfig }) {
this.metadataConfigurator = new PrivateMetadataSendgridConfigurator(
const sendgridConfigurator = new PrivateMetadataSendgridConfigurator( createSettingsManager(args.apiClient),
createSettingsManager(apiClient), args.saleorApiUrl
saleorApiUrl
); );
const savedSendgridConfig = (await sendgridConfigurator.getConfig()) ?? null; if (args.initialData) {
this.configurationData = args.initialData;
logger.debug(savedSendgridConfig, "Retrieved sendgrid config from Metadata. Will return it");
if (savedSendgridConfig) {
return savedSendgridConfig;
} }
} }
// 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<SendgridConfiguration, "id" | "events">) {
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);
}
} }

View file

@ -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 => { export const generateSendgridConfigurationId = () => Date.now().toString();
const defaultConfig = {
active: false, export const getDefaultEventsConfiguration = (): SendgridConfiguration["events"] =>
messageEventTypes.map((eventType) => ({
active: true,
eventType: eventType,
template: "",
}));
export const getDefaultEmptyConfiguration = (): SendgridConfiguration => {
const defaultConfig: SendgridConfiguration = {
id: "",
active: true,
configurationName: "", configurationName: "",
sandboxMode: false, senderName: undefined,
senderName: "", senderEmail: undefined,
senderEmail: "",
apiKey: "", apiKey: "",
templateInvoiceSentSubject: "Invoice sent", sandboxMode: false,
templateInvoiceSentTemplate: "", events: getDefaultEventsConfiguration(),
templateOrderCancelledSubject: "Order Cancelled",
templateOrderCancelledTemplate: "",
templateOrderConfirmedSubject: "Order Confirmed",
templateOrderConfirmedTemplate: "",
templateOrderFullyPaidSubject: "Order Fully Paid",
templateOrderFullyPaidTemplate: "",
templateOrderCreatedSubject: "Order created",
templateOrderCreatedTemplate: "",
templateOrderFulfilledSubject: "Order fulfilled",
templateOrderFulfilledTemplate: "",
}; };
return defaultConfig; return defaultConfig;
}; };
const getSendgridConfigurationById = interface GetConfigurationArgs {
(sendgridConfig: SendgridConfig | null | undefined) => (configurationId?: string) => { id: string;
if (!configurationId?.length) { }
return getDefaultEmptySendgridConfiguration();
const getConfiguration =
(sendgridConfigRoot: SendgridConfigurationRoot | null | undefined) =>
({ id }: GetConfigurationArgs) => {
if (!sendgridConfigRoot || !sendgridConfigRoot.configurations) {
return;
} }
const existingConfig = sendgridConfig?.availableConfigurations[configurationId];
if (!existingConfig) { return sendgridConfigRoot.configurations.find((c) => c.id === id);
return getDefaultEmptySendgridConfiguration();
}
return existingConfig;
}; };
const setSendgridConfigurationById = export interface FilterConfigurationsArgs {
(sendgridConfig: SendgridConfig | null | undefined) => ids?: string[];
(configurationId: string | undefined) => active?: boolean;
(sendgridConfiguration: SendgridConfiguration) => { }
const sendgridConfigNormalized = structuredClone(sendgridConfig) ?? {
availableConfigurations: {}, 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<SendgridConfiguration, "id" | "events">) => {
const sendgridConfigNormalized = structuredClone(sendgridConfigRoot) ?? { configurations: [] };
// for creating a new configurations, the ID has to be generated // for creating a new configurations, the ID has to be generated
const id = configurationId || Date.now(); const newConfiguration = {
sendgridConfigNormalized.availableConfigurations[id] ??= getDefaultEmptySendgridConfiguration(); ...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; return sendgridConfigNormalized;
}; };
export const SendgridConfigContainer = { export const SendgridConfigContainer = {
getSendgridConfigurationById, createConfiguration,
setSendgridConfigurationById, getConfiguration,
updateConfiguration,
deleteConfiguration,
getConfigurations,
}; };

View file

@ -1,26 +1,51 @@
import { z } from "zod"; import { z } from "zod";
import { messageEventTypes } from "../../event-handlers/message-event-types";
export const sendgridConfigInputSchema = z.object({ export const sendgridConfigurationEventObjectSchema = z.object({
availableConfigurations: z.record( active: z.boolean(),
z.object({ eventType: z.enum(messageEventTypes),
active: z.boolean(), template: z.string().min(1),
configurationName: z.string().min(1), });
sandboxMode: z.boolean(),
senderName: z.string().min(0), export const sendgridConfigurationBaseObjectSchema = z.object({
senderEmail: z.string().email(), active: z.boolean(),
apiKey: z.string().min(0), configurationName: z.string().min(1),
templateInvoiceSentSubject: z.string(), sandboxMode: z.boolean(),
templateInvoiceSentTemplate: z.string(), apiKey: z.string().min(1),
templateOrderCancelledSubject: z.string(), senderName: z.string().min(1).optional(),
templateOrderCancelledTemplate: z.string(), senderEmail: z.string().email().min(5).optional(),
templateOrderConfirmedSubject: z.string(), });
templateOrderConfirmedTemplate: z.string(),
templateOrderFullyPaidSubject: z.string(), export const sendgridCreateConfigurationSchema = sendgridConfigurationBaseObjectSchema.omit({
templateOrderFullyPaidTemplate: z.string(), senderEmail: true,
templateOrderCreatedSubject: z.string(), senderName: true,
templateOrderCreatedTemplate: z.string(), });
templateOrderFulfilledSubject: z.string(), export const sendgridUpdateOrCreateConfigurationSchema =
templateOrderFulfilledTemplate: z.string(), 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),
}); });

View file

@ -1,26 +1,22 @@
import { MessageEventTypes } from "../../event-handlers/message-event-types";
export interface SendgridEventConfiguration {
active: boolean;
eventType: MessageEventTypes;
template: string;
}
export interface SendgridConfiguration { export interface SendgridConfiguration {
id: string;
active: boolean; active: boolean;
configurationName: string; configurationName: string;
sandboxMode: boolean; sandboxMode: boolean;
senderName: string; senderName?: string;
senderEmail: string; senderEmail?: string;
apiKey: string; apiKey: string;
templateInvoiceSentSubject: string; events: SendgridEventConfiguration[];
templateInvoiceSentTemplate: string;
templateOrderCancelledSubject: string;
templateOrderCancelledTemplate: string;
templateOrderConfirmedSubject: string;
templateOrderConfirmedTemplate: string;
templateOrderFullyPaidSubject: string;
templateOrderFullyPaidTemplate: string;
templateOrderCreatedSubject: string;
templateOrderCreatedTemplate: string;
templateOrderFulfilledSubject: string;
templateOrderFulfilledTemplate: string;
} }
export type SendgridConfigurationsIdMap = Record<string, SendgridConfiguration>;
export type SendgridConfig = { export type SendgridConfig = {
availableConfigurations: SendgridConfigurationsIdMap; configurations: SendgridConfiguration[];
}; };

View file

@ -1,37 +1,159 @@
import { PrivateMetadataSendgridConfigurator } from "./sendgrid-configurator";
import { logger as pinoLogger } from "../../../lib/logger"; import { logger as pinoLogger } from "../../../lib/logger";
import { sendgridConfigInputSchema } from "./sendgrid-config-input-schema"; import {
import { GetSendgridConfigurationService } from "./get-sendgrid-configuration.service"; 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 { router } from "../../trpc/trpc-server";
import { protectedClientProcedure } from "../../trpc/protected-client-procedure"; 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({ export const sendgridConfigurationRouter = router({
fetch: protectedClientProcedure.query(async ({ ctx, input }) => { fetch: protectedWithConfigurationService.query(async ({ ctx }) => {
const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl }); const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
logger.debug("sendgridConfigurationRouter.fetch called"); logger.debug("sendgridConfigurationRouter.fetch called");
return ctx.configurationService.getConfigurationRoot();
return new GetSendgridConfigurationService({
apiClient: ctx.apiClient,
saleorApiUrl: ctx.saleorApiUrl,
}).getConfiguration();
}), }),
setAndReplace: protectedClientProcedure getConfiguration: protectedWithConfigurationService
.meta({ requiredClientPermissions: ["MANAGE_APPS"] }) .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 }) => { .mutation(async ({ ctx, input }) => {
const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl }); 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( const configuration = await ctx.configurationService.getConfiguration({
createSettingsManager(ctx.apiClient), id: input.configurationId,
ctx.saleorApiUrl });
);
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;
}), }),
}); });

View file

@ -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 [];
}
};

View file

@ -1,52 +1,146 @@
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { import {
Divider,
FormControl, FormControl,
FormControlLabel,
InputLabel, InputLabel,
Switch, MenuItem,
Select,
TextField, TextField,
TextFieldProps, TextFieldProps,
Typography, Typography,
} from "@material-ui/core"; } from "@material-ui/core";
import { Button, makeStyles } from "@saleor/macaw-ui"; import { Button, makeStyles, SwitchSelector, SwitchSelectorButton } from "@saleor/macaw-ui";
import React, { useEffect } from "react"; import React, { useEffect, useState } from "react";
import { SendgridConfiguration } from "../sendgrid-config"; import { SendgridConfiguration } from "../sendgrid-config";
import { useQuery } from "@tanstack/react-query"; import { trpcClient } from "../../../trpc/trpc-client";
import { TemplateSelectionField } from "./template-selection-field"; import { useAppBridge, actions } from "@saleor/app-sdk/app-bridge";
import { fetchTemplates } from "./fetch-templates"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { fetchSenders } from "../../sendgrid-api";
const useStyles = makeStyles({ const useStyles = makeStyles((theme) => ({
field: { field: {
marginBottom: 20, marginBottom: 20,
}, },
form: { editor: {
padding: 20, marginBottom: 20,
}, },
}); preview: {
marginBottom: 20,
},
sectionHeader: {
marginTop: 20,
},
}));
type Props = { type Props = {
onSubmit(data: SendgridConfiguration): Promise<void>; onConfigurationSaved: () => void;
initialData: SendgridConfiguration; initialData: SendgridConfiguration;
configurationId?: string; configurationId?: string;
}; };
export const SendgridConfigurationForm = (props: Props) => { export const SendgridConfigurationForm = (props: Props) => {
const { handleSubmit, control, reset } = useForm<SendgridConfiguration>({ const styles = useStyles();
const { appBridge } = useAppBridge();
const [senderId, setSenderId] = useState<string | undefined>(undefined);
const { handleSubmit, control, reset, setError, setValue } = useForm<SendgridConfiguration>({
defaultValues: props.initialData, 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<Array<SendgridConfiguration>>(
["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 // when the configuration tab is changed, initialData change and form has to be updated
useEffect(() => { useEffect(() => {
reset(props.initialData); reset(props.initialData);
}, [props.initialData, reset]); }, [props.initialData, props.configurationId, reset]);
const { data: templateChoices, isLoading: isTemplateChoicesLoading } = useQuery({ // fill sender email and name when sender is changed
queryKey: ["sendgridTemplates"], useEffect(() => {
queryFn: fetchTemplates({ apiKey: props.initialData.apiKey }), const sender = sendersChoices?.find((choice) => choice.value === senderId);
enabled: !!props.initialData?.apiKey.length, if (sender) {
}); setValue("senderName", sender.nickname);
setValue("senderEmail", sender.from_email);
const styles = useStyles(); } else {
setValue("senderName", undefined);
setValue("senderEmail", undefined);
}
}, [senderId, sendersChoices]);
const CommonFieldProps: TextFieldProps = { const CommonFieldProps: TextFieldProps = {
className: styles.field, className: styles.field,
@ -58,88 +152,80 @@ export const SendgridConfigurationForm = (props: Props) => {
return ( return (
<form <form
onSubmit={handleSubmit((data, event) => { onSubmit={handleSubmit((data, event) => {
props.onSubmit(data); createOrUpdateConfiguration({
...data,
});
})} })}
className={styles.form}
> >
{isNewConfiguration ? ( {isNewConfiguration ? (
<Typography variant="h4" paragraph> <Typography variant="h2" paragraph>
Create a new configuration Create a new configuration
</Typography> </Typography>
) : ( ) : (
<Typography variant="h4" paragraph> <Typography variant="h2" paragraph>
Configuration {props.initialData?.configurationName} Configuration
<strong>{` ${props.initialData.configurationName} `}</strong>
</Typography> </Typography>
)} )}
<Controller
control={control}
name="active"
render={({ field: { value, onChange } }) => {
return (
<FormControlLabel
control={
<Switch value={value} checked={value} onChange={(event, val) => onChange(val)} />
}
label="Active"
/>
);
}}
/>
<Controller
control={control}
name="sandboxMode"
render={({ field: { value, onChange } }) => {
return (
<FormControlLabel
control={
<Switch value={value} checked={value} onChange={(event, val) => onChange(val)} />
}
label="Sandbox mode"
/>
);
}}
/>
<Controller <Controller
name="configurationName" name="configurationName"
control={control} control={control}
render={({ field: { onChange, value } }) => ( render={({ field: { onChange, value }, fieldState: { error }, formState: { errors } }) => (
<TextField <TextField
label="Configuration name" label="Configuration name"
value={value} value={value}
onChange={onChange} onChange={onChange}
error={!!error}
helperText={error?.message}
{...CommonFieldProps} {...CommonFieldProps}
/> />
)} )}
/> />
<Controller <Controller
name="senderName"
control={control} control={control}
render={({ field: { onChange, value } }) => ( name="active"
<TextField label="Sender name" value={value} onChange={onChange} {...CommonFieldProps} /> render={({ field: { value, name, onChange } }) => (
)} <div className={styles.field}>
/> {/* TODO: fix types in the MacawUI */}
<Controller {/* @ts-ignore: MacawUI use wrong type for */}
name="senderEmail" <SwitchSelector key={name} className={styles.field}>
control={control} {[
render={({ field: { onChange, value } }) => ( { label: "Active", value: true },
<TextField label="Sender email" value={value} onChange={onChange} {...CommonFieldProps} /> { label: "Disabled", value: false },
].map((button) => (
// @ts-ignore: MacawUI use wrong type for SwitchSelectorButton
<SwitchSelectorButton
value={button.value.toString()}
onClick={() => onChange(button.value)}
activeTab={value.toString()}
key={button.label}
>
{button.label}
</SwitchSelectorButton>
))}
</SwitchSelector>
</div>
)} )}
/> />
<Divider />
<Typography variant="h3" paragraph className={styles.sectionHeader}>
API configuration
</Typography>
<Controller <Controller
name="apiKey" name="apiKey"
control={control} control={control}
render={({ field: { onChange, value } }) => ( render={({ field: { onChange, value }, fieldState: { error } }) => (
<TextField label="API key" value={value} onChange={onChange} {...CommonFieldProps} />
)}
/>
<Controller
name="templateOrderCreatedSubject"
control={control}
render={({ field: { onChange, value } }) => (
<TextField <TextField
label="Order Created Email subject" label="Sendgrid API key"
value={value} value={value}
onChange={onChange} onChange={onChange}
helperText={error?.message}
error={!!error}
{...CommonFieldProps} {...CommonFieldProps}
/> />
)} )}
@ -147,171 +233,107 @@ export const SendgridConfigurationForm = (props: Props) => {
<Controller <Controller
control={control} control={control}
name="templateOrderCreatedTemplate" name="sandboxMode"
render={({ field: { value, onChange } }) => { render={({ field: { value, name, onChange } }) => (
return ( <div className={styles.field}>
<FormControl className={styles.field} disabled={isTemplateChoicesLoading} fullWidth> {/* TODO: fix types in the MacawUI */}
<InputLabel>Template for Order Created</InputLabel> {/* @ts-ignore: MacawUI use wrong type for */}
<TemplateSelectionField <SwitchSelector key={name} className={styles.field}>
value={value} {[
onChange={onChange} { label: "Live", value: false },
templateChoices={templateChoices} { label: "Sandbox", value: true },
/> ].map((button) => (
</FormControl> // @ts-ignore: MacawUI use wrong type for SwitchSelectorButton
); <SwitchSelectorButton
}} value={button.value.toString()}
/> onClick={() => onChange(button.value)}
activeTab={value.toString()}
<Controller key={button.label}
name="templateOrderFulfilledSubject" >
control={control} {button.label}
render={({ field: { onChange, value } }) => ( </SwitchSelectorButton>
<TextField ))}
label="Order Fulfilled Email subject" </SwitchSelector>
value={value} </div>
onChange={onChange}
{...CommonFieldProps}
/>
)} )}
/> />
<Controller <Divider />
control={control}
name="templateOrderFulfilledTemplate" {/* Sender can be chosen after the API key is saved in the configuration */}
render={({ field: { value, onChange } }) => { {!isNewConfiguration && (
return ( <>
<FormControl className={styles.field} disabled={isTemplateChoicesLoading} fullWidth> <Typography variant="h3" paragraph className={styles.sectionHeader}>
<InputLabel>Template for Order Fulfilled</InputLabel> Sender details
<TemplateSelectionField </Typography>
<FormControl className={styles.field} fullWidth>
<InputLabel>Sender</InputLabel>
<Select
variant="outlined"
value={senderId}
disabled={isSendersChoicesLoading}
onChange={(event, val) => {
if (val) {
const node = val as React.ReactElement;
setSenderId(node.props.value);
return;
}
setSenderId(undefined);
}}
>
<MenuItem key="none" value={undefined}>
No sender
</MenuItem>
{!!sendersChoices &&
sendersChoices.map((choice) => (
<MenuItem key={choice.value} value={choice.value}>
{choice.label}
</MenuItem>
))}
</Select>
{!sendersChoices?.length && (
<Typography variant="caption" color="textSecondary">
Please set up and verify senders in your Sendgrid dashboard.
</Typography>
)}
</FormControl>
<Controller
name="senderName"
control={control}
render={({ field: { onChange, value }, fieldState: { error } }) => (
<TextField
label="Sender name"
disabled={true}
error={!!error}
helperText={error?.message}
value={value} value={value}
onChange={onChange} onChange={onChange}
templateChoices={templateChoices} {...CommonFieldProps}
/> />
</FormControl> )}
);
}}
/>
<Controller
name="templateOrderConfirmedSubject"
control={control}
render={({ field: { onChange, value } }) => (
<TextField
label="Order Confirmed Email subject"
value={value}
onChange={onChange}
{...CommonFieldProps}
/> />
)}
/>
<Controller <Controller
control={control} name="senderEmail"
name="templateOrderConfirmedTemplate" control={control}
render={({ field: { value, onChange } }) => { render={({ field: { onChange, value }, fieldState: { error } }) => (
return ( <>
<FormControl className={styles.field} disabled={isTemplateChoicesLoading} fullWidth> <TextField
<InputLabel>Template for Order Confirmed</InputLabel> label="Sender email"
<TemplateSelectionField value={value}
value={value} disabled={true}
onChange={onChange} helperText={error?.message}
templateChoices={templateChoices} error={!!error}
/> onChange={onChange}
</FormControl> {...CommonFieldProps}
); />
}} </>
/> )}
<Controller
name="templateOrderCancelledSubject"
control={control}
render={({ field: { onChange, value } }) => (
<TextField
label="Order Cancelled Email subject"
value={value}
onChange={onChange}
{...CommonFieldProps}
/> />
)} </>
/> )}
<Controller
control={control}
name="templateOrderCancelledTemplate"
render={({ field: { value, onChange } }) => {
return (
<FormControl className={styles.field} disabled={isTemplateChoicesLoading} fullWidth>
<InputLabel>Template for Order Cancelled</InputLabel>
<TemplateSelectionField
value={value}
onChange={onChange}
templateChoices={templateChoices}
/>
</FormControl>
);
}}
/>
<Controller
name="templateOrderFullyPaidSubject"
control={control}
render={({ field: { onChange, value } }) => (
<TextField
label="Order Fully Paid Email subject"
value={value}
onChange={onChange}
{...CommonFieldProps}
/>
)}
/>
<Controller
control={control}
name="templateOrderFullyPaidTemplate"
render={({ field: { value, onChange } }) => {
return (
<FormControl className={styles.field} disabled={isTemplateChoicesLoading} fullWidth>
<InputLabel>Template for Order Fully Paid</InputLabel>
<TemplateSelectionField
value={value}
onChange={onChange}
templateChoices={templateChoices}
/>
</FormControl>
);
}}
/>
<Controller
name="templateInvoiceSentSubject"
control={control}
render={({ field: { onChange, value } }) => (
<TextField
label="Invoice sent Email subject"
value={value}
onChange={onChange}
{...CommonFieldProps}
/>
)}
/>
<Controller
control={control}
name="templateInvoiceSentTemplate"
render={({ field: { value, onChange } }) => {
return (
<FormControl className={styles.field} disabled={isTemplateChoicesLoading} fullWidth>
<InputLabel>Template for Invoice Sent</InputLabel>
<TemplateSelectionField
value={value}
onChange={onChange}
templateChoices={templateChoices}
/>
</FormControl>
);
}}
/>
<Button type="submit" fullWidth variant="primary"> <Button type="submit" fullWidth variant="primary">
Save configuration Save configuration
</Button> </Button>

View file

@ -1,16 +1,18 @@
import { CircularProgress, Paper } from "@material-ui/core"; import React from "react";
import React, { useEffect, useState } from "react"; import { IconButton, makeStyles } from "@saleor/macaw-ui";
import { makeStyles } from "@saleor/macaw-ui";
import { ConfigurationsList } from "../../../app-configuration/ui/configurations-list";
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge"; import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
import { AppColumnsLayout } from "../../../ui/app-columns-layout"; import { AppColumnsLayout } from "../../../ui/app-columns-layout";
import { trpcClient } from "../../../trpc/trpc-client"; 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 { 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) => { const useStyles = makeStyles((theme) => {
return { return {
@ -24,101 +26,149 @@ const useStyles = makeStyles((theme) => {
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
gap: 20, gap: 20,
}, maxWidth: 600,
loaderContainer: {
margin: "50px auto",
display: "flex",
alignItems: "center",
justifyContent: "center",
}, },
}; };
}); });
type Configurations = { interface SendgridConfigurationTabProps {
name: string; configurationId?: string;
id: 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 styles = useStyles();
const { appBridge } = useAppBridge(); const { appBridge } = useAppBridge();
const [configurationsListData, setConfigurationsListData] = useState<Configurations[]>([]); const router = useRouter();
const [activeConfigurationId, setActiveConfigurationId] = useState<string>(); const queryClient = useQueryClient();
const [initialData, setInitialData] = useState<SendgridConfiguration>();
const { const {
data: configurationData, data: configurations,
refetch: refetchConfig, refetch: refetchConfigurations,
isLoading, isLoading: configurationsIsLoading,
} = trpcClient.sendgridConfiguration.fetch.useQuery(undefined, { isFetching: configurationsIsFetching,
isRefetching: configurationsIsRefetching,
} = trpcClient.sendgridConfiguration.getConfigurations.useQuery(undefined, {
onSuccess(data) { onSuccess(data) {
if (!data.availableConfigurations) { if (!configurationId) {
return; 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({ const { mutate: deleteConfiguration } =
onSuccess() { trpcClient.sendgridConfiguration.deleteConfiguration.useMutation({
refetchConfig(); onError: (error) => {
appBridge?.dispatch( appBridge?.dispatch(
actions.Notification({ actions.Notification({
title: "Success", title: "Could not remove the configuration",
text: "Saved configuration", text: error.message,
status: "success", status: "error",
}) })
); );
}, },
}); onSuccess: async (_data, variables) => {
await queryClient.cancelQueries({
queryKey: ["sendgridConfiguration", "getConfigurations"],
});
// remove value from the cache after the success
queryClient.setQueryData<Array<SendgridConfiguration>>(
["sendgridConfiguration", "getConfigurations"],
(old) => {
if (old) {
const index = old.findIndex((c) => c.id === variables.id);
if (index !== -1) {
delete old[index];
return [...old];
}
}
}
);
useEffect(() => { // if we just deleted the configuration that was selected
setInitialData( // we have to update the URL
activeConfigurationId if (variables.id === configurationId) {
? SendgridConfigContainer.getSendgridConfigurationById(configurationData)( router.replace(sendgridUrls.configuration());
activeConfigurationId }
)
: getDefaultEmptySendgridConfiguration()
);
}, [activeConfigurationId, configurationData]);
if (isLoading) { refetchConfigurations();
return ( appBridge?.dispatch(
<div className={styles.loaderContainer}> actions.Notification({
<CircularProgress color="primary" /> title: "Success",
</div> text: "Removed successfully",
); status: "success",
})
);
},
});
if (configurationsIsLoading || configurationsIsFetching) {
return <LoadingIndicator />;
}
const configuration = configurations?.find((c) => c.id === configurationId?.toString());
if (configurationId && !configuration) {
return <div>Configuration not found</div>;
} }
return ( return (
<AppColumnsLayout> <AppColumnsLayout>
<ConfigurationsList <SideMenu
// TODO: FIXME title="Configurations"
listItems={[]} selectedItemId={configurationId}
activeItemId={activeConfigurationId} headerToolbar={
onItemClick={setActiveConfigurationId} <IconButton
variant="secondary"
onClick={() => {
router.replace(sendgridUrls.configuration());
}}
>
<Add />
</IconButton>
}
onClick={(id) => router.replace(sendgridUrls.configuration(id))}
onDelete={(id) => {
deleteConfiguration({ id });
}}
items={configurations?.map((c) => ({ label: c.configurationName, id: c.id })) || []}
/> />
<div className={styles.configurationColumn}> <div className={styles.configurationColumn}>
<Paper elevation={0} className={styles.formContainer}> {configurationsIsLoading || configurationsIsFetching ? (
{!!initialData && ( <LoadingIndicator />
) : (
<>
<SendgridConfigurationForm <SendgridConfigurationForm
onSubmit={async (data) => { onConfigurationSaved={() => refetchConfigurations()}
const newConfig = initialData={configuration || getDefaultEmptyConfiguration()}
SendgridConfigContainer.setSendgridConfigurationById(configurationData)( configurationId={configurationId}
activeConfigurationId
)(data);
mutate(newConfig);
}}
initialData={initialData}
configurationId={activeConfigurationId}
/> />
)} {!!configurationId && !!configuration && (
{saveError && <span>{saveError.message}</span>} <SendgridTemplatesCard
</Paper> configurationId={configurationId}
configuration={configuration}
onEventChanged={() => {
refetchConfigurations();
}}
/>
)}
</>
)}
</div> </div>
</AppColumnsLayout> </AppColumnsLayout>
); );

View file

@ -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<SendgridEventConfiguration>({
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 (
<div className={styles.viewContainer}>
<div className={styles.header}>
<IconButton
variant="secondary"
onClick={() => {
router.push(sendgridUrls.configuration(configurationId));
}}
>
<BackSmallIcon />
</IconButton>
<Typography variant="h2">
{messageEventTypesLabels[eventType]} event configuration
</Typography>
</div>
<Grid container spacing={2}>
<Grid item xs={12} lg={7}>
<form
onSubmit={handleSubmit((data, event) => {
updateEventConfiguration({ ...data, configurationId });
})}
className={styles.form}
>
<Controller
control={control}
name="active"
render={({ field: { value, name, onChange } }) => (
<div className={styles.field}>
{/* TODO: fix types in the MacawUI */}
{/* @ts-ignore: MacawUI use wrong type for */}
<SwitchSelector key={name} className={styles.field}>
{[
{ label: "Active", value: true },
{ label: "Disabled", value: false },
].map((button) => (
// @ts-ignore: MacawUI use wrong type for SwitchSelectorButton
<SwitchSelectorButton
value={button.value.toString()}
onClick={() => onChange(button.value)}
activeTab={value.toString()}
key={button.label}
>
{button.label}
</SwitchSelectorButton>
))}
</SwitchSelector>
</div>
)}
/>
<Controller
control={control}
name="template"
render={({ field: { value, onChange } }) => {
return (
<FormControl className={styles.field} fullWidth>
<InputLabel>Template</InputLabel>
<Select
variant="outlined"
value={value}
onChange={(event, val) => {
onChange(event.target.value);
}}
>
<MenuItem key="none" value={undefined}>
No template
</MenuItem>
{!!templateChoices &&
templateChoices.map((choice) => (
<MenuItem key={choice.value} value={choice.value}>
{choice.label}
</MenuItem>
))}
</Select>
{!templateChoices?.length && (
<Typography variant="caption" color="textSecondary">
No templates found in your account. Visit Sendgrid dashboard and create one.
</Typography>
)}
</FormControl>
);
}}
/>
<Button type="submit" fullWidth variant="primary">
Save configuration
</Button>
</form>
</Grid>
</Grid>
</div>
);
};

View file

@ -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 (
<Paper elevation={0}>
<ListHeader>
<ListItem className={classes.tableRow}>
<ListItemCell>Supported events and templates</ListItemCell>
</ListItem>
</ListHeader>
<List gridTemplate={["1fr"]}>
<Divider />
{configuration.events.map((eventConfiguration) => (
<React.Fragment key={eventConfiguration.eventType}>
<ListItem className={classes.tableRow}>
<ListItemCell>
<div className={classes.spaceBetween}>
<Typography>{messageEventTypesLabels[eventConfiguration.eventType]}</Typography>
<div className={classes.rowActions}>
{/* TODO: fix types in the MacawUI */}
{/* @ts-ignore: MacawUI use wrong type for */}
<SwitchSelector key={eventConfiguration.eventType}>
{[
{ label: "Active", value: true },
{ label: "Disabled", value: false },
].map((button) => (
// @ts-ignore: MacawUI use wrong type for SwitchSelectorButton
<SwitchSelectorButton
value={button.value.toString()}
onClick={() => {
updateEventConfiguration({
configurationId,
...eventConfiguration,
active: button.value,
});
}}
activeTab={eventConfiguration.active.toString()}
key={button.label}
>
{button.label}
</SwitchSelectorButton>
))}
</SwitchSelector>
<IconButton
variant="secondary"
onClick={(event) => {
event.stopPropagation();
event.preventDefault();
router.push(
sendgridUrls.eventConfiguration(
configurationId,
eventConfiguration.eventType
)
);
}}
>
<EditIcon />
</IconButton>
</div>
</div>
</ListItemCell>
</ListItem>
<Divider />
</React.Fragment>
))}
</List>
</Paper>
);
};

View file

@ -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;
};

View file

@ -1,16 +1,13 @@
import { logger as pinoLogger } from "../../lib/logger"; import { logger as pinoLogger } from "../../lib/logger";
import { AuthData } from "@saleor/app-sdk/APL";
import { SendgridConfiguration } from "./configuration/sendgrid-config"; import { SendgridConfiguration } from "./configuration/sendgrid-config";
import { getSendgridSettings } from "./get-sendgrid-settings";
import { MailService } from "@sendgrid/mail"; import { MailService } from "@sendgrid/mail";
import { MessageEventTypes } from "../event-handlers/message-event-types"; import { MessageEventTypes } from "../event-handlers/message-event-types";
interface SendSendgridArgs { interface SendSendgridArgs {
authData: AuthData;
channel: string;
recipientEmail: string; recipientEmail: string;
event: MessageEventTypes; event: MessageEventTypes;
payload: any; payload: any;
sendgridConfiguration: SendgridConfiguration;
} }
export interface EmailServiceResponse { 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 ({ export const sendSendgrid = async ({
authData,
channel,
payload, payload,
recipientEmail, recipientEmail,
event, event,
sendgridConfiguration,
}: SendSendgridArgs) => { }: SendSendgridArgs) => {
const logger = pinoLogger.child({ const logger = pinoLogger.child({
fn: "sendSendgrid", fn: "sendSendgrid",
event, 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) { if (!eventSettings.active) {
logger.debug("Sendgrid is not active, skipping"); logger.debug("Event settings are not active, skipping");
return; return {
errors: [
{
message: "Event settings are not active",
},
],
};
} }
logger.debug("Sending an email using Sendgrid"); 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"); logger.error("No template defined in the settings");
return { return {
errors: [{ message: `No template specified for the event ${event}` }], errors: [{ message: `No template specified for the event ${event}` }],
@ -87,35 +74,21 @@ export const sendSendgrid = async ({
try { try {
const mailService = new MailService(); const mailService = new MailService();
mailService.setApiKey(settings.apiKey); mailService.setApiKey(sendgridConfiguration.apiKey);
await mailService.send({ await mailService.send({
mailSettings: { mailSettings: {
sandboxMode: { sandboxMode: {
enable: settings.sandboxMode, enable: sendgridConfiguration.sandboxMode,
}, },
}, },
from: { from: {
email: settings.senderEmail, name: sendgridConfiguration.senderName,
email: sendgridConfiguration.senderEmail,
}, },
to: { to: recipientEmail,
email: recipientEmail, dynamicTemplateData: payload,
}, templateId: template,
personalizations: [
{
from: {
email: settings.senderEmail,
},
to: [
{
email: recipientEmail,
},
],
subject,
dynamicTemplateData: payload,
},
],
templateId,
}); });
logger.debug("Email has been send"); logger.debug("Email has been send");
} catch (error) { } catch (error) {

View file

@ -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 [];
}
};

View file

@ -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}`,
};

View file

@ -24,9 +24,8 @@ export const ConfigurationPageBaseLayout = ({ children }: Props) => {
{ key: "mjml", label: "MJML", url: "/configuration/mjml" }, { key: "mjml", label: "MJML", url: "/configuration/mjml" },
{ {
key: "sendgrid", key: "sendgrid",
label: "Sendgrid (Coming soon!)", label: "Sendgrid",
url: "/configuration/sendgrid", url: "/configuration/sendgrid",
disabled: true,
}, },
]; ];
@ -42,7 +41,7 @@ export const ConfigurationPageBaseLayout = ({ children }: Props) => {
<div className={styles.appContainer}> <div className={styles.appContainer}>
<PageTabs value={activePath} onChange={navigateToTab}> <PageTabs value={activePath} onChange={navigateToTab}>
{tabs.map((tab) => ( {tabs.map((tab) => (
<PageTab key={tab.key} value={tab.key} label={tab.label} disabled={tab.disabled} /> <PageTab key={tab.key} value={tab.key} label={tab.label} />
))} ))}
</PageTabs> </PageTabs>
{children} {children}

View file

@ -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 (
<ConfigurationPageBaseLayout>
<SendgridConfigurationTab configurationId={configurationId} />
</ConfigurationPageBaseLayout>
);
};
export default SendgridConfigurationPage;

View file

@ -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 (
<ConfigurationPageBaseLayout>
<LoadingIndicator />
</ConfigurationPageBaseLayout>
);
}
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 (
<ConfigurationPageBaseLayout>
<EventConfigurationForm
initialData={eventConfiguration}
configurationId={configurationId}
configuration={configuration}
eventType={eventType}
/>
</ConfigurationPageBaseLayout>
);
};
export default EventConfigurationPage;