2026-01-13 17:55:00 +07:00
import { prisma } from '../config/database' ;
import { Prisma } from '@prisma/client' ;
import { config } from '../config' ;
import { logger } from '../config/logger' ;
import jwt from 'jsonwebtoken' ;
2026-01-14 14:06:09 +07:00
import bcrypt from 'bcrypt' ;
2026-01-13 17:55:00 +07:00
import {
UserResponse ,
ProfileResponse ,
ProfileUpdate ,
ProfileUpdateResponse ,
ChangePasswordRequest ,
2026-01-28 11:49:11 +07:00
ChangePasswordResponse ,
updateAvatarRequest ,
2026-01-30 14:53:50 +07:00
updateAvatarResponse ,
SendVerifyEmailResponse ,
VerifyEmailResponse
2026-01-13 17:55:00 +07:00
} from '../types/user.types' ;
2026-01-30 14:53:50 +07:00
import nodemailer from 'nodemailer' ;
2026-01-13 17:55:00 +07:00
import { UnauthorizedError , ValidationError , ForbiddenError } from '../middleware/errorHandler' ;
2026-01-28 11:49:11 +07:00
import { uploadFile , deleteFile , getPresignedUrl } from '../config/minio' ;
2026-01-13 17:55:00 +07:00
export class UserService {
async getUserProfile ( token : string ) : Promise < UserResponse > {
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" ) ;
2026-01-15 10:17:15 +07:00
// 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' ) ;
}
2026-01-13 17:55:00 +07:00
return {
id : user.id ,
username : user.username ,
email : user.email ,
2026-02-03 10:38:59 +07:00
email_verified_at : user.email_verified_at ,
2026-01-13 17:55:00 +07:00
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 ,
2026-01-28 11:49:11 +07:00
avatar_url : user.profile.avatar_url ? await this . getAvatarPresignedUrl ( user . profile . avatar_url ) : null ,
2026-01-13 17:55:00 +07:00
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 ;
}
} ;
2026-01-14 14:06:09 +07:00
/ * *
* Change user password
* /
async changePassword ( token : string , oldPassword : string , newPassword : string ) : Promise < ChangePasswordResponse > {
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' ) ;
2026-01-15 10:17:15 +07:00
// 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' ) ;
}
2026-01-14 14:06:09 +07:00
// 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 } ) ;
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 } ) ;
throw error ;
}
}
2026-01-14 16:29:18 +07:00
/ * *
* Update user profile
* /
async updateProfile ( token : string , profile : ProfileUpdate ) : Promise < ProfileUpdateResponse > {
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' ) ;
2026-01-15 10:17:15 +07:00
// 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' ) ;
}
2026-01-14 16:29:18 +07:00
// 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 } ) ;
throw error ;
}
}
2026-01-28 11:49:11 +07:00
/ * *
* Upload avatar picture to MinIO
* /
async uploadAvatarPicture ( token : string , file : Express.Multer.File ) : Promise < updateAvatarResponse > {
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 {
2026-01-28 15:01:48 +07:00
await deleteFile ( user . profile . avatar_url ) ;
logger . info ( ` Deleted old avatar: ${ user . profile . avatar_url } ` ) ;
2026-01-28 11:49:11 +07:00
} 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' ) ;
2026-01-28 15:01:48 +07:00
logger . info ( ` Uploaded avatar: ${ filePath } ` ) ;
2026-01-28 14:38:11 +07:00
// Update or create profile - store only file path
2026-01-28 11:49:11 +07:00
if ( user . profile ) {
await prisma . userProfile . update ( {
where : { user_id : decoded.id } ,
2026-01-28 14:38:11 +07:00
data : { avatar_url : filePath }
2026-01-28 11:49:11 +07:00
} ) ;
} else {
await prisma . userProfile . create ( {
data : {
user_id : decoded.id ,
2026-01-28 14:38:11 +07:00
avatar_url : filePath ,
2026-01-28 11:49:11 +07:00
first_name : '' ,
last_name : ''
}
} ) ;
}
2026-01-28 14:38:11 +07:00
// Generate presigned URL for response
const presignedUrl = await this . getAvatarPresignedUrl ( filePath ) ;
2026-01-28 11:49:11 +07:00
return {
code : 200 ,
message : 'Avatar uploaded successfully' ,
data : {
id : decoded.id ,
2026-01-28 14:38:11 +07:00
avatar_url : presignedUrl
2026-01-28 11:49:11 +07:00
}
} ;
} 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 } ) ;
throw error ;
}
}
/ * *
* Get presigned URL for avatar
* /
2026-01-28 14:38:11 +07:00
private async getAvatarPresignedUrl ( avatarPath : string ) : Promise < string > {
2026-01-28 11:49:11 +07:00
try {
2026-01-28 14:38:11 +07:00
// avatarPath is now stored as file path directly (e.g., avatars/1/filename.jpg)
return await getPresignedUrl ( avatarPath , 3600 ) ; // 1 hour expiry
2026-01-28 11:49:11 +07:00
} catch ( error ) {
logger . warn ( ` Failed to generate presigned URL for avatar: ${ error } ` ) ;
2026-01-28 15:01:48 +07:00
throw error ;
2026-01-28 11:49:11 +07:00
}
}
2026-01-15 10:17:15 +07:00
2026-01-13 17:55:00 +07:00
/ * *
* Format user response
* /
private formatUserResponse ( user : any ) : UserResponse {
return {
id : user.id ,
username : user.username ,
email : user.email ,
2026-02-03 10:38:59 +07:00
email_verified_at : user.email_verified_at ,
2026-01-13 17:55:00 +07:00
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
} ;
}
2026-01-30 14:53:50 +07:00
/ * *
* Send verification email to user
* /
async sendVerifyEmail ( token : string ) : Promise < SendVerifyEmailResponse > {
try {
2026-01-30 17:29:08 +07:00
const decoded = jwt . verify ( token , config . jwt . secret ) as { id : number ; email : string ; roleCode : string } ;
2026-01-30 14:53:50 +07:00
const user = await prisma . user . findUnique ( {
2026-01-30 17:29:08 +07:00
where : { id : decoded.id } ,
include : { role : true }
2026-01-30 14:53:50 +07:00
} ) ;
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' }
) ;
2026-01-30 17:29:08 +07:00
// 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 } ` ;
2026-01-30 14:53:50 +07:00
// 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 : `
< h2 > Email Verification < / h2 >
< p > Thank you for registering with E - Learning Platform . < / p >
< p > Please click on the following link to verify your email address : < / p >
< p > < a href = "${verifyURL}" > $ { verifyURL } < / a > < / p >
< p > This link will expire in 24 hours . < / p >
< p > If you did not create an account , please ignore this email . < / p >
`
} ;
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 } ) ;
throw error ;
}
}
/ * *
* Verify email with token
* /
async verifyEmail ( verifyToken : string ) : Promise < VerifyEmailResponse > {
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 } ) ;
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 } ) ;
throw error ;
}
}
2026-01-13 17:55:00 +07:00
}