From c658bd02334f3d29035328745c031a9315f18b0d Mon Sep 17 00:00:00 2001 From: Adrien de Peretti Date: Wed, 20 Mar 2024 13:44:26 +0100 Subject: [PATCH] feat(fulfillment): Events management (#6730) **What** This pr includes some cleanup and refactoring around the abstract event emitter method for the module service to not rely on a named class property but instead on resolution naming. Includes fulfillment set events on creation. The idea is that if that pr allows us to align and agreed on the approach as well as including the cleanup for other pr to use, If this gets merged I ll continue with another pr to do the rest of the event management partially fix CORE-1735 --- .../integration-tests/__fixtures__/events.ts | 24 ++++ .../integration-tests/__fixtures__/index.ts | 1 + .../fulfillment-set.spec.ts | 115 +++++++++++++++++- .../services/fulfillment-module-service.ts | 67 +++++++++- packages/types/src/common/common.ts | 14 +++ .../src/event-bus/build-event-messages.ts | 18 +-- packages/utils/src/event-bus/index.ts | 1 + packages/utils/src/event-bus/utils.ts | 86 +++++++++++++ packages/utils/src/fulfillment/events.ts | 21 ++++ packages/utils/src/fulfillment/index.ts | 1 + .../abstract-module-service-factory.ts | 22 ++-- .../src/modules-sdk/decorators/emit-events.ts | 5 +- 12 files changed, 356 insertions(+), 19 deletions(-) create mode 100644 packages/fulfillment/integration-tests/__fixtures__/events.ts create mode 100644 packages/utils/src/event-bus/utils.ts create mode 100644 packages/utils/src/fulfillment/events.ts 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