diff --git a/config/services.yaml b/config/services.yaml index 4139e5b..2bc74dd 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -90,6 +90,12 @@ parameters: season_start: '11-01' season_end: '04-01' + # The teamer questionnaire after a bus escort (config/questionnaires/teamer_escort.yaml) is + # offered for assignments ending on or after this day (Y-m-d). Set it to the deployment day: + # without a cutoff every past escort would show up as pending on the teamers' dashboards. + # null offers it to nobody. + questionnaire_go_live: ~ + # How far ahead of the destination's first travel day a season assignment's checklist # entries fall due. A relative date expression, applied to destination.dateFrom, and the # single definition of both deadlines: the first is this offset, the second defaults to @@ -153,6 +159,7 @@ services: $emailTextsFile: '%kernel.project_dir%/config/email_texts.yaml' # Likewise parsed on demand, by App\Config\QuestionnaireCatalog. $questionnaireDir: '%kernel.project_dir%/config/questionnaires' + $questionnaireGoLive: '%questionnaire_go_live%' $mailingBusTransport: '%mailing_bus_transport%' $seasonStart: '%season_start%' $seasonEnd: '%season_end%' diff --git a/src/Security/Voter/DispositionVoter.php b/src/Security/Voter/DispositionVoter.php index 27d49c3..659f020 100644 --- a/src/Security/Voter/DispositionVoter.php +++ b/src/Security/Voter/DispositionVoter.php @@ -22,9 +22,21 @@ class DispositionVoter extends Voter public const FEEDBACK = 'FEEDBACK'; public const CALL_OFF = 'CALL_OFF'; public const MANAGE_RECEIPTS = 'MANAGE_RECEIPTS'; + public const QUESTIONNAIRE = 'QUESTIONNAIRE'; - public function __construct(private readonly Security $security) - { + private readonly ?\DateTimeImmutable $questionnaireGoLive; + + /** + * @param string|null $questionnaireGoLive Y-m-d, the services.yaml parameter questionnaire_go_live + */ + public function __construct( + private readonly Security $security, + ?string $questionnaireGoLive, + ) { + $this->questionnaireGoLive = null === $questionnaireGoLive + ? null + : (\DateTimeImmutable::createFromFormat('!Y-m-d', $questionnaireGoLive) + ?: throw new \InvalidArgumentException(sprintf('questionnaire_go_live "%s" is not a Y-m-d date', $questionnaireGoLive))); } protected function supports(string $attribute, mixed $subject): bool @@ -44,6 +56,7 @@ class DispositionVoter extends Voter static::FEEDBACK, static::CALL_OFF, static::MANAGE_RECEIPTS, + static::QUESTIONNAIRE, ]); } @@ -69,6 +82,7 @@ class DispositionVoter extends Voter static::CALL_OFF => $this->security->isGranted('ROLE_ADMINISTRATIVE') && Disposition::STATUS_CALLED_OFF !== $disposition->getStatus(), static::MANAGE_RECEIPTS => $this->assertManageReceiptsAllowed($token, $disposition), + static::QUESTIONNAIRE => $this->assertQuestionnaireAllowed($token, $disposition), default => false, }; } @@ -104,6 +118,36 @@ class DispositionVoter extends Voter ], true); } + /** + * Whether the teamer may fill in, and later read, the questionnaire after this placement + * (config/questionnaires/teamer_escort.yaml). Only the teamer themself: the office reads the + * submitted responses through ROLE_ADMINISTRATIVE, not through this attribute. + * + * "Over" is the destination's end date, not Disposition::STATUS_ENDED: the cron sets that a + * run late, and it moves on to checking_invoice and completed while the date stays true. An + * assignment ending today counts as over, as in DispositionRepository's feedback queries. + * + * Skip-formalities placements are left out, as they are from contract, invoice and feedback. + */ + private function assertQuestionnaireAllowed(TokenInterface $token, Disposition $disposition): bool + { + if (null === $this->questionnaireGoLive || false === $this->assertTeamerAccess($token, $disposition)) { + return false; + } + + if (true === $disposition->isSkipFormalities() + || Disposition::STATUS_CALLED_OFF === $disposition->getStatus() + || true !== $disposition->getAssignment()?->hasBusEscort()) { + return false; + } + + $dateTo = $disposition->getAssignment()->getDestination()?->getDateTo(); + + return null !== $dateTo + && $dateTo >= $this->questionnaireGoLive + && $dateTo <= new \DateTimeImmutable('today'); + } + private function assertHouseManagerAccess(TokenInterface $token, Disposition $disposition): bool { if (false === $this->security->isGranted('ROLE_HOUSE_MANAGER')) { diff --git a/tests/Security/Voter/DispositionVoterTest.php b/tests/Security/Voter/DispositionVoterTest.php index 9885ec8..c072742 100644 --- a/tests/Security/Voter/DispositionVoterTest.php +++ b/tests/Security/Voter/DispositionVoterTest.php @@ -6,6 +6,7 @@ namespace App\Tests\Security\Voter; use App\Entity\Application; use App\Entity\Assignment; +use App\Entity\Destination; use App\Entity\Disposition; use App\Entity\Teamer; use App\Entity\User; @@ -158,7 +159,7 @@ class DispositionVoterTest extends TestCase $this->assertSame( $expected ? VoterInterface::ACCESS_GRANTED : VoterInterface::ACCESS_DENIED, - (new DispositionVoter($this->security))->vote($token, $disposition, [DispositionVoter::MANAGE_RECEIPTS]), + (new DispositionVoter($this->security, null))->vote($token, $disposition, [DispositionVoter::MANAGE_RECEIPTS]), ); } @@ -173,9 +174,119 @@ class DispositionVoterTest extends TestCase ]; } + public function testTheTeamerMayFillInTheQuestionnaireAfterAnEscort(): void + { + $this->assertQuestionnaire(true, $this->escortDisposition()); + } + + /** + * Ending today already counts as over, as in DispositionRepository's feedback queries. + */ + public function testTheQuestionnaireOpensOnTheLastDay(): void + { + $this->assertQuestionnaire(true, $this->escortDisposition(dateTo: 'today')); + } + + /** + * @param \Closure(Disposition): mixed $change + */ + #[\PHPUnit\Framework\Attributes\DataProvider('questionnaireDenials')] + public function testTheQuestionnaireIsDenied(\Closure $change): void + { + $disposition = $this->escortDisposition(); + $change($disposition); + + $this->assertQuestionnaire(false, $disposition); + } + + /** + * @return array + */ + public static function questionnaireDenials(): array + { + return [ + 'no bus escort' => [static fn (Disposition $d) => $d->getAssignment()->setPickup(0)], + 'assignment not over yet' => [static fn (Disposition $d) => $d->getAssignment()->getDestination()->setDateTo(new \DateTimeImmutable('tomorrow'))], + 'ended before go-live' => [static fn (Disposition $d) => $d->getAssignment()->getDestination()->setDateTo(new \DateTimeImmutable('2025-12-31'))], + 'skip formalities' => [static fn (Disposition $d) => $d->getAssignment()->setSkipFormalities(true)], + 'called off' => [static fn (Disposition $d) => $d->setStatus(Disposition::STATUS_CALLED_OFF)], + ]; + } + + public function testTheQuestionnaireIsOfferedToNobodyWithoutAGoLiveDate(): void + { + $this->assertQuestionnaire(false, $this->escortDisposition(), goLive: null); + } + + public function testAnotherTeamerMayNotFillInTheQuestionnaire(): void + { + $this->assertQuestionnaire(false, $this->escortDisposition(), user: (new User())->setTeamer(new Teamer())); + } + + /** + * The office reads submitted responses through ROLE_ADMINISTRATIVE; filling one in on the + * teamer's behalf is not a thing. + */ + public function testTheOfficeMayNotFillInTheQuestionnaire(): void + { + $this->security + ->method('isGranted') + ->willReturnCallback(fn (string $role): bool => 'ROLE_ADMINISTRATIVE' === $role) + ; + $disposition = $this->escortDisposition(); + + $this->assertSame( + VoterInterface::ACCESS_DENIED, + $this->questionnaireVote($disposition, (new User())->setTeamer($disposition->getTeamer()), '2026-01-01'), + ); + } + + public function testAMalformedGoLiveDateFailsLoudly(): void + { + $this->expectException(\InvalidArgumentException::class); + + new DispositionVoter($this->security, '01.01.2026'); + } + + private function assertQuestionnaire(bool $expected, Disposition $disposition, ?string $goLive = '2026-01-01', ?User $user = null): void + { + $this->security + ->method('isGranted') + ->willReturnCallback(fn (string $role): bool => 'ROLE_TEAMER' === $role) + ; + + $this->assertSame( + $expected ? VoterInterface::ACCESS_GRANTED : VoterInterface::ACCESS_DENIED, + $this->questionnaireVote($disposition, $user ?? (new User())->setTeamer($disposition->getTeamer()), $goLive), + ); + } + + private function questionnaireVote(Disposition $disposition, User $user, ?string $goLive): int + { + $token = $this->createStub(TokenInterface::class); + $token->method('getUser')->willReturn($user); + + return (new DispositionVoter($this->security, $goLive))->vote($token, $disposition, [DispositionVoter::QUESTIONNAIRE]); + } + + /** + * A bus escort that ended yesterday, after the go-live date the tests use. + */ + private function escortDisposition(string $dateTo = 'yesterday'): Disposition + { + $destination = (new Destination())->setDateTo(new \DateTimeImmutable($dateTo)); + $assignment = (new Assignment()) + ->setDestination($destination) + ->setPickup(4711) + ->setSkipFormalities(false) + ; + + return (new Disposition(new Application($assignment, new Teamer())))->setStatus(Disposition::STATUS_ENDED); + } + private function vote(string $attribute, Disposition $disposition): int { - return (new DispositionVoter($this->security))->vote( + return (new DispositionVoter($this->security, null))->vote( $this->createStub(TokenInterface::class), $disposition, [$attribute],