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 */
|
/** @var User $user */
|
||||||
$user = $users[0];
|
$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();
|
$addressId = $profileResponse->getAddressId();
|
||||||
$personId = $profileResponse->getPersonId();
|
$personId = $profileResponse->getPersonId();
|
||||||
if (null === $addressId || null === $personId) {
|
if (null === $addressId || null === $personId) {
|
||||||
@@ -232,6 +239,12 @@ class UserDataHandler
|
|||||||
*/
|
*/
|
||||||
public function disableForRevokedCrmRoles(User $user): void
|
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
|
// 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
|
// findLocalUser() may have refreshed the BusPro ids and nothing else flushes here
|
||||||
if (true === $user->isDisabled()) {
|
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 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\Address;
|
||||||
use App\Entity\Embeddable\BankAccount;
|
use App\Entity\Embeddable\BankAccount;
|
||||||
use App\Entity\Embeddable\Communication;
|
use App\Entity\Embeddable\Communication;
|
||||||
|
use App\Entity\Traits\SoftDeletableEntity;
|
||||||
use App\Entity\Traits\TimestampableEntity;
|
use App\Entity\Traits\TimestampableEntity;
|
||||||
use App\Repository\TeamerRepository;
|
use App\Repository\TeamerRepository;
|
||||||
use Doctrine\Common\Collections\ArrayCollection;
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
@@ -16,9 +17,10 @@ use Symfony\Component\Uid\Uuid;
|
|||||||
use Symfony\Component\Validator\Constraints as Assert;
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
#[ORM\Entity(repositoryClass: TeamerRepository::class)]
|
#[ORM\Entity(repositoryClass: TeamerRepository::class)]
|
||||||
class Teamer implements TimestampableEntityInterface
|
class Teamer implements TimestampableEntityInterface, SoftDeletableEntityInterface
|
||||||
{
|
{
|
||||||
use TimestampableEntity;
|
use TimestampableEntity;
|
||||||
|
use SoftDeletableEntity;
|
||||||
|
|
||||||
public const STATUS_NEW = 'new';
|
public const STATUS_NEW = 'new';
|
||||||
public const STATUS_EXISTING = 'existing';
|
public const STATUS_EXISTING = 'existing';
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ trait SoftDeletableEntity
|
|||||||
return $this->deletedAt;
|
return $this->deletedAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setDeletedAt(\DateTimeImmutable $deletedAt): static
|
public function setDeletedAt(?\DateTimeImmutable $deletedAt): static
|
||||||
{
|
{
|
||||||
$this->deletedAt = $deletedAt;
|
$this->deletedAt = $deletedAt;
|
||||||
|
|
||||||
@@ -28,6 +28,13 @@ trait SoftDeletableEntity
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function setRestored(): static
|
||||||
|
{
|
||||||
|
$this->setDeletedAt(null);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
public function isDeleted(): bool
|
public function isDeleted(): bool
|
||||||
{
|
{
|
||||||
return null !== $this->getDeletedAt();
|
return null !== $this->getDeletedAt();
|
||||||
|
|||||||
+19
-1
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Entity;
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Entity\Traits\SoftDeletableEntity;
|
||||||
use App\Entity\Traits\TimestampableEntity;
|
use App\Entity\Traits\TimestampableEntity;
|
||||||
use App\Repository\UserRepository;
|
use App\Repository\UserRepository;
|
||||||
use Doctrine\DBAL\Types\Types;
|
use Doctrine\DBAL\Types\Types;
|
||||||
@@ -12,9 +13,10 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||||
|
|
||||||
#[ORM\Entity(repositoryClass: UserRepository::class)]
|
#[ORM\Entity(repositoryClass: UserRepository::class)]
|
||||||
class User implements UserInterface, TimestampableEntityInterface
|
class User implements UserInterface, TimestampableEntityInterface, SoftDeletableEntityInterface
|
||||||
{
|
{
|
||||||
use TimestampableEntity;
|
use TimestampableEntity;
|
||||||
|
use SoftDeletableEntity;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Assignable roles and their labels.
|
* Assignable roles and their labels.
|
||||||
@@ -315,6 +317,11 @@ class User implements UserInterface, TimestampableEntityInterface
|
|||||||
|
|
||||||
public function setTeamer(?Teamer $teamer): static
|
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;
|
$this->teamer = $teamer;
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
@@ -408,6 +415,17 @@ class User implements UserInterface, TimestampableEntityInterface
|
|||||||
return $this->setDisabledAt(new \DateTimeImmutable());
|
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
|
public function getDisabledReason(): ?string
|
||||||
{
|
{
|
||||||
return $this->disabledReason;
|
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\Email\Mailer;
|
||||||
use App\Entity\Application;
|
use App\Entity\Application;
|
||||||
|
use App\Entity\Teamer;
|
||||||
use App\Entity\Upload;
|
use App\Entity\Upload;
|
||||||
use App\Event\ApplicationStatusEvent;
|
use App\Event\ApplicationStatusEvent;
|
||||||
use App\Event\AssignmentCalledOffEvent;
|
use App\Event\AssignmentCalledOffEvent;
|
||||||
@@ -46,6 +47,13 @@ class EmailNotificationSubscriber implements EventSubscriberInterface
|
|||||||
{
|
{
|
||||||
$disposition = $event->getDisposition();
|
$disposition = $event->getDisposition();
|
||||||
$teamer = $disposition->getTeamer();
|
$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();
|
$assignment = $disposition->getAssignment();
|
||||||
$destination = $assignment->getDestination();
|
$destination = $assignment->getDestination();
|
||||||
|
|
||||||
@@ -78,6 +86,11 @@ class EmailNotificationSubscriber implements EventSubscriberInterface
|
|||||||
|
|
||||||
$disposition = $document->getDisposition();
|
$disposition = $document->getDisposition();
|
||||||
$teamer = $disposition->getTeamer();
|
$teamer = $disposition->getTeamer();
|
||||||
|
|
||||||
|
if (true === $this->isExcluded($teamer)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$assignment = $disposition->getAssignment();
|
$assignment = $disposition->getAssignment();
|
||||||
$destination = $assignment->getDestination();
|
$destination = $assignment->getDestination();
|
||||||
|
|
||||||
@@ -152,6 +165,10 @@ class EmailNotificationSubscriber implements EventSubscriberInterface
|
|||||||
$disposition = $document->getDisposition();
|
$disposition = $document->getDisposition();
|
||||||
$teamer = $disposition->getTeamer();
|
$teamer = $disposition->getTeamer();
|
||||||
|
|
||||||
|
if (true === $this->isExcluded($teamer)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$documentTypeLabel = match ($document->getType()) {
|
$documentTypeLabel = match ($document->getType()) {
|
||||||
Upload::TYPE_CONTRACT => 'dein Honorarvertrag',
|
Upload::TYPE_CONTRACT => 'dein Honorarvertrag',
|
||||||
Upload::TYPE_INVOICE => 'deine Honorarnote',
|
Upload::TYPE_INVOICE => 'deine Honorarnote',
|
||||||
@@ -182,6 +199,10 @@ class EmailNotificationSubscriber implements EventSubscriberInterface
|
|||||||
|
|
||||||
$teamer = $application->getTeamer();
|
$teamer = $application->getTeamer();
|
||||||
|
|
||||||
|
if (true === $this->isExcluded($teamer)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$this->mailer->createAndSendEmail([
|
$this->mailer->createAndSendEmail([
|
||||||
'application' => $application,
|
'application' => $application,
|
||||||
], [
|
], [
|
||||||
@@ -203,6 +224,10 @@ class EmailNotificationSubscriber implements EventSubscriberInterface
|
|||||||
$disposition = $event->getDisposition();
|
$disposition = $event->getDisposition();
|
||||||
$teamer = $disposition->getTeamer();
|
$teamer = $disposition->getTeamer();
|
||||||
|
|
||||||
|
if (true === $this->isExcluded($teamer)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$this->mailer->createAndSendEmail([
|
$this->mailer->createAndSendEmail([
|
||||||
'assignment' => $disposition->getAssignment(),
|
'assignment' => $disposition->getAssignment(),
|
||||||
'reason' => $disposition->getCalledOffReason(),
|
'reason' => $disposition->getCalledOffReason(),
|
||||||
@@ -222,6 +247,10 @@ class EmailNotificationSubscriber implements EventSubscriberInterface
|
|||||||
foreach ($assignment->getDispositions() as $disposition) {
|
foreach ($assignment->getDispositions() as $disposition) {
|
||||||
$teamer = $disposition->getTeamer();
|
$teamer = $disposition->getTeamer();
|
||||||
|
|
||||||
|
if (true === $this->isExcluded($teamer)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$this->mailer->createAndSendEmail([
|
$this->mailer->createAndSendEmail([
|
||||||
'assignment' => $assignment,
|
'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
|
// while a check is outstanding - otherwise a teamer asked to confirm
|
||||||
// wrong data has no way to fix it first
|
// wrong data has no way to fix it first
|
||||||
'app_teamer_profile_index',
|
'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_upload_delete',
|
||||||
'app_security_logout',
|
'app_security_logout',
|
||||||
], true)) {
|
], 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',
|
'label' => 'inaktive Teamer:innen anzeigen',
|
||||||
'required' => false,
|
'required' => false,
|
||||||
])
|
])
|
||||||
|
->add('includeDeleted', CheckboxType::class, [
|
||||||
|
'label' => 'gelöschte Teamer:innen anzeigen',
|
||||||
|
'required' => false,
|
||||||
|
])
|
||||||
->add('apply', SubmitType::class, [
|
->add('apply', SubmitType::class, [
|
||||||
'label' => 'filtern',
|
'label' => 'filtern',
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ class TeamerFilterDto extends AbstractFilterDto
|
|||||||
protected bool $driverLicenseVerified = false;
|
protected bool $driverLicenseVerified = false;
|
||||||
protected bool $noTrainings = false;
|
protected bool $noTrainings = false;
|
||||||
protected bool $includeInactive = false;
|
protected bool $includeInactive = false;
|
||||||
|
protected bool $includeDeleted = false;
|
||||||
|
|
||||||
public function getName(): ?string
|
public function getName(): ?string
|
||||||
{
|
{
|
||||||
@@ -119,4 +120,16 @@ class TeamerFilterDto extends AbstractFilterDto
|
|||||||
|
|
||||||
return $this;
|
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) {
|
if (false === $custom) {
|
||||||
$qb->andWhere($qb->expr()->isNull('availability.owner'));
|
$qb->andWhere($qb->expr()->isNull('availability.owner'));
|
||||||
} else {
|
} 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();
|
return $qb->getQuery();
|
||||||
@@ -49,6 +54,10 @@ class AvailabilityRepository extends ServiceEntityRepository
|
|||||||
|
|
||||||
public function getSelectableForTeamer(Teamer $teamer): array
|
public function getSelectableForTeamer(Teamer $teamer): array
|
||||||
{
|
{
|
||||||
|
if (true === $teamer->isDeleted()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
$qb = $this->createQueryBuilder('availability');
|
$qb = $this->createQueryBuilder('availability');
|
||||||
|
|
||||||
return $qb
|
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
|
$qb
|
||||||
->andWhere($qb->expr()->gte('user.lastLoginAt', ':lastLoginThreshold'))
|
->andWhere($qb->expr()->gte('user.lastLoginAt', ':lastLoginThreshold'))
|
||||||
->setParameter('lastLoginThreshold', new \DateTimeImmutable($this->teamerInactivePeriod))
|
->setParameter('lastLoginThreshold', new \DateTimeImmutable($this->teamerInactivePeriod))
|
||||||
@@ -125,6 +132,7 @@ class TeamerRepository extends ServiceEntityRepository
|
|||||||
$qb->expr()->like('teamer.lastName', ':search'),
|
$qb->expr()->like('teamer.lastName', ':search'),
|
||||||
$qb->expr()->like('teamer.firstName', ':search'),
|
$qb->expr()->like('teamer.firstName', ':search'),
|
||||||
))
|
))
|
||||||
|
->andWhere($qb->expr()->isNull('teamer.deletedAt'))
|
||||||
->orderBy('teamer.lastName', 'ASC')
|
->orderBy('teamer.lastName', 'ASC')
|
||||||
->addOrderBy('teamer.firstName', 'ASC')
|
->addOrderBy('teamer.firstName', 'ASC')
|
||||||
->setParameter('search', '%'.$this->escapeLikeWildcards($search).'%')
|
->setParameter('search', '%'.$this->escapeLikeWildcards($search).'%')
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ class UserRepository extends ServiceEntityRepository
|
|||||||
));
|
));
|
||||||
|
|
||||||
return $qb
|
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')
|
->orderBy('u.lastName', 'ASC')
|
||||||
->getQuery()
|
->getQuery()
|
||||||
->getResult()
|
->getResult()
|
||||||
@@ -82,6 +85,7 @@ class UserRepository extends ServiceEntityRepository
|
|||||||
|
|
||||||
return $qb
|
return $qb
|
||||||
->andWhere('JSON_CONTAINS(user.roles, :role) = 1')
|
->andWhere('JSON_CONTAINS(user.roles, :role) = 1')
|
||||||
|
->andWhere($qb->expr()->isNull('user.deletedAt'))
|
||||||
->setParameter('role', json_encode($role))
|
->setParameter('role', json_encode($role))
|
||||||
->getQuery()
|
->getQuery()
|
||||||
->getResult()
|
->getResult()
|
||||||
@@ -98,6 +102,7 @@ class UserRepository extends ServiceEntityRepository
|
|||||||
$qb->expr()->like('user.firstName', ':search'),
|
$qb->expr()->like('user.firstName', ':search'),
|
||||||
))
|
))
|
||||||
->andWhere('JSON_CONTAINS(user.roles, :role) = 1')
|
->andWhere('JSON_CONTAINS(user.roles, :role) = 1')
|
||||||
|
->andWhere($qb->expr()->isNull('user.deletedAt'))
|
||||||
->orderBy('user.lastName', 'ASC')
|
->orderBy('user.lastName', 'ASC')
|
||||||
->addOrderBy('user.firstName', 'ASC')
|
->addOrderBy('user.firstName', 'ASC')
|
||||||
->setParameter('search', '%'.$this->escapeLikeWildcards($search).'%')
|
->setParameter('search', '%'.$this->escapeLikeWildcards($search).'%')
|
||||||
|
|||||||
@@ -140,6 +140,19 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
|
|||||||
->findLocalUser($profileResponse)
|
->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
|
// 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
|
// 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.
|
// the blocked user lets the UserChecker explain why the login was refused.
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ class UserChecker implements UserCheckerInterface
|
|||||||
return;
|
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()) {
|
if (true === $user->isDisabled()) {
|
||||||
throw new CustomUserMessageAccountStatusException('Dein Account wurde gesperrt: '.$user->getDisabledReason());
|
throw new CustomUserMessageAccountStatusException('Dein Account wurde gesperrt: '.$user->getDisabledReason());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,12 @@ class ImpersonationVoter extends Voter
|
|||||||
return false;
|
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
|
// Admin is the only role allowed to impersonate
|
||||||
if (false === $this->security->isGranted('ROLE_ADMIN')) {
|
if (false === $this->security->isGranted('ROLE_ADMIN')) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ class TeamerFilterHandler extends AbstractFilterHandler
|
|||||||
$filterDto->setIncludeInactive((bool) $data['include_inactive']);
|
$filterDto->setIncludeInactive((bool) $data['include_inactive']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isset($data['include_deleted'])) {
|
||||||
|
$filterDto->setIncludeDeleted((bool) $data['include_deleted']);
|
||||||
|
}
|
||||||
|
|
||||||
if (0 < count($data['job_profiles'])) {
|
if (0 < count($data['job_profiles'])) {
|
||||||
$jobProfiles = $this
|
$jobProfiles = $this
|
||||||
->entityManager
|
->entityManager
|
||||||
@@ -57,6 +61,7 @@ class TeamerFilterHandler extends AbstractFilterHandler
|
|||||||
'driver_license_verified' => $filterDto->hasDriverLicenseVerified(),
|
'driver_license_verified' => $filterDto->hasDriverLicenseVerified(),
|
||||||
'no_trainings' => $filterDto->hasNoTrainings(),
|
'no_trainings' => $filterDto->hasNoTrainings(),
|
||||||
'include_inactive' => $filterDto->isIncludeInactive(),
|
'include_inactive' => $filterDto->isIncludeInactive(),
|
||||||
|
'include_deleted' => $filterDto->isIncludeDeleted(),
|
||||||
'job_profiles' => array_map(function (JobProfile $jobProfile) {
|
'job_profiles' => array_map(function (JobProfile $jobProfile) {
|
||||||
return $jobProfile->getId();
|
return $jobProfile->getId();
|
||||||
}, $filterDto->getJobProfiles()),
|
}, $filterDto->getJobProfiles()),
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ class DispositionReminderService
|
|||||||
->innerJoin('disposition.assignment', 'assignment')
|
->innerJoin('disposition.assignment', 'assignment')
|
||||||
->innerJoin('assignment.destination', 'destination')
|
->innerJoin('assignment.destination', 'destination')
|
||||||
->innerJoin('disposition.documents', 'document', Join::WITH, 'document.type = :documentType')
|
->innerJoin('disposition.documents', 'document', Join::WITH, 'document.type = :documentType')
|
||||||
|
->innerJoin('disposition.teamer', 'teamer')
|
||||||
->where($qb->expr()->andX(
|
->where($qb->expr()->andX(
|
||||||
|
$qb->expr()->isNull('teamer.deletedAt'),
|
||||||
$qb->expr()->notIn('assignment.status', ':statusAssignment'),
|
$qb->expr()->notIn('assignment.status', ':statusAssignment'),
|
||||||
$qb->expr()->eq('destination.dateFrom', ':dateFrom'),
|
$qb->expr()->eq('destination.dateFrom', ':dateFrom'),
|
||||||
$qb->expr()->eq('document.status', ':statusContract'),
|
$qb->expr()->eq('document.status', ':statusContract'),
|
||||||
|
|||||||
@@ -35,8 +35,12 @@ class UploadReminderService
|
|||||||
->innerJoin('disposition.assignment', 'assignment')
|
->innerJoin('disposition.assignment', 'assignment')
|
||||||
->innerJoin('assignment.destination', 'destination')
|
->innerJoin('assignment.destination', 'destination')
|
||||||
->leftJoin('disposition.documents', 'document', Join::WITH, 'document.type = :documentType')
|
->leftJoin('disposition.documents', 'document', Join::WITH, 'document.type = :documentType')
|
||||||
|
->innerJoin('disposition.teamer', 'teamer')
|
||||||
->where($qb->expr()->andX(
|
->where($qb->expr()->andX(
|
||||||
$qb->expr()->isNull('document'),
|
$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()->orX(
|
||||||
$qb->expr()->eq('document.createdAt', ':contractDueDateFirst'),
|
$qb->expr()->eq('document.createdAt', ':contractDueDateFirst'),
|
||||||
$qb->expr()->eq('document.createdAt', ':contractDueDateSecond'),
|
$qb->expr()->eq('document.createdAt', ':contractDueDateSecond'),
|
||||||
@@ -90,8 +94,10 @@ class UploadReminderService
|
|||||||
->innerJoin('disposition.assignment', 'assignment')
|
->innerJoin('disposition.assignment', 'assignment')
|
||||||
->innerJoin('assignment.destination', 'destination')
|
->innerJoin('assignment.destination', 'destination')
|
||||||
->leftJoin('disposition.documents', 'document', Join::WITH, 'document.type = :documentType')
|
->leftJoin('disposition.documents', 'document', Join::WITH, 'document.type = :documentType')
|
||||||
|
->innerJoin('disposition.teamer', 'teamer')
|
||||||
->where($qb->expr()->andX(
|
->where($qb->expr()->andX(
|
||||||
$qb->expr()->isNull('document'),
|
$qb->expr()->isNull('document'),
|
||||||
|
$qb->expr()->isNull('teamer.deletedAt'),
|
||||||
$qb->expr()->orX(
|
$qb->expr()->orX(
|
||||||
// Due date can be determined by assignment's date which potentially overrides destination date
|
// Due date can be determined by assignment's date which potentially overrides destination date
|
||||||
$qb->expr()->andX(
|
$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 $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
|
public function getTargets(): array|string
|
||||||
{
|
{
|
||||||
return static::CLASS_CONSTRAINT;
|
return static::CLASS_CONSTRAINT;
|
||||||
|
|||||||
@@ -21,6 +21,18 @@ class ApplicationValidator extends ConstraintValidator
|
|||||||
|
|
||||||
$teamer = $application->getTeamer();
|
$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()) {
|
if (true === $teamer->isAllowOverlappingApplications()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{% set assignment = disposition.assignment %}
|
{% set assignment = disposition.assignment %}
|
||||||
<span class="whitespace-nowrap">{{ disposition.teamer.fullName(true) }}</span>
|
<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' %}
|
{% include '_partials/_dot.html.twig' %}
|
||||||
<span class="whitespace-nowrap">{{ assignment.effectivePeriod.start|date('d.m.y') }} - {{ assignment.effectivePeriod.end|date('d.m.y') }}</span>
|
<span class="whitespace-nowrap">{{ assignment.effectivePeriod.start|date('d.m.y') }} - {{ assignment.effectivePeriod.end|date('d.m.y') }}</span>
|
||||||
{% include '_partials/_dot.html.twig' %}
|
{% 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() }) }}">
|
<a href="{{ path('app_administrative_assignment_detail', { 'uuid': assignment.uuid, 'r': return_url() }) }}">
|
||||||
{{ application.teamer }}
|
{{ application.teamer }}
|
||||||
</a>
|
</a>
|
||||||
|
{% include '_partials/_teamer_deleted_badge.html.twig' with { 'teamer': application.teamer } %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="flex items-center justify-end space-x-1"
|
<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-target="body"
|
||||||
hx-swap="beforeend">
|
hx-swap="beforeend">
|
||||||
<span>{{ application.teamer }}</span>
|
<span>{{ application.teamer }}</span>
|
||||||
|
{% include '_partials/_teamer_deleted_badge.html.twig' with { 'teamer': application.teamer } %}
|
||||||
{{ icon('info', 'w-4 h-4') }}
|
{{ icon('info', 'w-4 h-4') }}
|
||||||
</button>
|
</button>
|
||||||
<twig:RatingStars rating="{{ application.teamer.averageRating }}" />
|
<twig:RatingStars rating="{{ application.teamer.averageRating }}" />
|
||||||
@@ -155,6 +156,7 @@
|
|||||||
hx-target="body"
|
hx-target="body"
|
||||||
hx-swap="beforeend">
|
hx-swap="beforeend">
|
||||||
<span>{{ disposition.teamer }}</span>
|
<span>{{ disposition.teamer }}</span>
|
||||||
|
{% include '_partials/_teamer_deleted_badge.html.twig' with { 'teamer': disposition.teamer } %}
|
||||||
{{ icon('info', 'w-4 h-4') }}
|
{{ icon('info', 'w-4 h-4') }}
|
||||||
</button>
|
</button>
|
||||||
<twig:RatingStars rating="{{ disposition.teamer.averageRating }}" />
|
<twig:RatingStars rating="{{ disposition.teamer.averageRating }}" />
|
||||||
|
|||||||
@@ -83,6 +83,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{{ disposition.teamer }}
|
{{ disposition.teamer }}
|
||||||
|
{% include '_partials/_teamer_deleted_badge.html.twig' with { 'teamer': disposition.teamer } %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{{ upload.status|upload_status_badge }}
|
{{ upload.status|upload_status_badge }}
|
||||||
|
|||||||
@@ -60,6 +60,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{{ feedback.teamer.firstName }}
|
{{ feedback.teamer.firstName }}
|
||||||
|
{% include '_partials/_teamer_deleted_badge.html.twig' with { 'teamer': feedback.teamer } %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if feedback.assignmentDateFrom and feedback.assignmentDateTo %}
|
{% if feedback.assignmentDateFrom and feedback.assignmentDateTo %}
|
||||||
|
|||||||
@@ -104,7 +104,19 @@
|
|||||||
{{ icon('check') }}
|
{{ icon('check') }}
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% 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') %}
|
{% if is_granted('ROLE_ADMIN') %}
|
||||||
<button type="button"
|
<button type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
@@ -132,11 +144,28 @@
|
|||||||
{{ icon('mask') }}
|
{{ icon('mask') }}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% 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 %}
|
{% endif %}
|
||||||
<a href="{{ path('app_administrative_teamer_profile', { 'uuid': teamer.uuid, 'r': return_url() }) }}" title="Teamer:innenprofil {{ teamer }}">
|
<a href="{{ path('app_administrative_teamer_profile', { 'uuid': teamer.uuid, 'r': return_url() }) }}" title="Teamer:innenprofil {{ teamer }}">
|
||||||
{{ icon('user') }}
|
{{ icon('user') }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</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 %}
|
{% 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">
|
<div class="py-1 px-2 mt-2 text-xs bg-red-500 text-white">
|
||||||
gesp. am {{ teamer.user.disabledAt | date('d.m.Y') }}
|
gesp. am {{ teamer.user.disabledAt | date('d.m.Y') }}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
{{ form_row(filterForm.driverLicenseVerified) }}
|
{{ form_row(filterForm.driverLicenseVerified) }}
|
||||||
{{ form_row(filterForm.noTrainings) }}
|
{{ form_row(filterForm.noTrainings) }}
|
||||||
{{ form_row(filterForm.includeInactive) }}
|
{{ form_row(filterForm.includeInactive) }}
|
||||||
|
{{ form_row(filterForm.includeDeleted) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
{{ form_widget(filterForm.apply, { 'attr': { 'class': 'btn' } }) }}
|
{{ form_widget(filterForm.apply, { 'attr': { 'class': 'btn' } }) }}
|
||||||
|
|||||||
@@ -164,6 +164,23 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</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());
|
$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
|
private function createProfileResponse(?int $addressId = 200, ?int $personId = 100): ProfileResponse
|
||||||
{
|
{
|
||||||
$address = (new BusProAddress())
|
$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;
|
namespace App\Tests\Entity;
|
||||||
|
|
||||||
|
use App\Entity\Teamer;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use Symfony\Component\Validator\ConstraintViolationListInterface;
|
use Symfony\Component\Validator\ConstraintViolationListInterface;
|
||||||
@@ -118,6 +119,44 @@ class UserTest extends TestCase
|
|||||||
$this->assertNull($enabled->getDisabledAt());
|
$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
|
private function validate(User $user): ConstraintViolationListInterface
|
||||||
{
|
{
|
||||||
return Validation::createValidatorBuilder()
|
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();
|
$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
|
private function createCrmAttributes(): CrmAttributesResponse
|
||||||
{
|
{
|
||||||
return (new CrmAttributesResponse())->setAttributeGroups([new CrmAttributeGroup()]);
|
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