Added ability to archive projects and clients, fixes ST-37

This commit is contained in:
Constantin Graf
2024-06-24 13:36:30 +02:00
committed by Gregor Vostrak
parent f21a2d4bdd
commit a69d1cb4c4
17 changed files with 524 additions and 10 deletions

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Exceptions\Api\EntityStillInUseApiException;
use App\Http\Requests\V1\Client\ClientIndexRequest;
use App\Http\Requests\V1\Client\ClientStoreRequest;
use App\Http\Requests\V1\Client\ClientUpdateRequest;
use App\Http\Resources\V1\Client\ClientCollection;
@@ -13,6 +14,7 @@ use App\Models\Client;
use App\Models\Organization;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Carbon;
class ClientController extends Controller
{
@@ -33,14 +35,22 @@ class ClientController extends Controller
*
* @operationId getClients
*/
public function index(Organization $organization): ClientCollection
public function index(Organization $organization, ClientIndexRequest $request): ClientCollection
{
$this->checkPermission($organization, 'clients:view');
$clients = Client::query()
$clientsQuery = Client::query()
->whereBelongsTo($organization, 'organization')
->orderBy('created_at', 'desc')
->paginate(config('app.pagination_per_page_default'));
->orderBy('created_at', 'desc');
$filterArchived = $request->getFilterArchived();
if ($filterArchived === 'true') {
$clientsQuery->whereNotNull('archived_at');
} elseif ($filterArchived === 'false') {
$clientsQuery->whereNull('archived_at');
}
$clients = $clientsQuery->paginate(config('app.pagination_per_page_default'));
return new ClientCollection($clients);
}
@@ -76,6 +86,9 @@ class ClientController extends Controller
$this->checkPermission($organization, 'clients:update', $client);
$client->name = $request->input('name');
if ($request->has('is_archived')) {
$client->archived_at = $request->getIsArchived() ? Carbon::now() : null;
}
$client->save();
return new ClientResource($client);

View File

@@ -13,11 +13,11 @@ use App\Http\Resources\V1\Project\ProjectResource;
use App\Models\Organization;
use App\Models\Project;
use App\Models\ProjectMember;
use App\Models\User;
use App\Service\BillableRateService;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
class ProjectController extends Controller
@@ -51,6 +51,12 @@ class ProjectController extends Controller
if (! $canViewAllProjects) {
$projectsQuery->visibleByEmployee($user);
}
$filterArchived = $request->getFilterArchived();
if ($filterArchived === 'true') {
$projectsQuery->whereNotNull('archived_at');
} elseif ($filterArchived === 'false') {
$projectsQuery->whereNull('archived_at');
}
$projects = $projectsQuery->paginate(config('app.pagination_per_page_default'));
@@ -108,6 +114,9 @@ class ProjectController extends Controller
$project->name = $request->input('name');
$project->color = $request->input('color');
$project->is_billable = (bool) $request->input('is_billable');
if ($request->has('is_archived')) {
$project->archived_at = $request->getIsArchived() ? Carbon::now() : null;
}
$project->billable_rate = $request->getBillableRate();
$project->client_id = $request->input('client_id');
$project->save();

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\Client;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class ClientIndexRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule>>
*/
public function rules(): array
{
return [
'page' => [
'integer',
'min:1',
],
'archived' => [
'string',
'in:true,false,all',
],
];
}
public function getFilterArchived(): string
{
return $this->input('archived', 'false');
}
}

View File

@@ -35,6 +35,16 @@ class ClientUpdateRequest extends FormRequest
return $builder->whereBelongsTo($this->organization, 'organization');
}))->ignore($this->client->getKey())->withCustomTranslation('validation.client_name_already_exists'),
],
'is_archived' => [
'boolean',
],
];
}
public function getIsArchived(): bool
{
assert($this->has('is_archived'));
return (bool) $this->input('is_archived');
}
}

View File

@@ -21,6 +21,15 @@ class ProjectIndexRequest extends FormRequest
'integer',
'min:1',
],
'archived' => [
'string',
'in:true,false,all',
],
];
}
public function getFilterArchived(): string
{
return $this->input('archived', 'false');
}
}

View File

