feat: participation

This commit is contained in:
Leon Morival 2026-04-30 10:17:42 +02:00
parent 9cd57aa79e
commit 7373111927
15 changed files with 599 additions and 110 deletions

View File

@ -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 }}"

View File

@ -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';
} }

View File

@ -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,6 +66,10 @@ 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'),
@ -82,20 +86,19 @@ class HuntsResource extends Resource
->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 {
@ -109,7 +112,7 @@ class HuntsResource extends Resource
'lng' => $record->longitude, 'lng' => $record->longitude,
]); ]);
} }
}) }),
]); ]);
} }

View File

@ -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')

View File

@ -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;
}
} }

View File

@ -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) === '') {

View File

@ -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',

View File

@ -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',

View File

@ -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(),
];
}
} }

View File

@ -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

View File

@ -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(),

View File

@ -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();
}); });
} }

View File

@ -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');
});
}
};

View File

@ -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']);
}); });

View File

@ -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,
]);
});