From efa3308d78993a7801fd14cde597a502423d8d94 Mon Sep 17 00:00:00 2001 From: Riqwan Thamir Date: Tue, 30 Apr 2024 16:59:43 +0200 Subject: [PATCH] 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 --- .changeset/nervous-glasses-exercise.md | 5 ++ .../__tests__/product/store/index.spec.ts | 73 +++++++++++++++++++ .../src/api-v2/store/products/[id]/route.ts | 20 ++--- .../src/api-v2/store/products/helpers.ts | 24 ------ .../src/api-v2/store/products/middlewares.ts | 23 +++++- .../medusa/src/api-v2/store/products/route.ts | 17 +++-- .../common/apply-default-filters.ts | 39 ++++++---- .../products/set-pricing-context.ts | 28 +++++-- 8 files changed, 165 insertions(+), 64 deletions(-) create mode 100644 .changeset/nervous-glasses-exercise.md diff --git a/.changeset/nervous-glasses-exercise.md b/.changeset/nervous-glasses-exercise.md new file mode 100644 index 0000000000..e762bab17a --- /dev/null +++ b/.changeset/nervous-glasses-exercise.md @@ -0,0 +1,5 @@ +--- +"@medusajs/medusa": patch +--- + +feat(medusa): add middleware filters + scope products diff --git a/integration-tests/modules/__tests__/product/store/index.spec.ts b/integration-tests/modules/__tests__/product/store/index.spec.ts index b989cd4ed7..bfede76bc6 100644 --- a/integration-tests/modules/__tests__/product/store/index.spec.ts +++ b/integration-tests/modules/__tests__/product/store/index.spec.ts @@ -33,6 +33,27 @@ medusaIntegrationTestRunner({ 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 response = await api.post( "/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", () => { let salesChannel1 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 () => { let error = await api .get( diff --git a/packages/medusa/src/api-v2/store/products/[id]/route.ts b/packages/medusa/src/api-v2/store/products/[id]/route.ts index d3cc6044f2..d359bb2f28 100644 --- a/packages/medusa/src/api-v2/store/products/[id]/route.ts +++ b/packages/medusa/src/api-v2/store/products/[id]/route.ts @@ -7,17 +7,19 @@ export const GET = async ( req: MedusaRequest, res: MedusaResponse ) => { - const context = isPresent(req.pricingContext) - ? { - "variants.calculated_price": { context: req.pricingContext }, - } - : undefined + const filters: object = { + id: req.params.id, + ...req.filterableFields, + } + + if (isPresent(req.pricingContext)) { + filters["context"] = { + "variants.calculated_price": { context: req.pricingContext }, + } + } const product = await refetchProduct( - { - id: req.params.id, - context, - }, + filters, req.scope, req.remoteQueryConfig.fields ) diff --git a/packages/medusa/src/api-v2/store/products/helpers.ts b/packages/medusa/src/api-v2/store/products/helpers.ts index 6c796de959..b18f8ed74a 100644 --- a/packages/medusa/src/api-v2/store/products/helpers.ts +++ b/packages/medusa/src/api-v2/store/products/helpers.ts @@ -1,29 +1,5 @@ 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, diff --git a/packages/medusa/src/api-v2/store/products/middlewares.ts b/packages/medusa/src/api-v2/store/products/middlewares.ts index 6d755b47d0..0c727c836f 100644 --- a/packages/medusa/src/api-v2/store/products/middlewares.ts +++ b/packages/medusa/src/api-v2/store/products/middlewares.ts @@ -1,4 +1,4 @@ -import { ProductStatus } from "@medusajs/utils" +import { isPresent, 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" @@ -38,8 +38,18 @@ export const storeProductRoutesMiddlewares: MiddlewareRoute[] = [ resourceId: "product_id", filterableField: "sales_channel_id", }), - applyDefaultFilters({ + applyDefaultFilters({ 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(), ], @@ -58,8 +68,15 @@ export const storeProductRoutesMiddlewares: MiddlewareRoute[] = [ resourceId: "product_id", filterableField: "sales_channel_id", }), - applyDefaultFilters({ + applyDefaultFilters({ status: ProductStatus.PUBLISHED, + categories: (_filters, fields: string[]) => { + if (!fields.some((field) => field.startsWith("categories"))) { + return + } + + return { is_internal: false, is_active: true } + }, }), setPricingContext(), ], diff --git a/packages/medusa/src/api-v2/store/products/route.ts b/packages/medusa/src/api-v2/store/products/route.ts index 353642caba..2a704061b1 100644 --- a/packages/medusa/src/api-v2/store/products/route.ts +++ b/packages/medusa/src/api-v2/store/products/route.ts @@ -4,7 +4,6 @@ import { remoteQueryObjectFromString, } from "@medusajs/utils" import { MedusaRequest, MedusaResponse } from "../../../types/routing" -import { wrapWithCategoryFilters } from "./helpers" import { StoreGetProductsParamsType } from "./validators" export const GET = async ( @@ -12,18 +11,20 @@ export const GET = async ( res: MedusaResponse ) => { const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY) - const context = isPresent(req.pricingContext) - ? { - "variants.calculated_price": { context: req.pricingContext }, - } - : undefined + const context: object = {} + + if (isPresent(req.pricingContext)) { + context["variants.calculated_price"] = { + context: req.pricingContext, + } + } const queryObject = remoteQueryObjectFromString({ entryPoint: "product", variables: { - filters: wrapWithCategoryFilters(req.filterableFields), - ...context, + filters: req.filterableFields, ...req.remoteQueryConfig.pagination, + ...context, }, fields: req.remoteQueryConfig.fields, }) diff --git a/packages/medusa/src/api-v2/utils/middlewares/common/apply-default-filters.ts b/packages/medusa/src/api-v2/utils/middlewares/common/apply-default-filters.ts index efc3b68258..f74f8c61af 100644 --- a/packages/medusa/src/api-v2/utils/middlewares/common/apply-default-filters.ts +++ b/packages/medusa/src/api-v2/utils/middlewares/common/apply-default-filters.ts @@ -1,27 +1,38 @@ -import { isObject } from "@medusajs/utils" +import { isObject, isPresent } from "@medusajs/utils" import { NextFunction } from "express" import { MedusaRequest } from "../../../../types/routing" -export function applyDefaultFilters(filters: TFilter) { +export function applyDefaultFilters( + filtersToApply: TFilter +) { 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)) { - let existingFilter = filterableFields[filter] + // If certain manipulations need to be done on a middleware level, we can provide a simple + // function that mutates the data based on any custom requirement + if (typeof filterValue === "function") { + // pass the actual filterable fields so that the function can mutate the original object. + // Currently we only need it to delete filter keys from the request filter object, but this could + // be used for other purposes. If we can't find other purposes, we can refactor to accept an array + // of strings to delete after filters have been applied. + valueToApply = filterValue( + req.filterableFields, + req.remoteQueryConfig.fields + ) + } - if (existingFilter && isObject(existingFilter)) { - // If an existing filter is found, append to it - filterableFields[filter] = { - ...existingFilter, - [filter]: filterValue, + // 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 { - filterableFields[filter] = filterValue + } else if (isPresent(valueToApply)) { + req.filterableFields[filter] = valueToApply } } - req.filterableFields = filterableFields - return next() } } diff --git a/packages/medusa/src/api-v2/utils/middlewares/products/set-pricing-context.ts b/packages/medusa/src/api-v2/utils/middlewares/products/set-pricing-context.ts index e2107de2aa..317c5b6e96 100644 --- a/packages/medusa/src/api-v2/utils/middlewares/products/set-pricing-context.ts +++ b/packages/medusa/src/api-v2/utils/middlewares/products/set-pricing-context.ts @@ -10,14 +10,30 @@ export function setPricingContext() { if ( !req.remoteQueryConfig.fields.some((field) => 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() } + // 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 pricingContext: MedusaPricingContext = {} const customerId = req.user?.customer_id @@ -70,8 +86,8 @@ export function setPricingContext() { 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)) { + // If a currency_code is not present in the context, we will not be able to calculate prices + if (!isPresent(pricingContext.currency_code)) { try { throw new MedusaError( MedusaError.Types.INVALID_DATA,