diff --git a/.changeset/lazy-frogs-teach.md b/.changeset/lazy-frogs-teach.md new file mode 100644 index 0000000000..95edd6e33f --- /dev/null +++ b/.changeset/lazy-frogs-teach.md @@ -0,0 +1,5 @@ +--- +"@medusajs/medusa": patch +--- + +feat(medusa): add or remove categories from products diff --git a/integration-tests/api/__tests__/admin/__snapshots__/product.js.snap b/integration-tests/api/__tests__/admin/__snapshots__/product.js.snap index 2eeafb5cde..d803fb9ad2 100644 --- a/integration-tests/api/__tests__/admin/__snapshots__/product.js.snap +++ b/integration-tests/api/__tests__/admin/__snapshots__/product.js.snap @@ -3,6 +3,7 @@ exports[`/admin/products GET /admin/products returns a list of products with only giftcard in list 1`] = ` Array [ Object { + "categories": Array [], "collection": null, "collection_id": null, "created_at": Any, @@ -111,6 +112,7 @@ Array [ exports[`/admin/products POST /admin/products creates a product 1`] = ` Object { + "categories": Array [], "collection": Object { "created_at": Any, "deleted_at": null, @@ -314,6 +316,7 @@ Object { exports[`/admin/products POST /admin/products updates a product (update prices, tags, update status, delete collection, delete type, replaces images) 1`] = ` Object { + "categories": Array [], "collection": null, "collection_id": null, "created_at": Any, diff --git a/integration-tests/api/__tests__/admin/product.js b/integration-tests/api/__tests__/admin/product.js index 1e3110478d..cd17ec513a 100644 --- a/integration-tests/api/__tests__/admin/product.js +++ b/integration-tests/api/__tests__/admin/product.js @@ -6,6 +6,8 @@ const { initDb, useDb } = require("../../../helpers/use-db") const adminSeeder = require("../../helpers/admin-seeder") const productSeeder = require("../../helpers/product-seeder") +const { Product, ProductCategory } = require("@medusajs/medusa") + const { ProductVariant, ProductOptionValue, @@ -17,12 +19,14 @@ const priceListSeeder = require("../../helpers/price-list-seeder") const { simpleProductFactory, simpleDiscountFactory, + simpleProductCategoryFactory, } = require("../../factories") const { DiscountRuleType, AllocationType } = require("@medusajs/medusa/dist") const { IdMap } = require("medusa-test-utils") jest.setTimeout(50000) +const testProductId = "test-product" const adminHeaders = { headers: { Authorization: "Bearer test_token", @@ -1426,6 +1430,7 @@ describe("/admin/products", () => { ], type: null, collection: null, + categories: [], }) ) }) @@ -1456,6 +1461,141 @@ describe("/admin/products", () => { }) ) }) + + describe("Categories", () => { + let categoryWithProduct, categoryWithoutProduct + const categoryWithProductId = "category-with-product-id" + const categoryWithoutProductId = "category-without-product-id" + + beforeEach(async () => { + const manager = dbConnection.manager + categoryWithProduct = await manager.create(ProductCategory, { + id: categoryWithProductId, + name: "category with Product", + products: [{ id: testProductId }], + }) + await manager.save(categoryWithProduct) + + categoryWithoutProduct = await manager.create(ProductCategory, { + id: categoryWithoutProductId, + name: "category without product", + }) + await manager.save(categoryWithoutProduct) + }) + + it("creates a product with categories associated to it", async () => { + const api = useApi() + + const payload = { + title: "Test", + description: "test-product-description", + categories: [{ id: categoryWithProductId }, { id: categoryWithoutProductId }] + } + + const response = await api + .post("/admin/products", payload, adminHeaders) + .catch(e => e) + + expect(response.status).toEqual(200) + expect(response.data.product).toEqual( + expect.objectContaining({ + categories: [ + expect.objectContaining({ + id: categoryWithProductId, + }), + expect.objectContaining({ + id: categoryWithoutProductId, + }), + ], + }) + ) + }) + + it("throws error when creating a product with invalid category ID", async () => { + const api = useApi() + const categoryNotFoundId = "category-doesnt-exist" + + const payload = { + title: "Test", + description: "test-product-description", + categories: [{ id: categoryNotFoundId }] + } + + const error = await api + .post("/admin/products", payload, adminHeaders) + .catch(e => e) + + expect(error.response.status).toEqual(404) + expect(error.response.data.type).toEqual("not_found") + expect(error.response.data.message).toEqual(`Product_category with product_category_id ${categoryNotFoundId} does not exist.`) + }) + + it("updates a product's categories", async () => { + const api = useApi() + + const payload = { + categories: [{ id: categoryWithoutProductId }], + } + + const response = await api + .post(`/admin/products/${testProductId}`, payload, adminHeaders) + + expect(response.status).toEqual(200) + expect(response.data.product).toEqual( + expect.objectContaining({ + id: testProductId, + handle: "test-product", + categories: [ + expect.objectContaining({ + id: categoryWithoutProductId, + }), + ], + }) + ) + }) + + it("remove all categories of a product", async () => { + const api = useApi() + const category = await simpleProductCategoryFactory( + dbConnection, + { + id: "existing-category", + name: "existing category", + products: [{ id: "test-product" }] + } + ) + + const payload = { + categories: [], + } + + const response = await api + .post("/admin/products/test-product", payload, adminHeaders) + + expect(response.status).toEqual(200) + expect(response.data.product).toEqual( + expect.objectContaining({ + id: "test-product", + categories: [], + }) + ) + }) + + it("throws error if product categories input is incorreect", async () => { + const api = useApi() + const payload = { + categories: [{ incorrect: "test-category-d2B" }], + } + + const error = await api + .post("/admin/products/test-product", payload, adminHeaders) + .catch(e => e) + + expect(error.response.status).toEqual(400) + expect(error.response.data.type).toEqual("invalid_data") + expect(error.response.data.message).toEqual("property incorrect should not exist, id must be a string") + }) + }) }) describe("DELETE /admin/products/:id/options/:option_id", () => { diff --git a/integration-tests/api/factories/simple-product-category-factory.ts b/integration-tests/api/factories/simple-product-category-factory.ts index 30a10bbf71..fa68f19720 100644 --- a/integration-tests/api/factories/simple-product-category-factory.ts +++ b/integration-tests/api/factories/simple-product-category-factory.ts @@ -6,7 +6,7 @@ export const simpleProductCategoryFactory = async ( data: Partial = {} ): Promise => { const manager = connection.manager - const address = manager.create(ProductCategory, data) + const category = manager.create(ProductCategory, data) - return await manager.save(address) + return await manager.save(category) } 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 87a54663cc..de5fc0aa29 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 @@ -65,6 +65,7 @@ describe("GET /admin/products/:id", () => { "tags", "type", "collection", + "categories", "sales_channels", ], } diff --git a/packages/medusa/src/api/routes/admin/products/create-product.ts b/packages/medusa/src/api/routes/admin/products/create-product.ts index 6d7c0a65ed..67b2ed752c 100644 --- a/packages/medusa/src/api/routes/admin/products/create-product.ts +++ b/packages/medusa/src/api/routes/admin/products/create-product.ts @@ -17,6 +17,7 @@ import { } from "../../../../services" import { ProductSalesChannelReq, + ProductProductCategoryReq, ProductTagReq, ProductTypeReq, } from "../../../../types/product" @@ -340,6 +341,16 @@ class ProductVariantReq { * id: * description: The ID of an existing Sales channel. * type: string + * categories: + * description: "Categories to add the Product to." + * type: array + * items: + * required: + * - id + * properties: + * id: + * description: The ID of a Product Category. + * type: string * options: * description: The Options that the Product should have. These define on which properties the Product's Product Variants will differ. * type: array @@ -527,6 +538,12 @@ export class AdminPostProductsReq { ]) sales_channels?: ProductSalesChannelReq[] + @IsOptional() + @Type(() => ProductProductCategoryReq) + @ValidateNested({ each: true }) + @IsArray() + categories?: ProductProductCategoryReq[] + @IsOptional() @Type(() => ProductOptionReq) @ValidateNested({ each: true }) diff --git a/packages/medusa/src/api/routes/admin/products/index.ts b/packages/medusa/src/api/routes/admin/products/index.ts index 8db38ebd8e..22e2184f1a 100644 --- a/packages/medusa/src/api/routes/admin/products/index.ts +++ b/packages/medusa/src/api/routes/admin/products/index.ts @@ -100,6 +100,7 @@ export const defaultAdminProductRelations = [ "tags", "type", "collection", + "categories", ] export const defaultAdminProductFields: (keyof Product)[] = [ diff --git a/packages/medusa/src/api/routes/admin/products/update-product.ts b/packages/medusa/src/api/routes/admin/products/update-product.ts index 59cfbfc3a6..3141737567 100644 --- a/packages/medusa/src/api/routes/admin/products/update-product.ts +++ b/packages/medusa/src/api/routes/admin/products/update-product.ts @@ -17,6 +17,7 @@ import { ProductSalesChannelReq, ProductTagReq, ProductTypeReq, + ProductProductCategoryReq, } from "../../../../types/product" import { Type } from "class-transformer" @@ -278,6 +279,16 @@ class ProductVariantReq { * id: * description: The ID of an existing Sales channel. * type: string + * categories: + * description: "Categories to add the Product to." + * type: array + * items: + * required: + * - id + * properties: + * id: + * description: The ID of a Product Category. + * type: string * variants: * description: A list of Product Variants to create with the Product. * type: array @@ -459,6 +470,12 @@ export class AdminPostProductsProductReq { ]) sales_channels?: ProductSalesChannelReq[] | null + @IsOptional() + @Type(() => ProductProductCategoryReq) + @ValidateNested({ each: true }) + @IsArray() + categories?: ProductProductCategoryReq[] + @IsOptional() @Type(() => ProductVariantReq) @ValidateNested({ each: true }) diff --git a/packages/medusa/src/models/product-category.ts b/packages/medusa/src/models/product-category.ts index b42f6c7863..9db1a75b9f 100644 --- a/packages/medusa/src/models/product-category.ts +++ b/packages/medusa/src/models/product-category.ts @@ -56,11 +56,11 @@ export class ProductCategory extends SoftDeletableEntity { @JoinTable({ name: "product_category_product", joinColumn: { - name: "product_id", + name: "product_category_id", referencedColumnName: "id", }, inverseJoinColumn: { - name: "product_category_id", + name: "product_id", referencedColumnName: "id", }, }) @@ -122,6 +122,12 @@ export class ProductCategory extends SoftDeletableEntity { * parent_category: * description: A product category object. Available if the relation `parent_category` is expanded. * type: object + * products: + * description: products associated with category. Available if the relation `products` is expanded. + * type: array + * items: + * type: object + * description: A product object. * created_at: * type: string * description: "The date with timezone at which the resource was created." diff --git a/packages/medusa/src/models/product.ts b/packages/medusa/src/models/product.ts index 42ef75f03f..18f1582508 100644 --- a/packages/medusa/src/models/product.ts +++ b/packages/medusa/src/models/product.ts @@ -82,11 +82,11 @@ export class Product extends SoftDeletableEntity { @JoinTable({ name: "product_category_product", joinColumn: { - name: "product_category_id", + name: "product_id", referencedColumnName: "id", }, inverseJoinColumn: { - name: "product_id", + name: "product_category_id", referencedColumnName: "id", }, }) @@ -320,6 +320,12 @@ export class Product extends SoftDeletableEntity { * items: * type: object * description: A sales channel object. + * categories: + * description: The product's associated categories. Available if the relation `categories` is expanded. + * type: array + * items: + * type: object + * description: A category object. * created_at: * type: string * description: "The date with timezone at which the resource was created." diff --git a/packages/medusa/src/services/product.ts b/packages/medusa/src/services/product.ts index a5423d5f1b..651157635a 100644 --- a/packages/medusa/src/services/product.ts +++ b/packages/medusa/src/services/product.ts @@ -12,12 +12,14 @@ import { ProductType, ProductVariant, SalesChannel, + ProductCategory, } from "../models" import { ImageRepository } from "../repositories/image" import { FindWithoutRelationsOptions, ProductRepository, } from "../repositories/product" +import { ProductCategoryRepository } from "../repositories/product-category" import { ProductOptionRepository } from "../repositories/product-option" import { ProductTagRepository } from "../repositories/product-tag" import { ProductTypeRepository } from "../repositories/product-type" @@ -42,6 +44,7 @@ type InjectedDependencies = { productTypeRepository: typeof ProductTypeRepository productTagRepository: typeof ProductTagRepository imageRepository: typeof ImageRepository + productCategoryRepository: typeof ProductCategoryRepository productVariantService: ProductVariantService searchService: SearchService eventBusService: EventBusService @@ -58,6 +61,8 @@ class ProductService extends TransactionBaseService { protected readonly productTypeRepository_: typeof ProductTypeRepository protected readonly productTagRepository_: typeof ProductTagRepository protected readonly imageRepository_: typeof ImageRepository + // eslint-disable-next-line max-len + protected readonly productCategoryRepository_: typeof ProductCategoryRepository protected readonly productVariantService_: ProductVariantService protected readonly searchService_: SearchService protected readonly eventBus_: EventBusService @@ -79,6 +84,7 @@ class ProductService extends TransactionBaseService { productVariantService, productTypeRepository, productTagRepository, + productCategoryRepository, imageRepository, searchService, featureFlagRouter, @@ -92,6 +98,7 @@ class ProductService extends TransactionBaseService { this.productVariantRepository_ = productVariantRepository this.eventBus_ = eventBusService this.productVariantService_ = productVariantService + this.productCategoryRepository_ = productCategoryRepository this.productTypeRepository_ = productTypeRepository this.productTagRepository_ = productTagRepository this.imageRepository_ = imageRepository @@ -393,6 +400,7 @@ class ProductService extends TransactionBaseService { type, images, sales_channels: salesChannels, + categories: categories, ...rest } = productObject @@ -433,6 +441,17 @@ class ProductService extends TransactionBaseService { } } + if (isDefined(categories)) { + product.categories = [] + + if (categories?.length) { + const categoryIds = categories.map((c) => c.id) + const categoryRecords = categoryIds.map((id) => ({ id } as ProductCategory)) + + product.categories = categoryRecords + } + } + product = await productRepo.save(product) product.options = await Promise.all( @@ -513,6 +532,7 @@ class ProductService extends TransactionBaseService { tags, type, sales_channels: salesChannels, + categories: categories, ...rest } = update @@ -536,6 +556,17 @@ class ProductService extends TransactionBaseService { product.tags = await productTagRepo.upsertTags(tags) } + if (isDefined(categories)) { + product.categories = [] + + if (categories?.length) { + const categoryIds = categories.map((c) => c.id) + const categoryRecords = categoryIds.map((id) => ({ id } as ProductCategory)) + + product.categories = categoryRecords + } + } + if ( this.featureFlagRouter_.isFeatureEnabled(SalesChannelFeatureFlag.key) ) { diff --git a/packages/medusa/src/types/product.ts b/packages/medusa/src/types/product.ts index a5625832fc..a9ed1c856c 100644 --- a/packages/medusa/src/types/product.ts +++ b/packages/medusa/src/types/product.ts @@ -14,6 +14,7 @@ import { ProductOptionValue, ProductStatus, SalesChannel, + ProductCategory, } from "../models" import { FeatureFlagDecorators } from "../utils/feature-flag-decorators" import { optionalBooleanMapper } from "../utils/validators/is-boolean" @@ -74,6 +75,10 @@ export class FilterableProductProps { @FeatureFlagDecorators(SalesChannelFeatureFlag.key, [IsOptional(), IsArray()]) sales_channel_id?: string[] + @IsArray() + @IsOptional() + category_id?: string[] + @IsString() @IsOptional() discount_condition_id?: string @@ -101,6 +106,7 @@ export type ProductSelector = discount_condition_id?: string price_list_id?: string[] | FindOperator sales_channel_id?: string[] | FindOperator + category_id?: string[] | FindOperator }) /** @@ -124,6 +130,7 @@ export type CreateProductInput = { options?: CreateProductProductOption[] variants?: CreateProductProductVariantInput[] sales_channels?: CreateProductProductSalesChannelInput[] | null + categories?: CreateProductProductCategoryInput[] | null weight?: number length?: number height?: number @@ -145,6 +152,10 @@ export type CreateProductProductSalesChannelInput = { id: string } +export type CreateProductProductCategoryInput = { + id: string +} + export type CreateProductProductTypeInput = { id?: string value: string @@ -226,6 +237,11 @@ export class ProductSalesChannelReq { id: string } +export class ProductProductCategoryReq { + @IsString() + id: string +} + export class ProductTagReq { @IsString() @IsOptional()