From 1d70e75152bcbaea6c3f736e8c02a32a5a420f5e Mon Sep 17 00:00:00 2001 From: Regis Freyd Date: Tue, 2 Mar 2021 22:49:42 -0500 Subject: [PATCH 01/47] feat: notion of groups --- app/Helpers/LogHelper.php | 7 ++ app/Models/Company/Company.php | 10 +++ app/Models/Company/Employee.php | 10 +++ app/Models/Company/Group.php | 43 ++++++++++ app/Services/Company/Group/CreateGroup.php | 77 +++++++++++++++++ database/factories/Company/GroupFactory.php | 32 +++++++ .../2021_03_03_031835_create_groups_table.php | 33 ++++++++ resources/lang/en/account.php | 1 + tests/Unit/Models/Company/CompanyTest.php | 12 +++ tests/Unit/Models/Company/EmployeeTest.php | 14 ++++ tests/Unit/Models/Company/GroupTest.php | 33 ++++++++ .../Company/Group/CreateGroupTest.php | 84 +++++++++++++++++++ 12 files changed, 356 insertions(+) create mode 100644 app/Models/Company/Group.php create mode 100644 app/Services/Company/Group/CreateGroup.php create mode 100644 database/factories/Company/GroupFactory.php create mode 100644 database/migrations/2021_03_03_031835_create_groups_table.php create mode 100644 tests/Unit/Models/Company/GroupTest.php create mode 100644 tests/Unit/Services/Company/Group/CreateGroupTest.php diff --git a/app/Helpers/LogHelper.php b/app/Helpers/LogHelper.php index 93f2e19d7..e2fb5d061 100644 --- a/app/Helpers/LogHelper.php +++ b/app/Helpers/LogHelper.php @@ -1068,6 +1068,13 @@ public static function processAuditLog(AuditLog $log): string $sentence = trans('account.log_toggle_e_coffee_process'); break; + case 'group_created': + $sentence = trans('account.log_group_created', [ + 'group_id' => $log->object->{'group_id'}, + 'group_name' => $log->object->{'group_name'}, + ]); + break; + default: $sentence = ''; break; diff --git a/app/Models/Company/Company.php b/app/Models/Company/Company.php index a40589e8e..cda37629b 100644 --- a/app/Models/Company/Company.php +++ b/app/Models/Company/Company.php @@ -238,6 +238,16 @@ public function importJobs() return $this->hasMany(ImportJob::class); } + /** + * Get all groups in the company. + * + * @return HasMany + */ + public function groups() + { + return $this->hasMany(Group::class); + } + /** * Return the PTO policy for the current year. * diff --git a/app/Models/Company/Employee.php b/app/Models/Company/Employee.php index cd8fb827d..29c68fd3d 100644 --- a/app/Models/Company/Employee.php +++ b/app/Models/Company/Employee.php @@ -538,6 +538,16 @@ public function timesheetsAsApprover() return $this->hasMany(Timesheet::class, 'approver_id', 'id'); } + /** + * Get the group records associated with the employee. + * + * @return BelongsToMany + */ + public function groups() + { + return $this->belongsToMany(Group::class); + } + /** * Scope a query to only include unlocked users. * diff --git a/app/Models/Company/Group.php b/app/Models/Company/Group.php new file mode 100644 index 000000000..17f073142 --- /dev/null +++ b/app/Models/Company/Group.php @@ -0,0 +1,43 @@ +belongsTo(Company::class); + } + + /** + * Get the employee records associated with the group. + * + * @return BelongsToMany + */ + public function employees() + { + return $this->belongsToMany(Employee::class); + } +} diff --git a/app/Services/Company/Group/CreateGroup.php b/app/Services/Company/Group/CreateGroup.php new file mode 100644 index 000000000..ebdda41da --- /dev/null +++ b/app/Services/Company/Group/CreateGroup.php @@ -0,0 +1,77 @@ + 'required|integer|exists:companies,id', + 'author_id' => 'required|integer|exists:employees,id', + 'name' => 'required|string|max:255', + ]; + } + + /** + * Create a group. + * + * @param array $data + * @return Group + */ + public function execute(array $data): Group + { + $this->data = $data; + $this->validate(); + $this->createGroup(); + $this->log(); + + return $this->group; + } + + private function validate(): void + { + $this->validateRules($this->data); + + $this->author($this->data['author_id']) + ->inCompany($this->data['company_id']) + ->asNormalUser() + ->canExecuteService(); + } + + private function createGroup(): void + { + $this->group = Group::create([ + 'company_id' => $this->data['company_id'], + 'name' => $this->data['name'], + ]); + } + + private function log(): void + { + LogAccountAudit::dispatch([ + 'company_id' => $this->data['company_id'], + 'action' => 'group_created', + 'author_id' => $this->author->id, + 'author_name' => $this->author->name, + 'audited_at' => Carbon::now(), + 'objects' => json_encode([ + 'group_id' => $this->group->id, + 'group_name' => $this->group->name, + ]), + ])->onQueue('low'); + } +} diff --git a/database/factories/Company/GroupFactory.php b/database/factories/Company/GroupFactory.php new file mode 100644 index 000000000..4966d6764 --- /dev/null +++ b/database/factories/Company/GroupFactory.php @@ -0,0 +1,32 @@ +create(); + + return [ + 'company_id' => $company->id, + 'name' => 'Group name', + ]; + } +} diff --git a/database/migrations/2021_03_03_031835_create_groups_table.php b/database/migrations/2021_03_03_031835_create_groups_table.php new file mode 100644 index 000000000..0e7933ea7 --- /dev/null +++ b/database/migrations/2021_03_03_031835_create_groups_table.php @@ -0,0 +1,33 @@ +id(); + $table->unsignedBigInteger('company_id'); + $table->string('name'); + $table->timestamps(); + $table->foreign('company_id')->references('id')->on('companies')->onDelete('cascade'); + }); + + Schema::create('employee_group', function (Blueprint $table) { + $table->unsignedBigInteger('employee_id'); + $table->unsignedBigInteger('group_id'); + $table->timestamps(); + $table->foreign('employee_id')->references('id')->on('employees')->onDelete('cascade'); + $table->foreign('group_id')->references('id')->on('groups')->onDelete('cascade'); + }); + } +} diff --git a/resources/lang/en/account.php b/resources/lang/en/account.php index fd08e8c0a..b9c7e4e06 100644 --- a/resources/lang/en/account.php +++ b/resources/lang/en/account.php @@ -290,6 +290,7 @@ 'log_consultant_rate_destroy' => 'Destroyed the consulting rate of :rate for :employee_name.', 'log_e_coffee_match_session_as_happened' => 'Mark an eCoffee session as happened between :employee_name and :other_employee_name.', 'log_toggle_e_coffee_process' => 'Toggle the eCoffee process in the company.', + 'log_group_created' => 'Created the group called :group_name.', // employee logs 'employee_log_employee_created' => 'Created this employee entry.', diff --git a/tests/Unit/Models/Company/CompanyTest.php b/tests/Unit/Models/Company/CompanyTest.php index 77593a4fa..1cd91096c 100644 --- a/tests/Unit/Models/Company/CompanyTest.php +++ b/tests/Unit/Models/Company/CompanyTest.php @@ -6,6 +6,7 @@ use Tests\TestCase; use App\Models\Company\Flow; use App\Models\Company\Team; +use App\Models\Company\Group; use App\Models\Company\Skill; use App\Models\Company\Company; use App\Models\Company\ECoffee; @@ -239,6 +240,17 @@ public function it_has_many_import_jobs(): void $this->assertTrue($company->importJobs()->exists()); } + /** @test */ + public function it_has_many_groups(): void + { + $company = Company::factory()->create(); + Group::factory()->create([ + 'company_id' => $company->id, + ]); + + $this->assertTrue($company->groups()->exists()); + } + /** @test */ public function it_returns_the_pto_policy_for_the_current_year(): void { diff --git a/tests/Unit/Models/Company/EmployeeTest.php b/tests/Unit/Models/Company/EmployeeTest.php index a4d02ce91..abb03d502 100644 --- a/tests/Unit/Models/Company/EmployeeTest.php +++ b/tests/Unit/Models/Company/EmployeeTest.php @@ -9,6 +9,7 @@ use App\Models\Company\Task; use App\Models\Company\Team; use App\Models\User\Pronoun; +use App\Models\Company\Group; use App\Models\Company\Place; use App\Models\Company\Skill; use App\Models\Company\Answer; @@ -479,6 +480,19 @@ public function it_has_many_consultant_rates(): void $this->assertTrue($dwight->consultantRates()->exists()); } + /** @test */ + public function it_has_many_groups(): void + { + $dwight = Employee::factory()->create([]); + $group = Group::factory()->create([ + 'company_id' => $dwight->company_id, + ]); + + $dwight->groups()->sync([$group->id]); + + $this->assertTrue($dwight->groups()->exists()); + } + /** @test */ public function it_scopes_the_employees_by_the_locked_status(): void { diff --git a/tests/Unit/Models/Company/GroupTest.php b/tests/Unit/Models/Company/GroupTest.php new file mode 100644 index 000000000..8c3203467 --- /dev/null +++ b/tests/Unit/Models/Company/GroupTest.php @@ -0,0 +1,33 @@ +create([]); + $this->assertTrue($group->company()->exists()); + } + + /** @test */ + public function it_has_many_employees(): void + { + $group = Group::factory()->create(); + $dwight = factory(Employee::class)->create([ + 'company_id' => $group->company_id, + ]); + + $group->employees()->syncWithoutDetaching([$dwight->id]); + + $this->assertTrue($group->employees()->exists()); + } +} diff --git a/tests/Unit/Services/Company/Group/CreateGroupTest.php b/tests/Unit/Services/Company/Group/CreateGroupTest.php new file mode 100644 index 000000000..84102237a --- /dev/null +++ b/tests/Unit/Services/Company/Group/CreateGroupTest.php @@ -0,0 +1,84 @@ +createAdministrator(); + $this->executeService($michael); + } + + /** @test */ + public function it_creates_a_group_as_hr(): void + { + $michael = $this->createHR(); + $this->executeService($michael); + } + + /** @test */ + public function it_creates_a_group_as_normal_user(): void + { + $michael = $this->createEmployee(); + $this->executeService($michael); + } + + /** @test */ + public function it_fails_if_wrong_parameters_are_given(): void + { + $michael = factory(Employee::class)->create([]); + + $request = [ + 'company_id' => $michael->company_id, + ]; + + $this->expectException(ValidationException::class); + (new CreateProject)->execute($request); + } + + private function executeService(Employee $michael): void + { + Queue::fake(); + + $request = [ + 'company_id' => $michael->company_id, + 'author_id' => $michael->id, + 'name' => 'Steering Commitee', + ]; + + $group = (new CreateGroup)->execute($request); + + $this->assertDatabaseHas('groups', [ + 'id' => $group->id, + 'name' => 'Steering Commitee', + ]); + + $this->assertInstanceOf( + Group::class, + $group + ); + + Queue::assertPushed(LogAccountAudit::class, function ($job) use ($michael, $group) { + return $job->auditLog['action'] === 'group_created' && + $job->auditLog['author_id'] === $michael->id && + $job->auditLog['objects'] === json_encode([ + 'group_id' => $group->id, + 'group_name' => $group->name, + ]); + }); + } +} From 5bc523987e0fc27a752d80aaffaad062065eda87 Mon Sep 17 00:00:00 2001 From: Regis Freyd Date: Thu, 4 Mar 2021 18:47:13 -0500 Subject: [PATCH 02/47] wip --- app/Helpers/LogHelper.php | 38 +++ .../Company/Company/Group/GroupController.php | 146 +++++++++ .../Company/Group/GroupMembersController.php | 141 ++++++++ .../Company/Project/ProjectController.php | 3 +- .../Company/Team/TeamRecentShipController.php | 2 +- .../Company/Group/GroupMembersViewHelper.php | 83 +++++ .../Company/Group/GroupShowViewHelper.php | 44 +++ app/Jobs/AttachEmployeeToGroup.php | 41 +++ app/Models/Company/Employee.php | 2 +- app/Models/Company/Group.php | 2 +- app/Models/Company/Meeting.php | 52 +++ .../Company/Group/AddEmployeeToGroup.php | 105 ++++++ app/Services/Company/Group/CreateGroup.php | 19 ++ app/Services/Company/Group/DestroyGroup.php | 74 +++++ .../Company/Group/RemoveEmployeeFromGroup.php | 97 ++++++ .../2021_03_03_031835_create_groups_table.php | 30 ++ .../Adminland/Employee/Archives/Show.vue | 46 +-- resources/js/Pages/Company/Group/Create.vue | 241 ++++++++++++++ .../js/Pages/Company/Group/Members/Index.vue | 309 ++++++++++++++++++ .../Company/Group/Partials/GroupMenu.vue | 67 ++++ resources/js/Pages/Company/Group/Show.vue | 85 +++++ resources/lang/en/account.php | 11 + resources/lang/en/app.php | 3 + resources/lang/en/group.php | 13 + routes/web.php | 16 + .../Company/Group/AddEmployeeToGroupTest.php | 135 ++++++++ .../Company/Group/CreateGroupTest.php | 20 +- .../Company/Group/DestroyGroupTest.php | 96 ++++++ .../Group/RemoveEmployeeFromGroupTest.php | 131 ++++++++ .../Group/GroupMembersViewHelperTest.php | 98 ++++++ .../Company/Group/GroupShowViewHelperTest.php | 48 +++ 31 files changed, 2170 insertions(+), 28 deletions(-) create mode 100644 app/Http/Controllers/Company/Company/Group/GroupController.php create mode 100644 app/Http/Controllers/Company/Company/Group/GroupMembersController.php create mode 100644 app/Http/ViewHelpers/Company/Group/GroupMembersViewHelper.php create mode 100644 app/Http/ViewHelpers/Company/Group/GroupShowViewHelper.php create mode 100644 app/Jobs/AttachEmployeeToGroup.php create mode 100644 app/Models/Company/Meeting.php create mode 100644 app/Services/Company/Group/AddEmployeeToGroup.php create mode 100644 app/Services/Company/Group/DestroyGroup.php create mode 100644 app/Services/Company/Group/RemoveEmployeeFromGroup.php create mode 100644 resources/js/Pages/Company/Group/Create.vue create mode 100644 resources/js/Pages/Company/Group/Members/Index.vue create mode 100644 resources/js/Pages/Company/Group/Partials/GroupMenu.vue create mode 100644 resources/js/Pages/Company/Group/Show.vue create mode 100644 resources/lang/en/group.php create mode 100644 tests/Unit/Services/Company/Group/AddEmployeeToGroupTest.php create mode 100644 tests/Unit/Services/Company/Group/DestroyGroupTest.php create mode 100644 tests/Unit/Services/Company/Group/RemoveEmployeeFromGroupTest.php create mode 100644 tests/Unit/ViewHelpers/Company/Group/GroupMembersViewHelperTest.php create mode 100644 tests/Unit/ViewHelpers/Company/Group/GroupShowViewHelperTest.php diff --git a/app/Helpers/LogHelper.php b/app/Helpers/LogHelper.php index e2fb5d061..080badcb5 100644 --- a/app/Helpers/LogHelper.php +++ b/app/Helpers/LogHelper.php @@ -1075,6 +1075,30 @@ public static function processAuditLog(AuditLog $log): string ]); break; + case 'employee_added_to_group': + $sentence = trans('account.log_employee_added_to_group', [ + 'employee_id' => $log->object->{'employee_id'}, + 'employee_name' => $log->object->{'employee_name'}, + 'group_id' => $log->object->{'group_id'}, + 'group_name' => $log->object->{'group_name'}, + ]); + break; + + case 'employee_removed_from_group': + $sentence = trans('account.log_employee_removed_from_group', [ + 'employee_id' => $log->object->{'employee_id'}, + 'employee_name' => $log->object->{'employee_name'}, + 'group_id' => $log->object->{'group_id'}, + 'group_name' => $log->object->{'group_name'}, + ]); + break; + + case 'group_destroyed': + $sentence = trans('account.log_group_destroyed', [ + 'group_name' => $log->object->{'group_name'}, + ]); + break; + default: $sentence = ''; break; @@ -1569,6 +1593,20 @@ public static function processEmployeeLog(EmployeeLog $log): string ]); break; + case 'employee_added_to_group': + $sentence = trans('account.employee_log_employee_added_to_group', [ + 'group_id' => $log->object->{'group_id'}, + 'group_name' => $log->object->{'group_name'}, + ]); + break; + + case 'employee_removed_from_group': + $sentence = trans('account.employee_log_employee_removed_from_group', [ + 'group_id' => $log->object->{'group_id'}, + 'group_name' => $log->object->{'group_name'}, + ]); + break; + default: $sentence = ''; break; diff --git a/app/Http/Controllers/Company/Company/Group/GroupController.php b/app/Http/Controllers/Company/Company/Group/GroupController.php new file mode 100644 index 000000000..105a98bff --- /dev/null +++ b/app/Http/Controllers/Company/Company/Group/GroupController.php @@ -0,0 +1,146 @@ + $statistics, + 'tab' => 'projects', + 'projects' => ProjectViewHelper::index($company), + 'notifications' => NotificationHelper::getNotifications(InstanceHelper::getLoggedEmployee()), + ]); + } + + /** + * Display the group. + * + * @param Request $request + * @param int $companyId + * @param int $groupId + * @return Response + */ + public function show(Request $request, int $companyId, int $groupId): Response + { + $company = InstanceHelper::getLoggedCompany(); + $group = Group::where('company_id', $company->id) + ->findOrFail($groupId); + + return Inertia::render('Company/Group/Show', [ + 'group' => GroupShowViewHelper::information($group, $company), + 'tab' => 'info', + 'notifications' => NotificationHelper::getNotifications(InstanceHelper::getLoggedEmployee()), + ]); + } + + /** + * Display the create new group form. + * + * @param Request $request + * @param int $companyId + * @return Response + */ + public function create(Request $request, int $companyId): Response + { + return Inertia::render('Company/Group/Create', [ + 'notifications' => NotificationHelper::getNotifications(InstanceHelper::getLoggedEmployee()), + ]); + } + + /** + * Search an employee to add as a team lead. + * + * @param Request $request + * @param int $companyId + * @return JsonResponse + */ + public function search(Request $request, int $companyId): JsonResponse + { + $potentialEmployees = Employee::search( + $request->input('searchTerm'), + $companyId, + 10, + 'created_at desc', + 'and locked = false', + ); + + $employees = collect([]); + foreach ($potentialEmployees as $employee) { + $employees->push([ + 'id' => $employee->id, + 'name' => $employee->name, + 'avatar' => $employee->avatar, + ]); + } + + return response()->json([ + 'data' => $employees, + ], 200); + } + + /** + * Actually create the new group. + * + * @param Request $request + * @param int $companyId + * @return JsonResponse + */ + public function store(Request $request, int $companyId): JsonResponse + { + $loggedEmployee = InstanceHelper::getLoggedEmployee(); + $loggedCompany = InstanceHelper::getLoggedCompany(); + + $employees = null; + if ($request->input('employees')) { + $employees = []; + foreach ($request->input('employees') as $employee) { + array_push($employees, $employee['id']); + } + } + + $data = [ + 'company_id' => $loggedCompany->id, + 'author_id' => $loggedEmployee->id, + 'name' => $request->input('name'), + 'employees' => $employees, + ]; + + $group = (new CreateGroup)->execute($data); + + return response()->json([ + 'data' => [ + 'id' => $group->id, + 'url' => route('groups.show', [ + 'company' => $loggedCompany, + 'group' => $group, + ]), + ], + ], 201); + } +} diff --git a/app/Http/Controllers/Company/Company/Group/GroupMembersController.php b/app/Http/Controllers/Company/Company/Group/GroupMembersController.php new file mode 100644 index 000000000..8f1ef6847 --- /dev/null +++ b/app/Http/Controllers/Company/Company/Group/GroupMembersController.php @@ -0,0 +1,141 @@ +id) + ->findOrFail($groupId); + } catch (ModelNotFoundException $e) { + return redirect('home'); + } + + $members = GroupMembersViewHelper::members($group); + $info = GroupShowViewHelper::information($group, $company); + + return Inertia::render('Company/Group/Members/Index', [ + 'members' => $members, + 'tab' => 'members', + 'group' => $info, + 'notifications' => NotificationHelper::getNotifications(InstanceHelper::getLoggedEmployee()), + ]); + } + + /** + * Get the list of potential new members for this group. + * + * @param Request $request + * @param int $companyId + * @param int $groupId + * @return JsonResponse + */ + public function search(Request $request, int $companyId, int $groupId): JsonResponse + { + $company = InstanceHelper::getLoggedCompany(); + + try { + $group = Group::where('company_id', $company->id) + ->findOrFail($groupId); + } catch (ModelNotFoundException $e) { + return redirect('home'); + } + + $potentialMembers = GroupMembersViewHelper::potentialMembers( + $request->input('searchTerm'), + $group, + $company + ); + + return response()->json([ + 'data' => $potentialMembers, + ]); + } + + /** + * Add the member to the group. + * + * @param Request $request + * @param int $companyId + * @param int $groupId + * @return JsonResponse + */ + public function store(Request $request, int $companyId, int $groupId): JsonResponse + { + $loggedCompany = InstanceHelper::getLoggedCompany(); + $loggedEmployee = InstanceHelper::getLoggedEmployee(); + + $group = Group::where('company_id', $loggedCompany->id) + ->findOrFail($groupId); + + $employee = (new AddEmployeeToGroup)->execute([ + 'company_id' => $loggedCompany->id, + 'author_id' => $loggedEmployee->id, + 'group_id' => $group->id, + 'employee_id' => $request->input('employee'), + 'role' => null, + ]); + + return response()->json([ + 'data' => [ + 'id' => $employee->id, + 'name' => $employee->name, + 'avatar' => $employee->avatar, + ], + ]); + } + + /** + * Remove the member from the group. + * + * @param Request $request + * @param int $companyId + * @param int $groupId + * @return JsonResponse + */ + public function remove(Request $request, int $companyId, int $groupId): JsonResponse + { + $loggedCompany = InstanceHelper::getLoggedCompany(); + $loggedEmployee = InstanceHelper::getLoggedEmployee(); + + $group = Group::where('company_id', $loggedCompany->id) + ->findOrFail($groupId); + + (new RemoveEmployeeFromGroup)->execute([ + 'company_id' => $loggedCompany->id, + 'author_id' => $loggedEmployee->id, + 'employee_id' => $request->input('employee'), + 'group_id' => $group->id, + ]); + + return response()->json([ + 'data' => true, + ]); + } +} diff --git a/app/Http/Controllers/Company/Company/Project/ProjectController.php b/app/Http/Controllers/Company/Company/Project/ProjectController.php index eca8d1909..376b6ed33 100644 --- a/app/Http/Controllers/Company/Company/Project/ProjectController.php +++ b/app/Http/Controllers/Company/Company/Project/ProjectController.php @@ -63,7 +63,8 @@ public function show(Request $request, int $companyId, int $projectId): Response { $company = InstanceHelper::getLoggedCompany(); $employee = InstanceHelper::getLoggedEmployee(); - $project = Project::findOrFail($projectId); + $project = Project::where('company_id', $company->id) + ->findOrFail($projectId); return Inertia::render('Company/Project/Show', [ 'project' => ProjectViewHelper::info($project), diff --git a/app/Http/Controllers/Company/Team/TeamRecentShipController.php b/app/Http/Controllers/Company/Team/TeamRecentShipController.php index 174f4adcc..3bdd63057 100644 --- a/app/Http/Controllers/Company/Team/TeamRecentShipController.php +++ b/app/Http/Controllers/Company/Team/TeamRecentShipController.php @@ -56,7 +56,7 @@ public function index(Request $request, int $companyId, int $teamId) } /** - * Search an employee to add as a team lead. + * Search an employee to add to the recent ship entry. * * @param Request $request * @param int $companyId diff --git a/app/Http/ViewHelpers/Company/Group/GroupMembersViewHelper.php b/app/Http/ViewHelpers/Company/Group/GroupMembersViewHelper.php new file mode 100644 index 000000000..610475d9d --- /dev/null +++ b/app/Http/ViewHelpers/Company/Group/GroupMembersViewHelper.php @@ -0,0 +1,83 @@ +employees() + ->where('locked', false) + ->with('position') + ->orderBy('pivot_created_at', 'desc') + ->get(); + + $membersCollection = collect([]); + foreach ($members as $member) { + $membersCollection->push([ + 'id' => $member->id, + 'name' => $member->name, + 'avatar' => $member->avatar, + 'added_at' => DateHelper::formatDate($member->pivot->created_at), + 'position' => (! $member->position) ? null : [ + 'id' => $member->position->id, + 'title' => $member->position->title, + ], + 'url' => route('employees.show', [ + 'company' => $group->company_id, + 'employee' => $member, + ]), + ]); + } + + return $membersCollection; + } + + /** + * Returns the potential employees that can be assigned as members of the + * group, matching the name. + * This filters out the current members of the group (doh). + * + * @param string $employeeName + * @param Group $group + * @param Company $company + * @return Collection + */ + public static function potentialMembers(string $employeeName, Group $group, Company $company): Collection + { + $potentialEmployees = Employee::search( + $employeeName, + $company->id, + 10, + 'created_at desc', + 'and locked = false', + ); + + $currentMembers = $group->employees; + + $potentialMembers = $potentialEmployees->diff($currentMembers); + + $potentialMembersCollection = collect([]); + foreach ($potentialMembers as $potential) { + $potentialMembersCollection->push([ + 'id' => $potential->id, + 'name' => $potential->name, + 'avatar' => $potential->avatar, + ]); + } + + return $potentialMembersCollection; + } +} diff --git a/app/Http/ViewHelpers/Company/Group/GroupShowViewHelper.php b/app/Http/ViewHelpers/Company/Group/GroupShowViewHelper.php new file mode 100644 index 000000000..0def6b9d0 --- /dev/null +++ b/app/Http/ViewHelpers/Company/Group/GroupShowViewHelper.php @@ -0,0 +1,44 @@ +employees; + + $membersCollection = collect([]); + foreach ($groupMembers as $employee) { + $membersCollection->push([ + 'id' => $employee->id, + 'name' => $employee->name, + 'avatar' => $employee->avatar, + 'position' => (! $employee->position) ? null : [ + 'id' => $employee->position->id, + 'title' => $employee->position->title, + ], + 'url' => route('employees.show', [ + 'company' => $company, + 'employee' => $employee, + ]), + ]); + } + + return [ + 'id' => $group->id, + 'name' => $group->name, + 'members' => $membersCollection, + ]; + } +} diff --git a/app/Jobs/AttachEmployeeToGroup.php b/app/Jobs/AttachEmployeeToGroup.php new file mode 100644 index 000000000..85351d50b --- /dev/null +++ b/app/Jobs/AttachEmployeeToGroup.php @@ -0,0 +1,41 @@ +data = $data; + } + + /** + * Execute the job. + */ + public function handle(): void + { + (new AddEmployeeToGroup)->execute([ + 'company_id' => $this->data['company_id'], + 'author_id' => $this->data['author_id'], + 'employee_id' => $this->data['employee_id'], + 'group_id' => $this->data['group_id'], + ]); + } +} diff --git a/app/Models/Company/Employee.php b/app/Models/Company/Employee.php index 29c68fd3d..5f666439a 100644 --- a/app/Models/Company/Employee.php +++ b/app/Models/Company/Employee.php @@ -545,7 +545,7 @@ public function timesheetsAsApprover() */ public function groups() { - return $this->belongsToMany(Group::class); + return $this->belongsToMany(Group::class)->withTimestamps(); } /** diff --git a/app/Models/Company/Group.php b/app/Models/Company/Group.php index 17f073142..cd30153e9 100644 --- a/app/Models/Company/Group.php +++ b/app/Models/Company/Group.php @@ -38,6 +38,6 @@ public function company() */ public function employees() { - return $this->belongsToMany(Employee::class); + return $this->belongsToMany(Employee::class)->withTimestamps(); } } diff --git a/app/Models/Company/Meeting.php b/app/Models/Company/Meeting.php new file mode 100644 index 000000000..37999fbbc --- /dev/null +++ b/app/Models/Company/Meeting.php @@ -0,0 +1,52 @@ +belongsTo(Company::class); + } + + /** + * Get the employee records associated with the group. + * + * @return BelongsToMany + */ + public function employees() + { + return $this->belongsToMany(Employee::class)->withTimestamps(); + } +} diff --git a/app/Services/Company/Group/AddEmployeeToGroup.php b/app/Services/Company/Group/AddEmployeeToGroup.php new file mode 100644 index 000000000..24debbada --- /dev/null +++ b/app/Services/Company/Group/AddEmployeeToGroup.php @@ -0,0 +1,105 @@ + 'required|integer|exists:companies,id', + 'author_id' => 'required|integer|exists:employees,id', + 'group_id' => 'required|integer|exists:groups,id', + 'employee_id' => 'required|integer|exists:employees,id', + 'role' => 'nullable|string', + ]; + } + + /** + * Add an employee to a group. + * + * @param array $data + * @return Employee + */ + public function execute(array $data): Employee + { + $this->data = $data; + $this->validate(); + + $this->attachEmployee(); + $this->log(); + + return $this->employee; + } + + private function validate(): void + { + $this->validateRules($this->data); + + $this->author($this->data['author_id']) + ->inCompany($this->data['company_id']) + ->asNormalUser() + ->canExecuteService(); + + $this->employee = $this->validateEmployeeBelongsToCompany($this->data); + + $this->group = Group::where('company_id', $this->data['company_id']) + ->findOrFail($this->data['group_id']); + } + + private function attachEmployee(): void + { + $this->group->employees()->syncWithoutDetaching([ + $this->data['employee_id'] => [ + 'role' => $this->valueOrNull($this->data, 'role'), + ], + ]); + } + + private function log(): void + { + LogAccountAudit::dispatch([ + 'company_id' => $this->data['company_id'], + 'action' => 'employee_added_to_group', + 'author_id' => $this->author->id, + 'author_name' => $this->author->name, + 'audited_at' => Carbon::now(), + 'objects' => json_encode([ + 'group_id' => $this->group->id, + 'group_name' => $this->group->name, + 'employee_id' => $this->employee->id, + 'employee_name' => $this->employee->name, + ]), + ])->onQueue('low'); + + LogEmployeeAudit::dispatch([ + 'employee_id' => $this->employee->id, + 'action' => 'employee_added_to_group', + 'author_id' => $this->author->id, + 'author_name' => $this->author->name, + 'audited_at' => Carbon::now(), + 'objects' => json_encode([ + 'group_id' => $this->group->id, + 'group_name' => $this->group->name, + ]), + ])->onQueue('low'); + } +} diff --git a/app/Services/Company/Group/CreateGroup.php b/app/Services/Company/Group/CreateGroup.php index ebdda41da..1b3defeb6 100644 --- a/app/Services/Company/Group/CreateGroup.php +++ b/app/Services/Company/Group/CreateGroup.php @@ -6,6 +6,7 @@ use App\Jobs\LogAccountAudit; use App\Models\Company\Group; use App\Services\BaseService; +use App\Jobs\AttachEmployeeToGroup; class CreateGroup extends BaseService { @@ -23,6 +24,7 @@ public function rules(): array 'company_id' => 'required|integer|exists:companies,id', 'author_id' => 'required|integer|exists:employees,id', 'name' => 'required|string|max:255', + 'employees' => 'nullable|array', ]; } @@ -37,6 +39,7 @@ public function execute(array $data): Group $this->data = $data; $this->validate(); $this->createGroup(); + $this->attachEmployees(); $this->log(); return $this->group; @@ -60,6 +63,22 @@ private function createGroup(): void ]); } + private function attachEmployees(): void + { + if (! $this->data['employees']) { + return; + } + + foreach ($this->data['employees'] as $key => $employeeId) { + AttachEmployeeToGroup::dispatch([ + 'company_id' => $this->data['company_id'], + 'author_id' => $this->data['author_id'], + 'employee_id' => $employeeId, + 'group_id' => $this->group->id, + ])->onQueue('low'); + } + } + private function log(): void { LogAccountAudit::dispatch([ diff --git a/app/Services/Company/Group/DestroyGroup.php b/app/Services/Company/Group/DestroyGroup.php new file mode 100644 index 000000000..fcc58763e --- /dev/null +++ b/app/Services/Company/Group/DestroyGroup.php @@ -0,0 +1,74 @@ + 'required|integer|exists:companies,id', + 'author_id' => 'required|integer|exists:employees,id', + 'group_id' => 'nullable|integer|exists:groups,id', + ]; + } + + /** + * Delete a group. + * + * @param array $data + */ + public function execute(array $data): void + { + $this->data = $data; + $this->validate(); + $this->destroy(); + $this->log(); + } + + private function validate(): void + { + $this->validateRules($this->data); + + $this->author($this->data['author_id']) + ->inCompany($this->data['company_id']) + ->asNormalUser() + ->canExecuteService(); + + // make sure the group belongs to the company + $this->group = Group::where('company_id', $this->data['company_id']) + ->findOrFail($this->data['group_id']); + } + + private function destroy(): void + { + $this->group->delete(); + } + + private function log(): void + { + LogAccountAudit::dispatch([ + 'company_id' => $this->data['company_id'], + 'action' => 'group_destroyed', + 'author_id' => $this->author->id, + 'author_name' => $this->author->name, + 'audited_at' => Carbon::now(), + 'objects' => json_encode([ + 'group_name' => $this->group->name, + ]), + ])->onQueue('low'); + } +} diff --git a/app/Services/Company/Group/RemoveEmployeeFromGroup.php b/app/Services/Company/Group/RemoveEmployeeFromGroup.php new file mode 100644 index 000000000..9415eaeae --- /dev/null +++ b/app/Services/Company/Group/RemoveEmployeeFromGroup.php @@ -0,0 +1,97 @@ + 'required|integer|exists:companies,id', + 'author_id' => 'required|integer|exists:employees,id', + 'employee_id' => 'required|integer|exists:employees,id', + 'group_id' => 'required|integer|exists:groups,id', + ]; + } + + /** + * Detach an employee from a group. + * + * @param array $data + */ + public function execute(array $data): void + { + $this->data = $data; + $this->validate(); + $this->detachEmployee(); + $this->log(); + } + + private function validate(): void + { + $this->validateRules($this->data); + + $this->author($this->data['author_id']) + ->inCompany($this->data['company_id']) + ->asAtLeastHR() + ->canBypassPermissionLevelIfEmployee($this->data['employee_id']) + ->canExecuteService(); + + $this->employee = $this->validateEmployeeBelongsToCompany($this->data); + + $this->group = Group::where('company_id', $this->data['company_id']) + ->findOrFail($this->data['group_id']); + } + + private function detachEmployee(): void + { + $this->group->employees()->detach($this->data['employee_id']); + } + + private function log(): void + { + LogAccountAudit::dispatch([ + 'company_id' => $this->data['company_id'], + 'action' => 'employee_removed_from_group', + 'author_id' => $this->author->id, + 'author_name' => $this->author->name, + 'audited_at' => Carbon::now(), + 'objects' => json_encode([ + 'group_id' => $this->group->id, + 'group_name' => $this->group->name, + 'employee_id' => $this->employee->id, + 'employee_name' => $this->employee->name, + ]), + ])->onQueue('low'); + + LogEmployeeAudit::dispatch([ + 'employee_id' => $this->employee->id, + 'action' => 'employee_removed_from_group', + 'author_id' => $this->author->id, + 'author_name' => $this->author->name, + 'audited_at' => Carbon::now(), + 'objects' => json_encode([ + 'group_id' => $this->group->id, + 'group_name' => $this->group->name, + ]), + ])->onQueue('low'); + } +} diff --git a/database/migrations/2021_03_03_031835_create_groups_table.php b/database/migrations/2021_03_03_031835_create_groups_table.php index 0e7933ea7..5a23a1319 100644 --- a/database/migrations/2021_03_03_031835_create_groups_table.php +++ b/database/migrations/2021_03_03_031835_create_groups_table.php @@ -25,9 +25,39 @@ public function up() Schema::create('employee_group', function (Blueprint $table) { $table->unsignedBigInteger('employee_id'); $table->unsignedBigInteger('group_id'); + $table->string('role')->nullable(); $table->timestamps(); $table->foreign('employee_id')->references('id')->on('employees')->onDelete('cascade'); $table->foreign('group_id')->references('id')->on('groups')->onDelete('cascade'); }); + + Schema::create('group_meetings', function (Blueprint $table) { + $table->unsignedBigInteger('group_id'); + $table->unsignedBigInteger('meeting_id'); + $table->timestamps(); + $table->foreign('group_id')->references('id')->on('groups')->onDelete('cascade'); + $table->foreign('meeting_id')->references('id')->on('meetings')->onDelete('cascade'); + }); + + Schema::create('meetings', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('company_id'); + $table->datetime('happened_at'); + $table->timestamps(); + $table->foreign('company_id')->references('id')->on('companies')->onDelete('cascade'); + }); + + Schema::create('agenda_items', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('group_meeting_id'); + $table->boolean('checked')->default(false); + $table->string('summary'); + $table->text('description')->nullable(); + $table->boolean('follow_up_next_time')->default(false); + $table->unsignedBigInteger('presented_by_id')->nullable(); + $table->timestamps(); + $table->foreign('group_meeting_id')->references('id')->on('group_meetings')->onDelete('cascade'); + $table->foreign('presented_by_id')->references('id')->on('employees')->onDelete('set null'); + }); } } diff --git a/resources/js/Pages/Adminland/Employee/Archives/Show.vue b/resources/js/Pages/Adminland/Employee/Archives/Show.vue index 62fea4177..d5cdf7a40 100644 --- a/resources/js/Pages/Adminland/Employee/Archives/Show.vue +++ b/resources/js/Pages/Adminland/Employee/Archives/Show.vue @@ -122,35 +122,35 @@
-

