diff --git a/.env.example b/.env.example index 9dba2da..f126936 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 3ae7203..58a324b 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -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}" diff --git a/AGENTS.md b/AGENTS.md index e5db0e3..ad6d3b1 100644 --- a/AGENTS.md +++ b/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) +
+ {{ $item->name }} +
+ @endforeach + ``` + +- Prefer lifecycle hooks like `mount()`, `updatedFoo()` for initialization and reactive side effects: + + + public function mount(User $user) { $this->user = $user; } + public function updatedSearch() { $this->resetPage(); } + + + +## Testing Livewire + + + Livewire::test(Counter::class) + ->assertSet('count', 0) + ->call('increment') + ->assertSet('count', 1) + ->assertSee(1) + ->assertStatus(200); + + + + + $this->get('/posts/create') + ->assertSeeLivewire(CreatePost::class); + + + +=== 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: + + +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); + }); +}); + + + === pint/core rules === ## Laravel Pint Code Formatter diff --git a/Dockerfile b/Dockerfile index 882a161..b37ffbb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 \ diff --git a/app/Http/Controllers/HuntsController.php b/app/Http/Controllers/HuntsController.php index 4846a2d..ad015e0 100644 --- a/app/Http/Controllers/HuntsController.php +++ b/app/Http/Controllers/HuntsController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers; use App\Enums\HuntDifficulty; use App\Models\Hunts; +use App\Notifications\HuntParticipantJoinedNotification; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Carbon; @@ -30,7 +31,7 @@ class HuntsController extends Controller ]); $result = $search->paginate(10); - + Log::info('HUNTS_SEARCH_SUCCESS', [ 'total' => $result->total(), 'count' => count($result->items()), @@ -59,7 +60,7 @@ class HuntsController extends Controller $hunt->load([ 'participants:id,username,avatar_uri,xp,level_id', 'participants.currentLevel', - 'steps' + 'steps', ]); Log::info('HUNT_SHOW_SUCCESS', [ @@ -108,7 +109,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 +144,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 +168,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 +210,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 +261,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 +297,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 +354,7 @@ class HuntsController extends Controller $filters[] = "participant_ids != \"{$escapedUserId}\""; } } elseif ($participating === true) { - $filters[] = "id < 0"; + $filters[] = 'id < 0'; } } @@ -392,21 +399,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 +436,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 +452,7 @@ class HuntsController extends Controller { $searchTerm = $request->query('search') ?? $request->query('q'); - if (!is_string($searchTerm)) { + if (! is_string($searchTerm)) { return null; } @@ -456,7 +463,7 @@ class HuntsController extends Controller private function parseDateFilterUtc(mixed $value): ?Carbon { - if (!is_string($value) || trim($value) === '') { + if (! is_string($value) || trim($value) === '') { return null; } @@ -471,6 +478,7 @@ class HuntsController extends Controller { // Échappe les backslashes et les guillemets pour une string Meilisearch $value = str_replace('\\', '\\\\', $value); + return str_replace('"', '\"', $value); } } diff --git a/app/Models/Hunts.php b/app/Models/Hunts.php index 39fe596..d10a6ef 100644 --- a/app/Models/Hunts.php +++ b/app/Models/Hunts.php @@ -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') diff --git a/app/Notifications/HuntParticipantJoinedNotification.php b/app/Notifications/HuntParticipantJoinedNotification.php new file mode 100644 index 0000000..585773c --- /dev/null +++ b/app/Notifications/HuntParticipantJoinedNotification.php @@ -0,0 +1,37 @@ + + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + public function toMail(object $notifiable): MailMessage + { + return (new MailMessage) + ->subject('Nouveau participant sur '.$this->hunt->title) + ->greeting('Bonjour '.$notifiable->username) + ->line($this->participant->username.' participe maintenant à votre chasse "'.$this->hunt->title.'".') + ->line('Vous pouvez suivre les participants depuis votre espace organisateur.'); + } +} diff --git a/app/Notifications/VerifyEmailNotification.php b/app/Notifications/VerifyEmailNotification.php index 88bfcfb..82737ad 100644 --- a/app/Notifications/VerifyEmailNotification.php +++ b/app/Notifications/VerifyEmailNotification.php @@ -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; diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index 8ce4eb0..48d741f 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -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, diff --git a/app/Providers/HorizonServiceProvider.php b/app/Providers/HorizonServiceProvider.php new file mode 100644 index 0000000..526e3ea --- /dev/null +++ b/app/Providers/HorizonServiceProvider.php @@ -0,0 +1,35 @@ +isAdmin() ?? false; + }); + } +} diff --git a/bootstrap/providers.php b/bootstrap/providers.php index 22744d1..2a33f62 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -3,4 +3,5 @@ return [ App\Providers\AppServiceProvider::class, App\Providers\Filament\AdminPanelProvider::class, + App\Providers\HorizonServiceProvider::class, ]; diff --git a/compose.yaml b/compose.yaml index 53ad614..d605e33 100644 --- a/compose.yaml +++ b/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 diff --git a/composer.json b/composer.json index db777df..f3f0b05 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/composer.lock b/composer.lock index e6e2a31..9590b37 100644 --- a/composer.lock +++ b/composer.lock @@ -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", diff --git a/config/horizon.php b/config/horizon.php new file mode 100644 index 0000000..32f8f0d --- /dev/null +++ b/config/horizon.php @@ -0,0 +1,254 @@ + 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', + ], +]; diff --git a/config/queue.php b/config/queue.php index 79c2c0a..a0f669c 100644 --- a/config/queue.php +++ b/config/queue.php @@ -13,7 +13,7 @@ return [ | */ - 'default' => env('QUEUE_CONNECTION', 'database'), + 'default' => env('QUEUE_CONNECTION', 'redis'), /* |-------------------------------------------------------------------------- diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 1c2f413..f23befb 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -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: diff --git a/tests/Feature/HuntParticipationNotificationTest.php b/tests/Feature/HuntParticipationNotificationTest.php new file mode 100644 index 0000000..d3236a5 --- /dev/null +++ b/tests/Feature/HuntParticipationNotificationTest.php @@ -0,0 +1,66 @@ +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('queues email verification notifications', function () { + expect(new VerifyEmailNotification)->toBeInstanceOf(ShouldQueue::class); +});