From c439fdd6f9c0d75fd1ced4b0cde6a42d6e61947d Mon Sep 17 00:00:00 2001 From: morteza-mortezai Date: Thu, 23 Jul 2026 11:41:09 +0330 Subject: [PATCH] ai question list --- src/modules/ai/ai.module.ts | 3 +- src/modules/ai/controllers/ai.controller.ts | 19 +++++++- src/modules/ai/crone/ai.crone.ts | 44 +++++++++++++++++++ src/modules/ai/dto/find-ai-chats.dto.ts | 37 ++++++++++++++++ src/modules/ai/providers/ai.service.ts | 12 ++++- .../ai/repositories/ai-chat.repository.ts | 34 ++++++++++++++ 6 files changed, 144 insertions(+), 5 deletions(-) create mode 100644 src/modules/ai/crone/ai.crone.ts create mode 100644 src/modules/ai/dto/find-ai-chats.dto.ts diff --git a/src/modules/ai/ai.module.ts b/src/modules/ai/ai.module.ts index 337a080..3812847 100644 --- a/src/modules/ai/ai.module.ts +++ b/src/modules/ai/ai.module.ts @@ -6,11 +6,12 @@ import { AiService } from './providers/ai.service'; import { AiChatRepository } from './repositories/ai-chat.repository'; import { AiChat } from './entities/ai-chat.entity'; import { RestaurantsModule } from '../restaurants/restaurants.module'; +import { AiCrone } from './crone/ai.crone'; @Module({ imports: [MikroOrmModule.forFeature([AiChat]), JwtModule, RestaurantsModule], controllers: [AiController], - providers: [AiService, AiChatRepository], + providers: [AiService, AiChatRepository, AiCrone], exports: [AiChatRepository], }) export class AiModule {} diff --git a/src/modules/ai/controllers/ai.controller.ts b/src/modules/ai/controllers/ai.controller.ts index 2590a0d..475e8b6 100644 --- a/src/modules/ai/controllers/ai.controller.ts +++ b/src/modules/ai/controllers/ai.controller.ts @@ -1,7 +1,8 @@ -import { Body, Controller, Post, UseGuards } from '@nestjs/common'; -import { ApiBearerAuth, ApiBody, ApiHeader, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiBody, ApiHeader, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'; import { AiService } from '../providers/ai.service'; import { ChatDto } from '../dto/chat.dto'; +import { FindAiChatsDto } from '../dto/find-ai-chats.dto'; import { GenerateFoodDescriptionDto } from '../dto/generate-food-description.dto'; import { GenerateFoodContentDto } from '../dto/generate-food-content.dto'; import { OptionalAuthGuard } from '../../auth/guards/optinalAuth.guard'; @@ -42,6 +43,20 @@ export class AiController { }); } + @UseGuards(AdminAuthGuard) + @Permissions(Permission.UPDATE_RESTAURANT) + @Get('admin/ai/chats') + @ApiBearerAuth() + @ApiOperation({ summary: 'Get paginated list of AI chats for the restaurant' }) + @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'] }) + async findAllPaginated(@RestId() restId: string, @Query() dto: FindAiChatsDto) { + return this.aiService.findAllPaginated(restId, dto); + } + @UseGuards(AdminAuthGuard) @Permissions(Permission.MANAGE_FOODS) @Post('admin/ai/food-description') diff --git a/src/modules/ai/crone/ai.crone.ts b/src/modules/ai/crone/ai.crone.ts new file mode 100644 index 0000000..10714bc --- /dev/null +++ b/src/modules/ai/crone/ai.crone.ts @@ -0,0 +1,44 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { CreateRequestContext } from '@mikro-orm/core'; +import { EntityManager } from '@mikro-orm/postgresql'; +import { AiChat } from '../entities/ai-chat.entity'; + +@Injectable() +export class AiCrone { + private readonly logger = new Logger(AiCrone.name); + + constructor(private readonly em: EntityManager) {} + + // run every day at 03:00 (3:00 AM) + @Cron('0 3 * * *', { + name: 'deleteOldAiChats', + timeZone: 'UTC', + }) + @CreateRequestContext() + async handleCron() { + try { + this.logger.debug('Starting daily AI chat cleanup (removing questions older than 1 month)'); + + const oneMonthAgo = new Date(); + oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1); + + const oldAiChats = await this.em.find(AiChat, { + createdAt: { $lt: oneMonthAgo }, + }); + + if (!oldAiChats || oldAiChats.length === 0) { + this.logger.debug('No old AI chats found to delete'); + return; + } + + this.logger.log(`Found ${oldAiChats.length} AI chats older than 1 month to delete`); + + await this.em.removeAndFlush(oldAiChats); + + this.logger.log(`Successfully deleted ${oldAiChats.length} old AI chats`); + } catch (err) { + this.logger.error(`AiCrone failed: ${err?.message}`, err); + } + } +} diff --git a/src/modules/ai/dto/find-ai-chats.dto.ts b/src/modules/ai/dto/find-ai-chats.dto.ts new file mode 100644 index 0000000..f335819 --- /dev/null +++ b/src/modules/ai/dto/find-ai-chats.dto.ts @@ -0,0 +1,37 @@ +import { IsOptional, IsNumber, IsString, IsIn, Min } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; + +const sortOrderOptions = ['asc', 'desc'] as const; +type SortOrder = (typeof sortOrderOptions)[number]; + +export class FindAiChatsDto { + @IsOptional() + @IsNumber() + @Min(1) + @Type(() => Number) + @ApiPropertyOptional({ example: 1, description: 'Page number', minimum: 1, default: 1 }) + page?: number = 1; + + @IsOptional() + @IsNumber() + @Min(1) + @Type(() => Number) + @ApiPropertyOptional({ example: 10, description: 'Items per page', minimum: 1, default: 10 }) + limit?: number = 10; + + @IsOptional() + @IsString() + @ApiPropertyOptional({ example: 'گیاه‌خوار', description: 'Search in question text' }) + search?: string; + + @IsOptional() + @IsString() + @ApiPropertyOptional({ example: 'createdAt', description: 'Field to sort by', default: 'createdAt' }) + orderBy?: string = 'createdAt'; + + @IsOptional() + @IsIn(sortOrderOptions) + @ApiPropertyOptional({ example: 'desc', enum: sortOrderOptions, default: 'desc' }) + order?: SortOrder = 'desc'; +} diff --git a/src/modules/ai/providers/ai.service.ts b/src/modules/ai/providers/ai.service.ts index 5751284..27f949a 100644 --- a/src/modules/ai/providers/ai.service.ts +++ b/src/modules/ai/providers/ai.service.ts @@ -11,9 +11,11 @@ import { EntityManager } from '@mikro-orm/postgresql'; import { AxiosError } from 'axios'; import { catchError, firstValueFrom, throwError } from 'rxjs'; import { ChatDto } from '../dto/chat.dto'; +import { FindAiChatsDto } from '../dto/find-ai-chats.dto'; import { GenerateFoodDescriptionDto } from '../dto/generate-food-description.dto'; import { GenerateFoodContentDto } from '../dto/generate-food-content.dto'; import { AiChat } from '../entities/ai-chat.entity'; +import { AiChatRepository } from '../repositories/ai-chat.repository'; import { AiChatCompletionResponse, AiChatResult } from '../interface/ai-chat.interface'; import { Restaurant } from '../../restaurants/entities/restaurant.entity'; import { User } from '../../users/entities/user.entity'; @@ -24,6 +26,7 @@ import { RestaurantCreditTransactionType, } from '../../restaurants/interface/restaurant-credit-transaction.interface'; import { AiMessage, RestMessage } from 'src/common/enums/message.enum'; +import { PaginatedResult } from 'src/common/interfaces/pagination.interface'; @Injectable() export class AiService { @@ -34,8 +37,13 @@ export class AiService { private readonly httpService: HttpService, private readonly configService: ConfigService, private readonly restaurantWalletService: RestaurantWalletService, + private readonly aiChatRepository: AiChatRepository, ) { } + findAllPaginated(restId: string, dto: FindAiChatsDto): Promise> { + return this.aiChatRepository.findAllPaginated(restId, dto); + } + async chat(dto: ChatDto, params: { restId?: string; userId?: string; slug?: string }): Promise { const restaurant = await this.resolveRestaurant(params.restId, params.slug); const user = params.userId ? await this.em.findOne(User, { id: params.userId }) : null; @@ -82,7 +90,7 @@ export class AiService { 'لحن باید جذاب، مختصر و مناسب منوی دیجیتال باشد.', ].join('\n'), userPrompt: `نام غذا: ${dto.foodName}`, - maxTokens: 150, + maxTokens: 20000, temperature: 0.7, }); @@ -105,7 +113,7 @@ export class AiService { 'مواد باید رایج، مختصر و مناسب منوی دیجیتال باشد.', ].join('\n'), userPrompt: `نام غذا: ${dto.foodName}`, - maxTokens: 150, + maxTokens: 20000, temperature: 0.7, }); diff --git a/src/modules/ai/repositories/ai-chat.repository.ts b/src/modules/ai/repositories/ai-chat.repository.ts index 6375575..5360f0f 100644 --- a/src/modules/ai/repositories/ai-chat.repository.ts +++ b/src/modules/ai/repositories/ai-chat.repository.ts @@ -1,10 +1,44 @@ import { Injectable } from '@nestjs/common'; import { EntityManager, EntityRepository } from '@mikro-orm/postgresql'; +import { FilterQuery } from '@mikro-orm/core'; import { AiChat } from '../entities/ai-chat.entity'; +import { FindAiChatsDto } from '../dto/find-ai-chats.dto'; +import { PaginatedResult } from 'src/common/interfaces/pagination.interface'; @Injectable() export class AiChatRepository extends EntityRepository { constructor(readonly em: EntityManager) { super(em, AiChat); } + + async findAllPaginated(restaurantId: string, dto: FindAiChatsDto): Promise> { + const { page = 1, limit = 10, search, orderBy = 'createdAt', order = 'desc' } = dto; + + const offset = (page - 1) * limit; + + const where: FilterQuery = { + restaurant: { id: restaurantId }, + }; + + if (search) { + where.question = { $ilike: `%${search}%` }; + } + + const [data, total] = await this.findAndCount(where, { + limit, + offset, + orderBy: { [orderBy]: order.toLowerCase() as 'asc' | 'desc' }, + populate: ['user'], + }); + + return { + data, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }; + } }