531 lines
20 KiB
TypeScript
531 lines
20 KiB
TypeScript
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<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");
|
|
|
|
// 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<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');
|
|
|
|
// 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<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');
|
|
|
|
// 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<rolesResponse> {
|
|
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<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 {
|
|
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<string> {
|
|
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<SendVerifyEmailResponse> {
|
|
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: `
|
|
<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 });
|
|
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<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 });
|
|
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;
|
|
}
|
|
}
|
|
}
|