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

  1. <?php
  2. namespace Aqarmap\Bundle\SearchBundle\Controller\Api\V4;
  3. use App\Constant\Amenities;
  4. use App\Exception\BadRequestHttpException;
  5. use App\Exception\UnauthorizedHttpException;
  6. use Aqarmap\Bundle\ListingBundle\Constant\ListingStatus;
  7. use Aqarmap\Bundle\ListingBundle\Event\SearchTriggerEvent;
  8. use Aqarmap\Bundle\ListingBundle\Message\Search;
  9. use Aqarmap\Bundle\ListingBundle\Repository\ListingRepository;
  10. use Aqarmap\Bundle\ListingBundle\Repository\LocationRepository;
  11. use Aqarmap\Bundle\ListingBundle\Repository\PropertyTypeRepository;
  12. use Aqarmap\Bundle\ListingBundle\Repository\SectionRepository;
  13. use Aqarmap\Bundle\ListingBundle\Service\ListingManager;
  14. use Aqarmap\Bundle\ListingBundle\Service\RelatedResultService;
  15. use Aqarmap\Bundle\ListingBundle\Service\SectionService;
  16. use Aqarmap\Bundle\MainBundle\Controller\Api\V4\BaseController;
  17. use Aqarmap\Bundle\MainBundle\Model\Listing\V4\ListingDataMapper;
  18. use Aqarmap\Bundle\MainBundle\Repository\CustomParagraphRepository;
  19. use Aqarmap\Bundle\SearchBundle\CriteriaBuilders\BuilderDirector;
  20. use Aqarmap\Bundle\SearchBundle\CriteriaMediator\Contracts\MediatorInterface;
  21. use Aqarmap\Bundle\SearchBundle\Services\ListingFaqService;
  22. use Aqarmap\Bundle\SearchBundle\Services\SEOListingSearchService;
  23. use Aqarmap\Bundle\UserBundle\Entity\User;
  24. use FOS\RestBundle\Controller\Annotations as Rest;
  25. use FOS\UserBundle\Model\UserInterface;
  26. use JMS\Serializer\SerializationContext;
  27. use JMS\Serializer\SerializerInterface;
  28. use OpenApi\Attributes as OA;
  29. use Symfony\Component\EventDispatcher\EventDispatcherInterface;
  30. use Symfony\Component\HttpFoundation\JsonResponse;
  31. use Symfony\Component\HttpFoundation\Request;
  32. use Symfony\Component\HttpFoundation\Response;
  33. use Symfony\Component\HttpKernel\Attribute\Cache;
  34. use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
  35. use Symfony\Component\Messenger\MessageBusInterface;
  36. use Symfony\Contracts\Cache\CacheInterface;
  37. use Symfony\Contracts\Cache\ItemInterface;
  38. use Symfony\Contracts\HttpClient\HttpClientInterface;
  39. class ListingSearchController extends BaseController
  40. {
  41. public function __construct(private readonly BuilderDirector $builderDirector, private readonly MediatorInterface $mediator, private readonly EventDispatcherInterface $eventDispatcher, private readonly SerializerInterface $serializer, RelatedResultService $relatedResultService, private readonly LocationRepository $locationRepository, CustomParagraphRepository $customParagraphRepository, private readonly SEOListingSearchService $seoListingSearchService, private readonly SectionRepository $sectionRepository, private readonly PropertyTypeRepository $propertyTypeRepository, ListingFaqService $listingFaqService, private readonly MessageBusInterface $messageBus)
  42. {
  43. }
  44. /**
  45. * Listings Search V4 ( Legacy code ).
  46. */
  47. #[Rest\Get('/api/v4/listings/search-legacy', name: 'legacy_aqarmap_api_listings_search_v4', options: ['i18n' => false])]
  48. #[Rest\QueryParam(name: 'propertyType', requirements: '\d+', default: null, description: 'Property Type ID')]
  49. #[Rest\QueryParam(name: 'location', requirements: '\d+', default: null, description: 'Location ID comma spereted')]
  50. #[Rest\QueryParam(name: 'section', requirements: '\d+', default: null, description: 'Section ID')]
  51. #[Rest\QueryParam(name: 'bounds', requirements: 'South-West Latitude ,
  52. Longitude , North-East Latitude , Longitude')] // default=null,
  53. #[Rest\QueryParam(name: 'minPrice', requirements: '\d+', default: null, description: 'Minimum Prices')]
  54. #[Rest\QueryParam(name: 'maxPrice', requirements: '\d+', default: null, description: 'Maximum Prices')]
  55. #[Rest\QueryParam(name: 'minArea', requirements: '\d+', default: null, description: 'Minimum Area')]
  56. #[Rest\QueryParam(name: 'maxArea', requirements: '\d+', default: null, description: 'Maximum Area')]
  57. #[Rest\QueryParam(name: 'minFloor', requirements: '\d+', default: null, description: 'Minimum Floor')]
  58. #[Rest\QueryParam(name: 'minRoom', requirements: '\d+', default: null, description: 'Minimum Room')]
  59. #[Rest\QueryParam(name: 'floor', requirements: '\d+', default: null, description: 'Floor Number')]
  60. #[Rest\QueryParam(name: 'room', requirements: '\d+', default: null, description: 'Number of Rooms')]
  61. #[Rest\QueryParam(name: 'baths', requirements: '\d+', default: null, description: 'Number of Baths')]
  62. #[Rest\QueryParam(name: 'finishType', default: null, description: 'finish Type')]
  63. #[Rest\QueryParam(name: 'sellerRole', requirements: '\d+', default: null, description: 'sellerRole')]
  64. #[Rest\QueryParam(name: 'paymentMethod', requirements: '\d+', default: null, description: 'paymentMethod')]
  65. #[Rest\QueryParam(name: 'deliveryYear', requirements: '\d+', default: null, description: 'deliveryYear')]
  66. #[Rest\QueryParam(name: 'bath', default: null, description: 'Number of Baths')]
  67. #[Rest\QueryParam(name: 'photos', requirements: '(1)|(0)', default: '0', description: 'Get only listings with photos', strict: true, nullable: true)]
  68. #[Rest\QueryParam(name: 'isMortgage', requirements: '(1)|(0)', default: '0', description: 'Get only listings that support mortgage', strict: true, nullable: true)]
  69. #[Rest\QueryParam(name: 'eligibleForMortgage', description: 'Get listings that has mortgage Percentage', strict: true, nullable: true)]
  70. #[Rest\QueryParam(name: 'page', requirements: '\d+', default: 1, description: 'Page number, starting from 1.', nullable: true)]
  71. #[Rest\QueryParam(name: 'limit', requirements: '\d+', default: 10, description: 'Number of items per page.', nullable: true)]
  72. #[Rest\QueryParam(name: 'sort', requirements: 'price|area', default: null, description: 'Sort search results by price or area.', nullable: true)]
  73. #[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)]
  74. #[Rest\QueryParam(name: 'keywordSearch', description: "Search by keyword in listing's title, description and address")]
  75. #[Rest\QueryParam(name: 'unitOnly', requirements: '(1)|(0)', description: 'Get compound units only', nullable: true)]
  76. #[Rest\View]
  77. #[Cache(expires: '+2 hours', maxage: '+2 hours', smaxage: '+2 hours', public: true, vary: ['Accept-Language', 'X-Accept-Version', 'Accept'])]
  78. #[OA\Parameter(name: 'propertyType', description: 'Property Type ID', in: 'query', required: false)]
  79. #[OA\Parameter(name: 'location', description: 'Location ID comma spereted', in: 'query', required: false)]
  80. #[OA\Parameter(name: 'section', description: 'Section ID', in: 'query', required: false)]
  81. #[OA\Parameter(name: 'bounds', description: 'Map bounds (example: 24.6275450,46.6363017,24.6977461,46.817232)', in: 'query', required: false)]
  82. #[OA\Parameter(name: 'minPrice', description: 'Minimum Prices', in: 'query', required: false)]
  83. #[OA\Parameter(name: 'maxPrice', description: 'Maximum Prices', in: 'query', required: false)]
  84. #[OA\Parameter(name: 'minArea', description: 'Minimum Area', in: 'query', required: false)]
  85. #[OA\Parameter(name: 'maxArea', description: 'Maximum Area', in: 'query', required: false)]
  86. #[OA\Parameter(name: 'minFloor', description: 'Minimum Floor', in: 'query', required: false)]
  87. #[OA\Parameter(name: 'minRoom', description: 'Minimum Room', in: 'query', required: false)]
  88. #[OA\Parameter(name: 'floor', description: 'Floor Number', in: 'query', required: false)]
  89. #[OA\Parameter(name: 'room', description: 'Number of Rooms', in: 'query', required: false)]
  90. #[OA\Parameter(name: 'baths', description: 'Number of Baths', in: 'query', required: false)]
  91. #[OA\Parameter(name: 'finishType', description: 'finish Type', in: 'query', required: false)]
  92. #[OA\Parameter(name: 'sellerRole', description: 'sellerRole', in: 'query', required: false)]
  93. #[OA\Parameter(name: 'paymentMethod', description: 'paymentMethod', in: 'query', required: false)]
  94. #[OA\Parameter(name: 'deliveryYear', description: 'deliveryYear', in: 'query', required: false)]
  95. #[OA\Parameter(name: 'bath', description: 'Number of Baths', in: 'query', required: false)]
  96. #[OA\Parameter(name: 'photos', description: 'Get only listings with photos', in: 'query', required: false)]
  97. #[OA\Parameter(name: 'isMortgage', description: 'Get only listings that support mortgage', in: 'query', required: false)]
  98. #[OA\Parameter(name: 'eligibleForMortgage', description: 'Get listings that has mortgage Percentage', in: 'query', required: false)]
  99. #[OA\Parameter(name: 'page', description: 'Page number, starting from 1.', in: 'query', required: false)]
  100. #[OA\Parameter(name: 'limit', description: 'Number of items per page.', in: 'query', required: false)]
  101. #[OA\Parameter(name: 'sort', description: 'Sort search results by price or area.', in: 'query', required: false)]
  102. #[OA\Parameter(name: 'direction', description: 'Ascending (A to Z, 0 to 9), Descending (Z to A, 9 to 0)', in: 'query', required: false)]
  103. #[OA\Parameter(name: 'keywordSearch', description: "Search by keyword in listing's title, description and address", in: 'query', required: false)]
  104. #[OA\Parameter(name: 'unitOnly', description: 'Get compound units only', in: 'query', required: false)]
  105. #[OA\Response(response: 500, description: 'Returned when something went wrong, for example if you entered non existing propertyType ID')]
  106. public function getSearchListings(Request $request, CacheInterface $cache): Response
  107. {
  108. $criteria = $this->builderDirector->build($request)->getResult();
  109. $locations = explode(',', $request->query->get('location', ''));
  110. $request->query->set('locations', $locations);
  111. if (!empty($criteria['keywordSearch'])) {
  112. $this->eventDispatcher->dispatch(new SearchTriggerEvent($request));
  113. }
  114. if ($this->getUser() instanceof User) {
  115. $this->messageBus->dispatch(new Search($request->query, $this->getUser()));
  116. }
  117. $criteria['status'] = ListingStatus::LIVE;
  118. $cacheKey = sprintf('api_v4_listings_search_%s_%s', $request->getLocale(), md5(http_build_query($request->query->all())));
  119. $cachedSerialize = $cache->getItem($cacheKey);
  120. if (!$cachedSerialize->isHit() || empty($cachedSerialize->get())) {
  121. $result = $this->mediator->start($criteria)->getResults();
  122. $mapped = new ListingDataMapper();
  123. $mapped->setMapListingAttributes(true);
  124. $mapped->setMapListingMainPhoto(true);
  125. $mapped->setMapListingPhotos(true);
  126. $data = [
  127. 'default' => $this->makeMappedPaginatedBody($result['searchResults'], $mapped),
  128. 'related' => [],
  129. ];
  130. $data = $this->serializer->serialize(
  131. $data,
  132. 'json',
  133. SerializationContext::create()
  134. ->setGroups('SearchV4')
  135. ->enableMaxDepthChecks()
  136. );
  137. $cachedSerialize->set($data);
  138. $cachedSerialize->expiresAfter(3600 * 3);
  139. $cache->save($cachedSerialize);
  140. }
  141. return new Response($cachedSerialize->get());
  142. }
  143. /**
  144. * Related Listings Search V4.
  145. */
  146. #[Rest\Get('/api/v4/listings/search/related', name: 'aqarmap_api_get_related_results_v4', options: ['i18n' => false])]
  147. #[Rest\QueryParam(name: 'propertyType', requirements: '\d+', default: null, description: 'Property Type ID')]
  148. #[Rest\QueryParam(name: 'location', requirements: '\d+', default: null, description: 'Location ID comma spereted')]
  149. #[Rest\QueryParam(name: 'section', requirements: '\d+', default: null, description: 'Section ID')]
  150. #[Rest\QueryParam(name: 'page', requirements: '\d+', default: 1, description: 'Page number, starting from 1.', nullable: true)]
  151. #[Rest\QueryParam(name: 'limit', requirements: '\d+', default: 10, description: 'Number of items per page.', nullable: true)]
  152. #[Rest\QueryParam(name: 'sort', requirements: 'price|area', default: null, description: 'Sort search results by price or area.', nullable: true)]
  153. #[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)]
  154. #[Rest\View(serializerGroups: ['DefaultV4', 'SearchV4'])]
  155. #[OA\Parameter(name: 'propertyType', description: 'Property Type ID', in: 'query', required: false)]
  156. #[OA\Parameter(name: 'location', description: 'Location ID comma seperated', in: 'query', required: false)]
  157. #[OA\Parameter(name: 'section', description: 'Section ID', in: 'query', required: false)]
  158. #[OA\Parameter(name: 'page', description: 'Page number, starting from 1.', in: 'query', required: false)]
  159. #[OA\Parameter(name: 'limit', description: 'Number of items per page.', in: 'query', required: false)]
  160. #[OA\Parameter(name: 'sort', description: 'Sort search results by price or area.', in: 'query', required: false)]
  161. #[OA\Parameter(name: 'direction', description: 'Ascending (A to Z, 0 to 9), Descending (Z to A, 9 to 0)', in: 'query', required: false)]
  162. #[OA\Response(response: 500, description: 'Returned when something went wrong, for example if you entered non existing propertyType ID')]
  163. public function getRelatedSearchListings(Request $request, HttpClientInterface $searchClient, SerializerInterface $jmsSerializer, ListingRepository $listingRepository): JsonResponse
  164. {
  165. $response = $searchClient->request('GET', '/api/listing/nearest', [
  166. 'query' => $request->query->all(),
  167. 'headers' => [
  168. 'Accept-Language' => $request->getPreferredLanguage(),
  169. ],
  170. ]);
  171. if (200 === $response->getStatusCode()) {
  172. $data = $response->toArray();
  173. if (isset($data['data']) && is_array($data['data'])) {
  174. foreach ($data['data'] as &$item) {
  175. if (isset($item['listings']) && is_array($item['listings'])) {
  176. $listings = $listingRepository->getListingsByIds(array_column($item['listings'], 'id'))->getQuery()->getResult();
  177. $context = SerializationContext::create()
  178. ->setGroups(['DefaultV4', 'SearchV4']);
  179. $item['listings'] = json_decode(
  180. $jmsSerializer->serialize($listings, 'json', $context),
  181. true
  182. );
  183. }
  184. }
  185. }
  186. return $this->json($data);
  187. }
  188. return $this->json([
  189. 'error' => 'Failed to fetch nearest search listings',
  190. ], $response->getStatusCode());
  191. }
  192. #[Rest\Get('/api/v4/listings', name: 'aqarmap_api_get_listings', options: ['i18n' => false])]
  193. #[Rest\Get('/api/v4/listings/search', name: 'aqarmap_api_listings_search_v4', options: ['i18n' => false])]
  194. #[OA\Get(summary: 'Search listings', description: 'Returns listing search results for the provided filters and pagination settings.', tags: ['ListingsSearch'])]
  195. #[OA\Parameter(name: 'minPrice', description: 'Minimum listing price.', in: 'query', required: false, schema: new OA\Schema(type: 'integer'))]
  196. #[OA\Parameter(name: 'maxPrice', description: 'Maximum listing price.', in: 'query', required: false, schema: new OA\Schema(type: 'integer'))]
  197. #[OA\Parameter(name: 'minArea', description: 'Minimum listing area.', in: 'query', required: false, schema: new OA\Schema(type: 'integer'))]
  198. #[OA\Parameter(name: 'maxArea', description: 'Maximum listing area.', in: 'query', required: false, schema: new OA\Schema(type: 'integer'))]
  199. #[OA\Parameter(name: 'room', description: 'Exact number of rooms.', in: 'query', required: false, schema: new OA\Schema(type: 'integer'))]
  200. #[OA\Parameter(name: 'minRoom', description: 'Minimum number of rooms.', in: 'query', required: false, schema: new OA\Schema(type: 'integer'))]
  201. #[OA\Parameter(name: 'bounds', description: 'Map bounds in south-west/north-east latitude-longitude format.', in: 'query', required: false, schema: new OA\Schema(type: 'string'))]
  202. #[OA\Parameter(name: 'marketPropertyType', description: 'Market-specific property type filter.', in: 'query', required: false, schema: new OA\Schema(type: 'string'))]
  203. #[OA\Parameter(name: 'excludedCategory', description: 'Category identifier to exclude from results.', in: 'query', required: false, schema: new OA\Schema(type: 'string'))]
  204. #[OA\Parameter(name: 'trackTotalHits', description: 'Whether to request exact total hit tracking from the search backend.', in: 'query', required: false, schema: new OA\Schema(type: 'boolean'))]
  205. #[OA\Parameter(name: 'scoredebug', description: 'Include search scoring debug details when permitted.', in: 'query', required: false, schema: new OA\Schema(type: 'boolean'))]
  206. #[OA\Parameter(name: 'esdebug', description: 'Include Elasticsearch debug details when permitted.', in: 'query', required: false, schema: new OA\Schema(type: 'boolean'))]
  207. #[OA\Parameter(name: 'limit', description: 'Maximum number of listings returned per page.', in: 'query', required: false, schema: new OA\Schema(type: 'integer'))]
  208. #[OA\Parameter(name: 'sort', description: 'Sorting field for the result set.', in: 'query', required: false, schema: new OA\Schema(type: 'string'))]
  209. #[OA\Parameter(name: 'aggregatedField', description: 'Aggregation field requested from the search backend.', in: 'query', required: false, schema: new OA\Schema(type: 'string'))]
  210. #[OA\Parameter(name: 'creditExpiryDate', description: 'Credit expiry date filter.', in: 'query', required: false, schema: new OA\Schema(type: 'string', format: 'date'))]
  211. #[Rest\QueryParam(name: 'personalizedSearch', requirements: '(1)|(0)', default: '0', description: 'Enable personalized search', strict: true, nullable: true)]
  212. #[OA\Parameter(name: 'personalizedSearch', description: 'Enable personalized search (requires an authenticated user with an active subscription).', in: 'query', required: false, schema: new OA\Schema(type: 'string', enum: ['0', '1']))]
  213. #[OA\Parameter(name: 'personalizedForUser', description: 'Internal user identifier used to scope personalized results.', in: 'query', required: false, schema: new OA\Schema(type: 'integer'))]
  214. #[OA\Parameter(name: 'status', description: 'Listing status filter.', in: 'query', required: false, schema: new OA\Schema(type: 'string'))]
  215. #[OA\Parameter(name: 'location', description: 'Comma-separated location identifiers.', in: 'query', required: false, schema: new OA\Schema(type: 'string'))]
  216. #[OA\Parameter(name: 'propertyType', description: 'Property type identifier.', in: 'query', required: false, schema: new OA\Schema(type: 'integer'))]
  217. #[OA\Parameter(name: 'section', description: 'Section identifier.', in: 'query', required: false, schema: new OA\Schema(type: 'integer'))]
  218. #[OA\Parameter(name: 'excludedListingsIds', description: 'Comma-separated listing identifiers to exclude from results.', in: 'query', required: false, schema: new OA\Schema(type: 'string'))]
  219. #[Rest\QueryParam(name: 'amenities', description: 'A list of amenities to filter by.', map: true, nullable: true)]
  220. #[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)))]
  221. #[Rest\QueryParam(name: 'downPayment', requirements: '\d+', default: null, description: 'Filter by maximum down payment amount', nullable: true)]
  222. #[OA\Parameter(name: 'downPayment', description: 'Filter by maximum down payment amount', in: 'query', required: false, schema: new OA\Schema(type: 'integer'))]
  223. #[Rest\QueryParam(name: 'duration', requirements: '\d+', default: null, description: 'Filter by minimum installment duration (in days)', nullable: true)]
  224. #[OA\Parameter(name: 'duration', description: 'Filter by minimum installment duration (in days)', in: 'query', required: false, schema: new OA\Schema(type: 'integer'))]
  225. #[OA\Response(response: 200, description: 'Paginated listing search results.')]
  226. #[OA\Response(response: 400, description: 'Invalid search request, such as a page number above the supported limit.')]
  227. #[OA\Response(response: 401, description: 'Returned when personalized search is requested without an authenticated user.')]
  228. #[OA\Response(response: 403, description: 'Returned when personalized search is requested without an active subscription.')]
  229. public function getListings(Request $request, ListingRepository $listingRepository, ListingManager $listingManager, CacheInterface $cache, MessageBusInterface $messageBus)
  230. {
  231. // This to prevent the "result window is too large" exception in ElasticSearch.
  232. if ($request->get('page', 1) > 500) {
  233. throw new BadRequestHttpException('Page number must be less than or equal to 500');
  234. }
  235. /** @var UserInterface $user */
  236. $user = $this->getUser();
  237. $hasSearchScoringRole = $user && $user->hasRole('ROLE_SEARCH_SCORING');
  238. if (!$hasSearchScoringRole && ($request->get('esdebug') || $request->get('scoredebug'))) {
  239. $request->query->remove('esdebug');
  240. $request->query->remove('scoredebug');
  241. }
  242. if ($request->get('location')) {
  243. $request->query->set('locations', explode(',', $request->query->get('location', '')));
  244. }
  245. $personalizedSearch = $request->query->getBoolean('personalizedSearch', false);
  246. if ($personalizedSearch) {
  247. if (!$user instanceof User) {
  248. throw new UnauthorizedHttpException('Personalized search requires an active session. Please log in.');
  249. }
  250. if (!$user->hasSubscriptionPlan()) {
  251. throw new AccessDeniedHttpException('Active subscription required for personalized search.');
  252. }
  253. $request->query->set('personalizedSearch', true);
  254. $request->query->set('personalizedForUser', $user->getId());
  255. }
  256. if ($personalizedSearch) {
  257. $data = $this->getSerializedListingSearchResults($request, $listingRepository, $listingManager);
  258. } else {
  259. $cacheKey = sprintf('api_listings_search_v4_%s_%s', $request->getLocale(), md5(http_build_query($request->query->all())));
  260. $data = $cache->get(
  261. $cacheKey,
  262. function(ItemInterface $item) use ($request, $listingRepository, $listingManager) {
  263. $item->expiresAfter(3600 * 3);
  264. return $this->getSerializedListingSearchResults($request, $listingRepository, $listingManager);
  265. }
  266. );
  267. }
  268. return new Response($data);
  269. }
  270. private function getSerializedListingSearchResults(Request $request, ListingRepository $listingRepository, ListingManager $listingManager): string
  271. {
  272. $listingsElasticResponse = $listingManager->getListingsElasticResponse($request);
  273. $listingsQueryBuilder = $listingRepository->getListingsByIds(array_column($listingsElasticResponse['items'], 'id'));
  274. if ('aqarmap_api_listings_search_v4' === $request->attributes->get('_route')) {
  275. $listingsElasticResponse['pagination']['totalPages'] = (float) $listingsElasticResponse['pagination']['totalPages'];
  276. }
  277. $listings = $listingsQueryBuilder->getQuery()->getResult();
  278. $data = [
  279. 'default' => $this->mapPaginatedBody($listingsElasticResponse, $listings),
  280. 'related' => [],
  281. ];
  282. return $this->serializer->serialize(
  283. $data,
  284. 'json',
  285. SerializationContext::create()
  286. ->setGroups('SearchV4')
  287. ->enableMaxDepthChecks()
  288. );
  289. }
  290. #[Rest\Get('/api/v4/listings/debug', options: ['i18n' => false], name: 'aqarmap_api_get_listings_debug')]
  291. public function getListingsEsDebug(Request $request, ListingManager $listingManager)
  292. {
  293. /** @var UserInterface $user */
  294. $user = $this->getUser();
  295. if (!($user && $user->hasRole('ROLE_SEARCH_SCORING'))) {
  296. throw new UnauthorizedHttpException();
  297. }
  298. $request->query->set('esdebug', 1);
  299. if ($request->get('location')) {
  300. $request->query->set('locations', explode(',', $request->query->get('location', '')));
  301. }
  302. $listingsDebugElasticResponse = $listingManager->getListingsDebugElasticResponse($request);
  303. if ('aqarmap_api_listings_search_v4' === $request->attributes->get('_route')) {
  304. $listingsDebugElasticResponse['pagination']['totalPages'] = (float) $listingsDebugElasticResponse['pagination']['totalPages'];
  305. }
  306. $data = [
  307. 'default' => $this->mapPaginatedBody($listingsDebugElasticResponse, $listingsDebugElasticResponse['items']),
  308. ];
  309. return new JsonResponse($data);
  310. }
  311. #[Rest\Get('/api/v4/listings/search/ssr-data', name: 'aqarmap_api_get_listings_search_ssr-data', options: ['i18n' => false])]
  312. #[OA\Get(
  313. summary: 'Get listing search SSR data',
  314. description: 'Provides the pre-rendered content required for the listing search SSR page such as location hierarchy, SEO metadata and UI chips.',
  315. tags: ['ListingsSearch']
  316. )]
  317. #[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'))]
  318. #[OA\Parameter(
  319. name: 'locations[]',
  320. in: 'query',
  321. required: false,
  322. description: 'Optional list of location slugs used to resolve additional data.',
  323. schema: new OA\Schema(type: 'array', items: new OA\Items(type: 'string'))
  324. )]
  325. #[OA\Parameter(name: 'section', in: 'query', required: false, description: 'Section slug to scope the returned data.', schema: new OA\Schema(type: 'string'))]
  326. #[OA\Parameter(name: 'propertyType', in: 'query', required: false, description: 'Property type slug to scope the returned data.', schema: new OA\Schema(type: 'string'))]
  327. #[OA\Parameter(name: 'byOwnerOnly', in: 'query', required: false, description: 'Whether to limit content to owner only listings.', schema: new OA\Schema(type: 'boolean'))]
  328. #[OA\Response(
  329. response: 200,
  330. description: 'Successful response containing SSR data required by the listing search page.',
  331. content: new OA\JsonContent(
  332. type: 'object',
  333. properties: [
  334. new OA\Property(
  335. property: 'locationChildren',
  336. description: 'Hierarchy of child locations related to the requested location.',
  337. type: 'array',
  338. items: new OA\Items(type: 'object')
  339. ),
  340. new OA\Property(
  341. property: 'longTail',
  342. description: 'SEO long-tail content blocks generated for the section, property type and location.',
  343. type: 'array',
  344. items: new OA\Items(type: 'object')
  345. ),
  346. new OA\Property(
  347. property: 'customParagraph',
  348. description: 'Custom paragraph content tailored to the current search context.',
  349. type: 'array',
  350. items: new OA\Items(type: 'object')
  351. ),
  352. new OA\Property(
  353. property: 'slugResolver',
  354. description: 'Resolved slugs mapping for the provided filters.',
  355. type: 'array',
  356. items: new OA\Items(type: 'object')
  357. ),
  358. new OA\Property(
  359. property: 'faqData',
  360. description: 'Frequently asked questions related to the current search.',
  361. type: 'array',
  362. items: new OA\Items(type: 'object')
  363. ),
  364. new OA\Property(
  365. property: 'locationParents',
  366. description: 'Ordered list of parent locations for breadcrumb generation.',
  367. type: 'array',
  368. items: new OA\Items(type: 'object')
  369. ),
  370. new OA\Property(
  371. property: 'sections',
  372. description: 'Available sections that can be displayed in the SSR page.',
  373. type: 'array',
  374. items: new OA\Items(type: 'object')
  375. ),
  376. new OA\Property(
  377. property: 'propertyTypeChips',
  378. description: 'Property type chips rendered in the search interface.',
  379. type: 'array',
  380. items: new OA\Items(type: 'object')
  381. ),
  382. ]
  383. )
  384. )]
  385. #[OA\Response(response: 500, description: 'Returned when SSR data could not be generated or cached.')]
  386. public function getListingsSearchSSRData(Request $request, ListingManager $listingManager, CacheInterface $cache, SectionService $sectionService)
  387. {
  388. $cacheKey = sprintf('api_listings_search_ssr_data_%s_%s', $request->getLocale(), md5(http_build_query($request->query->all())));
  389. $cachedResponse = $cache->getItem($cacheKey);
  390. if (!$cachedResponse->isHit() || empty($cachedResponse->get())) {
  391. $customParagraph = $locationChildren = $slugResolver = $faqData = $locationParents = $longTail = [];
  392. if ($request->get('location')) {
  393. $request->query->set('locations', explode(',', $request->query->get('location', '')));
  394. }
  395. $location = $request->query->all('locations') ? $request->query->all('locations')[0] : null;
  396. $location = $this->locationRepository->findOneBy(['slug' => $location]);
  397. $section = $this->sectionRepository->findOneBy(['slug' => $request->query->get('section')]);
  398. $propertyType = $this->propertyTypeRepository->findOneBy(['slug' => $request->query->get('propertyType')]);
  399. if ($location) {
  400. $locationChildren = $listingManager->getSerializedLocationChildren($location);
  401. $locationParents = $listingManager->getLocationParents($location, $request->getLocale());
  402. }
  403. if ($section && $propertyType && $location) {
  404. $longTail = $this->seoListingSearchService->getSerializedLongTailData($section, $propertyType, $location) ?? [];
  405. }
  406. if ($section) {
  407. $slugResolver = $listingManager->getSerializedResolvedSlugs($section, $propertyType, $request->query->all('locations'));
  408. }
  409. if ($section && $propertyType) {
  410. $byOwnerOnly = filter_var($request->get('byOwnerOnly'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? false;
  411. $customParagraph = $listingManager->getSerializedCustomParagraph($section, $propertyType, $location, $byOwnerOnly) ?? [];
  412. $faqData = $listingManager->getFaqs($section, $propertyType, $location, $request->getLocale());
  413. }
  414. $response = [
  415. 'locationChildren' => !empty($locationChildren) ? json_decode((string) $locationChildren, true) : [],
  416. 'longTail' => !empty($longTail) ? json_decode((string) $longTail, true) : [],
  417. 'customParagraph' => !empty($customParagraph) ? json_decode((string) $customParagraph, true) : [],
  418. 'slugResolver' => !empty($slugResolver) ? json_decode((string) $slugResolver, true) : [],
  419. 'faqData' => $faqData,
  420. 'locationParents' => $locationParents,
  421. 'sections' => json_decode((string) $sectionService->getSerializedSections(), true),
  422. 'propertyTypeChips' => $listingManager->getListingPropertyTypesChips($request, $location, $section, $propertyType),
  423. ];
  424. $cachedResponse->set(json_encode($response));
  425. $cachedResponse->expiresAfter(3600 * 3);
  426. $cache->save($cachedResponse);
  427. }
  428. return new JsonResponse(json_decode((string) $cachedResponse->get(), true));
  429. }
  430. #[Rest\Get('/api/v4/listings/trigger-search', options: ['i18n' => false], name: 'aqarmap_api_trigger_search_listings_v4')]
  431. #[OA\Get(summary: 'Trigger listings search tracking', description: 'Dispatches search tracking and background processing events for the current listing search request.', tags: ['ListingsSearch'])]
  432. #[OA\Parameter(name: 'location', in: 'query', required: false, description: 'Comma-separated location identifiers to normalize into the locations filter.', schema: new OA\Schema(type: 'string'))]
  433. #[OA\Parameter(name: 'keywordSearch', in: 'query', required: false, description: 'Keyword query that triggers the search event tracking flow.', schema: new OA\Schema(type: 'string'))]
  434. #[OA\Response(response: 200, description: 'Search trigger event dispatched successfully.')]
  435. public function triggerSearchListings(Request $request, MessageBusInterface $messageBus)
  436. {
  437. $user = $this->getUser();
  438. if ($request->get('location')) {
  439. $request->query->set('locations', explode(',', $request->query->get('location', '')));
  440. }
  441. if ($request->get('keywordSearch')) {
  442. $this->eventDispatcher->dispatch(new SearchTriggerEvent($request));
  443. }
  444. if ($user) {
  445. $messageBus->dispatch(new Search($request->query, $user));
  446. }
  447. return new JsonResponse([
  448. 'statusCode' => Response::HTTP_OK,
  449. 'statusMessage' => 'Search triggered successfully!',
  450. ]);
  451. }
  452. private function mapPaginatedBody(array $result, array $listings): array
  453. {
  454. if (!$result['pagination']) {
  455. return [
  456. 'statusCode' => $this->getStatusCode(),
  457. 'statusMessage' => $this->getStatusMessage(),
  458. 'paginate' => [],
  459. 'data' => [],
  460. 'errors' => $this->getErrors(),
  461. ];
  462. }
  463. return [
  464. 'statusCode' => $this->getStatusCode(),
  465. 'statusMessage' => $this->getStatusMessage(),
  466. 'paginate' => $result['pagination'],
  467. 'data' => $listings,
  468. 'errors' => $this->getErrors(),
  469. ];
  470. }
  471. }