Add emails and messages app

This commit is contained in:
Krzysztof Wolski 2023-03-06 14:01:03 +01:00
parent dca82bb93c
commit 807b809940
117 changed files with 43150 additions and 12 deletions

View file

@ -41,6 +41,7 @@ In the `apps` folder, you will find the following applications:
- [data-importer](./apps/data-importer) - import data from CSV to Saleor. - [data-importer](./apps/data-importer) - import data from CSV to Saleor.
- [invoices](./apps/invoices) - generate invoice PDF for each order. - [invoices](./apps/invoices) - generate invoice PDF for each order.
- [klaviyo](./apps/klaviyo) - send Saleor events to Klaviyo, where you can notify the customers. - [klaviyo](./apps/klaviyo) - send Saleor events to Klaviyo, where you can notify the customers.
- [emails-and-messages](./apps/emails-and-messages) - notifications and email communication with customers.
- [search](./apps/search) - connect Saleor with search engines. - [search](./apps/search) - connect Saleor with search engines.
- [slack](./apps/slack) - get notifications on Slack channel from Saleor events. - [slack](./apps/slack) - get notifications on Slack channel from Saleor events.
- [taxes](./apps/taxes) - calculate order and checkout taxes using external services. - [taxes](./apps/taxes) - calculate order and checkout taxes using external services.

View file

@ -0,0 +1,9 @@
# The key used for metadata encryption. Required for production builds
SECRET_KEY=
# APL Config
# https://github.com/saleor/saleor-app-sdk/blob/main/docs/apl.md
APL=
REST_APL_ENDPOINT=
REST_APL_TOKEN=

View file

@ -0,0 +1,4 @@
{
"root": true,
"extends": ["saleor"]
}

49
apps/emails-and-messages/.gitignore vendored Normal file
View file

@ -0,0 +1,49 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env*.local
.envfile
.saleor-app-auth.json
# vercel
.vercel
# typescript
*.tsbuildinfo
.auth_token
#editor
.vscode
.idea
# GraphQL auto-generated
generated/
# Sentry
.sentryclirc

View file

@ -0,0 +1,20 @@
schema: graphql/schema.graphql
documents: [graphql/**/*.graphql, src/**/*.ts, src/**/*.tsx]
extensions:
codegen:
overwrite: true
generates:
generated/graphql.ts:
config:
dedupeFragments: true
plugins:
- typescript
- typescript-operations
- urql-introspection
- typescript-urql:
documentVariablePrefix: "Untyped"
fragmentVariablePrefix: "Untyped"
- typed-document-node
generated/schema.graphql:
plugins:
- schema-ast

View file

@ -0,0 +1 @@
strict-peer-dependencies=false

View file

@ -0,0 +1,5 @@
.next
saleor/api.tsx
pnpm-lock.yaml
graphql/schema.graphql
generated

View file

@ -0,0 +1,4 @@
{
"singleQuote": false,
"printWidth": 100
}

View file

