feat(utils): add campaign + promotion create / update (#6077)

* chore: add campaign + promotion operations

* chore: update type

* chore: added validation for campaign_id and campaign

* chore: added update promotions for campaigns

* chore: update campaign on promotion

* chore: fix lint

* chore: add test to remove promotions
This commit is contained in:
Riqwan Thamir
2024-01-16 09:37:57 +01:00
committed by GitHub
parent 5d1965f2fa
commit e28fa7fbdf
11 changed files with 460 additions and 21 deletions

View File

@@ -0,0 +1,5 @@
---
"@medusajs/types": patch
---
feat(types): add campaign + promotion operations

View File

@@ -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

View File

@@ -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", () => {

View File

@@ -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", () => {

View File

@@ -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<Campaign[]> {
const manager = this.getActiveManager<SqlEntityManager>(context)
const promotionIdsToUpsert: string[] = []
const campaignIdentifierPromotionsMap = new Map<string, string[]>()
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<string, Promotion>(
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<Campaign[]> {
const manager = this.getActiveManager<SqlEntityManager>(context)
const promotionIdsToUpsert: string[] = []
const campaignIds: string[] = []
const campaignPromotionIdsMap = new Map<string, string[]>()
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<string, Campaign>(
existingCampaigns.map((campaign) => [campaign.id, campaign])
)
const existingPromotionsMap = new Map<string, Promotion>(
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
}
}

View File

@@ -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<string, Promotion>(
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
)

View File

@@ -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)[]
}

View File

@@ -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
}

View File

@@ -31,25 +31,25 @@ export interface RemoveShippingMethodAdjustment {
adjustment_id: string
}
export interface ComputeActionAdjustmentLine {
export interface ComputeActionAdjustmentLine extends Record<string, unknown> {
id: string
code: string
}
export interface ComputeActionItemLine {
export interface ComputeActionItemLine extends Record<string, unknown> {
id: string
quantity: number
unit_price: number
adjustments?: ComputeActionAdjustmentLine[]
}
export interface ComputeActionShippingLine {
export interface ComputeActionShippingLine extends Record<string, unknown> {
id: string
unit_price: number
adjustments?: ComputeActionAdjustmentLine[]
}
export interface ComputeActionContext {
export interface ComputeActionContext extends Record<string, unknown> {
items?: ComputeActionItemLine[]
shipping_methods?: ComputeActionShippingLine[]
}

View File

@@ -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[]
}

View File

@@ -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<PromotionDTO, "id">[]
}
export interface UpdateCampaignDTO {
@@ -32,4 +33,5 @@ export interface UpdateCampaignDTO {
starts_at?: Date
ends_at?: Date
budget?: Omit<UpdateCampaignBudgetDTO, "id">
promotions?: Pick<PromotionDTO, "id">[]
}