Compare commits

..

6 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
15 changed files with 276 additions and 28 deletions
+3
View File
@@ -3,6 +3,9 @@
{
"path": ".",
},
{
"path": "../dmenu-admin",
},
],
"settings": {},
}
@@ -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";`);
}
}
+5
View File
@@ -602,6 +602,7 @@ 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 = 'شرح و هدف قالب الزامی است',
@@ -714,6 +715,10 @@ export const enum OrderMessage {
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 {
+4 -1
View File
@@ -21,7 +21,10 @@ export class AiController {
@UseGuards(OptionalAuthGuard)
@Post('public/ai/chat')
@ApiOperation({ summary: 'Ask the restaurant AI assistant about the menu' })
@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',
@@ -18,8 +18,20 @@ export interface AiChatCompletionResponse {
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;
+126 -13
View File
@@ -16,7 +16,11 @@ import { GenerateFoodDescriptionDto } from '../dto/generate-food-description.dto
import { GenerateFoodContentDto } from '../dto/generate-food-content.dto';
import { AiChat } from '../entities/ai-chat.entity';
import { AiChatRepository } from '../repositories/ai-chat.repository';
import { AiChatCompletionResponse, AiChatResult } from '../interface/ai-chat.interface';
import {
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';
@@ -46,6 +50,11 @@ export class AiService {
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);
@@ -53,7 +62,10 @@ export class AiService {
const foods = await this.em.find(
Food,
{ restaurant: restaurant.id, isActive: true },
{ fields: ['title', 'desc', 'content'], orderBy: { order: 'asc' } },
{
fields: ['id', 'title', 'desc', 'content', 'price', 'images', 'discount', 'isSpecialOffer'],
orderBy: { order: 'asc' },
},
);
const menuContext = this.buildMenuContext(foods);
@@ -61,8 +73,13 @@ export class AiService {
systemPrompt: [
'تو دستیار هوشمند منوی رستوران هستی.',
'فقط بر اساس منوی زیر به سوالات کاربر پاسخ بده.',
'یک توضیح کوتاه دو خطی هم اگه لازم بود اضافه کن',
'اگر اطلاعات کافی در منو نیست، صادقانه بگو.',
'خیلی کوتاه جواب بده',
'وقتی غذایی پیشنهاد می‌دهی، فقط از شناسه‌های (id) موجود در منو استفاده کن.',
'پاسخ را فقط و فقط به صورت JSON معتبر برگردان، بدون متن اضافه یا markdown:',
'{"answer":"پاسخ کوتاه فارسی","foodIds":["id1","id2"]}',
'اگر غذایی پیشنهاد نمی‌کنی، foodIds را آرایه خالی بگذار.',
'هیچ وقت id غذا را داخل متن قرار نده.',
'',
'منوی رستوران:',
menuContext,
@@ -70,11 +87,16 @@ export class AiService {
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,
});
}
@@ -136,20 +158,22 @@ export class AiService {
user?: User | null;
question: string;
aiResponse: AiChatCompletionResponse;
answerOverride?: string;
foods?: AiChatOfferedFood[];
}): Promise<AiChatResult> {
const { restaurant, user = null, question, aiResponse } = params;
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 = choice?.message?.content?.trim();
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)}`,
`reasoning_tokens=${choice?.message?.reasoning_content?.length ?? 0} ` +
`usage=${JSON.stringify(aiResponse.usage ?? null)}`,
);
throw new ServiceUnavailableException(AiMessage.NO_RESPONSE_FROM_AI_MODEL);
}
@@ -188,6 +212,7 @@ export class AiService {
return {
answer,
foods,
consumedTokens,
cost,
promptTokens,
@@ -212,23 +237,111 @@ export class AiService {
return restaurant;
}
private buildMenuContext(foods: Array<Pick<Food, 'title' | 'desc' | 'content'>>): string {
private buildMenuContext(
foods: Array<Pick<Food, 'id' | 'title' >>,
): string {
if (!foods.length) {
return 'منوی رستوران در حال حاضر خالی است.';
}
return foods
.map((food, index) => {
const contents = food.content?.length ? food.content.join('، ') : 'نامشخص';
return [
`${index + 1}. نام: ${food.title ?? 'بدون نام'}`,
`توضیحات: ${food.desc ?? 'ندارد'}`,
`محتویات: ${contents}`,
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;
+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)) {
@@ -19,6 +19,7 @@ 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()
@@ -184,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')
+56 -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';
@@ -36,6 +38,7 @@ 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 = {
@@ -65,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) {
@@ -180,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)
@@ -434,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);
@@ -782,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;
@@ -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,
+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);
}