add prevent_overlapping_time_entries setting to organization

when enabled users are blocked from creating or editing new time entries that are overlapping with other time entries
This commit is contained in:
Gregor Vostrak
2025-10-03 14:58:21 +02:00
parent c0788c270b
commit 19a206d57c
14 changed files with 450 additions and 17 deletions

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Api;
class OverlappingTimeEntryApiException extends ApiException
{
public const string KEY = 'overlapping_time_entry';
}

View File

@@ -61,6 +61,9 @@ class OrganizationController extends Controller
if ($request->getTimeFormat() !== null) {
$organization->time_format = $request->getTimeFormat();
}
if ($request->getPreventOverlappingTimeEntries() !== null) {
$organization->prevent_overlapping_time_entries = $request->getPreventOverlappingTimeEntries();
}
$hasBillableRate = $request->has('billable_rate');
if ($hasBillableRate) {
$oldBillableRate = $organization->billable_rate;

View File

@@ -7,6 +7,7 @@ namespace App\Http\Controllers\Api\V1;
use App\Enums\ExportFormat;
use App\Enums\Role;
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
use App\Exceptions\Api\OverlappingTimeEntryApiException;
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
use App\Exceptions\Api\TimeEntryStillRunningApiException;
@@ -45,6 +46,7 @@ use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\File;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Blade;
@@ -56,6 +58,43 @@ use Spatie\TemporaryDirectory\TemporaryDirectory;
class TimeEntryController extends Controller
{
private function assertNoOverlap(Organization $organization, Member $member, \Illuminate\Support\Carbon $start, ?\Illuminate\Support\Carbon $end, ?TimeEntry $exclude = null): void
{
if (! $organization->prevent_overlapping_time_entries) {
return;
}
$query = TimeEntry::query()
->where('organization_id', $organization->getKey())
->where('user_id', $member->user_id)
->when($exclude !== null, function (Builder $q) use ($exclude): void {
$q->where('id', '!=', $exclude->getKey());
})
->where(function (Builder $q) use ($start, $end): void {
$q->where(function (Builder $q2) use ($start): void {
$q2->where('end', '>', $start)
->where('start', '<', $start);
});
if ($end !== null) {
$q->orWhere(function (Builder $q4) use ($end): void {
$q4->where('start', '<', $end)
->where('end', '>', $end);
});
// Check if the new entry completely surrounds an existing entry
$q->orWhere(function (Builder $q6) use ($start, $end): void {
$q6->where('start', '>=', $start)
->where('end', '<=', $end);
});
}
});
if ($query->exists()) {
throw new OverlappingTimeEntryApiException;
}
}
protected function checkPermission(Organization $organization, string $permission, ?TimeEntry $timeEntry = null): void
{
parent::checkPermission($organization, $permission);
@@ -549,17 +588,15 @@ class TimeEntryController extends Controller
throw new TimeEntryStillRunningApiException;
}
// Overlap check for create
$start = Carbon::parse($request->input('start'));
$end = $request->input('end') !== null ? Carbon::parse($request->input('end')) : null;
$this->assertNoOverlap($organization, $member, $start, $end);
$project = $request->input('project_id') !== null ? Project::findOrFail((string) $request->input('project_id')) : null;
$client = $project?->client;
$task = $request->input('task_id') !== null ? $project->tasks()->findOrFail((string) $request->input('task_id')) : null;
if ($project !== null) {
RecalculateSpentTimeForProject::dispatch($project);
}
if ($task !== null) {
RecalculateSpentTimeForTask::dispatch($task);
}
$timeEntry = new TimeEntry;
$timeEntry->fill($request->validated());
$timeEntry->client()->associate($client);
@@ -569,6 +606,13 @@ class TimeEntryController extends Controller
$timeEntry->setComputedAttributeValue('billable_rate');
$timeEntry->save();
if ($project !== null) {
RecalculateSpentTimeForProject::dispatch($project);
}
if ($task !== null) {
RecalculateSpentTimeForTask::dispatch($task);
}
return new TimeEntryResource($timeEntry);
}
@@ -593,6 +637,13 @@ class TimeEntryController extends Controller
throw new TimeEntryCanNotBeRestartedApiException;
}
// Overlap check for update (exclude current)
/** @var Member $effectiveMember */
$effectiveMember = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : $timeEntry->member;
$effectiveStart = $request->has('start') ? Carbon::parse($request->input('start')) : $timeEntry->start;
$effectiveEnd = $request->has('end') ? ($request->input('end') !== null ? Carbon::parse($request->input('end')) : null) : $timeEntry->end;
$this->assertNoOverlap($organization, $effectiveMember, $effectiveStart, $effectiveEnd, $timeEntry);
$oldProject = $timeEntry->project;
$oldTask = $timeEntry->task;

