mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-10-15 17:23:16 +01:00
Reject changing the member of running time entries
Moving a running time entry to another member could give that member's user a second running time entry. Stopping the time entry and changing the member in the same request is still allowed.
This commit is contained in:
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Exceptions\Api;
|
||||||
|
|
||||||
|
class TimeEntryMemberCanNotBeChangedWhileRunningApiException extends ApiException
|
||||||
|
{
|
||||||
|
public const string KEY = 'time_entry_member_can_not_be_changed_while_running';
|
||||||
|
|
||||||
|
public function report(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
|
|||||||
use App\Exceptions\Api\OverlappingTimeEntryApiException;
|
use App\Exceptions\Api\OverlappingTimeEntryApiException;
|
||||||
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
|
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
|
||||||
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
|
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
|
||||||
|
use App\Exceptions\Api\TimeEntryMemberCanNotBeChangedWhileRunningApiException;
|
||||||
use App\Exceptions\Api\TimeEntryStillRunningApiException;
|
use App\Exceptions\Api\TimeEntryStillRunningApiException;
|
||||||
use App\Http\Requests\V1\TimeEntry\TimeEntryAggregateExportRequest;
|
use App\Http\Requests\V1\TimeEntry\TimeEntryAggregateExportRequest;
|
||||||
use App\Http\Requests\V1\TimeEntry\TimeEntryAggregateRequest;
|
use App\Http\Requests\V1\TimeEntry\TimeEntryAggregateRequest;
|
||||||
@@ -668,7 +669,7 @@ class TimeEntryController extends Controller
|
|||||||
*
|
*
|
||||||
* To stop a running timer, set `end` to the stop time (UTC). Times the user gives in their own timezone must be converted to UTC first.
|
* To stop a running timer, set `end` to the stop time (UTC). Times the user gives in their own timezone must be converted to UTC first.
|
||||||
*
|
*
|
||||||
* @throws AuthorizationException|TimeEntryCanNotBeRestartedApiException
|
* @throws AuthorizationException|TimeEntryCanNotBeRestartedApiException|TimeEntryMemberCanNotBeChangedWhileRunningApiException
|
||||||
*
|
*
|
||||||
* @operationId updateTimeEntry
|
* @operationId updateTimeEntry
|
||||||
*/
|
*/
|
||||||
@@ -694,6 +695,11 @@ class TimeEntryController extends Controller
|
|||||||
$effectiveEnd = $request->has('end') ? ($request->input('end') !== null ? Carbon::parse($request->input('end')) : null) : $timeEntry->end;
|
$effectiveEnd = $request->has('end') ? ($request->input('end') !== null ? Carbon::parse($request->input('end')) : null) : $timeEntry->end;
|
||||||
$this->assertNoOverlap($organization, $effectiveMember, $effectiveStart, $effectiveEnd, $timeEntry);
|
$this->assertNoOverlap($organization, $effectiveMember, $effectiveStart, $effectiveEnd, $timeEntry);
|
||||||
|
|
||||||
|
// The new member could already have a running time entry
|
||||||
|
if ($effectiveEnd === null && $newMember !== null && $newMember->getKey() !== $timeEntry->member_id) {
|
||||||
|
throw new TimeEntryMemberCanNotBeChangedWhileRunningApiException;
|
||||||
|
}
|
||||||
|
|
||||||
$oldProject = $timeEntry->project;
|
$oldProject = $timeEntry->project;
|
||||||
$oldTask = $timeEntry->task;
|
$oldTask = $timeEntry->task;
|
||||||
|
|
||||||
@@ -819,6 +825,12 @@ class TimeEntryController extends Controller
|
|||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// The new member could already have a running time entry
|
||||||
|
if ($newMember !== null && $timeEntry->end === null && $newMember->getKey() !== $timeEntry->member_id) {
|
||||||
|
$error->push($id);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
$oldProject = $timeEntry->project;
|
$oldProject = $timeEntry->project;
|
||||||
$oldTask = $timeEntry->task;
|
$oldTask = $timeEntry->task;
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
|
|||||||
use App\Exceptions\Api\PersonalAccessClientIsNotConfiguredException;
|
use App\Exceptions\Api\PersonalAccessClientIsNotConfiguredException;
|
||||||
use App\Exceptions\Api\ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
|
use App\Exceptions\Api\ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
|
||||||
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
|
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
|
||||||
|
use App\Exceptions\Api\TimeEntryMemberCanNotBeChangedWhileRunningApiException;
|
||||||
use App\Exceptions\Api\TimeEntryStillRunningApiException;
|
use App\Exceptions\Api\TimeEntryStillRunningApiException;
|
||||||
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
|
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
|
||||||
use App\Exceptions\Api\UserIsAlreadyMemberOfProjectApiException;
|
use App\Exceptions\Api\UserIsAlreadyMemberOfProjectApiException;
|
||||||
@@ -31,6 +32,7 @@ return [
|
|||||||
TimeEntryStillRunningApiException::KEY => 'Time entry is still running',
|
TimeEntryStillRunningApiException::KEY => 'Time entry is still running',
|
||||||
UserNotPlaceholderApiException::KEY => 'The given user is not a placeholder',
|
UserNotPlaceholderApiException::KEY => 'The given user is not a placeholder',
|
||||||
TimeEntryCanNotBeRestartedApiException::KEY => 'Time entry is already stopped and can not be restarted',
|
TimeEntryCanNotBeRestartedApiException::KEY => 'Time entry is already stopped and can not be restarted',
|
||||||
|
TimeEntryMemberCanNotBeChangedWhileRunningApiException::KEY => 'The member of a running time entry can not be changed',
|
||||||
InactiveUserCanNotBeUsedApiException::KEY => 'Inactive user can not be used',
|
InactiveUserCanNotBeUsedApiException::KEY => 'Inactive user can not be used',
|
||||||
UserIsAlreadyMemberOfOrganizationApiException::KEY => 'User is already a member of the organization',
|
UserIsAlreadyMemberOfOrganizationApiException::KEY => 'User is already a member of the organization',
|
||||||
UserIsAlreadyMemberOfProjectApiException::KEY => 'User is already a member of the project',
|
UserIsAlreadyMemberOfProjectApiException::KEY => 'User is already a member of the project',
|
||||||
|
|||||||
@@ -3332,6 +3332,80 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_update_endpoint_fails_if_member_of_running_time_entry_is_changed(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'time-entries:update:all',
|
||||||
|
]);
|
||||||
|
$otherMember = Member::factory()->forOrganization($data->organization)->role(Role::Employee)->create();
|
||||||
|
$timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->active()->create();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $timeEntry->getKey()]), [
|
||||||
|
'member_id' => $otherMember->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertStatus(400);
|
||||||
|
$response->assertJsonPath('key', 'time_entry_member_can_not_be_changed_while_running');
|
||||||
|
$this->assertDatabaseHas(TimeEntry::class, [
|
||||||
|
'id' => $timeEntry->getKey(),
|
||||||
|
'member_id' => $data->member->getKey(),
|
||||||
|
'user_id' => $data->user->getKey(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_update_endpoint_can_update_running_time_entry_with_unchanged_member_id(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'time-entries:update:own',
|
||||||
|
]);
|
||||||
|
$timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->active()->create();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $timeEntry->getKey()]), [
|
||||||
|
'member_id' => $data->member->getKey(),
|
||||||
|
'description' => 'New description',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertStatus(200);
|
||||||
|
$this->assertDatabaseHas(TimeEntry::class, [
|
||||||
|
'id' => $timeEntry->getKey(),
|
||||||
|
'description' => 'New description',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_update_endpoint_can_stop_running_time_entry_and_change_member_in_the_same_request(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'time-entries:update:all',
|
||||||
|
]);
|
||||||
|
$otherUser = User::factory()->create();
|
||||||
|
$otherMember = Member::factory()->forOrganization($data->organization)->forUser($otherUser)->role(Role::Employee)->create();
|
||||||
|
$timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->active()->create();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $timeEntry->getKey()]), [
|
||||||
|
'member_id' => $otherMember->getKey(),
|
||||||
|
'end' => Carbon::now()->toIso8601ZuluString(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertStatus(200);
|
||||||
|
$this->assertDatabaseHas(TimeEntry::class, [
|
||||||
|
'id' => $timeEntry->getKey(),
|
||||||
|
'member_id' => $otherMember->getKey(),
|
||||||
|
'user_id' => $otherUser->getKey(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_update_endpoint_can_update_project_and_automatically_set_client(): void
|
public function test_update_endpoint_can_update_project_and_automatically_set_client(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
@@ -4199,6 +4273,51 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_update_multiple_does_not_change_member_of_running_time_entries(): void
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
$data = $this->createUserWithPermission([
|
||||||
|
'time-entries:update:all',
|
||||||
|
]);
|
||||||
|
$otherMember = Member::factory()->forOrganization($data->organization)->role(Role::Employee)->create();
|
||||||
|
$runningTimeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->active()->create();
|
||||||
|
$runningTimeEntryOfOtherMember = TimeEntry::factory()->forOrganization($data->organization)->forMember($otherMember)->active()->create();
|
||||||
|
$finishedTimeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->create();
|
||||||
|
Passport::actingAs($data->user);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
$response = $this->patchJson(route('api.v1.time-entries.update-multiple', [$data->organization->getKey()]), [
|
||||||
|
'ids' => [
|
||||||
|
$runningTimeEntry->getKey(),
|
||||||
|
$runningTimeEntryOfOtherMember->getKey(),
|
||||||
|
$finishedTimeEntry->getKey(),
|
||||||
|
],
|
||||||
|
'changes' => [
|
||||||
|
'member_id' => $otherMember->getKey(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
$response->assertStatus(200);
|
||||||
|
$response->assertExactJson([
|
||||||
|
'success' => [
|
||||||
|
$runningTimeEntryOfOtherMember->getKey(),
|
||||||
|
$finishedTimeEntry->getKey(),
|
||||||
|
],
|
||||||
|
'error' => [
|
||||||
|
$runningTimeEntry->getKey(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas(TimeEntry::class, [
|
||||||
|
'id' => $runningTimeEntry->getKey(),
|
||||||
|
'member_id' => $data->member->getKey(),
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas(TimeEntry::class, [
|
||||||
|
'id' => $finishedTimeEntry->getKey(),
|
||||||
|
'member_id' => $otherMember->getKey(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_update_multiple_updates_user_id_when_member_id_changes(): void
|
public function test_update_multiple_updates_user_id_when_member_id_changes(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
|
|||||||
use App\Exceptions\Api\PersonalAccessClientIsNotConfiguredException;
|
use App\Exceptions\Api\PersonalAccessClientIsNotConfiguredException;
|
||||||
use App\Exceptions\Api\ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
|
use App\Exceptions\Api\ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
|
||||||
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
|
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
|
||||||
|
use App\Exceptions\Api\TimeEntryMemberCanNotBeChangedWhileRunningApiException;
|
||||||
use App\Exceptions\Api\TimeEntryStillRunningApiException;
|
use App\Exceptions\Api\TimeEntryStillRunningApiException;
|
||||||
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
|
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
|
||||||
use App\Exceptions\Api\UserIsAlreadyMemberOfProjectApiException;
|
use App\Exceptions\Api\UserIsAlreadyMemberOfProjectApiException;
|
||||||
@@ -53,6 +54,7 @@ class ApiExceptionTest extends TestCase
|
|||||||
yield OverlappingTimeEntryApiException::class => [new OverlappingTimeEntryApiException];
|
yield OverlappingTimeEntryApiException::class => [new OverlappingTimeEntryApiException];
|
||||||
yield ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException::class => [new ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException];
|
yield ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException::class => [new ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException];
|
||||||
yield TimeEntryCanNotBeRestartedApiException::class => [new TimeEntryCanNotBeRestartedApiException];
|
yield TimeEntryCanNotBeRestartedApiException::class => [new TimeEntryCanNotBeRestartedApiException];
|
||||||
|
yield TimeEntryMemberCanNotBeChangedWhileRunningApiException::class => [new TimeEntryMemberCanNotBeChangedWhileRunningApiException];
|
||||||
yield TimeEntryStillRunningApiException::class => [new TimeEntryStillRunningApiException];
|
yield TimeEntryStillRunningApiException::class => [new TimeEntryStillRunningApiException];
|
||||||
yield UserIsAlreadyMemberOfOrganizationApiException::class => [new UserIsAlreadyMemberOfOrganizationApiException];
|
yield UserIsAlreadyMemberOfOrganizationApiException::class => [new UserIsAlreadyMemberOfOrganizationApiException];
|
||||||
yield UserIsAlreadyMemberOfProjectApiException::class => [new UserIsAlreadyMemberOfProjectApiException];
|
yield UserIsAlreadyMemberOfProjectApiException::class => [new UserIsAlreadyMemberOfProjectApiException];
|
||||||
|
|||||||
Reference in New Issue
Block a user