Skip to content

Commit

Permalink
Added mail to inform users about still running time entries
Browse files Browse the repository at this point in the history
  • Loading branch information
korridor committed Jul 18, 2024
1 parent 855db81 commit 8db0a7d
Show file tree
Hide file tree
Showing 10 changed files with 347 additions and 1 deletion.
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php

declare(strict_types=1);

namespace App\Console\Commands\TimeEntry;

use App\Mail\TimeEntryStillRunningMail;
use App\Models\TimeEntry;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Mail;

class TimeEntrySendStillRunningMailsCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'time-entry:send-still-running-mails '.
' { --dry-run : Do not actually send emails or save anything to the database, just output what would happen }';

/**
* The console command description.
*
* @var string
*/
protected $description = 'Sends emails to users who have running time entries for more than 8 hours.';

/**
* Execute the console command.
*/
public function handle(): int
{
$this->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<int, TimeEntry> $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;
}
}
4 changes: 3 additions & 1 deletion app/Console/Kernel.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

/**
Expand Down
43 changes: 43 additions & 0 deletions app/Mail/TimeEntryStillRunningMail.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

declare(strict_types=1);

namespace App\Mail;

use App\Models\TimeEntry;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\URL;

class TimeEntryStillRunningMail extends Mailable
{
use Queueable, SerializesModels;

public TimeEntry $timeEntry;

public User $user;

/**
* Create a new message instance.
*
* @return void
*/
public function __construct(TimeEntry $timeEntry, User $user)
{
$this->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!'));
}
}
2 changes: 2 additions & 0 deletions app/Models/TimeEntry.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
* @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
Expand Down Expand Up @@ -59,6 +60,7 @@ class TimeEntry extends Model
'tags' => 'array',
'billable_rate' => 'int',
'is_imported' => 'bool',
'still_active_email_sent_at' => 'datetime',
];

/**
Expand Down
3 changes: 3 additions & 0 deletions app/Providers/Filament/AdminPanelProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ public function panel(Panel $panel): Panel
NavigationGroup::make()
->label('Users')
->collapsed(),
NavigationGroup::make()
->label('System')
->collapsed(),
])
->middleware([
EncryptCookies::class,
Expand Down
10 changes: 10 additions & 0 deletions config/scheduling.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

declare(strict_types=1);

return [

'tasks' => [
'time_entry_send_still_running_mails' => (bool) env('SCHEDULING_TASK_TIME_ENTRY_SEND_STILL_RUNNING_MAILS', true),
],
];
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('time_entries', function (Blueprint $table) {
$table->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');
});
}
};
14 changes: 14 additions & 0 deletions resources/views/emails/time-entry-still-running.blade.php
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
<?php

declare(strict_types=1);

namespace Tests\Unit\Console\Commands\TimeEntry;

use App\Console\Commands\TimeEntry\TimeEntrySendStillRunningMailsCommand;
use App\Mail\TimeEntryStillRunningMail;
use App\Models\TimeEntry;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Mail;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\UsesClass;
use Tests\TestCaseWithDatabase;

#[CoversClass(TimeEntrySendStillRunningMailsCommand::class)]
#[UsesClass(TimeEntrySendStillRunningMailsCommand::class)]
class TimeEntrySendStillRunningMailsCommandTest extends TestCaseWithDatabase
{
public function test_sends_mails_for_still_running_time_entries(): 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');

// 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);
}
}
32 changes: 32 additions & 0 deletions tests/Unit/Mail/TimeEntryStillRunningMailTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

declare(strict_types=1);

namespace Tests\Unit\Mail;

use App\Mail\TimeEntryStillRunningMail;
use App\Models\TimeEntry;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\UsesClass;
use Tests\TestCaseWithDatabase;

#[CoversClass(TimeEntryStillRunningMail::class)]
#[UsesClass(TimeEntryStillRunningMail::class)]
class TimeEntryStillRunningMailTest extends TestCaseWithDatabase
{
public function test_mail_renders_content_correctly(): void
{
// Arrange
$user = $this->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);
}
}

0 comments on commit 8db0a7d

Please sign in to comment.