diff --git a/api/app/Console/Commands/SuspendPlacedCandidates.php b/api/app/Console/Commands/SuspendPlacedCandidates.php new file mode 100644 index 00000000000..4c01e87addb --- /dev/null +++ b/api/app/Console/Commands/SuspendPlacedCandidates.php @@ -0,0 +1,50 @@ +name, PoolCandidateStatus::PLACED_INDETERMINATE->name]; + + PoolCandidate::whereIn('pool_candidate_status', $applicableStatuses) + ->whereNull('suspended_at') + ->with('user') + ->chunkById(100, function (Collection $candidates) { + foreach ($candidates as $candidate) { + /** @var \App\Models\PoolCandidate $candidate */ + $candidate->suspended_at = Carbon::now(); + $candidate->save(); + } + } + ); + + return Command::SUCCESS; + } +} diff --git a/api/app/Enums/PoolCandidateStatus.php b/api/app/Enums/PoolCandidateStatus.php index 3140cfb378a..85e77b78761 100644 --- a/api/app/Enums/PoolCandidateStatus.php +++ b/api/app/Enums/PoolCandidateStatus.php @@ -28,12 +28,15 @@ enum PoolCandidateStatus case EXPIRED; case REMOVED; + // searchable candidates, so the available status and others treated as available for search purposes public static function qualifiedEquivalentGroup(): array { return [ PoolCandidateStatus::QUALIFIED_AVAILABLE->name, PoolCandidateStatus::PLACED_TENTATIVE->name, PoolCandidateStatus::PLACED_CASUAL->name, + PoolCandidateStatus::PLACED_TERM->name, + PoolCandidateStatus::PLACED_INDETERMINATE->name, ]; } diff --git a/api/app/GraphQL/Mutations/PlaceCandidate.php b/api/app/GraphQL/Mutations/PlaceCandidate.php index f5b3e62b769..f7797d260de 100644 --- a/api/app/GraphQL/Mutations/PlaceCandidate.php +++ b/api/app/GraphQL/Mutations/PlaceCandidate.php @@ -2,6 +2,7 @@ namespace App\GraphQL\Mutations; +use App\Enums\PlacementType; use App\Models\PoolCandidate; use Carbon\Carbon; @@ -25,6 +26,13 @@ public function __invoke($_, array $args) $candidate->computed_final_decision = $finalDecision['decision']; $candidate->computed_final_decision_weight = $finalDecision['weight']; + // If setting to term or indeterminate automatically suspend the candidate, otherwise null the field + if ($placementType === PlacementType::PLACED_TERM->name || $placementType === PlacementType::PLACED_INDETERMINATE->name) { + $candidate->suspended_at = $now; + } else { + $candidate->suspended_at = null; + } + $candidate->save(); return $candidate; diff --git a/api/app/GraphQL/Mutations/RevertPlaceCandidate.php b/api/app/GraphQL/Mutations/RevertPlaceCandidate.php index 9adfc1042ce..7dd869d14b7 100644 --- a/api/app/GraphQL/Mutations/RevertPlaceCandidate.php +++ b/api/app/GraphQL/Mutations/RevertPlaceCandidate.php @@ -17,6 +17,7 @@ public function __invoke($_, array $args) $candidate->pool_candidate_status = PoolCandidateStatus::QUALIFIED_AVAILABLE->name; $candidate->placed_at = null; $candidate->placed_department_id = null; + $candidate->suspended_at = null; $candidate->save(); diff --git a/api/app/GraphQL/Queries/CountPoolCandidatesByPool.php b/api/app/GraphQL/Queries/CountPoolCandidatesByPool.php index f5a002d8fc7..cc5c65bbd72 100644 --- a/api/app/GraphQL/Queries/CountPoolCandidatesByPool.php +++ b/api/app/GraphQL/Queries/CountPoolCandidatesByPool.php @@ -33,7 +33,7 @@ public function __invoke($_, array $args) } }); - // available candidates scope (scope CANDIDATE_STATUS_QUALIFIED_AVAILABLE or CANDIDATE_STATUS_PLACED_CASUAL, or PLACED_TENTATIVE) + // available candidates scope (qualifiedEquivalentGroup, not expired, not suspended) PoolCandidate::scopeAvailable($queryBuilder); // Only display IT & OTHER publishing group candidates diff --git a/api/app/Models/PoolCandidate.php b/api/app/Models/PoolCandidate.php index 1b6dadcb303..2f95d9c3e38 100644 --- a/api/app/Models/PoolCandidate.php +++ b/api/app/Models/PoolCandidate.php @@ -48,7 +48,7 @@ * @property ?int $status_weight * @property string $pool_id * @property string $user_id - * @property ?\Illuminate\Support\Carbon $suspended_at + * @property ?\Carbon\Carbon $suspended_at * @property \Illuminate\Support\Carbon $created_at * @property ?\Illuminate\Support\Carbon $updated_at * @property array $submitted_steps diff --git a/api/tests/Feature/CountPoolCandidatesByPoolTest.php b/api/tests/Feature/CountPoolCandidatesByPoolTest.php index d0ba1da5c5c..fdd72b71507 100644 --- a/api/tests/Feature/CountPoolCandidatesByPoolTest.php +++ b/api/tests/Feature/CountPoolCandidatesByPoolTest.php @@ -828,4 +828,58 @@ public function testAdditionalAvailabilityScopes() ], ]); } + + // asserts placed term/indeterminate candidates can appear in this query, can't check by id though so check that 2 out of 3 appear + public function testPlacedSuspendedNotSuspendedCandidates() + { + $itPool = Pool::factory()->create([ + ...$this->poolData(), + 'publishing_group' => PublishingGroup::IT_JOBS->name, + ]); + PoolCandidate::truncate(); + PoolCandidate::factory()->availableInSearch()->create([ + 'pool_id' => $itPool, + 'pool_candidate_status' => PoolCandidateStatus::PLACED_TERM->name, + 'suspended_at' => null, + ]); + PoolCandidate::factory()->availableInSearch()->create([ + 'pool_id' => $itPool, + 'pool_candidate_status' => PoolCandidateStatus::PLACED_INDETERMINATE->name, + 'suspended_at' => null, + ]); + PoolCandidate::factory()->availableInSearch()->create([ + 'pool_id' => $itPool, + 'pool_candidate_status' => PoolCandidateStatus::PLACED_TERM->name, + 'suspended_at' => config('constants.far_past_datetime'), + ]); + + // expect 2, the 2 un-suspended ones + $this->graphQL( + /** @lang GraphQL */ + ' + query ($where: ApplicantFilterInput) { + countPoolCandidatesByPool(where: $where) { + pool { id } + candidateCount + } + } + ', + [ + 'where' => [ + 'pools' => [ + ['id' => $itPool->id], + ], + ], + ] + )->assertSimilarJson([ + 'data' => [ + 'countPoolCandidatesByPool' => [ + [ + 'pool' => ['id' => $itPool->id], + 'candidateCount' => 2, + ], + ], + ], + ]); + } } diff --git a/api/tests/Feature/PoolCandidateTest.php b/api/tests/Feature/PoolCandidateTest.php index 2451366fe7f..19ef7397176 100644 --- a/api/tests/Feature/PoolCandidateTest.php +++ b/api/tests/Feature/PoolCandidateTest.php @@ -20,6 +20,8 @@ use Tests\TestCase; use Tests\UsesProtectedGraphqlEndpoint; +use function PHPUnit\Framework\assertEqualsCanonicalizing; + class PoolCandidateTest extends TestCase { use MakesGraphQLRequests; @@ -849,4 +851,35 @@ public function testScopeCandidatesInCommunity(): void ])->assertJsonFragment(['total' => 1]) ->assertJsonFragment(['id' => $communityCandidate->id]); } + + // test scopeAvailable + public function testScopeAvailable(): void + { + PoolCandidate::truncate(); + $candidate = PoolCandidate::factory()->availableInSearch()->create([ + 'pool_candidate_status' => PoolCandidateStatus::PLACED_TERM->name, + 'expiry_date' => null, + 'suspended_at' => null, + ]); + + $suspendedCandidate = PoolCandidate::factory()->availableInSearch()->create([ + 'pool_candidate_status' => PoolCandidateStatus::PLACED_TERM->name, + 'expiry_date' => null, + 'suspended_at' => config('constants.far_past_datetime'), + ]); + + $expiredCandidate = PoolCandidate::factory()->availableInSearch()->create([ + 'pool_candidate_status' => PoolCandidateStatus::PLACED_TERM->name, + 'expiry_date' => config('constants.past_date'), + 'suspended_at' => null, + ]); + + $queryBuilder = PoolCandidate::query(); + $candidateIds = PoolCandidate::scopeAvailable($queryBuilder)->get()->pluck('id')->toArray(); + + // suspended and expired not present + assertEqualsCanonicalizing([ + $candidate->id, + ], $candidateIds); + } } diff --git a/api/tests/Feature/PoolCandidateUpdateTest.php b/api/tests/Feature/PoolCandidateUpdateTest.php index 770bf98d916..34735e8e7c9 100644 --- a/api/tests/Feature/PoolCandidateUpdateTest.php +++ b/api/tests/Feature/PoolCandidateUpdateTest.php @@ -160,6 +160,7 @@ protected function setUp(): void placedDepartment { id } + suspendedAt } } '; @@ -175,6 +176,7 @@ protected function setUp(): void placedDepartment { id } + suspendedAt } } '; @@ -502,12 +504,66 @@ public function testPlaceCandidateMutation(): void assertSame($response['placedDepartment']['id'], $department->id); } + // placing candidates sets suspended_at automatically or keeps/makes it null appropriately + public function testPlaceCandidateMutationSuspensionLogic(): void + { + $department = Department::factory()->create(); + $this->poolCandidate->pool_candidate_status = PoolCandidateStatus::QUALIFIED_AVAILABLE->name; + $this->poolCandidate->placed_at = null; + $this->poolCandidate->save(); + $placedDoNotSuspend = [ + PlacementType::PLACED_TENTATIVE->name, + PlacementType::PLACED_CASUAL->name, + ]; + $placedDoSuspend = [ + PlacementType::PLACED_TERM->name, + PlacementType::PLACED_INDETERMINATE->name, + ]; + + foreach ($placedDoNotSuspend as $placementType) { + $response = $this->actingAs($this->poolOperatorUser, 'api') + ->graphQL( + $this->placeCandidateMutation, + [ + 'id' => $this->poolCandidate->id, + 'placeCandidate' => [ + 'placementType' => $placementType, + 'departmentId' => $department->id, + ], + ] + )->json('data.placeCandidate'); + + assertSame($response['status']['value'], $placementType); + assertNotNull($response['placedAt']); + assertNull($response['suspendedAt']); + } + + foreach ($placedDoSuspend as $placementType) { + $response = $this->actingAs($this->poolOperatorUser, 'api') + ->graphQL( + $this->placeCandidateMutation, + [ + 'id' => $this->poolCandidate->id, + 'placeCandidate' => [ + 'placementType' => $placementType, + 'departmentId' => $department->id, + ], + ] + )->json('data.placeCandidate'); + + assertSame($response['status']['value'], $placementType); + assertNotNull($response['placedAt']); + assertNotNull($response['suspendedAt']); + } + } + public function testRevertPlaceCandidateMutation(): void { $department = Department::factory()->create(); $this->poolCandidate->pool_candidate_status = PoolCandidateStatus::NEW_APPLICATION->name; $this->poolCandidate->placed_department_id = $department->id; $this->poolCandidate->placed_at = config('constants.far_past_date'); + $this->poolCandidate->suspended_at = config('constants.far_past_date'); $this->poolCandidate->save(); // cannot execute mutation due to candidate not being placed @@ -535,6 +591,7 @@ public function testRevertPlaceCandidateMutation(): void assertSame($response['status']['value'], PoolCandidateStatus::QUALIFIED_AVAILABLE->name); assertNull($response['placedAt']); assertNull($response['placedDepartment']); + assertNull($response['suspendedAt']); } public function testQualifyCandidateMutation(): void diff --git a/api/tests/Feature/UserTest.php b/api/tests/Feature/UserTest.php index fe8c3148f3b..4363eea456b 100644 --- a/api/tests/Feature/UserTest.php +++ b/api/tests/Feature/UserTest.php @@ -1729,6 +1729,7 @@ public function testCountApplicantsQuery(): void 'user_id' => $user['id'], ]); + // group one PoolCandidate::factory()->count(8)->create([ 'pool_id' => $pool1['id'], 'expiry_date' => config('constants.far_future_date'), @@ -1739,6 +1740,7 @@ public function testCountApplicantsQuery(): void 'looking_for_bilingual' => false, ]), ]); + // group two PoolCandidate::factory()->count(5)->create([ 'pool_id' => $pool1['id'], 'expiry_date' => config('constants.far_future_date'), @@ -1750,6 +1752,7 @@ public function testCountApplicantsQuery(): void ]), ]); // Should appear in searches, but in pool 2. + // group three PoolCandidate::factory()->create([ 'pool_id' => $pool2['id'], 'expiry_date' => config('constants.far_future_date'), @@ -1761,6 +1764,7 @@ public function testCountApplicantsQuery(): void ]), ]); // Expired in pool - should not appear in searches + // group four PoolCandidate::factory()->create([ 'pool_id' => $pool1['id'], 'expiry_date' => '2000-01-01', @@ -1771,11 +1775,13 @@ public function testCountApplicantsQuery(): void 'looking_for_bilingual' => false, ]), ]); - // Already placed - should not appear in searches + // Already placed - should appear in searches + // group five PoolCandidate::factory()->create([ 'pool_id' => $pool1['id'], 'expiry_date' => config('constants.far_future_date'), 'pool_candidate_status' => PoolCandidateStatus::PLACED_TERM->name, + 'suspended_at' => null, 'user_id' => User::factory([ 'looking_for_english' => true, 'looking_for_french' => false, @@ -1783,6 +1789,7 @@ public function testCountApplicantsQuery(): void ]), ]); // User status inactive - should not appear in searches + // group six PoolCandidate::factory()->create([ 'pool_id' => $pool1['id'], 'expiry_date' => config('constants.far_future_date'), @@ -1812,7 +1819,8 @@ public function testCountApplicantsQuery(): void ); $response->assertJson([ 'data' => [ - 'countApplicants' => 14, // including base admin user + // contains groups one, two, and five + 'countApplicants' => 15, // including base admin user ], ]); @@ -1834,7 +1842,8 @@ public function testCountApplicantsQuery(): void ] )->assertJson([ 'data' => [ - 'countApplicants' => 9, //including base admin user + // counts groups one and five, filtered out two due to added language + 'countApplicants' => 10, // including base admin user ], ]); }