// File: src/common/file-storage/file-storage.service.ts import { Injectable, NotFoundException, BadRequestException, Logger, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, In } from 'typeorm'; import { ConfigService } from '@nestjs/config'; import * as fs from 'fs-extra'; import * as path from 'path'; import * as crypto from 'crypto'; import { v4 as uuidv4 } from 'uuid'; import { Attachment } from './entities/attachment.entity'; import { ForbiddenException } from '@nestjs/common'; // ✅ Import เพิ่ม @Injectable() export class FileStorageService { private readonly logger = new Logger(FileStorageService.name); private readonly tempDir: string; private readonly permanentDir: string; constructor( @InjectRepository(Attachment) private attachmentRepository: Repository, private configService: ConfigService ) { // ใช้ env vars จาก docker-compose สำหรับ Production // ถ้าไม่ได้กำหนดจะ fallback เป็น ./uploads/temp และ ./uploads/permanent this.tempDir = this.configService.get('UPLOAD_TEMP_DIR') || path.join(process.cwd(), 'uploads', 'temp'); this.permanentDir = this.configService.get('UPLOAD_PERMANENT_DIR') || path.join(process.cwd(), 'uploads', 'permanent'); // สร้างโฟลเดอร์ temp และ permanent รอไว้เลยถ้ายังไม่มี fs.ensureDirSync(this.tempDir); fs.ensureDirSync(this.permanentDir); } /** * Phase 1: Upload (บันทึกไฟล์ลง Temp) */ async upload(file: Express.Multer.File, userId: number): Promise { const tempId = uuidv4(); const fileExt = path.extname(file.originalname); const storedFilename = `${uuidv4()}${fileExt}`; const tempPath = path.join(this.tempDir, storedFilename); // 1. คำนวณ Checksum (SHA-256) เพื่อความปลอดภัยและความถูกต้องของไฟล์ const checksum = this.calculateChecksum(file.buffer); // 2. บันทึกไฟล์ลง Disk (Temp Folder) try { await fs.writeFile(tempPath, file.buffer); } catch (error) { this.logger.error(`Failed to write file: ${tempPath}`, error); throw new BadRequestException('File upload failed'); } // 3. สร้าง Record ใน Database const attachment = this.attachmentRepository.create({ originalFilename: file.originalname, storedFilename: storedFilename, filePath: tempPath, // เก็บ path ปัจจุบันไปก่อน mimeType: file.mimetype, fileSize: file.size, isTemporary: true, tempId: tempId, expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // หมดอายุใน 24 ชม. checksum: checksum, uploadedByUserId: userId, }); return this.attachmentRepository.save(attachment); } /** * Phase 2: Commit (ย้ายไฟล์จาก Temp -> Permanent) * เมธอดนี้จะถูกเรียกโดย Service อื่น (เช่น CorrespondenceService) เมื่อกด Save */ /** * Phase 2: Commit (ย้ายไฟล์จาก Temp -> Permanent) * เมธอดนี้จะถูกเรียกโดย Service อื่น (เช่น CorrespondenceService) เมื่อกด Save * Updated [Phase 2]: Support issueDate and documentType for organized storage */ async commit( tempIds: string[], options?: { issueDate?: Date; documentType?: string } ): Promise { if (!tempIds || tempIds.length === 0) { return []; } const attachments = await this.attachmentRepository.find({ where: { tempId: In(tempIds), isTemporary: true }, }); if (attachments.length !== tempIds.length) { // แจ้งเตือนแต่อาจจะไม่ throw ถ้าต้องการให้ process ต่อไปได้บางส่วน (ขึ้นอยู่กับ business logic) // แต่เพื่อความปลอดภัยควรแจ้งว่าไฟล์ไม่ครบ this.logger.warn( `Expected ${tempIds.length} files to commit, but found ${attachments.length}` ); throw new NotFoundException('Some files not found or already committed'); } const committedAttachments: Attachment[] = []; // Use issueDate if provided, otherwise default to current date const refDate = options?.issueDate ? new Date(options.issueDate) : new Date(); // Validate Date (in case invalid string passed) const effectiveDate = isNaN(refDate.getTime()) ? new Date() : refDate; const year = effectiveDate.getFullYear().toString(); const month = (effectiveDate.getMonth() + 1).toString().padStart(2, '0'); // Construct Path: permanent/{DocumentType}/{YYYY}/{MM}/filename const docTypeFolder = options?.documentType || 'General'; // โฟลเดอร์ถาวรแยกตาม Type/ปี/เดือน const permanentDir = path.join( this.permanentDir, docTypeFolder, year, month ); await fs.ensureDir(permanentDir); for (const att of attachments) { const oldPath = att.filePath; const newPath = path.join(permanentDir, att.storedFilename); try { // ย้ายไฟล์ if (await fs.pathExists(oldPath)) { await fs.move(oldPath, newPath, { overwrite: true }); // อัปเดตข้อมูลใน DB att.filePath = newPath; att.isTemporary = false; att.tempId = null as any; // เคลียร์ tempId (TypeORM อาจต้องการ null แทน undefined สำหรับ nullable) att.expiresAt = null as any; // เคลียร์วันหมดอายุ att.referenceDate = effectiveDate; // Save reference date committedAttachments.push(await this.attachmentRepository.save(att)); } else { this.logger.error(`File missing during commit: ${oldPath}`); throw new NotFoundException( `File not found on disk: ${att.originalFilename}` ); } } catch (error) { this.logger.error( `Failed to move file from ${oldPath} to ${newPath}`, error ); throw new BadRequestException( `Failed to commit file: ${att.originalFilename}` ); } } return committedAttachments; } /** * Download File * ดึงไฟล์มาเป็น Stream เพื่อส่งกลับไปให้ Controller */ async download( id: number ): Promise<{ stream: fs.ReadStream; attachment: Attachment }> { // 1. ค้นหาข้อมูลไฟล์จาก DB const attachment = await this.attachmentRepository.findOne({ where: { id }, }); if (!attachment) { throw new NotFoundException(`Attachment #${id} not found`); } // 2. ตรวจสอบว่าไฟล์มีอยู่จริงบน Disk หรือไม่ const filePath = attachment.filePath; if (!fs.existsSync(filePath)) { this.logger.error(`File missing on disk: ${filePath}`); throw new NotFoundException('File not found on server storage'); } // 3. สร้าง Read Stream (มีประสิทธิภาพกว่าการโหลดทั้งไฟล์เข้า Memory) const stream = fs.createReadStream(filePath); return { stream, attachment }; } private calculateChecksum(buffer: Buffer): string { return crypto.createHash('sha256').update(buffer).digest('hex'); } /** * ✅ NEW: Import Staging File (For Legacy Migration) * ย้ายไฟล์จาก staging_ai ไปยัง permanent storage โดยตรง */ async importStagingFile( sourceFilePath: string, userId: number, options?: { issueDate?: Date; documentType?: string } ): Promise { if (!(await fs.pathExists(sourceFilePath))) { this.logger.error(`Staging file not found: ${sourceFilePath}`); throw new NotFoundException(`Source file not found: ${sourceFilePath}`); } // 1. Get file stats & checksum const stats = await fs.stat(sourceFilePath); const fileExt = path.extname(sourceFilePath); const originalFilename = path.basename(sourceFilePath); const storedFilename = `${uuidv4()}${fileExt}`; // Determine mime type basic let mimeType = 'application/octet-stream'; if (fileExt.toLowerCase() === '.pdf') mimeType = 'application/pdf'; else if (fileExt.toLowerCase() === '.xlsx') mimeType = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; const fileBuffer = await fs.readFile(sourceFilePath); const checksum = this.calculateChecksum(fileBuffer); // 2. Generate Permanent Path const refDate = options?.issueDate || new Date(); const effectiveDate = isNaN(refDate.getTime()) ? new Date() : refDate; const year = effectiveDate.getFullYear().toString(); const month = (effectiveDate.getMonth() + 1).toString().padStart(2, '0'); const docTypeFolder = options?.documentType || 'General'; const permanentDir = path.join( this.permanentDir, docTypeFolder, year, month ); await fs.ensureDir(permanentDir); const newPath = path.join(permanentDir, storedFilename); // 3. Move File try { await fs.move(sourceFilePath, newPath, { overwrite: true }); } catch (error) { this.logger.error(`Failed to move staging file to ${newPath}`, error); throw new BadRequestException('Failed to process staging file'); } // 4. Create Database Record const attachment = this.attachmentRepository.create({ originalFilename, storedFilename, filePath: newPath, mimeType, fileSize: stats.size, isTemporary: false, referenceDate: effectiveDate, checksum, uploadedByUserId: userId, }); return this.attachmentRepository.save(attachment); } /** * ✅ NEW: Delete File * ลบไฟล์ออกจาก Disk และ Database */ async delete(id: number, userId: number): Promise { // 1. ค้นหาไฟล์ const attachment = await this.attachmentRepository.findOne({ where: { id }, }); if (!attachment) { throw new NotFoundException(`Attachment #${id} not found`); } // 2. ตรวจสอบความเป็นเจ้าของ (Security Check) // อนุญาตให้ลบถ้าเป็นคนอัปโหลดเอง // (ในอนาคตอาจเพิ่มเงื่อนไข OR User เป็น Admin/Document Control) if (attachment.uploadedByUserId !== userId) { this.logger.warn( `User ${userId} tried to delete file ${id} owned by ${attachment.uploadedByUserId}` ); throw new ForbiddenException('You are not allowed to delete this file'); } // 3. ลบไฟล์ออกจาก Disk try { if (await fs.pathExists(attachment.filePath)) { await fs.remove(attachment.filePath); } else { this.logger.warn( `File not found on disk during deletion: ${attachment.filePath}` ); } } catch (error) { this.logger.error( `Failed to delete file from disk: ${attachment.filePath}`, error ); throw new BadRequestException('Failed to delete file from storage'); } // 4. ลบ Record ออกจาก Database await this.attachmentRepository.remove(attachment); this.logger.log(`File deleted: ${id} by user ${userId}`); } }