add default sorting to task, project, member, invitation, api token endpoints

This commit is contained in:
Gregor Vostrak
2026-02-18 19:16:14 +01:00
parent 864f41bda6
commit eed638d0aa
12 changed files with 181 additions and 6 deletions

View File

@@ -35,6 +35,7 @@ class ApiTokenController extends Controller
/** @var Builder<Client> $query */
$query->whereJsonContains('grant_types', 'personal_access');
})
->orderBy('created_at', 'desc')
->get();
return new ApiTokenCollection($tokens);

View File

@@ -41,6 +41,7 @@ class InvitationController extends Controller
$this->checkPermission($organization, 'invitations:view');
$invitations = $organization->teamInvitations()
->orderBy('created_at', 'desc')
->paginate(config('app.pagination_per_page_default'));
return InvitationCollection::make($invitations);

View File

@@ -60,6 +60,7 @@ class MemberController extends Controller
$members = Member::query()
->whereBelongsTo($organization, 'organization')
->with(['user'])
->orderBy('created_at', 'desc')
->paginate(config('app.pagination_per_page_default'));
return MemberCollection::make($members);

View File

@@ -60,7 +60,9 @@ class ProjectController extends Controller
$projectsQuery->whereNull('archived_at');
}
$projects = $projectsQuery->paginate(config('app.pagination_per_page_default'));
$projects = $projectsQuery
->orderBy('created_at', 'desc')
->paginate(config('app.pagination_per_page_default'));
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;

View File

@@ -47,6 +47,7 @@ class ProjectMemberController extends Controller
$projectMembers = ProjectMember::query()
->whereBelongsTo($project, 'project')
->orderBy('created_at', 'desc')
->paginate(config('app.pagination_per_page_default'));
return new ProjectMemberCollection($projectMembers);

View File

@@ -82,7 +82,9 @@ class TaskController extends Controller
$query->whereNull('done_at');
}
$tasks = $query->paginate(config('app.pagination_per_page_default'));
$tasks = $query
->orderBy('created_at', 'desc')
->paginate(config('app.pagination_per_page_default'));
return new TaskCollection($tasks);
}

View File

@@ -46,6 +46,31 @@ class ApiTokenEndpointTest extends ApiEndpointTestAbstract
]);
}
public function test_index_endpoint_returns_api_tokens_ordered_by_created_at_descending(): void
{
// Arrange
$data = $this->createUserWithPermission([]);
$personalAccessClient = $this->createPersonalAccessClient();
$tokenOldest = Token::factory()->forUser($data->user)->forClient($personalAccessClient)->create([
'created_at' => now()->subDays(3),
]);
$tokenNewest = Token::factory()->forUser($data->user)->forClient($personalAccessClient)->create([
'created_at' => now()->subDay(),
]);
$tokenMiddle = Token::factory()->forUser($data->user)->forClient($personalAccessClient)->create([
'created_at' => now()->subDays(2),
]);
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.api-tokens.index'));
// Assert
$this->assertResponseCode($response, 200);
$ids = collect($response->json('data'))->pluck('id')->values()->toArray();
$this->assertSame([$tokenNewest->id, $tokenMiddle->id, $tokenOldest->id], $ids);
}
public function test_store_endpoint_creates_new_api_token(): void
{
// Arrange

View File

@@ -55,6 +55,32 @@ class InvitationEndpointTest extends ApiEndpointTestAbstract
]);
}
public function test_index_returns_invitations_ordered_by_created_at_descending(): void
{
// Arrange
$data = $this->createUserWithPermission([
'invitations:view',
]);
$invitationOldest = OrganizationInvitation::factory()->forOrganization($data->organization)->create([
'created_at' => now()->subDays(3),
]);
$invitationNewest = OrganizationInvitation::factory()->forOrganization($data->organization)->create([
'created_at' => now()->subDay(),
]);
$invitationMiddle = OrganizationInvitation::factory()->forOrganization($data->organization)->create([
'created_at' => now()->subDays(2),
]);
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.invitations.index', $data->organization->getKey()));
// Assert
$response->assertStatus(200);
$ids = collect($response->json('data'))->pluck('id')->values()->toArray();
$this->assertSame([$invitationNewest->getKey(), $invitationMiddle->getKey(), $invitationOldest->getKey()], $ids);
}
public function test_store_fails_if_user_has_no_permission_to_create_invitations(): void
{
// Arrange

View File

@@ -52,6 +52,38 @@ class MemberEndpointTest extends ApiEndpointTestAbstract
$response->assertStatus(200);
}
public function test_index_returns_members_ordered_by_created_at_descending(): void
{
// Arrange
$data = $this->createUserWithPermission([
'members:view',
]);
$memberOldest = Member::factory()->forOrganization($data->organization)->create([
'created_at' => now()->subDays(3),
]);
$memberNewest = Member::factory()->forOrganization($data->organization)->create([
'created_at' => now()->subDay(),
]);
$memberMiddle = Member::factory()->forOrganization($data->organization)->create([
'created_at' => now()->subDays(2),
]);
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.members.index', $data->organization->getKey()));
// Assert
$response->assertStatus(200);
$ids = collect($response->json('data'))->pluck('id')->values()->toArray();
// Verify that the three explicitly created members appear in newest-first order
$createdMemberIds = array_values(array_filter($ids, fn ($id) => in_array($id, [
$memberOldest->getKey(),
$memberNewest->getKey(),
$memberMiddle->getKey(),
], true)));
$this->assertSame([$memberNewest->getKey(), $memberMiddle->getKey(), $memberOldest->getKey()], $createdMemberIds);
}
public function test_update_member_fails_if_user_has_no_permission_to_update_members(): void
{
// Arrange

View File

@@ -54,6 +54,33 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
$response->assertJsonCount(4, 'data');
}
public function test_index_endpoint_returns_projects_ordered_by_created_at_descending(): void
{
// Arrange
$data = $this->createUserWithPermission([
'projects:view',
'projects:view:all',
]);
$projectOldest = Project::factory()->forOrganization($data->organization)->create([
'created_at' => now()->subDays(3),
]);
$projectNewest = Project::factory()->forOrganization($data->organization)->create([
'created_at' => now()->subDay(),
]);
$projectMiddle = Project::factory()->forOrganization($data->organization)->create([
'created_at' => now()->subDays(2),
]);
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.projects.index', [$data->organization->getKey()]));
// Assert
$response->assertStatus(200);
$ids = collect($response->json('data'))->pluck('id')->values()->toArray();
$this->assertSame([$projectNewest->getKey(), $projectMiddle->getKey(), $projectOldest->getKey()], $ids);
}
public function test_index_endpoint_without_filter_archived_returns_only_non_archived_projects(): void
{
// Arrange
@@ -211,10 +238,10 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
->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)
->where('data.0.billable_rate', 113)
->where('data.1.billable_rate', 113)
->where('data.2.billable_rate', 112)
->where('data.3.billable_rate', 112)
);
}

