feat: implement avatar upload functionality with presigned URL support for user profiles and announcement attachments.
This commit is contained in:
parent
bacb8a3824
commit
53314dfd7e
5 changed files with 192 additions and 26 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue