Add still-running email preference

This commit is contained in:
Constantin Graf
2026-08-31 11:14:39 +02:00
parent 28aba7b27a
commit 425175c43e
11 changed files with 114 additions and 2 deletions

View File

@@ -51,7 +51,8 @@ class TimeEntrySendStillRunningMailsCommand extends Command
]) ])
->whereHas('user', function (Builder $query): void { ->whereHas('user', function (Builder $query): void {
/** @var Builder<User> $query */ /** @var Builder<User> $query */
$query->where('is_placeholder', '=', false); $query->where('is_placeholder', '=', false)
->where('send_time_entry_still_running_email', '=', true);
}) })
->orderBy('created_at', 'asc') ->orderBy('created_at', 'asc')
->chunk(500, function (Collection $timeEntries) use ($dryRun, &$sentMails): void { ->chunk(500, function (Collection $timeEntries) use ($dryRun, &$sentMails): void {

View File

@@ -124,6 +124,10 @@ class UserController extends Controller
$user->week_start = $request->getWeekStart(); $user->week_start = $request->getWeekStart();
} }
if ($request->getSendTimeEntryStillRunningEmail() !== null) {
$user->send_time_entry_still_running_email = $request->getSendTimeEntryStillRunningEmail();
}
$user->save(); $user->save();
if ($emailToVerify !== null) { if ($emailToVerify !== null) {

View File

@@ -58,6 +58,9 @@ class UserUpdateRequest extends BaseFormRequest
'week_start' => [ 'week_start' => [
Rule::enum(Weekday::class), Rule::enum(Weekday::class),
], ],
'send_time_entry_still_running_email' => [
'boolean',
],
]; ];
} }
@@ -81,6 +84,13 @@ class UserUpdateRequest extends BaseFormRequest
return $this->has('week_start') ? Weekday::from($this->input('week_start')) : null; return $this->has('week_start') ? Weekday::from($this->input('week_start')) : null;
} }
public function getSendTimeEntryStillRunningEmail(): ?bool
{
return $this->has('send_time_entry_still_running_email')
? $this->boolean('send_time_entry_still_running_email')
: null;
}
public function hasPhotoKey(): bool public function hasPhotoKey(): bool
{ {
return $this->has('photo'); return $this->has('photo');

View File

@@ -36,6 +36,8 @@ class UserResource extends BaseResource
'timezone' => $this->resource->timezone, 'timezone' => $this->resource->timezone,
/** @var Weekday $week_start Starting day of the week */ /** @var Weekday $week_start Starting day of the week */
'week_start' => $this->resource->week_start->value, 'week_start' => $this->resource->week_start->value,
/** @var bool $send_time_entry_still_running_email Whether to email the user when a time entry has been running for more than 8 hours */
'send_time_entry_still_running_email' => $this->resource->send_time_entry_still_running_email,
]; ];
} }
} }

View File

@@ -42,6 +42,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property string $timezone * @property string $timezone
* @property bool $is_placeholder * @property bool $is_placeholder
* @property Weekday $week_start * @property Weekday $week_start
* @property bool $send_time_entry_still_running_email
* @property string|null $profile_photo_path * @property string|null $profile_photo_path
* @property-read Organization|null $currentOrganization * @property-read Organization|null $currentOrganization
* @property-read string $profile_photo_url * @property-read string $profile_photo_url
@@ -108,6 +109,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
'is_admin' => 'boolean', 'is_admin' => 'boolean',
'is_placeholder' => 'boolean', 'is_placeholder' => 'boolean',
'week_start' => Weekday::class, 'week_start' => Weekday::class,
'send_time_entry_still_running_email' => 'boolean',
]; ];
/** /**
@@ -117,6 +119,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
*/ */
protected $attributes = [ protected $attributes = [
'week_start' => Weekday::Monday, 'week_start' => Weekday::Monday,
'send_time_entry_still_running_email' => true,
]; ];
/** /**

View File

@@ -0,0 +1,24 @@
<?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
{
public function up(): void
{
Schema::table('users', function (Blueprint $table): void {
$table->boolean('send_time_entry_still_running_email')->default(true)->after('week_start');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table): void {
$table->dropColumn('send_time_entry_still_running_email');
});
}
};

View File

@@ -8,6 +8,7 @@ import { Button } from '@/packages/ui/src/Buttons';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue'; import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue'; import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
import TextInput from '@/packages/ui/src/Input/TextInput.vue'; import TextInput from '@/packages/ui/src/Input/TextInput.vue';
import { Checkbox } from '@/packages/ui/src';
import { import {
useResendUserEmailVerificationMutation, useResendUserEmailVerificationMutation,
useResetUserPendingEmailMutation, useResetUserPendingEmailMutation,
@@ -26,6 +27,7 @@ const name = ref('');
const email = ref(''); const email = ref('');
const timezone = ref(''); const timezone = ref('');
const weekStart = ref(''); const weekStart = ref('');
const sendTimeEntryStillRunningEmail = ref(true);
const photoBase64 = ref<string | null>(null); const photoBase64 = ref<string | null>(null);
const photoPreview = ref<string | null>(null); const photoPreview = ref<string | null>(null);
@@ -40,6 +42,7 @@ function seedForm(u: User) {
email.value = u.email; email.value = u.email;
timezone.value = u.timezone; timezone.value = u.timezone;
weekStart.value = u.week_start; weekStart.value = u.week_start;
sendTimeEntryStillRunningEmail.value = u.send_time_entry_still_running_email;
} }
watch( watch(
@@ -78,6 +81,11 @@ function buildPayload(): UpdateUserBody {
if (weekStart.value !== user.value.week_start) { if (weekStart.value !== user.value.week_start) {
body.week_start = weekStart.value as UpdateUserBody['week_start']; body.week_start = weekStart.value as UpdateUserBody['week_start'];
} }
if (
sendTimeEntryStillRunningEmail.value !== user.value.send_time_entry_still_running_email
) {
body.send_time_entry_still_running_email = sendTimeEntryStillRunningEmail.value;
}
if (photoBase64.value !== null) body.photo = photoBase64.value; if (photoBase64.value !== null) body.photo = photoBase64.value;
return body; return body;
} }
@@ -330,6 +338,21 @@ const page = usePage<{
</select> </select>
<FieldError v-if="fieldErrors.week_start">{{ fieldErrors.week_start }}</FieldError> <FieldError v-if="fieldErrors.week_start">{{ fieldErrors.week_start }}</FieldError>
</Field> </Field>
<div class="col-span-6 sm:col-span-4">
<Field orientation="horizontal">
<Checkbox
id="send_time_entry_still_running_email"
v-model:checked="sendTimeEntryStillRunningEmail"
:disabled="!isUserLoaded" />
<FieldLabel for="send_time_entry_still_running_email">
Email me when a time entry has been running for more than 8 hours
</FieldLabel>
</Field>
<FieldError v-if="fieldErrors.send_time_entry_still_running_email">
{{ fieldErrors.send_time_entry_still_running_email }}
</FieldError>
</div>
</template> </template>
<template #actions> <template #actions>

View File

@@ -705,6 +705,7 @@ const UserResource = z
profile_photo_url: z.string(), profile_photo_url: z.string(),
timezone: z.string(), timezone: z.string(),
week_start: Weekday, week_start: Weekday,
send_time_entry_still_running_email: z.boolean(),
}) })
.passthrough(); .passthrough();
const UserUpdateRequest = z const UserUpdateRequest = z
@@ -714,6 +715,7 @@ const UserUpdateRequest = z
photo: z.union([z.string(), z.null()]), photo: z.union([z.string(), z.null()]),
timezone: z.string(), timezone: z.string(),
week_start: Weekday, week_start: Weekday,
send_time_entry_still_running_email: z.boolean(),
}) })
.partial() .partial()
.passthrough(); .passthrough();

View File

@@ -76,6 +76,7 @@ export interface User {
two_factor_confirmed_at: string | null; two_factor_confirmed_at: string | null;
timezone: string; timezone: string;
week_start: string; week_start: string;
send_time_entry_still_running_email: boolean;
profile_photo_url: string; profile_photo_url: string;
organizations: Organization[]; organizations: Organization[];
clients: Client[]; clients: Client[];

View File

@@ -159,4 +159,26 @@ class TimeEntrySendStillRunningMailsCommandTest extends TestCaseWithDatabase
$this->assertSame("Sending still running time entry emails...\n". $this->assertSame("Sending still running time entry emails...\n".
"Finished sending 0 still running time entry emails...\n", $output); "Finished sending 0 still running time entry emails...\n", $output);
} }
public function test_does_not_send_emails_to_users_who_disabled_them(): void
{
// Arrange
$user = $this->createUserWithPermission();
$user->user->send_time_entry_still_running_email = false;
$user->user->save();
$timeEntry = 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::assertNothingOutgoing();
$this->assertNull($timeEntry->fresh()->still_active_email_sent_at);
$this->assertSame(Command::SUCCESS, $exitCode);
$this->assertSame("Sending still running time entry emails...\n".
"Finished sending 0 still running time entry emails...\n", Artisan::output());
}
} }

View File

@@ -45,6 +45,7 @@ class UserEndpointTest extends ApiEndpointTestAbstract
'profile_photo_url' => $data->user->profile_photo_url, 'profile_photo_url' => $data->user->profile_photo_url,
'timezone' => $data->user->timezone, 'timezone' => $data->user->timezone,
'week_start' => $data->user->week_start->value, 'week_start' => $data->user->week_start->value,
'send_time_entry_still_running_email' => true,
], ],
]); ]);
} }
@@ -131,7 +132,7 @@ class UserEndpointTest extends ApiEndpointTestAbstract
$response->assertJsonValidationErrors('organization_id'); $response->assertJsonValidationErrors('organization_id');
} }
public function test_update_changes_user_name_timezone_and_week_start(): void public function test_update_changes_user_profile_settings(): void
{ {
// Arrange // Arrange
$data = $this->createUserWithPermission(); $data = $this->createUserWithPermission();
@@ -142,6 +143,7 @@ class UserEndpointTest extends ApiEndpointTestAbstract
'name' => 'Updated Name', 'name' => 'Updated Name',
'timezone' => 'America/New_York', 'timezone' => 'America/New_York',
'week_start' => Weekday::Sunday->value, 'week_start' => Weekday::Sunday->value,
'send_time_entry_still_running_email' => false,
]); ]);
// Assert // Assert
@@ -152,6 +154,7 @@ class UserEndpointTest extends ApiEndpointTestAbstract
'name' => 'Updated Name', 'name' => 'Updated Name',
'timezone' => 'America/New_York', 'timezone' => 'America/New_York',
'week_start' => Weekday::Sunday->value, 'week_start' => Weekday::Sunday->value,
'send_time_entry_still_running_email' => false,
], ],
]); ]);
@@ -159,6 +162,7 @@ class UserEndpointTest extends ApiEndpointTestAbstract
$this->assertSame('Updated Name', $user->name); $this->assertSame('Updated Name', $user->name);
$this->assertSame('America/New_York', $user->timezone); $this->assertSame('America/New_York', $user->timezone);
$this->assertSame(Weekday::Sunday, $user->week_start); $this->assertSame(Weekday::Sunday, $user->week_start);
$this->assertFalse($user->send_time_entry_still_running_email);
} }
public function test_update_does_not_change_user_fields_that_are_not_given(): void public function test_update_does_not_change_user_fields_that_are_not_given(): void
@@ -503,6 +507,22 @@ class UserEndpointTest extends ApiEndpointTestAbstract
$response->assertJsonValidationErrors(['week_start']); $response->assertJsonValidationErrors(['week_start']);
} }
public function test_update_fails_if_send_time_entry_still_running_email_is_not_boolean(): void
{
// Arrange
$data = $this->createUserWithPermission();
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.users.update', $data->user->getKey()), [
'send_time_entry_still_running_email' => 'not-a-boolean',
]);
// Assert
$response->assertUnprocessable();
$response->assertJsonValidationErrors(['send_time_entry_still_running_email']);
}
public function test_update_fails_if_photo_is_not_a_string(): void public function test_update_fails_if_photo_is_not_a_string(): void
{ {
// Arrange // Arrange