feat: move profile completeness check from login to booking flow entry

- Add profileComplete flag to User entity to avoid API calls
- Set flag during login (BpnAuthenticator) and profile save
- Check flag in IndexController when entering booking flow
- Remove ProfileCompletionSubscriber (no longer needed)

Users with incomplete profiles are only redirected when starting
a new booking, not on every login. Eliminates extra API call by
caching completeness status on the user entity.
This commit is contained in:
Björn Fromme
2026-01-19 18:55:55 +01:00
parent da33620ea9
commit a097159897
6 changed files with 76 additions and 114 deletions
+29
View File
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260119174522 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add profile_complete flag to user table';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE user ADD profile_complete TINYINT(1) NOT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE user DROP profile_complete');
}
}
@@ -9,11 +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 App\Service\ProfileCompletenessChecker;
use Doctrine\ORM\EntityManagerInterface;
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;
@@ -30,11 +30,14 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
*/ */
class PersonalDataController extends AbstractController class PersonalDataController extends AbstractController
{ {
public const SESSION_REDIRECT_KEY = '_profile_completion_redirect';
/** /**
* @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 ProfileCompletenessChecker $completenessChecker Profile validation service * @param ProfileCompletenessChecker $completenessChecker Profile validation service
* @param EntityManagerInterface $entityManager Entity manager for persisting user changes
* @param LoggerInterface $logger Logger for audit trails and debugging * @param LoggerInterface $logger Logger for audit trails and debugging
*/ */
public function __construct( public function __construct(
@@ -42,6 +45,7 @@ class PersonalDataController extends AbstractController
private readonly Crypt $crypt, private readonly Crypt $crypt,
private readonly BookingEditDataLoaderService $dataLoader, private readonly BookingEditDataLoaderService $dataLoader,
private readonly ProfileCompletenessChecker $completenessChecker, private readonly ProfileCompletenessChecker $completenessChecker,
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger, private readonly LoggerInterface $logger,
) { ) {
} }
@@ -100,6 +104,11 @@ class PersonalDataController extends AbstractController
// Invalidate cached bookings to ensure edit mode shows updated applicant data // Invalidate cached bookings to ensure edit mode shows updated applicant data
$this->dataLoader->invalidateUserBookingCaches($user); $this->dataLoader->invalidateUserBookingCaches($user);
// Update profile completeness flag on user entity
$isComplete = $this->completenessChecker->isComplete($personalData);
$user->setProfileComplete($isComplete);
$this->entityManager->flush();
$this->addFlash('success', 'Deine persönlichen Daten wurden aktualisiert'); $this->addFlash('success', 'Deine persönlichen Daten wurden aktualisiert');
$this->logger->info('Updated personal data', [ $this->logger->info('Updated personal data', [
'email' => $user->getEmail(), 'email' => $user->getEmail(),
@@ -107,10 +116,10 @@ class PersonalDataController extends AbstractController
// Handle profile completion redirect // Handle profile completion redirect
$session = $request->getSession(); $session = $request->getSession();
$redirectUrl = $session->get(ProfileCompletionSubscriber::SESSION_REDIRECT_KEY); $redirectUrl = $session->get(self::SESSION_REDIRECT_KEY);
if (null !== $redirectUrl && true === $this->completenessChecker->isComplete($personalData)) { if (null !== $redirectUrl && true === $isComplete) {
$session->remove(ProfileCompletionSubscriber::SESSION_REDIRECT_KEY); $session->remove(self::SESSION_REDIRECT_KEY);
return $this->redirect($redirectUrl); return $this->redirect($redirectUrl);
} }
@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace App\Controller\Booking\Create; namespace App\Controller\Booking\Create;
use App\BusProNet\XmlLoader\AgencyLoader; use App\BusProNet\XmlLoader\AgencyLoader;
use App\Controller\Account\PersonalDataController;
use App\Entity\User;
use App\Exception\HotelNotFoundException; use App\Exception\HotelNotFoundException;
use App\Exception\HotelNotInTravelException; use App\Exception\HotelNotInTravelException;
use App\Exception\NoRoomsAvailableException; use App\Exception\NoRoomsAvailableException;
@@ -42,17 +44,26 @@ class IndexController extends AbstractController
* *
* Renders a loading page that triggers the actual initialization via HTMX. * Renders a loading page that triggers the actual initialization via HTMX.
* This provides immediate visual feedback while BusProNet API calls are made. * This provides immediate visual feedback while BusProNet API calls are made.
* Redirects to profile completion if logged-in user has incomplete profile data.
*/ */
#[Route( #[Route(
path: '/bookings/create', path: '/bookings/create',
name: 'app_booking_create', name: 'app_booking_create',
)] )]
public function index(#[MapQueryString] ?BookingQueryParams $params): Response public function index(Request $request, #[MapQueryString] ?BookingQueryParams $params): Response
{ {
if (null === $params) { if (null === $params) {
throw $this->createNotFoundException('Invalid booking parameters provided'); throw $this->createNotFoundException('Invalid booking parameters provided');
} }
$user = $this->getUser();
if ($user instanceof User && false === $user->isProfileComplete()) {
$request->getSession()->set(PersonalDataController::SESSION_REDIRECT_KEY, $request->getUri());
return $this->redirectToRoute('app_personal_data');
}
return $this->render('booking/create/index.html.twig', [ return $this->render('booking/create/index.html.twig', [
'params' => $params, 'params' => $params,
]); ]);
+15
View File
@@ -35,6 +35,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[ORM\Column(type: 'datetime_immutable', nullable: true)] #[ORM\Column(type: 'datetime_immutable', nullable: true)]
private ?\DateTimeImmutable $lastLoginAt = null; private ?\DateTimeImmutable $lastLoginAt = null;
#[ORM\Column(type: 'boolean')]
private bool $profileComplete = false;
public function __construct(string $email) public function __construct(string $email)
{ {
$this->email = $email; $this->email = $email;
@@ -129,6 +132,18 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this; return $this;
} }
public function isProfileComplete(): bool
{
return $this->profileComplete;
}
public function setProfileComplete(bool $profileComplete): static
{
$this->profileComplete = $profileComplete;
return $this;
}
public function eraseCredentials(): void public function eraseCredentials(): void
{ {
} }
@@ -1,105 +0,0 @@
<?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));
}
}
+7 -4
View File
@@ -9,6 +9,7 @@ use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Model\PersonalData; use App\BusProNet\Model\PersonalData;
use App\Entity\User; use App\Entity\User;
use App\Htmx\HxRedirectResponse; use App\Htmx\HxRedirectResponse;
use App\Service\ProfileCompletenessChecker;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\RedirectResponse;
@@ -41,6 +42,7 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
private readonly ApiClient $apiClient, private readonly ApiClient $apiClient,
private readonly EntityManagerInterface $entityManager, private readonly EntityManagerInterface $entityManager,
private readonly Crypt $crypt, private readonly Crypt $crypt,
private readonly ProfileCompletenessChecker $completenessChecker,
private readonly LoggerInterface $authLogger, private readonly LoggerInterface $authLogger,
) { ) {
} }
@@ -72,7 +74,7 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
return new SelfValidatingPassport( return new SelfValidatingPassport(
new UserBadge($email, function () use ($email, $password, $response) { new UserBadge($email, function () use ($email, $password, $response) {
return $this->createOrUpdateLocalUser($email, $password, $response->personId, $response->addressId); return $this->createOrUpdateLocalUser($email, $password, $response);
}), }),
[ [
new CsrfTokenBadge('authenticate', $csrfToken), new CsrfTokenBadge('authenticate', $csrfToken),
@@ -80,7 +82,7 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
); );
} }
private function createOrUpdateLocalUser(string $email, string $password, ?int $personId, ?int $addressId): User private function createOrUpdateLocalUser(string $email, string $password, PersonalData $personalData): User
{ {
try { try {
$crmAttributes = $this->apiClient->getCrmAttributes($email, $password); $crmAttributes = $this->apiClient->getCrmAttributes($email, $password);
@@ -102,11 +104,12 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
$user $user
->setPassword($encryptedPassword) ->setPassword($encryptedPassword)
->setPersonId($personId) ->setPersonId($personalData->personId)
->setAddressId($addressId) ->setAddressId($personalData->addressId)
->setRoles($roles) ->setRoles($roles)
->setHotelCodes($hotelCodes) ->setHotelCodes($hotelCodes)
->setLastLoginAt(new \DateTimeImmutable()) ->setLastLoginAt(new \DateTimeImmutable())
->setProfileComplete($this->completenessChecker->isComplete($personalData))
; ;
$this->entityManager->flush(); $this->entityManager->flush();