resolveSearchTerm($request); $filters = $this->buildMeilisearchFilters($request); $search = Hunts::search($searchTerm ?? '') ->query(fn ($query) => $query->with('participants:id,avatar_uri')) ->options(['filter' => $filters]); Log::info('HUNTS_SEARCH', [ 'q' => $searchTerm ?? '', 'filters' => $filters, 'meili_host' => config('scout.meilisearch.host'), 'index' => (new Hunts)->searchableAs(), 'request_params' => $request->all(), ]); $result = $search->paginate(10); Log::info('HUNTS_SEARCH_SUCCESS', [ 'total' => $result->total(), 'count' => count($result->items()), ]); return response()->json($result); } catch (\Throwable $e) { Log::error('HUNTS_ERROR_CAUGHT', [ 'message' => $e->getMessage(), 'file' => $e->getFile(), 'line' => $e->getLine(), 'trace' => $e->getTraceAsString(), ]); return response()->json([ 'error' => 'Server Error', 'message' => $e->getMessage(), ], 500); } } private function buildMeilisearchFilters(Request $request): string { Log::info('BUILD_FILTERS_START', [ 'has_participating' => $request->has('participating'), 'has_active' => $request->has('active'), 'has_city' => $request->filled('city'), 'has_difficulty' => $request->filled('difficulty'), ]); $filters = []; // participating if ($request->has('participating')) { Log::info('PROCESSING_PARTICIPATING_FILTER', [ 'participating_raw' => $request->query('participating'), ]); $participating = filter_var( $request->query('participating'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE ); Log::info('PARTICIPATING_PARSED', [ 'participating_value' => $participating, 'is_bool' => is_bool($participating), ]); $userId = auth('sanctum')->id(); // chez toi => UUID string Log::info('USER_ID_RETRIEVED', [ 'user_id' => $userId, 'user_id_type' => gettype($userId), ]); if ($userId) { $escapedUserId = $this->escapeMeiliString((string) $userId); Log::info('USER_ID_ESCAPED', [ 'escaped_user_id' => $escapedUserId, ]); if ($participating === true) { // ✅ UUID => string $filter = "participant_ids = \"{$escapedUserId}\""; $filters[] = $filter; Log::info('ADDED_PARTICIPATING_TRUE_FILTER', [ 'filter' => $filter, ]); } elseif ($participating === false) { $filter = "participant_ids != \"{$escapedUserId}\""; $filters[] = $filter; Log::info('ADDED_PARTICIPATING_FALSE_FILTER', [ 'filter' => $filter, ]); } } elseif ($participating === true) { $filter = "id < 0"; $filters[] = $filter; Log::info('ADDED_NO_USER_PARTICIPATING_FILTER', [ 'filter' => $filter, ]); } } // active if ($request->has('active')) { Log::info('PROCESSING_ACTIVE_FILTER', [ 'active_raw' => $request->query('active'), ]); $now = Carbon::now('UTC')->timestamp; $active = filter_var($request->query('active'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); Log::info('ACTIVE_PARSED', [ 'active_value' => $active, 'is_bool' => is_bool($active), 'now_timestamp' => $now, ]); if ($active === true) { // Hunt actif = démarré ET (pas terminé OU pas de date de fin) $filter1 = "start_at_timestamp <= {$now}"; $filters[] = $filter1; Log::info('ADDED_ACTIVE_START_FILTER', [ 'filter' => $filter1, ]); // Meilisearch : la syntaxe est 'field EXISTS' et non 'EXISTS field' // Un hunt est actif si end_at_timestamp n'existe pas OU >= now $filter2 = "(end_at_timestamp >= {$now} OR NOT end_at_timestamp EXISTS)"; $filters[] = $filter2; Log::info('ADDED_ACTIVE_END_FILTER', [ 'filter' => $filter2, ]); } elseif ($active === false) { // Hunt non actif = pas encore commencé OU (déjà terminé ET a une date de fin) // Si pas de end_at_timestamp, le hunt ne peut pas être terminé $filter = "(start_at_timestamp > {$now} OR (end_at_timestamp < {$now} AND end_at_timestamp EXISTS))"; $filters[] = $filter; Log::info('ADDED_NOT_ACTIVE_FILTER', [ 'filter' => $filter, ]); } } // city if ($request->filled('city')) { Log::info('PROCESSING_CITY_FILTER', [ 'city_raw' => $request->query('city'), ]); $city = $request->query('city'); if (is_string($city)) { $city = $this->escapeMeiliString($city); $filter = "city = \"{$city}\""; $filters[] = $filter; Log::info('ADDED_CITY_FILTER', [ 'filter' => $filter, ]); } } // difficulty if ($request->filled('difficulty')) { Log::info('PROCESSING_DIFFICULTY_FILTER', [ 'difficulty_raw' => $request->query('difficulty'), ]); $difficulties = $request->query('difficulty'); if (is_string($difficulties)) { $difficulties = explode(',', $difficulties); } Log::info('DIFFICULTY_PARSED', [ 'difficulties' => $difficulties, 'is_array' => is_array($difficulties), ]); if (is_array($difficulties)) { $validDifficulties = array_values(array_intersect( HuntDifficulty::values(), array_map('strtolower', $difficulties) )); Log::info('VALID_DIFFICULTIES', [ 'valid_difficulties' => $validDifficulties, 'count' => count($validDifficulties), ]); if (!empty($validDifficulties)) { $difficultyFilters = array_map( fn ($d) => 'difficulty = "' . $this->escapeMeiliString($d) . '"', $validDifficulties ); $filter = '(' . implode(' OR ', $difficultyFilters) . ')'; $filters[] = $filter; Log::info('ADDED_DIFFICULTY_FILTER', [ 'filter' => $filter, 'difficulty_filters' => $difficultyFilters, ]); } } } // dates (UTC) $startFrom = $this->parseDateFilterUtc($request->query('start_from')); $startTo = $this->parseDateFilterUtc($request->query('start_to')); $endFrom = $this->parseDateFilterUtc($request->query('end_from')); $endTo = $this->parseDateFilterUtc($request->query('end_to')); Log::info('DATE_FILTERS_PARSED', [ 'start_from' => $startFrom?->toIso8601String(), 'start_to' => $startTo?->toIso8601String(), 'end_from' => $endFrom?->toIso8601String(), 'end_to' => $endTo?->toIso8601String(), ]); if ($startFrom) { // start_at_timestamp est toujours présent (created_at par défaut), donc pas besoin de EXISTS $filter = "start_at_timestamp >= {$startFrom->timestamp}"; $filters[] = $filter; Log::info('ADDED_START_FROM_FILTER', [ 'filter' => $filter, 'timestamp' => $startFrom->timestamp, ]); } if ($startTo) { // start_at_timestamp est toujours présent $filter = "start_at_timestamp <= {$startTo->timestamp}"; $filters[] = $filter; Log::info('ADDED_START_TO_FILTER', [ 'filter' => $filter, 'timestamp' => $startTo->timestamp, ]); } if ($endFrom) { // end_at_timestamp peut être null, donc on vérifie qu'il existe avant de comparer $filter = "(end_at_timestamp EXISTS AND end_at_timestamp >= {$endFrom->timestamp})"; $filters[] = $filter; Log::info('ADDED_END_FROM_FILTER', [ 'filter' => $filter, 'timestamp' => $endFrom->timestamp, ]); } if ($endTo) { // end_at_timestamp peut être null, donc on vérifie qu'il existe avant de comparer $filter = "(end_at_timestamp EXISTS AND end_at_timestamp <= {$endTo->timestamp})"; $filters[] = $filter; Log::info('ADDED_END_TO_FILTER', [ 'filter' => $filter, 'timestamp' => $endTo->timestamp, ]); } // Filtre par défaut : si aucun filtre n'est spécifié, afficher uniquement les hunts publics // Cela évite l'erreur Meilisearch avec une chaîne de filtres vide if (empty($filters)) { // En Meilisearch, les booléens peuvent être comparés avec true/false (sans guillemets) $filter = "is_public = true"; $filters[] = $filter; Log::info('ADDED_DEFAULT_PUBLIC_FILTER', [ 'filter' => $filter, 'reason' => 'no_filters_provided', ]); } $finalFilters = implode(' AND ', $filters); Log::info('BUILD_FILTERS_COMPLETE', [ 'filters_array' => $filters, 'filters_count' => count($filters), 'final_filters' => $finalFilters, 'final_filters_length' => strlen($finalFilters), ]); return $finalFilters; } private function resolveSearchTerm(Request $request): ?string { $searchTerm = $request->query('search') ?? $request->query('q'); if (!is_string($searchTerm)) { return null; } $searchTerm = trim($searchTerm); return $searchTerm !== '' ? $searchTerm : null; } private function parseDateFilterUtc(mixed $value): ?Carbon { if (!is_string($value) || trim($value) === '') { return null; } try { return Carbon::parse(trim($value))->utc(); } catch (\Throwable $e) { return null; } } private function escapeMeiliString(string $value): string { // Échappe les backslashes et les guillemets pour une string Meilisearch $value = str_replace('\\', '\\\\', $value); return str_replace('"', '\"', $value); } }