Compare commits

12 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
morteza 549da39473 add group to user
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-22 11:52:10 +03:30
morteza 096297d1d1 fix notif
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-19 19:46:49 +03:30
morteza 9fa55cc6b5 perms
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-19 11:33:05 +03:30
morteza d1f85d78f4 add payment
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-19 10:18:33 +03:30
40 changed files with 1314 additions and 56 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
}'
+11
View File
@@ -0,0 +1,11 @@
{
"folders": [
{
"path": ".",
},
{
"path": "../dmenu-admin",
},
],
"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 { IconsModule } from './modules/icons/icons.module';
import { SliderModule } from './modules/slider/slider.module';
import { AiModule } from './modules/ai/ai.module';
import { CacheModule } from '@nestjs/cache-manager';
import { cacheConfig } from './config/cache.config';
@@ -61,6 +62,7 @@ import { cacheConfig } from './config/cache.config';
InventoryModule,
IconsModule,
SliderModule,
AiModule,
],
controllers: [],
providers: [],
+9
View File
@@ -600,6 +600,9 @@ export const enum SettingMessageEnum {
export const enum AiMessage {
NO_RESPONSE_FROM_AI_MODEL = 'خطا در دریافت پاسخ از مدل AI',
LLM_SERVICE_ERROR = 'سرویس هوش مصنوعی موقتاً در دسترس نیست',
INSUFFICIENT_CREDIT = 'اعتبار کیف پول رستوران برای استفاده از هوش مصنوعی کافی نیست',
AI_CHAT_DISABLED = 'چت هوش مصنوعی برای این رستوران فعال نیست',
MESSAGE_NOT_FOUND_OR_ACCESS_DENIED = 'ایمیل یافت نشد یا دسترسی آن را ندارید',
EMAIL_CONTENT_IS_EMPTY_OR_COULD_NOT_BE_EXTRACTED = 'محتوای ایمیل خالی است یا قابل استخراج نیست',
DESCRIPTION_IS_REQUIRED = 'شرح و هدف قالب الزامی است',
@@ -710,6 +713,12 @@ export const enum OrderMessage {
REFUND_AMOUNT_EXCEEDS_PAID = 'مبلغ بازگشت وجه بیشتر از مبلغ پرداخت‌شده است',
REFUND_AMOUNT_EXCEEDS_OVERPAYMENT = 'مبلغ بازگشت وجه بیشتر از مانده منفی سفارش است',
NO_PAID_PAYMENTS_TO_REFUND = 'پرداختی برای بازگشت وجه یافت نشد',
ADD_PAYMENT_NOT_ALLOWED = 'امکان افزودن پرداخت برای این سفارش وجود ندارد',
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 {
+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,
CartItemService,
],
exports: [CartService],
exports: [CartService, CartValidationService],
})
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(
restaurantId: string,
latitude?: number | null,
longitude?: number | null,
): 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) {
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
const rings = serviceArea.coordinates;
if (!rings || rings.length === 0 || !Array.isArray(rings[0])) return;
const ring = rings[0];
const ring = restaurant.serviceArea!.coordinates[0];
const point: [number, number] = [Number(longitude), Number(latitude)];
// Ensure polygon has the correct tuple typing ([lng, lat] pairs)
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)) {
@@ -177,6 +177,19 @@ export class NotificationsController {
return { smsCount };
}
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Post('admin/notifications/test-notis')
@ApiOperation({ summary: 'Send a test in-app notification (socket) to the current admin' })
async createTestNotis(@RestId() restaurantId: string, @AdminId() adminId: string) {
const notification = await this.notificationService.sendTestInAppNotification(adminId, restaurantId);
return {
message: 'Test in-app notification queued',
notification,
};
}
// super admin endpoints
@UseGuards(SuperAdminAuthGuard)
@@ -37,10 +37,19 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco
) {}
async handleConnection(client: Socket) {
this.logger.log(
`[WS] connection attempt socketId=${client.id} transport=${client.conn.transport.name} ` +
`hasAuthToken=${Boolean(client.handshake.auth?.token || client.handshake.auth?.authorization)} ` +
`hasQueryToken=${Boolean(client.handshake.query?.token || client.handshake.query?.authorization)} ` +
`hasHeaderAuth=${Boolean(client.handshake.headers.authorization)}`,
);
try {
await this.authenticateConnection(client);
const authenticatedClient = client as AuthenticatedSocket;
this.logger.log(`Admin connected: ${authenticatedClient.adminId}`);
this.logger.log(
`[WS] connected socketId=${client.id} adminId=${authenticatedClient.adminId} restId=${authenticatedClient.restId}`,
);
this.handleJoinRoom(authenticatedClient);
// Start ping interval for this client
@@ -51,7 +60,7 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco
// this.pingIntervals.set(client.id, intervalId);
} catch (error) {
this.logger.error(
`Connection authentication failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
`[WS] connection auth failed socketId=${client.id}: ${error instanceof Error ? error.message : 'Unknown error'}`,
);
void client.emit('error', { message: 'Authentication failed' });
client.disconnect();
@@ -59,8 +68,11 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco
}
handleDisconnect(client: Socket) {
this.logger.log(`Client disconnected: ${client.id}`);
this.handleLeaveRoom(client);
const authenticatedClient = client as AuthenticatedSocket;
this.logger.log(
`[WS] disconnect socketId=${client.id} adminId=${authenticatedClient.adminId ?? 'n/a'} restId=${authenticatedClient.restId ?? 'n/a'}`,
);
this.handleLeaveRoom(authenticatedClient);
// Clear ping interval for this client
// const intervalId = this.pingIntervals.get(client.id);
@@ -72,13 +84,41 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco
sendInAppNotification(repipient: { adminId: string; restaurantId: string }, payload: IInAppNotificationPayload) {
const room = this.getRoom(repipient.adminId, repipient.restaurantId);
const adapter = this.server.sockets?.adapter ?? this.server.adapter;
const socketsMap = this.server.sockets?.sockets ?? this.server.sockets;
const socketsInRoom = adapter?.rooms?.get(room);
const roomSize = socketsInRoom?.size ?? 0;
const connectedSocketIds =
socketsMap instanceof Map ? [...socketsMap.keys()] : [...(Object.keys(socketsMap ?? {}) as string[])];
const allRooms = adapter?.rooms ? [...adapter.rooms.keys()] : [];
this.logger.log(
`[WS] emit start room=${room} adminId=${repipient.adminId} restaurantId=${repipient.restaurantId} ` +
`notificationId=${payload.notificationId} subject=${payload.subject} roomSize=${roomSize} ` +
`namespaceConnected=${connectedSocketIds.length}`,
);
if (roomSize === 0) {
this.logger.warn(
`[WS] emit to EMPTY room=${room} — no client joined this room. ` +
`connectedSockets=[${connectedSocketIds.join(', ') || 'none'}] ` +
`allRooms=[${allRooms.join(', ') || 'none'}]`,
);
} else {
this.logger.log(`[WS] room members socketIds=[${[...(socketsInRoom ?? [])].join(', ')}]`);
}
this.logger.log(`Sending in app notification to admin: ${repipient.adminId}`);
this.server.to(room).emit('notifications', payload, async () => {
this.logger.log(
`[WS] ACK received room=${room} notificationId=${payload.notificationId} (client acknowledged)`,
);
// 3. ACK (delivered)
// await this.notificationService.markAsDelivered(payload.notificationId);
});
this.logger.log(`In app notification sent to admin: ${repipient.adminId}`);
this.logger.log(
`[WS] emit queued event=notifications room=${room} roomSize=${roomSize} notificationId=${payload.notificationId}`,
);
}
private getRoom(adminId: string, restaurantId: string) {
@@ -86,16 +126,30 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco
}
handleLeaveRoom(client: AuthenticatedSocket) {
const room = this.getRoom(client.adminId!, client.restId!);
if (!client.adminId || !client.restId) {
this.logger.warn(`[WS] leave skipped socketId=${client.id} — missing adminId/restId`);
return;
}
const room = this.getRoom(client.adminId, client.restId);
void client.leave(room);
this.logger.log(`Admin ${client.adminId} (Client ${client.id}) left room: ${room}`);
this.logger.log(`[WS] left room=${room} adminId=${client.adminId} socketId=${client.id}`);
void client.emit('left', { room, message: 'Successfully Admin left room' });
}
handleJoinRoom(client: AuthenticatedSocket) {
const room = this.getRoom(client.adminId!, client.restId!);
if (!client.adminId || !client.restId) {
this.logger.warn(`[WS] join skipped socketId=${client.id} — missing adminId/restId`);
return;
}
const room = this.getRoom(client.adminId, client.restId);
void client.join(room);
this.logger.log(`Admin ${client.adminId} (Client ${client.id}) joined room: ${room}`);
const adapter = this.server.sockets?.adapter ?? this.server.adapter;
const roomSize = adapter?.rooms?.get(room)?.size ?? 0;
this.logger.log(
`[WS] joined room=${room} adminId=${client.adminId} restId=${client.restId} socketId=${client.id} roomSize=${roomSize}`,
);
void client.emit('joined', { room, message: 'Successfully Admin joined room' });
}
@@ -2,9 +2,7 @@ import { Processor, WorkerHost, OnWorkerEvent } from '@nestjs/bullmq';
import { Logger } from '@nestjs/common';
import { Job } from 'bullmq';
import { CreateRequestContext } from '@mikro-orm/core';
import { InjectRepository } from '@mikro-orm/nestjs';
import { EntityManager } from '@mikro-orm/postgresql';
import { Notification } from '../entities/notification.entity';
import { InAppNotificationQueueJob } from '../interfaces/jobs-queue.interface';
import { NotificationQueueNameEnum } from '../constants/queue';
import { NotificationsGateway } from '../notifications.gateway';
@@ -14,7 +12,6 @@ export class InAppProcessor extends WorkerHost {
private readonly logger = new Logger(InAppProcessor.name);
constructor(
@InjectRepository(Notification)
private readonly em: EntityManager,
private readonly notificationsGateway: NotificationsGateway,
) {
@@ -25,10 +22,18 @@ export class InAppProcessor extends WorkerHost {
async process(job: Job<InAppNotificationQueueJob>): Promise<void> {
const { recipient, subject, body, notificationId } = job.data;
this.logger.log(`Processing InApp notification - Recipient: ${JSON.stringify(recipient)}, subject: ${subject}`);
this.logger.log(
`[InAppProcessor] processing jobId=${job.id} attempt=${job.attemptsMade + 1} ` +
`recipient=${JSON.stringify(recipient)} subject=${subject} notificationId=${notificationId}`,
);
if (!recipient.adminId) {
this.logger.warn(`Skipping in-app notification job ${job.id}: missing adminId`);
if (!recipient?.adminId) {
this.logger.warn(`[InAppProcessor] skipping job ${job.id}: missing adminId`);
return;
}
if (!recipient.restaurantId) {
this.logger.warn(`[InAppProcessor] skipping job ${job.id}: missing restaurantId`);
return;
}
@@ -38,20 +43,30 @@ export class InAppProcessor extends WorkerHost {
{ subject, body, notificationId },
);
this.logger.log(`InApp notification sent successfully to ${recipient.adminId}`);
this.logger.log(
`[InAppProcessor] gateway emit done for admin=${recipient.adminId} restaurant=${recipient.restaurantId}`,
);
} catch (error) {
this.logger.error(`Error processing InApp notification job ${job.id}:`, error);
this.logger.error(`[InAppProcessor] error processing job ${job.id}:`, error);
throw error;
}
}
@OnWorkerEvent('active')
onActive(job: Job) {
this.logger.log(`[InAppProcessor] job ${job.id} became active`);
}
@OnWorkerEvent('completed')
onCompleted(job: Job) {
this.logger.log(`Notification job ${job.id} completed`);
this.logger.log(`[InAppProcessor] job ${job.id} completed`);
}
@OnWorkerEvent('failed')
onFailed(job: Job, error: Error) {
this.logger.error(`Notification job ${job.id} failed:`, error);
this.logger.error(
`[InAppProcessor] job ${job.id} failed after ${job.attemptsMade} attempt(s): ${error.message}`,
error.stack,
);
}
}
@@ -119,6 +119,12 @@ export class NotificationQueueService {
}
async addBulkInAppNotifications(jobs: InAppNotificationQueueJob[]): Promise<void> {
try {
this.logger.log(
`[addBulkInAppNotifications] queueing ${jobs.length} job(s): ${jobs
.map(j => `admin=${j.recipient.adminId} rest=${j.recipient.restaurantId} notif=${j.notificationId}`)
.join(' | ')}`,
);
const queueJobs = jobs.map(job => ({
name: 'in-app-notification',
data: job,
@@ -132,10 +138,12 @@ export class NotificationQueueService {
},
}));
await this.inAppQueue.addBulk(queueJobs);
this.logger.log(`Added ${jobs.length} InApp notification jobs to queue`);
const added = await this.inAppQueue.addBulk(queueJobs);
this.logger.log(
`[addBulkInAppNotifications] added ${added.length} job(s) to queue "${NotificationQueueNameEnum.IN_APP}": ids=${added.map(j => j.id).join(', ')}`,
);
} catch (error) {
this.logger.error(`Failed to add bulk notifications to queue:`, error);
this.logger.error(`[addBulkInAppNotifications] failed to add jobs to queue:`, error);
throw error;
}
}
@@ -24,6 +24,11 @@ export class NotificationService {
async sendNotification(params: NotifRequest): Promise<Notification[]> {
const { recipients, message, metadata, restaurantId } = params;
this.logger.log(
`[sendNotification] start restaurant=${restaurantId} title=${message.title} recipients=${recipients.length} ` +
`admins=${recipients.filter(r => 'adminId' in r).length} users=${recipients.filter(r => 'userId' in r).length}`,
);
// create Database notifications
const notifications = await this.createAdminBulkNotifications(
recipients.map(recipient => ({
@@ -35,18 +40,35 @@ export class NotificationService {
})),
);
this.logger.log(`[sendNotification] created ${notifications.length} DB notification(s)`);
// get admin prefrences
const preference = await this.preferenceService.findByRestaurantAndType(restaurantId, message.title);
if (preference?.channels?.length === 0) {
this.logger.log(
`[sendNotification] preference=${preference ? `id=${preference.id} channels=${JSON.stringify(preference.channels)}` : 'NOT FOUND'}`,
);
if (!preference) {
this.logger.warn(
`[sendNotification] skipping channels — no preference for restaurant ${restaurantId}, title ${message.title}`,
);
return notifications;
}
if (preference.channels?.length === 0) {
this.logger.warn(`Notification type is NONE for restaurant ${restaurantId}, title ${message.title}`);
return notifications;
}
// send in app notification (admins only — user records must not emit to admin socket rooms)
if (preference?.channels?.includes(NotifChannelEnum.IN_APP)) {
if (preference.channels?.includes(NotifChannelEnum.IN_APP)) {
this.logger.log(`[sendNotification] IN_APP channel enabled, building jobs`);
const inAppJobs = recipients.flatMap((recipient, index) => {
if (!('adminId' in recipient) || !recipient.adminId) {
this.logger.debug(
`[sendNotification] skipping recipient index=${index} (not admin): ${JSON.stringify(recipient)}`,
);
return [];
}
@@ -61,16 +83,23 @@ export class NotificationService {
});
if (inAppJobs.length > 0) {
this.logger.log(
`[sendNotification] queueing ${inAppJobs.length} in-app job(s): ${inAppJobs.map(j => j.recipient.adminId).join(', ')}`,
);
await this.queueService.addBulkInAppNotifications(inAppJobs);
} else {
this.logger.warn(
`No admin recipients for in-app notification: restaurant ${restaurantId}, title ${message.title}`,
);
}
} else {
this.logger.warn(
`[sendNotification] IN_APP channel NOT enabled for restaurant ${restaurantId}, title ${message.title}, channels=${JSON.stringify(preference.channels)}`,
);
}
// add sms notifications to queue
if (preference?.channels?.includes(NotifChannelEnum.SMS)) {
if (preference.channels?.includes(NotifChannelEnum.SMS)) {
await this.queueService.addBulkSmsNotifications(
recipients.map(recipient => ({
recipient,
@@ -293,4 +322,40 @@ export class NotificationService {
async getSmsCountByRestaurantId(restaurantId: string): Promise<number> {
return this.smsLogRepository.getSmsCountByRestaurantId(restaurantId);
}
async sendTestInAppNotification(adminId: string, restaurantId: string): Promise<Notification> {
const title = NotifTitleEnum.ORDER_CREATED;
const content = 'Test in-app notification';
this.logger.log(`[sendTestInAppNotification] start adminId=${adminId} restaurantId=${restaurantId}`);
const [notification] = await this.createAdminBulkNotifications([
{
restaurantId,
title,
content,
adminId,
userId: null,
},
]);
this.logger.log(
`[sendTestInAppNotification] created notification id=${notification.id}, queueing in-app job`,
);
await this.queueService.addBulkInAppNotifications([
{
recipient: { adminId, restaurantId },
subject: title,
body: content,
notificationId: notification.id,
},
]);
this.logger.log(
`[sendTestInAppNotification] queued successfully admin=${adminId} restaurant=${restaurantId} notificationId=${notification.id}`,
);
return notification;
}
}
@@ -18,6 +18,8 @@ import { AdminUpdateOrderItemDto } from '../dto/admin-update-order-item.dto';
import { FoodOrderReportDto } from '../dto/food-order-report.dto';
import { DailyOrderReportDto } from '../dto/daily-order-report.dto';
import { AdminRefundOrderDto } from '../dto/admin-refund-order.dto';
import { AdminAddPaymentDto } from '../dto/admin-add-payment.dto';
import { OrderMessage } from 'src/common/enums/message.enum';
@ApiTags('orders')
@ApiBearerAuth()
@@ -155,6 +157,20 @@ export class OrdersController {
return this.ordersService.removeOrderItem(orderId, restId, itemId);
}
@UseGuards(AdminAuthGuard)
@Permissions(Permission.MANAGE_ORDERS)
@Post('admin/orders/:orderId/payments')
@ApiOperation({ summary: 'Add a cash payment to an existing order' })
@ApiParam({ name: 'orderId', description: 'Order ID' })
@ApiBody({ type: AdminAddPaymentDto })
addPayment(
@Param('orderId') orderId: string,
@Body() dto: AdminAddPaymentDto,
@RestId() restId: string,
) {
return this.ordersService.addPayment(orderId, restId, dto);
}
@UseGuards(AdminAuthGuard)
@Permissions(Permission.MANAGE_ORDERS)
@Post('admin/orders/:orderId/refund')
@@ -169,6 +185,16 @@ export class OrdersController {
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)
@Permissions(Permission.MANAGE_ORDERS)
@Patch('admin/orders/:orderId/:status')
@@ -0,0 +1,16 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsNumber, IsOptional, IsString, Min } from 'class-validator';
export class AdminAddPaymentDto {
@ApiProperty({ description: 'Payment amount', minimum: 1 })
@Type(() => Number)
@IsNumber()
@Min(1)
amount!: number;
@ApiPropertyOptional({ description: 'Payment description' })
@IsOptional()
@IsString()
description?: string;
}
+103 -1
View File
@@ -1,4 +1,5 @@
import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { EntityManager } from '@mikro-orm/postgresql';
import { Order } from '../entities/order.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 { Food } from '../../foods/entities/food.entity';
import { CartService } from '../../cart/providers/cart.service';
import { CartValidationService } from '../../cart/providers/cart-validation.service';
import { OrderStatus, OrderUserAddress, OrderCarAddress } from '../interface/order.interface';
import { PaymentMethodEnum, PaymentStatusEnum } from '../../payments/interface/payment';
import { Cart } from '../../cart/interfaces/cart.interface';
@@ -29,12 +31,14 @@ import { AdminUpdateOrderFeesDto } from '../dto/admin-update-order-fees.dto';
import { AdminAddOrderItemDto } from '../dto/admin-add-order-item.dto';
import { AdminUpdateOrderItemDto } from '../dto/admin-update-order-item.dto';
import { AdminRefundOrderDto } from '../dto/admin-refund-order.dto';
import { AdminAddPaymentDto } from '../dto/admin-add-payment.dto';
import { FoodOrderReportDto, FoodOrderReportSortBy } from '../dto/food-order-report.dto';
import { DailyOrderReportDto, DailyOrderReportSortBy } from '../dto/daily-order-report.dto';
import { CashShiftsService } from './cash-shifts.service';
import { WalletTransaction } from 'src/modules/users/entities/wallet-transaction.entity';
import { WalletTransactionReason, WalletTransactionType } from 'src/modules/users/interface/wallet';
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 ValidatedCartForOrder = {
@@ -64,11 +68,14 @@ export class OrdersService {
constructor(
private readonly em: EntityManager,
private readonly cartService: CartService,
private readonly cartValidationService: CartValidationService,
private readonly orderRepository: OrderRepository,
private readonly paymentsService: PaymentsService,
private readonly inventoryService: InventoryService,
private readonly eventEmitter: EventEmitter2,
private readonly cashShiftsService: CashShiftsService,
private readonly smsQueueService: SmsQueueService,
private readonly configService: ConfigService,
) { }
async checkout(userId: string, restaurantId: string) {
@@ -179,6 +186,14 @@ export class OrdersService {
? await this.resolveUserAddressForOrder(user!, dto.addressId!)
: null;
if (userAddress) {
await this.cartValidationService.assertAddressInsideServiceArea(
restaurantId,
userAddress.latitude,
userAddress.longitude,
);
}
const orderItemsData = await this.buildOrderItemsDataFromDto(dto.items, restaurantId);
// Validate admin discount against item totals (not persisted — DB recomputes order money fields)
@@ -433,6 +448,14 @@ export class OrdersService {
this.assertMeetsMinOrderForDelivery(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);
this.assertPaymentMethodEnabled(paymentMethod);
this.assertCreditCardPaymentDetails(paymentMethod, cart.paymentDesc, cart.attachments);
@@ -545,6 +568,52 @@ export class OrdersService {
return order;
}
async addPayment(orderId: string, restId: string, dto: AdminAddPaymentDto): Promise<Order> {
return this.em.transactional(async em => {
const order = await em.findOne(
Order,
{ id: orderId, restaurant: { id: restId } },
{ populate: ['payments', 'user', 'paymentMethod', 'deliveryMethod', 'items', 'items.food'] },
);
if (!order) {
throw new NotFoundException(OrderMessage.NOT_FOUND);
}
if (order.status === OrderStatus.CANCELED) {
throw new BadRequestException(OrderMessage.ADD_PAYMENT_NOT_ALLOWED);
}
const balance = Number(order.balance ?? 0);
if (balance <= 0) {
throw new BadRequestException(OrderMessage.ADD_PAYMENT_NO_BALANCE);
}
const amount = Number(dto.amount);
if (!amount || amount <= 0) {
throw new BadRequestException(PaymentMessage.AMOUNT_MUST_BE_GREATER_THAN_ZERO);
}
const payment = em.create(Payment, {
order,
amount,
method: PaymentMethodEnum.Cash,
gateway: null,
status: PaymentStatusEnum.Paid,
paidAt: new Date(),
description: dto.description?.trim() || null,
});
em.persist(payment);
await em.flush();
return em.findOneOrFail(
Order,
{ id: orderId },
{ populate: ['payments', 'user', 'paymentMethod', 'deliveryMethod', 'items', 'items.food'] },
);
});
}
async refundOrder(orderId: string, restId: string, dto: AdminRefundOrderDto): Promise<Order> {
return this.em.transactional(async em => {
const order = await em.findOne(
@@ -735,12 +804,45 @@ export class OrdersService {
const order = await this.em.findOne(
Order,
{ id: orderId, restaurant: { id: restId } },
{ populate: ['paymentMethod', 'deliveryMethod', 'user'] },
{ populate: ['paymentMethod', 'deliveryMethod', 'user', 'restaurant'] },
);
if (!order) throw new NotFoundException(OrderMessage.NOT_FOUND);
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) {
if (!cart.items || cart.items.length === 0) {
throw new BadRequestException(OrderMessage.CART_EMPTY);
@@ -52,4 +52,9 @@ export class CreateRestaurantDto {
@IsBoolean()
isActive?: boolean;
@ApiPropertyOptional({ example: false, description: 'فعال بودن چت هوش مصنوعی', default: false })
@IsOptional()
@IsBoolean()
enableAiChat?: boolean;
}
@@ -51,4 +51,9 @@ export class SetupRestaurantDto {
@IsBoolean()
isActive?: boolean;
@ApiPropertyOptional({ example: false, description: 'فعال بودن چت هوش مصنوعی', default: false })
@IsOptional()
@IsBoolean()
enableAiChat?: boolean;
}
@@ -40,6 +40,9 @@ export class Restaurant extends BaseEntity {
@Property({ default: true })
isActive: boolean = true;
@Property({ default: false })
enableAiChat: boolean = false;
@Property({ nullable: true })
establishedYear?: number;
@@ -7,4 +7,5 @@ export enum RestaurantCreditTransactionReason {
SMS_SEND = 'sms_send',
DEPOSIT = 'deposit',
ADJUSTMENT = 'adjustment',
AI_CHAT = 'ai_chat',
}
@@ -73,6 +73,7 @@ export class RestaurantsService {
establishedYear: dto.establishedYear,
phones: dto.phone ? [dto.phone] : undefined,
isActive: true,
enableAiChat: dto.enableAiChat ?? false,
plan: dto.plan,
domain: `https://dmenu.danakcorp.com/${slug}`,
subscriptionId: dto.subscriptionId,
@@ -33,15 +33,11 @@ export class RolesController {
@Get('admin/roles/permissions')
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Get all permissions that the admin has' })
async findAllPermissions(@AdminId() adminId: string, @RestId() restId: string) {
const adminPermissionNames = await this.permissionService.getAdminFullPermissions(adminId, restId);
return adminPermissionNames;
@ApiOperation({ summary: 'Get all permissions ' })
async findAllPermissions() {
return this.permissionService.getPermissions();
}
@Post('admin/roles')
@UseGuards(AdminAuthGuard)
@Permissions(Permission.MANAGE_ROLES)
@@ -41,6 +41,10 @@ export class PermissionsService {
return permissionNames;
}
async getPermissions() {
return this.permissionRepository.findAll()
}
/**
* Refresh admin permissions in Redis (used after login or role changes)
*/
@@ -215,6 +215,17 @@ export class UsersController {
return this.userService.adminUpdateUser(userId, restId, dto);
}
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Permissions(Permission.MANAGE_USERS)
@ApiHeader(API_HEADER_SLUG)
@ApiOperation({ summary: 'Get user groups for a customer (admin)' })
@ApiParam({ name: 'userId', description: 'User ID' })
@Get('admin/users/:userId/groups')
async adminGetUserGroups(@Param('userId') userId: string, @RestId() restId: string) {
return this.userService.getUserGroups(userId, restId);
}
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
@Permissions(Permission.MANAGE_USERS)
+12 -1
View File
@@ -1,5 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsMobilePhone, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { IsArray, IsBoolean, IsMobilePhone, IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class CreateUserDto {
@ApiProperty({ description: "User's phone number", example: '09121234567' })
@@ -37,4 +37,15 @@ export class CreateUserDto {
@IsOptional()
@IsString()
avatarUrl?: string;
@ApiPropertyOptional({
description: 'User group IDs to assign the customer to',
type: [String],
example: ['01HXABCDEFGHIJKLMNOPQRSTUV'],
})
@IsOptional()
@IsArray()
@IsString({ each: true })
@IsNotEmpty({ each: true })
groupIds?: string[];
}
+12 -1
View File
@@ -1,4 +1,4 @@
import { IsString, IsOptional, IsBoolean } from 'class-validator';
import { IsString, IsOptional, IsBoolean, IsArray, IsNotEmpty } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class UpdateUserDto {
@@ -32,4 +32,15 @@ export class UpdateUserDto {
@IsOptional()
@IsString()
avatarUrl?: string;
@ApiPropertyOptional({
description: 'User group IDs to assign the customer to (replaces current groups)',
type: [String],
example: ['01HXABCDEFGHIJKLMNOPQRSTUV'],
})
@IsOptional()
@IsArray()
@IsString({ each: true })
@IsNotEmpty({ each: true })
groupIds?: string[];
}
@@ -138,4 +138,42 @@ export class UserGroupService {
group.count = Math.max(0, group.count - 1);
return group;
}
async assignUserToGroups(restId: string, userId: string, groupIds: string[]): Promise<void> {
const uniqueGroupIds = [...new Set(groupIds)];
for (const groupId of uniqueGroupIds) {
await this.addUsers(restId, groupId, { userIds: [userId] });
}
}
async getGroupsForUser(restId: string, userId: string): Promise<UserGroup[]> {
const links = await this.userGroupUserRepository.find(
{
user: { id: userId },
userGroup: { restaurant: { id: restId }, deletedAt: null },
},
{ populate: ['userGroup'], orderBy: { createdAt: 'asc' } },
);
return links.map(link => link.userGroup);
}
async syncUserGroups(restId: string, userId: string, groupIds: string[]): Promise<void> {
const targetGroupIds = [...new Set(groupIds)];
const currentGroups = await this.getGroupsForUser(restId, userId);
const currentGroupIds = new Set(currentGroups.map(group => group.id));
const targetGroupIdsSet = new Set(targetGroupIds);
for (const group of currentGroups) {
if (!targetGroupIdsSet.has(group.id)) {
await this.removeUser(restId, group.id, userId);
}
}
for (const groupId of targetGroupIds) {
if (!currentGroupIds.has(groupId)) {
await this.addUsers(restId, groupId, { userIds: [userId] });
}
}
}
}
+33 -7
View File
@@ -25,6 +25,7 @@ import { UserRestaurant } from '../entities/user-restuarant.entity';
import { ImportUsersResult } from '../interfaces/import-users.interface';
import { assertExcelMimeType, parseUsersExcel } from '../utils/import-users-excel.util';
import { AuthMessage, UserImportMessage } from 'src/common/enums/message.enum';
import { UserGroupService } from './user-group.service';
@Injectable()
export class UserService {
@@ -35,6 +36,7 @@ export class UserService {
private readonly walletService: WalletService,
private readonly em: EntityManager,
private readonly userRestaurantRepository: UserRestaurantRepository,
private readonly userGroupService: UserGroupService,
) { }
async findOrCreateByPhone(phone: string): Promise<User> {
@@ -189,6 +191,10 @@ export class UserService {
this.em.persist(userRestaurant);
await this.em.flush();
if (dto.groupIds?.length) {
await this.userGroupService.assignUserToGroups(restId, user.id, dto.groupIds);
}
await this.em.populate(userRestaurant, ['user']);
return userRestaurant;
}
@@ -202,7 +208,26 @@ export class UserService {
throw new NotFoundException(`User with ID ${userId} not found.`);
}
return this.updateUser(userId, restId, dto);
const { groupIds, ...userDto } = dto;
const user = await this.updateUser(userId, restId, userDto);
if (groupIds !== undefined) {
await this.userGroupService.syncUserGroups(restId, userId, groupIds);
}
return user;
}
async getUserGroups(userId: string, restId: string) {
const userRestaurant = await this.userRestaurantRepository.findOne({
user: { id: userId },
restaurant: { id: restId },
});
if (!userRestaurant) {
throw new NotFoundException(`User with ID ${userId} not found.`);
}
return this.userGroupService.getGroupsForUser(restId, userId);
}
async updateUser(userId: string, restId: string, dto: UpdateUserDto): Promise<User> {
@@ -211,15 +236,16 @@ export class UserService {
throw new NotFoundException(`User with ID ${userId} not found.`);
}
const { groupIds: _groupIds, ...userFields } = dto;
// Normalize date strings into Date objects if provided
const assignData: Partial<User> = { ...dto } as Partial<User>;
if (dto.birthDate) {
assignData.birthDate = new Date(dto.birthDate);
const assignData: Partial<User> = { ...userFields } as Partial<User>;
if (userFields.birthDate) {
assignData.birthDate = new Date(userFields.birthDate);
}
if (dto.marriageDate) {
assignData.marriageDate = new Date(dto.marriageDate);
if (userFields.marriageDate) {
assignData.marriageDate = new Date(userFields.marriageDate);
}
// get restuarant
this.em.assign(user, assignData);
await this.em.flush();
+1
View File
@@ -14,6 +14,7 @@ export class RestaurantsSeeder {
subscriptionStartDate: new Date('2026-01-01'),
subscriptionEndDate: new Date('2026-01-07'),
isActive: true,
enableAiChat: false,
});
em.persist(restaurant);
}