Compare commits
10 Commits
3f6dbe1c55
...
9cd57aa79e
| Author | SHA1 | Date |
|---|---|---|
|
|
9cd57aa79e | |
|
|
6f4aa9f4e4 | |
|
|
270f10a916 | |
|
|
9b7d3ea9dc | |
|
|
932d9a38a4 | |
|
|
33f87bfa10 | |
|
|
2894b59452 | |
|
|
fba0942407 | |
|
|
571daea8b0 | |
|
|
9f2d478213 |
|
|
@ -35,7 +35,7 @@ SESSION_DOMAIN=null
|
|||
|
||||
BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=database
|
||||
QUEUE_CONNECTION=redis
|
||||
|
||||
CACHE_STORE=database
|
||||
# CACHE_PREFIX=
|
||||
|
|
@ -49,9 +49,13 @@ MEILISEARCH_PORT=7700
|
|||
MEMCACHED_HOST=127.0.0.1
|
||||
|
||||
REDIS_CLIENT=phpredis
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_HOST=redis
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
REDIS_QUEUE=default
|
||||
|
||||
HORIZON_NAME="${APP_NAME}"
|
||||
HORIZON_PREFIX=treasure_api_horizon:
|
||||
|
||||
MAIL_MAILER=log
|
||||
MAIL_SCHEME=null
|
||||
|
|
|
|||
|
|
@ -63,6 +63,8 @@ deploy-to-portainer:
|
|||
export MAIL_MAILER="${MAIL_MAILER:-smtp}"
|
||||
export MAIL_SCHEME="${MAIL_SCHEME:-null}"
|
||||
export MAIL_FROM_NAME="${MAIL_FROM_NAME:-$APP_NAME}"
|
||||
export HORIZON_NAME="${HORIZON_NAME:-treasure-api}"
|
||||
export HORIZON_PREFIX="${HORIZON_PREFIX:-treasure_api_horizon:}"
|
||||
: "${MAIL_HOST:?Missing MAIL_HOST GitLab CI/CD variable}"
|
||||
: "${MAIL_PORT:?Missing MAIL_PORT GitLab CI/CD variable}"
|
||||
: "${MAIL_USERNAME:?Missing MAIL_USERNAME GitLab CI/CD variable}"
|
||||
|
|
|
|||
89
AGENTS.md
89
AGENTS.md
|
|
@ -8,10 +8,14 @@ The Laravel Boost guidelines are specifically curated by Laravel maintainers for
|
|||
## Foundational Context
|
||||
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
||||
|
||||
- php - 8.3.6
|
||||
- php - 8.4.20
|
||||
- filament/filament (FILAMENT) - v4
|
||||
- laravel/framework (LARAVEL) - v12
|
||||
- laravel/horizon (HORIZON) - v5
|
||||
- laravel/prompts (PROMPTS) - v0
|
||||
- laravel/sanctum (SANCTUM) - v4
|
||||
- laravel/scout (SCOUT) - v10
|
||||
- livewire/livewire (LIVEWIRE) - v3
|
||||
- laravel/mcp (MCP) - v0
|
||||
- laravel/pint (PINT) - v1
|
||||
- laravel/sail (SAIL) - v1
|
||||
|
|
@ -177,6 +181,89 @@ protected function isAccessible(User $user, ?string $path = null): bool
|
|||
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
|
||||
|
||||
|
||||
=== livewire/core rules ===
|
||||
|
||||
## Livewire Core
|
||||
- Use the `search-docs` tool to find exact version specific documentation for how to write Livewire & Livewire tests.
|
||||
- Use the `php artisan make:livewire [Posts\CreatePost]` artisan command to create new components
|
||||
- State should live on the server, with the UI reflecting it.
|
||||
- All Livewire requests hit the Laravel backend, they're like regular HTTP requests. Always validate form data, and run authorization checks in Livewire actions.
|
||||
|
||||
## Livewire Best Practices
|
||||
- Livewire components require a single root element.
|
||||
- Use `wire:loading` and `wire:dirty` for delightful loading states.
|
||||
- Add `wire:key` in loops:
|
||||
|
||||
```blade
|
||||
@foreach ($items as $item)
|
||||
<div wire:key="item-{{ $item->id }}">
|
||||
{{ $item->name }}
|
||||
</div>
|
||||
@endforeach
|
||||
```
|
||||
|
||||
- Prefer lifecycle hooks like `mount()`, `updatedFoo()` for initialization and reactive side effects:
|
||||
|
||||
<code-snippet name="Lifecycle hook examples" lang="php">
|
||||
public function mount(User $user) { $this->user = $user; }
|
||||
public function updatedSearch() { $this->resetPage(); }
|
||||
</code-snippet>
|
||||
|
||||
|
||||
## Testing Livewire
|
||||
|
||||
<code-snippet name="Example Livewire component test" lang="php">
|
||||
Livewire::test(Counter::class)
|
||||
->assertSet('count', 0)
|
||||
->call('increment')
|
||||
->assertSet('count', 1)
|
||||
->assertSee(1)
|
||||
->assertStatus(200);
|
||||
</code-snippet>
|
||||
|
||||
|
||||
<code-snippet name="Testing a Livewire component exists within a page" lang="php">
|
||||
$this->get('/posts/create')
|
||||
->assertSeeLivewire(CreatePost::class);
|
||||
</code-snippet>
|
||||
|
||||
|
||||
=== livewire/v3 rules ===
|
||||
|
||||
## Livewire 3
|
||||
|
||||
### Key Changes From Livewire 2
|
||||
- These things changed in Livewire 2, but may not have been updated in this application. Verify this application's setup to ensure you conform with application conventions.
|
||||
- Use `wire:model.live` for real-time updates, `wire:model` is now deferred by default.
|
||||
- Components now use the `App\Livewire` namespace (not `App\Http\Livewire`).
|
||||
- Use `$this->dispatch()` to dispatch events (not `emit` or `dispatchBrowserEvent`).
|
||||
- Use the `components.layouts.app` view as the typical layout path (not `layouts.app`).
|
||||
|
||||
### New Directives
|
||||
- `wire:show`, `wire:transition`, `wire:cloak`, `wire:offline`, `wire:target` are available for use. Use the documentation to find usage examples.
|
||||
|
||||
### Alpine
|
||||
- Alpine is now included with Livewire, don't manually include Alpine.js.
|
||||
- Plugins included with Alpine: persist, intersect, collapse, and focus.
|
||||
|
||||
### Lifecycle Hooks
|
||||
- You can listen for `livewire:init` to hook into Livewire initialization, and `fail.status === 419` for the page expiring:
|
||||
|
||||
<code-snippet name="livewire:load example" lang="js">
|
||||
document.addEventListener('livewire:init', function () {
|
||||
Livewire.hook('request', ({ fail }) => {
|
||||
if (fail && fail.status === 419) {
|
||||
alert('Your session expired');
|
||||
}
|
||||
});
|
||||
|
||||
Livewire.hook('message.failed', (message, component) => {
|
||||
console.error(message);
|
||||
});
|
||||
});
|
||||
</code-snippet>
|
||||
|
||||
|
||||
=== pint/core rules ===
|
||||
|
||||
## Laravel Pint Code Formatter
|
||||
|
|
|
|||
|
|
@ -28,6 +28,10 @@ RUN docker-php-ext-configure pdo_mysql --with-pdo-mysql=mysqlnd \
|
|||
&& docker-php-ext-enable pdo_mysql \
|
||||
&& php -m | grep -i pdo_mysql
|
||||
|
||||
# 👉 Installation de l'extension Redis pour les queues Horizon
|
||||
RUN if ! php -m | grep -qi '^redis$'; then pecl install redis && docker-php-ext-enable redis; fi \
|
||||
&& php -m | grep -i redis
|
||||
|
||||
# 👉 Installation de l'extension intl
|
||||
RUN apt-get update && apt-get install -y libicu-dev \
|
||||
&& docker-php-ext-configure intl \
|
||||
|
|
|
|||
|
|
@ -3,13 +3,14 @@
|
|||
namespace App\Filament\Resources\Hunts\RelationManagers;
|
||||
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\DetachAction;
|
||||
use Filament\Actions\DetachBulkAction;
|
||||
use Filament\Forms;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class ParticipantsRelationManager extends RelationManager
|
||||
{
|
||||
protected static string $relationship = 'participants';
|
||||
|
|
@ -51,11 +52,11 @@ class ParticipantsRelationManager extends RelationManager
|
|||
// Tables\Actions\AttachAction::make(),
|
||||
])
|
||||
->actions([
|
||||
DeleteAction::make(),
|
||||
DetachAction::make(),
|
||||
])
|
||||
->bulkActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make(),
|
||||
DetachBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,11 +2,14 @@
|
|||
|
||||
namespace App\Filament\Resources\Hunts\RelationManagers;
|
||||
|
||||
use Dotswan\MapPicker\Fields\Map;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Forms\Components\FileUpload;
|
||||
use Filament\Forms\Components\Hidden;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
|
|
@ -20,6 +23,14 @@ class StepsRelationManager extends RelationManager
|
|||
|
||||
public function form(Schema $schema): Schema
|
||||
{
|
||||
$ownerRecord = $this->getOwnerRecord();
|
||||
$defaultLocation = ($ownerRecord->latitude !== null && $ownerRecord->longitude !== null)
|
||||
? [
|
||||
'lat' => (float) $ownerRecord->latitude,
|
||||
'lng' => (float) $ownerRecord->longitude,
|
||||
]
|
||||
: null;
|
||||
|
||||
return $schema
|
||||
->components([
|
||||
TextInput::make('step_number')
|
||||
|
|
@ -31,14 +42,16 @@ class StepsRelationManager extends RelationManager
|
|||
Textarea::make('description'),
|
||||
Textarea::make('hint_text')
|
||||
->label('Hint'),
|
||||
TextInput::make('hint_media_url')
|
||||
->url()
|
||||
->label('Hint Media URL'),
|
||||
TextInput::make('latitude')
|
||||
->numeric()
|
||||
FileUpload::make('hint_media_url')
|
||||
->label('Hint image')
|
||||
->image()
|
||||
->disk('public')
|
||||
->directory('hunt-steps'),
|
||||
Hidden::make('latitude')
|
||||
->default($defaultLocation['lat'] ?? null)
|
||||
->required(),
|
||||
TextInput::make('longitude')
|
||||
->numeric()
|
||||
Hidden::make('longitude')
|
||||
->default($defaultLocation['lng'] ?? null)
|
||||
->required(),
|
||||
TextInput::make('radius_m')
|
||||
->numeric()
|
||||
|
|
@ -47,6 +60,58 @@ class StepsRelationManager extends RelationManager
|
|||
TextInput::make('xp')
|
||||
->numeric()
|
||||
->default(10),
|
||||
Map::make('location')
|
||||
->label('Location')
|
||||
->columnSpanFull()
|
||||
->default($defaultLocation)
|
||||
->defaultLocation(
|
||||
latitude: $defaultLocation['lat'] ?? 40.4168,
|
||||
longitude: $defaultLocation['lng'] ?? -3.7038,
|
||||
)
|
||||
->draggable(true)
|
||||
->clickable(true)
|
||||
->zoom(15)
|
||||
->minZoom(0)
|
||||
->maxZoom(28)
|
||||
->tilesUrl('https://tile.openstreetmap.org/{z}/{x}/{y}.png')
|
||||
->detectRetina(true)
|
||||
->showFullscreenControl(false)
|
||||
->showZoomControl(true)
|
||||
->showMyLocationButton(true)
|
||||
->boundaries(true, 49.5, -11, 61, 2)
|
||||
->rangeSelectField('radius_m')
|
||||
->extraStyles([
|
||||
'border-radius: 10px',
|
||||
])
|
||||
->afterStateUpdated(function ($set, ?array $state): void {
|
||||
if ($state === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$set('latitude', $state['lat']);
|
||||
$set('longitude', $state['lng']);
|
||||
})
|
||||
->afterStateHydrated(function (?array $state, $record, $set) use ($defaultLocation): void {
|
||||
if (($state['lat'] ?? null) !== null && ($state['lng'] ?? null) !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$latitude = $record?->latitude ?? ($defaultLocation['lat'] ?? null);
|
||||
$longitude = $record?->longitude ?? ($defaultLocation['lng'] ?? null);
|
||||
|
||||
if ($latitude === null || $longitude === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$location = [
|
||||
'lat' => (float) $latitude,
|
||||
'lng' => (float) $longitude,
|
||||
];
|
||||
|
||||
$set('location', $location);
|
||||
$set('latitude', $location['lat']);
|
||||
$set('longitude', $location['lng']);
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -56,6 +121,9 @@ class StepsRelationManager extends RelationManager
|
|||
->recordTitleAttribute('title')
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('step_number')->sortable(),
|
||||
Tables\Columns\ImageColumn::make('hint_media_url')
|
||||
->label('Hint image')
|
||||
->disk('public'),
|
||||
Tables\Columns\TextColumn::make('title'),
|
||||
Tables\Columns\TextColumn::make('latitude'),
|
||||
Tables\Columns\TextColumn::make('longitude'),
|
||||
|
|
|
|||
|
|
@ -59,6 +59,12 @@ class AuthController extends Controller
|
|||
]);
|
||||
}
|
||||
|
||||
if (! $user->hasVerifiedEmail()) {
|
||||
throw ValidationException::withMessages([
|
||||
'email' => [__('auth.email_not_verified')],
|
||||
]);
|
||||
}
|
||||
|
||||
$token = $user->createToken(
|
||||
$this->resolveDeviceName($data)
|
||||
)->plainTextToken;
|
||||
|
|
|
|||
|
|
@ -3,10 +3,13 @@
|
|||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\HuntDifficulty;
|
||||
use App\Http\Resources\HuntResource;
|
||||
use App\Models\Hunts;
|
||||
use App\Notifications\HuntParticipantJoinedNotification;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class HuntsController extends Controller
|
||||
|
|
@ -18,19 +21,15 @@ class HuntsController extends Controller
|
|||
$filters = $this->buildMeilisearchFilters($request);
|
||||
|
||||
$search = Hunts::search($searchTerm ?? '')
|
||||
->query(fn ($query) => $query->with('participants:id,avatar_uri'))
|
||||
->query(fn ($query) => $query
|
||||
->with('participants:id,avatar_uri')
|
||||
->withSum('steps', 'xp')
|
||||
)
|
||||
->options(['filter' => $filters]);
|
||||
|
||||
Log::info('HUNTS_SEARCH', [
|
||||
'q' => $searchTerm ?? '',
|
||||
'filters' => $filters,
|
||||
'meili_host' => config('scout.meilisearch.host'),
|
||||
'index' => (new Hunts)->searchableAs(),
|
||||
'request_params' => $request->all(),
|
||||
]);
|
||||
|
||||
$result = $search->paginate(10);
|
||||
|
||||
$this->appendTotalXpToHunts($result->getCollection());
|
||||
|
||||
Log::info('HUNTS_SEARCH_SUCCESS', [
|
||||
'total' => $result->total(),
|
||||
'count' => count($result->items()),
|
||||
|
|
@ -38,12 +37,6 @@ class HuntsController extends Controller
|
|||
|
||||
return response()->json($result);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('HUNTS_ERROR_CAUGHT', [
|
||||
'message' => $e->getMessage(),
|
||||
'file' => $e->getFile(),
|
||||
'line' => $e->getLine(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'error' => 'Server Error',
|
||||
|
|
@ -59,7 +52,7 @@ class HuntsController extends Controller
|
|||
$hunt->load([
|
||||
'participants:id,username,avatar_uri,xp,level_id',
|
||||
'participants.currentLevel',
|
||||
'steps'
|
||||
'steps',
|
||||
]);
|
||||
|
||||
Log::info('HUNT_SHOW_SUCCESS', [
|
||||
|
|
@ -67,7 +60,7 @@ class HuntsController extends Controller
|
|||
'hunt_title' => $hunt->title,
|
||||
]);
|
||||
|
||||
return response()->json($hunt);
|
||||
return HuntResource::make($hunt)->response();
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('HUNT_SHOW_ERROR', [
|
||||
'hunt_id' => $hunt->id ?? null,
|
||||
|
|
@ -108,7 +101,7 @@ class HuntsController extends Controller
|
|||
$originalSlug = $validated['slug'];
|
||||
$counter = 1;
|
||||
while (Hunts::where('slug', $validated['slug'])->exists()) {
|
||||
$validated['slug'] = $originalSlug . '-' . $counter;
|
||||
$validated['slug'] = $originalSlug.'-'.$counter;
|
||||
$counter++;
|
||||
}
|
||||
|
||||
|
|
@ -143,7 +136,7 @@ class HuntsController extends Controller
|
|||
try {
|
||||
// Vérifier que l'utilisateur est le créateur ou admin
|
||||
$user = auth('sanctum')->user();
|
||||
if ($hunt->creator_id !== $user->id && !$user->isAdmin()) {
|
||||
if ($hunt->creator_id !== $user->id && ! $user->isAdmin()) {
|
||||
return response()->json([
|
||||
'error' => 'Forbidden',
|
||||
'message' => 'You are not authorized to update this hunt',
|
||||
|
|
@ -167,12 +160,12 @@ class HuntsController extends Controller
|
|||
// Update slug if title changed
|
||||
if (isset($validated['title']) && $validated['title'] !== $hunt->title) {
|
||||
$validated['slug'] = \Illuminate\Support\Str::slug($validated['title']);
|
||||
|
||||
|
||||
// Ensure slug is unique
|
||||
$originalSlug = $validated['slug'];
|
||||
$counter = 1;
|
||||
while (Hunts::where('slug', $validated['slug'])->where('id', '!=', $hunt->id)->exists()) {
|
||||
$validated['slug'] = $originalSlug . '-' . $counter;
|
||||
$validated['slug'] = $originalSlug.'-'.$counter;
|
||||
$counter++;
|
||||
}
|
||||
}
|
||||
|
|
@ -209,7 +202,7 @@ class HuntsController extends Controller
|
|||
try {
|
||||
// Vérifier que l'utilisateur est le créateur ou admin
|
||||
$user = auth('sanctum')->user();
|
||||
if ($hunt->creator_id !== $user->id && !$user->isAdmin()) {
|
||||
if ($hunt->creator_id !== $user->id && ! $user->isAdmin()) {
|
||||
return response()->json([
|
||||
'error' => 'Forbidden',
|
||||
'message' => 'You are not authorized to delete this hunt',
|
||||
|
|
@ -260,6 +253,12 @@ class HuntsController extends Controller
|
|||
'status' => 'in_progress',
|
||||
]);
|
||||
|
||||
$hunt->loadMissing('creator');
|
||||
|
||||
if ($hunt->creator !== null && $hunt->creator->getKey() !== $user->getKey()) {
|
||||
$hunt->creator->notify(new HuntParticipantJoinedNotification($hunt, $user));
|
||||
}
|
||||
|
||||
Log::info('HUNT_PARTICIPATE', [
|
||||
'hunt_id' => $hunt->id,
|
||||
'user_id' => $user->id,
|
||||
|
|
@ -290,7 +289,7 @@ class HuntsController extends Controller
|
|||
$user = auth('sanctum')->user();
|
||||
|
||||
// Vérifier si l'utilisateur participe
|
||||
if (!$hunt->participants()->where('user_id', $user->id)->exists()) {
|
||||
if (! $hunt->participants()->where('user_id', $user->id)->exists()) {
|
||||
return response()->json([
|
||||
'error' => 'Not Participating',
|
||||
'message' => 'You are not participating in this hunt',
|
||||
|
|
@ -347,7 +346,7 @@ class HuntsController extends Controller
|
|||
$filters[] = "participant_ids != \"{$escapedUserId}\"";
|
||||
}
|
||||
} elseif ($participating === true) {
|
||||
$filters[] = "id < 0";
|
||||
$filters[] = 'id < 0';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -392,21 +391,21 @@ class HuntsController extends Controller
|
|||
array_map('strtolower', $difficulties)
|
||||
));
|
||||
|
||||
if (!empty($validDifficulties)) {
|
||||
if (! empty($validDifficulties)) {
|
||||
$difficultyFilters = array_map(
|
||||
fn ($d) => 'difficulty = "' . $this->escapeMeiliString($d) . '"',
|
||||
fn ($d) => 'difficulty = "'.$this->escapeMeiliString($d).'"',
|
||||
$validDifficulties
|
||||
);
|
||||
$filters[] = '(' . implode(' OR ', $difficultyFilters) . ')';
|
||||
$filters[] = '('.implode(' OR ', $difficultyFilters).')';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// dates (UTC)
|
||||
$startFrom = $this->parseDateFilterUtc($request->query('start_from'));
|
||||
$startTo = $this->parseDateFilterUtc($request->query('start_to'));
|
||||
$endFrom = $this->parseDateFilterUtc($request->query('end_from'));
|
||||
$endTo = $this->parseDateFilterUtc($request->query('end_to'));
|
||||
$startTo = $this->parseDateFilterUtc($request->query('start_to'));
|
||||
$endFrom = $this->parseDateFilterUtc($request->query('end_from'));
|
||||
$endTo = $this->parseDateFilterUtc($request->query('end_to'));
|
||||
|
||||
if ($startFrom) {
|
||||
// start_at_timestamp est toujours présent (created_at par défaut), donc pas besoin de EXISTS
|
||||
|
|
@ -429,7 +428,7 @@ class HuntsController extends Controller
|
|||
// Cela évite l'erreur Meilisearch avec une chaîne de filtres vide
|
||||
if (empty($filters)) {
|
||||
// En Meilisearch, les booléens peuvent être comparés avec true/false (sans guillemets)
|
||||
$filters[] = "is_public = true";
|
||||
$filters[] = 'is_public = true';
|
||||
}
|
||||
|
||||
$finalFilters = implode(' AND ', $filters);
|
||||
|
|
@ -445,7 +444,7 @@ class HuntsController extends Controller
|
|||
{
|
||||
$searchTerm = $request->query('search') ?? $request->query('q');
|
||||
|
||||
if (!is_string($searchTerm)) {
|
||||
if (! is_string($searchTerm)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -454,9 +453,20 @@ class HuntsController extends Controller
|
|||
return $searchTerm !== '' ? $searchTerm : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, Hunts> $hunts
|
||||
*/
|
||||
private function appendTotalXpToHunts(Collection $hunts): void
|
||||
{
|
||||
$hunts->each(function (Hunts $hunt): void {
|
||||
$hunt->setAttribute('xp', $hunt->totalXp());
|
||||
$hunt->makeHidden('steps_sum_xp');
|
||||
});
|
||||
}
|
||||
|
||||
private function parseDateFilterUtc(mixed $value): ?Carbon
|
||||
{
|
||||
if (!is_string($value) || trim($value) === '') {
|
||||
if (! is_string($value) || trim($value) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -471,6 +481,7 @@ class HuntsController extends Controller
|
|||
{
|
||||
// Échappe les backslashes et les guillemets pour une string Meilisearch
|
||||
$value = str_replace('\\', '\\\\', $value);
|
||||
|
||||
return str_replace('"', '\"', $value);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ class UsersController extends Controller
|
|||
$user = $request->user();
|
||||
|
||||
$request->validate([
|
||||
'username' => ['required', 'string', 'max:255'],
|
||||
'username' => ['required', 'string','min:3', 'max:30', 'regex:/^[A-Za-z0-9._]+$/'],
|
||||
]);
|
||||
|
||||
$user->update([
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ class RegisterRequest extends FormRequest
|
|||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'username' => ['required', 'string', 'max:120', 'unique:users,username'],
|
||||
'username' => ['required', 'string','min:3', 'max:30', 'regex:/^[A-Za-z0-9._]+$/'],
|
||||
'email' => ['required', 'string', 'email', 'max:255', 'unique:users,email'],
|
||||
'password' => [
|
||||
'required',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class HuntResource extends JsonResource
|
||||
{
|
||||
public static $wrap = null;
|
||||
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return array_merge(parent::toArray($request), [
|
||||
'xp' => $this->resource->totalXp(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ use App\Enums\HuntDifficulty;
|
|||
use App\Enums\HuntStatus;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Laravel\Scout\Searchable;
|
||||
|
|
@ -51,6 +52,11 @@ class Hunts extends Model
|
|||
return $this->hasMany(HuntSteps::class, 'hunt_id');
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'creator_id');
|
||||
}
|
||||
|
||||
public function participants(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(User::class, 'hunt_participants', 'hunt_id', 'user_id')
|
||||
|
|
@ -58,6 +64,21 @@ class Hunts extends Model
|
|||
->withTimestamps();
|
||||
}
|
||||
|
||||
public function totalXp(): int
|
||||
{
|
||||
if ($this->relationLoaded('steps')) {
|
||||
return (int) $this->steps->sum('xp');
|
||||
}
|
||||
|
||||
$stepsSumXp = $this->getAttribute('steps_sum_xp');
|
||||
|
||||
if ($stepsSumXp !== null) {
|
||||
return (int) $stepsSumXp;
|
||||
}
|
||||
|
||||
return (int) $this->steps()->sum('xp');
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure Meilisearch index settings
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\Hunts;
|
||||
use App\Models\User;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class HuntParticipantJoinedNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
public Hunts $hunt,
|
||||
public User $participant,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
return (new MailMessage)
|
||||
->subject('Nouveau participant sur '.$this->hunt->title)
|
||||
->view('mail.hunts.joined-hunt', [
|
||||
'notifiable' => $notifiable,
|
||||
'hunt' => $this->hunt,
|
||||
'participant' => $this->participant,
|
||||
'participantsCount' => $this->hunt->participants()->count(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,12 +4,13 @@ namespace App\Notifications;
|
|||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
|
||||
class VerifyEmailNotification extends Notification
|
||||
class VerifyEmailNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
|
|
|
|||
|
|
@ -6,10 +6,12 @@ use Filament\Http\Middleware\Authenticate;
|
|||
use Filament\Http\Middleware\AuthenticateSession;
|
||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||
use Filament\Http\Middleware\DispatchServingFilamentEvent;
|
||||
use Filament\Navigation\NavigationItem;
|
||||
use Filament\Pages\Dashboard;
|
||||
use Filament\Panel;
|
||||
use Filament\PanelProvider;
|
||||
use Filament\Support\Colors\Color;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Widgets\AccountWidget;
|
||||
use Filament\Widgets\FilamentInfoWidget;
|
||||
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
|
||||
|
|
@ -41,6 +43,18 @@ class AdminPanelProvider extends PanelProvider
|
|||
AccountWidget::class,
|
||||
FilamentInfoWidget::class,
|
||||
])
|
||||
->navigationItems([
|
||||
NavigationItem::make('Horizon')
|
||||
->label('Queues')
|
||||
->url(
|
||||
fn (): string => '/'.trim((string) config('horizon.path', 'horizon'), '/'),
|
||||
shouldOpenInNewTab: true,
|
||||
)
|
||||
->icon(Heroicon::OutlinedQueueList)
|
||||
->group('Monitoring')
|
||||
->sort(100)
|
||||
->visible(fn (): bool => auth()->user()?->isAdmin() ?? false),
|
||||
])
|
||||
->middleware([
|
||||
EncryptCookies::class,
|
||||
AddQueuedCookiesToResponse::class,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Laravel\Horizon\Horizon;
|
||||
use Laravel\Horizon\HorizonApplicationServiceProvider;
|
||||
|
||||
class HorizonServiceProvider extends HorizonApplicationServiceProvider
|
||||
{
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
// Horizon::routeSmsNotificationsTo('15556667777');
|
||||
// Horizon::routeMailNotificationsTo('example@example.com');
|
||||
// Horizon::routeSlackNotificationsTo('slack-webhook-url', '#channel');
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the Horizon gate.
|
||||
*
|
||||
* This gate determines who can access Horizon in non-local environments.
|
||||
*/
|
||||
protected function gate(): void
|
||||
{
|
||||
Gate::define('viewHorizon', function (?User $user = null): bool {
|
||||
return $user?->isAdmin() ?? false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -3,4 +3,5 @@
|
|||
return [
|
||||
App\Providers\AppServiceProvider::class,
|
||||
App\Providers\Filament\AdminPanelProvider::class,
|
||||
App\Providers\HorizonServiceProvider::class,
|
||||
];
|
||||
|
|
|
|||
51
compose.yaml
51
compose.yaml
|
|
@ -17,6 +17,10 @@ services:
|
|||
XDEBUG_MODE: "${SAIL_XDEBUG_MODE:-off}"
|
||||
XDEBUG_CONFIG: "${SAIL_XDEBUG_CONFIG:-client_host=host.docker.internal}"
|
||||
IGNITION_LOCAL_SITES_PATH: "${PWD}"
|
||||
QUEUE_CONNECTION: redis
|
||||
REDIS_HOST: redis
|
||||
REDIS_CLIENT: phpredis
|
||||
REDIS_QUEUE: default
|
||||
volumes:
|
||||
- ".:/var/www/html"
|
||||
- "sail-storage:/var/www/html/storage/app"
|
||||
|
|
@ -25,6 +29,36 @@ services:
|
|||
depends_on:
|
||||
- mysql
|
||||
- meilisearch
|
||||
- redis
|
||||
horizon:
|
||||
build:
|
||||
context: "./vendor/laravel/sail/runtimes/8.4"
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
WWWGROUP: "${WWWGROUP}"
|
||||
image: "sail-84/app"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
command: php artisan horizon
|
||||
environment:
|
||||
WWWUSER: "${WWWUSER}"
|
||||
LARAVEL_SAIL: 1
|
||||
XDEBUG_MODE: "${SAIL_XDEBUG_MODE:-off}"
|
||||
XDEBUG_CONFIG: "${SAIL_XDEBUG_CONFIG:-client_host=host.docker.internal}"
|
||||
IGNITION_LOCAL_SITES_PATH: "${PWD}"
|
||||
QUEUE_CONNECTION: redis
|
||||
REDIS_HOST: redis
|
||||
REDIS_CLIENT: phpredis
|
||||
REDIS_QUEUE: default
|
||||
volumes:
|
||||
- ".:/var/www/html"
|
||||
- "sail-storage:/var/www/html/storage/app"
|
||||
networks:
|
||||
- sail
|
||||
depends_on:
|
||||
- mysql
|
||||
- meilisearch
|
||||
- redis
|
||||
mysql:
|
||||
image: "mysql/mysql-server:8.0"
|
||||
ports:
|
||||
|
|
@ -72,6 +106,21 @@ services:
|
|||
- "sail-meilisearch:/meili_data"
|
||||
networks:
|
||||
- sail
|
||||
redis:
|
||||
image: "redis:7-alpine"
|
||||
ports:
|
||||
- "${FORWARD_REDIS_PORT:-6380}:6379"
|
||||
volumes:
|
||||
- "sail-redis:/data"
|
||||
networks:
|
||||
- sail
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- redis-cli
|
||||
- ping
|
||||
retries: 3
|
||||
timeout: 5s
|
||||
|
||||
networks:
|
||||
sail:
|
||||
|
|
@ -81,5 +130,7 @@ volumes:
|
|||
driver: local
|
||||
sail-meilisearch:
|
||||
driver: local
|
||||
sail-redis:
|
||||
driver: local
|
||||
sail-storage:
|
||||
driver: local
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
"filament/filament": "^4.0",
|
||||
"http-interop/http-factory-guzzle": "*",
|
||||
"laravel/framework": "^12.0",
|
||||
"laravel/horizon": "^5.46",
|
||||
"laravel/sanctum": "^4.0",
|
||||
"laravel/scout": "*",
|
||||
"laravel/tinker": "^2.10.1",
|
||||
|
|
@ -54,7 +55,7 @@
|
|||
],
|
||||
"dev": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
|
||||
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan horizon\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,horizon,logs,vite --kill-others"
|
||||
],
|
||||
"test": [
|
||||
"@php artisan config:clear --ansi",
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "8632b5fc1053d477088663c92da4db8c",
|
||||
"content-hash": "d38e1d4ebc365fe7117cf31db8246bb8",
|
||||
"packages": [
|
||||
{
|
||||
"name": "anourvalar/eloquent-serialize",
|
||||
|
|
@ -2578,6 +2578,86 @@
|
|||
},
|
||||
"time": "2025-11-26T19:24:25+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/horizon",
|
||||
"version": "v5.46.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/laravel/horizon.git",
|
||||
"reference": "bfea968e8aa674fb649d02e55ea0d38bdf5137d5"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/laravel/horizon/zipball/bfea968e8aa674fb649d02e55ea0d38bdf5137d5",
|
||||
"reference": "bfea968e8aa674fb649d02e55ea0d38bdf5137d5",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-json": "*",
|
||||
"ext-pcntl": "*",
|
||||
"ext-posix": "*",
|
||||
"illuminate/contracts": "^9.21|^10.0|^11.0|^12.0|^13.0",
|
||||
"illuminate/queue": "^9.21|^10.0|^11.0|^12.0|^13.0",
|
||||
"illuminate/support": "^9.21|^10.0|^11.0|^12.0|^13.0",
|
||||
"laravel/sentinel": "^1.0",
|
||||
"nesbot/carbon": "^2.17|^3.0",
|
||||
"php": "^8.0",
|
||||
"ramsey/uuid": "^4.0",
|
||||
"symfony/console": "^6.0|^7.0|^8.0",
|
||||
"symfony/error-handler": "^6.0|^7.0|^8.0",
|
||||
"symfony/polyfill-php83": "^1.28",
|
||||
"symfony/process": "^6.0|^7.0|^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"mockery/mockery": "^1.0",
|
||||
"orchestra/testbench": "^7.56|^8.37|^9.16|^10.9|^11.0",
|
||||
"phpstan/phpstan": "^1.10|^2.0",
|
||||
"predis/predis": "^1.1|^2.0|^3.0"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-redis": "Required to use the Redis PHP driver.",
|
||||
"predis/predis": "Required when not using the Redis PHP driver (^1.1|^2.0|^3.0)."
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"aliases": {
|
||||
"Horizon": "Laravel\\Horizon\\Horizon"
|
||||
},
|
||||
"providers": [
|
||||
"Laravel\\Horizon\\HorizonServiceProvider"
|
||||
]
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-master": "6.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Laravel\\Horizon\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Taylor Otwell",
|
||||
"email": "taylor@laravel.com"
|
||||
}
|
||||
],
|
||||
"description": "Dashboard and code-driven configuration for Laravel queues.",
|
||||
"keywords": [
|
||||
"laravel",
|
||||
"queue"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/laravel/horizon/issues",
|
||||
"source": "https://github.com/laravel/horizon/tree/v5.46.0"
|
||||
},
|
||||
"time": "2026-04-20T18:08:11+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/prompts",
|
||||
"version": "v0.3.8",
|
||||
|
|
@ -2781,6 +2861,62 @@
|
|||
},
|
||||
"time": "2025-12-16T15:43:03+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/sentinel",
|
||||
"version": "v1.1.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/laravel/sentinel.git",
|
||||
"reference": "972d9885d9d14312a118e9565c4e6ecc5e751ea1"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/laravel/sentinel/zipball/972d9885d9d14312a118e9565c4e6ecc5e751ea1",
|
||||
"reference": "972d9885d9d14312a118e9565c4e6ecc5e751ea1",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-json": "*",
|
||||
"illuminate/container": "^8.37|^9.0|^10.0|^11.0|^12.0|^13.0",
|
||||
"php": "^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"laravel/pint": "^1.27",
|
||||
"orchestra/testbench": "^6.47.1|^7.56|^8.37|^9.16|^10.9|^11.0",
|
||||
"phpstan/phpstan": "^2.1.33"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"Laravel\\Sentinel\\SentinelServiceProvider"
|
||||
]
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Laravel\\Sentinel\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Taylor Otwell",
|
||||
"email": "taylor@laravel.com"
|
||||
},
|
||||
{
|
||||
"name": "Mior Muhammad Zaki",
|
||||
"email": "mior@laravel.com"
|
||||
}
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/laravel/sentinel/tree/v1.1.0"
|
||||
},
|
||||
"time": "2026-03-24T14:03:38+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/serializable-closure",
|
||||
"version": "v2.0.7",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,254 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Horizon Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This name appears in notifications and in the Horizon UI. Unique names
|
||||
| can be useful while running multiple instances of Horizon within an
|
||||
| application, allowing you to identify the Horizon you're viewing.
|
||||
|
|
||||
*/
|
||||
|
||||
'name' => env('HORIZON_NAME'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Horizon Domain
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This is the subdomain where Horizon will be accessible from. If this
|
||||
| setting is null, Horizon will reside under the same domain as the
|
||||
| application. Otherwise, this value will serve as the subdomain.
|
||||
|
|
||||
*/
|
||||
|
||||
'domain' => env('HORIZON_DOMAIN'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Horizon Path
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This is the URI path where Horizon will be accessible from. Feel free
|
||||
| to change this path to anything you like. Note that the URI will not
|
||||
| affect the paths of its internal API that aren't exposed to users.
|
||||
|
|
||||
*/
|
||||
|
||||
'path' => env('HORIZON_PATH', 'horizon'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Horizon Redis Connection
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This is the name of the Redis connection where Horizon will store the
|
||||
| meta information required for it to function. It includes the list
|
||||
| of supervisors, failed jobs, job metrics, and other information.
|
||||
|
|
||||
*/
|
||||
|
||||
'use' => 'default',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Horizon Redis Prefix
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This prefix will be used when storing all Horizon data in Redis. You
|
||||
| may modify the prefix when you are running multiple installations
|
||||
| of Horizon on the same server so that they don't have problems.
|
||||
|
|
||||
*/
|
||||
|
||||
'prefix' => env(
|
||||
'HORIZON_PREFIX',
|
||||
Str::slug(env('APP_NAME', 'laravel'), '_').'_horizon:'
|
||||
),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Horizon Route Middleware
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These middleware will get attached onto each Horizon route, giving you
|
||||
| the chance to add your own middleware to this list or change any of
|
||||
| the existing middleware. Or, you can simply stick with this list.
|
||||
|
|
||||
*/
|
||||
|
||||
'middleware' => ['web'],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Queue Wait Time Thresholds
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option allows you to configure when the LongWaitDetected event
|
||||
| will be fired. Every connection / queue combination may have its
|
||||
| own, unique threshold (in seconds) before this event is fired.
|
||||
|
|
||||
*/
|
||||
|
||||
'waits' => [
|
||||
'redis:default' => 60,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Job Trimming Times
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you can configure for how long (in minutes) you desire Horizon to
|
||||
| persist the recent and failed jobs. Typically, recent jobs are kept
|
||||
| for one hour while all failed jobs are stored for an entire week.
|
||||
|
|
||||
*/
|
||||
|
||||
'trim' => [
|
||||
'recent' => 60,
|
||||
'pending' => 60,
|
||||
'completed' => 60,
|
||||
'recent_failed' => 10080,
|
||||
'failed' => 10080,
|
||||
'monitored' => 10080,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Silenced Jobs
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Silencing a job will instruct Horizon to not place the job in the list
|
||||
| of completed jobs within the Horizon dashboard. This setting may be
|
||||
| used to fully remove any noisy jobs from the completed jobs list.
|
||||
|
|
||||
*/
|
||||
|
||||
'silenced' => [
|
||||
// App\Jobs\ExampleJob::class,
|
||||
],
|
||||
|
||||
'silenced_tags' => [
|
||||
// 'notifications',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Metrics
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you can configure how many snapshots should be kept to display in
|
||||
| the metrics graph. This will get used in combination with Horizon's
|
||||
| `horizon:snapshot` schedule to define how long to retain metrics.
|
||||
|
|
||||
*/
|
||||
|
||||
'metrics' => [
|
||||
'trim_snapshots' => [
|
||||
'job' => 24,
|
||||
'queue' => 24,
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Fast Termination
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When this option is enabled, Horizon's "terminate" command will not
|
||||
| wait on all of the workers to terminate unless the --wait option
|
||||
| is provided. Fast termination can shorten deployment delay by
|
||||
| allowing a new instance of Horizon to start while the last
|
||||
| instance will continue to terminate each of its workers.
|
||||
|
|
||||
*/
|
||||
|
||||
'fast_termination' => false,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Memory Limit (MB)
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value describes the maximum amount of memory the Horizon master
|
||||
| supervisor may consume before it is terminated and restarted. For
|
||||
| configuring these limits on your workers, see the next section.
|
||||
|
|
||||
*/
|
||||
|
||||
'memory_limit' => 64,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Queue Worker Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may define the queue worker settings used by your application
|
||||
| in all environments. These supervisors and settings handle all your
|
||||
| queued jobs and will be provisioned by Horizon during deployment.
|
||||
|
|
||||
*/
|
||||
|
||||
'defaults' => [
|
||||
'supervisor-1' => [
|
||||
'connection' => 'redis',
|
||||
'queue' => ['default'],
|
||||
'balance' => 'auto',
|
||||
'autoScalingStrategy' => 'time',
|
||||
'maxProcesses' => 1,
|
||||
'maxTime' => 0,
|
||||
'maxJobs' => 0,
|
||||
'memory' => 128,
|
||||
'tries' => 1,
|
||||
'timeout' => 60,
|
||||
'nice' => 0,
|
||||
],
|
||||
],
|
||||
|
||||
'environments' => [
|
||||
'production' => [
|
||||
'supervisor-1' => [
|
||||
'maxProcesses' => 10,
|
||||
'balanceMaxShift' => 1,
|
||||
'balanceCooldown' => 3,
|
||||
],
|
||||
],
|
||||
|
||||
'local' => [
|
||||
'supervisor-1' => [
|
||||
'maxProcesses' => 3,
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| File Watcher Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The following list of directories and files will be watched when using
|
||||
| the `horizon:listen` command. Whenever any directories or files are
|
||||
| changed, Horizon will automatically restart to apply all changes.
|
||||
|
|
||||
*/
|
||||
|
||||
'watch' => [
|
||||
'app',
|
||||
'bootstrap',
|
||||
'config/**/*.php',
|
||||
'database/**/*.php',
|
||||
'public/**/*.php',
|
||||
'resources/**/*.php',
|
||||
'routes',
|
||||
'composer.lock',
|
||||
'composer.json',
|
||||
'.env',
|
||||
],
|
||||
];
|
||||
|
|
@ -13,7 +13,7 @@ return [
|
|||
|
|
||||
*/
|
||||
|
||||
'default' => env('QUEUE_CONNECTION', 'database'),
|
||||
'default' => env('QUEUE_CONNECTION', 'redis'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -28,8 +28,62 @@ services:
|
|||
MAIL_PASSWORD: "${MAIL_PASSWORD}"
|
||||
MAIL_FROM_ADDRESS: "${MAIL_FROM_ADDRESS}"
|
||||
MAIL_FROM_NAME: "${MAIL_FROM_NAME}"
|
||||
QUEUE_CONNECTION: "redis"
|
||||
REDIS_CLIENT: "phpredis"
|
||||
REDIS_HOST: "redis"
|
||||
REDIS_PASSWORD: "null"
|
||||
REDIS_PORT: 6379
|
||||
REDIS_QUEUE: "default"
|
||||
HORIZON_NAME: "${HORIZON_NAME}"
|
||||
HORIZON_PREFIX: "${HORIZON_PREFIX}"
|
||||
volumes:
|
||||
- storage-data:/var/www/html/storage/app
|
||||
depends_on:
|
||||
- mysql
|
||||
- meilisearch
|
||||
- redis
|
||||
|
||||
treasure-api-horizon:
|
||||
image: registry.gitlab.com/treasure-hunt4/treasure-api:latest
|
||||
restart: unless-stopped
|
||||
command: php artisan horizon
|
||||
environment:
|
||||
APP_ENV: "production"
|
||||
APP_KEY: "${APP_KEY}"
|
||||
APP_URL: "${APP_URL}"
|
||||
APP_DEBUG: "false"
|
||||
SCOUT_DRIVER: "${SCOUT_DRIVER}"
|
||||
MEILISEARCH_HOST: "${MEILISEARCH_HOST}"
|
||||
MEILISEARCH_KEY: "${MEILISEARCH_KEY}"
|
||||
DB_CONNECTION: "mysql"
|
||||
DB_HOST: "mysql"
|
||||
DB_PORT: 3306
|
||||
DB_DATABASE: "${DB_DATABASE}"
|
||||
DB_USERNAME: "${DB_USERNAME}"
|
||||
DB_PASSWORD: "${DB_PASSWORD}"
|
||||
MAIL_MAILER: "${MAIL_MAILER}"
|
||||
MAIL_SCHEME: "${MAIL_SCHEME}"
|
||||
MAIL_HOST: "${MAIL_HOST}"
|
||||
MAIL_PORT: "${MAIL_PORT}"
|
||||
MAIL_USERNAME: "${MAIL_USERNAME}"
|
||||
MAIL_PASSWORD: "${MAIL_PASSWORD}"
|
||||
MAIL_FROM_ADDRESS: "${MAIL_FROM_ADDRESS}"
|
||||
MAIL_FROM_NAME: "${MAIL_FROM_NAME}"
|
||||
QUEUE_CONNECTION: "redis"
|
||||
REDIS_CLIENT: "phpredis"
|
||||
REDIS_HOST: "redis"
|
||||
REDIS_PASSWORD: "null"
|
||||
REDIS_PORT: 6379
|
||||
REDIS_QUEUE: "default"
|
||||
HORIZON_NAME: "${HORIZON_NAME}"
|
||||
HORIZON_PREFIX: "${HORIZON_PREFIX}"
|
||||
volumes:
|
||||
- storage-data:/var/www/html/storage/app
|
||||
depends_on:
|
||||
- treasure-api
|
||||
- mysql
|
||||
- meilisearch
|
||||
- redis
|
||||
|
||||
# treasure-api-migrate:
|
||||
# image: registry.gitlab.com/treasure-hunt4/treasure-api:latest
|
||||
|
|
@ -124,7 +178,15 @@ services:
|
|||
ports:
|
||||
- "7700:7700"
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
command: redis-server --appendonly yes
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
|
||||
volumes:
|
||||
mysql-data:
|
||||
meilisearch-data:
|
||||
redis-data:
|
||||
storage-data:
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ return [
|
|||
'logout_success' => 'You have been deconnecte',
|
||||
'email_verified' => 'Email address verified.',
|
||||
'email_already_verified' => 'Email address already verified.',
|
||||
'email_not_verified' => 'Please verify your email address before logging in.',
|
||||
'email_verification_sent' => 'Verification link sent.',
|
||||
'soft_delete_success' => 'Your Account has been successfully deleted.',
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -16,9 +16,11 @@ return [
|
|||
'failed' => 'Impossible de vous connecter',
|
||||
'password' => 'Email ou mot de passe incorrect',
|
||||
'throttle' => 'Trop de tentatives veuillez reessayer dans :seconds seconds.',
|
||||
'logout_success' => 'Vous êtes bien déconnecté',
|
||||
'logout_success' => 'Vous êtes bien déconnecté.',
|
||||
'email_verified' => 'Adresse email vérifiée.',
|
||||
'email_already_verified' => 'Adresse email déjà vérifiée.',
|
||||
'email_not_verified' => 'Veuillez vérifier votre adresse email avant de vous connecter.',
|
||||
'email_verification_sent' => 'Lien de vérification envoyé.',
|
||||
'soft_delete_success' => 'Votre compte à bien été supprimé.',
|
||||
|
||||
];
|
||||
|
|
|
|||
28
phpunit.xml
28
phpunit.xml
|
|
@ -18,18 +18,20 @@
|
|||
</include>
|
||||
</source>
|
||||
<php>
|
||||
<env name="APP_ENV" value="testing"/>
|
||||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||
<env name="BROADCAST_CONNECTION" value="null"/>
|
||||
<env name="CACHE_STORE" value="array"/>
|
||||
<env name="DB_CONNECTION" value="sqlite"/>
|
||||
<env name="DB_DATABASE" value=":memory:"/>
|
||||
<env name="MAIL_MAILER" value="array"/>
|
||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||
<env name="SESSION_DRIVER" value="array"/>
|
||||
<env name="PULSE_ENABLED" value="false"/>
|
||||
<env name="TELESCOPE_ENABLED" value="false"/>
|
||||
<env name="NIGHTWATCH_ENABLED" value="false"/>
|
||||
<env name="APP_ENV" value="testing" force="true"/>
|
||||
<env name="APP_MAINTENANCE_DRIVER" value="file" force="true"/>
|
||||
<env name="BCRYPT_ROUNDS" value="4" force="true"/>
|
||||
<env name="BROADCAST_CONNECTION" value="null" force="true"/>
|
||||
<env name="CACHE_STORE" value="array" force="true"/>
|
||||
<env name="DB_CONNECTION" value="sqlite" force="true"/>
|
||||
<env name="DB_DATABASE" value=":memory:" force="true"/>
|
||||
<server name="DB_CONNECTION" value="sqlite" force="true"/>
|
||||
<server name="DB_DATABASE" value=":memory:" force="true"/>
|
||||
<env name="MAIL_MAILER" value="array" force="true"/>
|
||||
<env name="QUEUE_CONNECTION" value="sync" force="true"/>
|
||||
<env name="SESSION_DRIVER" value="array" force="true"/>
|
||||
<env name="PULSE_ENABLED" value="false" force="true"/>
|
||||
<env name="TELESCOPE_ENABLED" value="false" force="true"/>
|
||||
<env name="NIGHTWATCH_ENABLED" value="false" force="true"/>
|
||||
</php>
|
||||
</phpunit>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Nouveau participant sur {{ $hunt->title }}</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; background: #f4f7fb; color: #172033; font-family: Arial, Helvetica, sans-serif;">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background: #f4f7fb; padding: 32px 16px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width: 560px; overflow: hidden; background: #ffffff; border: 1px solid #e6ebf2; border-radius: 12px;">
|
||||
<tr>
|
||||
<td style="padding: 32px 32px 16px;">
|
||||
<h1 style="margin: 0; color: #101828; font-size: 28px; line-height: 1.2;">Quelqu'un vient de rejoindre ta hunt</h1>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 0 32px 24px;">
|
||||
<p style="margin: 0 0 16px; color: #344054; font-size: 16px; line-height: 1.6;">
|
||||
Bonjour {{ $notifiable->username }},
|
||||
</p>
|
||||
<p style="margin: 0 0 24px; color: #344054; font-size: 16px; line-height: 1.6;">
|
||||
<strong>{{ $participant->username }}</strong> participe maintenant à ta hunt <strong>{{ $hunt->title }}</strong>.
|
||||
</p>
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="margin: 0 0 24px; background: #f8fafc; border: 1px solid #e6ebf2; border-radius: 10px;">
|
||||
<tr>
|
||||
<td style="padding: 18px 20px;">
|
||||
<p style="margin: 0 0 6px; color: #667085; font-size: 13px; font-weight: 700; text-transform: uppercase;">Hunt</p>
|
||||
<p style="margin: 0 0 16px; color: #101828; font-size: 18px; font-weight: 700; line-height: 1.35;">{{ $hunt->title }}</p>
|
||||
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td style="width: 50%; padding: 0 12px 0 0;">
|
||||
<p style="margin: 0 0 4px; color: #667085; font-size: 13px;">Ville</p>
|
||||
<p style="margin: 0; color: #344054; font-size: 15px; font-weight: 700;">{{ $hunt->city }}</p>
|
||||
</td>
|
||||
<td style="width: 50%; padding: 0 0 0 12px;">
|
||||
<p style="margin: 0 0 4px; color: #667085; font-size: 13px;">Participants</p>
|
||||
<p style="margin: 0; color: #344054; font-size: 15px; font-weight: 700;">{{ $participantsCount }}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="margin: 0 0 16px; color: #344054; font-size: 16px; line-height: 1.6;">
|
||||
Garde un oeil sur l'activité de cette hunt pour suivre sa progression et préparer la suite de l'expérience.
|
||||
</p>
|
||||
<p style="margin: 0;">
|
||||
<a href="{{ config('app.url') . '/admin' }}" style="display: inline-block; padding: 14px 22px; background: #16a34a; border-radius: 8px; color: #ffffff; font-size: 16px; font-weight: 700; text-decoration: none;">
|
||||
Ouvrir le tableau de bord
|
||||
</a>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 20px 32px 32px; border-top: 1px solid #edf1f7;">
|
||||
<p style="margin: 0; color: #98a2b3; font-size: 13px; line-height: 1.5;">
|
||||
Cet email est envoyé parce que tu es le créateur de la hunt {{ $hunt->title }}.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('prevents unverified users from logging in', function () {
|
||||
$user = User::factory()->unverified()->create([
|
||||
'password' => 'password',
|
||||
]);
|
||||
|
||||
$response = $this->postJson('/api/auth/login', [
|
||||
'email' => $user->email,
|
||||
'password' => 'password',
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors([
|
||||
'email' => __('auth.email_not_verified'),
|
||||
]);
|
||||
});
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\UserRole;
|
||||
use App\Filament\Resources\Hunts\Pages\EditHunts;
|
||||
use App\Filament\Resources\Hunts\RelationManagers\ParticipantsRelationManager;
|
||||
use App\Models\Hunts;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('detaches a participant from a hunt without deleting the user', function () {
|
||||
$admin = User::factory()->create([
|
||||
'role' => UserRole::ADMIN,
|
||||
]);
|
||||
|
||||
$hunt = Hunts::withoutSyncingToSearch(fn () => Hunts::factory()->create([
|
||||
'creator_id' => $admin->id,
|
||||
]));
|
||||
|
||||
$participant = User::factory()->create();
|
||||
|
||||
$hunt->participants()->attach($participant->id);
|
||||
|
||||
$this->actingAs($admin);
|
||||
|
||||
Livewire::test(ParticipantsRelationManager::class, [
|
||||
'ownerRecord' => $hunt,
|
||||
'pageClass' => EditHunts::class,
|
||||
])
|
||||
->assertTableActionExists('detach', record: $participant->getKey())
|
||||
->assertTableActionDoesNotExist('delete', record: $participant->getKey())
|
||||
->callTableAction('detach', $participant->getKey());
|
||||
|
||||
$this->assertDatabaseHas('users', [
|
||||
'id' => $participant->id,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseMissing('hunt_participants', [
|
||||
'hunt_id' => $hunt->id,
|
||||
'user_id' => $participant->id,
|
||||
]);
|
||||
});
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\UserRole;
|
||||
use App\Filament\Resources\Hunts\Pages\EditHunts;
|
||||
use App\Filament\Resources\Hunts\RelationManagers\StepsRelationManager;
|
||||
use App\Models\Hunts;
|
||||
use App\Models\User;
|
||||
use Dotswan\MapPicker\Fields\Map;
|
||||
use Filament\Forms\Components\FileUpload;
|
||||
use Filament\Forms\Components\Hidden;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('uses an image upload and map picker when creating hunt steps', function () {
|
||||
$admin = User::factory()->create([
|
||||
'role' => UserRole::ADMIN,
|
||||
]);
|
||||
|
||||
$hunt = Hunts::withoutSyncingToSearch(fn () => Hunts::factory()->create([
|
||||
'creator_id' => $admin->id,
|
||||
'latitude' => 44.837789,
|
||||
'longitude' => -0.57918,
|
||||
]));
|
||||
|
||||
$this->actingAs($admin);
|
||||
|
||||
Livewire::test(StepsRelationManager::class, [
|
||||
'ownerRecord' => $hunt,
|
||||
'pageClass' => EditHunts::class,
|
||||
])
|
||||
->mountTableAction('create')
|
||||
->assertTableActionDataSet([
|
||||
'latitude' => 44.837789,
|
||||
'longitude' => -0.57918,
|
||||
'location' => [
|
||||
'lat' => 44.837789,
|
||||
'lng' => -0.57918,
|
||||
],
|
||||
])
|
||||
->assertFormFieldExists(
|
||||
'hint_media_url',
|
||||
fn ($field): bool => $field instanceof FileUpload,
|
||||
)
|
||||
->assertFormFieldExists(
|
||||
'latitude',
|
||||
fn ($field): bool => $field instanceof Hidden,
|
||||
)
|
||||
->assertFormFieldExists(
|
||||
'longitude',
|
||||
fn ($field): bool => $field instanceof Hidden,
|
||||
)
|
||||
->assertFormFieldExists(
|
||||
'location',
|
||||
fn ($field): bool => $field instanceof Map,
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Hunts;
|
||||
use App\Models\HuntSteps;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Laravel\Scout\EngineManager;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('lists hunts with total step xp', function () {
|
||||
config(['scout.driver' => 'collection']);
|
||||
app(EngineManager::class)->forgetEngines();
|
||||
|
||||
$user = User::factory()->create();
|
||||
$hunt = Hunts::withoutSyncingToSearch(fn () => Hunts::factory()->create([
|
||||
'title' => 'Treasure hunt',
|
||||
]));
|
||||
|
||||
HuntSteps::factory()->create([
|
||||
'hunt_id' => $hunt->id,
|
||||
'xp' => 125,
|
||||
]);
|
||||
|
||||
HuntSteps::factory()->create([
|
||||
'hunt_id' => $hunt->id,
|
||||
'xp' => 75,
|
||||
]);
|
||||
|
||||
$response = $this
|
||||
->actingAs($user, 'sanctum')
|
||||
->getJson('/api/hunts');
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertJsonPath('data.0.id', $hunt->id)
|
||||
->assertJsonPath('data.0.xp', 200)
|
||||
->assertJsonMissingPath('data.0.steps_sum_xp');
|
||||
});
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Hunts;
|
||||
use App\Models\User;
|
||||
use App\Notifications\HuntParticipantJoinedNotification;
|
||||
use App\Notifications\VerifyEmailNotification;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('notifies the hunt creator when a user joins their hunt', function () {
|
||||
Notification::fake();
|
||||
|
||||
$creator = User::factory()->create();
|
||||
$participant = User::factory()->create();
|
||||
$hunt = Hunts::withoutSyncingToSearch(fn () => Hunts::factory()->create([
|
||||
'creator_id' => $creator->id,
|
||||
]));
|
||||
|
||||
$response = $this
|
||||
->actingAs($participant, 'sanctum')
|
||||
->postJson("/api/hunts/{$hunt->id}/participate");
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertJson([
|
||||
'message' => 'Successfully joined the hunt',
|
||||
]);
|
||||
|
||||
Notification::assertSentTo(
|
||||
$creator,
|
||||
HuntParticipantJoinedNotification::class,
|
||||
fn (HuntParticipantJoinedNotification $notification): bool => $notification->hunt->is($hunt)
|
||||
&& $notification->participant->is($participant)
|
||||
&& $notification instanceof ShouldQueue,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not notify the hunt creator when the user already participates', function () {
|
||||
Notification::fake();
|
||||
|
||||
$creator = User::factory()->create();
|
||||
$participant = User::factory()->create();
|
||||
$hunt = Hunts::withoutSyncingToSearch(fn () => Hunts::factory()->create([
|
||||
'creator_id' => $creator->id,
|
||||
]));
|
||||
|
||||
$hunt->participants()->attach($participant->id, [
|
||||
'current_step_number' => 0,
|
||||
'status' => 'in_progress',
|
||||
]);
|
||||
|
||||
$response = $this
|
||||
->actingAs($participant, 'sanctum')
|
||||
->postJson("/api/hunts/{$hunt->id}/participate");
|
||||
|
||||
$response->assertConflict();
|
||||
|
||||
Notification::assertNotSentTo($creator, HuntParticipantJoinedNotification::class);
|
||||
});
|
||||
|
||||
it('uses the personalized joined hunt email view', function () {
|
||||
$creator = User::factory()->create([
|
||||
'username' => 'creator-test',
|
||||
]);
|
||||
$participant = User::factory()->create([
|
||||
'username' => 'participant-test',
|
||||
]);
|
||||
$hunt = Hunts::withoutSyncingToSearch(fn () => Hunts::factory()->create([
|
||||
'creator_id' => $creator->id,
|
||||
'title' => 'La quête des lanternes',
|
||||
'city' => 'Lyon',
|
||||
]));
|
||||
|
||||
$hunt->participants()->attach($participant->id, [
|
||||
'current_step_number' => 0,
|
||||
'status' => 'in_progress',
|
||||
]);
|
||||
|
||||
$notification = new HuntParticipantJoinedNotification($hunt, $participant);
|
||||
$mailMessage = $notification->toMail($creator);
|
||||
|
||||
expect($mailMessage->subject)->toBe('Nouveau participant sur La quête des lanternes')
|
||||
->and($mailMessage->view)->toBe('mail.hunts.joined-hunt')
|
||||
->and($mailMessage->viewData)
|
||||
->toMatchArray([
|
||||
'notifiable' => $creator,
|
||||
'hunt' => $hunt,
|
||||
'participant' => $participant,
|
||||
'participantsCount' => 1,
|
||||
]);
|
||||
|
||||
$renderedView = view($mailMessage->view, $mailMessage->viewData)->render();
|
||||
|
||||
expect($renderedView)
|
||||
->toContain('Bonjour creator-test')
|
||||
->toContain('participant-test')
|
||||
->toContain('La quête des lanternes')
|
||||
->toContain('Lyon');
|
||||
});
|
||||
|
||||
it('queues email verification notifications', function () {
|
||||
expect(new VerifyEmailNotification)->toBeInstanceOf(ShouldQueue::class);
|
||||
});
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Hunts;
|
||||
use App\Models\HuntSteps;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Testing\Fluent\AssertableJson;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('shows a hunt with participants, steps, and total step xp', function () {
|
||||
$user = User::factory()->create();
|
||||
$participant = User::factory()->create([
|
||||
'avatar_uri' => 'avatars/participant.png',
|
||||
'xp' => 450,
|
||||
]);
|
||||
$hunt = Hunts::withoutSyncingToSearch(fn () => Hunts::factory()->create([
|
||||
'title' => 'Treasure hunt',
|
||||
]));
|
||||
|
||||
$hunt->participants()->attach($participant->id, [
|
||||
'current_step_number' => 1,
|
||||
'status' => 'in_progress',
|
||||
]);
|
||||
|
||||
HuntSteps::factory()->create([
|
||||
'hunt_id' => $hunt->id,
|
||||
'step_number' => 1,
|
||||
'title' => 'First step',
|
||||
'xp' => 125,
|
||||
]);
|
||||
|
||||
HuntSteps::factory()->create([
|
||||
'hunt_id' => $hunt->id,
|
||||
'step_number' => 2,
|
||||
'title' => 'Second step',
|
||||
'xp' => 75,
|
||||
]);
|
||||
|
||||
$response = $this
|
||||
->actingAs($user, 'sanctum')
|
||||
->getJson("/api/hunts/{$hunt->id}");
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertJson(fn (AssertableJson $json) => $json
|
||||
->where('id', $hunt->id)
|
||||
->where('title', 'Treasure hunt')
|
||||
->where('xp', 200)
|
||||
->has('participants', 1)
|
||||
->where('participants.0.id', $participant->id)
|
||||
->where('participants.0.username', $participant->username)
|
||||
->has('steps', 2)
|
||||
->etc()
|
||||
);
|
||||
});
|
||||
Loading…
Reference in New Issue