423 lines
14 KiB
TypeScript
423 lines
14 KiB
TypeScript
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<Food> {
|
|
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<User> {
|
|
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<UserAddress> {
|
|
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<Food> {
|
|
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<Restaurant> {
|
|
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<void> {
|
|
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<Delivery> {
|
|
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<Delivery> {
|
|
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<PaymentMethod> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<Map<string, Food>> {
|
|
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<void> {
|
|
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, string> = {
|
|
[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),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|