diff --git a/.env b/.env index 76c46ee..74f1b00 100644 --- a/.env +++ b/.env @@ -75,3 +75,5 @@ FEATURE_SANITIZE_UPLOADS=false FEATURE_STAMP_INVOICES=false UPLOAD_REPLACEMENT_FILE=assets/pdf/chicken.pdf + +PERSONAL_DATA_CHECK_DEADLINES=04-01,10-01 diff --git a/config/services.yaml b/config/services.yaml index b7ee4eb..ba4dc97 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -160,12 +160,15 @@ services: $datevEmailRecipient: '%env(DATEV_EMAIL_RECIPIENT)%' $datevEmailSender: '%env(DATEV_EMAIL_SENDER)%' - App\Security\RequiredCheck\RequiredTeamerCheckRegistry: + App\RequiredTeamerCheck\RequiredTeamerCheckRegistry: arguments: - $checks: !tagged_iterator app.required_teamer_check + $checks: + - '@App\RequiredTeamerCheck\DriverLicenseRequiredCheck' + - '@App\RequiredTeamerCheck\PersonalDataVerificationRequiredCheck' - App\Security\RequiredCheck\DriverLicenseRequiredCheck: - tags: [ 'app.required_teamer_check' ] + App\RequiredTeamerCheck\PersonalDataVerificationRequiredCheck: + arguments: + $dataVerificationDeadlines: '%env(csv:PERSONAL_DATA_CHECK_DEADLINES)%' app.upload_namer: class: App\Service\Upload\UploadNamer diff --git a/migrations/Version20260218173855.php b/migrations/Version20260218173855.php new file mode 100644 index 0000000..a372182 --- /dev/null +++ b/migrations/Version20260218173855.php @@ -0,0 +1,26 @@ +addSql("ALTER TABLE teamer ADD data_verified_at DATETIME DEFAULT NULL COMMENT '(DC2Type:datetime_immutable)'"); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE teamer DROP data_verified_at'); + } +} diff --git a/src/BusProNet/ApiClient.php b/src/BusProNet/ApiClient.php index c13cbfc..16bcf00 100644 --- a/src/BusProNet/ApiClient.php +++ b/src/BusProNet/ApiClient.php @@ -6,6 +6,7 @@ use App\BusProNet\Model\BaseDataResponse; use App\BusProNet\Model\CrmAttributesResponse; use App\BusProNet\Model\NotificationResponse; use App\BusProNet\Model\ProfileResponse; +use App\BusProNet\Model\ProfileUpdateResponse; use App\Entity\User; use League\Flysystem\FilesystemException; use League\Flysystem\FilesystemOperator; @@ -60,7 +61,7 @@ class ApiClient * @throws ApiClientException * @throws ResponseParserException */ - public function updateProfile(User $user, string $password): NotificationResponse|ProfileResponse + public function updateProfile(User $user, string $password): NotificationResponse|ProfileUpdateResponse { if (null === $teamer = $user->getTeamer()) { throw new ApiClientException('Invalid argument'); diff --git a/src/BusProNet/Model/ProfileUpdateResponse.php b/src/BusProNet/Model/ProfileUpdateResponse.php new file mode 100644 index 0000000..7d76df8 --- /dev/null +++ b/src/BusProNet/Model/ProfileUpdateResponse.php @@ -0,0 +1,46 @@ +addressId; + } + + public function setAddressId(?int $addressId): static + { + $this->addressId = $addressId; + + return $this; + } + + public function getPersonId(): ?int + { + return $this->personId; + } + + public function setPersonId(?int $personId): static + { + $this->personId = $personId; + + return $this; + } + + public function isUpdated(): bool + { + return $this->updated; + } + + public function setUpdated(bool $updated): static + { + $this->updated = $updated; + + return $this; + } +} diff --git a/src/BusProNet/ResponseParser.php b/src/BusProNet/ResponseParser.php index 45da370..9a3c9b2 100644 --- a/src/BusProNet/ResponseParser.php +++ b/src/BusProNet/ResponseParser.php @@ -13,6 +13,7 @@ use App\BusProNet\Model\Hotel; use App\BusProNet\Model\NotificationResponse; use App\BusProNet\Model\Pickup; use App\BusProNet\Model\ProfileResponse; +use App\BusProNet\Model\ProfileUpdateResponse; use Symfony\Component\OptionsResolver\OptionsResolver; class ResponseParser @@ -45,8 +46,9 @@ class ResponseParser $subType = (string) $xml->art; switch ($subType) { case 'Adressdaten': - case 'Adressdaten_Ändern': return $this->createProfileResponse($xml); + case 'Adressdaten_Ändern': + return $this->createProfileUpdateResponse($xml); case 'SelektionCRM': case 'SelektionCRM_Ändern': return $this->createCrmAttributesResponse($xml); @@ -138,6 +140,27 @@ class ResponseParser return $response; } + public function createProfileUpdateResponse(\SimpleXMLElement $xml): ProfileUpdateResponse + { + $addressId = (int) $xml->idadresse; + $personId = (int) $xml->idperson; + + $updated = false; + $updatedXml = $xml->xpath('änderung|aenderung'); + if (0 < count($updatedXml)) { + $updated = 'true' === strtolower((string) $updatedXml[0]); + } + + $response = new ProfileUpdateResponse(); + $response + ->setAddressId($addressId) + ->setPersonId($personId) + ->setUpdated($updated) + ; + + return $response; + } + public function createCrmAttributesResponse(\SimpleXMLElement $xml): CrmAttributesResponse { $groups = []; diff --git a/src/Controller/Teamer/Profile/IndexController.php b/src/Controller/Teamer/Profile/IndexController.php index 8f08e88..5cdccdf 100644 --- a/src/Controller/Teamer/Profile/IndexController.php +++ b/src/Controller/Teamer/Profile/IndexController.php @@ -9,6 +9,7 @@ use App\Entity\Upload; use App\Entity\User; use App\Form\TeamerProfileType; use App\Model\UploadSessionDto; +use App\RequiredTeamerCheck\PersonalDataVerificationRequiredCheck; use App\Service\Upload\UploadHandler; use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; @@ -27,6 +28,7 @@ class IndexController extends AbstractController private readonly ApiClient $apiClient, private readonly UploadHandler $uploadHandler, private readonly LoggerInterface $logger, + private readonly PersonalDataVerificationRequiredCheck $personalDataVerificationRequiredCheck, ) { } @@ -53,8 +55,11 @@ class IndexController extends AbstractController // Validate teamer data to show missing data right away $errors = $this->validator->validate($teamer, null, ['profile_preflight']); + $verificationMode = false === $this->personalDataVerificationRequiredCheck->isSatisfied($user); + $form = $this->createForm(TeamerProfileType::class, $teamer, [ 'upload_session' => $uploadSession, + 'verification_mode' => $verificationMode, ]); $form->handleRequest($request); @@ -66,6 +71,10 @@ class IndexController extends AbstractController } catch (ApiClientException $e) { } + if (true === $verificationMode) { + $teamer->setDataVerifiedAt(new \DateTimeImmutable()); + } + $this->entityManager->flush(); $this->addFlash('success', 'Deine Daten wurden aktualisiert'); @@ -81,6 +90,7 @@ class IndexController extends AbstractController 'form' => $form->createView(), 'teamer' => $teamer, 'errors' => $errors, + 'verification_mode' => $verificationMode, ]); } diff --git a/src/Entity/Teamer.php b/src/Entity/Teamer.php index e2a68ce..b3543c6 100644 --- a/src/Entity/Teamer.php +++ b/src/Entity/Teamer.php @@ -145,6 +145,9 @@ class Teamer implements TimestampableEntityInterface #[ORM\Column] private bool $allowOverlappingApplications = false; + #[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)] + private ?\DateTimeImmutable $dataVerifiedAt = null; + #[ORM\Column(nullable: true)] private ?bool $driverLicenseDeclaration = null; @@ -922,4 +925,16 @@ class Teamer implements TimestampableEntityInterface return $this; } + + public function getDataVerifiedAt(): ?\DateTimeImmutable + { + return $this->dataVerifiedAt; + } + + public function setDataVerifiedAt(?\DateTimeImmutable $dataVerifiedAt): static + { + $this->dataVerifiedAt = $dataVerifiedAt; + + return $this; + } } diff --git a/src/EventListener/RequiredTeamerCheckSubscriber.php b/src/EventListener/RequiredTeamerCheckSubscriber.php index 9d594d4..fbd112f 100644 --- a/src/EventListener/RequiredTeamerCheckSubscriber.php +++ b/src/EventListener/RequiredTeamerCheckSubscriber.php @@ -3,7 +3,7 @@ namespace App\EventListener; use App\Entity\User; -use App\Security\RequiredCheck\RequiredTeamerCheckRegistry; +use App\RequiredTeamerCheck\RequiredTeamerCheckRegistry; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpFoundation\RedirectResponse; diff --git a/src/Form/TeamerProfileType.php b/src/Form/TeamerProfileType.php index 6341062..edb8ab4 100644 --- a/src/Form/TeamerProfileType.php +++ b/src/Form/TeamerProfileType.php @@ -7,6 +7,7 @@ use App\Model\UploadSessionDto; use App\Service\Upload\UploadHandler; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\BirthdayType; +use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\CollectionType; use Symfony\Component\Form\Extension\Core\Type\TextType; @@ -14,6 +15,7 @@ use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormInterface; use Symfony\Component\Form\FormView; use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\Component\Validator\Constraints\IsTrue; class TeamerProfileType extends AbstractType { @@ -94,6 +96,19 @@ class TeamerProfileType extends AbstractType ], ]) ; + + if (true === $options['verification_mode']) { + $builder->add('confirmDataVerification', CheckboxType::class, [ + 'label' => 'Ich bestätige, dass meine Daten korrekt und aktuell sind', + 'mapped' => false, + 'constraints' => [ + new IsTrue([ + 'message' => 'Deine Bestätigung ist erforderlich', + 'groups' => ['profile'], + ]), + ], + ]); + } } public function buildView(FormView $view, FormInterface $form, array $options): void @@ -114,9 +129,11 @@ class TeamerProfileType extends AbstractType 'profile', ], 'anti_xss' => true, + 'verification_mode' => false, ]) ->setRequired(['upload_session']) ->setAllowedTypes('upload_session', UploadSessionDto::class) + ->setAllowedTypes('verification_mode', 'bool') ; } } diff --git a/src/Security/RequiredCheck/DriverLicenseRequiredCheck.php b/src/RequiredTeamerCheck/DriverLicenseRequiredCheck.php similarity index 94% rename from src/Security/RequiredCheck/DriverLicenseRequiredCheck.php rename to src/RequiredTeamerCheck/DriverLicenseRequiredCheck.php index 4d56b0d..6378529 100644 --- a/src/Security/RequiredCheck/DriverLicenseRequiredCheck.php +++ b/src/RequiredTeamerCheck/DriverLicenseRequiredCheck.php @@ -1,10 +1,11 @@ hasRole('ROLE_TEAMER'); + } + + public function isSatisfied(User $user): bool + { + $teamer = $user->getTeamer(); + + if (null === $teamer) { + return false; + } + + return false === $this->isVerificationRequired($teamer->getDataVerifiedAt()); + } + + public function getRouteName(): string + { + return 'app_teamer_profile_index'; + } + + private function isVerificationRequired(?\DateTimeImmutable $dataVerifiedAt): bool + { + $deadline = $this->getCurrentDeadline(CarbonImmutable::now()->startOfDay()); + + if (null === $deadline) { + return false; + } + + if (null === $dataVerifiedAt) { + return true; + } + + return CarbonImmutable::instance($dataVerifiedAt) < $deadline; + } + + private function getCurrentDeadline(CarbonImmutable $now): ?CarbonImmutable + { + $deadlines = array_filter(array_map('trim', $this->dataVerificationDeadlines)); + rsort($deadlines); + + foreach ($deadlines as $date) { + $deadline = CarbonImmutable::createFromFormat('Y-m-d', sprintf('%s-%s', $now->format('Y'), $date)); + + if (false === $deadline instanceof CarbonImmutable) { + continue; + } + + $deadline = $deadline->startOfDay(); + + if ($deadline <= $now) { + return $deadline; + } + } + + return null; + } +} diff --git a/src/Security/RequiredCheck/RequiredTeamerCheckInterface.php b/src/RequiredTeamerCheck/RequiredTeamerCheckInterface.php similarity index 87% rename from src/Security/RequiredCheck/RequiredTeamerCheckInterface.php rename to src/RequiredTeamerCheck/RequiredTeamerCheckInterface.php index a545fe4..7a1895f 100644 --- a/src/Security/RequiredCheck/RequiredTeamerCheckInterface.php +++ b/src/RequiredTeamerCheck/RequiredTeamerCheckInterface.php @@ -1,6 +1,6 @@ Mein Profil + {% if verification_mode %} +
+ Bitte überprüfe deine Daten und bestätige sie am Ende des Formulars. +
+ {% endif %} {% if not form.vars.valid %}
{% include '_partials/_form_errors.html.twig' with { 'form': form } %} @@ -189,8 +194,14 @@ } %}
+ {% if verification_mode %} +
+ {{ form_row(form.confirmDataVerification) }} +
+ {% endif %} + diff --git a/tests/BusProNet/ResponseParserTest.php b/tests/BusProNet/ResponseParserTest.php index fdc1e9b..2fa3922 100644 --- a/tests/BusProNet/ResponseParserTest.php +++ b/tests/BusProNet/ResponseParserTest.php @@ -8,6 +8,7 @@ use App\BusProNet\Model\CrmAttributesResponse; use App\BusProNet\Model\Hotel; use App\BusProNet\Model\Pickup; use App\BusProNet\Model\ProfileResponse; +use App\BusProNet\Model\ProfileUpdateResponse; use App\BusProNet\ResponseParser; use PHPUnit\Framework\TestCase; @@ -47,6 +48,19 @@ class ResponseParserTest extends TestCase $this->assertTrue($response->isTeamer()); } + public function testParseSuccessfulProfileUpdateResponseWithoutAddressData(): void + { + $content = 'Adressdaten_Ändern141747224526<änderung>True'; + + $parser = $this->getParserInstance(); + $response = $parser->parseXmlString(ApiClient::TYPE_CUSTOMER_DATA, $content); + + $this->assertInstanceOf(ProfileUpdateResponse::class, $response); + $this->assertSame(141747, $response->getAddressId()); + $this->assertSame(224526, $response->getPersonId()); + $this->assertTrue($response->isUpdated()); + } + public function testParseSuccessfulPickupsResponse(): void { $content = 'KarlsruheA5 - Autohof BruchsalBUSFalseTrueTrueBonnBahnhofBUSFalseTrueTrue'; @@ -100,4 +114,4 @@ class ResponseParserTest extends TestCase 'bpn_crm_id_teamer' => 1070, ]); } -} \ No newline at end of file +} diff --git a/tests/Security/RequiredCheck/DriverLicenseRequiredCheckTest.php b/tests/Security/RequiredCheck/DriverLicenseRequiredCheckTest.php index 74ed1d3..d4d5dc8 100644 --- a/tests/Security/RequiredCheck/DriverLicenseRequiredCheckTest.php +++ b/tests/Security/RequiredCheck/DriverLicenseRequiredCheckTest.php @@ -7,7 +7,7 @@ namespace App\Tests\Security\RequiredCheck; use App\Entity\Teamer; use App\Entity\Upload; use App\Entity\User; -use App\Security\RequiredCheck\DriverLicenseRequiredCheck; +use App\RequiredTeamerCheck\DriverLicenseRequiredCheck; use PHPUnit\Framework\TestCase; class DriverLicenseRequiredCheckTest extends TestCase diff --git a/tests/Security/RequiredCheck/PersonalDataVerificationRequiredCheckTest.php b/tests/Security/RequiredCheck/PersonalDataVerificationRequiredCheckTest.php new file mode 100644 index 0000000..e782647 --- /dev/null +++ b/tests/Security/RequiredCheck/PersonalDataVerificationRequiredCheckTest.php @@ -0,0 +1,96 @@ +setRoles(['ROLE_TEAMER']); + + $this->assertTrue($check->appliesTo($user)); + } + + public function testIsNotSatisfiedWhenTeamerIsMissing(): void + { + $check = new PersonalDataVerificationRequiredCheck(); + $user = (new User())->setRoles(['ROLE_TEAMER']); + + $this->assertFalse($check->isSatisfied($user)); + } + + public function testIsSatisfiedBeforeFirstDeadlineEvenWithoutVerification(): void + { + CarbonImmutable::setTestNow('2026-01-10 12:00:00'); + $check = new PersonalDataVerificationRequiredCheck(['01-15', '07-15']); + + $teamer = new Teamer(); + $user = (new User()) + ->setRoles(['ROLE_TEAMER']) + ->setTeamer($teamer) + ; + + $this->assertTrue($check->isSatisfied($user)); + } + + public function testIsNotSatisfiedAfterFirstDeadlineWhenNeverVerified(): void + { + CarbonImmutable::setTestNow('2026-01-20 12:00:00'); + $check = new PersonalDataVerificationRequiredCheck(['01-15', '07-15']); + + $teamer = new Teamer(); + $user = (new User()) + ->setRoles(['ROLE_TEAMER']) + ->setTeamer($teamer) + ; + + $this->assertFalse($check->isSatisfied($user)); + } + + public function testIsSatisfiedWhenVerifiedAfterCurrentDeadline(): void + { + CarbonImmutable::setTestNow('2026-05-20 12:00:00'); + $check = new PersonalDataVerificationRequiredCheck(['01-15', '07-15']); + + $teamer = (new Teamer())->setDataVerifiedAt(new \DateTimeImmutable('2026-01-16 10:00:00')); + $user = (new User()) + ->setRoles(['ROLE_TEAMER']) + ->setTeamer($teamer) + ; + + $this->assertTrue($check->isSatisfied($user)); + } + + public function testIsNotSatisfiedWhenVerificationIsOutdated(): void + { + CarbonImmutable::setTestNow('2026-07-20 12:00:00'); + $check = new PersonalDataVerificationRequiredCheck(['01-15', '07-15']); + + $teamer = (new Teamer())->setDataVerifiedAt(new \DateTimeImmutable('2026-02-01 10:00:00')); + $user = (new User()) + ->setRoles(['ROLE_TEAMER']) + ->setTeamer($teamer) + ; + + $this->assertFalse($check->isSatisfied($user)); + } +}