diff --git a/integration-tests/api/__tests__/admin/price-list.js b/integration-tests/api/__tests__/admin/price-list.js index d6cbe543d3..0c03141bf2 100644 --- a/integration-tests/api/__tests__/admin/price-list.js +++ b/integration-tests/api/__tests__/admin/price-list.js @@ -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" }), + ]) + }) + }) }) diff --git a/integration-tests/api/factories/index.ts b/integration-tests/api/factories/index.ts index 02b884d37e..4b63d87db9 100644 --- a/integration-tests/api/factories/index.ts +++ b/integration-tests/api/factories/index.ts @@ -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" diff --git a/integration-tests/api/factories/simple-price-list-factory.ts b/integration-tests/api/factories/simple-price-list-factory.ts new file mode 100644 index 0000000000..439b35deec --- /dev/null +++ b/integration-tests/api/factories/simple-price-list-factory.ts @@ -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 => { + 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 +} diff --git a/packages/medusa/src/api/routes/admin/price-lists/index.ts b/packages/medusa/src/api/routes/admin/price-lists/index.ts index 5c9b8ccaa9..d61f46a077 100644 --- a/packages/medusa/src/api/routes/admin/price-lists/index.ts +++ b/packages/medusa/src/api/routes/admin/price-lists/index.ts @@ -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" diff --git a/packages/medusa/src/api/routes/admin/price-lists/list-price-list-products.ts b/packages/medusa/src/api/routes/admin/price-lists/list-price-list-products.ts new file mode 100644 index 0000000000..5cbe0abba6 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/price-lists/list-price-list-products.ts @@ -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 +} 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 66aef90771..dd5a9e3e78 100644 --- a/packages/medusa/src/api/routes/admin/products/list-products.ts +++ b/packages/medusa/src/api/routes/admin/products/list-products.ts @@ -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 = { - 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 diff --git a/packages/medusa/src/controllers/products/admin-list-products.ts b/packages/medusa/src/controllers/products/admin-list-products.ts new file mode 100644 index 0000000000..030a82ba68 --- /dev/null +++ b/packages/medusa/src/controllers/products/admin-list-products.ts @@ -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 => { + 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( + 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 diff --git a/packages/medusa/src/index.js b/packages/medusa/src/index.js index ff62eeaf68..d274b46a24 100644 --- a/packages/medusa/src/index.js +++ b/packages/medusa/src/index.js @@ -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" diff --git a/packages/medusa/src/repositories/product.ts b/packages/medusa/src/repositories/product.ts index 7b7d72b636..348ac0aa5d 100644 --- a/packages/medusa/src/repositories/product.ts +++ b/packages/medusa/src/repositories/product.ts @@ -9,6 +9,7 @@ import { } from "typeorm" import { ProductTag } from ".." import { Product } from "../models/product" +import { PriceList } from "../models/price-list" type DefaultWithoutRelations = Omit, "relations"> @@ -16,6 +17,7 @@ type CustomOptions = { select?: DefaultWithoutRelations["select"] where?: DefaultWithoutRelations["where"] & { tags?: FindOperator + price_list_id?: FindOperator } order?: OrderByCondition skip?: number @@ -42,27 +44,50 @@ export class ProductRepository extends Repository { ): 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[] diff --git a/packages/medusa/src/services/product.js b/packages/medusa/src/services/product.js index 146b2ded6b..5e1da6caa8 100644 --- a/packages/medusa/src/services/product.js +++ b/packages/medusa/src/services/product.js @@ -156,7 +156,7 @@ class ProductService extends BaseService { * by * @param {object} config - object that defines the scope for what should be * returned - * @return {[Promise, 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. */ diff --git a/packages/medusa/src/types/product.ts b/packages/medusa/src/types/product.ts index c8198ce33a..49e7876ec7 100644 --- a/packages/medusa/src/types/product.ts +++ b/packages/medusa/src/types/product.ts @@ -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])