feat: enforce profile data completeness before entering booking flow
This commit is contained in:
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\BusProNet\Model;
|
namespace App\BusProNet\Model;
|
||||||
|
|
||||||
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents a physical address with street, postal code, city, and country information.
|
* Represents a physical address with street, postal code, city, and country information.
|
||||||
*
|
*
|
||||||
@@ -13,14 +15,18 @@ namespace App\BusProNet\Model;
|
|||||||
*/
|
*/
|
||||||
class Address
|
class Address
|
||||||
{
|
{
|
||||||
|
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data'])]
|
||||||
public ?string $street = null;
|
public ?string $street = null;
|
||||||
|
|
||||||
|
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data'])]
|
||||||
public ?string $postCode = null;
|
public ?string $postCode = null;
|
||||||
|
|
||||||
|
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data'])]
|
||||||
public ?string $city = null;
|
public ?string $city = null;
|
||||||
|
|
||||||
public ?string $district = null;
|
public ?string $district = null;
|
||||||
|
|
||||||
|
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data'])]
|
||||||
public ?string $country = null;
|
public ?string $country = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -31,11 +31,18 @@ class PersonalData
|
|||||||
|
|
||||||
public ?string $salutation = null;
|
public ?string $salutation = null;
|
||||||
public ?string $title = null;
|
public ?string $title = null;
|
||||||
|
|
||||||
|
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data'])]
|
||||||
|
#[Assert\Choice(choices: ['M', 'W', 'D'], message: 'Bitte gib einen gültigen Wert an', groups: ['personal_data'])]
|
||||||
public ?string $gender = null;
|
public ?string $gender = null;
|
||||||
|
|
||||||
|
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data'])]
|
||||||
public ?string $nationality = null;
|
public ?string $nationality = null;
|
||||||
public ?string $height = null;
|
public ?string $height = null;
|
||||||
public ?string $shoeSize = null;
|
public ?string $shoeSize = null;
|
||||||
public ?string $weight = null;
|
public ?string $weight = null;
|
||||||
|
|
||||||
|
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data'])]
|
||||||
public ?\DateTimeImmutable $dateOfBirth = null;
|
public ?\DateTimeImmutable $dateOfBirth = null;
|
||||||
public ?string $remarks = null;
|
public ?string $remarks = null;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Controller\Account;
|
namespace App\Controller\Account;
|
||||||
|
|
||||||
use App\BusProNet\ApiClient;
|
use App\BusProNet\ApiClient;
|
||||||
@@ -7,9 +9,11 @@ use App\BusProNet\Exception\ApiClientException;
|
|||||||
use App\BusProNet\Model\Notification;
|
use App\BusProNet\Model\Notification;
|
||||||
use App\BusProNet\Model\PersonalData;
|
use App\BusProNet\Model\PersonalData;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
|
use App\EventSubscriber\ProfileCompletionSubscriber;
|
||||||
use App\Form\PersonalDataType;
|
use App\Form\PersonalDataType;
|
||||||
use App\Security\Crypt;
|
use App\Security\Crypt;
|
||||||
use App\Service\BookingEditDataLoaderService;
|
use App\Service\BookingEditDataLoaderService;
|
||||||
|
use App\Service\ProfileCompletenessChecker;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
@@ -27,15 +31,17 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
|||||||
class PersonalDataController extends AbstractController
|
class PersonalDataController extends AbstractController
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* @param ApiClient $apiClient BusProNet API client for data operations
|
* @param ApiClient $apiClient BusProNet API client for data operations
|
||||||
* @param Crypt $crypt Encryption service for password handling
|
* @param Crypt $crypt Encryption service for password handling
|
||||||
* @param BookingEditDataLoaderService $dataLoader Data loader for cache invalidation
|
* @param BookingEditDataLoaderService $dataLoader Data loader for cache invalidation
|
||||||
* @param LoggerInterface $logger Logger for audit trails and debugging
|
* @param ProfileCompletenessChecker $completenessChecker Profile validation service
|
||||||
|
* @param LoggerInterface $logger Logger for audit trails and debugging
|
||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ApiClient $apiClient,
|
private readonly ApiClient $apiClient,
|
||||||
private readonly Crypt $crypt,
|
private readonly Crypt $crypt,
|
||||||
private readonly BookingEditDataLoaderService $dataLoader,
|
private readonly BookingEditDataLoaderService $dataLoader,
|
||||||
|
private readonly ProfileCompletenessChecker $completenessChecker,
|
||||||
private readonly LoggerInterface $logger,
|
private readonly LoggerInterface $logger,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
@@ -98,6 +104,16 @@ class PersonalDataController extends AbstractController
|
|||||||
$this->logger->info('Updated personal data', [
|
$this->logger->info('Updated personal data', [
|
||||||
'email' => $user->getEmail(),
|
'email' => $user->getEmail(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Handle profile completion redirect
|
||||||
|
$session = $request->getSession();
|
||||||
|
$redirectUrl = $session->get(ProfileCompletionSubscriber::SESSION_REDIRECT_KEY);
|
||||||
|
|
||||||
|
if (null !== $redirectUrl && true === $this->completenessChecker->isComplete($personalData)) {
|
||||||
|
$session->remove(ProfileCompletionSubscriber::SESSION_REDIRECT_KEY);
|
||||||
|
|
||||||
|
return $this->redirect($redirectUrl);
|
||||||
|
}
|
||||||
} catch (ApiClientException $e) {
|
} catch (ApiClientException $e) {
|
||||||
$this->addFlash('error', $e->getMessage());
|
$this->addFlash('error', $e->getMessage());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\EventSubscriber;
|
||||||
|
|
||||||
|
use App\BusProNet\ApiClient;
|
||||||
|
use App\BusProNet\Exception\ApiClientException;
|
||||||
|
use App\BusProNet\Model\PersonalData;
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Security\Crypt;
|
||||||
|
use App\Service\ProfileCompletenessChecker;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||||
|
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||||
|
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||||
|
use Symfony\Component\Security\Http\Event\LoginSuccessEvent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks profile completeness after successful login and redirects to profile page if incomplete.
|
||||||
|
*
|
||||||
|
* This subscriber intercepts the login success flow to validate that users have complete
|
||||||
|
* profile data before proceeding. When incomplete profiles are detected, the original
|
||||||
|
* target URL is stored in the session and the user is redirected to complete their data.
|
||||||
|
*/
|
||||||
|
class ProfileCompletionSubscriber implements EventSubscriberInterface
|
||||||
|
{
|
||||||
|
public const SESSION_REDIRECT_KEY = '_profile_completion_redirect';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly ApiClient $apiClient,
|
||||||
|
private readonly Crypt $crypt,
|
||||||
|
private readonly ProfileCompletenessChecker $completenessChecker,
|
||||||
|
private readonly UrlGeneratorInterface $urlGenerator,
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getSubscribedEvents(): array
|
||||||
|
{
|
||||||
|
// Use lower priority to run after the authenticator sets the response
|
||||||
|
return [
|
||||||
|
LoginSuccessEvent::class => ['onLoginSuccess', -10],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function onLoginSuccess(LoginSuccessEvent $event): void
|
||||||
|
{
|
||||||
|
$user = $event->getUser();
|
||||||
|
|
||||||
|
if (false === $user instanceof User) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$email = $user->getEmail();
|
||||||
|
$password = $this->crypt->decrypt($user->getPassword());
|
||||||
|
|
||||||
|
try {
|
||||||
|
$personalData = $this->apiClient->getPersonalData($email, $password);
|
||||||
|
} catch (ApiClientException $e) {
|
||||||
|
$this->logger->warning('Failed to fetch personal data for profile completeness check', [
|
||||||
|
'email' => $email,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (false === $personalData instanceof PersonalData) {
|
||||||
|
$this->logger->warning('Invalid response when fetching personal data for profile completeness check', [
|
||||||
|
'email' => $email,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (true === $this->completenessChecker->isComplete($personalData)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->logger->info('Incomplete profile detected, redirecting to profile completion', [
|
||||||
|
'email' => $email,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$request = $event->getRequest();
|
||||||
|
$session = $request->getSession();
|
||||||
|
|
||||||
|
// Store the original target URL (from authenticator's response or target path)
|
||||||
|
$originalResponse = $event->getResponse();
|
||||||
|
$targetUrl = null;
|
||||||
|
|
||||||
|
if ($originalResponse instanceof RedirectResponse) {
|
||||||
|
$targetUrl = $originalResponse->getTargetUrl();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't redirect back to the personal data page itself
|
||||||
|
$personalDataUrl = $this->urlGenerator->generate('app_personal_data');
|
||||||
|
if (null !== $targetUrl && $targetUrl !== $personalDataUrl) {
|
||||||
|
$session->set(self::SESSION_REDIRECT_KEY, $targetUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override the response to redirect to personal data page
|
||||||
|
$event->setResponse(new RedirectResponse($personalDataUrl));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Form;
|
namespace App\Form;
|
||||||
|
|
||||||
use App\BusProNet\Form\CountryType;
|
use App\BusProNet\Form\CountryType;
|
||||||
use Symfony\Component\Form\AbstractType;
|
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\EmailType;
|
use Symfony\Component\Form\Extension\Core\Type\EmailType;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||||
use Symfony\Component\Form\FormBuilderInterface;
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
@@ -13,6 +17,23 @@ class PersonalDataType extends AbstractType
|
|||||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||||
{
|
{
|
||||||
$builder
|
$builder
|
||||||
|
->add('gender', ChoiceType::class, [
|
||||||
|
'label' => 'Gender',
|
||||||
|
'placeholder' => false,
|
||||||
|
'choices' => [
|
||||||
|
'männlich' => 'M',
|
||||||
|
'weiblich' => 'W',
|
||||||
|
'divers' => 'D',
|
||||||
|
],
|
||||||
|
'invalid_message' => 'Bitte gib einen gültigen Wert an',
|
||||||
|
])
|
||||||
|
->add('dateOfBirth', BirthdayType::class, [
|
||||||
|
'label' => 'Geburtsdatum',
|
||||||
|
'widget' => 'text',
|
||||||
|
'input' => 'datetime_immutable',
|
||||||
|
'html5' => false,
|
||||||
|
'invalid_message' => 'Bitte gib ein gültiges Datum ein',
|
||||||
|
])
|
||||||
->add('street', TextType::class, [
|
->add('street', TextType::class, [
|
||||||
'label' => 'Straße',
|
'label' => 'Straße',
|
||||||
'property_path' => 'address.street',
|
'property_path' => 'address.street',
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use App\BusProNet\Model\PersonalData;
|
||||||
|
use Symfony\Component\Validator\Validator\ValidatorInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates profile completeness for booking requirements.
|
||||||
|
*
|
||||||
|
* Checks whether a user's personal data contains all required fields needed to create
|
||||||
|
* or edit bookings using Symfony's validation system with the 'personal_data' group.
|
||||||
|
* Required fields include name, gender, date of birth, nationality, contact information,
|
||||||
|
* and full address data.
|
||||||
|
*/
|
||||||
|
class ProfileCompletenessChecker
|
||||||
|
{
|
||||||
|
private const VALIDATION_GROUP = 'personal_data';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly ValidatorInterface $validator,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the personal data contains all required fields for booking operations.
|
||||||
|
*
|
||||||
|
* Validates against the 'personal_data' group which includes:
|
||||||
|
* - firstName and name (personal identification)
|
||||||
|
* - gender and dateOfBirth (personal attributes)
|
||||||
|
* - nationality (booking form requirement)
|
||||||
|
* - email and mobile (communication)
|
||||||
|
* - street, postCode, city, country (applicant address)
|
||||||
|
*
|
||||||
|
* @param PersonalData $personalData The personal data to validate
|
||||||
|
*
|
||||||
|
* @return bool True if all required fields are present and valid, false otherwise
|
||||||
|
*/
|
||||||
|
public function isComplete(PersonalData $personalData): bool
|
||||||
|
{
|
||||||
|
$violations = $this->validator->validate($personalData, null, [self::VALIDATION_GROUP]);
|
||||||
|
|
||||||
|
return 0 === $violations->count();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +1,27 @@
|
|||||||
{% extends 'layout.html.twig' %}
|
{% extends 'layout.html.twig' %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% include '_partials/_flashes.html.twig' %}
|
||||||
<div class="px-4 lg:px-8 py-8 lg:py-16">
|
<div class="px-4 lg:px-8 py-8 lg:py-16">
|
||||||
<h1 class="text-white uppercase pb-4">
|
<h1 class="text-white uppercase pb-4">
|
||||||
Meine<br>Daten
|
Meine<br>Daten
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
{% if app.session.get('_profile_completion_redirect') %}
|
||||||
|
<div class="mx-4 lg:mx-8">
|
||||||
|
{% include '_partials/_alert.html.twig' with {
|
||||||
|
level: 'info',
|
||||||
|
messages: ['Bitte vervollständige erst deine Daten, um fortzufahren.']
|
||||||
|
} %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
<div class="p-4 lg:p-8">
|
<div class="p-4 lg:p-8">
|
||||||
<div class="divide-y divide-primary-bg/40">
|
<div class="divide-y divide-primary-bg/40">
|
||||||
<div class="grid md:grid-cols-2 gap-x-8 gap-y-4 pb-8">
|
<div class="grid md:grid-cols-2 gap-x-8 gap-y-4 pb-8">
|
||||||
<div class="text-white">
|
<div class="text-white">
|
||||||
<strong>Name</strong>: {{ personalData.fullName }}
|
<h2>
|
||||||
<br>
|
{{ personalData.fullName }}
|
||||||
<strong>Gender</strong>: {{ personalData.gender|map_gender }}
|
</h2>
|
||||||
<br>
|
|
||||||
<strong>Geburtsdatum</strong>: {{ personalData.dateOfBirth|date('d.m.Y') }}
|
|
||||||
</div>
|
</div>
|
||||||
<div id="newsletter">
|
<div id="newsletter">
|
||||||
<p class="pb-4 text-white">
|
<p class="pb-4 text-white">
|
||||||
@@ -32,24 +39,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="pt-8">
|
<div class="pt-8">
|
||||||
<h2 class="text-white uppercase text-xl">
|
|
||||||
Kontaktdaten
|
|
||||||
</h2>
|
|
||||||
{% include '_partials/_flashes.html.twig' %}
|
|
||||||
{{ form_start(personalDataForm) }}
|
{{ form_start(personalDataForm) }}
|
||||||
<div class="grid md:grid-cols-2 gap-x-8 gap-y-4 pb-4">
|
<div class="grid md:grid-cols-2 gap-x-8 gap-y-4 pb-4">
|
||||||
<div>
|
<div>
|
||||||
|
<h3 class="text-white">
|
||||||
|
Persönliches
|
||||||
|
</h3>
|
||||||
|
{{ form_row(personalDataForm.gender, { 'label_attr': { 'class': 'text-white' } }) }}
|
||||||
|
{{ form_row(personalDataForm.dateOfBirth, { 'label_attr': { 'class': 'text-white' } }) }}
|
||||||
|
{{ form_row(personalDataForm.nationality, { 'label_attr': { 'class': 'text-white' } }) }}
|
||||||
|
<h3 class="text-white">
|
||||||
|
Kontakt
|
||||||
|
</h3>
|
||||||
|
{{ form_row(personalDataForm.email, { 'label_attr': { 'class': 'text-white' } }) }}
|
||||||
|
{{ form_row(personalDataForm.mobile, { 'label_attr': { 'class': 'text-white' } }) }}
|
||||||
|
{{ form_row(personalDataForm.phone, { 'label_attr': { 'class': 'text-white' } }) }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-white">
|
||||||
|
Anschrift
|
||||||
|
</h3>
|
||||||
{{ form_row(personalDataForm.street, { 'label_attr': { 'class': 'text-white' } }) }}
|
{{ form_row(personalDataForm.street, { 'label_attr': { 'class': 'text-white' } }) }}
|
||||||
{{ form_row(personalDataForm.postCode, { 'label_attr': { 'class': 'text-white' } }) }}
|
{{ form_row(personalDataForm.postCode, { 'label_attr': { 'class': 'text-white' } }) }}
|
||||||
{{ form_row(personalDataForm.city, { 'label_attr': { 'class': 'text-white' } }) }}
|
{{ form_row(personalDataForm.city, { 'label_attr': { 'class': 'text-white' } }) }}
|
||||||
{{ form_row(personalDataForm.country, { 'label_attr': { 'class': 'text-white' } }) }}
|
{{ form_row(personalDataForm.country, { 'label_attr': { 'class': 'text-white' } }) }}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
{{ form_row(personalDataForm.nationality, { 'label_attr': { 'class': 'text-white' } }) }}
|
|
||||||
{{ form_row(personalDataForm.email, { 'label_attr': { 'class': 'text-white' } }) }}
|
|
||||||
{{ form_row(personalDataForm.phone, { 'label_attr': { 'class': 'text-white' } }) }}
|
|
||||||
{{ form_row(personalDataForm.mobile, { 'label_attr': { 'class': 'text-white' } }) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<a href="{{ path('app_account') }}" class="button button--secondary">
|
<a href="{{ path('app_account') }}" class="button button--secondary">
|
||||||
|
|||||||
Reference in New Issue
Block a user