diff --git a/app/Http/Controllers/Api/V1/Controller.php b/app/Http/Controllers/Api/V1/Controller.php index 1cf01348..da63d507 100644 --- a/app/Http/Controllers/Api/V1/Controller.php +++ b/app/Http/Controllers/Api/V1/Controller.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Http\Controllers\Api\V1; use App\Models\Organization; +use App\Service\BillingContract; use App\Service\PermissionStore; use Illuminate\Auth\Access\AuthorizationException; @@ -43,4 +44,9 @@ class Controller extends \App\Http\Controllers\Controller { return $this->permissionStore->has($organization, $permission); } + + protected function canAccessPremiumFeatures(Organization $organization): bool + { + return app(BillingContract::class)->hasSubscription($organization) || app(BillingContract::class)->hasTrial($organization); + } } diff --git a/app/Http/Controllers/Api/V1/ProjectController.php b/app/Http/Controllers/Api/V1/ProjectController.php index 84d4b130..9f5748f9 100644 --- a/app/Http/Controllers/Api/V1/ProjectController.php +++ b/app/Http/Controllers/Api/V1/ProjectController.php @@ -95,6 +95,9 @@ class ProjectController extends Controller $project->is_billable = (bool) $request->input('is_billable'); $project->billable_rate = $request->getBillableRate(); $project->client_id = $request->input('client_id'); + if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) { + $project->estimated_time = $request->getEstimatedTime(); + } $project->organization()->associate($organization); $project->save(); @@ -117,6 +120,9 @@ class ProjectController extends Controller if ($request->has('is_archived')) { $project->archived_at = $request->getIsArchived() ? Carbon::now() : null; } + if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) { + $project->estimated_time = $request->getEstimatedTime(); + } $oldBillableRate = $project->billable_rate; $project->billable_rate = $request->getBillableRate(); $project->client_id = $request->input('client_id'); @@ -147,8 +153,8 @@ class ProjectController extends Controller throw new EntityStillInUseApiException('project', 'time_entry'); } - DB::transaction(function () use (&$project) { - $project->members->each(function (ProjectMember $member) { + DB::transaction(function () use (&$project): void { + $project->members->each(function (ProjectMember $member): void { $member->delete(); }); diff --git a/app/Http/Controllers/Api/V1/TaskController.php b/app/Http/Controllers/Api/V1/TaskController.php index 46778560..8c90f215 100644 --- a/app/Http/Controllers/Api/V1/TaskController.php +++ b/app/Http/Controllers/Api/V1/TaskController.php @@ -79,6 +79,9 @@ class TaskController extends Controller $task = new Task; $task->name = $request->input('name'); $task->project_id = $request->input('project_id'); + if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) { + $task->estimated_time = $request->getEstimatedTime(); + } $task->organization()->associate($organization); $task->save(); @@ -96,6 +99,9 @@ class TaskController extends Controller { $this->checkPermission($organization, 'tasks:update', $task); $task->name = $request->input('name'); + if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) { + $task->estimated_time = $request->getEstimatedTime(); + } if ($request->has('is_done')) { $task->done_at = $request->getIsDone() ? Carbon::now() : null; } diff --git a/app/Http/Requests/V1/Project/ProjectStoreRequest.php b/app/Http/Requests/V1/Project/ProjectStoreRequest.php index 8e655749..167bd5cf 100644 --- a/app/Http/Requests/V1/Project/ProjectStoreRequest.php +++ b/app/Http/Requests/V1/Project/ProjectStoreRequest.php @@ -52,6 +52,7 @@ class ProjectStoreRequest extends FormRequest 'integer', 'min:0', ], + // ID of the client 'client_id' => [ 'nullable', new ExistsEloquent(Client::class, null, function (Builder $builder): Builder { @@ -59,6 +60,12 @@ class ProjectStoreRequest extends FormRequest return $builder->whereBelongsTo($this->organization, 'organization'); }), ], + // Estimated time in seconds + 'estimated_time' => [ + 'nullable', + 'integer', + 'min:0', + ], ]; } @@ -68,4 +75,11 @@ class ProjectStoreRequest extends FormRequest return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null; } + + public function getEstimatedTime(): ?int + { + $input = $this->input('estimated_time'); + + return $input !== null && $input !== 0 ? (int) $this->input('estimated_time') : null; + } } diff --git a/app/Http/Requests/V1/Project/ProjectUpdateRequest.php b/app/Http/Requests/V1/Project/ProjectUpdateRequest.php index 8bb5360e..51fc829d 100644 --- a/app/Http/Requests/V1/Project/ProjectUpdateRequest.php +++ b/app/Http/Requests/V1/Project/ProjectUpdateRequest.php @@ -62,6 +62,12 @@ class ProjectUpdateRequest extends FormRequest 'integer', 'min:0', ], + // Estimated time in seconds + 'estimated_time' => [ + 'nullable', + 'integer', + 'min:0', + ], ]; } @@ -78,4 +84,11 @@ class ProjectUpdateRequest extends FormRequest return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null; } + + public function getEstimatedTime(): ?int + { + $input = $this->input('estimated_time'); + + return $input !== null && $input !== 0 ? (int) $this->input('estimated_time') : null; + } } diff --git a/app/Http/Requests/V1/Task/TaskStoreRequest.php b/app/Http/Requests/V1/Task/TaskStoreRequest.php index e35c496c..7c60ba1a 100644 --- a/app/Http/Requests/V1/Task/TaskStoreRequest.php +++ b/app/Http/Requests/V1/Task/TaskStoreRequest.php @@ -43,6 +43,19 @@ class TaskStoreRequest extends FormRequest return $builder->whereBelongsTo($this->organization, 'organization'); }), ], + // Estimated time in seconds + 'estimated_time' => [ + 'nullable', + 'integer', + 'min:0', + ], ]; } + + public function getEstimatedTime(): ?int + { + $input = $this->input('estimated_time'); + + return $input !== null && $input !== 0 ? (int) $this->input('estimated_time') : null; + } } diff --git a/app/Http/Requests/V1/Task/TaskUpdateRequest.php b/app/Http/Requests/V1/Task/TaskUpdateRequest.php index 81bca001..b9211eb7 100644 --- a/app/Http/Requests/V1/Task/TaskUpdateRequest.php +++ b/app/Http/Requests/V1/Task/TaskUpdateRequest.php @@ -38,6 +38,12 @@ class TaskUpdateRequest extends FormRequest 'is_done' => [ 'boolean', ], + // Estimated time in seconds + 'estimated_time' => [ + 'nullable', + 'integer', + 'min:0', + ], ]; } @@ -47,4 +53,11 @@ class TaskUpdateRequest extends FormRequest return $this->boolean('is_done'); } + + public function getEstimatedTime(): ?int + { + $input = $this->input('estimated_time'); + + return $input !== null && $input !== 0 ? (int) $this->input('estimated_time') : null; + } } diff --git a/app/Http/Resources/V1/Project/ProjectResource.php b/app/Http/Resources/V1/Project/ProjectResource.php index 7f9069ca..2d8567cd 100644 --- a/app/Http/Resources/V1/Project/ProjectResource.php +++ b/app/Http/Resources/V1/Project/ProjectResource.php @@ -35,6 +35,8 @@ class ProjectResource extends BaseResource 'billable_rate' => $this->resource->billable_rate, /** @var bool $is_billable Project time entries billable default */ 'is_billable' => $this->resource->is_billable, + /** @var int|null $estimated_time Estimated time in seconds */ + 'estimated_time' => $this->resource->estimated_time, ]; } } diff --git a/app/Http/Resources/V1/Task/TaskResource.php b/app/Http/Resources/V1/Task/TaskResource.php index c360a7e1..26107935 100644 --- a/app/Http/Resources/V1/Task/TaskResource.php +++ b/app/Http/Resources/V1/Task/TaskResource.php @@ -30,6 +30,8 @@ class TaskResource extends BaseResource 'is_done' => $this->resource->is_done, /** @var string $project_id ID of the project */ 'project_id' => $this->resource->project_id, + /** @var int|null $estimated_time Estimated time in seconds */ + 'estimated_time' => $this->resource->estimated_time, /** @var string $created_at When the tag was created */ 'created_at' => $this->formatDateTime($this->resource->created_at), /** @var string $updated_at When the tag was last updated */ diff --git a/app/Models/Project.php b/app/Models/Project.php index 32317d1c..4eb555c7 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -27,6 +27,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract; * @property bool $is_public * @property bool $is_billable * @property-read bool $is_archived + * @property int|null $estimated_time * @property Carbon|null $archived_at * @property Carbon|null $created_at * @property Carbon|null $updated_at @@ -56,6 +57,7 @@ class Project extends Model implements AuditableContract 'name' => 'string', 'color' => 'string', 'archived_at' => 'datetime', + 'estimated_time' => 'integer', ]; /** diff --git a/app/Models/Task.php b/app/Models/Task.php index 037a33cb..fa696505 100644 --- a/app/Models/Task.php +++ b/app/Models/Task.php @@ -23,6 +23,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract; * @property string $project_id * @property string $organization_id * @property Carbon|null $done_at + * @property int|null $estimated_time * @property Carbon|null $created_at * @property Carbon|null $updated_at * @property-read Project $project @@ -48,6 +49,7 @@ class Task extends Model implements AuditableContract */ protected $casts = [ 'name' => 'string', + 'estimated_time' => 'integer', 'done_at' => 'datetime', ]; diff --git a/database/factories/ProjectFactory.php b/database/factories/ProjectFactory.php index ff4eb278..cfb33075 100644 --- a/database/factories/ProjectFactory.php +++ b/database/factories/ProjectFactory.php @@ -33,9 +33,19 @@ class ProjectFactory extends Factory 'archived_at' => null, 'client_id' => null, 'organization_id' => Organization::factory(), + 'estimated_time' => null, ]; } + public function withEstimatedTime(): self + { + return $this->state(function (array $attributes): array { + return [ + 'estimated_time' => $this->faker->randomNumber(3), + ]; + }); + } + public function billable(): self { return $this->state(function (array $attributes): array { diff --git a/database/factories/TaskFactory.php b/database/factories/TaskFactory.php index b21e2f3c..beeaaf56 100644 --- a/database/factories/TaskFactory.php +++ b/database/factories/TaskFactory.php @@ -26,6 +26,7 @@ class TaskFactory extends Factory 'project_id' => Project::factory(), 'organization_id' => Organization::factory(), 'done_at' => null, + 'estimated_time' => null, ]; } diff --git a/database/migrations/2024_07_02_134307_add_estimated_time_to_projects_and_tasks_table.php b/database/migrations/2024_07_02_134307_add_estimated_time_to_projects_and_tasks_table.php new file mode 100644 index 00000000..2ca1feb0 --- /dev/null +++ b/database/migrations/2024_07_02_134307_add_estimated_time_to_projects_and_tasks_table.php @@ -0,0 +1,36 @@ +integer('estimated_time')->unsigned()->nullable(); + }); + Schema::table('tasks', function (Blueprint $table): void { + $table->integer('estimated_time')->unsigned()->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('projects', function (Blueprint $table): void { + $table->dropColumn('estimated_time'); + }); + Schema::table('tasks', function (Blueprint $table): void { + $table->dropColumn('estimated_time'); + }); + } +}; diff --git a/tests/TestCase.php b/tests/TestCase.php index c246269d..6fdb3a85 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -72,11 +72,21 @@ abstract class TestCase extends BaseTestCase protected function assertBillableRateServiceIsUnused(): void { - $this->mock(BillableRateService::class, function (MockInterface $mock) { + $this->mock(BillableRateService::class, function (MockInterface $mock): void { $mock->shouldNotReceive('updateTimeEntriesBillableRateForProjectMember'); $mock->shouldNotReceive('updateTimeEntriesBillableRateForProject'); $mock->shouldNotReceive('updateTimeEntriesBillableRateForMember'); $mock->shouldNotReceive('updateTimeEntriesBillableRateForOrganization'); }); } + + protected function actAsOrganizationWithSubscription(): void + { + $this->mock(BillingContract::class, function (MockInterface $mock): void { + $mock->shouldReceive('hasSubscription')->andReturn(true); + $mock->shouldReceive('hasTrial')->andReturn(false); + $mock->shouldReceive('getTrialUntil')->andReturn(null); + $mock->shouldReceive('isBlocked')->andReturn(false); + }); + } } diff --git a/tests/Unit/Endpoint/Api/V1/ProjectEndpointTest.php b/tests/Unit/Endpoint/Api/V1/ProjectEndpointTest.php index f9978bac..49c7f204 100644 --- a/tests/Unit/Endpoint/Api/V1/ProjectEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/ProjectEndpointTest.php @@ -251,6 +251,75 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract ]); } + public function test_store_endpoint_ignores_estimated_time_if_pro_features_are_disabled(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'projects:create', + ]); + $projectFake = Project::factory()->forOrganization($data->organization)->make(); + Passport::actingAs($data->user); + + // Act + $response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [ + 'name' => $projectFake->name, + 'color' => $projectFake->color, + 'is_billable' => $projectFake->is_billable, + 'estimated_time' => 10000, + ]); + + // Assert + $response->assertStatus(201); + $response->assertJson(fn (AssertableJson $json) => $json + ->has('data') + ->where('data.name', $projectFake->name) + ->where('data.color', $projectFake->color) + ->where('data.estimated_time', null) + ); + $this->assertDatabaseHas(Project::class, [ + 'name' => $projectFake->name, + 'color' => $projectFake->color, + 'organization_id' => $projectFake->organization_id, + 'is_billable' => $projectFake->is_billable, + 'estimated_time' => null, + ]); + } + + public function test_store_endpoint_can_store_project_with_estimated_time_with_pro_features_enabled(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'projects:create', + ]); + $projectFake = Project::factory()->forOrganization($data->organization)->make(); + Passport::actingAs($data->user); + $this->actAsOrganizationWithSubscription(); + + // Act + $response = $this->postJson(route('api.v1.projects.store', [$data->organization->getKey()]), [ + 'name' => $projectFake->name, + 'color' => $projectFake->color, + 'is_billable' => $projectFake->is_billable, + 'estimated_time' => 10000, + ]); + + // Assert + $response->assertStatus(201); + $response->assertJson(fn (AssertableJson $json) => $json + ->has('data') + ->where('data.name', $projectFake->name) + ->where('data.color', $projectFake->color) + ->where('data.estimated_time', 10000) + ); + $this->assertDatabaseHas(Project::class, [ + 'name' => $projectFake->name, + 'color' => $projectFake->color, + 'organization_id' => $projectFake->organization_id, + 'is_billable' => $projectFake->is_billable, + 'estimated_time' => 10000, + ]); + } + public function test_store_endpoint_fails_if_name_is_already_used_in_organization(): void { // Arrange @@ -507,6 +576,71 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract $this->assertFalse($project->is_archived); } + public function test_update_endpoint_ignores_estimated_time_if_pro_features_are_disabled(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'projects:update', + ]); + $project = Project::factory()->forOrganization($data->organization)->create(); + $projectFake = Project::factory()->make(); + Passport::actingAs($data->user); + + // Act + $response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [ + 'name' => $projectFake->name, + 'color' => $projectFake->color, + 'is_billable' => $projectFake->is_billable, + 'estimated_time' => 10000, + ]); + + // Assert + $response->assertStatus(200); + $project->refresh(); + $response->assertJson(fn (AssertableJson $json) => $json + ->has('data') + ->where('data.name', $projectFake->name) + ->where('data.color', $projectFake->color) + ->where('data.estimated_time', null) + ); + $this->assertSame($projectFake->name, $project->name); + $this->assertSame($projectFake->color, $project->color); + $this->assertNull($project->estimated_time); + } + + public function test_update_endpoint_can_store_project_with_estimated_time_with_pro_features_enabled(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'projects:update', + ]); + $project = Project::factory()->forOrganization($data->organization)->create(); + $projectFake = Project::factory()->make(); + Passport::actingAs($data->user); + $this->actAsOrganizationWithSubscription(); + + // Act + $response = $this->putJson(route('api.v1.projects.update', [$data->organization->getKey(), $project->getKey()]), [ + 'name' => $projectFake->name, + 'color' => $projectFake->color, + 'is_billable' => $projectFake->is_billable, + 'estimated_time' => 10000, + ]); + + // Assert + $response->assertStatus(200); + $project->refresh(); + $response->assertJson(fn (AssertableJson $json) => $json + ->has('data') + ->where('data.name', $projectFake->name) + ->where('data.color', $projectFake->color) + ->where('data.estimated_time', 10000) + ); + $this->assertSame($projectFake->name, $project->name); + $this->assertSame($projectFake->color, $project->color); + $this->assertSame(10000, $project->estimated_time); + } + public function test_update_endpoint_does_not_update_billable_rates_of_time_entries_if_billable_rate_is_unchanged(): void { // Arrange diff --git a/tests/Unit/Endpoint/Api/V1/TaskEndpointTest.php b/tests/Unit/Endpoint/Api/V1/TaskEndpointTest.php index a8076080..e1ed06e9 100644 --- a/tests/Unit/Endpoint/Api/V1/TaskEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/TaskEndpointTest.php @@ -10,6 +10,7 @@ use App\Models\ProjectMember; use App\Models\Task; use App\Models\TimeEntry; use Illuminate\Support\Carbon; +use Illuminate\Testing\Fluent\AssertableJson; use Laravel\Passport\Passport; use PHPUnit\Framework\Attributes\UsesClass; @@ -274,7 +275,7 @@ class TaskEndpointTest extends ApiEndpointTestAbstract $response->assertJsonCount(2, 'data'); } - public function test_store_endpoint_fails_if_user_has_no_permission_to_create_tasks() + public function test_store_endpoint_fails_if_user_has_no_permission_to_create_tasks(): void { // Arrange $data = $this->createUserWithPermission(); @@ -347,7 +348,7 @@ class TaskEndpointTest extends ApiEndpointTestAbstract ]); } - public function test_store_endpoint_creates_new_task_if_user_has_permission_to_create_tasks() + public function test_store_endpoint_creates_new_task_if_user_has_permission_to_create_tasks(): void { // Arrange $data = $this->createUserWithPermission([ @@ -371,6 +372,71 @@ class TaskEndpointTest extends ApiEndpointTestAbstract ]); } + public function test_store_endpoint_ignores_estimated_time_if_pro_features_are_disabled(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'tasks:create', + ]); + $project = Project::factory()->forOrganization($data->organization)->create(); + Passport::actingAs($data->user); + + // Act + $response = $this->postJson(route('api.v1.tasks.store', [$data->organization->getKey()]), [ + 'name' => 'Task 1', + 'project_id' => $project->getKey(), + 'estimated_time' => 3600, + ]); + + // Assert + $response->assertStatus(201); + $response->assertJson(fn (AssertableJson $json) => $json + ->has('data') + ->where('data.name', 'Task 1') + ->where('data.project_id', $project->getKey()) + ->where('data.estimated_time', null) + ); + $this->assertDatabaseHas(Task::class, [ + 'name' => 'Task 1', + 'project_id' => $project->getKey(), + 'organization_id' => $data->organization->getKey(), + 'estimated_time' => null, + ]); + } + + public function test_store_endpoint_can_store_with_estimated_time_with_pro_features_enabled(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'tasks:create', + ]); + $project = Project::factory()->forOrganization($data->organization)->create(); + Passport::actingAs($data->user); + $this->actAsOrganizationWithSubscription(); + + // Act + $response = $this->postJson(route('api.v1.tasks.store', [$data->organization->getKey()]), [ + 'name' => 'Task 1', + 'project_id' => $project->getKey(), + 'estimated_time' => 3600, + ]); + + // Assert + $response->assertStatus(201); + $response->assertJson(fn (AssertableJson $json) => $json + ->has('data') + ->where('data.name', 'Task 1') + ->where('data.project_id', $project->getKey()) + ->where('data.estimated_time', 3600) + ); + $this->assertDatabaseHas(Task::class, [ + 'name' => 'Task 1', + 'project_id' => $project->getKey(), + 'organization_id' => $data->organization->getKey(), + 'estimated_time' => 3600, + ]); + } + public function test_update_endpoint_fails_if_user_has_no_permission(): void { // Arrange @@ -523,6 +589,63 @@ class TaskEndpointTest extends ApiEndpointTestAbstract ]); } + public function test_update_endpoint_ignores_estimated_time_if_pro_features_are_disabled(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'tasks:update', + ]); + $task = Task::factory()->forOrganization($data->organization)->create(); + Passport::actingAs($data->user); + + // Act + $response = $this->putJson(route('api.v1.tasks.update', [$data->organization->getKey(), $task->getKey()]), [ + 'name' => $task->name, + 'estimated_time' => 3600, + ]); + + // Assert + $response->assertStatus(200); + $response->assertJson(fn (AssertableJson $json) => $json + ->has('data') + ->where('data.name', $task->name) + ->where('data.estimated_time', null) + ); + $this->assertDatabaseHas(Task::class, [ + 'id' => $task->getKey(), + 'estimated_time' => null, + ]); + } + + public function test_update_endpoint_can_update_estimated_time_with_pro_features_enabled(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'tasks:update', + ]); + $task = Task::factory()->forOrganization($data->organization)->create(); + Passport::actingAs($data->user); + $this->actAsOrganizationWithSubscription(); + + // Act + $response = $this->putJson(route('api.v1.tasks.update', [$data->organization->getKey(), $task->getKey()]), [ + 'name' => $task->name, + 'estimated_time' => 3600, + ]); + + // Assert + $response->assertStatus(200); + $response->assertJson(fn (AssertableJson $json) => $json + ->has('data') + ->where('data.name', $task->name) + ->where('data.estimated_time', 3600) + ); + $this->assertDatabaseHas(Task::class, [ + 'id' => $task->getKey(), + 'estimated_time' => 3600, + ]); + } + public function test_delete_endpoint_deletes_tasks_if_user_has_permission(): void { // Arrange diff --git a/tests/Unit/Model/UserModelTest.php b/tests/Unit/Model/UserModelTest.php index 9444154d..56f2840b 100644 --- a/tests/Unit/Model/UserModelTest.php +++ b/tests/Unit/Model/UserModelTest.php @@ -92,7 +92,10 @@ class UserModelTest extends ModelTestAbstract // Assert $this->assertNotNull($timeEntriesRel); $this->assertCount(3, $timeEntriesRel); - $this->assertTrue($timeEntriesRel->first()->is($timeEntries->first())); + $this->assertEqualsCanonicalizing( + $timeEntries->pluck('id')->toArray(), + $timeEntriesRel->pluck('id')->toArray() + ); } public function test_it_has_many_project_members(): void