From 8db0a7d25eaa516ae2c080b88805614b87259e6b Mon Sep 17 00:00:00 2001 From: Constantin Graf Date: Thu, 18 Jul 2024 12:49:28 +0200 Subject: [PATCH] Added mail to inform users about still running time entries --- .../TimeEntrySendStillRunningMailsCommand.php | 70 +++++++++ app/Console/Kernel.php | 4 +- app/Mail/TimeEntryStillRunningMail.php | 43 ++++++ app/Models/TimeEntry.php | 2 + app/Providers/Filament/AdminPanelProvider.php | 3 + config/scheduling.php | 10 ++ ...ve_email_sent_at_to_time_entries_table.php | 30 ++++ .../emails/time-entry-still-running.blade.php | 14 ++ ...eEntrySendStillRunningMailsCommandTest.php | 140 ++++++++++++++++++ .../Mail/TimeEntryStillRunningMailTest.php | 32 ++++ 10 files changed, 347 insertions(+), 1 deletion(-) create mode 100644 app/Console/Commands/TimeEntry/TimeEntrySendStillRunningMailsCommand.php create mode 100644 app/Mail/TimeEntryStillRunningMail.php create mode 100644 config/scheduling.php create mode 100644 database/migrations/2024_07_18_080906_add_still_active_email_sent_at_to_time_entries_table.php create mode 100644 resources/views/emails/time-entry-still-running.blade.php create mode 100644 tests/Unit/Console/Commands/TimeEntry/TimeEntrySendStillRunningMailsCommandTest.php create mode 100644 tests/Unit/Mail/TimeEntryStillRunningMailTest.php diff --git a/app/Console/Commands/TimeEntry/TimeEntrySendStillRunningMailsCommand.php b/app/Console/Commands/TimeEntry/TimeEntrySendStillRunningMailsCommand.php new file mode 100644 index 00000000..b5a91eab --- /dev/null +++ b/app/Console/Commands/TimeEntry/TimeEntrySendStillRunningMailsCommand.php @@ -0,0 +1,70 @@ +comment('Sending still running time entry emails...'); + $dryRun = (bool) $this->option('dry-run'); + if ($dryRun) { + $this->comment('Running in dry-run mode. No emails will be sent and nothing will be saved to the database.'); + } + + $sentMails = 0; + TimeEntry::query() + ->whereNull('end') + ->where('start', '<', now()->subHours(8)) + ->whereNull('still_active_email_sent_at') + ->with([ + 'user', + ]) + ->orderBy('created_at', 'asc') + ->chunk(500, function (Collection $timeEntries) use ($dryRun, &$sentMails) { + /** @var Collection $timeEntries */ + foreach ($timeEntries as $timeEntry) { + $user = $timeEntry->user; + $this->info('Start sending email to user "'.$user->email.'" ('.$user->getKey().') for time entry '.$timeEntry->getKey()); + $sentMails++; + if (! $dryRun) { + Mail::to($user->email) + ->queue(new TimeEntryStillRunningMail($timeEntry, $user)); + $timeEntry->still_active_email_sent_at = Carbon::now(); + $timeEntry->save(); + } + } + }); + + $this->comment('Finished sending '.$sentMails.' still running time entry emails...'); + + return self::SUCCESS; + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 3b12bc9c..6e7596ec 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -14,7 +14,9 @@ class Kernel extends ConsoleKernel */ protected function schedule(Schedule $schedule): void { - // $schedule->command('inspire')->hourly(); + $schedule->command('time-entry:send-still-running-mails') + ->when(fn (): bool => config('scheduling.tasks.time_entry_send_still_running_mails')) + ->everyTenMinutes(); } /** diff --git a/app/Mail/TimeEntryStillRunningMail.php b/app/Mail/TimeEntryStillRunningMail.php new file mode 100644 index 00000000..4a6f83dd --- /dev/null +++ b/app/Mail/TimeEntryStillRunningMail.php @@ -0,0 +1,43 @@ +timeEntry = $timeEntry; + $this->user = $user; + } + + /** + * Build the message. + */ + public function build(): self + { + return $this->markdown('emails.time-entry-still-running', [ + 'dashboardUrl' => URL::route('dashboard'), + ]) + ->subject(__('Your Time Tracker is still running!')); + } +} diff --git a/app/Models/TimeEntry.php b/app/Models/TimeEntry.php index d730d080..bd0e6171 100644 --- a/app/Models/TimeEntry.php +++ b/app/Models/TimeEntry.php @@ -26,6 +26,7 @@ use Korridor\LaravelComputedAttributes\ComputedAttributes; * @property string $user_id * @property string $member_id * @property bool $is_imported + * @property Carbon|null $still_active_email_sent_at * @property-read User $user * @property-read Member $member * @property string $organization_id @@ -59,6 +60,7 @@ class TimeEntry extends Model 'tags' => 'array', 'billable_rate' => 'int', 'is_imported' => 'bool', + 'still_active_email_sent_at' => 'datetime', ]; /** diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index c50aa1fd..18ae723f 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -63,6 +63,9 @@ class AdminPanelProvider extends PanelProvider NavigationGroup::make() ->label('Users') ->collapsed(), + NavigationGroup::make() + ->label('System') + ->collapsed(), ]) ->middleware([ EncryptCookies::class, diff --git a/config/scheduling.php b/config/scheduling.php new file mode 100644 index 00000000..a577eb4e --- /dev/null +++ b/config/scheduling.php @@ -0,0 +1,10 @@ + [ + 'time_entry_send_still_running_mails' => (bool) env('SCHEDULING_TASK_TIME_ENTRY_SEND_STILL_RUNNING_MAILS', true), + ], +]; diff --git a/database/migrations/2024_07_18_080906_add_still_active_email_sent_at_to_time_entries_table.php b/database/migrations/2024_07_18_080906_add_still_active_email_sent_at_to_time_entries_table.php new file mode 100644 index 00000000..a2eafdb8 --- /dev/null +++ b/database/migrations/2024_07_18_080906_add_still_active_email_sent_at_to_time_entries_table.php @@ -0,0 +1,30 @@ +dateTime('still_active_email_sent_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('time_entries', function (Blueprint $table) { + $table->dropColumn('still_active_email_sent_at'); + }); + } +}; diff --git a/resources/views/emails/time-entry-still-running.blade.php b/resources/views/emails/time-entry-still-running.blade.php new file mode 100644 index 00000000..0c9f84d3 --- /dev/null +++ b/resources/views/emails/time-entry-still-running.blade.php @@ -0,0 +1,14 @@ +@component('mail::message') +@if(empty($timeEntry->description)) +{{ __('Your currently running time entry is now running for more than 8 hours!') }} +@else +{{ __('Your currently running time entry ":description" is now running for more than 8 hours!', ['description' => $timeEntry->description]) }} +@endif + +{{ __('If you forgot to stop the Time Tracker you do that in solidtime:') }} + +@component('mail::button', ['url' => $dashboardUrl]) +{{ __('Go to solidtime') }} +@endcomponent + +@endcomponent diff --git a/tests/Unit/Console/Commands/TimeEntry/TimeEntrySendStillRunningMailsCommandTest.php b/tests/Unit/Console/Commands/TimeEntry/TimeEntrySendStillRunningMailsCommandTest.php new file mode 100644 index 00000000..21d6f33c --- /dev/null +++ b/tests/Unit/Console/Commands/TimeEntry/TimeEntrySendStillRunningMailsCommandTest.php @@ -0,0 +1,140 @@ +createUserWithPermission(); + $timeEntryRunningLongerThanThreshold = TimeEntry::factory()->forMember($user->member)->create([ + 'start' => Carbon::now()->subHours(8)->subSecond(), + 'end' => null, + ]); + + // Act + $exitCode = $this->withoutMockingConsoleOutput()->artisan('time-entry:send-still-running-mails'); + + // Assert + Mail::assertQueued(TimeEntryStillRunningMail::class, function ($mail) use ($user, $timeEntryRunningLongerThanThreshold) { + return $mail->hasTo($user->user->email) && + $mail->timeEntry->is($timeEntryRunningLongerThanThreshold) && + $mail->user->is($user->user); + }); + $timeEntryRunningLongerThanThreshold->refresh(); + $this->assertNotNull($timeEntryRunningLongerThanThreshold->still_active_email_sent_at); + $this->assertSame(Command::SUCCESS, $exitCode); + $output = Artisan::output(); + $this->assertSame("Sending still running time entry emails...\n". + 'Start sending email to user "'.$user->user->email.'" ('.$user->user->getKey().') for time entry '.$timeEntryRunningLongerThanThreshold->getKey()."\n". + "Finished sending 1 still running time entry emails...\n", $output); + + } + + public function test_does_not_send_emails_for_not_running_time_entries(): void + { + // Arrange + $user = $this->createUserWithPermission(); + $timeEntry = TimeEntry::factory()->forMember($user->member)->create([ + 'start' => Carbon::now()->subHours(8)->subSecond(), + 'end' => Carbon::now(), + ]); + + // Act + $exitCode = $this->withoutMockingConsoleOutput()->artisan('time-entry:send-still-running-mails'); + + // Assert + Mail::assertNothingOutgoing(); + $timeEntry->refresh(); + $this->assertNull($timeEntry->still_active_email_sent_at); + $this->assertSame(Command::SUCCESS, $exitCode); + $output = Artisan::output(); + $this->assertSame("Sending still running time entry emails...\n". + "Finished sending 0 still running time entry emails...\n", $output); + } + + public function test_does_not_send_emails_for_running_time_entries_that_are_short_than_the_threshold(): void + { + // Arrange + $user = $this->createUserWithPermission(); + $timeEntry = TimeEntry::factory()->forMember($user->member)->create([ + 'start' => Carbon::now()->subHours(8)->addMinute(), + 'end' => null, + ]); + + // Act + $exitCode = $this->withoutMockingConsoleOutput()->artisan('time-entry:send-still-running-mails'); + + // Assert + Mail::assertNothingOutgoing(); + $timeEntry->refresh(); + $this->assertNull($timeEntry->still_active_email_sent_at); + $this->assertSame(Command::SUCCESS, $exitCode); + $output = Artisan::output(); + $this->assertSame("Sending still running time entry emails...\n". + "Finished sending 0 still running time entry emails...\n", $output); + } + + public function test_does_not_send_emails_for_running_time_entries_that_are_longer_than_the_threshold_but_already_received_the_email(): void + { + // Arrange + $user = $this->createUserWithPermission(); + $timeEntry = TimeEntry::factory()->forMember($user->member)->create([ + 'start' => Carbon::now()->subHours(8)->subMinute(), + 'end' => null, + 'still_active_email_sent_at' => Carbon::now()->subMinute(), + ]); + + // Act + $exitCode = $this->withoutMockingConsoleOutput()->artisan('time-entry:send-still-running-mails'); + + // Assert + Mail::assertNothingOutgoing(); + $timeEntry->refresh(); + $this->assertNotNull($timeEntry->still_active_email_sent_at); + $this->assertSame(Command::SUCCESS, $exitCode); + $output = Artisan::output(); + $this->assertSame("Sending still running time entry emails...\n". + "Finished sending 0 still running time entry emails...\n", $output); + } + + public function test_dry_run_option_does_not_send_mails_but_outputs_what_would_happen(): void + { + // Arrange + $user = $this->createUserWithPermission(); + $timeEntryRunningLongerThanThreshold = TimeEntry::factory()->forMember($user->member)->create([ + 'start' => Carbon::now()->subHours(8)->subSecond(), + 'end' => null, + ]); + + // Act + $exitCode = $this->withoutMockingConsoleOutput()->artisan('time-entry:send-still-running-mails --dry-run'); + + // Assert + Mail::assertNothingOutgoing(); + $timeEntryRunningLongerThanThreshold->refresh(); + $this->assertNull($timeEntryRunningLongerThanThreshold->still_active_email_sent_at); + $this->assertSame(Command::SUCCESS, $exitCode); + $output = Artisan::output(); + $this->assertSame("Sending still running time entry emails...\n". + "Running in dry-run mode. No emails will be sent and nothing will be saved to the database.\n". + 'Start sending email to user "'.$user->user->email.'" ('.$user->user->getKey().') for time entry '.$timeEntryRunningLongerThanThreshold->getKey()."\n". + "Finished sending 1 still running time entry emails...\n", $output); + } +} diff --git a/tests/Unit/Mail/TimeEntryStillRunningMailTest.php b/tests/Unit/Mail/TimeEntryStillRunningMailTest.php new file mode 100644 index 00000000..86f6c7e7 --- /dev/null +++ b/tests/Unit/Mail/TimeEntryStillRunningMailTest.php @@ -0,0 +1,32 @@ +createUserWithPermission(); + $timeEntry = TimeEntry::factory()->create([ + 'description' => 'TEST 123', + ]); + $mail = new TimeEntryStillRunningMail($timeEntry, $user->user); + + // Act + $rendered = $mail->render(); + + // Assert + $this->assertStringContainsString('Your currently running time entry "TEST 123"', $rendered); + } +}