From d9e80f760319aa8f3631256b48c253679d41086e Mon Sep 17 00:00:00 2001 From: Leon Morival Date: Sat, 16 May 2026 15:53:39 +0200 Subject: [PATCH] feat: expo channel --- .env.example | 2 + app/Broadcasting/ExpoPushChannel.php | 158 ++++++++++++++++++ .../Controllers/DeviceTokenController.php | 9 + app/Models/User.php | 15 +- app/Notifications/TestNotification.php | 62 +++++++ app/Providers/AppServiceProvider.php | 20 +-- config/services.php | 4 + ...5_16_122204_create_device_tokens_table.php | 2 +- ...5_16_131810_create_notifications_table.php | 31 ++++ phpunit.xml | 3 +- routes/api.php | 1 + tests/Feature/ExpoPushChannelTest.php | 157 +++++++++++++++++ 12 files changed, 448 insertions(+), 16 deletions(-) create mode 100644 app/Broadcasting/ExpoPushChannel.php create mode 100644 app/Notifications/TestNotification.php create mode 100644 database/migrations/2026_05_16_131810_create_notifications_table.php create mode 100644 tests/Feature/ExpoPushChannelTest.php diff --git a/.env.example b/.env.example index cf1b7b8..d00de92 100644 --- a/.env.example +++ b/.env.example @@ -57,6 +57,8 @@ MAIL_PASSWORD=null MAIL_FROM_ADDRESS="hello@example.com" MAIL_FROM_NAME="${APP_NAME}" +EXPO_ACCESS_TOKEN= + AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= AWS_DEFAULT_REGION=us-east-1 diff --git a/app/Broadcasting/ExpoPushChannel.php b/app/Broadcasting/ExpoPushChannel.php new file mode 100644 index 0000000..0f728e9 --- /dev/null +++ b/app/Broadcasting/ExpoPushChannel.php @@ -0,0 +1,158 @@ +tokensFor($notifiable, $notification); + + if ($tokens->isEmpty()) { + return; + } + + $payload = $this->payloadFor($notifiable, $notification); + + $tokens + ->chunk(self::MAX_MESSAGES_PER_REQUEST) + ->each(fn (Collection $chunk) => $this->sendChunk($chunk, $payload)); + } + + private function tokensFor($notifiable, Notification $notification): Collection + { + $tokens = method_exists($notifiable, 'routeNotificationFor') + ? $notifiable->routeNotificationFor('expoPush', $notification) + : null; + + return collect($tokens) + ->filter(fn ($token) => is_string($token) && $token !== '') + ->unique() + ->values(); + } + + private function payloadFor($notifiable, Notification $notification): array + { + if (! method_exists($notification, 'toExpoPush')) { + throw new InvalidArgumentException(sprintf( + 'Notification [%s] is missing a toExpoPush($notifiable) method.', + $notification::class, + )); + } + + $payload = $notification->toExpoPush($notifiable); + + if ($payload instanceof Arrayable) { + $payload = $payload->toArray(); + } + + if (! is_array($payload)) { + throw new InvalidArgumentException(sprintf( + 'Notification [%s] must return an array or Arrayable from toExpoPush($notifiable).', + $notification::class, + )); + } + + return collect($payload) + ->except('to') + ->reject(fn ($value) => $value === null) + ->all(); + } + + private function sendChunk(Collection $tokens, array $payload): void + { + $messages = $tokens + ->map(fn (string $token) => ['to' => $token] + $payload) + ->values() + ->all(); + + try { + $response = Http::acceptJson() + ->asJson() + ->timeout(10) + ->connectTimeout(5) + ->when(config('services.expo.access_token'), fn ($request, string $accessToken) => $request->withToken($accessToken)) + ->retry( + 3, + 250, + fn ($exception) => $this->shouldRetry($exception), + throw: false, + ) + ->post(self::EXPO_PUSH_ENDPOINT, $messages); + } catch (ConnectionException $exception) { + Log::warning('Unable to connect to Expo Push API.', [ + 'exception' => $exception->getMessage(), + 'tokens_count' => $tokens->count(), + ]); + + return; + } + + if ($response->failed()) { + Log::warning('Expo Push API rejected a notification request.', [ + 'status' => $response->status(), + 'response' => $response->json(), + 'tokens_count' => $tokens->count(), + ]); + + return; + } + + $body = $response->json(); + + $this->handleTickets(is_array($body) ? $body : null, $tokens); + } + + private function shouldRetry($exception): bool + { + if ($exception instanceof ConnectionException) { + return true; + } + + if (! $exception instanceof RequestException) { + return false; + } + + return $exception->response->status() === 429 + || $exception->response->serverError(); + } + + private function handleTickets(?array $response, Collection $tokens): void + { + foreach (Arr::get($response, 'data', []) as $index => $ticket) { + if (($ticket['status'] ?? null) !== 'error') { + continue; + } + + $token = $tokens->values()->get($index); + $error = Arr::get($ticket, 'details.error'); + + if ($error === 'DeviceNotRegistered' && is_string($token)) { + DeviceToken::query() + ->where('expo_push_token', $token) + ->delete(); + } + + Log::warning('Expo Push API rejected a notification.', [ + 'error' => $error, + 'message' => $ticket['message'] ?? null, + 'token' => $token, + ]); + } + } +} diff --git a/app/Http/Controllers/DeviceTokenController.php b/app/Http/Controllers/DeviceTokenController.php index a5fab81..f1f9155 100644 --- a/app/Http/Controllers/DeviceTokenController.php +++ b/app/Http/Controllers/DeviceTokenController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers; use App\Http\Requests\DeviceTokenRequest; use App\Models\DeviceToken; +use App\Notifications\TestNotification; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\Response; @@ -33,6 +34,14 @@ class DeviceTokenController extends Controller ], ], $deviceToken->wasRecentlyCreated ? 201 : 200); } + public function notifs(): JsonResponse + { + auth()->user()->notify(new TestNotification()); + return response()->json([ + 'message' => 'Notification envoyée' + ]); + + } public function destroy(Request $request): Response { diff --git a/app/Models/User.php b/app/Models/User.php index 241e5a1..5304958 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -15,7 +15,7 @@ use Laravel\Sanctum\HasApiTokens; class User extends Authenticatable implements FilamentUser { /** @use HasFactory<\Database\Factories\UserFactory> */ - use HasFactory, Notifiable, HasApiTokens, HasUlids; + use HasApiTokens, HasFactory, HasUlids, Notifiable; /** * The attributes that are mass assignable. @@ -27,7 +27,7 @@ class User extends Authenticatable implements FilamentUser 'email', 'password', 'avatar_url', - 'bio' + 'bio', ]; /** @@ -53,16 +53,23 @@ class User extends Authenticatable implements FilamentUser ]; } - public function deviceTokens(): HasMany { return $this->hasMany(DeviceToken::class); } + public function routeNotificationForExpoPush(): array + { + return $this->deviceTokens() + ->orderBy('id') + ->pluck('expo_push_token') + ->all(); + } + public function isAdmin() { return true; -// return $this->role === 'admin'; + // return $this->role === 'admin'; } public function canAccessPanel(Panel $panel): bool diff --git a/app/Notifications/TestNotification.php b/app/Notifications/TestNotification.php new file mode 100644 index 0000000..7d5e674 --- /dev/null +++ b/app/Notifications/TestNotification.php @@ -0,0 +1,62 @@ + + */ + public function via(object $notifiable): array + { + return ['expo']; + } + + /** + * Get the mail representation of the notification. + */ + public function toMail(object $notifiable): MailMessage + { + return (new MailMessage) + ->line('The introduction to the notification.') + ->action('Notification Action', url('/')) + ->line('Thank you for using our application!'); + } + public function toExpoPush($notifiable) + { + return [ + 'title' => 'Test', + 'body' => 'Notification depuis Laravel API', + 'data' => ['test' => true], + ]; + } + + /** + * Get the array representation of the notification. + * + * @return array + */ + public function toArray(object $notifiable): array + { + return [ + // + ]; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index d937af7..90afb29 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,23 +2,21 @@ namespace App\Providers; -use Dedoc\Scramble\Scramble; -use Dedoc\Scramble\Support\Generator\OpenApi; -use Dedoc\Scramble\Support\Generator\SecurityScheme; +use App\Broadcasting\ExpoPushChannel; use Illuminate\Support\Facades\Gate; +use Illuminate\Support\Facades\Notification; use Illuminate\Support\Facades\URL; use Illuminate\Support\ServiceProvider; -use Meilisearch\Meilisearch; +use Spatie\Health\Checks\Checks\DatabaseCheck; +use Spatie\Health\Checks\Checks\DebugModeCheck; use Spatie\Health\Checks\Checks\EnvironmentCheck; +use Spatie\Health\Checks\Checks\HorizonCheck; use Spatie\Health\Checks\Checks\MeilisearchCheck; +use Spatie\Health\Checks\Checks\OptimizedAppCheck; +use Spatie\Health\Checks\Checks\RedisCheck; use Spatie\Health\Checks\Checks\ScheduleCheck; use Spatie\Health\Checks\Checks\UsedDiskSpaceCheck; use Spatie\Health\Facades\Health; -use Spatie\Health\Checks\Checks\DatabaseCheck; -use Spatie\Health\Checks\Checks\RedisCheck; -use Spatie\Health\Checks\Checks\HorizonCheck; -use Spatie\Health\Checks\Checks\OptimizedAppCheck; -use Spatie\Health\Checks\Checks\DebugModeCheck; class AppServiceProvider extends ServiceProvider { @@ -39,6 +37,8 @@ class AppServiceProvider extends ServiceProvider URL::forceScheme('https'); } + Notification::extend('expo', fn ($app) => $app->make(ExpoPushChannel::class)); + Gate::define('viewApiDocs', function ($user = null) { // Option A : Autoriser tout le monde (Attention : la doc sera publique hors local) return true; @@ -60,7 +60,7 @@ class AppServiceProvider extends ServiceProvider EnvironmentCheck::new(), DebugModeCheck::new(), MeilisearchCheck::new() - ->url('http://meilisearch:7700/health') + ->url('http://meilisearch:7700/health'), ]); } diff --git a/config/services.php b/config/services.php index 6a90eb8..6c964cc 100644 --- a/config/services.php +++ b/config/services.php @@ -22,6 +22,10 @@ return [ 'key' => env('RESEND_API_KEY'), ], + 'expo' => [ + 'access_token' => env('EXPO_ACCESS_TOKEN'), + ], + 'ses' => [ 'key' => env('AWS_ACCESS_KEY_ID'), 'secret' => env('AWS_SECRET_ACCESS_KEY'), diff --git a/database/migrations/2026_05_16_122204_create_device_tokens_table.php b/database/migrations/2026_05_16_122204_create_device_tokens_table.php index 101714e..a3464ab 100644 --- a/database/migrations/2026_05_16_122204_create_device_tokens_table.php +++ b/database/migrations/2026_05_16_122204_create_device_tokens_table.php @@ -14,7 +14,7 @@ return new class extends Migration Schema::create('device_tokens', function (Blueprint $table) { $table->id(); $table->foreignUlid('user_id')->constrained()->cascadeOnDelete(); - $table->string('expo_push_token'); + $table->string('expo_push_token')->unique(); $table->string('platform')->nullable(); $table->timestampsTz(); }); diff --git a/database/migrations/2026_05_16_131810_create_notifications_table.php b/database/migrations/2026_05_16_131810_create_notifications_table.php new file mode 100644 index 0000000..60f790a --- /dev/null +++ b/database/migrations/2026_05_16_131810_create_notifications_table.php @@ -0,0 +1,31 @@ +uuid('id')->primary(); + $table->string('type'); + $table->ulidMorphs('notifiable'); + $table->text('data'); + $table->timestamp('read_at')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('notifications'); + } +}; diff --git a/phpunit.xml b/phpunit.xml index bbfcf81..d703241 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -23,7 +23,8 @@ - + + diff --git a/routes/api.php b/routes/api.php index e6791f4..c1f3214 100644 --- a/routes/api.php +++ b/routes/api.php @@ -19,6 +19,7 @@ Route::prefix('auth')->group(function (): void { Route::middleware('auth:sanctum')->group(function (): void { Route::post('device-tokens', [DeviceTokenController::class, 'store']); Route::delete('device-tokens', [DeviceTokenController::class, 'destroy']); + Route::get('test-notifs', [DeviceTokenController::class, 'notifs']); }); Route::middleware('auth:sanctum')->apiResource('meal-posts', MealPostController::class); diff --git a/tests/Feature/ExpoPushChannelTest.php b/tests/Feature/ExpoPushChannelTest.php new file mode 100644 index 0000000..fba18fe --- /dev/null +++ b/tests/Feature/ExpoPushChannelTest.php @@ -0,0 +1,157 @@ + Http::response([ + 'data' => [ + ['status' => 'ok', 'id' => 'ticket-one'], + ['status' => 'ok', 'id' => 'ticket-two'], + ], + ]), + ]); + + $user = User::factory()->create(); + $user->deviceTokens()->create([ + 'expo_push_token' => 'ExponentPushToken[first]', + 'platform' => 'ios', + ]); + $user->deviceTokens()->create([ + 'expo_push_token' => 'ExponentPushToken[second]', + 'platform' => 'android', + ]); + + (new ExpoPushChannel)->send($user, expoPushTestNotification()); + + Http::assertSent(fn (Request $request) => $request->url() === 'https://exp.host/--/api/v2/push/send' + && $request->data() === [ + [ + 'to' => 'ExponentPushToken[first]', + 'title' => 'Nouveau repas', + 'body' => 'Un ami vient de publier son repas.', + 'data' => ['meal_post_id' => 123], + ], + [ + 'to' => 'ExponentPushToken[second]', + 'title' => 'Nouveau repas', + 'body' => 'Un ami vient de publier son repas.', + 'data' => ['meal_post_id' => 123], + ], + ]); +}); + +it('can be used from a notification via the expo channel alias', function () { + Http::fake([ + 'https://exp.host/*' => Http::response([ + 'data' => [ + ['status' => 'ok', 'id' => 'ticket-one'], + ], + ]), + ]); + + $user = User::factory()->create(); + $user->deviceTokens()->create([ + 'expo_push_token' => 'ExponentPushToken[first]', + 'platform' => 'ios', + ]); + + $user->notify(new class extends Notification + { + public function via(object $notifiable): array + { + return ['expo']; + } + + public function toExpoPush(object $notifiable): array + { + return [ + 'title' => 'Nouveau repas', + 'body' => 'Un ami vient de publier son repas.', + 'data' => ['meal_post_id' => 123], + ]; + } + }); + + Http::assertSent(fn (Request $request) => $request->data()[0]['to'] === 'ExponentPushToken[first]'); +}); + +it('removes device tokens rejected as not registered by expo', function () { + Http::fake([ + 'https://exp.host/*' => Http::response([ + 'data' => [ + [ + 'status' => 'error', + 'message' => '"ExponentPushToken[invalid]" is not a registered push notification recipient', + 'details' => ['error' => 'DeviceNotRegistered'], + ], + ['status' => 'ok', 'id' => 'ticket-two'], + ], + ]), + ]); + + $user = User::factory()->create(); + $invalidToken = $user->deviceTokens()->create([ + 'expo_push_token' => 'ExponentPushToken[invalid]', + 'platform' => 'ios', + ]); + $validToken = $user->deviceTokens()->create([ + 'expo_push_token' => 'ExponentPushToken[valid]', + 'platform' => 'android', + ]); + + (new ExpoPushChannel)->send($user, expoPushTestNotification()); + + expect(DeviceToken::query()->whereKey($invalidToken->getKey())->exists())->toBeFalse() + ->and(DeviceToken::query()->whereKey($validToken->getKey())->exists())->toBeTrue(); +}); + +it('chunks expo push notifications into requests of one hundred messages', function () { + Http::fake([ + 'https://exp.host/*' => Http::response(['data' => []]), + ]); + + $user = User::factory()->create(); + + foreach (range(1, 101) as $number) { + $user->deviceTokens()->create([ + 'expo_push_token' => sprintf('ExponentPushToken[%03d]', $number), + 'platform' => 'ios', + ]); + } + + (new ExpoPushChannel)->send($user, expoPushTestNotification()); + + Http::assertSentCount(2); + + $requests = Http::recorded() + ->map(fn (array $pair) => $pair[0]->data()) + ->values(); + + expect($requests[0])->toHaveCount(100) + ->and($requests[1])->toHaveCount(1) + ->and($requests[1][0]['to'])->toBe('ExponentPushToken[101]'); +}); + +function expoPushTestNotification(): Notification +{ + return new class extends Notification + { + public function toExpoPush(object $notifiable): array + { + return [ + 'title' => 'Nouveau repas', + 'body' => 'Un ami vient de publier son repas.', + 'data' => ['meal_post_id' => 123], + ]; + } + }; +}