Compare commits

...
5 Commits
13 changed files with 272 additions and 47 deletions

No files matched your search

+1
View File
@@ -19,6 +19,7 @@ composer_version: "2"
web_environment: [ ]
nodejs_version: "24"
corepack_enable: false
omit_containers: [ddev-ssh-agent]
# Key features of DDEV's config.yaml:
Generated
+41 -40
View File
@@ -468,16 +468,16 @@
},
{
"name": "doctrine/doctrine-bundle",
"version": "2.19.1",
"version": "2.19.2",
"source": {
"type": "git",
"url": "https://github.com/doctrine/DoctrineBundle.git",
"reference": "72b53688c30451a7c65addd50b2167d31459724a"
"reference": "d12ccf37ef6590d3d92a79c8ef550cab20db2754"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/doctrine/DoctrineBundle/zipball/72b53688c30451a7c65addd50b2167d31459724a",
"reference": "72b53688c30451a7c65addd50b2167d31459724a",
"url": "https://api.github.com/repos/doctrine/DoctrineBundle/zipball/d12ccf37ef6590d3d92a79c8ef550cab20db2754",
"reference": "d12ccf37ef6590d3d92a79c8ef550cab20db2754",
"shasum": ""
},
"require": {
@@ -572,7 +572,7 @@
],
"support": {
"issues": "https://github.com/doctrine/DoctrineBundle/issues",
"source": "https://github.com/doctrine/DoctrineBundle/tree/2.19.1"
"source": "https://github.com/doctrine/DoctrineBundle/tree/2.19.2"
},
"funding": [
{
@@ -588,7 +588,7 @@
"type": "tidelift"
}
],
"time": "2026-09-08T11:48:41+00:00"
"time": "2026-10-21T10:23:14+00:00"
},
{
"name": "doctrine/doctrine-migrations-bundle",
@@ -3217,20 +3217,21 @@
},
{
"name": "masterminds/html5",
"version": "2.11.0",
"version": "2.12.0",
"source": {
"type": "git",
"url": "https://github.com/Masterminds/html5-php.git",
"reference": "a1e7a2f88ee13635d86fc61cfbdf2306a76ddfc7"
"reference": "15e16663bd6f512352209d0e3a789c0a691c0d9b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Masterminds/html5-php/zipball/a1e7a2f88ee13635d86fc61cfbdf2306a76ddfc7",
"reference": "a1e7a2f88ee13635d86fc61cfbdf2306a76ddfc7",
"url": "https://api.github.com/repos/Masterminds/html5-php/zipball/15e16663bd6f512352209d0e3a789c0a691c0d9b",
"reference": "15e16663bd6f512352209d0e3a789c0a691c0d9b",
"shasum": ""
},
"require": {
"ext-dom": "*",
"ext-mbstring": "*",
"php": ">=7.4"
},
"require-dev": {
@@ -3278,9 +3279,9 @@
],
"support": {
"issues": "https://github.com/Masterminds/html5-php/issues",
"source": "https://github.com/Masterminds/html5-php/tree/2.11.0"
"source": "https://github.com/Masterminds/html5-php/tree/2.12.0"
},
"time": "2026-08-18T06:18:41+00:00"
"time": "2026-10-25T08:57:58+00:00"
},
{
"name": "mcp/sdk",
@@ -3474,16 +3475,16 @@
},
{
"name": "nesbot/carbon",
"version": "3.15.0",
"version": "3.15.1",
"source": {
"type": "git",
"url": "https://github.com/CarbonPHP/carbon.git",
"reference": "217d38553209bdb44e01590d9e9a2e4df8354cce"
"reference": "ce0e5636fae7d1e99856eb70cae4c719a284aa08"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/217d38553209bdb44e01590d9e9a2e4df8354cce",
"reference": "217d38553209bdb44e01590d9e9a2e4df8354cce",
"url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/ce0e5636fae7d1e99856eb70cae4c719a284aa08",
"reference": "ce0e5636fae7d1e99856eb70cae4c719a284aa08",
"shasum": ""
},
"require": {
@@ -3576,7 +3577,7 @@
"type": "tidelift"
}
],
"time": "2026-10-18T18:27:53+00:00"
"time": "2026-10-25T14:32:48+00:00"
},
{
"name": "nyholm/psr7",
@@ -12450,16 +12451,16 @@
},
{
"name": "composer/semver",
"version": "3.5.1",
"version": "3.5.2",
"source": {
"type": "git",
"url": "https://github.com/composer/semver.git",
"reference": "528948210cc46f967a4b98e83b19e76f2b504993"
"reference": "9dfce66f1e97360ea5e6d3e38ab14277e4cc591e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/composer/semver/zipball/528948210cc46f967a4b98e83b19e76f2b504993",
"reference": "528948210cc46f967a4b98e83b19e76f2b504993",
"url": "https://api.github.com/repos/composer/semver/zipball/9dfce66f1e97360ea5e6d3e38ab14277e4cc591e",
"reference": "9dfce66f1e97360ea5e6d3e38ab14277e4cc591e",
"shasum": ""
},
"require": {
@@ -12511,7 +12512,7 @@
"support": {
"irc": "ircs://irc.libera.chat:6697/composer",
"issues": "https://github.com/composer/semver/issues",
"source": "https://github.com/composer/semver/tree/3.5.1"
"source": "https://github.com/composer/semver/tree/3.5.2"
},
"funding": [
{
@@ -12523,7 +12524,7 @@
"type": "github"
}
],
"time": "2026-10-05T13:12:12+00:00"
"time": "2026-10-22T15:37:26+00:00"
},
{
"name": "composer/xdebug-handler",
@@ -13164,11 +13165,11 @@
},
{
"name": "phpstan/phpstan",
"version": "2.3.1",
"version": "2.3.2",
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/6155239b488e95d26d1ebd95c6b4bb294d7f09db",
"reference": "6155239b488e95d26d1ebd95c6b4bb294d7f09db",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/18bc23a0682f08591529377d271b5c4dfe9ed1dd",
"reference": "18bc23a0682f08591529377d271b5c4dfe9ed1dd",
"shasum": ""
},
"require": {
@@ -13224,7 +13225,7 @@
"type": "github"
}
],
"time": "2026-10-08T21:09:11+00:00"
"time": "2026-10-24T11:36:00+00:00"
},
{
"name": "phpstan/phpstan-doctrine",
@@ -13305,16 +13306,16 @@
},
{
"name": "phpstan/phpstan-symfony",
"version": "2.1.0",
"version": "2.1.1",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpstan-symfony.git",
"reference": "1981b94f0dd3a5bb42268f6a7a0e8954ac76dc2e"
"reference": "e218a49b5d24c9fd74903e22433fb10e16c33a53"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan-symfony/zipball/1981b94f0dd3a5bb42268f6a7a0e8954ac76dc2e",
"reference": "1981b94f0dd3a5bb42268f6a7a0e8954ac76dc2e",
"url": "https://api.github.com/repos/phpstan/phpstan-symfony/zipball/e218a49b5d24c9fd74903e22433fb10e16c33a53",
"reference": "e218a49b5d24c9fd74903e22433fb10e16c33a53",
"shasum": ""
},
"require": {
@@ -13373,9 +13374,9 @@
],
"support": {
"issues": "https://github.com/phpstan/phpstan-symfony/issues",
"source": "https://github.com/phpstan/phpstan-symfony/tree/2.1.0"
"source": "https://github.com/phpstan/phpstan-symfony/tree/2.1.1"
},
"time": "2026-10-03T15:54:38+00:00"
"time": "2026-10-25T14:57:26+00:00"
},
{
"name": "phpunit/php-code-coverage",
@@ -14352,21 +14353,21 @@
},
{
"name": "rector/rector",
"version": "2.7.0",
"version": "2.8.1",
"source": {
"type": "git",
"url": "https://github.com/rectorphp/rector.git",
"reference": "4d6a39b7fff302cac0ce90cd959a53c2f6a32776"
"reference": "10c96546bb72b38b4e2e9960fcd3f884d7fa02eb"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/rectorphp/rector/zipball/4d6a39b7fff302cac0ce90cd959a53c2f6a32776",
"reference": "4d6a39b7fff302cac0ce90cd959a53c2f6a32776",
"url": "https://api.github.com/repos/rectorphp/rector/zipball/10c96546bb72b38b4e2e9960fcd3f884d7fa02eb",
"reference": "10c96546bb72b38b4e2e9960fcd3f884d7fa02eb",
"shasum": ""
},
"require": {
"php": "^7.4|^8.0",
"phpstan/phpstan": "^2.3.0"
"phpstan/phpstan": "^2.3.2"
},
"conflict": {
"rector/rector-doctrine": "*",
@@ -14397,7 +14398,7 @@
],
"support": {
"issues": "https://github.com/rectorphp/rector/issues",
"source": "https://github.com/rectorphp/rector/tree/2.7.0"
"source": "https://github.com/rectorphp/rector/tree/2.8.1"
},
"funding": [
{
@@ -14405,7 +14406,7 @@
"type": "github"
}
],
"time": "2026-10-06T08:46:45+00:00"
"time": "2026-10-26T13:03:01+00:00"
},
{
"name": "sebastian/cli-parser",
+31
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\BusProNet\Model;
use App\Validator\Constraints as AppAssert;
use Symfony\Component\Validator\Constraints as Assert;
/**
@@ -15,17 +16,47 @@ use Symfony\Component\Validator\Constraints as Assert;
*/
class Communication
{
#[Assert\When(
expression: 'this.isPhoneNewlyEntered()',
constraints: [new AppAssert\PhoneNumber()],
groups: ['personal_data'],
)]
public ?string $phone = null;
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data'])]
#[Assert\When(
expression: 'this.isMobileNewlyEntered()',
constraints: [new AppAssert\PhoneNumber()],
groups: ['personal_data'],
)]
public ?string $mobile = null;
// The numbers as loaded from BusPro, which go back unchanged and are exempt from PhoneNumber.
public ?string $loadedPhone = null;
public ?string $loadedMobile = null;
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_identity'])]
#[Assert\Email(message: 'Bitte eine gültige Adresse angeben', mode: 'strict', groups: ['personal_identity'])]
public ?string $email = null;
public bool $newsletter = false;
public function rememberLoadedNumbers(): void
{
$this->loadedPhone = $this->phone;
$this->loadedMobile = $this->mobile;
}
public function isPhoneNewlyEntered(): bool
{
return AppAssert\PhoneNumber::isNewlyEntered($this->phone, $this->loadedPhone);
}
public function isMobileNewlyEntered(): bool
{
return AppAssert\PhoneNumber::isNewlyEntered($this->mobile, $this->loadedMobile);
}
/**
* Converts the communication data to API payload format.
*
@@ -50,6 +50,7 @@ class PersonalDataParser extends AbstractParser
$communication->mobile = $this->getStringOrNullValue($contactDataNode->filterXPath('//telefonmobil'));
$communication->email = $this->getStringOrNullValue($contactDataNode->filterXPath('//email'));
$communication->newsletter = $this->getBoolValue($contactDataNode->filterXPath('//newsletter'));
$communication->rememberLoadedNumbers();
$personalData->communication = $communication;
}
+3 -2
View File
@@ -4,11 +4,11 @@ declare(strict_types=1);
namespace App\Form;
use App\BusProNet\Form\CountryType;
use App\Form\Model\ParticipantDto;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\BirthdayType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\CountryType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
@@ -53,6 +53,8 @@ class BookingApplicantType extends AbstractType
->add('nationality', CountryType::class, [
'label' => 'Nationalität',
'required' => true,
'property' => 'nationality',
'placeholder' => 'Bitte auswählen...',
'preferred_choices' => ['D', 'A', 'CH'],
])
->add('dateOfBirth', BirthdayType::class, [
@@ -72,7 +74,6 @@ class BookingApplicantType extends AbstractType
->add('mobile', TextType::class, [
'label' => 'Telefon (mobil)',
'required' => true,
'sanitize_html' => true,
'attr' => [
'autocomplete' => 'leave-me-alone-chrome',
],
-1
View File
@@ -222,7 +222,6 @@ class BookingParticipantType extends AbstractType
$form->add('mobile', TextType::class, $this->mergeFieldState([
'label' => 'Telefon (mobil)',
'required' => false,
'sanitize_html' => true,
'property_path' => 'participant.mobile',
'attr' => [
'autocomplete' => 'leave-me-alone-chrome',
+15
View File
@@ -93,8 +93,17 @@ class ParticipantDto
)]
public ?string $email = null;
#[Assert\When(
expression: 'this.isMobileNewlyEntered()',
constraints: [new AppAssert\PhoneNumber()],
groups: ['booking_edit', 'booking_create'],
)]
public ?string $mobile = null;
// The number as loaded from BusPro, which goes back unchanged and is exempt from PhoneNumber.
// Public so it survives the session round trip through __unserialize().
public ?string $loadedMobile = null;
#[Assert\Valid(groups: ['booking_edit', 'booking_create'])]
#[Assert\NotNull(message: 'Bitte Adresse angeben', groups: ['applicant_address'])]
public ?Address $address = null;
@@ -240,6 +249,7 @@ class ParticipantDto
$instance->nationality = $personalData->nationality ?: 'D';
$instance->email = $personalData->communication->email;
$instance->mobile = $personalData->communication->mobile;
$instance->loadedMobile = $personalData->communication->mobile;
$instance->dateOfBirth = $personalData->dateOfBirth;
$instance->height = $personalData->height;
$instance->weight = $personalData->weight;
@@ -254,6 +264,11 @@ class ParticipantDto
return $instance;
}
public function isMobileNewlyEntered(): bool
{
return AppAssert\PhoneNumber::isNewlyEntered($this->mobile, $this->loadedMobile);
}
public function isApplicant(): bool
{
return 0 === $this->index;
+27
View File
@@ -8,6 +8,7 @@ use App\BusProNet\Model\Address;
use App\BusProNet\Model\PersonalData;
use App\BusProNet\Model\VoucherPaymentMethod;
use App\BusProNet\Model\VoucherTemplate;
use App\Validator\Constraints as AppAssert;
use Symfony\Component\Validator\Constraints as Assert;
/**
@@ -69,10 +70,24 @@ class VoucherPurchaseDto
#[Assert\Valid]
public Address $address;
#[Assert\When(
expression: 'this.isPhoneNewlyEntered()',
constraints: [new AppAssert\PhoneNumber()],
groups: ['voucher_step_2'],
)]
public ?string $phone = null;
#[Assert\When(
expression: 'this.isMobileNewlyEntered()',
constraints: [new AppAssert\PhoneNumber()],
groups: ['voucher_step_2'],
)]
public ?string $mobile = null;
// The numbers prefilled from BusPro, which are exempt from PhoneNumber.
public ?string $loadedPhone = null;
public ?string $loadedMobile = null;
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['voucher_step_2'])]
#[Assert\Email(message: 'Bitte eine gültige Adresse angeben', mode: 'strict', groups: ['voucher_step_2'])]
public ?string $email = null;
@@ -90,6 +105,16 @@ class VoucherPurchaseDto
$this->address->country = 'D';
}
public function isPhoneNewlyEntered(): bool
{
return AppAssert\PhoneNumber::isNewlyEntered($this->phone, $this->loadedPhone);
}
public function isMobileNewlyEntered(): bool
{
return AppAssert\PhoneNumber::isNewlyEntered($this->mobile, $this->loadedMobile);
}
public function getBuyerFullName(): string
{
return trim(sprintf('%s %s', $this->firstName ?? '', $this->lastName ?? ''));
@@ -119,6 +144,8 @@ class VoucherPurchaseDto
$this->address->country ??= 'D';
$this->phone = $personalData->communication->phone;
$this->mobile = $personalData->communication->mobile;
$this->loadedPhone = $this->phone;
$this->loadedMobile = $this->mobile;
$this->email = $loginEmail;
}
}
-2
View File
@@ -81,12 +81,10 @@ class PersonalDataType extends AbstractType
'label' => 'Telefon',
'required' => false,
'property_path' => 'communication.phone',
'sanitize_html' => true,
])
->add('mobile', TextType::class, [
'label' => 'Mobil',
'property_path' => 'communication.mobile',
'sanitize_html' => true,
])
;
}
-2
View File
@@ -55,12 +55,10 @@ class VoucherPurchaseStep2Type extends AbstractType
->add('phone', TextType::class, [
'label' => 'Telefon privat',
'required' => false,
'sanitize_html' => true,
])
->add('mobile', TextType::class, [
'label' => 'Mobil',
'required' => false,
'sanitize_html' => true,
])
;
}
+1
View File
@@ -67,6 +67,7 @@ class ParticipantDataPrefiller
$applicant->dateOfBirth = $prepopulated->dateOfBirth;
$applicant->email = $prepopulated->email;
$applicant->mobile = $prepopulated->mobile;
$applicant->loadedMobile = $prepopulated->loadedMobile;
$applicant->address = $prepopulated->address;
$applicant->height = $prepopulated->height;
$applicant->weight = $prepopulated->weight;
+44
View File
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Validator\Constraints;
use Symfony\Component\Validator\Constraints\Regex;
use Symfony\Component\Validator\Constraints\RegexValidator;
use function Symfony\Component\String\u;
/**
* Limits a phone number to digits, spaces, slashes and hyphens, with an optional leading plus.
*
* Only applied to numbers the user entered or changed: BusPro already holds numbers that break
* this rule, and one loaded from BusPro has to go back unchanged, or an edit is rejected for the
* mismatch. The DTOs remember the loaded number and gate the constraint with isNewlyEntered().
*/
#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
class PhoneNumber extends Regex
{
public const PATTERN = '~^\+?[0-9][0-9 /\-]*$~';
/** @param string[]|null $groups */
public function __construct(?array $groups = null, mixed $payload = null)
{
parent::__construct(
pattern: self::PATTERN,
message: 'Bitte nur Ziffern, Leerzeichen, /, - und ein führendes + verwenden',
groups: $groups,
payload: $payload,
);
}
public static function isNewlyEntered(?string $value, ?string $loaded): bool
{
return u($value ?? '')->trim()->toString() !== u($loaded ?? '')->trim()->toString();
}
public function validatedBy(): string
{
return RegexValidator::class;
}
}
@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace App\Tests\Validator\Constraints;
use App\BusProNet\Model\Communication;
use App\BusProNet\Model\PersonalData;
use App\Form\Model\ParticipantDto;
use App\Form\Model\VoucherPurchaseDto;
use App\Validator\Constraints\PhoneNumber;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Validator\Validation;
use Symfony\Component\Validator\Validator\ValidatorInterface;
class PhoneNumberTest extends TestCase
{
private ValidatorInterface $validator;
protected function setUp(): void
{
$this->validator = Validation::createValidatorBuilder()->enableAttributeMapping()->getValidator();
}
/** @return iterable<string, array{string, bool}> */
public static function numbers(): iterable
{
yield 'digits' => ['01711234567', true];
yield 'international' => ['+49 171 1234567', true];
yield 'hyphen' => ['0171-1234567', true];
yield 'slash' => ['0171/1234567', true];
yield 'brackets' => ['+49 (0) 171 1234567', false];
yield 'sanitized plus' => ['&#43;49 171 1234567', false];
yield 'plus inside' => ['0171+1234567', false];
yield 'letters' => ['0171 privat', false];
}
#[DataProvider('numbers')]
public function testOnlyDigitsSpacesHyphensAndALeadingPlusPass(string $number, bool $valid): void
{
self::assertSame($valid, 0 === $this->validator->validate($number, new PhoneNumber())->count());
}
public function testAnEnteredParticipantNumberIsValidated(): void
{
$participant = new ParticipantDto();
$participant->mobile = '0171 (1234567)';
self::assertCount(1, $this->validator->validateProperty($participant, 'mobile', ['booking_create']));
self::assertCount(1, $this->validator->validateProperty($participant, 'mobile', ['booking_edit']));
}
/**
* A number loaded from BusPro goes back unchanged, so it must not block the form, even when it
* is one the rule would reject.
*/
public function testAParticipantNumberLoadedFromBusProIsExemptUntilChanged(): void
{
$personalData = new PersonalData();
$personalData->communication->mobile = '&#43;49 171 1234567';
$participant = ParticipantDto::fromPersonalData($personalData);
self::assertCount(0, $this->validator->validateProperty($participant, 'mobile', ['booking_edit']));
$participant->mobile = '0171 (1234567)';
self::assertCount(1, $this->validator->validateProperty($participant, 'mobile', ['booking_edit']));
}
public function testTheExemptionSurvivesTheSessionRoundTrip(): void
{
$personalData = new PersonalData();
$personalData->communication->mobile = '+49 (171) 1234567';
$participant = unserialize(serialize(ParticipantDto::fromPersonalData($personalData)));
self::assertCount(0, $this->validator->validateProperty($participant, 'mobile', ['booking_edit']));
}
public function testCommunicationExemptsOnlyTheNumbersItWasLoadedWith(): void
{
$communication = new Communication();
$communication->phone = '030 (123456)';
$communication->mobile = '+49 (171) 1234567';
$communication->rememberLoadedNumbers();
self::assertCount(0, $this->validator->validate($communication, null, ['personal_data']));
$communication->phone = '030 (654321)';
$violations = $this->validator->validate($communication, null, ['personal_data']);
self::assertCount(1, $violations);
self::assertSame('phone', $violations->get(0)->getPropertyPath());
}
public function testVoucherBuyerNumbersAreExemptOnlyAsPrefilled(): void
{
$personalData = new PersonalData();
$personalData->communication->phone = '030 (123456)';
$personalData->communication->mobile = '+49 (171) 1234567';
$dto = new VoucherPurchaseDto();
$dto->prefillBuyer($personalData, null);
self::assertCount(0, $this->validator->validateProperty($dto, 'phone', ['voucher_step_2']));
self::assertCount(0, $this->validator->validateProperty($dto, 'mobile', ['voucher_step_2']));
$dto->mobile = '0171 (1234567)';
self::assertCount(1, $this->validator->validateProperty($dto, 'mobile', ['voucher_step_2']));
}
}