fix: guest and admin logic

This commit is contained in:
2026-06-22 11:41:34 +07:00
parent b1a539235b
commit 860395cb14
61 changed files with 1291 additions and 244 deletions
+19 -1
View File
@@ -1,5 +1,23 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { UnauthorizedException } from '@nestjs/common';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
export class JwtAuthGuard extends AuthGuard('jwt') {}
// Guard that rejects anonymous/guest users - used for dashboard and sensitive endpoints
@Injectable()
export class JwtAuthGuardNoAnonymous extends AuthGuard('jwt') {
handleRequest(err: any, user: any, info: any) {
if (err || !user) {
throw err || new UnauthorizedException('Phiên làm việc không hợp lệ hoặc đã hết hạn');
}
// Reject anonymous/temporary users
if (user.isAnonymous) {
throw new UnauthorizedException('Tài khoản khách không có quyền truy cập tính năng này. Vui lòng đăng ký tài khoản để tiếp tục.');
}
return user;
}
}
+2
View File
@@ -22,6 +22,8 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
throw new UnauthorizedException('Người dùng không tồn tại hoặc phiên làm việc hết hạn');
}
// Note: We allow anonymous users to pass JWT validation
// Individual endpoints decide whether to accept anonymous users based on their guard
return user;
}
}
+253 -30
View File
@@ -23,6 +23,7 @@ import { AdminGuard } from './auth/admin.guard';
import * as nodemailer from 'nodemailer';
import { JwtModule, JwtService } from '@nestjs/jwt';
import { JwtAuthGuard } from './auth/jwt-auth.guard';
import { JwtAuthGuardNoAnonymous } from './auth/jwt-auth.guard';
import { JwtStrategy } from './auth/jwt.strategy';
import { Reflector } from '@nestjs/core';
import { SetMetadata, CanActivate, ExecutionContext } from '@nestjs/common';
@@ -787,6 +788,37 @@ class TourController {
}
}
});
// Auto-create default note template for new tour
const noteContent = `<h2>${filteredTitle} - Initial Planning</h2>
<p><strong>Start Date:</strong> ${startDate ? new Date(startDate).toLocaleDateString() : 'TBD'}</p>
<p><strong>End Date:</strong> ${endDate ? new Date(endDate).toLocaleDateString() : 'TBD'}</p>
<p><strong>Adult Participants:</strong> ${adultCount || 1}</p>
<p><strong>Child Participants:</strong> ${childCount || 0}</p>
<h3>Key Items to Plan:</h3>
<ul>
<li>Accommodations</li>
<li>Transportation</li>
<li>Activities & Attractions</li>
<li>Budget & Expenses</li>
<li>Important Contact Numbers</li>
<li>Special Requirements & Notes</li>
</ul>
<p><em>Add your planning notes here...</em></p>`;
try {
await this.prisma.tourNote.create({
data: {
tourId: tour.id,
userId: req.user.id,
title: `[${filteredTitle}] - Initial Planning`,
content: noteContent
}
});
} catch (err) {
console.warn(`Failed to auto-create note for tour ${tour.id}:`, err.message);
}
await this.cacheManager.del(`/api/v1/tours/explore`);
return tour;
}
@@ -1117,7 +1149,7 @@ class TourController {
}
@UseInterceptors(CompressCacheInterceptor) // Áp dụng Interceptor nén và cache
@UseGuards(JwtAuthGuard)
@UseGuards(JwtAuthGuardNoAnonymous)
@Get('explore')
async getPublicTours(@Req() req: any) {
// Trả về tất cả các tour trong hệ thống để hiển thị trên bản đồ cộng đồng
@@ -1596,18 +1628,42 @@ class TourController {
}
}
// Ensure /uploads/tours/ directory exists
const uploadsDir = path.dirname(displayFilePath);
if (!fs.existsSync(uploadsDir)) {
try {
fs.mkdirSync(uploadsDir, { recursive: true });
console.log(`[Photo] Created directory: ${uploadsDir}`);
} catch (e) {
console.error(`[Photo] Failed to create directory ${uploadsDir}:`, e);
throw new BadRequestException(`Lỗi tạo thư mục: ${e.message}`);
}
}
// Sử dụng Sharp để resize và tối ưu dung lượng ảnh
await sharp(processBuffer)
.rotate()
.resize(2560, 2560, {
fit: 'inside', // Giữ nguyên tỷ lệ, không vượt quá khung 2K
withoutEnlargement: true // Nếu ảnh nhỏ hơn 2K thì giữ nguyên, không làm vỡ ảnh
})
.jpeg({ quality: 85 }) // Tối ưu chất lượng/dung lượng
.toFile(displayFilePath);
try {
await sharp(processBuffer)
.rotate()
.resize(2560, 2560, {
fit: 'inside', // Giữ nguyên tỷ lệ, không vượt quá khung 2K
withoutEnlargement: true // Nếu ảnh nhỏ hơn 2K thì giữ nguyên, không làm vỡ ảnh
})
.jpeg({ quality: 85 }) // Tối ưu chất lượng/dung lượng
.toFile(displayFilePath);
console.log(`[Photo] Display image saved successfully: ${displayFilePath}`);
} catch (e) {
console.error(`[Photo] Failed to save display image to ${displayFilePath}:`, e);
throw new BadRequestException(`Lỗi lưu hình ảnh: ${e.message}`);
}
// Verify file was created
if (!fs.existsSync(displayFilePath)) {
console.error(`[Photo] File verification failed at ${displayFilePath}`);
throw new BadRequestException('Lỗi: Hình ảnh không được lưu thành công. Vui lòng kiểm tra quyền lưu trữ.');
}
// 6. Lưu thông tin vào Database (Lưu cả 2 đường dẫn và metadata GPS)
return this.prisma.photo.create({
const photoRecord = await this.prisma.photo.create({
data: {
tourId: tourId,
uploaderId: uploaderId,
@@ -1620,6 +1676,8 @@ class TourController {
}
}
});
console.log(`[Photo] Database record created: ID=${photoRecord.id}, imageUrl=${photoRecord.imageUrl}`);
return photoRecord;
}));
}
@@ -1680,20 +1738,39 @@ class TourController {
@Post('join-by-token')
async joinByToken(@Body() body: { token: string }, @Req() req: any) {
const { token } = body;
console.log('[joinByToken] Request received, token length:', token ? token.length : 0);
if (!token) {
console.error('[joinByToken] No token provided');
throw new BadRequestException('Vui lòng cung cấp token lời mời.');
}
console.log('[joinByToken] Searching for invitation with token:', token.substring(0, 20) + '...');
const invitation = await this.prisma.tourInvitation.findUnique({
where: { token },
include: { tour: { select: { title: true } } }
include: { tour: { select: { id: true, title: true } } }
});
if (!invitation) {
console.error('[joinByToken] Invitation not found for token:', token.substring(0, 20) + '...');
// Check how many invitations exist in database for debugging
const totalInvitations = await this.prisma.tourInvitation.count();
console.log('[joinByToken] Total invitations in database:', totalInvitations);
throw new NotFoundException('Lời mời không hợp lệ hoặc đã bị hủy.');
}
// Verify the invitation email matches the logged-in user's email
const userEmail = req.user.email;
console.log('[joinByToken] Checking email - User:', userEmail, 'Invitation:', invitation.email);
if (invitation.email && userEmail && invitation.email !== userEmail) {
console.error('[joinByToken] Email mismatch - User email:', userEmail, 'Invitation email:', invitation.email);
throw new BadRequestException('Lời mời này được gửi tới một địa chỉ email khác. Vui lòng đăng nhập bằng tài khoản đúng.');
}
console.log('[joinByToken] Invitation found, tourId:', invitation.tourId, 'expired:', invitation.expiredAt < new Date());
if (invitation.expiredAt < new Date()) {
console.error('[joinByToken] Invitation expired at:', invitation.expiredAt);
try {
await this.prisma.tourInvitation.delete({ where: { id: invitation.id } });
} catch (e) {}
@@ -1701,12 +1778,14 @@ class TourController {
}
const userId = req.user.id;
console.log('[joinByToken] User attempting to join - userId:', userId, 'tourId:', invitation.tourId);
const existingParticipation = await this.prisma.tourParticipant.findUnique({
where: { tourId_userId: { tourId: invitation.tourId, userId } }
});
if (existingParticipation) {
console.log('[joinByToken] User is already a participant');
try {
await this.prisma.tourInvitation.delete({ where: { id: invitation.id } });
} catch (e) {}
@@ -1743,6 +1822,7 @@ class TourController {
})
]);
console.log('[joinByToken] Successfully joined tour:', invitation.tourId, 'userId:', userId);
return { success: true, tourId: invitation.tourId, message: `Bạn đã gia nhập hành trình "${invitation.tour.title}"!`, shouldRefresh: true };
}
@@ -2112,13 +2192,17 @@ class PhotoController {
const displayFilename = `${uniqueSuffix}.jpg`;
const memberOriginalDir = path.join(UPLOAD_ROOT, 'members', uploaderId, 'originals');
const displayFilePath = path.join(UPLOAD_ROOT, 'tours', displayFilename);
const tourDisplayPath = path.join(UPLOAD_ROOT, 'tours');
const displayFilePath = path.join(tourDisplayPath, displayFilename);
const originalFilePath = path.join(memberOriginalDir, originalFilename);
// Đảm bảo thư mục lưu trữ ảnh gốc tồn tại
// Đảm bảo thư mục lưu trữ ảnh gốc và ảnh hiển thị tồn tại
if (!fs.existsSync(memberOriginalDir)) {
fs.mkdirSync(memberOriginalDir, { recursive: true });
}
if (!fs.existsSync(tourDisplayPath)) {
fs.mkdirSync(tourDisplayPath, { recursive: true });
}
// 1. Lưu ảnh gốc nguyên bản vào thư mục riêng của khách
await fs.promises.writeFile(originalFilePath, file.buffer);
@@ -3014,6 +3098,33 @@ class PublicPhotoController {
res.type('text/html').send(htmlContent);
}
@UseGuards(JwtAuthGuard)
@Post(':photoId/flag')
async flagPhoto(
@Param('photoId', ParseUUIDPipe) photoId: string,
@Body() body: { reason: string },
@Req() req: any
) {
const photo = await this.prisma.photo.findUnique({
where: { id: photoId }
});
if (!photo) {
throw new NotFoundException('Ảnh không tồn tại');
}
// Update photo as flagged
const updatedPhoto = await this.prisma.photo.update({
where: { id: photoId },
data: {
isFlagged: true,
flaggedReason: body.reason || 'Không xác định',
flaggedAt: new Date()
}
});
return { success: true, message: 'Đã báo cáo ảnh thành công', photo: updatedPhoto };
}
}
@UseGuards(JwtAuthGuard)
@@ -3536,6 +3647,67 @@ class AdminModerationController {
}
}
@Controller('admin/photos')
@UseGuards(JwtAuthGuard, AdminGuard)
class AdminPhotosController {
constructor(private prisma: PrismaService) {}
@Get('flagged')
async getFlaggedPhotos() {
return this.prisma.photo.findMany({
where: { isFlagged: true },
include: {
uploader: { select: { id: true, name: true, email: true } },
tour: { select: { id: true, title: true } }
},
orderBy: { flaggedAt: 'desc' }
});
}
@Post(':photoId/approve')
async approvePhoto(@Param('photoId', ParseUUIDPipe) photoId: string) {
const photo = await this.prisma.photo.findUnique({
where: { id: photoId }
});
if (!photo) {
throw new NotFoundException('Ảnh không tồn tại');
}
// Unflag the photo
const updatedPhoto = await this.prisma.photo.update({
where: { id: photoId },
data: {
isFlagged: false,
flaggedReason: null,
flaggedAt: null
}
});
return { success: true, message: 'Đã duyệt ảnh thành công', photo: updatedPhoto };
}
@Delete(':photoId')
async deletePhoto(@Param('photoId', ParseUUIDPipe) photoId: string) {
const photo = await this.prisma.photo.findUnique({
where: { id: photoId }
});
if (!photo) {
throw new NotFoundException('Ảnh không tồn tại');
}
// Soft delete the photo
const deletedPhoto = await this.prisma.photo.update({
where: { id: photoId },
data: {
isDeleted: true,
deletedAt: new Date()
}
});
return { success: true, message: 'Đã xóa ảnh thành công', photo: deletedPhoto };
}
}
@Controller('reports')
class ReportsController {
constructor(private prisma: PrismaService) {}
@@ -4141,54 +4313,105 @@ class AdminTrashController {
@Post('delete-permanent')
async deletePermanentItems(@Body() body: { type: 'tour' | 'photo' | 'note'; ids: string[] }) {
const { type, ids } = body;
console.log('[deletePermanent] Request received - type:', type, 'ids count:', ids.length);
if (!type || !ids || !Array.isArray(ids)) {
throw new BadRequestException('Tham số không hợp lệ.');
}
if (type === 'tour') {
for (const tourId of ids) {
console.log('[deletePermanent] Deleting tour:', tourId);
const photos = await this.prisma.photo.findMany({ where: { tourId } });
console.log('[deletePermanent] Found', photos.length, 'photos for tour');
for (const p of photos) {
if (p.imageUrl) {
const displayFilePath = path.join(process.cwd(), p.imageUrl.replace(/^\//, ''));
console.log('[deletePermanent] Checking display file:', displayFilePath);
if (fs.existsSync(displayFilePath)) {
try { fs.unlinkSync(displayFilePath); } catch (e) {}
try {
fs.unlinkSync(displayFilePath);
console.log('[deletePermanent] Deleted display file:', displayFilePath);
} catch (e) {
console.error('[deletePermanent] Error deleting display file:', e);
}
} else {
console.log('[deletePermanent] Display file not found (OK):', displayFilePath);
}
}
if (p.originalUrl) {
const originalFilePath = path.join(process.cwd(), p.originalUrl.replace(/^\//, ''));
console.log('[deletePermanent] Checking original file:', originalFilePath);
if (fs.existsSync(originalFilePath)) {
try { fs.unlinkSync(originalFilePath); } catch (e) {}
try {
fs.unlinkSync(originalFilePath);
console.log('[deletePermanent] Deleted original file:', originalFilePath);
} catch (e) {
console.error('[deletePermanent] Error deleting original file:', e);
}
} else {
console.log('[deletePermanent] Original file not found (OK):', originalFilePath);
}
}
}
await this.prisma.tour.delete({ where: { id: tourId } });
console.log('[deletePermanent] Deleted tour from database:', tourId);
}
} else if (type === 'photo') {
console.log('[deletePermanent] Deleting', ids.length, 'photos');
for (const photoId of ids) {
console.log('[deletePermanent] Processing photo:', photoId);
const photo = await this.prisma.photo.findUnique({ where: { id: photoId } });
if (photo) {
if (photo.imageUrl) {
const displayFilePath = path.join(process.cwd(), photo.imageUrl.replace(/^\//, ''));
if (fs.existsSync(displayFilePath)) {
try { fs.unlinkSync(displayFilePath); } catch (e) {}
}
}
if (photo.originalUrl) {
const originalFilePath = path.join(process.cwd(), photo.originalUrl.replace(/^\//, ''));
if (fs.existsSync(originalFilePath)) {
try { fs.unlinkSync(originalFilePath); } catch (e) {}
}
}
await this.prisma.photo.delete({ where: { id: photoId } });
if (!photo) {
console.warn('[deletePermanent] Photo not found:', photoId);
continue;
}
console.log('[deletePermanent] Photo found, imageUrl:', photo.imageUrl);
if (photo.imageUrl) {
const displayFilePath = path.join(process.cwd(), photo.imageUrl.replace(/^\//, ''));
console.log('[deletePermanent] Checking display file:', displayFilePath);
if (fs.existsSync(displayFilePath)) {
try {
fs.unlinkSync(displayFilePath);
console.log('[deletePermanent] Deleted display file');
} catch (e) {
console.error('[deletePermanent] Error deleting display file:', e);
}
} else {
console.log('[deletePermanent] Display file not found (OK)');
}
}
if (photo.originalUrl) {
const originalFilePath = path.join(process.cwd(), photo.originalUrl.replace(/^\//, ''));
console.log('[deletePermanent] Checking original file:', originalFilePath);
if (fs.existsSync(originalFilePath)) {
try {
fs.unlinkSync(originalFilePath);
console.log('[deletePermanent] Deleted original file');
} catch (e) {
console.error('[deletePermanent] Error deleting original file:', e);
}
} else {
console.log('[deletePermanent] Original file not found (OK)');
}
}
const result = await this.prisma.photo.delete({ where: { id: photoId } });
console.log('[deletePermanent] Deleted photo from database:', photoId);
}
} else if (type === 'note') {
await this.prisma.tourNote.deleteMany({
console.log('[deletePermanent] Deleting', ids.length, 'notes');
const result = await this.prisma.tourNote.deleteMany({
where: { id: { in: ids } }
});
console.log('[deletePermanent] Deleted notes from database, count:', result.count);
}
console.log('[deletePermanent] Deletion complete, type:', type);
return { success: true };
}
}