diff --git a/integration-tests/api/__tests__/store/__snapshots__/product-variants.js.snap b/integration-tests/api/__tests__/store/__snapshots__/product-variants.js.snap index 3aa71856e9..8ae969e513 100644 --- a/integration-tests/api/__tests__/store/__snapshots__/product-variants.js.snap +++ b/integration-tests/api/__tests__/store/__snapshots__/product-variants.js.snap @@ -111,3 +111,60 @@ Object { ], } `; + +exports[`/store/variants lists by title 1`] = ` +Object { + "variants": Array [ + Object { + "allow_backorder": false, + "barcode": null, + "created_at": Any, + "deleted_at": null, + "ean": null, + "height": null, + "hs_code": null, + "id": Any, + "inventory_quantity": 12, + "length": null, + "manage_inventory": true, + "material": null, + "metadata": null, + "mid_code": null, + "options": Array [ + Object { + "created_at": Any, + "deleted_at": null, + "id": Any, + "metadata": null, + "option_id": Any, + "updated_at": Any, + "value": "Handcrafted", + "variant_id": Any, + }, + ], + "origin_country": null, + "prices": Array [ + Object { + "amount": 100, + "created_at": Any, + "currency_code": "usd", + "deleted_at": null, + "id": Any, + "region_id": null, + "sale_amount": null, + "updated_at": Any, + "variant_id": Any, + }, + ], + "product": Any, + "product_id": Any, + "sku": null, + "title": "test2", + "upc": null, + "updated_at": Any, + "weight": null, + "width": null, + }, + ], +} +`; diff --git a/integration-tests/api/__tests__/store/__snapshots__/products.js.snap b/integration-tests/api/__tests__/store/__snapshots__/products.js.snap index 93e1632e74..300a0ebee5 100644 --- a/integration-tests/api/__tests__/store/__snapshots__/products.js.snap +++ b/integration-tests/api/__tests__/store/__snapshots__/products.js.snap @@ -280,3 +280,56 @@ Object { }, } `; + +exports[`/store/products list params works with expand and fields 1`] = ` +Object { + "count": 2, + "limit": 1, + "offset": 0, + "products": Array [ + Object { + "id": Any, + "title": "testprod", + "variants": Array [ + Object { + "allow_backorder": false, + "barcode": null, + "created_at": Any, + "deleted_at": null, + "ean": null, + "height": null, + "hs_code": null, + "id": Any, + "inventory_quantity": 10, + "length": null, + "manage_inventory": true, + "material": null, + "metadata": null, + "mid_code": null, + "origin_country": null, + "prices": Array [ + Object { + "amount": 100, + "created_at": Any, + "currency_code": "usd", + "deleted_at": null, + "id": Any, + "region_id": null, + "sale_amount": null, + "updated_at": Any, + "variant_id": Any, + }, + ], + "product_id": Any, + "sku": null, + "title": "test-variant", + "upc": null, + "updated_at": Any, + "weight": null, + "width": null, + }, + ], + }, + ], +} +`; diff --git a/integration-tests/api/__tests__/store/product-variants.js b/integration-tests/api/__tests__/store/product-variants.js index 92494af9d8..22afde0fff 100644 --- a/integration-tests/api/__tests__/store/product-variants.js +++ b/integration-tests/api/__tests__/store/product-variants.js @@ -2,6 +2,7 @@ const path = require("path") const setupServer = require("../../../helpers/setup-server") const { useApi } = require("../../../helpers/use-api") const { initDb, useDb } = require("../../../helpers/use-db") +const { simpleProductFactory } = require("../../factories") const productSeeder = require("../../helpers/product-seeder") jest.setTimeout(30000) @@ -24,6 +25,24 @@ describe("/store/variants", () => { beforeEach(async () => { try { await productSeeder(dbConnection) + + await simpleProductFactory( + dbConnection, + { + title: "prod", + variants: [ + { + title: "test1", + inventory_quantity: 10, + }, + { + title: "test2", + inventory_quantity: 12, + }, + ], + }, + 100 + ) } catch (err) { console.log(err) throw err @@ -93,6 +112,43 @@ describe("/store/variants", () => { }) }) + it("lists by title", async () => { + const api = useApi() + + const response = await api.get( + "/store/variants?title[]=test1&title[]=test2&inventory_quantity[gt]=10" + ) + expect(response.data).toMatchSnapshot({ + variants: [ + { + id: expect.any(String), + title: "test2", + created_at: expect.any(String), + updated_at: expect.any(String), + options: [ + { + created_at: expect.any(String), + updated_at: expect.any(String), + id: expect.any(String), + option_id: expect.any(String), + variant_id: expect.any(String), + }, + ], + prices: [ + { + id: expect.any(String), + variant_id: expect.any(String), + created_at: expect.any(String), + updated_at: expect.any(String), + }, + ], + product: expect.any(Object), + product_id: expect.any(String), + }, + ], + }) + }) + it("/test-variant", async () => { const api = useApi() diff --git a/integration-tests/api/__tests__/store/products.js b/integration-tests/api/__tests__/store/products.js index fb5af35f7d..50dd847917 100644 --- a/integration-tests/api/__tests__/store/products.js +++ b/integration-tests/api/__tests__/store/products.js @@ -4,6 +4,7 @@ const setupServer = require("../../../helpers/setup-server") const { useApi } = require("../../../helpers/use-api") const { initDb, useDb } = require("../../../helpers/use-db") +const { simpleProductFactory } = require("../../factories") const productSeeder = require("../../helpers/store-product-seeder") const adminSeeder = require("../../helpers/admin-seeder") jest.setTimeout(30000) @@ -222,6 +223,74 @@ describe("/store/products", () => { }) }) + describe("list params", () => { + beforeEach(async () => { + try { + await adminSeeder(dbConnection) + + const p1 = await simpleProductFactory( + dbConnection, + { + title: "testprod", + status: "published", + variants: [{ title: "test-variant" }], + }, + 11 + ) + + const p2 = await simpleProductFactory( + dbConnection, + { + title: "testprod3", + status: "published", + variants: [{ title: "test-variant1" }], + }, + 12 + ) + } catch (err) { + console.log(err) + throw err + } + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("works with expand and fields", async () => { + const api = useApi() + + const response = await api.get( + "/store/products?expand=variants,variants.prices&fields=id,title&limit=1" + ) + + expect(response.data).toMatchSnapshot({ + products: [ + { + id: expect.any(String), + variants: [ + { + created_at: expect.any(String), + updated_at: expect.any(String), + id: expect.any(String), + product_id: expect.any(String), + prices: [ + { + created_at: expect.any(String), + updated_at: expect.any(String), + id: expect.any(String), + variant_id: expect.any(String), + }, + ], + }, + ], + }, + ], + }) + }) + }) + describe("/store/products/:id", () => { beforeEach(async () => { try { diff --git a/integration-tests/api/factories/simple-product-factory.ts b/integration-tests/api/factories/simple-product-factory.ts index 24746c0f84..fda928efe3 100644 --- a/integration-tests/api/factories/simple-product-factory.ts +++ b/integration-tests/api/factories/simple-product-factory.ts @@ -16,6 +16,7 @@ import { export type ProductFactoryData = { id?: string is_giftcard?: boolean + status?: string title?: string type?: string options?: { id: string; title: string }[] @@ -54,6 +55,7 @@ export const simpleProductFactory = async ( const toSave = manager.create(Product, { id: prodId, type_id: typeId, + status: data.status, title: data.title || faker.commerce.productName(), is_giftcard: data.is_giftcard || false, discountable: !data.is_giftcard, diff --git a/packages/medusa/src/api/routes/admin/products/__tests__/get-product.js b/packages/medusa/src/api/routes/admin/products/__tests__/get-product.js index eea66fb8f4..36e8ab7292 100644 --- a/packages/medusa/src/api/routes/admin/products/__tests__/get-product.js +++ b/packages/medusa/src/api/routes/admin/products/__tests__/get-product.js @@ -33,6 +33,8 @@ describe("GET /admin/products/:id", () => { "id", "title", "subtitle", + "status", + "external_id", "description", "handle", "is_giftcard", @@ -51,6 +53,7 @@ describe("GET /admin/products/:id", () => { "material", "created_at", "updated_at", + "deleted_at", "metadata", ], relations: [ diff --git a/packages/medusa/src/api/routes/admin/products/index.ts b/packages/medusa/src/api/routes/admin/products/index.ts index 29a752e613..2199dffd8a 100644 --- a/packages/medusa/src/api/routes/admin/products/index.ts +++ b/packages/medusa/src/api/routes/admin/products/index.ts @@ -73,6 +73,8 @@ export const defaultAdminProductFields = [ "id", "title", "subtitle", + "status", + "external_id", "description", "handle", "is_giftcard", @@ -91,6 +93,7 @@ export const defaultAdminProductFields = [ "material", "created_at", "updated_at", + "deleted_at", "metadata", ] @@ -98,6 +101,8 @@ export const allowedAdminProductFields = [ "id", "title", "subtitle", + "status", + "external_id", "description", "handle", "is_giftcard", @@ -116,6 +121,7 @@ export const allowedAdminProductFields = [ "material", "created_at", "updated_at", + "deleted_at", "metadata", ] diff --git a/packages/medusa/src/api/routes/store/products/list-products.ts b/packages/medusa/src/api/routes/store/products/list-products.ts index cf9e0a7af7..55b8ea99c3 100644 --- a/packages/medusa/src/api/routes/store/products/list-products.ts +++ b/packages/medusa/src/api/routes/store/products/list-products.ts @@ -8,11 +8,11 @@ import { ValidateNested, } from "class-validator" import { omit, pickBy, identity } from "lodash" -import { MedusaError } from "medusa-core-utils" import { defaultStoreProductsRelations } from "." import { ProductService } from "../../../../services" import { DateComparisonOperator } from "../../../../types/common" import { validator } from "../../../../utils/validator" +import { IsType } from "../../../../utils/validators/is-type" import { optionalBooleanMapper } from "../../../../utils/validators/is-boolean" /** @@ -64,6 +64,8 @@ export default async (req, res) => { const validated = await validator(StoreGetProductsParams, req.query) const filterableFields: StoreGetProductsParams = omit(validated, [ + "fields", + "expand", "limit", "offset", ]) @@ -71,8 +73,23 @@ export default async (req, res) => { // get only published products for store endpoint filterableFields["status"] = ["published"] + let includeFields: string[] = [] + if (validated.fields) { + const set = new Set(validated.fields.split(",")) + set.add("id") + includeFields = [...set] + } + + let expandFields: string[] = [] + if (validated.expand) { + expandFields = validated.expand.split(",") + } + const listConfig = { - relations: defaultStoreProductsRelations, + select: includeFields.length ? includeFields : undefined, + relations: expandFields.length + ? expandFields + : defaultStoreProductsRelations, skip: validated.offset, take: validated.limit, } @@ -91,6 +108,14 @@ export default async (req, res) => { } export class StoreGetProductsPaginationParams { + @IsString() + @IsOptional() + fields?: string + + @IsString() + @IsOptional() + expand?: string + @IsNumber() @IsOptional() @Type(() => Number) @@ -103,9 +128,9 @@ export class StoreGetProductsPaginationParams { } export class StoreGetProductsParams extends StoreGetProductsPaginationParams { - @IsString() @IsOptional() - id?: string + @IsType([String, [String]]) + id?: string | string[] @IsString() @IsOptional() diff --git a/packages/medusa/src/api/routes/store/variants/list-variants.ts b/packages/medusa/src/api/routes/store/variants/list-variants.ts index 3dc2c18d3a..5cfd175a43 100644 --- a/packages/medusa/src/api/routes/store/variants/list-variants.ts +++ b/packages/medusa/src/api/routes/store/variants/list-variants.ts @@ -1,8 +1,12 @@ import { Type } from "class-transformer" +import { omit } from "lodash" import { IsInt, IsOptional, IsString } from "class-validator" import { defaultStoreVariantRelations } from "." +import { FilterableProductVariantProps } from "../../../../types/product-variant" import ProductVariantService from "../../../../services/product-variant" import { validator } from "../../../../utils/validator" +import { IsType } from "../../../../utils/validators/is-type" +import { NumericalComparisonOperator } from "../../../../types/common" /** * @oas [get] /variants @@ -29,17 +33,14 @@ import { validator } from "../../../../utils/validator" * $ref: "#/components/schemas/product_variant" */ export default async (req, res) => { - const { limit, offset, expand, ids } = await validator( - StoreGetVariantsParams, - req.query - ) + const validated = await validator(StoreGetVariantsParams, req.query) + const { expand, offset, limit } = validated let expandFields: string[] = [] if (expand) { expandFields = expand.split(",") } - let selector = {} const listConfig = { relations: expandFields.length ? expandFields @@ -48,14 +49,21 @@ export default async (req, res) => { take: limit, } - if (ids) { - selector = { id: ids.split(",") } + const filterableFields: FilterableProductVariantProps = omit(validated, [ + "ids", + "limit", + "offset", + "expand", + ]) + + if (validated.ids) { + filterableFields.id = validated.ids.split(",") } const variantService: ProductVariantService = req.scope.resolve( "productVariantService" ) - const variants = await variantService.list(selector, listConfig) + const variants = await variantService.list(filterableFields, listConfig) res.json({ variants }) } @@ -78,4 +86,16 @@ export class StoreGetVariantsParams { @IsOptional() @IsString() ids?: string + + @IsOptional() + @IsType([String, [String]]) + id?: string | string[] + + @IsOptional() + @IsType([String, [String]]) + title?: string | string[] + + @IsOptional() + @IsType([Number, NumericalComparisonOperator]) + inventory_quantity?: number | NumericalComparisonOperator } diff --git a/packages/medusa/src/repositories/product.ts b/packages/medusa/src/repositories/product.ts index 8926ab5d11..7b7d72b636 100644 --- a/packages/medusa/src/repositories/product.ts +++ b/packages/medusa/src/repositories/product.ts @@ -13,6 +13,7 @@ import { Product } from "../models/product" type DefaultWithoutRelations = Omit, "relations"> type CustomOptions = { + select?: DefaultWithoutRelations["select"] where?: DefaultWithoutRelations["where"] & { tags?: FindOperator } @@ -77,9 +78,7 @@ export class ProductRepository extends Repository { return [entities, count] } - private getGroupedRelations( - relations: Array - ): { + private getGroupedRelations(relations: Array): { [toplevel: string]: string[] } { const groupedRelations: { [toplevel: string]: string[] } = {} @@ -98,12 +97,17 @@ export class ProductRepository extends Repository { private async queryProductsWithIds( entityIds: string[], groupedRelations: { [toplevel: string]: string[] }, - withDeleted = false + withDeleted = false, + select: (keyof Product)[] = [] ): Promise { const entitiesIdsWithRelations = await Promise.all( Object.entries(groupedRelations).map(([toplevel, rels]) => { let querybuilder = this.createQueryBuilder("products") + if (select && select.length) { + querybuilder.select(select.map((f) => `products.${f}`)) + } + if (toplevel === "variants") { querybuilder = querybuilder .leftJoinAndSelect( @@ -193,13 +197,13 @@ export class ProductRepository extends Repository { const entitiesIdsWithRelations = await this.queryProductsWithIds( entitiesIds, groupedRelations, - idsOrOptionsWithoutRelations.withDeleted + idsOrOptionsWithoutRelations.withDeleted, + idsOrOptionsWithoutRelations.select ) const entitiesAndRelations = entitiesIdsWithRelations.concat(entities) - const entitiesToReturn = this.mergeEntitiesWithRelations( - entitiesAndRelations - ) + const entitiesToReturn = + this.mergeEntitiesWithRelations(entitiesAndRelations) return [entitiesToReturn, count] } @@ -240,9 +244,8 @@ export class ProductRepository extends Repository { ) const entitiesAndRelations = entitiesIdsWithRelations.concat(entities) - const entitiesToReturn = this.mergeEntitiesWithRelations( - entitiesAndRelations - ) + const entitiesToReturn = + this.mergeEntitiesWithRelations(entitiesAndRelations) return entitiesToReturn } diff --git a/packages/medusa/src/services/product.js b/packages/medusa/src/services/product.js index 1185e3b054..553c24edd5 100644 --- a/packages/medusa/src/services/product.js +++ b/packages/medusa/src/services/product.js @@ -327,7 +327,9 @@ class ProductService extends BaseService { return existing.id } - const created = productTypeRepository.create(type) + const created = productTypeRepository.create({ + value: type.value, + }) const result = await productTypeRepository.save(created) return result.id diff --git a/packages/medusa/src/types/product-variant.ts b/packages/medusa/src/types/product-variant.ts index fd645bf5ff..244d02471d 100644 --- a/packages/medusa/src/types/product-variant.ts +++ b/packages/medusa/src/types/product-variant.ts @@ -70,8 +70,8 @@ export class FilterableProductVariantProps { @IsType([String, [String], StringComparisonOperator]) id?: string | string[] | StringComparisonOperator - @IsString() - title?: string + @IsType([String, [String]]) + title?: string | string[] @IsType([String, [String]]) product_id?: string | string[] @@ -88,8 +88,8 @@ export class FilterableProductVariantProps { @IsType([String]) upc?: string - @IsNumber() - inventory_quantity?: number + @IsType([Number, NumericalComparisonOperator]) + inventory_quantity?: number | NumericalComparisonOperator @IsBoolean() allow_backorder?: boolean