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 */ /** @var Builder<Client> $query */
$query->whereJsonContains('grant_types', 'personal_access'); $query->whereJsonContains('grant_types', 'personal_access');
}) })
->orderBy('created_at', 'desc')
->get(); ->get();
return new ApiTokenCollection($tokens); return new ApiTokenCollection($tokens);

View File

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

View File

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

View File

@@ -60,7 +60,9 @@ class ProjectController extends Controller
$projectsQuery->whereNull('archived_at'); $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; $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() $projectMembers = ProjectMember::query()
->whereBelongsTo($project, 'project') ->whereBelongsTo($project, 'project')
->orderBy('created_at', 'desc')
->paginate(config('app.pagination_per_page_default')); ->paginate(config('app.pagination_per_page_default'));
return new ProjectMemberCollection($projectMembers); return new ProjectMemberCollection($projectMembers);

View File

@@ -82,7 +82,9 @@ class TaskController extends Controller
$query->whereNull('done_at'); $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); 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 public function test_store_endpoint_creates_new_api_token(): void
{ {
// Arrange // 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 public function test_store_fails_if_user_has_no_permission_to_create_invitations(): void
{ {
// Arrange // Arrange

View File

@@ -52,6 +52,38 @@ class MemberEndpointTest extends ApiEndpointTestAbstract
$response->assertStatus(200); $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 public function test_update_member_fails_if_user_has_no_permission_to_update_members(): void
{ {
// Arrange // Arrange

View File

@@ -54,6 +54,33 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
$response->assertJsonCount(4, 'data'); $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 public function test_index_endpoint_without_filter_archived_returns_only_non_archived_projects(): void
{ {
// Arrange // Arrange
@@ -211,10 +238,10 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract
->has('data') ->has('data')
->has('links') ->has('links')
->has('meta') ->has('meta')
->where('data.0.billable_rate', 112) ->where('data.0.billable_rate', 113)
->where('data.1.billable_rate', 112) ->where('data.1.billable_rate', 113)
->where('data.2.billable_rate', 113) ->where('data.2.billable_rate', 112)
->where('data.3.billable_rate', 113) ->where('data.3.billable_rate', 112)
); );
} }

View File

@@ -80,6 +80,36 @@ class ProjectMemberEndpointTest extends ApiEndpointTestAbstract
$response->assertJsonCount(4, 'data'); $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 public function test_store_endpoint_fails_if_user_has_no_permission_to_add_members_to_project(): void
{ {
// Arrange // Arrange

View File

@@ -78,6 +78,33 @@ class TaskEndpointTest extends ApiEndpointTestAbstract
$response->assertJsonCount(4, 'data'); $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 public function test_index_endpoint_without_filter_done_returns_list_of_all_tasks_of_organization(): void
{ {
// Arrange // Arrange