feat: tải ảnh lên bằng tài khoản public và cho phép bình luận
This commit is contained in:
Vendored
+2
@@ -24,5 +24,7 @@ export declare class CommentGateway implements OnGatewayConnection {
|
||||
server: Server;
|
||||
handleConnection(client: Socket): void;
|
||||
handleJoinTour(client: Socket, tourId: string): void;
|
||||
handleJoinPhoto(client: Socket, photoId: string): void;
|
||||
notifyNewComment(tourId: string, data: any): void;
|
||||
notifyNewPhotoComment(photoId: string, data: any): void;
|
||||
}
|
||||
|
||||
Vendored
+282
-17
@@ -56,6 +56,8 @@ require("reflect-metadata");
|
||||
const zlib = __importStar(require("zlib"));
|
||||
const util_1 = require("util");
|
||||
const sharp_1 = __importDefault(require("sharp"));
|
||||
const exifr_1 = __importDefault(require("exifr"));
|
||||
const heic_convert_1 = __importDefault(require("heic-convert"));
|
||||
const core_1 = require("@nestjs/core");
|
||||
const common_1 = require("@nestjs/common");
|
||||
const platform_express_1 = require("@nestjs/platform-express");
|
||||
@@ -147,8 +149,11 @@ let TourRoleGuard = class TourRoleGuard {
|
||||
}
|
||||
else {
|
||||
const photo = await this.prisma.photo.findUnique({ where: { id: resourceId }, select: { tourId: true } });
|
||||
if (photo)
|
||||
if (photo) {
|
||||
if (!photo.tourId)
|
||||
return true;
|
||||
tourId = photo.tourId;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -320,6 +325,23 @@ let AuthController = class AuthController {
|
||||
},
|
||||
};
|
||||
}
|
||||
async createGuestUser() {
|
||||
const user = await this.prisma.user.create({
|
||||
data: {
|
||||
isAnonymous: true,
|
||||
name: `Lữ khách #${Math.floor(1000 + Math.random() * 9000)}`,
|
||||
},
|
||||
});
|
||||
const payload = { sub: user.id, isAnonymous: true };
|
||||
return {
|
||||
access_token: this.jwtService.sign(payload),
|
||||
user: {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
isAnonymous: user.isAnonymous,
|
||||
},
|
||||
};
|
||||
}
|
||||
async getStatus() {
|
||||
const userCount = await this.prisma.user.count();
|
||||
console.log(`[Status Check] Users found: ${userCount}`);
|
||||
@@ -363,7 +385,7 @@ let AuthController = class AuthController {
|
||||
}
|
||||
}
|
||||
async signupVerify(body) {
|
||||
const { email, otp } = body;
|
||||
const { email, otp, guestId } = body;
|
||||
const storedOtp = await this.cacheManager.get(`signup_otp:${email}`);
|
||||
if (!storedOtp || storedOtp !== otp) {
|
||||
throw new common_1.BadRequestException('Mã OTP không chính xác hoặc đã hết hạn.');
|
||||
@@ -373,13 +395,41 @@ let AuthController = class AuthController {
|
||||
throw new common_1.BadRequestException('Thông tin đăng ký đã hết hạn. Vui lòng thực hiện lại từ đầu.');
|
||||
}
|
||||
const { password, name, phone, address } = JSON.parse(cachedDataStr);
|
||||
const userCount = await this.prisma.user.count();
|
||||
const shouldBeAdmin = userCount === 0;
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
const user = await this.prisma.user.create({
|
||||
data: { email, passwordHash, name, phone, address, isAdmin: shouldBeAdmin },
|
||||
select: { id: true, email: true, name: true, phone: true, address: true, isAdmin: true }
|
||||
});
|
||||
let user;
|
||||
if (guestId) {
|
||||
const guestUser = await this.prisma.user.findUnique({
|
||||
where: { id: guestId }
|
||||
});
|
||||
if (guestUser && guestUser.isAnonymous) {
|
||||
const existingUser = await this.prisma.user.findFirst({
|
||||
where: { email, isAnonymous: false }
|
||||
});
|
||||
if (existingUser) {
|
||||
throw new common_1.BadRequestException('Email này đã được đăng ký bởi tài khoản khác.');
|
||||
}
|
||||
user = await this.prisma.user.update({
|
||||
where: { id: guestId },
|
||||
data: {
|
||||
email,
|
||||
passwordHash,
|
||||
name,
|
||||
phone,
|
||||
address,
|
||||
isAnonymous: false,
|
||||
},
|
||||
select: { id: true, email: true, name: true, phone: true, address: true, isAdmin: true }
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!user) {
|
||||
const userCount = await this.prisma.user.count();
|
||||
const shouldBeAdmin = userCount === 0;
|
||||
user = await this.prisma.user.create({
|
||||
data: { email, passwordHash, name, phone, address, isAdmin: shouldBeAdmin },
|
||||
select: { id: true, email: true, name: true, phone: true, address: true, isAdmin: true }
|
||||
});
|
||||
}
|
||||
await Promise.all([
|
||||
this.cacheManager.del(`signup_otp:${email}`),
|
||||
this.cacheManager.del(`signup_data:${email}`)
|
||||
@@ -394,6 +444,12 @@ __decorate([
|
||||
__metadata("design:paramtypes", [Object]),
|
||||
__metadata("design:returntype", Promise)
|
||||
], AuthController.prototype, "convertGuestToOfficial", null);
|
||||
__decorate([
|
||||
(0, common_1.Post)('create-guest'),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", []),
|
||||
__metadata("design:returntype", Promise)
|
||||
], AuthController.prototype, "createGuestUser", null);
|
||||
__decorate([
|
||||
(0, common_1.Get)('status'),
|
||||
__metadata("design:type", Function),
|
||||
@@ -998,12 +1054,28 @@ let TourController = class TourController {
|
||||
fs.mkdirSync(tourDisplayPath, { recursive: true });
|
||||
return Promise.all(files.map(async (file) => {
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
||||
const extension = path.extname(file.originalname).toLowerCase() || '.jpg';
|
||||
const filename = `${uniqueSuffix}${extension}`;
|
||||
const originalFilePath = path.join(memberOriginalDir, filename);
|
||||
const displayFilePath = path.join(tourDisplayPath, filename);
|
||||
const originalExtension = path.extname(file.originalname).toLowerCase() || '.jpg';
|
||||
const originalFilename = `${uniqueSuffix}${originalExtension}`;
|
||||
const displayFilename = `${uniqueSuffix}.jpg`;
|
||||
const originalFilePath = path.join(memberOriginalDir, originalFilename);
|
||||
const displayFilePath = path.join(tourDisplayPath, displayFilename);
|
||||
await fs.promises.writeFile(originalFilePath, file.buffer);
|
||||
await (0, sharp_1.default)(file.buffer)
|
||||
let processBuffer = file.buffer;
|
||||
const isHeic = file.originalname.toLowerCase().endsWith('.heic') || file.originalname.toLowerCase().endsWith('.heif') || file.mimetype === 'image/heic' || file.mimetype === 'image/heif';
|
||||
if (isHeic) {
|
||||
try {
|
||||
processBuffer = await (0, heic_convert_1.default)({
|
||||
buffer: file.buffer,
|
||||
format: 'JPEG',
|
||||
quality: 1
|
||||
});
|
||||
console.log(`[HEIC] Converted original HEIC image to JPEG for display`);
|
||||
}
|
||||
catch (e) {
|
||||
console.warn('[HEIC] Failed to convert HEIC to JPEG, attempting to process anyway:', e.message);
|
||||
}
|
||||
}
|
||||
await (0, sharp_1.default)(processBuffer)
|
||||
.resize(2560, 2560, {
|
||||
fit: 'inside',
|
||||
withoutEnlargement: true
|
||||
@@ -1014,8 +1086,8 @@ let TourController = class TourController {
|
||||
data: {
|
||||
tourId: tourId,
|
||||
uploaderId: uploaderId,
|
||||
imageUrl: `/uploads/tours/${filename}`,
|
||||
originalUrl: `/uploads/members/${uploaderId}/originals/${filename}`,
|
||||
imageUrl: `/uploads/tours/${displayFilename}`,
|
||||
originalUrl: `/uploads/members/${uploaderId}/originals/${originalFilename}`,
|
||||
privacy: 'TOUR_ONLY',
|
||||
}
|
||||
});
|
||||
@@ -1488,6 +1560,86 @@ let PhotoController = class PhotoController {
|
||||
constructor(prisma) {
|
||||
this.prisma = prisma;
|
||||
}
|
||||
async uploadAnonymousPhoto(files, req) {
|
||||
if (!files || files.length === 0) {
|
||||
throw new common_1.BadRequestException('Vui lòng chọn một ảnh.');
|
||||
}
|
||||
const uploaderId = req.user.id;
|
||||
const isAnonymous = req.user.isAnonymous;
|
||||
if (!isAnonymous) {
|
||||
throw new common_1.ForbiddenException('Chỉ người dùng khách mới có thể sử dụng tính năng này.');
|
||||
}
|
||||
const file = files[0];
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
||||
const originalExtension = path.extname(file.originalname).toLowerCase() || '.jpg';
|
||||
const originalFilename = `${uniqueSuffix}${originalExtension}`;
|
||||
const displayFilename = `${uniqueSuffix}.jpg`;
|
||||
const memberOriginalDir = path.join(UPLOAD_ROOT, 'members', uploaderId, 'originals');
|
||||
const displayFilePath = path.join(UPLOAD_ROOT, 'tours', displayFilename);
|
||||
const originalFilePath = path.join(memberOriginalDir, originalFilename);
|
||||
if (!fs.existsSync(memberOriginalDir)) {
|
||||
fs.mkdirSync(memberOriginalDir, { recursive: true });
|
||||
}
|
||||
await fs.promises.writeFile(originalFilePath, file.buffer);
|
||||
let lat;
|
||||
let lng;
|
||||
try {
|
||||
const gps = await exifr_1.default.gps(file.buffer);
|
||||
if (gps && typeof gps.latitude === 'number' && typeof gps.longitude === 'number') {
|
||||
lat = gps.latitude;
|
||||
lng = gps.longitude;
|
||||
console.log(`[EXIF GPS] Đã tìm thấy tọa độ từ EXIF: lat=${lat}, lng=${lng}`);
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
console.warn('[EXIF GPS] Không thể giải nén GPS từ EXIF ảnh:', e.message);
|
||||
}
|
||||
if (lat === undefined || lng === undefined) {
|
||||
const bodyLat = req.body.latitude ? parseFloat(req.body.latitude) : undefined;
|
||||
const bodyLng = req.body.longitude ? parseFloat(req.body.longitude) : undefined;
|
||||
if (bodyLat !== undefined && !isNaN(bodyLat) && bodyLng !== undefined && !isNaN(bodyLng)) {
|
||||
lat = bodyLat;
|
||||
lng = bodyLng;
|
||||
console.log(`[EXIF GPS] Sử dụng tọa độ dự phòng từ Frontend: lat=${lat}, lng=${lng}`);
|
||||
}
|
||||
}
|
||||
if (lat === undefined || lng === undefined) {
|
||||
lat = 10.7769;
|
||||
lng = 106.7009;
|
||||
console.log(`[EXIF GPS] Không tìm thấy tọa độ nào, ghim tại TP.HCM mặc định: lat=${lat}, lng=${lng}`);
|
||||
}
|
||||
let processBuffer = file.buffer;
|
||||
const isHeic = file.originalname.toLowerCase().endsWith('.heic') || file.originalname.toLowerCase().endsWith('.heif') || file.mimetype === 'image/heic' || file.mimetype === 'image/heif';
|
||||
if (isHeic) {
|
||||
try {
|
||||
processBuffer = await (0, heic_convert_1.default)({
|
||||
buffer: file.buffer,
|
||||
format: 'JPEG',
|
||||
quality: 1
|
||||
});
|
||||
console.log(`[HEIC] Converted anonymous HEIC image to JPEG for display`);
|
||||
}
|
||||
catch (e) {
|
||||
console.warn('[HEIC] Failed to convert HEIC to JPEG, attempting to process anyway:', e.message);
|
||||
}
|
||||
}
|
||||
await (0, sharp_1.default)(processBuffer)
|
||||
.resize(2560, 2560, { fit: 'inside', withoutEnlargement: true })
|
||||
.jpeg({ quality: 85 })
|
||||
.toFile(displayFilePath);
|
||||
return this.prisma.photo.create({
|
||||
data: {
|
||||
uploaderId: uploaderId,
|
||||
imageUrl: `/uploads/tours/${displayFilename}`,
|
||||
originalUrl: `/uploads/members/${uploaderId}/originals/${originalFilename}`,
|
||||
privacy: 'PUBLIC',
|
||||
metadata: {
|
||||
lat: lat,
|
||||
lng: lng
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
async deletePhoto(id, req) {
|
||||
const photo = await this.prisma.photo.findUnique({
|
||||
where: { id },
|
||||
@@ -1495,7 +1647,7 @@ let PhotoController = class PhotoController {
|
||||
if (!photo) {
|
||||
throw new common_1.NotFoundException('Không tìm thấy ảnh.');
|
||||
}
|
||||
if (photo.uploaderId !== req.user.id) {
|
||||
if (photo.uploaderId !== req.user.id && !req.user.isAdmin) {
|
||||
throw new common_1.ForbiddenException('Bạn không có quyền xóa ảnh này.');
|
||||
}
|
||||
if (photo.imageUrl) {
|
||||
@@ -1514,6 +1666,16 @@ let PhotoController = class PhotoController {
|
||||
return { message: 'Ảnh đã được xóa thành công.' };
|
||||
}
|
||||
};
|
||||
__decorate([
|
||||
(0, common_1.Post)('upload-anonymous'),
|
||||
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard),
|
||||
(0, common_1.UseInterceptors)((0, platform_express_1.FilesInterceptor)('images', 1)),
|
||||
__param(0, (0, common_1.UploadedFiles)()),
|
||||
__param(1, (0, common_1.Req)()),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", [Array, Object]),
|
||||
__metadata("design:returntype", Promise)
|
||||
], PhotoController.prototype, "uploadAnonymousPhoto", null);
|
||||
__decorate([
|
||||
(0, common_1.Delete)(':id'),
|
||||
__param(0, (0, common_1.Param)('id', common_1.ParseUUIDPipe)),
|
||||
@@ -1661,9 +1823,16 @@ let CommentGateway = class CommentGateway {
|
||||
client.join(`tour_${tourId}`);
|
||||
console.log(`[WS] Client ${client.id} joined room: tour_${tourId}`);
|
||||
}
|
||||
handleJoinPhoto(client, photoId) {
|
||||
client.join(`photo_${photoId}`);
|
||||
console.log(`[WS] Client ${client.id} joined room: photo_${photoId}`);
|
||||
}
|
||||
notifyNewComment(tourId, data) {
|
||||
this.server.to(`tour_${tourId}`).emit('commentAdded', data);
|
||||
}
|
||||
notifyNewPhotoComment(photoId, data) {
|
||||
this.server.to(`photo_${photoId}`).emit('photoCommentAdded', data);
|
||||
}
|
||||
};
|
||||
exports.CommentGateway = CommentGateway;
|
||||
__decorate([
|
||||
@@ -1676,6 +1845,12 @@ __decorate([
|
||||
__metadata("design:paramtypes", [socket_io_1.Socket, String]),
|
||||
__metadata("design:returntype", void 0)
|
||||
], CommentGateway.prototype, "handleJoinTour", null);
|
||||
__decorate([
|
||||
(0, websockets_1.SubscribeMessage)('joinPhoto'),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", [socket_io_1.Socket, String]),
|
||||
__metadata("design:returntype", void 0)
|
||||
], CommentGateway.prototype, "handleJoinPhoto", null);
|
||||
exports.CommentGateway = CommentGateway = __decorate([
|
||||
(0, websockets_1.WebSocketGateway)({ cors: { origin: '*' } })
|
||||
], CommentGateway);
|
||||
@@ -1842,6 +2017,96 @@ AdminOtpController = __decorate([
|
||||
__metadata("design:paramtypes", [prisma_service_1.PrismaService,
|
||||
EmailService, Object])
|
||||
], AdminOtpController);
|
||||
let PublicPhotoController = class PublicPhotoController {
|
||||
constructor(prisma, commentGateway) {
|
||||
this.prisma = prisma;
|
||||
this.commentGateway = commentGateway;
|
||||
}
|
||||
async getPublicPhotos() {
|
||||
return this.prisma.photo.findMany({
|
||||
where: { privacy: 'PUBLIC' },
|
||||
select: {
|
||||
id: true,
|
||||
imageUrl: true,
|
||||
originalUrl: true,
|
||||
capturedAt: true,
|
||||
metadata: true,
|
||||
uploader: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: { capturedAt: 'desc' }
|
||||
});
|
||||
}
|
||||
async getPhotoComments(photoId) {
|
||||
return this.prisma.comment.findMany({
|
||||
where: { photoId },
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, name: true }
|
||||
}
|
||||
},
|
||||
orderBy: { createdAt: 'asc' }
|
||||
});
|
||||
}
|
||||
async addPhotoComment(photoId, body, req) {
|
||||
const { content } = body;
|
||||
if (!content || content.trim() === '') {
|
||||
throw new common_1.BadRequestException('Nội dung bình luận không được để trống.');
|
||||
}
|
||||
const photo = await this.prisma.photo.findUnique({
|
||||
where: { id: photoId }
|
||||
});
|
||||
if (!photo) {
|
||||
throw new common_1.NotFoundException('Không tìm thấy ảnh.');
|
||||
}
|
||||
const comment = await this.prisma.comment.create({
|
||||
data: {
|
||||
content: content.trim(),
|
||||
photoId,
|
||||
userId: req.user.id
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, name: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
this.commentGateway.notifyNewPhotoComment(photoId, comment);
|
||||
return comment;
|
||||
}
|
||||
};
|
||||
__decorate([
|
||||
(0, common_1.Get)(),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", []),
|
||||
__metadata("design:returntype", Promise)
|
||||
], PublicPhotoController.prototype, "getPublicPhotos", null);
|
||||
__decorate([
|
||||
(0, common_1.Get)(':photoId/comments'),
|
||||
__param(0, (0, common_1.Param)('photoId', common_1.ParseUUIDPipe)),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", [String]),
|
||||
__metadata("design:returntype", Promise)
|
||||
], PublicPhotoController.prototype, "getPhotoComments", null);
|
||||
__decorate([
|
||||
(0, common_1.UseGuards)(jwt_auth_guard_1.JwtAuthGuard),
|
||||
(0, common_1.Post)(':photoId/comments'),
|
||||
__param(0, (0, common_1.Param)('photoId', common_1.ParseUUIDPipe)),
|
||||
__param(1, (0, common_1.Body)()),
|
||||
__param(2, (0, common_1.Req)()),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", [String, Object, Object]),
|
||||
__metadata("design:returntype", Promise)
|
||||
], PublicPhotoController.prototype, "addPhotoComment", null);
|
||||
PublicPhotoController = __decorate([
|
||||
(0, common_1.Controller)('public-photos'),
|
||||
__metadata("design:paramtypes", [prisma_service_1.PrismaService,
|
||||
CommentGateway])
|
||||
], PublicPhotoController);
|
||||
let AppModule = class AppModule {
|
||||
};
|
||||
AppModule = __decorate([
|
||||
@@ -1866,7 +2131,7 @@ AppModule = __decorate([
|
||||
signOptions: { expiresIn: '1d' },
|
||||
}),
|
||||
],
|
||||
controllers: [AppController, AuthController, PublicTourController, TourController, UserController, RoutingController, LegController, LocationController, CommentController, PhotoController, AdminOtpController],
|
||||
controllers: [AppController, AuthController, PublicTourController, TourController, UserController, RoutingController, LegController, LocationController, CommentController, PhotoController, PublicPhotoController, AdminOtpController],
|
||||
providers: [prisma_service_1.PrismaService, jwt_strategy_1.JwtStrategy, TourRoleGuard, jwt_auth_guard_1.JwtAuthGuard, admin_guard_1.AdminGuard, CommentGateway, core_2.Reflector, EmailService, config_1.ConfigService],
|
||||
exports: [prisma_service_1.PrismaService]
|
||||
})
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -34,6 +34,8 @@
|
||||
"cache-manager": "^7.2.8",
|
||||
"cache-manager-redis-yet": "^5.1.5",
|
||||
"dotenv": "^17.4.2",
|
||||
"exifr": "^7.1.3",
|
||||
"heic-convert": "^2.1.0",
|
||||
"nodemailer": "^9.0.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Comment" ADD COLUMN "photoId" TEXT,
|
||||
ALTER COLUMN "locationId" DROP NOT NULL;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Comment" ADD CONSTRAINT "Comment_photoId_fkey" FOREIGN KEY ("photoId") REFERENCES "Photo"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -193,14 +193,17 @@ model Photo {
|
||||
tour Tour? @relation(fields: [tourId], references: [id], onDelete: SetNull)
|
||||
location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
|
||||
uploader User @relation(fields: [uploaderId], references: [id])
|
||||
comments Comment[]
|
||||
}
|
||||
|
||||
model Comment {
|
||||
id String @id @default(uuid())
|
||||
content String
|
||||
createdAt DateTime @default(now())
|
||||
locationId String
|
||||
location Location @relation(fields: [locationId], references: [id], onDelete: Cascade)
|
||||
locationId String?
|
||||
location Location? @relation(fields: [locationId], references: [id], onDelete: Cascade)
|
||||
photoId String?
|
||||
photo Photo? @relation(fields: [photoId], references: [id], onDelete: Cascade)
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
+289
-20
@@ -6,6 +6,8 @@ import 'reflect-metadata';
|
||||
import * as zlib from 'zlib';
|
||||
import { promisify } from 'util';
|
||||
import sharp from 'sharp';
|
||||
import exifr from 'exifr';
|
||||
import heicConvert from 'heic-convert';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { Module, Controller, Get, Post, Body, Param, Patch, Delete, ParseUUIDPipe, NotFoundException, BadRequestException, UnauthorizedException, UseGuards, Req, Query, ForbiddenException, Injectable, UseInterceptors, UploadedFiles, Inject, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||
@@ -128,7 +130,10 @@ export class TourRoleGuard implements CanActivate {
|
||||
} else {
|
||||
// Thử xem resourceId có phải là photoId không
|
||||
const photo = await this.prisma.photo.findUnique({ where: { id: resourceId }, select: { tourId: true } });
|
||||
if (photo) tourId = photo.tourId;
|
||||
if (photo) {
|
||||
if (!photo.tourId) return true; // Cho phép đi qua nếu ảnh không thuộc tour nào (ví dụ ảnh ẩn danh public)
|
||||
tourId = photo.tourId;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -317,6 +322,26 @@ class AuthController {
|
||||
};
|
||||
}
|
||||
|
||||
@Post('create-guest')
|
||||
async createGuestUser() {
|
||||
const user = await this.prisma.user.create({
|
||||
data: {
|
||||
isAnonymous: true,
|
||||
name: `Lữ khách #${Math.floor(1000 + Math.random() * 9000)}`,
|
||||
},
|
||||
});
|
||||
|
||||
const payload = { sub: user.id, isAnonymous: true };
|
||||
return {
|
||||
access_token: this.jwtService.sign(payload),
|
||||
user: {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
isAnonymous: user.isAnonymous,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Get('status')
|
||||
async getStatus() {
|
||||
const userCount = await this.prisma.user.count();
|
||||
@@ -374,8 +399,8 @@ class AuthController {
|
||||
}
|
||||
|
||||
@Post('signup/verify')
|
||||
async signupVerify(@Body() body: { email: string; otp: string }) {
|
||||
const { email, otp } = body;
|
||||
async signupVerify(@Body() body: { email: string; otp: string; guestId?: string }) {
|
||||
const { email, otp, guestId } = body;
|
||||
|
||||
const storedOtp = await this.cacheManager.get<string>(`signup_otp:${email}`);
|
||||
if (!storedOtp || storedOtp !== otp) {
|
||||
@@ -388,15 +413,48 @@ class AuthController {
|
||||
}
|
||||
|
||||
const { password, name, phone, address } = JSON.parse(cachedDataStr);
|
||||
const userCount = await this.prisma.user.count();
|
||||
const shouldBeAdmin = userCount === 0;
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
|
||||
const user = await this.prisma.user.create({
|
||||
data: { email, passwordHash, name, phone, address, isAdmin: shouldBeAdmin },
|
||||
select: { id: true, email: true, name: true, phone: true, address: true, isAdmin: true }
|
||||
});
|
||||
let user;
|
||||
if (guestId) {
|
||||
// Tìm tài khoản khách
|
||||
const guestUser = await this.prisma.user.findUnique({
|
||||
where: { id: guestId }
|
||||
});
|
||||
if (guestUser && guestUser.isAnonymous) {
|
||||
// Kiểm tra xem email này đã được sử dụng bởi một tài khoản chính thức khác chưa
|
||||
const existingUser = await this.prisma.user.findFirst({
|
||||
where: { email, isAnonymous: false }
|
||||
});
|
||||
if (existingUser) {
|
||||
throw new BadRequestException('Email này đã được đăng ký bởi tài khoản khác.');
|
||||
}
|
||||
|
||||
// Cập nhật thông tin của tài khoản khách
|
||||
user = await this.prisma.user.update({
|
||||
where: { id: guestId },
|
||||
data: {
|
||||
email,
|
||||
passwordHash,
|
||||
name,
|
||||
phone,
|
||||
address,
|
||||
isAnonymous: false,
|
||||
},
|
||||
select: { id: true, email: true, name: true, phone: true, address: true, isAdmin: true }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
const userCount = await this.prisma.user.count();
|
||||
const shouldBeAdmin = userCount === 0;
|
||||
|
||||
user = await this.prisma.user.create({
|
||||
data: { email, passwordHash, name, phone, address, isAdmin: shouldBeAdmin },
|
||||
select: { id: true, email: true, name: true, phone: true, address: true, isAdmin: true }
|
||||
});
|
||||
}
|
||||
|
||||
// Xóa dữ liệu cache sau khi đăng ký thành công
|
||||
await Promise.all([
|
||||
@@ -1112,18 +1170,34 @@ class TourController {
|
||||
|
||||
return Promise.all(files.map(async (file) => {
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
||||
const extension = path.extname(file.originalname).toLowerCase() || '.jpg';
|
||||
const filename = `${uniqueSuffix}${extension}`;
|
||||
const originalExtension = path.extname(file.originalname).toLowerCase() || '.jpg';
|
||||
const originalFilename = `${uniqueSuffix}${originalExtension}`;
|
||||
const displayFilename = `${uniqueSuffix}.jpg`;
|
||||
|
||||
const originalFilePath = path.join(memberOriginalDir, filename);
|
||||
const displayFilePath = path.join(tourDisplayPath, filename);
|
||||
const originalFilePath = path.join(memberOriginalDir, originalFilename);
|
||||
const displayFilePath = path.join(tourDisplayPath, displayFilename);
|
||||
|
||||
// 1. Lưu ảnh gốc nguyên bản vào thư mục riêng của thành viên
|
||||
await fs.promises.writeFile(originalFilePath, file.buffer);
|
||||
|
||||
// 2. Xử lý ảnh để hiển thị (Độ phân giải 2K: tối đa 2560px)
|
||||
let processBuffer = file.buffer;
|
||||
const isHeic = file.originalname.toLowerCase().endsWith('.heic') || file.originalname.toLowerCase().endsWith('.heif') || file.mimetype === 'image/heic' || file.mimetype === 'image/heif';
|
||||
if (isHeic) {
|
||||
try {
|
||||
processBuffer = await heicConvert({
|
||||
buffer: file.buffer,
|
||||
format: 'JPEG',
|
||||
quality: 1
|
||||
});
|
||||
console.log(`[HEIC] Converted original HEIC image to JPEG for display`);
|
||||
} catch (e) {
|
||||
console.warn('[HEIC] Failed to convert HEIC to JPEG, attempting to process anyway:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Sử dụng Sharp để resize và tối ưu dung lượng ảnh
|
||||
await sharp(file.buffer)
|
||||
await sharp(processBuffer)
|
||||
.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
|
||||
@@ -1136,8 +1210,8 @@ class TourController {
|
||||
data: {
|
||||
tourId: tourId,
|
||||
uploaderId: uploaderId,
|
||||
imageUrl: `/uploads/tours/${filename}`, // URL ảnh 2K dùng để render
|
||||
originalUrl: `/uploads/members/${uploaderId}/originals/${filename}`, // URL ảnh gốc để tải xuống
|
||||
imageUrl: `/uploads/tours/${displayFilename}`, // URL ảnh 2K dùng để render (luôn là .jpg)
|
||||
originalUrl: `/uploads/members/${uploaderId}/originals/${originalFilename}`, // URL ảnh gốc để tải xuống
|
||||
privacy: 'TOUR_ONLY',
|
||||
}
|
||||
});
|
||||
@@ -1426,6 +1500,110 @@ class RoutingController {
|
||||
class PhotoController {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
@Post('upload-anonymous')
|
||||
@UseGuards(JwtAuthGuard) // Vẫn dùng Guard để lấy user từ token
|
||||
@UseInterceptors(FilesInterceptor('images', 1)) // Chỉ cho phép 1 ảnh mỗi lần
|
||||
async uploadAnonymousPhoto(@UploadedFiles() files: any[], @Req() req: any) {
|
||||
if (!files || files.length === 0) {
|
||||
throw new BadRequestException('Vui lòng chọn một ảnh.');
|
||||
}
|
||||
|
||||
const uploaderId = req.user.id;
|
||||
const isAnonymous = req.user.isAnonymous;
|
||||
|
||||
// Chỉ người dùng ẩn danh mới được dùng endpoint này
|
||||
if (!isAnonymous) {
|
||||
throw new ForbiddenException('Chỉ người dùng khách mới có thể sử dụng tính năng này.');
|
||||
}
|
||||
|
||||
const file = files[0];
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
||||
const originalExtension = path.extname(file.originalname).toLowerCase() || '.jpg';
|
||||
const originalFilename = `${uniqueSuffix}${originalExtension}`;
|
||||
const displayFilename = `${uniqueSuffix}.jpg`;
|
||||
|
||||
const memberOriginalDir = path.join(UPLOAD_ROOT, 'members', uploaderId, 'originals');
|
||||
const displayFilePath = path.join(UPLOAD_ROOT, 'tours', displayFilename);
|
||||
const originalFilePath = path.join(memberOriginalDir, originalFilename);
|
||||
|
||||
// Đảm bảo thư mục lưu trữ ảnh gốc tồn tại
|
||||
if (!fs.existsSync(memberOriginalDir)) {
|
||||
fs.mkdirSync(memberOriginalDir, { 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);
|
||||
|
||||
// 2. Trích xuất GPS từ EXIF bằng exifr
|
||||
let lat: number | undefined;
|
||||
let lng: number | undefined;
|
||||
|
||||
try {
|
||||
const gps = await exifr.gps(file.buffer);
|
||||
if (gps && typeof gps.latitude === 'number' && typeof gps.longitude === 'number') {
|
||||
lat = gps.latitude;
|
||||
lng = gps.longitude;
|
||||
console.log(`[EXIF GPS] Đã tìm thấy tọa độ từ EXIF: lat=${lat}, lng=${lng}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[EXIF GPS] Không thể giải nén GPS từ EXIF ảnh:', e.message);
|
||||
}
|
||||
|
||||
// 3. Nếu EXIF không có tọa độ, dùng tọa độ dự phòng gửi từ frontend
|
||||
if (lat === undefined || lng === undefined) {
|
||||
const bodyLat = req.body.latitude ? parseFloat(req.body.latitude) : undefined;
|
||||
const bodyLng = req.body.longitude ? parseFloat(req.body.longitude) : undefined;
|
||||
|
||||
if (bodyLat !== undefined && !isNaN(bodyLat) && bodyLng !== undefined && !isNaN(bodyLng)) {
|
||||
lat = bodyLat;
|
||||
lng = bodyLng;
|
||||
console.log(`[EXIF GPS] Sử dụng tọa độ dự phòng từ Frontend: lat=${lat}, lng=${lng}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Nếu vẫn không có, sử dụng vị trí mặc định (TP.HCM)
|
||||
if (lat === undefined || lng === undefined) {
|
||||
lat = 10.7769;
|
||||
lng = 106.7009;
|
||||
console.log(`[EXIF GPS] Không tìm thấy tọa độ nào, ghim tại TP.HCM mặc định: lat=${lat}, lng=${lng}`);
|
||||
}
|
||||
|
||||
// 5. Chuyển đổi HEIC nếu cần để lưu hiển thị
|
||||
let processBuffer = file.buffer;
|
||||
const isHeic = file.originalname.toLowerCase().endsWith('.heic') || file.originalname.toLowerCase().endsWith('.heif') || file.mimetype === 'image/heic' || file.mimetype === 'image/heif';
|
||||
if (isHeic) {
|
||||
try {
|
||||
processBuffer = await heicConvert({
|
||||
buffer: file.buffer,
|
||||
format: 'JPEG',
|
||||
quality: 1
|
||||
});
|
||||
console.log(`[HEIC] Converted anonymous HEIC image to JPEG for display`);
|
||||
} catch (e) {
|
||||
console.warn('[HEIC] Failed to convert HEIC to JPEG, attempting to process anyway:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Xử lý ảnh để hiển thị (kích thước tối đa 2K: 2560px)
|
||||
await sharp(processBuffer)
|
||||
.resize(2560, 2560, { fit: 'inside', withoutEnlargement: true })
|
||||
.jpeg({ quality: 85 })
|
||||
.toFile(displayFilePath);
|
||||
|
||||
return this.prisma.photo.create({
|
||||
data: {
|
||||
uploaderId: uploaderId,
|
||||
imageUrl: `/uploads/tours/${displayFilename}`,
|
||||
originalUrl: `/uploads/members/${uploaderId}/originals/${originalFilename}`,
|
||||
privacy: 'PUBLIC',
|
||||
metadata: {
|
||||
lat: lat,
|
||||
lng: lng
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async deletePhoto(@Param('id', ParseUUIDPipe) id: string, @Req() req: any) {
|
||||
const photo = await this.prisma.photo.findUnique({
|
||||
@@ -1436,8 +1614,8 @@ class PhotoController {
|
||||
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) {
|
||||
// Chỉ người tải lên hoặc Admin mới có quyền xóa ảnh
|
||||
if (photo.uploaderId !== req.user.id && !req.user.isAdmin) {
|
||||
throw new ForbiddenException('Bạn không có quyền xóa ảnh này.');
|
||||
}
|
||||
|
||||
@@ -1577,10 +1755,21 @@ export class CommentGateway implements OnGatewayConnection {
|
||||
console.log(`[WS] Client ${client.id} joined room: tour_${tourId}`);
|
||||
}
|
||||
|
||||
@SubscribeMessage('joinPhoto')
|
||||
handleJoinPhoto(client: Socket, photoId: string) {
|
||||
client.join(`photo_${photoId}`);
|
||||
console.log(`[WS] Client ${client.id} joined room: photo_${photoId}`);
|
||||
}
|
||||
|
||||
notifyNewComment(tourId: string, data: any) {
|
||||
// Gửi thông báo tới tất cả client trong phòng của Tour này
|
||||
this.server.to(`tour_${tourId}`).emit('commentAdded', data);
|
||||
}
|
||||
|
||||
notifyNewPhotoComment(photoId: string, data: any) {
|
||||
// Gửi thông báo tới tất cả client trong phòng của Ảnh này
|
||||
this.server.to(`photo_${photoId}`).emit('photoCommentAdded', data);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('locations')
|
||||
@@ -1748,6 +1937,86 @@ class AdminOtpController {
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('public-photos')
|
||||
class PublicPhotoController {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private commentGateway: CommentGateway
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
async getPublicPhotos() {
|
||||
return this.prisma.photo.findMany({
|
||||
where: { privacy: 'PUBLIC' },
|
||||
select: {
|
||||
id: true,
|
||||
imageUrl: true,
|
||||
originalUrl: true,
|
||||
capturedAt: true,
|
||||
metadata: true,
|
||||
uploader: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: { capturedAt: 'desc' }
|
||||
});
|
||||
}
|
||||
|
||||
@Get(':photoId/comments')
|
||||
async getPhotoComments(@Param('photoId', ParseUUIDPipe) photoId: string) {
|
||||
return this.prisma.comment.findMany({
|
||||
where: { photoId },
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, name: true }
|
||||
}
|
||||
},
|
||||
orderBy: { createdAt: 'asc' }
|
||||
});
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post(':photoId/comments')
|
||||
async addPhotoComment(
|
||||
@Param('photoId', ParseUUIDPipe) photoId: string,
|
||||
@Body() body: { content: string },
|
||||
@Req() req: any
|
||||
) {
|
||||
const { content } = body;
|
||||
if (!content || content.trim() === '') {
|
||||
throw new BadRequestException('Nội dung bình luận không được để trống.');
|
||||
}
|
||||
|
||||
const photo = await this.prisma.photo.findUnique({
|
||||
where: { id: photoId }
|
||||
});
|
||||
if (!photo) {
|
||||
throw new NotFoundException('Không tìm thấy ảnh.');
|
||||
}
|
||||
|
||||
const comment = await this.prisma.comment.create({
|
||||
data: {
|
||||
content: content.trim(),
|
||||
photoId,
|
||||
userId: req.user.id
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, name: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Phát tín hiệu WebSocket cho các client đang xem ảnh này
|
||||
this.commentGateway.notifyNewPhotoComment(photoId, comment);
|
||||
|
||||
return comment;
|
||||
}
|
||||
}
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
@@ -1771,7 +2040,7 @@ class AdminOtpController {
|
||||
signOptions: { expiresIn: '1d' },
|
||||
}) as any,
|
||||
],
|
||||
controllers: [AppController, AuthController, PublicTourController, TourController, UserController, RoutingController, LegController, LocationController, CommentController, PhotoController, AdminOtpController],
|
||||
controllers: [AppController, AuthController, PublicTourController, TourController, UserController, RoutingController, LegController, LocationController, CommentController, PhotoController, PublicPhotoController, AdminOtpController],
|
||||
providers: [PrismaService, JwtStrategy, TourRoleGuard, JwtAuthGuard, AdminGuard, CommentGateway, Reflector, EmailService, ConfigService],
|
||||
exports: [PrismaService]
|
||||
})
|
||||
|
||||
BIN
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 888 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 611 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 756 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
Reference in New Issue
Block a user