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
|
|
@ -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 { ValidationError } from '../middleware/errorHandler';
|
||||||
import { UserService } from '../services/user.service';
|
import { UserService } from '../services/user.service';
|
||||||
import {
|
import {
|
||||||
|
|
@ -7,7 +7,8 @@ import {
|
||||||
ProfileUpdate,
|
ProfileUpdate,
|
||||||
ProfileUpdateResponse,
|
ProfileUpdateResponse,
|
||||||
ChangePasswordRequest,
|
ChangePasswordRequest,
|
||||||
ChangePasswordResponse
|
ChangePasswordResponse,
|
||||||
|
updateAvatarResponse
|
||||||
} from '../types/user.types';
|
} from '../types/user.types';
|
||||||
import { ChangePassword } from '../types/auth.types';
|
import { ChangePassword } from '../types/auth.types';
|
||||||
import { profileUpdateSchema, changePasswordSchema } from "../validators/user.validator";
|
import { profileUpdateSchema, changePasswordSchema } from "../validators/user.validator";
|
||||||
|
|
@ -78,4 +79,31 @@ export class UserController {
|
||||||
|
|
||||||
return await this.userService.changePassword(token, body.oldPassword, body.newPassword);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ import {
|
||||||
Announcement,
|
Announcement,
|
||||||
} from '../types/announcements.types';
|
} from '../types/announcements.types';
|
||||||
import { CoursesInstructorService } from './CoursesInstructor.service';
|
import { CoursesInstructorService } from './CoursesInstructor.service';
|
||||||
import { uploadFile, deleteFile } from '../config/minio';
|
import { uploadFile, deleteFile, getPresignedUrl } from '../config/minio';
|
||||||
|
|
||||||
export class AnnouncementsService {
|
export class AnnouncementsService {
|
||||||
|
|
||||||
|
|
@ -54,9 +54,7 @@ export class AnnouncementsService {
|
||||||
where: { course_id, user_id: decoded.id },
|
where: { course_id, user_id: decoded.id },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!isAdmin && !isInstructor && !isEnrolled) {
|
if (!isAdmin && !isInstructor && !isEnrolled) throw new ForbiddenError('You do not have access to this course announcements');
|
||||||
throw new ForbiddenError('You do not have access to this course announcements');
|
|
||||||
}
|
|
||||||
|
|
||||||
const skip = (page - 1) * limit;
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
|
@ -79,26 +77,45 @@ export class AnnouncementsService {
|
||||||
prisma.announcement.count({ where: { course_id } }),
|
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 {
|
return {
|
||||||
code: 200,
|
code: 200,
|
||||||
message: 'Announcements retrieved successfully',
|
message: 'Announcements retrieved successfully',
|
||||||
data: announcements.map(a => ({
|
data: announcementsWithUrls,
|
||||||
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,
|
|
||||||
})),
|
|
||||||
})),
|
|
||||||
total,
|
total,
|
||||||
page,
|
page,
|
||||||
limit,
|
limit,
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,12 @@ import {
|
||||||
ProfileUpdate,
|
ProfileUpdate,
|
||||||
ProfileUpdateResponse,
|
ProfileUpdateResponse,
|
||||||
ChangePasswordRequest,
|
ChangePasswordRequest,
|
||||||
ChangePasswordResponse
|
ChangePasswordResponse,
|
||||||
|
updateAvatarRequest,
|
||||||
|
updateAvatarResponse
|
||||||
} from '../types/user.types';
|
} from '../types/user.types';
|
||||||
import { UnauthorizedError, ValidationError, ForbiddenError } from '../middleware/errorHandler';
|
import { UnauthorizedError, ValidationError, ForbiddenError } from '../middleware/errorHandler';
|
||||||
|
import { uploadFile, deleteFile, getPresignedUrl } from '../config/minio';
|
||||||
|
|
||||||
export class UserService {
|
export class UserService {
|
||||||
async getUserProfile(token: string): Promise<UserResponse> {
|
async getUserProfile(token: string): Promise<UserResponse> {
|
||||||
|
|
@ -52,7 +55,7 @@ export class UserService {
|
||||||
prefix: user.profile.prefix as { th?: string; en?: string } | undefined,
|
prefix: user.profile.prefix as { th?: string; en?: string } | undefined,
|
||||||
first_name: user.profile.first_name,
|
first_name: user.profile.first_name,
|
||||||
last_name: user.profile.last_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,
|
birth_date: user.profile.birth_date,
|
||||||
phone: user.profile.phone
|
phone: user.profile.phone
|
||||||
} : undefined
|
} : 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
|
* Format user response
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ export interface AnnouncementAttachment {
|
||||||
announcement_id: number;
|
announcement_id: number;
|
||||||
file_name: string;
|
file_name: string;
|
||||||
file_path: string;
|
file_path: string;
|
||||||
|
presigned_url?: string | null;
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
updated_at: Date;
|
updated_at: Date;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -69,3 +69,17 @@ export interface ChangePasswordResponse {
|
||||||
code: number;
|
code: number;
|
||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface updateAvatarRequest {
|
||||||
|
token: string;
|
||||||
|
avatar_url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface updateAvatarResponse {
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data: {
|
||||||
|
id: number;
|
||||||
|
avatar_url: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
Loading…
Add table
Add a link
Reference in a new issue