remove unused
This commit is contained in:
+1
-15
@@ -11,19 +11,12 @@ import { ThrottlerModule } from '@nestjs/throttler';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { RestaurantsModule } from './modules/restaurants/restaurants.module';
|
||||
import { FoodModule } from './modules/foods/food.module';
|
||||
import { CartModule } from './modules/cart/cart.module';
|
||||
import { RolesModule } from './modules/roles/roles.module';
|
||||
import { PaymentsModule } from './modules/payments/payments.module';
|
||||
import { DeliveryModule } from './modules/delivery/delivery.module';
|
||||
import { OrdersModule } from './modules/orders/orders.module';
|
||||
import { CouponModule } from './modules/coupons/coupon.module';
|
||||
import { ReviewModule } from './modules/review/review.module';
|
||||
import { NotificationsModule } from './modules/notifications/notifications.module';
|
||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||
import { PagerModule } from './modules/pager/pager.module';
|
||||
import { ContactModule } from './modules/contact/contact.module';
|
||||
import { InventoryModule } from './modules/inventory/inventory.module';
|
||||
import { IconsModule } from './modules/icons/icons.module';
|
||||
import { CacheModule } from '@nestjs/cache-manager';
|
||||
import { cacheConfig } from './config/cache.config';
|
||||
import { BusinessModule } from './modules/business/business.module';
|
||||
@@ -48,19 +41,12 @@ import { CatalogueModule } from './modules/catalogue/catalogue.module';
|
||||
ScheduleModule.forRoot(),
|
||||
RestaurantsModule,
|
||||
FoodModule,
|
||||
CartModule,
|
||||
RolesModule,
|
||||
PaymentsModule,
|
||||
DeliveryModule,
|
||||
OrdersModule,
|
||||
CouponModule,
|
||||
ReviewModule,
|
||||
NotificationsModule,
|
||||
EventEmitterModule.forRoot(),
|
||||
PagerModule,
|
||||
ContactModule,
|
||||
InventoryModule,
|
||||
IconsModule,
|
||||
BusinessModule,
|
||||
CatalogueModule,
|
||||
],
|
||||
@@ -68,4 +54,4 @@ import { CatalogueModule } from './modules/catalogue/catalogue.module';
|
||||
providers: [],
|
||||
// exports: [CacheService],
|
||||
})
|
||||
export class AppModule {}
|
||||
export class AppModule { }
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MikroOrmModule } from '@mikro-orm/nestjs';
|
||||
import { CartService } from './providers/cart.service';
|
||||
import { CartController } from './controllers/cart.controller';
|
||||
import { Food } from '../foods/entities/food.entity';
|
||||
import { Restaurant } from '../restaurants/entities/restaurant.entity';
|
||||
import { PaymentMethod } from '../payments/entities/payment-method.entity';
|
||||
import { UserAddress } from '../users/entities/user-address.entity';
|
||||
import { User } from '../users/entities/user.entity';
|
||||
import { Delivery } from '../delivery/entities/delivery.entity';
|
||||
import { Order } from '../orders/entities/order.entity';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { UserModule } from '../users/user.module';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { UtilsModule } from '../utils/utils.module';
|
||||
import { CouponModule } from '../coupons/coupon.module';
|
||||
import { CartRepository } from './repositories/cart.repository';
|
||||
import { CartValidationService } from './providers/cart-validation.service';
|
||||
import { CartCalculationService } from './providers/cart-calculation.service';
|
||||
import { CartItemService } from './providers/cart-item.service';
|
||||
import { PointTransaction } from '../users/entities/point-transaction.entity';
|
||||
import { WalletTransaction } from '../users/entities/wallet-transaction.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
MikroOrmModule.forFeature([
|
||||
Food,
|
||||
Restaurant,
|
||||
PaymentMethod,
|
||||
UserAddress,
|
||||
User,
|
||||
PointTransaction,
|
||||
WalletTransaction,
|
||||
Delivery,
|
||||
Order,
|
||||
]),
|
||||
AuthModule,
|
||||
UserModule,
|
||||
JwtModule,
|
||||
UtilsModule,
|
||||
CouponModule,
|
||||
],
|
||||
controllers: [CartController],
|
||||
providers: [
|
||||
CartService,
|
||||
CartRepository,
|
||||
CartValidationService,
|
||||
CartCalculationService,
|
||||
CartItemService,
|
||||
],
|
||||
exports: [CartService],
|
||||
})
|
||||
export class CartModule { }
|
||||
@@ -1,92 +0,0 @@
|
||||
import { Controller, Get, Post, Body, Patch, Delete, UseGuards, Param } from '@nestjs/common';
|
||||
import { CartService } from '../providers/cart.service';
|
||||
import { BulkAddItemsToCartDto } from '../dto/bulk-add-items.dto';
|
||||
import { ApplyCouponDto } from '../dto/apply-coupon.dto';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody, ApiParam, ApiHeader } from '@nestjs/swagger';
|
||||
import { AuthGuard } from '../../auth/guards/auth.guard';
|
||||
import { UserId } from 'src/common/decorators/user-id.decorator';
|
||||
import { RestId } from 'src/common/decorators/rest-id.decorator';
|
||||
import { SetAllCartParmsDto } from '../dto/set-all-cart-params.dto';
|
||||
import { API_HEADER_SLUG } from 'src/common/constants/index';
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiTags('cart')
|
||||
@Controller('public/cart')
|
||||
export class CartController {
|
||||
constructor(private readonly cartService: CartService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Get cart for current user and restaurant' })
|
||||
@ApiHeader(API_HEADER_SLUG)
|
||||
async findOne(@UserId() userId: string, @RestId() restaurantId: string) {
|
||||
return this.cartService.getOrCreateCart(userId, restaurantId);
|
||||
}
|
||||
|
||||
@Post('items/:foodId/increment')
|
||||
@ApiOperation({ summary: 'Increment item quantity in cart by 1' })
|
||||
@ApiHeader(API_HEADER_SLUG)
|
||||
@ApiParam({ name: 'foodId', description: 'Food ID to increment in cart' })
|
||||
async incrementItem(@UserId() userId: string, @RestId() restaurantId: string, @Param('foodId') foodId: string) {
|
||||
return this.cartService.incrementItem(userId, restaurantId, foodId, 1);
|
||||
}
|
||||
|
||||
@Post('items/:foodId/decrement')
|
||||
@ApiOperation({ summary: 'Decrement item quantity in cart by 1 (removes item if quantity reaches 0)' })
|
||||
@ApiHeader(API_HEADER_SLUG)
|
||||
@ApiParam({ name: 'foodId', description: 'Food ID to decrement in cart' })
|
||||
async decrementItem(@UserId() userId: string, @RestId() restaurantId: string, @Param('foodId') foodId: string) {
|
||||
return this.cartService.decrementItem(userId, restaurantId, foodId);
|
||||
}
|
||||
|
||||
@Post('items/bulk')
|
||||
@ApiOperation({ summary: 'Bulk add items to cart (increments quantity if items exist)' })
|
||||
@ApiHeader(API_HEADER_SLUG)
|
||||
@ApiBody({ type: BulkAddItemsToCartDto })
|
||||
bulkAddItems(
|
||||
@UserId() userId: string,
|
||||
@RestId() restaurantId: string,
|
||||
@Body() bulkAddItemsDto: BulkAddItemsToCartDto,
|
||||
) {
|
||||
return this.cartService.bulkAddItems(userId, restaurantId, bulkAddItemsDto);
|
||||
}
|
||||
|
||||
@Delete('items/:foodId')
|
||||
@ApiOperation({ summary: 'Remove item from cart' })
|
||||
@ApiHeader(API_HEADER_SLUG)
|
||||
@ApiParam({ name: 'foodId', description: 'Food ID in the cart' })
|
||||
async removeItem(@UserId() userId: string, @RestId() restaurantId: string, @Param('foodId') foodId: string) {
|
||||
return this.cartService.removeItem(userId, restaurantId, foodId);
|
||||
}
|
||||
|
||||
@Delete()
|
||||
@ApiOperation({ summary: 'Clear entire cart' })
|
||||
@ApiHeader(API_HEADER_SLUG)
|
||||
async clearCart(@UserId() userId: string, @RestId() restaurantId: string) {
|
||||
return this.cartService.clearCart(userId, restaurantId);
|
||||
}
|
||||
|
||||
@Post('apply-coupon')
|
||||
@ApiOperation({ summary: 'Apply coupon to cart' })
|
||||
@ApiHeader(API_HEADER_SLUG)
|
||||
@ApiBody({ type: ApplyCouponDto })
|
||||
async applyCoupon(@UserId() userId: string, @RestId() restaurantId: string, @Body() applyCouponDto: ApplyCouponDto) {
|
||||
return this.cartService.applyCoupon(userId, restaurantId, applyCouponDto);
|
||||
}
|
||||
|
||||
@Delete('coupon')
|
||||
@ApiOperation({ summary: 'Remove coupon from cart' })
|
||||
@ApiHeader(API_HEADER_SLUG)
|
||||
async removeCoupon(@UserId() userId: string, @RestId() restaurantId: string) {
|
||||
return this.cartService.removeCoupon(userId, restaurantId);
|
||||
}
|
||||
|
||||
|
||||
@Patch('all')
|
||||
@ApiOperation({ summary: 'Set all cart params' })
|
||||
@ApiHeader(API_HEADER_SLUG)
|
||||
@ApiBody({ type: SetAllCartParmsDto })
|
||||
setAllCartParams(@UserId() userId: string, @RestId() restaurantId: string, @Body() dto: SetAllCartParmsDto) {
|
||||
return this.cartService.setAllCartParams(userId, restaurantId, dto);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class ApplyCouponDto {
|
||||
@ApiProperty({ description: 'Coupon code', example: 'DISCOUNT10' })
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
code!: string;
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsArray, ValidateNested, ArrayMinSize, IsInt, Min, IsString } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
class AddItemToCartDto {
|
||||
@ApiProperty({ description: 'Quantity of the food item', example: 1, minimum: 1 })
|
||||
@IsNotEmpty()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
quantity!: number;
|
||||
|
||||
@ApiProperty({ description: 'Food ID' })
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
foodId!: string;
|
||||
}
|
||||
|
||||
export class BulkAddItemsToCartDto {
|
||||
@ApiProperty({
|
||||
description: 'Array of items to add to cart',
|
||||
type: [AddItemToCartDto],
|
||||
example: [
|
||||
{ foodId: 'food-123', quantity: 2 },
|
||||
{ foodId: 'food-456', quantity: 1 },
|
||||
],
|
||||
})
|
||||
@IsNotEmpty()
|
||||
@IsArray()
|
||||
@ArrayMinSize(1, { message: 'At least one item is required' })
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => AddItemToCartDto)
|
||||
items!: AddItemToCartDto[];
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsString, IsArray, ValidateNested, IsInt, Min } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class CartItemDto {
|
||||
@ApiProperty({ description: 'Food ID' })
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
foodId!: string;
|
||||
|
||||
@ApiProperty({ description: 'Quantity of the food item', example: 2, minimum: 1 })
|
||||
@IsNotEmpty()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
quantity!: number;
|
||||
}
|
||||
|
||||
export class CreateCartDto {
|
||||
@ApiProperty({
|
||||
description: 'List of cart items',
|
||||
type: [CartItemDto],
|
||||
})
|
||||
@IsNotEmpty()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CartItemDto)
|
||||
items!: CartItemDto[];
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsObject, IsOptional, IsString } from 'class-validator';
|
||||
import { SetCarDeliveryDto } from './set-car-delivery.dto';
|
||||
|
||||
|
||||
export class SetAllCartParmsDto {
|
||||
@ApiProperty({
|
||||
description: 'Cart description or notes',
|
||||
required: false,
|
||||
example: 'Please deliver to the back door',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@ApiProperty({ description: 'Delivery method ID', example: '01ARZ3NDEKTSV4RRFFQ69G5FAV' })
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
deliveryMethodId!: string;
|
||||
|
||||
@ApiProperty({ description: 'User address ID', example: '01ARZ3NDEKTSV4RRFFQ69G5FAV' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
addressId?: string;
|
||||
|
||||
@ApiProperty({ description: 'Payment method ID', example: '01ARZ3NDEKTSV4RRFFQ69G5FAV' })
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
paymentMethodId!: string;
|
||||
|
||||
@ApiProperty({ description: 'Table number for dine-in orders', example: '5', required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
tableNumber?: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
carAddress?: SetCarDeliveryDto;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class SetCarDeliveryDto {
|
||||
@ApiProperty({ description: 'Car model', example: 'Toyota Camry' })
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
carModel!: string;
|
||||
|
||||
@ApiProperty({ description: 'Car color', example: 'White' })
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
carColor!: string;
|
||||
|
||||
@ApiProperty({ description: 'License plate number', example: '12ABC345' })
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
plateNumber!: string;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { PartialType, ApiProperty } from '@nestjs/swagger';
|
||||
import { CreateCartDto, CartItemDto } from './create-cart.dto';
|
||||
|
||||
export class UpdateCartItemDto extends PartialType(CartItemDto) {}
|
||||
|
||||
export class UpdateCartDto {
|
||||
@ApiProperty({ description: 'List of cart items to update', type: [UpdateCartItemDto], required: false })
|
||||
items?: UpdateCartItemDto[];
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import type { OrderCouponDetail, OrderUserAddress, OrderCarAddress } from 'src/modules/orders/interface/order.interface';
|
||||
|
||||
export interface CartItem {
|
||||
foodId: string;
|
||||
foodTitle?: string;
|
||||
quantity: number;
|
||||
price: number;
|
||||
discount: number;
|
||||
totalPrice: number;
|
||||
}
|
||||
|
||||
export interface Cart {
|
||||
userId: string;
|
||||
restaurantId: string;
|
||||
restaurantName?: string;
|
||||
|
||||
items: CartItem[];
|
||||
coupon?: OrderCouponDetail;
|
||||
paymentMethodId?: string;
|
||||
deliveryMethodId?: string;
|
||||
description?: string;
|
||||
tableNumber?: string;
|
||||
|
||||
deliveryFee: number;
|
||||
subTotal: number;
|
||||
tax: number;
|
||||
couponDiscount: number;
|
||||
itemsDiscount: number;
|
||||
totalDiscount: number;
|
||||
total: number;
|
||||
|
||||
carAddress?: OrderCarAddress | null;
|
||||
userAddress?: OrderUserAddress | null;
|
||||
|
||||
totalItems: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { EntityManager } from '@mikro-orm/postgresql';
|
||||
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
|
||||
import { Delivery } from '../../delivery/entities/delivery.entity';
|
||||
import { DeliveryFeeTypeEnum } from '../../delivery/interface/delivery';
|
||||
import { CouponType } from '../../coupons/interface/coupon';
|
||||
import { Food } from '../../foods/entities/food.entity';
|
||||
import { Cart } from '../interfaces/cart.interface';
|
||||
import { CouponService } from '../../coupons/providers/coupon.service';
|
||||
import { GeographicUtils } from '../utils/geographic.utils';
|
||||
|
||||
@Injectable()
|
||||
export class CartCalculationService {
|
||||
constructor(
|
||||
private readonly em: EntityManager,
|
||||
private readonly couponService: CouponService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Calculate total price for a cart item
|
||||
*/
|
||||
calculateItemTotalPrice(unitPrice: number, unitDiscount: number, quantity: number): number {
|
||||
const safeUnitPrice = Number(unitPrice) || 0;
|
||||
const safeUnitDiscount = Math.min(Number(unitDiscount) || 0, safeUnitPrice);
|
||||
const itemTotalPrice = safeUnitPrice * quantity;
|
||||
const itemTotalDiscount = safeUnitDiscount * quantity;
|
||||
return itemTotalPrice - itemTotalDiscount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate items totals (subtotal, items discount, total items)
|
||||
*/
|
||||
calculateItemsTotals(cart: Cart): { subTotal: number; itemsDiscount: number; totalItems: number } {
|
||||
let subTotal = 0;
|
||||
let itemsDiscount = 0;
|
||||
let totalItems = 0;
|
||||
|
||||
for (const item of cart.items) {
|
||||
const unitPrice = Number(item.price) || 0;
|
||||
const unitDiscount = Math.min(Number(item.discount) || 0, unitPrice);
|
||||
const itemPrice = unitPrice * item.quantity;
|
||||
const itemDiscount = unitDiscount * item.quantity;
|
||||
subTotal += itemPrice;
|
||||
itemsDiscount += itemDiscount;
|
||||
totalItems += item.quantity;
|
||||
item.totalPrice = itemPrice - itemDiscount;
|
||||
}
|
||||
|
||||
return { subTotal, itemsDiscount, totalItems };
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate coupon discount
|
||||
*/
|
||||
async calculateCouponDiscount(cart: Cart, subTotal: number, itemsDiscount: number): Promise<number> {
|
||||
if (!cart.coupon) return 0;
|
||||
|
||||
const coupon = await this.getCouponRestrictionsOrClear(cart);
|
||||
if (!cart.coupon || !coupon) return 0;
|
||||
|
||||
const hasCategoryRestriction = (coupon.foodCategories?.length ?? 0) > 0;
|
||||
const hasFoodRestriction = (coupon.foods?.length ?? 0) > 0;
|
||||
|
||||
let eligibleItemsTotal = subTotal;
|
||||
let eligibleItemsDiscount = itemsDiscount;
|
||||
|
||||
if (hasCategoryRestriction || hasFoodRestriction) {
|
||||
eligibleItemsTotal = 0;
|
||||
eligibleItemsDiscount = 0;
|
||||
|
||||
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 && (coupon.foodCategories ?? []).includes(food.category.id);
|
||||
const matchesFood = hasFoodRestriction && (coupon.foods ?? []).includes(food.id);
|
||||
|
||||
if (matchesCategory || matchesFood) {
|
||||
const unitPrice = Number(item.price) || 0;
|
||||
const unitDiscount = Math.min(Number(item.discount) || 0, unitPrice);
|
||||
eligibleItemsTotal += unitPrice * item.quantity;
|
||||
eligibleItemsDiscount += unitDiscount * item.quantity;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const priceAfterItemDiscount = Math.max(0, eligibleItemsTotal - eligibleItemsDiscount);
|
||||
|
||||
if (cart.coupon.type === CouponType.PERCENTAGE) {
|
||||
let discount = (priceAfterItemDiscount * cart.coupon.value) / 100;
|
||||
if (cart.coupon.maxDiscount && discount > cart.coupon.maxDiscount) {
|
||||
discount = cart.coupon.maxDiscount;
|
||||
}
|
||||
return discount;
|
||||
}
|
||||
|
||||
return Math.min(cart.coupon.value, priceAfterItemDiscount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate tax
|
||||
*/
|
||||
async calculateTax(restaurantId: string, amountAfterDiscounts: number): Promise<number> {
|
||||
const restaurant = await this.em.findOne(Restaurant, { id: restaurantId });
|
||||
const vat = restaurant?.vat ? Number(restaurant.vat) : 0;
|
||||
if (!vat || vat <= 0) return 0;
|
||||
return (Math.max(0, amountAfterDiscounts) * vat) / 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate delivery fee
|
||||
*/
|
||||
async calculateDeliveryFee(cart: Cart): Promise<number> {
|
||||
const deliveryMethodId = cart.deliveryMethodId;
|
||||
if (!deliveryMethodId) return 0;
|
||||
|
||||
const deliveryMethod = await this.em.findOne(Delivery, { id: deliveryMethodId }, { populate: ['restaurant'] });
|
||||
if (!deliveryMethod || !deliveryMethod.enabled) return 0;
|
||||
|
||||
// If not distance based, return fixed delivery fee
|
||||
if (deliveryMethod.deliveryFeeType !== DeliveryFeeTypeEnum.DISTANCE_BASED) {
|
||||
return Number(deliveryMethod.deliveryFee) || 0;
|
||||
}
|
||||
|
||||
// For distance based calculation we need restaurant and user coordinates
|
||||
const restaurant = (deliveryMethod as any).restaurant as Restaurant | undefined;
|
||||
const userAddr = cart.userAddress;
|
||||
|
||||
if (!restaurant || restaurant.latitude == null || restaurant.longitude == null) {
|
||||
// fallback to configured fixed fee when coordinates are missing
|
||||
return Number(deliveryMethod.deliveryFee) || 0;
|
||||
}
|
||||
|
||||
if (!userAddr || userAddr.latitude == null || userAddr.longitude == null) {
|
||||
return Number(deliveryMethod.deliveryFee) || 0;
|
||||
}
|
||||
|
||||
const restLat = Number(restaurant.latitude);
|
||||
const restLng = Number(restaurant.longitude);
|
||||
const userLat = Number(userAddr.latitude);
|
||||
const userLng = Number(userAddr.longitude);
|
||||
|
||||
const distanceKm = GeographicUtils.getDistanceKmRounded(restLat, restLng, userLat, userLng);
|
||||
|
||||
// Try to read either possible property names (legacy vs new)
|
||||
const perKm = Number(deliveryMethod.perKilometerFee);
|
||||
const minFee = Number(deliveryMethod.distanceBasedMinCost);
|
||||
|
||||
let fee = 0;
|
||||
if (perKm <= 0) {
|
||||
fee = Number(deliveryMethod.deliveryFee) || 0;
|
||||
} else {
|
||||
fee = distanceKm * perKm;
|
||||
}
|
||||
|
||||
if (minFee > 0 && fee < minFee) fee = minFee;
|
||||
|
||||
return Math.max(0, Number(fee));
|
||||
}
|
||||
|
||||
/**
|
||||
* Recalculate cart totals (including coupon discount and tax)
|
||||
*/
|
||||
async recalculateCartTotals(cart: Cart): Promise<void> {
|
||||
const { subTotal, itemsDiscount, totalItems } = this.calculateItemsTotals(cart);
|
||||
cart.subTotal = subTotal;
|
||||
cart.itemsDiscount = itemsDiscount;
|
||||
|
||||
const couponDiscount = await this.calculateCouponDiscount(cart, subTotal, itemsDiscount);
|
||||
cart.couponDiscount = couponDiscount;
|
||||
cart.totalDiscount = couponDiscount + itemsDiscount;
|
||||
|
||||
cart.tax = await this.calculateTax(cart.restaurantId, Math.max(0, subTotal - cart.totalDiscount));
|
||||
cart.deliveryFee = await this.calculateDeliveryFee(cart);
|
||||
|
||||
// total = subtotal – totalDiscount + tax + deliveryFee
|
||||
cart.total = Math.max(0, subTotal - cart.totalDiscount) + cart.tax + cart.deliveryFee;
|
||||
cart.totalItems = totalItems;
|
||||
cart.updatedAt = this.nowIso();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get coupon restrictions or clear if invalid
|
||||
*/
|
||||
private async getCouponRestrictionsOrClear(cart: Cart): Promise<{
|
||||
isActive?: boolean;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
foodCategories?: string[];
|
||||
foods?: string[];
|
||||
} | null> {
|
||||
if (!cart.coupon) return null;
|
||||
|
||||
try {
|
||||
const c = await this.couponService.findById(cart.coupon.couponId);
|
||||
const now = new Date();
|
||||
if (c.isActive === false) {
|
||||
cart.coupon = undefined;
|
||||
return null;
|
||||
}
|
||||
if (c.startDate && now < c.startDate) {
|
||||
cart.coupon = undefined;
|
||||
return null;
|
||||
}
|
||||
if (c.endDate && now > c.endDate) {
|
||||
cart.coupon = undefined;
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
isActive: c.isActive,
|
||||
startDate: c.startDate,
|
||||
endDate: c.endDate,
|
||||
foodCategories: c.foodCategories,
|
||||
foods: c.foods,
|
||||
};
|
||||
} catch {
|
||||
cart.coupon = undefined;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get foods in cart with categories
|
||||
*/
|
||||
private 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]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current ISO timestamp
|
||||
*/
|
||||
private nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Food } from '../../foods/entities/food.entity';
|
||||
import { Cart, CartItem } from '../interfaces/cart.interface';
|
||||
import { CartValidationService } from './cart-validation.service';
|
||||
import { CartCalculationService } from './cart-calculation.service';
|
||||
import { CartMessage } from 'src/common/enums/message.enum';
|
||||
|
||||
@Injectable()
|
||||
export class CartItemService {
|
||||
constructor(
|
||||
private readonly validationService: CartValidationService,
|
||||
private readonly calculationService: CartCalculationService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create a new cart item from food
|
||||
*/
|
||||
createCartItem(food: Food, quantity: number): CartItem {
|
||||
const itemPrice = food.price || 0;
|
||||
const itemDiscount = food.discount || 0;
|
||||
|
||||
return {
|
||||
foodId: food.id,
|
||||
foodTitle: food.title,
|
||||
quantity,
|
||||
price: itemPrice,
|
||||
discount: itemDiscount,
|
||||
totalPrice: this.calculationService.calculateItemTotalPrice(itemPrice, itemDiscount, quantity),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build cart item from food (updating existing item if provided)
|
||||
*/
|
||||
buildCartItemFromFood(food: Food, quantity: number, previous?: CartItem): CartItem {
|
||||
const itemPrice = food.price || 0;
|
||||
const itemDiscount = food.discount || 0;
|
||||
return {
|
||||
...(previous ?? ({} as CartItem)),
|
||||
foodId: food.id,
|
||||
foodTitle: food.title,
|
||||
quantity,
|
||||
price: itemPrice,
|
||||
discount: itemDiscount,
|
||||
totalPrice: this.calculationService.calculateItemTotalPrice(itemPrice, itemDiscount, quantity),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get item index in cart
|
||||
*/
|
||||
getItemIndex(cart: Cart, foodId: string): number {
|
||||
return cart.items.findIndex(item => item.foodId === foodId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or increment item in cart
|
||||
*/
|
||||
async addOrIncrementItem(cart: Cart, foodId: string, restaurantId: string, quantityToAdd: number): Promise<void> {
|
||||
const food = await this.validationService.validateAndGetFood(foodId, restaurantId, quantityToAdd);
|
||||
|
||||
// Validate meal type compatibility
|
||||
this.validationService.assertMealTypeCompatibility(food);
|
||||
|
||||
// Validate weekday compatibility
|
||||
this.validationService.assertWeekdayCompatibility(food);
|
||||
|
||||
const index = this.getItemIndex(cart, food.id);
|
||||
|
||||
if (index < 0) {
|
||||
cart.items.push(this.createCartItem(food, quantityToAdd));
|
||||
return;
|
||||
}
|
||||
|
||||
const existingItem = cart.items[index];
|
||||
const newQuantity = existingItem.quantity + quantityToAdd;
|
||||
this.validationService.validateStock(food, newQuantity);
|
||||
cart.items[index] = this.buildCartItemFromFood(food, newQuantity, existingItem);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove item from cart
|
||||
*/
|
||||
removeItemOrFail(cart: Cart, foodId: string): void {
|
||||
const itemIndex = this.getItemIndex(cart, foodId);
|
||||
if (itemIndex < 0) {
|
||||
throw new NotFoundException(CartMessage.ITEM_NOT_FOUND);
|
||||
}
|
||||
cart.items.splice(itemIndex, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrement item quantity or remove if quantity reaches 0
|
||||
*/
|
||||
async decrementOrRemoveItem(cart: Cart, foodId: string): Promise<void> {
|
||||
const itemIndex = this.getItemIndex(cart, foodId);
|
||||
if (itemIndex < 0) {
|
||||
throw new NotFoundException(CartMessage.ITEM_NOT_FOUND);
|
||||
}
|
||||
|
||||
const existingItem = cart.items[itemIndex];
|
||||
const newQuantity = existingItem.quantity - 1;
|
||||
|
||||
if (newQuantity <= 0) {
|
||||
cart.items.splice(itemIndex, 1);
|
||||
return;
|
||||
}
|
||||
|
||||
const food = await this.validationService.getFoodOrFail(foodId);
|
||||
cart.items[itemIndex] = this.buildCartItemFromFood(food, newQuantity, existingItem);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,422 +0,0 @@
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,487 +0,0 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { DeliveryMethodEnum } from '../../delivery/interface/delivery';
|
||||
import { PaymentMethodEnum } from '../../payments/interface/payment';
|
||||
import { OrderCouponDetail } from '../../orders/interface/order.interface';
|
||||
import { Cart } from '../interfaces/cart.interface';
|
||||
import { BulkAddItemsToCartDto } from '../dto/bulk-add-items.dto';
|
||||
import { ApplyCouponDto } from '../dto/apply-coupon.dto';
|
||||
import { SetAllCartParmsDto } from '../dto/set-all-cart-params.dto';
|
||||
import { CouponService } from '../../coupons/providers/coupon.service';
|
||||
import { CartRepository } from '../repositories/cart.repository';
|
||||
import { CartValidationService } from './cart-validation.service';
|
||||
import { CartCalculationService } from './cart-calculation.service';
|
||||
import { CartItemService } from './cart-item.service';
|
||||
import { SetCarDeliveryDto } from '../dto/set-car-delivery.dto';
|
||||
import { CartMessage } from 'src/common/enums/message.enum';
|
||||
|
||||
@Injectable()
|
||||
export class CartService {
|
||||
constructor(
|
||||
private readonly cartRepository: CartRepository,
|
||||
private readonly validationService: CartValidationService,
|
||||
private readonly calculationService: CartCalculationService,
|
||||
private readonly itemService: CartItemService,
|
||||
private readonly couponService: CouponService,
|
||||
) { }
|
||||
|
||||
/**
|
||||
* Set all cart parameters at once
|
||||
*/
|
||||
async setAllCartParams(userId: string, restaurantId: string, params: SetAllCartParmsDto): Promise<Cart> {
|
||||
const { deliveryMethodId, paymentMethodId, addressId, carAddress, description, tableNumber } = params;
|
||||
|
||||
// get existing cart (or throw)
|
||||
const cart = await this.findOneOrFail(userId, restaurantId);
|
||||
|
||||
// Validate and get delivery method
|
||||
const deliveryMethod = await this.validationService.getEnabledDeliveryMethodOrFail(
|
||||
restaurantId,
|
||||
deliveryMethodId,
|
||||
);
|
||||
cart.deliveryMethodId = deliveryMethodId;
|
||||
|
||||
// Handle each delivery method with its specific requirements
|
||||
switch (deliveryMethod.method) {
|
||||
case DeliveryMethodEnum.DineIn:
|
||||
// DineIn requires table number and clears incompatible fields
|
||||
if (!tableNumber) {
|
||||
throw new BadRequestException(CartMessage.TABLE_NUMBER_REQUIRED);
|
||||
}
|
||||
this.clearFieldsIncompatibleWithDeliveryMethod(cart, DeliveryMethodEnum.DineIn);
|
||||
cart.tableNumber = tableNumber;
|
||||
break;
|
||||
|
||||
case DeliveryMethodEnum.CustomerPickup:
|
||||
// CustomerPickup just clears incompatible fields
|
||||
this.clearFieldsIncompatibleWithDeliveryMethod(cart, DeliveryMethodEnum.CustomerPickup);
|
||||
break;
|
||||
|
||||
case DeliveryMethodEnum.DeliveryCar:
|
||||
// DeliveryCar requires carAddress and clears incompatible fields
|
||||
if (!carAddress) {
|
||||
throw new BadRequestException(CartMessage.CAR_ADDRESS_REQUIRED);
|
||||
}
|
||||
this.clearFieldsIncompatibleWithDeliveryMethod(cart, DeliveryMethodEnum.DeliveryCar);
|
||||
const user = await this.validationService.getUserOrFail(userId);
|
||||
cart.carAddress = {
|
||||
carModel: carAddress.carModel,
|
||||
carColor: carAddress.carColor,
|
||||
plateNumber: carAddress.plateNumber,
|
||||
phone: user.phone,
|
||||
};
|
||||
break;
|
||||
|
||||
case DeliveryMethodEnum.DeliveryCourier:
|
||||
// DeliveryCourier requires addressId and clears incompatible fields
|
||||
if (!addressId) {
|
||||
throw new BadRequestException(CartMessage.ADDRESS_REQUIRED);
|
||||
}
|
||||
this.clearFieldsIncompatibleWithDeliveryMethod(cart, DeliveryMethodEnum.DeliveryCourier);
|
||||
const address = await this.validationService.getUserAddressOrFail(addressId);
|
||||
this.validationService.validateAddressOwnership(address, userId);
|
||||
|
||||
// ensure address is within restaurant service area
|
||||
await this.validationService.assertAddressInsideServiceArea(
|
||||
restaurantId,
|
||||
address.latitude,
|
||||
address.longitude,
|
||||
);
|
||||
|
||||
cart.userAddress = {
|
||||
address: address.address,
|
||||
latitude: address.latitude,
|
||||
longitude: address.longitude,
|
||||
city: address.city,
|
||||
province: address.province || '',
|
||||
postalCode: address.postalCode || undefined,
|
||||
fullName: `${address.user.firstName || ''} ${address.user.lastName || ''}`.trim(),
|
||||
phone: address.user.phone,
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
// Validate and set payment method
|
||||
const paymentMethod = await this.validationService.getEnabledPaymentMethodOrFail(
|
||||
restaurantId,
|
||||
paymentMethodId,
|
||||
);
|
||||
|
||||
// Recalculate totals first so wallet check uses up-to-date total (delivery method may have changed above).
|
||||
await this.calculationService.recalculateCartTotals(cart);
|
||||
|
||||
if (paymentMethod.method === PaymentMethodEnum.Wallet) {
|
||||
await this.validationService.assertWalletHasEnoughBalance(userId, restaurantId, cart.total);
|
||||
}
|
||||
|
||||
cart.paymentMethodId = paymentMethodId;
|
||||
|
||||
// Set description (if provided)
|
||||
if (description !== undefined) {
|
||||
cart.description = description;
|
||||
}
|
||||
|
||||
// Final validation: if effective delivery method is courier, ensure all foods have pickupServe
|
||||
await this.validationService.assertAllFoodsHavePickupServeForCourier(cart, deliveryMethod.method);
|
||||
|
||||
// Final recalculation + save and return cart
|
||||
return this.recalculateAndSaveCart(cart);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create cart for user and restaurant
|
||||
*/
|
||||
async getOrCreateCart(userId: string, restaurantId: string): Promise<Cart> {
|
||||
const existingCart = await this.cartRepository.findByUserAndRestaurant(userId, restaurantId);
|
||||
if (existingCart) {
|
||||
return existingCart;
|
||||
}
|
||||
|
||||
// Find restaurant
|
||||
const restaurant = await this.validationService.getRestaurantOrFail(restaurantId);
|
||||
|
||||
// Create new cart
|
||||
const now = this.nowIso();
|
||||
const cart: Cart = {
|
||||
userId,
|
||||
restaurantId,
|
||||
restaurantName: restaurant.name,
|
||||
items: [],
|
||||
deliveryFee: 0,
|
||||
subTotal: 0,
|
||||
itemsDiscount: 0,
|
||||
couponDiscount: 0,
|
||||
totalDiscount: 0,
|
||||
tax: 0,
|
||||
total: 0,
|
||||
totalItems: 0,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await this.cartRepository.save(cart);
|
||||
return cart;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find cart by user and restaurant
|
||||
*/
|
||||
async findOneOrFail(userId: string, restaurantId: string): Promise<Cart> {
|
||||
const cart = await this.cartRepository.findByUserAndRestaurant(userId, restaurantId);
|
||||
if (!cart) {
|
||||
throw new NotFoundException(CartMessage.NOT_FOUND);
|
||||
}
|
||||
return cart;
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment item quantity in cart
|
||||
*/
|
||||
async incrementItem(userId: string, restaurantId: string, foodId: string, quantity: number): Promise<Cart> {
|
||||
const cart = await this.getOrCreateCart(userId, restaurantId);
|
||||
await this.itemService.addOrIncrementItem(cart, foodId, restaurantId, quantity);
|
||||
return this.recalculateAndSaveCart(cart);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrement item quantity in cart by 1 (removes item if quantity reaches 0)
|
||||
*/
|
||||
async decrementItem(userId: string, restaurantId: string, foodId: string): Promise<Cart> {
|
||||
const cart = await this.findOneOrFail(userId, restaurantId);
|
||||
await this.itemService.decrementOrRemoveItem(cart, foodId);
|
||||
return this.recalculateAndSaveCart(cart);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk add items to cart (increments quantity if items exist)
|
||||
*/
|
||||
async bulkAddItems(userId: string, restaurantId: string, bulkAddItemsDto: BulkAddItemsToCartDto): Promise<Cart> {
|
||||
const cart = await this.getOrCreateCart(userId, restaurantId);
|
||||
|
||||
for (const addItemDto of bulkAddItemsDto.items) {
|
||||
await this.itemService.addOrIncrementItem(cart, addItemDto.foodId, restaurantId, addItemDto.quantity);
|
||||
}
|
||||
|
||||
return this.recalculateAndSaveCart(cart);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove item from cart
|
||||
*/
|
||||
async removeItem(userId: string, restaurantId: string, foodId: string): Promise<Cart> {
|
||||
const cart = await this.findOneOrFail(userId, restaurantId);
|
||||
this.itemService.removeItemOrFail(cart, foodId);
|
||||
return this.recalculateAndSaveCart(cart);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply coupon to cart
|
||||
*/
|
||||
async applyCoupon(userId: string, restaurantId: string, applyCouponDto: ApplyCouponDto): Promise<Cart> {
|
||||
const cart = await this.findOneOrFail(userId, restaurantId);
|
||||
|
||||
const orderAmount = cart.subTotal - cart.itemsDiscount;
|
||||
const user = await this.validationService.getUserOrFail(userId);
|
||||
// check if coupon is valid and belong to the restaurant
|
||||
const { valid, coupon, message } = await this.couponService.validateCoupon(
|
||||
applyCouponDto.code,
|
||||
restaurantId,
|
||||
Number(orderAmount),
|
||||
user.phone,
|
||||
);
|
||||
|
||||
if (!valid) {
|
||||
throw new BadRequestException(message || CartMessage.COUPON_NOT_FOUND);
|
||||
}
|
||||
|
||||
if (!coupon) {
|
||||
throw new BadRequestException(CartMessage.COUPON_NOT_FOUND);
|
||||
}
|
||||
|
||||
// Check maxUsesPerUser limit by counting how many orders this user has with this coupon
|
||||
await this.validationService.assertCouponUsageLimit(userId, coupon.id, coupon.maxUsesPerUser);
|
||||
await this.validationService.assertCouponMatchesCartIfRestricted(
|
||||
cart,
|
||||
coupon.foodCategories,
|
||||
coupon.foods,
|
||||
);
|
||||
|
||||
const couponDetail: OrderCouponDetail = {
|
||||
couponId: coupon.id,
|
||||
couponName: coupon.name,
|
||||
couponCode: coupon.code,
|
||||
type: coupon.type,
|
||||
value: coupon.value,
|
||||
maxDiscount: coupon.maxDiscount,
|
||||
};
|
||||
|
||||
cart.coupon = couponDetail;
|
||||
return this.recalculateAndSaveCart(cart);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove coupon from cart
|
||||
*/
|
||||
async removeCoupon(userId: string, restaurantId: string): Promise<Cart> {
|
||||
const cart = await this.findOneOrFail(userId, restaurantId);
|
||||
|
||||
cart.coupon = undefined;
|
||||
return this.recalculateAndSaveCart(cart);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set address for cart
|
||||
*/
|
||||
async setAddress(userId: string, restaurantId: string, addressId: string): Promise<Cart> {
|
||||
const cart = await this.findOneOrFail(userId, restaurantId);
|
||||
|
||||
const deliveryMethodId = this.validationService.requireDeliveryMethodId(
|
||||
cart,
|
||||
'Delivery method must be set before setting address',
|
||||
);
|
||||
const deliveryMethod = await this.validationService.getDeliveryMethodForRestaurantOrFail(
|
||||
restaurantId,
|
||||
deliveryMethodId,
|
||||
);
|
||||
this.validationService.assertDeliveryMethod(
|
||||
deliveryMethod.method,
|
||||
DeliveryMethodEnum.DeliveryCourier,
|
||||
'Delivery method must be DeliveryCourier',
|
||||
);
|
||||
|
||||
// Find and validate address belongs to user
|
||||
const address = await this.validationService.getUserAddressOrFail(addressId);
|
||||
|
||||
// Verify address belongs to the user
|
||||
this.validationService.validateAddressOwnership(address, userId);
|
||||
|
||||
// ensure address is within restaurant service area
|
||||
await this.validationService.assertAddressInsideServiceArea(
|
||||
restaurantId,
|
||||
address.latitude,
|
||||
address.longitude,
|
||||
);
|
||||
|
||||
this.clearFieldsIncompatibleWithDeliveryMethod(cart, DeliveryMethodEnum.DeliveryCourier);
|
||||
cart.userAddress = {
|
||||
address: address.address,
|
||||
latitude: address.latitude,
|
||||
longitude: address.longitude,
|
||||
city: address.city,
|
||||
province: address.province || '',
|
||||
postalCode: address.postalCode || undefined,
|
||||
fullName: `${address.user.firstName || ''} ${address.user.lastName || ''}`.trim(),
|
||||
phone: address.user.phone,
|
||||
};
|
||||
return this.saveTouchedCart(cart);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set payment method for cart
|
||||
*/
|
||||
async setPaymentMethod(
|
||||
userId: string,
|
||||
restaurantId: string,
|
||||
paymentMethodId: string,
|
||||
): Promise<Cart> {
|
||||
const cart = await this.findOneOrFail(userId, restaurantId);
|
||||
|
||||
const paymentMethod = await this.validationService.getEnabledPaymentMethodOrFail(
|
||||
restaurantId,
|
||||
paymentMethodId,
|
||||
);
|
||||
if (paymentMethod.method === PaymentMethodEnum.Wallet) {
|
||||
await this.validationService.assertWalletHasEnoughBalance(userId, restaurantId, cart.total);
|
||||
}
|
||||
cart.paymentMethodId = paymentMethodId;
|
||||
return this.recalculateAndSaveCart(cart);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set delivery method for cart
|
||||
*/
|
||||
async setDeliveryMethod(
|
||||
userId: string,
|
||||
restaurantId: string,
|
||||
deliveryMethodId: string,
|
||||
): Promise<Cart> {
|
||||
const cart = await this.findOneOrFail(userId, restaurantId);
|
||||
|
||||
const deliveryMethod = await this.validationService.getEnabledDeliveryMethodOrFail(
|
||||
restaurantId,
|
||||
deliveryMethodId,
|
||||
);
|
||||
|
||||
cart.deliveryMethodId = deliveryMethodId;
|
||||
this.clearFieldsIncompatibleWithDeliveryMethod(cart, deliveryMethod.method);
|
||||
|
||||
return this.recalculateAndSaveCart(cart);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set description for cart
|
||||
*/
|
||||
async setDescription(userId: string, restaurantId: string, description: string): Promise<Cart> {
|
||||
const cart = await this.findOneOrFail(userId, restaurantId);
|
||||
|
||||
cart.description = description;
|
||||
return this.saveTouchedCart(cart);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set table number for cart
|
||||
*/
|
||||
async setTableNumber(userId: string, restaurantId: string, tableNumber: string): Promise<Cart> {
|
||||
const cart = await this.findOneOrFail(userId, restaurantId);
|
||||
|
||||
const deliveryMethodId = this.validationService.requireDeliveryMethodId(
|
||||
cart,
|
||||
'Delivery method must be set before setting table number',
|
||||
);
|
||||
const deliveryMethod = await this.validationService.getDeliveryMethodForRestaurantOrFail(
|
||||
restaurantId,
|
||||
deliveryMethodId,
|
||||
);
|
||||
this.validationService.assertDeliveryMethod(
|
||||
deliveryMethod.method,
|
||||
DeliveryMethodEnum.DineIn,
|
||||
'Delivery method must be DineIn',
|
||||
);
|
||||
|
||||
this.clearFieldsIncompatibleWithDeliveryMethod(cart, DeliveryMethodEnum.DineIn);
|
||||
cart.tableNumber = tableNumber;
|
||||
return this.saveTouchedCart(cart);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set car delivery for cart
|
||||
*/
|
||||
async setCarDelivery(userId: string, restaurantId: string, setCarDeliveryDto: SetCarDeliveryDto): Promise<Cart> {
|
||||
const cart = await this.findOneOrFail(userId, restaurantId);
|
||||
|
||||
const deliveryMethodId = this.validationService.requireDeliveryMethodId(
|
||||
cart,
|
||||
'Delivery method must be set before setting car delivery',
|
||||
);
|
||||
const deliveryMethod = await this.validationService.getDeliveryMethodForRestaurantOrFail(
|
||||
restaurantId,
|
||||
deliveryMethodId,
|
||||
);
|
||||
this.validationService.assertDeliveryMethod(
|
||||
deliveryMethod.method,
|
||||
DeliveryMethodEnum.DeliveryCar,
|
||||
'Delivery method must be DeliveryCar',
|
||||
);
|
||||
const user = await this.validationService.getUserOrFail(userId);
|
||||
|
||||
this.clearFieldsIncompatibleWithDeliveryMethod(cart, DeliveryMethodEnum.DeliveryCar);
|
||||
cart.carAddress = {
|
||||
carModel: setCarDeliveryDto.carModel,
|
||||
carColor: setCarDeliveryDto.carColor,
|
||||
plateNumber: setCarDeliveryDto.plateNumber,
|
||||
phone: user.phone,
|
||||
};
|
||||
return this.saveTouchedCart(cart);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cart from cache
|
||||
*/
|
||||
async clearCart(userId: string, restaurantId: string): Promise<void> {
|
||||
return this.cartRepository.delete(userId, restaurantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears cart fields that are not applicable for a given delivery method.
|
||||
*/
|
||||
private clearFieldsIncompatibleWithDeliveryMethod(cart: Cart, method: DeliveryMethodEnum): void {
|
||||
if (method === DeliveryMethodEnum.DineIn) {
|
||||
cart.userAddress = null;
|
||||
cart.carAddress = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === DeliveryMethodEnum.CustomerPickup) {
|
||||
cart.userAddress = null;
|
||||
cart.carAddress = null;
|
||||
cart.tableNumber = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === DeliveryMethodEnum.DeliveryCourier) {
|
||||
cart.carAddress = null;
|
||||
cart.tableNumber = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === DeliveryMethodEnum.DeliveryCar) {
|
||||
cart.userAddress = null;
|
||||
cart.tableNumber = undefined;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save cart with updated timestamp
|
||||
*/
|
||||
private async saveTouchedCart(cart: Cart): Promise<Cart> {
|
||||
cart.updatedAt = this.nowIso();
|
||||
await this.cartRepository.save(cart);
|
||||
return cart;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recalculate cart totals and save
|
||||
*/
|
||||
private async recalculateAndSaveCart(cart: Cart): Promise<Cart> {
|
||||
await this.calculationService.recalculateCartTotals(cart);
|
||||
await this.cartRepository.save(cart);
|
||||
return cart;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current ISO timestamp
|
||||
*/
|
||||
private nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CacheService } from '../../utils/cache.service';
|
||||
import { Cart } from '../interfaces/cart.interface';
|
||||
|
||||
@Injectable()
|
||||
export class CartRepository {
|
||||
private readonly CART_TTL = 3600; // 1 hour in seconds
|
||||
private readonly CART_KEY_PREFIX = 'cart';
|
||||
|
||||
constructor(private readonly cacheService: CacheService) {}
|
||||
|
||||
/**
|
||||
* Get cart by user and restaurant
|
||||
*/
|
||||
async findByUserAndRestaurant(userId: string, restaurantId: string): Promise<Cart | null> {
|
||||
const cacheKey = this.getCacheKey(userId, restaurantId);
|
||||
const cachedCart = await this.cacheService.get<string>(cacheKey);
|
||||
|
||||
if (cachedCart) {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(cachedCart);
|
||||
if (this.isCart(parsed) && parsed.userId === userId && parsed.restaurantId === restaurantId) {
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
// If parsing fails, return null
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save cart to cache
|
||||
*/
|
||||
async save(cart: Cart): Promise<void> {
|
||||
const cacheKey = this.getCacheKey(cart.userId, cart.restaurantId);
|
||||
await this.cacheService.set(cacheKey, JSON.stringify(cart), this.CART_TTL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete cart from cache
|
||||
*/
|
||||
async delete(userId: string, restaurantId: string): Promise<void> {
|
||||
const cacheKey = this.getCacheKey(userId, restaurantId);
|
||||
await this.cacheService.del(cacheKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate cache key for cart
|
||||
*/
|
||||
private getCacheKey(userId: string, restaurantId: string): string {
|
||||
return `${this.CART_KEY_PREFIX}:${userId}:${restaurantId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if an object is a Cart
|
||||
*/
|
||||
private isCart(obj: unknown): obj is Cart {
|
||||
if (typeof obj !== 'object' || obj === null) {
|
||||
return false;
|
||||
}
|
||||
const cart = obj as Record<string, unknown>;
|
||||
return (
|
||||
typeof cart.userId === 'string' &&
|
||||
typeof cart.restaurantId === 'string' &&
|
||||
Array.isArray(cart.items) &&
|
||||
typeof cart.subTotal === 'number' &&
|
||||
typeof cart.itemsDiscount === 'number' &&
|
||||
typeof cart.couponDiscount === 'number' &&
|
||||
typeof cart.totalDiscount === 'number' &&
|
||||
typeof cart.tax === 'number' &&
|
||||
typeof cart.deliveryFee === 'number' &&
|
||||
typeof cart.total === 'number' &&
|
||||
typeof cart.totalItems === 'number' &&
|
||||
typeof cart.createdAt === 'string' &&
|
||||
typeof cart.updatedAt === 'string'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
/**
|
||||
* Utility class for geographic calculations
|
||||
*/
|
||||
export class GeographicUtils {
|
||||
/**
|
||||
* Check if a point is inside a polygon using ray-casting algorithm
|
||||
* @param point - [longitude, latitude]
|
||||
* @param polygon - Array of [longitude, latitude] pairs
|
||||
*/
|
||||
static isPointInPolygon(point: [number, number], polygon: [number, number][]): boolean {
|
||||
const x = point[0];
|
||||
const y = point[1];
|
||||
let inside = false;
|
||||
|
||||
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
||||
const xi = polygon[i][0];
|
||||
const yi = polygon[i][1];
|
||||
const xj = polygon[j][0];
|
||||
const yj = polygon[j][1];
|
||||
|
||||
const intersect =
|
||||
yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi + Number.EPSILON) + xi;
|
||||
if (intersect) inside = !inside;
|
||||
}
|
||||
|
||||
return inside;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert degrees to radians
|
||||
*/
|
||||
private static toRad(value: number): number {
|
||||
return (value * Math.PI) / 180;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate distance between two points in kilometers using Haversine formula
|
||||
*/
|
||||
static getDistanceKm(lat1: number, lng1: number, lat2: number, lng2: number): number {
|
||||
const R = 6371; // Earth radius in KM
|
||||
|
||||
const dLat = this.toRad(lat2 - lat1);
|
||||
const dLng = this.toRad(lng2 - lng1);
|
||||
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(this.toRad(lat1)) * Math.cos(this.toRad(lat2)) * Math.sin(dLng / 2) ** 2;
|
||||
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
|
||||
return R * c;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate distance between two points in kilometers (rounded)
|
||||
*/
|
||||
static getDistanceKmRounded(
|
||||
lat1: number,
|
||||
lng1: number,
|
||||
lat2: number,
|
||||
lng2: number,
|
||||
precision = 2,
|
||||
): number {
|
||||
const distance = this.getDistanceKm(lat1, lng1, lat2, lng2);
|
||||
return Number(distance.toFixed(precision));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { EntityManager } from '@mikro-orm/postgresql';
|
||||
import { Inventory } from '../../inventory/entities/inventory.entity';
|
||||
|
||||
|
||||
@Injectable()
|
||||
export class FoodStockCrone {
|
||||
private readonly logger = new Logger(FoodStockCrone.name);
|
||||
@@ -18,22 +17,10 @@ export class FoodStockCrone {
|
||||
try {
|
||||
this.logger.debug('Starting daily inventory reset (availableStock = totalStock)');
|
||||
|
||||
const inventories = await this.em.find(Inventory, {});
|
||||
if (!inventories || inventories.length === 0) {
|
||||
this.logger.debug('No inventory records found to reset');
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log(`Resetting available stock for ${inventories.length} inventory records`);
|
||||
|
||||
|
||||
|
||||
await this.em.transactional(async em => {
|
||||
for (const inv of inventories) {
|
||||
// reload inside transaction to avoid concurrency issues
|
||||
const record = await em.findOne(Inventory, { id: inv.id });
|
||||
if (!record) continue;
|
||||
record.availableStock = record.totalStock;
|
||||
em.persist(record);
|
||||
}
|
||||
|
||||
await em.flush();
|
||||
});
|
||||
|
||||
|
||||
@@ -2,8 +2,7 @@ import { Cascade, Collection, Entity, Index, ManyToOne, OneToMany, Property, One
|
||||
import { Category } from './category.entity';
|
||||
import { BaseEntity } from '../../../common/entities/base.entity';
|
||||
import { Restaurant } from '../../../modules/restaurants/entities/restaurant.entity';
|
||||
import { Review } from 'src/modules/review/entities/review.entity';
|
||||
import { Inventory } from 'src/modules/inventory/entities/inventory.entity';
|
||||
|
||||
import { MealType } from '../interface/food.interface';
|
||||
import { Favorite } from './favorite.entity';
|
||||
|
||||
@@ -18,15 +17,6 @@ export class Food extends BaseEntity {
|
||||
@ManyToOne(() => Category)
|
||||
category: Category;
|
||||
|
||||
@OneToMany(() => Review, review => review.food, { cascade: [Cascade.ALL], orphanRemoval: true })
|
||||
reviews = new Collection<Review>(this);
|
||||
|
||||
@OneToOne(() => Inventory, {
|
||||
mappedBy: 'food',
|
||||
nullable: true,
|
||||
})
|
||||
inventory?: Inventory;
|
||||
|
||||
@OneToMany(() => Favorite, favorite => favorite.food)
|
||||
favorites = new Collection<Favorite>(this);
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import { RestRepository } from 'src/modules/restaurants/repositories/rest.reposi
|
||||
import { CacheService } from '../../utils/cache.service';
|
||||
import { Favorite } from '../entities/favorite.entity';
|
||||
import { MealType } from '../interface/food.interface';
|
||||
import { Inventory } from 'src/modules/inventory/entities/inventory.entity';
|
||||
|
||||
@Injectable()
|
||||
export class FoodService {
|
||||
@@ -33,7 +32,7 @@ export class FoodService {
|
||||
throw new NotFoundException(CategoryMessage.NOT_FOUND);
|
||||
}
|
||||
|
||||
const { food, inventory } = await this.em.transactional(async em => {
|
||||
const { food } = await this.em.transactional(async em => {
|
||||
// prepare data with defaults for required fields so repository.create typing is satisfied
|
||||
const data: RequiredEntityData<Food> = {
|
||||
desc: rest.desc,
|
||||
@@ -58,23 +57,18 @@ export class FoodService {
|
||||
};
|
||||
|
||||
const food = em.create(Food, data);
|
||||
const newInventoryRecord = em.create(Inventory, {
|
||||
food: food,
|
||||
availableStock: dailyStock,
|
||||
totalStock: dailyStock,
|
||||
});
|
||||
|
||||
|
||||
await em.flush();
|
||||
return { food, inventory: newInventoryRecord };
|
||||
return { food };
|
||||
});
|
||||
|
||||
// Re-load created entities with the primary EM to ensure they're attached
|
||||
const savedFood = food?.id
|
||||
? await this.foodRepository.findOne({ id: food.id }, { populate: ['category', 'restaurant'] })
|
||||
: null;
|
||||
const savedInventory = inventory?.id ? await this.em.findOne(Inventory, { id: inventory.id }) : inventory;
|
||||
|
||||
return { food: savedFood ?? food, inventory: savedInventory ?? inventory };
|
||||
return { food: savedFood ?? food };
|
||||
}
|
||||
|
||||
findAll(restId: string, dto: FindFoodsDto) {
|
||||
@@ -85,7 +79,7 @@ export class FoodService {
|
||||
* Public food detail (only active foods are visible).
|
||||
*/
|
||||
async findPublicById(foodId: string, userId?: string): Promise<any> {
|
||||
const food = await this.foodRepository.findOne({ id: foodId, isActive: true }, { populate: ['category', 'inventory'] });
|
||||
const food = await this.foodRepository.findOne({ id: foodId, isActive: true }, { populate: ['category',] });
|
||||
if (!food) throw new NotFoundException(FoodMessage.NOT_FOUND);
|
||||
let isFavorite = false;
|
||||
if (userId) {
|
||||
@@ -101,7 +95,7 @@ export class FoodService {
|
||||
* Admin food detail (scoped to the authenticated restaurant).
|
||||
*/
|
||||
async findAdminById(restId: string, id: string): Promise<Food> {
|
||||
const food = await this.foodRepository.findOne({ id, restaurant: { id: restId } }, { populate: ['category', 'inventory'] });
|
||||
const food = await this.foodRepository.findOne({ id, restaurant: { id: restId } }, { populate: ['category',] });
|
||||
|
||||
if (!food) throw new NotFoundException(FoodMessage.NOT_FOUND);
|
||||
return food;
|
||||
@@ -206,18 +200,6 @@ export class FoodService {
|
||||
await this.em.transactional(async em => {
|
||||
await em.persistAndFlush(food);
|
||||
|
||||
if (typeof dailyStock !== 'undefined') {
|
||||
const inventoryRecord = await em.findOne(Inventory, { food: food });
|
||||
if (inventoryRecord) {
|
||||
inventoryRecord.totalStock = dailyStock;
|
||||
} else {
|
||||
em.create(Inventory, {
|
||||
food: food,
|
||||
availableStock: dailyStock,
|
||||
totalStock: dailyStock,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await em.flush();
|
||||
});
|
||||
|
||||
@@ -48,7 +48,7 @@ export class FoodRepository extends EntityRepository<Food> {
|
||||
limit,
|
||||
offset,
|
||||
orderBy: { [orderBy]: order.toLowerCase() as 'asc' | 'desc' },
|
||||
populate: ['category','inventory'],
|
||||
populate: ['category' ],
|
||||
});
|
||||
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common';
|
||||
import { IconService } from '../providers/icon.service';
|
||||
import { GroupService } from '../providers/group.service';
|
||||
import { CreateIconDto } from '../dto/create-icon.dto';
|
||||
import { UpdateIconDto } from '../dto/update-icon.dto';
|
||||
import { CreateGroupDto } from '../dto/create-group.dto';
|
||||
import { UpdateGroupDto } from '../dto/update-group.dto';
|
||||
import { ApiTags, ApiOperation, ApiParam, ApiBody, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { SuperAdminAuthGuard } from 'src/modules/auth/guards/superAdminAuth.guard';
|
||||
import { UseGuards } from '@nestjs/common';
|
||||
import { AdminAuthGuard } from 'src/modules/auth/guards/adminAuth.guard';
|
||||
|
||||
@ApiTags('icons')
|
||||
@Controller()
|
||||
export class IconsController {
|
||||
constructor(
|
||||
private readonly iconService: IconService,
|
||||
private readonly groupService: GroupService,
|
||||
) {}
|
||||
|
||||
@Get('admin/groups/icons')
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Get all icons' })
|
||||
findAllPublicIcons() {
|
||||
return this.groupService.findAllGroups();
|
||||
}
|
||||
|
||||
/**
|
||||
* Super Admin Endpoints
|
||||
*/
|
||||
@Post('super-admin/icons')
|
||||
@UseGuards(SuperAdminAuthGuard)
|
||||
@ApiOperation({ summary: 'Create icon' })
|
||||
@ApiBody({ type: CreateIconDto })
|
||||
createIcon(@Body() dto: CreateIconDto) {
|
||||
return this.iconService.create(dto);
|
||||
}
|
||||
|
||||
@Get('super-admin/icons')
|
||||
@UseGuards(SuperAdminAuthGuard)
|
||||
@ApiOperation({ summary: 'Get all icons' })
|
||||
findAllIcons() {
|
||||
return this.iconService.findAll();
|
||||
}
|
||||
|
||||
@Get('super-admin/icons/:id')
|
||||
@UseGuards(SuperAdminAuthGuard)
|
||||
@ApiOperation({ summary: 'Get icon by id' })
|
||||
@ApiParam({ name: 'id', required: true, type: String })
|
||||
findOneIcon(@Param('id') id: string) {
|
||||
return this.iconService.findOne(id);
|
||||
}
|
||||
|
||||
@Patch('super-admin/icons/:id')
|
||||
@UseGuards(SuperAdminAuthGuard)
|
||||
@ApiOperation({ summary: 'Update icon' })
|
||||
@ApiParam({ name: 'id' })
|
||||
@ApiBody({ type: UpdateIconDto })
|
||||
updateIcon(@Param('id') id: string, @Body() dto: UpdateIconDto) {
|
||||
return this.iconService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete('super-admin/icons/:id')
|
||||
@UseGuards(SuperAdminAuthGuard)
|
||||
@ApiOperation({ summary: 'Delete icon' })
|
||||
@ApiParam({ name: 'id' })
|
||||
removeIcon(@Param('id') id: string) {
|
||||
return this.iconService.remove(id);
|
||||
}
|
||||
|
||||
// Group endpoints (must come before :id routes to avoid route conflicts)
|
||||
@Post('super-admin/icons/groups')
|
||||
@UseGuards(SuperAdminAuthGuard)
|
||||
@ApiOperation({ summary: 'Create icon group' })
|
||||
@ApiBody({ type: CreateGroupDto })
|
||||
createGroup(@Body() dto: CreateGroupDto) {
|
||||
return this.groupService.create(dto);
|
||||
}
|
||||
|
||||
@Get('super-admin/icons/groups')
|
||||
@UseGuards(SuperAdminAuthGuard)
|
||||
@ApiOperation({ summary: 'Get all icon groups' })
|
||||
async findAllGroups() {
|
||||
console.log('find icona');
|
||||
const a = await this.groupService.findAllGroups();
|
||||
console.log(a);
|
||||
return a;
|
||||
}
|
||||
|
||||
@Get('super-admin/icons/groups/:id')
|
||||
@UseGuards(SuperAdminAuthGuard)
|
||||
@ApiOperation({ summary: 'Get icon group by id' })
|
||||
@ApiParam({ name: 'id', required: true, type: String })
|
||||
findOneGroup(@Param('id') id: string) {
|
||||
return this.groupService.findOne(id);
|
||||
}
|
||||
|
||||
@Patch('super-admin/icons/groups/:id')
|
||||
@UseGuards(SuperAdminAuthGuard)
|
||||
@ApiOperation({ summary: 'Update icon group' })
|
||||
@ApiParam({ name: 'id' })
|
||||
updateGroup(@Param('id') id: string, @Body() dto: UpdateGroupDto) {
|
||||
return this.groupService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete('super-admin/icons/groups/:id')
|
||||
@UseGuards(SuperAdminAuthGuard)
|
||||
@ApiOperation({ summary: 'Delete icon group' })
|
||||
@ApiParam({ name: 'id' })
|
||||
removeGroup(@Param('id') id: string) {
|
||||
return this.groupService.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { IsString } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class CreateGroupDto {
|
||||
@IsString()
|
||||
@ApiProperty({ example: 'Social Media' })
|
||||
name!: string;
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { IsString } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class CreateIconDto {
|
||||
@IsString()
|
||||
@ApiProperty({ example: 'https://example.com/icons/facebook.svg' })
|
||||
url!: string;
|
||||
|
||||
@IsString()
|
||||
@ApiProperty({ example: 'group-id-here' })
|
||||
groupId!: string;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateGroupDto } from './create-group.dto';
|
||||
|
||||
export class UpdateGroupDto extends PartialType(CreateGroupDto) {}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateIconDto } from './create-icon.dto';
|
||||
|
||||
export class UpdateIconDto extends PartialType(CreateIconDto) {}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { Entity, Index, Property, Collection, OneToMany, Cascade } from '@mikro-orm/core';
|
||||
import { BaseEntity } from '../../../common/entities/base.entity';
|
||||
import { Icon } from './icon.entity';
|
||||
|
||||
@Entity({ tableName: 'icon_groups' })
|
||||
export class Group extends BaseEntity {
|
||||
@Property()
|
||||
name!: string;
|
||||
|
||||
@OneToMany(() => Icon, icon => icon.group,{
|
||||
cascade: [Cascade.ALL],
|
||||
orphanRemoval: true,
|
||||
})
|
||||
icons = new Collection<Icon>(this);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { Entity, Index, Property, ManyToOne } from '@mikro-orm/core';
|
||||
import { BaseEntity } from '../../../common/entities/base.entity';
|
||||
import { Group } from './group.entity';
|
||||
|
||||
@Entity({ tableName: 'icons' })
|
||||
@Index({ properties: ['group'] })
|
||||
export class Icon extends BaseEntity {
|
||||
@Property()
|
||||
url!: string;
|
||||
|
||||
@ManyToOne(() => Group)
|
||||
group!: Group;
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MikroOrmModule } from '@mikro-orm/nestjs';
|
||||
import { Icon } from './entities/icon.entity';
|
||||
import { Group } from './entities/group.entity';
|
||||
import { IconService } from './providers/icon.service';
|
||||
import { GroupService } from './providers/group.service';
|
||||
import { IconRepository } from './repositories/icon.repository';
|
||||
import { GroupRepository } from './repositories/group.repository';
|
||||
import { IconsController } from './controllers/icons.controller';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
|
||||
@Module({
|
||||
imports: [MikroOrmModule.forFeature([Icon, Group]), JwtModule],
|
||||
controllers: [IconsController],
|
||||
providers: [IconService, GroupService, IconRepository, GroupRepository],
|
||||
exports: [IconRepository, GroupRepository, IconService, GroupService],
|
||||
})
|
||||
export class IconsModule {}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { CreateGroupDto } from '../dto/create-group.dto';
|
||||
import { UpdateGroupDto } from '../dto/update-group.dto';
|
||||
import { GroupRepository } from '../repositories/group.repository';
|
||||
import { EntityManager } from '@mikro-orm/postgresql';
|
||||
import { RequiredEntityData } from '@mikro-orm/core';
|
||||
import { Group } from '../entities/group.entity';
|
||||
import { GroupMessage } from 'src/common/enums/message.enum';
|
||||
|
||||
@Injectable()
|
||||
export class GroupService {
|
||||
constructor(
|
||||
private readonly groupRepository: GroupRepository,
|
||||
private readonly em: EntityManager,
|
||||
) {}
|
||||
|
||||
async create(dto: CreateGroupDto): Promise<Group> {
|
||||
const data: RequiredEntityData<Group> = {
|
||||
name: dto.name,
|
||||
};
|
||||
|
||||
const group = this.groupRepository.create(data);
|
||||
await this.em.persistAndFlush(group);
|
||||
return group;
|
||||
}
|
||||
|
||||
async findOne(id: string): Promise<Group> {
|
||||
const group = await this.groupRepository.findOne({ id }, { populate: ['icons'] });
|
||||
if (!group) {
|
||||
throw new NotFoundException(GroupMessage.NOT_FOUND);
|
||||
}
|
||||
return group;
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateGroupDto): Promise<Group> {
|
||||
const group = await this.groupRepository.findOne({ id });
|
||||
if (!group) {
|
||||
throw new NotFoundException(GroupMessage.NOT_FOUND);
|
||||
}
|
||||
this.em.assign(group, dto);
|
||||
await this.em.persistAndFlush(group);
|
||||
return group;
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
const group = await this.groupRepository.findOne({ id }, { populate: ['icons'] });
|
||||
if (!group) {
|
||||
throw new NotFoundException(GroupMessage.NOT_FOUND);
|
||||
}
|
||||
await this.em.removeAndFlush(group);
|
||||
}
|
||||
|
||||
findAllGroups(): Promise<Group[]> {
|
||||
return this.groupRepository.find({}, { populate: ['icons'] });
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { CreateIconDto } from '../dto/create-icon.dto';
|
||||
import { UpdateIconDto } from '../dto/update-icon.dto';
|
||||
import { IconRepository } from '../repositories/icon.repository';
|
||||
import { EntityManager } from '@mikro-orm/postgresql';
|
||||
import { RequiredEntityData } from '@mikro-orm/core';
|
||||
import { Icon } from '../entities/icon.entity';
|
||||
import { IconMessage, GroupMessage } from 'src/common/enums/message.enum';
|
||||
import { Group } from '../entities/group.entity';
|
||||
|
||||
@Injectable()
|
||||
export class IconService {
|
||||
constructor(
|
||||
private readonly iconRepository: IconRepository,
|
||||
private readonly em: EntityManager,
|
||||
) {}
|
||||
|
||||
async create(dto: CreateIconDto): Promise<Icon> {
|
||||
const group = await this.em.findOne(Group, { id: dto.groupId });
|
||||
if (!group) {
|
||||
throw new NotFoundException(GroupMessage.NOT_FOUND);
|
||||
}
|
||||
|
||||
const data: RequiredEntityData<Icon> = {
|
||||
url: dto.url,
|
||||
group: group,
|
||||
};
|
||||
|
||||
const icon = this.iconRepository.create(data);
|
||||
await this.em.persistAndFlush(icon);
|
||||
return icon;
|
||||
}
|
||||
|
||||
async findAll(): Promise<Icon[]> {
|
||||
return this.iconRepository.find({}, { populate: ['group'] });
|
||||
}
|
||||
|
||||
async findOne(id: string): Promise<Icon> {
|
||||
const icon = await this.iconRepository.findOne({ id }, { populate: ['group'] });
|
||||
if (!icon) {
|
||||
throw new NotFoundException(IconMessage.NOT_FOUND);
|
||||
}
|
||||
return icon;
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateIconDto): Promise<Icon> {
|
||||
const icon = await this.iconRepository.findOne({ id });
|
||||
if (!icon) {
|
||||
throw new NotFoundException(IconMessage.NOT_FOUND);
|
||||
}
|
||||
|
||||
if (dto.groupId) {
|
||||
const group = await this.em.findOne(Group, { id: dto.groupId });
|
||||
if (!group) {
|
||||
throw new NotFoundException(GroupMessage.NOT_FOUND);
|
||||
}
|
||||
icon.group = group;
|
||||
}
|
||||
|
||||
this.em.assign(icon, {
|
||||
url: dto.url,
|
||||
});
|
||||
|
||||
await this.em.persistAndFlush(icon);
|
||||
return icon;
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
const icon = await this.iconRepository.findOne({ id });
|
||||
if (!icon) {
|
||||
throw new NotFoundException(IconMessage.NOT_FOUND);
|
||||
}
|
||||
await this.em.removeAndFlush(icon);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { EntityManager, EntityRepository } from '@mikro-orm/postgresql';
|
||||
import { Group } from '../entities/group.entity';
|
||||
|
||||
@Injectable()
|
||||
export class GroupRepository extends EntityRepository<Group> {
|
||||
constructor(readonly em: EntityManager) {
|
||||
super(em, Group);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { EntityManager, EntityRepository } from '@mikro-orm/postgresql';
|
||||
import { Icon } from '../entities/icon.entity';
|
||||
|
||||
@Injectable()
|
||||
export class IconRepository extends EntityRepository<Icon> {
|
||||
constructor(readonly em: EntityManager) {
|
||||
super(em, Icon);
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsArray, ValidateNested, ArrayMinSize, IsString, IsNumber, Min } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class BulkReserveFoodItemDto {
|
||||
@ApiProperty({ example: 'food-123', description: 'Food ID' })
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
foodId!: string;
|
||||
|
||||
@ApiProperty({ example: 5, description: 'Quantity to reserve' })
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Type(() => Number)
|
||||
quantity!: number;
|
||||
}
|
||||
|
||||
export class BulkReserveFoodDto {
|
||||
@ApiProperty({
|
||||
description: 'Array of food reservations to create',
|
||||
type: [BulkReserveFoodItemDto],
|
||||
example: [
|
||||
{ foodId: 'food-123', quantity: 5 },
|
||||
{ foodId: 'food-789', quantity: 3 },
|
||||
],
|
||||
})
|
||||
@IsNotEmpty()
|
||||
@IsArray()
|
||||
@ArrayMinSize(1, { message: 'At least one item is required' })
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => BulkReserveFoodItemDto)
|
||||
items!: BulkReserveFoodItemDto[];
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsArray, ValidateNested, ArrayMinSize } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { SetStockDto } from './set-stock.dto';
|
||||
|
||||
export class BulkSetStockItemDto extends SetStockDto {
|
||||
@ApiProperty({ example: 'food-123', description: 'Food ID' })
|
||||
@IsNotEmpty()
|
||||
foodId!: string;
|
||||
}
|
||||
|
||||
export class BulkSetStockDto {
|
||||
@ApiProperty({
|
||||
description: 'Array of stock items to set',
|
||||
type: [BulkSetStockItemDto],
|
||||
example: [
|
||||
{ foodId: 'food-123', totalStock: 100, availableStock: 80 },
|
||||
{ foodId: 'food-456', totalStock: 50, availableStock: 45 },
|
||||
],
|
||||
})
|
||||
@IsNotEmpty()
|
||||
@IsArray()
|
||||
@ArrayMinSize(1, { message: 'At least one item is required' })
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => BulkSetStockItemDto)
|
||||
items!: BulkSetStockItemDto[];
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export class CreateInventoryDto {}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNumber, Min } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class SetStockDto {
|
||||
@ApiProperty({ example: 100, description: 'Total stock quantity' })
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Type(() => Number)
|
||||
totalStock!: number;
|
||||
|
||||
@ApiProperty({ example: 80, description: 'Available stock quantity' })
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Type(() => Number)
|
||||
availableStock!: number;
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Cascade, Entity, Property, OneToOne } from '@mikro-orm/core';
|
||||
import { BaseEntity } from '../../../common/entities/base.entity';
|
||||
import { Food } from '../../foods/entities/food.entity';
|
||||
|
||||
@Entity({ tableName: 'inventory' })
|
||||
export class Inventory extends BaseEntity {
|
||||
@OneToOne(() => Food, {
|
||||
cascade: [Cascade.ALL],
|
||||
orphanRemoval: true,
|
||||
})
|
||||
food!: Food;
|
||||
|
||||
@Property({ type: 'int' })
|
||||
totalStock!: number;
|
||||
|
||||
@Property({ type: 'int' })
|
||||
availableStock!: number;
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export enum ReservationStatus {
|
||||
ACTIVE = 'active',
|
||||
CONFIRMED = 'confirmed',
|
||||
}
|
||||
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { Controller, Body, Patch, Param, Post, UseGuards } from '@nestjs/common';
|
||||
import { InventoryService } from './inventory.service';
|
||||
import { SetStockDto } from './dto/set-stock.dto';
|
||||
import { BulkSetStockDto } from './dto/bulk-set-stock.dto';
|
||||
import { ApiTags, ApiOperation, ApiBody, ApiParam, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { AdminAuthGuard } from 'src/modules/auth/guards/adminAuth.guard';
|
||||
import { RestId } from 'src/common/decorators';
|
||||
import { Inventory } from './entities/inventory.entity';
|
||||
import { Permissions } from 'src/common/decorators/permissions.decorator';
|
||||
import { Permission } from 'src/common/enums/permission.enum';
|
||||
|
||||
@ApiTags('inventory')
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@Permissions(Permission.MANAGE_FOODS)
|
||||
@Controller()
|
||||
export class InventoryController {
|
||||
constructor(private readonly inventoryService: InventoryService) { }
|
||||
|
||||
@Patch('admin/inventory/food/:foodId/stock')
|
||||
@ApiOperation({ summary: 'Set available and total stock for a food item' })
|
||||
@ApiParam({ name: 'foodId', description: 'Food ID' })
|
||||
@ApiBody({ type: SetStockDto })
|
||||
setStockForFood(
|
||||
@Param('foodId') foodId: string,
|
||||
@RestId() restaurantId: string,
|
||||
@Body() setStockDto: SetStockDto,
|
||||
): Promise<Inventory> {
|
||||
return this.inventoryService.setStockForFood(foodId, restaurantId, setStockDto);
|
||||
}
|
||||
|
||||
@Post('admin/inventory/foods/stock/bulk')
|
||||
@ApiOperation({ summary: 'Bulk set available and total stock for multiple food items' })
|
||||
@ApiBody({ type: BulkSetStockDto })
|
||||
bulkSetStockForFoods(@RestId() restaurantId: string, @Body() bulkSetStockDto: BulkSetStockDto): Promise<Inventory[]> {
|
||||
return this.inventoryService.bulkSetStockForFoods(restaurantId, bulkSetStockDto);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MikroOrmModule } from '@mikro-orm/nestjs';
|
||||
import { InventoryService } from './inventory.service';
|
||||
import { InventoryController } from './inventory.controller';
|
||||
import { Inventory } from './entities/inventory.entity';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
|
||||
@Module({
|
||||
imports: [MikroOrmModule.forFeature([Inventory]), AuthModule, JwtModule],
|
||||
controllers: [InventoryController],
|
||||
providers: [InventoryService],
|
||||
exports: [InventoryService],
|
||||
})
|
||||
export class InventoryModule {}
|
||||
@@ -1,227 +0,0 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { EntityManager } from '@mikro-orm/postgresql';
|
||||
import { SetStockDto } from './dto/set-stock.dto';
|
||||
import { BulkSetStockDto } from './dto/bulk-set-stock.dto';
|
||||
import { BulkReserveFoodDto } from './dto/bulk-reserve-food.dto';
|
||||
import { Inventory } from './entities/inventory.entity';
|
||||
import { Food } from '../foods/entities/food.entity';
|
||||
import { Restaurant } from '../restaurants/entities/restaurant.entity';
|
||||
|
||||
@Injectable()
|
||||
export class InventoryService {
|
||||
constructor(private readonly em: EntityManager) {}
|
||||
|
||||
async setStockForFood(foodId: string, restaurantId: string, setStockDto: SetStockDto): Promise<Inventory> {
|
||||
// Validate that availableStock doesn't exceed totalStock
|
||||
if (setStockDto.availableStock > setStockDto.totalStock) {
|
||||
throw new BadRequestException('Available stock cannot exceed total stock');
|
||||
}
|
||||
|
||||
// Find food and verify it belongs to the restaurant
|
||||
const food = await this.em.findOne(Food, { id: foodId }, { populate: ['restaurant'] });
|
||||
if (!food) {
|
||||
throw new NotFoundException(`Food with ID ${foodId} not found`);
|
||||
}
|
||||
|
||||
if (food.restaurant.id !== restaurantId) {
|
||||
throw new BadRequestException(`Food does not belong to restaurant ${restaurantId}`);
|
||||
}
|
||||
|
||||
// Find or create inventory record
|
||||
let inventory = await this.em.findOne(Inventory, {
|
||||
food: { id: foodId, restaurant: { id: restaurantId } },
|
||||
});
|
||||
|
||||
if (!inventory) {
|
||||
// Create new inventory record
|
||||
const restaurant = await this.em.findOne(Restaurant, { id: restaurantId });
|
||||
if (!restaurant) {
|
||||
throw new NotFoundException(`Restaurant with ID ${restaurantId} not found`);
|
||||
}
|
||||
|
||||
inventory = this.em.create(Inventory, {
|
||||
food,
|
||||
totalStock: setStockDto.totalStock,
|
||||
availableStock: setStockDto.availableStock,
|
||||
});
|
||||
} else {
|
||||
// Update existing inventory record
|
||||
inventory.totalStock = setStockDto.totalStock;
|
||||
inventory.availableStock = setStockDto.availableStock;
|
||||
}
|
||||
|
||||
await this.em.flush();
|
||||
|
||||
return inventory;
|
||||
}
|
||||
|
||||
async bulkSetStockForFoods(restaurantId: string, bulkSetStockDto: BulkSetStockDto): Promise<Inventory[]> {
|
||||
const { items } = bulkSetStockDto;
|
||||
// Validate all items first
|
||||
for (const item of items) {
|
||||
if (item.availableStock > item.totalStock) {
|
||||
throw new BadRequestException(`Available stock cannot exceed total stock for food ${item.foodId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Get all food IDs
|
||||
const foodIds = items.map(item => item.foodId);
|
||||
|
||||
// Load all foods in one query
|
||||
const foods = await this.em.find(Food, { id: { $in: foodIds } }, { populate: ['restaurant'] });
|
||||
|
||||
// Verify all foods exist and belong to the restaurant
|
||||
const foodMap = new Map<string, Food>();
|
||||
for (const food of foods) {
|
||||
if (food.restaurant.id !== restaurantId) {
|
||||
throw new BadRequestException(`Food ${food.id} does not belong to restaurant ${restaurantId}`);
|
||||
}
|
||||
foodMap.set(food.id, food);
|
||||
}
|
||||
|
||||
// Check for missing foods
|
||||
const missingFoodIds = foodIds.filter(id => !foodMap.has(id));
|
||||
if (missingFoodIds.length > 0) {
|
||||
throw new NotFoundException(`Foods not found: ${missingFoodIds.join(', ')}`);
|
||||
}
|
||||
|
||||
// Load all existing inventories in one query
|
||||
const existingInventories = await this.em.find(Inventory, {
|
||||
food: { id: { $in: foodIds }, restaurant: { id: restaurantId } },
|
||||
});
|
||||
|
||||
const inventoryMap = new Map<string, Inventory>();
|
||||
for (const inventory of existingInventories) {
|
||||
inventoryMap.set(inventory.food.id, inventory);
|
||||
}
|
||||
|
||||
// Get restaurant once
|
||||
const restaurant = await this.em.findOne(Restaurant, { id: restaurantId });
|
||||
if (!restaurant) {
|
||||
throw new NotFoundException(`Restaurant with ID ${restaurantId} not found`);
|
||||
}
|
||||
|
||||
// Process all items
|
||||
const results: Inventory[] = [];
|
||||
for (const item of items) {
|
||||
const food = foodMap.get(item.foodId)!;
|
||||
let inventory = inventoryMap.get(item.foodId);
|
||||
|
||||
if (!inventory) {
|
||||
// Create new inventory record
|
||||
inventory = this.em.create(Inventory, {
|
||||
food,
|
||||
totalStock: item.totalStock,
|
||||
availableStock: item.availableStock,
|
||||
});
|
||||
} else {
|
||||
// Update existing inventory record
|
||||
inventory.totalStock = item.totalStock;
|
||||
inventory.availableStock = item.availableStock;
|
||||
}
|
||||
|
||||
results.push(inventory);
|
||||
}
|
||||
|
||||
// Flush all changes at once
|
||||
await this.em.flush();
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async deductFromInventory(em: EntityManager, bulkReserveFoodDto: BulkReserveFoodDto): Promise<Inventory[]> {
|
||||
return em.transactional(async em => {
|
||||
const { items } = bulkReserveFoodDto;
|
||||
// Get all unique food IDs
|
||||
const foodIds = [...new Set(items.map(item => item.foodId))];
|
||||
|
||||
// Load all foods in one query
|
||||
const foods = await em.find(Food, { id: { $in: foodIds } });
|
||||
|
||||
// Verify all foods exist and belong to the restaurant
|
||||
const foodMap = new Map<string, Food>();
|
||||
for (const food of foods) {
|
||||
foodMap.set(food.id, food);
|
||||
}
|
||||
|
||||
// Check for missing foods
|
||||
const missingFoodIds = foodIds.filter(id => !foodMap.has(id));
|
||||
if (missingFoodIds.length > 0) {
|
||||
throw new NotFoundException(`Foods not found: ${missingFoodIds.join(', ')}`);
|
||||
}
|
||||
|
||||
// Load all existing inventories in one query
|
||||
const existingInventories = await em.find(Inventory, {
|
||||
food: { id: { $in: foodIds } },
|
||||
});
|
||||
|
||||
const inventoryMap = new Map<string, Inventory>();
|
||||
for (const inventory of existingInventories) {
|
||||
inventoryMap.set(inventory.food.id, inventory);
|
||||
}
|
||||
|
||||
// Validate stock availability and create reservations
|
||||
const inventories: Inventory[] = [];
|
||||
for (const item of items) {
|
||||
const inventory = inventoryMap.get(item.foodId);
|
||||
|
||||
// Check if inventory exists
|
||||
if (!inventory) {
|
||||
throw new NotFoundException(`Inventory not found for food ${item.foodId}`);
|
||||
}
|
||||
|
||||
// Check if available stock is sufficient
|
||||
if (inventory.availableStock < item.quantity) {
|
||||
throw new BadRequestException(
|
||||
`Insufficient stock for food ${item.foodId}. Available: ${inventory.availableStock}, Requested: ${item.quantity}`,
|
||||
);
|
||||
}
|
||||
inventory.availableStock -= item.quantity;
|
||||
inventories.push(inventory);
|
||||
em.persist(inventory);
|
||||
}
|
||||
return inventories;
|
||||
});
|
||||
}
|
||||
|
||||
async restoreToInventory(em: EntityManager, bulkRestoreDto: BulkReserveFoodDto): Promise<Inventory[]> {
|
||||
return em.transactional(async em => {
|
||||
const { items } = bulkRestoreDto;
|
||||
|
||||
const foodIds = [...new Set(items.map(item => item.foodId))];
|
||||
|
||||
const foods = await em.find(Food, { id: { $in: foodIds } });
|
||||
const foodMap = new Map<string, Food>();
|
||||
for (const food of foods) {
|
||||
foodMap.set(food.id, food);
|
||||
}
|
||||
|
||||
const missingFoodIds = foodIds.filter(id => !foodMap.has(id));
|
||||
if (missingFoodIds.length > 0) {
|
||||
throw new NotFoundException(`Foods not found: ${missingFoodIds.join(', ')}`);
|
||||
}
|
||||
|
||||
const existingInventories = await em.find(Inventory, {
|
||||
food: { id: { $in: foodIds } },
|
||||
});
|
||||
|
||||
const inventoryMap = new Map<string, Inventory>();
|
||||
for (const inventory of existingInventories) {
|
||||
inventoryMap.set(inventory.food.id, inventory);
|
||||
}
|
||||
|
||||
const inventories: Inventory[] = [];
|
||||
for (const item of items) {
|
||||
const inventory = inventoryMap.get(item.foodId);
|
||||
if (!inventory) {
|
||||
throw new NotFoundException(`Inventory not found for food ${item.foodId}`);
|
||||
}
|
||||
inventory.availableStock += item.quantity;
|
||||
inventories.push(inventory);
|
||||
em.persist(inventory);
|
||||
}
|
||||
|
||||
return inventories;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
import { Controller, Get, Post, Param, UseGuards, Patch, Query, Body } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiHeader, ApiBody } from '@nestjs/swagger';
|
||||
import { OrdersService } from '../providers/orders.service';
|
||||
import { AuthGuard } from '../../auth/guards/auth.guard';
|
||||
import { UserId } from '../../../common/decorators/user-id.decorator';
|
||||
import { RestId } from 'src/common/decorators/rest-id.decorator';
|
||||
import { AdminAuthGuard } from '../../auth/guards/adminAuth.guard';
|
||||
import { FindOrdersDto } from '../dto/find-orders.dto';
|
||||
import { OrderStatus } from '../interface/order.interface';
|
||||
import { API_HEADER_SLUG } from 'src/common/constants/index';
|
||||
import { UpdateOrderStatusDto } from '../dto/update-order-status.dto';
|
||||
import { Permissions } from 'src/common/decorators/permissions.decorator';
|
||||
import { Permission } from 'src/common/enums/permission.enum';
|
||||
|
||||
@ApiTags('orders')
|
||||
@ApiBearerAuth()
|
||||
@Controller()
|
||||
export class OrdersController {
|
||||
constructor(private readonly ordersService: OrdersService) { }
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@Post('public/checkout')
|
||||
@ApiHeader(API_HEADER_SLUG)
|
||||
@ApiOperation({ summary: 'Checkout : create order and payment record' })
|
||||
checkout(@UserId() userId: string, @RestId() restaurantId: string) {
|
||||
return this.ordersService.checkout(userId, restaurantId);
|
||||
}
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@Get('public/orders')
|
||||
@ApiHeader(API_HEADER_SLUG)
|
||||
@ApiOperation({ summary: 'Get all orders with pagination and filters' })
|
||||
findAll(@RestId() restId: string, @Query() dto: FindOrdersDto, @UserId() userId: string) {
|
||||
return this.ordersService.findAllForUser(restId, dto, userId);
|
||||
}
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@ApiOperation({ summary: 'Get an order By id for User' })
|
||||
@ApiParam({ name: 'orderId', description: 'Order ID' })
|
||||
@ApiHeader(API_HEADER_SLUG)
|
||||
@Get('public/orders/:orderId')
|
||||
findOne(@Param('orderId') orderId: string, @RestId() restId: string) {
|
||||
return this.ordersService.findOne(orderId, restId);
|
||||
}
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@Patch('public/orders/:id/:status')
|
||||
@ApiParam({
|
||||
name: 'status',
|
||||
description: 'Order status',
|
||||
enum: OrderStatus,
|
||||
})
|
||||
@ApiHeader(API_HEADER_SLUG)
|
||||
@ApiBody({ type: UpdateOrderStatusDto })
|
||||
@ApiOperation({ summary: 'Update status of an order By User' })
|
||||
@ApiParam({ name: 'id', description: 'Order ID' })
|
||||
cancelOrder(
|
||||
@Body() dto: UpdateOrderStatusDto,
|
||||
@Param('id') orderId: string,
|
||||
@Param('status') status: OrderStatus,
|
||||
@RestId() restId: string,
|
||||
) {
|
||||
return this.ordersService.changeOrderStatus(orderId, restId, status, 'user', dto?.desc);
|
||||
}
|
||||
|
||||
/******************** Admin Routes **********************/
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@Permissions(Permission.MANAGE_ORDERS)
|
||||
@Get('admin/orders')
|
||||
@ApiOperation({ summary: 'Get all orders with pagination and filters' })
|
||||
findAllAdmin(@RestId() restId: string, @Query() dto: FindOrdersDto) {
|
||||
return this.ordersService.findAllForAdmin(restId, dto);
|
||||
}
|
||||
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@Permissions(Permission.MANAGE_ORDERS)
|
||||
@ApiOperation({ summary: 'Get an order By id for User' })
|
||||
@ApiParam({ name: 'orderId', description: 'Order ID' })
|
||||
@Get('admin/orders/:orderId')
|
||||
findOneAsAdmin(@Param('orderId') orderId: string, @RestId() restId: string) {
|
||||
return this.ordersService.findOne(orderId, restId);
|
||||
}
|
||||
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@Permissions(Permission.MANAGE_ORDERS)
|
||||
@Patch('admin/orders/:orderId/:status')
|
||||
@ApiOperation({ summary: 'Update an order status' })
|
||||
@ApiParam({ name: 'orderId', description: 'Order ID' })
|
||||
@ApiBody({ type: UpdateOrderStatusDto })
|
||||
@ApiParam({
|
||||
name: 'status',
|
||||
description: 'Order status',
|
||||
enum: OrderStatus,
|
||||
})
|
||||
updateStatus(
|
||||
@Param('orderId') orderId: string,
|
||||
@Body() dto: UpdateOrderStatusDto,
|
||||
@Param('status') status: OrderStatus,
|
||||
@RestId() restId: string,
|
||||
) {
|
||||
return this.ordersService.changeOrderStatus(orderId, restId, status, 'admin', dto?.desc);
|
||||
}
|
||||
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@Permissions(Permission.VIEW_REPORTS)
|
||||
@ApiOperation({ summary: 'Get Stats for report page' })
|
||||
@Get('admin/orders/stats')
|
||||
findStats(@RestId() restId: string) {
|
||||
return this.ordersService.getStats(restId);
|
||||
}
|
||||
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@Permissions(Permission.VIEW_REPORTS)
|
||||
@ApiOperation({ summary: 'Get food sales pie chart data for last month' })
|
||||
@ApiHeader(API_HEADER_SLUG)
|
||||
@Get('admin/orders/food-sales-pie-chart')
|
||||
getFoodSalesPieChart(@RestId() restId: string) {
|
||||
return this.ordersService.getFoodSalesPieChart(restId);
|
||||
}
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { EntityManager } from '@mikro-orm/postgresql';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { Payment } from '../../payments/entities/payment.entity';
|
||||
import { PaymentMethodEnum, PaymentStatusEnum } from '../../payments/interface/payment';
|
||||
import { InventoryService } from '../../inventory/inventory.service';
|
||||
import { OrderStatus } from '../interface/order.interface';
|
||||
import { Order } from '../entities/order.entity';
|
||||
import { OrderStatusChangedEvent } from '../events/order.events';
|
||||
|
||||
@Injectable()
|
||||
export class OrdersCrone {
|
||||
private readonly logger = new Logger(OrdersCrone.name);
|
||||
|
||||
constructor(
|
||||
private readonly em: EntityManager,
|
||||
private readonly inventoryService: InventoryService,
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
) { }
|
||||
|
||||
// run every minute and fail pending online payments older than 15 minutes
|
||||
@Cron('*/1 * * * *', {
|
||||
name: 'failOldOnlinePayments',
|
||||
timeZone: 'UTC',
|
||||
})
|
||||
async cancelOldOnlinePendingOrders() {
|
||||
try {
|
||||
const cutoff = new Date(Date.now() - 15 * 60 * 1000);
|
||||
|
||||
this.logger.debug('Searching for pending online payments older than 15 minutes');
|
||||
|
||||
const payments = await this.em.find(
|
||||
Payment,
|
||||
{
|
||||
method: PaymentMethodEnum.Online,
|
||||
status: PaymentStatusEnum.Pending,
|
||||
createdAt: { $lte: cutoff },
|
||||
},
|
||||
{ populate: ['order', 'order.items', 'order.items.food'] },
|
||||
);
|
||||
|
||||
if (!payments || payments.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log(`Found ${payments.length} stale pending online payments`);
|
||||
|
||||
for (const p of payments) {
|
||||
try {
|
||||
await this.em.transactional(async em => {
|
||||
// reload inside transaction to avoid concurrency issues
|
||||
const payment = await em.findOne(
|
||||
Payment,
|
||||
{ id: p.id },
|
||||
{ populate: ['order', 'order.items', 'order.items.food'] },
|
||||
);
|
||||
if (!payment) return;
|
||||
if (payment.status !== PaymentStatusEnum.Pending) return;
|
||||
|
||||
payment.status = PaymentStatusEnum.Failed;
|
||||
payment.failedAt = new Date();
|
||||
|
||||
if (payment.order) {
|
||||
payment.order.status = OrderStatus.CANCELED;
|
||||
|
||||
// prepare restore payload
|
||||
const items = (payment.order as any).items || [];
|
||||
const restorePayload = {
|
||||
items: items.map((it: any) => ({ foodId: it.food.id, quantity: it.quantity })),
|
||||
};
|
||||
|
||||
if (restorePayload.items.length > 0) {
|
||||
await this.inventoryService.restoreToInventory(em, restorePayload);
|
||||
}
|
||||
}
|
||||
|
||||
em.persist(payment);
|
||||
if (payment.order) em.persist(payment.order);
|
||||
await em.flush();
|
||||
this.logger.log(
|
||||
`Marked payment ${payment.id} and order ${payment.order?.id} as failed and restored inventory`,
|
||||
);
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error(`Error processing payment ${p.id}: ${err.message}`, err.stack);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(`OrdersCrone failed: ${err.message}`, err.stack);
|
||||
}
|
||||
}
|
||||
|
||||
// run every 15 minutes to complete orders that have been in shipped/delivered statuses for more than 3 hours
|
||||
@Cron('*/15 * * * *', {
|
||||
name: 'completeOldDeliveredOrders',
|
||||
timeZone: 'UTC',
|
||||
})
|
||||
async completeOldDeliveredOrders() {
|
||||
try {
|
||||
const cutoff = new Date(Date.now() - 3 * 60 * 60 * 1000); // 3 hours ago
|
||||
|
||||
this.logger.debug('Searching for orders in shipped/delivered statuses older than 3 hours');
|
||||
|
||||
const orders = await this.em.find(
|
||||
Order,
|
||||
{
|
||||
status: {
|
||||
$in: [
|
||||
OrderStatus.SHIPPED,
|
||||
OrderStatus.DELIVERED_TO_WAITER,
|
||||
OrderStatus.DELIVERED_TO_RECEPTIONIST,
|
||||
],
|
||||
},
|
||||
updatedAt: { $lte: cutoff },
|
||||
},
|
||||
{ populate: ['restaurant'] },
|
||||
);
|
||||
|
||||
if (!orders || orders.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log(`Found ${orders.length} orders to mark as completed`);
|
||||
|
||||
for (const order of orders) {
|
||||
try {
|
||||
await this.em.transactional(async em => {
|
||||
// reload inside transaction to avoid concurrency issues
|
||||
const reloadedOrder = await em.findOne(
|
||||
Order,
|
||||
{ id: order.id },
|
||||
{ populate: ['restaurant', 'user'] },
|
||||
);
|
||||
if (!reloadedOrder) return;
|
||||
if (
|
||||
![
|
||||
OrderStatus.SHIPPED,
|
||||
OrderStatus.DELIVERED_TO_WAITER,
|
||||
OrderStatus.DELIVERED_TO_RECEPTIONIST,
|
||||
].includes(reloadedOrder.status)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousStatus = reloadedOrder.status;
|
||||
const restaurantId =
|
||||
typeof reloadedOrder.restaurant === 'string'
|
||||
? reloadedOrder.restaurant
|
||||
: reloadedOrder.restaurant.id;
|
||||
|
||||
// Update order status and history
|
||||
reloadedOrder.status = OrderStatus.COMPLETED;
|
||||
reloadedOrder.history.push({
|
||||
status: OrderStatus.COMPLETED,
|
||||
changedAt: new Date(),
|
||||
desc: 'تکمیل سفارش توسط سیستم',
|
||||
});
|
||||
|
||||
em.persist(reloadedOrder);
|
||||
await em.flush();
|
||||
|
||||
// // Emit event after transaction completes
|
||||
this.eventEmitter.emit(
|
||||
OrderStatusChangedEvent.name,
|
||||
new OrderStatusChangedEvent(
|
||||
reloadedOrder.id,
|
||||
reloadedOrder.user?.id || '',
|
||||
String(reloadedOrder.orderNumber) || '',
|
||||
restaurantId,
|
||||
previousStatus,
|
||||
OrderStatus.COMPLETED,
|
||||
'admin',
|
||||
),
|
||||
);
|
||||
|
||||
this.logger.log(`Marked order ${reloadedOrder.id} as completed`);
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error(`Error processing order ${order.id}: ${err.message}`, err.stack);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(`completeOldDeliveredOrders cron failed: ${err.message}`, err.stack);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import {
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsNumber,
|
||||
Min,
|
||||
IsIn,
|
||||
IsEnum,
|
||||
IsDateString,
|
||||
IsArray,
|
||||
} from 'class-validator';
|
||||
import { Type, Transform } from 'class-transformer';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { OrderStatus } from '../interface/order.interface';
|
||||
import { PaymentStatusEnum } from '../../payments/interface/payment';
|
||||
|
||||
const sortOrderOptions = ['asc', 'desc'] as const;
|
||||
type SortOrder = (typeof sortOrderOptions)[number];
|
||||
|
||||
export class FindOrdersDto {
|
||||
@ApiPropertyOptional({ default: 1, minimum: 1 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
page: number = 1;
|
||||
|
||||
@ApiPropertyOptional({ default: 10, minimum: 1 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
limit: number = 10;
|
||||
|
||||
/**
|
||||
* ?statuses=paid,confirmed
|
||||
* ?statuses=paid&statuses=confirmed
|
||||
*/
|
||||
@ApiPropertyOptional({
|
||||
description: 'Filter by order statuses',
|
||||
enum: OrderStatus,
|
||||
isArray: true,
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(({ value }) =>
|
||||
Array.isArray(value) ? value : value?.split(',')
|
||||
)
|
||||
@IsArray()
|
||||
@IsEnum(OrderStatus, { each: true })
|
||||
statuses?: OrderStatus[];
|
||||
|
||||
@ApiPropertyOptional({ enum: PaymentStatusEnum })
|
||||
@IsOptional()
|
||||
@IsEnum(PaymentStatusEnum)
|
||||
paymentStatus?: PaymentStatusEnum;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'date-time' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
startDate?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'date-time' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
endDate?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: 'createdAt' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
orderBy: string = 'createdAt';
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: sortOrderOptions,
|
||||
default: 'desc',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(sortOrderOptions)
|
||||
order: SortOrder = 'desc';
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
export class UpdateOrderStatusDto {
|
||||
@ApiPropertyOptional({
|
||||
description: 'Change Status description',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
desc?: string;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
// import { PartialType } from '@nestjs/swagger';
|
||||
// import { CreateOrderDto } from './create-order.dto';
|
||||
|
||||
// export class UpdateOrderDto extends PartialType(CreateOrderDto) {}
|
||||
export class UpdateOrderDto {}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { Entity, Index, ManyToOne, Property } from '@mikro-orm/core';
|
||||
import { BaseEntity } from '../../../common/entities/base.entity';
|
||||
import { Order } from './order.entity';
|
||||
import { Food } from '../../foods/entities/food.entity';
|
||||
|
||||
@Entity({ tableName: 'order_items' })
|
||||
@Index({ properties: ['order'] })
|
||||
@Index({ properties: ['food'] })
|
||||
export class OrderItem extends BaseEntity {
|
||||
@ManyToOne(() => Order)
|
||||
order!: Order;
|
||||
|
||||
@ManyToOne(() => Food)
|
||||
food!: Food;
|
||||
|
||||
@Property({ type: 'int' })
|
||||
quantity!: number;
|
||||
|
||||
@Property({ type: 'decimal', precision: 10, scale: 0 })
|
||||
unitPrice!: number;
|
||||
|
||||
@Property({ type: 'decimal', precision: 10, scale: 0, default: 0 })
|
||||
discount: number = 0;
|
||||
|
||||
@Property({ type: 'decimal', precision: 10, scale: 0 })
|
||||
totalPrice!: number;
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
import {
|
||||
Entity,
|
||||
Index,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
Property,
|
||||
Collection,
|
||||
Cascade,
|
||||
Enum,
|
||||
BeforeCreate,
|
||||
Unique,
|
||||
type EventArgs,
|
||||
} from '@mikro-orm/core';
|
||||
import { BaseEntity } from '../../../common/entities/base.entity';
|
||||
import { OrderCouponDetail, OrderStatus } from '../interface/order.interface';
|
||||
import { User } from '../../users/entities/user.entity';
|
||||
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
|
||||
import { PaymentMethod } from '../../payments/entities/payment-method.entity';
|
||||
import { OrderItem } from './order-item.entity';
|
||||
import { Delivery } from '../../delivery/entities/delivery.entity';
|
||||
import { OrderUserAddress, OrderCarAddress } from '../interface/order.interface';
|
||||
import { Payment } from 'src/modules/payments/entities/payment.entity';
|
||||
|
||||
@Entity({ tableName: 'orders' })
|
||||
@Unique({ properties: ['restaurant', 'orderNumber'] })
|
||||
@Index({ properties: ['restaurant', 'status'] })
|
||||
@Index({ properties: ['user', 'status'] })
|
||||
@Index({ properties: ['restaurant', 'orderNumber'] })
|
||||
@Index({ properties: ['status'] })
|
||||
export class Order extends BaseEntity {
|
||||
@ManyToOne(() => User)
|
||||
user!: User;
|
||||
|
||||
@ManyToOne(() => Restaurant)
|
||||
restaurant!: Restaurant;
|
||||
|
||||
@ManyToOne(() => Delivery)
|
||||
deliveryMethod!: Delivery;
|
||||
|
||||
@OneToMany(() => Payment, payment => payment.order, {
|
||||
cascade: [Cascade.ALL],
|
||||
orphanRemoval: true,
|
||||
})
|
||||
payments = new Collection<Payment>(this);
|
||||
|
||||
@OneToMany(() => OrderItem, item => item.order, {
|
||||
cascade: [Cascade.ALL],
|
||||
orphanRemoval: true,
|
||||
})
|
||||
items = new Collection<OrderItem>(this);
|
||||
|
||||
@Property({ type: 'json', nullable: true })
|
||||
userAddress?: OrderUserAddress | null;
|
||||
// for car delivery
|
||||
@Property({ type: 'json', nullable: true })
|
||||
carAddress?: OrderCarAddress | null;
|
||||
|
||||
@ManyToOne(() => PaymentMethod)
|
||||
paymentMethod: PaymentMethod;
|
||||
|
||||
@Property({ type: 'int', nullable: true })
|
||||
orderNumber?: number;
|
||||
|
||||
@Property({ type: 'decimal', precision: 10, scale: 0, default: 0 })
|
||||
couponDiscount: number = 0;
|
||||
|
||||
@Property({ type: 'jsonb', nullable: true })
|
||||
couponDetail?: OrderCouponDetail | null;
|
||||
|
||||
@Property({ type: 'decimal', precision: 10, scale: 0, default: 0 })
|
||||
itemsDiscount: number = 0;
|
||||
|
||||
@Property({ type: 'decimal', precision: 10, scale: 0, default: 0 })
|
||||
totalDiscount: number = 0;
|
||||
|
||||
@Property({ type: 'decimal', precision: 10, scale: 0 })
|
||||
subTotal!: number;
|
||||
|
||||
@Property({ type: 'decimal', precision: 10, scale: 0, default: 0 })
|
||||
tax: number = 0;
|
||||
|
||||
@Property({ type: 'decimal', precision: 10, scale: 0, default: 0 })
|
||||
deliveryFee: number = 0;
|
||||
|
||||
@Property({ type: 'decimal', precision: 10, scale: 0 })
|
||||
total!: number;
|
||||
|
||||
@Property({ type: 'int', default: 0 })
|
||||
totalItems: number = 0;
|
||||
|
||||
@Property({ type: 'text', nullable: true })
|
||||
description?: string;
|
||||
|
||||
@Property({ nullable: true })
|
||||
tableNumber?: string;
|
||||
|
||||
@Enum(() => OrderStatus)
|
||||
status!: OrderStatus;
|
||||
|
||||
@Property({ type: 'json', nullable: true })
|
||||
history: Array<{ status: OrderStatus; changedAt: Date; desc: string | null }> = [];
|
||||
|
||||
@BeforeCreate()
|
||||
async generateOrderNumber(args: EventArgs<Order>) {
|
||||
const em = args.em;
|
||||
const order = args.entity;
|
||||
|
||||
// Ensure restaurant is loaded
|
||||
if (!order.restaurant) {
|
||||
throw new Error('Restaurant must be set before creating order');
|
||||
}
|
||||
|
||||
// Get the restaurant ID (handle both entity and ID cases)
|
||||
const restaurantId = typeof order.restaurant === 'string' ? order.restaurant : order.restaurant.id;
|
||||
|
||||
// Query for max orderNumber for this restaurant
|
||||
// Using findOne with orderBy to get the highest order number
|
||||
const maxOrder = await em.findOne(
|
||||
Order,
|
||||
{ restaurant: restaurantId },
|
||||
{
|
||||
orderBy: { orderNumber: 'DESC' },
|
||||
fields: ['orderNumber'],
|
||||
},
|
||||
);
|
||||
|
||||
// Set the next order number (1 if no orders exist, otherwise max + 1)
|
||||
order.orderNumber = maxOrder?.orderNumber ? maxOrder.orderNumber + 1 : 1;
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { OrderStatus, StatusTransitionRef } from '../interface/order.interface';
|
||||
|
||||
export class OrderCreatedEvent {
|
||||
constructor(
|
||||
public readonly orderId: string,
|
||||
public readonly restaurantId: string,
|
||||
public readonly orderNumber: string,
|
||||
public readonly total: number,
|
||||
) {}
|
||||
}
|
||||
|
||||
export class OrderStatusChangedEvent {
|
||||
constructor(
|
||||
public readonly orderId: string,
|
||||
public readonly userId: string,
|
||||
public readonly orderNumber: string,
|
||||
public readonly restaurantId: string,
|
||||
public readonly previousStatus: OrderStatus,
|
||||
public readonly newStatus: OrderStatus,
|
||||
public readonly changedBy: StatusTransitionRef,
|
||||
) {}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import type { CouponType } from 'src/modules/coupons/interface/coupon';
|
||||
|
||||
export interface OrderUserAddress {
|
||||
address?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
city: string;
|
||||
province: string;
|
||||
postalCode?: string;
|
||||
fullName: string;
|
||||
phone: string;
|
||||
}
|
||||
|
||||
export interface OrderCarAddress {
|
||||
carModel: string;
|
||||
carColor: string;
|
||||
plateNumber: string;
|
||||
phone: string;
|
||||
}
|
||||
|
||||
export enum OrderStatus {
|
||||
PENDING_PAYMENT = 'pendingPayment',
|
||||
PAID = 'paid',
|
||||
PREPARING = 'preparing',
|
||||
DELIVERED_TO_WAITER = 'deliveredToWaiter',
|
||||
DELIVERED_TO_RECEPTIONIST = 'deliveredToReceptionist',
|
||||
SHIPPED = 'shipped',
|
||||
COMPLETED = 'completed',
|
||||
CANCELED = 'canceled',
|
||||
}
|
||||
|
||||
export interface OrderCouponDetail {
|
||||
couponId: string;
|
||||
couponName: string;
|
||||
couponCode: string;
|
||||
type: CouponType;
|
||||
value: number;
|
||||
|
||||
maxDiscount?: number;
|
||||
}
|
||||
|
||||
export type StatusTransitionRef = 'user' | 'admin';
|
||||
@@ -1,184 +0,0 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { AdminRepository } from 'src/modules/admin/repositories/admin.repository';
|
||||
import { OrderCreatedEvent, OrderStatusChangedEvent } from '../events/order.events';
|
||||
import { Permission } from 'src/common/enums/permission.enum';
|
||||
import { NotifTitleEnum } from 'src/modules/notifications/interfaces/notification.interface';
|
||||
import { NotificationService } from 'src/modules/notifications/services/notification.service';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { OrderRepository } from '../repositories/order.repository';
|
||||
import { OrderStatus } from '../interface/order.interface';
|
||||
import { PaymentMethodEnum } from 'src/modules/payments/interface/payment';
|
||||
import { UserService } from 'src/modules/users/providers/user.service';
|
||||
import { WalletTransactionReason, WalletTransactionType } from 'src/modules/users/interface/wallet';
|
||||
|
||||
@Injectable()
|
||||
export class OrderListeners {
|
||||
private readonly logger = new Logger(OrderListeners.name);
|
||||
private orderCreatedSmsTemplateId: string;
|
||||
private orderStatusChangedSmsTemplateId: string;
|
||||
// private orderCompletedSmsTemplateId: string;
|
||||
constructor(
|
||||
private readonly adminService: AdminRepository,
|
||||
private readonly OrderRepository: OrderRepository,
|
||||
private readonly notificationService: NotificationService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly userService: UserService,
|
||||
) {
|
||||
this.orderCreatedSmsTemplateId = this.configService.get<string>('SMS_PATTERN_ORDER_CREATED') ?? '123';
|
||||
this.orderStatusChangedSmsTemplateId = this.configService.get<string>('SMS_PATTERN_ORDER_STATUS_CHANGE') ?? '123';
|
||||
// this.orderCompletedSmsTemplateId = this.configService.get<string>('SMS_PATTERN_ORDER_STATUS_COMPLETED') ?? '123';
|
||||
}
|
||||
|
||||
private getStatusFarsi(status: OrderStatus): string {
|
||||
const statusMap: Record<OrderStatus, string> = {
|
||||
[OrderStatus.PENDING_PAYMENT]: 'در انتظار پرداخت',
|
||||
[OrderStatus.PAID]: 'پرداخت شده',
|
||||
[OrderStatus.PREPARING]: 'در حال آمادهسازی',
|
||||
[OrderStatus.DELIVERED_TO_RECEPTIONIST]: 'تحویل به پذیرش',
|
||||
[OrderStatus.DELIVERED_TO_WAITER]: 'تحویل به گارسون',
|
||||
[OrderStatus.SHIPPED]: 'ارسال شده',
|
||||
[OrderStatus.COMPLETED]: 'تکمیل شده',
|
||||
[OrderStatus.CANCELED]: 'لغو شده',
|
||||
};
|
||||
return statusMap[status] || status;
|
||||
}
|
||||
|
||||
@OnEvent(OrderCreatedEvent.name)
|
||||
async handleOrderCreated(event: OrderCreatedEvent) {
|
||||
try {
|
||||
this.logger.log(
|
||||
`Order created event received: ${event.orderId} for restaurant: ${event.restaurantId} and order number: ${event.orderNumber}`,
|
||||
);
|
||||
|
||||
const order = await this.OrderRepository.findOne(event.orderId);
|
||||
if (order?.paymentMethod.method === PaymentMethodEnum.Online) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// get admnin os restuaraant that have order permissuins
|
||||
const admins = await this.adminService.findAdminsWithPermission(event.restaurantId, Permission.NEW_ORDER_NOTIFICATION);
|
||||
const recipients = admins.map(admin => ({
|
||||
adminId: admin.id,
|
||||
}));
|
||||
|
||||
await this.notificationService.sendNotification({
|
||||
restaurantId: event.restaurantId,
|
||||
message: {
|
||||
title: NotifTitleEnum.ORDER_CREATED,
|
||||
content: `سفارش شماره ${event.orderNumber} با مبلغ ${event.total} تومان با موفقیت ایجاد شد`,
|
||||
sms: {
|
||||
templateId: this.orderCreatedSmsTemplateId,
|
||||
parameters: {
|
||||
orderNumber: event.orderNumber,
|
||||
total: event.total.toString(),
|
||||
},
|
||||
},
|
||||
pushNotif: {
|
||||
title: `سفارش جدید`,
|
||||
content: `سفارش شماره ${event.orderNumber} با مبلغ ${event.total} تومان با موفقیت ایجاد شد`,
|
||||
icon: `/`,
|
||||
action: {
|
||||
type: NotifTitleEnum.ORDER_CREATED,
|
||||
url: `/`,
|
||||
},
|
||||
},
|
||||
},
|
||||
recipients,
|
||||
metadata: {
|
||||
priority: 1,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to send notification for order created event: ${event.restaurantId}`,
|
||||
error instanceof Error ? error.stack : String(error),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@OnEvent(OrderStatusChangedEvent.name)
|
||||
async handleOrderStatusChanged(event: OrderStatusChangedEvent) {
|
||||
try {
|
||||
this.logger.log(
|
||||
`Order status changed event received: ${event.orderId} for restaurant: ${event.restaurantId} and order number: ${event.orderNumber}`,
|
||||
);
|
||||
//TODO : REFACTOR to use queue or other way to handle this
|
||||
const recipients = [
|
||||
{
|
||||
userId: event.userId,
|
||||
},
|
||||
];
|
||||
if (event.newStatus === OrderStatus.COMPLETED) {
|
||||
|
||||
if (!event?.userId) {
|
||||
this.logger.log(
|
||||
`User not found for order: ${event.orderId} for restaurant: ${event.restaurantId} and order number: ${event.orderNumber}`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.notificationService.sendNotification({
|
||||
restaurantId: event.restaurantId,
|
||||
message: {
|
||||
title: NotifTitleEnum.ORDER_STATUS_CHANGED,
|
||||
content: `لطفابرای ثبت نظر سفارش ${event.orderNumber} به اپ مراجعه کنید`,
|
||||
sms: {
|
||||
templateId: this.orderCreatedSmsTemplateId,
|
||||
parameters: {
|
||||
orderNumber: event.orderNumber,
|
||||
},
|
||||
},
|
||||
pushNotif: {
|
||||
title: `تغییر وضعیت سفارش`,
|
||||
content: `لطفا برای ثبت نظر سفارش ${event.orderNumber} به اپ مراجعه کنید`,
|
||||
icon: `/`,
|
||||
action: {
|
||||
type: NotifTitleEnum.ORDER_STATUS_CHANGED,
|
||||
url: ``,
|
||||
},
|
||||
},
|
||||
},
|
||||
recipients,
|
||||
metadata: {
|
||||
priority: 1,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await this.notificationService.sendNotification({
|
||||
restaurantId: event.restaurantId,
|
||||
message: {
|
||||
title: NotifTitleEnum.ORDER_STATUS_CHANGED,
|
||||
content: `وضعیت سفارش شماره ${event.orderNumber} به ${this.getStatusFarsi(event.newStatus)} تغییر کرد`,
|
||||
sms: {
|
||||
templateId: this.orderStatusChangedSmsTemplateId,
|
||||
parameters: {
|
||||
orderNumber: event.orderNumber,
|
||||
status: this.getStatusFarsi(event.newStatus),
|
||||
},
|
||||
},
|
||||
pushNotif: {
|
||||
title: `تغییر وضعیت سفارش`,
|
||||
content: `وضعیت سفارش شماره ${event.orderNumber} به ${this.getStatusFarsi(event.newStatus)} تغییر کرد`,
|
||||
icon: `/`,
|
||||
action: {
|
||||
type: NotifTitleEnum.ORDER_STATUS_CHANGED,
|
||||
url: ``,
|
||||
},
|
||||
},
|
||||
},
|
||||
recipients,
|
||||
metadata: {
|
||||
priority: 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to send notification for order status changed event: ${event.restaurantId}`,
|
||||
error instanceof Error ? error.stack : String(error),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { MikroOrmModule } from '@mikro-orm/nestjs';
|
||||
import { OrdersService } from './providers/orders.service';
|
||||
import { OrdersController } from './controllers/orders.controller';
|
||||
import { Order } from './entities/order.entity';
|
||||
import { OrderItem } from './entities/order-item.entity';
|
||||
import { User } from '../users/entities/user.entity';
|
||||
import { Restaurant } from '../restaurants/entities/restaurant.entity';
|
||||
import { Food } from '../foods/entities/food.entity';
|
||||
import { UserAddress } from '../users/entities/user-address.entity';
|
||||
import { PaymentMethod } from '../payments/entities/payment-method.entity';
|
||||
import { CartModule } from '../cart/cart.module';
|
||||
import { UtilsModule } from '../utils/utils.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { PaymentsModule } from '../payments/payments.module';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { OrderRepository } from './repositories/order.repository';
|
||||
import { OrderListeners } from './listeners/order.listeners';
|
||||
import { AdminModule } from '../admin/admin.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { InventoryModule } from '../inventory/inventory.module';
|
||||
import { UserModule } from '../users/user.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
MikroOrmModule.forFeature([Order, OrderItem, User, Restaurant, Food, UserAddress, PaymentMethod]),
|
||||
CartModule,
|
||||
UtilsModule,
|
||||
AuthModule,
|
||||
forwardRef(() => PaymentsModule),
|
||||
JwtModule,
|
||||
AdminModule,
|
||||
NotificationsModule,
|
||||
InventoryModule,
|
||||
forwardRef(() => UserModule)
|
||||
],
|
||||
controllers: [OrdersController],
|
||||
providers: [OrdersService, OrderRepository, OrderListeners],
|
||||
exports: [OrderRepository, OrdersService],
|
||||
})
|
||||
export class OrdersModule { }
|
||||
@@ -1,601 +0,0 @@
|
||||
import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
|
||||
import { EntityManager } from '@mikro-orm/postgresql';
|
||||
import { Order } from '../entities/order.entity';
|
||||
import { OrderItem } from '../entities/order-item.entity';
|
||||
import { User } from '../../users/entities/user.entity';
|
||||
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
|
||||
import { Food } from '../../foods/entities/food.entity';
|
||||
import { CartService } from '../../cart/providers/cart.service';
|
||||
import { OrderStatus, OrderUserAddress, OrderCarAddress } from '../interface/order.interface';
|
||||
import { PaymentMethodEnum, PaymentStatusEnum } from '../../payments/interface/payment';
|
||||
import { Cart } from '../../cart/interfaces/cart.interface';
|
||||
import { PaymentMethod } from '../../payments/entities/payment-method.entity';
|
||||
import { PaymentsService } from '../../payments/services/payments.service';
|
||||
import { DeliveryMethodEnum } from '../../delivery/interface/delivery';
|
||||
import { Delivery } from '../../delivery/entities/delivery.entity';
|
||||
import { OrderRepository } from '../repositories/order.repository';
|
||||
import { FindOrdersDto } from '../dto/find-orders.dto';
|
||||
import { PaginatedResult } from 'src/common/interfaces/pagination.interface';
|
||||
import { Payment } from 'src/modules/payments/entities/payment.entity';
|
||||
import { InventoryService } from 'src/modules/inventory/inventory.service';
|
||||
import { BulkReserveFoodDto } from 'src/modules/inventory/dto/bulk-reserve-food.dto';
|
||||
import { StatusTransitionRef } from '../interface/order.interface';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { OrderCreatedEvent, OrderStatusChangedEvent } from '../events/order.events';
|
||||
import { OrderMessage } from 'src/common/enums/message.enum';
|
||||
type OrderItemData = { food: Food; quantity: number; unitPrice: number; discount: number };
|
||||
|
||||
type ValidatedCartForOrder = {
|
||||
user: User;
|
||||
restaurant: Restaurant;
|
||||
delivery: Delivery;
|
||||
userAddress: OrderUserAddress | null;
|
||||
carAddress: OrderCarAddress | null;
|
||||
paymentMethod: PaymentMethod;
|
||||
orderItemsData: OrderItemData[];
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class OrdersService {
|
||||
private readonly logger = new Logger(OrdersService.name);
|
||||
|
||||
private static readonly STATUS_TRANSITIONS: Record<OrderStatus, readonly OrderStatus[]> = {
|
||||
[OrderStatus.PENDING_PAYMENT]: [OrderStatus.PAID, OrderStatus.CANCELED, OrderStatus.PREPARING],
|
||||
[OrderStatus.PAID]: [OrderStatus.PREPARING, OrderStatus.CANCELED],
|
||||
[OrderStatus.PREPARING]: [OrderStatus.DELIVERED_TO_RECEPTIONIST, OrderStatus.DELIVERED_TO_WAITER, OrderStatus.SHIPPED, OrderStatus.CANCELED],
|
||||
[OrderStatus.DELIVERED_TO_WAITER]: [OrderStatus.COMPLETED, OrderStatus.CANCELED],
|
||||
[OrderStatus.DELIVERED_TO_RECEPTIONIST]: [OrderStatus.COMPLETED, OrderStatus.CANCELED],
|
||||
[OrderStatus.SHIPPED]: [OrderStatus.COMPLETED, OrderStatus.CANCELED],
|
||||
[OrderStatus.COMPLETED]: [OrderStatus.CANCELED],
|
||||
[OrderStatus.CANCELED]: [],
|
||||
};
|
||||
|
||||
constructor(
|
||||
private readonly em: EntityManager,
|
||||
private readonly cartService: CartService,
|
||||
private readonly orderRepository: OrderRepository,
|
||||
private readonly paymentsService: PaymentsService,
|
||||
private readonly inventoryService: InventoryService,
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
) { }
|
||||
|
||||
async checkout(userId: string, restaurantId: string) {
|
||||
const cart = await this.cartService.findOneOrFail(userId, restaurantId);
|
||||
const validated = await this.validateCartForOrder(userId, restaurantId, cart);
|
||||
|
||||
const order = await this.em.transactional(async em => {
|
||||
const order = em.create(Order, {
|
||||
user: validated.user,
|
||||
restaurant: validated.restaurant,
|
||||
deliveryMethod: validated.delivery,
|
||||
userAddress: validated.userAddress,
|
||||
carAddress: validated.carAddress,
|
||||
paymentMethod: validated.paymentMethod,
|
||||
couponDiscount: cart.couponDiscount || 0,
|
||||
couponDetail: cart.coupon,
|
||||
itemsDiscount: cart.itemsDiscount || 0,
|
||||
totalDiscount: cart.totalDiscount || 0,
|
||||
subTotal: cart.subTotal || 0,
|
||||
tax: cart.tax || 0,
|
||||
deliveryFee: cart.deliveryFee || 0,
|
||||
total: cart.total || 0,
|
||||
totalItems: cart.totalItems || 0,
|
||||
description: cart.description,
|
||||
tableNumber: cart.tableNumber,
|
||||
status: OrderStatus.PENDING_PAYMENT,
|
||||
history: [{ status: OrderStatus.PENDING_PAYMENT, changedAt: new Date() }],
|
||||
});
|
||||
|
||||
em.persist(order);
|
||||
|
||||
for (const itemData of validated.orderItemsData) {
|
||||
const { food, quantity, unitPrice, discount } = itemData;
|
||||
|
||||
this.assertFoodHasSufficientStock(food, quantity);
|
||||
|
||||
const totalPrice = (unitPrice - discount) * quantity;
|
||||
|
||||
const orderItem = em.create(OrderItem, {
|
||||
order,
|
||||
food,
|
||||
quantity,
|
||||
unitPrice,
|
||||
discount,
|
||||
totalPrice,
|
||||
});
|
||||
|
||||
em.persist(orderItem);
|
||||
}
|
||||
|
||||
const payment = em.create(Payment, {
|
||||
order,
|
||||
amount: order.total,
|
||||
status: PaymentStatusEnum.Pending,
|
||||
method: order.paymentMethod.method,
|
||||
gateway: order.paymentMethod.gateway ?? null,
|
||||
});
|
||||
|
||||
em.persist(payment);
|
||||
// reserve stock based on payment method.
|
||||
const bulkReserveFoodDto: BulkReserveFoodDto = {
|
||||
items: validated.orderItemsData.map(item => ({
|
||||
foodId: item.food.id,
|
||||
quantity: item.quantity,
|
||||
})),
|
||||
};
|
||||
await this.inventoryService.deductFromInventory(em, bulkReserveFoodDto);
|
||||
await em.flush();
|
||||
this.logger.debug(`Order ${order.id} created for user ${userId} (restaurant ${restaurantId})`);
|
||||
return order;
|
||||
});
|
||||
|
||||
await this.cartService.clearCart(userId, restaurantId);
|
||||
|
||||
const { paymentUrl } = await this.paymentsService.payOrder(order.id);
|
||||
this.eventEmitter.emit(
|
||||
OrderCreatedEvent.name,
|
||||
new OrderCreatedEvent(order.id, restaurantId, String(order?.orderNumber) || '', order.total),
|
||||
);
|
||||
|
||||
return { paymentUrl, order };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates cart and prepares all required data for order creation
|
||||
*/
|
||||
private async validateCartForOrder(userId: string, restaurantId: string, cart: Cart): Promise<ValidatedCartForOrder> {
|
||||
this.assertCartHasItems(cart);
|
||||
this.assertCartHasDeliveryMethod(cart);
|
||||
this.assertCartHasPaymentMethod(cart);
|
||||
|
||||
const [user, restaurant, delivery] = await Promise.all([
|
||||
this.getUserOrFail(userId),
|
||||
this.getRestaurantOrFail(restaurantId),
|
||||
this.getDeliveryOrFail(cart.deliveryMethodId!),
|
||||
]);
|
||||
|
||||
this.assertMeetsMinOrderForDelivery(cart, delivery);
|
||||
this.assertDeliveryMethodRequirements(cart, delivery);
|
||||
|
||||
const paymentMethod = await this.getPaymentMethodOrFail(cart.paymentMethodId!, restaurantId);
|
||||
this.assertPaymentMethodEnabled(paymentMethod);
|
||||
|
||||
const orderItemsData = await this.buildOrderItemsData(cart, restaurantId);
|
||||
|
||||
return {
|
||||
user,
|
||||
restaurant,
|
||||
delivery,
|
||||
paymentMethod,
|
||||
userAddress: delivery.method === DeliveryMethodEnum.DeliveryCourier ? (cart?.userAddress ?? null) : null,
|
||||
carAddress: delivery.method === DeliveryMethodEnum.DeliveryCar ? (cart?.carAddress ?? null) : null,
|
||||
orderItemsData,
|
||||
};
|
||||
}
|
||||
|
||||
async findAllForUser(restId: string, dto: FindOrdersDto, userId: string): Promise<PaginatedResult<Order>> {
|
||||
const result = await this.orderRepository.findAllPaginated(restId, {
|
||||
page: dto.page,
|
||||
limit: dto.limit,
|
||||
statuses: dto.statuses,
|
||||
paymentStatus: dto.paymentStatus,
|
||||
search: dto.search,
|
||||
startDate: dto.startDate,
|
||||
endDate: dto.endDate,
|
||||
orderBy: dto.orderBy,
|
||||
order: dto.order,
|
||||
userId,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async findAllForAdmin(restId: string, dto: FindOrdersDto): Promise<PaginatedResult<Order>> {
|
||||
const result = await this.orderRepository.findAllPaginated(restId, {
|
||||
page: dto.page,
|
||||
limit: dto.limit,
|
||||
statuses: dto.statuses,
|
||||
paymentStatus: dto.paymentStatus,
|
||||
search: dto.search,
|
||||
startDate: dto.startDate,
|
||||
endDate: dto.endDate,
|
||||
orderBy: dto.orderBy,
|
||||
order: dto.order,
|
||||
excludeOnlinePendingPayment: true,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async findOne(id: string, restId: string): Promise<Order> {
|
||||
const order = await this.orderRepository.findOne(
|
||||
{ id, restaurant: { id: restId } },
|
||||
{
|
||||
populate: [
|
||||
'user',
|
||||
'restaurant',
|
||||
'deliveryMethod',
|
||||
'userAddress',
|
||||
'carAddress',
|
||||
'paymentMethod',
|
||||
'payments',
|
||||
'items',
|
||||
'items.food',
|
||||
],
|
||||
},
|
||||
);
|
||||
if (!order) {
|
||||
throw new NotFoundException(OrderMessage.NOT_FOUND);
|
||||
}
|
||||
return order;
|
||||
}
|
||||
|
||||
|
||||
|
||||
async changeOrderStatus(
|
||||
orderId: string,
|
||||
restId: string,
|
||||
toStatus: OrderStatus,
|
||||
ref: StatusTransitionRef,
|
||||
desc?: string,
|
||||
): Promise<Order> {
|
||||
const order = await this.getOrderOrFail(orderId, restId);
|
||||
|
||||
// Store previous status before changing it
|
||||
const previousStatus = order.status;
|
||||
|
||||
this.assertStatusTransitionAllowed(order, toStatus, ref);
|
||||
|
||||
order.status = toStatus;
|
||||
order.history.push({ status: toStatus, changedAt: new Date(), desc: desc || null });
|
||||
await this.em.persistAndFlush(order);
|
||||
|
||||
this.eventEmitter.emit(
|
||||
OrderStatusChangedEvent.name,
|
||||
new OrderStatusChangedEvent(
|
||||
orderId,
|
||||
order.user?.id || '',
|
||||
String(order?.orderNumber) || '',
|
||||
restId,
|
||||
previousStatus,
|
||||
toStatus,
|
||||
ref,
|
||||
),
|
||||
);
|
||||
return order;
|
||||
}
|
||||
/* Helpers */
|
||||
private assertStatusTransitionAllowed(order: Order, to: OrderStatus, ref: StatusTransitionRef) {
|
||||
const paymentMethod = order.paymentMethod?.method;
|
||||
if (!paymentMethod) {
|
||||
throw new BadRequestException(OrderMessage.PAYMENT_METHOD_MISSING);
|
||||
}
|
||||
|
||||
if (!this.canTransition(order.status, to, paymentMethod, ref, order.deliveryMethod.method)) {
|
||||
throw new BadRequestException(OrderMessage.INVALID_STATUS_TRANSITION);
|
||||
}
|
||||
}
|
||||
|
||||
private canTransition(from: OrderStatus, to: OrderStatus, paymentMethod: PaymentMethodEnum, ref: 'user' | 'admin', deliveryMethod: DeliveryMethodEnum) {
|
||||
if (!OrdersService.STATUS_TRANSITIONS[from]?.includes(to)) return false;
|
||||
|
||||
if (to === OrderStatus.CANCELED) {
|
||||
// only allow orders with status of PENDING_PAYMENT and PAID are allowed to be canceled by user
|
||||
if (ref === 'user' && ![OrderStatus.PENDING_PAYMENT, OrderStatus.PAID].includes(from)) {
|
||||
return false;
|
||||
} else if (ref === 'admin') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// only allow orders with status of PENDING_PAYMENT and payment
|
||||
// method of cash are allowed to move to CONFIRMED directly
|
||||
if (
|
||||
from == OrderStatus.PENDING_PAYMENT &&
|
||||
to == OrderStatus.PREPARING &&
|
||||
paymentMethod !== PaymentMethodEnum.Cash
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Only allow orders with status of PREPARING to be shipped if the delivery method is DeliveryCourier
|
||||
*/
|
||||
if (
|
||||
from == OrderStatus.PREPARING &&
|
||||
to == OrderStatus.SHIPPED &&
|
||||
deliveryMethod !== DeliveryMethodEnum.DeliveryCourier
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
from == OrderStatus.PREPARING &&
|
||||
to == OrderStatus.DELIVERED_TO_WAITER &&
|
||||
deliveryMethod !== DeliveryMethodEnum.DineIn &&
|
||||
deliveryMethod !== DeliveryMethodEnum.DeliveryCar
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
from == OrderStatus.PREPARING &&
|
||||
to == OrderStatus.DELIVERED_TO_RECEPTIONIST &&
|
||||
deliveryMethod !== DeliveryMethodEnum.CustomerPickup
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (paymentMethod === PaymentMethodEnum.Online) {
|
||||
if (to === OrderStatus.PREPARING && from !== OrderStatus.PAID) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async getOrderOrFail(orderId: string, restId: string): Promise<Order> {
|
||||
const order = await this.em.findOne(
|
||||
Order,
|
||||
{ id: orderId, restaurant: { id: restId } },
|
||||
{ populate: ['paymentMethod', 'deliveryMethod', 'user'] },
|
||||
);
|
||||
if (!order) throw new NotFoundException(OrderMessage.NOT_FOUND);
|
||||
return order;
|
||||
}
|
||||
|
||||
private assertCartHasItems(cart: Cart) {
|
||||
if (!cart.items || cart.items.length === 0) {
|
||||
throw new BadRequestException(OrderMessage.CART_EMPTY);
|
||||
}
|
||||
}
|
||||
|
||||
private assertCartHasDeliveryMethod(cart: Cart) {
|
||||
if (!cart.deliveryMethodId) {
|
||||
throw new NotFoundException(OrderMessage.DELIVERY_METHOD_NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
private assertCartHasPaymentMethod(cart: Cart) {
|
||||
if (!cart.paymentMethodId) {
|
||||
throw new BadRequestException(OrderMessage.PAYMENT_METHOD_REQUIRED);
|
||||
}
|
||||
}
|
||||
|
||||
private async getUserOrFail(userId: string): Promise<User> {
|
||||
const user = await this.em.findOne(User, { id: userId });
|
||||
if (!user) throw new NotFoundException(OrderMessage.USER_NOT_FOUND);
|
||||
return user;
|
||||
}
|
||||
|
||||
private async getRestaurantOrFail(restaurantId: string): Promise<Restaurant> {
|
||||
const restaurant = await this.em.findOne(Restaurant, { id: restaurantId });
|
||||
if (!restaurant) throw new NotFoundException(OrderMessage.RESTAURANT_NOT_FOUND);
|
||||
return restaurant;
|
||||
}
|
||||
|
||||
private async getDeliveryOrFail(deliveryId: string): Promise<Delivery> {
|
||||
const delivery = await this.em.findOne(Delivery, { id: deliveryId });
|
||||
if (!delivery) throw new NotFoundException(OrderMessage.DELIVERY_NOT_FOUND);
|
||||
return delivery;
|
||||
}
|
||||
|
||||
private assertMeetsMinOrderForDelivery(cart: Cart, delivery: Delivery) {
|
||||
const minOrderPrice = Number(delivery.minOrderPrice) || 0;
|
||||
if (minOrderPrice > 0 && cart.total < minOrderPrice) {
|
||||
throw new BadRequestException(OrderMessage.MIN_ORDER_AMOUNT_NOT_MET);
|
||||
}
|
||||
}
|
||||
|
||||
private assertDeliveryMethodRequirements(cart: Cart, delivery: Delivery) {
|
||||
if (delivery.method === DeliveryMethodEnum.DineIn) {
|
||||
if (!cart.tableNumber || cart.tableNumber.trim() === '') {
|
||||
throw new BadRequestException(OrderMessage.TABLE_NUMBER_REQUIRED);
|
||||
}
|
||||
}
|
||||
|
||||
if (delivery.method === DeliveryMethodEnum.DeliveryCourier && !cart.userAddress) {
|
||||
throw new BadRequestException(OrderMessage.ADDRESS_REQUIRED);
|
||||
}
|
||||
|
||||
if (delivery.method === DeliveryMethodEnum.DeliveryCar && !cart.carAddress) {
|
||||
throw new BadRequestException(OrderMessage.CAR_ADDRESS_REQUIRED);
|
||||
}
|
||||
}
|
||||
|
||||
private async getPaymentMethodOrFail(paymentMethodId: string, restaurantId: 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}`);
|
||||
}
|
||||
|
||||
return paymentMethod;
|
||||
}
|
||||
|
||||
private assertPaymentMethodEnabled(paymentMethod: PaymentMethod) {
|
||||
if (!paymentMethod.enabled) {
|
||||
throw new BadRequestException(OrderMessage.PAYMENT_METHOD_NOT_ENABLED);
|
||||
}
|
||||
}
|
||||
|
||||
private async buildOrderItemsData(cart: Cart, restaurantId: string): Promise<OrderItemData[]> {
|
||||
const orderItemsData: OrderItemData[] = [];
|
||||
|
||||
for (const cartItem of cart.items) {
|
||||
const food = await this.em.findOne(Food, { id: cartItem.foodId }, { populate: ['restaurant', 'inventory'] });
|
||||
if (!food) throw new NotFoundException(OrderMessage.FOOD_NOT_FOUND);
|
||||
|
||||
if (food.restaurant.id !== restaurantId) {
|
||||
throw new BadRequestException(OrderMessage.FOOD_NOT_BELONGS_TO_RESTAURANT);
|
||||
}
|
||||
|
||||
this.assertFoodHasSufficientStock(food, cartItem.quantity);
|
||||
|
||||
orderItemsData.push({
|
||||
food,
|
||||
quantity: cartItem.quantity,
|
||||
unitPrice: food.price || 0,
|
||||
discount: food.discount || 0,
|
||||
});
|
||||
}
|
||||
|
||||
return orderItemsData;
|
||||
}
|
||||
|
||||
private assertFoodHasSufficientStock(food: Food, quantity: number) {
|
||||
const availableStock = food.inventory?.availableStock ?? 0;
|
||||
if (availableStock < quantity) {
|
||||
throw new BadRequestException(OrderMessage.FOOD_NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async getStats(restId: string) {
|
||||
return this.em.transactional(async em => {
|
||||
// 1. Total orders count (excluding pending and canceled)
|
||||
const totalOrders = await em.count(Order, {
|
||||
restaurant: { id: restId },
|
||||
status: {
|
||||
$in: [
|
||||
OrderStatus.PAID,
|
||||
OrderStatus.PREPARING,
|
||||
OrderStatus.DELIVERED_TO_WAITER,
|
||||
OrderStatus.DELIVERED_TO_RECEPTIONIST,
|
||||
OrderStatus.SHIPPED,
|
||||
OrderStatus.COMPLETED,
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// 2. Active foods count
|
||||
const activeFoods = await em.count(Food, {
|
||||
restaurant: { id: restId },
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
// 3. Total clients count (distinct users with orders for this restaurant)
|
||||
const clientsResult = await em.execute(
|
||||
`
|
||||
SELECT COUNT(DISTINCT o.user_id) as count
|
||||
FROM orders o
|
||||
WHERE o.restaurant_id = ?
|
||||
`,
|
||||
[restId],
|
||||
);
|
||||
const totalClients = Number(clientsResult[0]?.count || 0);
|
||||
|
||||
// 4. Total revenue (sum of paid payments for orders of this restaurant)
|
||||
const revenueResult = await em.execute(
|
||||
`
|
||||
SELECT COALESCE(SUM(p.amount), 0)::numeric as total
|
||||
FROM payments p
|
||||
INNER JOIN orders o ON p.order_id = o.id
|
||||
WHERE o.restaurant_id = ? AND p.status = 'paid'
|
||||
`,
|
||||
[restId],
|
||||
);
|
||||
const totalRevenue = Number(revenueResult[0]?.total || 0);
|
||||
|
||||
return {
|
||||
totalOrders,
|
||||
activeFoods,
|
||||
totalClients,
|
||||
totalRevenue,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async getFoodSalesPieChart(restId: string) {
|
||||
return this.em.transactional(async em => {
|
||||
// Use last 30 days instead of just last month to be more inclusive
|
||||
const now = new Date();
|
||||
const endDate = new Date(now);
|
||||
endDate.setHours(23, 59, 59, 999);
|
||||
const startDate = new Date(now);
|
||||
startDate.setDate(startDate.getDate() - 30);
|
||||
startDate.setHours(0, 0, 0, 0);
|
||||
|
||||
// Calculate actual last month for the period display
|
||||
const firstDayOfLastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||
firstDayOfLastMonth.setHours(0, 0, 0, 0);
|
||||
const lastDayOfLastMonth = new Date(now.getFullYear(), now.getMonth(), 0, 23, 59, 59, 999);
|
||||
|
||||
this.logger.debug(
|
||||
`Food sales pie chart query params: restId=${restId}, startDate=${startDate.toISOString()}, endDate=${endDate.toISOString()}`,
|
||||
);
|
||||
|
||||
// Diagnostic query to check what orders exist
|
||||
const diagnosticResult = await em.execute(
|
||||
`
|
||||
SELECT
|
||||
o.id,
|
||||
o.status,
|
||||
o.created_at,
|
||||
COUNT(oi.id) as item_count
|
||||
FROM orders o
|
||||
LEFT JOIN order_items oi ON oi.order_id = o.id
|
||||
WHERE o.restaurant_id = ?
|
||||
GROUP BY o.id, o.status, o.created_at
|
||||
ORDER BY o.created_at DESC
|
||||
LIMIT 10
|
||||
`,
|
||||
[restId],
|
||||
);
|
||||
this.logger.debug(`Diagnostic: Found ${diagnosticResult.length} recent orders for restaurant ${restId}`);
|
||||
if (diagnosticResult.length > 0) {
|
||||
this.logger.debug(`Sample order: status=${diagnosticResult[0].status}, created_at=${diagnosticResult[0].created_at}`);
|
||||
}
|
||||
|
||||
// Query order items from orders in the date range
|
||||
// Only include completed orders (excluding canceled and pending)
|
||||
const result = await em.execute(
|
||||
`
|
||||
SELECT
|
||||
f.id as food_id,
|
||||
f.title as food_title,
|
||||
COALESCE(SUM(oi.quantity), 0)::int as total_quantity,
|
||||
COALESCE(SUM(oi.total_price), 0)::numeric as total_revenue
|
||||
FROM order_items oi
|
||||
INNER JOIN orders o ON oi.order_id = o.id
|
||||
INNER JOIN foods f ON oi.food_id = f.id
|
||||
WHERE o.restaurant_id = ?
|
||||
AND o.created_at >= ?
|
||||
AND o.created_at <= ?
|
||||
AND o.status NOT IN ('pendingPayment', 'canceled')
|
||||
GROUP BY f.id, f.title
|
||||
ORDER BY total_revenue DESC
|
||||
`,
|
||||
[restId, startDate, endDate],
|
||||
);
|
||||
|
||||
this.logger.debug(`Food sales pie chart query returned ${result.length} rows`);
|
||||
|
||||
// Calculate total revenue for percentage calculation
|
||||
const totalRevenue = result.reduce((sum: number, item: any) => {
|
||||
return sum + Number(item.total_revenue || 0);
|
||||
}, 0);
|
||||
|
||||
// Format data for pie chart and calculate percentages
|
||||
const chartData = result.map((item: any) => ({
|
||||
foodId: item.food_id,
|
||||
foodTitle: item.food_title || 'Unknown',
|
||||
quantity: Number(item.total_quantity || 0),
|
||||
revenue: Number(item.total_revenue || 0),
|
||||
percentage: totalRevenue > 0 ? Number(((Number(item.total_revenue || 0) / totalRevenue) * 100).toFixed(2)) : 0,
|
||||
}));
|
||||
|
||||
return {
|
||||
period: {
|
||||
startDate: firstDayOfLastMonth.toISOString(),
|
||||
endDate: lastDayOfLastMonth.toISOString(),
|
||||
},
|
||||
totalRevenue,
|
||||
data: chartData,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { EntityManager, EntityRepository } from '@mikro-orm/postgresql';
|
||||
import { FilterQuery } from '@mikro-orm/core';
|
||||
import { Order } from '../entities/order.entity';
|
||||
import { PaginatedResult } from 'src/common/interfaces/pagination.interface';
|
||||
import { OrderStatus } from '../interface/order.interface';
|
||||
import { PaymentMethodEnum, PaymentStatusEnum } from '../../payments/interface/payment';
|
||||
import { Review } from '../../review/entities/review.entity';
|
||||
|
||||
type FindOrdersOpts = {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
statuses?: OrderStatus[];
|
||||
paymentStatus?: PaymentStatusEnum;
|
||||
search?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
orderBy?: string;
|
||||
order?: 'asc' | 'desc';
|
||||
userId?: string;
|
||||
excludeOnlinePendingPayment?: boolean;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class OrderRepository extends EntityRepository<Order> {
|
||||
constructor(readonly em: EntityManager) {
|
||||
super(em, Order);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find orders with pagination and optional filters.
|
||||
* Supports: statuses, paymentStatus, search (orderNumber), date range, ordering.
|
||||
*/
|
||||
async findAllPaginated(restId: string, opts: FindOrdersOpts = {}): Promise<PaginatedResult<Order>> {
|
||||
const {
|
||||
page = 1,
|
||||
limit = 10,
|
||||
statuses,
|
||||
search,
|
||||
startDate,
|
||||
endDate,
|
||||
orderBy = 'createdAt',
|
||||
order = 'desc',
|
||||
userId,
|
||||
excludeOnlinePendingPayment = false,
|
||||
} = opts;
|
||||
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const where: FilterQuery<Order> = { restaurant: { id: restId } };
|
||||
|
||||
// Filter by statuses
|
||||
if (statuses) {
|
||||
// Ensure statuses is always an array and normalize it
|
||||
const normalizedStatuses = Array.isArray(statuses) ? statuses : [statuses];
|
||||
// Filter out any empty values
|
||||
const validStatuses = normalizedStatuses.filter((s): s is OrderStatus => !!s);
|
||||
|
||||
if (validStatuses.length > 0) {
|
||||
// Use direct equality for single value, $in for multiple values to avoid SQL generation issues
|
||||
if (validStatuses.length === 1) {
|
||||
where.status = validStatuses[0];
|
||||
} else {
|
||||
where.status = { $in: validStatuses };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (userId) {
|
||||
where.user = { id: userId };
|
||||
}
|
||||
|
||||
// Filter by date range
|
||||
if (startDate || endDate) {
|
||||
where.createdAt = {};
|
||||
if (startDate) {
|
||||
where.createdAt.$gte = new Date(startDate);
|
||||
}
|
||||
if (endDate) {
|
||||
where.createdAt.$lte = new Date(endDate);
|
||||
}
|
||||
}
|
||||
|
||||
// Search by order number or user information
|
||||
if (search) {
|
||||
const searchPattern = `%${search}%`;
|
||||
const searchConditions: any[] = [
|
||||
{ user: { firstName: { $ilike: searchPattern } } },
|
||||
{ user: { lastName: { $ilike: searchPattern } } },
|
||||
{ user: { phone: { $ilike: searchPattern } } },
|
||||
];
|
||||
|
||||
// If search is numeric, also search by orderNumber
|
||||
const numericSearch = Number(search);
|
||||
if (!isNaN(numericSearch) && isFinite(numericSearch)) {
|
||||
searchConditions.push({ orderNumber: numericSearch });
|
||||
}
|
||||
|
||||
where.$or = searchConditions;
|
||||
}
|
||||
|
||||
// Filter: Exclude orders with payment method Online and status pending_payment
|
||||
if (excludeOnlinePendingPayment) {
|
||||
const existingConditions = where.$and || [];
|
||||
where.$and = [
|
||||
...existingConditions,
|
||||
{
|
||||
$or: [
|
||||
{ paymentMethod: { method: { $ne: PaymentMethodEnum.Online } } },
|
||||
{ status: { $ne: OrderStatus.PENDING_PAYMENT } },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// First, fetch orders without reviews
|
||||
const [data, total] = await this.findAndCount(where, {
|
||||
limit,
|
||||
offset,
|
||||
orderBy: { [orderBy]: order.toLowerCase() as 'asc' | 'desc' },
|
||||
populate: ['user', 'restaurant', 'deliveryMethod', 'paymentMethod', 'items', 'items.food'] as never,
|
||||
});
|
||||
|
||||
// Collect all (orderId, foodId) pairs for efficient review lookup
|
||||
const orderFoodPairs: Array<{ orderId: string; foodId: string }> = [];
|
||||
for (const order of data) {
|
||||
for (const item of order.items.getItems()) {
|
||||
if (item.food?.id) {
|
||||
orderFoodPairs.push({ orderId: order.id, foodId: item.food.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch all relevant reviews in a single query
|
||||
const reviewsMap: Map<string, string> = new Map();
|
||||
if (orderFoodPairs.length > 0) {
|
||||
const orderIds = [...new Set(orderFoodPairs.map(p => p.orderId))];
|
||||
const foodIds = [...new Set(orderFoodPairs.map(p => p.foodId))];
|
||||
|
||||
const reviews = await this.em.find(
|
||||
Review,
|
||||
{
|
||||
order: { id: { $in: orderIds } },
|
||||
food: { id: { $in: foodIds } },
|
||||
},
|
||||
{
|
||||
fields: ['id', 'order', 'food'],
|
||||
populate: ['order', 'food'],
|
||||
},
|
||||
);
|
||||
|
||||
// Create a map: key = `${orderId}-${foodId}`, value = reviewId
|
||||
for (const review of reviews) {
|
||||
const key = `${review.order.id}-${review.food.id}`;
|
||||
reviewsMap.set(key, review.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Map reviewIds to foods
|
||||
for (const order of data) {
|
||||
for (const item of order.items.getItems()) {
|
||||
if (item.food) {
|
||||
const key = `${order.id}-${item.food.id}`;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const food = item.food as any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
food.reviewId = reviewsMap.get(key) || null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
return {
|
||||
data,
|
||||
meta: {
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import { Controller, Get, Post, Body, UseGuards, Res, Req, Patch, Param } from '@nestjs/common';
|
||||
import { PagerService } from '../providers/pager.service';
|
||||
import { CreatePagerDto } from '../dto/create-pager.dto';
|
||||
import { OptionalAuthGuard } from '../../auth/guards/optinalAuth.guard';
|
||||
import { RestId } from 'src/common/decorators/rest-id.decorator';
|
||||
import { UserId } from 'src/common/decorators/user-id.decorator';
|
||||
import { RestSlug } from 'src/common/decorators/rest-slug.decorator';
|
||||
import { ApiTags, ApiOperation, ApiBody, ApiHeader, ApiBearerAuth, ApiParam } from '@nestjs/swagger';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { AdminAuthGuard } from '../../auth/guards/adminAuth.guard';
|
||||
import { AdminId } from 'src/common/decorators/admin-id.decorator';
|
||||
import { UpdatePagerStatusDto } from '../dto/update-pager.dto';
|
||||
import { PagerMessage } from 'src/common/enums/message.enum';
|
||||
import { Permissions } from 'src/common/decorators/permissions.decorator';
|
||||
import { Permission } from 'src/common/enums/permission.enum';
|
||||
import { API_HEADER_SLUG } from 'src/common/constants';
|
||||
|
||||
@ApiTags('pager')
|
||||
@Controller()
|
||||
export class PagerController {
|
||||
constructor(private readonly pagerService: PagerService) { }
|
||||
|
||||
@UseGuards(OptionalAuthGuard)
|
||||
@Post('public/pager')
|
||||
@ApiOperation({ summary: 'Create a new pager request' })
|
||||
@ApiHeader({ name: 'X-Slug', required: true, description: 'Restaurant slug identifier' })
|
||||
@ApiHeader({ name: 'Authorization', required: false, description: 'Bearer token (optional authentication)' })
|
||||
@ApiBody({ type: CreatePagerDto })
|
||||
async create(
|
||||
@Body() createPagerDto: CreatePagerDto,
|
||||
@Req() request: FastifyRequest,
|
||||
@Res() reply: FastifyReply,
|
||||
@RestSlug() slug: string,
|
||||
@RestId() restId?: string,
|
||||
@UserId() userId?: string,
|
||||
) {
|
||||
// Convert empty strings to undefined for cleaner validation
|
||||
const normalizedRestId = restId || undefined;
|
||||
const normalizedUserId = userId || undefined;
|
||||
const userCookieId = request.cookies['pagerId'] || undefined;
|
||||
|
||||
const pager = await this.pagerService.create(createPagerDto, {
|
||||
restId: normalizedRestId,
|
||||
userId: normalizedUserId,
|
||||
userCookieId,
|
||||
slug,
|
||||
});
|
||||
reply.setCookie('pagerId', pager.cookieId, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: 1000 * 60 * 60 * 24 * 1, // 1 days
|
||||
path: '/',
|
||||
});
|
||||
return reply.code(200).send({
|
||||
success: true,
|
||||
message: PagerMessage.CREATED_SUCCESS,
|
||||
data: null,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@UseGuards(OptionalAuthGuard)
|
||||
@Get('public/pager')
|
||||
@ApiOperation({ summary: 'Get all pager requests for the current session' })
|
||||
@ApiHeader(API_HEADER_SLUG)
|
||||
@ApiHeader({ name: 'Authorization', required: false, description: 'Bearer token (optional authentication)' })
|
||||
async findAll(@Req() request: FastifyRequest) {
|
||||
const cookieId = request.cookies['pagerId'] || undefined;
|
||||
if (!cookieId) {
|
||||
return [];
|
||||
}
|
||||
return this.pagerService.findAll(cookieId);
|
||||
}
|
||||
|
||||
/******************** Admin Routes **********************/
|
||||
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@Permissions(Permission.MANAGE_PAGER)
|
||||
@ApiBearerAuth()
|
||||
@Get('admin/pager')
|
||||
@ApiOperation({ summary: 'Get all pager requests for the restaurant' })
|
||||
async findAllAdmin(@RestId() restId: string) {
|
||||
return this.pagerService.findAllByRestaurantId(restId);
|
||||
}
|
||||
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@Permissions(Permission.MANAGE_PAGER)
|
||||
@ApiBearerAuth()
|
||||
@Patch('admin/pager/:pagerId/status')
|
||||
@ApiParam({ name: 'pagerId', required: true, type: String })
|
||||
@ApiOperation({ summary: 'Update the status of a pager request' })
|
||||
@ApiBody({ type: UpdatePagerStatusDto })
|
||||
async updateStatus(
|
||||
@Param('pagerId') pagerId: string,
|
||||
@RestId() restId: string,
|
||||
@AdminId() adminId: string,
|
||||
@Body() dto: UpdatePagerStatusDto,
|
||||
) {
|
||||
return this.pagerService.updateStatus(pagerId, restId, adminId, dto.status);
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import type { ExecutionContext } from '@nestjs/common';
|
||||
import { createParamDecorator } from '@nestjs/common';
|
||||
import type { AuthenticatedSocket } from '../guards/ws-admin-auth.guard';
|
||||
|
||||
/**
|
||||
* Extract admin ID from authenticated WebSocket client
|
||||
* Use this decorator in WebSocket handlers to get the adminId from the authenticated admin
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* @SubscribeMessage('some:event')
|
||||
* handleEvent(@WsAdminId() adminId: string) {
|
||||
* // adminId is automatically extracted from authenticated admin
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const WsAdminId = createParamDecorator((data: unknown, ctx: ExecutionContext): string => {
|
||||
const client = ctx.switchToWs().getClient<AuthenticatedSocket>();
|
||||
const adminId = client.adminId;
|
||||
|
||||
if (!adminId) {
|
||||
throw new Error('Admin ID not found. Ensure WsAdminAuthGuard is applied.');
|
||||
}
|
||||
|
||||
return adminId;
|
||||
});
|
||||
@@ -1,26 +0,0 @@
|
||||
import type { ExecutionContext } from '@nestjs/common';
|
||||
import { createParamDecorator } from '@nestjs/common';
|
||||
import type { AuthenticatedSocket } from '../guards/ws-admin-auth.guard';
|
||||
|
||||
/**
|
||||
* Extract restaurant ID from authenticated WebSocket client
|
||||
* Use this decorator in WebSocket handlers to get the restId from the authenticated admin
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* @SubscribeMessage('join:restaurant')
|
||||
* handleJoinRestaurant(@WsRestId() restId: string) {
|
||||
* // restId is automatically extracted from authenticated admin
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const WsRestId = createParamDecorator((data: unknown, ctx: ExecutionContext): string => {
|
||||
const client = ctx.switchToWs().getClient<AuthenticatedSocket>();
|
||||
const restId = client.restId;
|
||||
|
||||
if (!restId) {
|
||||
throw new Error('Restaurant ID not found. Ensure WsAdminAuthGuard is applied.');
|
||||
}
|
||||
|
||||
return restId;
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class CreatePagerDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@ApiProperty({ description: 'Table number' })
|
||||
tableNumber!: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({ description: 'Message' })
|
||||
message?: string;
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
import type { PagerStatus } from '../interface/pager';
|
||||
|
||||
/**
|
||||
* Socket connection configuration for admin pager module
|
||||
* Used by frontend to establish WebSocket connection
|
||||
*/
|
||||
export interface PagerSocketConnectionConfig {
|
||||
/**
|
||||
* WebSocket server URL
|
||||
* Example: 'http://localhost:3000' or 'https://api.example.com'
|
||||
*/
|
||||
serverUrl: string;
|
||||
|
||||
/**
|
||||
* Socket namespace (default: '/pager')
|
||||
* The gateway is configured to use '/pager' namespace
|
||||
*/
|
||||
namespace?: string;
|
||||
|
||||
/**
|
||||
* JWT authentication token for admin
|
||||
* Token should contain adminId and restId in payload
|
||||
* Can be passed via auth.token, auth.authorization, query.token, or headers.authorization
|
||||
* Example: { auth: { token: 'your-jwt-token' } }
|
||||
*/
|
||||
token: string;
|
||||
|
||||
/**
|
||||
* Optional: Additional connection options
|
||||
*/
|
||||
options?: {
|
||||
/**
|
||||
* Enable auto-reconnection
|
||||
* @default true
|
||||
*/
|
||||
autoConnect?: boolean;
|
||||
|
||||
/**
|
||||
* Connection timeout in milliseconds
|
||||
* @default 5000
|
||||
*/
|
||||
timeout?: number;
|
||||
|
||||
/**
|
||||
* Enable transports (websocket, polling)
|
||||
* @default ['websocket', 'polling']
|
||||
*/
|
||||
transports?: ('websocket' | 'polling')[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Socket event names for pager module
|
||||
*/
|
||||
export enum PagerSocketEvents {
|
||||
// Client to Server Events
|
||||
JOIN_RESTAURANT = 'join:restaurant',
|
||||
LEAVE_ROOM = 'leave:room',
|
||||
GET_PAGERS = 'get:pagers',
|
||||
UPDATE_PAGER_STATUS = 'update:pager:status',
|
||||
|
||||
// Server to Client Events
|
||||
JOINED = 'joined',
|
||||
LEFT = 'left',
|
||||
PAGER_CREATED = 'pager:created',
|
||||
PAGER_STATUS_UPDATED = 'pager:status:updated',
|
||||
PAGERS_LIST = 'pagers:list',
|
||||
PAGER_STATUS_UPDATED_RESPONSE = 'pager:status:updated:response',
|
||||
ERROR = 'error',
|
||||
CONNECT = 'connect',
|
||||
DISCONNECT = 'disconnect',
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload for joining restaurant room
|
||||
* Note: restId is automatically extracted from JWT token, no need to send it
|
||||
*/
|
||||
export type JoinRestaurantPayload = Record<string, never>;
|
||||
|
||||
/**
|
||||
* Payload for leaving a room
|
||||
*/
|
||||
export interface LeaveRoomPayload {
|
||||
room: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload for getting pagers list
|
||||
* Note: restId is automatically extracted from JWT token, no need to send it
|
||||
*/
|
||||
export type GetPagersPayload = Record<string, never>;
|
||||
|
||||
/**
|
||||
* Payload for updating pager status
|
||||
* Note: restId and adminId are automatically extracted from JWT token
|
||||
*/
|
||||
export interface UpdatePagerStatusPayload {
|
||||
pagerId: string;
|
||||
status: PagerStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response when successfully joined a room
|
||||
*/
|
||||
export interface JoinedResponse {
|
||||
room: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response when successfully left a room
|
||||
*/
|
||||
export interface LeftResponse {
|
||||
room: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* User information in pager response
|
||||
*/
|
||||
export interface PagerUserInfo {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Staff information in pager response
|
||||
*/
|
||||
export interface PagerStaffInfo {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pager data structure emitted by socket events
|
||||
*/
|
||||
export interface PagerSocketData {
|
||||
id: string;
|
||||
tableNumber: string;
|
||||
message?: string;
|
||||
status: PagerStatus;
|
||||
cookieId?: string;
|
||||
createdAt?: Date | string;
|
||||
updatedAt?: Date | string;
|
||||
user?: PagerUserInfo | null;
|
||||
staff?: PagerStaffInfo | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload for pager:created event
|
||||
*/
|
||||
export interface PagerCreatedPayload {
|
||||
pager: PagerSocketData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload for pager:status:updated event
|
||||
*/
|
||||
export interface PagerStatusUpdatedPayload {
|
||||
pager: PagerSocketData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload for pagers:list event
|
||||
*/
|
||||
export interface PagersListPayload {
|
||||
pagers: PagerSocketData[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload for pager:status:updated:response event (response to update request)
|
||||
*/
|
||||
export interface PagerStatusUpdatedResponsePayload {
|
||||
pager: PagerSocketData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error response from socket
|
||||
*/
|
||||
export interface SocketErrorResponse {
|
||||
message: string;
|
||||
code?: string;
|
||||
details?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete socket event map for type safety
|
||||
*/
|
||||
export interface PagerSocketEventMap {
|
||||
// Client emits
|
||||
[PagerSocketEvents.JOIN_RESTAURANT]: (payload?: JoinRestaurantPayload) => void;
|
||||
[PagerSocketEvents.LEAVE_ROOM]: (payload: LeaveRoomPayload) => void;
|
||||
[PagerSocketEvents.GET_PAGERS]: (payload?: GetPagersPayload) => void;
|
||||
[PagerSocketEvents.UPDATE_PAGER_STATUS]: (payload: UpdatePagerStatusPayload) => void;
|
||||
|
||||
// Server emits
|
||||
[PagerSocketEvents.JOINED]: (response: JoinedResponse) => void;
|
||||
[PagerSocketEvents.LEFT]: (response: LeftResponse) => void;
|
||||
[PagerSocketEvents.PAGER_CREATED]: (payload: PagerCreatedPayload) => void;
|
||||
[PagerSocketEvents.PAGER_STATUS_UPDATED]: (payload: PagerStatusUpdatedPayload) => void;
|
||||
[PagerSocketEvents.PAGERS_LIST]: (payload: PagersListPayload) => void;
|
||||
[PagerSocketEvents.PAGER_STATUS_UPDATED_RESPONSE]: (payload: PagerStatusUpdatedResponsePayload) => void;
|
||||
[PagerSocketEvents.ERROR]: (error: SocketErrorResponse) => void;
|
||||
[PagerSocketEvents.CONNECT]: () => void;
|
||||
[PagerSocketEvents.DISCONNECT]: (reason: string) => void;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsEnum, IsNotEmpty } from 'class-validator';
|
||||
import { PagerStatus } from '../interface/pager';
|
||||
|
||||
export class UpdatePagerStatusDto {
|
||||
@ApiProperty({ description: 'Status of the pager', enum: PagerStatus })
|
||||
@IsEnum(PagerStatus)
|
||||
@IsNotEmpty()
|
||||
status!: PagerStatus;
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { Entity, ManyToOne, Property, Enum } from '@mikro-orm/core';
|
||||
import { BaseEntity } from '../../../common/entities/base.entity';
|
||||
import { User } from '../../users/entities/user.entity';
|
||||
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
|
||||
import { PagerStatus } from '../interface/pager';
|
||||
import { Admin } from 'src/modules/admin/entities/admin.entity';
|
||||
|
||||
@Entity({ tableName: 'pagers' })
|
||||
export class Pager extends BaseEntity {
|
||||
@Property({ type: 'string' })
|
||||
cookieId!: string;
|
||||
|
||||
@ManyToOne(() => Restaurant)
|
||||
restaurant!: Restaurant;
|
||||
|
||||
@Property({ type: 'string' })
|
||||
tableNumber!: string;
|
||||
|
||||
@ManyToOne(() => User, { nullable: true })
|
||||
user?: User;
|
||||
|
||||
@ManyToOne(() => Admin, { nullable: true })
|
||||
staff?: Admin;
|
||||
|
||||
@Property({ type: 'string', nullable: true })
|
||||
message?: string;
|
||||
|
||||
@Enum(() => PagerStatus)
|
||||
status: PagerStatus = PagerStatus.Pending;
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export class PagerCreatedEvent {
|
||||
constructor(
|
||||
public readonly restaurantId: string,
|
||||
public readonly tableNumber: string,
|
||||
) {}
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import { CanActivate, ExecutionContext, Injectable, Logger, Inject } from '@nestjs/common';
|
||||
import { WsException } from '@nestjs/websockets';
|
||||
import { Socket } from 'socket.io';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { IAdminTokenPayload } from '../../auth/interfaces/IToken-payload';
|
||||
|
||||
export interface AuthenticatedSocket extends Socket {
|
||||
adminId?: string;
|
||||
restId?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class WsAdminAuthGuard implements CanActivate {
|
||||
private readonly logger = new Logger(WsAdminAuthGuard.name);
|
||||
|
||||
constructor(
|
||||
@Inject(JwtService)
|
||||
private readonly jwtService: JwtService,
|
||||
@Inject(ConfigService)
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const client: Socket = context.switchToWs().getClient<Socket>();
|
||||
|
||||
try {
|
||||
const token = this.extractToken(client);
|
||||
|
||||
if (!token) {
|
||||
this.logger.warn('No token provided in WebSocket connection', {
|
||||
socketId: client.id,
|
||||
auth: client.handshake.auth,
|
||||
query: client.handshake.query,
|
||||
});
|
||||
throw new WsException('Authentication required');
|
||||
}
|
||||
|
||||
const secret = this.configService.getOrThrow<string>('JWT_SECRET');
|
||||
const payload = await this.jwtService.verifyAsync<IAdminTokenPayload>(token, {
|
||||
secret,
|
||||
});
|
||||
|
||||
if (!payload.adminId || !payload.restId) {
|
||||
this.logger.error('Invalid token payload structure', payload);
|
||||
throw new WsException('Invalid token payload');
|
||||
}
|
||||
|
||||
// Attach admin info to socket
|
||||
(client as AuthenticatedSocket).adminId = payload.adminId;
|
||||
(client as AuthenticatedSocket).restId = payload.restId;
|
||||
|
||||
this.logger.log(`Admin authenticated via WebSocket: ${payload.adminId}, restId: ${payload.restId}`);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof WsException) {
|
||||
throw error;
|
||||
}
|
||||
this.logger.error('WebSocket authentication error', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
socketId: client.id,
|
||||
});
|
||||
throw new WsException('Authentication failed');
|
||||
}
|
||||
}
|
||||
|
||||
private extractToken(client: Socket): string | undefined {
|
||||
// Try to get token from handshake auth (recommended for socket.io)
|
||||
const auth = client.handshake.auth;
|
||||
const authToken = (auth?.token as string | undefined) || (auth?.authorization as string | undefined);
|
||||
|
||||
if (authToken) {
|
||||
// If it's "Bearer <token>", extract just the token
|
||||
if (typeof authToken === 'string' && authToken.startsWith('Bearer ')) {
|
||||
return authToken.substring(7);
|
||||
}
|
||||
return authToken;
|
||||
}
|
||||
|
||||
// Try to get from query parameters (fallback)
|
||||
const queryToken = client.handshake.query?.token || client.handshake.query?.authorization;
|
||||
|
||||
if (queryToken) {
|
||||
if (typeof queryToken === 'string' && queryToken.startsWith('Bearer ')) {
|
||||
return queryToken.substring(7);
|
||||
}
|
||||
return queryToken as string;
|
||||
}
|
||||
|
||||
// Try to get from headers (if available)
|
||||
const headers = client.handshake.headers;
|
||||
const authHeader = headers.authorization || headers['authorization'];
|
||||
|
||||
if (authHeader && typeof authHeader === 'string') {
|
||||
const [type, token] = authHeader.split(' ');
|
||||
if (type?.toLowerCase() === 'bearer' && token) {
|
||||
return token;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export enum PagerStatus {
|
||||
Pending = 'pending',
|
||||
Acknowledged = 'acknowledged',
|
||||
Rejected = 'rejected',
|
||||
Completed = 'completed',
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { AdminRepository } from 'src/modules/admin/repositories/admin.repository';
|
||||
import { PagerCreatedEvent } from '../events/pager.events';
|
||||
import { Permission } from 'src/common/enums/permission.enum';
|
||||
import { NotifTitleEnum } from 'src/modules/notifications/interfaces/notification.interface';
|
||||
import { NotificationService } from 'src/modules/notifications/services/notification.service';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@Injectable()
|
||||
export class PagerListeners {
|
||||
private readonly logger = new Logger(PagerListeners.name);
|
||||
private readonly smsPatternPagerCreated: string
|
||||
constructor(
|
||||
private readonly adminService: AdminRepository,
|
||||
private readonly notificationService: NotificationService,
|
||||
private readonly configService: ConfigService,
|
||||
) {
|
||||
this.smsPatternPagerCreated = this.configService.get<string>('SMS_PATTERN_PAGER_CREATED') ?? '123';
|
||||
}
|
||||
|
||||
@OnEvent(PagerCreatedEvent.name)
|
||||
async handlePagerCreated(event: PagerCreatedEvent) {
|
||||
try {
|
||||
this.logger.log(`Pager created event received: ${event.restaurantId}`);
|
||||
// get admnin os restuaraant that have pager permissuins
|
||||
const admins = await this.adminService.findAdminsWithPermission(event.restaurantId, Permission.PAGER_NOTIFICATION);
|
||||
const recipients = admins.map(admin => ({
|
||||
adminId: admin.id,
|
||||
}));
|
||||
await this.notificationService.sendNotification({
|
||||
restaurantId: event.restaurantId,
|
||||
message: {
|
||||
title: NotifTitleEnum.PAGER_CREATED,
|
||||
content: `میز شماره ${event.tableNumber} پیج جدیدی انجام داد`,
|
||||
sms: {
|
||||
templateId: this.smsPatternPagerCreated,
|
||||
parameters: {
|
||||
tableNumber: event.tableNumber.toString(),
|
||||
},
|
||||
},
|
||||
pushNotif: {
|
||||
title: ` پیج جدید`,
|
||||
content: `میز شماره ${event.tableNumber} پیج جدیدی انجام داد`,
|
||||
icon: `/assets/images/logo.png`,
|
||||
action: {
|
||||
type: NotifTitleEnum.PAGER_CREATED,
|
||||
url: `/restaurants/${event.restaurantId}/pagers`,
|
||||
},
|
||||
},
|
||||
},
|
||||
recipients,
|
||||
metadata: {
|
||||
priority: 1,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to send notification for pager created event: ${event.restaurantId}`,
|
||||
error instanceof Error ? error.stack : String(error),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PagerService } from './providers/pager.service';
|
||||
import { PagerController } from './controllers/pager.controller';
|
||||
import { MikroOrmModule } from '@mikro-orm/nestjs';
|
||||
import { Pager } from './entities/pager.entity';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { RolesModule } from '../roles/roles.module';
|
||||
import { PagerListeners } from './listeners/notification.listeners';
|
||||
import { AdminModule } from '../admin/admin.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
|
||||
@Module({
|
||||
imports: [MikroOrmModule.forFeature([Pager]), JwtModule, RolesModule, AdminModule, NotificationsModule],
|
||||
controllers: [PagerController],
|
||||
providers: [PagerService, PagerListeners],
|
||||
})
|
||||
export class PagerModule {}
|
||||
@@ -1,115 +0,0 @@
|
||||
import { BadRequestException, Injectable, NotFoundException, Inject, forwardRef } from '@nestjs/common';
|
||||
import { CreatePagerDto } from '../dto/create-pager.dto';
|
||||
import { User } from '../../users/entities/user.entity';
|
||||
import { EntityManager } from '@mikro-orm/postgresql';
|
||||
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
|
||||
import { Pager } from '../entities/pager.entity';
|
||||
import { PagerStatus } from '../interface/pager';
|
||||
import { ulid } from 'ulid';
|
||||
import { Admin } from '../../admin/entities/admin.entity';
|
||||
import { PagerCreatedEvent } from '../events/pager.events';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
|
||||
@Injectable()
|
||||
export class PagerService {
|
||||
constructor(
|
||||
private readonly em: EntityManager,
|
||||
// @Inject(forwardRef(() => PagerGateway))
|
||||
// private readonly pagerGateway: PagerGateway,
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
) {}
|
||||
|
||||
async create(
|
||||
createPagerDto: CreatePagerDto,
|
||||
params: { restId?: string; userId?: string; slug: string; userCookieId?: string },
|
||||
) {
|
||||
const { restId, userId, slug, userCookieId } = params;
|
||||
let restaurant: Restaurant | null = null;
|
||||
let user: User | null = null;
|
||||
if (!restId && !userId && !slug) {
|
||||
throw new BadRequestException('Invalid parameters');
|
||||
}
|
||||
if (restId) {
|
||||
restaurant = await this.em.findOne(Restaurant, { id: restId });
|
||||
if (!restaurant) {
|
||||
throw new NotFoundException('Restaurant not found');
|
||||
}
|
||||
}
|
||||
if (userId) {
|
||||
user = await this.em.findOne(User, { id: userId });
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
}
|
||||
if (!restaurant && slug) {
|
||||
restaurant = await this.em.findOne(Restaurant, { slug });
|
||||
if (!restaurant) {
|
||||
throw new NotFoundException('Restaurant not found');
|
||||
}
|
||||
}
|
||||
const cookieId = userCookieId || ulid().toString();
|
||||
// const existingPager = await this.em.findOne(Pager, {
|
||||
// restaurant: { id: restaurant!.id },
|
||||
// status: PagerStatus.Pending,
|
||||
// cookieId,
|
||||
// });
|
||||
// if (existingPager) {
|
||||
// throw new BadRequestException('Pager already exists');
|
||||
// }
|
||||
|
||||
const pager = this.em.create(Pager, {
|
||||
...createPagerDto,
|
||||
restaurant: restaurant!,
|
||||
user,
|
||||
cookieId: ulid().toString(),
|
||||
status: PagerStatus.Pending,
|
||||
});
|
||||
|
||||
await this.em.persistAndFlush(pager);
|
||||
|
||||
// Populate relations before emitting
|
||||
await this.em.populate(pager, ['restaurant']);
|
||||
// Emit socket event for new pager
|
||||
this.eventEmitter.emit(PagerCreatedEvent.name, new PagerCreatedEvent(pager.restaurant.id, pager.tableNumber));
|
||||
|
||||
return pager;
|
||||
}
|
||||
|
||||
async findAll(cookieId: string) {
|
||||
const pagers = await this.em.find(Pager, { cookieId }, { populate: ['user'] });
|
||||
return pagers;
|
||||
}
|
||||
|
||||
async findAllByRestaurantId(restId: string) {
|
||||
const pagers = await this.em.find(Pager, { restaurant: { id: restId } }, { populate: ['user'] });
|
||||
return pagers;
|
||||
}
|
||||
|
||||
async updateStatus(pagerId: string, restId: string, staffId: string, status: PagerStatus) {
|
||||
const pager = await this.em.findOne(
|
||||
Pager,
|
||||
{ id: pagerId, restaurant: { id: restId } },
|
||||
{ populate: ['restaurant'] },
|
||||
);
|
||||
if (!pager) {
|
||||
throw new NotFoundException('Pager not found *');
|
||||
}
|
||||
const staff = await this.em.findOne(Admin, { id: staffId });
|
||||
if (!staff) {
|
||||
throw new NotFoundException('Staff not found');
|
||||
}
|
||||
pager.status = status;
|
||||
if (status === PagerStatus.Acknowledged) {
|
||||
pager.staff = staff;
|
||||
}
|
||||
await this.em.persistAndFlush(pager);
|
||||
|
||||
// Populate relations before emitting
|
||||
await this.em.populate(pager, ['restaurant', 'staff', 'user']);
|
||||
|
||||
// Emit socket event for status update
|
||||
// this.pagerGateway.emitPagerStatusUpdated(pager);
|
||||
|
||||
return pager;
|
||||
}
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards, Query } from '@nestjs/common';
|
||||
import { PaymentsService } from '../services/payments.service';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiNotFoundResponse,
|
||||
ApiBody,
|
||||
ApiParam,
|
||||
ApiBearerAuth,
|
||||
ApiHeader,
|
||||
} from '@nestjs/swagger';
|
||||
import { AdminAuthGuard } from 'src/modules/auth/guards/adminAuth.guard';
|
||||
import { PaymentMethodService } from '../services/payment-method.service';
|
||||
import { CreatePaymentMethodDto } from '../dto/create-payment-method.dto';
|
||||
import { UpdatePaymentMethodDto } from '../dto/update-payment-method.dto';
|
||||
import { VerifyPaymentDto } from '../dto/verify-payment.dto';
|
||||
import { PaymentChartDto } from '../dto/payment-chart.dto';
|
||||
import { RestId, UserId } from 'src/common/decorators';
|
||||
import { AuthGuard } from 'src/modules/auth/guards/auth.guard';
|
||||
import { API_HEADER_SLUG } from 'src/common/constants';
|
||||
import { Permissions } from 'src/common/decorators/permissions.decorator';
|
||||
import { Permission } from 'src/common/enums/permission.enum';
|
||||
|
||||
@ApiTags('payments')
|
||||
@Controller()
|
||||
export class PaymentsController {
|
||||
constructor(
|
||||
private readonly paymentsService: PaymentsService,
|
||||
private readonly paymentMethodService: PaymentMethodService,
|
||||
) { }
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@Get('public/payments/methods/restaurant')
|
||||
@ApiOperation({ summary: 'Get the restaurant payment methods' })
|
||||
@ApiHeader(API_HEADER_SLUG)
|
||||
@ApiNotFoundResponse({ description: 'Restaurant payment methods not found' })
|
||||
getTheRestaurantPaymentMethods(@RestId() restId: string) {
|
||||
return this.paymentMethodService.findByRestaurant(restId);
|
||||
}
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@Get('public/payments')
|
||||
@ApiOperation({ summary: 'Get all the restaurant payments for user' })
|
||||
@ApiHeader(API_HEADER_SLUG)
|
||||
findAllByRestaurant(@UserId() userId: string, @RestId() restId: string) {
|
||||
return this.paymentsService.findAllPaymentsByRestaurantId(restId, userId);
|
||||
}
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@Get('public/payments/pay-order/:orderId')
|
||||
@ApiOperation({ summary: 'Pay for an order' })
|
||||
@ApiParam({ name: 'orderId', type: 'string', description: 'Order ID' })
|
||||
@ApiHeader(API_HEADER_SLUG)
|
||||
payAnOrder(@Param('orderId') orderId: string) {
|
||||
return this.paymentsService.payOrder(orderId);
|
||||
}
|
||||
|
||||
@Post('public/payments/verify')
|
||||
@ApiOperation({ summary: 'Verify a payment' })
|
||||
@ApiHeader(API_HEADER_SLUG)
|
||||
@ApiBody({ type: VerifyPaymentDto })
|
||||
@ApiNotFoundResponse({ description: 'Payment not found' })
|
||||
verifyPayment(@Body() verifyPaymentDto: VerifyPaymentDto) {
|
||||
return this.paymentsService.verifyOnlinePayment(verifyPaymentDto.authority, verifyPaymentDto.orderId);
|
||||
}
|
||||
|
||||
/** admin routes */
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@Permissions(Permission.MANAGE_PAYMENTS)
|
||||
@ApiBearerAuth()
|
||||
@Get('admin/payments/methods')
|
||||
@ApiOperation({ summary: 'Get restaurant all payment methods' })
|
||||
findByRestaurant(@RestId() restId: string) {
|
||||
return this.paymentMethodService.findByRestaurant(restId);
|
||||
}
|
||||
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@Permissions(Permission.MANAGE_PAYMENTS)
|
||||
@ApiBearerAuth()
|
||||
@Post('admin/payments/methods')
|
||||
@ApiOperation({ summary: 'Create a new restaurant payment method' })
|
||||
@ApiBody({ type: CreatePaymentMethodDto })
|
||||
createRestaurantPaymentMethod(@RestId() restId: string, @Body() createPaymentMethodDto: CreatePaymentMethodDto) {
|
||||
return this.paymentMethodService.create(restId, createPaymentMethodDto);
|
||||
}
|
||||
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@Permissions(Permission.MANAGE_PAYMENTS)
|
||||
@ApiBearerAuth()
|
||||
@Get('admin/payments/methods/:paymentMethodId')
|
||||
@ApiOperation({ summary: 'Get restaurant payment method by ID' })
|
||||
@ApiParam({ name: 'paymentMethodId', type: 'string', description: 'Payment method ID' })
|
||||
findOneRestaurantPaymentMethod(@Param('paymentMethodId') paymentMethodId: string) {
|
||||
return this.paymentMethodService.findOne(paymentMethodId);
|
||||
}
|
||||
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@Permissions(Permission.MANAGE_PAYMENTS)
|
||||
@ApiBearerAuth()
|
||||
@Patch('admin/payments/methods/:paymentMethodId')
|
||||
@ApiOperation({ summary: 'Update a restaurant payment method' })
|
||||
@ApiParam({ name: 'paymentMethodId', type: 'string', description: 'Payment method ID' })
|
||||
@ApiBody({ type: UpdatePaymentMethodDto })
|
||||
updateRestaurantPaymentMethod(
|
||||
@Param('paymentMethodId') paymentMethodId: string,
|
||||
@Body() updatePaymentMethodDto: UpdatePaymentMethodDto,
|
||||
) {
|
||||
return this.paymentMethodService.update(paymentMethodId, updatePaymentMethodDto);
|
||||
}
|
||||
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@Permissions(Permission.MANAGE_PAYMENTS)
|
||||
@ApiBearerAuth()
|
||||
@Delete('admin/payments/methods/:paymentMethodId')
|
||||
@ApiOperation({ summary: 'Delete a restaurant payment method' })
|
||||
@ApiParam({ name: 'paymentMethodId', type: 'string', description: 'Payment method ID' })
|
||||
removeRestaurantPaymentMethod(@Param('paymentMethodId') paymentMethodId: string) {
|
||||
return this.paymentMethodService.remove(paymentMethodId);
|
||||
}
|
||||
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@Permissions(Permission.MANAGE_PAYMENTS)
|
||||
@ApiBearerAuth()
|
||||
@Post('admin/payments/verify-cash-method/:paymentId')
|
||||
@ApiOperation({ summary: 'Verify cash payment ' })
|
||||
@ApiParam({ name: 'paymentId', type: 'string', description: 'Payment ID' })
|
||||
verifyCashPayment(@Param('paymentId') paymentId: string) {
|
||||
return this.paymentsService.verifyCashPayment(paymentId);
|
||||
}
|
||||
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@Permissions(Permission.VIEW_REPORTS)
|
||||
@ApiBearerAuth()
|
||||
@Get('admin/payments/chart')
|
||||
@ApiOperation({ summary: 'Get payment chart data with date and period filters' })
|
||||
@ApiHeader(API_HEADER_SLUG)
|
||||
getPaymentChart(@Query() query: PaymentChartDto, @RestId() restId: string) {
|
||||
return this.paymentsService.getChartData(query, restId);
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { IsString, IsOptional, IsBoolean, IsNumber, IsEnum } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { PaymentMethodEnum, PaymentGatewayEnum } from '../interface/payment';
|
||||
|
||||
export class CreatePaymentMethodDto {
|
||||
|
||||
@ApiProperty({ description: 'Payment method', enum: PaymentMethodEnum })
|
||||
@IsEnum(PaymentMethodEnum)
|
||||
method!: PaymentMethodEnum;
|
||||
|
||||
@ApiProperty({ description: 'Payment gateway', enum: PaymentGatewayEnum, required: false })
|
||||
@IsOptional()
|
||||
@IsEnum(PaymentGatewayEnum)
|
||||
gateway?: PaymentGatewayEnum | null;
|
||||
|
||||
@ApiProperty({ description: 'Payment method description', required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@ApiProperty({ description: 'Is payment method enabled', required: false, default: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enabled?: boolean;
|
||||
|
||||
@ApiProperty({ description: 'Display order', required: false, default: 0 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
order?: number;
|
||||
|
||||
@ApiProperty({ description: 'Merchant ID', required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
merchantId?: string;
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { IsOptional, IsString, IsEnum, IsDateString } from 'class-validator';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export enum ChartPeriodEnum {
|
||||
Daily = 'daily',
|
||||
Weekly = 'weekly',
|
||||
Monthly = 'monthly',
|
||||
}
|
||||
|
||||
export class PaymentChartDto {
|
||||
@ApiPropertyOptional({
|
||||
description: 'Start date for filtering (ISO date string)',
|
||||
type: String,
|
||||
format: 'date-time',
|
||||
example: '2024-01-01T00:00:00Z',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
startDate?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'End date for filtering (ISO date string)',
|
||||
type: String,
|
||||
format: 'date-time',
|
||||
example: '2024-12-31T23:59:59Z',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
endDate?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Period type for grouping data',
|
||||
enum: ChartPeriodEnum,
|
||||
default: ChartPeriodEnum.Daily,
|
||||
example: ChartPeriodEnum.Daily,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(ChartPeriodEnum)
|
||||
type?: ChartPeriodEnum = ChartPeriodEnum.Daily;
|
||||
}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreatePaymentMethodDto } from './create-payment-method.dto';
|
||||
|
||||
export class UpdatePaymentMethodDto extends PartialType(CreatePaymentMethodDto) {}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { IsNumber } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class UpdatePaymentDto {
|
||||
@ApiProperty({ description: 'Payment ID' })
|
||||
@IsNumber()
|
||||
id: number;
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { IsString } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class VerifyPaymentDto {
|
||||
@ApiProperty({ description: 'Payment authority token' })
|
||||
@IsString()
|
||||
authority: string;
|
||||
|
||||
@ApiProperty({ description: 'Payment order id' })
|
||||
@IsString()
|
||||
orderId: string;
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { Entity, Enum, Index, ManyToOne, Property, Unique } from '@mikro-orm/core';
|
||||
import { BaseEntity } from '../../../common/entities/base.entity';
|
||||
import { Restaurant } from 'src/modules/restaurants/entities/restaurant.entity';
|
||||
import { PaymentGatewayEnum, PaymentMethodEnum } from '../interface/payment';
|
||||
|
||||
@Entity({ tableName: 'payment_methods' })
|
||||
@Unique({ properties: ['restaurant', 'method'] })
|
||||
@Index({ properties: ['restaurant', 'enabled'] })
|
||||
export class PaymentMethod extends BaseEntity {
|
||||
@ManyToOne(() => Restaurant)
|
||||
restaurant!: Restaurant;
|
||||
|
||||
@Enum(() => PaymentMethodEnum)
|
||||
method!: PaymentMethodEnum;
|
||||
|
||||
@Enum(() => PaymentGatewayEnum)
|
||||
gateway: PaymentGatewayEnum | null = null;
|
||||
|
||||
@Property({ nullable: true })
|
||||
description?: string;
|
||||
|
||||
@Property({ default: true })
|
||||
enabled: boolean = true;
|
||||
|
||||
@Property({ type: 'integer', default: 0 })
|
||||
order: number = 0;
|
||||
|
||||
@Property({ nullable: true })
|
||||
merchantId?: string;
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { Entity, ManyToOne, Property, Enum, Index } from '@mikro-orm/core';
|
||||
import { BaseEntity } from '../../../common/entities/base.entity';
|
||||
import { Order } from '../../orders/entities/order.entity';
|
||||
import { PaymentGatewayEnum, PaymentMethodEnum, PaymentStatusEnum } from '../interface/payment';
|
||||
|
||||
@Entity({ tableName: 'payments' })
|
||||
export class Payment extends BaseEntity {
|
||||
@ManyToOne(() => Order)
|
||||
@Index()
|
||||
order!: Order;
|
||||
|
||||
@Property({ type: 'decimal', precision: 10, scale: 0 })
|
||||
amount!: number;
|
||||
|
||||
@Property({ unique: true, nullable: true })
|
||||
referenceId?: string | null;
|
||||
|
||||
@Enum(() => PaymentMethodEnum)
|
||||
method!: PaymentMethodEnum;
|
||||
|
||||
@Enum(() => PaymentGatewayEnum)
|
||||
gateway?: PaymentGatewayEnum | null = null;
|
||||
|
||||
@Property({ nullable: true })
|
||||
transactionId?: string | null = null;
|
||||
|
||||
@Enum(() => PaymentStatusEnum)
|
||||
status!: PaymentStatusEnum;
|
||||
|
||||
@Property({ nullable: true })
|
||||
cardPan?: string | null = null;
|
||||
|
||||
@Property({ type: 'json', nullable: true })
|
||||
verifyResponse?: Record<string, any> | null = null;
|
||||
|
||||
@Property({ nullable: true })
|
||||
paidAt?: Date | null = null;
|
||||
|
||||
@Property({ nullable: true })
|
||||
failedAt?: Date | null = null;
|
||||
|
||||
@Property({ nullable: true })
|
||||
description?: string | null = null;
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { PaymentMethodEnum } from "../interface/payment";
|
||||
|
||||
export class paymentSucceedEvent {
|
||||
constructor(
|
||||
public readonly orderId: string,
|
||||
public readonly restaurantId: string,
|
||||
public readonly orderNumber: string,
|
||||
public readonly paymentMethod: PaymentMethodEnum,
|
||||
public readonly total: number
|
||||
) { }
|
||||
}
|
||||
|
||||
|
||||
|
||||
export class onlinePaymentSucceedEvent {
|
||||
constructor(
|
||||
public readonly paymentId: string,
|
||||
public readonly orderId: string,
|
||||
public readonly restaurantId: string,
|
||||
public readonly orderNumber: string,
|
||||
public readonly total: number,
|
||||
) { }
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { PaymentGatewayEnum } from '../interface/payment';
|
||||
import { ZarinpalGateway } from './zarinpal.gateway';
|
||||
import { IPaymentGateway } from '../interface/gateway';
|
||||
|
||||
@Injectable()
|
||||
export class GatewayManager {
|
||||
constructor(
|
||||
private readonly zarinpal: ZarinpalGateway,
|
||||
) { }
|
||||
|
||||
get(gateway: PaymentGatewayEnum): IPaymentGateway {
|
||||
switch (gateway) {
|
||||
case PaymentGatewayEnum.ZarinPal:
|
||||
return this.zarinpal;
|
||||
default:
|
||||
throw new BadRequestException(`Unsupported gateway: ${gateway}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
IPaymentGateway,
|
||||
IPaymentVerifyParams,
|
||||
IRequestPaymentData,
|
||||
IRequestPaymentParams,
|
||||
IPaymentVerifyData,
|
||||
IZarinpalPaymentResponse,
|
||||
IZarinpalRequestPayment,
|
||||
IZarinpalVerifyRequest,
|
||||
IZarinpalVerifyResponse,
|
||||
} from '../interface/gateway';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PaymentMessage } from 'src/common/enums/message.enum';
|
||||
|
||||
@Injectable()
|
||||
export class ZarinpalGateway implements IPaymentGateway {
|
||||
private readonly logger = new Logger(ZarinpalGateway.name);
|
||||
private readonly zarinpalRequestUrl: string;
|
||||
private readonly zarinpalVerifyUrl: string;
|
||||
private readonly zarinpalPaymentBaseUrl: string;
|
||||
private readonly axiosConfig = {
|
||||
timeout: 10_000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
};
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
const zarinpalBaseUrl = this.configService.get<string>('ZARINPAL_BASE_URL') || 'https://sandbox.zarinpal.com';
|
||||
this.zarinpalPaymentBaseUrl = zarinpalBaseUrl;
|
||||
this.zarinpalRequestUrl = `${zarinpalBaseUrl}/pg/v4/payment/request.json`;
|
||||
this.zarinpalVerifyUrl = `${zarinpalBaseUrl}/pg/v4/payment/verify.json`;
|
||||
}
|
||||
|
||||
private getAxiosErrorData(error: unknown): { status?: number; data?: unknown } | null {
|
||||
if (!axios.isAxiosError(error) || !error.response) return null;
|
||||
return { status: error.response.status, data: error.response.data };
|
||||
}
|
||||
|
||||
async requestPayment({ amount, merchantId, orderId, domain }: IRequestPaymentParams): Promise<IRequestPaymentData> {
|
||||
// Transform camelCase to snake_case for Zarinpal API v4
|
||||
const callbackUrl = `${domain}/verify/${orderId}`;
|
||||
const zarinpalRequest: IZarinpalRequestPayment = {
|
||||
amount,
|
||||
merchant_id: merchantId,
|
||||
description: `Payment for order #${orderId}`,
|
||||
callback_url: callbackUrl,
|
||||
currency: 'IRT',
|
||||
metadata: {
|
||||
order_id: orderId,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await axios.post<IZarinpalPaymentResponse>(
|
||||
this.zarinpalRequestUrl,
|
||||
zarinpalRequest,
|
||||
this.axiosConfig,
|
||||
);
|
||||
const { data, errors } = res.data ?? {};
|
||||
const code = data?.code;
|
||||
const message = data?.message;
|
||||
const authority = data?.authority;
|
||||
|
||||
if (!data) {
|
||||
this.logger.error('Invalid response from Zarinpal API (missing data)', JSON.stringify(res.data));
|
||||
throw new BadRequestException(PaymentMessage.ZARINPAL_INVALID_RESPONSE);
|
||||
}
|
||||
|
||||
if ((Array.isArray(errors) && errors.length > 0) || code !== 100 || !authority) {
|
||||
this.logger.error(
|
||||
'Zarinpal payment request failed',
|
||||
JSON.stringify({
|
||||
code,
|
||||
message,
|
||||
errors,
|
||||
callbackUrl,
|
||||
amount,
|
||||
orderId,
|
||||
}),
|
||||
);
|
||||
throw new BadRequestException(message ?? PaymentMessage.ZARINPAL_PAYMENT_REQUEST_ERROR);
|
||||
}
|
||||
|
||||
return { transactionId: authority };
|
||||
} catch (error) {
|
||||
if (error instanceof BadRequestException) throw error;
|
||||
|
||||
const axiosErr = this.getAxiosErrorData(error);
|
||||
if (axiosErr) {
|
||||
this.logger.error(
|
||||
'Zarinpal payment request axios error',
|
||||
JSON.stringify({
|
||||
status: axiosErr.status,
|
||||
data: axiosErr.data,
|
||||
callbackUrl,
|
||||
amount,
|
||||
orderId,
|
||||
}),
|
||||
);
|
||||
throw new BadRequestException(PaymentMessage.ZARINPAL_PAYMENT_REQUEST_ERROR);
|
||||
}
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
this.logger.error('Zarinpal payment request error', errorMessage);
|
||||
throw new BadRequestException(PaymentMessage.GATEWAY_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
async verifyPayment({ merchantId, amount, transactionId }: IPaymentVerifyParams): Promise<IPaymentVerifyData> {
|
||||
try {
|
||||
// Transform camelCase to snake_case for Zarinpal API v4
|
||||
const zarinpalVerifyRequest: IZarinpalVerifyRequest = {
|
||||
merchant_id: merchantId,
|
||||
amount,
|
||||
authority: transactionId,
|
||||
};
|
||||
|
||||
const res = await axios.post<IZarinpalVerifyResponse>(
|
||||
this.zarinpalVerifyUrl,
|
||||
zarinpalVerifyRequest,
|
||||
this.axiosConfig,
|
||||
);
|
||||
|
||||
// Check if response data exists
|
||||
if (!res.data || !res.data.data) {
|
||||
throw new BadRequestException(PaymentMessage.ZARINPAL_INVALID_RESPONSE);
|
||||
}
|
||||
|
||||
const { code, message, ref_id, card_pan } = res.data.data;
|
||||
|
||||
// Check if there are errors in the response
|
||||
if (code !== 100 && code !== 0) {
|
||||
throw new BadRequestException(message ?? PaymentMessage.ZARINPAL_VERIFY_ERROR);
|
||||
}
|
||||
|
||||
return {
|
||||
success: code === 100,
|
||||
referenceId: ref_id ? ref_id.toString() : '',
|
||||
cardPan: card_pan,
|
||||
raw: res.data,
|
||||
};
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error) && error.response) {
|
||||
this.logger.error('Zarinpal verify failed', error.response.data);
|
||||
throw new BadRequestException(error.response.data);
|
||||
}
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
throw new BadRequestException(PaymentMessage.GATEWAY_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
getPaymentUrl(authority: string): string {
|
||||
return `${this.zarinpalPaymentBaseUrl}/pg/StartPay/${authority}`;
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
export interface IPaymentGateway {
|
||||
requestPayment(requestPaymentParams: IRequestPaymentParams): Promise<IRequestPaymentData>;
|
||||
verifyPayment(verifyPaymentParam: IPaymentVerifyParams): Promise<IPaymentVerifyData>;
|
||||
getPaymentUrl(authority: string): string;
|
||||
}
|
||||
|
||||
export interface IRequestPaymentParams {
|
||||
amount: number;
|
||||
merchantId: string;
|
||||
domain: string;
|
||||
orderId: string;
|
||||
}
|
||||
|
||||
export interface IRequestPaymentData {
|
||||
transactionId: string;
|
||||
}
|
||||
|
||||
export interface IPaymentVerifyParams {
|
||||
amount: number;
|
||||
merchantId: string;
|
||||
transactionId: string;
|
||||
}
|
||||
|
||||
export interface IPaymentVerifyData {
|
||||
success: boolean;
|
||||
referenceId: string;
|
||||
cardPan?: string;
|
||||
raw: Record<string, any>;
|
||||
}
|
||||
////////////////////////// Zarinpal API v4 //////////////////////////
|
||||
export interface IZarinpalRequestPayment {
|
||||
amount: number;
|
||||
merchant_id: string;
|
||||
description: string;
|
||||
callback_url: string;
|
||||
metadata?: {
|
||||
order_id?: string;
|
||||
mobile?: string;
|
||||
email?: string;
|
||||
};
|
||||
referrer_id?: string;
|
||||
currency: 'IRT';
|
||||
}
|
||||
export interface IZarinpalPaymentResponse {
|
||||
data: {
|
||||
code: number;
|
||||
message: string;
|
||||
authority: string;
|
||||
fee_type: string;
|
||||
fee: number;
|
||||
};
|
||||
errors: unknown[];
|
||||
}
|
||||
export interface IZarinpalVerifyRequest {
|
||||
merchant_id: string;
|
||||
amount: number;
|
||||
authority: string;
|
||||
}
|
||||
export interface IZarinpalVerifyResponse {
|
||||
data: {
|
||||
code: number;
|
||||
message: string;
|
||||
card_hash: string;
|
||||
card_pan: string;
|
||||
ref_id: number;
|
||||
fee_type: string;
|
||||
fee: number;
|
||||
};
|
||||
errors: unknown[];
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import type { Order } from 'src/modules/orders/entities/order.entity';
|
||||
|
||||
export enum PaymentMethodEnum {
|
||||
Online = 'Online',
|
||||
Cash = 'Cash',
|
||||
Wallet = 'Wallet',
|
||||
}
|
||||
export enum PaymentStatusEnum {
|
||||
Pending = 'pending',
|
||||
Paid = 'paid',
|
||||
Failed = 'failed',
|
||||
Refunded = 'refunded',
|
||||
}
|
||||
export enum PaymentGatewayEnum {
|
||||
ZarinPal = 'zarinpal',
|
||||
}
|
||||
|
||||
export interface ICreatePayment {
|
||||
orderId: string;
|
||||
amount: number;
|
||||
method: PaymentMethodEnum;
|
||||
transactionId: string;
|
||||
gateway?: PaymentGatewayEnum | null;
|
||||
}
|
||||
|
||||
export interface OrderPaymentContext {
|
||||
order: Order;
|
||||
amount: number;
|
||||
method: PaymentMethodEnum;
|
||||
gateway: PaymentGatewayEnum | null;
|
||||
merchantId: string | null;
|
||||
restaurantDomain: string | null;
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { AdminRepository } from 'src/modules/admin/repositories/admin.repository';
|
||||
import { onlinePaymentSucceedEvent, paymentSucceedEvent } from '../events/payment.events';
|
||||
import { Permission } from 'src/common/enums/permission.enum';
|
||||
import { NotifTitleEnum } from 'src/modules/notifications/interfaces/notification.interface';
|
||||
import { NotificationService } from 'src/modules/notifications/services/notification.service';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { OrdersService } from 'src/modules/orders/providers/orders.service';
|
||||
|
||||
@Injectable()
|
||||
export class PaymentListeners {
|
||||
private readonly logger = new Logger(PaymentListeners.name);
|
||||
private orderCreatedSmsTemplateId: string;
|
||||
private paymentSucceedSmsTemplateId: string;
|
||||
constructor(
|
||||
private readonly adminService: AdminRepository,
|
||||
private readonly notificationService: NotificationService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly orderService: OrdersService,
|
||||
) {
|
||||
this.orderCreatedSmsTemplateId = this.configService.get<string>('SMS_PATTERN_ORDER_CREATED') ?? '123';
|
||||
this.paymentSucceedSmsTemplateId = this.configService.get<string>('SMS_PATTERN_ONLINE_PAYMENT_SUCCEED') ?? '123';
|
||||
}
|
||||
|
||||
private getPaymentMethodFarsi(method: string): string {
|
||||
const methodMap: Record<string, string> = {
|
||||
Cash: 'نقدی',
|
||||
Wallet: 'کیف پول',
|
||||
Online: 'آنلاین',
|
||||
};
|
||||
return methodMap[method] || method;
|
||||
}
|
||||
|
||||
@OnEvent(paymentSucceedEvent.name)
|
||||
async handlePaymentSucceed(event: paymentSucceedEvent) {
|
||||
try {
|
||||
this.logger.log(
|
||||
`Payment paid event received: ${event.orderId} for restaurant: ${event.restaurantId} and order number: ${event.orderNumber}`,
|
||||
);
|
||||
// get admnin os restuaraant that have order permissuins
|
||||
const admins = await this.adminService.findAdminsWithPermission(event.restaurantId, Permission.MANAGE_ORDERS);
|
||||
// const order=await
|
||||
const recipients = admins.map(admin => ({
|
||||
adminId: admin.id,
|
||||
}));
|
||||
await this.notificationService.sendNotification({
|
||||
restaurantId: event.restaurantId,
|
||||
message: {
|
||||
title: NotifTitleEnum.PAYMENT_SUCCESS,
|
||||
content: `پرداخت سفارش شماره ${event.orderNumber} با روش ${this.getPaymentMethodFarsi(event.paymentMethod)} و مبلغ ${event.total} تومان با موفقیت انجام شد`,
|
||||
sms: {
|
||||
templateId: this.orderCreatedSmsTemplateId,
|
||||
parameters: {
|
||||
orderNumber: event.orderNumber,
|
||||
|
||||
},
|
||||
},
|
||||
pushNotif: {
|
||||
title: `پرداخت موفق`,
|
||||
content: `پرداخت سفارش شماره ${event.orderNumber} با روش ${this.getPaymentMethodFarsi(event.paymentMethod)} و مبلغ ${event.total} تومان با موفقیت انجام شد`,
|
||||
icon: ``,
|
||||
action: {
|
||||
type: NotifTitleEnum.PAYMENT_SUCCESS,
|
||||
url: `/`,
|
||||
},
|
||||
},
|
||||
},
|
||||
recipients,
|
||||
metadata: {
|
||||
priority: 1,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to send notification for order created event: ${event.restaurantId}`,
|
||||
error instanceof Error ? error.stack : String(error),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@OnEvent(onlinePaymentSucceedEvent.name)
|
||||
async handleOnlinePaymentSucceed(event: onlinePaymentSucceedEvent) {
|
||||
try {
|
||||
|
||||
this.logger.log(
|
||||
`Online payment succeed event received: ${event.paymentId} for restaurant: ${event.restaurantId} and order number: ${event.orderNumber}`,
|
||||
);
|
||||
const admins = await this.adminService.findAdminsWithPermission(event.restaurantId, Permission.MANAGE_ORDERS);
|
||||
const recipients = admins.map(admin => ({
|
||||
adminId: admin.id,
|
||||
}));
|
||||
|
||||
// admin notifs
|
||||
await this.notificationService.sendNotification({
|
||||
restaurantId: event.restaurantId,
|
||||
message: {
|
||||
title: NotifTitleEnum.ORDER_CREATED,
|
||||
content: `سفارش شماره ${event.orderNumber} با مبلغ ${event.total} تومان با موفقیت ایجاد شد`,
|
||||
sms: {
|
||||
templateId: this.orderCreatedSmsTemplateId,
|
||||
parameters: {
|
||||
orderNumber: event.orderNumber,
|
||||
total: event.total.toString(),
|
||||
},
|
||||
},
|
||||
pushNotif: {
|
||||
title: `سفارش جدید`,
|
||||
content: `سفارش شماره ${event.orderNumber} با مبلغ ${event.total} تومان با موفقیت ایجاد شد`,
|
||||
icon: `/`,
|
||||
action: {
|
||||
type: NotifTitleEnum.ORDER_CREATED,
|
||||
url: `/`,
|
||||
},
|
||||
},
|
||||
},
|
||||
recipients,
|
||||
metadata: {
|
||||
priority: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const order = await this.orderService.findOne(event.orderId, event.restaurantId);
|
||||
if (!order) {
|
||||
this.logger.error(
|
||||
`Order not found: ${event.orderId} for restaurant: ${event.restaurantId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const userRecipients = [
|
||||
{
|
||||
userId: order.user.id,
|
||||
},
|
||||
];
|
||||
// user notif
|
||||
await this.notificationService.sendNotification({
|
||||
restaurantId: event.restaurantId,
|
||||
message: {
|
||||
title: NotifTitleEnum.PAYMENT_SUCCESS,
|
||||
content: `پرداخت سفارش شماره ${order.orderNumber} با روش ${this.getPaymentMethodFarsi(order.paymentMethod.method)} و مبلغ ${order.total} تومان با موفقیت انجام شد`,
|
||||
sms: {
|
||||
templateId: this.paymentSucceedSmsTemplateId,
|
||||
parameters: {
|
||||
orderNumber: event.orderNumber,
|
||||
total: event.total.toString(),
|
||||
},
|
||||
},
|
||||
pushNotif: {
|
||||
title: `پرداخت موفق`,
|
||||
content: `پرداخت سفارش شماره ${order.orderNumber} با روش ${this.getPaymentMethodFarsi(order.paymentMethod.method)} و مبلغ ${order.total} تومان با موفقیت انجام شد`,
|
||||
icon: `/`,
|
||||
action: {
|
||||
type: NotifTitleEnum.PAYMENT_SUCCESS,
|
||||
url: `/`,
|
||||
},
|
||||
},
|
||||
},
|
||||
recipients: userRecipients,
|
||||
metadata: {
|
||||
priority: 1,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to send notification for online payment succeed event: ${event.restaurantId}`,
|
||||
error instanceof Error ? error.stack : String(error),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { PaymentsService } from './services/payments.service';
|
||||
import { MikroOrmModule } from '@mikro-orm/nestjs';
|
||||
import { PaymentMethod } from './entities/payment-method.entity';
|
||||
import { PaymentMethodRepository } from './repositories/payment-method.repository';
|
||||
import { PaymentMethodService } from './services/payment-method.service';
|
||||
import { Restaurant } from '../restaurants/entities/restaurant.entity';
|
||||
import { PaymentsController } from './controllers/payments.controller';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { Payment } from './entities/payment.entity';
|
||||
import { ZarinpalGateway } from './gateways/zarinpal.gateway';
|
||||
import { GatewayManager } from './gateways/gateway.manager';
|
||||
import { PaymentRepository } from './repositories/payment.repository';
|
||||
import { InventoryModule } from '../inventory/inventory.module';
|
||||
import { PaymentListeners } from './listeners/payment.listeners';
|
||||
import { AdminModule } from '../admin/admin.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { OrdersModule } from '../orders/orders.module';
|
||||
|
||||
@Module({
|
||||
imports: [MikroOrmModule.forFeature([PaymentMethod, Payment, Restaurant]),
|
||||
AuthModule, JwtModule, InventoryModule, AdminModule, NotificationsModule,
|
||||
forwardRef(() => OrdersModule)],
|
||||
controllers: [PaymentsController],
|
||||
providers: [PaymentsService, PaymentMethodService, PaymentMethodRepository,
|
||||
PaymentRepository, ZarinpalGateway, GatewayManager, PaymentListeners],
|
||||
exports: [
|
||||
PaymentMethodRepository,
|
||||
PaymentRepository,
|
||||
PaymentMethodService,
|
||||
PaymentsService,
|
||||
// PaymentGatewayService,
|
||||
],
|
||||
})
|
||||
export class PaymentsModule { }
|
||||
@@ -1,10 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { EntityManager, EntityRepository } from '@mikro-orm/postgresql';
|
||||
import { PaymentMethod } from '../entities/payment-method.entity';
|
||||
|
||||
@Injectable()
|
||||
export class PaymentMethodRepository extends EntityRepository<PaymentMethod> {
|
||||
constructor(readonly em: EntityManager) {
|
||||
super(em, PaymentMethod);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { EntityManager, EntityRepository } from '@mikro-orm/postgresql';
|
||||
import { Payment } from '../entities/payment.entity';
|
||||
|
||||
@Injectable()
|
||||
export class PaymentRepository extends EntityRepository<Payment> {
|
||||
constructor(readonly em: EntityManager) {
|
||||
super(em, Payment);
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import { EntityManager } from '@mikro-orm/postgresql';
|
||||
import { PaymentMethod } from '../entities/payment-method.entity';
|
||||
import { PaymentMethodRepository } from '../repositories/payment-method.repository';
|
||||
import { CreatePaymentMethodDto } from '../dto/create-payment-method.dto';
|
||||
import { UpdatePaymentMethodDto } from '../dto/update-payment-method.dto';
|
||||
import { RequiredEntityData } from '@mikro-orm/core';
|
||||
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
|
||||
import { RestMessage, PaymentMessage } from 'src/common/enums/message.enum';
|
||||
|
||||
@Injectable()
|
||||
export class PaymentMethodService {
|
||||
constructor(
|
||||
private readonly paymentMethodRepository: PaymentMethodRepository,
|
||||
private readonly em: EntityManager,
|
||||
) {}
|
||||
|
||||
async create(restId: string, createPaymentMethodDto: CreatePaymentMethodDto): Promise<PaymentMethod> {
|
||||
// Check if restaurant exists
|
||||
const restaurant = await this.em.findOne(Restaurant, { id: restId });
|
||||
if (!restaurant) {
|
||||
throw new NotFoundException(RestMessage.NOT_FOUND);
|
||||
}
|
||||
|
||||
const paymentMethod = this.paymentMethodRepository.create({
|
||||
restaurant,
|
||||
...createPaymentMethodDto,
|
||||
} as unknown as RequiredEntityData<PaymentMethod>);
|
||||
await this.em.persistAndFlush(paymentMethod);
|
||||
return paymentMethod;
|
||||
}
|
||||
|
||||
async findAll(): Promise<PaymentMethod[]> {
|
||||
return this.paymentMethodRepository.findAll({ populate: ['restaurant'] });
|
||||
}
|
||||
|
||||
async findOne(id: string): Promise<PaymentMethod> {
|
||||
const paymentMethod = await this.paymentMethodRepository.findOne({ id }, { populate: ['restaurant'] });
|
||||
if (!paymentMethod) {
|
||||
throw new NotFoundException(PaymentMessage.PAYMENT_METHOD_NOT_FOUND);
|
||||
}
|
||||
return paymentMethod;
|
||||
}
|
||||
|
||||
async findByRestaurant(restaurantId: string): Promise<PaymentMethod[]> {
|
||||
return this.paymentMethodRepository.find({ restaurant: { id: restaurantId } }, { populate: ['restaurant'] });
|
||||
}
|
||||
|
||||
async update(id: string, updatePaymentMethodDto: UpdatePaymentMethodDto): Promise<PaymentMethod> {
|
||||
const paymentMethod = await this.findOne(id);
|
||||
this.em.assign(paymentMethod, updatePaymentMethodDto);
|
||||
await this.em.flush();
|
||||
return paymentMethod;
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<PaymentMethod> {
|
||||
const paymentMethod = await this.findOne(id);
|
||||
await this.em.removeAndFlush(paymentMethod);
|
||||
return paymentMethod;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -1,442 +0,0 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PaymentGatewayEnum, PaymentMethodEnum, PaymentStatusEnum } from '../interface/payment';
|
||||
import { Payment } from '../entities/payment.entity';
|
||||
import { EntityManager } from '@mikro-orm/postgresql';
|
||||
import { Order } from '../../orders/entities/order.entity';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { GatewayManager } from '../gateways/gateway.manager';
|
||||
import { WalletTransaction } from 'src/modules/users/entities/wallet-transaction.entity';
|
||||
import { OrderPaymentContext } from '../interface/payment';
|
||||
import { OrderStatus } from 'src/modules/orders/interface/order.interface';
|
||||
import { ChartPeriodEnum, PaymentChartDto } from '../dto/payment-chart.dto';
|
||||
import { PaymentMessage, OrderMessage } from 'src/common/enums/message.enum';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { onlinePaymentSucceedEvent, paymentSucceedEvent } from '../events/payment.events';
|
||||
import { WalletTransactionReason, WalletTransactionType } from 'src/modules/users/interface/wallet';
|
||||
|
||||
@Injectable()
|
||||
export class PaymentsService {
|
||||
private readonly logger = new Logger(PaymentsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly em: EntityManager,
|
||||
private readonly gatewayManager: GatewayManager,
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
) { }
|
||||
|
||||
async payOrder(orderId: string): Promise<{ paymentUrl: string | null }> {
|
||||
const ctx = await this.loadAndValidateOrder(orderId);
|
||||
|
||||
// Idempotency: avoid creating/charging again for already-paid orders
|
||||
if (ctx.order.status === OrderStatus.PAID) {
|
||||
return { paymentUrl: null };
|
||||
}
|
||||
|
||||
switch (ctx.method) {
|
||||
case PaymentMethodEnum.Cash:
|
||||
await this.handleCashPayment(ctx);
|
||||
return { paymentUrl: null };
|
||||
|
||||
case PaymentMethodEnum.Wallet:
|
||||
await this.handleWalletPayment(ctx);
|
||||
return { paymentUrl: null };
|
||||
|
||||
case PaymentMethodEnum.Online:
|
||||
return this.handleOnlinePayment(ctx);
|
||||
|
||||
default:
|
||||
throw new BadRequestException(PaymentMessage.UNSUPPORTED_PAYMENT_METHOD);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadAndValidateOrder(orderId: string): Promise<OrderPaymentContext> {
|
||||
const order = await this.em.findOne(
|
||||
Order,
|
||||
{ id: orderId },
|
||||
{ populate: ['user', 'restaurant', 'paymentMethod', 'paymentMethod.restaurant'] },
|
||||
);
|
||||
|
||||
if (!order) {
|
||||
throw new NotFoundException(OrderMessage.NOT_FOUND);
|
||||
}
|
||||
|
||||
if (order.total <= 0) {
|
||||
throw new BadRequestException(PaymentMessage.AMOUNT_MUST_BE_GREATER_THAN_ZERO);
|
||||
}
|
||||
|
||||
const pm = order.paymentMethod;
|
||||
if (!pm) {
|
||||
throw new NotFoundException(PaymentMessage.PAYMENT_METHOD_NOT_FOUND);
|
||||
}
|
||||
|
||||
if (pm.method === PaymentMethodEnum.Online) {
|
||||
if (!pm.gateway) throw new BadRequestException(PaymentMessage.GATEWAY_REQUIRED);
|
||||
if (!pm.merchantId) throw new BadRequestException(PaymentMessage.MERCHANT_ID_REQUIRED);
|
||||
if (!pm.restaurant?.domain) throw new BadRequestException(PaymentMessage.RESTAURANT_DOMAIN_REQUIRED);
|
||||
}
|
||||
|
||||
return {
|
||||
order,
|
||||
amount: order.total,
|
||||
method: pm.method,
|
||||
gateway: pm.gateway ?? null,
|
||||
merchantId: pm.merchantId ?? null,
|
||||
restaurantDomain: pm.restaurant.domain ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
private async handleCashPayment(ctx: OrderPaymentContext): Promise<void> {
|
||||
await this.getOrCreateLatestPendingPayment(ctx.order.id, {
|
||||
amount: ctx.amount,
|
||||
method: PaymentMethodEnum.Cash,
|
||||
gateway: null,
|
||||
});
|
||||
}
|
||||
|
||||
// TODO clean this code and refactor it
|
||||
private async handleWalletPayment(ctx: OrderPaymentContext): Promise<void> {
|
||||
await this.em.transactional(async em => {
|
||||
const order = await em.findOne(Order, { id: ctx.order.id }, { populate: ['user', 'restaurant'] });
|
||||
if (!order) throw new NotFoundException(OrderMessage.NOT_FOUND);
|
||||
|
||||
if (order.status === OrderStatus.PAID) {
|
||||
return;
|
||||
}
|
||||
|
||||
const walletTransaction = await em.findOne(WalletTransaction, {
|
||||
user: { id: order.user.id },
|
||||
restaurant: { id: order.restaurant.id },
|
||||
}, {
|
||||
orderBy: { createdAt: 'DESC' }
|
||||
});
|
||||
|
||||
if (!walletTransaction) {
|
||||
throw new NotFoundException('User wallet not found');
|
||||
}
|
||||
|
||||
if (walletTransaction.balance < ctx.amount) {
|
||||
throw new BadRequestException('Insufficient wallet balance');
|
||||
}
|
||||
|
||||
const newBalance = walletTransaction.balance - ctx.amount;
|
||||
|
||||
|
||||
const payment = await this.getOrCreateLatestPendingPayment(order.id, {
|
||||
em,
|
||||
amount: ctx.amount,
|
||||
method: PaymentMethodEnum.Wallet,
|
||||
gateway: null,
|
||||
});
|
||||
|
||||
const newWalletTransaction = em.create(WalletTransaction, {
|
||||
user: order.user,
|
||||
restaurant: order.restaurant,
|
||||
amount: ctx.amount,
|
||||
type: WalletTransactionType.DEBIT,
|
||||
reason: WalletTransactionReason.ORDER_PAYMENT,
|
||||
balance: newBalance,
|
||||
});
|
||||
|
||||
payment.status = PaymentStatusEnum.Paid;
|
||||
payment.paidAt = new Date();
|
||||
order.status = OrderStatus.PAID;
|
||||
|
||||
em.persist([ payment, order, newWalletTransaction]);
|
||||
await em.flush();
|
||||
});
|
||||
this.eventEmitter.emit(
|
||||
paymentSucceedEvent.name,
|
||||
new paymentSucceedEvent(ctx.order.id, ctx.order.restaurant.id, String(ctx.order?.orderNumber) || '', ctx.method, ctx.amount),
|
||||
);
|
||||
}
|
||||
|
||||
private async handleOnlinePayment(ctx: OrderPaymentContext): Promise<{ paymentUrl: string }> {
|
||||
const gateway = this.gatewayManager.get(ctx.gateway!);
|
||||
|
||||
const payment = await this.getOrCreateLatestPendingPayment(ctx.order.id, {
|
||||
amount: ctx.amount,
|
||||
method: PaymentMethodEnum.Online,
|
||||
gateway: ctx.gateway!,
|
||||
});
|
||||
|
||||
// If we already requested a gateway transaction, just return the same URL (idempotent)
|
||||
if (payment.transactionId) {
|
||||
return { paymentUrl: gateway.getPaymentUrl(payment.transactionId) };
|
||||
}
|
||||
|
||||
const { transactionId } = await gateway.requestPayment({
|
||||
amount: ctx.amount,
|
||||
orderId: ctx.order.id,
|
||||
merchantId: ctx.merchantId!,
|
||||
domain: ctx.restaurantDomain!,
|
||||
});
|
||||
|
||||
payment.gateway = ctx.gateway;
|
||||
payment.transactionId = transactionId;
|
||||
payment.status = PaymentStatusEnum.Pending;
|
||||
|
||||
await this.em.persistAndFlush(payment);
|
||||
|
||||
return {
|
||||
paymentUrl: gateway.getPaymentUrl(transactionId),
|
||||
};
|
||||
}
|
||||
|
||||
async verifyOnlinePayment(transactionId: string, orderId: string): Promise<Payment> {
|
||||
const payment = await this.em.transactional(async em => {
|
||||
const payment = await em.findOne(
|
||||
Payment,
|
||||
{ transactionId, order: { id: orderId } },
|
||||
{ populate: ['order', 'order.paymentMethod'] },
|
||||
);
|
||||
|
||||
if (!payment) {
|
||||
throw new NotFoundException(PaymentMessage.PAYMENT_NOT_FOUND);
|
||||
}
|
||||
|
||||
if (payment.status === PaymentStatusEnum.Paid) {
|
||||
return payment;
|
||||
}
|
||||
|
||||
const pm = payment.order.paymentMethod;
|
||||
if (!pm?.merchantId || !payment.gateway) {
|
||||
throw new BadRequestException(PaymentMessage.INVALID_PAYMENT_CONFIGURATION);
|
||||
}
|
||||
|
||||
const gateway = this.gatewayManager.get(payment.gateway);
|
||||
|
||||
const result = await gateway.verifyPayment({
|
||||
merchantId: pm.merchantId,
|
||||
amount: payment.amount,
|
||||
transactionId: payment.transactionId!,
|
||||
});
|
||||
|
||||
payment.verifyResponse = result.raw;
|
||||
|
||||
if (!result.success) {
|
||||
this.failPayment(payment);
|
||||
return payment;
|
||||
}
|
||||
|
||||
this.markPaid(payment, result.referenceId);
|
||||
if (payment.order.status === OrderStatus.PENDING_PAYMENT) {
|
||||
payment.order.status = OrderStatus.PAID;
|
||||
}
|
||||
|
||||
await em.flush();
|
||||
return payment;
|
||||
});
|
||||
this.eventEmitter.emit(
|
||||
onlinePaymentSucceedEvent.name,
|
||||
new onlinePaymentSucceedEvent(payment.id, payment.order.id, payment.order.restaurant.id, String(payment.order?.orderNumber) || '', payment.amount),
|
||||
);
|
||||
return payment;
|
||||
}
|
||||
|
||||
findAllPaymentsByRestaurantId(restId: string, userId: string) {
|
||||
return this.em.find(
|
||||
Payment,
|
||||
{ order: { restaurant: { id: restId }, user: { id: userId } } },
|
||||
{ populate: ['order', 'order.paymentMethod'] },
|
||||
);
|
||||
}
|
||||
|
||||
async verifyCashPayment(id: string): Promise<Payment> {
|
||||
const payment = await this.em.transactional(async em => {
|
||||
const payment = await em.findOne(Payment, id, { populate: ['order'] });
|
||||
if (!payment) {
|
||||
throw new NotFoundException(PaymentMessage.PAYMENT_NOT_FOUND);
|
||||
}
|
||||
if (payment.method !== PaymentMethodEnum.Cash) {
|
||||
throw new BadRequestException(PaymentMessage.PAYMENT_METHOD_NOT_CASH);
|
||||
}
|
||||
if (payment.status === PaymentStatusEnum.Paid) {
|
||||
throw new BadRequestException(PaymentMessage.PAYMENT_ALREADY_PAID);
|
||||
}
|
||||
if (payment.order.status === OrderStatus.PENDING_PAYMENT) {
|
||||
payment.order.status = OrderStatus.PAID;
|
||||
}
|
||||
payment.status = PaymentStatusEnum.Paid;
|
||||
payment.paidAt = new Date();
|
||||
em.persist(payment);
|
||||
em.persist(payment.order);
|
||||
await em.flush();
|
||||
return payment;
|
||||
});
|
||||
return payment;
|
||||
}
|
||||
|
||||
private markPaid(payment: Payment, referenceId: string) {
|
||||
payment.status = PaymentStatusEnum.Paid;
|
||||
payment.paidAt = new Date();
|
||||
payment.referenceId = referenceId;
|
||||
}
|
||||
|
||||
private failPayment(payment: Payment) {
|
||||
payment.status = PaymentStatusEnum.Failed;
|
||||
payment.failedAt = new Date();
|
||||
payment.order.status = OrderStatus.CANCELED;
|
||||
}
|
||||
|
||||
|
||||
private async getOrCreateLatestPendingPayment(
|
||||
orderId: string,
|
||||
params: {
|
||||
amount: number;
|
||||
method: PaymentMethodEnum;
|
||||
gateway: PaymentGatewayEnum | null;
|
||||
em?: EntityManager;
|
||||
},
|
||||
): Promise<Payment> {
|
||||
const em = params.em ?? this.em;
|
||||
|
||||
const existing = await em.findOne(
|
||||
Payment,
|
||||
{ order: { id: orderId }, status: PaymentStatusEnum.Pending },
|
||||
{ orderBy: { createdAt: 'DESC' } },
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
if (existing.method !== params.method) {
|
||||
throw new BadRequestException(PaymentMessage.EXISTING_PENDING_PAYMENT_MISMATCH);
|
||||
}
|
||||
|
||||
return existing;
|
||||
}
|
||||
|
||||
const orderRef = em.getReference(Order, orderId);
|
||||
const payment = em.create(Payment, {
|
||||
amount: params.amount,
|
||||
order: orderRef,
|
||||
method: params.method,
|
||||
gateway: params.gateway,
|
||||
transactionId: null,
|
||||
status: PaymentStatusEnum.Pending,
|
||||
});
|
||||
|
||||
em.persist(payment);
|
||||
await em.flush();
|
||||
return payment;
|
||||
}
|
||||
|
||||
async getChartData(dto: PaymentChartDto, restaurantId: string): Promise<Array<{ date: string; cash: number; online: number }>> {
|
||||
const { startDate, endDate, type = ChartPeriodEnum.Daily } = dto;
|
||||
|
||||
const start = startDate ? new Date(startDate) : new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
const end = endDate ? new Date(endDate) : new Date();
|
||||
|
||||
const startOfDay = new Date(start);
|
||||
startOfDay.setHours(0, 0, 0, 0);
|
||||
|
||||
const endOfDay = new Date(end);
|
||||
endOfDay.setHours(23, 59, 59, 999);
|
||||
|
||||
// 1. Map your Enum to valid Postgres date_trunc units
|
||||
// 'Daily' is not valid in PG, it must be 'day'
|
||||
const pgPeriod = {
|
||||
[ChartPeriodEnum.Daily]: 'day',
|
||||
[ChartPeriodEnum.Weekly]: 'week',
|
||||
[ChartPeriodEnum.Monthly]: 'month',
|
||||
}[type] || 'day';
|
||||
|
||||
const params: any[] = [startOfDay, endOfDay];
|
||||
let restaurantFilter = '';
|
||||
|
||||
if (restaurantId) {
|
||||
params.push(restaurantId);
|
||||
restaurantFilter = `AND o.restaurant_id = ?`;
|
||||
}
|
||||
|
||||
// MikroORM uses ? placeholders for parameter binding
|
||||
// Ensure paid_at is not NULL and handle timestamp casting properly
|
||||
const query = `
|
||||
SELECT
|
||||
TO_CHAR(DATE_TRUNC('${pgPeriod}', CAST(p.paid_at AS timestamp)), 'YYYY-MM-DD') as "date",
|
||||
COALESCE(SUM(CASE WHEN p.method = '${PaymentMethodEnum.Cash}' THEN p.amount ELSE 0 END), 0)::numeric as cash,
|
||||
COALESCE(SUM(CASE WHEN p.method = '${PaymentMethodEnum.Online}' THEN p.amount ELSE 0 END), 0)::numeric as online
|
||||
FROM payments p
|
||||
INNER JOIN orders o ON p.order_id = o.id
|
||||
WHERE p.status = '${PaymentStatusEnum.Paid}'
|
||||
AND p.paid_at IS NOT NULL
|
||||
AND p.paid_at >= ?
|
||||
AND p.paid_at <= ?
|
||||
${restaurantFilter}
|
||||
GROUP BY DATE_TRUNC('${pgPeriod}', CAST(p.paid_at AS timestamp))
|
||||
ORDER BY DATE_TRUNC('${pgPeriod}', CAST(p.paid_at AS timestamp)) ASC
|
||||
`;
|
||||
|
||||
this.logger.debug(`Chart query params: startOfDay=${startOfDay.toISOString()}, endOfDay=${endOfDay.toISOString()}, restaurantId=${restaurantId}`);
|
||||
const result = await this.em.execute(query, params);
|
||||
this.logger.debug(`Chart query returned ${result.length} rows`);
|
||||
|
||||
const dataMap = new Map<string, { cash: number; online: number }>();
|
||||
result.forEach((row: any) => {
|
||||
dataMap.set(row.date, {
|
||||
cash: Number(row.cash),
|
||||
online: Number(row.online),
|
||||
});
|
||||
});
|
||||
|
||||
const allDates = this.generateDateRange(startOfDay, endOfDay, type);
|
||||
|
||||
return allDates.map(date => ({
|
||||
date,
|
||||
cash: dataMap.get(date)?.cash ?? 0,
|
||||
online: dataMap.get(date)?.online ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
private generateDateRange(start: Date, end: Date, type: ChartPeriodEnum): string[] {
|
||||
const dates: string[] = [];
|
||||
const current = new Date(start);
|
||||
const endDate = new Date(end);
|
||||
|
||||
// Normalize start date based on period type
|
||||
switch (type) {
|
||||
case ChartPeriodEnum.Weekly:
|
||||
// Start of week (Monday) - PostgreSQL DATE_TRUNC('week') uses ISO week (Monday as first day)
|
||||
const dayOfWeek = current.getDay();
|
||||
const diff = current.getDate() - dayOfWeek + (dayOfWeek === 0 ? -6 : 1);
|
||||
current.setDate(diff);
|
||||
current.setHours(0, 0, 0, 0);
|
||||
// Also normalize end date to include the week containing it
|
||||
const endDayOfWeek = endDate.getDay();
|
||||
const endDiff = endDate.getDate() - endDayOfWeek + (endDayOfWeek === 0 ? -6 : 1);
|
||||
endDate.setDate(endDiff);
|
||||
endDate.setHours(23, 59, 59, 999);
|
||||
break;
|
||||
case ChartPeriodEnum.Monthly:
|
||||
// Start of month
|
||||
current.setDate(1);
|
||||
current.setHours(0, 0, 0, 0);
|
||||
// Also normalize end date to include the month containing it
|
||||
endDate.setDate(1);
|
||||
endDate.setHours(23, 59, 59, 999);
|
||||
break;
|
||||
case ChartPeriodEnum.Daily:
|
||||
default:
|
||||
current.setHours(0, 0, 0, 0);
|
||||
endDate.setHours(23, 59, 59, 999);
|
||||
}
|
||||
|
||||
while (current <= endDate) {
|
||||
const dateStr = current.toISOString().split('T')[0];
|
||||
dates.push(dateStr);
|
||||
|
||||
// Increment based on period type
|
||||
switch (type) {
|
||||
case ChartPeriodEnum.Weekly:
|
||||
current.setDate(current.getDate() + 7);
|
||||
break;
|
||||
case ChartPeriodEnum.Monthly:
|
||||
current.setMonth(current.getMonth() + 1);
|
||||
break;
|
||||
case ChartPeriodEnum.Daily:
|
||||
default:
|
||||
current.setDate(current.getDate() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return dates;
|
||||
}
|
||||
}
|
||||
@@ -16,13 +16,10 @@ import { normalizePhone } from 'src/modules/utils/phone.util';
|
||||
import slugify from 'slugify';
|
||||
import { NotificationPreference } from '../../notifications/entities/notification-preference.entity';
|
||||
import { notificationPreferencesData } from '../../../seeders/data/notification-preferences.data';
|
||||
import { Order } from '../../orders/entities/order.entity';
|
||||
import { Coupon } from '../../coupons/entities/coupon.entity';
|
||||
import { Category } from '../../foods/entities/category.entity';
|
||||
import { Food } from '../../foods/entities/food.entity';
|
||||
import { Delivery } from '../../delivery/entities/delivery.entity';
|
||||
import { PaymentMethod } from '../../payments/entities/payment-method.entity';
|
||||
import { Pager } from '../../pager/entities/pager.entity';
|
||||
import { Schedule } from '../entities/schedule.entity';
|
||||
import { WalletTransaction } from '../../users/entities/wallet-transaction.entity';
|
||||
import { SmsLog } from '../../notifications/entities/smsLogs.entity';
|
||||
@@ -234,9 +231,6 @@ export class RestaurantsService {
|
||||
throw new NotFoundException(RestMessage.NOT_FOUND);
|
||||
}
|
||||
|
||||
// Delete orders first (will cascade to order items and payments due to cascade settings)
|
||||
await em.nativeDelete(Order, { restaurant: id });
|
||||
|
||||
// Delete coupons
|
||||
await em.nativeDelete(Coupon, { restaurant: id });
|
||||
|
||||
@@ -264,11 +258,6 @@ export class RestaurantsService {
|
||||
// Delete admin roles for this restaurant
|
||||
await em.nativeDelete(AdminRole, { restaurant: id });
|
||||
|
||||
// Delete payment methods
|
||||
await em.nativeDelete(PaymentMethod, { restaurant: id });
|
||||
|
||||
// Delete pagers
|
||||
await em.nativeDelete(Pager, { restaurant: id });
|
||||
|
||||
// Delete wallet transactions
|
||||
await em.nativeDelete(WalletTransaction, { restaurant: id });
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
import { Controller, Get, Post, Body, Patch, Param, Delete, Query, UseGuards } from '@nestjs/common';
|
||||
import { ReviewService } from '../providers/review.service';
|
||||
import { CreateReviewDto } from '../dto/create-review.dto';
|
||||
import { UpdateReviewDto } from '../dto/update-review.dto';
|
||||
import { FindReviewsDto, FindRestuarantReviewsDto } from '../dto/find-reviews.dto';
|
||||
import { ChangeStatusDto } from '../dto/change-status.dto';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiCreatedResponse,
|
||||
ApiOkResponse,
|
||||
ApiQuery,
|
||||
ApiBody,
|
||||
ApiParam,
|
||||
ApiBearerAuth,
|
||||
ApiHeader,
|
||||
} from '@nestjs/swagger';
|
||||
import { AuthGuard } from 'src/modules/auth/guards/auth.guard';
|
||||
import { AdminAuthGuard } from 'src/modules/auth/guards/adminAuth.guard';
|
||||
import { UserId } from 'src/common/decorators/user-id.decorator';
|
||||
import { RestId } from 'src/common/decorators/rest-id.decorator';
|
||||
import { ReviewStatus } from '../enums/review-status.enum';
|
||||
import { Permissions } from 'src/common/decorators/permissions.decorator';
|
||||
import { Permission } from 'src/common/enums/permission.enum';
|
||||
import { API_HEADER_SLUG } from 'src/common/constants';
|
||||
|
||||
@ApiTags('review')
|
||||
@Controller()
|
||||
export class ReviewController {
|
||||
constructor(private readonly reviewService: ReviewService) {}
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@Post('public/reviews')
|
||||
@ApiOperation({ summary: 'Create a new review and rating' })
|
||||
@ApiHeader(API_HEADER_SLUG)
|
||||
@ApiCreatedResponse({ description: 'The review has been successfully created.' })
|
||||
@ApiBody({ type: CreateReviewDto })
|
||||
create(@Body() createReviewDto: CreateReviewDto, @UserId() userId: string) {
|
||||
return this.reviewService.create(userId, createReviewDto);
|
||||
}
|
||||
|
||||
@Get('public/reviews/:foodId')
|
||||
@ApiOperation({ summary: 'Get all reviews of food by id(public - only approved)' })
|
||||
@ApiHeader(API_HEADER_SLUG)
|
||||
@ApiOkResponse({ description: 'List of approved reviews' })
|
||||
@ApiParam({ name: 'foodId', required: true, type: String })
|
||||
@ApiQuery({ name: 'page', required: false, type: Number })
|
||||
@ApiQuery({ name: 'limit', required: false, type: Number })
|
||||
@ApiQuery({ name: 'orderBy', required: false, type: String })
|
||||
@ApiQuery({ name: 'order', required: false, enum: ['asc', 'desc'] })
|
||||
findAll(@Query() dto: FindReviewsDto, @Param('foodId') foodId: string) {
|
||||
// Only show approved reviews for public endpoint
|
||||
return this.reviewService.findAll({ ...dto, status: ReviewStatus.APPROVED, foodId });
|
||||
}
|
||||
|
||||
@Get('public/reviews/restuarant/:restuarantSlug')
|
||||
@ApiOperation({ summary: 'Get all reviews of restuarant by slug(public - only approved)' })
|
||||
@ApiHeader(API_HEADER_SLUG)
|
||||
@ApiParam({ name: 'restuarantSlug', required: true, type: String })
|
||||
@ApiQuery({ name: 'page', required: false, type: Number })
|
||||
@ApiQuery({ name: 'limit', required: false, type: Number })
|
||||
@ApiQuery({ name: 'orderBy', required: false, type: String })
|
||||
@ApiQuery({ name: 'order', required: false, enum: ['asc', 'desc'] })
|
||||
findRestuarantAllReviews(@Query() dto: FindRestuarantReviewsDto, @Param('restuarantSlug') restuarantSlug: string) {
|
||||
// Only show approved reviews for public endpoint
|
||||
return this.reviewService.findRestuarantAllReviews({ ...dto, status: ReviewStatus.APPROVED, restuarantSlug });
|
||||
}
|
||||
|
||||
// @Get('public/reviews/:id')
|
||||
// @ApiOperation({ summary: 'Get a review by id' })
|
||||
// @ApiParam({ name: 'id', required: true })
|
||||
// @ApiOkResponse({ description: 'The review' })
|
||||
// @ApiNotFoundResponse({ description: 'Review not found' })
|
||||
// findById(@Param('id') id: string) {
|
||||
// return this.reviewService.findById(id);
|
||||
// }
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@Patch('public/reviews/:reviewId')
|
||||
@ApiOperation({ summary: 'Update a review (own reviews only)' })
|
||||
@ApiHeader(API_HEADER_SLUG)
|
||||
@ApiParam({ name: 'reviewId', required: true })
|
||||
@ApiBody({ type: UpdateReviewDto })
|
||||
@ApiOkResponse({ description: 'The updated review' })
|
||||
update(@Param('reviewId') reviewId: string, @Body() updateReviewDto: UpdateReviewDto, @UserId() userId: string) {
|
||||
return this.reviewService.update(reviewId, userId, updateReviewDto, false);
|
||||
}
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@Delete('public/reviews/:id')
|
||||
@ApiOperation({ summary: 'Delete a review (own reviews only)' })
|
||||
@ApiHeader(API_HEADER_SLUG)
|
||||
@ApiParam({ name: 'id', required: true })
|
||||
remove(@Param('id') id: string, @UserId() userId: string) {
|
||||
return this.reviewService.remove(id, userId, false);
|
||||
}
|
||||
|
||||
/******************** Admin Routes **********************/
|
||||
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@Permissions(Permission.MANAGE_REVIEWS)
|
||||
@Get('admin/reviews')
|
||||
@ApiOperation({ summary: 'Get all reviews (admin - including unapproved)' })
|
||||
@ApiOkResponse({ description: 'List of all reviews' })
|
||||
@ApiQuery({ name: 'page', required: false, type: Number })
|
||||
@ApiQuery({ name: 'limit', required: false, type: Number })
|
||||
@ApiQuery({ name: 'foodId', required: false, type: String })
|
||||
@ApiQuery({ name: 'userId', required: false, type: String })
|
||||
@ApiQuery({ name: 'status', required: false, enum: ReviewStatus })
|
||||
@ApiQuery({ name: 'orderBy', required: false, type: String })
|
||||
@ApiQuery({ name: 'order', required: false, enum: ['asc', 'desc'] })
|
||||
findAllAdmin(@Query() dto: FindReviewsDto, @RestId() restId: string) {
|
||||
return this.reviewService.findAll({ ...dto, restId: restId });
|
||||
}
|
||||
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@Permissions(Permission.MANAGE_REVIEWS)
|
||||
@Patch('admin/reviews/:id')
|
||||
@ApiOperation({ summary: 'Update a review (admin - can change approval status)' })
|
||||
@ApiParam({ name: 'id', required: true })
|
||||
@ApiBody({ type: UpdateReviewDto })
|
||||
updateAdmin(@Param('id') id: string, @Body() updateReviewDto: UpdateReviewDto, @UserId() userId: string) {
|
||||
return this.reviewService.update(id, userId, updateReviewDto, true);
|
||||
}
|
||||
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@Permissions(Permission.MANAGE_REVIEWS)
|
||||
@Get('admin/reviews/:id')
|
||||
@ApiOperation({ summary: 'review detail' })
|
||||
@ApiParam({ name: 'id', required: true })
|
||||
reviewDetail(@Param('id') reviewId: string, @RestId() restaurantId: string) {
|
||||
return this.reviewService.findById(reviewId, restaurantId);
|
||||
}
|
||||
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@Permissions(Permission.MANAGE_REVIEWS)
|
||||
@Patch('admin/reviews/:id/status')
|
||||
@ApiOperation({ summary: 'Change review status (pending, approved, rejected)' })
|
||||
@ApiParam({ name: 'id', required: true })
|
||||
@ApiBody({ type: ChangeStatusDto })
|
||||
changeStatus(
|
||||
@Param('id') reviewId: string,
|
||||
@Body() changeStatusDto: ChangeStatusDto,
|
||||
@RestId() restaurantId: string,
|
||||
) {
|
||||
return this.reviewService.changeStatus(reviewId, changeStatusDto.status, restaurantId);
|
||||
}
|
||||
|
||||
@UseGuards(AdminAuthGuard)
|
||||
@Permissions(Permission.MANAGE_REVIEWS)
|
||||
@ApiBearerAuth()
|
||||
@Delete('admin/reviews/:id')
|
||||
@ApiOperation({ summary: 'Delete a review (admin)' })
|
||||
@ApiParam({ name: 'id', required: true })
|
||||
removeAdmin(@Param('id') id: string, @UserId() userId: string) {
|
||||
return this.reviewService.remove(id, userId, true);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { IsEnum } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ReviewStatus } from '../enums/review-status.enum';
|
||||
|
||||
export class ChangeStatusDto {
|
||||
@IsEnum(ReviewStatus)
|
||||
@ApiProperty({
|
||||
enum: ReviewStatus,
|
||||
description: 'Review status: pending, approved, or rejected',
|
||||
example: ReviewStatus.APPROVED,
|
||||
})
|
||||
status: ReviewStatus;
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { IsNotEmpty, IsNumber, IsOptional, IsString, Max, Min, IsArray, IsEnum } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { PositivePoint, NegativePoint } from '../enums/review-point.enum';
|
||||
|
||||
export class CreateReviewDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@ApiProperty({ description: 'Food ID' })
|
||||
foodId: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@ApiProperty({ description: 'Order ID' })
|
||||
orderId: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(5)
|
||||
@Type(() => Number)
|
||||
@ApiProperty({ description: 'Rating from 1 to 5', example: 5, minimum: 1, maximum: 5 })
|
||||
rating: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@ApiPropertyOptional({ description: 'Comment text', example: 'Very delicious food!' })
|
||||
comment?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsEnum(PositivePoint, { each: true })
|
||||
@ApiPropertyOptional({
|
||||
description: 'Positive points about the food',
|
||||
example: [PositivePoint.GREAT_TASTE, PositivePoint.FAST_DELIVERY],
|
||||
enum: PositivePoint,
|
||||
isArray: true,
|
||||
})
|
||||
positivePoints?: PositivePoint[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsEnum(NegativePoint, { each: true })
|
||||
@ApiPropertyOptional({
|
||||
description: 'Negative points about the food',
|
||||
example: [NegativePoint.SMALL_PORTION],
|
||||
enum: NegativePoint,
|
||||
isArray: true,
|
||||
})
|
||||
negativePoints?: NegativePoint[];
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { IsEnum, IsNumber, IsOptional, IsString } from 'class-validator';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ReviewStatus } from '../enums/review-status.enum';
|
||||
|
||||
export class FindReviewsDto {
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
@ApiPropertyOptional({ description: 'Page number', example: 1 })
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
@ApiPropertyOptional({ description: 'Items per page', example: 10 })
|
||||
limit?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@ApiPropertyOptional({ description: 'Food ID to filter comments' })
|
||||
foodId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@ApiPropertyOptional({ description: 'Restaurant ID to filter comments' })
|
||||
restId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@ApiPropertyOptional({ description: 'User ID to filter comments' })
|
||||
userId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ReviewStatus)
|
||||
@ApiPropertyOptional({ description: 'Filter by review status', enum: ReviewStatus })
|
||||
status?: ReviewStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@ApiPropertyOptional({ description: 'Order by field', example: 'createdAt' })
|
||||
orderBy?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@ApiPropertyOptional({ description: 'Order direction', enum: ['asc', 'desc'], example: 'desc' })
|
||||
order?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export class FindRestuarantReviewsDto extends FindReviewsDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@ApiPropertyOptional({ description: 'Restaurant slug to filter comments' })
|
||||
restuarantSlug?: string;
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { IsNumber, IsOptional, IsString, Max, Min, IsArray, IsEnum } from 'class-validator';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { PositivePoint, NegativePoint } from '../enums/review-point.enum';
|
||||
import { ReviewStatus } from '../enums/review-status.enum';
|
||||
|
||||
export class UpdateReviewDto {
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(5)
|
||||
@Type(() => Number)
|
||||
@ApiPropertyOptional({ description: 'Rating from 1 to 5', minimum: 1, maximum: 5 })
|
||||
rating?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@ApiPropertyOptional({ description: 'Comment text' })
|
||||
comment?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsEnum(PositivePoint, { each: true })
|
||||
@ApiPropertyOptional({
|
||||
description: 'Positive points about the food',
|
||||
enum: PositivePoint,
|
||||
isArray: true,
|
||||
})
|
||||
positivePoints?: PositivePoint[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsEnum(NegativePoint, { each: true })
|
||||
@ApiPropertyOptional({
|
||||
description: 'Negative points about the food',
|
||||
enum: NegativePoint,
|
||||
isArray: true,
|
||||
})
|
||||
negativePoints?: NegativePoint[];
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ReviewStatus)
|
||||
@ApiPropertyOptional({ description: 'Review status (admin only)', enum: ReviewStatus })
|
||||
status?: ReviewStatus;
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { Entity, Index, ManyToOne, Property, Unique, Enum } from '@mikro-orm/core';
|
||||
import { BaseEntity } from '../../../common/entities/base.entity';
|
||||
import { Food } from '../../foods/entities/food.entity';
|
||||
import { User } from '../../users/entities/user.entity';
|
||||
import { Order } from 'src/modules/orders/entities/order.entity';
|
||||
import { PositivePoint, NegativePoint } from '../enums/review-point.enum';
|
||||
import { ReviewStatus } from '../enums/review-status.enum';
|
||||
|
||||
@Entity({ tableName: 'reviews' })
|
||||
@Unique({ properties: ['order', 'food', 'user'] })
|
||||
@Index({ properties: ['food', 'status'] })
|
||||
@Index({ properties: ['user'] })
|
||||
@Index({ properties: ['order'] })
|
||||
export class Review extends BaseEntity {
|
||||
@ManyToOne(() => Order)
|
||||
order: Order;
|
||||
|
||||
@ManyToOne(() => Food)
|
||||
food: Food;
|
||||
|
||||
@ManyToOne(() => User)
|
||||
user: User;
|
||||
|
||||
@Property({ type: 'text', nullable: true })
|
||||
comment?: string;
|
||||
|
||||
@Property({ type: 'int', nullable: false })
|
||||
rating: number = 0;
|
||||
|
||||
@Property({ type: 'json', nullable: true })
|
||||
positivePoints?: PositivePoint[];
|
||||
|
||||
@Property({ type: 'json', nullable: true })
|
||||
negativePoints?: NegativePoint[];
|
||||
|
||||
@Enum(() => ReviewStatus)
|
||||
@Property({ type: 'string', default: ReviewStatus.PENDING })
|
||||
status: ReviewStatus = ReviewStatus.PENDING;
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
export enum PositivePoint {
|
||||
GREAT_TASTE = 'great_taste',
|
||||
FAST_DELIVERY = 'fast_delivery',
|
||||
GOOD_QUALITY = 'good_quality',
|
||||
GOOD_PORTION_SIZE = 'good_portion_size',
|
||||
FRIENDLY_SERVICE = 'friendly_service',
|
||||
}
|
||||
|
||||
export enum NegativePoint {
|
||||
SMALL_PORTION = 'small_portion',
|
||||
SLOW_DELIVERY = 'slow_delivery',
|
||||
POOR_QUALITY = 'poor_quality',
|
||||
OVERPRICED = 'overpriced',
|
||||
UNFRIENDLY_SERVICE = 'unfriendly_service',
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export enum ReviewStatus {
|
||||
PENDING = 'pending',
|
||||
REJECTED = 'rejected',
|
||||
APPROVED = 'approved',
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
export class ReviewCreatedEvent {
|
||||
constructor(
|
||||
public readonly reviewId: string,
|
||||
public readonly restaurantId: string,
|
||||
public readonly userId: string,
|
||||
public readonly foodId: string,
|
||||
public readonly foodName: string,
|
||||
public readonly orderId: string,
|
||||
public readonly orderNumber: string | number,
|
||||
public readonly rating: number,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { ReviewCreatedEvent } from '../events/review.events';
|
||||
import { AdminRepository } from 'src/modules/admin/repositories/admin.repository';
|
||||
import { Permission } from 'src/common/enums/permission.enum';
|
||||
import { NotifTitleEnum } from 'src/modules/notifications/interfaces/notification.interface';
|
||||
import { NotificationService } from 'src/modules/notifications/services/notification.service';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@Injectable()
|
||||
export class ReviewListeners {
|
||||
private readonly logger = new Logger(ReviewListeners.name);
|
||||
private reviewCreatedSmsTemplateId: string;
|
||||
|
||||
constructor(
|
||||
private readonly adminService: AdminRepository,
|
||||
private readonly notificationService: NotificationService,
|
||||
private readonly configService: ConfigService,
|
||||
) {
|
||||
this.reviewCreatedSmsTemplateId = this.configService.get<string>('SMS_PATTERN_REVIEW_CREATED') ?? '123';
|
||||
}
|
||||
|
||||
@OnEvent(ReviewCreatedEvent.name)
|
||||
async handleReviewCreated(event: ReviewCreatedEvent) {
|
||||
try {
|
||||
this.logger.log(
|
||||
`Review created event received: ${event.reviewId} for restaurant: ${event.restaurantId}, food: ${event.foodName}, rating: ${event.rating}`,
|
||||
);
|
||||
|
||||
// Get admins of restaurant that have review management permissions
|
||||
const admins = await this.adminService.findAdminsWithPermission(event.restaurantId, Permission.MANAGE_REVIEWS);
|
||||
const recipients = admins.map(admin => ({
|
||||
adminId: admin.id,
|
||||
}));
|
||||
|
||||
if (recipients.length === 0) {
|
||||
this.logger.warn(`No admins found with MANAGE_REVIEWS permission for restaurant ${event.restaurantId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.notificationService.sendNotification({
|
||||
restaurantId: event.restaurantId,
|
||||
message: {
|
||||
title: NotifTitleEnum.REVIEW_CREATED,
|
||||
content: `نظر جدید برای غذا "${event.foodName}" با امتیاز ${event.rating} از 5 برای سفارش شماره ${event.orderNumber} ثبت شد`,
|
||||
sms: {
|
||||
templateId: this.reviewCreatedSmsTemplateId,
|
||||
parameters: {
|
||||
foodName: event.foodName,
|
||||
rating: event.rating.toString(),
|
||||
orderNumber: String(event.orderNumber),
|
||||
},
|
||||
},
|
||||
pushNotif: {
|
||||
title: `نظر جدید`,
|
||||
content: `نظر جدید برای غذا "${event.foodName}" با امتیاز ${event.rating} ثبت شد`,
|
||||
icon: `/`,
|
||||
action: {
|
||||
type: NotifTitleEnum.REVIEW_CREATED,
|
||||
url: `/`,
|
||||
},
|
||||
},
|
||||
},
|
||||
recipients,
|
||||
metadata: {
|
||||
priority: 1,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to send notification for review created event: ${event.reviewId}`,
|
||||
error instanceof Error ? error.stack : String(error),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { EntityManager } from '@mikro-orm/postgresql';
|
||||
import { FoodRepository } from '../../foods/repositories/food.repository';
|
||||
import { ReviewRepository } from '../repositories/review.repository';
|
||||
import { ReviewStatus } from '../enums/review-status.enum';
|
||||
|
||||
@Injectable()
|
||||
export class FoodRatingCronService {
|
||||
private readonly logger = new Logger(FoodRatingCronService.name);
|
||||
|
||||
constructor(
|
||||
private readonly em: EntityManager,
|
||||
private readonly foodRepository: FoodRepository,
|
||||
private readonly reviewRepository: ReviewRepository,
|
||||
) { }
|
||||
|
||||
/**
|
||||
* Cron job that runs at midnight every day (00:00:00 UTC)
|
||||
* Calculates and updates the average rating for each restaurant food
|
||||
*/
|
||||
@Cron('0 0 * * *', {
|
||||
name: 'calculateFoodRatings',
|
||||
timeZone: 'UTC',
|
||||
})
|
||||
async handleCron() {
|
||||
this.logger.log('Starting daily food rating calculation cron job...');
|
||||
|
||||
try {
|
||||
// Get all foods from all restaurants
|
||||
const foods = await this.foodRepository.find({});
|
||||
|
||||
this.logger.log(`Found ${foods.length} foods to process`);
|
||||
|
||||
let updatedCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
// Process each food item
|
||||
for (const food of foods) {
|
||||
try {
|
||||
// Get all approved reviews for this food that are not deleted
|
||||
const reviews = await this.reviewRepository.find(
|
||||
{
|
||||
food: { id: food.id },
|
||||
status: ReviewStatus.APPROVED,
|
||||
deletedAt: null,
|
||||
},
|
||||
{ fields: ['rating'] },
|
||||
);
|
||||
|
||||
// Calculate average rating
|
||||
let averageRating = 0;
|
||||
if (reviews.length > 0) {
|
||||
const sum = reviews.reduce((acc, review) => acc + review.rating, 0);
|
||||
averageRating = sum / reviews.length;
|
||||
}
|
||||
|
||||
// Update food score
|
||||
food.score = parseFloat(averageRating.toFixed(2));
|
||||
this.em.persist(food);
|
||||
|
||||
updatedCount++;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error processing food ${food.id}: ${error.message}`, error.stack);
|
||||
errorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Flush all updates at once for better performance
|
||||
await this.em.flush();
|
||||
|
||||
this.logger.log(`Food rating calculation completed. Updated: ${updatedCount}, Errors: ${errorCount}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Error in food rating cron job: ${error.message}`, error.stack);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { CreateReviewDto } from '../dto/create-review.dto';
|
||||
import { UpdateReviewDto } from '../dto/update-review.dto';
|
||||
import { FindRestuarantReviewsDto, FindReviewsDto } from '../dto/find-reviews.dto';
|
||||
import { ReviewRepository } from '../repositories/review.repository';
|
||||
import { FoodRepository } from '../../foods/repositories/food.repository';
|
||||
import { UserRepository } from '../../users/repositories/user.repository';
|
||||
import { EntityManager } from '@mikro-orm/postgresql';
|
||||
import { RequiredEntityData } from '@mikro-orm/core';
|
||||
import { Review } from '../entities/review.entity';
|
||||
import { FoodMessage, UserMessage, ReviewMessage } from 'src/common/enums/message.enum';
|
||||
import { Order } from '../../orders/entities/order.entity';
|
||||
import { OrderItem } from '../../orders/entities/order-item.entity';
|
||||
import { Restaurant } from 'src/modules/restaurants/entities/restaurant.entity';
|
||||
import { ReviewStatus } from '../enums/review-status.enum';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { ReviewCreatedEvent } from '../events/review.events';
|
||||
import { sanitizeText } from '../../utils/sanitize.util';
|
||||
|
||||
@Injectable()
|
||||
export class ReviewService {
|
||||
constructor(
|
||||
private readonly reviewRepository: ReviewRepository,
|
||||
private readonly foodRepository: FoodRepository,
|
||||
private readonly userRepository: UserRepository,
|
||||
private readonly em: EntityManager,
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
) { }
|
||||
|
||||
async create(userId: string, createReviewDto: CreateReviewDto): Promise<Review> {
|
||||
const { foodId, orderId, rating, comment, positivePoints, negativePoints } = createReviewDto;
|
||||
|
||||
const food = await this.foodRepository.findOne({ id: foodId });
|
||||
if (!food) {
|
||||
throw new NotFoundException(FoodMessage.NOT_FOUND);
|
||||
}
|
||||
|
||||
const user = await this.userRepository.findOne({ id: userId });
|
||||
if (!user) {
|
||||
throw new NotFoundException(UserMessage.USER_NOT_FOUND);
|
||||
}
|
||||
|
||||
// Find order and verify it belongs to the user, populate restaurant to get restaurantId
|
||||
const order = await this.em.findOne(Order, { id: orderId, user: { id: userId } }, { populate: ['restaurant'] });
|
||||
if (!order) {
|
||||
throw new NotFoundException('Order not found or does not belong to the current user');
|
||||
}
|
||||
|
||||
// Verify the food is in the order
|
||||
const orderItem = await this.em.findOne(OrderItem, {
|
||||
order: { id: orderId },
|
||||
food: { id: foodId },
|
||||
});
|
||||
if (!orderItem) {
|
||||
throw new BadRequestException('Food is not in the specified order');
|
||||
}
|
||||
|
||||
// Check if user already commented on this food for this order
|
||||
const existingReview = await this.reviewRepository.findOne({
|
||||
order: { id: orderId },
|
||||
food: { id: foodId },
|
||||
user: { id: userId },
|
||||
});
|
||||
|
||||
if (existingReview) {
|
||||
throw new BadRequestException(ReviewMessage.ALREADY_COMMENTED);
|
||||
}
|
||||
|
||||
const data: RequiredEntityData<Review> = {
|
||||
order,
|
||||
food,
|
||||
user,
|
||||
rating,
|
||||
comment: sanitizeText(comment) || undefined,
|
||||
positivePoints: positivePoints || undefined,
|
||||
negativePoints: negativePoints || undefined,
|
||||
status: ReviewStatus.PENDING,
|
||||
};
|
||||
|
||||
const review = this.reviewRepository.create(data);
|
||||
if (!review) {
|
||||
throw new BadRequestException('Failed to create review entity');
|
||||
}
|
||||
|
||||
await this.em.persistAndFlush(review);
|
||||
|
||||
// Update food rating average
|
||||
await this.updateFoodRating(foodId);
|
||||
|
||||
// Emit ReviewCreatedEvent
|
||||
this.eventEmitter.emit(
|
||||
ReviewCreatedEvent.name,
|
||||
new ReviewCreatedEvent(
|
||||
review.id,
|
||||
order.restaurant.id,
|
||||
userId,
|
||||
foodId,
|
||||
food.title || 'Unknown Food',
|
||||
orderId,
|
||||
order.orderNumber || '',
|
||||
rating,
|
||||
),
|
||||
);
|
||||
|
||||
return review;
|
||||
}
|
||||
|
||||
async findAll(dto: FindReviewsDto) {
|
||||
return this.reviewRepository.findAllPaginated(dto);
|
||||
}
|
||||
|
||||
async findRestuarantAllReviews(dto: FindRestuarantReviewsDto) {
|
||||
const { restuarantSlug, ...restDto } = dto;
|
||||
const restaurant = await this.em.findOne(Restaurant, { slug: restuarantSlug });
|
||||
if (!restaurant) {
|
||||
throw new NotFoundException('RestaurantMessage.NOT_FOUND');
|
||||
}
|
||||
return this.reviewRepository.findAllPaginated({ ...restDto, restId: restaurant.id });
|
||||
}
|
||||
|
||||
async findById(id: string, restaurantId: string): Promise<Review> {
|
||||
const review = await this.reviewRepository.findOne(
|
||||
{ id, order: { restaurant: { id: restaurantId } } },
|
||||
{ populate: ['food', 'user', 'order'] },
|
||||
);
|
||||
if (!review) {
|
||||
throw new NotFoundException(ReviewMessage.NOT_FOUND);
|
||||
}
|
||||
return review;
|
||||
}
|
||||
|
||||
async update(id: string, userId: string, dto: UpdateReviewDto, isAdmin: boolean = false): Promise<Review> {
|
||||
const review = await this.reviewRepository.findOne({ id }, { populate: ['food', 'user', 'order'] });
|
||||
if (!review) {
|
||||
throw new NotFoundException(ReviewMessage.NOT_FOUND);
|
||||
}
|
||||
|
||||
// Only allow user to update their own reviews (unless admin)
|
||||
if (!isAdmin && review.user.id !== userId) {
|
||||
throw new BadRequestException(ReviewMessage.CAN_ONLY_UPDATE_OWN);
|
||||
}
|
||||
|
||||
// Users can only update rating and comment, not status
|
||||
if (!isAdmin && dto.status !== undefined) {
|
||||
throw new BadRequestException('Only admins can change review status');
|
||||
}
|
||||
|
||||
const oldRating = review.rating;
|
||||
|
||||
// Sanitize comment if provided
|
||||
const sanitizedDto = {
|
||||
...dto,
|
||||
comment: dto.comment !== undefined ? sanitizeText(dto.comment) || undefined : undefined,
|
||||
};
|
||||
|
||||
this.em.assign(review, sanitizedDto);
|
||||
await this.em.persistAndFlush(review);
|
||||
|
||||
// Update food rating average if rating changed
|
||||
if (dto.rating !== undefined && dto.rating !== oldRating) {
|
||||
await this.updateFoodRating(review.food.id);
|
||||
}
|
||||
|
||||
return review;
|
||||
}
|
||||
|
||||
async changeStatus(reviewId: string, status: ReviewStatus, restaurantId: string): Promise<Review> {
|
||||
const review = await this.reviewRepository.findOne({ id: reviewId, order: { restaurant: { id: restaurantId } } });
|
||||
if (!review) {
|
||||
throw new NotFoundException(ReviewMessage.NOT_FOUND);
|
||||
}
|
||||
review.status = status;
|
||||
await this.em.persistAndFlush(review);
|
||||
return review;
|
||||
}
|
||||
|
||||
async remove(id: string, userId: string, isAdmin: boolean = false) {
|
||||
const review = await this.reviewRepository.findOne({ id }, { populate: ['food', 'user', 'order'] });
|
||||
if (!review) {
|
||||
throw new NotFoundException(ReviewMessage.NOT_FOUND);
|
||||
}
|
||||
|
||||
// Only allow user to delete their own reviews (unless admin)
|
||||
if (!isAdmin && review.user.id !== userId) {
|
||||
throw new BadRequestException(ReviewMessage.CAN_ONLY_DELETE_OWN);
|
||||
}
|
||||
|
||||
const foodId = review.food.id;
|
||||
review.deletedAt = new Date();
|
||||
await this.em.persistAndFlush(review);
|
||||
|
||||
// Update food rating average
|
||||
await this.updateFoodRating(foodId);
|
||||
}
|
||||
|
||||
private async updateFoodRating(foodId: string): Promise<void> {
|
||||
const reviews = await this.reviewRepository.find(
|
||||
{ food: { id: foodId }, status: ReviewStatus.APPROVED, deletedAt: null },
|
||||
{ fields: ['rating'] },
|
||||
);
|
||||
|
||||
if (reviews.length === 0) {
|
||||
const food = await this.foodRepository.findOne({ id: foodId });
|
||||
if (food) {
|
||||
food.score = 0;
|
||||
await this.em.persistAndFlush(food);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const averageRating = reviews.reduce((sum, review) => sum + review.rating, 0) / reviews.length;
|
||||
|
||||
const food = await this.foodRepository.findOne({ id: foodId });
|
||||
if (food) {
|
||||
food.score = parseFloat(averageRating.toFixed(2));
|
||||
await this.em.persistAndFlush(food);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { EntityManager, EntityRepository } from '@mikro-orm/postgresql';
|
||||
import { FilterQuery } from '@mikro-orm/core';
|
||||
import { Review } from '../entities/review.entity';
|
||||
import { PaginatedResult } from 'src/common/interfaces/pagination.interface';
|
||||
import { ReviewStatus } from '../enums/review-status.enum';
|
||||
|
||||
type FindReviewsOpts = {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
foodId?: string;
|
||||
userId?: string;
|
||||
status?: ReviewStatus;
|
||||
orderBy?: string;
|
||||
restId?: string;
|
||||
order?: 'asc' | 'desc';
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ReviewRepository extends EntityRepository<Review> {
|
||||
constructor(readonly em: EntityManager) {
|
||||
super(em, Review);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find reviews with pagination and optional filters.
|
||||
* Supports: foodId, userId, status, ordering.
|
||||
*/
|
||||
async findAllPaginated(opts: FindReviewsOpts = {}): Promise<PaginatedResult<Review>> {
|
||||
const { page = 1, limit = 10, foodId, restId, userId, status, orderBy = 'createdAt', order = 'desc' } = opts;
|
||||
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const where: FilterQuery<Review> = {};
|
||||
|
||||
if (foodId) {
|
||||
where.food = { id: foodId };
|
||||
}
|
||||
|
||||
if (restId) {
|
||||
where.food = { restaurant: { id: restId } };
|
||||
}
|
||||
|
||||
if (userId) {
|
||||
where.user = { id: userId };
|
||||
}
|
||||
|
||||
if (status) {
|
||||
where.status = status;
|
||||
}
|
||||
|
||||
const [data, total] = await this.findAndCount(where, {
|
||||
limit,
|
||||
offset,
|
||||
orderBy: { [orderBy]: order.toLowerCase() as 'asc' | 'desc' },
|
||||
populate: ['food', 'user'],
|
||||
});
|
||||
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
return {
|
||||
data,
|
||||
meta: {
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user