feat: make email optional for under-16 participants except applicant

This commit is contained in:
fromme committed 2026-10-19 09:18:22 +02:00
1 parent b958d1a371
commit 1d9c852113
18 files changed
+435 -336

No files matched your search

+1 -3
View File
@@ -210,9 +210,7 @@ class BookingParticipantType extends AbstractType
if ($this->fieldStateProvider->shouldIncludeField('email', $bookingDto, $participantIndex)) {
$form->add('email', EmailType::class, $this->mergeFieldState([
'label' => 'E-Mail',
// Required for every participant (BPN 650); EditFieldStateProvider clears it
// again for internal agency bookings, where staff may leave data blank.
'required' => true,
'required' => false,
'property_path' => 'participant.email',
'attr' => [
'autocomplete' => 'leave-me-alone-chrome',
+49 -4
View File
@@ -213,6 +213,50 @@ class BookingDto
$this->applicantTravels = true;
}
/**
* Whether the participant at $index has to supply an email address.
*
* Everyone does except an under-16 who is not the applicant; BusPro itself accepts a blank
* for any participant. Validation enforces this in two parts, the Assert\When on
* ParticipantDto::$email (age) and validateFirstParticipantApplicant() (the applicant), and
* this is the same rule in one place for the field state. A participant that does not exist
* yet counts as needing one, matching the "unknown counts as an adult" stance of isChild().
*/
public function requiresParticipantEmail(int $index): bool
{
$participant = $this->getParticipant($index);
if (null === $participant || false === $participant->isChild()) {
return true;
}
return 0 === $index && true === $this->isFirstParticipantApplicant();
}
/**
* The applicant's email address, wherever the applicant is held.
*
* - participant[0] where they are the applicant
* - create, split: the separate applicant block
* - edit, split: the loaded booking's anmelder, since $applicant is only filled in create mode
*/
public function getApplicantEmail(): ?string
{
if (true === $this->isFirstParticipantApplicant()) {
$email = $this->getParticipant(0)?->email;
} elseif (self::MODE_CREATE === $this->getMode()) {
$email = $this->applicant?->email;
} else {
$email = $this->booking?->applicant?->communication->email;
}
if (null === $email || '' === trim($email)) {
return null;
}
return trim($email);
}
/**
* Whether participant[0] is also the applicant.
*
@@ -311,18 +355,19 @@ class BookingDto
}
/**
* Requires a complete address from participant[0] only where they are the applicant.
* Requires a complete address and an email from participant[0] only where they are the applicant.
*
* BusPro does not require an anschrift from an ordinary participant, so on a split booking
* the traveller at index 0 is not asked for one.
* BusPro asks neither of an ordinary participant, so on a split booking the traveller at index 0
* is not asked for an address, and an under-16 there may leave the email blank.
*/
#[Assert\Callback(groups: ['strict_required'])]
public function validateFirstParticipantAddress(ExecutionContextInterface $context): void
public function validateFirstParticipantApplicant(ExecutionContextInterface $context): void
{
if (false === $this->isFirstParticipantApplicant()) {
return;
}
$this->getParticipant(0)?->validateApplicantEmail($context, 'participants[0].');
$this->getParticipant(0)?->validateAddressFields($context, 'participants[0].');
}
+32 -4
View File
@@ -80,11 +80,17 @@ class ParticipantDto
#[Assert\NotNull(message: 'Bitte angeben', groups: ['strict_required'])]
public ?\DateTimeImmutable $dateOfBirth = null;
// 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.
// BusPro accepts a participant without an email address, so this is the only gate. Under-16s
// may leave it blank, everyone else needs one. An under-16 applicant needs one as well; that
// case depends on the booking and is enforced by BookingDto::validateFirstParticipantApplicant()
// and ParticipantEditDto::validateApplicant(). It stays a property constraint so the missing-field
// labels keep their order (see BookingEditPreFlightChecker).
#[Assert\Email(message: 'Bitte eine gültige E-Mail Adresse angeben', mode: 'strict', groups: ['booking_edit', 'booking_create'])]
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['strict_required'])]
#[Assert\When(
expression: 'not this.isChild()',
constraints: [new Assert\NotBlank(message: 'Bitte angeben')],
groups: ['strict_required'],
)]
public ?string $email = null;
public ?string $mobile = null;
@@ -530,6 +536,28 @@ class ParticipantDto
}
}
/**
* Validates that this person, as the booking's contact, has an email address.
*
* The property constraint on $email already holds everyone aged 16 or older (and anyone
* without a date of birth) to it, so this only adds the under-16 contact and never reports
* the same field twice. Like validateAddressFields(), the callers decide who the contact is.
*/
public function validateApplicantEmail(ExecutionContextInterface $context, string $pathPrefix = ''): void
{
if (false === $this->isChild()) {
return;
}
if (null !== $this->email && false === u($this->email)->trim()->isEmpty()) {
return;
}
$context->buildViolation('Bitte angeben')
->atPath($pathPrefix.'email')
->addViolation();
}
/**
* Validates that this person has a complete address.
*
+22 -5
View File
@@ -93,13 +93,13 @@ class ParticipantEditDto
}
/**
* Requires a complete address from the participant form's applicant only.
* Requires a complete address and an email from the participant form's applicant only.
*
* Mirrors BookingDto::validateFirstParticipantAddress() for the per-participant form, which
* Mirrors BookingDto::validateFirstParticipantApplicant() for the per-participant form, which
* binds this wrapper rather than the booking.
*/
#[Assert\Callback(groups: ['strict_required'])]
public function validateApplicantAddress(ExecutionContextInterface $context): void
public function validateApplicant(ExecutionContextInterface $context): void
{
if (false === $this->participant->isApplicant()) {
return;
@@ -109,6 +109,7 @@ class ParticipantEditDto
return;
}
$this->participant->validateApplicantEmail($context, 'participant.');
$this->participant->validateAddressFields($context, 'participant.');
}
@@ -117,7 +118,11 @@ class ParticipantEditDto
*
* Exempt from validation:
* - The applicant: can share an email with dependents they're booking for
* - Children (under 16): Can share email addresses with adults
* - Children (under 16): Can share email addresses with adults, or have none at all
*
* Where the applicant does not travel they are not among the participants, so their address
* is checked separately: an adult traveller may not reuse it, just as on an ordinary booking
* an adult may not reuse participant[0]'s.
*
* Email comparison is case-insensitive and whitespace is normalized.
*/
@@ -140,7 +145,7 @@ class ParticipantEditDto
return;
}
// Skip if email is null or empty (handled by @Email and @NotBlank constraints)
// Nothing to compare. Whether a blank is allowed is the property constraint's business.
if (null === $this->participant->email || u($this->participant->email)->trim()->isEmpty()) {
return;
}
@@ -178,5 +183,17 @@ class ParticipantEditDto
return;
}
}
if (true === $this->bookingContext->isFirstParticipantApplicant()) {
return;
}
$applicantEmail = $this->bookingContext->getApplicantEmail();
if (null !== $applicantEmail && $normalizedEmail === u($applicantEmail)->lower()->toString()) {
$context->buildViolation('Diese E-Mail Adresse wird bereits von der anmeldenden Person verwendet')
->atPath('participant.email')
->addViolation();
}
}
}
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Form\Service\Condition;
use App\Form\Model\BookingDto;
use App\Form\Service\Contract\FieldConditionInterface;
/**
* Condition that mirrors BookingDto::requiresParticipantEmail() into the field state layer.
*
* It deliberately holds no logic of its own, so the mandatory marker rendered next to the field
* is derived from the same method that describes what validation enforces, rather than from a
* second, independently maintained composition of age and applicant conditions.
*/
class RequiresOwnEmailCondition implements FieldConditionInterface
{
/**
* Evaluates whether the participant has to supply an email address of their own.
*
* @param BookingDto $bookingDto The current booking data (create or edit)
* @param int $participantIndex The index of the participant being evaluated
* @param array<string, mixed> $formData Current form data (unused)
*
* @return bool True if the participant needs their own email address
*/
public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool
{
return $bookingDto->requiresParticipantEmail($participantIndex);
}
/**
* The rule reads the date of birth, so the state has to be recalculated when it changes.
*
* @return string[] Array containing 'dateOfBirth'
*/
public function getDependentFields(): array
{
return ['dateOfBirth'];
}
public function getDescription(): string
{
return 'Checks if the participant needs an email address of their own';
}
}
@@ -21,6 +21,7 @@ use App\Form\Service\Condition\FirstParticipantReadOnlyCondition;
use App\Form\Service\Condition\MultipleParticipantsCondition;
use App\Form\Service\Condition\RentalInsuranceAvailableCondition;
use App\Form\Service\Condition\RentalSelectionCondition;
use App\Form\Service\Condition\RequiresOwnEmailCondition;
use App\Form\Service\Condition\RoomSelectionCondition;
use App\Form\Service\Condition\ServiceSubTypeCondition;
use App\Form\Service\Condition\SingleRoomTypeCondition;
@@ -112,10 +113,9 @@ class CreateFieldStateProvider extends AbstractFieldStateProvider
'static_text' => $firstParticipantReadOnlyCondition,
];
// Every participant needs an email address, children included (BPN 650), so
// requiredness is the form type's base option and not a condition here.
$this->fieldStateConditions['email'] = [
'static_text' => $firstParticipantReadOnlyCondition,
'required' => new RequiresOwnEmailCondition(),
];
// Mobile field: static text for first participant (when read-only). Mandatory only for the
+5 -1
View File
@@ -20,6 +20,7 @@ use App\Form\Service\Condition\PersonalDataMutabilityCondition;
use App\Form\Service\Condition\PickupsMutabilityCondition;
use App\Form\Service\Condition\RentalInsuranceAvailableCondition;
use App\Form\Service\Condition\RentalSelectionCondition;
use App\Form\Service\Condition\RequiresOwnEmailCondition;
use App\Form\Service\Condition\RoomSelectionCondition;
use App\Form\Service\Condition\ServiceSubTypeCondition;
use App\Form\Service\Condition\SkiPassSelectionCondition;
@@ -92,7 +93,10 @@ class EditFieldStateProvider extends AbstractFieldStateProvider
// data optional throughout.
$this->fieldStateConditions['email'] = [
'static_text' => $firstParticipantReadOnlyCondition,
'required' => CompositeCondition::not($internalAgencyBookingCondition),
'required' => CompositeCondition::and(
CompositeCondition::not($internalAgencyBookingCondition),
new RequiresOwnEmailCondition(),
),
];
// Address fields - render as static text if first participant in non-internal agency OR participant not mutable
@@ -1,110 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Form\Service;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Form\Service\Abstract\AbstractParticipantFieldHandler;
/**
* Handles the email field, pre-filling the applicant's address for under-16 participants.
*
* BusPro requires a Mail-Adresse for every participant once a room is present and rejects the
* step-3 Anfrage with 650 otherwise, children included. It does accept an address identical to
* the Anmelder's, so children inherit the applicant's rather than being asked for one they
* typically do not have. The value stays editable and stays mandatory. This happens in the
* create flow only; in edit mode the handler just stores what was submitted.
*
* The copy only ever fills a blank, so an address somebody typed is never overwritten. A later
* correction to the applicant's own address therefore does not propagate to children who already
* inherited the previous one.
*
* Depends on dateOfBirth: whether a participant counts as a child is only known once their date
* of birth has been processed.
*/
class ParticipantEmailFieldHandler extends AbstractParticipantFieldHandler
{
public function getFieldName(): string
{
return 'email';
}
/**
* @return string[]
*/
public function getDependencies(): array
{
return ['dateOfBirth'];
}
/**
* @param array<string, mixed> $submittedData The submitted participant form data
* @param BookingDto $bookingDto The booking DTO to update (create or edit)
* @param int $participantIndex The index of the participant being processed
*/
public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void
{
$participant = $this->getParticipant($bookingDto, $participantIndex);
if (false === $participant instanceof ParticipantDto) {
return;
}
$submitted = $this->getFieldValue($submittedData, $this->getFieldName());
if (true === is_string($submitted)) {
$participant->email = '' !== trim($submitted) ? trim($submitted) : null;
}
$this->applyApplicantEmail($bookingDto, $participant);
}
/**
* Copies the applicant's address onto a child who has none of their own.
*/
public function applyApplicantEmail(BookingDto $bookingDto, ParticipantDto $participant): void
{
// Create mode only. An existing booking already carries whatever BusPro accepted, a
// blank may be intended where personal data is optional, and participant[0] is not
// reliably the applicant there.
if (BookingDto::MODE_CREATE !== $bookingDto->getMode()) {
return;
}
if (false === $participant->isChild()) {
return;
}
$applicantEmail = $this->resolveApplicantEmail($bookingDto);
if (null === $applicantEmail) {
return;
}
// The applicant themselves never inherits, and on an ordinary booking that is participant[0].
if (true === $participant->isApplicant() && true === $bookingDto->applicantTravels) {
return;
}
if (null === $participant->email || '' === trim($participant->email)) {
$participant->email = $applicantEmail;
}
}
private function resolveApplicantEmail(BookingDto $bookingDto): ?string
{
$applicant = $bookingDto->applicantTravels
? ($bookingDto->participants[0] ?? null)
: $bookingDto->applicant;
$email = $applicant?->email;
if (null === $email || '' === trim($email)) {
return null;
}
return trim($email);
}
}
@@ -250,9 +250,6 @@ class ParticipantFieldHandlerRegistry
{
// Define fields that may be reassigned by handlers and need syncing
$serviceFields = [
// Not a service: ParticipantEmailFieldHandler pre-fills a child's blank email from
// the applicant, and without syncing it back the form would bind the blank over it.
'email',
'transportationOutbound',
'transportationInbound',
'insurance',