diff --git a/.changeset/wet-cups-kick.md b/.changeset/wet-cups-kick.md new file mode 100644 index 0000000000..150d6489b6 --- /dev/null +++ b/.changeset/wet-cups-kick.md @@ -0,0 +1,6 @@ +--- +"@medusajs/types": patch +"@medusajs/utils": patch +--- + +feat(utils,types): campaigns and campaign budgets + services CRUD diff --git a/packages/promotion/integration-tests/__fixtures__/campaigns/data.ts b/packages/promotion/integration-tests/__fixtures__/campaigns/data.ts new file mode 100644 index 0000000000..fb659cb660 --- /dev/null +++ b/packages/promotion/integration-tests/__fixtures__/campaigns/data.ts @@ -0,0 +1,32 @@ +import { CampaignBudgetType } from "@medusajs/utils" + +export const defaultCampaignsData = [ + { + 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 1", + 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, + }, + }, +] diff --git a/packages/promotion/integration-tests/__fixtures__/campaigns/index.ts b/packages/promotion/integration-tests/__fixtures__/campaigns/index.ts new file mode 100644 index 0000000000..a56b537cc4 --- /dev/null +++ b/packages/promotion/integration-tests/__fixtures__/campaigns/index.ts @@ -0,0 +1,23 @@ +import { CreateCampaignDTO } from "@medusajs/types" +import { SqlEntityManager } from "@mikro-orm/postgresql" +import { Campaign } from "@models" +import { defaultCampaignsData } from "./data" + +export * from "./data" + +export async function createCampaigns( + manager: SqlEntityManager, + campaignsData: CreateCampaignDTO[] = defaultCampaignsData +): Promise { + const campaigns: Campaign[] = [] + + for (let campaignData of campaignsData) { + let campaign = manager.create(Campaign, campaignData) + + manager.persist(campaign) + + await manager.flush() + } + + return campaigns +} 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 new file mode 100644 index 0000000000..277d5d3fac --- /dev/null +++ b/packages/promotion/integration-tests/__tests__/services/promotion-module/campaign.spec.ts @@ -0,0 +1,241 @@ +import { IPromotionModuleService } from "@medusajs/types" +import { SqlEntityManager } from "@mikro-orm/postgresql" +import { initialize } from "../../../../src" +import { createCampaigns } from "../../../__fixtures__/campaigns" +import { DB_URL, MikroOrmWrapper } from "../../../utils" + +jest.setTimeout(30000) + +describe("Promotion Module Service: Campaigns", () => { + let service: IPromotionModuleService + let repositoryManager: SqlEntityManager + + beforeEach(async () => { + await MikroOrmWrapper.setupDatabase() + repositoryManager = MikroOrmWrapper.forkManager() + + service = await initialize({ + database: { + clientUrl: DB_URL, + schema: process.env.MEDUSA_PROMOTION_DB_SCHEMA, + }, + }) + }) + + afterEach(async () => { + await MikroOrmWrapper.clearDatabase() + }) + + describe("createCampaigns", () => { + it("should throw an error when required params are not passed", async () => { + const error = await service + .createCampaigns([ + { + name: "test", + } as any, + ]) + .catch((e) => e) + + expect(error.message).toContain( + "Value for Campaign.campaign_identifier is required, 'undefined' found" + ) + }) + + it("should create a basic campaign successfully", async () => { + 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, + }, + ]) + + const campaign = await service.retrieveCampaign(createdCampaign.id) + + expect(campaign).toEqual( + expect.objectContaining({ + name: "test", + campaign_identifier: "test", + starts_at: startsAt, + ends_at: endsAt, + }) + ) + }) + + it("should create a campaign with campaign budget successfully", async () => { + const startsAt = new Date("01/01/2024") + const endsAt = new Date("01/01/2025") + + const [createdPromotion] = await service.createCampaigns([ + { + name: "test", + campaign_identifier: "test", + starts_at: startsAt, + ends_at: endsAt, + budget: { + limit: 1000, + type: "usage", + used: 10, + }, + }, + ]) + + const campaign = await service.retrieveCampaign(createdPromotion.id, { + relations: ["budget"], + }) + + expect(campaign).toEqual( + expect.objectContaining({ + name: "test", + campaign_identifier: "test", + starts_at: startsAt, + ends_at: endsAt, + budget: expect.objectContaining({ + limit: 1000, + type: "usage", + used: 10, + }), + }) + ) + }) + }) + + describe("updateCampaigns", () => { + it("should throw an error when required params are not passed", async () => { + const error = await service + .updateCampaigns([ + { + name: "test", + } as any, + ]) + .catch((e) => e) + + expect(error.message).toContain('Campaign with id "" not found') + }) + + it("should update the attributes of a campaign successfully", async () => { + await createCampaigns(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"), + }, + ]) + + 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"), + }) + ) + }) + + it("should update the attributes of a campaign budget successfully", async () => { + await createCampaigns(repositoryManager) + + const [updatedCampaign] = await service.updateCampaigns([ + { + id: "campaign-id-1", + budget: { + limit: 100, + used: 100, + }, + }, + ]) + + expect(updatedCampaign).toEqual( + expect.objectContaining({ + budget: expect.objectContaining({ + limit: 100, + used: 100, + }), + }) + ) + }) + }) + + describe("retrieveCampaign", () => { + beforeEach(async () => { + await createCampaigns(repositoryManager) + }) + + const id = "campaign-id-1" + + it("should return campaign for the given id", async () => { + const campaign = await service.retrieveCampaign(id) + + expect(campaign).toEqual( + expect.objectContaining({ + id, + }) + ) + }) + + it("should throw an error when campaign with id does not exist", async () => { + let error + + try { + await service.retrieveCampaign("does-not-exist") + } catch (e) { + error = e + } + + expect(error.message).toEqual( + "Campaign with id: does-not-exist was not found" + ) + }) + + it("should throw an error when a id is not provided", async () => { + let error + + try { + await service.retrieveCampaign(undefined as unknown as string) + } catch (e) { + error = e + } + + expect(error.message).toEqual('"campaignId" must be defined') + }) + + it("should return campaign based on config select param", async () => { + const campaign = await service.retrieveCampaign(id, { + select: ["id"], + }) + + const serialized = JSON.parse(JSON.stringify(campaign)) + + expect(serialized).toEqual({ + id, + }) + }) + }) + + describe("deleteCampaigns", () => { + beforeEach(async () => { + await createCampaigns(repositoryManager) + }) + + const id = "campaign-id-1" + + it("should delete the campaigns given an id successfully", async () => { + await service.deleteCampaigns([id]) + + const campaigns = await service.list({ + id: [id], + }) + + expect(campaigns).toHaveLength(0) + }) + }) +}) diff --git a/packages/promotion/jest.config.js b/packages/promotion/jest.config.js index 860ba90a49..a3d93259ca 100644 --- a/packages/promotion/jest.config.js +++ b/packages/promotion/jest.config.js @@ -3,6 +3,8 @@ module.exports = { "^@models": "/src/models", "^@services": "/src/services", "^@repositories": "/src/repositories", + "^@utils": "/src/utils", + "^@types": "/src/types", }, transform: { "^.+\\.[jt]s?$": [ diff --git a/packages/promotion/src/loaders/container.ts b/packages/promotion/src/loaders/container.ts index 39f3881115..9168c3c5a1 100644 --- a/packages/promotion/src/loaders/container.ts +++ b/packages/promotion/src/loaders/container.ts @@ -28,6 +28,10 @@ export default async ({ applicationMethodService: asClass( defaultServices.ApplicationMethodService ).singleton(), + campaignService: asClass(defaultServices.CampaignService).singleton(), + campaignBudgetService: asClass( + defaultServices.CampaignBudgetService + ).singleton(), }) if (customRepositories) { @@ -56,5 +60,11 @@ function loadDefaultRepositories({ container }) { promotionRuleValueRepository: asClass( defaultRepositories.PromotionRuleValueRepository ).singleton(), + campaignRepository: asClass( + defaultRepositories.CampaignRepository + ).singleton(), + campaignBudgetRepository: asClass( + defaultRepositories.CampaignBudgetRepository + ).singleton(), }) } diff --git a/packages/promotion/src/models/application-method.ts b/packages/promotion/src/models/application-method.ts index 6eab16e0e6..57931ba0c5 100644 --- a/packages/promotion/src/models/application-method.ts +++ b/packages/promotion/src/models/application-method.ts @@ -2,6 +2,7 @@ import { ApplicationMethodAllocationValues, ApplicationMethodTargetTypeValues, ApplicationMethodTypeValues, + DAL, } from "@medusajs/types" import { PromotionUtils, generateEntityId } from "@medusajs/utils" import { @@ -24,9 +25,8 @@ type OptionalFields = | "value" | "max_quantity" | "allocation" - | "created_at" - | "updated_at" | "deleted_at" + | DAL.EntityDateColumns @Entity() export default class ApplicationMethod { diff --git a/packages/promotion/src/models/campaign-budget.ts b/packages/promotion/src/models/campaign-budget.ts new file mode 100644 index 0000000000..ee0c447842 --- /dev/null +++ b/packages/promotion/src/models/campaign-budget.ts @@ -0,0 +1,81 @@ +import { CampaignBudgetTypeValues, DAL } from "@medusajs/types" +import { PromotionUtils, generateEntityId } from "@medusajs/utils" +import { + BeforeCreate, + Entity, + Enum, + Index, + OnInit, + OneToOne, + OptionalProps, + PrimaryKey, + Property, +} from "@mikro-orm/core" +import Campaign from "./campaign" + +type OptionalFields = + | "description" + | "limit" + | "used" + | "deleted_at" + | DAL.EntityDateColumns + +@Entity() +export default class CampaignBudget { + [OptionalProps]?: OptionalFields + + @PrimaryKey({ columnType: "text" }) + id!: string + + @Index({ name: "IDX_campaign_budget_type" }) + @Enum(() => PromotionUtils.CampaignBudgetType) + type: CampaignBudgetTypeValues + + @OneToOne({ + entity: () => Campaign, + }) + campaign?: Campaign + + @Property({ + columnType: "numeric", + nullable: true, + serializer: Number, + default: null, + }) + limit: number | null + + @Property({ + columnType: "numeric", + serializer: Number, + default: 0, + }) + used: number + + @Property({ + onCreate: () => new Date(), + columnType: "timestamptz", + defaultRaw: "now()", + }) + created_at: Date + + @Property({ + onCreate: () => new Date(), + onUpdate: () => new Date(), + columnType: "timestamptz", + defaultRaw: "now()", + }) + updated_at: Date + + @Property({ columnType: "timestamptz", nullable: true }) + deleted_at: Date | null + + @BeforeCreate() + onCreate() { + this.id = generateEntityId(this.id, "probudg") + } + + @OnInit() + onInit() { + this.id = generateEntityId(this.id, "probudg") + } +} diff --git a/packages/promotion/src/models/campaign.ts b/packages/promotion/src/models/campaign.ts new file mode 100644 index 0000000000..c721348e81 --- /dev/null +++ b/packages/promotion/src/models/campaign.ts @@ -0,0 +1,103 @@ +import { DAL } from "@medusajs/types" +import { generateEntityId } from "@medusajs/utils" +import { + BeforeCreate, + Collection, + Entity, + OnInit, + OneToMany, + OneToOne, + OptionalProps, + PrimaryKey, + Property, + Unique, +} from "@mikro-orm/core" +import CampaignBudget from "./campaign-budget" +import Promotion from "./promotion" + +type OptionalFields = + | "description" + | "currency" + | "starts_at" + | "ends_at" + | "deleted_at" + | DAL.EntityDateColumns + +type OptionalRelations = "budget" + +@Entity() +export default class Campaign { + [OptionalProps]?: OptionalFields | OptionalRelations + + @PrimaryKey({ columnType: "text" }) + id!: string + + @Property({ columnType: "text" }) + name: string + + @Property({ columnType: "text", nullable: true }) + description: string | null + + @Property({ columnType: "text", nullable: true }) + currency: string | null + + @Property({ columnType: "text" }) + @Unique({ + name: "IDX_campaign_identifier_unique", + properties: ["campaign_identifier"], + }) + campaign_identifier: string + + @Property({ + columnType: "timestamptz", + nullable: true, + }) + starts_at: Date | null + + @Property({ + columnType: "timestamptz", + nullable: true, + }) + ends_at: Date | null + + @OneToOne({ + entity: () => CampaignBudget, + mappedBy: (cb) => cb.campaign, + cascade: ["soft-remove"] as any, + nullable: true, + }) + budget: CampaignBudget | null + + @OneToMany(() => Promotion, (promotion) => promotion.campaign, { + orphanRemoval: true, + }) + promotions = new Collection(this) + + @Property({ + onCreate: () => new Date(), + columnType: "timestamptz", + defaultRaw: "now()", + }) + created_at: Date + + @Property({ + onCreate: () => new Date(), + onUpdate: () => new Date(), + columnType: "timestamptz", + defaultRaw: "now()", + }) + updated_at: Date + + @Property({ columnType: "timestamptz", nullable: true }) + deleted_at: Date | null + + @BeforeCreate() + onCreate() { + this.id = generateEntityId(this.id, "procamp") + } + + @OnInit() + onInit() { + this.id = generateEntityId(this.id, "procamp") + } +} diff --git a/packages/promotion/src/models/index.ts b/packages/promotion/src/models/index.ts index 1f6f773839..057dc73c77 100644 --- a/packages/promotion/src/models/index.ts +++ b/packages/promotion/src/models/index.ts @@ -1,4 +1,6 @@ export { default as ApplicationMethod } from "./application-method" +export { default as Campaign } from "./campaign" +export { default as CampaignBudget } from "./campaign-budget" export { default as Promotion } from "./promotion" export { default as PromotionRule } from "./promotion-rule" export { default as PromotionRuleValue } from "./promotion-rule-value" diff --git a/packages/promotion/src/models/promotion-rule.ts b/packages/promotion/src/models/promotion-rule.ts index dfe7311cd2..8241f8a6e7 100644 --- a/packages/promotion/src/models/promotion-rule.ts +++ b/packages/promotion/src/models/promotion-rule.ts @@ -1,4 +1,4 @@ -import { PromotionRuleOperatorValues } from "@medusajs/types" +import { DAL, PromotionRuleOperatorValues } from "@medusajs/types" import { PromotionUtils, generateEntityId } from "@medusajs/utils" import { BeforeCreate, @@ -18,7 +18,7 @@ import ApplicationMethod from "./application-method" import Promotion from "./promotion" import PromotionRuleValue from "./promotion-rule-value" -type OptionalFields = "description" | "created_at" | "updated_at" | "deleted_at" +type OptionalFields = "description" | "deleted_at" | DAL.EntityDateColumns type OptionalRelations = "values" | "promotions" @Entity() diff --git a/packages/promotion/src/models/promotion.ts b/packages/promotion/src/models/promotion.ts index 94af940a8e..6327e35b77 100644 --- a/packages/promotion/src/models/promotion.ts +++ b/packages/promotion/src/models/promotion.ts @@ -1,4 +1,4 @@ -import { PromotionType } from "@medusajs/types" +import { DAL, PromotionType } from "@medusajs/types" import { PromotionUtils, generateEntityId } from "@medusajs/utils" import { BeforeCreate, @@ -7,6 +7,7 @@ import { Enum, Index, ManyToMany, + ManyToOne, OnInit, OneToOne, OptionalProps, @@ -15,14 +16,12 @@ import { Unique, } from "@mikro-orm/core" import ApplicationMethod from "./application-method" +import Campaign from "./campaign" import PromotionRule from "./promotion-rule" -type OptionalFields = - | "is_automatic" - | "created_at" - | "updated_at" - | "deleted_at" -type OptionalRelations = "application_method" +type OptionalFields = "is_automatic" | "deleted_at" | DAL.EntityDateColumns +type OptionalRelations = "application_method" | "campaign" + @Entity() export default class Promotion { [OptionalProps]?: OptionalFields | OptionalRelations @@ -38,6 +37,13 @@ export default class Promotion { }) code: string + @ManyToOne(() => Campaign, { + joinColumn: "campaign", + fieldName: "campaign_id", + nullable: true, + }) + campaign: Campaign + @Property({ columnType: "boolean", default: false }) is_automatic?: boolean = false diff --git a/packages/promotion/src/repositories/campaign-budget.ts b/packages/promotion/src/repositories/campaign-budget.ts new file mode 100644 index 0000000000..32e4b98edd --- /dev/null +++ b/packages/promotion/src/repositories/campaign-budget.ts @@ -0,0 +1,11 @@ +import { DALUtils } from "@medusajs/utils" +import { CampaignBudget } from "@models" +import { CreateCampaignBudgetDTO, UpdateCampaignBudgetDTO } from "@types" + +export class CampaignBudgetRepository extends DALUtils.mikroOrmBaseRepositoryFactory< + CampaignBudget, + { + create: CreateCampaignBudgetDTO + update: UpdateCampaignBudgetDTO + } +>(CampaignBudget) {} diff --git a/packages/promotion/src/repositories/campaign.ts b/packages/promotion/src/repositories/campaign.ts new file mode 100644 index 0000000000..5bc683689b --- /dev/null +++ b/packages/promotion/src/repositories/campaign.ts @@ -0,0 +1,11 @@ +import { DALUtils } from "@medusajs/utils" +import { Campaign } from "@models" +import { CreateCampaignDTO, UpdateCampaignDTO } from "@types" + +export class CampaignRepository extends DALUtils.mikroOrmBaseRepositoryFactory< + Campaign, + { + create: CreateCampaignDTO + update: UpdateCampaignDTO + } +>(Campaign) {} diff --git a/packages/promotion/src/repositories/index.ts b/packages/promotion/src/repositories/index.ts index 421433c704..724cb68578 100644 --- a/packages/promotion/src/repositories/index.ts +++ b/packages/promotion/src/repositories/index.ts @@ -1,5 +1,7 @@ export { MikroOrmBaseRepository as BaseRepository } from "@medusajs/utils" export { ApplicationMethodRepository } from "./application-method" +export { CampaignRepository } from "./campaign" +export { CampaignBudgetRepository } from "./campaign-budget" export { PromotionRepository } from "./promotion" export { PromotionRuleRepository } from "./promotion-rule" export { PromotionRuleValueRepository } from "./promotion-rule-value" diff --git a/packages/promotion/src/services/campaign-budget.ts b/packages/promotion/src/services/campaign-budget.ts new file mode 100644 index 0000000000..affef9a89b --- /dev/null +++ b/packages/promotion/src/services/campaign-budget.ts @@ -0,0 +1,105 @@ +import { Context, DAL, FindConfig, PromotionTypes } from "@medusajs/types" +import { + InjectManager, + InjectTransactionManager, + MedusaContext, + ModulesSdkUtils, + retrieveEntity, +} from "@medusajs/utils" +import { CampaignBudget } from "@models" +import { CampaignBudgetRepository } from "@repositories" +import { CreateCampaignBudgetDTO, UpdateCampaignBudgetDTO } from "../types" + +type InjectedDependencies = { + campaignBudgetRepository: DAL.RepositoryService +} + +export default class CampaignBudgetService< + TEntity extends CampaignBudget = CampaignBudget +> { + protected readonly campaignBudgetRepository_: DAL.RepositoryService + + constructor({ campaignBudgetRepository }: InjectedDependencies) { + this.campaignBudgetRepository_ = campaignBudgetRepository + } + + @InjectManager("campaignBudgetRepository_") + async retrieve( + campaignBudgetId: string, + config: FindConfig = {}, + @MedusaContext() sharedContext: Context = {} + ): Promise { + return (await retrieveEntity< + CampaignBudget, + PromotionTypes.CampaignBudgetDTO + >({ + id: campaignBudgetId, + entityName: CampaignBudget.name, + repository: this.campaignBudgetRepository_, + config, + sharedContext, + })) as TEntity + } + + @InjectManager("campaignBudgetRepository_") + async list( + filters: PromotionTypes.FilterableCampaignBudgetProps = {}, + config: FindConfig = {}, + @MedusaContext() sharedContext: Context = {} + ): Promise { + const queryOptions = ModulesSdkUtils.buildQuery( + filters, + config + ) + + return (await this.campaignBudgetRepository_.find( + queryOptions, + sharedContext + )) as TEntity[] + } + + @InjectManager("campaignBudgetRepository_") + async listAndCount( + filters: PromotionTypes.FilterableCampaignBudgetProps = {}, + config: FindConfig = {}, + @MedusaContext() sharedContext: Context = {} + ): Promise<[TEntity[], number]> { + const queryOptions = ModulesSdkUtils.buildQuery( + filters, + config + ) + + return (await this.campaignBudgetRepository_.findAndCount( + queryOptions, + sharedContext + )) as [TEntity[], number] + } + + @InjectTransactionManager("campaignBudgetRepository_") + async create( + data: CreateCampaignBudgetDTO[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + return (await ( + this.campaignBudgetRepository_ as CampaignBudgetRepository + ).create(data, sharedContext)) as TEntity[] + } + + @InjectTransactionManager("campaignBudgetRepository_") + async update( + data: UpdateCampaignBudgetDTO[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + return (await ( + this.campaignBudgetRepository_ as CampaignBudgetRepository + ).update(data, sharedContext)) as TEntity[] + } + + @InjectTransactionManager("campaignBudgetRepository_") + async delete( + ids: string[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + await this.campaignBudgetRepository_.delete(ids, sharedContext) + } +} diff --git a/packages/promotion/src/services/campaign.ts b/packages/promotion/src/services/campaign.ts new file mode 100644 index 0000000000..e7cf2f6a1d --- /dev/null +++ b/packages/promotion/src/services/campaign.ts @@ -0,0 +1,96 @@ +import { Context, DAL, FindConfig, PromotionTypes } from "@medusajs/types" +import { + InjectManager, + InjectTransactionManager, + MedusaContext, + ModulesSdkUtils, + retrieveEntity, +} from "@medusajs/utils" +import { Campaign } from "@models" +import { CampaignRepository } from "@repositories" +import { CreateCampaignDTO, UpdateCampaignDTO } from "../types" + +type InjectedDependencies = { + campaignRepository: DAL.RepositoryService +} + +export default class CampaignService { + protected readonly campaignRepository_: DAL.RepositoryService + + constructor({ campaignRepository }: InjectedDependencies) { + this.campaignRepository_ = campaignRepository + } + + @InjectManager("campaignRepository_") + async retrieve( + campaignId: string, + config: FindConfig = {}, + @MedusaContext() sharedContext: Context = {} + ): Promise { + return (await retrieveEntity({ + id: campaignId, + entityName: Campaign.name, + repository: this.campaignRepository_, + config, + sharedContext, + })) as TEntity + } + + @InjectManager("campaignRepository_") + async list( + filters: PromotionTypes.FilterableCampaignProps = {}, + config: FindConfig = {}, + @MedusaContext() sharedContext: Context = {} + ): Promise { + const queryOptions = ModulesSdkUtils.buildQuery(filters, config) + + return (await this.campaignRepository_.find( + queryOptions, + sharedContext + )) as TEntity[] + } + + @InjectManager("campaignRepository_") + async listAndCount( + filters: PromotionTypes.FilterableCampaignProps = {}, + config: FindConfig = {}, + @MedusaContext() sharedContext: Context = {} + ): Promise<[TEntity[], number]> { + const queryOptions = ModulesSdkUtils.buildQuery(filters, config) + + return (await this.campaignRepository_.findAndCount( + queryOptions, + sharedContext + )) as [TEntity[], number] + } + + @InjectTransactionManager("campaignRepository_") + async create( + data: CreateCampaignDTO[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + return (await (this.campaignRepository_ as CampaignRepository).create( + data, + sharedContext + )) as TEntity[] + } + + @InjectTransactionManager("campaignRepository_") + async update( + data: UpdateCampaignDTO[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + return (await (this.campaignRepository_ as CampaignRepository).update( + data, + sharedContext + )) as TEntity[] + } + + @InjectTransactionManager("campaignRepository_") + async delete( + ids: string[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + await this.campaignRepository_.delete(ids, sharedContext) + } +} diff --git a/packages/promotion/src/services/index.ts b/packages/promotion/src/services/index.ts index e18681c6a4..dcd93f46b8 100644 --- a/packages/promotion/src/services/index.ts +++ b/packages/promotion/src/services/index.ts @@ -1,4 +1,6 @@ export { default as ApplicationMethodService } from "./application-method" +export { default as CampaignService } from "./campaign" +export { default as CampaignBudgetService } from "./campaign-budget" export { default as PromotionService } from "./promotion" export { default as PromotionModuleService } from "./promotion-module" export { default as PromotionRuleService } from "./promotion-rule" diff --git a/packages/promotion/src/services/promotion-module.ts b/packages/promotion/src/services/promotion-module.ts index 07d4d6eb68..275d74e265 100644 --- a/packages/promotion/src/services/promotion-module.ts +++ b/packages/promotion/src/services/promotion-module.ts @@ -17,25 +17,31 @@ import { import { ApplicationMethod, Promotion } from "@models" import { ApplicationMethodService, + CampaignBudgetService, + CampaignService, PromotionRuleService, PromotionRuleValueService, PromotionService, } from "@services" -import { joinerConfig } from "../joiner-config" import { CreateApplicationMethodDTO, + CreateCampaignBudgetDTO, + CreateCampaignDTO, CreatePromotionDTO, CreatePromotionRuleDTO, UpdateApplicationMethodDTO, + UpdateCampaignBudgetDTO, + UpdateCampaignDTO, UpdatePromotionDTO, -} from "../types" +} from "@types" import { ComputeActionUtils, allowedAllocationForQuantity, areRulesValidForContext, validateApplicationMethodAttributes, validatePromotionRuleAttributes, -} from "../utils" +} from "@utils" +import { joinerConfig } from "../joiner-config" type InjectedDependencies = { baseRepository: DAL.RepositoryService @@ -43,6 +49,8 @@ type InjectedDependencies = { applicationMethodService: ApplicationMethodService promotionRuleService: PromotionRuleService promotionRuleValueService: PromotionRuleValueService + campaignService: CampaignService + campaignBudgetService: CampaignBudgetService } export default class PromotionModuleService< @@ -54,6 +62,8 @@ export default class PromotionModuleService< protected applicationMethodService_: ApplicationMethodService protected promotionRuleService_: PromotionRuleService protected promotionRuleValueService_: PromotionRuleValueService + protected campaignService_: CampaignService + protected campaignBudgetService_: CampaignBudgetService constructor( { @@ -62,6 +72,8 @@ export default class PromotionModuleService< applicationMethodService, promotionRuleService, promotionRuleValueService, + campaignService, + campaignBudgetService, }: InjectedDependencies, protected readonly moduleDeclaration: InternalModuleDeclaration ) { @@ -70,6 +82,8 @@ export default class PromotionModuleService< this.applicationMethodService_ = applicationMethodService this.promotionRuleService_ = promotionRuleService this.promotionRuleValueService_ = promotionRuleValueService + this.campaignService_ = campaignService + this.campaignBudgetService_ = campaignBudgetService } __joinerConfig(): ModuleJoinerConfig { @@ -271,15 +285,28 @@ export default class PromotionModuleService< ) } - @InjectManager("baseRepository_") + async create( + data: PromotionTypes.CreatePromotionDTO, + sharedContext?: Context + ): Promise + async create( data: PromotionTypes.CreatePromotionDTO[], - @MedusaContext() sharedContext: Context = {} - ): Promise { - const promotions = await this.create_(data, sharedContext) + sharedContext?: Context + ): Promise - return await this.list( - { id: promotions.map((p) => p!.id) }, + @InjectManager("baseRepository_") + async create( + data: + | PromotionTypes.CreatePromotionDTO + | PromotionTypes.CreatePromotionDTO[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + const input = Array.isArray(data) ? data : [data] + const createdPromotions = await this.create_(input, sharedContext) + + const promotions = await this.list( + { id: createdPromotions.map((p) => p!.id) }, { relations: [ "application_method", @@ -291,6 +318,8 @@ export default class PromotionModuleService< }, sharedContext ) + + return Array.isArray(data) ? promotions : promotions[0] } @InjectTransactionManager("baseRepository_") @@ -399,15 +428,28 @@ export default class PromotionModuleService< return createdPromotions } - @InjectManager("baseRepository_") + async update( + data: PromotionTypes.UpdatePromotionDTO, + sharedContext?: Context + ): Promise + async update( data: PromotionTypes.UpdatePromotionDTO[], - @MedusaContext() sharedContext: Context = {} - ): Promise { - const promotions = await this.update_(data, sharedContext) + sharedContext?: Context + ): Promise - return await this.list( - { id: promotions.map((p) => p!.id) }, + @InjectManager("baseRepository_") + async update( + data: + | PromotionTypes.UpdatePromotionDTO + | PromotionTypes.UpdatePromotionDTO[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + const input = Array.isArray(data) ? data : [data] + const updatedPromotions = await this.update_(input, sharedContext) + + const promotions = await this.list( + { id: updatedPromotions.map((p) => p!.id) }, { relations: [ "application_method", @@ -418,6 +460,8 @@ export default class PromotionModuleService< }, sharedContext ) + + return Array.isArray(data) ? promotions : promotions[0] } @InjectTransactionManager("baseRepository_") @@ -587,10 +631,12 @@ export default class PromotionModuleService< @InjectTransactionManager("baseRepository_") async delete( - ids: string[], + ids: string | string[], @MedusaContext() sharedContext: Context = {} ): Promise { - await this.promotionService_.delete(ids, sharedContext) + const promotionIds = Array.isArray(ids) ? ids : [ids] + + await this.promotionService_.delete(promotionIds, sharedContext) } @InjectManager("baseRepository_") @@ -691,4 +737,214 @@ export default class PromotionModuleService< sharedContext ) } + + @InjectManager("baseRepository_") + async retrieveCampaign( + id: string, + config: FindConfig = {}, + @MedusaContext() sharedContext: Context = {} + ): Promise { + const campaign = await this.campaignService_.retrieve( + id, + config, + sharedContext + ) + + return await this.baseRepository_.serialize( + campaign, + { + populate: true, + } + ) + } + + @InjectManager("baseRepository_") + async listCampaigns( + filters: PromotionTypes.FilterableCampaignProps = {}, + config: FindConfig = {}, + @MedusaContext() sharedContext: Context = {} + ): Promise { + const campaigns = await this.campaignService_.list( + filters, + config, + sharedContext + ) + + return await this.baseRepository_.serialize( + campaigns, + { + populate: true, + } + ) + } + + async createCampaigns( + data: PromotionTypes.CreateCampaignDTO, + sharedContext?: Context + ): Promise + + async createCampaigns( + data: PromotionTypes.CreateCampaignDTO[], + sharedContext?: Context + ): Promise + + @InjectManager("baseRepository_") + async createCampaigns( + data: PromotionTypes.CreateCampaignDTO | PromotionTypes.CreateCampaignDTO[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + const input = Array.isArray(data) ? data : [data] + const createdCampaigns = await this.createCampaigns_(input, sharedContext) + + const campaigns = await this.listCampaigns( + { id: createdCampaigns.map((p) => p!.id) }, + { + relations: ["budget"], + }, + sharedContext + ) + + return Array.isArray(data) ? campaigns : campaigns[0] + } + + @InjectTransactionManager("baseRepository_") + protected async createCampaigns_( + data: PromotionTypes.CreateCampaignDTO[], + @MedusaContext() sharedContext: Context = {} + ) { + const campaignsData: CreateCampaignDTO[] = [] + const campaignBudgetsData: CreateCampaignBudgetDTO[] = [] + const campaignIdentifierBudgetMap = new Map< + string, + CreateCampaignBudgetDTO + >() + + for (const createCampaignData of data) { + const { budget: campaignBudgetData, ...campaignData } = createCampaignData + + if (campaignBudgetData) { + campaignIdentifierBudgetMap.set( + campaignData.campaign_identifier, + campaignBudgetData + ) + } + + campaignsData.push(campaignData) + } + + const createdCampaigns = await this.campaignService_.create( + campaignsData, + sharedContext + ) + + for (const createdCampaign of createdCampaigns) { + const campaignBudgetData = campaignIdentifierBudgetMap.get( + createdCampaign.campaign_identifier + ) + + if (campaignBudgetData) { + campaignBudgetsData.push({ + ...campaignBudgetData, + campaign: createdCampaign.id, + }) + } + } + + if (campaignBudgetsData.length) { + await this.campaignBudgetService_.create( + campaignBudgetsData, + sharedContext + ) + } + + return createdCampaigns + } + + async updateCampaigns( + data: PromotionTypes.UpdateCampaignDTO, + sharedContext?: Context + ): Promise + + async updateCampaigns( + data: PromotionTypes.UpdateCampaignDTO[], + sharedContext?: Context + ): Promise + + @InjectManager("baseRepository_") + async updateCampaigns( + data: PromotionTypes.UpdateCampaignDTO | PromotionTypes.UpdateCampaignDTO[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + const input = Array.isArray(data) ? data : [data] + + const updatedCampaigns = await this.updateCampaigns_(input, sharedContext) + + const campaigns = await this.listCampaigns( + { id: updatedCampaigns.map((p) => p!.id) }, + { + relations: ["budget"], + }, + sharedContext + ) + + return Array.isArray(data) ? campaigns : campaigns[0] + } + + @InjectTransactionManager("baseRepository_") + protected async updateCampaigns_( + data: PromotionTypes.UpdateCampaignDTO[], + @MedusaContext() sharedContext: Context = {} + ) { + const campaignIds = data.map((d) => d.id) + const campaignsData: UpdateCampaignDTO[] = [] + const campaignBudgetsData: UpdateCampaignBudgetDTO[] = [] + + const existingCampaigns = await this.listCampaigns( + { + id: campaignIds, + }, + { + relations: ["budget"], + }, + sharedContext + ) + + const existingCampaignsMap = new Map( + existingCampaigns.map((campaign) => [campaign.id, campaign]) + ) + + for (const updateCampaignData of data) { + const { budget: campaignBudgetData, ...campaignData } = updateCampaignData + + const existingCampaign = existingCampaignsMap.get(campaignData.id) + const existingCampaignBudget = existingCampaign?.budget + + campaignsData.push(campaignData) + + if (existingCampaignBudget && campaignBudgetData) { + campaignBudgetsData.push({ + id: existingCampaignBudget.id, + ...campaignBudgetData, + }) + } + } + + const updatedCampaigns = await this.campaignService_.update(campaignsData) + + if (campaignBudgetsData.length) { + await this.campaignBudgetService_.update(campaignBudgetsData) + } + + return updatedCampaigns + } + + @InjectTransactionManager("baseRepository_") + async deleteCampaigns( + ids: string | string[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + const campaignIds = Array.isArray(ids) ? ids : [ids] + + await this.promotionService_.delete(campaignIds, sharedContext) + } } diff --git a/packages/promotion/src/types/campaign-budget.ts b/packages/promotion/src/types/campaign-budget.ts new file mode 100644 index 0000000000..9bbdae5009 --- /dev/null +++ b/packages/promotion/src/types/campaign-budget.ts @@ -0,0 +1,16 @@ +import { CampaignBudgetTypeValues } from "@medusajs/types" +import { Campaign } from "@models" + +export interface CreateCampaignBudgetDTO { + type: CampaignBudgetTypeValues + limit: number | null + used?: number + campaign?: Campaign | string +} + +export interface UpdateCampaignBudgetDTO { + id: string + type?: CampaignBudgetTypeValues + limit?: number | null + used?: number +} diff --git a/packages/promotion/src/types/campaign.ts b/packages/promotion/src/types/campaign.ts new file mode 100644 index 0000000000..be55383763 --- /dev/null +++ b/packages/promotion/src/types/campaign.ts @@ -0,0 +1,18 @@ +export interface CreateCampaignDTO { + name: string + description?: string + currency?: string + campaign_identifier: string + starts_at: Date + ends_at: Date +} + +export interface UpdateCampaignDTO { + id: string + name?: string + description?: string + currency?: string + campaign_identifier?: string + starts_at?: Date + ends_at?: Date +} diff --git a/packages/promotion/src/types/index.ts b/packages/promotion/src/types/index.ts index 3891291eeb..145c717bae 100644 --- a/packages/promotion/src/types/index.ts +++ b/packages/promotion/src/types/index.ts @@ -5,6 +5,8 @@ export type InitializeModuleInjectableDependencies = { } export * from "./application-method" +export * from "./campaign" +export * from "./campaign-budget" export * from "./promotion" export * from "./promotion-rule" export * from "./promotion-rule-value" diff --git a/packages/types/src/promotion/common/campaign-budget.ts b/packages/types/src/promotion/common/campaign-budget.ts new file mode 100644 index 0000000000..0b2b103d58 --- /dev/null +++ b/packages/types/src/promotion/common/campaign-budget.ts @@ -0,0 +1,16 @@ +import { BaseFilterable } from "../../dal" + +export type CampaignBudgetTypeValues = "spend" | "usage" + +export interface CampaignBudgetDTO { + id: string + type?: CampaignBudgetTypeValues + limit?: string | null + used?: string +} + +export interface FilterableCampaignBudgetProps + extends BaseFilterable { + id?: string[] + type?: string[] +} diff --git a/packages/types/src/promotion/common/campaign.ts b/packages/types/src/promotion/common/campaign.ts new file mode 100644 index 0000000000..9034c2c94e --- /dev/null +++ b/packages/types/src/promotion/common/campaign.ts @@ -0,0 +1,19 @@ +import { BaseFilterable } from "../../dal" +import { CampaignBudgetDTO } from "./campaign-budget" + +export interface CampaignDTO { + id: string + name?: string + description?: string + currency?: string + campaign_identifier?: string + starts_at?: Date + ends_at?: Date + budget?: CampaignBudgetDTO +} + +export interface FilterableCampaignProps + extends BaseFilterable { + id?: string[] + campaign_identifier?: string[] +} diff --git a/packages/types/src/promotion/common/index.ts b/packages/types/src/promotion/common/index.ts index 11bae51b17..bac7566457 100644 --- a/packages/types/src/promotion/common/index.ts +++ b/packages/types/src/promotion/common/index.ts @@ -1,4 +1,6 @@ export * from "./application-method" +export * from "./campaign" +export * from "./campaign-budget" export * from "./compute-actions" export * from "./promotion" export * from "./promotion-rule" diff --git a/packages/types/src/promotion/index.ts b/packages/types/src/promotion/index.ts index eade309433..0c73656566 100644 --- a/packages/types/src/promotion/index.ts +++ b/packages/types/src/promotion/index.ts @@ -1,2 +1,3 @@ export * from "./common" +export * from "./mutations" export * from "./service" diff --git a/packages/types/src/promotion/mutations.ts b/packages/types/src/promotion/mutations.ts new file mode 100644 index 0000000000..a8e3fef9f5 --- /dev/null +++ b/packages/types/src/promotion/mutations.ts @@ -0,0 +1,35 @@ +import { CampaignBudgetTypeValues } from "./common" + +export interface CreateCampaignBudgetDTO { + type: CampaignBudgetTypeValues + limit: number | null + used?: number +} + +export interface UpdateCampaignBudgetDTO { + id: string + type?: CampaignBudgetTypeValues + limit?: number | null + used?: number +} + +export interface CreateCampaignDTO { + name: string + description?: string + currency?: string + campaign_identifier: string + starts_at: Date + ends_at: Date + budget?: CreateCampaignBudgetDTO +} + +export interface UpdateCampaignDTO { + id: string + name?: string + description?: string + currency?: string + campaign_identifier?: string + starts_at?: Date + ends_at?: Date + budget?: Omit +} diff --git a/packages/types/src/promotion/service.ts b/packages/types/src/promotion/service.ts index a22a54d9f9..9f35256ac0 100644 --- a/packages/types/src/promotion/service.ts +++ b/packages/types/src/promotion/service.ts @@ -2,16 +2,20 @@ import { FindConfig } from "../common" import { IModuleService } from "../modules-sdk" import { Context } from "../shared-context" import { + CampaignDTO, ComputeActionContext, ComputeActions, CreatePromotionDTO, CreatePromotionRuleDTO, + FilterableCampaignProps, FilterablePromotionProps, PromotionDTO, RemovePromotionRuleDTO, UpdatePromotionDTO, } from "./common" +import { CreateCampaignDTO, UpdateCampaignDTO } from "./mutations" + export interface IPromotionModuleService extends IModuleService { computeActions( promotionCodesToApply: string[], @@ -24,11 +28,21 @@ export interface IPromotionModuleService extends IModuleService { sharedContext?: Context ): Promise + create( + data: CreatePromotionDTO, + sharedContext?: Context + ): Promise + update( data: UpdatePromotionDTO[], sharedContext?: Context ): Promise + update( + data: UpdatePromotionDTO, + sharedContext?: Context + ): Promise + list( filters?: FilterablePromotionProps, config?: FindConfig, @@ -42,6 +56,7 @@ export interface IPromotionModuleService extends IModuleService { ): Promise delete(ids: string[], sharedContext?: Context): Promise + delete(ids: string, sharedContext?: Context): Promise addPromotionRules( promotionId: string, @@ -66,4 +81,39 @@ export interface IPromotionModuleService extends IModuleService { rulesData: RemovePromotionRuleDTO[], sharedContext?: Context ): Promise + + createCampaigns( + data: CreateCampaignDTO, + sharedContext?: Context + ): Promise + + createCampaigns( + data: CreateCampaignDTO[], + sharedContext?: Context + ): Promise + + updateCampaigns( + data: UpdateCampaignDTO[], + sharedContext?: Context + ): Promise + + updateCampaigns( + data: UpdateCampaignDTO, + sharedContext?: Context + ): Promise + + listCampaigns( + filters?: FilterableCampaignProps, + config?: FindConfig, + sharedContext?: Context + ): Promise + + retrieveCampaign( + id: string, + config?: FindConfig, + sharedContext?: Context + ): Promise + + deleteCampaigns(ids: string[], sharedContext?: Context): Promise + deleteCampaigns(ids: string, sharedContext?: Context): Promise } diff --git a/packages/utils/src/promotion/index.ts b/packages/utils/src/promotion/index.ts index 0094b41a84..b416a7cd96 100644 --- a/packages/utils/src/promotion/index.ts +++ b/packages/utils/src/promotion/index.ts @@ -28,3 +28,8 @@ export enum PromotionRuleOperator { NE = "ne", IN = "in", } + +export enum CampaignBudgetType { + SPEND = "spend", + USAGE = "usage", +}