235 lines
7.8 KiB
TypeScript
235 lines
7.8 KiB
TypeScript
import { Injectable } from '@nestjs/common';
|
|
import * as bcrypt from 'bcryptjs';
|
|
import { randomBytes } from 'crypto';
|
|
import { PrismaService } from '../../shared/prisma/prisma.service';
|
|
import { appBadRequest, appNotFound } from '../../shared/common/app-error';
|
|
import { ensureUserInviteCode } from '../../shared/common/invite-code.util';
|
|
|
|
const STAFF_ROLE_CODES = ['SUPER_ADMIN', 'MATCH_ADMIN', 'FINANCE_ADMIN', 'SUPPORT'] as const;
|
|
|
|
function generatePassword(length = 10): string {
|
|
const raw = randomBytes(12).toString('base64url').replace(/[^a-zA-Z0-9]/g, '');
|
|
const base = (raw + 'Aa1').slice(0, Math.max(8, length));
|
|
return base;
|
|
}
|
|
|
|
@Injectable()
|
|
export class AdminStaffService {
|
|
constructor(private prisma: PrismaService) {}
|
|
|
|
async listRoles() {
|
|
const roles = await this.prisma.role.findMany({
|
|
where: { code: { in: [...STAFF_ROLE_CODES] } },
|
|
orderBy: { code: 'asc' },
|
|
select: { id: true, code: true, name: true },
|
|
});
|
|
return roles.map((r) => ({
|
|
id: r.id.toString(),
|
|
code: r.code,
|
|
name: r.name,
|
|
}));
|
|
}
|
|
|
|
async listStaff(page = 1, pageSize = 20, keyword?: string) {
|
|
const where = {
|
|
userType: 'ADMIN' as const,
|
|
deletedAt: null,
|
|
...(keyword?.trim()
|
|
? { username: { contains: keyword.trim(), mode: 'insensitive' as const } }
|
|
: {}),
|
|
};
|
|
const [total, items] = await Promise.all([
|
|
this.prisma.user.count({ where }),
|
|
this.prisma.user.findMany({
|
|
where,
|
|
skip: (page - 1) * pageSize,
|
|
take: pageSize,
|
|
orderBy: { createdAt: 'desc' },
|
|
include: {
|
|
auth: { select: { lastLoginAt: true } },
|
|
adminRole: { include: { role: { select: { code: true, name: true } } } },
|
|
},
|
|
}),
|
|
]);
|
|
return {
|
|
total,
|
|
items: items.map((u) => ({
|
|
id: u.id.toString(),
|
|
username: u.username,
|
|
status: u.status,
|
|
role: u.adminRole?.role?.code ?? null,
|
|
roleName: u.adminRole?.role?.name ?? null,
|
|
lastLoginAt: u.auth?.lastLoginAt ?? null,
|
|
createdAt: u.createdAt,
|
|
visibleMenus: u.visibleMenus,
|
|
})),
|
|
};
|
|
}
|
|
|
|
async createStaff(data: { username: string; password: string; roleCode: string; visibleMenus?: string }) {
|
|
const username = data.username.trim();
|
|
if (!username) throw appBadRequest('USERNAME_REQUIRED');
|
|
if (data.password.length < 8) throw appBadRequest('PASSWORD_MIN_LENGTH');
|
|
if (!STAFF_ROLE_CODES.includes(data.roleCode as (typeof STAFF_ROLE_CODES)[number])) {
|
|
throw appBadRequest('INVALID_ROLE');
|
|
}
|
|
|
|
const existing = await this.prisma.user.findUnique({ where: { username } });
|
|
if (existing) throw appBadRequest('USERNAME_TAKEN');
|
|
|
|
const role = await this.prisma.role.findUnique({ where: { code: data.roleCode } });
|
|
if (!role) throw appBadRequest('INVALID_ROLE');
|
|
|
|
const hash = await bcrypt.hash(data.password, 10);
|
|
const user = await this.prisma.user.create({
|
|
data: {
|
|
username,
|
|
userType: 'ADMIN',
|
|
auth: { create: { passwordHash: hash } },
|
|
adminRole: { create: { roleId: role.id } },
|
|
visibleMenus: data.visibleMenus,
|
|
},
|
|
include: {
|
|
adminRole: { include: { role: { select: { code: true, name: true } } } },
|
|
},
|
|
});
|
|
await ensureUserInviteCode(this.prisma, user.id);
|
|
|
|
return {
|
|
id: user.id.toString(),
|
|
username: user.username,
|
|
status: user.status,
|
|
role: user.adminRole?.role?.code ?? null,
|
|
roleName: user.adminRole?.role?.name ?? null,
|
|
visibleMenus: user.visibleMenus,
|
|
};
|
|
}
|
|
|
|
async updateStaff(
|
|
staffId: bigint,
|
|
data: { status?: string; roleCode?: string; password?: string; visibleMenus?: string },
|
|
) {
|
|
const user = await this.prisma.user.findFirst({
|
|
where: { id: staffId, userType: 'ADMIN', deletedAt: null },
|
|
include: { auth: true, adminRole: true },
|
|
});
|
|
if (!user) throw appNotFound('USER_NOT_FOUND');
|
|
|
|
if (data.status !== undefined) {
|
|
if (!['ACTIVE', 'SUSPENDED', 'DISABLED'].includes(data.status)) {
|
|
throw appBadRequest('INVALID_STATUS');
|
|
}
|
|
await this.prisma.user.update({
|
|
where: { id: staffId },
|
|
data: { status: data.status },
|
|
});
|
|
}
|
|
|
|
if (data.roleCode !== undefined) {
|
|
if (!STAFF_ROLE_CODES.includes(data.roleCode as (typeof STAFF_ROLE_CODES)[number])) {
|
|
throw appBadRequest('INVALID_ROLE');
|
|
}
|
|
const role = await this.prisma.role.findUnique({ where: { code: data.roleCode } });
|
|
if (!role) throw appBadRequest('INVALID_ROLE');
|
|
if (user.adminRole) {
|
|
await this.prisma.adminUserRole.update({
|
|
where: { userId: staffId },
|
|
data: { roleId: role.id },
|
|
});
|
|
} else {
|
|
await this.prisma.adminUserRole.create({
|
|
data: { userId: staffId, roleId: role.id },
|
|
});
|
|
}
|
|
}
|
|
|
|
if (data.visibleMenus !== undefined) {
|
|
await this.prisma.user.update({
|
|
where: { id: staffId },
|
|
data: { visibleMenus: data.visibleMenus },
|
|
});
|
|
}
|
|
|
|
let plainPassword: string | undefined;
|
|
if (data.password !== undefined) {
|
|
if (data.password.length < 8) throw appBadRequest('PASSWORD_MIN_LENGTH');
|
|
if (!user.auth) throw appBadRequest('AUTH_INFO_MISSING');
|
|
plainPassword = data.password;
|
|
const hash = await bcrypt.hash(data.password, 10);
|
|
await this.prisma.userAuth.update({
|
|
where: { userId: staffId },
|
|
data: { passwordHash: hash, loginFailCount: 0, lockedUntil: null },
|
|
});
|
|
}
|
|
|
|
const refreshed = await this.prisma.user.findUnique({
|
|
where: { id: staffId },
|
|
include: { adminRole: { include: { role: { select: { code: true, name: true } } } } },
|
|
});
|
|
|
|
return {
|
|
id: refreshed!.id.toString(),
|
|
username: refreshed!.username,
|
|
status: refreshed!.status,
|
|
role: refreshed!.adminRole?.role?.code ?? null,
|
|
roleName: refreshed!.adminRole?.role?.name ?? null,
|
|
visibleMenus: refreshed!.visibleMenus,
|
|
...(plainPassword ? { password: plainPassword } : {}),
|
|
};
|
|
}
|
|
|
|
async deleteStaff(staffId: bigint, operatorId?: bigint) {
|
|
if (operatorId && staffId === operatorId) {
|
|
throw appBadRequest('CANNOT_DELETE_SELF');
|
|
}
|
|
const user = await this.prisma.user.findFirst({
|
|
where: { id: staffId, userType: 'ADMIN', deletedAt: null },
|
|
include: { adminRole: { include: { role: true } } },
|
|
});
|
|
if (!user) throw appNotFound('STAFF_NOT_FOUND');
|
|
|
|
if (user.adminRole?.role?.code === 'SUPER_ADMIN') {
|
|
const superAdminCount = await this.prisma.user.count({
|
|
where: {
|
|
userType: 'ADMIN',
|
|
deletedAt: null,
|
|
adminRole: { role: { code: 'SUPER_ADMIN' } },
|
|
},
|
|
});
|
|
if (superAdminCount <= 1) {
|
|
throw appBadRequest('CANNOT_DELETE_LAST_SUPER_ADMIN');
|
|
}
|
|
}
|
|
|
|
return this.prisma.user.update({
|
|
where: { id: staffId },
|
|
data: { deletedAt: new Date(), status: 'DISABLED' },
|
|
});
|
|
}
|
|
|
|
async resetPlayerPassword(playerId: bigint, password?: string) {
|
|
const user = await this.prisma.user.findFirst({
|
|
where: { id: playerId, userType: 'PLAYER', deletedAt: null },
|
|
include: { auth: true },
|
|
});
|
|
if (!user) throw appNotFound('PLAYER_NOT_FOUND');
|
|
if (!user.auth) throw appBadRequest('AUTH_INFO_MISSING');
|
|
|
|
const nextPassword = password?.trim() || generatePassword();
|
|
if (nextPassword.length < 8) throw appBadRequest('PASSWORD_MIN_LENGTH');
|
|
|
|
const hash = await bcrypt.hash(nextPassword, 10);
|
|
await this.prisma.userAuth.update({
|
|
where: { userId: playerId },
|
|
data: { passwordHash: hash, loginFailCount: 0, lockedUntil: null },
|
|
});
|
|
await this.prisma.userPreference.upsert({
|
|
where: { userId: playerId },
|
|
create: { userId: playerId, managedPassword: nextPassword },
|
|
update: { managedPassword: nextPassword },
|
|
});
|
|
|
|
return { password: nextPassword };
|
|
}
|
|
}
|