feat(medusa,types): added store apis for products (#7144)

what:

- adds products list api
- adds products retrieve api

RESOLVES CORE-2016
This commit is contained in:
Riqwan Thamir
2024-04-29 17:14:41 +02:00
committed by GitHub
parent 32c2a9d76b
commit 11517f0faf
24 changed files with 980 additions and 48 deletions

View File

@@ -0,0 +1,6 @@
---
"@medusajs/medusa": patch
"@medusajs/types": patch
---
feat(medusa,types): added store apis for products

View File

@@ -0,0 +1,408 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { ApiKeyType, ProductStatus } from "@medusajs/utils"
import { medusaIntegrationTestRunner } from "medusa-test-utils"
import { createAdminUser } from "../../../../helpers/create-admin-user"
import { createDefaultRuleTypes } from "../../../helpers/create-default-rule-types"
jest.setTimeout(50000)
const env = { MEDUSA_FF_MEDUSA_V2: true }
const adminHeaders = { headers: { "x-medusa-access-token": "test_token" } }
medusaIntegrationTestRunner({
env,
testSuite: ({ dbConnection, getContainer, api }) => {
describe("Store: Products API", () => {
let appContainer
let product
let product2
let product3
let product4
let variant
let variant2
let variant3
let variant4
const createProducts = async (data) => {
const response = await api.post(
"/admin/products?fields=*variants",
data,
adminHeaders
)
return [response.data.product, response.data.product.variants || []]
}
const createSalesChannel = async (data, productIds) => {
const response = await api.post(
"/admin/sales-channels",
data,
adminHeaders
)
const salesChannel = response.data.sales_channel
await api.post(
`/admin/sales-channels/${salesChannel.id}/products`,
{ add: productIds },
adminHeaders
)
return salesChannel
}
beforeAll(async () => {
appContainer = getContainer()
})
beforeEach(async () => {
await createAdminUser(dbConnection, adminHeaders, appContainer)
await createDefaultRuleTypes(appContainer)
})
describe("GET /store/products", () => {
beforeEach(async () => {
;[product, [variant]] = await createProducts({
title: "test product 1",
status: ProductStatus.PUBLISHED,
variants: [
{
title: "test variant 1",
prices: [{ amount: 3000, currency_code: "usd" }],
},
],
})
;[product2, [variant2]] = await createProducts({
title: "test product 2 uniquely",
status: ProductStatus.PUBLISHED,
variants: [{ title: "test variant 2", prices: [] }],
})
;[product3, [variant3]] = await createProducts({
title: "product not in price list",
status: ProductStatus.PUBLISHED,
variants: [{ title: "test variant 3", prices: [] }],
})
;[product4, [variant4]] = await createProducts({
title: "draft product",
status: ProductStatus.DRAFT,
variants: [{ title: "test variant 4", prices: [] }],
})
const defaultSalesChannel = await createSalesChannel(
{ name: "default sales channel" },
[product.id, product2.id, product3.id, product4.id]
)
const service = appContainer.resolve(ModuleRegistrationName.STORE)
const [store] = await service.list()
if (store) {
await service.delete(store.id)
}
await service.create({
supported_currency_codes: ["usd", "dkk"],
default_currency_code: "usd",
default_sales_channel_id: defaultSalesChannel.id,
})
})
it("should list all published products", async () => {
let response = await api.get(`/store/products`)
expect(response.status).toEqual(200)
expect(response.data.count).toEqual(3)
expect(response.data.products).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: product.id,
}),
expect.objectContaining({
id: product2.id,
}),
expect.objectContaining({
id: product3.id,
}),
])
)
response = await api.get(`/store/products?q=uniquely`)
expect(response.status).toEqual(200)
expect(response.data.count).toEqual(1)
expect(response.data.products).toEqual([
expect.objectContaining({
id: product2.id,
}),
])
})
it("should list all products for a sales channel", async () => {
const salesChannel = await createSalesChannel(
{ name: "sales channel test" },
[product.id]
)
let response = await api.get(
`/store/products?sales_channel_id[]=${salesChannel.id}`
)
expect(response.status).toEqual(200)
expect(response.data.count).toEqual(1)
expect(response.data.products).toEqual([
expect.objectContaining({
id: product.id,
}),
])
})
describe("with publishable keys", () => {
let salesChannel1
let salesChannel2
let publishableKey1
beforeEach(async () => {
salesChannel1 = await createSalesChannel(
{ name: "sales channel test" },
[product.id]
)
salesChannel2 = await createSalesChannel(
{ name: "sales channel test 2" },
[product2.id]
)
const api1Res = await api.post(
`/admin/api-keys`,
{ title: "Test publishable KEY", type: ApiKeyType.PUBLISHABLE },
adminHeaders
)
publishableKey1 = api1Res.data.api_key
await api.post(
`/admin/api-keys/${publishableKey1.id}/sales-channels`,
{ add: [salesChannel1.id] },
adminHeaders
)
})
it("should list all products for a sales channel", async () => {
let response = await api.get(
`/store/products?sales_channel_id[]=${salesChannel1.id}`,
{ headers: { "x-publishable-api-key": publishableKey1.token } }
)
expect(response.status).toEqual(200)
expect(response.data.count).toEqual(1)
expect(response.data.products).toEqual([
expect.objectContaining({
id: product.id,
}),
])
})
it("should throw error when publishable key is invalid", async () => {
let error = await api
.get(`/store/products?sales_channel_id[]=does-not-exist`, {
headers: { "x-publishable-api-key": "does-not-exist" },
})
.catch((e) => e)
expect(error.response.status).toEqual(400)
expect(error.response.data).toEqual({
message: `Publishable API key not found`,
type: "invalid_data",
})
})
it("should throw error when sales channel does not exist", async () => {
let error = await api
.get(`/store/products?sales_channel_id[]=does-not-exist`, {
headers: { "x-publishable-api-key": publishableKey1.token },
})
.catch((e) => e)
expect(error.response.status).toEqual(400)
expect(error.response.data).toEqual({
message: `Invalid sales channel filters provided - does-not-exist`,
type: "invalid_data",
})
})
it("should throw error when sales channel not in publishable key", async () => {
let error = await api
.get(`/store/products?sales_channel_id[]=${salesChannel2.id}`, {
headers: { "x-publishable-api-key": publishableKey1.token },
})
.catch((e) => e)
expect(error.response.status).toEqual(400)
expect(error.response.data).toEqual({
message: `Invalid sales channel filters provided - ${salesChannel2.id}`,
type: "invalid_data",
})
})
})
it("should throw error when calculating prices without context", async () => {
let error = await api
.get(`/store/products?fields=*variants.calculated_price`)
.catch((e) => e)
expect(error.response.status).toEqual(400)
expect(error.response.data).toEqual({
message:
"Pricing parameters (currency_code or region_id) are required to calculate prices",
type: "invalid_data",
})
})
it("should list products with prices when context is present", async () => {
let response = await api.get(
`/store/products?fields=*variants.calculated_price&currency_code=usd`
)
expect(response.status).toEqual(200)
expect(response.data.count).toEqual(3)
expect(response.data.products).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: product.id,
variants: [
expect.objectContaining({
calculated_price: {
id: expect.any(String),
is_calculated_price_price_list: false,
calculated_amount: 3000,
is_original_price_price_list: false,
original_amount: 3000,
currency_code: "usd",
calculated_price: {
id: expect.any(String),
price_list_id: null,
price_list_type: null,
min_quantity: null,
max_quantity: null,
},
original_price: {
id: expect.any(String),
price_list_id: null,
price_list_type: null,
min_quantity: null,
max_quantity: null,
},
},
}),
],
}),
])
)
})
})
describe("GET /store/products/:id", () => {
beforeEach(async () => {
;[product, [variant]] = await createProducts({
title: "test product 1",
status: ProductStatus.PUBLISHED,
variants: [
{
title: "test variant 1",
prices: [{ amount: 3000, currency_code: "usd" }],
},
],
})
const defaultSalesChannel = await createSalesChannel(
{ name: "default sales channel" },
[product.id]
)
const service = appContainer.resolve(ModuleRegistrationName.STORE)
const [store] = await service.list()
if (store) {
await service.delete(store.id)
}
await service.create({
supported_currency_codes: ["usd", "dkk"],
default_currency_code: "usd",
default_sales_channel_id: defaultSalesChannel.id,
})
})
it("should retrieve product successfully", async () => {
let response = await api.get(`/store/products/${product.id}`)
expect(response.status).toEqual(200)
expect(response.data.product).toEqual(
expect.objectContaining({
id: product.id,
variants: [
expect.objectContaining({
id: expect.any(String),
}),
],
})
)
})
it("should throw error when calculating prices without context", async () => {
let error = await api
.get(
`/store/products/${product.id}?fields=*variants.calculated_price`
)
.catch((e) => e)
expect(error.response.status).toEqual(400)
expect(error.response.data).toEqual({
message:
"Pricing parameters (currency_code or region_id) are required to calculate prices",
type: "invalid_data",
})
})
it("should get product with prices when context is present", async () => {
let response = await api.get(
`/store/products/${product.id}?fields=*variants.calculated_price&currency_code=usd`
)
expect(response.status).toEqual(200)
expect(response.data.product).toEqual(
expect.objectContaining({
id: product.id,
variants: [
expect.objectContaining({
calculated_price: {
id: expect.any(String),
is_calculated_price_price_list: false,
calculated_amount: 3000,
is_original_price_price_list: false,
original_amount: 3000,
currency_code: "usd",
calculated_price: {
id: expect.any(String),
price_list_id: null,
price_list_type: null,
min_quantity: null,
max_quantity: null,
},
original_price: {
id: expect.any(String),
price_list_id: null,
price_list_type: null,
min_quantity: null,
max_quantity: null,
},
},
}),
],
})
)
})
})
})
},
})

