✨ 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 ✅
295 lines
7.0 KiB
TypeScript
295 lines
7.0 KiB
TypeScript
/**
|
|
* 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; |