feat: soft-delete for teamer accounts
addresses #869dv9br3
This commit is contained in:
@@ -97,6 +97,13 @@ class UserDataHandler
|
||||
/** @var User $user */
|
||||
$user = $users[0];
|
||||
|
||||
// A deleted account still has to be matched here, otherwise the caller would take
|
||||
// it for an unknown person and create a second account for them - resurrecting
|
||||
// them under a new row. It is returned as found, without writing anything to it.
|
||||
if (true === $user->isDeleted()) {
|
||||
return $user;
|
||||
}
|
||||
|
||||
$addressId = $profileResponse->getAddressId();
|
||||
$personId = $profileResponse->getPersonId();
|
||||
if (null === $addressId || null === $personId) {
|
||||
@@ -232,6 +239,12 @@ class UserDataHandler
|
||||
*/
|
||||
public function disableForRevokedCrmRoles(User $user): void
|
||||
{
|
||||
// a deleted account is already excluded from everything and must not be written
|
||||
// to by the CRM at all
|
||||
if (true === $user->isDeleted()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// an existing block may be a disciplinary one and must never be overwritten, but
|
||||
// findLocalUser() may have refreshed the BusPro ids and nothing else flushes here
|
||||
if (true === $user->isDisabled()) {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Admin\Teamer;
|
||||
|
||||
use App\Entity\Teamer;
|
||||
use App\Htmx\HxRedirectResponse;
|
||||
use App\Repository\ApplicationRepository;
|
||||
use App\Repository\DispositionRepository;
|
||||
use App\Service\Teamer\AccountDeletionHandler;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
class DeleteAccountController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AccountDeletionHandler $accountDeletionHandler,
|
||||
private readonly ApplicationRepository $applicationRepository,
|
||||
private readonly DispositionRepository $dispositionRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/admin/teamer/delete-account/{uuid}', name: 'app_admin_teamer_delete_account')]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
public function index(Teamer $teamer, Request $request): Response
|
||||
{
|
||||
if (true === $request->isMethod(Request::METHOD_POST)) {
|
||||
$this->accountDeletionHandler->delete($teamer, AccountDeletionHandler::SOURCE_ADMIN);
|
||||
|
||||
$this->addFlash('success', 'Der Account wurde gelöscht');
|
||||
|
||||
return new HxRedirectResponse($this->generateUrl('app_administrative_teamer_index'));
|
||||
}
|
||||
|
||||
// open commitments survive the deletion and stay visible, but the teamer is no
|
||||
// longer written to about them - withdrawing them is the admin's decision
|
||||
return $this->render('admin/teamer/modal_delete_account.html.twig', [
|
||||
'teamer' => $teamer,
|
||||
'pendingApplicationCount' => count($this->applicationRepository->getPendingForTeamer($teamer, 100)),
|
||||
'upcomingDispositionCount' => count($this->dispositionRepository->getUpcomingDispositionsByTeamer($teamer, 100)),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/admin/teamer/restore-account/{uuid}', name: 'app_admin_teamer_restore_account')]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
public function restore(Teamer $teamer, Request $request): Response
|
||||
{
|
||||
if (true === $request->isMethod(Request::METHOD_POST)) {
|
||||
$this->accountDeletionHandler->restore($teamer);
|
||||
|
||||
$this->addFlash('success', 'Der Account wurde wiederhergestellt');
|
||||
|
||||
return new HxRedirectResponse($this->generateUrl('app_administrative_teamer_index'));
|
||||
}
|
||||
|
||||
return $this->render('admin/teamer/modal_restore_account.html.twig', [
|
||||
'teamer' => $teamer,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Teamer\Profile;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Form\DeleteAccountType;
|
||||
use App\Htmx\HxRedirectResponse;
|
||||
use App\Service\Teamer\AccountDeletionHandler;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
class DeleteAccountController extends AbstractController
|
||||
{
|
||||
public function __construct(private readonly AccountDeletionHandler $accountDeletionHandler)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/teamer/profile/delete-account', name: 'app_teamer_profile_delete_account')]
|
||||
#[IsGranted('ROLE_TEAMER')]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
|
||||
$form = $this->createForm(DeleteAccountType::class, null, ['hx_post' => $request->getUri()]);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$this->accountDeletionHandler->delete($user, AccountDeletionHandler::SOURCE_SELF);
|
||||
|
||||
$this->addFlash('success', 'Dein Account wurde gelöscht');
|
||||
|
||||
// the session stays authenticated until the next request, so the logout is
|
||||
// done here rather than left to the DeletedUserSubscriber
|
||||
return new HxRedirectResponse($this->generateUrl('app_security_logout'));
|
||||
}
|
||||
|
||||
return $this->render('teamer/profile/modal_delete_account.html.twig', [
|
||||
'form' => $form->createView(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -6,5 +6,5 @@ interface SoftDeletableEntityInterface
|
||||
{
|
||||
public function getDeletedAt(): ?\DateTimeImmutable;
|
||||
|
||||
public function setDeletedAt(\DateTimeImmutable $createdAt): static;
|
||||
public function setDeletedAt(?\DateTimeImmutable $deletedAt): static;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\BusProNet\Model\ProfileResponse;
|
||||
use App\Entity\Embeddable\Address;
|
||||
use App\Entity\Embeddable\BankAccount;
|
||||
use App\Entity\Embeddable\Communication;
|
||||
use App\Entity\Traits\SoftDeletableEntity;
|
||||
use App\Entity\Traits\TimestampableEntity;
|
||||
use App\Repository\TeamerRepository;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
@@ -16,9 +17,10 @@ use Symfony\Component\Uid\Uuid;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ORM\Entity(repositoryClass: TeamerRepository::class)]
|
||||
class Teamer implements TimestampableEntityInterface
|
||||
class Teamer implements TimestampableEntityInterface, SoftDeletableEntityInterface
|
||||
{
|
||||
use TimestampableEntity;
|
||||
use SoftDeletableEntity;
|
||||
|
||||
public const STATUS_NEW = 'new';
|
||||
public const STATUS_EXISTING = 'existing';
|
||||
|
||||
@@ -14,7 +14,7 @@ trait SoftDeletableEntity
|
||||
return $this->deletedAt;
|
||||
}
|
||||
|
||||
public function setDeletedAt(\DateTimeImmutable $deletedAt): static
|
||||
public function setDeletedAt(?\DateTimeImmutable $deletedAt): static
|
||||
{
|
||||
$this->deletedAt = $deletedAt;
|
||||
|
||||
@@ -28,6 +28,13 @@ trait SoftDeletableEntity
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setRestored(): static
|
||||
{
|
||||
$this->setDeletedAt(null);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isDeleted(): bool
|
||||
{
|
||||
return null !== $this->getDeletedAt();
|
||||
|
||||
+19
-1
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Entity\Traits\SoftDeletableEntity;
|
||||
use App\Entity\Traits\TimestampableEntity;
|
||||
use App\Repository\UserRepository;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
@@ -12,9 +13,10 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
|
||||
#[ORM\Entity(repositoryClass: UserRepository::class)]
|
||||
class User implements UserInterface, TimestampableEntityInterface
|
||||
class User implements UserInterface, TimestampableEntityInterface, SoftDeletableEntityInterface
|
||||
{
|
||||
use TimestampableEntity;
|
||||
use SoftDeletableEntity;
|
||||
|
||||
/**
|
||||
* Assignable roles and their labels.
|
||||
@@ -315,6 +317,11 @@ class User implements UserInterface, TimestampableEntityInterface
|
||||
|
||||
public function setTeamer(?Teamer $teamer): static
|
||||
{
|
||||
// keep the inverse side in sync: it is only ever hydrated from the database, so
|
||||
// without this a teamer attached during the request has no user to point back to
|
||||
$this->teamer?->setUser(null);
|
||||
$teamer?->setUser($this);
|
||||
|
||||
$this->teamer = $teamer;
|
||||
|
||||
return $this;
|
||||
@@ -408,6 +415,17 @@ class User implements UserInterface, TimestampableEntityInterface
|
||||
return $this->setDisabledAt(new \DateTimeImmutable());
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the account is barred from logging in, for either reason. A block and a
|
||||
* deletion are independent states: a disciplinary block must survive a deletion and
|
||||
* the restore that follows it, so the two are only ever combined for decisions,
|
||||
* never merged into one another.
|
||||
*/
|
||||
public function isBlocked(): bool
|
||||
{
|
||||
return $this->isDeleted() || $this->isDisabled();
|
||||
}
|
||||
|
||||
public function getDisabledReason(): ?string
|
||||
{
|
||||
return $this->disabledReason;
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\EventListener;
|
||||
|
||||
use App\Entity\User;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
|
||||
/**
|
||||
* Ends the session of a user whose account was deleted while they were logged in.
|
||||
*
|
||||
* The UserChecker only runs while authenticating, and Symfony's ContextListener does not
|
||||
* re-run it when restoring a session from the cookie. Without this an account deleted by
|
||||
* an admin would stay usable until the person logs out by themselves, which is exactly
|
||||
* the continued participation a deletion is meant to end.
|
||||
*/
|
||||
class DeletedUserSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Security $security,
|
||||
private readonly UrlGeneratorInterface $urlGenerator,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
// ahead of RequiredTeamerCheckSubscriber, so a deleted teamer is logged out
|
||||
// rather than being sent into an outstanding check
|
||||
KernelEvents::REQUEST => ['onKernelRequest', 10],
|
||||
];
|
||||
}
|
||||
|
||||
public function onKernelRequest(RequestEvent $event): void
|
||||
{
|
||||
if (false === $event->isMainRequest()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user = $this->security->getUser();
|
||||
|
||||
if (false === $user instanceof User || false === $user->isDeleted()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->security->logout(false);
|
||||
|
||||
$event->setResponse(new RedirectResponse($this->urlGenerator->generate('app_security_login')));
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ namespace App\EventListener;
|
||||
|
||||
use App\Email\Mailer;
|
||||
use App\Entity\Application;
|
||||
use App\Entity\Teamer;
|
||||
use App\Entity\Upload;
|
||||
use App\Event\ApplicationStatusEvent;
|
||||
use App\Event\AssignmentCalledOffEvent;
|
||||
@@ -46,6 +47,13 @@ class EmailNotificationSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
$disposition = $event->getDisposition();
|
||||
$teamer = $disposition->getTeamer();
|
||||
|
||||
// checked before the contract is rendered, so no document is produced for a mail
|
||||
// that is never sent
|
||||
if (true === $this->isExcluded($teamer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$assignment = $disposition->getAssignment();
|
||||
$destination = $assignment->getDestination();
|
||||
|
||||
@@ -78,6 +86,11 @@ class EmailNotificationSubscriber implements EventSubscriberInterface
|
||||
|
||||
$disposition = $document->getDisposition();
|
||||
$teamer = $disposition->getTeamer();
|
||||
|
||||
if (true === $this->isExcluded($teamer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$assignment = $disposition->getAssignment();
|
||||
$destination = $assignment->getDestination();
|
||||
|
||||
@@ -152,6 +165,10 @@ class EmailNotificationSubscriber implements EventSubscriberInterface
|
||||
$disposition = $document->getDisposition();
|
||||
$teamer = $disposition->getTeamer();
|
||||
|
||||
if (true === $this->isExcluded($teamer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$documentTypeLabel = match ($document->getType()) {
|
||||
Upload::TYPE_CONTRACT => 'dein Honorarvertrag',
|
||||
Upload::TYPE_INVOICE => 'deine Honorarnote',
|
||||
@@ -182,6 +199,10 @@ class EmailNotificationSubscriber implements EventSubscriberInterface
|
||||
|
||||
$teamer = $application->getTeamer();
|
||||
|
||||
if (true === $this->isExcluded($teamer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->mailer->createAndSendEmail([
|
||||
'application' => $application,
|
||||
], [
|
||||
@@ -203,6 +224,10 @@ class EmailNotificationSubscriber implements EventSubscriberInterface
|
||||
$disposition = $event->getDisposition();
|
||||
$teamer = $disposition->getTeamer();
|
||||
|
||||
if (true === $this->isExcluded($teamer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->mailer->createAndSendEmail([
|
||||
'assignment' => $disposition->getAssignment(),
|
||||
'reason' => $disposition->getCalledOffReason(),
|
||||
@@ -222,6 +247,10 @@ class EmailNotificationSubscriber implements EventSubscriberInterface
|
||||
foreach ($assignment->getDispositions() as $disposition) {
|
||||
$teamer = $disposition->getTeamer();
|
||||
|
||||
if (true === $this->isExcluded($teamer)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->mailer->createAndSendEmail([
|
||||
'assignment' => $assignment,
|
||||
], [
|
||||
@@ -231,4 +260,13 @@ class EmailNotificationSubscriber implements EventSubscriberInterface
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A deleted teamer is excluded from all communication. Their dispositions and
|
||||
* documents stay in place and remain visible, they are simply no longer written to.
|
||||
*/
|
||||
private function isExcluded(?Teamer $teamer): bool
|
||||
{
|
||||
return null === $teamer || true === $teamer->isDeleted();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,9 @@ class RequiredTeamerCheckSubscriber implements EventSubscriberInterface
|
||||
// while a check is outstanding - otherwise a teamer asked to confirm
|
||||
// wrong data has no way to fix it first
|
||||
'app_teamer_profile_index',
|
||||
// deleting the own account must stay reachable while a check is outstanding,
|
||||
// otherwise a teamer could be bounced into a check forever with no way out
|
||||
'app_teamer_profile_delete_account',
|
||||
'app_upload_delete',
|
||||
'app_security_logout',
|
||||
], true)) {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\Validator\Constraints\IsTrue;
|
||||
|
||||
class DeleteAccountType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
// deleting one's own account cannot be undone without asking the office, so it
|
||||
// takes a deliberate second step rather than a single click
|
||||
$builder->add('confirmed', CheckboxType::class, [
|
||||
'label' => 'Ja, ich möchte meinen Account endgültig löschen',
|
||||
'mapped' => false,
|
||||
'constraints' => [
|
||||
new IsTrue([
|
||||
'message' => 'Bitte bestätige die Löschung',
|
||||
]),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,10 @@ class TeamerFilterType extends AbstractType
|
||||
'label' => 'inaktive Teamer:innen anzeigen',
|
||||
'required' => false,
|
||||
])
|
||||
->add('includeDeleted', CheckboxType::class, [
|
||||
'label' => 'gelöschte Teamer:innen anzeigen',
|
||||
'required' => false,
|
||||
])
|
||||
->add('apply', SubmitType::class, [
|
||||
'label' => 'filtern',
|
||||
])
|
||||
|
||||
@@ -13,6 +13,7 @@ class TeamerFilterDto extends AbstractFilterDto
|
||||
protected bool $driverLicenseVerified = false;
|
||||
protected bool $noTrainings = false;
|
||||
protected bool $includeInactive = false;
|
||||
protected bool $includeDeleted = false;
|
||||
|
||||
public function getName(): ?string
|
||||
{
|
||||
@@ -119,4 +120,16 @@ class TeamerFilterDto extends AbstractFilterDto
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isIncludeDeleted(): bool
|
||||
{
|
||||
return $this->includeDeleted;
|
||||
}
|
||||
|
||||
public function setIncludeDeleted(bool $includeDeleted): static
|
||||
{
|
||||
$this->includeDeleted = $includeDeleted;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,12 @@ class AvailabilityRepository extends ServiceEntityRepository
|
||||
if (false === $custom) {
|
||||
$qb->andWhere($qb->expr()->isNull('availability.owner'));
|
||||
} else {
|
||||
$qb->andWhere($qb->expr()->isNotNull('availability.owner'));
|
||||
$qb
|
||||
->andWhere($qb->expr()->isNotNull('availability.owner'))
|
||||
// a deleted teamer takes no further assignments, so their own
|
||||
// availabilities must no longer be offered for disposition
|
||||
->andWhere($qb->expr()->isNull('teamer.deletedAt'))
|
||||
;
|
||||
}
|
||||
|
||||
return $qb->getQuery();
|
||||
@@ -49,6 +54,10 @@ class AvailabilityRepository extends ServiceEntityRepository
|
||||
|
||||
public function getSelectableForTeamer(Teamer $teamer): array
|
||||
{
|
||||
if (true === $teamer->isDeleted()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$qb = $this->createQueryBuilder('availability');
|
||||
|
||||
return $qb
|
||||
|
||||
@@ -106,7 +106,14 @@ class TeamerRepository extends ServiceEntityRepository
|
||||
;
|
||||
}
|
||||
|
||||
if (false === $filterDto->isIncludeInactive()) {
|
||||
if (false === $filterDto->isIncludeDeleted()) {
|
||||
$qb->andWhere($qb->expr()->isNull('teamer.deletedAt'));
|
||||
}
|
||||
|
||||
// a deleted teamer never logs in again and would always count as inactive, so
|
||||
// asking for them has to lift this filter - otherwise they could not be found
|
||||
// again to be restored
|
||||
if (false === $filterDto->isIncludeInactive() && false === $filterDto->isIncludeDeleted()) {
|
||||
$qb
|
||||
->andWhere($qb->expr()->gte('user.lastLoginAt', ':lastLoginThreshold'))
|
||||
->setParameter('lastLoginThreshold', new \DateTimeImmutable($this->teamerInactivePeriod))
|
||||
@@ -125,6 +132,7 @@ class TeamerRepository extends ServiceEntityRepository
|
||||
$qb->expr()->like('teamer.lastName', ':search'),
|
||||
$qb->expr()->like('teamer.firstName', ':search'),
|
||||
))
|
||||
->andWhere($qb->expr()->isNull('teamer.deletedAt'))
|
||||
->orderBy('teamer.lastName', 'ASC')
|
||||
->addOrderBy('teamer.firstName', 'ASC')
|
||||
->setParameter('search', '%'.$this->escapeLikeWildcards($search).'%')
|
||||
|
||||
@@ -52,6 +52,9 @@ class UserRepository extends ServiceEntityRepository
|
||||
));
|
||||
|
||||
return $qb
|
||||
// andWhere after a chain of orWhere yields "(... OR ...) AND ...", so this
|
||||
// narrows the whole list rather than widening it by one more alternative
|
||||
->andWhere($qb->expr()->isNull('u.deletedAt'))
|
||||
->orderBy('u.lastName', 'ASC')
|
||||
->getQuery()
|
||||
->getResult()
|
||||
@@ -82,6 +85,7 @@ class UserRepository extends ServiceEntityRepository
|
||||
|
||||
return $qb
|
||||
->andWhere('JSON_CONTAINS(user.roles, :role) = 1')
|
||||
->andWhere($qb->expr()->isNull('user.deletedAt'))
|
||||
->setParameter('role', json_encode($role))
|
||||
->getQuery()
|
||||
->getResult()
|
||||
@@ -98,6 +102,7 @@ class UserRepository extends ServiceEntityRepository
|
||||
$qb->expr()->like('user.firstName', ':search'),
|
||||
))
|
||||
->andWhere('JSON_CONTAINS(user.roles, :role) = 1')
|
||||
->andWhere($qb->expr()->isNull('user.deletedAt'))
|
||||
->orderBy('user.lastName', 'ASC')
|
||||
->addOrderBy('user.firstName', 'ASC')
|
||||
->setParameter('search', '%'.$this->escapeLikeWildcards($search).'%')
|
||||
|
||||
@@ -140,6 +140,19 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
|
||||
->findLocalUser($profileResponse)
|
||||
;
|
||||
|
||||
// A deleted account is excluded from every process, and the CRM must not be able
|
||||
// to undo that: no data is written back, no role is granted or revoked, not even
|
||||
// lastLoginAt is bumped. It is returned untouched so the UserChecker can refuse
|
||||
// the login and say why. Only an admin restores it.
|
||||
if (true === $user?->isDeleted()) {
|
||||
$this->logger->info('Skip CRM sync for deleted user', [
|
||||
'user_id' => $user->getId(),
|
||||
'user_email' => $email,
|
||||
]);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
// BusPro knows this person but grants them nothing in this application, so they
|
||||
// are no user of it: never create an account, block an existing one. Returning
|
||||
// the blocked user lets the UserChecker explain why the login was refused.
|
||||
|
||||
@@ -15,6 +15,12 @@ class UserChecker implements UserCheckerInterface
|
||||
return;
|
||||
}
|
||||
|
||||
// checked before the block, as a deletion is the stronger statement and its
|
||||
// message has to win when an account carries both
|
||||
if (true === $user->isDeleted()) {
|
||||
throw new CustomUserMessageAccountStatusException('Dein Account wurde gelöscht. Wende dich an das Team, wenn du ihn wiederherstellen möchtest.');
|
||||
}
|
||||
|
||||
if (true === $user->isDisabled()) {
|
||||
throw new CustomUserMessageAccountStatusException('Dein Account wurde gesperrt: '.$user->getDisabledReason());
|
||||
}
|
||||
|
||||
@@ -44,6 +44,12 @@ class ImpersonationVoter extends Voter
|
||||
return false;
|
||||
}
|
||||
|
||||
// a deleted account is excluded from every process, so impersonating it must not
|
||||
// become a way back into the teamer area
|
||||
if (true === $targetUser->isDeleted()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Admin is the only role allowed to impersonate
|
||||
if (false === $this->security->isGranted('ROLE_ADMIN')) {
|
||||
return false;
|
||||
|
||||
@@ -33,6 +33,10 @@ class TeamerFilterHandler extends AbstractFilterHandler
|
||||
$filterDto->setIncludeInactive((bool) $data['include_inactive']);
|
||||
}
|
||||
|
||||
if (isset($data['include_deleted'])) {
|
||||
$filterDto->setIncludeDeleted((bool) $data['include_deleted']);
|
||||
}
|
||||
|
||||
if (0 < count($data['job_profiles'])) {
|
||||
$jobProfiles = $this
|
||||
->entityManager
|
||||
@@ -57,6 +61,7 @@ class TeamerFilterHandler extends AbstractFilterHandler
|
||||
'driver_license_verified' => $filterDto->hasDriverLicenseVerified(),
|
||||
'no_trainings' => $filterDto->hasNoTrainings(),
|
||||
'include_inactive' => $filterDto->isIncludeInactive(),
|
||||
'include_deleted' => $filterDto->isIncludeDeleted(),
|
||||
'job_profiles' => array_map(function (JobProfile $jobProfile) {
|
||||
return $jobProfile->getId();
|
||||
}, $filterDto->getJobProfiles()),
|
||||
|
||||
@@ -30,7 +30,9 @@ class DispositionReminderService
|
||||
->innerJoin('disposition.assignment', 'assignment')
|
||||
->innerJoin('assignment.destination', 'destination')
|
||||
->innerJoin('disposition.documents', 'document', Join::WITH, 'document.type = :documentType')
|
||||
->innerJoin('disposition.teamer', 'teamer')
|
||||
->where($qb->expr()->andX(
|
||||
$qb->expr()->isNull('teamer.deletedAt'),
|
||||
$qb->expr()->notIn('assignment.status', ':statusAssignment'),
|
||||
$qb->expr()->eq('destination.dateFrom', ':dateFrom'),
|
||||
$qb->expr()->eq('document.status', ':statusContract'),
|
||||
|
||||
@@ -35,8 +35,12 @@ class UploadReminderService
|
||||
->innerJoin('disposition.assignment', 'assignment')
|
||||
->innerJoin('assignment.destination', 'destination')
|
||||
->leftJoin('disposition.documents', 'document', Join::WITH, 'document.type = :documentType')
|
||||
->innerJoin('disposition.teamer', 'teamer')
|
||||
->where($qb->expr()->andX(
|
||||
$qb->expr()->isNull('document'),
|
||||
// deleted teamers are excluded here rather than when sending, so that the
|
||||
// count reported below stays truthful
|
||||
$qb->expr()->isNull('teamer.deletedAt'),
|
||||
$qb->expr()->orX(
|
||||
$qb->expr()->eq('document.createdAt', ':contractDueDateFirst'),
|
||||
$qb->expr()->eq('document.createdAt', ':contractDueDateSecond'),
|
||||
@@ -90,8 +94,10 @@ class UploadReminderService
|
||||
->innerJoin('disposition.assignment', 'assignment')
|
||||
->innerJoin('assignment.destination', 'destination')
|
||||
->leftJoin('disposition.documents', 'document', Join::WITH, 'document.type = :documentType')
|
||||
->innerJoin('disposition.teamer', 'teamer')
|
||||
->where($qb->expr()->andX(
|
||||
$qb->expr()->isNull('document'),
|
||||
$qb->expr()->isNull('teamer.deletedAt'),
|
||||
$qb->expr()->orX(
|
||||
// Due date can be determined by assignment's date which potentially overrides destination date
|
||||
$qb->expr()->andX(
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\Teamer;
|
||||
|
||||
use App\Entity\Teamer;
|
||||
use App\Entity\User;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Soft-deletes a teamer together with their user account, and restores them again.
|
||||
*
|
||||
* A deletion excludes the person from every forward-looking process - the teamer list,
|
||||
* autocompletes, forms, mailings and cron reminders - while every existing record stays
|
||||
* untouched and visible: dispositions, applications, documents, feedback and the
|
||||
* contracts and invoices rendered from them are business records that have to survive.
|
||||
*
|
||||
* Both Teamer and User carry their own flag, because neither side is guaranteed to
|
||||
* exist and the queries that have to filter only ever have one of the two aliases at
|
||||
* hand. This class is the only place that writes either flag, so the two cannot drift
|
||||
* apart.
|
||||
*
|
||||
* Deletion is deliberately independent of the block expressed by User::$disabledAt: a
|
||||
* disciplinary block has to survive a deletion and the restore that follows it.
|
||||
*/
|
||||
class AccountDeletionHandler
|
||||
{
|
||||
public const SOURCE_ADMIN = 'admin';
|
||||
public const SOURCE_SELF = 'self';
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
public function delete(Teamer|User $subject, string $source): void
|
||||
{
|
||||
[$teamer, $user] = $this->resolvePair($subject);
|
||||
|
||||
if (false === $this->isDeleted($teamer, $user)) {
|
||||
$teamer?->setDeleted();
|
||||
$user?->setDeleted();
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->logger->info('Delete account', [...$this->logContext($teamer, $user), 'source' => $source]);
|
||||
}
|
||||
}
|
||||
|
||||
public function restore(Teamer|User $subject): void
|
||||
{
|
||||
[$teamer, $user] = $this->resolvePair($subject);
|
||||
|
||||
if (true === $this->isDeleted($teamer, $user)) {
|
||||
$teamer?->setRestored();
|
||||
$user?->setRestored();
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->logger->info('Restore account', $this->logContext($teamer, $user));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: ?Teamer, 1: ?User}
|
||||
*/
|
||||
private function resolvePair(Teamer|User $subject): array
|
||||
{
|
||||
if ($subject instanceof Teamer) {
|
||||
return [$subject, $subject->getUser()];
|
||||
}
|
||||
|
||||
return [$subject->getTeamer(), $subject];
|
||||
}
|
||||
|
||||
/**
|
||||
* Either side being flagged counts as deleted, so that a pair left inconsistent by
|
||||
* an earlier failure is repaired rather than skipped.
|
||||
*/
|
||||
private function isDeleted(?Teamer $teamer, ?User $user): bool
|
||||
{
|
||||
return true === $teamer?->isDeleted() || true === $user?->isDeleted();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function logContext(?Teamer $teamer, ?User $user): array
|
||||
{
|
||||
return [
|
||||
'teamer' => $teamer?->getFullName(),
|
||||
'teamer_id' => $teamer?->getId(),
|
||||
'user' => $user?->getEmail(),
|
||||
'user_id' => $user?->getId(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ class Application extends Constraint
|
||||
{
|
||||
public string $message = 'Diese Bewerbung überlappt sich mit einem deiner bestätigten Einsätze';
|
||||
|
||||
public string $deletedMessage = 'Für einen gelöschten Account können keine Bewerbungen angelegt werden';
|
||||
|
||||
public function getTargets(): array|string
|
||||
{
|
||||
return static::CLASS_CONSTRAINT;
|
||||
|
||||
@@ -21,6 +21,18 @@ class ApplicationValidator extends ConstraintValidator
|
||||
|
||||
$teamer = $application->getTeamer();
|
||||
|
||||
// a deleted teamer is excluded from every process, so no new application may be
|
||||
// created for them - not even by an admin acting on their behalf
|
||||
if (true === $teamer->isDeleted()) {
|
||||
$this
|
||||
->context
|
||||
->buildViolation($constraint->deletedMessage)
|
||||
->addViolation()
|
||||
;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (true === $teamer->isAllowOverlappingApplications()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user