From d80bc577d9c5ca961f48f0c9fe7242c4dce5a093 Mon Sep 17 00:00:00 2001 From: olivermrbl Date: Thu, 21 Mar 2024 09:58:13 +0100 Subject: [PATCH 1/3] feat: List products middleware --- .../api/__tests__/admin/product.js | 142 +++++++++++++++++- .../src/api-v2/admin/products/middlewares.ts | 6 + .../src/api-v2/admin/products/query-config.ts | 1 + .../medusa/src/api-v2/admin/products/route.ts | 50 +----- .../src/api-v2/admin/products/utils/index.ts | 3 + .../utils/maybe-apply-price-lists-filter.ts | 51 +++++++ .../maybe-apply-sales-channels-filter.ts | 36 +++++ .../src/api-v2/admin/products/validators.ts | 13 +- .../src/api-v2/admin/sales-channels/route.ts | 6 +- 9 files changed, 249 insertions(+), 59 deletions(-) create mode 100644 packages/medusa/src/api-v2/admin/products/utils/index.ts create mode 100644 packages/medusa/src/api-v2/admin/products/utils/maybe-apply-price-lists-filter.ts create mode 100644 packages/medusa/src/api-v2/admin/products/utils/maybe-apply-sales-channels-filter.ts diff --git a/integration-tests/api/__tests__/admin/product.js b/integration-tests/api/__tests__/admin/product.js index 7ece3f1261a90..9c7a5bda0146e 100644 --- a/integration-tests/api/__tests__/admin/product.js +++ b/integration-tests/api/__tests__/admin/product.js @@ -4,7 +4,12 @@ const { } = require("../../../helpers/create-admin-user") const { breaking } = require("../../../helpers/breaking") const { IdMap, medusaIntegrationTestRunner } = require("medusa-test-utils") -const { ModuleRegistrationName } = require("@medusajs/modules-sdk") +const { ModuleRegistrationName, Modules } = require("@medusajs/modules-sdk") +const { + createVariantPriceSet, +} = require("../../../modules/helpers/create-variant-price-set") +const { PriceListStatus, PriceListType } = require("@medusajs/types") +const { ContainerRegistrationKeys } = require("@medusajs/utils") let productSeeder = undefined let priceListSeeder = undefined @@ -73,6 +78,12 @@ medusaIntegrationTestRunner({ env: { MEDUSA_FF_PRODUCT_CATEGORIES: true }, testSuite: ({ dbConnection, getContainer, api }) => { let v2Product + let pricingService + let productService + let scService + let remoteLink + let container + beforeAll(() => { // Note: We have to lazily load everything because there are weird ordering issues when doing `require` of `@medusajs/medusa` productSeeder = require("../../../helpers/product-seeder") @@ -96,7 +107,7 @@ medusaIntegrationTestRunner({ }) beforeEach(async () => { - const container = getContainer() + container = getContainer() await createAdminUser(dbConnection, adminHeaders, container) // We want to seed another product for v2 that has pricing correctly wired up for all pricing-related tests. @@ -107,6 +118,11 @@ medusaIntegrationTestRunner({ await api.post("/admin/products", productFixture, adminHeaders) ) )?.data?.product + + pricingService = container.resolve(ModuleRegistrationName.PRICING) + productService = container.resolve(ModuleRegistrationName.PRODUCT) + scService = container.resolve(ModuleRegistrationName.SALES_CHANNEL) + remoteLink = container.resolve(ContainerRegistrationKeys.REMOTE_LINK) }) describe("/admin/products", () => { @@ -1104,6 +1120,128 @@ medusaIntegrationTestRunner({ ]) ) }) + + it("should return products filtered by price_list_id", async () => { + const priceList = await breaking( + async () => { + return await simplePriceListFactory(dbConnection, { + prices: [ + { + variant_id: "test-variant", + amount: 100, + currency_code: "usd", + }, + ], + }) + }, + async () => { + const variantId = v2Product.variants[0].id + + await pricingService.createRuleTypes([ + { + name: "Region ID", + rule_attribute: "region_id", + }, + ]) + + const priceSet = await createVariantPriceSet({ + container, + variantId, + }) + + const [priceList] = await pricingService.createPriceLists([ + { + title: "Test price list", + description: "Test", + status: PriceListStatus.ACTIVE, + type: PriceListType.OVERRIDE, + prices: [ + { + amount: 5000, + currency_code: "usd", + price_set_id: priceSet.id, + rules: { + region_id: "test-region", + }, + }, + ], + }, + ]) + + return priceList + } + ) + + const res = await api.get( + `/admin/products?price_list_id[]=${priceList.id}`, + adminHeaders + ) + + expect(res.status).toEqual(200) + expect(res.data.products.length).toEqual(1) + expect(res.data.products).toEqual([ + expect.objectContaining({ + id: breaking( + () => "test-product", + () => v2Product.id + ), + status: "draft", + }), + ]) + }) + + it("should return products filtered by sales_channel_id", async () => { + const { salesChannel, product } = await breaking( + async () => { + const product = await simpleProductFactory(dbConnection, { + id: "product_1", + title: "test title", + }) + + const salesChannel = await simpleSalesChannelFactory( + dbConnection, + { + name: "test name", + description: "test description", + products: [product], + } + ) + + return { salesChannel, product } + }, + async () => { + const salesChannel = await scService.create({ + name: "Test channel", + description: "Lorem Ipsum", + }) + + await remoteLink.create({ + [Modules.PRODUCT]: { + product_id: v2Product.id, + }, + [Modules.SALES_CHANNEL]: { + sales_channel_id: salesChannel.id, + }, + }) + + return { salesChannel, product: v2Product } + } + ) + + const res = await api.get( + `/admin/products?sales_channel_id[]=${salesChannel.id}`, + adminHeaders + ) + + expect(res.status).toEqual(200) + expect(res.data.products.length).toEqual(1) + expect(res.data.products).toEqual([ + expect.objectContaining({ + id: product.id, + status: "draft", + }), + ]) + }) }) describe("GET /admin/products/:id", () => { diff --git a/packages/medusa/src/api-v2/admin/products/middlewares.ts b/packages/medusa/src/api-v2/admin/products/middlewares.ts index b38c8c608c452..5ea31ea034d9f 100644 --- a/packages/medusa/src/api-v2/admin/products/middlewares.ts +++ b/packages/medusa/src/api-v2/admin/products/middlewares.ts @@ -2,6 +2,10 @@ import { transformBody, transformQuery } from "../../../api/middlewares" import { MiddlewareRoute } from "../../../loaders/helpers/routing/types" import { authenticate } from "../../../utils/authenticate-middleware" import * as QueryConfig from "./query-config" +import { + maybeApplyPriceListsFilter, + maybeApplySalesChannelsFilter, +} from "./utils" import { AdminGetProductsOptionsParams, AdminGetProductsParams, @@ -31,6 +35,8 @@ export const adminProductRoutesMiddlewares: MiddlewareRoute[] = [ AdminGetProductsParams, QueryConfig.listProductQueryConfig ), + maybeApplySalesChannelsFilter(), + maybeApplyPriceListsFilter(), ], }, { diff --git a/packages/medusa/src/api-v2/admin/products/query-config.ts b/packages/medusa/src/api-v2/admin/products/query-config.ts index 3a33b864d0b00..85d680e48afe4 100644 --- a/packages/medusa/src/api-v2/admin/products/query-config.ts +++ b/packages/medusa/src/api-v2/admin/products/query-config.ts @@ -86,6 +86,7 @@ export const defaultAdminProductFields = [ "*variants", "*variants.prices", "*variants.options", + "*sales_channels", ] export const retrieveProductQueryConfig = { diff --git a/packages/medusa/src/api-v2/admin/products/route.ts b/packages/medusa/src/api-v2/admin/products/route.ts index 09d5f0bc6e13f..4e06b502d884f 100644 --- a/packages/medusa/src/api-v2/admin/products/route.ts +++ b/packages/medusa/src/api-v2/admin/products/route.ts @@ -2,74 +2,26 @@ import { createProductsWorkflow } from "@medusajs/core-flows" import { CreateProductDTO } from "@medusajs/types" import { ContainerRegistrationKeys, - isString, remoteQueryObjectFromString, } from "@medusajs/utils" -import { MedusaContainer } from "medusa-core-utils" import { AuthenticatedMedusaRequest, MedusaResponse, } from "../../../types/routing" -import { listPriceLists } from "../price-lists/queries" import { refetchProduct, remapKeysForProduct, remapProduct } from "./helpers" import { AdminGetProductsParams } from "./validators" -const applyVariantFiltersForPriceList = async ( - scope: MedusaContainer, - filterableFields: AdminGetProductsParams -) => { - const filterByPriceListIds = filterableFields.price_list_id - const priceListVariantIds: string[] = [] - - // When filtering by price_list_id, we need use the remote query to get - // the variant IDs through the price list price sets. - if (Array.isArray(filterByPriceListIds)) { - const [priceLists] = await listPriceLists({ - container: scope, - remoteQueryFields: ["price_set_money_amounts.price_set.variant.id"], - apiFields: ["prices.variant_id"], - variables: { filters: { id: filterByPriceListIds }, skip: 0, take: null }, - }) - - priceListVariantIds.push( - ...((priceLists - .map((priceList) => priceList.prices?.map((price) => price.variant_id)) - .flat(2) - .filter(isString) || []) as string[]) - ) - - delete filterableFields.price_list_id - } - - if (priceListVariantIds.length) { - const existingVariantFilters = filterableFields.variants || {} - - filterableFields.variants = { - ...existingVariantFilters, - id: priceListVariantIds, - } - } - - return filterableFields -} - export const GET = async ( req: AuthenticatedMedusaRequest, res: MedusaResponse ) => { const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY) - let filterableFields: AdminGetProductsParams = { ...req.filterableFields } - - filterableFields = await applyVariantFiltersForPriceList( - req.scope, - filterableFields - ) const selectFields = remapKeysForProduct(req.remoteQueryConfig.fields ?? []) const queryObject = remoteQueryObjectFromString({ entryPoint: "product", variables: { - filters: filterableFields, + filters: req.filterableFields, ...req.remoteQueryConfig.pagination, }, fields: selectFields, diff --git a/packages/medusa/src/api-v2/admin/products/utils/index.ts b/packages/medusa/src/api-v2/admin/products/utils/index.ts new file mode 100644 index 0000000000000..ff7c427cb568f --- /dev/null +++ b/packages/medusa/src/api-v2/admin/products/utils/index.ts @@ -0,0 +1,3 @@ +export * from "./maybe-apply-price-lists-filter" +export * from "./maybe-apply-sales-channels-filter" + diff --git a/packages/medusa/src/api-v2/admin/products/utils/maybe-apply-price-lists-filter.ts b/packages/medusa/src/api-v2/admin/products/utils/maybe-apply-price-lists-filter.ts new file mode 100644 index 0000000000000..55dd63dba5a6c --- /dev/null +++ b/packages/medusa/src/api-v2/admin/products/utils/maybe-apply-price-lists-filter.ts @@ -0,0 +1,51 @@ +import { + ContainerRegistrationKeys, + remoteQueryObjectFromString, +} from "@medusajs/utils" +import { NextFunction } from "express" +import { MedusaRequest } from "../../../../types/routing" +import { AdminGetProductsParams } from "../validators" + +export function maybeApplyPriceListsFilter() { + return async (req: MedusaRequest, _, next: NextFunction) => { + const filterableFields: AdminGetProductsParams = req.filterableFields + + if (!filterableFields.price_list_id) { + return next() + } + + const priceListIds = filterableFields.price_list_id + delete filterableFields.price_list_id + + const queryObject = remoteQueryObjectFromString({ + entryPoint: "price_list", + fields: ["price_set_money_amounts.price_set.variant.id"], + variables: { + id: priceListIds, + }, + }) + + const remoteQuery = req.scope.resolve( + ContainerRegistrationKeys.REMOTE_QUERY + ) + + const variantIds: string[] = [] + const priceLists = await remoteQuery(queryObject) + + priceLists.forEach((priceList) => { + priceList.price_set_money_amounts?.forEach((psma) => { + const variantId = psma.price_set?.variant?.id + if (variantId) { + variantIds.push(variantId) + } + }) + }) + + filterableFields.variants = { + ...(filterableFields.variants ?? {}), + id: variantIds, + } + + return next() + } +} diff --git a/packages/medusa/src/api-v2/admin/products/utils/maybe-apply-sales-channels-filter.ts b/packages/medusa/src/api-v2/admin/products/utils/maybe-apply-sales-channels-filter.ts new file mode 100644 index 0000000000000..586f6ee7b1218 --- /dev/null +++ b/packages/medusa/src/api-v2/admin/products/utils/maybe-apply-sales-channels-filter.ts @@ -0,0 +1,36 @@ +import { + ContainerRegistrationKeys, + remoteQueryObjectFromString, +} from "@medusajs/utils" +import { NextFunction } from "express" +import { MedusaRequest } from "../../../../types/routing" +import { AdminGetProductsParams } from "../validators" + +export function maybeApplySalesChannelsFilter() { + return async (req: MedusaRequest, _, next: NextFunction) => { + const filterableFields: AdminGetProductsParams = req.filterableFields + + if (!filterableFields.sales_channel_id) { + return next() + } + + const salesChannelIds = filterableFields.sales_channel_id + delete filterableFields.sales_channel_id + + const remoteQuery = req.scope.resolve( + ContainerRegistrationKeys.REMOTE_QUERY + ) + + const queryObject = remoteQueryObjectFromString({ + entryPoint: "product_sales_channel", + fields: ["product_id"], + variables: { sales_channel_id: salesChannelIds }, + }) + + const productsInSalesChannels = await remoteQuery(queryObject) + + filterableFields.id = productsInSalesChannels.map((p) => p.product_id) + + return next() + } +} diff --git a/packages/medusa/src/api-v2/admin/products/validators.ts b/packages/medusa/src/api-v2/admin/products/validators.ts index 267cb8c1ea565..708086fb71de3 100644 --- a/packages/medusa/src/api-v2/admin/products/validators.ts +++ b/packages/medusa/src/api-v2/admin/products/validators.ts @@ -81,6 +81,13 @@ export class AdminGetProductsParams extends extendedFindParamsMixin({ @IsArray() price_list_id?: string[] + /** + * Filter products by associated sales channel IDs. + */ + @IsOptional() + @IsArray() + sales_channel_id?: string[] + /** * Filter products by their associated product collection's ID. */ @@ -107,12 +114,6 @@ export class AdminGetProductsParams extends extendedFindParamsMixin({ @IsObject() variants?: Record - // /** - // * Filter products by their associated sales channels' ID. - // */ - // @FeatureFlagDecorators(SalesChannelFeatureFlag.key, [IsOptional(), IsArray()]) - // sales_channel_id?: string[] - // /** // * Filter products by their associated discount condition's ID. // */ diff --git a/packages/medusa/src/api-v2/admin/sales-channels/route.ts b/packages/medusa/src/api-v2/admin/sales-channels/route.ts index 275b3f3545707..c2f4bc2876446 100644 --- a/packages/medusa/src/api-v2/admin/sales-channels/route.ts +++ b/packages/medusa/src/api-v2/admin/sales-channels/route.ts @@ -42,7 +42,7 @@ export const POST = async ( ) => { const salesChannelsData = [req.validatedBody] - const { errors } = await createSalesChannelsWorkflow(req.scope).run({ + const { errors, result } = await createSalesChannelsWorkflow(req.scope).run({ input: { salesChannelsData }, throwOnError: false, }) @@ -51,11 +51,13 @@ export const POST = async ( throw errors[0].error } + const salesChannel = result[0] + const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY) const queryObject = remoteQueryObjectFromString({ entryPoint: "sales_channels", - variables: { id: req.params.id }, + variables: { id: salesChannel.id }, fields: req.remoteQueryConfig.fields, }) From 9d4b637af8cf265f74f02057296a512fc42c94e0 Mon Sep 17 00:00:00 2001 From: olivermrbl Date: Fri, 22 Mar 2024 09:55:48 +0100 Subject: [PATCH 2/3] chore: Move fixture inside module test runner --- .../api/__tests__/admin/product.js | 91 ++++++++++--------- 1 file changed, 48 insertions(+), 43 deletions(-) diff --git a/integration-tests/api/__tests__/admin/product.js b/integration-tests/api/__tests__/admin/product.js index 9c7a5bda0146e..43dac2858a5fb 100644 --- a/integration-tests/api/__tests__/admin/product.js +++ b/integration-tests/api/__tests__/admin/product.js @@ -32,50 +32,8 @@ let { jest.setTimeout(50000) -const productFixture = { - title: "Test fixture", - description: "test-product-description", - type: { value: "test-type" }, - images: ["test-image.png", "test-image-2.png"], - tags: [{ value: "123" }, { value: "456" }], - options: breaking( - () => [{ title: "size" }, { title: "color" }], - () => [ - { title: "size", values: ["large"] }, - { title: "color", values: ["green"] }, - ] - ), - variants: [ - { - title: "Test variant", - inventory_quantity: 10, - prices: [ - { - currency_code: "usd", - amount: 100, - }, - { - currency_code: "eur", - amount: 45, - }, - { - currency_code: "dkk", - amount: 30, - }, - ], - options: breaking( - () => [{ value: "large" }, { value: "green" }], - () => ({ - size: "large", - color: "green", - }) - ), - }, - ], -} - medusaIntegrationTestRunner({ - env: { MEDUSA_FF_PRODUCT_CATEGORIES: true }, + env: { MEDUSA_FF_PRODUCT_CATEGORIES: true, MEDUSA_FF_MEDUSA_V2: true }, testSuite: ({ dbConnection, getContainer, api }) => { let v2Product let pricingService @@ -110,6 +68,48 @@ medusaIntegrationTestRunner({ container = getContainer() await createAdminUser(dbConnection, adminHeaders, container) + const productFixture = { + title: "Test fixture", + description: "test-product-description", + type: { value: "test-type" }, + images: ["test-image.png", "test-image-2.png"], + tags: [{ value: "123" }, { value: "456" }], + options: breaking( + () => [{ title: "size" }, { title: "color" }], + () => [ + { title: "size", values: ["large"] }, + { title: "color", values: ["green"] }, + ] + ), + variants: [ + { + title: "Test variant", + inventory_quantity: 10, + prices: [ + { + currency_code: "usd", + amount: 100, + }, + { + currency_code: "eur", + amount: 45, + }, + { + currency_code: "dkk", + amount: 30, + }, + ], + options: breaking( + () => [{ value: "large" }, { value: "green" }], + () => ({ + size: "large", + color: "green", + }) + ), + }, + ], + } + // We want to seed another product for v2 that has pricing correctly wired up for all pricing-related tests. v2Product = ( await breaking( @@ -1198,6 +1198,11 @@ medusaIntegrationTestRunner({ title: "test title", }) + await simpleProductFactory(dbConnection, { + id: "product_2", + title: "test title 2", + }) + const salesChannel = await simpleSalesChannelFactory( dbConnection, { From 647c7261d57bb65976b13b95c3c192a7fe220a8b Mon Sep 17 00:00:00 2001 From: olivermrbl Date: Fri, 22 Mar 2024 14:26:38 +0100 Subject: [PATCH 3/3] fix: Undefined productFixture --- integration-tests/api/__tests__/admin/product.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/integration-tests/api/__tests__/admin/product.js b/integration-tests/api/__tests__/admin/product.js index 43dac2858a5fb..c7ec540ae1489 100644 --- a/integration-tests/api/__tests__/admin/product.js +++ b/integration-tests/api/__tests__/admin/product.js @@ -33,7 +33,7 @@ let { jest.setTimeout(50000) medusaIntegrationTestRunner({ - env: { MEDUSA_FF_PRODUCT_CATEGORIES: true, MEDUSA_FF_MEDUSA_V2: true }, + env: { MEDUSA_FF_PRODUCT_CATEGORIES: true }, testSuite: ({ dbConnection, getContainer, api }) => { let v2Product let pricingService @@ -41,6 +41,7 @@ medusaIntegrationTestRunner({ let scService let remoteLink let container + let productFixture beforeAll(() => { // Note: We have to lazily load everything because there are weird ordering issues when doing `require` of `@medusajs/medusa` @@ -68,7 +69,7 @@ medusaIntegrationTestRunner({ container = getContainer() await createAdminUser(dbConnection, adminHeaders, container) - const productFixture = { + productFixture = { title: "Test fixture", description: "test-product-description", type: { value: "test-type" },