feat: price list products (#1239)

* feat: add product list for price lists

* feat: add product list for price lists

* refactor: product list controller

* fix: add integration test for price list products

* fix: use getListConfig
This commit is contained in:
Sebastian Rindom
2022-03-30 13:29:14 +02:00
committed by GitHub
parent 3083aaee81
commit fb33dbaca3
11 changed files with 566 additions and 67 deletions

View File

@@ -4,6 +4,10 @@ const setupServer = require("../../../helpers/setup-server")
const { useApi } = require("../../../helpers/use-api")
const { useDb, initDb } = require("../../../helpers/use-db")
const {
simpleProductFactory,
simplePriceListFactory,
} = require("../../factories")
const adminSeeder = require("../../helpers/admin-seeder")
const customerSeeder = require("../../helpers/customer-seeder")
const priceListSeeder = require("../../helpers/price-list-seeder")
@@ -750,4 +754,101 @@ describe("/admin/price-lists", () => {
)
})
})
describe("GET /admin/price-lists/:id/products", () => {
let tag
beforeEach(async () => {
try {
await adminSeeder(dbConnection)
await simpleProductFactory(
dbConnection,
{
id: "test-prod-1",
title: "MedusaHeadphones",
variants: [{ id: "test-variant-1" }, { id: "test-variant-2" }],
},
1
)
const prod = await simpleProductFactory(
dbConnection,
{
id: "test-prod-2",
tags: ["test-tag"],
variants: [{ id: "test-variant-3" }, { id: "test-variant-4" }],
},
2
)
tag = prod.tags[0].id
await simpleProductFactory(
dbConnection,
{
id: "test-prod-3",
variants: [{ id: "test-variant-5" }],
},
3
)
await simplePriceListFactory(dbConnection, {
id: "test-list",
prices: [
{ variant_id: "test-variant-1", currency_code: "usd", amount: 100 },
{ variant_id: "test-variant-4", currency_code: "usd", amount: 100 },
],
})
} catch (err) {
console.log(err)
throw err
}
})
afterEach(async () => {
const db = useDb()
await db.teardown()
})
it("lists only product 1, 2", async () => {
const api = useApi()
const response = await api
.get(`/admin/price-lists/test-list/products?order=-created_at`, {
headers: {
Authorization: "Bearer test_token",
},
})
.catch((err) => {
console.warn(err.response.data)
})
expect(response.status).toEqual(200)
expect(response.data.count).toEqual(2)
expect(response.data.products).toEqual([
expect.objectContaining({ id: "test-prod-1" }),
expect.objectContaining({ id: "test-prod-2" }),
])
})
it("lists only product 2", async () => {
const api = useApi()
const response = await api
.get(`/admin/price-lists/test-list/products?tags[]=${tag}`, {
headers: {
Authorization: "Bearer test_token",
},
})
.catch((err) => {
console.warn(err.response.data)
})
expect(response.status).toEqual(200)
expect(response.data.count).toEqual(1)
expect(response.data.products).toEqual([
expect.objectContaining({ id: "test-prod-2" }),
])
})
})
})

View File

@@ -11,3 +11,4 @@ export * from "./simple-tax-rate-factory"
export * from "./simple-shipping-option-factory"
export * from "./simple-shipping-method-factory"
export * from "./simple-product-type-tax-rate-factory"
export * from "./simple-price-list-factory"

View File

@@ -0,0 +1,66 @@
import {
PriceList,
MoneyAmount,
PriceListType,
PriceListStatus,
} from "@medusajs/medusa"
import faker from "faker"
import { Connection } from "typeorm"
type ProductListPrice = {
variant_id: string
currency_code: string
region_id: string
amount: number
}
export type PriceListFactoryData = {
id?: string
name?: string
description?: string
type?: PriceListType
status?: PriceListStatus
starts_at?: Date
ends_at?: Date
customer_groups?: string[]
prices?: ProductListPrice[]
}
export const simplePriceListFactory = async (
connection: Connection,
data: PriceListFactoryData = {},
seed?: number
): Promise<PriceList> => {
if (typeof seed !== "undefined") {
faker.seed(seed)
}
const manager = connection.manager
const listId = data.id || `simple-price-list-${Math.random() * 1000}`
const toCreate = {
id: listId,
name: data.name || faker.commerce.productName(),
description: data.description || "Some text",
status: data.status || PriceListStatus.ACTIVE,
type: data.type || PriceListType.OVERRIDE,
starts_at: data.starts_at || null,
ends_at: data.ends_at || null,
}
const toSave = manager.create(PriceList, toCreate)
const toReturn = await manager.save(toSave)
if (typeof data.prices !== "undefined") {
for (const ma of data.prices) {
const factoryData = {
...ma,
price_list_id: listId,
}
const toSave = manager.create(MoneyAmount, factoryData)
await manager.save(toSave)
}
}
return toReturn
}

