feat(core-flows,medusa,utils): promotion and campaign create/update endpoint (#6130)

what:

- adds create endpoint for promotions including workflows and endpoint (RESOLVES CORE-1678)
- adds update endpoint for promotions including workflows and endpoint (RESOLVES CORE-1679)
- adds create endpoint for campaigns including workflows and endpoint (RESOLVES CORE-1684)
- adds update endpoint for campaigns including workflows and endpoint (RESOLVES CORE-1685)
This commit is contained in:
Riqwan Thamir
2024-01-22 12:54:17 +01:00
committed by GitHub
parent a52586880c
commit da5cc4cf7f
30 changed files with 1240 additions and 50 deletions

View File

@@ -0,0 +1,7 @@
---
"@medusajs/core-flows": patch
"@medusajs/medusa": patch
"@medusajs/utils": patch
---
feat(core-flows,medusa,utils): promotion and campaign create/update endpoint

View File

@@ -0,0 +1,101 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { IPromotionModuleService } from "@medusajs/types"
import path from "path"
import { startBootstrapApp } from "../../../../environment-helpers/bootstrap-app"
import { useApi } from "../../../../environment-helpers/use-api"
import { getContainer } from "../../../../environment-helpers/use-container"
import { initDb, useDb } from "../../../../environment-helpers/use-db"
import adminSeeder from "../../../../helpers/admin-seeder"
const env = { MEDUSA_FF_MEDUSA_V2: true }
const adminHeaders = {
headers: { "x-medusa-access-token": "test_token" },
}
describe("POST /admin/campaigns", () => {
let dbConnection
let appContainer
let shutdownServer
let promotionModuleService: IPromotionModuleService
beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", "..", ".."))
dbConnection = await initDb({ cwd, env } as any)
shutdownServer = await startBootstrapApp({ cwd, env })
appContainer = getContainer()
promotionModuleService = appContainer.resolve(
ModuleRegistrationName.PROMOTION
)
})
afterAll(async () => {
const db = useDb()
await db.shutdown()
await shutdownServer()
})
beforeEach(async () => {
await adminSeeder(dbConnection)
})
afterEach(async () => {
const db = useDb()
await db.teardown()
})
it("should throw an error if required params are not passed", async () => {
const api = useApi() as any
const { response } = await api
.post(`/admin/campaigns`, {}, adminHeaders)
.catch((e) => e)
expect(response.status).toEqual(400)
expect(response.data.message).toEqual(
"name must be a string, name should not be empty"
)
})
it("should create a campaign successfully", async () => {
const createdPromotion = await promotionModuleService.create({
code: "TEST",
type: "standard",
})
const api = useApi() as any
const response = await api.post(
`/admin/campaigns`,
{
name: "test",
campaign_identifier: "test",
starts_at: new Date("01/01/2024").toISOString(),
ends_at: new Date("01/01/2029").toISOString(),
promotions: [{ id: createdPromotion.id }],
budget: {
limit: 1000,
type: "usage",
},
},
adminHeaders
)
expect(response.status).toEqual(200)
expect(response.data.campaign).toEqual(
expect.objectContaining({
id: expect.any(String),
name: "test",
campaign_identifier: "test",
starts_at: expect.any(String),
ends_at: expect.any(String),
budget: expect.objectContaining({
limit: 1000,
type: "usage",
}),
promotions: [
expect.objectContaining({
id: createdPromotion.id,
}),
],
})
)
})
})

View File