@ -0,0 +1,96 @@
![Hero image](https://user-images.githubusercontent.com/249912/71523206-4e45f800-28c8-11ea-84ba-345a9bfc998a.png)
<div align="center">
<h1>Emails and messages</h1>
</div>
### What's included?
- sending emails via SMTP
- MJML template support
- supported messages:
- order
- created
- confirmed
- cancelled
- fully fulfilled
- fully paid
- invoice
- sent
### How to install
- start local dev server or deploy the application
- install the application in your Saleor Instance using manifest URL
- configure the application in the dashboard
### Learn more about Apps
[Apps guide](https://docs.saleor.io/docs/3.x/developer/extending/apps/key-concepts)
[Configuring apps in dashboard](https://docs.saleor.io/docs/3.x/dashboard/apps)
## Development
### Requirements
Before you start, make sure you have installed:
- [Node.js](https://nodejs.org/en/)
- [pnpm](https://pnpm.io/)
- [Saleor CLI](https://docs.saleor.io/docs/3.x/cli) - optional, but recommended
1. Install the dependencies by running:
```
pnpm install
```
2. Start the local server with:
```
pnpm dev
```
3. Expose local environment using tunnel:
- Use Saleor CLI `saleor app tunnel`
- Use tunneling tools like [localtunnel](https://github.com/localtunnel/localtunnel) or [ngrok](https://ngrok.com/).
4. Install application at your dashboard:
If you use Saleor Cloud or your local server is exposed, you can install your app by following this link:
```
[YOUR_SALEOR_DASHBOARD_URL]/apps/install?manifestUrl=[YOUR_APP_TUNNEL_MANIFEST_URL]
```
This template host manifest at `/api/manifest`
You can also install application using GQL or command line. Follow the guide [how to install your app](https://docs.saleor.io/docs/3.x/developer/extending/apps/installing-apps#installation-using-graphql-api) to learn more.
5. Start the dev SMTP server
```bash
docker compose up
```
All emails will be captured by the MailHog service. To inspect emails, open `http://localhost:8025/` in your browser.
### Generated schema and typings
Commands `build` and `dev` would generate schema and typed functions using Saleor's GraphQL endpoint. Commit the `generated` folder to your repo as they are necessary for queries and keeping track of the schema changes.
[Learn more](https://www.graphql-code-generator.com/) about GraphQL code generation.
### Storing registration data - APL
During registration process Saleor API pass the auth token to the app. With this token App can query Saleor API with privileged access (depending on requested permissions during the installation).
To store this data, app-template use a different [APL interfaces](https://github.com/saleor/saleor-app-sdk/blob/main/docs/apl.md).
The choice of the APL is done using `APL` environment variable. If value is not set, FileAPL is used. Available choices:
- `file`: no additional setup is required. Good choice for local development. Can't be used for multi tenant-apps or be deployed (not intended for production)
- `upstash`: use [Upstash](https://upstash.com/) Redis as storage method. Free account required. Can be used for development and production and supports multi-tenancy. Requires `UPSTASH_URL` and `UPSTASH_TOKEN` environment variables to be set
If you want to use your own database, you can implement your own APL. [Check the documentation to read more.](https://github.com/saleor/saleor-app-sdk/blob/main/docs/apl.md)

View file

@ -0,0 +1,8 @@
version: "3.4"
services:
mailhog:
image: mailhog/mailhog
ports:
- 1025:1025 # smtp server
- 8025:8025 # web ui. Visit http://localhost:8025/ to check emails

View file

@ -0,0 +1,69 @@
fragment OrderDetails on Order {
id
number
userEmail
channel {
slug
}
user {
email
firstName
lastName
}
billingAddress {
streetAddress1
city
postalCode
country {
country
}
}
shippingAddress {
streetAddress1
city
postalCode
country {
country
}
}
lines {
id
productName
variantName
quantity
thumbnail {
url
alt
}
unitPrice {
gross {
currency
amount
}
}
totalPrice {
gross {
currency
amount
}
}
}
subtotal {
gross {
amount
currency
}
}
shippingPrice {
gross {
amount
currency
}
}
total {
gross {
amount
currency
}
}
}

View file

@ -0,0 +1,9 @@
query FetchAppDetails {
app {
id
privateMetadata {
key
value
}
}
}

View file

@ -0,0 +1,6 @@
query ShopDetails {
shop {
name
description
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

View file

@ -0,0 +1,4 @@
/** @type {import('next').NextConfig} */
module.exports = {
reactStrictMode: true,
};

View file

@ -0,0 +1,85 @@
{
"name": "saleor-app-emails-and-messages",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "pnpm generate && NODE_OPTIONS='--inspect' next dev",
"build": "pnpm generate && next build",
"start": "next start",
"lint": "pnpm generate && prettier --loglevel warn --write . && eslint --fix .",
"fetch-schema": "curl https://raw.githubusercontent.com/saleor/saleor/${npm_package_saleor_schemaVersion}/saleor/graphql/schema.graphql > graphql/schema.graphql",
"generate": "graphql-codegen",
"test": "vitest"
},
"saleor": {
"schemaVersion": "3.11.7"
},
"dependencies": {
"@material-ui/core": "^4.12.4",
"@material-ui/icons": "^4.11.3",
"@material-ui/lab": "4.0.0-alpha.61",
"@monaco-editor/react": "^4.4.6",
"@saleor/app-sdk": "0.30.0",
"@saleor/macaw-ui": "^0.7.2",
"@sendgrid/client": "^7.7.0",
"@sendgrid/mail": "^7.7.0",
"@tanstack/react-query": "^4.24.4",
"@trpc/client": "^10.13.0",
"@trpc/next": "^10.13.0",
"@trpc/react-query": "^10.13.0",
"@trpc/server": "^10.13.0",
"@urql/exchange-auth": "^1.0.0",
"@vitejs/plugin-react": "^3.0.1",
"clsx": "^1.2.1",
"graphql": "^16.6.0",
"graphql-tag": "^2.12.6",
"handlebars": "^4.7.7",
"html-to-text": "^9.0.3",
"jsdom": "^20.0.3",
"mjml": "^4.13.0",
"next": "13.2.1",
"next-urql": "^4.0.3",
"nodemailer": "^6.9.1",
"pino": "^8.8.0",
"pino-pretty": "^9.1.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-hook-form": "^7.43.0",
"react-is": "^18.2.0",
"react-query": "^3.39.3",
"urql": "^3.0.3",
"usehooks-ts": "^2.9.1",
"vite": "^4.0.4",
"vitest": "^0.27.1",
"zod": "^3.20.2",
"@saleor/apps-shared": "workspace:*"
},
"devDependencies": {
"@graphql-codegen/cli": "2.13.3",
"@graphql-codegen/introspection": "2.2.1",
"@graphql-codegen/typed-document-node": "^2.3.3",
"@graphql-codegen/typescript": "2.7.3",
"@graphql-codegen/typescript-operations": "2.5.3",
"@graphql-codegen/typescript-urql": "^3.7.0",
"@graphql-codegen/urql-introspection": "2.2.1",
"@graphql-typed-document-node/core": "^3.1.1",
"@testing-library/react": "^13.4.0",
"@testing-library/react-hooks": "^8.0.1",
"@types/html-to-text": "^9.0.0",
"@types/mjml": "^4.7.0",
"@types/node": "^18.11.18",
"@types/nodemailer": "^6.4.7",
"@types/react-dom": "^18.0.10",
"@types/react": "^18.0.26",
"eslint": "8.31.0",
"eslint-config-next": "13.1.2",
"eslint-config-prettier": "^8.6.0",
"prettier": "^2.8.2",
"typescript": "4.9.4",
"eslint-config-saleor": "workspace:*"
},
"lint-staged": {
"*.{js,ts,tsx}": "eslint --cache --fix",
"*.{js,ts,tsx,css,md,json}": "prettier --write"
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,2 @@
export const appName = "Emails and messages";
export const appBrandColor = "rgb(199, 159, 58)";

View file

@ -0,0 +1,48 @@
import { AuthConfig, authExchange } from "@urql/exchange-auth";
import {
cacheExchange,
createClient as urqlCreateClient,
dedupExchange,
fetchExchange,
} from "urql";
interface IAuthState {
token: string;
}
export const createClient = (url: string, getAuth: AuthConfig<IAuthState>["getAuth"]) =>
urqlCreateClient({
url,
exchanges: [
dedupExchange,
cacheExchange,
authExchange<IAuthState>({
addAuthToOperation: ({ authState, operation }) => {
if (!authState || !authState?.token) {
return operation;
}
const fetchOptions =
typeof operation.context.fetchOptions === "function"
? operation.context.fetchOptions()
: operation.context.fetchOptions || {};
return {
...operation,
context: {
...operation.context,
fetchOptions: {
...fetchOptions,
headers: {
...fetchOptions.headers,
"Authorization-Bearer": authState.token,
},
},
},
};
},
getAuth,
}),
fetchExchange,
],
});

View file

@ -0,0 +1,7 @@
export function isInIframe() {
try {
return window.self !== window.top;
} catch (e) {
return true;
}
}

View file

@ -0,0 +1,19 @@
import pino from "pino";
/**
* TODO Set up log drain etc
*/
export const logger = pino({
level: "debug",
transport:
process.env.NODE_ENV === "development"
? {
target: "pino-pretty",
options: {
colorize: true,
},
}
: undefined,
});
export const createLogger = logger.child.bind(logger);

View file

@ -0,0 +1,93 @@
import { MetadataEntry, EncryptedMetadataManager } from "@saleor/app-sdk/settings-manager";
import { Client, gql } from "urql";
import {
FetchAppDetailsDocument,
FetchAppDetailsQuery,
UpdateAppMetadataDocument,
} from "../../generated/graphql";
gql`
mutation UpdateAppMetadata($id: ID!, $input: [MetadataInput!]!) {
updatePrivateMetadata(id: $id, input: $input) {
item {
privateMetadata {
key
value
}
}
}
}
`;
gql`
query FetchAppDetails {
app {
id
privateMetadata {
key
value
}
}
}
`;
export async function fetchAllMetadata(client: Client): Promise<MetadataEntry[]> {
const { error, data } = await client
.query<FetchAppDetailsQuery>(FetchAppDetailsDocument, {})
.toPromise();
if (error) {
return [];
}
return data?.app?.privateMetadata.map((md) => ({ key: md.key, value: md.value })) || [];
}
export async function mutateMetadata(client: Client, metadata: MetadataEntry[]) {
// to update the metadata, ID is required
const { error: idQueryError, data: idQueryData } = await client
.query(FetchAppDetailsDocument, {})
.toPromise();
if (idQueryError) {
throw new Error(
"Could not fetch the app id. Please check if auth data for the client are valid."
);
}
const appId = idQueryData?.app?.id;
if (!appId) {
throw new Error("Could not fetch the app ID");
}
const { error: mutationError, data: mutationData } = await client
.mutation(UpdateAppMetadataDocument, {
id: appId,
input: metadata,
})
.toPromise();
if (mutationError) {
throw new Error(`Mutation error: ${mutationError.message}`);
}
return (
mutationData?.updatePrivateMetadata?.item?.privateMetadata.map((md) => ({
key: md.key,
value: md.value,
})) || []
);
}
export const createSettingsManager = (client: Client) => {
// EncryptedMetadataManager gives you interface to manipulate metadata and cache values in memory.
// We recommend it for production, because all values are encrypted.
// If your use case require plain text values, you can use MetadataManager.
return new EncryptedMetadataManager({
// Secret key should be randomly created for production and set as environment variable
encryptionKey: process.env.SECRET_KEY!,
fetchMetadata: () => fetchAllMetadata(client),
mutateMetadata: (metadata) => mutateMetadata(client, metadata),
});
};

View file

@ -0,0 +1,19 @@
import React, { PropsWithChildren } from "react";
import dynamic from "next/dynamic";
const Wrapper = (props: PropsWithChildren<{}>) => <React.Fragment>{props.children}</React.Fragment>;
/**
* Saleor App can be rendered only as a Saleor Dashboard iframe.
* All content is rendered after Dashboard exchanges auth with the app.
* Hence, there is no reason to render app server side.
*
* This component forces app to work in SPA-mode. It simplifies browser-only code and reduces need
* of using dynamic() calls
*
* You can use this wrapper selectively for some pages or remove it completely.
* It doesn't affect Saleor communication, but may cause problems with some client-only code.
*/
export const NoSSRWrapper = dynamic(() => Promise.resolve(Wrapper), {
ssr: false,
});

View file

@ -0,0 +1,48 @@
import { describe, expect, it, vi } from "vitest";
import { AppBridgeState } from "@saleor/app-sdk/app-bridge";
import { render, waitFor } from "@testing-library/react";
import { ThemeSynchronizer } from "./theme-synchronizer";
const appBridgeState: AppBridgeState = {
ready: true,
token: "token",
domain: "some-domain.saleor.cloud",
theme: "dark",
path: "/",
locale: "en",
id: "app-id",
saleorApiUrl: "https://some-domain.saleor.cloud/graphql/",
};
const mockThemeChange = vi.fn();
vi.mock("@saleor/app-sdk/app-bridge", () => {
return {
useAppBridge() {
return {
appBridgeState: appBridgeState,
};
},
};
});
vi.mock("@saleor/macaw-ui", () => {
return {
useTheme() {
return {
setTheme: mockThemeChange,
themeType: "light",
};
},
};
});
describe("ThemeSynchronizer", () => {
it("Updates MacawUI theme when AppBridgeState theme changes", () => {
render(<ThemeSynchronizer />);
return waitFor(() => {
expect(mockThemeChange).toHaveBeenCalledWith("dark");
});
});
});

View file

@ -0,0 +1,33 @@
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
import { useTheme } from "@saleor/macaw-ui";
import { memo, useEffect } from "react";
/**
* Macaw-ui stores its theme mode in memory and local storage. To synchronize App with Dashboard,
* Macaw must be informed about this change from AppBridge.
*
* If you are not using Macaw, you can remove this.
*/
function _ThemeSynchronizer() {
const { appBridgeState } = useAppBridge();
const { setTheme, themeType } = useTheme();
useEffect(() => {
if (!setTheme || !appBridgeState?.theme) {
return;
}
if (themeType !== appBridgeState?.theme) {
setTheme(appBridgeState.theme);
/**
* Hack to fix macaw, which is going into infinite loop on light mode (probably de-sync local storage with react state)
* TODO Fix me when Macaw 2.0 is shipped
*/
window.localStorage.setItem("macaw-ui-theme", appBridgeState.theme);
}
}, [appBridgeState?.theme, setTheme, themeType]);
return null;
}
export const ThemeSynchronizer = memo(_ThemeSynchronizer);

View file

@ -0,0 +1,33 @@
import { AppConfig, AppConfigurationPerChannel } from "./app-config";
export const getDefaultEmptyAppConfiguration = (): AppConfigurationPerChannel => ({
active: false,
mjmlConfigurationId: undefined,
sendgridConfigurationId: undefined,
});
const getChannelAppConfiguration =
(appConfig: AppConfig | null | undefined) => (channelSlug: string) => {
try {
return appConfig?.configurationsPerChannel[channelSlug] ?? null;
} catch (e) {
return null;
}
};
const setChannelAppConfiguration =
(appConfig: AppConfig | null | undefined) =>
(channelSlug: string) =>
(appConfiguration: AppConfigurationPerChannel) => {
const appConfigNormalized = structuredClone(appConfig) ?? { configurationsPerChannel: {} };
appConfigNormalized.configurationsPerChannel[channelSlug] ??= getDefaultEmptyAppConfiguration();
appConfigNormalized.configurationsPerChannel[channelSlug] = appConfiguration;
return appConfigNormalized;
};
export const AppConfigContainer = {
getChannelAppConfiguration,
setChannelAppConfiguration,
};

View file

@ -0,0 +1,11 @@
import { z } from "zod";
export const appConfigInputSchema = z.object({
configurationsPerChannel: z.record(
z.object({
active: z.boolean(),
mjmlConfigurationId: z.string().optional(),
sendgridConfigurationId: z.string().optional(),
})
),
});

View file

@ -0,0 +1,11 @@
export interface AppConfigurationPerChannel {
active: boolean;
mjmlConfigurationId?: string;
sendgridConfigurationId?: string;
}
export type AppConfigurationsChannelMap = Record<string, AppConfigurationPerChannel>;
export type AppConfig = {
configurationsPerChannel: AppConfigurationsChannelMap;
};

View file

@ -0,0 +1,37 @@
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 { router } from "../trpc/trpc-server";
import { protectedClientProcedure } from "../trpc/protected-client-procedure";
export const appConfigurationRouter = router({
fetch: protectedClientProcedure.query(async ({ ctx, input }) => {
const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
logger.debug("appConfigurationRouter.fetch called");
return new GetAppConfigurationService({
apiClient: ctx.apiClient,
saleorApiUrl: ctx.saleorApiUrl,
}).getConfiguration();
}),
setAndReplace: protectedClientProcedure
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(appConfigInputSchema)
.mutation(async ({ ctx, input }) => {
const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
logger.debug(input, "appConfigurationRouter.setAndReplace called with input");
const appConfigurator = new PrivateMetadataAppConfigurator(
createSettingsManager(ctx.apiClient),
ctx.saleorApiUrl
);
await appConfigurator.setConfig(input);
return null;
}),
});

View file

@ -0,0 +1,35 @@
import { AppConfig } from "./app-config";
import { SettingsManager } from "@saleor/app-sdk/settings-manager";
export interface AppConfigurator {
setConfig(config: AppConfig): Promise<void>;
getConfig(): Promise<AppConfig | undefined>;
}
export class PrivateMetadataAppConfigurator implements AppConfigurator {
private metadataKey = "app-config";
constructor(private metadataManager: SettingsManager, private saleorApiUrl: string) {}
getConfig(): Promise<AppConfig | undefined> {
return this.metadataManager.get(this.metadataKey, this.saleorApiUrl).then((data) => {
if (!data) {
return data;
}
try {
return JSON.parse(data);
} catch (e) {
throw new Error("Invalid metadata value, cant be parsed");
}
});
}
setConfig(config: AppConfig): Promise<void> {
return this.metadataManager.set({
key: this.metadataKey,
value: JSON.stringify(config),
domain: this.saleorApiUrl,
});
}
}

View file

@ -0,0 +1,22 @@
import { AppConfig } from "./app-config";
import { AppConfigContainer, getDefaultEmptyAppConfiguration } from "./app-config-container";
import { ChannelFragment, ShopInfoFragment } from "../../../generated/graphql";
/**
* TODO Test
*/
export const FallbackAppConfig = {
createFallbackConfigFromExistingShopAndChannels(
channels: ChannelFragment[],
shopAppConfiguration: ShopInfoFragment | null
) {
return (channels ?? []).reduce<AppConfig>(
(state, channel) => {
return AppConfigContainer.setChannelAppConfiguration(state)(channel.slug)(
getDefaultEmptyAppConfiguration()
);
},
{ configurationsPerChannel: {} }
);
},
};

View file

@ -0,0 +1,65 @@
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";
// todo test
export class GetAppConfigurationService {
constructor(
private settings: {
apiClient: Client;
saleorApiUrl: string;
}
) {}
async getConfiguration() {
const logger = pinoLogger.child({
service: "GetAppConfigurationService",
saleorApiUrl: this.settings.saleorApiUrl,
});
const { saleorApiUrl, apiClient } = this.settings;
const appConfigurator = new PrivateMetadataAppConfigurator(
createSettingsManager(apiClient),
saleorApiUrl
);
const savedAppConfig = (await appConfigurator.getConfig()) ?? 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.");
const channelsFetcher = new ChannelsFetcher(apiClient);
const shopInfoFetcher = new ShopInfoFetcher(apiClient);
const [channels, shopAppConfiguration] = await Promise.all([
channelsFetcher.fetchChannels(),
shopInfoFetcher.fetchShopInfo(),
]);
logger.debug(channels, "Fetched channels");
logger.debug(shopAppConfiguration, "Fetched shop app configuration");
const appConfig = FallbackAppConfig.createFallbackConfigFromExistingShopAndChannels(
channels ?? [],
shopAppConfiguration
);
logger.debug(appConfig, "Created a fallback AppConfig. Will save it.");
await appConfigurator.setConfig(appConfig);
logger.info("Saved initial AppConfig");
return appConfig;
}
}

View file

@ -0,0 +1,93 @@
import { MetadataEntry, EncryptedMetadataManager } from "@saleor/app-sdk/settings-manager";
import { Client, gql } from "urql";
import {
FetchAppDetailsDocument,
FetchAppDetailsQuery,
UpdateAppMetadataDocument,
} from "../../../generated/graphql";
gql`
mutation UpdateAppMetadata($id: ID!, $input: [MetadataInput!]!) {
updatePrivateMetadata(id: $id, input: $input) {
item {
privateMetadata {
key
value
}
}
}
}
`;
gql`
query FetchAppDetails {
app {
id
privateMetadata {
key
value
}
}
}
`;
export async function fetchAllMetadata(client: Client): Promise<MetadataEntry[]> {
const { error, data } = await client
.query<FetchAppDetailsQuery>(FetchAppDetailsDocument, {})
.toPromise();
if (error) {
return [];
}
return data?.app?.privateMetadata.map((md) => ({ key: md.key, value: md.value })) || [];
}
export async function mutateMetadata(client: Client, metadata: MetadataEntry[]) {
// to update the metadata, ID is required
const { error: idQueryError, data: idQueryData } = await client
.query(FetchAppDetailsDocument, {})
.toPromise();
if (idQueryError) {
throw new Error(
"Could not fetch the app id. Please check if auth data for the client are valid."
);
}
const appId = idQueryData?.app?.id;
if (!appId) {
throw new Error("Could not fetch the app ID");
}
const { error: mutationError, data: mutationData } = await client
.mutation(UpdateAppMetadataDocument, {
id: appId,
input: metadata,
})
.toPromise();
if (mutationError) {
throw new Error(`Mutation error: ${mutationError.message}`);
}
return (
mutationData?.updatePrivateMetadata?.item?.privateMetadata.map((md) => ({
key: md.key,
value: md.value,
})) || []
);
}
export const createSettingsManager = (client: Client) => {
// EncryptedMetadataManager gives you interface to manipulate metadata and cache values in memory.
// We recommend it for production, because all values are encrypted.
// If your use case require plain text values, you can use MetadataManager.
return new EncryptedMetadataManager({
// Secret key should be randomly created for production and set as environment variable
encryptionKey: process.env.SECRET_KEY!,
fetchMetadata: () => fetchAllMetadata(client),
mutateMetadata: (metadata) => mutateMetadata(client, metadata),
});
};

View file

@ -0,0 +1,181 @@
import { AppConfigurationPerChannel } from "../app-config";
import { Controller, useForm } from "react-hook-form";
import { FormControl, InputLabel, Link, MenuItem, Select, Typography } from "@material-ui/core";
import { Button, makeStyles, SwitchSelector, SwitchSelectorButton } from "@saleor/macaw-ui";
import React, { useEffect } from "react";
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
import { useRouter } from "next/router";
import { mjmlUrls } from "../../mjml/urls";
const useStyles = makeStyles((theme) => ({
field: {
marginBottom: 20,
},
channelName: {
cursor: "pointer",
borderBottom: `2px solid ${theme.palette.secondary.main}`,
},
}));
type AppConfigurationFormProps = {
channelSlug: string;
channelName: string;
channelID: string;
mjmlConfigurationChoices: { label: string; value: string }[];
sendgridConfigurationChoices: { label: string; value: string }[];
onSubmit(data: AppConfigurationPerChannel): Promise<void>;
initialData?: AppConfigurationPerChannel | null;
};
export const AppConfigurationForm = (props: AppConfigurationFormProps) => {
const styles = useStyles();
const { appBridge } = useAppBridge();
const router = useRouter();
const { handleSubmit, getValues, setValue, control, reset } = useForm<AppConfigurationPerChannel>(
{
defaultValues: props.initialData ?? undefined,
}
);
useEffect(() => {
reset(props.initialData || undefined);
}, [props.initialData, reset]);
const handleChannelNameClick = () => {
appBridge?.dispatch(
actions.Redirect({
to: `/channels/${props.channelID}`,
})
);
};
const isNoSendgridConfigurations = !props.sendgridConfigurationChoices.length;
const isNoMjmlConfigurations = !props.mjmlConfigurationChoices.length;
return (
<form
onSubmit={handleSubmit((data, event) => {
props.onSubmit(data);
})}
>
<Typography variant="h2" paragraph>
Configure
<span onClick={handleChannelNameClick} className={styles.channelName}>
{` ${props.channelName} `}
</span>
channel:
</Typography>
<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="mjmlConfigurationId"
render={({ field: { value, onChange } }) => {
return (
<FormControl disabled={isNoMjmlConfigurations} className={styles.field} fullWidth>
<InputLabel>MJML Configuration</InputLabel>
<Select
variant="outlined"
value={value}
onChange={(event, val) => {
onChange(event.target.value);
}}
>
<MenuItem key="none" value={undefined}>
No configuration
</MenuItem>
{props.mjmlConfigurationChoices.map((choice) => (
<MenuItem key={choice.value} value={choice.value}>
{choice.label}
</MenuItem>
))}
</Select>
{isNoMjmlConfigurations && (
<Link
href="#"
onClick={() => {
router.push(mjmlUrls.configuration());
}}
>
<Typography variant="caption" color="textSecondary">
Currently theres no MJML configuration available. Click here to create one.
</Typography>
</Link>
)}
</FormControl>
);
}}
/>
<Controller
control={control}
name="sendgridConfigurationId"
render={({ field: { value, onChange } }) => {
return (
<FormControl disabled={isNoSendgridConfigurations} className={styles.field} fullWidth>
<InputLabel>Sendgrid Configuration</InputLabel>
<Select
variant="outlined"
value={value}
onChange={(event, val) => {
onChange(event.target.value);
}}
>
<MenuItem key="none" value={undefined}>
No configuration
</MenuItem>
{props.sendgridConfigurationChoices.map((choice) => (
<MenuItem key={choice.value} value={choice.value}>
{choice.label}
</MenuItem>
))}
</Select>
{isNoSendgridConfigurations && (
<Link
href="#"
onClick={() => {
router.push("");
}}
>
<Typography variant="caption" color="textSecondary">
Currently theres no Sendgrid configuration available. Click here to create one.
</Typography>
</Link>
)}
</FormControl>
);
}}
/>
<Button type="submit" fullWidth variant="primary">
Save configuration
</Button>
</form>
);
};

View file

@ -0,0 +1,163 @@
import React, { useEffect, 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";
import { trpcClient } from "../../trpc/trpc-client";
import SideMenu from "./side-menu";
import { LoadingIndicator } from "../../ui/loading-indicator";
const useStyles = makeStyles((theme) => {
return {
formContainer: {
top: 0,
},
instructionsContainer: {
padding: 15,
},
configurationColumn: {
display: "flex",
flexDirection: "column",
gap: 20,
maxWidth: 600,
},
};
});
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 activeChannel = useMemo(() => {
try {
return channels!.find((c) => c.slug === activeChannelSlug)!;
} catch (e) {
return null;
}
}, [channels, activeChannelSlug]);
if (isChannelsLoading) {
return <LoadingIndicator />;
}
if (!channels?.length) {
return <div>NO CHANNELS</div>;
}
if (!activeChannel) {
return <div>Error. No channel available</div>;
}
return (
<AppColumnsLayout>
<SideMenu
title="Channels"
selectedItemId={activeChannel.slug}
headerToolbar={
<IconButton
variant="secondary"
onClick={() => {
appBridge?.dispatch(
actions.Redirect({
to: `/channels/`,
})
);
}}
>
<EditIcon />
</IconButton>
}
onClick={(id) => setActiveChannelSlug(id)}
items={channels.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}
</AppColumnsLayout>
);
};

View file

@ -0,0 +1,72 @@
import {
makeStyles,
OffsettedList,
OffsettedListBody,
OffsettedListHeader,
OffsettedListItem,
OffsettedListItemCell,
} from "@saleor/macaw-ui";
import clsx from "clsx";
import { Typography } from "@material-ui/core";
import React from "react";
import { ChannelFragment } from "../../../../generated/graphql";
const useStyles = makeStyles((theme) => {
return {
listItem: {
cursor: "pointer",
height: "auto !important",
},
listItemActive: {
background: "#f4f4f4",
borderRadius: 4,
overflow: "hidden",
},
channelSlug: {
fontFamily: "monospace",
opacity: 0.8,
},
};
});
type Props = {
channels: ChannelFragment[];
activeChannelSlug: string;
onChannelClick(channelSlug: string): void;
};
export const ChannelsList = ({ channels, activeChannelSlug, onChannelClick }: Props) => {
const styles = useStyles();
return (
<OffsettedList gridTemplate={["1fr"]}>
<OffsettedListHeader>
<Typography variant="h3" paragraph>
Available channels
</Typography>
</OffsettedListHeader>
<OffsettedListBody>
{channels.map((c) => {
return (
<OffsettedListItem
className={clsx(styles.listItem, {
[styles.listItemActive]: c.slug === activeChannelSlug,
})}
key={c.slug}
onClick={() => {
onChannelClick(c.slug);
}}
>
<OffsettedListItemCell>
{c.name}
<Typography variant="caption" className={styles.channelSlug}>
{c.slug}
</Typography>
</OffsettedListItemCell>
</OffsettedListItem>
);
})}
</OffsettedListBody>
</OffsettedList>
);
};

View file

@ -0,0 +1,87 @@
import {
DeleteIcon,
IconButton,
makeStyles,
OffsettedList,
OffsettedListBody,
OffsettedListItem,
OffsettedListItemCell,
} from "@saleor/macaw-ui";
import clsx from "clsx";
import React from "react";
const useStyles = makeStyles((theme) => {
return {
listItem: {
cursor: "pointer",
height: "auto !important",
},
listItemActive: {
background: "#f4f4f4",
borderRadius: 4,
overflow: "hidden",
},
channelSlug: {
fontFamily: "monospace",
opacity: 0.8,
},
};
});
type ListItem = {
label: string;
id: string;
};
type Props = {
listItems: ListItem[];
activeItemId?: string;
onItemClick(itemId?: string): void;
};
export const ConfigurationsList = ({ listItems, activeItemId, onItemClick }: Props) => {
const styles = useStyles();
return (
<OffsettedList gridTemplate={["1fr"]}>
<OffsettedListBody>
{listItems.map((c) => {
return (
<OffsettedListItem
className={clsx(styles.listItem, {
[styles.listItemActive]: c.id === activeItemId,
})}
key={c.id}
onClick={() => {
onItemClick(c.id);
}}
>
<OffsettedListItemCell>
{c.label}
<IconButton
variant="secondary"
onClick={(event) => {
event.stopPropagation();
event.preventDefault();
}}
>
<DeleteIcon />
</IconButton>
</OffsettedListItemCell>
</OffsettedListItem>
);
})}
<OffsettedListItem
className={clsx(styles.listItem, {
[styles.listItemActive]: activeItemId === undefined,
})}
key="new"
onClick={() => {
onItemClick();
}}
>
<OffsettedListItemCell>Create new</OffsettedListItemCell>
</OffsettedListItem>
</OffsettedListBody>
</OffsettedList>
);
};

View file

@ -0,0 +1,42 @@
import { Link, Paper, Typography } from "@material-ui/core";
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
import { makeStyles } from "@saleor/macaw-ui";
const useStyles = makeStyles((theme) => {
return {
instructionsContainer: {
padding: 15,
},
};
});
export const Instructions = () => {
const styles = useStyles();
const { appBridge } = useAppBridge();
return (
<Paper elevation={0} className={styles.instructionsContainer}>
<Typography paragraph variant="h4">
Usage instructions
</Typography>
<Typography paragraph>
Example of the external link to the documentation{" "}
<Link
href="#"
onClick={() => {
appBridge?.dispatch(
actions.Redirect({
to: "https://saleor.io/",
newContext: true,
})
);
}}
>
here
</Link>
.
</Typography>
</Paper>
);
};

View file

@ -0,0 +1,111 @@
import { Card, CardContent, CardHeader, Divider } from "@material-ui/core";
("@material-ui/icons");
import { DeleteIcon, IconButton, List, ListItem, ListItemCell } from "@saleor/macaw-ui";
import clsx from "clsx";
import React from "react";
import { makeStyles } from "@saleor/macaw-ui";
import { Skeleton } from "@material-ui/lab";
export const useStyles = makeStyles((theme) => ({
menu: {
height: "fit-content",
},
clickable: {
cursor: "pointer",
},
selected: {
"&&&&::before": {
position: "absolute",
left: 0,
width: "4px",
height: "100%",
backgroundColor: theme.palette.saleor.active[1],
},
},
spaceBetween: {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
},
tableRow: {
minHeight: "48px",
"&::after": {
display: "none",
},
},
greyText: {
color: theme.palette.text.hint,
},
link: {
all: "inherit",
display: "contents",
},
}));
interface SideMenuProps {
title: string;
noItemsText?: string;
items: { id: string; label: string }[];
selectedItemId?: string;
headerToolbar?: React.ReactNode;
onDelete?: (itemId: string) => void;
onClick: (itemId: string) => void;
}
export const SideMenu: React.FC<SideMenuProps> = ({
title,
items,
headerToolbar,
selectedItemId,
noItemsText,
onDelete,
onClick,
}) => {
const classes = useStyles();
const isNoItems = !items || !items.length;
return (
<Card className={classes.menu}>
<CardHeader title={title} action={headerToolbar} />
{isNoItems ? (
!!noItemsText && <CardContent className={classes.greyText}>{noItemsText}</CardContent>
) : (
<List gridTemplate={["1fr"]}>
{items.map((item) => (
<React.Fragment key={item.id}>
<Divider />
<ListItem
className={clsx(classes.clickable, classes.tableRow, {
[classes.selected]: item.id === selectedItemId,
})}
onClick={() => onClick(item.id)}
>
<ListItemCell>
<div className={classes.spaceBetween}>
{item.label}
{!!onDelete && (
<IconButton
variant="secondary"
onClick={(event) => {
event.stopPropagation();
event.preventDefault();
onDelete(item.id);
}}
>
<DeleteIcon />
</IconButton>
)}
</div>
</ListItemCell>
</ListItem>
</React.Fragment>
)) ?? <Skeleton />}
<Divider />
</List>
)}
</Card>
);
};
export default SideMenu;

View file

@ -0,0 +1,4 @@
export const channelUrls = {
configuration: (id?: string) =>
!id ? "/configuration/channels" : `/configuration/channels/${id}`,
};

View file

@ -0,0 +1,27 @@
import { Client, gql } from "urql";
import { FetchChannelsDocument } from "../../../generated/graphql";
gql`
fragment Channel on Channel {
name
id
slug
}
query FetchChannels {
channels {
...Channel
}
}
`;
export class ChannelsFetcher {
constructor(private client: Client) {}
fetchChannels() {
return this.client
.query(FetchChannelsDocument, {})
.toPromise()
.then((r) => r.data?.channels ?? null);
}
}

View file

@ -0,0 +1,17 @@
import { ChannelsFetcher } from "./channels-fetcher";
import { ChannelFragment } from "../../../generated/graphql";
import { createClient } from "../../lib/create-graphq-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[]> => {
const client = createClient(ctx.saleorApiUrl, async () =>
Promise.resolve({ token: ctx.appToken })
);
const fetcher = new ChannelsFetcher(client);
return fetcher.fetchChannels().then((channels) => channels ?? []);
}),
});

View file

@ -0,0 +1,10 @@
import { MessageEventTypes, messageEventTypes } from "./message-event-types";
/**
* Returns the event type if it is valid, otherwise undefined.
*/
export const checkMessageEventType = (eventType?: string): MessageEventTypes | undefined => {
if (messageEventTypes.includes(eventType as MessageEventTypes)) {
return eventType as MessageEventTypes;
}
};

View file

@ -0,0 +1,146 @@
import { MessageEventTypes } from "./message-event-types";
import {
OrderDetailsFragment,
OrderCreatedWebhookPayloadFragment,
OrderConfirmedWebhookPayloadFragment,
OrderCancelledWebhookPayloadFragment,
OrderFulfilledWebhookPayloadFragment,
OrderFullyPaidWebhookPayloadFragment,
InvoiceSentWebhookPayloadFragment,
} from "../../../generated/graphql";
const exampleOrderPayload: OrderDetailsFragment = {
id: "T3JkZXI6NTdiNTBhNDAtYzRmYi00YjQzLWIxODgtM2JhZmRlMTc3MGQ5",
number: "198",
userEmail: "adrian.king@example.com",
channel: {
slug: "default-channel",
},
user: {
email: "adrian.king@example.com",
firstName: "Adrian",
lastName: "King",
},
billingAddress: {
streetAddress1: "59314 Mary Well Suite 281",
city: "METROPOLIS",
postalCode: "71653",
country: {
country: "United States of America",
},
},
shippingAddress: {
streetAddress1: "59314 Mary Well Suite 281",
city: "METROPOLIS",
postalCode: "71653",
country: {
country: "United States of America",
},
},
lines: [
{
id: "T3JkZXJMaW5lOjNkNjc4OWE3LWUyNWItNDBlMi1iNjk2LTdmMzA0ZWFjOWI2OQ==",
productName: "Black Hoodie",
variantName: "XL",
quantity: 1,
thumbnail: {
url: "https://placehold.jp/150x150.png",
alt: "",
},
unitPrice: {
gross: {
currency: "USD",
amount: 5,
},
},
totalPrice: {
gross: {
currency: "USD",
amount: 5,
},
},
},
{
id: "T3JkZXJMaW5lOjVhYmEzMTBkLTZkMzEtNDNlNy1hZjAyLTdlNGUwM2UzYmI4ZA==",
productName: "Code Division T-shirt",
variantName: "L",
quantity: 1,
thumbnail: {
url: "https://placehold.jp/150x150.png",
alt: "",
},
unitPrice: {
gross: {
currency: "USD",
amount: 5,
},
},
totalPrice: {
gross: {
currency: "USD",
amount: 5,
},
},
},
],
subtotal: {
gross: {
amount: 10,
currency: "USD",
},
},
shippingPrice: {
gross: {
amount: 61.62,
currency: "USD",
},
},
total: {
gross: {
amount: 71.62,
currency: "USD",
},
},
};
const orderCreatedPayload: OrderCreatedWebhookPayloadFragment = {
order: exampleOrderPayload,
};
const orderConfirmedPayload: OrderConfirmedWebhookPayloadFragment = {
order: exampleOrderPayload,
};
const orderCancelledPayload: OrderCancelledWebhookPayloadFragment = {
order: exampleOrderPayload,
};
const orderFulfilledPayload: OrderFulfilledWebhookPayloadFragment = {
order: exampleOrderPayload,
};
const orderFullyPaidPayload: OrderFullyPaidWebhookPayloadFragment = {
order: exampleOrderPayload,
};
const invoiceSentPayload: InvoiceSentWebhookPayloadFragment = {
invoice: {
id: "SW52b2ljZToxMDE=",
message: null,
externalUrl: null,
url: "https://example.com/media/invoices/invoice-1032023-order-57b50a40-c4fb-4b43-b188-3bafde1770d9-fa968541-02fa-4317-b121-7205.pdf",
order: {
id: "T3JkZXI6NTdiNTBhNDAtYzRmYi00YjQzLWIxODgtM2JhZmRlMTc3MGQ5",
},
},
order: exampleOrderPayload,
};
export const examplePayloads: Record<MessageEventTypes, any> = {
ORDER_CREATED: orderCreatedPayload,
ORDER_CONFIRMED: orderConfirmedPayload,
ORDER_CANCELLED: orderCancelledPayload,
ORDER_FULFILLED: orderFulfilledPayload,
ORDER_FULLY_PAID: orderFullyPaidPayload,
INVOICE_SENT: invoiceSentPayload,
};

View file

@ -0,0 +1,19 @@
export const messageEventTypes = [
"ORDER_CREATED",
"ORDER_FULFILLED",
"ORDER_CONFIRMED",
"ORDER_CANCELLED",
"ORDER_FULLY_PAID",
"INVOICE_SENT",
] as const;
export type MessageEventTypes = (typeof messageEventTypes)[number];
export const messageEventTypesLabels: Record<MessageEventTypes, string> = {
ORDER_CREATED: "Order created",
ORDER_FULFILLED: "Order fulfilled",
ORDER_CONFIRMED: "Order confirmed",
ORDER_CANCELLED: "Order cancelled",
ORDER_FULLY_PAID: "Order fully paid",
INVOICE_SENT: "Invoice sent",
};

View file

@ -0,0 +1,79 @@
import { AuthData } from "@saleor/app-sdk/APL";
import { logger as pinoLogger } from "../../lib/logger";
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 {
recipientEmail: string;
channel: string;
event: MessageEventTypes;
authData: AuthData;
payload: any;
}
export const sendEventMessages = async ({
recipientEmail,
channel,
event,
authData,
payload,
}: SendEventMessagesArgs) => {
const logger = pinoLogger.child({
fn: "sendEventMessages",
});
logger.debug("Function called");
// get app configuration
const caller = appRouter.createCaller({
appId: authData.appId,
saleorApiUrl: authData.saleorApiUrl,
token: authData.token,
ssr: true,
});
const appConfigurations = await caller.appConfiguration.fetch();
const channelAppConfiguration = appConfigurations.configurationsPerChannel[channel];
if (!channelAppConfiguration) {
logger.warn("App has no configuration for this channel");
return;
}
logger.debug("Channel has assigned app configuration");
if (!channelAppConfiguration.active) {
logger.warn("App configuration is not active for this channel");
return;
}
if (channelAppConfiguration.mjmlConfigurationId) {
logger.debug("Channel has assigned MJML configuration");
const mjmlStatus = await sendMjml({
authData,
channel,
event,
payload,
recipientEmail,
mjmlConfigurationId: channelAppConfiguration.mjmlConfigurationId,
});
if (mjmlStatus?.errors.length) {
logger.error("MJML errors");
logger.error(mjmlStatus?.errors);
}
}
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);
}
};

View file

@ -0,0 +1,23 @@
import Handlebars from "handlebars";
import { logger as pinoLogger } from "../../lib/logger";
const logger = pinoLogger.child({
fn: "compileHandlebarsTemplate",
});
export const compileHandlebarsTemplate = (template: string, variables: any) => {
logger.debug("Compiling handlebars template");
try {
const templateDelegate = Handlebars.compile(template);
const htmlTemplate = templateDelegate(variables);
logger.debug("Template successfully compiled");
return {
template: htmlTemplate,
};
} catch (error) {
logger.error(error);
return {
errors: [{ message: "Error during the using the handlebars template" }],
};
}
};

View file

@ -0,0 +1,19 @@
import mjml2html from "mjml";
import { logger as pinoLogger } from "../../lib/logger";
const logger = pinoLogger.child({
fn: "compileMjml",
});
export const compileMjml = (mjml: string) => {
logger.debug("Converting MJML template to HTML");
try {
return mjml2html(mjml);
} catch (error) {
logger.error(error);
return {
html: undefined,
errors: [{ message: "Critical error during the mjml to html compilation" }],
};
}
};

View file

@ -0,0 +1,36 @@
import { PrivateMetadataMjmlConfigurator } from "./mjml-configurator";
import { Client } from "urql";
import { logger as pinoLogger } from "../../../lib/logger";
import { createSettingsManager } from "../../app-configuration/metadata-manager";
// todo test
export class GetMjmlConfigurationService {
constructor(
private settings: {
apiClient: Client;
saleorApiUrl: string;
}
) {}
async getConfiguration() {
const logger = pinoLogger.child({
service: "GetMjmlConfigurationService",
saleorApiUrl: this.settings.saleorApiUrl,
});
const { saleorApiUrl, apiClient } = this.settings;
const mjmlConfigurator = new PrivateMetadataMjmlConfigurator(
createSettingsManager(apiClient),
saleorApiUrl
);
const savedMjmlConfig = (await mjmlConfigurator.getConfig()) ?? null;
logger.debug(savedMjmlConfig, "Retrieved MJML config from Metadata. Will return it");
if (savedMjmlConfig) {
return savedMjmlConfig;
}
}
}

View file

@ -0,0 +1,119 @@
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"] =>
messageEventTypes.map((eventType) => ({
active: true,
eventType: eventType,
template: defaultMjmlTemplates[eventType],
subject: defaultMjmlSubjectTemplates[eventType],
}));
export const getDefaultEmptyConfiguration = (): MjmlConfiguration => {
const defaultConfig: MjmlConfiguration = {
id: "",
active: true,
configurationName: "",
senderName: "",
senderEmail: "",
smtpHost: "",
smtpPort: "",
smtpUser: "",
encryption: "NONE",
events: getDefaultEventsConfiguration(),
};
return defaultConfig;
};
interface GetConfigurationArgs {
id: string;
}
const getConfiguration =
(mjmlConfigRoot: MjmlConfigurationRoot | null | undefined) =>
({ id }: GetConfigurationArgs) => {
if (!mjmlConfigRoot || !mjmlConfigRoot.configurations) {
return;
}
return mjmlConfigRoot.configurations.find((c) => c.id === id);
};
interface FilterConfigurationsArgs {
ids?: string[];
active?: boolean;
}
const getConfigurations =
(mjmlConfigRoot: MjmlConfigurationRoot | null | undefined) =>
({ ids, active }: FilterConfigurationsArgs): MjmlConfiguration[] => {
if (!mjmlConfigRoot || !mjmlConfigRoot.configurations) {
return [];
}
let filtered = mjmlConfigRoot.configurations;
if (ids?.length) {
filtered = filtered.filter((c) => ids.includes(c.id));
}
if (active !== undefined) {
filtered = filtered.filter((c) => c.active === active);
}
return filtered;
};
const createConfiguration =
(mjmlConfigRoot: MjmlConfigurationRoot | null | undefined) =>
(mjmlConfiguration: Omit<MjmlConfiguration, "id" | "events">) => {
const mjmlConfigNormalized = structuredClone(mjmlConfigRoot) ?? { configurations: [] };
// for creating a new configurations, the ID has to be generated
const newConfiguration = {
...mjmlConfiguration,
id: Date.now().toString(),
events: getDefaultEventsConfiguration(),
};
mjmlConfigNormalized.configurations.unshift(newConfiguration);
return mjmlConfigNormalized;
};
const updateConfiguration =
(mjmlConfig: MjmlConfigurationRoot | null | undefined) =>
(mjmlConfiguration: MjmlConfiguration) => {
const mjmlConfigNormalized = structuredClone(mjmlConfig) ?? { configurations: [] };
const configurationIndex = mjmlConfigNormalized.configurations.findIndex(
(configuration) => configuration.id === mjmlConfiguration.id
);
mjmlConfigNormalized.configurations[configurationIndex] = mjmlConfiguration;
return mjmlConfigNormalized;
};
interface DeleteConfigurationArgs {
id: string;
}
const deleteConfiguration =
(mjmlConfig: MjmlConfigurationRoot | null | undefined) =>
({ id }: DeleteConfigurationArgs) => {
const mjmlConfigNormalized = structuredClone(mjmlConfig) ?? { configurations: [] };
mjmlConfigNormalized.configurations = mjmlConfigNormalized.configurations.filter(
(configuration) => configuration.id !== id
);
return mjmlConfigNormalized;
};
export const MjmlConfigContainer = {
createConfiguration,
getConfiguration,
updateConfiguration,
deleteConfiguration,
getConfigurations,
};

View file

@ -0,0 +1,77 @@
import { z } from "zod";
import { messageEventTypes } from "../../event-handlers/message-event-types";
import { smtpEncryptionTypes } from "./mjml-config";
export const mjmlConfigInputSchema = z.object({
configurations: z.array(
z.object({
active: z.boolean(),
configurationName: z.string(),
senderName: z.string(),
senderEmail: z.string().email(),
smtpHost: z.string(),
smtpPort: z.string(),
smtpUser: z.string().min(0),
useTls: z.boolean(),
useSsl: z.boolean(),
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 mjmlConfigurationEventObjectSchema = z.object({
active: z.boolean(),
eventType: z.enum(messageEventTypes),
template: z.string().min(1),
subject: z.string().min(1),
});
export const mjmlConfigurationBaseObjectSchema = z.object({
active: z.boolean(),
configurationName: z.string().min(1),
senderName: z.string().min(1),
senderEmail: z.string().email().min(5),
smtpHost: z.string().min(1),
smtpPort: z.string(),
smtpUser: z.string(),
encryption: z.enum(smtpEncryptionTypes),
});
export const mjmlCreateConfigurationSchema = mjmlConfigurationBaseObjectSchema;
export const mjmlUpdateOrCreateConfigurationSchema = mjmlConfigurationBaseObjectSchema.merge(
z.object({
id: z.string().optional(),
})
);
export const mjmlGetConfigurationInputSchema = z.object({
id: z.string(),
});
export const mjmlDeleteConfigurationInputSchema = z.object({
id: z.string(),
});
export const mjmlGetConfigurationsInputSchema = z.object({
ids: z.array(z.string()).optional(),
active: z.boolean().optional(),
});
export const mjmlUpdateEventConfigurationInputSchema = z
.object({
configurationId: z.string(),
})
.merge(mjmlConfigurationEventObjectSchema);
export const mjmlGetEventConfigurationInputSchema = z.object({
configurationId: z.string(),
eventType: z.enum(messageEventTypes),
});

View file

@ -0,0 +1,29 @@
import { MessageEventTypes } from "../../event-handlers/message-event-types";
export interface MjmlEventConfiguration {
active: boolean;
eventType: MessageEventTypes;
template: string;
subject: string;
}
export const smtpEncryptionTypes = ["NONE", "TLS", "SSL"] as const;
export type SmtpEncryptionType = (typeof smtpEncryptionTypes)[number];
export interface MjmlConfiguration {
id: string;
active: boolean;
configurationName: string;
senderName: string;
senderEmail: string;
smtpHost: string;
smtpPort: string;
smtpUser: string;
encryption: SmtpEncryptionType;
events: MjmlEventConfiguration[];
}
export type MjmlConfig = {
configurations: MjmlConfiguration[];
};

View file

@ -0,0 +1,248 @@
import { PrivateMetadataMjmlConfigurator } from "./mjml-configurator";
import { logger as pinoLogger } from "../../../lib/logger";
import {
mjmlCreateConfigurationSchema,
mjmlDeleteConfigurationInputSchema,
mjmlGetConfigurationInputSchema,
mjmlGetConfigurationsInputSchema,
mjmlGetEventConfigurationInputSchema,
mjmlUpdateEventConfigurationInputSchema,
mjmlUpdateOrCreateConfigurationSchema,
} from "./mjml-config-input-schema";
import { GetMjmlConfigurationService } 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";
export const mjmlConfigurationRouter = router({
fetch: protectedClientProcedure.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();
}),
getConfiguration: protectedClientProcedure
.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);
}),
getConfigurations: protectedClientProcedure
.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);
}),
createConfiguration: protectedClientProcedure
.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;
}),
deleteConfiguration: protectedClientProcedure
.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);
return null;
}),
updateOrCreateConfiguration: protectedClientProcedure
.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 (!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);
return configuration;
} else {
const newConfigurationRoot = MjmlConfigContainer.createConfiguration(configRoot)(input);
await mjmlConfigurator.setConfig(newConfigurationRoot);
return newConfigurationRoot.configurations[newConfigurationRoot.configurations.length - 1];
}
}),
getEventConfiguration: protectedClientProcedure
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(mjmlGetEventConfigurationInputSchema)
.query(async ({ ctx, input }) => {
const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
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)({
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: protectedClientProcedure
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(mjmlUpdateEventConfigurationInputSchema)
.mutation(async ({ ctx, input }) => {
const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
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)({
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,
eventType: input.eventType,
template: input.template,
subject: input.subject,
};
const newConfigurationRoot =
MjmlConfigContainer.updateConfiguration(configRoot)(configuration);
await mjmlConfigurator.setConfig(newConfigurationRoot);
return configuration;
}),
renderTemplate: protectedClientProcedure
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(
z.object({
template: z.string().optional(),
subject: z.string().optional(),
payload: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
logger.debug(input, "mjmlConfigurationRouter.renderTemplate called");
let renderedSubject = "";
const payload = JSON.parse(input.payload);
if (input.subject) {
const compiledSubjectTemplate = Handlebars.compile(input.subject);
logger.warn("subject part");
renderedSubject = compiledSubjectTemplate(payload);
}
let renderedEmail = "";
if (input.template) {
const compiledSubjectTemplate = Handlebars.compile(input.template);
const templatedEmail = compiledSubjectTemplate(payload);
const { html: rawHtml } = compileMjml(templatedEmail);
if (rawHtml) {
renderedEmail = rawHtml;
}
}
return {
renderedSubject,
renderedEmailBody: renderedEmail,
};
}),
});

View file

@ -0,0 +1,35 @@
import { MjmlConfig } from "./mjml-config";
import { SettingsManager } from "@saleor/app-sdk/settings-manager";
export interface MjmlConfigurator {
setConfig(config: MjmlConfig): Promise<void>;
getConfig(): Promise<MjmlConfig | undefined>;
}
export class PrivateMetadataMjmlConfigurator implements MjmlConfigurator {
private metadataKey = "mjml-config";
constructor(private metadataManager: SettingsManager, private saleorApiUrl: string) {}
getConfig(): Promise<MjmlConfig | undefined> {
return this.metadataManager.get(this.metadataKey, this.saleorApiUrl).then((data) => {
if (!data) {
return data;
}
try {
return JSON.parse(data);
} catch (e) {
throw new Error("Invalid metadata value, can't be parsed");
}
});
}
setConfig(config: MjmlConfig): Promise<void> {
return this.metadataManager.set({
key: this.metadataKey,
value: JSON.stringify(config),
domain: this.saleorApiUrl,
});
}
}

View file

@ -0,0 +1,275 @@
import { Controller, useForm } from "react-hook-form";
import { Divider, TextField, TextFieldProps, Typography } from "@material-ui/core";
import { Button, makeStyles, SwitchSelector, SwitchSelectorButton } from "@saleor/macaw-ui";
import React, { useEffect } from "react";
import { MjmlConfiguration, smtpEncryptionTypes } from "../mjml-config";
import { trpcClient } from "../../../trpc/trpc-client";
import { useAppBridge, actions } from "@saleor/app-sdk/app-bridge";
import { useRouter } from "next/router";
import { mjmlUrls } from "../../urls";
const useStyles = makeStyles((theme) => ({
field: {
marginBottom: 20,
},
editor: {
marginBottom: 20,
},
preview: {
marginBottom: 20,
},
sectionHeader: {
marginTop: 20,
},
form: {},
}));
type Props = {
onConfigurationSaved: () => void;
initialData: MjmlConfiguration;
configurationId?: string;
};
export const MjmlConfigurationForm = (props: Props) => {
const styles = useStyles();
const router = useRouter();
const { appBridge } = useAppBridge();
const { handleSubmit, control, setValue, getValues, reset, setError } =
useForm<MjmlConfiguration>({
defaultValues: props.initialData,
});
const { mutate: createOrUpdateConfiguration, error: createOrUpdateError } =
trpcClient.mjmlConfiguration.updateOrCreateConfiguration.useMutation({
onSuccess(data, variables) {
router.replace(mjmlUrls.configuration(data.id));
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 MjmlConfiguration, {
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, props.configurationId, reset]);
const CommonFieldProps: TextFieldProps = {
className: styles.field,
fullWidth: true,
};
const isNewConfiguration = !props.configurationId;
return (
<form
onSubmit={handleSubmit((data, event) => {
createOrUpdateConfiguration({
...data,
});
})}
className={styles.form}
>
{isNewConfiguration ? (
<Typography variant="h2" paragraph>
Create a new configuration
</Typography>
) : (
<Typography variant="h2" paragraph>
Configuration
<strong>{` ${props.initialData.configurationName} `}</strong>
</Typography>
)}
<Controller
name="configurationName"
control={control}
render={({ field: { onChange, value }, fieldState: { error }, formState: { errors } }) => (
<TextField
label="Configuration name"
value={value}
onChange={onChange}
error={!!error}
helperText={error?.message}
{...CommonFieldProps}
/>
)}
/>
<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>
)}
/>
<Divider />
<Typography variant="h3" paragraph className={styles.sectionHeader}>
Sender details
</Typography>
<Controller
name="senderName"
control={control}
render={({ field: { onChange, value }, fieldState: { error } }) => (
<TextField
label="Sender name"
error={!!error}
helperText={error?.message}
value={value}
onChange={onChange}
{...CommonFieldProps}
/>
)}
/>
<Controller
name="senderEmail"
control={control}
render={({ field: { onChange, value }, fieldState: { error } }) => (
<>
<TextField
label="Sender email"
value={value}
helperText={error?.message}
error={!!error}
onChange={onChange}
{...CommonFieldProps}
/>
</>
)}
/>
<Divider />
<Typography variant="h3" paragraph className={styles.sectionHeader}>
SMTP server configuration
</Typography>
<Controller
name="smtpHost"
control={control}
render={({ field: { onChange, value }, fieldState: { error } }) => (
<TextField
label="SMTP server host"
value={value}
onChange={onChange}
helperText={error?.message}
error={!!error}
{...CommonFieldProps}
/>
)}
/>
<Controller
name="smtpPort"
control={control}
render={({ field: { onChange, value }, fieldState: { error } }) => (
<TextField
label="SMTP server port"
helperText={error?.message}
error={!!error}
value={value}
onChange={onChange}
{...CommonFieldProps}
/>
)}
/>
<Controller
name="smtpUser"
control={control}
render={({ field: { onChange, value }, fieldState: { error } }) => (
<TextField
label="SMTP server user"
value={value}
helperText={error?.message}
error={!!error}
onChange={onChange}
{...CommonFieldProps}
/>
)}
/>
<Controller
control={control}
name="encryption"
render={({ field: { value, name, onChange } }) => (
<div className={styles.field}>
{/* TODO: fix types in the MacawUI */}
{/* @ts-ignore: MacawUI use wrong type for SwitchSelector */}
<SwitchSelector key={name}>
{smtpEncryptionTypes.map((encryptionType) => (
// @ts-ignore: MacawUI use wrong type for SwitchSelectorButton
<SwitchSelectorButton
value={encryptionType}
onClick={() => onChange(encryptionType)}
activeTab={value}
key={encryptionType}
>
{encryptionType === "NONE" ? "No encryption" : encryptionType}
</SwitchSelectorButton>
))}
</SwitchSelector>
</div>
)}
/>
<Button type="submit" fullWidth variant="primary">
Save configuration
</Button>
{createOrUpdateError && <span>{createOrUpdateError.message}</span>}
</form>
);
};

View file

@ -0,0 +1,136 @@
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 { MjmlConfigurationForm } from "./mjml-configuration-form";
import { getDefaultEmptyConfiguration } from "../mjml-config-container";
import { NextRouter, useRouter } from "next/router";
import { mjmlUrls } from "../../urls";
import { MjmlTemplatesCard } from "./mjml-templates-card";
import SideMenu from "../../../app-configuration/ui/side-menu";
import { MjmlConfiguration } from "../mjml-config";
import { LoadingIndicator } from "../../../ui/loading-indicator";
import { Add } from "@material-ui/icons";
const useStyles = makeStyles((theme) => {
return {
formContainer: {
top: 0,
},
instructionsContainer: {
padding: 15,
},
configurationColumn: {
display: "flex",
flexDirection: "column",
gap: 20,
},
};
});
interface MjmlConfigurationTabProps {
configurationId?: string;
}
const navigateToFirstConfiguration = (router: NextRouter, configurations?: MjmlConfiguration[]) => {
if (!configurations || !configurations?.length) {
router.replace(mjmlUrls.configuration());
return;
}
const firstConfigurationId = configurations[0]?.id;
if (firstConfigurationId) {
router.replace(mjmlUrls.configuration(firstConfigurationId));
return;
}
};
export const MjmlConfigurationTab = ({ configurationId }: MjmlConfigurationTabProps) => {
const styles = useStyles();
const { appBridge } = useAppBridge();
const router = useRouter();
const {
data: configurations,
refetch: refetchConfigurations,
isLoading: configurationsIsLoading,
} = trpcClient.mjmlConfiguration.getConfigurations.useQuery(
{},
{
onSuccess(data) {
if (!configurationId) {
navigateToFirstConfiguration(router, data);
}
},
}
);
const { mutate: deleteConfiguration, error: deleteError } =
trpcClient.mjmlConfiguration.deleteConfiguration.useMutation({
onSuccess(data, variables) {
refetchConfigurations();
// if we just deleted the configuration that was selected
// we have to navigate to the first configuration
if (variables.id === configurationId) {
navigateToFirstConfiguration(router, configurations);
}
appBridge?.dispatch(
actions.Notification({
title: "Success",
text: "Removed successfully",
status: "success",
})
);
},
});
if (configurationsIsLoading) {
return <LoadingIndicator />;
}
const configuration = configurations?.find((c) => c.id === configurationId?.toString());
if (configurationId && !configuration) {
return <div>Configuration not found</div>;
}
return (
<AppColumnsLayout>
<SideMenu
title="Configurations"
selectedItemId={configurationId}
headerToolbar={
<IconButton
variant="secondary"
onClick={() => {
router.replace(mjmlUrls.configuration());
}}
>
<Add />
</IconButton>
}
onClick={(id) => router.replace(mjmlUrls.configuration(id))}
onDelete={(id) => {
deleteConfiguration({ id });
}}
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();
}}
/>
)}
</div>
</AppColumnsLayout>
);
};

