diff --git a/local-db-init/docker-compose.yaml b/local-db-init/docker-compose.yaml index dcdf77c..8a1f7f4 100644 --- a/local-db-init/docker-compose.yaml +++ b/local-db-init/docker-compose.yaml @@ -7,4 +7,5 @@ services: ports: - "3306:3306" volumes: - - ./sql-scripts:/docker-entrypoint-initdb.d \ No newline at end of file + - ./sql-scripts:/docker-entrypoint-initdb.d + command: ["--log_bin_trust_function_creators=1"] \ No newline at end of file diff --git a/src/feature/search/dto/search-food-request.dto.ts b/src/feature/search/dto/search-food-request.dto.ts new file mode 100644 index 0000000..c914f07 --- /dev/null +++ b/src/feature/search/dto/search-food-request.dto.ts @@ -0,0 +1,33 @@ +export enum ResultType { + FOOD = 'FOOD', + RESTAURANT = 'RESTAURANT', +} +export enum SortType { + RELEVANCE = 'RELEVANCE', + PRICE_ASC = 'PRICE_ASC', + PRICE_DESC = 'PRICE_DESC', +} +export enum Filter { + FROM_4STAR = 'FROM_4STAR', + VEG = 'VEG', + UPTO_500KCAL = 'UPTO_500KCAL', +} + +enum IsoLangCode { + VIE = 'vie', + ENG = 'eng', +} + +export class SearchFoodRequest { + keyword: string; //REQUIRED + ISO_language_code: IsoLangCode; //REQUIRED + lat: string; //REQUIRED + long: string; //REQUIRED + result_type: ResultType; //REQUIRED - FOOD | RESTAURANT + sort_type: SortType; //REQUIRED - RELEVANCE | PRICE_ASC | PRICE_DESC + filter: Filter[]; //OPTIONAL - FROM_4STAR | VEG | UPTO_500KCAL + offset: number; //REQUIRED + page_size: number; //REQUIRED + distance_limit_m: number; //OPTIONAL - DEFAULT: 10;000 + base_distance_for_grouping_m: number; //OPTIONAL - DEFAUL: 200 +} diff --git a/src/feature/search/dto/search-food-response.dto.ts b/src/feature/search/dto/search-food-response.dto.ts new file mode 100644 index 0000000..aabd019 --- /dev/null +++ b/src/feature/search/dto/search-food-response.dto.ts @@ -0,0 +1,63 @@ +export class SearchFoodResponse { + results: FoodDTO[] | SrestaurantDTO[]; + keyword: string; + ISO_language_code: string; + lat: string; + long: string; + result_type: string; + sort_type: string; + filter: string[]; + offset: number; + total: number; + distance_limit_m: number; + base_distance_for_grouping_m: number; +} + +export interface FoodDTO { + id: number; + image: string; + top_label: string; + bottom_label: string; + name: TextByLang[]; + restaurant_name: TextByLang[]; + restaurant_id: number; + calorie_kcal: number; + rating: number; + distance_km: number; + delivery_time_s: number; + main_cooking_method: TextByLang[]; + ingredient_brief_vie: string; + ingredient_brief_eng: string; + price: number; + price_after_discount: number; + promotion: string; + preparing_time_s: number; + cooking_time_s: number; + quantity_available: number; + is_vegetarian: boolean; + cooking_schedule: string; + units_sold: number; + is_advanced_customizable: boolean; +} +export interface SrestaurantDTO { + id: number; + intro_video: string; + logo_img: string; + name: TextByLang[]; + rating: number; + distance_km: number; + delivery_time_s: number; + specialty: TextByLang[]; + top_food: string; + promotion: string; + having_vegeterian_food: boolean; + max_price: number; + min_price: number; + unit: string; + is_advanced_customizable: boolean; + food_result: string[]; +} +interface TextByLang { + ISO_language_code: string; + text: string; +} diff --git a/src/feature/search/search.controller.ts b/src/feature/search/search.controller.ts index 769aa38..4120794 100644 --- a/src/feature/search/search.controller.ts +++ b/src/feature/search/search.controller.ts @@ -1,9 +1,20 @@ -import { Controller, Inject } from '@nestjs/common'; +import { Controller, Inject, UseFilters } from '@nestjs/common'; import { SearchService } from './search.service'; import { MessagePattern } from '@nestjs/microservices'; import { FlagsmithService } from 'src/dependency/flagsmith/flagsmith.service'; import { SearchFoodByNameRequest } from './dto/search-food-by-name-request.dto'; import { SearchResult } from 'src/dto/search-result.dto'; +import { + ResultType, + SearchFoodRequest, + SortType, +} from './dto/search-food-request.dto'; +import { + SearchFoodResponse, + FoodDTO as SearchFoodDTO, + SrestaurantDTO as SearchRestaurantDTO, +} from './dto/search-food-response.dto'; +import { CustomRpcExceptionFilter } from 'src/filters/custom-rpc-exception.filter'; @Controller() export class SearchController { @@ -28,4 +39,72 @@ export class SearchController { //CURRENT LOGIC } + + @MessagePattern({ cmd: 'search_food' }) + @UseFilters(new CustomRpcExceptionFilter()) + async searchFood(data: SearchFoodRequest): Promise { + const { + keyword, + ISO_language_code, + lat, + long, + result_type, + sort_type, + filter = [], + offset, + page_size, + distance_limit_m = 10000, + base_distance_for_grouping_m = 200, + } = data; + + const fullResult: SearchFoodDTO[] | SearchRestaurantDTO[] = + await this.searchService.searchFoodInGeneral( + keyword.trim(), + ISO_language_code, + Number(lat), + Number(long), + distance_limit_m, + base_distance_for_grouping_m, + result_type, + filter, + ); + + //Sort the result based on the sort type + if (result_type == ResultType.FOOD) { + switch (sort_type) { + case SortType.RELEVANCE: + //Do nothing + break; + case SortType.PRICE_ASC: + fullResult.sort((a, b) => a.price - b.price); + break; + case SortType.PRICE_DESC: + fullResult.sort((a, b) => b.price - a.price); + break; + + default: + break; + } + } + + //Filter with the offset & page_size + const finalResult = fullResult.slice(offset, page_size); + + const result: SearchFoodResponse = { + results: finalResult, + keyword: keyword, + ISO_language_code: ISO_language_code, + lat: lat, + long: long, + result_type: result_type, + sort_type: sort_type, + filter: filter, + offset: offset + finalResult.length, + total: fullResult.length, + distance_limit_m: distance_limit_m, + base_distance_for_grouping_m: base_distance_for_grouping_m, + }; + + return result; + } } diff --git a/src/feature/search/search.service.ts b/src/feature/search/search.service.ts index 1869663..820f724 100644 --- a/src/feature/search/search.service.ts +++ b/src/feature/search/search.service.ts @@ -10,6 +10,12 @@ import { FoodDTO } from 'src/dto/food.dto'; import { PriceRange } from 'src/type'; import { GeneralResponse } from 'src/dto/general-response.dto'; import { CommonService } from '../common/common.service'; +import { + FoodDTO as SearchFoodDTO, + SrestaurantDTO as SearchRestaurantDTO, +} from './dto/search-food-response.dto'; +import { ResultType, Filter } from './dto/search-food-request.dto'; +import { MenuItem } from 'src/entity/menu-item.entity'; @Injectable() export class SearchService { @@ -132,4 +138,141 @@ export class SearchService { response.data = searchResult; return response; } + + async searchFoodInGeneral( + keyword: string, + ISO_language_code: string, + lat: number, + long: number, + distance_limit_m: number, + base_distance_for_grouping_m: number, + result_type: ResultType, + filter: Filter[], + ): Promise { + let searchResult: SearchFoodDTO[] | SearchRestaurantDTO[]; + let rawData: any; + // Search with raw SQL query in the full-search table Food_Search + if (keyword) { + rawData = await this.entityManager.query( + `SELECT food_search.menu_item_id, food_search.restaurant_id, calculate_distance(${lat}, ${long}, food_search.latitude, food_search.longitude, ${base_distance_for_grouping_m}) AS distance, MATCH (food_search.name , food_search.short_name) AGAINST ('${keyword}' IN NATURAL LANGUAGE MODE) AS score FROM Food_Search AS food_search Where food_search.ISO_language_code = '${ISO_language_code}' GROUP BY menu_item_id HAVING distance <= ${distance_limit_m} AND score > 0 ORDER BY distance ASC , score DESC`, + ); + } else if (!keyword) { + rawData = await this.entityManager.query( + `SELECT food_search.menu_item_id, food_search.restaurant_id, calculate_distance(${lat}, ${long}, food_search.latitude, food_search.longitude, ${base_distance_for_grouping_m}) AS distance FROM Food_Search AS food_search Where food_search.ISO_language_code = '${ISO_language_code}' GROUP BY menu_item_id HAVING distance <= ${distance_limit_m} ORDER BY distance ASC`, + ); + } + + //Extract list of menu_item_id and restaurant_id + const menuItemIds = []; + const restaurantIds = []; + rawData.forEach((item) => { + menuItemIds.push(item.menu_item_id); + + //Only add the unique restaurant Id + if ( + restaurantIds.find((val) => val === item.restaurant_id) == undefined + ) { + restaurantIds.push(item.restaurant_id); + } + }); + + //Get list of menu item + const menuItems = await this.foodService.getFoodsWithListOfMenuItem( + menuItemIds, + [ISO_language_code], + ); + + // Get list of delivery restaurant data + const deliveryRestaurants = + await this.restaurantService.getDeliveryRestaurantByListOfId( + restaurantIds, + lat, + long, + ); + + if (result_type === ResultType.FOOD) { + //Filtering + const filteredMenuItems: MenuItem[] = []; + for (const menuItem of menuItems) { + if (filter.includes(Filter.FROM_4STAR)) { + if (menuItem.rating < 4) { + continue; + } + } + + if (filter.includes(Filter.UPTO_500KCAL)) { + if (Number(menuItem.skus[0].calorie_kcal) > 500) { + continue; + } + } + + if (filter.includes(Filter.VEG)) { + if (menuItem.is_vegetarian == 0) { + continue; + } + } + + filteredMenuItems.push(menuItem); + } + //Build FoodDTOs + const foodDTOs: SearchFoodDTO[] = []; + for (const menuItem of filteredMenuItems) { + const deliveryRestaurant = deliveryRestaurants.find( + (res) => menuItem.restaurant_id == res.restaurant_id, + ); + const foodDTO = await this.commonService.convertIntoFoodDTO( + menuItem, + deliveryRestaurant, + ); + foodDTOs.push(foodDTO); + } + searchResult = foodDTOs; + } else if (result_type === ResultType.RESTAURANT) { + //Filtering + const filteredRestaurants = []; + for (const restaurant of deliveryRestaurants) { + if (filter.includes(Filter.FROM_4STAR)) { + if (restaurant.rating < 4) { + continue; + } + } + filteredRestaurants.push(restaurant); + } + //Build RestaurantDTOs + const srestaurantDTOs: SearchRestaurantDTO[] = []; + for (const restaurant of filteredRestaurants) { + const menuItemIds: number[] = rawData.map((item) => { + if (item.restaurant_id == restaurant.restaurant_id) { + return item.menu_item_id; + } + }); + const priceBeforeDiscountList = menuItems.map((food) => { + if (menuItemIds.includes(food.menu_item_id)) { + return food.skus[0].price; + } + }); + + const priceRange: PriceRange = { + min: Math.min(...priceBeforeDiscountList), + max: Math.max(...priceBeforeDiscountList), + }; + const restaurantDTO = + await this.restaurantService.convertIntoRestaurantDTO( + restaurant, + priceRange, + ); + const srestaurantDTO: SearchRestaurantDTO = { + ...restaurantDTO, + food_result: menuItems.map((item) => { + if (menuItemIds.includes(item.menu_item_id)) { + return item.menuItemExt[0].short_name; + } + }), + }; + srestaurantDTOs.push(srestaurantDTO); + } + searchResult = srestaurantDTOs; + } + return searchResult; + } }