Refactor PersonCreate flow to introduce PersonCreateDTO

- Replaced `Person` entity binding with `PersonCreateDTO` in `CreationPersonType` to enable better data handling.
- Added `PersonCreateDTOFactory` for creating and mapping `PersonCreateDTO` instances.
- Extracted `newAction` logic into `PersonCreateController` for clearer separation of responsibilities.
- Updated `PersonIdentifiersDataMapper` and `PersonIdentifierWorker` to support default identifier values.
- Adjusted related services, configurations, and templates accordingly.
This commit is contained in:
2025-10-21 14:24:43 +02:00
parent e9e6c05e3d
commit 870907804b
10 changed files with 396 additions and 175 deletions

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\PersonBundle\Actions\PersonCreate;
use Chill\MainBundle\Entity\Address;
use Chill\MainBundle\Entity\Center;
use Chill\MainBundle\Entity\Civility;
use Chill\MainBundle\Entity\Gender;
use Chill\PersonBundle\Entity\Identifier\PersonIdentifier;
use Chill\PersonBundle\Entity\PersonAltName;
use libphonenumber\PhoneNumber;
use Misd\PhoneNumberBundle\Validator\Constraints\PhoneNumber as MisdPhoneNumberConstraint;
use Symfony\Component\Validator\Constraints as Assert;
use Chill\PersonBundle\Validator\Constraints\Person\Birthdate;
class PersonCreateDTO
{
#[Assert\NotBlank(message: 'The firstname cannot be empty')]
#[Assert\Length(max: 255)]
public string $firstName;
#[Assert\NotBlank(message: 'The lastname cannot be empty')]
#[Assert\Length(max: 255)]
public string $lastName;
#[Birthdate]
public ?\DateTime $birthdate = null;
#[Assert\NotNull(message: 'The gender must be set')]
public ?Gender $gender = null;
public ?Civility $civility = null;
#[MisdPhoneNumberConstraint(type: [MisdPhoneNumberConstraint::FIXED_LINE, MisdPhoneNumberConstraint::VOIP, MisdPhoneNumberConstraint::PERSONAL_NUMBER])]
public ?PhoneNumber $phonenumber = null;
#[MisdPhoneNumberConstraint(type: [MisdPhoneNumberConstraint::MOBILE])]
public ?PhoneNumber $mobilenumber = null;
#[Assert\Email]
public ?string $email = '';
// Checkbox that indicates whether the address form was checked in creation form
public bool $addressForm = false;
// Selected address value (unmapped in Person entity during creation)
public ?Address $address = null;
public ?Center $center = null;
/**
* @var array<string, PersonAltName> where the key is the altname's key
*/
public array $altNames = [];
/**
* @var array<string, PersonIdentifier>
*/
#[Assert\Valid(traverse: true)]
public array $identifiers = [];
}

View File

