From 3c34cfa76a822a9c8fcec9b7631daff1cfa06aa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Tue, 10 Nov 2026 09:23:23 +0100 Subject: [PATCH] feat: offer the questionnaire for placements ended within a rolling window --- config/services.yaml | 13 +++---- src/Config/QuestionnaireCatalog.php | 14 ++++---- src/Controller/Teamer/IndexController.php | 9 ++--- src/Repository/DispositionRepository.php | 14 ++++---- src/Security/Voter/DispositionVoter.php | 11 +++--- tests/Config/QuestionnaireCatalogTest.php | 12 +++++++ ...DispositionRepositoryQuestionnaireTest.php | 13 ++++--- tests/Security/Voter/DispositionVoterTest.php | 34 +++++++++---------- 8 files changed, 65 insertions(+), 55 deletions(-) diff --git a/config/services.yaml b/config/services.yaml index b495f27..d4a9b2f 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -121,11 +121,10 @@ parameters: season_start: '11-01' season_end: '04-01' - # The teamer questionnaire after a placement (config/questionnaires/teamer_assignment.yaml) is - # offered for placements ending on or after this day (Y-m-d). Set it to the deployment day: - # without a cutoff every past placement would show up as pending on the teamers' dashboards. - # null offers it to nobody. - questionnaire_go_live: ~ + # The teamer questionnaires after a placement (config/questionnaires/teamer_feedback.yaml, plus + # bus_feedback.yaml for a bus escort) are offered for placements that ended within this many + # days. Without a window every past placement would show up as pending on the teamers' dashboards. + questionnaire_window_days: 30 # 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 @@ -190,7 +189,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%' + $questionnaireWindowDays: '%questionnaire_window_days%' $mailingBusTransport: '%mailing_bus_transport%' $seasonStart: '%season_start%' $seasonEnd: '%season_end%' @@ -516,5 +515,3 @@ when@test: # a job profile id ListControllerTest inserts itself - db_test has no job profiles assignment_export.job_profile_ids: [ 900001 ] mailing_bus_transport: 'sync' - # the questionnaire tests need it offered; any day before the placements they create - questionnaire_go_live: '2026-01-01' diff --git a/src/Config/QuestionnaireCatalog.php b/src/Config/QuestionnaireCatalog.php index 4e8a69b..4b2e16b 100644 --- a/src/Config/QuestionnaireCatalog.php +++ b/src/Config/QuestionnaireCatalog.php @@ -57,17 +57,17 @@ class QuestionnaireCatalog } /** - * The services.yaml parameter questionnaire_go_live as a date: the teamer questionnaire is - * offered for placements ending on or after it, and to nobody while it is null. + * The first end date the teamer questionnaire is still offered for: the services.yaml + * parameter questionnaire_window_days, in days before today. Placements that ended earlier are neither + * pending nor open, so a deploy does not offer it for every past placement at once. */ - public static function parseGoLive(?string $goLive): ?\DateTimeImmutable + public static function windowStart(int $windowDays): \DateTimeImmutable { - if (null === $goLive) { - return null; + if ($windowDays < 0) { + throw new \InvalidArgumentException(sprintf('questionnaire_window_days must not be negative, got %d', $windowDays)); } - return \DateTimeImmutable::createFromFormat('!Y-m-d', $goLive) - ?: throw new \InvalidArgumentException(sprintf('questionnaire_go_live "%s" is not a Y-m-d date', $goLive)); + return new \DateTimeImmutable(sprintf('today -%d days', $windowDays)); } /** diff --git a/src/Controller/Teamer/IndexController.php b/src/Controller/Teamer/IndexController.php index 8d6c001..704a808 100644 --- a/src/Controller/Teamer/IndexController.php +++ b/src/Controller/Teamer/IndexController.php @@ -72,12 +72,13 @@ class IndexController extends AbstractController $dispositionDocuments = $this->processDocuments($upcomingDispositions); - // offered to nobody until the go-live date is configured, as in DispositionVoter - $questionnaireGoLive = QuestionnaireCatalog::parseGoLive($this->getParameter('questionnaire_go_live')); - $pendingQuestionnaires = null === $questionnaireGoLive ? [] : $this + $pendingQuestionnaires = $this ->entityManager ->getRepository(Disposition::class) - ->findPendingQuestionnaireByTeamer($teamer, $questionnaireGoLive) + ->findPendingQuestionnaireByTeamer( + $teamer, + QuestionnaireCatalog::windowStart((int) $this->getParameter('questionnaire_window_days')), + ) ; // the list merges in season assignments; the document notices above stay on dispositions diff --git a/src/Repository/DispositionRepository.php b/src/Repository/DispositionRepository.php index e86ed9f..0420601 100644 --- a/src/Repository/DispositionRepository.php +++ b/src/Repository/DispositionRepository.php @@ -80,8 +80,8 @@ class DispositionRepository extends ServiceEntityRepository * The teamer's placements with a questionnaire still to be submitted - not started, or saved as * a draft: the teamer feedback, and after a bus escort the bus feedback too * (DispositionContext::questionnaires(), spelled out here in DQL). The rules are those of - * DispositionVoter::QUESTIONNAIRE - over (today included), on or after go-live, neither called - * off nor skip-formalities - so every row shown on the dashboard can be opened. + * DispositionVoter::QUESTIONNAIRE - over (today included), on or after $windowStart, neither + * called off nor skip-formalities - so every row shown on the dashboard can be opened. * * The responses are joined as arbitrary entities because the association is one-way; the * questionnaire key has to be part of each JOIN condition, or one questionnaire's response @@ -90,7 +90,7 @@ class DispositionRepository extends ServiceEntityRepository * Rows are [0 => Disposition, 'teamerStatus' => ?QuestionnaireResponseStatus, * 'busStatus' => ?QuestionnaireResponseStatus]. */ - public function getPendingQuestionnaireQuery(Teamer $teamer, \DateTimeImmutable $goLive): Query + public function getPendingQuestionnaireQuery(Teamer $teamer, \DateTimeImmutable $windowStart): Query { $qb = $this->createQueryBuilder('disposition'); @@ -115,7 +115,7 @@ class DispositionRepository extends ServiceEntityRepository $qb->expr()->neq('disposition.status', ':dispositionCalledOff'), $qb->expr()->notIn('assignment.status', ':assignmentStatuses'), $qb->expr()->eq('assignment.skipFormalities', ':skipFormalities'), - $qb->expr()->gte('destination.dateTo', ':goLive'), + $qb->expr()->gte('destination.dateTo', ':windowStart'), $qb->expr()->lte('destination.dateTo', ':today'), $qb->expr()->orX( $qb->expr()->isNull('teamer_response.id'), @@ -138,7 +138,7 @@ class DispositionRepository extends ServiceEntityRepository ->setParameter('dispositionCalledOff', Disposition::STATUS_CALLED_OFF) ->setParameter('assignmentStatuses', [Assignment::STATUS_CALLED_OFF, Assignment::STATUS_DELETED]) ->setParameter('skipFormalities', false) - ->setParameter('goLive', $goLive, Types::DATE_IMMUTABLE) + ->setParameter('windowStart', $windowStart, Types::DATE_IMMUTABLE) ->setParameter('today', new \DateTimeImmutable('today'), Types::DATE_IMMUTABLE) ->setParameter('draft', QuestionnaireResponseStatus::DRAFT) ->getQuery() @@ -148,7 +148,7 @@ class DispositionRepository extends ServiceEntityRepository /** * @return list */ - public function findPendingQuestionnaireByTeamer(Teamer $teamer, \DateTimeImmutable $goLive): array + public function findPendingQuestionnaireByTeamer(Teamer $teamer, \DateTimeImmutable $windowStart): array { return array_map( static fn (array $row): array => [ @@ -156,7 +156,7 @@ class DispositionRepository extends ServiceEntityRepository 'draft' => QuestionnaireResponseStatus::DRAFT === $row['teamerStatus'] || QuestionnaireResponseStatus::DRAFT === $row['busStatus'], ], - $this->getPendingQuestionnaireQuery($teamer, $goLive)->getResult(), + $this->getPendingQuestionnaireQuery($teamer, $windowStart)->getResult(), ); } diff --git a/src/Security/Voter/DispositionVoter.php b/src/Security/Voter/DispositionVoter.php index 58ba58b..7113e66 100644 --- a/src/Security/Voter/DispositionVoter.php +++ b/src/Security/Voter/DispositionVoter.php @@ -25,16 +25,13 @@ class DispositionVoter extends Voter public const MANAGE_RECEIPTS = 'MANAGE_RECEIPTS'; public const QUESTIONNAIRE = 'QUESTIONNAIRE'; - private readonly ?\DateTimeImmutable $questionnaireGoLive; - /** - * @param string|null $questionnaireGoLive Y-m-d, the services.yaml parameter questionnaire_go_live + * @param int $questionnaireWindowDays the services.yaml parameter questionnaire_window_days */ public function __construct( private readonly Security $security, - ?string $questionnaireGoLive, + private readonly int $questionnaireWindowDays, ) { - $this->questionnaireGoLive = QuestionnaireCatalog::parseGoLive($questionnaireGoLive); } protected function supports(string $attribute, mixed $subject): bool @@ -134,7 +131,7 @@ class DispositionVoter extends Voter */ private function assertQuestionnaireAllowed(TokenInterface $token, Disposition $disposition): bool { - if (null === $this->questionnaireGoLive || false === $this->assertTeamerAccess($token, $disposition)) { + if (false === $this->assertTeamerAccess($token, $disposition)) { return false; } @@ -146,7 +143,7 @@ class DispositionVoter extends Voter $dateTo = $disposition->getAssignment()?->getDestination()?->getDateTo(); return null !== $dateTo - && $dateTo >= $this->questionnaireGoLive + && $dateTo >= QuestionnaireCatalog::windowStart($this->questionnaireWindowDays) && $dateTo <= new \DateTimeImmutable('today'); } diff --git a/tests/Config/QuestionnaireCatalogTest.php b/tests/Config/QuestionnaireCatalogTest.php index 779c2e9..d419675 100644 --- a/tests/Config/QuestionnaireCatalogTest.php +++ b/tests/Config/QuestionnaireCatalogTest.php @@ -112,6 +112,18 @@ class QuestionnaireCatalogTest extends TestCase $this->assertSame('Alte Frage', $catalog->get('sample', 1)->getQuestion('answer')->label); } + public function testTheWindowStartsTheGivenDaysBeforeToday(): void + { + $this->assertEquals(new \DateTimeImmutable('today -2 days'), QuestionnaireCatalog::windowStart(2)); + } + + public function testANegativeWindowFailsLoudly(): void + { + $this->expectException(\InvalidArgumentException::class); + + QuestionnaireCatalog::windowStart(-1); + } + public function testUnknownQuestionnaireIsRejected(): void { $this->expectException(\InvalidArgumentException::class); diff --git a/tests/Repository/DispositionRepositoryQuestionnaireTest.php b/tests/Repository/DispositionRepositoryQuestionnaireTest.php index d05808f..c1345c8 100644 --- a/tests/Repository/DispositionRepositoryQuestionnaireTest.php +++ b/tests/Repository/DispositionRepositoryQuestionnaireTest.php @@ -25,8 +25,6 @@ class DispositionRepositoryQuestionnaireTest extends KernelTestCase { use QuestionnaireFixturesTrait; - private const string GO_LIVE = '2026-01-01'; - private EntityManagerInterface $entityManager; private DispositionRepository $repository; @@ -123,6 +121,13 @@ class DispositionRepositoryQuestionnaireTest extends KernelTestCase $this->assertSame([['disposition' => $disposition, 'draft' => false]], $this->pending()); } + public function testAnAssignmentEndingOnTheWindowsFirstDayIsPending(): void + { + $this->escort(dateTo: 'today -30 days'); + + $this->assertCount(1, $this->pending()); + } + public function testAnAssignmentEndingTodayIsPending(): void { $this->escort(dateTo: 'today'); @@ -149,7 +154,7 @@ class DispositionRepositoryQuestionnaireTest extends KernelTestCase { return [ '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'))], + 'ended before the window' => [static fn (Disposition $d) => $d->getAssignment()->getDestination()->setDateTo(new \DateTimeImmutable('today -31 days'))], 'skip formalities' => [static fn (Disposition $d) => $d->getAssignment()->setSkipFormalities(true)], 'called off' => [static fn (Disposition $d) => $d->setStatus(Disposition::STATUS_CALLED_OFF)], 'assignment called off' => [static fn (Disposition $d) => $d->getAssignment()->setStatus(Assignment::STATUS_CALLED_OFF)], @@ -172,6 +177,6 @@ class DispositionRepositoryQuestionnaireTest extends KernelTestCase { $this->entityManager->flush(); - return $this->repository->findPendingQuestionnaireByTeamer($this->teamer, new \DateTimeImmutable(self::GO_LIVE)); + return $this->repository->findPendingQuestionnaireByTeamer($this->teamer, QuestionnaireCatalog::windowStart(30)); } } diff --git a/tests/Security/Voter/DispositionVoterTest.php b/tests/Security/Voter/DispositionVoterTest.php index 43e4b53..058cdd4 100644 --- a/tests/Security/Voter/DispositionVoterTest.php +++ b/tests/Security/Voter/DispositionVoterTest.php @@ -159,7 +159,7 @@ class DispositionVoterTest extends TestCase $this->assertSame( $expected ? VoterInterface::ACCESS_GRANTED : VoterInterface::ACCESS_DENIED, - (new DispositionVoter($this->security, null))->vote($token, $disposition, [DispositionVoter::MANAGE_RECEIPTS]), + (new DispositionVoter($this->security, 30))->vote($token, $disposition, [DispositionVoter::MANAGE_RECEIPTS]), ); } @@ -217,15 +217,20 @@ class DispositionVoterTest extends TestCase { return [ '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'))], + 'ended before the window' => [static fn (Disposition $d) => $d->getAssignment()->getDestination()->setDateTo(new \DateTimeImmutable('today -31 days'))], '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 + public function testTheQuestionnaireIsStillOfferedOnTheWindowsLastDay(): void { - $this->assertQuestionnaire(false, $this->escortDisposition(), goLive: null); + $this->assertQuestionnaire(true, $this->escortDisposition(dateTo: 'today -30 days')); + } + + public function testTheWindowIsConfigurable(): void + { + $this->assertQuestionnaire(false, $this->escortDisposition(dateTo: 'today -8 days'), windowDays: 7); } public function testAnotherTeamerMayNotFillInTheQuestionnaire(): void @@ -247,18 +252,11 @@ class DispositionVoterTest extends TestCase $this->assertSame( VoterInterface::ACCESS_DENIED, - $this->questionnaireVote($disposition, (new User())->setTeamer($disposition->getTeamer()), '2026-01-01'), + $this->questionnaireVote($disposition, (new User())->setTeamer($disposition->getTeamer()), 30), ); } - 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 + private function assertQuestionnaire(bool $expected, Disposition $disposition, int $windowDays = 30, ?User $user = null): void { $this->security ->method('isGranted') @@ -267,20 +265,20 @@ class DispositionVoterTest extends TestCase $this->assertSame( $expected ? VoterInterface::ACCESS_GRANTED : VoterInterface::ACCESS_DENIED, - $this->questionnaireVote($disposition, $user ?? (new User())->setTeamer($disposition->getTeamer()), $goLive), + $this->questionnaireVote($disposition, $user ?? (new User())->setTeamer($disposition->getTeamer()), $windowDays), ); } - private function questionnaireVote(Disposition $disposition, User $user, ?string $goLive): int + private function questionnaireVote(Disposition $disposition, User $user, int $windowDays): int { $token = $this->createStub(TokenInterface::class); $token->method('getUser')->willReturn($user); - return (new DispositionVoter($this->security, $goLive))->vote($token, $disposition, [DispositionVoter::QUESTIONNAIRE]); + return (new DispositionVoter($this->security, $windowDays))->vote($token, $disposition, [DispositionVoter::QUESTIONNAIRE]); } /** - * A bus escort that ended yesterday, after the go-live date the tests use. + * A bus escort that ended yesterday, well within the 30-day window the tests use. */ private function escortDisposition(string $dateTo = 'yesterday'): Disposition { @@ -296,7 +294,7 @@ class DispositionVoterTest extends TestCase private function vote(string $attribute, Disposition $disposition): int { - return (new DispositionVoter($this->security, null))->vote( + return (new DispositionVoter($this->security, 30))->vote( $this->createStub(TokenInterface::class), $disposition, [$attribute],