@@ -0,0 +1,151 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { IPromotionModuleService } from "@medusajs/types"
import { PromotionType } from "@medusajs/utils"
import path from "path"
import { startBootstrapApp } from "../../../../environment-helpers/bootstrap-app"
import { useApi } from "../../../../environment-helpers/use-api"
import { getContainer } from "../../../../environment-helpers/use-container"
import { initDb, useDb } from "../../../../environment-helpers/use-db"
import adminSeeder from "../../../../helpers/admin-seeder"
const env = { MEDUSA_FF_MEDUSA_V2: true }
const adminHeaders = {
headers: { "x-medusa-access-token": "test_token" },
}
describe("POST /admin/promotions", () => {
let dbConnection
let appContainer
let shutdownServer
let promotionModuleService: IPromotionModuleService
beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", "..", ".."))
dbConnection = await initDb({ cwd, env } as any)
shutdownServer = await startBootstrapApp({ cwd, env })
appContainer = getContainer()
promotionModuleService = appContainer.resolve(
ModuleRegistrationName.PROMOTION
)
})
afterAll(async () => {
const db = useDb()
await db.shutdown()
await shutdownServer()
})
beforeEach(async () => {
await adminSeeder(dbConnection)
})
afterEach(async () => {
const db = useDb()
await db.teardown()
})
it("should throw an error if required params are not passed", async () => {
const api = useApi() as any
const { response } = await api
.post(
`/admin/promotions`,
{
type: PromotionType.STANDARD,
},
adminHeaders
)
.catch((e) => e)
expect(response.status).toEqual(400)
expect(response.data.message).toEqual(
"code must be a string, code should not be empty, application_method should not be empty"
)
})
it("should create a promotion successfully", async () => {
const api = useApi() as any
const response = await api.post(
`/admin/promotions`,
{
code: "TEST",
type: PromotionType.STANDARD,
is_automatic: true,
campaign: {
name: "test",
campaign_identifier: "test-1",
budget: {
type: "usage",
limit: 100,
},
},
application_method: {
target_type: "items",
type: "fixed",
allocation: "each",
value: "100",
max_quantity: 100,
target_rules: [
{
attribute: "test.test",
operator: "eq",
values: ["test1", "test2"],
},
],
},
rules: [
{
attribute: "test.test",
operator: "eq",
values: ["test1", "test2"],
},
],
},
adminHeaders
)
expect(response.status).toEqual(200)
expect(response.data.promotion).toEqual(
expect.objectContaining({
id: expect.any(String),
code: "TEST",
type: "standard",
is_automatic: true,
campaign: expect.objectContaining({
name: "test",
campaign_identifier: "test-1",
budget: expect.objectContaining({
type: "usage",
limit: 100,
}),
}),
application_method: expect.objectContaining({
value: 100,
max_quantity: 100,
type: "fixed",
target_type: "items",
allocation: "each",
target_rules: [
expect.objectContaining({
operator: "eq",
attribute: "test.test",
values: expect.arrayContaining([
expect.objectContaining({ value: "test1" }),
expect.objectContaining({ value: "test2" }),
]),
}),
],
}),
rules: [
expect.objectContaining({
operator: "eq",
attribute: "test.test",
values: expect.arrayContaining([
expect.objectContaining({ value: "test1" }),
expect.objectContaining({ value: "test2" }),
]),
}),
],
})
)
})
})

View File

@@ -141,25 +141,27 @@ describe("GET /admin/campaigns", () => {
expect(response.status).toEqual(200)
expect(response.data.count).toEqual(2)
expect(response.data.campaigns).toEqual([
{
id: expect.any(String),
name: "campaign 1",
created_at: expect.any(String),
budget: {
expect(response.data.campaigns).toEqual(
expect.arrayContaining([
{
id: expect.any(String),
campaign: expect.any(Object),
name: "campaign 1",
created_at: expect.any(String),
budget: {
id: expect.any(String),
campaign: expect.any(Object),
},
},
},
{
id: expect.any(String),
name: "campaign 2",
created_at: expect.any(String),
budget: {
{
id: expect.any(String),
campaign: expect.any(Object),
name: "campaign 2",
created_at: expect.any(String),
budget: {
id: expect.any(String),
campaign: expect.any(Object),
},
},
},
])
])
)
})
})

View File

@@ -0,0 +1,115 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { IPromotionModuleService } from "@medusajs/types"
import path from "path"
import { startBootstrapApp } from "../../../../environment-helpers/bootstrap-app"
import { useApi } from "../../../../environment-helpers/use-api"
import { getContainer } from "../../../../environment-helpers/use-container"
import { initDb, useDb } from "../../../../environment-helpers/use-db"
import adminSeeder from "../../../../helpers/admin-seeder"
const env = { MEDUSA_FF_MEDUSA_V2: true }
const adminHeaders = {
headers: { "x-medusa-access-token": "test_token" },
}
describe("POST /admin/campaigns/:id", () => {
let dbConnection
let appContainer
let shutdownServer
let promotionModuleService: IPromotionModuleService
beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", "..", ".."))
dbConnection = await initDb({ cwd, env } as any)
shutdownServer = await startBootstrapApp({ cwd, env })
appContainer = getContainer()
promotionModuleService = appContainer.resolve(
ModuleRegistrationName.PROMOTION
)
})
afterAll(async () => {
const db = useDb()
await db.shutdown()
await shutdownServer()
})
beforeEach(async () => {
await adminSeeder(dbConnection)
})
afterEach(async () => {
const db = useDb()
await db.teardown()
})
it("should throw an error if id does not exist", async () => {
const api = useApi() as any
const { response } = await api
.post(`/admin/campaigns/does-not-exist`, {}, adminHeaders)
.catch((e) => e)
expect(response.status).toEqual(404)
expect(response.data.message).toEqual(
`Campaign with id "does-not-exist" not found`
)
})
it("should update a campaign successfully", async () => {
const createdPromotion = await promotionModuleService.create({
code: "TEST",
type: "standard",
})
const createdPromotion2 = await promotionModuleService.create({
code: "TEST_2",
type: "standard",
})
const createdCampaign = await promotionModuleService.createCampaigns({
name: "test",
campaign_identifier: "test",
starts_at: new Date("01/01/2024").toISOString(),
ends_at: new Date("01/01/2029").toISOString(),
promotions: [{ id: createdPromotion.id }],
budget: {
limit: 1000,
type: "usage",
used: 10,
},
})
const api = useApi() as any
const response = await api.post(
`/admin/campaigns/${createdCampaign.id}`,
{
name: "test-2",
campaign_identifier: "test-2",
budget: {
limit: 2000,
},
promotions: [{ id: createdPromotion2.id }],
},
adminHeaders
)
expect(response.status).toEqual(200)
expect(response.data.campaign).toEqual(
expect.objectContaining({
id: expect.any(String),
name: "test-2",
campaign_identifier: "test-2",
budget: expect.objectContaining({
limit: 2000,
type: "usage",
used: 10,
}),
promotions: [
expect.objectContaining({
id: createdPromotion2.id,
}),
],
})
)
})
})