View File

@@ -13,6 +13,11 @@ export default (app) => {
route.get("/", middlewares.wrap(require("./list-price-lists").default))
route.get(
"/:id/products",
middlewares.wrap(require("./list-price-list-products").default)
)
route.post("/", middlewares.wrap(require("./create-price-list").default))
route.post("/:id", middlewares.wrap(require("./update-price-list").default))
@@ -65,3 +70,4 @@ export * from "./delete-price-list"
export * from "./get-price-list"
export * from "./list-price-lists"
export * from "./update-price-list"
export * from "./list-price-list-products"

View File

@@ -0,0 +1,175 @@
import { Type } from "class-transformer"
import { omit } from "lodash"
import {
IsArray,
IsBoolean,
IsEnum,
IsOptional,
IsString,
ValidateNested,
} from "class-validator"
import { Product } from "../../../../models/product"
import { DateComparisonOperator } from "../../../../types/common"
import { validator } from "../../../../utils/validator"
import { FilterableProductProps } from "../../../../types/product"
import {
AdminGetProductsPaginationParams,
allowedAdminProductFields,
defaultAdminProductFields,
defaultAdminProductRelations,
} from "../products"
import listAndCount from "../../../../controllers/products/admin-list-products"
/**
* @oas [get] /price-lists/:id/products
* operationId: "GetPriceListsPriceListProducts"
* summary: "List Product in a Price List"
* description: "Retrieves a list of Product that are part of a Price List"
* x-authenticated: true
* parameters:
* - (query) q {string} Query used for searching products.
* - (query) id {string} Id of the product to search for.
* - (query) status {string[]} Status to search for.
* - (query) collection_id {string[]} Collection ids to search for.
* - (query) tags {string[]} Tags to search for.
* - (query) title {string} to search for.
* - (query) description {string} to search for.
* - (query) handle {string} to search for.
* - (query) is_giftcard {string} Search for giftcards using is_giftcard=true.
* - (query) type {string} to search for.
* - (query) order {string} to retrieve products in.
* - (query) deleted_at {DateComparisonOperator} Date comparison for when resulting products was deleted, i.e. less than, greater than etc.
* - (query) created_at {DateComparisonOperator} Date comparison for when resulting products was created, i.e. less than, greater than etc.
* - (query) updated_at {DateComparisonOperator} Date comparison for when resulting products was updated, i.e. less than, greater than etc.
* - (query) offset {string} How many products to skip in the result.
* - (query) limit {string} Limit the number of products returned.
* - (query) expand {string} (Comma separated) Which fields should be expanded in each product of the result.
* - (query) fields {string} (Comma separated) Which fields should be included in each product of the result.
* tags:
* - Product
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* properties:
* count:
* description: The number of Products.
* type: integer
* offset:
* description: The offset of the Product query.
* type: integer
* limit:
* description: The limit of the Product query.
* type: integer
* products:
* type: array
* items:
* $ref: "#/components/schemas/product"
*/
export default async (req, res) => {
const { id } = req.params
const validatedParams = await validator(
AdminGetPriceListsPriceListProductsParams,
req.query
)
req.query.price_list_id = [id]
const filterableFields: FilterableProductProps = omit(req.query, [
"limit",
"offset",
"expand",
"fields",
"order",
])
const result = await listAndCount(
req.scope,
filterableFields,
{},
{
limit: validatedParams.limit ?? 50,
offset: validatedParams.offset ?? 0,
expand: validatedParams.expand,
fields: validatedParams.fields,
order: validatedParams.order,
allowedFields: allowedAdminProductFields,
defaultFields: defaultAdminProductFields as (keyof Product)[],
defaultRelations: defaultAdminProductRelations,
}
)
res.json(result)
}
enum ProductStatus {
DRAFT = "draft",
PROPOSED = "proposed",
PUBLISHED = "published",
REJECTED = "rejected",
}
export class AdminGetPriceListsPriceListProductsParams extends AdminGetProductsPaginationParams {
@IsString()
@IsOptional()
id?: string
@IsString()
@IsOptional()
q?: string
@IsOptional()
@IsEnum(ProductStatus, { each: true })
status?: ProductStatus[]
@IsArray()
@IsOptional()
collection_id?: string[]
@IsArray()
@IsOptional()
tags?: string[]
@IsString()
@IsOptional()
title?: string
@IsString()
@IsOptional()
description?: string
@IsString()
@IsOptional()
handle?: string
@IsBoolean()
@IsOptional()
@Type(() => Boolean)
is_giftcard?: string
@IsString()
@IsOptional()
type?: string
@IsString()
@IsOptional()
order?: string
@IsOptional()
@ValidateNested()
@Type(() => DateComparisonOperator)
created_at?: DateComparisonOperator
@IsOptional()
@ValidateNested()
@Type(() => DateComparisonOperator)
updated_at?: DateComparisonOperator
@ValidateNested()
@IsOptional()
@Type(() => DateComparisonOperator)
deleted_at?: DateComparisonOperator
}

View File

@@ -8,16 +8,16 @@ import {
IsString,
ValidateNested,
} from "class-validator"
import { pickBy, omit } from "lodash"
import { MedusaError } from "medusa-core-utils"
import { omit } from "lodash"
import { Product } from "../../../../models/product"
import { DateComparisonOperator } from "../../../../types/common"
import {
allowedAdminProductFields,
defaultAdminProductFields,
defaultAdminProductRelations,
} from "."
import { ProductService } from "../../../../services"
import { FindConfig, DateComparisonOperator } from "../../../../types/common"
import listAndCount from "../../../../controllers/products/admin-list-products"
import { validator } from "../../../../utils/validator"
/**
@@ -71,47 +71,6 @@ import { validator } from "../../../../utils/validator"
export default async (req, res) => {
const validatedParams = await validator(AdminGetProductsParams, req.query)
const productService: ProductService = req.scope.resolve("productService")
let includeFields: string[] = []
if (validatedParams.fields) {
includeFields = validatedParams.fields!.split(",")
}
let expandFields: string[] = []
if (validatedParams.expand) {
expandFields = validatedParams.expand!.split(",")
}
const listConfig: FindConfig<Product> = {
select: (includeFields.length
? includeFields
: defaultAdminProductFields) as (keyof Product)[],
relations: expandFields.length
? expandFields
: defaultAdminProductRelations,
skip: validatedParams.offset,
take: validatedParams.limit,
}
if (typeof validatedParams.order !== "undefined") {
let orderField = validatedParams.order
if (validatedParams.order.startsWith("-")) {
const [, field] = validatedParams.order.split("-")
orderField = field
listConfig.order = { [field]: "DESC" }
} else {
listConfig.order = { [validatedParams.order]: "ASC" }
}
if (!allowedAdminProductFields.includes(orderField)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Order field must be a valid product field"
)
}
}
const filterableFields = omit(validatedParams, [
"limit",
"offset",
@@ -120,17 +79,22 @@ export default async (req, res) => {
"order",
])
const [products, count] = await productService.listAndCount(
pickBy(filterableFields, (val) => typeof val !== "undefined"),
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,
}
)
res.json({
products,
count,
offset: validatedParams.offset,
limit: validatedParams.limit,
})
res.json(result)
}
export enum ProductStatus {
@@ -181,6 +145,10 @@ export class AdminGetProductsParams extends AdminGetProductsPaginationParams {
@IsOptional()
tags?: string[]
@IsArray()
@IsOptional()
price_list_id?: string[]
@IsString()
@IsOptional()
title?: string

View File

@@ -0,0 +1,84 @@
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 } from "../../services"
import { getListConfig } from "../../utils/get-query-config"
import { FindConfig } from "../../types/common"
import { FilterableProductProps } from "../../types/product"
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")
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 [products, count] = await productService.listAndCount(
pickBy(query, (val) => typeof val !== "undefined"),
listConfig
)
return {
products,
count,
offset,
limit,
}
}
export default listAndCount

View File

@@ -4,6 +4,9 @@ export * from "./api"
// Interfaces
export * from "./interfaces"
// Types
export * from "./types/price-list"
// Models
export * from "./models/shipping-tax-rate"
export * from "./models/product-tax-rate"

View File

@@ -9,6 +9,7 @@ import {
} from "typeorm"
import { ProductTag } from ".."
import { Product } from "../models/product"
import { PriceList } from "../models/price-list"
type DefaultWithoutRelations = Omit<FindManyOptions<Product>, "relations">
@@ -16,6 +17,7 @@ type CustomOptions = {
select?: DefaultWithoutRelations["select"]
where?: DefaultWithoutRelations["where"] & {
tags?: FindOperator<ProductTag>
price_list_id?: FindOperator<PriceList>
}
order?: OrderByCondition
skip?: number
@@ -42,27 +44,50 @@ export class ProductRepository extends Repository<Product> {
): Promise<[Product[], number]> {
const tags = optionsWithoutRelations?.where?.tags
delete optionsWithoutRelations?.where?.tags
let qb = this.createQueryBuilder("product")
const price_lists = optionsWithoutRelations?.where?.price_list_id
delete optionsWithoutRelations?.where?.price_list_id
const qb = this.createQueryBuilder("product")
.select(["product.id"])
.skip(optionsWithoutRelations.skip)
.take(optionsWithoutRelations.take)
qb = optionsWithoutRelations.where
? qb.where(optionsWithoutRelations.where)
: qb
if (optionsWithoutRelations.where) {
qb.where(optionsWithoutRelations.where)
}
qb = optionsWithoutRelations.order
? qb.orderBy(optionsWithoutRelations.order)
: qb
if (optionsWithoutRelations.order) {
const toSelect: string[] = []
const parsed = Object.entries(optionsWithoutRelations.order).reduce(
(acc, [k, v]) => {
const key = `product.${k}`
toSelect.push(key)
acc[key] = v
return acc
},
{}
)
qb.addSelect(toSelect)
qb.orderBy(parsed)
}
if (tags) {
qb = qb
.leftJoinAndSelect("product.tags", "tags")
.andWhere(`tags.id IN (:...ids)`, { ids: tags.value })
qb.leftJoin("product.tags", "tags").andWhere(`tags.id IN (:...tag_ids)`, {
tag_ids: tags.value,
})
}
if (price_lists) {
qb.leftJoin("product.variants", "variants")
.leftJoin("variants.prices", "ma")
.andWhere("ma.price_list_id IN (:...price_list_ids)", {
price_list_ids: price_lists.value,
})
}
if (optionsWithoutRelations.withDeleted) {
qb = qb.withDeleted()
qb.withDeleted()
}
let entities: Product[]

View File

@@ -156,7 +156,7 @@ class ProductService extends BaseService {
* by
* @param {object} config - object that defines the scope for what should be
* returned
* @return {[Promise<Product[]>, number]} an array containing the products as
* @return {Promise<[Product[], number]>} an array containing the products as
* the first element and the total count of products that matches the query
* as the second element.
*/

View File

@@ -1,4 +1,12 @@
import { ValidateNested } from "class-validator"
import { Type } from "class-transformer"
import {
IsArray,
IsBoolean,
IsEnum,
IsOptional,
IsString,
ValidateNested,
} from "class-validator"
import { IsType } from "../utils/validators/is-type"
import { DateComparisonOperator, StringComparisonOperator } from "./common"
@@ -9,6 +17,68 @@ export enum ProductStatus {
REJECTED = "rejected",
}
export class FilterableProductProps {
@IsString()
@IsOptional()
id?: string
@IsString()
@IsOptional()
q?: string
@IsOptional()
@IsEnum(ProductStatus, { each: true })
status?: ProductStatus[]
@IsArray()
@IsOptional()
price_list_id?: string[]
@IsArray()
@IsOptional()
collection_id?: string[]
@IsArray()
@IsOptional()
tags?: string[]
@IsString()
@IsOptional()
title?: string
@IsString()
@IsOptional()
description?: string
@IsString()
@IsOptional()
handle?: string
@IsBoolean()
@IsOptional()
@Type(() => Boolean)
is_giftcard?: string
@IsString()
@IsOptional()
type?: string
@IsOptional()
@ValidateNested()
@Type(() => DateComparisonOperator)
created_at?: DateComparisonOperator
@IsOptional()
@ValidateNested()
@Type(() => DateComparisonOperator)
updated_at?: DateComparisonOperator
@ValidateNested()
@IsOptional()
@Type(() => DateComparisonOperator)
deleted_at?: DateComparisonOperator
}
export class FilterableProductTagProps {
@ValidateNested()
@IsType([String, [String], StringComparisonOperator])