feat: participation
This commit is contained in:
parent
9cd57aa79e
commit
7373111927
|
|
@ -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 }}"
|
||||||
|
|
@ -4,6 +4,8 @@ namespace App\Enums;
|
||||||
|
|
||||||
enum ParticipateStatus: string
|
enum ParticipateStatus: string
|
||||||
{
|
{
|
||||||
|
case Registered = 'registered';
|
||||||
|
case InProgress = 'in_progress';
|
||||||
case Started = 'started';
|
case Started = 'started';
|
||||||
case Completed = 'completed';
|
case Completed = 'completed';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ use App\Filament\Resources\Hunts\Pages\EditHunts;
|
||||||
use App\Filament\Resources\Hunts\Pages\ListHunts;
|
use App\Filament\Resources\Hunts\Pages\ListHunts;
|
||||||
use App\Models\Hunts;
|
use App\Models\Hunts;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
|
use Dotswan\MapPicker\Fields\Map;
|
||||||
use Filament\Actions\BulkActionGroup;
|
use Filament\Actions\BulkActionGroup;
|
||||||
use Filament\Actions\DeleteBulkAction;
|
use Filament\Actions\DeleteBulkAction;
|
||||||
use Filament\Actions\EditAction;
|
use Filament\Actions\EditAction;
|
||||||
|
|
@ -23,8 +24,7 @@ use Filament\Schemas\Schema;
|
||||||
use Filament\Tables;
|
use Filament\Tables;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Dotswan\MapPicker\Fields\Map;
|
|
||||||
use Filament\Resources\Forms\Form;
|
|
||||||
class HuntsResource extends Resource
|
class HuntsResource extends Resource
|
||||||
{
|
{
|
||||||
public static function getEloquentQuery(): Builder
|
public static function getEloquentQuery(): Builder
|
||||||
|
|
@ -66,50 +66,53 @@ class HuntsResource extends Resource
|
||||||
TextInput::make('city')
|
TextInput::make('city')
|
||||||
->required()
|
->required()
|
||||||
->maxLength(255),
|
->maxLength(255),
|
||||||
|
TextInput::make('start_radius_m')
|
||||||
|
->numeric()
|
||||||
|
->label('Start radius (meters)')
|
||||||
|
->default(50),
|
||||||
Forms\Components\Hidden::make('latitude'),
|
Forms\Components\Hidden::make('latitude'),
|
||||||
Forms\Components\Hidden::make('longitude'),
|
Forms\Components\Hidden::make('longitude'),
|
||||||
DateTimePicker::make('start_at'),
|
DateTimePicker::make('start_at'),
|
||||||
DateTimePicker::make('end_at'),
|
DateTimePicker::make('end_at'),
|
||||||
|
|
||||||
// Map
|
// Map
|
||||||
Map::make('location')
|
Map::make('location')
|
||||||
->label('Location')
|
->label('Location')
|
||||||
->columnSpanFull()
|
->columnSpanFull()
|
||||||
// Basic Configuration
|
// Basic Configuration
|
||||||
->defaultLocation(latitude: 40.4168, longitude: -3.7038)
|
->defaultLocation(latitude: 40.4168, longitude: -3.7038)
|
||||||
->draggable(true)
|
->draggable(true)
|
||||||
->clickable(true) // click to move marker
|
->clickable(true) // click to move marker
|
||||||
->zoom(15)
|
->zoom(15)
|
||||||
->minZoom(0)
|
->minZoom(0)
|
||||||
->maxZoom(28)
|
->maxZoom(28)
|
||||||
->tilesUrl("https://tile.openstreetmap.org/{z}/{x}/{y}.png")
|
->tilesUrl('https://tile.openstreetmap.org/{z}/{x}/{y}.png')
|
||||||
->detectRetina(true)
|
->detectRetina(true)
|
||||||
|
->showFullscreenControl(false)
|
||||||
->showFullscreenControl(false)
|
->showZoomControl(true)
|
||||||
->showZoomControl(true)
|
|
||||||
|
|
||||||
// Location Features
|
// Location Features
|
||||||
->showMyLocationButton(true)
|
->showMyLocationButton(true)
|
||||||
->boundaries(true, 49.5, -11, 61, 2)
|
->boundaries(true, 49.5, -11, 61, 2)
|
||||||
->rangeSelectField('distance')
|
->rangeSelectField('start_radius_m')
|
||||||
|
|
||||||
// Extra Customization
|
// Extra Customization
|
||||||
->extraStyles([
|
->extraStyles([
|
||||||
'border-radius: 10px'
|
'border-radius: 10px',
|
||||||
])
|
])
|
||||||
// State Management
|
// State Management
|
||||||
->afterStateUpdated(function ($set, ?array $state): void {
|
->afterStateUpdated(function ($set, ?array $state): void {
|
||||||
$set('latitude', $state['lat']);
|
$set('latitude', $state['lat']);
|
||||||
$set('longitude', $state['lng']);
|
$set('longitude', $state['lng']);
|
||||||
})
|
})
|
||||||
->afterStateHydrated(function ($state, $record, $set): void {
|
->afterStateHydrated(function ($state, $record, $set): void {
|
||||||
if ($record && $record->latitude && $record->longitude) {
|
if ($record && $record->latitude && $record->longitude) {
|
||||||
$set('location', [
|
$set('location', [
|
||||||
'lat' => $record->latitude,
|
'lat' => $record->latitude,
|
||||||
'lng' => $record->longitude,
|
'lng' => $record->longitude,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
})
|
}),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ class ParticipantsRelationManager extends RelationManager
|
||||||
->color(fn (string $state): string => match ($state) {
|
->color(fn (string $state): string => match ($state) {
|
||||||
'completed' => 'success',
|
'completed' => 'success',
|
||||||
'in_progress' => 'warning',
|
'in_progress' => 'warning',
|
||||||
|
'registered' => 'info',
|
||||||
default => 'gray',
|
default => 'gray',
|
||||||
}),
|
}),
|
||||||
Tables\Columns\TextColumn::make('pivot.created_at')
|
Tables\Columns\TextColumn::make('pivot.created_at')
|
||||||
|
|
|
||||||
|
|
@ -2,92 +2,70 @@
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
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
|
class HuntStepsController extends Controller
|
||||||
{
|
{
|
||||||
/**
|
public function store(StoreHuntStepRequest $request, Hunts $hunt): JsonResponse
|
||||||
* 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)
|
|
||||||
{
|
{
|
||||||
if ($hunt->creator_id !== auth()->id()) {
|
if ($hunt->creator_id !== auth()->id()) {
|
||||||
return response()->json(['message' => 'Unauthorized'], 403);
|
return response()->json(['message' => 'Unauthorized'], 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
$validated = $request->validated();
|
$step = $hunt->steps()->create($request->validated());
|
||||||
|
|
||||||
$step = $hunt->steps()->create($validated);
|
|
||||||
|
|
||||||
return response()->json($step, 201);
|
return response()->json($step, 201);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public function validateStep(Request $request, Hunts $hunt, HuntSteps $step): JsonResponse
|
||||||
* Validate a hunt step.
|
|
||||||
*/
|
|
||||||
public function validateStep(\Illuminate\Http\Request $request, \App\Models\Hunts $hunt, \App\Models\HuntSteps $step)
|
|
||||||
{
|
{
|
||||||
$request->validate([
|
$validated = $request->validate([
|
||||||
'latitude' => 'required|numeric',
|
'latitude' => 'required|numeric',
|
||||||
'longitude' => 'required|numeric',
|
'longitude' => 'required|numeric',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user = $request->user();
|
$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();
|
$participation = $user->participations()->where('hunt_id', $hunt->id)->first();
|
||||||
|
|
||||||
if (! $participation) {
|
if (! $participation) {
|
||||||
return response()->json(['message' => 'Not participating in this hunt'], 403);
|
return response()->json(['message' => 'Not participating in this hunt'], 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Check if this is the current step
|
if ($participation->pivot->status !== ParticipateStatus::InProgress->value) {
|
||||||
if ($participation->pivot->current_step_number !== $step->step_number) {
|
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);
|
return response()->json(['message' => 'This is not your current step'], 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Check proximity
|
$distance = $this->distanceMeters(
|
||||||
// Haversine formula
|
(float) $validated['latitude'],
|
||||||
$earthRadius = 6371000; // meters
|
(float) $validated['longitude'],
|
||||||
|
(float) $step->latitude,
|
||||||
$latFrom = deg2rad($request->latitude);
|
(float) $step->longitude,
|
||||||
$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;
|
|
||||||
|
|
||||||
if ($distance > $step->radius_m) {
|
if ($distance > $step->radius_m) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'message' => 'You are too far from the target.',
|
'message' => 'You are too far from the target.',
|
||||||
'distance' => round($distance).'m',
|
'distance_m' => round($distance),
|
||||||
|
'radius_m' => $step->radius_m,
|
||||||
], 400);
|
], 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Update progress
|
$nextStepNumber = (int) $step->step_number + 1;
|
||||||
// Check if there is a next step
|
|
||||||
$nextStepNumber = $step->step_number + 1;
|
|
||||||
$nextStep = $hunt->steps()->where('step_number', $nextStepNumber)->first();
|
$nextStep = $hunt->steps()->where('step_number', $nextStepNumber)->first();
|
||||||
|
|
||||||
$updateData = [];
|
$updateData = [];
|
||||||
|
|
@ -95,39 +73,64 @@ class HuntStepsController extends Controller
|
||||||
if ($nextStep) {
|
if ($nextStep) {
|
||||||
$updateData['current_step_number'] = $nextStepNumber;
|
$updateData['current_step_number'] = $nextStepNumber;
|
||||||
} else {
|
} 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['current_step_number'] = $nextStepNumber;
|
||||||
|
$updateData['status'] = ParticipateStatus::Completed->value;
|
||||||
}
|
}
|
||||||
|
|
||||||
$hunt->participants()->updateExistingPivot($user->id, $updateData);
|
$hunt->participants()->updateExistingPivot($user->id, $updateData);
|
||||||
|
|
||||||
// 5. Return response
|
|
||||||
$response = [
|
$response = [
|
||||||
'success' => true,
|
'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) {
|
if ($nextStep) {
|
||||||
$response['next_step'] = [
|
$response['next_step'] = $this->stepTargetPayload($nextStep);
|
||||||
'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
|
|
||||||
];
|
|
||||||
} else {
|
} else {
|
||||||
$response['message'] = 'Hunt completed! Congratulations!';
|
$response['message'] = 'Hunt completed';
|
||||||
$response['completed'] = true;
|
$response['completed'] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return response()->json($response);
|
return response()->json($response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,10 @@
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Enums\HuntDifficulty;
|
use App\Enums\HuntDifficulty;
|
||||||
|
use App\Enums\ParticipateStatus;
|
||||||
use App\Http\Resources\HuntResource;
|
use App\Http\Resources\HuntResource;
|
||||||
use App\Models\Hunts;
|
use App\Models\Hunts;
|
||||||
|
use App\Models\HuntSteps;
|
||||||
use App\Notifications\HuntParticipantJoinedNotification;
|
use App\Notifications\HuntParticipantJoinedNotification;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
@ -28,7 +30,7 @@ class HuntsController extends Controller
|
||||||
->options(['filter' => $filters]);
|
->options(['filter' => $filters]);
|
||||||
|
|
||||||
$result = $search->paginate(10);
|
$result = $search->paginate(10);
|
||||||
$this->appendTotalXpToHunts($result->getCollection());
|
$this->appendListMetadataToHunts($result->getCollection(), $request->user()?->id);
|
||||||
|
|
||||||
Log::info('HUNTS_SEARCH_SUCCESS', [
|
Log::info('HUNTS_SEARCH_SUCCESS', [
|
||||||
'total' => $result->total(),
|
'total' => $result->total(),
|
||||||
|
|
@ -87,6 +89,7 @@ class HuntsController extends Controller
|
||||||
'difficulty' => 'required|string',
|
'difficulty' => 'required|string',
|
||||||
'latitude' => 'nullable|numeric',
|
'latitude' => 'nullable|numeric',
|
||||||
'longitude' => 'nullable|numeric',
|
'longitude' => 'nullable|numeric',
|
||||||
|
'start_radius_m' => 'nullable|integer|min:1',
|
||||||
'is_public' => 'boolean',
|
'is_public' => 'boolean',
|
||||||
'estimated_duration_min' => 'nullable|integer',
|
'estimated_duration_min' => 'nullable|integer',
|
||||||
'start_at' => 'nullable|date',
|
'start_at' => 'nullable|date',
|
||||||
|
|
@ -150,6 +153,7 @@ class HuntsController extends Controller
|
||||||
'difficulty' => 'sometimes|string',
|
'difficulty' => 'sometimes|string',
|
||||||
'latitude' => 'nullable|numeric',
|
'latitude' => 'nullable|numeric',
|
||||||
'longitude' => 'nullable|numeric',
|
'longitude' => 'nullable|numeric',
|
||||||
|
'start_radius_m' => 'nullable|integer|min:1',
|
||||||
'is_public' => 'boolean',
|
'is_public' => 'boolean',
|
||||||
'estimated_duration_min' => 'nullable|integer',
|
'estimated_duration_min' => 'nullable|integer',
|
||||||
'start_at' => 'nullable|date',
|
'start_at' => 'nullable|date',
|
||||||
|
|
@ -250,9 +254,11 @@ class HuntsController extends Controller
|
||||||
// Ajouter l'utilisateur comme participant
|
// Ajouter l'utilisateur comme participant
|
||||||
$hunt->participants()->attach($user->id, [
|
$hunt->participants()->attach($user->id, [
|
||||||
'current_step_number' => 0,
|
'current_step_number' => 0,
|
||||||
'status' => 'in_progress',
|
'status' => ParticipateStatus::Registered->value,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$this->refreshSearchIndex($hunt);
|
||||||
|
|
||||||
$hunt->loadMissing('creator');
|
$hunt->loadMissing('creator');
|
||||||
|
|
||||||
if ($hunt->creator !== null && $hunt->creator->getKey() !== $user->getKey()) {
|
if ($hunt->creator !== null && $hunt->creator->getKey() !== $user->getKey()) {
|
||||||
|
|
@ -266,6 +272,12 @@ class HuntsController extends Controller
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'message' => 'Successfully joined the hunt',
|
'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'),
|
'hunt' => $hunt->load('participants:id,username,avatar_uri'),
|
||||||
]);
|
]);
|
||||||
} catch (\Throwable $e) {
|
} 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
|
public function unparticipate(Hunts $hunt): JsonResponse
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
|
@ -298,6 +429,7 @@ class HuntsController extends Controller
|
||||||
|
|
||||||
// Retirer l'utilisateur des participants
|
// Retirer l'utilisateur des participants
|
||||||
$hunt->participants()->detach($user->id);
|
$hunt->participants()->detach($user->id);
|
||||||
|
$this->refreshSearchIndex($hunt);
|
||||||
|
|
||||||
Log::info('HUNT_UNPARTICIPATE', [
|
Log::info('HUNT_UNPARTICIPATE', [
|
||||||
'hunt_id' => $hunt->id,
|
'hunt_id' => $hunt->id,
|
||||||
|
|
@ -456,14 +588,91 @@ class HuntsController extends Controller
|
||||||
/**
|
/**
|
||||||
* @param Collection<int, Hunts> $hunts
|
* @param Collection<int, Hunts> $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('xp', $hunt->totalXp());
|
||||||
|
$hunt->setAttribute('is_participating', false);
|
||||||
|
$hunt->setAttribute('participation_status', null);
|
||||||
$hunt->makeHidden('steps_sum_xp');
|
$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
|
private function parseDateFilterUtc(mixed $value): ?Carbon
|
||||||
{
|
{
|
||||||
if (! is_string($value) || trim($value) === '') {
|
if (! is_string($value) || trim($value) === '') {
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ class StoreHuntRequest extends FormRequest
|
||||||
'city' => 'required|string',
|
'city' => 'required|string',
|
||||||
'latitude' => 'nullable|numeric',
|
'latitude' => 'nullable|numeric',
|
||||||
'longitude' => 'nullable|numeric',
|
'longitude' => 'nullable|numeric',
|
||||||
|
'start_radius_m' => 'nullable|integer|min:1',
|
||||||
'is_public' => 'boolean',
|
'is_public' => 'boolean',
|
||||||
'estimated_duration_min' => 'nullable|integer',
|
'estimated_duration_min' => 'nullable|integer',
|
||||||
'start_at' => 'nullable|date',
|
'start_at' => 'nullable|date',
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ class UpdateHuntRequest extends FormRequest
|
||||||
'city' => 'sometimes|string',
|
'city' => 'sometimes|string',
|
||||||
'latitude' => 'nullable|numeric',
|
'latitude' => 'nullable|numeric',
|
||||||
'longitude' => 'nullable|numeric',
|
'longitude' => 'nullable|numeric',
|
||||||
|
'start_radius_m' => 'nullable|integer|min:1',
|
||||||
'is_public' => 'boolean',
|
'is_public' => 'boolean',
|
||||||
'estimated_duration_min' => 'nullable|integer',
|
'estimated_duration_min' => 'nullable|integer',
|
||||||
'start_at' => 'nullable|date',
|
'start_at' => 'nullable|date',
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,39 @@ class HuntResource extends JsonResource
|
||||||
*/
|
*/
|
||||||
public function toArray(Request $request): array
|
public function toArray(Request $request): array
|
||||||
{
|
{
|
||||||
|
$participationStatus = $this->participationStatus($request);
|
||||||
|
|
||||||
return array_merge(parent::toArray($request), [
|
return array_merge(parent::toArray($request), [
|
||||||
'xp' => $this->resource->totalXp(),
|
'xp' => $this->resource->totalXp(),
|
||||||
|
'is_participating' => $participationStatus !== null,
|
||||||
|
'participation_status' => $participationStatus,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>|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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ class Hunts extends Model
|
||||||
'city',
|
'city',
|
||||||
'latitude',
|
'latitude',
|
||||||
'longitude',
|
'longitude',
|
||||||
|
'start_radius_m',
|
||||||
'is_public',
|
'is_public',
|
||||||
'estimated_duration_min',
|
'estimated_duration_min',
|
||||||
'start_at',
|
'start_at',
|
||||||
|
|
@ -42,6 +43,7 @@ class Hunts extends Model
|
||||||
'end_at' => 'datetime',
|
'end_at' => 'datetime',
|
||||||
'latitude' => 'float',
|
'latitude' => 'float',
|
||||||
'longitude' => 'float',
|
'longitude' => 'float',
|
||||||
|
'start_radius_m' => 'integer',
|
||||||
'estimated_duration_min' => 'integer',
|
'estimated_duration_min' => 'integer',
|
||||||
'status' => HuntStatus::class,
|
'status' => HuntStatus::class,
|
||||||
'difficulty' => HuntDifficulty::class,
|
'difficulty' => HuntDifficulty::class,
|
||||||
|
|
@ -117,6 +119,7 @@ class Hunts extends Model
|
||||||
* creator_id: int,
|
* creator_id: int,
|
||||||
* latitude: float|null,
|
* latitude: float|null,
|
||||||
* longitude: float|null,
|
* longitude: float|null,
|
||||||
|
* start_radius_m: int,
|
||||||
* start_at: string|null,
|
* start_at: string|null,
|
||||||
* end_at: string|null,
|
* end_at: string|null,
|
||||||
* start_at_timestamp: int|null,
|
* start_at_timestamp: int|null,
|
||||||
|
|
@ -152,6 +155,7 @@ class Hunts extends Model
|
||||||
'creator_id' => $this->creator_id,
|
'creator_id' => $this->creator_id,
|
||||||
'latitude' => $this->latitude,
|
'latitude' => $this->latitude,
|
||||||
'longitude' => $this->longitude,
|
'longitude' => $this->longitude,
|
||||||
|
'start_radius_m' => $this->start_radius_m,
|
||||||
'start_at' => $this->start_at?->toAtomString(),
|
'start_at' => $this->start_at?->toAtomString(),
|
||||||
'end_at' => $this->end_at?->toAtomString(),
|
'end_at' => $this->end_at?->toAtomString(),
|
||||||
// Timestamps Unix pour les filtres de date dans Meilisearch
|
// Timestamps Unix pour les filtres de date dans Meilisearch
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ class HuntsFactory extends Factory
|
||||||
'city' => $this->faker->city(),
|
'city' => $this->faker->city(),
|
||||||
'latitude' => $this->faker->latitude(),
|
'latitude' => $this->faker->latitude(),
|
||||||
'longitude' => $this->faker->longitude(),
|
'longitude' => $this->faker->longitude(),
|
||||||
|
'start_radius_m' => 50,
|
||||||
'is_public' => true,
|
'is_public' => true,
|
||||||
'estimated_duration_min' => $this->faker->numberBetween(30, 180),
|
'estimated_duration_min' => $this->faker->numberBetween(30, 180),
|
||||||
'image' => $this->faker->imageUrl(),
|
'image' => $this->faker->imageUrl(),
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ return new class extends Migration
|
||||||
$table->foreignUuid('user_id')->constrained()->cascadeOnDelete();
|
$table->foreignUuid('user_id')->constrained()->cascadeOnDelete();
|
||||||
$table->foreignId('hunt_id')->constrained('hunts')->cascadeOnDelete();
|
$table->foreignId('hunt_id')->constrained('hunts')->cascadeOnDelete();
|
||||||
$table->unsignedSmallInteger('current_step_number')->default(1);
|
$table->unsignedSmallInteger('current_step_number')->default(1);
|
||||||
$table->string('status')->default(ParticipateStatus::Started->value);
|
$table->string('status')->default(ParticipateStatus::Registered->value);
|
||||||
$table->timestamps();
|
$table->timestamps();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('hunts', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -54,6 +54,8 @@ Route::middleware(['auth:sanctum', 'verified'])->group(function (): void {
|
||||||
Route::get('users/{user}', [UsersController::class, 'show']);
|
Route::get('users/{user}', [UsersController::class, 'show']);
|
||||||
Route::post('users/{user}/avatar', [UsersController::class, 'updateAvatar']);
|
Route::post('users/{user}/avatar', [UsersController::class, 'updateAvatar']);
|
||||||
Route::post('hunts/{hunt}/participate', [HuntsController::class, 'participate']);
|
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::delete('hunts/{hunt}/participate', [HuntsController::class, 'unparticipate']);
|
||||||
Route::post('hunts/{hunt}/steps/{step}/validate', [HuntStepsController::class, 'validateStep']);
|
Route::post('hunts/{hunt}/steps/{step}/validate', [HuntStepsController::class, 'validateStep']);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,136 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Enums\ParticipateStatus;
|
||||||
|
use App\Models\Hunts;
|
||||||
|
use App\Models\HuntSteps;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('registers a participant without starting the hunt', function () {
|
||||||
|
$user = User::factory()->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,
|
||||||
|
]);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue