diff --git a/app/Console/Commands/SendEngagementReminderNotifications.php b/app/Console/Commands/SendEngagementReminderNotifications.php new file mode 100644 index 0000000..94f1f3b --- /dev/null +++ b/app/Console/Commands/SendEngagementReminderNotifications.php @@ -0,0 +1,77 @@ +limitOption(); + + if ($limit === false) { + $this->error('The --limit option must be an integer greater than zero.'); + + return self::FAILURE; + } + + $query = $this->eligibleUsersQuery(); + + if ($this->option('dry-run')) { + $this->info("{$query->count()} eligible user(s) found."); + + return self::SUCCESS; + } + + $sent = 0; + + foreach ($query->lazyById(100) as $user) { + if ($limit !== null && $sent >= $limit) { + break; + } + + $user->notify( + new EngagementReminderNotification(EngagementReminderNotification::randomMessageKey()), + ); + + $sent++; + } + + $this->info("{$sent} engagement reminder notification(s) sent."); + + return self::SUCCESS; + } + + private function eligibleUsersQuery(): Builder + { + return User::query() + ->select(['id', 'locale', 'suspended_at']) + ->whereNull('suspended_at') + ->whereHas('deviceTokens') + ->whereDoesntHave('mealPosts', fn (Builder $query): Builder => $query + ->where('created_at', '>=', now()->subDay())); + } + + private function limitOption(): int|false|null + { + $limit = $this->option('limit'); + + if ($limit === null || $limit === '') { + return null; + } + + return filter_var($limit, FILTER_VALIDATE_INT, [ + 'options' => ['min_range' => 1], + ]); + } +} diff --git a/app/Notifications/EngagementReminderNotification.php b/app/Notifications/EngagementReminderNotification.php new file mode 100644 index 0000000..6d52743 --- /dev/null +++ b/app/Notifications/EngagementReminderNotification.php @@ -0,0 +1,100 @@ + + */ + public static function messageKeys(): array + { + return self::MESSAGE_KEYS; + } + + /** + * Get the notification's delivery channels. + * + * @return array + */ + public function via(object $notifiable): array + { + return ['expo']; + } + + /** + * @return array{ + * title: string, + * body: string, + * sound: string, + * channelId: string, + * data: array{type: string, message_key: string, url: string} + * } + */ + public function toExpoPush(object $notifiable): array + { + $message = $this->message(); + + return [ + 'title' => $message['title'], + 'body' => $message['body'], + 'sound' => 'default', + 'channelId' => 'default', + 'data' => [ + 'type' => 'engagement_reminder', + 'message_key' => $this->messageKey, + 'url' => MobileDeepLink::to($this->path()), + ], + ]; + } + + /** + * @return array{title: string, body: string} + */ + private function message(): array + { + $message = trans("api.notifications.engagement_reminders.{$this->messageKey}"); + + if (is_array($message)) { + return $message; + } + + $fallback = trans('api.notifications.engagement_reminders.publish_meal'); + + return is_array($fallback) + ? $fallback + : [ + 'title' => 'Bowli', + 'body' => 'Publie ton plat du jour quand tu as un moment.', + ]; + } + + private function path(): string + { + return $this->messageKey === 'workout_checkin' + ? 'profile/workouts?create=1' + : 'create'; + } +} diff --git a/app/Services/MobileDeepLink.php b/app/Services/MobileDeepLink.php new file mode 100644 index 0000000..0dec334 --- /dev/null +++ b/app/Services/MobileDeepLink.php @@ -0,0 +1,26 @@ + $query + */ + public static function to(string $path, array $query = []): string + { + $url = sprintf('%s://%s', self::scheme(), ltrim($path, '/')); + $queryString = http_build_query($query, '', '&', PHP_QUERY_RFC3986); + + return $queryString === '' + ? $url + : "{$url}?{$queryString}"; + } + + private static function scheme(): string + { + $scheme = rtrim((string) config('app.mobile_scheme', 'bowly'), ':/'); + + return $scheme !== '' ? $scheme : 'bowly'; + } +} diff --git a/routes/console.php b/routes/console.php index 0228a2e..4f9401d 100644 --- a/routes/console.php +++ b/routes/console.php @@ -26,6 +26,6 @@ Schedule::command('meal-posts:generate-ai') ->withoutOverlapping(55) ->onOneServer(); Schedule::command('notifications:send-engagement-reminders') - ->dailyAt('12:30') + ->dailyAt('13:02') ->withoutOverlapping(30) ->onOneServer(); diff --git a/tests/Feature/EngagementReminderNotificationsTest.php b/tests/Feature/EngagementReminderNotificationsTest.php new file mode 100644 index 0000000..0d08d47 --- /dev/null +++ b/tests/Feature/EngagementReminderNotificationsTest.php @@ -0,0 +1,103 @@ +setLocale('fr'); + config()->set('app.mobile_scheme', 'dailymeal'); + + Http::fake([ + 'https://exp.host/*' => Http::response([ + 'data' => [ + ['status' => 'ok', 'id' => 'ticket-one'], + ], + ]), + ]); + + $eligibleUser = User::factory()->create(['locale' => 'fr']); + $eligibleUser->deviceTokens()->create([ + 'expo_push_token' => 'ExponentPushToken[eligible]', + 'platform' => 'ios', + ]); + + $recentPoster = User::factory()->create(['locale' => 'fr']); + $recentPoster->deviceTokens()->create([ + 'expo_push_token' => 'ExponentPushToken[recent-poster]', + 'platform' => 'ios', + ]); + MealPosts::factory() + ->for($recentPoster, 'user') + ->create([ + 'created_at' => now()->subHours(2), + 'updated_at' => now()->subHours(2), + ]); + + User::factory()->create(['locale' => 'fr']); + + $suspendedUser = User::factory()->create([ + 'locale' => 'fr', + 'suspended_at' => now(), + ]); + $suspendedUser->deviceTokens()->create([ + 'expo_push_token' => 'ExponentPushToken[suspended]', + 'platform' => 'ios', + ]); + + $this->artisan('notifications:send-engagement-reminders') + ->expectsOutput('1 engagement reminder notification(s) sent.') + ->assertSuccessful(); + + Http::assertSentCount(1); + + Http::assertSent(function (Request $request): bool { + $payload = $request->data()[0]; + + return $payload['to'] === 'ExponentPushToken[eligible]' + && $payload['data']['type'] === 'engagement_reminder' + && in_array($payload['data']['url'], [ + 'dailymeal://create', + 'dailymeal://profile/workouts?create=1', + ], true); + }); +}); + +it('uses the hard-coded localized reminder copy', function () { + config()->set('app.mobile_scheme', 'dailymeal'); + + foreach (['fr', 'en'] as $locale) { + app()->setLocale($locale); + + foreach (EngagementReminderNotification::messageKeys() as $messageKey) { + $expectedPath = $messageKey === 'workout_checkin' + ? 'profile/workouts?create=1' + : 'create'; + + $payload = (new EngagementReminderNotification($messageKey)) + ->toExpoPush(User::factory()->make(['locale' => $locale])); + + expect($payload['title'])->toBeString()->not->toBeEmpty() + ->and($payload['body'])->toBeString()->not->toBeEmpty() + ->and($payload['data']['message_key'])->toBe($messageKey) + ->and($payload['data']['url'])->toBe("dailymeal://{$expectedPath}"); + } + } +}); + +it('builds mobile deep links from the configured scheme', function () { + config()->set('app.mobile_scheme', 'dailymeal://'); + + expect(MobileDeepLink::to('/create'))->toBe('dailymeal://create') + ->and(MobileDeepLink::to('strava/callback', ['code' => 'abc 123']))->toBe('dailymeal://strava/callback?code=abc%20123'); + + config()->set('app.mobile_scheme', ''); + + expect(MobileDeepLink::to('create'))->toBe('bowly://create'); +});