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

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