Compare commits

..

8 Commits

Author SHA1 Message Date
morteza 8225a3818c ai modification
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-28 16:43:23 +03:30
morteza 827864b19c new prompt
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-26 16:55:56 +03:30
morteza b59541b6f7 not to small answer
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-26 16:40:32 +03:30
morteza 40e78b4ccd add enable chat
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-26 12:27:38 +03:30
morteza ea629fcbf6 dmenu notif
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-26 11:49:18 +03:30
morteza c96df8748f update up
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-26 11:07:55 +03:30
morteza c439fdd6f9 ai question list
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-23 11:41:09 +03:30
morteza 27c45974f4 ai module 2026-07-23 10:52:29 +03:30
27 changed files with 939 additions and 24 deletions
+14
View File
@@ -0,0 +1,14 @@
curl --location 'https://arvancloudai.ir/gateway/models/DeepSeek-V4-Flash/GShkYZ-j5g8GPs2hizngJUlGKcqhDw14rUWb0wu-eLcYjfqUVlvxAhbcBdjg5iH_FSs0uHY0ypJj94AIKmr-gotsFuKm2FqCGbNXJcBi7-2lTu3Ep_cQp_eCSvmo9y4GWUU8j26WQN7pvm-MgH-L9aH-wEdU-qsyqgSslgaPdPigDJz29XNbVmBX5uw3fMpyrJi_I8myhEAoQ9IwpPblDvyHJHgaMAHFX5NZ77PWfyrxCUWkCnlsLZwUUnQ3YEjujmNm77mDqQQ/v1/chat/completions' \
-H 'Authorization: apikey 4fe6d150-7951-58f5-b813-2e4bd7e74734' \
-H 'Content-Type: application/json' \
-d '{
"model":"DeepSeek-V4-Flash-f5unr",
"messages":[
{
"role":"user",
"content":"چرا آسمان آبی است؟"
}
],
"max_tokens":1000,
"temperature":0.8
}'
+9 -9
View File
@@ -1,11 +1,11 @@
{ {
"folders": [ "folders": [
{ {
"path": "." "path": ".",
}, },
{ {
"path": "../dmenu-admin" "path": "../dmenu-admin",
} },
], ],
"settings": {} "settings": {},
} }
@@ -0,0 +1,59 @@
import { Migration } from '@mikro-orm/migrations';
export class Migration20260723090000_addAiChats extends Migration {
override async up(): Promise<void> {
this.addSql(`
create table "ai_chats" (
"id" char(26) not null,
"created_at" timestamptz not null default now(),
"updated_at" timestamptz not null default now(),
"deleted_at" timestamptz null,
"restaurant_id" char(26) not null,
"user_id" char(26) null,
"question" text not null,
"consumed_tokens" int not null,
"cost" numeric(10,0) not null,
constraint "ai_chats_pkey" primary key ("id")
);
`);
this.addSql(`create index "ai_chats_created_at_index" on "ai_chats" ("created_at");`);
this.addSql(`create index "ai_chats_deleted_at_index" on "ai_chats" ("deleted_at");`);
this.addSql(`create index "ai_chats_restaurant_id_index" on "ai_chats" ("restaurant_id");`);
this.addSql(`
alter table "ai_chats"
add constraint "ai_chats_restaurant_id_foreign"
foreign key ("restaurant_id") references "restaurants" ("id") on update cascade;
`);
this.addSql(`
alter table "ai_chats"
add constraint "ai_chats_user_id_foreign"
foreign key ("user_id") references "users" ("id") on update cascade on delete set null;
`);
this.addSql(`
alter table "restaurant_credit_transactions"
drop constraint if exists "restaurant_credit_transactions_reason_check";
`);
this.addSql(`
alter table "restaurant_credit_transactions"
add constraint "restaurant_credit_transactions_reason_check"
check ("reason" in ('sms_send', 'deposit', 'adjustment', 'ai_chat'));
`);
}
override async down(): Promise<void> {
this.addSql(`
alter table "restaurant_credit_transactions"
drop constraint if exists "restaurant_credit_transactions_reason_check";
`);
this.addSql(`
alter table "restaurant_credit_transactions"
add constraint "restaurant_credit_transactions_reason_check"
check ("reason" in ('sms_send', 'deposit', 'adjustment'));
`);
this.addSql(`alter table "ai_chats" drop constraint if exists "ai_chats_user_id_foreign";`);
this.addSql(`alter table "ai_chats" drop constraint if exists "ai_chats_restaurant_id_foreign";`);
this.addSql(`drop table if exists "ai_chats" cascade;`);
}
}
@@ -0,0 +1,13 @@
import { Migration } from '@mikro-orm/migrations';
export class Migration20260726120000_addEnableAiChatToRestaurants extends Migration {
override async up(): Promise<void> {
this.addSql(
`alter table "restaurants" add column "enable_ai_chat" boolean not null default false;`,
);
}
override async down(): Promise<void> {
this.addSql(`alter table "restaurants" drop column "enable_ai_chat";`);
}
}
+2
View File
@@ -25,6 +25,7 @@ import { ContactModule } from './modules/contact/contact.module';
import { InventoryModule } from './modules/inventory/inventory.module'; import { InventoryModule } from './modules/inventory/inventory.module';
import { IconsModule } from './modules/icons/icons.module'; import { IconsModule } from './modules/icons/icons.module';
import { SliderModule } from './modules/slider/slider.module'; import { SliderModule } from './modules/slider/slider.module';
import { AiModule } from './modules/ai/ai.module';
import { CacheModule } from '@nestjs/cache-manager'; import { CacheModule } from '@nestjs/cache-manager';
import { cacheConfig } from './config/cache.config'; import { cacheConfig } from './config/cache.config';
@@ -61,6 +62,7 @@ import { cacheConfig } from './config/cache.config';
InventoryModule, InventoryModule,
IconsModule, IconsModule,
SliderModule, SliderModule,
AiModule,
], ],
controllers: [], controllers: [],
providers: [], providers: [],
+7
View File
@@ -600,6 +600,9 @@ export const enum SettingMessageEnum {
export const enum AiMessage { export const enum AiMessage {
NO_RESPONSE_FROM_AI_MODEL = 'خطا در دریافت پاسخ از مدل AI', NO_RESPONSE_FROM_AI_MODEL = 'خطا در دریافت پاسخ از مدل AI',
LLM_SERVICE_ERROR = 'سرویس هوش مصنوعی موقتاً در دسترس نیست',
INSUFFICIENT_CREDIT = 'اعتبار کیف پول رستوران برای استفاده از هوش مصنوعی کافی نیست',
AI_CHAT_DISABLED = 'چت هوش مصنوعی برای این رستوران فعال نیست',
MESSAGE_NOT_FOUND_OR_ACCESS_DENIED = 'ایمیل یافت نشد یا دسترسی آن را ندارید', MESSAGE_NOT_FOUND_OR_ACCESS_DENIED = 'ایمیل یافت نشد یا دسترسی آن را ندارید',
EMAIL_CONTENT_IS_EMPTY_OR_COULD_NOT_BE_EXTRACTED = 'محتوای ایمیل خالی است یا قابل استخراج نیست', EMAIL_CONTENT_IS_EMPTY_OR_COULD_NOT_BE_EXTRACTED = 'محتوای ایمیل خالی است یا قابل استخراج نیست',
DESCRIPTION_IS_REQUIRED = 'شرح و هدف قالب الزامی است', DESCRIPTION_IS_REQUIRED = 'شرح و هدف قالب الزامی است',
@@ -712,6 +715,10 @@ export const enum OrderMessage {
NO_PAID_PAYMENTS_TO_REFUND = 'پرداختی برای بازگشت وجه یافت نشد', NO_PAID_PAYMENTS_TO_REFUND = 'پرداختی برای بازگشت وجه یافت نشد',
ADD_PAYMENT_NOT_ALLOWED = 'امکان افزودن پرداخت برای این سفارش وجود ندارد', ADD_PAYMENT_NOT_ALLOWED = 'امکان افزودن پرداخت برای این سفارش وجود ندارد',
ADD_PAYMENT_NO_BALANCE = 'مانده‌ای برای پرداخت وجود ندارد', ADD_PAYMENT_NO_BALANCE = 'مانده‌ای برای پرداخت وجود ندارد',
PLEASE_COME_SMS_QUEUED = 'پیامک دعوت به مراجعه در صف ارسال قرار گرفت',
PLEASE_COME_SMS_PATTERN_MISSING = 'الگوی پیامک دعوت به مراجعه تنظیم نشده است',
PLEASE_COME_USER_MISSING = 'این سفارش کاربری ندارد',
PLEASE_COME_PHONE_MISSING = 'شماره تلفن کاربر یافت نشد',
} }
export const enum CartMessage { export const enum CartMessage {
+17
View File
@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { JwtModule } from '@nestjs/jwt';
import { AiController } from './controllers/ai.controller';
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, AiCrone],
exports: [AiChatRepository],
})
export class AiModule {}
@@ -0,0 +1,82 @@
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';
import { AdminAuthGuard } from '../../auth/guards/adminAuth.guard';
import { RestId } from 'src/common/decorators/rest-id.decorator';
import { UserId } from 'src/common/decorators/user-id.decorator';
import { RestSlug } from 'src/common/decorators/rest-slug.decorator';
import { Permissions } from 'src/common/decorators/permissions.decorator';
import { Permission } from 'src/common/enums/permission.enum';
import { API_HEADER_SLUG } from 'src/common/constants';
@ApiTags('ai')
@Controller()
export class AiController {
constructor(private readonly aiService: AiService) {}
@UseGuards(OptionalAuthGuard)
@Post('public/ai/chat')
@ApiOperation({
summary: 'Ask the restaurant AI assistant about the menu',
description: 'Returns an answer plus any offered foods populated from AI-selected food IDs',
})
@ApiHeader(API_HEADER_SLUG)
@ApiHeader({
name: 'Authorization',
required: false,
description: 'Bearer token (optional authentication)',
})
@ApiBearerAuth()
@ApiBody({ type: ChatDto })
async chat(
@Body() dto: ChatDto,
@RestSlug() slug: string,
@RestId() restId?: string,
@UserId() userId?: string,
) {
return this.aiService.chat(dto, {
restId: restId || undefined,
userId: userId || undefined,
slug,
});
}
@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')
@ApiBearerAuth()
@ApiOperation({ summary: 'Generate a two-line food description from a food name' })
@ApiBody({ type: GenerateFoodDescriptionDto })
async generateFoodDescription(@Body() dto: GenerateFoodDescriptionDto, @RestId() restId: string) {
return this.aiService.generateFoodDescription(dto, restId);
}
@UseGuards(AdminAuthGuard)
@Permissions(Permission.MANAGE_FOODS)
@Post('admin/ai/food-content')
@ApiBearerAuth()
@ApiOperation({ summary: 'Generate food ingredients/contents from a food name' })
@ApiBody({ type: GenerateFoodContentDto })
async generateFoodContent(@Body() dto: GenerateFoodContentDto, @RestId() restId: string) {
return this.aiService.generateFoodContent(dto, restId);
}
}
+44
View File
@@ -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);
}
}
}
+13
View File
@@ -0,0 +1,13 @@
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class ChatDto {
@IsNotEmpty()
@IsString()
@MaxLength(2000)
@ApiProperty({
example: 'کدام غذا برای گیاه‌خواران مناسب است؟',
description: 'User question for the restaurant AI assistant',
})
question!: string;
}
+37
View File
@@ -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';
}
@@ -0,0 +1,13 @@
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class GenerateFoodContentDto {
@IsNotEmpty()
@IsString()
@MaxLength(200)
@ApiProperty({
example: 'چلو کباب کوبیده',
description: 'Food name to generate ingredients/contents for',
})
foodName!: string;
}
@@ -0,0 +1,13 @@
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class GenerateFoodDescriptionDto {
@IsNotEmpty()
@IsString()
@MaxLength(200)
@ApiProperty({
example: 'چلو کباب کوبیده',
description: 'Food name to generate a short description for',
})
foodName!: string;
}
+23
View File
@@ -0,0 +1,23 @@
import { Entity, Index, ManyToOne, Property } from '@mikro-orm/core';
import { BaseEntity } from '../../../common/entities/base.entity';
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
import { User } from '../../users/entities/user.entity';
@Entity({ tableName: 'ai_chats' })
@Index({ properties: ['restaurant'] })
export class AiChat extends BaseEntity {
@ManyToOne(() => Restaurant)
restaurant!: Restaurant;
@ManyToOne(() => User, { nullable: true })
user?: User | null;
@Property({ type: 'text' })
question!: string;
@Property({ type: 'int' })
consumedTokens!: number;
@Property({ type: 'decimal', precision: 10, scale: 0 })
cost!: number;
}
@@ -0,0 +1,39 @@
export interface AiChatCompletionUsage {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
}
export interface AiChatCompletionChoice {
finish_reason?: string | null;
message?: {
role?: string;
content?: string | null;
reasoning_content?: string | null;
};
}
export interface AiChatCompletionResponse {
choices?: AiChatCompletionChoice[];
usage?: AiChatCompletionUsage;
}
export interface AiChatOfferedFood {
id: string;
title?: string;
desc?: string;
content?: string[];
price?: number;
images?: string[];
discount: number;
isSpecialOffer: boolean;
}
export interface AiChatResult {
answer: string;
foods: AiChatOfferedFood[];
consumedTokens: number;
cost: number;
promptTokens: number;
completionTokens: number;
}
+395
View File
@@ -0,0 +1,395 @@
import {
BadRequestException,
Injectable,
Logger,
NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common';
import { HttpService } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config';
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,
AiChatOfferedFood,
AiChatResult,
} from '../interface/ai-chat.interface';
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
import { User } from '../../users/entities/user.entity';
import { Food } from '../../foods/entities/food.entity';
import { RestaurantWalletService } from '../../restaurants/providers/restaurant-wallet.service';
import {
RestaurantCreditTransactionReason,
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 {
private readonly logger = new Logger(AiService.name);
constructor(
private readonly em: EntityManager,
private readonly httpService: HttpService,
private readonly configService: ConfigService,
private readonly restaurantWalletService: RestaurantWalletService,
private readonly aiChatRepository: AiChatRepository,
) { }
findAllPaginated(restId: string, dto: FindAiChatsDto): Promise<PaginatedResult<AiChat>> {
return this.aiChatRepository.findAllPaginated(restId, dto);
}
async chat(dto: ChatDto, params: { restId?: string; userId?: string; slug?: string }): Promise<AiChatResult> {
const restaurant = await this.resolveRestaurant(params.restId, params.slug);
if (!restaurant.enableAiChat) {
throw new BadRequestException(AiMessage.AI_CHAT_DISABLED);
}
const user = params.userId ? await this.em.findOne(User, { id: params.userId }) : null;
await this.ensureHasCredit(restaurant.id);
const foods = await this.em.find(
Food,
{ restaurant: restaurant.id, isActive: true },
{
fields: ['id', 'title', 'desc', 'content', 'price', 'images', 'discount', 'isSpecialOffer'],
orderBy: { order: 'asc' },
},
);
const menuContext = this.buildMenuContext(foods);
const aiResponse = await this.callAiApi({
systemPrompt: [
'تو دستیار هوشمند منوی رستوران هستی.',
'فقط بر اساس منوی زیر به سوالات کاربر پاسخ بده.',
'یک توضیح کوتاه دو خطی هم اگه لازم بود اضافه کن',
'اگر اطلاعات کافی در منو نیست، صادقانه بگو.',
'وقتی غذایی پیشنهاد می‌دهی، فقط از شناسه‌های (id) موجود در منو استفاده کن.',
'پاسخ را فقط و فقط به صورت JSON معتبر برگردان، بدون متن اضافه یا markdown:',
'{"answer":"پاسخ کوتاه فارسی","foodIds":["id1","id2"]}',
'اگر غذایی پیشنهاد نمی‌کنی، foodIds را آرایه خالی بگذار.',
'هیچ وقت id غذا را داخل متن قرار نده.',
'',
'منوی رستوران:',
menuContext,
].join('\n'),
userPrompt: dto.question,
});
const parsed = this.parseChatAiContent(aiResponse);
const offeredFoods = this.populateOfferedFoods(parsed.foodIds, foods);
return this.persistAiUsage({
restaurant,
user,
question: dto.question,
aiResponse,
answerOverride: parsed.answer,
foods: offeredFoods,
});
}
async generateFoodDescription(dto: GenerateFoodDescriptionDto, restId: string): Promise<AiChatResult> {
const restaurant = await this.resolveRestaurant(restId);
await this.ensureHasCredit(restaurant.id);
const aiResponse = await this.callAiApi({
systemPrompt: [
'تو نویسنده منوی رستوران هستی.',
'برای نام غذای داده‌شده، دقیقاً یک توضیح کوتاه دو خطی به فارسی بنویس.',
'فقط همان دو خط را برگردان؛ بدون عنوان، بدون بولت و بدون توضیح اضافه.',
'لحن باید جذاب، مختصر و مناسب منوی دیجیتال باشد.',
].join('\n'),
userPrompt: `نام غذا: ${dto.foodName}`,
maxTokens: 20000,
temperature: 0.7,
});
return this.persistAiUsage({
restaurant,
question: `تولید توضیح برای غذا: ${dto.foodName}`,
aiResponse,
});
}
async generateFoodContent(dto: GenerateFoodContentDto, restId: string): Promise<AiChatResult> {
const restaurant = await this.resolveRestaurant(restId);
await this.ensureHasCredit(restaurant.id);
const aiResponse = await this.callAiApi({
systemPrompt: [
'تو نویسنده منوی رستوران هستی.',
'برای نام غذای داده‌شده، محتویات و مواد تشکیل‌دهنده را به فارسی بنویس.',
'فقط لیست مواد را با ویرگول فارسی (،) جدا کن؛ بدون عنوان، بدون بولت و بدون توضیح اضافه.',
'مواد باید رایج، مختصر و مناسب منوی دیجیتال باشد.',
].join('\n'),
userPrompt: `نام غذا: ${dto.foodName}`,
maxTokens: 20000,
temperature: 0.7,
});
return this.persistAiUsage({
restaurant,
question: `تولید محتویات برای غذا: ${dto.foodName}`,
aiResponse,
});
}
private async ensureHasCredit(restaurantId: string): Promise<void> {
const balance = await this.restaurantWalletService.getCurrentBalance(restaurantId);
if (balance <= 0) {
throw new BadRequestException(AiMessage.INSUFFICIENT_CREDIT);
}
}
private async persistAiUsage(params: {
restaurant: Restaurant;
user?: User | null;
question: string;
aiResponse: AiChatCompletionResponse;
answerOverride?: string;
foods?: AiChatOfferedFood[];
}): Promise<AiChatResult> {
const { restaurant, user = null, question, aiResponse, answerOverride, foods = [] } = params;
const promptTokens = Number(aiResponse.usage?.prompt_tokens ?? 0);
const completionTokens = Number(aiResponse.usage?.completion_tokens ?? 0);
const consumedTokens = Number(aiResponse.usage?.total_tokens ?? promptTokens + completionTokens);
const choice = aiResponse.choices?.[0];
const answer = (answerOverride ?? choice?.message?.content)?.trim();
if (!answer) {
this.logger.error(
`Empty AI content. finish_reason=${choice?.finish_reason ?? 'unknown'} ` +
`reasoning_tokens=${choice?.message?.reasoning_content?.length ?? 0} ` +
`usage=${JSON.stringify(aiResponse.usage ?? null)}`,
);
throw new ServiceUnavailableException(AiMessage.NO_RESPONSE_FROM_AI_MODEL);
}
// Prices in .env are per 1,000,000 tokens
const promptTokenPricePerMillion = Number(this.configService.getOrThrow<string>('AI_PROMPT_TOKEN_PRICE_PER_MILLION'));
const completionTokenPricePerMillion = Number(
this.configService.getOrThrow<string>('AI_COMPLETION_TOKEN_PRICE_PER_MILLION'),
);
const cost = Math.ceil(
(promptTokens * promptTokenPricePerMillion + completionTokens * completionTokenPricePerMillion) /
1_000_000,
);
await this.em.transactional(async (em) => {
const aiChat = em.create(AiChat, {
restaurant,
user,
question,
consumedTokens,
cost,
});
await em.persistAndFlush(aiChat);
if (cost > 0) {
await this.restaurantWalletService.createTransaction({
restaurant,
amount: cost,
type: RestaurantCreditTransactionType.DEBIT,
reason: RestaurantCreditTransactionReason.AI_CHAT,
insufficientBalanceMessage: AiMessage.INSUFFICIENT_CREDIT,
em,
});
}
});
return {
answer,
foods,
consumedTokens,
cost,
promptTokens,
completionTokens,
};
}
private async resolveRestaurant(restId?: string, slug?: string): Promise<Restaurant> {
if (!restId && !slug) {
throw new BadRequestException('شناسه یا اسلاگ رستوران الزامی است');
}
const restaurant = await this.em.findOne(Restaurant, {
...(restId ? { id: restId } : {}),
...(slug ? { slug } : {}),
});
if (!restaurant) {
throw new NotFoundException(RestMessage.NOT_FOUND);
}
return restaurant;
}
private buildMenuContext(
foods: Array<Pick<Food, 'id' | 'title' >>,
): string {
if (!foods.length) {
return 'منوی رستوران در حال حاضر خالی است.';
}
return foods
.map((food, index) => {
return [
`${index + 1}. id: ${food.id}`,
`نام: ${food.title ?? 'بدون نام'}`
].join('\n');
})
.join('\n\n');
}
private parseChatAiContent(aiResponse: AiChatCompletionResponse): {
answer: string;
foodIds: string[];
} {
const choice = aiResponse.choices?.[0];
const raw = choice?.message?.content?.trim();
if (!raw) {
this.logger.error(
`Empty AI content. finish_reason=${choice?.finish_reason ?? 'unknown'} ` +
`reasoning_tokens=${choice?.message?.reasoning_content?.length ?? 0} ` +
`usage=${JSON.stringify(aiResponse.usage ?? null)}`,
);
throw new ServiceUnavailableException(AiMessage.NO_RESPONSE_FROM_AI_MODEL);
}
const jsonPayload = this.extractJsonObject(raw);
if (!jsonPayload) {
return { answer: raw, foodIds: [] };
}
try {
const parsed = JSON.parse(jsonPayload) as { answer?: unknown; foodIds?: unknown };
const answer = typeof parsed.answer === 'string' ? parsed.answer.trim() : raw;
const foodIds = Array.isArray(parsed.foodIds)
? parsed.foodIds.filter((id): id is string => typeof id === 'string' && id.length > 0)
: [];
return { answer: answer || raw, foodIds };
} catch {
this.logger.warn(`Failed to parse AI chat JSON response: ${raw.slice(0, 200)}`);
return { answer: raw, foodIds: [] };
}
}
private extractJsonObject(raw: string): string | null {
const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/i);
if (fenced?.[1]) {
return fenced[1].trim();
}
const start = raw.indexOf('{');
const end = raw.lastIndexOf('}');
if (start === -1 || end === -1 || end <= start) {
return null;
}
return raw.slice(start, end + 1);
}
private populateOfferedFoods(
foodIds: string[],
menuFoods: Array<
Pick<Food, 'id' | 'title' | 'desc' | 'content' | 'price' | 'images' | 'discount' | 'isSpecialOffer'>
>,
): AiChatOfferedFood[] {
if (!foodIds.length) {
return [];
}
const foodMap = new Map(menuFoods.map((food) => [food.id, food]));
const seen = new Set<string>();
return foodIds.reduce<AiChatOfferedFood[]>((acc, foodId) => {
if (seen.has(foodId)) {
return acc;
}
seen.add(foodId);
const food = foodMap.get(foodId);
if (!food) {
return acc;
}
acc.push({
id: food.id,
title: food.title,
desc: food.desc,
content: food.content,
price: food.price,
images: food.images,
discount: food.discount,
isSpecialOffer: food.isSpecialOffer,
});
return acc;
}, []);
}
private async callAiApi(params: {
systemPrompt: string;
userPrompt: string;
maxTokens?: number;
temperature?: number;
}): Promise<AiChatCompletionResponse> {
const apiUrl = this.configService.getOrThrow<string>('AI_API_URL');
const apiKey = this.configService.getOrThrow<string>('AI_API_KEY');
const model = this.configService.getOrThrow<string>('AI_MODEL');
const maxTokens = params.maxTokens ?? Number(this.configService.get('AI_MAX_TOKENS') ?? 1000);
const temperature = params.temperature ?? Number(this.configService.get('AI_TEMPERATURE') ?? 0.8);
try {
const { data } = await firstValueFrom(
this.httpService
.post<AiChatCompletionResponse>(
apiUrl,
{
model,
messages: [
{ role: 'system', content: params.systemPrompt },
{ role: 'user', content: params.userPrompt },
],
max_tokens: maxTokens,
temperature,
// DeepSeek V4 thinking is on by default; CoT can exhaust max_tokens and leave content empty.
thinking: { type: 'disabled' },
},
{
headers: {
Authorization: `apikey ${apiKey}`,
'Content-Type': 'application/json',
},
timeout: 60000,
},
)
.pipe(
catchError((err: AxiosError) => {
this.logger.error(`AI API request failed: ${err.message}`, err.stack);
return throwError(() => err);
}),
),
);
return data;
} catch (error: unknown) {
this.logger.error(`AI API error: ${error instanceof Error ? error.message : 'Unknown error'}`);
throw new ServiceUnavailableException(AiMessage.LLM_SERVICE_ERROR);
}
}
}
@@ -0,0 +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<AiChat> {
constructor(readonly em: EntityManager) {
super(em, AiChat);
}
async findAllPaginated(restaurantId: string, dto: FindAiChatsDto): Promise<PaginatedResult<AiChat>> {
const { page = 1, limit = 10, search, orderBy = 'createdAt', order = 'desc' } = dto;
const offset = (page - 1) * limit;
const where: FilterQuery<AiChat> = {
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),
},
};
}
}
+1 -1
View File
@@ -48,6 +48,6 @@ import { WalletTransaction } from '../users/entities/wallet-transaction.entity';
CartCalculationService, CartCalculationService,
CartItemService, CartItemService,
], ],
exports: [CartService], exports: [CartService, CartValidationService],
}) })
export class CartModule { } export class CartModule { }
@@ -109,33 +109,51 @@ export class CartValidationService {
} }
/** /**
* Assert address is inside restaurant service area * Whether the restaurant has a usable GeoJSON polygon service area.
* Empty / incomplete polygons are treated as "not set".
*/
hasConfiguredServiceArea(
serviceArea?: { type: 'Polygon'; coordinates: number[][][] } | null,
): boolean {
if (!serviceArea?.coordinates || !Array.isArray(serviceArea.coordinates)) return false;
const ring = serviceArea.coordinates[0];
if (!Array.isArray(ring) || ring.length < 3) return false;
return ring.every(
coord =>
Array.isArray(coord) &&
coord.length >= 2 &&
Number.isFinite(Number(coord[0])) &&
Number.isFinite(Number(coord[1])),
);
}
/**
* Assert address is inside restaurant service area.
* Skips entirely when the restaurant has not configured a service area.
*/ */
async assertAddressInsideServiceArea( async assertAddressInsideServiceArea(
restaurantId: string, restaurantId: string,
latitude?: number | null, latitude?: number | null,
longitude?: number | null, longitude?: number | null,
): Promise<void> { ): Promise<void> {
const restaurant = await this.getRestaurantOrFail(restaurantId);
// If no service area is defined, assume service is available everywhere
if (!this.hasConfiguredServiceArea(restaurant.serviceArea)) return;
if (latitude === undefined || latitude === null || longitude === undefined || longitude === null) { if (latitude === undefined || latitude === null || longitude === undefined || longitude === null) {
throw new BadRequestException(CartMessage.ADDRESS_NO_COORDINATES); throw new BadRequestException(CartMessage.ADDRESS_NO_COORDINATES);
} }
const restaurant = await this.getRestaurantOrFail(restaurantId);
const serviceArea = restaurant.serviceArea;
// If no service area is defined, assume service is available everywhere
if (!serviceArea || !serviceArea.coordinates || !Array.isArray(serviceArea.coordinates)) return;
// GeoJSON coordinates: [ [ [lng, lat], ... ] ] -> take first ring // GeoJSON coordinates: [ [ [lng, lat], ... ] ] -> take first ring
const rings = serviceArea.coordinates; const ring = restaurant.serviceArea!.coordinates[0];
if (!rings || rings.length === 0 || !Array.isArray(rings[0])) return;
const ring = rings[0];
const point: [number, number] = [Number(longitude), Number(latitude)]; const point: [number, number] = [Number(longitude), Number(latitude)];
// Ensure polygon has the correct tuple typing ([lng, lat] pairs) // Ensure polygon has the correct tuple typing ([lng, lat] pairs)
const polygon: [number, number][] = ring.map( const polygon: [number, number][] = ring.map(
coord => [Number(coord[0] ?? 0), Number(coord[1] ?? 0)] as [number, number], coord => [Number(coord[0]), Number(coord[1])] as [number, number],
); );
if (!GeographicUtils.isPointInPolygon(point, polygon)) { if (!GeographicUtils.isPointInPolygon(point, polygon)) {
@@ -19,6 +19,7 @@ import { FoodOrderReportDto } from '../dto/food-order-report.dto';
import { DailyOrderReportDto } from '../dto/daily-order-report.dto'; import { DailyOrderReportDto } from '../dto/daily-order-report.dto';
import { AdminRefundOrderDto } from '../dto/admin-refund-order.dto'; import { AdminRefundOrderDto } from '../dto/admin-refund-order.dto';
import { AdminAddPaymentDto } from '../dto/admin-add-payment.dto'; import { AdminAddPaymentDto } from '../dto/admin-add-payment.dto';
import { OrderMessage } from 'src/common/enums/message.enum';
@ApiTags('orders') @ApiTags('orders')
@ApiBearerAuth() @ApiBearerAuth()
@@ -184,6 +185,16 @@ export class OrdersController {
return this.ordersService.refundOrder(orderId, restId, dto); return this.ordersService.refundOrder(orderId, restId, dto);
} }
@UseGuards(AdminAuthGuard)
@Permissions(Permission.MANAGE_ORDERS)
@Post('admin/orders/:orderId/please-come')
@ApiOperation({ summary: 'Send please-come SMS to the order user' })
@ApiParam({ name: 'orderId', description: 'Order ID' })
async sendPleaseComeSms(@Param('orderId') orderId: string, @RestId() restId: string) {
await this.ordersService.sendPleaseComeSms(orderId, restId);
return { message: OrderMessage.PLEASE_COME_SMS_QUEUED };
}
@UseGuards(AdminAuthGuard) @UseGuards(AdminAuthGuard)
@Permissions(Permission.MANAGE_ORDERS) @Permissions(Permission.MANAGE_ORDERS)
@Patch('admin/orders/:orderId/:status') @Patch('admin/orders/:orderId/:status')
+56 -1
View File
@@ -1,4 +1,5 @@
import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common'; import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { EntityManager } from '@mikro-orm/postgresql'; import { EntityManager } from '@mikro-orm/postgresql';
import { Order } from '../entities/order.entity'; import { Order } from '../entities/order.entity';
import { OrderItem } from '../entities/order-item.entity'; import { OrderItem } from '../entities/order-item.entity';
@@ -8,6 +9,7 @@ import { UserRestaurant } from '../../users/entities/user-restuarant.entity';
import { Restaurant } from '../../restaurants/entities/restaurant.entity'; import { Restaurant } from '../../restaurants/entities/restaurant.entity';
import { Food } from '../../foods/entities/food.entity'; import { Food } from '../../foods/entities/food.entity';
import { CartService } from '../../cart/providers/cart.service'; import { CartService } from '../../cart/providers/cart.service';
import { CartValidationService } from '../../cart/providers/cart-validation.service';
import { OrderStatus, OrderUserAddress, OrderCarAddress } from '../interface/order.interface'; import { OrderStatus, OrderUserAddress, OrderCarAddress } from '../interface/order.interface';
import { PaymentMethodEnum, PaymentStatusEnum } from '../../payments/interface/payment'; import { PaymentMethodEnum, PaymentStatusEnum } from '../../payments/interface/payment';
import { Cart } from '../../cart/interfaces/cart.interface'; import { Cart } from '../../cart/interfaces/cart.interface';
@@ -36,6 +38,7 @@ import { CashShiftsService } from './cash-shifts.service';
import { WalletTransaction } from 'src/modules/users/entities/wallet-transaction.entity'; import { WalletTransaction } from 'src/modules/users/entities/wallet-transaction.entity';
import { WalletTransactionReason, WalletTransactionType } from 'src/modules/users/interface/wallet'; import { WalletTransactionReason, WalletTransactionType } from 'src/modules/users/interface/wallet';
import { OrderMessage, PaymentMessage } from 'src/common/enums/message.enum'; import { OrderMessage, PaymentMessage } from 'src/common/enums/message.enum';
import { SmsQueueService } from 'src/modules/notifications/services/sms-queue.service';
type OrderItemData = { food: Food; quantity: number; unitPrice: number; discount: number }; type OrderItemData = { food: Food; quantity: number; unitPrice: number; discount: number };
type ValidatedCartForOrder = { type ValidatedCartForOrder = {
@@ -65,11 +68,14 @@ export class OrdersService {
constructor( constructor(
private readonly em: EntityManager, private readonly em: EntityManager,
private readonly cartService: CartService, private readonly cartService: CartService,
private readonly cartValidationService: CartValidationService,
private readonly orderRepository: OrderRepository, private readonly orderRepository: OrderRepository,
private readonly paymentsService: PaymentsService, private readonly paymentsService: PaymentsService,
private readonly inventoryService: InventoryService, private readonly inventoryService: InventoryService,
private readonly eventEmitter: EventEmitter2, private readonly eventEmitter: EventEmitter2,
private readonly cashShiftsService: CashShiftsService, private readonly cashShiftsService: CashShiftsService,
private readonly smsQueueService: SmsQueueService,
private readonly configService: ConfigService,
) { } ) { }
async checkout(userId: string, restaurantId: string) { async checkout(userId: string, restaurantId: string) {
@@ -180,6 +186,14 @@ export class OrdersService {
? await this.resolveUserAddressForOrder(user!, dto.addressId!) ? await this.resolveUserAddressForOrder(user!, dto.addressId!)
: null; : null;
if (userAddress) {
await this.cartValidationService.assertAddressInsideServiceArea(
restaurantId,
userAddress.latitude,
userAddress.longitude,
);
}
const orderItemsData = await this.buildOrderItemsDataFromDto(dto.items, restaurantId); const orderItemsData = await this.buildOrderItemsDataFromDto(dto.items, restaurantId);
// Validate admin discount against item totals (not persisted — DB recomputes order money fields) // Validate admin discount against item totals (not persisted — DB recomputes order money fields)
@@ -434,6 +448,14 @@ export class OrdersService {
this.assertMeetsMinOrderForDelivery(cart, delivery); this.assertMeetsMinOrderForDelivery(cart, delivery);
this.assertDeliveryMethodRequirements(cart, delivery); this.assertDeliveryMethodRequirements(cart, delivery);
if (delivery.method === DeliveryMethodEnum.DeliveryCourier && cart.userAddress) {
await this.cartValidationService.assertAddressInsideServiceArea(
restaurantId,
cart.userAddress.latitude,
cart.userAddress.longitude,
);
}
const paymentMethod = await this.getPaymentMethodOrFail(cart.paymentMethodId!, restaurantId); const paymentMethod = await this.getPaymentMethodOrFail(cart.paymentMethodId!, restaurantId);
this.assertPaymentMethodEnabled(paymentMethod); this.assertPaymentMethodEnabled(paymentMethod);
this.assertCreditCardPaymentDetails(paymentMethod, cart.paymentDesc, cart.attachments); this.assertCreditCardPaymentDetails(paymentMethod, cart.paymentDesc, cart.attachments);
@@ -782,12 +804,45 @@ export class OrdersService {
const order = await this.em.findOne( const order = await this.em.findOne(
Order, Order,
{ id: orderId, restaurant: { id: restId } }, { id: orderId, restaurant: { id: restId } },
{ populate: ['paymentMethod', 'deliveryMethod', 'user'] }, { populate: ['paymentMethod', 'deliveryMethod', 'user', 'restaurant'] },
); );
if (!order) throw new NotFoundException(OrderMessage.NOT_FOUND); if (!order) throw new NotFoundException(OrderMessage.NOT_FOUND);
return order; return order;
} }
async sendPleaseComeSms(orderId: string, restId: string): Promise<void> {
const order = await this.getOrderOrFail(orderId, restId);
const user = order.user;
if (!user) {
throw new BadRequestException(OrderMessage.PLEASE_COME_USER_MISSING);
}
if (!user.phone) {
throw new BadRequestException(OrderMessage.PLEASE_COME_PHONE_MISSING);
}
const templateId = this.configService.get<string>('SMS_PATTERN_PLEASE_COME');
if (!templateId) {
throw new BadRequestException(OrderMessage.PLEASE_COME_SMS_PATTERN_MISSING);
}
this.logger.log(
`Queueing please-come SMS for order ${orderId} to user ${user.id} (${user.phone})`,
);
await this.smsQueueService.enqueue({
phone: user.phone,
templateId,
restaurantId: restId,
quantity: 1,
params: {
name: user.firstName ?? '',
rest: order.restaurant?.name ?? '',
},
});
}
private assertCartHasItems(cart: Cart) { private assertCartHasItems(cart: Cart) {
if (!cart.items || cart.items.length === 0) { if (!cart.items || cart.items.length === 0) {
throw new BadRequestException(OrderMessage.CART_EMPTY); throw new BadRequestException(OrderMessage.CART_EMPTY);
@@ -52,4 +52,9 @@ export class CreateRestaurantDto {
@IsBoolean() @IsBoolean()
isActive?: boolean; isActive?: boolean;
@ApiPropertyOptional({ example: false, description: 'فعال بودن چت هوش مصنوعی', default: false })
@IsOptional()
@IsBoolean()
enableAiChat?: boolean;
} }
@@ -51,4 +51,9 @@ export class SetupRestaurantDto {
@IsBoolean() @IsBoolean()
isActive?: boolean; isActive?: boolean;
@ApiPropertyOptional({ example: false, description: 'فعال بودن چت هوش مصنوعی', default: false })
@IsOptional()
@IsBoolean()
enableAiChat?: boolean;
} }
@@ -40,6 +40,9 @@ export class Restaurant extends BaseEntity {
@Property({ default: true }) @Property({ default: true })
isActive: boolean = true; isActive: boolean = true;
@Property({ default: false })
enableAiChat: boolean = false;
@Property({ nullable: true }) @Property({ nullable: true })
establishedYear?: number; establishedYear?: number;
@@ -7,4 +7,5 @@ export enum RestaurantCreditTransactionReason {
SMS_SEND = 'sms_send', SMS_SEND = 'sms_send',
DEPOSIT = 'deposit', DEPOSIT = 'deposit',
ADJUSTMENT = 'adjustment', ADJUSTMENT = 'adjustment',
AI_CHAT = 'ai_chat',
} }
@@ -73,6 +73,7 @@ export class RestaurantsService {
establishedYear: dto.establishedYear, establishedYear: dto.establishedYear,
phones: dto.phone ? [dto.phone] : undefined, phones: dto.phone ? [dto.phone] : undefined,
isActive: true, isActive: true,
enableAiChat: dto.enableAiChat ?? false,
plan: dto.plan, plan: dto.plan,
domain: `https://dmenu.danakcorp.com/${slug}`, domain: `https://dmenu.danakcorp.com/${slug}`,
subscriptionId: dto.subscriptionId, subscriptionId: dto.subscriptionId,
+1
View File
@@ -14,6 +14,7 @@ export class RestaurantsSeeder {
subscriptionStartDate: new Date('2026-01-01'), subscriptionStartDate: new Date('2026-01-01'),
subscriptionEndDate: new Date('2026-01-07'), subscriptionEndDate: new Date('2026-01-07'),
isActive: true, isActive: true,
enableAiChat: false,
}); });
em.persist(restaurant); em.persist(restaurant);
} }