From 2a8ab1201786ee3124e89c5e5fe829a8d5c1211e Mon Sep 17 00:00:00 2001 From: Constantin Graf Date: Tue, 21 May 2024 17:31:45 +0200 Subject: [PATCH] Added filled gaps to time entry aggregation; Moved aggregation to service --- app/Enums/TimeEntryAggregationType.php | 29 ++ .../TimeEntryAggregationTypeInterval.php | 13 + app/Enums/Weekday.php | 13 + .../Controllers/Api/V1/ProjectController.php | 4 +- .../Controllers/Api/V1/TaskController.php | 2 +- .../Api/V1/TimeEntryController.php | 147 ++------ .../Controllers/Web/HealthCheckController.php | 2 +- .../Requests/V1/Task/TaskIndexRequest.php | 2 +- .../TimeEntry/TimeEntryAggregateRequest.php | 44 ++- .../V1/TimeEntry/TimeEntryIndexRequest.php | 10 +- app/Http/Resources/V1/BaseResource.php | 2 +- app/Models/Project.php | 6 +- app/Models/Task.php | 4 +- app/Service/DashboardService.php | 2 +- .../Import/Importers/TogglDataImporter.php | 18 + app/Service/TimeEntryAggregationService.php | 295 ++++++++++++++++ app/Service/TimezoneService.php | 2 +- database/factories/TimeEntryFactory.php | 38 +- tests/TestCaseWithDatabase.php | 43 +++ .../Api/V1/ApiEndpointTestAbstract.php | 37 +- .../Api/V1/InvitationEndpointTest.php | 10 + .../Endpoint/Api/V1/ProjectEndpointTest.php | 7 +- .../Api/V1/ProjectMemberEndpointTest.php | 6 +- .../Endpoint/Api/V1/TimeEntryEndpointTest.php | 324 ++++++++++++++++-- .../Api/V1/UserTimeEntryEndpointTest.php | 25 ++ tests/Unit/Model/ProjectModelTest.php | 2 +- tests/Unit/Model/TaskModelTest.php | 2 +- tests/Unit/Model/TimeEntryModelTest.php | 2 +- tests/Unit/Service/DashboardServiceTest.php | 2 +- .../TimeEntryAggregationServiceTest.php | 142 ++++++++ 30 files changed, 1017 insertions(+), 218 deletions(-) create mode 100644 app/Enums/TimeEntryAggregationType.php create mode 100644 app/Enums/TimeEntryAggregationTypeInterval.php create mode 100644 app/Service/TimeEntryAggregationService.php create mode 100644 tests/TestCaseWithDatabase.php create mode 100644 tests/Unit/Service/TimeEntryAggregationServiceTest.php diff --git a/app/Enums/TimeEntryAggregationType.php b/app/Enums/TimeEntryAggregationType.php new file mode 100644 index 00000000..97a331f2 --- /dev/null +++ b/app/Enums/TimeEntryAggregationType.php @@ -0,0 +1,29 @@ + TimeEntryAggregationTypeInterval::Day, + TimeEntryAggregationType::Week => TimeEntryAggregationTypeInterval::Week, + TimeEntryAggregationType::Month => TimeEntryAggregationTypeInterval::Month, + TimeEntryAggregationType::Year => TimeEntryAggregationTypeInterval::Year, + default => null + }; + } +} diff --git a/app/Enums/TimeEntryAggregationTypeInterval.php b/app/Enums/TimeEntryAggregationTypeInterval.php new file mode 100644 index 00000000..1ad2631d --- /dev/null +++ b/app/Enums/TimeEntryAggregationTypeInterval.php @@ -0,0 +1,13 @@ + Weekday::Sunday, + Weekday::Tuesday => Weekday::Monday, + Weekday::Wednesday => Weekday::Tuesday, + Weekday::Thursday => Weekday::Wednesday, + Weekday::Friday => Weekday::Thursday, + Weekday::Saturday => Weekday::Friday, + Weekday::Sunday => Weekday::Saturday, + }; + } + public function carbonWeekDay(): int { return match ($this) { diff --git a/app/Http/Controllers/Api/V1/ProjectController.php b/app/Http/Controllers/Api/V1/ProjectController.php index 661ffe89..5e04a9d6 100644 --- a/app/Http/Controllers/Api/V1/ProjectController.php +++ b/app/Http/Controllers/Api/V1/ProjectController.php @@ -48,7 +48,7 @@ class ProjectController extends Controller ->whereBelongsTo($organization, 'organization'); if (! $canViewAllProjects) { - $projectsQuery->visibleByUser($user); + $projectsQuery->visibleByEmployee($user); } $projects = $projectsQuery->paginate(config('app.pagination_per_page_default')); @@ -131,7 +131,7 @@ class ProjectController extends Controller } DB::transaction(function () use (&$project) { - $project->members()->each(function (ProjectMember $member) { + $project->members->each(function (ProjectMember $member) { $member->delete(); }); diff --git a/app/Http/Controllers/Api/V1/TaskController.php b/app/Http/Controllers/Api/V1/TaskController.php index 233ed443..a83ac994 100644 --- a/app/Http/Controllers/Api/V1/TaskController.php +++ b/app/Http/Controllers/Api/V1/TaskController.php @@ -51,7 +51,7 @@ class TaskController extends Controller } if (! $canViewAllTasks) { - $query->visibleByUser($user); + $query->visibleByEmployee($user); } $tasks = $query->paginate(config('app.pagination_per_page_default')); diff --git a/app/Http/Controllers/Api/V1/TimeEntryController.php b/app/Http/Controllers/Api/V1/TimeEntryController.php index 22cc7c8c..946002e2 100644 --- a/app/Http/Controllers/Api/V1/TimeEntryController.php +++ b/app/Http/Controllers/Api/V1/TimeEntryController.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace App\Http\Controllers\Api\V1; -use App\Enums\Weekday; use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException; use App\Exceptions\Api\TimeEntryStillRunningApiException; use App\Http\Requests\V1\TimeEntry\TimeEntryAggregateRequest; @@ -17,13 +16,12 @@ use App\Http\Resources\V1\TimeEntry\TimeEntryResource; use App\Models\Member; use App\Models\Organization; use App\Models\TimeEntry; +use App\Service\TimeEntryAggregationService; use App\Service\TimeEntryFilter; use App\Service\TimezoneService; -use Carbon\CarbonTimeZone; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Http\JsonResponse; use Illuminate\Http\Resources\Json\JsonResource; -use Illuminate\Support\Carbon; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Log; @@ -82,7 +80,7 @@ class TimeEntryController extends Controller $timeEntries = $timeEntriesQuery->get(); - if ($timeEntries->count() === $limit && $request->has('only_full_dates') && (bool) $request->get('only_full_dates') === true) { + if ($timeEntries->count() === $limit && $request->getOnlyFullDates()) { $user = $this->user(); $timezone = app(TimezoneService::class)->getTimezoneFromUser($user); $lastDate = null; @@ -126,16 +124,18 @@ class TimeEntryController extends Controller * * @return array{ * data: array{ + * grouped_type: string|null, * grouped_data: null|array * }>, * seconds: int, @@ -145,7 +145,7 @@ class TimeEntryController extends Controller * * @throws AuthorizationException */ - public function aggregate(Organization $organization, TimeEntryAggregateRequest $request): array + public function aggregate(Organization $organization, TimeEntryAggregateRequest $request, TimeEntryAggregationService $aggregationService): array { /** @var Member|null $member */ $member = $request->has('member_id') ? Member::query()->findOrFail($request->get('member_id')) : null; @@ -173,132 +173,25 @@ class TimeEntryController extends Controller $user = $this->user(); - /** @var string|null $group1Type */ - $group1Type = $request->get('group'); - /** @var string|null $group2Type */ - $group2Type = $request->get('sub_group'); + $group1Type = $request->getGroup(); + $group2Type = $request->getSubGroup(); - $group1Select = null; - $group2Select = null; - $groupBy = null; - if ($group1Type !== null) { - $group1Select = $this->getGroupByQuery($group1Type, $user->timezone, $user->week_start); - $groupBy = ['group_1']; - if ($group2Type !== null) { - $group2Select = $this->getGroupByQuery($group2Type, $user->timezone, $user->week_start); - $groupBy = ['group_1', 'group_2']; - } - } - - $timeEntriesQuery->selectRaw( - ($group1Select !== null ? $group1Select.' as group_1,' : ''). - ($group2Select !== null ? $group2Select.' as group_2,' : ''). - ' round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate,'. - ' round( - sum( - extract(epoch from (coalesce("end", now()) - start)) * (coalesce(billable_rate, 0)::float/60/60) - ) - ) as cost' + $aggregatedData = $aggregationService->getAggregatedTimeEntries( + $timeEntriesQuery, + $group1Type, + $group2Type, + $user->timezone, + $user->week_start, + $request->getFillGapsInTimeGroups(), + $request->getStart(), + $request->getEnd() ); - if ($groupBy !== null) { - $timeEntriesQuery->groupBy($groupBy); - } - - $timeEntriesAggregates = $timeEntriesQuery->get(); - - if ($group1Select !== null) { - $groupedAggregates = $timeEntriesAggregates->groupBy($group2Select !== null ? ['group_1', 'group_2'] : ['group_1']); - - $group1Response = []; - $group1ResponseSum = 0; - $group1ResponseCost = 0; - foreach ($groupedAggregates as $group1 => $group1Aggregates) { - /** @var string $group1 */ - $group2Response = []; - if ($group2Select !== null) { - $group2ResponseSum = 0; - $group2ResponseCost = 0; - foreach ($group1Aggregates as $group2 => $aggregate) { - /** @var string $group2 */ - /** @var Collection $aggregate */ - /** @var string $group2Type */ - $group2Response[] = [ - 'type' => $group2Type, - 'key' => $group2 === '' ? null : (string) $group2, - 'seconds' => (int) $aggregate->get(0)->aggregate, - 'cost' => (int) $aggregate->get(0)->cost, - ]; - $group2ResponseSum += (int) $aggregate->get(0)->aggregate; - $group2ResponseCost += (int) $aggregate->get(0)->cost; - } - } else { - /** @var Collection $group1Aggregates */ - $group2ResponseSum = (int) $group1Aggregates->get(0)->aggregate; - $group2ResponseCost = (int) $group1Aggregates->get(0)->cost; - $group2Response = null; - } - - /** @var string $group1Type */ - $group1Response[] = [ - 'type' => $group1Type, - 'key' => $group1 === '' ? null : (string) $group1, - 'seconds' => $group2ResponseSum, - 'cost' => $group2ResponseCost, - 'grouped_data' => $group2Response, - ]; - $group1ResponseSum += $group2ResponseSum; - $group1ResponseCost += $group2ResponseCost; - } - } else { - $group1Response = null; - /** @var Collection $timeEntriesAggregates */ - $group1ResponseSum = (int) $timeEntriesAggregates->get(0)->aggregate; - $group1ResponseCost = (int) $timeEntriesAggregates->get(0)->cost; - } return [ - 'data' => [ - 'grouped_data' => $group1Response, - 'seconds' => $group1ResponseSum, - 'cost' => $group1ResponseCost, - ], + 'data' => $aggregatedData, ]; } - private function getGroupByQuery(string $group, string $timezone, Weekday $startOfWeek): string - { - $timezoneShift = app(TimezoneService::class)->getShiftFromUtc(new CarbonTimeZone($timezone)); - if ($timezoneShift > 0) { - $dateWithTimeZone = 'start + INTERVAL \''.$timezoneShift.' second\''; - } elseif ($timezoneShift < 0) { - $dateWithTimeZone = 'start - INTERVAL \''.abs($timezoneShift).' second\''; - } else { - $dateWithTimeZone = 'start'; - } - $startOfWeek = Carbon::now()->setTimezone($timezone)->startOfWeek($startOfWeek->carbonWeekDay())->utc()->toDateTimeString(); - if ($group === 'day') { - return 'date('.$dateWithTimeZone.')'; - } elseif ($group === 'week') { - return "to_char(date_bin('7 days', ".$dateWithTimeZone.", timestamp '".$startOfWeek."'), 'YYYY-MM-DD HH24:MI:SS')"; - } elseif ($group === 'month') { - return 'to_char('.$dateWithTimeZone.', \'YYYY-MM\')'; - } elseif ($group === 'year') { - return 'to_char('.$dateWithTimeZone.', \'YYYY\')'; - } elseif ($group === 'user') { - return 'user_id'; - } elseif ($group === 'project') { - return 'project_id'; - } elseif ($group === 'task') { - return 'task_id'; - } elseif ($group === 'client') { - return 'client_id'; - } elseif ($group === 'billable') { - return 'billable'; - } - - throw new \LogicException('Invalid group'); - } - /** * Create time entry * diff --git a/app/Http/Controllers/Web/HealthCheckController.php b/app/Http/Controllers/Web/HealthCheckController.php index 3b4c38c2..5851354f 100644 --- a/app/Http/Controllers/Web/HealthCheckController.php +++ b/app/Http/Controllers/Web/HealthCheckController.php @@ -6,9 +6,9 @@ namespace App\Http\Controllers\Web; use App\Http\Controllers\Controller; use App\Models\User; -use Carbon\Carbon; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; diff --git a/app/Http/Requests/V1/Task/TaskIndexRequest.php b/app/Http/Requests/V1/Task/TaskIndexRequest.php index 8d3b7480..1da3d4b6 100644 --- a/app/Http/Requests/V1/Task/TaskIndexRequest.php +++ b/app/Http/Requests/V1/Task/TaskIndexRequest.php @@ -33,7 +33,7 @@ class TaskIndexRequest extends FormRequest $builder = $builder->whereBelongsTo($this->organization, 'organization'); if (! app(PermissionStore::class)->has($this->organization, 'tasks:view:all')) { - $builder = $builder->visibleByUser(Auth::user()); + $builder = $builder->visibleByEmployee(Auth::user()); } return $builder; diff --git a/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateRequest.php b/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateRequest.php index ee2dcc33..6bae8f24 100644 --- a/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateRequest.php +++ b/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateRequest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Http\Requests\V1\TimeEntry; +use App\Enums\TimeEntryAggregationType; use App\Models\Member; use App\Models\Organization; use App\Models\Project; @@ -13,7 +14,8 @@ use App\Models\User; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Database\Eloquent\Builder; use Illuminate\Foundation\Http\FormRequest; -use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Carbon; +use Illuminate\Validation\Rule; use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent; /** @@ -24,7 +26,7 @@ class TimeEntryAggregateRequest extends FormRequest /** * Get the validation rules that apply to the request. * - * @return array> + * @return array> */ public function rules(): array { @@ -32,12 +34,12 @@ class TimeEntryAggregateRequest extends FormRequest 'group' => [ 'nullable', 'required_with:group_2', - 'in:day,week,month,year,user,project,task,client,billable', + Rule::enum(TimeEntryAggregationType::class), ], 'sub_group' => [ 'nullable', - 'in:day,week,month,year,user,project,task,client,billable', + Rule::enum(TimeEntryAggregationType::class), ], // Filter by member ID 'member_id' => [ @@ -81,7 +83,7 @@ class TimeEntryAggregateRequest extends FormRequest 'uuid', new ExistsEloquent(Project::class, null, function (Builder $builder): Builder { /** @var Builder $builder */ - return $builder->visibleByUser(Auth::user()); + return $builder->whereBelongsTo($this->organization, 'organization'); }), ], // Filter by tag IDs, tag IDs are AND combined @@ -106,8 +108,7 @@ class TimeEntryAggregateRequest extends FormRequest 'string', 'uuid', new ExistsEloquent(Task::class, null, function (Builder $builder): Builder { - /** @var Builder $builder */ - return $builder->visibleByUser(Auth::user()); + return $builder->whereBelongsTo($this->organization, 'organization'); }), ], // Filter only time entries that have a start date before the given timestamp in UTC (example: 2021-01-01T00:00:00Z) @@ -133,6 +134,35 @@ class TimeEntryAggregateRequest extends FormRequest 'string', 'in:true,false', ], + 'fill_gaps_in_time_groups' => [ + 'string', + 'in:true,false', + ], ]; } + + public function getGroup(): ?TimeEntryAggregationType + { + return $this->get('group') !== null ? TimeEntryAggregationType::from($this->get('group')) : null; + } + + public function getSubGroup(): ?TimeEntryAggregationType + { + return $this->get('sub_group') !== null ? TimeEntryAggregationType::from($this->get('sub_group')) : null; + } + + public function getFillGapsInTimeGroups(): bool + { + return $this->has('fill_gaps_in_time_groups') && $this->get('fill_gaps_in_time_groups') === 'true'; + } + + public function getStart(): ?Carbon + { + return $this->get('after') !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->get('after'), 'UTC') : null; + } + + public function getEnd(): ?Carbon + { + return $this->get('before') !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->get('before'), 'UTC') : null; + } } diff --git a/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php b/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php index ca15392c..26957f21 100644 --- a/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php +++ b/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php @@ -12,7 +12,6 @@ use App\Models\Task; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Database\Eloquent\Builder; use Illuminate\Foundation\Http\FormRequest; -use Illuminate\Support\Facades\Auth; use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent; /** @@ -60,7 +59,7 @@ class TimeEntryIndexRequest extends FormRequest 'uuid', new ExistsEloquent(Project::class, null, function (Builder $builder): Builder { /** @var Builder $builder */ - return $builder->visibleByUser(Auth::user()); + return $builder->whereBelongsTo($this->organization, 'organization'); }), ], // Filter by tag IDs, tag IDs are AND combined @@ -86,7 +85,7 @@ class TimeEntryIndexRequest extends FormRequest 'uuid', new ExistsEloquent(Task::class, null, function (Builder $builder): Builder { /** @var Builder $builder */ - return $builder->visibleByUser(Auth::user()); + return $builder->whereBelongsTo($this->organization, 'organization'); }), ], // Filter only time entries that have a start date before the given timestamp in UTC (example: 2021-01-01T00:00:00Z) @@ -125,4 +124,9 @@ class TimeEntryIndexRequest extends FormRequest ], ]; } + + public function getOnlyFullDates(): bool + { + return $this->input('only_full_dates', 'false') === 'true'; + } } diff --git a/app/Http/Resources/V1/BaseResource.php b/app/Http/Resources/V1/BaseResource.php index 5ffefb03..e757881c 100644 --- a/app/Http/Resources/V1/BaseResource.php +++ b/app/Http/Resources/V1/BaseResource.php @@ -4,8 +4,8 @@ declare(strict_types=1); namespace App\Http\Resources\V1; -use Carbon\Carbon; use Illuminate\Http\Resources\Json\JsonResource; +use Illuminate\Support\Carbon; abstract class BaseResource extends JsonResource { diff --git a/app/Models/Project.php b/app/Models/Project.php index d6e2da26..47daddd4 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -25,7 +25,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany; * @property-read Collection $tasks * @property-read Collection $members * - * @method Builder visibleByUser(User $user) + * @method Builder visibleByEmployee(User $user) * @method static ProjectFactory factory() */ class Project extends Model @@ -64,7 +64,7 @@ class Project extends Model */ public function members(): HasMany { - return $this->hasMany(ProjectMember::class); + return $this->hasMany(ProjectMember::class, 'project_id'); } /** @@ -86,7 +86,7 @@ class Project extends Model /** * @param Builder $builder */ - public function scopeVisibleByUser(Builder $builder, User $user): void + public function scopeVisibleByEmployee(Builder $builder, User $user): void { $builder->where(function (Builder $builder) use ($user): Builder { return $builder->where('is_public', '=', true) diff --git a/app/Models/Task.php b/app/Models/Task.php index b520dce8..c1cb1962 100644 --- a/app/Models/Task.php +++ b/app/Models/Task.php @@ -69,11 +69,11 @@ class Task extends Model * @param Builder $builder * @return Builder */ - public function scopeVisibleByUser(Builder $builder, User $user): Builder + public function scopeVisibleByEmployee(Builder $builder, User $user): Builder { return $builder->whereHas('project', function (Builder $builder) use ($user): Builder { /** @var Builder $builder */ - return $builder->visibleByUser($user); + return $builder->visibleByEmployee($user); }); } } diff --git a/app/Service/DashboardService.php b/app/Service/DashboardService.php index 568d9905..3d11ba2b 100644 --- a/app/Service/DashboardService.php +++ b/app/Service/DashboardService.php @@ -10,9 +10,9 @@ use App\Models\Project; use App\Models\Task; use App\Models\TimeEntry; use App\Models\User; -use Carbon\Carbon; use Carbon\CarbonTimeZone; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Support\Carbon; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; diff --git a/app/Service/Import/Importers/TogglDataImporter.php b/app/Service/Import/Importers/TogglDataImporter.php index f1d2b78a..136e7fb0 100644 --- a/app/Service/Import/Importers/TogglDataImporter.php +++ b/app/Service/Import/Importers/TogglDataImporter.php @@ -36,6 +36,9 @@ class TogglDataImporter extends DefaultImporter throw new ImportException('File "clients.json" can not be opened'); } $clients = json_decode($clientsFileContent); + if ($clients === null) { + throw new ImportException('File "clients.json" is empty'); + } if (! file_exists($temporaryDirectory->path('projects.json'))) { throw new ImportException('File "projects.json" missing in ZIP'); } @@ -44,6 +47,9 @@ class TogglDataImporter extends DefaultImporter throw new ImportException('File "projects.json" can not be opened'); } $projects = json_decode($projectsFileContent); + if ($projects === null) { + throw new ImportException('File "projects.json" is empty'); + } if (! file_exists($temporaryDirectory->path('tags.json'))) { throw new ImportException('File "tags.json" missing in ZIP'); } @@ -52,6 +58,9 @@ class TogglDataImporter extends DefaultImporter throw new ImportException('File "tags.json" can not be opened'); } $tags = json_decode($tagsFileContent); + if ($tags === null) { + throw new ImportException('File "tags.json" is empty'); + } if (! file_exists($temporaryDirectory->path('workspace_users.json'))) { throw new ImportException('File "workspace_users.json" missing in ZIP'); } @@ -60,6 +69,9 @@ class TogglDataImporter extends DefaultImporter throw new ImportException('File "workspace_users.json" can not be opened'); } $workspaceUsers = json_decode($workspaceUsersFileContent); + if ($workspaceUsers === null) { + throw new ImportException('File "workspace_users.json" is empty'); + } foreach ($clients as $client) { $this->clientImportHelper->getKey([ 'name' => $client->name, @@ -117,6 +129,9 @@ class TogglDataImporter extends DefaultImporter throw new ImportException('File "projects_users/'.$project->id.'.json" can not be opened'); } $projectMembers = json_decode($projectMembersFileContent); + if ($projectMembers === null) { + throw new ImportException('File "projects_users/'.$project->id.'.json" is empty'); + } foreach ($projectMembers as $projectMember) { $userId = $this->userImportHelper->getKeyByExternalIdentifier((string) $projectMember->user_id); $this->projectMemberImportHelper->getKey([ @@ -138,6 +153,9 @@ class TogglDataImporter extends DefaultImporter throw new ImportException('File "tasks/'.$projectIdExternal.'.json" can not be opened'); } $tasks = json_decode($tasksFileContent); + if ($tasks === null) { + throw new ImportException('File "tasks/'.$projectIdExternal.'.json" is empty'); + } foreach ($tasks as $task) { $projectId = $this->projectImportHelper->getKeyByExternalIdentifier((string) $projectIdExternal); diff --git a/app/Service/TimeEntryAggregationService.php b/app/Service/TimeEntryAggregationService.php new file mode 100644 index 00000000..2e75d536 --- /dev/null +++ b/app/Service/TimeEntryAggregationService.php @@ -0,0 +1,295 @@ + $timeEntriesQuery + * @return array{ + * grouped_type: string|null, + * grouped_data: null|array + * }>, + * seconds: int, + * cost: int + * } + */ + public function getAggregatedTimeEntries(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end): array + { + $fillGapsInTimeGroupsIsPossible = $fillGapsInTimeGroups && $start !== null && $end !== null; + $group1Select = null; + $group2Select = null; + $groupBy = null; + if ($group1Type !== null) { + $group1Select = $this->getGroupByQuery($group1Type, $timezone, $startOfWeek); + $groupBy = ['group_1']; + if ($group2Type !== null) { + $group2Select = $this->getGroupByQuery($group2Type, $timezone, $startOfWeek); + $groupBy = ['group_1', 'group_2']; + } + } + + $timeEntriesQuery->selectRaw( + ($group1Select !== null ? $group1Select.' as group_1,' : ''). + ($group2Select !== null ? $group2Select.' as group_2,' : ''). + ' round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate,'. + ' round( + sum( + extract(epoch from (coalesce("end", now()) - start)) * (coalesce(billable_rate, 0)::float/60/60) + ) + ) as cost' + ); + if ($groupBy !== null) { + $timeEntriesQuery->groupBy($groupBy); + } + + $timeEntriesAggregates = $timeEntriesQuery->get(); + + if ($group1Select !== null) { + $groupedAggregates = $timeEntriesAggregates->groupBy($group2Select !== null ? ['group_1', 'group_2'] : ['group_1']); + + $group1Response = []; + $group1ResponseSum = 0; + $group1ResponseCost = 0; + foreach ($groupedAggregates as $group1 => $group1Aggregates) { + /** @var string|int $group1 */ + $group2Response = []; + if ($group2Select !== null) { + $group2ResponseSum = 0; + $group2ResponseCost = 0; + foreach ($group1Aggregates as $group2 => $aggregate) { + /** @var string|int $group2 */ + /** @var Collection $aggregate */ + $group2Response[] = [ + 'key' => $group2 === '' ? null : (string) $group2, + 'seconds' => (int) $aggregate->get(0)->aggregate, + 'cost' => (int) $aggregate->get(0)->cost, + 'grouped_type' => null, + 'grouped_data' => null, + ]; + $group2ResponseSum += (int) $aggregate->get(0)->aggregate; + $group2ResponseCost += (int) $aggregate->get(0)->cost; + } + } else { + /** @var Collection $group1Aggregates */ + $group2ResponseSum = (int) $group1Aggregates->get(0)->aggregate; + $group2ResponseCost = (int) $group1Aggregates->get(0)->cost; + $group2Response = null; + } + + $group1Response[] = [ + 'key' => $group1 === '' ? null : (string) $group1, + 'seconds' => $group2ResponseSum, + 'cost' => $group2ResponseCost, + 'grouped_type' => $group2Type?->value, + 'grouped_data' => $group2Response, + ]; + $group1ResponseSum += $group2ResponseSum; + $group1ResponseCost += $group2ResponseCost; + } + + if ($fillGapsInTimeGroupsIsPossible) { + $group1Response = $this->fillGapsInTimeGroups($group1Response, $group1Type, $group2Type, $timezone, $startOfWeek, $start, $end); + } + } else { + $group1Response = null; + /** @var Collection $timeEntriesAggregates */ + $group1ResponseSum = (int) $timeEntriesAggregates->get(0)->aggregate; + $group1ResponseCost = (int) $timeEntriesAggregates->get(0)->cost; + } + + return [ + 'seconds' => $group1ResponseSum, + 'cost' => $group1ResponseCost, + 'grouped_type' => $group1Type?->value, + 'grouped_data' => $group1Response, + ]; + } + + /** + * @param array + * }> $data + * @return array + * }> + */ + public function fillGapsInTimeGroups(array $data, TimeEntryAggregationType $groupType, ?TimeEntryAggregationType $subGroupType, string $timezone, Weekday $startOfWeek, Carbon $start, Carbon $end): array + { + $interval = $groupType->toInterval(); + if ($interval === null) { + foreach ($data as $key => $item) { + $data[$key]['grouped_data'] = $this->fillGapsInTimeGroups( + $item['grouped_data'], + $subGroupType, + null, + $timezone, + $startOfWeek, + $start, + $end + ); + } + + return $data; + } else { + $format = match ($interval) { + TimeEntryAggregationTypeInterval::Day => 'Y-m-d', + TimeEntryAggregationTypeInterval::Week => 'Y-m-d H:i:s', + TimeEntryAggregationTypeInterval::Month => 'Y-m', + TimeEntryAggregationTypeInterval::Year => 'Y', + }; + $slots = $this->timeSlotsBetween($start, $end, $timezone, $startOfWeek, $interval, $format); + $filledData = []; + foreach ($slots as $slot) { + $foundDataSet = null; + foreach ($data as $item) { + if ($item['key'] === $slot) { + $foundDataSet = $item; + break; + } + } + if ($foundDataSet !== null) { + $filledData[] = [ + 'key' => $slot, + 'seconds' => $foundDataSet['seconds'], + 'cost' => $foundDataSet['cost'], + 'grouped_type' => $subGroupType?->value, + 'grouped_data' => $subGroupType === null + ? null + : $this->fillGapsInTimeGroups( + $foundDataSet['grouped_data'], + $subGroupType, + null, + $timezone, + $startOfWeek, + $start, + $end + ), + ]; + } else { + $filledData[] = [ + 'key' => $slot, + 'seconds' => 0, + 'cost' => 0, + 'grouped_type' => $subGroupType?->value, + 'grouped_data' => $subGroupType === null ? null : [], + ]; + } + } + + return $filledData; + } + } + + private function getGroupByQuery(TimeEntryAggregationType $group, string $timezone, Weekday $startOfWeek): string + { + $timezoneShift = app(TimezoneService::class)->getShiftFromUtc(new CarbonTimeZone($timezone)); + if ($timezoneShift > 0) { + $dateWithTimeZone = 'start + INTERVAL \''.$timezoneShift.' second\''; + } elseif ($timezoneShift < 0) { + $dateWithTimeZone = 'start - INTERVAL \''.abs($timezoneShift).' second\''; + } else { + $dateWithTimeZone = 'start'; + } + $startOfWeek = Carbon::now()->setTimezone($timezone)->startOfWeek($startOfWeek->carbonWeekDay())->utc()->toDateTimeString(); + if ($group === TimeEntryAggregationType::Day) { + return 'date('.$dateWithTimeZone.')'; + } elseif ($group === TimeEntryAggregationType::Week) { + return "to_char(date_bin('7 days', ".$dateWithTimeZone.", timestamp '".$startOfWeek."'), 'YYYY-MM-DD HH24:MI:SS')"; + } elseif ($group === TimeEntryAggregationType::Month) { + return 'to_char('.$dateWithTimeZone.', \'YYYY-MM\')'; + } elseif ($group === TimeEntryAggregationType::Year) { + return 'to_char('.$dateWithTimeZone.', \'YYYY\')'; + } elseif ($group === TimeEntryAggregationType::User) { + return 'user_id'; + } elseif ($group === TimeEntryAggregationType::Project) { + return 'project_id'; + } elseif ($group === TimeEntryAggregationType::Task) { + return 'task_id'; + } elseif ($group === TimeEntryAggregationType::Client) { + return 'client_id'; + } elseif ($group === TimeEntryAggregationType::Billable) { + return 'billable'; + } + } + + /** + * @return Collection + */ + public function timeSlotsBetween(Carbon $start, Carbon $end, string $timezone, Weekday $startOfWeek, TimeEntryAggregationTypeInterval $interval, string $format): Collection + { + if ($start->gt($end)) { + throw new \InvalidArgumentException('Start date must be before end date'); + } + $slots = new Collection(); + $current = $start->copy()->timezone($timezone); + if ($interval === TimeEntryAggregationTypeInterval::Day) { + $current->startOfDay(); + } elseif ($interval === TimeEntryAggregationTypeInterval::Week) { + $current->startOfWeek($startOfWeek->carbonWeekDay())->utc(); + } elseif ($interval === TimeEntryAggregationTypeInterval::Month) { + $current->startOfMonth(); + } elseif ($interval === TimeEntryAggregationTypeInterval::Year) { + $current->startOfYear(); + } else { + throw new \InvalidArgumentException('Invalid interval'); + } + + while ($current->lt($end)) { + $slots->push($current->format($format)); + if ($interval === TimeEntryAggregationTypeInterval::Day) { + $current->addDay(); + } elseif ($interval === TimeEntryAggregationTypeInterval::Week) { + $current->addWeek(); + } elseif ($interval === TimeEntryAggregationTypeInterval::Month) { + $current->addMonth(); + } elseif ($interval === TimeEntryAggregationTypeInterval::Year) { + $current->addYear(); + } + } + + return $slots; + } +} diff --git a/app/Service/TimezoneService.php b/app/Service/TimezoneService.php index 6714c775..ec430a2f 100644 --- a/app/Service/TimezoneService.php +++ b/app/Service/TimezoneService.php @@ -5,8 +5,8 @@ declare(strict_types=1); namespace App\Service; use App\Models\User; -use Carbon\Carbon; use Carbon\CarbonTimeZone; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Log; class TimezoneService diff --git a/database/factories/TimeEntryFactory.php b/database/factories/TimeEntryFactory.php index de34c9d1..c565a78b 100644 --- a/database/factories/TimeEntryFactory.php +++ b/database/factories/TimeEntryFactory.php @@ -11,8 +11,8 @@ use App\Models\Tag; use App\Models\Task; use App\Models\TimeEntry; use App\Models\User; -use Carbon\Carbon; use Illuminate\Database\Eloquent\Factories\Factory; +use Illuminate\Support\Carbon; /** * @extends Factory @@ -67,11 +67,13 @@ class TimeEntryFactory extends Factory }); } - public function startBetween(Carbon $rangeStart, Carbon $rangeEnd): self + public function startBetween(Carbon $rangeStart, Carbon $rangeEnd, bool $fixedValueForMultiple = false): self { - $start = Carbon::instance($this->faker->dateTimeBetween($rangeStart, $rangeEnd)); + $fixedStart = Carbon::instance($this->faker->dateTimeBetween($rangeStart, $rangeEnd)); + + return $this->state(function (array $attributes) use ($rangeStart, $rangeEnd, $fixedStart, $fixedValueForMultiple): array { + $start = $fixedValueForMultiple ? $fixedStart : Carbon::instance($this->faker->dateTimeBetween($rangeStart, $rangeEnd)); - return $this->state(function (array $attributes) use ($start): array { return [ 'start' => $start->utc(), 'end' => $this->faker->dateTimeBetween($start, 'now'), @@ -111,6 +113,34 @@ class TimeEntryFactory extends Factory }); } + public function billable(): self + { + return $this->state(function (array $attributes): array { + return [ + 'billable' => true, + ]; + }); + } + + public function startWithDuration(Carbon $start, int $durationInSeconds): self + { + return $this->state(function (array $attributes) use ($start, $durationInSeconds): array { + return [ + 'start' => $start->utc(), + 'end' => $start->copy()->addSeconds($durationInSeconds), + ]; + }); + } + + public function start(Carbon $start): self + { + return $this->state(function (array $attributes) use ($start): array { + return [ + 'start' => $start->utc(), + ]; + }); + } + public function forOrganization(Organization $organization): self { return $this->state(function (array $attributes) use ($organization) { diff --git a/tests/TestCaseWithDatabase.php b/tests/TestCaseWithDatabase.php new file mode 100644 index 00000000..880a3652 --- /dev/null +++ b/tests/TestCaseWithDatabase.php @@ -0,0 +1,43 @@ + $permissions + * @return object{user: User, organization: Organization, member: Member} + */ + protected function createUserWithPermission(array $permissions = [], bool $isOwner = false): object + { + $roleName = 'custom-test-'.Str::uuid(); + Jetstream::role($roleName, 'Custom Test', $permissions) + ->description('Role custom for testing'); + $user = User::factory()->create(); + if ($isOwner) { + $organization = Organization::factory()->withOwner($user)->create(); + } else { + $organization = Organization::factory()->create(); + } + $member = Member::factory()->forUser($user)->forOrganization($organization)->create([ + 'role' => $roleName, + ]); + + return (object) [ + 'user' => $user, + 'organization' => $organization, + 'member' => $member, + ]; + } +} diff --git a/tests/Unit/Endpoint/Api/V1/ApiEndpointTestAbstract.php b/tests/Unit/Endpoint/Api/V1/ApiEndpointTestAbstract.php index 4d8efb74..4f9702a5 100644 --- a/tests/Unit/Endpoint/Api/V1/ApiEndpointTestAbstract.php +++ b/tests/Unit/Endpoint/Api/V1/ApiEndpointTestAbstract.php @@ -4,41 +4,8 @@ declare(strict_types=1); namespace Tests\Unit\Endpoint\Api\V1; -use App\Models\Member; -use App\Models\Organization; -use App\Models\User; -use Illuminate\Foundation\Testing\RefreshDatabase; -use Illuminate\Support\Str; -use Laravel\Jetstream\Jetstream; -use Tests\TestCase; +use Tests\TestCaseWithDatabase; -class ApiEndpointTestAbstract extends TestCase +class ApiEndpointTestAbstract extends TestCaseWithDatabase { - use RefreshDatabase; - - /** - * @param array $permissions - * @return object{user: User, organization: Organization, member: Member} - */ - protected function createUserWithPermission(array $permissions = [], bool $isOwner = false): object - { - $roleName = 'custom-test-'.Str::uuid(); - Jetstream::role($roleName, 'Custom Test', $permissions) - ->description('Role custom for testing'); - $user = User::factory()->create(); - if ($isOwner) { - $organization = Organization::factory()->withOwner($user)->create(); - } else { - $organization = Organization::factory()->create(); - } - $member = Member::factory()->forUser($user)->forOrganization($organization)->create([ - 'role' => $roleName, - ]); - - return (object) [ - 'user' => $user, - 'organization' => $organization, - 'member' => $member, - ]; - } } diff --git a/tests/Unit/Endpoint/Api/V1/InvitationEndpointTest.php b/tests/Unit/Endpoint/Api/V1/InvitationEndpointTest.php index 70198147..3baeceee 100644 --- a/tests/Unit/Endpoint/Api/V1/InvitationEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/InvitationEndpointTest.php @@ -32,6 +32,7 @@ class InvitationEndpointTest extends ApiEndpointTestAbstract $data = $this->createUserWithPermission([ 'invitations:view', ]); + $invitation1 = OrganizationInvitation::factory()->forOrganization($data->organization)->create(); Passport::actingAs($data->user); // Act @@ -39,6 +40,15 @@ class InvitationEndpointTest extends ApiEndpointTestAbstract // Assert $response->assertStatus(200); + $response->assertJson([ + 'data' => [ + [ + 'id' => $invitation1->getKey(), + 'email' => $invitation1->email, + 'role' => $invitation1->role, + ], + ], + ]); } public function test_store_fails_if_user_has_no_permission_to_create_invitations(): void diff --git a/tests/Unit/Endpoint/Api/V1/ProjectEndpointTest.php b/tests/Unit/Endpoint/Api/V1/ProjectEndpointTest.php index d1aab704..75ea7bad 100644 --- a/tests/Unit/Endpoint/Api/V1/ProjectEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/ProjectEndpointTest.php @@ -7,6 +7,7 @@ namespace Tests\Unit\Endpoint\Api\V1; use App\Models\Client; use App\Models\Organization; use App\Models\Project; +use App\Models\ProjectMember; use App\Models\Task; use App\Models\TimeEntry; use Laravel\Passport\Passport; @@ -377,13 +378,14 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract ]); } - public function test_destroy_endpoint_deletes_project(): void + public function test_destroy_endpoint_deletes_project_with_project_members(): void { // Arrange $data = $this->createUserWithPermission([ 'projects:delete', ]); $project = Project::factory()->forOrganization($data->organization)->create(); + $projectMember = ProjectMember::factory()->forMember($data->member)->forProject($project)->create(); Passport::actingAs($data->user); // Act @@ -395,5 +397,8 @@ class ProjectEndpointTest extends ApiEndpointTestAbstract $this->assertDatabaseMissing(Project::class, [ 'id' => $project->getKey(), ]); + $this->assertDatabaseMissing(ProjectMember::class, [ + 'id' => $projectMember->getKey(), + ]); } } diff --git a/tests/Unit/Endpoint/Api/V1/ProjectMemberEndpointTest.php b/tests/Unit/Endpoint/Api/V1/ProjectMemberEndpointTest.php index 0662e24c..29edad42 100644 --- a/tests/Unit/Endpoint/Api/V1/ProjectMemberEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/ProjectMemberEndpointTest.php @@ -327,7 +327,7 @@ class ProjectMemberEndpointTest extends ApiEndpointTestAbstract ]); } - public function test_destroy_endpoint_fails_if_user_has_no_permission_to_delete_projects(): void + public function test_destroy_endpoint_fails_if_user_has_no_permission_to_delete_project_members(): void { // Arrange $data = $this->createUserWithPermission([ @@ -346,14 +346,14 @@ class ProjectMemberEndpointTest extends ApiEndpointTestAbstract ]); } - public function test_destroy_endpoint_deletes_project(): void + public function test_destroy_endpoint_deletes_project_member(): void { // Arrange $data = $this->createUserWithPermission([ 'project-members:delete', ]); $project = Project::factory()->forOrganization($data->organization)->create(); - $projectMember = ProjectMember::factory()->forProject($project)->create(); + $projectMember = ProjectMember::factory()->forProject($project)->forMember($data->member)->create(); Passport::actingAs($data->user); // Act diff --git a/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php b/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php index 933d734c..71d7af6d 100644 --- a/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php @@ -8,10 +8,11 @@ use App\Enums\Role; use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException; use App\Models\Member; use App\Models\Project; +use App\Models\Tag; use App\Models\Task; use App\Models\TimeEntry; use App\Models\User; -use Carbon\Carbon; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; use Illuminate\Testing\Fluent\AssertableJson; @@ -293,7 +294,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract 'time-entries:view:own', ]); $timeEntriesDay1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member) - ->startBetween(Carbon::now()->subDay()->startOfDay(), Carbon::now()->subDay()->endOfDay()) + ->startBetween(Carbon::now()->subDay()->startOfDay(), Carbon::now()->subDay()->endOfDay(), true) ->createMany(7); Passport::actingAs($data->user); @@ -306,11 +307,11 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract ])); // Assert + $response->assertStatus(200); + $response->assertJsonCount(7, 'data'); Log::assertLogged(fn (LogEntry $log) => $log->level === 'warning' && $log->message === 'User has has more than 5 time entries on one date' ); - $response->assertStatus(200); - $response->assertJsonCount(7, 'data'); } public function test_index_endpoint_before_filter_returns_time_entries_before_date(): void @@ -331,6 +332,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract Carbon::now()->timezone($data->user->timezone)->subDays(2)->endOfDay()->utc() ) ->createMany(3); + $timeEntriesBeforeSorted = $timeEntriesBefore->sortByDesc('start')->values(); $timeEntriesDirectlyBeforeLimit = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member) ->create([ 'start' => Carbon::now()->timezone($data->user->timezone)->subDays(2)->endOfDay()->utc(), @@ -350,9 +352,9 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract ->has('data') ->count('data', 4) ->where('data.0.id', $timeEntriesDirectlyBeforeLimit->getKey()) - ->where('data.1.id', $timeEntriesBefore->sortByDesc('start')->get(0)->getKey()) - ->where('data.2.id', $timeEntriesBefore->sortByDesc('start')->get(1)->getKey()) - ->where('data.3.id', $timeEntriesBefore->sortByDesc('start')->get(2)->getKey()) + ->where('data.1.id', $timeEntriesBeforeSorted->get(0)->getKey()) + ->where('data.2.id', $timeEntriesBeforeSorted->get(1)->getKey()) + ->where('data.3.id', $timeEntriesBeforeSorted->get(2)->getKey()) ); } @@ -365,6 +367,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract $timeEntriesAfter = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member) ->startBetween(Carbon::now($data->user->timezone)->startOfDay()->utc(), Carbon::now($data->user->timezone)->utc()) ->createMany(3); + $timeEntriesAfterSorted = $timeEntriesAfter->sortByDesc('start')->values(); $timeEntriesBefore = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member) ->startBetween(Carbon::now($data->user->timezone)->subDay()->startOfDay()->utc(), Carbon::now($data->user->timezone)->subDay()->endOfDay()->utc()) ->createMany(3); @@ -386,13 +389,61 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract $response->assertJson(fn (AssertableJson $json) => $json ->has('data') ->count('data', 4) - ->where('data.0.id', $timeEntriesAfter->sortByDesc('start')->get(0)->getKey()) - ->where('data.1.id', $timeEntriesAfter->sortByDesc('start')->get(1)->getKey()) - ->where('data.2.id', $timeEntriesAfter->sortByDesc('start')->get(2)->getKey()) + ->where('data.0.id', $timeEntriesAfterSorted->get(0)->getKey()) + ->where('data.1.id', $timeEntriesAfterSorted->get(1)->getKey()) + ->where('data.2.id', $timeEntriesAfterSorted->get(2)->getKey()) ->where('data.3.id', $timeEntriesDirectlyAfterLimit->getKey()) ); } + public function test_index_endpoint_with_all_available_filters(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'time-entries:view:all', + 'time-entries:view:own', + ]); + $project = Project::factory()->forOrganization($data->organization)->create(); + $task = Task::factory()->forOrganization($data->organization)->forProject($project)->create(); + $tag = Tag::factory()->forOrganization($data->organization)->create(); + $timeEntry1 = TimeEntry::factory() + ->forOrganization($data->organization) + ->forProject($project) + ->forTask($task) + ->forMember($data->member) + ->billable() + ->active() + ->create([ + 'start' => Carbon::now()->subHour(), + 'tags' => [$tag->getKey()], + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.time-entries.index', [ + $data->organization->getKey(), + 'member_id' => $data->member->getKey(), + 'member_ids' => [$data->member->getKey()], + 'project_ids' => [$project->getKey()], + 'task_ids' => [$task->getKey()], + 'tag_ids' => [$tag->getKey()], + 'before' => Carbon::now()->toIso8601ZuluString(), + 'after' => Carbon::now()->subDay()->toIso8601ZuluString(), + 'active' => 'true', + 'only_full_dates' => 'true', + 'limit' => 1, + ])); + + // Assert + $response->assertValid(); + $response->assertStatus(200); + $response->assertJson(fn (AssertableJson $json) => $json + ->has('data') + ->count('data', 1) + ->where('data.0.id', $timeEntry1->getKey()) + ); + } + public function test_aggregate_endpoint_fails_if_user_has_no_permission_to_view_time_entries(): void { // Arrange @@ -413,12 +464,13 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract $data = $this->createUserWithPermission([ 'time-entries:view:all', ]); - $timeEntries = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->createMany(3); $project = Project::factory()->forOrganization($data->organization)->create(); - $timeEntries = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->forProject($project)->createMany(3); - $timeEntries = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->state([ - 'start' => $timeEntries->get(0)->start, - ])->createMany(3); + $day1 = Carbon::now()->timezone($data->user->timezone)->subDays(1)->utc(); + $day2 = Carbon::now()->timezone($data->user->timezone)->subDays(3)->utc(); + $timeEntry1NoProject = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration($day1, 10)->create(); + $timeEntry2NoProject = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration($day2, 10)->create(); + $timeEntry1WithProject = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->forProject($project)->startWithDuration($day1, 10)->create(); + $timeEntry2WithProject = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->forProject($project)->startWithDuration($day2, 10)->create(); Passport::actingAs($data->user); // Act @@ -430,6 +482,154 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract // Assert $response->assertSuccessful(); + $response->assertExactJson([ + 'data' => [ + 'seconds' => 40, + 'cost' => 0, + 'grouped_data' => [ + 0 => [ + 'key' => $day2->format('Y-m-d'), + 'seconds' => 20, + 'cost' => 0, + 'grouped_type' => 'project', + 'grouped_data' => [ + 0 => [ + 'key' => $project->getKey(), + 'seconds' => 10, + 'cost' => 0, + 'grouped_type' => null, + 'grouped_data' => null, + ], + 1 => [ + 'key' => null, + 'seconds' => 10, + 'cost' => 0, + 'grouped_type' => null, + 'grouped_data' => null, + ], + ], + ], + 1 => [ + 'key' => $day1->format('Y-m-d'), + 'seconds' => 20, + 'cost' => 0, + 'grouped_type' => 'project', + 'grouped_data' => [ + 0 => [ + 'key' => $project->getKey(), + 'seconds' => 10, + 'cost' => 0, + 'grouped_type' => null, + 'grouped_data' => null, + ], + 1 => [ + 'key' => null, + 'seconds' => 10, + 'cost' => 0, + 'grouped_type' => null, + 'grouped_data' => null, + ], + ], + ], + ], + 'grouped_type' => 'day', + ], + ]); + } + + public function test_aggregate_endpoint_groups_by_two_groups_with_fill_gaps_argument(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'time-entries:view:all', + ]); + $project = Project::factory()->forOrganization($data->organization)->create(); + $day1 = Carbon::now()->timezone($data->user->timezone)->subDays(1)->utc(); + $day2 = Carbon::now()->timezone($data->user->timezone)->subDays(3)->utc(); + $timeEntry1NoProject = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration($day1, 10)->create(); + $timeEntry2NoProject = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration($day2, 10)->create(); + $timeEntry1WithProject = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->forProject($project)->startWithDuration($day1, 10)->create(); + $timeEntry2WithProject = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->forProject($project)->startWithDuration($day2, 10)->create(); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.time-entries.aggregate', [ + $data->organization->getKey(), + 'group' => 'project', + 'sub_group' => 'day', + 'fill_gaps_in_time_groups' => 'true', + 'after' => $day2->copy()->subSecond()->toIso8601ZuluString(), + 'before' => $day1->copy()->addSecond()->toIso8601ZuluString(), + ])); + + // Assert + $response->assertSuccessful(); + $response->assertExactJson(['data' => [ + 'seconds' => 40, + 'cost' => 0, + 'grouped_type' => 'project', + 'grouped_data' => [ + 0 => [ + 'key' => $project->getKey(), + 'seconds' => 20, + 'cost' => 0, + 'grouped_type' => 'day', + 'grouped_data' => [ + 0 => [ + 'key' => $day2->format('Y-m-d'), + 'seconds' => 10, + 'cost' => 0, + 'grouped_type' => null, + 'grouped_data' => null, + ], + 1 => [ + 'key' => $day2->copy()->addDay()->format('Y-m-d'), + 'seconds' => 0, + 'cost' => 0, + 'grouped_type' => null, + 'grouped_data' => null, + ], + 2 => [ + 'key' => $day1->format('Y-m-d'), + 'seconds' => 10, + 'cost' => 0, + 'grouped_type' => null, + 'grouped_data' => null, + ], + ], + ], + 1 => [ + 'key' => null, + 'seconds' => 20, + 'cost' => 0, + 'grouped_type' => 'day', + 'grouped_data' => [ + 0 => [ + 'key' => $day2->format('Y-m-d'), + 'seconds' => 10, + 'cost' => 0, + 'grouped_type' => null, + 'grouped_data' => null, + ], + 1 => [ + 'key' => $day2->copy()->addDay()->format('Y-m-d'), + 'seconds' => 0, + 'cost' => 0, + 'grouped_type' => null, + 'grouped_data' => null, + ], + 2 => [ + 'key' => $day1->format('Y-m-d'), + 'seconds' => 10, + 'cost' => 0, + 'grouped_type' => null, + 'grouped_data' => null, + ], + ], + ], + ], + ], + ]); } public function test_aggregate_endpoint_groups_by_one_group(): void @@ -438,12 +638,12 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract $data = $this->createUserWithPermission([ 'time-entries:view:all', ]); - $timeEntries = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->createMany(3); - $project = Project::factory()->forOrganization($data->organization)->create(); - $timeEntries = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->forProject($project)->createMany(3); - $timeEntries = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->state([ - 'start' => $timeEntries->get(0)->start, - ])->createMany(3); + $week1 = Carbon::now()->timezone($data->user->timezone)->startOfWeek($data->user->week_start->carbonWeekDay())->utc(); + $week2 = Carbon::now()->timezone($data->user->timezone)->subWeeks(2)->startOfWeek($data->user->week_start->carbonWeekDay())->utc(); + $timeEntry1Week1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration($week1->copy()->addDays(1), 10)->create(); + $timeEntry2Week1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration($week1->copy()->addDays(2), 10)->create(); + $timeEntry1Week2 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration($week2->copy()->addDays(3), 10)->create(); + $timeEntry2Week2 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration($week2->copy()->addDays(4), 10)->create(); Passport::actingAs($data->user); // Act @@ -454,6 +654,88 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract // Assert $response->assertSuccessful(); + $response->assertExactJson([ + 'data' => [ + 'seconds' => 40, + 'cost' => 0, + 'grouped_type' => 'week', + 'grouped_data' => [ + 0 => [ + 'key' => $week2->format('Y-m-d H:i:s'), + 'seconds' => 20, + 'cost' => 0, + 'grouped_type' => null, + 'grouped_data' => null, + ], + 1 => [ + 'key' => $week1->format('Y-m-d H:i:s'), + 'seconds' => 20, + 'cost' => 0, + 'grouped_type' => null, + 'grouped_data' => null, + ], + ], + ], + ]); + } + + public function test_aggregate_endpoint_groups_by_one_group_with_fill_gaps_argument(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'time-entries:view:all', + ]); + $laterWeekEnd = Carbon::now()->timezone($data->user->timezone)->endOfWeek($data->user->week_start->toEndOfWeek()->carbonWeekDay())->utc(); + $earlierWeekStart = Carbon::now()->timezone($data->user->timezone)->subWeeks(2)->startOfWeek($data->user->week_start->carbonWeekDay())->utc(); + + $timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration($laterWeekEnd->copy()->subDays(1), 10)->create(); + $timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration($laterWeekEnd->copy()->subDays(2), 10)->create(); + $timeEntry3 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration($earlierWeekStart->copy()->addDays(1), 10)->create(); + $timeEntry4 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration($earlierWeekStart->copy()->addDays(2), 10)->create(); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.time-entries.aggregate', [ + $data->organization->getKey(), + 'group' => 'week', + 'fill_gaps_in_time_groups' => 'true', + 'after' => $earlierWeekStart->toIso8601ZuluString(), + 'before' => $laterWeekEnd->toIso8601ZuluString(), + ])); + + // Assert + $response->assertSuccessful(); + $response->assertExactJson([ + 'data' => [ + 'seconds' => 40, + 'cost' => 0, + 'grouped_type' => 'week', + 'grouped_data' => [ + 0 => [ + 'key' => '2024-05-05 22:00:00', + 'seconds' => 20, + 'cost' => 0, + 'grouped_type' => null, + 'grouped_data' => null, + ], + 1 => [ + 'key' => '2024-05-12 22:00:00', + 'seconds' => 0, + 'cost' => 0, + 'grouped_type' => null, + 'grouped_data' => null, + ], + 2 => [ + 'key' => '2024-05-19 22:00:00', + 'seconds' => 20, + 'cost' => 0, + 'grouped_type' => null, + 'grouped_data' => null, + ], + ], + ], + ] + ); } public function test_aggregate_endpoint_with_no_group(): void diff --git a/tests/Unit/Endpoint/Api/V1/UserTimeEntryEndpointTest.php b/tests/Unit/Endpoint/Api/V1/UserTimeEntryEndpointTest.php index 72e5c9c7..fb090f1b 100644 --- a/tests/Unit/Endpoint/Api/V1/UserTimeEntryEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/UserTimeEntryEndpointTest.php @@ -5,7 +5,10 @@ declare(strict_types=1); namespace Tests\Unit\Endpoint\Api\V1; use App\Models\TimeEntry; +use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Log; use Laravel\Passport\Passport; +use TiMacDonald\Log\LogEntry; class UserTimeEntryEndpointTest extends ApiEndpointTestAbstract { @@ -36,6 +39,28 @@ class UserTimeEntryEndpointTest extends ApiEndpointTestAbstract // Assert $response->assertSuccessful(); + $response->assertJsonPath('data.id', $activeTimeEntry->getKey()); + } + + public function test_my_active_endpoint_logs_a_warning_if_user_has_multiple_active_time_entries_and_return_the_latest_one(): void + { + // Arrange + $data = $this->createUserWithPermission([ + ]); + $activeTimeEntry1 = TimeEntry::factory()->forMember($data->member)->active()->start(Carbon::now()->subDay())->create(); + $activeTimeEntry2 = TimeEntry::factory()->forMember($data->member)->active()->start(Carbon::now())->create(); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.users.time-entries.my-active')); + + // Assert + Log::assertLogged(fn (LogEntry $log) => $log->level === 'warning' + && $log->message === 'User has more than one active time entry.' + && $log->context === ['user' => $data->user->getKey()] + ); + $response->assertSuccessful(); + $response->assertJsonPath('data.id', $activeTimeEntry2->getKey()); } public function test_my_active_endpoint_returns_not_found_if_user_has_no_active_time_entry(): void diff --git a/tests/Unit/Model/ProjectModelTest.php b/tests/Unit/Model/ProjectModelTest.php index b5c527c6..1eeb8060 100644 --- a/tests/Unit/Model/ProjectModelTest.php +++ b/tests/Unit/Model/ProjectModelTest.php @@ -98,7 +98,7 @@ class ProjectModelTest extends ModelTestAbstract ProjectMember::factory()->forProject($projectPrivateButMember)->forMember($member)->create(); // Act - $projectsVisible = Project::query()->visibleByUser($member->user)->get(); + $projectsVisible = Project::query()->visibleByEmployee($member->user)->get(); $allProjects = Project::query()->get(); // Assert diff --git a/tests/Unit/Model/TaskModelTest.php b/tests/Unit/Model/TaskModelTest.php index 9bc48cea..c2c07480 100644 --- a/tests/Unit/Model/TaskModelTest.php +++ b/tests/Unit/Model/TaskModelTest.php @@ -72,7 +72,7 @@ class TaskModelTest extends ModelTestAbstract $taskPrivateButMember = Task::factory()->forProject($projectPrivateButMember)->create(); // Act - $tasksVisible = Task::query()->visibleByUser($member->user)->get(); + $tasksVisible = Task::query()->visibleByEmployee($member->user)->get(); $allTasks = Task::query()->get(); // Assert diff --git a/tests/Unit/Model/TimeEntryModelTest.php b/tests/Unit/Model/TimeEntryModelTest.php index 8bf63a7e..561ee283 100644 --- a/tests/Unit/Model/TimeEntryModelTest.php +++ b/tests/Unit/Model/TimeEntryModelTest.php @@ -10,7 +10,7 @@ use App\Models\Tag; use App\Models\Task; use App\Models\TimeEntry; use App\Models\User; -use Carbon\Carbon; +use Illuminate\Support\Carbon; class TimeEntryModelTest extends ModelTestAbstract { diff --git a/tests/Unit/Service/DashboardServiceTest.php b/tests/Unit/Service/DashboardServiceTest.php index 502ae6c4..462bed55 100644 --- a/tests/Unit/Service/DashboardServiceTest.php +++ b/tests/Unit/Service/DashboardServiceTest.php @@ -13,9 +13,9 @@ use App\Models\Task; use App\Models\TimeEntry; use App\Models\User; use App\Service\DashboardService; -use Carbon\Carbon; use Carbon\CarbonImmutable; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Carbon; use Tests\TestCase; class DashboardServiceTest extends TestCase diff --git a/tests/Unit/Service/TimeEntryAggregationServiceTest.php b/tests/Unit/Service/TimeEntryAggregationServiceTest.php new file mode 100644 index 00000000..83d31e66 --- /dev/null +++ b/tests/Unit/Service/TimeEntryAggregationServiceTest.php @@ -0,0 +1,142 @@ +service = app(TimeEntryAggregationService::class); + } + + public function test_aggregate_time_entries_by_day_and_project_returns_empty_array_if_no_time_entries_given(): void + { + // Arrange + $query = TimeEntry::query(); + + // Act + $result = $this->service->getAggregatedTimeEntries( + $query, + TimeEntryAggregationType::Day, + TimeEntryAggregationType::Project, + 'Europe/Vienna', + Weekday::Monday, + false, + null, + null + ); + + // Assert + $this->assertSame([ + 'seconds' => 0, + 'cost' => 0, + 'grouped_type' => 'day', + 'grouped_data' => [], + ], $result); + } + + public function test_aggregate_time_entries_by_day_and_project_with_filled_gaps(): void + { + // Arrange + $query = TimeEntry::query(); + + // Act + $result = $this->service->getAggregatedTimeEntries( + $query, + TimeEntryAggregationType::Day, + TimeEntryAggregationType::Project, + 'Europe/Vienna', + Weekday::Monday, + true, + Carbon::now()->subDays(2)->utc(), + Carbon::now()->subDays(1)->utc(), + ); + + // Assert + $this->assertSame([ + 'seconds' => 0, + 'cost' => 0, + 'grouped_type' => 'day', + 'grouped_data' => [ + [ + 'key' => Carbon::now()->subDays(2)->utc()->format('Y-m-d'), + 'seconds' => 0, + 'cost' => 0, + 'grouped_type' => 'project', + 'grouped_data' => [], + ], + [ + 'key' => Carbon::now()->subDays(1)->utc()->format('Y-m-d'), + 'seconds' => 0, + 'cost' => 0, + 'grouped_type' => 'project', + 'grouped_data' => [], + ], + ], + ], $result); + } + + public function test_aggregate_time_entries_by_user_and_project_with_filled_gaps(): void + { + // Arrange + $query = TimeEntry::query(); + + // Act + $result = $this->service->getAggregatedTimeEntries( + $query, + TimeEntryAggregationType::User, + TimeEntryAggregationType::Project, + 'Europe/Vienna', + Weekday::Monday, + true, + Carbon::now()->subDays(2), + Carbon::now()->subDays(1), + ); + + // Assert + $this->assertSame([ + 'seconds' => 0, + 'cost' => 0, + 'grouped_type' => 'user', + 'grouped_data' => [], + ], $result); + } + + public function test_aggregate_time_entries_by_user_and_day_with_filled_gaps(): void + { + // Arrange + $query = TimeEntry::query(); + + // Act + $result = $this->service->getAggregatedTimeEntries( + $query, + TimeEntryAggregationType::User, + TimeEntryAggregationType::Day, + 'Europe/Vienna', + Weekday::Monday, + true, + Carbon::now()->subDays(2), + Carbon::now()->subDays(1), + ); + + // Assert + $this->assertSame([ + 'seconds' => 0, + 'cost' => 0, + 'grouped_type' => 'user', + 'grouped_data' => [], + ], $result); + } +}