src/Aqarmap/Bundle/SearchBundle/Controller/Api/V4/ListingSearchController.php line 592

  1. #[OA\Parameter(name: 'eligibleForMortgage', description: 'Get listings that has mortgage Percentage', in: 'query', required: false)]
  2. #[OA\Parameter(name: 'page', description: 'Page number, starting from 1.', in: 'query', required: false)]
  3. #[OA\Parameter(name: 'limit', description: 'Number of items per page.', in: 'query', required: false)]
  4. #[OA\Parameter(name: 'sort', description: 'Sort search results by price or area.', in: 'query', required: false)]
  5. #[OA\Parameter(name: 'direction', description: 'Ascending (A to Z, 0 to 9), Descending (Z to A, 9 to 0)', in: 'query', required: false)]
  6. #[OA\Parameter(name: 'keywordSearch', description: "Search by keyword in listing's title, description and address", in: 'query', required: false)]
  7. #[OA\Parameter(name: 'unitOnly', description: 'Get compound units only', in: 'query', required: false)]
  8. #[OA\Response(response: 500, description: 'Returned when something went wrong, for example if you entered non existing propertyType ID')]
  9. public function getSearchListings(Request $request, CacheInterface $cache): Response
  10. {
  11. $criteria = $this->builderDirector->build($request)->getResult();
  12. $locations = explode(',', $request->query->get('location', ''));
  13. $request->query->set('locations', $locations);
  14. if (!empty($criteria['keywordSearch'])) {
  15. $this->eventDispatcher->dispatch(new SearchTriggerEvent($request));
  16. }
  17. if ($this->getUser() instanceof User) {
  18. $this->messageBus->dispatch(new Search($request->query, $this->getUser()));
  19. }
  20. $criteria['status'] = ListingStatus::LIVE;
  21. $cacheKey = sprintf('api_v4_listings_search_%s_%s', $request->getLocale(), md5(http_build_query($request->query->all())));
  22. $cachedSerialize = $cache->getItem($cacheKey);
  23. if (!$cachedSerialize->isHit() || empty($cachedSerialize->get())) {
  24. $result = $this->mediator->start($criteria)->getResults();
  25. $mapped = new ListingDataMapper();
  26. $mapped->setMapListingAttributes(true);
  27. $mapped->setMapListingMainPhoto(true);
  28. $mapped->setMapListingPhotos(true);
  29. $data = [
  30. 'default' => $this->makeMappedPaginatedBody($result['searchResults'], $mapped),
  31. 'related' => [],
  32. ];
  33. $data = $this->serializer->serialize(
  34. $data,
  35. 'json',
  36. SerializationContext::create()
  37. ->setGroups('SearchV4')
  38. ->enableMaxDepthChecks()
  39. );
  40. $cachedSerialize->set($data);
  41. $cachedSerialize->expiresAfter(3600 * 3);
  42. $cache->save($cachedSerialize);
  43. }
  44. return new Response($cachedSerialize->get());
  45. }
  46. /**
  47. * Related Listings Search V4.
  48. */
  49. #[Rest\Get('/api/v4/listings/search/related', name: 'aqarmap_api_get_related_results_v4', options: ['i18n' => false])]
  50. #[Rest\QueryParam(name: 'propertyType', requirements: '\d+', default: null, description: 'Property Type ID')]
  51. #[Rest\QueryParam(name: 'location', requirements: '\d+', default: null, description: 'Location ID comma spereted')]
  52. #[Rest\QueryParam(name: 'section', requirements: '\d+', default: null, description: 'Section ID')]
  53. #[Rest\QueryParam(name: 'page', requirements: '\d+', default: 1, description: 'Page number, starting from 1.', nullable: true)]
  54. #[Rest\QueryParam(name: 'limit', requirements: '\d+', default: 10, description: 'Number of items per page.', nullable: true)]
  55. #[Rest\QueryParam(name: 'sort', requirements: 'price|area', default: null, description: 'Sort search results by price or area.', nullable: true)]
  56. #[Rest\QueryParam(name: 'direction', requirements: 'asc|desc', default: 'asc', description: 'Ascending (A to Z, 0 to 9), Descending (Z to A, 9 to 0)', nullable: true)]
  57. #[Rest\View(serializerGroups: ['DefaultV4', 'SearchV4'])]
  58. #[OA\Parameter(name: 'propertyType', description: 'Property Type ID', in: 'query', required: false)]
  59. #[OA\Parameter(name: 'location', description: 'Location ID comma seperated', in: 'query', required: false)]
  60. #[OA\Parameter(name: 'section', description: 'Section ID', in: 'query', required: false)]
  61. #[OA\Parameter(name: 'page', description: 'Page number, starting from 1.', in: 'query', required: false)]
  62. #[OA\Parameter(name: 'limit', description: 'Number of items per page.', in: 'query', required: false)]
  63. #[OA\Parameter(name: 'sort', description: 'Sort search results by price or area.', in: 'query', required: false)]
  64. #[OA\Parameter(name: 'direction', description: 'Ascending (A to Z, 0 to 9), Descending (Z to A, 9 to 0)', in: 'query', required: false)]
  65. #[OA\Response(response: 500, description: 'Returned when something went wrong, for example if you entered non existing propertyType ID')]
  66. public function getRelatedSearchListings(Request $request, HttpClientInterface $searchClient, SerializerInterface $jmsSerializer, ListingRepository $listingRepository): JsonResponse
  67. {
  68. $response = $searchClient->request('GET', '/api/listing/nearest', [
  69. 'query' => $request->query->all(),
  70. 'headers' => [
  71. 'Accept-Language' => $request->getPreferredLanguage(),
  72. ],
  73. ]);
  74. if (200 === $response->getStatusCode()) {
  75. $data = $response->toArray();
  76. if (isset($data['data']) && is_array($data['data'])) {
  77. foreach ($data['data'] as &$item) {
  78. if (isset($item['listings']) && is_array($item['listings'])) {
  79. $listings = $listingRepository->getListingsByIds(array_column($item['listings'], 'id'))->getQuery()->getResult();
  80. $context = SerializationContext::create()
  81. ->setGroups(['DefaultV4', 'SearchV4']);
  82. $item['listings'] = json_decode(
  83. $jmsSerializer->serialize($listings, 'json', $context),
  84. true
  85. );
  86. }
  87. }
  88. }
  89. return $this->json($data);
  90. }
  91. return $this->json([
  92. 'error' => 'Failed to fetch nearest search listings',
  93. ], $response->getStatusCode());
  94. }
  95. #[Rest\Get('/api/v4/listings', name: 'aqarmap_api_get_listings', options: ['i18n' => false])]
  96. #[Rest\Get('/api/v4/listings/search', name: 'aqarmap_api_listings_search_v4', options: ['i18n' => false])]
  97. #[Rest\QueryParam(name: 'personalizedSearch', requirements: '(1)|(0)', default: '0', description: 'Enable personalized search', strict: true, nullable: true)]
  98. #[OA\Parameter(name: 'personalizedSearch', description: 'Enable personalized search (requires active subscription)', in: 'query', required: false)]
  99. #[Rest\QueryParam(name: 'amenities', description: 'A list of amenities to filter by.', map: true, nullable: true)]
  100. #[OA\Parameter(name: 'amenities', description: 'Filter by a list of amenities. Can be provided as an array.', in: 'query', required: false, schema: new OA\Schema(type: 'array', items: new OA\Items(type: 'string', enum: Amenities::ALL)))]
  101. public function getListings(Request $request, ListingRepository $listingRepository, ListingManager $listingManager, CacheInterface $cache, MessageBusInterface $messageBus)
  102. {
  103. // This to prevent the "result window is too large" exception in ElasticSearch.
  104. if ($request->get('page', 1) > 500) {
  105. throw new \LogicException('Page number must be less than or equal to 500');
  106. }
  107. /** @var UserInterface $user */
  108. $user = $this->getUser();
  109. $hasSearchScoringRole = $user && $user->hasRole('ROLE_SEARCH_SCORING');
  110. if (!$hasSearchScoringRole && ($request->get('esdebug') || $request->get('scoredebug'))) {
  111. $request->query->remove('esdebug');
  112. $request->query->remove('scoredebug');
  113. }
  114. if ($request->get('location')) {
  115. $request->query->set('locations', explode(',', $request->query->get('location', '')));
  116. }
  117. $personalizedSearch = $request->query->getBoolean('personalizedSearch', false);
  118. if ($personalizedSearch) {
  119. if (!$user instanceof User) {
  120. throw new UnauthorizedHttpException('Personalized search requires an active session. Please log in.');
  121. }
  122. if (!$user->hasSubscriptionPlan()) {
  123. throw new AccessDeniedHttpException('Active subscription required for personalized search.');
  124. }
  125. $request->query->set('personalizedSearch', true);
  126. $request->query->set('personalizedForUser', $user->getId());
  127. }
  128. if ($personalizedSearch) {
  129. $data = $this->getSerializedListingSearchResults($request, $listingRepository, $listingManager);
  130. } else {
  131. $cacheKey = sprintf('api_listings_search_v4_%s_%s', $request->getLocale(), md5(http_build_query($request->query->all())));
  132. $data = $cache->get(
  133. $cacheKey,
  134. function(ItemInterface $item) use ($request, $listingRepository, $listingManager) {
  135. $item->expiresAfter(3600 * 3);
  136. return $this->getSerializedListingSearchResults($request, $listingRepository, $listingManager);
  137. }
  138. );
  139. }
  140. return new Response($data);
  141. }
  142. private function getSerializedListingSearchResults(Request $request, ListingRepository $listingRepository, ListingManager $listingManager): string
  143. {
  144. $listingsElasticResponse = $listingManager->getListingsElasticResponse($request);
  145. $listingsQueryBuilder = $listingRepository->getListingsByIds(array_column($listingsElasticResponse['items'], 'id'));
  146. if ('aqarmap_api_listings_search_v4' === $request->attributes->get('_route')) {
  147. $listingsElasticResponse['pagination']['totalPages'] = (float) $listingsElasticResponse['pagination']['totalPages'];
  148. }
  149. $listings = $listingsQueryBuilder->getQuery()->getResult();
  150. $data = [
  151. 'default' => $this->mapPaginatedBody($listingsElasticResponse, $listings),
  152. 'related' => [],
  153. ];
  154. return $this->serializer->serialize(
  155. $data,
  156. 'json',
  157. SerializationContext::create()
  158. ->setGroups('SearchV4')
  159. ->enableMaxDepthChecks()
  160. );
  161. }
  162. #[Rest\Get('/api/v4/listings/debug', options: ['i18n' => false], name: 'aqarmap_api_get_listings_debug')]
  163. public function getListingsEsDebug(Request $request, ListingManager $listingManager)
  164. {
  165. /** @var UserInterface $user */
  166. $user = $this->getUser();
  167. if (!($user && $user->hasRole('ROLE_SEARCH_SCORING'))) {
  168. throw new UnauthorizedHttpException();
  169. }
  170. $request->query->set('esdebug', 1);
  171. if ($request->get('location')) {
  172. $request->query->set('locations', explode(',', $request->query->get('location', '')));
  173. }
  174. $listingsDebugElasticResponse = $listingManager->getListingsDebugElasticResponse($request);
  175. if ('aqarmap_api_listings_search_v4' === $request->attributes->get('_route')) {
  176. $listingsDebugElasticResponse['pagination']['totalPages'] = (float) $listingsDebugElasticResponse['pagination']['totalPages'];
  177. }
  178. $data = [
  179. 'default' => $this->mapPaginatedBody($listingsDebugElasticResponse, $listingsDebugElasticResponse['items']),
  180. ];
  181. return new JsonResponse($data);
  182. }
  183. #[Rest\Get('/api/v4/listings/search/ssr-data', name: 'aqarmap_api_get_listings_search_ssr-data', options: ['i18n' => false])]
  184. #[OA\Get(
  185. summary: 'Get listing search SSR data',
  186. description: 'Provides the pre-rendered content required for the listing search SSR page such as location hierarchy, SEO metadata and UI chips.',
  187. tags: ['Listings', 'Search']
  188. )]
  189. #[OA\Parameter(name: 'location', in: 'query', required: false, description: 'Slug of the primary location to load contextual data for.', schema: new OA\Schema(type: 'string'))]
  190. #[OA\Parameter(
  191. name: 'locations[]',
  192. in: 'query',
  193. required: false,
  194. description: 'Optional list of location slugs used to resolve additional data.',
  195. schema: new OA\Schema(type: 'array', items: new OA\Items(type: 'string'))
  196. )]
  197. #[OA\Parameter(name: 'section', in: 'query', required: false, description: 'Section slug to scope the returned data.', schema: new OA\Schema(type: 'string'))]
  198. #[OA\Parameter(name: 'propertyType', in: 'query', required: false, description: 'Property type slug to scope the returned data.', schema: new OA\Schema(type: 'string'))]
  199. #[OA\Parameter(name: 'byOwnerOnly', in: 'query', required: false, description: 'Whether to limit content to owner only listings.', schema: new OA\Schema(type: 'boolean'))]
  200. #[OA\Response(
  201. response: 200,
  202. description: 'Successful response containing SSR data required by the listing search page.',
  203. content: new OA\JsonContent(
  204. type: 'object',
  205. properties: [
  206. new OA\Property(
  207. property: 'locationChildren',
  208. description: 'Hierarchy of child locations related to the requested location.',
  209. type: 'array',
  210. items: new OA\Items(type: 'object')
  211. ),
  212. new OA\Property(
  213. property: 'longTail',
  214. description: 'SEO long-tail content blocks generated for the section, property type and location.',
  215. type: 'array',
  216. items: new OA\Items(type: 'object')
  217. ),
  218. new OA\Property(
  219. property: 'customParagraph',
  220. description: 'Custom paragraph content tailored to the current search context.',
  221. type: 'array',
  222. items: new OA\Items(type: 'object')
  223. ),
  224. new OA\Property(
  225. property: 'slugResolver',
  226. description: 'Resolved slugs mapping for the provided filters.',
  227. type: 'array',
  228. items: new OA\Items(type: 'object')
  229. ),
  230. new OA\Property(
  231. property: 'faqData',
  232. description: 'Frequently asked questions related to the current search.',
  233. type: 'array',
  234. items: new OA\Items(type: 'object')
  235. ),
  236. new OA\Property(
  237. property: 'locationParents',
  238. description: 'Ordered list of parent locations for breadcrumb generation.',
  239. type: 'array',
  240. items: new OA\Items(type: 'object')
  241. ),
  242. new OA\Property(
  243. property: 'sections',
  244. description: 'Available sections that can be displayed in the SSR page.',
  245. type: 'array',
  246. items: new OA\Items(type: 'object')
  247. ),
  248. new OA\Property(
  249. property: 'propertyTypeChips',
  250. description: 'Property type chips rendered in the search interface.',
  251. type: 'array',
  252. items: new OA\Items(type: 'object')
  253. ),
  254. ]
  255. )
  256. )]
  257. #[OA\Response(response: 500, description: 'Returned when SSR data could not be generated or cached.')]
  258. public function getListingsSearchSSRData(Request $request, ListingManager $listingManager, CacheInterface $cache, SectionService $sectionService)
  259. {
  260. $cacheKey = sprintf('api_listings_search_ssr_data_%s_%s', $request->getLocale(), md5(http_build_query($request->query->all())));
  261. $cachedResponse = $cache->getItem($cacheKey);
  262. if (!$cachedResponse->isHit() || empty($cachedResponse->get())) {
  263. $customParagraph = $locationChildren = $slugResolver = $faqData = $locationParents = [];
  264. if ($request->get('location')) {
  265. $request->query->set('locations', explode(',', $request->query->get('location', '')));
  266. }
  267. $location = $request->query->all('locations') ? $request->query->all('locations')[0] : null;
  268. $location = $this->locationRepository->findOneBy(['slug' => $location]);
  269. $section = $this->sectionRepository->findOneBy(['slug' => $request->query->get('section')]);
  270. $propertyType = $this->propertyTypeRepository->findOneBy(['slug' => $request->query->get('propertyType')]);
  271. if ($location) {
  272. $locationChildren = $listingManager->getSerializedLocationChildren($location);
  273. $locationParents = $listingManager->getLocationParents($location, $request->getLocale());
  274. }
  275. if ($section && $propertyType && $location) {
  276. $longTail = $this->seoListingSearchService->getSerializedLongTailData($section, $propertyType, $location) ?? [];
  277. }
  278. if ($section && $propertyType) {
  279. $byOwnerOnly = filter_var($request->get('byOwnerOnly'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? false;
  280. $customParagraph = $listingManager->getSerializedCustomParagraph($section, $propertyType, $location, $byOwnerOnly) ?? [];
  281. $slugResolver = $listingManager->getSerializedResolvedSlugs($section, $propertyType, $request->query->all('locations'));
  282. $faqData = $listingManager->getFaqs($section, $propertyType, $location, $request->getLocale());
  283. }
  284. $response = [
  285. 'locationChildren' => !empty($locationChildren) ? json_decode((string) $locationChildren, true) : [],
  286. 'longTail' => !empty($longTail) ? json_decode((string) $longTail, true) : [],
  287. 'customParagraph' => !empty($customParagraph) ? json_decode((string) $customParagraph, true) : [],
  288. 'slugResolver' => !empty($slugResolver) ? json_decode((string) $slugResolver, true) : [],
  289. 'faqData' => $faqData,
  290. 'locationParents' => $locationParents,
  291. 'sections' => json_decode((string) $sectionService->getSerializedSections(), true),
  292. 'propertyTypeChips' => $listingManager->getListingPropertyTypesChips($request, $location, $section, $propertyType),
  293. ];
  294. $cachedResponse->set(json_encode($response));
  295. $cachedResponse->expiresAfter(3600 * 3);
  296. $cache->save($cachedResponse);
  297. }
  298. return new JsonResponse(json_decode((string) $cachedResponse->get(), true));
  299. }
  300. #[Rest\Get('/api/v4/listings/trigger-search', options: ['i18n' => false], name: 'aqarmap_api_trigger_search_listings_v4')]
  301. public function triggerSearchListings(Request $request, MessageBusInterface $messageBus)
  302. {
  303. $user = $this->getUser();
  304. if ($request->get('location')) {
  305. $request->query->set('locations', explode(',', $request->query->get('location', '')));
  306. }
  307. if ($request->get('keywordSearch')) {
  308. $this->eventDispatcher->dispatch(new SearchTriggerEvent($request));
  309. }
  310. if ($user) {
  311. $messageBus->dispatch(new Search($request->query, $user));
  312. }
  313. return new JsonResponse([
  314. 'statusCode' => Response::HTTP_OK,
  315. 'statusMessage' => 'Search triggered successfully!',
  316. ]);
  317. }
  318. private function mapPaginatedBody(array $result, array $listings): array
  319. {
  320. if (!$result['pagination']) {
  321. return [
  322. 'statusCode' => $this->getStatusCode(),
  323. 'statusMessage' => $this->getStatusMessage(),
  324. 'paginate' => [],
  325. 'data' => [],
  326. 'errors' => $this->getErrors(),
  327. ];
  328. }
  329. return [
  330. 'statusCode' => $this->getStatusCode(),
  331. 'statusMessage' => $this->getStatusMessage(),
  332. 'paginate' => $result['pagination'],
  333. 'data' => $listings,
  334. 'errors' => $this->getErrors(),
  335. ];
  336. }
  337. }