fix: lỗi các thành viên không thể upload ảnh và không có nút xóa ảnh của user

This commit is contained in:
2026-06-16 18:54:24 +07:00
parent 88b2182789
commit 00554224a1
8 changed files with 318 additions and 34 deletions
+145 -8
View File
@@ -16,12 +16,14 @@ import { diskStorage } from 'multer';
import { WebSocketGateway, WebSocketServer, SubscribeMessage, OnGatewayConnection, OnGatewayDisconnect } from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { PrismaService } from '../prisma/prisma.service';
import { ParticipantRole } from '@prisma/client';
import * as bcrypt from 'bcrypt';
import { AdminGuard } from './auth/admin.guard';
import { JwtModule, JwtService } from '@nestjs/jwt';
import { JwtAuthGuard } from './auth/jwt-auth.guard';
import { JwtStrategy } from './auth/jwt.strategy';
import { TourRoleGuard } from './common/rbac.middleware';
import { Reflector } from '@nestjs/core';
import { SetMetadata, CanActivate, ExecutionContext } from '@nestjs/common';
// Khai báo vị trí thư mục upload cụ thể
const UPLOAD_ROOT = path.join(process.cwd(), 'uploads');
@@ -56,6 +58,45 @@ async function bootstrap() {
console.log(`🚀 Server is running on: http://localhost:3001`);
}
// Define ROLES_KEY and Roles decorator
export const ROLES_KEY = 'roles';
export const Roles = (...roles: ParticipantRole[]) => SetMetadata(ROLES_KEY, roles);
// Implement TourRoleGuard (assuming it's here or similar to this)
// This guard checks if the user is a participant of the tour and has one of the required roles.
@Injectable()
export class TourRoleGuard implements CanActivate {
constructor(private reflector: Reflector, private prisma: PrismaService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const requiredRoles = this.reflector.getAllAndOverride<ParticipantRole[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
// If no specific roles are required, default to OWNER and MANAGER for editing actions
const defaultRoles = [ParticipantRole.OWNER, ParticipantRole.MANAGER];
const rolesToCheck = requiredRoles && requiredRoles.length > 0 ? requiredRoles : defaultRoles;
const request = context.switchToHttp().getRequest();
const user = request.user; // User object from JwtAuthGuard
// Kiểm tra cả 'tourId' và 'id' để tương thích với các route khác nhau
const tourId = request.params.tourId || request.params.id;
if (!user || !tourId) {
return false; // User or tourId not available
}
const participation = await this.prisma.tourParticipant.findUnique({
where: { tourId_userId: { tourId, userId: user.id } },
});
if (!participation || !rolesToCheck.some(role => participation.role === role)) {
throw new ForbiddenException('Bạn không có quyền thực hiện hành động này trong tour này.');
}
return true;
}
}
@Controller()
class AppController {
@@ -202,6 +243,7 @@ class TourController {
});
}
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER, ParticipantRole.MEMBER) // Allow members to add locations
@UseGuards(JwtAuthGuard, TourRoleGuard)
@Post(':tourId/locations')
async addLocation(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: any, @Req() req: any) {
@@ -244,6 +286,7 @@ class TourController {
});
}
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Only owner/manager can set start point
@UseGuards(JwtAuthGuard, TourRoleGuard)
@Post(':tourId/start-point')
async updateTourStartPoint(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: any, @Req() req: any) {
@@ -281,6 +324,7 @@ class TourController {
});
}
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Only owner/manager can set end point
@UseGuards(JwtAuthGuard, TourRoleGuard)
@Post(':tourId/end-point')
async updateTourEndPoint(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: any, @Req() req: any) {
@@ -316,6 +360,7 @@ class TourController {
});
}
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Only owner/manager can initialize legs
@UseGuards(JwtAuthGuard, TourRoleGuard)
@Post(':tourId/legs/batch')
async initializeLegs(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: { count: number }) {
@@ -361,6 +406,7 @@ class TourController {
return allLegs;
}
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Only owner/manager can add legs
@UseGuards(JwtAuthGuard, TourRoleGuard)
@Post(':tourId/legs')
async addLeg(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: any) {
@@ -379,6 +425,7 @@ class TourController {
});
}
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Only owner/manager can update tour details
@UseGuards(JwtAuthGuard, TourRoleGuard)
@Patch(':id')
async updateTour(@Param('id', ParseUUIDPipe) id: string, @Body() body: any) {
@@ -398,6 +445,7 @@ class TourController {
});
}
@Roles(ParticipantRole.OWNER) // Only owner can delete tour
@UseGuards(JwtAuthGuard, TourRoleGuard)
@Delete(':id')
async deleteTour(@Param('id', ParseUUIDPipe) id: string) {
@@ -408,19 +456,27 @@ class TourController {
// 2. Xóa các file vật lý (ảnh 2K) trong thư mục uploads/tours
for (const photo of photos) {
const displayFilePath = path.join(process.cwd(), photo.imageUrl.replace(/^\//, ''));
if (fs.existsSync(displayFilePath)) {
fs.unlinkSync(displayFilePath);
if (photo.imageUrl) {
const displayFilePath = path.join(process.cwd(), photo.imageUrl.replace(/^\//, ''));
if (fs.existsSync(displayFilePath)) {
fs.unlinkSync(displayFilePath);
}
}
}
// Xóa tour sẽ xóa cascade các Leg, Location, Expense nhờ config onDelete: Cascade trong schema
// Cập nhật DB: Xóa link ảnh 2K vì file vật lý đã bị xóa, tourId sẽ tự động SetNull
await this.prisma.photo.updateMany({
where: { tourId: id },
data: { imageUrl: null }
});
await this.prisma.tour.delete({
where: { id },
});
return { success: true };
}
// getPublicTours does not need TourRoleGuard as it's for any logged-in user to see their tours
@UseGuards(JwtAuthGuard)
@Get('explore')
async getPublicTours(@Req() req: any) {
@@ -451,6 +507,7 @@ class TourController {
});
}
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER, ParticipantRole.MEMBER, ParticipantRole.MEMBER_NO_FINANCE, ParticipantRole.VIEWER_ONLY) // Any participant can view tour details
@UseGuards(JwtAuthGuard, TourRoleGuard)
@Get(':id')
async getTourDetails(@Param('id', ParseUUIDPipe) id: string) {
@@ -487,6 +544,7 @@ class TourController {
return tour;
}
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Only owner/manager can add members
@UseGuards(JwtAuthGuard, TourRoleGuard)
@Post(':tourId/members')
async addMember(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: { userId: string; role?: string }, @Req() req: any) {
@@ -537,6 +595,7 @@ class TourController {
});
}
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Only owner/manager can view join requests
@UseGuards(JwtAuthGuard, TourRoleGuard)
@Get(':tourId/join-requests')
async getJoinRequests(@Param('tourId', ParseUUIDPipe) tourId: string, @Req() req: any) {
@@ -552,6 +611,7 @@ class TourController {
return requests;
}
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER, ParticipantRole.MEMBER, ParticipantRole.MEMBER_NO_FINANCE, ParticipantRole.VIEWER_ONLY) // Any participant can create a join request for themselves or others (if they have permission)
@UseGuards(JwtAuthGuard, TourRoleGuard)
@Post(':tourId/join-requests')
async createJoinRequest(@Param('tourId', ParseUUIDPipe) tourId: string, @Body() body: { userId?: string }, @Req() req: any) {
@@ -587,6 +647,7 @@ class TourController {
return joinRequest;
}
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Only owner/manager can accept join requests
@UseGuards(JwtAuthGuard, TourRoleGuard)
@Post(':tourId/join-requests/:requestId/accept')
async acceptJoinRequest(@Param('tourId', ParseUUIDPipe) tourId: string, @Param('requestId') requestId: string, @Req() req: any) {
@@ -639,6 +700,7 @@ class TourController {
return { success: true, message: 'Đã chấp nhận yêu cầu tham gia.' };
}
@Roles(ParticipantRole.OWNER) // Only owner can reject join requests
@UseGuards(JwtAuthGuard, TourRoleGuard)
@Post(':tourId/join-requests/:requestId/reject')
async rejectJoinRequest(@Param('tourId', ParseUUIDPipe) tourId: string, @Param('requestId') requestId: string, @Req() req: any) {
@@ -671,6 +733,7 @@ class TourController {
return { success: true, message: 'Đã từ chối yêu cầu tham gia.' };
}
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Only owner/manager can remove members
@UseGuards(JwtAuthGuard, TourRoleGuard)
@Delete(':tourId/members/:userId')
async removeMember(@Param('tourId', ParseUUIDPipe) tourId: string, @Param('userId', ParseUUIDPipe) userId: string) {
@@ -686,7 +749,8 @@ class TourController {
return { message: 'Đã xóa thành viên khỏi tour' };
}
@UseGuards(JwtAuthGuard, TourRoleGuard)
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER, ParticipantRole.MEMBER, ParticipantRole.MEMBER_NO_FINANCE) // All members can upload photos
@UseGuards(JwtAuthGuard, TourRoleGuard) // TourRoleGuard will now check for these roles
@Post(':tourId/photos')
@UseInterceptors(FilesInterceptor('images', 10)) // Chuyển sang memory storage để xử lý ảnh trước khi lưu
async uploadPhotos(@Param('tourId', ParseUUIDPipe) tourId: string, @UploadedFiles() files: any[], @Req() req: any) {
@@ -797,6 +861,7 @@ class LocationController {
}
@Controller('legs')
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Default roles for Leg operations
@UseGuards(JwtAuthGuard)
class LegController {
constructor(private prisma: PrismaService) {}
@@ -844,6 +909,7 @@ function calculateDistance(lat1: number, lon1: number, lat2: number, lon2: numbe
}
@Controller('routing')
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Default roles for Routing operations
class RoutingController {
constructor(private prisma: PrismaService) {}
@@ -956,7 +1022,50 @@ class RoutingController {
}
}
@Controller('photos')
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER, ParticipantRole.MEMBER, ParticipantRole.MEMBER_NO_FINANCE) // All members can delete their own photos
@UseGuards(JwtAuthGuard)
class PhotoController {
constructor(private prisma: PrismaService) {}
@Delete(':id')
async deletePhoto(@Param('id', ParseUUIDPipe) id: string, @Req() req: any) {
const photo = await this.prisma.photo.findUnique({
where: { id },
});
if (!photo) {
throw new NotFoundException('Không tìm thấy ảnh.');
}
// Chỉ người tải lên mới có quyền xóa ảnh của họ
if (photo.uploaderId !== req.user.id) {
throw new ForbiddenException('Bạn không có quyền xóa ảnh này.');
}
// Xóa file 2K (imageUrl) nếu tồn tại
if (photo.imageUrl) {
const displayFilePath = path.join(process.cwd(), photo.imageUrl.replace(/^\//, ''));
if (fs.existsSync(displayFilePath)) {
fs.unlinkSync(displayFilePath);
}
}
// Xóa file gốc (originalUrl) nếu tồn tại
if (photo.originalUrl) {
const originalFilePath = path.join(process.cwd(), photo.originalUrl.replace(/^\//, ''));
if (fs.existsSync(originalFilePath)) {
fs.unlinkSync(originalFilePath);
}
}
await this.prisma.photo.delete({ where: { id } });
return { message: 'Ảnh đã được xóa thành công.' };
}
}
@Controller('users')
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Default roles for User management (Admin/Manager)
class UserController {
constructor(private prisma: PrismaService) {}
@@ -977,6 +1086,20 @@ class UserController {
return users.filter((u: any) => u.id !== currentUserId);
}
// getMyPhotos does not need TourRoleGuard as it's for the user's own photos
@UseGuards(JwtAuthGuard)
@Get('me/photos')
async getMyPhotos(@Req() req: any) {
return this.prisma.photo.findMany({
where: { uploaderId: req.user.id },
include: {
tour: { select: { title: true } }
},
orderBy: { capturedAt: 'desc' }
});
}
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Default roles for updating user (Admin/Manager)
@Patch(':id')
async updateUser(@Param('id', ParseUUIDPipe) id: string, @Body() data: any) {
if (data.password) {
@@ -990,6 +1113,7 @@ class UserController {
});
}
@Roles(ParticipantRole.OWNER) // Only owner can delete user
@Delete(':id')
async deleteUser(@Param('id', ParseUUIDPipe) id: string) {
const user = await this.prisma.user.findUnique({ where: { id } });
@@ -1002,6 +1126,18 @@ class UserController {
// 1. Xác định thư mục chứa ảnh gốc của thành viên
const memberDir = path.join(UPLOAD_ROOT, 'members', id);
// 2. Tìm tất cả ảnh của user này để dọn dẹp nốt các bản 2K còn lại trong thư mục tours
const photos = await this.prisma.photo.findMany({
where: { uploaderId: id }
});
for (const photo of photos) {
if (photo.imageUrl) {
const displayFilePath = path.join(process.cwd(), photo.imageUrl.replace(/^\//, ''));
if (fs.existsSync(displayFilePath)) fs.unlinkSync(displayFilePath);
}
}
// 2. Xóa các ràng buộc và dữ liệu trong DB
await this.prisma.photo.deleteMany({ where: { uploaderId: id } });
await this.prisma.tourParticipant.deleteMany({ where: { userId: id } });
@@ -1015,6 +1151,7 @@ class UserController {
return { message: 'Đã xóa người dùng' };
}
@Roles(ParticipantRole.OWNER, ParticipantRole.MANAGER) // Default roles for blocking user (Admin/Manager)
@Post('block/:id')
async toggleBlock(@Param('id', ParseUUIDPipe) id: string) {
const user = await this.prisma.user.findUnique({ where: { id } });
@@ -1107,8 +1244,8 @@ class CommentController {
signOptions: { expiresIn: '1d' },
}) as any,
],
controllers: [AppController, AuthController, PublicTourController, TourController, UserController, RoutingController, LegController, LocationController, CommentController],
providers: [PrismaService, JwtStrategy, TourRoleGuard, JwtAuthGuard, AdminGuard, CommentGateway],
controllers: [AppController, AuthController, PublicTourController, TourController, UserController, RoutingController, LegController, LocationController, CommentController, PhotoController],
providers: [PrismaService, JwtStrategy, TourRoleGuard, JwtAuthGuard, AdminGuard, CommentGateway, Reflector],
exports: [PrismaService]
})
class AppModule {}