This commit is contained in:
@@ -11,7 +11,10 @@ import {
|
|||||||
ApiParam,
|
ApiParam,
|
||||||
ApiBearerAuth,
|
ApiBearerAuth,
|
||||||
ApiHeader,
|
ApiHeader,
|
||||||
|
ApiOkResponse,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
|
import { SearchFoodsDto } from '../dto/search-foods.dto';
|
||||||
|
import { PublicFoodSearchItemDto } from '../dto/public-food-search-item.dto';
|
||||||
import { AdminAuthGuard } from 'src/modules/auth/guards/adminAuth.guard';
|
import { AdminAuthGuard } from 'src/modules/auth/guards/adminAuth.guard';
|
||||||
import { RestId, UserId } from 'src/common/decorators';
|
import { RestId, UserId } from 'src/common/decorators';
|
||||||
import { AuthGuard } from 'src/modules/auth/guards/auth.guard';
|
import { AuthGuard } from 'src/modules/auth/guards/auth.guard';
|
||||||
@@ -37,6 +40,16 @@ export class FoodController {
|
|||||||
return this.foodsService.findByRestuarantSlug(slug);
|
return this.foodsService.findByRestuarantSlug(slug);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('public/foods/search')
|
||||||
|
@ApiOperation({ summary: 'Search foods across active restaurants' })
|
||||||
|
@ApiQuery({ name: 'search', required: true, type: String, description: 'Search term (min 2 characters)' })
|
||||||
|
@ApiQuery({ name: 'page', required: false, type: Number })
|
||||||
|
@ApiQuery({ name: 'limit', required: false, type: Number })
|
||||||
|
@ApiOkResponse({ description: 'Paginated food search results', type: [PublicFoodSearchItemDto] })
|
||||||
|
searchFoodsAcrossRestaurants(@Query() dto: SearchFoodsDto) {
|
||||||
|
return this.foodsService.searchAcrossRestaurants(dto);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('public/foods/:foodId')
|
@Get('public/foods/:foodId')
|
||||||
@UseGuards(OptionalAuthGuard)
|
@UseGuards(OptionalAuthGuard)
|
||||||
@ApiHeader(API_HEADER_SLUG)
|
@ApiHeader(API_HEADER_SLUG)
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
class FoodSearchRestaurantDto {
|
||||||
|
@ApiProperty({ example: 'zhivan', description: 'Restaurant slug' })
|
||||||
|
slug!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'کافه ژیوان', description: 'Restaurant name' })
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'https://example.com/logo.png', description: 'Restaurant logo URL' })
|
||||||
|
logo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PublicFoodSearchItemDto {
|
||||||
|
@ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000', description: 'Food ID' })
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'پیتزا مخصوص', description: 'Food title' })
|
||||||
|
title?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 'پیتزا با پنیر و قارچ', description: 'Food description' })
|
||||||
|
desc?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 250000, description: 'Food price' })
|
||||||
|
price?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: ['https://example.com/food.jpg'],
|
||||||
|
description: 'Food image URLs',
|
||||||
|
type: [String],
|
||||||
|
})
|
||||||
|
images?: string[];
|
||||||
|
|
||||||
|
@ApiProperty({ example: 0, description: 'Discount percentage' })
|
||||||
|
discount!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ example: false, description: 'Whether the food is a special offer' })
|
||||||
|
isSpecialOffer!: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({ type: FoodSearchRestaurantDto, description: 'Restaurant that serves this food' })
|
||||||
|
restaurant!: FoodSearchRestaurantDto;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { IsNotEmpty, IsNumber, IsOptional, IsString, Min, MinLength } from 'class-validator';
|
||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
|
export class SearchFoodsDto {
|
||||||
|
@ApiProperty({ example: 'پیتزا', description: 'Search term matched against food title and description' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MinLength(2)
|
||||||
|
search!: string;
|
||||||
|
|
||||||
|
@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;
|
||||||
|
}
|
||||||
@@ -27,3 +27,18 @@ export interface PublicFoodListItem {
|
|||||||
isAvailable: boolean;
|
isAvailable: boolean;
|
||||||
unavailabilityMessage: string | null;
|
unavailabilityMessage: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PublicFoodSearchItem {
|
||||||
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
desc?: string;
|
||||||
|
price?: number;
|
||||||
|
images?: string[];
|
||||||
|
discount: number;
|
||||||
|
isSpecialOffer: boolean;
|
||||||
|
restaurant: {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
logo?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import { RequiredEntityData, FilterQuery } from '@mikro-orm/core';
|
|||||||
import { Food } from '../entities/food.entity';
|
import { Food } from '../entities/food.entity';
|
||||||
import { CategoryMessage, FoodMessage, RestMessage, CartMessage } from 'src/common/enums/message.enum';
|
import { CategoryMessage, FoodMessage, RestMessage, CartMessage } from 'src/common/enums/message.enum';
|
||||||
import { Favorite } from '../entities/favorite.entity';
|
import { Favorite } from '../entities/favorite.entity';
|
||||||
import { MealType, PublicFoodListItem, FoodAvailabilityInfo } from '../interface/food.interface';
|
import { MealType, PublicFoodListItem, PublicFoodSearchItem, FoodAvailabilityInfo } from '../interface/food.interface';
|
||||||
|
import { SearchFoodsDto } from '../dto/search-foods.dto';
|
||||||
|
import { PaginatedResult } from 'src/common/interfaces/pagination.interface';
|
||||||
import { Inventory } from 'src/modules/inventory/entities/inventory.entity';
|
import { Inventory } from 'src/modules/inventory/entities/inventory.entity';
|
||||||
import { RestaurantsService } from 'src/modules/restaurants/providers/restaurants.service';
|
import { RestaurantsService } from 'src/modules/restaurants/providers/restaurants.service';
|
||||||
import { CacheService } from 'src/modules/utils/cache.service';
|
import { CacheService } from 'src/modules/utils/cache.service';
|
||||||
@@ -86,6 +88,31 @@ export class FoodService {
|
|||||||
return this.foodRepository.findAllPaginated(restId, dto);
|
return this.foodRepository.findAllPaginated(restId, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async searchAcrossRestaurants(dto: SearchFoodsDto): Promise<PaginatedResult<PublicFoodSearchItem>> {
|
||||||
|
const result = await this.foodRepository.searchActiveAcrossRestaurants(dto.search, {
|
||||||
|
page: dto.page,
|
||||||
|
limit: dto.limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: result.data.map((food): PublicFoodSearchItem => ({
|
||||||
|
id: food.id,
|
||||||
|
title: food.title,
|
||||||
|
desc: food.desc,
|
||||||
|
price: food.price,
|
||||||
|
images: food.images,
|
||||||
|
discount: food.discount,
|
||||||
|
isSpecialOffer: food.isSpecialOffer,
|
||||||
|
restaurant: {
|
||||||
|
slug: food.restaurant.slug,
|
||||||
|
name: food.restaurant.name,
|
||||||
|
logo: food.restaurant.logo,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
meta: result.meta,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Public food detail (only active foods are visible).
|
* Public food detail (only active foods are visible).
|
||||||
*/
|
*/
|
||||||
@@ -198,7 +225,7 @@ export class FoodService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
isAvailable: false,
|
isAvailable: false,
|
||||||
unavailabilityMessage: `فقط در روزهای ${allowedDays} در دسترس است. امروز ${currentDayName} است.`,
|
unavailabilityMessage: `عدم تطابق روز`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,7 +233,7 @@ export class FoodService {
|
|||||||
if (mealType === null) {
|
if (mealType === null) {
|
||||||
return {
|
return {
|
||||||
isAvailable: false,
|
isAvailable: false,
|
||||||
unavailabilityMessage: 'فقط در ساعات وعدههای غذایی (صبحانه، ناهار، عصرانه یا شام) در دسترس است.',
|
unavailabilityMessage: 'عدم تطابق ساعت',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,7 +243,7 @@ export class FoodService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
isAvailable: false,
|
isAvailable: false,
|
||||||
unavailabilityMessage: `فقط برای ${allowedMealTypes} در دسترس است. زمان فعلی ${currentMealType} است.`,
|
unavailabilityMessage: `عدم تطابق ساعت`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,37 @@ export class FoodRepository extends EntityRepository<Food> {
|
|||||||
totalPages,
|
totalPages,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchActiveAcrossRestaurants(
|
||||||
|
search: string,
|
||||||
|
opts: Pick<FindFoodsOpts, 'page' | 'limit'> = {},
|
||||||
|
): Promise<PaginatedResult<Food>> {
|
||||||
|
const { page = 1, limit = 10 } = opts;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
const pattern = `%${search}%`;
|
||||||
|
|
||||||
|
const where: FilterQuery<Food> = {
|
||||||
|
isActive: true,
|
||||||
|
restaurant: { isActive: true },
|
||||||
|
$or: [{ title: { $ilike: pattern } }, { desc: { $ilike: pattern } }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const [data, total] = await this.findAndCount(where, {
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
orderBy: { title: 'asc' },
|
||||||
|
populate: ['restaurant'],
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
meta: {
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ export class RestaurantsService {
|
|||||||
async findAllActiveRestaurants(): Promise<ActiveRestaurantListItemDto[]> {
|
async findAllActiveRestaurants(): Promise<ActiveRestaurantListItemDto[]> {
|
||||||
const restaurants = await this.restRepository.findAllActive();
|
const restaurants = await this.restRepository.findAllActive();
|
||||||
|
|
||||||
return restaurants.map(({ slug, name, seoTitle, address, score, logo, establishedYear }) => ({
|
return restaurants.map(({ slug, name, seoTitle, address, logo, establishedYear }) => ({
|
||||||
slug,
|
slug,
|
||||||
name,
|
name,
|
||||||
title: seoTitle,
|
title: seoTitle,
|
||||||
|
|||||||
Reference in New Issue
Block a user