Files
lcbp3/backend/src/common/file-storage/file-storage.service.ts
admin 276d06e950
All checks were successful
Build and Deploy / deploy (push) Successful in 2m49s
260228:1412 20260228: setup n8n
2026-02-28 14:12:48 +07:00

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}`);
}
}