View file

@ -0,0 +1,277 @@
import { Controller, useForm } from "react-hook-form";
import { CircularProgress, TextField, TextFieldProps, Typography } from "@material-ui/core";
import {
BackSmallIcon,
Button,
IconButton,
makeStyles,
SwitchSelector,
SwitchSelectorButton,
} from "@saleor/macaw-ui";
import React, { useEffect, useState } from "react";
import { MjmlEventConfiguration } from "../mjml-config";
import { CodeEditor } from "../../../ui/code-editor";
import { MjmlPreview } from "./mjml-preview";
import {
MessageEventTypes,
messageEventTypesLabels,
} from "../../../event-handlers/message-event-types";
import { trpcClient } from "../../../trpc/trpc-client";
import { useDebounce } from "usehooks-ts";
import { useRouter } from "next/router";
import { mjmlUrls } from "../../urls";
import { useAppBridge, actions } from "@saleor/app-sdk/app-bridge";
import { examplePayloads } from "../../../event-handlers/default-payloads";
const PREVIEW_DEBOUNCE_DELAY = 500;
const useStyles = makeStyles((theme) => ({
header: {
display: "flex",
justifyContent: "flex-start",
alignItems: "center",
gap: theme.spacing(1),
padding: theme.spacing(3),
maxWidth: 1180,
margin: "0 auto",
},
previewHeader: {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: theme.spacing(1),
marginBottom: theme.spacing(3),
},
field: {
marginBottom: theme.spacing(3),
},
editor: {
marginBottom: theme.spacing(3),
},
preview: {
marginBottom: theme.spacing(3),
},
form: {
padding: theme.spacing(3),
},
formAndPreviewSection: {
display: "grid",
gridTemplateColumns: "2fr 1fr",
alignItems: "start",
gap: 32,
maxWidth: 1180,
margin: "0 auto",
},
}));
type EventConfigurationFormProps = {
initialData: MjmlEventConfiguration;
configurationId: string;
eventType: MessageEventTypes;
};
export const EventConfigurationForm = ({
initialData,
configurationId,
eventType,
}: EventConfigurationFormProps) => {
const router = useRouter();
const { appBridge } = useAppBridge();
const { handleSubmit, control, getValues, setError } = useForm<MjmlEventConfiguration>({
defaultValues: initialData,
});
const [lastValidRenderedTemplate, setLastValidRenderedTemplate] = useState("");
const [lastValidRenderedSubject, setLastValidRenderedSubject] = useState("");
const [payload, setPayload] = useState<string>(
JSON.stringify(examplePayloads[eventType], undefined, 2)
);
const { template, subject } = getValues();
const debouncedMutationVariables = useDebounce(
{ template, subject, payload },
PREVIEW_DEBOUNCE_DELAY
);
const styles = useStyles();
const CommonFieldProps: TextFieldProps = {
className: styles.field,
fullWidth: true,
};
const { mutate: fetchTemplatePreview, isLoading: isFetchingTemplatePreview } =
trpcClient.mjmlConfiguration.renderTemplate.useMutation({
onSuccess: (data) => {
if (data.renderedEmailBody) {
setLastValidRenderedTemplate(data.renderedEmailBody);
}
if (data.renderedSubject) {
setLastValidRenderedSubject(data.renderedSubject);
}
},
});
const { mutate: updateEventConfiguration, isLoading: isFetchingUpdateEvent } =
trpcClient.mjmlConfiguration.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 MjmlEventConfiguration, {
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",
})
);
},
});
const {
template: debouncedTemplate,
subject: debouncedSubject,
payload: debouncedPayload,
} = debouncedMutationVariables;
useEffect(() => {
fetchTemplatePreview({
template: debouncedTemplate,
subject: debouncedSubject,
payload: debouncedPayload,
});
}, [debouncedPayload, debouncedSubject, debouncedTemplate, fetchTemplatePreview]);
return (
<div>
<div className={styles.header}>
<IconButton
variant="secondary"
onClick={() => {
router.push(mjmlUrls.configuration(configurationId));
}}
>
<BackSmallIcon />
</IconButton>
<Typography variant="h2">
{messageEventTypesLabels[eventType]} event configuration
</Typography>
</div>
<div className={styles.formAndPreviewSection}>
<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
name="subject"
control={control}
render={({ field: { onChange, value }, fieldState: { error } }) => (
<TextField
label="Email subject"
value={value}
onChange={onChange}
error={!!error}
helperText={
!error
? "You can use variables like {{ order.number }} or {{ order.userEmail }}"
: error.message
}
{...CommonFieldProps}
/>
)}
/>
<Controller
control={control}
name="template"
render={({ field: { value, onChange } }) => {
return (
<>
<div className={styles.editor}>
<CodeEditor
initialTemplate={value}
value={value}
onChange={onChange}
language="xml"
/>
</div>
</>
);
}}
/>
<Button type="submit" fullWidth variant="primary">
Save configuration
</Button>
</form>
<div>
<div className={styles.previewHeader}>
<Typography variant="h2">Preview</Typography>
{isFetchingTemplatePreview && <CircularProgress size="3rem" color="primary" />}
</div>
<Typography variant="h3" paragraph>
Subject: {lastValidRenderedSubject}
</Typography>
<div className={styles.preview}>
<MjmlPreview value={lastValidRenderedTemplate} />
</div>
<CodeEditor
initialTemplate={payload}
value={payload}
onChange={setPayload}
language="json"
/>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,18 @@
import { Card } from "@material-ui/core";
import React from "react";
type Props = {
value?: string;
};
export const MjmlPreview = ({ value }: Props) => {
return (
<Card style={{ padding: "2rem", width: "100%" }}>
{value?.length ? (
<div dangerouslySetInnerHTML={{ __html: value }} />
) : (
<p>No template preview</p>
)}
</Card>
);
};

View file

@ -0,0 +1,132 @@
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 { mjmlUrls } from "../../urls";
import { messageEventTypesLabels } from "../../../event-handlers/message-event-types";
import { MjmlConfiguration } from "../mjml-config";
import { trpcClient } from "../../../trpc/trpc-client";
import { useAppBridge, actions } from "@saleor/app-sdk/app-bridge";
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 MjmlTemplatesCardProps {
configurationId: string;
configuration: MjmlConfiguration;
onEventChanged: () => void;
}
export const MjmlTemplatesCard = ({
configurationId,
configuration,
onEventChanged,
}: MjmlTemplatesCardProps) => {
const classes = useStyles();
const router = useRouter();
const { appBridge } = useAppBridge();
const { mutate: updateEventConfiguration } =
trpcClient.mjmlConfiguration.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(
mjmlUrls.eventConfiguration(configurationId, eventConfiguration.eventType)
);
}}
>
<EditIcon />
</IconButton>
</div>
</div>
</ListItemCell>
</ListItem>
<Divider />
</React.Fragment>
))}
</List>
</Paper>
);
};

View file

@ -0,0 +1,197 @@
import { MessageEventTypes } from "../event-handlers/message-event-types";
const addressSection = `
<mj-section>
<mj-column>
<mj-table>
<thead>
<tr>
<th>
Billing address
</th>
<th>
Shipping address
</th>
</tr>
</thead>
<tbody>
<tr>
<td>
{{#if order.billingAddress}}
{{ order.billingAddress.streetAddress1 }}
{{else}}
No billing address
{{/if}}
</td>
<td>
{{#if order.shippingAddress}}
{{ order.shippingAddress.streetAddress1}}
{{else}}
No shipping required
{{/if}}
</td>
</tr>
</tbody>
</mj-table>
</mj-column>
</mj-section>
`;
const orderLinesSection = `
<mj-section>
<mj-column>
<mj-table>
<tbody>
{{#each order.lines }}
<tr>
<td>
{{ this.quantity }} x {{ this.productName }} - {{ this.variantName }}
</td>
<td align="right">
{{ this.totalPrice.gross.amount }} {{ this.totalPrice.gross.currency }}
</td>
</tr>
{{/each}}
<tr>
<td>
</td>
<td align="right">
Shipping: {{ order.shippingPrice.gross.amount }} {{ order.shippingPrice.gross.currency }}
</td>
</tr>
<tr>
<td>
</td>
<td align="right">
Total: {{ order.total.gross.amount }} {{ order.total.gross.currency }}
</td>
</tr>
</tbody>
</mj-table>
</mj-column>
</mj-section>
`;
const defaultOrderCreatedMjmlTemplate = `
<mjml>
<mj-body>
<mj-section>
<mj-column>
<mj-text font-size="16px">
Hello!
</mj-text>
<mj-text>
Order {{ order.number }} has been created.
</mj-text>
</mj-column>
</mj-section>
${addressSection}
${orderLinesSection}
</mj-body>
</mjml>`;
const defaultOrderFulfilledMjmlTemplate = `
<mjml>
<mj-body>
<mj-section>
<mj-column>
<mj-text font-size="16px">
Hello!
</mj-text>
<mj-text>
Order {{ order.number}} has been fulfilled.
</mj-text>
</mj-column>
</mj-section>
${addressSection}
${orderLinesSection}
</mj-body>
</mjml>`;
const defaultOrderConfirmedMjmlTemplate = `
<mjml>
<mj-body>
<mj-section>
<mj-column>
<mj-text font-size="16px">
Hello!
</mj-text>
<mj-text>
Order {{ order.number}} has been confirmed.
</mj-text>
</mj-column>
</mj-section>
${addressSection}
${orderLinesSection}
</mj-body>
</mjml>`;
const defaultOrderFullyPaidMjmlTemplate = `
<mjml>
<mj-body>
<mj-section>
<mj-column>
<mj-text font-size="16px">
Hello!
</mj-text>
<mj-text>
Order {{ order.number}} has been fully paid.
</mj-text>
</mj-column>
</mj-section>
${addressSection}
${orderLinesSection}
</mj-body>
</mjml>`;
const defaultOrderCancelledMjmlTemplate = `
<mjml>
<mj-body>
<mj-section>
<mj-column>
<mj-text font-size="16px">
Hello!
</mj-text>
<mj-text>
Order {{ order.number}} has been cancelled.
</mj-text>
</mj-column>
</mj-section>
${addressSection}
${orderLinesSection}
</mj-body>
</mjml>`;
const defaultInvoiceSentMjmlTemplate = `
<mjml>
<mj-body>
<mj-section>
<mj-column>
<mj-text font-size="16px">
Hi!
</mj-text>
<mj-text>
New invoice has been created
</mj-text>
</mj-column>
</mj-section>
</mj-body>
</mjml>`;
export const defaultMjmlTemplates: Record<MessageEventTypes, string> = {
ORDER_CREATED: defaultOrderCreatedMjmlTemplate,
ORDER_FULFILLED: defaultOrderFulfilledMjmlTemplate,
ORDER_CONFIRMED: defaultOrderConfirmedMjmlTemplate,
ORDER_FULLY_PAID: defaultOrderFullyPaidMjmlTemplate,
ORDER_CANCELLED: defaultOrderCancelledMjmlTemplate,
INVOICE_SENT: defaultInvoiceSentMjmlTemplate,
};
export const defaultMjmlSubjectTemplates: Record<MessageEventTypes, string> = {
ORDER_CREATED: "Order {{ order.number }} has been created",
ORDER_FULFILLED: "Order {{ order.number }} has been fulfilled",
ORDER_CONFIRMED: "Order {{ order.number }} has been confirmed",
ORDER_FULLY_PAID: "Order {{ order.number }} has been fully paid",
ORDER_CANCELLED: "Order {{ order.number }} has been cancelled",
INVOICE_SENT: "New invoice has been created",
};

