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 }; } }