api/app/Http/Controllers/HuntsController.php

485 lines
17 KiB
PHP

<?php
namespace App\Http\Controllers;
use App\Enums\HuntDifficulty;
use App\Models\Hunts;
use App\Notifications\HuntParticipantJoinedNotification;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
class HuntsController extends Controller
{
public function index(Request $request): JsonResponse
{
try {
$searchTerm = $this->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',
]);
$hunt->loadMissing('creator');
if ($hunt->creator !== null && $hunt->creator->getKey() !== $user->getKey()) {
$hunt->creator->notify(new HuntParticipantJoinedNotification($hunt, $user));
}
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);
}
}