Snapshot of main at 9f39a6e1e (2026-11-02)
This commit is contained in:
commit
6ccf1792d7
2580 files changed
+1040202
No files matched your search
@@ -0,0 +1,564 @@
|
||||
<?php
|
||||
|
||||
namespace App\Helpers;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\SearchMetric;
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user