View File

@@ -1,6 +1,6 @@
import { ProductStatus } from "@medusajs/utils"
import { z } from "zod"
import { optionalBooleanMapper } from "../../../utils/validators/is-boolean"
import { GetProductsParams } from "../../utils/common-validators"
import {
createFindParams,
createOperatorMap,
@@ -13,50 +13,6 @@ export const AdminGetProductParams = createSelectParams()
export const AdminGetProductVariantParams = createSelectParams()
export const AdminGetProductOptionParams = createSelectParams()
export type AdminGetProductsParamsType = z.infer<typeof AdminGetProductsParams>
export const AdminGetProductsParams = createFindParams({
offset: 0,
limit: 50,
}).merge(
z.object({
q: z.string().optional(),
id: z.union([z.string(), z.array(z.string())]).optional(),
status: statusEnum.array().optional(),
title: z.string().optional(),
handle: z.string().optional(),
is_giftcard: z.preprocess(
(val: any) => optionalBooleanMapper.get(val?.toLowerCase()),
z.boolean().optional()
),
category_id: z.string().array().optional(),
price_list_id: z.string().array().optional(),
sales_channel_id: z.string().array().optional(),
collection_id: z.string().array().optional(),
tags: z.string().array().optional(),
type_id: z.string().array().optional(),
// TODO: Replace this with AdminGetProductVariantsParams when its available
variants: z.record(z.unknown()).optional(),
created_at: createOperatorMap().optional(),
updated_at: createOperatorMap().optional(),
deleted_at: createOperatorMap().optional(),
$and: z.lazy(() => AdminGetProductsParams.array()).optional(),
$or: z.lazy(() => AdminGetProductsParams.array()).optional(),
})
)
// TODO: These were part of the products find query, add them once supported
// @IsString()
// @IsOptional()
// discount_condition_id?: string
// @IsArray()
// @IsOptional()
// category_id?: string[]
// @IsBoolean()
// @IsOptional()
// @Transform(({ value }) => optionalBooleanMapper.get(value.toLowerCase()))
// include_category_children?: boolean
export type AdminGetProductVariantsParamsType = z.infer<
typeof AdminGetProductVariantsParams
>
@@ -77,6 +33,21 @@ export const AdminGetProductVariantsParams = createFindParams({
})
)
export type AdminGetProductsParamsType = z.infer<typeof AdminGetProductsParams>
export const AdminGetProductsParams = createFindParams({
offset: 0,
limit: 50,
}).merge(
z
.object({
variants: AdminGetProductVariantsParams.optional(),
price_list_id: z.string().array().optional(),
$and: z.lazy(() => AdminGetProductsParams.array()).optional(),
$or: z.lazy(() => AdminGetProductsParams.array()).optional(),
})
.merge(GetProductsParams)
)
export type AdminGetProductOptionsParamsType = z.infer<
typeof AdminGetProductOptionsParams
>

