697 lines
24 KiB
PHP
697 lines
24 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers;
|
|
|
|
use App\Enums\HuntDifficulty;
|
|
use App\Enums\ParticipateStatus;
|
|
use App\Http\Resources\HuntResource;
|
|
use App\Models\Hunts;
|
|
use App\Models\HuntSteps;
|
|
use App\Notifications\HuntParticipantJoinedNotification;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Collection;
|
|
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')
|
|
->withSum('steps', 'xp')
|
|
)
|
|
->options(['filter' => $filters]);
|
|
|
|
$result = $search->paginate(10);
|
|
$this->appendListMetadataToHunts($result->getCollection(), $request->user()?->id);
|
|
|
|
Log::info('HUNTS_SEARCH_SUCCESS', [
|
|
'total' => $result->total(),
|
|
'count' => count($result->items()),
|
|
]);
|
|
|
|
return response()->json($result);
|
|
} catch (\Throwable $e) {
|
|
|
|
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 HuntResource::make($hunt)->response();
|
|
} 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',
|
|
'start_radius_m' => 'nullable|integer|min:1',
|
|
'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',
|
|
'start_radius_m' => 'nullable|integer|min:1',
|
|
'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' => ParticipateStatus::Registered->value,
|
|
]);
|
|
|
|
$this->refreshSearchIndex($hunt);
|
|
|
|
$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',
|
|
'participation_status' => [
|
|
'user_id' => $user->id,
|
|
'hunt_id' => $hunt->id,
|
|
'current_step_number' => 0,
|
|
'status' => ParticipateStatus::Registered->value,
|
|
],
|
|
'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 start(Request $request, Hunts $hunt): JsonResponse
|
|
{
|
|
$validated = $request->validate([
|
|
'latitude' => 'required|numeric',
|
|
'longitude' => 'required|numeric',
|
|
]);
|
|
|
|
$user = $request->user();
|
|
$participation = $user->participations()->where('hunt_id', $hunt->id)->first();
|
|
|
|
if (! $participation) {
|
|
return response()->json(['message' => 'Not participating in this hunt'], 403);
|
|
}
|
|
|
|
if ($participation->pivot->status === ParticipateStatus::Completed->value) {
|
|
return response()->json([
|
|
'completed' => true,
|
|
'participation_status' => $this->participationPayloadFromPivot($participation->pivot),
|
|
'message' => 'Hunt already completed',
|
|
]);
|
|
}
|
|
|
|
if (
|
|
$participation->pivot->status === ParticipateStatus::InProgress->value
|
|
&& (int) $participation->pivot->current_step_number > 0
|
|
) {
|
|
$currentStep = $hunt->steps()
|
|
->where('step_number', (int) $participation->pivot->current_step_number)
|
|
->first();
|
|
|
|
return response()->json([
|
|
'message' => 'Hunt already started',
|
|
'participation_status' => $this->participationPayloadFromPivot($participation->pivot),
|
|
'current_step' => $currentStep ? $this->stepTargetPayload($currentStep) : null,
|
|
]);
|
|
}
|
|
|
|
if ($hunt->latitude === null || $hunt->longitude === null) {
|
|
return response()->json(['message' => 'Hunt start location is not configured'], 422);
|
|
}
|
|
|
|
$distance = $this->distanceMeters(
|
|
(float) $validated['latitude'],
|
|
(float) $validated['longitude'],
|
|
(float) $hunt->latitude,
|
|
(float) $hunt->longitude,
|
|
);
|
|
$radius = $hunt->start_radius_m ?? 50;
|
|
|
|
if ($distance > $radius) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => 'You are too far from the hunt start.',
|
|
'distance_m' => round($distance),
|
|
'radius_m' => $radius,
|
|
], 400);
|
|
}
|
|
|
|
$firstStep = $hunt->steps()->orderBy('step_number')->first();
|
|
|
|
if (! $firstStep) {
|
|
return response()->json(['message' => 'Hunt has no steps'], 409);
|
|
}
|
|
|
|
$hunt->participants()->updateExistingPivot($user->id, [
|
|
'current_step_number' => $firstStep->step_number,
|
|
'status' => ParticipateStatus::InProgress->value,
|
|
]);
|
|
|
|
$participation->pivot->current_step_number = $firstStep->step_number;
|
|
$participation->pivot->status = ParticipateStatus::InProgress->value;
|
|
|
|
return response()->json([
|
|
'message' => 'Hunt started',
|
|
'participation_status' => $this->participationPayloadFromPivot($participation->pivot),
|
|
'current_step' => $this->stepTargetPayload($firstStep),
|
|
]);
|
|
}
|
|
|
|
public function currentStep(Request $request, Hunts $hunt): JsonResponse
|
|
{
|
|
$user = $request->user();
|
|
$participation = $user->participations()->where('hunt_id', $hunt->id)->first();
|
|
|
|
if (! $participation) {
|
|
return response()->json(['message' => 'Not participating in this hunt'], 403);
|
|
}
|
|
|
|
if ($participation->pivot->status === ParticipateStatus::Completed->value) {
|
|
return response()->json([
|
|
'completed' => true,
|
|
'participation_status' => $this->participationPayloadFromPivot($participation->pivot),
|
|
]);
|
|
}
|
|
|
|
if (
|
|
$participation->pivot->status !== ParticipateStatus::InProgress->value
|
|
|| (int) $participation->pivot->current_step_number < 1
|
|
) {
|
|
return response()->json([
|
|
'message' => 'Hunt is joined but not started',
|
|
'participation_status' => $this->participationPayloadFromPivot($participation->pivot),
|
|
], 409);
|
|
}
|
|
|
|
$currentStep = $hunt->steps()
|
|
->where('step_number', (int) $participation->pivot->current_step_number)
|
|
->first();
|
|
|
|
if (! $currentStep) {
|
|
return response()->json(['message' => 'Current step not found'], 404);
|
|
}
|
|
|
|
return response()->json([
|
|
'participation_status' => $this->participationPayloadFromPivot($participation->pivot),
|
|
'current_step' => $this->stepTargetPayload($currentStep),
|
|
]);
|
|
}
|
|
|
|
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);
|
|
$this->refreshSearchIndex($hunt);
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* @param Collection<int, Hunts> $hunts
|
|
*/
|
|
private function appendListMetadataToHunts(Collection $hunts, string|int|null $userId): void
|
|
{
|
|
$hunts->each(function (Hunts $hunt) use ($userId): void {
|
|
$hunt->setAttribute('xp', $hunt->totalXp());
|
|
$hunt->setAttribute('is_participating', false);
|
|
$hunt->setAttribute('participation_status', null);
|
|
$hunt->makeHidden('steps_sum_xp');
|
|
|
|
if ($userId === null || ! $hunt->relationLoaded('participants')) {
|
|
return;
|
|
}
|
|
|
|
$participant = $hunt->participants->firstWhere('id', $userId);
|
|
|
|
if ($participant === null || $participant->pivot === null) {
|
|
return;
|
|
}
|
|
|
|
$hunt->setAttribute('is_participating', true);
|
|
$hunt->setAttribute('participation_status', $this->participationPayloadFromPivot($participant->pivot));
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function stepTargetPayload(HuntSteps $step): array
|
|
{
|
|
return [
|
|
'id' => $step->id,
|
|
'hunt_id' => $step->hunt_id,
|
|
'step_number' => $step->step_number,
|
|
'title' => $step->title,
|
|
'latitude' => $step->latitude,
|
|
'longitude' => $step->longitude,
|
|
'radius_m' => $step->radius_m,
|
|
'ar_model' => 'chest',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function participationPayloadFromPivot(mixed $pivot): array
|
|
{
|
|
return [
|
|
'user_id' => $pivot->user_id,
|
|
'hunt_id' => $pivot->hunt_id,
|
|
'current_step_number' => (int) $pivot->current_step_number,
|
|
'status' => $pivot->status,
|
|
'created_at' => $pivot->created_at?->toJSON(),
|
|
'updated_at' => $pivot->updated_at?->toJSON(),
|
|
];
|
|
}
|
|
|
|
private function distanceMeters(float $latitudeFrom, float $longitudeFrom, float $latitudeTo, float $longitudeTo): float
|
|
{
|
|
$earthRadius = 6371000;
|
|
|
|
$latFrom = deg2rad($latitudeFrom);
|
|
$lonFrom = deg2rad($longitudeFrom);
|
|
$latTo = deg2rad($latitudeTo);
|
|
$lonTo = deg2rad($longitudeTo);
|
|
|
|
$latDelta = $latTo - $latFrom;
|
|
$lonDelta = $lonTo - $lonFrom;
|
|
|
|
$angle = 2 * asin(sqrt(pow(sin($latDelta / 2), 2) +
|
|
cos($latFrom) * cos($latTo) * pow(sin($lonDelta / 2), 2)));
|
|
|
|
return $angle * $earthRadius;
|
|
}
|
|
|
|
private function refreshSearchIndex(Hunts $hunt): void
|
|
{
|
|
try {
|
|
$hunt->searchable();
|
|
} catch (\Throwable $e) {
|
|
Log::warning('HUNT_SEARCH_INDEX_REFRESH_FAILED', [
|
|
'hunt_id' => $hunt->id,
|
|
'message' => $e->getMessage(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|