feat: split booking between participants and applicant

addresses #869f2q1vu
This commit is contained in:
fromme committed 2026-10-19 09:18:22 +02:00
1 parent 2d49212b08
commit 1ff293f41f
44 files changed
+2024 -316

No files matched your search

+66 -39
View File
@@ -21,8 +21,9 @@ class ParticipantDto
/**
* Age below which a participant counts as a child.
*
* Children may share an email address with an adult and are not required to have one of
* their own; both rules go through isChild(), so this is the only place the age lives.
* Children may share an email address with an adult, and have the applicant's address
* pre-filled for them; both rules go through isChild(), so this is the only place the
* age lives.
*/
public const CHILD_AGE_THRESHOLD = 16;
@@ -79,14 +80,11 @@ class ParticipantDto
#[Assert\NotNull(message: 'Bitte angeben', groups: ['strict_required'])]
public ?\DateTimeImmutable $dateOfBirth = null;
// Requiredness is delegated to requiresOwnEmail() so the constraint and the field state
// conditions cannot state the rule differently.
// Every participant needs an email address: BusPro rejects the step-3 Anfrage with 650
// otherwise, children included. Under-16s have the applicant's address pre-filled by
// ParticipantEmailFieldHandler and may override it, but may not leave it blank.
#[Assert\Email(message: 'Bitte eine gültige E-Mail Adresse angeben', mode: 'strict', groups: ['booking_edit', 'booking_create'])]
#[Assert\When(
expression: 'this.requiresOwnEmail()',
constraints: [new Assert\NotBlank(message: 'Bitte angeben')],
groups: ['strict_required']
)]
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['strict_required'])]
public ?string $email = null;
public ?string $mobile = null;
@@ -369,22 +367,6 @@ class ParticipantDto
*
* @return bool True if participant is under the age threshold, false otherwise or if age unknown
*/
/**
* Decides whether this participant has to supply an email address of their own.
*
* The applicant is the booking's contact and always has to be reachable. Everyone else only
* needs an address once they are old enough to have one, so children are exempt. An unknown
* date of birth counts as an adult, following isChild().
*
* Single source for the rule: the Assert\When on $email enforces it, and the 'required'
* field state conditions in CreateFieldStateProvider/EditFieldStateProvider render the
* matching mandatory marker.
*/
public function requiresOwnEmail(): bool
{
return $this->isApplicant() || false === $this->isChild();
}
public function isChild(int $ageThreshold = self::CHILD_AGE_THRESHOLD): bool
{
$age = $this->getAge();
@@ -495,28 +477,73 @@ class ParticipantDto
}
/**
* Validates that the participant has a complete address when required.
* Validates that this person is usable as the booking's applicant.
*
* In regular create/edit submission flows this only applies to participant 0.
* The edit overview preflight checks address fields separately for the applicant only.
* Runs on its own in the 'booking_applicant' group, which only the applicant form activates,
* the way 'applicant_address' already scopes the address NotNull above. BookingDto delegates
* to the same body for the step-2 submit, so the rule has one home.
*/
#[Assert\Callback(groups: ['strict_required'])]
public function validateApplicantAddress(ExecutionContextInterface $context): void
#[Assert\Callback(groups: ['booking_applicant'])]
public function validateAsApplicant(ExecutionContextInterface $context): void
{
// Only validate applicant's address in regular create/edit submission flows.
if (0 !== $this->index) {
// The applicant record is the only ParticipantDto without an index. Travellers carry one,
// and BookingDto cascades the 'booking_applicant' group into $participants as well, so
// without this guard every traveller would be held to the applicant's rules - an adult
// address and a minimum age of 18.
if (null !== $this->index) {
return;
}
$this->validateAddressFields($context);
$this->validateApplicantCompleteness($context);
}
private function validateAddressFields(ExecutionContextInterface $context): void
/**
* Shared body. $pathPrefix places the violations in the caller's object graph; it is not a
* callback itself, because Symfony passes the constraint payload as the second argument.
*/
public function validateApplicantCompleteness(ExecutionContextInterface $context, string $pathPrefix = ''): void
{
foreach (['firstName', 'lastName', 'gender', 'nationality', 'email', 'mobile'] as $field) {
$value = $this->$field;
if (null === $value || '' === trim($value)) {
$context->buildViolation('Bitte angeben')
->atPath($pathPrefix.$field)
->addViolation();
}
}
$this->validateAddressFields($context, $pathPrefix);
if (null === $this->dateOfBirth) {
$context->buildViolation('Bitte angeben')
->atPath($pathPrefix.'dateOfBirth')
->addViolation();
return;
}
$age = $this->getAge();
if (null !== $age && 18 > $age) {
$context->buildViolation('Die anmeldende Person muss volljährig sein.')
->atPath($pathPrefix.'dateOfBirth')
->addViolation();
}
}
/**
* Validates that this person has a complete address.
*
* Only the booking's contact needs one: BusPro does not require an anschrift from an
* ordinary participant (measured on 2036, an empty anschrift passes). Deciding who the
* contact is needs the booking, so the callers own the gate and this method only ever
* runs the field checks. $pathPrefix places the violations in the caller's object graph.
*/
public function validateAddressFields(ExecutionContextInterface $context, string $pathPrefix = ''): void
{
// Address object is required for the validated participant
if (null === $this->address) {
$context->buildViolation('Bitte angeben')
->atPath('address')
->atPath($pathPrefix.'address')
->addViolation();
return;
@@ -525,25 +552,25 @@ class ParticipantDto
// Validate address subfields
if (null === $this->address->street || u($this->address->street)->trim()->isEmpty()) {
$context->buildViolation('Bitte angeben')
->atPath('address.street')
->atPath($pathPrefix.'address.street')
->addViolation();
}
if (null === $this->address->postCode || u($this->address->postCode)->trim()->isEmpty()) {
$context->buildViolation('Bitte angeben')
->atPath('address.postCode')
->atPath($pathPrefix.'address.postCode')
->addViolation();
}
if (null === $this->address->city || u($this->address->city)->trim()->isEmpty()) {
$context->buildViolation('Bitte angeben')
->atPath('address.city')
->atPath($pathPrefix.'address.city')
->addViolation();
}
if (null === $this->address->country || u($this->address->country)->trim()->isEmpty()) {
$context->buildViolation('Bitte angeben')
->atPath('address.country')
->atPath($pathPrefix.'address.country')
->addViolation();
}
}