View File

@@ -36,6 +36,7 @@ import { hooksRoutesMiddlewares } from "./hooks/middlewares"
import { storeCartRoutesMiddlewares } from "./store/carts/middlewares"
import { storeCurrencyRoutesMiddlewares } from "./store/currencies/middlewares"
import { storeCustomerRoutesMiddlewares } from "./store/customers/middlewares"
import { storeProductRoutesMiddlewares } from "./store/products/middlewares"
import { storeRegionRoutesMiddlewares } from "./store/regions/middlewares"
export const config: MiddlewaresConfig = {
@@ -80,5 +81,6 @@ export const config: MiddlewaresConfig = {
...adminShippingProfilesMiddlewares,
...adminFulfillmentsRoutesMiddlewares,
...adminFulfillmentProvidersRoutesMiddlewares,
...storeProductRoutesMiddlewares,
],
}

View File

@@ -0,0 +1,26 @@
import { isPresent } from "@medusajs/utils"
import { MedusaRequest, MedusaResponse } from "../../../../types/routing"
import { refetchProduct } from "../helpers"
import { StoreGetProductsParamsType } from "../validators"
export const GET = async (
req: MedusaRequest<StoreGetProductsParamsType>,
res: MedusaResponse
) => {
const context = isPresent(req.pricingContext)
? {
"variants.calculated_price": { context: req.pricingContext },
}
: undefined
const product = await refetchProduct(
{
id: req.params.id,
context,
},
req.scope,
req.remoteQueryConfig.fields
)
res.json({ product })
}

