251122:1700 Phase 4

This commit is contained in:
admin
2025-11-22 17:21:55 +07:00
parent bf0308e350
commit a3474bff6a
63 changed files with 10062 additions and 109 deletions

View File

@@ -5,7 +5,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthService } from './auth.service.js';
import { AuthController } from './auth.controller.js';
import { UserModule } from '../../modules/user/user.module.js';
import { JwtStrategy } from './jwt.strategy.js';
import { JwtStrategy } from '../guards/jwt.strategy.js';
@Module({
imports: [

View File

@@ -0,0 +1,19 @@
// File: src/common/decorators/current-user.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
/**
* Decorator สำหรับดึงข้อมูล User ปัจจุบันจาก Request Object
* ใช้คู่กับ JwtAuthGuard
*
* ตัวอย่างการใช้:
* @Get()
* findAll(@CurrentUser() user: User) { ... }
*/
export const CurrentUser = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
// request.user ถูก set โดย Passport/JwtStrategy
return request.user;
},
);

View File

@@ -0,0 +1,53 @@
import {
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { User } from '../../../modules/user/entities/user.entity.js';
@Entity('attachments')
export class Attachment {
@PrimaryGeneratedColumn()
id!: number;
@Column({ name: 'original_filename', length: 255 })
originalFilename!: string;
@Column({ name: 'stored_filename', length: 255 })
storedFilename!: string;
@Column({ name: 'file_path', length: 500 })
filePath!: string;
@Column({ name: 'mime_type', length: 100 })
mimeType!: string;
@Column({ name: 'file_size' })
fileSize!: number;
@Column({ name: 'is_temporary', default: true })
isTemporary!: boolean;
@Column({ name: 'temp_id', length: 100, nullable: true })
tempId?: string;
@Column({ name: 'expires_at', type: 'datetime', nullable: true })
expiresAt?: Date;
@Column({ length: 64, nullable: true })
checksum?: string;
@Column({ name: 'uploaded_by_user_id' })
uploadedByUserId!: number;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
// Relation กับ User (คนอัปโหลด)
@ManyToOne(() => User)
@JoinColumn({ name: 'uploaded_by_user_id' })
uploadedBy?: User;
}

View File

@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { FileStorageController } from './file-storage.controller';
describe('FileStorageController', () => {
let controller: FileStorageController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [FileStorageController],
}).compile();
controller = module.get<FileStorageController>(FileStorageController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});

View File

@@ -0,0 +1,49 @@
import {
Controller,
Post,
UseInterceptors,
UploadedFile,
UseGuards,
Request,
ParseFilePipe,
MaxFileSizeValidator,
FileTypeValidator,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { FileStorageService } from './file-storage.service.js';
import { JwtAuthGuard } from '../guards/jwt-auth.guard.js';
// ✅ 1. สร้าง Interface เพื่อระบุ Type ของ Request
interface RequestWithUser {
user: {
userId: number;
username: string;
};
}
@Controller('files')
@UseGuards(JwtAuthGuard)
export class FileStorageController {
constructor(private readonly fileStorageService: FileStorageService) {}
@Post('upload')
@UseInterceptors(FileInterceptor('file')) // รับ field ชื่อ 'file'
async uploadFile(
@UploadedFile(
new ParseFilePipe({
validators: [
new MaxFileSizeValidator({ maxSize: 50 * 1024 * 1024 }), // 50MB
// ตรวจสอบประเภทไฟล์ (Regex)
new FileTypeValidator({
fileType: /(pdf|msword|openxmlformats|zip|octet-stream)/,
}),
],
}),
)
file: Express.Multer.File,
@Request() req: RequestWithUser, // ✅ 2. ระบุ Type ตรงนี้แทน any
) {
// ส่ง userId จาก Token ไปด้วย
return this.fileStorageService.upload(file, req.user.userId);
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { FileStorageService } from './file-storage.service.js';
import { FileStorageController } from './file-storage.controller.js';
import { Attachment } from './entities/attachment.entity.js';
@Module({
imports: [TypeOrmModule.forFeature([Attachment])],
controllers: [FileStorageController],
providers: [FileStorageService],
exports: [FileStorageService], // Export ให้ Module อื่น (เช่น Correspondence) เรียกใช้ตอน Commit
})
export class FileStorageModule {}

View File

@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { FileStorageService } from './file-storage.service';
describe('FileStorageService', () => {
let service: FileStorageService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [FileStorageService],
}).compile();
service = module.get<FileStorageService>(FileStorageService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@@ -0,0 +1,128 @@
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.js';
@Injectable()
export class FileStorageService {
private readonly logger = new Logger(FileStorageService.name);
private readonly uploadRoot: string;
constructor(
@InjectRepository(Attachment)
private attachmentRepository: Repository<Attachment>,
private configService: ConfigService,
) {
// ใช้ Path จริงถ้าอยู่บน Server (Production) หรือใช้ ./uploads ถ้าอยู่ Local
this.uploadRoot =
this.configService.get('NODE_ENV') === 'production'
? '/share/dms-data'
: path.join(process.cwd(), 'uploads');
// สร้างโฟลเดอร์รอไว้เลยถ้ายังไม่มี
fs.ensureDirSync(path.join(this.uploadRoot, 'temp'));
}
/**
* 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.uploadRoot, 'temp', 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
*/
async commit(tempIds: string[]): Promise<Attachment[]> {
const attachments = await this.attachmentRepository.find({
where: { tempId: In(tempIds), isTemporary: true },
});
if (attachments.length !== tempIds.length) {
throw new NotFoundException('Some files not found or already committed');
}
const committedAttachments: Attachment[] = [];
const today = new Date();
const year = today.getFullYear().toString();
const month = (today.getMonth() + 1).toString().padStart(2, '0');
// โฟลเดอร์ถาวรแยกตาม ปี/เดือน
const permanentDir = path.join(this.uploadRoot, 'permanent', year, month);
await fs.ensureDir(permanentDir);
for (const att of attachments) {
const oldPath = att.filePath;
const newPath = path.join(permanentDir, att.storedFilename);
try {
// ย้ายไฟล์
await fs.move(oldPath, newPath, { overwrite: true });
// อัปเดตข้อมูลใน DB
att.filePath = newPath;
att.isTemporary = false;
att.tempId = undefined; // เคลียร์ tempId
att.expiresAt = undefined; // เคลียร์วันหมดอายุ
committedAttachments.push(await this.attachmentRepository.save(att));
} catch (error) {
this.logger.error(
`Failed to move file from ${oldPath} to ${newPath}`,
error,
);
// ถ้า error ตัวนึง ควรจะ rollback หรือ throw error (ในที่นี้ throw เพื่อให้ Transaction ของผู้เรียกจัดการ)
throw new BadRequestException(
`Failed to commit file: ${att.originalFilename}`,
);
}
}
return committedAttachments;
}
private calculateChecksum(buffer: Buffer): string {
return crypto.createHash('sha256').update(buffer).digest('hex');
}
}

View File

@@ -9,18 +9,26 @@ interface JwtPayload {
username: string;
}
import { UserService } from '../../modules/user/user.service.js';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(configService: ConfigService) {
constructor(
configService: ConfigService,
private userService: UserService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
// ใส่ ! เพื่อยืนยันว่ามีค่าแน่นอน (ConfigValidation เช็คให้แล้ว)
secretOrKey: configService.get<string>('JWT_SECRET')!,
});
}
async validate(payload: JwtPayload) {
return { userId: payload.sub, username: payload.username };
const user = await this.userService.findOne(payload.sub);
if (!user) {
throw new Error('User not found');
}
return user;
}
}