diff --git a/.changeset/moody-days-drive.md b/.changeset/moody-days-drive.md new file mode 100644 index 0000000000..237d40f58b --- /dev/null +++ b/.changeset/moody-days-drive.md @@ -0,0 +1,8 @@ +--- +"@medusajs/modules-sdk": minor +"@medusajs/types": minor +"@medusajs/utils": minor +"@medusajs/notification": patch +--- + +Add basic implementation of a notification module diff --git a/packages/core/modules-sdk/src/definitions.ts b/packages/core/modules-sdk/src/definitions.ts index 7f8e63d673..bf8e5d8f82 100644 --- a/packages/core/modules-sdk/src/definitions.ts +++ b/packages/core/modules-sdk/src/definitions.ts @@ -36,6 +36,7 @@ export enum Modules { STORE = "store", CURRENCY = "currency", FILE = "file", + NOTIFICATION = "notification", } export enum ModuleRegistrationName { @@ -61,6 +62,7 @@ export enum ModuleRegistrationName { STORE = "storeModuleService", CURRENCY = "currencyModuleService", FILE = "fileModuleService", + NOTIFICATION = "notificationModuleService", } export const MODULE_PACKAGE_NAMES = { @@ -87,6 +89,7 @@ export const MODULE_PACKAGE_NAMES = { [Modules.STORE]: "@medusajs/store", [Modules.CURRENCY]: "@medusajs/currency", [Modules.FILE]: "@medusajs/file", + [Modules.NOTIFICATION]: "@medusajs/notification", } export const ModulesDefinition: { [key: string | Modules]: ModuleDefinition } = @@ -378,6 +381,19 @@ export const ModulesDefinition: { [key: string | Modules]: ModuleDefinition } = resources: MODULE_RESOURCE_TYPE.SHARED, }, }, + [Modules.NOTIFICATION]: { + key: Modules.NOTIFICATION, + registrationName: ModuleRegistrationName.NOTIFICATION, + defaultPackage: false, + label: upperCaseFirst(ModuleRegistrationName.NOTIFICATION), + isRequired: false, + isQueryable: true, + dependencies: ["logger"], + defaultModuleDeclaration: { + scope: MODULE_SCOPE.INTERNAL, + resources: MODULE_RESOURCE_TYPE.SHARED, + }, + }, } export const MODULE_DEFINITIONS: ModuleDefinition[] = diff --git a/packages/core/types/src/bundles.ts b/packages/core/types/src/bundles.ts index e0c8de7370..cded2a45b6 100644 --- a/packages/core/types/src/bundles.ts +++ b/packages/core/types/src/bundles.ts @@ -27,3 +27,4 @@ export * as StoreTypes from "./store" export * as CurrencyTypes from "./currency" export * as HttpTypes from "./http" export * as FileTypes from "./file" +export * as NotificationTypes from "./notification" diff --git a/packages/core/types/src/index.ts b/packages/core/types/src/index.ts index 248ba36942..3fd3ca3248 100644 --- a/packages/core/types/src/index.ts +++ b/packages/core/types/src/index.ts @@ -38,3 +38,4 @@ export * from "./transaction-base" export * from "./user" export * from "./workflow" export * from "./workflows" +export * from "./notification" diff --git a/packages/core/types/src/notification/common.ts b/packages/core/types/src/notification/common.ts new file mode 100644 index 0000000000..480a155efe --- /dev/null +++ b/packages/core/types/src/notification/common.ts @@ -0,0 +1,138 @@ +import { BaseFilterable } from "../dal" +import { OperatorMap } from "../dal/utils" + +/** + * @interface + * + * A notification's data. + */ +export interface NotificationDTO { + /** + * The ID of the notification. + */ + id: string + /** + * The recipient of the notification. It can be email, phone number, or username, depending on the channel. + */ + to: string + /** + * The channel through which the notification is sent, such as 'email' or 'sms' + */ + channel: string + /** + * The template name in the provider's system. + */ + template: string + /** + * The data that gets passed over to the provider for rendering the notification. + */ + data: Record | null + /** + * The event name, the workflow, or anything else that can help to identify what triggered the notification. + */ + trigger_type?: string | null + /** + * The ID of the resource this notification is for, if applicable. Useful for displaying relevant information in the UI + */ + resource_id?: string | null + /** + * The type of the resource this notification is for, if applicable, eg. "order" + */ + resource_type?: string | null + /** + * The ID of the customer this notification is for, if applicable. + */ + receiver_id?: string | null + /** + * The original notification, in case this is a retried notification. + */ + original_notification_id?: string | null + /** + * The id of the notification in the external system, if applicable + */ + external_id?: string | null + /** + * The ID of the notification provider. + */ + provider_id: string + /** + * Information about the notification provider + */ + provider: NotificationProviderDTO + /** + * The date and time the notification was created. + */ + created_at: Date +} + +/** + * @interface + * + * Information about the notification provider + */ +export interface NotificationProviderDTO { + /** + * The ID of the notification provider. + */ + id: string + /** + * The handle of the notification provider. + */ + handle: string + /** + * A user-friendly name of the notification provider. + */ + name: string + /** + * The supported channels by the notification provider. + */ + channels: string[] +} + +/** + * @interface + * + * The filters to apply on retrieved notifications. + * + * @prop q - Search through the notifications' attributes, such as trigger types and recipients, using this search term. + */ + +export interface FilterableNotificationProps + extends BaseFilterable { + /** + * Search through the notifications' attributes, such as trigger types and recipients, using this search term. + */ + q?: string + /** + * Filter based on the recipient of the notification. + */ + to?: string | string[] | OperatorMap + /** + * Filter based on the channel through which the notification is sent, such as 'email' or 'sms' + */ + channel?: string | string[] | OperatorMap + /** + * Filter based on the template name. + */ + template?: string | string[] | OperatorMap + /** + * Filter based on the trigger type. + */ + trigger_type?: string | string[] | OperatorMap + /** + * Filter based on the resource that was the trigger for the notification. + */ + resource_id?: string | string[] | OperatorMap + /** + * T* Filter based on the resource type that was the trigger for the notification. + */ + resource_type?: string | string[] | OperatorMap + /** + * Filter based on the customer ID. + */ + receiver_id?: string | string[] | OperatorMap + /** + * Filters a notification based on when it was sent and created in the database + */ + created_at?: OperatorMap +} diff --git a/packages/core/types/src/notification/index.ts b/packages/core/types/src/notification/index.ts new file mode 100644 index 0000000000..caee203da9 --- /dev/null +++ b/packages/core/types/src/notification/index.ts @@ -0,0 +1,5 @@ +export * from "./common" +export * from "./providers" +export * from "./mutations" +export * from "./service" +export * from "./provider" diff --git a/packages/core/types/src/notification/mutations.ts b/packages/core/types/src/notification/mutations.ts new file mode 100644 index 0000000000..7501f941cf --- /dev/null +++ b/packages/core/types/src/notification/mutations.ts @@ -0,0 +1,48 @@ +/** + * @interface + * + * A notification to send and have created in the DB + * + */ +export interface CreateNotificationDTO { + /** + * The recipient of the notification. It can be email, phone number, or username, depending on the channel. + */ + to: string + /** + * The channel through which the notification is sent, such as 'email' or 'sms' + */ + channel: string + /** + * The template name in the provider's system. + */ + template: string + /** + * The data that gets passed over to the provider for rendering the notification. + */ + data?: Record | null + /** + * The event name, the workflow, or anything else that can help to identify what triggered the notification. + */ + trigger_type?: string | null + /** + * The ID of the resource this notification is for, if applicable. Useful for displaying relevant information in the UI + */ + resource_id?: string | null + /** + * The type of the resource this notification is for, if applicable, eg. "order" + */ + resource_type?: string | null + /** + * The ID of the customer this notification is for, if applicable. + */ + receiver_id?: string | null + /** + * The original notification, in case this is a retried notification. + */ + original_notification_id?: string | null + /** + * An idempotency key that ensures the same notification is not sent multiple times. + */ + idempotency_key?: string | null +} diff --git a/packages/core/types/src/notification/provider.ts b/packages/core/types/src/notification/provider.ts new file mode 100644 index 0000000000..218679d52b --- /dev/null +++ b/packages/core/types/src/notification/provider.ts @@ -0,0 +1,54 @@ +/** + * @interface + * + * The details of the notification to send. + */ +export type ProviderSendNotificationDTO = { + /** + * The recipient of the notification. It can be email, phone number, or username, depending on the channel. + */ + to: string + /** + * The channel through which the notification is sent, such as 'email' or 'sms' + */ + channel: string + /** + * The template name in the provider's system. + */ + template: string + /** + * The data that gets passed over to the provider for rendering the notification. + */ + data?: Record | null +} + +/** + * @interface + * + * The result of sending the notification + */ +export type ProviderSendNotificationResultsDTO = { + /** + * The ID of the notification in the external system, if provided in the response + */ + id?: string +} + +/** + * ## Overview + * + * Notification provider interface for the notification module. + * + */ +export interface INotificationProvider { + /** + * This method is used to send a notification. + * + * @param {ProviderSendNotificationDTO} notification - All information needed to send a notification. + * @returns {Promise} The result of sending the notification. + * + */ + send( + notification: ProviderSendNotificationDTO + ): Promise +} diff --git a/packages/core/types/src/notification/providers/index.ts b/packages/core/types/src/notification/providers/index.ts new file mode 100644 index 0000000000..7f665698b6 --- /dev/null +++ b/packages/core/types/src/notification/providers/index.ts @@ -0,0 +1 @@ +export * from "./local" diff --git a/packages/core/types/src/notification/providers/local.ts b/packages/core/types/src/notification/providers/local.ts new file mode 100644 index 0000000000..0fcf7365bf --- /dev/null +++ b/packages/core/types/src/notification/providers/local.ts @@ -0,0 +1 @@ +export interface LocalNotificationServiceOptions {} diff --git a/packages/core/types/src/notification/service.ts b/packages/core/types/src/notification/service.ts new file mode 100644 index 0000000000..e968d04188 --- /dev/null +++ b/packages/core/types/src/notification/service.ts @@ -0,0 +1,199 @@ +import { FindConfig } from "../common" +import { IModuleService } from "../modules-sdk" +import { Context } from "../shared-context" +import { FilterableNotificationProps, NotificationDTO } from "./common" +import { CreateNotificationDTO } from "./mutations" + +/** + * The main service interface for the Notification Module. + */ +export interface INotificationModuleService extends IModuleService { + /** + * This method is used to send multiple notifications, and store the requests in the DB. + * + * @param {CreateNotificationDTO[]} data - The notifications to be sent. + * @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module. + * @returns {Promise} The list of sent notifications. + * + * @example + * const notifications = await notificationModuleService.create([ + * { + * to: "john@doe.me", + * template: "order-confirmation", + * channel: "email", + * }, + * { + * to: "+38975123456", + * template: "order-confirmation", + * channel: "sms", + * }, + * ]) + */ + create( + data: CreateNotificationDTO[], + sharedContext?: Context + ): Promise + + /** + * This method is used to send a notification, and store the request in the DB. + * + * @param {CreateNotificationDTO} data - The notification to be sent. + * @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module. + * @returns {Promise} The sent notification. + * + * @example + * const notification = await notificationModuleService.create({ + * to: "john@doe.me", + * template: "order-confirmation", + * channel: "email", + * }) + */ + create( + data: CreateNotificationDTO, + sharedContext?: Context + ): Promise + + /** + * This method is used to retrieve a notification by its ID + * + * @param {string} notificationId - The ID of the notification to retrieve. + * @param {FindConfig} config - + * The configurations determining how the notification is retrieved. Its properties, such as `select` or `relations`, accept the + * attributes or relations associated with a notification. + * @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module. + * @returns {Promise} The retrieved notification. + * + * @example + * A simple example that retrieves a notification by its ID: + * + * ```ts + * const notification = + * await notificationModuleService.retrieve("noti_123") + * ``` + * + * To specify relations that should be retrieved: + * + * ```ts + * const notification = await notificationModuleService.retrieve( + * "noti_123", + * { + * relations: ["provider"], + * } + * ) + * ``` + */ + retrieve( + notificationId: string, + config?: FindConfig, + sharedContext?: Context + ): Promise + + /** + * This method is used to retrieve a paginated list of notifications based on optional filters and configuration. + * + * @param {FilterableNotificationProps} filters - The filters to apply on the retrieved notifications. + * @param {FindConfig} config - + * The configurations determining how the notifications are retrieved. Its properties, such as `select` or `relations`, accept the + * attributes or relations associated with a notification. + * @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module. + * @returns {Promise} The list of notifications. + * + * @example + * To retrieve a list of notifications using their IDs: + * + * ```ts + * const notifications = await notificationModuleService.list({ + * id: ["noti_123", "noti_321"], + * }) + * ``` + * + * To specify relations that should be retrieved within the notifications: + * + * ```ts + * const notifications = await notificationModuleService.list( + * { + * id: ["noti_123", "noti_321"], + * }, + * { + * relations: ["provider"], + * } + * ) + * ``` + * + * By default, only the first `15` records are retrieved. You can control pagination by specifying the `skip` and `take` properties of the `config` parameter: + * + * ```ts + * const notifications = await notificationModuleService.list( + * { + * id: ["noti_123", "noti_321"], + * }, + * { + * relations: ["provider"], + * take: 20, + * skip: 2, + * } + * ) + * ``` + */ + list( + filters?: FilterableNotificationProps, + config?: FindConfig, + sharedContext?: Context + ): Promise + + /** + * This method is used to retrieve a paginated list of notifications along with the total count of available notifications satisfying the provided filters. + * + * @param {FilterableNotificationProps} filters - The filters to apply on the retrieved notifications. + * @param {FindConfig} config - + * The configurations determining how the notifications are retrieved. Its properties, such as `select` or `relations`, accept the + * attributes or relations associated with a notification. + * @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module. + * @returns {Promise} The list of notifications along with the total count. + * + * @example + * To retrieve a list of notifications using their IDs: + * + * ```ts + * const [notifications, count] = + * await notificationModuleService.listAndCount({ + * id: ["noti_123", "noti_321"], + * }) + * ``` + * + * To specify relations that should be retrieved within the notifications: + * + * ```ts + * const [notifications, count] = + * await notificationModuleService.listAndCount( + * { + * id: ["noti_123", "noti_321"], + * }, + * { + * relations: ["provider"], + * } + * ) + * ``` + * + * By default, only the first `15` records are retrieved. You can control pagination by specifying the `skip` and `take` properties of the `config` parameter: + * + * ```ts + * const [notifications, count] = + * await notificationModuleService.listAndCount( + * { + * id: ["noti_123", "noti_321"], + * }, + * { + * relations: ["provider"], + * take: 20, + * skip: 2, + * } + * ) + * ``` + */ + listAndCount( + filters?: FilterableNotificationProps, + config?: FindConfig, + sharedContext?: Context + ): Promise<[NotificationDTO[], number]> +} diff --git a/packages/core/types/src/product/service.ts b/packages/core/types/src/product/service.ts index 285fabd5ea..31850e0d92 100644 --- a/packages/core/types/src/product/service.ts +++ b/packages/core/types/src/product/service.ts @@ -79,7 +79,7 @@ export interface IProductModuleService extends IModuleService { ): Promise /** - * This method is used to retrieve a paginated list of price sets based on optional filters and configuration. + * This method is used to retrieve a paginated list of products based on optional filters and configuration. * * @param {FilterableProductProps} filters - The filters to apply on the retrieved products. * @param {FindConfig} config - diff --git a/packages/core/utils/src/index.ts b/packages/core/utils/src/index.ts index 8847072272..fa32d65f4f 100644 --- a/packages/core/utils/src/index.ts +++ b/packages/core/utils/src/index.ts @@ -24,5 +24,6 @@ export * from "./user" export * from "./api-key" export * from "./link" export * from "./file" +export * from "./notification" export const MedusaModuleType = Symbol.for("MedusaModule") diff --git a/packages/core/utils/src/modules-sdk/definition.ts b/packages/core/utils/src/modules-sdk/definition.ts index e1674e9cd8..c166cee5ca 100644 --- a/packages/core/utils/src/modules-sdk/definition.ts +++ b/packages/core/utils/src/modules-sdk/definition.ts @@ -22,4 +22,5 @@ export enum Modules { STORE = "store", CURRENCY = "currency", FILE = "file", + NOTIFICATION = "notification", } diff --git a/packages/core/utils/src/notification/abstract-notification-provider.ts b/packages/core/utils/src/notification/abstract-notification-provider.ts new file mode 100644 index 0000000000..04b96f55fd --- /dev/null +++ b/packages/core/utils/src/notification/abstract-notification-provider.ts @@ -0,0 +1,15 @@ +import { NotificationTypes, INotificationProvider } from "@medusajs/types" + +export class AbstractNotificationProviderService + implements INotificationProvider +{ + async send( + notification: NotificationTypes.ProviderSendNotificationDTO + ): Promise { + throw Error( + `send is not implemented in ${ + Object.getPrototypeOf(this).constructor.name + }` + ) + } +} diff --git a/packages/core/utils/src/notification/index.ts b/packages/core/utils/src/notification/index.ts new file mode 100644 index 0000000000..1b24b3e068 --- /dev/null +++ b/packages/core/utils/src/notification/index.ts @@ -0,0 +1 @@ +export * from "./abstract-notification-provider" diff --git a/packages/modules/notification/.gitignore b/packages/modules/notification/.gitignore new file mode 100644 index 0000000000..874c6c69d3 --- /dev/null +++ b/packages/modules/notification/.gitignore @@ -0,0 +1,6 @@ +/dist +node_modules +.DS_store +.env* +.env +*.sql diff --git a/packages/modules/notification/CHANGELOG.md b/packages/modules/notification/CHANGELOG.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/notification/README.md b/packages/modules/notification/README.md new file mode 100644 index 0000000000..aab7999616 --- /dev/null +++ b/packages/modules/notification/README.md @@ -0,0 +1 @@ +# Notification Module diff --git a/packages/modules/notification/integration-tests/__fixtures__/providers/default-provider.ts b/packages/modules/notification/integration-tests/__fixtures__/providers/default-provider.ts new file mode 100644 index 0000000000..f9540a6920 --- /dev/null +++ b/packages/modules/notification/integration-tests/__fixtures__/providers/default-provider.ts @@ -0,0 +1,14 @@ +import { NotificationTypes } from "@medusajs/types" +import { AbstractNotificationProviderService } from "@medusajs/utils/src" + +export class NotificationProviderServiceFixtures extends AbstractNotificationProviderService { + static identifier = "fixtures-notification-provider" + + async send( + notification: NotificationTypes.ProviderSendNotificationDTO + ): Promise { + return { id: "external_id" } + } +} + +export const services = [NotificationProviderServiceFixtures] diff --git a/packages/modules/notification/integration-tests/__fixtures__/providers/index.ts b/packages/modules/notification/integration-tests/__fixtures__/providers/index.ts new file mode 100644 index 0000000000..e19230b5b7 --- /dev/null +++ b/packages/modules/notification/integration-tests/__fixtures__/providers/index.ts @@ -0,0 +1 @@ +export * from "./default-provider" diff --git a/packages/modules/notification/integration-tests/__tests__/notification-module-service/index.spec.ts b/packages/modules/notification/integration-tests/__tests__/notification-module-service/index.spec.ts new file mode 100644 index 0000000000..867581c2d4 --- /dev/null +++ b/packages/modules/notification/integration-tests/__tests__/notification-module-service/index.spec.ts @@ -0,0 +1,71 @@ +import { Modules } from "@medusajs/modules-sdk" +import { INotificationModuleService } from "@medusajs/types" +import { + moduleIntegrationTestRunner, + SuiteOptions, +} from "medusa-test-utils/dist" +import { resolve } from "path" + +let moduleOptions = { + providers: [ + { + resolve: resolve( + process.cwd() + + "/integration-tests/__fixtures__/providers/default-provider" + ), + options: { + config: { + "test-provider": { + name: "Test provider", + channels: ["email"], + }, + }, + }, + }, + ], +} + +moduleIntegrationTestRunner({ + moduleName: Modules.NOTIFICATION, + moduleOptions, + testSuite: ({ service }: SuiteOptions) => + describe("Notification Module Service", () => { + it("sends a notification and stores it in the database", async () => { + const notification = { + to: "admin@medusa.com", + template: "some-template", + channel: "email", + data: {}, + } + + const result = await service.create(notification) + expect(result).toEqual( + expect.objectContaining({ + provider_id: "test-provider", + external_id: "external_id", + }) + ) + }) + + it("ensures the same notification is not sent twice", async () => { + const notification = { + to: "admin@medusa.com", + template: "some-template", + channel: "email", + data: {}, + idempotency_key: "idempotency-key", + } + + const result = await service.create(notification) + expect(result).toEqual( + expect.objectContaining({ + provider_id: "test-provider", + external_id: "external_id", + }) + ) + + const secondResult = await service.create(notification) + expect(secondResult).toBe(undefined) + }) + }), +}) diff --git a/packages/modules/notification/jest.config.js b/packages/modules/notification/jest.config.js new file mode 100644 index 0000000000..1b7795a88b --- /dev/null +++ b/packages/modules/notification/jest.config.js @@ -0,0 +1,21 @@ +module.exports = { + moduleNameMapper: { + "^@models": "/src/models", + "^@services": "/src/services", + "^@repositories": "/src/repositories", + "^@types": "/src/types", + "^@utils": "/src/utils", + }, + transform: { + "^.+\\.[jt]s?$": [ + "ts-jest", + { + tsconfig: "tsconfig.spec.json", + isolatedModules: true, + }, + ], + }, + testEnvironment: `node`, + moduleFileExtensions: [`js`, `ts`], + modulePathIgnorePatterns: ["dist/"], +} diff --git a/packages/modules/notification/mikro-orm.config.dev.ts b/packages/modules/notification/mikro-orm.config.dev.ts new file mode 100644 index 0000000000..b1963ac8ac --- /dev/null +++ b/packages/modules/notification/mikro-orm.config.dev.ts @@ -0,0 +1,12 @@ +import * as entities from "./src/models" +import { TSMigrationGenerator } from "@medusajs/utils" + +module.exports = { + entities: Object.values(entities), + schema: "public", + clientUrl: "postgres://postgres@localhost/medusa-notification", + type: "postgresql", + migrations: { + generator: TSMigrationGenerator, + }, +} diff --git a/packages/modules/notification/package.json b/packages/modules/notification/package.json new file mode 100644 index 0000000000..b4fa04512d --- /dev/null +++ b/packages/modules/notification/package.json @@ -0,0 +1,58 @@ +{ + "name": "@medusajs/notification", + "version": "0.1.2", + "description": "Medusa Notification module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "engines": { + "node": ">=16" + }, + "repository": { + "type": "git", + "url": "https://github.com/medusajs/medusa", + "directory": "packages/modules/notification" + }, + "publishConfig": { + "access": "public" + }, + "author": "Medusa", + "license": "MIT", + "scripts": { + "watch": "tsc --build --watch", + "watch:test": "tsc --build tsconfig.spec.json --watch", + "prepublishOnly": "cross-env NODE_ENV=production tsc --build && tsc-alias -p tsconfig.json", + "build": "rimraf dist && tsc --build && tsc-alias -p tsconfig.json", + "test": "jest --runInBand --bail --forceExit --passWithNoTests -- src", + "test:integration": "jest --forceExit -- integration-tests/**/__tests__/**/*.spec.ts", + "migration:generate": " MIKRO_ORM_CLI=./mikro-orm.config.dev.ts mikro-orm migration:generate", + "migration:initial": " MIKRO_ORM_CLI=./mikro-orm.config.dev.ts mikro-orm migration:create --initial -n InitialSetupMigration", + "migration:create": " MIKRO_ORM_CLI=./mikro-orm.config.dev.ts mikro-orm migration:create", + "migration:up": " MIKRO_ORM_CLI=./mikro-orm.config.dev.ts mikro-orm migration:up", + "orm:cache:clear": " MIKRO_ORM_CLI=./mikro-orm.config.dev.ts mikro-orm cache:clear" + }, + "devDependencies": { + "@mikro-orm/cli": "5.9.7", + "cross-env": "^5.2.1", + "jest": "^29.6.3", + "medusa-test-utils": "^1.1.44", + "rimraf": "^3.0.2", + "ts-jest": "^29.1.1", + "ts-node": "^10.9.1", + "tsc-alias": "^1.8.6", + "typescript": "^5.1.6" + }, + "dependencies": { + "@medusajs/modules-sdk": "^1.12.11", + "@medusajs/types": "^1.11.16", + "@medusajs/utils": "^1.11.9", + "@mikro-orm/core": "5.9.7", + "@mikro-orm/migrations": "5.9.7", + "@mikro-orm/postgresql": "5.9.7", + "awilix": "^8.0.0", + "dotenv": "^16.4.5", + "knex": "2.4.2" + } +} diff --git a/packages/modules/notification/src/index.ts b/packages/modules/notification/src/index.ts new file mode 100644 index 0000000000..0bd8f8a930 --- /dev/null +++ b/packages/modules/notification/src/index.ts @@ -0,0 +1,14 @@ +import { moduleDefinition } from "./module-definition" +import { initializeFactory, Modules } from "@medusajs/modules-sdk" + +export * from "./types" +export * from "./models" +export * from "./services" + +export const initialize = initializeFactory({ + moduleName: Modules.NOTIFICATION, + moduleDefinition, +}) +export const runMigrations = moduleDefinition.runMigrations +export const revertMigration = moduleDefinition.revertMigration +export default moduleDefinition diff --git a/packages/modules/notification/src/joiner-config.ts b/packages/modules/notification/src/joiner-config.ts new file mode 100644 index 0000000000..5f1a5203c4 --- /dev/null +++ b/packages/modules/notification/src/joiner-config.ts @@ -0,0 +1,33 @@ +import { Modules } from "@medusajs/modules-sdk" +import { ModuleJoinerConfig } from "@medusajs/types" +import { MapToConfig } from "@medusajs/utils" +import { NotificationModel } from "@models" + +export const LinkableKeys: Record = { + notification_id: NotificationModel.name, +} + +const entityLinkableKeysMap: MapToConfig = {} +Object.entries(LinkableKeys).forEach(([key, value]) => { + entityLinkableKeysMap[value] ??= [] + entityLinkableKeysMap[value].push({ + mapTo: key, + valueFrom: key.split("_").pop()!, + }) +}) + +export const entityNameToLinkableKeysMap: MapToConfig = entityLinkableKeysMap + +export const joinerConfig: ModuleJoinerConfig = { + serviceName: Modules.NOTIFICATION, + primaryKeys: ["id"], + linkableKeys: LinkableKeys, + alias: [ + { + name: ["notification", "notifications"], + args: { + entity: NotificationModel.name, + }, + }, + ], +} as ModuleJoinerConfig diff --git a/packages/modules/notification/src/loaders/providers.ts b/packages/modules/notification/src/loaders/providers.ts new file mode 100644 index 0000000000..9fc659f140 --- /dev/null +++ b/packages/modules/notification/src/loaders/providers.ts @@ -0,0 +1,132 @@ +import { moduleProviderLoader } from "@medusajs/modules-sdk" +import { LoaderOptions, ModuleProvider, ModulesSdkTypes } from "@medusajs/types" +import { + ContainerRegistrationKeys, + lowerCaseFirst, + promiseAll, +} from "@medusajs/utils" +import { NotificationProvider } from "@models" +import { NotificationProviderService } from "@services" +import { + NotificationIdentifiersRegistrationName, + NotificationProviderRegistrationPrefix, +} from "@types" +import { Lifetime, asFunction, asValue } from "awilix" + +const registrationFn = async (klass, container, pluginOptions) => { + Object.entries(pluginOptions.config || []).map(([name, config]) => { + container.register({ + [NotificationProviderRegistrationPrefix + name]: asFunction( + (cradle) => new klass(cradle, config), + { + lifetime: klass.LIFE_TIME || Lifetime.SINGLETON, + } + ), + }) + + container.registerAdd( + NotificationIdentifiersRegistrationName, + asValue(name) + ) + }) +} + +export default async ({ + container, + options, +}: LoaderOptions< + ( + | ModulesSdkTypes.ModuleServiceInitializeOptions + | ModulesSdkTypes.ModuleServiceInitializeCustomDataLayerOptions + ) & { providers: ModuleProvider[] } +>): Promise => { + await moduleProviderLoader({ + container, + providers: options?.providers || [], + registerServiceFn: registrationFn, + }) + + await syncDatabaseProviders({ + container, + providers: options?.providers || [], + }) +} + +async function syncDatabaseProviders({ + container, + providers, +}: { + container: any + providers: ModuleProvider[] +}) { + const providerServiceRegistrationKey = lowerCaseFirst( + NotificationProviderService.name + ) + const providerService: ModulesSdkTypes.InternalModuleService = + container.resolve(providerServiceRegistrationKey) + + const logger = container.resolve(ContainerRegistrationKeys.LOGGER) ?? console + const normalizedProviders = providers.map((provider) => { + const [name, config] = Object.entries( + provider.options?.config as any + )?.[0] as any + if (!name) { + throw new Error( + "An entry in the provider config is required to initialize notification providers" + ) + } + + const id = name + return { + id, + handle: name, + name: config?.name ?? name, + is_enabled: true, + channels: config?.channels ?? [], + } + }) + + validateProviders(normalizedProviders) + + try { + const providersInDb = await providerService.list({}) + const providersToDisable = providersInDb.filter( + (dbProvider) => + !normalizedProviders.some( + (normalizedProvider) => normalizedProvider.id === dbProvider.id + ) + ) + + const promises: Promise[] = [] + + if (normalizedProviders.length) { + promises.push(providerService.upsert(normalizedProviders)) + } + + if (providersToDisable.length) { + promises.push( + providerService.update( + providersToDisable.map((p) => ({ id: p.id, is_enabled: false })) + ) + ) + } + + await promiseAll(promises) + } catch (error) { + logger.error(`Error syncing the notification providers: ${error.message}`) + } +} + +function validateProviders(providers: { channels: string[] }[]) { + const hasForChannel = {} + providers.forEach((provider) => { + provider.channels.forEach((channel) => { + if (hasForChannel[channel]) { + throw new Error( + `Multiple providers are configured for the same channel: ${channel}` + ) + } + hasForChannel[channel] = true + }) + }) +} diff --git a/packages/modules/notification/src/migrations/.snapshot-medusa-notification.json b/packages/modules/notification/src/migrations/.snapshot-medusa-notification.json new file mode 100644 index 0000000000..7c3b11312c --- /dev/null +++ b/packages/modules/notification/src/migrations/.snapshot-medusa-notification.json @@ -0,0 +1,258 @@ +{ + "namespaces": [ + "public" + ], + "name": "public", + "tables": [ + { + "columns": { + "id": { + "name": "id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "handle": { + "name": "handle", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "name": { + "name": "name", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "default": "true", + "mappedType": "boolean" + }, + "channels": { + "name": "channels", + "type": "text[]", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "array" + } + }, + "name": "notification_provider", + "schema": "public", + "indexes": [ + { + "keyName": "notification_provider_pkey", + "columnNames": [ + "id" + ], + "composite": false, + "primary": true, + "unique": true + } + ], + "checks": [], + "foreignKeys": {} + }, + { + "columns": { + "id": { + "name": "id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "to": { + "name": "to", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "channel": { + "name": "channel", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "template": { + "name": "template", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "data": { + "name": "data", + "type": "jsonb", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "json" + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "text" + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "text" + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "text" + }, + "receiver_id": { + "name": "receiver_id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "text" + }, + "original_notification_id": { + "name": "original_notification_id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "text" + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "text" + }, + "external_id": { + "name": "external_id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "text" + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "text" + }, + "created_at": { + "name": "created_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 6, + "default": "now()", + "mappedType": "datetime" + } + }, + "name": "notification", + "schema": "public", + "indexes": [ + { + "keyName": "IDX_notification_receiver_id", + "columnNames": [], + "composite": false, + "primary": false, + "unique": false, + "expression": "CREATE INDEX IF NOT EXISTS \"IDX_notification_receiver_id\" ON \"notification\" (receiver_id)" + }, + { + "keyName": "IDX_notification_idempotency_key", + "columnNames": [], + "composite": false, + "primary": false, + "unique": false, + "expression": "CREATE INDEX IF NOT EXISTS \"IDX_notification_idempotency_key\" ON \"notification\" (idempotency_key)" + }, + { + "keyName": "IDX_notification_provider_id", + "columnNames": [], + "composite": false, + "primary": false, + "unique": false, + "expression": "CREATE INDEX IF NOT EXISTS \"IDX_notification_provider_id\" ON \"notification\" (provider_id)" + }, + { + "keyName": "notification_pkey", + "columnNames": [ + "id" + ], + "composite": false, + "primary": true, + "unique": true + } + ], + "checks": [], + "foreignKeys": { + "notification_provider_id_foreign": { + "constraintName": "notification_provider_id_foreign", + "columnNames": [ + "provider_id" + ], + "localTableName": "public.notification", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "public.notification_provider", + "deleteRule": "set null", + "updateRule": "cascade" + } + } + } + ] +} diff --git a/packages/modules/notification/src/migrations/Migration20240509083918_InitialSetupMigration.ts b/packages/modules/notification/src/migrations/Migration20240509083918_InitialSetupMigration.ts new file mode 100644 index 0000000000..09f4fe41c9 --- /dev/null +++ b/packages/modules/notification/src/migrations/Migration20240509083918_InitialSetupMigration.ts @@ -0,0 +1,29 @@ +import { Migration } from "@mikro-orm/migrations" + +export class Migration20240509083918_InitialSetupMigration extends Migration { + async up(): Promise { + this.addSql("drop table if exists notification") + this.addSql("drop table if exists notification_provider") + + this.addSql( + 'create table if not exists "notification_provider" ("id" text not null, "handle" text not null, "name" text not null, "is_enabled" boolean not null default true, "channels" text[] not null, constraint "notification_provider_pkey" primary key ("id"));' + ) + + this.addSql( + 'create table if not exists "notification" ("id" text not null, "to" text not null, "channel" text not null, "template" text not null, "data" jsonb null, "trigger_type" text null, "resource_id" text null, "resource_type" text null, "receiver_id" text null, "original_notification_id" text null, "idempotency_key" text null, "external_id" text null, "provider_id" text null, "created_at" timestamptz not null default now(), constraint "notification_pkey" primary key ("id"));' + ) + this.addSql( + 'CREATE INDEX IF NOT EXISTS "IDX_notification_provider_id" ON "notification" (provider_id);' + ) + this.addSql( + 'CREATE INDEX IF NOT EXISTS "IDX_notification_idempotency_key" ON "notification" (idempotency_key);' + ) + this.addSql( + 'CREATE INDEX IF NOT EXISTS "IDX_notification_receiver_id" ON "notification" (receiver_id);' + ) + + this.addSql( + 'alter table if exists "notification" add constraint "notification_provider_id_foreign" foreign key ("provider_id") references "notification_provider" ("id") on update cascade on delete set null;' + ) + } +} diff --git a/packages/modules/notification/src/models/index.ts b/packages/modules/notification/src/models/index.ts new file mode 100644 index 0000000000..297e2809d1 --- /dev/null +++ b/packages/modules/notification/src/models/index.ts @@ -0,0 +1,2 @@ +export { default as NotificationModel } from "./notification" +export { default as NotificationProvider } from "./notification-provider" diff --git a/packages/modules/notification/src/models/notification-provider.ts b/packages/modules/notification/src/models/notification-provider.ts new file mode 100644 index 0000000000..56d3c081e0 --- /dev/null +++ b/packages/modules/notification/src/models/notification-provider.ts @@ -0,0 +1,46 @@ +import { generateEntityId } from "@medusajs/utils" +import { + ArrayType, + BeforeCreate, + Collection, + Entity, + OnInit, + OneToMany, + PrimaryKey, + Property, +} from "@mikro-orm/core" +import NotificationModel from "./notification" + +@Entity() +export default class NotificationProvider { + @PrimaryKey({ columnType: "text" }) + id: string + + @Property({ columnType: "text" }) + handle: string + + @Property({ columnType: "text" }) + name: string + + @Property({ columnType: "boolean", defaultRaw: "true" }) + is_enabled: boolean = true + + @Property({ type: ArrayType }) + channels: string[] + + @OneToMany({ + entity: () => NotificationModel, + mappedBy: (notification) => notification.provider_id, + }) + notifications = new Collection(this) + + @BeforeCreate() + onCreate() { + this.id = generateEntityId(this.id, "notpro") + } + + @OnInit() + onInit() { + this.id = generateEntityId(this.id, "notpro") + } +} diff --git a/packages/modules/notification/src/models/notification.ts b/packages/modules/notification/src/models/notification.ts new file mode 100644 index 0000000000..9bbf504b05 --- /dev/null +++ b/packages/modules/notification/src/models/notification.ts @@ -0,0 +1,109 @@ +import { + createPsqlIndexStatementHelper, + generateEntityId, +} from "@medusajs/utils" + +import { + BeforeCreate, + Entity, + ManyToOne, + OnInit, + PrimaryKey, + Property, +} from "@mikro-orm/core" +import NotificationProvider from "./notification-provider" + +const NotificationProviderIdIndex = createPsqlIndexStatementHelper({ + tableName: "notification", + columns: "provider_id", +}) + +const NotificationIdempotencyKeyIndex = createPsqlIndexStatementHelper({ + tableName: "notification", + columns: "idempotency_key", +}) + +const NotificationReceiverIdIndex = createPsqlIndexStatementHelper({ + tableName: "notification", + columns: "receiver_id", +}) + +// We don't need to support soft deletes here as this information is mainly used for auditing purposes. +// Instead, we probably want to have a TTL for each entry, so we don't bloat the DB (and also for GDPR reasons if TTL < 30 days). +@NotificationProviderIdIndex.MikroORMIndex() +@NotificationIdempotencyKeyIndex.MikroORMIndex() +@NotificationReceiverIdIndex.MikroORMIndex() +@Entity({ tableName: "notification" }) +// Since there is a native `Notification` type, we have to call this something else here and in a couple of other places. +export default class NotificationModel { + @PrimaryKey({ columnType: "text" }) + id: string + + // This can be an email, phone number, or username, depending on the channel. + @Property({ columnType: "text" }) + to: string + + @Property({ columnType: "text" }) + channel: string + + // The template name in the provider's system. + @Property({ columnType: "text" }) + template: string + + // The data that gets passed over to the provider for rendering the notification. + @Property({ columnType: "jsonb", nullable: true }) + data: Record | null + + // This can be the event name, the workflow, or anything else that can help to identify what triggered the notification. + @Property({ columnType: "text", nullable: true }) + trigger_type?: string | null + + // The ID of the resource this notification is for, if applicable. Useful for displaying relevant information in the UI + @Property({ columnType: "text", nullable: true }) + resource_id?: string | null + + // The typeame of the resource this notification is for, if applicable, eg. "order" + @Property({ columnType: "text", nullable: true }) + resource_type?: string | null + + // The ID of the receiver of the notification, if applicable. This can be a customer, user, a company, or anything else. + @Property({ columnType: "text", nullable: true }) + receiver_id?: string | null + + // The original notification, in case this is a retried notification. + @Property({ columnType: "text", nullable: true }) + original_notification_id?: string | null + + @Property({ columnType: "text", nullable: true }) + idempotency_key?: string | null + + // The ID of the notification in the external system, if applicable + @Property({ columnType: "text", nullable: true }) + external_id?: string | null + + @ManyToOne(() => NotificationProvider, { + columnType: "text", + fieldName: "provider_id", + mapToPk: true, + nullable: true, + onDelete: "set null", + }) + provider_id: string + + @ManyToOne(() => NotificationProvider, { persist: false }) + provider: NotificationProvider + + @Property({ + onCreate: () => new Date(), + columnType: "timestamptz", + defaultRaw: "now()", + }) + created_at: Date + + @BeforeCreate() + @OnInit() + onCreate() { + this.id = generateEntityId(this.id, "noti") + this.provider_id ??= this.provider_id ?? this.provider?.id + } +} diff --git a/packages/modules/notification/src/module-definition.ts b/packages/modules/notification/src/module-definition.ts new file mode 100644 index 0000000000..8d43ebb923 --- /dev/null +++ b/packages/modules/notification/src/module-definition.ts @@ -0,0 +1,44 @@ +import { ModuleExports } from "@medusajs/types" +import * as ModuleServices from "@services" +import { NotificationModuleService } from "@services" +import { Modules } from "@medusajs/modules-sdk" +import * as ModuleModels from "@models" +import { ModulesSdkUtils } from "@medusajs/utils" +import * as ModuleRepositories from "@repositories" +import loadProviders from "./loaders/providers" + +const migrationScriptOptions = { + moduleName: Modules.NOTIFICATION, + models: ModuleModels, + pathToMigrations: __dirname + "/migrations", +} + +const runMigrations = ModulesSdkUtils.buildMigrationScript( + migrationScriptOptions +) + +const revertMigration = ModulesSdkUtils.buildRevertMigrationScript( + migrationScriptOptions +) + +const containerLoader = ModulesSdkUtils.moduleContainerLoaderFactory({ + moduleModels: ModuleModels, + moduleRepositories: ModuleRepositories, + moduleServices: ModuleServices, +}) + +const connectionLoader = ModulesSdkUtils.mikroOrmConnectionLoaderFactory({ + moduleName: Modules.NOTIFICATION, + moduleModels: Object.values(ModuleModels), + migrationsPath: __dirname + "/migrations", +}) + +const service = NotificationModuleService +const loaders = [containerLoader, connectionLoader, loadProviders] + +export const moduleDefinition: ModuleExports = { + service, + loaders, + revertMigration, + runMigrations, +} diff --git a/packages/modules/notification/src/repositories/index.ts b/packages/modules/notification/src/repositories/index.ts new file mode 100644 index 0000000000..147c9cc259 --- /dev/null +++ b/packages/modules/notification/src/repositories/index.ts @@ -0,0 +1 @@ +export { MikroOrmBaseRepository as BaseRepository } from "@medusajs/utils" diff --git a/packages/modules/notification/src/services/index.ts b/packages/modules/notification/src/services/index.ts new file mode 100644 index 0000000000..cccea76f68 --- /dev/null +++ b/packages/modules/notification/src/services/index.ts @@ -0,0 +1,2 @@ +export { default as NotificationModuleService } from "./notification-module-service" +export { default as NotificationProviderService } from "./notification-provider" diff --git a/packages/modules/notification/src/services/notification-module-service.ts b/packages/modules/notification/src/services/notification-module-service.ts new file mode 100644 index 0000000000..32f20674a8 --- /dev/null +++ b/packages/modules/notification/src/services/notification-module-service.ts @@ -0,0 +1,153 @@ +import { + Context, + DAL, + NotificationTypes, + INotificationModuleService, + InternalModuleDeclaration, + ModuleJoinerConfig, + ModulesSdkTypes, +} from "@medusajs/types" +import { + InjectManager, + InjectTransactionManager, + MedusaContext, + ModulesSdkUtils, + promiseAll, + MedusaError, +} from "@medusajs/utils" +import { entityNameToLinkableKeysMap, joinerConfig } from "../joiner-config" +import NotificationProviderService from "./notification-provider" +import { NotificationModel, NotificationProvider } from "@models" + +const generateMethodForModels = [NotificationProvider] + +type InjectedDependencies = { + baseRepository: DAL.RepositoryService + notificationModelService: ModulesSdkTypes.InternalModuleService + notificationProviderService: NotificationProviderService +} + +export default class NotificationModuleService< + TEntity extends NotificationModel = NotificationModel + > + extends ModulesSdkUtils.abstractModuleServiceFactory< + InjectedDependencies, + NotificationTypes.NotificationDTO, + { + NotificationProvider: { dto: NotificationTypes.NotificationProviderDTO } + } + >(NotificationModel, generateMethodForModels, entityNameToLinkableKeysMap) + implements INotificationModuleService +{ + protected baseRepository_: DAL.RepositoryService + protected readonly notificationService_: ModulesSdkTypes.InternalModuleService + protected readonly notificationProviderService_: NotificationProviderService + + constructor( + { + baseRepository, + notificationModelService, + notificationProviderService, + }: InjectedDependencies, + protected readonly moduleDeclaration: InternalModuleDeclaration + ) { + // @ts-ignore + super(...arguments) + this.baseRepository_ = baseRepository + this.notificationService_ = notificationModelService + this.notificationProviderService_ = notificationProviderService + } + + __joinerConfig(): ModuleJoinerConfig { + return joinerConfig + } + create( + data: NotificationTypes.CreateNotificationDTO[], + sharedContext?: Context + ): Promise + create( + data: NotificationTypes.CreateNotificationDTO, + sharedContext?: Context + ): Promise + + @InjectManager("baseRepository_") + async create( + data: + | NotificationTypes.CreateNotificationDTO + | NotificationTypes.CreateNotificationDTO[], + @MedusaContext() sharedContext: Context = {} + ): Promise< + NotificationTypes.NotificationDTO | NotificationTypes.NotificationDTO[] + > { + const normalized = Array.isArray(data) ? data : [data] + + const createdNotifications = await this.create_(normalized, sharedContext) + + const serialized = await this.baseRepository_.serialize< + NotificationTypes.NotificationDTO[] + >(createdNotifications) + + return Array.isArray(data) ? serialized : serialized[0] + } + + @InjectTransactionManager("baseRepository_") + protected async create_( + data: NotificationTypes.CreateNotificationDTO[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + if (!data.length) { + return [] + } + + // TODO: At this point we should probably take a lock with the idempotency keys so we don't have race conditions. + // Also, we should probably rely on Redis for this instead of the database. + const idempotencyKeys = data + .map((entry) => entry.idempotency_key) + .filter(Boolean) + const alreadySentNotifications = await this.notificationService_.list( + { + idempotency_key: idempotencyKeys, + }, + { take: null }, + sharedContext + ) + const existsMap = new Map( + alreadySentNotifications.map((n) => [n.idempotency_key, true]) + ) + + const notificationsToProcess = data.filter( + (entry) => !existsMap.has(entry.idempotency_key) + ) + + const notificationsToCreate = await promiseAll( + notificationsToProcess.map(async (entry) => { + const provider = + await this.notificationProviderService_.getProviderForChannel( + entry.channel + ) + + if (!provider) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Could not find a notification provider for channel: ${entry.channel}` + ) + } + + const res = await this.notificationProviderService_.send( + provider, + entry + ) + return { ...entry, provider_id: provider.id, external_id: res.id } + }) + ) + + // Currently we store notifications after they are sent, which might result in a notification being sent that is not registered in the database. + // If necessary, we can switch to a two-step process where we first create the notification, send it, and update it after it being sent. + const createdNotifications = await this.notificationService_.create( + notificationsToCreate, + sharedContext + ) + + return createdNotifications + } +} diff --git a/packages/modules/notification/src/services/notification-provider.ts b/packages/modules/notification/src/services/notification-provider.ts new file mode 100644 index 0000000000..7b2c49227e --- /dev/null +++ b/packages/modules/notification/src/services/notification-provider.ts @@ -0,0 +1,64 @@ +import { Constructor, DAL, NotificationTypes } from "@medusajs/types" +import { ModulesSdkUtils } from "@medusajs/utils" +import { MedusaError } from "medusa-core-utils" +import { NotificationProvider } from "@models" +import { NotificationProviderRegistrationPrefix } from "@types" + +type InjectedDependencies = { + notificationProviderRepository: DAL.RepositoryService + [ + key: `${typeof NotificationProviderRegistrationPrefix}${string}` + ]: NotificationTypes.INotificationProvider +} + +export default class NotificationProviderService extends ModulesSdkUtils.internalModuleServiceFactory( + NotificationProvider +) { + protected readonly notificationProviderRepository_: DAL.RepositoryService + // We can store the providers in a memory since they can only be registered on startup and not changed during runtime + protected providersCache: Map + + constructor(container: InjectedDependencies) { + super(container) + this.notificationProviderRepository_ = + container.notificationProviderRepository + } + + protected retrieveProviderRegistration( + providerId: string + ): NotificationTypes.INotificationProvider { + try { + return this.__container__[ + `${NotificationProviderRegistrationPrefix}${providerId}` + ] + } catch (err) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Could not find a notification provider with id: ${providerId}` + ) + } + } + + async getProviderForChannel( + channel: string + ): Promise { + if (!this.providersCache) { + const providers = await this.notificationProviderRepository_.find() + this.providersCache = new Map( + providers.flatMap((provider) => + provider.channels.map((c) => [c, provider]) + ) + ) + } + + return this.providersCache.get(channel) + } + + async send( + provider: NotificationProvider, + notification: NotificationTypes.ProviderSendNotificationDTO + ): Promise { + const providerHandler = this.retrieveProviderRegistration(provider.id) + return await providerHandler.send(notification) + } +} diff --git a/packages/modules/notification/src/types/index.ts b/packages/modules/notification/src/types/index.ts new file mode 100644 index 0000000000..6d05e33dda --- /dev/null +++ b/packages/modules/notification/src/types/index.ts @@ -0,0 +1,33 @@ +import { + Logger, + ModuleProviderExports, + ModuleServiceInitializeOptions, +} from "@medusajs/types" + +export type InitializeModuleInjectableDependencies = { + logger?: Logger +} + +export const NotificationIdentifiersRegistrationName = + "notification_providers_identifier" + +export const NotificationProviderRegistrationPrefix = "np_" + +export type NotificationModuleOptions = + Partial & { + /** + * Providers to be registered + */ + providers?: { + /** + * The module provider to be registered + */ + resolve: string | ModuleProviderExports + options: { + /** + * key value pair of the provider name and the configuration to be passed to the provider constructor + */ + config: Record + } + }[] + } diff --git a/packages/modules/notification/src/utils/index.ts b/packages/modules/notification/src/utils/index.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/notification/tsconfig.json b/packages/modules/notification/tsconfig.json new file mode 100644 index 0000000000..7adee6410c --- /dev/null +++ b/packages/modules/notification/tsconfig.json @@ -0,0 +1,38 @@ +{ + "compilerOptions": { + "lib": ["es2020"], + "target": "es2020", + "outDir": "./dist", + "esModuleInterop": true, + "declaration": true, + "module": "commonjs", + "moduleResolution": "node", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "sourceMap": false, + "noImplicitReturns": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "noImplicitThis": true, + "allowJs": true, + "skipLibCheck": true, + "downlevelIteration": true, // to use ES5 specific tooling + "baseUrl": ".", + "resolveJsonModule": true, + "paths": { + "@models": ["./src/models"], + "@services": ["./src/services"], + "@repositories": ["./src/repositories"], + "@types": ["./src/types"], + "@utils": ["./src/utils"] + } + }, + "include": ["src"], + "exclude": [ + "dist", + "./src/**/__tests__", + "./src/**/__mocks__", + "./src/**/__fixtures__", + "node_modules" + ] +} diff --git a/packages/modules/notification/tsconfig.spec.json b/packages/modules/notification/tsconfig.spec.json new file mode 100644 index 0000000000..48e47e8cbb --- /dev/null +++ b/packages/modules/notification/tsconfig.spec.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "include": ["src", "integration-tests"], + "exclude": ["node_modules", "dist"], + "compilerOptions": { + "sourceMap": true + } +} diff --git a/yarn.lock b/yarn.lock index 26a5047fb5..3d23d24fbf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5473,6 +5473,31 @@ __metadata: languageName: unknown linkType: soft +"@medusajs/notification@workspace:packages/modules/notification": + version: 0.0.0-use.local + resolution: "@medusajs/notification@workspace:packages/modules/notification" + dependencies: + "@medusajs/modules-sdk": ^1.12.11 + "@medusajs/types": ^1.11.16 + "@medusajs/utils": ^1.11.9 + "@mikro-orm/cli": 5.9.7 + "@mikro-orm/core": 5.9.7 + "@mikro-orm/migrations": 5.9.7 + "@mikro-orm/postgresql": 5.9.7 + awilix: ^8.0.0 + cross-env: ^5.2.1 + dotenv: ^16.4.5 + jest: ^29.6.3 + knex: 2.4.2 + medusa-test-utils: ^1.1.44 + rimraf: ^3.0.2 + ts-jest: ^29.1.1 + ts-node: ^10.9.1 + tsc-alias: ^1.8.6 + typescript: ^5.1.6 + languageName: unknown + linkType: soft + "@medusajs/oas-github-ci@workspace:packages/cli/oas/oas-github-ci": version: 0.0.0-use.local resolution: "@medusajs/oas-github-ci@workspace:packages/cli/oas/oas-github-ci"