214 lines
8.1 KiB
PHP
214 lines
8.1 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Repository;
|
|
|
|
use App\Entity\BookingEditDraft;
|
|
use App\Entity\Groups\Accommodation;
|
|
use App\Entity\Groups\AccommodationBooking;
|
|
use App\Entity\Groups\AccommodationPrice;
|
|
use App\Entity\Groups\AdditionalService;
|
|
use App\Entity\Groups\BoardService;
|
|
use App\Entity\User;
|
|
use App\Form\Model\Filter\UserFilterDto;
|
|
use App\Repository\Filter\AppliesListFiltersTrait;
|
|
use App\Security\Role;
|
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
|
use Doctrine\ORM\Query\Expr\Join;
|
|
use Doctrine\ORM\QueryBuilder;
|
|
use Doctrine\Persistence\ManagerRegistry;
|
|
|
|
/**
|
|
* @extends ServiceEntityRepository<User>
|
|
*/
|
|
class UserRepository extends ServiceEntityRepository
|
|
{
|
|
use AppliesListFiltersTrait;
|
|
|
|
public function __construct(ManagerRegistry $registry)
|
|
{
|
|
parent::__construct($registry, User::class);
|
|
}
|
|
|
|
public function createFilteredQueryBuilder(UserFilterDto $filter): QueryBuilder
|
|
{
|
|
$qb = $this->createQueryBuilder('user');
|
|
|
|
$this->applySearchTerm($qb, $filter->searchTerm(), [
|
|
'user.email',
|
|
'user.firstName',
|
|
'user.lastName',
|
|
'user.addressId',
|
|
'user.personId',
|
|
]);
|
|
|
|
$this->applyDateWithin($qb, 'user.lastLoginAt', $filter->dateFrom, $filter->dateTo);
|
|
|
|
if (null !== $filter->role && '' !== $filter->role) {
|
|
// Same reasoning as findGroupsStaff(): the roles are a JSON array column and no
|
|
// JSON_CONTAINS is registered, so the needle carries its quotes to stay anchored
|
|
// to a whole array entry.
|
|
$qb
|
|
->andWhere('user.roles LIKE :role')
|
|
->setParameter('role', '%"'.$filter->role.'"%')
|
|
;
|
|
}
|
|
|
|
return $qb;
|
|
}
|
|
|
|
/**
|
|
* Users who can look after a group booking. The roles are a JSON array column and no
|
|
* JSON_CONTAINS is registered, so the needles carry their quotes to stay anchored to
|
|
* whole array entries. Only the literal roles count — a plain ROLE_ADMIN is not offered.
|
|
*
|
|
* @return User[]
|
|
*/
|
|
public function findGroupsStaff(): array
|
|
{
|
|
return $this->createQueryBuilder('u')
|
|
->andWhere('u.roles LIKE :groupsAdmin OR u.roles LIKE :groupsManager')
|
|
->setParameter('groupsAdmin', '%"ROLE_GROUPS_ADMIN"%')
|
|
->setParameter('groupsManager', '%"ROLE_GROUPS_MANAGER"%')
|
|
->orderBy('u.email', 'ASC')
|
|
->getQuery()
|
|
->getResult();
|
|
}
|
|
|
|
/**
|
|
* Everyone worth filtering an accommodation booking by: current groups staff, plus whoever
|
|
* a booking is still assigned to even after losing the role — otherwise a booking assigned
|
|
* last season would become unfindable the moment its manager is deroled.
|
|
*/
|
|
public function createAccommodationBookingManagersQueryBuilder(): QueryBuilder
|
|
{
|
|
return $this->createQueryBuilder('u')
|
|
->leftJoin(AccommodationBooking::class, 'booking', Join::ON, 'booking.managedBy = u')
|
|
->andWhere('u.roles LIKE :groupsAdmin OR u.roles LIKE :groupsManager OR booking.id IS NOT NULL')
|
|
->setParameter('groupsAdmin', '%"ROLE_GROUPS_ADMIN"%')
|
|
->setParameter('groupsManager', '%"ROLE_GROUPS_MANAGER"%')
|
|
->distinct()
|
|
->orderBy('u.firstName')
|
|
->addOrderBy('u.lastName')
|
|
->addOrderBy('u.email')
|
|
;
|
|
}
|
|
|
|
/**
|
|
* The accounts carrying a nomination nobody has approved yet.
|
|
*
|
|
* Same reasoning as findGroupsStaff(): the roles are a JSON array column and no
|
|
* JSON_CONTAINS is registered, so each needle carries its quotes to stay anchored to a whole
|
|
* array entry. The markers are spelled out one by one rather than matched as '%_PENDING"%'
|
|
* because "_" is a single-character wildcard in LIKE, which would match more than markers.
|
|
*
|
|
* @return User[]
|
|
*/
|
|
public function findWithPendingRoles(int $limit): array
|
|
{
|
|
$qb = $this->createQueryBuilder('u');
|
|
$orX = $qb->expr()->orX();
|
|
|
|
foreach (Role::ADMINISTRATIVE as $index => $role) {
|
|
$parameter = 'pending'.$index;
|
|
$orX->add('u.roles LIKE :'.$parameter);
|
|
$qb->setParameter($parameter, '%"'.Role::pending($role).'"%');
|
|
}
|
|
|
|
/** @var User[] $users */
|
|
$users = $qb
|
|
->andWhere($orX)
|
|
->orderBy('u.lastLoginAt', 'DESC')
|
|
->setMaxResults($limit)
|
|
->getQuery()
|
|
->getResult()
|
|
;
|
|
|
|
return $users;
|
|
}
|
|
|
|
/**
|
|
* The entities that point at a User and would silently lose that pointer if the account were
|
|
* deleted. Every one of these join columns is ON DELETE SET NULL, so the database does not
|
|
* stand in the way — it just erases who created, last touched or looks after a group booking.
|
|
* The cleanup therefore has to ask, rather than let the constraint decide.
|
|
*
|
|
* @var array<class-string, string[]>
|
|
*/
|
|
private const USER_REFERENCES = [
|
|
Accommodation::class => ['createdBy', 'updatedBy'],
|
|
AccommodationPrice::class => ['createdBy', 'updatedBy'],
|
|
AccommodationBooking::class => ['createdBy', 'updatedBy', 'managedBy'],
|
|
AdditionalService::class => ['createdBy', 'updatedBy'],
|
|
BoardService::class => ['createdBy', 'updatedBy'],
|
|
];
|
|
|
|
/**
|
|
* Accounts the cleanup may consider: holding no booking edit draft and named by nothing in the
|
|
* Groups subsystem. $inactiveSince narrows that to accounts dormant since then, and is the
|
|
* caller's choice rather than this method's default — BpnAuthenticator recreates an account on
|
|
* sight, so how long ago somebody last signed in says nothing about whether the row may go.
|
|
*
|
|
* Deliberately unbounded: the caller still has to drop everything whose roles disqualify it,
|
|
* so a LIMIT here would cap the rows examined rather than the rows acted on, and a caller
|
|
* asking for ten deletions would get however many of the first ten candidates survived the
|
|
* role filter. Ordered oldest login first so a caller that does cap the result takes the
|
|
* least recently seen accounts.
|
|
*
|
|
* The roles are deliberately not part of this query. They live in a JSON array column with no
|
|
* JSON_CONTAINS registered, and the column holds more than one spelling of the same set — rows
|
|
* written by different Doctrine versions differ in whitespace — so a LIKE or an equality test
|
|
* on the raw JSON would quietly miss accounts or, far worse, match the wrong ones. The caller
|
|
* filters the shortlist through Role::effectiveOnly(), which is the only thing that knows what
|
|
* a role set actually means.
|
|
*
|
|
* @return User[]
|
|
*/
|
|
public function findCleanupCandidates(?\DateTimeImmutable $inactiveSince = null): array
|
|
{
|
|
$qb = $this->createQueryBuilder('u');
|
|
|
|
if (null !== $inactiveSince) {
|
|
$qb
|
|
->andWhere('u.lastLoginAt IS NOT NULL')
|
|
->andWhere('u.lastLoginAt < :inactiveSince')
|
|
->setParameter('inactiveSince', $inactiveSince)
|
|
;
|
|
}
|
|
|
|
$qb->andWhere($qb->expr()->not($qb->expr()->exists(
|
|
$this->getEntityManager()->createQueryBuilder()
|
|
->select('draft.id')
|
|
->from(BookingEditDraft::class, 'draft')
|
|
->andWhere('draft.user = u')
|
|
->getDQL()
|
|
)));
|
|
|
|
$index = 0;
|
|
|
|
foreach (self::USER_REFERENCES as $class => $fields) {
|
|
foreach ($fields as $field) {
|
|
$alias = sprintf('ref%d%s', $index++, $field);
|
|
|
|
$qb->andWhere($qb->expr()->not($qb->expr()->exists(
|
|
$this->getEntityManager()->createQueryBuilder()
|
|
->select($alias.'.id')
|
|
->from($class, $alias)
|
|
->andWhere(sprintf('%s.%s = u', $alias, $field))
|
|
->getDQL()
|
|
)));
|
|
}
|
|
}
|
|
|
|
/** @var User[] $users */
|
|
$users = $qb
|
|
->orderBy('u.lastLoginAt', 'ASC')
|
|
->getQuery()
|
|
->getResult()
|
|
;
|
|
|
|
return $users;
|
|
}
|
|
}
|