1556 lines
68 KiB
PHP
1556 lines
68 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Livewire;
|
|
|
|
use App\Helpers\SearchOptimizationHelper;
|
|
use App\Models\Locations\City;
|
|
use App\Overrides\Matchish\ScoutElasticSearch\ElasticSearch\EloquentHitsIteratorAggregate;
|
|
use App\Traits\ProfilePermissionTrait;
|
|
use Elastic\Elasticsearch\Client;
|
|
use Elastic\Elasticsearch\ClientBuilder;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Livewire\Component;
|
|
use Matchish\ScoutElasticSearch\MixedSearch;
|
|
use ONGR\ElasticsearchDSL\Highlight\Highlight;
|
|
use ONGR\ElasticsearchDSL\Query\Compound\BoolQuery;
|
|
use ONGR\ElasticsearchDSL\Query\FullText\MultiMatchQuery;
|
|
use ONGR\ElasticsearchDSL\Search;
|
|
|
|
class MainSearchBar extends Component
|
|
{
|
|
use ProfilePermissionTrait;
|
|
|
|
public $search;
|
|
public $suggestions = [];
|
|
public $fetchedResults = [];
|
|
public $results = [];
|
|
public int $total = 0;
|
|
public $showResults = false;
|
|
|
|
// Store the raw search term separately from display suggestions
|
|
private $rawSearchTerm = '';
|
|
|
|
// Map suggestions to their original search contexts
|
|
private $suggestionMap = [];
|
|
|
|
// Location-based search properties
|
|
public $profileLocation = null;
|
|
public $locationHierarchy = [];
|
|
|
|
public function mount()
|
|
{
|
|
$this->loadProfileLocation();
|
|
}
|
|
|
|
public function render()
|
|
{
|
|
return view('livewire.main-search-bar');
|
|
}
|
|
|
|
public function selectSuggestion($suggestionIndex)
|
|
{
|
|
\Log::info('selectSuggestion called', ['index' => $suggestionIndex, 'suggestions' => $this->suggestions]);
|
|
|
|
// Get the suggestion text from the suggestions collection
|
|
$suggestion = $this->suggestions[$suggestionIndex] ?? null;
|
|
|
|
\Log::info('Found suggestion', ['suggestion' => $suggestion]);
|
|
|
|
if ($suggestion && isset($suggestion['text'])) {
|
|
// Simply set the search to the suggestion text
|
|
$this->search = $suggestion['text'];
|
|
|
|
\Log::info('Set search to', ['search' => $this->search]);
|
|
|
|
// Clear suggestions
|
|
$this->suggestions = collect();
|
|
|
|
// Execute search and redirect
|
|
$this->updatedSearch();
|
|
return $this->showSearchResults();
|
|
}
|
|
|
|
\Log::warning('selectSuggestion failed - no valid suggestion found');
|
|
}
|
|
|
|
private function loadProfileLocation()
|
|
{
|
|
try {
|
|
if (function_exists('getActiveProfile')) {
|
|
$activeProfile = getActiveProfile();
|
|
if ($activeProfile && $activeProfile->locations) {
|
|
$this->profileLocation = $activeProfile->locations->first();
|
|
|
|
if ($this->profileLocation) {
|
|
$this->locationHierarchy = $this->profileLocation->getCompleteHierarchy();
|
|
|
|
}
|
|
}
|
|
}
|
|
} catch (\Exception $e) {
|
|
\Log::warning('MainSearchBar: Failed to load profile location: ' . $e->getMessage());
|
|
$this->profileLocation = null;
|
|
$this->locationHierarchy = [];
|
|
}
|
|
}
|
|
|
|
private function getLocationName($location)
|
|
{
|
|
if (!$location) {
|
|
return null;
|
|
}
|
|
|
|
// If location is an array or collection, return null
|
|
if (is_array($location) || ($location instanceof \Illuminate\Support\Collection)) {
|
|
return null;
|
|
}
|
|
|
|
$locale = app()->getLocale();
|
|
|
|
// Try to get translated name
|
|
try {
|
|
// Check if location has a 'name' property that is an array of translations
|
|
if (isset($location->name) && is_array($location->name)) {
|
|
// Find translation matching current locale
|
|
foreach ($location->name as $translation) {
|
|
if (isset($translation['locale']) && $translation['locale'] === $locale && isset($translation['name'])) {
|
|
return $translation['name'];
|
|
}
|
|
}
|
|
// Fall back to English if current locale not found
|
|
foreach ($location->name as $translation) {
|
|
if (isset($translation['locale']) && $translation['locale'] === 'en' && isset($translation['name'])) {
|
|
return $translation['name'];
|
|
}
|
|
}
|
|
// Fall back to first translation
|
|
if (isset($location->name[0]['name'])) {
|
|
return $location->name[0]['name'];
|
|
}
|
|
}
|
|
|
|
// Try to get from translations relationship
|
|
if (method_exists($location, 'getRelation') && $location->relationLoaded('translations')) {
|
|
$translations = $location->getRelation('translations');
|
|
if ($translations && $translations->count() > 0) {
|
|
$translation = $translations->where('locale', $locale)->first();
|
|
if ($translation && isset($translation->name)) {
|
|
return $translation->name;
|
|
}
|
|
}
|
|
}
|
|
} catch (\Exception $e) {
|
|
\Log::warning('MainSearchBar: Failed to get translated location name', [
|
|
'error' => $e->getMessage()
|
|
]);
|
|
}
|
|
|
|
// Fall back to default name property if it's a string
|
|
if (isset($location->name) && is_string($location->name)) {
|
|
return $location->name;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function getProfileSearchFields(): array
|
|
{
|
|
$locale = app()->getLocale();
|
|
|
|
// Text/tag fields — use per-field language analyzers (no override)
|
|
return [
|
|
'cyclos_skills^' . timebank_config('main_search_bar.boosted_fields.profile.cyclos_skills', '1'),
|
|
'tags.contexts.tags.name_' . $locale . '^' . timebank_config('main_search_bar.boosted_fields.profile.name', '1'),
|
|
'tags.contexts.categories.name_' . $locale . '^' . timebank_config('main_search_bar.boosted_fields.profile.tag_categories', '1'),
|
|
'motivation_' . $locale . '^' . timebank_config('main_search_bar.boosted_fields.profile.motivation', '1'),
|
|
'about_short_' . $locale . '^' . timebank_config('main_search_bar.boosted_fields.profile.about_short', '1'),
|
|
'about_' . $locale . '^' . timebank_config('main_search_bar.boosted_fields.profile.about', '1'),
|
|
];
|
|
}
|
|
|
|
// Name fields use an edge n-gram analyzer at index time; standard analyzer must be used at query time.
|
|
private function getProfileNameFields(): array
|
|
{
|
|
return [
|
|
'name^' . timebank_config('main_search_bar.boosted_fields.profile.name', '1'),
|
|
'full_name^' . timebank_config('main_search_bar.boosted_fields.profile.full_name', '1'),
|
|
];
|
|
}
|
|
|
|
// Location fields are searchable (e.g. "computer brussels" matches city) using standard analyzer.
|
|
// Kept separate from proximity boosts in addLocationBoosts() which boost by the searcher's own location.
|
|
private function getProfileLocationFields(): array
|
|
{
|
|
return [
|
|
'locations.district^' . timebank_config('main_search_bar.boosted_fields.profile.district', '1'),
|
|
'locations.city^' . timebank_config('main_search_bar.boosted_fields.profile.city', '1'),
|
|
'locations.division^' . timebank_config('main_search_bar.boosted_fields.profile.division', '1'),
|
|
'locations.country^' . timebank_config('main_search_bar.boosted_fields.profile.country', '1'),
|
|
];
|
|
}
|
|
|
|
// Cross-fields query covers name + location fields only.
|
|
// This rewards profiles where different query terms match across name/location
|
|
// (e.g. "computer" in tags AND "brussels" in city).
|
|
// Free-text fields (about, cyclos_skills) are intentionally excluded to avoid
|
|
// inflating scores for incidental word mentions in profile text.
|
|
private function getProfileCrossFields(): array
|
|
{
|
|
return [
|
|
'name',
|
|
'full_name',
|
|
'locations.city',
|
|
'locations.district',
|
|
'locations.division',
|
|
'locations.country',
|
|
];
|
|
}
|
|
private function getPostSearchFields(): array
|
|
{
|
|
$locale = app()->getLocale();
|
|
|
|
return [
|
|
|
|
"post_translations.title_{$locale}^" . timebank_config('main_search_bar.boosted_fields.post.title', '3'),
|
|
"post_translations.content_{$locale}^" . timebank_config('main_search_bar.boosted_fields.post.content', '1'),
|
|
"post_translations.excerpt_{$locale}^" . timebank_config('main_search_bar.boosted_fields.post.excerpt', '2'), // Add excerpt
|
|
"post_category.names.name_{$locale}^" . timebank_config('main_search_bar.boosted_fields.post.post_category_name', '2'),
|
|
// 'locations.district^' . timebank_config('main_search_bar.boosted_fields.profile.district', '1'),
|
|
// 'locations.city^' . timebank_config('main_search_bar.boosted_fields.profile.city', '1'),
|
|
// 'locations.division^' . timebank_config('main_search_bar.boosted_fields.profile.division', '1'),
|
|
// 'locations.country^' . timebank_config('main_search_bar.boosted_fields.profile.country', '1'),
|
|
];
|
|
}
|
|
|
|
private function getProfileHighlightFields(): array
|
|
{
|
|
$locale = app()->getLocale();
|
|
|
|
// Apply global highlight settings to each field
|
|
$highlightOptions = [
|
|
'fragment_size' => timebank_config('main_search_bar.search.fragment_size', 80),
|
|
'number_of_fragments' => timebank_config('main_search_bar.search.number_of_fragments', 1),
|
|
'fragmenter' => timebank_config('main_search_bar.search.fragmenter', 'span'),
|
|
'order' => timebank_config('main_search_bar.search.order', 'score'),
|
|
];
|
|
|
|
return [
|
|
// Content fields only - no location fields for highlighting
|
|
'name' => $highlightOptions,
|
|
'full_name' => $highlightOptions,
|
|
'about_short_' . $locale => $highlightOptions,
|
|
'about_' . $locale => $highlightOptions,
|
|
'cyclos_skills' => $highlightOptions,
|
|
'tags.contexts.tags.name_' . $locale => $highlightOptions,
|
|
'tags.contexts.categories.name_' . $locale => $highlightOptions,
|
|
// Location fields removed from highlighting (still used for search boosting and ordering)
|
|
];
|
|
}
|
|
|
|
private function getPostHighlightFields(): array
|
|
{
|
|
$locale = app()->getLocale();
|
|
|
|
// Apply global highlight settings to each field
|
|
$highlightOptions = [
|
|
'fragment_size' => timebank_config('main_search_bar.search.fragment_size', 80),
|
|
'number_of_fragments' => timebank_config('main_search_bar.search.number_of_fragments', 1),
|
|
'fragmenter' => timebank_config('main_search_bar.search.fragmenter', 'span'),
|
|
'order' => timebank_config('main_search_bar.search.order', 'score'),
|
|
];
|
|
|
|
return [
|
|
// Content fields only - no location fields for highlighting
|
|
"post_translations.title_{$locale}" => $highlightOptions,
|
|
"post_translations.content_{$locale}" => $highlightOptions,
|
|
"post_translations.excerpt_{$locale}" => $highlightOptions,
|
|
"post_category.names.name_{$locale}" => $highlightOptions,
|
|
// Location fields removed from highlighting (still used for search boosting and ordering)
|
|
];
|
|
}
|
|
|
|
private function getCallHighlightFields(): array
|
|
{
|
|
$locale = app()->getLocale();
|
|
|
|
$highlightOptions = [
|
|
'fragment_size' => timebank_config('main_search_bar.search.fragment_size', 80),
|
|
'number_of_fragments' => timebank_config('main_search_bar.search.number_of_fragments', 1),
|
|
'fragmenter' => timebank_config('main_search_bar.search.fragmenter', 'span'),
|
|
'order' => timebank_config('main_search_bar.search.order', 'score'),
|
|
];
|
|
|
|
$fields = [
|
|
"tag.name_{$locale}" => $highlightOptions,
|
|
"call_translations.content_{$locale}" => $highlightOptions,
|
|
];
|
|
foreach (['en', 'nl', 'fr', 'de', 'es'] as $otherLocale) {
|
|
if ($otherLocale !== $locale) {
|
|
$fields["tag.name_{$otherLocale}"] = $highlightOptions;
|
|
$fields["call_translations.content_{$otherLocale}"] = $highlightOptions;
|
|
}
|
|
}
|
|
return $fields;
|
|
}
|
|
|
|
public function updatedSearch()
|
|
{
|
|
// Store the raw search term before any processing
|
|
$this->rawSearchTerm = $this->search;
|
|
|
|
// Process the search term
|
|
$search = $this->search;
|
|
// Allow Unicode letters (including German umlauts, Spanish accents, etc.), numbers, and spaces
|
|
$search = preg_replace('/[^\p{L}\p{N}\s]/u', '', $search);
|
|
$search = rtrim($search);
|
|
|
|
if (strlen($search) < 2) {
|
|
$this->suggestions = collect();
|
|
$this->fetchedResults = [];
|
|
$this->suggestionMap = [];
|
|
return;
|
|
}
|
|
|
|
$cleanSearch = trim(str_replace('*', '', $search));
|
|
|
|
try {
|
|
// Use MixedSearch with proper highlighting
|
|
$currentTime = now()->toISOString();
|
|
$locale = app()->getLocale();
|
|
|
|
// Create the main bool query
|
|
$mainBoolQuery = new BoolQuery();
|
|
|
|
// Profile queries use three separate sub-queries combined with minimum_should_match:1:
|
|
// 1. Text/tag fields — language-specific analyzers (education->educ stem etc.)
|
|
// 2. Name fields — standard analyzer (edge n-gram index, bypass n-gram at query time)
|
|
// 3. Location fields — standard analyzer (so "brussels" matches the city field)
|
|
// A separate exact-name phrase SHOULD clause (^10) boosts profiles whose name matches exactly.
|
|
// addLocationBoosts() adds proximity bonuses based on the searcher's own location.
|
|
$exactNameFields = ['name^10', 'full_name^10'];
|
|
$searchType = timebank_config('main_search_bar.search.type', 'best_fields');
|
|
|
|
$userMatchBool = new BoolQuery();
|
|
$userMatchBool->addParameter('minimum_should_match', 1);
|
|
$userTextQuery = new MultiMatchQuery($this->getProfileSearchFields(), $cleanSearch);
|
|
$userTextQuery->addParameter('type', $searchType);
|
|
$userTextQuery->addParameter('boost', timebank_config('main_search_bar.boosted_models.user', 1));
|
|
$userMatchBool->add($userTextQuery, BoolQuery::SHOULD);
|
|
$userNameQuery = new MultiMatchQuery($this->getProfileNameFields(), $cleanSearch);
|
|
$userNameQuery->addParameter('type', 'best_fields');
|
|
$userNameQuery->addParameter('analyzer', 'standard');
|
|
$userMatchBool->add($userNameQuery, BoolQuery::SHOULD);
|
|
$userLocationQuery = new MultiMatchQuery($this->getProfileLocationFields(), $cleanSearch);
|
|
$userLocationQuery->addParameter('type', 'best_fields');
|
|
$userLocationQuery->addParameter('analyzer', 'standard');
|
|
$userLocationQuery->addParameter('boost', 3.0);
|
|
$userMatchBool->add($userLocationQuery, BoolQuery::SHOULD);
|
|
$userCrossQuery = new MultiMatchQuery($this->getProfileCrossFields(), $cleanSearch);
|
|
$userCrossQuery->addParameter('type', 'cross_fields');
|
|
$userCrossQuery->addParameter('analyzer', 'standard');
|
|
$userCrossQuery->addParameter('boost', 5.0);
|
|
$userMatchBool->add($userCrossQuery, BoolQuery::SHOULD);
|
|
$userBoolQuery = new BoolQuery();
|
|
$userBoolQuery->add(new \ONGR\ElasticsearchDSL\Query\TermLevel\TermQuery('__class_name', 'App\Models\User'), BoolQuery::MUST);
|
|
$userBoolQuery->add($userMatchBool, BoolQuery::MUST);
|
|
$userExactNameQuery = new MultiMatchQuery($exactNameFields, $cleanSearch);
|
|
$userExactNameQuery->addParameter('type', 'phrase');
|
|
$userExactNameQuery->addParameter('analyzer', 'standard');
|
|
$userBoolQuery->add($userExactNameQuery, BoolQuery::SHOULD);
|
|
$this->addLocationBoosts($userBoolQuery);
|
|
|
|
$orgMatchBool = new BoolQuery();
|
|
$orgMatchBool->addParameter('minimum_should_match', 1);
|
|
$orgTextQuery = new MultiMatchQuery($this->getProfileSearchFields(), $cleanSearch);
|
|
$orgTextQuery->addParameter('type', $searchType);
|
|
$orgTextQuery->addParameter('boost', timebank_config('main_search_bar.boosted_models.organization', 3));
|
|
$orgMatchBool->add($orgTextQuery, BoolQuery::SHOULD);
|
|
$orgNameQuery = new MultiMatchQuery($this->getProfileNameFields(), $cleanSearch);
|
|
$orgNameQuery->addParameter('type', 'best_fields');
|
|
$orgNameQuery->addParameter('analyzer', 'standard');
|
|
$orgMatchBool->add($orgNameQuery, BoolQuery::SHOULD);
|
|
$orgLocationQuery = new MultiMatchQuery($this->getProfileLocationFields(), $cleanSearch);
|
|
$orgLocationQuery->addParameter('type', 'best_fields');
|
|
$orgLocationQuery->addParameter('analyzer', 'standard');
|
|
$orgLocationQuery->addParameter('boost', 3.0);
|
|
$orgMatchBool->add($orgLocationQuery, BoolQuery::SHOULD);
|
|
$orgCrossQuery = new MultiMatchQuery($this->getProfileCrossFields(), $cleanSearch);
|
|
$orgCrossQuery->addParameter('type', 'cross_fields');
|
|
$orgCrossQuery->addParameter('analyzer', 'standard');
|
|
$orgCrossQuery->addParameter('boost', 5.0);
|
|
$orgMatchBool->add($orgCrossQuery, BoolQuery::SHOULD);
|
|
$orgBoolQuery = new BoolQuery();
|
|
$orgBoolQuery->add(new \ONGR\ElasticsearchDSL\Query\TermLevel\TermQuery('__class_name', 'App\Models\Organization'), BoolQuery::MUST);
|
|
$orgBoolQuery->add($orgMatchBool, BoolQuery::MUST);
|
|
$orgExactNameQuery = new MultiMatchQuery($exactNameFields, $cleanSearch);
|
|
$orgExactNameQuery->addParameter('type', 'phrase');
|
|
$orgExactNameQuery->addParameter('analyzer', 'standard');
|
|
$orgBoolQuery->add($orgExactNameQuery, BoolQuery::SHOULD);
|
|
$this->addLocationBoosts($orgBoolQuery);
|
|
|
|
$bankMatchBool = new BoolQuery();
|
|
$bankMatchBool->addParameter('minimum_should_match', 1);
|
|
$bankTextQuery = new MultiMatchQuery($this->getProfileSearchFields(), $cleanSearch);
|
|
$bankTextQuery->addParameter('type', $searchType);
|
|
$bankTextQuery->addParameter('boost', timebank_config('main_search_bar.boosted_models.bank', 3));
|
|
$bankMatchBool->add($bankTextQuery, BoolQuery::SHOULD);
|
|
$bankNameQuery = new MultiMatchQuery($this->getProfileNameFields(), $cleanSearch);
|
|
$bankNameQuery->addParameter('type', 'best_fields');
|
|
$bankNameQuery->addParameter('analyzer', 'standard');
|
|
$bankMatchBool->add($bankNameQuery, BoolQuery::SHOULD);
|
|
$bankLocationQuery = new MultiMatchQuery($this->getProfileLocationFields(), $cleanSearch);
|
|
$bankLocationQuery->addParameter('type', 'best_fields');
|
|
$bankLocationQuery->addParameter('analyzer', 'standard');
|
|
$bankLocationQuery->addParameter('boost', 3.0);
|
|
$bankMatchBool->add($bankLocationQuery, BoolQuery::SHOULD);
|
|
$bankCrossQuery = new MultiMatchQuery($this->getProfileCrossFields(), $cleanSearch);
|
|
$bankCrossQuery->addParameter('type', 'cross_fields');
|
|
$bankCrossQuery->addParameter('analyzer', 'standard');
|
|
$bankCrossQuery->addParameter('boost', 5.0);
|
|
$bankMatchBool->add($bankCrossQuery, BoolQuery::SHOULD);
|
|
$bankBoolQuery = new BoolQuery();
|
|
$bankBoolQuery->add(new \ONGR\ElasticsearchDSL\Query\TermLevel\TermQuery('__class_name', 'App\Models\Bank'), BoolQuery::MUST);
|
|
$bankBoolQuery->add($bankMatchBool, BoolQuery::MUST);
|
|
$bankExactNameQuery = new MultiMatchQuery($exactNameFields, $cleanSearch);
|
|
$bankExactNameQuery->addParameter('type', 'phrase');
|
|
$bankExactNameQuery->addParameter('analyzer', 'standard');
|
|
$bankBoolQuery->add($bankExactNameQuery, BoolQuery::SHOULD);
|
|
$this->addLocationBoosts($bankBoolQuery);
|
|
|
|
// Add profile queries to main query with their individual boosts
|
|
$mainBoolQuery->add($userBoolQuery, BoolQuery::SHOULD);
|
|
$mainBoolQuery->add($orgBoolQuery, BoolQuery::SHOULD);
|
|
$mainBoolQuery->add($bankBoolQuery, BoolQuery::SHOULD);
|
|
|
|
// Add posts search query
|
|
$postsBoolQuery = $this->addPostsSearch($cleanSearch, $currentTime, $locale);
|
|
$mainBoolQuery->add($postsBoolQuery, BoolQuery::SHOULD);
|
|
|
|
// Add calls search query
|
|
$callsBoolQuery = $this->addCallsSearch($cleanSearch, $currentTime, $locale);
|
|
$mainBoolQuery->add($callsBoolQuery, BoolQuery::SHOULD);
|
|
|
|
// Create highlight configuration
|
|
$highlight = new Highlight();
|
|
$highlight->addParameter('fragment_size', timebank_config('main_search_bar.search.fragment_size', 80));
|
|
$highlight->addParameter('number_of_fragments', timebank_config('main_search_bar.search.number_of_fragments', 1));
|
|
$highlight->addParameter('fragmenter', timebank_config('main_search_bar.search.fragmenter', 'span'));
|
|
$highlight->addParameter('order', timebank_config('main_search_bar.search.order', 'score'));
|
|
|
|
// Limit total fragments across all fields - use max_analyzed_offset to limit highlighting
|
|
$highlight->addParameter('max_analyzed_offset', timebank_config('main_search_bar.search.fragment_size', 80));
|
|
$highlight->addParameter('require_field_match', true);
|
|
|
|
// Add pre/post tags for highlighting
|
|
$preTags = timebank_config('main_search_bar.search.pre-tags');
|
|
$postTags = timebank_config('main_search_bar.search.post-tags');
|
|
if ($preTags && $postTags) {
|
|
$highlight->addParameter('pre_tags', is_array($preTags) ? $preTags : [$preTags]);
|
|
$highlight->addParameter('post_tags', is_array($postTags) ? $postTags : [$postTags]);
|
|
}
|
|
|
|
// Add profile highlight fields
|
|
foreach ($this->getProfileHighlightFields() as $field => $options) {
|
|
$highlight->addField($field, $options);
|
|
}
|
|
|
|
// Add post highlight fields
|
|
foreach ($this->getPostHighlightFields() as $field => $options) {
|
|
$highlight->addField($field, $options);
|
|
}
|
|
|
|
// Add call highlight fields
|
|
foreach ($this->getCallHighlightFields() as $field => $options) {
|
|
$highlight->addField($field, $options);
|
|
}
|
|
|
|
// Execute MixedSearch with callback
|
|
// Pass empty string so SearchFactory does not inject a query_string MUST clause —
|
|
// all matching is handled by our own $mainBoolQuery in the callback.
|
|
$rawResponse = MixedSearch::search('', function (Client $client, Search $body) use ($mainBoolQuery, $highlight, $cleanSearch, $locale) {
|
|
// Add the query
|
|
$body->addQuery($mainBoolQuery);
|
|
|
|
// Add highlighting
|
|
$body->addHighlight($highlight);
|
|
|
|
// Set size
|
|
$body->setSize(timebank_config('main_search_bar.search.max_results', 50));
|
|
|
|
// Log the query for debugging
|
|
$queryArray = $body->toArray();
|
|
\Log::info('MainSearchBar: Elasticsearch query', [
|
|
'locale' => $locale,
|
|
'search_term' => $cleanSearch,
|
|
'query' => json_encode($queryArray)
|
|
]);
|
|
|
|
// Execute the search
|
|
return $client->search([
|
|
'index' => implode(',', timebank_config('main_search_bar.model_indices', [])),
|
|
'body' => $queryArray,
|
|
])->asArray();
|
|
})->raw();
|
|
|
|
// Get models from the response
|
|
$results = collect();
|
|
if (isset($rawResponse['hits']['hits'])) {
|
|
foreach ($rawResponse['hits']['hits'] as $hit) {
|
|
$modelClass = $hit['_source']['__class_name'] ?? null;
|
|
$modelId = $hit['_source']['id'] ?? null;
|
|
|
|
if ($modelClass && $modelId && class_exists($modelClass)) {
|
|
try {
|
|
$model = $modelClass::find($modelId);
|
|
if ($model) {
|
|
$results->push($model);
|
|
}
|
|
} catch (\Exception $e) {
|
|
\Log::warning("MainSearchBar: Could not load model {$modelClass}:{$modelId}", ['error' => $e->getMessage()]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
$this->processMixedResults($results, $rawResponse, $cleanSearch);
|
|
} catch (\Exception $e) {
|
|
\Log::error('MainSearchBar: Enhanced search failed', [
|
|
'error' => $e->getMessage(),
|
|
'search_term' => $cleanSearch
|
|
]);
|
|
|
|
$this->suggestions = collect();
|
|
$this->fetchedResults = [];
|
|
$this->total = 0;
|
|
$this->suggestionMap = [];
|
|
}
|
|
}
|
|
|
|
|
|
private function addPostsSearch(string $cleanSearch, string $currentTime, string $locale): BoolQuery
|
|
{
|
|
$postsBoolQuery = new BoolQuery();
|
|
|
|
$postsBoolQuery->add(
|
|
new \ONGR\ElasticsearchDSL\Query\TermLevel\TermQuery('__class_name', 'App\Models\Post'),
|
|
BoolQuery::MUST
|
|
);
|
|
|
|
$postSearchFields = [
|
|
'post_translations.title_' . $locale . '^' .timebank_config('main_search_bar.boosted_fields.post.title', '1'),
|
|
'post_translations.content_' . $locale . '^' . timebank_config('main_search_bar.boosted_fields.post.content', '1'),
|
|
'post_translations.excerpt_' . $locale . '^' . timebank_config('main_search_bar.boosted_fields.post.excerpt', '1'),
|
|
'post_translations.category.names.name_' . $locale . '^' . timebank_config('main_search_bar.boosted_fields.post.category_name', '1'),
|
|
];
|
|
|
|
$postMultiMatchQuery = new MultiMatchQuery($postSearchFields, $cleanSearch);
|
|
$postMultiMatchQuery->addParameter('boost', timebank_config('main_search_bar.boosted_models.post', 4));
|
|
$postsBoolQuery->add($postMultiMatchQuery, BoolQuery::MUST);
|
|
|
|
$this->addPostPublicationFilters($postsBoolQuery, $currentTime, $locale);
|
|
|
|
$includedCategoryIds = timebank_config('main_search_bar.category_ids_posts');
|
|
if (!empty($includedCategoryIds)) {
|
|
$categoryBoolQuery = new BoolQuery();
|
|
foreach ($includedCategoryIds as $categoryId) {
|
|
$categoryBoolQuery->add(
|
|
new \ONGR\ElasticsearchDSL\Query\TermLevel\TermQuery('category_id', $categoryId),
|
|
BoolQuery::SHOULD
|
|
);
|
|
}
|
|
$postsBoolQuery->add($categoryBoolQuery, BoolQuery::MUST);
|
|
}
|
|
|
|
return $postsBoolQuery;
|
|
}
|
|
|
|
private function addPostPublicationFilters(BoolQuery $boolQuery, string $currentTime, string $locale): void
|
|
{
|
|
// From date: must exist AND be in the past (null means NOT published)
|
|
$boolQuery->add(
|
|
new \ONGR\ElasticsearchDSL\Query\TermLevel\ExistsQuery(
|
|
"post_translations.from_{$locale}"
|
|
),
|
|
BoolQuery::MUST
|
|
);
|
|
$boolQuery->add(
|
|
new \ONGR\ElasticsearchDSL\Query\TermLevel\RangeQuery(
|
|
"post_translations.from_{$locale}",
|
|
['lte' => $currentTime]
|
|
),
|
|
BoolQuery::MUST
|
|
);
|
|
|
|
// Till date: (NOT exists) OR (exists AND in future) = never expires OR not yet expired
|
|
$tillFilter = new BoolQuery();
|
|
// Option 1: field doesn't exist (null)
|
|
$tillNotExists = new BoolQuery();
|
|
$tillNotExists->add(
|
|
new \ONGR\ElasticsearchDSL\Query\TermLevel\ExistsQuery(
|
|
"post_translations.till_{$locale}"
|
|
),
|
|
BoolQuery::MUST_NOT
|
|
);
|
|
$tillFilter->add($tillNotExists, BoolQuery::SHOULD);
|
|
|
|
// Option 2: field exists and is in the future
|
|
$tillInFuture = new BoolQuery();
|
|
$tillInFuture->add(
|
|
new \ONGR\ElasticsearchDSL\Query\TermLevel\ExistsQuery(
|
|
"post_translations.till_{$locale}"
|
|
),
|
|
BoolQuery::MUST
|
|
);
|
|
$tillInFuture->add(
|
|
new \ONGR\ElasticsearchDSL\Query\TermLevel\RangeQuery(
|
|
"post_translations.till_{$locale}",
|
|
['gte' => $currentTime]
|
|
),
|
|
BoolQuery::MUST
|
|
);
|
|
$tillFilter->add($tillInFuture, BoolQuery::SHOULD);
|
|
$boolQuery->add($tillFilter, BoolQuery::MUST);
|
|
|
|
// Deleted date: (NOT exists) OR (exists AND in future) = not deleted OR scheduled for future
|
|
$deletionFilter = new BoolQuery();
|
|
// Option 1: field doesn't exist (null)
|
|
$deletionNotExists = new BoolQuery();
|
|
$deletionNotExists->add(
|
|
new \ONGR\ElasticsearchDSL\Query\TermLevel\ExistsQuery(
|
|
"post_translations.deleted_at_{$locale}"
|
|
),
|
|
BoolQuery::MUST_NOT
|
|
);
|
|
$deletionFilter->add($deletionNotExists, BoolQuery::SHOULD);
|
|
|
|
// Option 2: field exists and is in the future
|
|
$deletionInFuture = new BoolQuery();
|
|
$deletionInFuture->add(
|
|
new \ONGR\ElasticsearchDSL\Query\TermLevel\ExistsQuery(
|
|
"post_translations.deleted_at_{$locale}"
|
|
),
|
|
BoolQuery::MUST
|
|
);
|
|
$deletionInFuture->add(
|
|
new \ONGR\ElasticsearchDSL\Query\TermLevel\RangeQuery(
|
|
"post_translations.deleted_at_{$locale}",
|
|
['gt' => $currentTime]
|
|
),
|
|
BoolQuery::MUST
|
|
);
|
|
$deletionFilter->add($deletionInFuture, BoolQuery::SHOULD);
|
|
$boolQuery->add($deletionFilter, BoolQuery::MUST);
|
|
}
|
|
|
|
|
|
private function addCallsSearch(string $cleanSearch, string $currentTime, string $locale): BoolQuery
|
|
{
|
|
$callsBoolQuery = new BoolQuery();
|
|
|
|
$callsBoolQuery->add(
|
|
new \ONGR\ElasticsearchDSL\Query\TermLevel\TermQuery('__class_name', 'App\Models\Call'),
|
|
BoolQuery::MUST
|
|
);
|
|
|
|
// Search current locale with higher boost, plus all other locales as fallback
|
|
// This handles compound words in NL/DE where a search term may be part of a compound
|
|
$callSearchFields = [
|
|
'tag.name_' . $locale . '^3',
|
|
'call_translations.content_' . $locale . '^1',
|
|
'callable.name^1',
|
|
];
|
|
foreach (['en', 'nl', 'fr', 'de', 'es'] as $otherLocale) {
|
|
if ($otherLocale !== $locale) {
|
|
$callSearchFields[] = 'tag.name_' . $otherLocale . '^1';
|
|
$callSearchFields[] = 'call_translations.content_' . $otherLocale . '^0.5';
|
|
}
|
|
}
|
|
|
|
// Standard multi-match across all locale fields
|
|
$callMultiMatchQuery = new \ONGR\ElasticsearchDSL\Query\FullText\MultiMatchQuery($callSearchFields, $cleanSearch);
|
|
$callMultiMatchQuery->addParameter('boost', timebank_config('main_search_bar.boosted_models.post', 4));
|
|
$callsBoolQuery->add($callMultiMatchQuery, BoolQuery::SHOULD);
|
|
|
|
// Wildcard fallback for compound words (e.g. NL/DE where search term is part of a compound)
|
|
// Added as SHOULD alongside multi-match; the outer MUST requirement is satisfied if either matches
|
|
foreach (['en', 'nl', 'fr', 'de', 'es'] as $wLocale) {
|
|
$callsBoolQuery->add(
|
|
new \ONGR\ElasticsearchDSL\Query\TermLevel\WildcardQuery('tag.name_' . $wLocale, strtolower($cleanSearch) . '*'),
|
|
BoolQuery::SHOULD
|
|
);
|
|
}
|
|
|
|
// Require at least one text clause to match (minimum_should_match on the should clauses)
|
|
$callsBoolQuery->addParameter('minimum_should_match', 1);
|
|
|
|
// from: must exist and be in the past
|
|
$callsBoolQuery->add(
|
|
new \ONGR\ElasticsearchDSL\Query\TermLevel\ExistsQuery('from'),
|
|
BoolQuery::MUST
|
|
);
|
|
$callsBoolQuery->add(
|
|
new \ONGR\ElasticsearchDSL\Query\TermLevel\RangeQuery('from', ['lte' => $currentTime]),
|
|
BoolQuery::MUST
|
|
);
|
|
|
|
// till: not exists (no expiry) OR exists and in the future
|
|
$tillFilter = new BoolQuery();
|
|
$tillNotExists = new BoolQuery();
|
|
$tillNotExists->add(new \ONGR\ElasticsearchDSL\Query\TermLevel\ExistsQuery('till'), BoolQuery::MUST_NOT);
|
|
$tillFilter->add($tillNotExists, BoolQuery::SHOULD);
|
|
$tillInFuture = new BoolQuery();
|
|
$tillInFuture->add(new \ONGR\ElasticsearchDSL\Query\TermLevel\ExistsQuery('till'), BoolQuery::MUST);
|
|
$tillInFuture->add(new \ONGR\ElasticsearchDSL\Query\TermLevel\RangeQuery('till', ['gte' => $currentTime]), BoolQuery::MUST);
|
|
$tillFilter->add($tillInFuture, BoolQuery::SHOULD);
|
|
$callsBoolQuery->add($tillFilter, BoolQuery::MUST);
|
|
|
|
return $callsBoolQuery;
|
|
}
|
|
|
|
|
|
private function addLocationBoosts(BoolQuery $boolQuery)
|
|
{
|
|
if (empty($this->locationHierarchy)) {
|
|
return;
|
|
}
|
|
|
|
if (isset($this->locationHierarchy['district']) && $this->locationHierarchy['district']) {
|
|
$districtName = $this->getLocationName($this->locationHierarchy['district']);
|
|
if ($districtName) {
|
|
$districtQuery = new \ONGR\ElasticsearchDSL\Query\FullText\MatchQuery('locations.district', $districtName);
|
|
$districtQuery->addParameter('boost', timebank_config('search_optimization.location_boosts.same_district', 5.0));
|
|
$boolQuery->add($districtQuery, BoolQuery::SHOULD);
|
|
}
|
|
}
|
|
|
|
if (isset($this->locationHierarchy['city']) && $this->locationHierarchy['city']) {
|
|
$cityName = $this->getLocationName($this->locationHierarchy['city']);
|
|
if ($cityName) {
|
|
$cityQuery = new \ONGR\ElasticsearchDSL\Query\FullText\MatchQuery('locations.city', $cityName);
|
|
$cityQuery->addParameter('boost', timebank_config('search_optimization.location_boosts.same_city', 3.0));
|
|
$boolQuery->add($cityQuery, BoolQuery::SHOULD);
|
|
}
|
|
}
|
|
|
|
if (isset($this->locationHierarchy['division']) && $this->locationHierarchy['division']) {
|
|
$divisionName = $this->getLocationName($this->locationHierarchy['division']);
|
|
\Log::info('MainSearchBar: Division location boost', [
|
|
'division_object' => json_encode($this->locationHierarchy['division']),
|
|
'division_name' => $divisionName,
|
|
'locale' => app()->getLocale()
|
|
]);
|
|
if ($divisionName) {
|
|
$divisionQuery = new \ONGR\ElasticsearchDSL\Query\FullText\MatchQuery('locations.division', $divisionName);
|
|
$divisionQuery->addParameter('boost', timebank_config('search_optimization.location_boosts.same_division', 2.0));
|
|
$boolQuery->add($divisionQuery, BoolQuery::SHOULD);
|
|
}
|
|
}
|
|
|
|
if (isset($this->locationHierarchy['country']) && $this->locationHierarchy['country']) {
|
|
$countryName = $this->getLocationName($this->locationHierarchy['country']);
|
|
if ($countryName) {
|
|
$countryQuery = new \ONGR\ElasticsearchDSL\Query\FullText\MatchQuery('locations.country', $countryName);
|
|
$countryQuery->addParameter('boost', timebank_config('search_optimization.location_boosts.same_country', 1.5));
|
|
$boolQuery->add($countryQuery, BoolQuery::SHOULD);
|
|
}
|
|
}
|
|
}
|
|
|
|
private function processMixedResults($models, array $rawResponse, string $cleanSearch): void
|
|
{
|
|
$cardData = [];
|
|
|
|
foreach ($models as $model) {
|
|
$type = get_class($model);
|
|
|
|
if ($type === \App\Models\Post::class) {
|
|
$result = $this->processPostCard($model);
|
|
} elseif ($type === \App\Models\Call::class) {
|
|
// Always check current DB state regardless of index freshness
|
|
if ($model->trashed()) continue;
|
|
if ($model->is_suppressed) continue;
|
|
if ($model->is_paused) continue;
|
|
if ($model->till !== null && $model->till->isPast()) continue;
|
|
if (!$model->is_public && !\Illuminate\Support\Facades\Auth::check()) continue;
|
|
$result = $this->processCallCard($model);
|
|
} else {
|
|
$result = $this->processProfileCard($model);
|
|
}
|
|
|
|
if (!empty($result)) {
|
|
$cardData[] = $result;
|
|
}
|
|
}
|
|
|
|
$results = [];
|
|
$rawHits = $rawResponse['hits']['hits'] ?? [];
|
|
// Key by model+id to avoid collisions between different model types with the same id
|
|
$cardDataByKey = collect($cardData)->keyBy(fn($r) => $r['model'] . ':' . $r['id']);
|
|
|
|
foreach ($rawHits as $rawItem) {
|
|
$id = $rawItem['_source']['id'] ?? null;
|
|
$modelClass = $rawItem['_source']['__class_name'] ?? null;
|
|
$key = $modelClass . ':' . $id;
|
|
if ($id && isset($cardDataByKey[$key])) {
|
|
$result = $cardDataByKey[$key];
|
|
$result['score'] = round(($rawItem['_score'] ?? 1.0) / 4, 1);
|
|
|
|
// Limit highlights to only the most important one per result
|
|
$highlight = $rawItem['highlight'] ?? [];
|
|
$limitedHighlight = $this->limitHighlights($highlight);
|
|
|
|
// CRITICAL XSS PROTECTION POINT
|
|
// Sanitize highlights to prevent XSS attacks from user-generated content
|
|
// Elasticsearch returns highlights containing user profile data that could include malicious HTML/JS
|
|
// This MUST be sanitized before caching or displaying to prevent stored XSS attacks
|
|
// DO NOT remove or bypass this sanitization - it protects all search result displays
|
|
$result['highlight'] = $this->sanitizeHighlights($limitedHighlight);
|
|
|
|
$results[] = $result;
|
|
}
|
|
}
|
|
|
|
$extractedData = $this->processAndScoreResults($results, $cleanSearch);
|
|
|
|
if (timebank_config('main_search_bar.suggestions') > 0) {
|
|
// Create suggestions with mapped search terms
|
|
$this->suggestionMap = [];
|
|
$suggestionIndex = 0;
|
|
|
|
$suggestions = collect($extractedData)->flatMap(function ($item) use (&$suggestionIndex, $cleanSearch) {
|
|
$itemSuggestions = $item['suggest'] ?? [];
|
|
$mappedSuggestions = [];
|
|
|
|
foreach ($itemSuggestions as $suggestion) {
|
|
$this->suggestionMap[$suggestionIndex] = [
|
|
'display_text' => $suggestion,
|
|
'original_term' => $cleanSearch, // Always use the original search term
|
|
'field_source' => $item['highlight_field'] ?? 'unknown'
|
|
];
|
|
$mappedSuggestions[] = [
|
|
'text' => $suggestion,
|
|
'index' => $suggestionIndex
|
|
];
|
|
$suggestionIndex++;
|
|
}
|
|
|
|
return $mappedSuggestions;
|
|
})->unique('text')->take(timebank_config('main_search_bar.suggestions'));
|
|
|
|
$this->suggestions = $suggestions;
|
|
}
|
|
|
|
$this->fetchedResults = $extractedData->values()->all();
|
|
$this->total = $rawResponse['hits']['total']['value'] ?? $models->count();
|
|
}
|
|
|
|
private function processPostCard($model): array
|
|
{
|
|
// Load relationships only when needed
|
|
if (!$model->relationLoaded('category')) {
|
|
$model = \App\Models\Post::with(['category.categoryable'])->find($model->id);
|
|
}
|
|
|
|
if (!$model) {
|
|
return [];
|
|
}
|
|
|
|
$locale = app()->getLocale();
|
|
$translation = $model->translations()->where('locale', $locale)->first();
|
|
|
|
// Handle missing translation
|
|
if (!$translation) {
|
|
return []; // Skip this post
|
|
}
|
|
|
|
// Check publication status using dates (same logic as in search)
|
|
$currentTime = now();
|
|
$isPublished = true;
|
|
$filterReason = '';
|
|
|
|
// From date MUST exist and be in the past (null = NOT published)
|
|
if (!$translation->from) {
|
|
$isPublished = false; // No publication date = not published
|
|
$filterReason = 'No from date (unpublished)';
|
|
} elseif ($currentTime->lt($translation->from)) {
|
|
$isPublished = false; // Not yet published
|
|
$filterReason = 'Future from date: ' . $translation->from;
|
|
}
|
|
|
|
// Till date can be null (never expires) or must be in the future
|
|
if ($translation->till && $currentTime->gt($translation->till)) {
|
|
$isPublished = false; // Publication ended
|
|
$filterReason = 'Past till date: ' . $translation->till;
|
|
}
|
|
|
|
// Deleted date can be null (not deleted) or must be in the future
|
|
if ($translation->deleted_at && $currentTime->gte($translation->deleted_at)) {
|
|
$isPublished = false; // Scheduled deletion occurred
|
|
$filterReason = 'Deleted at: ' . $translation->deleted_at;
|
|
}
|
|
|
|
if (!$isPublished) {
|
|
return []; // Skip unpublished posts
|
|
}
|
|
|
|
$categoryId = optional($model->category)->id;
|
|
$photoUrl = $model->getFirstMediaUrl('posts', 'hero');
|
|
$categoryName = null;
|
|
|
|
if ($model->category && $model->category->relationLoaded('translations')) {
|
|
$categoryName = optional($model->category->translations->where('locale', $locale)->first())->name;
|
|
}
|
|
|
|
return [
|
|
'id' => $model->id,
|
|
'model' => get_class($model),
|
|
'model_object' => $model,
|
|
'category_id' => $categoryId,
|
|
'category' => $categoryName,
|
|
'title' => $translation->title ?? '',
|
|
'subtitle' => $translation->excerpt ?? '',
|
|
'photo' => $photoUrl,
|
|
'location_short' => '',
|
|
'location' => '',
|
|
'status' => '',
|
|
'location_proximity' => $this->calculateLocationProximity($model),
|
|
];
|
|
}
|
|
|
|
private function processCallCard($model): array
|
|
{
|
|
$model->loadMissing([
|
|
'callable',
|
|
'tag.contexts.category.translations',
|
|
'tag.contexts.category.ancestors.translations',
|
|
'location.city.translations',
|
|
'location.country.translations',
|
|
]);
|
|
|
|
$tag = $model->tag;
|
|
$tagName = $tag?->translation?->name ?? $tag?->name;
|
|
$tagCategory = $tag?->contexts->first()?->category;
|
|
$tagColor = $tagCategory?->relatedColor ?? 'gray';
|
|
$city = optional($model->location?->city?->translations->first())->name;
|
|
$country = optional($model->location?->country?->translations->first())->name;
|
|
|
|
$tagCategories = [];
|
|
if ($tagCategory) {
|
|
$ancestors = $tagCategory->ancestorsAndSelf()->get()->reverse();
|
|
$locale = app()->getLocale();
|
|
foreach ($ancestors as $cat) {
|
|
$catName = $cat->translations->firstWhere('locale', $locale)?->name
|
|
?? $cat->translations->first()?->name
|
|
?? '';
|
|
if ($catName) {
|
|
$tagCategories[] = [
|
|
'name' => $catName,
|
|
'color' => $cat->relatedColor ?? 'gray',
|
|
];
|
|
}
|
|
}
|
|
}
|
|
|
|
return [
|
|
'id' => $model->id,
|
|
'model' => get_class($model),
|
|
'model_object' => $model,
|
|
'category_id' => null,
|
|
'category' => trans_with_platform('@PLATFORM_NAME@ call'),
|
|
'title' => $tagName ?? '',
|
|
'subtitle' => '',
|
|
'photo' => $model->callable?->profile_photo_url ?? '',
|
|
'location_short' => $city ?? '',
|
|
'location' => collect([$city, $country])->filter()->implode(', '),
|
|
'tag_name' => $tagName,
|
|
'tag_color' => $tagColor,
|
|
'tag_categories' => $tagCategories,
|
|
'callable_name' => $model->callable?->name ?? '',
|
|
'till' => $model->till,
|
|
'status' => '',
|
|
'location_proximity' => $this->calculateLocationProximity($model),
|
|
];
|
|
}
|
|
|
|
private function processProfileCard($model): array
|
|
{
|
|
$canManageProfiles = $this->getCanManageProfiles();
|
|
if (!$canManageProfiles) {
|
|
if (
|
|
timebank_config('profile_inactive.profile_search_hidden')
|
|
&& !empty($model->inactive_at)
|
|
&& \Carbon\Carbon::parse($model->inactive_at)->isPast()
|
|
) {
|
|
return [];
|
|
}
|
|
|
|
if (
|
|
timebank_config('profile_email_unverified.profile_search_hidden')
|
|
&& empty($model->email_verified_at)
|
|
) {
|
|
return [];
|
|
}
|
|
|
|
if (
|
|
!empty($model->deleted_at)
|
|
&& \Carbon\Carbon::parse($model->deleted_at)->isPast()
|
|
) {
|
|
return [];
|
|
}
|
|
|
|
if (
|
|
timebank_config('profile_incomplete.profile_search_hidden')
|
|
&& method_exists($model, 'hasIncompleteProfile')
|
|
&& $model->hasIncompleteProfile($model)
|
|
) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
// Load locations only when needed
|
|
if (!$model->relationLoaded('locations')) {
|
|
$locations = $model->locations()->with([
|
|
'district.translations',
|
|
'city.translations',
|
|
'division.translations',
|
|
'country'
|
|
])->get();
|
|
} else {
|
|
$locations = $model->locations;
|
|
}
|
|
|
|
$firstLocation = $locations->first();
|
|
$locationShort = null;
|
|
$location = '';
|
|
|
|
if ($firstLocation) {
|
|
$city = null;
|
|
$district = null;
|
|
$division = null;
|
|
$country = null;
|
|
|
|
if ($firstLocation->city) {
|
|
$cityTranslation = $firstLocation->city->translations->where('locale', app()->getLocale())->first();
|
|
$city = $cityTranslation ? $cityTranslation->name : $firstLocation->city->name;
|
|
}
|
|
if ($firstLocation->district) {
|
|
$districtTranslation = $firstLocation->district->translations->where('locale', app()->getLocale())->first();
|
|
$district = $districtTranslation ? $districtTranslation->name : $firstLocation->district->name;
|
|
}
|
|
if ($firstLocation->division) {
|
|
$divisionTranslation = $firstLocation->division->translations->where('locale', app()->getLocale())->first();
|
|
$division = $divisionTranslation ? $divisionTranslation->name : $firstLocation->division->name;
|
|
}
|
|
if (!$firstLocation->division && $city != null) {
|
|
$cityId = $firstLocation->city->id;
|
|
$cityModel = City::find($cityId);
|
|
if ($cityModel && $cityModel->division) {
|
|
$divisionTranslation = $cityModel->division->translations->where('locale', app()->getLocale())->first();
|
|
$division = $divisionTranslation ? $divisionTranslation->name : $cityModel->division->name;
|
|
}
|
|
}
|
|
if ($firstLocation->country) {
|
|
$country = $firstLocation->country->code;
|
|
}
|
|
|
|
if ($city) {
|
|
$locationShort = $city . ', ' . $division;
|
|
if ($district) {
|
|
$location = $city . ' ' . $district . ', ' . $division . ', ' . $country;
|
|
} else {
|
|
$location = $city . ', ' . $division . ', ' . $country;
|
|
}
|
|
} else {
|
|
if ($country && !$city) {
|
|
$division ? $locationShort = $division . ', ' . $country : $locationShort = $country;
|
|
$division ? $location = $division . ', ' . $country : $location = $country;
|
|
}
|
|
}
|
|
}
|
|
|
|
$status = '';
|
|
|
|
return [
|
|
'id' => $model->id,
|
|
'model' => get_class($model),
|
|
'model_object' => $model,
|
|
'name' => $model->name ?? '',
|
|
'photo' => $model->profile_photo_url ?? '',
|
|
'location_short' => $locationShort ?? '',
|
|
'location' => $location,
|
|
'status' => $status,
|
|
'type' => strtolower(class_basename($model)),
|
|
'location_proximity' => $this->calculateLocationProximity($model),
|
|
];
|
|
}
|
|
|
|
private function calculateLocationProximity($model): array
|
|
{
|
|
if (empty($this->locationHierarchy)) {
|
|
return ['level' => 'unknown', 'distance' => null];
|
|
}
|
|
|
|
$modelLocation = null;
|
|
|
|
if (get_class($model) === \App\Models\Call::class) {
|
|
$modelLocation = $model->location;
|
|
if (!$modelLocation) {
|
|
return ['level' => 'no_location', 'distance' => null];
|
|
}
|
|
try {
|
|
$modelHierarchy = $modelLocation->getCompleteHierarchy();
|
|
return $this->compareLocationHierarchies($modelHierarchy);
|
|
} catch (\Exception $e) {
|
|
return ['level' => 'error', 'distance' => null];
|
|
}
|
|
}
|
|
|
|
if (get_class($model) === 'App\Models\Post') {
|
|
if ($model->category && $model->category->categoryable) {
|
|
$categoryable = $model->category->categoryable;
|
|
|
|
if (in_array(get_class($categoryable), [
|
|
'App\Models\Locations\City',
|
|
'App\Models\Locations\District',
|
|
'App\Models\Locations\Division',
|
|
'App\Models\Locations\Country'
|
|
])) {
|
|
try {
|
|
$modelHierarchy = $this->buildLocationHierarchyFromCategoryable($categoryable);
|
|
return $this->compareLocationHierarchies($modelHierarchy);
|
|
} catch (\Exception $e) {
|
|
return ['level' => 'error', 'distance' => null];
|
|
}
|
|
}
|
|
}
|
|
return ['level' => 'no_location', 'distance' => null];
|
|
} else {
|
|
$modelLocation = $model->locations->first();
|
|
}
|
|
|
|
if (!$modelLocation) {
|
|
return ['level' => 'no_location', 'distance' => null];
|
|
}
|
|
|
|
try {
|
|
$modelHierarchy = $modelLocation->getCompleteHierarchy();
|
|
return $this->compareLocationHierarchies($modelHierarchy);
|
|
} catch (\Exception $e) {
|
|
return ['level' => 'error', 'distance' => null];
|
|
}
|
|
}
|
|
|
|
private function buildLocationHierarchyFromCategoryable($categoryable): array
|
|
{
|
|
$hierarchy = [
|
|
'country' => null,
|
|
'division' => null,
|
|
'city' => null,
|
|
'district' => null,
|
|
];
|
|
|
|
$categoryableClass = get_class($categoryable);
|
|
|
|
switch ($categoryableClass) {
|
|
case 'App\Models\Locations\Country':
|
|
$hierarchy['country'] = $categoryable;
|
|
break;
|
|
|
|
case 'App\Models\Locations\Division':
|
|
$hierarchy['division'] = $categoryable;
|
|
$hierarchy['country'] = $categoryable->country ?? null;
|
|
break;
|
|
|
|
case 'App\Models\Locations\City':
|
|
$hierarchy['city'] = $categoryable;
|
|
$hierarchy['division'] = $categoryable->division ?? null;
|
|
$hierarchy['country'] = $categoryable->country ?? null;
|
|
break;
|
|
|
|
case 'App\Models\Locations\District':
|
|
$hierarchy['district'] = $categoryable;
|
|
$hierarchy['city'] = $categoryable->city ?? null;
|
|
$hierarchy['division'] = $categoryable->city->division ?? null;
|
|
$hierarchy['country'] = $categoryable->city->country ?? null;
|
|
break;
|
|
}
|
|
|
|
return $hierarchy;
|
|
}
|
|
|
|
private function compareLocationHierarchies($modelHierarchy): array
|
|
{
|
|
if (
|
|
isset($this->locationHierarchy['district'], $modelHierarchy['district'])
|
|
&& $this->locationHierarchy['district']
|
|
&& $modelHierarchy['district']
|
|
&& $this->locationHierarchy['district']->id === $modelHierarchy['district']->id
|
|
) {
|
|
return ['level' => 'same_district', 'distance' => 1];
|
|
}
|
|
|
|
if (
|
|
isset($this->locationHierarchy['city'], $modelHierarchy['city'])
|
|
&& $this->locationHierarchy['city']
|
|
&& $modelHierarchy['city']
|
|
&& $this->locationHierarchy['city']->id === $modelHierarchy['city']->id
|
|
) {
|
|
return ['level' => 'same_city', 'distance' => 2];
|
|
}
|
|
|
|
if (
|
|
isset($this->locationHierarchy['division'], $modelHierarchy['division'])
|
|
&& $this->locationHierarchy['division']
|
|
&& $modelHierarchy['division']
|
|
&& $this->locationHierarchy['division']->id === $modelHierarchy['division']->id
|
|
) {
|
|
return ['level' => 'same_division', 'distance' => 3];
|
|
}
|
|
|
|
if (
|
|
isset($this->locationHierarchy['country'], $modelHierarchy['country'])
|
|
&& $this->locationHierarchy['country']
|
|
&& $modelHierarchy['country']
|
|
&& $this->locationHierarchy['country']->id === $modelHierarchy['country']->id
|
|
) {
|
|
return ['level' => 'same_country', 'distance' => 4];
|
|
}
|
|
|
|
return ['level' => 'different_country', 'distance' => 5];
|
|
}
|
|
|
|
private function getLocationProximityBoost(array $locationProximity): float
|
|
{
|
|
if (empty($locationProximity) || !isset($locationProximity['level'])) {
|
|
return 1.0;
|
|
}
|
|
|
|
// Use config values for location boosts
|
|
return timebank_config("search_optimization.location_boosts.{$locationProximity['level']}", 1.0);
|
|
}
|
|
|
|
/**
|
|
* Processes the given search results and assigns a score to each result based on the cleaned search string.
|
|
*
|
|
* @param array $results The array of search results to process and score.
|
|
* @param string $cleanSearch The cleaned search string used for scoring the results.
|
|
* @return \Illuminate\Support\Collection A collection of processed and scored search results.
|
|
*/
|
|
private function processAndScoreResults(array $results, string $cleanSearch): \Illuminate\Support\Collection
|
|
{
|
|
// Track search analytics if enabled
|
|
if (timebank_config('search_optimization.analytics.enabled')) {
|
|
$startTime = microtime(true);
|
|
}
|
|
|
|
$extractedData = array_map(function ($result) use ($cleanSearch) {
|
|
$type = strtolower(class_basename($result['model']));
|
|
$locationBoost = $this->getLocationProximityBoost($result['location_proximity'] ?? []);
|
|
|
|
$originalScore = $result['score'] ?? 1;
|
|
|
|
// Apply model boost only if SearchOptimizationHelper is disabled to avoid double boosting
|
|
$modelBoost = 1;
|
|
if (!timebank_config('search_optimization.enabled')) {
|
|
$modelBoost = timebank_config("main_search_bar.boosted_models.{$type}", 1);
|
|
}
|
|
|
|
$boostedScore = $originalScore * $modelBoost * $locationBoost;
|
|
|
|
// Use the proper location proximity system from logs and config
|
|
$locationProximity = $result['location_proximity'] ?? [];
|
|
$proximityLevel = $locationProximity['level'] ?? 'no_location';
|
|
|
|
// Convert level-based boosts to base scores for composite calculation
|
|
$levelToScoreMap = [
|
|
'same_district' => 100, // Highest priority
|
|
'same_city' => 60, // High priority
|
|
'same_division' => 30, // Medium priority
|
|
'same_country' => 10, // Low priority
|
|
'different_country' => 0, // No proximity bonus
|
|
'no_location' => 0, // No location bonus
|
|
];
|
|
|
|
$locationBaseScore = $levelToScoreMap[$proximityLevel] ?? 0;
|
|
$normalizedScore = $boostedScore * 20;
|
|
$compositeScore = $locationBaseScore + $normalizedScore;
|
|
|
|
// Extract the field that produced the highlight for context
|
|
$highlightField = $this->getHighlightField($result['highlight'] ?? []);
|
|
|
|
return [
|
|
'model' => $result['model'],
|
|
'id' => $result['id'] ?? '',
|
|
'score' => round($compositeScore / 10, 1),
|
|
'original_score' => $originalScore,
|
|
'highlight' => $result['highlight'] ?? [],
|
|
'highlight_field' => $highlightField,
|
|
'suggest' => $this->extractSuggestions($result['highlight'] ?? [], $cleanSearch),
|
|
'location_proximity' => $result['location_proximity'] ?? [],
|
|
];
|
|
}, $results);
|
|
|
|
$sortedResults = collect($extractedData)->sortByDesc('score');
|
|
|
|
// Apply SearchOptimizationHelper if enabled
|
|
if (timebank_config('search_optimization.enabled')) {
|
|
$userLocation = $this->locationHierarchy;
|
|
$optimized = SearchOptimizationHelper::optimizeSearchResults(
|
|
$sortedResults->toArray(),
|
|
explode(' ', $cleanSearch),
|
|
$userLocation
|
|
);
|
|
$sortedResults = collect($optimized);
|
|
}
|
|
|
|
// Track analytics
|
|
if (timebank_config('search_optimization.analytics.enabled') && isset($startTime)) {
|
|
$executionTime = microtime(true) - $startTime;
|
|
SearchOptimizationHelper::trackLocationSearchAnalytics(
|
|
[], // No specific category IDs in this search
|
|
count($results),
|
|
$executionTime,
|
|
$this->locationHierarchy
|
|
);
|
|
}
|
|
|
|
return $sortedResults->take(timebank_config('main_search_bar.search.max_results', 50));
|
|
}
|
|
|
|
/**
|
|
* Get the field name that produced the highlight
|
|
*/
|
|
private function getHighlightField(array $highlights): string
|
|
{
|
|
if (empty($highlights)) {
|
|
return 'unknown';
|
|
}
|
|
|
|
// Check for advanced fields first
|
|
$advancedFields = [
|
|
'tags.contexts.tags.name_',
|
|
'tags.contexts.categories.name_'
|
|
];
|
|
|
|
foreach ($highlights as $field => $value) {
|
|
foreach ($advancedFields as $advancedField) {
|
|
if (strpos($field, $advancedField) !== false) {
|
|
return 'advanced_field';
|
|
}
|
|
}
|
|
}
|
|
|
|
// Return the first field if no advanced fields found
|
|
return array_key_first($highlights) ?? 'unknown';
|
|
}
|
|
|
|
/**
|
|
* Extracts suggestion strings from the provided highlights array based on the search term.
|
|
*
|
|
* This method processes the highlights array to generate a list of suggestions
|
|
* that are relevant to the given search term. The suggestions can be used for
|
|
* features such as autocomplete or search recommendations.
|
|
*
|
|
* @param array $highlights An array containing highlight data from which suggestions will be extracted.
|
|
* @param string $searchTerm The term that the user has entered to search for suggestions.
|
|
* @return array An array of suggestion strings relevant to the search term.
|
|
*/
|
|
private function extractSuggestions(array $highlights, string $searchTerm): array
|
|
{
|
|
$suggestions = [];
|
|
|
|
foreach ($highlights as $field => $fieldHighlights) {
|
|
foreach ($fieldHighlights as $highlight) {
|
|
// Remove highlight tags to get clean text
|
|
$preTag = timebank_config('main_search_bar.search.pre-tags', '<em>');
|
|
$postTag = timebank_config('main_search_bar.search.post-tags', '</em>');
|
|
|
|
// Handle array format for tags
|
|
if (is_array($preTag)) {
|
|
$preTag = $preTag[0] ?? '<em>';
|
|
}
|
|
if (is_array($postTag)) {
|
|
$postTag = $postTag[0] ?? '</em>';
|
|
}
|
|
|
|
$cleanText = str_replace([$preTag, $postTag], '', $highlight);
|
|
|
|
// Split by sentences first
|
|
$sentences = preg_split('/[.!?]+/', $cleanText);
|
|
|
|
foreach ($sentences as $sentence) {
|
|
$sentence = trim($sentence);
|
|
|
|
// If sentence contains search term and is reasonable length
|
|
if (stripos($sentence, $searchTerm) !== false && strlen($sentence) > 3) {
|
|
// Limit sentence to max 5 words to keep suggestions readable
|
|
$words = preg_split('/\s+/', $sentence);
|
|
if (count($words) > 5) {
|
|
// Find search term and keep 4 words around it
|
|
$searchPosition = -1;
|
|
for ($i = 0; $i < count($words); $i++) {
|
|
if (stripos($words[$i], $searchTerm) !== false) {
|
|
$searchPosition = $i;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if ($searchPosition >= 0) {
|
|
$start = max(0, $searchPosition - 2);
|
|
$end = min(count($words) - 1, $start + 3);
|
|
$sentence = implode(' ', array_slice($words, $start, $end - $start + 1));
|
|
}
|
|
}
|
|
|
|
$phrase = trim($sentence, '.,!?;:()[]{}');
|
|
if (strlen($phrase) > 7) {
|
|
$suggestions[] = $phrase;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return array_slice(array_unique($suggestions), 0, 5);
|
|
}
|
|
|
|
/**
|
|
* Limit highlights to only show the most relevant content field
|
|
* Location fields are excluded from highlighting entirely
|
|
*/
|
|
private function limitHighlights(array $highlights): array
|
|
{
|
|
if (empty($highlights)) {
|
|
return [];
|
|
}
|
|
|
|
// Priority order for content fields only (location fields no longer included in highlighting)
|
|
$priorityFields = [
|
|
'name',
|
|
'full_name',
|
|
'about_short_' . app()->getLocale(),
|
|
'about_' . app()->getLocale(),
|
|
'cyclos_skills',
|
|
'tags.contexts.tags.name_' . app()->getLocale(),
|
|
'tags.contexts.categories.name_' . app()->getLocale(),
|
|
'post_translations.title_' . app()->getLocale(),
|
|
'post_translations.excerpt_' . app()->getLocale(),
|
|
'post_translations.content_' . app()->getLocale(),
|
|
'post_category.names.name_' . app()->getLocale(),
|
|
'tag.name_' . app()->getLocale(),
|
|
'call_translations.content_' . app()->getLocale(),
|
|
];
|
|
|
|
// Return highlight from the highest priority field that has highlights
|
|
foreach ($priorityFields as $field) {
|
|
if (isset($highlights[$field]) && !empty($highlights[$field])) {
|
|
return [$field => $highlights[$field]];
|
|
}
|
|
}
|
|
|
|
// Final fallback - return first available highlight (should not contain location fields now)
|
|
$firstField = array_key_first($highlights);
|
|
return $firstField ? [$firstField => $highlights[$firstField]] : [];
|
|
}
|
|
|
|
/**
|
|
* CRITICAL SECURITY METHOD - XSS PROTECTION
|
|
*
|
|
* Sanitize highlights to prevent XSS attacks while preserving Elasticsearch highlight tags.
|
|
*
|
|
* This method ensures that user-generated content in search highlights is properly escaped,
|
|
* while allowing only the configured highlight wrapper tags (e.g., <span> tags used by Elasticsearch).
|
|
*
|
|
* SECURITY VULNERABILITY FIXED:
|
|
* - User-generated content from profile fields (about, about_short, motivation, website, skills)
|
|
* - Gets indexed in Elasticsearch and returned in search highlights
|
|
* - Without sanitization, malicious HTML/JavaScript executes in victim's browser
|
|
* - This method escapes ALL user content while preserving only safe highlight tags
|
|
*
|
|
* HOW THIS PROTECTION CAN BE BROKEN:
|
|
* 1. DANGER: If you bypass this method and pass highlights directly to the view
|
|
* 2. DANGER: If you modify the placeholder strings to match actual user content
|
|
* 3. DANGER: If you change config pre/post tags to include JavaScript event handlers (e.g., onclick=)
|
|
* 4. DANGER: If you use str_replace with user-controlled input for placeholders
|
|
* 5. DANGER: If you cache highlights BEFORE sanitization (must cache AFTER)
|
|
* 6. DANGER: If you add additional HTML tags to the whitelist without careful review
|
|
* 7. DANGER: If you use {!! !!} syntax on highlights from a different source without sanitization
|
|
*
|
|
* SAFE USAGE:
|
|
* - REQUIRED: Always call this method before caching or displaying highlights
|
|
* - REQUIRED: Only use {!! !!} syntax on highlights that have passed through this method
|
|
* - REQUIRED: Keep pre/post tags simple HTML without attributes that can execute code
|
|
* - REQUIRED: Use unique placeholder strings that cannot appear in user input
|
|
*
|
|
* @param array $highlights Array of highlight strings from Elasticsearch
|
|
* @return array Sanitized highlights safe for HTML output with {!! !!}
|
|
*/
|
|
private function sanitizeHighlights(array $highlights): array
|
|
{
|
|
if (empty($highlights)) {
|
|
return [];
|
|
}
|
|
|
|
// SECURITY: Get configured highlight tags - these must be reviewed if changed
|
|
// Only simple HTML tags without event handlers are safe (e.g., <span class="...">, not <span onclick="...">)
|
|
$preTag = timebank_config('main_search_bar.search.pre-tags', '<span class="font-semibold text-white leading-tight">');
|
|
$postTag = timebank_config('main_search_bar.search.post-tags', '</span>');
|
|
|
|
$sanitized = [];
|
|
|
|
foreach ($highlights as $field => $highlightArray) {
|
|
$sanitized[$field] = [];
|
|
|
|
foreach ($highlightArray as $highlightText) {
|
|
// SECURITY STEP 1: Temporarily replace Elasticsearch highlight tags with unique placeholders
|
|
// Placeholders must be unique strings that cannot appear in user input
|
|
$placeholder_pre = '___HIGHLIGHT_START___';
|
|
$placeholder_post = '___HIGHLIGHT_END___';
|
|
|
|
$safe = str_replace($preTag, $placeholder_pre, $highlightText);
|
|
$safe = str_replace($postTag, $placeholder_post, $safe);
|
|
|
|
// SECURITY STEP 2: Escape ALL HTML entities to prevent XSS
|
|
// This converts < > & " ' to HTML entities, making them harmless
|
|
// ENT_QUOTES escapes both single and double quotes
|
|
// ENT_HTML5 ensures proper HTML5 entity encoding
|
|
$safe = htmlspecialchars($safe, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
|
|
|
// SECURITY STEP 3: Restore ONLY the safe Elasticsearch highlight tags
|
|
// At this point, any malicious HTML is escaped, so restoring our known-safe tags is safe
|
|
$safe = str_replace($placeholder_pre, $preTag, $safe);
|
|
$safe = str_replace($placeholder_post, $postTag, $safe);
|
|
|
|
$sanitized[$field][] = $safe;
|
|
}
|
|
}
|
|
|
|
return $sanitized;
|
|
}
|
|
|
|
public function showSearchResults()
|
|
{
|
|
if (strlen($this->search) > 1) {
|
|
$searchTerm = $this->search;
|
|
$total = $this->total;
|
|
$this->results = $this->fetchedResults;
|
|
|
|
$key = 'main_search_bar_results_' . Auth::guard('web')->id();
|
|
$results = collect($this->results)->map(fn ($r) => [
|
|
'model' => $r['model'],
|
|
'id' => $r['id'],
|
|
'highlight' => $r['highlight'] ?? [],
|
|
'score' => isset($r['score']) ? round($r['score'] / 10, 1) : null,
|
|
'location_proximity' => $r['location_proximity'] ?? [],
|
|
])->values()->all();
|
|
|
|
cache()->put($key, ['results' => $results, 'searchTerm' => $searchTerm, 'total' => $total], now()->addMinutes(timebank_config('main_search_bar.cache_results', 5)));
|
|
|
|
|
|
return redirect()->route('search.show');
|
|
} else {
|
|
return null;
|
|
}
|
|
}
|
|
}
|