From 51cd919db668cde770088f8fad6748e8faadb63d Mon Sep 17 00:00:00 2001 From: Constantin Graf Date: Tue, 1 Oct 2024 18:14:26 +0200 Subject: [PATCH] Add organization setting employees_can_see_billable_rates --- .../Api/V1/OrganizationController.php | 10 ++- .../Controllers/Api/V1/ProjectController.php | 14 +++-- .../OrganizationUpdateRequest.php | 3 + .../V1/Organization/OrganizationResource.php | 18 +++++- .../V1/Project/ProjectCollection.php | 33 ++++++++-- .../Resources/V1/Project/ProjectResource.php | 11 +++- app/Models/Organization.php | 2 + database/factories/OrganizationFactory.php | 1 + database/factories/ProjectFactory.php | 16 ++++- ..._billable_rates_to_organizations_table.php | 30 +++++++++ tests/TestCaseWithDatabase.php | 26 ++++++++ .../Api/V1/OrganizationEndpointTest.php | 61 +++++++++++++++++++ .../Endpoint/Api/V1/ProjectEndpointTest.php | 59 ++++++++++++++++++ 13 files changed, 269 insertions(+), 15 deletions(-) create mode 100644 database/migrations/2024_10_01_143608_add_employees_can_see_billable_rates_to_organizations_table.php diff --git a/app/Http/Controllers/Api/V1/OrganizationController.php b/app/Http/Controllers/Api/V1/OrganizationController.php index c1f628c8..2fe8eff7 100644 --- a/app/Http/Controllers/Api/V1/OrganizationController.php +++ b/app/Http/Controllers/Api/V1/OrganizationController.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Http\Controllers\Api\V1; +use App\Enums\Role; use App\Http\Requests\V1\Organization\OrganizationUpdateRequest; use App\Http\Resources\V1\Organization\OrganizationResource; use App\Models\Organization; @@ -23,7 +24,9 @@ class OrganizationController extends Controller { $this->checkPermission($organization, 'organizations:view'); - return new OrganizationResource($organization); + $showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates; + + return new OrganizationResource($organization, $showBillableRate); } /** @@ -39,6 +42,9 @@ class OrganizationController extends Controller $organization->name = $request->input('name'); $oldBillableRate = $organization->billable_rate; + if ($request->has('employees_can_see_billable_rates')) { + $organization->employees_can_see_billable_rates = $request->validated('employees_can_see_billable_rates'); + } $organization->billable_rate = $request->getBillableRate(); $organization->save(); @@ -46,6 +52,6 @@ class OrganizationController extends Controller $billableRateService->updateTimeEntriesBillableRateForOrganization($organization); } - return new OrganizationResource($organization); + return new OrganizationResource($organization, true); } } diff --git a/app/Http/Controllers/Api/V1/ProjectController.php b/app/Http/Controllers/Api/V1/ProjectController.php index 9f5748f9..6977d9ec 100644 --- a/app/Http/Controllers/Api/V1/ProjectController.php +++ b/app/Http/Controllers/Api/V1/ProjectController.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Http\Controllers\Api\V1; +use App\Enums\Role; use App\Exceptions\Api\EntityStillInUseApiException; use App\Http\Requests\V1\Project\ProjectIndexRequest; use App\Http\Requests\V1\Project\ProjectStoreRequest; @@ -60,7 +61,9 @@ class ProjectController extends Controller $projects = $projectsQuery->paginate(config('app.pagination_per_page_default')); - return new ProjectCollection($projects); + $showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates; + + return new ProjectCollection($projects, $showBillableRate); } /** @@ -74,9 +77,12 @@ class ProjectController extends Controller { $this->checkPermission($organization, 'projects:view', $project); + // Note: There is currently no need to check if a user is a member of the project, + // since this is only relevant for users with the role "employee" and they can not access this endpoint. + $project->load('organization'); - return new ProjectResource($project); + return new ProjectResource($project, true); } /** @@ -101,7 +107,7 @@ class ProjectController extends Controller $project->organization()->associate($organization); $project->save(); - return new ProjectResource($project); + return new ProjectResource($project, true); } /** @@ -132,7 +138,7 @@ class ProjectController extends Controller $billableRateService->updateTimeEntriesBillableRateForProject($project); } - return new ProjectResource($project); + return new ProjectResource($project, true); } /** diff --git a/app/Http/Requests/V1/Organization/OrganizationUpdateRequest.php b/app/Http/Requests/V1/Organization/OrganizationUpdateRequest.php index 7c25fa37..994a184a 100644 --- a/app/Http/Requests/V1/Organization/OrganizationUpdateRequest.php +++ b/app/Http/Requests/V1/Organization/OrganizationUpdateRequest.php @@ -31,6 +31,9 @@ class OrganizationUpdateRequest extends FormRequest 'integer', 'min:0', ], + 'employees_can_see_billable_rates' => [ + 'boolean', + ], ]; } diff --git a/app/Http/Resources/V1/Organization/OrganizationResource.php b/app/Http/Resources/V1/Organization/OrganizationResource.php index bce584fc..b671aa65 100644 --- a/app/Http/Resources/V1/Organization/OrganizationResource.php +++ b/app/Http/Resources/V1/Organization/OrganizationResource.php @@ -13,6 +13,20 @@ use Illuminate\Http\Request; */ class OrganizationResource extends BaseResource { + private bool $showBillableRate; + + /** + * Create a new resource instance. + * + * @return void + */ + public function __construct(Organization $resource, bool $showBillableRate) + { + parent::__construct($resource); + + $this->showBillableRate = $showBillableRate; + } + /** * Transform the resource into an array. * @@ -28,7 +42,9 @@ class OrganizationResource extends BaseResource /** @var bool $color Personal organizations automatically created after registration */ 'is_personal' => $this->resource->personal_team, /** @var int|null $billable_rate Billable rate in cents per hour */ - 'billable_rate' => $this->resource->billable_rate, + '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, ]; } } diff --git a/app/Http/Resources/V1/Project/ProjectCollection.php b/app/Http/Resources/V1/Project/ProjectCollection.php index 4a299cfb..1142e654 100644 --- a/app/Http/Resources/V1/Project/ProjectCollection.php +++ b/app/Http/Resources/V1/Project/ProjectCollection.php @@ -5,14 +5,39 @@ declare(strict_types=1); namespace App\Http\Resources\V1\Project; use App\Http\Resources\PaginatedResourceCollection; +use App\Models\Project; +use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\ResourceCollection; +use Illuminate\Pagination\LengthAwarePaginator; class ProjectCollection extends ResourceCollection implements PaginatedResourceCollection { + private bool $showBillableRates; + /** - * The resource that this resource collects. - * - * @var string + * @param LengthAwarePaginator $resource */ - public $collects = ProjectResource::class; + public function __construct($resource, bool $showBillableRates) + { + parent::__construct($resource); + $this->showBillableRates = $showBillableRates; + } + + protected function collects(): ?string + { + return null; + } + + /** + * Transform the resource collection into an array. + * + * @return array> + */ + public function toArray(Request $request): array + { + return $this->collection->map(function (Project $project) use ($request): array { + return (new ProjectResource($project, $this->showBillableRates)) + ->toArray($request); + })->all(); + } } diff --git a/app/Http/Resources/V1/Project/ProjectResource.php b/app/Http/Resources/V1/Project/ProjectResource.php index e96f2f04..0a2bb907 100644 --- a/app/Http/Resources/V1/Project/ProjectResource.php +++ b/app/Http/Resources/V1/Project/ProjectResource.php @@ -13,6 +13,15 @@ use Illuminate\Http\Request; */ class ProjectResource extends BaseResource { + private bool $showBillableRate; + + public function __construct(Project $resource, bool $showBillableRate) + { + parent::__construct($resource); + + $this->showBillableRate = $showBillableRate; + } + /** * Transform the resource into an array. * @@ -32,7 +41,7 @@ class ProjectResource extends BaseResource /** @var bool $is_archived Whether the client is archived */ 'is_archived' => $this->resource->is_archived, /** @var int|null $billable_rate Billable rate in cents per hour */ - 'billable_rate' => $this->resource->billable_rate, + 'billable_rate' => $this->showBillableRate ? $this->resource->billable_rate : null, /** @var bool $is_billable Project time entries billable default */ 'is_billable' => $this->resource->is_billable, /** @var int|null $estimated_time Estimated time in seconds */ diff --git a/app/Models/Organization.php b/app/Models/Organization.php index 581e7370..7dd8a3b0 100644 --- a/app/Models/Organization.php +++ b/app/Models/Organization.php @@ -29,6 +29,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract; * @property string $currency * @property int|null $billable_rate * @property string $user_id + * @property bool $employees_can_see_billable_rates * @property User $owner * @property Carbon|null $created_at * @property Carbon|null $updated_at @@ -58,6 +59,7 @@ class Organization extends JetstreamTeam implements AuditableContract 'name' => 'string', 'personal_team' => 'boolean', 'currency' => 'string', + 'employees_can_see_billable_rates' => 'boolean', ]; /** diff --git a/database/factories/OrganizationFactory.php b/database/factories/OrganizationFactory.php index 420afd4c..5f19e124 100644 --- a/database/factories/OrganizationFactory.php +++ b/database/factories/OrganizationFactory.php @@ -26,6 +26,7 @@ class OrganizationFactory extends Factory 'billable_rate' => null, 'user_id' => User::factory(), 'personal_team' => true, + 'employees_can_see_billable_rates' => false, ]; } diff --git a/database/factories/ProjectFactory.php b/database/factories/ProjectFactory.php index cfb33075..6bffbfcd 100644 --- a/database/factories/ProjectFactory.php +++ b/database/factories/ProjectFactory.php @@ -11,6 +11,7 @@ use App\Models\Project; use App\Models\ProjectMember; use App\Service\ColorService; use Illuminate\Database\Eloquent\Factories\Factory; +use Illuminate\Support\Carbon; /** * @extends Factory @@ -46,12 +47,21 @@ class ProjectFactory extends Factory }); } - public function billable(): self + public function billable(?int $billableRate = null): self { - return $this->state(function (array $attributes): array { + return $this->state(function (array $attributes) use ($billableRate): array { return [ 'is_billable' => true, - 'billable_rate' => $this->faker->numberBetween(50, 1000) * 100, + 'billable_rate' => $billableRate === null ? $this->faker->numberBetween(50, 1000) * 100 : $billableRate, + ]; + }); + } + + public function createdAt(Carbon $createdAt): self + { + return $this->state(function (array $attributes) use ($createdAt): array { + return [ + 'created_at' => $createdAt, ]; }); } diff --git a/database/migrations/2024_10_01_143608_add_employees_can_see_billable_rates_to_organizations_table.php b/database/migrations/2024_10_01_143608_add_employees_can_see_billable_rates_to_organizations_table.php new file mode 100644 index 00000000..74faf71f --- /dev/null +++ b/database/migrations/2024_10_01_143608_add_employees_can_see_billable_rates_to_organizations_table.php @@ -0,0 +1,30 @@ +boolean('employees_can_see_billable_rates')->default(false); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('organizations', function (Blueprint $table): void { + $table->dropColumn('employees_can_see_billable_rates'); + }); + } +}; diff --git a/tests/TestCaseWithDatabase.php b/tests/TestCaseWithDatabase.php index 5dbff1d6..2db4dd5b 100644 --- a/tests/TestCaseWithDatabase.php +++ b/tests/TestCaseWithDatabase.php @@ -53,6 +53,32 @@ abstract class TestCaseWithDatabase extends TestCase ]; } + public function createUserWithRole(Role $role): object + { + $owner = User::factory()->create(); + $organization = Organization::factory()->withOwner($owner)->create(); + $ownerMember = Member::factory()->forUser($owner)->forOrganization($organization)->role(Role::Owner)->create(); + $owner->currentOrganization()->associate($organization); + $owner->save(); + + if ($role === Role::Owner) { + $user = $owner; + $member = $ownerMember; + } else { + $user = User::factory()->create(); + $member = Member::factory()->forUser($user)->forOrganization($organization)->role($role)->create(); + $user->currentOrganization()->associate($organization); + } + + return (object) [ + 'user' => $user, + 'organization' => $organization, + 'member' => $member, + 'owner' => $owner, + 'ownerMember' => $ownerMember, + ]; + } + protected function enableQueryLog(): void { DB::flushQueryLog(); diff --git a/tests/Unit/Endpoint/Api/V1/OrganizationEndpointTest.php b/tests/Unit/Endpoint/Api/V1/OrganizationEndpointTest.php index 50981278..aa6a5480 100644 --- a/tests/Unit/Endpoint/Api/V1/OrganizationEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/OrganizationEndpointTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Tests\Unit\Endpoint\Api\V1; +use App\Enums\Role; use App\Http\Controllers\Api\V1\OrganizationController; use App\Models\Organization; use App\Service\BillableRateService; @@ -58,6 +59,40 @@ class OrganizationEndpointTest extends ApiEndpointTestAbstract $response->assertJsonPath('data.id', $data->organization->getKey()); } + public function test_show_endpoint_shows_billable_rate_for_members_with_role_employee_if_organization_allows_it(): void + { + // Arrange + $data = $this->createUserWithRole(Role::Employee); + $data->organization->employees_can_see_billable_rates = true; + $data->organization->billable_rate = 100; + $data->organization->save(); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.organizations.show', [$data->organization->getKey()])); + + // Assert + $response->assertStatus(200); + $response->assertJsonPath('data.billable_rate', 100); + } + + public function test_show_endpoint_does_not_show_billable_rate_for_members_with_role_employee_if_organization_does_not_allow_it(): void + { + // Arrange + $data = $this->createUserWithRole(Role::Employee); + $data->organization->employees_can_see_billable_rates = false; + $data->organization->billable_rate = 100; + $data->organization->save(); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.organizations.show', [$data->organization->getKey()])); + + // Assert + $response->assertStatus(200); + $response->assertJsonPath('data.billable_rate', null); + } + public function test_update_endpoint_fails_if_user_has_no_permission_to_update_organizations(): void { // Arrange @@ -123,6 +158,32 @@ class OrganizationEndpointTest extends ApiEndpointTestAbstract ]); } + public function test_update_endpoint_can_update_the_setting_employees_can_see_billable_rates(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'organizations:update', + ]); + $this->assertBillableRateServiceIsUnused(); + $data->organization->employees_can_see_billable_rates = false; + $data->organization->save(); + $organizationFake = Organization::factory()->make(); + Passport::actingAs($data->user); + + // Act + $response = $this->putJson(route('api.v1.organizations.update', [$data->organization->getKey()]), [ + 'name' => $organizationFake->name, + 'employees_can_see_billable_rates' => true, + ]); + + // Assert + $response->assertStatus(200); + $this->assertDatabaseHas(Organization::class, [ + 'name' => $organizationFake->name, + 'employees_can_see_billable_rates' => true, + ]); + } + public function test_update_endpoint_can_update_billable_rate_of_organization_and_update_time_entries(): void { // Arrange diff --git a/tests/Unit/Endpoint/Api/V1/ProjectEndpointTest.php b/tests/Unit/Endpoint/Api/V1/ProjectEndpointTest.php index 49c7f204..072c25a4 100644 --- a/tests/Unit/Endpoint/Api/V1/ProjectEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/ProjectEndpointTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Tests\Unit\Endpoint\Api\V1; +use App\Enums\Role; use App\Http\Controllers\Api\V1\ProjectController; use App\Models\Client; use App\Models\Organization; @@ -159,6 +160,64 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract $response->assertJsonCount(4, 'data'); } + public function test_index_endpoint_sets_billable_rate_to_null_if_member_is_employee_and_organization_does_not_allow_employees_to_see_billable_rates(): void + { + // Arrange + $data = $this->createUserWithRole(Role::Employee); + $organization = $data->organization; + $organization->employees_can_see_billable_rates = false; + $organization->save(); + $privateProjects = Project::factory()->forOrganization($data->organization)->isPrivate()->billable(111)->createMany(2); + $publicProjects = Project::factory()->forOrganization($data->organization)->isPublic()->billable(112)->createMany(2); + $privateProjectsWithMembership = Project::factory()->forOrganization($data->organization)->addMember($data->member)->billable(113)->isPrivate()->createMany(2); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.projects.index', [$organization->getKey()])); + + // Assert + $response->assertStatus(200); + $response->assertJsonCount(4, 'data'); + $response->assertJson(fn (AssertableJson $json) => $json + ->has('data') + ->has('links') + ->has('meta') + ->where('data.0.billable_rate', null) + ->where('data.1.billable_rate', null) + ->where('data.2.billable_rate', null) + ->where('data.3.billable_rate', null) + ); + } + + public function test_index_endpoint_does_not_set_billable_rate_to_null_if_member_is_employee_and_organization_allows_employees_to_see_billable_rates(): void + { + // Arrange + $data = $this->createUserWithRole(Role::Employee); + $organization = $data->organization; + $organization->employees_can_see_billable_rates = true; + $organization->save(); + $privateProjects = Project::factory()->forOrganization($data->organization)->isPrivate()->billable(111)->createdAt(now()->subMinutes(4))->createMany(2); + $publicProjects = Project::factory()->forOrganization($data->organization)->isPublic()->billable(112)->createdAt(now()->subMinutes(3))->createMany(2); + $privateProjectsWithMembership = Project::factory()->forOrganization($data->organization)->addMember($data->member)->billable(113)->isPrivate()->createdAt(now()->subMinutes(2))->createMany(2); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.projects.index', [$organization->getKey()])); + + // Assert + $response->assertStatus(200); + $response->assertJsonCount(4, 'data'); + $response->assertJson(fn (AssertableJson $json) => $json + ->has('data') + ->has('links') + ->has('meta') + ->where('data.0.billable_rate', 112) + ->where('data.1.billable_rate', 112) + ->where('data.2.billable_rate', 113) + ->where('data.3.billable_rate', 113) + ); + } + public function test_show_endpoint_fails_if_user_is_not_part_of_project_organization(): void { // Arrange