View File

@@ -0,0 +1,34 @@
import { MedusaContainer } from "@medusajs/types"
import { isPresent } from "@medusajs/utils"
import { refetchEntity } from "../../utils/refetch-entity"
import { StoreGetProductsParamsType } from "./validators"
// For category filters, we only allow showcasing public and active categories
// TODO: This should ideally be done in the middleware, write a generic filter to conditionally
// map these values or normalize the filters to the ones expected by remote query
export function wrapWithCategoryFilters(filters: StoreGetProductsParamsType) {
const categoriesFilter = isPresent(filters.category_id)
? {
categories: {
...filters.category_id,
is_internal: false,
is_active: true,
},
}
: {}
delete filters.category_id
return {
...filters,
...categoriesFilter,
}
}
export const refetchProduct = async (
idOrFilter: string | object,
scope: MedusaContainer,
fields: string[]
) => {
return await refetchEntity("product", idOrFilter, scope, fields)
}

View File

@@ -0,0 +1,67 @@
import { ProductStatus } from "@medusajs/utils"
import { MiddlewareRoute } from "../../../loaders/helpers/routing/types"
import { authenticate } from "../../../utils/authenticate-middleware"
import { maybeApplyLinkFilter } from "../../utils/maybe-apply-link-filter"
import {
applyDefaultFilters,
filterByValidSalesChannels,
setPricingContext,
} from "../../utils/middlewares"
import { validateAndTransformQuery } from "../../utils/validate-query"
import * as QueryConfig from "./query-config"
import {
StoreGetProductsParams,
StoreGetProductsParamsType,
} from "./validators"
export const storeProductRoutesMiddlewares: MiddlewareRoute[] = [
{
method: "ALL",
matcher: "/store/products*",
middlewares: [
authenticate("store", ["session", "bearer"], {
allowUnauthenticated: true,
}),
],
},
{
method: ["GET"],
matcher: "/store/products",
middlewares: [
validateAndTransformQuery(
StoreGetProductsParams,
QueryConfig.listProductQueryConfig
),
filterByValidSalesChannels(),
maybeApplyLinkFilter({
entryPoint: "product_sales_channel",
resourceId: "product_id",
filterableField: "sales_channel_id",
}),
applyDefaultFilters<StoreGetProductsParamsType>({
status: ProductStatus.PUBLISHED,
}),
setPricingContext(),
],
},
{
method: ["GET"],
matcher: "/store/products/:id",
middlewares: [
validateAndTransformQuery(
StoreGetProductsParams,
QueryConfig.retrieveProductQueryConfig
),
filterByValidSalesChannels(),
maybeApplyLinkFilter({
entryPoint: "product_sales_channel",
resourceId: "product_id",
filterableField: "sales_channel_id",
}),
applyDefaultFilters<StoreGetProductsParamsType>({
status: ProductStatus.PUBLISHED,
}),
setPricingContext(),
],
},
]

