From 5e8510ade0c71331686ed75e78d45549b2884568 Mon Sep 17 00:00:00 2001 From: Constantin Graf Date: Fri, 8 Nov 2024 13:27:51 +0100 Subject: [PATCH] Added shareable reports --- app/Enums/Weekday.php | 3 + .../Controllers/Api/V1/ProjectController.php | 4 + .../Api/V1/Public/ReportController.php | 50 ++++++- .../Controllers/Api/V1/ReportController.php | 30 ++++- .../V1/Project/ProjectStoreRequest.php | 9 ++ .../V1/Project/ProjectUpdateRequest.php | 3 + .../Requests/V1/Report/ReportStoreRequest.php | 31 ++++- .../TimeEntryAggregateExportRequest.php | 6 +- .../TimeEntry/TimeEntryAggregateRequest.php | 3 +- .../V1/Member/PersonalMembershipResource.php | 2 + .../V1/Organization/OrganizationResource.php | 2 + .../Resources/V1/Project/ProjectResource.php | 2 + .../V1/Report/DetailedReportResource.php | 14 ++ .../Report/DetailedWithDataReportResource.php | 123 ++++++++++++++++++ .../Resources/V1/Report/ReportResource.php | 4 + app/Models/Report.php | 2 + app/Service/Dto/ReportPropertiesDto.php | 96 ++++++++++---- app/Service/TimeEntryFilter.php | 54 +++++++- database/factories/ReportFactory.php | 5 + routes/api.php | 24 ++-- .../Endpoint/Api/V1/ReportEndpointTest.php | 12 +- .../TimeEntryAggregationServiceTest.php | 88 +++++++++++++ 22 files changed, 511 insertions(+), 56 deletions(-) create mode 100644 app/Http/Resources/V1/Report/DetailedWithDataReportResource.php diff --git a/app/Enums/Weekday.php b/app/Enums/Weekday.php index 983d2bbb..88ef2945 100644 --- a/app/Enums/Weekday.php +++ b/app/Enums/Weekday.php @@ -4,10 +4,13 @@ namespace App\Enums; +use Datomatic\LaravelEnumHelper\LaravelEnumHelper; use Illuminate\Support\Carbon; enum Weekday: string { + use LaravelEnumHelper; + case Monday = 'monday'; case Tuesday = 'tuesday'; case Wednesday = 'wednesday'; diff --git a/app/Http/Controllers/Api/V1/ProjectController.php b/app/Http/Controllers/Api/V1/ProjectController.php index 0f9e5bb9..72ee24f7 100644 --- a/app/Http/Controllers/Api/V1/ProjectController.php +++ b/app/Http/Controllers/Api/V1/ProjectController.php @@ -102,6 +102,7 @@ public function store(Organization $organization, ProjectStoreRequest $request): $project->is_billable = (bool) $request->input('is_billable'); $project->billable_rate = $request->getBillableRate(); $project->client_id = $request->input('client_id'); + $project->is_public = $request->getIsPublic(); if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) { $project->estimated_time = $request->getEstimatedTime(); } @@ -127,6 +128,9 @@ public function update(Organization $organization, Project $project, ProjectUpda if ($request->has('is_archived')) { $project->archived_at = $request->getIsArchived() ? Carbon::now() : null; } + if ($request->has('is_public')) { + $project->is_public = $request->boolean('is_public'); + } if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) { $project->estimated_time = $request->getEstimatedTime(); } diff --git a/app/Http/Controllers/Api/V1/Public/ReportController.php b/app/Http/Controllers/Api/V1/Public/ReportController.php index 31e422da..4ce4c0c0 100644 --- a/app/Http/Controllers/Api/V1/Public/ReportController.php +++ b/app/Http/Controllers/Api/V1/Public/ReportController.php @@ -4,9 +4,13 @@ namespace App\Http\Controllers\Api\V1\Public; +use App\Enums\TimeEntryAggregationType; use App\Http\Controllers\Api\V1\Controller; -use App\Http\Resources\V1\Report\DetailedReportResource; +use App\Http\Resources\V1\Report\DetailedWithDataReportResource; use App\Models\Report; +use App\Models\TimeEntry; +use App\Service\TimeEntryAggregationService; +use App\Service\TimeEntryFilter; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\Request; @@ -22,7 +26,7 @@ class ReportController extends Controller * * @operationId getPublicReport */ - public function show(Request $request): DetailedReportResource + public function show(Request $request, TimeEntryAggregationService $timeEntryAggregationService): DetailedWithDataReportResource { $shareSecret = $request->header('X-Api-Key'); if (! is_string($shareSecret)) { @@ -30,6 +34,9 @@ public function show(Request $request): DetailedReportResource } $report = Report::query() + ->with([ + 'organization', + ]) ->where('share_secret', '=', $shareSecret) ->where('is_public', '=', true) ->where(function (Builder $builder): void { @@ -38,7 +45,44 @@ public function show(Request $request): DetailedReportResource ->orWhere('public_until', '>', now()); }) ->firstOrFail(); + $properties = $report->properties; - return new DetailedReportResource($report); + $timeEntriesQuery = TimeEntry::query() + ->whereBelongsTo($report->organization, 'organization'); + + $filter = new TimeEntryFilter($timeEntriesQuery); + $filter->addStart($properties->start); + $filter->addEnd($properties->end); + $filter->addActive($properties->active); + $filter->addBillable($properties->billable); + $filter->addMemberIdsFilter($request->input('member_ids')); + $filter->addProjectIdsFilter($request->input('project_ids')); + $filter->addTagIdsFilter($request->input('tag_ids')); + $filter->addTaskIdsFilter($request->input('task_ids')); + $filter->addClientIdsFilter($request->input('client_ids')); + $timeEntriesQuery = $filter->get(); + + $data = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions( + $timeEntriesQuery->clone(), + $report->properties->group, + $report->properties->subGroup, + $report->properties->timezone, + $report->properties->weekStart, + false, + $report->properties->start, + $report->properties->end, + ); + $historyData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions( + $timeEntriesQuery->clone(), + TimeEntryAggregationType::fromInterval($report->properties->historyGroup), + null, + $report->properties->timezone, + $report->properties->weekStart, + true, + $report->properties->start, + $report->properties->end, + ); + + return new DetailedWithDataReportResource($report, $data, $historyData); } } diff --git a/app/Http/Controllers/Api/V1/ReportController.php b/app/Http/Controllers/Api/V1/ReportController.php index 5b4e85c1..7de6fe5a 100644 --- a/app/Http/Controllers/Api/V1/ReportController.php +++ b/app/Http/Controllers/Api/V1/ReportController.php @@ -5,14 +5,18 @@ namespace App\Http\Controllers\Api\V1; use App\Enums\TimeEntryAggregationType; +use App\Enums\TimeEntryAggregationTypeInterval; +use App\Enums\Weekday; use App\Http\Requests\V1\Report\ReportStoreRequest; use App\Http\Requests\V1\Report\ReportUpdateRequest; use App\Http\Resources\V1\Report\DetailedReportResource; use App\Http\Resources\V1\Report\ReportCollection; +use App\Http\Resources\V1\Report\ReportResource; use App\Models\Organization; use App\Models\Report; use App\Service\Dto\ReportPropertiesDto; use App\Service\ReportService; +use App\Service\TimezoneService; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Http\JsonResponse; @@ -32,6 +36,8 @@ protected function checkPermission(Organization $organization, string $permissio /** * Get reports * + * @return ReportCollection + * * @throws AuthorizationException * * @operationId getReports @@ -69,9 +75,10 @@ public function show(Organization $organization, Report $report): DetailedReport * * @operationId createReport */ - public function store(Organization $organization, ReportStoreRequest $request): DetailedReportResource + public function store(Organization $organization, ReportStoreRequest $request, TimezoneService $timezoneService): DetailedReportResource { $this->checkPermission($organization, 'reports:create'); + $user = $this->user(); $report = new Report; $report->name = $request->getName(); @@ -81,6 +88,27 @@ public function store(Organization $organization, ReportStoreRequest $request): $properties = new ReportPropertiesDto; $properties->group = TimeEntryAggregationType::from($request->input('properties.group')); $properties->subGroup = TimeEntryAggregationType::from($request->input('properties.sub_group')); + $properties->historyGroup = TimeEntryAggregationTypeInterval::from($request->input('properties.history_group')); + $properties->start = $request->getPropertyStart(); + $properties->end = $request->getPropertyEnd(); + $properties->active = $request->boolean('properties.active', null); + $properties->setMemberIds($request->input('properties.member_ids', null)); + $properties->billable = $request->boolean('properties.billable', null); + $properties->setClientIds($request->input('properties.client_ids', null)); + $properties->setProjectIds($request->input('properties.project_ids', null)); + $properties->setTagIds($request->input('properties.tag_ids', null)); + $properties->setTaskIds($request->input('properties.task_ids', null)); + $properties->weekStart = $request->has('properties.week_start') ? Weekday::from($request->input('properties.week_start')) : $user->week_start; + $timezone = $user->timezone; + if ($request->has('properties.timezone')) { + if ($timezoneService->isValid($request->input('properties.timezone'))) { + $timezone = $request->input('properties.timezone'); + } + if ($timezoneService->mapLegacyTimezone($request->input('properties.timezone')) !== null) { + $timezone = $timezoneService->mapLegacyTimezone($request->input('properties.timezone')); + } + } + $properties->timezone = $timezone; $report->properties = $properties; if ($isPublic) { $report->share_secret = app(ReportService::class)->generateSecret(); diff --git a/app/Http/Requests/V1/Project/ProjectStoreRequest.php b/app/Http/Requests/V1/Project/ProjectStoreRequest.php index 5a4795a6..e4e00cd8 100644 --- a/app/Http/Requests/V1/Project/ProjectStoreRequest.php +++ b/app/Http/Requests/V1/Project/ProjectStoreRequest.php @@ -68,9 +68,18 @@ public function rules(): array 'min:0', 'max:2147483647', ], + // Whether the project is public + 'is_public' => [ + 'boolean', + ], ]; } + public function getIsPublic(): bool + { + return $this->has('is_public') && $this->boolean('is_public'); + } + public function getBillableRate(): ?int { $input = $this->input('billable_rate'); diff --git a/app/Http/Requests/V1/Project/ProjectUpdateRequest.php b/app/Http/Requests/V1/Project/ProjectUpdateRequest.php index e50fdb34..3fdd80f7 100644 --- a/app/Http/Requests/V1/Project/ProjectUpdateRequest.php +++ b/app/Http/Requests/V1/Project/ProjectUpdateRequest.php @@ -50,6 +50,9 @@ public function rules(): array 'is_archived' => [ 'boolean', ], + 'is_public' => [ + 'boolean', + ], 'client_id' => [ 'nullable', ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder { diff --git a/app/Http/Requests/V1/Report/ReportStoreRequest.php b/app/Http/Requests/V1/Report/ReportStoreRequest.php index bdcaa25f..ba154ce0 100644 --- a/app/Http/Requests/V1/Report/ReportStoreRequest.php +++ b/app/Http/Requests/V1/Report/ReportStoreRequest.php @@ -5,6 +5,8 @@ namespace App\Http\Requests\V1\Report; use App\Enums\TimeEntryAggregationType; +use App\Enums\TimeEntryAggregationTypeInterval; +use App\Enums\Weekday; use App\Models\Organization; use Illuminate\Contracts\Validation\Rule as LegacyValidationRule; use Illuminate\Contracts\Validation\ValidationRule; @@ -80,6 +82,7 @@ public function rules(): array 'string', 'uuid', ], + // Filter by project IDs, project IDs are OR combined 'properties.project_ids' => [ 'nullable', 'array', @@ -88,6 +91,7 @@ public function rules(): array 'string', 'uuid', ], + // Filter by tag IDs, tag IDs are OR combined 'properties.tag_ids' => [ 'nullable', 'array', @@ -108,11 +112,22 @@ public function rules(): array 'required', Rule::enum(TimeEntryAggregationType::class), ], - 'properties.sub_group' => [ 'required', Rule::enum(TimeEntryAggregationType::class), ], + 'properties.history_group' => [ + 'required', + Rule::enum(TimeEntryAggregationTypeInterval::class), + ], + 'properties.week_start' => [ + 'nullable', + Rule::enum(Weekday::class), + ], + 'properties.timezone' => [ + 'nullable', + 'timezone:all', + ], ]; } @@ -137,4 +152,18 @@ public function getPublicUntil(): ?Carbon return $publicUntil === null ? null : Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $publicUntil); } + + public function getPropertyStart(): ?Carbon + { + $start = $this->input('properties.start'); + + return $start === null ? null : Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $start); + } + + public function getPropertyEnd(): ?Carbon + { + $end = $this->input('properties.end'); + + return $end === null ? null : Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $end); + } } diff --git a/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateExportRequest.php b/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateExportRequest.php index 0b5cc34b..4c1a75b4 100644 --- a/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateExportRequest.php +++ b/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateExportRequest.php @@ -34,21 +34,23 @@ class TimeEntryAggregateExportRequest extends FormRequest public function rules(): array { return [ + // Data format of the export 'format' => [ 'required', 'string', Rule::enum(ExportFormat::class), ], + // Type of first grouping 'group' => [ 'required', Rule::enum(TimeEntryAggregationType::class), ], - + // Type of second grouping 'sub_group' => [ 'required', Rule::enum(TimeEntryAggregationType::class), ], - + // Type of grouping of the historic aggregation (time chart) 'history_group' => [ 'required', 'nullable', diff --git a/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateRequest.php b/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateRequest.php index 01fc7f05..b9583e08 100644 --- a/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateRequest.php +++ b/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateRequest.php @@ -32,12 +32,13 @@ class TimeEntryAggregateRequest extends FormRequest public function rules(): array { return [ + // Type of first grouping 'group' => [ 'nullable', 'required_with:sub_group', Rule::enum(TimeEntryAggregationType::class), ], - + // Type of second grouping 'sub_group' => [ 'nullable', Rule::enum(TimeEntryAggregationType::class), diff --git a/app/Http/Resources/V1/Member/PersonalMembershipResource.php b/app/Http/Resources/V1/Member/PersonalMembershipResource.php index 103e9fb7..917d7c69 100644 --- a/app/Http/Resources/V1/Member/PersonalMembershipResource.php +++ b/app/Http/Resources/V1/Member/PersonalMembershipResource.php @@ -28,6 +28,8 @@ public function toArray(Request $request): array 'id' => $this->resource->organization->id, /** @var string $name Name of organization */ 'name' => $this->resource->organization->name, + /** @var string $currency Currency code (ISO 4217) of organization */ + 'currency' => $this->resource->organization->currency, ], /** @var string $role Role */ 'role' => $this->resource->role, diff --git a/app/Http/Resources/V1/Organization/OrganizationResource.php b/app/Http/Resources/V1/Organization/OrganizationResource.php index b671aa65..2d33fdc7 100644 --- a/app/Http/Resources/V1/Organization/OrganizationResource.php +++ b/app/Http/Resources/V1/Organization/OrganizationResource.php @@ -45,6 +45,8 @@ public function toArray(Request $request): array 'billable_rate' => $this->showBillableRate ? $this->resource->billable_rate : null, /** @var bool $employees_can_see_billable_rates Can members of the organization with role "employee" see the billable rates */ 'employees_can_see_billable_rates' => $this->resource->employees_can_see_billable_rates, + /** @var string $currency Currency code (ISO 4217) */ + 'currency' => $this->resource->currency, ]; } } diff --git a/app/Http/Resources/V1/Project/ProjectResource.php b/app/Http/Resources/V1/Project/ProjectResource.php index 0a2bb907..91cf27bb 100644 --- a/app/Http/Resources/V1/Project/ProjectResource.php +++ b/app/Http/Resources/V1/Project/ProjectResource.php @@ -48,6 +48,8 @@ public function toArray(Request $request): array 'estimated_time' => $this->resource->estimated_time, /** @var int $spent_time Spent time on this project in seconds (sum of the duration of all associated time entries, excl. still running time entries) */ 'spent_time' => $this->resource->spent_time, + /** @var bool $is_public Whether the project is public */ + 'is_public' => $this->resource->is_public, ]; } } diff --git a/app/Http/Resources/V1/Report/DetailedReportResource.php b/app/Http/Resources/V1/Report/DetailedReportResource.php index b576932f..122168e8 100644 --- a/app/Http/Resources/V1/Report/DetailedReportResource.php +++ b/app/Http/Resources/V1/Report/DetailedReportResource.php @@ -34,21 +34,35 @@ public function toArray(Request $request): array /** @var string|null $shareable_link Get link to access the report externally, not set if the report is private */ 'shareable_link' => $this->resource->getShareableLink(), 'properties' => [ + /** @var string $group Type of first grouping */ 'group' => $this->resource->properties->group->value, + /** @var string $sub_group Type of second grouping */ 'sub_group' => $this->resource->properties->subGroup->value, + /** @var string $history_group Type of grouping of the historic aggregation (time chart) */ + 'history_group' => $this->resource->properties->historyGroup->value, /** @var string|null $start Start date of the report */ 'start' => $this->resource->properties->start?->toIso8601ZuluString(), /** @var string|null $end End date of the report */ 'end' => $this->resource->properties->end?->toIso8601ZuluString(), /** @var bool|null $active Whether the report is active */ 'active' => $this->resource->properties->active, + /** @var array $member_ids Filter by multiple member IDs, member IDs are OR combined */ 'member_ids' => $this->resource->properties->memberIds?->toArray(), + /** @var bool $billable Filter by billable status */ 'billable' => $this->resource->properties->billable, + /** @var array $client_ids Filter by client IDs, client IDs are OR combined */ 'client_ids' => $this->resource->properties->clientIds?->toArray(), + /** @var array $project_ids Filter by project IDs, project IDs are OR combined */ 'project_ids' => $this->resource->properties->projectIds?->toArray(), + /** @var array $tags_ids Filter by tag IDs, tag IDs are OR combined */ 'tag_ids' => $this->resource->properties->tagIds?->toArray(), + /** @var array $task_ids Filter by task IDs, task IDs are OR combined */ 'task_ids' => $this->resource->properties->taskIds?->toArray(), ], + /** @var string $created_at Date when the report was created */ + 'created_at' => $this->resource->created_at?->toIso8601ZuluString(), + /** @var string $updated_at Date when the report was last updated */ + 'updated_at' => $this->resource->updated_at?->toIso8601ZuluString(), ]; } } diff --git a/app/Http/Resources/V1/Report/DetailedWithDataReportResource.php b/app/Http/Resources/V1/Report/DetailedWithDataReportResource.php new file mode 100644 index 00000000..cfd272bd --- /dev/null +++ b/app/Http/Resources/V1/Report/DetailedWithDataReportResource.php @@ -0,0 +1,123 @@ +data = $data; + $this->historyData = $historyData; + } + + /** + * Transform the resource into an array. + * + * @return array>> + */ + public function toArray(Request $request): array + { + return [ + /** @var string $id ID of the report */ + 'id' => $this->resource->id, + /** @var string $name Name */ + 'name' => $this->resource->name, + /** @var string|null $email Description */ + 'description' => $this->resource->description, + /** @var bool $is_public Whether the report can be accessed via an external link */ + 'is_public' => $this->resource->is_public, + /** @var string|null $public_until Date until the report is public */ + 'public_until' => $this->resource->public_until?->toIso8601ZuluString(), + /** @var string|null $shareable_link Get link to access the report externally, not set if the report is private */ + 'shareable_link' => $this->resource->getShareableLink(), + 'properties' => [ + /** @var string $group Type of first grouping */ + 'group' => $this->resource->properties->group->value, + /** @var string $sub_group Type of second grouping */ + 'sub_group' => $this->resource->properties->subGroup->value, + /** @var string $history_group Type of grouping of the historic aggregation (time chart) */ + 'history_group' => $this->resource->properties->historyGroup->value, + /** @var string|null $start Start date of the report */ + 'start' => $this->resource->properties->start?->toIso8601ZuluString(), + /** @var string|null $end End date of the report */ + 'end' => $this->resource->properties->end?->toIso8601ZuluString(), + /** @var bool|null $active Whether the report is active */ + 'active' => $this->resource->properties->active, + /** @var array $member_ids Filter by multiple member IDs, member IDs are OR combined */ + 'member_ids' => $this->resource->properties->memberIds?->toArray(), + /** @var bool $billable Filter by billable status */ + 'billable' => $this->resource->properties->billable, + /** @var array $client_ids Filter by client IDs, client IDs are OR combined */ + 'client_ids' => $this->resource->properties->clientIds?->toArray(), + /** @var array $project_ids Filter by project IDs, project IDs are OR combined */ + 'project_ids' => $this->resource->properties->projectIds?->toArray(), + /** @var array $tags_ids Filter by tag IDs, tag IDs are OR combined */ + 'tag_ids' => $this->resource->properties->tagIds?->toArray(), + /** @var array $task_ids Filter by task IDs, task IDs are OR combined */ + 'task_ids' => $this->resource->properties->taskIds?->toArray(), + ], + /** @var array{ + * grouped_type: string|null, + * grouped_data: null|array + * }>, + * seconds: int, + * cost: int + * } $data Aggregated data + */ + 'data' => $this->data, + /** @var array{ + * grouped_type: string|null, + * grouped_data: null|array + * }>, + * seconds: int, + * cost: int + * } $history_data Historic aggregated data + */ + 'history_data' => $this->historyData, + /** @var string $created_at Date when the report was created */ + 'created_at' => $this->resource->created_at?->toIso8601ZuluString(), + /** @var string $updated_at Date when the report was last updated */ + 'updated_at' => $this->resource->updated_at?->toIso8601ZuluString(), + ]; + } +} diff --git a/app/Http/Resources/V1/Report/ReportResource.php b/app/Http/Resources/V1/Report/ReportResource.php index e41c45c2..2eec3973 100644 --- a/app/Http/Resources/V1/Report/ReportResource.php +++ b/app/Http/Resources/V1/Report/ReportResource.php @@ -33,6 +33,10 @@ public function toArray(Request $request): array 'public_until' => $this->resource->public_until?->toIso8601ZuluString(), /** @var string|null $shareable_link Get link to access the report externally, not set if the report is private */ 'shareable_link' => $this->resource->getShareableLink(), + /** @var string $created_at Date when the report was created */ + 'created_at' => $this->resource->created_at?->toIso8601ZuluString(), + /** @var string $updated_at Date when the report was last updated */ + 'updated_at' => $this->resource->updated_at?->toIso8601ZuluString(), ]; } } diff --git a/app/Models/Report.php b/app/Models/Report.php index 9751a191..04d19e48 100644 --- a/app/Models/Report.php +++ b/app/Models/Report.php @@ -22,6 +22,8 @@ * @property string|null $share_secret * @property ReportPropertiesDto $properties * @property-read Organization $organization + * @property Carbon|null $created_at + * @property Carbon|null $updated_at * * @method static ReportFactory factory() */ diff --git a/app/Service/Dto/ReportPropertiesDto.php b/app/Service/Dto/ReportPropertiesDto.php index 6de7074f..bc6ea757 100644 --- a/app/Service/Dto/ReportPropertiesDto.php +++ b/app/Service/Dto/ReportPropertiesDto.php @@ -5,6 +5,8 @@ namespace App\Service\Dto; use App\Enums\TimeEntryAggregationType; +use App\Enums\TimeEntryAggregationTypeInterval; +use App\Enums\Weekday; use Illuminate\Contracts\Database\Eloquent\Castable; use Illuminate\Contracts\Database\Eloquent\CastsAttributes; use Illuminate\Database\Eloquent\Model; @@ -18,6 +20,12 @@ class ReportPropertiesDto implements Castable public ?TimeEntryAggregationType $subGroup = null; + public ?TimeEntryAggregationTypeInterval $historyGroup = null; + + public Weekday $weekStart; + + public string $timezone; + public ?Carbon $start = null; public ?Carbon $end = null; @@ -64,6 +72,9 @@ public static function castUsing(array $arguments): CastsAttributes private const array REQUIRED_PROPERTIES = [ 'group', 'subGroup', + 'historyGroup', + 'weekStart', + 'timezone', 'start', 'end', 'active', @@ -93,38 +104,21 @@ public function get(Model $model, string $key, mixed $value, array $attributes): $dto->end = $data->end !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $data->end) : null; $dto->start = $data->start !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $data->start) : null; $dto->active = $data->active; - $dto->memberIds = $data->memberIds !== null ? $this->idArrayToCollection($data->memberIds) : null; + $dto->memberIds = $data->memberIds !== null ? ReportPropertiesDto::idArrayToCollection($data->memberIds) : null; $dto->billable = $data->billable; - $dto->clientIds = $data->clientIds !== null ? $this->idArrayToCollection($data->clientIds) : null; - $dto->projectIds = $data->projectIds !== null ? $this->idArrayToCollection($data->projectIds) : null; - $dto->tagIds = $data->tagIds !== null ? $this->idArrayToCollection($data->tagIds) : null; - $dto->taskIds = $data->taskIds ? $this->idArrayToCollection($data->taskIds) : null; - $dto->group = $data->group !== null ? TimeEntryAggregationType::from($data->group) : null; - $dto->subGroup = $data->subGroup !== null ? TimeEntryAggregationType::from($data->subGroup) : null; + $dto->clientIds = $data->clientIds !== null ? ReportPropertiesDto::idArrayToCollection($data->clientIds) : null; + $dto->projectIds = $data->projectIds !== null ? ReportPropertiesDto::idArrayToCollection($data->projectIds) : null; + $dto->tagIds = $data->tagIds !== null ? ReportPropertiesDto::idArrayToCollection($data->tagIds) : null; + $dto->taskIds = $data->taskIds ? ReportPropertiesDto::idArrayToCollection($data->taskIds) : null; + $dto->group = TimeEntryAggregationType::from($data->group); + $dto->subGroup = TimeEntryAggregationType::from($data->subGroup); + $dto->historyGroup = TimeEntryAggregationTypeInterval::from($data->historyGroup); + $dto->weekStart = Weekday::from($data->weekStart); + $dto->timezone = $data->timezone; return $dto; } - /** - * @param array $ids - * @return Collection - */ - private function idArrayToCollection(array $ids): Collection - { - $collection = new Collection; - foreach ($ids as $id) { - if (! is_string($id)) { - throw new \InvalidArgumentException('The given ID is not a string'); - } - if (Str::isUuid($id)) { - throw new \InvalidArgumentException('The given ID is not a valid UUID'); - } - $collection->push($id); - } - - return $collection; - } - /** * @param ReportPropertiesDto $value */ @@ -146,6 +140,9 @@ public function set(Model $model, string $key, mixed $value, array $attributes): 'taskIds' => $value->taskIds?->toArray(), 'group' => $value->group?->value, 'subGroup' => $value->subGroup?->value, + 'historyGroup' => $value->historyGroup?->value, + 'weekStart' => $value->weekStart->value, + 'timezone' => $value->timezone, ]; $jsonString = json_encode($data); @@ -157,4 +154,49 @@ public function set(Model $model, string $key, mixed $value, array $attributes): } }; } + + /** + * @param array $ids + * @return Collection + */ + public static function idArrayToCollection(array $ids): Collection + { + $collection = new Collection; + foreach ($ids as $id) { + if (! is_string($id)) { + throw new \InvalidArgumentException('The given ID is not a string'); + } + if (Str::isUuid($id)) { + throw new \InvalidArgumentException('The given ID is not a valid UUID'); + } + $collection->push($id); + } + + return $collection; + } + + public function setMemberIds(?array $memberIds): void + { + $this->memberIds = $memberIds !== null ? ReportPropertiesDto::idArrayToCollection($memberIds) : null; + } + + public function setClientIds(?array $clientIds): void + { + $this->clientIds = $clientIds !== null ? ReportPropertiesDto::idArrayToCollection($clientIds) : null; + } + + public function setProjectIds(?array $projectIds): void + { + $this->projectIds = $projectIds !== null ? ReportPropertiesDto::idArrayToCollection($projectIds) : null; + } + + public function setTagIds(?array $tagIds): void + { + $this->tagIds = $tagIds !== null ? ReportPropertiesDto::idArrayToCollection($tagIds) : null; + } + + public function setTaskIds(?array $taskIds): void + { + $this->taskIds = $taskIds !== null ? ReportPropertiesDto::idArrayToCollection($taskIds) : null; + } } diff --git a/app/Service/TimeEntryFilter.php b/app/Service/TimeEntryFilter.php index 7f35fe3a..2b79d7ad 100644 --- a/app/Service/TimeEntryFilter.php +++ b/app/Service/TimeEntryFilter.php @@ -30,7 +30,17 @@ public function addEndFilter(?string $dateTime): self if ($dateTime === null) { return $this; } - $this->builder->where('start', '<', Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $dateTime, 'UTC')); + $this->addEnd(Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $dateTime, 'UTC')); + + return $this; + } + + public function addEnd(?Carbon $end): self + { + if ($end === null) { + return $this; + } + $this->builder->where('start', '<', $end); return $this; } @@ -40,7 +50,17 @@ public function addStartFilter(?string $dateTime): self if ($dateTime === null) { return $this; } - $this->builder->where('start', '>', Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $dateTime, 'UTC')); + $this->addStart(Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $dateTime, 'UTC')); + + return $this; + } + + public function addStart(?Carbon $start): self + { + if ($start === null) { + return $this; + } + $this->builder->where('start', '>', $start); return $this; } @@ -51,9 +71,21 @@ public function addActiveFilter(?string $active): self return $this; } if ($active === 'true') { - $this->builder->whereNull('end'); + $this->addActive(true); + } elseif ($active === 'false') { + $this->addActive(false); + } else { + Log::warning('Invalid active filter value', ['value' => $active]); } - if ($active === 'false') { + + return $this; + } + + public function addActive(?bool $active): self + { + if ($active) { + $this->builder->whereNull('end'); + } else { $this->builder->whereNotNull('end'); } @@ -89,9 +121,9 @@ public function addBillableFilter(?string $billable): self return $this; } if ($billable === 'true') { - $this->builder->where('billable', '=', true); + $this->addBillable(true); } elseif ($billable === 'false') { - $this->builder->where('billable', '=', false); + $this->addBillable(false); } else { Log::warning('Invalid billable filter value', ['value' => $billable]); } @@ -99,6 +131,16 @@ public function addBillableFilter(?string $billable): self return $this; } + public function addBillable(?bool $billable): self + { + if ($billable === null) { + return $this; + } + $this->builder->where('billable', '=', $billable); + + return $this; + } + /** * @param array|null $clientIds */ diff --git a/database/factories/ReportFactory.php b/database/factories/ReportFactory.php index 0a6ae458..85a5e320 100644 --- a/database/factories/ReportFactory.php +++ b/database/factories/ReportFactory.php @@ -5,6 +5,8 @@ namespace Database\Factories; use App\Enums\TimeEntryAggregationType; +use App\Enums\TimeEntryAggregationTypeInterval; +use App\Enums\Weekday; use App\Models\Organization; use App\Models\Report; use App\Service\Dto\ReportPropertiesDto; @@ -26,6 +28,9 @@ public function definition(): array $reportDto = new ReportPropertiesDto; $reportDto->group = TimeEntryAggregationType::Project; $reportDto->subGroup = TimeEntryAggregationType::Task; + $reportDto->historyGroup = TimeEntryAggregationTypeInterval::Day; + $reportDto->weekStart = Weekday::from($this->faker->randomElement(Weekday::values())); + $reportDto->timezone = $this->faker->timezone(); return [ 'name' => $this->faker->company(), diff --git a/routes/api.php b/routes/api.php index 1509d162..d657576e 100644 --- a/routes/api.php +++ b/routes/api.php @@ -87,18 +87,18 @@ Route::delete('/project-members/{projectMember}', [ProjectMemberController::class, 'destroy'])->name('destroy'); }); - // Time entry routes - Route::name('time-entries.')->prefix('/organizations/{organization}')->group(static function (): void { - Route::get('/time-entries', [TimeEntryController::class, 'index'])->name('index'); - Route::get('/time-entries/export', [TimeEntryController::class, 'indexExport'])->name('index-export'); - Route::get('/time-entries/aggregate', [TimeEntryController::class, 'aggregate'])->name('aggregate'); - Route::get('/time-entries/aggregate/export', [TimeEntryController::class, 'aggregateExport'])->name('aggregate-export'); - Route::post('/time-entries', [TimeEntryController::class, 'store'])->name('store')->middleware('check-organization-blocked'); - Route::put('/time-entries/{timeEntry}', [TimeEntryController::class, 'update'])->name('update')->middleware('check-organization-blocked'); - Route::patch('/time-entries', [TimeEntryController::class, 'updateMultiple'])->name('update-multiple')->middleware('check-organization-blocked'); - Route::delete('/time-entries/{timeEntry}', [TimeEntryController::class, 'destroy'])->name('destroy'); - Route::delete('/time-entries', [TimeEntryController::class, 'destroyMultiple'])->name('destroy-multiple'); - }); + // Time entry routes + Route::name('time-entries.')->prefix('/organizations/{organization}')->group(static function (): void { + Route::get('/time-entries', [TimeEntryController::class, 'index'])->name('index'); + Route::get('/time-entries/export', [TimeEntryController::class, 'indexExport'])->name('index-export'); + Route::get('/time-entries/aggregate', [TimeEntryController::class, 'aggregate'])->name('aggregate'); + Route::get('/time-entries/aggregate/export', [TimeEntryController::class, 'aggregateExport'])->name('aggregate-export'); + Route::post('/time-entries', [TimeEntryController::class, 'store'])->name('store')->middleware('check-organization-blocked'); + Route::put('/time-entries/{timeEntry}', [TimeEntryController::class, 'update'])->name('update')->middleware('check-organization-blocked'); + Route::patch('/time-entries', [TimeEntryController::class, 'updateMultiple'])->name('update-multiple')->middleware('check-organization-blocked'); + Route::delete('/time-entries/{timeEntry}', [TimeEntryController::class, 'destroy'])->name('destroy'); + Route::delete('/time-entries', [TimeEntryController::class, 'destroyMultiple'])->name('destroy-multiple'); + }); Route::name('users.time-entries.')->group(static function (): void { Route::get('/users/me/time-entries/active', [UserTimeEntryController::class, 'myActive'])->name('my-active'); diff --git a/tests/Unit/Endpoint/Api/V1/ReportEndpointTest.php b/tests/Unit/Endpoint/Api/V1/ReportEndpointTest.php index 8a432d9a..2a6007ba 100644 --- a/tests/Unit/Endpoint/Api/V1/ReportEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/ReportEndpointTest.php @@ -5,6 +5,7 @@ namespace Tests\Unit\Endpoint\Api\V1; use App\Enums\TimeEntryAggregationType; +use App\Enums\Weekday; use App\Http\Controllers\Api\V1\ReportController; use App\Models\Report; use Illuminate\Support\Carbon; @@ -70,6 +71,7 @@ public function test_store_endpoint_fails_if_user_has_no_permission_to_create_re 'properties' => [ 'group' => TimeEntryAggregationType::Project->value, 'sub_group' => TimeEntryAggregationType::Task->value, + 'history_group' => TimeEntryAggregationType::Day->value, ], ]); @@ -86,12 +88,13 @@ public function test_store_endpoint_creates_new_report_with_minimal_properties() Passport::actingAs($data->user); // Act - $response = $this->postJson(route('api.v1.reports.store', [$data->organization->getKey()]), [ + $response = $this->withoutExceptionHandling()->postJson(route('api.v1.reports.store', [$data->organization->getKey()]), [ 'name' => 'Test Report', 'is_public' => false, 'properties' => [ 'group' => TimeEntryAggregationType::Project->value, 'sub_group' => TimeEntryAggregationType::Task->value, + 'history_group' => TimeEntryAggregationType::Day->value, ], ]); @@ -117,7 +120,7 @@ public function test_store_endpoint_creates_new_report_with_all_properties(): vo Passport::actingAs($data->user); // Act - $response = $this->postJson(route('api.v1.reports.store', [$data->organization->getKey()]), [ + $response = $this->withoutExceptionHandling()->postJson(route('api.v1.reports.store', [$data->organization->getKey()]), [ 'name' => 'Test Report', 'description' => 'Test description', 'is_public' => true, @@ -134,6 +137,9 @@ public function test_store_endpoint_creates_new_report_with_all_properties(): vo 'task_ids' => [], 'group' => TimeEntryAggregationType::Project->value, 'sub_group' => TimeEntryAggregationType::Task->value, + 'history_group' => TimeEntryAggregationType::Day->value, + 'week_start' => Weekday::Monday->value, + 'timezone' => 'Europe/Berlin', ], ]); @@ -269,7 +275,7 @@ public function test_update_endpoint_can_set_a_report_to_public_which_generates_ $report->refresh(); $this->assertTrue($report->is_public); $this->assertNotNull($report->share_secret); - $response->assertStatus(200); + $this->assertResponseCode($response, 200); $response->assertJson(fn (AssertableJson $json) => $json ->has('data') ->where('data.is_public', true) diff --git a/tests/Unit/Service/TimeEntryAggregationServiceTest.php b/tests/Unit/Service/TimeEntryAggregationServiceTest.php index 65487b47..5e234366 100644 --- a/tests/Unit/Service/TimeEntryAggregationServiceTest.php +++ b/tests/Unit/Service/TimeEntryAggregationServiceTest.php @@ -318,4 +318,92 @@ public function test_aggregate_time_entries_by_client_and_project(): void ], ], $result); } + + public function test_aggregate_time_entries_by_client_and_project_with_filled_gaps(): void + { + // Arrange + $client1 = Client::factory()->create(); + $client2 = Client::factory()->create(); + $project1 = Project::factory()->forClient($client1)->create(); + $project2 = Project::factory()->forClient($client2)->create(); + $project3 = Project::factory()->create(); + TimeEntry::factory()->startWithDuration(now(), 10)->forProject($project1)->create(); + TimeEntry::factory()->startWithDuration(now(), 10)->forProject($project2)->create(); + TimeEntry::factory()->startWithDuration(now(), 10)->forProject($project3)->create(); + TimeEntry::factory()->startWithDuration(now(), 10)->create(); + $query = TimeEntry::query(); + + // Act + $result = $this->service->getAggregatedTimeEntries( + $query, + TimeEntryAggregationType::Client, + TimeEntryAggregationType::Project, + 'Europe/Vienna', + Weekday::Monday, + true, + null, + null + ); + + // Assert + $this->assertEqualsCanonicalizing([ + 'seconds' => 40, + 'cost' => 0, + 'grouped_type' => 'client', + 'grouped_data' => [ + [ + 'key' => null, + 'seconds' => 20, + 'cost' => 0, + 'grouped_type' => 'project', + 'grouped_data' => [ + [ + 'key' => null, + 'seconds' => 10, + 'cost' => 0, + 'grouped_type' => null, + 'grouped_data' => null, + ], + [ + 'key' => $project3->getKey(), + 'seconds' => 10, + 'cost' => 0, + 'grouped_type' => null, + 'grouped_data' => null, + ], + ], + ], + [ + 'key' => $client1->getKey(), + 'seconds' => 10, + 'cost' => 0, + 'grouped_type' => 'project', + 'grouped_data' => [ + [ + 'key' => $project1->getKey(), + 'seconds' => 10, + 'cost' => 0, + 'grouped_type' => null, + 'grouped_data' => null, + ], + ], + ], + [ + 'key' => $client2->getKey(), + 'seconds' => 10, + 'cost' => 0, + 'grouped_type' => 'project', + 'grouped_data' => [ + [ + 'key' => $project2->getKey(), + 'seconds' => 10, + 'cost' => 0, + 'grouped_type' => null, + 'grouped_data' => null, + ], + ], + ], + ], + ], $result); + } }