@@ -47,6 +47,9 @@ class ProjectUpdateRequest extends FormRequest
'required',
'boolean',
],
'is_archived' => [
'boolean',
],
'client_id' => [
'nullable',
new ExistsEloquent(Client::class, null, function (Builder $builder): Builder {
@@ -66,6 +69,13 @@ class ProjectUpdateRequest extends FormRequest
];
}
public function getIsArchived(): bool
{
assert($this->has('is_archived'));
return (bool) $this->input('is_archived');
}
public function getBillableRate(): ?int
{
$input = $this->input('billable_rate');

View File

@@ -25,6 +25,8 @@ class ClientResource extends BaseResource
'id' => $this->resource->id,
/** @var string $name Name */
'name' => $this->resource->name,
/** @var bool $is_archived Whether the client is archived */
'is_archived' => $this->resource->is_archived,
/** @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 */

View File

@@ -29,6 +29,8 @@ class ProjectResource extends BaseResource
'color' => $this->resource->color,
/** @var string|null $client_id ID of client */
'client_id' => $this->resource->client_id,
/** @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,
/** @var bool $is_billable Project time entries billable default */

View File

@@ -6,6 +6,7 @@ namespace App\Models;
use App\Models\Concerns\HasUuids;
use Database\Factories\ClientFactory;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -16,6 +17,8 @@ use Illuminate\Support\Carbon;
* @property string $id
* @property string $name
* @property string $organization_id
* @property-read bool $is_archived
* @property Carbon|null $archived_at
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property-read Organization $organization
@@ -51,4 +54,14 @@ class Client extends Model
{
return $this->hasMany(Project::class, 'client_id');
}
/**
* @return Attribute<bool, never>
*/
protected function isArchived(): Attribute
{
return Attribute::make(
get: fn (mixed $value, array $attributes) => isset($attributes['archived_at']),
);
}
}

View File

@@ -7,11 +7,13 @@ namespace App\Models;
use App\Models\Concerns\HasUuids;
use Database\Factories\ProjectFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Carbon;
/**
* @property string $id
@@ -21,6 +23,10 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
* @property string $client_id
* @property int|null $billable_rate
* @property bool $is_billable
* @property-read bool $is_archived
* @property Carbon|null $archived_at
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property-read Organization $organization
* @property-read Client|null $client
* @property-read Collection<int, Task> $tasks
@@ -105,4 +111,14 @@ class Project extends Model
});
});
}
/**
* @return Attribute<bool, never>
*/
protected function isArchived(): Attribute
{
return Attribute::make(
get: fn (mixed $value, array $attributes) => isset($attributes['archived_at']),
);
}
}

View File

@@ -22,6 +22,7 @@ class ClientFactory extends Factory
{
return [
'name' => $this->faker->company(),
'archived_at' => null,
'organization_id' => Organization::factory(),
];
}
@@ -43,4 +44,13 @@ class ClientFactory extends Factory
];
});
}
public function archived(): self
{
return $this->state(function (array $attributes): array {
return [
'archived_at' => $this->faker->dateTime(),
];
});
}
}

View File