View File

@@ -0,0 +1,41 @@
export const defaultStoreProductFields = [
"id",
"title",
"subtitle",
"description",
"handle",
"is_giftcard",
"discountable",
"thumbnail",
"collection_id",
"type_id",
"weight",
"length",
"height",
"width",
"hs_code",
"origin_country",
"mid_code",
"material",
"created_at",
"updated_at",
"*type",
"*collection",
"*options",
"*options.values",
"*tags",
"*images",
"*variants",
"*variants.options",
]
export const retrieveProductQueryConfig = {
defaults: defaultStoreProductFields,
isList: false,
}
export const listProductQueryConfig = {
...retrieveProductQueryConfig,
defaultLimit: 50,
isList: true,
}

View File

@@ -0,0 +1,39 @@
import {
ContainerRegistrationKeys,
isPresent,
remoteQueryObjectFromString,
} from "@medusajs/utils"
import { MedusaRequest, MedusaResponse } from "../../../types/routing"
import { wrapWithCategoryFilters } from "./helpers"
import { StoreGetProductsParamsType } from "./validators"
export const GET = async (
req: MedusaRequest<StoreGetProductsParamsType>,
res: MedusaResponse
) => {
const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY)
const context = isPresent(req.pricingContext)
? {
"variants.calculated_price": { context: req.pricingContext },
}
: undefined
const queryObject = remoteQueryObjectFromString({
entryPoint: "product",
variables: {
filters: wrapWithCategoryFilters(req.filterableFields),
...context,
...req.remoteQueryConfig.pagination,
},
fields: req.remoteQueryConfig.fields,
})
const { rows: products, metadata } = await remoteQuery(queryObject)
res.json({
products,
count: metadata.count,
offset: metadata.skip,
limit: metadata.take,
})
}

View File

@@ -0,0 +1,48 @@
import { z } from "zod"
import {
GetProductsParams,
ProductStatusEnum,
} from "../../utils/common-validators"
import {
createFindParams,
createOperatorMap,
createSelectParams,
} from "../../utils/validators"
export const StoreGetProductParams = createSelectParams()
export type StoreGetProductVariantsParamsType = z.infer<
typeof StoreGetProductVariantsParams
>
export const StoreGetProductVariantsParams = createFindParams({
offset: 0,
limit: 50,
}).merge(
z.object({
q: z.string().optional(),
id: z.union([z.string(), z.array(z.string())]).optional(),
status: ProductStatusEnum.array().optional(),
created_at: createOperatorMap().optional(),
updated_at: createOperatorMap().optional(),
deleted_at: createOperatorMap().optional(),
$and: z.lazy(() => StoreGetProductsParams.array()).optional(),
$or: z.lazy(() => StoreGetProductsParams.array()).optional(),
})
)
export type StoreGetProductsParamsType = z.infer<typeof StoreGetProductsParams>
export const StoreGetProductsParams = createFindParams({
offset: 0,
limit: 50,
}).merge(
z
.object({
region_id: z.string().optional(),
currency_code: z.string().optional(),
variants: StoreGetProductVariantsParams.optional(),
$and: z.lazy(() => StoreGetProductsParams.array()).optional(),
$or: z.lazy(() => StoreGetProductsParams.array()).optional(),
})
.merge(GetProductsParams)
.strict()
)

View File

@@ -1,4 +1,5 @@
import { z } from "zod"
import { optionalBooleanMapper } from "../../../utils/validators/is-boolean"
export const AddressPayload = z
.object({
@@ -24,3 +25,8 @@ export const BigNumberInput = z.union([
precision: z.number(),
}),
])
export const OptionalBooleanValidator = z.preprocess(
(val: any) => optionalBooleanMapper.get(val?.toLowerCase()),
z.boolean().optional()
)

View File

@@ -0,0 +1,2 @@
export * from "./common"
export * from "./products"

View File