View File

@@ -39,6 +39,9 @@ class OrganizationUpdateRequest extends BaseFormRequest
'employees_can_see_billable_rates' => [
'boolean',
],
'prevent_overlapping_time_entries' => [
'boolean',
],
'number_format' => [
Rule::enum(NumberFormat::class),
],
@@ -98,4 +101,9 @@ class OrganizationUpdateRequest extends BaseFormRequest
{
return $this->has('employees_can_see_billable_rates') ? $this->boolean('employees_can_see_billable_rates') : null;
}
public function getPreventOverlappingTimeEntries(): ?bool
{
return $this->has('prevent_overlapping_time_entries') ? $this->boolean('prevent_overlapping_time_entries') : null;
}
}

View File

@@ -53,6 +53,8 @@ class OrganizationResource extends BaseResource
'billable_rate' => $this->showBillableRate ? $this->resource->billable_rate : null,
/** @var bool $employees_can_see_billable_rates Can members of the organization with role "employee" see the billable rates */
'employees_can_see_billable_rates' => $this->resource->employees_can_see_billable_rates,
/** @var bool $prevent_overlapping_time_entries Prevent creating overlapping time entries (only new entries) */
'prevent_overlapping_time_entries' => $this->resource->prevent_overlapping_time_entries,
/** @var string $currency Currency code (ISO 4217) */
'currency' => $this->resource->currency,
/** @var string $currency_symbol Currency symbol */

View File

@@ -70,6 +70,7 @@ class Organization extends JetstreamTeam implements AuditableContract
'personal_team' => 'boolean',
'currency' => 'string',
'employees_can_see_billable_rates' => 'boolean',
'prevent_overlapping_time_entries' => 'boolean',
'number_format' => NumberFormat::class,
'currency_format' => CurrencyFormat::class,
'date_format' => DateFormat::class,

View File

@@ -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('organizations', function (Blueprint $table): void {
$table->boolean('prevent_overlapping_time_entries')->default(false)->after('employees_can_see_billable_rates');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('organizations', function (Blueprint $table): void {
$table->dropColumn('prevent_overlapping_time_entries');
});
}
};

View File

@@ -14,6 +14,7 @@ use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
use App\Exceptions\Api\OnlyPlaceholdersCanBeMergedIntoAnotherMember;
use App\Exceptions\Api\OrganizationHasNoSubscriptionButMultipleMembersException;
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
use App\Exceptions\Api\OverlappingTimeEntryApiException;
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
use App\Exceptions\Api\PersonalAccessClientIsNotConfiguredException;
use App\Exceptions\Api\ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
@@ -47,6 +48,7 @@ return [
OnlyPlaceholdersCanBeMergedIntoAnotherMember::KEY => 'Only placeholders can be merged into another member',
ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException::KEY => 'This placeholder can not be invited use the merge tool instead',
InvitationForTheEmailAlreadyExistsApiException::KEY => 'The email has already been invited to the organization. Please wait for the user to accept the invitation or resend the invitation email.',
OverlappingTimeEntryApiException::KEY => 'Overlapping time entries are not allowed.',
],
'unknown_error_in_admin_panel' => 'An unknown error occurred. Please check the logs.',
];

View File