@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\PersonBundle\Actions\PersonCreate\Service;
use Chill\PersonBundle\Actions\PersonCreate\PersonCreateDTO;
use Chill\PersonBundle\Config\ConfigPersonAltNamesHelper;
use Chill\PersonBundle\Entity\Identifier\PersonIdentifier;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Entity\PersonAltName;
use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface;
class PersonCreateDTOFactory
{
public function __construct(
private readonly ConfigPersonAltNamesHelper $configPersonAltNamesHelper,
private readonly PersonIdentifierManagerInterface $personIdentifierManager,
) {}
public function createPersonCreateDTO(Person $person): PersonCreateDTO
{
$dto = new PersonCreateDTO();
$dto->firstName = $person->getFirstName();
$dto->lastName = $person->getLastName();
$dto->birthdate = $person->getBirthdate();
$dto->gender = $person->getGender();
$dto->civility = $person->getCivility();
$dto->phonenumber = $person->getPhonenumber();
$dto->mobilenumber = $person->getMobilenumber();
$dto->email = $person->getEmail();
$dto->center = $person->getCenter();
// address/addressForm are not mapped on Person entity; left to defaults
foreach ($this->configPersonAltNamesHelper->getChoices() as $key => $labels) {
$altName = $person->getAltNames()->findFirst(fn (int $k, PersonAltName $altName) => $altName->getKey() === $key);
if (null === $altName) {
$altName = new PersonAltName();
$altName->setKey($key);
}
$dto->altNames[$key] = $altName;
}
foreach ($this->personIdentifierManager->getWorkers() as $worker) {
$identifier = $person
->getIdentifiers()
->findFirst(fn (int $key, PersonIdentifier $identifier) => $worker->getDefinition() === $identifier->getDefinition());
if (null === $identifier) {
$identifier = new PersonIdentifier($worker->getDefinition());
$person->addIdentifier($identifier);
}
$dto->identifiers['identifier_'.$worker->getDefinition()->getId()] = $identifier;
}
return $dto;
}
public function mapPersonCreateDTOtoPerson(PersonCreateDTO $personCreateDTO, Person $person): void
{
$person
->setFirstName($personCreateDTO->firstName)
->setLastName($personCreateDTO->lastName)
->setBirthdate($personCreateDTO->birthdate)
->setGender($personCreateDTO->gender)
->setCivility($personCreateDTO->civility)
->setPhonenumber($personCreateDTO->phonenumber)
->setMobilenumber($personCreateDTO->mobilenumber)
->setEmail($personCreateDTO->email)
->setCenter($personCreateDTO->center);
foreach ($personCreateDTO->altNames as $altName) {
if ('' === $altName->getLabel()) {
$person->removeAltName($altName);
} else {
$person->addAltName($altName);
}
}
foreach ($personCreateDTO->identifiers as $identifier) {
$worker = $this->personIdentifierManager->buildWorkerByPersonIdentifierDefinition($identifier->getDefinition());
if ($worker->isEmpty($identifier)) {
$person->removeIdentifier($identifier);
} else {
$person->addIdentifier($identifier);
}
}
// Note: address and addressForm are handled by controller/form during creation, not mapped here
}
}

View File

@@ -11,36 +11,22 @@ declare(strict_types=1);
namespace Chill\PersonBundle\Controller;
use Chill\MainBundle\Security\Authorization\AuthorizationHelperInterface;
use Chill\PersonBundle\Config\ConfigPersonAltNamesHelper;
use Chill\PersonBundle\Entity\Household\Household;
use Chill\PersonBundle\Entity\Household\HouseholdMember;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Form\CreationPersonType;
use Chill\PersonBundle\Privacy\PrivacyEvent;
use Chill\PersonBundle\Repository\PersonRepository;
use Chill\PersonBundle\Search\SimilarPersonMatcher;
use Chill\PersonBundle\Security\Authorization\PersonVoter;
use Doctrine\ORM\EntityManagerInterface;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Form;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
use function hash;
final class PersonController extends AbstractController
{
public function __construct(
private readonly AuthorizationHelperInterface $authorizationHelper,
private readonly SimilarPersonMatcher $similarPersonMatcher,
private readonly TranslatorInterface $translator,
private readonly EventDispatcherInterface $eventDispatcher,
private readonly PersonRepository $personRepository,
private readonly ConfigPersonAltNamesHelper $configPersonAltNameHelper,
@@ -85,110 +71,6 @@ final class PersonController extends AbstractController
);
}
/**
* Method for creating a new person.
*
* The controller register data from a previous post on the form, and
* register it in the session.
*
* The next post compare the data with previous one and, if yes, show a
* review page if there are "alternate persons".
*/
#[Route(path: '/{_locale}/person/new', name: 'chill_person_new')]
public function newAction(Request $request): Response
{
$person = new Person();
$authorizedCenters = $this->authorizationHelper->getReachableCenters($this->getUser(), PersonVoter::CREATE);
if (1 === \count($authorizedCenters)) {
$person->setCenter($authorizedCenters[0]);
}
$form = $this->createForm(CreationPersonType::class, $person)
->add('editPerson', SubmitType::class, [
'label' => 'Add the person',
])->add('createPeriod', SubmitType::class, [
'label' => 'Add the person and create an accompanying period',
])->add('createHousehold', SubmitType::class, [
'label' => 'Add the person and create a household',
]);
$form->handleRequest($request);
if (Request::METHOD_GET === $request->getMethod()) {
$this->lastPostDataReset();
} elseif (
Request::METHOD_POST === $request->getMethod()
&& $form->isValid()
) {
$alternatePersons = $this->similarPersonMatcher
->matchPerson($person);
if (
false === $this->isLastPostDataChanges($form, $request, true)
|| 0 === \count($alternatePersons)
) {
$this->em->persist($person);
$this->em->flush();
$this->lastPostDataReset();
$address = $form->get('address')->getData();
$addressForm = (bool) $form->get('addressForm')->getData();
if (null !== $address && $addressForm) {
$household = new Household();
$member = new HouseholdMember();
$member->setPerson($person);
$member->setStartDate(new \DateTimeImmutable());
$household->addMember($member);
$household->setForceAddress($address);
$this->em->persist($member);
$this->em->persist($household);
$this->em->flush();
if ($form->get('createHousehold')->isClicked()) {
return $this->redirectToRoute('chill_person_household_members_editor', [
'persons' => [$person->getId()],
'household' => $household->getId(),
]);
}
}
if ($form->get('createPeriod')->isClicked()) {
return $this->redirectToRoute('chill_person_accompanying_course_new', [
'person_id' => [$person->getId()],
]);
}
if ($form->get('createHousehold')->isClicked()) {
return $this->redirectToRoute('chill_person_household_members_editor', [
'persons' => [$person->getId()],
]);
}
return $this->redirectToRoute(
'chill_person_general_edit',
['person_id' => $person->getId()]
);
}
} elseif (Request::METHOD_POST === $request->getMethod() && !$form->isValid()) {
$this->addFlash('error', $this->translator->trans('This form contains errors'));
}
return $this->render(
'@ChillPerson/Person/create.html.twig',
[
'form' => $form->createView(),
'alternatePersons' => $alternatePersons ?? [],
]
);
}
#[Route(path: '/{_locale}/person/{person_id}/general', name: 'chill_person_view')]
public function viewAction(int $person_id)
{
@@ -250,51 +132,4 @@ final class PersonController extends AbstractController
return $errors;
}
private function isLastPostDataChanges(Form $form, Request $request, bool $replace = false): bool
{
/** @var SessionInterface $session */
$session = $this->get('session');
if (!$session->has('last_person_data')) {
return true;
}
$newPost = $this->lastPostDataBuildHash($form, $request);
$isChanged = $session->get('last_person_data') !== $newPost;
if ($replace) {
$session->set('last_person_data', $newPost);
}
return $isChanged;
}
/**
* build the hash for posted data.
*
* For privacy reasons, the data are hashed using sha512
*/
private function lastPostDataBuildHash(Form $form, Request $request): string
{
$fields = [];
$ignoredFields = ['form_status', '_token', 'identifiers'];
foreach ($request->request->all()[$form->getName()] as $field => $value) {
if (\in_array($field, $ignoredFields, true)) {
continue;
}
$fields[$field] = \is_array($value) ?
\implode(',', $value) : $value;
}
ksort($fields);
return \hash('sha512', \implode('&', $fields));
}
private function lastPostDataReset(): void
{
$this->get('session')->set('last_person_data', '');
}
}

View File

@@ -0,0 +1,205 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\PersonBundle\Controller;
use Chill\MainBundle\Security\Authorization\AuthorizationHelperInterface;
use Chill\PersonBundle\Actions\PersonCreate\Service\PersonCreateDTOFactory;
use Chill\PersonBundle\Entity\Household\Household;
use Chill\PersonBundle\Entity\Household\HouseholdMember;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Form\CreationPersonType;
use Chill\PersonBundle\Search\SimilarPersonMatcher;
use Chill\PersonBundle\Security\Authorization\PersonVoter;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\ClickableInterface;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Form;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Contracts\Translation\TranslatorInterface;
final class PersonCreateController extends AbstractController
{
public function __construct(
private readonly AuthorizationHelperInterface $authorizationHelper,
private readonly SimilarPersonMatcher $similarPersonMatcher,
private readonly TranslatorInterface $translator,
private readonly EntityManagerInterface $em,
private readonly PersonCreateDTOFactory $personCreateDTOFactory,
) {}
/**
* Method for creating a new person.
*
* The controller registers data from a previous post on the form and
* registers it in the session.
*
* The next post compares the data with the previous one and, if yes, shows a
* review page if there are "alternate persons".
*/
#[Route(path: '/{_locale}/person/new', name: 'chill_person_new')]
public function newAction(Request $request, SessionInterface $session): Response
{
$person = new Person();
$authorizedCenters = $this->authorizationHelper->getReachableCenters($this->getUser(), PersonVoter::CREATE);
$dto = $this->personCreateDTOFactory->createPersonCreateDTO($person);
if (1 === \count($authorizedCenters)) {
$dto->center = $authorizedCenters[0];
}
$form = $this->createForm(CreationPersonType::class, $dto)
->add('editPerson', SubmitType::class, [
'label' => 'Add the person',
])->add('createPeriod', SubmitType::class, [
'label' => 'Add the person and create an accompanying period',
])->add('createHousehold', SubmitType::class, [
'label' => 'Add the person and create a household',
]);
$form->handleRequest($request);
if (Request::METHOD_GET === $request->getMethod()) {
$this->lastPostDataReset($session);
} elseif (
Request::METHOD_POST === $request->getMethod()
&& $form->isValid()
) {
$alternatePersons = $this->similarPersonMatcher
->matchPerson($person);
$createHouseholdButton = $form->get('createHousehold');
$createPeriodButton = $form->get('createPeriod');
$editPersonButton = $form->get('editPerson');
if (!$createHouseholdButton instanceof ClickableInterface) {
throw new \UnexpectedValueException();
}
if (!$createPeriodButton instanceof ClickableInterface) {
throw new \UnexpectedValueException();
}
if (!$editPersonButton instanceof ClickableInterface) {
throw new \UnexpectedValueException();
}
if (
false === $this->isLastPostDataChanges($form, $request, $session)
|| 0 === \count($alternatePersons)
) {
$this->personCreateDTOFactory->mapPersonCreateDTOtoPerson($dto, $person);
$this->em->persist($person);
$this->em->flush();
$this->lastPostDataReset($session);
$address = $dto->address;
$addressForm = $dto->addressForm;
if (null !== $address && $addressForm) {
$household = new Household();
$member = new HouseholdMember();
$member->setPerson($person);
$member->setStartDate(new \DateTimeImmutable());
$household->addMember($member);
$household->setForceAddress($address);
$this->em->persist($member);
$this->em->persist($household);
$this->em->flush();
if ($createHouseholdButton->isClicked()) {
return $this->redirectToRoute('chill_person_household_members_editor', [
'persons' => [$person->getId()],
'household' => $household->getId(),
]);
}
}
if ($createPeriodButton->isClicked()) {
return $this->redirectToRoute('chill_person_accompanying_course_new', [
'person_id' => [$person->getId()],
]);
}
if ($createHouseholdButton->isClicked()) {
return $this->redirectToRoute('chill_person_household_members_editor', [
'persons' => [$person->getId()],
]);
}
return $this->redirectToRoute(
'chill_person_general_edit',
['person_id' => $person->getId()]
);
}
} elseif (Request::METHOD_POST === $request->getMethod() && !$form->isValid()) {
$this->addFlash('error', $this->translator->trans('This form contains errors'));
}
return $this->render(
'@ChillPerson/Person/create.html.twig',
[
'form' => $form->createView(),
'alternatePersons' => $alternatePersons ?? [],
]
);
}
private function isLastPostDataChanges(FormInterface $form, Request $request, SessionInterface $session): bool
{
if (!$session->has('last_person_data')) {
return true;
}
$newPost = $this->lastPostDataBuildHash($form, $request);
$isChanged = $session->get('last_person_data') !== $newPost;
$session->set('last_person_data', $newPost);
return $isChanged;
}
/**
* build the hash for posted data.
*
* For privacy reasons, the data are hashed using sha512
*/
private function lastPostDataBuildHash(FormInterface $form, Request $request): string
{
$fields = [];
$ignoredFields = ['form_status', '_token', 'identifiers'];
foreach ($request->request->all()[$form->getName()] as $field => $value) {
if (\in_array($field, $ignoredFields, true)) {
continue;
}
$fields[$field] = \is_array($value) ?
\implode(',', $value) : $value;
}
ksort($fields);
return \hash('sha512', \implode('&', $fields));
}
private function lastPostDataReset(SessionInterface $session): void
{
$session->set('last_person_data', '');
}
}

