feat: Add HTTP endpoints and workflows for price preference management (#7960)

REF CORE-2376

Remaining pieces are adding UI to manage the flag, showing the flag in price editor, plugging it in cart calculations, and https://github.com/medusajs/medusa/pull/7827
This commit is contained in:
Stevche Radevski
2024-07-05 10:47:01 +02:00
committed by GitHub
parent 1162b1625d
commit 3e86cb6ac3
27 changed files with 702 additions and 10 deletions

View File

@@ -0,0 +1,221 @@
import { medusaIntegrationTestRunner } from "medusa-test-utils"
import {
createAdminUser,
adminHeaders,
} from "../../../../helpers/create-admin-user"
jest.setTimeout(30000)
medusaIntegrationTestRunner({
env: {},
testSuite: ({ dbConnection, getContainer, api }) => {
let pricePreference1
let pricePreference2
beforeEach(async () => {
const container = getContainer()
await createAdminUser(dbConnection, adminHeaders, container)
pricePreference1 = (
await api.post(
"/admin/price-preferences",
{
attribute: "region_id",
value: "region-1",
is_tax_inclusive: true,
},
adminHeaders
)
).data.price_preference
pricePreference2 = (
await api.post(
"/admin/price-preferences",
{
attribute: "currency_code",
value: "EUR",
is_tax_inclusive: true,
},
adminHeaders
)
).data.price_preference
})
describe("/admin/price-preferences", () => {
describe("POST /admin/price-preferences", () => {
it("creates a price preference", async () => {
const newPricePreference = (
await api.post(
"/admin/price-preferences",
{
attribute: "region_id",
value: "region-2",
is_tax_inclusive: true,
},
adminHeaders
)
).data.price_preference
expect(newPricePreference).toEqual(
expect.objectContaining({
attribute: "region_id",
value: "region-2",
is_tax_inclusive: true,
})
)
})
it("creates a price preference with false tax inclusivity by default", async () => {
const newPricePreference = (
await api.post(
"/admin/price-preferences",
{
attribute: "region_id",
value: "region-2",
},
adminHeaders
)
).data.price_preference
expect(newPricePreference).toEqual(
expect.objectContaining({
attribute: "region_id",
value: "region-2",
is_tax_inclusive: false,
})
)
})
})
describe("GET /admin/price-preferences", () => {
it("returns a list of price preferences", async () => {
const response = (
await api.get("/admin/price-preferences", adminHeaders)
).data.price_preferences
expect(response).toEqual(
expect.arrayContaining([
expect.objectContaining({
attribute: "region_id",
value: "region-1",
is_tax_inclusive: true,
}),
expect.objectContaining({
attribute: "currency_code",
value: "EUR",
is_tax_inclusive: true,
}),
])
)
})
it("filters price preferences by attribute", async () => {
const response = (
await api.get(
"/admin/price-preferences?attribute=region_id",
adminHeaders
)
).data.price_preferences
expect(response).toEqual([
expect.objectContaining({
attribute: "region_id",
value: "region-1",
is_tax_inclusive: true,
}),
])
})
})
describe("GET /admin/price-preferences/:id", () => {
it("returns a price preference by :id", async () => {
const response = (
await api.get(
`/admin/price-preferences/${pricePreference1.id}`,
adminHeaders
)
).data.price_preference
expect(response).toEqual(
expect.objectContaining({
attribute: "region_id",
value: "region-1",
is_tax_inclusive: true,
})
)
})
})
describe("POST /admin/price-preferences/:id", () => {
it("updates a price preference", async () => {
const response = (
await api.post(
`/admin/price-preferences/${pricePreference1.id}`,
{
attribute: "region_id",
value: "region-2",
is_tax_inclusive: false,
},
adminHeaders
)
).data.price_preference
expect(response).toEqual(
expect.objectContaining({
attribute: "region_id",
value: "region-2",
is_tax_inclusive: false,
})
)
})
it("updates the tax inclusivity in the price preference", async () => {
const response = (
await api.post(
`/admin/price-preferences/${pricePreference1.id}`,
{
is_tax_inclusive: false,
},
adminHeaders
)
).data.price_preference
expect(response).toEqual(
expect.objectContaining({
attribute: "region_id",
value: "region-1",
is_tax_inclusive: false,
})
)
})
})
describe("DELETE /admin/price-preferences/:id", () => {
it("Deletes a price preference", async () => {
const deleteResponse = await api.delete(
`/admin/price-preferences/${pricePreference1.id}`,
adminHeaders
)
const remainingPricePreferences = (
await api.get("/admin/price-preferences", adminHeaders)
).data.price_preferences
expect(deleteResponse.data).toEqual(
expect.objectContaining({
id: pricePreference1.id,
object: "price_preference",
deleted: true,
})
)
expect(remainingPricePreferences).toEqual([
expect.objectContaining({
attribute: "currency_code",
value: "EUR",
is_tax_inclusive: true,
}),
])
})
})
})
},
})

View File

@@ -18,9 +18,9 @@ type OrderOutput =
metadata: any
}
export const getOrdersListlWorkflowId = "get-orders-list"
export const getOrdersListlWorkflow = createWorkflow(
getOrdersListlWorkflowId,
export const getOrdersListWorkflowId = "get-orders-list"
export const getOrdersListWorkflow = createWorkflow(
getOrdersListWorkflowId,
(
input: WorkflowData<{
fields: string[]

View File

@@ -1 +1,2 @@
export * from "./steps"
export * from "./workflows"

View File

@@ -0,0 +1,34 @@
import { IPricingModuleService } from "@medusajs/types"
import { PricingWorkflow } from "@medusajs/types/dist/workflow"
import { ModuleRegistrationName } from "@medusajs/utils"
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
type StepInput = PricingWorkflow.CreatePricePreferencesWorkflowInput[]
export const createPricePreferencesStepId = "create-price-preferences"
export const createPricePreferencesStep = createStep(
createPricePreferencesStepId,
async (data: StepInput, { container }) => {
const pricingModule = container.resolve<IPricingModuleService>(
ModuleRegistrationName.PRICING
)
const pricePreferences = await pricingModule.createPricePreferences(data)
return new StepResponse(
pricePreferences,
pricePreferences.map((pricePreference) => pricePreference.id)
)
},
async (pricePreferences, { container }) => {
if (!pricePreferences?.length) {
return
}
const pricingModule = container.resolve<IPricingModuleService>(
ModuleRegistrationName.PRICING
)
await pricingModule.deletePricePreferences(pricePreferences)
}
)

View File

@@ -0,0 +1,28 @@
import { IPricingModuleService } from "@medusajs/types"
import { ModuleRegistrationName } from "@medusajs/utils"
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
export const deletePricePreferencesStepId = "delete-price-preferences"
export const deletePricePreferencesStep = createStep(
deletePricePreferencesStepId,
async (ids: string[], { container }) => {
const service = container.resolve<IPricingModuleService>(
ModuleRegistrationName.PRICING
)
await service.softDeletePricePreferences(ids)
return new StepResponse(void 0, ids)
},
async (prevIds, { container }) => {
if (!prevIds?.length) {
return
}
const service = container.resolve<IPricingModuleService>(
ModuleRegistrationName.PRICING
)
await service.restorePricePreferences(prevIds)
}
)

View File

@@ -1,2 +1,5 @@
export * from "./create-price-sets"
export * from "./update-price-sets"
export * from "./create-price-preferences"
export * from "./update-price-preferences"
export * from "./delete-price-preferences"

View File

@@ -0,0 +1,45 @@
import { PricingWorkflow, IPricingModuleService } from "@medusajs/types"
import {
ModuleRegistrationName,
getSelectsAndRelationsFromObjectArray,
} from "@medusajs/utils"
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
type StepInput = PricingWorkflow.UpdatePricePreferencesWorkflowInput
export const updatePricePreferencesStepId = "update-price-preferences"
export const updatePricePreferencesStep = createStep(
updatePricePreferencesStepId,
async (input: StepInput, { container }) => {
const service = container.resolve<IPricingModuleService>(
ModuleRegistrationName.PRICING
)
const { selects, relations } = getSelectsAndRelationsFromObjectArray([
input.update,
])
const prevData = await service.listPricePreferences(input.selector, {
select: selects,
relations,
})
const updatedPricePreferences = await service.updatePricePreferences(
input.selector,
input.update
)
return new StepResponse(updatedPricePreferences, prevData)
},
async (prevData, { container }) => {
if (!prevData?.length) {
return
}
const service = container.resolve<IPricingModuleService>(
ModuleRegistrationName.PRICING
)
await service.upsertPricePreferences(prevData)
}
)

View File

@@ -0,0 +1,13 @@
import { PricingWorkflow } from "@medusajs/types"
import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk"
import { createPricePreferencesStep } from "../steps"
type WorkflowInputData = PricingWorkflow.CreatePricePreferencesWorkflowInput[]
export const createPricePreferencesWorkflowId = "create-price-preferences"
export const createPricePreferencesWorkflow = createWorkflow(
createPricePreferencesWorkflowId,
(input: WorkflowData<WorkflowInputData>) => {
return createPricePreferencesStep(input)
}
)

View File

@@ -0,0 +1,10 @@
import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk"
import { deletePricePreferencesStep } from "../steps"
export const deletePricePreferencesWorkflowId = "delete-price-preferences"
export const deletePricePreferencesWorkflow = createWorkflow(
deletePricePreferencesWorkflowId,
(input: WorkflowData<string[]>) => {
return deletePricePreferencesStep(input)
}
)

View File

@@ -0,0 +1,3 @@
export * from "./create-price-preferences"
export * from "./update-price-preferences"
export * from "./delete-price-preferences"

View File

@@ -0,0 +1,13 @@
import { PricingWorkflow } from "@medusajs/types"
import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk"
import { updatePricePreferencesStep } from "../steps"
type WorkflowInputData = PricingWorkflow.UpdatePricePreferencesWorkflowInput
export const updatePricePreferencesWorkflowId = "update-price-preferences"
export const updatePricePreferencesWorkflow = createWorkflow(
updatePricePreferencesWorkflowId,
(input: WorkflowData<WorkflowInputData>) => {
return updatePricePreferencesStep(input)
}
)

View File

@@ -29,3 +29,13 @@ export interface AdminPrice {
updated_at: string
deleted_at: string | null
}
export interface AdminPricePreference {
id: string
attribute: string | null
value: string | null
is_tax_inclusive: boolean
created_at: string
updated_at: string
deleted_at: null | string
}

View File

@@ -1 +1,4 @@
export * from "./entities"
export * from "./payloads"
export * from "./queries"
export * from "./responses"

View File

@@ -0,0 +1,11 @@
export interface AdminCreatePricePreference {
attribute?: string
value?: string
is_tax_inclusive?: boolean
}
export interface AdminUpdatePricePreference {
attribute?: string | null
value?: string | null
is_tax_inclusive?: boolean
}

View File

@@ -0,0 +1,12 @@
import { BaseFilterable } from "../../../dal"
import { FindParams, SelectParams } from "../../common"
export interface AdminPricePreferenceListParams
extends FindParams,
BaseFilterable<AdminPricePreferenceListParams> {
id?: string | string[]
attribute?: string | string[]
value?: string | string[]
}
export interface AdminPricePreferenceParams extends SelectParams {}

View File

@@ -0,0 +1,14 @@
import { DeleteResponse, PaginatedResponse } from "../../common"
import { AdminPricePreference } from "./entities"
export interface AdminPricePreferenceResponse {
price_preference: AdminPricePreference
}
export interface AdminPricePreferenceListResponse
extends PaginatedResponse<{
price_preferences: AdminPricePreference[]
}> {}
export interface AdminPricePreferenceDeleteResponse
extends DeleteResponse<"price_preference"> {}

View File

@@ -10,3 +10,4 @@ export * as RegionWorkflow from "./region"
export * as ReservationWorkflow from "./reservation"
export * as UserWorkflow from "./user"
export * as OrderWorkflow from "./order"
export * as PricingWorkflow from "./pricing"

View File

@@ -0,0 +1,18 @@
import { FilterablePricePreferenceProps } from "../../pricing"
export interface CreatePricePreferencesWorkflowInput {
attribute?: string
value?: string
is_tax_inclusive?: boolean
}
interface UpdatePricePreferences {
attribute?: string | null
value?: string | null
is_tax_inclusive?: boolean
}
export interface UpdatePricePreferencesWorkflowInput {
selector: FilterablePricePreferenceProps
update: UpdatePricePreferences
}

View File

@@ -1,4 +1,4 @@
import { getOrdersListlWorkflow } from "@medusajs/core-flows"
import { getOrdersListWorkflow } from "@medusajs/core-flows"
import { OrderDTO } from "@medusajs/types"
import {
AuthenticatedMedusaRequest,
@@ -17,7 +17,7 @@ export const GET = async (
...req.remoteQueryConfig.pagination,
}
const workflow = getOrdersListlWorkflow(req.scope)
const workflow = getOrdersListWorkflow(req.scope)
const { result } = await workflow.run({
input: {
fields: req.remoteQueryConfig.fields,

View File

@@ -0,0 +1,64 @@
import {
deletePricePreferencesWorkflow,
updatePricePreferencesWorkflow,
} from "@medusajs/core-flows"
import { HttpTypes } from "@medusajs/types"
import {
AuthenticatedMedusaRequest,
MedusaResponse,
} from "../../../../types/routing"
import { refetchEntity } from "../../../utils/refetch-entity"
export const GET = async (
req: AuthenticatedMedusaRequest,
res: MedusaResponse
) => {
const price_preference = await refetchEntity(
"price_preference",
req.params.id,
req.scope,
req.remoteQueryConfig.fields
)
res.status(200).json({ price_preference })
}
export const POST = async (
req: AuthenticatedMedusaRequest<HttpTypes.AdminUpdatePricePreference>,
res: MedusaResponse
) => {
const id = req.params.id
const workflow = updatePricePreferencesWorkflow(req.scope)
await workflow.run({
input: { selector: { id: [id] }, update: req.body },
})
const price_preference = await refetchEntity(
"price_preference",
id,
req.scope,
req.remoteQueryConfig.fields
)
res.status(200).json({ price_preference })
}
export const DELETE = async (
req: AuthenticatedMedusaRequest,
res: MedusaResponse
) => {
const id = req.params.id
const workflow = deletePricePreferencesWorkflow(req.scope)
await workflow.run({
input: [id],
})
res.status(200).json({
id,
object: "price_preference",
deleted: true,
})
}

View File

@@ -0,0 +1,55 @@
import { MiddlewareRoute } from "../../../loaders/helpers/routing/types"
import { validateAndTransformBody } from "../../utils/validate-body"
import { validateAndTransformQuery } from "../../utils/validate-query"
import * as QueryConfig from "./query-config"
import {
AdminCreatePricePreference,
AdminGetPricePreferenceParams,
AdminGetPricePreferencesParams,
AdminUpdatePricePreference,
} from "./validators"
export const adminPricePreferencesRoutesMiddlewares: MiddlewareRoute[] = [
{
method: ["GET"],
matcher: "/admin/price-preferences",
middlewares: [
validateAndTransformQuery(
AdminGetPricePreferencesParams,
QueryConfig.listPricePreferenceQueryConfig
),
],
},
{
method: ["GET"],
matcher: "/admin/price-preferences/:id",
middlewares: [
validateAndTransformQuery(
AdminGetPricePreferenceParams,
QueryConfig.retrivePricePreferenceQueryConfig
),
],
},
{
method: ["POST"],
matcher: "/admin/price-preferences",
middlewares: [
validateAndTransformBody(AdminCreatePricePreference),
validateAndTransformQuery(
AdminGetPricePreferenceParams,
QueryConfig.retrivePricePreferenceQueryConfig
),
],
},
{
method: ["POST"],
matcher: "/admin/price-preferences/:id",
middlewares: [
validateAndTransformBody(AdminUpdatePricePreference),
validateAndTransformQuery(
AdminGetPricePreferenceParams,
QueryConfig.retrivePricePreferenceQueryConfig
),
],
},
]

View File

@@ -0,0 +1,19 @@
export const adminPricePreferenceRemoteQueryFields = [
"id",
"attribute",
"value",
"is_tax_inclusive",
"created_at",
"deleted_at",
"updated_at",
]
export const retrivePricePreferenceQueryConfig = {
defaults: adminPricePreferenceRemoteQueryFields,
isList: false,
}
export const listPricePreferenceQueryConfig = {
...retrivePricePreferenceQueryConfig,
isList: true,
}

View File

@@ -0,0 +1,45 @@
import { HttpTypes } from "@medusajs/types"
import {
AuthenticatedMedusaRequest,
MedusaResponse,
} from "../../../types/routing"
import { refetchEntities, refetchEntity } from "../../utils/refetch-entity"
import { createPricePreferencesWorkflow } from "@medusajs/core-flows"
export const GET = async (
req: AuthenticatedMedusaRequest,
res: MedusaResponse
) => {
const { rows: price_preferences, metadata } = await refetchEntities(
"price_preference",
req.filterableFields,
req.scope,
req.remoteQueryConfig.fields,
req.remoteQueryConfig.pagination
)
res.json({
price_preferences: price_preferences,
count: metadata.count,
offset: metadata.skip,
limit: metadata.take,
})
}
export const POST = async (
req: AuthenticatedMedusaRequest<HttpTypes.AdminCreatePricePreference>,
res: MedusaResponse
) => {
const workflow = createPricePreferencesWorkflow(req.scope)
const { result } = await workflow.run({
input: [req.validatedBody],
})
const price_preference = await refetchEntity(
"price_preference",
result[0].id,
req.scope,
req.remoteQueryConfig.fields
)
res.status(200).json({ price_preference })
}

View File

@@ -0,0 +1,37 @@
import { z } from "zod"
import { createFindParams, createSelectParams } from "../../utils/validators"
export const AdminGetPricePreferenceParams = createSelectParams()
export const AdminGetPricePreferencesParams = createFindParams({
offset: 0,
limit: 50,
}).merge(
z.object({
q: z.string().optional(),
id: z.union([z.string(), z.array(z.string())]).optional(),
attribute: z.union([z.string(), z.array(z.string())]).optional(),
value: z.union([z.string(), z.array(z.string())]).optional(),
$and: z.lazy(() => AdminGetPricePreferencesParams.array()).optional(),
$or: z.lazy(() => AdminGetPricePreferencesParams.array()).optional(),
})
)
export const AdminCreatePricePreference = z.object({
attribute: z.string(),
value: z.string(),
is_tax_inclusive: z.boolean().optional(),
})
export type AdminCreatePricePreferencePriceType = z.infer<
typeof AdminCreatePricePreference
>
export const AdminUpdatePricePreference = z.object({
attribute: z.string().optional(),
value: z.string().optional(),
is_tax_inclusive: z.boolean().optional(),
})
export type AdminUpdatePricePreferenceType = z.infer<
typeof AdminUpdatePricePreference
>

View File

@@ -14,6 +14,7 @@ import { adminInviteRoutesMiddlewares } from "./admin/invites/middlewares"
import { adminOrderRoutesMiddlewares } from "./admin/orders/middlewares"
import { adminPaymentRoutesMiddlewares } from "./admin/payments/middlewares"
import { adminPriceListsRoutesMiddlewares } from "./admin/price-lists/middlewares"
import { adminPricePreferencesRoutesMiddlewares } from "./admin/price-preferences/middlewares"
import { adminProductCategoryRoutesMiddlewares } from "./admin/product-categories/middlewares"
import { adminProductTypeRoutesMiddlewares } from "./admin/product-types/middlewares"
import { adminProductTagRoutesMiddlewares } from "./admin/product-tags/middlewares"
@@ -78,6 +79,7 @@ export const config: MiddlewaresConfig = {
...adminProductRoutesMiddlewares,
...adminPaymentRoutesMiddlewares,
...adminPriceListsRoutesMiddlewares,
...adminPricePreferencesRoutesMiddlewares,
...adminInventoryRoutesMiddlewares,
...adminCollectionRoutesMiddlewares,
...adminShippingOptionRoutesMiddlewares,

View File

@@ -2,7 +2,7 @@ import { defineJoinerConfig, Modules } from "@medusajs/utils"
import { Price, PriceList, PricePreference, PriceSet } from "@models"
export const joinerConfig = defineJoinerConfig(Modules.PRICING, {
models: [PriceSet, PriceList, Price],
models: [PriceSet, PriceList, Price, PricePreference],
linkableKeys: {
price_set_id: PriceSet.name,
price_list_id: PriceList.name,

View File

@@ -734,7 +734,10 @@ export default class PricingModuleService
sharedContext
)
return await this.baseRepository_.serialize<any[]>(preferences)
const serialized = await this.baseRepository_.serialize<
PricePreferenceDTO[]
>(preferences)
return Array.isArray(data) ? serialized : serialized[0]
}
async upsertPricePreferences(
@@ -800,11 +803,38 @@ export default class PricingModuleService
data: PricingTypes.UpdatePricePreferenceDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<PricePreferenceDTO | PricePreferenceDTO[]> {
const preferences = await this.pricePreferenceService_.update(
data,
let normalizedInput: ServiceTypes.UpdatePricePreferenceInput[] = []
if (isString(idOrSelector)) {
// Check if the ID exists, it will throw if not.
await this.pricePreferenceService_.retrieve(
idOrSelector,
{},
sharedContext
)
normalizedInput = [{ id: idOrSelector, ...data }]
} else {
const pricePreferences = await this.pricePreferenceService_.list(
idOrSelector,
{},
sharedContext
)
normalizedInput = pricePreferences.map((pricePreference) => ({
id: pricePreference.id,
...data,
}))
}
const updateResult = await this.pricePreferenceService_.update(
normalizedInput,
sharedContext
)
return await this.baseRepository_.serialize<any[]>(preferences)
const pricePreferences = await this.baseRepository_.serialize<
PricePreferenceDTO[] | PricePreferenceDTO
>(updateResult)
return isString(idOrSelector) ? pricePreferences[0] : pricePreferences
}
@InjectTransactionManager("baseRepository_")