This commit is contained in:
@@ -11,7 +11,10 @@ import {
|
||||
ApiParam,
|
||||
ApiBearerAuth,
|
||||
ApiHeader,
|
||||
ApiOkResponse,
|
||||
} 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 { RestId, UserId } from 'src/common/decorators';
|
||||
import { AuthGuard } from 'src/modules/auth/guards/auth.guard';
|
||||
@@ -37,6 +40,16 @@ export class FoodController {
|
||||
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')
|
||||
@UseGuards(OptionalAuthGuard)
|
||||
@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;
|
||||
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 { CategoryMessage, FoodMessage, RestMessage, CartMessage } from 'src/common/enums/message.enum';
|
||||
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 { RestaurantsService } from 'src/modules/restaurants/providers/restaurants.service';
|
||||
import { CacheService } from 'src/modules/utils/cache.service';
|
||||
@@ -86,6 +88,31 @@ export class FoodService {
|
||||
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).
|
||||
*/
|
||||
@@ -198,7 +225,7 @@ export class FoodService {
|
||||
|
||||
return {
|
||||
isAvailable: false,
|
||||
unavailabilityMessage: `فقط در روزهای ${allowedDays} در دسترس است. امروز ${currentDayName} است.`,
|
||||
unavailabilityMessage: `عدم تطابق روز`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -206,7 +233,7 @@ export class FoodService {
|
||||
if (mealType === null) {
|
||||
return {
|
||||
isAvailable: false,
|
||||
unavailabilityMessage: 'فقط در ساعات وعدههای غذایی (صبحانه، ناهار، عصرانه یا شام) در دسترس است.',
|
||||
unavailabilityMessage: 'عدم تطابق ساعت',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -216,7 +243,7 @@ export class FoodService {
|
||||
|
||||
return {
|
||||
isAvailable: false,
|
||||
unavailabilityMessage: `فقط برای ${allowedMealTypes} در دسترس است. زمان فعلی ${currentMealType} است.`,
|
||||
unavailabilityMessage: `عدم تطابق ساعت`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +62,37 @@ export class FoodRepository extends EntityRepository<Food> {
|
||||
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[]> {
|
||||
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,
|
||||
name,
|
||||
title: seoTitle,
|
||||
|
||||
Reference in New Issue
Block a user