feat: missing
This commit is contained in:
parent
f716a205ce
commit
835dfcc677
|
|
@ -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],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue