diff --git a/.changeset/silver-lemons-fly.md b/.changeset/silver-lemons-fly.md new file mode 100644 index 0000000000..feb899e43e --- /dev/null +++ b/.changeset/silver-lemons-fly.md @@ -0,0 +1,7 @@ +--- +"@medusajs/core-flows": patch +"@medusajs/types": patch +"@medusajs/medusa": patch +--- + +feat(core-flows,types,medusa): API to add promotions to campaign diff --git a/integration-tests/modules/__tests__/promotion/admin/campaigns.spec.ts b/integration-tests/modules/__tests__/promotion/admin/campaigns.spec.ts new file mode 100644 index 0000000000..10ffcf7c5d --- /dev/null +++ b/integration-tests/modules/__tests__/promotion/admin/campaigns.spec.ts @@ -0,0 +1,600 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { IPromotionModuleService } from "@medusajs/types" +import { CampaignBudgetType, PromotionType } from "@medusajs/utils" +import { medusaIntegrationTestRunner } from "medusa-test-utils" +import { createAdminUser } from "../../../../helpers/create-admin-user" + +jest.setTimeout(50000) + +export const campaignData = { + name: "campaign 1", + description: "test description", + currency: "USD", + campaign_identifier: "test-1", + starts_at: new Date("01/01/2023").toISOString(), + ends_at: new Date("01/01/2024").toISOString(), + budget: { + type: CampaignBudgetType.SPEND, + limit: 1000, + used: 0, + }, +} + +export const campaignsData = [ + { + 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 2", + 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, + }, + }, +] + +const env = { MEDUSA_FF_MEDUSA_V2: true } +const adminHeaders = { + headers: { "x-medusa-access-token": "test_token" }, +} + +medusaIntegrationTestRunner({ + env, + testSuite: ({ dbConnection, getContainer, api }) => { + describe("Admin Campaigns API", () => { + let appContainer + let promotionModuleService: IPromotionModuleService + + beforeAll(async () => { + appContainer = getContainer() + promotionModuleService = appContainer.resolve( + ModuleRegistrationName.PROMOTION + ) + }) + + beforeEach(async () => { + await createAdminUser(dbConnection, adminHeaders, appContainer) + }) + + const generatePromotionData = () => { + const code = Math.random().toString(36).substring(7) + + return { + code, + type: PromotionType.STANDARD, + is_automatic: true, + application_method: { + target_type: "items", + type: "fixed", + allocation: "each", + value: 100, + max_quantity: 100, + target_rules: [], + }, + rules: [], + } + } + + describe("GET /admin/campaigns", () => { + beforeEach(async () => { + await promotionModuleService.createCampaigns(campaignsData) + }) + + it("should get all campaigns and its count", async () => { + const response = await api.get(`/admin/campaigns`, adminHeaders) + + expect(response.status).toEqual(200) + expect(response.data.count).toEqual(2) + expect(response.data.campaigns).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(String), + name: "campaign 1", + description: "test description", + currency: "USD", + campaign_identifier: "test-1", + starts_at: expect.any(String), + ends_at: expect.any(String), + budget: { + id: expect.any(String), + type: "spend", + limit: 1000, + used: 0, + raw_limit: { + precision: 20, + value: "1000", + }, + raw_used: { + precision: 20, + value: "0", + }, + created_at: expect.any(String), + updated_at: expect.any(String), + deleted_at: null, + }, + created_at: expect.any(String), + updated_at: expect.any(String), + deleted_at: null, + }), + expect.objectContaining({ + id: expect.any(String), + name: "campaign 2", + description: "test description", + currency: "USD", + campaign_identifier: "test-2", + starts_at: expect.any(String), + ends_at: expect.any(String), + budget: { + id: expect.any(String), + type: "usage", + limit: 1000, + used: 0, + raw_limit: { + precision: 20, + value: "1000", + }, + raw_used: { + precision: 20, + value: "0", + }, + created_at: expect.any(String), + updated_at: expect.any(String), + deleted_at: null, + }, + created_at: expect.any(String), + updated_at: expect.any(String), + deleted_at: null, + }), + ]) + ) + }) + + it("should support search on campaigns", async () => { + const response = await api.get( + `/admin/campaigns?q=ign%202`, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.campaigns).toEqual([ + expect.objectContaining({ + name: "campaign 2", + }), + ]) + }) + + it("should get all campaigns and its count filtered", async () => { + const response = await api.get( + `/admin/campaigns?fields=name,created_at,budget.id`, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.count).toEqual(2) + expect(response.data.campaigns).toEqual( + expect.arrayContaining([ + { + id: expect.any(String), + name: "campaign 1", + created_at: expect.any(String), + budget: { + id: expect.any(String), + }, + }, + { + id: expect.any(String), + name: "campaign 2", + created_at: expect.any(String), + budget: { + id: expect.any(String), + }, + }, + ]) + ) + }) + }) + + describe("GET /admin/campaigns/:id", () => { + it("should throw an error if id does not exist", async () => { + const { response } = await api + .get(`/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 was not found" + ) + }) + + it("should get the requested campaign", async () => { + const createdCampaign = await promotionModuleService.createCampaigns( + campaignData + ) + + const response = await api.get( + `/admin/campaigns/${createdCampaign.id}`, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.campaign).toEqual({ + id: expect.any(String), + name: "campaign 1", + description: "test description", + currency: "USD", + campaign_identifier: "test-1", + starts_at: expect.any(String), + ends_at: expect.any(String), + budget: { + id: expect.any(String), + type: "spend", + limit: 1000, + raw_limit: { + precision: 20, + value: "1000", + }, + raw_used: { + precision: 20, + value: "0", + }, + used: 0, + created_at: expect.any(String), + updated_at: expect.any(String), + deleted_at: null, + }, + created_at: expect.any(String), + updated_at: expect.any(String), + deleted_at: null, + }) + }) + + it("should get the requested campaign with filtered fields and relations", async () => { + const createdCampaign = await promotionModuleService.createCampaigns( + campaignData + ) + + const response = await api.get( + `/admin/campaigns/${createdCampaign.id}?fields=name`, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.campaign).toEqual({ + id: expect.any(String), + name: "campaign 1", + }) + }) + }) + + describe("POST /admin/campaigns", () => { + it("should throw an error if required params are not passed", async () => { + 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 response = await api.post( + `/admin/campaigns?fields=*promotions`, + { + name: "test", + campaign_identifier: "test", + starts_at: new Date("01/01/2024").toISOString(), + ends_at: new Date("01/01/2029").toISOString(), + 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", + }), + }) + ) + }) + + it("should create 3 campaigns in parallel and have the context passed as argument when calling createCampaigns with different transactionId", async () => { + const parallelPromotion = await promotionModuleService.create({ + code: "PARALLEL", + type: "standard", + }) + + const spyCreateCampaigns = jest.spyOn( + promotionModuleService.constructor.prototype, + "createCampaigns" + ) + + const a = async () => { + return await api.post( + `/admin/campaigns`, + { + name: "camp_1", + campaign_identifier: "camp_1", + starts_at: new Date("01/01/2024").toISOString(), + ends_at: new Date("01/02/2024").toISOString(), + budget: { + limit: 1000, + type: "usage", + }, + }, + adminHeaders + ) + } + + const b = async () => { + return await api.post( + `/admin/campaigns`, + { + name: "camp_2", + campaign_identifier: "camp_2", + starts_at: new Date("01/02/2024").toISOString(), + ends_at: new Date("01/03/2029").toISOString(), + budget: { + limit: 500, + type: "usage", + }, + }, + adminHeaders + ) + } + + const c = async () => { + return await api.post( + `/admin/campaigns`, + { + name: "camp_3", + campaign_identifier: "camp_3", + starts_at: new Date("01/03/2024").toISOString(), + ends_at: new Date("01/04/2029").toISOString(), + budget: { + limit: 250, + type: "usage", + }, + }, + { + headers: { + ...adminHeaders.headers, + "x-request-id": "my-custom-request-id", + }, + } + ) + } + + await Promise.all([a(), b(), c()]) + + expect(spyCreateCampaigns).toHaveBeenCalledTimes(3) + expect(spyCreateCampaigns.mock.calls[0][1].__type).toBe( + "MedusaContext" + ) + + const distinctTransactionId = [ + ...new Set( + spyCreateCampaigns.mock.calls.map((call) => call[1].transactionId) + ), + ] + expect(distinctTransactionId).toHaveLength(3) + + const distinctRequestId = [ + ...new Set( + spyCreateCampaigns.mock.calls.map((call) => call[1].requestId) + ), + ] + + expect(distinctRequestId).toHaveLength(3) + expect(distinctRequestId).toContain("my-custom-request-id") + }) + }) + + describe("POST /admin/campaigns/:id", () => { + it("should throw an error if id does not exist", async () => { + 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 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(), + budget: { + limit: 1000, + type: "usage", + used: 10, + }, + }) + + await promotionModuleService.addPromotionsToCampaign({ + id: createdCampaign.id, + promotion_ids: [createdPromotion.id], + }) + + const response = await api.post( + `/admin/campaigns/${createdCampaign.id}?fields=*promotions`, + { + name: "test-2", + campaign_identifier: "test-2", + budget: { + limit: 2000, + }, + }, + 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: createdPromotion.id, + }), + ], + }) + ) + }) + }) + + describe("DELETE /admin/campaigns/:id", () => { + it("should delete campaign successfully", async () => { + const [createdCampaign] = + await promotionModuleService.createCampaigns([ + { + name: "test", + campaign_identifier: "test", + starts_at: new Date("01/01/2024"), + ends_at: new Date("01/01/2025"), + }, + ]) + + const deleteRes = await api.delete( + `/admin/campaigns/${createdCampaign.id}`, + adminHeaders + ) + + expect(deleteRes.status).toEqual(200) + + const campaigns = await promotionModuleService.listCampaigns({ + id: [createdCampaign.id], + }) + + expect(campaigns.length).toEqual(0) + }) + }) + + describe("POST /admin/campaigns/:id/promotions", () => { + it("should add or remove promotions from campaign", async () => { + const campaign = ( + await api.post(`/admin/campaigns`, campaignData, adminHeaders) + ).data.campaign + + const promotion1 = ( + await api.post( + `/admin/promotions`, + generatePromotionData(), + adminHeaders + ) + ).data.promotion + + const promotion2 = ( + await api.post( + `/admin/promotions`, + generatePromotionData(), + adminHeaders + ) + ).data.promotion + + let response = await api.post( + `/admin/campaigns/${campaign.id}/promotions`, + { add: [promotion1.id, promotion2.id] }, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.campaign).toEqual( + expect.objectContaining({ + id: expect.any(String), + }) + ) + + response = await api.get( + `/admin/promotions?campaign_id=${campaign.id}`, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.promotions).toHaveLength(2) + expect(response.data.promotions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: promotion1.id, + }), + expect.objectContaining({ + id: promotion2.id, + }), + ]) + ) + + await api.post( + `/admin/campaigns/${campaign.id}/promotions`, + { remove: [promotion1.id] }, + adminHeaders + ) + + response = await api.get( + `/admin/promotions?campaign_id=${campaign.id}`, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.promotions).toHaveLength(1) + expect(response.data.promotions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: promotion2.id, + }), + ]) + ) + }) + }) + }) + }, +}) diff --git a/integration-tests/modules/__tests__/promotion/admin/create-campaign.spec.ts b/integration-tests/modules/__tests__/promotion/admin/create-campaign.spec.ts deleted file mode 100644 index a1d5f1a086..0000000000 --- a/integration-tests/modules/__tests__/promotion/admin/create-campaign.spec.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { IPromotionModuleService } from "@medusajs/types" -import { ModuleRegistrationName } from "@medusajs/modules-sdk" -import { createAdminUser } from "../../../../helpers/create-admin-user" -import { medusaIntegrationTestRunner } from "medusa-test-utils" - -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("POST /admin/campaigns", () => { - let appContainer - let promotionModuleService: IPromotionModuleService - - beforeAll(async () => { - appContainer = getContainer() - promotionModuleService = appContainer.resolve( - ModuleRegistrationName.PROMOTION - ) - }) - - beforeEach(async () => { - await createAdminUser(dbConnection, adminHeaders, appContainer) - }) - - it("should throw an error if required params are not passed", async () => { - 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 response = await api.post( - `/admin/campaigns?fields=*promotions`, - { - 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, - }), - ], - }) - ) - }) - - it("should create 3 campaigns in parallel and have the context passed as argument when calling createCampaigns with different transactionId", async () => { - const parallelPromotion = await promotionModuleService.create({ - code: "PARALLEL", - type: "standard", - }) - - const spyCreateCampaigns = jest.spyOn( - promotionModuleService.constructor.prototype, - "createCampaigns" - ) - - const a = async () => { - return await api.post( - `/admin/campaigns`, - { - name: "camp_1", - campaign_identifier: "camp_1", - starts_at: new Date("01/01/2024").toISOString(), - ends_at: new Date("01/02/2024").toISOString(), - promotions: [{ id: parallelPromotion.id }], - budget: { - limit: 1000, - type: "usage", - }, - }, - adminHeaders - ) - } - - const b = async () => { - return await api.post( - `/admin/campaigns`, - { - name: "camp_2", - campaign_identifier: "camp_2", - starts_at: new Date("01/02/2024").toISOString(), - ends_at: new Date("01/03/2029").toISOString(), - promotions: [{ id: parallelPromotion.id }], - budget: { - limit: 500, - type: "usage", - }, - }, - adminHeaders - ) - } - - const c = async () => { - return await api.post( - `/admin/campaigns`, - { - name: "camp_3", - campaign_identifier: "camp_3", - starts_at: new Date("01/03/2024").toISOString(), - ends_at: new Date("01/04/2029").toISOString(), - promotions: [{ id: parallelPromotion.id }], - budget: { - limit: 250, - type: "usage", - }, - }, - { - headers: { - ...adminHeaders.headers, - "x-request-id": "my-custom-request-id", - }, - } - ) - } - - await Promise.all([a(), b(), c()]) - - expect(spyCreateCampaigns).toHaveBeenCalledTimes(3) - expect(spyCreateCampaigns.mock.calls[0][1].__type).toBe("MedusaContext") - - const distinctTransactionId = [ - ...new Set( - spyCreateCampaigns.mock.calls.map((call) => call[1].transactionId) - ), - ] - expect(distinctTransactionId).toHaveLength(3) - - const distinctRequestId = [ - ...new Set( - spyCreateCampaigns.mock.calls.map((call) => call[1].requestId) - ), - ] - - expect(distinctRequestId).toHaveLength(3) - expect(distinctRequestId).toContain("my-custom-request-id") - }) - }) - }, -}) diff --git a/integration-tests/modules/__tests__/promotion/admin/delete-campaign.spec.ts b/integration-tests/modules/__tests__/promotion/admin/delete-campaign.spec.ts deleted file mode 100644 index bc1a65c7ab..0000000000 --- a/integration-tests/modules/__tests__/promotion/admin/delete-campaign.spec.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { IPromotionModuleService } from "@medusajs/types" -import { ModuleRegistrationName } from "@medusajs/modules-sdk" -import { createAdminUser } from "../../../../helpers/create-admin-user" -import { medusaIntegrationTestRunner } from "medusa-test-utils" - -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("DELETE /admin/campaigns/:id", () => { - let appContainer - let promotionModuleService: IPromotionModuleService - - beforeAll(async () => { - appContainer = getContainer() - promotionModuleService = appContainer.resolve( - ModuleRegistrationName.PROMOTION - ) - }) - - beforeEach(async () => { - await createAdminUser(dbConnection, adminHeaders, appContainer) - }) - - it("should delete campaign successfully", async () => { - const [createdCampaign] = await promotionModuleService.createCampaigns([ - { - name: "test", - campaign_identifier: "test", - starts_at: new Date("01/01/2024"), - ends_at: new Date("01/01/2025"), - }, - ]) - - const deleteRes = await api.delete( - `/admin/campaigns/${createdCampaign.id}`, - adminHeaders - ) - - expect(deleteRes.status).toEqual(200) - - const campaigns = await promotionModuleService.listCampaigns({ - id: [createdCampaign.id], - }) - - expect(campaigns.length).toEqual(0) - }) - }) - }, -}) diff --git a/integration-tests/modules/__tests__/promotion/admin/list-campaigns.spec.ts b/integration-tests/modules/__tests__/promotion/admin/list-campaigns.spec.ts deleted file mode 100644 index c1f1eb7c8d..0000000000 --- a/integration-tests/modules/__tests__/promotion/admin/list-campaigns.spec.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { ModuleRegistrationName } from "@medusajs/modules-sdk" -import { IPromotionModuleService } from "@medusajs/types" -import { CampaignBudgetType } from "@medusajs/utils" -import { createAdminUser } from "../../../../helpers/create-admin-user" -import { medusaIntegrationTestRunner } from "medusa-test-utils" - -jest.setTimeout(50000) - -export const campaignsData = [ - { - 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 2", - 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, - }, - }, -] - -const env = { MEDUSA_FF_MEDUSA_V2: true } -const adminHeaders = { - headers: { "x-medusa-access-token": "test_token" }, -} - -medusaIntegrationTestRunner({ - env, - testSuite: ({ dbConnection, getContainer, api }) => { - describe("GET /admin/campaigns", () => { - let appContainer - let promotionModuleService: IPromotionModuleService - - beforeAll(async () => { - appContainer = getContainer() - promotionModuleService = appContainer.resolve( - ModuleRegistrationName.PROMOTION - ) - }) - - beforeEach(async () => { - await createAdminUser(dbConnection, adminHeaders, appContainer) - await promotionModuleService.createCampaigns(campaignsData) - }) - - it("should get all campaigns and its count", async () => { - const response = await api.get(`/admin/campaigns`, adminHeaders) - - expect(response.status).toEqual(200) - expect(response.data.count).toEqual(2) - expect(response.data.campaigns).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: expect.any(String), - name: "campaign 1", - description: "test description", - currency: "USD", - campaign_identifier: "test-1", - starts_at: expect.any(String), - ends_at: expect.any(String), - budget: { - id: expect.any(String), - type: "spend", - limit: 1000, - used: 0, - raw_limit: { - precision: 20, - value: "1000", - }, - raw_used: { - precision: 20, - value: "0", - }, - created_at: expect.any(String), - updated_at: expect.any(String), - deleted_at: null, - }, - created_at: expect.any(String), - updated_at: expect.any(String), - deleted_at: null, - }), - expect.objectContaining({ - id: expect.any(String), - name: "campaign 2", - description: "test description", - currency: "USD", - campaign_identifier: "test-2", - starts_at: expect.any(String), - ends_at: expect.any(String), - budget: { - id: expect.any(String), - type: "usage", - limit: 1000, - used: 0, - raw_limit: { - precision: 20, - value: "1000", - }, - raw_used: { - precision: 20, - value: "0", - }, - created_at: expect.any(String), - updated_at: expect.any(String), - deleted_at: null, - }, - created_at: expect.any(String), - updated_at: expect.any(String), - deleted_at: null, - }), - ]) - ) - }) - - it("should support search on campaigns", async () => { - const response = await api.get( - `/admin/campaigns?q=ign%202`, - adminHeaders - ) - - expect(response.status).toEqual(200) - expect(response.data.campaigns).toEqual([ - expect.objectContaining({ - name: "campaign 2", - }), - ]) - }) - - it("should get all campaigns and its count filtered", async () => { - const response = await api.get( - `/admin/campaigns?fields=name,created_at,budget.id`, - adminHeaders - ) - - expect(response.status).toEqual(200) - expect(response.data.count).toEqual(2) - expect(response.data.campaigns).toEqual( - expect.arrayContaining([ - { - id: expect.any(String), - name: "campaign 1", - created_at: expect.any(String), - budget: { - id: expect.any(String), - }, - }, - { - id: expect.any(String), - name: "campaign 2", - created_at: expect.any(String), - budget: { - id: expect.any(String), - }, - }, - ]) - ) - }) - }) - }, -}) 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 94ca5b6cd1..fcb24aa525 100644 --- a/integration-tests/modules/__tests__/promotion/admin/list-promotions.spec.ts +++ b/integration-tests/modules/__tests__/promotion/admin/list-promotions.spec.ts @@ -113,7 +113,7 @@ medusaIntegrationTestRunner({ ]) const response = await api.get( - `/admin/promotions?fields=code,created_at,application_method.id&expand=application_method`, + `/admin/promotions?fields=code,created_at,application_method.id`, adminHeaders ) diff --git a/integration-tests/modules/__tests__/promotion/admin/retrieve-campaign.spec.ts b/integration-tests/modules/__tests__/promotion/admin/retrieve-campaign.spec.ts deleted file mode 100644 index 4b63a0fb69..0000000000 --- a/integration-tests/modules/__tests__/promotion/admin/retrieve-campaign.spec.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { ModuleRegistrationName } from "@medusajs/modules-sdk" -import { IPromotionModuleService } from "@medusajs/types" -import { CampaignBudgetType } from "@medusajs/utils" -import { createAdminUser } from "../../../../helpers/create-admin-user" -import { medusaIntegrationTestRunner } from "medusa-test-utils" - -jest.setTimeout(50000) - -export const campaignData = { - 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, - }, -} - -const env = { MEDUSA_FF_MEDUSA_V2: true } -const adminHeaders = { - headers: { "x-medusa-access-token": "test_token" }, -} - -medusaIntegrationTestRunner({ - env, - testSuite: ({ dbConnection, getContainer, api }) => { - describe("GET /admin/campaigns", () => { - let appContainer - let promotionModuleService: IPromotionModuleService - - beforeAll(async () => { - appContainer = getContainer() - promotionModuleService = appContainer.resolve( - ModuleRegistrationName.PROMOTION - ) - }) - - beforeEach(async () => { - await createAdminUser(dbConnection, adminHeaders, appContainer) - }) - - it("should throw an error if id does not exist", async () => { - const { response } = await api - .get(`/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 was not found" - ) - }) - - it("should get the requested campaign", async () => { - const createdCampaign = await promotionModuleService.createCampaigns( - campaignData - ) - - const response = await api.get( - `/admin/campaigns/${createdCampaign.id}`, - adminHeaders - ) - - expect(response.status).toEqual(200) - expect(response.data.campaign).toEqual({ - id: expect.any(String), - name: "campaign 1", - description: "test description", - currency: "USD", - campaign_identifier: "test-1", - starts_at: expect.any(String), - ends_at: expect.any(String), - budget: { - id: expect.any(String), - type: "spend", - limit: 1000, - raw_limit: { - precision: 20, - value: "1000", - }, - raw_used: { - precision: 20, - value: "0", - }, - used: 0, - created_at: expect.any(String), - updated_at: expect.any(String), - deleted_at: null, - }, - created_at: expect.any(String), - updated_at: expect.any(String), - deleted_at: null, - }) - }) - - it("should get the requested campaign with filtered fields and relations", async () => { - const createdCampaign = await promotionModuleService.createCampaigns( - campaignData - ) - - const response = await api.get( - `/admin/campaigns/${createdCampaign.id}?fields=name`, - adminHeaders - ) - - expect(response.status).toEqual(200) - expect(response.data.campaign).toEqual({ - id: expect.any(String), - name: "campaign 1", - }) - }) - }) - }, -}) diff --git a/integration-tests/modules/__tests__/promotion/admin/update-campaign.spec.ts b/integration-tests/modules/__tests__/promotion/admin/update-campaign.spec.ts deleted file mode 100644 index 318a88a00a..0000000000 --- a/integration-tests/modules/__tests__/promotion/admin/update-campaign.spec.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { IPromotionModuleService } from "@medusajs/types" -import { ModuleRegistrationName } from "@medusajs/modules-sdk" -import { createAdminUser } from "../../../../helpers/create-admin-user" -import { medusaIntegrationTestRunner } from "medusa-test-utils" - -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("POST /admin/campaigns/:id", () => { - let appContainer - let promotionModuleService: IPromotionModuleService - - beforeAll(async () => { - appContainer = getContainer() - promotionModuleService = appContainer.resolve( - ModuleRegistrationName.PROMOTION - ) - }) - - beforeEach(async () => { - await createAdminUser(dbConnection, adminHeaders, appContainer) - }) - - it("should throw an error if id does not exist", async () => { - 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 response = await api.post( - `/admin/campaigns/${createdCampaign.id}?fields=*promotions`, - { - 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/packages/core/core-flows/src/promotion/steps/add-campaign-promotions.ts b/packages/core/core-flows/src/promotion/steps/add-campaign-promotions.ts new file mode 100644 index 0000000000..a175b5b15d --- /dev/null +++ b/packages/core/core-flows/src/promotion/steps/add-campaign-promotions.ts @@ -0,0 +1,41 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { IPromotionModuleService, LinkWorkflowInput } from "@medusajs/types" +import { StepResponse, WorkflowData, createStep } from "@medusajs/workflows-sdk" + +export const addCampaignPromotionsStepId = "add-campaign-promotions" +export const addCampaignPromotionsStep = createStep( + addCampaignPromotionsStepId, + async (input: WorkflowData, { container }) => { + const { id: campaignId, add: promotionIdsToAdd = [] } = input + + const promotionModule = container.resolve( + ModuleRegistrationName.PROMOTION + ) + + if (promotionIdsToAdd.length) { + await promotionModule.addPromotionsToCampaign({ + id: campaignId, + promotion_ids: promotionIdsToAdd, + }) + } + + return new StepResponse(null, input) + }, + async (data, { container }) => { + if (!data) { + return + } + + const { id: campaignId, add: promotionIdsToRemove = [] } = data + const promotionModule = container.resolve( + ModuleRegistrationName.PROMOTION + ) + + if (promotionIdsToRemove.length) { + await promotionModule.removePromotionsFromCampaign({ + id: campaignId, + promotion_ids: promotionIdsToRemove, + }) + } + } +) diff --git a/packages/core/core-flows/src/promotion/steps/index.ts b/packages/core/core-flows/src/promotion/steps/index.ts index f3b89f7a68..632fd1c808 100644 --- a/packages/core/core-flows/src/promotion/steps/index.ts +++ b/packages/core/core-flows/src/promotion/steps/index.ts @@ -1,8 +1,10 @@ +export * from "./add-campaign-promotions" export * from "./add-rules-to-promotions" export * from "./create-campaigns" export * from "./create-promotions" export * from "./delete-campaigns" export * from "./delete-promotions" +export * from "./remove-campaign-promotions" export * from "./remove-rules-from-promotions" export * from "./update-campaigns" export * from "./update-promotion-rules" diff --git a/packages/core/core-flows/src/promotion/steps/remove-campaign-promotions.ts b/packages/core/core-flows/src/promotion/steps/remove-campaign-promotions.ts new file mode 100644 index 0000000000..fdcf242b1a --- /dev/null +++ b/packages/core/core-flows/src/promotion/steps/remove-campaign-promotions.ts @@ -0,0 +1,40 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { IPromotionModuleService, LinkWorkflowInput } from "@medusajs/types" +import { StepResponse, WorkflowData, createStep } from "@medusajs/workflows-sdk" + +export const removeCampaignPromotionsStepId = "remove-campaign-promotions" +export const removeCampaignPromotionsStep = createStep( + removeCampaignPromotionsStepId, + async (input: WorkflowData, { container }) => { + const { id: campaignId, remove: promotionIdsToRemove = [] } = input + const promotionModule = container.resolve( + ModuleRegistrationName.PROMOTION + ) + + if (promotionIdsToRemove.length) { + await promotionModule.removePromotionsFromCampaign({ + id: campaignId, + promotion_ids: promotionIdsToRemove, + }) + } + + return new StepResponse(null, input) + }, + async (data, { container }) => { + if (!data) { + return + } + + const { id: campaignId, remove: promotionIdsToAdd = [] } = data + const promotionModule = container.resolve( + ModuleRegistrationName.PROMOTION + ) + + if (promotionIdsToAdd.length) { + await promotionModule.addPromotionsToCampaign({ + id: campaignId, + promotion_ids: promotionIdsToAdd, + }) + } + } +) diff --git a/packages/core/core-flows/src/promotion/workflows/add-or-remove-campaign-promotions.ts b/packages/core/core-flows/src/promotion/workflows/add-or-remove-campaign-promotions.ts new file mode 100644 index 0000000000..adb34649c6 --- /dev/null +++ b/packages/core/core-flows/src/promotion/workflows/add-or-remove-campaign-promotions.ts @@ -0,0 +1,16 @@ +import { LinkWorkflowInput } from "@medusajs/types" +import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk" +import { + addCampaignPromotionsStep, + removeCampaignPromotionsStep, +} from "../steps" + +export const addOrRemoveCampaignPromotionsWorkflowId = + "add-or-remove-campaign-promotions" +export const addOrRemoveCampaignPromotionsWorkflow = createWorkflow( + addOrRemoveCampaignPromotionsWorkflowId, + (input: WorkflowData): WorkflowData => { + addCampaignPromotionsStep(input) + removeCampaignPromotionsStep(input) + } +) diff --git a/packages/core/core-flows/src/promotion/workflows/index.ts b/packages/core/core-flows/src/promotion/workflows/index.ts index e90129f58c..604b82679b 100644 --- a/packages/core/core-flows/src/promotion/workflows/index.ts +++ b/packages/core/core-flows/src/promotion/workflows/index.ts @@ -1,10 +1,11 @@ +export * from "./add-or-remove-campaign-promotions" export * from "./batch-promotion-rules" export * from "./create-campaigns" +export * from "./create-promotion-rules" export * from "./create-promotions" export * from "./delete-campaigns" +export * from "./delete-promotion-rules" export * from "./delete-promotions" export * from "./update-campaigns" export * from "./update-promotion-rules" -export * from "./delete-promotion-rules" -export * from "./create-promotion-rules" export * from "./update-promotions" diff --git a/packages/core/types/src/promotion/common/campaign.ts b/packages/core/types/src/promotion/common/campaign.ts index fa502c1b27..9af0dec0d2 100644 --- a/packages/core/types/src/promotion/common/campaign.ts +++ b/packages/core/types/src/promotion/common/campaign.ts @@ -1,5 +1,6 @@ import { BaseFilterable } from "../../dal" import { CampaignBudgetDTO } from "./campaign-budget" +import { PromotionDTO } from "./promotion" /** * The campaign details. @@ -44,6 +45,11 @@ export interface CampaignDTO { * The associated campaign budget. */ budget?: CampaignBudgetDTO + + /** + * The associated promotions. + */ + promotions?: PromotionDTO[] } /** diff --git a/packages/core/types/src/promotion/common/promotion.ts b/packages/core/types/src/promotion/common/promotion.ts index 73bd193ee8..e3d9723c60 100644 --- a/packages/core/types/src/promotion/common/promotion.ts +++ b/packages/core/types/src/promotion/common/promotion.ts @@ -133,7 +133,7 @@ export interface UpdatePromotionDTO { /** * The associated campaign's ID. */ - campaign_id?: string + campaign_id?: string | null } /** diff --git a/packages/core/types/src/promotion/mutations.ts b/packages/core/types/src/promotion/mutations.ts index ec9462ddf8..6370a87e4d 100644 --- a/packages/core/types/src/promotion/mutations.ts +++ b/packages/core/types/src/promotion/mutations.ts @@ -1,4 +1,4 @@ -import { CampaignBudgetTypeValues, PromotionDTO } from "./common" +import { CampaignBudgetTypeValues } from "./common" /** * The campaign budget to be created. @@ -83,11 +83,6 @@ export interface CreateCampaignDTO { * The associated campaign budget. */ budget?: CreateCampaignBudgetDTO - - /** - * The promotions of the campaign. - */ - promotions?: Pick[] } /** @@ -133,9 +128,28 @@ export interface UpdateCampaignDTO { * The budget of the campaign. */ budget?: Omit +} + +export interface AddPromotionsToCampaignDTO { + /** + * The ID of the campaign. + */ + id: string /** - * The promotions of the campaign. + * Ids of promotions to add */ - promotions?: Pick[] + promotion_ids: string[] +} + +export interface RemovePromotionsFromCampaignDTO { + /** + * The ID of the campaign. + */ + id: string + + /** + * Ids of promotions to add + */ + promotion_ids: string[] } diff --git a/packages/core/types/src/promotion/service.ts b/packages/core/types/src/promotion/service.ts index 37855478ff..d92e92a2d9 100644 --- a/packages/core/types/src/promotion/service.ts +++ b/packages/core/types/src/promotion/service.ts @@ -16,7 +16,12 @@ import { UpdatePromotionDTO, UpdatePromotionRuleDTO, } from "./common" -import { CreateCampaignDTO, UpdateCampaignDTO } from "./mutations" +import { + AddPromotionsToCampaignDTO, + CreateCampaignDTO, + RemovePromotionsFromCampaignDTO, + UpdateCampaignDTO, +} from "./mutations" /** * The main service interface for the Promotion Module. @@ -967,4 +972,14 @@ export interface IPromotionModuleService extends IModuleService { config?: RestoreReturn, sharedContext?: Context ): Promise | void> + + addPromotionsToCampaign( + data: AddPromotionsToCampaignDTO, + sharedContext?: Context + ): Promise<{ ids: string[] }> + + removePromotionsFromCampaign( + data: RemovePromotionsFromCampaignDTO, + sharedContext?: Context + ): Promise<{ ids: string[] }> } diff --git a/packages/medusa/src/api-v2/admin/campaigns/[id]/promotions/route.ts b/packages/medusa/src/api-v2/admin/campaigns/[id]/promotions/route.ts new file mode 100644 index 0000000000..7fc71e3165 --- /dev/null +++ b/packages/medusa/src/api-v2/admin/campaigns/[id]/promotions/route.ts @@ -0,0 +1,34 @@ +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "../../../../../types/routing" + +import { addOrRemoveCampaignPromotionsWorkflow } from "@medusajs/core-flows" +import { LinkMethodRequest } from "@medusajs/types/src" +import { refetchCampaign } from "../../helpers" + +export const POST = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const { id } = req.params + const { add, remove } = req.validatedBody + const { errors } = await addOrRemoveCampaignPromotionsWorkflow(req.scope).run( + { + input: { id, add, remove }, + throwOnError: false, + } + ) + + if (Array.isArray(errors) && errors[0]) { + throw errors[0].error + } + + const campaign = await refetchCampaign( + req.params.id, + req.scope, + req.remoteQueryConfig.fields + ) + + res.status(200).json({ campaign }) +} diff --git a/packages/medusa/src/api-v2/admin/campaigns/middlewares.ts b/packages/medusa/src/api-v2/admin/campaigns/middlewares.ts index 6217dfd7b4..a83bfe4d37 100644 --- a/packages/medusa/src/api-v2/admin/campaigns/middlewares.ts +++ b/packages/medusa/src/api-v2/admin/campaigns/middlewares.ts @@ -1,14 +1,15 @@ -import * as QueryConfig from "./query-config" import { MiddlewareRoute } from "../../../loaders/helpers/routing/types" import { authenticate } from "../../../utils/authenticate-middleware" +import { validateAndTransformBody } from "../../utils/validate-body" import { validateAndTransformQuery } from "../../utils/validate-query" +import { createLinkBody } from "../../utils/validators" +import * as QueryConfig from "./query-config" import { AdminCreateCampaign, AdminGetCampaignParams, AdminGetCampaignsParams, AdminUpdateCampaign, } from "./validators" -import { validateAndTransformBody } from "../../utils/validate-body" export const adminCampaignRoutesMiddlewares: MiddlewareRoute[] = [ { @@ -57,4 +58,15 @@ export const adminCampaignRoutesMiddlewares: MiddlewareRoute[] = [ ), ], }, + { + method: ["POST"], + matcher: "/admin/campaigns/:id/promotions", + middlewares: [ + validateAndTransformBody(createLinkBody()), + validateAndTransformQuery( + AdminGetCampaignParams, + QueryConfig.retrieveTransformQueryConfig + ), + ], + }, ] diff --git a/packages/medusa/src/api-v2/admin/promotions/middlewares.ts b/packages/medusa/src/api-v2/admin/promotions/middlewares.ts index e52f4b03db..d0bb738b73 100644 --- a/packages/medusa/src/api-v2/admin/promotions/middlewares.ts +++ b/packages/medusa/src/api-v2/admin/promotions/middlewares.ts @@ -1,7 +1,9 @@ -import * as QueryConfig from "./query-config" import { MiddlewareRoute } from "../../../loaders/helpers/routing/types" import { authenticate } from "../../../utils/authenticate-middleware" +import { validateAndTransformBody } from "../../utils/validate-body" import { validateAndTransformQuery } from "../../utils/validate-query" +import { createBatchBody } from "../../utils/validators" +import * as QueryConfig from "./query-config" import { AdminCreatePromotion, AdminCreatePromotionRule, @@ -13,8 +15,6 @@ import { AdminUpdatePromotion, AdminUpdatePromotionRule, } from "./validators" -import { validateAndTransformBody } from "../../utils/validate-body" -import { createBatchBody } from "../../utils/validators" export const adminPromotionRoutesMiddlewares: MiddlewareRoute[] = [ { diff --git a/packages/medusa/src/api-v2/admin/promotions/route.ts b/packages/medusa/src/api-v2/admin/promotions/route.ts index 17866457f2..e9731503a5 100644 --- a/packages/medusa/src/api-v2/admin/promotions/route.ts +++ b/packages/medusa/src/api-v2/admin/promotions/route.ts @@ -1,17 +1,17 @@ import { createPromotionsWorkflow } from "@medusajs/core-flows" -import { - AuthenticatedMedusaRequest, - MedusaResponse, -} from "../../../types/routing" import { ContainerRegistrationKeys, remoteQueryObjectFromString, } from "@medusajs/utils" +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "../../../types/routing" +import { refetchPromotion } from "./helpers" import { AdminCreatePromotionType, AdminGetPromotionsParamsType, } from "./validators" -import { refetchPromotion } from "./helpers" 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 17adcbc97a..37effa3e0a 100644 --- a/packages/medusa/src/api-v2/admin/promotions/validators.ts +++ b/packages/medusa/src/api-v2/admin/promotions/validators.ts @@ -24,17 +24,20 @@ export type AdminGetPromotionsParamsType = z.infer< export const AdminGetPromotionsParams = createFindParams({ limit: 50, offset: 0, -}).merge( - z.object({ - q: z.string().optional(), - code: z.union([z.string(), z.array(z.string())]).optional(), - created_at: createOperatorMap().optional(), - updated_at: createOperatorMap().optional(), - deleted_at: createOperatorMap().optional(), - $and: z.lazy(() => AdminGetPromotionsParams.array()).optional(), - $or: z.lazy(() => AdminGetPromotionsParams.array()).optional(), - }) -) +}) + .merge( + z.object({ + q: z.string().optional(), + code: z.union([z.string(), z.array(z.string())]).optional(), + campaign_id: z.union([z.string(), z.array(z.string())]).optional(), + created_at: createOperatorMap().optional(), + updated_at: createOperatorMap().optional(), + deleted_at: createOperatorMap().optional(), + $and: z.lazy(() => AdminGetPromotionsParams.array()).optional(), + $or: z.lazy(() => AdminGetPromotionsParams.array()).optional(), + }) + ) + .strict() export type AdminGetPromotionRuleParamsType = z.infer< typeof AdminGetPromotionRuleParams @@ -152,7 +155,6 @@ export const AdminCreateCampaign = z.object({ budget: CreateCampaignBudget.optional(), starts_at: z.coerce.date().optional(), ends_at: z.coerce.date().optional(), - promotions: z.array(z.object({ id: z.string() })).optional(), }) export type AdminCreatePromotionType = z.infer diff --git a/packages/modules/promotion/integration-tests/__tests__/services/promotion-module/campaign.spec.ts b/packages/modules/promotion/integration-tests/__tests__/services/promotion-module/campaign.spec.ts index d6765daab5..9905e642cc 100644 --- a/packages/modules/promotion/integration-tests/__tests__/services/promotion-module/campaign.spec.ts +++ b/packages/modules/promotion/integration-tests/__tests__/services/promotion-module/campaign.spec.ts @@ -152,43 +152,6 @@ moduleIntegrationTestRunner({ }) ) }) - - it("should create a basic campaign with promotions successfully", async () => { - await createPromotions(MikroOrmWrapper.forkManager()) - - 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, - promotions: [{ id: "promotion-id-1" }, { id: "promotion-id-2" }], - }, - ]) - - const campaign = await service.retrieveCampaign(createdCampaign.id, { - relations: ["promotions"], - }) - - expect(campaign).toEqual( - expect.objectContaining({ - name: "test", - campaign_identifier: "test", - starts_at: startsAt, - ends_at: endsAt, - promotions: [ - expect.objectContaining({ - id: "promotion-id-1", - }), - expect.objectContaining({ - id: "promotion-id-2", - }), - ], - }) - ) - }) }) describe("updateCampaigns", () => { @@ -251,66 +214,6 @@ moduleIntegrationTestRunner({ }) ) }) - - it("should update promotions of a campaign successfully", async () => { - await createCampaigns(MikroOrmWrapper.forkManager()) - await createPromotions(MikroOrmWrapper.forkManager()) - - 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"), - promotions: [{ id: "promotion-id-1" }, { id: "promotion-id-2" }], - }, - ]) - - 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"), - promotions: [ - expect.objectContaining({ - id: "promotion-id-1", - }), - expect.objectContaining({ - id: "promotion-id-2", - }), - ], - }) - ) - }) - - it("should remove promotions of the campaign successfully", async () => { - await createCampaigns(MikroOrmWrapper.forkManager()) - await createPromotions(MikroOrmWrapper.forkManager()) - - await service.updateCampaigns({ - id: "campaign-id-1", - promotions: [{ id: "promotion-id-1" }, { id: "promotion-id-2" }], - }) - - const updatedCampaign = await service.updateCampaigns({ - id: "campaign-id-1", - promotions: [{ id: "promotion-id-1" }], - }) - - expect(updatedCampaign).toEqual( - expect.objectContaining({ - promotions: [ - expect.objectContaining({ - id: "promotion-id-1", - }), - ], - }) - ) - }) }) describe("retrieveCampaign", () => { @@ -438,6 +341,77 @@ moduleIntegrationTestRunner({ expect(campaigns).toHaveLength(1) }) }) + + describe("addPromotionsToCampaign", () => { + beforeEach(async () => { + await createCampaigns(MikroOrmWrapper.forkManager()) + await createPromotions(MikroOrmWrapper.forkManager()) + + await service.addPromotionsToCampaign({ + id, + promotion_ids: ["promotion-id-1"], + }) + }) + + const id = "campaign-id-1" + + it("should add promotions to a campaign", async () => { + await service.addPromotionsToCampaign({ + id, + promotion_ids: ["promotion-id-2"], + }) + + const campaign = await service.retrieveCampaign(id, { + relations: ["promotions"], + }) + + expect(campaign.promotions).toHaveLength(2) + expect(campaign).toEqual( + expect.objectContaining({ + id, + promotions: expect.arrayContaining([ + expect.objectContaining({ id: "promotion-id-1" }), + expect.objectContaining({ id: "promotion-id-2" }), + ]), + }) + ) + }) + }) + + describe("removePromotionsFromCampaign", () => { + beforeEach(async () => { + await createCampaigns(MikroOrmWrapper.forkManager()) + await createPromotions(MikroOrmWrapper.forkManager()) + + await service.addPromotionsToCampaign({ + id, + promotion_ids: ["promotion-id-1", "promotion-id-2"], + }) + }) + + const id = "campaign-id-1" + + it("should remove promotions to a campaign", async () => { + await service.removePromotionsFromCampaign({ + id, + promotion_ids: ["promotion-id-1"], + }) + + const campaign = await service.retrieveCampaign(id, { + relations: ["promotions"], + }) + + expect(campaign.promotions).toHaveLength(1) + expect(campaign).toEqual( + expect.objectContaining({ + id, + promotions: expect.arrayContaining([ + expect.objectContaining({ id: "promotion-id-2" }), + ]), + }) + ) + }) + }) }) }, }) diff --git a/packages/modules/promotion/integration-tests/__tests__/services/promotion-module/compute-actions.spec.ts b/packages/modules/promotion/integration-tests/__tests__/services/promotion-module/compute-actions.spec.ts index 0bd32d73b0..9b9a951141 100644 --- a/packages/modules/promotion/integration-tests/__tests__/services/promotion-module/compute-actions.spec.ts +++ b/packages/modules/promotion/integration-tests/__tests__/services/promotion-module/compute-actions.spec.ts @@ -539,7 +539,7 @@ moduleIntegrationTestRunner({ }, ]) - await service.updateCampaigns({ + const updated = await service.updateCampaigns({ id: "campaign-id-2", budget: { used: 1000 }, }) diff --git a/packages/modules/promotion/integration-tests/__tests__/services/promotion-module/promotion.spec.ts b/packages/modules/promotion/integration-tests/__tests__/services/promotion-module/promotion.spec.ts index 03a3386741..a59c95eb12 100644 --- a/packages/modules/promotion/integration-tests/__tests__/services/promotion-module/promotion.spec.ts +++ b/packages/modules/promotion/integration-tests/__tests__/services/promotion-module/promotion.spec.ts @@ -6,9 +6,9 @@ import { CampaignBudgetType, PromotionType, } from "@medusajs/utils" +import { moduleIntegrationTestRunner, SuiteOptions } from "medusa-test-utils" import { createCampaigns } from "../../../__fixtures__/campaigns" import { createPromotions } from "../../../__fixtures__/promotion" -import { moduleIntegrationTestRunner, SuiteOptions } from "medusa-test-utils" jest.setTimeout(30000) @@ -918,6 +918,7 @@ moduleIntegrationTestRunner({ { id: "promotion-id-1", code: "PROMOTION_1", + campaign_id: null, campaign: null, is_automatic: false, type: "standard", @@ -929,6 +930,7 @@ moduleIntegrationTestRunner({ { id: "promotion-id-2", code: "PROMOTION_2", + campaign_id: null, campaign: null, is_automatic: false, type: "standard", diff --git a/packages/modules/promotion/src/models/campaign.ts b/packages/modules/promotion/src/models/campaign.ts index 6874398035..bafa194a4a 100644 --- a/packages/modules/promotion/src/models/campaign.ts +++ b/packages/modules/promotion/src/models/campaign.ts @@ -70,9 +70,7 @@ export default class Campaign { }) budget: CampaignBudget | null = null - @OneToMany(() => Promotion, (promotion) => promotion.campaign, { - orphanRemoval: true, - }) + @OneToMany(() => Promotion, (promotion) => promotion.campaign) promotions = new Collection(this) @Property({ diff --git a/packages/modules/promotion/src/models/promotion.ts b/packages/modules/promotion/src/models/promotion.ts index bee0fe91c3..2f53cae22a 100644 --- a/packages/modules/promotion/src/models/promotion.ts +++ b/packages/modules/promotion/src/models/promotion.ts @@ -45,13 +45,17 @@ export default class Promotion { }) code: string - @Searchable() @ManyToOne(() => Campaign, { + columnType: "text", fieldName: "campaign_id", nullable: true, - cascade: ["soft-remove"] as any, + mapToPk: true, + onDelete: "set null", }) - campaign: Campaign | null = null + campaign_id: string | null = null + + @ManyToOne(() => Campaign, { persist: false }) + campaign: Campaign | null @Property({ columnType: "boolean", default: false }) is_automatic: boolean = false diff --git a/packages/modules/promotion/src/repositories/campaign.ts b/packages/modules/promotion/src/repositories/campaign.ts deleted file mode 100644 index afec080397..0000000000 --- a/packages/modules/promotion/src/repositories/campaign.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { Context } from "@medusajs/types" -import { DALUtils } from "@medusajs/utils" -import { SqlEntityManager } from "@mikro-orm/postgresql" -import { Campaign, Promotion } from "@models" -import { CreateCampaignDTO, UpdateCampaignDTO } from "@types" - -export class CampaignRepository extends DALUtils.mikroOrmBaseRepositoryFactory( - Campaign -) { - async create( - data: CreateCampaignDTO[], - context: Context = {} - ): Promise { - const manager = this.getActiveManager(context) - const promotionIdsToUpsert: string[] = [] - const campaignIdentifierPromotionsMap = new Map() - - data.forEach((campaignData) => { - const campaignPromotionIds = - campaignData.promotions?.map((p) => p.id) || [] - - promotionIdsToUpsert.push(...campaignPromotionIds) - - campaignIdentifierPromotionsMap.set( - campaignData.campaign_identifier, - campaignPromotionIds - ) - - delete campaignData.promotions - }) - - const existingPromotions = await manager.find(Promotion, { - id: promotionIdsToUpsert, - }) - - const existingPromotionsMap = new Map( - existingPromotions.map((promotion) => [promotion.id, promotion]) - ) - - const createdCampaigns = await super.create(data, context) - - for (const createdCampaign of createdCampaigns) { - const campaignPromotionIds = - campaignIdentifierPromotionsMap.get( - createdCampaign.campaign_identifier - ) || [] - - for (const campaignPromotionId of campaignPromotionIds) { - const promotion = existingPromotionsMap.get(campaignPromotionId) - - if (!promotion) { - continue - } - - createdCampaign.promotions.add(promotion) - } - } - - return createdCampaigns - } - - async update( - data: { entity: Campaign; update: UpdateCampaignDTO }[], - context: Context = {} - ): Promise { - const manager = this.getActiveManager(context) - const promotionIdsToUpsert: string[] = [] - const campaignIds: string[] = [] - const campaignPromotionIdsMap = new Map() - - data.forEach(({ update: campaignData }) => { - const campaignPromotionIds = campaignData.promotions?.map((p) => p.id) - - campaignIds.push(campaignData.id) - - if (campaignPromotionIds) { - promotionIdsToUpsert.push(...campaignPromotionIds) - campaignPromotionIdsMap.set(campaignData.id, campaignPromotionIds) - } - - delete campaignData.promotions - }) - - const existingCampaigns = await manager.find( - Campaign, - { id: campaignIds }, - { populate: ["promotions"] } - ) - - const promotionIds = existingCampaigns - .map((campaign) => campaign.promotions?.map((p) => p.id)) - .flat(1) - .concat(promotionIdsToUpsert) - - const existingPromotions = await manager.find(Promotion, { - id: promotionIds, - }) - - const existingCampaignsMap = new Map( - existingCampaigns.map((campaign) => [campaign.id, campaign]) - ) - - const existingPromotionsMap = new Map( - existingPromotions.map((promotion) => [promotion.id, promotion]) - ) - - const updatedCampaigns = await super.update(data, context) - - for (const updatedCampaign of updatedCampaigns) { - const upsertPromotionIds = campaignPromotionIdsMap.get(updatedCampaign.id) - - if (!upsertPromotionIds) { - continue - } - - const existingPromotionIds = ( - existingCampaignsMap.get(updatedCampaign.id)?.promotions || [] - ).map((p) => p.id) - - for (const existingPromotionId of existingPromotionIds) { - const promotion = existingPromotionsMap.get(existingPromotionId) - - if (!promotion) { - continue - } - - if (!upsertPromotionIds.includes(existingPromotionId)) { - updatedCampaign.promotions.remove(promotion) - } - } - - for (const promotionIdToAdd of upsertPromotionIds) { - const promotion = existingPromotionsMap.get(promotionIdToAdd) - - if (!promotion) { - continue - } - - if (existingPromotionIds.includes(promotionIdToAdd)) { - continue - } else { - updatedCampaign.promotions.add(promotion) - } - } - } - - return updatedCampaigns - } -} diff --git a/packages/modules/promotion/src/repositories/index.ts b/packages/modules/promotion/src/repositories/index.ts index db193bb79f..147c9cc259 100644 --- a/packages/modules/promotion/src/repositories/index.ts +++ b/packages/modules/promotion/src/repositories/index.ts @@ -1,2 +1 @@ export { MikroOrmBaseRepository as BaseRepository } from "@medusajs/utils" -export { CampaignRepository } from "./campaign" diff --git a/packages/modules/promotion/src/services/promotion-module.ts b/packages/modules/promotion/src/services/promotion-module.ts index a4c628ed21..bf6942eed9 100644 --- a/packages/modules/promotion/src/services/promotion-module.ts +++ b/packages/modules/promotion/src/services/promotion-module.ts @@ -528,7 +528,7 @@ export default class PromotionModuleService< promotionsData.push({ ...promotionData, - campaign: campaignId, + campaign_id: campaignId, }) } @@ -536,6 +536,7 @@ export default class PromotionModuleService< promotionsData, sharedContext ) + const promotionsToAdd: PromotionTypes.AddPromotionsToCampaignDTO[] = [] for (const promotion of createdPromotions) { const applMethodData = promotionCodeApplicationMethodDataMap.get( @@ -617,8 +618,25 @@ export default class PromotionModuleService< sharedContext ) - if (campaignsData.length) { - await this.createCampaigns(campaignsData, sharedContext) + const createdCampaigns = await this.createCampaigns( + campaignsData, + sharedContext + ) + + for (const campaignData of campaignsData) { + const promotions = campaignData.promotions + const campaign = createdCampaigns.find( + (c) => c.campaign_identifier === campaignData.campaign_identifier + ) + + if (!campaign || !promotions || !promotions.length) { + continue + } + + await this.addPromotionsToCampaign( + { id: campaign.id, promotion_ids: promotions.map((p) => p.id) }, + sharedContext + ) } for (const applicationMethod of createdApplicationMethods) { @@ -704,7 +722,7 @@ export default class PromotionModuleService< ...promotionData } of data) { if (campaignId) { - promotionsData.push({ ...promotionData, campaign: campaignId }) + promotionsData.push({ ...promotionData, campaign_id: campaignId }) } else { promotionsData.push(promotionData) } @@ -1110,19 +1128,7 @@ export default class PromotionModuleService< >() for (const createCampaignData of data) { - const { - budget: campaignBudgetData, - promotions, - ...campaignData - } = createCampaignData - - const promotionsToAdd = promotions - ? await this.list( - { id: promotions.map((p) => p.id) }, - { take: null }, - sharedContext - ) - : [] + const { budget: campaignBudgetData, ...campaignData } = createCampaignData if (campaignBudgetData) { campaignIdentifierBudgetMap.set( @@ -1133,7 +1139,6 @@ export default class PromotionModuleService< campaignsData.push({ ...campaignData, - promotions: promotionsToAdd, }) } @@ -1244,4 +1249,104 @@ export default class PromotionModuleService< return updatedCampaigns } + + @InjectManager("baseRepository_") + async addPromotionsToCampaign( + data: PromotionTypes.AddPromotionsToCampaignDTO, + sharedContext?: Context + ): Promise<{ ids: string[] }> { + const ids = await this.addPromotionsToCampaign_(data, sharedContext) + + return { ids } + } + + // TODO: + // - introduce currency_code to promotion + // - allow promotions to be queried by currency code + // - when the above is present, validate adding promotion to campaign based on currency code + @InjectTransactionManager("baseRepository_") + protected async addPromotionsToCampaign_( + data: PromotionTypes.AddPromotionsToCampaignDTO, + @MedusaContext() sharedContext: Context = {} + ) { + const { id, promotion_ids: promotionIds = [] } = data + + const campaign = await this.campaignService_.retrieve(id, {}, sharedContext) + const promotionsToAdd = await this.promotionService_.list( + { id: promotionIds, campaign_id: null }, + { take: null }, + sharedContext + ) + + const diff = arrayDifference( + promotionsToAdd.map((p) => p.id), + promotionIds + ) + + if (diff.length > 0) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Cannot add promotions (${diff.join( + "," + )}) to campaign. These promotions are either already part of a campaign or not found.` + ) + } + + await this.promotionService_.update( + promotionsToAdd.map((promotion) => ({ + id: promotion.id, + campaign_id: campaign.id, + })), + sharedContext + ) + + return promotionsToAdd.map((promo) => promo.id) + } + + @InjectManager("baseRepository_") + async removePromotionsFromCampaign( + data: PromotionTypes.AddPromotionsToCampaignDTO, + sharedContext?: Context + ): Promise<{ ids: string[] }> { + const ids = await this.removePromotionsFromCampaign_(data, sharedContext) + + return { ids } + } + + @InjectTransactionManager("baseRepository_") + protected async removePromotionsFromCampaign_( + data: PromotionTypes.AddPromotionsToCampaignDTO, + @MedusaContext() sharedContext: Context = {} + ) { + const { id, promotion_ids: promotionIds = [] } = data + + await this.campaignService_.retrieve(id, {}, sharedContext) + const promotionsToRemove = await this.promotionService_.list( + { id: promotionIds }, + { take: null }, + sharedContext + ) + + const diff = arrayDifference( + promotionsToRemove.map((p) => p.id), + promotionIds + ) + + if (diff.length > 0) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Promotions with ids (${diff.join(",")}) not found.` + ) + } + + await this.promotionService_.update( + promotionsToRemove.map((promotion) => ({ + id: promotion.id, + campaign_id: null, + })), + sharedContext + ) + + return promotionsToRemove.map((promo) => promo.id) + } } diff --git a/packages/modules/promotion/src/types/promotion.ts b/packages/modules/promotion/src/types/promotion.ts index ae63f9f36b..c1c6fffd6b 100644 --- a/packages/modules/promotion/src/types/promotion.ts +++ b/packages/modules/promotion/src/types/promotion.ts @@ -4,7 +4,7 @@ export interface CreatePromotionDTO { code: string type: PromotionTypeValues is_automatic?: boolean - campaign?: string + campaign_id?: string } export interface UpdatePromotionDTO { @@ -12,5 +12,5 @@ export interface UpdatePromotionDTO { code?: string type?: PromotionTypeValues is_automatic?: boolean - campaign?: string + campaign_id?: string }