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 = { desc: rest.desc, isActive: rest.isActive ?? true, inPlaceServe: rest.inPlaceServe ?? false, pickupServe: rest.pickupServe ?? false, discount: rest.discount ?? 0, isSpecialOffer: rest.isSpecialOffer ?? false, order: rest.order ?? null, // 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 { 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 { const food = await this.foodRepository.findOne({ id, restaurant: { id: restId } }, { populate: ['category','inventory'] }); 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 { const restaurant = await this.restRepository.findOne({ slug }); if (!restaurant) { throw new NotFoundException(RestMessage.NOT_FOUND); } const { iranWeekDay, mealType } = this.getCurrentIranTimeContext(); const queryFilter: FilterQuery = { restaurant: { slug }, isActive: true, // weekDays: { $contains: iranWeekDay }, // ...(mealType ? { mealTypes: { $contains: mealType } } : {}), } as unknown as FilterQuery; 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): Promise { 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 { 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,restId: string) { return this.em.find(Favorite, { user: userId, food: { restaurant: { id: restId } } }, { populate: ['food'] }); } /** * Invalidate cache for restaurant foods */ // private async invalidateRestaurantFoodsCache(slug: string): Promise { // const cacheKey = `${this.FOODS_BY_RESTAURANT_CACHE_KEY_PREFIX}${slug}`; // await this.cacheService.del(cacheKey); // } }