View File

@@ -0,0 +1,135 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { IPromotionModuleService } from "@medusajs/types"
import { PromotionType } from "@medusajs/utils"
import path from "path"
import { startBootstrapApp } from "../../../../environment-helpers/bootstrap-app"
import { useApi } from "../../../../environment-helpers/use-api"
import { getContainer } from "../../../../environment-helpers/use-container"
import { initDb, useDb } from "../../../../environment-helpers/use-db"
import adminSeeder from "../../../../helpers/admin-seeder"
const env = { MEDUSA_FF_MEDUSA_V2: true }
const adminHeaders = {
headers: { "x-medusa-access-token": "test_token" },
}
describe("POST /admin/promotions/:id", () => {
let dbConnection
let appContainer
let shutdownServer
let promotionModuleService: IPromotionModuleService
beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", "..", ".."))
dbConnection = await initDb({ cwd, env } as any)
shutdownServer = await startBootstrapApp({ cwd, env })
appContainer = getContainer()
promotionModuleService = appContainer.resolve(
ModuleRegistrationName.PROMOTION
)
})
afterAll(async () => {
const db = useDb()
await db.shutdown()
await shutdownServer()
})
beforeEach(async () => {
await adminSeeder(dbConnection)
})
afterEach(async () => {
const db = useDb()
await db.teardown()
})
it("should throw an error if id does not exist", async () => {
const api = useApi() as any
const { response } = await api
.post(
`/admin/promotions/does-not-exist`,
{ type: PromotionType.STANDARD },
adminHeaders
)
.catch((e) => e)
expect(response.status).toEqual(404)
expect(response.data.message).toEqual(
`Promotion with id "does-not-exist" not found`
)
})
it("should throw an error when both campaign and campaign_id params are passed", async () => {
const createdPromotion = await promotionModuleService.create({
code: "TEST",
type: PromotionType.STANDARD,
is_automatic: true,
application_method: {
target_type: "items",
type: "fixed",
allocation: "each",
value: "100",
max_quantity: 100,
},
})
const api = useApi() as any
const { response } = await api
.post(
`/admin/promotions/${createdPromotion.id}`,
{
campaign: {
name: "test campaign",
},
campaign_id: "test",
},
adminHeaders
)
.catch((e) => e)
expect(response.status).toEqual(400)
expect(response.data.message).toContain(
`Failed XOR relation between "campaign_id" and "campaign"`
)
})
it("should update a promotion successfully", async () => {
const createdPromotion = await promotionModuleService.create({
code: "TEST",
type: PromotionType.STANDARD,
is_automatic: true,
application_method: {
target_type: "items",
type: "fixed",
allocation: "each",
value: "100",
max_quantity: 100,
},
})
const api = useApi() as any
const response = await api.post(
`/admin/promotions/${createdPromotion.id}`,
{
code: "TEST_TWO",
application_method: {
value: "200",
},
},
adminHeaders
)
expect(response.status).toEqual(200)
expect(response.data.promotion).toEqual(
expect.objectContaining({
id: expect.any(String),
code: "TEST_TWO",
application_method: expect.objectContaining({
value: 200,
}),
})
)
})
})

View File

@@ -1,4 +1,5 @@
export * from "./cart"
export * from "./product"
export * from "./inventory"
export * from "./price-list"
export * from "./product"
export * from "./promotion"

View File

@@ -0,0 +1,13 @@
import { CampaignDTO, CreateCampaignDTO } from "@medusajs/types"
import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk"
import { createCampaignsStep } from "../../handlers/promotion"
type WorkflowInput = { campaignsData: CreateCampaignDTO[] }
export const createCampaignsWorkflowId = "create-campaigns"
export const createCampaignsWorkflow = createWorkflow(
createCampaignsWorkflowId,
(input: WorkflowData<WorkflowInput>): WorkflowData<CampaignDTO[]> => {
return createCampaignsStep(input.campaignsData)
}
)

View File

@@ -0,0 +1,14 @@
import { CreatePromotionDTO, PromotionDTO } from "@medusajs/types"
import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk"
import { createPromotionsStep } from "../../handlers/promotion"
type WorkflowInput = { promotionsData: CreatePromotionDTO[] }
type WorkflowOutput = PromotionDTO[]
export const createPromotionsWorkflowId = "create-promotions"
export const createPromotionsWorkflow = createWorkflow(
createPromotionsWorkflowId,
(input: WorkflowData<WorkflowInput>): WorkflowData<PromotionDTO[]> => {
return createPromotionsStep(input.promotionsData)
}
)