@@ -0,0 +1,22 @@
import { ProductStatus } from "@medusajs/utils"
import { z } from "zod"
import { createOperatorMap } from "../../validators"
import { OptionalBooleanValidator } from "../common"
export const ProductStatusEnum = z.nativeEnum(ProductStatus)
export const GetProductsParams = z.object({
q: z.string().optional(),
id: z.union([z.string(), z.array(z.string())]).optional(),
title: z.string().optional(),
handle: z.string().optional(),
is_giftcard: OptionalBooleanValidator,
category_id: z.string().array().optional(),
sales_channel_id: z.string().array().optional(),
collection_id: z.string().array().optional(),
tags: z.string().array().optional(),
type_id: z.string().array().optional(),
created_at: createOperatorMap().optional(),
updated_at: createOperatorMap().optional(),
deleted_at: createOperatorMap().optional(),
})

View File

@@ -0,0 +1,27 @@
import { isObject } from "@medusajs/utils"
import { NextFunction } from "express"
import { MedusaRequest } from "../../../../types/routing"
export function applyDefaultFilters<TFilter extends object>(filters: TFilter) {
return async (req: MedusaRequest, _, next: NextFunction) => {
const filterableFields = req.filterableFields || {}
for (const [filter, filterValue] of Object.entries(filters)) {
let existingFilter = filterableFields[filter]
if (existingFilter && isObject(existingFilter)) {
// If an existing filter is found, append to it
filterableFields[filter] = {
...existingFilter,
[filter]: filterValue,
}
} else {
filterableFields[filter] = filterValue
}
}
req.filterableFields = filterableFields
return next()
}
}

View File

@@ -0,0 +1 @@
export * from "./apply-default-filters"

View File

@@ -0,0 +1,2 @@
export * from "./common"
export * from "./products"

View File

@@ -0,0 +1,79 @@
import { isPresent, MedusaError } from "@medusajs/utils"
import { NextFunction } from "express"
import { AuthenticatedMedusaRequest } from "../../../../types/routing"
import { refetchEntity } from "../../refetch-entity"
export function filterByValidSalesChannels() {
return async (req: AuthenticatedMedusaRequest, _, next: NextFunction) => {
const publishableApiKey = req.get("x-publishable-api-key")
const salesChannelIds = req.filterableFields.sales_channel_id as
| string[]
| undefined
const store = await refetchEntity("stores", {}, req.scope, [
"default_sales_channel_id",
])
if (!store) {
try {
throw new MedusaError(MedusaError.Types.INVALID_DATA, `Store not found`)
} catch (e) {
return next(e)
}
}
// Always set sales channels in the following priority
// - any existing sales chennel ids passed through query params
// - if none, we set the default sales channel
req.filterableFields.sales_channel_id = salesChannelIds ?? [
store.default_sales_channel_id,
]
// Return early if no publishable keys are found
if (!isPresent(publishableApiKey)) {
return next()
}
// When publishable keys are present, we fetch for all sales chennels attached
// to the publishable key and validate the sales channel filter against it
const apiKey = await refetchEntity(
"api_key",
{ token: publishableApiKey },
req.scope,
["id", "sales_channels_link.sales_channel_id"]
)
if (!apiKey) {
try {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Publishable API key not found`
)
} catch (e) {
return next(e)
}
}
const validSalesChannelIds = apiKey.sales_channels_link.map(
(link) => link.sales_channel_id
)
const invalidSalesChannelIds = (salesChannelIds || []).filter(
(id) => !validSalesChannelIds.includes(id)
)
if (invalidSalesChannelIds.length) {
try {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Invalid sales channel filters provided - ${invalidSalesChannelIds.join(
", "
)}`
)
} catch (e) {
return next(e)
}
}
return next()
}
}

View File

@@ -0,0 +1,2 @@
export * from "./filter-by-valid-sales-channels"
export * from "./set-pricing-context"

View File

