From 264b6585a8f52da83254ba5044c594939b41e740 Mon Sep 17 00:00:00 2001 From: morteza-mortezai Date: Wed, 14 Jan 2026 21:00:43 +0330 Subject: [PATCH] category --- src/common/enums/message.enum.ts | 1 + .../product/controllers/product.controller.ts | 47 +++++- .../product/dto/create-category.dto.ts | 11 +- src/modules/product/dto/create-product.dto.ts | 4 +- .../product/dto/find-categories.dto.ts | 39 +++++ .../product/entities/category.entity.ts | 22 +-- src/modules/product/product.module.ts | 7 +- .../product/providers/category.service.ts | 143 ++++++++++++++++++ .../product/providers/product.service.ts | 2 +- .../repositories/category.repository.ts | 8 +- 10 files changed, 257 insertions(+), 27 deletions(-) create mode 100644 src/modules/product/dto/find-categories.dto.ts create mode 100644 src/modules/product/providers/category.service.ts diff --git a/src/common/enums/message.enum.ts b/src/common/enums/message.enum.ts index 2c0eaf3..af47706 100755 --- a/src/common/enums/message.enum.ts +++ b/src/common/enums/message.enum.ts @@ -127,6 +127,7 @@ export const enum CategoryMessage { NOT_CREATED = 'ایجاد دسته‌بندی با خطا مواجه شد', NOT_UPDATED = 'به‌روزرسانی دسته‌بندی با خطا مواجه شد', NOT_DELETED = 'حذف دسته‌بندی با خطا مواجه شد', + ALREADY_EXIST = 'وجود دارد', } export const enum UserMessage { USER_NOT_FOUND = 'کاربری با این مشخصات یافت نشد', diff --git a/src/modules/product/controllers/product.controller.ts b/src/modules/product/controllers/product.controller.ts index 8b22c53..1a0a96d 100644 --- a/src/modules/product/controllers/product.controller.ts +++ b/src/modules/product/controllers/product.controller.ts @@ -1,5 +1,5 @@ import { Controller, Get, Post, Body, Patch, Param, Delete, Query, UseGuards } from '@nestjs/common'; -import { productService } from '../providers/product.service'; +import { ProductService } from '../providers/product.service'; import { CreateproductDto } from '../dto/create-product.dto'; import { UpdateproductDto } from '../dto/update-product.dto'; import { FindproductsDto } from '../dto/find-products.dto'; @@ -17,11 +17,17 @@ import { UserId } from 'src/common/decorators'; import { AuthGuard } from 'src/modules/auth/guards/auth.guard'; import { Permission } from 'src/common/enums/permission.enum'; import { Permissions } from 'src/common/decorators/permissions.decorator'; +import { CreateCategoryDto } from '../dto/create-category.dto'; +import { CategoryService } from '../providers/category.service'; +import { FindCategoriesDto } from '../dto/find-categories.dto'; @ApiTags('products') @Controller() export class productController { - constructor(private readonly productsService: productService) { } + constructor( + private readonly productService: ProductService, + private readonly categoryService: CategoryService, + ) { } // @Get('public/products/:productId') @@ -43,7 +49,7 @@ export class productController { @ApiOperation({ summary: 'Create a new product' }) @ApiBody({ type: CreateproductDto }) create(@Body() createproductDto: CreateproductDto) { - return this.productsService.create(createproductDto); + return this.productService.create(createproductDto); } @Get('admin/products') @@ -59,7 +65,7 @@ export class productController { @ApiQuery({ name: 'categoryId', required: false, type: String }) @ApiQuery({ name: 'isActive', required: false, type: Boolean }) async findAll(@Query() dto: FindproductsDto) { - const result = await this.productsService.findAll(dto); + const result = await this.productService.findAll(dto); return result; } @@ -70,7 +76,7 @@ export class productController { @ApiOperation({ summary: 'Get a product by id' }) @ApiParam({ name: 'id', required: true }) findById(@Param('id') id: string,) { - return this.productsService.findById(id); + return this.productService.findById(id); } @Patch('admin/products/:id') @@ -81,7 +87,7 @@ export class productController { @ApiParam({ name: 'id', required: true }) @ApiBody({ type: UpdateproductDto }) update(@Param('id') id: string, @Body() updateproductDto: UpdateproductDto) { - return this.productsService.update(id, updateproductDto); + return this.productService.update(id, updateproductDto); } @Delete('admin/products/:id') @@ -91,6 +97,33 @@ export class productController { @ApiOperation({ summary: 'Delete (soft) a product' }) @ApiParam({ name: 'id', required: true }) remove(@Param('id') id: string,) { - return this.productsService.remove(id); + return this.productService.remove(id); + } + + /**CATEGORY */ + @Post('admin/product/category') + // @UseGuards(AdminAuthGuard) + @ApiBearerAuth() + @Permissions(Permission.MANAGE_productS) + @ApiOperation({ summary: 'Create a new category' }) + @ApiBody({ type: CreateCategoryDto }) + createCategory(@Body() createproductDto: CreateCategoryDto) { + return this.categoryService.create(createproductDto); + } + + @Get('admin/products/category') + // @UseGuards(AdminAuthGuard) + @ApiBearerAuth() + @Permissions(Permission.MANAGE_productS) + @ApiOperation({ summary: 'Get a paginated list of Categories' }) + @ApiQuery({ name: 'page', required: false, type: Number }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + @ApiQuery({ name: 'search', required: false, type: String }) + @ApiQuery({ name: 'orderBy', required: false, type: String }) + @ApiQuery({ name: 'order', required: false, enum: ['asc', 'desc'] }) + @ApiQuery({ name: 'isActive', required: false, type: Boolean }) + async findAllCategories(@Query() dto: FindCategoriesDto) { + const result = await this.categoryService.findAll(dto); + return result; } } diff --git a/src/modules/product/dto/create-category.dto.ts b/src/modules/product/dto/create-category.dto.ts index 8646850..6501ca3 100644 --- a/src/modules/product/dto/create-category.dto.ts +++ b/src/modules/product/dto/create-category.dto.ts @@ -1,11 +1,16 @@ import { IsString, IsOptional, IsBoolean, IsInt, Min } from 'class-validator'; -import { ApiPropertyOptional } from '@nestjs/swagger'; +import { ApiPropertyOptional,ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; export class CreateCategoryDto { @IsString() - @ApiPropertyOptional({ example: 'ایرانی' }) - title!: string; + @ApiProperty({ example: 'چاپ' }) + title: string; + + @IsOptional() + @IsInt() + @ApiPropertyOptional() + parentId?: number; @IsOptional() @IsBoolean() diff --git a/src/modules/product/dto/create-product.dto.ts b/src/modules/product/dto/create-product.dto.ts index 2346323..f26e56b 100644 --- a/src/modules/product/dto/create-product.dto.ts +++ b/src/modules/product/dto/create-product.dto.ts @@ -12,9 +12,9 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; export class CreateproductDto { @IsNotEmpty() - @IsString() + @IsInt() @ApiProperty() - categoryId: string; + categoryId: number; @IsOptional() @IsString() diff --git a/src/modules/product/dto/find-categories.dto.ts b/src/modules/product/dto/find-categories.dto.ts new file mode 100644 index 0000000..b6f37a1 --- /dev/null +++ b/src/modules/product/dto/find-categories.dto.ts @@ -0,0 +1,39 @@ +import { IsOptional, IsNumber, IsString, IsIn, IsBoolean } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; + +export class FindCategoriesDto { + @IsOptional() + @IsNumber() + @Type(() => Number) + @ApiPropertyOptional({ example: 1 }) + page?: number; + + @IsOptional() + @IsNumber() + @Type(() => Number) + @ApiPropertyOptional({ example: 10 }) + limit?: number; + + @IsOptional() + @IsString() + @ApiPropertyOptional() + search?: string; + + @IsOptional() + @IsString() + @ApiPropertyOptional({ example: 'createdAt' }) + orderBy?: string; + + @IsOptional() + @IsIn(['asc', 'desc']) + @ApiPropertyOptional({ example: 'desc' }) + order?: 'asc' | 'desc'; + + + @IsOptional() + @IsBoolean() + @Type(() => Boolean) + @ApiPropertyOptional({ example: true }) + isActive?: boolean; +} diff --git a/src/modules/product/entities/category.entity.ts b/src/modules/product/entities/category.entity.ts index 2764226..a46aa0a 100644 --- a/src/modules/product/entities/category.entity.ts +++ b/src/modules/product/entities/category.entity.ts @@ -1,23 +1,25 @@ -import { Entity, Index, Property, Collection, OneToMany, ManyToOne } from '@mikro-orm/core'; -import { Product } from './product.entity'; +import { Entity, Index, Property, Collection, OneToMany, ManyToOne, PrimaryKey } from '@mikro-orm/core'; +// import { Product } from './product.entity'; import { BaseEntity } from '../../../common/entities/base.entity'; @Entity({ tableName: 'categories' }) -@Index({ properties: ['isActive'] }) export class Category extends BaseEntity { - @ManyToOne(() => Category) + @ManyToOne(() => Category, { nullable: true }) parent?: Category | null - @Property({ primary: true }) - id: bigint + @OneToMany(() => Category, (c) => c.parent) + children?: Category[] - @Property() - title!: string; + @PrimaryKey({ type: 'bigint', autoincrement: true }) + id: number - @OneToMany(() => Product, product => product.category) - Products = new Collection(this); + @Property({ unique: true }) + title: string; + + // @OneToMany(() => Product, product => product.category) + // Products = new Collection(this); @Property({ default: true }) isActive: boolean = true; diff --git a/src/modules/product/product.module.ts b/src/modules/product/product.module.ts index dc0e156..b3eff0c 100644 --- a/src/modules/product/product.module.ts +++ b/src/modules/product/product.module.ts @@ -1,5 +1,5 @@ import { Module } from '@nestjs/common'; -import { productService } from './providers/product.service'; +import { ProductService } from './providers/product.service'; import { productController } from './controllers/product.controller'; import { ProductRepository } from './repositories/product.repository'; import { MikroOrmModule } from '@mikro-orm/nestjs'; @@ -11,6 +11,7 @@ import { AuthModule } from '../auth/auth.module'; import { JwtModule } from '@nestjs/jwt'; import { UtilsModule } from '../util/utils.module'; import { CategoryRepository } from './repositories/category.repository'; +import { CategoryService } from './providers/category.service'; @Module({ imports: [ @@ -20,7 +21,7 @@ import { CategoryRepository } from './repositories/category.repository'; UtilsModule, ], controllers: [productController,], - providers: [productService, ProductRepository,CategoryRepository], - exports: [ProductRepository,CategoryRepository], + providers: [ProductService, ProductRepository, CategoryRepository, CategoryService], + exports: [ProductRepository, CategoryRepository, CategoryService], }) export class productModule { } diff --git a/src/modules/product/providers/category.service.ts b/src/modules/product/providers/category.service.ts new file mode 100644 index 0000000..e0dfe43 --- /dev/null +++ b/src/modules/product/providers/category.service.ts @@ -0,0 +1,143 @@ +import { BadGatewayException, Injectable, NotFoundException } from '@nestjs/common'; +import { CreateproductDto } from '../dto/create-product.dto'; +import { FindproductsDto } from '../dto/find-products.dto'; +import { ProductRepository } from '../repositories/product.repository'; +import { EntityManager } from '@mikro-orm/postgresql'; +import { RequiredEntityData } from '@mikro-orm/core'; +import { Product } from '../entities/product.entity'; +import { CategoryMessage, productMessage } from 'src/common/enums/message.enum'; +import { CategoryRepository } from '../repositories/category.repository'; +import { CreateCategoryDto } from '../dto/create-category.dto'; +import { Category } from '../entities/category.entity'; +import { FindCategoriesDto } from '../dto/find-categories.dto'; + +@Injectable() +export class CategoryService { + + constructor( + private readonly productRepository: ProductRepository, + private readonly categoryRepository: CategoryRepository, + private readonly em: EntityManager, + ) { } + + async create(dto: CreateCategoryDto) { + let parent: null | Category = null + + if (dto.parentId) { + parent = await this.categoryRepository.findOne({ id: dto.parentId }) + if (!parent) { + throw new BadGatewayException('Parent category not found') + } + } + + const data: RequiredEntityData = { + title: dto.title, + isActive: dto.isActive ?? true, + order: dto.order, + avatarUrl: dto.avatarUrl, + parent + }; + + const category = this.categoryRepository.create(data) + + await this.em.persistAndFlush(category) + + return category + + } + + // async update(productId: string, createproductDto: Partial) { + // const { categoryId, ...rest } = createproductDto; + // const product = await this.productRepository.findOne({ id: productId }) + // if (!product) { + // throw new NotFoundException(productMessage.NOT_FOUND); + // } + + // if (categoryId) { + // const category = await this.categoryRepository.findOne({ id: categoryId }); + // if (!category) { + // throw new NotFoundException(CategoryMessage.NOT_FOUND); + // } + // this.productRepository.assign(product, { category }) + // } + + // this.productRepository.assign(product, rest) + // //TODO : which one od these are correct + // // this.productRepository.nativeUpdate(productId,product) + // this.em.persistAndFlush(product) + + // } + + findAll(dto: FindCategoriesDto) { + return this.categoryRepository.findAllPaginated(dto); + } + + async findById(productId: string): Promise { + const product = await this.productRepository.findOne({ id: productId }, { populate: ['category', 'attributes'] }); + + if (!product) throw new NotFoundException(productMessage.NOT_FOUND); + return product; + } + + // async update( id: string, dto: Partial): Promise { + // const { categoryId, dailyStock, ...rest } = dto; + // const product = await this.productRepository.findOne({ id, restaurant: { id: } }, { populate: ['restaurant'] }); + // if (!product) { + // throw new NotFoundException(productMessage.NOT_FOUND); + // } + + // // attach new categories if provided (adds, does not attempt to preserve/remove existing ones) + // if (categoryId) { + // const category = await this.categoryRepository.findOne({ id: categoryId, restaurant: { id: } }); + + // if (!category) { + // throw new NotFoundException(CategoryMessage.NOT_FOUND); + // } + + // this.em.assign(product, { category: category }); + // } + + // // assign other fields from DTO (excluding dailyStock handled below) + // this.em.assign(product, rest); + + // // Persist product and update/create related inventory atomically + // await this.em.transactional(async em => { + // await em.persistAndFlush(product); + + // if (typeof dailyStock !== 'undefined') { + // const inventoryRecord = await em.findOne(Inventory, { product: product }); + // if (inventoryRecord) { + // inventoryRecord.totalStock = dailyStock; + // } else { + // em.create(Inventory, { + // product: product, + // availableStock: dailyStock, + // totalStock: dailyStock, + // }); + // } + // } + + // await em.flush(); + // }); + + // // Re-load the product to ensure populated relations and stable instance + // const savedproduct = await this.productRepository.findOne({ id: product.id }, { populate: ['category', 'restaurant'] }); + + // // Invalidate cache for the restaurant if present (optional) + // // await this.invalidateRestaurantproductsCache(savedproduct?.restaurant?.slug); + + // return savedproduct ?? product; + // } + + async remove(id: string): Promise { + const product = await this.productRepository.findOne({ id }); + if (!product) { + throw new NotFoundException(productMessage.NOT_FOUND); + } + + product.deletedAt = new Date(); + return await this.em.persistAndFlush(product); + + } + +} diff --git a/src/modules/product/providers/product.service.ts b/src/modules/product/providers/product.service.ts index 2ede72c..e375418 100644 --- a/src/modules/product/providers/product.service.ts +++ b/src/modules/product/providers/product.service.ts @@ -9,7 +9,7 @@ import { CategoryMessage, productMessage } from 'src/common/enums/message.enum'; import { CategoryRepository } from '../repositories/category.repository'; @Injectable() -export class productService { +export class ProductService { constructor( private readonly productRepository: ProductRepository, diff --git a/src/modules/product/repositories/category.repository.ts b/src/modules/product/repositories/category.repository.ts index 7ab8506..f126213 100644 --- a/src/modules/product/repositories/category.repository.ts +++ b/src/modules/product/repositories/category.repository.ts @@ -25,17 +25,23 @@ export class CategoryRepository extends EntityRepository { const offset = (page - 1) * limit; - const where: FilterQuery = {}; + const where: FilterQuery = { parent: null }; if (typeof isActive === 'boolean') { where.isActive = isActive; } + if (search) { + where.$or = [ + + ] + } const [data, total] = await this.findAndCount(where, { limit, offset, orderBy: { [orderBy]: order.toLowerCase() as 'asc' | 'desc' }, + populate: ['children'] }); const totalPages = Math.ceil(total / limit);