import { Injectable, BadRequestException } from '@nestjs/common'; import * as bcrypt from 'bcryptjs'; import { SUPPORTED_LOCALES, isValidAvatarKey, assertPlayerUsername } from '@thebet365/shared'; import { PrismaService } from '../../shared/prisma/prisma.service'; import { SystemConfigService } from '../../shared/config/system-config.service'; import { AgentsService } from '../agent/agents.service'; import { CashbackService } from '../operations/cashback/cashback.service'; import { PresenceService } from '../presence/presence.service'; import { appBadRequest, appForbidden, appNotFound } from '../../shared/common/app-error'; import { Decimal } from '@prisma/client/runtime/library'; export type PlayerListFilters = { keyword?: string; parentId?: bigint; platformDirect?: boolean; status?: string; }; @Injectable() export class UsersService { constructor( private prisma: PrismaService, private agents: AgentsService, private systemConfig: SystemConfigService, private cashback: CashbackService, private presence: PresenceService, ) {} private buildAffiliationAgents( parent?: { username: string; agentLevel: number | null; parent?: { username: string; agentLevel: number | null; parent?: unknown } | null; } | null, chain?: string[], ): string[] { if (chain?.length) return chain; if (!parent) return []; const ancestors: string[] = []; let cur: typeof parent | null | undefined = parent; while (cur) { ancestors.unshift(cur.username); cur = cur.parent as typeof parent | null | undefined; } return ancestors; } private async buildAffiliationChainMap(parentIds: (bigint | null | undefined)[]) { const map = new Map(); const pending = new Set(); const cache = new Map(); for (const pid of parentIds) { if (pid) pending.add(pid); } while (pending.size > 0) { const batch = [...pending]; pending.clear(); const agents = await this.prisma.user.findMany({ where: { id: { in: batch }, userType: 'AGENT', deletedAt: null }, select: { id: true, username: true, parentId: true }, }); for (const agent of agents) { cache.set(agent.id.toString(), { username: agent.username, parentId: agent.parentId }); if (agent.parentId && !cache.has(agent.parentId.toString())) { pending.add(agent.parentId); } } } const build = (startId: bigint | null | undefined): string[] => { const chain: string[] = []; let cur = startId ?? null; while (cur) { const hit = cache.get(cur.toString()); if (!hit) break; chain.unshift(hit.username); cur = hit.parentId; } return chain; }; for (const pid of parentIds) { if (pid) map.set(pid.toString(), build(pid)); } return map; } private formatPlayerRow( u: { id: bigint; username: string; status: string; locale: string; parentId: bigint | null; createdAt: Date; updatedAt: Date; wallet?: { availableBalance: { toString(): string }; frozenBalance: { toString(): string } } | null; preferences?: { phone: string | null; email: string | null; managedPassword?: string | null; } | null; parent?: { username: string; agentLevel: number | null; parent?: { username: string; agentLevel: number | null } | null; } | null; auth?: { lastLoginAt: Date | null } | null; usedInvite?: { code: string } | null; }, bet?: { count: number; totalStake: string; totalReturn: string }, affiliationChain?: string[], ) { const affiliationAgents = this.buildAffiliationAgents(u.parent, affiliationChain); return { id: u.id.toString(), username: u.username, status: u.status, locale: u.locale, parentId: u.parentId?.toString() ?? null, parentUsername: u.parent?.username ?? null, affiliationAgents, inviteCode: u.usedInvite?.code ?? null, phone: u.preferences?.phone ?? null, email: u.preferences?.email ?? null, managedPassword: u.preferences?.managedPassword ?? null, availableBalance: u.wallet?.availableBalance?.toString() ?? '0', frozenBalance: u.wallet?.frozenBalance?.toString() ?? '0', lastLoginAt: u.auth?.lastLoginAt ?? null, betCount: bet?.count ?? 0, totalStake: bet?.totalStake ?? '0', totalReturn: bet?.totalReturn ?? '0', createdAt: u.createdAt, updatedAt: u.updatedAt, }; } private async loadBetStatsMap(userIds: bigint[]) { if (userIds.length === 0) return new Map(); const groups = await this.prisma.bet.groupBy({ by: ['userId'], where: { userId: { in: userIds } }, _count: { _all: true }, _sum: { stake: true, actualReturn: true }, }); return new Map( groups.map((g) => [ g.userId.toString(), { count: g._count._all, totalStake: g._sum.stake?.toString() ?? '0', totalReturn: g._sum.actualReturn?.toString() ?? '0', }, ]), ); } async findById(id: bigint) { return this.prisma.user.findUnique({ where: { id }, include: { wallet: true, agentProfile: true, preferences: true }, }); } async updateProfile( userId: bigint, data: { phone?: string; email?: string; avatarKey?: string | null; username?: string }, ) { const user = await this.prisma.user.findUnique({ where: { id: userId }, include: { preferences: true }, }); if (!user) throw appNotFound('USER_NOT_FOUND'); if (data.username !== undefined) { const nextUsername = data.username.trim(); if (!nextUsername) throw appBadRequest('USERNAME_REQUIRED'); try { assertPlayerUsername(nextUsername); } catch { throw appBadRequest('USERNAME_FORMAT_INVALID'); } const settings = await this.systemConfig.getPlayerAccountSettings(); if (!settings.allowUsernameChange) { throw appForbidden('USERNAME_CHANGE_DISABLED'); } if (nextUsername !== user.username) { const taken = await this.prisma.user.findUnique({ where: { username: nextUsername } }); if (taken) throw appBadRequest('USERNAME_TAKEN'); await this.prisma.user.update({ where: { id: userId }, data: { username: nextUsername }, }); } } const phone = data.phone !== undefined ? data.phone.trim() || null : undefined; const email = data.email !== undefined ? data.email.trim() || null : undefined; let avatarKey: string | null | undefined; if (data.avatarKey !== undefined) { avatarKey = data.avatarKey?.trim() || null; if (avatarKey && !isValidAvatarKey(avatarKey)) { throw appBadRequest('INVALID_AVATAR'); } } const existing = await this.prisma.userPreference.findUnique({ where: { userId } }); if (!existing && phone === undefined && email === undefined && avatarKey === undefined) { return this.findById(userId); } await this.prisma.userPreference.upsert({ where: { userId }, create: { userId, phone: phone ?? null, email: email ?? null, ...(avatarKey !== undefined ? { avatarKey } : {}), }, update: { ...(phone !== undefined ? { phone } : {}), ...(email !== undefined ? { email } : {}), ...(avatarKey !== undefined ? { avatarKey } : {}), }, }); return this.findById(userId); } async updateLocale(userId: bigint, locale: string) { if (!(SUPPORTED_LOCALES as readonly string[]).includes(locale)) { throw appBadRequest('UNSUPPORTED_LOCALE'); } await this.prisma.user.update({ where: { id: userId }, data: { locale }, }); await this.prisma.userPreference.upsert({ where: { userId }, create: { userId, locale }, update: { locale }, }); return { locale }; } async listPlayers( page = 1, pageSize = 10, filters: PlayerListFilters = {}, ) { const where: { userType: string; deletedAt: null; parentId?: bigint | null; status?: string; OR?: { username?: { contains: string; mode: 'insensitive' } }[]; } = { userType: 'PLAYER', deletedAt: null, }; if (filters.platformDirect) { where.parentId = null; } else if (filters.parentId) { where.parentId = filters.parentId; } if (filters.status) where.status = filters.status; if (filters.keyword?.trim()) { const kw = filters.keyword.trim(); where.OR = [{ username: { contains: kw, mode: 'insensitive' } }]; } const skip = (page - 1) * pageSize; const [rows, total] = await Promise.all([ this.prisma.user.findMany({ where, include: { wallet: true, preferences: true, parent: { select: { id: true, username: true, agentLevel: true, parent: { select: { username: true, agentLevel: true } }, }, }, auth: { select: { lastLoginAt: true } }, usedInvite: { select: { code: true } }, }, skip, take: pageSize, orderBy: { createdAt: 'desc' }, }), this.prisma.user.count({ where }), ]); const betMap = await this.loadBetStatsMap(rows.map((r) => r.id)); const affiliationMap = await this.buildAffiliationChainMap(rows.map((r) => r.parentId)); const onlineSet = await this.presence.filterOnlineIds(rows.map((r) => r.id)); return { items: rows.map((u) => { const row = this.formatPlayerRow( u, betMap.get(u.id.toString()), u.parentId ? affiliationMap.get(u.parentId.toString()) : undefined, ); return { ...row, isOnline: onlineSet.has(row.id), }; }), total, page, pageSize, }; } async getPlayerAdminDetail(playerId: bigint) { const user = await this.prisma.user.findFirst({ where: { id: playerId, userType: 'PLAYER', deletedAt: null }, include: { wallet: true, preferences: true, parent: { select: { id: true, username: true, agentLevel: true, parent: { select: { username: true, agentLevel: true } }, }, }, auth: { select: { lastLoginAt: true, loginFailCount: true, lockedUntil: true } }, usedInvite: { select: { code: true } }, }, }); if (!user) throw appNotFound('PLAYER_NOT_FOUND'); const affiliationMap = await this.buildAffiliationChainMap([user.parentId]); const [betCount, betStake, customCashbackRate, defaultCashbackRate] = await Promise.all([ this.prisma.bet.count({ where: { userId: playerId } }), this.prisma.bet.aggregate({ where: { userId: playerId }, _sum: { stake: true, actualReturn: true }, }), this.cashback.getPlayerCustomCashbackRate(playerId), this.cashback.resolvePlayerDefaultCashbackRate({ parentId: user.parentId, inviteSponsorId: user.inviteSponsorId, }), ]); return { ...this.formatPlayerRow( user, undefined, user.parentId ? affiliationMap.get(user.parentId.toString()) : undefined, ), lastLoginAt: user.auth?.lastLoginAt ?? null, loginFailCount: user.auth?.loginFailCount ?? 0, lockedUntil: user.auth?.lockedUntil ?? null, betCount, totalStake: betStake._sum.stake?.toString() ?? '0', totalReturn: betStake._sum.actualReturn?.toString() ?? '0', customCashbackRate: customCashbackRate?.toString() ?? null, defaultCashbackRate: defaultCashbackRate.toString(), }; } async updatePlayerAdmin( playerId: bigint, data: { status?: string; locale?: string; phone?: string; email?: string; username?: string; password?: string; cashbackRate?: number | null; }, ) { 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 (data.status && !['ACTIVE', 'SUSPENDED'].includes(data.status)) { throw appBadRequest('INVALID_STATUS'); } if (data.username !== undefined) { const nextUsername = data.username.trim(); if (!nextUsername) throw appBadRequest('USERNAME_REQUIRED'); try { assertPlayerUsername(nextUsername); } catch { throw appBadRequest('USERNAME_FORMAT_INVALID'); } if (nextUsername !== user.username) { const taken = await this.prisma.user.findUnique({ where: { username: nextUsername } }); if (taken) throw appBadRequest('USERNAME_TAKEN'); await this.prisma.user.update({ where: { id: playerId }, data: { username: nextUsername }, }); } } if (data.password !== undefined) { const nextPassword = data.password; if (nextPassword.length < 8) throw appBadRequest('PASSWORD_MIN_LENGTH'); if (!user.auth) throw appBadRequest('AUTH_INFO_MISSING'); 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 }, }); } if (data.status) { await this.prisma.user.update({ where: { id: playerId }, data: { status: data.status }, }); } if (data.locale) { await this.prisma.user.update({ where: { id: playerId }, data: { locale: data.locale }, }); } const prefPatch: { locale?: string; phone?: string | null; email?: string | null; } = {}; if (data.locale) prefPatch.locale = data.locale; if (data.phone !== undefined) prefPatch.phone = data.phone.trim() || null; if (data.email !== undefined) prefPatch.email = data.email.trim() || null; if (Object.keys(prefPatch).length > 0) { await this.prisma.userPreference.upsert({ where: { userId: playerId }, create: { userId: playerId, locale: data.locale ?? user.locale, phone: prefPatch.phone ?? null, email: prefPatch.email ?? null, }, update: prefPatch, }); } if (data.cashbackRate !== undefined) { if (data.cashbackRate != null && (!Number.isFinite(data.cashbackRate) || data.cashbackRate < 0)) { throw appBadRequest('CASHBACK_RATE_NEGATIVE'); } const rate = data.cashbackRate != null && data.cashbackRate > 0 ? new Decimal(data.cashbackRate) : null; await this.cashback.setPlayerCustomCashbackRate(playerId, rate); } return this.getPlayerAdminDetail(playerId); } async getPlayerAccountPermissions() { return this.systemConfig.getPlayerAccountSettings(); } async clearManagedPassword(userId: bigint) { const pref = await this.prisma.userPreference.findUnique({ where: { userId } }); if (pref?.managedPassword) { await this.prisma.userPreference.update({ where: { userId }, data: { managedPassword: null }, }); } } async softDeletePlayer(playerId: bigint) { const user = await this.prisma.user.findFirst({ where: { id: playerId, deletedAt: null }, }); if (!user) throw appNotFound('USER_NOT_FOUND'); if (user.userType !== 'PLAYER') { throw appBadRequest('NOT_PLAYER'); } // Block deletion when the player has any unresolved bets const betCount = await this.prisma.bet.count({ where: { userId: playerId, status: 'PENDING', }, }); if (betCount > 0) { throw appBadRequest('PLAYER_HAS_PENDING_BETS'); } // Block deletion when wallet still has balance const wallet = await this.prisma.wallet.findUnique({ where: { userId: playerId } }); if (wallet) { const available = new Decimal(wallet.availableBalance); const frozen = new Decimal(wallet.frozenBalance); if (available.gt(0) || frozen.gt(0)) { throw appBadRequest('PLAYER_HAS_BALANCE'); } } return this.prisma.user.update({ where: { id: playerId }, data: { deletedAt: new Date(), status: 'SUSPENDED' }, }); } }