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
{
case Registered = 'registered';
case InProgress = 'in_progress';
case Started = 'started';
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\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,
]);
}
}),
]);
}

View File

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

View File

@ -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<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;
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<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('is_participating', false);
$hunt->setAttribute('participation_status', null);
$hunt->makeHidden('steps_sum_xp');
if ($userId === null || ! $hunt->relationLoaded('participants')) {
return;
}
$participant = $hunt->participants->firstWhere('id', $userId);
if ($participant === null || $participant->pivot === null) {
return;
}
$hunt->setAttribute('is_participating', true);
$hunt->setAttribute('participation_status', $this->participationPayloadFromPivot($participant->pivot));
});
}
/**
* @return array<string, mixed>
*/
private function stepTargetPayload(HuntSteps $step): array
{
return [
'id' => $step->id,
'hunt_id' => $step->hunt_id,
'step_number' => $step->step_number,
'title' => $step->title,
'latitude' => $step->latitude,
'longitude' => $step->longitude,
'radius_m' => $step->radius_m,
'ar_model' => 'chest',
];
}
/**
* @return array<string, mixed>
*/
private function participationPayloadFromPivot(mixed $pivot): array
{
return [
'user_id' => $pivot->user_id,
'hunt_id' => $pivot->hunt_id,
'current_step_number' => (int) $pivot->current_step_number,
'status' => $pivot->status,
'created_at' => $pivot->created_at?->toJSON(),
'updated_at' => $pivot->updated_at?->toJSON(),
];
}
private function distanceMeters(float $latitudeFrom, float $longitudeFrom, float $latitudeTo, float $longitudeTo): float
{
$earthRadius = 6371000;
$latFrom = deg2rad($latitudeFrom);
$lonFrom = deg2rad($longitudeFrom);
$latTo = deg2rad($latitudeTo);
$lonTo = deg2rad($longitudeTo);
$latDelta = $latTo - $latFrom;
$lonDelta = $lonTo - $lonFrom;
$angle = 2 * asin(sqrt(pow(sin($latDelta / 2), 2) +
cos($latFrom) * cos($latTo) * pow(sin($lonDelta / 2), 2)));
return $angle * $earthRadius;
}
private function refreshSearchIndex(Hunts $hunt): void
{
try {
$hunt->searchable();
} catch (\Throwable $e) {
Log::warning('HUNT_SEARCH_INDEX_REFRESH_FAILED', [
'hunt_id' => $hunt->id,
'message' => $e->getMessage(),
]);
}
}
private function parseDateFilterUtc(mixed $value): ?Carbon
{
if (! is_string($value) || trim($value) === '') {

View File

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

View File

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

View File

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

View File

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

View File

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

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

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