View file

@ -0,0 +1,43 @@
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

@ -0,0 +1,22 @@
import { convert } from "html-to-text";
import { logger as pinoLogger } from "../../lib/logger";
const logger = pinoLogger.child({
fn: "htmlToPlaintext",
});
export const htmlToPlaintext = (html: string) => {
logger.debug("Converting HTML template to plaintext");
try {
const plaintext = convert(html);
logger.debug("Converted successfully");
return {
plaintext,
};
} catch (error) {
logger.error(error);
return {
errors: [{ message: "Could not convert html to plaintext" }],
};
}
};

View file

@ -0,0 +1,42 @@
import nodemailer from "nodemailer";
import { logger as pinoLogger } from "../../lib/logger";
const logger = pinoLogger.child({
fn: "sendEmailWithSmtp",
});
interface SendMailArgs {
smtpSettings: {
host: string;
port: number;
};
mailData: {
from: string;
to: string;
text: string;
html: string;
subject: string;
};
}
export const sendEmailWithSmtp = async ({ smtpSettings, mailData }: SendMailArgs) => {
logger.debug("Sending an email with SMTP");
try {
const transporter = nodemailer.createTransport({
...smtpSettings,
});
const response = await transporter.sendMail({
...mailData,
});
logger.debug("An email has been sent");
return { response };
} catch (error) {
logger.error("Error during sending the email");
if (error instanceof Error) {
logger.error(error.message);
return { errors: [{ message: error.message }] };
}
return { errors: [{ message: "SMTP error" }] };
}
};

View file

@ -0,0 +1,171 @@
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";
interface SendMjmlArgs {
authData: AuthData;
mjmlConfigurationId: string;
channel: string;
recipientEmail: string;
event: MessageEventTypes;
payload: any;
}
export interface EmailServiceResponse {
errors?: {
code: number;
message: string;
}[];
}
export const sendMjml = async ({
authData,
channel,
payload,
recipientEmail,
event,
mjmlConfigurationId,
}: 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);
if (!eventSettings) {
logger.debug("No active settings for this event, skipping");
return {
errors: [
{
message: "No active settings for this event",
},
],
};
}
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 MJML");
const { template: rawTemplate, subject } = eventSettings;
const { template: emailSubject, errors: handlebarsSubjectErrors } = compileHandlebarsTemplate(
subject,
payload
);
logger.warn(`email subject ${emailSubject} ${subject}`);
if (handlebarsSubjectErrors?.length) {
logger.error("Error during the handlebars subject template compilation");
return {
errors: [{ message: "Error during the handlebars subject template compilation" }],
};
}
if (!emailSubject || !emailSubject?.length) {
logger.error("Mjml subject message is empty, skipping");
return {
errors: [{ message: "Mjml subject message is empty, skipping" }],
};
}
const { template: mjmlTemplate, errors: handlebarsErrors } = compileHandlebarsTemplate(
rawTemplate,
payload
);
if (handlebarsErrors?.length) {
logger.error("Error during the handlebars template compilation");
return {
errors: [{ message: "Error during the handlebars template compilation" }],
};
}
if (!mjmlTemplate || !mjmlTemplate?.length) {
logger.error("Mjml template message is empty, skipping");
return {
errors: [{ message: "Mjml template message is empty, skipping" }],
};
}
const { html: emailBodyHtml, errors: mjmlCompilationErrors } = compileMjml(mjmlTemplate);
if (mjmlCompilationErrors.length) {
logger.error("Error during the MJML compilation");
logger.error(mjmlCompilationErrors);
return {
errors: [
{
message: "Error during the MJML compilation. Please Validate your MJML template",
},
],
};
}
if (!emailBodyHtml || !emailBodyHtml?.length) {
logger.error("No MJML template returned after the compilation");
return {
errors: [{ message: "No MJML template returned after the compilation" }],
};
}
const { plaintext: emailBodyPlaintext } = htmlToPlaintext(emailBodyHtml);
if (!emailBodyPlaintext || !emailBodyPlaintext?.length) {
logger.error("Email body could not be converted to plaintext");
return {
errors: [{ message: "Email body could not be converted to plaintext" }],
};
}
const { response, errors: smtpErrors } = await sendEmailWithSmtp({
mailData: {
text: emailBodyPlaintext,
html: emailBodyHtml,
from: `${settings.senderName} <${settings.senderEmail}>`,
to: recipientEmail,
subject: emailSubject,
},
smtpSettings: {
host: settings.smtpHost,
port: parseInt(settings.smtpPort, 10),
},
});
if (smtpErrors?.length) {
return { errors: smtpErrors };
}
logger.debug(response?.response);
};

View file

@ -0,0 +1,7 @@
import { MessageEventTypes } from "../event-handlers/message-event-types";
export const mjmlUrls = {
configuration: (id?: string) => (!id ? "/configuration/mjml" : `/configuration/mjml/${id}`),
eventConfiguration: (id: string, event: MessageEventTypes) =>
`/configuration/mjml/${id}/event/${event}`,
};

View file

@ -0,0 +1,36 @@
import { PrivateMetadataSendgridConfigurator } from "./sendgrid-configurator";
import { Client } from "urql";
import { logger as pinoLogger } from "../../../lib/logger";
import { createSettingsManager } from "../../app-configuration/metadata-manager";
// todo test
export class GetSendgridConfigurationService {
constructor(
private settings: {
apiClient: Client;
saleorApiUrl: string;
}
) {}
async getConfiguration() {
const logger = pinoLogger.child({
service: "GetSendgridConfigurationService",
saleorApiUrl: this.settings.saleorApiUrl,
});
const { saleorApiUrl, apiClient } = this.settings;
const sendgridConfigurator = new PrivateMetadataSendgridConfigurator(
createSettingsManager(apiClient),
saleorApiUrl
);
const savedSendgridConfig = (await sendgridConfigurator.getConfig()) ?? null;
logger.debug(savedSendgridConfig, "Retrieved sendgrid config from Metadata. Will return it");
if (savedSendgridConfig) {
return savedSendgridConfig;
}
}
}

View file

@ -0,0 +1,60 @@
import { SendgridConfig as SendgridConfig, SendgridConfiguration } from "./sendgrid-config";
export const getDefaultEmptySendgridConfiguration = (): SendgridConfiguration => {
const defaultConfig = {
active: false,
configurationName: "",
sandboxMode: false,
senderName: "",
senderEmail: "",
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: "",
};
return defaultConfig;
};
const getSendgridConfigurationById =
(sendgridConfig: SendgridConfig | null | undefined) => (configurationId?: string) => {
if (!configurationId?.length) {
return getDefaultEmptySendgridConfiguration();
}
const existingConfig = sendgridConfig?.availableConfigurations[configurationId];
if (!existingConfig) {
return getDefaultEmptySendgridConfiguration();
}
return existingConfig;
};
const setSendgridConfigurationById =
(sendgridConfig: SendgridConfig | null | undefined) =>
(configurationId: string | undefined) =>
(sendgridConfiguration: SendgridConfiguration) => {
const sendgridConfigNormalized = structuredClone(sendgridConfig) ?? {
availableConfigurations: {},
};
// for creating a new configurations, the ID has to be generated
const id = configurationId || Date.now();
sendgridConfigNormalized.availableConfigurations[id] ??= getDefaultEmptySendgridConfiguration();
sendgridConfigNormalized.availableConfigurations[id] = sendgridConfiguration;
return sendgridConfigNormalized;
};
export const SendgridConfigContainer = {
getSendgridConfigurationById,
setSendgridConfigurationById,
};

View file

@ -0,0 +1,26 @@
import { z } from "zod";
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(),
})
),
});

View file

@ -0,0 +1,26 @@
export interface SendgridConfiguration {
active: boolean;
configurationName: string;
sandboxMode: boolean;
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;
}
export type SendgridConfigurationsIdMap = Record<string, SendgridConfiguration>;
export type SendgridConfig = {
availableConfigurations: SendgridConfigurationsIdMap;
};

View file

@ -0,0 +1,37 @@
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 { router } from "../../trpc/trpc-server";
import { protectedClientProcedure } from "../../trpc/protected-client-procedure";
import { createSettingsManager } from "../../app-configuration/metadata-manager";
export const sendgridConfigurationRouter = router({
fetch: protectedClientProcedure.query(async ({ ctx, input }) => {
const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
logger.debug("sendgridConfigurationRouter.fetch called");
return new GetSendgridConfigurationService({
apiClient: ctx.apiClient,
saleorApiUrl: ctx.saleorApiUrl,
}).getConfiguration();
}),
setAndReplace: protectedClientProcedure
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(sendgridConfigInputSchema)
.mutation(async ({ ctx, input }) => {
const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
logger.debug(input, "sendgridConfigurationRouter.setAndReplace called with input");
const sendgridConfigurator = new PrivateMetadataSendgridConfigurator(
createSettingsManager(ctx.apiClient),
ctx.saleorApiUrl
);
await sendgridConfigurator.setConfig(input);
return null;
}),
});

View file

@ -0,0 +1,35 @@
import { SendgridConfig } from "./sendgrid-config";
import { SettingsManager } from "@saleor/app-sdk/settings-manager";
export interface SendgridConfigurator {
setConfig(config: SendgridConfig): Promise<void>;
getConfig(): Promise<SendgridConfig | undefined>;
}
export class PrivateMetadataSendgridConfigurator implements SendgridConfigurator {
private metadataKey = "sendgrid-config";
constructor(private metadataManager: SettingsManager, private saleorApiUrl: string) {}
getConfig(): Promise<SendgridConfig | undefined> {
return this.metadataManager.get(this.metadataKey, this.saleorApiUrl).then((data) => {
if (!data) {
return data;
}
try {
return JSON.parse(data);
} catch (e) {
throw new Error("Invalid metadata value, cant be parsed");
}
});
}
setConfig(config: SendgridConfig): Promise<void> {
return this.metadataManager.set({
key: this.metadataKey,
value: JSON.stringify(config),
domain: this.saleorApiUrl,
});
}
}

View file

@ -0,0 +1,41 @@
interface FetchTemplatesArgs {
apiKey?: string;
}
export const fetchTemplates =
({ apiKey }: FetchTemplatesArgs) =>
async () => {
if (!apiKey) {
console.warn(
"The Sendgrid API key has not been set up yet. Skipping fetching available templates."
);
return [];
}
const response = await fetch(
"https://api.sendgrid.com/v3/templates?generations=dynamic&page_size=18",
{
method: "GET",
headers: {
Authorization: `Bearer ${apiKey}`,
},
}
);
if (!response.ok) {
console.error("Could not fetch available Sendgrid templates");
return [];
}
try {
const resJson = (await response.json()) as {
result?: { id: string; name: string }[];
};
const templates =
resJson.result?.map((r) => ({
value: r.id,
label: r.name,
})) || [];
return templates;
} catch (e) {
console.error("Could not parse the response from Sendgrid", e);
return [];
}
};

View file

@ -0,0 +1,320 @@
import { Controller, useForm } from "react-hook-form";
import {
FormControl,
FormControlLabel,
InputLabel,
Switch,
TextField,
TextFieldProps,
Typography,
} from "@material-ui/core";
import { Button, makeStyles } from "@saleor/macaw-ui";
import React, { useEffect } from "react";
import { SendgridConfiguration } from "../sendgrid-config";
import { useQuery } from "@tanstack/react-query";
import { TemplateSelectionField } from "./template-selection-field";
import { fetchTemplates } from "./fetch-templates";
const useStyles = makeStyles({
field: {
marginBottom: 20,
},
form: {
padding: 20,
},
});
type Props = {
onSubmit(data: SendgridConfiguration): Promise<void>;
initialData: SendgridConfiguration;
configurationId?: string;
};
export const SendgridConfigurationForm = (props: Props) => {
const { handleSubmit, control, reset } = useForm<SendgridConfiguration>({
defaultValues: props.initialData,
});
// when the configuration tab is changed, initialData change and form has to be updated
useEffect(() => {
reset(props.initialData);
}, [props.initialData, reset]);
const { data: templateChoices, isLoading: isTemplateChoicesLoading } = useQuery({
queryKey: ["sendgridTemplates"],
queryFn: fetchTemplates({ apiKey: props.initialData.apiKey }),
enabled: !!props.initialData?.apiKey.length,
});
const styles = useStyles();
const CommonFieldProps: TextFieldProps = {
className: styles.field,
fullWidth: true,
};
const isNewConfiguration = !props.configurationId;
return (
<form
onSubmit={handleSubmit((data, event) => {
props.onSubmit(data);
})}
className={styles.form}
>
{isNewConfiguration ? (
<Typography variant="h4" paragraph>
Create a new configuration
</Typography>
) : (
<Typography variant="h4" paragraph>
Configuration {props.initialData?.configurationName}
</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 } }) => (
<TextField
label="Configuration name"
value={value}
onChange={onChange}
{...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} />
)}
/>
<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 } }) => (
<TextField
label="Order Created Email subject"
value={value}
onChange={onChange}
{...CommonFieldProps}
/>
)}
/>
<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}
/>
)}
/>
<Controller
control={control}
name="templateOrderFulfilledTemplate"
render={({ field: { value, onChange } }) => {
return (
<FormControl className={styles.field} disabled={isTemplateChoicesLoading} fullWidth>
<InputLabel>Template for Order Fulfilled</InputLabel>
<TemplateSelectionField
value={value}
onChange={onChange}
templateChoices={templateChoices}
/>
</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
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>
</form>
);
};

View file

@ -0,0 +1,125 @@
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 { 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";
const useStyles = makeStyles((theme) => {
return {
formContainer: {
top: 0,
},
instructionsContainer: {
padding: 15,
},
configurationColumn: {
display: "flex",
flexDirection: "column",
gap: 20,
},
loaderContainer: {
margin: "50px auto",
display: "flex",
alignItems: "center",
justifyContent: "center",
},
};
});
type Configurations = {
name: string;
id: string;
};
export const SendgridConfigurationTab = () => {
const styles = useStyles();
const { appBridge } = useAppBridge();
const [configurationsListData, setConfigurationsListData] = useState<Configurations[]>([]);
const [activeConfigurationId, setActiveConfigurationId] = useState<string>();
const [initialData, setInitialData] = useState<SendgridConfiguration>();
const {
data: configurationData,
refetch: refetchConfig,
isLoading,
} = trpcClient.sendgridConfiguration.fetch.useQuery(undefined, {
onSuccess(data) {
if (!data.availableConfigurations) {
return;
}
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",
})
);
},
});
useEffect(() => {
setInitialData(
activeConfigurationId
? SendgridConfigContainer.getSendgridConfigurationById(configurationData)(
activeConfigurationId
)
: getDefaultEmptySendgridConfiguration()
);
}, [activeConfigurationId, configurationData]);
if (isLoading) {
return (
<div className={styles.loaderContainer}>
<CircularProgress color="primary" />
</div>
);
}
return (
<AppColumnsLayout>
<ConfigurationsList
// TODO: FIXME
listItems={[]}
activeItemId={activeConfigurationId}
onItemClick={setActiveConfigurationId}
/>
<div className={styles.configurationColumn}>
<Paper elevation={0} className={styles.formContainer}>
{!!initialData && (
<SendgridConfigurationForm
onSubmit={async (data) => {
const newConfig =
SendgridConfigContainer.setSendgridConfigurationById(configurationData)(
activeConfigurationId
)(data);
mutate(newConfig);
}}
initialData={initialData}
configurationId={activeConfigurationId}
/>
)}
{saveError && <span>{saveError.message}</span>}
</Paper>
</div>
</AppColumnsLayout>
);
};

View file

@ -0,0 +1,31 @@
import { MenuItem, Select } from "@material-ui/core";
interface TemplateSelectionFieldProps {
templateChoices?: { label: string; value: string }[];
value?: string;
onChange: (value: unknown) => void;
}
export const TemplateSelectionField = ({
value,
onChange,
templateChoices,
}: TemplateSelectionFieldProps) => {
return (
<Select
variant="outlined"
value={value}
onChange={(event, val) => onChange(event.target.value)}
>
{!templateChoices?.length ? (
<MenuItem value="">No templates available</MenuItem>
) : (
templateChoices.map((choice) => (
<MenuItem key={choice.value} value={choice.value}>
{choice.label}
</MenuItem>
))
)}
</Select>
);
};

View file

@ -0,0 +1,54 @@
import { AuthData } from "@saleor/app-sdk/APL";
import { appRouter } from "../trpc/trpc-app-router";
import { logger as pinoLogger } from "../../lib/logger";
interface GetSendgridSettingsArgs {
authData: AuthData;
channel: string;
}
export const getSendgridSettings = async ({ authData, channel }: GetSendgridSettingsArgs) => {
const logger = pinoLogger.child({
fn: "getMjmlSettings",
channel,
});
const caller = appRouter.createCaller({
appId: authData.appId,
saleorApiUrl: authData.saleorApiUrl,
token: authData.token,
ssr: true,
});
const sendgridConfigurations = await caller.sendgridConfiguration.fetch();
const appConfigurations = await caller.appConfiguration.fetch();
const channelAppConfiguration = appConfigurations.configurationsPerChannel[channel];
if (!channelAppConfiguration) {
logger.warn("App has no configuration for this channel");
return;
}
if (!channelAppConfiguration.active) {
logger.warn("App configuration is not active for this channel");
return;
}
const sendgridConfigurationId = channelAppConfiguration.sendgridConfigurationId;
if (!sendgridConfigurationId?.length) {
logger.warn("Sendgrid configuration has not been chosen for this channel");
return;
}
const configuration = sendgridConfigurations?.availableConfigurations[sendgridConfigurationId];
if (!configuration) {
logger.warn(`The Sendgrid configuration with id ${sendgridConfigurationId} does not exist`);
return;
}
if (!configuration.active) {
logger.warn(`The Sendgrid configuration ${configuration.configurationName} is not active`);
return;
}
return configuration;
};

View file

@ -0,0 +1,128 @@
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;
}
export interface EmailServiceResponse {
errors?: {
code: number;
message: string;
}[];
}
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,
}: SendSendgridArgs) => {
const logger = pinoLogger.child({
fn: "sendSendgrid",
event,
});
const settings = await getSendgridSettings({ authData, channel });
if (!settings?.active) {
logger.debug("Sendgrid is not active, skipping");
return;
}
logger.debug("Sending an email using Sendgrid");
const { templateId, subject } = eventMapping(event, settings);
if (!templateId) {
logger.error("No template defined in the settings");
return {
errors: [{ message: `No template specified for the event ${event}` }],
};
}
try {
const mailService = new MailService();
mailService.setApiKey(settings.apiKey);
await mailService.send({
mailSettings: {
sandboxMode: {
enable: settings.sandboxMode,
},
},
from: {
email: settings.senderEmail,
},
to: {
email: recipientEmail,
},
personalizations: [
{
from: {
email: settings.senderEmail,
},
to: [
{
email: recipientEmail,
},
],
subject,
dynamicTemplateData: payload,
},
],
templateId,
});
logger.debug("Email has been send");
} catch (error) {
logger.error("The Sendgrid API returned an error");
logger.error(error);
if (error instanceof Error) {
return { errors: [{ message: error.message }] };
}
}
};

View file

@ -0,0 +1,43 @@
import { Client, gql } from "urql";
import { ShopInfoDocument, ShopInfoFragment } from "../../../generated/graphql";
gql`
fragment ShopInfo on Shop {
companyAddress {
country {
country
code
}
city
firstName
lastName
streetAddress1
streetAddress2
companyName
phone
postalCode
countryArea
cityArea
}
}
query ShopInfo {
shop {
...ShopInfo
}
}
`;
export interface IShopInfoFetcher {
fetchShopInfo(): Promise<ShopInfoFragment | null>;
}
export class ShopInfoFetcher implements IShopInfoFetcher {
constructor(private client: Client) {}
fetchShopInfo(): Promise<ShopInfoFragment | null> {
return this.client
.query(ShopInfoDocument, {})
.toPromise()
.then((resp) => resp.data?.shop ?? null);
}
}

View file

@ -0,0 +1,119 @@
import { verifyJWT } from "@saleor/app-sdk/verify-jwt";
import { middleware, procedure } from "./trpc-server";
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";
const attachAppToken = middleware(async ({ ctx, next }) => {
logger.debug("attachAppToken middleware");
if (!ctx.saleorApiUrl) {
logger.debug("ctx.saleorApiUrl not found, throwing");
throw new TRPCError({
code: "BAD_REQUEST",
message: "Missing saleorApiUrl in request",
});
}
const authData = await saleorApp.apl.get(ctx.saleorApiUrl);
if (!authData) {
logger.debug("authData not found, throwing 401");
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Missing auth data",
});
}
return next({
ctx: {
appToken: authData.token,
saleorApiUrl: authData.saleorApiUrl,
appId: authData.appId,
},
});
});
const validateClientToken = middleware(async ({ ctx, next, meta }) => {
logger.debug(
{
permissions: meta?.requiredClientPermissions,
},
"Calling validateClientToken middleware with permissions required"
);
if (!ctx.token) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Missing token in request. This middleware can be used only in frontend",
});
}
if (!ctx.appId) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Missing appId in request. This middleware can be used after auth is attached",
});
}
if (!ctx.saleorApiUrl) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message:
"Missing saleorApiUrl in request. This middleware can be used after auth is attached",
});
}
if (!ctx.ssr) {
try {
logger.debug("trying to verify JWT token from frontend");
logger.debug({ token: ctx.token ? `${ctx.token[0]}...` : undefined });
await verifyJWT({
appId: ctx.appId,
token: ctx.token,
saleorApiUrl: ctx.saleorApiUrl,
requiredPermissions: meta?.requiredClientPermissions ?? [],
});
} catch (e) {
logger.debug("JWT verification failed, throwing");
throw new ProtectedHandlerError("JWT verification failed: ", "JWT_VERIFICATION_FAILED");
}
}
return next({
ctx: {
...ctx,
saleorApiUrl: ctx.saleorApiUrl,
},
});
});
/**
* Construct common graphQL client and attach it to the context
*
* Can be used only if called from the frontend (react-query),
* otherwise jwks validation will fail (if createCaller used)
*
* TODO Rethink middleware composition to enable safe server-side router calls
*/
export const protectedClientProcedure = procedure
.use(attachAppToken)
.use(validateClientToken)
.use(async ({ ctx, next }) => {
const client = createClient(ctx.saleorApiUrl, async () =>
Promise.resolve({ token: ctx.appToken })
);
return next({
ctx: {
apiClient: client,
appToken: ctx.appToken,
saleorApiUrl: ctx.saleorApiUrl,
},
});
});

View file

@ -0,0 +1,14 @@
import { channelsRouter } from "../channels/channels.router";
import { router } from "./trpc-server";
import { appConfigurationRouter } from "../app-configuration/app-configuration.router";
import { mjmlConfigurationRouter } from "../mjml/configuration/mjml-configuration.router";
import { sendgridConfigurationRouter } from "../sendgrid/configuration/sendgrid-configuration.router";
export const appRouter = router({
channels: channelsRouter,
appConfiguration: appConfigurationRouter,
mjmlConfiguration: mjmlConfigurationRouter,
sendgridConfiguration: sendgridConfigurationRouter,
});
export type AppRouter = typeof appRouter;

View file

@ -0,0 +1,43 @@
import { httpBatchLink } from "@trpc/client";
import { createTRPCNext } from "@trpc/next";
import { SALEOR_API_URL_HEADER, SALEOR_AUTHORIZATION_BEARER_HEADER } from "@saleor/app-sdk/const";
import { appBridgeInstance } from "../../pages/_app";
import { AppRouter } from "./trpc-app-router";
function getBaseUrl() {
if (typeof window !== "undefined") return "";
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
return `http://localhost:${process.env.PORT ?? 3000}`;
}
export const trpcClient = createTRPCNext<AppRouter>({
config({ ctx }) {
return {
links: [
httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
headers() {
const { token, saleorApiUrl } = appBridgeInstance?.getState() || {};
if (!token || !saleorApiUrl) {
console.error(
"Can't initialize tRPC client before establishing the App Bridge connection"
);
throw new Error("Token and Saleor API URL unknown");
}
return {
/**
* Attach headers from app to client requests, so tRPC can add them to context
*/
[SALEOR_AUTHORIZATION_BEARER_HEADER]: appBridgeInstance?.getState().token,
[SALEOR_API_URL_HEADER]: appBridgeInstance?.getState().saleorApiUrl,
};
},
}),
],
queryClientConfig: { defaultOptions: { queries: { refetchOnWindowFocus: false } } },
};
},
ssr: false,
});

View file

@ -0,0 +1,14 @@
import * as trpcNext from "@trpc/server/adapters/next";
import { SALEOR_AUTHORIZATION_BEARER_HEADER, SALEOR_API_URL_HEADER } from "@saleor/app-sdk/const";
import { inferAsyncReturnType } from "@trpc/server";
export const createTrpcContext = async ({ res, req }: trpcNext.CreateNextContextOptions) => {
return {
token: req.headers[SALEOR_AUTHORIZATION_BEARER_HEADER] as string | undefined,
saleorApiUrl: req.headers[SALEOR_API_URL_HEADER] as string | undefined,
appId: undefined as undefined | string,
ssr: undefined as undefined | boolean,
};
};
export type TrpcContext = inferAsyncReturnType<typeof createTrpcContext>;

View file

@ -0,0 +1,30 @@
import { initTRPC } from "@trpc/server";
import { TrpcContext } from "./trpc-context";
import { AppPermission } from "@saleor/app-sdk/types";
import { ZodError } from "zod";
interface Meta {
requiredClientPermissions?: AppPermission[];
}
const t = initTRPC
.context<TrpcContext>()
.meta<Meta>()
.create({
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError:
error.code === "BAD_REQUEST" && error.cause instanceof ZodError
? error.cause.flatten()
: null,
},
};
},
});
export const router = t.router;
export const procedure = t.procedure;
export const middleware = t.middleware;

View file

@ -0,0 +1,22 @@
import { makeStyles } from "@saleor/macaw-ui";
import { PropsWithChildren } from "react";
const useStyles = makeStyles({
root: {
display: "grid",
gridTemplateColumns: "280px auto",
alignItems: "start",
gap: 32,
maxWidth: 1180,
margin: "0 auto",
padding: "20px 0",
},
});
type AppColumnsLayoutProps = PropsWithChildren<{}>;
export const AppColumnsLayout = ({ children }: AppColumnsLayoutProps) => {
const styles = useStyles();
return <div className={styles.root}>{children}</div>;
};

View file

@ -0,0 +1,38 @@
import React, { useRef } from "react";
import Editor from "@monaco-editor/react";
import { useTheme } from "@saleor/macaw-ui";
type Props = {
onChange(value: string): void;
initialTemplate: string;
value: string;
language: string;
};
export const CodeEditor = ({ initialTemplate, onChange, value, language }: Props) => {
const { themeType } = useTheme();
const editorRef = useRef(null);
// @ts-ignore
function handleEditorDidMount(editor, monaco) {
editorRef.current = editor;
}
return (
<>
<Editor
height="40vh"
width="100%"
value={value}
theme={themeType === "dark" ? "vs-dark" : "vs-light"}
defaultLanguage={language}
defaultValue={initialTemplate}
onMount={handleEditorDidMount}
onChange={(value) => {
onChange(value ?? "");
}}
/>
</>
);
};

View file

@ -0,0 +1,121 @@
import React, { PropsWithChildren } from "react";
import { Button, makeStyles, PageTab, PageTabs } from "@saleor/macaw-ui";
import { GitHub, OfflineBoltOutlined } from "@material-ui/icons";
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
import { MainBar } from "../../modules/ui/main-bar";
import { appBrandColor, appName } from "../../const";
import Image from "next/image";
import appIcon from "../../public/notification-hub.svg";
import { useRouter } from "next/router";
const useStyles = makeStyles({
buttonsGrid: { display: "flex", gap: 10 },
topBar: {
marginBottom: 32,
},
appIconContainer: {
background: appBrandColor,
padding: 10,
borderRadius: "50%",
width: 50,
height: 50,
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
color: "#fff",
},
});
const AppIcon = () => {
const styles = useStyles();
return (
<div className={styles.appIconContainer}>
<Image width={30} height={30} alt="icon" src={appIcon} />
</div>
);
};
type Props = PropsWithChildren<{}>;
export const ConfigurationPageBaseLayout = ({ children }: Props) => {
const styles = useStyles();
const { appBridge } = useAppBridge();
const openInNewTab = (url: string) => {
appBridge?.dispatch(
actions.Redirect({
to: url,
newContext: true,
})
);
};
const router = useRouter();
const tabs = [
{
key: "channels",
label: "Channels",
url: "/configuration/channels",
},
{ key: "mjml", label: "MJML", url: "/configuration/mjml" },
{
key: "sendgrid",
label: "Sendgrid (Coming soon!)",
url: "/configuration/sendgrid",
disabled: true,
},
];
const activePath = tabs.find((tab) => router.pathname.startsWith(tab.url))?.key;
const navigateToTab = (value: string) => {
const redirectionUrl = tabs.find((tab) => tab.key === value)?.url;
if (redirectionUrl) {
router.push(redirectionUrl);
}
};
return (
<div>
<MainBar
icon={<AppIcon />}
className={styles.topBar}
name={appName}
author="By Saleor Commerce"
rightColumnContent={
<div className={styles.buttonsGrid}>
<Button
variant="secondary"
startIcon={<GitHub />}
onClick={() => {
openInNewTab("https://github.com/saleor/saleor-emails-and-messages");
}}
>
Repository
</Button>
<Button
startIcon={<OfflineBoltOutlined />}
variant="secondary"
onClick={() => {
openInNewTab("https://github.com/saleor/apps/discussions");
}}
>
Request a feature
</Button>
</div>
}
/>
<PageTabs
value={activePath}
onChange={navigateToTab}
style={{ maxWidth: 1180, margin: "0 auto" }}
>
{tabs.map((tab) => (
<PageTab key={tab.key} value={tab.key} label={tab.label} disabled={tab.disabled} />
))}
</PageTabs>
{children}
</div>
);
};

View file

@ -0,0 +1,22 @@
import { CircularProgress } from "@material-ui/core";
import { makeStyles } from "@saleor/macaw-ui";
const useStyles = makeStyles((theme) => {
return {
loaderContainer: {
margin: "50px auto",
display: "flex",
alignItems: "center",
justifyContent: "center",
},
};
});
export const LoadingIndicator = () => {
const styles = useStyles();
return (
<div className={styles.loaderContainer}>
<CircularProgress color="primary" />
</div>
);
};

View file

@ -0,0 +1,54 @@
import { makeStyles } from "@saleor/macaw-ui";
import { ReactNode } from "react";
import { Paper, PaperProps } from "@material-ui/core";
import clsx from "clsx";
const useStyles = makeStyles((theme) => ({
root: {
height: 96,
padding: "0 32px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
borderLeft: "none",
borderRight: "none",
borderTop: "none",
},
leftColumn: {
marginRight: "auto",
},
rightColumn: {},
iconColumn: {
marginRight: 24,
},
appName: { fontSize: 24, margin: 0 },
appAuthor: {
fontSize: 12,
textTransform: "uppercase",
color: theme.palette.text.secondary,
fontWeight: 500,
margin: 0,
},
}));
type Props = {
name: string;
author: string;
rightColumnContent?: ReactNode;
icon?: ReactNode;
} & PaperProps;
export const MainBar = ({ name, author, rightColumnContent, className, icon }: Props) => {
const styles = useStyles();
return (
<Paper square variant="outlined" elevation={0} className={clsx(styles.root, className)}>
{icon && <div className={styles.iconColumn}>{icon}</div>}
<div className={styles.leftColumn}>
<h1 className={styles.appName}>{name}</h1>
<h1 className={styles.appAuthor}>{author}</h1>
</div>
<div className={styles.rightColumn}>{rightColumnContent}</div>
</Paper>
);
};

View file

@ -0,0 +1,37 @@
import { AlertBase, Button } from "@saleor/macaw-ui";
import React from "react";
import { Typography } from "@material-ui/core";
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
import { appName } from "./const";
const NotReadyPage = () => {
const { appBridge } = useAppBridge();
return (
<div>
<h1>{appName}</h1>
<AlertBase variant="error">
<Typography variant="h3" paragraph>
App can not be used
</Typography>
<Typography paragraph>
To configure the app you need to create at least 1 channel
</Typography>
<Button
variant="primary"
onClick={() => {
appBridge?.dispatch(
actions.Redirect({
to: `/channels/add`,
})
);
}}
>
Set up channel
</Button>
</AlertBase>
</div>
);
};
export default NotReadyPage;

View file

@ -0,0 +1,103 @@
import "../styles/globals.css";
import { Theme } from "@material-ui/core/styles";
import { AppBridge, AppBridgeProvider } from "@saleor/app-sdk/app-bridge";
import { RoutePropagator } from "@saleor/app-sdk/app-bridge/next";
import {
dark,
light,
SaleorThemeColors,
ThemeProvider as MacawUIThemeProvider,
} from "@saleor/macaw-ui";
import React, { PropsWithChildren, useEffect } from "react";
import { AppProps } from "next/app";
import { ThemeSynchronizer } from "../lib/theme-synchronizer";
import { NoSSRWrapper } from "../lib/no-ssr-wrapper";
import { trpcClient } from "../modules/trpc/trpc-client";
import { createGenerateClassName, StylesProvider } from "@material-ui/core";
type PalettesOverride = Record<"light" | "dark", SaleorThemeColors>;
/**
* Temporary override of colors, to match new dashboard palette.
* Long term this will be replaced with Macaw UI 2.x with up to date design tokens
*/
const palettes: PalettesOverride = {
light: {
...light,
background: {
default: "#fff",
paper: "#fff",
},
},
dark: {
...dark,
background: {
default: "hsla(211, 42%, 14%, 1)",
paper: "hsla(211, 42%, 14%, 1)",
},
},
};
const themeOverrides: Partial<Theme> = {
overrides: {
MuiTableCell: {
body: {
paddingBottom: 8,
paddingTop: 8,
},
root: {
height: 56,
paddingBottom: 4,
paddingTop: 4,
},
},
},
};
const generateClassName = createGenerateClassName({
productionPrefix: "c",
disableGlobal: true,
});
/**
* Ensure instance is a singleton.
* TODO: This is React 18 issue, consider hiding this workaround inside app-sdk
*/
export const appBridgeInstance = typeof window !== "undefined" ? new AppBridge() : undefined;
/**
* That's a hack required by Macaw-UI incompatibility with React@18
*/
const ThemeProvider = MacawUIThemeProvider as React.FC<
PropsWithChildren<{ overrides?: Partial<Theme>; ssr: boolean; palettes: PalettesOverride }>
>;
function NextApp({ Component, pageProps }: AppProps) {
/**
* Configure JSS (used by MacawUI) for SSR. If Macaw is not used, can be removed.
*/
useEffect(() => {
const jssStyles = document.querySelector("#jss-server-side");
if (jssStyles) {
jssStyles?.parentElement?.removeChild(jssStyles);
}
}, []);
return (
<NoSSRWrapper>
<AppBridgeProvider appBridgeInstance={appBridgeInstance}>
<StylesProvider generateClassName={generateClassName}>
<ThemeProvider palettes={palettes} overrides={themeOverrides} ssr>
<ThemeSynchronizer />
<RoutePropagator />
<Component {...pageProps} />
</ThemeProvider>
</StylesProvider>
</AppBridgeProvider>
</NoSSRWrapper>
);
}
export default trpcClient.withTRPC(NextApp);

View file

@ -0,0 +1,39 @@
import { createManifestHandler } from "@saleor/app-sdk/handlers/next";
import { AppManifest } from "@saleor/app-sdk/types";
import packageJson from "../../../package.json";
import { invoiceSentWebhook } from "./webhooks/invoice-sent";
import { orderCancelledWebhook } from "./webhooks/order-cancelled";
import { orderConfirmedWebhook } from "./webhooks/order-confirmed";
import { orderCreatedWebhook } from "./webhooks/order-created";
import { orderFulfilledWebhook } from "./webhooks/order-fulfilled";
import { orderFullyPaidWebhook } from "./webhooks/order-fully-paid";
export default createManifestHandler({
async manifestFactory(context) {
const manifest: AppManifest = {
name: "Emails & Messages",
tokenTargetUrl: `${context.appBaseUrl}/api/register`,
appUrl: context.appBaseUrl,
permissions: ["MANAGE_ORDERS"],
id: "saleor.app.emails-and-messages",
version: packageJson.version,
webhooks: [
orderCreatedWebhook.getWebhookManifest(context.appBaseUrl),
orderFulfilledWebhook.getWebhookManifest(context.appBaseUrl),
orderConfirmedWebhook.getWebhookManifest(context.appBaseUrl),
orderCancelledWebhook.getWebhookManifest(context.appBaseUrl),
orderFullyPaidWebhook.getWebhookManifest(context.appBaseUrl),
invoiceSentWebhook.getWebhookManifest(context.appBaseUrl),
],
extensions: [
/**
* Optionally, extend Dashboard with custom UIs
* https://docs.saleor.io/docs/3.x/developer/extending/apps/extending-dashboard-with-apps
*/
],
};
return manifest;
},
});

View file

@ -0,0 +1,25 @@
import { createAppRegisterHandler } from "@saleor/app-sdk/handlers/next";
import { saleorApp } from "../../saleor-app";
/**
* Required endpoint, called by Saleor to install app.
* It will exchange tokens with app, so saleorApp.apl will contain token
*/
export default createAppRegisterHandler({
apl: saleorApp.apl,
allowedSaleorUrls: [
/**
* You may want your app to work only for certain Saleor instances.
*
* Your app can work for every Saleor that installs it, but you can
* limit it here
*
* By default, every url is allowed.
*
* URL should be a full graphQL address, usually starting with https:// and ending with /graphql/
*
* Alternatively pass a function
*/
],
});

View file

@ -0,0 +1,8 @@
import * as trpcNext from "@trpc/server/adapters/next";
import { createTrpcContext } from "../../../modules/trpc/trpc-context";
import { appRouter } from "../../../modules/trpc/trpc-app-router";
export default trpcNext.createNextApiHandler({
router: appRouter,
createContext: createTrpcContext,
});

View file

@ -0,0 +1,92 @@
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 {
InvoiceSentWebhookPayloadFragment,
OrderDetailsFragmentDoc,
} from "../../../../generated/graphql";
import { sendEventMessages } from "../../../modules/event-handlers/send-event-messages";
const InvoiceSentWebhookPayload = gql`
${OrderDetailsFragmentDoc}
fragment InvoiceSentWebhookPayload on InvoiceSent {
invoice {
id
message
externalUrl
url
order {
id
}
}
order {
...OrderDetails
}
}
`;
const InvoiceSentGraphqlSubscription = gql`
${InvoiceSentWebhookPayload}
subscription InvoiceSent {
event {
...InvoiceSentWebhookPayload
}
}
`;
export const invoiceSentWebhook = new SaleorAsyncWebhook<InvoiceSentWebhookPayloadFragment>({
name: "Invoice sent in Saleor",
webhookPath: "api/webhooks/invoice-sent",
asyncEvent: "INVOICE_SENT",
apl: saleorApp.apl,
subscriptionQueryAst: InvoiceSentGraphqlSubscription,
});
const handler: NextWebhookApiHandler<InvoiceSentWebhookPayloadFragment> = async (
req,
res,
context
) => {
const logger = pinoLogger.child({
webhook: invoiceSentWebhook.name,
});
logger.debug("Webhook received");
const { payload, authData } = context;
const { order } = payload;
if (!order) {
logger.error("No order data payload");
return res.status(200).end();
}
const recipientEmail = order.userEmail || order.user?.email;
if (!recipientEmail?.length) {
logger.error(`The order ${order.number} had no email recipient set. Aborting.`);
return res
.status(200)
.json({ error: "Email recipient has not been specified in the event payload." });
}
const channel = order.channel.slug;
await sendEventMessages({
authData,
channel,
event: "INVOICE_SENT",
payload: { order: payload.order },
recipientEmail,
});
return res.status(200).json({ message: "The event has been handled" });
};
export default invoiceSentWebhook.createHandler(handler);
export const config = {
api: {
bodyParser: false,
},
};

View file

@ -0,0 +1,83 @@
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 {
OrderCancelledWebhookPayloadFragment,
OrderDetailsFragmentDoc,
} from "../../../../generated/graphql";
import { sendEventMessages } from "../../../modules/event-handlers/send-event-messages";
const OrderCancelledWebhookPayload = gql`
${OrderDetailsFragmentDoc}
fragment OrderCancelledWebhookPayload on OrderCancelled {
order {
...OrderDetails
}
}
`;
const OrderCancelledGraphqlSubscription = gql`
${OrderCancelledWebhookPayload}
subscription OrderCancelled {
event {
...OrderCancelledWebhookPayload
}
}
`;
export const orderCancelledWebhook = new SaleorAsyncWebhook<OrderCancelledWebhookPayloadFragment>({
name: "Order Cancelled in Saleor",
webhookPath: "api/webhooks/order-cancelled",
asyncEvent: "ORDER_CANCELLED",
apl: saleorApp.apl,
subscriptionQueryAst: OrderCancelledGraphqlSubscription,
});
const handler: NextWebhookApiHandler<OrderCancelledWebhookPayloadFragment> = async (
req,
res,
context
) => {
const logger = pinoLogger.child({
webhook: orderCancelledWebhook.name,
});
logger.debug("Webhook received");
const { payload, authData } = context;
const { order } = payload;
if (!order) {
logger.error("No order data payload");
return res.status(200).end();
}
const recipientEmail = order.userEmail || order.user?.email;
if (!recipientEmail?.length) {
logger.error(`The order ${order.number} had no email recipient set. Aborting.`);
return res
.status(200)
.json({ error: "Email recipient has not been specified in the event payload." });
}
const channel = order.channel.slug;
await sendEventMessages({
authData,
channel,
event: "ORDER_CANCELLED",
payload: { order: payload.order },
recipientEmail,
});
return res.status(200).json({ message: "The event has been handled" });
};
export default orderCancelledWebhook.createHandler(handler);
export const config = {
api: {
bodyParser: false,
},
};

Some files were not shown because too many files have changed in this diff Show more