322 lines
12 KiB
TypeScript
322 lines
12 KiB
TypeScript
// 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<Attachment>,
|
|
private configService: ConfigService
|
|
) {
|
|
// ใช้ env vars จาก docker-compose สำหรับ Production
|
|
// ถ้าไม่ได้กำหนดจะ fallback เป็น ./uploads/temp และ ./uploads/permanent
|
|
this.tempDir =
|
|
this.configService.get<string>('UPLOAD_TEMP_DIR') ||
|
|
path.join(process.cwd(), 'uploads', 'temp');
|
|
this.permanentDir =
|
|
this.configService.get<string>('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<Attachment> {
|
|
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<Attachment[]> {
|
|
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<Attachment> {
|
|
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<void> {
|
|
// 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}`);
|
|
}
|
|
}
|