Merge branch 'feature/admin-user-management'
This commit is contained in:
30
myteamwallet_backend/src/auth/auth.controller.spec.ts
Normal file
30
myteamwallet_backend/src/auth/auth.controller.spec.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { GUARDS_METADATA } from '@nestjs/common/constants';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthRegisterLoginDto } from './dto/auth-register-login.dto';
|
||||
|
||||
describe('AuthController session enforcement', () => {
|
||||
it('protects GET auth/me with JWT validation', () => {
|
||||
const guards = Reflect.getMetadata(
|
||||
GUARDS_METADATA,
|
||||
AuthController.prototype.me,
|
||||
);
|
||||
|
||||
expect(guards).toBeDefined();
|
||||
expect(guards).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not expose self-deletion that can race with an admin promotion', () => {
|
||||
expect(AuthController.prototype).not.toHaveProperty('delete');
|
||||
});
|
||||
|
||||
it('uses the narrow validated registration DTO instead of an untyped body', () => {
|
||||
const parameterTypes = Reflect.getMetadata(
|
||||
'design:paramtypes',
|
||||
AuthController.prototype,
|
||||
'register',
|
||||
);
|
||||
|
||||
expect(parameterTypes[0]).toBe(AuthRegisterLoginDto);
|
||||
expect(AuthRegisterLoginDto.prototype).not.toHaveProperty('linkPlayerId');
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
Post,
|
||||
UseGuards,
|
||||
Patch,
|
||||
Delete,
|
||||
UseInterceptors,
|
||||
ClassSerializerInterceptor,
|
||||
SerializeOptions,
|
||||
@@ -27,6 +26,7 @@ import {
|
||||
ApiOkResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import { CreateInviteDTO } from './dto/create-invite.dto';
|
||||
import { AuthRegisterLoginDto } from './dto/auth-register-login.dto';
|
||||
|
||||
@ApiTags('Auth')
|
||||
@Controller({
|
||||
@@ -51,7 +51,7 @@ export class AuthController {
|
||||
|
||||
@Post('email/register')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
async register(@Body() createUserDto: any) {
|
||||
async register(@Body() createUserDto: AuthRegisterLoginDto) {
|
||||
return this.service.register(createUserDto);
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ export class AuthController {
|
||||
groups: ['exposeProvider'],
|
||||
})
|
||||
@Get('me')
|
||||
// @UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@HttpCode(HttpStatus.OK)
|
||||
public me(@Request() request: Request) {
|
||||
return this.service.me(request.headers['authorization']);
|
||||
@@ -95,14 +95,6 @@ export class AuthController {
|
||||
return this.service.update(request.user, userDto);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@Delete('me')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@HttpCode(HttpStatus.OK)
|
||||
public async delete(@Request() request) {
|
||||
return this.service.softDelete(request.user);
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: 'Erstellt Registrierungstoken',
|
||||
description:
|
||||
|
||||
291
myteamwallet_backend/src/auth/auth.service.spec.ts
Normal file
291
myteamwallet_backend/src/auth/auth.service.spec.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { AuthProvidersEnum } from './auth-providers.enum';
|
||||
import { AuthService } from './auth.service';
|
||||
import { RoleEnum } from '../roles/roles.enum';
|
||||
import { StatusEnum } from '../statuses/statuses.enum';
|
||||
|
||||
describe('AuthService inactive-user enforcement and safe logging', () => {
|
||||
let jwtService: any;
|
||||
let usersService: any;
|
||||
let logger: any;
|
||||
let dataSource: any;
|
||||
let confirmationUser: any;
|
||||
let lockedUserQuery: any;
|
||||
let userRepository: any;
|
||||
let service: AuthService;
|
||||
let mailService: any;
|
||||
|
||||
beforeEach(() => {
|
||||
jwtService = {
|
||||
sign: jest.fn(() => 'signed-token'),
|
||||
verify: jest.fn(),
|
||||
decode: jest.fn(),
|
||||
};
|
||||
usersService = {
|
||||
findOne: jest.fn(),
|
||||
update: jest.fn(),
|
||||
create: jest.fn(),
|
||||
linkPlayerToUserId: jest.fn(),
|
||||
};
|
||||
logger = { info: jest.fn(), debug: jest.fn() };
|
||||
mailService = { userSignUp: jest.fn() };
|
||||
confirmationUser = user(StatusEnum.inactive);
|
||||
confirmationUser.hash = 'confirmation-hash';
|
||||
lockedUserQuery = {
|
||||
where: jest.fn().mockReturnThis(),
|
||||
setLock: jest.fn().mockReturnThis(),
|
||||
getOne: jest.fn(() => confirmationUser),
|
||||
};
|
||||
userRepository = {
|
||||
createQueryBuilder: jest.fn(() => lockedUserQuery),
|
||||
save: jest.fn((value) => Promise.resolve(value)),
|
||||
};
|
||||
const manager = { getRepository: jest.fn(() => userRepository) };
|
||||
dataSource = {
|
||||
transaction: jest.fn((work) => work(manager)),
|
||||
};
|
||||
service = new AuthService(
|
||||
jwtService,
|
||||
usersService,
|
||||
{} as any,
|
||||
mailService,
|
||||
logger,
|
||||
dataSource,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects password login for an inactive user before issuing a token', async () => {
|
||||
usersService.findOne.mockResolvedValue(user(StatusEnum.inactive));
|
||||
|
||||
await expect(
|
||||
service.validateLogin({
|
||||
email: 'inactive@example.com',
|
||||
password: 'password',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
|
||||
expect(jwtService.sign).not.toHaveBeenCalled();
|
||||
expect(logger.info).toHaveBeenCalledWith({
|
||||
event: 'user_login_fail',
|
||||
details: 'userId=2 reason=inactive',
|
||||
userId: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects social login when the existing account is inactive', async () => {
|
||||
const inactive = socialUser(
|
||||
AuthProvidersEnum.google,
|
||||
RoleEnum.user,
|
||||
StatusEnum.inactive,
|
||||
);
|
||||
configureSocialQueries([inactive], inactive);
|
||||
|
||||
await expect(
|
||||
service.validateSocialLogin(AuthProvidersEnum.google, {
|
||||
id: 'social-id',
|
||||
email: inactive.email,
|
||||
firstName: 'Inactive',
|
||||
lastName: 'User',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
|
||||
expect(usersService.update).not.toHaveBeenCalled();
|
||||
expect(jwtService.sign).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reloads and rejects a concurrently deactivated social user instead of signing stale state', async () => {
|
||||
const stale = socialUser(
|
||||
AuthProvidersEnum.google,
|
||||
RoleEnum.admin,
|
||||
StatusEnum.active,
|
||||
);
|
||||
const current = socialUser(
|
||||
AuthProvidersEnum.google,
|
||||
RoleEnum.user,
|
||||
StatusEnum.inactive,
|
||||
);
|
||||
configureSocialQueries([stale], current);
|
||||
|
||||
await expect(
|
||||
service.validateSocialLogin(AuthProvidersEnum.google, {
|
||||
id: stale.socialId,
|
||||
email: 'updated@example.com',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
|
||||
expect(dataSource.transaction).toHaveBeenCalledTimes(1);
|
||||
expect(userRepository.save).not.toHaveBeenCalledWith(stale);
|
||||
expect(usersService.update).not.toHaveBeenCalled();
|
||||
expect(jwtService.sign).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
AuthProvidersEnum.facebook,
|
||||
AuthProvidersEnum.google,
|
||||
AuthProvidersEnum.twitter,
|
||||
AuthProvidersEnum.apple,
|
||||
])(
|
||||
'locks and reloads an existing %s user, narrowly updates email, and signs the current role',
|
||||
async (provider) => {
|
||||
const stale = socialUser(provider, RoleEnum.admin, StatusEnum.active);
|
||||
const current = socialUser(provider, RoleEnum.user, StatusEnum.active);
|
||||
configureSocialQueries([stale], current);
|
||||
|
||||
const result = await service.validateSocialLogin(provider, {
|
||||
id: stale.socialId,
|
||||
email: 'updated@example.com',
|
||||
});
|
||||
|
||||
expect(userRepository.update).toHaveBeenCalledWith(stale.id, {
|
||||
email: 'updated@example.com',
|
||||
});
|
||||
expect(userRepository.save).not.toHaveBeenCalledWith(stale);
|
||||
expect(jwtService.sign).toHaveBeenCalledWith({
|
||||
id: current.id,
|
||||
role: current.role,
|
||||
});
|
||||
expect(result.user).toBe(current);
|
||||
},
|
||||
);
|
||||
|
||||
it('never includes an email in an unknown-user login audit event', async () => {
|
||||
usersService.findOne.mockResolvedValue(undefined);
|
||||
|
||||
await expect(
|
||||
service.validateLogin({
|
||||
email: 'secret@example.com',
|
||||
password: 'secret-password',
|
||||
}),
|
||||
).rejects.toBeDefined();
|
||||
|
||||
expect(logger.info).toHaveBeenCalledWith({
|
||||
event: 'user_login_fail',
|
||||
details: 'reason=user_not_found',
|
||||
userId: -1,
|
||||
});
|
||||
expect(JSON.stringify(logger.info.mock.calls)).not.toContain(
|
||||
'secret@example.com',
|
||||
);
|
||||
expect(JSON.stringify(logger.info.mock.calls)).not.toContain(
|
||||
'secret-password',
|
||||
);
|
||||
});
|
||||
|
||||
it('never includes a rejected invite token in logging details', async () => {
|
||||
jwtService.verify.mockImplementation(() => {
|
||||
throw new Error('invalid');
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.getTeamFromInvite('secret-token'),
|
||||
).rejects.toBeDefined();
|
||||
|
||||
expect(logger.info).toHaveBeenCalledWith({
|
||||
event: 'user_invite_link_validate_fail',
|
||||
details: 'invitation validation failed',
|
||||
userId: 0,
|
||||
});
|
||||
expect(JSON.stringify(logger.info.mock.calls)).not.toContain(
|
||||
'secret-token',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not refresh an inactive user through the me endpoint flow', async () => {
|
||||
jwtService.verify.mockReturnValue({ id: 2 });
|
||||
usersService.findOne.mockResolvedValue(user(StatusEnum.inactive));
|
||||
|
||||
await expect(service.me('Bearer existing-token')).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
expect(jwtService.sign).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('serializes email confirmation on the user row and consumes the hash', async () => {
|
||||
await service.confirmEmail('confirmation-hash');
|
||||
|
||||
expect(dataSource.transaction).toHaveBeenCalledTimes(1);
|
||||
expect(lockedUserQuery.setLock).toHaveBeenCalledWith(
|
||||
'pessimistic_write',
|
||||
undefined,
|
||||
['user'],
|
||||
);
|
||||
expect(confirmationUser.status).toEqual({ id: StatusEnum.active });
|
||||
expect(confirmationUser.hash).toBeNull();
|
||||
expect(userRepository.save).toHaveBeenCalledWith(confirmationUser);
|
||||
});
|
||||
|
||||
it('ignores a public registration player id and never mutates player ownership', async () => {
|
||||
usersService.create.mockResolvedValue({
|
||||
id: 8,
|
||||
email: 'new@example.com',
|
||||
});
|
||||
|
||||
await service.register({
|
||||
email: 'new@example.com',
|
||||
password: 'password',
|
||||
firstName: 'New',
|
||||
lastName: 'User',
|
||||
linkPlayerId: 101,
|
||||
} as any);
|
||||
|
||||
expect(usersService.create.mock.calls[0][0]).not.toHaveProperty(
|
||||
'linkPlayerId',
|
||||
);
|
||||
expect(usersService.linkPlayerToUserId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
function user(statusId: StatusEnum) {
|
||||
return {
|
||||
id: 2,
|
||||
email: 'inactive@example.com',
|
||||
password: 'password-hash',
|
||||
provider: AuthProvidersEnum.email,
|
||||
role: { id: RoleEnum.user, name: 'User' },
|
||||
status: {
|
||||
id: statusId,
|
||||
name: statusId === StatusEnum.active ? 'Active' : 'Inactive',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function socialUser(
|
||||
provider: AuthProvidersEnum,
|
||||
roleId: RoleEnum,
|
||||
statusId: StatusEnum,
|
||||
) {
|
||||
return {
|
||||
...user(statusId),
|
||||
email: 'old@example.com',
|
||||
socialId: `${provider}-id`,
|
||||
provider,
|
||||
role: { id: roleId, name: roleId === RoleEnum.admin ? 'Admin' : 'User' },
|
||||
hash: 'stale-hash',
|
||||
};
|
||||
}
|
||||
|
||||
function configureSocialQueries(candidates: any[], current: any) {
|
||||
const candidateQuery = chain({ getMany: jest.fn(() => candidates) });
|
||||
const reloadQuery = chain({ getOne: jest.fn(() => current) });
|
||||
userRepository.createQueryBuilder = jest.fn((alias: string) =>
|
||||
alias === 'socialCandidate' ? candidateQuery : reloadQuery,
|
||||
);
|
||||
userRepository.update = jest.fn();
|
||||
}
|
||||
|
||||
function chain(overrides: Record<string, jest.Mock>) {
|
||||
const query: Record<string, jest.Mock> = {};
|
||||
[
|
||||
'leftJoinAndSelect',
|
||||
'where',
|
||||
'orWhere',
|
||||
'andWhere',
|
||||
'setParameter',
|
||||
'setParameters',
|
||||
'setLock',
|
||||
'orderBy',
|
||||
].forEach((method) => {
|
||||
query[method] = jest.fn(() => query);
|
||||
});
|
||||
return Object.assign(query, overrides);
|
||||
}
|
||||
});
|
||||
@@ -1,4 +1,10 @@
|
||||
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
ForbiddenException,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { User } from '../users/entities/user.entity';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
@@ -19,6 +25,7 @@ import { ForgotService } from 'src/forgot/forgot.service';
|
||||
import { MailService } from 'src/mail/mail.service';
|
||||
import { CreateInviteDTO } from './dto/create-invite.dto';
|
||||
import { LoggingService } from 'src/database/logging/logging.service';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
@@ -28,6 +35,7 @@ export class AuthService {
|
||||
private forgotService: ForgotService,
|
||||
private mailService: MailService,
|
||||
private logger: LoggingService,
|
||||
private dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
async validateLogin(
|
||||
@@ -40,7 +48,7 @@ export class AuthService {
|
||||
if (!user) {
|
||||
await this.logger.info({
|
||||
event: 'user_login_fail',
|
||||
details: `mail not found: ${loginDto.email}`,
|
||||
details: 'reason=user_not_found',
|
||||
userId: -1,
|
||||
});
|
||||
throw new HttpException(
|
||||
@@ -54,6 +62,8 @@ export class AuthService {
|
||||
);
|
||||
}
|
||||
|
||||
await this.assertActiveUser(user);
|
||||
|
||||
if (user.provider !== AuthProvidersEnum.email) {
|
||||
throw new HttpException(
|
||||
{
|
||||
@@ -79,7 +89,7 @@ export class AuthService {
|
||||
|
||||
await this.logger.info({
|
||||
event: 'user_login_success',
|
||||
details: `logged in: ${loginDto.email}`,
|
||||
details: `userId=${user.id}`,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
@@ -87,7 +97,7 @@ export class AuthService {
|
||||
} else {
|
||||
await this.logger.info({
|
||||
event: 'user_login_fail',
|
||||
details: `incorrect password for user: ${loginDto.email}`,
|
||||
details: `userId=${user.id} reason=incorrect_password`,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
@@ -107,57 +117,68 @@ export class AuthService {
|
||||
authProvider: string,
|
||||
socialData: SocialInterface,
|
||||
): Promise<{ token: string; user: User }> {
|
||||
let user: User;
|
||||
const socialEmail = socialData.email?.toLowerCase();
|
||||
|
||||
const userByEmail = await this.usersService.findOne({
|
||||
email: socialEmail,
|
||||
});
|
||||
|
||||
user = await this.usersService.findOne({
|
||||
socialId: socialData.id,
|
||||
provider: authProvider,
|
||||
});
|
||||
|
||||
if (user) {
|
||||
if (socialEmail && !userByEmail) {
|
||||
user.email = socialEmail;
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const repository = manager.getRepository(User);
|
||||
const candidateQuery = repository
|
||||
.createQueryBuilder('socialCandidate')
|
||||
.where(
|
||||
'socialCandidate.socialId = :socialId AND socialCandidate.provider = :authProvider',
|
||||
{ socialId: socialData.id, authProvider },
|
||||
);
|
||||
if (socialEmail) {
|
||||
candidateQuery.orWhere('socialCandidate.email = :socialEmail', {
|
||||
socialEmail,
|
||||
});
|
||||
}
|
||||
await this.usersService.update(user.id, user);
|
||||
} else if (userByEmail) {
|
||||
user = userByEmail;
|
||||
} else {
|
||||
const role = plainToClass(Role, {
|
||||
id: RoleEnum.user,
|
||||
const candidates = await candidateQuery
|
||||
.setLock('pessimistic_write', undefined, ['socialCandidate'])
|
||||
.orderBy('socialCandidate.id', 'ASC')
|
||||
.getMany();
|
||||
const socialUser = candidates.find(
|
||||
(candidate) =>
|
||||
candidate.socialId === socialData.id &&
|
||||
candidate.provider === authProvider,
|
||||
);
|
||||
const emailUser = socialEmail
|
||||
? candidates.find((candidate) => candidate.email === socialEmail)
|
||||
: undefined;
|
||||
let user = socialUser ?? emailUser;
|
||||
if (!user) {
|
||||
user = await repository.save(
|
||||
repository.create({
|
||||
email: socialEmail,
|
||||
firstName: socialData.firstName,
|
||||
lastName: socialData.lastName,
|
||||
socialId: socialData.id,
|
||||
provider: authProvider,
|
||||
role: { id: RoleEnum.user } as Role,
|
||||
status: { id: StatusEnum.active } as Status,
|
||||
}),
|
||||
);
|
||||
} else if (
|
||||
socialUser &&
|
||||
socialEmail &&
|
||||
!emailUser &&
|
||||
socialUser.email !== socialEmail
|
||||
) {
|
||||
await repository.update(socialUser.id, { email: socialEmail });
|
||||
}
|
||||
const currentUser = await repository
|
||||
.createQueryBuilder('currentSocialUser')
|
||||
.leftJoinAndSelect('currentSocialUser.role', 'role')
|
||||
.leftJoinAndSelect('currentSocialUser.status', 'status')
|
||||
.where('currentSocialUser.id = :userId', { userId: user.id })
|
||||
.setLock('pessimistic_write', undefined, ['currentSocialUser'])
|
||||
.getOne();
|
||||
if (!currentUser) throw new UnauthorizedException();
|
||||
await this.assertActiveUser(currentUser);
|
||||
const token = await this.jwtService.sign({
|
||||
id: currentUser.id,
|
||||
role: currentUser.role,
|
||||
});
|
||||
const status = plainToClass(Status, {
|
||||
id: StatusEnum.active,
|
||||
});
|
||||
|
||||
user = await this.usersService.create({
|
||||
email: socialEmail,
|
||||
firstName: socialData.firstName,
|
||||
lastName: socialData.lastName,
|
||||
socialId: socialData.id,
|
||||
provider: authProvider,
|
||||
role,
|
||||
status,
|
||||
});
|
||||
|
||||
user = await this.usersService.findOne({
|
||||
id: user.id,
|
||||
});
|
||||
}
|
||||
|
||||
const jwtToken = await this.jwtService.sign({
|
||||
id: user.id,
|
||||
role: user.role,
|
||||
return { token, user: currentUser };
|
||||
});
|
||||
|
||||
return {
|
||||
token: jwtToken,
|
||||
user,
|
||||
};
|
||||
}
|
||||
|
||||
async register(dto: AuthRegisterLoginDto): Promise<void> {
|
||||
@@ -167,8 +188,10 @@ export class AuthService {
|
||||
.digest('hex');
|
||||
|
||||
const user = await this.usersService.create({
|
||||
...dto,
|
||||
email: dto.email,
|
||||
password: dto.password,
|
||||
firstName: dto.firstName,
|
||||
lastName: dto.lastName,
|
||||
role: {
|
||||
id: RoleEnum.user,
|
||||
} as Role,
|
||||
@@ -178,13 +201,9 @@ export class AuthService {
|
||||
hash,
|
||||
});
|
||||
|
||||
if (user && dto.linkPlayerId != null) {
|
||||
await this.usersService.linkPlayerToUserId(user, dto.linkPlayerId);
|
||||
}
|
||||
|
||||
await this.logger.info({
|
||||
event: 'user_create',
|
||||
details: `user created with mail: ${dto.email}`,
|
||||
details: `userId=${user.id}`,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
@@ -198,25 +217,26 @@ export class AuthService {
|
||||
}
|
||||
|
||||
async confirmEmail(hash: string): Promise<void> {
|
||||
const user = await this.usersService.findOne({
|
||||
hash,
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const repository = manager.getRepository(User);
|
||||
const user = await repository
|
||||
.createQueryBuilder('user')
|
||||
.where('user.hash = :hash', { hash })
|
||||
.setLock('pessimistic_write', undefined, ['user'])
|
||||
.getOne();
|
||||
if (!user) {
|
||||
throw new HttpException(
|
||||
{
|
||||
status: HttpStatus.NOT_FOUND,
|
||||
error: `notFound`,
|
||||
},
|
||||
HttpStatus.NOT_FOUND,
|
||||
);
|
||||
}
|
||||
user.hash = null;
|
||||
user.status = plainToClass(Status, { id: StatusEnum.active });
|
||||
await repository.save(user);
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new HttpException(
|
||||
{
|
||||
status: HttpStatus.NOT_FOUND,
|
||||
error: `notFound`,
|
||||
},
|
||||
HttpStatus.NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
user.hash = null;
|
||||
user.status = plainToClass(Status, {
|
||||
id: StatusEnum.active,
|
||||
});
|
||||
await user.save();
|
||||
}
|
||||
|
||||
async forgotPassword(email: string): Promise<void> {
|
||||
@@ -281,40 +301,31 @@ export class AuthService {
|
||||
|
||||
async me(token: string): Promise<User> {
|
||||
token = token.replace('Bearer ', '');
|
||||
let role: any;
|
||||
let payload: any;
|
||||
let refreshToken = false;
|
||||
try {
|
||||
role = this.jwtService.verify(token);
|
||||
|
||||
const u = await this.usersService.findOne({
|
||||
id: role.id,
|
||||
});
|
||||
|
||||
await this.logger.debug({
|
||||
event: 'user_token_verification_success',
|
||||
details: `Email: ${u.email}`,
|
||||
userId: u.id,
|
||||
});
|
||||
|
||||
return u;
|
||||
} catch (error) {
|
||||
const role = this.jwtService.decode(token);
|
||||
|
||||
const user = await this.usersService.findOne({
|
||||
id: (role as any).id,
|
||||
});
|
||||
payload = this.jwtService.verify(token);
|
||||
} catch {
|
||||
payload = this.jwtService.decode(token);
|
||||
refreshToken = true;
|
||||
}
|
||||
if (!payload?.id) throw new UnauthorizedException();
|
||||
const user = await this.usersService.findOne({ id: payload.id });
|
||||
if (!user) throw new UnauthorizedException();
|
||||
await this.assertActiveUser(user);
|
||||
if (refreshToken) {
|
||||
const t = await this.jwtService.sign({
|
||||
id: user.id,
|
||||
role: user.role,
|
||||
});
|
||||
|
||||
user['token'] = t;
|
||||
await this.logger.debug({
|
||||
event: 'user_token_verification_success',
|
||||
details: `Email: ${user.email}`,
|
||||
userId: user.id,
|
||||
});
|
||||
return user;
|
||||
}
|
||||
await this.logger.debug({
|
||||
event: 'user_token_verification_success',
|
||||
details: `userId=${user.id}`,
|
||||
userId: user.id,
|
||||
});
|
||||
return user;
|
||||
}
|
||||
|
||||
async update(user: User, userDto: AuthUpdateDto): Promise<User> {
|
||||
@@ -360,10 +371,6 @@ export class AuthService {
|
||||
});
|
||||
}
|
||||
|
||||
async softDelete(user: User): Promise<void> {
|
||||
await this.usersService.softDelete(user.id);
|
||||
}
|
||||
|
||||
async createTeamInvite(object: CreateInviteDTO) {
|
||||
const token = await this.jwtService.sign(object, {
|
||||
expiresIn: '30d',
|
||||
@@ -394,7 +401,7 @@ export class AuthService {
|
||||
} catch {
|
||||
await this.logger.info({
|
||||
event: 'user_invite_link_validate_fail',
|
||||
details: `validation failed for token ${token}`,
|
||||
details: 'invitation validation failed',
|
||||
userId: 0,
|
||||
});
|
||||
|
||||
@@ -404,4 +411,14 @@ export class AuthService {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async assertActiveUser(user: User): Promise<void> {
|
||||
if (user.status?.id === StatusEnum.active) return;
|
||||
await this.logger.info({
|
||||
event: 'user_login_fail',
|
||||
details: `userId=${user.id} reason=inactive`,
|
||||
userId: user.id,
|
||||
});
|
||||
throw new ForbiddenException('User account is inactive');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,4 @@ export class AuthRegisterLoginDto {
|
||||
@ApiProperty({ example: 'Doe' })
|
||||
@IsNotEmpty()
|
||||
lastName: string;
|
||||
|
||||
@ApiProperty({ example: 27 })
|
||||
linkPlayerId: number | null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
import { RoleEnum } from '../../roles/roles.enum';
|
||||
import { StatusEnum } from '../../statuses/statuses.enum';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
|
||||
describe('JwtStrategy', () => {
|
||||
const jwtService = {} as any;
|
||||
const configService = { get: jest.fn(() => 'secret') } as any;
|
||||
let usersService: any;
|
||||
let strategy: JwtStrategy;
|
||||
|
||||
beforeEach(() => {
|
||||
usersService = { findOne: jest.fn() };
|
||||
strategy = new JwtStrategy(jwtService, configService, usersService);
|
||||
});
|
||||
|
||||
it('reloads the current database user and replaces a stale token role', async () => {
|
||||
usersService.findOne.mockResolvedValue({
|
||||
id: 2,
|
||||
role: { id: RoleEnum.user, name: 'User' },
|
||||
status: { id: StatusEnum.active, name: 'Active' },
|
||||
password: 'must-not-be-exposed',
|
||||
});
|
||||
|
||||
const result = await strategy.validate({
|
||||
id: 2,
|
||||
role: { id: RoleEnum.admin },
|
||||
iat: 1,
|
||||
exp: 2,
|
||||
} as any);
|
||||
|
||||
expect(usersService.findOne).toHaveBeenCalledWith({ id: 2 });
|
||||
expect(result).toEqual({
|
||||
id: 2,
|
||||
role: { id: RoleEnum.user, name: 'User' },
|
||||
status: { id: StatusEnum.active, name: 'Active' },
|
||||
});
|
||||
expect(result).not.toHaveProperty('password');
|
||||
});
|
||||
|
||||
it('rejects a missing or soft-deleted database user', async () => {
|
||||
usersService.findOne.mockResolvedValue(undefined);
|
||||
|
||||
await expect(
|
||||
strategy.validate({ id: 2, iat: 1, exp: 2 } as any),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('rejects an inactive current database user', async () => {
|
||||
usersService.findOne.mockResolvedValue({
|
||||
id: 2,
|
||||
role: { id: RoleEnum.user },
|
||||
status: { id: StatusEnum.inactive },
|
||||
});
|
||||
|
||||
await expect(
|
||||
strategy.validate({ id: 2, iat: 1, exp: 2 } as any),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('rejects a payload without an id without querying the database', async () => {
|
||||
await expect(
|
||||
strategy.validate({ iat: 1, exp: 2 } as any),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
expect(usersService.findOne).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,8 @@ import { JwtService } from '@nestjs/jwt';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { User } from '../../users/entities/user.entity';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { UsersService } from '../../users/users.service';
|
||||
import { StatusEnum } from '../../statuses/statuses.enum';
|
||||
|
||||
type JwtPayload = Pick<User, 'id' | 'role'> & { iat: number; exp: number };
|
||||
|
||||
@@ -12,6 +14,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(
|
||||
private jwtService: JwtService,
|
||||
private configService: ConfigService,
|
||||
private usersService: UsersService,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
@@ -19,10 +22,18 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
});
|
||||
}
|
||||
|
||||
public validate(payload: JwtPayload) {
|
||||
public async validate(payload: JwtPayload) {
|
||||
if (!payload.id) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
return payload;
|
||||
const user = await this.usersService.findOne({ id: payload.id });
|
||||
if (!user || user.status?.id !== StatusEnum.active) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
return {
|
||||
id: user.id,
|
||||
role: user.role ?? null,
|
||||
status: user.status ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { LoggingService } from './logging.service';
|
||||
|
||||
describe('LoggingService', () => {
|
||||
let service: LoggingService;
|
||||
it('can persist an event through the caller transaction manager', async () => {
|
||||
const defaultRepository = { save: jest.fn() };
|
||||
const transactionRepository = { save: jest.fn() };
|
||||
const manager = {
|
||||
getRepository: jest.fn(() => transactionRepository),
|
||||
} as any;
|
||||
const service = new LoggingService(defaultRepository as any);
|
||||
const event = {
|
||||
event: 'admin_user_profile_update' as const,
|
||||
details: 'targetUserId=2',
|
||||
userId: 1,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [LoggingService],
|
||||
}).compile();
|
||||
await service.info(event, manager);
|
||||
|
||||
service = module.get<LoggingService>(LoggingService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
expect(transactionRepository.save).toHaveBeenCalledWith({
|
||||
...event,
|
||||
level: 'INFO',
|
||||
});
|
||||
expect(defaultRepository.save).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { EntityManager, Repository } from 'typeorm';
|
||||
import { CreateLogDTO } from './dto/create-log.dto';
|
||||
import { LogEntry } from './entities/log-entry.entity';
|
||||
import { LOGEVENT } from './model/logging-event.type';
|
||||
@@ -23,22 +23,25 @@ export class LoggingService {
|
||||
});
|
||||
}
|
||||
|
||||
async info({
|
||||
event,
|
||||
details,
|
||||
userId,
|
||||
}: {
|
||||
event: LOGEVENT;
|
||||
details: string;
|
||||
userId: number;
|
||||
}) {
|
||||
async info(
|
||||
{
|
||||
event,
|
||||
details,
|
||||
userId,
|
||||
}: {
|
||||
event: LOGEVENT;
|
||||
details: string;
|
||||
userId: number;
|
||||
},
|
||||
manager?: EntityManager,
|
||||
) {
|
||||
const e: CreateLogDTO = {
|
||||
event,
|
||||
details,
|
||||
userId,
|
||||
level: 'INFO',
|
||||
};
|
||||
await this.repository.save(e);
|
||||
await (manager?.getRepository(LogEntry) ?? this.repository).save(e);
|
||||
}
|
||||
|
||||
async error({
|
||||
|
||||
@@ -15,6 +15,11 @@ export type LOGEVENT =
|
||||
| 'transaction_create_fail'
|
||||
| 'transaction_reverse'
|
||||
| 'player_creation'
|
||||
| 'admin_user_profile_update'
|
||||
| 'admin_user_role_update'
|
||||
| 'admin_user_status_update'
|
||||
| 'admin_player_assign'
|
||||
| 'admin_player_unlink'
|
||||
| 'team_create';
|
||||
|
||||
export type LOGLEVEL = 'FATAL' | 'ERROR' | 'WARN' | 'INFO' | 'DEBUG' | 'TRACE';
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddPlayerLookupIndexes1785517200000 implements MigrationInterface {
|
||||
name = 'AddPlayerLookupIndexes1785517200000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
'CREATE INDEX "IDX_player_team_id" ON "player" ("teamId")',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'CREATE INDEX "IDX_player_user_id" ON "player" ("userId")',
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query('DROP INDEX "IDX_player_user_id"');
|
||||
await queryRunner.query('DROP INDEX "IDX_player_team_id"');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { getMetadataArgsStorage } from 'typeorm';
|
||||
import { Player } from '../../players/entities/player.entity';
|
||||
import { AddPlayerLookupIndexes1785517200000 } from './1785517200000-AddPlayerLookupIndexes';
|
||||
|
||||
describe('AddPlayerLookupIndexes1785517200000', () => {
|
||||
it('adds reversible indexes for team and user foreign-key lookups', async () => {
|
||||
const queryRunner = { query: jest.fn() } as any;
|
||||
const migration = new AddPlayerLookupIndexes1785517200000();
|
||||
|
||||
await migration.up(queryRunner);
|
||||
expect(queryRunner.query.mock.calls.map(([sql]) => sql)).toEqual([
|
||||
'CREATE INDEX "IDX_player_team_id" ON "player" ("teamId")',
|
||||
'CREATE INDEX "IDX_player_user_id" ON "player" ("userId")',
|
||||
]);
|
||||
|
||||
queryRunner.query.mockClear();
|
||||
await migration.down(queryRunner);
|
||||
expect(queryRunner.query.mock.calls.map(([sql]) => sql)).toEqual([
|
||||
'DROP INDEX "IDX_player_user_id"',
|
||||
'DROP INDEX "IDX_player_team_id"',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps entity index metadata aligned with the migration', () => {
|
||||
const playerIndexes = getMetadataArgsStorage()
|
||||
.indices.filter((index) => index.target === Player)
|
||||
.map((index) => index.name);
|
||||
|
||||
expect(playerIndexes).toEqual(
|
||||
expect.arrayContaining(['IDX_player_team_id', 'IDX_player_user_id']),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -3,9 +3,11 @@ import {
|
||||
BeforeInsert,
|
||||
Column,
|
||||
Entity,
|
||||
Index,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
PrimaryGeneratedColumn,
|
||||
RelationId,
|
||||
} from 'typeorm';
|
||||
import { EntityHelper } from 'src/utils/entity-helper';
|
||||
import { TeamRole } from 'src/team-roles/entities/team-roles.entity';
|
||||
@@ -29,6 +31,7 @@ export class Player extends EntityHelper {
|
||||
})
|
||||
teamRole?: TeamRole | null;
|
||||
|
||||
@Index('IDX_player_team_id')
|
||||
@ManyToOne(() => Team, {
|
||||
eager: true,
|
||||
})
|
||||
@@ -37,11 +40,15 @@ export class Player extends EntityHelper {
|
||||
@Column({ type: 'decimal', precision: 10, scale: 2, default: 0 })
|
||||
balance: number;
|
||||
|
||||
@Index('IDX_player_user_id')
|
||||
@ManyToOne(() => User, (user) => user.players, {
|
||||
eager: true,
|
||||
})
|
||||
user?: User | null;
|
||||
|
||||
@RelationId((player: Player) => player.user)
|
||||
userId?: number | null;
|
||||
|
||||
@OneToMany(() => Transaction, (transaction) => transaction.player)
|
||||
transactions: Transaction[];
|
||||
|
||||
|
||||
145
myteamwallet_backend/src/users/admin-users.controller.spec.ts
Normal file
145
myteamwallet_backend/src/users/admin-users.controller.spec.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { GUARDS_METADATA, PATH_METADATA } from '@nestjs/common/constants';
|
||||
import { validate } from 'class-validator';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { RoleEnum } from '../roles/roles.enum';
|
||||
import { RolesGuard } from '../roles/roles.guard';
|
||||
import { AdminUsersController } from './admin-users.controller';
|
||||
import {
|
||||
AdminPlayerQueryDto,
|
||||
AdminUserProfileDto,
|
||||
AdminUserRoleDto,
|
||||
AdminUserStatusDto,
|
||||
} from './dto/admin-user.dto';
|
||||
|
||||
describe('AdminUsersController', () => {
|
||||
const service = {
|
||||
updateProfile: jest.fn(),
|
||||
updateRole: jest.fn(),
|
||||
updateStatus: jest.fn(),
|
||||
findPlayers: jest.fn(),
|
||||
assignPlayer: jest.fn(),
|
||||
unlinkPlayer: jest.fn(),
|
||||
};
|
||||
const controller = new AdminUsersController(service as any);
|
||||
const actor = { user: { id: 7, role: { id: RoleEnum.admin } } };
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('uses a separate versioned admin/users controller guarded by the global admin role', () => {
|
||||
expect(Reflect.getMetadata(PATH_METADATA, AdminUsersController)).toBe(
|
||||
'admin/users',
|
||||
);
|
||||
expect(Reflect.getMetadata('roles', AdminUsersController)).toEqual([
|
||||
RoleEnum.admin,
|
||||
]);
|
||||
expect(
|
||||
Reflect.getMetadata(GUARDS_METADATA, AdminUsersController),
|
||||
).toContain(RolesGuard);
|
||||
});
|
||||
|
||||
it('passes the authenticated actor and target to profile, role, and status mutations', async () => {
|
||||
await controller.updateProfile(actor as any, 2, {
|
||||
firstName: 'New',
|
||||
lastName: 'Name',
|
||||
});
|
||||
await controller.updateRole(actor as any, 2, { role: RoleEnum.user });
|
||||
await controller.updateStatus(actor as any, 2, {
|
||||
status: 2,
|
||||
});
|
||||
|
||||
expect(service.updateProfile).toHaveBeenCalledWith(7, 2, {
|
||||
firstName: 'New',
|
||||
lastName: 'Name',
|
||||
});
|
||||
expect(service.updateRole).toHaveBeenCalledWith(7, 2, RoleEnum.user);
|
||||
expect(service.updateStatus).toHaveBeenCalledWith(7, 2, 2);
|
||||
});
|
||||
|
||||
it('passes player search and atomic assignment operations to the service', async () => {
|
||||
const query = { assignment: 'unassigned' as const, page: 2, limit: 10 };
|
||||
|
||||
await controller.findPlayers(query);
|
||||
await controller.assignPlayer(actor as any, 3, 11);
|
||||
await controller.unlinkPlayer(actor as any, 3, 11);
|
||||
|
||||
expect(service.findPlayers).toHaveBeenCalledWith(query);
|
||||
expect(service.assignPlayer).toHaveBeenCalledWith(7, 3, 11);
|
||||
expect(service.unlinkPlayer).toHaveBeenCalledWith(7, 3, 11);
|
||||
});
|
||||
});
|
||||
|
||||
describe('admin user DTOs', () => {
|
||||
it('accepts omitted and string profile names but rejects explicit nulls', async () => {
|
||||
const omitted = plainToInstance(AdminUserProfileDto, {});
|
||||
const names = plainToInstance(AdminUserProfileDto, {
|
||||
firstName: 'Ada',
|
||||
lastName: 'Admin',
|
||||
});
|
||||
const nulls = plainToInstance(AdminUserProfileDto, {
|
||||
firstName: null,
|
||||
lastName: null,
|
||||
});
|
||||
|
||||
expect(await validate(omitted)).toEqual([]);
|
||||
expect(await validate(names)).toEqual([]);
|
||||
expect(await validate(nulls)).not.toEqual([]);
|
||||
});
|
||||
|
||||
it('accepts only profile names in the profile DTO', async () => {
|
||||
const dto = plainToInstance(AdminUserProfileDto, {
|
||||
firstName: 'Ada',
|
||||
lastName: 'Admin',
|
||||
email: 'must-not-pass@example.com',
|
||||
role: RoleEnum.user,
|
||||
status: 2,
|
||||
});
|
||||
|
||||
expect(await validate(dto, { whitelist: true })).toEqual([]);
|
||||
expect(dto).toEqual({ firstName: 'Ada', lastName: 'Admin' });
|
||||
});
|
||||
|
||||
it('rejects role and status ids outside their enums', async () => {
|
||||
const role = plainToInstance(AdminUserRoleDto, { role: 999 });
|
||||
const status = plainToInstance(AdminUserStatusDto, { status: 999 });
|
||||
|
||||
expect(await validate(role)).not.toEqual([]);
|
||||
expect(await validate(status)).not.toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects numeric-enum reverse-map names instead of sending strings to integer foreign keys', async () => {
|
||||
const role = plainToInstance(AdminUserRoleDto, { role: 'admin' });
|
||||
const status = plainToInstance(AdminUserStatusDto, { status: 'inactive' });
|
||||
|
||||
expect(await validate(role)).not.toEqual([]);
|
||||
expect(await validate(status)).not.toEqual([]);
|
||||
});
|
||||
|
||||
it.each([true, '1'])(
|
||||
'rejects non-number role and status bodies: %p',
|
||||
async (value) => {
|
||||
const role = plainToInstance(AdminUserRoleDto, { role: value });
|
||||
const status = plainToInstance(AdminUserStatusDto, { status: value });
|
||||
|
||||
expect(await validate(role)).not.toEqual([]);
|
||||
expect(await validate(status)).not.toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it('validates player assignment filters and pagination bounds', async () => {
|
||||
const valid = plainToInstance(AdminPlayerQueryDto, {
|
||||
assignment: 'assigned',
|
||||
teamId: '4',
|
||||
page: '2',
|
||||
limit: '50',
|
||||
});
|
||||
const invalid = plainToInstance(AdminPlayerQueryDto, {
|
||||
assignment: 'someone',
|
||||
page: 0,
|
||||
limit: 51,
|
||||
});
|
||||
|
||||
expect(await validate(valid)).toEqual([]);
|
||||
expect(valid).toMatchObject({ teamId: 4, page: 2, limit: 50 });
|
||||
expect(await validate(invalid)).not.toEqual([]);
|
||||
});
|
||||
});
|
||||
102
myteamwallet_backend/src/users/admin-users.controller.ts
Normal file
102
myteamwallet_backend/src/users/admin-users.controller.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Put,
|
||||
Query,
|
||||
Request,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Roles } from '../roles/roles.decorator';
|
||||
import { RoleEnum } from '../roles/roles.enum';
|
||||
import { RolesGuard } from '../roles/roles.guard';
|
||||
import { AdminUsersService } from './admin-users.service';
|
||||
import {
|
||||
AdminPlayerQueryDto,
|
||||
AdminUserProfileDto,
|
||||
AdminUserRoleDto,
|
||||
AdminUserStatusDto,
|
||||
} from './dto/admin-user.dto';
|
||||
|
||||
type AdminRequest = { user: { id: number; role: { id: RoleEnum } } };
|
||||
|
||||
@ApiBearerAuth()
|
||||
@ApiTags('Admin users')
|
||||
@UseGuards(AuthGuard('jwt'), RolesGuard)
|
||||
@Roles([RoleEnum.admin])
|
||||
@Controller({ path: 'admin/users', version: '1' })
|
||||
export class AdminUsersController {
|
||||
constructor(private readonly adminUsersService: AdminUsersService) {}
|
||||
|
||||
@Patch(':id/profile')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
updateProfile(
|
||||
@Request() request: AdminRequest,
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() dto: AdminUserProfileDto,
|
||||
) {
|
||||
return this.adminUsersService.updateProfile(request.user.id, id, dto);
|
||||
}
|
||||
|
||||
@Patch(':id/role')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
updateRole(
|
||||
@Request() request: AdminRequest,
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() dto: AdminUserRoleDto,
|
||||
) {
|
||||
return this.adminUsersService.updateRole(request.user.id, id, dto.role);
|
||||
}
|
||||
|
||||
@Patch(':id/status')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
updateStatus(
|
||||
@Request() request: AdminRequest,
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() dto: AdminUserStatusDto,
|
||||
) {
|
||||
return this.adminUsersService.updateStatus(request.user.id, id, dto.status);
|
||||
}
|
||||
|
||||
@Get('players')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
findPlayers(@Query() query: AdminPlayerQueryDto) {
|
||||
return this.adminUsersService.findPlayers(query);
|
||||
}
|
||||
|
||||
@Put(':userId/players/:playerId')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
assignPlayer(
|
||||
@Request() request: AdminRequest,
|
||||
@Param('userId', ParseIntPipe) userId: number,
|
||||
@Param('playerId', ParseIntPipe) playerId: number,
|
||||
) {
|
||||
return this.adminUsersService.assignPlayer(
|
||||
request.user.id,
|
||||
userId,
|
||||
playerId,
|
||||
);
|
||||
}
|
||||
|
||||
@Delete(':userId/players/:playerId')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
unlinkPlayer(
|
||||
@Request() request: AdminRequest,
|
||||
@Param('userId', ParseIntPipe) userId: number,
|
||||
@Param('playerId', ParseIntPipe) playerId: number,
|
||||
) {
|
||||
return this.adminUsersService.unlinkPlayer(
|
||||
request.user.id,
|
||||
userId,
|
||||
playerId,
|
||||
);
|
||||
}
|
||||
}
|
||||
173
myteamwallet_backend/src/users/admin-users.http.spec.ts
Normal file
173
myteamwallet_backend/src/users/admin-users.http.spec.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import {
|
||||
INestApplication,
|
||||
UnauthorizedException,
|
||||
ValidationPipe,
|
||||
VersioningType,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import * as request from 'supertest';
|
||||
import { useContainer } from 'class-validator';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { AuthController } from '../auth/auth.controller';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import { RoleEnum } from '../roles/roles.enum';
|
||||
import { RolesGuard } from '../roles/roles.guard';
|
||||
import validationOptions from '../utils/validation-options';
|
||||
import { IsNotExist } from '../utils/validators/is-not-exists.validator';
|
||||
import { AdminUsersController } from './admin-users.controller';
|
||||
import { AdminUsersService } from './admin-users.service';
|
||||
import { UsersController } from './users.controller';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
describe('admin user HTTP security boundary', () => {
|
||||
let app: INestApplication;
|
||||
const safeUser = {
|
||||
id: 2,
|
||||
firstName: 'Target',
|
||||
lastName: 'User',
|
||||
email: 'target@example.com',
|
||||
role: { id: RoleEnum.user, name: 'User' },
|
||||
status: { id: 1, name: 'Active' },
|
||||
assignments: [],
|
||||
};
|
||||
const adminService = {
|
||||
updateProfile: jest.fn(() => safeUser),
|
||||
updateRole: jest.fn(() => safeUser),
|
||||
updateStatus: jest.fn(() => safeUser),
|
||||
findPlayers: jest.fn(),
|
||||
assignPlayer: jest.fn(),
|
||||
unlinkPlayer: jest.fn(),
|
||||
};
|
||||
const usersService = {
|
||||
findDirectory: jest.fn(),
|
||||
findMyTeams: jest.fn(() => [
|
||||
{
|
||||
id: 11,
|
||||
firstName: 'Pat',
|
||||
lastName: 'Player',
|
||||
team: { id: 4, name: 'Alpha' },
|
||||
},
|
||||
]),
|
||||
};
|
||||
const authService = { register: jest.fn() };
|
||||
|
||||
beforeAll(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
controllers: [AdminUsersController, UsersController, AuthController],
|
||||
providers: [
|
||||
Reflector,
|
||||
RolesGuard,
|
||||
IsNotExist,
|
||||
{
|
||||
provide: DataSource,
|
||||
useValue: {
|
||||
getRepository: () => ({ findOne: jest.fn(() => undefined) }),
|
||||
},
|
||||
},
|
||||
{ provide: AdminUsersService, useValue: adminService },
|
||||
{ provide: UsersService, useValue: usersService },
|
||||
{ provide: AuthService, useValue: authService },
|
||||
],
|
||||
})
|
||||
.overrideGuard(AuthGuard('jwt'))
|
||||
.useValue({
|
||||
canActivate(context) {
|
||||
const httpRequest = context.switchToHttp().getRequest();
|
||||
const token = httpRequest.headers.authorization;
|
||||
if (token === 'Bearer admin') {
|
||||
httpRequest.user = { id: 1, role: { id: RoleEnum.admin } };
|
||||
return true;
|
||||
}
|
||||
if (token === 'Bearer user') {
|
||||
httpRequest.user = { id: 7, role: { id: RoleEnum.user } };
|
||||
return true;
|
||||
}
|
||||
throw new UnauthorizedException();
|
||||
},
|
||||
})
|
||||
.compile();
|
||||
app = module.createNestApplication();
|
||||
useContainer(app, { fallbackOnErrors: true });
|
||||
app.setGlobalPrefix('api');
|
||||
app.enableVersioning({ type: VersioningType.URI });
|
||||
app.useGlobalPipes(new ValidationPipe(validationOptions));
|
||||
await app.init();
|
||||
});
|
||||
|
||||
afterAll(() => app.close());
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('enforces JWT and current global admin role on an admin mutation', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.patch('/api/v1/admin/users/2/role')
|
||||
.send({ role: RoleEnum.user })
|
||||
.expect(401);
|
||||
await request(app.getHttpServer())
|
||||
.patch('/api/v1/admin/users/2/role')
|
||||
.set('Authorization', 'Bearer user')
|
||||
.send({ role: RoleEnum.user })
|
||||
.expect(403);
|
||||
await request(app.getHttpServer())
|
||||
.patch('/api/v1/admin/users/2/role')
|
||||
.set('Authorization', 'Bearer admin')
|
||||
.send({ role: RoleEnum.user })
|
||||
.expect(200, safeUser);
|
||||
});
|
||||
|
||||
it.each([true, '1'])(
|
||||
'rejects non-numeric role JSON at the HTTP validation boundary: %p',
|
||||
async (role) => {
|
||||
await request(app.getHttpServer())
|
||||
.patch('/api/v1/admin/users/2/role')
|
||||
.set('Authorization', 'Bearer admin')
|
||||
.send({ role })
|
||||
.expect(422);
|
||||
expect(adminService.updateRole).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('strips public player assignment input from registration', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v1/auth/email/register')
|
||||
.send({
|
||||
email: 'new@example.com',
|
||||
password: 'password',
|
||||
firstName: 'New',
|
||||
lastName: 'User',
|
||||
linkPlayerId: 101,
|
||||
})
|
||||
.expect(201);
|
||||
|
||||
expect(authService.register).toHaveBeenCalledWith({
|
||||
email: 'new@example.com',
|
||||
password: 'password',
|
||||
firstName: 'New',
|
||||
lastName: 'User',
|
||||
});
|
||||
});
|
||||
|
||||
it('restricts team bootstrap to self and serializes only the safe projection', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get('/api/v1/users/8/teams')
|
||||
.set('Authorization', 'Bearer user')
|
||||
.expect(403);
|
||||
const response = await request(app.getHttpServer())
|
||||
.get('/api/v1/users/7/teams')
|
||||
.set('Authorization', 'Bearer user')
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toEqual([
|
||||
{
|
||||
id: 11,
|
||||
firstName: 'Pat',
|
||||
lastName: 'Player',
|
||||
team: { id: 4, name: 'Alpha' },
|
||||
},
|
||||
]);
|
||||
expect(JSON.stringify(response.body)).not.toMatch(
|
||||
/provider|socialId|password|hash|balance|createdAt|updatedAt/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
330
myteamwallet_backend/src/users/admin-users.service.spec.ts
Normal file
330
myteamwallet_backend/src/users/admin-users.service.spec.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
import { ConflictException, ForbiddenException } from '@nestjs/common';
|
||||
import { RoleEnum } from '../roles/roles.enum';
|
||||
import { StatusEnum } from '../statuses/statuses.enum';
|
||||
import { AdminUsersService } from './admin-users.service';
|
||||
|
||||
describe('AdminUsersService', () => {
|
||||
const team = { id: 10, name: 'Alpha', alias: 'alpha' };
|
||||
const teamRole = { id: 1, name: 'Player' };
|
||||
const actorId = 1;
|
||||
let target: any;
|
||||
let activeAdmins: any[];
|
||||
let player: any;
|
||||
let playerRows: any[];
|
||||
let playerTotal: number;
|
||||
let lockQuery: any;
|
||||
let lockedUserQuery: any;
|
||||
let playerQuery: any;
|
||||
let lockedPlayerQuery: any;
|
||||
let userRepository: any;
|
||||
let playerRepository: any;
|
||||
let manager: any;
|
||||
let dataSource: any;
|
||||
let logger: any;
|
||||
let service: AdminUsersService;
|
||||
|
||||
beforeEach(() => {
|
||||
target = user(2, RoleEnum.user, StatusEnum.active);
|
||||
activeAdmins = [user(actorId, RoleEnum.admin, StatusEnum.active)];
|
||||
player = assignment(101, null);
|
||||
playerRows = [];
|
||||
playerTotal = 0;
|
||||
lockQuery = chain({ getMany: jest.fn(() => activeAdmins) });
|
||||
lockedUserQuery = chain({ getOne: jest.fn(() => target) });
|
||||
playerQuery = chain({
|
||||
getRawMany: jest.fn(() => playerRows),
|
||||
getCount: jest.fn(() => playerTotal),
|
||||
});
|
||||
lockedPlayerQuery = chain({ getOne: jest.fn(() => player) });
|
||||
userRepository = {
|
||||
createQueryBuilder: jest.fn((alias: string) =>
|
||||
alias === 'lockedUser' ? lockedUserQuery : lockQuery,
|
||||
),
|
||||
findOne: jest.fn(() => target),
|
||||
save: jest.fn((value) => Promise.resolve(value)),
|
||||
};
|
||||
playerRepository = {
|
||||
createQueryBuilder: jest.fn((alias: string) =>
|
||||
alias === 'lockedPlayer' ? lockedPlayerQuery : playerQuery,
|
||||
),
|
||||
findOne: jest.fn(() => player),
|
||||
save: jest.fn((value) => Promise.resolve(value)),
|
||||
};
|
||||
manager = {
|
||||
getRepository: jest.fn((entity) =>
|
||||
entity.name === 'User' ? userRepository : playerRepository,
|
||||
),
|
||||
};
|
||||
dataSource = {
|
||||
manager,
|
||||
transaction: jest.fn((work) => work(manager)),
|
||||
};
|
||||
logger = { info: jest.fn() };
|
||||
service = new AdminUsersService(dataSource, logger);
|
||||
});
|
||||
|
||||
it('updates only names and returns an explicit Task 1-compatible safe admin summary', async () => {
|
||||
target.players = [assignment(101, target)];
|
||||
|
||||
const result = await service.updateProfile(actorId, target.id, {
|
||||
firstName: 'New',
|
||||
lastName: 'Name',
|
||||
});
|
||||
|
||||
expect(userRepository.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ firstName: 'New', lastName: 'Name' }),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
id: 2,
|
||||
firstName: 'New',
|
||||
lastName: 'Name',
|
||||
email: 'user-2@example.com',
|
||||
role: { id: RoleEnum.user, name: 'User' },
|
||||
status: { id: StatusEnum.active, name: 'Active' },
|
||||
assignments: [playerSummary(101)],
|
||||
});
|
||||
expect(result).not.toHaveProperty('password');
|
||||
expect(result).not.toHaveProperty('hash');
|
||||
expect(result).not.toHaveProperty('socialId');
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
{
|
||||
event: 'admin_user_profile_update',
|
||||
details: 'targetUserId=2',
|
||||
userId: actorId,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
expect(lockedUserQuery.setLock).toHaveBeenCalledWith(
|
||||
'pessimistic_write',
|
||||
undefined,
|
||||
['lockedUser'],
|
||||
);
|
||||
expect(lockedUserQuery.leftJoinAndSelect).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('rejects self-demotion and self-deactivation before saving', async () => {
|
||||
target = user(actorId, RoleEnum.admin, StatusEnum.active);
|
||||
|
||||
await expect(
|
||||
service.updateRole(actorId, actorId, RoleEnum.user),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await expect(
|
||||
service.updateStatus(actorId, actorId, StatusEnum.inactive),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(userRepository.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('locks the active-admin set and rejects loss of the last active admin', async () => {
|
||||
target = user(2, RoleEnum.admin, StatusEnum.active);
|
||||
activeAdmins = [target];
|
||||
|
||||
await expect(
|
||||
service.updateRole(actorId, target.id, RoleEnum.user),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
|
||||
expect(lockQuery.setLock).toHaveBeenCalledWith(
|
||||
'pessimistic_write',
|
||||
undefined,
|
||||
['user'],
|
||||
);
|
||||
expect(dataSource.transaction).toHaveBeenCalledTimes(1);
|
||||
expect(userRepository.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows a locked role change when another active admin remains', async () => {
|
||||
target = user(2, RoleEnum.admin, StatusEnum.active);
|
||||
activeAdmins = [target, user(3, RoleEnum.admin, StatusEnum.active)];
|
||||
|
||||
const result = await service.updateRole(actorId, target.id, RoleEnum.user);
|
||||
|
||||
expect(target.role).toEqual({ id: RoleEnum.user });
|
||||
expect(result.role).toEqual({ id: RoleEnum.user, name: undefined });
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
{
|
||||
event: 'admin_user_role_update',
|
||||
details: 'targetUserId=2 roleId=2',
|
||||
userId: actorId,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
});
|
||||
|
||||
it('deactivates under the same lock without changing player assignments', async () => {
|
||||
target = user(2, RoleEnum.user, StatusEnum.active);
|
||||
target.players = [assignment(101, target)];
|
||||
|
||||
const result = await service.updateStatus(
|
||||
actorId,
|
||||
target.id,
|
||||
StatusEnum.inactive,
|
||||
);
|
||||
|
||||
expect(target.status).toEqual({ id: StatusEnum.inactive });
|
||||
expect(target.hash).toBeNull();
|
||||
expect(result.assignments).toEqual([playerSummary(101)]);
|
||||
expect(playerRepository.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns an explicit paginated player projection with safe current-user summaries', async () => {
|
||||
playerRows = [
|
||||
{
|
||||
player_id: 101,
|
||||
first_name: 'Pat',
|
||||
last_name: 'Player',
|
||||
active: 1,
|
||||
team_id: 10,
|
||||
team_name: 'Alpha',
|
||||
team_alias: 'alpha',
|
||||
user_id: 2,
|
||||
user_first_name: 'Target',
|
||||
user_last_name: 'User',
|
||||
status_id: StatusEnum.active,
|
||||
status_name: 'Active',
|
||||
},
|
||||
];
|
||||
playerTotal = 1;
|
||||
|
||||
const result = await service.findPlayers({
|
||||
search: 'pat',
|
||||
teamId: 10,
|
||||
assignment: 'assigned',
|
||||
page: 1,
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
data: [
|
||||
{
|
||||
id: 101,
|
||||
firstName: 'Pat',
|
||||
lastName: 'Player',
|
||||
active: true,
|
||||
team,
|
||||
currentUser: {
|
||||
id: 2,
|
||||
firstName: 'Target',
|
||||
lastName: 'User',
|
||||
status: { id: StatusEnum.active, name: 'Active' },
|
||||
},
|
||||
},
|
||||
],
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 1,
|
||||
hasNextPage: false,
|
||||
});
|
||||
expect(playerQuery.andWhere).toHaveBeenCalledWith(
|
||||
'player.teamId = :teamId',
|
||||
{
|
||||
teamId: 10,
|
||||
},
|
||||
);
|
||||
expect(playerQuery.andWhere).toHaveBeenCalledWith(
|
||||
'player.userId IS NOT NULL',
|
||||
);
|
||||
});
|
||||
|
||||
it('atomically reassigns a locked player and returns the refreshed target summary', async () => {
|
||||
const previous = user(9, RoleEnum.user, StatusEnum.active);
|
||||
player = assignment(101, previous);
|
||||
target.players = [player];
|
||||
|
||||
const result = await service.assignPlayer(actorId, target.id, player.id);
|
||||
|
||||
expect(dataSource.transaction).toHaveBeenCalledTimes(1);
|
||||
expect(lockedPlayerQuery.setLock).toHaveBeenCalledWith('pessimistic_write');
|
||||
expect(player.user).toBe(target);
|
||||
expect(playerRepository.save).toHaveBeenCalledWith(player);
|
||||
expect(result.id).toBe(target.id);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
{
|
||||
event: 'admin_player_assign',
|
||||
details: 'targetUserId=2 playerId=101 previousUserId=9',
|
||||
userId: actorId,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
});
|
||||
|
||||
it('unlinks only when the locked player is currently linked to the target user', async () => {
|
||||
player = assignment(101, user(9, RoleEnum.user, StatusEnum.active));
|
||||
|
||||
await expect(
|
||||
service.unlinkPlayer(actorId, target.id, player.id),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
expect(playerRepository.save).not.toHaveBeenCalled();
|
||||
|
||||
player.user = target;
|
||||
player.userId = target.id;
|
||||
await service.unlinkPlayer(actorId, target.id, player.id);
|
||||
expect(player.user).toBeNull();
|
||||
expect(playerRepository.save).toHaveBeenCalledWith(player);
|
||||
});
|
||||
|
||||
function user(id: number, roleId: RoleEnum, statusId: StatusEnum) {
|
||||
return {
|
||||
id,
|
||||
firstName: id === 2 ? 'Target' : 'Admin',
|
||||
lastName: id === 2 ? 'User' : String(id),
|
||||
email: `user-${id}@example.com`,
|
||||
password: `password-${id}`,
|
||||
hash: `hash-${id}`,
|
||||
socialId: `social-${id}`,
|
||||
role: {
|
||||
id: roleId,
|
||||
name: roleId === RoleEnum.admin ? 'Admin' : 'User',
|
||||
},
|
||||
status: {
|
||||
id: statusId,
|
||||
name: statusId === StatusEnum.active ? 'Active' : 'Inactive',
|
||||
},
|
||||
players: [],
|
||||
};
|
||||
}
|
||||
|
||||
function assignment(id: number, assignedUser: any) {
|
||||
return {
|
||||
id,
|
||||
firstName: 'Pat',
|
||||
lastName: 'Player',
|
||||
active: true,
|
||||
team,
|
||||
teamRole,
|
||||
user: assignedUser,
|
||||
userId: assignedUser?.id ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function playerSummary(id: number) {
|
||||
return {
|
||||
id,
|
||||
firstName: 'Pat',
|
||||
lastName: 'Player',
|
||||
active: true,
|
||||
team,
|
||||
teamRole,
|
||||
};
|
||||
}
|
||||
|
||||
function chain(overrides: Record<string, jest.Mock>) {
|
||||
const query: Record<string, jest.Mock> = {};
|
||||
[
|
||||
'innerJoin',
|
||||
'innerJoinAndSelect',
|
||||
'leftJoinAndSelect',
|
||||
'leftJoin',
|
||||
'select',
|
||||
'where',
|
||||
'andWhere',
|
||||
'setParameter',
|
||||
'setParameters',
|
||||
'setLock',
|
||||
'orderBy',
|
||||
'offset',
|
||||
'limit',
|
||||
].forEach((method) => {
|
||||
query[method] = jest.fn(() => query);
|
||||
});
|
||||
return Object.assign(query, overrides);
|
||||
}
|
||||
});
|
||||
381
myteamwallet_backend/src/users/admin-users.service.ts
Normal file
381
myteamwallet_backend/src/users/admin-users.service.ts
Normal file
@@ -0,0 +1,381 @@
|
||||
import {
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Brackets, DataSource, EntityManager, Repository } from 'typeorm';
|
||||
import { LoggingService } from '../database/logging/logging.service';
|
||||
import { Player } from '../players/entities/player.entity';
|
||||
import { Role } from '../roles/entities/role.entity';
|
||||
import { RoleEnum } from '../roles/roles.enum';
|
||||
import { Status } from '../statuses/entities/status.entity';
|
||||
import { StatusEnum } from '../statuses/statuses.enum';
|
||||
import {
|
||||
AdminPlayerPageDto,
|
||||
AdminPlayerSummaryDto,
|
||||
} from './dto/admin-player-response.dto';
|
||||
import { AdminPlayerQueryDto, AdminUserProfileDto } from './dto/admin-user.dto';
|
||||
import { AdminUserDirectorySummaryDto } from './dto/user-directory-response.dto';
|
||||
import { User } from './entities/user.entity';
|
||||
|
||||
type PlayerSearchRow = {
|
||||
player_id: number | string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
active: boolean;
|
||||
team_id: number | string;
|
||||
team_name: string;
|
||||
team_alias: string;
|
||||
user_id: number | string | null;
|
||||
user_first_name: string | null;
|
||||
user_last_name: string | null;
|
||||
status_id: number | string | null;
|
||||
status_name: string | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AdminUsersService {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly logger: LoggingService,
|
||||
) {}
|
||||
|
||||
async updateProfile(
|
||||
actorId: number,
|
||||
targetUserId: number,
|
||||
dto: AdminUserProfileDto,
|
||||
): Promise<AdminUserDirectorySummaryDto> {
|
||||
const summary = await this.dataSource.transaction(async (manager) => {
|
||||
const repository = manager.getRepository(User);
|
||||
const target = await this.findLockedUser(repository, targetUserId);
|
||||
if (dto.firstName !== undefined) target.firstName = dto.firstName;
|
||||
if (dto.lastName !== undefined) target.lastName = dto.lastName;
|
||||
await repository.save(target);
|
||||
const summary = await this.findAdminSummary(manager, targetUserId);
|
||||
await this.log(
|
||||
manager,
|
||||
'admin_user_profile_update',
|
||||
actorId,
|
||||
`targetUserId=${targetUserId}`,
|
||||
);
|
||||
return summary;
|
||||
});
|
||||
return summary;
|
||||
}
|
||||
|
||||
async updateRole(
|
||||
actorId: number,
|
||||
targetUserId: number,
|
||||
roleId: RoleEnum,
|
||||
): Promise<AdminUserDirectorySummaryDto> {
|
||||
const summary = await this.dataSource.transaction(async (manager) => {
|
||||
const activeAdmins = await this.lockActiveAdmins(manager);
|
||||
const repository = manager.getRepository(User);
|
||||
const target = await this.findLockedUser(repository, targetUserId);
|
||||
const isDemotion =
|
||||
target.role?.id === RoleEnum.admin && roleId !== RoleEnum.admin;
|
||||
if (actorId === targetUserId && isDemotion) {
|
||||
throw new ForbiddenException('Administrators cannot demote themselves');
|
||||
}
|
||||
if (
|
||||
isDemotion &&
|
||||
target.status?.id === StatusEnum.active &&
|
||||
activeAdmins.length <= 1
|
||||
) {
|
||||
throw new ConflictException('At least one active admin must remain');
|
||||
}
|
||||
target.role = { id: roleId } as Role;
|
||||
await repository.save(target);
|
||||
const summary = await this.findAdminSummary(manager, targetUserId);
|
||||
await this.log(
|
||||
manager,
|
||||
'admin_user_role_update',
|
||||
actorId,
|
||||
`targetUserId=${targetUserId} roleId=${roleId}`,
|
||||
);
|
||||
return summary;
|
||||
});
|
||||
return summary;
|
||||
}
|
||||
|
||||
async updateStatus(
|
||||
actorId: number,
|
||||
targetUserId: number,
|
||||
statusId: StatusEnum,
|
||||
): Promise<AdminUserDirectorySummaryDto> {
|
||||
const summary = await this.dataSource.transaction(async (manager) => {
|
||||
const activeAdmins = await this.lockActiveAdmins(manager);
|
||||
const repository = manager.getRepository(User);
|
||||
const target = await this.findLockedUser(repository, targetUserId);
|
||||
const isDeactivation =
|
||||
target.status?.id === StatusEnum.active &&
|
||||
statusId === StatusEnum.inactive;
|
||||
if (actorId === targetUserId && isDeactivation) {
|
||||
throw new ForbiddenException(
|
||||
'Administrators cannot deactivate themselves',
|
||||
);
|
||||
}
|
||||
if (
|
||||
isDeactivation &&
|
||||
target.role?.id === RoleEnum.admin &&
|
||||
activeAdmins.length <= 1
|
||||
) {
|
||||
throw new ConflictException('At least one active admin must remain');
|
||||
}
|
||||
target.status = { id: statusId } as Status;
|
||||
if (statusId === StatusEnum.inactive) target.hash = null;
|
||||
await repository.save(target);
|
||||
const summary = await this.findAdminSummary(manager, targetUserId);
|
||||
await this.log(
|
||||
manager,
|
||||
'admin_user_status_update',
|
||||
actorId,
|
||||
`targetUserId=${targetUserId} statusId=${statusId}`,
|
||||
);
|
||||
return summary;
|
||||
});
|
||||
return summary;
|
||||
}
|
||||
|
||||
async findPlayers(query: AdminPlayerQueryDto): Promise<AdminPlayerPageDto> {
|
||||
const page = query.page ?? 1;
|
||||
const limit = query.limit ?? 20;
|
||||
const repository = this.dataSource.manager.getRepository(Player);
|
||||
const builder = repository
|
||||
.createQueryBuilder('player')
|
||||
.innerJoin('player.team', 'team')
|
||||
.leftJoin('player.user', 'currentUser')
|
||||
.leftJoin('currentUser.status', 'currentStatus');
|
||||
const term = query.search?.trim().toLocaleLowerCase();
|
||||
if (term) {
|
||||
builder
|
||||
.andWhere(
|
||||
new Brackets((where) =>
|
||||
where
|
||||
.where('LOWER(player.firstName) LIKE :playerSearch')
|
||||
.orWhere('LOWER(player.lastName) LIKE :playerSearch')
|
||||
.orWhere('LOWER(team.name) LIKE :playerSearch'),
|
||||
),
|
||||
)
|
||||
.setParameter('playerSearch', `%${term}%`);
|
||||
}
|
||||
if (query.teamId !== undefined) {
|
||||
builder.andWhere('player.teamId = :teamId', { teamId: query.teamId });
|
||||
}
|
||||
if (query.assignment === 'assigned') {
|
||||
builder.andWhere('player.userId IS NOT NULL');
|
||||
} else if (query.assignment === 'unassigned') {
|
||||
builder.andWhere('player.userId IS NULL');
|
||||
}
|
||||
const total = await builder.getCount();
|
||||
const rows = await builder
|
||||
.select([
|
||||
'player.id AS player_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',
|
||||
'currentUser.id AS user_id',
|
||||
'currentUser.firstName AS user_first_name',
|
||||
'currentUser.lastName AS user_last_name',
|
||||
'currentStatus.id AS status_id',
|
||||
'currentStatus.name AS status_name',
|
||||
])
|
||||
.orderBy('player.id', 'ASC')
|
||||
.offset((page - 1) * limit)
|
||||
.limit(limit)
|
||||
.getRawMany<PlayerSearchRow>();
|
||||
return {
|
||||
data: rows.map((row) => this.mapPlayerSearchRow(row)),
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
hasNextPage: page * limit < total,
|
||||
};
|
||||
}
|
||||
|
||||
async assignPlayer(
|
||||
actorId: number,
|
||||
targetUserId: number,
|
||||
playerId: number,
|
||||
): Promise<AdminUserDirectorySummaryDto> {
|
||||
let previousUserId: number | null = null;
|
||||
const summary = await this.dataSource.transaction(async (manager) => {
|
||||
const userRepository = manager.getRepository(User);
|
||||
const playerRepository = manager.getRepository(Player);
|
||||
const target = await userRepository.findOne({
|
||||
where: { id: targetUserId },
|
||||
});
|
||||
if (!target) throw new NotFoundException('User not found');
|
||||
const lockedPlayer = await this.findLockedPlayer(
|
||||
playerRepository,
|
||||
playerId,
|
||||
);
|
||||
previousUserId = lockedPlayer.userId ?? null;
|
||||
lockedPlayer.user = target;
|
||||
await playerRepository.save(lockedPlayer);
|
||||
const summary = await this.findAdminSummary(manager, targetUserId);
|
||||
await this.log(
|
||||
manager,
|
||||
'admin_player_assign',
|
||||
actorId,
|
||||
`targetUserId=${targetUserId} playerId=${playerId} previousUserId=${
|
||||
previousUserId ?? 'none'
|
||||
}`,
|
||||
);
|
||||
return summary;
|
||||
});
|
||||
return summary;
|
||||
}
|
||||
|
||||
async unlinkPlayer(
|
||||
actorId: number,
|
||||
targetUserId: number,
|
||||
playerId: number,
|
||||
): Promise<AdminUserDirectorySummaryDto> {
|
||||
const summary = await this.dataSource.transaction(async (manager) => {
|
||||
const playerRepository = manager.getRepository(Player);
|
||||
const lockedPlayer = await this.findLockedPlayer(
|
||||
playerRepository,
|
||||
playerId,
|
||||
);
|
||||
if (lockedPlayer.userId !== targetUserId) {
|
||||
throw new ConflictException('Player is not assigned to this user');
|
||||
}
|
||||
lockedPlayer.user = null;
|
||||
await playerRepository.save(lockedPlayer);
|
||||
const summary = await this.findAdminSummary(manager, targetUserId);
|
||||
await this.log(
|
||||
manager,
|
||||
'admin_player_unlink',
|
||||
actorId,
|
||||
`targetUserId=${targetUserId} playerId=${playerId}`,
|
||||
);
|
||||
return summary;
|
||||
});
|
||||
return summary;
|
||||
}
|
||||
|
||||
private lockActiveAdmins(manager: EntityManager): Promise<User[]> {
|
||||
return manager
|
||||
.getRepository(User)
|
||||
.createQueryBuilder('user')
|
||||
.innerJoinAndSelect('user.role', 'role')
|
||||
.innerJoinAndSelect('user.status', 'status')
|
||||
.where('role.id = :adminRole', { adminRole: RoleEnum.admin })
|
||||
.andWhere('status.id = :activeStatus', {
|
||||
activeStatus: StatusEnum.active,
|
||||
})
|
||||
.setLock('pessimistic_write', undefined, ['user'])
|
||||
.orderBy('user.id', 'ASC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
private async findLockedUser(
|
||||
repository: Repository<User>,
|
||||
userId: number,
|
||||
): Promise<User> {
|
||||
const user = await repository
|
||||
.createQueryBuilder('lockedUser')
|
||||
.leftJoinAndSelect('lockedUser.role', 'role')
|
||||
.leftJoinAndSelect('lockedUser.status', 'status')
|
||||
.where('lockedUser.id = :userId', { userId })
|
||||
.setLock('pessimistic_write', undefined, ['lockedUser'])
|
||||
.getOne();
|
||||
if (!user) throw new NotFoundException('User not found');
|
||||
return user;
|
||||
}
|
||||
|
||||
private async findLockedPlayer(
|
||||
repository: Repository<Player>,
|
||||
playerId: number,
|
||||
): Promise<Player> {
|
||||
const player = await repository
|
||||
.createQueryBuilder('lockedPlayer')
|
||||
.where('lockedPlayer.id = :playerId', { playerId })
|
||||
.setLock('pessimistic_write')
|
||||
.getOne();
|
||||
if (!player) throw new NotFoundException('Player not found');
|
||||
return player;
|
||||
}
|
||||
|
||||
private async findAdminSummary(
|
||||
manager: EntityManager,
|
||||
userId: number,
|
||||
): Promise<AdminUserDirectorySummaryDto> {
|
||||
const user = await manager.getRepository(User).findOne({
|
||||
where: { id: userId },
|
||||
relations: {
|
||||
role: true,
|
||||
status: true,
|
||||
players: { team: true, teamRole: true },
|
||||
},
|
||||
});
|
||||
if (!user) throw new NotFoundException('User not found');
|
||||
return {
|
||||
id: user.id,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
email: user.email,
|
||||
role: user.role ? { id: user.role.id, name: user.role.name } : null,
|
||||
status: user.status
|
||||
? { id: user.status.id, name: user.status.name }
|
||||
: null,
|
||||
assignments: (user.players ?? []).map((player) => ({
|
||||
id: player.id,
|
||||
firstName: player.firstName,
|
||||
lastName: player.lastName,
|
||||
active: player.active,
|
||||
team: {
|
||||
id: player.team.id,
|
||||
name: player.team.name,
|
||||
alias: player.team.alias,
|
||||
},
|
||||
teamRole: player.teamRole
|
||||
? { id: player.teamRole.id, name: player.teamRole.name }
|
||||
: null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
private mapPlayerSearchRow(row: PlayerSearchRow): AdminPlayerSummaryDto {
|
||||
return {
|
||||
id: Number(row.player_id),
|
||||
firstName: row.first_name,
|
||||
lastName: row.last_name,
|
||||
active: Boolean(row.active),
|
||||
team: {
|
||||
id: Number(row.team_id),
|
||||
name: row.team_name,
|
||||
alias: row.team_alias,
|
||||
},
|
||||
currentUser:
|
||||
row.user_id === null
|
||||
? null
|
||||
: {
|
||||
id: Number(row.user_id),
|
||||
firstName: row.user_first_name,
|
||||
lastName: row.user_last_name,
|
||||
status:
|
||||
row.status_id === null
|
||||
? null
|
||||
: {
|
||||
id: Number(row.status_id),
|
||||
name: row.status_name ?? undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private log(
|
||||
manager: EntityManager,
|
||||
event: Parameters<LoggingService['info']>[0]['event'],
|
||||
userId: number,
|
||||
details: string,
|
||||
) {
|
||||
return this.logger.info({ event, userId, details }, manager);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { UserDirectoryReferenceDto } from './user-directory-response.dto';
|
||||
|
||||
export class AdminPlayerTeamDto {
|
||||
id: number;
|
||||
name: string;
|
||||
alias: string;
|
||||
}
|
||||
|
||||
export class AdminPlayerCurrentUserDto {
|
||||
id: number;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
status: UserDirectoryReferenceDto | null;
|
||||
}
|
||||
|
||||
export class AdminPlayerSummaryDto {
|
||||
id: number;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
active: boolean;
|
||||
team: AdminPlayerTeamDto;
|
||||
currentUser: AdminPlayerCurrentUserDto | null;
|
||||
}
|
||||
|
||||
export class AdminPlayerPageDto {
|
||||
data: AdminPlayerSummaryDto[];
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
hasNextPage: boolean;
|
||||
}
|
||||
68
myteamwallet_backend/src/users/dto/admin-user.dto.ts
Normal file
68
myteamwallet_backend/src/users/dto/admin-user.dto.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
import { RoleEnum } from '../../roles/roles.enum';
|
||||
import { StatusEnum } from '../../statuses/statuses.enum';
|
||||
|
||||
export class AdminUserProfileDto {
|
||||
@ValidateIf((_, value) => value !== undefined)
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
firstName?: string;
|
||||
|
||||
@ValidateIf((_, value) => value !== undefined)
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
lastName?: string;
|
||||
}
|
||||
|
||||
export class AdminUserRoleDto {
|
||||
@IsInt()
|
||||
@IsIn([RoleEnum.admin, RoleEnum.user])
|
||||
role: RoleEnum;
|
||||
}
|
||||
|
||||
export class AdminUserStatusDto {
|
||||
@IsInt()
|
||||
@IsIn([StatusEnum.active, StatusEnum.inactive])
|
||||
status: StatusEnum;
|
||||
}
|
||||
|
||||
export type AdminPlayerAssignmentFilter = 'all' | 'assigned' | 'unassigned';
|
||||
|
||||
export class AdminPlayerQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
teamId?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['all', 'assigned', 'unassigned'])
|
||||
assignment: AdminPlayerAssignmentFilter = 'all';
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page = 1;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(50)
|
||||
limit = 20;
|
||||
}
|
||||
@@ -1,13 +1,7 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Role } from '../../roles/entities/role.entity';
|
||||
import {
|
||||
IsEmail,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
MinLength,
|
||||
Validate,
|
||||
} from 'class-validator';
|
||||
import { IsEmail, IsNotEmpty, MinLength, Validate } from 'class-validator';
|
||||
import { Status } from '../../statuses/entities/status.entity';
|
||||
import { IsNotExist } from '../../utils/validators/is-not-exists.validator';
|
||||
import { IsExist } from '../../utils/validators/is-exists.validator';
|
||||
@@ -51,6 +45,4 @@ export class CreateUserDto {
|
||||
status?: Status;
|
||||
|
||||
hash?: string | null;
|
||||
|
||||
linkPlayerId?: number | null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
|
||||
export class UserDirectoryQueryDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page = 1;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(50)
|
||||
limit = 20;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
export class UserDirectoryReferenceDto {
|
||||
id: number;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export class UserDirectoryTeamDto {
|
||||
id: number;
|
||||
name: string;
|
||||
alias: string;
|
||||
}
|
||||
|
||||
export class UserDirectoryAssignmentDto {
|
||||
id: number;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
active: boolean;
|
||||
team: UserDirectoryTeamDto;
|
||||
teamRole: UserDirectoryReferenceDto | null;
|
||||
}
|
||||
|
||||
export class UserDirectorySummaryDto {
|
||||
id: number;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
status: UserDirectoryReferenceDto | null;
|
||||
assignments: UserDirectoryAssignmentDto[];
|
||||
}
|
||||
|
||||
export class AdminUserDirectorySummaryDto extends UserDirectorySummaryDto {
|
||||
email: string | null;
|
||||
role: UserDirectoryReferenceDto | null;
|
||||
}
|
||||
|
||||
export class UserDirectoryPageDto {
|
||||
data: Array<UserDirectorySummaryDto | AdminUserDirectorySummaryDto>;
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
hasNextPage: boolean;
|
||||
}
|
||||
11
myteamwallet_backend/src/users/dto/user-team-response.dto.ts
Normal file
11
myteamwallet_backend/src/users/dto/user-team-response.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export class UserTeamReferenceDto {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export class UserTeamPlayerDto {
|
||||
id: number;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
team: UserTeamReferenceDto;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { UsersController } from './users.controller';
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
|
||||
describe('UsersController admin mutation isolation', () => {
|
||||
it('does not expose generic create, update, or delete handlers that bypass safeguards', () => {
|
||||
expect(UsersController.prototype).not.toHaveProperty('create');
|
||||
expect(UsersController.prototype).not.toHaveProperty('update');
|
||||
expect(UsersController.prototype).not.toHaveProperty('remove');
|
||||
expect(UsersController.prototype).not.toHaveProperty('findAll');
|
||||
expect(UsersController.prototype).not.toHaveProperty('findOne');
|
||||
});
|
||||
|
||||
it('restricts team bootstrap to the authenticated user id', async () => {
|
||||
const service = { findMyTeams: jest.fn(() => []) };
|
||||
const controller = new UsersController(service as any);
|
||||
const request = { user: { id: 7, role: { id: 2 } } };
|
||||
|
||||
expect(() => (controller as any).findTeamsOfPlayer(request, 8)).toThrow(
|
||||
ForbiddenException,
|
||||
);
|
||||
await (controller as any).findTeamsOfPlayer(request, 7);
|
||||
|
||||
expect(service.findMyTeams).toHaveBeenCalledWith(7);
|
||||
});
|
||||
});
|
||||
@@ -1,27 +1,23 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Patch,
|
||||
Param,
|
||||
Delete,
|
||||
UseGuards,
|
||||
Query,
|
||||
DefaultValuePipe,
|
||||
ParseIntPipe,
|
||||
HttpStatus,
|
||||
HttpCode,
|
||||
Request,
|
||||
ForbiddenException,
|
||||
ParseIntPipe,
|
||||
} from '@nestjs/common';
|
||||
import { UsersService } from './users.service';
|
||||
import { CreateUserDto } from './dto/create-user.dto';
|
||||
import { UpdateUserDto } from './dto/update-user.dto';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { Roles } from 'src/roles/roles.decorator';
|
||||
import { RoleEnum } from 'src/roles/roles.enum';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { RolesGuard } from 'src/roles/roles.guard';
|
||||
import { infinityPagination } from 'src/utils/infinity-pagination';
|
||||
import { UserDirectoryQueryDto } from './dto/user-directory-query.dto';
|
||||
import { User } from './entities/user.entity';
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(AuthGuard('jwt'), RolesGuard)
|
||||
@@ -33,57 +29,26 @@ import { infinityPagination } from 'src/utils/infinity-pagination';
|
||||
export class UsersController {
|
||||
constructor(private readonly usersService: UsersService) {}
|
||||
|
||||
@Roles([RoleEnum.admin])
|
||||
@Post()
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
create(@Body() createProfileDto: CreateUserDto) {
|
||||
return this.usersService.create(createProfileDto);
|
||||
}
|
||||
|
||||
@Roles([RoleEnum.admin])
|
||||
@Get()
|
||||
@Roles([RoleEnum.user, RoleEnum.admin])
|
||||
@Get('directory')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async findAll(
|
||||
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
|
||||
@Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number,
|
||||
findDirectory(
|
||||
@Request() request: { user: Pick<User, 'id' | 'role'> },
|
||||
@Query() query: UserDirectoryQueryDto,
|
||||
) {
|
||||
if (limit > 50) {
|
||||
limit = 50;
|
||||
}
|
||||
|
||||
return infinityPagination(
|
||||
await this.usersService.findManyWithPagination({
|
||||
page,
|
||||
limit,
|
||||
}),
|
||||
{ page, limit },
|
||||
);
|
||||
}
|
||||
|
||||
@Roles([RoleEnum.admin])
|
||||
@Get(':id')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.usersService.findOne({ id: +id });
|
||||
return this.usersService.findDirectory(request.user, query);
|
||||
}
|
||||
|
||||
@Roles([RoleEnum.user, RoleEnum.admin])
|
||||
@Get(':id/teams')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
findTeamsOfPlayer(@Param('id') id: string) {
|
||||
return this.usersService.findTeams({ id: +id });
|
||||
}
|
||||
|
||||
@Roles([RoleEnum.admin])
|
||||
@Patch(':id')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
update(@Param('id') id: number, @Body() updateProfileDto: UpdateUserDto) {
|
||||
return this.usersService.update(id, updateProfileDto);
|
||||
}
|
||||
|
||||
@Roles([RoleEnum.admin])
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: number) {
|
||||
return this.usersService.softDelete(id);
|
||||
findTeamsOfPlayer(
|
||||
@Request() request: { user: Pick<User, 'id' | 'role'> },
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
) {
|
||||
if (request.user.id !== id) {
|
||||
throw new ForbiddenException('Users may only load their own teams');
|
||||
}
|
||||
return this.usersService.findMyTeams(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,11 +7,14 @@ import { IsExist } from 'src/utils/validators/is-exists.validator';
|
||||
import { IsNotExist } from 'src/utils/validators/is-not-exists.validator';
|
||||
import { Team } from 'src/teams/entities/team.entity';
|
||||
import { Player } from 'src/players/entities/player.entity';
|
||||
import { LoggingModule } from 'src/database/logging/logging.module';
|
||||
import { AdminUsersController } from './admin-users.controller';
|
||||
import { AdminUsersService } from './admin-users.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([User, Team, Player])],
|
||||
controllers: [UsersController],
|
||||
providers: [IsExist, IsNotExist, UsersService],
|
||||
imports: [TypeOrmModule.forFeature([User, Team, Player]), LoggingModule],
|
||||
controllers: [UsersController, AdminUsersController],
|
||||
providers: [IsExist, IsNotExist, UsersService, AdminUsersService],
|
||||
exports: [UsersService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
|
||||
390
myteamwallet_backend/src/users/users.service.spec.ts
Normal file
390
myteamwallet_backend/src/users/users.service.spec.ts
Normal file
@@ -0,0 +1,390 @@
|
||||
import { RoleEnum } from '../roles/roles.enum';
|
||||
import { StatusEnum } from '../statuses/statuses.enum';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
describe('UsersService directory', () => {
|
||||
const teamA = { id: 10, name: 'Alpha', alias: 'alpha' };
|
||||
const teamB = { id: 20, name: 'Bravo', alias: 'bravo' };
|
||||
const playerRole = { id: 1, name: 'Player' };
|
||||
|
||||
const users = [
|
||||
user(1, 'Riley', 'Reader', 'reader@example.com'),
|
||||
user(2, 'Emma', 'Shared', 'emma@example.com'),
|
||||
user(3, 'Iva', 'Inactive', 'iva@example.com', StatusEnum.inactive),
|
||||
user(4, 'Otis', 'Outside', 'otis@example.com'),
|
||||
user(5, 'Morgan', 'Multiple', 'morgan@example.com'),
|
||||
user(
|
||||
6,
|
||||
'Ada',
|
||||
'Admin',
|
||||
'admin@example.com',
|
||||
StatusEnum.active,
|
||||
RoleEnum.admin,
|
||||
),
|
||||
];
|
||||
|
||||
const players = [
|
||||
assignment(101, users[0], teamA),
|
||||
assignment(201, users[1], teamA),
|
||||
assignment(301, users[2], teamA, false),
|
||||
assignment(401, users[3], teamB),
|
||||
assignment(501, users[4], teamA),
|
||||
assignment(502, users[4], teamB),
|
||||
];
|
||||
|
||||
let userRows: ReturnType<typeof directoryUserRow>[];
|
||||
let assignmentRows: ReturnType<typeof directoryAssignmentRow>[];
|
||||
let total: number;
|
||||
let rejectActiveSharedTeamPredicate: boolean;
|
||||
const usersRepository = {
|
||||
find: jest.fn(() => {
|
||||
throw new Error('directory queries must use a safe database projection');
|
||||
}),
|
||||
createQueryBuilder: jest.fn(() => createDirectoryUsersQuery()),
|
||||
};
|
||||
const playersRepository = {
|
||||
find: jest.fn(() => {
|
||||
throw new Error('directory queries must use a safe database projection');
|
||||
}),
|
||||
createQueryBuilder: jest.fn((alias: string) =>
|
||||
alias === 'requesterPlayer'
|
||||
? createSharedTeamsQuery()
|
||||
: createDirectoryAssignmentsQuery(),
|
||||
),
|
||||
};
|
||||
let service: UsersService;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
rejectActiveSharedTeamPredicate = false;
|
||||
setDirectoryResult(
|
||||
[users[0], users[1], users[2], users[4]],
|
||||
[players[0], players[1], players[2], players[4]],
|
||||
);
|
||||
service = new UsersService(
|
||||
usersRepository as any,
|
||||
playersRepository as any,
|
||||
);
|
||||
});
|
||||
|
||||
it('hides users and assignments from teams that the requester does not share', async () => {
|
||||
const result = await directoryFor(users[0]);
|
||||
|
||||
expect(result.data.map((entry) => entry.id)).toEqual([1, 2, 3, 5]);
|
||||
expect(result.data.find((entry) => entry.id === 4)).toBeUndefined();
|
||||
expect(result.data.find((entry) => entry.id === 5).assignments).toEqual([
|
||||
assignmentSummary(501, 'Morgan', 'Multiple', true, teamA),
|
||||
]);
|
||||
});
|
||||
|
||||
it('redacts email and authentication secrets for a non-admin requester', async () => {
|
||||
const result = await directoryFor(users[0]);
|
||||
const entry = result.data.find((candidate) => candidate.id === 2);
|
||||
|
||||
expect(entry).toEqual({
|
||||
id: 2,
|
||||
firstName: 'Emma',
|
||||
lastName: 'Shared',
|
||||
status: { id: StatusEnum.active, name: 'Active' },
|
||||
assignments: [assignmentSummary(201, 'Emma', 'Shared', true, teamA)],
|
||||
});
|
||||
expect(entry).not.toHaveProperty('email');
|
||||
expect(entry).not.toHaveProperty('password');
|
||||
expect(entry).not.toHaveProperty('hash');
|
||||
expect(entry).not.toHaveProperty('socialId');
|
||||
});
|
||||
|
||||
it('keeps inactive users and inactive assignments visible in shared teams', async () => {
|
||||
const result = await directoryFor(users[0]);
|
||||
|
||||
expect(result.data.find((entry) => entry.id === 3)).toEqual({
|
||||
id: 3,
|
||||
firstName: 'Iva',
|
||||
lastName: 'Inactive',
|
||||
status: { id: StatusEnum.inactive, name: 'Inactive' },
|
||||
assignments: [assignmentSummary(301, 'Iva', 'Inactive', false, teamA)],
|
||||
});
|
||||
});
|
||||
|
||||
it('treats an inactive requester assignment as a shared team membership', async () => {
|
||||
rejectActiveSharedTeamPredicate = true;
|
||||
setDirectoryResult(
|
||||
[users[0], users[1]],
|
||||
[
|
||||
assignment(101, users[0], teamA, false),
|
||||
assignment(201, users[1], teamA),
|
||||
],
|
||||
);
|
||||
|
||||
const result = await directoryFor(users[0]);
|
||||
|
||||
expect(result.data.map((entry) => entry.id)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('does not hydrate whole user entities for the directory', async () => {
|
||||
const result = await directoryFor(users[0]);
|
||||
|
||||
expect(
|
||||
result.data.every(
|
||||
(entry) =>
|
||||
!('password' in entry || 'hash' in entry || 'socialId' in entry),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns every user and assignment with email and role for an admin requester', async () => {
|
||||
setDirectoryResult(users, players, true);
|
||||
|
||||
const result = await directoryFor(users[5]);
|
||||
const multiple = result.data.find((entry) => entry.id === 5);
|
||||
const outsider = result.data.find((entry) => entry.id === 4);
|
||||
|
||||
expect(result.data).toHaveLength(6);
|
||||
expect(outsider).toMatchObject({
|
||||
email: 'otis@example.com',
|
||||
role: { id: RoleEnum.user, name: 'User' },
|
||||
});
|
||||
expect(multiple.assignments).toEqual([
|
||||
assignmentSummary(501, 'Morgan', 'Multiple', true, teamA),
|
||||
assignmentSummary(502, 'Morgan', 'Multiple', true, teamB),
|
||||
]);
|
||||
expect(multiple).not.toHaveProperty('password');
|
||||
expect(multiple).not.toHaveProperty('hash');
|
||||
expect(multiple).not.toHaveProperty('socialId');
|
||||
});
|
||||
|
||||
it('deduplicates a user with assignments in more than one shared team before pagination', async () => {
|
||||
setDirectoryResult(
|
||||
[users[2], users[4]],
|
||||
[players[2], players[4]],
|
||||
false,
|
||||
4,
|
||||
);
|
||||
|
||||
const result = await directoryFor(users[0], { page: 2, limit: 2 });
|
||||
|
||||
expect(result.data.map((entry) => entry.id)).toEqual([3, 5]);
|
||||
expect(result.total).toBe(4);
|
||||
expect(result.hasNextPage).toBe(false);
|
||||
});
|
||||
|
||||
it('searches visible names case-insensitively without exposing outside-team users', async () => {
|
||||
setDirectoryResult([users[4]], [players[4]]);
|
||||
const matched = await directoryFor(users[0], { search: 'mOrGaN' });
|
||||
setDirectoryResult([], []);
|
||||
const hidden = await directoryFor(users[0], { search: 'outside' });
|
||||
|
||||
expect(matched.data.map((entry) => entry.id)).toEqual([5]);
|
||||
expect(hidden.data).toEqual([]);
|
||||
});
|
||||
|
||||
it('paginates the deduplicated, filtered directory and reports the next page', async () => {
|
||||
setDirectoryResult(
|
||||
[users[0], users[1]],
|
||||
[players[0], players[1]],
|
||||
false,
|
||||
4,
|
||||
);
|
||||
|
||||
const result = await directoryFor(users[0], { page: 1, limit: 2 });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
page: 1,
|
||||
limit: 2,
|
||||
total: 4,
|
||||
hasNextPage: true,
|
||||
});
|
||||
expect(result.data.map((entry) => entry.id)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
function directoryFor(
|
||||
requester: typeof users[number],
|
||||
query: { page?: number; limit?: number; search?: string } = {},
|
||||
) {
|
||||
return (service as any).findDirectory(requester, {
|
||||
page: 1,
|
||||
limit: 20,
|
||||
...query,
|
||||
});
|
||||
}
|
||||
|
||||
function user(
|
||||
id: number,
|
||||
firstName: string,
|
||||
lastName: string,
|
||||
email: string,
|
||||
statusId = StatusEnum.active,
|
||||
roleId = RoleEnum.user,
|
||||
) {
|
||||
return {
|
||||
id,
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
password: `password-${id}`,
|
||||
hash: `hash-${id}`,
|
||||
socialId: `social-${id}`,
|
||||
provider: 'email',
|
||||
previousPassword: `previous-password-${id}`,
|
||||
status: {
|
||||
id: statusId,
|
||||
name: statusId === StatusEnum.active ? 'Active' : 'Inactive',
|
||||
},
|
||||
role: {
|
||||
id: roleId,
|
||||
name: roleId === RoleEnum.admin ? 'Admin' : 'User',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function assignment(
|
||||
id: number,
|
||||
playerUser: typeof users[number],
|
||||
team: typeof teamA,
|
||||
active = true,
|
||||
) {
|
||||
return {
|
||||
id,
|
||||
firstName: playerUser.firstName,
|
||||
lastName: playerUser.lastName,
|
||||
active,
|
||||
user: playerUser,
|
||||
team,
|
||||
teamRole: playerRole,
|
||||
};
|
||||
}
|
||||
|
||||
function assignmentSummary(
|
||||
id: number,
|
||||
firstName: string,
|
||||
lastName: string,
|
||||
active: boolean,
|
||||
team: typeof teamA,
|
||||
) {
|
||||
return {
|
||||
id,
|
||||
firstName,
|
||||
lastName,
|
||||
active,
|
||||
team,
|
||||
teamRole: playerRole,
|
||||
};
|
||||
}
|
||||
|
||||
function setDirectoryResult(
|
||||
directoryUsers: typeof users,
|
||||
directoryPlayers: typeof players,
|
||||
isAdmin = false,
|
||||
directoryTotal = directoryUsers.length,
|
||||
) {
|
||||
userRows = directoryUsers.map((directoryUser) =>
|
||||
directoryUserRow(directoryUser, isAdmin),
|
||||
);
|
||||
assignmentRows = directoryPlayers.map(directoryAssignmentRow);
|
||||
total = directoryTotal;
|
||||
}
|
||||
|
||||
function directoryUserRow(
|
||||
directoryUser: typeof users[number],
|
||||
isAdmin = false,
|
||||
) {
|
||||
return {
|
||||
user_id: directoryUser.id,
|
||||
first_name: directoryUser.firstName,
|
||||
last_name: directoryUser.lastName,
|
||||
status_id: directoryUser.status.id,
|
||||
status_name: directoryUser.status.name,
|
||||
...(isAdmin
|
||||
? {
|
||||
email: directoryUser.email,
|
||||
role_id: directoryUser.role.id,
|
||||
role_name: directoryUser.role.name,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function directoryAssignmentRow(directoryPlayer: typeof players[number]) {
|
||||
return {
|
||||
player_id: directoryPlayer.id,
|
||||
user_id: directoryPlayer.user.id,
|
||||
first_name: directoryPlayer.firstName,
|
||||
last_name: directoryPlayer.lastName,
|
||||
active: directoryPlayer.active,
|
||||
team_id: directoryPlayer.team.id,
|
||||
team_name: directoryPlayer.team.name,
|
||||
team_alias: directoryPlayer.team.alias,
|
||||
team_role_id: directoryPlayer.teamRole.id,
|
||||
team_role_name: directoryPlayer.teamRole.name,
|
||||
};
|
||||
}
|
||||
|
||||
function createDirectoryUsersQuery() {
|
||||
const query: any = {
|
||||
leftJoin: () => query,
|
||||
innerJoin: () => query,
|
||||
setParameters: () => query,
|
||||
setParameter: () => query,
|
||||
andWhere: () => query,
|
||||
distinct: () => query,
|
||||
orderBy: () => query,
|
||||
offset: () => query,
|
||||
limit: () => query,
|
||||
select: (selection: string | string[]) => {
|
||||
const selected = Array.isArray(selection) ? selection : [selection];
|
||||
if (selected.join(' ').match(/password|hash|socialId|provider/i)) {
|
||||
throw new Error(
|
||||
'directory projection contains an authentication field',
|
||||
);
|
||||
}
|
||||
return query;
|
||||
},
|
||||
clone: () => createDirectoryCountQuery(),
|
||||
getRawMany: () => Promise.resolve(userRows),
|
||||
};
|
||||
return query;
|
||||
}
|
||||
|
||||
function createDirectoryCountQuery() {
|
||||
const query: any = {
|
||||
select: () => query,
|
||||
getRawOne: () => Promise.resolve({ total: String(total) }),
|
||||
};
|
||||
return query;
|
||||
}
|
||||
|
||||
function createSharedTeamsQuery() {
|
||||
const query: any = {
|
||||
select: () => query,
|
||||
where: (predicate: string) => {
|
||||
if (
|
||||
rejectActiveSharedTeamPredicate &&
|
||||
/requesterPlayer\.active/i.test(predicate)
|
||||
) {
|
||||
throw new Error(
|
||||
'shared-team membership must not filter inactive requester assignments',
|
||||
);
|
||||
}
|
||||
return query;
|
||||
},
|
||||
getQuery: () =>
|
||||
'SELECT requesterPlayer.teamId FROM player requesterPlayer',
|
||||
getParameters: () => ({ requesterId: users[0].id }),
|
||||
};
|
||||
return query;
|
||||
}
|
||||
|
||||
function createDirectoryAssignmentsQuery() {
|
||||
const query: any = {
|
||||
innerJoin: () => query,
|
||||
leftJoin: () => query,
|
||||
select: () => query,
|
||||
where: () => query,
|
||||
andWhere: () => query,
|
||||
setParameters: () => query,
|
||||
orderBy: () => query,
|
||||
getRawMany: () => Promise.resolve(assignmentRows),
|
||||
};
|
||||
return query;
|
||||
}
|
||||
});
|
||||
@@ -3,10 +3,20 @@ 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 { Repository } from 'typeorm';
|
||||
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';
|
||||
import { UserTeamPlayerDto } from './dto/user-team-response.dto';
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
@@ -30,6 +40,61 @@ export class UsersService {
|
||||
});
|
||||
}
|
||||
|
||||
async findDirectory(
|
||||
requester: Pick<User, 'id' | 'role'>,
|
||||
query: UserDirectoryQueryDto,
|
||||
): Promise<UserDirectoryPageDto> {
|
||||
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<DirectoryUserRow>();
|
||||
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<number, DirectoryAssignmentRow[]>());
|
||||
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<User>) {
|
||||
return this.usersRepository.findOne({
|
||||
where: fields,
|
||||
@@ -49,32 +114,232 @@ export class UsersService {
|
||||
await this.usersRepository.softDelete(id);
|
||||
}
|
||||
|
||||
async findTeams(fields: EntityCondition<User>) {
|
||||
const user = await this.findOne(fields);
|
||||
if (!user) {
|
||||
async findMyTeams(userId: number): Promise<UserTeamPlayerDto[]> {
|
||||
const rows = await this.playersRepository
|
||||
.createQueryBuilder('player')
|
||||
.innerJoin('player.team', 'team')
|
||||
.select([
|
||||
'player.id AS player_id',
|
||||
'player.firstName AS first_name',
|
||||
'player.lastName AS last_name',
|
||||
'team.id AS team_id',
|
||||
'team.name AS team_name',
|
||||
])
|
||||
.where('player.userId = :userId', { userId })
|
||||
.orderBy('player.id', 'ASC')
|
||||
.getRawMany<{
|
||||
player_id: number | string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
team_id: number | string;
|
||||
team_name: string;
|
||||
}>();
|
||||
return rows.map((row) => ({
|
||||
id: Number(row.player_id),
|
||||
firstName: row.first_name,
|
||||
lastName: row.last_name,
|
||||
team: { id: Number(row.team_id), name: row.team_name },
|
||||
}));
|
||||
}
|
||||
|
||||
private createSharedTeamsQuery(requesterId: number) {
|
||||
return this.playersRepository
|
||||
.createQueryBuilder('requesterPlayer')
|
||||
.select('requesterPlayer.teamId')
|
||||
.where('requesterPlayer.userId = :requesterId', { requesterId });
|
||||
}
|
||||
|
||||
private createDirectoryUsersQuery(
|
||||
isAdmin: boolean,
|
||||
sharedTeamsQuery: SelectQueryBuilder<Player> | 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<Player> | undefined,
|
||||
): Promise<DirectoryAssignmentRow[]> {
|
||||
if (userIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const players = await this.playersRepository.find({
|
||||
where: {
|
||||
user: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
relations: ['team'],
|
||||
});
|
||||
return players;
|
||||
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<DirectoryAssignmentRow>();
|
||||
}
|
||||
|
||||
async linkPlayerToUserId(user: User, playerId: number): Promise<boolean> {
|
||||
return new Promise<boolean>(async (resolve) => {
|
||||
const player = await this.playersRepository.findOneByOrFail({
|
||||
id: playerId,
|
||||
});
|
||||
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),
|
||||
),
|
||||
};
|
||||
|
||||
player.user = user;
|
||||
await this.playersRepository.save(player);
|
||||
return resolve(true);
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
49
myteamwallet_backend/src/users/users.teams.spec.ts
Normal file
49
myteamwallet_backend/src/users/users.teams.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
describe('UsersService safe current-user teams', () => {
|
||||
it('returns only the player and team fields required by team bootstrap', async () => {
|
||||
const rows = [
|
||||
{
|
||||
player_id: 11,
|
||||
first_name: 'Pat',
|
||||
last_name: 'Player',
|
||||
team_id: 4,
|
||||
team_name: 'Alpha',
|
||||
user_id: 7,
|
||||
provider: 'google',
|
||||
social_id: 'must-not-leak',
|
||||
balance: '100.00',
|
||||
},
|
||||
];
|
||||
const query = chain({ getRawMany: jest.fn(() => rows) });
|
||||
const playersRepository = {
|
||||
createQueryBuilder: jest.fn(() => query),
|
||||
};
|
||||
const service = new UsersService({} as any, playersRepository as any);
|
||||
|
||||
const result = await service.findMyTeams(7);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: 11,
|
||||
firstName: 'Pat',
|
||||
lastName: 'Player',
|
||||
team: { id: 4, name: 'Alpha' },
|
||||
},
|
||||
]);
|
||||
expect(JSON.stringify(result)).not.toMatch(
|
||||
/user|provider|social|role|status|password|hash|balance|createdAt|updatedAt/i,
|
||||
);
|
||||
expect(query.where).toHaveBeenCalledWith('player.userId = :userId', {
|
||||
userId: 7,
|
||||
});
|
||||
});
|
||||
|
||||
function chain(overrides: Record<string, jest.Mock>) {
|
||||
const query: Record<string, jest.Mock> = {};
|
||||
['innerJoin', 'select', 'where', 'orderBy'].forEach((method) => {
|
||||
query[method] = jest.fn(() => query);
|
||||
});
|
||||
return Object.assign(query, overrides);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user