feat(medusa): add middleware filters + scope products (#7178)
* chore: add middleware filters + scope products * chore: fix spec + add changeset * chore: add internal category to list test
This commit is contained in:
1 parent
1eeb1e9de3
commit
efa3308d78
8 files changed
+164
-63
No files matched your search
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@medusajs/medusa": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
feat(medusa): add middleware filters + scope products
|
||||||
@@ -33,6 +33,27 @@ medusaIntegrationTestRunner({
|
|||||||
return [response.data.product, response.data.product.variants || []]
|
return [response.data.product, response.data.product.variants || []]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const createCategory = async (data, productIds) => {
|
||||||
|
const response = await api.post(
|
||||||
|
"/admin/product-categories",
|
||||||
|
data,
|
||||||
|
adminHeaders
|
||||||
|
)
|
||||||
|
|
||||||
|
await api.post(
|
||||||
|
`/admin/product-categories/${response.data.product_category.id}/products`,
|
||||||
|
{ add: productIds },
|
||||||
|
adminHeaders
|
||||||
|
)
|
||||||
|
|
||||||
|
const response2 = await api.get(
|
||||||
|
`/admin/product-categories/${response.data.product_category.id}?fields=*products`,
|
||||||
|
adminHeaders
|
||||||
|
)
|
||||||
|
|
||||||
|
return response2.data.product_category
|
||||||
|
}
|
||||||
|
|
||||||
const createSalesChannel = async (data, productIds) => {
|
const createSalesChannel = async (data, productIds) => {
|
||||||
const response = await api.post(
|
const response = await api.post(
|
||||||
"/admin/sales-channels",
|
"/admin/sales-channels",
|
||||||
@@ -156,6 +177,30 @@ medusaIntegrationTestRunner({
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("should list all products for a category", async () => {
|
||||||
|
const category = await createCategory(
|
||||||
|
{ name: "test", is_internal: false, is_active: true },
|
||||||
|
[product.id]
|
||||||
|
)
|
||||||
|
|
||||||
|
const category2 = await createCategory(
|
||||||
|
{ name: "test2", is_internal: true, is_active: true },
|
||||||
|
[product4.id]
|
||||||
|
)
|
||||||
|
|
||||||
|
const response = await api.get(
|
||||||
|
`/store/products?category_id[]=${category.id}&category_id[]=${category2.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", () => {
|
describe("with publishable keys", () => {
|
||||||
let salesChannel1
|
let salesChannel1
|
||||||
let salesChannel2
|
let salesChannel2
|
||||||
@@ -349,6 +394,34 @@ medusaIntegrationTestRunner({
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// TODO: There are 2 problems that need to be solved to enable this test
|
||||||
|
// 1. When adding product to another category, the product is being removed from earlier assigned categories
|
||||||
|
// 2. MikroORM seems to be doing a join strategy to load relationships, we need to send a separate query to fetch relationships
|
||||||
|
// to scope the relationships
|
||||||
|
it.skip("should list only categories that are public and active", async () => {
|
||||||
|
const category = await createCategory(
|
||||||
|
{ name: "test 1", is_internal: true, is_active: true },
|
||||||
|
[product.id]
|
||||||
|
)
|
||||||
|
|
||||||
|
await createCategory(
|
||||||
|
{ name: "test 2", is_internal: false, is_active: true },
|
||||||
|
[product.id]
|
||||||
|
)
|
||||||
|
|
||||||
|
const response = await api.get(
|
||||||
|
`/store/products/${product.id}?fields=*categories`
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.status).toEqual(200)
|
||||||
|
expect(response.data.product).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: product.id,
|
||||||
|
categories: [expect.objectContaining({ id: category.id })],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it("should throw error when calculating prices without context", async () => {
|
it("should throw error when calculating prices without context", async () => {
|
||||||
let error = await api
|
let error = await api
|
||||||
.get(
|
.get(
|
||||||
|
|||||||
@@ -7,17 +7,19 @@ export const GET = async (
|
|||||||
req: MedusaRequest<StoreGetProductsParamsType>,
|
req: MedusaRequest<StoreGetProductsParamsType>,
|
||||||
res: MedusaResponse
|
res: MedusaResponse
|
||||||
) => {
|
) => {
|
||||||
const context = isPresent(req.pricingContext)
|
const filters: object = {
|
||||||
? {
|
id: req.params.id,
|
||||||
|
...req.filterableFields,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPresent(req.pricingContext)) {
|
||||||
|
filters["context"] = {
|
||||||
"variants.calculated_price": { context: req.pricingContext },
|
"variants.calculated_price": { context: req.pricingContext },
|
||||||
}
|
}
|
||||||
: undefined
|
}
|
||||||
|
|
||||||
const product = await refetchProduct(
|
const product = await refetchProduct(
|
||||||
{
|
filters,
|
||||||
id: req.params.id,
|
|
||||||
context,
|
|
||||||
},
|
|
||||||
req.scope,
|
req.scope,
|
||||||
req.remoteQueryConfig.fields
|
req.remoteQueryConfig.fields
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,29 +1,5 @@
|
|||||||
import { MedusaContainer } from "@medusajs/types"
|
import { MedusaContainer } from "@medusajs/types"
|
||||||
import { isPresent } from "@medusajs/utils"
|
|
||||||
import { refetchEntity } from "../../utils/refetch-entity"
|
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 (
|
export const refetchProduct = async (
|
||||||
idOrFilter: string | object,
|
idOrFilter: string | object,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ProductStatus } from "@medusajs/utils"
|
import { isPresent, ProductStatus } from "@medusajs/utils"
|
||||||
import { MiddlewareRoute } from "../../../loaders/helpers/routing/types"
|
import { MiddlewareRoute } from "../../../loaders/helpers/routing/types"
|
||||||
import { authenticate } from "../../../utils/authenticate-middleware"
|
import { authenticate } from "../../../utils/authenticate-middleware"
|
||||||
import { maybeApplyLinkFilter } from "../../utils/maybe-apply-link-filter"
|
import { maybeApplyLinkFilter } from "../../utils/maybe-apply-link-filter"
|
||||||
@@ -38,8 +38,18 @@ export const storeProductRoutesMiddlewares: MiddlewareRoute[] = [
|
|||||||
resourceId: "product_id",
|
resourceId: "product_id",
|
||||||
filterableField: "sales_channel_id",
|
filterableField: "sales_channel_id",
|
||||||
}),
|
}),
|
||||||
applyDefaultFilters<StoreGetProductsParamsType>({
|
applyDefaultFilters({
|
||||||
status: ProductStatus.PUBLISHED,
|
status: ProductStatus.PUBLISHED,
|
||||||
|
categories: (filters: StoreGetProductsParamsType, fields: string[]) => {
|
||||||
|
const categoryIds = filters.category_id
|
||||||
|
delete filters.category_id
|
||||||
|
|
||||||
|
if (!isPresent(categoryIds)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return { id: categoryIds, is_internal: false, is_active: true }
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
setPricingContext(),
|
setPricingContext(),
|
||||||
],
|
],
|
||||||
@@ -58,8 +68,15 @@ export const storeProductRoutesMiddlewares: MiddlewareRoute[] = [
|
|||||||
resourceId: "product_id",
|
resourceId: "product_id",
|
||||||
filterableField: "sales_channel_id",
|
filterableField: "sales_channel_id",
|
||||||
}),
|
}),
|
||||||
applyDefaultFilters<StoreGetProductsParamsType>({
|
applyDefaultFilters({
|
||||||
status: ProductStatus.PUBLISHED,
|
status: ProductStatus.PUBLISHED,
|
||||||
|
categories: (_filters, fields: string[]) => {
|
||||||
|
if (!fields.some((field) => field.startsWith("categories"))) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return { is_internal: false, is_active: true }
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
setPricingContext(),
|
setPricingContext(),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
remoteQueryObjectFromString,
|
remoteQueryObjectFromString,
|
||||||
} from "@medusajs/utils"
|
} from "@medusajs/utils"
|
||||||
import { MedusaRequest, MedusaResponse } from "../../../types/routing"
|
import { MedusaRequest, MedusaResponse } from "../../../types/routing"
|
||||||
import { wrapWithCategoryFilters } from "./helpers"
|
|
||||||
import { StoreGetProductsParamsType } from "./validators"
|
import { StoreGetProductsParamsType } from "./validators"
|
||||||
|
|
||||||
export const GET = async (
|
export const GET = async (
|
||||||
@@ -12,18 +11,20 @@ export const GET = async (
|
|||||||
res: MedusaResponse
|
res: MedusaResponse
|
||||||
) => {
|
) => {
|
||||||
const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY)
|
const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY)
|
||||||
const context = isPresent(req.pricingContext)
|
const context: object = {}
|
||||||
? {
|
|
||||||
"variants.calculated_price": { context: req.pricingContext },
|
if (isPresent(req.pricingContext)) {
|
||||||
|
context["variants.calculated_price"] = {
|
||||||
|
context: req.pricingContext,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
: undefined
|
|
||||||
|
|
||||||
const queryObject = remoteQueryObjectFromString({
|
const queryObject = remoteQueryObjectFromString({
|
||||||
entryPoint: "product",
|
entryPoint: "product",
|
||||||
variables: {
|
variables: {
|
||||||
filters: wrapWithCategoryFilters(req.filterableFields),
|
filters: req.filterableFields,
|
||||||
...context,
|
|
||||||
...req.remoteQueryConfig.pagination,
|
...req.remoteQueryConfig.pagination,
|
||||||
|
...context,
|
||||||
},
|
},
|
||||||
fields: req.remoteQueryConfig.fields,
|
fields: req.remoteQueryConfig.fields,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,26 +1,37 @@
|
|||||||
import { isObject } from "@medusajs/utils"
|
import { isObject, isPresent } from "@medusajs/utils"
|
||||||
import { NextFunction } from "express"
|
import { NextFunction } from "express"
|
||||||
import { MedusaRequest } from "../../../../types/routing"
|
import { MedusaRequest } from "../../../../types/routing"
|
||||||
|
|
||||||
export function applyDefaultFilters<TFilter extends object>(filters: TFilter) {
|
export function applyDefaultFilters<TFilter extends object>(
|
||||||
|
filtersToApply: TFilter
|
||||||
|
) {
|
||||||
return async (req: MedusaRequest, _, next: NextFunction) => {
|
return async (req: MedusaRequest, _, next: NextFunction) => {
|
||||||
const filterableFields = req.filterableFields || {}
|
for (const [filter, filterValue] of Object.entries(filtersToApply)) {
|
||||||
|
let valueToApply = filterValue
|
||||||
|
|
||||||
for (const [filter, filterValue] of Object.entries(filters)) {
|
// If certain manipulations need to be done on a middleware level, we can provide a simple
|
||||||
let existingFilter = filterableFields[filter]
|
// function that mutates the data based on any custom requirement
|
||||||
|
if (typeof filterValue === "function") {
|
||||||
if (existingFilter && isObject(existingFilter)) {
|
// pass the actual filterable fields so that the function can mutate the original object.
|
||||||
// If an existing filter is found, append to it
|
// Currently we only need it to delete filter keys from the request filter object, but this could
|
||||||
filterableFields[filter] = {
|
// be used for other purposes. If we can't find other purposes, we can refactor to accept an array
|
||||||
...existingFilter,
|
// of strings to delete after filters have been applied.
|
||||||
[filter]: filterValue,
|
valueToApply = filterValue(
|
||||||
}
|
req.filterableFields,
|
||||||
} else {
|
req.remoteQueryConfig.fields
|
||||||
filterableFields[filter] = filterValue
|
)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
req.filterableFields = filterableFields
|
// If the value to apply is an object, we add it to any existing filters thats already applied
|
||||||
|
if (isObject(valueToApply)) {
|
||||||
|
req.filterableFields[filter] = {
|
||||||
|
...(req.filterableFields[filter] || {}),
|
||||||
|
...valueToApply,
|
||||||
|
}
|
||||||
|
} else if (isPresent(valueToApply)) {
|
||||||
|
req.filterableFields[filter] = valueToApply
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return next()
|
return next()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,14 +10,30 @@ export function setPricingContext() {
|
|||||||
if (
|
if (
|
||||||
!req.remoteQueryConfig.fields.some((field) =>
|
!req.remoteQueryConfig.fields.some((field) =>
|
||||||
field.startsWith("variants.calculated_price")
|
field.startsWith("variants.calculated_price")
|
||||||
)
|
) &&
|
||||||
|
!req.filterableFields.region_id &&
|
||||||
|
!req.filterableFields.currency_code
|
||||||
) {
|
) {
|
||||||
delete req.filterableFields.region_id
|
|
||||||
delete req.filterableFields.currency_code
|
|
||||||
|
|
||||||
return next()
|
return next()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If pricing parameters are passed, but pricing fields are not passed, throw an error
|
||||||
|
if (
|
||||||
|
!req.remoteQueryConfig.fields.some((field) =>
|
||||||
|
field.startsWith("variants.calculated_price")
|
||||||
|
) &&
|
||||||
|
(req.filterableFields.region_id || req.filterableFields.currency_code)
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
throw new MedusaError(
|
||||||
|
MedusaError.Types.INVALID_DATA,
|
||||||
|
`Missing required pricing fields to calculate prices`
|
||||||
|
)
|
||||||
|
} catch (e) {
|
||||||
|
return next(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const query = req.filterableFields || {}
|
const query = req.filterableFields || {}
|
||||||
const pricingContext: MedusaPricingContext = {}
|
const pricingContext: MedusaPricingContext = {}
|
||||||
const customerId = req.user?.customer_id
|
const customerId = req.user?.customer_id
|
||||||
@@ -70,8 +86,8 @@ export function setPricingContext() {
|
|||||||
delete req.filterableFields.customer_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 a currency_code is not present in the context, we will not be able to calculate prices
|
||||||
if (!isPresent(pricingContext)) {
|
if (!isPresent(pricingContext.currency_code)) {
|
||||||
try {
|
try {
|
||||||
throw new MedusaError(
|
throw new MedusaError(
|
||||||
MedusaError.Types.INVALID_DATA,
|
MedusaError.Types.INVALID_DATA,
|
||||||
|
|||||||
Reference in new issue
Block a user