diff --git a/.changeset/hungry-starfishes-count.md b/.changeset/hungry-starfishes-count.md new file mode 100644 index 0000000000000..3c281a24149cf --- /dev/null +++ b/.changeset/hungry-starfishes-count.md @@ -0,0 +1,5 @@ +--- +"@medusajs/medusa": patch +--- + +feat(medusa): added admin create endpoint for product categories diff --git a/integration-tests/api/__tests__/admin/product-category.ts b/integration-tests/api/__tests__/admin/product-category.ts index 0dccf11f751e9..9e06a98cfe3ba 100644 --- a/integration-tests/api/__tests__/admin/product-category.ts +++ b/integration-tests/api/__tests__/admin/product-category.ts @@ -234,6 +234,71 @@ describe("/admin/product-categories", () => { }) }) + describe("POST /admin/product-categories", () => { + beforeEach(async () => { + await adminSeeder(dbConnection) + }) + + afterEach(async () => { + const db = useDb() + return await db.teardown() + }) + + it("throws an error if required fields are missing", async () => { + const api = useApi() + + const error = await api.post( + `/admin/product-categories`, + {}, + adminHeaders + ).catch(e => e) + + expect(error.response.status).toEqual(400) + expect(error.response.data.type).toEqual("invalid_data") + expect(error.response.data.message).toEqual( + "name should not be empty, name must be a string" + ) + }) + + it("successfully creates a product category", async () => { + productCategoryParent = await simpleProductCategoryFactory(dbConnection, { + name: "category parent", + handle: "category-parent", + }) + + const api = useApi() + + const response = await api.post( + `/admin/product-categories`, + { + name: "test", + handle: "test", + is_internal: true, + parent_category_id: productCategoryParent.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: false, + created_at: expect.any(String), + updated_at: expect.any(String), + parent_category: expect.objectContaining({ + id: productCategoryParent.id + }), + category_children: [] + }), + }) + ) + }) + }) + describe("DELETE /admin/product-categories/:id", () => { beforeEach(async () => { await adminSeeder(dbConnection) diff --git a/packages/medusa/src/api/routes/admin/product-categories/create-product-category.ts b/packages/medusa/src/api/routes/admin/product-categories/create-product-category.ts new file mode 100644 index 0000000000000..de9860f9944ea --- /dev/null +++ b/packages/medusa/src/api/routes/admin/product-categories/create-product-category.ts @@ -0,0 +1,126 @@ +import { IsNotEmpty, IsOptional, IsString, IsBoolean } 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 + * operationId: "PostProductCategories" + * summary: "Create a Product Category" + * description: "Creates a Product Category." + * x-authenticated: true + * parameters: + * - (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/AdminPostProductCategoriesReq" + * 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.create({ + * name: 'Jeans', + * }) + * .then(({ productCategory }) => { + * console.log(productCategory.id); + * }); + * - lang: Shell + * label: cURL + * source: | + * curl --location --request POST 'https://medusa-url.com/admin/product-categories' \ + * --header 'Authorization: Bearer {api_token}' \ + * --header 'Content-Type: application/json' \ + * --data-raw '{ + * "name": "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 { validatedBody } = req as { + validatedBody: AdminPostProductCategoriesReq + } + + const productCategoryService: ProductCategoryService = req.scope.resolve( + "productCategoryService" + ) + + const manager: EntityManager = req.scope.resolve("manager") + const created = await manager.transaction(async (transactionManager) => { + return await productCategoryService + .withTransaction(transactionManager) + .create(validatedBody) + }) + + const productCategory = await productCategoryService.retrieve( + created.id, + req.retrieveConfig, + ) + + res.status(200).json({ product_category: productCategory }) +} + +/** + * @schema AdminPostProductCategoriesReq + * type: object + * required: + * - name + * properties: + * name: + * type: string + * description: The name to identify the Product Category by. + * handle: + * type: string + * description: An optional handle to be used in slugs, if none is provided we will kebab-case the title. + * 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 AdminPostProductCategoriesReq extends AdminProductCategoriesReqBase { + @IsString() + @IsNotEmpty() + name: string +} + +export class AdminPostProductCategoriesParams 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 fc8f1f4930db3..69e5960f40e75 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,6 @@ import { Router } from "express" -import middlewares, { transformQuery } from "../../../middlewares" +import middlewares, { transformQuery, transformBody } from "../../../middlewares" import { isFeatureFlagEnabled } from "../../../middlewares/feature-flag-enabled" import deleteProductCategory from "./delete-product-category" @@ -12,6 +12,11 @@ import listProductCategories, { AdminGetProductCategoriesParams, } from "./list-product-categories" +import createProductCategory, { + AdminPostProductCategoriesReq, + AdminPostProductCategoriesParams, +} from "./create-product-category" + const route = Router() export default (app) => { @@ -21,6 +26,17 @@ export default (app) => { route ) + route.post( + "/", + transformQuery(AdminPostProductCategoriesParams, { + defaultFields: defaultProductCategoryFields, + defaultRelations: defaultAdminProductCategoryRelations, + isList: false, + }), + transformBody(AdminPostProductCategoriesReq), + middlewares.wrap(createProductCategory) + ) + route.get( "/", transformQuery(AdminGetProductCategoriesParams, { @@ -48,6 +64,7 @@ export default (app) => { export * from "./get-product-category" export * from "./delete-product-category" export * from "./list-product-categories" +export * from "./create-product-category" export const defaultAdminProductCategoryRelations = [ "parent_category", @@ -60,4 +77,6 @@ export const defaultProductCategoryFields = [ "handle", "is_active", "is_internal", + "created_at", + "updated_at", ] diff --git a/packages/medusa/src/models/product-category.ts b/packages/medusa/src/models/product-category.ts index b69cd252932e4..3155f2c768b72 100644 --- a/packages/medusa/src/models/product-category.ts +++ b/packages/medusa/src/models/product-category.ts @@ -1,5 +1,6 @@ import { generateEntityId } from "../utils/generate-entity-id" import { SoftDeletableEntity } from "../interfaces/models/soft-deletable-entity" +import { kebabCase } from "lodash" import { BeforeInsert, Index, @@ -41,7 +42,7 @@ export class ProductCategory extends SoftDeletableEntity { // Typeorm also keeps track of the category's parent at all times. @Column() - parent_category_id: ProductCategory + parent_category_id: string | null @TreeChildren({ cascade: true }) category_children: ProductCategory[] @@ -49,6 +50,10 @@ export class ProductCategory extends SoftDeletableEntity { @BeforeInsert() private beforeInsert(): void { this.id = generateEntityId(this.id, "pcat") + + if (!this.handle) { + this.handle = kebabCase(this.name) + } } } @@ -60,7 +65,6 @@ export class ProductCategory extends SoftDeletableEntity { * type: object * required: * - name - * - handle * properties: * id: * type: string diff --git a/packages/medusa/src/services/__tests__/product-category.ts b/packages/medusa/src/services/__tests__/product-category.ts index 6a97426557c80..03c768efccfdf 100644 --- a/packages/medusa/src/services/__tests__/product-category.ts +++ b/packages/medusa/src/services/__tests__/product-category.ts @@ -97,6 +97,30 @@ describe("ProductCategoryService", () => { }) }) + describe("create", () => { + const productCategoryRepository = MockRepository({ + findOne: query => Promise.resolve({ id: IdMap.getId("jeans") }), + }) + + const productCategoryService = new ProductCategoryService({ + manager: MockManager, + productCategoryRepository, + }) + + beforeEach(async () => { + jest.clearAllMocks() + }) + + it("successfully creates a product category", async () => { + await productCategoryService.create({ name: "jeans" }) + + expect(productCategoryRepository.create).toHaveBeenCalledTimes(1) + expect(productCategoryRepository.create).toHaveBeenCalledWith({ + name: "jeans", + }) + }) + }) + describe("delete", () => { const productCategoryRepository = MockRepository({ findOne: query => { diff --git a/packages/medusa/src/services/product-category.ts b/packages/medusa/src/services/product-category.ts index 92d564763c759..94fe2bf26717b 100644 --- a/packages/medusa/src/services/product-category.ts +++ b/packages/medusa/src/services/product-category.ts @@ -1,10 +1,11 @@ import { isDefined, MedusaError } from "medusa-core-utils" -import { EntityManager } from "typeorm" +import { EntityManager, DeepPartial } from "typeorm" import { TransactionBaseService } from "../interfaces" 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" type InjectedDependencies = { manager: EntityManager @@ -99,6 +100,22 @@ class ProductCategoryService extends TransactionBaseService { return productCategoryTree } + /** + * Creates a product category + * @param productCategory - params used to create + * @return created product category + */ + async create( + productCategory: CreateProductCategoryInput + ): Promise { + return await this.atomicPhase_(async (manager) => { + const pcRepo = manager.getCustomRepository(this.productCategoryRepo_) + const productCategoryRecord = pcRepo.create(productCategory) + + return await pcRepo.save(productCategoryRecord) + }) + } + /** * Deletes a product category * @@ -115,7 +132,7 @@ class ProductCategoryService extends TransactionBaseService { }).catch((err) => void 0) if (!productCategory) { - return Promise.resolve() + return } if (productCategory.category_children.length > 0) { @@ -126,8 +143,6 @@ class ProductCategoryService extends TransactionBaseService { } await productCategoryRepository.delete(productCategory.id) - - return Promise.resolve() }) } } diff --git a/packages/medusa/src/types/product-category.ts b/packages/medusa/src/types/product-category.ts new file mode 100644 index 0000000000000..624ed1fea9a03 --- /dev/null +++ b/packages/medusa/src/types/product-category.ts @@ -0,0 +1,32 @@ +import { Transform } from "class-transformer" +import { IsNotEmpty, IsOptional, IsString, IsBoolean } from "class-validator" + +export type CreateProductCategoryInput = { + name: string + handle?: string + is_internal?: boolean + is_active?: boolean + parent_category_id?: string | null +} + +export class AdminProductCategoriesReqBase { + @IsOptional() + @IsString() + @IsNotEmpty() + handle?: string + + @IsBoolean() + @IsOptional() + is_internal?: boolean + + @IsBoolean() + @IsOptional() + is_active?: boolean + + @IsString() + @IsOptional() + @Transform(({ value }) => { + return value === "null" ? null : value + }) + parent_category_id?: string | null +}