feat: soft-delete for teamer accounts

addresses #869dv9br3
This commit is contained in:
Björn Fromme
2026-08-11 12:26:13 +02:00
parent 388ecf9603
commit d654ce77fb
45 changed files with 1151 additions and 7 deletions
@@ -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(),
];
}
}