194 lines
7.3 KiB
TypeScript
194 lines
7.3 KiB
TypeScript
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<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: 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
|
|
.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<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) => {
|
|
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<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.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<AdminPlayerView> {
|
|
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,
|
|
};
|
|
}
|