diff --git a/src/common/enums/message.enum.ts b/src/common/enums/message.enum.ts index 1e9899f..32ced4e 100755 --- a/src/common/enums/message.enum.ts +++ b/src/common/enums/message.enum.ts @@ -714,6 +714,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 { diff --git a/src/modules/ai/controllers/ai.controller.ts b/src/modules/ai/controllers/ai.controller.ts index 475e8b6..75a811a 100644 --- a/src/modules/ai/controllers/ai.controller.ts +++ b/src/modules/ai/controllers/ai.controller.ts @@ -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', diff --git a/src/modules/ai/interface/ai-chat.interface.ts b/src/modules/ai/interface/ai-chat.interface.ts index 3ec2fed..0ddc71e 100644 --- a/src/modules/ai/interface/ai-chat.interface.ts +++ b/src/modules/ai/interface/ai-chat.interface.ts @@ -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; diff --git a/src/modules/ai/providers/ai.service.ts b/src/modules/ai/providers/ai.service.ts index 27f949a..cc4cfbd 100644 --- a/src/modules/ai/providers/ai.service.ts +++ b/src/modules/ai/providers/ai.service.ts @@ -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'; @@ -53,7 +57,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); @@ -62,7 +69,11 @@ export class AiService { 'تو دستیار هوشمند منوی رستوران هستی.', 'فقط بر اساس منوی زیر به سوالات کاربر پاسخ بده.', 'اگر اطلاعات کافی در منو نیست، صادقانه بگو.', - 'خیلی کوتاه جواب بده', + 'خیلی کوتاه جواب بده.', + 'وقتی غذایی پیشنهاد می‌دهی، فقط از شناسه‌های (id) موجود در منو استفاده کن.', + 'پاسخ را فقط و فقط به صورت JSON معتبر برگردان، بدون متن اضافه یا markdown:', + '{"answer":"پاسخ کوتاه فارسی","foodIds":["id1","id2"]}', + 'اگر غذایی پیشنهاد نمی‌کنی، foodIds را آرایه خالی بگذار.', '', 'منوی رستوران:', menuContext, @@ -70,11 +81,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,14 +152,16 @@ export class AiService { user?: User | null; question: string; aiResponse: AiChatCompletionResponse; + answerOverride?: string; + foods?: AiChatOfferedFood[]; }): Promise { - 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( @@ -188,6 +206,7 @@ export class AiService { return { answer, + foods, consumedTokens, cost, promptTokens, @@ -212,7 +231,9 @@ export class AiService { return restaurant; } - private buildMenuContext(foods: Array>): string { + private buildMenuContext( + foods: Array>, + ): string { if (!foods.length) { return 'منوی رستوران در حال حاضر خالی است.'; } @@ -221,14 +242,104 @@ export class AiService { .map((food, index) => { const contents = food.content?.length ? food.content.join('، ') : 'نامشخص'; return [ - `${index + 1}. نام: ${food.title ?? 'بدون نام'}`, + `${index + 1}. id: ${food.id}`, + `نام: ${food.title ?? 'بدون نام'}`, `توضیحات: ${food.desc ?? 'ندارد'}`, `محتویات: ${contents}`, + `قیمت: ${food.price ?? 'نامشخص'}`, ].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 + >, + ): AiChatOfferedFood[] { + if (!foodIds.length) { + return []; + } + + const foodMap = new Map(menuFoods.map((food) => [food.id, food])); + const seen = new Set(); + + return foodIds.reduce((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; diff --git a/src/modules/cart/cart.module.ts b/src/modules/cart/cart.module.ts index 0549845..6b4a4d1 100644 --- a/src/modules/cart/cart.module.ts +++ b/src/modules/cart/cart.module.ts @@ -48,6 +48,6 @@ import { WalletTransaction } from '../users/entities/wallet-transaction.entity'; CartCalculationService, CartItemService, ], - exports: [CartService], + exports: [CartService, CartValidationService], }) export class CartModule { } diff --git a/src/modules/cart/providers/cart-validation.service.ts b/src/modules/cart/providers/cart-validation.service.ts index 5d149a2..1db0e03 100644 --- a/src/modules/cart/providers/cart-validation.service.ts +++ b/src/modules/cart/providers/cart-validation.service.ts @@ -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 { + 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)) { diff --git a/src/modules/orders/controllers/orders.controller.ts b/src/modules/orders/controllers/orders.controller.ts index 4a13daa..ac7c3e1 100644 --- a/src/modules/orders/controllers/orders.controller.ts +++ b/src/modules/orders/controllers/orders.controller.ts @@ -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') diff --git a/src/modules/orders/providers/orders.service.ts b/src/modules/orders/providers/orders.service.ts index f3df8fa..ba87719 100644 --- a/src/modules/orders/providers/orders.service.ts +++ b/src/modules/orders/providers/orders.service.ts @@ -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); @@ -788,6 +810,38 @@ export class OrdersService { return order; } + async sendPleaseComeSms(orderId: string, restId: string): Promise { + 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('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 ?? '', + }, + }); + } + private assertCartHasItems(cart: Cart) { if (!cart.items || cart.items.length === 0) { throw new BadRequestException(OrderMessage.CART_EMPTY);