From e2ac8f685dce602eef049c6171630eb77f9a6a13 Mon Sep 17 00:00:00 2001 From: Constantin Graf Date: Fri, 8 Nov 2024 13:27:51 +0100 Subject: [PATCH] Added shareable reports --- .../ReportSetExpiredToPrivateCommand.php | 8 +- app/Enums/Weekday.php | 3 + .../Controllers/Api/V1/ProjectController.php | 4 + .../Api/V1/Public/ReportController.php | 52 ++- .../Controllers/Api/V1/ReportController.php | 40 ++- .../V1/Project/ProjectStoreRequest.php | 9 + .../V1/Project/ProjectUpdateRequest.php | 3 + .../Requests/V1/Report/ReportStoreRequest.php | 74 ++++- .../TimeEntryAggregateExportRequest.php | 20 +- .../TimeEntry/TimeEntryAggregateRequest.php | 3 +- .../V1/Member/PersonalMembershipResource.php | 2 + .../V1/Organization/OrganizationResource.php | 2 + .../Resources/V1/Project/ProjectResource.php | 2 + .../V1/Report/DetailedReportResource.php | 22 +- .../Report/DetailedWithDataReportResource.php | 134 ++++++++ .../Resources/V1/Report/ReportResource.php | 4 + app/Models/Report.php | 2 + app/Models/TimeEntry.php | 2 +- app/Service/Dto/ReportPropertiesDto.php | 127 +++++-- app/Service/TimeEntryAggregationService.php | 74 +++-- app/Service/TimeEntryFilter.php | 54 ++- database/factories/ReportFactory.php | 8 + routes/api.php | 24 +- .../V1/Public/PublicReportEndpointTest.php | 314 +++++++++++++++++- .../Endpoint/Api/V1/ReportEndpointTest.php | 18 +- .../TimeEntryAggregationServiceTest.php | 88 +++++ 26 files changed, 986 insertions(+), 107 deletions(-) create mode 100644 app/Http/Resources/V1/Report/DetailedWithDataReportResource.php diff --git a/app/Console/Commands/Report/ReportSetExpiredToPrivateCommand.php b/app/Console/Commands/Report/ReportSetExpiredToPrivateCommand.php index 57b09718..268eed1c 100644 --- a/app/Console/Commands/Report/ReportSetExpiredToPrivateCommand.php +++ b/app/Console/Commands/Report/ReportSetExpiredToPrivateCommand.php @@ -8,6 +8,7 @@ use Illuminate\Console\Command; use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Carbon; +use LogicException; class ReportSetExpiredToPrivateCommand extends Command { @@ -44,7 +45,12 @@ public function handle(): int ->chunk(500, function (Collection $reports) use ($dryRun, &$resetReports): void { /** @var Collection $reports */ foreach ($reports as $report) { - $this->info('Make report "'.$report->name.'" ('.$report->getKey().') private, expired: '.$report->public_until->toIso8601ZuluString().' ('.$report->public_until->diffForHumans().')'); + $publicUntil = $report->public_until; + if ($publicUntil === null) { + throw new LogicException('public_until should not be null'); + } + $this->info('Make report "'.$report->name.'" ('.$report->getKey().') private, expired: '. + $publicUntil->toIso8601ZuluString().' ('.$publicUntil->diffForHumans().')'); $resetReports++; if (! $dryRun) { $report->is_public = false; 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..5c2c3ef2 100644 --- a/app/Http/Controllers/Api/V1/Public/ReportController.php +++ b/app/Http/Controllers/Api/V1/Public/ReportController.php @@ -4,9 +4,14 @@ 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\Dto\ReportPropertiesDto; +use App\Service\TimeEntryAggregationService; +use App\Service\TimeEntryFilter; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\Request; @@ -22,7 +27,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 +35,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 +46,45 @@ public function show(Request $request): DetailedReportResource ->orWhere('public_until', '>', now()); }) ->firstOrFail(); + /** @var ReportPropertiesDto $properties */ + $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($properties->memberIds?->toArray()); + $filter->addProjectIdsFilter($properties->projectIds?->toArray()); + $filter->addTagIdsFilter($properties->tagIds?->toArray()); + $filter->addTaskIdsFilter($properties->taskIds?->toArray()); + $filter->addClientIdsFilter($properties->clientIds?->toArray()); + $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..46fe7208 100644 --- a/app/Http/Controllers/Api/V1/ReportController.php +++ b/app/Http/Controllers/Api/V1/ReportController.php @@ -4,15 +4,17 @@ namespace App\Http\Controllers\Api\V1; -use App\Enums\TimeEntryAggregationType; +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 +34,8 @@ protected function checkPermission(Organization $organization, string $permissio /** * Get reports * + * @return ReportCollection + * * @throws AuthorizationException * * @operationId getReports @@ -69,9 +73,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, ReportService $reportService): DetailedReportResource { $this->checkPermission($organization, 'reports:create'); + $user = $this->user(); $report = new Report; $report->name = $request->getName(); @@ -79,11 +84,32 @@ public function store(Organization $organization, ReportStoreRequest $request): $isPublic = $request->getIsPublic(); $report->is_public = $isPublic; $properties = new ReportPropertiesDto; - $properties->group = TimeEntryAggregationType::from($request->input('properties.group')); - $properties->subGroup = TimeEntryAggregationType::from($request->input('properties.sub_group')); + $properties->group = $request->getPropertyGroup(); + $properties->subGroup = $request->getPropertySubGroup(); + $properties->historyGroup = $request->getPropertyHistoryGroup(); + $properties->start = $request->getPropertyStart(); + $properties->end = $request->getPropertyEnd(); + $properties->active = $request->getPropertyActive(); + $properties->setMemberIds($request->input('properties.member_ids', null)); + $properties->billable = $request->getPropertyBillable(); + $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(); + $report->share_secret = $reportService->generateSecret(); $report->public_until = $request->getPublicUntil(); } else { $report->share_secret = null; @@ -102,7 +128,7 @@ public function store(Organization $organization, ReportStoreRequest $request): * * @operationId updateReport */ - public function update(Organization $organization, Report $report, ReportUpdateRequest $request): DetailedReportResource + public function update(Organization $organization, Report $report, ReportUpdateRequest $request, ReportService $reportService): DetailedReportResource { $this->checkPermission($organization, 'reports:update', $report); @@ -116,7 +142,7 @@ public function update(Organization $organization, Report $report, ReportUpdateR $isPublic = $request->getIsPublic(); $report->is_public = $isPublic; if ($isPublic) { - $report->share_secret = app(ReportService::class)->generateSecret(); + $report->share_secret = $reportService->generateSecret(); $report->public_until = $request->getPublicUntil(); } else { $report->share_secret = null; 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..85413d15 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; @@ -49,11 +51,11 @@ public function rules(): array 'array', ], 'properties.start' => [ - 'nullable', + 'required', 'date_format:Y-m-d\TH:i:s\Z', ], 'properties.end' => [ - 'nullable', + 'required', 'date_format:Y-m-d\TH:i:s\Z', ], 'properties.active' => [ @@ -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,57 @@ public function getPublicUntil(): ?Carbon return $publicUntil === null ? null : Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $publicUntil); } + + public function getPropertyStart(): Carbon + { + $start = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('properties.start')); + if ($start === null) { + throw new \LogicException('Start date validation is not working'); + } + + return $start; + } + + public function getPropertyEnd(): Carbon + { + $end = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('properties.end')); + if ($end === null) { + throw new \LogicException('End date validation is not working'); + } + + return $end; + } + + public function getPropertyActive(): ?bool + { + if ($this->has('properties.active') && $this->input('properties.active') !== null) { + return (bool) $this->input('properties.active'); + } + + return null; + } + + public function getPropertyBillable(): ?bool + { + if ($this->has('properties.billable') && $this->input('properties.billable') !== null) { + return (bool) $this->input('properties.billable'); + } + + return null; + } + + public function getPropertyGroup(): TimeEntryAggregationType + { + return TimeEntryAggregationType::from($this->input('properties.group')); + } + + public function getPropertySubGroup(): TimeEntryAggregationType + { + return TimeEntryAggregationType::from($this->input('properties.sub_group')); + } + + public function getPropertyHistoryGroup(): TimeEntryAggregationTypeInterval + { + return TimeEntryAggregationTypeInterval::from($this->input('properties.history_group')); + } } diff --git a/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateExportRequest.php b/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateExportRequest.php index 0b5cc34b..5dab40c0 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', @@ -178,12 +180,22 @@ public function getHistoryGroup(): TimeEntryAggregationType public function getStart(): Carbon { - return Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('start'), 'UTC'); + $start = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('start'), 'UTC'); + if ($start === null) { + throw new \LogicException('Start date validation is not working'); + } + + return $start; } public function getEnd(): Carbon { - return Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('end'), 'UTC'); + $end = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('end'), 'UTC'); + if ($end === null) { + throw new \LogicException('End date validation is not working'); + } + + return $end; } public function getFormatValue(): ExportFormat 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..3882f7c3 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|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 string $history_group Type of grouping of the historic aggregation (time chart) */ + 'history_group' => $this->resource->properties->historyGroup->value, + /** @var string $start Start date of the report */ + 'start' => $this->resource->properties->start->toIso8601ZuluString(), + /** @var string $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|null $member_ids Filter by multiple member IDs, member IDs are OR combined */ 'member_ids' => $this->resource->properties->memberIds?->toArray(), + /** @var bool|null $billable Filter by billable status */ 'billable' => $this->resource->properties->billable, + /** @var array|null $client_ids Filter by client IDs, client IDs are OR combined */ 'client_ids' => $this->resource->properties->clientIds?->toArray(), + /** @var array|null $project_ids Filter by project IDs, project IDs are OR combined */ 'project_ids' => $this->resource->properties->projectIds?->toArray(), + /** @var array|null $tags_ids Filter by tag IDs, tag IDs are OR combined */ 'tag_ids' => $this->resource->properties->tagIds?->toArray(), + /** @var array|null $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..100ff7e3 --- /dev/null +++ b/app/Http/Resources/V1/Report/DetailedWithDataReportResource.php @@ -0,0 +1,134 @@ + + * }>, + * seconds: int, + * cost: int + * } + */ +class DetailedWithDataReportResource extends BaseResource +{ + /** + * @var Data + */ + private array $data; + + /** + * @var Data + */ + private array $historyData; + + /** + * @param Data $data + * @param Data $historyData + */ + public function __construct(Report $resource, array $data, array $historyData) + { + parent::__construct($resource); + $this->data = $data; + $this->historyData = $historyData; + } + + /** + * Transform the resource into an array. + * + * @return array>> + */ + public function toArray(Request $request): array + { + return [ + /** @var string $name Name */ + 'name' => $this->resource->name, + /** @var string|null $email Description */ + 'description' => $this->resource->description, + /** @var string|null $public_until Date until the report is public */ + 'public_until' => $this->resource->public_until?->toIso8601ZuluString(), + /** @var string $currency Currency code (ISO 4217) */ + 'currency' => $this->resource->organization->currency, + '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 $start Start date of the report */ + 'start' => $this->resource->properties->start->toIso8601ZuluString(), + /** @var string $end End date of the report */ + 'end' => $this->resource->properties->end->toIso8601ZuluString(), + ], + /** @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, + ]; + } +} 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/Models/TimeEntry.php b/app/Models/TimeEntry.php index 16626eb4..a92539da 100644 --- a/app/Models/TimeEntry.php +++ b/app/Models/TimeEntry.php @@ -104,7 +104,7 @@ public function getBillableRateComputed(): ?int public function getClientIdComputed(): ?string { - return $this->project_id === null ? null : $this->project->client_id; + return $this->project_id === null || $this->project === null ? null : $this->project->client_id; } /** diff --git a/app/Service/Dto/ReportPropertiesDto.php b/app/Service/Dto/ReportPropertiesDto.php index 6de7074f..53e4ef8f 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; @@ -14,13 +16,19 @@ class ReportPropertiesDto implements Castable { - public ?TimeEntryAggregationType $group = null; + public TimeEntryAggregationType $group; - public ?TimeEntryAggregationType $subGroup = null; + public TimeEntryAggregationType $subGroup; - public ?Carbon $start = null; + public TimeEntryAggregationTypeInterval $historyGroup; - public ?Carbon $end = null; + public Weekday $weekStart; + + public string $timezone; + + public Carbon $start; + + public Carbon $end; public ?bool $active = 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 */ @@ -135,8 +129,8 @@ public function set(Model $model, string $key, mixed $value, array $attributes): } $data = (object) [ - 'end' => $value->end?->toIso8601ZuluString(), - 'start' => $value->start?->toIso8601ZuluString(), + 'end' => $value->end->toIso8601ZuluString(), + 'start' => $value->start->toIso8601ZuluString(), 'active' => $value->active, 'memberIds' => $value->memberIds?->toArray(), 'billable' => $value->billable, @@ -144,8 +138,11 @@ public function set(Model $model, string $key, mixed $value, array $attributes): 'projectIds' => $value->projectIds?->toArray(), 'tagIds' => $value->tagIds?->toArray(), 'taskIds' => $value->taskIds?->toArray(), - 'group' => $value->group?->value, - 'subGroup' => $value->subGroup?->value, + '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,64 @@ 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; + } + + /** + * @param array|null $memberIds + */ + public function setMemberIds(?array $memberIds): void + { + $this->memberIds = $memberIds !== null ? ReportPropertiesDto::idArrayToCollection($memberIds) : null; + } + + /** + * @param array|null $clientIds + */ + public function setClientIds(?array $clientIds): void + { + $this->clientIds = $clientIds !== null ? ReportPropertiesDto::idArrayToCollection($clientIds) : null; + } + + /** + * @param array|null $projectIds + */ + public function setProjectIds(?array $projectIds): void + { + $this->projectIds = $projectIds !== null ? ReportPropertiesDto::idArrayToCollection($projectIds) : null; + } + + /** + * @param array|null $tagIds + */ + public function setTagIds(?array $tagIds): void + { + $this->tagIds = $tagIds !== null ? ReportPropertiesDto::idArrayToCollection($tagIds) : null; + } + + /** + * @param array|null $taskIds + */ + public function setTaskIds(?array $taskIds): void + { + $this->taskIds = $taskIds !== null ? ReportPropertiesDto::idArrayToCollection($taskIds) : null; + } } diff --git a/app/Service/TimeEntryAggregationService.php b/app/Service/TimeEntryAggregationService.php index eecbebff..62d0613f 100644 --- a/app/Service/TimeEntryAggregationService.php +++ b/app/Service/TimeEntryAggregationService.php @@ -146,12 +146,14 @@ public function getAggregatedTimeEntries(Builder $timeEntriesQuery, ?TimeEntryAg * grouped_data: null|arrayloadDescriptionMap($keysGroup1, $group1Type) : []; - $descriptionMapGroup2 = $group2Type !== null ? $this->loadDescriptionMap($keysGroup2, $group2Type) : []; + $descriptionMapGroup1 = $group1Type !== null ? $this->loadDescriptorsMap($keysGroup1, $group1Type) : []; + $descriptionMapGroup2 = $group2Type !== null ? $this->loadDescriptorsMap($keysGroup2, $group2Type) : []; if ($aggregatedTimeEntries['grouped_data'] !== null) { foreach ($aggregatedTimeEntries['grouped_data'] as $keyGroup1 => $group1) { - $aggregatedTimeEntries['grouped_data'][$keyGroup1]['description'] = $group1['key'] !== null ? ($descriptionMapGroup1[$group1['key']] ?? null) : null; + $aggregatedTimeEntries['grouped_data'][$keyGroup1]['description'] = $group1['key'] !== null ? ($descriptionMapGroup1[$group1['key']]['description'] ?? null) : null; + $aggregatedTimeEntries['grouped_data'][$keyGroup1]['color'] = $group1['key'] !== null ? ($descriptionMapGroup1[$group1['key']]['color'] ?? null) : null; if ($aggregatedTimeEntries['grouped_data'][$keyGroup1]['grouped_data'] !== null) { foreach ($aggregatedTimeEntries['grouped_data'][$keyGroup1]['grouped_data'] as $keyGroup2 => $group2) { - $aggregatedTimeEntries['grouped_data'][$keyGroup1]['grouped_data'][$keyGroup2]['description'] = $group2['key'] !== null ? ($descriptionMapGroup2[$group2['key']] ?? null) : null; + $aggregatedTimeEntries['grouped_data'][$keyGroup1]['grouped_data'][$keyGroup2]['description'] = $group2['key'] !== null ? ($descriptionMapGroup2[$group2['key']]['description'] ?? null) : null; + $aggregatedTimeEntries['grouped_data'][$keyGroup1]['grouped_data'][$keyGroup2]['color'] = $group2['key'] !== null ? ($descriptionMapGroup2[$group2['key']]['color'] ?? null) : null; } } } @@ -200,12 +204,14 @@ public function getAggregatedTimeEntriesWithDescriptions(Builder $timeEntriesQue * grouped_data: null|array $keys - * @return array + * @return array */ - private function loadDescriptionMap(array $keys, TimeEntryAggregationType $type): array + private function loadDescriptorsMap(array $keys, TimeEntryAggregationType $type): array { + $descriptorMap = []; if ($type === TimeEntryAggregationType::Client) { - return Client::query() + $clients = Client::query() ->whereIn('id', $keys) - ->pluck('name', 'id') - ->toArray(); + ->select('id', 'name') + ->get(); + foreach ($clients as $client) { + $descriptorMap[$client->id] = [ + 'description' => $client->name, + 'color' => null, + ]; + } } elseif ($type === TimeEntryAggregationType::User) { - return User::query() + $users = User::query() ->whereIn('id', $keys) - ->pluck('name', 'id') - ->toArray(); + ->select('id', 'name') + ->get(); + foreach ($users as $user) { + $descriptorMap[$user->id] = [ + 'description' => $user->name, + 'color' => null, + ]; + } } elseif ($type === TimeEntryAggregationType::Project) { - return Project::query() + $projects = Project::query() ->whereIn('id', $keys) - ->pluck('name', 'id') - ->toArray(); + ->select('id', 'name', 'color') + ->get(); + foreach ($projects as $project) { + $descriptorMap[$project->id] = [ + 'description' => $project->name, + 'color' => $project->color, + ]; + } } elseif ($type === TimeEntryAggregationType::Task) { - return Task::query() + $tasks = Task::query() ->whereIn('id', $keys) - ->pluck('name', 'id') - ->toArray(); - } else { - return []; + ->select('id', 'name') + ->get(); + foreach ($tasks as $task) { + $descriptorMap[$task->id] = [ + 'description' => $task->name, + 'color' => null, + ]; + } } + + return $descriptorMap; } /** 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..7c10b45c 100644 --- a/database/factories/ReportFactory.php +++ b/database/factories/ReportFactory.php @@ -5,11 +5,14 @@ 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; use App\Service\ReportService; use Illuminate\Database\Eloquent\Factories\Factory; +use Illuminate\Support\Carbon; /** * @extends Factory @@ -24,8 +27,13 @@ class ReportFactory extends Factory public function definition(): array { $reportDto = new ReportPropertiesDto; + $reportDto->start = Carbon::createFromDate($this->faker->dateTimeBetween('-1 year', '-1 month')); + $reportDto->end = Carbon::createFromDate($this->faker->dateTimeBetween('-1 month', 'now')); $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/Public/PublicReportEndpointTest.php b/tests/Unit/Endpoint/Api/V1/Public/PublicReportEndpointTest.php index 6bf12fa3..909e311e 100644 --- a/tests/Unit/Endpoint/Api/V1/Public/PublicReportEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/Public/PublicReportEndpointTest.php @@ -4,7 +4,18 @@ namespace Tests\Unit\Endpoint\Api\V1\Public; +use App\Enums\TimeEntryAggregationType; +use App\Enums\TimeEntryAggregationTypeInterval; +use App\Enums\Weekday; +use App\Models\Client; +use App\Models\Organization; +use App\Models\Project; use App\Models\Report; +use App\Models\Tag; +use App\Models\Task; +use App\Models\TimeEntry; +use App\Service\Dto\ReportPropertiesDto; +use Illuminate\Support\Str; use Tests\Unit\Endpoint\Api\V1\ApiEndpointTestAbstract; class PublicReportEndpointTest extends ApiEndpointTestAbstract @@ -68,9 +79,29 @@ public function test_show_fails_with_not_found_if_report_is_expired(): void public function test_show_returns_detailed_information_about_the_report(): void { // Arrange - $report = Report::factory()->public()->create([ + $reportDto = new ReportPropertiesDto; + $organization = Organization::factory()->create(); + $reportDto->start = now()->subDays(2); + $reportDto->end = now(); + $reportDto->group = TimeEntryAggregationType::Project; + $reportDto->subGroup = TimeEntryAggregationType::Task; + $reportDto->historyGroup = TimeEntryAggregationTypeInterval::Day; + $reportDto->weekStart = Weekday::Monday; + $reportDto->timezone = 'Europe/Vienna'; + $report = Report::factory()->forOrganization($organization)->public()->create([ 'public_until' => null, + 'properties' => $reportDto, + ]); + $project = Project::factory()->forOrganization($organization)->create(); + $task1 = Task::factory()->forOrganization($organization)->forProject($project)->create([ + 'id' => '1b0f1b32-0def-4932-8829-b68f52161987', + ]); + $task2 = Task::factory()->forOrganization($organization)->forProject($project)->create([ + 'id' => '3c54796d-5ab4-41e1-8f30-aa61a0a919ae', ]); + TimeEntry::factory()->forOrganization($organization)->forTask($task1)->startWithDuration(now()->subDay(), 100)->create(); + TimeEntry::factory()->forOrganization($organization)->forTask($task2)->startWithDuration(now()->subDay(), 100)->create(); + TimeEntry::factory()->forOrganization($organization)->startWithDuration(now()->subDay(), 100)->create(); // Act $response = $this->getJson(route('api.v1.public.reports.show'), [ @@ -79,12 +110,106 @@ public function test_show_returns_detailed_information_about_the_report(): void // Assert $response->assertOk(); - $response->assertJsonFragment([ - 'id' => $report->id, + $response->assertExactJson([ 'name' => $report->name, 'description' => $report->description, - 'is_public' => $report->is_public, 'public_until' => $report->public_until?->toIso8601ZuluString(), + 'currency' => $organization->currency, + 'properties' => [ + 'group' => $reportDto->group->value, + 'sub_group' => $reportDto->subGroup->value, + 'history_group' => $reportDto->historyGroup->value, + 'start' => $reportDto->start->toIso8601ZuluString(), + 'end' => $reportDto->end->toIso8601ZuluString(), + ], + 'data' => [ + 'seconds' => 300, + 'cost' => 0, + 'grouped_type' => TimeEntryAggregationType::Project->value, + 'grouped_data' => [ + [ + 'key' => $project->id, + 'seconds' => 200, + 'cost' => 0, + 'grouped_type' => TimeEntryAggregationType::Task->value, + 'grouped_data' => [ + [ + 'key' => $task1->id, + 'seconds' => 100, + 'cost' => 0, + 'grouped_type' => null, + 'grouped_data' => null, + 'description' => $task1->name, + 'color' => null, + ], + [ + 'key' => $task2->id, + 'seconds' => 100, + 'cost' => 0, + 'grouped_type' => null, + 'grouped_data' => null, + 'description' => $task2->name, + 'color' => null, + ], + ], + 'description' => $project->name, + 'color' => $project->color, + ], + [ + 'key' => null, + 'seconds' => 100, + 'cost' => 0, + 'grouped_type' => TimeEntryAggregationType::Task->value, + 'grouped_data' => [ + [ + 'key' => null, + 'seconds' => 100, + 'cost' => 0, + 'grouped_type' => null, + 'grouped_data' => null, + 'description' => null, + 'color' => null, + ], + ], + 'description' => null, + 'color' => null, + ], + ], + ], + 'history_data' => [ + 'seconds' => 300, + 'cost' => 0, + 'grouped_type' => TimeEntryAggregationTypeInterval::Day->value, + 'grouped_data' => [ + [ + 'key' => now()->subDays(2)->toDateString(), + 'seconds' => 0, + 'cost' => 0, + 'grouped_type' => null, + 'grouped_data' => null, + 'description' => null, + 'color' => null, + ], + [ + 'key' => now()->subDays(1)->toDateString(), + 'seconds' => 300, + 'cost' => 0, + 'grouped_type' => null, + 'grouped_data' => null, + 'description' => null, + 'color' => null, + ], + [ + 'key' => now()->toDateString(), + 'seconds' => 0, + 'cost' => 0, + 'grouped_type' => null, + 'grouped_data' => null, + 'description' => null, + 'color' => null, + ], + ], + ], ]); } @@ -103,11 +228,188 @@ public function test_show_returns_detailed_information_about_the_report_with_not // Assert $response->assertOk(); $response->assertJsonFragment([ - 'id' => $report->id, 'name' => $report->name, 'description' => $report->description, - 'is_public' => $report->is_public, 'public_until' => $report->public_until?->toIso8601ZuluString(), ]); } + + public function test_show_returns_detailed_information_about_the_report_with_all_available_filters(): void + { + // Arrange + $organization = Organization::factory()->create(); + $client = Client::factory()->forOrganization($organization)->create(); + $otherClient = Client::factory()->forOrganization($organization)->create(); + $project = Project::factory()->forClient($client)->forOrganization($organization)->create(); + $otherProject = Project::factory()->forOrganization($organization)->create(); + $otherProjectWithClient = Project::factory()->forClient($client)->forOrganization($organization)->create(); + $task = Task::factory()->forOrganization($organization)->forProject($project)->create(); + $tag = Tag::factory()->forOrganization($organization)->create(); + $otherTag = Tag::factory()->forOrganization($organization)->create(); + + // Match for all filters + TimeEntry::factory()->forOrganization($organization) + ->forTask($task) + ->billable() + ->startWithDuration(now()->subDay(), 100) + ->create([ + 'tags' => [$tag->getKey()], + ]); + // No match for task filter + TimeEntry::factory()->forOrganization($organization) + ->forProject($otherProject) + ->startWithDuration(now()->subDay(), 100) + ->create(); + // No match for client filter + TimeEntry::factory()->forOrganization($organization) + ->forProject($otherProjectWithClient) + ->startWithDuration(now()->subDay(), 100) + ->create(); + + $reportDto = new ReportPropertiesDto; + $reportDto->start = now()->subDays(2); + $reportDto->end = now(); + $reportDto->group = TimeEntryAggregationType::Project; + $reportDto->subGroup = TimeEntryAggregationType::Task; + $reportDto->historyGroup = TimeEntryAggregationTypeInterval::Day; + $reportDto->weekStart = Weekday::Monday; + $reportDto->timezone = 'Europe/Vienna'; + $reportDto->active = false; + $reportDto->billable = true; + $reportDto->setMemberIds(null); + $reportDto->setClientIds([$client->getKey()]); + $reportDto->setProjectIds([$project->getKey()]); + $reportDto->setTagIds([$tag->getKey()]); + $reportDto->setTaskIds([$task->getKey()]); + $report = Report::factory()->forOrganization($organization)->public()->create([ + 'public_until' => null, + 'properties' => $reportDto, + ]); + + // Act + $response = $this->getJson(route('api.v1.public.reports.show'), [ + 'X-Api-Key' => $report->share_secret, + ]); + + // Assert + $response->assertOk(); + $response->assertJson([ + 'name' => $report->name, + 'description' => $report->description, + 'public_until' => $report->public_until?->toIso8601ZuluString(), + 'properties' => [ + 'group' => $reportDto->group->value, + 'sub_group' => $reportDto->subGroup->value, + 'history_group' => $reportDto->historyGroup->value, + 'start' => $reportDto->start->toIso8601ZuluString(), + 'end' => $reportDto->end->toIso8601ZuluString(), + ], + 'data' => [ + 'seconds' => 100, + 'cost' => 0, + 'grouped_type' => TimeEntryAggregationType::Project->value, + ], + 'history_data' => [ + 'seconds' => 100, + 'cost' => 0, + 'grouped_type' => TimeEntryAggregationTypeInterval::Day->value, + ], + ]); + } + + public function test_if_the_resources_behind_the_filters_no_longer_exist_the_report_ignores_those_filters_but_this_does_not_increase_the_visible_data(): void + { + // Arrange + $organization = Organization::factory()->create(); + $client = Client::factory()->forOrganization($organization)->create(); + $project = Project::factory()->forClient($client)->forOrganization($organization)->create(); + $task = Task::factory()->forOrganization($organization)->forProject($project)->create(); + $tag = Tag::factory()->forOrganization($organization)->create(); + + TimeEntry::factory()->forOrganization($organization) + ->forTask($task) + ->billable() + ->startWithDuration(now()->subDay(), 100) + ->create([ + 'tags' => [$tag->getKey()], + ]); + + $reportDto = new ReportPropertiesDto; + $reportDto->start = now()->subDays(2); + $reportDto->end = now(); + $reportDto->group = TimeEntryAggregationType::Project; + $reportDto->subGroup = TimeEntryAggregationType::Task; + $reportDto->historyGroup = TimeEntryAggregationTypeInterval::Day; + $reportDto->weekStart = Weekday::Monday; + $reportDto->timezone = 'Europe/Vienna'; + $reportDto->setMemberIds([Str::uuid()->toString()]); + $reportDto->setClientIds([Str::uuid()->toString()]); + $reportDto->setProjectIds([Str::uuid()->toString()]); + $reportDto->setTagIds([Str::uuid()->toString()]); + $reportDto->setTaskIds([Str::uuid()->toString()]); + $report = Report::factory()->forOrganization($organization)->public()->create([ + 'public_until' => null, + 'properties' => $reportDto, + ]); + + // Act + $response = $this->getJson(route('api.v1.public.reports.show'), [ + 'X-Api-Key' => $report->share_secret, + ]); + + // Assert + $response->assertOk(); + $response->assertJson([ + 'name' => $report->name, + 'description' => $report->description, + 'public_until' => $report->public_until?->toIso8601ZuluString(), + 'properties' => [ + 'group' => $reportDto->group->value, + 'sub_group' => $reportDto->subGroup->value, + 'history_group' => $reportDto->historyGroup->value, + 'start' => $reportDto->start->toIso8601ZuluString(), + 'end' => $reportDto->end->toIso8601ZuluString(), + ], + 'data' => [ + 'seconds' => 0, + 'cost' => 0, + 'grouped_type' => TimeEntryAggregationType::Project->value, + 'grouped_data' => [], + ], + 'history_data' => [ + 'seconds' => 0, + 'cost' => 0, + 'grouped_type' => TimeEntryAggregationTypeInterval::Day->value, + 'grouped_data' => [ + [ + 'key' => now()->subDays(2)->toDateString(), + 'seconds' => 0, + 'cost' => 0, + 'grouped_type' => null, + 'grouped_data' => null, + 'description' => null, + 'color' => null, + ], + [ + 'key' => now()->subDays(1)->toDateString(), + 'seconds' => 0, + 'cost' => 0, + 'grouped_type' => null, + 'grouped_data' => null, + 'description' => null, + 'color' => null, + ], + [ + 'key' => now()->toDateString(), + 'seconds' => 0, + 'cost' => 0, + 'grouped_type' => null, + 'grouped_data' => null, + 'description' => null, + 'color' => null, + ], + ], + ], + ]); + } } diff --git a/tests/Unit/Endpoint/Api/V1/ReportEndpointTest.php b/tests/Unit/Endpoint/Api/V1/ReportEndpointTest.php index 8a432d9a..0239e050 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,9 @@ 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, + 'start' => Carbon::now()->subDays(30)->toIso8601ZuluString(), + 'end' => Carbon::now()->toIso8601ZuluString(), ], ]); @@ -92,6 +96,9 @@ public function test_store_endpoint_creates_new_report_with_minimal_properties() 'properties' => [ 'group' => TimeEntryAggregationType::Project->value, 'sub_group' => TimeEntryAggregationType::Task->value, + 'history_group' => TimeEntryAggregationType::Day->value, + 'start' => Carbon::now()->subDays(30)->toIso8601ZuluString(), + 'end' => Carbon::now()->toIso8601ZuluString(), ], ]); @@ -117,7 +124,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 +141,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', ], ]); @@ -251,7 +261,7 @@ public function test_update_endpoint_can_update_only_the_description_of_the_repo ); } - public function test_update_endpoint_can_set_a_report_to_public_which_generates_a_new_secret(): void + public function test_update_endpoint_can_set_a_report_from_private_to_public_which_generates_a_new_secret(): void { // Arrange $data = $this->createUserWithPermission([ @@ -269,7 +279,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) @@ -277,7 +287,7 @@ public function test_update_endpoint_can_set_a_report_to_public_which_generates_ ); } - public function test_update_endpoint_can_set_a_report_to_private_which_resets_the_secret(): void + public function test_update_endpoint_can_set_a_report_from_public_to_private_which_resets_the_secret(): void { // Arrange $data = $this->createUserWithPermission([ 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); + } }