Update the sendgrid support (#321)
* Update the sendgrid support * Add changeset
This commit is contained in:
parent
f58043f72b
commit
14ac6144c0
20 changed files with 1401 additions and 619 deletions
5
.changeset/large-seals-study.md
Normal file
5
.changeset/large-seals-study.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"saleor-app-emails-and-messages": minor
|
||||
---
|
||||
|
||||
Enable Sendgrid support
|
|
@ -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]);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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<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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<SendgridConfiguration, "id" | "events">) => {
|
||||
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,
|
||||
};
|
||||
|
|
|
@ -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),
|
||||
});
|
||||
|
|
|
@ -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<string, SendgridConfiguration>;
|
||||
|
||||
export type SendgridConfig = {
|
||||
availableConfigurations: SendgridConfigurationsIdMap;
|
||||
configurations: SendgridConfiguration[];
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
}),
|
||||
});
|
||||
|
|
|
@ -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 [];
|
||||
}
|
||||
};
|
|
@ -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<void>;
|
||||
onConfigurationSaved: () => void;
|
||||
initialData: SendgridConfiguration;
|
||||
configurationId?: string;
|
||||
};
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
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
|
||||
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 (
|
||||
<form
|
||||
onSubmit={handleSubmit((data, event) => {
|
||||
props.onSubmit(data);
|
||||
createOrUpdateConfiguration({
|
||||
...data,
|
||||
});
|
||||
})}
|
||||
className={styles.form}
|
||||
>
|
||||
{isNewConfiguration ? (
|
||||
<Typography variant="h4" paragraph>
|
||||
<Typography variant="h2" paragraph>
|
||||
Create a new configuration
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography variant="h4" paragraph>
|
||||
Configuration {props.initialData?.configurationName}
|
||||
<Typography variant="h2" paragraph>
|
||||
Configuration
|
||||
<strong>{` ${props.initialData.configurationName} `}</strong>
|
||||
</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
|
||||
name="configurationName"
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
render={({ field: { onChange, value }, fieldState: { error }, formState: { errors } }) => (
|
||||
<TextField
|
||||
label="Configuration name"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
error={!!error}
|
||||
helperText={error?.message}
|
||||
{...CommonFieldProps}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="senderName"
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<TextField label="Sender name" value={value} onChange={onChange} {...CommonFieldProps} />
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="senderEmail"
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<TextField label="Sender email" value={value} onChange={onChange} {...CommonFieldProps} />
|
||||
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>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Typography variant="h3" paragraph className={styles.sectionHeader}>
|
||||
API configuration
|
||||
</Typography>
|
||||
|
||||
<Controller
|
||||
name="apiKey"
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<TextField label="API key" value={value} onChange={onChange} {...CommonFieldProps} />
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="templateOrderCreatedSubject"
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||
<TextField
|
||||
label="Order Created Email subject"
|
||||
label="Sendgrid API key"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
helperText={error?.message}
|
||||
error={!!error}
|
||||
{...CommonFieldProps}
|
||||
/>
|
||||
)}
|
||||
|
@ -147,171 +233,107 @@ export const SendgridConfigurationForm = (props: Props) => {
|
|||
|
||||
<Controller
|
||||
control={control}
|
||||
name="templateOrderCreatedTemplate"
|
||||
render={({ field: { value, onChange } }) => {
|
||||
return (
|
||||
<FormControl className={styles.field} disabled={isTemplateChoicesLoading} fullWidth>
|
||||
<InputLabel>Template for Order Created</InputLabel>
|
||||
<TemplateSelectionField
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
templateChoices={templateChoices}
|
||||
/>
|
||||
</FormControl>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="templateOrderFulfilledSubject"
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<TextField
|
||||
label="Order Fulfilled Email subject"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
{...CommonFieldProps}
|
||||
/>
|
||||
name="sandboxMode"
|
||||
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: "Live", value: false },
|
||||
{ label: "Sandbox", value: true },
|
||||
].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="templateOrderFulfilledTemplate"
|
||||
render={({ field: { value, onChange } }) => {
|
||||
return (
|
||||
<FormControl className={styles.field} disabled={isTemplateChoicesLoading} fullWidth>
|
||||
<InputLabel>Template for Order Fulfilled</InputLabel>
|
||||
<TemplateSelectionField
|
||||
<Divider />
|
||||
|
||||
{/* Sender can be chosen after the API key is saved in the configuration */}
|
||||
{!isNewConfiguration && (
|
||||
<>
|
||||
<Typography variant="h3" paragraph className={styles.sectionHeader}>
|
||||
Sender details
|
||||
</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}
|
||||
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
|
||||
control={control}
|
||||
name="templateOrderConfirmedTemplate"
|
||||
render={({ field: { value, onChange } }) => {
|
||||
return (
|
||||
<FormControl className={styles.field} disabled={isTemplateChoicesLoading} fullWidth>
|
||||
<InputLabel>Template for Order Confirmed</InputLabel>
|
||||
<TemplateSelectionField
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
templateChoices={templateChoices}
|
||||
/>
|
||||
</FormControl>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="templateOrderCancelledSubject"
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<TextField
|
||||
label="Order Cancelled Email subject"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
{...CommonFieldProps}
|
||||
<Controller
|
||||
name="senderEmail"
|
||||
control={control}
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => (
|
||||
<>
|
||||
<TextField
|
||||
label="Sender email"
|
||||
value={value}
|
||||
disabled={true}
|
||||
helperText={error?.message}
|
||||
error={!!error}
|
||||
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">
|
||||
Save configuration
|
||||
</Button>
|
||||
|
|
|
@ -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<Configurations[]>([]);
|
||||
const [activeConfigurationId, setActiveConfigurationId] = useState<string>();
|
||||
const [initialData, setInitialData] = useState<SendgridConfiguration>();
|
||||
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<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(() => {
|
||||
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 (
|
||||
<div className={styles.loaderContainer}>
|
||||
<CircularProgress color="primary" />
|
||||
</div>
|
||||
);
|
||||
refetchConfigurations();
|
||||
appBridge?.dispatch(
|
||||
actions.Notification({
|
||||
title: "Success",
|
||||
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 (
|
||||
<AppColumnsLayout>
|
||||
<ConfigurationsList
|
||||
// TODO: FIXME
|
||||
listItems={[]}
|
||||
activeItemId={activeConfigurationId}
|
||||
onItemClick={setActiveConfigurationId}
|
||||
<SideMenu
|
||||
title="Configurations"
|
||||
selectedItemId={configurationId}
|
||||
headerToolbar={
|
||||
<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}>
|
||||
<Paper elevation={0} className={styles.formContainer}>
|
||||
{!!initialData && (
|
||||
{configurationsIsLoading || configurationsIsFetching ? (
|
||||
<LoadingIndicator />
|
||||
) : (
|
||||
<>
|
||||
<SendgridConfigurationForm
|
||||
onSubmit={async (data) => {
|
||||
const newConfig =
|
||||
SendgridConfigContainer.setSendgridConfigurationById(configurationData)(
|
||||
activeConfigurationId
|
||||
)(data);
|
||||
mutate(newConfig);
|
||||
}}
|
||||
initialData={initialData}
|
||||
configurationId={activeConfigurationId}
|
||||
onConfigurationSaved={() => refetchConfigurations()}
|
||||
initialData={configuration || getDefaultEmptyConfiguration()}
|
||||
configurationId={configurationId}
|
||||
/>
|
||||
)}
|
||||
{saveError && <span>{saveError.message}</span>}
|
||||
</Paper>
|
||||
{!!configurationId && !!configuration && (
|
||||
<SendgridTemplatesCard
|
||||
configurationId={configurationId}
|
||||
configuration={configuration}
|
||||
onEventChanged={() => {
|
||||
refetchConfigurations();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</AppColumnsLayout>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
};
|
|
@ -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) {
|
||||
|
|
|
@ -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 [];
|
||||
}
|
||||
};
|
8
apps/emails-and-messages/src/modules/sendgrid/urls.ts
Normal file
8
apps/emails-and-messages/src/modules/sendgrid/urls.ts
Normal 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}`,
|
||||
};
|
|
@ -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) => {
|
|||
<div className={styles.appContainer}>
|
||||
<PageTabs value={activePath} onChange={navigateToTab}>
|
||||
{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>
|
||||
{children}
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
Loading…
Reference in a new issue