feat: implement avatar upload functionality with presigned URL support for user profiles and announcement attachments.

This commit is contained in:
JakkrapartXD 2026-01-28 11:49:11 +07:00
parent bacb8a3824
commit 53314dfd7e
5 changed files with 192 additions and 26 deletions

View file

@ -19,7 +19,7 @@ import {
Announcement,
} from '../types/announcements.types';
import { CoursesInstructorService } from './CoursesInstructor.service';
import { uploadFile, deleteFile } from '../config/minio';
import { uploadFile, deleteFile, getPresignedUrl } from '../config/minio';
export class AnnouncementsService {
@ -54,9 +54,7 @@ export class AnnouncementsService {
where: { course_id, user_id: decoded.id },
});
if (!isAdmin && !isInstructor && !isEnrolled) {
throw new ForbiddenError('You do not have access to this course announcements');
}
if (!isAdmin && !isInstructor && !isEnrolled) throw new ForbiddenError('You do not have access to this course announcements');
const skip = (page - 1) * limit;
@ -79,26 +77,45 @@ export class AnnouncementsService {
prisma.announcement.count({ where: { course_id } }),
]);
// Generate presigned URLs for attachments
const announcementsWithUrls = await Promise.all(
announcements.map(async (a) => {
const attachmentsWithUrls = await Promise.all(
a.attachments.map(async (att) => {
let presigned_url: string | null = null;
try {
presigned_url = await getPresignedUrl(att.file_path, 3600);
} catch (err) {
logger.warn(`Failed to generate presigned URL for ${att.file_path}: ${err}`);
}
return {
id: att.id,
announcement_id: att.announcement_id,
file_name: att.file_name,
file_path: att.file_path,
presigned_url,
created_at: att.created_at,
updated_at: att.created_at,
};
})
);
return {
id: a.id,
title: a.title as { th: string; en: string },
content: a.content as { th: string; en: string },
status: a.status,
is_pinned: a.is_pinned,
created_at: a.created_at,
updated_at: a.updated_at,
attachments: attachmentsWithUrls,
};
})
);
return {
code: 200,
message: 'Announcements retrieved successfully',
data: announcements.map(a => ({
id: a.id,
title: a.title as { th: string; en: string },
content: a.content as { th: string; en: string },
status: a.status,
is_pinned: a.is_pinned,
created_at: a.created_at,
updated_at: a.updated_at,
attachments: a.attachments.map(att => ({
id: att.id,
announcement_id: att.announcement_id,
file_name: att.file_name,
file_path: att.file_path,
created_at: att.created_at,
updated_at: att.created_at,
})),
})),
data: announcementsWithUrls,
total,
page,
limit,

View file

@ -10,9 +10,12 @@ import {
ProfileUpdate,
ProfileUpdateResponse,
ChangePasswordRequest,
ChangePasswordResponse
ChangePasswordResponse,
updateAvatarRequest,
updateAvatarResponse
} from '../types/user.types';
import { UnauthorizedError, ValidationError, ForbiddenError } from '../middleware/errorHandler';
import { uploadFile, deleteFile, getPresignedUrl } from '../config/minio';
export class UserService {
async getUserProfile(token: string): Promise<UserResponse> {
@ -52,7 +55,7 @@ export class UserService {
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,
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
@ -171,7 +174,110 @@ export class UserService {
}
}
/**
* 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 {
// Extract file path from URL
const urlParts = user.profile.avatar_url.split('/');
const oldFilePath = urlParts.slice(-2).join('/'); // Get avatars/{userId}/{filename}
await deleteFile(oldFilePath);
logger.info(`Deleted old avatar: ${oldFilePath}`);
} 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');
// Update user profile with avatar URL
const avatarUrl = `${config.s3.endpoint}/${config.s3.bucket}/${filePath}`;
// Update or create profile
if (user.profile) {
await prisma.userProfile.update({
where: { user_id: decoded.id },
data: { avatar_url: avatarUrl }
});
} else {
await prisma.userProfile.create({
data: {
user_id: decoded.id,
avatar_url: avatarUrl,
first_name: '',
last_name: ''
}
});
}
return {
code: 200,
message: 'Avatar uploaded successfully',
data: {
id: decoded.id,
avatar_url: avatarUrl
}
};
} 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
*/
private async getAvatarPresignedUrl(avatarUrl: string): Promise<string | null> {
try {
// Extract file path from stored URL or path
const filePath = avatarUrl.includes('/')
? `avatars/${avatarUrl.split('/').slice(-2).join('/')}`
: avatarUrl;
return await getPresignedUrl(filePath, 3600); // 1 hour expiry
} catch (error) {
logger.warn(`Failed to generate presigned URL for avatar: ${error}`);
return null;
}
}
/**
* Format user response