diff --git a/integration-tests/modules/__tests__/notification/admin/notification.spec.ts b/integration-tests/modules/__tests__/notification/admin/notification.spec.ts index d25d4ec8a1..73f68ebc48 100644 --- a/integration-tests/modules/__tests__/notification/admin/notification.spec.ts +++ b/integration-tests/modules/__tests__/notification/admin/notification.spec.ts @@ -10,9 +10,7 @@ import { medusaIntegrationTestRunner, TestEventUtils } from "medusa-test-utils" jest.setTimeout(50000) -const env = { MEDUSA_FF_MEDUSA_V2: true } medusaIntegrationTestRunner({ - env, testSuite: ({ getContainer }) => { describe("Notifications", () => { let service: INotificationModuleService diff --git a/packages/core/utils/src/modules-sdk/medusa-internal-service.ts b/packages/core/utils/src/modules-sdk/medusa-internal-service.ts index a548fb5772..5cade9bee0 100644 --- a/packages/core/utils/src/modules-sdk/medusa-internal-service.ts +++ b/packages/core/utils/src/modules-sdk/medusa-internal-service.ts @@ -28,6 +28,7 @@ import { InjectTransactionManager, MedusaContext, } from "./decorators" +import { DmlEntity, toMikroORMEntity } from "../dml" type SelectorAndData = { selector: FilterQuery | BaseFilterable> @@ -35,12 +36,16 @@ type SelectorAndData = { } export function MedusaInternalService( - model: any + rawModel: any ): { new ( container: TContainer ): ModulesSdkTypes.IMedusaInternalService } { + const model = DmlEntity.isDmlEntity(rawModel) + ? toMikroORMEntity(rawModel) + : rawModel + const injectedRepositoryName = `${lowerCaseFirst(model.name)}Repository` const propertyRepositoryName = `__${injectedRepositoryName}__` diff --git a/packages/modules/notification/mikro-orm.config.dev.ts b/packages/modules/notification/mikro-orm.config.dev.ts index b1963ac8ac..42d11e9c9e 100644 --- a/packages/modules/notification/mikro-orm.config.dev.ts +++ b/packages/modules/notification/mikro-orm.config.dev.ts @@ -1,12 +1,8 @@ import * as entities from "./src/models" -import { TSMigrationGenerator } from "@medusajs/utils" +import { defineMikroOrmCliConfig } from "@medusajs/utils" -module.exports = { +module.exports = defineMikroOrmCliConfig({ entities: Object.values(entities), schema: "public", - clientUrl: "postgres://postgres@localhost/medusa-notification", - type: "postgresql", - migrations: { - generator: TSMigrationGenerator, - }, -} + databaseName: "medusa-notification", +}) diff --git a/packages/modules/notification/src/loaders/providers.ts b/packages/modules/notification/src/loaders/providers.ts index 291e046528..3c9adb56d0 100644 --- a/packages/modules/notification/src/loaders/providers.ts +++ b/packages/modules/notification/src/loaders/providers.ts @@ -1,5 +1,11 @@ import { moduleProviderLoader } from "@medusajs/modules-sdk" -import { LoaderOptions, ModuleProvider, ModulesSdkTypes } from "@medusajs/types" +import { + DAL, + InferEntityType, + LoaderOptions, + ModuleProvider, + ModulesSdkTypes, +} from "@medusajs/types" import { ContainerRegistrationKeys, lowerCaseFirst, @@ -62,8 +68,9 @@ async function syncDatabaseProviders({ const providerServiceRegistrationKey = lowerCaseFirst( NotificationProviderService.name ) - const providerService: ModulesSdkTypes.IMedusaInternalService = - container.resolve(providerServiceRegistrationKey) + const providerService: ModulesSdkTypes.IMedusaInternalService< + typeof NotificationProvider + > = container.resolve(providerServiceRegistrationKey) const logger = container.resolve(ContainerRegistrationKeys.LOGGER) ?? console const normalizedProviders = providers.map((provider) => { @@ -106,7 +113,10 @@ async function syncDatabaseProviders({ if (providersToDisable.length) { promises.push( providerService.update( - providersToDisable.map((p) => ({ id: p.id, is_enabled: false })) + providersToDisable.map((p) => ({ + entity: p, + update: { is_enabled: false }, + })) ) ) } diff --git a/packages/modules/notification/src/migrations/.snapshot-medusa-notification.json b/packages/modules/notification/src/migrations/.snapshot-medusa-notification.json index 7c3b11312c..d3338923df 100644 --- a/packages/modules/notification/src/migrations/.snapshot-medusa-notification.json +++ b/packages/modules/notification/src/migrations/.snapshot-medusa-notification.json @@ -50,7 +50,40 @@ "autoincrement": false, "primary": false, "nullable": false, + "default": "'{}'", "mappedType": "array" + }, + "created_at": { + "name": "created_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 6, + "default": "now()", + "mappedType": "datetime" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 6, + "default": "now()", + "mappedType": "datetime" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 6, + "mappedType": "datetime" } }, "name": "notification_provider", @@ -198,6 +231,27 @@ "length": 6, "default": "now()", "mappedType": "datetime" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 6, + "default": "now()", + "mappedType": "datetime" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 6, + "mappedType": "datetime" } }, "name": "notification", @@ -209,15 +263,15 @@ "composite": false, "primary": false, "unique": false, - "expression": "CREATE INDEX IF NOT EXISTS \"IDX_notification_receiver_id\" ON \"notification\" (receiver_id)" + "expression": "CREATE INDEX IF NOT EXISTS \"IDX_notification_receiver_id\" ON \"notification\" (receiver_id) WHERE deleted_at IS NULL" }, { - "keyName": "IDX_notification_idempotency_key", + "keyName": "IDX_notification_idempotency_key_unique", "columnNames": [], "composite": false, "primary": false, "unique": false, - "expression": "CREATE INDEX IF NOT EXISTS \"IDX_notification_idempotency_key\" ON \"notification\" (idempotency_key)" + "expression": "CREATE UNIQUE INDEX IF NOT EXISTS \"IDX_notification_idempotency_key_unique\" ON \"notification\" (idempotency_key) WHERE deleted_at IS NULL" }, { "keyName": "IDX_notification_provider_id", @@ -225,7 +279,7 @@ "composite": false, "primary": false, "unique": false, - "expression": "CREATE INDEX IF NOT EXISTS \"IDX_notification_provider_id\" ON \"notification\" (provider_id)" + "expression": "CREATE INDEX IF NOT EXISTS \"IDX_notification_provider_id\" ON \"notification\" (provider_id) WHERE deleted_at IS NULL" }, { "keyName": "notification_pkey", diff --git a/packages/modules/notification/src/migrations/Migration20240628075401.ts b/packages/modules/notification/src/migrations/Migration20240628075401.ts new file mode 100644 index 0000000000..7c6a532213 --- /dev/null +++ b/packages/modules/notification/src/migrations/Migration20240628075401.ts @@ -0,0 +1,23 @@ +import { Migration } from "@mikro-orm/migrations" + +export class Migration20240628075401 extends Migration { + async up(): Promise { + this.addSql( + 'alter table if exists "notification_provider" add column if not exists "created_at" timestamptz not null default now(), add column "updated_at" timestamptz not null default now(), add column "deleted_at" timestamptz null;' + ) + this.addSql( + 'alter table if exists "notification_provider" alter column "channels" type text[] using ("channels"::text[]);' + ) + this.addSql( + 'alter table if exists "notification_provider" alter column "channels" set default \'{}\';' + ) + + this.addSql( + 'alter table if exists "notification" add column if not exists "updated_at" timestamptz not null default now(), add column "deleted_at" timestamptz null;' + ) + this.addSql('drop index if exists "IDX_notification_idempotency_key";') + this.addSql( + 'CREATE UNIQUE INDEX IF NOT EXISTS "IDX_notification_idempotency_key_unique" ON "notification" (idempotency_key) WHERE deleted_at IS NULL;' + ) + } +} diff --git a/packages/modules/notification/src/models/index.ts b/packages/modules/notification/src/models/index.ts index a6486c89a6..7dcc270e4c 100644 --- a/packages/modules/notification/src/models/index.ts +++ b/packages/modules/notification/src/models/index.ts @@ -1,3 +1,2 @@ -export { default as Notification } from "./notification" -export { default as NotificationProvider } from "./notification-provider" - +export { Notification } from "./notification" +export { NotificationProvider } from "./notification-provider" diff --git a/packages/modules/notification/src/models/notification-provider.ts b/packages/modules/notification/src/models/notification-provider.ts index a795e9e7e2..bb2d6e6a33 100644 --- a/packages/modules/notification/src/models/notification-provider.ts +++ b/packages/modules/notification/src/models/notification-provider.ts @@ -1,46 +1,11 @@ -import { generateEntityId } from "@medusajs/utils" -import { - ArrayType, - BeforeCreate, - Collection, - Entity, - OnInit, - OneToMany, - PrimaryKey, - Property, -} from "@mikro-orm/core" -import Notification from "./notification" +import { model } from "@medusajs/utils" +import { Notification } 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: () => Notification, - 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") - } -} +export const NotificationProvider = model.define("notificationProvider", { + id: model.id({ prefix: "notpro" }), + handle: model.text(), + name: model.text(), + is_enabled: model.boolean().default(true), + channels: model.array().default([]), + notifications: model.hasMany(() => Notification, { mappedBy: "provider" }), +}) diff --git a/packages/modules/notification/src/models/notification.ts b/packages/modules/notification/src/models/notification.ts index d5967289e7..29a97f66a9 100644 --- a/packages/modules/notification/src/models/notification.ts +++ b/packages/modules/notification/src/models/notification.ts @@ -1,109 +1,30 @@ -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 Notification { - @PrimaryKey({ columnType: "text" }) - id: string +import { model } from "@medusajs/utils" +import { NotificationProvider } from "./notification-provider" +// 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). +export const Notification = model.define("notification", { + id: model.id({ prefix: "noti" }), // This can be an email, phone number, or username, depending on the channel. - @Property({ columnType: "text" }) - to: string - - @Property({ columnType: "text" }) - channel: string - + to: model.text(), + channel: model.text(), // The template name in the provider's system. - @Property({ columnType: "text" }) - template: string - + template: model.text(), // The data that gets passed over to the provider for rendering the notification. - @Property({ columnType: "jsonb", nullable: true }) - data: Record | null - + data: model.json().nullable(), // 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 - + trigger_type: model.text().nullable(), // 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 - + resource_id: model.text().nullable(), // The typeame of the resource this notification is for, if applicable, eg. "order" - @Property({ columnType: "text", nullable: true }) - resource_type?: string | null - + resource_type: model.text().nullable(), // 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 - + receiver_id: model.text().index().nullable(), // 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 - + original_notification_id: model.text().nullable(), + idempotency_key: model.text().unique().nullable(), // 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 - } -} + external_id: model.text().nullable(), + provider: model + .belongsTo(() => NotificationProvider, { mappedBy: "notifications" }) + .nullable(), +}) diff --git a/packages/modules/notification/src/services/notification-module-service.ts b/packages/modules/notification/src/services/notification-module-service.ts index 43a67e5741..21b71a1dc6 100644 --- a/packages/modules/notification/src/services/notification-module-service.ts +++ b/packages/modules/notification/src/services/notification-module-service.ts @@ -6,6 +6,7 @@ import { ModuleJoinerConfig, ModulesSdkTypes, NotificationTypes, + InferEntityType, } from "@medusajs/types" import { InjectManager, @@ -21,7 +22,9 @@ import NotificationProviderService from "./notification-provider" type InjectedDependencies = { baseRepository: DAL.RepositoryService - notificationService: ModulesSdkTypes.IMedusaInternalService + notificationService: ModulesSdkTypes.IMedusaInternalService< + typeof Notification + > notificationProviderService: NotificationProviderService } @@ -32,7 +35,9 @@ export default class NotificationModuleService implements INotificationModuleService { protected baseRepository_: DAL.RepositoryService - protected readonly notificationService_: ModulesSdkTypes.IMedusaInternalService + protected readonly notificationService_: ModulesSdkTypes.IMedusaInternalService< + typeof Notification + > protected readonly notificationProviderService_: NotificationProviderService constructor( @@ -91,7 +96,7 @@ export default class NotificationModuleService protected async createNotifications_( data: NotificationTypes.CreateNotificationDTO[], @MedusaContext() sharedContext: Context = {} - ): Promise { + ): Promise[]> { if (!data.length) { return [] } @@ -108,12 +113,13 @@ export default class NotificationModuleService { take: null }, sharedContext ) + const existsMap = new Map( - alreadySentNotifications.map((n) => [n.idempotency_key, true]) + alreadySentNotifications.map((n) => [n.idempotency_key as string, true]) ) const notificationsToProcess = data.filter( - (entry) => !existsMap.has(entry.idempotency_key) + (entry) => !entry.idempotency_key || !existsMap.has(entry.idempotency_key) ) const notificationsToCreate = await promiseAll( diff --git a/packages/modules/notification/src/services/notification-provider.ts b/packages/modules/notification/src/services/notification-provider.ts index c0ebc4a4ec..883a8093f0 100644 --- a/packages/modules/notification/src/services/notification-provider.ts +++ b/packages/modules/notification/src/services/notification-provider.ts @@ -1,10 +1,12 @@ -import { DAL, NotificationTypes } from "@medusajs/types" +import { DAL, InferEntityType, NotificationTypes } from "@medusajs/types" import { MedusaError, ModulesSdkUtils } from "@medusajs/utils" import { NotificationProvider } from "@models" import { NotificationProviderRegistrationPrefix } from "@types" type InjectedDependencies = { - notificationProviderRepository: DAL.RepositoryService + notificationProviderRepository: DAL.RepositoryService< + InferEntityType + > [ key: `${typeof NotificationProviderRegistrationPrefix}${string}` ]: NotificationTypes.INotificationProvider @@ -13,9 +15,14 @@ type InjectedDependencies = { export default class NotificationProviderService extends ModulesSdkUtils.MedusaInternalService( NotificationProvider ) { - protected readonly notificationProviderRepository_: DAL.RepositoryService + protected readonly notificationProviderRepository_: DAL.RepositoryService< + InferEntityType + > // We can store the providers in a memory since they can only be registered on startup and not changed during runtime - protected providersCache: Map + protected providersCache: Map< + string, + InferEntityType + > constructor(container: InjectedDependencies) { super(container) @@ -40,7 +47,7 @@ export default class NotificationProviderService extends ModulesSdkUtils.MedusaI async getProviderForChannel( channel: string - ): Promise { + ): Promise | undefined> { if (!this.providersCache) { const providers = await this.notificationProviderRepository_.find() this.providersCache = new Map( @@ -54,7 +61,7 @@ export default class NotificationProviderService extends ModulesSdkUtils.MedusaI } async send( - provider: NotificationProvider, + provider: InferEntityType, notification: NotificationTypes.ProviderSendNotificationDTO ): Promise { const providerHandler = this.retrieveProviderRegistration(provider.id)