View File

@@ -11,15 +11,14 @@ declare(strict_types=1);
namespace Chill\PersonBundle\Form;
use Chill\MainBundle\Entity\Address;
use Chill\MainBundle\Form\Event\CustomizeFormEvent;
use Chill\MainBundle\Form\Type\ChillDateType;
use Chill\MainBundle\Form\Type\ChillPhoneNumberType;
use Chill\MainBundle\Form\Type\PickAddressType;
use Chill\MainBundle\Form\Type\PickCenterType;
use Chill\MainBundle\Form\Type\PickCivilityType;
use Chill\PersonBundle\Actions\PersonCreate\PersonCreateDTO;
use Chill\PersonBundle\Config\ConfigPersonAltNamesHelper;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Form\Type\PersonAltNameType;
use Chill\PersonBundle\Form\Type\PickGenderType;
use Chill\PersonBundle\Security\Authorization\PersonVoter;
@@ -33,6 +32,7 @@ use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Callback;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
final class CreationPersonType extends AbstractType
{
@@ -80,12 +80,10 @@ final class CreationPersonType extends AbstractType
->add('addressForm', CheckboxType::class, [
'label' => 'Create a household and add an address',
'required' => false,
'mapped' => false,
'help' => 'A new household will be created. The person will be member of this household.',
])
->add('address', PickAddressType::class, [
'required' => false,
'mapped' => false,
'label' => false,
]);
@@ -117,7 +115,7 @@ final class CreationPersonType extends AbstractType
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => Person::class,
'data_class' => PersonCreateDTO::class,
'constraints' => [
new Callback($this->validateCheckedAddress(...)),
],
@@ -134,10 +132,12 @@ final class CreationPersonType extends AbstractType
public function validateCheckedAddress($data, ExecutionContextInterface $context, $payload): void
{
/** @var bool $addressFrom */
$addressFrom = $context->getObject()->get('addressForm')->getData();
/** @var ?Address $address */
$address = $context->getObject()->get('address')->getData();
if (!$data instanceof PersonCreateDTO) {
throw new UnexpectedTypeException($data, PersonCreateDTO::class);
}
$addressFrom = $data->addressForm;
$address = $data->address;
if ($addressFrom && null === $address) {
$context->buildViolation('person_creation.If you want to create an household, an address is required')

View File

@@ -36,7 +36,7 @@ final readonly class PersonIdentifiersDataMapper implements DataMapperInterface
return;
}
foreach ($forms as $key => $form) {
$form->setData($viewData->getValue()[$key]);
$form->setData($viewData->getValue()[$key] ?? $worker->getDefaultValue()[$key] ?? '');
}
}

View File

@@ -70,4 +70,9 @@ final readonly class StringIdentifier implements PersonIdentifierEngineInterface
return $violations;
}
public function getDefaultValue(PersonIdentifierDefinition $definition): array
{
return ['content' => ''];
}
}

View File

@@ -42,4 +42,6 @@ interface PersonIdentifierEngineInterface
* @return list<IdentifierViolationDTO>
*/
public function validate(PersonIdentifier $identifier, PersonIdentifierDefinition $definition): array;
public function getDefaultValue(PersonIdentifierDefinition $definition): array;
}

View File

@@ -62,4 +62,9 @@ readonly class PersonIdentifierWorker
{
return $this->identifierEngine->validate($identifier, $definition);
}
public function getDefaultValue(): array
{
return $this->identifierEngine->getDefaultValue($this->definition);
}
}

View File

@@ -14,3 +14,6 @@ services:
Chill\PersonBundle\Actions\PersonEdit\Service\:
resource: '../../Actions/PersonEdit/Service'
Chill\PersonBundle\Actions\PersonCreate\Service\:
resource: '../../Actions/PersonCreate/Service'