import { prisma } from '../config/database'; import { Prisma } from '@prisma/client'; import { config } from '../config'; import { logger } from '../config/logger'; import jwt from 'jsonwebtoken'; import bcrypt from '@node-rs/bcrypt'; import { UserResponse, ProfileResponse, ProfileUpdate, ProfileUpdateResponse, ChangePasswordRequest, ChangePasswordResponse, updateAvatarRequest, updateAvatarResponse, SendVerifyEmailResponse, VerifyEmailResponse, rolesResponse } from '../types/user.types'; import nodemailer from 'nodemailer'; import { UnauthorizedError, ValidationError, ForbiddenError } from '../middleware/errorHandler'; import { uploadFile, deleteFile, getPresignedUrl } from '../config/minio'; import { auditService } from './audit.service'; import { AuditAction } from '@prisma/client'; export class UserService { async getUserProfile(token: string): Promise { try { // Decode JWT token to get user ID const decoded = jwt.verify(token, config.jwt.secret) as { id: number; username: string; email: string; roleCode: string }; const user = await prisma.user.findUnique({ where: { id: decoded.id }, include: { profile: true, role: true } }); if (!user) throw new UnauthorizedError("User not found"); // Check if account is deactivated if (user.is_deactivated) { logger.warn('Profile access attempt with deactivated account', { userId: user.id }); throw new ForbiddenError('This account has been deactivated'); } return { id: user.id, username: user.username, email: user.email, email_verified_at: user.email_verified_at, updated_at: user.updated_at, created_at: user.created_at, role: { code: user.role.code, name: user.role.name as { th: string; en: string } }, profile: user.profile ? { prefix: user.profile.prefix as { th?: string; en?: string } | undefined, first_name: user.profile.first_name, last_name: user.profile.last_name, avatar_url: user.profile.avatar_url ? await this.getAvatarPresignedUrl(user.profile.avatar_url) : null, birth_date: user.profile.birth_date, phone: user.profile.phone } : undefined }; } catch (error) { if (error instanceof jwt.JsonWebTokenError) { logger.error('Invalid JWT token:', error); throw new UnauthorizedError('Invalid token'); } if (error instanceof jwt.TokenExpiredError) { logger.error('JWT token expired:', error); throw new UnauthorizedError('Token expired'); } logger.error('Error fetching user profile:', error); throw error; } }; /** * Change user password */ async changePassword(token: string, oldPassword: string, newPassword: string): Promise { try { // Decode JWT token to get user ID const decoded = jwt.verify(token, config.jwt.secret) as { id: number; username: string; email: string; roleCode: string }; const user = await prisma.user.findUnique({ where: { id: decoded.id } }); if (!user) throw new UnauthorizedError('User not found'); // Check if account is deactivated if (user.is_deactivated) { logger.warn('Password change attempt with deactivated account', { userId: user.id }); throw new ForbiddenError('This account has been deactivated'); } // Verify old password const isPasswordValid = await bcrypt.compare(oldPassword, user.password); if (!isPasswordValid) throw new UnauthorizedError('Invalid old password'); // Hash new password const encryptedPassword = await bcrypt.hash(newPassword, 10); // Update password await prisma.user.update({ where: { id: user.id }, data: { password: encryptedPassword } }); logger.info('Password changed successfully', { userId: user.id }); // Audit log - CHANGE_PASSWORD auditService.log({ userId: user.id, action: AuditAction.CHANGE_PASSWORD, entityType: 'User', entityId: user.id, metadata: { email: user.email } }); return { code: 200, message: 'Password changed successfully' }; } catch (error) { if (error instanceof jwt.JsonWebTokenError) { logger.error('Invalid JWT token:', error); throw new UnauthorizedError('Invalid token'); } if (error instanceof jwt.TokenExpiredError) { logger.error('JWT token expired:', error); throw new UnauthorizedError('Token expired'); } logger.error('Failed to change password', { error }); const decoded = jwt.decode(token) as { id: number } | null; await auditService.logSync({ userId: decoded?.id || 0, action: AuditAction.ERROR, entityType: 'User', entityId: decoded?.id || 0, metadata: { operation: 'change_password', error: error instanceof Error ? error.message : String(error) } }); throw error; } } /** * Update user profile */ async updateProfile(token: string, profile: ProfileUpdate): Promise { try { // Decode JWT token to get user ID const decoded = jwt.verify(token, config.jwt.secret) as { id: number; username: string; email: string; roleCode: string }; const user = await prisma.user.findUnique({ where: { id: decoded.id } }); if (!user) throw new UnauthorizedError('User not found'); // Check if account is deactivated if (user.is_deactivated) { logger.warn('Profile update attempt with deactivated account', { userId: user.id }); throw new ForbiddenError('This account has been deactivated'); } // Update profile const updatedProfile = await prisma.userProfile.update({ where: { user_id: user.id }, data: profile }); logger.info('Profile updated successfully', { userId: user.id }); return { code: 200, message: 'Profile updated successfully', data: { id: updatedProfile.id, prefix: updatedProfile.prefix as { th?: string; en?: string } | undefined, first_name: updatedProfile.first_name, last_name: updatedProfile.last_name, avatar_url: updatedProfile.avatar_url, phone: updatedProfile.phone, birth_date: updatedProfile.birth_date } }; } catch (error) { if (error instanceof jwt.JsonWebTokenError) { logger.error('Invalid JWT token:', error); throw new UnauthorizedError('Invalid token'); } if (error instanceof jwt.TokenExpiredError) { logger.error('JWT token expired:', error); throw new UnauthorizedError('Token expired'); } logger.error('Failed to update profile', { error }); const decoded = jwt.decode(token) as { id: number } | null; await auditService.logSync({ userId: decoded?.id || 0, action: AuditAction.UPDATE, entityType: 'UserProfile', entityId: decoded?.id || 0, metadata: { operation: 'update_profile', error: error instanceof Error ? error.message : String(error) } }); throw error; } } async getRoles(token: string): Promise { try { jwt.verify(token, config.jwt.secret); const roles = await prisma.role.findMany({ select: { id: true, code: true } }); return { roles }; } catch (error) { if (error instanceof jwt.TokenExpiredError) { logger.error('JWT token expired:', error); throw new UnauthorizedError('Token expired'); } if (error instanceof jwt.JsonWebTokenError) { logger.error('Invalid JWT token:', error); throw new UnauthorizedError('Invalid token'); } logger.error('Failed to get roles', { error }); throw error; } } /** * Upload avatar picture to MinIO */ async uploadAvatarPicture(token: string, file: Express.Multer.File): Promise { try { const decoded = jwt.verify(token, config.jwt.secret) as { id: number }; // Check if user exists const user = await prisma.user.findUnique({ where: { id: decoded.id }, include: { profile: true } }); if (!user) { throw new UnauthorizedError('User not found'); } // Check if account is deactivated if (user.is_deactivated) { throw new ForbiddenError('This account has been deactivated'); } // Generate unique filename const timestamp = Date.now(); const uniqueId = Math.random().toString(36).substring(2, 15); const fileName = file.originalname || 'avatar'; const extension = fileName.split('.').pop() || 'jpg'; const safeFilename = `${timestamp}-${uniqueId}.${extension}`; const filePath = `avatars/${decoded.id}/${safeFilename}`; // Delete old avatar if exists if (user.profile?.avatar_url) { try { await deleteFile(user.profile.avatar_url); logger.info(`Deleted old avatar: ${user.profile.avatar_url}`); } catch (error) { logger.warn(`Failed to delete old avatar: ${error}`); // Continue with upload even if delete fails } } // Upload to MinIO await uploadFile(filePath, file.buffer, file.mimetype || 'image/jpeg'); logger.info(`Uploaded avatar: ${filePath}`); // Update or create profile - store only file path if (user.profile) { await prisma.userProfile.update({ where: { user_id: decoded.id }, data: { avatar_url: filePath } }); } else { await prisma.userProfile.create({ data: { user_id: decoded.id, avatar_url: filePath, first_name: '', last_name: '' } }); } // Audit log - UPLOAD_AVATAR await auditService.logSync({ userId: decoded.id, action: AuditAction.UPLOAD_FILE, entityType: 'User', entityId: decoded.id, metadata: { operation: 'upload_avatar', filePath } }); // Generate presigned URL for response const presignedUrl = await this.getAvatarPresignedUrl(filePath); return { code: 200, message: 'Avatar uploaded successfully', data: { id: decoded.id, avatar_url: presignedUrl } }; } catch (error) { if (error instanceof jwt.JsonWebTokenError) { logger.error('Invalid JWT token:', error); throw new UnauthorizedError('Invalid token'); } if (error instanceof jwt.TokenExpiredError) { logger.error('JWT token expired:', error); throw new UnauthorizedError('Token expired'); } logger.error('Failed to upload avatar', { error }); const decoded = jwt.decode(token) as { id: number } | null; await auditService.logSync({ userId: decoded?.id || 0, action: AuditAction.UPLOAD_FILE, entityType: 'UserProfile', entityId: decoded?.id || 0, metadata: { operation: 'upload_avatar', error: error instanceof Error ? error.message : String(error) } }); throw error; } } /** * Get presigned URL for avatar */ private async getAvatarPresignedUrl(avatarPath: string): Promise { try { // avatarPath is now stored as file path directly (e.g., avatars/1/filename.jpg) return await getPresignedUrl(avatarPath, 3600); // 1 hour expiry } catch (error) { logger.warn(`Failed to generate presigned URL for avatar: ${error}`); throw error; } } /** * Format user response */ private formatUserResponse(user: any): UserResponse { return { id: user.id, username: user.username, email: user.email, email_verified_at: user.email_verified_at, updated_at: user.updated_at, created_at: user.created_at, role: { code: user.role.code, name: user.role.name }, profile: user.profile ? { prefix: user.profile.prefix, first_name: user.profile.first_name, last_name: user.profile.last_name, avatar_url: user.profile.avatar_url, phone: user.profile.phone, birth_date: user.profile.birth_date } : undefined }; } /** * Send verification email to user */ async sendVerifyEmail(token: string): Promise { try { const decoded = jwt.verify(token, config.jwt.secret) as { id: number; email: string; roleCode: string }; const user = await prisma.user.findUnique({ where: { id: decoded.id }, include: { role: true } }); if (!user) throw new UnauthorizedError('User not found'); if (user.email_verified_at) throw new ValidationError('Email already verified'); // Generate verification token (expires in 24 hours) const verifyToken = jwt.sign( { id: user.id, email: user.email, type: 'email_verify' }, config.jwt.secret, { expiresIn: '24h' } ); // Create verification URL based on role const isInstructor = user.role.code === 'INSTRUCTOR'; const baseUrl = isInstructor ? (process.env.FRONTEND_URL_INSTRUCTOR || 'http://localhost:3001') : (process.env.FRONTEND_URL_STUDENT || 'http://localhost:3000'); const verifyURL = `${baseUrl}/verify-email?token=${verifyToken}`; // Create transporter const transporter = nodemailer.createTransport({ host: config.smtp.host, port: config.smtp.port, secure: config.smtp.secure, ...(config.smtp.user && config.smtp.pass && { auth: { user: config.smtp.user, pass: config.smtp.pass } }) }); // Send email const mailOptions = { from: config.smtp.from, to: user.email, subject: 'Email Verification - E-Learning Platform', text: `Please verify your email address by clicking on the following link:\n\n${verifyURL}\n\nThis link will expire in 24 hours.\n\nIf you did not create an account, please ignore this email.\n`, html: `

Email Verification

Thank you for registering with E-Learning Platform.

Please click on the following link to verify your email address:

${verifyURL}

This link will expire in 24 hours.

If you did not create an account, please ignore this email.

` }; await transporter.sendMail(mailOptions); logger.info('Verification email sent', { email: user.email }); return { code: 200, message: 'Verification email sent successfully' }; } catch (error) { if (error instanceof jwt.JsonWebTokenError) throw new UnauthorizedError('Invalid token'); if (error instanceof jwt.TokenExpiredError) throw new UnauthorizedError('Token expired'); logger.error('Failed to send verification email', { error }); const decoded = jwt.decode(token) as { id: number } | null; await auditService.logSync({ userId: decoded?.id || 0, action: AuditAction.ERROR, entityType: 'UserProfile', entityId: decoded?.id || 0, metadata: { operation: 'send_verification_email', error: error instanceof Error ? error.message : String(error) } }); throw error; } } /** * Verify email with token */ async verifyEmail(verifyToken: string): Promise { try { const decoded = jwt.verify(verifyToken, config.jwt.secret) as { id: number; email: string; type: string }; if (decoded.type !== 'email_verify') { throw new UnauthorizedError('Invalid verification token'); } const user = await prisma.user.findUnique({ where: { id: decoded.id } }); if (!user) throw new UnauthorizedError('User not found'); if (user.email_verified_at) throw new ValidationError('Email already verified'); // Update email_verified_at await prisma.user.update({ where: { id: user.id }, data: { email_verified_at: new Date() } }); logger.info('Email verified successfully', { userId: user.id, email: user.email }); await auditService.logSync({ userId: user.id, action: AuditAction.VERIFY_EMAIL, entityType: 'UserProfile', entityId: user.id, metadata: { operation: 'verify_email' } }); return { code: 200, message: 'Email verified successfully' }; } catch (error) { if (error instanceof jwt.JsonWebTokenError) throw new UnauthorizedError('Invalid verification token'); if (error instanceof jwt.TokenExpiredError) throw new UnauthorizedError('Verification link has expired'); logger.error('Failed to verify email', { error }); const decoded = jwt.decode(verifyToken) as { id: number } | null; await auditService.logSync({ userId: decoded?.id || 0, action: AuditAction.ERROR, entityType: 'UserProfile', entityId: decoded?.id || 0, metadata: { operation: 'verify_email', error: error instanceof Error ? error.message : String(error) } }); throw error; } } }