diff --git a/.changeset/plenty-seahorses-boil.md b/.changeset/plenty-seahorses-boil.md new file mode 100644 index 0000000000..2361bbac77 --- /dev/null +++ b/.changeset/plenty-seahorses-boil.md @@ -0,0 +1,7 @@ +--- +"@medusajs/core-flows": patch +"@medusajs/medusa": patch +"@medusajs/utils": patch +--- + +feat(core-flows,medusa,utils): promotion and campaign create/update endpoint diff --git a/integration-tests/plugins/__tests__/promotion/admin/create-campaign.spec.ts b/integration-tests/plugins/__tests__/promotion/admin/create-campaign.spec.ts new file mode 100644 index 0000000000..d9a65dbc31 --- /dev/null +++ b/integration-tests/plugins/__tests__/promotion/admin/create-campaign.spec.ts @@ -0,0 +1,101 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { IPromotionModuleService } from "@medusajs/types" +import path from "path" +import { startBootstrapApp } from "../../../../environment-helpers/bootstrap-app" +import { useApi } from "../../../../environment-helpers/use-api" +import { getContainer } from "../../../../environment-helpers/use-container" +import { initDb, useDb } from "../../../../environment-helpers/use-db" +import adminSeeder from "../../../../helpers/admin-seeder" + +const env = { MEDUSA_FF_MEDUSA_V2: true } +const adminHeaders = { + headers: { "x-medusa-access-token": "test_token" }, +} + +describe("POST /admin/campaigns", () => { + let dbConnection + let appContainer + let shutdownServer + let promotionModuleService: IPromotionModuleService + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..", "..")) + dbConnection = await initDb({ cwd, env } as any) + shutdownServer = await startBootstrapApp({ cwd, env }) + appContainer = getContainer() + promotionModuleService = appContainer.resolve( + ModuleRegistrationName.PROMOTION + ) + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + await shutdownServer() + }) + + beforeEach(async () => { + await adminSeeder(dbConnection) + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("should throw an error if required params are not passed", async () => { + const api = useApi() as any + const { response } = await api + .post(`/admin/campaigns`, {}, adminHeaders) + .catch((e) => e) + + expect(response.status).toEqual(400) + expect(response.data.message).toEqual( + "name must be a string, name should not be empty" + ) + }) + + it("should create a campaign successfully", async () => { + const createdPromotion = await promotionModuleService.create({ + code: "TEST", + type: "standard", + }) + + const api = useApi() as any + const response = await api.post( + `/admin/campaigns`, + { + name: "test", + campaign_identifier: "test", + starts_at: new Date("01/01/2024").toISOString(), + ends_at: new Date("01/01/2029").toISOString(), + promotions: [{ id: createdPromotion.id }], + budget: { + limit: 1000, + type: "usage", + }, + }, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.campaign).toEqual( + expect.objectContaining({ + id: expect.any(String), + name: "test", + campaign_identifier: "test", + starts_at: expect.any(String), + ends_at: expect.any(String), + budget: expect.objectContaining({ + limit: 1000, + type: "usage", + }), + promotions: [ + expect.objectContaining({ + id: createdPromotion.id, + }), + ], + }) + ) + }) +}) diff --git a/integration-tests/plugins/__tests__/promotion/admin/create-promotion.spec.ts b/integration-tests/plugins/__tests__/promotion/admin/create-promotion.spec.ts new file mode 100644 index 0000000000..33206fd1c9 --- /dev/null +++ b/integration-tests/plugins/__tests__/promotion/admin/create-promotion.spec.ts @@ -0,0 +1,151 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { IPromotionModuleService } from "@medusajs/types" +import { PromotionType } from "@medusajs/utils" +import path from "path" +import { startBootstrapApp } from "../../../../environment-helpers/bootstrap-app" +import { useApi } from "../../../../environment-helpers/use-api" +import { getContainer } from "../../../../environment-helpers/use-container" +import { initDb, useDb } from "../../../../environment-helpers/use-db" +import adminSeeder from "../../../../helpers/admin-seeder" + +const env = { MEDUSA_FF_MEDUSA_V2: true } +const adminHeaders = { + headers: { "x-medusa-access-token": "test_token" }, +} + +describe("POST /admin/promotions", () => { + let dbConnection + let appContainer + let shutdownServer + let promotionModuleService: IPromotionModuleService + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..", "..")) + dbConnection = await initDb({ cwd, env } as any) + shutdownServer = await startBootstrapApp({ cwd, env }) + appContainer = getContainer() + promotionModuleService = appContainer.resolve( + ModuleRegistrationName.PROMOTION + ) + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + await shutdownServer() + }) + + beforeEach(async () => { + await adminSeeder(dbConnection) + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("should throw an error if required params are not passed", async () => { + const api = useApi() as any + const { response } = await api + .post( + `/admin/promotions`, + { + type: PromotionType.STANDARD, + }, + adminHeaders + ) + .catch((e) => e) + + expect(response.status).toEqual(400) + expect(response.data.message).toEqual( + "code must be a string, code should not be empty, application_method should not be empty" + ) + }) + + it("should create a promotion successfully", async () => { + const api = useApi() as any + const response = await api.post( + `/admin/promotions`, + { + code: "TEST", + type: PromotionType.STANDARD, + is_automatic: true, + campaign: { + name: "test", + campaign_identifier: "test-1", + budget: { + type: "usage", + limit: 100, + }, + }, + application_method: { + target_type: "items", + type: "fixed", + allocation: "each", + value: "100", + max_quantity: 100, + target_rules: [ + { + attribute: "test.test", + operator: "eq", + values: ["test1", "test2"], + }, + ], + }, + rules: [ + { + attribute: "test.test", + operator: "eq", + values: ["test1", "test2"], + }, + ], + }, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.promotion).toEqual( + expect.objectContaining({ + id: expect.any(String), + code: "TEST", + type: "standard", + is_automatic: true, + campaign: expect.objectContaining({ + name: "test", + campaign_identifier: "test-1", + budget: expect.objectContaining({ + type: "usage", + limit: 100, + }), + }), + application_method: expect.objectContaining({ + value: 100, + max_quantity: 100, + type: "fixed", + target_type: "items", + allocation: "each", + target_rules: [ + expect.objectContaining({ + operator: "eq", + attribute: "test.test", + values: expect.arrayContaining([ + expect.objectContaining({ value: "test1" }), + expect.objectContaining({ value: "test2" }), + ]), + }), + ], + }), + rules: [ + expect.objectContaining({ + operator: "eq", + attribute: "test.test", + values: expect.arrayContaining([ + expect.objectContaining({ value: "test1" }), + expect.objectContaining({ value: "test2" }), + ]), + }), + ], + }) + ) + }) +}) diff --git a/integration-tests/plugins/__tests__/promotion/admin/list-campaigns.spec.ts b/integration-tests/plugins/__tests__/promotion/admin/list-campaigns.spec.ts index 65099ab551..2784d3c0e3 100644 --- a/integration-tests/plugins/__tests__/promotion/admin/list-campaigns.spec.ts +++ b/integration-tests/plugins/__tests__/promotion/admin/list-campaigns.spec.ts @@ -141,25 +141,27 @@ describe("GET /admin/campaigns", () => { expect(response.status).toEqual(200) expect(response.data.count).toEqual(2) - expect(response.data.campaigns).toEqual([ - { - id: expect.any(String), - name: "campaign 1", - created_at: expect.any(String), - budget: { + expect(response.data.campaigns).toEqual( + expect.arrayContaining([ + { id: expect.any(String), - campaign: expect.any(Object), + name: "campaign 1", + created_at: expect.any(String), + budget: { + id: expect.any(String), + campaign: expect.any(Object), + }, }, - }, - { - id: expect.any(String), - name: "campaign 2", - created_at: expect.any(String), - budget: { + { id: expect.any(String), - campaign: expect.any(Object), + name: "campaign 2", + created_at: expect.any(String), + budget: { + id: expect.any(String), + campaign: expect.any(Object), + }, }, - }, - ]) + ]) + ) }) }) diff --git a/integration-tests/plugins/__tests__/promotion/admin/update-campaign.spec.ts b/integration-tests/plugins/__tests__/promotion/admin/update-campaign.spec.ts new file mode 100644 index 0000000000..f0ae59c7f2 --- /dev/null +++ b/integration-tests/plugins/__tests__/promotion/admin/update-campaign.spec.ts @@ -0,0 +1,115 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { IPromotionModuleService } from "@medusajs/types" +import path from "path" +import { startBootstrapApp } from "../../../../environment-helpers/bootstrap-app" +import { useApi } from "../../../../environment-helpers/use-api" +import { getContainer } from "../../../../environment-helpers/use-container" +import { initDb, useDb } from "../../../../environment-helpers/use-db" +import adminSeeder from "../../../../helpers/admin-seeder" + +const env = { MEDUSA_FF_MEDUSA_V2: true } +const adminHeaders = { + headers: { "x-medusa-access-token": "test_token" }, +} + +describe("POST /admin/campaigns/:id", () => { + let dbConnection + let appContainer + let shutdownServer + let promotionModuleService: IPromotionModuleService + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..", "..")) + dbConnection = await initDb({ cwd, env } as any) + shutdownServer = await startBootstrapApp({ cwd, env }) + appContainer = getContainer() + promotionModuleService = appContainer.resolve( + ModuleRegistrationName.PROMOTION + ) + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + await shutdownServer() + }) + + beforeEach(async () => { + await adminSeeder(dbConnection) + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("should throw an error if id does not exist", async () => { + const api = useApi() as any + const { response } = await api + .post(`/admin/campaigns/does-not-exist`, {}, adminHeaders) + .catch((e) => e) + + expect(response.status).toEqual(404) + expect(response.data.message).toEqual( + `Campaign with id "does-not-exist" not found` + ) + }) + + it("should update a campaign successfully", async () => { + const createdPromotion = await promotionModuleService.create({ + code: "TEST", + type: "standard", + }) + + const createdPromotion2 = await promotionModuleService.create({ + code: "TEST_2", + type: "standard", + }) + + const createdCampaign = await promotionModuleService.createCampaigns({ + name: "test", + campaign_identifier: "test", + starts_at: new Date("01/01/2024").toISOString(), + ends_at: new Date("01/01/2029").toISOString(), + promotions: [{ id: createdPromotion.id }], + budget: { + limit: 1000, + type: "usage", + used: 10, + }, + }) + + const api = useApi() as any + const response = await api.post( + `/admin/campaigns/${createdCampaign.id}`, + { + name: "test-2", + campaign_identifier: "test-2", + budget: { + limit: 2000, + }, + promotions: [{ id: createdPromotion2.id }], + }, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.campaign).toEqual( + expect.objectContaining({ + id: expect.any(String), + name: "test-2", + campaign_identifier: "test-2", + budget: expect.objectContaining({ + limit: 2000, + type: "usage", + used: 10, + }), + promotions: [ + expect.objectContaining({ + id: createdPromotion2.id, + }), + ], + }) + ) + }) +}) diff --git a/integration-tests/plugins/__tests__/promotion/admin/update-promotion.spec.ts b/integration-tests/plugins/__tests__/promotion/admin/update-promotion.spec.ts new file mode 100644 index 0000000000..85f701a1be --- /dev/null +++ b/integration-tests/plugins/__tests__/promotion/admin/update-promotion.spec.ts @@ -0,0 +1,135 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { IPromotionModuleService } from "@medusajs/types" +import { PromotionType } from "@medusajs/utils" +import path from "path" +import { startBootstrapApp } from "../../../../environment-helpers/bootstrap-app" +import { useApi } from "../../../../environment-helpers/use-api" +import { getContainer } from "../../../../environment-helpers/use-container" +import { initDb, useDb } from "../../../../environment-helpers/use-db" +import adminSeeder from "../../../../helpers/admin-seeder" + +const env = { MEDUSA_FF_MEDUSA_V2: true } +const adminHeaders = { + headers: { "x-medusa-access-token": "test_token" }, +} + +describe("POST /admin/promotions/:id", () => { + let dbConnection + let appContainer + let shutdownServer + let promotionModuleService: IPromotionModuleService + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..", "..")) + dbConnection = await initDb({ cwd, env } as any) + shutdownServer = await startBootstrapApp({ cwd, env }) + appContainer = getContainer() + promotionModuleService = appContainer.resolve( + ModuleRegistrationName.PROMOTION + ) + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + await shutdownServer() + }) + + beforeEach(async () => { + await adminSeeder(dbConnection) + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("should throw an error if id does not exist", async () => { + const api = useApi() as any + const { response } = await api + .post( + `/admin/promotions/does-not-exist`, + { type: PromotionType.STANDARD }, + adminHeaders + ) + .catch((e) => e) + + expect(response.status).toEqual(404) + expect(response.data.message).toEqual( + `Promotion with id "does-not-exist" not found` + ) + }) + + it("should throw an error when both campaign and campaign_id params are passed", async () => { + const createdPromotion = await promotionModuleService.create({ + code: "TEST", + type: PromotionType.STANDARD, + is_automatic: true, + application_method: { + target_type: "items", + type: "fixed", + allocation: "each", + value: "100", + max_quantity: 100, + }, + }) + + const api = useApi() as any + + const { response } = await api + .post( + `/admin/promotions/${createdPromotion.id}`, + { + campaign: { + name: "test campaign", + }, + campaign_id: "test", + }, + adminHeaders + ) + .catch((e) => e) + + expect(response.status).toEqual(400) + expect(response.data.message).toContain( + `Failed XOR relation between "campaign_id" and "campaign"` + ) + }) + + it("should update a promotion successfully", async () => { + const createdPromotion = await promotionModuleService.create({ + code: "TEST", + type: PromotionType.STANDARD, + is_automatic: true, + application_method: { + target_type: "items", + type: "fixed", + allocation: "each", + value: "100", + max_quantity: 100, + }, + }) + + const api = useApi() as any + const response = await api.post( + `/admin/promotions/${createdPromotion.id}`, + { + code: "TEST_TWO", + application_method: { + value: "200", + }, + }, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.promotion).toEqual( + expect.objectContaining({ + id: expect.any(String), + code: "TEST_TWO", + application_method: expect.objectContaining({ + value: 200, + }), + }) + ) + }) +}) diff --git a/packages/core-flows/src/definition/index.ts b/packages/core-flows/src/definition/index.ts index 3e234743cd..2eef7cc60d 100644 --- a/packages/core-flows/src/definition/index.ts +++ b/packages/core-flows/src/definition/index.ts @@ -1,4 +1,5 @@ export * from "./cart" -export * from "./product" export * from "./inventory" export * from "./price-list" +export * from "./product" +export * from "./promotion" diff --git a/packages/core-flows/src/definition/promotion/create-campaigns.ts b/packages/core-flows/src/definition/promotion/create-campaigns.ts new file mode 100644 index 0000000000..af5bc0cd81 --- /dev/null +++ b/packages/core-flows/src/definition/promotion/create-campaigns.ts @@ -0,0 +1,13 @@ +import { CampaignDTO, CreateCampaignDTO } from "@medusajs/types" +import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk" +import { createCampaignsStep } from "../../handlers/promotion" + +type WorkflowInput = { campaignsData: CreateCampaignDTO[] } + +export const createCampaignsWorkflowId = "create-campaigns" +export const createCampaignsWorkflow = createWorkflow( + createCampaignsWorkflowId, + (input: WorkflowData): WorkflowData => { + return createCampaignsStep(input.campaignsData) + } +) diff --git a/packages/core-flows/src/definition/promotion/create-promotions.ts b/packages/core-flows/src/definition/promotion/create-promotions.ts new file mode 100644 index 0000000000..68deb30e73 --- /dev/null +++ b/packages/core-flows/src/definition/promotion/create-promotions.ts @@ -0,0 +1,14 @@ +import { CreatePromotionDTO, PromotionDTO } from "@medusajs/types" +import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk" +import { createPromotionsStep } from "../../handlers/promotion" + +type WorkflowInput = { promotionsData: CreatePromotionDTO[] } +type WorkflowOutput = PromotionDTO[] + +export const createPromotionsWorkflowId = "create-promotions" +export const createPromotionsWorkflow = createWorkflow( + createPromotionsWorkflowId, + (input: WorkflowData): WorkflowData => { + return createPromotionsStep(input.promotionsData) + } +) diff --git a/packages/core-flows/src/definition/promotion/index.ts b/packages/core-flows/src/definition/promotion/index.ts new file mode 100644 index 0000000000..92e4c5a638 --- /dev/null +++ b/packages/core-flows/src/definition/promotion/index.ts @@ -0,0 +1,4 @@ +export * from "./create-campaigns" +export * from "./create-promotions" +export * from "./update-campaigns" +export * from "./update-promotions" diff --git a/packages/core-flows/src/definition/promotion/update-campaigns.ts b/packages/core-flows/src/definition/promotion/update-campaigns.ts new file mode 100644 index 0000000000..d517c1f080 --- /dev/null +++ b/packages/core-flows/src/definition/promotion/update-campaigns.ts @@ -0,0 +1,13 @@ +import { CampaignDTO, UpdateCampaignDTO } from "@medusajs/types" +import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk" +import { updateCampaignsStep } from "../../handlers/promotion" + +type WorkflowInput = { campaignsData: UpdateCampaignDTO[] } + +export const updateCampaignsWorkflowId = "update-campaigns" +export const updateCampaignsWorkflow = createWorkflow( + updateCampaignsWorkflowId, + (input: WorkflowData): WorkflowData => { + return updateCampaignsStep(input.campaignsData) + } +) diff --git a/packages/core-flows/src/definition/promotion/update-promotions.ts b/packages/core-flows/src/definition/promotion/update-promotions.ts new file mode 100644 index 0000000000..157c3ca81e --- /dev/null +++ b/packages/core-flows/src/definition/promotion/update-promotions.ts @@ -0,0 +1,13 @@ +import { PromotionDTO, UpdatePromotionDTO } from "@medusajs/types" +import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk" +import { updatePromotionsStep } from "../../handlers/promotion" + +type WorkflowInput = { promotionsData: UpdatePromotionDTO[] } + +export const updatePromotionsWorkflowId = "update-promotions" +export const updatePromotionsWorkflow = createWorkflow( + updatePromotionsWorkflowId, + (input: WorkflowData): WorkflowData => { + return updatePromotionsStep(input.promotionsData) + } +) diff --git a/packages/core-flows/src/handlers/promotion/create-campaigns.ts b/packages/core-flows/src/handlers/promotion/create-campaigns.ts new file mode 100644 index 0000000000..4a9aec2d4b --- /dev/null +++ b/packages/core-flows/src/handlers/promotion/create-campaigns.ts @@ -0,0 +1,31 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { CreateCampaignDTO, IPromotionModuleService } from "@medusajs/types" +import { StepResponse, createStep } from "@medusajs/workflows-sdk" + +export const createCampaignsStepId = "create-campaigns" +export const createCampaignsStep = createStep( + createCampaignsStepId, + async (data: CreateCampaignDTO[], { container }) => { + const promotionModule = container.resolve( + ModuleRegistrationName.PROMOTION + ) + + const createdCampaigns = await promotionModule.createCampaigns(data) + + return new StepResponse( + createdCampaigns, + createdCampaigns.map((createdCampaigns) => createdCampaigns.id) + ) + }, + async (createdCampaignIds, { container }) => { + if (!createdCampaignIds?.length) { + return + } + + const promotionModule = container.resolve( + ModuleRegistrationName.PROMOTION + ) + + await promotionModule.delete(createdCampaignIds) + } +) diff --git a/packages/core-flows/src/handlers/promotion/create-promotions.ts b/packages/core-flows/src/handlers/promotion/create-promotions.ts new file mode 100644 index 0000000000..8a735b74b9 --- /dev/null +++ b/packages/core-flows/src/handlers/promotion/create-promotions.ts @@ -0,0 +1,31 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { CreatePromotionDTO, IPromotionModuleService } from "@medusajs/types" +import { StepResponse, createStep } from "@medusajs/workflows-sdk" + +export const createPromotionsStepId = "create-promotions" +export const createPromotionsStep = createStep( + createPromotionsStepId, + async (data: CreatePromotionDTO[], { container }) => { + const promotionModule = container.resolve( + ModuleRegistrationName.PROMOTION + ) + + const createdPromotions = await promotionModule.create(data) + + return new StepResponse( + createdPromotions, + createdPromotions.map((createdPromotions) => createdPromotions.id) + ) + }, + async (createdPromotionIds, { container }) => { + if (!createdPromotionIds?.length) { + return + } + + const promotionModule = container.resolve( + ModuleRegistrationName.PROMOTION + ) + + await promotionModule.delete(createdPromotionIds) + } +) diff --git a/packages/core-flows/src/handlers/promotion/index.ts b/packages/core-flows/src/handlers/promotion/index.ts new file mode 100644 index 0000000000..92e4c5a638 --- /dev/null +++ b/packages/core-flows/src/handlers/promotion/index.ts @@ -0,0 +1,4 @@ +export * from "./create-campaigns" +export * from "./create-promotions" +export * from "./update-campaigns" +export * from "./update-promotions" diff --git a/packages/core-flows/src/handlers/promotion/update-campaigns.ts b/packages/core-flows/src/handlers/promotion/update-campaigns.ts new file mode 100644 index 0000000000..85a03ab857 --- /dev/null +++ b/packages/core-flows/src/handlers/promotion/update-campaigns.ts @@ -0,0 +1,37 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { IPromotionModuleService, UpdateCampaignDTO } from "@medusajs/types" +import { getSelectsAndRelationsFromObjectArray } from "@medusajs/utils" +import { StepResponse, createStep } from "@medusajs/workflows-sdk" + +export const updateCampaignsStepId = "update-campaigns" +export const updateCampaignsStep = createStep( + updateCampaignsStepId, + async (data: UpdateCampaignDTO[], { container }) => { + const promotionModule = container.resolve( + ModuleRegistrationName.PROMOTION + ) + + const { selects, relations } = getSelectsAndRelationsFromObjectArray(data) + const dataBeforeUpdate = await promotionModule.listCampaigns( + { id: data.map((d) => d.id) }, + { relations, select: selects } + ) + + const updatedCampaigns = await promotionModule.updateCampaigns(data) + + return new StepResponse(updatedCampaigns, dataBeforeUpdate) + }, + async (dataBeforeUpdate, { container }) => { + if (!dataBeforeUpdate) { + return + } + + const promotionModule = container.resolve( + ModuleRegistrationName.PROMOTION + ) + + // TODO: This still requires some sanitation of data and transformation of + // shapes for manytomany and oneToMany relations. Create a common util. + await promotionModule.updateCampaigns(dataBeforeUpdate) + } +) diff --git a/packages/core-flows/src/handlers/promotion/update-promotions.ts b/packages/core-flows/src/handlers/promotion/update-promotions.ts new file mode 100644 index 0000000000..e2f482da84 --- /dev/null +++ b/packages/core-flows/src/handlers/promotion/update-promotions.ts @@ -0,0 +1,37 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { IPromotionModuleService, UpdatePromotionDTO } from "@medusajs/types" +import { getSelectsAndRelationsFromObjectArray } from "@medusajs/utils" +import { StepResponse, createStep } from "@medusajs/workflows-sdk" + +export const updatePromotionsStepId = "update-promotions" +export const updatePromotionsStep = createStep( + updatePromotionsStepId, + async (data: UpdatePromotionDTO[], { container }) => { + const promotionModule = container.resolve( + ModuleRegistrationName.PROMOTION + ) + + const { selects, relations } = getSelectsAndRelationsFromObjectArray(data) + const dataBeforeUpdate = await promotionModule.list( + { id: data.map((d) => d.id) }, + { relations, select: selects } + ) + + const updatedPromotions = await promotionModule.update(data) + + return new StepResponse(updatedPromotions, dataBeforeUpdate) + }, + async (dataBeforeUpdate, { container }) => { + if (!dataBeforeUpdate) { + return + } + + const promotionModule = container.resolve( + ModuleRegistrationName.PROMOTION + ) + + // TODO: This still requires some sanitation of data and transformation of + // shapes for manytomany and oneToMany relations. Create a common util. + await promotionModule.update(dataBeforeUpdate) + } +) diff --git a/packages/medusa/src/api-v2/admin/campaigns/[id]/route.ts b/packages/medusa/src/api-v2/admin/campaigns/[id]/route.ts index 8dd8c03983..5515a0f8e3 100644 --- a/packages/medusa/src/api-v2/admin/campaigns/[id]/route.ts +++ b/packages/medusa/src/api-v2/admin/campaigns/[id]/route.ts @@ -1,3 +1,4 @@ +import { updateCampaignsWorkflow } from "@medusajs/core-flows" import { ModuleRegistrationName } from "@medusajs/modules-sdk" import { IPromotionModuleService } from "@medusajs/types" import { MedusaRequest, MedusaResponse } from "../../../../types/routing" @@ -17,3 +18,24 @@ export const GET = async (req: MedusaRequest, res: MedusaResponse) => { res.status(200).json({ campaign }) } + +export const POST = async (req: MedusaRequest, res: MedusaResponse) => { + const updateCampaigns = updateCampaignsWorkflow(req.scope) + const campaignsData = [ + { + id: req.params.id, + ...(req.validatedBody || {}), + }, + ] + + const { result, errors } = await updateCampaigns.run({ + input: { campaignsData }, + throwOnError: false, + }) + + if (Array.isArray(errors) && errors[0]) { + throw errors[0].error + } + + res.status(200).json({ campaign: result[0] }) +} diff --git a/packages/medusa/src/api-v2/admin/campaigns/middlewares.ts b/packages/medusa/src/api-v2/admin/campaigns/middlewares.ts index dc2d679ec1..45febafdd7 100644 --- a/packages/medusa/src/api-v2/admin/campaigns/middlewares.ts +++ b/packages/medusa/src/api-v2/admin/campaigns/middlewares.ts @@ -1,10 +1,16 @@ import { MedusaV2Flag } from "@medusajs/utils" -import { isFeatureFlagEnabled, transformQuery } from "../../../api/middlewares" +import { + isFeatureFlagEnabled, + transformBody, + transformQuery, +} from "../../../api/middlewares" import { MiddlewareRoute } from "../../../loaders/helpers/routing/types" import * as QueryConfig from "./query-config" import { AdminGetCampaignsCampaignParams, AdminGetCampaignsParams, + AdminPostCampaignsCampaignReq, + AdminPostCampaignsReq, } from "./validators" export const adminCampaignRoutesMiddlewares: MiddlewareRoute[] = [ @@ -22,6 +28,11 @@ export const adminCampaignRoutesMiddlewares: MiddlewareRoute[] = [ ), ], }, + { + method: ["POST"], + matcher: "/admin/campaigns", + middlewares: [transformBody(AdminPostCampaignsReq)], + }, { method: ["GET"], matcher: "/admin/campaigns/:id", @@ -32,4 +43,9 @@ export const adminCampaignRoutesMiddlewares: MiddlewareRoute[] = [ ), ], }, + { + method: ["POST"], + matcher: "/admin/campaigns/:id", + middlewares: [transformBody(AdminPostCampaignsCampaignReq)], + }, ] diff --git a/packages/medusa/src/api-v2/admin/campaigns/route.ts b/packages/medusa/src/api-v2/admin/campaigns/route.ts index 35fbea1e14..9267425599 100644 --- a/packages/medusa/src/api-v2/admin/campaigns/route.ts +++ b/packages/medusa/src/api-v2/admin/campaigns/route.ts @@ -1,5 +1,6 @@ +import { createCampaignsWorkflow } from "@medusajs/core-flows" import { ModuleRegistrationName } from "@medusajs/modules-sdk" -import { IPromotionModuleService } from "@medusajs/types" +import { CreateCampaignDTO, IPromotionModuleService } from "@medusajs/types" import { MedusaRequest, MedusaResponse } from "../../../types/routing" export const GET = async (req: MedusaRequest, res: MedusaResponse) => { @@ -21,3 +22,19 @@ export const GET = async (req: MedusaRequest, res: MedusaResponse) => { limit, }) } + +export const POST = async (req: MedusaRequest, res: MedusaResponse) => { + const createCampaigns = createCampaignsWorkflow(req.scope) + const campaignsData = [req.validatedBody as CreateCampaignDTO] + + const { result, errors } = await createCampaigns.run({ + input: { campaignsData }, + throwOnError: false, + }) + + if (Array.isArray(errors) && errors[0]) { + throw errors[0].error + } + + res.status(200).json({ campaign: result[0] }) +} diff --git a/packages/medusa/src/api-v2/admin/campaigns/validators.ts b/packages/medusa/src/api-v2/admin/campaigns/validators.ts index cdd0676571..d4ab0d1fef 100644 --- a/packages/medusa/src/api-v2/admin/campaigns/validators.ts +++ b/packages/medusa/src/api-v2/admin/campaigns/validators.ts @@ -1,4 +1,15 @@ -import { IsOptional, IsString } from "class-validator" +import { CampaignBudgetType } from "@medusajs/utils" +import { Type } from "class-transformer" +import { + IsArray, + IsDateString, + IsEnum, + IsNotEmpty, + IsNumber, + IsOptional, + IsString, + ValidateNested, +} from "class-validator" import { FindParams, extendedFindParamsMixin } from "../../../types/common" export class AdminGetCampaignsCampaignParams extends FindParams {} @@ -15,3 +26,93 @@ export class AdminGetCampaignsParams extends extendedFindParamsMixin({ @IsOptional() currency?: string } + +export class AdminPostCampaignsReq { + @IsNotEmpty() + @IsString() + name: string + + @IsOptional() + @IsNotEmpty() + campaign_identifier?: string + + @IsOptional() + @IsString() + description?: string + + @IsOptional() + @IsString() + currency?: string + + @IsOptional() + @ValidateNested() + @Type(() => CampaignBudget) + budget?: CampaignBudget + + @IsOptional() + @IsDateString() + starts_at?: string + + @IsOptional() + @IsDateString() + ends_at?: string + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => IdObject) + promotions?: IdObject[] +} + +export class IdObject { + @IsString() + @IsNotEmpty() + id: string +} + +export class CampaignBudget { + @IsOptional() + @IsEnum(CampaignBudgetType) + type?: CampaignBudgetType + + @IsOptional() + @IsNumber() + limit?: number +} + +export class AdminPostCampaignsCampaignReq { + @IsOptional() + @IsString() + name?: string + + @IsOptional() + @IsNotEmpty() + campaign_identifier?: string + + @IsOptional() + @IsString() + description?: string + + @IsOptional() + @IsString() + currency?: string + + @IsOptional() + @ValidateNested() + @Type(() => CampaignBudget) + budget?: CampaignBudget + + @IsOptional() + @IsDateString() + starts_at?: string + + @IsOptional() + @IsDateString() + ends_at?: string + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => IdObject) + promotions?: IdObject[] +} 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 8cdc90e91f..388d697ea5 100644 --- a/packages/medusa/src/api-v2/admin/promotions/[id]/route.ts +++ b/packages/medusa/src/api-v2/admin/promotions/[id]/route.ts @@ -1,3 +1,4 @@ +import { updatePromotionsWorkflow } from "@medusajs/core-flows" import { ModuleRegistrationName } from "@medusajs/modules-sdk" import { IPromotionModuleService } from "@medusajs/types" import { MedusaRequest, MedusaResponse } from "../../../../types/routing" @@ -14,3 +15,24 @@ export const GET = async (req: MedusaRequest, res: MedusaResponse) => { res.status(200).json({ promotion }) } + +export const POST = async (req: MedusaRequest, res: MedusaResponse) => { + const updatePromotions = updatePromotionsWorkflow(req.scope) + const promotionsData = [ + { + id: req.params.id, + ...(req.validatedBody || {}), + }, + ] + + const { result, errors } = await updatePromotions.run({ + input: { promotionsData }, + throwOnError: false, + }) + + if (Array.isArray(errors) && errors[0]) { + throw errors[0].error + } + + res.status(200).json({ promotion: result[0] }) +} diff --git a/packages/medusa/src/api-v2/admin/promotions/middlewares.ts b/packages/medusa/src/api-v2/admin/promotions/middlewares.ts index b69c7705fb..cf27214e9d 100644 --- a/packages/medusa/src/api-v2/admin/promotions/middlewares.ts +++ b/packages/medusa/src/api-v2/admin/promotions/middlewares.ts @@ -1,10 +1,17 @@ import { MedusaV2Flag } from "@medusajs/utils" -import { isFeatureFlagEnabled, transformQuery } from "../../../api/middlewares" + +import { + isFeatureFlagEnabled, + transformBody, + transformQuery, +} from "../../../api/middlewares" import { MiddlewareRoute } from "../../../loaders/helpers/routing/types" import * as QueryConfig from "./query-config" import { AdminGetPromotionsParams, AdminGetPromotionsPromotionParams, + AdminPostPromotionsPromotionReq, + AdminPostPromotionsReq, } from "./validators" export const adminPromotionRoutesMiddlewares: MiddlewareRoute[] = [ @@ -22,6 +29,11 @@ export const adminPromotionRoutesMiddlewares: MiddlewareRoute[] = [ ), ], }, + { + method: ["POST"], + matcher: "/admin/promotions", + middlewares: [transformBody(AdminPostPromotionsReq)], + }, { method: ["GET"], matcher: "/admin/promotions/:id", @@ -32,4 +44,9 @@ export const adminPromotionRoutesMiddlewares: MiddlewareRoute[] = [ ), ], }, + { + method: ["POST"], + matcher: "/admin/promotions/:id", + middlewares: [transformBody(AdminPostPromotionsPromotionReq)], + }, ] diff --git a/packages/medusa/src/api-v2/admin/promotions/route.ts b/packages/medusa/src/api-v2/admin/promotions/route.ts index ee642be0df..70477ebbe2 100644 --- a/packages/medusa/src/api-v2/admin/promotions/route.ts +++ b/packages/medusa/src/api-v2/admin/promotions/route.ts @@ -1,5 +1,6 @@ +import { createPromotionsWorkflow } from "@medusajs/core-flows" import { ModuleRegistrationName } from "@medusajs/modules-sdk" -import { IPromotionModuleService } from "@medusajs/types" +import { CreatePromotionDTO, IPromotionModuleService } from "@medusajs/types" import { MedusaRequest, MedusaResponse } from "../../../types/routing" export const GET = async (req: MedusaRequest, res: MedusaResponse) => { @@ -21,3 +22,19 @@ export const GET = async (req: MedusaRequest, res: MedusaResponse) => { limit, }) } + +export const POST = async (req: MedusaRequest, res: MedusaResponse) => { + const createPromotions = createPromotionsWorkflow(req.scope) + const promotionsData = [req.validatedBody as CreatePromotionDTO] + + const { result, errors } = await createPromotions.run({ + input: { promotionsData }, + throwOnError: false, + }) + + if (Array.isArray(errors) && errors[0]) { + throw errors[0].error + } + + res.status(200).json({ promotion: result[0] }) +} diff --git a/packages/medusa/src/api-v2/admin/promotions/validators.ts b/packages/medusa/src/api-v2/admin/promotions/validators.ts index 20068f61c8..ff161dc215 100644 --- a/packages/medusa/src/api-v2/admin/promotions/validators.ts +++ b/packages/medusa/src/api-v2/admin/promotions/validators.ts @@ -1,5 +1,25 @@ -import { IsOptional, IsString } from "class-validator" +import { + ApplicationMethodAllocation, + ApplicationMethodTargetType, + ApplicationMethodType, + PromotionRuleOperator, + PromotionType, +} from "@medusajs/utils" +import { Type } from "class-transformer" +import { + IsArray, + IsBoolean, + IsEnum, + IsNotEmpty, + IsNumber, + IsOptional, + IsString, + Validate, + ValidateNested, +} from "class-validator" import { FindParams, extendedFindParamsMixin } from "../../../types/common" +import { XorConstraint } from "../../../types/validators/xor" +import { AdminPostCampaignsReq } from "../campaigns/validators" export class AdminGetPromotionsPromotionParams extends FindParams {} @@ -11,3 +31,122 @@ export class AdminGetPromotionsParams extends extendedFindParamsMixin({ @IsOptional() code?: string } + +export class AdminPostPromotionsReq { + @IsNotEmpty() + @IsString() + code: string + + @IsBoolean() + @IsOptional() + is_automatic?: boolean + + @IsOptional() + @IsEnum(PromotionType) + type?: PromotionType + + @IsOptional() + @IsString() + campaign_id?: string + + @IsOptional() + @ValidateNested() + @Type(() => AdminPostCampaignsReq) + campaign?: AdminPostCampaignsReq + + @IsNotEmpty() + @ValidateNested() + @Type(() => ApplicationMethod) + application_method: ApplicationMethod + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => PromotionRule) + rules?: PromotionRule[] +} + +export class PromotionRule { + @IsEnum(PromotionRuleOperator) + operator: PromotionRuleOperator + + @IsOptional() + @IsString() + description?: string | null + + @IsNotEmpty() + @IsString() + attribute: string + + @IsArray() + @Type(() => String) + values: string[] +} + +export class ApplicationMethod { + @IsOptional() + @IsString() + description?: string + + @IsOptional() + @IsString() + value?: string + + @IsOptional() + @IsNumber() + max_quantity?: number + + @IsOptional() + @IsEnum(ApplicationMethodType) + type?: ApplicationMethodType + + @IsOptional() + @IsEnum(ApplicationMethodTargetType) + target_type?: ApplicationMethodTargetType + + @IsOptional() + @IsEnum(ApplicationMethodAllocation) + allocation?: ApplicationMethodAllocation + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => PromotionRule) + target_rules?: PromotionRule[] +} + +export class AdminPostPromotionsPromotionReq { + @IsOptional() + @IsString() + code?: string + + @IsOptional() + @IsBoolean() + is_automatic?: boolean + + @IsOptional() + @IsEnum(PromotionType) + type?: PromotionType + + @IsOptional() + @Validate(XorConstraint, ["campaign"]) + @IsString() + campaign_id?: string + + @IsOptional() + @Validate(XorConstraint, ["campaign_id"]) + @ValidateNested() + @Type(() => AdminPostCampaignsReq) + campaign?: AdminPostCampaignsReq + + @IsOptional() + @ValidateNested() + @Type(() => ApplicationMethod) + application_method?: ApplicationMethod + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => PromotionRule) + rules?: PromotionRule[] +} diff --git a/packages/medusa/src/api/routes/admin/product-categories/update-product-category.ts b/packages/medusa/src/api/routes/admin/product-categories/update-product-category.ts index fcd8efce0c..ed1e651bd9 100644 --- a/packages/medusa/src/api/routes/admin/product-categories/update-product-category.ts +++ b/packages/medusa/src/api/routes/admin/product-categories/update-product-category.ts @@ -1,17 +1,17 @@ import { - IsOptional, - IsString, IsInt, - Min, IsNotEmpty, IsObject, + IsOptional, + IsString, + Min, } from "class-validator" import { Request, Response } from "express" import { EntityManager } from "typeorm" import { ProductCategoryService } from "../../../../services" -import { AdminProductCategoriesReqBase } from "../../../../types/product-category" import { FindParams } from "../../../../types/common" +import { AdminProductCategoriesReqBase } from "../../../../types/product-category" /** * @oas [post] /admin/product-categories/{id} diff --git a/packages/promotion/src/services/promotion-module.ts b/packages/promotion/src/services/promotion-module.ts index 56bae63e88..bbdbc5fc10 100644 --- a/packages/promotion/src/services/promotion-module.ts +++ b/packages/promotion/src/services/promotion-module.ts @@ -441,6 +441,7 @@ export default class PromotionModuleService< "rules", "rules.values", "campaign", + "campaign.budget", ], }, sharedContext @@ -558,7 +559,7 @@ export default class PromotionModuleService< } } - await this.createPromotionRulesAndValues( + await this.createPromotionRulesAndValues_( promotionCodeRulesDataMap.get(promotion.code) || [], "promotions", promotion, @@ -577,7 +578,7 @@ export default class PromotionModuleService< } for (const applicationMethod of createdApplicationMethods) { - await this.createPromotionRulesAndValues( + await this.createPromotionRulesAndValues_( applicationMethodRuleMap.get(applicationMethod.promotion.id) || [], "application_methods", applicationMethod, @@ -614,9 +615,11 @@ export default class PromotionModuleService< relations: [ "application_method", "application_method.target_rules", + "application_method.target_rules.values", "rules", "rules.values", "campaign", + "campaign.budget", ], }, sharedContext @@ -686,7 +689,10 @@ export default class PromotionModuleService< existingApplicationMethod.max_quantity, }) - applicationMethodsData.push(applicationMethodData) + applicationMethodsData.push({ + ...applicationMethodData, + id: existingApplicationMethod.id, + }) } const updatedPromotions = this.promotionService_.update( @@ -705,7 +711,6 @@ export default class PromotionModuleService< } @InjectManager("baseRepository_") - @InjectTransactionManager("baseRepository_") async addPromotionRules( promotionId: string, rulesData: PromotionTypes.CreatePromotionRuleDTO[], @@ -713,20 +718,21 @@ export default class PromotionModuleService< ): Promise { const promotion = await this.promotionService_.retrieve(promotionId) - await this.createPromotionRulesAndValues( + await this.createPromotionRulesAndValues_( rulesData, "promotions", promotion, sharedContext ) - return this.retrieve(promotionId, { - relations: ["rules", "rules.values"], - }) + return this.retrieve( + promotionId, + { relations: ["rules", "rules.values"] }, + sharedContext + ) } @InjectManager("baseRepository_") - @InjectTransactionManager("baseRepository_") async addPromotionTargetRules( promotionId: string, rulesData: PromotionTypes.CreatePromotionRuleDTO[], @@ -745,29 +751,34 @@ export default class PromotionModuleService< ) } - await this.createPromotionRulesAndValues( + await this.createPromotionRulesAndValues_( rulesData, "application_methods", applicationMethod, sharedContext ) - return this.retrieve(promotionId, { - relations: [ - "rules", - "rules.values", - "application_method", - "application_method.target_rules", - "application_method.target_rules.values", - ], - }) + return this.retrieve( + promotionId, + { + relations: [ + "rules", + "rules.values", + "application_method", + "application_method.target_rules", + "application_method.target_rules.values", + ], + }, + sharedContext + ) } - protected async createPromotionRulesAndValues( + @InjectTransactionManager("baseRepository_") + protected async createPromotionRulesAndValues_( rulesData: PromotionTypes.CreatePromotionRuleDTO[], relationName: "promotions" | "application_methods", relation: Promotion | ApplicationMethod, - sharedContext: Context + @MedusaContext() sharedContext: Context = {} ) { validatePromotionRuleAttributes(rulesData) @@ -789,7 +800,10 @@ export default class PromotionModuleService< promotion_rule: createdPromotionRule, })) - await this.promotionRuleValueService_.create(promotionRuleValuesData) + await this.promotionRuleValueService_.create( + promotionRuleValuesData, + sharedContext + ) } } @@ -980,7 +994,7 @@ export default class PromotionModuleService< const campaigns = await this.listCampaigns( { id: createdCampaigns.map((p) => p!.id) }, { - relations: ["budget"], + relations: ["budget", "promotions"], }, sharedContext ) diff --git a/packages/utils/src/common/__tests__/get-selects-and-relations-from-object-array.spec.ts b/packages/utils/src/common/__tests__/get-selects-and-relations-from-object-array.spec.ts new file mode 100644 index 0000000000..e09e546cc3 --- /dev/null +++ b/packages/utils/src/common/__tests__/get-selects-and-relations-from-object-array.spec.ts @@ -0,0 +1,62 @@ +import { getSelectsAndRelationsFromObjectArray } from "../get-selects-and-relations-from-object-array" + +describe("getSelectsAndRelationsFromObjectArray", function () { + it("should return true or false for different types of data", function () { + const expectations = [ + { + input: [ + { + attr_string: "string", + attr_boolean: true, + attr_null: null, + attr_undefined: undefined, + attr_object: { + attr_string: "string", + attr_boolean: true, + attr_null: null, + attr_undefined: undefined, + }, + attr_array: [ + { + attr_object: { + attr_string: "string", + attr_boolean: true, + attr_null: null, + attr_undefined: undefined, + }, + }, + { + attr_object: { + attr_string: "string", + }, + }, + ], + }, + ], + output: { + selects: [ + "attr_string", + "attr_boolean", + "attr_null", + "attr_undefined", + "attr_object.attr_string", + "attr_object.attr_boolean", + "attr_object.attr_null", + "attr_object.attr_undefined", + "attr_array.attr_object.attr_string", + "attr_array.attr_object.attr_boolean", + "attr_array.attr_object.attr_null", + "attr_array.attr_object.attr_undefined", + ], + relations: ["attr_object", "attr_array", "attr_array.attr_object"], + }, + }, + ] + + expectations.forEach((expectation) => { + expect(getSelectsAndRelationsFromObjectArray(expectation.input)).toEqual( + expectation.output + ) + }) + }) +}) diff --git a/packages/utils/src/common/get-selects-and-relations-from-object-array.ts b/packages/utils/src/common/get-selects-and-relations-from-object-array.ts new file mode 100644 index 0000000000..3352ece7df --- /dev/null +++ b/packages/utils/src/common/get-selects-and-relations-from-object-array.ts @@ -0,0 +1,53 @@ +import { deduplicate } from "./deduplicate" +import { isObject } from "./is-object" + +export function getSelectsAndRelationsFromObjectArray( + dataArray: object[], + prefix?: string +): { + selects: string[] + relations: string[] +} { + const selects: string[] = [] + const relations: string[] = [] + + for (const data of dataArray) { + for (const [key, value] of Object.entries(data)) { + if (isObject(value)) { + relations.push(setKey(key, prefix)) + const res = getSelectsAndRelationsFromObjectArray( + [value], + setKey(key, prefix) + ) + selects.push(...res.selects) + relations.push(...res.relations) + } else if (Array.isArray(value)) { + relations.push(setKey(key, prefix)) + const res = getSelectsAndRelationsFromObjectArray( + value, + setKey(key, prefix) + ) + selects.push(...res.selects) + relations.push(...res.relations) + } else { + selects.push(setKey(key, prefix)) + } + } + } + + const uniqueSelects: string[] = deduplicate(selects) + const uniqueRelations: string[] = deduplicate(relations) + + return { + selects: uniqueSelects, + relations: uniqueRelations, + } +} + +function setKey(key: string, prefix?: string) { + if (prefix) { + return `${prefix}.${key}` + } else { + return key + } +} diff --git a/packages/utils/src/common/index.ts b/packages/utils/src/common/index.ts index 45aa19fa51..fab0cae5ba 100644 --- a/packages/utils/src/common/index.ts +++ b/packages/utils/src/common/index.ts @@ -9,6 +9,7 @@ export * from "./errors" export * from "./generate-entity-id" export * from "./get-config-file" export * from "./get-iso-string-from-date" +export * from "./get-selects-and-relations-from-object-array" export * from "./group-by" export * from "./handle-postgres-database-error" export * from "./is-date"