79 lines
2.9 KiB
TypeScript
79 lines
2.9 KiB
TypeScript
import { Injectable } from '@nestjs/common';
|
|
import { DataSource } from 'typeorm';
|
|
import { v4 as uuid } from 'uuid';
|
|
import * as argon2 from 'argon2';
|
|
import { UserEntity, UserRole } from '../../database/entities/user.entity';
|
|
import { UsersService } from '../users/users.service';
|
|
import { ApiError } from '../../common/errors/api-error';
|
|
import { ERROR_CODES } from '../../common/errors/error-codes';
|
|
import { validatePassword } from '../../common/utils/password-policy';
|
|
import { ConfigService } from '@nestjs/config';
|
|
|
|
export interface ListUsersQuery {
|
|
limit?: number;
|
|
cursor?: string;
|
|
role?: UserRole;
|
|
}
|
|
|
|
@Injectable()
|
|
export class AdminService {
|
|
constructor(
|
|
private readonly usersService: UsersService,
|
|
private readonly dataSource: DataSource,
|
|
private readonly config: ConfigService,
|
|
) {}
|
|
|
|
async listUsers(query: ListUsersQuery = {}): Promise<UserEntity[]> {
|
|
const limit = Math.min(Math.max(query.limit ?? 50, 1), 200);
|
|
const repo = this.dataSource.getRepository(UserEntity);
|
|
const qb = repo
|
|
.createQueryBuilder('u')
|
|
.orderBy('u.createdAt', 'ASC')
|
|
.addOrderBy('u.id', 'ASC')
|
|
.limit(limit + 1);
|
|
if (query.role) qb.andWhere('u.role = :role', { role: query.role });
|
|
if (query.cursor) qb.andWhere('u.id > :cursor', { cursor: query.cursor });
|
|
const rows = await qb.getMany();
|
|
return rows.slice(0, limit);
|
|
}
|
|
|
|
async updateUserRole(targetId: string, role: UserRole): Promise<UserEntity> {
|
|
if (role !== 'admin' && role !== 'player') {
|
|
throw ApiError.validation('Invalid role');
|
|
}
|
|
return this.dataSource.transaction(async (manager) => {
|
|
await this.usersService.enforceLastAdminInvariant(manager, targetId, role);
|
|
const user = await manager.findOne(UserEntity, { where: { id: targetId } });
|
|
if (!user) throw ApiError.notFound('User not found');
|
|
user.role = role;
|
|
await manager.save(user);
|
|
return user;
|
|
});
|
|
}
|
|
|
|
async deleteUser(targetId: string): Promise<void> {
|
|
return this.dataSource.transaction(async (manager) => {
|
|
await this.usersService.enforceLastAdminInvariant(manager, targetId);
|
|
const res = await manager.delete(UserEntity, { id: targetId });
|
|
if (!res.affected) throw ApiError.notFound('User not found');
|
|
});
|
|
}
|
|
|
|
async createUser(username: string, password: string, role: UserRole): Promise<UserEntity> {
|
|
validatePassword(password, this.config);
|
|
return this.dataSource.transaction(async (manager) => {
|
|
const existing = await manager.findOne(UserEntity, { where: { username } });
|
|
if (existing) throw ApiError.conflict(ERROR_CODES.CONFLICT, 'Username already taken');
|
|
const hash = await argon2.hash(password, { type: argon2.argon2id });
|
|
const user = manager.create(UserEntity, {
|
|
id: uuid(),
|
|
username,
|
|
passwordHash: hash,
|
|
role,
|
|
status: 'enabled',
|
|
});
|
|
await manager.save(user);
|
|
return user;
|
|
});
|
|
}
|
|
} |