View File

@@ -0,0 +1,4 @@
export * from "./create-campaigns"
export * from "./create-promotions"
export * from "./update-campaigns"
export * from "./update-promotions"

View File

@@ -0,0 +1,13 @@
import { CampaignDTO, UpdateCampaignDTO } from "@medusajs/types"
import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk"
import { updateCampaignsStep } from "../../handlers/promotion"
type WorkflowInput = { campaignsData: UpdateCampaignDTO[] }
export const updateCampaignsWorkflowId = "update-campaigns"
export const updateCampaignsWorkflow = createWorkflow(
updateCampaignsWorkflowId,
(input: WorkflowData<WorkflowInput>): WorkflowData<CampaignDTO[]> => {
return updateCampaignsStep(input.campaignsData)
}
)

View File

@@ -0,0 +1,13 @@
import { PromotionDTO, UpdatePromotionDTO } from "@medusajs/types"
import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk"
import { updatePromotionsStep } from "../../handlers/promotion"
type WorkflowInput = { promotionsData: UpdatePromotionDTO[] }
export const updatePromotionsWorkflowId = "update-promotions"
export const updatePromotionsWorkflow = createWorkflow(
updatePromotionsWorkflowId,
(input: WorkflowData<WorkflowInput>): WorkflowData<PromotionDTO[]> => {
return updatePromotionsStep(input.promotionsData)
}
)

View File

@@ -0,0 +1,31 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { CreateCampaignDTO, IPromotionModuleService } from "@medusajs/types"
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
export const createCampaignsStepId = "create-campaigns"
export const createCampaignsStep = createStep(
createCampaignsStepId,
async (data: CreateCampaignDTO[], { container }) => {
const promotionModule = container.resolve<IPromotionModuleService>(
ModuleRegistrationName.PROMOTION
)
const createdCampaigns = await promotionModule.createCampaigns(data)
return new StepResponse(
createdCampaigns,
createdCampaigns.map((createdCampaigns) => createdCampaigns.id)
)
},
async (createdCampaignIds, { container }) => {
if (!createdCampaignIds?.length) {
return
}
const promotionModule = container.resolve<IPromotionModuleService>(
ModuleRegistrationName.PROMOTION
)
await promotionModule.delete(createdCampaignIds)
}
)

View File

@@ -0,0 +1,31 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { CreatePromotionDTO, IPromotionModuleService } from "@medusajs/types"
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
export const createPromotionsStepId = "create-promotions"
export const createPromotionsStep = createStep(
createPromotionsStepId,
async (data: CreatePromotionDTO[], { container }) => {
const promotionModule = container.resolve<IPromotionModuleService>(
ModuleRegistrationName.PROMOTION
)
const createdPromotions = await promotionModule.create(data)
return new StepResponse(
createdPromotions,
createdPromotions.map((createdPromotions) => createdPromotions.id)
)
},
async (createdPromotionIds, { container }) => {
if (!createdPromotionIds?.length) {
return
}
const promotionModule = container.resolve<IPromotionModuleService>(
ModuleRegistrationName.PROMOTION
)
await promotionModule.delete(createdPromotionIds)
}
)

View File

@@ -0,0 +1,4 @@
export * from "./create-campaigns"
export * from "./create-promotions"
export * from "./update-campaigns"
export * from "./update-promotions"

View File

@@ -0,0 +1,37 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { IPromotionModuleService, UpdateCampaignDTO } from "@medusajs/types"
import { getSelectsAndRelationsFromObjectArray } from "@medusajs/utils"
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
export const updateCampaignsStepId = "update-campaigns"
export const updateCampaignsStep = createStep(
updateCampaignsStepId,
async (data: UpdateCampaignDTO[], { container }) => {
const promotionModule = container.resolve<IPromotionModuleService>(
ModuleRegistrationName.PROMOTION
)
const { selects, relations } = getSelectsAndRelationsFromObjectArray(data)
const dataBeforeUpdate = await promotionModule.listCampaigns(
{ id: data.map((d) => d.id) },
{ relations, select: selects }
)
const updatedCampaigns = await promotionModule.updateCampaigns(data)
return new StepResponse(updatedCampaigns, dataBeforeUpdate)
},
async (dataBeforeUpdate, { container }) => {
if (!dataBeforeUpdate) {
return
}
const promotionModule = container.resolve<IPromotionModuleService>(
ModuleRegistrationName.PROMOTION
)
// TODO: This still requires some sanitation of data and transformation of
// shapes for manytomany and oneToMany relations. Create a common util.
await promotionModule.updateCampaigns(dataBeforeUpdate)
}
)

View File

