diff --git a/.changeset/lazy-shoes-kiss.md b/.changeset/lazy-shoes-kiss.md new file mode 100644 index 0000000000..e97e5b1f7e --- /dev/null +++ b/.changeset/lazy-shoes-kiss.md @@ -0,0 +1,5 @@ +--- +"@medusajs/types": patch +--- + +feat(types): add campaign + promotion operations diff --git a/packages/promotion/integration-tests/__fixtures__/promotion/index.ts b/packages/promotion/integration-tests/__fixtures__/promotion/index.ts index 2339d9a583..e45cece09f 100644 --- a/packages/promotion/integration-tests/__fixtures__/promotion/index.ts +++ b/packages/promotion/integration-tests/__fixtures__/promotion/index.ts @@ -15,8 +15,8 @@ export async function createPromotions( let promotion = manager.create(Promotion, promotionData) manager.persist(promotion) - await manager.flush() + promotions.push(promotion) } return promotions diff --git a/packages/promotion/integration-tests/__tests__/services/promotion-module/campaign.spec.ts b/packages/promotion/integration-tests/__tests__/services/promotion-module/campaign.spec.ts index 277d5d3fac..27ae0f5ab2 100644 --- a/packages/promotion/integration-tests/__tests__/services/promotion-module/campaign.spec.ts +++ b/packages/promotion/integration-tests/__tests__/services/promotion-module/campaign.spec.ts @@ -2,6 +2,7 @@ import { IPromotionModuleService } from "@medusajs/types" import { SqlEntityManager } from "@mikro-orm/postgresql" import { initialize } from "../../../../src" import { createCampaigns } from "../../../__fixtures__/campaigns" +import { createPromotions } from "../../../__fixtures__/promotion" import { DB_URL, MikroOrmWrapper } from "../../../utils" jest.setTimeout(30000) @@ -101,6 +102,43 @@ describe("Promotion Module Service: Campaigns", () => { }) ) }) + + it("should create a basic campaign with promotions successfully", async () => { + await createPromotions(repositoryManager) + + 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", () => { @@ -163,6 +201,66 @@ describe("Promotion Module Service: Campaigns", () => { }) ) }) + + it("should update promotions of a campaign successfully", async () => { + await createCampaigns(repositoryManager) + await createPromotions(repositoryManager) + + const [updatedCampaign] = await service.updateCampaigns([ + { + id: "campaign-id-1", + description: "test description 1", + currency: "EUR", + campaign_identifier: "new", + starts_at: new Date("01/01/2024"), + ends_at: new Date("01/01/2025"), + 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(repositoryManager) + await createPromotions(repositoryManager) + + 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", () => { diff --git a/packages/promotion/integration-tests/__tests__/services/promotion-module/promotion.spec.ts b/packages/promotion/integration-tests/__tests__/services/promotion-module/promotion.spec.ts index c98c18f949..72edf031e9 100644 --- a/packages/promotion/integration-tests/__tests__/services/promotion-module/promotion.spec.ts +++ b/packages/promotion/integration-tests/__tests__/services/promotion-module/promotion.spec.ts @@ -1,7 +1,8 @@ import { IPromotionModuleService } from "@medusajs/types" -import { PromotionType } from "@medusajs/utils" +import { CampaignBudgetType, PromotionType } from "@medusajs/utils" import { SqlEntityManager } from "@mikro-orm/postgresql" import { initialize } from "../../../../src" +import { createCampaigns } from "../../../__fixtures__/campaigns" import { createPromotions } from "../../../__fixtures__/promotion" import { DB_URL, MikroOrmWrapper } from "../../../utils" @@ -99,6 +100,112 @@ describe("Promotion Service", () => { ) }) + it("should throw an error when both campaign and campaign_id are provided", async () => { + const startsAt = new Date("01/01/2023") + const endsAt = new Date("01/01/2023") + + const error = await service + .create({ + code: "PROMOTION_TEST", + type: PromotionType.STANDARD, + campaign_id: "campaign-id-1", + campaign: { + name: "test", + campaign_identifier: "test-promotion-test", + starts_at: startsAt, + ends_at: endsAt, + budget: { + type: CampaignBudgetType.SPEND, + used: 100, + limit: 100, + }, + }, + }) + .catch((e) => e) + + expect(error.message).toContain( + "Provide either the 'campaign' or 'campaign_id' parameter; both cannot be used simultaneously." + ) + }) + + it("should create a basic promotion with campaign successfully", async () => { + const startsAt = new Date("01/01/2023") + const endsAt = new Date("01/01/2023") + + await createCampaigns(repositoryManager) + + const createdPromotion = await service.create({ + code: "PROMOTION_TEST", + type: PromotionType.STANDARD, + campaign: { + name: "test", + campaign_identifier: "test-promotion-test", + starts_at: startsAt, + ends_at: endsAt, + budget: { + type: CampaignBudgetType.SPEND, + used: 100, + limit: 100, + }, + }, + }) + + const [promotion] = await service.list( + { id: [createdPromotion.id] }, + { relations: ["campaign.budget"] } + ) + + expect(promotion).toEqual( + expect.objectContaining({ + code: "PROMOTION_TEST", + is_automatic: false, + type: "standard", + campaign: expect.objectContaining({ + name: "test", + campaign_identifier: "test-promotion-test", + starts_at: startsAt, + ends_at: endsAt, + budget: expect.objectContaining({ + type: CampaignBudgetType.SPEND, + used: 100, + limit: 100, + }), + }), + }) + ) + }) + + it("should create a basic promotion with an existing campaign successfully", async () => { + await createCampaigns(repositoryManager) + + const createdPromotion = await service.create({ + code: "PROMOTION_TEST", + type: PromotionType.STANDARD, + campaign_id: "campaign-id-1", + }) + + const [promotion] = await service.list( + { id: [createdPromotion.id] }, + { relations: ["campaign.budget"] } + ) + + expect(promotion).toEqual( + expect.objectContaining({ + code: "PROMOTION_TEST", + is_automatic: false, + type: "standard", + campaign: expect.objectContaining({ + id: "campaign-id-1", + budget: expect.objectContaining({ + type: CampaignBudgetType.SPEND, + limit: 1000, + used: 0, + }), + }), + }) + ) + }) + it("should throw error when creating an item application method without allocation", async () => { const error = await service .create([ @@ -488,6 +595,33 @@ describe("Promotion Service", () => { `application_method.type should be one of fixed, percentage` ) }) + + it("should update campaign of the promotion", async () => { + await createCampaigns(repositoryManager) + const [createdPromotion] = await createPromotions(repositoryManager, [ + { + is_automatic: true, + code: "TEST", + type: PromotionType.BUYGET, + campaign_id: "campaign-id-1", + }, + ]) + + const [updatedPromotion] = await service.update([ + { + id: createdPromotion.id, + campaign_id: "campaign-id-2", + }, + ]) + + expect(updatedPromotion).toEqual( + expect.objectContaining({ + campaign: expect.objectContaining({ + id: "campaign-id-2", + }), + }) + ) + }) }) describe("retrieve", () => { diff --git a/packages/promotion/src/repositories/campaign.ts b/packages/promotion/src/repositories/campaign.ts index 5bc683689b..1f68a6f7fd 100644 --- a/packages/promotion/src/repositories/campaign.ts +++ b/packages/promotion/src/repositories/campaign.ts @@ -1,5 +1,7 @@ +import { Context } from "@medusajs/types" import { DALUtils } from "@medusajs/utils" -import { Campaign } from "@models" +import { SqlEntityManager } from "@mikro-orm/postgresql" +import { Campaign, Promotion } from "@models" import { CreateCampaignDTO, UpdateCampaignDTO } from "@types" export class CampaignRepository extends DALUtils.mikroOrmBaseRepositoryFactory< @@ -8,4 +10,138 @@ export class CampaignRepository extends DALUtils.mikroOrmBaseRepositoryFactory< create: CreateCampaignDTO update: UpdateCampaignDTO } ->(Campaign) {} +>(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: UpdateCampaignDTO[], + context: Context = {} + ): Promise { + const manager = this.getActiveManager(context) + const promotionIdsToUpsert: string[] = [] + const campaignIds: string[] = [] + const campaignPromotionIdsMap = new Map() + + data.forEach((campaignData) => { + const campaignPromotionIds = + campaignData.promotions?.map((p) => p.id) || [] + + campaignIds.push(campaignData.id) + 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) || [] + 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/promotion/src/services/promotion-module.ts b/packages/promotion/src/services/promotion-module.ts index 275d74e265..bc56733d56 100644 --- a/packages/promotion/src/services/promotion-module.ts +++ b/packages/promotion/src/services/promotion-module.ts @@ -314,6 +314,7 @@ export default class PromotionModuleService< "application_method.target_rules.values", "rules", "rules.values", + "campaign", ], }, sharedContext @@ -329,12 +330,12 @@ export default class PromotionModuleService< ) { const promotionsData: CreatePromotionDTO[] = [] const applicationMethodsData: CreateApplicationMethodDTO[] = [] + const campaignsData: CreateCampaignDTO[] = [] const promotionCodeApplicationMethodDataMap = new Map< string, PromotionTypes.CreateApplicationMethodDTO >() - const promotionCodeRulesDataMap = new Map< string, PromotionTypes.CreatePromotionRuleDTO[] @@ -343,10 +344,16 @@ export default class PromotionModuleService< string, PromotionTypes.CreatePromotionRuleDTO[] >() + const promotionCodeCampaignMap = new Map< + string, + PromotionTypes.CreateCampaignDTO + >() for (const { application_method: applicationMethodData, rules: rulesData, + campaign: campaignData, + campaign_id: campaignId, ...promotionData } of data) { if (applicationMethodData) { @@ -360,7 +367,21 @@ export default class PromotionModuleService< promotionCodeRulesDataMap.set(promotionData.code, rulesData) } - promotionsData.push(promotionData) + if (campaignData && campaignId) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Provide either the 'campaign' or 'campaign_id' parameter; both cannot be used simultaneously.` + ) + } + + if (campaignData) { + promotionCodeCampaignMap.set(promotionData.code, campaignData) + } + + promotionsData.push({ + ...promotionData, + campaign: campaignId, + }) } const createdPromotions = await this.promotionService_.create( @@ -373,6 +394,15 @@ export default class PromotionModuleService< promotion.code ) + const campaignData = promotionCodeCampaignMap.get(promotion.code) + + if (campaignData) { + campaignsData.push({ + ...campaignData, + promotions: [promotion], + }) + } + if (applMethodData) { const { target_rules: targetRulesData = [], @@ -416,6 +446,10 @@ export default class PromotionModuleService< sharedContext ) + if (campaignsData.length) { + await this.createCampaigns(campaignsData, sharedContext) + } + for (const applicationMethod of createdApplicationMethods) { await this.createPromotionRulesAndValues( applicationMethodRuleMap.get(applicationMethod.promotion.id) || [], @@ -456,6 +490,7 @@ export default class PromotionModuleService< "application_method.target_rules", "rules", "rules.values", + "campaign", ], }, sharedContext @@ -471,13 +506,10 @@ export default class PromotionModuleService< ) { const promotionIds = data.map((d) => d.id) const existingPromotions = await this.promotionService_.list( - { - id: promotionIds, - }, - { - relations: ["application_method"], - } + { id: promotionIds }, + { relations: ["application_method"] } ) + const existingPromotionsMap = new Map( existingPromotions.map((promotion) => [promotion.id, promotion]) ) @@ -487,9 +519,14 @@ export default class PromotionModuleService< for (const { application_method: applicationMethodData, + campaign_id: campaignId, ...promotionData } of data) { - promotionsData.push(promotionData) + if (campaignId) { + promotionsData.push({ ...promotionData, campaign: campaignId }) + } else { + promotionsData.push(promotionData) + } if (!applicationMethodData) { continue @@ -820,7 +857,19 @@ export default class PromotionModuleService< >() for (const createCampaignData of data) { - const { budget: campaignBudgetData, ...campaignData } = createCampaignData + const { + budget: campaignBudgetData, + promotions, + ...campaignData + } = createCampaignData + + const promotionsToAdd = promotions + ? await this.list( + { id: promotions.map((p) => p.id) }, + {}, + sharedContext + ) + : [] if (campaignBudgetData) { campaignIdentifierBudgetMap.set( @@ -829,7 +878,10 @@ export default class PromotionModuleService< ) } - campaignsData.push(campaignData) + campaignsData.push({ + ...campaignData, + promotions: promotionsToAdd, + }) } const createdCampaigns = await this.campaignService_.create( @@ -882,7 +934,7 @@ export default class PromotionModuleService< const campaigns = await this.listCampaigns( { id: updatedCampaigns.map((p) => p!.id) }, { - relations: ["budget"], + relations: ["budget", "promotions"], }, sharedContext ) diff --git a/packages/promotion/src/types/campaign.ts b/packages/promotion/src/types/campaign.ts index be55383763..70e21d2395 100644 --- a/packages/promotion/src/types/campaign.ts +++ b/packages/promotion/src/types/campaign.ts @@ -1,3 +1,6 @@ +import { PromotionDTO } from "@medusajs/types" +import { Promotion } from "@models" + export interface CreateCampaignDTO { name: string description?: string @@ -5,6 +8,7 @@ export interface CreateCampaignDTO { campaign_identifier: string starts_at: Date ends_at: Date + promotions?: (PromotionDTO | Promotion)[] } export interface UpdateCampaignDTO { @@ -15,4 +19,5 @@ export interface UpdateCampaignDTO { campaign_identifier?: string starts_at?: Date ends_at?: Date + promotions?: (PromotionDTO | Promotion)[] } diff --git a/packages/promotion/src/types/promotion.ts b/packages/promotion/src/types/promotion.ts index 502e4fdd60..8e63c442cf 100644 --- a/packages/promotion/src/types/promotion.ts +++ b/packages/promotion/src/types/promotion.ts @@ -4,6 +4,7 @@ export interface CreatePromotionDTO { code: string type: PromotionType is_automatic?: boolean + campaign?: string } export interface UpdatePromotionDTO { @@ -12,4 +13,5 @@ export interface UpdatePromotionDTO { // TODO: add this when buyget is available // type: PromotionType is_automatic?: boolean + campaign?: string } diff --git a/packages/types/src/promotion/common/compute-actions.ts b/packages/types/src/promotion/common/compute-actions.ts index 3770c6ce7b..8e57402efe 100644 --- a/packages/types/src/promotion/common/compute-actions.ts +++ b/packages/types/src/promotion/common/compute-actions.ts @@ -31,25 +31,25 @@ export interface RemoveShippingMethodAdjustment { adjustment_id: string } -export interface ComputeActionAdjustmentLine { +export interface ComputeActionAdjustmentLine extends Record { id: string code: string } -export interface ComputeActionItemLine { +export interface ComputeActionItemLine extends Record { id: string quantity: number unit_price: number adjustments?: ComputeActionAdjustmentLine[] } -export interface ComputeActionShippingLine { +export interface ComputeActionShippingLine extends Record { id: string unit_price: number adjustments?: ComputeActionAdjustmentLine[] } -export interface ComputeActionContext { +export interface ComputeActionContext extends Record { items?: ComputeActionItemLine[] shipping_methods?: ComputeActionShippingLine[] } diff --git a/packages/types/src/promotion/common/promotion.ts b/packages/types/src/promotion/common/promotion.ts index 9c3a47528e..211223f889 100644 --- a/packages/types/src/promotion/common/promotion.ts +++ b/packages/types/src/promotion/common/promotion.ts @@ -1,4 +1,5 @@ import { BaseFilterable } from "../../dal" +import { CreateCampaignDTO } from "../mutations" import { ApplicationMethodDTO, CreateApplicationMethodDTO, @@ -23,6 +24,8 @@ export interface CreatePromotionDTO { is_automatic?: boolean application_method?: CreateApplicationMethodDTO rules?: CreatePromotionRuleDTO[] + campaign?: CreateCampaignDTO + campaign_id?: string } export interface UpdatePromotionDTO { @@ -31,6 +34,7 @@ export interface UpdatePromotionDTO { code?: string type?: PromotionType application_method?: UpdateApplicationMethodDTO + campaign_id?: string } export interface FilterablePromotionProps @@ -39,4 +43,5 @@ export interface FilterablePromotionProps code?: string[] is_automatic?: boolean type?: PromotionType[] + budget_id?: string[] } diff --git a/packages/types/src/promotion/mutations.ts b/packages/types/src/promotion/mutations.ts index a8e3fef9f5..cef6b28b29 100644 --- a/packages/types/src/promotion/mutations.ts +++ b/packages/types/src/promotion/mutations.ts @@ -1,4 +1,4 @@ -import { CampaignBudgetTypeValues } from "./common" +import { CampaignBudgetTypeValues, PromotionDTO } from "./common" export interface CreateCampaignBudgetDTO { type: CampaignBudgetTypeValues @@ -21,6 +21,7 @@ export interface CreateCampaignDTO { starts_at: Date ends_at: Date budget?: CreateCampaignBudgetDTO + promotions?: Pick[] } export interface UpdateCampaignDTO { @@ -32,4 +33,5 @@ export interface UpdateCampaignDTO { starts_at?: Date ends_at?: Date budget?: Omit + promotions?: Pick[] }