AI Implementation feature(867): Admin Area Players Management (#56)
This commit was merged in pull request #56.
This commit is contained in:
@@ -1,29 +1,56 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource, EntityManager } from 'typeorm';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import * as argon2 from 'argon2';
|
||||
import { UserEntity, UserRole } from '../../database/entities/user.entity';
|
||||
import { UserEntity, UserRole, UserStatus } from '../../database/entities/user.entity';
|
||||
import { RefreshTokenEntity } from '../../database/entities/refresh-token.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 { hashPassword } from '../../common/utils/password-hashing';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AdminPlayerView, ListUsersQueryDto } from './dto/admin.dto';
|
||||
|
||||
export interface ListUsersQuery {
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
role?: UserRole;
|
||||
/**
|
||||
* Every table that has a `userId` column and must be cleaned up when the
|
||||
* parent `user` row is deleted. Iterated by `deleteUserOwnedRows` so future
|
||||
* user-owned tables (e.g. comments, notifications) only need to be appended
|
||||
* here. The ON DELETE CASCADE migration that backs this list is the
|
||||
* deployment-wide safety net; the imperative delete below is what the
|
||||
* admin delete path requires.
|
||||
*/
|
||||
const USER_OWNED_TABLES: { tableName: string; userIdColumn: string }[] = [
|
||||
{ tableName: 'solve', userIdColumn: 'user_id' },
|
||||
{ tableName: 'refresh_token', userIdColumn: 'user_id' },
|
||||
];
|
||||
|
||||
async function deleteUserOwnedRows(manager: EntityManager, userId: string): Promise<Record<string, number>> {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const { tableName, userIdColumn } of USER_OWNED_TABLES) {
|
||||
const res = await manager.query(
|
||||
`DELETE FROM "${tableName}" WHERE "${userIdColumn}" = ?`,
|
||||
[userId],
|
||||
);
|
||||
counts[tableName] = Number(res?.changes ?? res?.affected ?? 0);
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AdminService {
|
||||
private readonly logger = new Logger(AdminService.name);
|
||||
|
||||
constructor(
|
||||
private readonly usersService: UsersService,
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
async listUsers(query: ListUsersQuery = {}): Promise<UserEntity[]> {
|
||||
async listUsers(query: ListUsersQueryDto = {}): Promise<AdminPlayerView[]> {
|
||||
return this.listPlayers(query);
|
||||
}
|
||||
|
||||
async listPlayers(query: ListUsersQueryDto = {}): Promise<AdminPlayerView[]> {
|
||||
const limit = Math.min(Math.max(query.limit ?? 50, 1), 200);
|
||||
const repo = this.dataSource.getRepository(UserEntity);
|
||||
const qb = repo
|
||||
@@ -34,37 +61,113 @@ export class AdminService {
|
||||
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);
|
||||
return rows.slice(0, limit).map(toPlayerView);
|
||||
}
|
||||
|
||||
async updateUserRole(targetId: string, role: UserRole): Promise<UserEntity> {
|
||||
async updateUserRole(targetId: string, role: UserRole): Promise<AdminPlayerView> {
|
||||
return this.updatePlayerRole(targetId, role);
|
||||
}
|
||||
|
||||
async updatePlayerRole(targetId: string, role: UserRole): Promise<AdminPlayerView> {
|
||||
if (role !== 'admin' && role !== 'player') {
|
||||
throw ApiError.validation('Invalid role');
|
||||
}
|
||||
const updated = await this.usersService.applyLastAdminSafeMutation<AdminPlayerView>(targetId, {
|
||||
demotesOrDelete: true,
|
||||
updatedRole: role,
|
||||
mutate: async (manager, target) => {
|
||||
target.role = role;
|
||||
await manager.save(target);
|
||||
return toPlayerView(target);
|
||||
},
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
async updatePlayerStatus(targetId: string, enabled: boolean): Promise<AdminPlayerView> {
|
||||
const target = await this.usersService.findById(targetId);
|
||||
if (!target) throw ApiError.notFound('User not found');
|
||||
const newStatus: UserStatus = enabled ? 'enabled' : 'disabled';
|
||||
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;
|
||||
user.status = newStatus;
|
||||
await manager.save(user);
|
||||
return user;
|
||||
if (!enabled) {
|
||||
await manager
|
||||
.createQueryBuilder()
|
||||
.update(RefreshTokenEntity)
|
||||
.set({ revokedAt: new Date().toISOString() })
|
||||
.where('userId = :userId', { userId: user.id })
|
||||
.andWhere('revokedAt IS NULL')
|
||||
.execute();
|
||||
}
|
||||
return toPlayerView(user);
|
||||
});
|
||||
}
|
||||
|
||||
async resetPlayerPassword(targetId: string, newPassword: string): Promise<void> {
|
||||
if (!newPassword) {
|
||||
throw ApiError.validation('New password is required');
|
||||
}
|
||||
validatePassword(newPassword, this.config);
|
||||
const target = await this.usersService.findById(targetId);
|
||||
if (!target) throw ApiError.notFound('User not found');
|
||||
const hash = await hashPassword(newPassword, this.config);
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const user = await manager.findOne(UserEntity, { where: { id: targetId } });
|
||||
if (!user) throw ApiError.notFound('User not found');
|
||||
user.passwordHash = hash;
|
||||
await manager.save(user);
|
||||
await manager
|
||||
.createQueryBuilder()
|
||||
.update(RefreshTokenEntity)
|
||||
.set({ revokedAt: new Date().toISOString() })
|
||||
.where('userId = :userId', { userId: user.id })
|
||||
.andWhere('revokedAt IS NULL')
|
||||
.execute();
|
||||
});
|
||||
}
|
||||
|
||||
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');
|
||||
return this.deletePlayer(targetId);
|
||||
}
|
||||
|
||||
async deletePlayer(targetId: string): Promise<void> {
|
||||
await this.usersService.applyLastAdminSafeMutation<null>(targetId, {
|
||||
demotesOrDelete: true,
|
||||
mutate: async (manager) => {
|
||||
// Imperative dependent cleanup in the SAME transaction. The
|
||||
// ON DELETE CASCADE migration is a safety net, but the explicit
|
||||
// deletes (a) surface the affected row counts to the application,
|
||||
// (b) keep deletes observable for future audit logging, and
|
||||
// (c) ensure that the post-mutation admin count is the only
|
||||
// invariant the caller still needs to verify.
|
||||
const cleanedCounts = await deleteUserOwnedRows(manager, targetId);
|
||||
|
||||
const res = await manager.delete(UserEntity, { id: targetId });
|
||||
if (!res.affected) {
|
||||
throw ApiError.notFound('User not found');
|
||||
}
|
||||
|
||||
// Note per-table cleanup counts; never log password hashes, token
|
||||
// hashes, or the plaintext password.
|
||||
this.logger.log(
|
||||
`Deleted user ${targetId}; dependent rows: ${Object.entries(cleanedCounts)
|
||||
.map(([t, n]) => `${t}=${n}`)
|
||||
.join(', ') || 'none'}`,
|
||||
);
|
||||
return null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async createUser(username: string, password: string, role: UserRole): Promise<UserEntity> {
|
||||
async createUser(username: string, password: string, role: UserRole): Promise<AdminPlayerView> {
|
||||
validatePassword(password, this.config);
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const created = await 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 hash = await hashPassword(password, this.config);
|
||||
const user = manager.create(UserEntity, {
|
||||
id: uuid(),
|
||||
username,
|
||||
@@ -73,7 +176,18 @@ export class AdminService {
|
||||
status: 'enabled',
|
||||
});
|
||||
await manager.save(user);
|
||||
return user;
|
||||
return toPlayerView(user);
|
||||
});
|
||||
return created;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function toPlayerView(user: UserEntity): AdminPlayerView {
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
status: user.status,
|
||||
createdAt: user.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user