@@ -0,0 +1,89 @@
import { MedusaPricingContext } from "@medusajs/types"
import { isPresent, MedusaError } from "@medusajs/utils"
import { NextFunction } from "express"
import { AuthenticatedMedusaRequest } from "../../../../types/routing"
import { refetchEntities, refetchEntity } from "../../refetch-entity"
export function setPricingContext() {
return async (req: AuthenticatedMedusaRequest, _, next: NextFunction) => {
// If the endpoint doesn't request prices, we can exit early
if (
!req.remoteQueryConfig.fields.some((field) =>
field.startsWith("variants.calculated_price")
)
) {
delete req.filterableFields.region_id
delete req.filterableFields.currency_code
return next()
}
const query = req.filterableFields || {}
const pricingContext: MedusaPricingContext = {}
const customerId = req.user?.customer_id
if (query.region_id) {
const region = await refetchEntity("region", query.region_id, req.scope, [
"id",
"currency_code",
])
if (region) {
pricingContext.region_id = region.id
}
if (region?.currency_code) {
pricingContext.currency_code = region.currency_code
}
delete req.filterableFields.region_id
}
// If a currency code is explicitly passed, we should be using that instead of the
// regions currency code
if (query.currency_code) {
const currency = await refetchEntity(
"currency",
{ code: query.currency_code },
req.scope,
["code"]
)
if (currency) {
pricingContext.currency_code = currency.code
}
delete req.filterableFields.currency_code
}
// Find all the customer groups the customer is a part of and set
if (customerId) {
const customerGroups = await refetchEntities(
"customer_group",
{ customer_id: customerId },
req.scope,
["id"]
)
pricingContext.customer_group_id = customerGroups.map((cg) => cg.id)
delete req.filterableFields.customer_id
}
// If a region or currency_code is not present in the context, we will not be able to calculate prices
if (!isPresent(pricingContext)) {
try {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Pricing parameters (currency_code or region_id) are required to calculate prices`
)
} catch (e) {
return next(e)
}
}
req.pricingContext = pricingContext
return next()
}
}

View File

@@ -0,0 +1,46 @@
import { MedusaContainer } from "@medusajs/types"
import {
ContainerRegistrationKeys,
isString,
remoteQueryObjectFromString,
} from "@medusajs/utils"
export const refetchEntities = async (
entryPoint: string,
idOrFilter: string | object,
scope: MedusaContainer,
fields: string[]
) => {
const remoteQuery = scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY)
const filters = isString(idOrFilter) ? { id: idOrFilter } : idOrFilter
let context: object = {}
if ("context" in filters) {
if (filters.context) {
context = filters.context!
}
delete filters.context
}
let variables = { filters, ...context }
const queryObject = remoteQueryObjectFromString({
entryPoint,
variables,
fields,
})
return await remoteQuery(queryObject)
}
export const refetchEntity = async (
entryPoint: string,
idOrFilter: string | object,
scope: MedusaContainer,
fields: string[]
) => {
const [entity] = await refetchEntities(entryPoint, idOrFilter, scope, fields)
return entity
}

View File

@@ -44,7 +44,6 @@ export function validateAndTransformQuery<TEntity extends BaseEntity>(
return async (req: MedusaRequest, _: MedusaResponse, next: NextFunction) => {
try {
const query = normalizeQuery(req)
const validated = await zodValidator(zodSchema, query)
const cnf = queryConfig.isList
? prepareListQuery(validated, queryConfig)

View File

@@ -1,9 +1,13 @@
import type { NextFunction, Request, Response } from "express"
import type { Customer, User } from "../models"
import { MedusaContainer, RequestQueryFields } from "@medusajs/types"
import { FindConfig } from "./common"
import {
MedusaContainer,
MedusaPricingContext,
RequestQueryFields,
} from "@medusajs/types"
import * as core from "express-serve-static-core"
import { FindConfig } from "./common"
export interface MedusaRequest<Body = unknown>
extends Request<core.ParamsDictionary, any, Body> {
@@ -50,6 +54,10 @@ export interface MedusaRequest<Body = unknown>
session?: any
rawBody?: any
requestId?: string
/**
* An object that carries the context that is used to calculate prices for variants
*/
pricingContext?: MedusaPricingContext
}
export interface AuthenticatedMedusaRequest<Body = never>

View File

@@ -4,4 +4,5 @@ export * from "./price-list"
export * from "./price-rule"
export * from "./price-set"
export * from "./price-set-rule-type"
export * from "./pricing-context"
export * from "./rule-type"

View File

@@ -0,0 +1,6 @@
export type MedusaPricingContext = {
region_id?: string
currency_code?: string
customer_id?: string
customer_group_id?: string[]
}