📧 Introduce new event - Gift Card Sent (#661)

* Introduce new event - Gift Card Sent

* Add feature flag service and use it with gift card event

* Add saleor version check on install
This commit is contained in:
Krzysztof Wolski 2023-06-28 11:39:26 +02:00 committed by GitHub
parent 1405deaf66
commit 6250095a4e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
36 changed files with 10423 additions and 15443 deletions

View file

@ -0,0 +1,6 @@
---
"saleor-app-emails-and-messages": patch
---
Added support for new event - Gift Card Sent. The event is available for Saleor version 3.13 and above.

File diff suppressed because it is too large Load diff

View file

@ -74,6 +74,6 @@
},
"private": true,
"saleor": {
"schemaVersion": "3.11.7"
"schemaVersion": "3.13"
}
}

View file

@ -6,5 +6,6 @@ export const generateRandomId = () => {
const offsetInMinutes = date.getTimezoneOffset();
const randomDate = date.setMinutes(date.getMinutes() + offsetInMinutes).valueOf();
const randomString = (Math.random() + 1).toString(36).substring(7);
return `${randomDate}${randomString}`;
};

View file

@ -32,7 +32,7 @@ export function setBackendErrors<T extends FieldValues = FieldValues>({
notifyError(
"Could not save the configuration",
isFieldErrorSet ? "Submitted form contain errors" : "Error saving configuration",
isFieldErrorSet ? "Submitted form contain errors" : error.message,
formErrorMessage
);
}

View file

@ -0,0 +1,12 @@
import { createLogger } from "@saleor/apps-shared";
import { router } from "../trpc/trpc-server";
import { protectedWithConfigurationServices } from "../trpc/protected-client-procedure-with-services";
export const appConfigurationRouter = router({
featureFlags: protectedWithConfigurationServices.query(async ({ ctx }) => {
const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl });
logger.debug("appConfigurationRouter.featureFlags called");
return await ctx.featureFlagService.getFeatureFlags();
}),
});

View file

