Use configuration service

This commit is contained in:
Krzysztof Wolski 2023-03-07 22:02:37 +01:00
parent d23e85a850
commit eb9bd700ca
23 changed files with 446 additions and 390 deletions

View file

@ -9,3 +9,12 @@ export const appConfigInputSchema = z.object({
})
),
});
export const appChannelConfigurationInputSchema = z.object({
channel: z.string(),
configuration: z.object({
active: z.boolean(),
mjmlConfigurationId: z.string().optional(),
sendgridConfigurationId: z.string().optional(),
}),
});

View file

@ -1,23 +1,57 @@
import { PrivateMetadataAppConfigurator } from "./app-configurator";
import { createSettingsManager } from "./metadata-manager";
import { logger as pinoLogger } from "../../lib/logger";
import { appConfigInputSchema } from "./app-config-input-schema";
import { GetAppConfigurationService } from "./get-app-configuration.service";
import {
appChannelConfigurationInputSchema,
appConfigInputSchema,
} from "./app-config-input-schema";
import { AppConfigurationService } from "./get-app-configuration.service";
import { router } from "../trpc/trpc-server";
import { protectedClientProcedure } from "../trpc/protected-client-procedure";
import { z } from "zod";
// 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 AppConfigurationService({
apiClient: ctx.apiClient,
saleorApiUrl: ctx.saleorApiUrl,
}),
},
})
);
export const appConfigurationRouter = router({
fetch: protectedClientProcedure.query(async ({ ctx, input }) => {
getChannelConfiguration: protectedWithConfigurationService
.input(z.object({ channelSlug: z.string() }))
.query(async ({ ctx, input }) => {
const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
logger.debug("Get Channel Configuration called");
return await ctx.configurationService.getChannelConfiguration(input.channelSlug);
}),
setChannelConfiguration: protectedWithConfigurationService
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(appChannelConfigurationInputSchema)
.mutation(async ({ ctx, input }) => {
const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
logger.debug("Set channel configuration called");
await ctx.configurationService.setChannelConfiguration(input);
}),
fetch: protectedWithConfigurationService.query(async ({ ctx, input }) => {
const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
logger.debug("appConfigurationRouter.fetch called");
return new GetAppConfigurationService({
return new AppConfigurationService({
apiClient: ctx.apiClient,
saleorApiUrl: ctx.saleorApiUrl,
}).getConfiguration();
}),
setAndReplace: protectedClientProcedure
setAndReplace: protectedWithConfigurationService
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(appConfigInputSchema)
.mutation(async ({ ctx, input }) => {
@ -25,12 +59,7 @@ export const appConfigurationRouter = router({
logger.debug(input, "appConfigurationRouter.setAndReplace called with input");
const appConfigurator = new PrivateMetadataAppConfigurator(
createSettingsManager(ctx.apiClient),
ctx.saleorApiUrl
);
await appConfigurator.setConfig(input);
await ctx.configurationService.setConfigurationRoot(input);
return null;
}),

View file

@ -1,65 +1,91 @@
import { PrivateMetadataAppConfigurator } from "./app-configurator";
import { createSettingsManager } from "./metadata-manager";
import { ChannelsFetcher } from "../channels/channels-fetcher";
import { ShopInfoFetcher } from "../shop-info/shop-info-fetcher";
import { FallbackAppConfig } from "./fallback-app-config";
import { Client } from "urql";
import { logger as pinoLogger } from "../../lib/logger";
import { AppConfig, AppConfigurationPerChannel } from "./app-config";
import { getDefaultEmptyAppConfiguration } from "./app-config-container";
// todo test
export class GetAppConfigurationService {
constructor(
private settings: {
apiClient: Client;
saleorApiUrl: string;
}
) {}
const logger = pinoLogger.child({
service: "AppConfigurationService",
});
export class AppConfigurationService {
private configurationData?: AppConfig;
private metadataConfigurator: PrivateMetadataAppConfigurator;
constructor(args: { apiClient: Client; saleorApiUrl: string; initialData?: AppConfig }) {
this.metadataConfigurator = new PrivateMetadataAppConfigurator(
createSettingsManager(args.apiClient),
args.saleorApiUrl
);
}
// 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!);
}
async getConfiguration() {
const logger = pinoLogger.child({
service: "GetAppConfigurationService",
saleorApiUrl: this.settings.saleorApiUrl,
});
logger.debug("Get configuration");
const { saleorApiUrl, apiClient } = this.settings;
if (!this.configurationData) {
logger.debug("No configuration found in cache. Will fetch it from Saleor API");
await this.pullConfiguration();
}
const appConfigurator = new PrivateMetadataAppConfigurator(
createSettingsManager(apiClient),
saleorApiUrl
);
const savedAppConfig = (await appConfigurator.getConfig()) ?? null;
const savedAppConfig = this.configurationData ?? null;
logger.debug(savedAppConfig, "Retrieved app config from Metadata. Will return it");
if (savedAppConfig) {
return savedAppConfig;
}
}
logger.info("App config not found in metadata. Will create default config now.");
// Saves configuration to Saleor API and cache it
async setConfigurationRoot(config: AppConfig) {
logger.debug("Set configuration");
const channelsFetcher = new ChannelsFetcher(apiClient);
const shopInfoFetcher = new ShopInfoFetcher(apiClient);
this.configurationData = config;
await this.pushConfiguration();
}
const [channels, shopAppConfiguration] = await Promise.all([
channelsFetcher.fetchChannels(),
shopInfoFetcher.fetchShopInfo(),
]);
// Returns channel configuration if existing. Otherwise returns default empty one
async getChannelConfiguration(channel: string) {
logger.debug("Get channel configuration");
const configurations = await this.getConfiguration();
if (!configurations) {
return getDefaultEmptyAppConfiguration();
}
logger.debug(channels, "Fetched channels");
logger.debug(shopAppConfiguration, "Fetched shop app configuration");
const channelConfiguration = configurations.configurationsPerChannel[channel];
return channelConfiguration || getDefaultEmptyAppConfiguration();
}
const appConfig = FallbackAppConfig.createFallbackConfigFromExistingShopAndChannels(
channels ?? [],
shopAppConfiguration
);
async setChannelConfiguration({
channel,
configuration,
}: {
channel: string;
configuration: AppConfigurationPerChannel;
}) {
logger.debug("Set channel configuration");
let configurations = await this.getConfiguration();
if (!configurations) {
configurations = { configurationsPerChannel: {} };
}
logger.debug(appConfig, "Created a fallback AppConfig. Will save it.");
await appConfigurator.setConfig(appConfig);
logger.info("Saved initial AppConfig");
return appConfig;
configurations.configurationsPerChannel[channel] = configuration;
await this.setConfigurationRoot(configurations);
}
}

View file

@ -83,7 +83,7 @@ export const AppConfigurationForm = (props: AppConfigurationFormProps) => {
<SwitchSelectorButton
value={button.value.toString()}
onClick={() => onChange(button.value)}
activeTab={value.toString()}
activeTab={value?.toString() || "false"}
key={button.label}
>
{button.label}

View file

@ -1,6 +1,5 @@
import React, { useEffect, useMemo, useState } from "react";
import React, { useMemo, useState } from "react";
import { EditIcon, IconButton, makeStyles } from "@saleor/macaw-ui";
import { AppConfigContainer } from "../app-config-container";
import { AppConfigurationForm } from "./app-configuration-form";
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
import { AppColumnsLayout } from "../../ui/app-columns-layout";
@ -28,96 +27,88 @@ const useStyles = makeStyles((theme) => {
export const ChannelsConfigurationTab = () => {
const styles = useStyles();
const { appBridge } = useAppBridge();
const [mjmlConfigurationsListData, setMjmlConfigurationsListData] = useState<
{ label: string; value: string }[]
>([]);
const [sendgridConfigurationsListData, setSendgridConfigurationsListData] = useState<
{ label: string; value: string }[]
>([]);
const { data: configurationData, refetch: refetchConfig } =
trpcClient.appConfiguration.fetch.useQuery();
trpcClient.mjmlConfiguration.getConfigurations.useQuery(
{},
{
onSuccess(data) {
setMjmlConfigurationsListData(
data.map((configuration) => ({
value: configuration.id,
label: configuration.configurationName,
}))
);
},
}
);
trpcClient.sendgridConfiguration.fetch.useQuery(undefined, {
onSuccess(data) {
const keys = Object.keys(data.availableConfigurations);
setSendgridConfigurationsListData(
keys.map((key) => ({
value: key,
label: data.availableConfigurations[key].configurationName,
}))
);
},
});
const {
data: channels,
isLoading: isChannelsLoading,
isSuccess: isChannelsFetchSuccess,
} = trpcClient.channels.fetch.useQuery();
const { mutate, error: saveError } = trpcClient.appConfiguration.setAndReplace.useMutation({
onSuccess() {
refetchConfig();
appBridge?.dispatch(
actions.Notification({
title: "Success",
text: "Saved app configuration",
status: "success",
})
);
},
});
const [activeChannelSlug, setActiveChannelSlug] = useState<string | null>(null);
useEffect(() => {
if (isChannelsFetchSuccess) {
setActiveChannelSlug(channels[0].slug ?? null);
}
}, [isChannelsFetchSuccess, channels]);
const { data: channelsData, isLoading: isChannelsDataLoading } =
trpcClient.channels.fetch.useQuery(undefined, {
onSuccess: (data) => {
if (data?.length) {
setActiveChannelSlug(data[0].slug);
}
},
});
const activeChannel = useMemo(() => {
try {
return channels!.find((c) => c.slug === activeChannelSlug)!;
} catch (e) {
return null;
}
}, [channels, activeChannelSlug]);
const {
data: configurationData,
refetch: refetchConfig,
isLoading: isConfigurationDataLoading,
} = trpcClient.appConfiguration.getChannelConfiguration.useQuery(
{
channelSlug: activeChannelSlug!,
},
{ enabled: !!activeChannelSlug }
);
if (isChannelsLoading) {
const { data: mjmlConfigurations, isLoading: isMjmlQueryLoading } =
trpcClient.mjmlConfiguration.getConfigurations.useQuery({});
const mjmlConfigurationsListData = useMemo(() => {
return (
mjmlConfigurations?.map((configuration) => ({
value: configuration.id,
label: configuration.configurationName,
})) ?? []
);
}, [mjmlConfigurations]);
const { data: sendgridConfigurations, isLoading: isSendgridQueryLoading } =
trpcClient.sendgridConfiguration.fetch.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]);
const { mutate: mutateAppChannelConfiguration, error: saveError } =
trpcClient.appConfiguration.setChannelConfiguration.useMutation({
onSuccess() {
refetchConfig();
appBridge?.dispatch(
actions.Notification({
title: "Success",
text: "Saved app configuration",
status: "success",
})
);
},
});
const activeChannel = channelsData?.find((c) => c.slug === activeChannelSlug);
if (isChannelsDataLoading) {
return <LoadingIndicator />;
}
if (!channels?.length) {
if (!channelsData?.length) {
return <div>NO CHANNELS</div>;
}
if (!activeChannel) {
return <div>Error. No channel available</div>;
}
const isFormDataLoading =
isConfigurationDataLoading || isMjmlQueryLoading || isSendgridQueryLoading;
return (
<AppColumnsLayout>
<SideMenu
title="Channels"
selectedItemId={activeChannel.slug}
selectedItemId={activeChannel?.slug}
headerToolbar={
<IconButton
variant="secondary"
@ -133,31 +124,32 @@ export const ChannelsConfigurationTab = () => {
</IconButton>
}
onClick={(id) => setActiveChannelSlug(id)}
items={channels.map((c) => ({ label: c.name, id: c.slug })) || []}
items={channelsData.map((c) => ({ label: c.name, id: c.slug })) || []}
/>
{activeChannel ? (
<div className={styles.configurationColumn}>
<AppConfigurationForm
channelID={activeChannel.id}
key={activeChannelSlug}
channelSlug={activeChannel.slug}
mjmlConfigurationChoices={mjmlConfigurationsListData}
sendgridConfigurationChoices={sendgridConfigurationsListData}
onSubmit={async (data) => {
const newConfig = AppConfigContainer.setChannelAppConfiguration(configurationData)(
activeChannel.slug
)(data);
mutate(newConfig);
}}
initialData={AppConfigContainer.getChannelAppConfiguration(configurationData)(
activeChannel.slug
)}
channelName={activeChannel?.name ?? activeChannelSlug}
/>
{saveError && <span>{saveError.message}</span>}
</div>
) : null}
<div className={styles.configurationColumn}>
{!activeChannel || isFormDataLoading ? (
<LoadingIndicator />
) : (
<>
<AppConfigurationForm
channelID={activeChannel.id}
key={activeChannelSlug}
channelSlug={activeChannel.slug}
mjmlConfigurationChoices={mjmlConfigurationsListData}
sendgridConfigurationChoices={sendgridConfigurationsListData}
onSubmit={async (data) => {
mutateAppChannelConfiguration({
channel: activeChannel.slug,
configuration: data,
});
}}
initialData={configurationData}
channelName={activeChannel?.name ?? activeChannelSlug}
/>
{saveError && <span>{saveError.message}</span>}
</>
)}
</div>
</AppColumnsLayout>
);
};

View file

@ -1,17 +1,16 @@
import { ChannelsFetcher } from "./channels-fetcher";
import { ChannelFragment } from "../../../generated/graphql";
import { createClient } from "../../lib/create-graphq-client";
import { createClient } from "../../lib/create-graphql-client";
import { router } from "../trpc/trpc-server";
import { protectedClientProcedure } from "../trpc/protected-client-procedure";
export const channelsRouter = router({
fetch: protectedClientProcedure.query(async ({ ctx, input }): Promise<ChannelFragment[]> => {
fetch: protectedClientProcedure.query(async ({ ctx }) => {
const client = createClient(ctx.saleorApiUrl, async () =>
Promise.resolve({ token: ctx.appToken })
);
const fetcher = new ChannelsFetcher(client);
return fetcher.fetchChannels().then((channels) => channels ?? []);
return await fetcher.fetchChannels().then((channels) => channels ?? []);
}),
});

View file

@ -1,8 +1,10 @@
import { AuthData } from "@saleor/app-sdk/APL";
import { Client } from "urql";
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 { sendSendgrid } from "../sendgrid/send-sendgrid";
import { appRouter } from "../trpc/trpc-app-router";
import { MessageEventTypes } from "./message-event-types";
interface SendEventMessagesArgs {
@ -11,6 +13,7 @@ interface SendEventMessagesArgs {
event: MessageEventTypes;
authData: AuthData;
payload: any;
client: Client;
}
export const sendEventMessages = async ({
@ -19,6 +22,7 @@ export const sendEventMessages = async ({
event,
authData,
payload,
client,
}: SendEventMessagesArgs) => {
const logger = pinoLogger.child({
fn: "sendEventMessages",
@ -26,16 +30,12 @@ export const sendEventMessages = async ({
logger.debug("Function called");
// get app configuration
const caller = appRouter.createCaller({
appId: authData.appId,
const appConfigurationService = new AppConfigurationService({
apiClient: client,
saleorApiUrl: authData.saleorApiUrl,
token: authData.token,
ssr: true,
});
const appConfigurations = await caller.appConfiguration.fetch();
const channelAppConfiguration = appConfigurations.configurationsPerChannel[channel];
const channelAppConfiguration = await appConfigurationService.getChannelConfiguration(channel);
if (!channelAppConfiguration) {
logger.warn("App has no configuration for this channel");
@ -50,18 +50,27 @@ export const sendEventMessages = async ({
if (channelAppConfiguration.mjmlConfigurationId) {
logger.debug("Channel has assigned MJML configuration");
const mjmlStatus = await sendMjml({
authData,
channel,
event,
payload,
recipientEmail,
mjmlConfigurationId: channelAppConfiguration.mjmlConfigurationId,
const mjmlConfigurationService = new MjmlConfigurationService({
apiClient: client,
saleorApiUrl: authData.saleorApiUrl,
});
if (mjmlStatus?.errors.length) {
logger.error("MJML errors");
logger.error(mjmlStatus?.errors);
const mjmlConfiguration = await mjmlConfigurationService.getConfiguration({
id: channelAppConfiguration.mjmlConfigurationId,
});
if (mjmlConfiguration) {
const mjmlStatus = await sendMjml({
event,
payload,
recipientEmail,
mjmlConfiguration,
});
if (mjmlStatus?.errors.length) {
logger.error("MJML errors");
logger.error(mjmlStatus?.errors);
}
}
}
const sendgridStatus = await sendSendgrid({

View file

@ -1,36 +1,107 @@
import { PrivateMetadataMjmlConfigurator } from "./mjml-configurator";
import { MjmlConfigurator, PrivateMetadataMjmlConfigurator } from "./mjml-configurator";
import { Client } from "urql";
import { logger as pinoLogger } from "../../../lib/logger";
import { createSettingsManager } from "../../app-configuration/metadata-manager";
import { MjmlConfig, MjmlConfiguration } from "./mjml-config";
import { FilterConfigurationsArgs, MjmlConfigContainer } from "./mjml-config-container";
// todo test
export class GetMjmlConfigurationService {
constructor(
private settings: {
apiClient: Client;
saleorApiUrl: string;
}
) {}
const logger = pinoLogger.child({
service: "MjmlConfigurationService",
});
async getConfiguration() {
const logger = pinoLogger.child({
service: "GetMjmlConfigurationService",
saleorApiUrl: this.settings.saleorApiUrl,
});
export class MjmlConfigurationService {
private configurationData?: MjmlConfig;
private metadataConfigurator: MjmlConfigurator;
const { saleorApiUrl, apiClient } = this.settings;
const mjmlConfigurator = new PrivateMetadataMjmlConfigurator(
createSettingsManager(apiClient),
saleorApiUrl
constructor(args: { apiClient: Client; saleorApiUrl: string; initialData?: MjmlConfig }) {
this.metadataConfigurator = new PrivateMetadataMjmlConfigurator(
createSettingsManager(args.apiClient),
args.saleorApiUrl
);
const savedMjmlConfig = (await mjmlConfigurator.getConfig()) ?? null;
logger.debug(savedMjmlConfig, "Retrieved MJML config from Metadata. Will return it");
if (savedMjmlConfig) {
return savedMjmlConfig;
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: MjmlConfig) {
logger.debug("Set configuration root");
this.configurationData = config;
await this.pushConfiguration();
}
async getConfiguration({ id }: { id: string }) {
logger.debug("Get configuration");
return MjmlConfigContainer.getConfiguration(await this.getConfigurationRoot())({ id });
}
async getConfigurations(filter: FilterConfigurationsArgs) {
logger.debug("Get configuration");
return MjmlConfigContainer.getConfigurations(await this.getConfigurationRoot())(filter);
}
async createConfiguration(config: Omit<MjmlConfiguration, "id" | "events">) {
logger.debug("Create configuration");
const updatedConfigurationRoot = MjmlConfigContainer.createConfiguration(
await this.getConfigurationRoot()
)(config);
await this.setConfigurationRoot(updatedConfigurationRoot);
return updatedConfigurationRoot.configurations[
updatedConfigurationRoot.configurations.length - 1
];
}
async updateConfiguration(config: MjmlConfiguration) {
logger.debug("Update configuration");
const updatedConfigurationRoot = MjmlConfigContainer.updateConfiguration(
await this.getConfigurationRoot()
)(config);
this.setConfigurationRoot(updatedConfigurationRoot);
}
async deleteConfiguration({ id }: { id: string }) {
logger.debug("Delete configuration");
const updatedConfigurationRoot = MjmlConfigContainer.deleteConfiguration(
await this.getConfigurationRoot()
)({ id });
this.setConfigurationRoot(updatedConfigurationRoot);
}
}

View file

@ -2,7 +2,9 @@ import { messageEventTypes } from "../../event-handlers/message-event-types";
import { MjmlConfig as MjmlConfigurationRoot, MjmlConfiguration } from "./mjml-config";
import { defaultMjmlTemplates, defaultMjmlSubjectTemplates } from "../default-templates";
const getDefaultEventsConfiguration = (): MjmlConfiguration["events"] =>
export const generateMjmlConfigurationId = () => Date.now().toString();
export const getDefaultEventsConfiguration = (): MjmlConfiguration["events"] =>
messageEventTypes.map((eventType) => ({
active: true,
eventType: eventType,
@ -41,7 +43,7 @@ const getConfiguration =
return mjmlConfigRoot.configurations.find((c) => c.id === id);
};
interface FilterConfigurationsArgs {
export interface FilterConfigurationsArgs {
ids?: string[];
active?: boolean;
}
@ -74,7 +76,7 @@ const createConfiguration =
// for creating a new configurations, the ID has to be generated
const newConfiguration = {
...mjmlConfiguration,
id: Date.now().toString(),
id: generateMjmlConfigurationId(),
events: getDefaultEventsConfiguration(),
};
mjmlConfigNormalized.configurations.unshift(newConfiguration);

View file

@ -1,4 +1,3 @@
import { PrivateMetadataMjmlConfigurator } from "./mjml-configurator";
import { logger as pinoLogger } from "../../../lib/logger";
import {
mjmlCreateConfigurationSchema,
@ -9,134 +8,96 @@ import {
mjmlUpdateEventConfigurationInputSchema,
mjmlUpdateOrCreateConfigurationSchema,
} from "./mjml-config-input-schema";
import { GetMjmlConfigurationService } from "./get-mjml-configuration.service";
import { MjmlConfigurationService } from "./get-mjml-configuration.service";
import { router } from "../../trpc/trpc-server";
import { protectedClientProcedure } from "../../trpc/protected-client-procedure";
import { createSettingsManager } from "../../app-configuration/metadata-manager";
import { z } from "zod";
import { compileMjml } from "../compile-mjml";
import Handlebars from "handlebars";
import { MjmlConfigContainer } from "./mjml-config-container";
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 MjmlConfigurationService({
apiClient: ctx.apiClient,
saleorApiUrl: ctx.saleorApiUrl,
}),
},
})
);
export const mjmlConfigurationRouter = router({
fetch: protectedClientProcedure.query(async ({ ctx }) => {
fetch: protectedWithConfigurationService.query(async ({ ctx }) => {
const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
logger.debug("mjmlConfigurationRouter.fetch called");
return new GetMjmlConfigurationService({
apiClient: ctx.apiClient,
saleorApiUrl: ctx.saleorApiUrl,
}).getConfiguration();
return ctx.configurationService.getConfigurationRoot();
}),
getConfiguration: protectedClientProcedure
getConfiguration: protectedWithConfigurationService
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(mjmlGetConfigurationInputSchema)
.query(async ({ ctx, input }) => {
const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
logger.debug(input, "mjmlConfigurationRouter.get called");
const mjmlConfigurator = new PrivateMetadataMjmlConfigurator(
createSettingsManager(ctx.apiClient),
ctx.saleorApiUrl
);
const configRoot = await mjmlConfigurator.getConfig();
return MjmlConfigContainer.getConfiguration(configRoot)(input);
return ctx.configurationService.getConfiguration(input);
}),
getConfigurations: protectedClientProcedure
getConfigurations: protectedWithConfigurationService
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(mjmlGetConfigurationsInputSchema)
.query(async ({ ctx, input }) => {
const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
logger.debug(input, "mjmlConfigurationRouter.getConfigurations called");
const mjmlConfigurator = new PrivateMetadataMjmlConfigurator(
createSettingsManager(ctx.apiClient),
ctx.saleorApiUrl
);
const configRoot = await mjmlConfigurator.getConfig();
return MjmlConfigContainer.getConfigurations(configRoot)(input);
return ctx.configurationService.getConfigurations(input);
}),
createConfiguration: protectedClientProcedure
createConfiguration: protectedWithConfigurationService
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(mjmlCreateConfigurationSchema)
.mutation(async ({ ctx, input }) => {
const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
logger.debug(input, "mjmlConfigurationRouter.create called");
const mjmlConfigurator = new PrivateMetadataMjmlConfigurator(
createSettingsManager(ctx.apiClient),
ctx.saleorApiUrl
);
const configRoot = await mjmlConfigurator.getConfig();
const newConfigurationRoot = MjmlConfigContainer.createConfiguration(configRoot)(input);
await mjmlConfigurator.setConfig(newConfigurationRoot);
return null;
return await ctx.configurationService.createConfiguration(input);
}),
deleteConfiguration: protectedClientProcedure
deleteConfiguration: protectedWithConfigurationService
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(mjmlDeleteConfigurationInputSchema)
.mutation(async ({ ctx, input }) => {
const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
logger.debug(input, "mjmlConfigurationRouter.delete called");
const mjmlConfigurator = new PrivateMetadataMjmlConfigurator(
createSettingsManager(ctx.apiClient),
ctx.saleorApiUrl
);
const configRoot = await mjmlConfigurator.getConfig();
const newConfigurationRoot = MjmlConfigContainer.deleteConfiguration(configRoot)(input);
await mjmlConfigurator.setConfig(newConfigurationRoot);
await ctx.configurationService.deleteConfiguration(input);
return null;
}),
updateOrCreateConfiguration: protectedClientProcedure
updateOrCreateConfiguration: protectedWithConfigurationService
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(mjmlUpdateOrCreateConfigurationSchema)
.mutation(async ({ ctx, input }) => {
const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
logger.debug(input, "mjmlConfigurationRouter.update or create called");
const mjmlConfigurator = new PrivateMetadataMjmlConfigurator(
createSettingsManager(ctx.apiClient),
ctx.saleorApiUrl
);
const configRoot = await mjmlConfigurator.getConfig();
const { id } = input;
if (!!id) {
const existingConfiguration = MjmlConfigContainer.getConfiguration(configRoot)({ id });
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",
});
}
// checking typeof id is not enough to satisfy typescript, so need to override id field issue
const configuration = {
id,
...input,
events: existingConfiguration.events,
};
const newConfigurationRoot =
MjmlConfigContainer.updateConfiguration(configRoot)(configuration);
await mjmlConfigurator.setConfig(newConfigurationRoot);
await ctx.configurationService.updateConfiguration(configuration);
return configuration;
} else {
const newConfigurationRoot = MjmlConfigContainer.createConfiguration(configRoot)(input);
await mjmlConfigurator.setConfig(newConfigurationRoot);
return newConfigurationRoot.configurations[newConfigurationRoot.configurations.length - 1];
}
}),
getEventConfiguration: protectedClientProcedure
getEventConfiguration: protectedWithConfigurationService
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(mjmlGetEventConfigurationInputSchema)
.query(async ({ ctx, input }) => {
@ -144,22 +105,17 @@ export const mjmlConfigurationRouter = router({
logger.debug(input, "mjmlConfigurationRouter.getEventConfiguration or create called");
const mjmlConfigurator = new PrivateMetadataMjmlConfigurator(
createSettingsManager(ctx.apiClient),
ctx.saleorApiUrl
);
const configRoot = await mjmlConfigurator.getConfig();
const configuration = MjmlConfigContainer.getConfiguration(configRoot)({
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({
@ -169,7 +125,7 @@ export const mjmlConfigurationRouter = router({
}
return event;
}),
updateEventConfiguration: protectedClientProcedure
updateEventConfiguration: protectedWithConfigurationService
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(mjmlUpdateEventConfigurationInputSchema)
.mutation(async ({ ctx, input }) => {
@ -177,22 +133,17 @@ export const mjmlConfigurationRouter = router({
logger.debug(input, "mjmlConfigurationRouter.updateEventConfiguration or create called");
const mjmlConfigurator = new PrivateMetadataMjmlConfigurator(
createSettingsManager(ctx.apiClient),
ctx.saleorApiUrl
);
const configRoot = await mjmlConfigurator.getConfig();
const configuration = MjmlConfigContainer.getConfiguration(configRoot)({
const configuration = await ctx.configurationService.getConfiguration({
id: input.configurationId,
});
if (!configuration) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Configuration not found",
});
}
const eventIndex = configuration.events.findIndex((e) => e.eventType === input.eventType);
configuration.events[eventIndex] = {
active: input.active,
@ -200,13 +151,11 @@ export const mjmlConfigurationRouter = router({
template: input.template,
subject: input.subject,
};
const newConfigurationRoot =
MjmlConfigContainer.updateConfiguration(configRoot)(configuration);
await mjmlConfigurator.setConfig(newConfigurationRoot);
await ctx.configurationService.updateConfiguration(configuration);
return configuration;
}),
renderTemplate: protectedClientProcedure
renderTemplate: protectedWithConfigurationService
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(
z.object({

View file

@ -116,19 +116,25 @@ export const MjmlConfigurationTab = ({ configurationId }: MjmlConfigurationTabPr
items={configurations?.map((c) => ({ label: c.configurationName, id: c.id })) || []}
/>
<div className={styles.configurationColumn}>
<MjmlConfigurationForm
onConfigurationSaved={() => refetchConfigurations()}
initialData={configuration || getDefaultEmptyConfiguration()}
configurationId={configurationId}
/>
{!!configurationId && !!configuration && (
<MjmlTemplatesCard
configurationId={configurationId}
configuration={configuration}
onEventChanged={() => {
refetchConfigurations();
}}
/>
{configurationsIsLoading ? (
<LoadingIndicator />
) : (
<>
<MjmlConfigurationForm
onConfigurationSaved={() => refetchConfigurations()}
initialData={configuration || getDefaultEmptyConfiguration()}
configurationId={configurationId}
/>
{!!configurationId && !!configuration && (
<MjmlTemplatesCard
configurationId={configurationId}
configuration={configuration}
onEventChanged={() => {
refetchConfigurations();
}}
/>
)}
</>
)}
</div>
</AppColumnsLayout>

View file

@ -1,43 +0,0 @@
import { AuthData } from "@saleor/app-sdk/APL";
import { appRouter } from "../trpc/trpc-app-router";
import { logger as pinoLogger } from "../../lib/logger";
interface GetMjmlSettingsArgs {
authData: AuthData;
channel: string;
configurationId: string;
}
export const getActiveMjmlSettings = async ({
authData,
channel,
configurationId,
}: GetMjmlSettingsArgs) => {
const logger = pinoLogger.child({
fn: "getMjmlSettings",
channel,
});
const caller = appRouter.createCaller({
appId: authData.appId,
saleorApiUrl: authData.saleorApiUrl,
token: authData.token,
ssr: true,
});
const configuration = await caller.mjmlConfiguration.getConfiguration({
id: configurationId,
});
if (!configuration) {
logger.warn(`The MJML configuration with id ${configurationId} does not exist`);
return;
}
if (!configuration.active) {
logger.warn(`The MJML configuration ${configuration.configurationName} is not active`);
return;
}
return configuration;
};

View file

@ -1,16 +1,13 @@
import { logger as pinoLogger } from "../../lib/logger";
import { AuthData } from "@saleor/app-sdk/APL";
import { getActiveMjmlSettings } from "./get-active-mjml-settings";
import { compileMjml } from "./compile-mjml";
import { compileHandlebarsTemplate } from "./compile-handlebars-template";
import { sendEmailWithSmtp } from "./send-email-with-smtp";
import { MessageEventTypes } from "../event-handlers/message-event-types";
import { htmlToPlaintext } from "./html-to-plaintext";
import { MjmlConfiguration } from "./configuration/mjml-config";
interface SendMjmlArgs {
authData: AuthData;
mjmlConfigurationId: string;
channel: string;
mjmlConfiguration: MjmlConfiguration;
recipientEmail: string;
event: MessageEventTypes;
payload: any;
@ -24,36 +21,17 @@ export interface EmailServiceResponse {
}
export const sendMjml = async ({
authData,
channel,
payload,
recipientEmail,
event,
mjmlConfigurationId,
mjmlConfiguration,
}: SendMjmlArgs) => {
const logger = pinoLogger.child({
fn: "sendMjml",
event,
});
const settings = await getActiveMjmlSettings({
authData,
channel,
configurationId: mjmlConfigurationId,
});
if (!settings) {
logger.debug("No active settings, skipping");
return {
errors: [
{
message: "No active settings",
},
],
};
}
const eventSettings = settings.events.find((e) => e.eventType === event);
const eventSettings = mjmlConfiguration.events.find((e) => e.eventType === event);
if (!eventSettings) {
logger.debug("No active settings for this event, skipping");
return {
@ -154,13 +132,13 @@ export const sendMjml = async ({
mailData: {
text: emailBodyPlaintext,
html: emailBodyHtml,
from: `${settings.senderName} <${settings.senderEmail}>`,
from: `${mjmlConfiguration.senderName} <${mjmlConfiguration.senderEmail}>`,
to: recipientEmail,
subject: emailSubject,
},
smtpSettings: {
host: settings.smtpHost,
port: parseInt(settings.smtpPort, 10),
host: mjmlConfiguration.smtpHost,
port: parseInt(mjmlConfiguration.smtpPort, 10),
},
});

View file

@ -4,7 +4,7 @@ import { TRPCError } from "@trpc/server";
import { ProtectedHandlerError } from "@saleor/app-sdk/handlers/next";
import { saleorApp } from "../../saleor-app";
import { logger } from "../../lib/logger";
import { createClient } from "../../lib/create-graphq-client";
import { createClient } from "../../lib/create-graphql-client";
const attachAppToken = middleware(async ({ ctx, next }) => {
logger.debug("attachAppToken middleware");

View file

@ -21,7 +21,6 @@ export const CodeEditor = ({ initialTemplate, onChange, value, language }: Props
const handleOnChange = useCallback(
(value?: string) => {
console.log("ON CHANGE");
onChange(value ?? "");
},
[value]

View file

@ -7,6 +7,7 @@ import {
OrderDetailsFragmentDoc,
} from "../../../../generated/graphql";
import { sendEventMessages } from "../../../modules/event-handlers/send-event-messages";
import { createClient } from "../../../lib/create-graphql-client";
const InvoiceSentWebhookPayload = gql`
${OrderDetailsFragmentDoc}
@ -71,10 +72,14 @@ const handler: NextWebhookApiHandler<InvoiceSentWebhookPayloadFragment> = async
}
const channel = order.channel.slug;
const client = createClient(authData.saleorApiUrl, async () =>
Promise.resolve({ token: authData.token })
);
await sendEventMessages({
authData,
channel,
client,
event: "INVOICE_SENT",
payload: { order: payload.order },
recipientEmail,

View file

@ -7,6 +7,7 @@ import {
OrderDetailsFragmentDoc,
} from "../../../../generated/graphql";
import { sendEventMessages } from "../../../modules/event-handlers/send-event-messages";
import { createClient } from "../../../lib/create-graphql-client";
const OrderCancelledWebhookPayload = gql`
${OrderDetailsFragmentDoc}
@ -62,10 +63,14 @@ const handler: NextWebhookApiHandler<OrderCancelledWebhookPayloadFragment> = asy
}
const channel = order.channel.slug;
const client = createClient(authData.saleorApiUrl, async () =>
Promise.resolve({ token: authData.token })
);
await sendEventMessages({
authData,
channel,
client,
event: "ORDER_CANCELLED",
payload: { order: payload.order },
recipientEmail,

View file

@ -7,6 +7,7 @@ import {
OrderDetailsFragmentDoc,
} from "../../../../generated/graphql";
import { sendEventMessages } from "../../../modules/event-handlers/send-event-messages";
import { createClient } from "../../../lib/create-graphql-client";
const OrderConfirmedWebhookPayload = gql`
${OrderDetailsFragmentDoc}
@ -63,10 +64,14 @@ const handler: NextWebhookApiHandler<OrderConfirmedWebhookPayloadFragment> = asy
}
const channel = order.channel.slug;
const client = createClient(authData.saleorApiUrl, async () =>
Promise.resolve({ token: authData.token })
);
await sendEventMessages({
authData,
channel,
client,
event: "ORDER_CONFIRMED",
payload: { order: payload.order },
recipientEmail,

View file

@ -1,10 +1,11 @@
import { OrderDetailsFragment, OrderDetailsFragmentDoc } from "./../../../../generated/graphql";
import { OrderDetailsFragmentDoc } from "./../../../../generated/graphql";
import { NextWebhookApiHandler, SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next";
import { gql } from "urql";
import { saleorApp } from "../../../saleor-app";
import { logger as pinoLogger } from "../../../lib/logger";
import { OrderCreatedWebhookPayloadFragment } from "../../../../generated/graphql";
import { sendEventMessages } from "../../../modules/event-handlers/send-event-messages";
import { createClient } from "../../../lib/create-graphql-client";
const OrderCreatedWebhookPayload = gql`
${OrderDetailsFragmentDoc}
@ -60,10 +61,14 @@ const handler: NextWebhookApiHandler<OrderCreatedWebhookPayloadFragment> = async
}
const channel = order.channel.slug;
const client = createClient(authData.saleorApiUrl, async () =>
Promise.resolve({ token: authData.token })
);
await sendEventMessages({
authData,
channel,
client,
event: "ORDER_CREATED",
payload: { order: payload.order },
recipientEmail,

View file

@ -7,6 +7,7 @@ import {
OrderFulfilledWebhookPayloadFragment,
} from "../../../../generated/graphql";
import { sendEventMessages } from "../../../modules/event-handlers/send-event-messages";
import { createClient } from "../../../lib/create-graphql-client";
const OrderFulfilledWebhookPayload = gql`
${OrderDetailsFragmentDoc}
@ -63,9 +64,13 @@ const handler: NextWebhookApiHandler<OrderFulfilledWebhookPayloadFragment> = asy
}
const channel = order.channel.slug;
const client = createClient(authData.saleorApiUrl, async () =>
Promise.resolve({ token: authData.token })
);
await sendEventMessages({
authData,
client,
channel,
event: "ORDER_FULFILLED",
payload: { order: payload.order },

View file

@ -7,6 +7,7 @@ import {
OrderFullyPaidWebhookPayloadFragment,
} from "../../../../generated/graphql";
import { sendEventMessages } from "../../../modules/event-handlers/send-event-messages";
import { createClient } from "../../../lib/create-graphql-client";
const OrderFullyPaidWebhookPayload = gql`
${OrderDetailsFragmentDoc}
@ -63,10 +64,14 @@ const handler: NextWebhookApiHandler<OrderFullyPaidWebhookPayloadFragment> = asy
}
const channel = order.channel.slug;
const client = createClient(authData.saleorApiUrl, async () =>
Promise.resolve({ token: authData.token })
);
await sendEventMessages({
authData,
channel,
client,
event: "ORDER_FULLY_PAID",
payload: { order: payload.order },
recipientEmail,

View file

@ -3,7 +3,7 @@ import React from "react";
import { useRouter } from "next/router";
import { trpcClient } from "../../../../../modules/trpc/trpc-client";
import { checkMessageEventType } from "../../../../../modules/event-handlers/check-message-event-type";
import { parseMessageEventType } from "../../../../../modules/event-handlers/parse-message-event-type";
import { ConfigurationPageBaseLayout } from "../../../../../modules/ui/configuration-page-base-layout";
import { EventConfigurationForm } from "../../../../../modules/mjml/configuration/ui/mjml-event-configuration-form";
import { LoadingIndicator } from "../../../../../modules/ui/loading-indicator";
@ -13,7 +13,7 @@ const EventConfigurationPage: NextPage = () => {
const configurationId = router.query.configurationId as string;
const eventTypeFromQuery = router.query.eventType as string | undefined;
const eventType = checkMessageEventType(eventTypeFromQuery);
const eventType = parseMessageEventType(eventTypeFromQuery);
const {
data: configuration,