From 192bc336cc2b6ec3820d94524c046dcd3c4ac7d9 Mon Sep 17 00:00:00 2001 From: Oli Juhl <59018053+olivermrbl@users.noreply.github.com> Date: Fri, 12 Jan 2024 11:30:57 +0100 Subject: [PATCH 1/8] feat(cart): Partial module service implementation (#6012) Awaiting #6000 #6008 **What** - CRUD for Address in Cart Module service - Tests for CRUD Carts + Address **Not** - Line items, shipping methods, tax lines, adjustment lines --- .changeset/soft-papayas-switch.md | 7 + .../__fixtures__/cart/data.ts | 2 +- .../__tests__/services/address/index.spec.ts | 2 +- .../services/cart-module/index.spec.ts | 245 ++++++++++++++++++ .../__tests__/services/cart/index.spec.ts | 7 +- packages/cart/package.json | 2 +- packages/cart/src/initialize/index.ts | 6 +- packages/cart/src/loaders/container.ts | 6 +- packages/cart/src/models/cart.ts | 32 ++- packages/cart/src/models/index.ts | 1 + packages/cart/src/services/cart-module.ts | 107 +++++++- packages/inventory/package.json | 2 +- packages/stock-location/package.json | 2 +- packages/types/src/cart/mutations.ts | 6 + packages/types/src/cart/service.ts | 37 ++- yarn.lock | 8 +- 16 files changed, 433 insertions(+), 39 deletions(-) create mode 100644 .changeset/soft-papayas-switch.md create mode 100644 packages/cart/integration-tests/__tests__/services/cart-module/index.spec.ts diff --git a/.changeset/soft-papayas-switch.md b/.changeset/soft-papayas-switch.md new file mode 100644 index 0000000000..e822f37ec4 --- /dev/null +++ b/.changeset/soft-papayas-switch.md @@ -0,0 +1,7 @@ +--- +"@medusajs/inventory": patch +"@medusajs/stock-location": patch +"@medusajs/types": patch +--- + +feat(cart): Partial module service implementation diff --git a/packages/cart/integration-tests/__fixtures__/cart/data.ts b/packages/cart/integration-tests/__fixtures__/cart/data.ts index ba3eb43849..dd9169b24d 100644 --- a/packages/cart/integration-tests/__fixtures__/cart/data.ts +++ b/packages/cart/integration-tests/__fixtures__/cart/data.ts @@ -14,7 +14,7 @@ export const defaultCartsData = [ first_name: "Tony", last_name: "Stark", }, - billing_address_id: { + billing_address: { address_1: "Stark Industries", city: "New York", }, diff --git a/packages/cart/integration-tests/__tests__/services/address/index.spec.ts b/packages/cart/integration-tests/__tests__/services/address/index.spec.ts index 54b1395499..4ec32c1284 100644 --- a/packages/cart/integration-tests/__tests__/services/address/index.spec.ts +++ b/packages/cart/integration-tests/__tests__/services/address/index.spec.ts @@ -68,7 +68,7 @@ describe("Address Service", () => { .catch((e) => e) expect(error.message).toContain( - "Address with id \"none-existing\" not found" + 'Address with id "none-existing" not found' ) }) diff --git a/packages/cart/integration-tests/__tests__/services/cart-module/index.spec.ts b/packages/cart/integration-tests/__tests__/services/cart-module/index.spec.ts new file mode 100644 index 0000000000..151343e1d1 --- /dev/null +++ b/packages/cart/integration-tests/__tests__/services/cart-module/index.spec.ts @@ -0,0 +1,245 @@ +import { ICartModuleService } from "@medusajs/types" +import { SqlEntityManager } from "@mikro-orm/postgresql" +import { initialize } from "../../../../src/initialize" +import { DB_URL, MikroOrmWrapper } from "../../../utils" + +jest.setTimeout(30000) + +describe("Cart Module Service", () => { + let service: ICartModuleService + let repositoryManager: SqlEntityManager + + beforeEach(async () => { + await MikroOrmWrapper.setupDatabase() + repositoryManager = await MikroOrmWrapper.forkManager() + + service = await initialize({ + database: { + clientUrl: DB_URL, + schema: process.env.MEDUSA_CART_DB_SCHEMA, + }, + }) + }) + + afterEach(async () => { + await MikroOrmWrapper.clearDatabase() + }) + + describe("create", () => { + it("should throw an error when required params are not passed", async () => { + const error = await service + .create([ + { + email: "test@email.com", + } as any, + ]) + .catch((e) => e) + + expect(error.message).toContain( + "Value for Cart.currency_code is required, 'undefined' found" + ) + }) + + it("should create a cart successfully", async () => { + const [createdCart] = await service.create([ + { + currency_code: "eur", + }, + ]) + + const [cart] = await service.list({ id: [createdCart.id] }) + + expect(cart).toEqual( + expect.objectContaining({ + id: createdCart.id, + currency_code: "eur", + }) + ) + }) + + it("should create a cart with billing + shipping address successfully", async () => { + const [createdCart] = await service.create([ + { + currency_code: "eur", + billing_address: { + first_name: "John", + last_name: "Doe", + }, + shipping_address: { + first_name: "John", + last_name: "Doe", + }, + }, + ]) + + const [cart] = await service.list( + { id: [createdCart.id] }, + { relations: ["billing_address", "shipping_address"] } + ) + + expect(cart).toEqual( + expect.objectContaining({ + id: createdCart.id, + currency_code: "eur", + billing_address: expect.objectContaining({ + first_name: "John", + last_name: "Doe", + }), + shipping_address: expect.objectContaining({ + first_name: "John", + last_name: "Doe", + }), + }) + ) + }) + + it("should create a cart with billing id + shipping id successfully", async () => { + const [createdAddress] = await service.createAddresses([ + { + first_name: "John", + last_name: "Doe", + }, + ]) + + const [createdCart] = await service.create([ + { + currency_code: "eur", + billing_address_id: createdAddress.id, + shipping_address_id: createdAddress.id, + }, + ]) + + expect(createdCart).toEqual( + expect.objectContaining({ + id: createdCart.id, + currency_code: "eur", + billing_address: expect.objectContaining({ + id: createdAddress.id, + first_name: "John", + last_name: "Doe", + }), + shipping_address: expect.objectContaining({ + id: createdAddress.id, + first_name: "John", + last_name: "Doe", + }), + }) + ) + }) + }) + + describe("update", () => { + it("should throw an error if cart does not exist", async () => { + const error = await service + .update([ + { + id: "none-existing", + }, + ]) + .catch((e) => e) + + expect(error.message).toContain('Cart with id "none-existing" not found') + }) + + it("should update a cart successfully", async () => { + const [createdCart] = await service.create([ + { + currency_code: "eur", + }, + ]) + + const [updatedCart] = await service.update([ + { + id: createdCart.id, + email: "test@email.com", + }, + ]) + + const [cart] = await service.list({ id: [createdCart.id] }) + + expect(cart).toEqual( + expect.objectContaining({ + id: createdCart.id, + currency_code: "eur", + email: updatedCart.email, + }) + ) + }) + }) + + describe("delete", () => { + it("should delete a cart successfully", async () => { + const [createdCart] = await service.create([ + { + currency_code: "eur", + }, + ]) + + await service.delete([createdCart.id]) + + const carts = await service.list({ id: [createdCart.id] }) + + expect(carts.length).toEqual(0) + }) + }) + + describe("createAddresses", () => { + it("should create an address successfully", async () => { + const [createdAddress] = await service.createAddresses([ + { + first_name: "John", + }, + ]) + + const [address] = await service.listAddresses({ + id: [createdAddress.id!], + }) + + expect(address).toEqual( + expect.objectContaining({ + id: createdAddress.id, + first_name: "John", + }) + ) + }) + }) + + describe("updateAddresses", () => { + it("should update an address successfully", async () => { + const [createdAddress] = await service.createAddresses([ + { + first_name: "John", + }, + ]) + + const [updatedAddress] = await service.updateAddresses([ + { id: createdAddress.id!, first_name: "Jane" }, + ]) + + expect(updatedAddress).toEqual( + expect.objectContaining({ + id: createdAddress.id, + first_name: "Jane", + }) + ) + }) + }) + + describe("deleteAddresses", () => { + it("should delete an address successfully", async () => { + const [createdAddress] = await service.createAddresses([ + { + first_name: "John", + }, + ]) + + await service.deleteAddresses([createdAddress.id!]) + + const [address] = await service.listAddresses({ + id: [createdAddress.id!], + }) + + expect(address).toBe(undefined) + }) + }) +}) diff --git a/packages/cart/integration-tests/__tests__/services/cart/index.spec.ts b/packages/cart/integration-tests/__tests__/services/cart/index.spec.ts index b1dfeb1826..b5eb017f0a 100644 --- a/packages/cart/integration-tests/__tests__/services/cart/index.spec.ts +++ b/packages/cart/integration-tests/__tests__/services/cart/index.spec.ts @@ -17,7 +17,7 @@ describe("Cart Service", () => { testManager = await MikroOrmWrapper.forkManager() const cartRepository = new CartRepository({ - manager: repositoryManager, + manager: repositoryManager }) service = new CartService({ @@ -74,9 +74,7 @@ describe("Cart Service", () => { ]) .catch((e) => e) - expect(error.message).toContain( - "Cart with id \"none-existing\" not found" - ) + expect(error.message).toContain('Cart with id "none-existing" not found') }) it("should update a cart successfully", async () => { @@ -87,7 +85,6 @@ describe("Cart Service", () => { ]) const [updatedCart] = await service.update([ - { id: createdCart.id, email: "test@email.com", diff --git a/packages/cart/package.json b/packages/cart/package.json index c84323c977..46e9908a95 100644 --- a/packages/cart/package.json +++ b/packages/cart/package.json @@ -39,6 +39,7 @@ "orm:cache:clear": " MIKRO_ORM_CLI=./mikro-orm.config.dev.ts mikro-orm cache:clear" }, "devDependencies": { + "@medusajs/types": "workspace:^", "@mikro-orm/cli": "5.9.7", "cross-env": "^5.2.1", "jest": "^29.6.3", @@ -51,7 +52,6 @@ }, "dependencies": { "@medusajs/modules-sdk": "^1.12.5", - "@medusajs/types": "^1.11.9", "@medusajs/utils": "^1.11.2", "@mikro-orm/core": "5.9.7", "@mikro-orm/migrations": "5.9.7", diff --git a/packages/cart/src/initialize/index.ts b/packages/cart/src/initialize/index.ts index bd54c0e101..4774d6c207 100644 --- a/packages/cart/src/initialize/index.ts +++ b/packages/cart/src/initialize/index.ts @@ -10,7 +10,11 @@ import { moduleDefinition } from "../module-definition" import { InitializeModuleInjectableDependencies } from "../types" export const initialize = async ( - options?: ModulesSdkTypes.ModuleBootstrapDeclaration, + options?: + | ModulesSdkTypes.ModuleServiceInitializeOptions + | ModulesSdkTypes.ModuleServiceInitializeCustomDataLayerOptions + | ExternalModuleDeclaration + | InternalModuleDeclaration, injectedDependencies?: InitializeModuleInjectableDependencies ): Promise => { const loaded = await MedusaModule.bootstrap({ diff --git a/packages/cart/src/loaders/container.ts b/packages/cart/src/loaders/container.ts index 7d56fa3f22..a8716ea5ab 100644 --- a/packages/cart/src/loaders/container.ts +++ b/packages/cart/src/loaders/container.ts @@ -3,6 +3,7 @@ import * as defaultRepositories from "@repositories" import { LoaderOptions } from "@medusajs/modules-sdk" import { ModulesSdkTypes } from "@medusajs/types" import { loadCustomRepositories } from "@medusajs/utils" +import * as defaultServices from "@services" import { asClass } from "awilix" export default async ({ @@ -17,7 +18,8 @@ export default async ({ )?.repositories container.register({ - // cartService: asClass(defaultServices.CartService).singleton(), + cartService: asClass(defaultServices.CartService).singleton(), + addressService: asClass(defaultServices.AddressService).singleton(), }) if (customRepositories) { @@ -34,5 +36,7 @@ export default async ({ function loadDefaultRepositories({ container }) { container.register({ baseRepository: asClass(defaultRepositories.BaseRepository).singleton(), + cartRepository: asClass(defaultRepositories.CartRepository).singleton(), + addressRepository: asClass(defaultRepositories.AddressRepository).singleton(), }) } diff --git a/packages/cart/src/models/cart.ts b/packages/cart/src/models/cart.ts index 9526e3054e..2dfc02aab4 100644 --- a/packages/cart/src/models/cart.ts +++ b/packages/cart/src/models/cart.ts @@ -5,9 +5,10 @@ import { Cascade, Collection, Entity, + Index, + ManyToOne, OnInit, OneToMany, - OneToOne, OptionalProps, PrimaryKey, Property, @@ -19,7 +20,7 @@ import ShippingMethod from "./shipping-method" type OptionalCartProps = | "shipping_address" | "billing_address" - | DAL.EntityDateColumns // TODO: To be revisited when more clear + | DAL.EntityDateColumns @Entity({ tableName: "cart" }) export default class Cart { @@ -47,18 +48,22 @@ export default class Cart { @Property({ columnType: "text" }) currency_code: string - @OneToOne({ - entity: () => Address, - joinColumn: "shipping_address_id", - cascade: [Cascade.REMOVE], + @Index({ name: "IDX_cart_shipping_address_id" }) + @Property({ columnType: "text", nullable: true }) + shipping_address_id?: string | null + + @ManyToOne(() => Address, { + fieldName: "shipping_address_id", nullable: true, }) shipping_address?: Address | null - @OneToOne({ - entity: () => Address, - joinColumn: "billing_address_id", - cascade: [Cascade.REMOVE], + @Index({ name: "IDX_cart_billing_address_id" }) + @Property({ columnType: "text", nullable: true }) + billing_address_id?: string | null + + @ManyToOne(() => Address, { + fieldName: "billing_address_id", nullable: true, }) billing_address?: Address | null @@ -116,7 +121,7 @@ export default class Cart { columnType: "timestamptz", defaultRaw: "now()", }) - created_at: Date + created_at?: Date @Property({ onCreate: () => new Date(), @@ -124,7 +129,10 @@ export default class Cart { columnType: "timestamptz", defaultRaw: "now()", }) - updated_at: Date + updated_at?: Date + + @Property({ columnType: "timestamptz", nullable: true }) + deleted_at?: Date @BeforeCreate() onCreate() { diff --git a/packages/cart/src/models/index.ts b/packages/cart/src/models/index.ts index 1c9016ae7f..9451e322dc 100644 --- a/packages/cart/src/models/index.ts +++ b/packages/cart/src/models/index.ts @@ -6,3 +6,4 @@ export { default as LineItemTaxLine } from "./line-item-tax-line" export { default as ShippingMethod } from "./shipping-method" export { default as ShippingMethodAdjustmentLine } from "./shipping-method-adjustment-line" export { default as ShippingMethodTaxLine } from "./shipping-method-tax-line" + diff --git a/packages/cart/src/services/cart-module.ts b/packages/cart/src/services/cart-module.ts index b951b660b7..419c71b84f 100644 --- a/packages/cart/src/services/cart-module.ts +++ b/packages/cart/src/services/cart-module.ts @@ -1,4 +1,6 @@ import { + AddressDTO, + CartAddressDTO, CartDTO, Context, CreateCartDTO, @@ -11,11 +13,13 @@ import { UpdateCartDTO, } from "@medusajs/types" +import { FilterableAddressProps } from "@medusajs/types" import { InjectManager, InjectTransactionManager, MedusaContext, } from "@medusajs/utils" +import { CreateAddressDTO, UpdateAddressDTO } from "@types" import { joinerConfig } from "../joiner-config" import AddressService from "./address" import CartService from "./cart" @@ -32,10 +36,12 @@ export default class CartModuleService implements ICartModuleService { protected addressService_: AddressService constructor( - { baseRepository }: InjectedDependencies, + { baseRepository, cartService, addressService }: InjectedDependencies, protected readonly moduleDeclaration: InternalModuleDeclaration ) { this.baseRepository_ = baseRepository + this.cartService_ = cartService + this.addressService_ = addressService } __joinerConfig(): ModuleJoinerConfig { @@ -157,15 +163,9 @@ export default class CartModuleService implements ICartModuleService { return await this.cartService_.update(data, sharedContext) } - async delete( - ids: string[], - sharedContext?: Context - ): Promise + async delete(ids: string[], sharedContext?: Context): Promise - async delete( - ids: string, - sharedContext?: Context - ): Promise + async delete(ids: string, sharedContext?: Context): Promise @InjectTransactionManager("baseRepository_") async delete( @@ -175,4 +175,93 @@ export default class CartModuleService implements ICartModuleService { const cartIds = Array.isArray(ids) ? ids : [ids] await this.cartService_.delete(cartIds, sharedContext) } + + @InjectManager("baseRepository_") + async listAddresses( + filters: FilterableAddressProps = {}, + config: FindConfig = {}, + @MedusaContext() sharedContext: Context = {} + ) { + const addresses = await this.addressService_.list( + filters, + config, + sharedContext + ) + + return await this.baseRepository_.serialize(addresses, { + populate: true, + }) + } + + async createAddresses(data: CreateAddressDTO, sharedContext?: Context) + async createAddresses(data: CreateAddressDTO[], sharedContext?: Context) + + @InjectManager("baseRepository_") + async createAddresses( + data: CreateAddressDTO[] | CreateAddressDTO, + @MedusaContext() sharedContext: Context = {} + ) { + const input = Array.isArray(data) ? data : [data] + const addresses = await this.createAddresses_(input, sharedContext) + + const result = await this.listAddresses( + { id: addresses.map((p) => p.id) }, + {}, + sharedContext + ) + + return (Array.isArray(data) ? result : result[0]) as + | AddressDTO + | AddressDTO[] + } + + @InjectTransactionManager("baseRepository_") + protected async createAddresses_( + data: CreateAddressDTO[], + @MedusaContext() sharedContext: Context = {} + ) { + return await this.addressService_.create(data, sharedContext) + } + + async updateAddresses(data: UpdateAddressDTO, sharedContext?: Context) + async updateAddresses(data: UpdateAddressDTO[], sharedContext?: Context) + + @InjectManager("baseRepository_") + async updateAddresses( + data: UpdateAddressDTO[] | UpdateAddressDTO, + @MedusaContext() sharedContext: Context = {} + ) { + const input = Array.isArray(data) ? data : [data] + const addresses = await this.updateAddresses_(input, sharedContext) + + const result = await this.listAddresses( + { id: addresses.map((p) => p.id) }, + {}, + sharedContext + ) + + return (Array.isArray(data) ? result : result[0]) as + | AddressDTO + | AddressDTO[] + } + + @InjectTransactionManager("baseRepository_") + protected async updateAddresses_( + data: UpdateAddressDTO[], + @MedusaContext() sharedContext: Context = {} + ) { + return await this.addressService_.update(data, sharedContext) + } + + async deleteAddresses(ids: string[], sharedContext?: Context) + async deleteAddresses(ids: string, sharedContext?: Context) + + @InjectTransactionManager("baseRepository_") + async deleteAddresses( + ids: string[] | string, + @MedusaContext() sharedContext: Context = {} + ) { + const addressIds = Array.isArray(ids) ? ids : [ids] + await this.addressService_.delete(addressIds, sharedContext) + } } diff --git a/packages/inventory/package.json b/packages/inventory/package.json index 9ee5e93437..13a86a8f4e 100644 --- a/packages/inventory/package.json +++ b/packages/inventory/package.json @@ -20,7 +20,7 @@ "author": "Medusa", "license": "MIT", "devDependencies": { - "@medusajs/types": "^1.11.8", + "@medusajs/types": "^1.11.9", "cross-env": "^5.2.1", "jest": "^29.6.3", "rimraf": "^5.0.1", diff --git a/packages/stock-location/package.json b/packages/stock-location/package.json index be792ada5f..ff2eea5921 100644 --- a/packages/stock-location/package.json +++ b/packages/stock-location/package.json @@ -20,7 +20,7 @@ "author": "Medusa", "license": "MIT", "devDependencies": { - "@medusajs/types": "^1.11.8", + "@medusajs/types": "^1.11.9", "cross-env": "^5.2.1", "jest": "^29.6.3", "rimraf": "^5.0.1", diff --git a/packages/types/src/cart/mutations.ts b/packages/types/src/cart/mutations.ts index 4fd39b5274..0ed7f581aa 100644 --- a/packages/types/src/cart/mutations.ts +++ b/packages/types/src/cart/mutations.ts @@ -27,6 +27,9 @@ export interface CreateCartDTO { email?: string currency_code: string + shipping_address_id?: string + billing_address_id?: string + shipping_address?: CreateAddressDTO | UpdateAddressDTO billing_address?: CreateAddressDTO | UpdateAddressDTO @@ -42,6 +45,9 @@ export interface UpdateCartDTO { email?: string currency_code?: string + shipping_address_id?: string + billing_address_id?: string + billing_address?: CreateAddressDTO | UpdateAddressDTO shipping_address?: CreateAddressDTO | UpdateAddressDTO diff --git a/packages/types/src/cart/service.ts b/packages/types/src/cart/service.ts index 800ede55a3..caba8cfb1d 100644 --- a/packages/types/src/cart/service.ts +++ b/packages/types/src/cart/service.ts @@ -1,8 +1,14 @@ +import { AddressDTO } from "../address" import { FindConfig } from "../common" import { IModuleService } from "../modules-sdk" import { Context } from "../shared-context" -import { CartDTO, FilterableCartProps } from "./common" -import { CreateCartDTO, UpdateCartDTO } from "./mutations" +import { CartAddressDTO, CartDTO, FilterableAddressProps, FilterableCartProps } from "./common" +import { + CreateAddressDTO, + CreateCartDTO, + UpdateAddressDTO, + UpdateCartDTO, +} from "./mutations" export interface ICartModuleService extends IModuleService { retrieve( @@ -32,6 +38,33 @@ export interface ICartModuleService extends IModuleService { delete(cartIds: string[], sharedContext?: Context): Promise delete(cartId: string, sharedContext?: Context): Promise + listAddresses( + filters?: FilterableAddressProps, + config?: FindConfig, + sharedContext?: Context + ): Promise + + createAddresses( + data: CreateAddressDTO[], + sharedContext?: Context + ): Promise + createAddresses( + data: CreateAddressDTO, + sharedContext?: Context + ): Promise + + updateAddresses( + data: UpdateAddressDTO[], + sharedContext?: Context + ): Promise + updateAddresses( + data: UpdateAddressDTO, + sharedContext?: Context + ): Promise + + deleteAddresses(ids: string[], sharedContext?: Context): Promise + deleteAddresses(ids: string, sharedContext?: Context): Promise + // addLineItems(data: AddLineItemsDTO, sharedContext?: Context): Promise // addLineItems( // data: AddLineItemsDTO[], diff --git a/yarn.lock b/yarn.lock index 3edcaca246..1850f0f6e2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7888,7 +7888,7 @@ __metadata: resolution: "@medusajs/cart@workspace:packages/cart" dependencies: "@medusajs/modules-sdk": ^1.12.5 - "@medusajs/types": ^1.11.9 + "@medusajs/types": "workspace:^" "@medusajs/utils": ^1.11.2 "@mikro-orm/cli": 5.9.7 "@mikro-orm/core": 5.9.7 @@ -8072,7 +8072,7 @@ __metadata: resolution: "@medusajs/inventory@workspace:packages/inventory" dependencies: "@medusajs/modules-sdk": ^1.12.4 - "@medusajs/types": ^1.11.8 + "@medusajs/types": ^1.11.9 "@medusajs/utils": ^1.11.1 awilix: ^8.0.0 cross-env: ^5.2.1 @@ -8478,7 +8478,7 @@ __metadata: resolution: "@medusajs/stock-location@workspace:packages/stock-location" dependencies: "@medusajs/modules-sdk": ^1.12.4 - "@medusajs/types": ^1.11.8 + "@medusajs/types": ^1.11.9 "@medusajs/utils": ^1.11.1 awilix: ^8.0.0 cross-env: ^5.2.1 @@ -8516,7 +8516,7 @@ __metadata: languageName: unknown linkType: soft -"@medusajs/types@^1.10.0, @medusajs/types@^1.11.10, @medusajs/types@^1.11.5, @medusajs/types@^1.11.6, @medusajs/types@^1.11.8, @medusajs/types@^1.11.9, @medusajs/types@^1.8.10, @medusajs/types@workspace:packages/types": +"@medusajs/types@^1.10.0, @medusajs/types@^1.11.10, @medusajs/types@^1.11.5, @medusajs/types@^1.11.6, @medusajs/types@^1.11.9, @medusajs/types@^1.8.10, @medusajs/types@workspace:^, @medusajs/types@workspace:packages/types": version: 0.0.0-use.local resolution: "@medusajs/types@workspace:packages/types" dependencies: From b782d3bcb7e8088a962584b9a55200dd29c2161c Mon Sep 17 00:00:00 2001 From: Riqwan Thamir Date: Fri, 12 Jan 2024 14:00:06 +0100 Subject: [PATCH 2/8] feat(utils,types): added item/shipping adjustments for order/items/shipping_methods (#6050) what: - adds compute actions for the following cases: - items => each & across - shipping_method => each & across - order - adds a remove compute actions when code is no longer present in adjustments array RESOLVES CORE-1625 RESOLVES CORE-1626 RESOLVES CORE-1627 RESOLVES CORE-1628 RESOLVES CORE-1585 --- .changeset/lucky-ducks-chew.md | 6 + .../promotion-module/compute-actions.spec.ts | 1852 +++++++++++++++++ .../promotion-module/promotion.spec.ts | 104 +- .../src/services/promotion-module.ts | 173 +- .../src/types/promotion-rule-value.ts | 5 +- .../promotion/src/types/promotion-rule.ts | 2 +- .../src/utils/compute-actions/index.ts | 3 + .../src/utils/compute-actions/items.ts | 131 ++ .../src/utils/compute-actions/order.ts | 19 + .../utils/compute-actions/shipping-methods.ts | 114 + packages/promotion/src/utils/index.ts | 1 + .../utils/validations/application-method.ts | 4 +- .../src/utils/validations/promotion-rule.ts | 69 +- .../promotion/common/application-method.ts | 5 +- .../src/promotion/common/compute-actions.ts | 55 + packages/types/src/promotion/common/index.ts | 1 + .../promotion/common/promotion-rule-value.ts | 3 +- .../src/promotion/common/promotion-rule.ts | 8 +- .../types/src/promotion/common/promotion.ts | 3 +- packages/types/src/promotion/service.ts | 8 + .../__tests__/pick-value-from-object.spec.ts | 111 + packages/utils/src/common/index.ts | 1 + .../src/common/pick-value-from-object.ts | 37 + packages/utils/src/promotion/index.ts | 4 +- 24 files changed, 2638 insertions(+), 81 deletions(-) create mode 100644 .changeset/lucky-ducks-chew.md create mode 100644 packages/promotion/integration-tests/__tests__/services/promotion-module/compute-actions.spec.ts create mode 100644 packages/promotion/src/utils/compute-actions/index.ts create mode 100644 packages/promotion/src/utils/compute-actions/items.ts create mode 100644 packages/promotion/src/utils/compute-actions/order.ts create mode 100644 packages/promotion/src/utils/compute-actions/shipping-methods.ts create mode 100644 packages/types/src/promotion/common/compute-actions.ts create mode 100644 packages/utils/src/common/__tests__/pick-value-from-object.spec.ts create mode 100644 packages/utils/src/common/pick-value-from-object.ts diff --git a/.changeset/lucky-ducks-chew.md b/.changeset/lucky-ducks-chew.md new file mode 100644 index 0000000000..89f444bd11 --- /dev/null +++ b/.changeset/lucky-ducks-chew.md @@ -0,0 +1,6 @@ +--- +"@medusajs/types": patch +"@medusajs/utils": patch +--- + +feat(utils,types): added item/shipping adjustments for order/items/shipping_methods diff --git a/packages/promotion/integration-tests/__tests__/services/promotion-module/compute-actions.spec.ts b/packages/promotion/integration-tests/__tests__/services/promotion-module/compute-actions.spec.ts new file mode 100644 index 0000000000..69fdb9ce07 --- /dev/null +++ b/packages/promotion/integration-tests/__tests__/services/promotion-module/compute-actions.spec.ts @@ -0,0 +1,1852 @@ +import { IPromotionModuleService } from "@medusajs/types" +import { PromotionType } from "@medusajs/utils" +import { SqlEntityManager } from "@mikro-orm/postgresql" +import { initialize } from "../../../../src" +import { DB_URL, MikroOrmWrapper } from "../../../utils" + +jest.setTimeout(30000) + +describe("Promotion Service: computeActions", () => { + let service: IPromotionModuleService + let repositoryManager: SqlEntityManager + + beforeEach(async () => { + await MikroOrmWrapper.setupDatabase() + repositoryManager = MikroOrmWrapper.forkManager() + + service = await initialize({ + database: { + clientUrl: DB_URL, + schema: process.env.MEDUSA_PROMOTION_DB_SCHEMA, + }, + }) + }) + + afterEach(async () => { + await MikroOrmWrapper.clearDatabase() + }) + + describe("when code is not present in database", () => { + it("should throw error when code in promotions array does not exist", async () => { + const error = await service + .computeActions(["DOES_NOT_EXIST"], { + customer: { + customer_group: { + id: "VIP", + }, + }, + items: [ + { + id: "item_cotton_tshirt", + quantity: 1, + unit_price: 100, + product_category: { + id: "catg_cotton", + }, + product: { + id: "prod_tshirt", + }, + }, + { + id: "item_cotton_sweater", + quantity: 5, + unit_price: 150, + product_category: { + id: "catg_cotton", + }, + product: { + id: "prod_sweater", + }, + }, + ], + }) + .catch((e) => e) + + expect(error.message).toContain( + "Promotion for code (DOES_NOT_EXIST) not found" + ) + }) + + it("should throw error when code in items adjustment does not exist", async () => { + const [createdPromotion] = await service.create([ + { + code: "PROMOTION_TEST", + type: PromotionType.STANDARD, + application_method: { + type: "fixed", + target_type: "items", + allocation: "each", + value: "200", + max_quantity: 1, + }, + }, + ]) + + const error = await service + .computeActions(["PROMOTION_TEST"], { + items: [ + { + id: "item_cotton_tshirt", + quantity: 1, + unit_price: 100, + adjustments: [ + { + id: "test-adjustment", + code: "DOES_NOT_EXIST", + }, + ], + }, + { + id: "item_cotton_sweater", + quantity: 5, + unit_price: 150, + }, + ], + }) + .catch((e) => e) + + expect(error.message).toContain( + "Applied Promotion for code (DOES_NOT_EXIST) not found" + ) + }) + + it("should throw error when code in shipping adjustment does not exist", async () => { + const [createdPromotion] = await service.create([ + { + code: "PROMOTION_TEST", + type: PromotionType.STANDARD, + application_method: { + type: "fixed", + target_type: "items", + allocation: "each", + value: "200", + max_quantity: 1, + }, + }, + ]) + + const error = await service + .computeActions(["PROMOTION_TEST"], { + items: [ + { + id: "item_cotton_tshirt", + quantity: 1, + unit_price: 100, + }, + { + id: "item_cotton_sweater", + quantity: 5, + unit_price: 150, + adjustments: [ + { + id: "test-adjustment", + code: "DOES_NOT_EXIST", + }, + ], + }, + ], + }) + .catch((e) => e) + + expect(error.message).toContain( + "Applied Promotion for code (DOES_NOT_EXIST) not found" + ) + }) + }) + + describe("when promotion is for items and allocation is each", () => { + it("should compute the correct item amendments", async () => { + const [createdPromotion] = await service.create([ + { + code: "PROMOTION_TEST", + type: PromotionType.STANDARD, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "items", + allocation: "each", + value: "200", + max_quantity: 1, + target_rules: [ + { + attribute: "product_category.id", + operator: "eq", + values: ["catg_cotton"], + }, + ], + }, + }, + ]) + + const result = await service.computeActions(["PROMOTION_TEST"], { + customer: { + customer_group: { + id: "VIP", + }, + }, + items: [ + { + id: "item_cotton_tshirt", + quantity: 1, + unit_price: 100, + product_category: { + id: "catg_cotton", + }, + product: { + id: "prod_tshirt", + }, + }, + { + id: "item_cotton_sweater", + quantity: 5, + unit_price: 150, + product_category: { + id: "catg_cotton", + }, + product: { + id: "prod_sweater", + }, + }, + ], + }) + + expect(result).toEqual([ + { + action: "addItemAdjustment", + item_id: "item_cotton_tshirt", + amount: 100, + code: "PROMOTION_TEST", + }, + { + action: "addItemAdjustment", + item_id: "item_cotton_sweater", + amount: 150, + code: "PROMOTION_TEST", + }, + ]) + }) + + it("should compute the correct item amendments when there are multiple promotions to apply", async () => { + const [createdPromotion] = await service.create([ + { + code: "PROMOTION_TEST", + type: PromotionType.STANDARD, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "items", + allocation: "each", + value: "30", + max_quantity: 2, + target_rules: [ + { + attribute: "product_category.id", + operator: "eq", + values: ["catg_cotton"], + }, + ], + }, + }, + ]) + + const [createdPromotionTwo] = await service.create([ + { + code: "PROMOTION_TEST_2", + type: PromotionType.STANDARD, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "items", + allocation: "each", + value: "50", + max_quantity: 1, + target_rules: [ + { + attribute: "product_category.id", + operator: "eq", + values: ["catg_cotton"], + }, + ], + }, + }, + ]) + + const result = await service.computeActions( + ["PROMOTION_TEST", "PROMOTION_TEST_2"], + { + customer: { + customer_group: { + id: "VIP", + }, + }, + items: [ + { + id: "item_cotton_tshirt", + quantity: 1, + unit_price: 50, + product_category: { + id: "catg_cotton", + }, + product: { + id: "prod_tshirt", + }, + }, + { + id: "item_cotton_sweater", + quantity: 1, + unit_price: 150, + product_category: { + id: "catg_cotton", + }, + product: { + id: "prod_sweater", + }, + }, + ], + } + ) + + expect(result).toEqual([ + { + action: "addItemAdjustment", + item_id: "item_cotton_tshirt", + amount: 30, + code: "PROMOTION_TEST", + }, + { + action: "addItemAdjustment", + item_id: "item_cotton_sweater", + amount: 30, + code: "PROMOTION_TEST", + }, + { + action: "addItemAdjustment", + item_id: "item_cotton_tshirt", + amount: 20, + code: "PROMOTION_TEST_2", + }, + { + action: "addItemAdjustment", + item_id: "item_cotton_sweater", + amount: 50, + code: "PROMOTION_TEST_2", + }, + ]) + }) + + it("should not compute actions when applicable total is 0", async () => { + const [createdPromotion] = await service.create([ + { + code: "PROMOTION_TEST", + type: PromotionType.STANDARD, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "items", + allocation: "each", + value: "500", + max_quantity: 2, + target_rules: [ + { + attribute: "product_category.id", + operator: "eq", + values: ["catg_cotton"], + }, + ], + }, + }, + ]) + + const [createdPromotionTwo] = await service.create([ + { + code: "PROMOTION_TEST_2", + type: PromotionType.STANDARD, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "items", + allocation: "each", + value: "50", + max_quantity: 1, + target_rules: [ + { + attribute: "product_category.id", + operator: "eq", + values: ["catg_cotton"], + }, + ], + }, + }, + ]) + + const result = await service.computeActions( + ["PROMOTION_TEST", "PROMOTION_TEST_2"], + { + customer: { + customer_group: { + id: "VIP", + }, + }, + items: [ + { + id: "item_cotton_tshirt", + quantity: 1, + unit_price: 50, + product_category: { + id: "catg_cotton", + }, + product: { + id: "prod_tshirt", + }, + }, + { + id: "item_cotton_sweater", + quantity: 1, + unit_price: 150, + product_category: { + id: "catg_cotton", + }, + product: { + id: "prod_sweater", + }, + }, + ], + } + ) + + expect(result).toEqual([ + { + action: "addItemAdjustment", + item_id: "item_cotton_tshirt", + amount: 50, + code: "PROMOTION_TEST", + }, + { + action: "addItemAdjustment", + item_id: "item_cotton_sweater", + amount: 150, + code: "PROMOTION_TEST", + }, + ]) + }) + }) + + describe("when promotion is for items and allocation is across", () => { + it("should compute the correct item amendments", async () => { + const [createdPromotion] = await service.create([ + { + code: "PROMOTION_TEST", + type: PromotionType.STANDARD, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "items", + allocation: "across", + value: "200", + max_quantity: 2, + target_rules: [ + { + attribute: "product_category.id", + operator: "eq", + values: ["catg_cotton"], + }, + ], + }, + }, + ]) + + const result = await service.computeActions(["PROMOTION_TEST"], { + customer: { + customer_group: { + id: "VIP", + }, + }, + items: [ + { + id: "item_cotton_tshirt", + quantity: 1, + unit_price: 100, + product_category: { + id: "catg_cotton", + }, + product: { + id: "prod_tshirt", + }, + }, + { + id: "item_cotton_sweater", + quantity: 2, + unit_price: 150, + product_category: { + id: "catg_cotton", + }, + product: { + id: "prod_sweater", + }, + }, + ], + }) + + expect(result).toEqual([ + { + action: "addItemAdjustment", + item_id: "item_cotton_tshirt", + amount: 50, + code: "PROMOTION_TEST", + }, + { + action: "addItemAdjustment", + item_id: "item_cotton_sweater", + amount: 150, + code: "PROMOTION_TEST", + }, + ]) + }) + + it("should compute the correct item amendments when there are multiple promotions to apply", async () => { + const [createdPromotion] = await service.create([ + { + code: "PROMOTION_TEST", + type: PromotionType.STANDARD, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "items", + allocation: "across", + value: "30", + max_quantity: 2, + target_rules: [ + { + attribute: "product_category.id", + operator: "eq", + values: ["catg_cotton"], + }, + ], + }, + }, + ]) + + const [createdPromotionTwo] = await service.create([ + { + code: "PROMOTION_TEST_2", + type: PromotionType.STANDARD, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "items", + allocation: "across", + value: "50", + max_quantity: 1, + target_rules: [ + { + attribute: "product_category.id", + operator: "eq", + values: ["catg_cotton"], + }, + ], + }, + }, + ]) + + const result = await service.computeActions( + ["PROMOTION_TEST", "PROMOTION_TEST_2"], + { + customer: { + customer_group: { + id: "VIP", + }, + }, + items: [ + { + id: "item_cotton_tshirt", + quantity: 1, + unit_price: 50, + product_category: { + id: "catg_cotton", + }, + product: { + id: "prod_tshirt", + }, + }, + { + id: "item_cotton_sweater", + quantity: 1, + unit_price: 150, + product_category: { + id: "catg_cotton", + }, + product: { + id: "prod_sweater", + }, + }, + ], + } + ) + + expect(result).toEqual([ + { + action: "addItemAdjustment", + item_id: "item_cotton_tshirt", + amount: 7.5, + code: "PROMOTION_TEST", + }, + { + action: "addItemAdjustment", + item_id: "item_cotton_sweater", + amount: 22.5, + code: "PROMOTION_TEST", + }, + { + action: "addItemAdjustment", + item_id: "item_cotton_tshirt", + amount: 12.5, + code: "PROMOTION_TEST_2", + }, + { + action: "addItemAdjustment", + item_id: "item_cotton_sweater", + amount: 37.5, + code: "PROMOTION_TEST_2", + }, + ]) + }) + + it("should not compute actions when applicable total is 0", async () => { + const [createdPromotion] = await service.create([ + { + code: "PROMOTION_TEST", + type: PromotionType.STANDARD, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "items", + allocation: "across", + value: "500", + max_quantity: 2, + target_rules: [ + { + attribute: "product_category.id", + operator: "eq", + values: ["catg_cotton"], + }, + ], + }, + }, + ]) + + const [createdPromotionTwo] = await service.create([ + { + code: "PROMOTION_TEST_2", + type: PromotionType.STANDARD, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "items", + allocation: "across", + value: "50", + max_quantity: 1, + target_rules: [ + { + attribute: "product_category.id", + operator: "eq", + values: ["catg_cotton"], + }, + ], + }, + }, + ]) + + const result = await service.computeActions( + ["PROMOTION_TEST", "PROMOTION_TEST_2"], + { + customer: { + customer_group: { + id: "VIP", + }, + }, + items: [ + { + id: "item_cotton_tshirt", + quantity: 1, + unit_price: 50, + product_category: { + id: "catg_cotton", + }, + product: { + id: "prod_tshirt", + }, + }, + { + id: "item_cotton_sweater", + quantity: 1, + unit_price: 150, + product_category: { + id: "catg_cotton", + }, + product: { + id: "prod_sweater", + }, + }, + ], + } + ) + + expect(result).toEqual([ + { + action: "addItemAdjustment", + item_id: "item_cotton_tshirt", + amount: 50, + code: "PROMOTION_TEST", + }, + { + action: "addItemAdjustment", + item_id: "item_cotton_sweater", + amount: 150, + code: "PROMOTION_TEST", + }, + { + action: "addItemAdjustment", + item_id: "item_cotton_tshirt", + amount: 12.5, + code: "PROMOTION_TEST_2", + }, + { + action: "addItemAdjustment", + item_id: "item_cotton_sweater", + amount: 37.5, + code: "PROMOTION_TEST_2", + }, + ]) + }) + }) + + describe("when promotion is for shipping_method and allocation is each", () => { + it("should compute the correct shipping_method amendments", async () => { + const [createdPromotion] = await service.create([ + { + code: "PROMOTION_TEST", + type: PromotionType.STANDARD, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "shipping_methods", + allocation: "each", + value: "200", + max_quantity: 2, + target_rules: [ + { + attribute: "shipping_option.id", + operator: "in", + values: ["express", "standard"], + }, + ], + }, + }, + ]) + + const result = await service.computeActions(["PROMOTION_TEST"], { + customer: { + customer_group: { + id: "VIP", + }, + }, + shipping_methods: [ + { + id: "shipping_method_express", + unit_price: 250, + shipping_option: { + id: "express", + }, + }, + { + id: "shipping_method_standard", + unit_price: 150, + shipping_option: { + id: "standard", + }, + }, + { + id: "shipping_method_snail", + unit_price: 200, + shipping_option: { + id: "snail", + }, + }, + ], + }) + + expect(result).toEqual([ + { + action: "addShippingMethodAdjustment", + shipping_method_id: "shipping_method_express", + amount: 200, + code: "PROMOTION_TEST", + }, + { + action: "addShippingMethodAdjustment", + shipping_method_id: "shipping_method_standard", + amount: 150, + code: "PROMOTION_TEST", + }, + ]) + }) + + it("should compute the correct item amendments when there are multiple promotions to apply", async () => { + const [createdPromotion] = await service.create([ + { + code: "PROMOTION_TEST", + type: PromotionType.STANDARD, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "shipping_methods", + allocation: "each", + value: "200", + max_quantity: 2, + target_rules: [ + { + attribute: "shipping_option.id", + operator: "in", + values: ["express", "standard"], + }, + ], + }, + }, + ]) + + const [createdPromotionTwo] = await service.create([ + { + code: "PROMOTION_TEST_2", + type: PromotionType.STANDARD, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "shipping_methods", + allocation: "each", + value: "200", + max_quantity: 2, + target_rules: [ + { + attribute: "shipping_option.id", + operator: "in", + values: ["express", "standard"], + }, + ], + }, + }, + ]) + + const result = await service.computeActions( + ["PROMOTION_TEST", "PROMOTION_TEST_2"], + { + customer: { + customer_group: { + id: "VIP", + }, + }, + shipping_methods: [ + { + id: "shipping_method_express", + unit_price: 250, + shipping_option: { + id: "express", + }, + }, + { + id: "shipping_method_standard", + unit_price: 150, + shipping_option: { + id: "standard", + }, + }, + { + id: "shipping_method_snail", + unit_price: 200, + shipping_option: { + id: "snail", + }, + }, + ], + } + ) + + expect(result).toEqual([ + { + action: "addShippingMethodAdjustment", + shipping_method_id: "shipping_method_express", + amount: 200, + code: "PROMOTION_TEST", + }, + { + action: "addShippingMethodAdjustment", + shipping_method_id: "shipping_method_standard", + amount: 150, + code: "PROMOTION_TEST", + }, + { + action: "addShippingMethodAdjustment", + shipping_method_id: "shipping_method_express", + amount: 50, + code: "PROMOTION_TEST_2", + }, + ]) + }) + + it("should not compute actions when applicable total is 0", async () => { + const [createdPromotion] = await service.create([ + { + code: "PROMOTION_TEST", + type: PromotionType.STANDARD, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "shipping_methods", + allocation: "each", + value: "500", + max_quantity: 2, + target_rules: [ + { + attribute: "shipping_option.id", + operator: "in", + values: ["express", "standard"], + }, + ], + }, + }, + ]) + + const [createdPromotionTwo] = await service.create([ + { + code: "PROMOTION_TEST_2", + type: PromotionType.STANDARD, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "shipping_methods", + allocation: "each", + value: "200", + max_quantity: 2, + target_rules: [ + { + attribute: "shipping_option.id", + operator: "in", + values: ["express", "standard"], + }, + ], + }, + }, + ]) + + const result = await service.computeActions( + ["PROMOTION_TEST", "PROMOTION_TEST_2"], + { + customer: { + customer_group: { + id: "VIP", + }, + }, + shipping_methods: [ + { + id: "shipping_method_express", + unit_price: 250, + shipping_option: { + id: "express", + }, + }, + { + id: "shipping_method_standard", + unit_price: 150, + shipping_option: { + id: "standard", + }, + }, + { + id: "shipping_method_snail", + unit_price: 200, + shipping_option: { + id: "snail", + }, + }, + ], + } + ) + + expect(result).toEqual([ + { + action: "addShippingMethodAdjustment", + shipping_method_id: "shipping_method_express", + amount: 250, + code: "PROMOTION_TEST", + }, + { + action: "addShippingMethodAdjustment", + shipping_method_id: "shipping_method_standard", + amount: 150, + code: "PROMOTION_TEST", + }, + ]) + }) + }) + + describe("when promotion is for shipping_method and allocation is across", () => { + it("should compute the correct shipping_method amendments", async () => { + const [createdPromotion] = await service.create([ + { + code: "PROMOTION_TEST", + type: PromotionType.STANDARD, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "shipping_methods", + allocation: "across", + value: "200", + max_quantity: 2, + target_rules: [ + { + attribute: "shipping_option.id", + operator: "in", + values: ["express", "standard"], + }, + ], + }, + }, + ]) + + const result = await service.computeActions(["PROMOTION_TEST"], { + customer: { + customer_group: { + id: "VIP", + }, + }, + shipping_methods: [ + { + id: "shipping_method_express", + unit_price: 500, + shipping_option: { + id: "express", + }, + }, + { + id: "shipping_method_standard", + unit_price: 100, + shipping_option: { + id: "standard", + }, + }, + { + id: "shipping_method_snail", + unit_price: 200, + shipping_option: { + id: "snail", + }, + }, + ], + }) + + expect(result).toEqual([ + { + action: "addShippingMethodAdjustment", + shipping_method_id: "shipping_method_express", + amount: 166.66666666666669, + code: "PROMOTION_TEST", + }, + { + action: "addShippingMethodAdjustment", + shipping_method_id: "shipping_method_standard", + amount: 33.33333333333333, + code: "PROMOTION_TEST", + }, + ]) + }) + + it("should compute the correct item amendments when there are multiple promotions to apply", async () => { + const [createdPromotion] = await service.create([ + { + code: "PROMOTION_TEST", + type: PromotionType.STANDARD, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "shipping_methods", + allocation: "across", + value: "200", + max_quantity: 2, + target_rules: [ + { + attribute: "shipping_option.id", + operator: "in", + values: ["express", "standard"], + }, + ], + }, + }, + ]) + + const [createdPromotion2] = await service.create([ + { + code: "PROMOTION_TEST_2", + type: PromotionType.STANDARD, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "shipping_methods", + allocation: "across", + value: "200", + max_quantity: 2, + target_rules: [ + { + attribute: "shipping_option.id", + operator: "in", + values: ["express", "standard"], + }, + ], + }, + }, + ]) + + const result = await service.computeActions( + ["PROMOTION_TEST", "PROMOTION_TEST_2"], + { + customer: { + customer_group: { + id: "VIP", + }, + }, + shipping_methods: [ + { + id: "shipping_method_express", + unit_price: 500, + shipping_option: { + id: "express", + }, + }, + { + id: "shipping_method_standard", + unit_price: 100, + shipping_option: { + id: "standard", + }, + }, + { + id: "shipping_method_snail", + unit_price: 200, + shipping_option: { + id: "snail", + }, + }, + ], + } + ) + + expect(result).toEqual([ + { + action: "addShippingMethodAdjustment", + shipping_method_id: "shipping_method_express", + amount: 166.66666666666669, + code: "PROMOTION_TEST", + }, + { + action: "addShippingMethodAdjustment", + shipping_method_id: "shipping_method_standard", + amount: 33.33333333333333, + code: "PROMOTION_TEST", + }, + { + action: "addShippingMethodAdjustment", + shipping_method_id: "shipping_method_express", + amount: 83.33333333333331, + code: "PROMOTION_TEST_2", + }, + { + action: "addShippingMethodAdjustment", + shipping_method_id: "shipping_method_standard", + amount: 16.66666666666667, + code: "PROMOTION_TEST_2", + }, + ]) + }) + + it("should not compute actions when applicable total is 0", async () => { + const [createdPromotion] = await service.create([ + { + code: "PROMOTION_TEST", + type: PromotionType.STANDARD, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "shipping_methods", + allocation: "across", + value: "1000", + max_quantity: 2, + target_rules: [ + { + attribute: "shipping_option.id", + operator: "in", + values: ["express", "standard"], + }, + ], + }, + }, + ]) + + const [createdPromotion2] = await service.create([ + { + code: "PROMOTION_TEST_2", + type: PromotionType.STANDARD, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "shipping_methods", + allocation: "across", + value: "200", + max_quantity: 2, + target_rules: [ + { + attribute: "shipping_option.id", + operator: "in", + values: ["express", "standard"], + }, + ], + }, + }, + ]) + + const result = await service.computeActions( + ["PROMOTION_TEST", "PROMOTION_TEST_2"], + { + customer: { + customer_group: { + id: "VIP", + }, + }, + shipping_methods: [ + { + id: "shipping_method_express", + unit_price: 500, + shipping_option: { + id: "express", + }, + }, + { + id: "shipping_method_standard", + unit_price: 100, + shipping_option: { + id: "standard", + }, + }, + { + id: "shipping_method_snail", + unit_price: 200, + shipping_option: { + id: "snail", + }, + }, + ], + } + ) + + expect(result).toEqual([ + { + action: "addShippingMethodAdjustment", + shipping_method_id: "shipping_method_express", + amount: 500, + code: "PROMOTION_TEST", + }, + { + action: "addShippingMethodAdjustment", + shipping_method_id: "shipping_method_standard", + amount: 100, + code: "PROMOTION_TEST", + }, + ]) + }) + }) + + describe("when promotion is for the entire order", () => { + it("should compute the correct item amendments", async () => { + const [createdPromotion] = await service.create([ + { + code: "PROMOTION_TEST", + type: PromotionType.STANDARD, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "order", + value: "200", + max_quantity: 2, + }, + }, + ]) + + const result = await service.computeActions(["PROMOTION_TEST"], { + customer: { + customer_group: { + id: "VIP", + }, + }, + items: [ + { + id: "item_cotton_tshirt", + quantity: 1, + unit_price: 100, + product_category: { + id: "catg_cotton", + }, + product: { + id: "prod_tshirt", + }, + }, + { + id: "item_cotton_sweater", + quantity: 2, + unit_price: 150, + product_category: { + id: "catg_cotton", + }, + product: { + id: "prod_sweater", + }, + }, + ], + }) + + expect(result).toEqual([ + { + action: "addItemAdjustment", + item_id: "item_cotton_tshirt", + amount: 50, + code: "PROMOTION_TEST", + }, + { + action: "addItemAdjustment", + item_id: "item_cotton_sweater", + amount: 150, + code: "PROMOTION_TEST", + }, + ]) + }) + + it("should compute the correct item amendments when there are multiple promotions to apply", async () => { + const [createdPromotion] = await service.create([ + { + code: "PROMOTION_TEST", + type: PromotionType.STANDARD, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "order", + value: "30", + max_quantity: 2, + }, + }, + ]) + + const [createdPromotionTwo] = await service.create([ + { + code: "PROMOTION_TEST_2", + type: PromotionType.STANDARD, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "order", + value: "50", + max_quantity: 1, + }, + }, + ]) + + const result = await service.computeActions( + ["PROMOTION_TEST", "PROMOTION_TEST_2"], + { + customer: { + customer_group: { + id: "VIP", + }, + }, + items: [ + { + id: "item_cotton_tshirt", + quantity: 1, + unit_price: 50, + product_category: { + id: "catg_cotton", + }, + product: { + id: "prod_tshirt", + }, + }, + { + id: "item_cotton_sweater", + quantity: 1, + unit_price: 150, + product_category: { + id: "catg_cotton", + }, + product: { + id: "prod_sweater", + }, + }, + ], + } + ) + + expect(result).toEqual([ + { + action: "addItemAdjustment", + item_id: "item_cotton_tshirt", + amount: 7.5, + code: "PROMOTION_TEST", + }, + { + action: "addItemAdjustment", + item_id: "item_cotton_sweater", + amount: 22.5, + code: "PROMOTION_TEST", + }, + { + action: "addItemAdjustment", + item_id: "item_cotton_tshirt", + amount: 12.5, + code: "PROMOTION_TEST_2", + }, + { + action: "addItemAdjustment", + item_id: "item_cotton_sweater", + amount: 37.5, + code: "PROMOTION_TEST_2", + }, + ]) + }) + + it("should not compute actions when applicable total is 0", async () => { + const [createdPromotion] = await service.create([ + { + code: "PROMOTION_TEST", + type: PromotionType.STANDARD, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "order", + value: "500", + max_quantity: 2, + }, + }, + ]) + + const [createdPromotionTwo] = await service.create([ + { + code: "PROMOTION_TEST_2", + type: PromotionType.STANDARD, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "order", + value: "50", + max_quantity: 1, + }, + }, + ]) + + const result = await service.computeActions( + ["PROMOTION_TEST", "PROMOTION_TEST_2"], + { + customer: { + customer_group: { + id: "VIP", + }, + }, + items: [ + { + id: "item_cotton_tshirt", + quantity: 1, + unit_price: 50, + product_category: { + id: "catg_cotton", + }, + product: { + id: "prod_tshirt", + }, + }, + { + id: "item_cotton_sweater", + quantity: 1, + unit_price: 150, + product_category: { + id: "catg_cotton", + }, + product: { + id: "prod_sweater", + }, + }, + ], + } + ) + + expect(result).toEqual([ + { + action: "addItemAdjustment", + item_id: "item_cotton_tshirt", + amount: 50, + code: "PROMOTION_TEST", + }, + { + action: "addItemAdjustment", + item_id: "item_cotton_sweater", + amount: 150, + code: "PROMOTION_TEST", + }, + { + action: "addItemAdjustment", + item_id: "item_cotton_tshirt", + amount: 12.5, + code: "PROMOTION_TEST_2", + }, + { + action: "addItemAdjustment", + item_id: "item_cotton_sweater", + amount: 37.5, + code: "PROMOTION_TEST_2", + }, + ]) + }) + }) + + describe("when adjustments are present in the context", () => { + it("should compute the correct item amendments along with removal of applied item adjustment", async () => { + const [adjustmentPromotion] = await service.create([ + { + code: "ADJUSTMENT_CODE", + type: PromotionType.STANDARD, + }, + ]) + + const [createdPromotion] = await service.create([ + { + code: "PROMOTION_TEST", + type: PromotionType.STANDARD, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "items", + allocation: "each", + value: "200", + max_quantity: 1, + target_rules: [ + { + attribute: "product_category.id", + operator: "eq", + values: ["catg_cotton"], + }, + ], + }, + }, + ]) + + const result = await service.computeActions(["PROMOTION_TEST"], { + customer: { + customer_group: { + id: "VIP", + }, + }, + items: [ + { + id: "item_cotton_tshirt", + quantity: 1, + unit_price: 100, + product_category: { + id: "catg_cotton", + }, + product: { + id: "prod_tshirt", + }, + adjustments: [ + { + id: "test-adjustment", + code: "ADJUSTMENT_CODE", + }, + ], + }, + { + id: "item_cotton_sweater", + quantity: 5, + unit_price: 150, + product_category: { + id: "catg_cotton", + }, + product: { + id: "prod_sweater", + }, + }, + ], + }) + + expect(result).toEqual([ + { + action: "removeItemAdjustment", + adjustment_id: "test-adjustment", + }, + { + action: "addItemAdjustment", + item_id: "item_cotton_tshirt", + amount: 100, + code: "PROMOTION_TEST", + }, + { + action: "addItemAdjustment", + item_id: "item_cotton_sweater", + amount: 150, + code: "PROMOTION_TEST", + }, + ]) + }) + + it("should compute the correct item amendments along with removal of applied shipping adjustment", async () => { + const [adjustmentPromotion] = await service.create([ + { + code: "ADJUSTMENT_CODE", + type: PromotionType.STANDARD, + }, + ]) + + const [createdPromotion] = await service.create([ + { + code: "PROMOTION_TEST", + type: PromotionType.STANDARD, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "shipping_methods", + allocation: "across", + value: "200", + max_quantity: 2, + target_rules: [ + { + attribute: "shipping_option.id", + operator: "in", + values: ["express", "standard"], + }, + ], + }, + }, + ]) + + const result = await service.computeActions(["PROMOTION_TEST"], { + customer: { + customer_group: { + id: "VIP", + }, + }, + shipping_methods: [ + { + id: "shipping_method_express", + unit_price: 500, + shipping_option: { + id: "express", + }, + adjustments: [ + { + id: "test-adjustment", + code: "ADJUSTMENT_CODE", + }, + ], + }, + { + id: "shipping_method_standard", + unit_price: 100, + shipping_option: { + id: "standard", + }, + }, + { + id: "shipping_method_snail", + unit_price: 200, + shipping_option: { + id: "snail", + }, + }, + ], + }) + + expect(result).toEqual([ + { + action: "removeShippingMethodAdjustment", + adjustment_id: "test-adjustment", + }, + { + action: "addShippingMethodAdjustment", + shipping_method_id: "shipping_method_express", + amount: 166.66666666666669, + code: "PROMOTION_TEST", + }, + { + action: "addShippingMethodAdjustment", + shipping_method_id: "shipping_method_standard", + amount: 33.33333333333333, + code: "PROMOTION_TEST", + }, + ]) + }) + }) +}) diff --git a/packages/promotion/integration-tests/__tests__/services/promotion-module/promotion.spec.ts b/packages/promotion/integration-tests/__tests__/services/promotion-module/promotion.spec.ts index dc259dccc0..c98c18f949 100644 --- a/packages/promotion/integration-tests/__tests__/services/promotion-module/promotion.spec.ts +++ b/packages/promotion/integration-tests/__tests__/services/promotion-module/promotion.spec.ts @@ -99,63 +99,6 @@ describe("Promotion Service", () => { ) }) - it("should create a promotion with order application method with rules successfully", async () => { - const [createdPromotion] = await service.create([ - { - code: "PROMOTION_TEST", - type: PromotionType.STANDARD, - application_method: { - type: "fixed", - target_type: "order", - value: "100", - target_rules: [ - { - attribute: "product_id", - operator: "eq", - values: ["prod_tshirt"], - }, - ], - }, - }, - ]) - - const [promotion] = await service.list( - { - id: [createdPromotion.id], - }, - { - relations: [ - "application_method", - "application_method.target_rules.values", - ], - } - ) - - expect(promotion).toEqual( - expect.objectContaining({ - code: "PROMOTION_TEST", - is_automatic: false, - type: "standard", - application_method: expect.objectContaining({ - type: "fixed", - target_type: "order", - value: 100, - target_rules: [ - expect.objectContaining({ - attribute: "product_id", - operator: "eq", - values: expect.arrayContaining([ - expect.objectContaining({ - value: "prod_tshirt", - }), - ]), - }), - ], - }), - }) - ) - }) - it("should throw error when creating an item application method without allocation", async () => { const error = await service .create([ @@ -164,7 +107,7 @@ describe("Promotion Service", () => { type: PromotionType.STANDARD, application_method: { type: "fixed", - target_type: "item", + target_type: "items", value: "100", }, }, @@ -172,7 +115,7 @@ describe("Promotion Service", () => { .catch((e) => e) expect(error.message).toContain( - "application_method.allocation should be either 'across OR each' when application_method.target_type is either 'shipping OR item'" + "application_method.allocation should be either 'across OR each' when application_method.target_type is either 'shipping_methods OR items'" ) }) @@ -185,7 +128,7 @@ describe("Promotion Service", () => { application_method: { type: "fixed", allocation: "each", - target_type: "shipping", + target_type: "shipping_methods", value: "100", }, }, @@ -197,6 +140,33 @@ describe("Promotion Service", () => { ) }) + it("should throw error when creating an order application method with rules", async () => { + const error = await service + .create([ + { + code: "PROMOTION_TEST", + type: PromotionType.STANDARD, + application_method: { + type: "fixed", + target_type: "order", + value: "100", + target_rules: [ + { + attribute: "product_id", + operator: "eq", + values: ["prod_tshirt"], + }, + ], + }, + }, + ]) + .catch((e) => e) + + expect(error.message).toContain( + "Target rules for application method with target type (order) is not allowed" + ) + }) + it("should create a promotion with rules successfully", async () => { const [createdPromotion] = await service.create([ { @@ -390,7 +360,7 @@ describe("Promotion Service", () => { type: PromotionType.STANDARD, application_method: { type: "fixed", - target_type: "item", + target_type: "items", allocation: "across", value: "100", }, @@ -424,7 +394,7 @@ describe("Promotion Service", () => { type: PromotionType.STANDARD, application_method: { type: "fixed", - target_type: "item", + target_type: "items", allocation: "each", value: "100", max_quantity: 500, @@ -483,7 +453,7 @@ describe("Promotion Service", () => { .catch((e) => e) expect(error.message).toContain( - `application_method.target_type should be one of order, shipping, item` + `application_method.target_type should be one of order, shipping_methods, items` ) error = await service @@ -604,7 +574,7 @@ describe("Promotion Service", () => { type: PromotionType.STANDARD, application_method: { type: "fixed", - target_type: "item", + target_type: "items", allocation: "each", value: "100", max_quantity: 500, @@ -676,7 +646,7 @@ describe("Promotion Service", () => { type: PromotionType.STANDARD, application_method: { type: "fixed", - target_type: "item", + target_type: "items", allocation: "each", value: "100", max_quantity: 500, @@ -760,7 +730,7 @@ describe("Promotion Service", () => { ], application_method: { type: "fixed", - target_type: "item", + target_type: "items", allocation: "each", value: "100", max_quantity: 500, @@ -821,7 +791,7 @@ describe("Promotion Service", () => { type: PromotionType.STANDARD, application_method: { type: "fixed", - target_type: "item", + target_type: "items", allocation: "each", value: "100", max_quantity: 500, diff --git a/packages/promotion/src/services/promotion-module.ts b/packages/promotion/src/services/promotion-module.ts index fbe52aa512..07d4d6eb68 100644 --- a/packages/promotion/src/services/promotion-module.ts +++ b/packages/promotion/src/services/promotion-module.ts @@ -7,10 +7,12 @@ import { PromotionTypes, } from "@medusajs/types" import { + ApplicationMethodTargetType, InjectManager, InjectTransactionManager, MedusaContext, MedusaError, + isString, } from "@medusajs/utils" import { ApplicationMethod, Promotion } from "@models" import { @@ -23,11 +25,14 @@ import { joinerConfig } from "../joiner-config" import { CreateApplicationMethodDTO, CreatePromotionDTO, + CreatePromotionRuleDTO, UpdateApplicationMethodDTO, UpdatePromotionDTO, } from "../types" import { + ComputeActionUtils, allowedAllocationForQuantity, + areRulesValidForContext, validateApplicationMethodAttributes, validatePromotionRuleAttributes, } from "../utils" @@ -71,6 +76,161 @@ export default class PromotionModuleService< return joinerConfig } + async computeActions( + promotionCodesToApply: string[], + applicationContext: PromotionTypes.ComputeActionContext, + // TODO: specify correct type with options + options: Record = {} + ): Promise { + const computedActions: PromotionTypes.ComputeActions[] = [] + const { items = [], shipping_methods: shippingMethods = [] } = + applicationContext + const appliedItemCodes: string[] = [] + const appliedShippingCodes: string[] = [] + const codeAdjustmentMap = new Map< + string, + PromotionTypes.ComputeActionAdjustmentLine + >() + const methodIdPromoValueMap = new Map() + + items.forEach((item) => { + item.adjustments?.forEach((adjustment) => { + if (isString(adjustment.code)) { + codeAdjustmentMap.set(adjustment.code, adjustment) + appliedItemCodes.push(adjustment.code) + } + }) + }) + + shippingMethods.forEach((shippingMethod) => { + shippingMethod.adjustments?.forEach((adjustment) => { + if (isString(adjustment.code)) { + codeAdjustmentMap.set(adjustment.code, adjustment) + appliedShippingCodes.push(adjustment.code) + } + }) + }) + + const promotions = await this.list( + { + code: [ + ...promotionCodesToApply, + ...appliedItemCodes, + ...appliedShippingCodes, + ], + }, + { + relations: [ + "application_method", + "application_method.target_rules", + "application_method.target_rules.values", + "rules", + "rules.values", + ], + } + ) + + const existingPromotionsMap = new Map( + promotions.map((promotion) => [promotion.code!, promotion]) + ) + + for (const appliedCode of [...appliedShippingCodes, ...appliedItemCodes]) { + const promotion = existingPromotionsMap.get(appliedCode) + + if (!promotion) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Applied Promotion for code (${appliedCode}) not found` + ) + } + + if (promotionCodesToApply.includes(appliedCode)) { + continue + } + + if (appliedItemCodes.includes(appliedCode)) { + computedActions.push({ + action: "removeItemAdjustment", + adjustment_id: codeAdjustmentMap.get(appliedCode)!.id, + }) + } + + if (appliedShippingCodes.includes(appliedCode)) { + computedActions.push({ + action: "removeShippingMethodAdjustment", + adjustment_id: codeAdjustmentMap.get(appliedCode)!.id, + }) + } + } + + for (const promotionCode of promotionCodesToApply) { + const promotion = existingPromotionsMap.get(promotionCode) + + if (!promotion) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Promotion for code (${promotionCode}) not found` + ) + } + + const { + application_method: applicationMethod, + rules: promotionRules = [], + } = promotion + + if (!applicationMethod) { + continue + } + + const isPromotionApplicable = areRulesValidForContext( + promotionRules, + applicationContext + ) + + if (!isPromotionApplicable) { + continue + } + + if (applicationMethod.target_type === ApplicationMethodTargetType.ORDER) { + const computedActionsForItems = + ComputeActionUtils.getComputedActionsForOrder( + promotion, + applicationContext, + methodIdPromoValueMap + ) + + computedActions.push(...computedActionsForItems) + } + + if (applicationMethod.target_type === ApplicationMethodTargetType.ITEMS) { + const computedActionsForItems = + ComputeActionUtils.getComputedActionsForItems( + promotion, + applicationContext[ApplicationMethodTargetType.ITEMS], + methodIdPromoValueMap + ) + + computedActions.push(...computedActionsForItems) + } + + if ( + applicationMethod.target_type === + ApplicationMethodTargetType.SHIPPING_METHODS + ) { + const computedActionsForShippingMethods = + ComputeActionUtils.getComputedActionsForShippingMethods( + promotion, + applicationContext[ApplicationMethodTargetType.SHIPPING_METHODS], + methodIdPromoValueMap + ) + + computedActions.push(...computedActionsForShippingMethods) + } + } + + return computedActions + } + @InjectManager("baseRepository_") async retrieve( id: string, @@ -194,6 +354,17 @@ export default class PromotionModuleService< promotion, } + if ( + applicationMethodData.target_type === + ApplicationMethodTargetType.ORDER && + targetRulesData.length + ) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Target rules for application method with target type (${ApplicationMethodTargetType.ORDER}) is not allowed` + ) + } + validateApplicationMethodAttributes(applicationMethodData) applicationMethodsData.push(applicationMethodData) @@ -394,7 +565,7 @@ export default class PromotionModuleService< for (const ruleData of rulesData) { const { values, ...rest } = ruleData - const promotionRuleData = { + const promotionRuleData: CreatePromotionRuleDTO = { ...rest, [relationName]: [relation], } diff --git a/packages/promotion/src/types/promotion-rule-value.ts b/packages/promotion/src/types/promotion-rule-value.ts index 0b644e88af..38b796be7a 100644 --- a/packages/promotion/src/types/promotion-rule-value.ts +++ b/packages/promotion/src/types/promotion-rule-value.ts @@ -1,12 +1,13 @@ import { PromotionRuleDTO } from "@medusajs/types" +import { PromotionRule } from "@models" export interface CreatePromotionRuleValueDTO { value: any - promotion_rule: string | PromotionRuleDTO + promotion_rule: string | PromotionRuleDTO | PromotionRule } export interface UpdatePromotionRuleValueDTO { id: string value: any - promotion_rule: string | PromotionRuleDTO + promotion_rule: string | PromotionRuleDTO | PromotionRule } diff --git a/packages/promotion/src/types/promotion-rule.ts b/packages/promotion/src/types/promotion-rule.ts index 0a43960c84..750fe96a41 100644 --- a/packages/promotion/src/types/promotion-rule.ts +++ b/packages/promotion/src/types/promotion-rule.ts @@ -1,7 +1,7 @@ import { PromotionRuleOperatorValues } from "@medusajs/types" export interface CreatePromotionRuleDTO { - description?: string + description?: string | null attribute: string operator: PromotionRuleOperatorValues } diff --git a/packages/promotion/src/utils/compute-actions/index.ts b/packages/promotion/src/utils/compute-actions/index.ts new file mode 100644 index 0000000000..8b2cbf288c --- /dev/null +++ b/packages/promotion/src/utils/compute-actions/index.ts @@ -0,0 +1,3 @@ +export * from "./items" +export * from "./order" +export * from "./shipping-methods" diff --git a/packages/promotion/src/utils/compute-actions/items.ts b/packages/promotion/src/utils/compute-actions/items.ts new file mode 100644 index 0000000000..0e2a308e88 --- /dev/null +++ b/packages/promotion/src/utils/compute-actions/items.ts @@ -0,0 +1,131 @@ +import { + ApplicationMethodAllocationValues, + PromotionTypes, +} from "@medusajs/types" +import { + ApplicationMethodAllocation, + ApplicationMethodTargetType, + MedusaError, +} from "@medusajs/utils" +import { areRulesValidForContext } from "../validations" + +export function getComputedActionsForItems( + promotion: PromotionTypes.PromotionDTO, + itemApplicationContext: PromotionTypes.ComputeActionContext[ApplicationMethodTargetType.ITEMS], + methodIdPromoValueMap: Map, + allocationOverride?: ApplicationMethodAllocationValues +): PromotionTypes.ComputeActions[] { + const applicableItems: PromotionTypes.ComputeActionContext[ApplicationMethodTargetType.ITEMS] = + [] + + if (!itemApplicationContext) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `"items" should be present as an array in the context for computeActions` + ) + } + + for (const itemContext of itemApplicationContext) { + const isPromotionApplicableToItem = areRulesValidForContext( + promotion?.application_method?.target_rules!, + itemContext + ) + + if (!isPromotionApplicableToItem) { + continue + } + + applicableItems.push(itemContext) + } + + return applyPromotionToItems( + promotion, + applicableItems, + methodIdPromoValueMap, + allocationOverride + ) +} + +export function applyPromotionToItems( + promotion: PromotionTypes.PromotionDTO, + items: PromotionTypes.ComputeActionContext[ApplicationMethodTargetType.ITEMS], + methodIdPromoValueMap: Map, + allocationOverride?: ApplicationMethodAllocationValues +): PromotionTypes.ComputeActions[] { + const { application_method: applicationMethod } = promotion + const allocation = applicationMethod?.allocation! + const computedActions: PromotionTypes.ComputeActions[] = [] + + if ( + [allocation, allocationOverride].includes(ApplicationMethodAllocation.EACH) + ) { + for (const method of items!) { + const appliedPromoValue = methodIdPromoValueMap.get(method.id) || 0 + const promotionValue = parseFloat(applicationMethod!.value!) + const applicableTotal = + method.unit_price * + Math.min(method.quantity, applicationMethod?.max_quantity!) - + appliedPromoValue + + const amount = Math.min(promotionValue, applicableTotal) + + if (amount <= 0) { + continue + } + + methodIdPromoValueMap.set(method.id, appliedPromoValue + amount) + + computedActions.push({ + action: "addItemAdjustment", + item_id: method.id, + amount, + code: promotion.code!, + }) + } + } + + if ( + [allocation, allocationOverride].includes( + ApplicationMethodAllocation.ACROSS + ) + ) { + const totalApplicableValue = items!.reduce((acc, method) => { + const appliedPromoValue = methodIdPromoValueMap.get(method.id) || 0 + return ( + acc + + method.unit_price * + Math.min(method.quantity, applicationMethod?.max_quantity!) - + appliedPromoValue + ) + }, 0) + + for (const method of items!) { + const promotionValue = parseFloat(applicationMethod!.value!) + const appliedPromoValue = methodIdPromoValueMap.get(method.id) || 0 + + const applicableTotal = + method.unit_price * + Math.min(method.quantity, applicationMethod?.max_quantity!) - + appliedPromoValue + + // TODO: should we worry about precision here? + const applicablePromotionValue = + (applicableTotal / totalApplicableValue) * promotionValue + + const amount = Math.min(applicablePromotionValue, applicableTotal) + + if (amount <= 0) { + continue + } + + computedActions.push({ + action: "addItemAdjustment", + item_id: method.id, + amount, + code: promotion.code!, + }) + } + } + + return computedActions +} diff --git a/packages/promotion/src/utils/compute-actions/order.ts b/packages/promotion/src/utils/compute-actions/order.ts new file mode 100644 index 0000000000..c48b6b318a --- /dev/null +++ b/packages/promotion/src/utils/compute-actions/order.ts @@ -0,0 +1,19 @@ +import { PromotionTypes } from "@medusajs/types" +import { + ApplicationMethodAllocation, + ApplicationMethodTargetType, +} from "@medusajs/utils" +import { getComputedActionsForItems } from "./items" + +export function getComputedActionsForOrder( + promotion: PromotionTypes.PromotionDTO, + itemApplicationContext: PromotionTypes.ComputeActionContext, + methodIdPromoValueMap: Map +): PromotionTypes.ComputeActions[] { + return getComputedActionsForItems( + promotion, + itemApplicationContext[ApplicationMethodTargetType.ITEMS], + methodIdPromoValueMap, + ApplicationMethodAllocation.ACROSS + ) +} diff --git a/packages/promotion/src/utils/compute-actions/shipping-methods.ts b/packages/promotion/src/utils/compute-actions/shipping-methods.ts new file mode 100644 index 0000000000..2028c1b907 --- /dev/null +++ b/packages/promotion/src/utils/compute-actions/shipping-methods.ts @@ -0,0 +1,114 @@ +import { PromotionTypes } from "@medusajs/types" +import { + ApplicationMethodAllocation, + ApplicationMethodTargetType, + MedusaError, +} from "@medusajs/utils" +import { areRulesValidForContext } from "../validations" + +export function getComputedActionsForShippingMethods( + promotion: PromotionTypes.PromotionDTO, + shippingMethodApplicationContext: PromotionTypes.ComputeActionContext[ApplicationMethodTargetType.SHIPPING_METHODS], + methodIdPromoValueMap: Map +): PromotionTypes.ComputeActions[] { + const applicableShippingItems: PromotionTypes.ComputeActionContext[ApplicationMethodTargetType.SHIPPING_METHODS] = + [] + + if (!shippingMethodApplicationContext) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `"shipping_methods" should be present as an array in the context for computeActions` + ) + } + + for (const shippingMethodContext of shippingMethodApplicationContext) { + const isPromotionApplicableToItem = areRulesValidForContext( + promotion.application_method?.target_rules!, + shippingMethodContext + ) + + if (!isPromotionApplicableToItem) { + continue + } + + applicableShippingItems.push(shippingMethodContext) + } + + return applyPromotionToShippingMethods( + promotion, + applicableShippingItems, + methodIdPromoValueMap + ) +} + +export function applyPromotionToShippingMethods( + promotion: PromotionTypes.PromotionDTO, + shippingMethods: PromotionTypes.ComputeActionContext[ApplicationMethodTargetType.SHIPPING_METHODS], + methodIdPromoValueMap: Map +): PromotionTypes.ComputeActions[] { + const { application_method: applicationMethod } = promotion + const allocation = applicationMethod?.allocation! + const computedActions: PromotionTypes.ComputeActions[] = [] + + if (allocation === ApplicationMethodAllocation.EACH) { + for (const method of shippingMethods!) { + const appliedPromoValue = methodIdPromoValueMap.get(method.id) || 0 + const promotionValue = parseFloat(applicationMethod!.value!) + const applicableTotal = method.unit_price - appliedPromoValue + const amount = Math.min(promotionValue, applicableTotal) + + if (amount <= 0) { + continue + } + + methodIdPromoValueMap.set(method.id, appliedPromoValue + amount) + + computedActions.push({ + action: "addShippingMethodAdjustment", + shipping_method_id: method.id, + amount, + code: promotion.code!, + }) + } + } + + if (allocation === ApplicationMethodAllocation.ACROSS) { + const totalApplicableValue = shippingMethods!.reduce((acc, method) => { + const appliedPromoValue = methodIdPromoValueMap.get(method.id) || 0 + + return acc + method.unit_price - appliedPromoValue + }, 0) + + if (totalApplicableValue <= 0) { + return computedActions + } + + for (const method of shippingMethods!) { + const promotionValue = parseFloat(applicationMethod!.value!) + const applicableTotal = method.unit_price + const appliedPromoValue = methodIdPromoValueMap.get(method.id) || 0 + + // TODO: should we worry about precision here? + const applicablePromotionValue = + (applicableTotal / totalApplicableValue) * promotionValue - + appliedPromoValue + + const amount = Math.min(applicablePromotionValue, applicableTotal) + + if (amount <= 0) { + continue + } + + methodIdPromoValueMap.set(method.id, appliedPromoValue + amount) + + computedActions.push({ + action: "addShippingMethodAdjustment", + shipping_method_id: method.id, + amount, + code: promotion.code!, + }) + } + } + + return computedActions +} diff --git a/packages/promotion/src/utils/index.ts b/packages/promotion/src/utils/index.ts index 81a5199502..912982c245 100644 --- a/packages/promotion/src/utils/index.ts +++ b/packages/promotion/src/utils/index.ts @@ -1 +1,2 @@ +export * as ComputeActionUtils from "./compute-actions" export * from "./validations" diff --git a/packages/promotion/src/utils/validations/application-method.ts b/packages/promotion/src/utils/validations/application-method.ts index 75dc0275a0..1f7d299fa6 100644 --- a/packages/promotion/src/utils/validations/application-method.ts +++ b/packages/promotion/src/utils/validations/application-method.ts @@ -12,8 +12,8 @@ import { } from "@medusajs/utils" export const allowedAllocationTargetTypes: string[] = [ - ApplicationMethodTargetType.SHIPPING, - ApplicationMethodTargetType.ITEM, + ApplicationMethodTargetType.SHIPPING_METHODS, + ApplicationMethodTargetType.ITEMS, ] export const allowedAllocationTypes: string[] = [ diff --git a/packages/promotion/src/utils/validations/promotion-rule.ts b/packages/promotion/src/utils/validations/promotion-rule.ts index 9c6a5eb843..fa932ee0e5 100644 --- a/packages/promotion/src/utils/validations/promotion-rule.ts +++ b/packages/promotion/src/utils/validations/promotion-rule.ts @@ -1,5 +1,11 @@ -import { PromotionRuleOperatorValues } from "@medusajs/types" -import { MedusaError, PromotionRuleOperator, isPresent } from "@medusajs/utils" +import { PromotionRuleDTO, PromotionRuleOperatorValues } from "@medusajs/types" +import { + MedusaError, + PromotionRuleOperator, + isPresent, + isString, + pickValueFromObject, +} from "@medusajs/utils" import { CreatePromotionRuleDTO } from "../../types" export function validatePromotionRuleAttributes( @@ -37,3 +43,62 @@ export function validatePromotionRuleAttributes( throw new MedusaError(MedusaError.Types.INVALID_DATA, errors.join(", ")) } + +export function areRulesValidForContext( + rules: PromotionRuleDTO[], + context: Record +): boolean { + return rules.every((rule) => { + const validRuleValues = rule.values?.map((ruleValue) => ruleValue.value) + + if (!rule.attribute) { + return false + } + + const valuesToCheck = pickValueFromObject(rule.attribute, context) + + return evaluateRuleValueCondition( + validRuleValues.filter(isString), + rule.operator!, + valuesToCheck + ) + }) +} + +export function evaluateRuleValueCondition( + ruleValues: string[], + operator: string, + ruleValuesToCheck: string[] | string +) { + if (!Array.isArray(ruleValuesToCheck)) { + ruleValuesToCheck = [ruleValuesToCheck] + } + + return ruleValuesToCheck.every((ruleValueToCheck: string) => { + if (operator === "in" || operator === "eq") { + return ruleValues.some((ruleValue) => ruleValue === ruleValueToCheck) + } + + if (operator === "ne") { + return ruleValues.some((ruleValue) => ruleValue !== ruleValueToCheck) + } + + if (operator === "gt") { + return ruleValues.some((ruleValue) => ruleValue > ruleValueToCheck) + } + + if (operator === "gte") { + return ruleValues.some((ruleValue) => ruleValue >= ruleValueToCheck) + } + + if (operator === "lt") { + return ruleValues.some((ruleValue) => ruleValue < ruleValueToCheck) + } + + if (operator === "lte") { + return ruleValues.some((ruleValue) => ruleValue <= ruleValueToCheck) + } + + return false + }) +} diff --git a/packages/types/src/promotion/common/application-method.ts b/packages/types/src/promotion/common/application-method.ts index c5ce5a2d2a..77478e14b2 100644 --- a/packages/types/src/promotion/common/application-method.ts +++ b/packages/types/src/promotion/common/application-method.ts @@ -3,7 +3,10 @@ import { PromotionDTO } from "./promotion" import { CreatePromotionRuleDTO, PromotionRuleDTO } from "./promotion-rule" export type ApplicationMethodTypeValues = "fixed" | "percentage" -export type ApplicationMethodTargetTypeValues = "order" | "shipping" | "item" +export type ApplicationMethodTargetTypeValues = + | "order" + | "shipping_methods" + | "items" export type ApplicationMethodAllocationValues = "each" | "across" export interface ApplicationMethodDTO { diff --git a/packages/types/src/promotion/common/compute-actions.ts b/packages/types/src/promotion/common/compute-actions.ts new file mode 100644 index 0000000000..3770c6ce7b --- /dev/null +++ b/packages/types/src/promotion/common/compute-actions.ts @@ -0,0 +1,55 @@ +export type ComputeActions = + | AddItemAdjustmentAction + | RemoveItemAdjustmentAction + | AddShippingMethodAdjustment + | RemoveShippingMethodAdjustment + +export interface AddItemAdjustmentAction { + action: "addItemAdjustment" + item_id: string + amount: number + code: string + description?: string +} + +export interface RemoveItemAdjustmentAction { + action: "removeItemAdjustment" + adjustment_id: string + description?: string +} + +export interface AddShippingMethodAdjustment { + action: "addShippingMethodAdjustment" + shipping_method_id: string + amount: number + code: string + description?: string +} + +export interface RemoveShippingMethodAdjustment { + action: "removeShippingMethodAdjustment" + adjustment_id: string +} + +export interface ComputeActionAdjustmentLine { + id: string + code: string +} + +export interface ComputeActionItemLine { + id: string + quantity: number + unit_price: number + adjustments?: ComputeActionAdjustmentLine[] +} + +export interface ComputeActionShippingLine { + id: string + unit_price: number + adjustments?: ComputeActionAdjustmentLine[] +} + +export interface ComputeActionContext { + items?: ComputeActionItemLine[] + shipping_methods?: ComputeActionShippingLine[] +} diff --git a/packages/types/src/promotion/common/index.ts b/packages/types/src/promotion/common/index.ts index 7bb35ddb07..11bae51b17 100644 --- a/packages/types/src/promotion/common/index.ts +++ b/packages/types/src/promotion/common/index.ts @@ -1,4 +1,5 @@ export * from "./application-method" +export * from "./compute-actions" export * from "./promotion" export * from "./promotion-rule" export * from "./promotion-rule-value" diff --git a/packages/types/src/promotion/common/promotion-rule-value.ts b/packages/types/src/promotion/common/promotion-rule-value.ts index ffca6450fc..7610202a77 100644 --- a/packages/types/src/promotion/common/promotion-rule-value.ts +++ b/packages/types/src/promotion/common/promotion-rule-value.ts @@ -3,10 +3,11 @@ import { PromotionRuleDTO } from "./promotion-rule" export interface PromotionRuleValueDTO { id: string + value?: string } export interface CreatePromotionRuleValueDTO { - value: any + value: string promotion_rule: PromotionRuleDTO } diff --git a/packages/types/src/promotion/common/promotion-rule.ts b/packages/types/src/promotion/common/promotion-rule.ts index 98adedc37f..eeac63a53d 100644 --- a/packages/types/src/promotion/common/promotion-rule.ts +++ b/packages/types/src/promotion/common/promotion-rule.ts @@ -1,4 +1,5 @@ import { BaseFilterable } from "../../dal" +import { PromotionRuleValueDTO } from "./promotion-rule-value" export type PromotionRuleOperatorValues = | "gt" @@ -11,10 +12,14 @@ export type PromotionRuleOperatorValues = export interface PromotionRuleDTO { id: string + description?: string | null + attribute?: string + operator?: PromotionRuleOperatorValues + values: PromotionRuleValueDTO[] } export interface CreatePromotionRuleDTO { - description?: string + description?: string | null attribute: string operator: PromotionRuleOperatorValues values: string[] | string @@ -31,4 +36,5 @@ export interface RemovePromotionRuleDTO { export interface FilterablePromotionRuleProps extends BaseFilterable { id?: string[] + code?: string[] } diff --git a/packages/types/src/promotion/common/promotion.ts b/packages/types/src/promotion/common/promotion.ts index 7fbbef439e..9c3a47528e 100644 --- a/packages/types/src/promotion/common/promotion.ts +++ b/packages/types/src/promotion/common/promotion.ts @@ -4,7 +4,7 @@ import { CreateApplicationMethodDTO, UpdateApplicationMethodDTO, } from "./application-method" -import { CreatePromotionRuleDTO } from "./promotion-rule" +import { CreatePromotionRuleDTO, PromotionRuleDTO } from "./promotion-rule" export type PromotionType = "standard" | "buyget" @@ -14,6 +14,7 @@ export interface PromotionDTO { type?: PromotionType is_automatic?: boolean application_method?: ApplicationMethodDTO + rules?: PromotionRuleDTO[] } export interface CreatePromotionDTO { diff --git a/packages/types/src/promotion/service.ts b/packages/types/src/promotion/service.ts index cacda1faa5..a22a54d9f9 100644 --- a/packages/types/src/promotion/service.ts +++ b/packages/types/src/promotion/service.ts @@ -2,6 +2,8 @@ import { FindConfig } from "../common" import { IModuleService } from "../modules-sdk" import { Context } from "../shared-context" import { + ComputeActionContext, + ComputeActions, CreatePromotionDTO, CreatePromotionRuleDTO, FilterablePromotionProps, @@ -11,6 +13,12 @@ import { } from "./common" export interface IPromotionModuleService extends IModuleService { + computeActions( + promotionCodesToApply: string[], + applicationContext: ComputeActionContext, + options?: Record + ): Promise + create( data: CreatePromotionDTO[], sharedContext?: Context diff --git a/packages/utils/src/common/__tests__/pick-value-from-object.spec.ts b/packages/utils/src/common/__tests__/pick-value-from-object.spec.ts new file mode 100644 index 0000000000..187b8163b9 --- /dev/null +++ b/packages/utils/src/common/__tests__/pick-value-from-object.spec.ts @@ -0,0 +1,111 @@ +import { pickValueFromObject } from "../pick-value-from-object" + +describe("pickValueFromObject", function () { + it("should return true or false for different types of data", function () { + const expectations = [ + { + input: { + 1: "attribute.another_attribute", + 2: { + attribute: { + another_attribute: "test", + }, + }, + }, + output: "test", + }, + { + input: { + 1: "attribute.another_attribute.array_attribute", + 2: { + attribute: { + another_attribute: [ + { + array_attribute: "test 1", + }, + { + array_attribute: "test 2", + }, + ], + }, + }, + }, + output: ["test 1", "test 2"], + }, + { + input: { + 1: "attribute.another_attribute.array_attribute.deep_array_attribute", + 2: { + attribute: { + another_attribute: [ + { + array_attribute: [ + { + deep_array_attribute: "test 1", + }, + { + deep_array_attribute: "test 2", + }, + ], + }, + { + array_attribute: [], + }, + ], + }, + }, + }, + output: ["test 1", "test 2"], + }, + { + input: { + 1: "attribute.another_attribute.array_attribute", + 2: { + attribute: { + another_attribute: [ + { + array_attribute: [ + { + deep_array_attribute: "test 1", + }, + { + deep_array_attribute: "test 2", + }, + ], + }, + { + array_attribute: [], + }, + ], + }, + }, + }, + output: [ + { + deep_array_attribute: "test 1", + }, + { + deep_array_attribute: "test 2", + }, + ], + }, + { + input: { + 1: "attribute.missing_attribute", + 2: { + attribute: { + another_attribute: "test", + }, + }, + }, + output: undefined, + }, + ] + + expectations.forEach((expectation) => { + expect( + pickValueFromObject(expectation.input["1"], expectation.input["2"]) + ).toEqual(expectation.output) + }) + }) +}) diff --git a/packages/utils/src/common/index.ts b/packages/utils/src/common/index.ts index 7c5def8d4f..45aa19fa51 100644 --- a/packages/utils/src/common/index.ts +++ b/packages/utils/src/common/index.ts @@ -23,6 +23,7 @@ export * from "./medusa-container" export * from "./object-from-string-path" export * from "./object-to-string-path" export * from "./optional-numeric-serializer" +export * from "./pick-value-from-object" export * from "./promise-all" export * from "./remote-query-object-from-string" export * from "./remote-query-object-to-string" diff --git a/packages/utils/src/common/pick-value-from-object.ts b/packages/utils/src/common/pick-value-from-object.ts new file mode 100644 index 0000000000..7153e23b0d --- /dev/null +++ b/packages/utils/src/common/pick-value-from-object.ts @@ -0,0 +1,37 @@ +import { isObject } from "./is-object" + +export function pickValueFromObject( + path: string, + object: Record +): any { + const segments = path.split(".") + let result: any = undefined + + for (const segment of segments) { + const segmentsLeft = [...segments].splice(1, segments.length - 1) + const segmentOutput = object[segment] + + if (segmentsLeft.length === 0) { + result = segmentOutput + break + } + + if (isObject(segmentOutput)) { + result = pickValueFromObject(segmentsLeft.join("."), segmentOutput) + break + } + + if (Array.isArray(segmentOutput)) { + result = segmentOutput + .map((segmentOutput_) => + pickValueFromObject(segmentsLeft.join("."), segmentOutput_) + ) + .flat() + break + } + + result = segmentOutput + } + + return result +} diff --git a/packages/utils/src/promotion/index.ts b/packages/utils/src/promotion/index.ts index 3e298f2568..0094b41a84 100644 --- a/packages/utils/src/promotion/index.ts +++ b/packages/utils/src/promotion/index.ts @@ -10,8 +10,8 @@ export enum ApplicationMethodType { export enum ApplicationMethodTargetType { ORDER = "order", - SHIPPING = "shipping", - ITEM = "item", + SHIPPING_METHODS = "shipping_methods", + ITEMS = "items", } export enum ApplicationMethodAllocation { From fade8ea7bf560343ecbde116d226ac44053cdb8e Mon Sep 17 00:00:00 2001 From: Riqwan Thamir Date: Fri, 12 Jan 2024 14:14:34 +0100 Subject: [PATCH 3/8] feat(utils,types): campaigns and campaign budgets + services CRUD (#6063) * chore: added item/shipping adjustments for order/items/shipping_methods * chore: add validation for order type and target rules * chore: add comment for applied promotions * chore: add shipping method and item adjustments * chore: include applied promotions to items/shipping_method for each case * chore: handle case for items across and order to consider existing applications * chore: handle case for applied promo values to shipping => across * chore: added changeset * chore: update return of function * chore: campaigns and campaign budgets + services CRUD * Apply suggestions from code review Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com> * chore: minor refactor * chore: added single/bulk interfaces * Apply suggestions from code review Co-authored-by: Philip Korsholm <88927411+pKorsholm@users.noreply.github.com> * chore: use DAL date entity * chore: align nullable * Update packages/promotion/src/models/promotion-rule.ts * chore: fix types * chore: review changes * Update packages/promotion/src/utils/compute-actions/shipping-methods.ts Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com> --------- Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com> Co-authored-by: Philip Korsholm <88927411+pKorsholm@users.noreply.github.com> --- .changeset/wet-cups-kick.md | 6 + .../__fixtures__/campaigns/data.ts | 32 ++ .../__fixtures__/campaigns/index.ts | 23 ++ .../promotion-module/campaign.spec.ts | 241 +++++++++++++++ packages/promotion/jest.config.js | 2 + packages/promotion/src/loaders/container.ts | 10 + .../src/models/application-method.ts | 4 +- .../promotion/src/models/campaign-budget.ts | 81 +++++ packages/promotion/src/models/campaign.ts | 103 +++++++ packages/promotion/src/models/index.ts | 2 + .../promotion/src/models/promotion-rule.ts | 4 +- packages/promotion/src/models/promotion.ts | 20 +- .../src/repositories/campaign-budget.ts | 11 + .../promotion/src/repositories/campaign.ts | 11 + packages/promotion/src/repositories/index.ts | 2 + .../promotion/src/services/campaign-budget.ts | 105 +++++++ packages/promotion/src/services/campaign.ts | 96 ++++++ packages/promotion/src/services/index.ts | 2 + .../src/services/promotion-module.ts | 290 +++++++++++++++++- .../promotion/src/types/campaign-budget.ts | 16 + packages/promotion/src/types/campaign.ts | 18 ++ packages/promotion/src/types/index.ts | 2 + .../src/promotion/common/campaign-budget.ts | 16 + .../types/src/promotion/common/campaign.ts | 19 ++ packages/types/src/promotion/common/index.ts | 2 + packages/types/src/promotion/index.ts | 1 + packages/types/src/promotion/mutations.ts | 35 +++ packages/types/src/promotion/service.ts | 50 +++ packages/utils/src/promotion/index.ts | 5 + 29 files changed, 1181 insertions(+), 28 deletions(-) create mode 100644 .changeset/wet-cups-kick.md create mode 100644 packages/promotion/integration-tests/__fixtures__/campaigns/data.ts create mode 100644 packages/promotion/integration-tests/__fixtures__/campaigns/index.ts create mode 100644 packages/promotion/integration-tests/__tests__/services/promotion-module/campaign.spec.ts create mode 100644 packages/promotion/src/models/campaign-budget.ts create mode 100644 packages/promotion/src/models/campaign.ts create mode 100644 packages/promotion/src/repositories/campaign-budget.ts create mode 100644 packages/promotion/src/repositories/campaign.ts create mode 100644 packages/promotion/src/services/campaign-budget.ts create mode 100644 packages/promotion/src/services/campaign.ts create mode 100644 packages/promotion/src/types/campaign-budget.ts create mode 100644 packages/promotion/src/types/campaign.ts create mode 100644 packages/types/src/promotion/common/campaign-budget.ts create mode 100644 packages/types/src/promotion/common/campaign.ts create mode 100644 packages/types/src/promotion/mutations.ts diff --git a/.changeset/wet-cups-kick.md b/.changeset/wet-cups-kick.md new file mode 100644 index 0000000000..150d6489b6 --- /dev/null +++ b/.changeset/wet-cups-kick.md @@ -0,0 +1,6 @@ +--- +"@medusajs/types": patch +"@medusajs/utils": patch +--- + +feat(utils,types): campaigns and campaign budgets + services CRUD diff --git a/packages/promotion/integration-tests/__fixtures__/campaigns/data.ts b/packages/promotion/integration-tests/__fixtures__/campaigns/data.ts new file mode 100644 index 0000000000..fb659cb660 --- /dev/null +++ b/packages/promotion/integration-tests/__fixtures__/campaigns/data.ts @@ -0,0 +1,32 @@ +import { CampaignBudgetType } from "@medusajs/utils" + +export const defaultCampaignsData = [ + { + id: "campaign-id-1", + name: "campaign 1", + description: "test description", + currency: "USD", + campaign_identifier: "test-1", + starts_at: new Date("01/01/2023"), + ends_at: new Date("01/01/2024"), + budget: { + type: CampaignBudgetType.SPEND, + limit: 1000, + used: 0, + }, + }, + { + id: "campaign-id-2", + name: "campaign 1", + description: "test description", + currency: "USD", + campaign_identifier: "test-2", + starts_at: new Date("01/01/2023"), + ends_at: new Date("01/01/2024"), + budget: { + type: CampaignBudgetType.USAGE, + limit: 1000, + used: 0, + }, + }, +] diff --git a/packages/promotion/integration-tests/__fixtures__/campaigns/index.ts b/packages/promotion/integration-tests/__fixtures__/campaigns/index.ts new file mode 100644 index 0000000000..a56b537cc4 --- /dev/null +++ b/packages/promotion/integration-tests/__fixtures__/campaigns/index.ts @@ -0,0 +1,23 @@ +import { CreateCampaignDTO } from "@medusajs/types" +import { SqlEntityManager } from "@mikro-orm/postgresql" +import { Campaign } from "@models" +import { defaultCampaignsData } from "./data" + +export * from "./data" + +export async function createCampaigns( + manager: SqlEntityManager, + campaignsData: CreateCampaignDTO[] = defaultCampaignsData +): Promise { + const campaigns: Campaign[] = [] + + for (let campaignData of campaignsData) { + let campaign = manager.create(Campaign, campaignData) + + manager.persist(campaign) + + await manager.flush() + } + + return campaigns +} diff --git a/packages/promotion/integration-tests/__tests__/services/promotion-module/campaign.spec.ts b/packages/promotion/integration-tests/__tests__/services/promotion-module/campaign.spec.ts new file mode 100644 index 0000000000..277d5d3fac --- /dev/null +++ b/packages/promotion/integration-tests/__tests__/services/promotion-module/campaign.spec.ts @@ -0,0 +1,241 @@ +import { IPromotionModuleService } from "@medusajs/types" +import { SqlEntityManager } from "@mikro-orm/postgresql" +import { initialize } from "../../../../src" +import { createCampaigns } from "../../../__fixtures__/campaigns" +import { DB_URL, MikroOrmWrapper } from "../../../utils" + +jest.setTimeout(30000) + +describe("Promotion Module Service: Campaigns", () => { + let service: IPromotionModuleService + let repositoryManager: SqlEntityManager + + beforeEach(async () => { + await MikroOrmWrapper.setupDatabase() + repositoryManager = MikroOrmWrapper.forkManager() + + service = await initialize({ + database: { + clientUrl: DB_URL, + schema: process.env.MEDUSA_PROMOTION_DB_SCHEMA, + }, + }) + }) + + afterEach(async () => { + await MikroOrmWrapper.clearDatabase() + }) + + describe("createCampaigns", () => { + it("should throw an error when required params are not passed", async () => { + const error = await service + .createCampaigns([ + { + name: "test", + } as any, + ]) + .catch((e) => e) + + expect(error.message).toContain( + "Value for Campaign.campaign_identifier is required, 'undefined' found" + ) + }) + + it("should create a basic campaign successfully", async () => { + const startsAt = new Date("01/01/2024") + const endsAt = new Date("01/01/2025") + const [createdCampaign] = await service.createCampaigns([ + { + name: "test", + campaign_identifier: "test", + starts_at: startsAt, + ends_at: endsAt, + }, + ]) + + const campaign = await service.retrieveCampaign(createdCampaign.id) + + expect(campaign).toEqual( + expect.objectContaining({ + name: "test", + campaign_identifier: "test", + starts_at: startsAt, + ends_at: endsAt, + }) + ) + }) + + it("should create a campaign with campaign budget successfully", async () => { + const startsAt = new Date("01/01/2024") + const endsAt = new Date("01/01/2025") + + const [createdPromotion] = await service.createCampaigns([ + { + name: "test", + campaign_identifier: "test", + starts_at: startsAt, + ends_at: endsAt, + budget: { + limit: 1000, + type: "usage", + used: 10, + }, + }, + ]) + + const campaign = await service.retrieveCampaign(createdPromotion.id, { + relations: ["budget"], + }) + + expect(campaign).toEqual( + expect.objectContaining({ + name: "test", + campaign_identifier: "test", + starts_at: startsAt, + ends_at: endsAt, + budget: expect.objectContaining({ + limit: 1000, + type: "usage", + used: 10, + }), + }) + ) + }) + }) + + describe("updateCampaigns", () => { + it("should throw an error when required params are not passed", async () => { + const error = await service + .updateCampaigns([ + { + name: "test", + } as any, + ]) + .catch((e) => e) + + expect(error.message).toContain('Campaign with id "" not found') + }) + + it("should update the attributes of a campaign successfully", async () => { + await createCampaigns(repositoryManager) + + const [updatedCampaign] = await service.updateCampaigns([ + { + id: "campaign-id-1", + description: "test description 1", + currency: "EUR", + campaign_identifier: "new", + starts_at: new Date("01/01/2024"), + ends_at: new Date("01/01/2025"), + }, + ]) + + expect(updatedCampaign).toEqual( + expect.objectContaining({ + description: "test description 1", + currency: "EUR", + campaign_identifier: "new", + starts_at: new Date("01/01/2024"), + ends_at: new Date("01/01/2025"), + }) + ) + }) + + it("should update the attributes of a campaign budget successfully", async () => { + await createCampaigns(repositoryManager) + + const [updatedCampaign] = await service.updateCampaigns([ + { + id: "campaign-id-1", + budget: { + limit: 100, + used: 100, + }, + }, + ]) + + expect(updatedCampaign).toEqual( + expect.objectContaining({ + budget: expect.objectContaining({ + limit: 100, + used: 100, + }), + }) + ) + }) + }) + + describe("retrieveCampaign", () => { + beforeEach(async () => { + await createCampaigns(repositoryManager) + }) + + const id = "campaign-id-1" + + it("should return campaign for the given id", async () => { + const campaign = await service.retrieveCampaign(id) + + expect(campaign).toEqual( + expect.objectContaining({ + id, + }) + ) + }) + + it("should throw an error when campaign with id does not exist", async () => { + let error + + try { + await service.retrieveCampaign("does-not-exist") + } catch (e) { + error = e + } + + expect(error.message).toEqual( + "Campaign with id: does-not-exist was not found" + ) + }) + + it("should throw an error when a id is not provided", async () => { + let error + + try { + await service.retrieveCampaign(undefined as unknown as string) + } catch (e) { + error = e + } + + expect(error.message).toEqual('"campaignId" must be defined') + }) + + it("should return campaign based on config select param", async () => { + const campaign = await service.retrieveCampaign(id, { + select: ["id"], + }) + + const serialized = JSON.parse(JSON.stringify(campaign)) + + expect(serialized).toEqual({ + id, + }) + }) + }) + + describe("deleteCampaigns", () => { + beforeEach(async () => { + await createCampaigns(repositoryManager) + }) + + const id = "campaign-id-1" + + it("should delete the campaigns given an id successfully", async () => { + await service.deleteCampaigns([id]) + + const campaigns = await service.list({ + id: [id], + }) + + expect(campaigns).toHaveLength(0) + }) + }) +}) diff --git a/packages/promotion/jest.config.js b/packages/promotion/jest.config.js index 860ba90a49..a3d93259ca 100644 --- a/packages/promotion/jest.config.js +++ b/packages/promotion/jest.config.js @@ -3,6 +3,8 @@ module.exports = { "^@models": "/src/models", "^@services": "/src/services", "^@repositories": "/src/repositories", + "^@utils": "/src/utils", + "^@types": "/src/types", }, transform: { "^.+\\.[jt]s?$": [ diff --git a/packages/promotion/src/loaders/container.ts b/packages/promotion/src/loaders/container.ts index 39f3881115..9168c3c5a1 100644 --- a/packages/promotion/src/loaders/container.ts +++ b/packages/promotion/src/loaders/container.ts @@ -28,6 +28,10 @@ export default async ({ applicationMethodService: asClass( defaultServices.ApplicationMethodService ).singleton(), + campaignService: asClass(defaultServices.CampaignService).singleton(), + campaignBudgetService: asClass( + defaultServices.CampaignBudgetService + ).singleton(), }) if (customRepositories) { @@ -56,5 +60,11 @@ function loadDefaultRepositories({ container }) { promotionRuleValueRepository: asClass( defaultRepositories.PromotionRuleValueRepository ).singleton(), + campaignRepository: asClass( + defaultRepositories.CampaignRepository + ).singleton(), + campaignBudgetRepository: asClass( + defaultRepositories.CampaignBudgetRepository + ).singleton(), }) } diff --git a/packages/promotion/src/models/application-method.ts b/packages/promotion/src/models/application-method.ts index 6eab16e0e6..57931ba0c5 100644 --- a/packages/promotion/src/models/application-method.ts +++ b/packages/promotion/src/models/application-method.ts @@ -2,6 +2,7 @@ import { ApplicationMethodAllocationValues, ApplicationMethodTargetTypeValues, ApplicationMethodTypeValues, + DAL, } from "@medusajs/types" import { PromotionUtils, generateEntityId } from "@medusajs/utils" import { @@ -24,9 +25,8 @@ type OptionalFields = | "value" | "max_quantity" | "allocation" - | "created_at" - | "updated_at" | "deleted_at" + | DAL.EntityDateColumns @Entity() export default class ApplicationMethod { diff --git a/packages/promotion/src/models/campaign-budget.ts b/packages/promotion/src/models/campaign-budget.ts new file mode 100644 index 0000000000..ee0c447842 --- /dev/null +++ b/packages/promotion/src/models/campaign-budget.ts @@ -0,0 +1,81 @@ +import { CampaignBudgetTypeValues, DAL } from "@medusajs/types" +import { PromotionUtils, generateEntityId } from "@medusajs/utils" +import { + BeforeCreate, + Entity, + Enum, + Index, + OnInit, + OneToOne, + OptionalProps, + PrimaryKey, + Property, +} from "@mikro-orm/core" +import Campaign from "./campaign" + +type OptionalFields = + | "description" + | "limit" + | "used" + | "deleted_at" + | DAL.EntityDateColumns + +@Entity() +export default class CampaignBudget { + [OptionalProps]?: OptionalFields + + @PrimaryKey({ columnType: "text" }) + id!: string + + @Index({ name: "IDX_campaign_budget_type" }) + @Enum(() => PromotionUtils.CampaignBudgetType) + type: CampaignBudgetTypeValues + + @OneToOne({ + entity: () => Campaign, + }) + campaign?: Campaign + + @Property({ + columnType: "numeric", + nullable: true, + serializer: Number, + default: null, + }) + limit: number | null + + @Property({ + columnType: "numeric", + serializer: Number, + default: 0, + }) + used: number + + @Property({ + onCreate: () => new Date(), + columnType: "timestamptz", + defaultRaw: "now()", + }) + created_at: Date + + @Property({ + onCreate: () => new Date(), + onUpdate: () => new Date(), + columnType: "timestamptz", + defaultRaw: "now()", + }) + updated_at: Date + + @Property({ columnType: "timestamptz", nullable: true }) + deleted_at: Date | null + + @BeforeCreate() + onCreate() { + this.id = generateEntityId(this.id, "probudg") + } + + @OnInit() + onInit() { + this.id = generateEntityId(this.id, "probudg") + } +} diff --git a/packages/promotion/src/models/campaign.ts b/packages/promotion/src/models/campaign.ts new file mode 100644 index 0000000000..c721348e81 --- /dev/null +++ b/packages/promotion/src/models/campaign.ts @@ -0,0 +1,103 @@ +import { DAL } from "@medusajs/types" +import { generateEntityId } from "@medusajs/utils" +import { + BeforeCreate, + Collection, + Entity, + OnInit, + OneToMany, + OneToOne, + OptionalProps, + PrimaryKey, + Property, + Unique, +} from "@mikro-orm/core" +import CampaignBudget from "./campaign-budget" +import Promotion from "./promotion" + +type OptionalFields = + | "description" + | "currency" + | "starts_at" + | "ends_at" + | "deleted_at" + | DAL.EntityDateColumns + +type OptionalRelations = "budget" + +@Entity() +export default class Campaign { + [OptionalProps]?: OptionalFields | OptionalRelations + + @PrimaryKey({ columnType: "text" }) + id!: string + + @Property({ columnType: "text" }) + name: string + + @Property({ columnType: "text", nullable: true }) + description: string | null + + @Property({ columnType: "text", nullable: true }) + currency: string | null + + @Property({ columnType: "text" }) + @Unique({ + name: "IDX_campaign_identifier_unique", + properties: ["campaign_identifier"], + }) + campaign_identifier: string + + @Property({ + columnType: "timestamptz", + nullable: true, + }) + starts_at: Date | null + + @Property({ + columnType: "timestamptz", + nullable: true, + }) + ends_at: Date | null + + @OneToOne({ + entity: () => CampaignBudget, + mappedBy: (cb) => cb.campaign, + cascade: ["soft-remove"] as any, + nullable: true, + }) + budget: CampaignBudget | null + + @OneToMany(() => Promotion, (promotion) => promotion.campaign, { + orphanRemoval: true, + }) + promotions = new Collection(this) + + @Property({ + onCreate: () => new Date(), + columnType: "timestamptz", + defaultRaw: "now()", + }) + created_at: Date + + @Property({ + onCreate: () => new Date(), + onUpdate: () => new Date(), + columnType: "timestamptz", + defaultRaw: "now()", + }) + updated_at: Date + + @Property({ columnType: "timestamptz", nullable: true }) + deleted_at: Date | null + + @BeforeCreate() + onCreate() { + this.id = generateEntityId(this.id, "procamp") + } + + @OnInit() + onInit() { + this.id = generateEntityId(this.id, "procamp") + } +} diff --git a/packages/promotion/src/models/index.ts b/packages/promotion/src/models/index.ts index 1f6f773839..057dc73c77 100644 --- a/packages/promotion/src/models/index.ts +++ b/packages/promotion/src/models/index.ts @@ -1,4 +1,6 @@ export { default as ApplicationMethod } from "./application-method" +export { default as Campaign } from "./campaign" +export { default as CampaignBudget } from "./campaign-budget" export { default as Promotion } from "./promotion" export { default as PromotionRule } from "./promotion-rule" export { default as PromotionRuleValue } from "./promotion-rule-value" diff --git a/packages/promotion/src/models/promotion-rule.ts b/packages/promotion/src/models/promotion-rule.ts index dfe7311cd2..8241f8a6e7 100644 --- a/packages/promotion/src/models/promotion-rule.ts +++ b/packages/promotion/src/models/promotion-rule.ts @@ -1,4 +1,4 @@ -import { PromotionRuleOperatorValues } from "@medusajs/types" +import { DAL, PromotionRuleOperatorValues } from "@medusajs/types" import { PromotionUtils, generateEntityId } from "@medusajs/utils" import { BeforeCreate, @@ -18,7 +18,7 @@ import ApplicationMethod from "./application-method" import Promotion from "./promotion" import PromotionRuleValue from "./promotion-rule-value" -type OptionalFields = "description" | "created_at" | "updated_at" | "deleted_at" +type OptionalFields = "description" | "deleted_at" | DAL.EntityDateColumns type OptionalRelations = "values" | "promotions" @Entity() diff --git a/packages/promotion/src/models/promotion.ts b/packages/promotion/src/models/promotion.ts index 94af940a8e..6327e35b77 100644 --- a/packages/promotion/src/models/promotion.ts +++ b/packages/promotion/src/models/promotion.ts @@ -1,4 +1,4 @@ -import { PromotionType } from "@medusajs/types" +import { DAL, PromotionType } from "@medusajs/types" import { PromotionUtils, generateEntityId } from "@medusajs/utils" import { BeforeCreate, @@ -7,6 +7,7 @@ import { Enum, Index, ManyToMany, + ManyToOne, OnInit, OneToOne, OptionalProps, @@ -15,14 +16,12 @@ import { Unique, } from "@mikro-orm/core" import ApplicationMethod from "./application-method" +import Campaign from "./campaign" import PromotionRule from "./promotion-rule" -type OptionalFields = - | "is_automatic" - | "created_at" - | "updated_at" - | "deleted_at" -type OptionalRelations = "application_method" +type OptionalFields = "is_automatic" | "deleted_at" | DAL.EntityDateColumns +type OptionalRelations = "application_method" | "campaign" + @Entity() export default class Promotion { [OptionalProps]?: OptionalFields | OptionalRelations @@ -38,6 +37,13 @@ export default class Promotion { }) code: string + @ManyToOne(() => Campaign, { + joinColumn: "campaign", + fieldName: "campaign_id", + nullable: true, + }) + campaign: Campaign + @Property({ columnType: "boolean", default: false }) is_automatic?: boolean = false diff --git a/packages/promotion/src/repositories/campaign-budget.ts b/packages/promotion/src/repositories/campaign-budget.ts new file mode 100644 index 0000000000..32e4b98edd --- /dev/null +++ b/packages/promotion/src/repositories/campaign-budget.ts @@ -0,0 +1,11 @@ +import { DALUtils } from "@medusajs/utils" +import { CampaignBudget } from "@models" +import { CreateCampaignBudgetDTO, UpdateCampaignBudgetDTO } from "@types" + +export class CampaignBudgetRepository extends DALUtils.mikroOrmBaseRepositoryFactory< + CampaignBudget, + { + create: CreateCampaignBudgetDTO + update: UpdateCampaignBudgetDTO + } +>(CampaignBudget) {} diff --git a/packages/promotion/src/repositories/campaign.ts b/packages/promotion/src/repositories/campaign.ts new file mode 100644 index 0000000000..5bc683689b --- /dev/null +++ b/packages/promotion/src/repositories/campaign.ts @@ -0,0 +1,11 @@ +import { DALUtils } from "@medusajs/utils" +import { Campaign } from "@models" +import { CreateCampaignDTO, UpdateCampaignDTO } from "@types" + +export class CampaignRepository extends DALUtils.mikroOrmBaseRepositoryFactory< + Campaign, + { + create: CreateCampaignDTO + update: UpdateCampaignDTO + } +>(Campaign) {} diff --git a/packages/promotion/src/repositories/index.ts b/packages/promotion/src/repositories/index.ts index 421433c704..724cb68578 100644 --- a/packages/promotion/src/repositories/index.ts +++ b/packages/promotion/src/repositories/index.ts @@ -1,5 +1,7 @@ export { MikroOrmBaseRepository as BaseRepository } from "@medusajs/utils" export { ApplicationMethodRepository } from "./application-method" +export { CampaignRepository } from "./campaign" +export { CampaignBudgetRepository } from "./campaign-budget" export { PromotionRepository } from "./promotion" export { PromotionRuleRepository } from "./promotion-rule" export { PromotionRuleValueRepository } from "./promotion-rule-value" diff --git a/packages/promotion/src/services/campaign-budget.ts b/packages/promotion/src/services/campaign-budget.ts new file mode 100644 index 0000000000..affef9a89b --- /dev/null +++ b/packages/promotion/src/services/campaign-budget.ts @@ -0,0 +1,105 @@ +import { Context, DAL, FindConfig, PromotionTypes } from "@medusajs/types" +import { + InjectManager, + InjectTransactionManager, + MedusaContext, + ModulesSdkUtils, + retrieveEntity, +} from "@medusajs/utils" +import { CampaignBudget } from "@models" +import { CampaignBudgetRepository } from "@repositories" +import { CreateCampaignBudgetDTO, UpdateCampaignBudgetDTO } from "../types" + +type InjectedDependencies = { + campaignBudgetRepository: DAL.RepositoryService +} + +export default class CampaignBudgetService< + TEntity extends CampaignBudget = CampaignBudget +> { + protected readonly campaignBudgetRepository_: DAL.RepositoryService + + constructor({ campaignBudgetRepository }: InjectedDependencies) { + this.campaignBudgetRepository_ = campaignBudgetRepository + } + + @InjectManager("campaignBudgetRepository_") + async retrieve( + campaignBudgetId: string, + config: FindConfig = {}, + @MedusaContext() sharedContext: Context = {} + ): Promise { + return (await retrieveEntity< + CampaignBudget, + PromotionTypes.CampaignBudgetDTO + >({ + id: campaignBudgetId, + entityName: CampaignBudget.name, + repository: this.campaignBudgetRepository_, + config, + sharedContext, + })) as TEntity + } + + @InjectManager("campaignBudgetRepository_") + async list( + filters: PromotionTypes.FilterableCampaignBudgetProps = {}, + config: FindConfig = {}, + @MedusaContext() sharedContext: Context = {} + ): Promise { + const queryOptions = ModulesSdkUtils.buildQuery( + filters, + config + ) + + return (await this.campaignBudgetRepository_.find( + queryOptions, + sharedContext + )) as TEntity[] + } + + @InjectManager("campaignBudgetRepository_") + async listAndCount( + filters: PromotionTypes.FilterableCampaignBudgetProps = {}, + config: FindConfig = {}, + @MedusaContext() sharedContext: Context = {} + ): Promise<[TEntity[], number]> { + const queryOptions = ModulesSdkUtils.buildQuery( + filters, + config + ) + + return (await this.campaignBudgetRepository_.findAndCount( + queryOptions, + sharedContext + )) as [TEntity[], number] + } + + @InjectTransactionManager("campaignBudgetRepository_") + async create( + data: CreateCampaignBudgetDTO[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + return (await ( + this.campaignBudgetRepository_ as CampaignBudgetRepository + ).create(data, sharedContext)) as TEntity[] + } + + @InjectTransactionManager("campaignBudgetRepository_") + async update( + data: UpdateCampaignBudgetDTO[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + return (await ( + this.campaignBudgetRepository_ as CampaignBudgetRepository + ).update(data, sharedContext)) as TEntity[] + } + + @InjectTransactionManager("campaignBudgetRepository_") + async delete( + ids: string[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + await this.campaignBudgetRepository_.delete(ids, sharedContext) + } +} diff --git a/packages/promotion/src/services/campaign.ts b/packages/promotion/src/services/campaign.ts new file mode 100644 index 0000000000..e7cf2f6a1d --- /dev/null +++ b/packages/promotion/src/services/campaign.ts @@ -0,0 +1,96 @@ +import { Context, DAL, FindConfig, PromotionTypes } from "@medusajs/types" +import { + InjectManager, + InjectTransactionManager, + MedusaContext, + ModulesSdkUtils, + retrieveEntity, +} from "@medusajs/utils" +import { Campaign } from "@models" +import { CampaignRepository } from "@repositories" +import { CreateCampaignDTO, UpdateCampaignDTO } from "../types" + +type InjectedDependencies = { + campaignRepository: DAL.RepositoryService +} + +export default class CampaignService { + protected readonly campaignRepository_: DAL.RepositoryService + + constructor({ campaignRepository }: InjectedDependencies) { + this.campaignRepository_ = campaignRepository + } + + @InjectManager("campaignRepository_") + async retrieve( + campaignId: string, + config: FindConfig = {}, + @MedusaContext() sharedContext: Context = {} + ): Promise { + return (await retrieveEntity({ + id: campaignId, + entityName: Campaign.name, + repository: this.campaignRepository_, + config, + sharedContext, + })) as TEntity + } + + @InjectManager("campaignRepository_") + async list( + filters: PromotionTypes.FilterableCampaignProps = {}, + config: FindConfig = {}, + @MedusaContext() sharedContext: Context = {} + ): Promise { + const queryOptions = ModulesSdkUtils.buildQuery(filters, config) + + return (await this.campaignRepository_.find( + queryOptions, + sharedContext + )) as TEntity[] + } + + @InjectManager("campaignRepository_") + async listAndCount( + filters: PromotionTypes.FilterableCampaignProps = {}, + config: FindConfig = {}, + @MedusaContext() sharedContext: Context = {} + ): Promise<[TEntity[], number]> { + const queryOptions = ModulesSdkUtils.buildQuery(filters, config) + + return (await this.campaignRepository_.findAndCount( + queryOptions, + sharedContext + )) as [TEntity[], number] + } + + @InjectTransactionManager("campaignRepository_") + async create( + data: CreateCampaignDTO[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + return (await (this.campaignRepository_ as CampaignRepository).create( + data, + sharedContext + )) as TEntity[] + } + + @InjectTransactionManager("campaignRepository_") + async update( + data: UpdateCampaignDTO[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + return (await (this.campaignRepository_ as CampaignRepository).update( + data, + sharedContext + )) as TEntity[] + } + + @InjectTransactionManager("campaignRepository_") + async delete( + ids: string[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + await this.campaignRepository_.delete(ids, sharedContext) + } +} diff --git a/packages/promotion/src/services/index.ts b/packages/promotion/src/services/index.ts index e18681c6a4..dcd93f46b8 100644 --- a/packages/promotion/src/services/index.ts +++ b/packages/promotion/src/services/index.ts @@ -1,4 +1,6 @@ export { default as ApplicationMethodService } from "./application-method" +export { default as CampaignService } from "./campaign" +export { default as CampaignBudgetService } from "./campaign-budget" export { default as PromotionService } from "./promotion" export { default as PromotionModuleService } from "./promotion-module" export { default as PromotionRuleService } from "./promotion-rule" diff --git a/packages/promotion/src/services/promotion-module.ts b/packages/promotion/src/services/promotion-module.ts index 07d4d6eb68..275d74e265 100644 --- a/packages/promotion/src/services/promotion-module.ts +++ b/packages/promotion/src/services/promotion-module.ts @@ -17,25 +17,31 @@ import { import { ApplicationMethod, Promotion } from "@models" import { ApplicationMethodService, + CampaignBudgetService, + CampaignService, PromotionRuleService, PromotionRuleValueService, PromotionService, } from "@services" -import { joinerConfig } from "../joiner-config" import { CreateApplicationMethodDTO, + CreateCampaignBudgetDTO, + CreateCampaignDTO, CreatePromotionDTO, CreatePromotionRuleDTO, UpdateApplicationMethodDTO, + UpdateCampaignBudgetDTO, + UpdateCampaignDTO, UpdatePromotionDTO, -} from "../types" +} from "@types" import { ComputeActionUtils, allowedAllocationForQuantity, areRulesValidForContext, validateApplicationMethodAttributes, validatePromotionRuleAttributes, -} from "../utils" +} from "@utils" +import { joinerConfig } from "../joiner-config" type InjectedDependencies = { baseRepository: DAL.RepositoryService @@ -43,6 +49,8 @@ type InjectedDependencies = { applicationMethodService: ApplicationMethodService promotionRuleService: PromotionRuleService promotionRuleValueService: PromotionRuleValueService + campaignService: CampaignService + campaignBudgetService: CampaignBudgetService } export default class PromotionModuleService< @@ -54,6 +62,8 @@ export default class PromotionModuleService< protected applicationMethodService_: ApplicationMethodService protected promotionRuleService_: PromotionRuleService protected promotionRuleValueService_: PromotionRuleValueService + protected campaignService_: CampaignService + protected campaignBudgetService_: CampaignBudgetService constructor( { @@ -62,6 +72,8 @@ export default class PromotionModuleService< applicationMethodService, promotionRuleService, promotionRuleValueService, + campaignService, + campaignBudgetService, }: InjectedDependencies, protected readonly moduleDeclaration: InternalModuleDeclaration ) { @@ -70,6 +82,8 @@ export default class PromotionModuleService< this.applicationMethodService_ = applicationMethodService this.promotionRuleService_ = promotionRuleService this.promotionRuleValueService_ = promotionRuleValueService + this.campaignService_ = campaignService + this.campaignBudgetService_ = campaignBudgetService } __joinerConfig(): ModuleJoinerConfig { @@ -271,15 +285,28 @@ export default class PromotionModuleService< ) } - @InjectManager("baseRepository_") + async create( + data: PromotionTypes.CreatePromotionDTO, + sharedContext?: Context + ): Promise + async create( data: PromotionTypes.CreatePromotionDTO[], - @MedusaContext() sharedContext: Context = {} - ): Promise { - const promotions = await this.create_(data, sharedContext) + sharedContext?: Context + ): Promise - return await this.list( - { id: promotions.map((p) => p!.id) }, + @InjectManager("baseRepository_") + async create( + data: + | PromotionTypes.CreatePromotionDTO + | PromotionTypes.CreatePromotionDTO[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + const input = Array.isArray(data) ? data : [data] + const createdPromotions = await this.create_(input, sharedContext) + + const promotions = await this.list( + { id: createdPromotions.map((p) => p!.id) }, { relations: [ "application_method", @@ -291,6 +318,8 @@ export default class PromotionModuleService< }, sharedContext ) + + return Array.isArray(data) ? promotions : promotions[0] } @InjectTransactionManager("baseRepository_") @@ -399,15 +428,28 @@ export default class PromotionModuleService< return createdPromotions } - @InjectManager("baseRepository_") + async update( + data: PromotionTypes.UpdatePromotionDTO, + sharedContext?: Context + ): Promise + async update( data: PromotionTypes.UpdatePromotionDTO[], - @MedusaContext() sharedContext: Context = {} - ): Promise { - const promotions = await this.update_(data, sharedContext) + sharedContext?: Context + ): Promise - return await this.list( - { id: promotions.map((p) => p!.id) }, + @InjectManager("baseRepository_") + async update( + data: + | PromotionTypes.UpdatePromotionDTO + | PromotionTypes.UpdatePromotionDTO[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + const input = Array.isArray(data) ? data : [data] + const updatedPromotions = await this.update_(input, sharedContext) + + const promotions = await this.list( + { id: updatedPromotions.map((p) => p!.id) }, { relations: [ "application_method", @@ -418,6 +460,8 @@ export default class PromotionModuleService< }, sharedContext ) + + return Array.isArray(data) ? promotions : promotions[0] } @InjectTransactionManager("baseRepository_") @@ -587,10 +631,12 @@ export default class PromotionModuleService< @InjectTransactionManager("baseRepository_") async delete( - ids: string[], + ids: string | string[], @MedusaContext() sharedContext: Context = {} ): Promise { - await this.promotionService_.delete(ids, sharedContext) + const promotionIds = Array.isArray(ids) ? ids : [ids] + + await this.promotionService_.delete(promotionIds, sharedContext) } @InjectManager("baseRepository_") @@ -691,4 +737,214 @@ export default class PromotionModuleService< sharedContext ) } + + @InjectManager("baseRepository_") + async retrieveCampaign( + id: string, + config: FindConfig = {}, + @MedusaContext() sharedContext: Context = {} + ): Promise { + const campaign = await this.campaignService_.retrieve( + id, + config, + sharedContext + ) + + return await this.baseRepository_.serialize( + campaign, + { + populate: true, + } + ) + } + + @InjectManager("baseRepository_") + async listCampaigns( + filters: PromotionTypes.FilterableCampaignProps = {}, + config: FindConfig = {}, + @MedusaContext() sharedContext: Context = {} + ): Promise { + const campaigns = await this.campaignService_.list( + filters, + config, + sharedContext + ) + + return await this.baseRepository_.serialize( + campaigns, + { + populate: true, + } + ) + } + + async createCampaigns( + data: PromotionTypes.CreateCampaignDTO, + sharedContext?: Context + ): Promise + + async createCampaigns( + data: PromotionTypes.CreateCampaignDTO[], + sharedContext?: Context + ): Promise + + @InjectManager("baseRepository_") + async createCampaigns( + data: PromotionTypes.CreateCampaignDTO | PromotionTypes.CreateCampaignDTO[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + const input = Array.isArray(data) ? data : [data] + const createdCampaigns = await this.createCampaigns_(input, sharedContext) + + const campaigns = await this.listCampaigns( + { id: createdCampaigns.map((p) => p!.id) }, + { + relations: ["budget"], + }, + sharedContext + ) + + return Array.isArray(data) ? campaigns : campaigns[0] + } + + @InjectTransactionManager("baseRepository_") + protected async createCampaigns_( + data: PromotionTypes.CreateCampaignDTO[], + @MedusaContext() sharedContext: Context = {} + ) { + const campaignsData: CreateCampaignDTO[] = [] + const campaignBudgetsData: CreateCampaignBudgetDTO[] = [] + const campaignIdentifierBudgetMap = new Map< + string, + CreateCampaignBudgetDTO + >() + + for (const createCampaignData of data) { + const { budget: campaignBudgetData, ...campaignData } = createCampaignData + + if (campaignBudgetData) { + campaignIdentifierBudgetMap.set( + campaignData.campaign_identifier, + campaignBudgetData + ) + } + + campaignsData.push(campaignData) + } + + const createdCampaigns = await this.campaignService_.create( + campaignsData, + sharedContext + ) + + for (const createdCampaign of createdCampaigns) { + const campaignBudgetData = campaignIdentifierBudgetMap.get( + createdCampaign.campaign_identifier + ) + + if (campaignBudgetData) { + campaignBudgetsData.push({ + ...campaignBudgetData, + campaign: createdCampaign.id, + }) + } + } + + if (campaignBudgetsData.length) { + await this.campaignBudgetService_.create( + campaignBudgetsData, + sharedContext + ) + } + + return createdCampaigns + } + + async updateCampaigns( + data: PromotionTypes.UpdateCampaignDTO, + sharedContext?: Context + ): Promise + + async updateCampaigns( + data: PromotionTypes.UpdateCampaignDTO[], + sharedContext?: Context + ): Promise + + @InjectManager("baseRepository_") + async updateCampaigns( + data: PromotionTypes.UpdateCampaignDTO | PromotionTypes.UpdateCampaignDTO[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + const input = Array.isArray(data) ? data : [data] + + const updatedCampaigns = await this.updateCampaigns_(input, sharedContext) + + const campaigns = await this.listCampaigns( + { id: updatedCampaigns.map((p) => p!.id) }, + { + relations: ["budget"], + }, + sharedContext + ) + + return Array.isArray(data) ? campaigns : campaigns[0] + } + + @InjectTransactionManager("baseRepository_") + protected async updateCampaigns_( + data: PromotionTypes.UpdateCampaignDTO[], + @MedusaContext() sharedContext: Context = {} + ) { + const campaignIds = data.map((d) => d.id) + const campaignsData: UpdateCampaignDTO[] = [] + const campaignBudgetsData: UpdateCampaignBudgetDTO[] = [] + + const existingCampaigns = await this.listCampaigns( + { + id: campaignIds, + }, + { + relations: ["budget"], + }, + sharedContext + ) + + const existingCampaignsMap = new Map( + existingCampaigns.map((campaign) => [campaign.id, campaign]) + ) + + for (const updateCampaignData of data) { + const { budget: campaignBudgetData, ...campaignData } = updateCampaignData + + const existingCampaign = existingCampaignsMap.get(campaignData.id) + const existingCampaignBudget = existingCampaign?.budget + + campaignsData.push(campaignData) + + if (existingCampaignBudget && campaignBudgetData) { + campaignBudgetsData.push({ + id: existingCampaignBudget.id, + ...campaignBudgetData, + }) + } + } + + const updatedCampaigns = await this.campaignService_.update(campaignsData) + + if (campaignBudgetsData.length) { + await this.campaignBudgetService_.update(campaignBudgetsData) + } + + return updatedCampaigns + } + + @InjectTransactionManager("baseRepository_") + async deleteCampaigns( + ids: string | string[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + const campaignIds = Array.isArray(ids) ? ids : [ids] + + await this.promotionService_.delete(campaignIds, sharedContext) + } } diff --git a/packages/promotion/src/types/campaign-budget.ts b/packages/promotion/src/types/campaign-budget.ts new file mode 100644 index 0000000000..9bbdae5009 --- /dev/null +++ b/packages/promotion/src/types/campaign-budget.ts @@ -0,0 +1,16 @@ +import { CampaignBudgetTypeValues } from "@medusajs/types" +import { Campaign } from "@models" + +export interface CreateCampaignBudgetDTO { + type: CampaignBudgetTypeValues + limit: number | null + used?: number + campaign?: Campaign | string +} + +export interface UpdateCampaignBudgetDTO { + id: string + type?: CampaignBudgetTypeValues + limit?: number | null + used?: number +} diff --git a/packages/promotion/src/types/campaign.ts b/packages/promotion/src/types/campaign.ts new file mode 100644 index 0000000000..be55383763 --- /dev/null +++ b/packages/promotion/src/types/campaign.ts @@ -0,0 +1,18 @@ +export interface CreateCampaignDTO { + name: string + description?: string + currency?: string + campaign_identifier: string + starts_at: Date + ends_at: Date +} + +export interface UpdateCampaignDTO { + id: string + name?: string + description?: string + currency?: string + campaign_identifier?: string + starts_at?: Date + ends_at?: Date +} diff --git a/packages/promotion/src/types/index.ts b/packages/promotion/src/types/index.ts index 3891291eeb..145c717bae 100644 --- a/packages/promotion/src/types/index.ts +++ b/packages/promotion/src/types/index.ts @@ -5,6 +5,8 @@ export type InitializeModuleInjectableDependencies = { } export * from "./application-method" +export * from "./campaign" +export * from "./campaign-budget" export * from "./promotion" export * from "./promotion-rule" export * from "./promotion-rule-value" diff --git a/packages/types/src/promotion/common/campaign-budget.ts b/packages/types/src/promotion/common/campaign-budget.ts new file mode 100644 index 0000000000..0b2b103d58 --- /dev/null +++ b/packages/types/src/promotion/common/campaign-budget.ts @@ -0,0 +1,16 @@ +import { BaseFilterable } from "../../dal" + +export type CampaignBudgetTypeValues = "spend" | "usage" + +export interface CampaignBudgetDTO { + id: string + type?: CampaignBudgetTypeValues + limit?: string | null + used?: string +} + +export interface FilterableCampaignBudgetProps + extends BaseFilterable { + id?: string[] + type?: string[] +} diff --git a/packages/types/src/promotion/common/campaign.ts b/packages/types/src/promotion/common/campaign.ts new file mode 100644 index 0000000000..9034c2c94e --- /dev/null +++ b/packages/types/src/promotion/common/campaign.ts @@ -0,0 +1,19 @@ +import { BaseFilterable } from "../../dal" +import { CampaignBudgetDTO } from "./campaign-budget" + +export interface CampaignDTO { + id: string + name?: string + description?: string + currency?: string + campaign_identifier?: string + starts_at?: Date + ends_at?: Date + budget?: CampaignBudgetDTO +} + +export interface FilterableCampaignProps + extends BaseFilterable { + id?: string[] + campaign_identifier?: string[] +} diff --git a/packages/types/src/promotion/common/index.ts b/packages/types/src/promotion/common/index.ts index 11bae51b17..bac7566457 100644 --- a/packages/types/src/promotion/common/index.ts +++ b/packages/types/src/promotion/common/index.ts @@ -1,4 +1,6 @@ export * from "./application-method" +export * from "./campaign" +export * from "./campaign-budget" export * from "./compute-actions" export * from "./promotion" export * from "./promotion-rule" diff --git a/packages/types/src/promotion/index.ts b/packages/types/src/promotion/index.ts index eade309433..0c73656566 100644 --- a/packages/types/src/promotion/index.ts +++ b/packages/types/src/promotion/index.ts @@ -1,2 +1,3 @@ export * from "./common" +export * from "./mutations" export * from "./service" diff --git a/packages/types/src/promotion/mutations.ts b/packages/types/src/promotion/mutations.ts new file mode 100644 index 0000000000..a8e3fef9f5 --- /dev/null +++ b/packages/types/src/promotion/mutations.ts @@ -0,0 +1,35 @@ +import { CampaignBudgetTypeValues } from "./common" + +export interface CreateCampaignBudgetDTO { + type: CampaignBudgetTypeValues + limit: number | null + used?: number +} + +export interface UpdateCampaignBudgetDTO { + id: string + type?: CampaignBudgetTypeValues + limit?: number | null + used?: number +} + +export interface CreateCampaignDTO { + name: string + description?: string + currency?: string + campaign_identifier: string + starts_at: Date + ends_at: Date + budget?: CreateCampaignBudgetDTO +} + +export interface UpdateCampaignDTO { + id: string + name?: string + description?: string + currency?: string + campaign_identifier?: string + starts_at?: Date + ends_at?: Date + budget?: Omit +} diff --git a/packages/types/src/promotion/service.ts b/packages/types/src/promotion/service.ts index a22a54d9f9..9f35256ac0 100644 --- a/packages/types/src/promotion/service.ts +++ b/packages/types/src/promotion/service.ts @@ -2,16 +2,20 @@ import { FindConfig } from "../common" import { IModuleService } from "../modules-sdk" import { Context } from "../shared-context" import { + CampaignDTO, ComputeActionContext, ComputeActions, CreatePromotionDTO, CreatePromotionRuleDTO, + FilterableCampaignProps, FilterablePromotionProps, PromotionDTO, RemovePromotionRuleDTO, UpdatePromotionDTO, } from "./common" +import { CreateCampaignDTO, UpdateCampaignDTO } from "./mutations" + export interface IPromotionModuleService extends IModuleService { computeActions( promotionCodesToApply: string[], @@ -24,11 +28,21 @@ export interface IPromotionModuleService extends IModuleService { sharedContext?: Context ): Promise + create( + data: CreatePromotionDTO, + sharedContext?: Context + ): Promise + update( data: UpdatePromotionDTO[], sharedContext?: Context ): Promise + update( + data: UpdatePromotionDTO, + sharedContext?: Context + ): Promise + list( filters?: FilterablePromotionProps, config?: FindConfig, @@ -42,6 +56,7 @@ export interface IPromotionModuleService extends IModuleService { ): Promise delete(ids: string[], sharedContext?: Context): Promise + delete(ids: string, sharedContext?: Context): Promise addPromotionRules( promotionId: string, @@ -66,4 +81,39 @@ export interface IPromotionModuleService extends IModuleService { rulesData: RemovePromotionRuleDTO[], sharedContext?: Context ): Promise + + createCampaigns( + data: CreateCampaignDTO, + sharedContext?: Context + ): Promise + + createCampaigns( + data: CreateCampaignDTO[], + sharedContext?: Context + ): Promise + + updateCampaigns( + data: UpdateCampaignDTO[], + sharedContext?: Context + ): Promise + + updateCampaigns( + data: UpdateCampaignDTO, + sharedContext?: Context + ): Promise + + listCampaigns( + filters?: FilterableCampaignProps, + config?: FindConfig, + sharedContext?: Context + ): Promise + + retrieveCampaign( + id: string, + config?: FindConfig, + sharedContext?: Context + ): Promise + + deleteCampaigns(ids: string[], sharedContext?: Context): Promise + deleteCampaigns(ids: string, sharedContext?: Context): Promise } diff --git a/packages/utils/src/promotion/index.ts b/packages/utils/src/promotion/index.ts index 0094b41a84..b416a7cd96 100644 --- a/packages/utils/src/promotion/index.ts +++ b/packages/utils/src/promotion/index.ts @@ -28,3 +28,8 @@ export enum PromotionRuleOperator { NE = "ne", IN = "in", } + +export enum CampaignBudgetType { + SPEND = "spend", + USAGE = "usage", +} From 57e00e7803bbefc2791ff38e49f0eef0a2ae1289 Mon Sep 17 00:00:00 2001 From: invalid w Date: Fri, 12 Jan 2024 22:11:29 +0800 Subject: [PATCH 4/8] docs: typo backendUrl default url (#6073) `http://location:9000` should be `http://localhost:9000` --- www/apps/docs/content/admin/onboarding.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/apps/docs/content/admin/onboarding.mdx b/www/apps/docs/content/admin/onboarding.mdx index 9f45b6c54d..847a189f23 100644 --- a/www/apps/docs/content/admin/onboarding.mdx +++ b/www/apps/docs/content/admin/onboarding.mdx @@ -1240,7 +1240,7 @@ Notice that as there are two types of flows, you'll be creating the components f const api_key = useMemo(() => keys?.[0]?.id || "", [keys]) const backendUrl = process.env.MEDUSA_BACKEND_URL === "/" || process.env.MEDUSA_ADMIN_BACKEND_URL === "/" ? location.origin : - process.env.MEDUSA_BACKEND_URL || process.env.MEDUSA_ADMIN_BACKEND_URL || "http://location:9000" + process.env.MEDUSA_BACKEND_URL || process.env.MEDUSA_ADMIN_BACKEND_URL || "http://localhost:9000" useEffect(() => { if (!isLoading && !keys?.length) { From 33ff2415ae45353afc0659e19e1bee0bbb668bb6 Mon Sep 17 00:00:00 2001 From: Shahed Nasser Date: Sat, 13 Jan 2024 15:21:16 +0200 Subject: [PATCH 5/8] fix: eslint configurations in the root (#6049) When running `yarn lint` in the root of the monorepo, we get the following error: ```bash Oops! Something went wrong! :( ESLint: 8.44.0 Error: ESLint configuration in .eslintrc.js is invalid: - Unexpected top-level property "overrides[4].ignorePatterns". ``` Rmoving the `ignorePatterns` in configurations for `packages/admin-next/dashboard/**/*` and instead adding the path `packages/admin-next/dashboard/**/dist` fixes the issue. --- .eslintrc.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index 8c3962e6ab..be653df480 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -72,7 +72,9 @@ module.exports = { node: true, jest: true, }, - ignorePatterns: [], + ignorePatterns: [ + "packages/admin-next/dashboard/**/dist" + ], overrides: [ { files: ["*.ts"], @@ -232,7 +234,6 @@ module.exports = { "plugin:@typescript-eslint/recommended", "plugin:react-hooks/recommended", ], - ignorePatterns: ["dist"], parser: "@typescript-eslint/parser", parserOptions: { project: "./packages/admin-next/dashboard/tsconfig.json", From 72bc52231ca3a72fa6d197a248fe07a938ed0d85 Mon Sep 17 00:00:00 2001 From: Adrien de Peretti Date: Sun, 14 Jan 2024 17:13:25 +0100 Subject: [PATCH 6/8] chore(utils): Update base repository to infer primary keys and support composite (#6062) --- .changeset/cyan-countries-brush.md | 7 + .../src/repositories/auth-provider.ts | 7 +- .../src/repositories/auth-user.ts | 5 + packages/cart/src/repositories/address.ts | 5 + packages/cart/src/repositories/cart.ts | 5 + packages/pricing/src/repositories/currency.ts | 7 +- .../pricing/src/repositories/money-amount.ts | 7 +- .../src/repositories/price-list-rule-value.ts | 5 + .../src/repositories/price-list-rule.ts | 5 + .../pricing/src/repositories/price-list.ts | 5 + .../pricing/src/repositories/price-rule.ts | 5 + .../price-set-money-amount-rules.ts | 7 +- .../repositories/price-set-money-amount.ts | 7 +- .../src/repositories/price-set-rule-type.ts | 7 +- .../pricing/src/repositories/price-set.ts | 7 +- .../pricing/src/repositories/rule-type.ts | 7 +- .../src/repositories/product-collection.ts | 5 + .../product/src/repositories/product-image.ts | 5 + .../src/repositories/product-option-value.ts | 5 + .../src/repositories/product-option.ts | 5 + .../product/src/repositories/product-tag.ts | 5 + .../product/src/repositories/product-type.ts | 5 + .../src/repositories/product-variant.ts | 7 +- packages/product/src/repositories/product.ts | 5 + .../src/repositories/application-method.ts | 7 +- .../src/repositories/promotion-rule-value.ts | 7 +- .../src/repositories/promotion-rule.ts | 7 +- .../promotion/src/repositories/promotion.ts | 7 +- .../src/dal/mikro-orm/mikro-orm-repository.ts | 147 +++++++++++++----- 29 files changed, 264 insertions(+), 51 deletions(-) create mode 100644 .changeset/cyan-countries-brush.md diff --git a/.changeset/cyan-countries-brush.md b/.changeset/cyan-countries-brush.md new file mode 100644 index 0000000000..cba8519534 --- /dev/null +++ b/.changeset/cyan-countries-brush.md @@ -0,0 +1,7 @@ +--- +"@medusajs/authentication": patch +"@medusajs/pricing": patch +"@medusajs/utils": patch +--- + +chore(utils): Update base repository to infer primary keys and support composite diff --git a/packages/authentication/src/repositories/auth-provider.ts b/packages/authentication/src/repositories/auth-provider.ts index a0d1d0f543..6445266d14 100644 --- a/packages/authentication/src/repositories/auth-provider.ts +++ b/packages/authentication/src/repositories/auth-provider.ts @@ -10,7 +10,12 @@ export class AuthProviderRepository extends DALUtils.mikroOrmBaseRepositoryFacto { create: RepositoryTypes.CreateAuthProviderDTO } ->(AuthProvider, "provider") { +>(AuthProvider) { + constructor(...args: any[]) { + // @ts-ignore + super(...arguments) + } + async update( data: RepositoryTypes.UpdateAuthProviderDTO[], context: Context = {} diff --git a/packages/authentication/src/repositories/auth-user.ts b/packages/authentication/src/repositories/auth-user.ts index 2da807aa41..6e5fa10f9c 100644 --- a/packages/authentication/src/repositories/auth-user.ts +++ b/packages/authentication/src/repositories/auth-user.ts @@ -8,6 +8,11 @@ import { SqlEntityManager } from "@mikro-orm/postgresql" export class AuthUserRepository extends DALUtils.mikroOrmBaseRepositoryFactory( AuthUser ) { + constructor(...args: any[]) { + // @ts-ignore + super(...arguments) + } + async create( data: RepositoryTypes.CreateAuthUserDTO[], context: Context = {} diff --git a/packages/cart/src/repositories/address.ts b/packages/cart/src/repositories/address.ts index 994ff44221..53c375821e 100644 --- a/packages/cart/src/repositories/address.ts +++ b/packages/cart/src/repositories/address.ts @@ -10,6 +10,11 @@ export class AddressRepository extends DALUtils.mikroOrmBaseRepositoryFactory< create: CreateAddressDTO } >(Address) { + constructor(...args: any[]) { + // @ts-ignore + super(...arguments) + } + async update( data: { address: Address; update: UpdateAddressDTO }[], context: Context = {} diff --git a/packages/cart/src/repositories/cart.ts b/packages/cart/src/repositories/cart.ts index 4aeddee7f3..952fc9fd1b 100644 --- a/packages/cart/src/repositories/cart.ts +++ b/packages/cart/src/repositories/cart.ts @@ -10,6 +10,11 @@ export class CartRepository extends DALUtils.mikroOrmBaseRepositoryFactory< create: CreateCartDTO } >(Cart) { + constructor(...args: any[]) { + // @ts-ignore + super(...arguments) + } + async update( data: { cart: Cart; update: UpdateCartDTO }[], context: Context = {} diff --git a/packages/pricing/src/repositories/currency.ts b/packages/pricing/src/repositories/currency.ts index 1aa87a5319..7a15ded16b 100644 --- a/packages/pricing/src/repositories/currency.ts +++ b/packages/pricing/src/repositories/currency.ts @@ -9,4 +9,9 @@ export class CurrencyRepository extends DALUtils.mikroOrmBaseRepositoryFactory< create: RepositoryTypes.CreateCurrencyDTO update: RepositoryTypes.UpdateCurrencyDTO } ->(Currency, "code") {} +>(Currency) { + constructor(...args: any[]) { + // @ts-ignore + super(...arguments) + } +} diff --git a/packages/pricing/src/repositories/money-amount.ts b/packages/pricing/src/repositories/money-amount.ts index 66595fc592..c23c7fa070 100644 --- a/packages/pricing/src/repositories/money-amount.ts +++ b/packages/pricing/src/repositories/money-amount.ts @@ -9,4 +9,9 @@ export class MoneyAmountRepository extends DALUtils.mikroOrmBaseRepositoryFactor create: RepositoryTypes.CreateMoneyAmountDTO update: RepositoryTypes.UpdateMoneyAmountDTO } ->(MoneyAmount) {} +>(MoneyAmount) { + constructor(...args: any[]) { + // @ts-ignore + super(...arguments) + } +} diff --git a/packages/pricing/src/repositories/price-list-rule-value.ts b/packages/pricing/src/repositories/price-list-rule-value.ts index 6829876056..d1512834c0 100644 --- a/packages/pricing/src/repositories/price-list-rule-value.ts +++ b/packages/pricing/src/repositories/price-list-rule-value.ts @@ -10,6 +10,11 @@ export class PriceListRuleValueRepository extends DALUtils.mikroOrmBaseRepositor update: RepositoryTypes.UpdatePriceListRuleValueDTO } >(PriceListRuleValue) { + constructor(...args: any[]) { + // @ts-ignore + super(...arguments) + } + async create( data: RepositoryTypes.CreatePriceListRuleValueDTO[], context: Context = {} diff --git a/packages/pricing/src/repositories/price-list-rule.ts b/packages/pricing/src/repositories/price-list-rule.ts index 2e0664fc78..481549f7ab 100644 --- a/packages/pricing/src/repositories/price-list-rule.ts +++ b/packages/pricing/src/repositories/price-list-rule.ts @@ -7,6 +7,11 @@ import { RepositoryTypes } from "@types" export class PriceListRuleRepository extends DALUtils.mikroOrmBaseRepositoryFactory( PriceListRule ) { + constructor(...args: any[]) { + // @ts-ignore + super(...arguments) + } + async create( data: RepositoryTypes.CreatePriceListRuleDTO[], context: Context = {} diff --git a/packages/pricing/src/repositories/price-list.ts b/packages/pricing/src/repositories/price-list.ts index 6245fbb752..e1db5946df 100644 --- a/packages/pricing/src/repositories/price-list.ts +++ b/packages/pricing/src/repositories/price-list.ts @@ -7,6 +7,11 @@ import { RepositoryTypes } from "@types" export class PriceListRepository extends DALUtils.mikroOrmBaseRepositoryFactory( PriceList ) { + constructor(...args: any[]) { + // @ts-ignore + super(...arguments) + } + async create( data: RepositoryTypes.CreatePriceListDTO[], context: Context = {} diff --git a/packages/pricing/src/repositories/price-rule.ts b/packages/pricing/src/repositories/price-rule.ts index 73ffe0a994..ce6d41ee30 100644 --- a/packages/pricing/src/repositories/price-rule.ts +++ b/packages/pricing/src/repositories/price-rule.ts @@ -10,6 +10,11 @@ export class PriceRuleRepository extends DALUtils.mikroOrmBaseRepositoryFactory< update: RepositoryTypes.UpdatePriceRuleDTO } >(PriceRule) { + constructor(...args: any[]) { + // @ts-ignore + super(...arguments) + } + async create( data: RepositoryTypes.CreatePriceRuleDTO[], context: Context = {} diff --git a/packages/pricing/src/repositories/price-set-money-amount-rules.ts b/packages/pricing/src/repositories/price-set-money-amount-rules.ts index 1cec6d2c9a..8577254b9f 100644 --- a/packages/pricing/src/repositories/price-set-money-amount-rules.ts +++ b/packages/pricing/src/repositories/price-set-money-amount-rules.ts @@ -9,4 +9,9 @@ export class PriceSetMoneyAmountRulesRepository extends DALUtils.mikroOrmBaseRep create: RepositoryTypes.CreatePriceSetMoneyAmountRulesDTO update: RepositoryTypes.UpdatePriceSetMoneyAmountRulesDTO } ->(PriceSetMoneyAmountRules) {} +>(PriceSetMoneyAmountRules) { + constructor(...args: any[]) { + // @ts-ignore + super(...arguments) + } +} diff --git a/packages/pricing/src/repositories/price-set-money-amount.ts b/packages/pricing/src/repositories/price-set-money-amount.ts index 2c9a017d6e..dde772edc6 100644 --- a/packages/pricing/src/repositories/price-set-money-amount.ts +++ b/packages/pricing/src/repositories/price-set-money-amount.ts @@ -9,4 +9,9 @@ export class PriceSetMoneyAmountRepository extends DALUtils.mikroOrmBaseReposito create: RepositoryTypes.CreatePriceSetMoneyAmountDTO update: RepositoryTypes.UpdatePriceSetMoneyAmountDTO } ->(PriceSetMoneyAmount) {} +>(PriceSetMoneyAmount) { + constructor(...args: any[]) { + // @ts-ignore + super(...arguments) + } +} diff --git a/packages/pricing/src/repositories/price-set-rule-type.ts b/packages/pricing/src/repositories/price-set-rule-type.ts index 1dea5f21ad..9cffb54d7e 100644 --- a/packages/pricing/src/repositories/price-set-rule-type.ts +++ b/packages/pricing/src/repositories/price-set-rule-type.ts @@ -9,4 +9,9 @@ export class PriceSetRuleTypeRepository extends DALUtils.mikroOrmBaseRepositoryF create: RepositoryTypes.CreatePriceSetRuleTypeDTO update: RepositoryTypes.UpdatePriceSetRuleTypeDTO } ->(PriceSetRuleType) {} +>(PriceSetRuleType) { + constructor(...args: any[]) { + // @ts-ignore + super(...arguments) + } +} diff --git a/packages/pricing/src/repositories/price-set.ts b/packages/pricing/src/repositories/price-set.ts index 03984ca0e0..94cfe23065 100644 --- a/packages/pricing/src/repositories/price-set.ts +++ b/packages/pricing/src/repositories/price-set.ts @@ -9,4 +9,9 @@ export class PriceSetRepository extends DALUtils.mikroOrmBaseRepositoryFactory< create: RepositoryTypes.CreatePriceSetDTO update: RepositoryTypes.UpdatePriceSetDTO } ->(PriceSet) {} +>(PriceSet) { + constructor(...args: any[]) { + // @ts-ignore + super(...arguments) + } +} diff --git a/packages/pricing/src/repositories/rule-type.ts b/packages/pricing/src/repositories/rule-type.ts index 1873ad675d..21d9f3d4f1 100644 --- a/packages/pricing/src/repositories/rule-type.ts +++ b/packages/pricing/src/repositories/rule-type.ts @@ -8,4 +8,9 @@ export class RuleTypeRepository extends DALUtils.mikroOrmBaseRepositoryFactory< create: RepositoryTypes.CreateRuleTypeDTO update: RepositoryTypes.UpdateRuleTypeDTO } ->(RuleType) {} +>(RuleType) { + constructor(...args: any[]) { + // @ts-ignore + super(...arguments) + } +} diff --git a/packages/product/src/repositories/product-collection.ts b/packages/product/src/repositories/product-collection.ts index 9fcdb62d19..6a8310e0f4 100644 --- a/packages/product/src/repositories/product-collection.ts +++ b/packages/product/src/repositories/product-collection.ts @@ -14,6 +14,11 @@ type CreateProductCollection = ProductTypes.CreateProductCollectionDTO & { export class ProductCollectionRepository extends DALUtils.mikroOrmBaseRepositoryFactory( ProductCollection ) { + constructor(...args: any[]) { + // @ts-ignore + super(...arguments) + } + async create( data: CreateProductCollection[], context: Context = {} diff --git a/packages/product/src/repositories/product-image.ts b/packages/product/src/repositories/product-image.ts index de12c58c04..5dc1799b1c 100644 --- a/packages/product/src/repositories/product-image.ts +++ b/packages/product/src/repositories/product-image.ts @@ -7,6 +7,11 @@ import { DALUtils } from "@medusajs/utils" export class ProductImageRepository extends DALUtils.mikroOrmBaseRepositoryFactory( Image ) { + constructor(...args: any[]) { + // @ts-ignore + super(...arguments) + } + async upsert(urls: string[], context: Context = {}): Promise { const manager = this.getActiveManager(context) diff --git a/packages/product/src/repositories/product-option-value.ts b/packages/product/src/repositories/product-option-value.ts index 219764cd35..2b29297430 100644 --- a/packages/product/src/repositories/product-option-value.ts +++ b/packages/product/src/repositories/product-option-value.ts @@ -11,6 +11,11 @@ import { SqlEntityManager } from "@mikro-orm/postgresql" export class ProductOptionValueRepository extends DALUtils.mikroOrmBaseRepositoryFactory( ProductOptionValue ) { + constructor(...args: any[]) { + // @ts-ignore + super(...arguments) + } + async upsert( optionValues: (UpdateProductOptionValueDTO | CreateProductOptionValueDTO)[], context: Context = {} diff --git a/packages/product/src/repositories/product-option.ts b/packages/product/src/repositories/product-option.ts index a51f9260fb..fc4616acd0 100644 --- a/packages/product/src/repositories/product-option.ts +++ b/packages/product/src/repositories/product-option.ts @@ -10,6 +10,11 @@ export class ProductOptionRepository extends DALUtils.mikroOrmBaseRepositoryFact update: ProductTypes.UpdateProductOptionDTO } >(ProductOption) { + constructor(...args: any[]) { + // @ts-ignore + super(...arguments) + } + async create( data: ProductTypes.CreateProductOptionDTO[], context: Context = {} diff --git a/packages/product/src/repositories/product-tag.ts b/packages/product/src/repositories/product-tag.ts index 69dfb03ea7..e3eae1543c 100644 --- a/packages/product/src/repositories/product-tag.ts +++ b/packages/product/src/repositories/product-tag.ts @@ -15,6 +15,11 @@ export class ProductTagRepository extends DALUtils.mikroOrmBaseRepositoryFactory update: UpdateProductTagDTO } >(ProductTag) { + constructor(...args: any[]) { + // @ts-ignore + super(...arguments) + } + async upsert( tags: UpsertProductTagDTO[], context: Context = {} diff --git a/packages/product/src/repositories/product-type.ts b/packages/product/src/repositories/product-type.ts index 082a926bcf..352ff1ace8 100644 --- a/packages/product/src/repositories/product-type.ts +++ b/packages/product/src/repositories/product-type.ts @@ -14,6 +14,11 @@ export class ProductTypeRepository extends DALUtils.mikroOrmBaseRepositoryFactor update: UpdateProductTypeDTO } >(ProductType) { + constructor(...args: any[]) { + // @ts-ignore + super(...arguments) + } + async upsert( types: CreateProductTypeDTO[], context: Context = {} diff --git a/packages/product/src/repositories/product-variant.ts b/packages/product/src/repositories/product-variant.ts index 3e7824e9c6..f792a8e3be 100644 --- a/packages/product/src/repositories/product-variant.ts +++ b/packages/product/src/repositories/product-variant.ts @@ -14,4 +14,9 @@ export class ProductVariantRepository extends DALUtils.mikroOrmBaseRepositoryFac "id" > } ->(ProductVariant) {} +>(ProductVariant) { + constructor(...args: any[]) { + // @ts-ignore + super(...arguments) + } +} diff --git a/packages/product/src/repositories/product.ts b/packages/product/src/repositories/product.ts index 855477db87..833c61e9d5 100644 --- a/packages/product/src/repositories/product.ts +++ b/packages/product/src/repositories/product.ts @@ -24,6 +24,11 @@ export class ProductRepository extends DALUtils.mikroOrmBaseRepositoryFactory< create: WithRequiredProperty } >(Product) { + constructor(...args: any[]) { + // @ts-ignore + super(...arguments) + } + async find( findOptions: DAL.FindOptions = { where: {} }, context: Context = {} diff --git a/packages/promotion/src/repositories/application-method.ts b/packages/promotion/src/repositories/application-method.ts index f2d7a60517..fd666d995b 100644 --- a/packages/promotion/src/repositories/application-method.ts +++ b/packages/promotion/src/repositories/application-method.ts @@ -8,4 +8,9 @@ export class ApplicationMethodRepository extends DALUtils.mikroOrmBaseRepository create: CreateApplicationMethodDTO update: UpdateApplicationMethodDTO } ->(ApplicationMethod) {} +>(ApplicationMethod) { + constructor(...args: any[]) { + // @ts-ignore + super(...arguments) + } +} diff --git a/packages/promotion/src/repositories/promotion-rule-value.ts b/packages/promotion/src/repositories/promotion-rule-value.ts index aa3868e5bf..7587fa295c 100644 --- a/packages/promotion/src/repositories/promotion-rule-value.ts +++ b/packages/promotion/src/repositories/promotion-rule-value.ts @@ -11,4 +11,9 @@ export class PromotionRuleValueRepository extends DALUtils.mikroOrmBaseRepositor create: CreatePromotionRuleValueDTO update: UpdatePromotionRuleValueDTO } ->(PromotionRuleValue) {} +>(PromotionRuleValue) { + constructor(...args: any[]) { + // @ts-ignore + super(...arguments) + } +} diff --git a/packages/promotion/src/repositories/promotion-rule.ts b/packages/promotion/src/repositories/promotion-rule.ts index d80c92c9f0..aceb5d33dd 100644 --- a/packages/promotion/src/repositories/promotion-rule.ts +++ b/packages/promotion/src/repositories/promotion-rule.ts @@ -8,4 +8,9 @@ export class PromotionRuleRepository extends DALUtils.mikroOrmBaseRepositoryFact create: CreatePromotionRuleDTO update: UpdatePromotionRuleDTO } ->(PromotionRule) {} +>(PromotionRule) { + constructor(...args: any[]) { + // @ts-ignore + super(...arguments) + } +} diff --git a/packages/promotion/src/repositories/promotion.ts b/packages/promotion/src/repositories/promotion.ts index 0df0002f02..c6732c74ba 100644 --- a/packages/promotion/src/repositories/promotion.ts +++ b/packages/promotion/src/repositories/promotion.ts @@ -8,4 +8,9 @@ export class PromotionRepository extends DALUtils.mikroOrmBaseRepositoryFactory< create: CreatePromotionDTO Update: UpdatePromotionDTO } ->(Promotion) {} +>(Promotion) { + constructor(...args: any[]) { + // @ts-ignore + super(...arguments) + } +} diff --git a/packages/utils/src/dal/mikro-orm/mikro-orm-repository.ts b/packages/utils/src/dal/mikro-orm/mikro-orm-repository.ts index 84c8b437f2..da3f18819c 100644 --- a/packages/utils/src/dal/mikro-orm/mikro-orm-repository.ts +++ b/packages/utils/src/dal/mikro-orm/mikro-orm-repository.ts @@ -7,7 +7,6 @@ import { import { EntityManager, EntitySchema, - FilterQuery, LoadStrategy, RequiredEntityData, } from "@mikro-orm/core" @@ -17,9 +16,9 @@ import { EntityName, FilterQuery as MikroFilterQuery, } from "@mikro-orm/core/typings" -import { MedusaError, arrayDifference, isString } from "../../common" +import { isString, MedusaError } from "../../common" import { MedusaContext } from "../../decorators" -import { InjectTransactionManager, buildQuery } from "../../modules-sdk" +import { buildQuery, InjectTransactionManager } from "../../modules-sdk" import { getSoftDeletedCascadedEntitiesIdsMappedBy, transactionWrapper, @@ -77,10 +76,11 @@ export class MikroOrmBase { export class MikroOrmBaseRepository< T extends object = object > extends MikroOrmBase { - constructor() { + constructor(...args: any[]) { // @ts-ignore super(...arguments) } + create(data: unknown[], context?: Context): Promise { throw new Error("Method not implemented.") } @@ -89,7 +89,7 @@ export class MikroOrmBaseRepository< throw new Error("Method not implemented.") } - delete(ids: string[], context?: Context): Promise { + delete(ids: string[] | object[], context?: Context): Promise { throw new Error("Method not implemented.") } @@ -227,11 +227,25 @@ export function mikroOrmBaseRepositoryFactory< TDTos extends { [K in DtoBasedMutationMethods]?: any } = { [K in DtoBasedMutationMethods]?: any } ->( - entity: EntityClass | EntitySchema | string, - primaryKey: string = "id" -) { +>(entity: EntityClass | EntitySchema) { class MikroOrmAbstractBaseRepository_ extends MikroOrmBaseRepository { + // @ts-ignore + constructor(...args: any[]) { + // @ts-ignore + super(...arguments) + } + + static buildUniqueCompositeKeyValue(keys: string[], data: object) { + return keys.map((k) => data[k]).join("_") + } + + static retrievePrimaryKeys(entity: EntityClass | EntitySchema) { + return ( + (entity as EntitySchema).meta?.primaryKeys ?? + (entity as EntityClass).prototype.__meta.primaryKeys + ) + } + async create(data: TDTos["create"][], context?: Context): Promise { const manager = this.getActiveManager(context) @@ -250,42 +264,71 @@ export function mikroOrmBaseRepositoryFactory< async update(data: TDTos["update"][], context?: Context): Promise { const manager = this.getActiveManager(context) - const primaryKeyValues: string[] = data.map((data_) => data_[primaryKey]) - const existingEntities = await this.find( - { - where: { - [primaryKey]: { - $in: primaryKeyValues, - }, - }, - } as DAL.FindOptions, - context + const primaryKeys = + MikroOrmAbstractBaseRepository_.retrievePrimaryKeys(entity) + + let primaryKeysCriteria: { [key: string]: any }[] = [] + if (primaryKeys.length === 1) { + primaryKeysCriteria.push({ + [primaryKeys[0]]: data.map((d) => d[primaryKeys[0]]), + }) + } else { + primaryKeysCriteria = data.map((d) => ({ + $and: primaryKeys.map((key) => ({ [key]: d[key] })), + })) + } + + const allEntities = await Promise.all( + primaryKeysCriteria.map( + async (criteria) => + await this.find({ where: criteria } as DAL.FindOptions, context) + ) ) - const missingEntities = arrayDifference( - data.map((d) => d[primaryKey]), - existingEntities.map((d: any) => d[primaryKey]) - ) + const existingEntities = allEntities.flat() + + const existingEntitiesMap = new Map() + existingEntities.forEach((entity) => { + if (entity) { + const key = + MikroOrmAbstractBaseRepository_.buildUniqueCompositeKeyValue( + primaryKeys, + entity + ) + existingEntitiesMap.set(key, entity) + } + }) + + const missingEntities = data.filter((data_) => { + const key = + MikroOrmAbstractBaseRepository_.buildUniqueCompositeKeyValue( + primaryKeys, + data_ + ) + return !existingEntitiesMap.has(key) + }) if (missingEntities.length) { const entityName = (entity as EntityClass).name ?? entity + const missingEntitiesKeys = data.map((data_) => + primaryKeys.map((key) => data_[key]).join(", ") + ) throw new MedusaError( MedusaError.Types.NOT_FOUND, - `${entityName} with ${[primaryKey]} "${missingEntities.join( + `${entityName} with ${primaryKeys.join( ", " - )}" not found` + )} "${missingEntitiesKeys.join(", ")}" not found` ) } - const existingEntitiesMap = new Map( - existingEntities.map<[string, T]>((entity_: any) => [ - entity_[primaryKey], - entity_, - ]) - ) - const entities = data.map((data_) => { - const existingEntity = existingEntitiesMap.get(data_[primaryKey])! + const key = + MikroOrmAbstractBaseRepository_.buildUniqueCompositeKeyValue( + primaryKeys, + data_ + ) + const existingEntity = existingEntitiesMap.get(key)! + return manager.assign(existingEntity, data_ as RequiredEntityData) }) @@ -294,13 +337,41 @@ export function mikroOrmBaseRepositoryFactory< return entities } - async delete(primaryKeyValues: string[], context?: Context): Promise { + async delete( + primaryKeyValues: string[] | object[], + context?: Context + ): Promise { const manager = this.getActiveManager(context) - await manager.nativeDelete( - entity as EntityName, - { [primaryKey]: { $in: primaryKeyValues } } as unknown as FilterQuery - ) + const primaryKeys = + MikroOrmAbstractBaseRepository_.retrievePrimaryKeys(entity) + + let deletionCriteria + if (primaryKeys.length > 1) { + deletionCriteria = { + $or: primaryKeyValues.map((compositeKeyValue) => { + const keys = Object.keys(compositeKeyValue) + if (!primaryKeys.every((k) => keys.includes(k))) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Composite key must contain all primary key fields: ${primaryKeys.join( + ", " + )}. Found: ${keys}` + ) + } + + const criteria: { [key: string]: any } = {} + for (const key of primaryKeys) { + criteria[key] = compositeKeyValue[key] + } + return criteria + }), + } + } else { + deletionCriteria = { [primaryKeys[0]]: { $in: primaryKeyValues } } + } + + await manager.nativeDelete(entity as EntityName, deletionCriteria) } async find(options?: DAL.FindOptions, context?: Context): Promise { From 304efb9ad8bf61c6eb62385a050678e0ef96540c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 15 Jan 2024 10:28:38 +0200 Subject: [PATCH 7/8] chore(docs): Removed Docs Announcement Bar (automated) (#6086) Co-authored-by: shahednasser --- www/apps/docs/announcement.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/apps/docs/announcement.json b/www/apps/docs/announcement.json index 120fb2db9e..9e26dfeeb6 100644 --- a/www/apps/docs/announcement.json +++ b/www/apps/docs/announcement.json @@ -1 +1 @@ -{"id":"https://github.com/medusajs/medusa/releases/tag/v1.20.0","content":"v1.20.0 is out","isCloseable":true} \ No newline at end of file +{} \ No newline at end of file From 5dacd4ac9f52cfecf8064208994ce3db21cd3cbe Mon Sep 17 00:00:00 2001 From: Shahed Nasser Date: Mon, 15 Jan 2024 11:20:55 +0200 Subject: [PATCH 8/8] docs: fix example of createFulfillment (#6088) --- docs-util/typedoc-json-output/fulfillment.json | 4 ++-- packages/medusa/src/interfaces/fulfillment-service.ts | 2 +- .../classes/fulfillment.AbstractFulfillmentService.mdx | 2 +- .../fulfillment/interfaces/fulfillment.FulfillmentService.mdx | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs-util/typedoc-json-output/fulfillment.json b/docs-util/typedoc-json-output/fulfillment.json index 3615b435ac..62d88cc5d4 100644 --- a/docs-util/typedoc-json-output/fulfillment.json +++ b/docs-util/typedoc-json-output/fulfillment.json @@ -998,7 +998,7 @@ }, { "kind": "code", - "text": "```ts\nclass MyFulfillmentService extends AbstractFulfillmentService {\n // ...\n async createFulfillment(\n data: Record,\n items: LineItem,\n order: Order,\n fulfillment: Fulfillment\n ) {\n // No data is being sent anywhere\n // No data to be stored in the fulfillment's data object\n return {}\n }\n}\n```" + "text": "```ts\nclass MyFulfillmentService extends AbstractFulfillmentService {\n // ...\n async createFulfillment(\n data: Record,\n items: LineItem[],\n order: Order,\n fulfillment: Fulfillment\n ) {\n // No data is being sent anywhere\n // No data to be stored in the fulfillment's data object\n return {}\n }\n}\n```" } ] } @@ -3356,7 +3356,7 @@ }, { "kind": "code", - "text": "```ts\nclass MyFulfillmentService extends AbstractFulfillmentService {\n // ...\n async createFulfillment(\n data: Record,\n items: LineItem,\n order: Order,\n fulfillment: Fulfillment\n ) {\n // No data is being sent anywhere\n // No data to be stored in the fulfillment's data object\n return {}\n }\n}\n```" + "text": "```ts\nclass MyFulfillmentService extends AbstractFulfillmentService {\n // ...\n async createFulfillment(\n data: Record,\n items: LineItem[],\n order: Order,\n fulfillment: Fulfillment\n ) {\n // No data is being sent anywhere\n // No data to be stored in the fulfillment's data object\n return {}\n }\n}\n```" } ] } diff --git a/packages/medusa/src/interfaces/fulfillment-service.ts b/packages/medusa/src/interfaces/fulfillment-service.ts index b5519cec51..e75a0bdfba 100644 --- a/packages/medusa/src/interfaces/fulfillment-service.ts +++ b/packages/medusa/src/interfaces/fulfillment-service.ts @@ -238,7 +238,7 @@ export interface FulfillmentService extends TransactionBaseService { * // ... * async createFulfillment( * data: Record, - * items: LineItem, + * items: LineItem[], * order: Order, * fulfillment: Fulfillment * ) { diff --git a/www/apps/docs/content/references/fulfillment/classes/fulfillment.AbstractFulfillmentService.mdx b/www/apps/docs/content/references/fulfillment/classes/fulfillment.AbstractFulfillmentService.mdx index 5199a07b5c..6c47e43616 100644 --- a/www/apps/docs/content/references/fulfillment/classes/fulfillment.AbstractFulfillmentService.mdx +++ b/www/apps/docs/content/references/fulfillment/classes/fulfillment.AbstractFulfillmentService.mdx @@ -196,7 +196,7 @@ class MyFulfillmentService extends AbstractFulfillmentService { // ... async createFulfillment( data: Record, - items: LineItem, + items: LineItem[], order: Order, fulfillment: Fulfillment ) { diff --git a/www/apps/docs/content/references/fulfillment/interfaces/fulfillment.FulfillmentService.mdx b/www/apps/docs/content/references/fulfillment/interfaces/fulfillment.FulfillmentService.mdx index a4c8aa55ef..a3528912b0 100644 --- a/www/apps/docs/content/references/fulfillment/interfaces/fulfillment.FulfillmentService.mdx +++ b/www/apps/docs/content/references/fulfillment/interfaces/fulfillment.FulfillmentService.mdx @@ -194,7 +194,7 @@ class MyFulfillmentService extends AbstractFulfillmentService { // ... async createFulfillment( data: Record, - items: LineItem, + items: LineItem[], order: Order, fulfillment: Fulfillment ) {