import { Injectable, Logger } from '@nestjs/common'; import { DataSource, EntityManager } from 'typeorm'; import { v4 as uuid } from 'uuid'; 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'; /** * 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> { const counts: Record = {}; 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: ListUsersQueryDto = {}): Promise { return this.listPlayers(query); } async listPlayers(query: ListUsersQueryDto = {}): Promise { 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).map(toPlayerView); } async updateUserRole(targetId: string, role: UserRole): Promise { return this.updatePlayerRole(targetId, role); } async updatePlayerRole(targetId: string, role: UserRole): Promise { if (role !== 'admin' && role !== 'player') { throw ApiError.validation('Invalid role'); } const updated = await this.usersService.applyLastAdminSafeMutation(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 { 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) => { const user = await manager.findOne(UserEntity, { where: { id: targetId } }); if (!user) throw ApiError.notFound('User not found'); user.status = newStatus; await manager.save(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 { 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 { return this.deletePlayer(targetId); } async deletePlayer(targetId: string): Promise { await this.usersService.applyLastAdminSafeMutation(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 { validatePassword(password, this.config); 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 hashPassword(password, this.config); const user = manager.create(UserEntity, { id: uuid(), username, passwordHash: hash, role, status: 'enabled', }); await manager.save(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, }; }