From 19f35ba6aa811dcb5baea1ea67612e3748e91b6c Mon Sep 17 00:00:00 2001 From: Philip Korsholm <88927411+pKorsholm@users.noreply.github.com> Date: Mon, 11 Jul 2022 18:45:01 +0200 Subject: [PATCH] Feat(medusa, medusa-js, medusa-react): Include sales channels in related queries as an optional expand parameter (#1816) **What** - Add `transformQuery` to get endpoints for product, order and cart - ensure that the default relations (when getting a singular entity) includes sales channels when enabled - Add `EmptyQueryParams` class in common types to prevent query parameters while using `transformQuery` - update product-, order- and cartFactory to include sales channels if provided - remove `packages/medusa/src/controllers/products/admin-list-products.ts` **Testing** - expands sales channel for single order - expands sales channels for orders with expand parameter - returns single product with sales channel - expands sales channels for products with expand parameter - returns cart with sales channel for single cart Fixes CORE-293 Co-authored-by: Sebastian Rindom <7554214+srindom@users.noreply.github.com> Co-authored-by: Adrien de Peretti <25098370+adrien2p@users.noreply.github.com> --- .../__snapshots__/sales-channels.js.snap | 36 +++ .../api/__tests__/admin/sales-channels.js | 280 ++++++++++++++++-- .../api/factories/simple-cart-factory.ts | 16 +- .../api/factories/simple-order-factory.ts | 36 ++- .../api/factories/simple-product-factory.ts | 17 ++ .../helpers/start-server-with-environment.js | 2 +- packages/medusa/jest.config.js | 1 + packages/medusa/setupTests.js | 3 + packages/medusa/src/api/routes/admin/index.js | 6 +- .../src/api/routes/admin/orders/get-order.ts | 6 +- .../src/api/routes/admin/orders/index.ts | 31 +- .../api/routes/admin/orders/list-orders.ts | 42 +-- .../api/routes/admin/products/get-product.ts | 6 +- .../src/api/routes/admin/products/index.ts | 32 +- .../routes/admin/products/list-products.ts | 53 ++-- .../src/api/routes/store/carts/get-cart.ts | 6 +- .../src/api/routes/store/carts/index.ts | 20 +- .../products/admin-list-products.ts | 94 ------ packages/medusa/src/models/product.ts | 6 +- packages/medusa/src/types/common.ts | 2 + .../src/utils/feature-flag-decorators.ts | 57 ++-- packages/medusa/src/utils/get-query-config.ts | 13 +- 22 files changed, 510 insertions(+), 255 deletions(-) create mode 100644 packages/medusa/setupTests.js delete mode 100644 packages/medusa/src/controllers/products/admin-list-products.ts diff --git a/integration-tests/api/__tests__/admin/__snapshots__/sales-channels.js.snap b/integration-tests/api/__tests__/admin/__snapshots__/sales-channels.js.snap index f4258ec5d4..ac4ab2e39e 100644 --- a/integration-tests/api/__tests__/admin/__snapshots__/sales-channels.js.snap +++ b/integration-tests/api/__tests__/admin/__snapshots__/sales-channels.js.snap @@ -8,6 +8,30 @@ Object { } `; +exports[`sales channels GET /admin/orders/:id expands sales channel for single 1`] = ` +Object { + "created_at": Any, + "deleted_at": null, + "description": "test description", + "id": Any, + "is_disabled": false, + "name": "test name", + "updated_at": Any, +} +`; + +exports[`sales channels GET /admin/orders?expand=sales_channels expands sales channel with parameter 1`] = ` +Object { + "created_at": Any, + "deleted_at": null, + "description": "test description", + "id": Any, + "is_disabled": false, + "name": "test name", + "updated_at": Any, +} +`; + exports[`sales channels GET /admin/sales-channels/:id should retrieve the requested sales channel 1`] = ` Object { "created_at": Any, @@ -20,6 +44,18 @@ Object { } `; +exports[`sales channels GET /store/cart/:id with saleschannel returns cart with sales channel for single cart 1`] = ` +Object { + "created_at": Any, + "deleted_at": null, + "description": "test description", + "id": Any, + "is_disabled": false, + "name": "test name", + "updated_at": Any, +} +`; + exports[`sales channels POST /admin/sales-channels successfully creates a sales channel 1`] = ` Object { "sales_channel": ObjectContaining { diff --git a/integration-tests/api/__tests__/admin/sales-channels.js b/integration-tests/api/__tests__/admin/sales-channels.js index 12f24e3a94..5cc9d220a5 100644 --- a/integration-tests/api/__tests__/admin/sales-channels.js +++ b/integration-tests/api/__tests__/admin/sales-channels.js @@ -1,11 +1,18 @@ const path = require("path") + const { SalesChannel } = require("@medusajs/medusa") const { useApi } = require("../../../helpers/use-api") const { useDb } = require("../../../helpers/use-db") const adminSeeder = require("../../helpers/admin-seeder") -const { simpleSalesChannelFactory } = require("../../factories") + +const { + simpleSalesChannelFactory, + simpleProductFactory, + simpleCartFactory, +} = require("../../factories") +const { simpleOrderFactory } = require("../../factories") const startServerWithEnvironment = require("../../../helpers/start-server-with-environment").default @@ -130,7 +137,6 @@ describe("sales channels", () => { }) }) - describe("POST /admin/sales-channels", () => { beforeEach(async () => { try { @@ -172,13 +178,10 @@ describe("sales channels", () => { }) }) - describe("GET /admin/sales-channels/:id", () => {}) - describe("POST /admin/sales-channels/:id", () => {}) - describe("DELETE /admin/sales-channels/:id", () => { let salesChannel - beforeEach(async() => { + beforeEach(async () => { try { await adminSeeder(dbConnection) salesChannel = await simpleSalesChannelFactory(dbConnection, { @@ -194,14 +197,16 @@ describe("sales channels", () => { const db = useDb() await db.teardown() }) - - it("should delete the requested sales channel", async() => { + it("should delete the requested sales channel", async () => { const api = useApi() - let deletedSalesChannel = await dbConnection.manager.findOne(SalesChannel, { - where: { id: salesChannel.id }, - withDeleted: true - }) + let deletedSalesChannel = await dbConnection.manager.findOne( + SalesChannel, + { + where: { id: salesChannel.id }, + withDeleted: true, + } + ) expect(deletedSalesChannel.id).toEqual(salesChannel.id) expect(deletedSalesChannel.deleted_at).toEqual(null) @@ -220,20 +225,23 @@ describe("sales channels", () => { deletedSalesChannel = await dbConnection.manager.findOne(SalesChannel, { where: { id: salesChannel.id }, - withDeleted: true + withDeleted: true, }) expect(deletedSalesChannel.id).toEqual(salesChannel.id) expect(deletedSalesChannel.deleted_at).not.toEqual(null) }) - it("should delete the requested sales channel idempotently", async() => { + it("should delete the requested sales channel idempotently", async () => { const api = useApi() - let deletedSalesChannel = await dbConnection.manager.findOne(SalesChannel, { - where: { id: salesChannel.id }, - withDeleted: true - }) + let deletedSalesChannel = await dbConnection.manager.findOne( + SalesChannel, + { + where: { id: salesChannel.id }, + withDeleted: true, + } + ) expect(deletedSalesChannel.id).toEqual(salesChannel.id) expect(deletedSalesChannel.deleted_at).toEqual(null) @@ -247,12 +255,12 @@ describe("sales channels", () => { expect(response.data).toEqual({ id: expect.any(String), object: "sales-channel", - deleted: true + deleted: true, }) deletedSalesChannel = await dbConnection.manager.findOne(SalesChannel, { where: { id: salesChannel.id }, - withDeleted: true + withDeleted: true, }) expect(deletedSalesChannel.id).toEqual(salesChannel.id) @@ -267,16 +275,244 @@ describe("sales channels", () => { expect(response.data).toEqual({ id: expect.any(String), object: "sales-channel", - deleted: true + deleted: true, }) deletedSalesChannel = await dbConnection.manager.findOne(SalesChannel, { where: { id: salesChannel.id }, - withDeleted: true + withDeleted: true, }) expect(deletedSalesChannel.id).toEqual(salesChannel.id) expect(deletedSalesChannel.deleted_at).not.toEqual(null) }) }) + + describe("GET /admin/orders/:id", () => { + let order + beforeEach(async () => { + try { + await adminSeeder(dbConnection) + + order = await simpleOrderFactory(dbConnection, { + sales_channel: { + name: "test name", + description: "test description", + }, + }) + } catch (err) { + console.log(err) + } + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("expands sales channel for single", async () => { + const api = useApi() + + const response = await api.get( + `/admin/orders/${order.id}`, + adminReqConfig + ) + + expect(response.data.order.sales_channel).toBeTruthy() + expect(response.data.order.sales_channel).toMatchSnapshot({ + id: expect.any(String), + name: "test name", + description: "test description", + is_disabled: false, + created_at: expect.any(String), + updated_at: expect.any(String), + }) + }) + }) + + describe("GET /admin/orders?expand=sales_channels", () => { + beforeEach(async () => { + try { + await adminSeeder(dbConnection) + + await simpleOrderFactory(dbConnection, { + sales_channel: { + name: "test name", + description: "test description", + }, + }) + } catch (err) { + console.log(err) + } + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("expands sales channel with parameter", async () => { + const api = useApi() + + const response = await api.get( + "/admin/orders?expand=sales_channel", + adminReqConfig + ) + + expect(response.data.orders[0].sales_channel).toBeTruthy() + expect(response.data.orders[0].sales_channel).toMatchSnapshot({ + id: expect.any(String), + name: "test name", + description: "test description", + is_disabled: false, + created_at: expect.any(String), + updated_at: expect.any(String), + }) + }) + }) + + describe("GET /admin/product/:id", () => { + let product + beforeEach(async () => { + try { + await adminSeeder(dbConnection) + + product = await simpleProductFactory(dbConnection, { + sales_channels: [ + { + name: "webshop", + description: "Webshop sales channel", + }, + { + name: "amazon", + description: "Amazon sales channel", + }, + ], + }) + } catch (err) { + console.log(err) + } + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("returns product with sales channel", async () => { + const api = useApi() + + const response = await api + .get(`/admin/products/${product.id}`, adminReqConfig) + .catch((err) => console.log(err)) + + expect(response.data.product.sales_channels).toBeTruthy() + expect(response.data.product.sales_channels).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "webshop", + description: "Webshop sales channel", + is_disabled: false, + }), + expect.objectContaining({ + name: "amazon", + description: "Amazon sales channel", + is_disabled: false, + }), + ]) + ) + }) + }) + + describe("GET /admin/products?expand[]=sales_channels", () => { + beforeEach(async () => { + try { + await adminSeeder(dbConnection) + + await simpleProductFactory(dbConnection, { + sales_channels: [ + { + name: "webshop", + description: "Webshop sales channel", + }, + { + name: "amazon", + description: "Amazon sales channel", + }, + ], + }) + } catch (err) { + console.log(err) + } + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("expands sales channel with parameter", async () => { + const api = useApi() + + const response = await api.get( + "/admin/products?expand=sales_channels", + adminReqConfig + ) + + expect(response.data.products[0].sales_channels).toBeTruthy() + expect(response.data.products[0].sales_channels).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "webshop", + description: "Webshop sales channel", + is_disabled: false, + }), + expect.objectContaining({ + name: "amazon", + description: "Amazon sales channel", + is_disabled: false, + }), + ]) + ) + }) + }) + + describe("GET /store/cart/:id with saleschannel", () => { + let cart + beforeEach(async () => { + try { + await adminSeeder(dbConnection) + + cart = await simpleCartFactory(dbConnection, { + sales_channel: { + name: "test name", + description: "test description", + }, + }) + } catch (err) { + console.log(err) + } + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("returns cart with sales channel for single cart", async () => { + const api = useApi() + + const response = await api.get(`/store/carts/${cart.id}`, adminReqConfig) + + expect(response.data.cart.sales_channel).toBeTruthy() + expect(response.data.cart.sales_channel).toMatchSnapshot({ + id: expect.any(String), + name: "test name", + description: "test description", + is_disabled: false, + created_at: expect.any(String), + updated_at: expect.any(String), + }) + }) + }) }) diff --git a/integration-tests/api/factories/simple-cart-factory.ts b/integration-tests/api/factories/simple-cart-factory.ts index 61cee1a2cf..49d353e772 100644 --- a/integration-tests/api/factories/simple-cart-factory.ts +++ b/integration-tests/api/factories/simple-cart-factory.ts @@ -11,6 +11,10 @@ import { simpleLineItemFactory, } from "./simple-line-item-factory" import { RegionFactoryData, simpleRegionFactory } from "./simple-region-factory" +import { + SalesChannelFactoryData, + simpleSalesChannelFactory, +} from "./simple-sales-channel-factory" import { ShippingMethodFactoryData, simpleShippingMethodFactory, @@ -24,6 +28,7 @@ export type CartFactoryData = { line_items?: LineItemFactoryData[] shipping_address?: AddressFactoryData shipping_methods?: ShippingMethodFactoryData[] + sales_channel?: SalesChannelFactoryData } export const simpleCartFactory = async ( @@ -62,6 +67,14 @@ export const simpleCartFactory = async ( const address = await simpleAddressFactory(connection, data.shipping_address) + let sales_channel + if (typeof data.sales_channel !== "undefined") { + sales_channel = await simpleSalesChannelFactory( + connection, + data.sales_channel + ) + } + const id = data.id || `simple-cart-${Math.random() * 1000}` const toSave = manager.create(Cart, { id, @@ -70,6 +83,7 @@ export const simpleCartFactory = async ( region_id: regionId, customer_id: customerId, shipping_address_id: address.id, + sales_channel_id: sales_channel?.id ?? null, }) const cart = await manager.save(toSave) @@ -79,7 +93,7 @@ export const simpleCartFactory = async ( await simpleShippingMethodFactory(connection, { ...sm, cart_id: id }) } - const items = data.line_items + const items = data.line_items || [] for (const item of items) { await simpleLineItemFactory(connection, { ...item, cart_id: id }) } diff --git a/integration-tests/api/factories/simple-order-factory.ts b/integration-tests/api/factories/simple-order-factory.ts index 085b3407f3..70869df543 100644 --- a/integration-tests/api/factories/simple-order-factory.ts +++ b/integration-tests/api/factories/simple-order-factory.ts @@ -6,7 +6,6 @@ import { PaymentStatus, FulfillmentStatus, } from "@medusajs/medusa" - import { DiscountFactoryData, simpleDiscountFactory, @@ -24,6 +23,10 @@ import { ShippingMethodFactoryData, simpleShippingMethodFactory, } from "./simple-shipping-method-factory" +import { + SalesChannelFactoryData, + simpleSalesChannelFactory, +} from "./simple-sales-channel-factory" export type OrderFactoryData = { id?: string @@ -37,6 +40,7 @@ export type OrderFactoryData = { discounts?: DiscountFactoryData[] shipping_address?: AddressFactoryData shipping_methods?: ShippingMethodFactoryData[] + sales_channel?: SalesChannelFactoryData } export const simpleOrderFactory = async ( @@ -79,6 +83,14 @@ export const simpleOrderFactory = async ( ) } + let sales_channel + if (typeof data.sales_channel !== "undefined") { + sales_channel = await simpleSalesChannelFactory( + connection, + data.sales_channel + ) + } + const id = data.id || `simple-order-${Math.random() * 1000}` const toSave = manager.create(Order, { id, @@ -92,6 +104,7 @@ export const simpleOrderFactory = async ( currency_code: currencyCode, tax_rate: taxRate, shipping_address_id: address.id, + sales_channel_id: sales_channel?.id ?? null, }) const order = await manager.save(toSave) @@ -101,16 +114,17 @@ export const simpleOrderFactory = async ( await simpleShippingMethodFactory(connection, { ...sm, order_id: order.id }) } - const items = data.line_items.map((item) => { - let adjustments = item?.adjustments || [] - return { - ...item, - adjustments: adjustments.map((adj) => ({ - ...adj, - discount_id: discounts.find((d) => d.code === adj?.discount_code), - })), - } - }) + const items = + data.line_items?.map((item) => { + const adjustments = item?.adjustments || [] + return { + ...item, + adjustments: adjustments.map((adj) => ({ + ...adj, + discount_id: discounts.find((d) => d.code === adj?.discount_code), + })), + } + }) || [] for (const item of items) { await simpleLineItemFactory(connection, { ...item, order_id: id }) diff --git a/integration-tests/api/factories/simple-product-factory.ts b/integration-tests/api/factories/simple-product-factory.ts index c8d36389cc..304043028d 100644 --- a/integration-tests/api/factories/simple-product-factory.ts +++ b/integration-tests/api/factories/simple-product-factory.ts @@ -12,6 +12,10 @@ import { ProductVariantFactoryData, simpleProductVariantFactory, } from "./simple-product-variant-factory" +import { + SalesChannelFactoryData, + simpleSalesChannelFactory, +} from "./simple-sales-channel-factory" export type ProductFactoryData = { id?: string @@ -22,6 +26,7 @@ export type ProductFactoryData = { tags?: string[] options?: { id: string; title: string }[] variants?: ProductVariantFactoryData[] + sales_channels?: SalesChannelFactoryData[] } export const simpleProductFactory = async ( @@ -43,6 +48,16 @@ export const simpleProductFactory = async ( type: ShippingProfileType.GIFT_CARD, }) + let sales_channels + if (data.sales_channels) { + sales_channels = await Promise.all( + data.sales_channels.map( + async (salesChannel) => + await simpleSalesChannelFactory(connection, salesChannel) + ) + ) + } + const prodId = data.id || `simple-product-${Math.random() * 1000}` const productToCreate = { id: prodId, @@ -77,6 +92,8 @@ export const simpleProductFactory = async ( const toSave = manager.create(Product, productToCreate) + toSave.sales_channels = sales_channels + await manager.save(toSave) const optionId = `${prodId}-option` diff --git a/integration-tests/helpers/start-server-with-environment.js b/integration-tests/helpers/start-server-with-environment.js index cef895fb74..f80e63789b 100644 --- a/integration-tests/helpers/start-server-with-environment.js +++ b/integration-tests/helpers/start-server-with-environment.js @@ -12,7 +12,7 @@ const startServerWithEnvironment = async ({ cwd, verbose, env }) => { cwd, }) - Object.entries(env).forEach(([key, value]) => { + Object.entries(env).forEach(([key]) => { delete process.env[key] }) diff --git a/packages/medusa/jest.config.js b/packages/medusa/jest.config.js index c4a71a5082..3bf256722f 100644 --- a/packages/medusa/jest.config.js +++ b/packages/medusa/jest.config.js @@ -20,4 +20,5 @@ module.exports = { }, testEnvironment: `node`, moduleFileExtensions: [`js`, `jsx`, `ts`, `tsx`, `json`], + "setupFilesAfterEnv": ["/setupTests.js"] } diff --git a/packages/medusa/setupTests.js b/packages/medusa/setupTests.js new file mode 100644 index 0000000000..592165a908 --- /dev/null +++ b/packages/medusa/setupTests.js @@ -0,0 +1,3 @@ +global.afterEach(async () => { + await new Promise(resolve => setImmediate(resolve)) +}) diff --git a/packages/medusa/src/api/routes/admin/index.js b/packages/medusa/src/api/routes/admin/index.js index 3bb2aafd8e..171679d144 100644 --- a/packages/medusa/src/api/routes/admin/index.js +++ b/packages/medusa/src/api/routes/admin/index.js @@ -44,6 +44,8 @@ export default (app, container, config) => { }) ) + const featureFlagRouter = container.resolve("featureFlagRouter") + // Unauthenticated routes authRoutes(route) @@ -74,9 +76,9 @@ export default (app, container, config) => { inviteRoutes(route) noteRoutes(route) notificationRoutes(route) - orderRoutes(route) + orderRoutes(route, featureFlagRouter) priceListRoutes(route) - productRoutes(route) + productRoutes(route, featureFlagRouter) productTagRoutes(route) productTypesRoutes(route) regionRoutes(route) diff --git a/packages/medusa/src/api/routes/admin/orders/get-order.ts b/packages/medusa/src/api/routes/admin/orders/get-order.ts index dad7d694c7..9400ad489a 100644 --- a/packages/medusa/src/api/routes/admin/orders/get-order.ts +++ b/packages/medusa/src/api/routes/admin/orders/get-order.ts @@ -1,4 +1,3 @@ -import { defaultAdminOrdersRelations, defaultAdminOrdersFields } from "." import { OrderService } from "../../../../services" /** @@ -26,10 +25,7 @@ export default async (req, res) => { const orderService: OrderService = req.scope.resolve("orderService") - const order = await orderService.retrieve(id, { - select: defaultAdminOrdersFields, - relations: defaultAdminOrdersRelations, - }) + const order = await orderService.retrieve(id, req.retrieveConfig) res.json({ order }) } diff --git a/packages/medusa/src/api/routes/admin/orders/index.ts b/packages/medusa/src/api/routes/admin/orders/index.ts index 05254db5c3..cb5ccaa8b4 100644 --- a/packages/medusa/src/api/routes/admin/orders/index.ts +++ b/packages/medusa/src/api/routes/admin/orders/index.ts @@ -1,20 +1,36 @@ import { Router } from "express" import "reflect-metadata" import { Order } from "../../../.." -import { DeleteResponse, PaginatedResponse } from "../../../../types/common" -import middlewares from "../../../middlewares" +import { + DeleteResponse, + EmptyQueryParams, + PaginatedResponse, +} from "../../../../types/common" +import middlewares, { transformQuery } from "../../../middlewares" +import { AdminGetOrdersParams } from "./list-orders" +import { FlagRouter } from "../../../../utils/flag-router" const route = Router() -export default (app) => { +export default (app, featureFlagRouter: FlagRouter) => { app.use("/orders", route) + const relations = [...defaultAdminOrdersRelations] + if (featureFlagRouter.isFeatureEnabled("sales_channels")) { + relations.push("sales_channel") + } + /** * List orders */ route.get( "/", - middlewares.normalizeQuery(), + transformQuery(AdminGetOrdersParams, { + defaultRelations: relations, + defaultFields: defaultAdminOrdersFields, + allowedFields: allowedAdminOrdersFields, + isList: true, + }), middlewares.wrap(require("./list-orders").default) ) @@ -23,7 +39,12 @@ export default (app) => { */ route.get( "/:id", - middlewares.normalizeQuery(), + transformQuery(EmptyQueryParams, { + defaultRelations: relations, + defaultFields: defaultAdminOrdersFields, + allowedFields: allowedAdminOrdersFields, + isList: false, + }), middlewares.wrap(require("./get-order").default) ) diff --git a/packages/medusa/src/api/routes/admin/orders/list-orders.ts b/packages/medusa/src/api/routes/admin/orders/list-orders.ts index 5b1507a5e5..a8f25b8ff3 100644 --- a/packages/medusa/src/api/routes/admin/orders/list-orders.ts +++ b/packages/medusa/src/api/routes/admin/orders/list-orders.ts @@ -1,7 +1,5 @@ -import { defaultAdminOrdersRelations, defaultAdminOrdersFields } from "." -import { validator } from "../../../../utils/validator" import { IsNumber, IsOptional, IsString } from "class-validator" -import { omit, pick, pickBy } from "lodash" +import { pick } from "lodash" import { OrderService } from "../../../../services" import { AdminListOrdersSelector } from "../../../../types/orders" import { Type } from "class-transformer" @@ -47,51 +45,23 @@ import { Type } from "class-transformer" * $ref: "#/components/schemas/order" */ export default async (req, res) => { - const value = await validator(AdminGetOrdersParams, req.query) - const orderService: OrderService = req.scope.resolve("orderService") - let includeFields: string[] = [] - if (value.fields) { - includeFields = value.fields.split(",") - // Ensure created_at is included, since we are sorting on this - includeFields.push("created_at") - } - - let expandFields: string[] = [] - if (value.expand) { - expandFields = value.expand.split(",") - } - - const listConfig = { - select: includeFields.length ? includeFields : defaultAdminOrdersFields, - relations: expandFields.length ? expandFields : defaultAdminOrdersRelations, - skip: value.offset, - take: value.limit, - order: { created_at: "DESC" }, - } - - const filterableFields = omit(value, [ - "limit", - "offset", - "expand", - "fields", - "order", - ]) + const { skip, take, select, relations } = req.listConfig const [orders, count] = await orderService.listAndCount( - pickBy(filterableFields, (val) => typeof val !== "undefined"), - listConfig + req.filterableFields, + req.listConfig ) let data = orders - const fields = [...includeFields, ...expandFields] + const fields = [...select, ...relations] if (fields.length) { data = orders.map((o) => pick(o, fields)) } - res.json({ orders: data, count, offset: value.offset, limit: value.limit }) + res.json({ orders: data, count, offset: skip, limit: take }) } export class AdminGetOrdersParams extends AdminListOrdersSelector { diff --git a/packages/medusa/src/api/routes/admin/products/get-product.ts b/packages/medusa/src/api/routes/admin/products/get-product.ts index 3935385888..cea4a9783b 100644 --- a/packages/medusa/src/api/routes/admin/products/get-product.ts +++ b/packages/medusa/src/api/routes/admin/products/get-product.ts @@ -1,4 +1,3 @@ -import { defaultAdminProductFields, defaultAdminProductRelations } from "." import { ProductService, PricingService } from "../../../../services" /** @@ -27,10 +26,7 @@ export default async (req, res) => { const productService: ProductService = req.scope.resolve("productService") const pricingService: PricingService = req.scope.resolve("pricingService") - const rawProduct = await productService.retrieve(id, { - select: defaultAdminProductFields, - relations: defaultAdminProductRelations, - }) + const rawProduct = await productService.retrieve(id, req.retrieveConfig) const [product] = await pricingService.setProductPrices([rawProduct]) diff --git a/packages/medusa/src/api/routes/admin/products/index.ts b/packages/medusa/src/api/routes/admin/products/index.ts index 2b3931680f..ffc1072bf3 100644 --- a/packages/medusa/src/api/routes/admin/products/index.ts +++ b/packages/medusa/src/api/routes/admin/products/index.ts @@ -2,14 +2,21 @@ import { Router } from "express" import "reflect-metadata" import { PricedProduct } from "../../../../types/pricing" import { Product, ProductTag, ProductType } from "../../../.." -import { PaginatedResponse } from "../../../../types/common" -import middlewares from "../../../middlewares" +import { EmptyQueryParams, PaginatedResponse } from "../../../../types/common" +import middlewares, { transformQuery } from "../../../middlewares" +import { AdminGetProductsParams } from "./list-products" +import { FlagRouter } from "../../../../utils/flag-router" const route = Router() -export default (app) => { +export default (app, featureFlagRouter: FlagRouter) => { app.use("/products", route) + const relations = [...defaultAdminProductRelations] + if (featureFlagRouter.isFeatureEnabled("sales_channels")) { + relations.push("sales_channels") + } + route.post("/", middlewares.wrap(require("./create-product").default)) route.post("/:id", middlewares.wrap(require("./update-product").default)) route.get("/types", middlewares.wrap(require("./list-types").default)) @@ -53,11 +60,25 @@ export default (app) => { "/:id/metadata", middlewares.wrap(require("./set-metadata").default) ) + route.get( + "/:id", + transformQuery(EmptyQueryParams, { + defaultRelations: relations, + defaultFields: defaultAdminProductFields, + allowedFields: allowedAdminProductFields, + isList: false, + }), + middlewares.wrap(require("./get-product").default) + ) - route.get("/:id", middlewares.wrap(require("./get-product").default)) route.get( "/", - middlewares.normalizeQuery(), + transformQuery(AdminGetProductsParams, { + defaultRelations: defaultAdminProductRelations, + defaultFields: defaultAdminProductFields, + allowedFields: allowedAdminProductFields, + isList: true, + }), middlewares.wrap(require("./list-products").default) ) @@ -141,6 +162,7 @@ export const allowedAdminProductRelations = [ "tags", "type", "collection", + "sales_channels", ] export type AdminProductsDeleteOptionRes = { diff --git a/packages/medusa/src/api/routes/admin/products/list-products.ts b/packages/medusa/src/api/routes/admin/products/list-products.ts index 22468e13b6..a65badea54 100644 --- a/packages/medusa/src/api/routes/admin/products/list-products.ts +++ b/packages/medusa/src/api/routes/admin/products/list-products.ts @@ -8,17 +8,11 @@ import { IsString, ValidateNested, } from "class-validator" -import { omit } from "lodash" import { Product, ProductStatus } from "../../../../models/product" import { DateComparisonOperator } from "../../../../types/common" -import { - allowedAdminProductFields, - defaultAdminProductFields, - defaultAdminProductRelations, -} from "." -import listAndCount from "../../../../controllers/products/admin-list-products" -import { validator } from "../../../../utils/validator" import { optionalBooleanMapper } from "../../../../utils/validators/is-boolean" +import { PricedProduct } from "../../../../types/pricing" +import { PricingService, ProductService } from "../../../../services" /** * @oas [get] /products @@ -69,32 +63,31 @@ import { optionalBooleanMapper } from "../../../../utils/validators/is-boolean" * $ref: "#/components/schemas/product" */ export default async (req, res) => { - const validatedParams = await validator(AdminGetProductsParams, req.query) + const productService: ProductService = req.scope.resolve("productService") + const pricingService: PricingService = req.scope.resolve("pricingService") - const filterableFields = omit(validatedParams, [ - "limit", - "offset", - "expand", - "fields", - "order", - ]) + const { skip, take, relations } = req.listConfig - const result = await listAndCount( - req.scope, - filterableFields, - {}, - { - limit: validatedParams.limit ?? 50, - offset: validatedParams.offset ?? 0, - expand: validatedParams.expand, - fields: validatedParams.fields, - allowedFields: allowedAdminProductFields, - defaultFields: defaultAdminProductFields as (keyof Product)[], - defaultRelations: defaultAdminProductRelations, - } + const [rawProducts, count] = await productService.listAndCount( + req.filterableFields, + req.listConfig ) - res.json(result) + let products: (Product | PricedProduct)[] = rawProducts + + const includesPricing = ["variants", "variants.prices"].every((relation) => + relations?.includes(relation) + ) + if (includesPricing) { + products = await pricingService.setProductPrices(rawProducts) + } + + res.json({ + products, + count, + offset: skip, + limit: take, + }) } export class AdminGetProductsPaginationParams { diff --git a/packages/medusa/src/api/routes/store/carts/get-cart.ts b/packages/medusa/src/api/routes/store/carts/get-cart.ts index 99c043f75b..4876a1e620 100644 --- a/packages/medusa/src/api/routes/store/carts/get-cart.ts +++ b/packages/medusa/src/api/routes/store/carts/get-cart.ts @@ -1,4 +1,3 @@ -import { defaultStoreCartFields, defaultStoreCartRelations } from "." import { CartService } from "../../../../services" import { decorateLineItemsWithTotals } from "./decorate-line-items-with-totals" @@ -43,10 +42,7 @@ export default async (req, res) => { } } - cart = await cartService.retrieve(id, { - select: defaultStoreCartFields, - relations: defaultStoreCartRelations, - }) + cart = await cartService.retrieve(id, req.retrieveConfig) const data = await decorateLineItemsWithTotals(cart, req) res.json({ cart: data }) diff --git a/packages/medusa/src/api/routes/store/carts/index.ts b/packages/medusa/src/api/routes/store/carts/index.ts index 9234a86ae9..ccecd4db8f 100644 --- a/packages/medusa/src/api/routes/store/carts/index.ts +++ b/packages/medusa/src/api/routes/store/carts/index.ts @@ -1,22 +1,36 @@ import { Router } from "express" import "reflect-metadata" import { Cart, Order, Swap } from "../../../../" -import { DeleteResponse } from "../../../../types/common" -import middlewares from "../../../middlewares" +import { DeleteResponse, EmptyQueryParams } from "../../../../types/common" +import middlewares, { transformQuery } from "../../../middlewares" const route = Router() export default (app, container) => { const middlewareService = container.resolve("middlewareService") + const featureFlagRouter = container.resolve("featureFlagRouter") app.use("/carts", route) + const relations = [...defaultStoreCartRelations] + if (featureFlagRouter.isFeatureEnabled("sales_channels")) { + relations.push("sales_channel") + } + // Inject plugin routes const routers = middlewareService.getRouters("store/carts") for (const router of routers) { route.use("/", router) } - route.get("/:id", middlewares.wrap(require("./get-cart").default)) + route.get( + "/:id", + transformQuery(EmptyQueryParams, { + defaultRelations: relations, + defaultFields: defaultStoreCartFields, + isList: false, + }), + middlewares.wrap(require("./get-cart").default) + ) route.post( "/", diff --git a/packages/medusa/src/controllers/products/admin-list-products.ts b/packages/medusa/src/controllers/products/admin-list-products.ts deleted file mode 100644 index 417a1ba2b8..0000000000 --- a/packages/medusa/src/controllers/products/admin-list-products.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { AwilixContainer } from "awilix" -import { AdminProductsListRes } from "../../api" -import { pickBy } from "lodash" -import { MedusaError } from "medusa-core-utils" -import { Product } from "../../models/product" -import { ProductService, PricingService } from "../../services" -import { getListConfig } from "../../utils/get-query-config" -import { FilterableProductProps } from "../../types/product" -import { PricedProduct } from "../../types/pricing" - -type ListContext = { - limit: number - offset: number - order?: string - fields?: string - expand?: string - allowedFields?: string[] - defaultFields?: (keyof Product)[] - defaultRelations?: string[] -} - -const listAndCount = async ( - scope: AwilixContainer, - query: FilterableProductProps, - body?: object, - context: ListContext = { limit: 50, offset: 0 } -): Promise => { - const { limit, offset, allowedFields, defaultFields, defaultRelations } = - context - - const productService: ProductService = scope.resolve("productService") - const pricingService: PricingService = scope.resolve("pricingService") - let includeFields: (keyof Product)[] | undefined - if (context.fields) { - includeFields = context.fields.split(",") as (keyof Product)[] - } - - let expandFields: string[] | undefined - if (context.expand) { - expandFields = context.expand.split(",") - } - - let orderBy: { [k: symbol]: "DESC" | "ASC" } | undefined - if (typeof context.order !== "undefined") { - let orderField = context.order - if (context.order.startsWith("-")) { - const [, field] = context.order.split("-") - orderField = field - orderBy = { [field]: "DESC" } - } else { - orderBy = { [context.order]: "ASC" } - } - - if (!(allowedFields || []).includes(orderField)) { - throw new MedusaError( - MedusaError.Types.INVALID_DATA, - "Order field must be a valid product field" - ) - } - } - - const listConfig = getListConfig( - defaultFields ?? [], - defaultRelations ?? [], - includeFields, - expandFields, - limit, - offset, - orderBy - ) - - const [rawProducts, count] = await productService.listAndCount( - pickBy(query, (val) => typeof val !== "undefined"), - listConfig - ) - - let products: (Product | PricedProduct)[] = rawProducts - - const includesPricing = ["variants", "variants.prices"].every((relation) => - listConfig?.relations?.includes(relation) - ) - if (includesPricing) { - products = await pricingService.setProductPrices(rawProducts) - } - - return { - products, - count, - offset, - limit, - } -} - -export default listAndCount diff --git a/packages/medusa/src/models/product.ts b/packages/medusa/src/models/product.ts index 85e1248961..c67d56290d 100644 --- a/packages/medusa/src/models/product.ts +++ b/packages/medusa/src/models/product.ts @@ -9,7 +9,6 @@ import { ManyToMany, ManyToOne, OneToMany, - OneToOne, } from "typeorm" import { DbAwareColumn } from "../utils/db-aware-column" import { Image } from "./image" @@ -21,10 +20,7 @@ import { ProductVariant } from "./product-variant" import { ShippingProfile } from "./shipping-profile" import { SoftDeletableEntity } from "../interfaces/models/soft-deletable-entity" import { generateEntityId } from "../utils/generate-entity-id" -import { - FeatureFlagColumn, - FeatureFlagDecorators, -} from "../utils/feature-flag-decorators" +import { FeatureFlagDecorators } from "../utils/feature-flag-decorators" import { SalesChannel } from "./sales-channel" export enum ProductStatus { diff --git a/packages/medusa/src/types/common.ts b/packages/medusa/src/types/common.ts index 09378d4dfb..dffbc91dc8 100644 --- a/packages/medusa/src/types/common.ts +++ b/packages/medusa/src/types/common.ts @@ -112,6 +112,8 @@ export type DeleteResponse = { deleted: boolean } +export class EmptyQueryParams {} + export class DateComparisonOperator { @IsOptional() @IsDate() diff --git a/packages/medusa/src/utils/feature-flag-decorators.ts b/packages/medusa/src/utils/feature-flag-decorators.ts index b89a88f8b9..7f8a882a5e 100644 --- a/packages/medusa/src/utils/feature-flag-decorators.ts +++ b/packages/medusa/src/utils/feature-flag-decorators.ts @@ -1,40 +1,59 @@ -import { getConfigFile } from "medusa-core-utils" + import { getConfigFile } from "medusa-core-utils" import { Column, ColumnOptions, Entity, EntityOptions } from "typeorm" import featureFlagsLoader from "../loaders/feature-flags" import path from "path" import { ConfigModule } from "../types/global" import { FlagRouter } from "./flag-router" + /** + * If that file is required in a non node environment then the setImmediate timer does not exists. + * This can happen when a client package require a server based package and that one of the import + * require to import that file which is using the setImmediate. + * In order to take care of those cases, the setImmediate timer will use the one provided by the api (node) + * if possible and will provide a mock in a browser like environment. + */ +let setImmediate_ +try { + setImmediate_ = setImmediate +} catch (e) { + console.warn( + "[feature-flag-decorator.ts] setImmediate will use a mock, this happen when this file is required in a browser environment and should not impact you" + ) + setImmediate_ = ((callback: () => void | Promise) => callback()) +} + export function FeatureFlagColumn( featureFlag: string, columnOptions: ColumnOptions = {} ): PropertyDecorator { - const featureFlagRouter = getFeatureFlagRouter() + return function (target, propertyName) { + setImmediate_((): any => { + const featureFlagRouter = getFeatureFlagRouter() - if (!featureFlagRouter.isFeatureEnabled(featureFlag)) { - return (): void => { - // noop - } + if (!featureFlagRouter.isFeatureEnabled(featureFlag)) { + return + } + + Column(columnOptions)(target, propertyName) + }) } - - return Column(columnOptions) } export function FeatureFlagDecorators( featureFlag: string, decorators: PropertyDecorator[] ): PropertyDecorator { - const featureFlagRouter = getFeatureFlagRouter() + return function (target, propertyName) { + setImmediate_((): any => { + const featureFlagRouter = getFeatureFlagRouter() - if (!featureFlagRouter.isFeatureEnabled(featureFlag)) { - return (): void => { - // noop - } - } - // eslint-disable-next-line @typescript-eslint/ban-types - return (target: Object, propertyKey: string | symbol): void => { - decorators.forEach((decorator) => { - decorator(target, propertyKey) + if (!featureFlagRouter.isFeatureEnabled(featureFlag)) { + return + } + + decorators.forEach((decorator: PropertyDecorator) => { + decorator(target, propertyName) + }) }) } } @@ -44,12 +63,10 @@ export function FeatureFlagEntity( name?: string, options?: EntityOptions ): ClassDecorator { - // eslint-disable-next-line @typescript-eslint/ban-types return function (target: Function): void { target["isFeatureEnabled"] = function (): boolean { const featureFlagRouter = getFeatureFlagRouter() - // const featureFlagRouter = featureFlagsLoader(configModule) return featureFlagRouter.isFeatureEnabled(featureFlag) } Entity(name, options)(target) diff --git a/packages/medusa/src/utils/get-query-config.ts b/packages/medusa/src/utils/get-query-config.ts index c876a05245..841a4981b0 100644 --- a/packages/medusa/src/utils/get-query-config.ts +++ b/packages/medusa/src/utils/get-query-config.ts @@ -27,14 +27,14 @@ export function getRetrieveConfig( ): FindConfig { let includeFields: (keyof TModel)[] = [] if (typeof fields !== "undefined") { - includeFields = Array - .from(new Set([...fields, "id"])) - .map(field => (typeof field === "string") ? field.trim() : field) as (keyof TModel)[] + includeFields = Array.from(new Set([...fields, "id"])).map((field) => + typeof field === "string" ? field.trim() : field + ) as (keyof TModel)[] } let expandFields: string[] = [] if (typeof expand !== "undefined") { - expandFields = expand.map(expandRelation => expandRelation.trim()) + expandFields = expand.map((expandRelation) => expandRelation.trim()) } return { @@ -106,7 +106,10 @@ export function prepareListQuery< orderBy = { [order]: "ASC" } } - if (queryConfig?.allowedFields?.length && !queryConfig?.allowedFields.includes(orderField)) { + if ( + queryConfig?.allowedFields?.length && + !queryConfig?.allowedFields.includes(orderField) + ) { throw new MedusaError( MedusaError.Types.INVALID_DATA, `Order field ${orderField} is not valid`