diff --git a/app/Extensions/Scramble/PaginatedResourceCollectionTypeToSchema.php b/app/Extensions/Scramble/PaginatedResourceCollectionTypeToSchema.php index 6767e698..484f73f1 100644 --- a/app/Extensions/Scramble/PaginatedResourceCollectionTypeToSchema.php +++ b/app/Extensions/Scramble/PaginatedResourceCollectionTypeToSchema.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Extensions\Scramble; use App\Http\Resources\PaginatedResourceCollection; +use App\Http\Resources\V1\TimeEntry\TimeEntryCollection; use Dedoc\Scramble\Extensions\TypeToSchemaExtension; use Dedoc\Scramble\Support\Generator\Response; use Dedoc\Scramble\Support\Generator\Schema; @@ -44,39 +45,49 @@ class PaginatedResourceCollectionTypeToSchema extends TypeToSchemaExtension return null; } - $type = new OpenApiObjectType; - $type->addProperty('data', (new ArrayType)->setItems($collectingType)); - $type->addProperty( - 'links', - (new OpenApiObjectType) - ->addProperty('first', (new StringType)->nullable(true)) - ->addProperty('last', (new StringType)->nullable(true)) - ->addProperty('prev', (new StringType)->nullable(true)) - ->addProperty('next', (new StringType)->nullable(true)) - ->setRequired(['first', 'last', 'prev', 'next']) - ); - $type->addProperty( - 'meta', - (new OpenApiObjectType) - ->addProperty('current_page', new IntegerType) - ->addProperty('from', (new IntegerType)->nullable(true)) - ->addProperty('last_page', new IntegerType) - ->addProperty('links', (new ArrayType)->setItems( - (new OpenApiObjectType) - ->addProperty('url', (new StringType)->nullable(true)) - ->addProperty('label', new StringType) - ->addProperty('active', new BooleanType) - ->setRequired(['url', 'label', 'active']) - )->setDescription('Generated paginator links.')) - ->addProperty('path', (new StringType)->nullable(true)->setDescription('Base path for paginator generated URLs.')) - ->addProperty('per_page', (new IntegerType)->setDescription('Number of items shown per page.')) - ->addProperty('to', (new IntegerType)->nullable(true)->setDescription('Number of the last item in the slice.')) - ->addProperty('total', (new IntegerType)->setDescription('Total number of items being paginated.')) - ->setRequired(['current_page', 'from', 'last_page', 'links', 'path', 'per_page', 'to', 'total']) - ); - $type->setRequired(['data', 'links', 'meta']); + $newType = new OpenApiObjectType; + $newType->addProperty('data', (new ArrayType)->setItems($collectingType)); + if ($type instanceof ObjectType && $type->isInstanceOf(TimeEntryCollection::class)) { + $newType->addProperty( + 'meta', + (new OpenApiObjectType) + ->addProperty('total', (new IntegerType)->setDescription('Total number of items being paginated.')) + ->setRequired(['total']) + ); + $newType->setRequired(['data', 'meta']); + } else { + $newType->addProperty( + 'links', + (new OpenApiObjectType) + ->addProperty('first', (new StringType)->nullable(true)) + ->addProperty('last', (new StringType)->nullable(true)) + ->addProperty('prev', (new StringType)->nullable(true)) + ->addProperty('next', (new StringType)->nullable(true)) + ->setRequired(['first', 'last', 'prev', 'next']) + ); + $newType->addProperty( + 'meta', + (new OpenApiObjectType) + ->addProperty('current_page', new IntegerType) + ->addProperty('from', (new IntegerType)->nullable(true)) + ->addProperty('last_page', new IntegerType) + ->addProperty('links', (new ArrayType)->setItems( + (new OpenApiObjectType) + ->addProperty('url', (new StringType)->nullable(true)) + ->addProperty('label', new StringType) + ->addProperty('active', new BooleanType) + ->setRequired(['url', 'label', 'active']) + )->setDescription('Generated paginator links.')) + ->addProperty('path', (new StringType)->nullable(true)->setDescription('Base path for paginator generated URLs.')) + ->addProperty('per_page', (new IntegerType)->setDescription('Number of items shown per page.')) + ->addProperty('to', (new IntegerType)->nullable(true)->setDescription('Number of the last item in the slice.')) + ->addProperty('total', (new IntegerType)->setDescription('Total number of items being paginated.')) + ->setRequired(['current_page', 'from', 'last_page', 'links', 'path', 'per_page', 'to', 'total']) + ); + $newType->setRequired(['data', 'links', 'meta']); + } - return $type; + return $newType; } /** diff --git a/app/Http/Controllers/Api/V1/TimeEntryController.php b/app/Http/Controllers/Api/V1/TimeEntryController.php index 308c8577..475e6047 100644 --- a/app/Http/Controllers/Api/V1/TimeEntryController.php +++ b/app/Http/Controllers/Api/V1/TimeEntryController.php @@ -46,6 +46,8 @@ class TimeEntryController extends Controller * If you only need time entries for a specific user, you can filter by `user_id`. * Users with the permission `time-entries:view:own` can only use this endpoint with their own user ID in the user_id filter. * + * @return TimeEntryCollection + * * @throws AuthorizationException * * @operationId getTimeEntries @@ -76,11 +78,14 @@ class TimeEntryController extends Controller $filter->addClientIdsFilter($request->input('client_ids')); $filter->addBillableFilter($request->input('billable')); - $limit = $request->has('limit') ? (int) $request->input('limit', 100) : 100; + $totalCount = $timeEntriesQuery->count(); + + $limit = $request->getLimit(); if ($limit > 1000) { $limit = 1000; } $timeEntriesQuery->limit($limit); + $timeEntriesQuery->skip($request->getSkip()); $timeEntries = $timeEntriesQuery->get(); @@ -114,7 +119,12 @@ class TimeEntryController extends Controller } } - return new TimeEntryCollection($timeEntries); + return (new TimeEntryCollection($timeEntries)) + ->additional([ + 'meta' => [ + 'total' => $totalCount, + ], + ]); } /** diff --git a/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php b/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php index 972bb0f1..22e7a954 100644 --- a/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php +++ b/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php @@ -131,6 +131,11 @@ class TimeEntryIndexRequest extends FormRequest 'min:1', 'max:500', ], + // Skip the first n time entries (default: 0) + 'skip' => [ + 'integer', + 'min:0', + ], // Filter makes sure that only time entries of a whole date are returned 'only_full_dates' => [ 'string', @@ -143,4 +148,14 @@ class TimeEntryIndexRequest extends FormRequest { return $this->input('only_full_dates', 'false') === 'true'; } + + public function getLimit(): int + { + return $this->has('limit') ? (int) $this->validated('limit', 100) : 100; + } + + public function getSkip(): int + { + return $this->has('skip') ? (int) $this->validated('skip', 0) : 0; + } } diff --git a/app/Http/Resources/V1/TimeEntry/TimeEntryCollection.php b/app/Http/Resources/V1/TimeEntry/TimeEntryCollection.php index 0dba3161..f3e2bebb 100644 --- a/app/Http/Resources/V1/TimeEntry/TimeEntryCollection.php +++ b/app/Http/Resources/V1/TimeEntry/TimeEntryCollection.php @@ -4,9 +4,10 @@ declare(strict_types=1); namespace App\Http\Resources\V1\TimeEntry; +use App\Http\Resources\PaginatedResourceCollection; use Illuminate\Http\Resources\Json\ResourceCollection; -class TimeEntryCollection extends ResourceCollection +class TimeEntryCollection extends ResourceCollection implements PaginatedResourceCollection { /** * The resource that this resource collects. diff --git a/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php b/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php index 23a6d958..628a6abf 100644 --- a/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php @@ -23,6 +23,7 @@ use Illuminate\Support\Str; use Illuminate\Testing\Fluent\AssertableJson; use Laravel\Passport\Passport; use PHPUnit\Framework\Attributes\UsesClass; +use Ramsey\Uuid\Type\Time; use TiMacDonald\Log\LogEntry; #[UsesClass(TimeEntryController::class)] @@ -166,6 +167,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract // Assert $response->assertStatus(200); $response->assertJsonCount(1, 'data'); + $response->assertJsonPath('meta.total', 1); $response->assertJsonPath('data.0.id', $activeTimeEntry->getKey()); } @@ -189,13 +191,13 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract // Assert $response->assertStatus(200); $response->assertJsonCount(1, 'data'); + $response->assertJsonPath('meta.total', 1); $response->assertJsonPath('data.0.id', $nonActiveTimeEntries->getKey()); } public function test_index_endpoint_filter_only_full_dates_returns_time_entries_for_the_whole_day_case_less_time_entries_than_limit(): void { // Arrange - $data = $this->createUserWithPermission([ 'time-entries:view:own', ]); @@ -213,6 +215,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract // Assert $response->assertStatus(200); $response->assertJsonCount(3, 'data'); + $response->assertJsonPath('meta.total', 3); } public function test_index_endpoint_filter_only_full_dates_returns_time_entries_for_the_whole_day_case_more_time_entries_than_limit(): void @@ -240,6 +243,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract // Assert $response->assertStatus(200); $response->assertJsonCount(3, 'data'); + $response->assertJsonPath('meta.total', 6); } public function test_index_endpoint_filter_only_full_dates_returns_time_entries_for_the_whole_day_case_more_time_entries_than_limit_with_a_timezone_edge_case(): void @@ -288,6 +292,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract // Assert $response->assertStatus(200); $response->assertJsonCount(2, 'data'); + $response->assertJsonPath('meta.total', 7); } public function test_index_endpoint_filter_only_full_dates_returns_time_entries_for_the_whole_day_case_more_time_entries_in_latest_day_than_limit(): void @@ -321,6 +326,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract // Assert $response->assertStatus(200); $response->assertJsonCount(7, 'data'); + $response->assertJsonPath('meta.total', 10); Log::assertLogged(fn (LogEntry $log) => $log->level === 'warning' && $log->message === 'User has has more than 5 time entries on one date' ); @@ -362,6 +368,8 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract $response->assertStatus(200); $response->assertJson(fn (AssertableJson $json) => $json ->has('data') + ->has('meta') + ->where('meta.total', 4) ->count('data', 4) ->where('data.0.id', $timeEntriesDirectlyBeforeLimit->getKey()) ->where('data.1.id', $timeEntriesBeforeSorted->get(0)->getKey()) @@ -400,6 +408,8 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract $response->assertStatus(200); $response->assertJson(fn (AssertableJson $json) => $json ->has('data') + ->has('meta') + ->where('meta.total', 4) ->count('data', 4) ->where('data.0.id', $timeEntriesAfterSorted->get(0)->getKey()) ->where('data.1.id', $timeEntriesAfterSorted->get(1)->getKey()) @@ -451,11 +461,50 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract $response->assertStatus(200); $response->assertJson(fn (AssertableJson $json) => $json ->has('data') + ->has('meta') + ->where('meta.total', 1) ->count('data', 1) ->where('data.0.id', $timeEntry1->getKey()) ); } + public function test_index_endpoint_with_limit_skip_and_only_full_dates_deactivated(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'time-entries:view:own', + ]); + $project1 = Project::factory()->forOrganization($data->organization)->create(); + $project2 = Project::factory()->forOrganization($data->organization)->create(); + TimeEntry::factory()->forMember($data->member)->forProject($project1)->forOrganization($data->organization)->create([ + 'start' => Carbon::now()->subDays(2), + ]); + $timeEntry = TimeEntry::factory()->forMember($data->member)->forProject($project1)->forOrganization($data->organization)->create([ + 'start' => Carbon::now()->subDays(3), + ]); + TimeEntry::factory()->forMember($data->member)->forProject($project1)->forOrganization($data->organization)->create([ + 'start' => Carbon::now()->subDays(4), + ]); + TimeEntry::factory()->forMember($data->member)->forProject($project2)->forOrganization($data->organization)->create(); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.time-entries.index', [ + $data->organization->getKey(), + 'member_id' => $data->member->getKey(), + 'project_ids' => [$project1->getKey()], + 'limit' => 1, + 'skip' => 1, + 'only_full_dates' => 'false', + ])); + + // Assert + $response->assertStatus(200); + $response->assertJsonCount(1, 'data'); + $response->assertJsonPath('meta.total', 3); + $response->assertJsonPath('data.*.id', [$timeEntry->getKey()]); + } + public function test_aggregate_endpoint_fails_if_user_has_no_permission_to_view_time_entries(): void { // Arrange