src/Aqarmap/Bundle/SearchBundle/Controller/Api/V4/ListingSearchController.php line 511
use OpenApi\Attributes as OA;use Symfony\Component\EventDispatcher\EventDispatcherInterface;use Symfony\Component\HttpFoundation\JsonResponse;use Symfony\Component\HttpFoundation\Request;use Symfony\Component\HttpFoundation\Response;use Symfony\Component\HttpKernel\Attribute\Cache;use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;use Symfony\Component\Messenger\MessageBusInterface;use Symfony\Contracts\Cache\CacheInterface;use Symfony\Contracts\Cache\ItemInterface;use Symfony\Contracts\HttpClient\HttpClientInterface;class ListingSearchController extends BaseController{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){}/*** Listings Search V4 ( Legacy code ).*/#[Rest\Get('/api/v4/listings/search-legacy', name: 'legacy_aqarmap_api_listings_search_v4', options: ['i18n' => false])]#[Rest\QueryParam(name: 'propertyType', requirements: '\d+', default: null, description: 'Property Type ID')]#[Rest\QueryParam(name: 'location', requirements: '\d+', default: null, description: 'Location ID comma spereted')]#[Rest\QueryParam(name: 'section', requirements: '\d+', default: null, description: 'Section ID')]#[Rest\QueryParam(name: 'bounds', requirements: 'South-West Latitude ,Longitude , North-East Latitude , Longitude')] // default=null,#[Rest\QueryParam(name: 'minPrice', requirements: '\d+', default: null, description: 'Minimum Prices')]#[Rest\QueryParam(name: 'maxPrice', requirements: '\d+', default: null, description: 'Maximum Prices')]#[Rest\QueryParam(name: 'minArea', requirements: '\d+', default: null, description: 'Minimum Area')]#[Rest\QueryParam(name: 'maxArea', requirements: '\d+', default: null, description: 'Maximum Area')]#[Rest\QueryParam(name: 'minFloor', requirements: '\d+', default: null, description: 'Minimum Floor')]#[Rest\QueryParam(name: 'minRoom', requirements: '\d+', default: null, description: 'Minimum Room')]#[Rest\QueryParam(name: 'floor', requirements: '\d+', default: null, description: 'Floor Number')]#[Rest\QueryParam(name: 'room', requirements: '\d+', default: null, description: 'Number of Rooms')]#[Rest\QueryParam(name: 'baths', requirements: '\d+', default: null, description: 'Number of Baths')]#[Rest\QueryParam(name: 'finishType', default: null, description: 'finish Type')]#[Rest\QueryParam(name: 'sellerRole', requirements: '\d+', default: null, description: 'sellerRole')]#[Rest\QueryParam(name: 'paymentMethod', requirements: '\d+', default: null, description: 'paymentMethod')]#[Rest\QueryParam(name: 'deliveryYear', requirements: '\d+', default: null, description: 'deliveryYear')]#[Rest\QueryParam(name: 'bath', default: null, description: 'Number of Baths')]#[Rest\QueryParam(name: 'photos', requirements: '(1)|(0)', default: '0', description: 'Get only listings with photos', strict: true, nullable: true)]#[Rest\QueryParam(name: 'isMortgage', requirements: '(1)|(0)', default: '0', description: 'Get only listings that support mortgage', strict: true, nullable: true)]#[Rest\QueryParam(name: 'eligibleForMortgage', description: 'Get listings that has mortgage Percentage', strict: true, nullable: true)]#[Rest\QueryParam(name: 'page', requirements: '\d+', default: 1, description: 'Page number, starting from 1.', nullable: true)]#[Rest\QueryParam(name: 'limit', requirements: '\d+', default: 10, description: 'Number of items per page.', nullable: true)]#[Rest\QueryParam(name: 'sort', requirements: 'price|area', default: null, description: 'Sort search results by price or area.', nullable: true)]#[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)]#[Rest\QueryParam(name: 'keywordSearch', description: "Search by keyword in listing's title, description and address")]#[Rest\QueryParam(name: 'unitOnly', requirements: '(1)|(0)', description: 'Get compound units only', nullable: true)]#[Rest\View]#[Cache(expires: '+2 hours', maxage: '+2 hours', smaxage: '+2 hours', public: true, vary: ['Accept-Language', 'X-Accept-Version', 'Accept'])]#[OA\Parameter(name: 'propertyType', description: 'Property Type ID', in: 'query', required: false)]#[OA\Parameter(name: 'location', description: 'Location ID comma spereted', in: 'query', required: false)]#[OA\Parameter(name: 'section', description: 'Section ID', in: 'query', required: false)]#[OA\Parameter(name: 'bounds', description: 'Map bounds (example: 24.6275450,46.6363017,24.6977461,46.817232)', in: 'query', required: false)]#[OA\Parameter(name: 'minPrice', description: 'Minimum Prices', in: 'query', required: false)]#[OA\Parameter(name: 'maxPrice', description: 'Maximum Prices', in: 'query', required: false)]#[OA\Parameter(name: 'minArea', description: 'Minimum Area', in: 'query', required: false)]#[OA\Parameter(name: 'maxArea', description: 'Maximum Area', in: 'query', required: false)]#[OA\Parameter(name: 'minFloor', description: 'Minimum Floor', in: 'query', required: false)]#[OA\Parameter(name: 'minRoom', description: 'Minimum Room', in: 'query', required: false)]#[OA\Parameter(name: 'floor', description: 'Floor Number', in: 'query', required: false)]#[OA\Parameter(name: 'room', description: 'Number of Rooms', in: 'query', required: false)]#[OA\Parameter(name: 'baths', description: 'Number of Baths', in: 'query', required: false)]#[OA\Parameter(name: 'finishType', description: 'finish Type', in: 'query', required: false)]#[OA\Parameter(name: 'sellerRole', description: 'sellerRole', in: 'query', required: false)]#[OA\Parameter(name: 'paymentMethod', description: 'paymentMethod', in: 'query', required: false)]#[OA\Parameter(name: 'deliveryYear', description: 'deliveryYear', in: 'query', required: false)]#[OA\Parameter(name: 'bath', description: 'Number of Baths', in: 'query', required: false)]#[OA\Parameter(name: 'photos', description: 'Get only listings with photos', in: 'query', required: false)]#[OA\Parameter(name: 'isMortgage', description: 'Get only listings that support mortgage', in: 'query', required: false)]#[OA\Parameter(name: 'eligibleForMortgage', description: 'Get listings that has mortgage Percentage', in: 'query', required: false)]#[OA\Parameter(name: 'page', description: 'Page number, starting from 1.', in: 'query', required: false)]#[OA\Parameter(name: 'limit', description: 'Number of items per page.', in: 'query', required: false)]#[OA\Parameter(name: 'sort', description: 'Sort search results by price or area.', in: 'query', required: false)]#[OA\Parameter(name: 'direction', description: 'Ascending (A to Z, 0 to 9), Descending (Z to A, 9 to 0)', in: 'query', required: false)]#[OA\Parameter(name: 'keywordSearch', description: "Search by keyword in listing's title, description and address", in: 'query', required: false)]#[OA\Parameter(name: 'unitOnly', description: 'Get compound units only', in: 'query', required: false)]#[OA\Response(response: 500, description: 'Returned when something went wrong, for example if you entered non existing propertyType ID')]public function getSearchListings(Request $request, CacheInterface $cache): Response{$criteria = $this->builderDirector->build($request)->getResult();$locations = explode(',', $request->query->get('location', ''));$request->query->set('locations', $locations);if (!empty($criteria['keywordSearch'])) {$this->eventDispatcher->dispatch(new SearchTriggerEvent($request));}if ($this->getUser() instanceof User) {$this->messageBus->dispatch(new Search($request->query, $this->getUser()));}$criteria['status'] = ListingStatus::LIVE;$cacheKey = sprintf('api_v4_listings_search_%s_%s', $request->getLocale(), md5(http_build_query($request->query->all())));$cachedSerialize = $cache->getItem($cacheKey);if (!$cachedSerialize->isHit() || empty($cachedSerialize->get())) {$result = $this->mediator->start($criteria)->getResults();$mapped = new ListingDataMapper();$mapped->setMapListingAttributes(true);$mapped->setMapListingMainPhoto(true);$mapped->setMapListingPhotos(true);$data = ['default' => $this->makeMappedPaginatedBody($result['searchResults'], $mapped),'related' => [],];$data = $this->serializer->serialize($data,'json',SerializationContext::create()->setGroups('SearchV4')->enableMaxDepthChecks());$cachedSerialize->set($data);$cachedSerialize->expiresAfter(3600 * 3);$cache->save($cachedSerialize);}return new Response($cachedSerialize->get());}/*** Related Listings Search V4.*/#[Rest\Get('/api/v4/listings/search/related', name: 'aqarmap_api_get_related_results_v4', options: ['i18n' => false])]#[Rest\QueryParam(name: 'propertyType', requirements: '\d+', default: null, description: 'Property Type ID')]#[Rest\QueryParam(name: 'location', requirements: '\d+', default: null, description: 'Location ID comma spereted')]#[Rest\QueryParam(name: 'section', requirements: '\d+', default: null, description: 'Section ID')]#[Rest\QueryParam(name: 'page', requirements: '\d+', default: 1, description: 'Page number, starting from 1.', nullable: true)]#[Rest\QueryParam(name: 'limit', requirements: '\d+', default: 10, description: 'Number of items per page.', nullable: true)]#[Rest\QueryParam(name: 'sort', requirements: 'price|area', default: null, description: 'Sort search results by price or area.', nullable: true)]#[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)]#[Rest\View(serializerGroups: ['DefaultV4', 'SearchV4'])]#[OA\Parameter(name: 'propertyType', description: 'Property Type ID', in: 'query', required: false)]#[OA\Parameter(name: 'location', description: 'Location ID comma seperated', in: 'query', required: false)]#[OA\Parameter(name: 'section', description: 'Section ID', in: 'query', required: false)]#[OA\Parameter(name: 'page', description: 'Page number, starting from 1.', in: 'query', required: false)]#[OA\Parameter(name: 'limit', description: 'Number of items per page.', in: 'query', required: false)]#[OA\Parameter(name: 'sort', description: 'Sort search results by price or area.', in: 'query', required: false)]#[OA\Parameter(name: 'direction', description: 'Ascending (A to Z, 0 to 9), Descending (Z to A, 9 to 0)', in: 'query', required: false)]#[OA\Response(response: 500, description: 'Returned when something went wrong, for example if you entered non existing propertyType ID')]public function getRelatedSearchListings(Request $request, HttpClientInterface $searchClient, SerializerInterface $jmsSerializer, ListingRepository $listingRepository): JsonResponse{$response = $searchClient->request('GET', '/api/listing/nearest', ['query' => $request->query->all(),'headers' => ['Accept-Language' => $request->getPreferredLanguage(),],]);if (200 === $response->getStatusCode()) {$data = $response->toArray();if (isset($data['data']) && is_array($data['data'])) {foreach ($data['data'] as &$item) {if (isset($item['listings']) && is_array($item['listings'])) {$listings = $listingRepository->getListingsByIds(array_column($item['listings'], 'id'))->getQuery()->getResult();$context = SerializationContext::create()->setGroups(['DefaultV4', 'SearchV4']);$item['listings'] = json_decode($jmsSerializer->serialize($listings, 'json', $context),true);}}}return $this->json($data);}return $this->json(['error' => 'Failed to fetch nearest search listings',], $response->getStatusCode());}#[Rest\Get('/api/v4/listings', name: 'aqarmap_api_get_listings', options: ['i18n' => false])]#[Rest\Get('/api/v4/listings/search', name: 'aqarmap_api_listings_search_v4', options: ['i18n' => false])]#[Rest\QueryParam(name: 'personalizedSearch', requirements: '(1)|(0)', default: '0', description: 'Enable personalized search', strict: true, nullable: true)]#[OA\Parameter(name: 'personalizedSearch', description: 'Enable personalized search (requires active subscription)', in: 'query', required: false)]public function getListings(Request $request, ListingRepository $listingRepository, ListingManager $listingManager, CacheInterface $cache, MessageBusInterface $messageBus){/** @var UserInterface $user */$user = $this->getUser();$hasSearchScoringRole = $user && $user->hasRole('ROLE_SEARCH_SCORING');if (!$hasSearchScoringRole && ($request->get('esdebug') || $request->get('scoredebug'))) {$request->query->remove('esdebug');$request->query->remove('scoredebug');}if ($request->get('location')) {$request->query->set('locations', explode(',', $request->query->get('location', '')));}$personalizedSearch = $request->query->getBoolean('personalizedSearch', false);if ($personalizedSearch) {if (!$user instanceof User) {throw new UnauthorizedHttpException('Personalized search requires an active session. Please log in.');}if (!$user->hasSubscriptionPlan()) {throw new AccessDeniedHttpException('Active subscription required for personalized search.');}$request->query->set('personalizedSearch', true);$request->query->set('personalizedForUser', $user->getId());}if ($personalizedSearch) {$data = $this->getSerializedListingSearchResults($request, $listingRepository, $listingManager);} else {$cacheKey = sprintf('api_listings_search_v4_%s_%s', $request->getLocale(), md5(http_build_query($request->query->all())));$data = $cache->get($cacheKey,function(ItemInterface $item) use ($request, $listingRepository, $listingManager) {$item->expiresAfter(3600 * 3);return $this->getSerializedListingSearchResults($request, $listingRepository, $listingManager);});}return new Response($data);}private function getSerializedListingSearchResults(Request $request, ListingRepository $listingRepository, ListingManager $listingManager): string{$listingsElasticResponse = $listingManager->getListingsElasticResponse($request);$listingsQueryBuilder = $listingRepository->getListingsByIds(array_column($listingsElasticResponse['items'], 'id'));if ('aqarmap_api_listings_search_v4' === $request->attributes->get('_route')) {$listingsElasticResponse['pagination']['totalPages'] = (float) $listingsElasticResponse['pagination']['totalPages'];}$listings = $listingsQueryBuilder->getQuery()->getResult();$data = ['default' => $this->mapPaginatedBody($listingsElasticResponse, $listings),'related' => [],];return $this->serializer->serialize($data,'json',SerializationContext::create()->setGroups('SearchV4')->enableMaxDepthChecks());}#[Rest\Get('/api/v4/listings/debug', options: ['i18n' => false], name: 'aqarmap_api_get_listings_debug')]public function getListingsEsDebug(Request $request, ListingManager $listingManager){/** @var UserInterface $user */$user = $this->getUser();if (!($user && $user->hasRole('ROLE_SEARCH_SCORING'))) {throw new UnauthorizedHttpException();}$request->query->set('esdebug', 1);if ($request->get('location')) {$request->query->set('locations', explode(',', $request->query->get('location', '')));}$listingsDebugElasticResponse = $listingManager->getListingsDebugElasticResponse($request);if ('aqarmap_api_listings_search_v4' === $request->attributes->get('_route')) {$listingsDebugElasticResponse['pagination']['totalPages'] = (float) $listingsDebugElasticResponse['pagination']['totalPages'];}$data = ['default' => $this->mapPaginatedBody($listingsDebugElasticResponse, $listingsDebugElasticResponse['items']),];return new JsonResponse($data);}#[Rest\Get('/api/v4/listings/search/ssr-data', name: 'aqarmap_api_get_listings_search_ssr-data', options: ['i18n' => false])]#[OA\Get(summary: 'Get listing search SSR data',description: 'Provides the pre-rendered content required for the listing search SSR page such as location hierarchy, SEO metadata and UI chips.',tags: ['Listings', 'Search'])]#[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'))]#[OA\Parameter(name: 'locations[]',in: 'query',required: false,description: 'Optional list of location slugs used to resolve additional data.',schema: new OA\Schema(type: 'array', items: new OA\Items(type: 'string')))]#[OA\Parameter(name: 'section', in: 'query', required: false, description: 'Section slug to scope the returned data.', schema: new OA\Schema(type: 'string'))]#[OA\Parameter(name: 'propertyType', in: 'query', required: false, description: 'Property type slug to scope the returned data.', schema: new OA\Schema(type: 'string'))]#[OA\Parameter(name: 'byOwnerOnly', in: 'query', required: false, description: 'Whether to limit content to owner only listings.', schema: new OA\Schema(type: 'boolean'))]#[OA\Response(response: 200,description: 'Successful response containing SSR data required by the listing search page.',content: new OA\JsonContent(type: 'object',properties: [new OA\Property(property: 'locationChildren',description: 'Hierarchy of child locations related to the requested location.',type: 'array',items: new OA\Items(type: 'object')),new OA\Property(property: 'longTail',description: 'SEO long-tail content blocks generated for the section, property type and location.',type: 'array',items: new OA\Items(type: 'object')),new OA\Property(property: 'customParagraph',description: 'Custom paragraph content tailored to the current search context.',type: 'array',items: new OA\Items(type: 'object')),new OA\Property(property: 'slugResolver',description: 'Resolved slugs mapping for the provided filters.',type: 'array',items: new OA\Items(type: 'object')),new OA\Property(property: 'faqData',description: 'Frequently asked questions related to the current search.',type: 'array',items: new OA\Items(type: 'object')),new OA\Property(property: 'locationParents',description: 'Ordered list of parent locations for breadcrumb generation.',type: 'array',items: new OA\Items(type: 'object')),new OA\Property(property: 'sections',description: 'Available sections that can be displayed in the SSR page.',type: 'array',items: new OA\Items(type: 'object')),new OA\Property(property: 'propertyTypeChips',description: 'Property type chips rendered in the search interface.',type: 'array',items: new OA\Items(type: 'object')),]))]#[OA\Response(response: 500, description: 'Returned when SSR data could not be generated or cached.')]public function getListingsSearchSSRData(Request $request, ListingManager $listingManager, CacheInterface $cache, SectionService $sectionService){$cacheKey = sprintf('api_listings_search_ssr_data_%s_%s', $request->getLocale(), md5(http_build_query($request->query->all())));$cachedResponse = $cache->getItem($cacheKey);if (!$cachedResponse->isHit() || empty($cachedResponse->get())) {$customParagraph = $locationChildren = $slugResolver = $faqData = $locationParents = [];if ($request->get('location')) {$request->query->set('locations', explode(',', $request->query->get('location', '')));}$location = $request->query->all('locations') ? $request->query->all('locations')[0] : null;$location = $this->locationRepository->findOneBy(['slug' => $location]);$section = $this->sectionRepository->findOneBy(['slug' => $request->query->get('section')]);$propertyType = $this->propertyTypeRepository->findOneBy(['slug' => $request->query->get('propertyType')]);if ($location) {$locationChildren = $listingManager->getSerializedLocationChildren($location);$locationParents = $listingManager->getLocationParents($location, $request->getLocale());}if ($section && $propertyType && $location) {$longTail = $this->seoListingSearchService->getSerializedLongTailData($section, $propertyType, $location) ?? [];}if ($section && $propertyType) {$byOwnerOnly = filter_var($request->get('byOwnerOnly'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? false;$customParagraph = $listingManager->getSerializedCustomParagraph($section, $propertyType, $location, $byOwnerOnly) ?? [];$slugResolver = $listingManager->getSerializedResolvedSlugs($section, $propertyType, $request->query->all('locations'));$faqData = $listingManager->getFaqs($section, $propertyType, $location, $request->getLocale());}$response = ['locationChildren' => !empty($locationChildren) ? json_decode((string) $locationChildren, true) : [],'longTail' => !empty($longTail) ? json_decode((string) $longTail, true) : [],'customParagraph' => !empty($customParagraph) ? json_decode((string) $customParagraph, true) : [],'slugResolver' => !empty($slugResolver) ? json_decode((string) $slugResolver, true) : [],'faqData' => $faqData,'locationParents' => $locationParents,'sections' => json_decode((string) $sectionService->getSerializedSections(), true),'propertyTypeChips' => $listingManager->getListingPropertyTypesChips($request, $location, $section, $propertyType),];$cachedResponse->set(json_encode($response));$cachedResponse->expiresAfter(3600 * 3);$cache->save($cachedResponse);}return new JsonResponse(json_decode((string) $cachedResponse->get(), true));}#[Rest\Get('/api/v4/listings/trigger-search', options: ['i18n' => false], name: 'aqarmap_api_trigger_search_listings_v4')]public function triggerSearchListings(Request $request, MessageBusInterface $messageBus){$user = $this->getUser();if ($request->get('location')) {$request->query->set('locations', explode(',', $request->query->get('location', '')));}if ($request->get('keywordSearch')) {$this->eventDispatcher->dispatch(new SearchTriggerEvent($request));}if ($user) {$messageBus->dispatch(new Search($request->query, $user));}return new JsonResponse(['statusCode' => Response::HTTP_OK,'statusMessage' => 'Search triggered successfully!',]);}private function mapPaginatedBody(array $result, array $listings): array{if (!$result['pagination']) {return ['statusCode' => $this->getStatusCode(),'statusMessage' => $this->getStatusMessage(),'paginate' => [],'data' => [],'errors' => $this->getErrors(),];}return ['statusCode' => $this->getStatusCode(),'statusMessage' => $this->getStatusMessage(),'paginate' => $result['pagination'],'data' => $listings,'errors' => $this->getErrors(),];}}