diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..5aa8a56 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,57 @@ +name: Laravel CI-CD (Gitea) + +on: + push: + branches: ["main"] + +jobs: + + test: + name: "Tests Unitaires" + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Run Tests + uses: docker://laravelsail/php84-composer:latest + env: + APP_ENV: testing + APP_KEY: base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= + DB_CONNECTION: sqlite + DB_DATABASE: ":memory:" + with: + args: | + bash -c "composer install --no-interaction --ignore-platform-req=ext-intl && php artisan test" + + build: + name: "Build & Push Docker" + needs: test + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Login to Gitea Registry + run: | + REGISTRY_DOMAIN=$(echo "${{ github.server_url }}" | sed -e 's|https://||' -e 's|http://||') + echo "${{ secrets.TOKEN_GITEA }}" | docker login "$REGISTRY_DOMAIN" -u "${{ github.actor }}" --password-stdin + + - name: Build and Push + run: | + REGISTRY_DOMAIN=$(echo "${{ github.server_url }}" | sed -e 's|https://||' -e 's|http://||') + IMAGE="$REGISTRY_DOMAIN/${{ github.repository }}:latest" + + docker build -t "$IMAGE" . + docker push "$IMAGE" + + deploy: + name: "Déploiement Simplifié" + needs: build + runs-on: ubuntu-latest + + steps: + - name: Trigger Portainer Webhook + run: curl -X POST "${{ secrets.PORTAINER_WEBHOOK_URL }}" diff --git a/app/Enums/ParticipateStatus.php b/app/Enums/ParticipateStatus.php index 950572d..1a3c1ac 100644 --- a/app/Enums/ParticipateStatus.php +++ b/app/Enums/ParticipateStatus.php @@ -4,6 +4,8 @@ namespace App\Enums; enum ParticipateStatus: string { + case Registered = 'registered'; + case InProgress = 'in_progress'; case Started = 'started'; case Completed = 'completed'; } diff --git a/app/Filament/Resources/Hunts/HuntsResource.php b/app/Filament/Resources/Hunts/HuntsResource.php index 71434ec..fa15ae8 100644 --- a/app/Filament/Resources/Hunts/HuntsResource.php +++ b/app/Filament/Resources/Hunts/HuntsResource.php @@ -10,6 +10,7 @@ use App\Filament\Resources\Hunts\Pages\EditHunts; use App\Filament\Resources\Hunts\Pages\ListHunts; use App\Models\Hunts; use BackedEnum; +use Dotswan\MapPicker\Fields\Map; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; @@ -23,8 +24,7 @@ use Filament\Schemas\Schema; use Filament\Tables; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; -use Dotswan\MapPicker\Fields\Map; -use Filament\Resources\Forms\Form; + class HuntsResource extends Resource { public static function getEloquentQuery(): Builder @@ -66,50 +66,53 @@ class HuntsResource extends Resource TextInput::make('city') ->required() ->maxLength(255), + TextInput::make('start_radius_m') + ->numeric() + ->label('Start radius (meters)') + ->default(50), Forms\Components\Hidden::make('latitude'), Forms\Components\Hidden::make('longitude'), DateTimePicker::make('start_at'), DateTimePicker::make('end_at'), // Map - Map::make('location') - ->label('Location') - ->columnSpanFull() + Map::make('location') + ->label('Location') + ->columnSpanFull() // Basic Configuration - ->defaultLocation(latitude: 40.4168, longitude: -3.7038) - ->draggable(true) - ->clickable(true) // click to move marker - ->zoom(15) - ->minZoom(0) - ->maxZoom(28) - ->tilesUrl("https://tile.openstreetmap.org/{z}/{x}/{y}.png") - ->detectRetina(true) + ->defaultLocation(latitude: 40.4168, longitude: -3.7038) + ->draggable(true) + ->clickable(true) // click to move marker + ->zoom(15) + ->minZoom(0) + ->maxZoom(28) + ->tilesUrl('https://tile.openstreetmap.org/{z}/{x}/{y}.png') + ->detectRetina(true) + ->showFullscreenControl(false) + ->showZoomControl(true) - ->showFullscreenControl(false) - ->showZoomControl(true) - // Location Features - ->showMyLocationButton(true) - ->boundaries(true, 49.5, -11, 61, 2) - ->rangeSelectField('distance') - + ->showMyLocationButton(true) + ->boundaries(true, 49.5, -11, 61, 2) + ->rangeSelectField('start_radius_m') + // Extra Customization - ->extraStyles([ - 'border-radius: 10px' - ]) + ->extraStyles([ + 'border-radius: 10px', + ]) // State Management - ->afterStateUpdated(function ($set, ?array $state): void { - $set('latitude', $state['lat']); - $set('longitude', $state['lng']); - }) - ->afterStateHydrated(function ($state, $record, $set): void { - if ($record && $record->latitude && $record->longitude) { - $set('location', [ - 'lat' => $record->latitude, - 'lng' => $record->longitude, - ]); - } - }) + ->afterStateUpdated(function ($set, ?array $state): void { + $set('latitude', $state['lat']); + $set('longitude', $state['lng']); + }) + ->afterStateHydrated(function ($state, $record, $set): void { + if ($record && $record->latitude && $record->longitude) { + $set('location', [ + 'lat' => $record->latitude, + 'lng' => $record->longitude, + ]); + } + }), ]); } diff --git a/app/Filament/Resources/Hunts/RelationManagers/ParticipantsRelationManager.php b/app/Filament/Resources/Hunts/RelationManagers/ParticipantsRelationManager.php index 63a432b..5150ba8 100644 --- a/app/Filament/Resources/Hunts/RelationManagers/ParticipantsRelationManager.php +++ b/app/Filament/Resources/Hunts/RelationManagers/ParticipantsRelationManager.php @@ -38,6 +38,7 @@ class ParticipantsRelationManager extends RelationManager ->color(fn (string $state): string => match ($state) { 'completed' => 'success', 'in_progress' => 'warning', + 'registered' => 'info', default => 'gray', }), Tables\Columns\TextColumn::make('pivot.created_at') diff --git a/app/Http/Controllers/HuntStepsController.php b/app/Http/Controllers/HuntStepsController.php index 45b20d2..5007145 100644 --- a/app/Http/Controllers/HuntStepsController.php +++ b/app/Http/Controllers/HuntStepsController.php @@ -2,92 +2,70 @@ namespace App\Http\Controllers; +use App\Enums\ParticipateStatus; +use App\Http\Requests\StoreHuntStepRequest; +use App\Models\Hunts; +use App\Models\HuntSteps; +use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; + class HuntStepsController extends Controller { - /** - * Display a listing of the resource. - */ - public function index() - { - // - } - - /** - * Show the form for creating a new resource. - */ - public function create() - { - // - } - - /** - * Store a newly created resource in storage. - */ - public function store(\App\Http\Requests\StoreHuntStepRequest $request, \App\Models\Hunts $hunt) + public function store(StoreHuntStepRequest $request, Hunts $hunt): JsonResponse { if ($hunt->creator_id !== auth()->id()) { return response()->json(['message' => 'Unauthorized'], 403); } - $validated = $request->validated(); - - $step = $hunt->steps()->create($validated); + $step = $hunt->steps()->create($request->validated()); return response()->json($step, 201); } - /** - * Validate a hunt step. - */ - public function validateStep(\Illuminate\Http\Request $request, \App\Models\Hunts $hunt, \App\Models\HuntSteps $step) + public function validateStep(Request $request, Hunts $hunt, HuntSteps $step): JsonResponse { - $request->validate([ + $validated = $request->validate([ 'latitude' => 'required|numeric', 'longitude' => 'required|numeric', ]); $user = $request->user(); - // 1. Check participation + if ((int) $step->hunt_id !== (int) $hunt->id) { + return response()->json(['message' => 'Step does not belong to this hunt'], 404); + } + $participation = $user->participations()->where('hunt_id', $hunt->id)->first(); if (! $participation) { return response()->json(['message' => 'Not participating in this hunt'], 403); } - // 2. Check if this is the current step - if ($participation->pivot->current_step_number !== $step->step_number) { + if ($participation->pivot->status !== ParticipateStatus::InProgress->value) { + return response()->json(['message' => 'Hunt is joined but not started'], 409); + } + + if ((int) $participation->pivot->current_step_number !== (int) $step->step_number) { return response()->json(['message' => 'This is not your current step'], 400); } - // 3. Check proximity - // Haversine formula - $earthRadius = 6371000; // meters - - $latFrom = deg2rad($request->latitude); - $lonFrom = deg2rad($request->longitude); - $latTo = deg2rad($step->latitude); - $lonTo = deg2rad($step->longitude); - - $latDelta = $latTo - $latFrom; - $lonDelta = $lonTo - $lonFrom; - - $angle = 2 * asin(sqrt(pow(sin($latDelta / 2), 2) + - cos($latFrom) * cos($latTo) * pow(sin($lonDelta / 2), 2))); - - $distance = $angle * $earthRadius; + $distance = $this->distanceMeters( + (float) $validated['latitude'], + (float) $validated['longitude'], + (float) $step->latitude, + (float) $step->longitude, + ); if ($distance > $step->radius_m) { return response()->json([ 'success' => false, 'message' => 'You are too far from the target.', - 'distance' => round($distance).'m', + 'distance_m' => round($distance), + 'radius_m' => $step->radius_m, ], 400); } - // 4. Update progress - // Check if there is a next step - $nextStepNumber = $step->step_number + 1; + $nextStepNumber = (int) $step->step_number + 1; $nextStep = $hunt->steps()->where('step_number', $nextStepNumber)->first(); $updateData = []; @@ -95,39 +73,64 @@ class HuntStepsController extends Controller if ($nextStep) { $updateData['current_step_number'] = $nextStepNumber; } else { - $updateData['status'] = 'completed'; - // Keep current_step_number as is (last step) or increment it to indicate "done"? - // Usually keeping it at last step or marking status completed is enough. - // Let's verify existing logic in HuntsController::show which filters based on step number. - // If we increment, show() might need adjustment to show everything if completed. - // For now, let's just mark completed and maybe keep same step number or increment. - // If we increment, $nextStep is null, so user matches nothing in filter? - // Let's increment current_step_number so that in show(), $step->step_number < $currentStepNumber becomes true for all steps. $updateData['current_step_number'] = $nextStepNumber; + $updateData['status'] = ParticipateStatus::Completed->value; } $hunt->participants()->updateExistingPivot($user->id, $updateData); - // 5. Return response $response = [ 'success' => true, - 'message' => 'Step validated!', + 'message' => 'Step validated', + 'revealed_hint' => [ + 'title' => $step->title, + 'hint_text' => $step->hint_text, + 'hint_media_url' => $step->hint_media_url, + ], ]; if ($nextStep) { - $response['next_step'] = [ - 'step_number' => $nextStep->step_number, - 'title' => $nextStep->title, - 'description' => $nextStep->description, - 'hint_text' => $nextStep->hint_text, - 'hint_media_url' => $nextStep->hint_media_url, - // Do NOT include lat/long - ]; + $response['next_step'] = $this->stepTargetPayload($nextStep); } else { - $response['message'] = 'Hunt completed! Congratulations!'; + $response['message'] = 'Hunt completed'; $response['completed'] = true; } return response()->json($response); } + + /** + * @return array + */ + 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', + ]; + } + + 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; + } } diff --git a/app/Http/Controllers/HuntsController.php b/app/Http/Controllers/HuntsController.php index 6fcd161..e09db5a 100644 --- a/app/Http/Controllers/HuntsController.php +++ b/app/Http/Controllers/HuntsController.php @@ -3,8 +3,10 @@ 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; @@ -28,7 +30,7 @@ class HuntsController extends Controller ->options(['filter' => $filters]); $result = $search->paginate(10); - $this->appendTotalXpToHunts($result->getCollection()); + $this->appendListMetadataToHunts($result->getCollection(), $request->user()?->id); Log::info('HUNTS_SEARCH_SUCCESS', [ 'total' => $result->total(), @@ -87,6 +89,7 @@ class HuntsController extends Controller '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', @@ -150,6 +153,7 @@ class HuntsController extends Controller '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', @@ -250,9 +254,11 @@ class HuntsController extends Controller // Ajouter l'utilisateur comme participant $hunt->participants()->attach($user->id, [ 'current_step_number' => 0, - 'status' => 'in_progress', + 'status' => ParticipateStatus::Registered->value, ]); + $this->refreshSearchIndex($hunt); + $hunt->loadMissing('creator'); if ($hunt->creator !== null && $hunt->creator->getKey() !== $user->getKey()) { @@ -266,6 +272,12 @@ class HuntsController extends Controller 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) { @@ -283,6 +295,125 @@ class HuntsController extends Controller } } + 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 { @@ -298,6 +429,7 @@ class HuntsController extends Controller // Retirer l'utilisateur des participants $hunt->participants()->detach($user->id); + $this->refreshSearchIndex($hunt); Log::info('HUNT_UNPARTICIPATE', [ 'hunt_id' => $hunt->id, @@ -456,14 +588,91 @@ class HuntsController extends Controller /** * @param Collection $hunts */ - private function appendTotalXpToHunts(Collection $hunts): void + private function appendListMetadataToHunts(Collection $hunts, string|int|null $userId): void { - $hunts->each(function (Hunts $hunt): 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 + */ + 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 + */ + 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) === '') { diff --git a/app/Http/Requests/StoreHuntRequest.php b/app/Http/Requests/StoreHuntRequest.php index 0b32b92..198ac34 100644 --- a/app/Http/Requests/StoreHuntRequest.php +++ b/app/Http/Requests/StoreHuntRequest.php @@ -33,6 +33,7 @@ class StoreHuntRequest extends FormRequest 'city' => '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', diff --git a/app/Http/Requests/UpdateHuntRequest.php b/app/Http/Requests/UpdateHuntRequest.php index 4a84480..d1f1775 100644 --- a/app/Http/Requests/UpdateHuntRequest.php +++ b/app/Http/Requests/UpdateHuntRequest.php @@ -38,6 +38,7 @@ class UpdateHuntRequest extends FormRequest 'city' => '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', diff --git a/app/Http/Resources/HuntResource.php b/app/Http/Resources/HuntResource.php index b9ca0de..78bfadd 100644 --- a/app/Http/Resources/HuntResource.php +++ b/app/Http/Resources/HuntResource.php @@ -16,8 +16,39 @@ class HuntResource extends JsonResource */ public function toArray(Request $request): array { + $participationStatus = $this->participationStatus($request); + return array_merge(parent::toArray($request), [ 'xp' => $this->resource->totalXp(), + 'is_participating' => $participationStatus !== null, + 'participation_status' => $participationStatus, ]); } + + /** + * @return array|null + */ + private function participationStatus(Request $request): ?array + { + $user = $request->user(); + + if ($user === null || ! $this->resource->relationLoaded('participants')) { + return null; + } + + $participant = $this->resource->participants->firstWhere('id', $user->id); + + if ($participant === null || $participant->pivot === null) { + return null; + } + + return [ + 'user_id' => $participant->pivot->user_id, + 'hunt_id' => $participant->pivot->hunt_id, + 'current_step_number' => (int) $participant->pivot->current_step_number, + 'status' => $participant->pivot->status, + 'created_at' => $participant->pivot->created_at?->toJSON(), + 'updated_at' => $participant->pivot->updated_at?->toJSON(), + ]; + } } diff --git a/app/Models/Hunts.php b/app/Models/Hunts.php index e4d9399..2f2be6b 100644 --- a/app/Models/Hunts.php +++ b/app/Models/Hunts.php @@ -28,6 +28,7 @@ class Hunts extends Model 'city', 'latitude', 'longitude', + 'start_radius_m', 'is_public', 'estimated_duration_min', 'start_at', @@ -42,6 +43,7 @@ class Hunts extends Model 'end_at' => 'datetime', 'latitude' => 'float', 'longitude' => 'float', + 'start_radius_m' => 'integer', 'estimated_duration_min' => 'integer', 'status' => HuntStatus::class, 'difficulty' => HuntDifficulty::class, @@ -117,6 +119,7 @@ class Hunts extends Model * creator_id: int, * latitude: float|null, * longitude: float|null, + * start_radius_m: int, * start_at: string|null, * end_at: string|null, * start_at_timestamp: int|null, @@ -152,6 +155,7 @@ class Hunts extends Model 'creator_id' => $this->creator_id, 'latitude' => $this->latitude, 'longitude' => $this->longitude, + 'start_radius_m' => $this->start_radius_m, 'start_at' => $this->start_at?->toAtomString(), 'end_at' => $this->end_at?->toAtomString(), // Timestamps Unix pour les filtres de date dans Meilisearch diff --git a/database/factories/HuntsFactory.php b/database/factories/HuntsFactory.php index 7f70a6b..8df3c75 100644 --- a/database/factories/HuntsFactory.php +++ b/database/factories/HuntsFactory.php @@ -26,6 +26,7 @@ class HuntsFactory extends Factory 'city' => $this->faker->city(), 'latitude' => $this->faker->latitude(), 'longitude' => $this->faker->longitude(), + 'start_radius_m' => 50, 'is_public' => true, 'estimated_duration_min' => $this->faker->numberBetween(30, 180), 'image' => $this->faker->imageUrl(), diff --git a/database/migrations/2025_12_19_113519_create_hunt_participants_table.php b/database/migrations/2025_12_19_113519_create_hunt_participants_table.php index 0b4804f..0b0a5d8 100644 --- a/database/migrations/2025_12_19_113519_create_hunt_participants_table.php +++ b/database/migrations/2025_12_19_113519_create_hunt_participants_table.php @@ -17,7 +17,7 @@ return new class extends Migration $table->foreignUuid('user_id')->constrained()->cascadeOnDelete(); $table->foreignId('hunt_id')->constrained('hunts')->cascadeOnDelete(); $table->unsignedSmallInteger('current_step_number')->default(1); - $table->string('status')->default(ParticipateStatus::Started->value); + $table->string('status')->default(ParticipateStatus::Registered->value); $table->timestamps(); }); } diff --git a/database/migrations/2026_04_30_075838_add_start_radius_to_hunts.php b/database/migrations/2026_04_30_075838_add_start_radius_to_hunts.php new file mode 100644 index 0000000..054fa47 --- /dev/null +++ b/database/migrations/2026_04_30_075838_add_start_radius_to_hunts.php @@ -0,0 +1,38 @@ +unsignedSmallInteger('start_radius_m')->default(50)->after('longitude'); + }); + + DB::table('hunt_participants') + ->where('status', 'started') + ->update(['status' => 'registered']); + + DB::table('hunt_participants') + ->where('status', 'in_progress') + ->where('current_step_number', 0) + ->update(['status' => 'registered']); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('hunts', function (Blueprint $table) { + $table->dropColumn('start_radius_m'); + }); + } +}; diff --git a/routes/api.php b/routes/api.php index acfe53a..d646656 100644 --- a/routes/api.php +++ b/routes/api.php @@ -54,6 +54,8 @@ Route::middleware(['auth:sanctum', 'verified'])->group(function (): void { Route::get('users/{user}', [UsersController::class, 'show']); Route::post('users/{user}/avatar', [UsersController::class, 'updateAvatar']); Route::post('hunts/{hunt}/participate', [HuntsController::class, 'participate']); + Route::post('hunts/{hunt}/start', [HuntsController::class, 'start']); + Route::get('hunts/{hunt}/current-step', [HuntsController::class, 'currentStep']); Route::delete('hunts/{hunt}/participate', [HuntsController::class, 'unparticipate']); Route::post('hunts/{hunt}/steps/{step}/validate', [HuntStepsController::class, 'validateStep']); }); diff --git a/tests/Feature/HuntProgressTest.php b/tests/Feature/HuntProgressTest.php new file mode 100644 index 0000000..9e5f8bc --- /dev/null +++ b/tests/Feature/HuntProgressTest.php @@ -0,0 +1,136 @@ +create(); + $hunt = Hunts::withoutSyncingToSearch(fn () => Hunts::factory()->create()); + + $response = $this + ->actingAs($user, 'sanctum') + ->postJson("/api/hunts/{$hunt->id}/participate"); + + $response + ->assertOk() + ->assertJsonPath('participation_status.current_step_number', 0) + ->assertJsonPath('participation_status.status', ParticipateStatus::Registered->value); + + $this->assertDatabaseHas('hunt_participants', [ + 'user_id' => $user->id, + 'hunt_id' => $hunt->id, + 'current_step_number' => 0, + 'status' => ParticipateStatus::Registered->value, + ]); +}); + +it('starts a joined hunt only when the user is inside the start radius', function () { + $user = User::factory()->create(); + $hunt = Hunts::withoutSyncingToSearch(fn () => Hunts::factory()->create([ + 'latitude' => 44.837789, + 'longitude' => -0.57918, + 'start_radius_m' => 40, + ])); + $step = HuntSteps::factory()->create([ + 'hunt_id' => $hunt->id, + 'step_number' => 1, + 'latitude' => 44.838, + 'longitude' => -0.579, + ]); + + $hunt->participants()->attach($user->id, [ + 'current_step_number' => 0, + 'status' => ParticipateStatus::Registered->value, + ]); + + $tooFarResponse = $this + ->actingAs($user, 'sanctum') + ->postJson("/api/hunts/{$hunt->id}/start", [ + 'latitude' => 44.85, + 'longitude' => -0.59, + ]); + + $tooFarResponse->assertBadRequest(); + + $response = $this + ->actingAs($user, 'sanctum') + ->postJson("/api/hunts/{$hunt->id}/start", [ + 'latitude' => 44.837789, + 'longitude' => -0.57918, + ]); + + $response + ->assertOk() + ->assertJsonPath('participation_status.current_step_number', 1) + ->assertJsonPath('participation_status.status', ParticipateStatus::InProgress->value) + ->assertJsonPath('current_step.id', $step->id) + ->assertJsonPath('current_step.step_number', 1); +}); + +it('does not expose a current step before the hunt is started', function () { + $user = User::factory()->create(); + $hunt = Hunts::withoutSyncingToSearch(fn () => Hunts::factory()->create()); + + $hunt->participants()->attach($user->id, [ + 'current_step_number' => 0, + 'status' => ParticipateStatus::Registered->value, + ]); + + $response = $this + ->actingAs($user, 'sanctum') + ->getJson("/api/hunts/{$hunt->id}/current-step"); + + $response + ->assertStatus(409) + ->assertJsonPath('participation_status.status', ParticipateStatus::Registered->value); +}); + +it('validates the current step and returns the next step target', function () { + $user = User::factory()->create(); + $hunt = Hunts::withoutSyncingToSearch(fn () => Hunts::factory()->create()); + $firstStep = HuntSteps::factory()->create([ + 'hunt_id' => $hunt->id, + 'step_number' => 1, + 'title' => 'First clue', + 'hint_text' => 'Go to the next chest', + 'latitude' => 44.837789, + 'longitude' => -0.57918, + 'radius_m' => 30, + ]); + $nextStep = HuntSteps::factory()->create([ + 'hunt_id' => $hunt->id, + 'step_number' => 2, + 'latitude' => 44.838, + 'longitude' => -0.579, + ]); + + $hunt->participants()->attach($user->id, [ + 'current_step_number' => 1, + 'status' => ParticipateStatus::InProgress->value, + ]); + + $response = $this + ->actingAs($user, 'sanctum') + ->postJson("/api/hunts/{$hunt->id}/steps/{$firstStep->id}/validate", [ + 'latitude' => 44.837789, + 'longitude' => -0.57918, + ]); + + $response + ->assertOk() + ->assertJsonPath('revealed_hint.hint_text', 'Go to the next chest') + ->assertJsonPath('next_step.id', $nextStep->id) + ->assertJsonPath('next_step.step_number', 2); + + $this->assertDatabaseHas('hunt_participants', [ + 'user_id' => $user->id, + 'hunt_id' => $hunt->id, + 'current_step_number' => 2, + 'status' => ParticipateStatus::InProgress->value, + ]); +});