diff --git a/src/app.module.ts b/src/app.module.ts index ff9b2e8..ba5c60c 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -15,7 +15,7 @@ import { AdminModule } from './modules/admin/admin.module'; // import { CacheService } from './modules/utils/cache.service'; import { ThrottlerModule } from '@nestjs/throttler'; import { RestaurantsModule } from './modules/restaurants/restaurants.module'; -import { FoodsModule } from './modules/foods/foods.module'; +import { FoodsModule } from './modules/foods/food.module'; @Module({ imports: [ diff --git a/src/common/enums/message.enum.ts b/src/common/enums/message.enum.ts index 21f753d..e58c3fa 100755 --- a/src/common/enums/message.enum.ts +++ b/src/common/enums/message.enum.ts @@ -67,7 +67,15 @@ export const enum AuthMessage { OLD_PASSWORD_REQUIRED = 'رمز عبور قبلی الزامی است', PASSWORD_CHANGE_FAILED = 'تغییر رمز عبور با مشکل روبرد شد', } - +export const enum FoodMessage { + NOT_FOUND = 'غذایی با این مشخصات یافت نشد', +} +export const enum CategoryMessage { + NOT_FOUND = 'دسته‌بندی مورد نظر یافت نشد', + NOT_CREATED = 'ایجاد دسته‌بندی با خطا مواجه شد', + NOT_UPDATED = 'به‌روزرسانی دسته‌بندی با خطا مواجه شد', + NOT_DELETED = 'حذف دسته‌بندی با خطا مواجه شد', +} export const enum UserMessage { USER_NOT_FOUND = 'کاربری با این مشخصات یافت نشد', Rest_NOT_FOUND = 'رستورانی با این مشخصات یافت نشد', diff --git a/src/modules/foods/controllers/category.controller.ts b/src/modules/foods/controllers/category.controller.ts new file mode 100644 index 0000000..d5cb952 --- /dev/null +++ b/src/modules/foods/controllers/category.controller.ts @@ -0,0 +1,70 @@ +import { Controller, Get, Post, Body, Patch, Param, Delete, Query } from '@nestjs/common'; +import { FoodService } from '../providers/food.service'; +import { CreateFoodDto } from '../dto/create-food.dto'; +import { UpdateFoodDto } from '../dto/update-food.dto'; +import { FindFoodsDto } from '../dto/find-foods.dto'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiCreatedResponse, + ApiOkResponse, + ApiNotFoundResponse, + ApiQuery, + ApiBody, + ApiParam, +} from '@nestjs/swagger'; + +@ApiTags('foods') +@Controller('foods') +export class CategoryController { + constructor(private readonly foodService: FoodService) {} + + @Post() + @ApiOperation({ summary: 'Create a new food' }) + @ApiCreatedResponse({ description: 'The food has been successfully created.', type: CreateFoodDto }) + @ApiBody({ type: CreateFoodDto }) + create(@Body() createFoodDto: CreateFoodDto) { + return this.foodService.create(createFoodDto); + } + + @Get() + @ApiOperation({ summary: 'Get a paginated list of foods' }) + @ApiOkResponse({ description: 'Paginated list of foods' }) + @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: 'categoryId', required: false, type: String }) + @ApiQuery({ name: 'isActive', required: false, type: Boolean }) + findAll(@Query() dto: FindFoodsDto) { + return this.foodService.findAll(dto); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a food by id' }) + @ApiParam({ name: 'id', required: true }) + @ApiOkResponse({ description: 'The food', type: CreateFoodDto }) + @ApiNotFoundResponse({ description: 'Food not found' }) + findOne(@Param('id') id: string) { + return this.foodService.findOne(id); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update a food' }) + @ApiParam({ name: 'id', required: true }) + @ApiBody({ type: UpdateFoodDto }) + @ApiOkResponse({ description: 'The updated food', type: UpdateFoodDto }) + update(@Param('id') id: string, @Body() updateFoodDto: UpdateFoodDto) { + return this.foodService.update(id, updateFoodDto); + } + + @Delete(':id') + @ApiOperation({ summary: 'Delete (soft) a food' }) + @ApiParam({ name: 'id', required: true }) + @ApiResponse({ status: 200, description: 'Food deleted' }) + remove(@Param('id') id: string) { + return this.foodService.remove(id); + } +} diff --git a/src/modules/foods/controllers/categorys.controller.ts b/src/modules/foods/controllers/categorys.controller.ts new file mode 100644 index 0000000..d5c030c --- /dev/null +++ b/src/modules/foods/controllers/categorys.controller.ts @@ -0,0 +1,65 @@ +import { Controller, Get, Post, Body, Patch, Param, Delete, Query } from '@nestjs/common'; +import { CategoryService } from '../providers/category.service'; +import { CreateCategoryDto } from '../dto/create-category.dto'; +import { UpdateCategoryDto } from '../dto/update-category.dto'; +import { + ApiTags, + ApiOperation, + ApiCreatedResponse, + ApiOkResponse, + ApiNotFoundResponse, + ApiParam, + ApiBody, + ApiQuery, +} from '@nestjs/swagger'; + +@ApiTags('categories') +@Controller('categories') +export class CategorysController { + constructor(private readonly categoryService: CategoryService) {} + + @Post() + @ApiOperation({ summary: 'Create category' }) + @ApiCreatedResponse({ description: 'Category created', type: CreateCategoryDto }) + @ApiBody({ type: CreateCategoryDto }) + create(@Body() dto: CreateCategoryDto) { + return this.categoryService.create(dto); + } + + @Get() + @ApiOperation({ summary: 'List categories' }) + @ApiOkResponse({ description: 'List of categories' }) + @ApiQuery({ name: 'restId', required: false, type: String }) + @ApiQuery({ name: 'isActive', required: false, type: Boolean }) + findAll(@Query('restId') restId?: string, @Query('isActive') isActive?: string) { + // convert isActive from query string to boolean when provided + const isActiveBool: boolean | undefined = typeof isActive !== 'undefined' ? isActive === 'true' : undefined; + return this.categoryService.findAll({ restId, isActive: isActiveBool }); + } + + @Get(':id') + @ApiOperation({ summary: 'Get category' }) + @ApiParam({ name: 'id' }) + @ApiOkResponse({ description: 'Category detail', type: CreateCategoryDto }) + @ApiNotFoundResponse({ description: 'Category not found' }) + findOne(@Param('id') id: string) { + return this.categoryService.findOne(id); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update category' }) + @ApiParam({ name: 'id' }) + @ApiBody({ type: UpdateCategoryDto }) + @ApiOkResponse({ description: 'Updated category', type: UpdateCategoryDto }) + update(@Param('id') id: string, @Body() dto: UpdateCategoryDto) { + return this.categoryService.update(id, dto); + } + + @Delete(':id') + @ApiOperation({ summary: 'Delete category' }) + @ApiParam({ name: 'id' }) + @ApiOkResponse({ description: 'Deleted' }) + remove(@Param('id') id: string) { + return this.categoryService.remove(id); + } +} diff --git a/src/modules/foods/controllers/food.controller.ts b/src/modules/foods/controllers/food.controller.ts new file mode 100644 index 0000000..633a4be --- /dev/null +++ b/src/modules/foods/controllers/food.controller.ts @@ -0,0 +1,70 @@ +import { Controller, Get, Post, Body, Patch, Param, Delete, Query } from '@nestjs/common'; +import { FoodService } from '../providers/food.service'; +import { CreateFoodDto } from '../dto/create-food.dto'; +import { UpdateFoodDto } from '../dto/update-food.dto'; +import { FindFoodsDto } from '../dto/find-foods.dto'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiCreatedResponse, + ApiOkResponse, + ApiNotFoundResponse, + ApiQuery, + ApiBody, + ApiParam, +} from '@nestjs/swagger'; + +@ApiTags('foods') +@Controller('foods') +export class FoodController { + constructor(private readonly foodsService: FoodService) {} + + @Post() + @ApiOperation({ summary: 'Create a new food' }) + @ApiCreatedResponse({ description: 'The food has been successfully created.', type: CreateFoodDto }) + @ApiBody({ type: CreateFoodDto }) + create(@Body() createFoodDto: CreateFoodDto) { + return this.foodsService.create(createFoodDto); + } + + @Get() + @ApiOperation({ summary: 'Get a paginated list of foods' }) + @ApiOkResponse({ description: 'Paginated list of foods' }) + @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: 'categoryId', required: false, type: String }) + @ApiQuery({ name: 'isActive', required: false, type: Boolean }) + findAll(@Query() dto: FindFoodsDto) { + return this.foodsService.findAll(dto); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a food by id' }) + @ApiParam({ name: 'id', required: true }) + @ApiOkResponse({ description: 'The food', type: CreateFoodDto }) + @ApiNotFoundResponse({ description: 'Food not found' }) + findOne(@Param('id') id: string) { + return this.foodsService.findOne(id); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update a food' }) + @ApiParam({ name: 'id', required: true }) + @ApiBody({ type: UpdateFoodDto }) + @ApiOkResponse({ description: 'The updated food', type: UpdateFoodDto }) + update(@Param('id') id: string, @Body() updateFoodDto: UpdateFoodDto) { + return this.foodsService.update(id, updateFoodDto); + } + + @Delete(':id') + @ApiOperation({ summary: 'Delete (soft) a food' }) + @ApiParam({ name: 'id', required: true }) + @ApiResponse({ status: 200, description: 'Food deleted' }) + remove(@Param('id') id: string) { + return this.foodsService.remove(id); + } +} diff --git a/src/modules/foods/dto/create-category.dto.ts b/src/modules/foods/dto/create-category.dto.ts new file mode 100644 index 0000000..669595c --- /dev/null +++ b/src/modules/foods/dto/create-category.dto.ts @@ -0,0 +1,26 @@ +import { IsString, IsOptional, IsBoolean } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; + +export class CreateCategoryDto { + @IsOptional() + @IsString() + @ApiPropertyOptional({ example: 'Beverages' }) + title?: string; + + @IsOptional() + @IsBoolean() + @Type(() => Boolean) + @ApiPropertyOptional({ example: true }) + isActive?: boolean; + + @IsOptional() + @IsString() + @ApiPropertyOptional({ example: 'restaurant-ulid' }) + restId?: string; + + @IsOptional() + @IsString() + @ApiPropertyOptional({ example: 'https://cdn.example.com/avatar.png' }) + avatarUrl?: string; +} diff --git a/src/modules/foods/dto/create-food.dto.ts b/src/modules/foods/dto/create-food.dto.ts index a27ccf5..31acb8c 100644 --- a/src/modules/foods/dto/create-food.dto.ts +++ b/src/modules/foods/dto/create-food.dto.ts @@ -1,87 +1,132 @@ import { IsBoolean, IsOptional, IsString, IsNumber, IsArray } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; export class CreateFoodDto { @IsOptional() @IsBoolean() + @ApiPropertyOptional({ example: false }) + @Type(() => Boolean) breakfast?: boolean; @IsOptional() @IsBoolean() + @ApiPropertyOptional({ example: false }) + @Type(() => Boolean) tue?: boolean; @IsOptional() @IsBoolean() + @ApiPropertyOptional({ example: false }) + @Type(() => Boolean) wed?: boolean; @IsOptional() @IsBoolean() + @ApiPropertyOptional({ example: false }) + @Type(() => Boolean) thu?: boolean; @IsOptional() @IsBoolean() + @ApiPropertyOptional({ example: false }) + @Type(() => Boolean) fri?: boolean; + @IsOptional() @IsString() - titleFa?: string; + @ApiPropertyOptional({ example: 'قرمه سبزی' }) + title?: string; + @IsOptional() @IsString() - titleEn?: string; - @IsOptional() - @IsString() - contentFa?: string; - @IsOptional() - @IsString() - contentEn?: string; + @ApiPropertyOptional({ example: 'توضیحات غذا' }) + content?: string; + @IsOptional() @IsNumber() + @Type(() => Number) + @ApiPropertyOptional({ example: 120000 }) price?: number; @IsOptional() @IsNumber() + @Type(() => Number) + @ApiPropertyOptional({ example: 10 }) points?: number; @IsOptional() @IsNumber() + @Type(() => Number) + @ApiPropertyOptional({ example: 15 }) prepareTime?: number; @IsOptional() @IsBoolean() + @ApiPropertyOptional({ example: true }) + @Type(() => Boolean) sat?: boolean; @IsOptional() @IsBoolean() + @ApiPropertyOptional({ example: true }) + @Type(() => Boolean) sun?: boolean; @IsOptional() @IsBoolean() + @ApiPropertyOptional({ example: true }) + @Type(() => Boolean) mon?: boolean; @IsOptional() @IsBoolean() + @ApiPropertyOptional({ example: false }) + @Type(() => Boolean) noon?: boolean; @IsOptional() @IsBoolean() + @ApiPropertyOptional({ example: true }) + @Type(() => Boolean) dinner?: boolean; @IsOptional() @IsBoolean() + @ApiPropertyOptional({ example: false }) + @Type(() => Boolean) isPickup?: boolean; @IsOptional() @IsNumber() + @Type(() => Number) + @ApiPropertyOptional({ example: 10 }) stock?: number; @IsOptional() @IsNumber() + @Type(() => Number) + @ApiPropertyOptional({ example: 10 }) stockDefault?: number; @IsOptional() @IsBoolean() + @ApiPropertyOptional({ example: true }) + @Type(() => Boolean) isActive?: boolean; @IsOptional() @IsArray() @IsString({ each: true }) + @ApiPropertyOptional({ type: [String] }) images?: string[]; @IsOptional() @IsBoolean() + @ApiPropertyOptional({ example: false }) + @Type(() => Boolean) inPlaceServe?: boolean; @IsOptional() @IsBoolean() + @ApiPropertyOptional({ example: false }) + @Type(() => Boolean) pickupServe?: boolean; @IsOptional() @IsNumber() + @Type(() => Number) + @ApiPropertyOptional({ example: 4.5 }) rate?: number; @IsOptional() @IsNumber() + @Type(() => Number) + @ApiPropertyOptional({ example: 0 }) discount?: number; @IsOptional() @IsArray() @IsString({ each: true }) + @ApiPropertyOptional({ type: [String] }) categoryIds?: string[]; } diff --git a/src/modules/foods/dto/find-foods.dto.ts b/src/modules/foods/dto/find-foods.dto.ts new file mode 100644 index 0000000..31e339f --- /dev/null +++ b/src/modules/foods/dto/find-foods.dto.ts @@ -0,0 +1,43 @@ +import { IsOptional, IsNumber, IsString, IsIn, IsBoolean } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; + +export class FindFoodsDto { + @IsOptional() + @IsNumber() + @Type(() => Number) + @ApiPropertyOptional({ example: 1 }) + page?: number; + + @IsOptional() + @IsNumber() + @Type(() => Number) + @ApiPropertyOptional({ example: 10 }) + limit?: number; + + @IsOptional() + @IsString() + @ApiPropertyOptional({ example: 'cheese' }) + search?: string; + + @IsOptional() + @IsString() + @ApiPropertyOptional({ example: 'createdAt' }) + orderBy?: string; + + @IsOptional() + @IsIn(['asc', 'desc']) + @ApiPropertyOptional({ example: 'desc' }) + order?: 'asc' | 'desc'; + + @IsOptional() + @IsString() + @ApiPropertyOptional({ example: 'category-ulid' }) + categoryId?: string; + + @IsOptional() + @IsBoolean() + @Type(() => Boolean) + @ApiPropertyOptional({ example: true }) + isActive?: boolean; +} diff --git a/src/modules/foods/dto/update-category.dto.ts b/src/modules/foods/dto/update-category.dto.ts new file mode 100644 index 0000000..d713b9b --- /dev/null +++ b/src/modules/foods/dto/update-category.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateCategoryDto } from './create-category.dto'; + +export class UpdateCategoryDto extends PartialType(CreateCategoryDto) {} diff --git a/src/modules/foods/entities/category.ts b/src/modules/foods/entities/category.entity.ts similarity index 70% rename from src/modules/foods/entities/category.ts rename to src/modules/foods/entities/category.entity.ts index db756fb..44e777b 100644 --- a/src/modules/foods/entities/category.ts +++ b/src/modules/foods/entities/category.entity.ts @@ -9,4 +9,13 @@ export class Category extends BaseEntity { @ManyToMany(() => Foods, food => food.categories) foods = new Collection(this); + + @Property({ default: true }) + isActive: boolean = true; + + @Property({ nullable: true }) + restId?: string; + + @Property({ nullable: true }) + avatarUrl?: string; } diff --git a/src/modules/foods/entities/food.entity.ts b/src/modules/foods/entities/food.entity.ts index 406e627..acb30c4 100644 --- a/src/modules/foods/entities/food.entity.ts +++ b/src/modules/foods/entities/food.entity.ts @@ -1,5 +1,5 @@ import { Collection, Entity, ManyToMany, Property } from '@mikro-orm/core'; -import { Category } from './category'; +import { Category } from './category.entity'; import { BaseEntity } from 'src/common/entities/base.entity'; @Entity({ tableName: 'foods' }) @@ -8,16 +8,10 @@ export class Foods extends BaseEntity { categories = new Collection(this); @Property({ nullable: true }) - titleFa?: string; - - @Property({ nullable: true }) - titleEn?: string; + title?: string; @Property({ type: 'text', nullable: true }) - contentFa?: string; - - @Property({ type: 'text', nullable: true }) - contentEn?: string; + content?: string; @Property({ type: 'decimal', precision: 10, scale: 2, nullable: true }) price?: number; @@ -25,6 +19,9 @@ export class Foods extends BaseEntity { @Property({ type: 'int', nullable: true }) points?: number; + @Property({ type: 'int', nullable: true }) + order?: number; + @Property({ type: 'int', nullable: true }) prepareTime?: number; // in minutes diff --git a/src/modules/foods/food.module.ts b/src/modules/foods/food.module.ts new file mode 100644 index 0000000..0c4d255 --- /dev/null +++ b/src/modules/foods/food.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { FoodService } from './providers/food.service'; +import { FoodController } from './controllers/food.controller'; +import { CategorysController } from './controllers/categorys.controller'; +import { CategoryService } from './providers/category.service'; + +@Module({ + controllers: [FoodController, CategorysController], + providers: [FoodService, CategoryService], +}) +export class FoodModule {} diff --git a/src/modules/foods/foods.controller.ts b/src/modules/foods/foods.controller.ts deleted file mode 100644 index 81bfd4a..0000000 --- a/src/modules/foods/foods.controller.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common'; -import { FoodsService } from './providers/foods.service'; -import { CreateFoodDto } from './dto/create-food.dto'; -import { UpdateFoodDto } from './dto/update-food.dto'; - -@Controller('foods') -export class FoodsController { - constructor(private readonly foodsService: FoodsService) {} - - @Post() - create(@Body() createFoodDto: CreateFoodDto) { - return this.foodsService.create(createFoodDto); - } - - @Get() - findAll() { - return this.foodsService.findAll(); - } - - @Get(':id') - findOne(@Param('id') id: string) { - return this.foodsService.findOne(+id); - } - - @Patch(':id') - update(@Param('id') id: string, @Body() updateFoodDto: UpdateFoodDto) { - return this.foodsService.update(+id, updateFoodDto); - } - - @Delete(':id') - remove(@Param('id') id: string) { - return this.foodsService.remove(+id); - } -} diff --git a/src/modules/foods/foods.module.ts b/src/modules/foods/foods.module.ts deleted file mode 100644 index 2710730..0000000 --- a/src/modules/foods/foods.module.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Module } from '@nestjs/common'; -import { FoodsService } from './providers/foods.service'; -import { FoodsController } from './foods.controller'; - -@Module({ - controllers: [FoodsController], - providers: [FoodsService], -}) -export class FoodsModule {} diff --git a/src/modules/foods/providers/category.service.ts b/src/modules/foods/providers/category.service.ts new file mode 100644 index 0000000..3eb308a --- /dev/null +++ b/src/modules/foods/providers/category.service.ts @@ -0,0 +1,57 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { CreateCategoryDto } from '../dto/create-category.dto'; +import { UpdateCategoryDto } from '../dto/update-category.dto'; +import { CategoryRepository } from '../repositories/category.repository'; +import { EntityManager } from '@mikro-orm/postgresql'; +import { RequiredEntityData, FilterQuery } from '@mikro-orm/core'; +import { Category } from '../entities/category.entity'; +import { CategoryMessage } from 'src/common/enums/message.enum'; + +@Injectable() +export class CategoryService { + constructor( + private readonly categoryRepository: CategoryRepository, + private readonly em: EntityManager, + ) {} + + async create(dto: CreateCategoryDto): Promise { + const data = { + title: dto.title, + isActive: dto.isActive ?? true, + restId: dto.restId, + avatarUrl: dto.avatarUrl, + } as RequiredEntityData; + + const category = this.categoryRepository.create(data); + await this.em.persistAndFlush(category); + return category; + } + + async findAll(opts?: { restId?: string; isActive?: boolean }): Promise { + const where: FilterQuery = {}; + if (opts?.restId) where.restId = opts.restId; + if (opts && typeof opts.isActive === 'boolean') where.isActive = opts.isActive; + return this.categoryRepository.find(where, { populate: ['foods'] }); + } + + async findOne(id: string): Promise { + const cat = await this.categoryRepository.findOne({ id }, { populate: ['foods'] }); + if (!cat) throw new NotFoundException(CategoryMessage.NOT_FOUND); + return cat; + } + + async update(id: string, dto: UpdateCategoryDto): Promise { + const cat = await this.categoryRepository.findOne({ id }); + if (!cat) throw new NotFoundException(CategoryMessage.NOT_FOUND); + this.em.assign(cat, dto as Partial); + await this.em.persistAndFlush(cat); + return cat; + } + + async remove(id: string): Promise { + const cat = await this.categoryRepository.findOne({ id }); + if (!cat) throw new NotFoundException(CategoryMessage.NOT_FOUND); + cat.deletedAt = new Date(); + await this.em.persistAndFlush(cat); + } +} diff --git a/src/modules/foods/providers/food.service.ts b/src/modules/foods/providers/food.service.ts new file mode 100644 index 0000000..8e24935 --- /dev/null +++ b/src/modules/foods/providers/food.service.ts @@ -0,0 +1,103 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { CreateFoodDto } from '../dto/create-food.dto'; +import { FindFoodsDto } from '../dto/find-foods.dto'; +import { FoodRepository } from '../repositories/food.repository'; +import { CategoryRepository } from '../repositories/category.repository'; +import { EntityManager } from '@mikro-orm/postgresql'; +import { RequiredEntityData } from '@mikro-orm/core'; +import { Foods } from '../entities/food.entity'; +import { FoodMessage } from 'src/common/enums/message.enum'; + +@Injectable() +export class FoodService { + constructor( + private readonly foodRepository: FoodRepository, + private readonly categoryRepository: CategoryRepository, + private readonly em: EntityManager, + ) {} + + async create(createFoodDto: CreateFoodDto): Promise { + const { categoryIds, ...rest } = createFoodDto; + + // prepare data with defaults for required fields so repository.create typing is satisfied + const data: RequiredEntityData = { + // boolean/day flags + sat: rest.sat ?? false, + sun: rest.sun ?? false, + mon: rest.mon ?? false, + breakfast: rest.breakfast ?? false, + noon: rest.noon ?? false, + dinner: rest.dinner ?? false, + // pickup/stock + isPickup: rest.isPickup ?? false, + stock: rest.stock ?? 0, + stockDefault: rest.stockDefault ?? 0, + isActive: rest.isActive ?? true, + inPlaceServe: rest.inPlaceServe ?? false, + pickupServe: rest.pickupServe ?? false, + rate: rest.rate ?? 0, + discount: rest.discount ?? 0, + // map single-title/content DTO to localized fields + titleFa: rest.title, + contentFa: rest.content, + // numeric/array fields + price: rest.price ?? 0, + points: rest.points ?? 0, + prepareTime: rest.prepareTime ?? 0, + images: rest.images ?? undefined, + } as RequiredEntityData; + + const food = this.foodRepository.create(data); + + // attach categories if provided + if (categoryIds && categoryIds.length) { + const categories = await this.categoryRepository.find({ id: { $in: categoryIds } }); + categories.forEach(cat => food.categories.add(cat)); + } + + await this.em.persistAndFlush(food); + return food; + } + + findAll(dto: FindFoodsDto) { + return this.foodRepository.findAllPaginated(dto); + } + + findOne(id: string) { + return this.foodRepository.findOne({ id }, { populate: ['categories'] }); + } + + async update(id: string, dto: Partial): Promise { + const food = await this.foodRepository.findOne({ id }); + + if (!food) { + throw new NotFoundException(FoodMessage.NOT_FOUND); + } + + const { categoryIds, ...rest } = dto; + + // assign scalar fields + this.em.assign(food, rest as Partial); + + // attach new categories if provided (adds, does not attempt to preserve/remove existing ones) + if (Array.isArray(categoryIds) && categoryIds.length) { + const categories = await this.categoryRepository.find({ id: { $in: categoryIds } }); + categories.forEach(cat => { + // Collection.add will ignore duplicates + food.categories.add(cat); + }); + } + + await this.em.persistAndFlush(food); + return food; + } + + async remove(id: string) { + const food = await this.foodRepository.findOne({ id }); + if (!food) { + throw new NotFoundException(FoodMessage.NOT_FOUND); + } + food.deletedAt = new Date(); + await this.em.persistAndFlush(food); + } +} diff --git a/src/modules/foods/providers/foods.service.ts b/src/modules/foods/providers/foods.service.ts deleted file mode 100644 index ce93af8..0000000 --- a/src/modules/foods/providers/foods.service.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { CreateFoodDto } from '../dto/create-food.dto'; -import { FoodRepository } from '../repositories/food.repository'; -import { CategoryRepository } from '../repositories/category.repository'; -import { EntityManager } from '@mikro-orm/postgresql'; -import { Foods } from '../entities/food.entity'; - -@Injectable() -export class FoodsService { - constructor( - private readonly foodRepository: FoodRepository, - private readonly categoryRepository: CategoryRepository, - private readonly em: EntityManager, - ) {} - - async create(createFoodDto: CreateFoodDto): Promise { - const { categoryIds, ...rest } = createFoodDto; - - // create an empty food entity and assign dto properties to avoid strict typing issues - const food = new Foods(); - this.em.assign(food, rest as Partial); - - // attach categories if provided - if (categoryIds && categoryIds.length) { - const categories = await this.categoryRepository.find({ id: { $in: categoryIds } }); - categories.forEach(cat => food.categories.add(cat)); - } - - await this.em.persistAndFlush(food); - return food; - } - - findAll() { - return `This action returns all foods`; - } - - findOne(id: number) { - return `This action returns a #${id} food`; - } - - update(id: number) { - return `This action updates a #${id} food`; - } - - remove(id: number) { - return `This action removes a #${id} food`; - } -} diff --git a/src/modules/foods/repositories/category.repository.ts b/src/modules/foods/repositories/category.repository.ts index 07b7d03..b3028b3 100644 --- a/src/modules/foods/repositories/category.repository.ts +++ b/src/modules/foods/repositories/category.repository.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { EntityManager, EntityRepository } from '@mikro-orm/postgresql'; -import { Category } from '../entities/category'; +import { Category } from '../entities/category.entity'; @Injectable() export class CategoryRepository extends EntityRepository { diff --git a/src/modules/foods/repositories/food.repository.ts b/src/modules/foods/repositories/food.repository.ts index 2477e6b..dc0458d 100644 --- a/src/modules/foods/repositories/food.repository.ts +++ b/src/modules/foods/repositories/food.repository.ts @@ -1,10 +1,66 @@ import { Injectable } from '@nestjs/common'; import { EntityManager, EntityRepository } from '@mikro-orm/postgresql'; +import { FilterQuery } from '@mikro-orm/core'; import { Foods } from '../entities/food.entity'; +import { PaginatedResult } from 'src/common/interfaces/pagination.interface'; + +type FindFoodsOpts = { + page?: number; + limit?: number; + search?: string; + orderBy?: string; + order?: 'asc' | 'desc'; + categoryId?: string; + isActive?: boolean; +}; @Injectable() export class FoodRepository extends EntityRepository { constructor(readonly em: EntityManager) { super(em, Foods); } + /** + * Find foods with pagination and optional filters. + * Supports: search (title/content), categoryId, isActive, ordering. + */ + async findAllPaginated(opts: FindFoodsOpts = {}): Promise> { + const { page = 1, limit = 10, search, orderBy = 'createdAt', order = 'desc', categoryId, isActive } = opts; + + const offset = (page - 1) * limit; + + const where: FilterQuery = {}; + + if (typeof isActive === 'boolean') { + where.isActive = isActive; + } + + if (search) { + const pattern = `%${search}%`; + where.$or = [{ title: { $ilike: pattern } }, { content: { $ilike: pattern } }]; + } + + if (categoryId) { + // filter by related categories (typed via FilterQuery) + Object.assign(where, { categories: { id: categoryId } } as unknown as FilterQuery); + } + + const [data, total] = await this.findAndCount(where, { + limit, + offset, + orderBy: { [orderBy]: order.toLowerCase() as 'asc' | 'desc' }, + populate: ['categories'], + }); + + const totalPages = Math.ceil(total / limit); + + return { + data, + meta: { + total, + page, + limit, + totalPages, + }, + }; + } } diff --git a/src/modules/users/dto/update-user.dto.ts b/src/modules/users/dto/update-user.dto.ts index 7d959c8..56d8aa8 100644 --- a/src/modules/users/dto/update-user.dto.ts +++ b/src/modules/users/dto/update-user.dto.ts @@ -1,53 +1,55 @@ -import { IsString, IsNotEmpty, IsBoolean, IsNumber, MinLength } from 'class-validator'; -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsString, IsOptional, IsBoolean, IsNumber } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +/** + * DTO for updating user profile. + * Mirrors fields defined on the User entity and keeps everything optional + * to allow partial updates. + */ export class UpdateUserDto { - @ApiProperty({ example: ' ', description: "User's first name" }) - @IsNotEmpty() + @ApiPropertyOptional({ description: "User's first name", example: 'John' }) + @IsOptional() @IsString() - firstName: string; + firstName?: string; - @ApiProperty({ example: ' ', description: "User's last name" }) - @IsNotEmpty() + @ApiPropertyOptional({ description: "User's last name", example: 'Doe' }) + @IsOptional() @IsString() - lastName: string; + lastName?: string; - @ApiProperty({ example: ' ', description: "User's father's name" }) - @IsNotEmpty() + @ApiPropertyOptional({ description: "User's birth date (ISO)", example: '1990-01-01' }) + @IsOptional() + // keep as string here, caller should send ISO date; service will assign to Date @IsString() - fatherName: string; + birthDate?: string; - // --- Personal Details --- + @ApiPropertyOptional({ description: "User's marriage date (ISO)", example: '2015-06-01' }) + @IsOptional() + @IsString() + marriageDate?: string; - @ApiPropertyOptional({ example: true, description: 'Gender: true for male, false for female' }) - @IsNotEmpty() + @ApiPropertyOptional({ description: "Referrer's identifier (optional)", example: 'abc123' }) + @IsOptional() + @IsString() + referrer?: string; + + @ApiPropertyOptional({ description: 'Is the user active?', example: true }) + @IsOptional() @IsBoolean() - isMale: boolean; + isActive?: boolean; - @ApiPropertyOptional({ example: '1234567890', description: 'Unique national code' }) - @IsNotEmpty() - @MinLength(10) - nationalCode: string; + @ApiPropertyOptional({ description: 'Gender flag (boolean)', example: true }) + @IsOptional() + @IsBoolean() + gender?: boolean; - @ApiPropertyOptional({ example: '1001', description: 'Employee personal code' }) - @IsNotEmpty() - @IsString() - personalCode: string; - - // --- Employment Details --- - - @ApiPropertyOptional({ example: 'قراردادی', description: 'Type of hire (e.g., permanent, temporary)' }) - @IsNotEmpty() - @IsString() - hireType: string; - - @ApiPropertyOptional({ example: 'اراک', description: 'Primary work location' }) - @IsNotEmpty() - @IsString() - workLocation: string; - - @ApiPropertyOptional({ example: 5, description: 'Years of work experience' }) - @IsNotEmpty() + @ApiPropertyOptional({ description: 'Wallet balance (integer)', example: 0 }) + @IsOptional() @IsNumber() - workExperienceYear: number; + wallet?: number; + + @ApiPropertyOptional({ description: 'Reward points (integer)', example: 0 }) + @IsOptional() + @IsNumber() + points?: number; } diff --git a/src/modules/users/entities/user.entity.ts b/src/modules/users/entities/user.entity.ts index 84fb9dc..868c7a1 100644 --- a/src/modules/users/entities/user.entity.ts +++ b/src/modules/users/entities/user.entity.ts @@ -4,8 +4,8 @@ import { BaseEntity } from '../../../common/entities/base.entity'; @Entity({ tableName: 'users' }) export class User extends BaseEntity { - @Property({ nullable: true }) - firstName?: string; + @Property() + firstName!: string; @Property({ nullable: true }) lastName?: string; @@ -13,8 +13,26 @@ export class User extends BaseEntity { @Property({ unique: true }) phone!: string; + @Property({ default: null, type: 'date' }) + birthDate!: Date; + + @Property({ default: null, type: 'date' }) + marriageDate!: Date; + + @Property({ nullable: true }) + referrer?: string; + @Property({ default: true }) - isActive?: boolean; + isActive?: boolean = true; + + @Property({ default: true }) + gender?: boolean; + + @Property({ default: 0, type: 'int' }) + wallet: number = 0; + + @Property({ default: 0, type: 'int' }) + points: number = 0; @OneToMany(() => RefreshToken, token => token.user) refreshTokens = new Collection(this); diff --git a/src/modules/users/user.controller.ts b/src/modules/users/user.controller.ts index 8cd41c2..0ff54b4 100644 --- a/src/modules/users/user.controller.ts +++ b/src/modules/users/user.controller.ts @@ -1,8 +1,8 @@ -import { Controller, Get, Req, UseGuards } from '@nestjs/common'; +import { Controller, Get, Req, UseGuards, Patch, Body, ValidationPipe } from '@nestjs/common'; import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; import { AuthGuard, type AuthRequest } from 'src/modules/auth/guards/auth.guard'; import { UserService } from './user.service'; -// import { UpdateUserDto } from './dto/update-user.dto'; +import { UpdateUserDto } from './dto/update-user.dto'; @ApiTags('User') @Controller('user') @@ -24,22 +24,23 @@ export class UserController { restId, }; } - - // 2. Update User Specification (PATCH /user/spec) - // @UseGuards(AuthGuard) - // @ApiBearerAuth() - // @ApiOperation({ summary: 'Update the user specification' }) - // @ApiBody({ type: UpdateUserDto }) - // @Patch('/') - // async updateUserSpec(@Req() req: AuthRequest, @Body() dto: UpdateUserDto) { - // const userId = req.user.sub; - // const user = await this.userService.updateUser(userId, dto); - // return { - // message: `PATCH request: Updating specification for user ${userId} `, - // userId, - // user, - // }; - // } + // 2. Update User Specification (PATCH /user) + @UseGuards(AuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Update the authenticated user profile' }) + @Patch('/') + async updateUser( + @Req() req: AuthRequest, + @Body(new ValidationPipe({ transform: true, whitelist: true })) dto: UpdateUserDto, + ) { + const userId = req.userId; + const user = await this.userService.updateUser(userId, dto); + return { + message: `PATCH request: Updating profile for user ${userId}`, + userId, + user, + }; + } // @UseGuards(AdminAuthGuard) // @ApiBearerAuth() diff --git a/src/modules/users/user.service.ts b/src/modules/users/user.service.ts index 671b04d..dff64fd 100644 --- a/src/modules/users/user.service.ts +++ b/src/modules/users/user.service.ts @@ -1,8 +1,8 @@ -import { Injectable } from '@nestjs/common'; -import { FilterQuery } from '@mikro-orm/core'; +import { Injectable, NotFoundException } from '@nestjs/common'; +import { FilterQuery, RequiredEntityData } from '@mikro-orm/core'; import { User } from './entities/user.entity'; import { EntityManager } from '@mikro-orm/postgresql'; -// import { UpdateUserDto } from './dto/update-user.dto'; +import { UpdateUserDto } from './dto/update-user.dto'; import { FindUsersDto } from './dto/find-user.dto'; import { PaginatedResult } from 'src/common/interfaces/pagination.interface'; import { UserRepository } from './repositories/user.repository'; @@ -18,9 +18,20 @@ export class UserService { let user = await this.userRepository.findOne({ phone }); if (!user) { - user = this.userRepository.create({ + // firstName is required on the entity; use phone as a sensible default + const _raw = { phone, - }); + firstName: phone, + // keep dates undefined (no value) — cast through unknown to satisfy TS typing + birthDate: undefined, + marriageDate: undefined, + wallet: 0, + points: 0, + }; + + const createData = _raw as unknown as RequiredEntityData; + + user = this.userRepository.create(createData); await this.em.persistAndFlush(user); } @@ -48,11 +59,43 @@ export class UserService { } async create(phone: string): Promise { - const user = this.userRepository.create({ phone }); + const _raw = { + phone, + firstName: phone, + birthDate: undefined, + marriageDate: undefined, + wallet: 0, + points: 0, + }; + + const createData = _raw as unknown as RequiredEntityData; + + const user = this.userRepository.create(createData); await this.em.persistAndFlush(user); return user; } + async updateUser(userId: string, dto: UpdateUserDto): Promise { + const user = await this.userRepository.findOne({ id: userId }); + if (!user) { + throw new NotFoundException(`User with ID ${userId} not found.`); + } + + // Normalize date strings into Date objects if provided + const assignData: Partial = { ...dto } as Partial; + if (dto.birthDate) { + assignData.birthDate = new Date(dto.birthDate); + } + if (dto.marriageDate) { + assignData.marriageDate = new Date(dto.marriageDate); + } + + this.em.assign(user, assignData); + await this.em.flush(); + + return user; + } + async findAll(dto: FindUsersDto): Promise> { const { page = 1, limit = 10, search, orderBy = 'createdAt', order = 'desc' } = dto;