diff --git a/.changeset/hot-colts-deliver.md b/.changeset/hot-colts-deliver.md new file mode 100644 index 0000000000000..d31758409456b --- /dev/null +++ b/.changeset/hot-colts-deliver.md @@ -0,0 +1,8 @@ +--- +"@medusajs/core-flows": patch +"@medusajs/medusa": patch +"@medusajs/types": patch +"@medusajs/utils": patch +--- + +feat(core-flows,medusa,types,utils): add rules to promotion endpoints + workflow diff --git a/integration-tests/modules/__tests__/promotion/admin/list-promotions.spec.ts b/integration-tests/modules/__tests__/promotion/admin/list-promotions.spec.ts index 173c0c594fe53..db3e75fbedbca 100644 --- a/integration-tests/modules/__tests__/promotion/admin/list-promotions.spec.ts +++ b/integration-tests/modules/__tests__/promotion/admin/list-promotions.spec.ts @@ -1,8 +1,8 @@ -import { IPromotionModuleService } from "@medusajs/types" import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { IPromotionModuleService } from "@medusajs/types" import { PromotionType } from "@medusajs/utils" -import { createAdminUser } from "../../../../helpers/create-admin-user" import { medusaIntegrationTestRunner } from "medusa-test-utils" +import { createAdminUser } from "../../../../helpers/create-admin-user" jest.setTimeout(50000) @@ -56,15 +56,13 @@ medusaIntegrationTestRunner({ created_at: expect.any(String), updated_at: expect.any(String), deleted_at: null, + rules: [], application_method: expect.objectContaining({ id: expect.any(String), value: 100, type: "fixed", target_type: "order", allocation: null, - created_at: expect.any(String), - updated_at: expect.any(String), - deleted_at: null, }), }), ]) @@ -84,7 +82,7 @@ medusaIntegrationTestRunner({ ]) const response = await api.get( - `/admin/promotions?fields=code,created_at,application_method.id`, + `/admin/promotions?fields=code,created_at,application_method.id&expand=application_method`, adminHeaders ) diff --git a/integration-tests/modules/__tests__/promotion/admin/promotion-rules.spec.ts b/integration-tests/modules/__tests__/promotion/admin/promotion-rules.spec.ts new file mode 100644 index 0000000000000..6de28b519540a --- /dev/null +++ b/integration-tests/modules/__tests__/promotion/admin/promotion-rules.spec.ts @@ -0,0 +1,347 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { IPromotionModuleService } from "@medusajs/types" +import { PromotionType } from "@medusajs/utils" +import { medusaIntegrationTestRunner } from "medusa-test-utils" +import { createAdminUser } from "../../../../helpers/create-admin-user" + +jest.setTimeout(50000) + +const env = { MEDUSA_FF_MEDUSA_V2: true } +const adminHeaders = { headers: { "x-medusa-access-token": "test_token" } } + +medusaIntegrationTestRunner({ + env, + testSuite: ({ dbConnection, getContainer, api }) => { + describe("Admin: Promotion Rules API", () => { + let appContainer + let standardPromotion + let promotionModule: IPromotionModuleService + const promotionRule = { + operator: "eq", + attribute: "old_attr", + values: ["old value"], + } + + beforeAll(async () => { + appContainer = getContainer() + promotionModule = appContainer.resolve(ModuleRegistrationName.PROMOTION) + }) + + beforeEach(async () => { + await createAdminUser(dbConnection, adminHeaders, appContainer) + + standardPromotion = await promotionModule.create({ + code: "TEST_ACROSS", + type: PromotionType.STANDARD, + application_method: { + type: "fixed", + allocation: "across", + target_type: "items", + value: 100, + target_rules: [promotionRule], + }, + rules: [promotionRule], + }) + }) + + describe("POST /admin/promotions/:id/rules", () => { + it("should throw error when required params are missing", async () => { + const { response } = await api + .post( + `/admin/promotions/${standardPromotion.id}/rules`, + { + rules: [ + { + operator: "eq", + values: ["new value"], + }, + ], + }, + adminHeaders + ) + .catch((e) => e) + + expect(response.status).toEqual(400) + expect(response.data).toEqual({ + type: "invalid_data", + message: + "attribute must be a string, attribute should not be empty", + }) + }) + + it("should throw error when promotion does not exist", async () => { + const { response } = await api + .post( + `/admin/promotions/does-not-exist/rules`, + { + rules: [ + { + attribute: "new_attr", + operator: "eq", + values: ["new value"], + }, + ], + }, + adminHeaders + ) + .catch((e) => e) + + expect(response.status).toEqual(404) + expect(response.data).toEqual({ + type: "not_found", + message: "Promotion with id: does-not-exist was not found", + }) + }) + + it("should add rules to a promotion successfully", async () => { + const response = await api.post( + `/admin/promotions/${standardPromotion.id}/rules`, + { + rules: [ + { + operator: "eq", + attribute: "new_attr", + values: ["new value"], + }, + ], + }, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.promotion).toEqual( + expect.objectContaining({ + id: standardPromotion.id, + rules: expect.arrayContaining([ + expect.objectContaining({ + operator: "eq", + attribute: "old_attr", + values: [expect.objectContaining({ value: "old value" })], + }), + expect.objectContaining({ + operator: "eq", + attribute: "new_attr", + values: [expect.objectContaining({ value: "new value" })], + }), + ]), + }) + ) + }) + }) + + describe("POST /admin/promotions/:id/target-rules", () => { + it("should throw error when required params are missing", async () => { + const { response } = await api + .post( + `/admin/promotions/${standardPromotion.id}/target-rules`, + { + rules: [ + { + operator: "eq", + values: ["new value"], + }, + ], + }, + adminHeaders + ) + .catch((e) => e) + + expect(response.status).toEqual(400) + expect(response.data).toEqual({ + type: "invalid_data", + message: + "attribute must be a string, attribute should not be empty", + }) + }) + + it("should throw error when promotion does not exist", async () => { + const { response } = await api + .post( + `/admin/promotions/does-not-exist/target-rules`, + { + rules: [ + { + attribute: "new_attr", + operator: "eq", + values: ["new value"], + }, + ], + }, + adminHeaders + ) + .catch((e) => e) + + expect(response.status).toEqual(404) + expect(response.data).toEqual({ + type: "not_found", + message: "Promotion with id: does-not-exist was not found", + }) + }) + + it("should add target rules to a promotion successfully", async () => { + const response = await api.post( + `/admin/promotions/${standardPromotion.id}/target-rules`, + { + rules: [ + { + operator: "eq", + attribute: "new_attr", + values: ["new value"], + }, + ], + }, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.promotion).toEqual( + expect.objectContaining({ + id: standardPromotion.id, + application_method: expect.objectContaining({ + target_rules: expect.arrayContaining([ + expect.objectContaining({ + operator: "eq", + attribute: "old_attr", + values: [expect.objectContaining({ value: "old value" })], + }), + expect.objectContaining({ + operator: "eq", + attribute: "new_attr", + values: [expect.objectContaining({ value: "new value" })], + }), + ]), + }), + }) + ) + }) + }) + + describe("POST /admin/promotions/:id/buy-rules", () => { + it("should throw error when required params are missing", async () => { + const { response } = await api + .post( + `/admin/promotions/${standardPromotion.id}/buy-rules`, + { + rules: [ + { + operator: "eq", + values: ["new value"], + }, + ], + }, + adminHeaders + ) + .catch((e) => e) + + expect(response.status).toEqual(400) + expect(response.data).toEqual({ + type: "invalid_data", + message: + "attribute must be a string, attribute should not be empty", + }) + }) + + it("should throw error when promotion does not exist", async () => { + const { response } = await api + .post( + `/admin/promotions/does-not-exist/buy-rules`, + { + rules: [ + { + attribute: "new_attr", + operator: "eq", + values: ["new value"], + }, + ], + }, + adminHeaders + ) + .catch((e) => e) + + expect(response.status).toEqual(404) + expect(response.data).toEqual({ + type: "not_found", + message: "Promotion with id: does-not-exist was not found", + }) + }) + + it("should throw an error when trying to add buy rules to a standard promotion", async () => { + const { response } = await api + .post( + `/admin/promotions/${standardPromotion.id}/buy-rules`, + { + rules: [ + { + operator: "eq", + attribute: "new_attr", + values: ["new value"], + }, + ], + }, + adminHeaders + ) + .catch((e) => e) + + expect(response.status).toEqual(400) + expect(response.data).toEqual({ + type: "invalid_data", + message: "Can't add buy rules to a standard promotion", + }) + }) + + it("should add buy rules to a buyget promotion successfully", async () => { + const buyGetPromotion = await promotionModule.create({ + code: "TEST_BUYGET", + type: PromotionType.BUYGET, + application_method: { + type: "fixed", + target_type: "items", + allocation: "across", + value: 100, + apply_to_quantity: 1, + buy_rules_min_quantity: 1, + buy_rules: [promotionRule], + target_rules: [promotionRule], + }, + rules: [promotionRule], + }) + + const response = await api.post( + `/admin/promotions/${buyGetPromotion.id}/buy-rules`, + { + rules: [ + { + operator: "eq", + attribute: "new_attr", + values: ["new value"], + }, + ], + }, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.promotion).toEqual( + expect.objectContaining({ + id: buyGetPromotion.id, + application_method: expect.objectContaining({ + buy_rules: expect.arrayContaining([ + expect.objectContaining({ + operator: "eq", + attribute: "old_attr", + values: [expect.objectContaining({ value: "old value" })], + }), + expect.objectContaining({ + operator: "eq", + attribute: "new_attr", + values: [expect.objectContaining({ value: "new value" })], + }), + ]), + }), + }) + ) + }) + }) + }) + }, +}) diff --git a/integration-tests/modules/__tests__/promotion/admin/retrieve-promotion.spec.ts b/integration-tests/modules/__tests__/promotion/admin/retrieve-promotion.spec.ts index a9fcf2f159b20..ff516111c1ad2 100644 --- a/integration-tests/modules/__tests__/promotion/admin/retrieve-promotion.spec.ts +++ b/integration-tests/modules/__tests__/promotion/admin/retrieve-promotion.spec.ts @@ -1,8 +1,8 @@ -import { IPromotionModuleService } from "@medusajs/types" import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { IPromotionModuleService } from "@medusajs/types" import { PromotionType } from "@medusajs/utils" -import { createAdminUser } from "../../../../helpers/create-admin-user" import { medusaIntegrationTestRunner } from "medusa-test-utils" +import { createAdminUser } from "../../../../helpers/create-admin-user" jest.setTimeout(50000) @@ -73,11 +73,7 @@ medusaIntegrationTestRunner({ value: 100, type: "fixed", target_type: "order", - max_quantity: 0, allocation: null, - created_at: expect.any(String), - updated_at: expect.any(String), - deleted_at: null, }), }) ) diff --git a/packages/core-flows/src/promotion/steps/add-rules-to-promotions.ts b/packages/core-flows/src/promotion/steps/add-rules-to-promotions.ts new file mode 100644 index 0000000000000..c188bd879e4c8 --- /dev/null +++ b/packages/core-flows/src/promotion/steps/add-rules-to-promotions.ts @@ -0,0 +1,76 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { + AddPromotionRulesWorkflowDTO, + IPromotionModuleService, +} from "@medusajs/types" +import { RuleType } from "@medusajs/utils" +import { StepResponse, createStep } from "@medusajs/workflows-sdk" + +export const addRulesToPromotionsStepId = "add-rules-to-promotions" +export const addRulesToPromotionsStep = createStep( + addRulesToPromotionsStepId, + async (input: AddPromotionRulesWorkflowDTO, { container }) => { + const { data, rule_type: ruleType } = input + + const promotionModule = container.resolve( + ModuleRegistrationName.PROMOTION + ) + + const createdPromotionRules = + ruleType === RuleType.RULES + ? await promotionModule.addPromotionRules(data.id, data.rules) + : [] + + const createdPromotionBuyRules = + ruleType === RuleType.BUY_RULES + ? await promotionModule.addPromotionBuyRules(data.id, data.rules) + : [] + + const createdPromotionTargetRules = + ruleType === RuleType.TARGET_RULES + ? await promotionModule.addPromotionTargetRules(data.id, data.rules) + : [] + + const promotionRules = [ + ...createdPromotionRules, + ...createdPromotionBuyRules, + ...createdPromotionTargetRules, + ] + + return new StepResponse(promotionRules, { + id: data.id, + ruleIds: createdPromotionRules.map((pr) => pr.id), + buyRuleIds: createdPromotionBuyRules.map((pr) => pr.id), + targetRuleIds: createdPromotionBuyRules.map((pr) => pr.id), + }) + }, + async (data, { container }) => { + if (!data) { + return + } + + const { id, ruleIds = [], buyRuleIds = [], targetRuleIds = [] } = data + + const promotionModule = container.resolve( + ModuleRegistrationName.PROMOTION + ) + + ruleIds.length && + (await promotionModule.removePromotionRules( + id, + ruleIds.map((id) => ({ id })) + )) + + buyRuleIds.length && + (await promotionModule.removePromotionBuyRules( + id, + buyRuleIds.map((id) => ({ id })) + )) + + targetRuleIds.length && + (await promotionModule.removePromotionBuyRules( + id, + targetRuleIds.map((id) => ({ id })) + )) + } +) diff --git a/packages/core-flows/src/promotion/steps/index.ts b/packages/core-flows/src/promotion/steps/index.ts index 5e81ec9e63796..f2f0ef311d987 100644 --- a/packages/core-flows/src/promotion/steps/index.ts +++ b/packages/core-flows/src/promotion/steps/index.ts @@ -1,3 +1,4 @@ +export * from "./add-rules-to-promotions" export * from "./create-campaigns" export * from "./create-promotions" export * from "./delete-campaigns" diff --git a/packages/core-flows/src/promotion/workflows/add-rules-to-promotions.ts b/packages/core-flows/src/promotion/workflows/add-rules-to-promotions.ts new file mode 100644 index 0000000000000..80e4d774b5309 --- /dev/null +++ b/packages/core-flows/src/promotion/workflows/add-rules-to-promotions.ts @@ -0,0 +1,11 @@ +import { AddPromotionRulesWorkflowDTO } from "@medusajs/types" +import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk" +import { addRulesToPromotionsStep } from "../steps" + +export const addRulesToPromotionsWorkflowId = "add-rules-to-promotions-workflow" +export const addRulesToPromotionsWorkflow = createWorkflow( + addRulesToPromotionsWorkflowId, + (input: WorkflowData): WorkflowData => { + addRulesToPromotionsStep(input) + } +) diff --git a/packages/core-flows/src/promotion/workflows/index.ts b/packages/core-flows/src/promotion/workflows/index.ts index 5e81ec9e63796..f2f0ef311d987 100644 --- a/packages/core-flows/src/promotion/workflows/index.ts +++ b/packages/core-flows/src/promotion/workflows/index.ts @@ -1,3 +1,4 @@ +export * from "./add-rules-to-promotions" export * from "./create-campaigns" export * from "./create-promotions" export * from "./delete-campaigns" diff --git a/packages/medusa/src/api-v2/admin/promotions/[id]/buy-rules/route.ts b/packages/medusa/src/api-v2/admin/promotions/[id]/buy-rules/route.ts new file mode 100644 index 0000000000000..bd506451f6ae2 --- /dev/null +++ b/packages/medusa/src/api-v2/admin/promotions/[id]/buy-rules/route.ts @@ -0,0 +1,46 @@ +import { addRulesToPromotionsWorkflow } from "@medusajs/core-flows" +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { IPromotionModuleService } from "@medusajs/types" +import { RuleType } from "@medusajs/utils" +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "../../../../../types/routing" +import { + defaultAdminPromotionFields, + defaultAdminPromotionRelations, +} from "../../query-config" +import { AdminPostPromotionsPromotionRulesReq } from "../../validators" + +export const POST = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const id = req.params.id + const workflow = addRulesToPromotionsWorkflow(req.scope) + const { errors } = await workflow.run({ + input: { + rule_type: RuleType.BUY_RULES, + data: { + id: req.params.id, + ...req.validatedBody, + }, + }, + throwOnError: false, + }) + + if (Array.isArray(errors) && errors[0]) { + throw errors[0].error + } + + const promotionModuleService: IPromotionModuleService = req.scope.resolve( + ModuleRegistrationName.PROMOTION + ) + + const promotion = await promotionModuleService.retrieve(id, { + select: defaultAdminPromotionFields, + relations: defaultAdminPromotionRelations, + }) + + res.status(200).json({ promotion }) +} diff --git a/packages/medusa/src/api-v2/admin/promotions/[id]/route.ts b/packages/medusa/src/api-v2/admin/promotions/[id]/route.ts index d4136aae603ad..442158f95629e 100644 --- a/packages/medusa/src/api-v2/admin/promotions/[id]/route.ts +++ b/packages/medusa/src/api-v2/admin/promotions/[id]/route.ts @@ -1,16 +1,15 @@ -import { - AuthenticatedMedusaRequest, - MedusaResponse, -} from "../../../../types/routing" import { deletePromotionsWorkflow, updatePromotionsWorkflow, } from "@medusajs/core-flows" +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "../../../../types/routing" -import { AdminPostPromotionsPromotionReq } from "../validators" -import { IPromotionModuleService } from "@medusajs/types" import { ModuleRegistrationName } from "@medusajs/modules-sdk" -import { UpdatePromotionDTO } from "@medusajs/types" +import { IPromotionModuleService, UpdatePromotionDTO } from "@medusajs/types" +import { AdminPostPromotionsPromotionReq } from "../validators" export const GET = async ( req: AuthenticatedMedusaRequest, diff --git a/packages/medusa/src/api-v2/admin/promotions/[id]/rules/route.ts b/packages/medusa/src/api-v2/admin/promotions/[id]/rules/route.ts new file mode 100644 index 0000000000000..6d75f3d2b0093 --- /dev/null +++ b/packages/medusa/src/api-v2/admin/promotions/[id]/rules/route.ts @@ -0,0 +1,47 @@ +import { addRulesToPromotionsWorkflow } from "@medusajs/core-flows" +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { IPromotionModuleService } from "@medusajs/types" +import { RuleType } from "@medusajs/utils" +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "../../../../../types/routing" +import { + defaultAdminPromotionFields, + defaultAdminPromotionRelations, +} from "../../query-config" +import { AdminPostPromotionsPromotionRulesReq } from "../../validators" + +export const POST = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const id = req.params.id + const workflow = addRulesToPromotionsWorkflow(req.scope) + + const { errors } = await workflow.run({ + input: { + rule_type: RuleType.RULES, + data: { + id: req.params.id, + ...req.validatedBody, + }, + }, + throwOnError: false, + }) + + if (Array.isArray(errors) && errors[0]) { + throw errors[0].error + } + + const promotionModuleService: IPromotionModuleService = req.scope.resolve( + ModuleRegistrationName.PROMOTION + ) + + const promotion = await promotionModuleService.retrieve(id, { + select: defaultAdminPromotionFields, + relations: defaultAdminPromotionRelations, + }) + + res.status(200).json({ promotion }) +} diff --git a/packages/medusa/src/api-v2/admin/promotions/[id]/target-rules/route.ts b/packages/medusa/src/api-v2/admin/promotions/[id]/target-rules/route.ts new file mode 100644 index 0000000000000..10b6a8a73524e --- /dev/null +++ b/packages/medusa/src/api-v2/admin/promotions/[id]/target-rules/route.ts @@ -0,0 +1,46 @@ +import { addRulesToPromotionsWorkflow } from "@medusajs/core-flows" +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { IPromotionModuleService } from "@medusajs/types" +import { RuleType } from "@medusajs/utils" +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "../../../../../types/routing" +import { + defaultAdminPromotionFields, + defaultAdminPromotionRelations, +} from "../../query-config" +import { AdminPostPromotionsPromotionRulesReq } from "../../validators" + +export const POST = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const id = req.params.id + const workflow = addRulesToPromotionsWorkflow(req.scope) + const { errors } = await workflow.run({ + input: { + rule_type: RuleType.TARGET_RULES, + data: { + id: req.params.id, + ...req.validatedBody, + }, + }, + throwOnError: false, + }) + + if (Array.isArray(errors) && errors[0]) { + throw errors[0].error + } + + const promotionModuleService: IPromotionModuleService = req.scope.resolve( + ModuleRegistrationName.PROMOTION + ) + + const promotion = await promotionModuleService.retrieve(id, { + select: defaultAdminPromotionFields, + relations: defaultAdminPromotionRelations, + }) + + res.status(200).json({ promotion }) +} diff --git a/packages/medusa/src/api-v2/admin/promotions/middlewares.ts b/packages/medusa/src/api-v2/admin/promotions/middlewares.ts index 37362a0c7e972..b52910f0dd0d0 100644 --- a/packages/medusa/src/api-v2/admin/promotions/middlewares.ts +++ b/packages/medusa/src/api-v2/admin/promotions/middlewares.ts @@ -1,18 +1,14 @@ import * as QueryConfig from "./query-config" +import { transformBody, transformQuery } from "../../../api/middlewares" import { AdminGetPromotionsParams, AdminGetPromotionsPromotionParams, AdminPostPromotionsPromotionReq, + AdminPostPromotionsPromotionRulesReq, AdminPostPromotionsReq, } from "./validators" -import { - isFeatureFlagEnabled, - transformBody, - transformQuery, -} from "../../../api/middlewares" -import { MedusaV2Flag } from "@medusajs/utils" import { MiddlewareRoute } from "../../../loaders/helpers/routing/types" import { authenticate } from "../../../utils/authenticate-middleware" @@ -51,4 +47,19 @@ export const adminPromotionRoutesMiddlewares: MiddlewareRoute[] = [ matcher: "/admin/promotions/:id", middlewares: [transformBody(AdminPostPromotionsPromotionReq)], }, + { + method: ["POST"], + matcher: "/admin/promotions/:id/rules", + middlewares: [transformBody(AdminPostPromotionsPromotionRulesReq)], + }, + { + method: ["POST"], + matcher: "/admin/promotions/:id/target-rules", + middlewares: [transformBody(AdminPostPromotionsPromotionRulesReq)], + }, + { + method: ["POST"], + matcher: "/admin/promotions/:id/buy-rules", + middlewares: [transformBody(AdminPostPromotionsPromotionRulesReq)], + }, ] diff --git a/packages/medusa/src/api-v2/admin/promotions/query-config.ts b/packages/medusa/src/api-v2/admin/promotions/query-config.ts index 06633e78f2fb4..38d3b65c8aa90 100644 --- a/packages/medusa/src/api-v2/admin/promotions/query-config.ts +++ b/packages/medusa/src/api-v2/admin/promotions/query-config.ts @@ -1,16 +1,43 @@ -export const defaultAdminPromotionRelations = ["campaign", "application_method"] +export const defaultAdminPromotionRelations = [ + "campaign", + "rules", + "rules.values", + "application_method", + "application_method.buy_rules", + "application_method.buy_rules.values", + "application_method.target_rules", + "application_method.target_rules.values", +] export const allowedAdminPromotionRelations = [ ...defaultAdminPromotionRelations, ] export const defaultAdminPromotionFields = [ "id", "code", - "campaign", "is_automatic", "type", "created_at", "updated_at", "deleted_at", + "campaign.id", + "campaign.name", + "application_method.value", + "application_method.type", + "application_method.max_quantity", + "application_method.target_type", + "application_method.allocation", + "application_method.created_at", + "application_method.updated_at", + "application_method.deleted_at", + "application_method.buy_rules.attribute", + "application_method.buy_rules.operator", + "application_method.buy_rules.values.value", + "application_method.target_rules.attribute", + "application_method.target_rules.operator", + "application_method.target_rules.values.value", + "rules.attribute", + "rules.operator", + "rules.values.value", ] export const retrieveTransformQueryConfig = { diff --git a/packages/medusa/src/api-v2/admin/promotions/route.ts b/packages/medusa/src/api-v2/admin/promotions/route.ts index 32334d3e177e4..701d553f850bb 100644 --- a/packages/medusa/src/api-v2/admin/promotions/route.ts +++ b/packages/medusa/src/api-v2/admin/promotions/route.ts @@ -1,11 +1,11 @@ +import { CreatePromotionDTO, IPromotionModuleService } from "@medusajs/types" import { AuthenticatedMedusaRequest, MedusaResponse, } from "../../../types/routing" -import { CreatePromotionDTO, IPromotionModuleService } from "@medusajs/types" -import { ModuleRegistrationName } from "@medusajs/modules-sdk" import { createPromotionsWorkflow } from "@medusajs/core-flows" +import { ModuleRegistrationName } from "@medusajs/modules-sdk" export const GET = async ( req: AuthenticatedMedusaRequest, diff --git a/packages/medusa/src/api-v2/admin/promotions/validators.ts b/packages/medusa/src/api-v2/admin/promotions/validators.ts index cd75d04975825..1e22d2af7678c 100644 --- a/packages/medusa/src/api-v2/admin/promotions/validators.ts +++ b/packages/medusa/src/api-v2/admin/promotions/validators.ts @@ -214,3 +214,10 @@ export class AdminPostPromotionsPromotionReq { @Type(() => PromotionRule) rules?: PromotionRule[] } + +export class AdminPostPromotionsPromotionRulesReq { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => PromotionRule) + rules: PromotionRule[] +} 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 94f6953370364..1f632c758fde3 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 @@ -1084,8 +1084,8 @@ describe("Promotion Service", () => { expect(error.message).toEqual("promotion - id must be defined") }) - it("should successfully create rules for a promotion", async () => { - promotion = await service.addPromotionRules(promotion.id, [ + it("should successfully add rules to a promotion", async () => { + const promotionRules = await service.addPromotionRules(promotion.id, [ { attribute: "customer_group_id", operator: "in", @@ -1093,21 +1093,17 @@ describe("Promotion Service", () => { }, ]) - expect(promotion).toEqual( + expect(promotionRules).toEqual([ expect.objectContaining({ - id: promotion.id, - rules: [ - expect.objectContaining({ - attribute: "customer_group_id", - operator: "in", - values: [ - expect.objectContaining({ value: "VIP" }), - expect.objectContaining({ value: "top100" }), - ], - }), - ], - }) - ) + id: promotionRules[0].id, + attribute: "customer_group_id", + operator: "in", + values: expect.arrayContaining([ + expect.objectContaining({ value: "VIP" }), + expect.objectContaining({ value: "top100" }), + ]), + }), + ]) }) }) @@ -1160,31 +1156,28 @@ describe("Promotion Service", () => { }) it("should successfully create target rules for a promotion", async () => { - promotion = await service.addPromotionTargetRules(promotion.id, [ - { + const promotionRules = await service.addPromotionTargetRules( + promotion.id, + [ + { + attribute: "customer_group_id", + operator: "in", + values: ["VIP", "top100"], + }, + ] + ) + + expect(promotionRules).toEqual([ + expect.objectContaining({ + id: promotionRules[0].id, attribute: "customer_group_id", operator: "in", - values: ["VIP", "top100"], - }, + values: expect.arrayContaining([ + expect.objectContaining({ value: "VIP" }), + expect.objectContaining({ value: "top100" }), + ]), + }), ]) - - expect(promotion).toEqual( - expect.objectContaining({ - id: promotion.id, - application_method: expect.objectContaining({ - target_rules: [ - expect.objectContaining({ - attribute: "customer_group_id", - operator: "in", - values: [ - expect.objectContaining({ value: "VIP" }), - expect.objectContaining({ value: "top100" }), - ], - }), - ], - }), - }) - ) }) }) @@ -1250,7 +1243,7 @@ describe("Promotion Service", () => { }) it("should successfully create buy rules for a buyget promotion", async () => { - promotion = await service.addPromotionBuyRules(promotion.id, [ + const promotionRules = await service.addPromotionBuyRules(promotion.id, [ { attribute: "product.id", operator: "in", @@ -1258,30 +1251,17 @@ describe("Promotion Service", () => { }, ]) - expect(promotion).toEqual( + expect(promotionRules).toEqual([ expect.objectContaining({ - id: promotion.id, - application_method: expect.objectContaining({ - buy_rules: expect.arrayContaining([ - expect.objectContaining({ - attribute: "product_collection.id", - operator: "eq", - values: expect.arrayContaining([ - expect.objectContaining({ value: "pcol_towel" }), - ]), - }), - expect.objectContaining({ - attribute: "product.id", - operator: "in", - values: expect.arrayContaining([ - expect.objectContaining({ value: "prod_3" }), - expect.objectContaining({ value: "prod_4" }), - ]), - }), - ]), - }), - }) - ) + id: promotionRules[0].id, + attribute: "product.id", + operator: "in", + values: expect.arrayContaining([ + expect.objectContaining({ value: "prod_3" }), + expect.objectContaining({ value: "prod_4" }), + ]), + }), + ]) }) }) @@ -1337,14 +1317,16 @@ describe("Promotion Service", () => { expect(error.message).toEqual("promotion - id must be defined") }) - it("should successfully create rules for a promotion", async () => { + it("should successfully remove rules for a promotion", async () => { const [ruleId] = promotion.rules.map((rule) => rule.id) - promotion = await service.removePromotionRules(promotion.id, [ - { id: ruleId }, - ]) + await service.removePromotionRules(promotion.id, [{ id: ruleId }]) - expect(promotion).toEqual( + const updatedPromotion = await service.retrieve(promotion.id, { + relations: ["rules", "rules.values"], + }) + + expect(updatedPromotion).toEqual( expect.objectContaining({ id: promotion.id, rules: [], @@ -1413,11 +1395,13 @@ describe("Promotion Service", () => { (rule) => rule.id ) - promotion = await service.removePromotionTargetRules(promotion.id, [ - { id: ruleId }, - ]) + await service.removePromotionTargetRules(promotion.id, [{ id: ruleId }]) - expect(promotion).toEqual( + const updatedPromotion = await service.retrieve(promotion.id, { + relations: ["application_method.target_rules.values"], + }) + + expect(updatedPromotion).toEqual( expect.objectContaining({ id: promotion.id, application_method: expect.objectContaining({ @@ -1497,11 +1481,13 @@ describe("Promotion Service", () => { (rule) => rule.id ) - promotion = await service.removePromotionBuyRules(promotion.id, [ - { id: ruleId }, - ]) + await service.removePromotionBuyRules(promotion.id, [{ id: ruleId }]) - expect(promotion).toEqual( + const updatedPromotion = await service.retrieve(promotion.id, { + relations: ["application_method.buy_rules.values"], + }) + + expect(updatedPromotion).toEqual( expect.objectContaining({ id: promotion.id, application_method: expect.objectContaining({ diff --git a/packages/promotion/src/services/promotion-module.ts b/packages/promotion/src/services/promotion-module.ts index 489a1ccc31709..46a20d555cfa9 100644 --- a/packages/promotion/src/services/promotion-module.ts +++ b/packages/promotion/src/services/promotion-module.ts @@ -755,19 +755,19 @@ export default class PromotionModuleService< promotionId: string, rulesData: PromotionTypes.CreatePromotionRuleDTO[], @MedusaContext() sharedContext: Context = {} - ): Promise { + ): Promise { const promotion = await this.promotionService_.retrieve(promotionId) - await this.createPromotionRulesAndValues_( + const createdPromotionRules = await this.createPromotionRulesAndValues_( rulesData, "promotions", promotion, sharedContext ) - return await this.retrieve( - promotionId, - { relations: ["rules", "rules.values"] }, + return this.listPromotionRules( + { id: createdPromotionRules.map((r) => r.id) }, + { relations: ["values"] }, sharedContext ) } @@ -777,7 +777,7 @@ export default class PromotionModuleService< promotionId: string, rulesData: PromotionTypes.CreatePromotionRuleDTO[], @MedusaContext() sharedContext: Context = {} - ): Promise { + ): Promise { const promotion = await this.promotionService_.retrieve(promotionId, { relations: ["application_method"], }) @@ -791,24 +791,16 @@ export default class PromotionModuleService< ) } - await this.createPromotionRulesAndValues_( + const createdPromotionRules = await this.createPromotionRulesAndValues_( rulesData, "method_target_rules", applicationMethod, sharedContext ) - return await this.retrieve( - promotionId, - { - relations: [ - "rules", - "rules.values", - "application_method", - "application_method.target_rules", - "application_method.target_rules.values", - ], - }, + return await this.listPromotionRules( + { id: createdPromotionRules.map((pr) => pr.id) }, + { relations: ["values"] }, sharedContext ) } @@ -818,7 +810,7 @@ export default class PromotionModuleService< promotionId: string, rulesData: PromotionTypes.CreatePromotionRuleDTO[], @MedusaContext() sharedContext: Context = {} - ): Promise { + ): Promise { const promotion = await this.promotionService_.retrieve(promotionId, { relations: ["application_method"], }) @@ -832,24 +824,16 @@ export default class PromotionModuleService< ) } - await this.createPromotionRulesAndValues_( + const createdPromotionRules = await this.createPromotionRulesAndValues_( rulesData, "method_buy_rules", applicationMethod, sharedContext ) - return await this.retrieve( - promotionId, - { - relations: [ - "rules", - "rules.values", - "application_method", - "application_method.buy_rules", - "application_method.buy_rules.values", - ], - }, + return await this.listPromotionRules( + { id: createdPromotionRules.map((pr) => pr.id) }, + { relations: ["values"] }, sharedContext ) } @@ -860,7 +844,25 @@ export default class PromotionModuleService< relationName: "promotions" | "method_target_rules" | "method_buy_rules", relation: Promotion | ApplicationMethod, @MedusaContext() sharedContext: Context = {} - ) { + ): Promise { + const createdPromotionRules: TPromotionRule[] = [] + const promotion = + relation instanceof ApplicationMethod ? relation.promotion : relation + + if (!rulesData.length) { + return [] + } + + if ( + relationName === "method_buy_rules" && + promotion.type === PromotionType.STANDARD + ) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Can't add buy rules to a ${PromotionType.STANDARD} promotion` + ) + } + validatePromotionRuleAttributes(rulesData) for (const ruleData of rulesData) { @@ -875,6 +877,8 @@ export default class PromotionModuleService< sharedContext ) + createdPromotionRules.push(createdPromotionRule) + const ruleValues = Array.isArray(values) ? values : [values] const promotionRuleValuesData = ruleValues.map((ruleValue) => ({ value: ruleValue, @@ -886,6 +890,8 @@ export default class PromotionModuleService< sharedContext ) } + + return createdPromotionRules } @InjectManager("baseRepository_") @@ -893,14 +899,8 @@ export default class PromotionModuleService< promotionId: string, rulesData: PromotionTypes.RemovePromotionRuleDTO[], @MedusaContext() sharedContext: Context = {} - ): Promise { + ): Promise { await this.removePromotionRules_(promotionId, rulesData, sharedContext) - - return await this.retrieve( - promotionId, - { relations: ["rules", "rules.values"] }, - sharedContext - ) } @InjectTransactionManager("baseRepository_") @@ -932,27 +932,13 @@ export default class PromotionModuleService< promotionId: string, rulesData: PromotionTypes.RemovePromotionRuleDTO[], @MedusaContext() sharedContext: Context = {} - ): Promise { + ): Promise { await this.removeApplicationMethodRules_( promotionId, rulesData, ApplicationMethodRuleTypes.TARGET_RULES, sharedContext ) - - return await this.retrieve( - promotionId, - { - relations: [ - "rules", - "rules.values", - "application_method", - "application_method.target_rules", - "application_method.target_rules.values", - ], - }, - sharedContext - ) } @InjectManager("baseRepository_") @@ -960,27 +946,13 @@ export default class PromotionModuleService< promotionId: string, rulesData: PromotionTypes.RemovePromotionRuleDTO[], @MedusaContext() sharedContext: Context = {} - ): Promise { + ): Promise { await this.removeApplicationMethodRules_( promotionId, rulesData, ApplicationMethodRuleTypes.BUY_RULES, sharedContext ) - - return await this.retrieve( - promotionId, - { - relations: [ - "rules", - "rules.values", - "application_method", - "application_method.buy_rules", - "application_method.buy_rules.values", - ], - }, - sharedContext - ) } @InjectTransactionManager("baseRepository_") diff --git a/packages/types/src/promotion/common/promotion-rule.ts b/packages/types/src/promotion/common/promotion-rule.ts index eeac63a53dabe..b6e616b06644b 100644 --- a/packages/types/src/promotion/common/promotion-rule.ts +++ b/packages/types/src/promotion/common/promotion-rule.ts @@ -38,3 +38,5 @@ export interface FilterablePromotionRuleProps id?: string[] code?: string[] } + +export type PromotionRuleTypes = "buy_rules" | "target_rules" | "rules" diff --git a/packages/types/src/promotion/index.ts b/packages/types/src/promotion/index.ts index 0c73656566caa..dfae9af8a2f1f 100644 --- a/packages/types/src/promotion/index.ts +++ b/packages/types/src/promotion/index.ts @@ -1,3 +1,4 @@ export * from "./common" export * from "./mutations" export * from "./service" +export * from "./workflows" diff --git a/packages/types/src/promotion/service.ts b/packages/types/src/promotion/service.ts index fd9721ef54344..905331cd55ca9 100644 --- a/packages/types/src/promotion/service.ts +++ b/packages/types/src/promotion/service.ts @@ -11,6 +11,7 @@ import { FilterableCampaignProps, FilterablePromotionProps, PromotionDTO, + PromotionRuleDTO, RemovePromotionRuleDTO, UpdatePromotionDTO, } from "./common" @@ -82,37 +83,37 @@ export interface IPromotionModuleService extends IModuleService { promotionId: string, rulesData: CreatePromotionRuleDTO[], sharedContext?: Context - ): Promise + ): Promise addPromotionTargetRules( promotionId: string, rulesData: CreatePromotionRuleDTO[], sharedContext?: Context - ): Promise + ): Promise addPromotionBuyRules( promotionId: string, rulesData: CreatePromotionRuleDTO[], sharedContext?: Context - ): Promise + ): Promise removePromotionRules( promotionId: string, rulesData: RemovePromotionRuleDTO[], sharedContext?: Context - ): Promise + ): Promise removePromotionTargetRules( promotionId: string, rulesData: RemovePromotionRuleDTO[], sharedContext?: Context - ): Promise + ): Promise removePromotionBuyRules( promotionId: string, rulesData: RemovePromotionRuleDTO[], sharedContext?: Context - ): Promise + ): Promise createCampaigns( data: CreateCampaignDTO, diff --git a/packages/types/src/promotion/workflows.ts b/packages/types/src/promotion/workflows.ts new file mode 100644 index 0000000000000..512e19298f9eb --- /dev/null +++ b/packages/types/src/promotion/workflows.ts @@ -0,0 +1,9 @@ +import { CreatePromotionRuleDTO, PromotionRuleTypes } from "./common" + +export type AddPromotionRulesWorkflowDTO = { + rule_type: PromotionRuleTypes + data: { + id: string + rules: CreatePromotionRuleDTO[] + } +} diff --git a/packages/utils/src/promotion/index.ts b/packages/utils/src/promotion/index.ts index 453558124fa86..cd55398b36c11 100644 --- a/packages/utils/src/promotion/index.ts +++ b/packages/utils/src/promotion/index.ts @@ -47,3 +47,9 @@ export enum PromotionActions { REMOVE = "remove", REPLACE = "replace", } + +export enum RuleType { + RULES = "rules", + TARGET_RULES = "target_rules", + BUY_RULES = "buy_rules", +}