import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { EntityManager } from '@mikro-orm/postgresql'; import { Food } from '../../foods/entities/food.entity'; import { Restaurant } from '../../restaurants/entities/restaurant.entity'; import { UserAddress } from '../../users/entities/user-address.entity'; import { User } from '../../users/entities/user.entity'; import { PaymentMethod } from '../../payments/entities/payment-method.entity'; import { Delivery } from '../../delivery/entities/delivery.entity'; import { DeliveryMethodEnum } from '../../delivery/interface/delivery'; import { PointTransaction } from '../../users/entities/point-transaction.entity'; import { Order } from '../../orders/entities/order.entity'; import { OrderStatus } from '../../orders/interface/order.interface'; import { Cart } from '../interfaces/cart.interface'; import { GeographicUtils } from '../utils/geographic.utils'; import { MealType } from '../../foods/interface/food.interface'; import { CartMessage } from 'src/common/enums/message.enum'; import { WalletTransactionRepository } from 'src/modules/users/repositories/wallet-transaction.repository'; @Injectable() export class CartValidationService { constructor( private readonly em: EntityManager, private readonly walletTransactionRepository: WalletTransactionRepository, ) { } /** * Validate food exists, belongs to restaurant, has inventory, and check stock */ async validateAndGetFood(foodId: string, restaurantId: string, quantity: number): Promise { const food = await this.em.findOne(Food, { id: foodId }, { populate: ['restaurant', 'inventory'] }); if (!food) { throw new NotFoundException(CartMessage.FOOD_NOT_FOUND); } if (food.restaurant.id !== restaurantId) { throw new BadRequestException(CartMessage.FOOD_NOT_BELONGS_TO_RESTAURANT); } if (!food.inventory) { throw new BadRequestException(CartMessage.FOOD_NO_INVENTORY); } this.validateStock(food, quantity); return food; } /** * Validate stock availability for food */ validateStock(food: Food, quantity: number): void { const availableStock = food.inventory!.availableStock; if (availableStock < quantity) { throw new BadRequestException(CartMessage.INSUFFICIENT_STOCK + food.title); } } /** * Get user or throw if not found */ async getUserOrFail(userId: string): Promise { const user = await this.em.findOne(User, { id: userId }); if (!user) { throw new NotFoundException(CartMessage.USER_NOT_FOUND); } return user; } /** * Get user address or throw if not found */ async getUserAddressOrFail(addressId: string): Promise { const address = await this.em.findOne(UserAddress, { id: addressId }, { populate: ['user'] }); if (!address) { throw new NotFoundException(CartMessage.ADDRESS_NOT_FOUND); } return address; } /** * Get food or throw if not found */ async getFoodOrFail(foodId: string): Promise { const food = await this.em.findOne(Food, { id: foodId }); if (!food) { throw new NotFoundException(CartMessage.FOOD_NOT_FOUND); } return food; } /** * Get restaurant or throw if not found */ async getRestaurantOrFail(restaurantId: string): Promise { const restaurant = await this.em.findOne(Restaurant, { id: restaurantId }); if (!restaurant) { throw new NotFoundException(CartMessage.RESTAURANT_NOT_FOUND); } return restaurant; } /** * Validate address belongs to user */ validateAddressOwnership(address: UserAddress, userId: string): void { if (address.user.id !== userId) { throw new BadRequestException(CartMessage.ADDRESS_NOT_BELONGS_TO_USER); } } /** * Assert address is inside restaurant service area */ async assertAddressInsideServiceArea( restaurantId: string, latitude?: number | null, longitude?: number | null, ): Promise { 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 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], ); if (!GeographicUtils.isPointInPolygon(point, polygon)) { throw new BadRequestException(CartMessage.ADDRESS_OUTSIDE_SERVICE_AREA); } } /** * Get delivery method for restaurant or throw if not found */ async getDeliveryMethodForRestaurantOrFail( restaurantId: string, deliveryMethodId: string, ): Promise { const deliveryMethod = await this.em.findOne(Delivery, { id: deliveryMethodId, restaurant: { id: restaurantId }, }); if (!deliveryMethod) { throw new NotFoundException(CartMessage.DELIVERY_METHOD_NOT_FOUND_FOR_RESTAURANT); } return deliveryMethod; } /** * Get enabled delivery method or throw if not found or disabled */ async getEnabledDeliveryMethodOrFail(restaurantId: string, deliveryMethodId: string): Promise { const deliveryMethod = await this.em.findOne( Delivery, { id: deliveryMethodId, restaurant: { id: restaurantId } }, { populate: ['restaurant'] }, ); if (!deliveryMethod) { throw new NotFoundException(CartMessage.DELIVERY_METHOD_NOT_FOUND); } if (!deliveryMethod.enabled) { throw new BadRequestException(CartMessage.DELIVERY_METHOD_NOT_ENABLED); } return deliveryMethod; } /** * Assert delivery method matches expected type */ assertDeliveryMethod(actual: DeliveryMethodEnum, expected: DeliveryMethodEnum, message: string): void { if (actual !== expected) { throw new BadRequestException(message); } } /** * Require delivery method ID or throw */ requireDeliveryMethodId(cart: Cart, message: string): string { if (!cart.deliveryMethodId) { throw new BadRequestException(message); } return cart.deliveryMethodId; } /** * Get enabled payment method or throw if not found or disabled */ async getEnabledPaymentMethodOrFail(restaurantId: string, paymentMethodId: string): Promise { const paymentMethod = await this.em.findOne( PaymentMethod, { id: paymentMethodId, restaurant: { id: restaurantId } }, { populate: ['restaurant'] }, ); if (!paymentMethod) { throw new NotFoundException(CartMessage.PAYMENT_METHOD_NOT_FOUND); } if (!paymentMethod.enabled) { throw new BadRequestException(CartMessage.PAYMENT_METHOD_NOT_ENABLED); } return paymentMethod; } /** * Assert wallet has enough balance */ async assertWalletHasEnoughBalance(userId: string, restaurantId: string, amount: number): Promise { const balance = await this.walletTransactionRepository.getCurrentWalletBalance(userId, restaurantId); if (balance < amount) { throw new BadRequestException(CartMessage.WALLET_INSUFFICIENT); } } /** * Assert coupon usage limit */ async assertCouponUsageLimit(userId: string, couponId: string, maxUsesPerUser?: number): Promise { if (!maxUsesPerUser) return; const ordersThatUsedTheCouponCount = await this.em.count(Order, { user: { id: userId }, couponDetail: { couponId: couponId }, status: { $ne: OrderStatus.CANCELED }, deletedAt: null, }); if (ordersThatUsedTheCouponCount >= maxUsesPerUser) { throw new BadRequestException(CartMessage.COUPON_MAX_USES_REACHED); } } /** * Assert coupon matches cart if restricted */ async assertCouponMatchesCartIfRestricted( cart: Cart, foodCategories?: string[] | null, foods?: string[] | null, ): Promise { const categoryRestriction = foodCategories?.filter(Boolean) ?? []; const foodRestriction = foods?.filter(Boolean) ?? []; const hasCategoryRestriction = categoryRestriction.length > 0; const hasFoodRestriction = foodRestriction.length > 0; if (!hasCategoryRestriction && !hasFoodRestriction) return; const foodMap = await this.getFoodsInCartWithCategories(cart); for (const item of cart.items) { const food = foodMap.get(item.foodId); if (!food) continue; const matchesCategory = hasCategoryRestriction && food.category && categoryRestriction.includes(food.category.id); const matchesFood = hasFoodRestriction && foodRestriction.includes(food.id); if (matchesCategory || matchesFood) return; } throw new BadRequestException(CartMessage.COUPON_CANNOT_BE_APPLIED); } /** * Get foods in cart with categories */ async getFoodsInCartWithCategories(cart: Cart): Promise> { const foodIds = cart.items.map(item => item.foodId); if (foodIds.length === 0) return new Map(); const foods = await this.em.find(Food, { id: { $in: foodIds } }, { populate: ['category'] }); return new Map(foods.map(f => [f.id, f])); } /** * Assert all foods in cart have pickupServe true when delivery method is courier */ async assertAllFoodsHavePickupServeForCourier(cart: Cart, deliveryMethod: DeliveryMethodEnum): Promise { if (deliveryMethod !== DeliveryMethodEnum.DeliveryCourier) { return; } if (cart.items.length === 0) { return; } const foodIds = cart.items.map(item => item.foodId); const foods = await this.em.find(Food, { id: { $in: foodIds } }); const foodsWithoutPickupServe = foods.filter(food => !food.pickupServe); if (foodsWithoutPickupServe.length > 0) { const foodTitles = foodsWithoutPickupServe.map(f => f.title || f.id).join(', '); throw new BadRequestException( CartMessage.FOODS_MUST_HAVE_PICKUP_SERVE_FOR_COURIER.replace('[foodTitles]', foodTitles), ); } } /** * Get current Iran time context (weekday and meal type) */ private getCurrentIranTimeContext(): { iranWeekDay: number; mealType: MealType | null } { const nowInIran = new Date(new Date().toLocaleString('en-US', { timeZone: 'Asia/Tehran' })); const weekDay = nowInIran.getDay(); // 0 = Sunday, 6 = Saturday const currentHour = nowInIran.getHours(); // Convert to Iran weekday: Saturday=0, Sunday=1, ..., Friday=6 // JavaScript: Sunday=0, Monday=1, ..., Saturday=6 // Iran week: Saturday=0, Sunday=1, ..., Friday=6 const iranWeekDay = (weekDay + 1) % 7; const mealType = this.getMealTypeByHour(currentHour); return { iranWeekDay, mealType }; } /** * Determine meal type based on current hour in Iran timezone. * @param hour - Current hour (0-23) * @returns Meal type or null if outside meal hours */ private getMealTypeByHour(hour: number): MealType | null { const MEAL_TIME_RANGES = { BREAKFAST: { start: 6, end: 11 }, LUNCH: { start: 11, end: 15 }, SNACK: { start: 15, end: 19 }, DINNER: { start: 19, end: 24 }, } as const; if (hour >= MEAL_TIME_RANGES.BREAKFAST.start && hour < MEAL_TIME_RANGES.BREAKFAST.end) { return MealType.BREAKFAST; } if (hour >= MEAL_TIME_RANGES.LUNCH.start && hour < MEAL_TIME_RANGES.LUNCH.end) { return MealType.LUNCH; } if (hour >= MEAL_TIME_RANGES.SNACK.start && hour < MEAL_TIME_RANGES.SNACK.end) { return MealType.SNACK; } if (hour >= MEAL_TIME_RANGES.DINNER.start && hour < MEAL_TIME_RANGES.DINNER.end) { return MealType.DINNER; } return null; } /** * Assert meal type compatibility - if food has mealTypes, current meal time must be in that array */ assertMealTypeCompatibility(food: Food): void { // If food has no meal type restrictions, allow it if (!food.mealTypes || food.mealTypes.length === 0) { return; } const { mealType } = this.getCurrentIranTimeContext(); // If current time is outside meal hours, throw error if (mealType === null) { const foodTitle = food.title || food.id; throw new BadRequestException( CartMessage.FOOD_ONLY_AVAILABLE_DURING_MEAL_TIMES.replace('[foodTitle]', foodTitle), ); } // Check if current meal type is in food's allowed meal types if (!food.mealTypes.includes(mealType)) { const foodTitle = food.title || food.id; const mealTypeMap: Record = { [MealType.BREAKFAST]: 'صبحانه', [MealType.LUNCH]: 'ناهار', [MealType.SNACK]: 'عصرانه', [MealType.DINNER]: 'شام', }; const allowedMealTypes = food.mealTypes.map(mt => mealTypeMap[mt]).join(', '); const currentMealType = mealType ? mealTypeMap[mealType] : mealType; throw new BadRequestException( CartMessage.FOOD_ONLY_AVAILABLE_FOR_MEAL_TYPES.replace('[foodTitle]', foodTitle) .replace('[allowedMealTypes]', allowedMealTypes) .replace('[mealType]', currentMealType), ); } } /** * Assert weekday compatibility - current weekday must be in food's weekDays array */ assertWeekdayCompatibility(food: Food): void { // If food has no weekday restrictions (empty array or all days), allow it if (!food.weekDays || food.weekDays.length === 0 || food.weekDays.length === 7) { return; } const { iranWeekDay } = this.getCurrentIranTimeContext(); // Check if current weekday is in food's allowed weekdays if (!food.weekDays.includes(iranWeekDay)) { const dayNames = ['شنبه', 'یکشنبه', 'دوشنبه', 'سه‌شنبه', 'چهارشنبه', 'پنج‌شنبه', 'جمعه']; const currentDayName = dayNames[iranWeekDay]; const allowedDays = food.weekDays.map(day => dayNames[day]).join(', '); const foodTitle = food.title || food.id; throw new BadRequestException( CartMessage.FOOD_ONLY_AVAILABLE_ON_DAYS.replace('[foodTitle]', foodTitle) .replace('[allowedDays]', allowedDays) .replace('[currentDay]', currentDayName), ); } } }