From 5ec6d438fb1f909be925461c788f3a3a958528e4 Mon Sep 17 00:00:00 2001 From: Riqwan Thamir Date: Tue, 31 Jan 2023 10:23:03 +0100 Subject: [PATCH] feat(medusa): batch remove products from a category (#3141) * chore: added batch endpoint to remove products from categories * chore: remove consoles * Apply suggestions from code review Co-authored-by: Patrick <116003638+patrick-medusajs@users.noreply.github.com> * chore: added oas changes --------- Co-authored-by: Patrick <116003638+patrick-medusajs@users.noreply.github.com> --- .changeset/mean-rings-explain.md | 5 + .../api/__tests__/admin/product-category.ts | 131 +++++++++++++++++- .../product-categories/add-products-batch.ts | 5 +- .../delete-products-batch.ts | 119 ++++++++++++++++ .../routes/admin/product-categories/index.ts | 29 ++++ .../src/repositories/product-category.ts | 16 +++ .../services/__tests__/product-category.ts | 35 ++++- .../medusa/src/services/product-category.ts | 21 +++ 8 files changed, 354 insertions(+), 7 deletions(-) create mode 100644 .changeset/mean-rings-explain.md create mode 100644 packages/medusa/src/api/routes/admin/product-categories/delete-products-batch.ts diff --git a/.changeset/mean-rings-explain.md b/.changeset/mean-rings-explain.md new file mode 100644 index 0000000000..faf8554344 --- /dev/null +++ b/.changeset/mean-rings-explain.md @@ -0,0 +1,5 @@ +--- +"@medusajs/medusa": patch +--- + +feat(medusa): batch remove products from a category diff --git a/integration-tests/api/__tests__/admin/product-category.ts b/integration-tests/api/__tests__/admin/product-category.ts index 794ab3a450..0c26029361 100644 --- a/integration-tests/api/__tests__/admin/product-category.ts +++ b/integration-tests/api/__tests__/admin/product-category.ts @@ -1,5 +1,5 @@ import path from "path" -import { Product } from "@medusajs/medusa" +import { Product, ProductCategory } from "@medusajs/medusa" import { In } from "typeorm" import startServerWithEnvironment from "../../../helpers/start-server-with-environment" @@ -32,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 @@ -586,4 +586,131 @@ describe("/admin/product-categories", () => { }) }) }) + + describe("DELETE /admin/product-categories/:id/products/batch", () => { + let testProduct1, testProduct2 + + beforeEach(async () => { + await adminSeeder(dbConnection) + + testProduct1 = await simpleProductFactory(dbConnection, { + id: "test-product-1", + title: "test product 1", + }) + + testProduct2 = await simpleProductFactory(dbConnection, { + id: "test-product-2", + title: "test product 2", + }) + + productCategory = await simpleProductCategoryFactory(dbConnection, { + id: "test-category", + name: "test category", + products: [testProduct1, testProduct2] + }) + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("should remove products from a product category", async () => { + const api = useApi() + + const payload = { + product_ids: [{ id: testProduct2.id }], + } + + const response = await api.delete( + `/admin/product-categories/${productCategory.id}/products/batch`, + { + ...adminHeaders, + data: payload, + } + ) + + 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([]) + }) + + it("throws error when product ID is invalid", async () => { + const api = useApi() + + const payload = { + product_ids: [{ id: "product-id-invalid" }], + } + + const error = await api.delete( + `/admin/product-categories/${productCategory.id}/products/batch`, + { + ...adminHeaders, + data: payload, + } + ).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.delete( + `/admin/product-categories/invalid-category-id/products/batch`, + { + ...adminHeaders, + data: payload, + } + ).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.delete( + `/admin/product-categories/invalid-category-id/products/batch?expand=products`, + { + ...adminHeaders, + data: payload, + } + ).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/routes/admin/product-categories/add-products-batch.ts b/packages/medusa/src/api/routes/admin/product-categories/add-products-batch.ts index 10094aa10f..06b106318f 100644 --- 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 @@ -50,10 +50,7 @@ import { FindParams } from "../../../../types/common" * content: * application/json: * schema: - * type: object - * properties: - * product_category: - * $ref: "#/components/schemas/ProductCategory" + * $ref: "#/components/schemas/AdminProductCategoriesRes" * "400": * $ref: "#/components/responses/400_error" * "401": diff --git a/packages/medusa/src/api/routes/admin/product-categories/delete-products-batch.ts b/packages/medusa/src/api/routes/admin/product-categories/delete-products-batch.ts new file mode 100644 index 0000000000..f6db3712c7 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/product-categories/delete-products-batch.ts @@ -0,0 +1,119 @@ +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 [delete] /product-categories/{id}/products/batch + * operationId: "DeleteProductCategoriesCategoryProductsBatch" + * summary: "Delete Products" + * description: "Remove a list of products from 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/AdminDeleteProductCategoriesCategoryProductsBatchReq" + * x-codegen: + * method: removeProducts + * queryParams: AdminDeleteProductCategoriesCategoryProductsBatchParams + * x-codeSamples: + * - lang: Shell + * label: cURL + * source: | + * curl --location --request DELETE 'https://medusa-url.com/admin/product-categories/{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: + * $ref: "#/components/schemas/AdminProductCategoriesRes" + * "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) => { + const { id } = req.params + const validatedBody = + req.validatedBody as AdminDeleteProductCategoriesCategoryProductsBatchReq + + const productCategoryService: ProductCategoryService = req.scope.resolve( + "productCategoryService" + ) + + const manager: EntityManager = req.scope.resolve("manager") + await manager.transaction(async (manager) => { + return await productCategoryService.withTransaction(manager).removeProducts( + id, + validatedBody.product_ids.map((p) => p.id) + ) + }) + + const productCategory = await productCategoryService.retrieve( + id, + req.retrieveConfig + ) + + res.status(200).json({ product_category: productCategory }) +} + +/** + * @schema AdminDeleteProductCategoriesCategoryProductsBatchReq + * type: object + * required: + * - product_ids + * properties: + * product_ids: + * description: The IDs of the products to delete from the Product Category. + * type: array + * items: + * type: object + * required: + * - id + * properties: + * id: + * description: The ID of a product + * type: string + */ +export class AdminDeleteProductCategoriesCategoryProductsBatchReq { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ProductBatchProductCategory) + product_ids: ProductBatchProductCategory[] +} + +// eslint-disable-next-line max-len +export class AdminDeleteProductCategoriesCategoryProductsBatchParams 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 e630dbaf55..5ad24f1f39 100644 --- a/packages/medusa/src/api/routes/admin/product-categories/index.ts +++ b/packages/medusa/src/api/routes/admin/product-categories/index.ts @@ -32,6 +32,13 @@ import addProductsBatch, { AdminPostProductCategoriesCategoryProductsBatchParams, } from "./add-products-batch" +import deleteProductsBatch, { + AdminDeleteProductCategoriesCategoryProductsBatchReq, + AdminDeleteProductCategoriesCategoryProductsBatchParams, +} from "./delete-products-batch" + +import { ProductCategory } from "../../../../models" + const route = Router() export default (app) => { @@ -98,6 +105,17 @@ export default (app) => { middlewares.wrap(addProductsBatch) ) + route.delete( + "/:id/products/batch", + transformQuery( + AdminDeleteProductCategoriesCategoryProductsBatchParams, + retrieveTransformQueryConfig + ), + transformBody(AdminDeleteProductCategoriesCategoryProductsBatchReq), + validateProductsExist((req) => req.body.product_ids), + middlewares.wrap(deleteProductsBatch) + ) + return app } @@ -125,3 +143,14 @@ export const defaultProductCategoryFields = [ "created_at", "updated_at", ] + +/** + * @schema AdminProductCategoriesRes + * type: object + * properties: + * product_category: + * $ref: "#/components/schemas/ProductCategory" + */ +export type AdminProductCategoriesRes = { + product_category: ProductCategory +} diff --git a/packages/medusa/src/repositories/product-category.ts b/packages/medusa/src/repositories/product-category.ts index 852e69c4ea..a16760fc84 100644 --- a/packages/medusa/src/repositories/product-category.ts +++ b/packages/medusa/src/repositories/product-category.ts @@ -4,6 +4,8 @@ import { Brackets, ILike, getConnection, + DeleteResult, + In, } from "typeorm" import { ProductCategory } from "../models/product-category" import { ExtendedFindConfig, Selector, QuerySelector } from "../types/common" @@ -101,4 +103,18 @@ export class ProductCategoryRepository extends TreeRepository { .orIgnore() .execute() } + + async removeProducts( + productCategoryId: string, + productIds: string[] + ): Promise { + return await this.createQueryBuilder() + .delete() + .from(ProductCategory.productCategoryProductJoinTable) + .where({ + product_category_id: productCategoryId, + product_id: In(productIds), + }) + .execute() + } } diff --git a/packages/medusa/src/services/__tests__/product-category.ts b/packages/medusa/src/services/__tests__/product-category.ts index a54ce2e8f6..6497becaa6 100644 --- a/packages/medusa/src/services/__tests__/product-category.ts +++ b/packages/medusa/src/services/__tests__/product-category.ts @@ -307,7 +307,7 @@ describe("ProductCategoryService", () => { jest.clearAllMocks() }) - it("should add a list of product to a sales channel", async () => { + it("should add a list of product to a category", async () => { const result = await productCategoryService.addProducts( IdMap.getId("product-category-id"), [IdMap.getId("product-id")] @@ -321,4 +321,37 @@ describe("ProductCategoryService", () => { ) }) }) + + describe("removeProducts", () => { + const productCategoryRepository = { + ...MockRepository(), + removeProducts: jest.fn().mockImplementation((id, productIds) => { + return Promise.resolve() + }), + } + + const productCategoryService = new ProductCategoryService({ + manager: MockManager, + productCategoryRepository, + eventBusService, + }) + + beforeEach(() => { + jest.clearAllMocks() + }) + + it("should remove a list of product from a category", async () => { + const result = await productCategoryService.removeProducts( + IdMap.getId("product-category-id"), + [IdMap.getId("product-id")] + ) + + expect(result).toBeUndefined() + expect(productCategoryRepository.removeProducts).toHaveBeenCalledTimes(1) + expect(productCategoryRepository.removeProducts).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 b4e7aa724a..60082d133a 100644 --- a/packages/medusa/src/services/product-category.ts +++ b/packages/medusa/src/services/product-category.ts @@ -235,6 +235,27 @@ class ProductCategoryService extends TransactionBaseService { await productCategoryRepository.addProducts(productCategoryId, productIds) }) } + + /** + * Remove a batch of product from a product category + * @param productCategoryId - The id of the product category on which to remove the products + * @param productIds - The products ids to remove from the product category + * @return the product category on which the products have been removed + */ + async removeProducts( + productCategoryId: string, + productIds: string[] + ): Promise { + return await this.atomicPhase_(async (manager) => { + const productCategoryRepository: ProductCategoryRepository = + manager.getCustomRepository(this.productCategoryRepo_) + + await productCategoryRepository.removeProducts( + productCategoryId, + productIds + ) + }) + } } export default ProductCategoryService