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

@ -1,4 +1,4 @@
import { Get, Body, Post, Route, Tags, SuccessResponse, Response, Example, Controller, Security, Request, Put } from 'tsoa';
import { Get, Body, Post, Route, Tags, SuccessResponse, Response, Example, Controller, Security, Request, Put, UploadedFile } from 'tsoa';
import { ValidationError } from '../middleware/errorHandler';
import { UserService } from '../services/user.service';
import {
@ -7,7 +7,8 @@ import {
ProfileUpdate,
ProfileUpdateResponse,
ChangePasswordRequest,
ChangePasswordResponse
ChangePasswordResponse,
updateAvatarResponse
} from '../types/user.types';
import { ChangePassword } from '../types/auth.types';
import { profileUpdateSchema, changePasswordSchema } from "../validators/user.validator";
@ -78,4 +79,31 @@ export class UserController {
return await this.userService.changePassword(token, body.oldPassword, body.newPassword);
}
/**
* Upload user avatar picture
* @param request Express request object with JWT token in Authorization header
* @param file Avatar image file
*/
@Post('upload-avatar')
@Security('jwt')
@SuccessResponse('200', 'Avatar uploaded successfully')
@Response('401', 'Invalid or expired token')
@Response('400', 'Validation error')
public async uploadAvatar(
@Request() request: any,
@UploadedFile() file: Express.Multer.File
): Promise<updateAvatarResponse> {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) throw new ValidationError('No token provided');
// Validate file type (images only)
if (!file.mimetype?.startsWith('image/')) throw new ValidationError('Only image files are allowed');
// Validate file size (max 5MB)
const maxSize = 5 * 1024 * 1024; // 5MB
if (file.size > maxSize) throw new ValidationError('File size must be less than 5MB');
return await this.userService.uploadAvatarPicture(token, file);
}
}

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

View file

@ -16,6 +16,7 @@ export interface AnnouncementAttachment {
announcement_id: number;
file_name: string;
file_path: string;
presigned_url?: string | null;
created_at: Date;
updated_at: Date;
}

View file

@ -68,4 +68,18 @@ export interface ChangePasswordRequest {
export interface ChangePasswordResponse {
code: number;
message: string;
};
export interface updateAvatarRequest {
token: string;
avatar_url: string;
};
export interface updateAvatarResponse {
code: number;
message: string;
data: {
id: number;
avatar_url: string;
};
};