feat: soft-delete for teamer accounts
addresses #869dv9br3
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260811075140 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE teamer ADD deleted_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\'');
|
||||
$this->addSql('ALTER TABLE user ADD deleted_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\'');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE teamer DROP deleted_at');
|
||||
$this->addSql('ALTER TABLE user DROP deleted_at');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{% set assignment = disposition.assignment %}
|
||||
<span class="whitespace-nowrap">{{ disposition.teamer.fullName(true) }}</span>
|
||||
{% include '_partials/_teamer_deleted_badge.html.twig' with { 'teamer': disposition.teamer } %}
|
||||
{% include '_partials/_dot.html.twig' %}
|
||||
<span class="whitespace-nowrap">{{ assignment.effectivePeriod.start|date('d.m.y') }} - {{ assignment.effectivePeriod.end|date('d.m.y') }}</span>
|
||||
{% include '_partials/_dot.html.twig' %}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{% if teamer is not null and teamer.deleted %}
|
||||
<span class="inline-block py-1 px-2 text-xs bg-gray-500 text-white"
|
||||
title="Account gelöscht am {{ teamer.deletedAt | date('d.m.Y') }}">
|
||||
gelöscht
|
||||
</span>
|
||||
{% endif %}
|
||||
@@ -100,6 +100,7 @@
|
||||
<a href="{{ path('app_administrative_assignment_detail', { 'uuid': assignment.uuid, 'r': return_url() }) }}">
|
||||
{{ application.teamer }}
|
||||
</a>
|
||||
{% include '_partials/_teamer_deleted_badge.html.twig' with { 'teamer': application.teamer } %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex items-center justify-end space-x-1"
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
{% extends 'htmx_confirmation_modal.html.twig' %}
|
||||
|
||||
{% block title %}Account löschen{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="pb-4">
|
||||
Möchtest du <em>{{ teamer }}</em> und den zugehörigen Benutzeraccount wirklich löschen?
|
||||
</div>
|
||||
<div class="pb-4">
|
||||
Die Person wird aus allen Listen, Formularen und dem Mailversand ausgeschlossen und kann
|
||||
sich nicht mehr anmelden. Bestehende Einsätze, Dokumente und Feedbacks bleiben erhalten
|
||||
und weiterhin sichtbar.
|
||||
</div>
|
||||
{% if pendingApplicationCount > 0 or upcomingDispositionCount > 0 %}
|
||||
<div class="p-4 bg-red-50 border border-red-200 rounded-md">
|
||||
<div class="font-bold pb-2">Achtung: offene Vorgänge</div>
|
||||
<ul class="list-disc list-inside">
|
||||
{% if pendingApplicationCount > 0 %}
|
||||
<li>{{ pendingApplicationCount }} offene Bewerbung(en)</li>
|
||||
{% endif %}
|
||||
{% if upcomingDispositionCount > 0 %}
|
||||
<li>{{ upcomingDispositionCount }} anstehende(r) Einsatz/Einsätze</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
<div class="pt-2">
|
||||
Diese bleiben bestehen, die Person wird darüber aber nicht mehr benachrichtigt.
|
||||
Sage sie bei Bedarf vorher ab.
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block button_confirm %}Löschen{% endblock %}
|
||||
@@ -0,0 +1,17 @@
|
||||
{% extends 'htmx_confirmation_modal.html.twig' %}
|
||||
|
||||
{% block title %}Account wiederherstellen{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="pb-4">
|
||||
Möchtest du <em>{{ teamer }}</em> und den zugehörigen Benutzeraccount wirklich
|
||||
wiederherstellen?
|
||||
</div>
|
||||
<div>
|
||||
Die Person erscheint danach wieder in allen Listen und erhält wieder E-Mails. Liegt in
|
||||
BusPro keine Berechtigung mehr vor, wird der Account bei der nächsten Anmeldung erneut
|
||||
gesperrt.
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block button_confirm %}Wiederherstellen{% endblock %}
|
||||
@@ -48,6 +48,7 @@
|
||||
hx-target="body"
|
||||
hx-swap="beforeend">
|
||||
<span>{{ application.teamer }}</span>
|
||||
{% include '_partials/_teamer_deleted_badge.html.twig' with { 'teamer': application.teamer } %}
|
||||
{{ icon('info', 'w-4 h-4') }}
|
||||
</button>
|
||||
<twig:RatingStars rating="{{ application.teamer.averageRating }}" />
|
||||
@@ -155,6 +156,7 @@
|
||||
hx-target="body"
|
||||
hx-swap="beforeend">
|
||||
<span>{{ disposition.teamer }}</span>
|
||||
{% include '_partials/_teamer_deleted_badge.html.twig' with { 'teamer': disposition.teamer } %}
|
||||
{{ icon('info', 'w-4 h-4') }}
|
||||
</button>
|
||||
<twig:RatingStars rating="{{ disposition.teamer.averageRating }}" />
|
||||
|
||||
@@ -83,6 +83,7 @@
|
||||
</td>
|
||||
<td>
|
||||
{{ disposition.teamer }}
|
||||
{% include '_partials/_teamer_deleted_badge.html.twig' with { 'teamer': disposition.teamer } %}
|
||||
</td>
|
||||
<td>
|
||||
{{ upload.status|upload_status_badge }}
|
||||
|
||||
@@ -60,6 +60,7 @@
|
||||
</td>
|
||||
<td>
|
||||
{{ feedback.teamer.firstName }}
|
||||
{% include '_partials/_teamer_deleted_badge.html.twig' with { 'teamer': feedback.teamer } %}
|
||||
</td>
|
||||
<td>
|
||||
{% if feedback.assignmentDateFrom and feedback.assignmentDateTo %}
|
||||
|
||||
@@ -104,7 +104,19 @@
|
||||
{{ icon('check') }}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if teamer.user is not null and teamer.user.disabled %}
|
||||
{% if teamer.deleted %}
|
||||
{% if is_granted('ROLE_ADMIN') %}
|
||||
<button type="button"
|
||||
role="menuitem"
|
||||
tabindex="-1"
|
||||
hx-get="{{ path('app_admin_teamer_restore_account', { 'uuid': teamer.uuid }) }}"
|
||||
hx-target="body"
|
||||
hx-swap="beforeend"
|
||||
title="Account wiederherstellen">
|
||||
{{ icon('refresh') }}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% elseif teamer.user is not null and teamer.user.disabled %}
|
||||
{% if is_granted('ROLE_ADMIN') %}
|
||||
<button type="button"
|
||||
role="menuitem"
|
||||
@@ -132,11 +144,28 @@
|
||||
{{ icon('mask') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if is_granted('ROLE_ADMIN') %}
|
||||
<button type="button"
|
||||
class="text-red-500"
|
||||
role="menuitem"
|
||||
tabindex="-1"
|
||||
hx-get="{{ path('app_admin_teamer_delete_account', { 'uuid': teamer.uuid }) }}"
|
||||
hx-target="body"
|
||||
hx-swap="beforeend"
|
||||
title="Account löschen">
|
||||
{{ icon('delete') }}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<a href="{{ path('app_administrative_teamer_profile', { 'uuid': teamer.uuid, 'r': return_url() }) }}" title="Teamer:innenprofil {{ teamer }}">
|
||||
{{ icon('user') }}
|
||||
</a>
|
||||
</div>
|
||||
{% if teamer.deleted %}
|
||||
<div class="py-1 px-2 mt-2 text-xs bg-gray-500 text-white">
|
||||
gel. am {{ teamer.deletedAt | date('d.m.Y') }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if teamer.user is not null and teamer.user.disabled %}
|
||||
<div class="py-1 px-2 mt-2 text-xs bg-red-500 text-white">
|
||||
gesp. am {{ teamer.user.disabledAt | date('d.m.Y') }}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
{{ form_row(filterForm.driverLicenseVerified) }}
|
||||
{{ form_row(filterForm.noTrainings) }}
|
||||
{{ form_row(filterForm.includeInactive) }}
|
||||
{{ form_row(filterForm.includeDeleted) }}
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
{{ form_widget(filterForm.apply, { 'attr': { 'class': 'btn' } }) }}
|
||||
|
||||
@@ -164,6 +164,23 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pt-8 mt-4 border-t border-gray-200">
|
||||
<h4 class="font-bold pb-2">
|
||||
Account löschen
|
||||
</h4>
|
||||
<div class="pb-2 text-sm">
|
||||
Du kannst deinen Account löschen. Du wirst danach aus allen Listen
|
||||
und dem E-Mail-Versand ausgeschlossen und kannst dich nicht mehr
|
||||
anmelden.
|
||||
</div>
|
||||
<button type="button"
|
||||
class="btn btn--small bg-red-500 text-white"
|
||||
hx-get="{{ path('app_teamer_profile_delete_account') }}"
|
||||
hx-target="body"
|
||||
hx-swap="beforeend">
|
||||
Account löschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
{% extends 'htmx_modal.html.twig' %}
|
||||
|
||||
{% block title %}Account löschen{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ form_start(form) }}
|
||||
<div class="flex flex-col space-y-4 pb-4">
|
||||
<div>
|
||||
Wenn du deinen Account löschst, wirst du aus allen Listen, Formularen und dem
|
||||
E-Mail-Versand ausgeschlossen und kannst dich nicht mehr anmelden.
|
||||
</div>
|
||||
<div>
|
||||
Deine bisherigen Einsätze, Honorarverträge und Honorarnoten bleiben aus rechtlichen
|
||||
Gründen gespeichert und für das Team weiterhin sichtbar.
|
||||
</div>
|
||||
<div>
|
||||
Wenn du deinen Account später wieder brauchst, wende dich bitte an das Büro. Du
|
||||
kannst ihn nicht selbst wiederherstellen.
|
||||
</div>
|
||||
{{ form_row(form.confirmed) }}
|
||||
</div>
|
||||
<button type="submit" class="btn bg-red-500 text-white">
|
||||
Account löschen
|
||||
</button>
|
||||
{{ form_rest(form) }}
|
||||
{{ form_end(form) }}
|
||||
{% endblock %}
|
||||
@@ -466,6 +466,73 @@ class UserDataHandlerTest extends TestCase
|
||||
$this->assertSame(2, $user->getBusProPersonId());
|
||||
}
|
||||
|
||||
/**
|
||||
* A deleted account still has to be matched by email, otherwise the caller takes the
|
||||
* person for unknown and creates a second account for them - which would resurrect
|
||||
* them under a new row and defeat the deletion entirely. Nothing is written to it.
|
||||
*/
|
||||
public function testFindLocalUserReturnsADeletedEmailMatchWithoutWritingToIt(): void
|
||||
{
|
||||
$user = (new User())
|
||||
->setFirstName('First')
|
||||
->setLastName('Last')
|
||||
->setEmail('[email protected]')
|
||||
->setBusProAddressId(1)
|
||||
->setBusProPersonId(2)
|
||||
;
|
||||
$user->setDeleted();
|
||||
|
||||
$profileResponse = $this->createProfileResponse();
|
||||
|
||||
$repository = $this->createMock(ObjectRepository::class);
|
||||
$repository
|
||||
->expects($this->once())
|
||||
->method('findOneBy')
|
||||
->willReturn(null);
|
||||
|
||||
$repository
|
||||
->expects($this->once())
|
||||
->method('findBy')
|
||||
->with([
|
||||
'email' => '[email protected]',
|
||||
])
|
||||
->willReturn([$user]);
|
||||
|
||||
$this->entityManager
|
||||
->expects($this->once())
|
||||
->method('getRepository')
|
||||
->with(User::class)
|
||||
->willReturn($repository);
|
||||
|
||||
$handler = new UserDataHandler($this->entityManager, $this->logger);
|
||||
$resolvedUser = $handler->findLocalUser($profileResponse);
|
||||
|
||||
$this->assertSame($user, $resolvedUser);
|
||||
$this->assertSame(1, $user->getBusProAddressId());
|
||||
$this->assertSame(2, $user->getBusProPersonId());
|
||||
}
|
||||
|
||||
public function testDisableForRevokedCrmRolesLeavesADeletedAccountAlone(): void
|
||||
{
|
||||
$user = (new User())
|
||||
->setFirstName('First')
|
||||
->setLastName('Last')
|
||||
->setEmail('[email protected]')
|
||||
->setRoles(['ROLE_TEAMER'])
|
||||
;
|
||||
$user->setDeleted();
|
||||
|
||||
$this->entityManager
|
||||
->expects($this->never())
|
||||
->method('flush');
|
||||
|
||||
$handler = new UserDataHandler($this->entityManager, $this->logger);
|
||||
$handler->disableForRevokedCrmRoles($user);
|
||||
|
||||
$this->assertFalse($user->isDisabled());
|
||||
$this->assertSame(['ROLE_TEAMER'], $user->getAssignedRoles());
|
||||
}
|
||||
|
||||
private function createProfileResponse(?int $addressId = 200, ?int $personId = 100): ProfileResponse
|
||||
{
|
||||
$address = (new BusProAddress())
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Entity\Traits;
|
||||
|
||||
use App\Entity\SoftDeletableEntityInterface;
|
||||
use App\Entity\Traits\SoftDeletableEntity;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class SoftDeletableEntityTest extends TestCase
|
||||
{
|
||||
public function testAFreshEntityIsNotDeleted(): void
|
||||
{
|
||||
$entity = $this->createEntity();
|
||||
|
||||
$this->assertFalse($entity->isDeleted());
|
||||
$this->assertNull($entity->getDeletedAt());
|
||||
}
|
||||
|
||||
public function testSetDeletedStampsTheTimestamp(): void
|
||||
{
|
||||
$entity = $this->createEntity();
|
||||
$entity->setDeleted();
|
||||
|
||||
$this->assertTrue($entity->isDeleted());
|
||||
$this->assertNotNull($entity->getDeletedAt());
|
||||
}
|
||||
|
||||
public function testSetRestoredClearsTheTimestamp(): void
|
||||
{
|
||||
$entity = $this->createEntity();
|
||||
$entity->setDeleted();
|
||||
$entity->setRestored();
|
||||
|
||||
$this->assertFalse($entity->isDeleted());
|
||||
$this->assertNull($entity->getDeletedAt());
|
||||
}
|
||||
|
||||
public function testDeletedAtAcceptsNullSoDeletionsCanBeUndone(): void
|
||||
{
|
||||
$entity = $this->createEntity();
|
||||
$entity->setDeletedAt(new \DateTimeImmutable());
|
||||
$entity->setDeletedAt(null);
|
||||
|
||||
$this->assertFalse($entity->isDeleted());
|
||||
}
|
||||
|
||||
private function createEntity(): SoftDeletableEntityInterface
|
||||
{
|
||||
return new class implements SoftDeletableEntityInterface {
|
||||
use SoftDeletableEntity;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Entity;
|
||||
|
||||
use App\Entity\Teamer;
|
||||
use App\Entity\User;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Validator\ConstraintViolationListInterface;
|
||||
@@ -118,6 +119,44 @@ class UserTest extends TestCase
|
||||
$this->assertNull($enabled->getDisabledAt());
|
||||
}
|
||||
|
||||
/**
|
||||
* A block and a deletion are independent states: setting one must never imply or
|
||||
* clear the other, so that a disciplinary block survives a deletion and the restore
|
||||
* that follows it.
|
||||
*/
|
||||
public function testDeletionAndBlockAreIndependentStates(): void
|
||||
{
|
||||
$user = new User();
|
||||
$this->assertFalse($user->isBlocked());
|
||||
|
||||
$user->setDeleted();
|
||||
$this->assertTrue($user->isBlocked());
|
||||
$this->assertFalse($user->isDisabled());
|
||||
|
||||
$user->setDisabled(true);
|
||||
$disabledAt = $user->getDisabledAt();
|
||||
|
||||
$user->setRestored();
|
||||
$this->assertFalse($user->isDeleted());
|
||||
$this->assertTrue($user->isBlocked());
|
||||
$this->assertSame($disabledAt, $user->getDisabledAt());
|
||||
|
||||
$user->setDisabled(false);
|
||||
$this->assertFalse($user->isBlocked());
|
||||
}
|
||||
|
||||
public function testSettingTheTeamerKeepsTheInverseSideInSync(): void
|
||||
{
|
||||
$teamer = new Teamer();
|
||||
$user = (new User())->setTeamer($teamer);
|
||||
|
||||
$this->assertSame($user, $teamer->getUser());
|
||||
|
||||
$user->setTeamer(null);
|
||||
|
||||
$this->assertNull($teamer->getUser());
|
||||
}
|
||||
|
||||
private function validate(User $user): ConstraintViolationListInterface
|
||||
{
|
||||
return Validation::createValidatorBuilder()
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\EventListener;
|
||||
|
||||
use App\Email\Mailer;
|
||||
use App\Entity\Application;
|
||||
use App\Entity\Assignment;
|
||||
use App\Entity\Disposition;
|
||||
use App\Entity\Embeddable\Communication;
|
||||
use App\Entity\Teamer;
|
||||
use App\Event\ApplicationStatusEvent;
|
||||
use App\Event\AssignmentCalledOffEvent;
|
||||
use App\Event\DispositionCalledOffEvent;
|
||||
use App\EventListener\EmailNotificationSubscriber;
|
||||
use App\Model\ApplicationStatusDto;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Service\Pdf\ContractRenderer;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* A deleted teamer must never be mailed again - this is the point of the deletion, so
|
||||
* every handler that writes to a teamer is covered here.
|
||||
*/
|
||||
class EmailNotificationSubscriberTest extends TestCase
|
||||
{
|
||||
private Mailer&MockObject $mailer;
|
||||
private EmailNotificationSubscriber $subscriber;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->mailer = $this->createMock(Mailer::class);
|
||||
|
||||
$this->subscriber = new EmailNotificationSubscriber(
|
||||
$this->mailer,
|
||||
$this->createMock(UserRepository::class),
|
||||
$this->createMock(ContractRenderer::class),
|
||||
);
|
||||
}
|
||||
|
||||
public function testDeletedTeamerIsNotNotifiedAboutCalledOffDisposition(): void
|
||||
{
|
||||
$disposition = $this->createDisposition($this->createDeletedTeamer());
|
||||
|
||||
$this->mailer->expects($this->never())->method('createAndSendEmail');
|
||||
|
||||
$this->subscriber->onDispositionCalledOff(new DispositionCalledOffEvent($disposition, true));
|
||||
}
|
||||
|
||||
public function testActiveTeamerIsNotifiedAboutCalledOffDisposition(): void
|
||||
{
|
||||
$disposition = $this->createDisposition($this->createTeamer());
|
||||
|
||||
$this->mailer->expects($this->once())->method('createAndSendEmail');
|
||||
|
||||
$this->subscriber->onDispositionCalledOff(new DispositionCalledOffEvent($disposition, true));
|
||||
}
|
||||
|
||||
public function testDeletedTeamerIsNotNotifiedAboutRejectedApplication(): void
|
||||
{
|
||||
$application = new Application(new Assignment(), $this->createDeletedTeamer());
|
||||
$application->setStatus(Application::STATUS_REJECTED);
|
||||
|
||||
$this->mailer->expects($this->never())->method('createAndSendEmail');
|
||||
|
||||
$this->subscriber->onApplicationStatus(
|
||||
new ApplicationStatusEvent(new ApplicationStatusDto($application))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The call-off of a whole assignment mails every teamer on it, so the exclusion has
|
||||
* to happen per teamer rather than for the event as a whole.
|
||||
*/
|
||||
public function testCalledOffAssignmentSkipsOnlyTheDeletedTeamer(): void
|
||||
{
|
||||
$assignment = new Assignment();
|
||||
$assignment->addDisposition($this->createDisposition($this->createTeamer(), $assignment));
|
||||
$assignment->addDisposition($this->createDisposition($this->createDeletedTeamer(), $assignment));
|
||||
|
||||
$this->mailer->expects($this->once())->method('createAndSendEmail');
|
||||
|
||||
$this->subscriber->onAssignmentCalledOff(new AssignmentCalledOffEvent($assignment));
|
||||
}
|
||||
|
||||
private function createDisposition(Teamer $teamer, ?Assignment $assignment = null): Disposition
|
||||
{
|
||||
return new Disposition(new Application($assignment ?? new Assignment(), $teamer));
|
||||
}
|
||||
|
||||
private function createTeamer(): Teamer
|
||||
{
|
||||
return (new Teamer())->setCommunication(
|
||||
(new Communication())->setEmail('[email protected]')
|
||||
);
|
||||
}
|
||||
|
||||
private function createDeletedTeamer(): Teamer
|
||||
{
|
||||
$teamer = $this->createTeamer();
|
||||
$teamer->setDeleted();
|
||||
|
||||
return $teamer;
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,44 @@ class BpnAuthenticatorTest extends TestCase
|
||||
$this->loadUser();
|
||||
}
|
||||
|
||||
/**
|
||||
* The CRM must not be able to undo a deletion, in either direction: neither by
|
||||
* refreshing the account's data nor by blocking it further.
|
||||
*/
|
||||
public function testDeletedUserIsReturnedWithoutAnyCrmSync(): void
|
||||
{
|
||||
$user = (new User())->setRoles(['ROLE_TEAMER']);
|
||||
$user->setDeleted();
|
||||
|
||||
$this->stubApiClient($this->createCrmAttributes());
|
||||
|
||||
$this->userDataHandler->method('collectRoles')->willReturn(['ROLE_TEAMER']);
|
||||
$this->userDataHandler->method('findLocalUser')->willReturn($user);
|
||||
|
||||
$this->userDataHandler->expects($this->never())->method('updateLocalUser');
|
||||
$this->userDataHandler->expects($this->never())->method('createLocalUser');
|
||||
$this->userDataHandler->expects($this->never())->method('disableForRevokedCrmRoles');
|
||||
|
||||
// returned rather than refused, so the UserChecker can explain the deletion
|
||||
$this->assertSame($user, $this->loadUser());
|
||||
}
|
||||
|
||||
public function testDeletedUserIsNotBlockedWhenTheCrmRevokedEveryRole(): void
|
||||
{
|
||||
$user = (new User())->setRoles(['ROLE_TEAMER']);
|
||||
$user->setDeleted();
|
||||
|
||||
$this->stubApiClient($this->createCrmAttributes());
|
||||
|
||||
$this->userDataHandler->method('collectRoles')->willReturn([]);
|
||||
$this->userDataHandler->method('findLocalUser')->willReturn($user);
|
||||
|
||||
$this->userDataHandler->expects($this->never())->method('disableForRevokedCrmRoles');
|
||||
|
||||
$this->assertSame($user, $this->loadUser());
|
||||
$this->assertFalse($user->isDisabled());
|
||||
}
|
||||
|
||||
private function createCrmAttributes(): CrmAttributesResponse
|
||||
{
|
||||
return (new CrmAttributesResponse())->setAttributeGroups([new CrmAttributeGroup()]);
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Security;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Security\UserChecker;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Security\Core\Exception\CustomUserMessageAccountStatusException;
|
||||
|
||||
class UserCheckerTest extends TestCase
|
||||
{
|
||||
private UserChecker $userChecker;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->userChecker = new UserChecker();
|
||||
}
|
||||
|
||||
public function testDeletedUserIsRefused(): void
|
||||
{
|
||||
$user = (new User())->setRoles(['ROLE_TEAMER']);
|
||||
$user->setDeleted();
|
||||
|
||||
$this->expectException(CustomUserMessageAccountStatusException::class);
|
||||
$this->expectExceptionMessageMatches('/gelöscht/');
|
||||
|
||||
$this->userChecker->checkPreAuth($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* A deletion is the stronger statement, so its message has to win over the block
|
||||
* message when an account carries both.
|
||||
*/
|
||||
public function testDeletedAndDisabledUserIsRefusedWithTheDeletionMessage(): void
|
||||
{
|
||||
$user = (new User())->setRoles(['ROLE_TEAMER']);
|
||||
$user->setDisabled(true);
|
||||
$user->setDisabledReason('Disziplinarisch gesperrt');
|
||||
$user->setDeleted();
|
||||
|
||||
$this->expectException(CustomUserMessageAccountStatusException::class);
|
||||
$this->expectExceptionMessageMatches('/gelöscht/');
|
||||
|
||||
$this->userChecker->checkPreAuth($user);
|
||||
}
|
||||
|
||||
public function testDisabledUserStillGetsTheBlockMessage(): void
|
||||
{
|
||||
$user = (new User())->setRoles(['ROLE_TEAMER']);
|
||||
$user->setDisabled(true);
|
||||
$user->setDisabledReason('Disziplinarisch gesperrt');
|
||||
|
||||
$this->expectException(CustomUserMessageAccountStatusException::class);
|
||||
$this->expectExceptionMessage('Dein Account wurde gesperrt: Disziplinarisch gesperrt');
|
||||
|
||||
$this->userChecker->checkPreAuth($user);
|
||||
}
|
||||
|
||||
public function testActiveUserWithValidRolePasses(): void
|
||||
{
|
||||
$user = (new User())->setRoles(['ROLE_TEAMER']);
|
||||
|
||||
$this->userChecker->checkPreAuth($user);
|
||||
|
||||
$this->assertFalse($user->isBlocked());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Service\Teamer;
|
||||
|
||||
use App\Entity\Teamer;
|
||||
use App\Entity\User;
|
||||
use App\Service\Teamer\AccountDeletionHandler;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class AccountDeletionHandlerTest extends TestCase
|
||||
{
|
||||
private EntityManagerInterface&MockObject $entityManager;
|
||||
private AccountDeletionHandler $handler;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
|
||||
$this->handler = new AccountDeletionHandler(
|
||||
$this->entityManager,
|
||||
$this->createMock(LoggerInterface::class),
|
||||
);
|
||||
}
|
||||
|
||||
public function testDeleteFromTeamerSideFlagsBothEntities(): void
|
||||
{
|
||||
$teamer = new Teamer();
|
||||
$user = (new User())->setTeamer($teamer);
|
||||
|
||||
$this->entityManager->expects($this->once())->method('flush');
|
||||
|
||||
$this->handler->delete($teamer, AccountDeletionHandler::SOURCE_ADMIN);
|
||||
|
||||
$this->assertTrue($teamer->isDeleted());
|
||||
$this->assertTrue($user->isDeleted());
|
||||
}
|
||||
|
||||
public function testDeleteFromUserSideFlagsBothEntities(): void
|
||||
{
|
||||
$teamer = new Teamer();
|
||||
$user = (new User())->setTeamer($teamer);
|
||||
|
||||
$this->handler->delete($user, AccountDeletionHandler::SOURCE_SELF);
|
||||
|
||||
$this->assertTrue($teamer->isDeleted());
|
||||
$this->assertTrue($user->isDeleted());
|
||||
}
|
||||
|
||||
public function testDeleteWorksForUserWithoutTeamer(): void
|
||||
{
|
||||
$user = new User();
|
||||
|
||||
$this->entityManager->expects($this->once())->method('flush');
|
||||
|
||||
$this->handler->delete($user, AccountDeletionHandler::SOURCE_ADMIN);
|
||||
|
||||
$this->assertTrue($user->isDeleted());
|
||||
}
|
||||
|
||||
public function testDeleteWorksForTeamerWithoutUser(): void
|
||||
{
|
||||
$teamer = new Teamer();
|
||||
|
||||
$this->entityManager->expects($this->once())->method('flush');
|
||||
|
||||
$this->handler->delete($teamer, AccountDeletionHandler::SOURCE_ADMIN);
|
||||
|
||||
$this->assertTrue($teamer->isDeleted());
|
||||
}
|
||||
|
||||
public function testDeletingTwiceIsANoOpAndKeepsTheOriginalTimestamp(): void
|
||||
{
|
||||
$teamer = new Teamer();
|
||||
$user = (new User())->setTeamer($teamer);
|
||||
|
||||
$this->entityManager->expects($this->once())->method('flush');
|
||||
|
||||
$this->handler->delete($teamer, AccountDeletionHandler::SOURCE_ADMIN);
|
||||
$deletedAt = $teamer->getDeletedAt();
|
||||
|
||||
$this->handler->delete($teamer, AccountDeletionHandler::SOURCE_ADMIN);
|
||||
|
||||
$this->assertSame($deletedAt, $teamer->getDeletedAt());
|
||||
}
|
||||
|
||||
/**
|
||||
* A deletion is not a block: the two states are independent, so that a disciplinary
|
||||
* block survives a deletion and the restore that follows it.
|
||||
*/
|
||||
public function testDeleteLeavesRolesAndTheBlockUntouched(): void
|
||||
{
|
||||
$teamer = new Teamer();
|
||||
$user = (new User())
|
||||
->setTeamer($teamer)
|
||||
->setRoles(['ROLE_TEAMER', 'ROLE_ADMIN'])
|
||||
;
|
||||
$user->setDisabled(true);
|
||||
$disabledAt = $user->getDisabledAt();
|
||||
|
||||
$this->handler->delete($user, AccountDeletionHandler::SOURCE_ADMIN);
|
||||
|
||||
$this->assertSame(['ROLE_TEAMER', 'ROLE_ADMIN'], $user->getAssignedRoles());
|
||||
$this->assertSame($disabledAt, $user->getDisabledAt());
|
||||
}
|
||||
|
||||
public function testRestoreClearsBothFlags(): void
|
||||
{
|
||||
$teamer = new Teamer();
|
||||
$user = (new User())->setTeamer($teamer);
|
||||
|
||||
$this->handler->delete($teamer, AccountDeletionHandler::SOURCE_ADMIN);
|
||||
$this->handler->restore($teamer);
|
||||
|
||||
$this->assertFalse($teamer->isDeleted());
|
||||
$this->assertFalse($user->isDeleted());
|
||||
}
|
||||
|
||||
public function testRestoreOfAnActiveAccountIsANoOp(): void
|
||||
{
|
||||
$teamer = new Teamer();
|
||||
|
||||
$this->entityManager->expects($this->never())->method('flush');
|
||||
|
||||
$this->handler->restore($teamer);
|
||||
|
||||
$this->assertFalse($teamer->isDeleted());
|
||||
}
|
||||
|
||||
/**
|
||||
* Should a pair ever be left half-flagged, restoring has to repair it rather than
|
||||
* skip it because one side already looks active.
|
||||
*/
|
||||
public function testRestoreRepairsAnInconsistentPair(): void
|
||||
{
|
||||
$teamer = (new Teamer())->setDeleted();
|
||||
$user = (new User())->setTeamer($teamer);
|
||||
|
||||
$this->handler->restore($user);
|
||||
|
||||
$this->assertFalse($teamer->isDeleted());
|
||||
$this->assertFalse($user->isDeleted());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user