467 lines
16 KiB
PHP
467 lines
16 KiB
PHP
<?php
|
|
|
|
namespace App\Helpers;
|
|
|
|
use App\Models\Category;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
class SearchOptimizationHelper
|
|
{
|
|
private const CACHE_PREFIX = 'search_optimization_';
|
|
private const DEFAULT_TTL = 3600; // 1 hour
|
|
|
|
/**
|
|
* Build optimized category hierarchy with caching
|
|
*/
|
|
public static function getCategoryHierarchy(string $locale = null): array
|
|
{
|
|
$locale = $locale ?? app()->getLocale();
|
|
$cacheKey = self::CACHE_PREFIX . "category_hierarchy_{$locale}";
|
|
|
|
return Cache::remember($cacheKey, self::DEFAULT_TTL, function () use ($locale) {
|
|
$categories = Category::where('type', 'App\Models\Tag')
|
|
->with(['translations' => function ($query) use ($locale) {
|
|
$query->where('locale', $locale);
|
|
}])
|
|
->get();
|
|
|
|
return self::buildHierarchyRecursive($categories);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get expanded category IDs with caching
|
|
*/
|
|
public static function getExpandedCategoryIds(array $categoryIds): array
|
|
{
|
|
$cacheKey = self::CACHE_PREFIX . 'expanded_' . md5(serialize($categoryIds));
|
|
|
|
return Cache::remember($cacheKey, self::DEFAULT_TTL, function () use ($categoryIds) {
|
|
$allIds = [];
|
|
|
|
foreach ($categoryIds as $categoryId) {
|
|
if (is_numeric($categoryId) && $categoryId > 0) {
|
|
$descendants = self::getCategoryDescendants((int)$categoryId);
|
|
$allIds = array_merge($allIds, $descendants);
|
|
}
|
|
}
|
|
|
|
return array_values(array_unique($allIds));
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get category names by IDs with caching
|
|
*/
|
|
public static function getCategoryNamesByIds(array $categoryIds, string $locale = null): array
|
|
{
|
|
$locale = $locale ?? app()->getLocale();
|
|
$cacheKey = self::CACHE_PREFIX . "names_{$locale}_" . md5(serialize($categoryIds));
|
|
|
|
return Cache::remember($cacheKey, 300, function () use ($categoryIds, $locale) {
|
|
$categories = Category::whereIn('id', $categoryIds)
|
|
->with(['translations' => function ($query) use ($locale) {
|
|
$query->where('locale', $locale);
|
|
}])
|
|
->get();
|
|
|
|
return $categories->map(function ($category) {
|
|
return $category->translation ? $category->translation->name : null;
|
|
})->filter()->values()->toArray();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Clear all search-related caches using app locales
|
|
*/
|
|
public static function clearSearchCaches(): void
|
|
{
|
|
// Use your supported locales
|
|
$locales = ['en', 'nl', 'fr', 'es', 'de'];
|
|
|
|
foreach ($locales as $locale) {
|
|
Cache::forget(self::CACHE_PREFIX . "category_hierarchy_{$locale}");
|
|
}
|
|
|
|
// Clear pattern-based cache keys
|
|
if (Cache::getStore() instanceof \Illuminate\Cache\RedisStore) {
|
|
$cacheKeys = Cache::getRedis()->keys(self::CACHE_PREFIX . '*');
|
|
if (!empty($cacheKeys)) {
|
|
Cache::getRedis()->del($cacheKeys);
|
|
}
|
|
}
|
|
|
|
Log::info('Search caches cleared');
|
|
}
|
|
|
|
/**
|
|
* Build search query suggestions based on category names
|
|
*/
|
|
public static function buildSearchSuggestions(array $categoryNames, string $locale = null): array
|
|
{
|
|
$locale = $locale ?? app()->getLocale();
|
|
$suggestions = [];
|
|
|
|
foreach ($categoryNames as $categoryName) {
|
|
// Add exact match
|
|
$suggestions[] = [
|
|
'text' => $categoryName,
|
|
'weight' => 10,
|
|
'input' => [$categoryName],
|
|
];
|
|
|
|
// Add partial matches
|
|
$words = explode(' ', $categoryName);
|
|
if (count($words) > 1) {
|
|
foreach ($words as $word) {
|
|
if (strlen($word) > 2) {
|
|
$suggestions[] = [
|
|
'text' => $word,
|
|
'weight' => 5,
|
|
'input' => [$word],
|
|
];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return array_unique($suggestions, SORT_REGULAR);
|
|
}
|
|
|
|
/**
|
|
* Optimize search results by applying relevance scoring including location
|
|
*/
|
|
public static function optimizeSearchResults(array $results, array $searchTerms = [], array $userLocation = []): array
|
|
{
|
|
$optimized = [];
|
|
|
|
foreach ($results as $result) {
|
|
$score = $result['score'] ?? 1;
|
|
|
|
// Boost based on model type
|
|
$modelBoost = self::getModelTypeBoost($result['model'] ?? '');
|
|
$score *= $modelBoost;
|
|
|
|
// Boost based on highlight quality
|
|
$highlightBoost = self::getHighlightBoost($result['highlight'] ?? []);
|
|
$score *= $highlightBoost;
|
|
|
|
// Boost based on data completeness
|
|
$completenessBoost = self::getCompletenessBoost($result);
|
|
$score *= $completenessBoost;
|
|
|
|
// Boost based on recency
|
|
$recencyBoost = self::getRecencyBoost($result);
|
|
$score *= $recencyBoost;
|
|
|
|
// Boost based on location proximity
|
|
$locationBoost = self::getLocationBoost($result, $userLocation);
|
|
$score *= $locationBoost;
|
|
|
|
$result['optimized_score'] = $score;
|
|
$optimized[] = $result;
|
|
}
|
|
|
|
// Sort by optimized score primarily, with location as a tie-breaker
|
|
usort($optimized, function ($a, $b) {
|
|
// Primary sort: optimized score (higher scores first)
|
|
$aScore = $a['optimized_score'] ?? 0;
|
|
$bScore = $b['optimized_score'] ?? 0;
|
|
|
|
$scoreDiff = $bScore <=> $aScore;
|
|
if ($scoreDiff !== 0) {
|
|
return $scoreDiff;
|
|
}
|
|
|
|
// Secondary sort: location distance (closer locations first)
|
|
$aDistance = $a['location_proximity']['distance'] ?? 9;
|
|
$bDistance = $b['location_proximity']['distance'] ?? 9;
|
|
return $aDistance <=> $bDistance;
|
|
});
|
|
|
|
return $optimized;
|
|
}
|
|
|
|
/**
|
|
* Generate search analytics data
|
|
*/
|
|
public static function trackSearchAnalytics(array $categoryIds, int $totalResults, float $executionTime): void
|
|
{
|
|
$analyticsData = [
|
|
'timestamp' => now(),
|
|
'category_ids' => $categoryIds,
|
|
'total_results' => $totalResults,
|
|
'execution_time' => $executionTime,
|
|
'locale' => app()->getLocale(),
|
|
'user_id' => auth()->id(),
|
|
];
|
|
|
|
// Store in cache for recent searches
|
|
$recentSearchesKey = self::CACHE_PREFIX . 'recent_searches_' . (auth()->id() ?? 'guest');
|
|
$recentSearches = Cache::get($recentSearchesKey, []);
|
|
|
|
array_unshift($recentSearches, $analyticsData);
|
|
$recentSearches = array_slice($recentSearches, 0, 10); // Keep last 10 searches
|
|
|
|
Cache::put($recentSearchesKey, $recentSearches, now()->addHours(24));
|
|
|
|
// Log for analysis
|
|
Log::info('Search performed', $analyticsData);
|
|
}
|
|
|
|
/**
|
|
* Get search performance metrics
|
|
*/
|
|
public static function getSearchMetrics(): array
|
|
{
|
|
$userId = auth()->id();
|
|
$recentSearchesKey = self::CACHE_PREFIX . 'recent_searches_' . ($userId ?? 'guest');
|
|
$recentSearches = Cache::get($recentSearchesKey, []);
|
|
|
|
if (empty($recentSearches)) {
|
|
return [
|
|
'total_searches' => 0,
|
|
'avg_results' => 0,
|
|
'avg_execution_time' => 0,
|
|
'popular_categories' => [],
|
|
];
|
|
}
|
|
|
|
$totalSearches = count($recentSearches);
|
|
$totalResults = array_sum(array_column($recentSearches, 'total_results'));
|
|
$totalExecutionTime = array_sum(array_column($recentSearches, 'execution_time'));
|
|
|
|
// Get popular categories
|
|
$allCategories = [];
|
|
foreach ($recentSearches as $search) {
|
|
$allCategories = array_merge($allCategories, $search['category_ids']);
|
|
}
|
|
$popularCategories = array_count_values($allCategories);
|
|
arsort($popularCategories);
|
|
|
|
return [
|
|
'total_searches' => $totalSearches,
|
|
'avg_results' => $totalSearches > 0 ? round($totalResults / $totalSearches, 2) : 0,
|
|
'avg_execution_time' => $totalSearches > 0 ? round($totalExecutionTime / $totalSearches, 4) : 0,
|
|
'popular_categories' => array_slice($popularCategories, 0, 5, true),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Build hierarchy recursively
|
|
*/
|
|
private static function buildHierarchyRecursive($categories, $parentId = null): array
|
|
{
|
|
$hierarchy = [];
|
|
|
|
foreach ($categories as $category) {
|
|
if ($category->parent_id == $parentId) {
|
|
$categoryData = [
|
|
'id' => $category->id,
|
|
'name' => $category->translation->name ?? $category->name,
|
|
'color' => $category->relatedColor,
|
|
'parent_id' => $category->parent_id,
|
|
'children' => self::buildHierarchyRecursive($categories, $category->id)
|
|
];
|
|
$hierarchy[] = $categoryData;
|
|
}
|
|
}
|
|
|
|
return $hierarchy;
|
|
}
|
|
|
|
/**
|
|
* Get category descendants with caching
|
|
*/
|
|
private static function getCategoryDescendants(int $categoryId): array
|
|
{
|
|
$cacheKey = self::CACHE_PREFIX . "descendants_{$categoryId}";
|
|
|
|
return Cache::remember($cacheKey, self::DEFAULT_TTL, function () use ($categoryId) {
|
|
$category = Category::find($categoryId);
|
|
if (!$category) {
|
|
return [$categoryId];
|
|
}
|
|
|
|
try {
|
|
$descendants = $category->descendants()->pluck('id')->toArray();
|
|
return array_merge([$categoryId], array_map('intval', $descendants));
|
|
} catch (\Exception $e) {
|
|
Log::warning("Failed to get descendants for category {$categoryId}: " . $e->getMessage());
|
|
return [$categoryId];
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get model type boost factor using timebank-cc configuration
|
|
*/
|
|
private static function getModelTypeBoost(string $modelClass): float
|
|
{
|
|
$type = strtolower(class_basename($modelClass));
|
|
return timebank_config('main_search_bar.boosted_models.' . $type, 1.0);
|
|
}
|
|
|
|
/**
|
|
* Get highlight quality boost
|
|
*/
|
|
private static function getHighlightBoost(array $highlights): float
|
|
{
|
|
if (empty($highlights)) {
|
|
return 1.0;
|
|
}
|
|
|
|
$totalHighlights = 0;
|
|
foreach ($highlights as $fieldHighlights) {
|
|
$totalHighlights += count($fieldHighlights);
|
|
}
|
|
|
|
// More highlights = higher relevance
|
|
return 1.0 + (min($totalHighlights, 10) * 0.05);
|
|
}
|
|
|
|
/**
|
|
* Get data completeness boost
|
|
*/
|
|
private static function getCompletenessBoost(array $result): float
|
|
{
|
|
$completenessFactors = 0;
|
|
$totalFactors = 0;
|
|
|
|
// Check various completeness factors
|
|
$checks = [
|
|
'about' => !empty($result['about'] ?? ''),
|
|
'about_short' => !empty($result['about_short'] ?? ''),
|
|
'subtitle' => !empty($result['subtitle'] ?? ''),
|
|
'photo' => !empty($result['photo'] ?? ''),
|
|
'location' => !empty($result['location'] ?? ''),
|
|
'category' => !empty($result['category'] ?? ''),
|
|
];
|
|
|
|
foreach ($checks as $factor => $isPresent) {
|
|
$totalFactors++;
|
|
if ($isPresent) {
|
|
$completenessFactors++;
|
|
}
|
|
}
|
|
|
|
if ($totalFactors === 0) {
|
|
return 1.0;
|
|
}
|
|
|
|
$completenessRatio = $completenessFactors / $totalFactors;
|
|
return 1.0 + ($completenessRatio * 0.2); // Up to 20% boost
|
|
}
|
|
|
|
/**
|
|
* Get location-based boost based on proximity to user
|
|
*/
|
|
private static function getLocationBoost(array $result, array $userLocation): float
|
|
{
|
|
if (empty($userLocation) || !isset($result['location_proximity'])) {
|
|
return 1.0;
|
|
}
|
|
|
|
$proximity = $result['location_proximity'];
|
|
|
|
// Location proximity boost factors
|
|
$locationBoosts = [
|
|
'same_district' => 1.1,
|
|
'same_city' => 1.05,
|
|
'same_division' => 1.02,
|
|
'same_country' => 1.0,
|
|
'different_country' => 1.0,
|
|
'no_location' => 1.0,
|
|
'unknown' => 1.0,
|
|
'error' => 1.0,
|
|
];
|
|
|
|
return $locationBoosts[$proximity['level'] ?? 'unknown'] ?? 1.0;
|
|
}
|
|
|
|
/**
|
|
* Get recency boost based on creation/update time
|
|
*/
|
|
private static function getRecencyBoost(array $result): float
|
|
{
|
|
// This would need to be implemented based on your specific timestamp fields
|
|
// For now, return neutral boost
|
|
return 1.0;
|
|
}
|
|
|
|
/**
|
|
* Generate location-aware search analytics
|
|
*/
|
|
public static function trackLocationSearchAnalytics(array $categoryIds, int $totalResults, float $executionTime, array $locationHierarchy = []): void
|
|
{
|
|
$analyticsData = [
|
|
'timestamp' => now(),
|
|
'category_ids' => $categoryIds,
|
|
'total_results' => $totalResults,
|
|
'execution_time' => $executionTime,
|
|
'locale' => app()->getLocale(),
|
|
'user_id' => auth()->id(),
|
|
'location_hierarchy' => [
|
|
'country_id' => $locationHierarchy['country']->id ?? null,
|
|
'division_id' => $locationHierarchy['division']->id ?? null,
|
|
'city_id' => $locationHierarchy['city']->id ?? null,
|
|
'district_id' => $locationHierarchy['district']->id ?? null,
|
|
],
|
|
];
|
|
|
|
// Store in cache for recent searches
|
|
$recentSearchesKey = self::CACHE_PREFIX . 'recent_searches_' . (auth()->id() ?? 'guest');
|
|
$recentSearches = Cache::get($recentSearchesKey, []);
|
|
|
|
array_unshift($recentSearches, $analyticsData);
|
|
$recentSearches = array_slice($recentSearches, 0, 10); // Keep last 10 searches
|
|
|
|
Cache::put($recentSearchesKey, $recentSearches, now()->addHours(24));
|
|
|
|
// Log for analysis
|
|
Log::info('Location-aware search performed', $analyticsData);
|
|
}
|
|
|
|
/**
|
|
* Validate search parameters using timebank-cc configuration
|
|
*/
|
|
public static function validateSearchParams(array $params): array
|
|
{
|
|
$validated = [];
|
|
|
|
// Validate category IDs
|
|
if (isset($params['category_ids']) && is_array($params['category_ids'])) {
|
|
$validated['category_ids'] = array_filter($params['category_ids'], function ($id) {
|
|
return is_numeric($id) && $id > 0;
|
|
});
|
|
}
|
|
|
|
// Validate locale using your base language and supported locales
|
|
if (isset($params['locale'])) {
|
|
// Use your supported locales (en, nl, fr, es, de from your config)
|
|
$availableLocales = ['en', 'nl', 'fr', 'es', 'de'];
|
|
$validated['locale'] = in_array($params['locale'], $availableLocales)
|
|
? $params['locale']
|
|
: timebank_config('base_language', 'en');
|
|
}
|
|
|
|
// Validate limit using your max_results
|
|
if (isset($params['limit'])) {
|
|
$maxResults = timebank_config('main_search_bar.search.max_results', 50);
|
|
$validated['limit'] = max(1, min((int)$params['limit'], $maxResults));
|
|
}
|
|
|
|
return $validated;
|
|
}
|
|
|
|
/**
|
|
* Build cache key for search results compatible with timebank-cc naming
|
|
*/
|
|
public static function buildSearchCacheKey(array $params): string
|
|
{
|
|
ksort($params); // Ensure consistent ordering
|
|
return 'category_search_results_' . md5(serialize($params));
|
|
}
|
|
}
|