chore: Migrate notification module to DML (#7835)

This commit is contained in:
Stevche Radevski
2024-07-01 09:17:32 +00:00
committed by GitHub
parent c661180c44
commit 9daec5d7ac
11 changed files with 161 additions and 177 deletions
@@ -10,9 +10,7 @@ import { medusaIntegrationTestRunner, TestEventUtils } from "medusa-test-utils"
jest.setTimeout(50000) jest.setTimeout(50000)
const env = { MEDUSA_FF_MEDUSA_V2: true }
medusaIntegrationTestRunner({ medusaIntegrationTestRunner({
env,
testSuite: ({ getContainer }) => { testSuite: ({ getContainer }) => {
describe("Notifications", () => { describe("Notifications", () => {
let service: INotificationModuleService let service: INotificationModuleService
@@ -28,6 +28,7 @@ import {
InjectTransactionManager, InjectTransactionManager,
MedusaContext, MedusaContext,
} from "./decorators" } from "./decorators"
import { DmlEntity, toMikroORMEntity } from "../dml"
type SelectorAndData = { type SelectorAndData = {
selector: FilterQuery<any> | BaseFilterable<FilterQuery<any>> selector: FilterQuery<any> | BaseFilterable<FilterQuery<any>>
@@ -35,12 +36,16 @@ type SelectorAndData = {
} }
export function MedusaInternalService<TContainer extends object = object>( export function MedusaInternalService<TContainer extends object = object>(
model: any rawModel: any
): { ): {
new <TEntity extends object = any>( new <TEntity extends object = any>(
container: TContainer container: TContainer
): ModulesSdkTypes.IMedusaInternalService<TEntity, TContainer> ): ModulesSdkTypes.IMedusaInternalService<TEntity, TContainer>
} { } {
const model = DmlEntity.isDmlEntity(rawModel)
? toMikroORMEntity(rawModel)
: rawModel
const injectedRepositoryName = `${lowerCaseFirst(model.name)}Repository` const injectedRepositoryName = `${lowerCaseFirst(model.name)}Repository`
const propertyRepositoryName = `__${injectedRepositoryName}__` const propertyRepositoryName = `__${injectedRepositoryName}__`
@@ -1,12 +1,8 @@
import * as entities from "./src/models" 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), entities: Object.values(entities),
schema: "public", schema: "public",
clientUrl: "postgres://postgres@localhost/medusa-notification", databaseName: "medusa-notification",
type: "postgresql", })
migrations: {
generator: TSMigrationGenerator,
},
}
@@ -1,5 +1,11 @@
import { moduleProviderLoader } from "@medusajs/modules-sdk" import { moduleProviderLoader } from "@medusajs/modules-sdk"
import { LoaderOptions, ModuleProvider, ModulesSdkTypes } from "@medusajs/types" import {
DAL,
InferEntityType,
LoaderOptions,
ModuleProvider,
ModulesSdkTypes,
} from "@medusajs/types"
import { import {
ContainerRegistrationKeys, ContainerRegistrationKeys,
lowerCaseFirst, lowerCaseFirst,
@@ -62,8 +68,9 @@ async function syncDatabaseProviders({
const providerServiceRegistrationKey = lowerCaseFirst( const providerServiceRegistrationKey = lowerCaseFirst(
NotificationProviderService.name NotificationProviderService.name
) )
const providerService: ModulesSdkTypes.IMedusaInternalService<NotificationProvider> = const providerService: ModulesSdkTypes.IMedusaInternalService<
container.resolve(providerServiceRegistrationKey) typeof NotificationProvider
> = container.resolve(providerServiceRegistrationKey)
const logger = container.resolve(ContainerRegistrationKeys.LOGGER) ?? console const logger = container.resolve(ContainerRegistrationKeys.LOGGER) ?? console
const normalizedProviders = providers.map((provider) => { const normalizedProviders = providers.map((provider) => {
@@ -106,7 +113,10 @@ async function syncDatabaseProviders({
if (providersToDisable.length) { if (providersToDisable.length) {
promises.push( promises.push(
providerService.update( providerService.update(
providersToDisable.map((p) => ({ id: p.id, is_enabled: false })) providersToDisable.map((p) => ({
entity: p,
update: { is_enabled: false },
}))
) )
) )
} }
@@ -50,7 +50,40 @@
"autoincrement": false, "autoincrement": false,
"primary": false, "primary": false,
"nullable": false, "nullable": false,
"default": "'{}'",
"mappedType": "array" "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", "name": "notification_provider",
@@ -198,6 +231,27 @@
"length": 6, "length": 6,
"default": "now()", "default": "now()",
"mappedType": "datetime" "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", "name": "notification",
@@ -209,15 +263,15 @@
"composite": false, "composite": false,
"primary": false, "primary": false,
"unique": 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": [], "columnNames": [],
"composite": false, "composite": false,
"primary": false, "primary": false,
"unique": 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", "keyName": "IDX_notification_provider_id",
@@ -225,7 +279,7 @@
"composite": false, "composite": false,
"primary": false, "primary": false,
"unique": 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", "keyName": "notification_pkey",
@@ -0,0 +1,23 @@
import { Migration } from "@mikro-orm/migrations"
export class Migration20240628075401 extends Migration {
async up(): Promise<void> {
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;'
)
}
}
@@ -1,3 +1,2 @@
export { default as Notification } from "./notification" export { Notification } from "./notification"
export { default as NotificationProvider } from "./notification-provider" export { NotificationProvider } from "./notification-provider"
@@ -1,46 +1,11 @@
import { generateEntityId } from "@medusajs/utils" import { model } from "@medusajs/utils"
import { import { Notification } from "./notification"
ArrayType,
BeforeCreate,
Collection,
Entity,
OnInit,
OneToMany,
PrimaryKey,
Property,
} from "@mikro-orm/core"
import Notification from "./notification"
@Entity() export const NotificationProvider = model.define("notificationProvider", {
export default class NotificationProvider { id: model.id({ prefix: "notpro" }),
@PrimaryKey({ columnType: "text" }) handle: model.text(),
id: string name: model.text(),
is_enabled: model.boolean().default(true),
@Property({ columnType: "text" }) channels: model.array().default([]),
handle: string notifications: model.hasMany(() => Notification, { mappedBy: "provider" }),
})
@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<Notification>(this)
@BeforeCreate()
onCreate() {
this.id = generateEntityId(this.id, "notpro")
}
@OnInit()
onInit() {
this.id = generateEntityId(this.id, "notpro")
}
}
@@ -1,109 +1,30 @@
import { import { model } from "@medusajs/utils"
createPsqlIndexStatementHelper, import { NotificationProvider } from "./notification-provider"
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
// 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. // This can be an email, phone number, or username, depending on the channel.
@Property({ columnType: "text" }) to: model.text(),
to: string channel: model.text(),
@Property({ columnType: "text" })
channel: string
// The template name in the provider's system. // The template name in the provider's system.
@Property({ columnType: "text" }) template: model.text(),
template: string
// The data that gets passed over to the provider for rendering the notification. // The data that gets passed over to the provider for rendering the notification.
@Property({ columnType: "jsonb", nullable: true }) data: model.json().nullable(),
data: Record<string, unknown> | null
// This can be the event name, the workflow, or anything else that can help to identify what triggered the notification. // 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: model.text().nullable(),
trigger_type?: string | null
// The ID of the resource this notification is for, if applicable. Useful for displaying relevant information in the UI // 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: model.text().nullable(),
resource_id?: string | null
// The typeame of the resource this notification is for, if applicable, eg. "order" // The typeame of the resource this notification is for, if applicable, eg. "order"
@Property({ columnType: "text", nullable: true }) resource_type: model.text().nullable(),
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. // 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: model.text().index().nullable(),
receiver_id?: string | null
// The original notification, in case this is a retried notification. // The original notification, in case this is a retried notification.
@Property({ columnType: "text", nullable: true }) original_notification_id: model.text().nullable(),
original_notification_id?: string | null idempotency_key: model.text().unique().nullable(),
@Property({ columnType: "text", nullable: true })
idempotency_key?: string | null
// The ID of the notification in the external system, if applicable // The ID of the notification in the external system, if applicable
@Property({ columnType: "text", nullable: true }) external_id: model.text().nullable(),
external_id?: string | null provider: model
.belongsTo(() => NotificationProvider, { mappedBy: "notifications" })
@ManyToOne(() => NotificationProvider, { .nullable(),
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
}
}
@@ -6,6 +6,7 @@ import {
ModuleJoinerConfig, ModuleJoinerConfig,
ModulesSdkTypes, ModulesSdkTypes,
NotificationTypes, NotificationTypes,
InferEntityType,
} from "@medusajs/types" } from "@medusajs/types"
import { import {
InjectManager, InjectManager,
@@ -21,7 +22,9 @@ import NotificationProviderService from "./notification-provider"
type InjectedDependencies = { type InjectedDependencies = {
baseRepository: DAL.RepositoryService baseRepository: DAL.RepositoryService
notificationService: ModulesSdkTypes.IMedusaInternalService<any> notificationService: ModulesSdkTypes.IMedusaInternalService<
typeof Notification
>
notificationProviderService: NotificationProviderService notificationProviderService: NotificationProviderService
} }
@@ -32,7 +35,9 @@ export default class NotificationModuleService
implements INotificationModuleService implements INotificationModuleService
{ {
protected baseRepository_: DAL.RepositoryService protected baseRepository_: DAL.RepositoryService
protected readonly notificationService_: ModulesSdkTypes.IMedusaInternalService<Notification> protected readonly notificationService_: ModulesSdkTypes.IMedusaInternalService<
typeof Notification
>
protected readonly notificationProviderService_: NotificationProviderService protected readonly notificationProviderService_: NotificationProviderService
constructor( constructor(
@@ -91,7 +96,7 @@ export default class NotificationModuleService
protected async createNotifications_( protected async createNotifications_(
data: NotificationTypes.CreateNotificationDTO[], data: NotificationTypes.CreateNotificationDTO[],
@MedusaContext() sharedContext: Context = {} @MedusaContext() sharedContext: Context = {}
): Promise<Notification[]> { ): Promise<InferEntityType<typeof Notification>[]> {
if (!data.length) { if (!data.length) {
return [] return []
} }
@@ -108,12 +113,13 @@ export default class NotificationModuleService
{ take: null }, { take: null },
sharedContext sharedContext
) )
const existsMap = new Map( const existsMap = new Map(
alreadySentNotifications.map((n) => [n.idempotency_key, true]) alreadySentNotifications.map((n) => [n.idempotency_key as string, true])
) )
const notificationsToProcess = data.filter( const notificationsToProcess = data.filter(
(entry) => !existsMap.has(entry.idempotency_key) (entry) => !entry.idempotency_key || !existsMap.has(entry.idempotency_key)
) )
const notificationsToCreate = await promiseAll( const notificationsToCreate = await promiseAll(
@@ -1,10 +1,12 @@
import { DAL, NotificationTypes } from "@medusajs/types" import { DAL, InferEntityType, NotificationTypes } from "@medusajs/types"
import { MedusaError, ModulesSdkUtils } from "@medusajs/utils" import { MedusaError, ModulesSdkUtils } from "@medusajs/utils"
import { NotificationProvider } from "@models" import { NotificationProvider } from "@models"
import { NotificationProviderRegistrationPrefix } from "@types" import { NotificationProviderRegistrationPrefix } from "@types"
type InjectedDependencies = { type InjectedDependencies = {
notificationProviderRepository: DAL.RepositoryService notificationProviderRepository: DAL.RepositoryService<
InferEntityType<typeof NotificationProvider>
>
[ [
key: `${typeof NotificationProviderRegistrationPrefix}${string}` key: `${typeof NotificationProviderRegistrationPrefix}${string}`
]: NotificationTypes.INotificationProvider ]: NotificationTypes.INotificationProvider
@@ -13,9 +15,14 @@ type InjectedDependencies = {
export default class NotificationProviderService extends ModulesSdkUtils.MedusaInternalService<InjectedDependencies>( export default class NotificationProviderService extends ModulesSdkUtils.MedusaInternalService<InjectedDependencies>(
NotificationProvider NotificationProvider
) { ) {
protected readonly notificationProviderRepository_: DAL.RepositoryService<NotificationProvider> protected readonly notificationProviderRepository_: DAL.RepositoryService<
InferEntityType<typeof NotificationProvider>
>
// We can store the providers in a memory since they can only be registered on startup and not changed during runtime // We can store the providers in a memory since they can only be registered on startup and not changed during runtime
protected providersCache: Map<string, NotificationProvider> protected providersCache: Map<
string,
InferEntityType<typeof NotificationProvider>
>
constructor(container: InjectedDependencies) { constructor(container: InjectedDependencies) {
super(container) super(container)
@@ -40,7 +47,7 @@ export default class NotificationProviderService extends ModulesSdkUtils.MedusaI
async getProviderForChannel( async getProviderForChannel(
channel: string channel: string
): Promise<NotificationProvider | undefined> { ): Promise<InferEntityType<typeof NotificationProvider> | undefined> {
if (!this.providersCache) { if (!this.providersCache) {
const providers = await this.notificationProviderRepository_.find() const providers = await this.notificationProviderRepository_.find()
this.providersCache = new Map( this.providersCache = new Map(
@@ -54,7 +61,7 @@ export default class NotificationProviderService extends ModulesSdkUtils.MedusaI
} }
async send( async send(
provider: NotificationProvider, provider: InferEntityType<typeof NotificationProvider>,
notification: NotificationTypes.ProviderSendNotificationDTO notification: NotificationTypes.ProviderSendNotificationDTO
): Promise<NotificationTypes.ProviderSendNotificationResultsDTO> { ): Promise<NotificationTypes.ProviderSendNotificationResultsDTO> {
const providerHandler = this.retrieveProviderRegistration(provider.id) const providerHandler = this.retrieveProviderRegistration(provider.id)