diff --git a/packages/medusa/src/api/routes/admin/notifications/list-notifications.ts b/packages/medusa/src/api/routes/admin/notifications/list-notifications.ts index 7c27c5f154..8a08c06130 100644 --- a/packages/medusa/src/api/routes/admin/notifications/list-notifications.ts +++ b/packages/medusa/src/api/routes/admin/notifications/list-notifications.ts @@ -7,6 +7,8 @@ import { defaultAdminNotificationsFields, defaultAdminNotificationsRelations, } from "./" +import { Notification } from "../../../../models" +import { FindConfig } from "../../../../types/common" /** * @oas [get] /notifications @@ -91,20 +93,23 @@ export default async (req, res) => { } const listConfig = { - select: includeFields.length + select: (includeFields.length ? includeFields - : defaultAdminNotificationsFields, + : defaultAdminNotificationsFields) as (keyof Notification)[], relations: expandFields.length ? expandFields : defaultAdminNotificationsRelations, skip: offset, take: limit, order: { created_at: "DESC" }, - } + } as FindConfig const notifications = await notificationService.list(selector, listConfig) - const resultFields = [...listConfig.select, ...listConfig.relations] + const resultFields = [ + ...(listConfig.select ?? []), + ...(listConfig.relations ?? []), + ] const data = notifications.map((o) => pick(o, resultFields)) res.json({ notifications: data }) diff --git a/packages/medusa/src/api/routes/admin/notifications/resend-notification.ts b/packages/medusa/src/api/routes/admin/notifications/resend-notification.ts index c2ca77e80a..a1acd264eb 100644 --- a/packages/medusa/src/api/routes/admin/notifications/resend-notification.ts +++ b/packages/medusa/src/api/routes/admin/notifications/resend-notification.ts @@ -6,6 +6,7 @@ import { } from "." import { validator } from "../../../../utils/validator" import { NotificationService } from "../../../../services" +import { Notification } from "../../../../models" /** * @oas [post] /notifications/{id}/resend @@ -56,7 +57,7 @@ export default async (req, res) => { await notificationService.resend(id, config) const notification = await notificationService.retrieve(id, { - select: defaultAdminNotificationsFields, + select: defaultAdminNotificationsFields as (keyof Notification)[], relations: defaultAdminNotificationsRelations, }) diff --git a/packages/medusa/src/interfaces/index.ts b/packages/medusa/src/interfaces/index.ts index 97fc1ffe72..492e7f056f 100644 --- a/packages/medusa/src/interfaces/index.ts +++ b/packages/medusa/src/interfaces/index.ts @@ -4,5 +4,7 @@ export * from "./tax-service" export * from "./transaction-base-service" export * from "./batch-job-strategy" export * from "./file-service" +export * from "./notification-service" +export * from "./price-selection-strategy" export * from "./models/base-entity" export * from "./models/soft-deletable-entity" diff --git a/packages/medusa/src/interfaces/notification-service.ts b/packages/medusa/src/interfaces/notification-service.ts new file mode 100644 index 0000000000..a90a054b73 --- /dev/null +++ b/packages/medusa/src/interfaces/notification-service.ts @@ -0,0 +1,55 @@ +import { TransactionBaseService } from "./transaction-base-service" +import BaseNotificationService from "medusa-interfaces/dist/notification-service" + +type ReturnedData = { + to: string + status: string + data: Record +} + +export interface INotificationService> + extends TransactionBaseService { + sendNotification( + event: string, + data: unknown, + attachmentGenerator: unknown + ): Promise + + resendNotification( + notification: unknown, + config: unknown, + attachmentGenerator: unknown + ): Promise +} + +export abstract class AbstractNotificationService< + T extends TransactionBaseService + > + extends TransactionBaseService + implements INotificationService +{ + static identifier: string + + getIdentifier(): string { + return (this.constructor as any).identifier + } + + abstract sendNotification( + event: string, + data: unknown, + attachmentGenerator: unknown + ): Promise + + abstract resendNotification( + notification: unknown, + config: unknown, + attachmentGenerator: unknown + ): Promise +} + +export const isNotificationService = (obj: unknown): boolean => { + return ( + obj instanceof AbstractNotificationService || + obj instanceof BaseNotificationService + ) +} diff --git a/packages/medusa/src/loaders/plugins.ts b/packages/medusa/src/loaders/plugins.ts index e777fcbbb8..9357a845f7 100644 --- a/packages/medusa/src/loaders/plugins.ts +++ b/packages/medusa/src/loaders/plugins.ts @@ -5,7 +5,6 @@ import { BaseService as LegacyBaseService, PaymentService, FulfillmentService, - NotificationService, FileService, OauthService, SearchService, @@ -21,6 +20,9 @@ import { isFileService, isTaxCalculationStrategy, TransactionBaseService as BaseService, + isNotificationService, + isBatchJobStrategy, + isPriceSelectionStrategy, } from "../interfaces" import formatRegistrationName from "../utils/format-registration-name" import { @@ -30,8 +32,6 @@ import { MedusaContainer, } from "../types/global" import { MiddlewareService } from "../services" -import { isBatchJobStrategy } from "../interfaces/batch-job-strategy" -import { isPriceSelectionStrategy } from "../interfaces/price-selection-strategy" import logger from "./logger" type Options = { @@ -392,7 +392,7 @@ export async function registerServices( ).singleton(), [`fp_${loaded.identifier}`]: aliasTo(name), }) - } else if (loaded.prototype instanceof NotificationService) { + } else if (isNotificationService(loaded.prototype)) { container.registerAdd( "notificationProviders", asFunction((cradle) => new loaded(cradle, pluginDetails.options)) diff --git a/packages/medusa/src/models/notification.ts b/packages/medusa/src/models/notification.ts index e4f1efeb44..550925756c 100644 --- a/packages/medusa/src/models/notification.ts +++ b/packages/medusa/src/models/notification.ts @@ -29,7 +29,7 @@ export class Notification extends BaseEntity { @Index() @Column({ nullable: true }) - customer_id: string + customer_id: string | null @ManyToOne(() => Customer) @JoinColumn({ name: "customer_id" }) diff --git a/packages/medusa/src/services/notification.js b/packages/medusa/src/services/notification.js deleted file mode 100644 index 27dffcf97c..0000000000 --- a/packages/medusa/src/services/notification.js +++ /dev/null @@ -1,261 +0,0 @@ -import { MedusaError } from "medusa-core-utils" -import { BaseService } from "medusa-interfaces" - -/** - * Provides layer to manipulate orchestrate notifications. - * @extends BaseService - */ -class NotificationService extends BaseService { - constructor(container) { - super() - - const { - manager, - notificationProviderRepository, - notificationRepository, - logger, - } = container - - this.container_ = container - - /** @private @const {EntityManager} */ - this.manager_ = manager - this.logger_ = logger - - /** @private @const {NotificationRepository} */ - this.notificationRepository_ = notificationRepository - this.notificationProviderRepository_ = notificationProviderRepository - - this.subscribers_ = {} - this.attachmentGenerator_ = null - } - - /** - * Registers an attachment generator to the service. The generator can be - * used to generate on demand invoices or other documents. - * @param {object} service - */ - registerAttachmentGenerator(service) { - this.attachmentGenerator_ = service - } - - /** - * Sets the service's manager to a given transaction manager. - * @param {EntityManager} transactionManager - the manager to use - * @return {NotificationService} a cloned notification service - */ - withTransaction(transactionManager) { - if (!transactionManager) { - return this - } - - const cloned = new NotificationService({ - manager: transactionManager, - notificationProviderRepository: this.notificationProviderRepository_, - notificationRepository: this.notificationRepository_, - logger: this.logger_, - }) - - cloned.transactionManager_ = transactionManager - - return cloned - } - - /** - * Takes a list of notification provider ids and persists them in the database. - * @param {Array} providers - a list of provider ids - */ - async registerInstalledProviders(providers) { - const { manager, notificationProviderRepository } = this.container_ - const model = manager.getCustomRepository(notificationProviderRepository) - model.update({}, { is_installed: false }) - for (const p of providers) { - const n = model.create({ id: p, is_installed: true }) - await model.save(n) - } - } - - /** - * Retrieves a list of notifications. - * @param {object} selector - the params to select the notifications by. - * @param {object} config - the configuration to apply to the query - * @return {Array} the notifications that satisfy the query. - */ - async list( - selector, - config = { skip: 0, take: 50, order: { created_at: "DESC" } } - ) { - const notiRepo = this.manager_.getCustomRepository( - this.notificationRepository_ - ) - const query = this.buildQuery_(selector, config) - return notiRepo.find(query) - } - - /** - * Retrieves a notification with a given id - * @param {string} id - the id of the notification - * @param {object} config - the configuration to apply to the query - * @return {Notification} the notification - */ - async retrieve(id, config = {}) { - const notiRepository = this.manager_.getCustomRepository( - this.notificationRepository_ - ) - - const validatedId = this.validateId_(id) - const query = this.buildQuery_({ id: validatedId }, config) - - const notification = await notiRepository.findOne(query) - - if (!notification) { - throw new MedusaError( - MedusaError.Types.NOT_FOUND, - `Notification with id: ${id} was not found.` - ) - } - - return notification - } - - /** - * Subscribes a given provider to an event. - * @param {string} eventName - the event to subscribe to - * @param {string} providerId - the provider that the event will be sent to - */ - subscribe(eventName, providerId) { - if (typeof providerId !== "string") { - throw new MedusaError( - MedusaError.Types.NOT_ALLOWED, - "providerId must be a string" - ) - } - - if (this.subscribers_[eventName]) { - this.subscribers_[eventName].push(providerId) - } else { - this.subscribers_[eventName] = [providerId] - } - } - - /** - * Finds a provider with a given id. Will throw a NOT_FOUND error if the - * resolution fails. - * @param {string} id - the id of the provider - * @return {NotificationProvider} the notification provider - */ - retrieveProvider_(id) { - try { - return this.container_[`noti_${id}`] - } catch (err) { - throw new MedusaError( - MedusaError.Types.NOT_FOUND, - `Could not find a notification provider with id: ${id}.` - ) - } - } - - /** - * Handles an event by relaying the event data to the subscribing providers. - * The result of the notification send will be persisted in the database in - * order to allow for resends. Will log any errors that are encountered. - * @param {string} eventName - the event to handle - * @param {object} data - the data the event was sent with - * @return {Promise} - the result of notification subscribed - */ - handleEvent(eventName, data) { - const subs = this.subscribers_[eventName] - if (!subs) { - return Promise.resolve() - } - if (data["no_notification"] === true) { - return - } - - return Promise.all( - subs.map(async (providerId) => { - return this.send(eventName, data, providerId).catch((err) => { - console.log(err) - this.logger_.warn( - `An error occured while ${providerId} was processing a notification for ${eventName}: ${err.message}` - ) - }) - }) - ) - } - - /** - * Sends a notification, by calling the given provider's sendNotification - * method. Persists the Notification in the database. - * @param {string} event - the name of the event - * @param {object} eventData - the data the event was sent with - * @param {string} providerId - the provider that should hande the event. - * @return {Notification} the created notification - */ - async send(event, eventData, providerId) { - const provider = this.retrieveProvider_(providerId) - const result = await provider.sendNotification( - event, - eventData, - this.attachmentGenerator_ - ) - - if (!result) { - return - } - - const { to, data } = result - const notiRepo = this.manager_.getCustomRepository( - this.notificationRepository_ - ) - - const [resource_type] = event.split(".") - const resource_id = eventData.id - const customer_id = eventData.customer_id || null - - const created = notiRepo.create({ - resource_type, - resource_id, - customer_id, - to, - data, - event_name: event, - provider_id: providerId, - }) - - return notiRepo.save(created) - } - - /** - * Resends a notification by retrieving a prior notification and calling the - * underlying provider's resendNotification method. - * @param {string} id - the id of the notification - * @param {object} config - any configuration that might override the previous - * send - * @return {Notification} the newly created notification - */ - async resend(id, config = {}) { - const notification = await this.retrieve(id) - - const provider = this.retrieveProvider_(notification.provider_id) - const { to, data } = await provider.resendNotification( - notification, - config, - this.attachmentGenerator_ - ) - - const notiRepo = this.manager_.getCustomRepository( - this.notificationRepository_ - ) - const created = notiRepo.create({ - ...notification, - to, - data, - parent_id: id, - }) - - return notiRepo.save(created) - } -} - -export default NotificationService diff --git a/packages/medusa/src/services/notification.ts b/packages/medusa/src/services/notification.ts new file mode 100644 index 0000000000..d67de09324 --- /dev/null +++ b/packages/medusa/src/services/notification.ts @@ -0,0 +1,282 @@ +import { MedusaError } from "medusa-core-utils" +import { + AbstractNotificationService, + TransactionBaseService, +} from "../interfaces" +import { EntityManager } from "typeorm" +import { Logger } from "../types/global" +import { NotificationRepository } from "../repositories/notification" +import { NotificationProviderRepository } from "../repositories/notification-provider" +import { FindConfig, Selector } from "../types/common" +import { buildQuery } from "../utils" +import { Notification } from "../models" + +type InjectedDependencies = { + manager: EntityManager + logger: Logger + notificationRepository: typeof NotificationRepository + notificationProviderRepository: typeof NotificationProviderRepository +} +type NotificationProviderKey = `noti_${string}` + +class NotificationService extends TransactionBaseService { + protected manager_: EntityManager + protected transactionManager_: EntityManager | undefined + + protected subscribers_ = {} + protected attachmentGenerator_: unknown = null + protected readonly container_: InjectedDependencies & { + [key in `${NotificationProviderKey}`]: AbstractNotificationService + } + protected readonly logger_: Logger + protected readonly notificationRepository_: typeof NotificationRepository + protected readonly notificationProviderRepository_: typeof NotificationProviderRepository + + constructor(container: InjectedDependencies) { + super(container) + + const { + manager, + notificationProviderRepository, + notificationRepository, + logger, + } = container + + this.container_ = container + + /** @private @const {EntityManager} */ + this.manager_ = manager + this.logger_ = logger + + /** @private @const {NotificationRepository} */ + this.notificationRepository_ = notificationRepository + this.notificationProviderRepository_ = notificationProviderRepository + } + + /** + * Registers an attachment generator to the service. The generator can be + * used to generate on demand invoices or other documents. + * @param service the service to assign to the attachmentGenerator + */ + registerAttachmentGenerator(service: unknown): void { + this.attachmentGenerator_ = service + } + + /** + * Takes a list of notification provider ids and persists them in the database. + * @param providerIds - a list of provider ids + */ + async registerInstalledProviders(providerIds: string[]): Promise { + const { manager, notificationProviderRepository } = this.container_ + const model = manager.getCustomRepository(notificationProviderRepository) + await model.update({}, { is_installed: false }) + for (const id of providerIds) { + const n = model.create({ id, is_installed: true }) + await model.save(n) + } + } + + /** + * Retrieves a list of notifications. + * @param selector - the params to select the notifications by. + * @param config - the configuration to apply to the query + * @return the notifications that satisfy the query. + */ + async list( + selector: Selector, + config: FindConfig = { + skip: 0, + take: 50, + order: { created_at: "DESC" }, + } + ): Promise { + const notiRepo = this.manager_.getCustomRepository( + this.notificationRepository_ + ) + const query = buildQuery(selector, config) + return await notiRepo.find(query) + } + + /** + * Retrieves a notification with a given id + * @param id - the id of the notification + * @param config - the configuration to apply to the query + * @return the notification + */ + async retrieve( + id: string, + config: FindConfig = {} + ): Promise { + const notiRepository = this.manager_.getCustomRepository( + this.notificationRepository_ + ) + + const query = buildQuery({ id }, config) + + const notification = await notiRepository.findOne(query) + + if (!notification) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Notification with id: ${id} was not found.` + ) + } + + return notification + } + + /** + * Subscribes a given provider to an event. + * @param eventName - the event to subscribe to + * @param providerId - the provider that the event will be sent to + */ + subscribe(eventName: string, providerId: string): void { + if (typeof providerId !== "string") { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "providerId must be a string" + ) + } + + if (this.subscribers_[eventName]) { + this.subscribers_[eventName].push(providerId) + } else { + this.subscribers_[eventName] = [providerId] + } + } + + /** + * Finds a provider with a given id. Will throw a NOT_FOUND error if the + * resolution fails. + * @param id - the id of the provider + * @return the notification provider + */ + protected retrieveProvider_(id: string): AbstractNotificationService { + try { + return this.container_[`noti_${id}`] + } catch (err) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Could not find a notification provider with id: ${id}.` + ) + } + } + + /** + * Handles an event by relaying the event data to the subscribing providers. + * The result of the notification send will be persisted in the database in + * order to allow for resends. Will log any errors that are encountered. + * @param eventName - the event to handle + * @param data - the data the event was sent with + * @return the result of notification subscribed + */ + handleEvent( + eventName: string, + data: Record + ): Promise { + const subs = this.subscribers_[eventName] + if (!subs) { + return Promise.resolve() + } + if (data["no_notification"] === true) { + return Promise.resolve() + } + + return Promise.all( + subs.map(async (providerId) => { + return this.send(eventName, data, providerId).catch((err) => { + console.log(err) + this.logger_.warn( + `An error occured while ${providerId} was processing a notification for ${eventName}: ${err.message}` + ) + }) + }) + ) + } + + /** + * Sends a notification, by calling the given provider's sendNotification + * method. Persists the Notification in the database. + * @param event - the name of the event + * @param eventData - the data the event was sent with + * @param providerId - the provider that should hande the event. + * @return the created notification + */ + async send( + event: string, + eventData: Record, + providerId: string + ): Promise { + return await this.atomicPhase_(async (transactionManager) => { + const provider = this.retrieveProvider_(providerId) + const result = await provider.sendNotification( + event, + eventData, + this.attachmentGenerator_ + ) + + if (!result) { + return + } + + const { to, data } = result + const notiRepo = transactionManager.getCustomRepository( + this.notificationRepository_ + ) + + const [resource_type] = event.split(".") as string[] + const resource_id = eventData.id as string + const customer_id = (eventData.customer_id as string) || null + + const created = notiRepo.create({ + resource_type, + resource_id, + customer_id, + to, + data, + event_name: event, + provider_id: providerId, + }) + + return await notiRepo.save(created) + }) + } + + /** + * Resends a notification by retrieving a prior notification and calling the + * underlying provider's resendNotification method. + * @param {string} id - the id of the notification + * @param {object} config - any configuration that might override the previous + * send + * @return {Notification} the newly created notification + */ + async resend( + id: string, + config: FindConfig = {} + ): Promise { + return await this.atomicPhase_(async (transactionManager) => { + const notification = await this.retrieve(id) + + const provider = this.retrieveProvider_(notification.provider_id) + const { to, data } = await provider.resendNotification( + notification, + config, + this.attachmentGenerator_ + ) + + const notiRepo = transactionManager.getCustomRepository( + this.notificationRepository_ + ) + const created = notiRepo.create({ + ...notification, + to, + data, + parent_id: id, + }) + + return notiRepo.save(created) + }) + } +} + +export default NotificationService