Files
dmenu-api/src/modules/cart/providers/cart-validation.service.ts
T
2025-12-20 19:33:09 +03:30

296 lines
9.8 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 { PaymentMethodEnum } from '../../payments/interface/payment';
import { UserWallet } from '../../users/entities/user-wallet.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';
@Injectable()
export class CartValidationService {
constructor(private readonly em: EntityManager) {}
/**
* 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(`Food with ID ${foodId} not found`);
}
if (food.restaurant.id !== restaurantId) {
throw new BadRequestException(`Food ${food.title || food.id} does not belong to restaurant ${restaurantId}`);
}
if (!food.inventory) {
throw new BadRequestException(`The food ${food.title || food.id} does not have 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(`Insufficient stock for food ${food.title}. Available: ${availableStock}`);
}
}
/**
* 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(`User with ID ${userId} 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(`Address with ID ${addressId} 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(`Food with ID ${foodId} 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('Restaurant not found');
}
return restaurant;
}
/**
* Validate address belongs to user
*/
validateAddressOwnership(address: UserAddress, userId: string): void {
if (address.user.id !== userId) {
throw new BadRequestException('Address does not belong to the current 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('Address does not have 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('Address is outside the restaurant 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(
`Delivery method with ID ${deliveryMethodId} not found for restaurant ${restaurantId}`,
);
}
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(
`Delivery method with ID ${deliveryMethodId} not found for restaurant ${restaurantId}`,
);
}
if (!deliveryMethod.enabled) {
throw new BadRequestException('Delivery method is not enabled for this restaurant');
}
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(`Payment method with ID ${paymentMethodId} not found for restaurant ${restaurantId}`);
}
if (!paymentMethod.enabled) {
throw new BadRequestException('Payment method is not enabled for this restaurant');
}
return paymentMethod;
}
/**
* Assert wallet has enough balance
*/
async assertWalletHasEnoughBalance(userId: string, restaurantId: string, amount: number): Promise<void> {
const wallet = await this.em.findOne(UserWallet, {
user: { id: userId },
restaurant: { id: restaurantId },
});
if (!wallet) {
throw new BadRequestException('User wallet not found');
}
if (wallet.wallet < amount) {
throw new BadRequestException('User wallet is not enough');
}
}
/**
* 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(`You have reached the maximum number of uses (${maxUsesPerUser}) for this coupon`);
}
}
/**
* 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(
'This coupon cannot be applied to the items in your cart. Please add items from the specified categories or foods.',
);
}
/**
* 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]));
}
}