*/ 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 */ 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; } }