diff --git a/.env.example b/.env.example index f6b550f..4e81288 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -# Saleor domain when you want to install the app. It will be used to verify JWT tokens. +# Saleor domain where do you want to install the app. It will be used to verify JWT tokens. SALEOR_URL=https://your.eu.saleor.cloud # Where your email assets are stored STATIC_URL=https://your.cdn/ @@ -8,7 +8,7 @@ STOREFRONT_URL=https://your.storefront.com # Email from which email will be sent FROM_EMAIL=hello@mirumee.com # Name from which email will be sent -FROM_NAME= +FROM_NAME=Mirumee # AWS SQS queue url SQS_QUEUE_URL=http://host.docker.internal:4566/000000000000/nimara-mailer-queue @@ -21,8 +21,8 @@ AWS_REGION=ap-southeast-1 AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= -# One of: NODE_MAILER, AWS_SES -EMAIL_PROVIDER=AWS_SES +# One of: AWS_SES, NODE_MAILER +EMAIL_PROVIDER=NODE_MAILER # Localstack only. When using AWS please comment this out FROM_DOMAIN=mirumee.com diff --git a/.env.test b/.env.test index bbdc6f3..383b292 100644 --- a/.env.test +++ b/.env.test @@ -9,3 +9,4 @@ AWS_SESSION_TOKEN=mock SECRET_MANAGER_APP_CONFIG_PATH=mock SQS_QUEUE_URL=https://sqs.queue.url STATIC_URL=https://static.url +AUTHORIZATION_TOKEN= diff --git a/.gitignore b/.gitignore index 5460b6a..959318c 100644 --- a/.gitignore +++ b/.gitignore @@ -11,8 +11,7 @@ coverage # out files .next/ out/ -build/* -package* +build/ dist/ vite.config.js.timestamp* *.zip diff --git a/package.json b/package.json index 6772ea5..6c5efc7 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "scripts": { "dev": "concurrently -k -p \"[{name}]\" -n \"TSC,BUILD,RECEIVER,SENDER\" -c \"blue.bold,red.bold,cyan.bold,green.bold\" \"pnpm tsc:watch\" \"pnpm dev:build\" \"pnpm dev:run:events-receiver\" \"pnpm dev:run:emails-sender-proxy\"", "dev:build": "dotenv -v NODE_ENV=development -- node ./etc/scripts/dev.mjs", - "dev:emails": "dotenv -- email dev -d ./src/emails -p 3002", + "dev:emails": "dotenv -- email dev -d ./src/emails/templates -p 3002", "dev:run:events-receiver": "dotenv -v NODE_ENV=development -- nodemon --inspect=0.0.0.0:9229 --watch build/events-receiver.js build/events-receiver.js", "dev:run:emails-sender-proxy": "dotenv -v NODE_ENV=development -- nodemon --inspect=0.0.0.0:9230 --watch build/emails-sender-proxy.js build/emails-sender-proxy.js", "build": "dotenv -v NODE_ENV=production node ./etc/scripts/build.mjs", diff --git a/src/api/rest/routes.test.ts b/src/api/rest/routes.test.ts index d9dab2c..f201e47 100644 --- a/src/api/rest/routes.test.ts +++ b/src/api/rest/routes.test.ts @@ -1,11 +1,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { CONFIG } from "@/config"; +import { EMAIL_EVENTS } from "@/const"; import * as validate from "@/lib/graphql/validate"; import * as auth from "@/lib/saleor/auth"; import { createServer } from "@/server"; -import { EVENT_HANDLERS } from "./saleor/webhooks"; - describe("apiRoutes", () => { describe("/api/healthcheck", () => { it("200", async () => { @@ -56,6 +56,36 @@ describe("apiRoutes", () => { expect(response.statusCode).toStrictEqual(expectedStatusCode); }); + it("Should return 401 with invalid config authorization token.", async () => { + // given + const expectedStatusCode = 401; + const expectedJson = { + error: "UNAUTHORIZED", + code: "UNAUTHORIZED_ERROR", + requestId: expect.any(String), + errors: [{ message: "Invalid authorization token." }], + }; + const event = EMAIL_EVENTS[0]; + + vi.spyOn(CONFIG, "AUTHORIZATION_TOKEN", "get").mockReturnValue( + "mocked-token" + ); + + // when + const response = await app.inject({ + method: "POST", + url, + headers: { + Authorization: "Bearer wrong", + }, + body: { data: {}, event }, + }); + + // then + expect(response.json()).toStrictEqual(expectedJson); + expect(response.statusCode).toStrictEqual(expectedStatusCode); + }); + it("Should return 400 passed when event is not supported.", async () => { // given const expectedStatusCode = 400; @@ -92,9 +122,9 @@ describe("apiRoutes", () => { const validateSpy = vi.spyOn(validate, "validateDocumentAgainstData"); const expectedJson = { status: "ok" }; const expectedStatusCode = 200; + const event = EMAIL_EVENTS[0]; // when - const event = EVENT_HANDLERS[0].event.toLowerCase(); jwtVerifySpy.mockImplementation(async () => undefined); validateSpy.mockImplementation(() => ({ isValid: true, error: null })); diff --git a/src/api/rest/routes.ts b/src/api/rest/routes.ts index a185061..b334cc6 100644 --- a/src/api/rest/routes.ts +++ b/src/api/rest/routes.ts @@ -4,6 +4,12 @@ import { type ZodTypeProvider } from "fastify-type-provider-zod"; import { z } from "zod"; import { CONFIG } from "@/config"; +import { + CUSTOM_EVENTS_SCHEMA, + type CustomEventType, + EMAIL_EVENTS, +} from "@/const"; +import { UnauthorizedError } from "@/lib/errors/api"; import { validateDocumentAgainstData } from "@/lib/graphql/validate"; import { serializePayload } from "@/lib/payload"; import { verifyJWTSignature } from "@/lib/saleor/auth"; @@ -11,7 +17,7 @@ import { saleorBearerHeader } from "@/lib/saleor/schema"; import { getJWKSProvider } from "@/providers/jwks"; import { saleorRoutes } from "./saleor"; -import { EVENT_HANDLERS } from "./saleor/webhooks"; +import { SALEOR_EVENTS_MAP } from "./saleor/webhooks"; export const restRoutes: FastifyPluginAsync = async (fastify) => { await fastify.register(saleorRoutes, { prefix: "/saleor" }); @@ -26,40 +32,55 @@ export const restRoutes: FastifyPluginAsync = async (fastify) => { headers: saleorBearerHeader, body: z.object({ data: z.any(), - event: z.enum( - EVENT_HANDLERS.map(({ event }) => event.toLowerCase()) as any - ), + event: z.enum(EMAIL_EVENTS), }), }, }, async (request, response) => { - await verifyJWTSignature({ - jwksProvider: getJWKSProvider(), - jwt: request.headers.authorization, - }); + if (CONFIG.AUTHORIZATION_TOKEN) { + if (CONFIG.AUTHORIZATION_TOKEN !== request.headers.authorization) { + throw new UnauthorizedError({ + message: "Invalid authorization token.", + }); + } + } else { + await verifyJWTSignature({ + jwksProvider: getJWKSProvider(), + jwt: request.headers.authorization, + }); + } - const document = EVENT_HANDLERS.find( + const saleorEvent = SALEOR_EVENTS_MAP.find( ({ event }) => event.toLowerCase() === request.body.event - )!.query; + ); + let data: unknown; - const { isValid, error } = validateDocumentAgainstData({ - data: request.body.data, - document, - }); + if (saleorEvent) { + const { isValid, error } = validateDocumentAgainstData({ + data: request.body.data, + document: saleorEvent.query, + }); + data = request.body.data; + + if (!isValid) { + throw new z.ZodError([ + { + path: ["body > data"], + message: error ?? "", + code: "custom", + }, + ]); + } + } else { + const schema = + CUSTOM_EVENTS_SCHEMA[request.body.event as CustomEventType]; - if (!isValid) { - throw new z.ZodError([ - { - path: ["body > data"], - message: error ?? "", - code: "custom", - }, - ]); + data = schema.parse(request.body.data, { path: ["body > data"] }); } const payload = serializePayload({ - data: request.body.data, + data, event: request.body.event, }); diff --git a/src/api/rest/saleor/index.ts b/src/api/rest/saleor/index.ts index dd5ba8e..a59e77a 100644 --- a/src/api/rest/saleor/index.ts +++ b/src/api/rest/saleor/index.ts @@ -2,20 +2,24 @@ import { type FastifyRequest } from "fastify"; import type { FastifyPluginAsync } from "fastify/types/plugin"; import { CONFIG } from "@/config"; -import { type AppManifestWebhook } from "@/graphql/schema"; +import { + type AppManifestWebhook, + type WebhookEventTypeAsyncEnum, +} from "@/graphql/schema"; import { saleorAppRouter } from "@/lib/saleor/apps/router"; import { getConfigProvider } from "@/providers/config"; import { getJWKSProvider } from "@/providers/jwks"; import { getSaleorClient } from "@/providers/saleorClient"; import { saleorRestRoutes } from "./saleor"; -import { EVENT_HANDLERS, saleorWebhooksRoutes } from "./webhooks"; +import { SALEOR_EVENTS_MAP, saleorWebhooksRoutes } from "./webhooks"; const getManifestWebhooks = (request: FastifyRequest): AppManifestWebhook[] => - EVENT_HANDLERS.map(({ event, query }) => { - const name = event.toLocaleLowerCase().replaceAll("_", "-"); + SALEOR_EVENTS_MAP.map(({ event, query }) => { + const name = event.replaceAll("_", "-"); + return { - asyncEvents: [event], + asyncEvents: [event.toUpperCase() as WebhookEventTypeAsyncEnum], name, query, syncEvents: [], diff --git a/src/api/rest/saleor/webhooks.test.ts b/src/api/rest/saleor/webhooks.test.ts index 4b3acc4..b100ecf 100644 --- a/src/api/rest/saleor/webhooks.test.ts +++ b/src/api/rest/saleor/webhooks.test.ts @@ -6,7 +6,7 @@ import { serializePayload } from "@/lib/payload"; import * as auth from "@/lib/saleor/auth"; import { createServer } from "@/server"; -import { EVENT_HANDLERS } from "./webhooks"; +import { SALEOR_EVENTS_MAP } from "./webhooks"; describe("saleorWebhooksRoutes", () => { const sendSpy = vi.fn(); @@ -21,7 +21,7 @@ describe("saleorWebhooksRoutes", () => { }); describe("/api/saleor/webhooks/email/*", () => { - it.each(EVENT_HANDLERS)( + it.each(SALEOR_EVENTS_MAP)( "should register route for $event event", ({ event }) => { // Given diff --git a/src/api/rest/saleor/webhooks.ts b/src/api/rest/saleor/webhooks.ts index be14e09..53a4405 100644 --- a/src/api/rest/saleor/webhooks.ts +++ b/src/api/rest/saleor/webhooks.ts @@ -4,6 +4,7 @@ import rawBody from "fastify-raw-body"; import type { ZodTypeProvider } from "fastify-type-provider-zod"; import { CONFIG } from "@/config"; +import { type SaleorEventType } from "@/const"; import { AccountChangeEmailRequestedSubscriptionDocument, AccountConfirmationRequestedSubscriptionDocument, @@ -19,66 +20,65 @@ import { OrderCreatedSubscriptionDocument, OrderRefundedSubscriptionDocument, } from "@/graphql/operations/subscriptions/generated"; -import { type WebhookEventTypeAsyncEnum } from "@/graphql/schema"; import { serializePayload } from "@/lib/payload"; import { verifyWebhookSignature } from "@/lib/saleor/auth"; import { saleorWebhookHeaders } from "@/lib/saleor/schema"; import { getJWKSProvider } from "@/providers/jwks"; -export const EVENT_HANDLERS: { - event: WebhookEventTypeAsyncEnum; +export const SALEOR_EVENTS_MAP: { + event: SaleorEventType; query: string; }[] = [ { - event: "ACCOUNT_CONFIRMATION_REQUESTED", + event: "account_confirmation_requested", query: AccountConfirmationRequestedSubscriptionDocument.toString(), }, { - event: "ACCOUNT_CONFIRMED", + event: "account_confirmed", query: AccountConfirmedSubscriptionDocument.toString(), }, { - event: "ACCOUNT_SET_PASSWORD_REQUESTED", + event: "account_set_password_requested", query: AccountSetPasswordRequestedSubscriptionDocument.toString(), }, { - event: "ACCOUNT_DELETE_REQUESTED", + event: "account_delete_requested", query: AccountDeleteRequestedSubscriptionDocument.toString(), }, { - event: "ACCOUNT_DELETED", + event: "account_deleted", query: AccountDeletedSubscriptionDocument.toString(), }, { - event: "ACCOUNT_CHANGE_EMAIL_REQUESTED", + event: "account_change_email_requested", query: AccountChangeEmailRequestedSubscriptionDocument.toString(), }, { - event: "ACCOUNT_EMAIL_CHANGED", + event: "account_email_changed", query: AccountEmailChangedSubscriptionDocument.toString(), }, { - event: "ORDER_CREATED", + event: "order_created", query: OrderCreatedSubscriptionDocument.toString(), }, { - event: "ORDER_CANCELLED", + event: "order_cancelled", query: OrderCancelledSubscriptionDocument.toString(), }, { - event: "ORDER_REFUNDED", + event: "order_refunded", query: OrderRefundedSubscriptionDocument.toString(), }, { - event: "FULFILLMENT_TRACKING_NUMBER_UPDATED", + event: "fulfillment_tracking_number_updated", query: FulfillmentTrackingNumberUpdatedSubscriptionDocument.toString(), }, { - event: "FULFILLMENT_CREATED", + event: "fulfillment_created", query: FulfillmentCreatedSubscriptionDocument.toString(), }, { - event: "GIFT_CARD_SENT", + event: "gift_card_sent", query: GiftCardSentSubscriptionDocument.toString(), }, ]; @@ -86,8 +86,8 @@ export const EVENT_HANDLERS: { export const saleorWebhooksRoutes: FastifyPluginAsync = async (fastify) => { await fastify.register(rawBody); - EVENT_HANDLERS.forEach(({ event }) => { - const name = event.toLowerCase().replaceAll("_", "-"); + SALEOR_EVENTS_MAP.forEach(({ event }) => { + const name = event.replaceAll("_", "-"); fastify.withTypeProvider().post( `/email/${name}`, diff --git a/src/config.ts b/src/config.ts index 5c3f6bc..5eb01e1 100644 --- a/src/config.ts +++ b/src/config.ts @@ -30,6 +30,8 @@ export const configSchema = z EMAIL_PROVIDER: z.enum(["NODE_MAILER", "AWS_SES"]).default("AWS_SES"), BASE_PATH: z.string().default("").optional(), WHITELISTED_DOMAINS: z.array(z.string()).optional(), + // Used in `send-notification` endpoint for authentication if you do not want to use Saleor JWT verification. + AUTHORIZATION_TOKEN: z.string().optional(), }) .and(commonConfigSchema) .and(appConfigSchema) diff --git a/src/const.ts b/src/const.ts new file mode 100644 index 0000000..1c0782a --- /dev/null +++ b/src/const.ts @@ -0,0 +1,46 @@ +import { z } from "zod"; + +import { type WebhookEventTypeAsyncEnum } from "./graphql/schema"; + +/** + * Define your Saleor supported events. + * Note: + * Saleor uses uppercase events names only in app manifest. In other places, + * like headers they are lower case so we are using that convention. + */ +export const SALEOR_EVENTS = [ + "account_confirmation_requested", + "account_confirmed", + "account_set_password_requested", + "account_delete_requested", + "account_deleted", + "account_change_email_requested", + "account_email_changed", + "order_created", + "order_cancelled", + "order_refunded", + "fulfillment_tracking_number_updated", + "fulfillment_created", + "gift_card_sent", +] as const satisfies Lowercase[]; +/** + * Define your custom events. + */ +export const CUSTOM_EVENTS = ["custom_event"] as const; + +export const EMAIL_EVENTS = [...SALEOR_EVENTS, ...CUSTOM_EVENTS] as const; + +export type SaleorEventType = (typeof SALEOR_EVENTS)[number]; +export type CustomEventType = (typeof CUSTOM_EVENTS)[number]; +export type EmailEventType = SaleorEventType | CustomEventType; + +/** + * Define your custom events body schema. + */ +export const CUSTOM_EVENTS_SCHEMA = { + custom_event: z.object({ + name: z.string(), + email: z.string(), + channel: z.string(), + }), +} satisfies Record; diff --git a/src/emails/templates/custom/CustomEventEmail.tsx b/src/emails/templates/custom/CustomEventEmail.tsx new file mode 100644 index 0000000..4752aad --- /dev/null +++ b/src/emails/templates/custom/CustomEventEmail.tsx @@ -0,0 +1,39 @@ +import { type z } from "zod"; + +import { type CUSTOM_EVENTS_SCHEMA } from "@/const"; +import Header from "@/emails/components/Header"; +import Layout from "@/emails/components/Layout"; +import Text from "@/emails/components/Text"; +import { type CustomEventData } from "@/lib/types"; + +type CustomEventEmailProps = CustomEventData< + z.infer<(typeof CUSTOM_EVENTS_SCHEMA)["custom_event"]> +>; + +const CustomEventEmail = ({ + data: { channel, email, name }, +}: CustomEventEmailProps) => { + return ( + + {() => ( + <> +
Hi {name}!
+ This is a custom email event sent by the Mirumee team. + + )} +
+ ); +}; + +const previewProps: CustomEventEmailProps = { + data: { + name: "Name", + email: "user@example.com", + channel: "channel-us", + }, +}; + +CustomEventEmail.PreviewProps = previewProps; +CustomEventEmail.Subject = "Custom event"; + +export default CustomEventEmail; diff --git a/src/emails/templates/AccountChangeEmailRequestedEmail.tsx b/src/emails/templates/saleor/AccountChangeEmailRequestedEmail.tsx similarity index 87% rename from src/emails/templates/AccountChangeEmailRequestedEmail.tsx rename to src/emails/templates/saleor/AccountChangeEmailRequestedEmail.tsx index 05ba403..7d44369 100644 --- a/src/emails/templates/AccountChangeEmailRequestedEmail.tsx +++ b/src/emails/templates/saleor/AccountChangeEmailRequestedEmail.tsx @@ -5,9 +5,12 @@ import Text from "@/emails/components/Text"; import { type AccountChangeEmailRequestedSubscription } from "@/graphql/operations/subscriptions/generated"; import { type EventData } from "@/lib/types"; +type AccountChangeEmailRequestedEmailProps = + EventData; + const AccountChangeEmailRequestedEmail = ({ data, -}: EventData) => { +}: AccountChangeEmailRequestedEmailProps) => { return ( = { +const previewProps: AccountChangeEmailRequestedEmailProps = { data: { redirectUrl: "https://example.com", user: { diff --git a/src/emails/templates/AccountConfirmationRequestedEmail.tsx b/src/emails/templates/saleor/AccountConfirmationRequestedEmail.tsx similarity index 86% rename from src/emails/templates/AccountConfirmationRequestedEmail.tsx rename to src/emails/templates/saleor/AccountConfirmationRequestedEmail.tsx index 206bfc7..58a0ebd 100644 --- a/src/emails/templates/AccountConfirmationRequestedEmail.tsx +++ b/src/emails/templates/saleor/AccountConfirmationRequestedEmail.tsx @@ -5,9 +5,12 @@ import Text from "@/emails/components/Text"; import { type AccountConfirmationRequestedSubscription } from "@/graphql/operations/subscriptions/generated"; import { type EventData } from "@/lib/types"; +type AccountConfirmationRequestedEmailProps = + EventData; + const AccountConfirmationRequestedEmail = ({ data, -}: EventData) => { +}: AccountConfirmationRequestedEmailProps) => { return ( = { +const previewProps: AccountConfirmationRequestedEmailProps = { data: { redirectUrl: "https://example.com", user: { diff --git a/src/emails/templates/AccountConfirmedEmail.tsx b/src/emails/templates/saleor/AccountConfirmedEmail.tsx similarity index 74% rename from src/emails/templates/AccountConfirmedEmail.tsx rename to src/emails/templates/saleor/AccountConfirmedEmail.tsx index d0f36a9..f8a6f48 100644 --- a/src/emails/templates/AccountConfirmedEmail.tsx +++ b/src/emails/templates/saleor/AccountConfirmedEmail.tsx @@ -4,11 +4,11 @@ import Text from "@/emails/components/Text"; import { type AccountConfirmedSubscription } from "@/graphql/operations/subscriptions/generated"; import { type EventData } from "@/lib/types"; -const AccountConfirmedEmail = ({ - data, -}: EventData) => { +type AccountConfirmedEmailProps = EventData; + +const AccountConfirmedEmail = ({ data }: AccountConfirmedEmailProps) => { return ( - + {() => ( <>
Hi {data.user?.firstName}!
@@ -24,7 +24,7 @@ const AccountConfirmedEmail = ({ ); }; -const previewProps: EventData = { +const previewProps: AccountConfirmedEmailProps = { data: { user: { email: "user@example.com", @@ -37,6 +37,6 @@ const previewProps: EventData = { }; AccountConfirmedEmail.PreviewProps = previewProps; -AccountConfirmedEmail.Subject = "Account Confirmed"; +AccountConfirmedEmail.Subject = "Account confirmed"; export default AccountConfirmedEmail; diff --git a/src/emails/templates/AccountDeleteRequestedEmail.tsx b/src/emails/templates/saleor/AccountDeleteRequestedEmail.tsx similarity index 86% rename from src/emails/templates/AccountDeleteRequestedEmail.tsx rename to src/emails/templates/saleor/AccountDeleteRequestedEmail.tsx index 4bb1db9..693ecf1 100644 --- a/src/emails/templates/AccountDeleteRequestedEmail.tsx +++ b/src/emails/templates/saleor/AccountDeleteRequestedEmail.tsx @@ -5,9 +5,12 @@ import Text from "@/emails/components/Text"; import { type AccountDeleteRequestedSubscription } from "@/graphql/operations/subscriptions/generated"; import { type EventData } from "@/lib/types"; +type AccountDeleteRequestedEmailProps = + EventData; + const AccountDeleteRequestedEmail = ({ data, -}: EventData) => { +}: AccountDeleteRequestedEmailProps) => { return ( {() => ( @@ -29,7 +32,7 @@ const AccountDeleteRequestedEmail = ({ ); }; -const previewProps: EventData = { +const previewProps: AccountDeleteRequestedEmailProps = { data: { redirectUrl: "https://example.com", user: { diff --git a/src/emails/templates/AccountDeletedEmail.tsx b/src/emails/templates/saleor/AccountDeletedEmail.tsx similarity index 86% rename from src/emails/templates/AccountDeletedEmail.tsx rename to src/emails/templates/saleor/AccountDeletedEmail.tsx index 912ca95..5caddf8 100644 --- a/src/emails/templates/AccountDeletedEmail.tsx +++ b/src/emails/templates/saleor/AccountDeletedEmail.tsx @@ -4,9 +4,9 @@ import Text from "@/emails/components/Text"; import { type AccountDeletedSubscription } from "@/graphql/operations/subscriptions/generated"; import { type EventData } from "@/lib/types"; -const AccountDeletedEmail = ({ - data, -}: EventData) => { +type AccountDeletedEmailProps = EventData; + +const AccountDeletedEmail = ({ data }: AccountDeletedEmailProps) => { return ( {() => ( @@ -27,7 +27,7 @@ const AccountDeletedEmail = ({ ); }; -const previewProps: EventData = { +const previewProps: AccountDeletedEmailProps = { data: { user: { email: "user@example.com", diff --git a/src/emails/templates/AccountEmailChangedEmail.tsx b/src/emails/templates/saleor/AccountEmailChangedEmail.tsx similarity index 80% rename from src/emails/templates/AccountEmailChangedEmail.tsx rename to src/emails/templates/saleor/AccountEmailChangedEmail.tsx index 7d10e4d..b981b3e 100644 --- a/src/emails/templates/AccountEmailChangedEmail.tsx +++ b/src/emails/templates/saleor/AccountEmailChangedEmail.tsx @@ -4,9 +4,9 @@ import Text from "@/emails/components/Text"; import { type AccountEmailChangedSubscription } from "@/graphql/operations/subscriptions/generated"; import { type EventData } from "@/lib/types"; -const AccountEmailChangedEmail = ({ - data, -}: EventData) => { +type AccountEmailChangedEmailProps = EventData; + +const AccountEmailChangedEmail = ({ data }: AccountEmailChangedEmailProps) => { return ( {() => ( @@ -19,7 +19,7 @@ const AccountEmailChangedEmail = ({ ); }; -const previewProps: EventData = { +const previewProps: AccountEmailChangedEmailProps = { data: { user: { email: "user@example.com", diff --git a/src/emails/templates/AccountSetPasswordRequestedEmail.tsx b/src/emails/templates/saleor/AccountSetPasswordRequestedEmail.tsx similarity index 83% rename from src/emails/templates/AccountSetPasswordRequestedEmail.tsx rename to src/emails/templates/saleor/AccountSetPasswordRequestedEmail.tsx index ced2028..6b407cb 100644 --- a/src/emails/templates/AccountSetPasswordRequestedEmail.tsx +++ b/src/emails/templates/saleor/AccountSetPasswordRequestedEmail.tsx @@ -1,14 +1,16 @@ +import Header from "@/emails/components/Header"; import Layout from "@/emails/components/Layout"; import Link from "@/emails/components/Link"; import Text from "@/emails/components/Text"; import { type AccountSetPasswordRequestedSubscription } from "@/graphql/operations/subscriptions/generated"; import { type EventData } from "@/lib/types"; -import Header from "../components/Header"; +type AccountSetPasswordRequestedEmailProps = + EventData; const AccountSetPasswordRequestedEmail = ({ data, -}: EventData) => { +}: AccountSetPasswordRequestedEmailProps) => { return ( = { +const previewProps: AccountSetPasswordRequestedEmailProps = { data: { redirectUrl: "https://example.com", user: { diff --git a/src/emails/templates/FulfillmentCreatedEmail.tsx b/src/emails/templates/saleor/FulfillmentCreatedEmail.tsx similarity index 94% rename from src/emails/templates/FulfillmentCreatedEmail.tsx rename to src/emails/templates/saleor/FulfillmentCreatedEmail.tsx index e3957d2..00105ee 100644 --- a/src/emails/templates/FulfillmentCreatedEmail.tsx +++ b/src/emails/templates/saleor/FulfillmentCreatedEmail.tsx @@ -10,9 +10,9 @@ import { type FulfillmentCreatedSubscription } from "@/graphql/operations/subscr import { orderLineToLine } from "@/lib/saleor/utils"; import { type EventData } from "@/lib/types"; -const FulfillmentCreatedEmail = ({ - data, -}: EventData) => { +type FulfillmentCreatedEmailProps = EventData; + +const FulfillmentCreatedEmail = ({ data }: FulfillmentCreatedEmailProps) => { const order = data!.order!; return ( @@ -67,7 +67,7 @@ const FulfillmentCreatedEmail = ({ ); }; -const previewProps: EventData = { +const previewProps: FulfillmentCreatedEmailProps = { data: { order: { number: "941", diff --git a/src/emails/templates/FulfillmentTrackingNumberUpdatedEmail.tsx b/src/emails/templates/saleor/FulfillmentTrackingNumberUpdatedEmail.tsx similarity index 95% rename from src/emails/templates/FulfillmentTrackingNumberUpdatedEmail.tsx rename to src/emails/templates/saleor/FulfillmentTrackingNumberUpdatedEmail.tsx index fb6ca7a..403eedf 100644 --- a/src/emails/templates/FulfillmentTrackingNumberUpdatedEmail.tsx +++ b/src/emails/templates/saleor/FulfillmentTrackingNumberUpdatedEmail.tsx @@ -12,9 +12,12 @@ import { orderLineToLine } from "@/lib/saleor/utils"; import { type EventData } from "@/lib/types"; import { isURL } from "@/lib/utils"; +type FulfillmentTrackingNumberUpdatedEmailProps = + EventData; + const FulfillmentTrackingNumberUpdatedEmail = ({ data, -}: EventData) => { +}: FulfillmentTrackingNumberUpdatedEmailProps) => { const order = data!.order!; return ( @@ -81,7 +84,7 @@ const FulfillmentTrackingNumberUpdatedEmail = ({ ); }; -const previewProps: EventData = { +const previewProps: FulfillmentTrackingNumberUpdatedEmailProps = { data: { order: { number: "941", diff --git a/src/emails/templates/GiftCardSentEmail.tsx b/src/emails/templates/saleor/GiftCardSentEmail.tsx similarity index 88% rename from src/emails/templates/GiftCardSentEmail.tsx rename to src/emails/templates/saleor/GiftCardSentEmail.tsx index 807a76a..aaa52c1 100644 --- a/src/emails/templates/GiftCardSentEmail.tsx +++ b/src/emails/templates/saleor/GiftCardSentEmail.tsx @@ -7,7 +7,9 @@ import Text from "@/emails/components/Text"; import { type GiftCardSentSubscription } from "@/graphql/operations/subscriptions/generated"; import { type EventData } from "@/lib/types"; -const GiftCardSentEmail = ({ data }: EventData) => { +type GiftCardSentEmailProps = EventData; + +const GiftCardSentEmail = ({ data }: GiftCardSentEmailProps) => { return ( {() => ( @@ -28,7 +30,7 @@ const GiftCardSentEmail = ({ data }: EventData) => { ); }; -const previewProps: EventData = { +const previewProps: GiftCardSentEmailProps = { data: { sentToEmail: "user@example.com", channel: "channel-us", diff --git a/src/emails/templates/OrderCancelledEmail.tsx b/src/emails/templates/saleor/OrderCancelledEmail.tsx similarity index 85% rename from src/emails/templates/OrderCancelledEmail.tsx rename to src/emails/templates/saleor/OrderCancelledEmail.tsx index c2ee5a4..338a63f 100644 --- a/src/emails/templates/OrderCancelledEmail.tsx +++ b/src/emails/templates/saleor/OrderCancelledEmail.tsx @@ -4,9 +4,9 @@ import Text from "@/emails/components/Text"; import { type OrderCancelledSubscription } from "@/graphql/operations/subscriptions/generated"; import { type EventData } from "@/lib/types"; -const OrderCancelledEmail = ({ - data, -}: EventData) => { +type OrderCancelledEmailProps = EventData; + +const OrderCancelledEmail = ({ data }: OrderCancelledEmailProps) => { const order = data!.order!; return ( @@ -28,7 +28,7 @@ const OrderCancelledEmail = ({ ); }; -const previewProps: EventData = { +const previewProps: OrderCancelledEmailProps = { data: { order: { channel: { diff --git a/src/emails/templates/OrderCreatedEmail.tsx b/src/emails/templates/saleor/OrderCreatedEmail.tsx similarity index 95% rename from src/emails/templates/OrderCreatedEmail.tsx rename to src/emails/templates/saleor/OrderCreatedEmail.tsx index 1b5a963..ab7a672 100644 --- a/src/emails/templates/OrderCreatedEmail.tsx +++ b/src/emails/templates/saleor/OrderCreatedEmail.tsx @@ -8,7 +8,9 @@ import { type OrderCreatedSubscription } from "@/graphql/operations/subscription import { orderLineToLine } from "@/lib/saleor/utils"; import { type EventData } from "@/lib/types"; -const OrderCreatedEmail = ({ data }: EventData) => { +type OrderCreatedEmailProps = EventData; + +const OrderCreatedEmail = ({ data }: OrderCreatedEmailProps) => { const order = data.order!; return ( @@ -62,7 +64,7 @@ const OrderCreatedEmail = ({ data }: EventData) => { ); }; -const previewProps: EventData = { +const previewProps: OrderCreatedEmailProps = { data: { order: { number: "939", diff --git a/src/emails/templates/OrderRefundedEmail.tsx b/src/emails/templates/saleor/OrderRefundedEmail.tsx similarity index 95% rename from src/emails/templates/OrderRefundedEmail.tsx rename to src/emails/templates/saleor/OrderRefundedEmail.tsx index 9becdc5..fe03298 100644 --- a/src/emails/templates/OrderRefundedEmail.tsx +++ b/src/emails/templates/saleor/OrderRefundedEmail.tsx @@ -8,7 +8,9 @@ import { type OrderRefundedSubscription } from "@/graphql/operations/subscriptio import { orderLineToLine } from "@/lib/saleor/utils"; import { type EventData } from "@/lib/types"; -const OrderRefundedEmail = ({ data }: EventData) => { +type OrderRefundedEmailProps = EventData; + +const OrderRefundedEmail = ({ data }: OrderRefundedEmailProps) => { const order = data!.order!; return ( @@ -60,7 +62,7 @@ const OrderRefundedEmail = ({ data }: EventData) => { ); }; -const previewProps: EventData = { +const previewProps: OrderRefundedEmailProps = { data: { order: { number: "939", diff --git a/src/lib/emails/const.ts b/src/lib/emails/const.ts index 7a10f81..3039a3f 100644 --- a/src/lib/emails/const.ts +++ b/src/lib/emails/const.ts @@ -1,19 +1,20 @@ import { type ComponentType } from "react"; -import AccountChangeEmailRequestedEmail from "@/emails/templates/AccountChangeEmailRequestedEmail"; -import AccountConfirmationRequestedEmail from "@/emails/templates/AccountConfirmationRequestedEmail"; -import AccountConfirmedEmail from "@/emails/templates/AccountConfirmedEmail"; -import AccountDeletedEmail from "@/emails/templates/AccountDeletedEmail"; -import AccountDeleteRequestedEmail from "@/emails/templates/AccountDeleteRequestedEmail"; -import AccountEmailChangedEmail from "@/emails/templates/AccountEmailChangedEmail"; -import AccountSetPasswordRequestedEmail from "@/emails/templates/AccountSetPasswordRequestedEmail"; -import FulfillmentCreatedEmail from "@/emails/templates/FulfillmentCreatedEmail"; -import FulfillmentTrackingNumberUpdatedEmail from "@/emails/templates/FulfillmentTrackingNumberUpdatedEmail"; -import GiftCardSentEmail from "@/emails/templates/GiftCardSentEmail"; -import OrderCancelledEmail from "@/emails/templates/OrderCancelledEmail"; -import OrderCreatedEmail from "@/emails/templates/OrderCreatedEmail"; -import OrderRefundedEmail from "@/emails/templates/OrderRefundedEmail"; -import { type Event } from "@/lib/payload"; +import { type EmailEventType } from "@/const"; +import CustomEventEmail from "@/emails/templates/custom/CustomEventEmail"; +import AccountChangeEmailRequestedEmail from "@/emails/templates/saleor/AccountChangeEmailRequestedEmail"; +import AccountConfirmationRequestedEmail from "@/emails/templates/saleor/AccountConfirmationRequestedEmail"; +import AccountConfirmedEmail from "@/emails/templates/saleor/AccountConfirmedEmail"; +import AccountDeletedEmail from "@/emails/templates/saleor/AccountDeletedEmail"; +import AccountDeleteRequestedEmail from "@/emails/templates/saleor/AccountDeleteRequestedEmail"; +import AccountEmailChangedEmail from "@/emails/templates/saleor/AccountEmailChangedEmail"; +import AccountSetPasswordRequestedEmail from "@/emails/templates/saleor/AccountSetPasswordRequestedEmail"; +import FulfillmentCreatedEmail from "@/emails/templates/saleor/FulfillmentCreatedEmail"; +import FulfillmentTrackingNumberUpdatedEmail from "@/emails/templates/saleor/FulfillmentTrackingNumberUpdatedEmail"; +import GiftCardSentEmail from "@/emails/templates/saleor/GiftCardSentEmail"; +import OrderCancelledEmail from "@/emails/templates/saleor/OrderCancelledEmail"; +import OrderCreatedEmail from "@/emails/templates/saleor/OrderCreatedEmail"; +import OrderRefundedEmail from "@/emails/templates/saleor/OrderRefundedEmail"; const extractEmailFromOrder = (data: { order: { userEmail: string } }) => data.order.userEmail; @@ -24,8 +25,10 @@ const extractEmailFromGiftCard = (data: { sentToEmail: string }) => const extractEmailFromUser = (data: { user: { email: string } }) => data.user.email; +const extractEmailFromCustomEvent = (data: { email: string }) => data.email; + export const TEMPLATES_MAP: { - [key in Event]?: { + [key in EmailEventType]?: { extractFn: (data: any) => string; template: ComponentType & { Subject: string }; }; @@ -82,4 +85,8 @@ export const TEMPLATES_MAP: { template: AccountChangeEmailRequestedEmail, extractFn: extractEmailFromUser, }, + custom_event: { + template: CustomEventEmail, + extractFn: extractEmailFromCustomEvent, + }, }; diff --git a/src/lib/payload.test.ts b/src/lib/payload.test.ts index 8d4ca4f..01c6188 100644 --- a/src/lib/payload.test.ts +++ b/src/lib/payload.test.ts @@ -2,20 +2,17 @@ import { type SQSRecord } from "aws-lambda"; import { describe, expect, it } from "vitest"; import { z } from "zod"; +import { EMAIL_EVENTS } from "@/const"; + import { ParsePayloadError } from "./errors/serverless"; -import { - parsePayload, - parseRecord, - serializePayload, - SUPPORTED_EVENTS, -} from "./payload"; +import { parsePayload, parseRecord, serializePayload } from "./payload"; describe("payload", () => { describe("serializePayload", () => { it("should serialize payload correctly when valid data is provided", () => { // given const mockData = { key: "value" }; - const mockEvent = SUPPORTED_EVENTS[0]; + const mockEvent = EMAIL_EVENTS[0]; // when const serialized = serializePayload({ @@ -102,7 +99,7 @@ describe("payload", () => { // given const data = { payload: { - event: SUPPORTED_EVENTS[0], + event: EMAIL_EVENTS[0], data: { key: "value" }, }, format: "any", diff --git a/src/lib/payload.ts b/src/lib/payload.ts index ebc38f0..d975048 100644 --- a/src/lib/payload.ts +++ b/src/lib/payload.ts @@ -2,22 +2,15 @@ import { type SQSRecord } from "aws-lambda"; import { type FastifyRequest } from "fastify"; import { z } from "zod"; -import { EVENT_HANDLERS } from "@/api/rest/saleor/webhooks"; import { CONFIG } from "@/config"; -import { type WebhookEventTypeAsyncEnum } from "@/graphql/schema"; +import { EMAIL_EVENTS, type EmailEventType } from "@/const"; import { ParsePayloadError } from "@/lib/errors/serverless"; -export const SUPPORTED_EVENTS = EVENT_HANDLERS.map(({ event }) => - event.toLowerCase() -) as any as z.EnumValues>; - -export type Event = (typeof SUPPORTED_EVENTS)[number]; - export const payloadSchema = z.object({ format: z.string(), payload: z.object({ data: z.object({}).passthrough(), - event: z.enum(SUPPORTED_EVENTS), + event: z.enum(EMAIL_EVENTS), }), }); @@ -26,7 +19,7 @@ export const serializePayload = ({ event, }: { data: FastifyRequest["body"]; - event: Event; + event: EmailEventType; }) => payloadSchema.parse({ format: getJSONFormatHeader({ name: CONFIG.NAME }), diff --git a/src/lib/saleor/schema.ts b/src/lib/saleor/schema.ts index 1aefafd..3ce1ecd 100644 --- a/src/lib/saleor/schema.ts +++ b/src/lib/saleor/schema.ts @@ -1,25 +1,21 @@ import { z } from "zod"; -import { type WebhookEventTypeAsyncEnum } from "@/graphql/schema"; +import { SALEOR_EVENTS } from "@/const"; /** - * Headers must be in lowercase, otherwise it will not be lookup by zod type provider. + * Headers must be in lowercase, otherwise, it will not be looked up by Zod type provider. */ export const saleorHeaders = z.object({ "saleor-domain": z.string(), "saleor-api-url": z.string(), }); - export type SaleorHeaders = z.infer; -export const saleorWebhookHeaders = ( - z.object({ - "saleor-event": z.string(), - }) as unknown as z.ZodObject<{ - "saleor-event": z.ZodLiteral>; - }> -) - .and(z.object({ "saleor-signature": z.string() })) +export const saleorWebhookHeaders = z + .object({ + "saleor-event": z.enum(SALEOR_EVENTS), + "saleor-signature": z.string(), + }) .and(saleorHeaders); export type SaleorWebhookHeaders = z.infer; @@ -27,5 +23,4 @@ export type SaleorWebhookHeaders = z.infer; export const saleorBearerHeader = z.object({ authorization: z.string().transform((value) => value.replace("Bearer ", "")), }); - export type SaleorBearerHeader = z.infer; diff --git a/src/lib/types.ts b/src/lib/types.ts index 3a3eda1..1a6ebfd 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -12,4 +12,8 @@ export type EventData = { data: NonNullable; }; +export type CustomEventData = { + data: NonNullable; +}; + export type PartialBy = Pick, K> & Omit;