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; } /** * Resolve the authenticated user's profile type label without storing any identifier. */ private static function resolveUserType(): string { if (!auth()->check()) { return 'guest'; } $profileType = getActiveProfileType(); return match (true) { $profileType === 'Admin' => 'admin', $profileType === 'Organization' => 'organization', $profileType === 'Bank' => 'bank', $profileType === 'User' => 'user', default => 'authenticated', }; } /** * Generate search analytics data */ public static function trackSearchAnalytics(array $categoryIds, int $totalResults, float $executionTime, string $searchString = ''): void { self::writeSearchMetricEntry([ 'search_string' => $searchString, 'category_ids' => $categoryIds, 'total_results' => $totalResults, 'execution_time' => $executionTime, 'locale' => app()->getLocale(), 'user_type' => self::resolveUserType(), ]); } /** * Get search performance metrics from the current month's log file */ public static function getSearchMetrics(?\Carbon\Carbon $since = null): array { $recentSearches = self::readCurrentMonthEntries(50, $since); if (empty($recentSearches)) { return [ 'total_searches' => 0, 'avg_results' => 0, 'avg_execution_time' => 0, 'popular_categories' => [], 'popular_category_colors' => [], ]; } $totalSearches = count($recentSearches); $totalResults = array_sum(array_column($recentSearches, 'total_results')); $totalExecutionTime = array_sum(array_column($recentSearches, 'execution_time')); $cutoff24h = now()->subHours(24); $searchesLast24h = count(array_filter($recentSearches, fn($s) => isset($s['timestamp']) && \Carbon\Carbon::parse($s['timestamp'])->gte($cutoff24h))); // Avg searches per day based on the actual span between oldest and newest entry $timestamps = array_filter(array_column($recentSearches, 'timestamp')); $spanDays = 1; if (count($timestamps) > 1) { $oldest = \Carbon\Carbon::parse(min($timestamps)); $newest = \Carbon\Carbon::parse(max($timestamps)); $spanDays = max(1, (int) ($oldest->diffInDays($newest, true)) + 1); } $avgSearchesPerDay = round($totalSearches / $spanDays, 1); // Get popular categories — count by ID, then resolve names and colors $allCategoryIds = []; foreach ($recentSearches as $search) { $allCategoryIds = array_merge($allCategoryIds, $search['category_ids'] ?? []); } $popularByCount = array_count_values($allCategoryIds); arsort($popularByCount); $topIds = array_keys(array_slice($popularByCount, 0, 5, true)); $tagMap = []; if (!empty($topIds)) { $locale = app()->getLocale(); $categories = Category::whereIn('id', $topIds) ->with(['translations' => fn($q) => $q->where('locale', $locale)]) ->get() ->keyBy('id'); foreach ($topIds as $id) { $cat = $categories->get($id); $tagMap[$id] = [ 'name' => $cat?->translation?->name ?? $cat?->name ?? $id, 'color' => $cat?->relatedColor ?? 'gray', ]; } } $popularCategories = []; $popularCategoryColors = []; $popularCategoryIds = []; foreach (array_slice($popularByCount, 0, 5, true) as $id => $count) { $name = $tagMap[$id]['name'] ?? $id; $popularCategories[$name] = $count; $popularCategoryColors[$name] = $tagMap[$id]['color'] ?? 'gray'; $popularCategoryIds[$name] = $id; } return [ 'total_searches' => $totalSearches, 'searches_last_24h' => $searchesLast24h, 'avg_results' => $totalSearches > 0 ? round($totalResults / $totalSearches, 2) : 0, 'avg_execution_time' => $totalSearches > 0 ? round($totalExecutionTime / $totalSearches, 4) : 0, 'avg_searches_per_day' => $avgSearchesPerDay, 'popular_categories' => $popularCategories, 'popular_category_colors' => $popularCategoryColors, 'popular_category_ids' => $popularCategoryIds, ]; } /** * Write a single search metric entry to the database */ private static function writeSearchMetricEntry(array $data): void { $locationHierarchy = $data['location_hierarchy'] ?? []; try { SearchMetric::create([ 'searched_at' => now(), 'search_string' => $data['search_string'] ?? '', 'category_ids' => $data['category_ids'] ?? [], 'total_results' => $data['total_results'] ?? 0, 'execution_time' => $data['execution_time'] ?? 0.0, 'locale' => $data['locale'] ?? '', 'user_type' => $data['user_type'] ?? 'guest', 'country_id' => $locationHierarchy['country_id'] ?? null, 'division_id' => $locationHierarchy['division_id'] ?? null, 'city_id' => $locationHierarchy['city_id'] ?? null, 'district_id' => $locationHierarchy['district_id'] ?? null, ]); } catch (\Exception $e) { Log::warning('Failed to write search metric entry: ' . $e->getMessage()); } } /** * Read the most recent entries from the past 12 months from the database */ public static function readCurrentMonthEntries(int $limit = 50, ?\Carbon\Carbon $since = null): array { $since ??= now()->subMonths(12)->startOfMonth(); try { return SearchMetric::where('searched_at', '>=', $since) ->orderBy('searched_at', 'desc') ->limit($limit) ->get() ->map(fn($row) => [ 'timestamp' => $row->searched_at->toISOString(), 'search_string' => $row->search_string, 'category_ids' => $row->category_ids, 'total_results' => $row->total_results, 'execution_time' => $row->execution_time, 'locale' => $row->locale, 'user_type' => $row->user_type, 'country_id' => $row->country_id, 'division_id' => $row->division_id, 'city_id' => $row->city_id, 'district_id' => $row->district_id, ]) ->toArray(); } catch (\Exception $e) { Log::warning('Failed to read search metrics from database: ' . $e->getMessage()); return []; } } /** * 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 = [], string $searchString = ''): void { self::writeSearchMetricEntry([ 'search_string' => $searchString, 'category_ids' => $categoryIds, 'total_results' => $totalResults, 'execution_time' => $executionTime, 'locale' => app()->getLocale(), 'user_type' => self::resolveUserType(), '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, ], ]); } /** * 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)); } }