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
|
||||
{
|
||||
case Registered = 'registered';
|
||||
case InProgress = 'in_progress';
|
||||
case Started = 'started';
|
||||
case Completed = 'completed';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) === '') {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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::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']);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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