diff --git a/.changeset/tidy-students-kiss.md b/.changeset/tidy-students-kiss.md new file mode 100644 index 000000000000..5496dbd8daa4 --- /dev/null +++ b/.changeset/tidy-students-kiss.md @@ -0,0 +1,5 @@ +--- +"@medusajs/medusa": patch +--- + +feat(medusa): Products can be added to categories in batch request diff --git a/integration-tests/api/__tests__/admin/product-category.ts b/integration-tests/api/__tests__/admin/product-category.ts index 4b6659f4f2c1..794ab3a45064 100644 --- a/integration-tests/api/__tests__/admin/product-category.ts +++ b/integration-tests/api/__tests__/admin/product-category.ts @@ -1,10 +1,15 @@ import path from "path" +import { Product } from "@medusajs/medusa" +import { In } from "typeorm" import startServerWithEnvironment from "../../../helpers/start-server-with-environment" import { useApi } from "../../../helpers/use-api" import { useDb } from "../../../helpers/use-db" import adminSeeder from "../../helpers/admin-seeder" -import { simpleProductCategoryFactory } from "../../factories" +import { + simpleProductCategoryFactory, + simpleProductFactory, +} from "../../factories" jest.setTimeout(30000) @@ -27,7 +32,7 @@ describe("/admin/product-categories", () => { const cwd = path.resolve(path.join(__dirname, "..", "..")) const [process, connection] = await startServerWithEnvironment({ cwd, - env: { MEDUSA_FF_PRODUCT_CATEGORIES: true }, + env: { MEDUSA_FF_PRODUCT_CATEGORIES: true } }) dbConnection = connection medusaProcess = process @@ -462,4 +467,123 @@ describe("/admin/product-categories", () => { ) }) }) + + describe("POST /admin/product-categories/:id/products/batch", () => { + beforeEach(async () => { + await adminSeeder(dbConnection) + + productCategory = await simpleProductCategoryFactory(dbConnection, { + id: "test-category", + name: "test category", + }) + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("should add products to a product category", async () => { + const api = useApi() + const testProduct1 = await simpleProductFactory(dbConnection, { + id: "test-product-1", + title: "test product 1", + }) + + const testProduct2 = await simpleProductFactory(dbConnection, { + id: "test-product-2", + title: "test product 2", + }) + + const payload = { + product_ids: [{ id: testProduct1.id }, { id: testProduct2.id }], + } + + const response = await api.post( + `/admin/product-categories/${productCategory.id}/products/batch`, + payload, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.product_category).toEqual( + expect.objectContaining({ + id: productCategory.id, + created_at: expect.any(String), + updated_at: expect.any(String), + }) + ) + + const products = await dbConnection.manager.find(Product, { + where: { id: In([testProduct1.id, testProduct2.id]) }, + relations: ["categories"], + }) + + expect(products[0].categories).toEqual([ + expect.objectContaining({ + id: productCategory.id + }) + ]) + + expect(products[1].categories).toEqual([ + expect.objectContaining({ + id: productCategory.id + }) + ]) + }) + + it("throws error when product ID is invalid", async () => { + const api = useApi() + + const payload = { + product_ids: [{ id: "product-id-invalid" }], + } + + const error = await api.post( + `/admin/product-categories/${productCategory.id}/products/batch`, + payload, + adminHeaders + ).catch(e => e) + + expect(error.response.status).toEqual(400) + expect(error.response.data).toEqual({ + errors: ["Products product-id-invalid do not exist"], + message: "Provided request body contains errors. Please check the data and retry the request" + }) + }) + + it("throws error when category ID is invalid", async () => { + const api = useApi() + const payload = { product_ids: [] } + + const error = await api.post( + `/admin/product-categories/invalid-category-id/products/batch`, + payload, + adminHeaders + ).catch(e => e) + + expect(error.response.status).toEqual(404) + expect(error.response.data).toEqual({ + message: "ProductCategory with id: invalid-category-id was not found", + type: "not_found", + }) + }) + + it("throws error trying to expand not allowed relations", async () => { + const api = useApi() + const payload = { product_ids: [] } + + const error = await api.post( + `/admin/product-categories/invalid-category-id/products/batch?expand=products`, + payload, + adminHeaders + ).catch(e => e) + + expect(error.response.status).toEqual(400) + expect(error.response.data).toEqual({ + message: "Relations [products] are not valid", + type: "invalid_data", + }) + }) + }) }) diff --git a/packages/medusa/src/api/middlewares/validators/product-existence.ts b/packages/medusa/src/api/middlewares/validators/product-existence.ts index 8e5a04954505..a601b581996c 100644 --- a/packages/medusa/src/api/middlewares/validators/product-existence.ts +++ b/packages/medusa/src/api/middlewares/validators/product-existence.ts @@ -1,26 +1,30 @@ import { NextFunction, Request, Response } from "express" -import { ProductService } from "../../../services" -import { ProductBatchSalesChannel } from "../../../types/sales-channels" -export function validateProductsExist( - getProducts: (req) => ProductBatchSalesChannel[] | undefined +type GetProductsRequiredParams = { + id: string +} + +export function validateProductsExist( + getProducts: (req) => T[] ): (req: Request, res: Response, next: NextFunction) => Promise { return async (req: Request, res: Response, next: NextFunction) => { - const products = getProducts(req) + const requestedProducts = getProducts(req) - if (!products?.length) { + if (!requestedProducts?.length) { return next() } - const productService: ProductService = req.scope.resolve("productService") - - const productIds = products.map((product) => product.id) - const [existingProducts] = await productService.listAndCount({ - id: productIds, + const productService = req.scope.resolve("productService") + const requestedProductIds = requestedProducts.map((product) => product.id) + const [productRecords] = await productService.listAndCount({ + id: requestedProductIds, }) - const nonExistingProducts = productIds.filter( - (scId) => existingProducts.findIndex((sc) => sc.id === scId) === -1 + const nonExistingProducts = requestedProductIds.filter( + (requestedProductId) => + productRecords.findIndex( + (productRecord) => productRecord.id === requestedProductId + ) === -1 ) if (nonExistingProducts.length) { diff --git a/packages/medusa/src/api/routes/admin/product-categories/add-products-batch.ts b/packages/medusa/src/api/routes/admin/product-categories/add-products-batch.ts new file mode 100644 index 000000000000..10094aa10ff5 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/product-categories/add-products-batch.ts @@ -0,0 +1,123 @@ +import { IsArray, ValidateNested } from "class-validator" +import { Request, Response } from "express" + +import { EntityManager } from "typeorm" +import { ProductBatchProductCategory } from "../../../../types/product-category" +import { ProductCategoryService } from "../../../../services" +import { Type } from "class-transformer" +import { FindParams } from "../../../../types/common" + +/** + * @oas [post] /product-categories/{id}/products/batch + * operationId: "PostProductCategoriesCategoryProductsBatch" + * summary: "Add Products to a category" + * description: "Assign a batch of products to a product category." + * x-authenticated: true + * parameters: + * - (path) id=* {string} The ID of the Product Category. + * - (query) expand {string} (Comma separated) Category fields to be expanded in the response. + * - (query) fields {string} (Comma separated) Category fields to be retrieved in the response. + * requestBody: + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/AdminPostProductCategoriesCategoryProductsBatchReq" + * x-codegen: + * method: addProducts + * x-codeSamples: + * - lang: Shell + * label: cURL + * source: | + * curl --location \ + * --request POST 'https://medusa-url.com/admin/product-categories/{product_category_id}/products/batch' \ + * --header 'Authorization: Bearer {api_token}' \ + * --header 'Content-Type: application/json' \ + * --data-raw '{ + * "product_ids": [ + * { + * "id": "{product_id}" + * } + * ] + * }' + * security: + * - api_token: [] + * - cookie_auth: [] + * tags: + * - Product Category + * responses: + * 200: + * description: OK + * content: + * application/json: + * schema: + * type: object + * properties: + * product_category: + * $ref: "#/components/schemas/ProductCategory" + * "400": + * $ref: "#/components/responses/400_error" + * "401": + * $ref: "#/components/responses/unauthorized" + * "404": + * $ref: "#/components/responses/not_found_error" + * "409": + * $ref: "#/components/responses/invalid_state_error" + * "422": + * $ref: "#/components/responses/invalid_request_error" + * "500": + * $ref: "#/components/responses/500_error" + */ +export default async (req: Request, res: Response): Promise => { + const validatedBody = + req.validatedBody as AdminPostProductCategoriesCategoryProductsBatchReq + + const { id } = req.params + + const productCategoryService: ProductCategoryService = req.scope.resolve( + "productCategoryService" + ) + + const manager: EntityManager = req.scope.resolve("manager") + await manager.transaction(async (transactionManager) => { + return await productCategoryService + .withTransaction(transactionManager) + .addProducts( + id, + validatedBody.product_ids.map((p) => p.id), + ) + }) + + const productCategory = await productCategoryService.retrieve( + id, + req.retrieveConfig + ) + + res.status(200).json({ product_category: productCategory }) +} + +/** + * @schema AdminPostProductCategoriesCategoryProductsBatchReq + * type: object + * required: + * - product_ids + * properties: + * product_ids: + * description: The IDs of the products to add to the Product Category + * type: array + * items: + * type: object + * required: + * - id + * properties: + * id: + * type: string + * description: The ID of the product + */ +export class AdminPostProductCategoriesCategoryProductsBatchReq { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ProductBatchProductCategory) + product_ids: ProductBatchProductCategory[] +} + +export class AdminPostProductCategoriesCategoryProductsBatchParams extends FindParams {} diff --git a/packages/medusa/src/api/routes/admin/product-categories/index.ts b/packages/medusa/src/api/routes/admin/product-categories/index.ts index b286989f4b4f..e630dbaf55d3 100644 --- a/packages/medusa/src/api/routes/admin/product-categories/index.ts +++ b/packages/medusa/src/api/routes/admin/product-categories/index.ts @@ -4,8 +4,10 @@ import middlewares, { transformQuery, transformBody, } from "../../../middlewares" + import { isFeatureFlagEnabled } from "../../../middlewares/feature-flag-enabled" import deleteProductCategory from "./delete-product-category" +import { validateProductsExist } from "../../../middlewares/validators/product-existence" import getProductCategory, { AdminGetProductCategoryParams, @@ -25,9 +27,26 @@ import updateProductCategory, { AdminPostProductCategoriesCategoryParams, } from "./update-product-category" +import addProductsBatch, { + AdminPostProductCategoriesCategoryProductsBatchReq, + AdminPostProductCategoriesCategoryProductsBatchParams, +} from "./add-products-batch" + const route = Router() export default (app) => { + const retrieveTransformQueryConfig = { + defaultFields: defaultProductCategoryFields, + defaultRelations: defaultAdminProductCategoryRelations, + allowedRelations: allowedAdminProductCategoryRelations, + isList: false, + } + + const listTransformQueryConfig = { + ...retrieveTransformQueryConfig, + isList: true, + } + app.use( "/product-categories", isFeatureFlagEnabled("product_categories"), @@ -36,47 +55,49 @@ export default (app) => { route.post( "/", - transformQuery(AdminPostProductCategoriesParams, { - defaultFields: defaultProductCategoryFields, - defaultRelations: defaultAdminProductCategoryRelations, - isList: false, - }), + transformQuery( + AdminPostProductCategoriesParams, + retrieveTransformQueryConfig + ), transformBody(AdminPostProductCategoriesReq), middlewares.wrap(createProductCategory) ) route.get( "/", - transformQuery(AdminGetProductCategoriesParams, { - defaultFields: defaultProductCategoryFields, - defaultRelations: defaultAdminProductCategoryRelations, - isList: true, - }), + transformQuery(AdminGetProductCategoriesParams, listTransformQueryConfig), middlewares.wrap(listProductCategories) ) route.get( "/:id", - transformQuery(AdminGetProductCategoryParams, { - defaultFields: defaultProductCategoryFields, - isList: false, - }), + transformQuery(AdminGetProductCategoryParams, retrieveTransformQueryConfig), middlewares.wrap(getProductCategory) ) route.post( "/:id", - transformQuery(AdminPostProductCategoriesCategoryParams, { - defaultFields: defaultProductCategoryFields, - defaultRelations: defaultAdminProductCategoryRelations, - isList: false, - }), + transformQuery( + AdminPostProductCategoriesCategoryParams, + retrieveTransformQueryConfig + ), transformBody(AdminPostProductCategoriesCategoryReq), middlewares.wrap(updateProductCategory) ) route.delete("/:id", middlewares.wrap(deleteProductCategory)) + route.post( + "/:id/products/batch", + transformQuery( + AdminPostProductCategoriesCategoryProductsBatchParams, + retrieveTransformQueryConfig + ), + transformBody(AdminPostProductCategoriesCategoryProductsBatchReq), + validateProductsExist((req) => req.body.product_ids), + middlewares.wrap(addProductsBatch) + ) + return app } @@ -90,6 +111,11 @@ export const defaultAdminProductCategoryRelations = [ "category_children", ] +export const allowedAdminProductCategoryRelations = [ + "parent_category", + "category_children", +] + export const defaultProductCategoryFields = [ "id", "name", diff --git a/packages/medusa/src/models/product-category.ts b/packages/medusa/src/models/product-category.ts index 9db1a75b9f28..811785c1a310 100644 --- a/packages/medusa/src/models/product-category.ts +++ b/packages/medusa/src/models/product-category.ts @@ -20,6 +20,7 @@ import { @Entity() @Tree("materialized-path") export class ProductCategory extends SoftDeletableEntity { + static productCategoryProductJoinTable = "product_category_product" static treeRelations = ["parent_category", "category_children"] @Column() @@ -54,7 +55,7 @@ export class ProductCategory extends SoftDeletableEntity { @ManyToMany(() => Product, { cascade: ["remove", "soft-remove"] }) @JoinTable({ - name: "product_category_product", + name: ProductCategory.productCategoryProductJoinTable, joinColumn: { name: "product_category_id", referencedColumnName: "id", diff --git a/packages/medusa/src/repositories/product-category.ts b/packages/medusa/src/repositories/product-category.ts index e101f9d934e0..852e69c4eaa5 100644 --- a/packages/medusa/src/repositories/product-category.ts +++ b/packages/medusa/src/repositories/product-category.ts @@ -84,4 +84,21 @@ export class ProductCategoryRepository extends TreeRepository { return await queryBuilder.getManyAndCount() } + + async addProducts( + productCategoryId: string, + productIds: string[] + ): Promise { + await this.createQueryBuilder() + .insert() + .into(ProductCategory.productCategoryProductJoinTable) + .values( + productIds.map((id) => ({ + product_category_id: productCategoryId, + product_id: id, + })) + ) + .orIgnore() + .execute() + } } diff --git a/packages/medusa/src/services/__tests__/product-category.ts b/packages/medusa/src/services/__tests__/product-category.ts index ef850a936875..a54ce2e8f6d5 100644 --- a/packages/medusa/src/services/__tests__/product-category.ts +++ b/packages/medusa/src/services/__tests__/product-category.ts @@ -288,4 +288,37 @@ describe("ProductCategoryService", () => { ) }) }) + + describe("addProducts", () => { + const productCategoryRepository = { + ...MockRepository(), + addProducts: jest.fn().mockImplementation((id, productIds) => { + return Promise.resolve() + }), + } + + const productCategoryService = new ProductCategoryService({ + manager: MockManager, + productCategoryRepository, + eventBusService, + }) + + beforeEach(() => { + jest.clearAllMocks() + }) + + it("should add a list of product to a sales channel", async () => { + const result = await productCategoryService.addProducts( + IdMap.getId("product-category-id"), + [IdMap.getId("product-id")] + ) + + expect(result).toBeUndefined() + expect(productCategoryRepository.addProducts).toHaveBeenCalledTimes(1) + expect(productCategoryRepository.addProducts).toHaveBeenCalledWith( + IdMap.getId("product-category-id"), + [IdMap.getId("product-id")] + ) + }) + }) }) diff --git a/packages/medusa/src/services/product-category.ts b/packages/medusa/src/services/product-category.ts index d6a7d9b1ff31..b4e7aa724a0a 100644 --- a/packages/medusa/src/services/product-category.ts +++ b/packages/medusa/src/services/product-category.ts @@ -59,7 +59,7 @@ class ProductCategoryService extends TransactionBaseService { take: 100, order: { created_at: "DESC" }, }, - treeSelector: QuerySelector = {}, + treeSelector: QuerySelector = {} ): Promise<[ProductCategory[], number]> { const manager = this.transactionManager_ ?? this.manager_ const productCategoryRepo = manager.getCustomRepository( @@ -217,6 +217,24 @@ class ProductCategoryService extends TransactionBaseService { }) }) } + + /** + * Add a batch of product to a product category + * @param productCategoryId - The id of the product category on which to add the products + * @param productIds - The products ids to attach to the product category + * @return the product category on which the products have been added + */ + async addProducts( + productCategoryId: string, + productIds: string[] + ): Promise { + return await this.atomicPhase_(async (manager) => { + const productCategoryRepository: ProductCategoryRepository = + manager.getCustomRepository(this.productCategoryRepo_) + + await productCategoryRepository.addProducts(productCategoryId, productIds) + }) + } } export default ProductCategoryService diff --git a/packages/medusa/src/services/product.ts b/packages/medusa/src/services/product.ts index 651157635a89..71ba150f5f4d 100644 --- a/packages/medusa/src/services/product.ts +++ b/packages/medusa/src/services/product.ts @@ -1,7 +1,7 @@ import { FlagRouter } from "../utils/flag-router" import { isDefined, MedusaError } from "medusa-core-utils" -import { EntityManager } from "typeorm" +import { EntityManager, In } from "typeorm" import { ProductVariantService, SearchService } from "." import { TransactionBaseService } from "../interfaces" import SalesChannelFeatureFlag from "../loaders/feature-flags/sales-channels" diff --git a/packages/medusa/src/types/product-category.ts b/packages/medusa/src/types/product-category.ts index 51f06ec50230..85e96468d9b9 100644 --- a/packages/medusa/src/types/product-category.ts +++ b/packages/medusa/src/types/product-category.ts @@ -38,3 +38,8 @@ export class AdminProductCategoriesReqBase { }) parent_category_id?: string | null } + +export class ProductBatchProductCategory { + @IsString() + id: string +}