@@ -0,0 +1,37 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { IPromotionModuleService, UpdatePromotionDTO } from "@medusajs/types"
import { getSelectsAndRelationsFromObjectArray } from "@medusajs/utils"
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
export const updatePromotionsStepId = "update-promotions"
export const updatePromotionsStep = createStep(
updatePromotionsStepId,
async (data: UpdatePromotionDTO[], { container }) => {
const promotionModule = container.resolve<IPromotionModuleService>(
ModuleRegistrationName.PROMOTION
)
const { selects, relations } = getSelectsAndRelationsFromObjectArray(data)
const dataBeforeUpdate = await promotionModule.list(
{ id: data.map((d) => d.id) },
{ relations, select: selects }
)
const updatedPromotions = await promotionModule.update(data)
return new StepResponse(updatedPromotions, dataBeforeUpdate)
},
async (dataBeforeUpdate, { container }) => {
if (!dataBeforeUpdate) {
return
}
const promotionModule = container.resolve<IPromotionModuleService>(
ModuleRegistrationName.PROMOTION
)
// TODO: This still requires some sanitation of data and transformation of
// shapes for manytomany and oneToMany relations. Create a common util.
await promotionModule.update(dataBeforeUpdate)
}
)

View File

@@ -1,3 +1,4 @@
import { updateCampaignsWorkflow } from "@medusajs/core-flows"
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { IPromotionModuleService } from "@medusajs/types"
import { MedusaRequest, MedusaResponse } from "../../../../types/routing"
@@ -17,3 +18,24 @@ export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
res.status(200).json({ campaign })
}
export const POST = async (req: MedusaRequest, res: MedusaResponse) => {
const updateCampaigns = updateCampaignsWorkflow(req.scope)
const campaignsData = [
{
id: req.params.id,
...(req.validatedBody || {}),
},
]
const { result, errors } = await updateCampaigns.run({
input: { campaignsData },
throwOnError: false,
})
if (Array.isArray(errors) && errors[0]) {
throw errors[0].error
}
res.status(200).json({ campaign: result[0] })
}

View File

@@ -1,10 +1,16 @@
import { MedusaV2Flag } from "@medusajs/utils"
import { isFeatureFlagEnabled, transformQuery } from "../../../api/middlewares"
import {
isFeatureFlagEnabled,
transformBody,
transformQuery,
} from "../../../api/middlewares"
import { MiddlewareRoute } from "../../../loaders/helpers/routing/types"
import * as QueryConfig from "./query-config"
import {
AdminGetCampaignsCampaignParams,
AdminGetCampaignsParams,
AdminPostCampaignsCampaignReq,
AdminPostCampaignsReq,
} from "./validators"
export const adminCampaignRoutesMiddlewares: MiddlewareRoute[] = [
@@ -22,6 +28,11 @@ export const adminCampaignRoutesMiddlewares: MiddlewareRoute[] = [
),
],
},
{
method: ["POST"],
matcher: "/admin/campaigns",
middlewares: [transformBody(AdminPostCampaignsReq)],
},
{
method: ["GET"],
matcher: "/admin/campaigns/:id",
@@ -32,4 +43,9 @@ export const adminCampaignRoutesMiddlewares: MiddlewareRoute[] = [
),
],
},
{
method: ["POST"],
matcher: "/admin/campaigns/:id",
middlewares: [transformBody(AdminPostCampaignsCampaignReq)],
},
]

View File

@@ -1,5 +1,6 @@
import { createCampaignsWorkflow } from "@medusajs/core-flows"
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { IPromotionModuleService } from "@medusajs/types"
import { CreateCampaignDTO, IPromotionModuleService } from "@medusajs/types"
import { MedusaRequest, MedusaResponse } from "../../../types/routing"
export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
@@ -21,3 +22,19 @@ export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
limit,
})
}
export const POST = async (req: MedusaRequest, res: MedusaResponse) => {
const createCampaigns = createCampaignsWorkflow(req.scope)
const campaignsData = [req.validatedBody as CreateCampaignDTO]
const { result, errors } = await createCampaigns.run({
input: { campaignsData },
throwOnError: false,
})
if (Array.isArray(errors) && errors[0]) {
throw errors[0].error
}
res.status(200).json({ campaign: result[0] })
}

View File

