Compare commits

...

12 commits

Author SHA1 Message Date
Krzysztof Wolski
850bd05c59 Type guard for the message types 2023-03-08 13:52:54 +01:00
Krzysztof Wolski
80d54bf456 Fix build 2023-03-07 22:05:10 +01:00
Krzysztof Wolski
eb9bd700ca Use configuration service 2023-03-07 22:03:58 +01:00
Krzysztof Wolski
d23e85a850 Rename helper fn 2023-03-07 22:03:58 +01:00
Krzysztof Wolski
49866e8942 Switch if case 2023-03-07 22:03:58 +01:00
Krzysztof Wolski
ffdc4932e8 Allow list for registration 2023-03-07 22:03:58 +01:00
Krzysztof Wolski
d21631892f Deps sort 2023-03-07 22:03:58 +01:00
Krzysztof Wolski
3f4eb03124 Remove brand color 2023-03-07 22:03:58 +01:00
Krzysztof Wolski
876591a8c8 Use memo on Editor 2023-03-07 22:03:58 +01:00
Krzysztof Wolski
03d479f348 Remove duplicated configs 2023-03-07 22:03:58 +01:00
Krzysztof Wolski
097b028657 Remove the top header in favor of dashboard build in 2023-03-07 22:03:58 +01:00
Krzysztof Wolski
807b809940 Add emails and messages app 2023-03-07 22:03:58 +01:00
112 changed files with 43038 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"]
}

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,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/apps-shared": "workspace:*",
"@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"
},
"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": "^18.0.26",
"@types/react-dom": "^18.0.10",
"eslint": "8.31.0",
"eslint-config-next": "13.1.2",
"eslint-config-prettier": "^8.6.0",
"eslint-config-saleor": "workspace:*",
"prettier": "^2.8.2",
"typescript": "4.9.4"
},
"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 @@
export const appName = "Emails and messages";

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,20 @@
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(),
})
),
});
export const appChannelConfigurationInputSchema = z.object({
channel: z.string(),
configuration: 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,66 @@
import { logger as pinoLogger } from "../../lib/logger";
import {
appChannelConfigurationInputSchema,
appConfigInputSchema,
} from "./app-config-input-schema";
import { AppConfigurationService } from "./get-app-configuration.service";
import { router } from "../trpc/trpc-server";
import { protectedClientProcedure } from "../trpc/protected-client-procedure";
import { z } from "zod";
// Allow access only for the dashboard users and attaches the
// configuration service to the context
const protectedWithConfigurationService = protectedClientProcedure.use(({ next, ctx }) =>
next({
ctx: {
...ctx,
configurationService: new AppConfigurationService({
apiClient: ctx.apiClient,
saleorApiUrl: ctx.saleorApiUrl,
}),
},
})
);
export const appConfigurationRouter = router({
getChannelConfiguration: protectedWithConfigurationService
.input(z.object({ channelSlug: z.string() }))
.query(async ({ ctx, input }) => {
const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
logger.debug("Get Channel Configuration called");
return await ctx.configurationService.getChannelConfiguration(input.channelSlug);
}),
setChannelConfiguration: protectedWithConfigurationService
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(appChannelConfigurationInputSchema)
.mutation(async ({ ctx, input }) => {
const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
logger.debug("Set channel configuration called");
await ctx.configurationService.setChannelConfiguration(input);
}),
fetch: protectedWithConfigurationService.query(async ({ ctx, input }) => {
const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
logger.debug("appConfigurationRouter.fetch called");
return new AppConfigurationService({
apiClient: ctx.apiClient,
saleorApiUrl: ctx.saleorApiUrl,
}).getConfiguration();
}),
setAndReplace: protectedWithConfigurationService
.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");
await ctx.configurationService.setConfigurationRoot(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,91 @@
import { PrivateMetadataAppConfigurator } from "./app-configurator";
import { createSettingsManager } from "./metadata-manager";
import { Client } from "urql";
import { logger as pinoLogger } from "../../lib/logger";
import { AppConfig, AppConfigurationPerChannel } from "./app-config";
import { getDefaultEmptyAppConfiguration } from "./app-config-container";
const logger = pinoLogger.child({
service: "AppConfigurationService",
});
export class AppConfigurationService {
private configurationData?: AppConfig;
private metadataConfigurator: PrivateMetadataAppConfigurator;
constructor(args: { apiClient: Client; saleorApiUrl: string; initialData?: AppConfig }) {
this.metadataConfigurator = new PrivateMetadataAppConfigurator(
createSettingsManager(args.apiClient),
args.saleorApiUrl
);
}
// Fetch configuration from Saleor API and cache it
private async pullConfiguration() {
logger.debug("Fetch configuration from Saleor API");
const config = await this.metadataConfigurator.getConfig();
this.configurationData = config;
}
// Push configuration to Saleor API
private async pushConfiguration() {
logger.debug("Push configuration to Saleor API");
await this.metadataConfigurator.setConfig(this.configurationData!);
}
async getConfiguration() {
logger.debug("Get configuration");
if (!this.configurationData) {
logger.debug("No configuration found in cache. Will fetch it from Saleor API");
await this.pullConfiguration();
}
const savedAppConfig = this.configurationData ?? null;
logger.debug(savedAppConfig, "Retrieved app config from Metadata. Will return it");
if (savedAppConfig) {
return savedAppConfig;
}
}
// Saves configuration to Saleor API and cache it
async setConfigurationRoot(config: AppConfig) {
logger.debug("Set configuration");
this.configurationData = config;
await this.pushConfiguration();
}
// Returns channel configuration if existing. Otherwise returns default empty one
async getChannelConfiguration(channel: string) {
logger.debug("Get channel configuration");
const configurations = await this.getConfiguration();
if (!configurations) {
return getDefaultEmptyAppConfiguration();
}
const channelConfiguration = configurations.configurationsPerChannel[channel];
return channelConfiguration || getDefaultEmptyAppConfiguration();
}
async setChannelConfiguration({
channel,
configuration,
}: {
channel: string;
configuration: AppConfigurationPerChannel;
}) {
logger.debug("Set channel configuration");
let configurations = await this.getConfiguration();
if (!configurations) {
configurations = { configurationsPerChannel: {} };
}
configurations.configurationsPerChannel[channel] = configuration;
await this.setConfigurationRoot(configurations);
}
}

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() || "false"}
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,155 @@
import React, { useMemo, useState } from "react";
import { EditIcon, IconButton, makeStyles } from "@saleor/macaw-ui";
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 [activeChannelSlug, setActiveChannelSlug] = useState<string | null>(null);
const { data: channelsData, isLoading: isChannelsDataLoading } =
trpcClient.channels.fetch.useQuery(undefined, {
onSuccess: (data) => {
if (data?.length) {
setActiveChannelSlug(data[0].slug);
}
},
});
const {
data: configurationData,
refetch: refetchConfig,
isLoading: isConfigurationDataLoading,
} = trpcClient.appConfiguration.getChannelConfiguration.useQuery(
{
channelSlug: activeChannelSlug!,
},
{ enabled: !!activeChannelSlug }
);
const { data: mjmlConfigurations, isLoading: isMjmlQueryLoading } =
trpcClient.mjmlConfiguration.getConfigurations.useQuery({});
const mjmlConfigurationsListData = useMemo(() => {
return (
mjmlConfigurations?.map((configuration) => ({
value: configuration.id,
label: configuration.configurationName,
})) ?? []
);
}, [mjmlConfigurations]);
const { data: sendgridConfigurations, isLoading: isSendgridQueryLoading } =
trpcClient.sendgridConfiguration.fetch.useQuery();
const sendgridConfigurationsListData = useMemo(() => {
if (!sendgridConfigurations) {
return [];
}
const keys = Object.keys(sendgridConfigurations.availableConfigurations ?? {}) || [];
return (
keys.map((key) => ({
value: key,
label: sendgridConfigurations.availableConfigurations[key].configurationName,
})) ?? []
);
}, [sendgridConfigurations]);
const { mutate: mutateAppChannelConfiguration, error: saveError } =
trpcClient.appConfiguration.setChannelConfiguration.useMutation({
onSuccess() {
refetchConfig();
appBridge?.dispatch(
actions.Notification({
title: "Success",
text: "Saved app configuration",
status: "success",
})
);
},
});
const activeChannel = channelsData?.find((c) => c.slug === activeChannelSlug);
if (isChannelsDataLoading) {
return <LoadingIndicator />;
}
if (!channelsData?.length) {
return <div>NO CHANNELS</div>;
}
const isFormDataLoading =
isConfigurationDataLoading || isMjmlQueryLoading || isSendgridQueryLoading;
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={channelsData.map((c) => ({ label: c.name, id: c.slug })) || []}
/>
<div className={styles.configurationColumn}>
{!activeChannel || isFormDataLoading ? (
<LoadingIndicator />
) : (
<>
<AppConfigurationForm
channelID={activeChannel.id}
key={activeChannelSlug}
channelSlug={activeChannel.slug}
mjmlConfigurationChoices={mjmlConfigurationsListData}
sendgridConfigurationChoices={sendgridConfigurationsListData}
onSubmit={async (data) => {
mutateAppChannelConfiguration({
channel: activeChannel.slug,
configuration: data,
});
}}
initialData={configurationData}
channelName={activeChannel?.name ?? activeChannelSlug}
/>
{saveError && <span>{saveError.message}</span>}
</>
)}
</div>
</AppColumnsLayout>
);
};

View file

@ -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/${id}` : "/configuration/channels",
};

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

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,23 @@
import { AsyncWebhookEventType } from "@saleor/app-sdk/types";
export const messageEventTypes = [
"ORDER_CREATED",
"ORDER_FULFILLED",
"ORDER_CONFIRMED",
"ORDER_CANCELLED",
"ORDER_FULLY_PAID",
"INVOICE_SENT",
] as const;
type Subset<K, T extends K> = T;
export type MessageEventTypes = Subset<AsyncWebhookEventType, (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,13 @@
import { MessageEventTypes, messageEventTypes } from "./message-event-types";
/**
* Returns the event type if it is valid, otherwise undefined.
*/
export const parseMessageEventType = (eventType?: string): MessageEventTypes | undefined => {
if (!eventType) {
return;
}
if (messageEventTypes.includes(eventType as MessageEventTypes)) {
return eventType as MessageEventTypes;
}
};

View file

@ -0,0 +1,88 @@
import { AuthData } from "@saleor/app-sdk/APL";
import { Client } from "urql";
import { logger as pinoLogger } from "../../lib/logger";
import { AppConfigurationService } from "../app-configuration/get-app-configuration.service";
import { MjmlConfigurationService } from "../mjml/configuration/get-mjml-configuration.service";
import { sendMjml } from "../mjml/send-mjml";
import { sendSendgrid } from "../sendgrid/send-sendgrid";
import { MessageEventTypes } from "./message-event-types";
interface SendEventMessagesArgs {
recipientEmail: string;
channel: string;
event: MessageEventTypes;
authData: AuthData;
payload: any;
client: Client;
}
export const sendEventMessages = async ({
recipientEmail,
channel,
event,
authData,
payload,
client,
}: SendEventMessagesArgs) => {
const logger = pinoLogger.child({
fn: "sendEventMessages",
});
logger.debug("Function called");
const appConfigurationService = new AppConfigurationService({
apiClient: client,
saleorApiUrl: authData.saleorApiUrl,
});
const channelAppConfiguration = await appConfigurationService.getChannelConfiguration(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 mjmlConfigurationService = new MjmlConfigurationService({
apiClient: client,
saleorApiUrl: authData.saleorApiUrl,
});
const mjmlConfiguration = await mjmlConfigurationService.getConfiguration({
id: channelAppConfiguration.mjmlConfigurationId,
});
if (mjmlConfiguration) {
const mjmlStatus = await sendMjml({
event,
payload,
recipientEmail,
mjmlConfiguration,
});
if (mjmlStatus?.errors.length) {
logger.error("MJML errors");
logger.error(mjmlStatus?.errors);
}
}
}
const sendgridStatus = await sendSendgrid({
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,107 @@
import { MjmlConfigurator, PrivateMetadataMjmlConfigurator } from "./mjml-configurator";
import { Client } from "urql";
import { logger as pinoLogger } from "../../../lib/logger";
import { createSettingsManager } from "../../app-configuration/metadata-manager";
import { MjmlConfig, MjmlConfiguration } from "./mjml-config";
import { FilterConfigurationsArgs, MjmlConfigContainer } from "./mjml-config-container";
const logger = pinoLogger.child({
service: "MjmlConfigurationService",
});
export class MjmlConfigurationService {
private configurationData?: MjmlConfig;
private metadataConfigurator: MjmlConfigurator;
constructor(args: { apiClient: Client; saleorApiUrl: string; initialData?: MjmlConfig }) {
this.metadataConfigurator = new PrivateMetadataMjmlConfigurator(
createSettingsManager(args.apiClient),
args.saleorApiUrl
);
if (args.initialData) {
this.configurationData = args.initialData;
}
}
// Fetch configuration from Saleor API and cache it
private async pullConfiguration() {
logger.debug("Fetch configuration from Saleor API");
const config = await this.metadataConfigurator.getConfig();
this.configurationData = config;
}
// Push configuration to Saleor API
private async pushConfiguration() {
logger.debug("Push configuration to Saleor API");
await this.metadataConfigurator.setConfig(this.configurationData!);
}
// Returns configuration from cache or fetches it from Saleor API
async getConfigurationRoot() {
logger.debug("Get configuration root");
if (this.configurationData) {
logger.debug("Using cached configuration");
return this.configurationData;
}
// No cached data, fetch it from Saleor API
await this.pullConfiguration();
if (!this.configurationData) {
logger.warn("No configuration found in Saleor API");
return;
}
return this.configurationData;
}
// Saves configuration to Saleor API and cache it
async setConfigurationRoot(config: MjmlConfig) {
logger.debug("Set configuration root");
this.configurationData = config;
await this.pushConfiguration();
}
async getConfiguration({ id }: { id: string }) {
logger.debug("Get configuration");
return MjmlConfigContainer.getConfiguration(await this.getConfigurationRoot())({ id });
}
async getConfigurations(filter: FilterConfigurationsArgs) {
logger.debug("Get configuration");
return MjmlConfigContainer.getConfigurations(await this.getConfigurationRoot())(filter);
}
async createConfiguration(config: Omit<MjmlConfiguration, "id" | "events">) {
logger.debug("Create configuration");
const updatedConfigurationRoot = MjmlConfigContainer.createConfiguration(
await this.getConfigurationRoot()
)(config);
await this.setConfigurationRoot(updatedConfigurationRoot);
return updatedConfigurationRoot.configurations[
updatedConfigurationRoot.configurations.length - 1
];
}
async updateConfiguration(config: MjmlConfiguration) {
logger.debug("Update configuration");
const updatedConfigurationRoot = MjmlConfigContainer.updateConfiguration(
await this.getConfigurationRoot()
)(config);
this.setConfigurationRoot(updatedConfigurationRoot);
}
async deleteConfiguration({ id }: { id: string }) {
logger.debug("Delete configuration");
const updatedConfigurationRoot = MjmlConfigContainer.deleteConfiguration(
await this.getConfigurationRoot()
)({ id });
this.setConfigurationRoot(updatedConfigurationRoot);
}
}

View file

@ -0,0 +1,121 @@
import { messageEventTypes } from "../../event-handlers/message-event-types";
import { MjmlConfig as MjmlConfigurationRoot, MjmlConfiguration } from "./mjml-config";
import { defaultMjmlTemplates, defaultMjmlSubjectTemplates } from "../default-templates";
export const generateMjmlConfigurationId = () => Date.now().toString();
export 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);
};
export 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: generateMjmlConfigurationId(),
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,197 @@
import { logger as pinoLogger } from "../../../lib/logger";
import {
mjmlCreateConfigurationSchema,
mjmlDeleteConfigurationInputSchema,
mjmlGetConfigurationInputSchema,
mjmlGetConfigurationsInputSchema,
mjmlGetEventConfigurationInputSchema,
mjmlUpdateEventConfigurationInputSchema,
mjmlUpdateOrCreateConfigurationSchema,
} from "./mjml-config-input-schema";
import { MjmlConfigurationService } from "./get-mjml-configuration.service";
import { router } from "../../trpc/trpc-server";
import { protectedClientProcedure } from "../../trpc/protected-client-procedure";
import { z } from "zod";
import { compileMjml } from "../compile-mjml";
import Handlebars from "handlebars";
import { TRPCError } from "@trpc/server";
// Allow access only for the dashboard users and attaches the
// configuration service to the context
const protectedWithConfigurationService = protectedClientProcedure.use(({ next, ctx }) =>
next({
ctx: {
...ctx,
configurationService: new MjmlConfigurationService({
apiClient: ctx.apiClient,
saleorApiUrl: ctx.saleorApiUrl,
}),
},
})
);
export const mjmlConfigurationRouter = router({
fetch: protectedWithConfigurationService.query(async ({ ctx }) => {
const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
logger.debug("mjmlConfigurationRouter.fetch called");
return ctx.configurationService.getConfigurationRoot();
}),
getConfiguration: protectedWithConfigurationService
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(mjmlGetConfigurationInputSchema)
.query(async ({ ctx, input }) => {
const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
logger.debug(input, "mjmlConfigurationRouter.get called");
return ctx.configurationService.getConfiguration(input);
}),
getConfigurations: protectedWithConfigurationService
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(mjmlGetConfigurationsInputSchema)
.query(async ({ ctx, input }) => {
const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
logger.debug(input, "mjmlConfigurationRouter.getConfigurations called");
return ctx.configurationService.getConfigurations(input);
}),
createConfiguration: protectedWithConfigurationService
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(mjmlCreateConfigurationSchema)
.mutation(async ({ ctx, input }) => {
const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
logger.debug(input, "mjmlConfigurationRouter.create called");
return await ctx.configurationService.createConfiguration(input);
}),
deleteConfiguration: protectedWithConfigurationService
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(mjmlDeleteConfigurationInputSchema)
.mutation(async ({ ctx, input }) => {
const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
logger.debug(input, "mjmlConfigurationRouter.delete called");
await ctx.configurationService.deleteConfiguration(input);
return null;
}),
updateOrCreateConfiguration: protectedWithConfigurationService
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(mjmlUpdateOrCreateConfigurationSchema)
.mutation(async ({ ctx, input }) => {
const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
logger.debug(input, "mjmlConfigurationRouter.update or create called");
const { id } = input;
if (!id) {
return await ctx.configurationService.createConfiguration(input);
} else {
const existingConfiguration = await ctx.configurationService.getConfiguration({ id });
if (!existingConfiguration) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Configuration not found",
});
}
const configuration = {
id,
...input,
events: existingConfiguration.events,
};
await ctx.configurationService.updateConfiguration(configuration);
return configuration;
}
}),
getEventConfiguration: protectedWithConfigurationService
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(mjmlGetEventConfigurationInputSchema)
.query(async ({ ctx, input }) => {
const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
logger.debug(input, "mjmlConfigurationRouter.getEventConfiguration or create called");
const configuration = await ctx.configurationService.getConfiguration({
id: input.configurationId,
});
if (!configuration) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Configuration not found",
});
}
const event = configuration.events.find((e) => e.eventType === input.eventType);
if (!event) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Event configuration not found",
});
}
return event;
}),
updateEventConfiguration: protectedWithConfigurationService
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(mjmlUpdateEventConfigurationInputSchema)
.mutation(async ({ ctx, input }) => {
const logger = pinoLogger.child({ saleorApiUrl: ctx.saleorApiUrl });
logger.debug(input, "mjmlConfigurationRouter.updateEventConfiguration or create called");
const configuration = await ctx.configurationService.getConfiguration({
id: input.configurationId,
});
if (!configuration) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Configuration not found",
});
}
const eventIndex = configuration.events.findIndex((e) => e.eventType === input.eventType);
configuration.events[eventIndex] = {
active: input.active,
eventType: input.eventType,
template: input.template,
subject: input.subject,
};
await ctx.configurationService.updateConfiguration(configuration);
return configuration;
}),
renderTemplate: protectedWithConfigurationService
.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,142 @@
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}>
{configurationsIsLoading ? (
<LoadingIndicator />
) : (
<>
<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,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,149 @@
import { logger as pinoLogger } from "../../lib/logger";
import { compileMjml } from "./compile-mjml";
import { compileHandlebarsTemplate } from "./compile-handlebars-template";
import { sendEmailWithSmtp } from "./send-email-with-smtp";
import { MessageEventTypes } from "../event-handlers/message-event-types";
import { htmlToPlaintext } from "./html-to-plaintext";
import { MjmlConfiguration } from "./configuration/mjml-config";
interface SendMjmlArgs {
mjmlConfiguration: MjmlConfiguration;
recipientEmail: string;
event: MessageEventTypes;
payload: any;
}
export interface EmailServiceResponse {
errors?: {
code: number;
message: string;
}[];
}
export const sendMjml = async ({
payload,
recipientEmail,
event,
mjmlConfiguration,
}: SendMjmlArgs) => {
const logger = pinoLogger.child({
fn: "sendMjml",
event,
});
const eventSettings = mjmlConfiguration.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: `${mjmlConfiguration.senderName} <${mjmlConfiguration.senderEmail}>`,
to: recipientEmail,
subject: emailSubject,
},
smtpSettings: {
host: mjmlConfiguration.smtpHost,
port: parseInt(mjmlConfiguration.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-graphql-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,43 @@
import React, { useCallback, 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;
}
const handleOnChange = useCallback(
(value?: string) => {
onChange(value ?? "");
},
[value]
);
return (
<>
<Editor
height="40vh"
width="100%"
value={value}
theme={themeType === "dark" ? "vs-dark" : "vs-light"}
defaultLanguage={language}
defaultValue={initialTemplate}
onMount={handleEditorDidMount}
onChange={handleOnChange}
/>
</>
);
};

View file

@ -0,0 +1,52 @@
import React, { PropsWithChildren } from "react";
import { makeStyles, PageTab, PageTabs } from "@saleor/macaw-ui";
import { useRouter } from "next/router";
const useStyles = makeStyles({
appContainer: { marginTop: 20 },
});
type Props = PropsWithChildren<{}>;
export const ConfigurationPageBaseLayout = ({ children }: Props) => {
const styles = useStyles();
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 className={styles.appContainer}>
<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,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,41 @@
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
*/
],
homepageUrl: "https://github.com/saleor/apps",
supportUrl: "https://github.com/saleor/apps/discussions",
};
return manifest;
},
});

View file

@ -0,0 +1,24 @@
import { createAppRegisterHandler } from "@saleor/app-sdk/handlers/next";
import { saleorApp } from "../../saleor-app";
const allowedUrlsPattern = process.env.ALLOWED_DOMAIN_PATTERN;
/**
* 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: [
(url) => {
if (allowedUrlsPattern) {
const regex = new RegExp(allowedUrlsPattern);
return regex.test(url);
}
return true;
},
],
});

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,97 @@
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";
import { createClient } from "../../../lib/create-graphql-client";
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;
const client = createClient(authData.saleorApiUrl, async () =>
Promise.resolve({ token: authData.token })
);
await sendEventMessages({
authData,
channel,
client,
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,88 @@
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";
import { createClient } from "../../../lib/create-graphql-client";
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;
const client = createClient(authData.saleorApiUrl, async () =>
Promise.resolve({ token: authData.token })
);
await sendEventMessages({
authData,
channel,
client,
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,
},
};

View file

@ -0,0 +1,89 @@
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 {
OrderConfirmedWebhookPayloadFragment,
OrderDetailsFragmentDoc,
} from "../../../../generated/graphql";
import { sendEventMessages } from "../../../modules/event-handlers/send-event-messages";
import { createClient } from "../../../lib/create-graphql-client";
const OrderConfirmedWebhookPayload = gql`
${OrderDetailsFragmentDoc}
fragment OrderConfirmedWebhookPayload on OrderConfirmed {
order {
...OrderDetails
}
}
`;
const OrderConfirmedGraphqlSubscription = gql`
${OrderConfirmedWebhookPayload}
subscription OrderConfirmed {
event {
...OrderConfirmedWebhookPayload
}
}
`;
export const orderConfirmedWebhook = new SaleorAsyncWebhook<OrderConfirmedWebhookPayloadFragment>({
name: "Order Confirmed in Saleor",
webhookPath: "api/webhooks/order-confirmed",
asyncEvent: "ORDER_CONFIRMED",
apl: saleorApp.apl,
subscriptionQueryAst: OrderConfirmedGraphqlSubscription,
});
const handler: NextWebhookApiHandler<OrderConfirmedWebhookPayloadFragment> = async (
req,
res,
context
) => {
const logger = pinoLogger.child({
webhook: orderConfirmedWebhook.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;
const client = createClient(authData.saleorApiUrl, async () =>
Promise.resolve({ token: authData.token })
);
await sendEventMessages({
authData,
channel,
client,
event: "ORDER_CONFIRMED",
payload: { order: payload.order },
recipientEmail,
});
return res.status(200).json({ message: "The event has been handled" });
};
export default orderConfirmedWebhook.createHandler(handler);
export const config = {
api: {
bodyParser: false,
},
};

View file

@ -0,0 +1,86 @@
import { OrderDetailsFragmentDoc } from "./../../../../generated/graphql";
import { NextWebhookApiHandler, SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next";
import { gql } from "urql";
import { saleorApp } from "../../../saleor-app";
import { logger as pinoLogger } from "../../../lib/logger";
import { OrderCreatedWebhookPayloadFragment } from "../../../../generated/graphql";
import { sendEventMessages } from "../../../modules/event-handlers/send-event-messages";
import { createClient } from "../../../lib/create-graphql-client";
const OrderCreatedWebhookPayload = gql`
${OrderDetailsFragmentDoc}
fragment OrderCreatedWebhookPayload on OrderCreated {
order {
...OrderDetails
}
}
`;
const OrderCreatedGraphqlSubscription = gql`
${OrderCreatedWebhookPayload}
subscription OrderCreated {
event {
...OrderCreatedWebhookPayload
}
}
`;
export const orderCreatedWebhook = new SaleorAsyncWebhook<OrderCreatedWebhookPayloadFragment>({
name: "Order Created in Saleor",
webhookPath: "api/webhooks/order-created",
asyncEvent: "ORDER_CREATED",
apl: saleorApp.apl,
subscriptionQueryAst: OrderCreatedGraphqlSubscription,
});
const handler: NextWebhookApiHandler<OrderCreatedWebhookPayloadFragment> = async (
req,
res,
context
) => {
const logger = pinoLogger.child({
webhook: orderCreatedWebhook.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;
const client = createClient(authData.saleorApiUrl, async () =>
Promise.resolve({ token: authData.token })
);
await sendEventMessages({
authData,
channel,
client,
event: "ORDER_CREATED",
payload: { order: payload.order },
recipientEmail,
});
return res.status(200).json({ message: "The event has been handled" });
};
export default orderCreatedWebhook.createHandler(handler);
export const config = {
api: {
bodyParser: false,
},
};

View file

@ -0,0 +1,89 @@
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 {
OrderDetailsFragmentDoc,
OrderFulfilledWebhookPayloadFragment,
} from "../../../../generated/graphql";
import { sendEventMessages } from "../../../modules/event-handlers/send-event-messages";
import { createClient } from "../../../lib/create-graphql-client";
const OrderFulfilledWebhookPayload = gql`
${OrderDetailsFragmentDoc}
fragment OrderFulfilledWebhookPayload on OrderFulfilled {
order {
...OrderDetails
}
}
`;
const OrderFulfilledGraphqlSubscription = gql`
${OrderFulfilledWebhookPayload}
subscription OrderFulfilled {
event {
...OrderFulfilledWebhookPayload
}
}
`;
export const orderFulfilledWebhook = new SaleorAsyncWebhook<OrderFulfilledWebhookPayloadFragment>({
name: "Order Fulfilled in Saleor",
webhookPath: "api/webhooks/order-fulfilled",
asyncEvent: "ORDER_FULFILLED",
apl: saleorApp.apl,
subscriptionQueryAst: OrderFulfilledGraphqlSubscription,
});
const handler: NextWebhookApiHandler<OrderFulfilledWebhookPayloadFragment> = async (
req,
res,
context
) => {
const logger = pinoLogger.child({
webhook: orderFulfilledWebhook.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;
const client = createClient(authData.saleorApiUrl, async () =>
Promise.resolve({ token: authData.token })
);
await sendEventMessages({
authData,
client,
channel,
event: "ORDER_FULFILLED",
payload: { order: payload.order },
recipientEmail,
});
return res.status(200).json({ message: "The event has been handled" });
};
export default orderFulfilledWebhook.createHandler(handler);
export const config = {
api: {
bodyParser: false,
},
};

View file

@ -0,0 +1,89 @@
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 {
OrderDetailsFragmentDoc,
OrderFullyPaidWebhookPayloadFragment,
} from "../../../../generated/graphql";
import { sendEventMessages } from "../../../modules/event-handlers/send-event-messages";
import { createClient } from "../../../lib/create-graphql-client";
const OrderFullyPaidWebhookPayload = gql`
${OrderDetailsFragmentDoc}
fragment OrderFullyPaidWebhookPayload on OrderFullyPaid {
order {
...OrderDetails
}
}
`;
const OrderFullyPaidGraphqlSubscription = gql`
${OrderFullyPaidWebhookPayload}
subscription OrderFullyPaid {
event {
...OrderFullyPaidWebhookPayload
}
}
`;
export const orderFullyPaidWebhook = new SaleorAsyncWebhook<OrderFullyPaidWebhookPayloadFragment>({
name: "Order Fully Paid in Saleor",
webhookPath: "api/webhooks/order-fully-paid",
asyncEvent: "ORDER_FULLY_PAID",
apl: saleorApp.apl,
subscriptionQueryAst: OrderFullyPaidGraphqlSubscription,
});
const handler: NextWebhookApiHandler<OrderFullyPaidWebhookPayloadFragment> = async (
req,
res,
context
) => {
const logger = pinoLogger.child({
webhook: orderFullyPaidWebhook.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;
const client = createClient(authData.saleorApiUrl, async () =>
Promise.resolve({ token: authData.token })
);
await sendEventMessages({
authData,
channel,
client,
event: "ORDER_FULLY_PAID",
payload: { order: payload.order },
recipientEmail,
});
return res.status(200).json({ message: "The event has been handled" });
};
export default orderFullyPaidWebhook.createHandler(handler);
export const config = {
api: {
bodyParser: false,
},
};

View file

@ -0,0 +1,24 @@
import { NextPage } from "next";
import React, { useEffect } from "react";
import { useRouter } from "next/router";
import { trpcClient } from "../../../modules/trpc/trpc-client";
import { ConfigurationPageBaseLayout } from "../../../modules/ui/configuration-page-base-layout";
import { ChannelsConfigurationTab } from "../../../modules/app-configuration/ui/channels-configuration-tab";
const ChannelsConfigurationPage: NextPage = () => {
const channels = trpcClient.channels.fetch.useQuery();
const router = useRouter();
useEffect(() => {
if (router && channels.isSuccess && channels.data.length === 0) {
router.push("/not-ready");
}
}, [channels.data, channels.isSuccess, router]);
return (
<ConfigurationPageBaseLayout>
<ChannelsConfigurationTab />
</ConfigurationPageBaseLayout>
);
};
export default ChannelsConfigurationPage;

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