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); } } public function show(Hunts $hunt): JsonResponse { try { // Charger les relations nécessaires $hunt->load([ 'participants:id,username,avatar_uri,xp,level_id', 'participants.currentLevel', 'steps' ]); Log::info('HUNT_SHOW_SUCCESS', [ 'hunt_id' => $hunt->id, 'hunt_title' => $hunt->title, ]); return response()->json($hunt); } catch (\Throwable $e) { Log::error('HUNT_SHOW_ERROR', [ 'hunt_id' => $hunt->id ?? null, 'message' => $e->getMessage(), 'file' => $e->getFile(), 'line' => $e->getLine(), 'trace' => $e->getTraceAsString(), ]); return response()->json([ 'error' => 'Server Error', 'message' => $e->getMessage(), ], 500); } } public function store(Request $request): JsonResponse { try { $validated = $request->validate([ 'title' => 'required|string|max:255', 'description' => 'nullable|string', 'city' => 'required|string|max:255', 'difficulty' => 'required|string', 'latitude' => 'nullable|numeric', 'longitude' => 'nullable|numeric', 'is_public' => 'boolean', 'estimated_duration_min' => 'nullable|integer', 'start_at' => 'nullable|date', 'end_at' => 'nullable|date|after:start_at', 'image' => 'nullable|string', ]); $validated['creator_id'] = auth('sanctum')->id(); $validated['slug'] = \Illuminate\Support\Str::slug($validated['title']); // Ensure slug is unique $originalSlug = $validated['slug']; $counter = 1; while (Hunts::where('slug', $validated['slug'])->exists()) { $validated['slug'] = $originalSlug . '-' . $counter; $counter++; } $hunt = Hunts::create($validated); Log::info('HUNT_CREATED', [ 'hunt_id' => $hunt->id, 'creator_id' => $validated['creator_id'], ]); return response()->json($hunt, 201); } catch (\Illuminate\Validation\ValidationException $e) { return response()->json([ 'error' => 'Validation Error', 'messages' => $e->errors(), ], 422); } catch (\Throwable $e) { Log::error('HUNT_STORE_ERROR', [ 'message' => $e->getMessage(), 'trace' => $e->getTraceAsString(), ]); return response()->json([ 'error' => 'Server Error', 'message' => $e->getMessage(), ], 500); } } public function update(Request $request, Hunts $hunt): JsonResponse { try { // Vérifier que l'utilisateur est le créateur ou admin $user = auth('sanctum')->user(); if ($hunt->creator_id !== $user->id && !$user->isAdmin()) { return response()->json([ 'error' => 'Forbidden', 'message' => 'You are not authorized to update this hunt', ], 403); } $validated = $request->validate([ 'title' => 'sometimes|string|max:255', 'description' => 'nullable|string', 'city' => 'sometimes|string|max:255', 'difficulty' => 'sometimes|string', 'latitude' => 'nullable|numeric', 'longitude' => 'nullable|numeric', 'is_public' => 'boolean', 'estimated_duration_min' => 'nullable|integer', 'start_at' => 'nullable|date', 'end_at' => 'nullable|date|after:start_at', 'image' => 'nullable|string', ]); // Update slug if title changed if (isset($validated['title']) && $validated['title'] !== $hunt->title) { $validated['slug'] = \Illuminate\Support\Str::slug($validated['title']); // Ensure slug is unique $originalSlug = $validated['slug']; $counter = 1; while (Hunts::where('slug', $validated['slug'])->where('id', '!=', $hunt->id)->exists()) { $validated['slug'] = $originalSlug . '-' . $counter; $counter++; } } $hunt->update($validated); Log::info('HUNT_UPDATED', [ 'hunt_id' => $hunt->id, 'updated_by' => $user->id, ]); return response()->json($hunt); } catch (\Illuminate\Validation\ValidationException $e) { return response()->json([ 'error' => 'Validation Error', 'messages' => $e->errors(), ], 422); } catch (\Throwable $e) { Log::error('HUNT_UPDATE_ERROR', [ 'hunt_id' => $hunt->id ?? null, 'message' => $e->getMessage(), 'trace' => $e->getTraceAsString(), ]); return response()->json([ 'error' => 'Server Error', 'message' => $e->getMessage(), ], 500); } } public function destroy(Hunts $hunt): JsonResponse { try { // Vérifier que l'utilisateur est le créateur ou admin $user = auth('sanctum')->user(); if ($hunt->creator_id !== $user->id && !$user->isAdmin()) { return response()->json([ 'error' => 'Forbidden', 'message' => 'You are not authorized to delete this hunt', ], 403); } $huntId = $hunt->id; $hunt->delete(); Log::info('HUNT_DELETED', [ 'hunt_id' => $huntId, 'deleted_by' => $user->id, ]); return response()->json([ 'message' => 'Hunt deleted successfully', ]); } catch (\Throwable $e) { Log::error('HUNT_DELETE_ERROR', [ 'hunt_id' => $hunt->id ?? null, 'message' => $e->getMessage(), 'trace' => $e->getTraceAsString(), ]); return response()->json([ 'error' => 'Server Error', 'message' => $e->getMessage(), ], 500); } } public function participate(Hunts $hunt): JsonResponse { try { $user = auth('sanctum')->user(); // Vérifier si l'utilisateur participe déjà if ($hunt->participants()->where('user_id', $user->id)->exists()) { return response()->json([ 'error' => 'Already Participating', 'message' => 'You are already participating in this hunt', ], 409); } // Ajouter l'utilisateur comme participant $hunt->participants()->attach($user->id, [ 'current_step_number' => 0, 'status' => 'in_progress', ]); Log::info('HUNT_PARTICIPATE', [ 'hunt_id' => $hunt->id, 'user_id' => $user->id, ]); return response()->json([ 'message' => 'Successfully joined the hunt', 'hunt' => $hunt->load('participants:id,username,avatar_uri'), ]); } catch (\Throwable $e) { Log::error('HUNT_PARTICIPATE_ERROR', [ 'hunt_id' => $hunt->id ?? null, 'user_id' => auth('sanctum')->id(), 'message' => $e->getMessage(), 'trace' => $e->getTraceAsString(), ]); return response()->json([ 'error' => 'Server Error', 'message' => $e->getMessage(), ], 500); } } public function unparticipate(Hunts $hunt): JsonResponse { try { $user = auth('sanctum')->user(); // Vérifier si l'utilisateur participe if (!$hunt->participants()->where('user_id', $user->id)->exists()) { return response()->json([ 'error' => 'Not Participating', 'message' => 'You are not participating in this hunt', ], 404); } // Retirer l'utilisateur des participants $hunt->participants()->detach($user->id); Log::info('HUNT_UNPARTICIPATE', [ 'hunt_id' => $hunt->id, 'user_id' => $user->id, ]); return response()->json([ 'message' => 'Successfully left the hunt', ]); } catch (\Throwable $e) { Log::error('HUNT_UNPARTICIPATE_ERROR', [ 'hunt_id' => $hunt->id ?? null, 'user_id' => auth('sanctum')->id(), 'message' => $e->getMessage(), 'trace' => $e->getTraceAsString(), ]); return response()->json([ 'error' => 'Server Error', 'message' => $e->getMessage(), ], 500); } } private function buildMeilisearchFilters(Request $request): string { $filters = []; // participating if ($request->has('participating')) { $participating = filter_var( $request->query('participating'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE ); $userId = auth('sanctum')->id(); // chez toi => UUID string if ($userId) { $escapedUserId = $this->escapeMeiliString((string) $userId); if ($participating === true) { // ✅ UUID => string $filters[] = "participant_ids = \"{$escapedUserId}\""; } elseif ($participating === false) { $filters[] = "participant_ids != \"{$escapedUserId}\""; } } elseif ($participating === true) { $filters[] = "id < 0"; } } // active if ($request->has('active')) { $now = Carbon::now('UTC')->timestamp; $active = filter_var($request->query('active'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); if ($active === true) { // Hunt actif = démarré ET (pas terminé OU pas de date de fin) $filters[] = "start_at_timestamp <= {$now}"; // Meilisearch : la syntaxe est 'field EXISTS' et non 'EXISTS field' // Un hunt est actif si end_at_timestamp n'existe pas OU >= now $filters[] = "(end_at_timestamp >= {$now} OR NOT end_at_timestamp EXISTS)"; } 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é $filters[] = "(start_at_timestamp > {$now} OR (end_at_timestamp < {$now} AND end_at_timestamp EXISTS))"; } } // city if ($request->filled('city')) { $city = $request->query('city'); if (is_string($city)) { $city = $this->escapeMeiliString($city); $filters[] = "city = \"{$city}\""; } } // difficulty if ($request->filled('difficulty')) { $difficulties = $request->query('difficulty'); if (is_string($difficulties)) { $difficulties = explode(',', $difficulties); } if (is_array($difficulties)) { $validDifficulties = array_values(array_intersect( HuntDifficulty::values(), array_map('strtolower', $difficulties) )); if (!empty($validDifficulties)) { $difficultyFilters = array_map( fn ($d) => 'difficulty = "' . $this->escapeMeiliString($d) . '"', $validDifficulties ); $filters[] = '(' . implode(' OR ', $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')); if ($startFrom) { // start_at_timestamp est toujours présent (created_at par défaut), donc pas besoin de EXISTS $filters[] = "start_at_timestamp >= {$startFrom->timestamp}"; } if ($startTo) { // start_at_timestamp est toujours présent $filters[] = "start_at_timestamp <= {$startTo->timestamp}"; } if ($endFrom) { // end_at_timestamp peut être null, donc on vérifie qu'il existe avant de comparer $filters[] = "(end_at_timestamp EXISTS AND end_at_timestamp >= {$endFrom->timestamp})"; } if ($endTo) { // end_at_timestamp peut être null, donc on vérifie qu'il existe avant de comparer $filters[] = "(end_at_timestamp EXISTS AND end_at_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) $filters[] = "is_public = true"; } $finalFilters = implode(' AND ', $filters); Log::info('BUILD_FILTERS_COMPLETE', [ 'final_filters' => $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); } }