From d5bbba2c2f27451fbe43e7d08b88008ee16de4f9 Mon Sep 17 00:00:00 2001 From: Constantin Graf Date: Mon, 6 May 2024 18:38:55 +0200 Subject: [PATCH 01/15] Added aggregate time entries endpoint --- .../Controllers/Api/V1/TaskController.php | 7 +- .../Api/V1/TimeEntryController.php | 181 ++++++++++++++++-- .../TimeEntry/TimeEntryAggregateRequest.php | 113 +++++++++++ .../V1/TimeEntry/TimeEntryIndexRequest.php | 50 ++++- app/Models/Task.php | 8 + app/Service/TimeEntryFilter.php | 147 ++++++++++++++ routes/api.php | 1 + .../Endpoint/Api/V1/TimeEntryEndpointTest.php | 87 +++++++++ tests/Unit/Model/TaskModelTest.php | 30 +++ 9 files changed, 597 insertions(+), 27 deletions(-) create mode 100644 app/Http/Requests/V1/TimeEntry/TimeEntryAggregateRequest.php create mode 100644 app/Service/TimeEntryFilter.php diff --git a/app/Http/Controllers/Api/V1/TaskController.php b/app/Http/Controllers/Api/V1/TaskController.php index 0cb0e947..f1b6393e 100644 --- a/app/Http/Controllers/Api/V1/TaskController.php +++ b/app/Http/Controllers/Api/V1/TaskController.php @@ -11,11 +11,9 @@ use App\Http\Resources\V1\Task\TaskCollection; use App\Http\Resources\V1\Task\TaskResource; use App\Models\Organization; -use App\Models\Project; use App\Models\Task; use App\Models\User; use Illuminate\Auth\Access\AuthorizationException; -use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\JsonResponse; use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Support\Facades\Auth; @@ -56,10 +54,7 @@ public function index(Organization $organization, TaskIndexRequest $request): Ta } if (! $canViewAllTasks) { - $query->whereHas('project', function (Builder $builder) use ($user): void { - /** @var Builder $builder */ - $builder->visibleByUser($user); - }); + $query->visibleByUser($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 8f2fbcec..839deef6 100644 --- a/app/Http/Controllers/Api/V1/TimeEntryController.php +++ b/app/Http/Controllers/Api/V1/TimeEntryController.php @@ -4,8 +4,10 @@ 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; use App\Http\Requests\V1\TimeEntry\TimeEntryIndexRequest; use App\Http\Requests\V1\TimeEntry\TimeEntryStoreRequest; use App\Http\Requests\V1\TimeEntry\TimeEntryUpdateRequest; @@ -13,13 +15,16 @@ use App\Http\Resources\V1\TimeEntry\TimeEntryResource; use App\Models\Organization; use App\Models\TimeEntry; +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\Facades\Auth; use Illuminate\Support\Facades\Log; +use Ramsey\Uuid\Type\Time; class TimeEntryController extends Controller { @@ -53,26 +58,16 @@ public function index(Organization $organization, TimeEntryIndexRequest $request ->whereBelongsTo($organization, 'organization') ->orderBy('start', 'desc'); - if ($request->has('before')) { - $timeEntriesQuery->where('start', '<', Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $request->input('before'), 'UTC')); - } - - if ($request->has('after')) { - $timeEntriesQuery->where('start', '>', Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $request->input('after'), 'UTC')); - } - - if ($request->has('active')) { - if ($request->get('active') === 'true') { - $timeEntriesQuery->whereNull('end'); - } - if ($request->get('active') === 'false') { - $timeEntriesQuery->whereNotNull('end'); - } - } - - if ($request->has('user_id')) { - $timeEntriesQuery->where('user_id', $request->input('user_id')); - } + $filter = new TimeEntryFilter($timeEntriesQuery); + $filter->addBeforeFilter($request->input('before')); + $filter->addAfterFilter($request->input('after')); + $filter->addActiveFilter($request->input('active')); + $filter->addUserIdFilter($request->input('user_id')); + $filter->addProjectIdsFilter($request->input('project_ids')); + $filter->addTagIdsFilter($request->input('tag_ids')); + $filter->addTaskIdsFilter($request->input('task_ids')); + $filter->addClientIdsFilter($request->input('client_ids')); + $filter->addBillableFilter($request->input('billable')); $limit = $request->has('limit') ? (int) $request->get('limit', 100) : 100; if ($limit > 1000) { @@ -115,6 +110,152 @@ public function index(Organization $organization, TimeEntryIndexRequest $request return new TimeEntryCollection($timeEntries); } + /** + * Get aggregated time entries in organization + * + * @throws AuthorizationException + */ + public function aggregate(Organization $organization, TimeEntryAggregateRequest $request): array + { + if ($request->has('user_id') && $request->get('user_id') === Auth::id()) { + $this->checkPermission($organization, 'time-entries:view:own'); + } else { + $this->checkPermission($organization, 'time-entries:view:all'); + } + + $timeEntriesQuery = TimeEntry::query() + ->whereBelongsTo($organization, 'organization'); + + $filter = new TimeEntryFilter($timeEntriesQuery); + $filter->addBeforeFilter($request->input('before')); + $filter->addAfterFilter($request->input('after')); + $filter->addActiveFilter($request->input('active')); + $filter->addUserIdFilter($request->input('user_id')); + $filter->addProjectIdsFilter($request->input('project_ids')); + $filter->addTagIdsFilter($request->input('tag_ids')); + $filter->addTaskIdsFilter($request->input('task_ids')); + $filter->addClientIdsFilter($request->input('client_ids')); + $filter->addBillableFilter($request->input('billable')); + $timeEntriesQuery = $filter->get(); + + $user = Auth::user(); + + $group1Type = $request->get('group_1'); + $group2Type = $request->get('group_2'); + + $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' + ); + 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) { + $group2Response = []; + if ($group2Select !== null) { + $group2ResponseSum = 0; + $group2ResponseCost = 0; + foreach ($group1Aggregates as $group2 => $aggregate) { + $group2Response[] = [ + 'type' => $group2Type, + 'value' => $group2 === '' ? null : $group2, + 'aggregate' => (int) $aggregate->get(0)->aggregate, + 'cost' => (int) $aggregate->get(0)->cost, + ]; + $group2ResponseSum += (int) $aggregate->get(0)->aggregate; + $group2ResponseCost += (int) $aggregate->get(0)->cost; + } + } else { + $group2ResponseSum = (int) $group1Aggregates->get(0)->aggregate; + $group2ResponseCost = (int) $group1Aggregates->get(0)->cost; + $group2Response = null; + } + + $group1Response[] = [ + 'type' => $group1Type, + 'value' => $group1 === '' ? null : $group1, + 'aggregate' => $group2ResponseSum, + 'grouped_data' => $group2Response, + ]; + $group1ResponseSum += $group2ResponseSum; + $group1ResponseCost += $group2ResponseCost; + } + } else { + $group1Response = null; + $group1ResponseSum = (int) $timeEntriesAggregates->get(0)->aggregate; + $group1ResponseCost = (int) $timeEntriesAggregates->get(0)->cost; + } + + return [ + 'data' => [ + 'grouped_data' => $group1Response, + 'aggregate' => $group1ResponseSum, + 'cost' => $group1ResponseCost, + ], + ]; + } + + 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/Requests/V1/TimeEntry/TimeEntryAggregateRequest.php b/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateRequest.php new file mode 100644 index 00000000..a8a5d274 --- /dev/null +++ b/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateRequest.php @@ -0,0 +1,113 @@ +> + */ + public function rules(): array + { + return [ + 'group_1' => [ + 'required_with:group_2', + 'in:day,week,month,year,user,project,task,client,billable', + ], + + 'group_2' => [ + 'in:day,week,month,year,user,project,task,client,billable', + ], + + // Filter by user ID + 'user_id' => [ + 'string', + 'uuid', + new ExistsEloquent(User::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->belongsToOrganization($this->organization); + }), + ], + // Filter by project IDs, project IDs are OR combined + 'project_ids' => [ + 'array', + 'min:1', + ], + 'project_ids.*' => [ + 'string', + 'uuid', + new ExistsEloquent(Project::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->visibleByUser(Auth::user()); + }), + ], + // Filter by tag IDs, tag IDs are AND combined + 'tag_ids' => [ + 'array', + 'min:1', + ], + 'tag_ids.*' => [ + 'string', + 'uuid', + new ExistsEloquent(Tag::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + }), + ], + // Filter by task IDs, task IDs are OR combined + 'task_ids' => [ + 'array', + 'min:1', + ], + 'task_ids.*' => [ + 'string', + 'uuid', + new ExistsEloquent(Task::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->visibleByUser(Auth::user()); + }), + ], + // Filter only time entries that have a start date before (not including) the given date (example: 2021-12-31) + 'before' => [ + 'nullable', + 'string', + 'date_format:Y-m-d\TH:i:s\Z', + 'before:after', + ], + // Filter only time entries that have a start date after (not including) the given date (example: 2021-12-31) + 'after' => [ + 'nullable', + 'string', + 'date_format:Y-m-d\TH:i:s\Z', + ], + // Filter by active status (active means has no end date, is still running) + 'active' => [ + 'string', + 'in:true,false', + ], + // Filter by billable status + 'billable' => [ + 'string', + 'in:true,false', + ], + ]; + } +} diff --git a/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php b/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php index 09cf44c1..3054080f 100644 --- a/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php +++ b/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php @@ -5,10 +5,14 @@ namespace App\Http\Requests\V1\TimeEntry; use App\Models\Organization; +use App\Models\Project; +use App\Models\Tag; +use App\Models\Task; 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 Korridor\LaravelModelValidationRules\Rules\ExistsEloquent; /** @@ -33,6 +37,45 @@ public function rules(): array return $builder->belongsToOrganization($this->organization); }), ], + // Filter by project IDs, project IDs are OR combined + 'project_ids' => [ + 'array', + 'min:1', + ], + 'project_ids.*' => [ + 'string', + 'uuid', + new ExistsEloquent(Project::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->visibleByUser(Auth::user()); + }), + ], + // Filter by tag IDs, tag IDs are AND combined + 'tag_ids' => [ + 'array', + 'min:1', + ], + 'tag_ids.*' => [ + 'string', + 'uuid', + new ExistsEloquent(Tag::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + }), + ], + // Filter by task IDs, task IDs are OR combined + 'task_ids' => [ + 'array', + 'min:1', + ], + 'task_ids.*' => [ + 'string', + 'uuid', + new ExistsEloquent(Task::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->visibleByUser(Auth::user()); + }), + ], // Filter only time entries that have a start date before (not including) the given date (example: 2021-12-31) 'before' => [ 'nullable', @@ -46,11 +89,16 @@ public function rules(): array 'string', 'date_format:Y-m-d\TH:i:s\Z', ], - // Filter only time entries that are active (have no end date, are still running) + // Filter by active status (active means has no end date, is still running) 'active' => [ 'string', 'in:true,false', ], + // Filter by billable status + 'billable' => [ + 'string', + 'in:true,false', + ], // Limit the number of returned time entries (default: 150) 'limit' => [ 'integer', diff --git a/app/Models/Task.php b/app/Models/Task.php index e868c93b..597664d2 100644 --- a/app/Models/Task.php +++ b/app/Models/Task.php @@ -5,6 +5,7 @@ namespace App\Models; use Database\Factories\TaskFactory; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -63,4 +64,11 @@ public function timeEntries(): HasMany { return $this->hasMany(TimeEntry::class, 'task_id'); } + + public function scopeVisibleByUser(Builder $builder, User $user): Builder + { + return $builder->whereHas('project', function (Builder $builder) use ($user): Builder { + return $builder->visibleByUser($user); + }); + } } diff --git a/app/Service/TimeEntryFilter.php b/app/Service/TimeEntryFilter.php new file mode 100644 index 00000000..c6570f7b --- /dev/null +++ b/app/Service/TimeEntryFilter.php @@ -0,0 +1,147 @@ + + */ + private Builder $builder; + + /** + * @param Builder $builder + */ + public function __construct(Builder $builder) + { + $this->builder = $builder; + } + + public function addBeforeFilter(?string $dateTime): self + { + if ($dateTime === null) { + return $this; + } + $this->builder->where('start', '<', Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $dateTime, 'UTC')); + + return $this; + } + + public function addAfterFilter(?string $dateTime): self + { + if ($dateTime === null) { + return $this; + } + $this->builder->where('start', '>', Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $dateTime, 'UTC')); + + return $this; + } + + public function addActiveFilter(?string $active): self + { + if ($active === null) { + return $this; + } + if ($active === 'true') { + $this->builder->whereNull('end'); + } + if ($active === 'false') { + $this->builder->whereNotNull('end'); + } + + return $this; + } + + public function addUserIdFilter(?string $userId): self + { + if ($userId === null) { + return $this; + } + $this->builder->where('user_id', $userId); + + return $this; + } + + public function addBillableFilter(?string $billable): self + { + if ($billable === null) { + return $this; + } + if ($billable === 'true') { + $this->builder->where('billable', '=', true); + } elseif ($billable === 'false') { + $this->builder->where('billable', '=', false); + } else { + Log::warning('Invalid billable filter value', ['value' => $billable]); + } + + return $this; + } + + /** + * @param array|null $clientIds + */ + public function addClientIdsFilter(?array $clientIds): self + { + if ($clientIds === null) { + return $this; + } + $this->builder->whereIn('client_id', $clientIds); + + return $this; + } + + /** + * @param array|null $projectIds + */ + public function addProjectIdsFilter(?array $projectIds): self + { + if ($projectIds === null) { + return $this; + } + $this->builder->whereIn('project_id', $projectIds); + + return $this; + } + + /** + * @param array|null $tagIds + */ + public function addTagIdsFilter(?array $tagIds): self + { + if ($tagIds === null) { + return $this; + } + $this->builder->whereJsonContains('tags', $tagIds); + + return $this; + } + + /** + * @param array|null $taskIds + */ + public function addTaskIdsFilter(?array $taskIds): self + { + if ($taskIds === null) { + return $this; + } + $this->builder->whereIn('task_id', $taskIds); + + return $this; + } + + /** + * @return Builder + */ + public function get(): Builder + { + return $this->builder; + } +} diff --git a/routes/api.php b/routes/api.php index 0c9494ba..a7520fff 100644 --- a/routes/api.php +++ b/routes/api.php @@ -73,6 +73,7 @@ // Time entry routes Route::name('time-entries.')->group(static function () { Route::get('/organizations/{organization}/time-entries', [TimeEntryController::class, 'index'])->name('index'); + Route::get('/organizations/{organization}/time-entries/aggregate', [TimeEntryController::class, 'aggregate'])->name('aggregate'); Route::post('/organizations/{organization}/time-entries', [TimeEntryController::class, 'store'])->name('store'); Route::put('/organizations/{organization}/time-entries/{timeEntry}', [TimeEntryController::class, 'update'])->name('update'); Route::delete('/organizations/{organization}/time-entries/{timeEntry}', [TimeEntryController::class, 'destroy'])->name('destroy'); diff --git a/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php b/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php index 91b55b8a..c4996ab7 100644 --- a/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php @@ -4,6 +4,7 @@ namespace Tests\Unit\Endpoint\Api\V1; +use App\Models\Project; use App\Models\TimeEntry; use App\Models\User; use Carbon\Carbon; @@ -384,6 +385,92 @@ public function test_index_endpoint_after_filter_returns_time_entries_after_date ); } + public function test_aggregate_endpoint_fails_if_user_has_no_permission_to_view_time_entries(): void + { + // Arrange + $data = $this->createUserWithPermission([ + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.time-entries.aggregate', [$data->organization->getKey()])); + + // Assert + $response->assertForbidden(); + } + + public function test_aggregate_endpoint_groups_by_two_groups(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'time-entries:view:all', + ]); + $timeEntries = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user)->createMany(3); + $project = Project::factory()->forOrganization($data->organization)->create(); + $timeEntries = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user)->forProject($project)->createMany(3); + $timeEntries = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user)->state([ + 'start' => $timeEntries->get(0)->start, + ])->createMany(3); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.time-entries.aggregate', [ + $data->organization->getKey(), + 'group_1' => 'day', + 'group_2' => 'project', + ])); + + // Assert + $response->assertSuccessful(); + } + + public function test_aggregate_endpoint_groups_by_one_group(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'time-entries:view:all', + ]); + $timeEntries = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user)->createMany(3); + $project = Project::factory()->forOrganization($data->organization)->create(); + $timeEntries = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user)->forProject($project)->createMany(3); + $timeEntries = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user)->state([ + 'start' => $timeEntries->get(0)->start, + ])->createMany(3); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.time-entries.aggregate', [ + $data->organization->getKey(), + 'group_1' => 'week', + ])); + + // Assert + $response->assertSuccessful(); + } + + public function test_aggregate_endpoint_with_no_group(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'time-entries:view:all', + ]); + $timeEntries = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user)->createMany(3); + $project = Project::factory()->forOrganization($data->organization)->create(); + $timeEntries = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user)->forProject($project)->createMany(3); + $timeEntries = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user)->state([ + 'start' => $timeEntries->get(0)->start, + ])->createMany(3); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.time-entries.aggregate', [ + $data->organization->getKey(), + ])); + + // Assert + $response->assertSuccessful(); + } + public function test_store_endpoint_fails_if_user_has_no_permission_to_create_time_entries(): void { // Arrange diff --git a/tests/Unit/Model/TaskModelTest.php b/tests/Unit/Model/TaskModelTest.php index b65fd667..6058e877 100644 --- a/tests/Unit/Model/TaskModelTest.php +++ b/tests/Unit/Model/TaskModelTest.php @@ -6,8 +6,10 @@ use App\Models\Organization; use App\Models\Project; +use App\Models\ProjectMember; use App\Models\Task; use App\Models\TimeEntry; +use App\Models\User; class TaskModelTest extends ModelTestAbstract { @@ -56,4 +58,32 @@ public function test_it_has_many_time_entries(): void // Assert $this->assertCount(3, $timeEntries); } + + public function test_scope_visible_by_user_filters_so_that_only_tasks_of_public_projects_or_projects_where_the_user_is_member_are_shown(): void + { + // Arrange + $user = User::factory()->create(); + $projectPrivate = Project::factory()->isPrivate()->create(); + $projectPublic = Project::factory()->isPublic()->create(); + $projectPrivateButMember = Project::factory()->isPrivate()->create(); + ProjectMember::factory()->forProject($projectPrivateButMember)->forUser($user)->create(); + $taskPrivate = Task::factory()->forProject($projectPrivate)->create(); + $taskPublic = Task::factory()->forProject($projectPublic)->create(); + $taskPrivateButMember = Task::factory()->forProject($projectPrivateButMember)->create(); + + // Act + $tasksVisible = Task::query()->visibleByUser($user)->get(); + $allTasks = Task::query()->get(); + + // Assert + $this->assertEqualsIdsOfEloquentCollection([ + $taskPublic->getKey(), + $taskPrivateButMember->getKey(), + ], $tasksVisible); + $this->assertEqualsIdsOfEloquentCollection([ + $taskPrivate->getKey(), + $taskPublic->getKey(), + $taskPrivateButMember->getKey(), + ], $allTasks); + } } From ba335b4f058fbeedb301ad8d82945cd294ac7b40 Mon Sep 17 00:00:00 2001 From: Constantin Graf Date: Tue, 7 May 2024 15:43:00 +0200 Subject: [PATCH 02/15] Migrated to UUID v4 --- app/Models/Client.php | 2 +- app/Models/Concerns/HasUuids.php | 20 ++++++++++++++++++++ app/Models/Membership.php | 2 +- app/Models/Organization.php | 2 +- app/Models/OrganizationInvitation.php | 2 +- app/Models/Project.php | 2 +- app/Models/ProjectMember.php | 2 +- app/Models/Tag.php | 2 +- app/Models/Task.php | 2 +- app/Models/TimeEntry.php | 2 +- app/Models/User.php | 2 +- 11 files changed, 30 insertions(+), 10 deletions(-) create mode 100644 app/Models/Concerns/HasUuids.php diff --git a/app/Models/Client.php b/app/Models/Client.php index 988f5f28..cb692e2a 100644 --- a/app/Models/Client.php +++ b/app/Models/Client.php @@ -4,8 +4,8 @@ namespace App\Models; +use App\Models\Concerns\HasUuids; use Database\Factories\ClientFactory; -use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; diff --git a/app/Models/Concerns/HasUuids.php b/app/Models/Concerns/HasUuids.php new file mode 100644 index 00000000..6e096e23 --- /dev/null +++ b/app/Models/Concerns/HasUuids.php @@ -0,0 +1,20 @@ + Date: Mon, 13 May 2024 19:50:50 +0200 Subject: [PATCH 03/15] Migrated endpoints from user to member; Renamed membership to member --- app/Actions/Jetstream/UpdateMemberRole.php | 4 +- .../Resources/OrganizationResource.php | 6 +- app/Http/Controllers/Api/V1/Controller.php | 18 ++ .../Api/V1/InvitationController.php | 2 +- .../Controllers/Api/V1/MemberController.php | 39 ++-- .../Controllers/Api/V1/ProjectController.php | 4 +- .../Api/V1/ProjectMemberController.php | 11 +- .../Controllers/Api/V1/TaskController.php | 5 +- .../Api/V1/TimeEntryController.php | 77 +++++-- .../Api/V1/UserTimeEntryController.php | 4 +- .../ProjectMemberStoreRequest.php | 10 +- .../TimeEntry/TimeEntryAggregateRequest.php | 29 ++- .../V1/TimeEntry/TimeEntryIndexRequest.php | 25 ++- .../V1/TimeEntry/TimeEntryStoreRequest.php | 12 +- .../V1/TimeEntry/TimeEntryUpdateRequest.php | 11 + .../V1/Member/MemberPivotResource.php | 12 +- .../Resources/V1/Member/MemberResource.php | 4 +- .../ProjectMember/ProjectMemberResource.php | 4 +- app/Listeners/RemovePlaceholder.php | 19 +- app/Models/{Membership.php => Member.php} | 12 +- app/Models/Organization.php | 2 +- app/Models/ProjectMember.php | 16 +- app/Models/Task.php | 5 + app/Models/TimeEntry.php | 10 + app/Models/User.php | 4 +- app/Providers/AppServiceProvider.php | 6 +- app/Providers/JetstreamServiceProvider.php | 2 + app/Service/BillableRateService.php | 10 +- app/Service/DashboardService.php | 16 +- .../Importers/ClockifyTimeEntriesImporter.php | 5 + .../Import/Importers/DefaultImporter.php | 12 +- .../Import/Importers/TogglDataImporter.php | 10 +- .../Importers/TogglTimeEntriesImporter.php | 5 + app/Service/TimeEntryFilter.php | 20 +- app/Service/UserService.php | 21 +- ...embershipFactory.php => MemberFactory.php} | 19 +- database/factories/ProjectFactory.php | 8 +- database/factories/ProjectMemberFactory.php | 15 ++ database/factories/TimeEntryFactory.php | 16 ++ ..._to_member_id_in_project_members_table.php | 46 +++++ ..._id_to_member_id_in_time_entries_table.php | 47 +++++ ...ame_table_organization_user_to_members.php | 25 +++ database/seeders/DatabaseSeeder.php | 70 ++++--- tests/Feature/CreateTeamTest.php | 15 +- tests/Feature/InviteTeamMemberTest.php | 20 +- tests/Feature/RegistrationTest.php | 4 +- tests/Feature/UpdateTeamMemberRoleTest.php | 6 +- .../Api/V1/ApiEndpointTestAbstract.php | 8 +- .../Api/V1/InvitationEndpointTest.php | 7 +- .../Endpoint/Api/V1/MemberEndpointTest.php | 27 +-- .../Endpoint/Api/V1/ProjectEndpointTest.php | 2 +- .../Api/V1/ProjectMemberEndpointTest.php | 42 ++-- .../Unit/Endpoint/Api/V1/TagEndpointTest.php | 2 +- .../Unit/Endpoint/Api/V1/TaskEndpointTest.php | 6 +- .../Endpoint/Api/V1/TimeEntryEndpointTest.php | 195 +++++++++--------- .../Api/V1/UserTimeEntryEndpointTest.php | 6 +- tests/Unit/Model/ProjectMemberModelTest.php | 18 +- tests/Unit/Model/ProjectModelTest.php | 8 +- tests/Unit/Model/TaskModelTest.php | 8 +- tests/Unit/Model/UserModelTest.php | 12 +- .../Unit/Service/BillableRateServiceTest.php | 74 ++++--- tests/Unit/Service/DashboardServiceTest.php | 121 +++++------ tests/Unit/Service/PermissionStoreTest.php | 13 +- tests/Unit/Service/UserServiceTest.php | 17 +- 64 files changed, 853 insertions(+), 456 deletions(-) rename app/Models/{Membership.php => Member.php} (78%) rename database/factories/{MembershipFactory.php => MemberFactory.php} (78%) create mode 100644 database/migrations/2024_05_07_134711_move_from_user_id_to_member_id_in_project_members_table.php create mode 100644 database/migrations/2024_05_07_141842_move_from_user_id_to_member_id_in_time_entries_table.php create mode 100644 database/migrations/2024_05_13_171020_rename_table_organization_user_to_members.php diff --git a/app/Actions/Jetstream/UpdateMemberRole.php b/app/Actions/Jetstream/UpdateMemberRole.php index 13a52fb8..d40c36ad 100644 --- a/app/Actions/Jetstream/UpdateMemberRole.php +++ b/app/Actions/Jetstream/UpdateMemberRole.php @@ -5,7 +5,7 @@ namespace App\Actions\Jetstream; use App\Enums\Role; -use App\Models\Membership; +use App\Models\Member; use App\Models\Organization; use App\Models\User; use App\Service\PermissionStore; @@ -32,7 +32,7 @@ public function update(User $actingUser, Organization $organization, string $use } $user = User::where('id', '=', $userId)->firstOrFail(); - $member = Membership::whereBelongsTo($user)->whereBelongsTo($organization)->firstOrFail(); + $member = Member::whereBelongsTo($user)->whereBelongsTo($organization)->firstOrFail(); if ($member->role === Role::Placeholder->value) { abort(403, 'Cannot update the role of a placeholder member.'); } diff --git a/app/Filament/Resources/OrganizationResource.php b/app/Filament/Resources/OrganizationResource.php index 303518c9..28bf83ae 100644 --- a/app/Filament/Resources/OrganizationResource.php +++ b/app/Filament/Resources/OrganizationResource.php @@ -108,11 +108,15 @@ public static function table(Table $table): Table ->icon('heroicon-o-inbox-arrow-down') ->action(function (Organization $record, array $data) { try { + $file = Storage::disk(config('filament.default_filesystem_disk'))->get($data['file']); + if ($file === null) { + throw new \Exception('File not found'); + } /** @var ReportDto $report */ $report = app(ImportService::class)->import( $record, $data['type'], - Storage::disk(config('filament.default_filesystem_disk'))->get($data['file']) + $file ); Notification::make() ->title('Import successful') diff --git a/app/Http/Controllers/Api/V1/Controller.php b/app/Http/Controllers/Api/V1/Controller.php index 6843a7e8..21340039 100644 --- a/app/Http/Controllers/Api/V1/Controller.php +++ b/app/Http/Controllers/Api/V1/Controller.php @@ -5,8 +5,11 @@ namespace App\Http\Controllers\Api\V1; use App\Models\Organization; +use App\Models\User; use App\Service\PermissionStore; use Illuminate\Auth\Access\AuthorizationException; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Log; class Controller extends \App\Http\Controllers\Controller { @@ -29,4 +32,19 @@ protected function hasPermission(Organization $organization, string $permission) { return $this->permissionStore->has($organization, $permission); } + + /** + * @throws AuthorizationException + */ + protected function user(): User + { + /** @var User|null $user */ + $user = Auth::user(); + if ($user === null) { + Log::error('This function should only be called in authenticated context'); + throw new AuthorizationException(); + } + + return $user; + } } diff --git a/app/Http/Controllers/Api/V1/InvitationController.php b/app/Http/Controllers/Api/V1/InvitationController.php index e7d45a79..0786c72d 100644 --- a/app/Http/Controllers/Api/V1/InvitationController.php +++ b/app/Http/Controllers/Api/V1/InvitationController.php @@ -57,7 +57,7 @@ public function store(Organization $organization, InvitationStoreRequest $reques $this->checkPermission($organization, 'invitations:create'); app(InvitesTeamMembers::class)->invite( - $request->user(), + $this->user(), $organization, $request->input('email'), $request->input('role') diff --git a/app/Http/Controllers/Api/V1/MemberController.php b/app/Http/Controllers/Api/V1/MemberController.php index 786bc527..9337ea2a 100644 --- a/app/Http/Controllers/Api/V1/MemberController.php +++ b/app/Http/Controllers/Api/V1/MemberController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\V1; +use App\Enums\Role; use App\Exceptions\Api\EntityStillInUseApiException; use App\Exceptions\Api\UserNotPlaceholderApiException; use App\Http\Requests\V1\Member\MemberIndexRequest; @@ -11,7 +12,7 @@ use App\Http\Resources\V1\Member\MemberCollection; use App\Http\Resources\V1\Member\MemberPivotResource; use App\Http\Resources\V1\Member\MemberResource; -use App\Models\Membership; +use App\Models\Member; use App\Models\Organization; use App\Models\ProjectMember; use App\Models\TimeEntry; @@ -23,10 +24,10 @@ class MemberController extends Controller { - protected function checkPermission(Organization $organization, string $permission, ?Membership $membership = null): void + protected function checkPermission(Organization $organization, string $permission, ?Member $member = null): void { parent::checkPermission($organization, $permission); - if ($membership !== null && $membership->organization_id !== $organization->id) { + if ($member !== null && $member->organization_id !== $organization->id) { throw new AuthorizationException('Member does not belong to organization'); } } @@ -57,15 +58,15 @@ public function index(Organization $organization, MemberIndexRequest $request): * * @operationId updateMember */ - public function update(Organization $organization, Membership $membership, MemberUpdateRequest $request): JsonResource + public function update(Organization $organization, Member $member, MemberUpdateRequest $request): JsonResource { - $this->checkPermission($organization, 'members:update', $membership); + $this->checkPermission($organization, 'members:update', $member); - $membership->billable_rate = $request->input('billable_rate'); - $membership->role = $request->input('role'); - $membership->save(); + $member->billable_rate = $request->input('billable_rate'); + $member->role = $request->input('role'); + $member->save(); - return new MemberResource($membership); + return new MemberResource($member); } /** @@ -75,18 +76,18 @@ public function update(Organization $organization, Membership $membership, Membe * * @operationId removeMember */ - public function destroy(Organization $organization, Membership $membership): JsonResponse + public function destroy(Organization $organization, Member $member): JsonResponse { - $this->checkPermission($organization, 'members:delete', $membership); + $this->checkPermission($organization, 'members:delete', $member); - if (TimeEntry::query()->where('user_id', $membership->user_id)->whereBelongsTo($organization, 'organization')->exists()) { + if (TimeEntry::query()->where('user_id', $member->user_id)->whereBelongsTo($organization, 'organization')->exists()) { throw new EntityStillInUseApiException('member', 'time_entry'); } - if (ProjectMember::query()->whereBelongsToOrganization($organization)->where('user_id', $membership->user_id)->exists()) { + if (ProjectMember::query()->whereBelongsToOrganization($organization)->where('user_id', $member->user_id)->exists()) { throw new EntityStillInUseApiException('member', 'project_member'); } - $membership->delete(); + $member->delete(); return response() ->json(null, 204); @@ -99,20 +100,20 @@ public function destroy(Organization $organization, Membership $membership): Jso * * @operationId invitePlaceholder */ - public function invitePlaceholder(Organization $organization, Membership $membership, Request $request): JsonResponse + public function invitePlaceholder(Organization $organization, Member $member, Request $request): JsonResponse { - $this->checkPermission($organization, 'members:invite-placeholder', $membership); - $user = $membership->user; + $this->checkPermission($organization, 'members:invite-placeholder', $member); + $user = $member->user; if (! $user->is_placeholder) { throw new UserNotPlaceholderApiException(); } app(InvitesTeamMembers::class)->invite( - $request->user(), + $this->user(), $organization, $user->email, - 'employee' + Role::Employee->value, ); return response()->json(null, 204); diff --git a/app/Http/Controllers/Api/V1/ProjectController.php b/app/Http/Controllers/Api/V1/ProjectController.php index 58c4e248..661ffe89 100644 --- a/app/Http/Controllers/Api/V1/ProjectController.php +++ b/app/Http/Controllers/Api/V1/ProjectController.php @@ -17,7 +17,6 @@ use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Http\JsonResponse; use Illuminate\Http\Resources\Json\JsonResource; -use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; class ProjectController extends Controller @@ -43,8 +42,7 @@ public function index(Organization $organization, ProjectIndexRequest $request): { $this->checkPermission($organization, 'projects:view'); $canViewAllProjects = $this->hasPermission($organization, 'projects:view:all'); - /** @var User $user */ - $user = Auth::user(); + $user = $this->user(); $projectsQuery = Project::query() ->whereBelongsTo($organization, 'organization'); diff --git a/app/Http/Controllers/Api/V1/ProjectMemberController.php b/app/Http/Controllers/Api/V1/ProjectMemberController.php index b6cd1881..27229506 100644 --- a/app/Http/Controllers/Api/V1/ProjectMemberController.php +++ b/app/Http/Controllers/Api/V1/ProjectMemberController.php @@ -10,10 +10,10 @@ use App\Http\Requests\V1\ProjectMember\ProjectMemberUpdateRequest; use App\Http\Resources\V1\ProjectMember\ProjectMemberCollection; use App\Http\Resources\V1\ProjectMember\ProjectMemberResource; +use App\Models\Member; use App\Models\Organization; use App\Models\Project; use App\Models\ProjectMember; -use App\Models\User; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Http\JsonResponse; use Illuminate\Http\Resources\Json\JsonResource; @@ -62,17 +62,18 @@ public function store(Organization $organization, Project $project, ProjectMembe { $this->checkPermission($organization, 'project-members:create', $project); - $user = User::findOrFail((string) $request->input('user_id')); - if ($user->is_placeholder) { + $member = Member::findOrFail((string) $request->input('member_id')); + if ($member->user->is_placeholder) { throw new InactiveUserCanNotBeUsedApiException(); } - if (ProjectMember::whereBelongsTo($project, 'project')->whereBelongsTo($user, 'user')->exists()) { + if (ProjectMember::whereBelongsTo($project, 'project')->whereBelongsTo($member, 'member')->exists()) { throw new UserIsAlreadyMemberOfProjectApiException(); } $projectMember = new ProjectMember(); $projectMember->billable_rate = $request->input('billable_rate'); - $projectMember->user()->associate($user); + $projectMember->member()->associate($member); + $projectMember->user()->associate($member->user); $projectMember->project()->associate($project); $projectMember->save(); diff --git a/app/Http/Controllers/Api/V1/TaskController.php b/app/Http/Controllers/Api/V1/TaskController.php index f1b6393e..233ed443 100644 --- a/app/Http/Controllers/Api/V1/TaskController.php +++ b/app/Http/Controllers/Api/V1/TaskController.php @@ -12,11 +12,9 @@ use App\Http\Resources\V1\Task\TaskResource; use App\Models\Organization; use App\Models\Task; -use App\Models\User; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Http\JsonResponse; use Illuminate\Http\Resources\Json\JsonResource; -use Illuminate\Support\Facades\Auth; class TaskController extends Controller { @@ -41,8 +39,7 @@ public function index(Organization $organization, TaskIndexRequest $request): Ta { $this->checkPermission($organization, 'tasks:view'); $canViewAllTasks = $this->hasPermission($organization, 'tasks:view:all'); - /** @var User $user */ - $user = Auth::user(); + $user = $this->user(); $projectId = $request->input('project_id'); diff --git a/app/Http/Controllers/Api/V1/TimeEntryController.php b/app/Http/Controllers/Api/V1/TimeEntryController.php index 839deef6..036de1d8 100644 --- a/app/Http/Controllers/Api/V1/TimeEntryController.php +++ b/app/Http/Controllers/Api/V1/TimeEntryController.php @@ -13,6 +13,7 @@ use App\Http\Requests\V1\TimeEntry\TimeEntryUpdateRequest; use App\Http\Resources\V1\TimeEntry\TimeEntryCollection; use App\Http\Resources\V1\TimeEntry\TimeEntryResource; +use App\Models\Member; use App\Models\Organization; use App\Models\TimeEntry; use App\Service\TimeEntryFilter; @@ -22,9 +23,9 @@ 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; -use Ramsey\Uuid\Type\Time; class TimeEntryController extends Controller { @@ -48,7 +49,9 @@ protected function checkPermission(Organization $organization, string $permissio */ public function index(Organization $organization, TimeEntryIndexRequest $request): JsonResource { - if ($request->has('user_id') && $request->get('user_id') === Auth::id()) { + /** @var Member|null $member */ + $member = $request->has('member_id') ? Member::query()->findOrFail($request->get('member_id')) : null; + if ($member !== null && $member->user_id === Auth::id()) { $this->checkPermission($organization, 'time-entries:view:own'); } else { $this->checkPermission($organization, 'time-entries:view:all'); @@ -62,7 +65,8 @@ public function index(Organization $organization, TimeEntryIndexRequest $request $filter->addBeforeFilter($request->input('before')); $filter->addAfterFilter($request->input('after')); $filter->addActiveFilter($request->input('active')); - $filter->addUserIdFilter($request->input('user_id')); + $filter->addMemberIdFilter($member); + $filter->addMemberIdsFilter($request->input('member_ids')); $filter->addProjectIdsFilter($request->input('project_ids')); $filter->addTagIdsFilter($request->input('tag_ids')); $filter->addTaskIdsFilter($request->input('task_ids')); @@ -78,7 +82,7 @@ public function index(Organization $organization, TimeEntryIndexRequest $request $timeEntries = $timeEntriesQuery->get(); if ($timeEntries->count() === $limit && $request->has('only_full_dates') && (bool) $request->get('only_full_dates') === true) { - $user = Auth::user(); + $user = $this->user(); $timezone = app(TimezoneService::class)->getTimezoneFromUser($user); $lastDate = null; /** @var TimeEntry $timeEntry */ @@ -113,11 +117,38 @@ public function index(Organization $organization, TimeEntryIndexRequest $request /** * Get aggregated time entries in organization * + * This endpoint allows you to filter time entries and aggregate them by different criteria. + * The parameters `group` and `sub_group` allow you to group the time entries by different criteria. + * If the group parameters are all set to `null` or are all missing, the endpoint will aggregate all filtered time entries. + * + * @operationId getAggregatedTimeEntries + * + * @return array{ + * data: array{ + * grouped_data: null|array + * }>, + * aggregate: int, + * cost: int + * } + * } + * * @throws AuthorizationException */ public function aggregate(Organization $organization, TimeEntryAggregateRequest $request): array { - if ($request->has('user_id') && $request->get('user_id') === Auth::id()) { + /** @var Member|null $member */ + $member = $request->has('member_id') ? Member::query()->findOrFail($request->get('member_id')) : null; + if ($member !== null && $member->user_id === Auth::id()) { $this->checkPermission($organization, 'time-entries:view:own'); } else { $this->checkPermission($organization, 'time-entries:view:all'); @@ -130,7 +161,8 @@ public function aggregate(Organization $organization, TimeEntryAggregateRequest $filter->addBeforeFilter($request->input('before')); $filter->addAfterFilter($request->input('after')); $filter->addActiveFilter($request->input('active')); - $filter->addUserIdFilter($request->input('user_id')); + $filter->addMemberIdFilter($member); + $filter->addMemberIdsFilter($request->input('member_ids')); $filter->addProjectIdsFilter($request->input('project_ids')); $filter->addTagIdsFilter($request->input('tag_ids')); $filter->addTaskIdsFilter($request->input('task_ids')); @@ -138,10 +170,12 @@ public function aggregate(Organization $organization, TimeEntryAggregateRequest $filter->addBillableFilter($request->input('billable')); $timeEntriesQuery = $filter->get(); - $user = Auth::user(); + $user = $this->user(); - $group1Type = $request->get('group_1'); - $group2Type = $request->get('group_2'); + /** @var string|null $group1Type */ + $group1Type = $request->get('group'); + /** @var string|null $group2Type */ + $group2Type = $request->get('sub_group'); $group1Select = null; $group2Select = null; @@ -178,11 +212,15 @@ public function aggregate(Organization $organization, TimeEntryAggregateRequest $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, 'value' => $group2 === '' ? null : $group2, @@ -193,15 +231,18 @@ public function aggregate(Organization $organization, TimeEntryAggregateRequest $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, 'value' => $group1 === '' ? null : $group1, 'aggregate' => $group2ResponseSum, + 'cost' => $group2ResponseCost, 'grouped_data' => $group2Response, ]; $group1ResponseSum += $group2ResponseSum; @@ -209,6 +250,7 @@ public function aggregate(Organization $organization, TimeEntryAggregateRequest } } else { $group1Response = null; + /** @var Collection $timeEntriesAggregates */ $group1ResponseSum = (int) $timeEntriesAggregates->get(0)->aggregate; $group1ResponseCost = (int) $timeEntriesAggregates->get(0)->cost; } @@ -266,18 +308,21 @@ private function getGroupByQuery(string $group, string $timezone, Weekday $start */ public function store(Organization $organization, TimeEntryStoreRequest $request): JsonResource { - if ($request->get('user_id') === Auth::id()) { + /** @var Member $member */ + $member = Member::query()->findOrFail($request->get('member_id')); + if ($member->user_id === Auth::id()) { $this->checkPermission($organization, 'time-entries:create:own'); } else { $this->checkPermission($organization, 'time-entries:create:all'); } - if ($request->get('end') === null && TimeEntry::query()->where('user_id', $request->get('user_id'))->where('end', null)->exists()) { + if ($request->get('end') === null && TimeEntry::query()->whereBelongsTo($member, 'member')->where('end', null)->exists()) { throw new TimeEntryStillRunningApiException(); } $timeEntry = new TimeEntry(); $timeEntry->fill($request->validated()); + $timeEntry->user_id = $member->user_id; $timeEntry->description = $request->get('description') ?? ''; $timeEntry->organization()->associate($organization); $timeEntry->setComputedAttributeValue('billable_rate'); @@ -295,10 +340,12 @@ public function store(Organization $organization, TimeEntryStoreRequest $request */ public function update(Organization $organization, TimeEntry $timeEntry, TimeEntryUpdateRequest $request): JsonResource { - if ($timeEntry->user_id === Auth::id() && $request->get('user_id') === Auth::id()) { - $this->checkPermission($organization, 'time-entries:update:own', $timeEntry); + /** @var Member|null $member */ + $member = $request->has('member_id') ? Member::query()->findOrFail($request->get('member_id')) : null; + if ($timeEntry->member->user_id === Auth::id() && $member?->user_id === Auth::id()) { + $this->checkPermission($organization, 'time-entries:update:own'); } else { - $this->checkPermission($organization, 'time-entries:update:all', $timeEntry); + $this->checkPermission($organization, 'time-entries:update:all'); } if ($timeEntry->end !== null && $request->has('end') && $request->get('end') === null) { @@ -321,7 +368,7 @@ public function update(Organization $organization, TimeEntry $timeEntry, TimeEnt */ public function destroy(Organization $organization, TimeEntry $timeEntry): JsonResponse { - if ($timeEntry->user_id === Auth::id()) { + if ($timeEntry->member->user_id === Auth::id()) { $this->checkPermission($organization, 'time-entries:delete:own', $timeEntry); } else { $this->checkPermission($organization, 'time-entries:delete:all', $timeEntry); diff --git a/app/Http/Controllers/Api/V1/UserTimeEntryController.php b/app/Http/Controllers/Api/V1/UserTimeEntryController.php index 9264c411..7c69fce4 100644 --- a/app/Http/Controllers/Api/V1/UserTimeEntryController.php +++ b/app/Http/Controllers/Api/V1/UserTimeEntryController.php @@ -10,7 +10,6 @@ use App\Models\User; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\Resources\Json\JsonResource; -use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Log; class UserTimeEntryController extends Controller @@ -24,8 +23,7 @@ class UserTimeEntryController extends Controller */ public function myActive(): JsonResource { - /** @var User $user */ - $user = Auth::user(); + $user = $this->user(); $activeTimeEntriesOfUser = TimeEntry::query() ->whereBelongsTo($user, 'user') diff --git a/app/Http/Requests/V1/ProjectMember/ProjectMemberStoreRequest.php b/app/Http/Requests/V1/ProjectMember/ProjectMemberStoreRequest.php index adb37e53..dd176c9f 100644 --- a/app/Http/Requests/V1/ProjectMember/ProjectMemberStoreRequest.php +++ b/app/Http/Requests/V1/ProjectMember/ProjectMemberStoreRequest.php @@ -4,8 +4,8 @@ namespace App\Http\Requests\V1\ProjectMember; +use App\Models\Member; use App\Models\Organization; -use App\Models\User; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Database\Eloquent\Builder; use Illuminate\Foundation\Http\FormRequest; @@ -24,12 +24,12 @@ class ProjectMemberStoreRequest extends FormRequest public function rules(): array { return [ - 'user_id' => [ + 'member_id' => [ 'required', 'uuid', - new ExistsEloquent(User::class, null, function (Builder $builder): Builder { - /** @var Builder $builder */ - return $builder->belongsToOrganization($this->organization); + new ExistsEloquent(Member::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); }), ], 'billable_rate' => [ diff --git a/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateRequest.php b/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateRequest.php index a8a5d274..0be57fa4 100644 --- a/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateRequest.php +++ b/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateRequest.php @@ -4,6 +4,7 @@ namespace App\Http\Requests\V1\TimeEntry; +use App\Models\Member; use App\Models\Organization; use App\Models\Project; use App\Models\Tag; @@ -28,14 +29,38 @@ class TimeEntryAggregateRequest extends FormRequest public function rules(): array { return [ - 'group_1' => [ + 'group' => [ + 'nullable', 'required_with:group_2', 'in:day,week,month,year,user,project,task,client,billable', ], - 'group_2' => [ + 'sub_group' => [ + 'nullable', 'in:day,week,month,year,user,project,task,client,billable', ], + // Filter by member ID + 'member_id' => [ + 'string', + 'uuid', + new ExistsEloquent(Member::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + }), + ], + // Filter by multiple member IDs, member IDs are OR combined, but AND combined with the member_id parameter + 'member_ids' => [ + 'array', + 'min:1', + ], + 'member_ids.*' => [ + 'string', + 'uuid', + new ExistsEloquent(Member::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + }), + ], // Filter by user ID 'user_id' => [ diff --git a/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php b/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php index 3054080f..e89c60ae 100644 --- a/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php +++ b/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php @@ -4,11 +4,11 @@ namespace App\Http\Requests\V1\TimeEntry; +use App\Models\Member; use App\Models\Organization; use App\Models\Project; use App\Models\Tag; use App\Models\Task; -use App\Models\User; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Database\Eloquent\Builder; use Illuminate\Foundation\Http\FormRequest; @@ -28,13 +28,26 @@ class TimeEntryIndexRequest extends FormRequest public function rules(): array { return [ - // Filter by user ID - 'user_id' => [ + // Filter by member ID + 'member_id' => [ 'string', 'uuid', - new ExistsEloquent(User::class, null, function (Builder $builder): Builder { - /** @var Builder $builder */ - return $builder->belongsToOrganization($this->organization); + new ExistsEloquent(Member::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + }), + ], + // Filter by multiple member IDs, member IDs are OR combined, but AND combined with the member_id parameter + 'member_ids' => [ + 'array', + 'min:1', + ], + 'member_ids.*' => [ + 'string', + 'uuid', + new ExistsEloquent(Member::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); }), ], // Filter by project IDs, project IDs are OR combined diff --git a/app/Http/Requests/V1/TimeEntry/TimeEntryStoreRequest.php b/app/Http/Requests/V1/TimeEntry/TimeEntryStoreRequest.php index 840cfb37..10bc8635 100644 --- a/app/Http/Requests/V1/TimeEntry/TimeEntryStoreRequest.php +++ b/app/Http/Requests/V1/TimeEntry/TimeEntryStoreRequest.php @@ -4,11 +4,11 @@ namespace App\Http\Requests\V1\TimeEntry; +use App\Models\Member; use App\Models\Organization; use App\Models\Project; use App\Models\Tag; use App\Models\Task; -use App\Models\User; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Database\Eloquent\Builder; use Illuminate\Foundation\Http\FormRequest; @@ -27,14 +27,14 @@ class TimeEntryStoreRequest extends FormRequest public function rules(): array { return [ - // ID of the user that the time entry should belong to - 'user_id' => [ + // ID of the organization member that the time entry should belong to + 'member_id' => [ 'required', 'string', 'uuid', - new ExistsEloquent(User::class, null, function (Builder $builder): Builder { - /** @var Builder $builder */ - return $builder->belongsToOrganization($this->organization); + new ExistsEloquent(Member::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); }), ], 'project_id' => [ diff --git a/app/Http/Requests/V1/TimeEntry/TimeEntryUpdateRequest.php b/app/Http/Requests/V1/TimeEntry/TimeEntryUpdateRequest.php index a61f990f..6fd1e7e1 100644 --- a/app/Http/Requests/V1/TimeEntry/TimeEntryUpdateRequest.php +++ b/app/Http/Requests/V1/TimeEntry/TimeEntryUpdateRequest.php @@ -4,6 +4,7 @@ namespace App\Http\Requests\V1\TimeEntry; +use App\Models\Member; use App\Models\Organization; use App\Models\Project; use App\Models\Tag; @@ -26,6 +27,16 @@ class TimeEntryUpdateRequest extends FormRequest public function rules(): array { return [ + // ID of the organization member that the time entry should belong to + 'member_id' => [ + 'string', + 'uuid', + new ExistsEloquent(Member::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + }), + ], + // ID of the project that the time entry should belong to 'project_id' => [ 'nullable', 'string', diff --git a/app/Http/Resources/V1/Member/MemberPivotResource.php b/app/Http/Resources/V1/Member/MemberPivotResource.php index fe56a0c1..bfd1125f 100644 --- a/app/Http/Resources/V1/Member/MemberPivotResource.php +++ b/app/Http/Resources/V1/Member/MemberPivotResource.php @@ -5,7 +5,7 @@ namespace App\Http\Resources\V1\Member; use App\Http\Resources\V1\BaseResource; -use App\Models\Membership; +use App\Models\Member; use App\Models\User; use Illuminate\Http\Request; @@ -21,12 +21,12 @@ class MemberPivotResource extends BaseResource */ public function toArray(Request $request): array { - /** @var Membership $membership */ - $membership = $this->resource->getRelationValue('membership'); + /** @var Member $member */ + $member = $this->resource->getRelationValue('membership'); return [ /** @var string $id ID of membership */ - 'id' => $membership->id, + 'id' => $member->id, /** @var string $id ID of user */ 'user_id' => $this->resource->id, /** @var string $name Name */ @@ -34,11 +34,11 @@ public function toArray(Request $request): array /** @var string $email Email */ 'email' => $this->resource->email, /** @var string $role Role */ - 'role' => $membership->role, + 'role' => $member->role, /** @var bool $is_placeholder Placeholder user for imports, user might not really exist and does not know about this placeholder membership */ 'is_placeholder' => $this->resource->is_placeholder, /** @var int|null $billable_rate Billable rate in cents per hour */ - 'billable_rate' => $membership->billable_rate, + 'billable_rate' => $member->billable_rate, ]; } } diff --git a/app/Http/Resources/V1/Member/MemberResource.php b/app/Http/Resources/V1/Member/MemberResource.php index 57bf619e..d5b8b087 100644 --- a/app/Http/Resources/V1/Member/MemberResource.php +++ b/app/Http/Resources/V1/Member/MemberResource.php @@ -5,12 +5,12 @@ namespace App\Http\Resources\V1\Member; use App\Http\Resources\V1\BaseResource; -use App\Models\Membership; +use App\Models\Member; use App\Models\User; use Illuminate\Http\Request; /** - * @property Membership $resource + * @property Member $resource */ class MemberResource extends BaseResource { diff --git a/app/Http/Resources/V1/ProjectMember/ProjectMemberResource.php b/app/Http/Resources/V1/ProjectMember/ProjectMemberResource.php index 2d8e9dc7..8e986b25 100644 --- a/app/Http/Resources/V1/ProjectMember/ProjectMemberResource.php +++ b/app/Http/Resources/V1/ProjectMember/ProjectMemberResource.php @@ -25,8 +25,8 @@ public function toArray(Request $request): array 'id' => $this->resource->id, /** @var int|null $billable_rate Billable rate in cents per hour */ 'billable_rate' => $this->resource->billable_rate, - /** @var string $user_id ID of the user */ - 'user_id' => $this->resource->user_id, + /** @var string $member_id ID of the organization member */ + 'member_id' => $this->resource->member_id, /** @var string $project_id ID of the project */ 'project_id' => $this->resource->project_id, ]; diff --git a/app/Listeners/RemovePlaceholder.php b/app/Listeners/RemovePlaceholder.php index 853d2338..55bb93c4 100644 --- a/app/Listeners/RemovePlaceholder.php +++ b/app/Listeners/RemovePlaceholder.php @@ -4,8 +4,9 @@ namespace App\Listeners; -use App\Models\User; +use App\Models\Member; use App\Service\UserService; +use Illuminate\Database\Eloquent\Builder; use Laravel\Jetstream\Events\TeamMemberAdded; class RemovePlaceholder @@ -17,15 +18,21 @@ public function handle(TeamMemberAdded $event): void { /** @var UserService $userService */ $userService = app(UserService::class); - $placeholders = User::query() - ->where('is_placeholder', '=', true) - ->where('email', '=', $event->user->email) - ->belongsToOrganization($event->team) + $placeholders = Member::query() + ->whereHas('user', function (Builder $query) use ($event) { + $query->where('is_placeholder', '=', true) + ->where('email', '=', $event->user->email); + }) + ->whereBelongsTo($event->team, 'organization') + ->with(['user']) ->get(); foreach ($placeholders as $placeholder) { - $userService->assignOrganizationEntitiesToDifferentUser($event->team, $placeholder, $event->user); + /** @var Member $placeholder */ + $placeholderUser = $placeholder->user; + $userService->assignOrganizationEntitiesToDifferentUser($event->team, $placeholderUser, $event->user); $placeholder->delete(); + $placeholderUser->delete(); } } } diff --git a/app/Models/Membership.php b/app/Models/Member.php similarity index 78% rename from app/Models/Membership.php rename to app/Models/Member.php index e39ddadb..b12742e5 100644 --- a/app/Models/Membership.php +++ b/app/Models/Member.php @@ -5,7 +5,7 @@ namespace App\Models; use App\Models\Concerns\HasUuids; -use Database\Factories\MembershipFactory; +use Database\Factories\MemberFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Laravel\Jetstream\Membership as JetstreamMembership; @@ -21,9 +21,9 @@ * @property-read Organization $organization * @property-read User $user * - * @method static MembershipFactory factory() + * @method static MemberFactory factory() */ -class Membership extends JetstreamMembership +class Member extends JetstreamMembership { use HasFactory; use HasUuids; @@ -33,10 +33,10 @@ class Membership extends JetstreamMembership * * @var string */ - protected $table = 'organization_user'; + protected $table = 'members'; /** - * @return BelongsTo + * @return BelongsTo */ public function user(): BelongsTo { @@ -44,7 +44,7 @@ public function user(): BelongsTo } /** - * @return BelongsTo + * @return BelongsTo */ public function organization(): BelongsTo { diff --git a/app/Models/Organization.php b/app/Models/Organization.php index ac65d975..315a1a6a 100644 --- a/app/Models/Organization.php +++ b/app/Models/Organization.php @@ -30,7 +30,7 @@ * @property Collection $users * @property Collection $realUsers * @property-read Collection $teamInvitations - * @property Membership $membership + * @property Member $membership * * @method HasMany teamInvitations() * @method static OrganizationFactory factory() diff --git a/app/Models/ProjectMember.php b/app/Models/ProjectMember.php index 3c6f1839..6b3e707c 100644 --- a/app/Models/ProjectMember.php +++ b/app/Models/ProjectMember.php @@ -14,9 +14,11 @@ /** * @property string $id * @property int|null $billable_rate - * @property string $project_id - * @property string $user_id + * @property string $project_id Project ID + * @property string $member_id Member ID + * @property string $user_id User ID (legacy) * @property-read Project $project + * @property-read Member $member * @property-read User $user * * @method static Builder whereBelongsToOrganization(Organization $organization) @@ -45,6 +47,8 @@ public function project(): BelongsTo } /** + * @deprecated Use member relationship instead + * * @return BelongsTo */ public function user(): BelongsTo @@ -52,6 +56,14 @@ public function user(): BelongsTo return $this->belongsTo(User::class, 'user_id'); } + /** + * @return BelongsTo + */ + public function member(): BelongsTo + { + return $this->belongsTo(Member::class, 'member_id'); + } + /** * @param Builder $builder */ diff --git a/app/Models/Task.php b/app/Models/Task.php index 8c467ff3..b520dce8 100644 --- a/app/Models/Task.php +++ b/app/Models/Task.php @@ -65,9 +65,14 @@ public function timeEntries(): HasMany return $this->hasMany(TimeEntry::class, 'task_id'); } + /** + * @param Builder $builder + * @return Builder + */ public function scopeVisibleByUser(Builder $builder, User $user): Builder { return $builder->whereHas('project', function (Builder $builder) use ($user): Builder { + /** @var Builder $builder */ return $builder->visibleByUser($user); }); } diff --git a/app/Models/TimeEntry.php b/app/Models/TimeEntry.php index 6f921b3b..a2001d5a 100644 --- a/app/Models/TimeEntry.php +++ b/app/Models/TimeEntry.php @@ -24,7 +24,9 @@ * @property bool $billable * @property array $tags * @property string $user_id + * @property string $member_id * @property-read User $user + * @property-read Member $member * @property string $organization_id * @property-read Organization $organization * @property string|null $project_id @@ -91,6 +93,14 @@ public function user(): BelongsTo return $this->belongsTo(User::class, 'user_id'); } + /** + * @return BelongsTo + */ + public function member(): BelongsTo + { + return $this->belongsTo(Member::class, 'member_id'); + } + /** * @return BelongsTo */ diff --git a/app/Models/User.php b/app/Models/User.php index fcdefe03..3984b234 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -43,7 +43,7 @@ * @property string $current_team_id * @property Collection $organizations * @property Collection $timeEntries - * @property Membership $membership + * @property Member $membership * * @method HasMany ownedTeams() * @method static UserFactory factory() @@ -136,7 +136,7 @@ public function canBeImpersonated(): bool */ public function organizations(): BelongsToMany { - return $this->belongsToMany(Organization::class, Membership::class) + return $this->belongsToMany(Organization::class, Member::class) ->withPivot([ 'id', 'role', diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 33610d74..05fc5dfd 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -5,7 +5,7 @@ namespace App\Providers; use App\Models\Client; -use App\Models\Membership; +use App\Models\Member; use App\Models\Organization; use App\Models\OrganizationInvitation; use App\Models\Project; @@ -51,7 +51,7 @@ public function boot(): void Model::preventSilentlyDiscardingAttributes(! $this->app->isProduction()); Model::preventAccessingMissingAttributes(! $this->app->isProduction()); Relation::enforceMorphMap([ - 'membership' => Membership::class, + 'membership' => Member::class, 'organization' => Organization::class, 'organization-invitation' => OrganizationInvitation::class, 'user' => User::class, @@ -85,7 +85,7 @@ public function boot(): void return new PermissionStore(); }); - Route::model('member', Membership::class); + Route::model('member', Member::class); Route::model('invitation', OrganizationInvitation::class); } } diff --git a/app/Providers/JetstreamServiceProvider.php b/app/Providers/JetstreamServiceProvider.php index 6f192680..e8df5c03 100644 --- a/app/Providers/JetstreamServiceProvider.php +++ b/app/Providers/JetstreamServiceProvider.php @@ -14,6 +14,7 @@ use App\Actions\Jetstream\UpdateOrganization; use App\Enums\Role; use App\Enums\Weekday; +use App\Models\Member; use App\Models\Organization; use App\Models\OrganizationInvitation; use App\Models\User; @@ -50,6 +51,7 @@ public function boot(): void Jetstream::deleteTeamsUsing(DeleteOrganization::class); Jetstream::deleteUsersUsing(DeleteUser::class); Jetstream::useTeamModel(Organization::class); + Jetstream::useMembershipModel(Member::class); Jetstream::useTeamInvitationModel(OrganizationInvitation::class); app()->singleton(UpdateTeamMemberRole::class, UpdateMemberRole::class); } diff --git a/app/Service/BillableRateService.php b/app/Service/BillableRateService.php index 791ec5eb..a5c11eac 100644 --- a/app/Service/BillableRateService.php +++ b/app/Service/BillableRateService.php @@ -4,7 +4,7 @@ namespace App\Service; -use App\Models\Membership; +use App\Models\Member; use App\Models\Organization; use App\Models\Project; use App\Models\ProjectMember; @@ -36,13 +36,13 @@ public function getBillableRateForTimeEntry(TimeEntry $timeEntry): ?int } } // Member rate - /** @var Membership|null $membership */ - $membership = Membership::query() + /** @var Member|null $member */ + $member = Member::query() ->where('user_id', '=', $timeEntry->user_id) ->where('organization_id', '=', $timeEntry->organization_id) ->first(); - if ($membership !== null && $membership->billable_rate !== null) { - return $membership->billable_rate; + if ($member !== null && $member->billable_rate !== null) { + return $member->billable_rate; } // Organization rate diff --git a/app/Service/DashboardService.php b/app/Service/DashboardService.php index 7bb58687..568d9905 100644 --- a/app/Service/DashboardService.php +++ b/app/Service/DashboardService.php @@ -335,20 +335,22 @@ public function weeklyProjectOverview(User $user, Organization $organization): a } /** - * Rhe 4 most recently active members of your team with user_id, name, description of the latest time entry, time_entry_id, task_id and a boolean status if the team member is currently working + * Rhe 4 most recently active members of your team with member_id, name, description of the latest time entry, time_entry_id, task_id and a boolean status if the team member is currently working * - * @return array + * @return array */ public function latestTeamActivity(Organization $organization): array { $timeEntries = TimeEntry::query() - ->select(DB::raw('distinct on (user_id) user_id, description, id, task_id, start, "end"')) + ->select(DB::raw('distinct on (member_id) member_id, description, id, task_id, start, "end"')) ->whereBelongsTo($organization, 'organization') - ->orderBy('user_id') + ->orderBy('member_id') ->orderBy('start', 'desc') // Note: limit here does not work because of the distinct on ->with([ - 'user', + 'member' => [ + 'user', + ], ]) ->get() ->sortByDesc('start') @@ -358,8 +360,8 @@ public function latestTeamActivity(Organization $organization): array foreach ($timeEntries as $timeEntry) { $response[] = [ - 'user_id' => $timeEntry->user_id, - 'name' => $timeEntry->user->name, + 'member_id' => $timeEntry->member_id, + 'name' => $timeEntry->member->user->name, 'description' => $timeEntry->description, 'time_entry_id' => $timeEntry->id, 'task_id' => $timeEntry->task_id, diff --git a/app/Service/Import/Importers/ClockifyTimeEntriesImporter.php b/app/Service/Import/Importers/ClockifyTimeEntriesImporter.php index 3b8246b6..eb893053 100644 --- a/app/Service/Import/Importers/ClockifyTimeEntriesImporter.php +++ b/app/Service/Import/Importers/ClockifyTimeEntriesImporter.php @@ -56,6 +56,10 @@ public function importData(string $data): void 'timezone' => 'UTC', 'is_placeholder' => true, ]); + $memberId = $this->memberImportHelper->getKey([ + 'user_id' => $userId, + 'organization_id' => $this->organization->getKey(), + ]); $clientId = null; if ($record['Client'] !== '') { $clientId = $this->clientImportHelper->getKey([ @@ -83,6 +87,7 @@ public function importData(string $data): void } $timeEntry = new TimeEntry(); $timeEntry->user_id = $userId; + $timeEntry->member_id = $memberId; $timeEntry->task_id = $taskId; $timeEntry->project_id = $projectId; $timeEntry->organization_id = $this->organization->id; diff --git a/app/Service/Import/Importers/DefaultImporter.php b/app/Service/Import/Importers/DefaultImporter.php index 98905a77..c79add23 100644 --- a/app/Service/Import/Importers/DefaultImporter.php +++ b/app/Service/Import/Importers/DefaultImporter.php @@ -6,6 +6,7 @@ use App\Enums\Role; use App\Models\Client; +use App\Models\Member; use App\Models\Organization; use App\Models\Project; use App\Models\ProjectMember; @@ -26,6 +27,11 @@ abstract class DefaultImporter implements ImporterContract */ protected ImportDatabaseHelper $userImportHelper; + /** + * @var ImportDatabaseHelper + */ + protected ImportDatabaseHelper $memberImportHelper; + /** * @var ImportDatabaseHelper */ @@ -77,6 +83,10 @@ public function init(Organization $organization): void 'timezone:all', ], ]); + $this->memberImportHelper = new ImportDatabaseHelper(Member::class, ['user_id', 'organization_id'], true, function (Builder $builder) { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + }); $this->projectImportHelper = new ImportDatabaseHelper(Project::class, ['name', 'organization_id'], true, function (Builder $builder) { /** @var Builder $builder */ return $builder->where('organization_id', $this->organization->id); @@ -90,7 +100,7 @@ public function init(Organization $organization): void 'integer', ], ]); - $this->projectMemberImportHelper = new ImportDatabaseHelper(ProjectMember::class, ['project_id', 'user_id'], true, function (Builder $builder) { + $this->projectMemberImportHelper = new ImportDatabaseHelper(ProjectMember::class, ['project_id', 'member_id'], true, function (Builder $builder) { /** @var Builder $builder */ return $builder->whereBelongsToOrganization($this->organization); }, validate: [ diff --git a/app/Service/Import/Importers/TogglDataImporter.php b/app/Service/Import/Importers/TogglDataImporter.php index ac8b5e9e..f1d2b78a 100644 --- a/app/Service/Import/Importers/TogglDataImporter.php +++ b/app/Service/Import/Importers/TogglDataImporter.php @@ -74,13 +74,17 @@ public function importData(string $data): void } foreach ($workspaceUsers as $workspaceUser) { - $this->userImportHelper->getKey([ + $userId = $this->userImportHelper->getKey([ 'email' => $workspaceUser->email, ], [ 'name' => $workspaceUser->name, 'timezone' => $workspaceUser->timezone ?? 'UTC', 'is_placeholder' => true, ], (string) $workspaceUser->uid); + $memberId = $this->memberImportHelper->getKey([ + 'user_id' => $userId, + 'organization_id' => $this->organization->getKey(), + ], [], $userId); } foreach ($projects as $project) { @@ -114,10 +118,12 @@ public function importData(string $data): void } $projectMembers = json_decode($projectMembersFileContent); foreach ($projectMembers as $projectMember) { + $userId = $this->userImportHelper->getKeyByExternalIdentifier((string) $projectMember->user_id); $this->projectMemberImportHelper->getKey([ 'project_id' => $projectId, - 'user_id' => $this->userImportHelper->getKeyByExternalIdentifier((string) $projectMember->user_id), + 'member_id' => $this->memberImportHelper->getKeyByExternalIdentifier($userId), ], [ + 'user_id' => $userId, 'billable_rate' => $projectMember->rate !== null ? (int) ($projectMember->rate * 100) : null, ]); } diff --git a/app/Service/Import/Importers/TogglTimeEntriesImporter.php b/app/Service/Import/Importers/TogglTimeEntriesImporter.php index 826e56d6..6ea004ab 100644 --- a/app/Service/Import/Importers/TogglTimeEntriesImporter.php +++ b/app/Service/Import/Importers/TogglTimeEntriesImporter.php @@ -56,6 +56,10 @@ public function importData(string $data): void 'timezone' => 'UTC', 'is_placeholder' => true, ]); + $memberId = $this->memberImportHelper->getKey([ + 'user_id' => $userId, + 'organization_id' => $this->organization->getKey(), + ]); $clientId = null; if ($record['Client'] !== '') { $clientId = $this->clientImportHelper->getKey([ @@ -83,6 +87,7 @@ public function importData(string $data): void } $timeEntry = new TimeEntry(); $timeEntry->user_id = $userId; + $timeEntry->member_id = $memberId; $timeEntry->task_id = $taskId; $timeEntry->project_id = $projectId; $timeEntry->organization_id = $this->organization->id; diff --git a/app/Service/TimeEntryFilter.php b/app/Service/TimeEntryFilter.php index c6570f7b..1302b40c 100644 --- a/app/Service/TimeEntryFilter.php +++ b/app/Service/TimeEntryFilter.php @@ -4,6 +4,7 @@ namespace App\Service; +use App\Models\Member; use App\Models\TimeEntry; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Carbon; @@ -59,12 +60,25 @@ public function addActiveFilter(?string $active): self return $this; } - public function addUserIdFilter(?string $userId): self + public function addMemberIdFilter(?Member $member): self { - if ($userId === null) { + if ($member === null) { return $this; } - $this->builder->where('user_id', $userId); + $this->builder->where('member_id', $member->getKey()); + + return $this; + } + + /** + * @param array|null $memberIds + */ + public function addMemberIdsFilter(?array $memberIds): self + { + if ($memberIds === null) { + return $this; + } + $this->builder->whereIn('member_id', $memberIds); return $this; } diff --git a/app/Service/UserService.php b/app/Service/UserService.php index ffa1b56d..7afeb9ef 100644 --- a/app/Service/UserService.php +++ b/app/Service/UserService.php @@ -5,7 +5,7 @@ namespace App\Service; use App\Enums\Role; -use App\Models\Membership; +use App\Models\Member; use App\Models\Organization; use App\Models\ProjectMember; use App\Models\TimeEntry; @@ -19,12 +19,22 @@ class UserService */ public function assignOrganizationEntitiesToDifferentUser(Organization $organization, User $fromUser, User $toUser): void { + /** @var Member|null $toMember */ + $toMember = Member::query() + ->whereBelongsTo($organization, 'organization') + ->whereBelongsTo($toUser, 'user') + ->first(); + if ($toMember === null) { + throw new \InvalidArgumentException('User is not a member of the organization'); + } + // Time entries TimeEntry::query() ->whereBelongsTo($organization, 'organization') ->whereBelongsTo($fromUser, 'user') ->update([ 'user_id' => $toUser->getKey(), + 'member_id' => $toMember->getKey(), ]); // Project members @@ -33,6 +43,7 @@ public function assignOrganizationEntitiesToDifferentUser(Organization $organiza ->whereBelongsTo($fromUser, 'user') ->update([ 'user_id' => $toUser->getKey(), + 'member_id' => $toMember->getKey(), ]); } @@ -45,13 +56,17 @@ public function changeOwnership(Organization $organization, User $newOwner): voi $organization->update([ 'user_id' => $newOwner->getKey(), ]); - $userMembership = Membership::query() + /** @var Member|null $userMembership */ + $userMembership = Member::query() ->whereBelongsTo($organization, 'organization') ->whereBelongsTo($newOwner, 'user') ->first(); + if ($userMembership === null) { + throw new \InvalidArgumentException('User is not a member of the organization'); + } $userMembership->role = Role::Owner->value; $userMembership->save(); - $oldOwners = Membership::query() + $oldOwners = Member::query() ->whereBelongsTo($organization, 'organization') ->where('role', '=', Role::Owner->value) ->where('user_id', '!=', $newOwner->getKey()) diff --git a/database/factories/MembershipFactory.php b/database/factories/MemberFactory.php similarity index 78% rename from database/factories/MembershipFactory.php rename to database/factories/MemberFactory.php index f2b7b1fe..3b2bf185 100644 --- a/database/factories/MembershipFactory.php +++ b/database/factories/MemberFactory.php @@ -5,15 +5,15 @@ namespace Database\Factories; use App\Enums\Role; -use App\Models\Membership; +use App\Models\Member; use App\Models\Organization; use App\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; /** - * @extends Factory + * @extends Factory */ -class MembershipFactory extends Factory +class MemberFactory extends Factory { /** * Define the model's default state. @@ -24,11 +24,20 @@ public function definition(): array { return [ 'role' => Role::Employee, - 'organization_id' => OrganizationFactory::class, - 'user_id' => UserFactory::class, + 'organization_id' => Organization::factory(), + 'user_id' => User::factory(), ]; } + public function role(Role $role): static + { + return $this->state(function (array $attributes) use ($role): array { + return [ + 'role' => $role->value, + ]; + }); + } + public function forOrganization(Organization $organization): static { return $this->state(function (array $attributes) use ($organization): array { diff --git a/database/factories/ProjectFactory.php b/database/factories/ProjectFactory.php index 0a05d150..55faa471 100644 --- a/database/factories/ProjectFactory.php +++ b/database/factories/ProjectFactory.php @@ -5,10 +5,10 @@ namespace Database\Factories; use App\Models\Client; +use App\Models\Member; use App\Models\Organization; use App\Models\Project; use App\Models\ProjectMember; -use App\Models\User; use App\Service\ColorService; use Illuminate\Database\Eloquent\Factories\Factory; @@ -61,12 +61,12 @@ public function isPrivate(): self }); } - public function addMember(User $user, array $attributes = []): self + public function addMember(Member $member, array $attributes = []): self { - return $this->afterCreating(function (Project $project) use ($user, $attributes): void { + return $this->afterCreating(function (Project $project) use ($member, $attributes): void { ProjectMember::factory() ->forProject($project) - ->forUser($user) + ->forMember($member) ->create($attributes); }); } diff --git a/database/factories/ProjectMemberFactory.php b/database/factories/ProjectMemberFactory.php index 23a66fa4..1ab38ef5 100644 --- a/database/factories/ProjectMemberFactory.php +++ b/database/factories/ProjectMemberFactory.php @@ -4,6 +4,7 @@ namespace Database\Factories; +use App\Models\Member; use App\Models\Project; use App\Models\ProjectMember; use App\Models\User; @@ -25,9 +26,13 @@ public function definition(): array 'billable_rate' => $this->faker->numberBetween(10, 10000) * 100, 'project_id' => Project::factory(), 'user_id' => User::factory(), + 'member_id' => Member::factory(), ]; } + /** + * @deprecated Use forMember instead + */ public function forUser(User $user): self { return $this->state(function (array $attributes) use ($user): array { @@ -37,6 +42,16 @@ public function forUser(User $user): self }); } + public function forMember(Member $member): self + { + return $this->state(function (array $attributes) use ($member): array { + return [ + 'member_id' => $member->getKey(), + 'user_id' => $member->user_id, // Legacy + ]; + }); + } + public function forProject(Project $project): self { return $this->state(function (array $attributes) use ($project): array { diff --git a/database/factories/TimeEntryFactory.php b/database/factories/TimeEntryFactory.php index 24ba97a5..de34c9d1 100644 --- a/database/factories/TimeEntryFactory.php +++ b/database/factories/TimeEntryFactory.php @@ -4,6 +4,7 @@ namespace Database\Factories; +use App\Models\Member; use App\Models\Organization; use App\Models\Project; use App\Models\Tag; @@ -34,6 +35,7 @@ public function definition(): array 'billable' => $this->faker->boolean(), 'tags' => [], 'user_id' => User::factory(), + 'member_id' => Member::factory(), 'task_id' => null, 'project_id' => null, 'organization_id' => Organization::factory(), @@ -86,6 +88,9 @@ public function active(): self }); } + /** + * @deprecated Use forMember instead + */ public function forUser(User $user): self { return $this->state(function (array $attributes) use ($user) { @@ -95,6 +100,17 @@ public function forUser(User $user): self }); } + public function forMember(Member $member): static + { + return $this->state(function (array $attributes) use ($member): array { + return [ + 'member_id' => $member->getKey(), + 'user_id' => $member->user_id, + 'organization_id' => $member->organization_id, + ]; + }); + } + public function forOrganization(Organization $organization): self { return $this->state(function (array $attributes) use ($organization) { diff --git a/database/migrations/2024_05_07_134711_move_from_user_id_to_member_id_in_project_members_table.php b/database/migrations/2024_05_07_134711_move_from_user_id_to_member_id_in_project_members_table.php new file mode 100644 index 00000000..a3b60bc3 --- /dev/null +++ b/database/migrations/2024_05_07_134711_move_from_user_id_to_member_id_in_project_members_table.php @@ -0,0 +1,46 @@ +foreignUuid('member_id') + ->nullable() + ->constrained('organization_user') + ->cascadeOnDelete() + ->cascadeOnUpdate(); + }); + DB::statement(' + update project_members + set member_id = organization_user.id + from projects + join organization_user on organization_user.organization_id = projects.organization_id + where projects.id = project_members.project_id and project_members.user_id = organization_user.user_id + '); + Schema::table('project_members', function (Blueprint $table): void { + $table->uuid('member_id')->nullable(false)->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('project_members', function (Blueprint $table): void { + $table->dropForeign(['member_id']); + $table->dropColumn('member_id'); + }); + } +}; diff --git a/database/migrations/2024_05_07_141842_move_from_user_id_to_member_id_in_time_entries_table.php b/database/migrations/2024_05_07_141842_move_from_user_id_to_member_id_in_time_entries_table.php new file mode 100644 index 00000000..37c0764d --- /dev/null +++ b/database/migrations/2024_05_07_141842_move_from_user_id_to_member_id_in_time_entries_table.php @@ -0,0 +1,47 @@ +foreignUuid('member_id') + ->nullable() + ->constrained('organization_user') + ->cascadeOnDelete() + ->cascadeOnUpdate(); + }); + DB::statement(' + update time_entries + set member_id = organization_user.id + from organization_user + where time_entries.organization_id = organization_user.organization_id and + time_entries.user_id = organization_user.user_id + '); + Schema::table('time_entries', function (Blueprint $table): void { + $table->uuid('member_id')->nullable(false)->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('time_entries', function (Blueprint $table): void { + $table->dropForeign(['member_id']); + $table->dropColumn('member_id'); + }); + } +}; diff --git a/database/migrations/2024_05_13_171020_rename_table_organization_user_to_members.php b/database/migrations/2024_05_13_171020_rename_table_organization_user_to_members.php new file mode 100644 index 00000000..d5ce77ed --- /dev/null +++ b/database/migrations/2024_05_13_171020_rename_table_organization_user_to_members.php @@ -0,0 +1,25 @@ +deleteAll(); + $userWithMultipleOrganizations = User::factory()->withPersonalOrganization()->create([ + 'name' => 'Mister Overemployed', + 'email' => 'overemployed@acme.test', + ]); + $userAcmeOwner = User::factory()->withPersonalOrganization()->create([ 'name' => 'Acme Owner', 'email' => 'owner@acme.test', @@ -34,7 +39,7 @@ public function run(): void 'personal_team' => false, 'currency' => 'EUR', ]); - $userAcmeManager = User::factory()->withPersonalOrganization()->create([ + $userRivalManager = User::factory()->withPersonalOrganization()->create([ 'name' => 'Acme Manager', 'email' => 'test@example.com', ]); @@ -51,36 +56,28 @@ public function run(): void 'email' => 'old.employee@acme.test', 'password' => null, ]); - $userAcmeOwner->organizations()->attach($organizationAcme, [ - 'role' => Role::Owner->value, - ]); - $userAcmeManager->organizations()->attach($organizationAcme, [ - 'role' => Role::Manager->value, - ]); - $userAcmeAdmin->organizations()->attach($organizationAcme, [ - 'role' => Role::Admin->value, - ]); - $userAcmeEmployee->organizations()->attach($organizationAcme, [ - 'role' => Role::Employee->value, - ]); - $userAcmePlaceholder->organizations()->attach($organizationAcme, [ - 'role' => Role::Placeholder->value, - ]); + $userAcmeOwnerMember = Member::factory()->forUser($userAcmeOwner)->forOrganization($organizationAcme)->role(Role::Owner)->create(); + $userAcmeManagerMember = Member::factory()->forUser($userRivalManager)->forOrganization($organizationAcme)->role(Role::Manager)->create(); + $userAcmeAdminMember = Member::factory()->forUser($userAcmeAdmin)->forOrganization($organizationAcme)->role(Role::Admin)->create(); + $userAcmeEmployeeMember = Member::factory()->forUser($userAcmeEmployee)->forOrganization($organizationAcme)->role(Role::Employee)->create(); + $userAcmePlaceholderMember = Member::factory()->forUser($userAcmePlaceholder)->forOrganization($organizationAcme)->role(Role::Placeholder)->create(); + $userWithMultipleOrganizationsAcmeMember = Member::factory()->forUser($userWithMultipleOrganizations)->forOrganization($organizationAcme)->role(Role::Employee)->create(); - $timeEntriesAcmeAdmin = TimeEntry::factory() + TimeEntry::factory() ->count(10) - ->forUser($userAcmeAdmin) - ->forOrganization($organizationAcme) + ->forMember($userAcmeAdminMember) ->create(); - $timeEntriesAcmePlaceholder = TimeEntry::factory() + TimeEntry::factory() ->count(10) - ->forUser($userAcmePlaceholder) - ->forOrganization($organizationAcme) + ->forMember($userAcmePlaceholderMember) ->create(); - $timeEntriesAcmePlaceholder = TimeEntry::factory() + TimeEntry::factory() ->count(10) - ->forUser($userAcmeEmployee) - ->forOrganization($organizationAcme) + ->forMember($userAcmeEmployeeMember) + ->create(); + TimeEntry::factory() + ->count(5) + ->forMember($userWithMultipleOrganizationsAcmeMember) ->create(); $client = Client::factory()->forOrganization($organizationAcme)->create([ 'name' => 'Big Company', @@ -88,6 +85,10 @@ public function run(): void $bigCompanyProject = Project::factory()->forOrganization($organizationAcme)->forClient($client)->create([ 'name' => 'Big Company Project', ]); + ProjectMember::factory()->forProject($bigCompanyProject)->forMember($userAcmeEmployeeMember)->create(); + ProjectMember::factory()->forProject($bigCompanyProject)->forMember($userAcmeAdminMember)->create(); + ProjectMember::factory()->forProject($bigCompanyProject)->forMember($userWithMultipleOrganizationsAcmeMember)->create(); + Task::factory()->forOrganization($organizationAcme)->forProject($bigCompanyProject)->create(); $internalProject = Project::factory()->forOrganization($organizationAcme)->create([ @@ -98,21 +99,26 @@ public function run(): void 'name' => 'Other Owner', 'email' => 'owner@rival-company.test', ]); - $organization2 = Organization::factory()->withOwner($organization2Owner)->create([ + $organizationRival = Organization::factory()->withOwner($organization2Owner)->create([ 'name' => 'Rival Corp', 'personal_team' => true, 'currency' => 'USD', ]); - $userAcmeManager = User::factory()->withPersonalOrganization()->create([ + $userRivalManager = User::factory()->withPersonalOrganization()->create([ 'name' => 'Other User', 'email' => 'test@rival-company.test', ]); - $userAcmeManager->organizations()->attach($organization2, [ - 'role' => Role::Admin->value, - ]); - $otherCompanyProject = Project::factory()->forOrganization($organization2)->forClient($client)->create([ + $userRivalManagerMember = Member::factory()->forUser($userRivalManager)->forOrganization($organizationRival)->role(Role::Admin)->create(); + $userWithMultipleOrganizationsRivalMember = Member::factory()->forUser($userWithMultipleOrganizations)->forOrganization($organizationRival)->role(Role::Employee)->create(); + $otherCompanyProject = Project::factory()->forOrganization($organizationRival)->forClient($client)->create([ 'name' => 'Scale Company', ]); + ProjectMember::factory()->forProject($otherCompanyProject)->forMember($userRivalManagerMember)->create(); + ProjectMember::factory()->forProject($otherCompanyProject)->forMember($userWithMultipleOrganizationsRivalMember)->create(); + TimeEntry::factory() + ->count(5) + ->forMember($userWithMultipleOrganizationsRivalMember) + ->create(); User::factory()->withPersonalOrganization()->create([ 'email' => 'admin@example.com', diff --git a/tests/Feature/CreateTeamTest.php b/tests/Feature/CreateTeamTest.php index 9f35813b..f2cbacd7 100644 --- a/tests/Feature/CreateTeamTest.php +++ b/tests/Feature/CreateTeamTest.php @@ -5,7 +5,8 @@ namespace Tests\Feature; use App\Enums\Role; -use App\Models\Membership; +use App\Models\Member; +use App\Models\Organization; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; @@ -19,6 +20,7 @@ public function test_teams_can_be_created(): void // Arrange $user = User::factory()->withPersonalOrganization()->create(); $this->actingAs($user); + sleep(1); // Act $response = $this->post('/teams', [ @@ -26,10 +28,13 @@ public function test_teams_can_be_created(): void ]); // Assert - $newOrganization = $user->fresh()->ownedTeams()->latest('id')->first(); - $this->assertCount(2, $user->fresh()->ownedTeams); - $this->assertEquals('Test Organization', $newOrganization->name); - $member = Membership::query()->whereBelongsTo($user, 'user')->whereBelongsTo($newOrganization, 'organization')->firstOrFail(); + /** @var Organization|null $newOrganization */ + $ownedTeams = $user->fresh()->ownedTeams; + $this->assertCount(2, $ownedTeams); + $this->assertTrue($ownedTeams->contains('name', 'Test Organization')); + $newOrganization = $ownedTeams->firstWhere('name', 'Test Organization'); + /** @var Member $member */ + $member = Member::query()->whereBelongsTo($user, 'user')->whereBelongsTo($newOrganization, 'organization')->firstOrFail(); $this->assertSame(Role::Owner->value, $member->role); } } diff --git a/tests/Feature/InviteTeamMemberTest.php b/tests/Feature/InviteTeamMemberTest.php index 5ac29982..7939bd8d 100644 --- a/tests/Feature/InviteTeamMemberTest.php +++ b/tests/Feature/InviteTeamMemberTest.php @@ -4,6 +4,8 @@ namespace Tests\Feature; +use App\Enums\Role; +use App\Models\Member; use App\Models\TimeEntry; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -62,13 +64,13 @@ public function test_team_member_can_be_invited_to_team_if_already_on_team_as_pl $existingUser = User::factory()->create([ 'is_placeholder' => true, ]); - $user->currentTeam->users()->attach($existingUser, ['role' => 'employee']); + $user->currentTeam->users()->attach($existingUser, ['role' => Role::Employee->value]); $this->actingAs($user); // Act $response = $this->post('/teams/'.$user->currentTeam->id.'/members', [ 'email' => $existingUser->email, - 'role' => 'employee', + 'role' => Role::Employee->value, ]); // Assert @@ -103,7 +105,7 @@ public function test_team_member_invitations_can_be_accepted(): void $user = User::factory()->withPersonalOrganization()->create(); $invitation = $owner->currentTeam->teamInvitations()->create([ 'email' => $user->email, - 'role' => 'employee', + 'role' => Role::Employee->value, ]); $this->actingAs($user); @@ -126,11 +128,11 @@ public function test_team_member_invitations_of_placeholder_can_be_accepted_and_ { // Arrange Mail::fake(); - $placeholder = User::factory()->withPersonalOrganization()->placeholder()->create(); - + $placeholder = User::factory()->placeholder()->create(); $owner = User::factory()->withPersonalOrganization()->create(); - $owner->currentTeam->users()->attach($placeholder, ['role' => 'employee']); - $timeEntries = TimeEntry::factory()->forOrganization($owner->currentTeam)->forUser($placeholder)->createMany(5); + $placeholderMember = Member::factory()->forOrganization($owner->currentTeam)->forUser($placeholder)->create(); + + $timeEntries = TimeEntry::factory()->forOrganization($owner->currentTeam)->forMember($placeholderMember)->createMany(5); $user = User::factory()->withPersonalOrganization()->create([ 'email' => $placeholder->email, @@ -138,7 +140,7 @@ public function test_team_member_invitations_of_placeholder_can_be_accepted_and_ $invitation = $owner->currentTeam->teamInvitations()->create([ 'email' => $user->email, - 'role' => 'employee', + 'role' => Role::Employee->value, ]); $this->actingAs($user); @@ -167,7 +169,7 @@ public function test_team_member_accept_fails_if_user_with_that_email_does_not_e $user = User::factory()->withPersonalOrganization()->create(); $invitation = $owner->currentTeam->teamInvitations()->create([ 'email' => 'firstname.lastname@mail.test', - 'role' => 'employee', + 'role' => Role::Employee->value, ]); $this->actingAs($user); diff --git a/tests/Feature/RegistrationTest.php b/tests/Feature/RegistrationTest.php index 5294a8c4..c492551a 100644 --- a/tests/Feature/RegistrationTest.php +++ b/tests/Feature/RegistrationTest.php @@ -5,7 +5,7 @@ namespace Tests\Feature; use App\Enums\Role; -use App\Models\Membership; +use App\Models\Member; use App\Models\User; use App\Providers\RouteServiceProvider; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -58,7 +58,7 @@ public function test_new_users_can_register(): void $this->assertSame('UTC', $user->timezone); $organization = $user->organizations()->firstOrFail(); $this->assertSame(true, $organization->personal_team); - $member = Membership::query()->whereBelongsTo($user, 'user')->whereBelongsTo($organization, 'organization')->firstOrFail(); + $member = Member::query()->whereBelongsTo($user, 'user')->whereBelongsTo($organization, 'organization')->firstOrFail(); $this->assertSame(Role::Owner->value, $member->role); } diff --git a/tests/Feature/UpdateTeamMemberRoleTest.php b/tests/Feature/UpdateTeamMemberRoleTest.php index 35d1219c..d6c574f6 100644 --- a/tests/Feature/UpdateTeamMemberRoleTest.php +++ b/tests/Feature/UpdateTeamMemberRoleTest.php @@ -25,12 +25,12 @@ public function test_team_member_roles_can_be_updated(): void // Act $response = $this->put('/teams/'.$user->currentTeam->id.'/members/'.$otherUser->id, [ - 'role' => 'employee', + 'role' => Role::Employee->value, ]); // Assert $this->assertTrue($otherUser->fresh()->hasTeamRole( - $user->currentTeam->fresh(), 'employee' + $user->currentTeam->fresh(), Role::Employee->value, )); } @@ -88,7 +88,7 @@ public function test_only_team_owner_can_update_team_member_roles(): void // Act $response = $this->put('/teams/'.$user->currentTeam->id.'/members/'.$otherUser->id, [ - 'role' => 'employee', + 'role' => Role::Employee->value, ]); // Assert diff --git a/tests/Unit/Endpoint/Api/V1/ApiEndpointTestAbstract.php b/tests/Unit/Endpoint/Api/V1/ApiEndpointTestAbstract.php index f3301a33..1d590b23 100644 --- a/tests/Unit/Endpoint/Api/V1/ApiEndpointTestAbstract.php +++ b/tests/Unit/Endpoint/Api/V1/ApiEndpointTestAbstract.php @@ -4,7 +4,7 @@ namespace Tests\Unit\Endpoint\Api\V1; -use App\Models\Membership; +use App\Models\Member; use App\Models\Organization; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -17,7 +17,7 @@ class ApiEndpointTestAbstract extends TestCase /** * @param array $permissions - * @return object{user: User, organization: Organization, member: Membership} + * @return object{user: User, organization: Organization, member: Member} */ protected function createUserWithPermission(array $permissions, bool $isOwner = false): object { @@ -29,14 +29,14 @@ protected function createUserWithPermission(array $permissions, bool $isOwner = } else { $organization = Organization::factory()->create(); } - $membership = Membership::factory()->forUser($user)->forOrganization($organization)->create([ + $member = Member::factory()->forUser($user)->forOrganization($organization)->create([ 'role' => 'custom-test', ]); return (object) [ 'user' => $user, 'organization' => $organization, - 'member' => $membership, + 'member' => $member, ]; } } diff --git a/tests/Unit/Endpoint/Api/V1/InvitationEndpointTest.php b/tests/Unit/Endpoint/Api/V1/InvitationEndpointTest.php index 1afb963b..70198147 100644 --- a/tests/Unit/Endpoint/Api/V1/InvitationEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/InvitationEndpointTest.php @@ -4,6 +4,7 @@ namespace Tests\Unit\Endpoint\Api\V1; +use App\Enums\Role; use App\Models\OrganizationInvitation; use Illuminate\Support\Facades\Mail; use Laravel\Jetstream\Mail\TeamInvitation; @@ -50,7 +51,7 @@ public function test_store_fails_if_user_has_no_permission_to_create_invitations // Act $response = $this->postJson(route('api.v1.invitations.store', $data->organization->getKey()), [ 'email' => 'test@mail.test', - 'role' => 'employee', + 'role' => Role::Employee->value, ]); // Assert @@ -68,7 +69,7 @@ public function test_store_invites_user_to_organization(): void // Act $response = $this->postJson(route('api.v1.invitations.store', $data->organization->getKey()), [ 'email' => 'test@asdf.at', - 'role' => 'employee', + 'role' => Role::Employee->value, ]); // Assert @@ -76,7 +77,7 @@ public function test_store_invites_user_to_organization(): void $invitation = OrganizationInvitation::first(); $this->assertNotNull($invitation); $this->assertEquals('test@asdf.at', $invitation->email); - $this->assertEquals('employee', $invitation->role); + $this->assertEquals(Role::Employee->value, $invitation->role); } public function test_resend_fails_if_user_has_no_permission_to_resend_the_invitation(): void diff --git a/tests/Unit/Endpoint/Api/V1/MemberEndpointTest.php b/tests/Unit/Endpoint/Api/V1/MemberEndpointTest.php index 045c6939..2534f6dc 100644 --- a/tests/Unit/Endpoint/Api/V1/MemberEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/MemberEndpointTest.php @@ -4,7 +4,8 @@ namespace Tests\Unit\Endpoint\Api\V1; -use App\Models\Membership; +use App\Enums\Role; +use App\Models\Member; use App\Models\Organization; use App\Models\Project; use App\Models\ProjectMember; @@ -53,7 +54,7 @@ public function test_update_member_fails_if_user_has_no_permission_to_update_mem // Act $response = $this->putJson(route('api.v1.members.update', [$data->organization->getKey(), $data->member->getKey()]), [ 'billable_rate' => 10001, - 'role' => 'employee', + 'role' => Role::Employee->value, ]); // Assert @@ -74,7 +75,7 @@ public function test_update_member_fails_if_member_is_not_part_of_org(): void // Act $response = $this->putJson(route('api.v1.members.update', [$data->organization->getKey(), $otherData->member->getKey()]), [ 'billable_rate' => 10001, - 'role' => 'employee', + 'role' => Role::Employee->value, ]); // Assert @@ -92,7 +93,7 @@ public function test_update_member_succeeds_if_data_is_valid(): void // Act $response = $this->putJson(route('api.v1.members.update', [$data->organization->id, $data->member]), [ 'billable_rate' => 10001, - 'role' => 'employee', + 'role' => Role::Employee->value, ]); // Assert @@ -100,7 +101,7 @@ public function test_update_member_succeeds_if_data_is_valid(): void $member = $data->member; $member->refresh(); $this->assertSame(10001, $member->billable_rate); - $this->assertSame('employee', $member->role); + $this->assertSame(Role::Employee->value, $member->role); } public function test_invite_placeholder_succeeds_if_data_is_valid(): void @@ -112,7 +113,7 @@ public function test_invite_placeholder_succeeds_if_data_is_valid(): void $user = User::factory()->create([ 'is_placeholder' => true, ]); - $member = Membership::factory()->forUser($user)->forOrganization($data->organization)->create(); + $member = Member::factory()->forUser($user)->forOrganization($data->organization)->create(); Passport::actingAs($data->user); // Act @@ -164,7 +165,7 @@ public function test_destroy_endpoint_fails_if_member_is_still_in_use_by_a_time_ $data = $this->createUserWithPermission([ 'members:delete', ]); - TimeEntry::factory()->forUser($data->user)->forOrganization($data->organization)->create(); + TimeEntry::factory()->forMember($data->member)->forOrganization($data->organization)->create(); Passport::actingAs($data->user); // Act @@ -173,7 +174,7 @@ public function test_destroy_endpoint_fails_if_member_is_still_in_use_by_a_time_ // Assert $response->assertStatus(400); $response->assertJsonPath('message', 'The member is still used by a time entry and can not be deleted.'); - $this->assertDatabaseHas(Membership::class, [ + $this->assertDatabaseHas(Member::class, [ 'id' => $data->member->getKey(), ]); } @@ -185,7 +186,7 @@ public function test_destroy_endpoint_fails_if_member_is_still_in_use_by_a_proje 'members:delete', ]); $project = Project::factory()->forOrganization($data->organization)->create(); - ProjectMember::factory()->forProject($project)->forUser($data->user)->create(); + ProjectMember::factory()->forProject($project)->forMember($data->member)->create(); Passport::actingAs($data->user); // Act @@ -194,7 +195,7 @@ public function test_destroy_endpoint_fails_if_member_is_still_in_use_by_a_proje // Assert $response->assertStatus(400); $response->assertJsonPath('message', 'The member is still used by a project member and can not be deleted.'); - $this->assertDatabaseHas(Membership::class, [ + $this->assertDatabaseHas(Member::class, [ 'id' => $data->member->getKey(), ]); } @@ -212,7 +213,7 @@ public function test_destroy_member_succeeds_if_data_is_valid(): void // Assert $response->assertStatus(204); - $this->assertDatabaseMissing(Membership::class, [ + $this->assertDatabaseMissing(Member::class, [ 'id' => $data->member->getKey(), ]); } @@ -225,7 +226,7 @@ public function test_invite_placeholder_fails_if_user_does_not_have_permission() $user = User::factory()->create([ 'is_placeholder' => true, ]); - $member = Membership::factory()->forUser($user)->forOrganization($data->organization)->create(); + $member = Member::factory()->forUser($user)->forOrganization($data->organization)->create(); Passport::actingAs($data->user); // Act @@ -249,7 +250,7 @@ public function test_invite_placeholder_fails_if_user_is_not_part_of_organizatio $user = User::factory()->create([ 'is_placeholder' => true, ]); - $member = Membership::factory()->forUser($user)->forOrganization($otherOrganization)->create(); + $member = Member::factory()->forUser($user)->forOrganization($otherOrganization)->create(); Passport::actingAs($data->user); // Act diff --git a/tests/Unit/Endpoint/Api/V1/ProjectEndpointTest.php b/tests/Unit/Endpoint/Api/V1/ProjectEndpointTest.php index 140f4e8c..d1aab704 100644 --- a/tests/Unit/Endpoint/Api/V1/ProjectEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/ProjectEndpointTest.php @@ -55,7 +55,7 @@ public function test_index_endpoint_returns_list_of_projects_of_organization_whi ]); $privateProjects = Project::factory()->forOrganization($data->organization)->isPrivate()->createMany(2); $publicProjects = Project::factory()->forOrganization($data->organization)->isPublic()->createMany(2); - $privateProjectsWithMembership = Project::factory()->forOrganization($data->organization)->addMember($data->user)->isPrivate()->createMany(2); + $privateProjectsWithMembership = Project::factory()->forOrganization($data->organization)->addMember($data->member)->isPrivate()->createMany(2); Passport::actingAs($data->user); // Act diff --git a/tests/Unit/Endpoint/Api/V1/ProjectMemberEndpointTest.php b/tests/Unit/Endpoint/Api/V1/ProjectMemberEndpointTest.php index dc746d5d..0662e24c 100644 --- a/tests/Unit/Endpoint/Api/V1/ProjectMemberEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/ProjectMemberEndpointTest.php @@ -4,6 +4,7 @@ namespace Tests\Unit\Endpoint\Api\V1; +use App\Models\Member; use App\Models\Project; use App\Models\ProjectMember; use App\Models\User; @@ -81,13 +82,14 @@ public function test_store_endpoint_fails_if_user_has_no_permission_to_add_membe ]); $project = Project::factory()->forOrganization($data->organization)->create(); $projectMemberFake = ProjectMember::factory()->make(); - $user = User::factory()->attachToOrganization($data->organization)->create(); + $user = User::factory()->create(); + $member = Member::factory()->forOrganization($data->organization)->forUser($user)->create(); Passport::actingAs($data->user); // Act $response = $this->postJson(route('api.v1.project-members.store', [$data->organization->getKey(), $project->getKey()]), [ 'billable_rate' => $projectMemberFake->billable_rate, - 'user_id' => $user->getKey(), + 'member_id' => $member->getKey(), ]); // Assert @@ -105,13 +107,14 @@ public function test_store_endpoint_fails_if_given_project_does_not_belong_to_or ]); $project = Project::factory()->forOrganization($otherData->organization)->create(); $projectMemberFake = ProjectMember::factory()->make(); - $user = User::factory()->attachToOrganization($data->organization)->create(); + $user = User::factory()->create(); + $member = Member::factory()->forOrganization($data->organization)->forUser($user)->create(); Passport::actingAs($data->user); // Act $response = $this->postJson(route('api.v1.project-members.store', [$data->organization->getKey(), $project->getKey()]), [ 'billable_rate' => $projectMemberFake->billable_rate, - 'user_id' => $user->getKey(), + 'member_id' => $member->getKey(), ]); // Assert @@ -129,17 +132,18 @@ public function test_store_endpoint_fails_if_given_user_does_not_belong_to_organ ]); $project = Project::factory()->forOrganization($data->organization)->create(); $projectMemberFake = ProjectMember::factory()->make(); - $user = User::factory()->attachToOrganization($otherData->organization)->create(); + $user = User::factory()->create(); + $member = Member::factory()->forOrganization($otherData->organization)->forUser($user)->create(); Passport::actingAs($data->user); // Act $response = $this->postJson(route('api.v1.project-members.store', [$data->organization->getKey(), $project->getKey()]), [ 'billable_rate' => $projectMemberFake->billable_rate, - 'user_id' => $user->getKey(), + 'member_id' => $member->getKey(), ]); // Assert - $response->assertInvalid(['user_id']); + $response->assertInvalid(['member_id']); } public function test_store_endpoint_fails_if_user_is_a_placeholder(): void @@ -150,13 +154,14 @@ public function test_store_endpoint_fails_if_user_is_a_placeholder(): void ]); $project = Project::factory()->forOrganization($data->organization)->create(); $projectMemberFake = ProjectMember::factory()->make(); - $user = User::factory()->attachToOrganization($data->organization)->placeholder()->create(); + $user = User::factory()->placeholder()->create(); + $member = Member::factory()->forOrganization($data->organization)->forUser($user)->create(); Passport::actingAs($data->user); // Act $response = $this->postJson(route('api.v1.project-members.store', [$data->organization->getKey(), $project->getKey()]), [ 'billable_rate' => $projectMemberFake->billable_rate, - 'user_id' => $user->getKey(), + 'member_id' => $member->getKey(), ]); // Assert @@ -168,7 +173,7 @@ public function test_store_endpoint_fails_if_user_is_a_placeholder(): void ]); $this->assertDatabaseMissing(ProjectMember::class, [ 'billable_rate' => $projectMemberFake->billable_rate, - 'user_id' => $user->getKey(), + 'member_id' => $member->getKey(), 'project_id' => $project->getKey(), ]); } @@ -181,14 +186,14 @@ public function test_store_endpoint_fails_if_user_is_already_member_of_project() ]); $project = Project::factory()->forOrganization($data->organization)->create(); $projectMemberFake = ProjectMember::factory()->make(); - $user = User::factory()->attachToOrganization($data->organization)->create(); - ProjectMember::factory()->forProject($project)->forUser($user)->create(); + $member = Member::factory()->forOrganization($data->organization)->create(); + ProjectMember::factory()->forProject($project)->forMember($member)->create(); Passport::actingAs($data->user); // Act $response = $this->postJson(route('api.v1.project-members.store', [$data->organization->getKey(), $project->getKey()]), [ 'billable_rate' => $projectMemberFake->billable_rate, - 'user_id' => $user->getKey(), + 'member_id' => $member->getKey(), ]); // Assert @@ -200,7 +205,7 @@ public function test_store_endpoint_fails_if_user_is_already_member_of_project() ]); $this->assertDatabaseMissing(ProjectMember::class, [ 'billable_rate' => $projectMemberFake->billable_rate, - 'user_id' => $user->getKey(), + 'member_id' => $member->getKey(), 'project_id' => $project->getKey(), ]); } @@ -213,20 +218,21 @@ public function test_store_endpoint_creates_new_project_member(): void ]); $project = Project::factory()->forOrganization($data->organization)->create(); $projectMemberFake = ProjectMember::factory()->make(); - $user = User::factory()->attachToOrganization($data->organization)->create(); + $user = User::factory()->create(); + $member = Member::factory()->forOrganization($data->organization)->forUser($user)->create(); Passport::actingAs($data->user); // Act $response = $this->postJson(route('api.v1.project-members.store', [$data->organization->getKey(), $project->getKey()]), [ 'billable_rate' => $projectMemberFake->billable_rate, - 'user_id' => $user->getKey(), + 'member_id' => $member->getKey(), ]); // Assert $response->assertStatus(201); $this->assertDatabaseHas(ProjectMember::class, [ 'billable_rate' => $projectMemberFake->billable_rate, - 'user_id' => $user->getKey(), + 'member_id' => $member->getKey(), 'project_id' => $project->getKey(), ]); } @@ -294,7 +300,7 @@ public function test_update_endpoint_updates_project_member(): void $this->assertDatabaseHas(ProjectMember::class, [ 'id' => $projectMember->getKey(), 'billable_rate' => $projectMemberFake->billable_rate, - 'user_id' => $projectMember->user_id, + 'member_id' => $projectMember->member_id, ]); } diff --git a/tests/Unit/Endpoint/Api/V1/TagEndpointTest.php b/tests/Unit/Endpoint/Api/V1/TagEndpointTest.php index 94631458..f9666439 100644 --- a/tests/Unit/Endpoint/Api/V1/TagEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/TagEndpointTest.php @@ -207,7 +207,7 @@ public function test_destroy_endpoint_fails_if_tag_is_still_in_use_by_a_time_ent 'tags:delete', ]); $tag = Tag::factory()->forOrganization($data->organization)->create(); - TimeEntry::factory()->forUser($data->user)->forOrganization($data->organization)->create([ + TimeEntry::factory()->forMember($data->member)->forOrganization($data->organization)->create([ 'tags' => [$tag->getKey()], ]); Passport::actingAs($data->user); diff --git a/tests/Unit/Endpoint/Api/V1/TaskEndpointTest.php b/tests/Unit/Endpoint/Api/V1/TaskEndpointTest.php index e27f59c5..8fad5433 100644 --- a/tests/Unit/Endpoint/Api/V1/TaskEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/TaskEndpointTest.php @@ -86,7 +86,7 @@ public function test_index_endpoint_returns_list_of_all_tasks_with_access_of_org $projectPublic = Project::factory()->isPublic()->create(); Task::factory()->forOrganization($data->organization)->forProject($projectPublic)->createMany(2); $projectAsMember = Project::factory()->isPrivate()->create(); - ProjectMember::factory()->forProject($projectAsMember)->forUser($data->user)->create(); + ProjectMember::factory()->forProject($projectAsMember)->forMember($data->member)->create(); Task::factory()->forOrganization($data->organization)->forProject($projectAsMember)->createMany(2); Passport::actingAs($data->user); @@ -177,7 +177,7 @@ public function test_index_endpoint_returns_list_of_all_tasks_of_organization_fi 'tasks:view', ]); $project = Project::factory()->forOrganization($data->organization)->create(); - ProjectMember::factory()->forProject($project)->forUser($data->user)->create(); + ProjectMember::factory()->forProject($project)->forMember($data->member)->create(); Task::factory()->forOrganization($data->organization)->createMany(4); Task::factory()->forOrganization($data->organization)->forProject($project)->createMany(2); Passport::actingAs($data->user); @@ -311,7 +311,7 @@ public function test_destroy_endpoint_fails_if_task_is_still_in_use_by_a_time_en 'tasks:delete', ]); $task = Task::factory()->forOrganization($data->organization)->create(); - TimeEntry::factory()->forUser($data->user)->forTask($task)->forOrganization($data->organization)->create(); + TimeEntry::factory()->forMember($data->member)->forTask($task)->forOrganization($data->organization)->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 c4996ab7..a00ed300 100644 --- a/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php @@ -4,6 +4,8 @@ namespace Tests\Unit\Endpoint\Api\V1; +use App\Enums\Role; +use App\Models\Member; use App\Models\Project; use App\Models\TimeEntry; use App\Models\User; @@ -51,11 +53,14 @@ public function test_index_endpoint_returns_time_entries_for_current_user(): voi $data = $this->createUserWithPermission([ 'time-entries:view:own', ]); - $timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user)->create(); + $timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->create(); Passport::actingAs($data->user); // Act - $response = $this->getJson(route('api.v1.time-entries.index', [$data->organization->getKey(), 'user_id' => $data->user->getKey()])); + $response = $this->getJson(route('api.v1.time-entries.index', [ + $data->organization->getKey(), + 'member_id' => $data->member->getKey(), + ])); // Assert $response->assertStatus(200); @@ -68,15 +73,20 @@ public function test_index_endpoint_fails_if_user_filter_is_from_different_organ $data = $this->createUserWithPermission([ 'time-entries:view:all', ]); - $user = User::factory()->withPersonalOrganization()->create(); + $otherData = $this->createUserWithPermission([ + 'time-entries:view:all', + ]); Passport::actingAs($data->user); // Act - $response = $this->getJson(route('api.v1.time-entries.index', [$data->organization->getKey(), 'user_id' => $user->getKey()])); + $response = $this->getJson(route('api.v1.time-entries.index', [ + $data->organization->getKey(), + 'member_id' => $otherData->member->getKey(), + ])); // Assert $response->assertStatus(422); - $response->assertJsonValidationErrorFor('user_id'); + $response->assertJsonValidationErrorFor('member_id'); } public function test_index_endpoint_returns_time_entries_for_other_user_in_organization(): void @@ -86,10 +96,8 @@ public function test_index_endpoint_returns_time_entries_for_other_user_in_organ 'time-entries:view:all', ]); $user = User::factory()->create(); - $data->organization->users()->attach($user, [ - 'role' => 'employee', - ]); - $timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forUser($user)->create(); + $member = Member::factory()->forOrganization($data->organization)->forUser($user)->role(Role::Employee)->create(); + $timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($member)->create(); Passport::actingAs($data->user); // Act @@ -107,16 +115,14 @@ public function test_index_endpoint_returns_time_entries_for_all_users_in_organi 'time-entries:view:all', ]); $user = User::factory()->create(); - $data->organization->users()->attach($user, [ - 'role' => 'employee', - ]); - $timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user)->create([ + $member = Member::factory()->forOrganization($data->organization)->forUser($user)->role(Role::Employee)->create(); + $timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->create([ 'start' => Carbon::now()->subDay(), ]); - $timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forUser($user)->create([ + $timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forMember($member)->create([ 'start' => Carbon::now()->subDays(2), ]); - $timeEntry3 = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user)->create([ + $timeEntry3 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->create([ 'start' => Carbon::now()->subDays(3), ]); Passport::actingAs($data->user); @@ -137,15 +143,15 @@ public function test_index_endpoint_returns_only_active_time_entries(): void $data = $this->createUserWithPermission([ 'time-entries:view:own', ]); - $activeTimeEntry = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user)->active()->create(); - $nonActiveTimeEntries = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user)->createMany(3); + $activeTimeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->active()->create(); + $nonActiveTimeEntries = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->createMany(3); Passport::actingAs($data->user); // Act $response = $this->getJson(route('api.v1.time-entries.index', [ $data->organization->getKey(), 'active' => 'true', - 'user_id' => $data->user->getKey(), + 'member_id' => $data->member->getKey(), ])); // Assert @@ -160,15 +166,15 @@ public function test_index_endpoint_returns_only_non_active_time_entries(): void $data = $this->createUserWithPermission([ 'time-entries:view:own', ]); - $activeTimeEntry = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user)->active()->createMany(3); - $nonActiveTimeEntries = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user)->create(); + $activeTimeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->active()->createMany(3); + $nonActiveTimeEntries = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->create(); Passport::actingAs($data->user); // Act $response = $this->getJson(route('api.v1.time-entries.index', [ $data->organization->getKey(), 'active' => 'false', - 'user_id' => $data->user->getKey(), + 'member_id' => $data->member->getKey(), ])); // Assert @@ -184,7 +190,7 @@ public function test_index_endpoint_filter_only_full_dates_returns_time_entries_ $data = $this->createUserWithPermission([ 'time-entries:view:own', ]); - $timeEntries = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user)->createMany(3); + $timeEntries = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->createMany(3); Passport::actingAs($data->user); // Act @@ -192,7 +198,7 @@ public function test_index_endpoint_filter_only_full_dates_returns_time_entries_ $data->organization->getKey(), 'only_full_dates' => 'true', 'limit' => 5, - 'user_id' => $data->user->getKey(), + 'member_id' => $data->member->getKey(), ])); // Assert @@ -206,10 +212,10 @@ public function test_index_endpoint_filter_only_full_dates_returns_time_entries_ $data = $this->createUserWithPermission([ 'time-entries:view:own', ]); - $timeEntriesDay1 = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user) + $timeEntriesDay1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member) ->startBetween(Carbon::now($data->user->timezone)->subDay()->startOfDay(), Carbon::now($data->user->timezone)->subDay()->endOfDay()) ->createMany(3); - $timeEntriesDay2 = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user) + $timeEntriesDay2 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member) ->startBetween(Carbon::now($data->user->timezone)->subDays(2)->startOfDay(), Carbon::now($data->user->timezone)->subDays(2)->endOfDay()) ->createMany(3); Passport::actingAs($data->user); @@ -219,7 +225,7 @@ public function test_index_endpoint_filter_only_full_dates_returns_time_entries_ $data->organization->getKey(), 'only_full_dates' => 'true', 'limit' => 5, - 'user_id' => $data->user->getKey(), + 'member_id' => $data->member->getKey(), ])); // Assert @@ -241,7 +247,7 @@ public function test_index_endpoint_filter_only_full_dates_returns_time_entries_ */ // Note: This entry is yesterday in user timezone and yesterday in UTC - $timeEntriesDay1InUserTimeZone = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user) + $timeEntriesDay1InUserTimeZone = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member) ->state([ 'start' => Carbon::now($data->user->timezone)->subDay()->startOfDay()->utc(), ]) @@ -249,7 +255,7 @@ public function test_index_endpoint_filter_only_full_dates_returns_time_entries_ //dump($timeEntriesDay1InUserTimeZone->first()->refresh()->start->toImmutable()->timezone('UTC')->toDateString()); //dump($timeEntriesDay1InUserTimeZone->first()->refresh()->start->toImmutable()->timezone($data->user->timezone)->toDateString()); // Note: This entry is yesterday in UTC timezone, but two days ago in user timezone - $timeEntriesDay1InUTC = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user) + $timeEntriesDay1InUTC = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member) ->state([ 'start' => Carbon::now('UTC')->subDay()->startOfDay()->utc(), ]) @@ -257,7 +263,7 @@ public function test_index_endpoint_filter_only_full_dates_returns_time_entries_ //dump($timeEntriesDay1InUTC->first()->refresh()->start->toImmutable()->timezone('UTC')->toDateString()); //dump($timeEntriesDay1InUTC->first()->refresh()->start->toImmutable()->timezone($data->user->timezone)->toDateString()); // Note: This entry is two days ago in user timezone - $timeEntriesDay2InUserTimeZone = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user) + $timeEntriesDay2InUserTimeZone = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member) ->state([ 'start' => Carbon::now($data->user->timezone)->subDays(2)->startOfDay()->utc(), ]) @@ -270,7 +276,7 @@ public function test_index_endpoint_filter_only_full_dates_returns_time_entries_ $data->organization->getKey(), 'only_full_dates' => 'true', 'limit' => 5, - 'user_id' => $data->user->getKey(), + 'member_id' => $data->member->getKey(), ])); // Assert @@ -284,7 +290,7 @@ public function test_index_endpoint_filter_only_full_dates_returns_time_entries_ $data = $this->createUserWithPermission([ 'time-entries:view:own', ]); - $timeEntriesDay1 = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user) + $timeEntriesDay1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member) ->startBetween(Carbon::now()->subDay()->startOfDay(), Carbon::now()->subDay()->endOfDay()) ->createMany(7); Passport::actingAs($data->user); @@ -294,7 +300,7 @@ public function test_index_endpoint_filter_only_full_dates_returns_time_entries_ $data->organization->getKey(), 'only_full_dates' => 'true', 'limit' => 5, - 'user_id' => $data->user->getKey(), + 'member_id' => $data->member->getKey(), ])); // Assert @@ -311,19 +317,19 @@ public function test_index_endpoint_before_filter_returns_time_entries_before_da $data = $this->createUserWithPermission([ 'time-entries:view:own', ]); - $timeEntriesAfter = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user) + $timeEntriesAfter = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member) ->startBetween( Carbon::now()->timezone($data->user->timezone)->subDay()->startOfDay()->utc(), Carbon::now()->timezone($data->user->timezone)->utc() ) ->createMany(3); - $timeEntriesBefore = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user) + $timeEntriesBefore = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member) ->startBetween( Carbon::now()->timezone($data->user->timezone)->subDays(2)->startOfDay()->utc(), Carbon::now()->timezone($data->user->timezone)->subDays(2)->endOfDay()->utc() ) ->createMany(3); - $timeEntriesDirectlyBeforeLimit = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user) + $timeEntriesDirectlyBeforeLimit = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member) ->create([ 'start' => Carbon::now()->timezone($data->user->timezone)->subDays(2)->endOfDay()->utc(), ]); @@ -333,7 +339,7 @@ public function test_index_endpoint_before_filter_returns_time_entries_before_da $response = $this->getJson(route('api.v1.time-entries.index', [ $data->organization->getKey(), 'before' => Carbon::now()->timezone($data->user->timezone)->subDay()->startOfDay()->toIso8601ZuluString(), - 'user_id' => $data->user->getKey(), + 'member_id' => $data->member->getKey(), ])); // Assert @@ -354,13 +360,13 @@ public function test_index_endpoint_after_filter_returns_time_entries_after_date $data = $this->createUserWithPermission([ 'time-entries:view:own', ]); - $timeEntriesAfter = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user) + $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); - $timeEntriesBefore = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user) + $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); - $timeEntriesDirectlyAfterLimit = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user) + $timeEntriesDirectlyAfterLimit = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member) ->create([ 'start' => Carbon::now($data->user->timezone)->startOfDay()->utc(), ]); @@ -370,7 +376,7 @@ public function test_index_endpoint_after_filter_returns_time_entries_after_date $response = $this->getJson(route('api.v1.time-entries.index', [ $data->organization->getKey(), 'after' => Carbon::now($data->user->timezone)->subDay()->endOfDay()->toIso8601ZuluString(), // yesterday - 'user_id' => $data->user->getKey(), + 'member_id' => $data->member->getKey(), ])); // Assert @@ -405,10 +411,10 @@ public function test_aggregate_endpoint_groups_by_two_groups(): void $data = $this->createUserWithPermission([ 'time-entries:view:all', ]); - $timeEntries = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user)->createMany(3); + $timeEntries = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->createMany(3); $project = Project::factory()->forOrganization($data->organization)->create(); - $timeEntries = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user)->forProject($project)->createMany(3); - $timeEntries = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user)->state([ + $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); Passport::actingAs($data->user); @@ -416,8 +422,8 @@ public function test_aggregate_endpoint_groups_by_two_groups(): void // Act $response = $this->getJson(route('api.v1.time-entries.aggregate', [ $data->organization->getKey(), - 'group_1' => 'day', - 'group_2' => 'project', + 'group' => 'day', + 'sub_group' => 'project', ])); // Assert @@ -430,10 +436,10 @@ public function test_aggregate_endpoint_groups_by_one_group(): void $data = $this->createUserWithPermission([ 'time-entries:view:all', ]); - $timeEntries = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user)->createMany(3); + $timeEntries = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->createMany(3); $project = Project::factory()->forOrganization($data->organization)->create(); - $timeEntries = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user)->forProject($project)->createMany(3); - $timeEntries = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user)->state([ + $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); Passport::actingAs($data->user); @@ -441,7 +447,7 @@ public function test_aggregate_endpoint_groups_by_one_group(): void // Act $response = $this->getJson(route('api.v1.time-entries.aggregate', [ $data->organization->getKey(), - 'group_1' => 'week', + 'group' => 'week', ])); // Assert @@ -454,10 +460,10 @@ public function test_aggregate_endpoint_with_no_group(): void $data = $this->createUserWithPermission([ 'time-entries:view:all', ]); - $timeEntries = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user)->createMany(3); + $timeEntries = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->createMany(3); $project = Project::factory()->forOrganization($data->organization)->create(); - $timeEntries = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user)->forProject($project)->createMany(3); - $timeEntries = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user)->state([ + $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); Passport::actingAs($data->user); @@ -486,7 +492,7 @@ public function test_store_endpoint_fails_if_user_has_no_permission_to_create_ti 'start' => $timeEntryFake->start->toIso8601ZuluString(), 'end' => $timeEntryFake->end->toIso8601ZuluString(), 'tags' => $timeEntryFake->tags, - 'user_id' => $data->user->getKey(), + 'member_id' => $data->member->getKey(), 'task_id' => $timeEntryFake->task_id, ]); @@ -500,7 +506,7 @@ public function test_store_endpoint_fails_if_user_already_has_active_time_entry_ $data = $this->createUserWithPermission([ 'time-entries:create:own', ]); - $activeTimeEntry = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user)->active()->create(); + $activeTimeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->active()->create(); $timeEntryFake = TimeEntry::factory()->forOrganization($data->organization)->withTask($data->organization)->withTags($data->organization)->make(); Passport::actingAs($data->user); @@ -511,7 +517,7 @@ public function test_store_endpoint_fails_if_user_already_has_active_time_entry_ 'start' => $timeEntryFake->start->toIso8601ZuluString(), 'end' => null, 'tags' => $timeEntryFake->tags, - 'user_id' => $data->user->getKey(), + 'member_id' => $data->member->getKey(), 'project_id' => $timeEntryFake->project_id, 'task_id' => $timeEntryFake->task_id, ]); @@ -538,7 +544,7 @@ public function test_store_endpoint_validation_fails_if_task_id_does_not_belong_ 'start' => $timeEntryFake->start->toIso8601ZuluString(), 'end' => $timeEntryFake->end->toIso8601ZuluString(), 'tags' => $timeEntryFake->tags, - 'user_id' => $data->user->getKey(), + 'member_id' => $data->member->getKey(), 'project_id' => $timeEntryFake->project_id, 'task_id' => $timeEntryFake2->task_id, ]); @@ -567,7 +573,7 @@ public function test_store_endpoint_validation_fails_if_project_id_is_missing_bu 'start' => $timeEntryFake->start->toIso8601ZuluString(), 'end' => $timeEntryFake->end->toIso8601ZuluString(), 'tags' => $timeEntryFake->tags, - 'user_id' => $data->user->getKey(), + 'member_id' => $data->member->getKey(), 'task_id' => $timeEntryFake2->task_id, ]); @@ -595,7 +601,7 @@ public function test_store_endpoint_creates_new_time_entry_for_current_user(): v 'start' => $timeEntryFake->start->toIso8601ZuluString(), 'end' => $timeEntryFake->end->toIso8601ZuluString(), 'tags' => $timeEntryFake->tags, - 'user_id' => $data->user->getKey(), + 'member_id' => $data->member->getKey(), 'project_id' => $timeEntryFake->project_id, 'task_id' => $timeEntryFake->task_id, ]); @@ -604,7 +610,7 @@ public function test_store_endpoint_creates_new_time_entry_for_current_user(): v $response->assertStatus(201); $this->assertDatabaseHas(TimeEntry::class, [ 'id' => $response->json('data.id'), - 'user_id' => $data->user->getKey(), + 'member_id' => $data->member->getKey(), 'task_id' => $timeEntryFake->task_id, ]); } @@ -622,14 +628,14 @@ public function test_store_endpoint_creates_new_time_entry_with_minimal_fields() $response = $this->postJson(route('api.v1.time-entries.store', [$data->organization->getKey()]), [ 'billable' => $timeEntryFake->billable, 'start' => $timeEntryFake->start->toIso8601ZuluString(), - 'user_id' => $data->user->getKey(), + 'member_id' => $data->member->getKey(), ]); // Assert $response->assertStatus(201); $this->assertDatabaseHas(TimeEntry::class, [ 'id' => $response->json('data.id'), - 'user_id' => $data->user->getKey(), + 'member_id' => $data->member->getKey(), 'task_id' => null, ]); } @@ -641,9 +647,7 @@ public function test_store_endpoint_fails_if_user_has_no_permission_to_create_ti 'time-entries:create:own', ]); $otherUser = User::factory()->create(); - $data->organization->users()->attach($otherUser, [ - 'role' => 'employee', - ]); + $otherMember = Member::factory()->forOrganization($data->organization)->forUser($otherUser)->role(Role::Employee)->create(); $timeEntryFake = TimeEntry::factory()->forOrganization($data->organization)->make(); Passport::actingAs($data->user); @@ -654,7 +658,7 @@ public function test_store_endpoint_fails_if_user_has_no_permission_to_create_ti 'start' => $timeEntryFake->start->toIso8601ZuluString(), 'end' => $timeEntryFake->end->toIso8601ZuluString(), 'tags' => $timeEntryFake->tags, - 'user_id' => $otherUser->getKey(), + 'member_id' => $otherMember->getKey(), 'project_id' => $timeEntryFake->project_id, 'task_id' => $timeEntryFake->task_id, ]); @@ -670,9 +674,7 @@ public function test_store_endpoint_creates_new_time_entry_for_other_user_in_org 'time-entries:create:all', ]); $otherUser = User::factory()->create(); - $data->organization->users()->attach($otherUser, [ - 'role' => 'employee', - ]); + $otherMember = Member::factory()->forOrganization($data->organization)->forUser($otherUser)->role(Role::Employee)->create(); $timeEntryFake = TimeEntry::factory()->forOrganization($data->organization)->make(); Passport::actingAs($data->user); @@ -683,7 +685,7 @@ public function test_store_endpoint_creates_new_time_entry_for_other_user_in_org 'start' => $timeEntryFake->start->toIso8601ZuluString(), 'end' => $timeEntryFake->end->toIso8601ZuluString(), 'tags' => $timeEntryFake->tags, - 'user_id' => $otherUser->getKey(), + 'member_id' => $otherMember->getKey(), 'task_id' => $timeEntryFake->task_id, ]); @@ -692,6 +694,7 @@ public function test_store_endpoint_creates_new_time_entry_for_other_user_in_org $this->assertDatabaseHas(TimeEntry::class, [ 'id' => $response->json('data.id'), 'user_id' => $otherUser->getKey(), + 'member_id' => $otherMember->getKey(), 'task_id' => $timeEntryFake->task_id, ]); } @@ -701,7 +704,7 @@ public function test_update_endpoint_fails_if_user_has_no_permission_to_update_o // Arrange $data = $this->createUserWithPermission([ ]); - $timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user)->create(); + $timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->create(); $timeEntryFake = TimeEntry::factory()->forOrganization($data->organization)->make(); Passport::actingAs($data->user); @@ -712,7 +715,7 @@ public function test_update_endpoint_fails_if_user_has_no_permission_to_update_o 'start' => $timeEntryFake->start->toIso8601ZuluString(), 'end' => $timeEntryFake->end->toIso8601ZuluString(), 'tags' => $timeEntryFake->tags, - 'user_id' => $data->user->getKey(), + 'member_id' => $data->member->getKey(), 'task_id' => $timeEntryFake->task_id, ]); @@ -729,7 +732,7 @@ public function test_update_endpoint_fails_if_user_is_not_part_of_time_entry_org $otherUser = $this->createUserWithPermission([ 'time-entries:update:own', ]); - $timeEntry = TimeEntry::factory()->forOrganization($otherUser->organization)->forUser($otherUser->user)->create(); + $timeEntry = TimeEntry::factory()->forOrganization($otherUser->organization)->forMember($otherUser->member)->create(); $timeEntryFake = TimeEntry::factory()->forOrganization($data->organization)->make(); Passport::actingAs($data->user); @@ -739,7 +742,6 @@ public function test_update_endpoint_fails_if_user_is_not_part_of_time_entry_org 'start' => $timeEntryFake->start->toIso8601ZuluString(), 'end' => $timeEntryFake->end->toIso8601ZuluString(), 'tags' => $timeEntryFake->tags, - 'user_id' => $data->user->getKey(), 'task_id' => $timeEntryFake->task_id, ]); @@ -754,10 +756,8 @@ public function test_update_endpoint_fails_if_user_has_no_permission_to_update_t 'time-entries:update:own', ]); $user = User::factory()->create(); - $data->organization->users()->attach($user, [ - 'role' => 'employee', - ]); - $timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forUser($user)->create(); + $member = Member::factory()->forOrganization($data->organization)->forUser($user)->role(Role::Employee)->create(); + $timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($member)->create(); $timeEntryFake = TimeEntry::factory()->forOrganization($data->organization)->make(); Passport::actingAs($data->user); @@ -767,7 +767,6 @@ public function test_update_endpoint_fails_if_user_has_no_permission_to_update_t 'start' => $timeEntryFake->start->toIso8601ZuluString(), 'end' => $timeEntryFake->end->toIso8601ZuluString(), 'tags' => $timeEntryFake->tags, - 'user_id' => $user->getKey(), 'task_id' => $timeEntryFake->task_id, ]); @@ -781,7 +780,7 @@ public function test_update_endpoint_validation_fails_if_task_id_does_not_belong $data = $this->createUserWithPermission([ 'time-entries:update:own', ]); - $timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user)->create(); + $timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->create(); $timeEntryFake = TimeEntry::factory()->forOrganization($data->organization)->withTask($data->organization)->make(); $timeEntryFake2 = TimeEntry::factory()->forOrganization($data->organization)->withTask($data->organization)->make(); Passport::actingAs($data->user); @@ -793,7 +792,6 @@ public function test_update_endpoint_validation_fails_if_task_id_does_not_belong 'start' => $timeEntryFake->start->toIso8601ZuluString(), 'end' => $timeEntryFake->end->toIso8601ZuluString(), 'tags' => $timeEntryFake->tags, - 'user_id' => $data->user->getKey(), 'project_id' => $timeEntryFake->project_id, 'task_id' => $timeEntryFake2->task_id, ]); @@ -811,7 +809,7 @@ public function test_update_endpoint_validation_fails_if_project_id_is_missing_b $data = $this->createUserWithPermission([ 'time-entries:update:own', ]); - $timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user)->create(); + $timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->create(); $timeEntryFake = TimeEntry::factory()->forOrganization($data->organization)->withTask($data->organization)->make(); $timeEntryFake2 = TimeEntry::factory()->forOrganization($data->organization)->withTask($data->organization)->make(); Passport::actingAs($data->user); @@ -823,7 +821,6 @@ public function test_update_endpoint_validation_fails_if_project_id_is_missing_b 'start' => $timeEntryFake->start->toIso8601ZuluString(), 'end' => $timeEntryFake->end->toIso8601ZuluString(), 'tags' => $timeEntryFake->tags, - 'user_id' => $data->user->getKey(), 'task_id' => $timeEntryFake2->task_id, ]); @@ -841,7 +838,7 @@ public function test_update_endpoint_updates_time_entry_for_current_user(): void $data = $this->createUserWithPermission([ 'time-entries:update:own', ]); - $timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user)->create(); + $timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->create(); $timeEntryFake = TimeEntry::factory()->withTags($data->organization)->forOrganization($data->organization)->make(); Passport::actingAs($data->user); @@ -851,14 +848,14 @@ public function test_update_endpoint_updates_time_entry_for_current_user(): void 'start' => $timeEntryFake->start->toIso8601ZuluString(), 'end' => $timeEntryFake->end->toIso8601ZuluString(), 'tags' => $timeEntryFake->tags, - 'user_id' => $data->user->getKey(), + 'member_id' => $data->member->getKey(), ]); // Assert $response->assertStatus(200); $this->assertDatabaseHas(TimeEntry::class, [ 'id' => $timeEntry->getKey(), - 'user_id' => $data->user->getKey(), + 'member_id' => $data->member->getKey(), 'task_id' => $timeEntryFake->task_id, ]); } @@ -870,10 +867,8 @@ public function test_update_endpoint_updates_time_entry_of_other_user_in_organiz 'time-entries:update:all', ]); $user = User::factory()->create(); - $data->organization->users()->attach($user, [ - 'role' => 'employee', - ]); - $timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forUser($user)->create(); + $member = Member::factory()->forOrganization($data->organization)->forUser($user)->role(Role::Employee)->create(); + $timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($member)->create(); $timeEntryFake = TimeEntry::factory()->forOrganization($data->organization)->make(); Passport::actingAs($data->user); @@ -883,7 +878,7 @@ public function test_update_endpoint_updates_time_entry_of_other_user_in_organiz 'start' => $timeEntryFake->start->toIso8601ZuluString(), 'end' => $timeEntryFake->end->toIso8601ZuluString(), 'tags' => $timeEntryFake->tags, - 'user_id' => $user->getKey(), + 'member_id' => $member->getKey(), 'task_id' => $timeEntryFake->task_id, ]); @@ -891,7 +886,7 @@ public function test_update_endpoint_updates_time_entry_of_other_user_in_organiz $response->assertStatus(200); $this->assertDatabaseHas(TimeEntry::class, [ 'id' => $timeEntry->getKey(), - 'user_id' => $user->getKey(), + 'member_id' => $member->getKey(), 'task_id' => $timeEntryFake->task_id, ]); } @@ -905,7 +900,7 @@ public function test_destroy_endpoint_fails_if_user_tries_to_delete_time_entry_i $otherUser = $this->createUserWithPermission([ 'time-entries:delete:all', ]); - $timeEntry = TimeEntry::factory()->forOrganization($otherUser->organization)->forUser($otherUser->user)->create(); + $timeEntry = TimeEntry::factory()->forOrganization($otherUser->organization)->forMember($otherUser->member)->create(); Passport::actingAs($data->user); // Act @@ -935,7 +930,7 @@ public function test_destroy_endpoint_fails_if_user_has_no_permission_to_delete_ // Arrange $data = $this->createUserWithPermission([ ]); - $timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user)->create(); + $timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->create(); Passport::actingAs($data->user); // Act @@ -952,10 +947,8 @@ public function test_destroy_endpoint_fails_if_user_has_no_permission_to_delete_ 'time-entries:delete:own', ]); $user = User::factory()->create(); - $data->organization->users()->attach($user, [ - 'role' => 'employee', - ]); - $timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forUser($user)->create(); + $member = Member::factory()->forOrganization($data->organization)->forUser($user)->role(Role::Employee)->create(); + $timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($member)->create(); Passport::actingAs($data->user); // Act @@ -971,7 +964,7 @@ public function test_destroy_endpoint_deletes_own_time_entry(): void $data = $this->createUserWithPermission([ 'time-entries:delete:own', ]); - $timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forUser($data->user)->create(); + $timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->create(); Passport::actingAs($data->user); // Act @@ -992,10 +985,8 @@ public function test_destroy_endpoint_deletes_time_entry_of_other_user_in_organi 'time-entries:delete:all', ]); $user = User::factory()->create(); - $data->organization->users()->attach($user, [ - 'role' => 'employee', - ]); - $timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forUser($user)->create(); + $member = Member::factory()->forOrganization($data->organization)->forUser($user)->role(Role::Employee)->create(); + $timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($member)->create(); Passport::actingAs($data->user); // Act diff --git a/tests/Unit/Endpoint/Api/V1/UserTimeEntryEndpointTest.php b/tests/Unit/Endpoint/Api/V1/UserTimeEntryEndpointTest.php index a2b245b4..72e5c9c7 100644 --- a/tests/Unit/Endpoint/Api/V1/UserTimeEntryEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/UserTimeEntryEndpointTest.php @@ -27,8 +27,8 @@ public function test_my_active_endpoint_returns_current_time_entry_of_logged_in_ // Arrange $data = $this->createUserWithPermission([ ]); - $activeTimeEntry = TimeEntry::factory()->forUser($data->user)->active()->create(); - $inactiveTimeEntry = TimeEntry::factory()->forUser($data->user)->create(); + $activeTimeEntry = TimeEntry::factory()->forMember($data->member)->active()->create(); + $inactiveTimeEntry = TimeEntry::factory()->forMember($data->member)->create(); Passport::actingAs($data->user); // Act @@ -43,7 +43,7 @@ public function test_my_active_endpoint_returns_not_found_if_user_has_no_active_ // Arrange $data = $this->createUserWithPermission([ ]); - $inactiveTimeEntry = TimeEntry::factory()->forUser($data->user)->create(); + $inactiveTimeEntry = TimeEntry::factory()->forMember($data->member)->create(); Passport::actingAs($data->user); // Act diff --git a/tests/Unit/Model/ProjectMemberModelTest.php b/tests/Unit/Model/ProjectMemberModelTest.php index 55f573da..a8effad7 100644 --- a/tests/Unit/Model/ProjectMemberModelTest.php +++ b/tests/Unit/Model/ProjectMemberModelTest.php @@ -4,10 +4,10 @@ namespace Tests\Unit\Model; +use App\Models\Member; use App\Models\Organization; use App\Models\Project; use App\Models\ProjectMember; -use App\Models\User; class ProjectMemberModelTest extends ModelTestAbstract { @@ -15,8 +15,8 @@ public function test_it_belongs_to_a_project(): void { // Arrange $project = Project::factory()->create(); - $user = User::factory()->create(); - $projectMember = ProjectMember::factory()->forProject($project)->forUser($user)->create(); + $member = Member::factory()->create(); + $projectMember = ProjectMember::factory()->forProject($project)->forMember($member)->create(); // Act $projectMember->refresh(); @@ -27,19 +27,19 @@ public function test_it_belongs_to_a_project(): void $this->assertTrue($projectRel->is($project)); } - public function test_it_belongs_to_a_user(): void + public function test_it_belongs_to_a_member(): void { // Arrange - $user = User::factory()->create(); - $projectMember = ProjectMember::factory()->forUser($user)->create(); + $member = Member::factory()->create(); + $projectMember = ProjectMember::factory()->forMember($member)->create(); // Act $projectMember->refresh(); - $userRel = $projectMember->user; + $memberRel = $projectMember->member; // Assert - $this->assertNotNull($userRel); - $this->assertTrue($userRel->is($user)); + $this->assertNotNull($memberRel); + $this->assertTrue($memberRel->is($member)); } public function test_scope_where_belongs_to_organization_filters_project_members_to_only_retrieve_project_members_that_belong_to_a_project_of_the_organization(): void diff --git a/tests/Unit/Model/ProjectModelTest.php b/tests/Unit/Model/ProjectModelTest.php index 287adb68..b5c527c6 100644 --- a/tests/Unit/Model/ProjectModelTest.php +++ b/tests/Unit/Model/ProjectModelTest.php @@ -5,11 +5,11 @@ namespace Tests\Unit\Model; use App\Models\Client; +use App\Models\Member; use App\Models\Organization; use App\Models\Project; use App\Models\ProjectMember; use App\Models\Task; -use App\Models\User; class ProjectModelTest extends ModelTestAbstract { @@ -91,14 +91,14 @@ public function test_it_has_many_members(): void public function test_scope_visible_by_user_filters_so_that_only_public_projects_or_projects_where_the_user_is_member_are_shown(): void { // Arrange - $user = User::factory()->create(); + $member = Member::factory()->create(); $projectPrivate = Project::factory()->isPrivate()->create(); $projectPublic = Project::factory()->isPublic()->create(); $projectPrivateButMember = Project::factory()->isPrivate()->create(); - ProjectMember::factory()->forProject($projectPrivateButMember)->forUser($user)->create(); + ProjectMember::factory()->forProject($projectPrivateButMember)->forMember($member)->create(); // Act - $projectsVisible = Project::query()->visibleByUser($user)->get(); + $projectsVisible = Project::query()->visibleByUser($member->user)->get(); $allProjects = Project::query()->get(); // Assert diff --git a/tests/Unit/Model/TaskModelTest.php b/tests/Unit/Model/TaskModelTest.php index 6058e877..9bc48cea 100644 --- a/tests/Unit/Model/TaskModelTest.php +++ b/tests/Unit/Model/TaskModelTest.php @@ -4,12 +4,12 @@ namespace Tests\Unit\Model; +use App\Models\Member; use App\Models\Organization; use App\Models\Project; use App\Models\ProjectMember; use App\Models\Task; use App\Models\TimeEntry; -use App\Models\User; class TaskModelTest extends ModelTestAbstract { @@ -62,17 +62,17 @@ public function test_it_has_many_time_entries(): void public function test_scope_visible_by_user_filters_so_that_only_tasks_of_public_projects_or_projects_where_the_user_is_member_are_shown(): void { // Arrange - $user = User::factory()->create(); + $member = Member::factory()->create(); $projectPrivate = Project::factory()->isPrivate()->create(); $projectPublic = Project::factory()->isPublic()->create(); $projectPrivateButMember = Project::factory()->isPrivate()->create(); - ProjectMember::factory()->forProject($projectPrivateButMember)->forUser($user)->create(); + ProjectMember::factory()->forProject($projectPrivateButMember)->forMember($member)->create(); $taskPrivate = Task::factory()->forProject($projectPrivate)->create(); $taskPublic = Task::factory()->forProject($projectPublic)->create(); $taskPrivateButMember = Task::factory()->forProject($projectPrivateButMember)->create(); // Act - $tasksVisible = Task::query()->visibleByUser($user)->get(); + $tasksVisible = Task::query()->visibleByUser($member->user)->get(); $allTasks = Task::query()->get(); // Assert diff --git a/tests/Unit/Model/UserModelTest.php b/tests/Unit/Model/UserModelTest.php index 7e9d4b82..9444154d 100644 --- a/tests/Unit/Model/UserModelTest.php +++ b/tests/Unit/Model/UserModelTest.php @@ -4,6 +4,8 @@ namespace Tests\Unit\Model; +use App\Enums\Role; +use App\Models\Member; use App\Models\Organization; use App\Models\ProjectMember; use App\Models\TimeEntry; @@ -57,12 +59,12 @@ public function test_scope_belongs_to_organization_returns_only_users_of_organiz $organization = Organization::factory()->withOwner($owner)->create(); $user = User::factory()->create(); $user->organizations()->attach($organization, [ - 'role' => 'employee', + 'role' => Role::Employee->value, ]); $otherOrganization = Organization::factory()->create(); $otherUser = User::factory()->create(); $otherUser->organizations()->attach($otherOrganization, [ - 'role' => 'employee', + 'role' => Role::Employee->value, ]); // Act @@ -98,8 +100,10 @@ public function test_it_has_many_project_members(): void // Arrange $user = User::factory()->create(); $otherUser = User::factory()->create(); - $projectMembers = ProjectMember::factory()->forUser($user)->createMany(3); - $otherProjectMembers = ProjectMember::factory()->forUser($otherUser)->createMany(3); + $member = Member::factory()->forUser($user)->create(); + $otherMember = Member::factory()->forUser($otherUser)->create(); + $projectMembers = ProjectMember::factory()->forMember($member)->createMany(3); + $otherProjectMembers = ProjectMember::factory()->forMember($otherMember)->createMany(3); // Act $user->refresh(); diff --git a/tests/Unit/Service/BillableRateServiceTest.php b/tests/Unit/Service/BillableRateServiceTest.php index 24679587..b8169369 100644 --- a/tests/Unit/Service/BillableRateServiceTest.php +++ b/tests/Unit/Service/BillableRateServiceTest.php @@ -4,6 +4,7 @@ namespace Tests\Unit\Service; +use App\Models\Member; use App\Models\Organization; use App\Models\Project; use App\Models\ProjectMember; @@ -31,16 +32,17 @@ public function test_billable_rate_is_null_if_time_entry_is_not_billable(): void $organization = Organization::factory()->create([ 'billable_rate' => 1001, ]); - $user = User::factory()->attachToOrganization($organization, [ + $user = User::factory()->create(); + $member = Member::factory()->forOrganization($organization)->forUser($user)->create([ 'billable_rate' => 2002, - ])->create(); + ]); $project = Project::factory()->forOrganization($organization)->create([ 'billable_rate' => 3003, ]); - $projectMember = ProjectMember::factory()->forUser($user)->forProject($project)->create([ + $projectMember = ProjectMember::factory()->forMember($member)->forProject($project)->create([ 'billable_rate' => 4004, ]); - $timeEntry = TimeEntry::factory()->forProject($project)->forUser($user)->forOrganization($organization)->create([ + $timeEntry = TimeEntry::factory()->forProject($project)->forMember($member)->forOrganization($organization)->create([ 'billable' => false, ]); @@ -57,16 +59,17 @@ public function test_billable_rate_uses_project_member_rate_as_first_priority(): $organization = Organization::factory()->create([ 'billable_rate' => 1001, ]); - $user = User::factory()->attachToOrganization($organization, [ + $user = User::factory()->create(); + $member = Member::factory()->forOrganization($organization)->forUser($user)->create([ 'billable_rate' => 2002, - ])->create(); + ]); $project = Project::factory()->forOrganization($organization)->create([ 'billable_rate' => 3003, ]); - $projectMember = ProjectMember::factory()->forUser($user)->forProject($project)->create([ + $projectMember = ProjectMember::factory()->forMember($member)->forProject($project)->create([ 'billable_rate' => 4004, ]); - $timeEntry = TimeEntry::factory()->forProject($project)->forUser($user)->forOrganization($organization)->create([ + $timeEntry = TimeEntry::factory()->forProject($project)->forMember($member)->forOrganization($organization)->create([ 'billable' => true, ]); @@ -83,16 +86,17 @@ public function test_billable_rate_uses_project_rate_as_second_priority_using_nu $organization = Organization::factory()->create([ 'billable_rate' => 1001, ]); - $user = User::factory()->attachToOrganization($organization, [ + $user = User::factory()->create(); + $member = Member::factory()->forOrganization($organization)->forUser($user)->create([ 'billable_rate' => 2002, - ])->create(); + ]); $project = Project::factory()->forOrganization($organization)->create([ 'billable_rate' => 3003, ]); - $projectMember = ProjectMember::factory()->forUser($user)->forProject($project)->create([ + $projectMember = ProjectMember::factory()->forMember($member)->forProject($project)->create([ 'billable_rate' => null, ]); - $timeEntry = TimeEntry::factory()->forProject($project)->forUser($user)->forOrganization($organization)->create([ + $timeEntry = TimeEntry::factory()->forProject($project)->forMember($member)->forOrganization($organization)->create([ 'billable' => true, ]); @@ -109,13 +113,14 @@ public function test_billable_rate_uses_project_rate_as_second_priority_using_no $organization = Organization::factory()->create([ 'billable_rate' => 1001, ]); - $user = User::factory()->attachToOrganization($organization, [ + $user = User::factory()->create(); + $member = Member::factory()->forOrganization($organization)->forUser($user)->create([ 'billable_rate' => 2002, - ])->create(); + ]); $project = Project::factory()->forOrganization($organization)->create([ 'billable_rate' => 3003, ]); - $timeEntry = TimeEntry::factory()->forProject($project)->forUser($user)->forOrganization($organization)->create([ + $timeEntry = TimeEntry::factory()->forProject($project)->forMember($member)->forOrganization($organization)->create([ 'billable' => true, ]); @@ -132,16 +137,17 @@ public function test_billable_rate_uses_organization_member_rate_as_third_priori $organization = Organization::factory()->create([ 'billable_rate' => 1001, ]); - $user = User::factory()->attachToOrganization($organization, [ + $user = User::factory()->create(); + $member = Member::factory()->forOrganization($organization)->forUser($user)->create([ 'billable_rate' => 2002, - ])->create(); + ]); $project = Project::factory()->forOrganization($organization)->create([ 'billable_rate' => null, ]); - $projectMember = ProjectMember::factory()->forUser($user)->forProject($project)->create([ + $projectMember = ProjectMember::factory()->forMember($member)->forProject($project)->create([ 'billable_rate' => null, ]); - $timeEntry = TimeEntry::factory()->forProject($project)->forUser($user)->forOrganization($organization)->create([ + $timeEntry = TimeEntry::factory()->forProject($project)->forMember($member)->forOrganization($organization)->create([ 'billable' => true, ]); @@ -158,10 +164,11 @@ public function test_billable_rate_uses_organization_member_rate_as_third_priori $organization = Organization::factory()->create([ 'billable_rate' => 1001, ]); - $user = User::factory()->attachToOrganization($organization, [ + $user = User::factory()->create(); + $member = Member::factory()->forOrganization($organization)->forUser($user)->create([ 'billable_rate' => 2002, - ])->create(); - $timeEntry = TimeEntry::factory()->forUser($user)->forOrganization($organization)->create([ + ]); + $timeEntry = TimeEntry::factory()->forMember($member)->forOrganization($organization)->create([ 'billable' => true, ]); @@ -178,16 +185,17 @@ public function test_billable_rate_uses_organization_rate_as_fourth_priority_usi $organization = Organization::factory()->create([ 'billable_rate' => 1001, ]); - $user = User::factory()->attachToOrganization($organization, [ + $user = User::factory()->create(); + $member = Member::factory()->forOrganization($organization)->forUser($user)->create([ 'billable_rate' => null, - ])->create(); + ]); $project = Project::factory()->forOrganization($organization)->create([ 'billable_rate' => null, ]); - $projectMember = ProjectMember::factory()->forUser($user)->forProject($project)->create([ + $projectMember = ProjectMember::factory()->forMember($member)->forProject($project)->create([ 'billable_rate' => null, ]); - $timeEntry = TimeEntry::factory()->forProject($project)->forUser($user)->forOrganization($organization)->create([ + $timeEntry = TimeEntry::factory()->forProject($project)->forMember($member)->forOrganization($organization)->create([ 'billable' => true, ]); @@ -205,7 +213,10 @@ public function test_billable_rate_uses_organization_rate_as_fourth_priority_usi 'billable_rate' => 1001, ]); $user = User::factory()->create(); - $timeEntry = TimeEntry::factory()->forUser($user)->forOrganization($organization)->create([ + $member = Member::factory()->forOrganization($organization)->forUser($user)->create([ + 'billable_rate' => null, + ]); + $timeEntry = TimeEntry::factory()->forMember($member)->forOrganization($organization)->create([ 'billable' => true, ]); @@ -222,16 +233,17 @@ public function test_billable_rate_is_null_if_billable_rate_on_all_levels_are_nu $organization = Organization::factory()->create([ 'billable_rate' => null, ]); - $user = User::factory()->attachToOrganization($organization, [ + $user = User::factory()->create(); + $member = Member::factory()->forOrganization($organization)->forUser($user)->create([ 'billable_rate' => null, - ])->create(); + ]); $project = Project::factory()->forOrganization($organization)->create([ 'billable_rate' => null, ]); - $projectMember = ProjectMember::factory()->forUser($user)->forProject($project)->create([ + $projectMember = ProjectMember::factory()->forMember($member)->forProject($project)->create([ 'billable_rate' => null, ]); - $timeEntry = TimeEntry::factory()->forProject($project)->forUser($user)->forOrganization($organization)->create([ + $timeEntry = TimeEntry::factory()->forProject($project)->forMember($member)->forOrganization($organization)->create([ 'billable' => true, ]); diff --git a/tests/Unit/Service/DashboardServiceTest.php b/tests/Unit/Service/DashboardServiceTest.php index 816ca190..502ae6c4 100644 --- a/tests/Unit/Service/DashboardServiceTest.php +++ b/tests/Unit/Service/DashboardServiceTest.php @@ -6,6 +6,7 @@ use App\Enums\Role; use App\Enums\Weekday; +use App\Models\Member; use App\Models\Organization; use App\Models\Project; use App\Models\Task; @@ -37,13 +38,13 @@ public function test_daily_tracked_hours_returns_correct_values(): void $user = User::factory()->create([ 'timezone' => 'Europe/Vienna', ]); - $user->organizations()->attach($organization); - $timeEntry1 = TimeEntry::factory()->forUser($user)->forOrganization($organization)->create([ + $member = Member::factory()->forUser($user)->forOrganization($organization)->create(); + $timeEntry1 = TimeEntry::factory()->forMember($member)->forOrganization($organization)->create([ // Note: The start time shifts in timezone Europe/Vienna to the next day 'start' => Carbon::create(2023, 12, 30, 23, 0, 0, 'UTC'), 'end' => Carbon::create(2023, 12, 30, 23, 0, 40, 'UTC'), ]); - $timeEntry2 = TimeEntry::factory()->forUser($user)->forOrganization($organization)->create([ + $timeEntry2 = TimeEntry::factory()->forMember($member)->forOrganization($organization)->create([ // Note: The start time NOT shifts in timezone Europe/Vienna to the next day 'start' => Carbon::create(2023, 12, 30, 22, 59, 59, 'UTC'), 'end' => Carbon::create(2023, 12, 30, 23, 0, 39, 'UTC'), @@ -87,15 +88,15 @@ public function test_weekly_history_returns_correct_values(): void 'timezone' => 'Europe/Vienna', 'week_start' => Weekday::Sunday, ]); - $user->organizations()->attach($organization); + $member = Member::factory()->forUser($user)->forOrganization($organization)->create(); // Note: This is a Sunday - $timeEntry1 = TimeEntry::factory()->forUser($user)->forOrganization($organization)->create([ + $timeEntry1 = TimeEntry::factory()->forMember($member)->forOrganization($organization)->create([ // Note: The start time shifts in timezone Europe/Vienna to the next day 'start' => Carbon::create(2023, 12, 30, 23, 0, 0, 'UTC'), 'end' => Carbon::create(2023, 12, 30, 23, 0, 40, 'UTC'), ]); // Note: This is a Saturday - $timeEntry2 = TimeEntry::factory()->forUser($user)->forOrganization($organization)->create([ + $timeEntry2 = TimeEntry::factory()->forMember($member)->forOrganization($organization)->create([ // Note: The start time NOT shifts in timezone Europe/Vienna to the next day 'start' => Carbon::create(2023, 12, 30, 22, 59, 59, 'UTC'), 'end' => Carbon::create(2023, 12, 30, 23, 0, 39, 'UTC'), @@ -147,15 +148,15 @@ public function test_total_weekly_time_returns_correct_value(): void 'timezone' => 'Europe/Vienna', 'week_start' => Weekday::Sunday, ]); - $user->organizations()->attach($organization); + $member = Member::factory()->forUser($user)->forOrganization($organization)->create(); // Note: This is a Sunday - $timeEntry1 = TimeEntry::factory()->forUser($user)->forOrganization($organization)->create([ + $timeEntry1 = TimeEntry::factory()->forMember($member)->forOrganization($organization)->create([ // Note: The start time shifts in timezone Europe/Vienna to the next day 'start' => Carbon::create(2023, 12, 30, 23, 0, 0, 'UTC'), 'end' => Carbon::create(2023, 12, 30, 23, 0, 40, 'UTC'), ]); // Note: This is a Saturday - $timeEntry2 = TimeEntry::factory()->forUser($user)->forOrganization($organization)->create([ + $timeEntry2 = TimeEntry::factory()->forMember($member)->forOrganization($organization)->create([ // Note: The start time NOT shifts in timezone Europe/Vienna to the next day 'start' => Carbon::create(2023, 12, 30, 22, 59, 59, 'UTC'), 'end' => Carbon::create(2023, 12, 30, 23, 0, 39, 'UTC'), @@ -178,23 +179,23 @@ public function test_total_weekly_billable_time_returns_correct_value(): void 'timezone' => 'Europe/Vienna', 'week_start' => Weekday::Sunday, ]); - $user->organizations()->attach($organization); + $member = Member::factory()->forUser($user)->forOrganization($organization)->create(); // Note: This is a Sunday - $timeEntry1 = TimeEntry::factory()->forUser($user)->forOrganization($organization)->create([ + $timeEntry1 = TimeEntry::factory()->forMember($member)->forOrganization($organization)->create([ // Note: The start time shifts in timezone Europe/Vienna to the next day 'billable' => true, 'start' => Carbon::create(2023, 12, 30, 23, 0, 0, 'UTC'), 'end' => Carbon::create(2023, 12, 30, 23, 0, 40, 'UTC'), ]); // Note: This is a Sunday (non-billable) - $timeEntry1 = TimeEntry::factory()->forUser($user)->forOrganization($organization)->create([ + $timeEntry1 = TimeEntry::factory()->forMember($member)->forOrganization($organization)->create([ // Note: The start time shifts in timezone Europe/Vienna to the next day 'billable' => false, 'start' => Carbon::create(2023, 12, 30, 23, 0, 40, 'UTC'), 'end' => Carbon::create(2023, 12, 30, 23, 0, 59, 'UTC'), ]); // Note: This is a Saturday - $timeEntry2 = TimeEntry::factory()->forUser($user)->forOrganization($organization)->create([ + $timeEntry2 = TimeEntry::factory()->forMember($member)->forOrganization($organization)->create([ // Note: The start time NOT shifts in timezone Europe/Vienna to the next day 'billable' => true, 'start' => Carbon::create(2023, 12, 30, 22, 59, 59, 'UTC'), @@ -221,9 +222,9 @@ public function test_total_weekly_billable_amount_returns_correct_value(): void 'timezone' => 'Europe/Vienna', 'week_start' => Weekday::Sunday, ]); - $user->organizations()->attach($organization); + $member = Member::factory()->forUser($user)->forOrganization($organization)->create(); // Note: This is a Sunday - $timeEntry1 = TimeEntry::factory()->forUser($user)->forOrganization($organization)->create([ + $timeEntry1 = TimeEntry::factory()->forMember($member)->forOrganization($organization)->create([ // Note: The start time shifts in timezone Europe/Vienna to the next day 'billable' => true, 'billable_rate' => 50 * 100, @@ -231,14 +232,14 @@ public function test_total_weekly_billable_amount_returns_correct_value(): void 'end' => Carbon::create(2023, 12, 31, 0, 0, 0, 'UTC'), ]); // Note: This is a Sunday (non-billable) - $timeEntry2 = TimeEntry::factory()->forUser($user)->forOrganization($organization)->create([ + $timeEntry2 = TimeEntry::factory()->forMember($member)->forOrganization($organization)->create([ // Note: The start time shifts in timezone Europe/Vienna to the next day 'billable' => false, 'start' => Carbon::create(2023, 12, 30, 23, 0, 40, 'UTC'), 'end' => Carbon::create(2023, 12, 30, 23, 0, 59, 'UTC'), ]); // Note: This is a Saturday - $timeEntry3 = TimeEntry::factory()->forUser($user)->forOrganization($organization)->create([ + $timeEntry3 = TimeEntry::factory()->forMember($member)->forOrganization($organization)->create([ // Note: The start time NOT shifts in timezone Europe/Vienna to the next day 'billable' => true, 'billable_rate' => 100 * 100, @@ -267,42 +268,40 @@ public function test_weekly_project_overview_returns_correct_value_if_time_entri 'week_start' => Weekday::Sunday, ]); $organization = Organization::factory()->withOwner($user)->create(); - $organization->users()->attach($user, [ - 'role' => Role::Owner->value, - ]); + $member = Member::factory()->forUser($user)->forOrganization($organization)->role(Role::Owner)->create(); $project1 = Project::factory()->forOrganization($organization)->create(); $project2 = Project::factory()->forOrganization($organization)->create(); - $timeEntry1Project1 = TimeEntry::factory()->forUser($user)->forOrganization($organization)->forProject($project1)->create([ + $timeEntry1Project1 = TimeEntry::factory()->forMember($member)->forOrganization($organization)->forProject($project1)->create([ // Note: At the start of the week 'start' => $now->startOfWeek(Weekday::Sunday->carbonWeekDay())->utc(), 'end' => $now->startOfWeek(Weekday::Sunday->carbonWeekDay())->addSeconds(40)->utc(), ]); - $timeEntry2Project1 = TimeEntry::factory()->forUser($user)->forOrganization($organization)->forProject($project1)->create([ + $timeEntry2Project1 = TimeEntry::factory()->forMember($member)->forOrganization($organization)->forProject($project1)->create([ // Note: At the end of the week 'start' => $now->endOfWeek(Weekday::Sunday->carbonWeekDay())->utc(), 'end' => $now->endOfWeek(Weekday::Sunday->carbonWeekDay())->addSeconds(40)->utc(), ]); - $timeEntry1Project2 = TimeEntry::factory()->forUser($user)->forOrganization($organization)->forProject($project2)->create([ + $timeEntry1Project2 = TimeEntry::factory()->forMember($member)->forOrganization($organization)->forProject($project2)->create([ // Note: At the start of the week 'start' => $now->startOfWeek(Weekday::Sunday->carbonWeekDay())->utc(), 'end' => $now->startOfWeek(Weekday::Sunday->carbonWeekDay())->addSeconds(40)->utc(), ]); - $timeEntry2Project2 = TimeEntry::factory()->forUser($user)->forOrganization($organization)->forProject($project2)->create([ + $timeEntry2Project2 = TimeEntry::factory()->forMember($member)->forOrganization($organization)->forProject($project2)->create([ // Note: At the end of the week 'start' => $now->endOfWeek(Weekday::Sunday->carbonWeekDay())->utc(), 'end' => $now->endOfWeek(Weekday::Sunday->carbonWeekDay())->addSeconds(40)->utc(), ]); - $timeEntry1WithoutProject = TimeEntry::factory()->forUser($user)->forOrganization($organization)->create([ + $timeEntry1WithoutProject = TimeEntry::factory()->forMember($member)->forOrganization($organization)->create([ // Note: At the start of the week 'start' => $now->startOfWeek(Weekday::Sunday->carbonWeekDay())->utc(), 'end' => $now->startOfWeek(Weekday::Sunday->carbonWeekDay())->addSeconds(40)->utc(), ]); - $timeEntry2WithoutProject = TimeEntry::factory()->forUser($user)->forOrganization($organization)->create([ + $timeEntry2WithoutProject = TimeEntry::factory()->forMember($member)->forOrganization($organization)->create([ // Note: At the end of the week 'start' => $now->endOfWeek(Weekday::Sunday->carbonWeekDay())->utc(), 'end' => $now->endOfWeek(Weekday::Sunday->carbonWeekDay())->addSeconds(40)->utc(), ]); - $timeEntry1WithoutProjectOutsideOfWeek = TimeEntry::factory()->forUser($user)->forOrganization($organization)->create([ + $timeEntry1WithoutProjectOutsideOfWeek = TimeEntry::factory()->forMember($member)->forOrganization($organization)->create([ // Note: Outside of week 'start' => $now->startOfWeek(Weekday::Sunday->carbonWeekDay())->subSecond()->utc(), 'end' => $now->startOfWeek(Weekday::Sunday->carbonWeekDay())->addSeconds(39)->utc(), @@ -312,7 +311,7 @@ public function test_weekly_project_overview_returns_correct_value_if_time_entri $result = $this->dashboardService->weeklyProjectOverview($user, $organization); // Assert - $this->assertSame([ + $this->assertEqualsCanonicalizing([ [ 'value' => 80, 'id' => $project1->getKey(), @@ -345,18 +344,18 @@ public function test_weekly_project_overview_returns_correct_value_if_only_entri 'timezone' => 'Europe/Vienna', 'week_start' => Weekday::Sunday, ]); - $user->organizations()->attach($organization); - $timeEntry1WithoutProject = TimeEntry::factory()->forUser($user)->forOrganization($organization)->create([ + $member = Member::factory()->forUser($user)->forOrganization($organization)->create(); + $timeEntry1WithoutProject = TimeEntry::factory()->forMember($member)->forOrganization($organization)->create([ // Note: At the start of the week 'start' => $now->startOfWeek(Weekday::Sunday->carbonWeekDay())->utc(), 'end' => $now->startOfWeek(Weekday::Sunday->carbonWeekDay())->addSeconds(40)->utc(), ]); - $timeEntry2WithoutProject = TimeEntry::factory()->forUser($user)->forOrganization($organization)->create([ + $timeEntry2WithoutProject = TimeEntry::factory()->forMember($member)->forOrganization($organization)->create([ // Note: At the end of the week 'start' => $now->endOfWeek(Weekday::Sunday->carbonWeekDay())->utc(), 'end' => $now->endOfWeek(Weekday::Sunday->carbonWeekDay())->addSeconds(40)->utc(), ]); - $timeEntry1WithoutProjectOutsideOfWeek = TimeEntry::factory()->forUser($user)->forOrganization($organization)->create([ + $timeEntry1WithoutProjectOutsideOfWeek = TimeEntry::factory()->forMember($member)->forOrganization($organization)->create([ // Note: Outside of week 'start' => $now->startOfWeek(Weekday::Sunday->carbonWeekDay())->subSecond()->utc(), 'end' => $now->startOfWeek(Weekday::Sunday->carbonWeekDay())->addSeconds(39)->utc(), @@ -386,7 +385,7 @@ public function test_weekly_project_overview_returns_correct_value_if_no_entries 'timezone' => 'Europe/Vienna', 'week_start' => Weekday::Sunday, ]); - $user->organizations()->attach($organization); + $member = Member::factory()->forUser($user)->forOrganization($organization)->create(); // Act $result = $this->dashboardService->weeklyProjectOverview($user, $organization); @@ -406,31 +405,31 @@ public function test_latest_team_activity_returns_the_most_current_working_users { // Arrange $organization = Organization::factory()->create(); - $user1 = User::factory()->create(); - $user2 = User::factory()->create(); - $user3 = User::factory()->create(); - $user4 = User::factory()->create(); - $user5 = User::factory()->create(); + $member1 = Member::factory()->forOrganization($organization)->create(); + $member2 = Member::factory()->forOrganization($organization)->create(); + $member3 = Member::factory()->forOrganization($organization)->create(); + $member4 = Member::factory()->forOrganization($organization)->create(); + $member5 = Member::factory()->forOrganization($organization)->create(); $task1 = Task::factory()->forOrganization($organization)->create(); - $timeEntry1 = TimeEntry::factory()->forUser($user1)->forOrganization($organization)->active()->create([ + $timeEntry1 = TimeEntry::factory()->forMember($member1)->forOrganization($organization)->active()->create([ 'start' => now()->subMinutes(10), ]); - $timeEntry2 = TimeEntry::factory()->forUser($user2)->forOrganization($organization)->create([ + $timeEntry2 = TimeEntry::factory()->forMember($member2)->forOrganization($organization)->create([ 'start' => now()->subMinutes(20), ]); - $timeEntry3 = TimeEntry::factory()->forUser($user3)->forOrganization($organization)->forTask($task1)->create([ + $timeEntry3 = TimeEntry::factory()->forMember($member3)->forOrganization($organization)->forTask($task1)->create([ 'description' => '', 'start' => now()->subMinutes(30), ]); - $timeEntry4 = TimeEntry::factory()->forUser($user4)->forOrganization($organization)->forTask($task1)->create([ + $timeEntry4 = TimeEntry::factory()->forMember($member4)->forOrganization($organization)->forTask($task1)->create([ 'description' => 'TEST 123', 'start' => now()->subMinutes(40), ]); - $timeEntry5 = TimeEntry::factory()->forUser($user4)->forOrganization($organization)->forTask($task1)->create([ + $timeEntry5 = TimeEntry::factory()->forMember($member4)->forOrganization($organization)->forTask($task1)->create([ 'description' => 'TEST 321', 'start' => now()->subMinutes(50), ]); - $timeEntry6 = TimeEntry::factory()->forUser($user5)->forOrganization($organization)->forTask($task1)->create([ + $timeEntry6 = TimeEntry::factory()->forMember($member5)->forOrganization($organization)->forTask($task1)->create([ 'description' => 'TEST 321', 'start' => now()->subMinutes(60), ]); @@ -441,32 +440,32 @@ public function test_latest_team_activity_returns_the_most_current_working_users // Assert $this->assertSame([ [ - 'user_id' => $user1->getKey(), - 'name' => $user1->name, + 'member_id' => $member1->getKey(), + 'name' => $member1->user->name, 'description' => $timeEntry1->description, 'time_entry_id' => $timeEntry1->getKey(), 'task_id' => null, 'status' => true, ], [ - 'user_id' => $user2->getKey(), - 'name' => $user2->name, + 'member_id' => $member2->getKey(), + 'name' => $member2->user->name, 'description' => $timeEntry2->description, 'time_entry_id' => $timeEntry2->getKey(), 'task_id' => null, 'status' => false, ], [ - 'user_id' => $user3->getKey(), - 'name' => $user3->name, + 'member_id' => $member3->getKey(), + 'name' => $member3->user->name, 'description' => $timeEntry3->description, 'time_entry_id' => $timeEntry3->getKey(), 'task_id' => $task1->getKey(), 'status' => false, ], [ - 'user_id' => $user4->getKey(), - 'name' => $user4->name, + 'member_id' => $member4->getKey(), + 'name' => $member4->user->name, 'description' => $timeEntry4->description, 'time_entry_id' => $timeEntry4->getKey(), 'task_id' => $task1->getKey(), @@ -480,25 +479,26 @@ public function test_latest_tasks_returns_the_4_tasks_with_the_latest_time_entri // Arrange $organization = Organization::factory()->create(); $user = User::factory()->create(); + $member = Member::factory()->forUser($user)->forOrganization($organization)->create(); $task1 = Task::factory()->forOrganization($organization)->create(); $task2 = Task::factory()->forOrganization($organization)->create(); $task3 = Task::factory()->forOrganization($organization)->create(); $task4 = Task::factory()->forOrganization($organization)->create(); $task5 = Task::factory()->forOrganization($organization)->create(); - $timeEntry1Task1 = TimeEntry::factory()->forTask($task1)->forUser($user)->forOrganization($organization)->create([ + $timeEntry1Task1 = TimeEntry::factory()->forTask($task1)->forMember($member)->forOrganization($organization)->create([ 'start' => now()->subMinutes(20), ]); - $timeEntry1Task2 = TimeEntry::factory()->forTask($task2)->forUser($user)->forOrganization($organization)->create([ + $timeEntry1Task2 = TimeEntry::factory()->forTask($task2)->forMember($member)->forOrganization($organization)->create([ 'start' => now()->subMinutes(30), ]); - $timeEntry1Task3 = TimeEntry::factory()->forTask($task3)->forUser($user)->forOrganization($organization)->create([ + $timeEntry1Task3 = TimeEntry::factory()->forTask($task3)->forMember($member)->forOrganization($organization)->create([ 'start' => now()->subMinutes(40), ]); - $timeEntry1Task4 = TimeEntry::factory()->forTask($task4)->forUser($user)->forOrganization($organization)->create([ + $timeEntry1Task4 = TimeEntry::factory()->forTask($task4)->forMember($member)->forOrganization($organization)->create([ 'start' => now()->subMinutes(50), ]); - $timeEntry1Task5 = TimeEntry::factory()->forTask($task5)->forUser($user)->forOrganization($organization)->create([ + $timeEntry1Task5 = TimeEntry::factory()->forTask($task5)->forMember($member)->forOrganization($organization)->create([ 'start' => now()->subMinutes(60), ]); @@ -543,15 +543,16 @@ public function test_last_seven_days_returns_spend_time_in_the_last_seven_days_a $user = User::factory()->create([ 'timezone' => 'Europe/Vienna', ]); - $timeEntryOverWholePeriod = TimeEntry::factory()->forUser($user)->forOrganization($organization)->create([ + $member = Member::factory()->forUser($user)->forOrganization($organization)->create(); + $timeEntryOverWholePeriod = TimeEntry::factory()->forMember($member)->forOrganization($organization)->create([ 'start' => now('Europe/Vienna')->subDays(7)->startOfDay()->utc(), 'end' => now('Europe/Vienna')->endOfDay()->addSecond()->utc(), // TODO: fix problem with last second ]); - $timeEntryOverWholePeriodWithoutEnd = TimeEntry::factory()->forUser($user)->forOrganization($organization)->create([ + $timeEntryOverWholePeriodWithoutEnd = TimeEntry::factory()->forMember($member)->forOrganization($organization)->create([ 'start' => now('Europe/Vienna')->subDays(7)->startOfDay()->utc(), 'end' => null, ]); - $timeEntry1Task1 = TimeEntry::factory()->forUser($user)->forOrganization($organization)->create([ + $timeEntry1Task1 = TimeEntry::factory()->forMember($member)->forOrganization($organization)->create([ 'start' => now('Europe/Vienna')->subMinutes(30)->utc(), 'end' => now('Europe/Vienna')->subMinutes(20)->utc(), ]); diff --git a/tests/Unit/Service/PermissionStoreTest.php b/tests/Unit/Service/PermissionStoreTest.php index 7ed3154c..925401da 100644 --- a/tests/Unit/Service/PermissionStoreTest.php +++ b/tests/Unit/Service/PermissionStoreTest.php @@ -4,6 +4,7 @@ namespace Tests\Unit\Service; +use App\Enums\Role; use App\Models\Organization; use App\Models\User; use App\Service\PermissionStore; @@ -20,7 +21,7 @@ public function test_has_method_returns_false_when_user_is_not_authenticated(): // Arrange $organization = Organization::factory()->create(); $user = User::factory()->create(); - $organization->users()->attach($user, ['role' => 'employee']); + $organization->users()->attach($user, ['role' => Role::Employee->value]); $permissionStore = new PermissionStore(); // Act @@ -50,7 +51,7 @@ public function test_has_method_returns_false_when_user_does_not_have_permission // Arrange $organization = Organization::factory()->create(); $user = User::factory()->create(); - $organization->users()->attach($user, ['role' => 'employee']); + $organization->users()->attach($user, ['role' => Role::Employee->value]); $permissionStore = new PermissionStore(); $this->actingAs($user); @@ -66,7 +67,7 @@ public function test_has_method_returns_true_when_user_has_permission(): void // Arrange $organization = Organization::factory()->create(); $user = User::factory()->create(); - $organization->users()->attach($user, ['role' => 'employee']); + $organization->users()->attach($user, ['role' => Role::Employee->value]); $permissionStore = new PermissionStore(); $this->actingAs($user); @@ -82,7 +83,7 @@ public function test_get_permissions_method_returns_empty_array_when_user_is_not // Arrange $organization = Organization::factory()->create(); $user = User::factory()->create(); - $organization->users()->attach($user, ['role' => 'employee']); + $organization->users()->attach($user, ['role' => Role::Employee->value]); $permissionStore = new PermissionStore(); // Act @@ -111,7 +112,7 @@ public function test_get_permissions_method_returns_permissions_when_user_belong // Arrange $organization = Organization::factory()->create(); $user = User::factory()->create(); - $organization->users()->attach($user, ['role' => 'employee']); + $organization->users()->attach($user, ['role' => Role::Employee->value]); $permissionStore = new PermissionStore(); $this->actingAs($user); @@ -119,6 +120,6 @@ public function test_get_permissions_method_returns_permissions_when_user_belong $result = $permissionStore->getPermissions($organization); // Assert - $this->assertSame(Jetstream::findRole('employee')->permissions, $result); + $this->assertSame(Jetstream::findRole(Role::Employee->value)->permissions, $result); } } diff --git a/tests/Unit/Service/UserServiceTest.php b/tests/Unit/Service/UserServiceTest.php index e9d522c0..8d2ce367 100644 --- a/tests/Unit/Service/UserServiceTest.php +++ b/tests/Unit/Service/UserServiceTest.php @@ -5,7 +5,7 @@ namespace Tests\Unit\Service; use App\Enums\Role; -use App\Models\Membership; +use App\Models\Member; use App\Models\Organization; use App\Models\Project; use App\Models\ProjectMember; @@ -27,10 +27,13 @@ public function test_assign_organization_entities_to_different_user(): void $otherUser = User::factory()->create(); $fromUser = User::factory()->create(); $toUser = User::factory()->create(); - TimeEntry::factory()->forOrganization($organization)->forUser($otherUser)->createMany(3); - TimeEntry::factory()->forOrganization($organization)->forUser($fromUser)->createMany(3); - ProjectMember::factory()->forProject($project)->forUser($otherUser)->create(); - ProjectMember::factory()->forProject($project)->forUser($fromUser)->create(); + $otherUserMember = Member::factory()->forOrganization($organization)->forUser($otherUser)->create(); + $fromUserMember = Member::factory()->forOrganization($organization)->forUser($fromUser)->create(); + $toUserMember = Member::factory()->forOrganization($organization)->forUser($toUser)->create(); + TimeEntry::factory()->forOrganization($organization)->forMember($otherUserMember)->createMany(3); + TimeEntry::factory()->forOrganization($organization)->forMember($fromUserMember)->createMany(3); + ProjectMember::factory()->forProject($project)->forMember($otherUserMember)->create(); + ProjectMember::factory()->forProject($project)->forMember($fromUserMember)->create(); // Act /** @var UserService $userService */ @@ -66,7 +69,7 @@ public function test_change_ownership_changes_ownership_of_organization_to_new_u // Assert $this->assertSame($newOwner->getKey(), $organization->refresh()->user_id); - $this->assertSame(Role::Owner->value, Membership::whereBelongsTo($newOwner)->whereBelongsTo($organization)->firstOrFail()->role); - $this->assertSame(Role::Admin->value, Membership::whereBelongsTo($oldOwner)->whereBelongsTo($organization)->firstOrFail()->role); + $this->assertSame(Role::Owner->value, Member::whereBelongsTo($newOwner)->whereBelongsTo($organization)->firstOrFail()->role); + $this->assertSame(Role::Admin->value, Member::whereBelongsTo($oldOwner)->whereBelongsTo($organization)->firstOrFail()->role); } } From b6f5f777815cfde22a3d3c67fbd1bf64db1936f9 Mon Sep 17 00:00:00 2001 From: Constantin Graf Date: Sun, 19 May 2024 12:04:25 +0200 Subject: [PATCH 04/15] Added healthcheck endpoint tests --- .../Endpoint/Web/HealthCheckEndpointTest.php | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 tests/Unit/Endpoint/Web/HealthCheckEndpointTest.php diff --git a/tests/Unit/Endpoint/Web/HealthCheckEndpointTest.php b/tests/Unit/Endpoint/Web/HealthCheckEndpointTest.php new file mode 100644 index 00000000..8827f83d --- /dev/null +++ b/tests/Unit/Endpoint/Web/HealthCheckEndpointTest.php @@ -0,0 +1,39 @@ +get('health-check/up'); + + // Assert + $response->assertSuccessful(); + $response->assertExactJson([ + 'success' => true, + ]); + } + + public function test_debug_endpoint_returns_ok(): void + { + // Act + $response = $this->get('health-check/debug'); + + // Assert + $response->assertSuccessful(); + $response->assertJsonStructure([ + 'ip_address', + 'hostname', + 'timestamp', + 'date_time_utc', + 'date_time_app', + 'timezone', + 'secure', + 'is_trusted_proxy', + ]); + } +} From 2e0db46fff096dde705054e4822d2cce128891c6 Mon Sep 17 00:00:00 2001 From: Constantin Graf Date: Sun, 19 May 2024 12:05:08 +0200 Subject: [PATCH 05/15] Added endpoint to update multiple time entries at once --- app/Http/Controllers/Api/V1/Controller.php | 31 ++ .../Api/V1/TimeEntryController.php | 51 +++ .../TimeEntryUpdateMultipleRequest.php | 102 +++++ routes/api.php | 1 + .../Api/V1/ApiEndpointTestAbstract.php | 8 +- .../Endpoint/Api/V1/TimeEntryEndpointTest.php | 352 ++++++++++++++++++ 6 files changed, 542 insertions(+), 3 deletions(-) create mode 100644 app/Http/Requests/V1/TimeEntry/TimeEntryUpdateMultipleRequest.php diff --git a/app/Http/Controllers/Api/V1/Controller.php b/app/Http/Controllers/Api/V1/Controller.php index 21340039..5d97fab7 100644 --- a/app/Http/Controllers/Api/V1/Controller.php +++ b/app/Http/Controllers/Api/V1/Controller.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\V1; +use App\Models\Member; use App\Models\Organization; use App\Models\User; use App\Service\PermissionStore; @@ -28,6 +29,21 @@ protected function checkPermission(Organization $organization, string $permissio } } + /** + * @param array $permissions + * + * @throws AuthorizationException + */ + protected function checkAnyPermission(Organization $organization, array $permissions): void + { + foreach ($permissions as $permission) { + if ($this->permissionStore->has($organization, $permission)) { + return; + } + } + throw new AuthorizationException(); + } + protected function hasPermission(Organization $organization, string $permission): bool { return $this->permissionStore->has($organization, $permission); @@ -47,4 +63,19 @@ protected function user(): User return $user; } + + /** + * @throws AuthorizationException + */ + protected function member(Organization $organization): Member + { + $user = $this->user(); + $member = Member::query()->whereBelongsTo($organization, 'organization')->whereBelongsTo($user, 'user')->first(); + if ($member === null) { + Log::error('This function should only be called in authenticated context after checking the user is a member of the organization'); + throw new AuthorizationException(); + } + + return $member; + } } diff --git a/app/Http/Controllers/Api/V1/TimeEntryController.php b/app/Http/Controllers/Api/V1/TimeEntryController.php index 036de1d8..04cbfc88 100644 --- a/app/Http/Controllers/Api/V1/TimeEntryController.php +++ b/app/Http/Controllers/Api/V1/TimeEntryController.php @@ -10,6 +10,7 @@ use App\Http\Requests\V1\TimeEntry\TimeEntryAggregateRequest; use App\Http\Requests\V1\TimeEntry\TimeEntryIndexRequest; use App\Http\Requests\V1\TimeEntry\TimeEntryStoreRequest; +use App\Http\Requests\V1\TimeEntry\TimeEntryUpdateMultipleRequest; use App\Http\Requests\V1\TimeEntry\TimeEntryUpdateRequest; use App\Http\Resources\V1\TimeEntry\TimeEntryCollection; use App\Http\Resources\V1\TimeEntry\TimeEntryResource; @@ -359,6 +360,56 @@ public function update(Organization $organization, TimeEntry $timeEntry, TimeEnt return new TimeEntryResource($timeEntry); } + /** + * @throws AuthorizationException + */ + public function updateMultiple(Organization $organization, TimeEntryUpdateMultipleRequest $request): JsonResponse + { + $this->checkAnyPermission($organization, ['time-entries:update:all', 'time-entries:update:own']); + $canAccessAll = $this->hasPermission($organization, 'time-entries:update:all'); + + $ids = $request->get('ids'); + + $timeEntries = TimeEntry::query() + ->whereBelongsTo($organization, 'organization') + ->whereIn('id', $ids) + ->get(); + + $changes = $request->get('changes'); + + if (isset($changes['member_id']) && ! $canAccessAll && $this->member($organization)->getKey() !== $changes['member_id']) { + throw new AuthorizationException(); + } + + $success = new Collection(); + $error = new Collection(); + + foreach ($ids as $id) { + $timeEntry = $timeEntries->firstWhere('id', $id); + if ($timeEntry === null) { + // Note: ID wrong or time entry in different organization + $error->push($id); + + continue; + } + if (! $canAccessAll && $timeEntry->user_id !== Auth::id()) { + $error->push($id); + + continue; + + } + + $timeEntry->fill($changes); + $timeEntry->save(); + $success->push($id); + } + + return response()->json([ + 'success' => $success->toArray(), + 'error' => $error->toArray(), + ]); + } + /** * Delete time entry * diff --git a/app/Http/Requests/V1/TimeEntry/TimeEntryUpdateMultipleRequest.php b/app/Http/Requests/V1/TimeEntry/TimeEntryUpdateMultipleRequest.php new file mode 100644 index 00000000..d4d27767 --- /dev/null +++ b/app/Http/Requests/V1/TimeEntry/TimeEntryUpdateMultipleRequest.php @@ -0,0 +1,102 @@ +> + */ + public function rules(): array + { + return [ + 'ids' => [ + 'required', + 'array', + ], + 'ids.*' => [ + 'string', + 'uuid', + ], + 'changes' => [ + 'required', + 'array', + ], + // ID of the organization member that the time entry should belong to + 'changes.member_id' => [ + 'string', + 'uuid', + new ExistsEloquent(Member::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + }), + ], + // ID of the project that the time entry should belong to + 'changes.project_id' => [ + 'nullable', + 'string', + 'uuid', + 'required_with:task_id', + new ExistsEloquent(Project::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + }), + ], + // ID of the task that the time entry should belong to + 'changes.task_id' => [ + 'nullable', + 'string', + 'uuid', + new ExistsEloquent(Task::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + }), + (new ExistsEloquent(Task::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization') + ->where('project_id', $this->input('changes.project_id')); + }))->withMessage(__('validation.task_belongs_to_project')), + ], + // Whether time entry is billable + 'changes.billable' => [ + 'boolean', + ], + // Description of time entry + 'changes.description' => [ + 'nullable', + 'string', + 'max:500', + ], + // List of tag IDs + 'changes.tags' => [ + 'nullable', + 'array', + ], + 'changes.tags.*' => [ + 'string', + 'uuid', + new ExistsEloquent(Tag::class, null, function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereBelongsTo($this->organization, 'organization'); + }), + ], + ]; + } +} diff --git a/routes/api.php b/routes/api.php index a7520fff..38823edf 100644 --- a/routes/api.php +++ b/routes/api.php @@ -76,6 +76,7 @@ Route::get('/organizations/{organization}/time-entries/aggregate', [TimeEntryController::class, 'aggregate'])->name('aggregate'); Route::post('/organizations/{organization}/time-entries', [TimeEntryController::class, 'store'])->name('store'); Route::put('/organizations/{organization}/time-entries/{timeEntry}', [TimeEntryController::class, 'update'])->name('update'); + Route::patch('/organizations/{organization}/time-entries', [TimeEntryController::class, 'updateMultiple'])->name('update-multiple'); Route::delete('/organizations/{organization}/time-entries/{timeEntry}', [TimeEntryController::class, 'destroy'])->name('destroy'); }); diff --git a/tests/Unit/Endpoint/Api/V1/ApiEndpointTestAbstract.php b/tests/Unit/Endpoint/Api/V1/ApiEndpointTestAbstract.php index 1d590b23..4d8efb74 100644 --- a/tests/Unit/Endpoint/Api/V1/ApiEndpointTestAbstract.php +++ b/tests/Unit/Endpoint/Api/V1/ApiEndpointTestAbstract.php @@ -8,6 +8,7 @@ use App\Models\Organization; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Str; use Laravel\Jetstream\Jetstream; use Tests\TestCase; @@ -19,9 +20,10 @@ class ApiEndpointTestAbstract extends TestCase * @param array $permissions * @return object{user: User, organization: Organization, member: Member} */ - protected function createUserWithPermission(array $permissions, bool $isOwner = false): object + protected function createUserWithPermission(array $permissions = [], bool $isOwner = false): object { - Jetstream::role('custom-test', 'Custom Test', $permissions) + $roleName = 'custom-test-'.Str::uuid(); + Jetstream::role($roleName, 'Custom Test', $permissions) ->description('Role custom for testing'); $user = User::factory()->create(); if ($isOwner) { @@ -30,7 +32,7 @@ protected function createUserWithPermission(array $permissions, bool $isOwner = $organization = Organization::factory()->create(); } $member = Member::factory()->forUser($user)->forOrganization($organization)->create([ - 'role' => 'custom-test', + 'role' => $roleName, ]); return (object) [ diff --git a/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php b/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php index a00ed300..933d734c 100644 --- a/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php @@ -5,8 +5,10 @@ namespace Tests\Unit\Endpoint\Api\V1; use App\Enums\Role; +use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException; use App\Models\Member; use App\Models\Project; +use App\Models\Task; use App\Models\TimeEntry; use App\Models\User; use Carbon\Carbon; @@ -860,6 +862,32 @@ public function test_update_endpoint_updates_time_entry_for_current_user(): void ]); } + public function test_update_endpoint_fails_if_user_tries_to_reactivate_a_time_entry(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'time-entries:update:own', + ]); + $timeEntry = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->create(); + $timeEntryFake = TimeEntry::factory()->forOrganization($data->organization)->make(); + Passport::actingAs($data->user); + + // Act + $response = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $timeEntry->getKey()]), [ + 'description' => $timeEntryFake->description, + 'start' => $timeEntryFake->start->toIso8601ZuluString(), + 'end' => null, + 'tags' => $timeEntryFake->tags, + 'member_id' => $data->member->getKey(), + 'task_id' => $timeEntryFake->task_id, + ]); + + // Assert + $response->assertStatus(400); + $response->assertJsonPath('error', true); + $response->assertJsonPath('message', __('exceptions.api.'.TimeEntryCanNotBeRestartedApiException::KEY)); + } + public function test_update_endpoint_updates_time_entry_of_other_user_in_organization(): void { // Arrange @@ -999,4 +1027,328 @@ public function test_destroy_endpoint_deletes_time_entry_of_other_user_in_organi 'id' => $timeEntry->getKey(), ]); } + + public function test_update_multiple_endpoint_fails_if_user_has_no_permission_to_update_own_time_entries_or_all_time_entries(): void + { + // Arrange + $data = $this->createUserWithPermission(); + $timeEntries = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->createMany(3); + $timeEntriesFake = TimeEntry::factory()->forOrganization($data->organization)->make(); + Passport::actingAs($data->user); + + // Act + $response = $this->patchJson(route('api.v1.time-entries.update-multiple', [$data->organization->getKey()]), [ + 'ids' => $timeEntries->pluck('id')->toArray(), + 'changes' => [ + 'description' => $timeEntriesFake->description, + ], + ]); + + // Assert + $response->assertValid(); + $response->assertForbidden(); + } + + public function test_update_multiple_updates_own_time_entries_and_fails_for_time_entries_of_other_users_and_and_other_organizations_with_own_time_entries_permission(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'time-entries:update:own', + ]); + $otherData = $this->createUserWithPermission(); + $otherUser = User::factory()->create(); + $otherMember = Member::factory()->forOrganization($data->organization)->forUser($otherUser)->role(Role::Employee)->create(); + + $ownTimeEntry = TimeEntry::factory()->forMember($data->member)->create(); + $otherTimeEntry = TimeEntry::factory()->forMember($otherMember)->create(); + $otherOrganizationTimeEntry = TimeEntry::factory()->forMember($otherData->member)->create(); + $timeEntriesFake = TimeEntry::factory()->forOrganization($data->organization)->make(); + $wrongId = Str::uuid(); + Passport::actingAs($data->user); + + // Act + $response = $this->patchJson(route('api.v1.time-entries.update-multiple', [$data->organization->getKey()]), [ + 'ids' => [ + $ownTimeEntry->getKey(), + $otherTimeEntry->getKey(), + $otherOrganizationTimeEntry->getKey(), + $wrongId, + ], + 'changes' => [ + 'description' => $timeEntriesFake->description, + ], + ]); + + // Assert + $response->assertValid(); + $response->assertStatus(200); + $response->assertExactJson([ + 'success' => [ + $ownTimeEntry->getKey(), + ], + 'error' => [ + $otherTimeEntry->getKey(), + $otherOrganizationTimeEntry->getKey(), + $wrongId, + ], + ]); + $this->assertDatabaseHas(TimeEntry::class, [ + 'id' => $ownTimeEntry->getKey(), + 'description' => $timeEntriesFake->description, + ]); + $this->assertDatabaseHas(TimeEntry::class, [ + 'id' => $otherOrganizationTimeEntry->getKey(), + 'description' => $otherOrganizationTimeEntry->description, + ]); + $this->assertDatabaseHas(TimeEntry::class, [ + 'id' => $otherTimeEntry->getKey(), + 'description' => $otherTimeEntry->description, + ]); + } + + public function test_update_multiple_updates_own_time_entries_and_fails_for_time_entries_of_other_users_and_and_other_organizations_with_own_time_entries_permission_and_full_changeset(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'time-entries:update:own', + ]); + $otherData = $this->createUserWithPermission(); + $otherUser = User::factory()->create(); + $otherMember = Member::factory()->forOrganization($data->organization)->forUser($otherUser)->role(Role::Employee)->create(); + + $ownTimeEntry = TimeEntry::factory()->forMember($data->member)->create(); + $otherTimeEntry = TimeEntry::factory()->forMember($otherMember)->create(); + $otherOrganizationTimeEntry = TimeEntry::factory()->forMember($otherData->member)->create(); + $timeEntriesFake = TimeEntry::factory()->forOrganization($data->organization)->withTags($data->organization)->make(); + $project = Project::factory()->forOrganization($data->organization)->create(); + $task = Task::factory()->forProject($project)->forOrganization($data->organization)->create(); + $wrongId = Str::uuid(); + Passport::actingAs($data->user); + + // Act + $response = $this->patchJson(route('api.v1.time-entries.update-multiple', [$data->organization->getKey()]), [ + 'ids' => [ + $ownTimeEntry->getKey(), + $otherTimeEntry->getKey(), + $otherOrganizationTimeEntry->getKey(), + $wrongId, + ], + 'changes' => [ + 'member_id' => $data->member->getKey(), + 'project_id' => $project->getKey(), + 'task_id' => $task->getKey(), + 'billable' => $timeEntriesFake->billable, + 'description' => $timeEntriesFake->description, + 'tags' => $timeEntriesFake->tags, + ], + ]); + + // Assert + $response->assertValid(); + $response->assertStatus(200); + $response->assertExactJson([ + 'success' => [ + $ownTimeEntry->getKey(), + ], + 'error' => [ + $otherTimeEntry->getKey(), + $otherOrganizationTimeEntry->getKey(), + $wrongId, + ], + ]); + $this->assertDatabaseHas(TimeEntry::class, [ + 'id' => $ownTimeEntry->getKey(), + 'member_id' => $data->member->getKey(), + 'project_id' => $project->getKey(), + 'task_id' => $task->getKey(), + 'billable' => $timeEntriesFake->billable, + 'description' => $timeEntriesFake->description, + 'tags' => json_encode($timeEntriesFake->tags), + ]); + $this->assertDatabaseHas(TimeEntry::class, [ + 'id' => $otherOrganizationTimeEntry->getKey(), + 'member_id' => $otherOrganizationTimeEntry->member_id, + 'project_id' => $otherOrganizationTimeEntry->project_id, + 'task_id' => $otherOrganizationTimeEntry->task_id, + 'billable' => $otherOrganizationTimeEntry->billable, + 'description' => $otherOrganizationTimeEntry->description, + 'tags' => json_encode($otherOrganizationTimeEntry->tags), + ]); + $this->assertDatabaseHas(TimeEntry::class, [ + 'id' => $otherTimeEntry->getKey(), + 'member_id' => $otherTimeEntry->member_id, + 'project_id' => $otherTimeEntry->project_id, + 'task_id' => $otherTimeEntry->task_id, + 'billable' => $otherTimeEntry->billable, + 'description' => $otherTimeEntry->description, + 'tags' => json_encode($otherTimeEntry->tags), + ]); + } + + public function test_update_multiple_updates_all_time_entries_and_fails_for_time_entries_of_other_users_and_and_other_organizations_with_all_time_entries_permission(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'time-entries:update:all', + ]); + $otherData = $this->createUserWithPermission(); + $otherUser = User::factory()->create(); + $otherMember = Member::factory()->forOrganization($data->organization)->forUser($otherUser)->role(Role::Employee)->create(); + + $ownTimeEntry = TimeEntry::factory()->forMember($data->member)->create(); + $otherTimeEntry = TimeEntry::factory()->forMember($otherMember)->create(); + $otherOrganizationTimeEntry = TimeEntry::factory()->forMember($otherData->member)->create(); + $timeEntriesFake = TimeEntry::factory()->forOrganization($data->organization)->make(); + $wrongId = Str::uuid(); + Passport::actingAs($data->user); + + // Act + $response = $this->patchJson(route('api.v1.time-entries.update-multiple', [$data->organization->getKey()]), [ + 'ids' => [ + $ownTimeEntry->getKey(), + $otherTimeEntry->getKey(), + $otherOrganizationTimeEntry->getKey(), + $wrongId, + ], + 'changes' => [ + 'description' => $timeEntriesFake->description, + ], + ]); + + // Assert + $response->assertValid(); + $response->assertStatus(200); + $response->assertExactJson([ + 'success' => [ + $ownTimeEntry->getKey(), + $otherTimeEntry->getKey(), + ], + 'error' => [ + $otherOrganizationTimeEntry->getKey(), + $wrongId, + ], + ]); + $this->assertDatabaseHas(TimeEntry::class, [ + 'id' => $ownTimeEntry->getKey(), + 'description' => $timeEntriesFake->description, + ]); + $this->assertDatabaseHas(TimeEntry::class, [ + 'id' => $otherOrganizationTimeEntry->getKey(), + 'description' => $otherOrganizationTimeEntry->description, + ]); + $this->assertDatabaseHas(TimeEntry::class, [ + 'id' => $otherTimeEntry->getKey(), + 'description' => $timeEntriesFake->description, + ]); + } + + public function test_update_multiple_updates_all_time_entries_and_fails_for_time_entries_of_other_users_and_and_other_organizations_with_all_time_entries_permission_and_full_changeset(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'time-entries:update:all', + ]); + $otherData = $this->createUserWithPermission(); + $otherUser = User::factory()->create(); + $otherMember = Member::factory()->forOrganization($data->organization)->forUser($otherUser)->role(Role::Employee)->create(); + + $ownTimeEntry = TimeEntry::factory()->forMember($data->member)->create(); + $otherTimeEntry = TimeEntry::factory()->forMember($otherMember)->create(); + $otherOrganizationTimeEntry = TimeEntry::factory()->forMember($otherData->member)->create(); + $timeEntriesFake = TimeEntry::factory()->forOrganization($data->organization)->withTags($data->organization)->make(); + $project = Project::factory()->forOrganization($data->organization)->create(); + $task = Task::factory()->forProject($project)->forOrganization($data->organization)->create(); + $wrongId = Str::uuid(); + Passport::actingAs($data->user); + + // Act + $response = $this->patchJson(route('api.v1.time-entries.update-multiple', [$data->organization->getKey()]), [ + 'ids' => [ + $ownTimeEntry->getKey(), + $otherTimeEntry->getKey(), + $otherOrganizationTimeEntry->getKey(), + $wrongId, + ], + 'changes' => [ + 'member_id' => $otherMember->getKey(), + 'project_id' => $project->getKey(), + 'task_id' => $task->getKey(), + 'billable' => $timeEntriesFake->billable, + 'description' => $timeEntriesFake->description, + 'tags' => $timeEntriesFake->tags, + ], + ]); + + // Assert + $response->assertValid(); + $response->assertStatus(200); + $response->assertExactJson([ + 'success' => [ + $ownTimeEntry->getKey(), + $otherTimeEntry->getKey(), + ], + 'error' => [ + $otherOrganizationTimeEntry->getKey(), + $wrongId, + ], + ]); + $this->assertDatabaseHas(TimeEntry::class, [ + 'id' => $ownTimeEntry->getKey(), + 'member_id' => $otherMember->getKey(), + 'project_id' => $project->getKey(), + 'task_id' => $task->getKey(), + 'billable' => $timeEntriesFake->billable, + 'description' => $timeEntriesFake->description, + 'tags' => json_encode($timeEntriesFake->tags), + ]); + $this->assertDatabaseHas(TimeEntry::class, [ + 'id' => $otherOrganizationTimeEntry->getKey(), + 'member_id' => $otherOrganizationTimeEntry->member_id, + 'project_id' => $otherOrganizationTimeEntry->project_id, + 'task_id' => $otherOrganizationTimeEntry->task_id, + 'billable' => $otherOrganizationTimeEntry->billable, + 'description' => $otherOrganizationTimeEntry->description, + 'tags' => json_encode($otherOrganizationTimeEntry->tags), + ]); + $this->assertDatabaseHas(TimeEntry::class, [ + 'id' => $otherTimeEntry->getKey(), + 'member_id' => $otherMember->getKey(), + 'project_id' => $project->getKey(), + 'task_id' => $task->getKey(), + 'billable' => $timeEntriesFake->billable, + 'description' => $timeEntriesFake->description, + 'tags' => json_encode($timeEntriesFake->tags), + ]); + } + + public function test_update_multiple_updates_own_time_entries_fails_if_member_id_is_not_your_own_and_you_dont_have_update_all_permission(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'time-entries:update:own', + ]); + $otherUser = User::factory()->create(); + $otherMember = Member::factory()->forOrganization($data->organization)->forUser($otherUser)->role(Role::Employee)->create(); + + $ownTimeEntry = TimeEntry::factory()->forMember($data->member)->create(); + Passport::actingAs($data->user); + + // Act + $response = $this->patchJson(route('api.v1.time-entries.update-multiple', [$data->organization->getKey()]), [ + 'ids' => [ + $ownTimeEntry->getKey(), + ], + 'changes' => [ + 'member_id' => $otherMember->getKey(), + ], + ]); + + // Assert + $response->assertValid(); + $response->assertStatus(403); + $this->assertDatabaseHas(TimeEntry::class, [ + 'id' => $ownTimeEntry->getKey(), + 'member_id' => $ownTimeEntry->member_id, + ]); + } } From 68ecc1227da26110bb9635e55e02c859c0d10306 Mon Sep 17 00:00:00 2001 From: Constantin Graf Date: Sun, 19 May 2024 12:06:20 +0200 Subject: [PATCH 06/15] Fixed validations and response naming in aggregate time entries endpoint --- .../Api/V1/TimeEntryController.php | 20 +++++++++---------- .../TimeEntry/TimeEntryAggregateRequest.php | 6 +++--- .../V1/TimeEntry/TimeEntryIndexRequest.php | 6 +++--- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/app/Http/Controllers/Api/V1/TimeEntryController.php b/app/Http/Controllers/Api/V1/TimeEntryController.php index 04cbfc88..3d4d0f5e 100644 --- a/app/Http/Controllers/Api/V1/TimeEntryController.php +++ b/app/Http/Controllers/Api/V1/TimeEntryController.php @@ -128,17 +128,17 @@ public function index(Organization $organization, TimeEntryIndexRequest $request * data: array{ * grouped_data: null|array * }>, - * aggregate: int, + * seconds: int, * cost: int * } * } @@ -224,8 +224,8 @@ public function aggregate(Organization $organization, TimeEntryAggregateRequest /** @var string $group2Type */ $group2Response[] = [ 'type' => $group2Type, - 'value' => $group2 === '' ? null : $group2, - 'aggregate' => (int) $aggregate->get(0)->aggregate, + 'key' => $group2 === '' ? null : $group2, + 'seconds' => (int) $aggregate->get(0)->aggregate, 'cost' => (int) $aggregate->get(0)->cost, ]; $group2ResponseSum += (int) $aggregate->get(0)->aggregate; @@ -241,8 +241,8 @@ public function aggregate(Organization $organization, TimeEntryAggregateRequest /** @var string $group1Type */ $group1Response[] = [ 'type' => $group1Type, - 'value' => $group1 === '' ? null : $group1, - 'aggregate' => $group2ResponseSum, + 'key' => $group1 === '' ? null : $group1, + 'seconds' => $group2ResponseSum, 'cost' => $group2ResponseCost, 'grouped_data' => $group2Response, ]; @@ -259,7 +259,7 @@ public function aggregate(Organization $organization, TimeEntryAggregateRequest return [ 'data' => [ 'grouped_data' => $group1Response, - 'aggregate' => $group1ResponseSum, + 'seconds' => $group1ResponseSum, 'cost' => $group1ResponseCost, ], ]; diff --git a/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateRequest.php b/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateRequest.php index 0be57fa4..ee2dcc33 100644 --- a/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateRequest.php +++ b/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateRequest.php @@ -110,18 +110,18 @@ public function rules(): array return $builder->visibleByUser(Auth::user()); }), ], - // Filter only time entries that have a start date before (not including) the given date (example: 2021-12-31) + // Filter only time entries that have a start date before the given timestamp in UTC (example: 2021-01-01T00:00:00Z) 'before' => [ 'nullable', 'string', 'date_format:Y-m-d\TH:i:s\Z', - 'before:after', ], - // Filter only time entries that have a start date after (not including) the given date (example: 2021-12-31) + // Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z) 'after' => [ 'nullable', 'string', 'date_format:Y-m-d\TH:i:s\Z', + 'before:before', ], // Filter by active status (active means has no end date, is still running) 'active' => [ diff --git a/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php b/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php index e89c60ae..ca15392c 100644 --- a/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php +++ b/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php @@ -89,18 +89,18 @@ public function rules(): array return $builder->visibleByUser(Auth::user()); }), ], - // Filter only time entries that have a start date before (not including) the given date (example: 2021-12-31) + // Filter only time entries that have a start date before the given timestamp in UTC (example: 2021-01-01T00:00:00Z) 'before' => [ 'nullable', 'string', 'date_format:Y-m-d\TH:i:s\Z', - 'before:after', ], - // Filter only time entries that have a start date after (not including) the given date (example: 2021-12-31) + // Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z) 'after' => [ 'nullable', 'string', 'date_format:Y-m-d\TH:i:s\Z', + 'before:before', ], // Filter by active status (active means has no end date, is still running) 'active' => [ From 8b12dec54651a300d3baf2fdf86619b8c1a1117b Mon Sep 17 00:00:00 2001 From: Gregor Vostrak Date: Tue, 21 May 2024 01:53:50 +0200 Subject: [PATCH 07/15] change color palette, change user_id to member_id --- .../Api/V1/TimeEntryController.php | 4 +- app/Http/Middleware/ShareInertiaData.php | 1 + config/auth.php | 4 +- e2e/projects.spec.ts | 2 +- e2e/tasks.spec.ts | 2 +- e2e/time.spec.ts | 10 +- e2e/utils/currentTimeEntry.ts | 2 +- openapi.json.client.ts | 391 +++++++++++++++--- package-lock.json | 10 +- package.json | 2 +- resources/css/app.css | 48 ++- resources/js/Components/Common/Badge.vue | 4 +- .../Components/Common/BillableRateInput.vue | 9 +- .../Common/BillableToggleButton.vue | 16 +- .../Common/GroupedItemsCountButton.vue | 37 ++ .../Components/Common/Icons/BillableIcon.vue | 14 + .../Common/Member/MemberCombobox.vue | 33 +- .../Member/MemberMultiselectDropdown.vue | 29 ++ .../Common/Member/MemberTableRow.vue | 2 +- .../Common/Project/ProjectColorSelector.vue | 16 +- .../Common/Project/ProjectCreateModal.vue | 63 +-- .../Common/Project/ProjectEditModal.vue | 4 +- .../Project/ProjectMultiselectDropdown.vue | 29 ++ .../ProjectMemberCreateModal.vue | 7 +- .../ProjectMember/ProjectMemberTableRow.vue | 2 +- .../TimeEntry/TimeEntryAggregateRow.vue | 28 +- .../TimeEntry/TimeEntryDescriptionInput.vue | 2 +- .../TimeEntry/TimeEntryRowTagDropdown.vue | 4 +- .../TimeTrackerProjectTaskDropdown.vue | 227 +++++----- .../TimeTracker/TimeTrackerTagDropdown.vue | 2 +- .../Common/TimeTrackerStartStop.vue | 2 +- .../Dashboard/ActivityGraphCard.vue | 13 +- .../js/Components/Dashboard/DashboardCard.vue | 2 +- .../Dashboard/DayOverviewCardChart.vue | 7 +- .../Dashboard/ProjectsChartCard.vue | 22 +- .../Components/Dashboard/ThisWeekOverview.vue | 33 +- resources/js/Components/DialogModal.vue | 2 +- resources/js/Components/Dropdown.vue | 13 +- resources/js/Components/Modal.vue | 2 +- resources/js/Components/SecondaryButton.vue | 2 +- resources/js/Components/TextInput.vue | 2 +- resources/js/Components/TimeTracker.vue | 6 +- resources/js/Pages/Tags.vue | 5 +- resources/js/Pages/Time.vue | 2 - resources/js/types/models.ts | 3 + resources/js/utils/notification.ts | 2 +- resources/js/utils/useCurrentTimeEntry.ts | 12 +- resources/js/utils/useMembers.ts | 2 +- resources/js/utils/useTimeEntries.ts | 24 +- resources/js/utils/useUser.ts | 7 + tailwind.config.js | 53 +-- 51 files changed, 834 insertions(+), 386 deletions(-) create mode 100644 resources/js/Components/Common/GroupedItemsCountButton.vue create mode 100644 resources/js/Components/Common/Icons/BillableIcon.vue create mode 100644 resources/js/Components/Common/Member/MemberMultiselectDropdown.vue create mode 100644 resources/js/Components/Common/Project/ProjectMultiselectDropdown.vue diff --git a/app/Http/Controllers/Api/V1/TimeEntryController.php b/app/Http/Controllers/Api/V1/TimeEntryController.php index 3d4d0f5e..22cc7c8c 100644 --- a/app/Http/Controllers/Api/V1/TimeEntryController.php +++ b/app/Http/Controllers/Api/V1/TimeEntryController.php @@ -224,7 +224,7 @@ public function aggregate(Organization $organization, TimeEntryAggregateRequest /** @var string $group2Type */ $group2Response[] = [ 'type' => $group2Type, - 'key' => $group2 === '' ? null : $group2, + 'key' => $group2 === '' ? null : (string) $group2, 'seconds' => (int) $aggregate->get(0)->aggregate, 'cost' => (int) $aggregate->get(0)->cost, ]; @@ -241,7 +241,7 @@ public function aggregate(Organization $organization, TimeEntryAggregateRequest /** @var string $group1Type */ $group1Response[] = [ 'type' => $group1Type, - 'key' => $group1 === '' ? null : $group1, + 'key' => $group1 === '' ? null : (string) $group1, 'seconds' => $group2ResponseSum, 'cost' => $group2ResponseCost, 'grouped_data' => $group2Response, diff --git a/app/Http/Middleware/ShareInertiaData.php b/app/Http/Middleware/ShareInertiaData.php index cbe154f3..e5c3cfba 100644 --- a/app/Http/Middleware/ShareInertiaData.php +++ b/app/Http/Middleware/ShareInertiaData.php @@ -85,6 +85,7 @@ public function handle(Request $request, Closure $next): Response 'currency' => $organization->currency, 'membership' => [ 'role' => $organization->membership->role, + 'id' => $organization->membership->id, ], ]; })->all(), diff --git a/config/auth.php b/config/auth.php index 6ac82835..8143c2ee 100644 --- a/config/auth.php +++ b/config/auth.php @@ -117,9 +117,9 @@ 'super_admins' => ! is_string(env('SUPER_ADMINS', null)) ? [] : explode(',', env('SUPER_ADMINS')), - 'terms_url' => env('TERMS_URL'), + 'terms_url' => env('TERMS_URL', ''), - 'privacy_policy_url' => env('PRIVACY_POLICY_URL'), + 'privacy_policy_url' => env('PRIVACY_POLICY_URL', ''), 'newsletter_consent' => env('NEWSLETTER_CONSENT', false), diff --git a/e2e/projects.spec.ts b/e2e/projects.spec.ts index 937ce670..797963a1 100644 --- a/e2e/projects.spec.ts +++ b/e2e/projects.spec.ts @@ -14,7 +14,7 @@ test('test that creating and deleting a new project via the modal works', async 'New Project ' + Math.floor(1 + Math.random() * 10000); await goToProjectsOverview(page); await page.getByRole('button', { name: 'Create Project' }).click(); - await page.getByPlaceholder('Project Name').fill(newProjectName); + await page.getByLabel('Project Name').fill(newProjectName); await Promise.all([ page.getByRole('button', { name: 'Create Project' }).nth(1).click(), page.waitForResponse( diff --git a/e2e/tasks.spec.ts b/e2e/tasks.spec.ts index 4a8196b4..54b7be78 100644 --- a/e2e/tasks.spec.ts +++ b/e2e/tasks.spec.ts @@ -14,7 +14,7 @@ test('test that creating and deleting a new tag in a new project works', async ( 'New Project ' + Math.floor(1 + Math.random() * 10000); await goToProjectsOverview(page); await page.getByRole('button', { name: 'Create Project' }).click(); - await page.getByPlaceholder('Project Name').fill(newProjectName); + await page.getByLabel('Project Name').fill(newProjectName); await Promise.all([ page.getByRole('button', { name: 'Create Project' }).nth(1).click(), page.waitForResponse( diff --git a/e2e/time.spec.ts b/e2e/time.spec.ts index ee63ea75..1de2fb11 100644 --- a/e2e/time.spec.ts +++ b/e2e/time.spec.ts @@ -57,7 +57,7 @@ test('test that starting and stopping an empty time entry shows a new time entry async function assertThatTimeEntryRowIsStopped(newTimeEntry: Locator) { await expect(newTimeEntry.getByTestId('timer_button')).toHaveClass( - /bg-accent-300\/50/ + /bg-accent-300\/70/ ); } @@ -297,7 +297,7 @@ test('test that stopping a time entry from the overview works', async ({ ]); await expect(newTimeEntry.getByTestId('timer_button')).toHaveClass( - /bg-accent-300\/50/ + /bg-accent-300\/70/ ); }); @@ -311,7 +311,7 @@ test('test that starting a time entry from the overview works', async ({ const newTimeEntry = timeEntryRows.first(); const startButton = newTimeEntry.getByTestId('timer_button'); - await expect(startButton).toHaveClass(/bg-accent-300\/50/); + await expect(startButton).toHaveClass(/bg-accent-300\/70/); await Promise.all([ page.waitForResponse(async (response) => { @@ -341,7 +341,7 @@ test('test that starting a time entry from the overview works', async ({ ); }), startOrStopTimerWithButton(page), - expect(startButton).toHaveClass(/bg-accent-300\/50/), + expect(startButton).toHaveClass(/bg-accent-300\/70/), ]); }); @@ -401,7 +401,7 @@ test('test that updating a the duration in the overview for a running timer work ); }), startOrStopTimerWithButton(page), - expect(startButton).toHaveClass(/bg-accent-300\/50/), + expect(startButton).toHaveClass(/bg-accent-300\/70/), ]); }); diff --git a/e2e/utils/currentTimeEntry.ts b/e2e/utils/currentTimeEntry.ts index a616552f..f73faa68 100644 --- a/e2e/utils/currentTimeEntry.ts +++ b/e2e/utils/currentTimeEntry.ts @@ -40,7 +40,7 @@ export async function assertThatTimerIsStopped(page: Page) { page.locator( '[data-testid="dashboard_timer"] [data-testid="timer_button"]' ) - ).toHaveClass(/bg-accent-300\/50/); + ).toHaveClass(/bg-accent-300\/70/); } export async function stoppedTimeEntryResponse( diff --git a/openapi.json.client.ts b/openapi.json.client.ts index f876523b..e6fafb4b 100644 --- a/openapi.json.client.ts +++ b/openapi.json.client.ts @@ -83,13 +83,13 @@ const ProjectMemberResource = z .object({ id: z.string(), billable_rate: z.union([z.number(), z.null()]), - user_id: z.string(), + member_id: z.string(), project_id: z.string(), }) .passthrough(); const createProjectMember_Body = z .object({ - user_id: z.string().uuid(), + member_id: z.string().uuid(), billable_rate: z.union([z.number(), z.null()]).optional(), }) .passthrough(); @@ -137,7 +137,7 @@ const TimeEntryResource = z const TimeEntryCollection = z.array(TimeEntryResource); const createTimeEntry_Body = z .object({ - user_id: z.string().uuid(), + member_id: z.string().uuid(), project_id: z.union([z.string(), z.null()]).optional(), task_id: z.union([z.string(), z.null()]).optional(), start: z.string(), @@ -147,8 +147,41 @@ const createTimeEntry_Body = z tags: z.union([z.array(z.string()), z.null()]).optional(), }) .passthrough(); +const v1_time_entries_update_multiple_Body = z + .object({ + ids: z.array(z.string()), + changes: z + .object({ + member_id: z.string().uuid(), + project_id: z.union([z.string(), z.null()]), + task_id: z.union([z.string(), z.null()]), + billable: z.boolean(), + description: z.union([z.string(), z.null()]), + tags: z.union([z.array(z.string()), z.null()]), + }) + .partial() + .passthrough(), + }) + .passthrough(); +const group = z + .union([ + z.enum([ + 'day', + 'week', + 'month', + 'year', + 'user', + 'project', + 'task', + 'client', + 'billable', + ]), + z.null(), + ]) + .optional(); const updateTimeEntry_Body = z .object({ + member_id: z.string().uuid().optional(), project_id: z.union([z.string(), z.null()]).optional(), task_id: z.union([z.string(), z.null()]).optional(), start: z.string(), @@ -184,6 +217,8 @@ export const schemas = { TimeEntryResource, TimeEntryCollection, createTimeEntry_Body, + v1_time_entries_update_multiple_Body, + group, updateTimeEntry_Body, }; @@ -197,7 +232,7 @@ const endpoints = makeApi([ { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z.object({ data: OrganizationResource }).passthrough(), @@ -228,7 +263,7 @@ const endpoints = makeApi([ { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z.object({ data: OrganizationResource }).passthrough(), @@ -264,7 +299,7 @@ const endpoints = makeApi([ { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z.object({ data: ClientCollection }).passthrough(), @@ -295,7 +330,7 @@ const endpoints = makeApi([ { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z.object({ data: ClientResource }).passthrough(), @@ -336,12 +371,12 @@ const endpoints = makeApi([ { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, { name: 'client', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z.object({ data: ClientResource }).passthrough(), @@ -382,12 +417,12 @@ const endpoints = makeApi([ { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, { name: 'client', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z.null(), @@ -429,7 +464,7 @@ const endpoints = makeApi([ { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z @@ -497,7 +532,7 @@ const endpoints = makeApi([ { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z @@ -535,7 +570,7 @@ const endpoints = makeApi([ { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z @@ -608,7 +643,7 @@ const endpoints = makeApi([ { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z.null(), @@ -649,12 +684,12 @@ const endpoints = makeApi([ { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, { name: 'invitation', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z.null(), @@ -685,12 +720,12 @@ const endpoints = makeApi([ { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, { name: 'invitation', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z.null(), @@ -716,7 +751,7 @@ const endpoints = makeApi([ { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z @@ -777,7 +812,7 @@ const endpoints = makeApi([ }, { method: 'put', - path: '/v1/organizations/:organization/members/:membership', + path: '/v1/organizations/:organization/members/:member', alias: 'updateMember', requestFormat: 'json', parameters: [ @@ -789,12 +824,12 @@ const endpoints = makeApi([ { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, { - name: 'membership', + name: 'member', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z.object({ data: MemberResource }).passthrough(), @@ -823,7 +858,7 @@ const endpoints = makeApi([ }, { method: 'delete', - path: '/v1/organizations/:organization/members/:membership', + path: '/v1/organizations/:organization/members/:member', alias: 'removeMember', requestFormat: 'json', parameters: [ @@ -835,12 +870,12 @@ const endpoints = makeApi([ { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, { - name: 'membership', + name: 'member', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z.null(), @@ -870,7 +905,7 @@ const endpoints = makeApi([ }, { method: 'post', - path: '/v1/organizations/:organization/members/:membership/invite-placeholder', + path: '/v1/organizations/:organization/members/:member/invite-placeholder', alias: 'invitePlaceholder', requestFormat: 'json', parameters: [ @@ -882,12 +917,12 @@ const endpoints = makeApi([ { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, { - name: 'membership', + name: 'member', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z.null(), @@ -929,12 +964,12 @@ const endpoints = makeApi([ { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, { name: 'projectMember', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z.object({ data: ProjectMemberResource }).passthrough(), @@ -975,12 +1010,12 @@ const endpoints = makeApi([ { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, { name: 'projectMember', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z.null(), @@ -1006,7 +1041,12 @@ const endpoints = makeApi([ { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), + }, + { + name: 'page', + type: 'Query', + schema: z.number().int().gte(1).optional(), }, ], response: z @@ -1053,6 +1093,16 @@ const endpoints = makeApi([ description: `Not found`, schema: z.object({ message: z.string() }).passthrough(), }, + { + status: 422, + description: `Validation error`, + schema: z + .object({ + message: z.string(), + errors: z.record(z.array(z.string())), + }) + .passthrough(), + }, ], }, { @@ -1069,7 +1119,7 @@ const endpoints = makeApi([ { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z.object({ data: ProjectResource }).passthrough(), @@ -1105,12 +1155,12 @@ const endpoints = makeApi([ { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, { name: 'project', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z.object({ data: ProjectResource }).passthrough(), @@ -1141,12 +1191,12 @@ const endpoints = makeApi([ { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, { name: 'project', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z.object({ data: ProjectResource }).passthrough(), @@ -1187,12 +1237,12 @@ const endpoints = makeApi([ { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, { name: 'project', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z.null(), @@ -1229,12 +1279,12 @@ const endpoints = makeApi([ { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, { name: 'project', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z @@ -1297,12 +1347,12 @@ const endpoints = makeApi([ { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, { name: 'project', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z.object({ data: ProjectMemberResource }).passthrough(), @@ -1349,7 +1399,7 @@ const endpoints = makeApi([ { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z.object({ data: TagCollection }).passthrough(), @@ -1380,7 +1430,7 @@ const endpoints = makeApi([ { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z.object({ data: TagResource }).passthrough(), @@ -1421,12 +1471,12 @@ const endpoints = makeApi([ { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, { name: 'tag', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z.object({ data: TagResource }).passthrough(), @@ -1467,12 +1517,12 @@ const endpoints = makeApi([ { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, { name: 'tag', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z.null(), @@ -1509,7 +1559,7 @@ const endpoints = makeApi([ { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, { name: 'project_id', @@ -1587,7 +1637,7 @@ const endpoints = makeApi([ { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z.object({ data: TaskResource }).passthrough(), @@ -1628,12 +1678,12 @@ const endpoints = makeApi([ { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, { name: 'task', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z.object({ data: TaskResource }).passthrough(), @@ -1674,12 +1724,12 @@ const endpoints = makeApi([ { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, { name: 'task', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z.null(), @@ -1718,10 +1768,10 @@ Users with the permission `time-entries:view:own` can only use this en { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, { - name: 'user_id', + name: 'member_id', type: 'Query', schema: z.string().uuid().optional(), }, @@ -1740,6 +1790,11 @@ Users with the permission `time-entries:view:own` can only use this en type: 'Query', schema: z.enum(['true', 'false']).optional(), }, + { + name: 'billable', + type: 'Query', + schema: z.enum(['true', 'false']).optional(), + }, { name: 'limit', type: 'Query', @@ -1750,6 +1805,26 @@ Users with the permission `time-entries:view:own` can only use this en type: 'Query', schema: z.enum(['true', 'false']).optional(), }, + { + name: 'member_ids', + type: 'Query', + schema: z.array(z.string()).min(1).optional(), + }, + { + name: 'project_ids', + type: 'Query', + schema: z.array(z.string()).min(1).optional(), + }, + { + name: 'tag_ids', + type: 'Query', + schema: z.array(z.string()).min(1).optional(), + }, + { + name: 'task_ids', + type: 'Query', + schema: z.array(z.string()).min(1).optional(), + }, ], response: z.object({ data: TimeEntryCollection }).passthrough(), errors: [ @@ -1789,7 +1864,7 @@ Users with the permission `time-entries:view:own` can only use this en { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z.object({ data: TimeEntryResource }).passthrough(), @@ -1827,6 +1902,49 @@ Users with the permission `time-entries:view:own` can only use this en }, ], }, + { + method: 'patch', + path: '/v1/organizations/:organization/time-entries', + alias: 'v1.time-entries.update-multiple', + requestFormat: 'json', + parameters: [ + { + name: 'body', + type: 'Body', + schema: v1_time_entries_update_multiple_Body, + }, + { + name: 'organization', + type: 'Path', + schema: z.string(), + }, + ], + response: z + .object({ success: z.string(), error: z.string() }) + .passthrough(), + errors: [ + { + status: 403, + description: `Authorization error`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 404, + description: `Not found`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 422, + description: `Validation error`, + schema: z + .object({ + message: z.string(), + errors: z.record(z.array(z.string())), + }) + .passthrough(), + }, + ], + }, { method: 'put', path: '/v1/organizations/:organization/time-entries/:timeEntry', @@ -1841,12 +1959,12 @@ Users with the permission `time-entries:view:own` can only use this en { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, { name: 'timeEntry', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z.object({ data: TimeEntryResource }).passthrough(), @@ -1898,12 +2016,12 @@ Users with the permission `time-entries:view:own` can only use this en { name: 'organization', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, { name: 'timeEntry', type: 'Path', - schema: z.string().uuid(), + schema: z.string(), }, ], response: z.null(), @@ -1920,6 +2038,145 @@ Users with the permission `time-entries:view:own` can only use this en }, ], }, + { + method: 'get', + path: '/v1/organizations/:organization/time-entries/aggregate', + alias: 'getAggregatedTimeEntries', + description: `This endpoint allows you to filter time entries and aggregate them by different criteria. +The parameters `group` and `sub_group` allow you to group the time entries by different criteria. +If the group parameters are all set to `null` or are all missing, the endpoint will aggregate all filtered time entries.`, + requestFormat: 'json', + parameters: [ + { + name: 'organization', + type: 'Path', + schema: z.string(), + }, + { + name: 'group', + type: 'Query', + schema: group, + }, + { + name: 'sub_group', + type: 'Query', + schema: group, + }, + { + name: 'member_id', + type: 'Query', + schema: z.string().uuid().optional(), + }, + { + name: 'user_id', + type: 'Query', + schema: z.string().uuid().optional(), + }, + { + name: 'before', + type: 'Query', + schema: before, + }, + { + name: 'after', + type: 'Query', + schema: before, + }, + { + name: 'active', + type: 'Query', + schema: z.enum(['true', 'false']).optional(), + }, + { + name: 'billable', + type: 'Query', + schema: z.enum(['true', 'false']).optional(), + }, + { + name: 'member_ids', + type: 'Query', + schema: z.array(z.string()).min(1).optional(), + }, + { + name: 'project_ids', + type: 'Query', + schema: z.array(z.string()).min(1).optional(), + }, + { + name: 'tag_ids', + type: 'Query', + schema: z.array(z.string()).min(1).optional(), + }, + { + name: 'task_ids', + type: 'Query', + schema: z.array(z.string()).min(1).optional(), + }, + ], + response: z + .object({ + data: z + .object({ + grouped_data: z.union([ + z.array( + z + .object({ + type: z.string(), + key: z.union([z.string(), z.null()]), + seconds: z.number().int(), + cost: z.number().int(), + grouped_data: z.union([ + z.array( + z + .object({ + type: z.string(), + key: z.union([ + z.string(), + z.null(), + ]), + seconds: z + .number() + .int(), + cost: z.number().int(), + }) + .passthrough() + ), + z.null(), + ]), + }) + .passthrough() + ), + z.null(), + ]), + seconds: z.number().int(), + cost: z.number().int(), + }) + .passthrough(), + }) + .passthrough(), + errors: [ + { + status: 403, + description: `Authorization error`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 404, + description: `Not found`, + schema: z.object({ message: z.string() }).passthrough(), + }, + { + status: 422, + description: `Validation error`, + schema: z + .object({ + message: z.string(), + errors: z.record(z.array(z.string())), + }) + .passthrough(), + }, + ], + }, { method: 'get', path: '/v1/users/me/time-entries/active', diff --git a/package-lock.json b/package-lock.json index 508cfc1a..3175d14d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "html", + "name": "solidtime", "lockfileVersion": 3, "requires": true, "packages": { @@ -19,7 +19,7 @@ "pinia": "^2.1.7", "radix-vue": "^1.5.2", "tailwind-merge": "^2.2.1", - "vue-echarts": "^6.6.9" + "vue-echarts": "^6.7.2" }, "devDependencies": { "@inertiajs/vue3": "^1.0.0", @@ -5735,9 +5735,9 @@ } }, "node_modules/vue-echarts": { - "version": "6.6.9", - "resolved": "https://registry.npmjs.org/vue-echarts/-/vue-echarts-6.6.9.tgz", - "integrity": "sha512-mojIq3ZvsjabeVmDthhAUDV8Kgf2Rr/X4lV4da7gEFd1fP05gcSJ0j7wa7HQkW5LlFmF2gdCJ8p4Chas6NNIQQ==", + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/vue-echarts/-/vue-echarts-6.7.2.tgz", + "integrity": "sha512-SG8Vmszhx24KjtySsk361DogZLRkPCyLhgoyh7iN1eH3WGJ0kyl3k0g4QiSJqK0+F1Ej0HDopq4A5OGcBlAwzw==", "hasInstallScript": true, "dependencies": { "resize-detector": "^0.3.0", diff --git a/package.json b/package.json index 835beac3..ab99128c 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,6 @@ "pinia": "^2.1.7", "radix-vue": "^1.5.2", "tailwind-merge": "^2.2.1", - "vue-echarts": "^6.6.9" + "vue-echarts": "^6.7.2" } } diff --git a/resources/css/app.css b/resources/css/app.css index 8236cc91..c055ca05 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -2,16 +2,40 @@ @tailwind components; @tailwind utilities; +:root { + --color-bg-primary: #0f1011; + --color-bg-secondary: #1b1c20; + --color-bg-tertiary: #2A2C32; + --color-bg-quaternary: #141518; + --color-text-primary: #ffffff; + --color-text-secondary: #e3e4e6; + --color-text-tertiary: #969799; + --color-text-quaternary: #595a5c; + --color-border-primary: #191b1f; + --color-border-secondary: #23252a; + --color-border-tertiary: #2c2e33; + --color-border-quaternary: #393B42; + --color-input-border-active: rgba(255,255,255,0.3); -:root{ - --theme-color-default-background: #0b0d1c; - --theme-color-icon-default: #42466C; - --theme-color-card-background: #13152B; - --theme-color-card-background-active: #1C1E34; - --theme-color-card-background-separator: #1c2033; - --theme-color-card-border: #1c2033; - --theme-color-card-border-active: #2A3461; - --theme-color-default-background-separator: #141a2f; + --color-accent-primary: 14, 165, 233; /* sky-500 */ + --color-accent-secondary: 56, 189, 248; + --color-accent-tertiary: 125, 211, 252; + --color-accent-quaternary: 186, 230, 253; + + --theme-color-default-background: var(--color-bg-primary); + --theme-color-icon-default: var(--color-text-tertiary); + --theme-color-icon-active: rgb(var(--color-text-tertiary)); + --theme-color-card-background: var(--color-bg-secondary); + --theme-color-card-background-active: var(--color-bg-tertiary); + --theme-color-card-background-separator: var(--color-border-quaternary); + --theme-color-card-border: var(--color-border-secondary); + --theme-color-card-border-active: var(--color-border-tertiary); + --theme-color-default-background-separator: var(--color-border-primary); + --theme-color-primary-text: var(--color-text-primary); + --theme-color-muted-text: var(--color-text-secondary); + --theme-color-menu-active: var(--color-bg-secondary); + --theme-color-input-border: var(--color-border-quaternary); + --theme-color-input-background: var(--color-bg-secondary); --theme-color-tab-background: var(--theme-color-card-background); --theme-color-tab-background-active: var(--theme-color-card-background-active); --theme-color-tab-border: var(--theme-color-card-border); @@ -21,17 +45,15 @@ --theme-color-row-heading-border: var(--theme-color-card-border); } -*{ +* { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } - [x-cloak] { display: none; } - -body{ +body { background-color: var(--theme-color-default-background); } diff --git a/resources/js/Components/Common/Badge.vue b/resources/js/Components/Common/Badge.vue index 52c32983..1bb6d429 100644 --- a/resources/js/Components/Common/Badge.vue +++ b/resources/js/Components/Common/Badge.vue @@ -37,10 +37,10 @@ const borderClasses = computed(() => { :is="tag" :class=" twMerge( - props.class, badgeClasses[size], borderClasses, - 'rounded inline-flex items-center font-semibold text-white' + 'rounded inline-flex items-center font-semibold text-white', + props.class ) "> diff --git a/resources/js/Components/Common/BillableRateInput.vue b/resources/js/Components/Common/BillableRateInput.vue index 7bba4cce..f43277da 100644 --- a/resources/js/Components/Common/BillableRateInput.vue +++ b/resources/js/Components/Common/BillableRateInput.vue @@ -6,6 +6,10 @@ import { getOrganizationCurrencySymbol, } from '../../utils/money'; +defineProps<{ + name: string; +}>(); + const model = defineModel({ default: null, type: Number, @@ -51,13 +55,14 @@ function formatCents(modelValue: number) { diff --git a/resources/js/Components/Common/GroupedItemsCountButton.vue b/resources/js/Components/Common/GroupedItemsCountButton.vue new file mode 100644 index 00000000..1a8d66e9 --- /dev/null +++ b/resources/js/Components/Common/GroupedItemsCountButton.vue @@ -0,0 +1,37 @@ + + + + + diff --git a/resources/js/Components/Common/Icons/BillableIcon.vue b/resources/js/Components/Common/Icons/BillableIcon.vue new file mode 100644 index 00000000..e2a32170 --- /dev/null +++ b/resources/js/Components/Common/Icons/BillableIcon.vue @@ -0,0 +1,14 @@ + + + + + diff --git a/resources/js/Components/Common/Member/MemberCombobox.vue b/resources/js/Components/Common/Member/MemberCombobox.vue index 83714877..8a40ed54 100644 --- a/resources/js/Components/Common/Member/MemberCombobox.vue +++ b/resources/js/Components/Common/Member/MemberCombobox.vue @@ -37,7 +37,7 @@ const filteredMembers = computed(() => { .toLowerCase() .includes(searchValue.value?.toLowerCase()?.trim() || '') && !props.hiddenMembers.some( - (hiddenMember) => hiddenMember.user_id === member.user_id + (hiddenMember) => hiddenMember.id === member.id ) && member.is_placeholder === false ); @@ -54,7 +54,7 @@ onMounted(() => { function resetHighlightedItem() { if (filteredMembers.value.length > 0) { - highlightedItemId.value = filteredMembers.value[0].user_id; + highlightedItemId.value = filteredMembers.value[0].id; } } @@ -65,10 +65,10 @@ function updateSearchValue(event: Event) { const highlightedClientId = highlightedItemId.value; if (highlightedClientId) { const highlightedClient = members.value.find( - (member) => member.user_id === highlightedClientId + (member) => member.id === highlightedClientId ); if (highlightedClient) { - model.value = highlightedClient.user_id; + model.value = highlightedClient.id; } } } else { @@ -94,10 +94,10 @@ function moveHighlightUp() { ); if (currentHightlightedIndex === 0) { highlightedItemId.value = - filteredMembers.value[filteredMembers.value.length - 1].user_id; + filteredMembers.value[filteredMembers.value.length - 1].id; } else { highlightedItemId.value = - filteredMembers.value[currentHightlightedIndex - 1].user_id; + filteredMembers.value[currentHightlightedIndex - 1].id; } } } @@ -108,10 +108,10 @@ function moveHighlightDown() { highlightedItem.value ); if (currentHightlightedIndex === filteredMembers.value.length - 1) { - highlightedItemId.value = filteredMembers.value[0].user_id; + highlightedItemId.value = filteredMembers.value[0].id; } else { highlightedItemId.value = - filteredMembers.value[currentHightlightedIndex + 1].user_id; + filteredMembers.value[currentHightlightedIndex + 1].id; } } } @@ -119,14 +119,13 @@ function moveHighlightDown() { const highlightedItemId = ref(null); const highlightedItem = computed(() => { return members.value.find( - (member) => member.user_id === highlightedItemId.value + (member) => member.id === highlightedItemId.value ); }); const currentValue = computed(() => { if (model.value) { - return members.value.find((member) => member.user_id === model.value) - ?.name; + return members.value.find((member) => member.id === model.value)?.name; } return searchValue.value; }); @@ -186,18 +185,18 @@ function onUnfocus() {
+ :data-client-id="member.id">
diff --git a/resources/js/Components/Common/Member/MemberMultiselectDropdown.vue b/resources/js/Components/Common/Member/MemberMultiselectDropdown.vue new file mode 100644 index 00000000..5d332749 --- /dev/null +++ b/resources/js/Components/Common/Member/MemberMultiselectDropdown.vue @@ -0,0 +1,29 @@ + + + diff --git a/resources/js/Components/Common/Member/MemberTableRow.vue b/resources/js/Components/Common/Member/MemberTableRow.vue index 56c4d3ab..dc4d64a4 100644 --- a/resources/js/Components/Common/Member/MemberTableRow.vue +++ b/resources/js/Components/Common/Member/MemberTableRow.vue @@ -29,7 +29,7 @@ async function invitePlaceholder(id: string) { { params: { organization: organizationId, - membership: id, + member: id, }, } ), diff --git a/resources/js/Components/Common/Project/ProjectColorSelector.vue b/resources/js/Components/Common/Project/ProjectColorSelector.vue index afa1f2c4..4b8fc1de 100644 --- a/resources/js/Components/Common/Project/ProjectColorSelector.vue +++ b/resources/js/Components/Common/Project/ProjectColorSelector.vue @@ -6,15 +6,17 @@ const model = defineModel({ default: '' });