@@ -1,4 +1,15 @@
import { IsOptional, IsString } from "class-validator"
import { CampaignBudgetType } from "@medusajs/utils"
import { Type } from "class-transformer"
import {
IsArray,
IsDateString,
IsEnum,
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
ValidateNested,
} from "class-validator"
import { FindParams, extendedFindParamsMixin } from "../../../types/common"
export class AdminGetCampaignsCampaignParams extends FindParams {}
@@ -15,3 +26,93 @@ export class AdminGetCampaignsParams extends extendedFindParamsMixin({
@IsOptional()
currency?: string
}
export class AdminPostCampaignsReq {
@IsNotEmpty()
@IsString()
name: string
@IsOptional()
@IsNotEmpty()
campaign_identifier?: string
@IsOptional()
@IsString()
description?: string
@IsOptional()
@IsString()
currency?: string
@IsOptional()
@ValidateNested()
@Type(() => CampaignBudget)
budget?: CampaignBudget
@IsOptional()
@IsDateString()
starts_at?: string
@IsOptional()
@IsDateString()
ends_at?: string
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => IdObject)
promotions?: IdObject[]
}
export class IdObject {
@IsString()
@IsNotEmpty()
id: string
}
export class CampaignBudget {
@IsOptional()
@IsEnum(CampaignBudgetType)
type?: CampaignBudgetType
@IsOptional()
@IsNumber()
limit?: number
}
export class AdminPostCampaignsCampaignReq {
@IsOptional()
@IsString()
name?: string
@IsOptional()
@IsNotEmpty()
campaign_identifier?: string
@IsOptional()
@IsString()
description?: string
@IsOptional()
@IsString()
currency?: string
@IsOptional()
@ValidateNested()
@Type(() => CampaignBudget)
budget?: CampaignBudget
@IsOptional()
@IsDateString()
starts_at?: string
@IsOptional()
@IsDateString()
ends_at?: string
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => IdObject)
promotions?: IdObject[]
}

View File

@@ -1,3 +1,4 @@
import { updatePromotionsWorkflow } from "@medusajs/core-flows"
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { IPromotionModuleService } from "@medusajs/types"
import { MedusaRequest, MedusaResponse } from "../../../../types/routing"
@@ -14,3 +15,24 @@ export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
res.status(200).json({ promotion })
}
export const POST = async (req: MedusaRequest, res: MedusaResponse) => {
const updatePromotions = updatePromotionsWorkflow(req.scope)
const promotionsData = [
{
id: req.params.id,
...(req.validatedBody || {}),
},
]
const { result, errors } = await updatePromotions.run({
input: { promotionsData },
throwOnError: false,
})
if (Array.isArray(errors) && errors[0]) {
throw errors[0].error
}
res.status(200).json({ promotion: result[0] })
}

View File

@@ -1,10 +1,17 @@
import { MedusaV2Flag } from "@medusajs/utils"
import { isFeatureFlagEnabled, transformQuery } from "../../../api/middlewares"
import {
isFeatureFlagEnabled,
transformBody,
transformQuery,
} from "../../../api/middlewares"
import { MiddlewareRoute } from "../../../loaders/helpers/routing/types"
import * as QueryConfig from "./query-config"
import {
AdminGetPromotionsParams,
AdminGetPromotionsPromotionParams,
AdminPostPromotionsPromotionReq,
AdminPostPromotionsReq,
} from "./validators"
export const adminPromotionRoutesMiddlewares: MiddlewareRoute[] = [
@@ -22,6 +29,11 @@ export const adminPromotionRoutesMiddlewares: MiddlewareRoute[] = [
),
],
},
{
method: ["POST"],
matcher: "/admin/promotions",
middlewares: [transformBody(AdminPostPromotionsReq)],
},
{
method: ["GET"],
matcher: "/admin/promotions/:id",
@@ -32,4 +44,9 @@ export const adminPromotionRoutesMiddlewares: MiddlewareRoute[] = [
),
],
},
{
method: ["POST"],
matcher: "/admin/promotions/:id",
middlewares: [transformBody(AdminPostPromotionsPromotionReq)],
},
]

View File

@@ -1,5 +1,6 @@
import { createPromotionsWorkflow } from "@medusajs/core-flows"
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { IPromotionModuleService } from "@medusajs/types"
import { CreatePromotionDTO, IPromotionModuleService } from "@medusajs/types"
import { MedusaRequest, MedusaResponse } from "../../../types/routing"
export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
@@ -21,3 +22,19 @@ export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
limit,
})
}
export const POST = async (req: MedusaRequest, res: MedusaResponse) => {
const createPromotions = createPromotionsWorkflow(req.scope)
const promotionsData = [req.validatedBody as CreatePromotionDTO]
const { result, errors } = await createPromotions.run({
input: { promotionsData },
throwOnError: false,
})
if (Array.isArray(errors) && errors[0]) {
throw errors[0].error
}
res.status(200).json({ promotion: result[0] })
}

View File