View File

@@ -80,6 +80,36 @@ class ProjectMemberEndpointTest extends ApiEndpointTestAbstract
$response->assertJsonCount(4, 'data');
}
public function test_index_endpoint_returns_project_members_ordered_by_created_at_descending(): void
{
// Arrange
$data = $this->createUserWithPermission([
'project-members:view',
]);
$project = Project::factory()->forOrganization($data->organization)->create();
$pmOldest = ProjectMember::factory()->forProject($project)->create([
'created_at' => now()->subDays(3),
]);
$pmNewest = ProjectMember::factory()->forProject($project)->create([
'created_at' => now()->subDay(),
]);
$pmMiddle = ProjectMember::factory()->forProject($project)->create([
'created_at' => now()->subDays(2),
]);
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.project-members.index', [
$data->organization->getKey(),
$project->getKey(),
]));
// Assert
$response->assertStatus(200);
$ids = collect($response->json('data'))->pluck('id')->values()->toArray();
$this->assertSame([$pmNewest->getKey(), $pmMiddle->getKey(), $pmOldest->getKey()], $ids);
}
public function test_store_endpoint_fails_if_user_has_no_permission_to_add_members_to_project(): void
{
// Arrange

View File

@@ -78,6 +78,33 @@ class TaskEndpointTest extends ApiEndpointTestAbstract
$response->assertJsonCount(4, 'data');
}
public function test_index_endpoint_returns_tasks_ordered_by_created_at_descending(): void
{
// Arrange
$data = $this->createUserWithPermission([
'tasks:view',
'tasks:view:all',
]);
$taskOldest = Task::factory()->forOrganization($data->organization)->create([
'created_at' => now()->subDays(3),
]);
$taskNewest = Task::factory()->forOrganization($data->organization)->create([
'created_at' => now()->subDay(),
]);
$taskMiddle = Task::factory()->forOrganization($data->organization)->create([
'created_at' => now()->subDays(2),
]);
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.tasks.index', [$data->organization->getKey(), 'done' => 'all']));
// Assert
$response->assertStatus(200);
$ids = collect($response->json('data'))->pluck('id')->values()->toArray();
$this->assertSame([$taskNewest->getKey(), $taskMiddle->getKey(), $taskOldest->getKey()], $ids);
}
public function test_index_endpoint_without_filter_done_returns_list_of_all_tasks_of_organization(): void
{
// Arrange