274 lines
9.9 KiB
TypeScript
274 lines
9.9 KiB
TypeScript
import { Injectable, NotFoundException } from '@nestjs/common';
|
|
import { CreateFoodDto } from '../dto/create-food.dto';
|
|
import { FindFoodsDto } from '../dto/find-foods.dto';
|
|
import { FoodRepository } from '../repositories/food.repository';
|
|
import { CategoryRepository } from '../repositories/category.repository';
|
|
import { EntityManager } from '@mikro-orm/postgresql';
|
|
import { RequiredEntityData, FilterQuery } from '@mikro-orm/core';
|
|
import { Food } from '../entities/food.entity';
|
|
import { CategoryMessage, FoodMessage, RestMessage } from 'src/common/enums/message.enum';
|
|
import { RestRepository } from 'src/modules/restaurants/repositories/rest.repository';
|
|
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 {
|
|
private readonly FOODS_BY_RESTAURANT_CACHE_KEY_PREFIX = 'foods:restaurant:';
|
|
private readonly FOODS_CACHE_TTL = 600; // 10 minutes in seconds
|
|
|
|
constructor(
|
|
private readonly foodRepository: FoodRepository,
|
|
private readonly categoryRepository: CategoryRepository,
|
|
private readonly restRepository: RestRepository,
|
|
private readonly em: EntityManager,
|
|
private readonly cacheService: CacheService,
|
|
) { }
|
|
|
|
async create(restId: string, createFoodDto: CreateFoodDto) {
|
|
const { categoryId, dailyStock = 0, ...rest } = createFoodDto;
|
|
const restaurant = await this.restRepository.findOne({ id: restId });
|
|
if (!restaurant) {
|
|
throw new NotFoundException(RestMessage.NOT_FOUND);
|
|
}
|
|
const category = await this.categoryRepository.findOne({ id: categoryId, restaurant: { id: restId } });
|
|
if (!category) {
|
|
throw new NotFoundException(CategoryMessage.NOT_FOUND);
|
|
}
|
|
|
|
const { food, inventory } = 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,
|
|
isActive: rest.isActive ?? true,
|
|
inPlaceServe: rest.inPlaceServe ?? false,
|
|
pickupServe: rest.pickupServe ?? false,
|
|
discount: rest.discount ?? 0,
|
|
isSpecialOffer: rest.isSpecialOffer ?? false,
|
|
// map single-title/content DTO to localized fields
|
|
title: rest.title,
|
|
content: rest.content,
|
|
// numeric/array fields
|
|
price: rest.price ?? 0,
|
|
prepareTime: rest.prepareTime ?? 0,
|
|
images: rest.images ?? undefined,
|
|
// new fields
|
|
weekDays: rest.weekDays ?? [0, 1, 2, 3, 4, 5, 6],
|
|
mealTypes: rest.mealTypes ?? [],
|
|
restaurant: restaurant,
|
|
category: category,
|
|
};
|
|
|
|
const food = em.create(Food, data);
|
|
const newInventoryRecord = em.create(Inventory, {
|
|
food: food,
|
|
availableStock: dailyStock,
|
|
totalStock: dailyStock,
|
|
});
|
|
|
|
await em.flush();
|
|
return { food, inventory: newInventoryRecord };
|
|
});
|
|
|
|
// 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 };
|
|
}
|
|
|
|
findAll(restId: string, dto: FindFoodsDto) {
|
|
return this.foodRepository.findAllPaginated(restId, dto);
|
|
}
|
|
|
|
/**
|
|
* 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'] });
|
|
if (!food) throw new NotFoundException(FoodMessage.NOT_FOUND);
|
|
let isFavorite = false;
|
|
if (userId) {
|
|
isFavorite = (await this.em.count(Favorite, { user: { id: userId }, food: { id: foodId } })) > 0;
|
|
}
|
|
return {
|
|
...food,
|
|
isFavorite,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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'] });
|
|
|
|
if (!food) throw new NotFoundException(FoodMessage.NOT_FOUND);
|
|
return food;
|
|
}
|
|
|
|
/**
|
|
* Find active foods for a restaurant based on current week day and meal type in Iran timezone.
|
|
* @param slug - Restaurant slug identifier
|
|
* @returns Array of active foods matching current day and meal time
|
|
*/
|
|
async findByWeekDateAndMealType(slug: string): Promise<Food[]> {
|
|
const restaurant = await this.restRepository.findOne({ slug });
|
|
if (!restaurant) {
|
|
throw new NotFoundException(RestMessage.NOT_FOUND);
|
|
}
|
|
|
|
const { iranWeekDay, mealType } = this.getCurrentIranTimeContext();
|
|
|
|
const queryFilter: FilterQuery<Food> = {
|
|
restaurant: { slug },
|
|
isActive: true,
|
|
weekDays: { $contains: iranWeekDay },
|
|
...(mealType ? { mealTypes: { $contains: mealType } } : {}),
|
|
} as unknown as FilterQuery<Food>;
|
|
|
|
return this.foodRepository.find(queryFilter, { populate: ['category'] });
|
|
}
|
|
|
|
/**
|
|
* Get current Iran timezone context (weekday and meal type).
|
|
* @returns Object containing Iran weekday (0-6, where 0=Saturday) and current 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;
|
|
}
|
|
|
|
async update(restId: string, id: string, dto: Partial<CreateFoodDto>): Promise<Food> {
|
|
const { categoryId, dailyStock, ...rest } = dto;
|
|
const food = await this.foodRepository.findOne({ id, restaurant: { id: restId } }, { populate: ['restaurant'] });
|
|
if (!food) {
|
|
throw new NotFoundException(FoodMessage.NOT_FOUND);
|
|
}
|
|
|
|
// attach new categories if provided (adds, does not attempt to preserve/remove existing ones)
|
|
if (categoryId) {
|
|
const category = await this.categoryRepository.findOne({ id: categoryId, restaurant: { id: restId } });
|
|
|
|
if (!category) {
|
|
throw new NotFoundException(CategoryMessage.NOT_FOUND);
|
|
}
|
|
|
|
this.em.assign(food, { category: category });
|
|
}
|
|
|
|
// assign other fields from DTO (excluding dailyStock handled below)
|
|
this.em.assign(food, rest);
|
|
|
|
// Persist food and update/create related inventory atomically
|
|
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();
|
|
});
|
|
|
|
// Re-load the food to ensure populated relations and stable instance
|
|
const savedFood = await this.foodRepository.findOne({ id: food.id }, { populate: ['category', 'restaurant'] });
|
|
|
|
// Invalidate cache for the restaurant if present (optional)
|
|
// await this.invalidateRestaurantFoodsCache(savedFood?.restaurant?.slug);
|
|
|
|
return savedFood ?? food;
|
|
}
|
|
|
|
async remove(restId: string, id: string): Promise<void> {
|
|
const food = await this.foodRepository.findOne({ id, restaurant: { id: restId } }, { populate: ['restaurant'] });
|
|
if (!food) {
|
|
throw new NotFoundException(FoodMessage.NOT_FOUND);
|
|
}
|
|
|
|
// const restaurantSlug = food.restaurant.slug;
|
|
food.deletedAt = new Date();
|
|
await this.em.persistAndFlush(food);
|
|
|
|
// Invalidate cache for the restaurant
|
|
// await this.invalidateRestaurantFoodsCache(restaurantSlug);
|
|
}
|
|
|
|
|
|
async toggleFavorite(userId: string, foodId: string) {
|
|
|
|
const favorite = await this.em.findOne(Favorite, { user: userId, food: foodId });
|
|
if (favorite) {
|
|
return this.em.removeAndFlush(favorite);
|
|
}
|
|
const newFavorite = this.em.create(Favorite, {
|
|
user: userId,
|
|
food: foodId,
|
|
});
|
|
return this.em.persistAndFlush(newFavorite);
|
|
}
|
|
|
|
|
|
async getMyFavorites(userId: string) {
|
|
return this.em.find(Favorite, { user: userId }, { populate: ['food'] });
|
|
}
|
|
/**
|
|
* Invalidate cache for restaurant foods
|
|
*/
|
|
// private async invalidateRestaurantFoodsCache(slug: string): Promise<void> {
|
|
// const cacheKey = `${this.FOODS_BY_RESTAURANT_CACHE_KEY_PREFIX}${slug}`;
|
|
// await this.cacheService.del(cacheKey);
|
|
// }
|
|
}
|