feat: missing
Laravel CI-CD / Tests Unitaires (push) Successful in 1m28s Details
Laravel CI-CD / Deploy with Kamal (push) Successful in 1m54s Details

This commit is contained in:
Leon Morival 2026-05-26 12:59:12 +02:00
parent f716a205ce
commit 835dfcc677
5 changed files with 307 additions and 1 deletions

View File

@ -0,0 +1,77 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use App\Notifications\EngagementReminderNotification;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
class SendEngagementReminderNotifications extends Command
{
protected $signature = 'notifications:send-engagement-reminders
{--limit= : Maximum number of users to notify}
{--dry-run : Count eligible users without sending notifications}';
protected $description = 'Send occasional hard-coded engagement reminders to mobile users.';
public function handle(): int
{
$limit = $this->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],
]);
}
}

View File

@ -0,0 +1,100 @@
<?php
namespace App\Notifications;
use App\Services\MobileDeepLink;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Arr;
class EngagementReminderNotification extends Notification
{
use Queueable;
private const MESSAGE_KEYS = [
'publish_meal',
'meal_photo',
'evening_checkin',
'community_inspiration',
'workout_checkin',
];
public function __construct(private readonly string $messageKey) {}
public static function randomMessageKey(): string
{
return Arr::random(self::MESSAGE_KEYS);
}
/**
* @return list<string>
*/
public static function messageKeys(): array
{
return self::MESSAGE_KEYS;
}
/**
* Get the notification's delivery channels.
*
* @return array<int, string>
*/
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';
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace App\Services;
class MobileDeepLink
{
/**
* @param array<string, mixed> $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';
}
}

View File

@ -26,6 +26,6 @@ Schedule::command('meal-posts:generate-ai')
->withoutOverlapping(55) ->withoutOverlapping(55)
->onOneServer(); ->onOneServer();
Schedule::command('notifications:send-engagement-reminders') Schedule::command('notifications:send-engagement-reminders')
->dailyAt('12:30') ->dailyAt('13:02')
->withoutOverlapping(30) ->withoutOverlapping(30)
->onOneServer(); ->onOneServer();

View File

@ -0,0 +1,103 @@
<?php
use App\Models\MealPosts;
use App\Models\User;
use App\Notifications\EngagementReminderNotification;
use App\Services\MobileDeepLink;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
uses(RefreshDatabase::class);
it('sends engagement reminders only to eligible mobile users', function () {
app()->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');
});