@@ -30,6 +30,7 @@ class ProjectFactory extends Factory
'is_billable' => false,
'billable_rate' => null,
'is_public' => false,
'archived_at' => null,
'client_id' => null,
'organization_id' => Organization::factory(),
];
@@ -45,6 +46,15 @@ class ProjectFactory extends Factory
});
}
public function archived(): self
{
return $this->state(function (array $attributes): array {
return [
'archived_at' => $this->faker->dateTime(),
];
});
}
public function forOrganization(Organization $organization): self
{
return $this->state(function (array $attributes) use ($organization): array {

View File

@@ -0,0 +1,36 @@
<?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('projects', function (Blueprint $table): void {
$table->dateTime('archived_at')->nullable();
});
Schema::table('clients', function (Blueprint $table): void {
$table->dateTime('archived_at')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('projects', function (Blueprint $table): void {
$table->dropColumn('archived_at');
});
Schema::table('clients', function (Blueprint $table): void {
$table->dropColumn('archived_at');
});
}
};

View File

@@ -57,6 +57,91 @@ class ClientEndpointTest extends ApiEndpointTestAbstract
);
}
public function test_index_endpoint_without_filter_archived_returns_only_non_archived_clients(): void
{
// Arrange
$data = $this->createUserWithPermission([
'clients:view',
]);
$archivedClients = Client::factory()->forOrganization($data->organization)->archived()->createMany(2);
$nonArchivedClients = Client::factory()->forOrganization($data->organization)->createMany(2);
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.clients.index', [$data->organization->getKey()]));
// Assert
$response->assertStatus(200);
$response->assertJsonCount(2, 'data');
$this->assertEqualsCanonicalizing($nonArchivedClients->pluck('id')->toArray(), $response->json('data.*.id'));
}
public function test_index_endpoint_with_filter_archived_true_returns_only_archived_clients(): void
{
// Arrange
$data = $this->createUserWithPermission([
'clients:view',
]);
$archivedClients = Client::factory()->forOrganization($data->organization)->archived()->createMany(2);
$nonArchivedClients = Client::factory()->forOrganization($data->organization)->createMany(2);
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.clients.index', [
$data->organization->getKey(),
'archived' => 'true',
]));
// Assert
$response->assertStatus(200);
$response->assertJsonCount(2, 'data');
$this->assertEqualsCanonicalizing($archivedClients->pluck('id')->toArray(), $response->json('data.*.id'));
}
public function test_index_endpoint_with_filter_archived_false_returns_only_non_archived_clients(): void
{
// Arrange
$data = $this->createUserWithPermission([
'clients:view',
]);
$archivedClients = Client::factory()->forOrganization($data->organization)->archived()->createMany(2);
$nonArchivedClients = Client::factory()->forOrganization($data->organization)->createMany(2);
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.clients.index', [
$data->organization->getKey(),
'archived' => 'false',
]));
// Assert
$response->assertStatus(200);
$response->assertJsonCount(2, 'data');
$this->assertEqualsCanonicalizing($nonArchivedClients->pluck('id')->toArray(), $response->json('data.*.id'));
}
public function test_index_endpoint_with_filter_archived_all_returns_all_clients(): void
{
// Arrange
$data = $this->createUserWithPermission([
'clients:view',
]);
$archivedClients = Client::factory()->forOrganization($data->organization)->archived()->createMany(2);
$nonArchivedClients = Client::factory()->forOrganization($data->organization)->createMany(2);
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.clients.index', [
$data->organization->getKey(),
'archived' => 'all',
]));
// Assert
$response->assertStatus(200);
$response->assertJsonCount(4, 'data');
$this->assertEqualsCanonicalizing($archivedClients->merge($nonArchivedClients)->pluck('id')->toArray(), $response->json('data.*.id'));
}
public function test_store_endpoint_fails_if_user_has_no_permission_to_create_clients(): void
{
// Arrange
@@ -257,6 +342,58 @@ class ClientEndpointTest extends ApiEndpointTestAbstract
]);
}
public function test_update_endpoint_can_archive_a_client(): void
{
// Arrange
$data = $this->createUserWithPermission([
'clients:update',
]);
$client = Client::factory()->forOrganization($data->organization)->create();
$clientFake = Client::factory()->make();
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.clients.update', [$data->organization->getKey(), $client->getKey()]), [
'name' => $clientFake->name,
'is_archived' => true,
]);
// Assert
$response->assertStatus(200);
$response->assertJson(fn (AssertableJson $json) => $json
->has('data')
->where('data.is_archived', true)
);
$client->refresh();
$this->assertTrue($client->is_archived);
}
public function test_update_endpoint_can_unarchive_a_client(): void
{
// Arrange
$data = $this->createUserWithPermission([
'clients:update',
]);
$client = Client::factory()->forOrganization($data->organization)->archived()->create();
$clientFake = Client::factory()->make();
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.clients.update', [$data->organization->getKey(), $client->getKey()]), [
'name' => $clientFake->name,
'is_archived' => false,
]);
// Assert
$response->assertStatus(200);
$response->assertJson(fn (AssertableJson $json) => $json
->has('data')
->where('data.is_archived', false)
);
$client->refresh();
$this->assertFalse($client->is_archived);
}
public function test_destroy_endpoint_fails_if_user_has_no_permission_to_delete_clients(): void
{
// Arrange

View File

@@ -12,6 +12,7 @@ use App\Models\ProjectMember;
use App\Models\Task;
use App\Models\TimeEntry;
use App\Service\BillableRateService;
use Illuminate\Testing\Fluent\AssertableJson;
use Laravel\Passport\Passport;
use Mockery\MockInterface;
use PHPUnit\Framework\Attributes\UsesClass;
@@ -52,6 +53,93 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
$response->assertJsonCount(4, 'data');
}
public function test_index_endpoint_without_filter_archived_returns_only_non_archived_projects(): void
{
// Arrange
$data = $this->createUserWithPermission([
'projects:view',
'projects:view:all',
]);
$archivedProjects = Project::factory()->forOrganization($data->organization)->archived()->createMany(2);
$nonArchivedProjects = Project::factory()->forOrganization($data->organization)->createMany(2);
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.projects.index', [$data->organization->getKey()]));
// Assert
$response->assertStatus(200);
$response->assertJsonCount(2, 'data');
$this->assertEqualsCanonicalizing($nonArchivedProjects->pluck('id')->toArray(), $response->json('data.*.id'));
}
public function test_index_endpoint_with_filter_archived_true_returns_only_archived_projects(): void
{
// Arrange
$data = $this->createUserWithPermission([
'projects:view',
'projects:view:all',
]);
$archivedProjects = Project::factory()->forOrganization($data->organization)->archived()->createMany(2);
$nonArchivedProjects = Project::factory()->forOrganization($data->organization)->createMany(2);
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.projects.index', [
$data->organization->getKey(),
'archived' => 'true',
]));
// Assert
$response->assertStatus(200);
$response->assertJsonCount(2, 'data');
$this->assertEqualsCanonicalizing($archivedProjects->pluck('id')->toArray(), $response->json('data.*.id'));
}
public function test_index_endpoint_with_filter_archived_false_returns_only_non_archived_projects(): void
{
// Arrange
$data = $this->createUserWithPermission([
'projects:view',
'projects:view:all',
]);
$archivedProjects = Project::factory()->forOrganization($data->organization)->archived()->createMany(2);
$nonArchivedProjects = Project::factory()->forOrganization($data->organization)->createMany(2);
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.projects.index', [
$data->organization->getKey(),
'archived' => 'false',
]));
// Assert
$response->assertStatus(200);
$response->assertJsonCount(2, 'data');
$this->assertEqualsCanonicalizing($nonArchivedProjects->pluck('id')->toArray(), $response->json('data.*.id'));
}
public function test_index_endpoint_with_filter_archived_all_returns_all_projects(): void
{
// Arrange
$data = $this->createUserWithPermission([
'projects:view',
'projects:view:all',
]);
$archivedProjects = Project::factory()->forOrganization($data->organization)->archived()->createMany(2);
$nonArchivedProjects = Project::factory()->forOrganization($data->organization)->createMany(2);
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.projects.index', [
$data->organization->getKey(),
'archived' => 'all',
]));
// Assert
$response->assertStatus(200);
$response->assertJsonCount(4, 'data');
}
public function test_index_endpoint_returns_list_of_projects_of_organization_which_are_public_or_where_user_is_member_for_user_with_restricted_permission(): void
{
// Arrange
@@ -406,11 +494,17 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
// Assert
$response->assertStatus(200);
$this->assertDatabaseHas(Project::class, [
'name' => $projectFake->name,
'color' => $projectFake->color,
'client_id' => $client->getKey(),
]);
$project->refresh();
$response->assertJson(fn (AssertableJson $json) => $json
->has('data')
->where('data.name', $projectFake->name)
->where('data.color', $projectFake->color)
->where('data.client_id', $client->getKey())
);
$this->assertSame($projectFake->name, $project->name);
$this->assertSame($projectFake->color, $project->color);
$this->assertSame($client->getKey(), $project->client_id);
$this->assertFalse($project->is_archived);
}
public function test_update_endpoint_can_update_projects_billable_rate(): void
@@ -474,6 +568,62 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
]);
}
public function test_update_endpoint_can_archive_a_project(): 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,
'is_archived' => true,
]);
// Assert
$response->assertStatus(200);
$response->assertJson(fn (AssertableJson $json) => $json
->has('data')
->where('data.is_archived', true)
);
$project->refresh();
$this->assertTrue($project->is_archived);
}
public function test_update_endpoint_can_unarchive_a_project(): void
{
// Arrange
$data = $this->createUserWithPermission([
'projects:update',
]);
$project = Project::factory()->forOrganization($data->organization)->archived()->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,
'is_archived' => false,
]);
// Assert
$response->assertStatus(200);
$response->assertJson(fn (AssertableJson $json) => $json
->has('data')
->where('data.is_archived', false)
);
$project->refresh();
$this->assertFalse($project->is_archived);
}
public function test_destroy_endpoint_fails_if_user_is_not_part_of_project_organization(): void
{
// Arrange

View File

@@ -46,4 +46,30 @@ class ClientModelTest extends ModelTestAbstract
$this->assertCount(4, $projectsRel);
$this->assertTrue($projectsRel->first()->is($projects->first()));
}
public function test_accessor_is_archived_is_true_if_archived_at_is_not_null(): void
{
// Arrange
$client = Client::factory()->archived()->create();
// Act
$client->refresh();
$isArchived = $client->is_archived;
// Assert
$this->assertTrue($isArchived);
}
public function test_accessor_is_archived_is_false_if_archived_at_is_null(): void
{
// Arrange
$client = Client::factory()->create();
// Act
$client->refresh();
$isArchived = $client->is_archived;
// Assert
$this->assertFalse($isArchived);
}
}

View File

@@ -116,4 +116,30 @@ class ProjectModelTest extends ModelTestAbstract
$projectPrivateButMember->getKey(),
], $allProjects);
}
public function test_accessor_is_archived_is_true_if_archived_at_is_not_null(): void
{
// Arrange
$project = Project::factory()->archived()->create();
// Act
$project->refresh();
$isArchived = $project->is_archived;
// Assert
$this->assertTrue($isArchived);
}
public function test_accessor_is_archived_is_false_if_archived_at_is_null(): void
{
// Arrange
$project = Project::factory()->create();
// Act
$project->refresh();
$isArchived = $project->is_archived;
// Assert
$this->assertFalse($isArchived);
}
}