diff --git a/.changeset/ninety-hairs-obey.md b/.changeset/ninety-hairs-obey.md new file mode 100644 index 0000000000000..31c28a7efa834 --- /dev/null +++ b/.changeset/ninety-hairs-obey.md @@ -0,0 +1,5 @@ +--- +"@medusajs/medusa": patch +--- + +feat(medusa): added admin endpoint to update product categories diff --git a/integration-tests/api/__tests__/admin/product-category.ts b/integration-tests/api/__tests__/admin/product-category.ts index 9e06a98cfe3ba..4b6659f4f2c1d 100644 --- a/integration-tests/api/__tests__/admin/product-category.ts +++ b/integration-tests/api/__tests__/admin/product-category.ts @@ -18,6 +18,7 @@ describe("/admin/product-categories", () => { let medusaProcess let dbConnection let productCategory = null + let productCategory2 = null let productCategoryChild = null let productCategoryParent = null let productCategoryChild2 = null @@ -370,4 +371,95 @@ describe("/admin/product-categories", () => { expect(errorFetchingDeleted.response.status).toEqual(404) }) }) + + describe("POST /admin/product-categories/:id", () => { + beforeEach(async () => { + await adminSeeder(dbConnection) + + productCategory = await simpleProductCategoryFactory(dbConnection, { + name: "skinny jeans", + handle: "skinny-jeans", + }) + + productCategory2 = await simpleProductCategoryFactory(dbConnection, { + name: "sweater", + handle: "sweater", + }) + }) + + afterEach(async () => { + const db = useDb() + return await db.teardown() + }) + + it("throws an error if invalid ID is sent", async () => { + const api = useApi() + + const error = await api.post( + `/admin/product-categories/not-found-id`, + { + name: 'testing' + }, + adminHeaders + ).catch(e => e) + + expect(error.response.status).toEqual(404) + expect(error.response.data.type).toEqual("not_found") + expect(error.response.data.message).toEqual( + "ProductCategory with id: not-found-id was not found" + ) + }) + + it("throws an error if invalid attribute is sent", async () => { + const api = useApi() + + const error = await api.post( + `/admin/product-categories/${productCategory.id}`, + { + invalid_property: 'string' + }, + 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 invalid_property should not exist" + ) + }) + + it("successfully updates a product category", async () => { + const api = useApi() + + const response = await api.post( + `/admin/product-categories/${productCategory.id}`, + { + name: "test", + handle: "test", + is_internal: true, + is_active: true, + parent_category_id: productCategory2.id, + }, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data).toEqual( + expect.objectContaining({ + product_category: expect.objectContaining({ + name: "test", + handle: "test", + is_internal: true, + is_active: true, + created_at: expect.any(String), + updated_at: expect.any(String), + parent_category: expect.objectContaining({ + id: productCategory2.id, + }), + category_children: [] + }), + }) + ) + }) + }) }) 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 69e5960f40e75..b286989f4b4f3 100644 --- a/packages/medusa/src/api/routes/admin/product-categories/index.ts +++ b/packages/medusa/src/api/routes/admin/product-categories/index.ts @@ -1,6 +1,9 @@ import { Router } from "express" -import middlewares, { transformQuery, transformBody } from "../../../middlewares" +import middlewares, { + transformQuery, + transformBody, +} from "../../../middlewares" import { isFeatureFlagEnabled } from "../../../middlewares/feature-flag-enabled" import deleteProductCategory from "./delete-product-category" @@ -17,6 +20,11 @@ import createProductCategory, { AdminPostProductCategoriesParams, } from "./create-product-category" +import updateProductCategory, { + AdminPostProductCategoriesCategoryReq, + AdminPostProductCategoriesCategoryParams, +} from "./update-product-category" + const route = Router() export default (app) => { @@ -56,6 +64,17 @@ export default (app) => { middlewares.wrap(getProductCategory) ) + route.post( + "/:id", + transformQuery(AdminPostProductCategoriesCategoryParams, { + defaultFields: defaultProductCategoryFields, + defaultRelations: defaultAdminProductCategoryRelations, + isList: false, + }), + transformBody(AdminPostProductCategoriesCategoryReq), + middlewares.wrap(updateProductCategory) + ) + route.delete("/:id", middlewares.wrap(deleteProductCategory)) return app diff --git a/packages/medusa/src/api/routes/admin/product-categories/update-product-category.ts b/packages/medusa/src/api/routes/admin/product-categories/update-product-category.ts new file mode 100644 index 0000000000000..3a1ae2aa91e71 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/product-categories/update-product-category.ts @@ -0,0 +1,125 @@ +import { IsOptional, IsString } from "class-validator" +import { Request, Response } from "express" +import { EntityManager } from "typeorm" + +import { ProductCategoryService } from "../../../../services" +import { AdminProductCategoriesReqBase } from "../../../../types/product-category" +import { FindParams } from "../../../../types/common" +/** + * @oas [post] /product-categories/{id} + * operationId: "PostProductCategoriesCategory" + * summary: "Update a Product Category" + * description: "Updates a Product Category." + * x-authenticated: true + * parameters: + * - (path) id=* {string} The ID of the Product Category. + * - (query) expand {string} (Comma separated) Which fields should be expanded in each product category. + * - (query) fields {string} (Comma separated) Which fields should be retrieved in each product category. + * requestBody: + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/AdminPostProductCategoriesCategoryReq" + * x-codeSamples: + * - lang: JavaScript + * label: JS Client + * source: | + * import Medusa from "@medusajs/medusa-js" + * const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 }) + * // must be previously logged in or use api token + * medusa.admin.productCategories.update(categoryId, { + * name: 'Skinny Jeans' + * }) + * .then(({ productCategory }) => { + * console.log(productCategory.id); + * }); + * - lang: Shell + * label: cURL + * source: | + * curl --location --request POST 'https://medusa-url.com/admin/product-categories/{id}' \ + * --header 'Authorization: Bearer {api_token}' \ + * --header 'Content-Type: application/json' \ + * --data-raw '{ + * "name": "Skinny Jeans" + * }' + * security: + * - api_token: [] + * - cookie_auth: [] + * tags: + * - Product Category + * responses: + * "200": + * description: OK + * content: + * application/json: + * schema: + * type: object + * properties: + * productCategory: + * $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) => { + const { id } = req.params + const { validatedBody } = req as { + validatedBody: AdminPostProductCategoriesCategoryReq + } + + const productCategoryService: ProductCategoryService = req.scope.resolve( + "productCategoryService" + ) + + const manager: EntityManager = req.scope.resolve("manager") + const updated = await manager.transaction(async (transactionManager) => { + return await productCategoryService + .withTransaction(transactionManager) + .update(id, validatedBody) + }) + + const productCategory = await productCategoryService.retrieve( + updated.id, + req.retrieveConfig, + ) + + res.status(200).json({ product_category: productCategory }) +} + +/** + * @schema AdminPostProductCategoriesCategoryReq + * type: object + * properties: + * name: + * type: string + * description: The name to identify the Product Category by. + * handle: + * type: string + * description: A handle to be used in slugs. + * is_internal: + * type: boolean + * description: A flag to make product category an internal category for admins + * is_active: + * type: boolean + * description: A flag to make product category visible/hidden in the store front + * parent_category_id: + * type: string + * description: The ID of the parent product category + */ +// eslint-disable-next-line max-len +export class AdminPostProductCategoriesCategoryReq extends AdminProductCategoriesReqBase { + @IsString() + @IsOptional() + name?: string +} + +export class AdminPostProductCategoriesCategoryParams extends FindParams {} diff --git a/packages/medusa/src/services/__tests__/product-category.ts b/packages/medusa/src/services/__tests__/product-category.ts index 03c768efccfdf..b08448f119be4 100644 --- a/packages/medusa/src/services/__tests__/product-category.ts +++ b/packages/medusa/src/services/__tests__/product-category.ts @@ -180,4 +180,50 @@ describe("ProductCategoryService", () => { ) }) }) + + describe("update", () => { + const productCategoryRepository = MockRepository({ + findOne: query => { + if (query.where.id === IdMap.getId(invalidProdCategoryId)) { + return null + } + + return Promise.resolve({ id: IdMap.getId(validProdCategoryId) }) + }, + findDescendantsTree: (productCategory) => { + return Promise.resolve(productCategory) + }, + }) + + const productCategoryService = new ProductCategoryService({ + manager: MockManager, + productCategoryRepository, + }) + + beforeEach(async () => { + jest.clearAllMocks() + }) + + it("successfully updates a product category", async () => { + await productCategoryService.update(IdMap.getId(validProdCategoryId), { + name: "bathrobes", + }) + + expect(productCategoryRepository.save).toHaveBeenCalledTimes(1) + expect(productCategoryRepository.save).toHaveBeenCalledWith({ + id: IdMap.getId(validProdCategoryId), + name: "bathrobes", + }) + }) + + it("fails on not-found Id product category", async () => { + const error = await productCategoryService.update(IdMap.getId(invalidProdCategoryId), { + name: "bathrobes", + }).catch(e => e) + + expect(error.message).toBe( + `ProductCategory with id: ${IdMap.getId(invalidProdCategoryId)} was not found` + ) + }) + }) }) diff --git a/packages/medusa/src/services/product-category.ts b/packages/medusa/src/services/product-category.ts index 94fe2bf26717b..0d2865f08be19 100644 --- a/packages/medusa/src/services/product-category.ts +++ b/packages/medusa/src/services/product-category.ts @@ -5,7 +5,10 @@ import { ProductCategory } from "../models" import { ProductCategoryRepository } from "../repositories/product-category" import { FindConfig, Selector, QuerySelector } from "../types/common" import { buildQuery } from "../utils" -import { CreateProductCategoryInput } from "../types/product-category" +import { + CreateProductCategoryInput, + UpdateProductCategoryInput, +} from "../types/product-category" type InjectedDependencies = { manager: EntityManager @@ -116,6 +119,33 @@ class ProductCategoryService extends TransactionBaseService { }) } + /** + * Updates a product category + * @param productCategoryId - id of product category to update + * @param productCategoryInput - parameters to update in product category + * @return updated product category + */ + async update( + productCategoryId: string, + productCategoryInput: UpdateProductCategoryInput + ): Promise { + return await this.atomicPhase_(async (manager) => { + const productCategoryRepo = manager.getCustomRepository( + this.productCategoryRepo_ + ) + + const productCategory = await this.retrieve(productCategoryId) + + for (const key in productCategoryInput) { + if (isDefined(productCategoryInput[key])) { + productCategory[key] = productCategoryInput[key] + } + } + + return await productCategoryRepo.save(productCategory) + }) + } + /** * Deletes a product category * diff --git a/packages/medusa/src/types/product-category.ts b/packages/medusa/src/types/product-category.ts index 624ed1fea9a03..51f06ec50230a 100644 --- a/packages/medusa/src/types/product-category.ts +++ b/packages/medusa/src/types/product-category.ts @@ -9,6 +9,14 @@ export type CreateProductCategoryInput = { parent_category_id?: string | null } +export type UpdateProductCategoryInput = { + name?: string + handle?: string + is_internal?: boolean + is_active?: boolean + parent_category_id?: string | null +} + export class AdminProductCategoriesReqBase { @IsOptional() @IsString()