@ -7,6 +7,7 @@ import {
OrderFulfilledWebhookPayloadFragment,
OrderFullyPaidWebhookPayloadFragment,
InvoiceSentWebhookPayloadFragment,
GiftCardSentWebhookPayloadFragment,
} from "../../../generated/graphql";
import { NotifyEventPayload } from "../../pages/api/webhooks/notify";
@ -247,6 +248,33 @@ const accountDeletePayload: NotifyEventPayload = {
logo_url: "",
};
// TODO: UPDATE WITH BETTER DATA
const giftCardSentPayload: GiftCardSentWebhookPayloadFragment = {
channel: "default_channel",
sentToEmail: "user@example.com",
giftCard: {
code: "XXXX",
tags: [],
created: "2021-03-16T13:12:00+00:00",
currentBalance: {
amount: 100,
currency: "USD",
},
id: "R2lmdENhcmQ6MjI=",
initialBalance: {
amount: 100,
currency: "USD",
},
isActive: true,
lastUsedOn: null,
displayCode: "XXXX-XXXX-XXXX-XXXX",
last4CodeChars: "XXXX",
expiryDate: "2021-03-16T13:12:00+00:00",
usedByEmail: null,
usedBy: null,
},
};
export const examplePayloads: Record<MessageEventTypes, any> = {
ORDER_CREATED: orderCreatedPayload,
ORDER_CONFIRMED: orderConfirmedPayload,
@ -254,6 +282,7 @@ export const examplePayloads: Record<MessageEventTypes, any> = {
ORDER_FULFILLED: orderFulfilledPayload,
ORDER_FULLY_PAID: orderFullyPaidPayload,
INVOICE_SENT: invoiceSentPayload,
GIFT_CARD_SENT: giftCardSentPayload,
ACCOUNT_CONFIRMATION: accountConfirmationPayload,
ACCOUNT_PASSWORD_RESET: accountPasswordResetPayload,
ACCOUNT_CHANGE_EMAIL_REQUEST: accountChangeEmailRequestPayload,

View file

@ -10,6 +10,7 @@ export const messageEventTypes = [
"ACCOUNT_CHANGE_EMAIL_REQUEST",
"ACCOUNT_CHANGE_EMAIL_CONFIRM",
"ACCOUNT_DELETE",
"GIFT_CARD_SENT",
] as const;
export type MessageEventTypes = (typeof messageEventTypes)[number];
@ -21,6 +22,7 @@ export const messageEventTypesLabels: Record<MessageEventTypes, string> = {
ORDER_CANCELLED: "Order cancelled",
ORDER_FULLY_PAID: "Order fully paid",
INVOICE_SENT: "Invoice sent",
GIFT_CARD_SENT: "Gift card sent",
ACCOUNT_CONFIRMATION: "Customer account confirmation",
ACCOUNT_PASSWORD_RESET: "Customer account password reset request",
ACCOUNT_CHANGE_EMAIL_REQUEST: "Customer account change email request",

View file

@ -9,6 +9,7 @@ import { MessageEventTypes } from "./message-event-types";
import { SmtpPrivateMetadataManager } from "../smtp/configuration/smtp-metadata-manager";
import { createSettingsManager } from "../../lib/metadata-manager";
import { SendgridPrivateMetadataManager } from "../sendgrid/configuration/sendgrid-metadata-manager";
import { FeatureFlagService } from "../feature-flag-service/feature-flag-service";
interface SendEventMessagesArgs {
recipientEmail: string;
@ -33,11 +34,16 @@ export const sendEventMessages = async ({
logger.debug("Function called");
const featureFlagService = new FeatureFlagService({
client,
});
const smtpConfigurationService = new SmtpConfigurationService({
metadataManager: new SmtpPrivateMetadataManager(
createSettingsManager(client, authData.appId),
authData.saleorApiUrl
),
featureFlagService,
});
const availableSmtpConfigurations = await smtpConfigurationService.getConfigurations({
@ -66,6 +72,7 @@ export const sendEventMessages = async ({
createSettingsManager(client, authData.appId),
authData.saleorApiUrl
),
featureFlagService,
});
const availableSendgridConfigurations = await sendgridConfigurationService.getConfigurations({

View file

@ -0,0 +1,48 @@
import { it, describe, expect, afterEach, vi } from "vitest";
import { FeatureFlagService } from "./feature-flag-service";
import * as fetchSaleorVersionExports from "./fetch-saleor-version";
import { Client } from "urql";
describe("FeatureFlagService", function () {
const createMockedClient = () => ({} as Client);
afterEach(() => {
vi.resetAllMocks();
});
it("No API calls, when version is passed to the constructor", async () => {
const passedVersion = "3.13.0";
const service = new FeatureFlagService({
client: createMockedClient(),
saleorVersion: passedVersion,
});
const versionFetchSpy = vi
.spyOn(fetchSaleorVersionExports, "fetchSaleorVersion")
.mockResolvedValue("XXXX");
expect(await service.getSaleorVersion()).toEqual(passedVersion);
expect(versionFetchSpy).not.toHaveBeenCalled();
});
it("Use cached version, when once fetched", async () => {
const fetchedVersion = "3.13.0";
const service = new FeatureFlagService({
client: createMockedClient(),
});
const versionFetchSpy = vi
.spyOn(fetchSaleorVersionExports, "fetchSaleorVersion")
.mockResolvedValue(fetchedVersion);
expect(await service.getSaleorVersion()).toEqual(fetchedVersion);
expect(versionFetchSpy).toHaveBeenCalledOnce();
// Request version once again - should be cached
expect(await service.getSaleorVersion()).toEqual(fetchedVersion);
expect(versionFetchSpy).toHaveBeenCalledOnce();
});
});

View file

@ -0,0 +1,40 @@
import { Client } from "urql";
import { FeatureFlagsState, getFeatureFlags } from "./get-feature-flags";
import { fetchSaleorVersion } from "./fetch-saleor-version";
import { createLogger } from "@saleor/apps-shared";
const logger = createLogger({
name: "FeatureFlagService",
});
/*
* Manages state of feature flags, based on Saleor version.
* If `saleorVersion` is not provided, it will be fetched from the API on first call.
* `saleorVersion` is expected to be in Semver format, e.g. "3.13.0"
*/
export class FeatureFlagService {
private client: Client;
private saleorVersion?: string;
constructor(args: { client: Client; saleorVersion?: string }) {
this.client = args.client;
this.saleorVersion = args.saleorVersion;
}
public getSaleorVersion = async (): Promise<string> => {
if (!this.saleorVersion) {
logger.debug("No cached value, fetching version from the API");
this.saleorVersion = await fetchSaleorVersion(this.client);
}
return this.saleorVersion;
};
public getFeatureFlags = async (): Promise<FeatureFlagsState> => {
logger.debug("Checking feature flags");
const saleorVersion = await this.getSaleorVersion();
const flags = getFeatureFlags({ saleorVersion });
logger.debug({ flags }, "Feature flags checked");
return flags;
};
}

View file

@ -0,0 +1,22 @@
import { Client, gql } from "urql";
import { FetchSaleorVersionDocument, FetchSaleorVersionQuery } from "../../../generated/graphql";
gql`
query FetchSaleorVersion {
shop {
version
}
}
`;
export async function fetchSaleorVersion(client: Client): Promise<string> {
const { error, data } = await client
.query<FetchSaleorVersionQuery>(FetchSaleorVersionDocument, {})
.toPromise();
if (error || !data?.shop.version) {
throw new Error("Can't fetch Saleor version");
}
return data.shop.version;
}

View file

@ -0,0 +1,11 @@
import { it, describe, expect } from "vitest";
import { getFeatureFlags } from "./get-feature-flags";
describe("getFeatureFlags", function () {
it("Flag should be turned off, when using too old version", () => {
expect(getFeatureFlags({ saleorVersion: "3.10.0" }).giftCardSentEvent).toEqual(false);
});
it("Flag should be turned on, when using version with feature support", () => {
expect(getFeatureFlags({ saleorVersion: "3.13.0" }).giftCardSentEvent).toEqual(true);
});
});

View file

@ -0,0 +1,21 @@
import { SaleorVersionCompatibilityValidator } from "@saleor/apps-shared";
export const featureFlags = ["giftCardSentEvent"] as const;
export type FeatureFlag = (typeof featureFlags)[number];
export type FeatureFlagsState = Record<FeatureFlag, boolean>;
interface GetFeatureFlagsArgs {
saleorVersion: string;
}
/*
* Returns list of feature flags based on Saleor version.
* `saleorVersion` is expected to be in Semver format, e.g. "3.13.0"
*/
export const getFeatureFlags = ({ saleorVersion }: GetFeatureFlagsArgs): FeatureFlagsState => {
return {
giftCardSentEvent: new SaleorVersionCompatibilityValidator(">=3.13").isValid(saleorVersion),
};
};

View file

@ -26,17 +26,22 @@ export const throwTrpcErrorFromConfigurationServiceError = (
case "CONFIGURATION_NOT_FOUND":
throw new TRPCError({
code: "NOT_FOUND",
message: "Configuration not found",
message: "Configuration not found.",
});
case "EVENT_CONFIGURATION_NOT_FOUND":
throw new TRPCError({
code: "NOT_FOUND",
message: "Event configuration not found",
message: "Event configuration not found.",
});
case "CANT_FETCH":
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Can't fetch configuration",
message: "Can't fetch configuration.",
});
case "WRONG_SALEOR_VERSION":
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Feature you are trying to use is not supported in this version of Saleor.",
});
}
}
@ -80,7 +85,7 @@ export const sendgridConfigurationRouter = router({
}
}),
createConfiguration: protectedWithConfigurationServices
.meta({ requiredClientPermissions: ["MANAGE_APPS"], updateWebhooks: true })
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(sendgridCreateConfigurationInputSchema)
.mutation(async ({ ctx, input }) => {
const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl });
@ -241,20 +246,17 @@ export const sendgridConfigurationRouter = router({
logger.debug(input, "sendgridConfigurationRouter.updateEventArray called");
return await Promise.all(
input.events.map(async (event) => {
const { eventType, ...eventConfiguration } = event;
try {
const configuration = await ctx.sendgridConfigurationService.getConfiguration({
id: input.configurationId,
});
try {
return await ctx.sendgridConfigurationService.updateEventConfiguration({
configurationId: input.configurationId,
eventType,
eventConfiguration,
});
} catch (e) {
throwTrpcErrorFromConfigurationServiceError(e);
}
})
);
await ctx.sendgridConfigurationService.updateConfiguration({
...configuration,
events: input.events,
});
} catch (e) {
throwTrpcErrorFromConfigurationServiceError(e);
}
}),
});

View file

@ -3,6 +3,8 @@ import { SendgridConfigurationService } from "./sendgrid-configuration.service";
import { SettingsManager } from "@saleor/app-sdk/settings-manager";
import { SendgridPrivateMetadataManager } from "./sendgrid-metadata-manager";
import { SendgridConfig } from "./sendgrid-config-schema";
import { Client } from "urql";
import { FeatureFlagService } from "../../feature-flag-service/feature-flag-service";
const mockSaleorApiUrl = "https://demo.saleor.io/graphql/";
@ -79,6 +81,11 @@ const validConfig: SendgridConfig = {
eventType: "ACCOUNT_DELETE",
template: undefined,
},
{
active: true,
eventType: "GIFT_CARD_SENT",
template: undefined,
},
],
sender: "1",
senderEmail: "no-reply@example.com",
@ -170,6 +177,10 @@ describe("SendgridConfigurationService", function () {
const getConfigMock = vi.spyOn(configurator, "getConfig").mockResolvedValue(undefined);
new SendgridConfigurationService({
featureFlagService: new FeatureFlagService({
client: {} as Client,
saleorVersion: "3.14.0",
}),
metadataManager: configurator,
});
@ -187,6 +198,10 @@ describe("SendgridConfigurationService", function () {
const getConfigMock = vi.spyOn(configurator, "getConfig").mockResolvedValue(validConfig);
const service = new SendgridConfigurationService({
featureFlagService: new FeatureFlagService({
client: {} as Client,
saleorVersion: "3.14.0",
}),
metadataManager: configurator,
});
@ -209,6 +224,10 @@ describe("SendgridConfigurationService", function () {
const getConfigMock = vi.spyOn(configurator, "getConfig").mockResolvedValue(emptyConfigRoot);
const service = new SendgridConfigurationService({
featureFlagService: new FeatureFlagService({
client: {} as Client,
saleorVersion: "3.14.0",
}),
metadataManager: configurator,
initialData: { ...validConfig },
});
@ -230,6 +249,10 @@ describe("SendgridConfigurationService", function () {
// Service initialized with empty configuration
const service = new SendgridConfigurationService({
featureFlagService: new FeatureFlagService({
client: {} as Client,
saleorVersion: "3.14.0",
}),
metadataManager: configurator,
initialData: emptyConfigRoot,
});
@ -244,6 +267,28 @@ describe("SendgridConfigurationService", function () {
expect(await service.getConfigurationRoot());
expect(getConfigMock).toBeCalledTimes(0);
});
it("Operation should be rejected, when attempting to save event not available according to feature flag", async () => {
const configurator = new SendgridPrivateMetadataManager(
null as unknown as SettingsManager,
mockSaleorApiUrl
);
// Service initialized with empty configuration
const service = new SendgridConfigurationService({
featureFlagService: new FeatureFlagService({
client: {} as Client,
saleorVersion: "3.12.0", // Feature flag is available since 3.13.0
}),
metadataManager: configurator,
initialData: emptyConfigRoot,
});
// Set configuration
await expect(async () => await service.setConfigurationRoot(validConfig)).rejects.toThrow(
"Gift card sent event is not supported for this Saleor version"
);
});
});
describe("getConfiguration", () => {
@ -254,6 +299,10 @@ describe("SendgridConfigurationService", function () {
);
const service = new SendgridConfigurationService({
featureFlagService: new FeatureFlagService({
client: {} as Client,
saleorVersion: "3.14.0",
}),
metadataManager: configurator,
initialData: { ...validConfig },
});
@ -270,6 +319,10 @@ describe("SendgridConfigurationService", function () {
);
const service = new SendgridConfigurationService({
featureFlagService: new FeatureFlagService({
client: {} as Client,
saleorVersion: "3.14.0",
}),
metadataManager: configurator,
initialData: { ...validConfig },
});
@ -288,6 +341,10 @@ describe("SendgridConfigurationService", function () {
);
const service = new SendgridConfigurationService({
featureFlagService: new FeatureFlagService({
client: {} as Client,
saleorVersion: "3.14.0",
}),
metadataManager: configurator,
initialData: emptyConfigRoot,
});
@ -302,6 +359,10 @@ describe("SendgridConfigurationService", function () {
);
const service = new SendgridConfigurationService({
featureFlagService: new FeatureFlagService({
client: {} as Client,
saleorVersion: "3.14.0",
}),
metadataManager: configurator,
initialData: { ...validConfig },
});
@ -323,6 +384,10 @@ describe("SendgridConfigurationService", function () {
const setConfigMock = vi.spyOn(configurator, "setConfig").mockResolvedValue();
const service = new SendgridConfigurationService({
featureFlagService: new FeatureFlagService({
client: {} as Client,
saleorVersion: "3.14.0",
}),
metadataManager: configurator,
initialData: emptyConfigRoot,
});
@ -351,6 +416,10 @@ describe("SendgridConfigurationService", function () {
const getConfigMock = vi.spyOn(configurator, "getConfig").mockResolvedValue(undefined);
const service = new SendgridConfigurationService({
featureFlagService: new FeatureFlagService({
client: {} as Client,
saleorVersion: "3.14.0",
}),
metadataManager: configurator,
initialData: { ...validConfig },
});
@ -381,6 +450,10 @@ describe("SendgridConfigurationService", function () {
const getConfigMock = vi.spyOn(configurator, "getConfig").mockResolvedValue(undefined);
const service = new SendgridConfigurationService({
featureFlagService: new FeatureFlagService({
client: {} as Client,
saleorVersion: "3.14.0",
}),
metadataManager: configurator,
initialData: { ...validConfig },
});
@ -404,6 +477,10 @@ describe("SendgridConfigurationService", function () {
const setConfigMock = vi.spyOn(configurator, "setConfig").mockResolvedValue();
const service = new SendgridConfigurationService({
featureFlagService: new FeatureFlagService({
client: {} as Client,
saleorVersion: "3.14.0",
}),
metadataManager: configurator,
initialData: { ...validConfig },
});
@ -433,6 +510,10 @@ describe("SendgridConfigurationService", function () {
const setConfigMock = vi.spyOn(configurator, "setConfig").mockResolvedValue();
const service = new SendgridConfigurationService({
featureFlagService: new FeatureFlagService({
client: {} as Client,
saleorVersion: "3.14.0",
}),
metadataManager: configurator,
initialData: { ...validConfig },
});
@ -456,6 +537,10 @@ describe("SendgridConfigurationService", function () {
);
const service = new SendgridConfigurationService({
featureFlagService: new FeatureFlagService({
client: {} as Client,
saleorVersion: "3.14.0",
}),
metadataManager: configurator,
initialData: { ...validConfig },
});
@ -475,6 +560,10 @@ describe("SendgridConfigurationService", function () {
);
const service = new SendgridConfigurationService({
featureFlagService: new FeatureFlagService({
client: {} as Client,
saleorVersion: "3.14.0",
}),
metadataManager: configurator,
initialData: { ...validConfig },
});
@ -506,6 +595,10 @@ describe("SendgridConfigurationService", function () {
const setConfigMock = vi.spyOn(configurator, "setConfig").mockResolvedValue();
const service = new SendgridConfigurationService({
featureFlagService: new FeatureFlagService({
client: {} as Client,
saleorVersion: "3.14.0",
}),
metadataManager: configurator,
initialData: { ...validConfig },
});
@ -538,6 +631,10 @@ describe("SendgridConfigurationService", function () {
const setConfigMock = vi.spyOn(configurator, "setConfig").mockResolvedValue();
const service = new SendgridConfigurationService({
featureFlagService: new FeatureFlagService({
client: {} as Client,
saleorVersion: "3.14.0",
}),
metadataManager: configurator,
initialData: { ...validConfig },
});

View file

@ -9,6 +9,7 @@ import { MessageEventTypes } from "../../event-handlers/message-event-types";
import { generateRandomId } from "../../../lib/generate-random-id";
import { filterConfigurations } from "../../app-configuration/filter-configurations";
import { SendgridPrivateMetadataManager } from "./sendgrid-metadata-manager";
import { FeatureFlagService } from "../../feature-flag-service/feature-flag-service";
const logger = createLogger({
service: "SendgridConfigurationService",
@ -18,7 +19,8 @@ export type SendgridConfigurationServiceErrorType =
| "OTHER"
| "CONFIGURATION_NOT_FOUND"
| "EVENT_CONFIGURATION_NOT_FOUND"
| "CANT_FETCH";
| "CANT_FETCH"
| "WRONG_SALEOR_VERSION";
export interface ConfigurationPartial extends Partial<SendgridConfiguration> {
id: SendgridConfiguration["id"];
@ -45,16 +47,20 @@ export interface FilterConfigurationsArgs {
export class SendgridConfigurationService {
private configurationData?: SendgridConfig;
private metadataConfigurator: SendgridPrivateMetadataManager;
private featureFlagService: FeatureFlagService;
constructor(args: {
metadataManager: SendgridPrivateMetadataManager;
initialData?: SendgridConfig;
featureFlagService: FeatureFlagService;
}) {
this.metadataConfigurator = args.metadataManager;
if (args.initialData) {
this.configurationData = args.initialData;
}
this.featureFlagService = args.featureFlagService;
}
/**
@ -101,10 +107,35 @@ export class SendgridConfigurationService {
return this.configurationData;
}
private containActiveGiftCardEvent(config: SendgridConfig) {
for (const configuration of config.configurations) {
const giftCardSentEvent = configuration.events.find(
(event) => event.eventType === "GIFT_CARD_SENT"
);
if (giftCardSentEvent?.active) {
return true;
}
}
return false;
}
// Saves configuration to Saleor API and cache it
async setConfigurationRoot(config: SendgridConfig) {
logger.debug("Set configuration root");
logger.debug("Validate configuration before sending it to the Saleor API");
const availableFeatures = await this.featureFlagService.getFeatureFlags();
if (!availableFeatures.giftCardSentEvent && this.containActiveGiftCardEvent(config)) {
logger.error(
"Attempt to enable gift card sent event for unsupported Saleor version. Aborting configuration update."
);
throw new SendgridConfigurationServiceError(
"Gift card sent event is not supported for this Saleor version",
"WRONG_SALEOR_VERSION"
);
}
logger.debug("Set configuration root");
this.configurationData = config;
await this.pushConfiguration();
}
@ -179,7 +210,7 @@ export class SendgridConfigurationService {
updatedConfigRoot.configurations[configurationIndex] = updatedConfiguration;
this.setConfigurationRoot(updatedConfigRoot);
await this.setConfigurationRoot(updatedConfigRoot);
return updatedConfiguration;
}
@ -199,7 +230,7 @@ export class SendgridConfigurationService {
(configuration) => configuration.id !== id
);
this.setConfigurationRoot(updatedConfigRoot);
await this.setConfigurationRoot(updatedConfigRoot);
}
/**

View file

@ -20,6 +20,7 @@ export const fetchTemplates =
},
}
);
if (!response.ok) {
console.error("Could not fetch available Sendgrid templates");
return [];
@ -33,6 +34,7 @@ export const fetchTemplates =
value: r.id.toString(),
label: r.name,
})) || [];
return templates;
} catch (e) {
console.error("Could not parse the response from Sendgrid", e);
@ -55,6 +57,7 @@ export const fetchSenders =
Authorization: `Bearer ${apiKey}`,
},
});
if (!response.ok) {
console.error("Could not fetch available Sendgrid senders");
return [];
@ -70,6 +73,7 @@ export const fetchSenders =
nickname: r.nickname,
from_email: r.from_email,
})) || [];
return senders;
} catch (e) {
console.error("Could not parse the response from Sendgrid", e);

View file

@ -1,9 +1,6 @@
import {
SendgridConfiguration,
SendgridEventConfiguration,
} from "../configuration/sendgrid-config-schema";
import { SendgridConfiguration } from "../configuration/sendgrid-config-schema";
import { BoxWithBorder } from "../../../components/box-with-border";
import { Box, Button, Text } from "@saleor/macaw-ui/next";
import { Box, Button, Text, Tooltip } from "@saleor/macaw-ui/next";
import { defaultPadding } from "../../../components/ui-defaults";
import { useDashboardNotification } from "@saleor/apps-shared";
import { trpcClient } from "../../trpc/trpc-client";
@ -30,6 +27,8 @@ interface SendgridEventsSectionProps {
export const SendgridEventsSection = ({ configuration }: SendgridEventsSectionProps) => {
const { notifySuccess, notifyError } = useDashboardNotification();
const { data: featureFlags } = trpcClient.app.featureFlags.useQuery();
// Sort events by displayed label
const eventsSorted = configuration.events.sort((a, b) =>
messageEventTypesLabels[a.eventType].localeCompare(messageEventTypesLabels[b.eventType])
@ -92,23 +91,43 @@ export const SendgridEventsSection = ({ configuration }: SendgridEventsSectionPr
</Table.Row>
</Table.Header>
<Table.Body>
{eventsSorted.map((event, index) => (
<Table.Row key={event.eventType}>
<Table.Cell>
<input type="checkbox" {...register(`events.${index}.active`)} />
</Table.Cell>
<Table.Cell>
<Text>{messageEventTypesLabels[event.eventType]}</Text>
</Table.Cell>
<Table.Cell>
<Select
control={control}
name={`events.${index}.template`}
options={templateChoices}
/>
</Table.Cell>
</Table.Row>
))}
{eventsSorted.map((event, index) => {
const isUnsupported =
!featureFlags?.giftCardSentEvent && event.eventType === "GIFT_CARD_SENT";
return (
<Table.Row key={event.eventType}>
<Table.Cell>
<Tooltip>
<Tooltip.Trigger>
<input
type="checkbox"
{...register(`events.${index}.active`)}
disabled={isUnsupported}
/>
</Tooltip.Trigger>
{isUnsupported && (
<Tooltip.Content side="left">
Event is available in Saleor version 3.13 and above only.
<Tooltip.Arrow />
</Tooltip.Content>
)}
</Tooltip>
</Table.Cell>
<Table.Cell>
<Text>{messageEventTypesLabels[event.eventType]}</Text>
</Table.Cell>
<Table.Cell>
<Select
control={control}
name={`events.${index}.template`}
options={templateChoices}
disabled={isUnsupported}
/>
</Table.Cell>
</Table.Row>
);
})}
</Table.Body>
</Table.Container>
</Box>

View file

@ -72,21 +72,6 @@ export const smtpUpdateEventSchema = smtpConfigurationEventSchema.merge(
export type SmtpUpdateEvent = z.infer<typeof smtpUpdateEventSchema>;
export const smtpUpdateEventActiveStatusInputSchema = smtpConfigurationEventSchema
.pick({
active: true,
eventType: true,
})
.merge(
smtpConfigurationSchema.pick({
id: true,
})
);
export type SmtpUpdateEventActiveStatusInput = z.infer<
typeof smtpUpdateEventActiveStatusInputSchema
>;
export const smtpGetEventConfigurationInputSchema = smtpConfigurationIdInputSchema.merge(
z.object({
eventType: z.enum(messageEventTypes),

View file

@ -11,7 +11,6 @@ import {
smtpGetConfigurationsInputSchema,
smtpGetEventConfigurationInputSchema,
smtpUpdateBasicInformationSchema,
smtpUpdateEventActiveStatusInputSchema,
smtpUpdateEventArraySchema,
smtpUpdateEventSchema,
smtpUpdateSenderSchema,
@ -29,17 +28,22 @@ export const throwTrpcErrorFromConfigurationServiceError = (
case "CONFIGURATION_NOT_FOUND":
throw new TRPCError({
code: "NOT_FOUND",
message: "Configuration not found",
message: "Configuration not found.",
});
case "EVENT_CONFIGURATION_NOT_FOUND":
throw new TRPCError({
code: "NOT_FOUND",
message: "Event configuration not found",
message: "Event configuration not found.",
});
case "CANT_FETCH":
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Can't fetch configuration",
message: "Can't fetch configuration.",
});
case "WRONG_SALEOR_VERSION":
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Feature you are trying to use is not supported in this version of Saleor.",
});
}
}
@ -84,7 +88,7 @@ export const smtpConfigurationRouter = router({
}
}),
createConfiguration: protectedWithConfigurationServices
.meta({ requiredClientPermissions: ["MANAGE_APPS"], updateWebhooks: true })
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(smtpCreateConfigurationInputSchema)
.mutation(async ({ ctx, input }) => {
const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl });
@ -95,7 +99,11 @@ export const smtpConfigurationRouter = router({
...input,
};
return await ctx.smtpConfigurationService.createConfiguration(newConfiguration);
try {
return await ctx.smtpConfigurationService.createConfiguration(newConfiguration);
} catch (e) {
console.log("erroro", e);
}
}),
deleteConfiguration: protectedWithConfigurationServices
.meta({ requiredClientPermissions: ["MANAGE_APPS"], updateWebhooks: true })
@ -260,25 +268,6 @@ export const smtpConfigurationRouter = router({
throwTrpcErrorFromConfigurationServiceError(e);
}
}),
updateEventActiveStatus: protectedWithConfigurationServices
.meta({ requiredClientPermissions: ["MANAGE_APPS"], updateWebhooks: true })
.input(smtpUpdateEventActiveStatusInputSchema)
.mutation(async ({ ctx, input }) => {
const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl });
logger.debug(input, "mjmlConfigurationRouter.updateEventActiveStatus called");
try {
return await ctx.smtpConfigurationService.updateEventConfiguration({
configurationId: input.id,
eventType: input.eventType,
eventConfiguration: {
active: input.active,
},
});
} catch (e) {
throwTrpcErrorFromConfigurationServiceError(e);
}
}),
updateEventArray: protectedWithConfigurationServices
.meta({ requiredClientPermissions: ["MANAGE_APPS"], updateWebhooks: true })
.input(smtpUpdateEventArraySchema)
@ -287,20 +276,17 @@ export const smtpConfigurationRouter = router({
logger.debug(input, "smtpConfigurationRouter.updateEventArray called");
return await Promise.all(
input.events.map(async (event) => {
const { eventType, ...eventConfiguration } = event;
try {
const configuration = await ctx.smtpConfigurationService.getConfiguration({
id: input.configurationId,
});
try {
return await ctx.smtpConfigurationService.updateEventConfiguration({
configurationId: input.configurationId,
eventType,
eventConfiguration,
});
} catch (e) {
throwTrpcErrorFromConfigurationServiceError(e);
}
})
);
await ctx.smtpConfigurationService.updateConfiguration({
...configuration,
events: input.events,
});
} catch (e) {
throwTrpcErrorFromConfigurationServiceError(e);
}
}),
});

View file

@ -3,6 +3,8 @@ import { SmtpConfigurationService } from "./smtp-configuration.service";
import { SettingsManager } from "@saleor/app-sdk/settings-manager";
import { SmtpPrivateMetadataManager } from "./smtp-metadata-manager";
import { SmtpConfig } from "./smtp-config-schema";
import { FeatureFlagService } from "../../feature-flag-service/feature-flag-service";
import { Client } from "urql";
const mockSaleorApiUrl = "https://demo.saleor.io/graphql/";
@ -91,6 +93,12 @@ const validConfig: SmtpConfig = {
template: "template",
subject: "Account deletion",
},
{
active: true,
eventType: "GIFT_CARD_SENT",
template: "template",
subject: "Gift card sent",
},
],
smtpUser: "John",
smtpPassword: "securepassword",
@ -196,6 +204,10 @@ describe("SmtpConfigurationService", function () {
const getConfigMock = vi.spyOn(configurator, "getConfig").mockResolvedValue(undefined);
new SmtpConfigurationService({
featureFlagService: new FeatureFlagService({
client: {} as Client,
saleorVersion: "3.14.0",
}),
metadataManager: configurator,
});
@ -213,6 +225,10 @@ describe("SmtpConfigurationService", function () {
const getConfigMock = vi.spyOn(configurator, "getConfig").mockResolvedValue(validConfig);
const service = new SmtpConfigurationService({
featureFlagService: new FeatureFlagService({
client: {} as Client,
saleorVersion: "3.14.0",
}),
metadataManager: configurator,
});
@ -235,6 +251,10 @@ describe("SmtpConfigurationService", function () {
const getConfigMock = vi.spyOn(configurator, "getConfig").mockResolvedValue(emptyConfigRoot);
const service = new SmtpConfigurationService({
featureFlagService: new FeatureFlagService({
client: {} as Client,
saleorVersion: "3.14.0",
}),
metadataManager: configurator,
initialData: { ...validConfig },
});
@ -256,6 +276,10 @@ describe("SmtpConfigurationService", function () {
// Service initialized with empty configuration
const service = new SmtpConfigurationService({
featureFlagService: new FeatureFlagService({
client: {} as Client,
saleorVersion: "3.14.0",
}),
metadataManager: configurator,
initialData: emptyConfigRoot,
});
@ -270,6 +294,28 @@ describe("SmtpConfigurationService", function () {
expect(await service.getConfigurationRoot());
expect(getConfigMock).toBeCalledTimes(0);
});
it("Operation should be rejected, when attempting to save event not available according to feature flag", async () => {
const configurator = new SmtpPrivateMetadataManager(
null as unknown as SettingsManager,
mockSaleorApiUrl
);
// Service initialized with empty configuration
const service = new SmtpConfigurationService({
featureFlagService: new FeatureFlagService({
client: {} as Client,
saleorVersion: "3.12.0", // This version does not support Gift Card event
}),
metadataManager: configurator,
initialData: emptyConfigRoot,
});
// Set configuration
await expect(async () => await service.setConfigurationRoot(validConfig)).rejects.toThrow(
"Gift card sent event is not supported for this Saleor version"
);
});
});
describe("getConfiguration", () => {
@ -280,6 +326,10 @@ describe("SmtpConfigurationService", function () {
);
const service = new SmtpConfigurationService({
featureFlagService: new FeatureFlagService({
client: {} as Client,
saleorVersion: "3.14.0",
}),
metadataManager: configurator,
initialData: { ...validConfig },
});
@ -296,6 +346,10 @@ describe("SmtpConfigurationService", function () {
);
const service = new SmtpConfigurationService({
featureFlagService: new FeatureFlagService({
client: {} as Client,
saleorVersion: "3.14.0",
}),
metadataManager: configurator,
initialData: { ...validConfig },
});
@ -314,6 +368,10 @@ describe("SmtpConfigurationService", function () {
);
const service = new SmtpConfigurationService({
featureFlagService: new FeatureFlagService({
client: {} as Client,
saleorVersion: "3.14.0",
}),
metadataManager: configurator,
initialData: emptyConfigRoot,
});
@ -328,6 +386,10 @@ describe("SmtpConfigurationService", function () {
);
const service = new SmtpConfigurationService({
featureFlagService: new FeatureFlagService({
client: {} as Client,
saleorVersion: "3.14.0",
}),
metadataManager: configurator,
initialData: { ...validConfig },
});
@ -349,6 +411,10 @@ describe("SmtpConfigurationService", function () {
const setConfigMock = vi.spyOn(configurator, "setConfig").mockResolvedValue();
const service = new SmtpConfigurationService({
featureFlagService: new FeatureFlagService({
client: {} as Client,
saleorVersion: "3.14.0",
}),
metadataManager: configurator,
initialData: emptyConfigRoot,
});
@ -378,6 +444,10 @@ describe("SmtpConfigurationService", function () {
const getConfigMock = vi.spyOn(configurator, "getConfig").mockResolvedValue(undefined);
const service = new SmtpConfigurationService({
featureFlagService: new FeatureFlagService({
client: {} as Client,
saleorVersion: "3.14.0",
}),
metadataManager: configurator,
initialData: { ...validConfig },
});
@ -408,6 +478,10 @@ describe("SmtpConfigurationService", function () {
const getConfigMock = vi.spyOn(configurator, "getConfig").mockResolvedValue(undefined);
const service = new SmtpConfigurationService({
featureFlagService: new FeatureFlagService({
client: {} as Client,
saleorVersion: "3.14.0",
}),
metadataManager: configurator,
initialData: { ...validConfig },
});
@ -431,6 +505,10 @@ describe("SmtpConfigurationService", function () {
const setConfigMock = vi.spyOn(configurator, "setConfig").mockResolvedValue();
const service = new SmtpConfigurationService({
featureFlagService: new FeatureFlagService({
client: {} as Client,
saleorVersion: "3.14.0",
}),
metadataManager: configurator,
initialData: { ...validConfig },
});
@ -460,6 +538,10 @@ describe("SmtpConfigurationService", function () {
const setConfigMock = vi.spyOn(configurator, "setConfig").mockResolvedValue();
const service = new SmtpConfigurationService({
featureFlagService: new FeatureFlagService({
client: {} as Client,
saleorVersion: "3.14.0",
}),
metadataManager: configurator,
initialData: { ...validConfig },
});
@ -483,6 +565,10 @@ describe("SmtpConfigurationService", function () {
);
const service = new SmtpConfigurationService({
featureFlagService: new FeatureFlagService({
client: {} as Client,
saleorVersion: "3.14.0",
}),
metadataManager: configurator,
initialData: { ...validConfig },
});
@ -502,6 +588,10 @@ describe("SmtpConfigurationService", function () {
);
const service = new SmtpConfigurationService({
featureFlagService: new FeatureFlagService({
client: {} as Client,
saleorVersion: "3.14.0",
}),
metadataManager: configurator,
initialData: { ...validConfig },
});
@ -533,6 +623,10 @@ describe("SmtpConfigurationService", function () {
const setConfigMock = vi.spyOn(configurator, "setConfig").mockResolvedValue();
const service = new SmtpConfigurationService({
featureFlagService: new FeatureFlagService({
client: {} as Client,
saleorVersion: "3.14.0",
}),
metadataManager: configurator,
initialData: { ...validConfig },
});
@ -565,6 +659,10 @@ describe("SmtpConfigurationService", function () {
const setConfigMock = vi.spyOn(configurator, "setConfig").mockResolvedValue();
const service = new SmtpConfigurationService({
featureFlagService: new FeatureFlagService({
client: {} as Client,
saleorVersion: "3.14.0",
}),
metadataManager: configurator,
initialData: { ...validConfig },
});

View file

@ -5,6 +5,7 @@ import { MessageEventTypes } from "../../event-handlers/message-event-types";
import { generateRandomId } from "../../../lib/generate-random-id";
import { smtpDefaultEmptyConfigurations } from "./smtp-default-empty-configurations";
import { filterConfigurations } from "../../app-configuration/filter-configurations";
import { FeatureFlagService } from "../../feature-flag-service/feature-flag-service";
const logger = createLogger({
service: "SmtpConfigurationService",
@ -14,7 +15,8 @@ export type SmtpConfigurationServiceErrorType =
| "OTHER"
| "CONFIGURATION_NOT_FOUND"
| "EVENT_CONFIGURATION_NOT_FOUND"
| "CANT_FETCH";
| "CANT_FETCH"
| "WRONG_SALEOR_VERSION";
export interface ConfigurationPartial extends Partial<SmtpConfiguration> {
id: SmtpConfiguration["id"];
@ -41,13 +43,20 @@ export interface FilterConfigurationsArgs {
export class SmtpConfigurationService {
private configurationData?: SmtpConfig;
private metadataConfigurator: SmtpPrivateMetadataManager;
private featureFlagService: FeatureFlagService;
constructor(args: { metadataManager: SmtpPrivateMetadataManager; initialData?: SmtpConfig }) {
constructor(args: {
metadataManager: SmtpPrivateMetadataManager;
initialData?: SmtpConfig;
featureFlagService: FeatureFlagService;
}) {
this.metadataConfigurator = args.metadataManager;
if (args.initialData) {
this.configurationData = args.initialData;
}
this.featureFlagService = args.featureFlagService;
}
/**
@ -94,10 +103,35 @@ export class SmtpConfigurationService {
return this.configurationData;
}
private containActiveGiftCardEvent(config: SmtpConfig) {
for (const configuration of config.configurations) {
const giftCardSentEvent = configuration.events.find(
(event) => event.eventType === "GIFT_CARD_SENT"
);
if (giftCardSentEvent?.active) {
return true;
}
}
return false;
}
// Saves configuration to Saleor API and cache it
async setConfigurationRoot(config: SmtpConfig) {
logger.debug("Set configuration root");
logger.debug("Validate configuration before sending it to the Saleor API");
const availableFeatures = await this.featureFlagService.getFeatureFlags();
if (!availableFeatures.giftCardSentEvent && this.containActiveGiftCardEvent(config)) {
logger.error(
"Attempt to enable gift card sent event for unsupported Saleor version. Aborting configuration update."
);
throw new SmtpConfigurationServiceError(
"Gift card sent event is not supported for this Saleor version",
"WRONG_SALEOR_VERSION"
);
}
logger.debug("Set configuration root");
this.configurationData = config;
await this.pushConfiguration();
}
@ -169,7 +203,7 @@ export class SmtpConfigurationService {
updatedConfigRoot.configurations[configurationIndex] = updatedConfiguration;
this.setConfigurationRoot(updatedConfigRoot);
await this.setConfigurationRoot(updatedConfigRoot);
return updatedConfiguration;
}
@ -189,7 +223,7 @@ export class SmtpConfigurationService {
(configuration) => configuration.id !== id
);
this.setConfigurationRoot(updatedConfigRoot);
await this.setConfigurationRoot(updatedConfigRoot);
}
/**

View file

@ -170,6 +170,22 @@ const defaultInvoiceSentMjmlTemplate = `<mjml>
</mj-body>
</mjml>`;
// TODO: Improve the template
const defaultGiftCardSentMjmlTemplate = `<mjml>
<mj-body>
<mj-section>
<mj-column>
<mj-text font-size="16px">
Hi!
</mj-text>
<mj-text>
Heres your gift card
</mj-text>
</mj-column>
</mj-section>
</mj-body>
</mjml>`;
const defaultAccountConfirmationMjmlTemplate = `<mjml>
<mj-body>
<mj-section>
@ -258,29 +274,31 @@ const defaultAccountDeleteMjmlTemplate = `<mjml>
</mjml>`;
export const defaultMjmlTemplates: Record<MessageEventTypes, string> = {
ACCOUNT_CHANGE_EMAIL_CONFIRM: defaultAccountChangeEmailConfirmationMjmlTemplate,
ACCOUNT_CHANGE_EMAIL_REQUEST: defaultAccountChangeEmailRequestMjmlTemplate,
ACCOUNT_CONFIRMATION: defaultAccountConfirmationMjmlTemplate,
ACCOUNT_DELETE: defaultAccountDeleteMjmlTemplate,
ACCOUNT_PASSWORD_RESET: defaultAccountPasswordResetMjmlTemplate,
GIFT_CARD_SENT: defaultGiftCardSentMjmlTemplate,
INVOICE_SENT: defaultInvoiceSentMjmlTemplate,
ORDER_CANCELLED: defaultOrderCancelledMjmlTemplate,
ORDER_CONFIRMED: defaultOrderConfirmedMjmlTemplate,
ORDER_CREATED: defaultOrderCreatedMjmlTemplate,
ORDER_FULFILLED: defaultOrderFulfilledMjmlTemplate,
ORDER_CONFIRMED: defaultOrderConfirmedMjmlTemplate,
ORDER_FULLY_PAID: defaultOrderFullyPaidMjmlTemplate,
ORDER_CANCELLED: defaultOrderCancelledMjmlTemplate,
INVOICE_SENT: defaultInvoiceSentMjmlTemplate,
ACCOUNT_CONFIRMATION: defaultAccountConfirmationMjmlTemplate,
ACCOUNT_PASSWORD_RESET: defaultAccountPasswordResetMjmlTemplate,
ACCOUNT_CHANGE_EMAIL_REQUEST: defaultAccountChangeEmailRequestMjmlTemplate,
ACCOUNT_CHANGE_EMAIL_CONFIRM: defaultAccountChangeEmailConfirmationMjmlTemplate,
ACCOUNT_DELETE: defaultAccountDeleteMjmlTemplate,
};
export const defaultMjmlSubjectTemplates: Record<MessageEventTypes, string> = {
ACCOUNT_CHANGE_EMAIL_CONFIRM: "Email change confirmation",
ACCOUNT_CHANGE_EMAIL_REQUEST: "Email change request",
ACCOUNT_CONFIRMATION: "Account activation",
ACCOUNT_DELETE: "Account deletion",
ACCOUNT_PASSWORD_RESET: "Password reset request",
GIFT_CARD_SENT: "Gift card",
INVOICE_SENT: "New invoice has been created",
ORDER_CANCELLED: "Order {{ order.number }} has been cancelled",
ORDER_CONFIRMED: "Order {{ order.number }} has been confirmed",
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",
ACCOUNT_CONFIRMATION: "Account activation",
ACCOUNT_PASSWORD_RESET: "Password reset request",
ACCOUNT_CHANGE_EMAIL_REQUEST: "Email change request",
ACCOUNT_CHANGE_EMAIL_CONFIRM: "Email change confirmation",
ACCOUNT_DELETE: "Account deletion",
};

View file

@ -1,6 +1,6 @@
import { SmtpConfiguration } from "../configuration/smtp-config-schema";
import { BoxWithBorder } from "../../../components/box-with-border";
import { Box, Button, Text } from "@saleor/macaw-ui/next";
import { Box, Button, Text, Tooltip } from "@saleor/macaw-ui/next";
import { defaultPadding } from "../../../components/ui-defaults";
import { SectionWithDescription } from "../../../components/section-with-description";
import { useRouter } from "next/router";
@ -28,6 +28,8 @@ export const SmtpEventsSection = ({ configuration }: SmtpEventsSectionProps) =>
const { notifySuccess, notifyError } = useDashboardNotification();
const router = useRouter();
const { data: featureFlags } = trpcClient.app.featureFlags.useQuery();
// Sort events by displayed label
const eventsSorted = configuration.events.sort((a, b) =>
messageEventTypesLabels[a.eventType].localeCompare(messageEventTypesLabels[b.eventType])
@ -84,29 +86,49 @@ export const SmtpEventsSection = ({ configuration }: SmtpEventsSectionProps) =>
</Table.Row>
</Table.Header>
<Table.Body>
{eventsSorted.map((event, index) => (
<Table.Row key={event.eventType}>
<Table.Cell>
<input type="checkbox" {...register(`events.${index}.active`)} />
</Table.Cell>
<Table.Cell>
<Text>{messageEventTypesLabels[event.eventType]}</Text>
</Table.Cell>
<Table.Cell>
<Button
variant="tertiary"
size="small"
onClick={() => {
router.push(
smtpUrls.eventConfiguration(configuration.id, event.eventType)
);
}}
>
Edit template
</Button>
</Table.Cell>
</Table.Row>
))}
{eventsSorted.map((event, index) => {
const isUnsupported =
!featureFlags?.giftCardSentEvent && event.eventType === "GIFT_CARD_SENT";
return (
<Table.Row key={event.eventType}>
<Table.Cell>
<Tooltip>
<Tooltip.Trigger>
<input
type="checkbox"
{...register(`events.${index}.active`)}
disabled={isUnsupported}
/>
</Tooltip.Trigger>
{isUnsupported && (
<Tooltip.Content side="left">
Event is available in Saleor version 3.13 and above only.
<Tooltip.Arrow />
</Tooltip.Content>
)}
</Tooltip>
</Table.Cell>
<Table.Cell>
<Text>{messageEventTypesLabels[event.eventType]}</Text>
</Table.Cell>
<Table.Cell>
<Button
variant="tertiary"
size="small"
onClick={() => {
router.push(
smtpUrls.eventConfiguration(configuration.id, event.eventType)
);
}}
disabled={isUnsupported}
>
Edit template
</Button>
</Table.Cell>
</Table.Row>
);
})}
</Table.Body>
</Table.Container>
</Box>

View file

@ -7,6 +7,7 @@ import { SmtpPrivateMetadataManager } from "../smtp/configuration/smtp-metadata-
import { syncWebhookStatus } from "../webhook-management/sync-webhook-status";
import { protectedClientProcedure } from "./protected-client-procedure";
import { WebhookManagementService } from "../webhook-management/webhook-management-service";
import { FeatureFlagService } from "../feature-flag-service/feature-flag-service";
const logger = createLogger({ name: "protectedWithConfigurationServices middleware" });
@ -19,11 +20,21 @@ const logger = createLogger({ name: "protectedWithConfigurationServices middlewa
*/
export const protectedWithConfigurationServices = protectedClientProcedure.use(
async ({ next, ctx, meta }) => {
/*
* TODO: When App Bridge will add Saleor Version do the context,
* extract it from there and pass it to the service constructor.
* It will reduce additional call to the API.
*/
const featureFlagService = new FeatureFlagService({
client: ctx.apiClient,
});
const smtpConfigurationService = new SmtpConfigurationService({
metadataManager: new SmtpPrivateMetadataManager(
createSettingsManager(ctx.apiClient, ctx.appId!),
ctx.saleorApiUrl
),
featureFlagService,
});
const sendgridConfigurationService = new SendgridConfigurationService({
@ -31,19 +42,25 @@ export const protectedWithConfigurationServices = protectedClientProcedure.use(
createSettingsManager(ctx.apiClient, ctx.appId!),
ctx.saleorApiUrl
),
featureFlagService,
});
const result = await next({
ctx: {
smtpConfigurationService,
sendgridConfigurationService,
featureFlagService,
},
});
if (meta?.updateWebhooks) {
logger.debug("Updating webhooks");
const webhookManagementService = new WebhookManagementService(ctx.baseUrl, ctx.apiClient);
const webhookManagementService = new WebhookManagementService({
appBaseUrl: ctx.baseUrl,
client: ctx.apiClient,
featureFlagService: featureFlagService,
});
await syncWebhookStatus({
sendgridConfigurationService,

View file

@ -2,8 +2,10 @@ import { channelsRouter } from "../channels/channels.router";
import { router } from "./trpc-server";
import { smtpConfigurationRouter } from "../smtp/configuration/smtp-configuration.router";
import { sendgridConfigurationRouter } from "../sendgrid/configuration/sendgrid-configuration.router";
import { appConfigurationRouter } from "../app-configuration/app-configuration.router";
export const appRouter = router({
app: appConfigurationRouter,
channels: channelsRouter,
smtpConfiguration: smtpConfigurationRouter,
sendgridConfiguration: sendgridConfigurationRouter,

View file

@ -177,6 +177,7 @@ describe("getWebhookStatusesFromConfigurations", function () {
orderFulfilledWebhook: false,
orderCreatedWebhook: false,
orderFullyPaidWebhook: false,
giftCardSentWebhook: false,
});
});
@ -194,6 +195,7 @@ describe("getWebhookStatusesFromConfigurations", function () {
orderFulfilledWebhook: false,
orderCreatedWebhook: false,
orderFullyPaidWebhook: false,
giftCardSentWebhook: false,
});
});
@ -216,6 +218,7 @@ describe("getWebhookStatusesFromConfigurations", function () {
orderFulfilledWebhook: false,
orderCreatedWebhook: false,
orderFullyPaidWebhook: false,
giftCardSentWebhook: false,
});
});
@ -246,6 +249,7 @@ describe("getWebhookStatusesFromConfigurations", function () {
orderFulfilledWebhook: false,
orderCreatedWebhook: false,
orderFullyPaidWebhook: false,
giftCardSentWebhook: false,
});
});
@ -282,6 +286,7 @@ describe("getWebhookStatusesFromConfigurations", function () {
orderFulfilledWebhook: false,
orderCreatedWebhook: false,
orderFullyPaidWebhook: false,
giftCardSentWebhook: false,
});
});
});

View file

@ -13,13 +13,15 @@ export const getWebhookStatusesFromConfigurations = ({
smtpConfigurations: SmtpConfiguration[];
sendgridConfigurations: SendgridConfiguration[];
}) => {
// TODO: this dict should be generated in one place instead of manually edited
const statuses: Record<AppWebhook, boolean> = {
giftCardSentWebhook: false,
invoiceSentWebhook: false,
notifyWebhook: false,
orderCancelledWebhook: false,
orderConfirmedWebhook: false,
orderFulfilledWebhook: false,
orderCreatedWebhook: false,
orderFulfilledWebhook: false,
orderFullyPaidWebhook: false,
};

View file

@ -64,6 +64,7 @@ describe("syncWebhookStatus", function () {
orderFulfilledWebhook: false,
orderCreatedWebhook: false,
orderFullyPaidWebhook: false,
giftCardSentWebhook: false,
});
const getWebhooksStatusMock = vi
@ -76,6 +77,7 @@ describe("syncWebhookStatus", function () {
orderFulfilledWebhook: false,
orderCreatedWebhook: false,
orderFullyPaidWebhook: false,
giftCardSentWebhook: false,
});
await syncWebhookStatus({
@ -98,6 +100,7 @@ describe("syncWebhookStatus", function () {
orderFulfilledWebhook: false,
orderCreatedWebhook: false,
orderFullyPaidWebhook: false,
giftCardSentWebhook: false,
});
const getWebhooksStatusMock = vi
@ -110,6 +113,7 @@ describe("syncWebhookStatus", function () {
orderFulfilledWebhook: false,
orderCreatedWebhook: false,
orderFullyPaidWebhook: false,
giftCardSentWebhook: false,
});
await syncWebhookStatus({
@ -132,6 +136,7 @@ describe("syncWebhookStatus", function () {
orderFulfilledWebhook: false,
orderCreatedWebhook: false,
orderFullyPaidWebhook: false,
giftCardSentWebhook: false,
});
const getWebhooksStatusMock = vi
@ -144,6 +149,7 @@ describe("syncWebhookStatus", function () {
orderFulfilledWebhook: false,
orderCreatedWebhook: false,
orderFullyPaidWebhook: false,
giftCardSentWebhook: false,
});
await syncWebhookStatus({

View file

@ -5,8 +5,8 @@ import * as operationExports from "./api-operations";
import { WebhookEventTypeAsyncEnum } from "../../../generated/graphql";
import { invoiceSentWebhook } from "../../pages/api/webhooks/invoice-sent";
import { orderCancelledWebhook } from "../../pages/api/webhooks/order-cancelled";
const mockSaleorApiUrl = "https://demo.saleor.io/graphql/";
import { FeatureFlagService } from "../feature-flag-service/feature-flag-service";
import { giftCardSentWebhook } from "../../pages/api/webhooks/gift-card-sent";
describe("WebhookManagementService", function () {
const mockedClient = {} as Client;
@ -16,10 +16,14 @@ describe("WebhookManagementService", function () {
});
it("API should be called, when getWebhooks is used", async () => {
const webhookManagementService = new WebhookManagementService(
"https://example.com",
mockedClient
);
const webhookManagementService = new WebhookManagementService({
client: mockedClient,
appBaseUrl: "https://example.com",
featureFlagService: new FeatureFlagService({
client: {} as Client,
saleorVersion: "3.14.0",
}),
});
const fetchAppWebhooksMock = vi.spyOn(operationExports, "fetchAppWebhooks").mockResolvedValue([
{
@ -43,11 +47,15 @@ describe("WebhookManagementService", function () {
expect(fetchAppWebhooksMock).toBeCalledTimes(1);
});
it("Webhook statuses should be active, when whists in API and active", async () => {
const webhookManagementService = new WebhookManagementService(
"https://example.com",
mockedClient
);
it("Webhook statuses should be active, when webhook is created in the API and set to active", async () => {
const webhookManagementService = new WebhookManagementService({
client: mockedClient,
appBaseUrl: "https://example.com",
featureFlagService: new FeatureFlagService({
client: {} as Client,
saleorVersion: "3.14.0",
}),
});
const fetchAppWebhooksMock = vi.spyOn(operationExports, "fetchAppWebhooks").mockResolvedValue([
{
@ -76,15 +84,20 @@ describe("WebhookManagementService", function () {
orderCreatedWebhook: false,
orderFulfilledWebhook: false,
orderFullyPaidWebhook: false,
giftCardSentWebhook: false,
});
expect(fetchAppWebhooksMock).toBeCalledTimes(1);
});
it("Webhook should be created using the API, when requested", async () => {
const webhookManagementService = new WebhookManagementService(
"https://example.com",
mockedClient
);
const webhookManagementService = new WebhookManagementService({
client: mockedClient,
appBaseUrl: "https://example.com",
featureFlagService: new FeatureFlagService({
client: {} as Client,
saleorVersion: "3.14.0",
}),
});
const createAppWebhookMock = vi.spyOn(operationExports, "createAppWebhook").mockResolvedValue({
id: "1",
@ -113,11 +126,33 @@ describe("WebhookManagementService", function () {
});
});
it("Should throw error, when attempting to create gift card sent webhook in unsupported environment", async () => {
const webhookManagementService = new WebhookManagementService({
client: mockedClient,
appBaseUrl: "https://example.com",
featureFlagService: new FeatureFlagService({
client: {} as Client,
saleorVersion: "3.12.0", // Gift card sent webhook is supported from 3.13.0
}),
});
await expect(
async () =>
await webhookManagementService.createWebhook({
webhook: "giftCardSentWebhook",
})
).rejects.toThrow("Gift card event is not supported in this environment");
});
it("Webhook should be deleted using the API, when requested", async () => {
const webhookManagementService = new WebhookManagementService(
"https://example.com",
mockedClient
);
const webhookManagementService = new WebhookManagementService({
client: mockedClient,
appBaseUrl: "https://example.com",
featureFlagService: new FeatureFlagService({
client: {} as Client,
saleorVersion: "3.14.0",
}),
});
vi.spyOn(operationExports, "fetchAppWebhooks").mockResolvedValue([
{

View file

@ -10,25 +10,30 @@ import { notifyWebhook } from "../../pages/api/webhooks/notify";
import { MessageEventTypes } from "../event-handlers/message-event-types";
import { createLogger } from "@saleor/apps-shared";
import { WebhookEventTypeAsyncEnum } from "../../../generated/graphql";
import { giftCardSentWebhook } from "../../pages/api/webhooks/gift-card-sent";
import { FeatureFlagService } from "../feature-flag-service/feature-flag-service";
import { FeatureFlagsState } from "../feature-flag-service/get-feature-flags";
export const AppWebhooks = {
orderCreatedWebhook,
orderFulfilledWebhook,
orderConfirmedWebhook,
orderCancelledWebhook,
orderFullyPaidWebhook,
giftCardSentWebhook,
invoiceSentWebhook,
notifyWebhook,
orderCancelledWebhook,
orderConfirmedWebhook,
orderCreatedWebhook,
orderFulfilledWebhook,
orderFullyPaidWebhook,
};
export type AppWebhook = keyof typeof AppWebhooks;
export const eventToWebhookMapping: Record<MessageEventTypes, AppWebhook> = {
ACCOUNT_CHANGE_EMAIL_CONFIRM: "notifyWebhook",
ACCOUNT_CHANGE_EMAIL_REQUEST: "notifyWebhook",
ACCOUNT_CONFIRMATION: "notifyWebhook",
ACCOUNT_DELETE: "notifyWebhook",
ACCOUNT_PASSWORD_RESET: "notifyWebhook",
ACCOUNT_CHANGE_EMAIL_REQUEST: "notifyWebhook",
ACCOUNT_CHANGE_EMAIL_CONFIRM: "notifyWebhook",
GIFT_CARD_SENT: "giftCardSentWebhook",
INVOICE_SENT: "invoiceSentWebhook",
ORDER_CANCELLED: "orderCancelledWebhook",
ORDER_CONFIRMED: "orderConfirmedWebhook",
@ -42,7 +47,19 @@ const logger = createLogger({
});
export class WebhookManagementService {
constructor(private appBaseUrl: string, private client: Client) {}
private appBaseUrl: string;
private client: Client;
private featureFlagService: FeatureFlagService;
constructor(args: {
appBaseUrl: string;
client: Client;
featureFlagService: FeatureFlagService;
}) {
this.appBaseUrl = args.appBaseUrl;
this.client = args.client;
this.featureFlagService = args.featureFlagService;
}
// Returns list of webhooks registered for the App in the Saleor instance
public async getWebhooks() {
@ -70,6 +87,13 @@ export class WebhookManagementService {
}
public async createWebhook({ webhook }: { webhook: AppWebhook }) {
const flags = await this.featureFlagService.getFeatureFlags();
if (!flags.giftCardSentEvent && webhook === "giftCardSentWebhook") {
logger.error(`Attempt to activate Gift Card Sent webhook despite unsupported environment`);
throw new Error("Gift card event is not supported in this environment");
}
const webhookManifest = AppWebhooks[webhook].getWebhookManifest(this.appBaseUrl);
const asyncWebhooks = webhookManifest.asyncEvents;

View file

@ -1,6 +1,12 @@
import { createAppRegisterHandler } from "@saleor/app-sdk/handlers/next";
import { saleorApp } from "../../saleor-app";
import { REQUIRED_SALEOR_VERSION, saleorApp } from "../../saleor-app";
import {
SaleorVersionCompatibilityValidator,
createGraphQLClient,
createLogger,
} from "@saleor/apps-shared";
import { fetchSaleorVersion } from "../../modules/feature-flag-service/fetch-saleor-version";
const allowedUrlsPattern = process.env.ALLOWED_DOMAIN_PATTERN;
@ -21,4 +27,54 @@ export default createAppRegisterHandler({
return true;
},
],
async onRequestVerified(req, { authData: { token, saleorApiUrl }, respondWithError }) {
const logger = createLogger({
name: "onRequestVerified",
});
let saleorVersion: string;
try {
const client = createGraphQLClient({
saleorApiUrl: saleorApiUrl,
token: token,
});
saleorVersion = await fetchSaleorVersion(client);
} catch (e: unknown) {
const message = (e as Error)?.message ?? "Unknown error";
logger.debug(
{ message, saleorApiUrl },
"Error during fetching saleor version in onRequestVerified handler"
);
throw respondWithError({
message: "Couldn't communicate with Saleor API",
status: 400,
});
}
if (!saleorVersion) {
logger.warn({ saleorApiUrl }, "No version returned from Saleor API");
throw respondWithError({
message: "Saleor version couldn't be fetched from the API",
status: 400,
});
}
const isVersionValid = new SaleorVersionCompatibilityValidator(REQUIRED_SALEOR_VERSION).isValid(
saleorVersion
);
if (!isVersionValid) {
logger.info({ saleorApiUrl }, "Rejecting installation due to incompatible Saleor version");
throw respondWithError({
message: `Saleor version (${saleorVersion}) is not compatible with this app version (${REQUIRED_SALEOR_VERSION})`,
status: 400,
});
}
logger.info("Saleor version validated successfully");
},
});

View file

@ -0,0 +1,118 @@
import { NextWebhookApiHandler, SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next";
import { gql } from "urql";
import { saleorApp } from "../../../saleor-app";
import { createLogger, createGraphQLClient } from "@saleor/apps-shared";
import { GiftCardSentWebhookPayloadFragment } from "../../../../generated/graphql";
import { sendEventMessages } from "../../../modules/event-handlers/send-event-messages";
const GiftCardSentWebhookPayload = gql`
fragment GiftCardSentWebhookPayload on GiftCardSent {
giftCard {
id
code
displayCode
last4CodeChars
created
usedByEmail
isActive
initialBalance {
currency
amount
}
currentBalance {
currency
amount
}
tags {
id
name
}
expiryDate
lastUsedOn
usedBy {
firstName
lastName
email
}
}
sentToEmail
channel
}
`;
const GiftCardSentGraphqlSubscription = gql`
${GiftCardSentWebhookPayload}
subscription GiftCardSent {
event {
...GiftCardSentWebhookPayload
}
}
`;
export const giftCardSentWebhook = new SaleorAsyncWebhook<GiftCardSentWebhookPayloadFragment>({
name: "Gift card sent in Saleor",
webhookPath: "api/webhooks/gift-card-sent",
asyncEvent: "GIFT_CARD_SENT",
apl: saleorApp.apl,
subscriptionQueryAst: GiftCardSentGraphqlSubscription,
});
const handler: NextWebhookApiHandler<GiftCardSentWebhookPayloadFragment> = async (
req,
res,
context
) => {
const logger = createLogger({
webhook: giftCardSentWebhook.name,
});
logger.debug("Webhook received");
const { payload, authData } = context;
const { giftCard } = payload;
if (!giftCard) {
logger.error("No gift card data payload");
return res.status(200).end();
}
const recipientEmail = payload.sentToEmail;
if (!recipientEmail?.length) {
logger.error(`The gift card ${giftCard.id} had no email recipient set. Aborting.`);
return res
.status(200)
.json({ error: "Email recipient has not been specified in the event payload." });
}
const channel = payload.channel;
if (!channel) {
logger.error("No channel specified in payload");
return res.status(200).end();
}
const client = createGraphQLClient({
saleorApiUrl: authData.saleorApiUrl,
token: authData.token,
});
await sendEventMessages({
authData,
channel,
client,
event: "GIFT_CARD_SENT",
payload,
recipientEmail,
});
return res.status(200).json({ message: "The event has been handled" });
};
export default giftCardSentWebhook.createHandler(handler);
export const config = {
api: {
bodyParser: false,
},
};

View file

@ -33,3 +33,5 @@ switch (aplType) {
export const saleorApp = new SaleorApp({
apl,
});
export const REQUIRED_SALEOR_VERSION = ">=3.11.7 <4";

View file

@ -3,12 +3,14 @@ const semver = require("semver");
export class SaleorVersionCompatibilityValidator {
constructor(private appRequiredVersion: string) {}
validateOrThrow(saleorVersion: string) {
const versionIsValid = semver.satisfies(saleorVersion, this.appRequiredVersion, {
isValid(saleorVersion: string) {
return semver.satisfies(saleorVersion, this.appRequiredVersion, {
includePrerelease: true,
});
}
if (!versionIsValid) {
validateOrThrow(saleorVersion: string) {
if (!this.isValid(saleorVersion)) {
throw new Error(
`Your Saleor version (${saleorVersion}) doesn't match App's required version (semver: ${this.appRequiredVersion})`
);