🎓 Initial commit: Math2 Platform - Plataforma de Álgebra Lineal PRO
✨ Características: - 45 ejercicios universitarios (Basic → Advanced) - Renderizado LaTeX profesional - IA generativa (Z.ai/DashScope) - Docker 9 servicios - Tests 123/123 pasando - Seguridad enterprise (JWT, XSS, Rate limiting) 🐳 Infraestructura: - Next.js 14 + Node.js 20 - PostgreSQL 15 + Redis 7 - Docker Compose completo - Nginx + SSL ready 📚 Documentación: - 5 informes técnicos completos - README profesional - Scripts de deployment automatizados Estado: Producción lista ✅
This commit is contained in:
295
backend/src/modules/user/user.service.ts
Normal file
295
backend/src/modules/user/user.service.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* User Service
|
||||
*
|
||||
* Business logic for user profile management
|
||||
*/
|
||||
|
||||
import { prisma } from '../../shared/database/prisma.client';
|
||||
import { NotFoundError, ValidationError, ConflictError, AuthenticationError } from '../../shared/types';
|
||||
import { logger } from '../../shared/utils/logger';
|
||||
import type { User, UserRole } from '@prisma/client';
|
||||
import bcrypt from 'bcrypt';
|
||||
|
||||
// ============================================
|
||||
// SERVICE
|
||||
// ============================================
|
||||
|
||||
class UserService {
|
||||
/**
|
||||
* Get user profile by ID
|
||||
*/
|
||||
async getUserProfile(userId: string): Promise<User> {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
username: true,
|
||||
role: true,
|
||||
telegramChatId: true,
|
||||
isActive: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
lastLoginAt: true,
|
||||
// Exclude passwordHash for security
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundError('User');
|
||||
}
|
||||
|
||||
return user as User;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user profile
|
||||
*/
|
||||
async updateProfile(userId: string, data: {
|
||||
username?: string;
|
||||
telegramChatId?: string | null;
|
||||
}): Promise<User> {
|
||||
// Validate username uniqueness if changing
|
||||
if (data.username) {
|
||||
const existingUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
username: data.username,
|
||||
NOT: { id: userId },
|
||||
},
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
throw new ConflictError('Username already taken');
|
||||
}
|
||||
}
|
||||
|
||||
// Validate telegramChatId uniqueness if setting
|
||||
if (data.telegramChatId) {
|
||||
const existingTelegram = await prisma.user.findFirst({
|
||||
where: {
|
||||
telegramChatId: data.telegramChatId,
|
||||
NOT: { id: userId },
|
||||
},
|
||||
});
|
||||
|
||||
if (existingTelegram) {
|
||||
throw new ConflictError('Telegram chat ID already linked to another account');
|
||||
}
|
||||
}
|
||||
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
...data,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
username: true,
|
||||
role: true,
|
||||
telegramChatId: true,
|
||||
isActive: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
lastLoginAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info({ userId, updates: data }, 'User profile updated');
|
||||
|
||||
return updatedUser as User;
|
||||
}
|
||||
|
||||
/**
|
||||
* Change user password
|
||||
*/
|
||||
async changePassword(userId: string, currentPassword: string, newPassword: string): Promise<User> {
|
||||
// Validate input
|
||||
if (!currentPassword || !newPassword) {
|
||||
throw new ValidationError('Current and new password are required');
|
||||
}
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
throw new ValidationError('New password must be at least 8 characters');
|
||||
}
|
||||
|
||||
// Find user with password hash
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true,
|
||||
passwordHash: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundError('User');
|
||||
}
|
||||
|
||||
// Verify current password
|
||||
const isPasswordValid = await bcrypt.compare(currentPassword, user.passwordHash);
|
||||
if (!isPasswordValid) {
|
||||
throw new AuthenticationError('Current password is incorrect');
|
||||
}
|
||||
|
||||
// Hash the new password
|
||||
const saltRounds = 12;
|
||||
const newPasswordHash = await bcrypt.hash(newPassword, saltRounds);
|
||||
|
||||
// Update user's password
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
passwordHash: newPasswordHash,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
username: true,
|
||||
role: true,
|
||||
telegramChatId: true,
|
||||
isActive: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
lastLoginAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info({ userId }, 'Password changed successfully');
|
||||
|
||||
return updatedUser as User;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user statistics
|
||||
*/
|
||||
async getUserStats(userId: string): Promise<{
|
||||
totalExercisesCompleted: number;
|
||||
totalPoints: number;
|
||||
currentStreak: number;
|
||||
achievementsUnlocked: number;
|
||||
}> {
|
||||
// Get progress stats
|
||||
const progressStats = await prisma.progress.aggregate({
|
||||
where: { userId },
|
||||
_sum: { points: true, exercisesCompleted: true },
|
||||
_count: { _all: true },
|
||||
});
|
||||
|
||||
// Get ranking for streak info
|
||||
const globalRanking = await prisma.ranking.findFirst({
|
||||
where: { userId, moduleId: null },
|
||||
select: { streak: true },
|
||||
});
|
||||
|
||||
// Get achievements count
|
||||
const achievementsCount = await prisma.userAchievement.count({
|
||||
where: { userId, unlockedAt: { not: null } },
|
||||
});
|
||||
|
||||
return {
|
||||
totalExercisesCompleted: progressStats._sum.exercisesCompleted || 0,
|
||||
totalPoints: progressStats._sum.points || 0,
|
||||
currentStreak: globalRanking?.streak || 0,
|
||||
achievementsUnlocked: achievementsCount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all users (admin only)
|
||||
*/
|
||||
async listUsers(options?: {
|
||||
role?: UserRole;
|
||||
isActive?: boolean;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}): Promise<{ users: User[]; total: number }> {
|
||||
const page = options?.page || 1;
|
||||
const limit = options?.limit || 50;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const where: any = {};
|
||||
if (options?.role) where.role = options.role;
|
||||
if (options?.isActive !== undefined) where.isActive = options.isActive;
|
||||
|
||||
const [users, total] = await Promise.all([
|
||||
prisma.user.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
username: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
createdAt: true,
|
||||
lastLoginAt: true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
prisma.user.count({ where }),
|
||||
]);
|
||||
|
||||
return { users: users as User[], total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user role (admin only)
|
||||
*/
|
||||
async updateUserRole(userId: string, role: UserRole): Promise<User> {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundError('User');
|
||||
}
|
||||
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { role },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
username: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info({ userId, newRole: role }, 'User role updated');
|
||||
|
||||
return updatedUser as User;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivate user (admin only)
|
||||
*/
|
||||
async deactivateUser(userId: string): Promise<User> {
|
||||
const user = await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { isActive: false },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
username: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info({ userId }, 'User deactivated');
|
||||
|
||||
return user as User;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// EXPORT
|
||||
// ============================================
|
||||
|
||||
export const userService = new UserService();
|
||||
export default userService;
|
||||
Reference in New Issue
Block a user