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>
This commit is contained in:
Philip Korsholm
2022-07-11 16:45:01 +00:00
committed by GitHub
co-authored by Sebastian Rindom Adrien de Peretti
parent fb4cfc3c3c
commit 19f35ba6aa
22 changed files with 510 additions and 255 deletions
@@ -8,6 +8,30 @@ Object {
} }
`; `;
exports[`sales channels GET /admin/orders/:id expands sales channel for single 1`] = `
Object {
"created_at": Any<String>,
"deleted_at": null,
"description": "test description",
"id": Any<String>,
"is_disabled": false,
"name": "test name",
"updated_at": Any<String>,
}
`;
exports[`sales channels GET /admin/orders?expand=sales_channels expands sales channel with parameter 1`] = `
Object {
"created_at": Any<String>,
"deleted_at": null,
"description": "test description",
"id": Any<String>,
"is_disabled": false,
"name": "test name",
"updated_at": Any<String>,
}
`;
exports[`sales channels GET /admin/sales-channels/:id should retrieve the requested sales channel 1`] = ` exports[`sales channels GET /admin/sales-channels/:id should retrieve the requested sales channel 1`] = `
Object { Object {
"created_at": Any<String>, "created_at": Any<String>,
@@ -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<String>,
"deleted_at": null,
"description": "test description",
"id": Any<String>,
"is_disabled": false,
"name": "test name",
"updated_at": Any<String>,
}
`;
exports[`sales channels POST /admin/sales-channels successfully creates a sales channel 1`] = ` exports[`sales channels POST /admin/sales-channels successfully creates a sales channel 1`] = `
Object { Object {
"sales_channel": ObjectContaining { "sales_channel": ObjectContaining {
@@ -1,11 +1,18 @@
const path = require("path") const path = require("path")
const { SalesChannel } = require("@medusajs/medusa") const { SalesChannel } = require("@medusajs/medusa")
const { useApi } = require("../../../helpers/use-api") const { useApi } = require("../../../helpers/use-api")
const { useDb } = require("../../../helpers/use-db") const { useDb } = require("../../../helpers/use-db")
const adminSeeder = require("../../helpers/admin-seeder") const adminSeeder = require("../../helpers/admin-seeder")
const { simpleSalesChannelFactory } = require("../../factories")
const {
simpleSalesChannelFactory,
simpleProductFactory,
simpleCartFactory,
} = require("../../factories")
const { simpleOrderFactory } = require("../../factories")
const startServerWithEnvironment = const startServerWithEnvironment =
require("../../../helpers/start-server-with-environment").default require("../../../helpers/start-server-with-environment").default
@@ -130,7 +137,6 @@ describe("sales channels", () => {
}) })
}) })
describe("POST /admin/sales-channels", () => { describe("POST /admin/sales-channels", () => {
beforeEach(async () => { beforeEach(async () => {
try { 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", () => { describe("DELETE /admin/sales-channels/:id", () => {
let salesChannel let salesChannel
beforeEach(async() => { beforeEach(async () => {
try { try {
await adminSeeder(dbConnection) await adminSeeder(dbConnection)
salesChannel = await simpleSalesChannelFactory(dbConnection, { salesChannel = await simpleSalesChannelFactory(dbConnection, {
@@ -194,14 +197,16 @@ describe("sales channels", () => {
const db = useDb() const db = useDb()
await db.teardown() await db.teardown()
}) })
it("should delete the requested sales channel", async () => {
it("should delete the requested sales channel", async() => {
const api = useApi() const api = useApi()
let deletedSalesChannel = await dbConnection.manager.findOne(SalesChannel, { let deletedSalesChannel = await dbConnection.manager.findOne(
where: { id: salesChannel.id }, SalesChannel,
withDeleted: true {
}) where: { id: salesChannel.id },
withDeleted: true,
}
)
expect(deletedSalesChannel.id).toEqual(salesChannel.id) expect(deletedSalesChannel.id).toEqual(salesChannel.id)
expect(deletedSalesChannel.deleted_at).toEqual(null) expect(deletedSalesChannel.deleted_at).toEqual(null)
@@ -220,20 +225,23 @@ describe("sales channels", () => {
deletedSalesChannel = await dbConnection.manager.findOne(SalesChannel, { deletedSalesChannel = await dbConnection.manager.findOne(SalesChannel, {
where: { id: salesChannel.id }, where: { id: salesChannel.id },
withDeleted: true withDeleted: true,
}) })
expect(deletedSalesChannel.id).toEqual(salesChannel.id) expect(deletedSalesChannel.id).toEqual(salesChannel.id)
expect(deletedSalesChannel.deleted_at).not.toEqual(null) 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() const api = useApi()
let deletedSalesChannel = await dbConnection.manager.findOne(SalesChannel, { let deletedSalesChannel = await dbConnection.manager.findOne(
where: { id: salesChannel.id }, SalesChannel,
withDeleted: true {
}) where: { id: salesChannel.id },
withDeleted: true,
}
)
expect(deletedSalesChannel.id).toEqual(salesChannel.id) expect(deletedSalesChannel.id).toEqual(salesChannel.id)
expect(deletedSalesChannel.deleted_at).toEqual(null) expect(deletedSalesChannel.deleted_at).toEqual(null)
@@ -247,12 +255,12 @@ describe("sales channels", () => {
expect(response.data).toEqual({ expect(response.data).toEqual({
id: expect.any(String), id: expect.any(String),
object: "sales-channel", object: "sales-channel",
deleted: true deleted: true,
}) })
deletedSalesChannel = await dbConnection.manager.findOne(SalesChannel, { deletedSalesChannel = await dbConnection.manager.findOne(SalesChannel, {
where: { id: salesChannel.id }, where: { id: salesChannel.id },
withDeleted: true withDeleted: true,
}) })
expect(deletedSalesChannel.id).toEqual(salesChannel.id) expect(deletedSalesChannel.id).toEqual(salesChannel.id)
@@ -267,16 +275,244 @@ describe("sales channels", () => {
expect(response.data).toEqual({ expect(response.data).toEqual({
id: expect.any(String), id: expect.any(String),
object: "sales-channel", object: "sales-channel",
deleted: true deleted: true,
}) })
deletedSalesChannel = await dbConnection.manager.findOne(SalesChannel, { deletedSalesChannel = await dbConnection.manager.findOne(SalesChannel, {
where: { id: salesChannel.id }, where: { id: salesChannel.id },
withDeleted: true withDeleted: true,
}) })
expect(deletedSalesChannel.id).toEqual(salesChannel.id) expect(deletedSalesChannel.id).toEqual(salesChannel.id)
expect(deletedSalesChannel.deleted_at).not.toEqual(null) 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),
})
})
})
}) })
@@ -11,6 +11,10 @@ import {
simpleLineItemFactory, simpleLineItemFactory,
} from "./simple-line-item-factory" } from "./simple-line-item-factory"
import { RegionFactoryData, simpleRegionFactory } from "./simple-region-factory" import { RegionFactoryData, simpleRegionFactory } from "./simple-region-factory"
import {
SalesChannelFactoryData,
simpleSalesChannelFactory,
} from "./simple-sales-channel-factory"
import { import {
ShippingMethodFactoryData, ShippingMethodFactoryData,
simpleShippingMethodFactory, simpleShippingMethodFactory,
@@ -24,6 +28,7 @@ export type CartFactoryData = {
line_items?: LineItemFactoryData[] line_items?: LineItemFactoryData[]
shipping_address?: AddressFactoryData shipping_address?: AddressFactoryData
shipping_methods?: ShippingMethodFactoryData[] shipping_methods?: ShippingMethodFactoryData[]
sales_channel?: SalesChannelFactoryData
} }
export const simpleCartFactory = async ( export const simpleCartFactory = async (
@@ -62,6 +67,14 @@ export const simpleCartFactory = async (
const address = await simpleAddressFactory(connection, data.shipping_address) 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 id = data.id || `simple-cart-${Math.random() * 1000}`
const toSave = manager.create(Cart, { const toSave = manager.create(Cart, {
id, id,
@@ -70,6 +83,7 @@ export const simpleCartFactory = async (
region_id: regionId, region_id: regionId,
customer_id: customerId, customer_id: customerId,
shipping_address_id: address.id, shipping_address_id: address.id,
sales_channel_id: sales_channel?.id ?? null,
}) })
const cart = await manager.save(toSave) const cart = await manager.save(toSave)
@@ -79,7 +93,7 @@ export const simpleCartFactory = async (
await simpleShippingMethodFactory(connection, { ...sm, cart_id: id }) await simpleShippingMethodFactory(connection, { ...sm, cart_id: id })
} }
const items = data.line_items const items = data.line_items || []
for (const item of items) { for (const item of items) {
await simpleLineItemFactory(connection, { ...item, cart_id: id }) await simpleLineItemFactory(connection, { ...item, cart_id: id })
} }
@@ -6,7 +6,6 @@ import {
PaymentStatus, PaymentStatus,
FulfillmentStatus, FulfillmentStatus,
} from "@medusajs/medusa" } from "@medusajs/medusa"
import { import {
DiscountFactoryData, DiscountFactoryData,
simpleDiscountFactory, simpleDiscountFactory,
@@ -24,6 +23,10 @@ import {
ShippingMethodFactoryData, ShippingMethodFactoryData,
simpleShippingMethodFactory, simpleShippingMethodFactory,
} from "./simple-shipping-method-factory" } from "./simple-shipping-method-factory"
import {
SalesChannelFactoryData,
simpleSalesChannelFactory,
} from "./simple-sales-channel-factory"
export type OrderFactoryData = { export type OrderFactoryData = {
id?: string id?: string
@@ -37,6 +40,7 @@ export type OrderFactoryData = {
discounts?: DiscountFactoryData[] discounts?: DiscountFactoryData[]
shipping_address?: AddressFactoryData shipping_address?: AddressFactoryData
shipping_methods?: ShippingMethodFactoryData[] shipping_methods?: ShippingMethodFactoryData[]
sales_channel?: SalesChannelFactoryData
} }
export const simpleOrderFactory = async ( 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 id = data.id || `simple-order-${Math.random() * 1000}`
const toSave = manager.create(Order, { const toSave = manager.create(Order, {
id, id,
@@ -92,6 +104,7 @@ export const simpleOrderFactory = async (
currency_code: currencyCode, currency_code: currencyCode,
tax_rate: taxRate, tax_rate: taxRate,
shipping_address_id: address.id, shipping_address_id: address.id,
sales_channel_id: sales_channel?.id ?? null,
}) })
const order = await manager.save(toSave) const order = await manager.save(toSave)
@@ -101,16 +114,17 @@ export const simpleOrderFactory = async (
await simpleShippingMethodFactory(connection, { ...sm, order_id: order.id }) await simpleShippingMethodFactory(connection, { ...sm, order_id: order.id })
} }
const items = data.line_items.map((item) => { const items =
let adjustments = item?.adjustments || [] data.line_items?.map((item) => {
return { const adjustments = item?.adjustments || []
...item, return {
adjustments: adjustments.map((adj) => ({ ...item,
...adj, adjustments: adjustments.map((adj) => ({
discount_id: discounts.find((d) => d.code === adj?.discount_code), ...adj,
})), discount_id: discounts.find((d) => d.code === adj?.discount_code),
} })),
}) }
}) || []
for (const item of items) { for (const item of items) {
await simpleLineItemFactory(connection, { ...item, order_id: id }) await simpleLineItemFactory(connection, { ...item, order_id: id })
@@ -12,6 +12,10 @@ import {
ProductVariantFactoryData, ProductVariantFactoryData,
simpleProductVariantFactory, simpleProductVariantFactory,
} from "./simple-product-variant-factory" } from "./simple-product-variant-factory"
import {
SalesChannelFactoryData,
simpleSalesChannelFactory,
} from "./simple-sales-channel-factory"
export type ProductFactoryData = { export type ProductFactoryData = {
id?: string id?: string
@@ -22,6 +26,7 @@ export type ProductFactoryData = {
tags?: string[] tags?: string[]
options?: { id: string; title: string }[] options?: { id: string; title: string }[]
variants?: ProductVariantFactoryData[] variants?: ProductVariantFactoryData[]
sales_channels?: SalesChannelFactoryData[]
} }
export const simpleProductFactory = async ( export const simpleProductFactory = async (
@@ -43,6 +48,16 @@ export const simpleProductFactory = async (
type: ShippingProfileType.GIFT_CARD, 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 prodId = data.id || `simple-product-${Math.random() * 1000}`
const productToCreate = { const productToCreate = {
id: prodId, id: prodId,
@@ -77,6 +92,8 @@ export const simpleProductFactory = async (
const toSave = manager.create(Product, productToCreate) const toSave = manager.create(Product, productToCreate)
toSave.sales_channels = sales_channels
await manager.save(toSave) await manager.save(toSave)
const optionId = `${prodId}-option` const optionId = `${prodId}-option`
@@ -12,7 +12,7 @@ const startServerWithEnvironment = async ({ cwd, verbose, env }) => {
cwd, cwd,
}) })
Object.entries(env).forEach(([key, value]) => { Object.entries(env).forEach(([key]) => {
delete process.env[key] delete process.env[key]
}) })
+1
View File
@@ -20,4 +20,5 @@ module.exports = {
}, },
testEnvironment: `node`, testEnvironment: `node`,
moduleFileExtensions: [`js`, `jsx`, `ts`, `tsx`, `json`], moduleFileExtensions: [`js`, `jsx`, `ts`, `tsx`, `json`],
"setupFilesAfterEnv": ["<rootDir>/setupTests.js"]
} }
+3
View File
@@ -0,0 +1,3 @@
global.afterEach(async () => {
await new Promise(resolve => setImmediate(resolve))
})
@@ -44,6 +44,8 @@ export default (app, container, config) => {
}) })
) )
const featureFlagRouter = container.resolve("featureFlagRouter")
// Unauthenticated routes // Unauthenticated routes
authRoutes(route) authRoutes(route)
@@ -74,9 +76,9 @@ export default (app, container, config) => {
inviteRoutes(route) inviteRoutes(route)
noteRoutes(route) noteRoutes(route)
notificationRoutes(route) notificationRoutes(route)
orderRoutes(route) orderRoutes(route, featureFlagRouter)
priceListRoutes(route) priceListRoutes(route)
productRoutes(route) productRoutes(route, featureFlagRouter)
productTagRoutes(route) productTagRoutes(route)
productTypesRoutes(route) productTypesRoutes(route)
regionRoutes(route) regionRoutes(route)
@@ -1,4 +1,3 @@
import { defaultAdminOrdersRelations, defaultAdminOrdersFields } from "."
import { OrderService } from "../../../../services" import { OrderService } from "../../../../services"
/** /**
@@ -26,10 +25,7 @@ export default async (req, res) => {
const orderService: OrderService = req.scope.resolve("orderService") const orderService: OrderService = req.scope.resolve("orderService")
const order = await orderService.retrieve(id, { const order = await orderService.retrieve(id, req.retrieveConfig)
select: defaultAdminOrdersFields,
relations: defaultAdminOrdersRelations,
})
res.json({ order }) res.json({ order })
} }
@@ -1,20 +1,36 @@
import { Router } from "express" import { Router } from "express"
import "reflect-metadata" import "reflect-metadata"
import { Order } from "../../../.." import { Order } from "../../../.."
import { DeleteResponse, PaginatedResponse } from "../../../../types/common" import {
import middlewares from "../../../middlewares" 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() const route = Router()
export default (app) => { export default (app, featureFlagRouter: FlagRouter) => {
app.use("/orders", route) app.use("/orders", route)
const relations = [...defaultAdminOrdersRelations]
if (featureFlagRouter.isFeatureEnabled("sales_channels")) {
relations.push("sales_channel")
}
/** /**
* List orders * List orders
*/ */
route.get( route.get(
"/", "/",
middlewares.normalizeQuery(), transformQuery(AdminGetOrdersParams, {
defaultRelations: relations,
defaultFields: defaultAdminOrdersFields,
allowedFields: allowedAdminOrdersFields,
isList: true,
}),
middlewares.wrap(require("./list-orders").default) middlewares.wrap(require("./list-orders").default)
) )
@@ -23,7 +39,12 @@ export default (app) => {
*/ */
route.get( route.get(
"/:id", "/:id",
middlewares.normalizeQuery(), transformQuery(EmptyQueryParams, {
defaultRelations: relations,
defaultFields: defaultAdminOrdersFields,
allowedFields: allowedAdminOrdersFields,
isList: false,
}),
middlewares.wrap(require("./get-order").default) middlewares.wrap(require("./get-order").default)
) )
@@ -1,7 +1,5 @@
import { defaultAdminOrdersRelations, defaultAdminOrdersFields } from "."
import { validator } from "../../../../utils/validator"
import { IsNumber, IsOptional, IsString } from "class-validator" import { IsNumber, IsOptional, IsString } from "class-validator"
import { omit, pick, pickBy } from "lodash" import { pick } from "lodash"
import { OrderService } from "../../../../services" import { OrderService } from "../../../../services"
import { AdminListOrdersSelector } from "../../../../types/orders" import { AdminListOrdersSelector } from "../../../../types/orders"
import { Type } from "class-transformer" import { Type } from "class-transformer"
@@ -47,51 +45,23 @@ import { Type } from "class-transformer"
* $ref: "#/components/schemas/order" * $ref: "#/components/schemas/order"
*/ */
export default async (req, res) => { export default async (req, res) => {
const value = await validator(AdminGetOrdersParams, req.query)
const orderService: OrderService = req.scope.resolve("orderService") const orderService: OrderService = req.scope.resolve("orderService")
let includeFields: string[] = [] const { skip, take, select, relations } = req.listConfig
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 [orders, count] = await orderService.listAndCount( const [orders, count] = await orderService.listAndCount(
pickBy(filterableFields, (val) => typeof val !== "undefined"), req.filterableFields,
listConfig req.listConfig
) )
let data = orders let data = orders
const fields = [...includeFields, ...expandFields] const fields = [...select, ...relations]
if (fields.length) { if (fields.length) {
data = orders.map((o) => pick(o, fields)) 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 { export class AdminGetOrdersParams extends AdminListOrdersSelector {
@@ -1,4 +1,3 @@
import { defaultAdminProductFields, defaultAdminProductRelations } from "."
import { ProductService, PricingService } from "../../../../services" import { ProductService, PricingService } from "../../../../services"
/** /**
@@ -27,10 +26,7 @@ export default async (req, res) => {
const productService: ProductService = req.scope.resolve("productService") const productService: ProductService = req.scope.resolve("productService")
const pricingService: PricingService = req.scope.resolve("pricingService") const pricingService: PricingService = req.scope.resolve("pricingService")
const rawProduct = await productService.retrieve(id, { const rawProduct = await productService.retrieve(id, req.retrieveConfig)
select: defaultAdminProductFields,
relations: defaultAdminProductRelations,
})
const [product] = await pricingService.setProductPrices([rawProduct]) const [product] = await pricingService.setProductPrices([rawProduct])
@@ -2,14 +2,21 @@ import { Router } from "express"
import "reflect-metadata" import "reflect-metadata"
import { PricedProduct } from "../../../../types/pricing" import { PricedProduct } from "../../../../types/pricing"
import { Product, ProductTag, ProductType } from "../../../.." import { Product, ProductTag, ProductType } from "../../../.."
import { PaginatedResponse } from "../../../../types/common" import { EmptyQueryParams, PaginatedResponse } from "../../../../types/common"
import middlewares from "../../../middlewares" import middlewares, { transformQuery } from "../../../middlewares"
import { AdminGetProductsParams } from "./list-products"
import { FlagRouter } from "../../../../utils/flag-router"
const route = Router() const route = Router()
export default (app) => { export default (app, featureFlagRouter: FlagRouter) => {
app.use("/products", route) 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("/", middlewares.wrap(require("./create-product").default))
route.post("/:id", middlewares.wrap(require("./update-product").default)) route.post("/:id", middlewares.wrap(require("./update-product").default))
route.get("/types", middlewares.wrap(require("./list-types").default)) route.get("/types", middlewares.wrap(require("./list-types").default))
@@ -53,11 +60,25 @@ export default (app) => {
"/:id/metadata", "/:id/metadata",
middlewares.wrap(require("./set-metadata").default) 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( route.get(
"/", "/",
middlewares.normalizeQuery(), transformQuery(AdminGetProductsParams, {
defaultRelations: defaultAdminProductRelations,
defaultFields: defaultAdminProductFields,
allowedFields: allowedAdminProductFields,
isList: true,
}),
middlewares.wrap(require("./list-products").default) middlewares.wrap(require("./list-products").default)
) )
@@ -141,6 +162,7 @@ export const allowedAdminProductRelations = [
"tags", "tags",
"type", "type",
"collection", "collection",
"sales_channels",
] ]
export type AdminProductsDeleteOptionRes = { export type AdminProductsDeleteOptionRes = {
@@ -8,17 +8,11 @@ import {
IsString, IsString,
ValidateNested, ValidateNested,
} from "class-validator" } from "class-validator"
import { omit } from "lodash"
import { Product, ProductStatus } from "../../../../models/product" import { Product, ProductStatus } from "../../../../models/product"
import { DateComparisonOperator } from "../../../../types/common" 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 { optionalBooleanMapper } from "../../../../utils/validators/is-boolean"
import { PricedProduct } from "../../../../types/pricing"
import { PricingService, ProductService } from "../../../../services"
/** /**
* @oas [get] /products * @oas [get] /products
@@ -69,32 +63,31 @@ import { optionalBooleanMapper } from "../../../../utils/validators/is-boolean"
* $ref: "#/components/schemas/product" * $ref: "#/components/schemas/product"
*/ */
export default async (req, res) => { 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, [ const { skip, take, relations } = req.listConfig
"limit",
"offset",
"expand",
"fields",
"order",
])
const result = await listAndCount( const [rawProducts, count] = await productService.listAndCount(
req.scope, req.filterableFields,
filterableFields, req.listConfig
{},
{
limit: validatedParams.limit ?? 50,
offset: validatedParams.offset ?? 0,
expand: validatedParams.expand,
fields: validatedParams.fields,
allowedFields: allowedAdminProductFields,
defaultFields: defaultAdminProductFields as (keyof Product)[],
defaultRelations: defaultAdminProductRelations,
}
) )
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 { export class AdminGetProductsPaginationParams {
@@ -1,4 +1,3 @@
import { defaultStoreCartFields, defaultStoreCartRelations } from "."
import { CartService } from "../../../../services" import { CartService } from "../../../../services"
import { decorateLineItemsWithTotals } from "./decorate-line-items-with-totals" import { decorateLineItemsWithTotals } from "./decorate-line-items-with-totals"
@@ -43,10 +42,7 @@ export default async (req, res) => {
} }
} }
cart = await cartService.retrieve(id, { cart = await cartService.retrieve(id, req.retrieveConfig)
select: defaultStoreCartFields,
relations: defaultStoreCartRelations,
})
const data = await decorateLineItemsWithTotals(cart, req) const data = await decorateLineItemsWithTotals(cart, req)
res.json({ cart: data }) res.json({ cart: data })
@@ -1,22 +1,36 @@
import { Router } from "express" import { Router } from "express"
import "reflect-metadata" import "reflect-metadata"
import { Cart, Order, Swap } from "../../../../" import { Cart, Order, Swap } from "../../../../"
import { DeleteResponse } from "../../../../types/common" import { DeleteResponse, EmptyQueryParams } from "../../../../types/common"
import middlewares from "../../../middlewares" import middlewares, { transformQuery } from "../../../middlewares"
const route = Router() const route = Router()
export default (app, container) => { export default (app, container) => {
const middlewareService = container.resolve("middlewareService") const middlewareService = container.resolve("middlewareService")
const featureFlagRouter = container.resolve("featureFlagRouter")
app.use("/carts", route) app.use("/carts", route)
const relations = [...defaultStoreCartRelations]
if (featureFlagRouter.isFeatureEnabled("sales_channels")) {
relations.push("sales_channel")
}
// Inject plugin routes // Inject plugin routes
const routers = middlewareService.getRouters("store/carts") const routers = middlewareService.getRouters("store/carts")
for (const router of routers) { for (const router of routers) {
route.use("/", router) 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( route.post(
"/", "/",
@@ -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<AdminProductsListRes> => {
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<Product>(
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
+1 -5
View File
@@ -9,7 +9,6 @@ import {
ManyToMany, ManyToMany,
ManyToOne, ManyToOne,
OneToMany, OneToMany,
OneToOne,
} from "typeorm" } from "typeorm"
import { DbAwareColumn } from "../utils/db-aware-column" import { DbAwareColumn } from "../utils/db-aware-column"
import { Image } from "./image" import { Image } from "./image"
@@ -21,10 +20,7 @@ import { ProductVariant } from "./product-variant"
import { ShippingProfile } from "./shipping-profile" import { ShippingProfile } from "./shipping-profile"
import { SoftDeletableEntity } from "../interfaces/models/soft-deletable-entity" import { SoftDeletableEntity } from "../interfaces/models/soft-deletable-entity"
import { generateEntityId } from "../utils/generate-entity-id" import { generateEntityId } from "../utils/generate-entity-id"
import { import { FeatureFlagDecorators } from "../utils/feature-flag-decorators"
FeatureFlagColumn,
FeatureFlagDecorators,
} from "../utils/feature-flag-decorators"
import { SalesChannel } from "./sales-channel" import { SalesChannel } from "./sales-channel"
export enum ProductStatus { export enum ProductStatus {
+2
View File
@@ -112,6 +112,8 @@ export type DeleteResponse = {
deleted: boolean deleted: boolean
} }
export class EmptyQueryParams {}
export class DateComparisonOperator { export class DateComparisonOperator {
@IsOptional() @IsOptional()
@IsDate() @IsDate()
@@ -1,40 +1,59 @@
import { getConfigFile } from "medusa-core-utils" import { getConfigFile } from "medusa-core-utils"
import { Column, ColumnOptions, Entity, EntityOptions } from "typeorm" import { Column, ColumnOptions, Entity, EntityOptions } from "typeorm"
import featureFlagsLoader from "../loaders/feature-flags" import featureFlagsLoader from "../loaders/feature-flags"
import path from "path" import path from "path"
import { ConfigModule } from "../types/global" import { ConfigModule } from "../types/global"
import { FlagRouter } from "./flag-router" 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<void>) => callback())
}
export function FeatureFlagColumn( export function FeatureFlagColumn(
featureFlag: string, featureFlag: string,
columnOptions: ColumnOptions = {} columnOptions: ColumnOptions = {}
): PropertyDecorator { ): PropertyDecorator {
const featureFlagRouter = getFeatureFlagRouter() return function (target, propertyName) {
setImmediate_((): any => {
const featureFlagRouter = getFeatureFlagRouter()
if (!featureFlagRouter.isFeatureEnabled(featureFlag)) { if (!featureFlagRouter.isFeatureEnabled(featureFlag)) {
return (): void => { return
// noop }
}
Column(columnOptions)(target, propertyName)
})
} }
return Column(columnOptions)
} }
export function FeatureFlagDecorators( export function FeatureFlagDecorators(
featureFlag: string, featureFlag: string,
decorators: PropertyDecorator[] decorators: PropertyDecorator[]
): PropertyDecorator { ): PropertyDecorator {
const featureFlagRouter = getFeatureFlagRouter() return function (target, propertyName) {
setImmediate_((): any => {
const featureFlagRouter = getFeatureFlagRouter()
if (!featureFlagRouter.isFeatureEnabled(featureFlag)) { if (!featureFlagRouter.isFeatureEnabled(featureFlag)) {
return (): void => { return
// noop }
}
} decorators.forEach((decorator: PropertyDecorator) => {
// eslint-disable-next-line @typescript-eslint/ban-types decorator(target, propertyName)
return (target: Object, propertyKey: string | symbol): void => { })
decorators.forEach((decorator) => {
decorator(target, propertyKey)
}) })
} }
} }
@@ -44,12 +63,10 @@ export function FeatureFlagEntity(
name?: string, name?: string,
options?: EntityOptions options?: EntityOptions
): ClassDecorator { ): ClassDecorator {
// eslint-disable-next-line @typescript-eslint/ban-types
return function (target: Function): void { return function (target: Function): void {
target["isFeatureEnabled"] = function (): boolean { target["isFeatureEnabled"] = function (): boolean {
const featureFlagRouter = getFeatureFlagRouter() const featureFlagRouter = getFeatureFlagRouter()
// const featureFlagRouter = featureFlagsLoader(configModule)
return featureFlagRouter.isFeatureEnabled(featureFlag) return featureFlagRouter.isFeatureEnabled(featureFlag)
} }
Entity(name, options)(target) Entity(name, options)(target)
@@ -27,14 +27,14 @@ export function getRetrieveConfig<TModel extends BaseEntity>(
): FindConfig<TModel> { ): FindConfig<TModel> {
let includeFields: (keyof TModel)[] = [] let includeFields: (keyof TModel)[] = []
if (typeof fields !== "undefined") { if (typeof fields !== "undefined") {
includeFields = Array includeFields = Array.from(new Set([...fields, "id"])).map((field) =>
.from(new Set([...fields, "id"])) typeof field === "string" ? field.trim() : field
.map(field => (typeof field === "string") ? field.trim() : field) as (keyof TModel)[] ) as (keyof TModel)[]
} }
let expandFields: string[] = [] let expandFields: string[] = []
if (typeof expand !== "undefined") { if (typeof expand !== "undefined") {
expandFields = expand.map(expandRelation => expandRelation.trim()) expandFields = expand.map((expandRelation) => expandRelation.trim())
} }
return { return {
@@ -106,7 +106,10 @@ export function prepareListQuery<
orderBy = { [order]: "ASC" } orderBy = { [order]: "ASC" }
} }
if (queryConfig?.allowedFields?.length && !queryConfig?.allowedFields.includes(orderField)) { if (
queryConfig?.allowedFields?.length &&
!queryConfig?.allowedFields.includes(orderField)
) {
throw new MedusaError( throw new MedusaError(
MedusaError.Types.INVALID_DATA, MedusaError.Types.INVALID_DATA,
`Order field ${orderField} is not valid` `Order field ${orderField} is not valid`