feat: make email optional for under-16 participants except applicant
This commit is contained in:
1 parent
55a9189284
commit
1ec9c972cd
18 files changed
+435
-336
No files matched your search
@@ -285,7 +285,7 @@ class BookingPayloadBuilder
|
||||
$participantData = [
|
||||
'@id' => $index + 1,
|
||||
'status' => self::DEFAULT_PARTICIPANT_STATUS,
|
||||
] + $this->buildPersonalDataPayload($participant, $applicantSource->email, true);
|
||||
] + $this->buildPersonalDataPayload($participant, true);
|
||||
|
||||
// Add body dimensions
|
||||
if (null !== $participant->height) {
|
||||
@@ -387,16 +387,12 @@ class BookingPayloadBuilder
|
||||
* Maps one person's personal data onto the payload shape shared by anmelder and teilnehmer.
|
||||
*
|
||||
* @param ParticipantDto $person The applicant or participant to map
|
||||
* @param string|null $fallbackEmail Used when the person has none of their own. BusPro
|
||||
* requires a Mail-Adresse for every participant once
|
||||
* a room is present and rejects the Anfrage with 650
|
||||
* otherwise, but accepts one shared with the Anmelder
|
||||
* @param bool $alwaysIncludeAddress Participants always carry the anschrift structure
|
||||
* even when empty; the anmelder only when set
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildPersonalDataPayload(ParticipantDto $person, ?string $fallbackEmail = null, bool $alwaysIncludeAddress = false): array
|
||||
private function buildPersonalDataPayload(ParticipantDto $person, bool $alwaysIncludeAddress = false): array
|
||||
{
|
||||
$data = [
|
||||
'name' => $person->lastName,
|
||||
@@ -421,9 +417,9 @@ class BookingPayloadBuilder
|
||||
];
|
||||
}
|
||||
|
||||
$email = null !== $person->email && '' !== trim($person->email)
|
||||
? $person->email
|
||||
: $fallbackEmail;
|
||||
// A participant may come without an email (under-16s, see ParticipantDto::$email), and
|
||||
// BusPro accepts that, so a blank is sent as a blank rather than borrowing the applicant's.
|
||||
$email = null !== $person->email && '' !== trim($person->email) ? $person->email : null;
|
||||
|
||||
if (null !== $email || null !== $person->mobile) {
|
||||
$data['kommunikation'] = [];
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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].');
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user