diff --git a/.changeset/perfect-fishes-listen.md b/.changeset/perfect-fishes-listen.md new file mode 100644 index 0000000000..1f7b1fb0ae --- /dev/null +++ b/.changeset/perfect-fishes-listen.md @@ -0,0 +1,7 @@ +--- +"@medusajs/notification-sendgrid": patch +"@medusajs/notification-logger": patch +"@medusajs/types": patch +--- + +Add sendgrid and logger notification providers diff --git a/integration-tests/modules/__tests__/notification/admin/notification.spec.ts b/integration-tests/modules/__tests__/notification/admin/notification.spec.ts new file mode 100644 index 0000000000..1d264e0cce --- /dev/null +++ b/integration-tests/modules/__tests__/notification/admin/notification.spec.ts @@ -0,0 +1,128 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { + CreateNotificationDTO, + INotificationModuleService, + Logger, +} from "@medusajs/types" +import { ContainerRegistrationKeys } from "@medusajs/utils" +import { medusaIntegrationTestRunner } from "medusa-test-utils" + +jest.setTimeout(50000) + +const env = { MEDUSA_FF_MEDUSA_V2: true } +medusaIntegrationTestRunner({ + env, + testSuite: ({ getContainer }) => { + describe("Notification module", () => { + let service: INotificationModuleService + let logger: Logger + + beforeAll(async () => { + service = getContainer().resolve(ModuleRegistrationName.NOTIFICATION) + logger = getContainer().resolve(ContainerRegistrationKeys.LOGGER) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it("should successfully send a notification for an available channel", async () => { + const logSpy = jest.spyOn(logger, "info") + const notification = { + to: "test@medusajs.com", + channel: "email", + template: "order-created", + data: { username: "john-doe" }, + trigger_type: "order-created", + resource_id: "order-id", + resource_type: "order", + } as CreateNotificationDTO + + const result = await service.create(notification) + const fromDB = await service.retrieve(result.id) + + expect(result).toEqual( + expect.objectContaining({ + id: expect.any(String), + to: "test@medusajs.com", + provider_id: "local-notification-provider", + }) + ) + + delete fromDB.original_notification_id + delete fromDB.external_id + delete fromDB.receiver_id + delete (fromDB as any).idempotency_key + delete (fromDB as any).provider + + expect(result).toEqual(fromDB) + expect(logSpy).toHaveBeenCalledWith( + 'Attempting to send a notification to: test@medusajs.com on the channel: email with template: order-created and data: {"username":"john-doe"}' + ) + }) + + it("should throw an exception if there is no provider for the channel", async () => { + const notification = { + to: "test@medusajs.com", + channel: "sms", + } as CreateNotificationDTO + + const error = await service.create(notification).catch((e) => e) + expect(error.message).toEqual( + "Could not find a notification provider for channel: sms" + ) + }) + + it("should allow listing all notifications with filters", async () => { + const notification1 = { + to: "test@medusajs.com", + channel: "email", + template: "order-created", + } as CreateNotificationDTO + + const notification2 = { + to: "test@medusajs.com", + channel: "log", + template: "product-created", + } as CreateNotificationDTO + + await service.create([notification1, notification2]) + + const notifications = await service.list({ channel: "log" }) + expect(notifications).toHaveLength(1) + expect(notifications[0]).toEqual( + expect.objectContaining({ + to: "test@medusajs.com", + channel: "log", + template: "product-created", + }) + ) + }) + + it("should allow retrieving a notification", async () => { + const notification1 = { + to: "test@medusajs.com", + channel: "email", + template: "order-created", + } as CreateNotificationDTO + + const notification2 = { + to: "test@medusajs.com", + channel: "log", + template: "product-created", + } as CreateNotificationDTO + + const [first] = await service.create([notification1, notification2]) + + const notification = await service.retrieve(first.id) + expect(notification).toEqual( + expect.objectContaining({ + to: "test@medusajs.com", + channel: "email", + template: "order-created", + }) + ) + }) + }) + }, +}) diff --git a/integration-tests/modules/medusa-config.js b/integration-tests/modules/medusa-config.js index 814355bad2..acd77847ae 100644 --- a/integration-tests/modules/medusa-config.js +++ b/integration-tests/modules/medusa-config.js @@ -104,5 +104,23 @@ module.exports = { providers: [customFulfillmentProvider], }, }, + [Modules.NOTIFICATION]: { + /** @type {import('@medusajs/types').LocalNotificationServiceOptions} */ + options: { + providers: [ + { + resolve: "@medusajs/notification-local", + options: { + config: { + "local-notification-provider": { + name: "Local Notification Provider", + channels: ["log", "email"], + }, + }, + }, + }, + ], + }, + }, }, } diff --git a/packages/core/types/src/notification/providers/index.ts b/packages/core/types/src/notification/providers/index.ts index 7f665698b6..e0069c9d0d 100644 --- a/packages/core/types/src/notification/providers/index.ts +++ b/packages/core/types/src/notification/providers/index.ts @@ -1 +1,2 @@ -export * from "./local" +export * from "./logger" +export * from "./sendgrid" diff --git a/packages/core/types/src/notification/providers/local.ts b/packages/core/types/src/notification/providers/logger.ts similarity index 100% rename from packages/core/types/src/notification/providers/local.ts rename to packages/core/types/src/notification/providers/logger.ts diff --git a/packages/core/types/src/notification/providers/sendgrid.ts b/packages/core/types/src/notification/providers/sendgrid.ts new file mode 100644 index 0000000000..2f1f869847 --- /dev/null +++ b/packages/core/types/src/notification/providers/sendgrid.ts @@ -0,0 +1,4 @@ +export interface SendgridNotificationServiceOptions { + api_key: string + from: string +} diff --git a/packages/modules/providers/notification-local/.gitignore b/packages/modules/providers/notification-local/.gitignore new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/providers/notification-local/README.md b/packages/modules/providers/notification-local/README.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/providers/notification-local/integration-tests/__tests__/services.spec.ts b/packages/modules/providers/notification-local/integration-tests/__tests__/services.spec.ts new file mode 100644 index 0000000000..aecec1ef69 --- /dev/null +++ b/packages/modules/providers/notification-local/integration-tests/__tests__/services.spec.ts @@ -0,0 +1,36 @@ +import { LocalNotificationService } from "../../src/services/local" +jest.setTimeout(100000) + +describe("Local notification provider", () => { + let localService: LocalNotificationService + + beforeAll(() => { + localService = new LocalNotificationService( + { + logger: console as any, + }, + {} + ) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it("sends logs to the console output with the notification details", async () => { + const logSpy = jest.spyOn(console, "info") + await localService.send({ + to: "test@medusajs.com", + channel: "email", + template: "some-template", + data: { + username: "john-doe", + }, + }) + + expect(logSpy).toHaveBeenCalled() + expect(logSpy).toHaveBeenCalledWith( + 'Attempting to send a notification to: test@medusajs.com on the channel: email with template: some-template and data: {"username":"john-doe"}' + ) + }) +}) diff --git a/packages/modules/providers/notification-local/jest.config.js b/packages/modules/providers/notification-local/jest.config.js new file mode 100644 index 0000000000..9cf8a99080 --- /dev/null +++ b/packages/modules/providers/notification-local/jest.config.js @@ -0,0 +1,7 @@ +module.exports = { + transform: { + "^.+\\.[jt]s?$": "@swc/jest", + }, + testEnvironment: `node`, + moduleFileExtensions: [`js`, `jsx`, `ts`, `tsx`, `json`], +} diff --git a/packages/modules/providers/notification-local/package.json b/packages/modules/providers/notification-local/package.json new file mode 100644 index 0000000000..18b0e03aae --- /dev/null +++ b/packages/modules/providers/notification-local/package.json @@ -0,0 +1,39 @@ +{ + "name": "@medusajs/notification-local", + "version": "0.0.1", + "description": "Local (logging) notification provider for Medusa, useful for testing purposes and log audits", + "main": "dist/index.js", + "repository": { + "type": "git", + "url": "https://github.com/medusajs/medusa", + "directory": "packages/modules/providers/notification-local" + }, + "files": [ + "dist" + ], + "engines": { + "node": ">=16" + }, + "author": "Medusa", + "license": "MIT", + "scripts": { + "prepublishOnly": "cross-env NODE_ENV=production tsc --build", + "test": "jest --passWithNoTests src", + "test:integration": "jest --forceExit -- integration-tests/**/__tests__/**/*.spec.ts", + "build": "rimraf dist && tsc -p ./tsconfig.json", + "watch": "tsc --watch" + }, + "devDependencies": { + "cross-env": "^5.2.1", + "jest": "^25.5.4", + "rimraf": "^5.0.1", + "typescript": "^4.9.5" + }, + "dependencies": { + "@medusajs/utils": "^1.11.7" + }, + "keywords": [ + "medusa-provider", + "medusa-provider-local" + ] +} diff --git a/packages/modules/providers/notification-local/src/index.ts b/packages/modules/providers/notification-local/src/index.ts new file mode 100644 index 0000000000..9280b5ff69 --- /dev/null +++ b/packages/modules/providers/notification-local/src/index.ts @@ -0,0 +1,10 @@ +import { ModuleProviderExports } from "@medusajs/types" +import { LocalNotificationService } from "./services/local" + +const services = [LocalNotificationService] + +const providerExport: ModuleProviderExports = { + services, +} + +export default providerExport diff --git a/packages/modules/providers/notification-local/src/services/local.ts b/packages/modules/providers/notification-local/src/services/local.ts new file mode 100644 index 0000000000..6210272597 --- /dev/null +++ b/packages/modules/providers/notification-local/src/services/local.ts @@ -0,0 +1,48 @@ +import { + Logger, + NotificationTypes, + LocalNotificationServiceOptions, +} from "@medusajs/types" +import { + AbstractNotificationProviderService, + MedusaError, +} from "@medusajs/utils" + +type InjectedDependencies = { + logger: Logger +} + +interface LocalServiceConfig {} + +export class LocalNotificationService extends AbstractNotificationProviderService { + protected config_: LocalServiceConfig + protected logger_: Logger + + constructor( + { logger }: InjectedDependencies, + options: LocalNotificationServiceOptions + ) { + super() + this.config_ = options + this.logger_ = logger + } + + async send( + notification: NotificationTypes.ProviderSendNotificationDTO + ): Promise { + if (!notification) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `No notification information provided` + ) + } + + const message = + `Attempting to send a notification to: ${notification.to}` + + ` on the channel: ${notification.channel} with template: ${notification.template}` + + ` and data: ${JSON.stringify(notification.data)}` + + this.logger_.info(message) + return {} + } +} diff --git a/packages/modules/providers/notification-local/tsconfig.json b/packages/modules/providers/notification-local/tsconfig.json new file mode 100644 index 0000000000..0fa3c23473 --- /dev/null +++ b/packages/modules/providers/notification-local/tsconfig.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + "lib": ["es2020"], + "target": "es2020", + "jsx": "react-jsx" /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */, + "outDir": "./dist", + "esModuleInterop": true, + "declaration": true, + "module": "commonjs", + "moduleResolution": "node", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "noImplicitReturns": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "noImplicitThis": true, + "allowJs": true, + "skipLibCheck": true, + "downlevelIteration": true, // to use ES5 specific tooling + "inlineSourceMap": true /* Emit a single file with source maps instead of having a separate file. */ + }, + "include": ["src"], + "exclude": [ + "dist", + "build", + "src/**/__tests__", + "src/**/__mocks__", + "src/**/__fixtures__", + "node_modules", + ".eslintrc.js" + ] +} diff --git a/packages/modules/providers/notification-sendgrid/.gitignore b/packages/modules/providers/notification-sendgrid/.gitignore new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/providers/notification-sendgrid/README.md b/packages/modules/providers/notification-sendgrid/README.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/providers/notification-sendgrid/integration-tests/__tests__/services.spec.ts b/packages/modules/providers/notification-sendgrid/integration-tests/__tests__/services.spec.ts new file mode 100644 index 0000000000..e312be4447 --- /dev/null +++ b/packages/modules/providers/notification-sendgrid/integration-tests/__tests__/services.spec.ts @@ -0,0 +1,70 @@ +import { SendgridNotificationService } from "../../src/services/sendgrid" +jest.setTimeout(100000) + +// Note: This test hits the sendgrid service, and it is mainly meant to be run manually after setting all the envvars below. +// We could also setup a sink email service to test this automatically, but it is not necessary for the time being. +describe.skip("Sendgrid notification provider", () => { + let sendgridService: SendgridNotificationService + let emailTemplate = "" + let to = "" + beforeAll(() => { + sendgridService = new SendgridNotificationService( + { + logger: console as any, + }, + { + api_key: process.env.SENDGRID_TEST_API_KEY ?? "", + from: process.env.SENDGRID_TEST_FROM ?? "", + } + ) + + emailTemplate = process.env.SENDGRID_TEST_TEMPLATE ?? "" + to = process.env.SENDGRID_TEST_TO ?? "" + }) + + it("sends an email with the specified template", async () => { + const resp = await sendgridService.send({ + to, + channel: "email", + template: emailTemplate, + data: { + username: "john-doe", + }, + }) + + expect(resp).toEqual({}) + }) + + it("throws an exception if the template does not exist", async () => { + const error = await sendgridService + .send({ + to, + channel: "email", + template: "unknown-template", + data: { + username: "john-doe", + }, + }) + .catch((e) => e) + + expect(error.message).toEqual( + "Failed to send email: 400 - The template_id must be a valid GUID, you provided 'unknown-template'." + ) + }) + it("throws an exception if the to email is not valid", async () => { + const error = await sendgridService + .send({ + to: "not-email", + channel: "email", + template: emailTemplate, + data: { + username: "john-doe", + }, + }) + .catch((e) => e) + + expect(error.message).toEqual( + "Failed to send email: 400 - Does not contain a valid address." + ) + }) +}) diff --git a/packages/modules/providers/notification-sendgrid/jest.config.js b/packages/modules/providers/notification-sendgrid/jest.config.js new file mode 100644 index 0000000000..bc24a517ac --- /dev/null +++ b/packages/modules/providers/notification-sendgrid/jest.config.js @@ -0,0 +1,16 @@ +module.exports = { + globals: { + "ts-jest": { + tsconfig: "tsconfig.spec.json", + isolatedModules: false, + }, + }, + transform: { + "^.+\\.[jt]s?$": "ts-jest", + }, + testEnvironment: `node`, + moduleNameMapper: { + "^axios$": "axios/dist/node/axios.cjs", + }, + moduleFileExtensions: [`js`, `jsx`, `ts`, `tsx`, `json`], +} diff --git a/packages/modules/providers/notification-sendgrid/package.json b/packages/modules/providers/notification-sendgrid/package.json new file mode 100644 index 0000000000..945d71be1c --- /dev/null +++ b/packages/modules/providers/notification-sendgrid/package.json @@ -0,0 +1,40 @@ +{ + "name": "@medusajs/notification-sendgrid", + "version": "0.0.1", + "description": "Sendgrid notification provider for Medusa", + "main": "dist/index.js", + "repository": { + "type": "git", + "url": "https://github.com/medusajs/medusa", + "directory": "packages/modules/providers/notification-sendgrid" + }, + "files": [ + "dist" + ], + "engines": { + "node": ">=16" + }, + "author": "Medusa", + "license": "MIT", + "scripts": { + "prepublishOnly": "cross-env NODE_ENV=production tsc --build", + "test": "jest --passWithNoTests src", + "test:integration": "jest --forceExit -- integration-tests/**/__tests__/**/*.spec.ts", + "build": "rimraf dist && tsc -p ./tsconfig.json", + "watch": "tsc --watch" + }, + "devDependencies": { + "cross-env": "^5.2.1", + "jest": "^25.5.4", + "rimraf": "^5.0.1", + "typescript": "^4.9.5" + }, + "dependencies": { + "@medusajs/utils": "^1.11.7", + "@sendgrid/mail": "^8.1.3" + }, + "keywords": [ + "medusa-provider", + "medusa-provider-sendgrid" + ] +} diff --git a/packages/modules/providers/notification-sendgrid/src/index.ts b/packages/modules/providers/notification-sendgrid/src/index.ts new file mode 100644 index 0000000000..7c8afdd5cc --- /dev/null +++ b/packages/modules/providers/notification-sendgrid/src/index.ts @@ -0,0 +1,10 @@ +import { ModuleProviderExports } from "@medusajs/types" +import { SendgridNotificationService } from "./services/sendgrid" + +const services = [SendgridNotificationService] + +const providerExport: ModuleProviderExports = { + services, +} + +export default providerExport diff --git a/packages/modules/providers/notification-sendgrid/src/services/sendgrid.ts b/packages/modules/providers/notification-sendgrid/src/services/sendgrid.ts new file mode 100644 index 0000000000..ee526c852f --- /dev/null +++ b/packages/modules/providers/notification-sendgrid/src/services/sendgrid.ts @@ -0,0 +1,73 @@ +import { + Logger, + NotificationTypes, + SendgridNotificationServiceOptions, +} from "@medusajs/types" +import { + AbstractNotificationProviderService, + MedusaError, +} from "@medusajs/utils" +import sendgrid from "@sendgrid/mail" + +type InjectedDependencies = { + logger: Logger +} + +interface SendgridServiceConfig { + apiKey: string + from: string +} + +export class SendgridNotificationService extends AbstractNotificationProviderService { + protected config_: SendgridServiceConfig + protected logger_: Logger + + constructor( + { logger }: InjectedDependencies, + options: SendgridNotificationServiceOptions + ) { + super() + + this.config_ = { + apiKey: options.api_key, + from: options.from, + } + this.logger_ = logger + sendgrid.setApiKey(this.config_.apiKey) + } + + async send( + notification: NotificationTypes.ProviderSendNotificationDTO + ): Promise { + if (!notification) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `No notification information provided` + ) + } + + const message = { + to: notification.to, + from: this.config_.from, + templateId: notification.template, + dynamicTemplateData: notification.data as + | { [key: string]: any } + | undefined, + } + + try { + // Unfortunately we don't get anything useful back in the response + await sendgrid.send(message) + return {} + } catch (error) { + const errorCode = error.code + const responseError = error.response?.body?.errors?.[0] + throw new MedusaError( + MedusaError.Types.UNEXPECTED_STATE, + `Failed to send email: ${errorCode} - ${ + responseError?.message ?? "unknown error" + }` + ) + } + } +} diff --git a/packages/modules/providers/notification-sendgrid/tsconfig.json b/packages/modules/providers/notification-sendgrid/tsconfig.json new file mode 100644 index 0000000000..0fa3c23473 --- /dev/null +++ b/packages/modules/providers/notification-sendgrid/tsconfig.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + "lib": ["es2020"], + "target": "es2020", + "jsx": "react-jsx" /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */, + "outDir": "./dist", + "esModuleInterop": true, + "declaration": true, + "module": "commonjs", + "moduleResolution": "node", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "noImplicitReturns": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "noImplicitThis": true, + "allowJs": true, + "skipLibCheck": true, + "downlevelIteration": true, // to use ES5 specific tooling + "inlineSourceMap": true /* Emit a single file with source maps instead of having a separate file. */ + }, + "include": ["src"], + "exclude": [ + "dist", + "build", + "src/**/__tests__", + "src/**/__mocks__", + "src/**/__fixtures__", + "node_modules", + ".eslintrc.js" + ] +} diff --git a/packages/modules/providers/notification-sendgrid/tsconfig.spec.json b/packages/modules/providers/notification-sendgrid/tsconfig.spec.json new file mode 100644 index 0000000000..9b62409191 --- /dev/null +++ b/packages/modules/providers/notification-sendgrid/tsconfig.spec.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["src"], + "exclude": ["node_modules"] +} diff --git a/yarn.lock b/yarn.lock index fad1fc377f..c492843c68 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5473,6 +5473,31 @@ __metadata: languageName: unknown linkType: soft +"@medusajs/notification-local@workspace:packages/modules/providers/notification-local": + version: 0.0.0-use.local + resolution: "@medusajs/notification-local@workspace:packages/modules/providers/notification-local" + dependencies: + "@medusajs/utils": ^1.11.7 + cross-env: ^5.2.1 + jest: ^25.5.4 + rimraf: ^5.0.1 + typescript: ^4.9.5 + languageName: unknown + linkType: soft + +"@medusajs/notification-sendgrid@workspace:packages/modules/providers/notification-sendgrid": + version: 0.0.0-use.local + resolution: "@medusajs/notification-sendgrid@workspace:packages/modules/providers/notification-sendgrid" + dependencies: + "@medusajs/utils": ^1.11.7 + "@sendgrid/mail": ^8.1.3 + cross-env: ^5.2.1 + jest: ^25.5.4 + rimraf: ^5.0.1 + typescript: ^4.9.5 + languageName: unknown + linkType: soft + "@medusajs/notification@workspace:packages/modules/notification": version: 0.0.0-use.local resolution: "@medusajs/notification@workspace:packages/modules/notification" @@ -8718,6 +8743,35 @@ __metadata: languageName: node linkType: hard +"@sendgrid/client@npm:^8.1.3": + version: 8.1.3 + resolution: "@sendgrid/client@npm:8.1.3" + dependencies: + "@sendgrid/helpers": ^8.0.0 + axios: ^1.6.8 + checksum: 1977e9e541d1277a0d8a29eff7f63d4580d2fc2c5e7d9d2adbaee46a471350b1967d6f03dac6adab63522f1775ee5a9e8eddb5502039d3ac8ae98acfedf190cf + languageName: node + linkType: hard + +"@sendgrid/helpers@npm:^8.0.0": + version: 8.0.0 + resolution: "@sendgrid/helpers@npm:8.0.0" + dependencies: + deepmerge: ^4.2.2 + checksum: e7f341099c63915eb095102f8c7ec220a8f2fb66a0cd836ab91e7424005e7c5fd27b65e3a5ed43654f5b2b3608fa7d14ebba924ff86e5d0bdfeaf0248f836a50 + languageName: node + linkType: hard + +"@sendgrid/mail@npm:^8.1.3": + version: 8.1.3 + resolution: "@sendgrid/mail@npm:8.1.3" + dependencies: + "@sendgrid/client": ^8.1.3 + "@sendgrid/helpers": ^8.0.0 + checksum: 180d4609710dd22e60ecfcfcc7aaf37d8936872c3b7f9413b86d32e55e9d20f21842c4d81fff35d0906ca3750262a6df95b6c96eae5c48fe6c260d8214bd54b8 + languageName: node + linkType: hard + "@sideway/address@npm:^4.1.5": version: 4.1.5 resolution: "@sideway/address@npm:4.1.5"