diff --git a/packages/fulfillment/integration-tests/__fixtures__/events.ts b/packages/fulfillment/integration-tests/__fixtures__/events.ts new file mode 100644 index 0000000000..858a9b9965 --- /dev/null +++ b/packages/fulfillment/integration-tests/__fixtures__/events.ts @@ -0,0 +1,24 @@ +import { EventBusTypes } from "@medusajs/types" + +export function buildExpectedEventMessageShape(options: { + eventName: string + action: string + object: string + eventGroupId?: string + data: any + options?: Record +}): EventBusTypes.Message { + return { + eventName: options.eventName, + body: { + metadata: { + action: options.action, + eventGroupId: options.eventGroupId, + service: "fulfillment", + object: options.object, + }, + data: options.data, + }, + options: options.options, + } +} diff --git a/packages/fulfillment/integration-tests/__fixtures__/index.ts b/packages/fulfillment/integration-tests/__fixtures__/index.ts index 51a4aea42f..cd0b42db58 100644 --- a/packages/fulfillment/integration-tests/__fixtures__/index.ts +++ b/packages/fulfillment/integration-tests/__fixtures__/index.ts @@ -4,6 +4,7 @@ import { IFulfillmentModuleService } from "@medusajs/types" export * from "./shipping-options" export * from "./fulfillment" +export * from "./events" export async function createFullDataStructure( service: IFulfillmentModuleService, diff --git a/packages/fulfillment/integration-tests/__tests__/fulfillment-module-service/fulfillment-set.spec.ts b/packages/fulfillment/integration-tests/__tests__/fulfillment-module-service/fulfillment-set.spec.ts index 68f743b11d..c76e86589f 100644 --- a/packages/fulfillment/integration-tests/__tests__/fulfillment-module-service/fulfillment-set.spec.ts +++ b/packages/fulfillment/integration-tests/__tests__/fulfillment-module-service/fulfillment-set.spec.ts @@ -6,14 +6,26 @@ import { ServiceZoneDTO, UpdateFulfillmentSetDTO, } from "@medusajs/types" -import { GeoZoneType } from "@medusajs/utils" +import { FulfillmentEvents, GeoZoneType } from "@medusajs/utils" import { moduleIntegrationTestRunner, SuiteOptions } from "medusa-test-utils" +import { MockEventBusService } from "medusa-test-utils/dist" +import { buildExpectedEventMessageShape } from "../../__fixtures__" jest.setTimeout(100000) moduleIntegrationTestRunner({ moduleName: Modules.FULFILLMENT, testSuite: ({ service }: SuiteOptions) => { + let eventBusEmitSpy + + beforeEach(() => { + eventBusEmitSpy = jest.spyOn(MockEventBusService.prototype, "emit") + }) + + afterEach(() => { + jest.clearAllMocks() + }) + describe("Fulfillment Module Service", () => { describe("read", () => { it("should list fulfillment sets with a filter", async function () { @@ -153,6 +165,15 @@ moduleIntegrationTestRunner({ type: data.type, }) ) + + expect(eventBusEmitSpy).toHaveBeenCalledWith([ + buildExpectedEventMessageShape({ + eventName: FulfillmentEvents.created, + action: "created", + object: "fulfillment_set", + data: { id: fulfillmentSet.id }, + }), + ]) }) it("should create a collection of fulfillment sets", async function () { @@ -180,6 +201,18 @@ moduleIntegrationTestRunner({ type: data_.type, }) ) + + expect(eventBusEmitSpy).toHaveBeenCalledWith( + expect.arrayContaining([ + buildExpectedEventMessageShape({ + eventName: FulfillmentEvents.created, + action: "created", + object: "fulfillment_set", + data: { id: fulfillmentSets[i].id }, + }), + ]) + ) + ++i } }) @@ -210,6 +243,21 @@ moduleIntegrationTestRunner({ ]), }) ) + + expect(eventBusEmitSpy).toHaveBeenCalledWith([ + buildExpectedEventMessageShape({ + eventName: FulfillmentEvents.created, + action: "created", + object: "fulfillment_set", + data: { id: fulfillmentSet.id }, + }), + buildExpectedEventMessageShape({ + eventName: FulfillmentEvents.service_zone_created, + action: "created", + object: "service_zone", + data: { id: fulfillmentSet.service_zones[0].id }, + }), + ]) }) it("should create a collection of fulfillment sets with new service zones", async function () { @@ -262,6 +310,24 @@ moduleIntegrationTestRunner({ ]), }) ) + + expect(eventBusEmitSpy).toHaveBeenCalledWith( + expect.arrayContaining([ + buildExpectedEventMessageShape({ + eventName: FulfillmentEvents.created, + action: "created", + object: "fulfillment_set", + data: { id: fulfillmentSets[i].id }, + }), + buildExpectedEventMessageShape({ + eventName: FulfillmentEvents.service_zone_created, + action: "created", + object: "service_zone", + data: { id: fulfillmentSets[i].service_zones[0].id }, + }), + ]) + ) + ++i } }) @@ -305,6 +371,27 @@ moduleIntegrationTestRunner({ ]), }) ) + + expect(eventBusEmitSpy).toHaveBeenCalledWith([ + buildExpectedEventMessageShape({ + eventName: FulfillmentEvents.created, + action: "created", + object: "fulfillment_set", + data: { id: fulfillmentSet.id }, + }), + buildExpectedEventMessageShape({ + eventName: FulfillmentEvents.service_zone_created, + action: "created", + object: "service_zone", + data: { id: fulfillmentSet.service_zones[0].id }, + }), + buildExpectedEventMessageShape({ + eventName: FulfillmentEvents.geo_zone_created, + action: "created", + object: "geo_zone", + data: { id: fulfillmentSet.service_zones[0].geo_zones[0].id }, + }), + ]) }) it("should create a collection of fulfillment sets with new service zones and new geo zones", async function () { @@ -385,6 +472,32 @@ moduleIntegrationTestRunner({ ]), }) ) + + expect(eventBusEmitSpy).toHaveBeenCalledWith( + expect.arrayContaining([ + buildExpectedEventMessageShape({ + eventName: FulfillmentEvents.created, + action: "created", + object: "fulfillment_set", + data: { id: fulfillmentSets[i].id }, + }), + buildExpectedEventMessageShape({ + eventName: FulfillmentEvents.service_zone_created, + action: "created", + object: "service_zone", + data: { id: fulfillmentSets[i].service_zones[0].id }, + }), + buildExpectedEventMessageShape({ + eventName: FulfillmentEvents.geo_zone_created, + action: "created", + object: "geo_zone", + data: { + id: fulfillmentSets[i].service_zones[0].geo_zones[0].id, + }, + }), + ]) + ) + ++i } }) diff --git a/packages/fulfillment/src/services/fulfillment-module-service.ts b/packages/fulfillment/src/services/fulfillment-module-service.ts index 22915ea28e..44a5f5d4aa 100644 --- a/packages/fulfillment/src/services/fulfillment-module-service.ts +++ b/packages/fulfillment/src/services/fulfillment-module-service.ts @@ -14,6 +14,8 @@ import { } from "@medusajs/types" import { arrayDifference, + EmitEvents, + FulfillmentUtils, getSetDifference, InjectManager, InjectTransactionManager, @@ -36,6 +38,7 @@ import { import { isContextValid, validateRules } from "@utils" import { entityNameToLinkableKeysMap, joinerConfig } from "../joiner-config" import FulfillmentProviderService from "./fulfillment-provider" +import { Modules } from "@medusajs/modules-sdk" const generateMethodForModels = [ ServiceZone, @@ -242,6 +245,7 @@ export default class FulfillmentModuleService< ): Promise @InjectManager("baseRepository_") + @EmitEvents() async create( data: | FulfillmentTypes.CreateFulfillmentSetDTO @@ -287,6 +291,11 @@ export default class FulfillmentModuleService< sharedContext ) + this.aggregateFulfillmentSetCreatedEvents( + createdFulfillmentSets, + sharedContext + ) + return Array.isArray(data) ? createdFulfillmentSets : createdFulfillmentSets[0] @@ -1498,7 +1507,7 @@ export default class FulfillmentModuleService< * ] * } */ - private static buildGeoZoneConstraintsFromAddress( + protected static buildGeoZoneConstraintsFromAddress( address: FulfillmentTypes.FilterableShippingOptionForContextProps["address"] ) { /** @@ -1557,4 +1566,60 @@ export default class FulfillmentModuleService< return geoZoneConstraints } + + protected aggregateFulfillmentSetCreatedEvents( + createdFulfillmentSets: TEntity[], + sharedContext: Context + ): void { + const buildMessage = ({ + eventName, + id, + object, + }: { + eventName: string + id: string + object: string + }) => { + return { + eventName, + metadata: { + object, + service: Modules.FULFILLMENT, + action: "created", + eventGroupId: sharedContext.eventGroupId, + }, + data: { id }, + } + } + + for (const fulfillmentSet of createdFulfillmentSets) { + sharedContext.messageAggregator!.saveRawMessageData( + buildMessage({ + eventName: FulfillmentUtils.FulfillmentEvents.created, + id: fulfillmentSet.id, + object: "fulfillment_set", + }) + ) + + for (const serviceZone of fulfillmentSet.service_zones ?? []) { + sharedContext.messageAggregator!.saveRawMessageData( + buildMessage({ + eventName: FulfillmentUtils.FulfillmentEvents.service_zone_created, + id: serviceZone.id, + object: "service_zone", + }) + ) + + for (const geoZone of serviceZone.geo_zones ?? []) { + sharedContext.messageAggregator!.saveRawMessageData( + buildMessage({ + eventName: FulfillmentUtils.FulfillmentEvents.geo_zone_created, + id: geoZone.id, + object: "geo_zone", + }) + ) + } + } + } + } } diff --git a/packages/types/src/common/common.ts b/packages/types/src/common/common.ts index d6d8c065f2..d77d5d8ac4 100644 --- a/packages/types/src/common/common.ts +++ b/packages/types/src/common/common.ts @@ -531,3 +531,17 @@ export type Pluralize = Singular extends `${infer R}y` | `${infer R}o` ? `${Singular}es` : `${Singular}s` + +export type SnakeCase = + S extends `${infer T}${infer U}${infer V}` + ? U extends Uppercase + ? `${Lowercase}_${SnakeCase<`${Lowercase}${V}`>}` + : `${T}${SnakeCase<`${U}${V}`>}` + : S + +export type KebabCase = + S extends `${infer T}${infer U}${infer V}` + ? U extends Uppercase + ? `${Lowercase}-${KebabCase<`${Lowercase}${V}`>}` + : `${T}${KebabCase<`${U}${V}`>}` + : S diff --git a/packages/utils/src/event-bus/build-event-messages.ts b/packages/utils/src/event-bus/build-event-messages.ts index 40eedd9184..e3ba782488 100644 --- a/packages/utils/src/event-bus/build-event-messages.ts +++ b/packages/utils/src/event-bus/build-event-messages.ts @@ -13,19 +13,21 @@ export function buildEventMessages( options?: Record ): EventBusTypes.Message[] { const messageData_ = Array.isArray(messageData) ? messageData : [messageData] - const messages: EventBusTypes.Message[] = [] + const messages: EventBusTypes.Message[] = [] messageData_.map((data) => { const data_ = Array.isArray(data.data) ? data.data : [data.data] data_.forEach((bodyData) => { - const message = { - eventName: data.eventName, - body: { - metadata: data.metadata, - data: bodyData, - }, + const message = composeMessage(data.eventName, { + data: bodyData, + service: data.metadata.service, + entity: data.metadata.object, + action: data.metadata.action, + context: { + eventGroupId: data.metadata.eventGroupId, + } as Context, options, - } + }) messages.push(message) }) }) diff --git a/packages/utils/src/event-bus/index.ts b/packages/utils/src/event-bus/index.ts index b62ccdaf97..3bcb03dcdc 100644 --- a/packages/utils/src/event-bus/index.ts +++ b/packages/utils/src/event-bus/index.ts @@ -106,3 +106,4 @@ export abstract class AbstractEventBusModuleService export * from "./build-event-messages" export * from "./common-events" export * from "./message-aggregator" +export * from "./utils" diff --git a/packages/utils/src/event-bus/utils.ts b/packages/utils/src/event-bus/utils.ts new file mode 100644 index 0000000000..8c0798f334 --- /dev/null +++ b/packages/utils/src/event-bus/utils.ts @@ -0,0 +1,86 @@ +import { camelToSnakeCase, kebabCase, lowerCaseFirst } from "../common" +import { CommonEvents } from "./common-events" +import { KebabCase, SnakeCase } from "@medusajs/types" + +type ReturnType = TNames extends [ + infer TFirstName, + ...infer TRest +] + ? { + [K in Lowercase]: `${KebabCase}.${K}` + } & { + [K in TRest[number] as `${SnakeCase}_created`]: `${KebabCase< + K & string + >}.created` + } & { + [K in TRest[number] as `${SnakeCase}_updated`]: `${KebabCase< + K & string + >}.updated` + } & { + [K in TRest[number] as `${SnakeCase}_deleted`]: `${KebabCase< + K & string + >}.deleted` + } & { + [K in TRest[number] as `${SnakeCase}_restored`]: `${KebabCase< + K & string + >}.restored` + } & { + [K in TRest[number] as `${SnakeCase}_attached`]: `${KebabCase< + K & string + >}.attached` + } & { + [K in TRest[number] as `${SnakeCase}_detached`]: `${KebabCase< + K & string + >}.detached` + } + : {} + +/** + * From the given strings it will produce the event names accordingly. + * the result will look like: + * input: 'serviceZone' + * output: { + * created: 'fulfillment-set.created', + * updated: 'fulfillment-set.updated', + * deleted: 'fulfillment-set.deleted', + * restored: 'fulfillment-set.restored', + * attached: 'fulfillment-set.attached', + * detached: 'fulfillment-set.detached', + * service_zone_created: 'service-zone.created', + * service_zone_updated: 'service-zone.updated', + * service_zone_deleted: 'service-zone.deleted', + * service_zone_restored: 'service-zone.restored', + * service_zone_attached: 'service-zone.attached', + * service_zone_detached: 'service-zone.detached', + * ... + * } + * + * @param names + */ +export function buildEventNamesFromEntityName( + names: TNames +): ReturnType { + const events = {} + + for (let i = 0; i < names.length; i++) { + const name = names[i] + const snakedCaseName = lowerCaseFirst(camelToSnakeCase(name)) + const kebabCaseName = lowerCaseFirst(kebabCase(name)) + + if (i === 0) { + for (const event of Object.values(CommonEvents) as string[]) { + events[event] = `${kebabCaseName}.${event}` + } + continue + } + + for (const event of Object.values(CommonEvents) as string[]) { + events[`${snakedCaseName}_${event}`] = + `${kebabCaseName}.${event}` as `${KebabCase< + typeof name + >}.${typeof event}` + } + } + + return events as ReturnType +} diff --git a/packages/utils/src/fulfillment/events.ts b/packages/utils/src/fulfillment/events.ts new file mode 100644 index 0000000000..3e4c8c2973 --- /dev/null +++ b/packages/utils/src/fulfillment/events.ts @@ -0,0 +1,21 @@ +import { buildEventNamesFromEntityName } from "../event-bus" + +const eventBaseNames: [ + "fulfillmentSet", + "serviceZone", + "geoZone", + "shippingOption", + "shippingProfile", + "shippingOptionRule", + "fulfillment" +] = [ + "fulfillmentSet", + "serviceZone", + "geoZone", + "shippingOption", + "shippingProfile", + "shippingOptionRule", + "fulfillment", +] + +export const FulfillmentEvents = buildEventNamesFromEntityName(eventBaseNames) diff --git a/packages/utils/src/fulfillment/index.ts b/packages/utils/src/fulfillment/index.ts index d4fc9ea972..5ffdd6a154 100644 --- a/packages/utils/src/fulfillment/index.ts +++ b/packages/utils/src/fulfillment/index.ts @@ -1,3 +1,4 @@ export * from "./geo-zone" export * from "./shipping-options" export * from "./provider" +export * from "./events" diff --git a/packages/utils/src/modules-sdk/abstract-module-service-factory.ts b/packages/utils/src/modules-sdk/abstract-module-service-factory.ts index cdaf3bd303..33b56db2a9 100644 --- a/packages/utils/src/modules-sdk/abstract-module-service-factory.ts +++ b/packages/utils/src/modules-sdk/abstract-module-service-factory.ts @@ -12,11 +12,11 @@ import { SoftDeleteReturn, } from "@medusajs/types" import { - MapToConfig, isString, kebabCase, lowerCaseFirst, mapObjectTo, + MapToConfig, pluralize, upperCaseFirst, } from "../common" @@ -467,11 +467,19 @@ export function abstractModuleServiceFactory< this.__container__ = container this.baseRepository_ = container.baseRepository - try { - this.eventBusModuleService_ = container.eventBusModuleService - } catch { - /* ignore */ - } + const hasEventBusModuleService = Object.keys(this.__container__).find( + // TODO: Should use ModuleRegistrationName.EVENT_BUS but it would require to move it to the utils package to prevent circular dependencies + (key) => key === "eventBusModuleService" + ) + const hasEventBusService = Object.keys(this.__container__).find( + (key) => key === "eventBusService" + ) + + this.eventBusModuleService_ = hasEventBusService + ? this.__container__.eventBusService + : hasEventBusModuleService + ? this.__container__.eventBusModuleService + : undefined } protected async emitEvents_(groupedEvents) { @@ -481,7 +489,7 @@ export function abstractModuleServiceFactory< const promises: Promise[] = [] for (const group of Object.keys(groupedEvents)) { - promises.push(this.eventBusModuleService_?.emit(groupedEvents[group])) + promises.push(this.eventBusModuleService_.emit(groupedEvents[group])) } await Promise.all(promises) diff --git a/packages/utils/src/modules-sdk/decorators/emit-events.ts b/packages/utils/src/modules-sdk/decorators/emit-events.ts index d08953600d..eb8a38665f 100644 --- a/packages/utils/src/modules-sdk/decorators/emit-events.ts +++ b/packages/utils/src/modules-sdk/decorators/emit-events.ts @@ -1,7 +1,8 @@ import { MessageAggregator } from "../../event-bus" import { InjectIntoContext } from "./inject-into-context" +import {MessageAggregatorFormat} from "@medusajs/types"; -export function EmitEvents() { +export function EmitEvents(options: MessageAggregatorFormat = {} as MessageAggregatorFormat) { return function ( target: any, propertyKey: string | symbol, @@ -17,7 +18,7 @@ export function EmitEvents() { descriptor.value = async function (...args: any[]) { const result = await original.apply(this, args) - await target.emitEvents_.apply(this, [aggregator.getMessages()]) + await target.emitEvents_.apply(this, [aggregator.getMessages(options)]) aggregator.clearMessages() return result