feat: enforce profile data completeness before entering booking flow

This commit is contained in:
Björn Fromme
2026-01-19 18:04:19 +01:00
parent 2949870acc
commit 0b44394b11
7 changed files with 235 additions and 19 deletions
+6
View File
@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\BusProNet\Model;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Represents a physical address with street, postal code, city, and country information.
*
@@ -13,14 +15,18 @@ namespace App\BusProNet\Model;
*/
class Address
{
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data'])]
public ?string $street = null;
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data'])]
public ?string $postCode = null;
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data'])]
public ?string $city = null;
public ?string $district = null;
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data'])]
public ?string $country = null;
/**
+7
View File
@@ -31,11 +31,18 @@ class PersonalData
public ?string $salutation = 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;
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data'])]
public ?string $nationality = null;
public ?string $height = null;
public ?string $shoeSize = null;
public ?string $weight = null;
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data'])]
public ?\DateTimeImmutable $dateOfBirth = null;
public ?string $remarks = null;
@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Controller\Account;
use App\BusProNet\ApiClient;
@@ -7,9 +9,11 @@ use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Model\Notification;
use App\BusProNet\Model\PersonalData;
use App\Entity\User;
use App\EventSubscriber\ProfileCompletionSubscriber;
use App\Form\PersonalDataType;
use App\Security\Crypt;
use App\Service\BookingEditDataLoaderService;
use App\Service\ProfileCompletenessChecker;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
@@ -27,15 +31,17 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
class PersonalDataController extends AbstractController
{
/**
* @param ApiClient $apiClient BusProNet API client for data operations
* @param Crypt $crypt Encryption service for password handling
* @param BookingEditDataLoaderService $dataLoader Data loader for cache invalidation
* @param LoggerInterface $logger Logger for audit trails and debugging
* @param ApiClient $apiClient BusProNet API client for data operations
* @param Crypt $crypt Encryption service for password handling
* @param BookingEditDataLoaderService $dataLoader Data loader for cache invalidation
* @param ProfileCompletenessChecker $completenessChecker Profile validation service
* @param LoggerInterface $logger Logger for audit trails and debugging
*/
public function __construct(
private readonly ApiClient $apiClient,
private readonly Crypt $crypt,
private readonly BookingEditDataLoaderService $dataLoader,
private readonly ProfileCompletenessChecker $completenessChecker,
private readonly LoggerInterface $logger,
) {
}
@@ -98,6 +104,16 @@ class PersonalDataController extends AbstractController
$this->logger->info('Updated personal data', [
'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) {
$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));
}
}
+21
View File
@@ -1,9 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Form;
use App\BusProNet\Form\CountryType;
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\TextType;
use Symfony\Component\Form\FormBuilderInterface;
@@ -13,6 +17,23 @@ class PersonalDataType extends AbstractType
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$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, [
'label' => 'Straße',
'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();
}
}
+29 -15
View File
@@ -1,20 +1,27 @@
{% extends 'layout.html.twig' %}
{% block content %}
{% include '_partials/_flashes.html.twig' %}
<div class="px-4 lg:px-8 py-8 lg:py-16">
<h1 class="text-white uppercase pb-4">
Meine<br>Daten
</h1>
</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="divide-y divide-primary-bg/40">
<div class="grid md:grid-cols-2 gap-x-8 gap-y-4 pb-8">
<div class="text-white">
<strong>Name</strong>: {{ personalData.fullName }}
<br>
<strong>Gender</strong>: {{ personalData.gender|map_gender }}
<br>
<strong>Geburtsdatum</strong>: {{ personalData.dateOfBirth|date('d.m.Y') }}
<h2>
{{ personalData.fullName }}
</h2>
</div>
<div id="newsletter">
<p class="pb-4 text-white">
@@ -32,24 +39,31 @@
</div>
</div>
<div class="pt-8">
<h2 class="text-white uppercase text-xl">
Kontaktdaten
</h2>
{% include '_partials/_flashes.html.twig' %}
{{ form_start(personalDataForm) }}
<div class="grid md:grid-cols-2 gap-x-8 gap-y-4 pb-4">
<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.postCode, { 'label_attr': { 'class': 'text-white' } }) }}
{{ form_row(personalDataForm.city, { 'label_attr': { 'class': 'text-white' } }) }}
{{ form_row(personalDataForm.country, { 'label_attr': { 'class': 'text-white' } }) }}
</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 class="flex justify-between">
<a href="{{ path('app_account') }}" class="button button--secondary">