mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-08 00:02:15 +01:00
Add skip and meta to resource in time entry endpoint
This commit is contained in:
committed by
Gregor Vostrak
parent
3ee7839ca9
commit
a882ec6ca0
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Extensions\Scramble;
|
namespace App\Extensions\Scramble;
|
||||||
|
|
||||||
use App\Http\Resources\PaginatedResourceCollection;
|
use App\Http\Resources\PaginatedResourceCollection;
|
||||||
|
use App\Http\Resources\V1\TimeEntry\TimeEntryCollection;
|
||||||
use Dedoc\Scramble\Extensions\TypeToSchemaExtension;
|
use Dedoc\Scramble\Extensions\TypeToSchemaExtension;
|
||||||
use Dedoc\Scramble\Support\Generator\Response;
|
use Dedoc\Scramble\Support\Generator\Response;
|
||||||
use Dedoc\Scramble\Support\Generator\Schema;
|
use Dedoc\Scramble\Support\Generator\Schema;
|
||||||
@@ -44,39 +45,49 @@ class PaginatedResourceCollectionTypeToSchema extends TypeToSchemaExtension
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$type = new OpenApiObjectType;
|
$newType = new OpenApiObjectType;
|
||||||
$type->addProperty('data', (new ArrayType)->setItems($collectingType));
|
$newType->addProperty('data', (new ArrayType)->setItems($collectingType));
|
||||||
$type->addProperty(
|
if ($type instanceof ObjectType && $type->isInstanceOf(TimeEntryCollection::class)) {
|
||||||
'links',
|
$newType->addProperty(
|
||||||
(new OpenApiObjectType)
|
'meta',
|
||||||
->addProperty('first', (new StringType)->nullable(true))
|
(new OpenApiObjectType)
|
||||||
->addProperty('last', (new StringType)->nullable(true))
|
->addProperty('total', (new IntegerType)->setDescription('Total number of items being paginated.'))
|
||||||
->addProperty('prev', (new StringType)->nullable(true))
|
->setRequired(['total'])
|
||||||
->addProperty('next', (new StringType)->nullable(true))
|
);
|
||||||
->setRequired(['first', 'last', 'prev', 'next'])
|
$newType->setRequired(['data', 'meta']);
|
||||||
);
|
} else {
|
||||||
$type->addProperty(
|
$newType->addProperty(
|
||||||
'meta',
|
'links',
|
||||||
(new OpenApiObjectType)
|
(new OpenApiObjectType)
|
||||||
->addProperty('current_page', new IntegerType)
|
->addProperty('first', (new StringType)->nullable(true))
|
||||||
->addProperty('from', (new IntegerType)->nullable(true))
|
->addProperty('last', (new StringType)->nullable(true))
|
||||||
->addProperty('last_page', new IntegerType)
|
->addProperty('prev', (new StringType)->nullable(true))
|
||||||
->addProperty('links', (new ArrayType)->setItems(
|
->addProperty('next', (new StringType)->nullable(true))
|
||||||
(new OpenApiObjectType)
|
->setRequired(['first', 'last', 'prev', 'next'])
|
||||||
->addProperty('url', (new StringType)->nullable(true))
|
);
|
||||||
->addProperty('label', new StringType)
|
$newType->addProperty(
|
||||||
->addProperty('active', new BooleanType)
|
'meta',
|
||||||
->setRequired(['url', 'label', 'active'])
|
(new OpenApiObjectType)
|
||||||
)->setDescription('Generated paginator links.'))
|
->addProperty('current_page', new IntegerType)
|
||||||
->addProperty('path', (new StringType)->nullable(true)->setDescription('Base path for paginator generated URLs.'))
|
->addProperty('from', (new IntegerType)->nullable(true))
|
||||||
->addProperty('per_page', (new IntegerType)->setDescription('Number of items shown per page.'))
|
->addProperty('last_page', new IntegerType)
|
||||||
->addProperty('to', (new IntegerType)->nullable(true)->setDescription('Number of the last item in the slice.'))
|
->addProperty('links', (new ArrayType)->setItems(
|
||||||
->addProperty('total', (new IntegerType)->setDescription('Total number of items being paginated.'))
|
(new OpenApiObjectType)
|
||||||
->setRequired(['current_page', 'from', 'last_page', 'links', 'path', 'per_page', 'to', 'total'])
|
->addProperty('url', (new StringType)->nullable(true))
|
||||||
);
|
->addProperty('label', new StringType)
|
||||||
$type->setRequired(['data', 'links', 'meta']);
|
->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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ class TimeEntryController extends Controller
|
|||||||
* If you only need time entries for a specific user, you can filter by `user_id`.
|
* 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.
|
* 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<TimeEntryResource>
|
||||||
|
*
|
||||||
* @throws AuthorizationException
|
* @throws AuthorizationException
|
||||||
*
|
*
|
||||||
* @operationId getTimeEntries
|
* @operationId getTimeEntries
|
||||||
@@ -76,11 +78,14 @@ class TimeEntryController extends Controller
|
|||||||
$filter->addClientIdsFilter($request->input('client_ids'));
|
$filter->addClientIdsFilter($request->input('client_ids'));
|
||||||
$filter->addBillableFilter($request->input('billable'));
|
$filter->addBillableFilter($request->input('billable'));
|
||||||
|
|
||||||
$limit = $request->has('limit') ? (int) $request->input('limit', 100) : 100;
|
$totalCount = $timeEntriesQuery->count();
|
||||||
|
|
||||||
|
$limit = $request->getLimit();
|
||||||
if ($limit > 1000) {
|
if ($limit > 1000) {
|
||||||
$limit = 1000;
|
$limit = 1000;
|
||||||
}
|
}
|
||||||
$timeEntriesQuery->limit($limit);
|
$timeEntriesQuery->limit($limit);
|
||||||
|
$timeEntriesQuery->skip($request->getSkip());
|
||||||
|
|
||||||
$timeEntries = $timeEntriesQuery->get();
|
$timeEntries = $timeEntriesQuery->get();
|
||||||
|
|
||||||
@@ -114,7 +119,12 @@ class TimeEntryController extends Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return new TimeEntryCollection($timeEntries);
|
return (new TimeEntryCollection($timeEntries))
|
||||||
|
->additional([
|
||||||
|
'meta' => [
|
||||||
|
'total' => $totalCount,
|
||||||
|
],
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -131,6 +131,11 @@ class TimeEntryIndexRequest extends FormRequest
|
|||||||
'min:1',
|
'min:1',
|
||||||
'max:500',
|
'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
|
// Filter makes sure that only time entries of a whole date are returned
|
||||||
'only_full_dates' => [
|
'only_full_dates' => [
|
||||||
'string',
|
'string',
|
||||||
@@ -143,4 +148,14 @@ class TimeEntryIndexRequest extends FormRequest
|
|||||||
{
|
{
|
||||||
return $this->input('only_full_dates', 'false') === 'true';
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Resources\V1\TimeEntry;
|
namespace App\Http\Resources\V1\TimeEntry;
|
||||||
|
|
||||||
|
use App\Http\Resources\PaginatedResourceCollection;
|
||||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||||
|
|
||||||
class TimeEntryCollection extends ResourceCollection
|
class TimeEntryCollection extends ResourceCollection implements PaginatedResourceCollection
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* The resource that this resource collects.
|
* The resource that this resource collects.
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ use Illuminate\Support\Str;
|
|||||||
use Illuminate\Testing\Fluent\AssertableJson;
|
use Illuminate\Testing\Fluent\AssertableJson;
|
||||||
use Laravel\Passport\Passport;
|
use Laravel\Passport\Passport;
|
||||||
use PHPUnit\Framework\Attributes\UsesClass;
|
use PHPUnit\Framework\Attributes\UsesClass;
|
||||||
|
use Ramsey\Uuid\Type\Time;
|
||||||
use TiMacDonald\Log\LogEntry;
|
use TiMacDonald\Log\LogEntry;
|
||||||
|
|
||||||
#[UsesClass(TimeEntryController::class)]
|
#[UsesClass(TimeEntryController::class)]
|
||||||
@@ -166,6 +167,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
// Assert
|
// Assert
|
||||||
$response->assertStatus(200);
|
$response->assertStatus(200);
|
||||||
$response->assertJsonCount(1, 'data');
|
$response->assertJsonCount(1, 'data');
|
||||||
|
$response->assertJsonPath('meta.total', 1);
|
||||||
$response->assertJsonPath('data.0.id', $activeTimeEntry->getKey());
|
$response->assertJsonPath('data.0.id', $activeTimeEntry->getKey());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,13 +191,13 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
// Assert
|
// Assert
|
||||||
$response->assertStatus(200);
|
$response->assertStatus(200);
|
||||||
$response->assertJsonCount(1, 'data');
|
$response->assertJsonCount(1, 'data');
|
||||||
|
$response->assertJsonPath('meta.total', 1);
|
||||||
$response->assertJsonPath('data.0.id', $nonActiveTimeEntries->getKey());
|
$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
|
public function test_index_endpoint_filter_only_full_dates_returns_time_entries_for_the_whole_day_case_less_time_entries_than_limit(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|
||||||
$data = $this->createUserWithPermission([
|
$data = $this->createUserWithPermission([
|
||||||
'time-entries:view:own',
|
'time-entries:view:own',
|
||||||
]);
|
]);
|
||||||
@@ -213,6 +215,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
// Assert
|
// Assert
|
||||||
$response->assertStatus(200);
|
$response->assertStatus(200);
|
||||||
$response->assertJsonCount(3, 'data');
|
$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
|
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
|
// Assert
|
||||||
$response->assertStatus(200);
|
$response->assertStatus(200);
|
||||||
$response->assertJsonCount(3, 'data');
|
$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
|
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
|
// Assert
|
||||||
$response->assertStatus(200);
|
$response->assertStatus(200);
|
||||||
$response->assertJsonCount(2, 'data');
|
$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
|
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
|
// Assert
|
||||||
$response->assertStatus(200);
|
$response->assertStatus(200);
|
||||||
$response->assertJsonCount(7, 'data');
|
$response->assertJsonCount(7, 'data');
|
||||||
|
$response->assertJsonPath('meta.total', 10);
|
||||||
Log::assertLogged(fn (LogEntry $log) => $log->level === 'warning'
|
Log::assertLogged(fn (LogEntry $log) => $log->level === 'warning'
|
||||||
&& $log->message === 'User has has more than 5 time entries on one date'
|
&& $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->assertStatus(200);
|
||||||
$response->assertJson(fn (AssertableJson $json) => $json
|
$response->assertJson(fn (AssertableJson $json) => $json
|
||||||
->has('data')
|
->has('data')
|
||||||
|
->has('meta')
|
||||||
|
->where('meta.total', 4)
|
||||||
->count('data', 4)
|
->count('data', 4)
|
||||||
->where('data.0.id', $timeEntriesDirectlyBeforeLimit->getKey())
|
->where('data.0.id', $timeEntriesDirectlyBeforeLimit->getKey())
|
||||||
->where('data.1.id', $timeEntriesBeforeSorted->get(0)->getKey())
|
->where('data.1.id', $timeEntriesBeforeSorted->get(0)->getKey())
|
||||||
@@ -400,6 +408,8 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
$response->assertStatus(200);
|
$response->assertStatus(200);
|
||||||
$response->assertJson(fn (AssertableJson $json) => $json
|
$response->assertJson(fn (AssertableJson $json) => $json
|
||||||
->has('data')
|
->has('data')
|
||||||
|
->has('meta')
|
||||||
|
->where('meta.total', 4)
|
||||||
->count('data', 4)
|
->count('data', 4)
|
||||||
->where('data.0.id', $timeEntriesAfterSorted->get(0)->getKey())
|
->where('data.0.id', $timeEntriesAfterSorted->get(0)->getKey())
|
||||||
->where('data.1.id', $timeEntriesAfterSorted->get(1)->getKey())
|
->where('data.1.id', $timeEntriesAfterSorted->get(1)->getKey())
|
||||||
@@ -451,11 +461,50 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
|
|||||||
$response->assertStatus(200);
|
$response->assertStatus(200);
|
||||||
$response->assertJson(fn (AssertableJson $json) => $json
|
$response->assertJson(fn (AssertableJson $json) => $json
|
||||||
->has('data')
|
->has('data')
|
||||||
|
->has('meta')
|
||||||
|
->where('meta.total', 1)
|
||||||
->count('data', 1)
|
->count('data', 1)
|
||||||
->where('data.0.id', $timeEntry1->getKey())
|
->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
|
public function test_aggregate_endpoint_fails_if_user_has_no_permission_to_view_time_entries(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|||||||
Reference in New Issue
Block a user