feat(migration): ADR-028 migration architecture refactor

- เพิ่ม POST /api/ai/jobs + GET /api/ai/jobs/:jobId endpoints (FR-001, FR-002)
- เพิ่ม BullMQ Worker MigrateDocumentWorker + OCR auto-detect (FR-003, FR-004)
- เพิ่ม cleanup-temp-files + expire-pending-reviews workers (FR-005, FR-005a/b)
- สร้าง SQL deltas: tags, correspondence_tags, alter migration_review_queue (FR-006, ADR-009)
- เพิ่ม MigrationReviewService.commitRecord() + SELECT FOR UPDATE (FR-007, FR-007a)
- เพิ่ม CASL permission migration.commit + MigrationReviewController (FR-007)
- สร้าง TagsModule + TagsService + TagsController (US3)
- สร้าง Migration Review Queue frontend page + ReviewQueueTable (US2)
- อัปเดต n8n guide: deterministic Idempotency-Key + token pre-flight (FR-001a, FR-010a/b)
- สร้าง spec.md, plan.md, tasks.md, data-model.md, contracts/, quickstart.md
- สร้าง ADR-028 document + validation-report.md (PASS 32/32 tasks, 173/173 tests)
This commit is contained in:
2026-05-22 17:10:07 +07:00
parent 990d80e16d
commit a2973be208
55 changed files with 4256 additions and 107 deletions
@@ -0,0 +1,88 @@
// File: src/modules/ai/workers/cleanup-temp-files.worker.ts
// Change Log:
// - 2026-05-22: อัปเดตและสร้างตัวล้างไฟล์ชั่วคราว (T016) เพื่อลบไฟล์ที่หมดอายุ 24 ชม.
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as fs from 'fs-extra';
import { Attachment } from '../../../common/file-storage/entities/attachment.entity';
import {
MigrationReviewQueue,
MigrationReviewStatus,
} from '../../migration/entities/migration-review-queue.entity';
@Injectable()
export class CleanupTempFilesWorker {
private readonly logger = new Logger(CleanupTempFilesWorker.name);
constructor(
@InjectRepository(Attachment)
private readonly attachmentRepository: Repository<Attachment>,
@InjectRepository(MigrationReviewQueue)
private readonly reviewQueueRepository: Repository<MigrationReviewQueue>
) {}
/**
* รันทุกชั่วโมงเพื่อลบไฟล์แนบชั่วคราวที่ครบ 24 ชั่วโมงและไม่ได้ถูกคอมมิต
* ยกเว้นไฟล์ที่ถูกอ้างอิงโดยรายการที่สถานะเป็น PENDING ใน Migration Review Queue
*/
@Cron(CronExpression.EVERY_HOUR)
async handleCleanup(): Promise<void> {
this.logger.log('Starting temporary files cleanup worker...');
try {
const oneDayAgo = new Date();
oneDayAgo.setHours(oneDayAgo.getHours() - 24);
const pendingRecords = await this.reviewQueueRepository.find({
select: ['tempAttachmentId'],
where: { status: MigrationReviewStatus.PENDING },
});
const pendingAttachmentIds = pendingRecords
.map((r) => r.tempAttachmentId)
.filter((id): id is number => id !== undefined && id !== null);
const query = this.attachmentRepository
.createQueryBuilder('attachment')
.where('attachment.isTemporary = :isTemporary', { isTemporary: true })
.andWhere('attachment.createdAt < :oneDayAgo', { oneDayAgo });
if (pendingAttachmentIds.length > 0) {
query.andWhere('attachment.id NOT IN (:...pendingAttachmentIds)', {
pendingAttachmentIds,
});
}
const expiredAttachments = await query.getMany();
if (expiredAttachments.length === 0) {
this.logger.log('No expired temporary files found.');
return;
}
this.logger.log(
`Found ${expiredAttachments.length} expired temporary files. Deleting...`
);
let deletedCount = 0;
let failedCount = 0;
for (const att of expiredAttachments) {
try {
if (await fs.pathExists(att.filePath)) {
await fs.remove(att.filePath);
}
await this.attachmentRepository.remove(att);
deletedCount++;
} catch (error) {
const errMessage = (error as Error).message;
this.logger.error(
`Failed to delete temporary file ID ${att.id}: ${errMessage}`
);
failedCount++;
}
}
this.logger.log(
`Temporary files cleanup completed. Deleted: ${deletedCount}, Failed: ${failedCount}`
);
} catch (err) {
const errMsg = (err as Error).message;
this.logger.error(
`Error occurred during temporary files cleanup: ${errMsg}`
);
}
}
}