chore: Migrate notification module to DML (#7835)

This commit is contained in:
Stevche Radevski
2024-07-01 11:17:32 +02:00
committed by GitHub
parent c661180c44
commit 9daec5d7ac
11 changed files with 161 additions and 177 deletions

View File

@@ -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

View File

@@ -28,6 +28,7 @@ import {
InjectTransactionManager,
MedusaContext,
} from "./decorators"
import { DmlEntity, toMikroORMEntity } from "../dml"
type SelectorAndData = {
selector: FilterQuery<any> | BaseFilterable<FilterQuery<any>>
@@ -35,12 +36,16 @@ type SelectorAndData = {
}
export function MedusaInternalService<TContainer extends object = object>(
model: any
rawModel: any
): {
new <TEntity extends object = any>(
container: TContainer
): ModulesSdkTypes.IMedusaInternalService<TEntity, TContainer>
} {
const model = DmlEntity.isDmlEntity(rawModel)
? toMikroORMEntity(rawModel)
: rawModel
const injectedRepositoryName = `${lowerCaseFirst(model.name)}Repository`
const propertyRepositoryName = `__${injectedRepositoryName}__`

View File

@@ -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",
})

View File

@@ -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<NotificationProvider> =
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 },
}))
)
)
}

View File

@@ -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",

View File

@@ -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;'
)
}
}

View File

@@ -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"

View File

@@ -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<Notification>(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" }),
})

View File

@@ -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<string, unknown> | 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(),
})

View File

@@ -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<any>
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<Notification>
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<Notification[]> {
): Promise<InferEntityType<typeof Notification>[]> {
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(

View File

@@ -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<typeof NotificationProvider>
>
[
key: `${typeof NotificationProviderRegistrationPrefix}${string}`
]: NotificationTypes.INotificationProvider
@@ -13,9 +15,14 @@ type InjectedDependencies = {
export default class NotificationProviderService extends ModulesSdkUtils.MedusaInternalService<InjectedDependencies>(
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
protected providersCache: Map<string, NotificationProvider>
protected providersCache: Map<
string,
InferEntityType<typeof NotificationProvider>
>
constructor(container: InjectedDependencies) {
super(container)
@@ -40,7 +47,7 @@ export default class NotificationProviderService extends ModulesSdkUtils.MedusaI
async getProviderForChannel(
channel: string
): Promise<NotificationProvider | undefined> {
): Promise<InferEntityType<typeof NotificationProvider> | 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<typeof NotificationProvider>,
notification: NotificationTypes.ProviderSendNotificationDTO
): Promise<NotificationTypes.ProviderSendNotificationResultsDTO> {
const providerHandler = this.retrieveProviderRegistration(provider.id)