@@ -203,6 +203,7 @@ return [
'organization' => 'The :attribute does not exist.',
'task_belongs_to_project' => 'The :attribute is not part of the given project.',
'project_name_already_exists' => 'A project with the same name and client already exists in the organization.',
'overlapping_time_entry' => 'Overlapping time entries are not allowed.',
'tag_name_already_exists' => 'A tag with the same name already exists in the organization.',
'client_name_already_exists' => 'A client with the same name already exists in the organization.',
'task_name_already_exists' => 'A task with the same name already exists in the project.',

View File

@@ -27,7 +27,7 @@ interface FormValues {
}
const store = useOrganizationStore();
const { fetchOrganization, updateOrganization } = store;
const { updateOrganization } = store;
const { organization } = storeToRefs(store);
const queryClient = useQueryClient();
@@ -47,7 +47,6 @@ const mutation = useMutation({
});
onMounted(async () => {
await fetchOrganization();
if (organization.value) {
form.value = {
number_format: organization.value.number_format as NumberFormat,

View File

@@ -0,0 +1,68 @@
<script setup lang="ts">
import FormSection from '@/Components/FormSection.vue';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import { onMounted, ref } from 'vue';
import InputLabel from '@/packages/ui/src/Input/InputLabel.vue';
import { Checkbox } from '@/packages/ui/src';
import type { UpdateOrganizationBody } from '@/packages/api/src';
import { useOrganizationStore } from '@/utils/useOrganization';
import { storeToRefs } from 'pinia';
import { useMutation, useQueryClient } from '@tanstack/vue-query';
const store = useOrganizationStore();
const { updateOrganization } = store;
const { organization } = storeToRefs(store);
const queryClient = useQueryClient();
const form = ref<{ prevent_overlapping_time_entries: boolean }>({
prevent_overlapping_time_entries: false,
});
onMounted(async () => {
form.value.prevent_overlapping_time_entries =
organization.value?.prevent_overlapping_time_entries ?? false;
});
const mutation = useMutation({
mutationFn: (values: Partial<UpdateOrganizationBody>) => updateOrganization(values),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['organization'] });
},
});
async function submit() {
await mutation.mutateAsync({
prevent_overlapping_time_entries: form.value.prevent_overlapping_time_entries,
});
}
</script>
<template>
<FormSection>
<template #title>Time Entry Settings</template>
<template #description>
Disallow overlapping time entries for members of this organization. When enabled, users
cannot create new time entries that overlap with their existing ones. This only affects
newly created entries.
</template>
<template #form>
<div class="col-span-6">
<div class="col-span-6 sm:col-span-4">
<div class="flex items-center space-x-2">
<Checkbox
id="preventOverlappingTimeEntries"
v-model:checked="form.prevent_overlapping_time_entries" />
<InputLabel
for="preventOverlappingTimeEntries"
value="Prevent overlapping time entries (new entries only)" />
</div>
</div>
</div>
</template>
<template #actions>
<PrimaryButton :disabled="mutation.isPending.value" @click="submit">Save</PrimaryButton>
</template>
</FormSection>
</template>

View File

@@ -8,12 +8,25 @@ import type { Permissions, Role } from '@/types/jetstream';
import { canUpdateOrganization } from '@/utils/permissions';
import OrganizationBillableRate from '@/Pages/Teams/Partials/OrganizationBillableRate.vue';
import OrganizationFormatSettings from '@/Pages/Teams/Partials/OrganizationFormatSettings.vue';
import OrganizationTimeEntrySettings from '@/Pages/Teams/Partials/OrganizationTimeEntrySettings.vue';
import { onMounted, ref } from 'vue';
import { useOrganizationStore } from '@/utils/useOrganization';
import { storeToRefs } from 'pinia';
defineProps<{
team: Organization;
availableRoles: Role[];
permissions: Permissions;
}>();
const loading = ref(true);
const orgStore = useOrganizationStore();
const { organization } = storeToRefs(orgStore);
onMounted(async () => {
await orgStore.fetchOrganization();
loading.value = false;
});
</script>
<template>
@@ -26,17 +39,25 @@ defineProps<{
<div>
<div class="max-w-7xl mx-auto py-10 sm:px-6 lg:px-8">
<UpdateTeamNameForm :team="team" :permissions="permissions" />
<div v-if="loading || !organization" class="py-16 text-center text-text-secondary">
Loading organization settings...
</div>
<template v-else>
<UpdateTeamNameForm :team="team" :permissions="permissions" />
<SectionBorder />
<OrganizationBillableRate v-if="canUpdateOrganization()" :team="team" />
<SectionBorder />
<SectionBorder />
<OrganizationBillableRate v-if="canUpdateOrganization()" :team="team" />
<SectionBorder />
<OrganizationFormatSettings v-if="canUpdateOrganization()" :team="team" />
<SectionBorder />
<OrganizationFormatSettings v-if="canUpdateOrganization()" :team="team" />
<SectionBorder />
<template v-if="permissions.canDeleteTeam && !team.personal_team">
<DeleteTeamForm class="mt-10 sm:mt-0" :team="team" />
<OrganizationTimeEntrySettings v-if="canUpdateOrganization()" />
<SectionBorder />
<template v-if="permissions.canDeleteTeam && !team.personal_team">
<DeleteTeamForm class="mt-10 sm:mt-0" :team="team" />
</template>
</template>
</div>
</div>

View File

@@ -317,6 +317,7 @@ const OrganizationResource = z
is_personal: z.boolean(),
billable_rate: z.union([z.number(), z.null()]),
employees_can_see_billable_rates: z.boolean(),
prevent_overlapping_time_entries: z.boolean(),
currency: z.string(),
currency_symbol: z.string(),
number_format: NumberFormat,
@@ -331,6 +332,7 @@ const OrganizationUpdateRequest = z
name: z.string().max(255),
billable_rate: z.union([z.number(), z.null()]),
employees_can_see_billable_rates: z.boolean(),
prevent_overlapping_time_entries: z.boolean(),
number_format: NumberFormat,
currency_format: CurrencyFormat,
date_format: DateFormat,

View File

@@ -3393,6 +3393,241 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
]);
}
public function test_store_endpoint_blocks_overlapping_entries_when_start_overlaps(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:create:own',
]);
$data->organization->prevent_overlapping_time_entries = true;
$data->organization->save();
$baseStart = Carbon::create(2025, 1, 1, 12, 0, 0, 'UTC');
$baseEnd = Carbon::create(2025, 1, 1, 13, 0, 0, 'UTC');
TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)
->create([
'start' => $baseStart,
'end' => $baseEnd,
]);
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
'member_id' => $data->member->getKey(),
'billable' => true,
'start' => $baseStart->copy()->addMinutes(30)->toIso8601ZuluString(),
'end' => $baseEnd->copy()->addMinutes(30)->toIso8601ZuluString(),
]);
// Assert
$response->assertStatus(400);
$response->assertExactJson([
'error' => true,
'key' => 'overlapping_time_entry',
'message' => 'Overlapping time entries are not allowed.',
]);
}
public function test_store_endpoint_blocks_overlapping_entries_when_end_overlaps(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:create:own',
]);
$data->organization->prevent_overlapping_time_entries = true;
$data->organization->save();
$baseStart = Carbon::create(2025, 1, 1, 12, 0, 0, 'UTC');
$baseEnd = Carbon::create(2025, 1, 1, 13, 0, 0, 'UTC');
TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)
->create([
'start' => $baseStart,
'end' => $baseEnd,
]);
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
'member_id' => $data->member->getKey(),
'billable' => true,
'start' => $baseStart->copy()->subMinutes(30)->toIso8601ZuluString(),
'end' => $baseStart->copy()->addMinutes(30)->toIso8601ZuluString(),
]);
// Assert
$response->assertStatus(400);
$response->assertExactJson([
'error' => true,
'key' => 'overlapping_time_entry',
'message' => 'Overlapping time entries are not allowed.',
]);
}
public function test_store_endpoint_blocks_overlapping_entries_when_new_entry_is_within_existing(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:create:own',
]);
$data->organization->prevent_overlapping_time_entries = true;
$data->organization->save();
$baseStart = Carbon::create(2025, 1, 1, 12, 0, 0, 'UTC');
$baseEnd = Carbon::create(2025, 1, 1, 13, 0, 0, 'UTC');
TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)
->create([
'start' => $baseStart,
'end' => $baseEnd,
]);
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
'member_id' => $data->member->getKey(),
'billable' => true,
'start' => $baseStart->copy()->addMinutes(15)->toIso8601ZuluString(),
'end' => $baseStart->copy()->addMinutes(45)->toIso8601ZuluString(),
]);
// Assert
$response->assertStatus(400);
$response->assertExactJson([
'error' => true,
'key' => 'overlapping_time_entry',
'message' => 'Overlapping time entries are not allowed.',
]);
}
public function test_store_endpoint_blocks_overlapping_entries_when_new_entry_surrounds_existing(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:create:own',
]);
$data->organization->prevent_overlapping_time_entries = true;
$data->organization->save();
$baseStart = Carbon::create(2025, 1, 1, 12, 0, 0, 'UTC');
$baseEnd = Carbon::create(2025, 1, 1, 13, 0, 0, 'UTC');
TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)
->create([
'start' => $baseStart,
'end' => $baseEnd,
]);
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
'member_id' => $data->member->getKey(),
'billable' => true,
'start' => $baseStart->copy()->subMinutes(30)->toIso8601ZuluString(),
'end' => $baseEnd->copy()->addMinutes(30)->toIso8601ZuluString(),
]);
// Assert
$response->assertStatus(400);
$response->assertExactJson([
'error' => true,
'key' => 'overlapping_time_entry',
'message' => 'Overlapping time entries are not allowed.',
]);
}
public function test_store_endpoint_blocks_starting_active_entry_when_it_overlaps_with_existing(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:create:own',
]);
$data->organization->prevent_overlapping_time_entries = true;
$data->organization->save();
$baseStart = Carbon::create(2025, 1, 1, 12, 0, 0, 'UTC');
$baseEnd = Carbon::create(2025, 1, 1, 13, 0, 0, 'UTC');
TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)
->create([
'start' => $baseStart,
'end' => $baseEnd,
]);
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
'member_id' => $data->member->getKey(),
'billable' => true,
'start' => $baseStart->copy()->addMinutes(30)->toIso8601ZuluString(),
'end' => null,
]);
// Assert
$response->assertStatus(400);
$response->assertExactJson([
'error' => true,
'key' => 'overlapping_time_entry',
'message' => 'Overlapping time entries are not allowed.',
]);
}
public function test_store_endpoint_allows_future_time_entries_even_with_running_now(): void
{
// Arrange
$now = Carbon::create(2025, 1, 1, 12, 0, 0, 'UTC');
$this->travelTo($now);
$data = $this->createUserWithPermission([
'time-entries:create:own',
]);
$data->organization->prevent_overlapping_time_entries = true;
$data->organization->save();
TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)
->create([
'start' => $now->copy()->subHour(),
'end' => null,
]);
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [
'member_id' => $data->member->getKey(),
'billable' => true,
'start' => $now->copy()->addDay()->toIso8601ZuluString(),
'end' => $now->copy()->addDay()->addHour()->toIso8601ZuluString(),
]);
// Assert
$response->assertStatus(201);
}
public function test_update_endpoint_blocks_overlap_and_excludes_current_entry(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:update:own',
]);
$data->organization->prevent_overlapping_time_entries = true;
$data->organization->save();
$baseStart = Carbon::create(2025, 1, 1, 14, 0, 0, 'UTC');
$baseEnd = Carbon::create(2025, 1, 1, 15, 0, 0, 'UTC');
$base = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)
->create([
'start' => $baseStart,
'end' => $baseEnd,
]);
$toUpdate = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)
->create([
'start' => $baseEnd->copy()->addMinutes(30),
'end' => $baseEnd->copy()->addHour(),
]);
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $toUpdate->getKey()]), [
'start' => $baseStart->copy()->addMinutes(30)->toIso8601ZuluString(),
]);
// Assert
$response->assertStatus(400);
$response->assertExactJson([
'error' => true,
'key' => 'overlapping_time_entry',
'message' => 'Overlapping time entries are not allowed.',
]);
}
public function test_update_multiple_refreshes_billable_rate_on_updates_time_entries(): void
{
// Arrange