diff --git a/app/Console/Commands/SendInactivityReminders.php b/app/Console/Commands/SendInactivityReminders.php new file mode 100644 index 0000000..40fe278 --- /dev/null +++ b/app/Console/Commands/SendInactivityReminders.php @@ -0,0 +1,54 @@ +subDays((int) $this->option('inactive-days')); + $cooldownSince = now()->subDays((int) $this->option('cooldown-days')); + + $users = User::query() + ->whereNotNull('email_verified_at') + ->where('created_at', '<=', $inactiveSince) + ->whereDoesntHave('workoutSessions', function ($q) use ($inactiveSince) { + $q->whereNotNull('completed_at')->where('completed_at', '>=', $inactiveSince); + }) + ->where(function ($q) use ($cooldownSince) { + $q->whereNull('last_reminder_sent_at')->orWhere('last_reminder_sent_at', '<', $cooldownSince); + }) + ->get(); + + foreach ($users as $user) { + $user->notify(new InactivityReminder()); + $user->last_reminder_sent_at = now(); + $user->save(); + } + + $this->info("Sent inactivity reminders to {$users->count()} user(s)."); + + return self::SUCCESS; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index f317748..be2c4a9 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -74,6 +74,11 @@ class User extends Authenticatable return $this->hasMany(Program::class); } + public function workoutSessions() + { + return $this->hasMany(WorkoutSession::class); + } + // Trainer/client relationships public function clients() { diff --git a/app/Notifications/InactivityReminder.php b/app/Notifications/InactivityReminder.php new file mode 100644 index 0000000..94b5c77 --- /dev/null +++ b/app/Notifications/InactivityReminder.php @@ -0,0 +1,36 @@ + + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + /** + * Get the mail representation of the notification. + */ + public function toMail(object $notifiable): MailMessage + { + return (new MailMessage) + ->subject("We haven't seen you in a while, {$notifiable->name}") + ->line('It has been a few days since your last completed workout.') + ->line('A short session today is better than a perfect one you keep putting off.') + ->action('Start a Workout', route('workouts.index')) + ->line('See you back soon!'); + } +} diff --git a/database/migrations/2026_08_10_093017_add_last_reminder_sent_at_to_users_table.php b/database/migrations/2026_08_10_093017_add_last_reminder_sent_at_to_users_table.php new file mode 100644 index 0000000..e9e6ecb --- /dev/null +++ b/database/migrations/2026_08_10_093017_add_last_reminder_sent_at_to_users_table.php @@ -0,0 +1,28 @@ +timestamp('last_reminder_sent_at')->nullable()->after('minutes_per_session'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('last_reminder_sent_at'); + }); + } +}; diff --git a/routes/console.php b/routes/console.php index 3c9adf1..e8893fd 100644 --- a/routes/console.php +++ b/routes/console.php @@ -2,7 +2,10 @@ use Illuminate\Foundation\Inspiring; use Illuminate\Support\Facades\Artisan; +use Illuminate\Support\Facades\Schedule; Artisan::command('inspire', function () { $this->comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); + +Schedule::command('app:send-inactivity-reminders')->dailyAt('09:00'); diff --git a/tests/Feature/SendInactivityRemindersTest.php b/tests/Feature/SendInactivityRemindersTest.php new file mode 100644 index 0000000..a46f9fb --- /dev/null +++ b/tests/Feature/SendInactivityRemindersTest.php @@ -0,0 +1,108 @@ +create([ + 'email_verified_at' => now()->subDays(10), + 'created_at' => now()->subDays(10), + ]); + + $this->artisan('app:send-inactivity-reminders')->assertSuccessful(); + + Notification::assertSentTo($user, InactivityReminder::class); + $this->assertNotNull($user->fresh()->last_reminder_sent_at); + } + + public function test_it_does_not_remind_a_user_with_a_recent_completed_session(): void + { + Notification::fake(); + + $user = User::factory()->create([ + 'email_verified_at' => now()->subDays(10), + 'created_at' => now()->subDays(10), + ]); + $workout = Workout::factory()->create(['user_id' => $user->id]); + WorkoutSession::factory()->create([ + 'user_id' => $user->id, + 'workout_id' => $workout->id, + 'completed_at' => now()->subDay(), + ]); + + $this->artisan('app:send-inactivity-reminders'); + + Notification::assertNotSentTo($user, InactivityReminder::class); + } + + public function test_it_does_not_remind_a_user_still_within_the_cooldown(): void + { + Notification::fake(); + + $user = User::factory()->create([ + 'email_verified_at' => now()->subDays(10), + 'created_at' => now()->subDays(10), + 'last_reminder_sent_at' => now()->subDays(2), + ]); + + $this->artisan('app:send-inactivity-reminders'); + + Notification::assertNotSentTo($user, InactivityReminder::class); + } + + public function test_it_reminds_again_once_the_cooldown_has_passed(): void + { + Notification::fake(); + + $user = User::factory()->create([ + 'email_verified_at' => now()->subDays(10), + 'created_at' => now()->subDays(10), + 'last_reminder_sent_at' => now()->subDays(8), + ]); + + $this->artisan('app:send-inactivity-reminders'); + + Notification::assertSentTo($user, InactivityReminder::class); + } + + public function test_it_does_not_remind_an_unverified_user(): void + { + Notification::fake(); + + $user = User::factory()->unverified()->create([ + 'created_at' => now()->subDays(10), + ]); + + $this->artisan('app:send-inactivity-reminders'); + + Notification::assertNotSentTo($user, InactivityReminder::class); + } + + public function test_it_does_not_remind_a_brand_new_user(): void + { + Notification::fake(); + + $user = User::factory()->create([ + 'email_verified_at' => now(), + 'created_at' => now(), + ]); + + $this->artisan('app:send-inactivity-reminders'); + + Notification::assertNotSentTo($user, InactivityReminder::class); + } +}