Add order refunded webhook (#751)

* Update the app sdk package

* Add order refunded webhook

* Add changeset
This commit is contained in:
Krzysztof Wolski 2023-07-13 17:50:00 +02:00 committed by GitHub
parent 3c6cd4ccec
commit 790a47ee08
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 1571 additions and 173 deletions

View file

@ -0,0 +1,5 @@
---
"saleor-app-emails-and-messages": patch
---
Added support for new event: order refunded.

File diff suppressed because it is too large Load diff

View file

@ -73,6 +73,6 @@
},
"private": true,
"saleor": {
"schemaVersion": "3.13"
"schemaVersion": "3.14"
}
}

View file

@ -1,4 +1,4 @@
import { vi, expect, describe, it } from "vitest";
import { expect, describe, it } from "vitest";
import { getEventFormStatus } from "./get-event-form-status";
import { PermissionEnum } from "../../generated/graphql";
@ -10,6 +10,7 @@ describe("getEventFormStatus", function () {
appPermissions: [PermissionEnum.ManageGiftCard],
featureFlags: {
giftCardSentEvent: true,
orderRefundedEvent: true,
},
})
).toEqual({
@ -22,6 +23,7 @@ describe("getEventFormStatus", function () {
appPermissions: [],
featureFlags: {
giftCardSentEvent: false,
orderRefundedEvent: true,
},
})
).toEqual({
@ -38,6 +40,7 @@ describe("getEventFormStatus", function () {
appPermissions: [],
featureFlags: {
giftCardSentEvent: true,
orderRefundedEvent: true,
},
})
).toEqual({
@ -54,6 +57,7 @@ describe("getEventFormStatus", function () {
appPermissions: [PermissionEnum.ManageGiftCard],
featureFlags: {
giftCardSentEvent: false,
orderRefundedEvent: true,
},
})
).toEqual({

View file

@ -17,24 +17,38 @@ export const getEventFormStatus = ({
isDisabled: boolean;
requiredSaleorVersion: string | undefined;
} => {
// Since GIFT_CARD_SENT is the only event with such validation, we can exit early
if (eventType !== "GIFT_CARD_SENT") {
return {
isDisabled: false,
missingPermission: undefined,
requiredSaleorVersion: undefined,
};
switch (eventType) {
case "ORDER_REFUNDED": {
const isUnsupported = !featureFlags?.orderRefundedEvent;
const hasPermission = (appPermissions || []).includes(PermissionEnum.ManageOrders);
const isDisabled = isUnsupported || !hasPermission;
return {
isDisabled,
missingPermission: hasPermission ? undefined : PermissionEnum.ManageOrders,
requiredSaleorVersion: isUnsupported ? ">=3.14" : undefined,
};
}
case "GIFT_CARD_SENT": {
const isUnsupported = !featureFlags?.giftCardSentEvent;
const hasPermission = (appPermissions || []).includes(PermissionEnum.ManageGiftCard);
const isDisabled = isUnsupported || !hasPermission;
return {
isDisabled,
missingPermission: hasPermission ? undefined : PermissionEnum.ManageGiftCard,
requiredSaleorVersion: isUnsupported ? ">=3.13" : undefined,
};
}
default:
return {
isDisabled: false,
missingPermission: undefined,
requiredSaleorVersion: undefined,
};
}
const isUnsupported = !featureFlags?.giftCardSentEvent;
const hasGiftCardPermission = (appPermissions || []).includes(PermissionEnum.ManageGiftCard);
const isDisabled = isUnsupported || !hasGiftCardPermission;
return {
isDisabled,
missingPermission: hasGiftCardPermission ? undefined : PermissionEnum.ManageGiftCard,
requiredSaleorVersion: isUnsupported ? ">=3.13" : undefined,
};
};

View file

@ -8,6 +8,7 @@ import {
OrderFullyPaidWebhookPayloadFragment,
InvoiceSentWebhookPayloadFragment,
GiftCardSentWebhookPayloadFragment,
OrderRefundedWebhookPayloadFragment,
} from "../../../generated/graphql";
import { NotifyEventPayload } from "../../pages/api/webhooks/notify";
@ -137,6 +138,10 @@ const orderFullyPaidPayload: OrderFullyPaidWebhookPayloadFragment = {
order: exampleOrderPayload,
};
const orderRefundedPayload: OrderRefundedWebhookPayloadFragment = {
order: exampleOrderPayload,
};
const invoiceSentPayload: InvoiceSentWebhookPayloadFragment = {
invoice: {
id: "SW52b2ljZToxMDE=",
@ -312,16 +317,17 @@ const giftCardSentPayload: GiftCardSentWebhookPayloadFragment = {
};
export const examplePayloads: Record<MessageEventTypes, any> = {
ORDER_CREATED: orderCreatedPayload,
ORDER_CONFIRMED: orderConfirmedPayload,
ACCOUNT_CHANGE_EMAIL_CONFIRM: accountChangeEmailConfirmPayload,
ACCOUNT_CHANGE_EMAIL_REQUEST: accountChangeEmailRequestPayload,
ACCOUNT_CONFIRMATION: accountConfirmationPayload,
ACCOUNT_DELETE: accountDeletePayload,
ACCOUNT_PASSWORD_RESET: accountPasswordResetPayload,
GIFT_CARD_SENT: giftCardSentPayload,
INVOICE_SENT: invoiceSentPayload,
ORDER_CANCELLED: orderCancelledPayload,
ORDER_CONFIRMED: orderConfirmedPayload,
ORDER_CREATED: orderCreatedPayload,
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,
ACCOUNT_CHANGE_EMAIL_CONFIRM: accountChangeEmailConfirmPayload,
ACCOUNT_DELETE: accountDeletePayload,
ORDER_REFUNDED: orderRefundedPayload,
};

View file

@ -1,31 +1,33 @@
export const messageEventTypes = [
"ACCOUNT_CHANGE_EMAIL_CONFIRM",
"ACCOUNT_CHANGE_EMAIL_REQUEST",
"ACCOUNT_CONFIRMATION",
"ACCOUNT_DELETE",
"ACCOUNT_PASSWORD_RESET",
"GIFT_CARD_SENT",
"INVOICE_SENT",
"ORDER_CANCELLED",
"ORDER_CONFIRMED",
"ORDER_CREATED",
"ORDER_FULFILLED",
"ORDER_CONFIRMED",
"ORDER_CANCELLED",
"ORDER_FULLY_PAID",
"INVOICE_SENT",
"ACCOUNT_CONFIRMATION",
"ACCOUNT_PASSWORD_RESET",
"ACCOUNT_CHANGE_EMAIL_REQUEST",
"ACCOUNT_CHANGE_EMAIL_CONFIRM",
"ACCOUNT_DELETE",
"GIFT_CARD_SENT",
"ORDER_REFUNDED",
] as const;
export type MessageEventTypes = (typeof messageEventTypes)[number];
export const messageEventTypesLabels: Record<MessageEventTypes, string> = {
ACCOUNT_CHANGE_EMAIL_CONFIRM: "Customer account change email confirmation",
ACCOUNT_CHANGE_EMAIL_REQUEST: "Customer account change email request",
ACCOUNT_CONFIRMATION: "Customer account confirmation",
ACCOUNT_DELETE: "Customer account delete request",
ACCOUNT_PASSWORD_RESET: "Customer account password reset request",
GIFT_CARD_SENT: "Gift card sent",
INVOICE_SENT: "Invoice sent",
ORDER_CANCELLED: "Order cancelled",
ORDER_CONFIRMED: "Order confirmed",
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",
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",
ACCOUNT_CHANGE_EMAIL_CONFIRM: "Customer account change email confirmation",
ACCOUNT_DELETE: "Customer account delete request",
ORDER_REFUNDED: "Order refunded",
};

View file

@ -1,6 +1,6 @@
import { SaleorVersionCompatibilityValidator } from "@saleor/apps-shared";
export const featureFlags = ["giftCardSentEvent"] as const;
export const featureFlags = ["giftCardSentEvent", "orderRefundedEvent"] as const;
export type FeatureFlag = (typeof featureFlags)[number];
@ -17,5 +17,6 @@ interface GetFeatureFlagsArgs {
export const getFeatureFlags = ({ saleorVersion }: GetFeatureFlagsArgs): FeatureFlagsState => {
return {
giftCardSentEvent: new SaleorVersionCompatibilityValidator(">=3.13").isValid(saleorVersion),
orderRefundedEvent: new SaleorVersionCompatibilityValidator(">=3.14").isValid(saleorVersion),
};
};

View file

@ -138,6 +138,23 @@ const defaultOrderFullyPaidMjmlTemplate = `<mjml>
</mj-body>
</mjml>`;
const defaultOrderRefundedMjmlTemplate = `<mjml>
<mj-body>
<mj-section>
<mj-column>
<mj-text font-size="16px">
Hello!
</mj-text>
<mj-text>
Order {{ order.number}} has been refunded.
</mj-text>
</mj-column>
</mj-section>
${addressSection}
${orderLinesSection}
</mj-body>
</mjml>`;
const defaultOrderCancelledMjmlTemplate = `<mjml>
<mj-body>
<mj-section>
@ -286,6 +303,7 @@ export const defaultMjmlTemplates: Record<MessageEventTypes, string> = {
ORDER_CREATED: defaultOrderCreatedMjmlTemplate,
ORDER_FULFILLED: defaultOrderFulfilledMjmlTemplate,
ORDER_FULLY_PAID: defaultOrderFullyPaidMjmlTemplate,
ORDER_REFUNDED: defaultOrderRefundedMjmlTemplate,
};
export const defaultMjmlSubjectTemplates: Record<MessageEventTypes, string> = {
@ -301,4 +319,5 @@ export const defaultMjmlSubjectTemplates: Record<MessageEventTypes, string> = {
ORDER_CREATED: "Order {{ order.number }} has been created",
ORDER_FULFILLED: "Order {{ order.number }} has been fulfilled",
ORDER_FULLY_PAID: "Order {{ order.number }} has been fully paid",
ORDER_REFUNDED: "Order {{ order.number }} has been refunded",
};

View file

@ -2,6 +2,7 @@ import { expect, describe, it } from "vitest";
import { SmtpConfiguration } from "../smtp/configuration/smtp-config-schema";
import { getWebhookStatusesFromConfigurations } from "./get-webhook-statuses-from-configurations";
import { SendgridConfiguration } from "../sendgrid/configuration/sendgrid-config-schema";
import { webhookStatusesFactory } from "./webhook-status-dict";
export const nonActiveSmtpConfiguration: SmtpConfiguration = {
id: "1685343953413npk9p",
@ -169,16 +170,7 @@ describe("getWebhookStatusesFromConfigurations", function () {
smtpConfigurations: [],
sendgridConfigurations: [],
})
).toStrictEqual({
invoiceSentWebhook: false,
notifyWebhook: false,
orderCancelledWebhook: false,
orderConfirmedWebhook: false,
orderFulfilledWebhook: false,
orderCreatedWebhook: false,
orderFullyPaidWebhook: false,
giftCardSentWebhook: false,
});
).toStrictEqual(webhookStatusesFactory({}));
});
it("Statuses should be set to false, when no active configurations passed", async () => {
@ -187,16 +179,7 @@ describe("getWebhookStatusesFromConfigurations", function () {
smtpConfigurations: [nonActiveSmtpConfiguration],
sendgridConfigurations: [nonActiveSendgridConfiguration],
})
).toStrictEqual({
invoiceSentWebhook: false,
notifyWebhook: false,
orderCancelledWebhook: false,
orderConfirmedWebhook: false,
orderFulfilledWebhook: false,
orderCreatedWebhook: false,
orderFullyPaidWebhook: false,
giftCardSentWebhook: false,
});
).toStrictEqual(webhookStatusesFactory({}));
});
it("Statuses should be set to false, when configuration is not active even if events were activated", async () => {
@ -210,16 +193,7 @@ describe("getWebhookStatusesFromConfigurations", function () {
smtpConfigurations: [smtpConfiguration],
sendgridConfigurations: [nonActiveSendgridConfiguration],
})
).toStrictEqual({
invoiceSentWebhook: false,
notifyWebhook: false,
orderCancelledWebhook: false,
orderConfirmedWebhook: false,
orderFulfilledWebhook: false,
orderCreatedWebhook: false,
orderFullyPaidWebhook: false,
giftCardSentWebhook: false,
});
).toStrictEqual(webhookStatusesFactory({}));
});
it("Status of the event should be set to true, when at least one active configuration has activated it", async () => {
@ -241,16 +215,7 @@ describe("getWebhookStatusesFromConfigurations", function () {
smtpConfigurations: [nonActiveSmtpConfiguration, smtpConfiguration],
sendgridConfigurations: [nonActiveSendgridConfiguration],
})
).toStrictEqual({
invoiceSentWebhook: true,
notifyWebhook: false,
orderCancelledWebhook: false,
orderConfirmedWebhook: false,
orderFulfilledWebhook: false,
orderCreatedWebhook: false,
orderFullyPaidWebhook: false,
giftCardSentWebhook: false,
});
).toStrictEqual(webhookStatusesFactory({ enabledWebhooks: ["invoiceSentWebhook"] }));
});
it("Status of the NOTIFY webhooks should be set to true, when at least one active configuration has activated one of its related events", async () => {
@ -278,15 +243,6 @@ describe("getWebhookStatusesFromConfigurations", function () {
smtpConfigurations: [nonActiveSmtpConfiguration, smtpConfiguration],
sendgridConfigurations: [nonActiveSendgridConfiguration],
})
).toStrictEqual({
invoiceSentWebhook: false,
notifyWebhook: true,
orderCancelledWebhook: false,
orderConfirmedWebhook: false,
orderFulfilledWebhook: false,
orderCreatedWebhook: false,
orderFullyPaidWebhook: false,
giftCardSentWebhook: false,
});
).toStrictEqual(webhookStatusesFactory({ enabledWebhooks: ["notifyWebhook"] }));
});
});

View file

@ -1,6 +1,7 @@
import { SendgridConfiguration } from "../sendgrid/configuration/sendgrid-config-schema";
import { SmtpConfiguration } from "../smtp/configuration/smtp-config-schema";
import { AppWebhook, eventToWebhookMapping } from "./webhook-management-service";
import { webhookStatusesFactory } from "./webhook-status-dict";
/*
* Returns dictionary of webhook statuses based on passed configurations.
@ -14,16 +15,7 @@ export const getWebhookStatusesFromConfigurations = ({
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,
orderCreatedWebhook: false,
orderFulfilledWebhook: false,
orderFullyPaidWebhook: false,
};
const statuses: Record<AppWebhook, boolean> = webhookStatusesFactory({});
smtpConfigurations.forEach(async (config) => {
if (!config.active) {

View file

@ -6,7 +6,7 @@ import { WebhookEventTypeAsyncEnum } from "../../../generated/graphql";
import { invoiceSentWebhook } from "../../pages/api/webhooks/invoice-sent";
import { orderCancelledWebhook } from "../../pages/api/webhooks/order-cancelled";
import { FeatureFlagService } from "../feature-flag-service/feature-flag-service";
import { giftCardSentWebhook } from "../../pages/api/webhooks/gift-card-sent";
import { webhookStatusesFactory } from "./webhook-status-dict";
describe("WebhookManagementService", function () {
const mockedClient = {} as Client;
@ -76,16 +76,9 @@ describe("WebhookManagementService", function () {
const statuses = await webhookManagementService.getWebhooksStatus();
expect(statuses).toStrictEqual({
invoiceSentWebhook: true,
notifyWebhook: false,
orderCancelledWebhook: false,
orderConfirmedWebhook: false,
orderCreatedWebhook: false,
orderFulfilledWebhook: false,
orderFullyPaidWebhook: false,
giftCardSentWebhook: false,
});
expect(statuses).toStrictEqual(
webhookStatusesFactory({ enabledWebhooks: ["invoiceSentWebhook"] })
);
expect(fetchAppWebhooksMock).toBeCalledTimes(1);
});

View file

@ -12,7 +12,7 @@ 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";
import { orderRefundedWebhook } from "../../pages/api/webhooks/order-refunded";
export const AppWebhooks = {
giftCardSentWebhook,
@ -23,6 +23,7 @@ export const AppWebhooks = {
orderCreatedWebhook,
orderFulfilledWebhook,
orderFullyPaidWebhook,
orderRefundedWebhook,
};
export type AppWebhook = keyof typeof AppWebhooks;
@ -40,6 +41,7 @@ export const eventToWebhookMapping: Record<MessageEventTypes, AppWebhook> = {
ORDER_CREATED: "orderCreatedWebhook",
ORDER_FULFILLED: "orderFulfilledWebhook",
ORDER_FULLY_PAID: "orderFullyPaidWebhook",
ORDER_REFUNDED: "orderRefundedWebhook",
};
const logger = createLogger({

View file

@ -0,0 +1,20 @@
import { AppWebhook, AppWebhooks } from "./webhook-management-service";
export type WebhookStatuses = Record<AppWebhook, boolean>;
export const webhookStatusesFactory = ({
enabledWebhooks,
}: {
enabledWebhooks?: AppWebhook[];
}): WebhookStatuses => ({
// TODO: This function clearly deserves a better implementation.
giftCardSentWebhook: !!enabledWebhooks?.includes("giftCardSentWebhook"),
invoiceSentWebhook: !!enabledWebhooks?.includes("invoiceSentWebhook"),
notifyWebhook: !!enabledWebhooks?.includes("notifyWebhook"),
orderCancelledWebhook: !!enabledWebhooks?.includes("orderCancelledWebhook"),
orderConfirmedWebhook: !!enabledWebhooks?.includes("orderConfirmedWebhook"),
orderCreatedWebhook: !!enabledWebhooks?.includes("orderCreatedWebhook"),
orderFulfilledWebhook: !!enabledWebhooks?.includes("orderFulfilledWebhook"),
orderFullyPaidWebhook: !!enabledWebhooks?.includes("orderFullyPaidWebhook"),
orderRefundedWebhook: !!enabledWebhooks?.includes("orderRefundedWebhook"),
});

View file

@ -0,0 +1,87 @@
import { OrderDetailsFragmentDoc } from "../../../../generated/graphql";
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 { OrderRefundedWebhookPayloadFragment } from "../../../../generated/graphql";
import { sendEventMessages } from "../../../modules/event-handlers/send-event-messages";
const OrderRefundedWebhookPayload = gql`
${OrderDetailsFragmentDoc}
fragment OrderRefundedWebhookPayload on OrderRefunded {
order {
...OrderDetails
}
}
`;
const OrderRefundedGraphqlSubscription = gql`
${OrderRefundedWebhookPayload}
subscription OrderRefunded {
event {
...OrderRefundedWebhookPayload
}
}
`;
export const orderRefundedWebhook = new SaleorAsyncWebhook<OrderRefundedWebhookPayloadFragment>({
name: "Order Refunded in Saleor",
webhookPath: "api/webhooks/order-refunded",
asyncEvent: "ORDER_REFUNDED",
apl: saleorApp.apl,
subscriptionQueryAst: OrderRefundedGraphqlSubscription,
});
const logger = createLogger({
name: orderRefundedWebhook.webhookPath,
});
const handler: NextWebhookApiHandler<OrderRefundedWebhookPayloadFragment> = async (
req,
res,
context
) => {
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 = createGraphQLClient({
saleorApiUrl: authData.saleorApiUrl,
token: authData.token,
});
await sendEventMessages({
authData,
channel,
client,
event: "ORDER_REFUNDED",
payload: { order: payload.order },
recipientEmail,
});
return res.status(200).json({ message: "The event has been handled" });
};
export default orderRefundedWebhook.createHandler(handler);
export const config = {
api: {
bodyParser: false,
},
};