@@ -1,5 +1,25 @@
import { IsOptional, IsString } from "class-validator"
import {
ApplicationMethodAllocation,
ApplicationMethodTargetType,
ApplicationMethodType,
PromotionRuleOperator,
PromotionType,
} from "@medusajs/utils"
import { Type } from "class-transformer"
import {
IsArray,
IsBoolean,
IsEnum,
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
Validate,
ValidateNested,
} from "class-validator"
import { FindParams, extendedFindParamsMixin } from "../../../types/common"
import { XorConstraint } from "../../../types/validators/xor"
import { AdminPostCampaignsReq } from "../campaigns/validators"
export class AdminGetPromotionsPromotionParams extends FindParams {}
@@ -11,3 +31,122 @@ export class AdminGetPromotionsParams extends extendedFindParamsMixin({
@IsOptional()
code?: string
}
export class AdminPostPromotionsReq {
@IsNotEmpty()
@IsString()
code: string
@IsBoolean()
@IsOptional()
is_automatic?: boolean
@IsOptional()
@IsEnum(PromotionType)
type?: PromotionType
@IsOptional()
@IsString()
campaign_id?: string
@IsOptional()
@ValidateNested()
@Type(() => AdminPostCampaignsReq)
campaign?: AdminPostCampaignsReq
@IsNotEmpty()
@ValidateNested()
@Type(() => ApplicationMethod)
application_method: ApplicationMethod
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => PromotionRule)
rules?: PromotionRule[]
}
export class PromotionRule {
@IsEnum(PromotionRuleOperator)
operator: PromotionRuleOperator
@IsOptional()
@IsString()
description?: string | null
@IsNotEmpty()
@IsString()
attribute: string
@IsArray()
@Type(() => String)
values: string[]
}
export class ApplicationMethod {
@IsOptional()
@IsString()
description?: string
@IsOptional()
@IsString()
value?: string
@IsOptional()
@IsNumber()
max_quantity?: number
@IsOptional()
@IsEnum(ApplicationMethodType)
type?: ApplicationMethodType
@IsOptional()
@IsEnum(ApplicationMethodTargetType)
target_type?: ApplicationMethodTargetType
@IsOptional()
@IsEnum(ApplicationMethodAllocation)
allocation?: ApplicationMethodAllocation
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => PromotionRule)
target_rules?: PromotionRule[]
}
export class AdminPostPromotionsPromotionReq {
@IsOptional()
@IsString()
code?: string
@IsOptional()
@IsBoolean()
is_automatic?: boolean
@IsOptional()
@IsEnum(PromotionType)
type?: PromotionType
@IsOptional()
@Validate(XorConstraint, ["campaign"])
@IsString()
campaign_id?: string
@IsOptional()
@Validate(XorConstraint, ["campaign_id"])
@ValidateNested()
@Type(() => AdminPostCampaignsReq)
campaign?: AdminPostCampaignsReq
@IsOptional()
@ValidateNested()
@Type(() => ApplicationMethod)
application_method?: ApplicationMethod
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => PromotionRule)
rules?: PromotionRule[]
}

View File

