mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-06-15 13:32:43 +01:00
Added mail to inform users about still running time entries
This commit is contained in:
committed by
Constantin Graf
parent
855db81104
commit
8db0a7d25e
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,9 @@ class Kernel extends ConsoleKernel
|
|||||||
*/
|
*/
|
||||||
protected function schedule(Schedule $schedule): void
|
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
43
app/Mail/TimeEntryStillRunningMail.php
Normal file
43
app/Mail/TimeEntryStillRunningMail.php
Normal file
@@ -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!'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ use Korridor\LaravelComputedAttributes\ComputedAttributes;
|
|||||||
* @property string $user_id
|
* @property string $user_id
|
||||||
* @property string $member_id
|
* @property string $member_id
|
||||||
* @property bool $is_imported
|
* @property bool $is_imported
|
||||||
|
* @property Carbon|null $still_active_email_sent_at
|
||||||
* @property-read User $user
|
* @property-read User $user
|
||||||
* @property-read Member $member
|
* @property-read Member $member
|
||||||
* @property string $organization_id
|
* @property string $organization_id
|
||||||
@@ -59,6 +60,7 @@ class TimeEntry extends Model
|
|||||||
'tags' => 'array',
|
'tags' => 'array',
|
||||||
'billable_rate' => 'int',
|
'billable_rate' => 'int',
|
||||||
'is_imported' => 'bool',
|
'is_imported' => 'bool',
|
||||||
|
'still_active_email_sent_at' => 'datetime',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -63,6 +63,9 @@ class AdminPanelProvider extends PanelProvider
|
|||||||
NavigationGroup::make()
|
NavigationGroup::make()
|
||||||
->label('Users')
|
->label('Users')
|
||||||
->collapsed(),
|
->collapsed(),
|
||||||
|
NavigationGroup::make()
|
||||||
|
->label('System')
|
||||||
|
->collapsed(),
|
||||||
])
|
])
|
||||||
->middleware([
|
->middleware([
|
||||||
EncryptCookies::class,
|
EncryptCookies::class,
|
||||||
|
|||||||
10
config/scheduling.php
Normal file
10
config/scheduling.php
Normal file
@@ -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),
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -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
resources/views/emails/time-entry-still-running.blade.php
Normal file
14
resources/views/emails/time-entry-still-running.blade.php
Normal file
@@ -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
|
||||||
@@ -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
tests/Unit/Mail/TimeEntryStillRunningMailTest.php
Normal file
32
tests/Unit/Mail/TimeEntryStillRunningMailTest.php
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user