First five of the {{ report.number_of_entries }} entries of the file

+

{{ $t('account.import_employees_show_first_five_entries', {count: report.number_of_entries }) }}

- Email + {{ $t('account.import_employees_show_email') }}
- First name + {{ $t('account.import_employees_show_firstname') }}
- Last name + {{ $t('account.import_employees_show_lastname') }}
- Status + {{ $t('account.import_employees_show_status') }}
-
+
- {{ report.employee_email }} + {{ entry.employee_email }}
- {{ report.employee_first_name }} + {{ entry.employee_first_name }}
- {{ report.employee_last_name }} + {{ entry.employee_last_name }}
-
+
- {{ report.skipped_during_upload_reason }} + {{ entry.skipped_during_upload_reason }}
@@ -162,26 +162,26 @@
-

All {{ report.failed_entries.length }} entries in error in the file

+

{{ $t('account.import_employees_show_entries_errors', { count: report.failed_entries.length }) }}

- Email + {{ $t('account.import_employees_show_email') }}
- First name + {{ $t('account.import_employees_show_firstname') }}
- Last name + {{ $t('account.import_employees_show_lastname') }}
- Status + {{ $t('account.import_employees_show_status') }}
-
+
-
- {{ report.employee_email }} +
+ {{ entry.employee_email }}
({{ $t('account.import_employees_archives_finalize_email_missing') }}) @@ -189,18 +189,18 @@
- {{ report.employee_first_name }} + {{ entry.employee_first_name }}
- {{ report.employee_last_name }} + {{ entry.employee_last_name }}
-
+
- {{ report.skipped_during_upload_reason }} + {{ entry.skipped_during_upload_reason }}
diff --git a/resources/js/Pages/Company/Group/Create.vue b/resources/js/Pages/Company/Group/Create.vue new file mode 100644 index 000000000..2db170e44 --- /dev/null +++ b/resources/js/Pages/Company/Group/Create.vue @@ -0,0 +1,241 @@ + + + + + diff --git a/resources/js/Pages/Company/Group/Members/Index.vue b/resources/js/Pages/Company/Group/Members/Index.vue new file mode 100644 index 000000000..bec9a6bc2 --- /dev/null +++ b/resources/js/Pages/Company/Group/Members/Index.vue @@ -0,0 +1,309 @@ + + + + + diff --git a/resources/js/Pages/Company/Group/Partials/GroupMenu.vue b/resources/js/Pages/Company/Group/Partials/GroupMenu.vue new file mode 100644 index 000000000..d2b2c8cff --- /dev/null +++ b/resources/js/Pages/Company/Group/Partials/GroupMenu.vue @@ -0,0 +1,67 @@ + + + + + diff --git a/resources/js/Pages/Company/Group/Show.vue b/resources/js/Pages/Company/Group/Show.vue new file mode 100644 index 000000000..b54f33087 --- /dev/null +++ b/resources/js/Pages/Company/Group/Show.vue @@ -0,0 +1,85 @@ + + + + + diff --git a/resources/lang/en/account.php b/resources/lang/en/account.php index b9c7e4e06..84af2ca34 100644 --- a/resources/lang/en/account.php +++ b/resources/lang/en/account.php @@ -291,6 +291,9 @@ 'log_e_coffee_match_session_as_happened' => 'Mark an eCoffee session as happened between :employee_name and :other_employee_name.', 'log_toggle_e_coffee_process' => 'Toggle the eCoffee process in the company.', 'log_group_created' => 'Created the group called :group_name.', + 'log_employee_added_to_group' => 'Added :employee_name to the group called :group_name.', + 'log_employee_removed_from_group' => 'Removed :employee_name from the group called :group_name.', + 'log_group_destroyed' => 'Deleted the group called :group_name.', // employee logs 'employee_log_employee_created' => 'Created this employee entry.', @@ -364,6 +367,8 @@ 'employee_log_employee_avatar_set' => 'Set a new avatar.', 'employee_log_consultant_rate_set' => 'Set the consulting rate to :rate.', 'employee_log_consultant_rate_destroyed' => 'Destroyed the rate of :rate.', + 'employee_log_employee_added_to_group' => 'Has been added to the group called :group_name.', + 'employee_log_employee_removed_from_group' => 'Has been remove from the group called :group_name.', // team logs 'team_log_team_created' => 'Created the team.', @@ -557,6 +562,12 @@ 'import_employees_show_title_number_entries_errors' => 'Entries in errors', 'import_employees_show_title_number_entries_import' => 'Entries we can import', 'import_employees_import_success' => 'Employees have been imported', + 'import_employees_show_email' => 'Email', + 'import_employees_show_firstname' => 'First name', + 'import_employees_show_lastname' => 'Last name', + 'import_employees_show_status' => 'Status', + 'import_employees_show_first_five_entries' => 'First five of the {count} entries of the file', + 'import_employees_show_entries_errors' => 'All {count} entries in error in the file', 'cancel_account_title' => 'Are you sure you want to cancel your account?', 'cancel_account_thanks' => 'Thanks for giving OfficeLife a try!', diff --git a/resources/lang/en/app.php b/resources/lang/en/app.php index 7b0db3d98..34a323bee 100644 --- a/resources/lang/en/app.php +++ b/resources/lang/en/app.php @@ -130,6 +130,9 @@ 'breadcrumb_account_manage_past_archives' => 'Past imports', 'breadcrumb_account_manage_past_archives_detail' => 'Detail of a past import', 'breadcrumb_account_manage_cancel_account' => 'Cancel the account', + 'breadcrumb_group_list' => 'Group list', + 'breadcrumb_group_create' => 'Create a group', + 'breadcrumb_group_detail' => 'Detail of a group', 'header_welcome' => 'Welcome', 'header_home' => 'Home', diff --git a/resources/lang/en/group.php b/resources/lang/en/group.php new file mode 100644 index 000000000..b53c8e49f --- /dev/null +++ b/resources/lang/en/group.php @@ -0,0 +1,13 @@ + 'Create a group', + 'create_input_name' => 'Give the group a name', + 'create_input_name_help' => 'A group can be a commitee, a chapter/guild (agile) or anything else.', + 'create_members' => 'Who should be part of this group?', + 'create_members_help' => 'Type the first letters of the employee', + + 'members_add_cta' => 'Add a new member', + 'members_add_placeholder' => 'Enter the first letters of a name', + 'members_index_blank' => ' Groups are more fun with employees. Add an employee now.', +]; diff --git a/routes/web.php b/routes/web.php index 5b0922c6c..56a8fada8 100644 --- a/routes/web.php +++ b/routes/web.php @@ -318,6 +318,22 @@ Route::post('{project}/tasks/{task}/log', 'Company\\Company\\Project\\ProjectTasksController@logTime'); }); + Route::prefix('groups')->group(function () { + Route::get('', 'Company\\Company\\Group\\GroupController@index'); + Route::get('create', 'Company\\Company\\Group\\GroupController@create'); + Route::post('', 'Company\\Company\\Group\\GroupController@store'); + Route::post('search', 'Company\\Company\\Group\\GroupController@search'); + + // group detail + Route::get('{group}', 'Company\\Company\\Group\\GroupController@show')->name('groups.show'); + + // members + Route::get('{group}/members', 'Company\\Company\\Group\\GroupMembersController@index')->name('groups.members.index'); + Route::post('{group}/members/search', 'Company\\Company\\Group\\GroupMembersController@search'); + Route::post('{group}/members/store', 'Company\\Company\\Group\\GroupMembersController@store'); + Route::post('{group}/members/remove', 'Company\\Company\\Group\\GroupMembersController@remove'); + }); + Route::prefix('hr')->group(function () { Route::get('', 'Company\\Company\\HR\\CompanyHRController@index'); }); diff --git a/tests/Unit/Services/Company/Group/AddEmployeeToGroupTest.php b/tests/Unit/Services/Company/Group/AddEmployeeToGroupTest.php new file mode 100644 index 000000000..2b8b5e421 --- /dev/null +++ b/tests/Unit/Services/Company/Group/AddEmployeeToGroupTest.php @@ -0,0 +1,135 @@ +createAdministrator(); + $dwight = $this->createAnotherEmployee($michael); + $group = Group::factory()->create([ + 'company_id' => $michael->company_id, + ]); + $this->executeService($michael, $dwight, $group); + } + + /** @test */ + public function it_adds_an_employee_to_a_group_as_hr(): void + { + $michael = $this->createHR(); + $dwight = $this->createAnotherEmployee($michael); + $group = Group::factory()->create([ + 'company_id' => $michael->company_id, + ]); + $this->executeService($michael, $dwight, $group); + } + + /** @test */ + public function it_adds_an_employee_to_a_group_as_normal_user(): void + { + $michael = $this->createEmployee(); + $group = Group::factory()->create([ + 'company_id' => $michael->company_id, + ]); + $this->executeService($michael, $michael, $group); + } + + /** @test */ + public function it_fails_if_wrong_parameters_are_given(): void + { + $request = [ + 'first_name' => 'Dwight', + ]; + + $this->expectException(ValidationException::class); + (new AddEmployeeToGroup)->execute($request); + } + + /** @test */ + public function it_fails_if_the_employee_is_not_in_the_authors_company(): void + { + $michael = $this->createAdministrator(); + $dwight = $this->createEmployee(); + $group = Group::factory()->create([ + 'company_id' => $michael->company_id, + ]); + + $this->expectException(ModelNotFoundException::class); + $this->executeService($michael, $dwight, $group); + } + + /** @test */ + public function it_fails_if_the_project_is_not_in_the_company(): void + { + $michael = $this->createAdministrator(); + $dwight = $this->createEmployee(); + $group = Group::factory()->create(); + + $this->expectException(ModelNotFoundException::class); + $this->executeService($michael, $dwight, $group); + } + + private function executeService(Employee $michael, Employee $dwight, Group $group): void + { + Queue::fake(); + + $request = [ + 'company_id' => $michael->company_id, + 'author_id' => $michael->id, + 'group_id' => $group->id, + 'employee_id' => $dwight->id, + ]; + + $employee = (new AddEmployeeToGroup)->execute($request); + + $this->assertDatabaseHas('groups', [ + 'id' => $group->id, + 'company_id' => $dwight->company_id, + ]); + + $this->assertDatabaseHas('employee_group', [ + 'group_id' => $group->id, + 'employee_id' => $dwight->id, + ]); + + $this->assertInstanceOf( + Employee::class, + $employee + ); + + Queue::assertPushed(LogAccountAudit::class, function ($job) use ($michael, $group, $dwight) { + return $job->auditLog['action'] === 'employee_added_to_group' && + $job->auditLog['author_id'] === $michael->id && + $job->auditLog['objects'] === json_encode([ + 'group_id' => $group->id, + 'group_name' => $group->name, + 'employee_id' => $dwight->id, + 'employee_name' => $dwight->name, + ]); + }); + + Queue::assertPushed(LogEmployeeAudit::class, function ($job) use ($michael, $group) { + return $job->auditLog['action'] === 'employee_added_to_group' && + $job->auditLog['author_id'] === $michael->id && + $job->auditLog['objects'] === json_encode([ + 'group_id' => $group->id, + 'group_name' => $group->name, + ]); + }); + } +} diff --git a/tests/Unit/Services/Company/Group/CreateGroupTest.php b/tests/Unit/Services/Company/Group/CreateGroupTest.php index 84102237a..d9e232f04 100644 --- a/tests/Unit/Services/Company/Group/CreateGroupTest.php +++ b/tests/Unit/Services/Company/Group/CreateGroupTest.php @@ -6,6 +6,7 @@ use App\Jobs\LogAccountAudit; use App\Models\Company\Group; use App\Models\Company\Employee; +use App\Jobs\AttachEmployeeToGroup; use Illuminate\Support\Facades\Queue; use App\Services\Company\Group\CreateGroup; use Illuminate\Validation\ValidationException; @@ -23,6 +24,18 @@ public function it_creates_a_group_as_administrator(): void $this->executeService($michael); } + /** @test */ + public function it_creates_a_group_as_administrator_and_associate_employees(): void + { + $michael = $this->createAdministrator(); + $andrew = $this->createAnotherEmployee($michael); + $john = $this->createAnotherEmployee($michael); + + $employees = [$andrew->id, $john->id]; + + $this->executeService($michael, $employees); + } + /** @test */ public function it_creates_a_group_as_hr(): void { @@ -50,7 +63,7 @@ public function it_fails_if_wrong_parameters_are_given(): void (new CreateProject)->execute($request); } - private function executeService(Employee $michael): void + private function executeService(Employee $michael, array $employees = null): void { Queue::fake(); @@ -58,6 +71,7 @@ private function executeService(Employee $michael): void 'company_id' => $michael->company_id, 'author_id' => $michael->id, 'name' => 'Steering Commitee', + 'employees' => $employees, ]; $group = (new CreateGroup)->execute($request); @@ -80,5 +94,9 @@ private function executeService(Employee $michael): void 'group_name' => $group->name, ]); }); + + if ($employees) { + Queue::assertPushed(AttachEmployeeToGroup::class, 2); + } } } diff --git a/tests/Unit/Services/Company/Group/DestroyGroupTest.php b/tests/Unit/Services/Company/Group/DestroyGroupTest.php new file mode 100644 index 000000000..4bb7fb831 --- /dev/null +++ b/tests/Unit/Services/Company/Group/DestroyGroupTest.php @@ -0,0 +1,96 @@ +createAdministrator(); + $group = Group::factory()->create([ + 'company_id' => $michael->company_id, + ]); + $this->executeService($michael, $group); + } + + /** @test */ + public function it_destroys_a_group_as_hr(): void + { + $michael = $this->createHR(); + $group = Group::factory()->create([ + 'company_id' => $michael->company_id, + ]); + $this->executeService($michael, $group); + } + + /** @test */ + public function it_destroys_a_group_as_normal_user(): void + { + $michael = $this->createEmployee(); + $group = Group::factory()->create([ + 'company_id' => $michael->company_id, + ]); + $this->executeService($michael, $group); + } + + /** @test */ + public function it_fails_if_project_is_not_part_of_the_company(): void + { + $michael = factory(Employee::class)->create([]); + $group = Group::factory()->create(); + + $this->expectException(ModelNotFoundException::class); + $this->executeService($michael, $group); + } + + /** @test */ + public function it_fails_if_wrong_parameters_are_given(): void + { + $michael = factory(Employee::class)->create([]); + + $request = [ + 'company_id' => $michael->company_id, + ]; + + $this->expectException(ValidationException::class); + (new DestroyGroup)->execute($request); + } + + private function executeService(Employee $michael, Group $group = null): void + { + Queue::fake(); + + $request = [ + 'company_id' => $michael->company_id, + 'author_id' => $michael->id, + 'group_id' => $group->id, + ]; + + (new DestroyGroup)->execute($request); + + $this->assertDatabaseMissing('groups', [ + 'id' => $group->id, + ]); + + Queue::assertPushed(LogAccountAudit::class, function ($job) use ($michael, $group) { + return $job->auditLog['action'] === 'group_destroyed' && + $job->auditLog['author_id'] === $michael->id && + $job->auditLog['objects'] === json_encode([ + 'group_name' => $group->name, + ]); + }); + } +} diff --git a/tests/Unit/Services/Company/Group/RemoveEmployeeFromGroupTest.php b/tests/Unit/Services/Company/Group/RemoveEmployeeFromGroupTest.php new file mode 100644 index 000000000..a8e80fa2e --- /dev/null +++ b/tests/Unit/Services/Company/Group/RemoveEmployeeFromGroupTest.php @@ -0,0 +1,131 @@ +createAdministrator(); + $dwight = $this->createAnotherEmployee($michael); + $group = Group::factory()->create([ + 'company_id' => $michael->company_id, + ]); + $group->employees()->attach([$dwight->id]); + + $this->executeService($michael, $dwight, $group); + } + + /** @test */ + public function it_removes_an_employee_as_hr(): void + { + $michael = $this->createHR(); + $dwight = $this->createAnotherEmployee($michael); + $group = Group::factory()->create([ + 'company_id' => $michael->company_id, + ]); + $group->employees()->attach([$dwight->id]); + + $this->executeService($michael, $dwight, $group); + } + + /** @test */ + public function it_removes_a_project_as_normal_user(): void + { + $michael = $this->createEmployee(); + $group = Group::factory()->create([ + 'company_id' => $michael->company_id, + ]); + $group->employees()->attach([$michael->id]); + + $this->executeService($michael, $michael, $group); + } + + /** @test */ + public function it_fails_if_wrong_parameters_are_given(): void + { + $request = [ + 'first_name' => 'Dwight', + ]; + + $this->expectException(ValidationException::class); + (new RemoveEmployeeFromGroup)->execute($request); + } + + /** @test */ + public function it_fails_if_the_employee_is_not_in_the_authors_company(): void + { + $michael = $this->createAdministrator(); + $dwight = $this->createEmployee(); + $group = Group::factory()->create([ + 'company_id' => $michael->company_id, + ]); + + $this->expectException(ModelNotFoundException::class); + $this->executeService($michael, $dwight, $group); + } + + /** @test */ + public function it_fails_if_the_project_is_not_in_the_authors_company(): void + { + $michael = $this->createAdministrator(); + $dwight = $this->createAnotherEmployee($michael); + $group = Group::factory()->create(); + + $this->expectException(ModelNotFoundException::class); + $this->executeService($michael, $dwight, $group); + } + + private function executeService(Employee $michael, Employee $dwight, Group $group): void + { + Queue::fake(); + + $request = [ + 'company_id' => $michael->company_id, + 'author_id' => $michael->id, + 'employee_id' => $dwight->id, + 'group_id' => $group->id, + ]; + + (new RemoveEmployeeFromGroup)->execute($request); + + $this->assertDatabaseMissing('employee_group', [ + 'group_id' => $group->id, + 'employee_id' => $dwight->id, + ]); + + Queue::assertPushed(LogAccountAudit::class, function ($job) use ($michael, $group, $dwight) { + return $job->auditLog['action'] === 'employee_removed_from_group' && + $job->auditLog['author_id'] === $michael->id && + $job->auditLog['objects'] === json_encode([ + 'group_id' => $group->id, + 'group_name' => $group->name, + 'employee_id' => $dwight->id, + 'employee_name' => $dwight->name, + ]); + }); + + Queue::assertPushed(LogEmployeeAudit::class, function ($job) use ($michael, $group) { + return $job->auditLog['action'] === 'employee_removed_from_group' && + $job->auditLog['author_id'] === $michael->id && + $job->auditLog['objects'] === json_encode([ + 'group_id' => $group->id, + 'group_name' => $group->name, + ]); + }); + } +} diff --git a/tests/Unit/ViewHelpers/Company/Group/GroupMembersViewHelperTest.php b/tests/Unit/ViewHelpers/Company/Group/GroupMembersViewHelperTest.php new file mode 100644 index 000000000..3b56d84b0 --- /dev/null +++ b/tests/Unit/ViewHelpers/Company/Group/GroupMembersViewHelperTest.php @@ -0,0 +1,98 @@ +createAdministrator(); + $jim = $this->createAnotherEmployee($michael); + $group = Group::factory()->create([ + 'company_id' => $michael->company_id, + ]); + $group->employees()->attach([ + $michael->id, + ]); + $group->employees()->attach([ + $jim->id, + ]); + + $collection = GroupMembersViewHelper::members($group); + $this->assertEquals( + [ + 0 => [ + 'id' => $michael->id, + 'name' => $michael->name, + 'avatar' => $michael->avatar, + 'added_at' => DateHelper::formatDate($michael->created_at), + 'position' => [ + 'id' => $michael->position->id, + 'title' => $michael->position->title, + ], + 'url' => env('APP_URL').'/'.$michael->company_id.'/employees/'.$michael->id, + ], + 1 => [ + 'id' => $jim->id, + 'name' => $jim->name, + 'avatar' => $jim->avatar, + 'added_at' => DateHelper::formatDate($jim->created_at), + 'position' => [ + 'id' => $jim->position->id, + 'title' => $jim->position->title, + ], + 'url' => env('APP_URL').'/'.$jim->company_id.'/employees/'.$jim->id, + ], + ], + $collection->toArray() + ); + } + + /** @test */ + public function it_gets_a_collection_of_potential_new_members(): void + { + $michael = $this->createAdministrator(); + $jim = $this->createAnotherEmployee($michael); + $jean = Employee::factory()->create([ + 'first_name' => 'jean', + 'company_id' => $michael->company_id, + ]); + $group = Group::factory()->create([ + 'company_id' => $michael->company_id, + ]); + $group->employees()->attach([ + $michael->id, + ]); + $group->employees()->attach([ + $jim->id, + ]); + + $collection = GroupMembersViewHelper::potentialMembers('je', $group, $michael->company); + $this->assertEquals( + [ + 0 => [ + 'id' => $jean->id, + 'name' => $jean->name, + 'avatar' => $jean->avatar, + ], + ], + $collection->toArray() + ); + + $collection = GroupMembersViewHelper::potentialMembers('roger', $group, $michael->company); + $this->assertEquals( + [], + $collection->toArray() + ); + } +} diff --git a/tests/Unit/ViewHelpers/Company/Group/GroupShowViewHelperTest.php b/tests/Unit/ViewHelpers/Company/Group/GroupShowViewHelperTest.php new file mode 100644 index 000000000..b03102e17 --- /dev/null +++ b/tests/Unit/ViewHelpers/Company/Group/GroupShowViewHelperTest.php @@ -0,0 +1,48 @@ +createAdministrator(); + $jim = $this->createAnotherEmployee($michael); + $tom = $this->createAnotherEmployee($michael); + $pam = $this->createAnotherEmployee($michael); + $jenny = $this->createAnotherEmployee($michael); + $group = Group::factory()->create([ + 'company_id' => $michael->company_id, + ]); + $group->employees()->attach([$michael->id]); + $group->employees()->attach([$jim->id]); + $group->employees()->attach([$tom->id]); + $group->employees()->attach([$pam->id]); + $group->employees()->attach([$jenny->id]); + + $array = GroupShowViewHelper::information($group, $group->company); + + $this->assertEquals( + $group->id, + $array['id'] + ); + + $this->assertEquals( + $group->name, + $array['name'] + ); + + $this->assertEquals( + 5, + count($array['members']->toArray()) + ); + } +} From c78d1b0b0f86da256bed92a8956f7935fde7e7b6 Mon Sep 17 00:00:00 2001 From: Regis Freyd Date: Thu, 4 Mar 2021 21:16:29 -0500 Subject: [PATCH 03/47] Update 2021_03_03_031835_create_groups_table.php --- .../2021_03_03_031835_create_groups_table.php | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/database/migrations/2021_03_03_031835_create_groups_table.php b/database/migrations/2021_03_03_031835_create_groups_table.php index 5a23a1319..01b6f7754 100644 --- a/database/migrations/2021_03_03_031835_create_groups_table.php +++ b/database/migrations/2021_03_03_031835_create_groups_table.php @@ -31,14 +31,6 @@ public function up() $table->foreign('group_id')->references('id')->on('groups')->onDelete('cascade'); }); - Schema::create('group_meetings', function (Blueprint $table) { - $table->unsignedBigInteger('group_id'); - $table->unsignedBigInteger('meeting_id'); - $table->timestamps(); - $table->foreign('group_id')->references('id')->on('groups')->onDelete('cascade'); - $table->foreign('meeting_id')->references('id')->on('meetings')->onDelete('cascade'); - }); - Schema::create('meetings', function (Blueprint $table) { $table->id(); $table->unsignedBigInteger('company_id'); @@ -47,6 +39,14 @@ public function up() $table->foreign('company_id')->references('id')->on('companies')->onDelete('cascade'); }); + Schema::create('group_meetings', function (Blueprint $table) { + $table->unsignedBigInteger('group_id'); + $table->unsignedBigInteger('meeting_id'); + $table->timestamps(); + $table->foreign('group_id')->references('id')->on('groups')->onDelete('cascade'); + $table->foreign('meeting_id')->references('id')->on('meetings')->onDelete('cascade'); + }); + Schema::create('agenda_items', function (Blueprint $table) { $table->id(); $table->unsignedBigInteger('group_meeting_id'); @@ -56,7 +56,7 @@ public function up() $table->boolean('follow_up_next_time')->default(false); $table->unsignedBigInteger('presented_by_id')->nullable(); $table->timestamps(); - $table->foreign('group_meeting_id')->references('id')->on('group_meetings')->onDelete('cascade'); + $table->foreign('meeting_id')->references('id')->on('meetings')->onDelete('cascade'); $table->foreign('presented_by_id')->references('id')->on('employees')->onDelete('set null'); }); } From 5bf570cbd902252df42fdb699da99f3057ade7fa Mon Sep 17 00:00:00 2001 From: Regis Freyd Date: Thu, 4 Mar 2021 21:22:16 -0500 Subject: [PATCH 04/47] Update 2021_03_03_031835_create_groups_table.php --- database/migrations/2021_03_03_031835_create_groups_table.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/database/migrations/2021_03_03_031835_create_groups_table.php b/database/migrations/2021_03_03_031835_create_groups_table.php index 01b6f7754..8c5b1a924 100644 --- a/database/migrations/2021_03_03_031835_create_groups_table.php +++ b/database/migrations/2021_03_03_031835_create_groups_table.php @@ -49,7 +49,7 @@ public function up() Schema::create('agenda_items', function (Blueprint $table) { $table->id(); - $table->unsignedBigInteger('group_meeting_id'); + $table->unsignedBigInteger('meeting_id'); $table->boolean('checked')->default(false); $table->string('summary'); $table->text('description')->nullable(); From 29ab0b8519e4861d9622a12fbeeb0b80f3d46977 Mon Sep 17 00:00:00 2001 From: Regis Freyd Date: Fri, 5 Mar 2021 07:52:06 -0500 Subject: [PATCH 05/47] fixes --- .../Company/Company/Group/GroupMembersController.php | 3 +-- app/Http/ViewHelpers/Company/Group/GroupMembersViewHelper.php | 2 +- phpstan.neon | 1 + 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/Http/Controllers/Company/Company/Group/GroupMembersController.php b/app/Http/Controllers/Company/Company/Group/GroupMembersController.php index 8f1ef6847..ea7dff5a1 100644 --- a/app/Http/Controllers/Company/Company/Group/GroupMembersController.php +++ b/app/Http/Controllers/Company/Company/Group/GroupMembersController.php @@ -24,9 +24,8 @@ class GroupMembersController extends Controller * @param Request $request * @param int $companyId * @param int $groupId - * @return Response */ - public function index(Request $request, int $companyId, int $groupId): Response + public function index(Request $request, int $companyId, int $groupId) { $company = InstanceHelper::getLoggedCompany(); diff --git a/app/Http/ViewHelpers/Company/Group/GroupMembersViewHelper.php b/app/Http/ViewHelpers/Company/Group/GroupMembersViewHelper.php index 610475d9d..02c9218e0 100644 --- a/app/Http/ViewHelpers/Company/Group/GroupMembersViewHelper.php +++ b/app/Http/ViewHelpers/Company/Group/GroupMembersViewHelper.php @@ -62,7 +62,7 @@ public static function potentialMembers(string $employeeName, Group $group, Comp $company->id, 10, 'created_at desc', - 'and locked = false', + 'and locked = 0', ); $currentMembers = $group->employees; diff --git a/phpstan.neon b/phpstan.neon index 2ab26c6ae..6f38fa1bc 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -11,6 +11,7 @@ parameters: excludePaths: - app/Http/ViewHelpers/Employee/EmployeeShowViewHelper.php - app/Http/ViewHelpers/Company/HR/CompanyHRViewHelper.php + - app/Http/ViewHelpers/Company/Group/GroupMembersViewHelper.php inferPrivatePropertyTypeFromConstructor: true checkMissingIterableValueType: false level: 5 From a53cd60b901a8584b1d6acffbfb62d5ed85becca Mon Sep 17 00:00:00 2001 From: Regis Freyd Date: Fri, 5 Mar 2021 22:45:46 -0500 Subject: [PATCH 06/47] wip --- .../Company/Company/Group/GroupController.php | 40 +++++++ app/Models/Company/Meeting.php | 14 ++- .../2021_03_03_031835_create_groups_table.php | 29 +++-- resources/js/Pages/Company/Group/Delete.vue | 103 ++++++++++++++++++ routes/web.php | 1 + 5 files changed, 176 insertions(+), 11 deletions(-) create mode 100644 resources/js/Pages/Company/Group/Delete.vue diff --git a/app/Http/Controllers/Company/Company/Group/GroupController.php b/app/Http/Controllers/Company/Company/Group/GroupController.php index 105a98bff..27a7ac225 100644 --- a/app/Http/Controllers/Company/Company/Group/GroupController.php +++ b/app/Http/Controllers/Company/Company/Group/GroupController.php @@ -143,4 +143,44 @@ public function store(Request $request, int $companyId): JsonResponse ], ], 201); } + + /** + * Destroy the group. + * + * @param Request $request + * @param int $companyId + * @return JsonResponse + */ + public function destroy(Request $request, int $companyId): JsonResponse + { + $loggedEmployee = InstanceHelper::getLoggedEmployee(); + $loggedCompany = InstanceHelper::getLoggedCompany(); + + $employees = null; + if ($request->input('employees')) { + $employees = []; + foreach ($request->input('employees') as $employee) { + array_push($employees, $employee['id']); + } + } + + $data = [ + 'company_id' => $loggedCompany->id, + 'author_id' => $loggedEmployee->id, + 'name' => $request->input('name'), + 'employees' => $employees, + ]; + + $group = (new CreateGroup)->execute($data); + + return response()->json([ + 'data' => [ + 'id' => $group->id, + 'url' => route('groups.show', [ + 'company' => $loggedCompany, + 'group' => $group, + ]), + ], + ], 201); + } } diff --git a/app/Models/Company/Meeting.php b/app/Models/Company/Meeting.php index 37999fbbc..69a43c13c 100644 --- a/app/Models/Company/Meeting.php +++ b/app/Models/Company/Meeting.php @@ -18,6 +18,7 @@ class Meeting extends Model */ protected $fillable = [ 'company_id', + 'happened', 'happened_at', ]; @@ -31,7 +32,16 @@ class Meeting extends Model ]; /** - * Get the company record associated with the group. + * The attributes that should be cast to native types. + * + * @var array + */ + protected $casts = [ + 'happened' => 'boolean', + ]; + + /** + * Get the company record associated with the meeting. * * @return BelongsTo */ @@ -41,7 +51,7 @@ public function company() } /** - * Get the employee records associated with the group. + * Get the employee records associated with the meeting. * * @return BelongsToMany */ diff --git a/database/migrations/2021_03_03_031835_create_groups_table.php b/database/migrations/2021_03_03_031835_create_groups_table.php index 8c5b1a924..764412179 100644 --- a/database/migrations/2021_03_03_031835_create_groups_table.php +++ b/database/migrations/2021_03_03_031835_create_groups_table.php @@ -34,30 +34,41 @@ public function up() Schema::create('meetings', function (Blueprint $table) { $table->id(); $table->unsignedBigInteger('company_id'); + $table->integer('meetingable_id')->nullable(); + $table->string('meetingable_type')->nullable(); + $table->boolean('happened')->default(false); $table->datetime('happened_at'); $table->timestamps(); $table->foreign('company_id')->references('id')->on('companies')->onDelete('cascade'); }); - Schema::create('group_meetings', function (Blueprint $table) { - $table->unsignedBigInteger('group_id'); - $table->unsignedBigInteger('meeting_id'); - $table->timestamps(); - $table->foreign('group_id')->references('id')->on('groups')->onDelete('cascade'); - $table->foreign('meeting_id')->references('id')->on('meetings')->onDelete('cascade'); - }); - Schema::create('agenda_items', function (Blueprint $table) { $table->id(); $table->unsignedBigInteger('meeting_id'); $table->boolean('checked')->default(false); $table->string('summary'); $table->text('description')->nullable(); - $table->boolean('follow_up_next_time')->default(false); $table->unsignedBigInteger('presented_by_id')->nullable(); $table->timestamps(); $table->foreign('meeting_id')->references('id')->on('meetings')->onDelete('cascade'); $table->foreign('presented_by_id')->references('id')->on('employees')->onDelete('set null'); }); + + Schema::create('meeting_decisions', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('agenda_item_id'); + $table->text('description'); + $table->timestamps(); + $table->foreign('agenda_item_id')->references('id')->on('agenda_items')->onDelete('cascade'); + }); + + Schema::create('employee_meeting', function (Blueprint $table) { + $table->unsignedBigInteger('employee_id'); + $table->unsignedBigInteger('meeting_id'); + $table->boolean('was_a_guest')->default(false); + $table->timestamps(); + $table->foreign('employee_id')->references('id')->on('employees')->onDelete('cascade'); + $table->foreign('meeting_id')->references('id')->on('meetings')->onDelete('cascade'); + }); } } diff --git a/resources/js/Pages/Company/Group/Delete.vue b/resources/js/Pages/Company/Group/Delete.vue new file mode 100644 index 000000000..f38686164 --- /dev/null +++ b/resources/js/Pages/Company/Group/Delete.vue @@ -0,0 +1,103 @@ + + + diff --git a/routes/web.php b/routes/web.php index 56a8fada8..0b2a4868b 100644 --- a/routes/web.php +++ b/routes/web.php @@ -326,6 +326,7 @@ // group detail Route::get('{group}', 'Company\\Company\\Group\\GroupController@show')->name('groups.show'); + Route::delete('{group}', 'Company\\Company\\Group\\GroupController@destroy'); // members Route::get('{group}/members', 'Company\\Company\\Group\\GroupMembersController@index')->name('groups.members.index'); From b6b1e186c9e5044bd63fae4f81a7a03ee2a3050d Mon Sep 17 00:00:00 2001 From: Regis Freyd Date: Sat, 6 Mar 2021 16:50:10 -0500 Subject: [PATCH 07/47] wip --- app/Models/Company/AgendaItem.php | 66 +++++++++++++++++++ app/Models/Company/Employee.php | 20 ++++++ app/Models/Company/Group.php | 10 +++ app/Models/Company/Meeting.php | 20 ++++-- app/Models/Company/MeetingDecision.php | 34 ++++++++++ .../factories/Company/AgendaItemFactory.php | 33 ++++++++++ .../Company/MeetingDecisionFactory.php | 32 +++++++++ database/factories/Company/MeetingFactory.php | 47 +++++++++++++ .../2021_03_03_031835_create_groups_table.php | 8 +-- tests/Unit/Models/Company/AgendaItemTest.php | 31 +++++++++ tests/Unit/Models/Company/EmployeeTest.php | 24 +++++++ tests/Unit/Models/Company/GroupTest.php | 2 +- .../Models/Company/MeetingDecisionTest.php | 19 ++++++ tests/Unit/Models/Company/MeetingTest.php | 45 +++++++++++++ 14 files changed, 380 insertions(+), 11 deletions(-) create mode 100644 app/Models/Company/AgendaItem.php create mode 100644 app/Models/Company/MeetingDecision.php create mode 100644 database/factories/Company/AgendaItemFactory.php create mode 100644 database/factories/Company/MeetingDecisionFactory.php create mode 100644 database/factories/Company/MeetingFactory.php create mode 100644 tests/Unit/Models/Company/AgendaItemTest.php create mode 100644 tests/Unit/Models/Company/MeetingDecisionTest.php create mode 100644 tests/Unit/Models/Company/MeetingTest.php diff --git a/app/Models/Company/AgendaItem.php b/app/Models/Company/AgendaItem.php new file mode 100644 index 000000000..15f850aa6 --- /dev/null +++ b/app/Models/Company/AgendaItem.php @@ -0,0 +1,66 @@ + 'boolean', + ]; + + /** + * Get the meeting record associated with the agenda item. + * + * @return BelongsTo + */ + public function meeting() + { + return $this->belongsTo(Meeting::class); + } + + /** + * Get the employee record associated with the agenda item. + * + * @return HasOne + */ + public function presenter() + { + return $this->hasOne(Employee::class, 'id', 'presented_by_id'); + } + + /** + * Get the decisions associated with the agenda item. + * + * @return HasMany + */ + public function decisions() + { + return $this->hasMany(MeetingDecision::class); + } +} diff --git a/app/Models/Company/Employee.php b/app/Models/Company/Employee.php index 5f666439a..06010a0c1 100644 --- a/app/Models/Company/Employee.php +++ b/app/Models/Company/Employee.php @@ -548,6 +548,26 @@ public function groups() return $this->belongsToMany(Group::class)->withTimestamps(); } + /** + * Get the meeting objects the employee has participated. + * + * @return belongsToMany + */ + public function meetings() + { + return $this->belongsToMany(Meeting::class)->withTimestamps()->withPivot('was_a_guest'); + } + + /** + * Get the agenda item objects presented by the employee. + * + * @return HasMany + */ + public function agendaItems() + { + return $this->hasMany(AgendaItem::class, 'presented_by_id'); + } + /** * Scope a query to only include unlocked users. * diff --git a/app/Models/Company/Group.php b/app/Models/Company/Group.php index cd30153e9..b690f5d86 100644 --- a/app/Models/Company/Group.php +++ b/app/Models/Company/Group.php @@ -40,4 +40,14 @@ public function employees() { return $this->belongsToMany(Employee::class)->withTimestamps(); } + + /** + * Get the meeting records associated with the group. + * + * @return BelongsToMany + */ + public function meetings() + { + return $this->belongsToMany(Employee::class)->withTimestamps(); + } } diff --git a/app/Models/Company/Meeting.php b/app/Models/Company/Meeting.php index 69a43c13c..91bba4c49 100644 --- a/app/Models/Company/Meeting.php +++ b/app/Models/Company/Meeting.php @@ -17,7 +17,7 @@ class Meeting extends Model * @var array */ protected $fillable = [ - 'company_id', + 'group_id', 'happened', 'happened_at', ]; @@ -41,13 +41,13 @@ class Meeting extends Model ]; /** - * Get the company record associated with the meeting. + * Get the group record associated with the meeting. * * @return BelongsTo */ - public function company() + public function group() { - return $this->belongsTo(Company::class); + return $this->belongsTo(Group::class); } /** @@ -57,6 +57,16 @@ public function company() */ public function employees() { - return $this->belongsToMany(Employee::class)->withTimestamps(); + return $this->belongsToMany(Employee::class)->withTimestamps()->withPivot('was_a_guest'); + } + + /** + * Get the agenda item records associated with the meeting. + * + * @return hasMany + */ + public function agendaItems() + { + return $this->hasMany(AgendaItem::class); } } diff --git a/app/Models/Company/MeetingDecision.php b/app/Models/Company/MeetingDecision.php new file mode 100644 index 000000000..bdcaf70c0 --- /dev/null +++ b/app/Models/Company/MeetingDecision.php @@ -0,0 +1,34 @@ +belongsTo(AgendaItem::class); + } +} diff --git a/database/factories/Company/AgendaItemFactory.php b/database/factories/Company/AgendaItemFactory.php new file mode 100644 index 000000000..0e0b68974 --- /dev/null +++ b/database/factories/Company/AgendaItemFactory.php @@ -0,0 +1,33 @@ +create(); + + return [ + 'meeting_id' => $meeting->id, + 'summary' => 'This is the summary', + 'description' => 'This is the description', + ]; + } +} diff --git a/database/factories/Company/MeetingDecisionFactory.php b/database/factories/Company/MeetingDecisionFactory.php new file mode 100644 index 000000000..aa7c5b046 --- /dev/null +++ b/database/factories/Company/MeetingDecisionFactory.php @@ -0,0 +1,32 @@ +create([]); + + return [ + 'agenda_item_id' => $agenda->id, + 'description' => 'This is a description', + ]; + } +} diff --git a/database/factories/Company/MeetingFactory.php b/database/factories/Company/MeetingFactory.php new file mode 100644 index 000000000..790e37a26 --- /dev/null +++ b/database/factories/Company/MeetingFactory.php @@ -0,0 +1,47 @@ +create(); + + return [ + 'group_id' => $group->id, + ]; + } + + /** + * Indicate that the meeting has happened. + * + * @return \Illuminate\Database\Eloquent\Factories\Factory + */ + public function happened() + { + return $this->state(function (array $attributes) { + return [ + 'happened' => true, + 'happened_at' => Carbon::now(), + ]; + }); + } +} diff --git a/database/migrations/2021_03_03_031835_create_groups_table.php b/database/migrations/2021_03_03_031835_create_groups_table.php index 764412179..0c8d6bf22 100644 --- a/database/migrations/2021_03_03_031835_create_groups_table.php +++ b/database/migrations/2021_03_03_031835_create_groups_table.php @@ -33,13 +33,11 @@ public function up() Schema::create('meetings', function (Blueprint $table) { $table->id(); - $table->unsignedBigInteger('company_id'); - $table->integer('meetingable_id')->nullable(); - $table->string('meetingable_type')->nullable(); + $table->unsignedBigInteger('group_id'); $table->boolean('happened')->default(false); - $table->datetime('happened_at'); + $table->datetime('happened_at')->nullable(); $table->timestamps(); - $table->foreign('company_id')->references('id')->on('companies')->onDelete('cascade'); + $table->foreign('group_id')->references('id')->on('groups')->onDelete('cascade'); }); Schema::create('agenda_items', function (Blueprint $table) { diff --git a/tests/Unit/Models/Company/AgendaItemTest.php b/tests/Unit/Models/Company/AgendaItemTest.php new file mode 100644 index 000000000..97408c2b6 --- /dev/null +++ b/tests/Unit/Models/Company/AgendaItemTest.php @@ -0,0 +1,31 @@ +create([]); + $this->assertTrue($agendaItem->meeting()->exists()); + } + + /** @test */ + public function it_has_one_presenter(): void + { + $dwight = Employee::factory()->create(); + $agendaItem = AgendaItem::factory()->create([ + 'presented_by_id' => $dwight->id, + ]); + + $this->assertTrue($agendaItem->presenter()->exists()); + } +} diff --git a/tests/Unit/Models/Company/EmployeeTest.php b/tests/Unit/Models/Company/EmployeeTest.php index abb03d502..3a7e8faf1 100644 --- a/tests/Unit/Models/Company/EmployeeTest.php +++ b/tests/Unit/Models/Company/EmployeeTest.php @@ -16,6 +16,7 @@ use App\Models\Company\Morale; use App\Models\Company\Company; use App\Models\Company\Expense; +use App\Models\Company\Meeting; use App\Models\Company\Project; use App\Models\Company\Worklog; use App\Models\Company\Employee; @@ -23,6 +24,7 @@ use App\Models\Company\Position; use App\Models\Company\TeamNews; use App\Models\Company\Timesheet; +use App\Models\Company\AgendaItem; use App\Models\Company\CompanyNews; use App\Models\Company\EmployeeLog; use App\Models\Company\ProjectTask; @@ -493,6 +495,28 @@ public function it_has_many_groups(): void $this->assertTrue($dwight->groups()->exists()); } + /** @test */ + public function it_belongs_to_many_meetings(): void + { + $dwight = Employee::factory()->create([]); + $meeting = Meeting::factory()->create(); + + $dwight->meetings()->sync([$meeting->id]); + + $this->assertTrue($dwight->meetings()->exists()); + } + + /** @test */ + public function it_has_many_agenda_items(): void + { + $dwight = Employee::factory()->create([]); + AgendaItem::factory()->count(2)->create([ + 'presented_by_id' => $dwight->id, + ]); + + $this->assertTrue($dwight->agendaItems()->exists()); + } + /** @test */ public function it_scopes_the_employees_by_the_locked_status(): void { diff --git a/tests/Unit/Models/Company/GroupTest.php b/tests/Unit/Models/Company/GroupTest.php index 8c3203467..1a2aaf87f 100644 --- a/tests/Unit/Models/Company/GroupTest.php +++ b/tests/Unit/Models/Company/GroupTest.php @@ -22,7 +22,7 @@ public function it_belongs_to_a_company(): void public function it_has_many_employees(): void { $group = Group::factory()->create(); - $dwight = factory(Employee::class)->create([ + $dwight = Employee::factory()->create([ 'company_id' => $group->company_id, ]); diff --git a/tests/Unit/Models/Company/MeetingDecisionTest.php b/tests/Unit/Models/Company/MeetingDecisionTest.php new file mode 100644 index 000000000..9dc05f412 --- /dev/null +++ b/tests/Unit/Models/Company/MeetingDecisionTest.php @@ -0,0 +1,19 @@ +create([]); + $this->assertTrue($meetingDecision->agendaItem()->exists()); + } +} diff --git a/tests/Unit/Models/Company/MeetingTest.php b/tests/Unit/Models/Company/MeetingTest.php new file mode 100644 index 000000000..c92c6a778 --- /dev/null +++ b/tests/Unit/Models/Company/MeetingTest.php @@ -0,0 +1,45 @@ +create([]); + $this->assertTrue($meeting->group()->exists()); + } + + /** @test */ + public function it_has_many_employees(): void + { + $meeting = Meeting::factory()->create(); + $dwight = Employee::factory()->create([ + 'company_id' => $meeting->group->company_id, + ]); + + $meeting->employees()->syncWithoutDetaching([$dwight->id]); + + $this->assertTrue($meeting->employees()->exists()); + } + + /** @test */ + public function it_has_many_agenda_items(): void + { + $meeting = Meeting::factory()->create(); + AgendaItem::factory()->count(2)->create([ + 'meeting_id' => $meeting->id, + ]); + + $this->assertTrue($meeting->agendaItems()->exists()); + } +} From e865455b08e1a005a44dbcce4d9e00f8484b456d Mon Sep 17 00:00:00 2001 From: Regis Freyd Date: Sat, 6 Mar 2021 18:38:19 -0500 Subject: [PATCH 08/47] new services --- app/Helpers/LogHelper.php | 16 +++ app/Services/Company/Group/CreateMeeting.php | 82 +++++++++++++ app/Services/Company/Group/DestroyMeeting.php | 77 +++++++++++++ resources/lang/en/account.php | 2 + .../Company/Group/CreateGroupTest.php | 1 + .../Company/Group/CreateMeetingTest.php | 106 +++++++++++++++++ .../Company/Group/DestroyMeetingTest.php | 109 ++++++++++++++++++ 7 files changed, 393 insertions(+) create mode 100644 app/Services/Company/Group/CreateMeeting.php create mode 100644 app/Services/Company/Group/DestroyMeeting.php create mode 100644 tests/Unit/Services/Company/Group/CreateMeetingTest.php create mode 100644 tests/Unit/Services/Company/Group/DestroyMeetingTest.php diff --git a/app/Helpers/LogHelper.php b/app/Helpers/LogHelper.php index 080badcb5..de8327d52 100644 --- a/app/Helpers/LogHelper.php +++ b/app/Helpers/LogHelper.php @@ -1099,6 +1099,22 @@ public static function processAuditLog(AuditLog $log): string ]); break; + case 'meeting_created': + $sentence = trans('account.log_meeting_created', [ + 'meeting_id' => $log->object->{'meeting_id'}, + 'group_id' => $log->object->{'group_id'}, + 'group_name' => $log->object->{'group_name'}, + ]); + break; + + case 'meeting_destroyed': + $sentence = trans('account.log_meeting_destroyed', [ + 'meeting_id' => $log->object->{'meeting_id'}, + 'group_id' => $log->object->{'group_id'}, + 'group_name' => $log->object->{'group_name'}, + ]); + break; + default: $sentence = ''; break; diff --git a/app/Services/Company/Group/CreateMeeting.php b/app/Services/Company/Group/CreateMeeting.php new file mode 100644 index 000000000..97a95ae45 --- /dev/null +++ b/app/Services/Company/Group/CreateMeeting.php @@ -0,0 +1,82 @@ + 'required|integer|exists:companies,id', + 'author_id' => 'required|integer|exists:employees,id', + 'group_id' => 'required|integer|exists:groups,id', + ]; + } + + /** + * Create a meeting. + * + * @param array $data + * @return Meeting + */ + public function execute(array $data): Meeting + { + $this->data = $data; + $this->validate(); + $this->createMeeting(); + $this->log(); + + return $this->meeting; + } + + private function validate(): void + { + $this->validateRules($this->data); + + $this->author($this->data['author_id']) + ->inCompany($this->data['company_id']) + ->asNormalUser() + ->canExecuteService(); + + $this->group = Group::where('company_id', $this->data['company_id']) + ->findOrFail($this->data['group_id']); + } + + private function createMeeting(): void + { + $this->meeting = Meeting::create([ + 'group_id' => $this->data['group_id'], + ]); + } + + private function log(): void + { + LogAccountAudit::dispatch([ + 'company_id' => $this->data['company_id'], + 'action' => 'meeting_created', + 'author_id' => $this->author->id, + 'author_name' => $this->author->name, + 'audited_at' => Carbon::now(), + 'objects' => json_encode([ + 'group_id' => $this->group->id, + 'group_name' => $this->group->name, + 'meeting_id' => $this->meeting->id, + ]), + ])->onQueue('low'); + } +} diff --git a/app/Services/Company/Group/DestroyMeeting.php b/app/Services/Company/Group/DestroyMeeting.php new file mode 100644 index 000000000..4eb7b4a7e --- /dev/null +++ b/app/Services/Company/Group/DestroyMeeting.php @@ -0,0 +1,77 @@ + 'required|integer|exists:companies,id', + 'author_id' => 'required|integer|exists:employees,id', + 'meeting_id' => 'nullable|integer|exists:meetings,id', + ]; + } + + /** + * Delete a meeting. + * + * @param array $data + */ + public function execute(array $data): void + { + $this->data = $data; + $this->validate(); + $this->destroy(); + $this->log(); + } + + private function validate(): void + { + $this->validateRules($this->data); + + $this->author($this->data['author_id']) + ->inCompany($this->data['company_id']) + ->asNormalUser() + ->canExecuteService(); + + $this->meeting = Meeting::findOrFail($this->data['meeting_id']); + + $this->group = Group::where('company_id', $this->data['company_id']) + ->findOrFail($this->meeting->group_id); + } + + private function destroy(): void + { + $this->meeting->delete(); + } + + private function log(): void + { + LogAccountAudit::dispatch([ + 'company_id' => $this->data['company_id'], + 'action' => 'meeting_destroyed', + 'author_id' => $this->author->id, + 'author_name' => $this->author->name, + 'audited_at' => Carbon::now(), + 'objects' => json_encode([ + 'group_name' => $this->group->name, + ]), + ])->onQueue('low'); + } +} diff --git a/resources/lang/en/account.php b/resources/lang/en/account.php index 84af2ca34..a5ed895ae 100644 --- a/resources/lang/en/account.php +++ b/resources/lang/en/account.php @@ -294,6 +294,8 @@ 'log_employee_added_to_group' => 'Added :employee_name to the group called :group_name.', 'log_employee_removed_from_group' => 'Removed :employee_name from the group called :group_name.', 'log_group_destroyed' => 'Deleted the group called :group_name.', + 'log_meeting_created' => 'Created a meeting in the group called :group_name.', + 'log_meeting_destroyed' => 'Deleted a meeting in the group called :group_name.', // employee logs 'employee_log_employee_created' => 'Created this employee entry.', diff --git a/tests/Unit/Services/Company/Group/CreateGroupTest.php b/tests/Unit/Services/Company/Group/CreateGroupTest.php index d9e232f04..d9891715e 100644 --- a/tests/Unit/Services/Company/Group/CreateGroupTest.php +++ b/tests/Unit/Services/Company/Group/CreateGroupTest.php @@ -78,6 +78,7 @@ private function executeService(Employee $michael, array $employees = null): voi $this->assertDatabaseHas('groups', [ 'id' => $group->id, + 'company_id' => $group->company_id, 'name' => 'Steering Commitee', ]); diff --git a/tests/Unit/Services/Company/Group/CreateMeetingTest.php b/tests/Unit/Services/Company/Group/CreateMeetingTest.php new file mode 100644 index 000000000..bd5246c63 --- /dev/null +++ b/tests/Unit/Services/Company/Group/CreateMeetingTest.php @@ -0,0 +1,106 @@ +createAdministrator(); + $group = Group::factory()->create([ + 'company_id' => $michael->company_id, + ]); + $this->executeService($michael, $michael->company, $group); + } + + /** @test */ + public function it_creates_a_meeting_as_hr(): void + { + $michael = $this->createHR(); + $group = Group::factory()->create([ + 'company_id' => $michael->company_id, + ]); + $this->executeService($michael, $michael->company, $group); + } + + /** @test */ + public function it_creates_a_meeting_as_normal_user(): void + { + $michael = $this->createEmployee(); + $group = Group::factory()->create([ + 'company_id' => $michael->company_id, + ]); + $this->executeService($michael, $michael->company, $group); + } + + /** @test */ + public function it_fails_if_wrong_parameters_are_given(): void + { + $michael = factory(Employee::class)->create([]); + + $request = [ + 'company_id' => $michael->company_id, + ]; + + $this->expectException(ValidationException::class); + (new CreateProject)->execute($request); + } + + /** @test */ + public function it_fails_if_the_group_doesnt_belong_to_the_company(): void + { + $michael = factory(Employee::class)->create([]); + $group = Group::factory()->create([]); + + $this->expectException(ModelNotFoundException::class); + $this->executeService($michael, $michael->company, $group); + } + + private function executeService(Employee $michael, Company $company, Group $group): void + { + Queue::fake(); + + $request = [ + 'company_id' => $company->id, + 'author_id' => $michael->id, + 'group_id' => $group->id, + ]; + + $meeting = (new CreateMeeting)->execute($request); + + $this->assertDatabaseHas('meetings', [ + 'id' => $meeting->id, + 'group_id' => $group->id, + ]); + + $this->assertInstanceOf( + Group::class, + $group + ); + + Queue::assertPushed(LogAccountAudit::class, function ($job) use ($michael, $group, $meeting) { + return $job->auditLog['action'] === 'meeting_created' && + $job->auditLog['author_id'] === $michael->id && + $job->auditLog['objects'] === json_encode([ + 'group_id' => $group->id, + 'group_name' => $group->name, + 'meeting_id' => $meeting->id, + ]); + }); + } +} diff --git a/tests/Unit/Services/Company/Group/DestroyMeetingTest.php b/tests/Unit/Services/Company/Group/DestroyMeetingTest.php new file mode 100644 index 000000000..5ec46aec0 --- /dev/null +++ b/tests/Unit/Services/Company/Group/DestroyMeetingTest.php @@ -0,0 +1,109 @@ +createAdministrator(); + $group = Group::factory()->create([ + 'company_id' => $michael->company_id, + ]); + $meeting = Meeting::factory()->create([ + 'group_id' => $group->id, + ]); + $this->executeService($michael, $group, $meeting); + } + + /** @test */ + public function it_destroys_a_meeting_as_hr(): void + { + $michael = $this->createHR(); + $group = Group::factory()->create([ + 'company_id' => $michael->company_id, + ]); + $meeting = Meeting::factory()->create([ + 'group_id' => $group->id, + ]); + $this->executeService($michael, $group, $meeting); + } + + /** @test */ + public function it_destroys_a_meeting_as_normal_user(): void + { + $michael = $this->createEmployee(); + $group = Group::factory()->create([ + 'company_id' => $michael->company_id, + ]); + $meeting = Meeting::factory()->create([ + 'group_id' => $group->id, + ]); + $this->executeService($michael, $group, $meeting); + } + + /** @test */ + public function it_fails_if_meeting_is_not_part_of_the_group(): void + { + $michael = $this->createEmployee(); + $group = Group::factory()->create([ + 'company_id' => $michael->company_id, + ]); + $meeting = Meeting::factory()->create([]); + + $this->expectException(ModelNotFoundException::class); + $this->executeService($michael, $group, $meeting); + } + + /** @test */ + public function it_fails_if_wrong_parameters_are_given(): void + { + $michael = factory(Employee::class)->create([]); + + $request = [ + 'company_id' => $michael->company_id, + ]; + + $this->expectException(ValidationException::class); + (new DestroyMeeting)->execute($request); + } + + private function executeService(Employee $michael, Group $group, Meeting $meeting): void + { + Queue::fake(); + + $request = [ + 'company_id' => $michael->company_id, + 'author_id' => $michael->id, + 'meeting_id' => $meeting->id, + ]; + + (new DestroyMeeting)->execute($request); + + $this->assertDatabaseMissing('meetings', [ + 'id' => $group->id, + ]); + + Queue::assertPushed(LogAccountAudit::class, function ($job) use ($michael, $group) { + return $job->auditLog['action'] === 'meeting_destroyed' && + $job->auditLog['author_id'] === $michael->id && + $job->auditLog['objects'] === json_encode([ + 'group_name' => $group->name, + ]); + }); + } +} From 946da0dc219145d042360f2f1ff10bcee1f72a5d Mon Sep 17 00:00:00 2001 From: Regis Freyd Date: Sun, 7 Mar 2021 08:40:57 -0500 Subject: [PATCH 09/47] wip --- app/Helpers/LogHelper.php | 18 +++ .../MarkEmployeeAsParticipantOfMeeting.php | 121 ++++++++++++++++ resources/lang/en/account.php | 2 + ...MarkEmployeeAsParticipantOfMeetingTest.php | 135 ++++++++++++++++++ 4 files changed, 276 insertions(+) create mode 100644 app/Services/Company/Group/MarkEmployeeAsParticipantOfMeeting.php create mode 100644 tests/Unit/Services/Company/Group/MarkEmployeeAsParticipantOfMeetingTest.php diff --git a/app/Helpers/LogHelper.php b/app/Helpers/LogHelper.php index de8327d52..4d9a30c72 100644 --- a/app/Helpers/LogHelper.php +++ b/app/Helpers/LogHelper.php @@ -1115,6 +1115,16 @@ public static function processAuditLog(AuditLog $log): string ]); break; + case 'employee_marked_as_participant_in_meeting': + $sentence = trans('account.log_employee_marked_as_participant_in_meeting', [ + 'group_id' => $log->object->{'group_id'}, + 'group_name' => $log->object->{'group_name'}, + 'employee_id' => $log->object->{'employee_id'}, + 'employee_name' => $log->object->{'employee_name'}, + 'meeting_id' => $log->object->{'meeting_id'}, + ]); + break; + default: $sentence = ''; break; @@ -1623,6 +1633,14 @@ public static function processEmployeeLog(EmployeeLog $log): string ]); break; + case 'employee_marked_as_participant_in_meeting': + $sentence = trans('account.employee_log_employee_marked_as_participant_in_meeting', [ + 'group_id' => $log->object->{'group_id'}, + 'group_name' => $log->object->{'group_name'}, + 'meeting_id' => $log->object->{'meeting_id'}, + ]); + break; + default: $sentence = ''; break; diff --git a/app/Services/Company/Group/MarkEmployeeAsParticipantOfMeeting.php b/app/Services/Company/Group/MarkEmployeeAsParticipantOfMeeting.php new file mode 100644 index 000000000..eb6053fe3 --- /dev/null +++ b/app/Services/Company/Group/MarkEmployeeAsParticipantOfMeeting.php @@ -0,0 +1,121 @@ + 'required|integer|exists:companies,id', + 'author_id' => 'required|integer|exists:employees,id', + 'meeting_id' => 'required|integer|exists:meetings,id', + 'employee_id' => 'required|integer|exists:employees,id', + ]; + } + + /** + * Mark an employee as participant of a meeting. + * When marking an employee, we should check if the employee is part of the + * group the meeting belongs to or not. + * If the employee is not part of the group, we should mark it as guest. + * + * @param array $data + * @return Employee + */ + public function execute(array $data): Employee + { + $this->data = $data; + $this->validate(); + + $this->attachEmployee(); + $this->log(); + + return $this->employee; + } + + private function validate(): void + { + $this->validateRules($this->data); + + $this->author($this->data['author_id']) + ->inCompany($this->data['company_id']) + ->asNormalUser() + ->canExecuteService(); + + $this->employee = $this->validateEmployeeBelongsToCompany($this->data); + + $this->group = Group::where('company_id', $this->data['company_id']) + ->findOrFail($this->data['group_id']); + + $this->meeting = Meeting::where('group_id', $this->data['group_id']) + ->findOrFail($this->data['meeting_id']); + } + + private function attachEmployee(): void + { + $this->meeting->employees()->syncWithoutDetaching([ + $this->data['employee_id'] => [ + 'was_a_guest' => $this->isEmployeePartOfGroup(), + ], + ]); + } + + private function isEmployeePartOfGroup(): bool + { + return DB::table('employee_group') + ->where('employee_id', $this->data['employee_id']) + ->where('group_id', $this->group->id) + ->count() == 1; + } + + private function log(): void + { + LogAccountAudit::dispatch([ + 'company_id' => $this->data['company_id'], + 'action' => 'employee_marked_as_participant_in_meeting', + 'author_id' => $this->author->id, + 'author_name' => $this->author->name, + 'audited_at' => Carbon::now(), + 'objects' => json_encode([ + 'group_id' => $this->group->id, + 'group_name' => $this->group->name, + 'employee_id' => $this->employee->id, + 'employee_name' => $this->employee->name, + 'meeting_id' => $this->meeting->id, + ]), + ])->onQueue('low'); + + LogEmployeeAudit::dispatch([ + 'employee_id' => $this->employee->id, + 'action' => 'employee_marked_as_participant_in_meeting', + 'author_id' => $this->author->id, + 'author_name' => $this->author->name, + 'audited_at' => Carbon::now(), + 'objects' => json_encode([ + 'group_id' => $this->group->id, + 'group_name' => $this->group->name, + 'meeting_id' => $this->meeting->id, + ]), + ])->onQueue('low'); + } +} diff --git a/resources/lang/en/account.php b/resources/lang/en/account.php index a5ed895ae..8dd4a58b1 100644 --- a/resources/lang/en/account.php +++ b/resources/lang/en/account.php @@ -296,6 +296,7 @@ 'log_group_destroyed' => 'Deleted the group called :group_name.', 'log_meeting_created' => 'Created a meeting in the group called :group_name.', 'log_meeting_destroyed' => 'Deleted a meeting in the group called :group_name.', + 'log_employee_marked_as_participant_in_meeting' => 'Indicated that :employee_name participated in a meeting in the group called :group_name.', // employee logs 'employee_log_employee_created' => 'Created this employee entry.', @@ -371,6 +372,7 @@ 'employee_log_consultant_rate_destroyed' => 'Destroyed the rate of :rate.', 'employee_log_employee_added_to_group' => 'Has been added to the group called :group_name.', 'employee_log_employee_removed_from_group' => 'Has been remove from the group called :group_name.', + 'employee_log_employee_marked_as_participant_in_meeting' => 'Has been added in a meeting in the group called :group_name.', // team logs 'team_log_team_created' => 'Created the team.', diff --git a/tests/Unit/Services/Company/Group/MarkEmployeeAsParticipantOfMeetingTest.php b/tests/Unit/Services/Company/Group/MarkEmployeeAsParticipantOfMeetingTest.php new file mode 100644 index 000000000..2ade7f267 --- /dev/null +++ b/tests/Unit/Services/Company/Group/MarkEmployeeAsParticipantOfMeetingTest.php @@ -0,0 +1,135 @@ +createAdministrator(); + $dwight = $this->createAnotherEmployee($michael); + $group = Group::factory()->create([ + 'company_id' => $michael->company_id, + ]); + $this->executeService($michael, $dwight, $group); + } + + /** @test */ + public function it_adds_an_employee_to_a_meeting_as_hr(): void + { + $michael = $this->createHR(); + $dwight = $this->createAnotherEmployee($michael); + $group = Group::factory()->create([ + 'company_id' => $michael->company_id, + ]); + $this->executeService($michael, $dwight, $group); + } + + /** @test */ + public function it_adds_an_employee_to_a_meeting_as_normal_user(): void + { + $michael = $this->createEmployee(); + $group = Group::factory()->create([ + 'company_id' => $michael->company_id, + ]); + $this->executeService($michael, $michael, $group); + } + + /** @test */ + public function it_fails_if_wrong_parameters_are_given(): void + { + $request = [ + 'first_name' => 'Dwight', + ]; + + $this->expectException(ValidationException::class); + (new MarkEmployeeAsParticipantOfMeeting)->execute($request); + } + + /** @test */ + public function it_fails_if_the_employee_is_not_in_the_authors_company(): void + { + $michael = $this->createAdministrator(); + $dwight = $this->createEmployee(); + $group = Group::factory()->create([ + 'company_id' => $michael->company_id, + ]); + + $this->expectException(ModelNotFoundException::class); + $this->executeService($michael, $dwight, $group); + } + + /** @test */ + public function it_fails_if_the_project_is_not_in_the_company(): void + { + $michael = $this->createAdministrator(); + $dwight = $this->createEmployee(); + $group = Group::factory()->create(); + + $this->expectException(ModelNotFoundException::class); + $this->executeService($michael, $dwight, $group); + } + + private function executeService(Employee $michael, Employee $dwight, Group $group): void + { + Queue::fake(); + + $request = [ + 'company_id' => $michael->company_id, + 'author_id' => $michael->id, + 'group_id' => $group->id, + 'employee_id' => $dwight->id, + ]; + + $employee = (new MarkEmployeeAsParticipantOfMeeting)->execute($request); + + $this->assertDatabaseHas('groups', [ + 'id' => $group->id, + 'company_id' => $dwight->company_id, + ]); + + $this->assertDatabaseHas('employee_group', [ + 'group_id' => $group->id, + 'employee_id' => $dwight->id, + ]); + + $this->assertInstanceOf( + Employee::class, + $employee + ); + + Queue::assertPushed(LogAccountAudit::class, function ($job) use ($michael, $group, $dwight) { + return $job->auditLog['action'] === 'employee_added_to_group' && + $job->auditLog['author_id'] === $michael->id && + $job->auditLog['objects'] === json_encode([ + 'group_id' => $group->id, + 'group_name' => $group->name, + 'employee_id' => $dwight->id, + 'employee_name' => $dwight->name, + ]); + }); + + Queue::assertPushed(LogEmployeeAudit::class, function ($job) use ($michael, $group) { + return $job->auditLog['action'] === 'employee_added_to_group' && + $job->auditLog['author_id'] === $michael->id && + $job->auditLog['objects'] === json_encode([ + 'group_id' => $group->id, + 'group_name' => $group->name, + ]); + }); + } +} From 7a8c98e082d621effca9ecf8759ee21f89753c01 Mon Sep 17 00:00:00 2001 From: Regis Freyd Date: Fri, 12 Mar 2021 16:05:11 -0500 Subject: [PATCH 10/47] wip --- app/Models/Company/Meeting.php | 1 + ...treamline-icon-meeting-table-3@140x140.png | Bin 0 -> 12211 bytes resources/js/Pages/Company/Group/Show.vue | 122 +++++++++++++++++- .../Company/Project/Partials/ProjectMenu.vue | 2 +- resources/lang/en/project.php | 1 + 5 files changed, 123 insertions(+), 3 deletions(-) create mode 100644 public/img/streamline-icon-meeting-table-3@140x140.png diff --git a/app/Models/Company/Meeting.php b/app/Models/Company/Meeting.php index 91bba4c49..3038a99d2 100644 --- a/app/Models/Company/Meeting.php +++ b/app/Models/Company/Meeting.php @@ -3,6 +3,7 @@ namespace App\Models\Company; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsToMany; diff --git a/public/img/streamline-icon-meeting-table-3@140x140.png b/public/img/streamline-icon-meeting-table-3@140x140.png new file mode 100644 index 0000000000000000000000000000000000000000..b9607684cbb967abf2265b0f69c10e3e2c7ed654 GIT binary patch literal 12211 zcmZ`ztcMFA!AT~y60 z`#i_nOHE=aeCuzHV(lto0xV)es00j^DCZeG*7t?a61X{{q9k~3>&9*i&f*eMAy}^q z&bZ%y^ukH%iNcx5Xi*y*Vq+sF2pv=9Y`!c#NYRptVK=zZ<|jXRKg&FCcDPq|2DYB4 zEtaePQ4~0Dn*YC&X-DE?6M}y#`@i`y@#3AR z);wSa7<@=yEA6!98iiOobV+yFKg4u3B;j3;;>7kjS@4#oS*20EFwr40djG_PlBYvY z8S3&;@AuiF zIOe>*GOfS7r)axOl;f$3fz9g-vLprdDW_QO8$qZP>E}{d=PsjFO_-k zGQ2dA^ak8u6pyS(p{{g(nCXbRZ&q(M>G@C4~Z;N$xPW5HIA zk%wj!VtDXr5R#ZvEESeh0Fj(I+(*X+zK_JQ_jb80n-%m6!0Djf@NkC!9PsooU=~DR zpgVHK$PwTD0jJ}iQ-$>}1@>>qU~5v6V;H=2xbAVG0d4>fP=+4IQwC_=W}Ad^eZ1b| zdeV!Y_VL1vdZNPW2)*jI9gPeBCw9;b#d!QJZ?@|L^ZoWgGfSn(4p2X6xRcWtq@tWG zJV`CT@xJRAftyU2pu(`J$QGp{Q?>l3S>MNH_WPsh0Wo@JO&gS*{`54e=w2Az4j8W?*c@Q*|;9U_}z~)HZ zZOBTCPG%*8l|Usai*2hCW3r51Xf=D-ru9k#+gPc0D?^X>k!no9T2%bBXF-ZUL9>%` zs5M>-?#U5{$-L_T-u?YW^Xo*g;>HdM8#T>XvqHIQ%^E+EX}S!py$G&R7g4=J8FZGw zM$1L5(?{_EmaKHFo}#`2bkwBVKx?8~tjdCAjFFg1_gZ z%lPwewnv;JPgI}-UV+2%DfUhh>G6g-^YcoqAHB@rQ^i+Joqu5VQgW=SC#<=6x<9se zlO6up@YQLnp6mUC$^(yH8}OU@&LN(f-?Ykrb#cs4J1gUf1>8YYUMal;03NXz- zS7j*!W$6i8(NI!UK+{clZF?PyVu(Zc>=vI`px?VE1x|Yn(q4Csp$aW%)`*cNZKd|+l;-aOuE!3W4vOKV)qGSJbLe=k)wY8hV_vIHo<;rMII!?sa* z1xRDo$|fy*AE>ZzE3(P3$DcLz{rvT+U$Gr?Te%()ItiBJ!|T&ky)sk=BR-Ar3j>>e zzxP8Vg3xg|#?eT_9Wk#ncitk4$T^mpk0gHMmVOK-SkOOq&qKhQuQ_J+ufooZII2qb zjN_ab^Jik3W_c=lyR+Y^gD^&nqvAe; zzl69WT0lBbjpbO{0{RHIsf3#KP#v4IIFscHWFUvbLvhm%`%9y1W;yR_U`L`V&{c6t z6c9WB&%h;(CMbed^!Z^>giz!S5Vql^Yl}||&5bKhgiq%`U%_JoL(ml_!xcqKo!MN? zgx_mw%c$Fml{~Z~tfdV<Zev)-yM+e3j5@PcC3a_1qeq)k zkFyUEfLktkC45O_uQo67{m$eFEm`YM4tZk9-ici8RJ8C@b@w((v%2*Hm=jWGw8%n_ z+GU(%xzvI71L~qQCGv3XBn%C&V5+lR9Larf(_5V@%YZ{i@G024s5&~H%8PB0Zj(SvFJ-G{%2#HYDqcF^ zBMU7M1NE4dnUSHyfFNjz=IZ2hrO5{A?>)K6`Q zF{C1Q&eNAdWl`LrHg88{9a?;{0dnRdZ%yS3z4#H&=lvuA#< zi#5n8yzbvUtQ8S7cn)~_z2w2IRBP}fAYhTLLH~SBQn{9keYEA&3%oF;G7$Q`IN>53 z?qO$f_sT~Xl04BV^=CHUmi0Ul^d;_qfD(y*QE@0_No(nJJ~iGnIhiez_vwo4;k@@iC%Y~=V%ydty@1EWTZUr(Ar za5y$5_S$Gp7v+}(#%m`)c=kMK`{A|mh)=Ms=BZWVb#{v;V=y~w^G1<|d- z&m)v>NDGO$i=j}l&%k2=A~!eABVJVvvU1FmO7=!2|D#j(r>Za+MMcE>^jj%ZBKMVc zu8CC>%BNu$vvzA&EBNZLQN_Na1)G*U-q^r&I-2yR66=I<1t_p|TA- zz>+cwPys=XAu%@prwlSMS{c>@zQ=+H=BC$ zJ9n6cI0YKgM5CD&hayL#<>*KxC`J$L_gl5-FLqg`D>XDKy;NCWM;aw1hu^Bnj#GCD zgk$4d_1ZN$^U`93&o5T_RWW}q+z8sBrmN({vH7E{W!-1e?39V&r&uBL^vt0#?f)xk z?u$d`o6@9iT|`}-6F#$nn3gmvoBjsM>e9t)YG`GT#aY`G=;i{We$~!y3zWDa@=6dZ z{BuqbUXSv_mZh%O+7BI`WHY%(A=#=u>H8P$NZTFU)Y zp(p&gP4%~zTA|7u56Dz~Drswv260`CtzAzmhne5~kBXK_-yT#C@QaB+SVGGcLSC$I zmBAyjHF2QP_`>m-+}1>^VeS6Ch=TFDSF5PNMV_mW^4#3=Gy{ChCH6%%bgDV zFfAqy&cv@Fl(7l^=_!nt0K!2P?Vte*YR-aBkP9@f7a-~y1_;nMv?qk-H-VCvMT0_) zIB_*HaQw8n^AIJIaFaV>;r_XdnPoFsHE2~aXS#&rbTSssKeL&?ny!!ll;2X@xX4Y6zUf#+Q?k!yE$ZQ%uef3XEBY z5o^+sl}|J7X3Sa!%KRs8zj>93N_E$RHZiQ+$J#<-RN=E2Dd~F;QlO6rtBhYG=xE|1 zhw@!#7Gq>i`jTqVjXBpwTUVqBg*N}1G#Kxj%yGZ>-mrA_Ut)m|ri>7c?vs^nc3fxb z{Dx$Q-?#B)85QGE#Le=caet9ky`0FYsJ2|c%GDaNwv8A~S0@7b!(H#r=ew9qxwH}P zVd_5Y0J5PlaQPL;>;Ot9HK7EL)i+Y2SG-q12~H+8K03a7fh+xNe($jxg*Yw5E-mZ>sKfB7I+*swk?ioFvMy+|-WYxvjq7e~+pdUTS_g#9lf%3@+bQ{B=Xy|rZo82`IL zedMc2V{O#XHmPnzTqHmkP;XG*gqtPj)Uh7MNFfYJJjx(MzgXD`#$KlWekvs&k(Pnp ztjRU#zJ!BQP%QUt1M8oyDL*SSKW?B1HN+yi)TS8GxC(bx`)qp1BR+uB$u3xL+qFEI zt=IX`%5=^M$N{x3H(-Fx*A|Y2@#MG`3y$&2ELJX+{hsy@g(rx6Pn>u%Ay<$Yd>Y{R ziJw@ID|&wWdz$`ZrR|`ry*$En+TYOz`j3eCg23m3uvOT9*w>+|W9W6+_AVuve3HQa z69P@uG5$INTXX6wq(}(Mk<}NSuS7kG34;_U?8ANSTc)V#I0YhssjE&CPbLzsCgdqHw5Ib0>?85XD=6@abt6T_7mcP9O zczQ@=6C_q5eR2aTt$wy< zX;}~~KajmZJAS^d=nk*7$v7cfZtu$1w;Ug%t6z9AC|u0PD7%_p&C!&Z3HX%xc*${e z3mqVKAN$dyR8esR9W2|3Q4)IeYL=oNYyfx<*wZ~{eDuQj$Y5K@`LPei8~P6ad$~MA zsE3AP=FcM{D!*r~(dI}G_Ii2avQS)hAp&&U&2Z&uwt$BbOV>*);~BV~K^b({Uj=`0 z%mhJ1NEcW`72@rJ!bQYT2*?b4T57tNriB8SzPW6AcFs<$J-{r-&P`W&{7GCf5cqsf zw-&B(o~mEL91q~Q>5Ed#O-#xKHb|8HO(DHMlGbJUn* zR1hZ>3WBW8R3E~#$e$1{goSgaXk%keuqHPmv9jq~o%8FktMho7Ag1ZClr zPP9Fw1vS^iUa0yOJ?z$E)W2p8K&10UvM8%~#9vpio)kEvW*gLc?>in#%{1NmyymP) zv!vZ?=&xk>qbttloX7Bojr-+@aLy4?S&6ODTbvqXIS==HDrah!y&NA^t_Ho$dUkz? zeN#|xNs?><&*HDI#|XHq=dcWL@3N7KM1kXHio%A0+0{L{`|Y1)2ET`S)$nAV7Zv51 z`;XJ#lYYHDo|``@6}pkHDWxehkm@qu&}ra^^P0PliZ0dc33C5c4x#s=3)%h0KDU|r z>`7DiC0)zhv1)fELuUxS!X#Ua=88T@(zY=*d-%4)e>_`mGweKtPN1ulTF+NXvVAyn ztGP*2N1?jq|y4FC+Td5Y}e<08Zd(C}>N%IobV0>vPLmvq(-K!Jq; zz{2PBlp?KpPTD_)g-Nk`tQq=a+wSY-c6w7a(rit(eH8-ujD4jyU+qBKOJBNOVV^!Q zZqdqgI?W*w*~6E*4#|3&A!eCt7f0L%h~_pK1Nzpz&lf{H%dp;`b9&ev7l{mX=J|s? z|1*bgmPoD@JJNgIxE>3Y=^;XR;3P?z0WT(-GxaymrVoJD{Wr=N3WFpayC+}j47I`x z)T*irJ!?^B$j@UF;}k5XykY;)vw7>%cQ{HRED^A$`Pp%!{}Y6%q0*^icRGB@Djvoz z!|-Z_C7ch8H+}sia}IX>at&XRthM>vqNAYKo8`pRJPoqn4^a#(8?@SPQsKU%w`GgG z`Geu+eI`Jij#P6qaV-^LNV65ZDpo*;uJ(sTnzjaHhtEV`svVBsx@?lD+CYG0fg)E} zKn^8@{)cE;9QQ^QAHHeki%#kD+*hWy_q1j?9r9ZWG~17R!v0NG?f3bVHfeqDs=&z)gAQu?d5zC3GXD%1tXxg zsHv~%i5g5ZzGWDj*Hdw#HE?_*E9dWyP&1n}xhTnq_7mVuWs=dLGX5SQlxEh?!?5?)tWxGLBLU zdkcw3!LGqQV=7o|iV%6l(BvQR0m` zQgK$fa6_rE$SR5#=o)t~m0QS##~bdN4|`tU)K?g3AfPS@RxvOzyI~TP+HC6-suBbX zd&Ct2Yg;auDJij3YbUH4!_1n|nVY9{2%eo{cIEQr@f*R2CcwfX8wSBeXFP@@p?15_ z(Kaew4sH9z;bvy0VfJ17dl&6z9f`0eLSF@I)t`xXxSjvkTkB)M6Lb@ZGBzVTy}*Ye zwDp_Hcm?8q)I2H5PD`mC0y^`ZI>yPOxCohKa=#hm`X127|9~neC>-Ng6_{Ku-RkIU z9jJiC!8vU)9FInMw3*d&2&|A{dlO1Y$Gx6)jsMeC81?x9RqEWZ=c{t!teA5qUd|du z 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