@@ -1,17 +1,17 @@
import {
IsOptional,
IsString,
IsInt,
Min,
IsNotEmpty,
IsObject,
IsOptional,
IsString,
Min,
} from "class-validator"
import { Request, Response } from "express"
import { EntityManager } from "typeorm"
import { ProductCategoryService } from "../../../../services"
import { AdminProductCategoriesReqBase } from "../../../../types/product-category"
import { FindParams } from "../../../../types/common"
import { AdminProductCategoriesReqBase } from "../../../../types/product-category"
/**
* @oas [post] /admin/product-categories/{id}

View File

@@ -441,6 +441,7 @@ export default class PromotionModuleService<
"rules",
"rules.values",
"campaign",
"campaign.budget",
],
},
sharedContext
@@ -558,7 +559,7 @@ export default class PromotionModuleService<
}
}
await this.createPromotionRulesAndValues(
await this.createPromotionRulesAndValues_(
promotionCodeRulesDataMap.get(promotion.code) || [],
"promotions",
promotion,
@@ -577,7 +578,7 @@ export default class PromotionModuleService<
}
for (const applicationMethod of createdApplicationMethods) {
await this.createPromotionRulesAndValues(
await this.createPromotionRulesAndValues_(
applicationMethodRuleMap.get(applicationMethod.promotion.id) || [],
"application_methods",
applicationMethod,
@@ -614,9 +615,11 @@ export default class PromotionModuleService<
relations: [
"application_method",
"application_method.target_rules",
"application_method.target_rules.values",
"rules",
"rules.values",
"campaign",
"campaign.budget",
],
},
sharedContext
@@ -686,7 +689,10 @@ export default class PromotionModuleService<
existingApplicationMethod.max_quantity,
})
applicationMethodsData.push(applicationMethodData)
applicationMethodsData.push({
...applicationMethodData,
id: existingApplicationMethod.id,
})
}
const updatedPromotions = this.promotionService_.update(
@@ -705,7 +711,6 @@ export default class PromotionModuleService<
}
@InjectManager("baseRepository_")
@InjectTransactionManager("baseRepository_")
async addPromotionRules(
promotionId: string,
rulesData: PromotionTypes.CreatePromotionRuleDTO[],
@@ -713,20 +718,21 @@ export default class PromotionModuleService<
): Promise<PromotionTypes.PromotionDTO> {
const promotion = await this.promotionService_.retrieve(promotionId)
await this.createPromotionRulesAndValues(
await this.createPromotionRulesAndValues_(
rulesData,
"promotions",
promotion,
sharedContext
)
return this.retrieve(promotionId, {
relations: ["rules", "rules.values"],
})
return this.retrieve(
promotionId,
{ relations: ["rules", "rules.values"] },
sharedContext
)
}
@InjectManager("baseRepository_")
@InjectTransactionManager("baseRepository_")
async addPromotionTargetRules(
promotionId: string,
rulesData: PromotionTypes.CreatePromotionRuleDTO[],
@@ -745,29 +751,34 @@ export default class PromotionModuleService<
)
}
await this.createPromotionRulesAndValues(
await this.createPromotionRulesAndValues_(
rulesData,
"application_methods",
applicationMethod,
sharedContext
)
return this.retrieve(promotionId, {
relations: [
"rules",
"rules.values",
"application_method",
"application_method.target_rules",
"application_method.target_rules.values",
],
})
return this.retrieve(
promotionId,
{
relations: [
"rules",
"rules.values",
"application_method",
"application_method.target_rules",
"application_method.target_rules.values",
],
},
sharedContext
)
}
protected async createPromotionRulesAndValues(
@InjectTransactionManager("baseRepository_")
protected async createPromotionRulesAndValues_(
rulesData: PromotionTypes.CreatePromotionRuleDTO[],
relationName: "promotions" | "application_methods",
relation: Promotion | ApplicationMethod,
sharedContext: Context
@MedusaContext() sharedContext: Context = {}
) {
validatePromotionRuleAttributes(rulesData)
@@ -789,7 +800,10 @@ export default class PromotionModuleService<
promotion_rule: createdPromotionRule,
}))
await this.promotionRuleValueService_.create(promotionRuleValuesData)
await this.promotionRuleValueService_.create(
promotionRuleValuesData,
sharedContext
)
}
}
@@ -980,7 +994,7 @@ export default class PromotionModuleService<
const campaigns = await this.listCampaigns(
{ id: createdCampaigns.map((p) => p!.id) },
{
relations: ["budget"],
relations: ["budget", "promotions"],
},
sharedContext
)

View File

@@ -0,0 +1,62 @@
import { getSelectsAndRelationsFromObjectArray } from "../get-selects-and-relations-from-object-array"
describe("getSelectsAndRelationsFromObjectArray", function () {
it("should return true or false for different types of data", function () {
const expectations = [
{
input: [
{
attr_string: "string",
attr_boolean: true,
attr_null: null,
attr_undefined: undefined,
attr_object: {
attr_string: "string",
attr_boolean: true,
attr_null: null,
attr_undefined: undefined,
},
attr_array: [
{
attr_object: {
attr_string: "string",
attr_boolean: true,
attr_null: null,
attr_undefined: undefined,
},
},
{
attr_object: {
attr_string: "string",
},
},
],
},
],
output: {
selects: [
"attr_string",
"attr_boolean",
"attr_null",
"attr_undefined",
"attr_object.attr_string",
"attr_object.attr_boolean",
"attr_object.attr_null",
"attr_object.attr_undefined",
"attr_array.attr_object.attr_string",
"attr_array.attr_object.attr_boolean",
"attr_array.attr_object.attr_null",
"attr_array.attr_object.attr_undefined",
],
relations: ["attr_object", "attr_array", "attr_array.attr_object"],
},
},
]
expectations.forEach((expectation) => {
expect(getSelectsAndRelationsFromObjectArray(expectation.input)).toEqual(
expectation.output
)
})
})
})

View File

@@ -0,0 +1,53 @@
import { deduplicate } from "./deduplicate"
import { isObject } from "./is-object"
export function getSelectsAndRelationsFromObjectArray(
dataArray: object[],
prefix?: string
): {
selects: string[]
relations: string[]
} {
const selects: string[] = []
const relations: string[] = []
for (const data of dataArray) {
for (const [key, value] of Object.entries(data)) {
if (isObject(value)) {
relations.push(setKey(key, prefix))
const res = getSelectsAndRelationsFromObjectArray(
[value],
setKey(key, prefix)
)
selects.push(...res.selects)
relations.push(...res.relations)
} else if (Array.isArray(value)) {
relations.push(setKey(key, prefix))
const res = getSelectsAndRelationsFromObjectArray(
value,
setKey(key, prefix)
)
selects.push(...res.selects)
relations.push(...res.relations)
} else {
selects.push(setKey(key, prefix))
}
}
}
const uniqueSelects: string[] = deduplicate(selects)
const uniqueRelations: string[] = deduplicate(relations)
return {
selects: uniqueSelects,
relations: uniqueRelations,
}
}
function setKey(key: string, prefix?: string) {
if (prefix) {
return `${prefix}.${key}`
} else {
return key
}
}

View File

@@ -9,6 +9,7 @@ export * from "./errors"
export * from "./generate-entity-id"
export * from "./get-config-file"
export * from "./get-iso-string-from-date"
export * from "./get-selects-and-relations-from-object-array"
export * from "./group-by"
export * from "./handle-postgres-database-error"
export * from "./is-date"