import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Player } from 'src/players/entities/player.entity'; import { EntityCondition } from 'src/utils/types/entity-condition.type'; import { IPaginationOptions } from 'src/utils/types/pagination-options'; import { Brackets, Repository, SelectQueryBuilder } from 'typeorm'; import { RoleEnum } from '../roles/roles.enum'; import { CreateUserDto } from './dto/create-user.dto'; import { UpdateUserDto } from './dto/update-user.dto'; import { UserDirectoryQueryDto } from './dto/user-directory-query.dto'; import { AdminUserDirectorySummaryDto, UserDirectoryAssignmentDto, UserDirectoryPageDto, UserDirectoryReferenceDto, UserDirectorySummaryDto, } from './dto/user-directory-response.dto'; import { User } from './entities/user.entity'; @Injectable() export class UsersService { constructor( @InjectRepository(User) private usersRepository: Repository, @InjectRepository(Player) private playersRepository: Repository, ) {} create(createProfileDto: CreateUserDto) { return this.usersRepository.save( this.usersRepository.create(createProfileDto), ); } findManyWithPagination(paginationOptions: IPaginationOptions) { return this.usersRepository.find({ skip: (paginationOptions.page - 1) * paginationOptions.limit, take: paginationOptions.limit, }); } async findDirectory( requester: Pick, query: UserDirectoryQueryDto, ): Promise { const isAdmin = requester.role?.id === RoleEnum.admin; const page = query.page ?? 1; const limit = query.limit ?? 20; const sharedTeamsQuery = isAdmin ? undefined : this.createSharedTeamsQuery(requester.id); const directoryQuery = this.createDirectoryUsersQuery( isAdmin, sharedTeamsQuery, query.search, ); const totalResult = await directoryQuery .clone() .select('COUNT(DISTINCT user.id)', 'total') .getRawOne<{ total: string }>(); const userRows = await directoryQuery .select(this.directoryUserSelects(isAdmin)) .distinct(true) .orderBy('user.id', 'ASC') .offset((page - 1) * limit) .limit(limit) .getRawMany(); const assignments = await this.findDirectoryAssignments( userRows.map((user) => Number(user.user_id)), isAdmin, sharedTeamsQuery, ); const assignmentsByUserId = assignments.reduce((byUserId, assignment) => { const userAssignments = byUserId.get(Number(assignment.user_id)) ?? []; userAssignments.push(assignment); byUserId.set(Number(assignment.user_id), userAssignments); return byUserId; }, new Map()); const total = Number(totalResult?.total ?? 0); const data = userRows.map((user) => this.mapDirectoryUser( user, assignmentsByUserId.get(Number(user.user_id)) ?? [], isAdmin, ), ); return { data, page, limit, total, hasNextPage: page * limit < total, }; } findOne(fields: EntityCondition) { return this.usersRepository.findOne({ where: fields, }); } update(id: number, updateProfileDto: UpdateUserDto) { return this.usersRepository.save( this.usersRepository.create({ id, ...updateProfileDto, }), ); } async softDelete(id: number): Promise { await this.usersRepository.softDelete(id); } async findTeams(fields: EntityCondition) { const user = await this.findOne(fields); if (!user) { return []; } const players = await this.playersRepository.find({ where: { user: { id: user.id, }, }, relations: ['team'], }); return players; } async linkPlayerToUserId(user: User, playerId: number): Promise { return new Promise(async (resolve) => { const player = await this.playersRepository.findOneByOrFail({ id: playerId, }); player.user = user; await this.playersRepository.save(player); return resolve(true); }); } private createSharedTeamsQuery(requesterId: number) { return this.playersRepository .createQueryBuilder('requesterPlayer') .select('requesterPlayer.teamId') .where('requesterPlayer.userId = :requesterId', { requesterId }); } private createDirectoryUsersQuery( isAdmin: boolean, sharedTeamsQuery: SelectQueryBuilder | undefined, search: string | undefined, ) { const query = this.usersRepository .createQueryBuilder('user') .leftJoin('user.status', 'status') .leftJoin('user.role', 'role'); if (!isAdmin && sharedTeamsQuery) { query .innerJoin( 'user.players', 'visibleAssignment', `visibleAssignment.teamId IN (${sharedTeamsQuery.getQuery()})`, ) .setParameters(sharedTeamsQuery.getParameters()); } const term = search?.trim().toLocaleLowerCase(); if (!term) { return query; } const assignmentAlias = isAdmin ? 'searchAssignment' : 'visibleAssignment'; if (isAdmin) { query.leftJoin('user.players', assignmentAlias); } const searchColumns = [ 'user.firstName', 'user.lastName', `${assignmentAlias}.firstName`, `${assignmentAlias}.lastName`, ...(isAdmin ? ['user.email'] : []), ]; query.andWhere( new Brackets((where) => { searchColumns.forEach((column, index) => { const expression = `LOWER(${column}) LIKE :directorySearch`; if (index === 0) { where.where(expression); } else { where.orWhere(expression); } }); }), ); return query.setParameter('directorySearch', `%${term}%`); } private directoryUserSelects(isAdmin: boolean): string[] { return [ 'user.id AS user_id', 'user.firstName AS first_name', 'user.lastName AS last_name', 'status.id AS status_id', 'status.name AS status_name', ...(isAdmin ? [ 'user.email AS email', 'role.id AS role_id', 'role.name AS role_name', ] : []), ]; } private async findDirectoryAssignments( userIds: number[], isAdmin: boolean, sharedTeamsQuery: SelectQueryBuilder | undefined, ): Promise { if (userIds.length === 0) { return []; } const query = this.playersRepository .createQueryBuilder('player') .innerJoin('player.team', 'team') .leftJoin('player.teamRole', 'teamRole') .select([ 'player.id AS player_id', 'player.userId AS user_id', 'player.firstName AS first_name', 'player.lastName AS last_name', 'player.active AS active', 'team.id AS team_id', 'team.name AS team_name', 'team.alias AS team_alias', 'teamRole.id AS team_role_id', 'teamRole.name AS team_role_name', ]) .where('player.userId IN (:...userIds)', { userIds }) .orderBy('player.id', 'ASC'); if (!isAdmin && sharedTeamsQuery) { query .andWhere(`player.teamId IN (${sharedTeamsQuery.getQuery()})`) .setParameters(sharedTeamsQuery.getParameters()); } return query.getRawMany(); } private mapDirectoryUser( user: DirectoryUserRow, assignments: DirectoryAssignmentRow[], includeAdminFields: boolean, ): UserDirectorySummaryDto | AdminUserDirectorySummaryDto { const summary: UserDirectorySummaryDto = { id: Number(user.user_id), firstName: user.first_name, lastName: user.last_name, status: this.mapDirectoryReference(user.status_id, user.status_name), assignments: assignments.map((assignment) => this.mapDirectoryAssignment(assignment), ), }; if (!includeAdminFields) { return summary; } return { ...summary, email: user.email, role: this.mapDirectoryReference(user.role_id, user.role_name), }; } private mapDirectoryAssignment( player: DirectoryAssignmentRow, ): UserDirectoryAssignmentDto { return { id: Number(player.player_id), firstName: player.first_name, lastName: player.last_name, active: this.asBoolean(player.active), team: { id: Number(player.team_id), name: player.team_name, alias: player.team_alias, }, teamRole: this.mapDirectoryReference( player.team_role_id, player.team_role_name, ), }; } private asBoolean(value: boolean | number | string): boolean { return value === true || value === 1 || value === '1'; } private mapDirectoryReference( id: number | string | null, name: string | null, ): UserDirectoryReferenceDto | null { if (id == null) { return null; } return { id: Number(id), name: name ?? undefined, }; } } interface DirectoryUserRow { user_id: number | string; first_name: string | null; last_name: string | null; status_id: number | string | null; status_name: string | null; email?: string | null; role_id?: number | string | null; role_name?: string | null; } interface DirectoryAssignmentRow { player_id: number | string; user_id: number | string; first_name: string; last_name: string; active: boolean | number | string; team_id: number | string; team_name: string; team_alias: string; team_role_id: number | string | null; team_role_name: string | null; }