feat(ai): unify AI architecture, implement RAG and legacy migration
CI / CD Pipeline / build (push) Failing after 5m36s
CI / CD Pipeline / deploy (push) Has been skipped

This commit is contained in:
2026-05-15 11:10:44 +07:00
parent 0240d80da5
commit 6cb3ae10ee
56 changed files with 6051 additions and 304 deletions
+8 -2
View File
@@ -29,10 +29,16 @@ export const envValidationSchema = Joi.object({
REDIS_PORT: Joi.number().default(6379),
REDIS_PASSWORD: Joi.string().required(),
// 5. AI Gateway Configuration (ADR-018, ADR-020)
// 5. AI Gateway Configuration (ADR-023)
// URL หลักของเครื่อง AI Host (Desk-5439)
AI_HOST_URL: Joi.string().uri().optional(),
// URL ของ Qdrant บนเครื่อง AI Host
AI_QDRANT_URL: Joi.string().uri().optional(),
// Token สำหรับ n8n Service Account ตาม ADR-023
AI_N8N_SERVICE_TOKEN: Joi.string().optional(),
// URL ของ n8n Webhook สำหรับส่งเอกสารไปประมวลผล
AI_N8N_WEBHOOK_URL: Joi.string().uri().optional(),
// Token สำหรับ Service Account Authentication กับ n8n
// Legacy alias: ใช้ AI_N8N_SERVICE_TOKEN สำหรับงานใหม่
AI_N8N_AUTH_TOKEN: Joi.string().optional(),
// URL ของ Ollama บน Admin Desktop (Desk-5439)
AI_OLLAMA_URL: Joi.string().uri().optional(),
@@ -0,0 +1,486 @@
// File: src/modules/ai/ai-ingest.service.spec.ts
// Change Log
// - 2026-05-14: เพิ่ม Unit Tests ครอบคลุม AiIngestService — ingest, listQueue, approve (ADR-023).
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { getRepositoryToken } from '@nestjs/typeorm';
import { AiIngestService } from './ai-ingest.service';
import { AiQueueService } from './ai-queue.service';
import { FileStorageService } from '../../common/file-storage/file-storage.service';
import { MigrationService } from '../migration/migration.service';
import {
MigrationReviewRecord,
MigrationReviewRecordStatus,
} from './entities/migration-review.entity';
import { AiAuditLog } from './entities/ai-audit-log.entity';
import { Project } from '../project/entities/project.entity';
import { Organization } from '../organization/entities/organization.entity';
import { CorrespondenceType } from '../correspondence/entities/correspondence-type.entity';
import {
BusinessException,
NotFoundException,
ValidationException,
} from '../../common/exceptions';
// ─── Fixtures ────────────────────────────────────────────────────────────────
function makeFile(
overrides: Partial<Express.Multer.File> = {}
): Express.Multer.File {
return {
fieldname: 'files',
originalname: 'test.pdf',
encoding: '7bit',
mimetype: 'application/pdf',
buffer: Buffer.from('pdf-content'),
size: 1024,
stream: null as unknown as NodeJS.ReadableStream,
destination: '',
filename: 'test.pdf',
path: '',
...overrides,
};
}
function makePendingRecord(
overrides: Partial<MigrationReviewRecord> = {}
): MigrationReviewRecord {
return {
id: 1,
publicId: 'rec-uuid-001',
batchId: 'batch-001',
originalFileName: 'test.pdf',
sourceAttachmentPublicId: 'att-uuid-001',
tempAttachmentId: 10,
extractedMetadata: { subject: 'Test' },
confidenceScore: 0.9,
status: MigrationReviewRecordStatus.PENDING,
errorReason: undefined,
version: 1,
createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-01'),
...overrides,
} as MigrationReviewRecord;
}
// ─── Mocks ───────────────────────────────────────────────────────────────────
const mockConfig = {
get: jest.fn((key: string) => (key === 'AI_SERVICE_USER_ID' ? 1 : undefined)),
};
const mockFileStorage = {
upload: jest.fn().mockResolvedValue({ id: 10, publicId: 'att-uuid-001' }),
};
const mockAiQueue = {
enqueueIngest: jest.fn().mockResolvedValue('job-id-001'),
};
const mockMigration = {
importCorrespondence: jest
.fn()
.mockResolvedValue({ publicId: 'corr-uuid-001' }),
};
const mockReviewRepo = {
create: jest.fn(),
save: jest.fn(),
findOne: jest.fn(),
createQueryBuilder: jest.fn().mockReturnValue({
where: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
getManyAndCount: jest.fn().mockResolvedValue([[], 0]),
}),
};
const mockAuditLogRepo = {
create: jest.fn(),
save: jest.fn().mockResolvedValue({}),
};
const mockProjectRepo = {
findOne: jest.fn().mockResolvedValue({ id: 5, publicId: 'proj-uuid-001' }),
};
const mockOrgRepo = {
findOne: jest.fn().mockResolvedValue({ id: 3, publicId: 'org-uuid-001' }),
};
const mockCorrTypeRepo = {
findOne: jest
.fn()
.mockResolvedValue({ id: 2, typeCode: 'CORR', typeName: 'Correspondence' }),
};
// ─── Tests ───────────────────────────────────────────────────────────────────
describe('AiIngestService', () => {
let service: AiIngestService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AiIngestService,
{ provide: ConfigService, useValue: mockConfig },
{ provide: FileStorageService, useValue: mockFileStorage },
{ provide: AiQueueService, useValue: mockAiQueue },
{ provide: MigrationService, useValue: mockMigration },
{
provide: getRepositoryToken(MigrationReviewRecord),
useValue: mockReviewRepo,
},
{ provide: getRepositoryToken(AiAuditLog), useValue: mockAuditLogRepo },
{ provide: getRepositoryToken(Project), useValue: mockProjectRepo },
{ provide: getRepositoryToken(Organization), useValue: mockOrgRepo },
{
provide: getRepositoryToken(CorrespondenceType),
useValue: mockCorrTypeRepo,
},
],
}).compile();
service = module.get<AiIngestService>(AiIngestService);
jest.clearAllMocks();
// ค่าเริ่มต้นของ mocks หลัง clearAllMocks
mockConfig.get.mockImplementation((key: string) =>
key === 'AI_SERVICE_USER_ID' ? 1 : undefined
);
mockFileStorage.upload.mockResolvedValue({
id: 10,
publicId: 'att-uuid-001',
});
mockAiQueue.enqueueIngest.mockResolvedValue('job-id-001');
mockMigration.importCorrespondence.mockResolvedValue({
publicId: 'corr-uuid-001',
});
mockProjectRepo.findOne.mockResolvedValue({
id: 5,
publicId: 'proj-uuid-001',
});
mockOrgRepo.findOne.mockResolvedValue({ id: 3, publicId: 'org-uuid-001' });
mockCorrTypeRepo.findOne.mockResolvedValue({
id: 2,
typeCode: 'CORR',
typeName: 'Correspondence',
});
mockAuditLogRepo.create.mockReturnValue({});
mockAuditLogRepo.save.mockResolvedValue({});
});
it('should be defined', () => {
expect(service).toBeDefined();
});
// ─── ingest() ──────────────────────────────────────────────────────────────
describe('ingest()', () => {
it('ควร throw ValidationException เมื่อไม่มีไฟล์และไม่มี records', async () => {
await expect(
service.ingest({ batchId: 'batch-001', records: [] }, [])
).rejects.toThrow(ValidationException);
});
it('ควร throw ValidationException เมื่อไฟล์เกิน 50MB', async () => {
const bigFile = makeFile({ size: 51 * 1024 * 1024 });
await expect(
service.ingest({ batchId: 'batch-001' }, [bigFile])
).rejects.toThrow(ValidationException);
});
it('ควร throw ValidationException เมื่อ MIME type ไม่รองรับ (image/png)', async () => {
const pngFile = makeFile({ mimetype: 'image/png' });
await expect(
service.ingest({ batchId: 'batch-001' }, [pngFile])
).rejects.toThrow(ValidationException);
});
it('ควร throw ValidationException เมื่อ records JSON ไม่ถูกต้อง', async () => {
await expect(
service.ingest({ batchId: 'batch-001', records: '{invalid-json' }, [])
).rejects.toThrow(ValidationException);
});
it('ควรสร้าง staging record และ enqueue job เมื่อรับไฟล์ที่ถูกต้อง', async () => {
const file = makeFile();
const createdRecord = makePendingRecord();
mockReviewRepo.create.mockReturnValue(createdRecord);
mockReviewRepo.save.mockResolvedValue([createdRecord]);
const result = await service.ingest({ batchId: 'batch-001' }, [file]);
expect(mockFileStorage.upload).toHaveBeenCalledWith(file, 1);
expect(mockReviewRepo.create).toHaveBeenCalledWith(
expect.objectContaining({ batchId: 'batch-001' })
);
expect(mockAiQueue.enqueueIngest).toHaveBeenCalledWith(
expect.objectContaining({ batchId: 'batch-001' })
);
expect(result).toMatchObject({ batchId: 'batch-001', queued: 1 });
});
it('ควรสร้างหลาย record จาก records JSON (ไม่มีไฟล์)', async () => {
const records = [
{ originalFileName: 'doc-1.pdf', confidenceScore: 0.9 },
{ originalFileName: 'doc-2.pdf', confidenceScore: 0.8 },
];
const createdRecord = makePendingRecord();
mockReviewRepo.create.mockReturnValue(createdRecord);
mockReviewRepo.save.mockResolvedValue([createdRecord, createdRecord]);
const result = await service.ingest(
{ batchId: 'batch-002', records },
[]
);
expect(mockReviewRepo.create).toHaveBeenCalledTimes(2);
expect(result.queued).toBe(2);
});
it('ควรยอมรับ records เป็น JSON string', async () => {
const recordsStr = JSON.stringify([{ originalFileName: 'doc.pdf' }]);
const createdRecord = makePendingRecord();
mockReviewRepo.create.mockReturnValue(createdRecord);
mockReviewRepo.save.mockResolvedValue([createdRecord]);
await expect(
service.ingest({ batchId: 'batch-003', records: recordsStr }, [])
).resolves.toMatchObject({ batchId: 'batch-003', queued: 1 });
});
});
// ─── deriveStatus() (ผ่าน ingest) ─────────────────────────────────────────
describe('deriveStatus — สถานะจาก record input', () => {
beforeEach(() => {
mockReviewRepo.save.mockImplementation(
(records: MigrationReviewRecord[]) => Promise.resolve(records)
);
});
it('ควร derive REJECTED เมื่อ confidenceScore < 0.6', async () => {
mockReviewRepo.create.mockImplementation(
(data: Partial<MigrationReviewRecord>) =>
({ ...data }) as MigrationReviewRecord
);
const records = [{ originalFileName: 'low.pdf', confidenceScore: 0.5 }];
await service.ingest({ batchId: 'b', records }, []);
expect(mockReviewRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
status: MigrationReviewRecordStatus.REJECTED,
})
);
});
it('ควร derive REJECTED เมื่อมี errorReason', async () => {
mockReviewRepo.create.mockImplementation(
(data: Partial<MigrationReviewRecord>) =>
({ ...data }) as MigrationReviewRecord
);
const records = [
{ originalFileName: 'err.pdf', errorReason: 'OCR failed' },
];
await service.ingest({ batchId: 'b', records }, []);
expect(mockReviewRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
status: MigrationReviewRecordStatus.REJECTED,
})
);
});
it('ควร derive PENDING เมื่อ confidence >= 0.6 และไม่มี errorReason', async () => {
mockReviewRepo.create.mockImplementation(
(data: Partial<MigrationReviewRecord>) =>
({ ...data }) as MigrationReviewRecord
);
const records = [{ originalFileName: 'ok.pdf', confidenceScore: 0.85 }];
await service.ingest({ batchId: 'b', records }, []);
expect(mockReviewRepo.create).toHaveBeenCalledWith(
expect.objectContaining({ status: MigrationReviewRecordStatus.PENDING })
);
});
});
// ─── listQueue() ───────────────────────────────────────────────────────────
describe('listQueue()', () => {
it('ควรคืน paginated response ที่ถูกต้อง', async () => {
const items = [makePendingRecord(), makePendingRecord()];
mockReviewRepo.createQueryBuilder.mockReturnValue({
where: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
getManyAndCount: jest.fn().mockResolvedValue([items, 25]),
});
const result = await service.listQueue({ page: 2, limit: 10 });
expect(result.total).toBe(25);
expect(result.page).toBe(2);
expect(result.limit).toBe(10);
expect(result.totalPages).toBe(3); // Math.ceil(25/10)
expect(result.items).toHaveLength(2);
});
it('ควรใช้ค่า default page=1, limit=20 เมื่อไม่ระบุ', async () => {
const qb = {
orderBy: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
getManyAndCount: jest.fn().mockResolvedValue([[], 0]),
};
mockReviewRepo.createQueryBuilder.mockReturnValue(qb);
await service.listQueue({});
expect(qb.skip).toHaveBeenCalledWith(0); // (1-1)*20
expect(qb.take).toHaveBeenCalledWith(20);
});
});
// ─── approve() ─────────────────────────────────────────────────────────────
describe('approve()', () => {
const dto = {
documentNumber: 'CORR-001',
subject: 'Test Subject',
categoryCode: 'CORR',
projectPublicId: 'proj-uuid-001',
finalMetadata: { subject: 'Corrected Subject' },
};
it('ควร throw ValidationException เมื่อ idempotencyKey ว่าง', async () => {
await expect(service.approve('rec-uuid-001', dto, '', 1)).rejects.toThrow(
ValidationException
);
});
it('ควร throw NotFoundException เมื่อไม่พบ record', async () => {
mockReviewRepo.findOne.mockResolvedValue(null);
await expect(
service.approve('not-found', dto, 'idem-key-001', 1)
).rejects.toThrow(NotFoundException);
});
it('ควร throw BusinessException เมื่อ record ไม่อยู่ในสถานะ PENDING (IMPORTED)', async () => {
mockReviewRepo.findOne.mockResolvedValue(
makePendingRecord({ status: MigrationReviewRecordStatus.IMPORTED })
);
await expect(
service.approve('rec-uuid-001', dto, 'idem-key-001', 1)
).rejects.toThrow(BusinessException);
});
it('ควร throw NotFoundException เมื่อ Project ไม่พบ', async () => {
mockReviewRepo.findOne.mockResolvedValue(makePendingRecord());
mockProjectRepo.findOne.mockResolvedValue(null);
await expect(
service.approve('rec-uuid-001', dto, 'idem-key-001', 1)
).rejects.toThrow(NotFoundException);
});
it('ควรอนุมัติ record สำเร็จ — เรียก importCorrespondence และบันทึก AuditLog', async () => {
const record = makePendingRecord();
mockReviewRepo.findOne.mockResolvedValue(record);
mockReviewRepo.save.mockResolvedValue({
...record,
status: MigrationReviewRecordStatus.IMPORTED,
});
const result = await service.approve(
'rec-uuid-001',
dto,
'idem-key-001',
99
);
// ตรวจสอบการเรียก importCorrespondence
expect(mockMigration.importCorrespondence).toHaveBeenCalledWith(
expect.objectContaining({
documentNumber: 'CORR-001',
subject: 'Test Subject',
category: 'CORR',
projectId: 5,
}),
'idem-key-001',
99
);
// ตรวจสอบสถานะที่ถูก save เป็น IMPORTED
expect(mockReviewRepo.save).toHaveBeenCalledWith(
expect.objectContaining({
status: MigrationReviewRecordStatus.IMPORTED,
})
);
// ตรวจสอบ AuditLog ถูกสร้าง (T025)
expect(mockAuditLogRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
documentPublicId: record.publicId,
confirmedByUserId: 99,
})
);
expect(mockAuditLogRepo.save).toHaveBeenCalled();
expect(result.record).toBeDefined();
expect(result.importResult).toBeDefined();
});
it('T025: AuditLog ควรบันทึก aiSuggestionJson และ humanOverrideJson (AI vs Human diff)', async () => {
const record = makePendingRecord({
extractedMetadata: { subject: 'AI Guess' },
confidenceScore: 0.85,
});
mockReviewRepo.findOne.mockResolvedValue(record);
mockReviewRepo.save.mockResolvedValue({
...record,
status: MigrationReviewRecordStatus.IMPORTED,
});
await service.approve(
'rec-uuid-001',
{ ...dto, finalMetadata: { subject: 'Human Corrected' } },
'idem-key-002',
42
);
expect(mockAuditLogRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
confidenceScore: expect.closeTo(0.85),
humanOverrideJson: { subject: 'Human Corrected' },
})
);
});
it('ควรไม่ throw แม้ auditLogRepo.save จะล้มเหลว (error ถูก swallow)', async () => {
const record = makePendingRecord();
mockReviewRepo.findOne.mockResolvedValue(record);
mockReviewRepo.save.mockResolvedValue({
...record,
status: MigrationReviewRecordStatus.IMPORTED,
});
mockAuditLogRepo.save.mockRejectedValueOnce(
new Error('DB connection lost')
);
// ไม่ควร throw ออกมา — saveApprovalAuditLog มี try/catch
await expect(
service.approve('rec-uuid-001', dto, 'idem-key-003', 1)
).resolves.toBeDefined();
});
});
});
+381
View File
@@ -0,0 +1,381 @@
// File: src/modules/ai/ai-ingest.service.ts
// Change Log
// - 2026-05-14: เพิ่ม service สำหรับ Legacy Migration staging queue ตาม ADR-023.
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import {
BusinessException,
NotFoundException,
ValidationException,
} from '../../common/exceptions';
import { FileStorageService } from '../../common/file-storage/file-storage.service';
import { MigrationService } from '../migration/migration.service';
import {
AiAuditLog,
AiAuditStatus as AiStatus,
} from './entities/ai-audit-log.entity';
import { Project } from '../project/entities/project.entity';
import { Organization } from '../organization/entities/organization.entity';
import { CorrespondenceType } from '../correspondence/entities/correspondence-type.entity';
import { AiQueueService } from './ai-queue.service';
import {
ApproveLegacyMigrationDto,
LegacyMigrationIngestDto,
LegacyMigrationQueueQueryDto,
LegacyMigrationRecordDto,
} from './dto/legacy-migration.dto';
import {
MigrationReviewRecord,
MigrationReviewRecordStatus,
} from './entities/migration-review.entity';
export interface MigrationReviewResponse {
publicId: string;
batchId: string;
originalFileName: string;
sourceAttachmentPublicId?: string;
extractedMetadata?: Record<string, unknown>;
confidenceScore?: number;
status: MigrationReviewRecordStatus;
errorReason?: string;
createdAt: Date;
updatedAt: Date;
}
export interface PaginatedMigrationReviewResponse {
items: MigrationReviewResponse[];
total: number;
page: number;
limit: number;
totalPages: number;
}
@Injectable()
export class AiIngestService {
private readonly logger = new Logger(AiIngestService.name);
private readonly maxFileSize = 50 * 1024 * 1024;
private readonly allowedMimeTypes = new Set<string>([
'application/pdf',
'application/x-pdf',
'application/octet-stream',
]);
constructor(
private readonly configService: ConfigService,
private readonly fileStorageService: FileStorageService,
private readonly aiQueueService: AiQueueService,
private readonly migrationService: MigrationService,
@InjectRepository(MigrationReviewRecord)
private readonly reviewRepo: Repository<MigrationReviewRecord>,
@InjectRepository(AiAuditLog)
private readonly auditLogRepo: Repository<AiAuditLog>,
@InjectRepository(Project)
private readonly projectRepo: Repository<Project>,
@InjectRepository(Organization)
private readonly organizationRepo: Repository<Organization>,
@InjectRepository(CorrespondenceType)
private readonly correspondenceTypeRepo: Repository<CorrespondenceType>
) {}
async ingest(
dto: LegacyMigrationIngestDto,
files: Express.Multer.File[]
): Promise<{ batchId: string; queued: number; queueJobId?: string }> {
const records = this.parseRecords(dto.records);
const serviceUserId = this.getServiceUserId();
const createdRecords: MigrationReviewRecord[] = [];
const filePublicIds: string[] = [];
for (let index = 0; index < files.length; index += 1) {
const file = files[index];
this.validateFile(file);
const attachment = await this.fileStorageService.upload(
file,
serviceUserId
);
const recordInput = this.matchRecord(records, file.originalname, index);
filePublicIds.push(attachment.publicId);
createdRecords.push(
this.reviewRepo.create({
batchId: dto.batchId,
originalFileName: recordInput.originalFileName ?? file.originalname,
sourceAttachmentPublicId: attachment.publicId,
tempAttachmentId: attachment.id,
extractedMetadata: recordInput.extractedMetadata,
confidenceScore: recordInput.confidenceScore,
status: this.deriveStatus(recordInput),
errorReason: recordInput.errorReason,
})
);
}
if (files.length === 0) {
for (const recordInput of records) {
createdRecords.push(
this.reviewRepo.create({
batchId: dto.batchId,
originalFileName:
recordInput.originalFileName ?? `${dto.batchId}-record.json`,
extractedMetadata: recordInput.extractedMetadata,
confidenceScore: recordInput.confidenceScore,
status: this.deriveStatus(recordInput),
errorReason: recordInput.errorReason,
})
);
}
}
if (createdRecords.length === 0) {
throw new ValidationException('At least one file or record is required');
}
const saved = await this.reviewRepo.save(createdRecords);
const queueJobId = await this.aiQueueService.enqueueIngest({
batchId: dto.batchId,
filePublicIds,
source: dto.source === 'folder-watcher' ? 'folder-watcher' : 'api',
});
this.logger.log(
`AI legacy migration batch ${dto.batchId} created ${saved.length} staging records`
);
return { batchId: dto.batchId, queued: saved.length, queueJobId };
}
async listQueue(
query: LegacyMigrationQueueQueryDto
): Promise<PaginatedMigrationReviewResponse> {
const page = query.page ?? 1;
const limit = query.limit ?? 20;
const qb = this.reviewRepo.createQueryBuilder('record');
if (query.status) {
qb.where('record.status = :status', { status: query.status });
}
qb.orderBy('record.createdAt', 'DESC')
.skip((page - 1) * limit)
.take(limit);
const [items, total] = await qb.getManyAndCount();
return {
items: items.map((item) => this.toResponse(item)),
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
async approve(
publicId: string,
dto: ApproveLegacyMigrationDto,
idempotencyKey: string,
userId: number
): Promise<{ record: MigrationReviewResponse; importResult: unknown }> {
if (!idempotencyKey) {
throw new ValidationException('Idempotency-Key header is required');
}
const record = await this.reviewRepo.findOne({ where: { publicId } });
if (!record) {
throw new NotFoundException('MigrationReviewRecord', publicId);
}
if (record.status !== MigrationReviewRecordStatus.PENDING) {
throw new BusinessException(
'AI_MIGRATION_RECORD_NOT_PENDING',
`Migration review record ${publicId} is ${record.status}`,
'รายการนี้ไม่อยู่ในสถานะรอตรวจสอบ',
['รีเฟรชรายการ staging queue', 'ตรวจสอบสถานะล่าสุดก่อนอนุมัติ']
);
}
const project = await this.resolveProject(dto.projectPublicId);
const correspondenceType = await this.resolveCorrespondenceType(
dto.categoryCode
);
const sender = dto.senderOrganizationPublicId
? await this.resolveOrganization(dto.senderOrganizationPublicId)
: undefined;
const receiver = dto.receiverOrganizationPublicId
? await this.resolveOrganization(dto.receiverOrganizationPublicId)
: undefined;
const importResult = await this.migrationService.importCorrespondence(
{
documentNumber: dto.documentNumber,
subject: dto.subject,
category: correspondenceType.typeCode,
migratedBy: 'AI_STAGING_APPROVAL',
batchId: record.batchId,
projectId: project.id,
senderId: sender?.id,
receiverId: receiver?.id,
issuedDate: dto.issuedDate,
receivedDate: dto.receivedDate,
body: dto.body,
tempAttachmentId: record.tempAttachmentId,
aiConfidence:
record.confidenceScore === undefined
? undefined
: Number(record.confidenceScore),
details: {
aiSuggestion: record.extractedMetadata,
humanOverride: dto.finalMetadata,
},
},
idempotencyKey,
userId
);
record.status = MigrationReviewRecordStatus.IMPORTED;
record.extractedMetadata = {
...(record.extractedMetadata ?? {}),
humanOverride: dto.finalMetadata ?? {},
};
const saved = await this.reviewRepo.save(record);
// T025: บันทึก AuditLog เปรียบเทียบ AI suggestion กับ Human override (ADR-023)
await this.saveApprovalAuditLog({
documentPublicId: record.publicId,
aiSuggestionJson: record.extractedMetadata,
humanOverrideJson: (dto.finalMetadata as Record<string, unknown>) ?? {},
confirmedByUserId: userId,
confidenceScore:
record.confidenceScore === undefined
? undefined
: Number(record.confidenceScore),
});
return { record: this.toResponse(saved), importResult };
}
private parseRecords(
records: LegacyMigrationIngestDto['records']
): LegacyMigrationRecordDto[] {
if (!records) return [];
if (Array.isArray(records)) return records;
try {
const parsed = JSON.parse(records) as unknown;
if (!Array.isArray(parsed)) {
throw new Error('records must be an array');
}
return parsed as LegacyMigrationRecordDto[];
} catch (error) {
throw new ValidationException(
`Invalid records payload: ${error instanceof Error ? error.message : String(error)}`
);
}
}
private matchRecord(
records: LegacyMigrationRecordDto[],
originalFileName: string,
index: number
): LegacyMigrationRecordDto {
return (
records.find((record) => record.originalFileName === originalFileName) ??
records[index] ??
{}
);
}
private deriveStatus(
record: LegacyMigrationRecordDto
): MigrationReviewRecordStatus {
if (record.status) return record.status;
if (record.errorReason) return MigrationReviewRecordStatus.REJECTED;
if (
record.confidenceScore !== undefined &&
Number(record.confidenceScore) < 0.6
) {
return MigrationReviewRecordStatus.REJECTED;
}
return MigrationReviewRecordStatus.PENDING;
}
private validateFile(file: Express.Multer.File): void {
if (file.size > this.maxFileSize) {
throw new ValidationException('File exceeds 50MB limit');
}
if (!this.allowedMimeTypes.has(file.mimetype)) {
throw new ValidationException(`Unsupported file type: ${file.mimetype}`);
}
}
private getServiceUserId(): number {
return this.configService.get<number>('AI_SERVICE_USER_ID') ?? 1;
}
private async resolveProject(publicId: string): Promise<Project> {
const project = await this.projectRepo.findOne({ where: { publicId } });
if (!project) throw new NotFoundException('Project', publicId);
return project;
}
private async resolveOrganization(publicId: string): Promise<Organization> {
const organization = await this.organizationRepo.findOne({
where: { publicId },
});
if (!organization) throw new NotFoundException('Organization', publicId);
return organization;
}
private async resolveCorrespondenceType(
typeCode: string
): Promise<CorrespondenceType> {
const type = await this.correspondenceTypeRepo.findOne({
where: [{ typeCode }, { typeName: typeCode }],
});
if (!type) throw new NotFoundException('CorrespondenceType', typeCode);
return type;
}
/** T025: บันทึก AuditLog สำหรับการอนุมัติ Human-in-the-loop (ADR-023 Rule 5) */
private async saveApprovalAuditLog(data: {
documentPublicId: string;
aiSuggestionJson?: Record<string, unknown>;
humanOverrideJson: Record<string, unknown>;
confirmedByUserId: number;
confidenceScore?: number;
}): Promise<void> {
try {
const log = this.auditLogRepo.create({
documentPublicId: data.documentPublicId,
aiModel: 'legacy-migration',
status: AiStatus.SUCCESS,
aiSuggestionJson: data.aiSuggestionJson,
humanOverrideJson: data.humanOverrideJson,
confirmedByUserId: data.confirmedByUserId,
confidenceScore: data.confidenceScore,
});
await this.auditLogRepo.save(log);
} catch (err: unknown) {
this.logger.error(
`Failed to save approval audit log for ${data.documentPublicId}: ${err instanceof Error ? err.message : String(err)}`
);
}
}
private toResponse(record: MigrationReviewRecord): MigrationReviewResponse {
return {
publicId: record.publicId,
batchId: record.batchId,
originalFileName: record.originalFileName,
sourceAttachmentPublicId: record.sourceAttachmentPublicId,
extractedMetadata: record.extractedMetadata,
confidenceScore:
record.confidenceScore === undefined
? undefined
: Number(record.confidenceScore),
status: record.status,
errorReason: record.errorReason,
createdAt: record.createdAt,
updatedAt: record.updatedAt,
};
}
}
@@ -0,0 +1,95 @@
// File: src/modules/ai/ai-queue.service.ts
// Change Log
// - 2026-05-14: เพิ่ม service กลางสำหรับส่งงาน AI เข้า BullMQ ตาม ADR-023.
// - 2026-05-14: เพิ่ม JSDoc idempotency contract สำหรับทุก enqueue method (💡 S3).
import { Injectable } from '@nestjs/common';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue, JobsOptions } from 'bullmq';
import {
QUEUE_AI_INGEST,
QUEUE_AI_RAG,
QUEUE_AI_VECTOR_DELETION,
} from '../common/constants/queue.constants';
/** Payload สำหรับงาน ingest เอกสารเก่าเข้า AI Pipeline */
export interface AiIngestJobPayload {
batchId: string;
filePublicIds: string[];
source: 'api' | 'folder-watcher';
}
/** Payload สำหรับงาน RAG Query ที่ต้องเข้าคิวบน Desk-5439 */
export interface AiRagJobPayload {
requestPublicId: string;
userPublicId: string;
projectPublicId: string;
query: string;
}
/** Payload สำหรับลบ vector ใน Qdrant แบบ eventual consistency */
export interface AiVectorDeletionJobPayload {
documentPublicId: string;
requestedByUserPublicId: string;
}
/** จัดการคิว AI ทั้งหมดให้อยู่หลัง BullMQ ตาม ADR-008/ADR-023 */
@Injectable()
export class AiQueueService {
private readonly defaultOptions: JobsOptions = {
attempts: 3,
backoff: { type: 'exponential', delay: 5000 },
removeOnComplete: true,
removeOnFail: 200,
};
constructor(
@InjectQueue(QUEUE_AI_INGEST)
private readonly ingestQueue: Queue<AiIngestJobPayload>,
@InjectQueue(QUEUE_AI_RAG)
private readonly ragQueue: Queue<AiRagJobPayload>,
@InjectQueue(QUEUE_AI_VECTOR_DELETION)
private readonly vectorDeletionQueue: Queue<AiVectorDeletionJobPayload>
) {}
/**
* ส่ง batch migration เข้า queue เพื่อไม่ให้ request thread ทำงานหนัก
* @idempotency `jobId = batchId:source` — การส่ง batch เดิมซ้ำจะคืน job ID เดิม ไม่สร้างงานใหม่
*/
async enqueueIngest(payload: AiIngestJobPayload): Promise<string> {
const job = await this.ingestQueue.add('legacy-migration-ingest', payload, {
...this.defaultOptions,
jobId: `${payload.batchId}:${payload.source}`,
});
return String(job.id);
}
/**
* ส่ง RAG query เข้า queue ที่ processor จะกำหนด concurrency = 1
* @idempotency `jobId = requestPublicId` — ถ้า request เดิม (UUID เดียวกัน) ถูก submit ซ้ำ BullMQ จะไม่สร้างงานใหม่
*/
async enqueueRagQuery(payload: AiRagJobPayload): Promise<string> {
const job = await this.ragQueue.add('rag-query', payload, {
...this.defaultOptions,
jobId: payload.requestPublicId,
});
return String(job.id);
}
/**
* ส่งคำสั่งลบ vector เข้า queue เพื่อ retry ได้เมื่อ Qdrant ไม่พร้อม
* @idempotency `jobId = documentPublicId` — การลบเอกสารเดิมซ้ำจะถูก de-duplicate โดย BullMQ
*/
async enqueueVectorDeletion(
payload: AiVectorDeletionJobPayload
): Promise<string> {
const job = await this.vectorDeletionQueue.add(
'delete-document-vectors',
payload,
{
...this.defaultOptions,
jobId: payload.documentPublicId,
}
);
return String(job.id);
}
}
+348
View File
@@ -0,0 +1,348 @@
// File: src/modules/ai/ai-rag.service.ts
// Change Log
// - 2026-05-14: เพิ่ม AiRagService สำหรับ BullMQ-backed RAG pipeline ตาม ADR-023 Phase 4.
// - 2026-05-14: แก้ไข corruption ในไฟล์ทั้งหมด — rewrite clean version.
// - 2026-05-14: ย้าย PROMPT_CONTEXT_LIMIT เป็น instance field ที่อ่านจาก RAG_CONTEXT_LIMIT_CHARS (💡 S1).
// Service จัดการ RAG query ผ่าน Ollama + AiQdrantService (project-isolated)
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRedis } from '@nestjs-modules/ioredis';
import Redis from 'ioredis';
import axios from 'axios';
import { AiQdrantService } from './qdrant.service';
/** ผลลัพธ์ของ RAG query แต่ละรายการที่ถูก reference ในคำตอบ */
export interface AiRagCitation {
pointId: string | number;
score: number;
docType?: string;
docNumber?: string;
snippet?: string;
}
/** ผลลัพธ์สมบูรณ์ของ RAG job ที่เก็บใน Redis */
export interface AiRagJobResult {
requestPublicId: string;
status: 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled';
answer?: string;
citations?: AiRagCitation[];
confidence?: number;
usedFallbackModel?: boolean;
errorMessage?: string;
completedAt?: string;
}
/** TTL สำหรับ Redis result key (5 นาที) */
const RAG_RESULT_TTL_SECONDS = 300;
/** TTL สำหรับ Redis active-job key ต่อ user (5 นาที) */
const RAG_ACTIVE_JOB_TTL_SECONDS = 300;
/** บริการหลักสำหรับประมวลผล RAG query ผ่าน Ollama และ Qdrant (ADR-023) */
@Injectable()
export class AiRagService {
private readonly logger = new Logger(AiRagService.name);
private readonly ollamaUrl: string;
private readonly ollamaModel: string;
private readonly ollamaEmbedModel: string;
private readonly timeoutMs: number;
/** จำนวนอักขระสูงสุดของ context ที่ส่งให้ LLM — ปรับได้ผ่าน RAG_CONTEXT_LIMIT_CHARS */
private readonly promptContextLimit: number;
constructor(
private readonly configService: ConfigService,
private readonly qdrantService: AiQdrantService,
@InjectRedis() private readonly redis: Redis
) {
this.ollamaUrl = this.configService.get<string>(
'OLLAMA_URL',
'http://localhost:11434'
);
this.ollamaModel = this.configService.get<string>(
'OLLAMA_RAG_MODEL',
'gemma2'
);
this.ollamaEmbedModel = this.configService.get<string>(
'OLLAMA_EMBED_MODEL',
'nomic-embed-text'
);
this.timeoutMs = this.configService.get<number>('RAG_TIMEOUT_MS', 30000);
this.promptContextLimit = this.configService.get<number>(
'RAG_CONTEXT_LIMIT_CHARS',
3000
);
}
// ─── Job State Management ────────────────────────────────────────────────────
/** กำหนด result key สำหรับ Redis */
private resultKey(requestPublicId: string): string {
return `ai:rag:result:${requestPublicId}`;
}
/** กำหนด active-job key ต่อ user สำหรับ FR-009 (1 active job per user) */
private activeJobKey(userPublicId: string): string {
return `ai:rag:active:${userPublicId}`;
}
/** กำหนด cancel-flag key สำหรับ T022 (AbortController) */
cancelKey(requestPublicId: string): string {
return `ai:rag:cancel:${requestPublicId}`;
}
/** ตรวจสอบว่า user มี active job อยู่ หรือไม่ (FR-009) */
async getActiveJob(userPublicId: string): Promise<string | null> {
return this.redis.get(this.activeJobKey(userPublicId));
}
/** ลงทะเบียน job ใหม่ให้ user เพื่อ enforce FR-009 */
async registerActiveJob(
userPublicId: string,
requestPublicId: string
): Promise<void> {
await this.redis.setex(
this.activeJobKey(userPublicId),
RAG_ACTIVE_JOB_TTL_SECONDS,
requestPublicId
);
await this.saveJobResult({
requestPublicId,
status: 'pending',
});
}
/** บันทึกผลลัพธ์ job ลง Redis */
async saveJobResult(result: AiRagJobResult): Promise<void> {
await this.redis.setex(
this.resultKey(result.requestPublicId),
RAG_RESULT_TTL_SECONDS,
JSON.stringify(result)
);
}
/** ดึงผลลัพธ์ job จาก Redis */
async getJobResult(requestPublicId: string): Promise<AiRagJobResult | null> {
const raw = await this.redis.get(this.resultKey(requestPublicId));
if (!raw) return null;
try {
return JSON.parse(raw) as AiRagJobResult;
} catch {
this.logger.warn(
`Corrupted RAG result in Redis — requestPublicId=${requestPublicId}`
);
return null;
}
}
/** ยกเลิก job โดยตั้ง cancel flag ใน Redis */
async cancelJob(requestPublicId: string): Promise<void> {
await this.redis.setex(
this.cancelKey(requestPublicId),
RAG_RESULT_TTL_SECONDS,
'1'
);
const current = await this.getJobResult(requestPublicId);
if (
current &&
(current.status === 'pending' || current.status === 'processing')
) {
await this.saveJobResult({ ...current, status: 'cancelled' });
}
}
/** ลบ active-job ของ user เมื่อ job เสร็จหรือถูกยกเลิก */
async clearActiveJob(userPublicId: string): Promise<void> {
await this.redis.del(this.activeJobKey(userPublicId));
}
// ─── Core Processing ─────────────────────────────────────────────────────────
/**
* ประมวลผล RAG query:
* 1. Embed คำถาม
* 2. ค้นหา Qdrant ด้วย project isolation (T020 — enforced in AiQdrantService.searchByProject)
* 3. Build prompt จาก context
* 4. Generate คำตอบผ่าน Ollama (รองรับ AbortSignal สำหรับ T022)
*/
async processQuery(
requestPublicId: string,
question: string,
projectPublicId: string,
userPublicId: string,
signal?: AbortSignal
): Promise<void> {
await this.saveJobResult({ requestPublicId, status: 'processing' });
try {
// ตรวจสอบว่าถูกยกเลิกก่อนเริ่มทำงาน
const cancelFlag = await this.redis.get(this.cancelKey(requestPublicId));
if (cancelFlag || signal?.aborted) {
await this.saveJobResult({ requestPublicId, status: 'cancelled' });
await this.clearActiveJob(userPublicId);
return;
}
// 1. สร้าง embedding สำหรับคำถาม
const queryVector = await this.embed(question, signal);
// ตรวจสอบ cancel อีกครั้งหลัง embed
if (
signal?.aborted ||
(await this.redis.get(this.cancelKey(requestPublicId)))
) {
await this.saveJobResult({ requestPublicId, status: 'cancelled' });
await this.clearActiveJob(userPublicId);
return;
}
// 2. ค้นหา Qdrant โดยบังคับ projectPublicId (T020 — FR-002)
const searchResults = await this.qdrantService.searchByProject(
queryVector,
projectPublicId,
10
);
// 3. สร้าง context จาก search results
const context = this.buildContext(searchResults);
// ตรวจสอบ cancel ก่อนเรียก LLM (ใช้ทรัพยากรมากที่สุด)
if (
signal?.aborted ||
(await this.redis.get(this.cancelKey(requestPublicId)))
) {
await this.saveJobResult({ requestPublicId, status: 'cancelled' });
await this.clearActiveJob(userPublicId);
return;
}
// 4. Generate คำตอบผ่าน Ollama (ส่ง signal เพื่อรองรับ T022)
const { answer, usedFallback } = await this.generateAnswer(
this.sanitizeInput(question),
context,
signal
);
const citations: AiRagCitation[] = searchResults.map((r) => ({
pointId: r.pointId,
score: r.score,
docType: r.payload['doc_type'] as string | undefined,
docNumber: r.payload['doc_number'] as string | undefined,
snippet: (r.payload['content_preview'] as string | undefined)?.slice(
0,
200
),
}));
const confidence = searchResults.length > 0 ? searchResults[0].score : 0;
await this.saveJobResult({
requestPublicId,
status: 'completed',
answer,
citations,
confidence,
usedFallbackModel: usedFallback,
completedAt: new Date().toISOString(),
});
this.logger.log(
`RAG query completed — requestPublicId=${requestPublicId}, confidence=${confidence.toFixed(3)}`
);
} catch (err: unknown) {
const errMsg = err instanceof Error ? err.message : String(err);
this.logger.error(
`RAG query failed — requestPublicId=${requestPublicId}: ${errMsg}`
);
await this.saveJobResult({
requestPublicId,
status: 'failed',
errorMessage: errMsg,
completedAt: new Date().toISOString(),
});
} finally {
await this.clearActiveJob(userPublicId);
}
}
// ─── Private Helpers ─────────────────────────────────────────────────────────
/** สร้าง embedding vector สำหรับข้อความ */
private async embed(text: string, signal?: AbortSignal): Promise<number[]> {
const response = await axios.post<{ embedding: number[] }>(
`${this.ollamaUrl}/api/embeddings`,
{ model: this.ollamaEmbedModel, prompt: text },
{ timeout: this.timeoutMs, signal }
);
return response.data.embedding;
}
/** Generate คำตอบจาก Ollama (รองรับ AbortSignal สำหรับ T022 FR-011) */
private async generateAnswer(
question: string,
context: string,
signal?: AbortSignal
): Promise<{ answer: string; usedFallback: boolean }> {
const prompt = this.buildPrompt(question, context);
try {
const response = await axios.post<{ response: string }>(
`${this.ollamaUrl}/api/generate`,
{ model: this.ollamaModel, prompt, stream: false },
{ timeout: this.timeoutMs, signal }
);
return { answer: response.data.response ?? '', usedFallback: false };
} catch (err: unknown) {
// ถ้าเป็น cancellation error ให้ re-throw เพื่อให้ processQuery จัดการ
if (
axios.isCancel(err) ||
(err instanceof Error && err.name === 'CanceledError')
) {
throw err;
}
this.logger.warn(
`Ollama generation failed — model=${this.ollamaModel}: ${err instanceof Error ? err.message : String(err)}`
);
return { answer: 'ไม่พบข้อมูลในเอกสารที่ระบุ', usedFallback: true };
}
}
/** สร้าง context string จาก search results ให้ไม่เกิน PROMPT_CONTEXT_LIMIT */
private buildContext(
results: Array<{ payload: Record<string, unknown> }>
): string {
let context = '';
for (const r of results) {
const docType = (r.payload['doc_type'] as string) ?? '';
const docNumber = (r.payload['doc_number'] as string) ?? '';
const preview = (r.payload['content_preview'] as string) ?? '';
const header = `[${docType}${docNumber ? ` - ${docNumber}` : ''}]`;
const snippet = `${header}\n${preview}\n\n`;
if ((context + snippet).length > this.promptContextLimit) break;
context += snippet;
}
return context.trim();
}
/** สร้าง prompt สำหรับ LLM ตาม RAG pattern ของโครงการ */
private buildPrompt(question: string, context: string): string {
return [
'คุณเป็นผู้ช่วยผู้เชี่ยวชาญด้านเอกสารโครงการก่อสร้าง',
'ตอบคำถามโดยอ้างอิงจากเอกสารที่ให้มาเท่านั้น ห้ามตอบจากความรู้ทั่วไป',
'หากข้อมูลในเอกสารไม่เพียงพอ ให้แจ้งว่า "ไม่พบข้อมูลในเอกสารที่ระบุ"',
'',
'=== เอกสารอ้างอิง ===',
context,
'',
'=== คำถาม ===',
question,
].join('\n');
}
/** กรอง input เพื่อป้องกัน prompt injection */
private sanitizeInput(text: string): string {
return text
.replace(/<CONTEXT_START>|<CONTEXT_END>/gi, '')
.replace(/ignore previous instructions/gi, '')
.replace(/system:/gi, '')
.slice(0, 500);
}
}
+204 -10
View File
@@ -1,17 +1,26 @@
// File: src/modules/ai/ai.controller.ts
// Controller สำหรับ AI Gateway Endpoints (ADR-018, ADR-020)
// Change Log
// - 2026-05-14: เพิ่ม Legacy Migration staging endpoints ตาม ADR-023.
// - 2026-05-14: ย้าย DeleteAuditLogsQueryDto ไป dto/ folder; ลบ authHeader passthrough (🟢 LOW-1/LOW-2).
// Controller สำหรับ AI Gateway Endpoints (ADR-023)
import {
Controller,
Post,
Get,
Patch,
Delete,
Body,
Param,
Query,
Headers,
HttpCode,
HttpStatus,
UseGuards,
UseInterceptors,
UploadedFiles,
} from '@nestjs/common';
import { FilesInterceptor } from '@nestjs/platform-express';
import { Throttle } from '@nestjs/throttler';
import {
ApiTags,
@@ -22,27 +31,50 @@ import {
ApiQuery,
} from '@nestjs/swagger';
import { AiService, ExtractionResult, PaginatedResult } from './ai.service';
import {
AiIngestService,
MigrationReviewResponse,
PaginatedMigrationReviewResponse,
} from './ai-ingest.service';
import { AiRagService } from './ai-rag.service';
import { AiQueueService } from './ai-queue.service';
import { AiRagQueryDto } from './dto/ai-rag-query.dto';
import { ExtractDocumentDto } from './dto/extract-document.dto';
import { AiCallbackDto } from './dto/ai-callback.dto';
import { MigrationUpdateDto } from './dto/migration-update.dto';
import { MigrationQueryDto } from './dto/migration-query.dto';
import {
ApproveLegacyMigrationDto,
LegacyMigrationIngestDto,
LegacyMigrationQueueQueryDto,
} from './dto/legacy-migration.dto';
import { MigrationLog } from './entities/migration-log.entity';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { RbacGuard } from '../../common/guards/rbac.guard';
import { ParseUuidPipe } from '../../common/pipes/parse-uuid.pipe';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { User } from '../user/entities/user.entity';
import { ServiceAccountGuard } from './guards/service-account.guard';
import { v7 as uuidv7 } from 'uuid';
import { DeleteAuditLogsQueryDto } from './dto/delete-audit-logs.dto';
@ApiTags('AI Gateway')
@Controller('ai')
export class AiController {
constructor(private readonly aiService: AiService) {}
constructor(
private readonly aiService: AiService,
private readonly aiIngestService: AiIngestService,
private readonly aiRagService: AiRagService,
private readonly aiQueueService: AiQueueService
) {}
// --- Real-time Extraction (User Upload) ---
@Post('extract')
@UseGuards(JwtAuthGuard)
@UseGuards(JwtAuthGuard, RbacGuard)
@ApiBearerAuth()
@RequirePermission('ai.extract')
@Throttle({ default: { limit: 5, ttl: 60000 } }) // Rate limit: 5 requests/minute (ADR-020)
@ApiOperation({
summary:
@@ -60,6 +92,7 @@ export class AiController {
// --- Webhook Callback จาก n8n (Service Account) ---
@Post('callback')
@UseGuards(ServiceAccountGuard) // T029: กำหนด guard ที่ controller layer (ADR-016)
@ApiOperation({
summary: 'AI Callback Endpoint — รับผลลัพธ์จาก n8n หลัง AI ประมวลผลเสร็จ',
description:
@@ -67,7 +100,8 @@ export class AiController {
})
@ApiHeader({
name: 'Authorization',
description: 'Bearer {AI_N8N_AUTH_TOKEN} — Service Account Token จาก n8n',
description:
'Bearer {AI_N8N_SERVICE_TOKEN} — Service Account Token จาก n8n',
required: true,
})
@ApiHeader({
@@ -77,14 +111,9 @@ export class AiController {
})
async handleCallback(
@Body() dto: AiCallbackDto,
@Headers('authorization') authHeader: string,
@Headers('x-ai-source') aiSource: string
): Promise<{ message: string }> {
await this.aiService.handleWebhookCallback(
dto,
aiSource ?? 'unknown',
authHeader
);
await this.aiService.handleWebhookCallback(dto, aiSource ?? 'unknown');
return { message: 'Callback processed successfully' };
}
@@ -150,4 +179,169 @@ export class AiController {
): Promise<MigrationLog> {
return this.aiService.updateMigrationLog(publicId, dto, user.user_id);
}
// ─── AI Audit Log Endpoints (Phase 5 — T026) ──────────────────────────────
@Delete('audit-logs')
@UseGuards(JwtAuthGuard, RbacGuard)
@ApiBearerAuth()
@RequirePermission('system.manage_all')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary:
'AI Audit Log Hard Delete — ลบ log ถาวร (SYSTEM_ADMIN เท่านั้น) (T026)',
description:
'ต้องระบุ documentPublicId หรือ olderThanDays อย่างน้อยหนึ่งอย่าง',
})
async deleteAuditLogs(
@Query() query: DeleteAuditLogsQueryDto
): Promise<{ deleted: number }> {
return this.aiService.deleteAuditLogs({
documentPublicId: query.documentPublicId,
olderThanDays: query.olderThanDays,
});
}
// ─── RAG Query Endpoints (Phase 4 — FR-009, FR-010, FR-011) ────────────────
@Post('rag/query')
@UseGuards(JwtAuthGuard, RbacGuard)
@ApiBearerAuth()
@Throttle({ default: { limit: 5, ttl: 60000 } }) // Rate limit: 5 requests/minute per user (FR-010)
@RequirePermission('rag.query')
@HttpCode(HttpStatus.ACCEPTED)
@ApiOperation({
summary:
'RAG Query — ส่ง query เข้า BullMQ เพื่อประมวลผลแบบ async (FR-009, FR-010)',
description:
'ส่งคำถาม RAG เข้าคิว BullMQ (concurrency=1 บน Desk-5439) แล้วคืน requestPublicId สำหรับ polling',
})
@ApiHeader({
name: 'Idempotency-Key',
description: 'Unique key สำหรับ request',
required: true,
})
async submitRagQuery(
@Body() dto: AiRagQueryDto,
@CurrentUser() user: User,
@Headers('idempotency-key') _idempotencyKey: string
): Promise<{ requestPublicId: string; jobId: string; status: string }> {
// ตรวจสอบว่า user มี active job อยู่แล้วหรือไม่ (FR-009: 1 active job per user)
const activeJob = await this.aiRagService.getActiveJob(
String(user.publicId ?? user.user_id)
);
if (activeJob) {
return { requestPublicId: activeJob, jobId: '', status: 'queued' };
}
// สร้าง requestPublicId ใหม่ (ADR-019: UUID)
const requestPublicId = uuidv7();
const userPublicId = String(user.publicId ?? user.user_id);
// ลงทะเบียน job ใน Redis ก่อนส่งเข้า BullMQ
await this.aiRagService.registerActiveJob(userPublicId, requestPublicId);
// ส่ง job เข้า BullMQ ตาม ADR-008
const jobId = await this.aiQueueService.enqueueRagQuery({
requestPublicId,
userPublicId,
projectPublicId: dto.projectPublicId,
query: dto.question,
});
return { requestPublicId, jobId, status: 'queued' };
}
@Get('rag/jobs/:requestPublicId')
@UseGuards(JwtAuthGuard, RbacGuard)
@ApiBearerAuth()
@RequirePermission('rag.query')
@ApiOperation({
summary: 'RAG Job Status — ดูสถานะและผลลัพธ์ของ RAG query (polling)',
})
@ApiParam({
name: 'requestPublicId',
description: 'requestPublicId จาก submit endpoint',
})
async getRagJobStatus(
@Param('requestPublicId', ParseUuidPipe) requestPublicId: string
) {
const result = await this.aiRagService.getJobResult(requestPublicId);
if (!result) {
return { requestPublicId, status: 'not_found' };
}
return result;
}
@Delete('rag/jobs/:requestPublicId')
@UseGuards(JwtAuthGuard, RbacGuard)
@ApiBearerAuth()
@RequirePermission('rag.query')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({
summary: 'RAG Job Cancel — ยกเลิก RAG job ที่กำลังประมวลผล (T022, FR-011)',
})
@ApiParam({
name: 'requestPublicId',
description: 'requestPublicId ของ job ที่ต้องการยกเลิก',
})
async cancelRagJob(
@Param('requestPublicId', ParseUuidPipe) requestPublicId: string
): Promise<void> {
await this.aiRagService.cancelJob(requestPublicId);
}
@Post('legacy-migration/ingest')
@UseGuards(ServiceAccountGuard)
@UseInterceptors(FilesInterceptor('files', 25))
@ApiOperation({
summary: 'Legacy Migration: ingest PDF batch into AI staging queue',
})
@ApiHeader({
name: 'Authorization',
description: 'Bearer {AI_N8N_SERVICE_TOKEN}',
required: true,
})
async ingestLegacyMigration(
@Body() dto: LegacyMigrationIngestDto,
@UploadedFiles() files: Express.Multer.File[] = []
) {
return this.aiIngestService.ingest(dto, files);
}
@Get('legacy-migration/queue')
@UseGuards(JwtAuthGuard, RbacGuard)
@ApiBearerAuth()
@RequirePermission('ai.migration_manage')
@ApiOperation({ summary: 'Legacy Migration: list AI staging queue records' })
async getLegacyMigrationQueue(
@Query() query: LegacyMigrationQueueQueryDto
): Promise<PaginatedMigrationReviewResponse> {
return this.aiIngestService.listQueue(query);
}
@Post('legacy-migration/queue/:publicId/approve')
@UseGuards(JwtAuthGuard, RbacGuard)
@ApiBearerAuth()
@RequirePermission('ai.migration_manage')
@ApiOperation({ summary: 'Legacy Migration: approve AI staging record' })
@ApiParam({ name: 'publicId', description: 'Migration review publicId' })
@ApiHeader({
name: 'Idempotency-Key',
description: 'Unique key for this approval/import operation',
required: true,
})
async approveLegacyMigrationRecord(
@Param('publicId', ParseUuidPipe) publicId: string,
@Body() dto: ApproveLegacyMigrationDto,
@Headers('idempotency-key') idempotencyKey: string,
@CurrentUser() user: User
): Promise<{ record: MigrationReviewResponse; importResult: unknown }> {
return this.aiIngestService.approve(
publicId,
dto,
idempotencyKey,
user.user_id
);
}
}
+53 -3
View File
@@ -1,22 +1,55 @@
// File: src/modules/ai/ai.module.ts
// Module สำหรับ AI Gateway — ลงทะเบียน Services และ Controllers (ADR-018, ADR-020)
// Change Log
// - 2026-05-14: เพิ่ม BullMQ/Qdrant/Service Account foundation สำหรับ ADR-023.
// Module สำหรับ AI Gateway — ลงทะเบียน Services และ Controllers (ADR-023)
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { HttpModule } from '@nestjs/axios';
import { ConfigModule } from '@nestjs/config';
import { BullModule } from '@nestjs/bullmq';
import { AiController } from './ai.controller';
import { AiService } from './ai.service';
import { AiIngestService } from './ai-ingest.service';
import { AiQueueService } from './ai-queue.service';
import { AiQdrantService } from './qdrant.service';
import { AiValidationService } from './ai-validation.service';
import { AiRagService } from './ai-rag.service';
import { AiRagProcessor } from './processors/rag.processor';
import { AiVectorDeletionProcessor } from './processors/vector-deletion.processor';
import { MigrationLog } from './entities/migration-log.entity';
import { AiAuditLog } from './entities/ai-audit-log.entity';
import { MigrationReviewRecord } from './entities/migration-review.entity';
import { UserModule } from '../user/user.module';
import { MigrationModule } from '../migration/migration.module';
import { FileStorageModule } from '../../common/file-storage/file-storage.module';
import { Project } from '../project/entities/project.entity';
import { Organization } from '../organization/entities/organization.entity';
import { CorrespondenceType } from '../correspondence/entities/correspondence-type.entity';
import { RbacGuard } from '../../common/guards/rbac.guard';
import {
QUEUE_AI_INGEST,
QUEUE_AI_RAG,
QUEUE_AI_VECTOR_DELETION,
} from '../common/constants/queue.constants';
@Module({
imports: [
// Entities สำหรับ AI Module
TypeOrmModule.forFeature([MigrationLog, AiAuditLog]),
TypeOrmModule.forFeature([
MigrationLog,
AiAuditLog,
MigrationReviewRecord,
Project,
Organization,
CorrespondenceType,
]),
BullModule.registerQueue(
{ name: QUEUE_AI_INGEST },
{ name: QUEUE_AI_RAG },
{ name: QUEUE_AI_VECTOR_DELETION }
),
// HTTP Client สำหรับเรียก n8n Webhook (ADR-018: AI สื่อสารผ่าน API)
HttpModule.register({
@@ -29,14 +62,31 @@ import { RbacGuard } from '../../common/guards/rbac.guard';
// UserModule สำหรับ RbacGuard (ต้องการ UserService)
UserModule,
MigrationModule,
FileStorageModule,
],
controllers: [AiController],
providers: [
AiService,
AiIngestService,
AiQueueService,
AiQdrantService,
AiValidationService,
// Phase 4: RAG BullMQ pipeline (ADR-023)
AiRagService,
AiRagProcessor,
// Phase 5: Vector Deletion async processor (ADR-023 FR-008)
AiVectorDeletionProcessor,
// RbacGuard ต้องการ UserService จาก UserModule
RbacGuard,
],
exports: [AiService, AiValidationService],
exports: [
AiService,
AiIngestService,
AiQueueService,
AiQdrantService,
AiValidationService,
AiRagService,
],
})
export class AiModule {}
+5 -25
View File
@@ -114,19 +114,7 @@ describe('AiService', () => {
processingTimeMs: 5000,
};
const validAuthHeader = 'Bearer test-token';
it('ควรปฏิเสธ request เมื่อไม่มี Authorization header', async () => {
await expect(
service.handleWebhookCallback(validPayload, 'n8n', '')
).rejects.toThrow();
});
it('ควรปฏิเสธ request เมื่อ Token ไม่ถูกต้อง', async () => {
await expect(
service.handleWebhookCallback(validPayload, 'n8n', 'Bearer wrong-token')
).rejects.toThrow();
});
// หมายเหตุ: token validation ย้ายไป ServiceAccountGuard ที่ controller layer แล้ว (🟢 LOW-1)
it('ควร throw NotFoundException เมื่อ MigrationLog ไม่พบ', async () => {
mockMigrationLogRepo.findOne.mockResolvedValue(null);
@@ -138,7 +126,7 @@ describe('AiService', () => {
});
await expect(
service.handleWebhookCallback(validPayload, 'n8n', validAuthHeader)
service.handleWebhookCallback(validPayload, 'n8n')
).rejects.toBeInstanceOf(NotFoundException);
});
@@ -159,7 +147,7 @@ describe('AiService', () => {
reasons: [],
});
await service.handleWebhookCallback(validPayload, 'n8n', validAuthHeader);
await service.handleWebhookCallback(validPayload, 'n8n');
expect(mockMigrationLogRepo.save).toHaveBeenCalled();
expect(mockAuditLogRepo.create).toHaveBeenCalled();
@@ -183,11 +171,7 @@ describe('AiService', () => {
reasons: [],
});
await service.handleWebhookCallback(
highConfidencePayload,
'n8n',
validAuthHeader
);
await service.handleWebhookCallback(highConfidencePayload, 'n8n');
const calls = mockMigrationLogRepo.save.mock.calls as [MigrationLog][];
const savedLog = calls[0][0];
@@ -215,11 +199,7 @@ describe('AiService', () => {
reasons: ['AI processing failed'],
});
await service.handleWebhookCallback(
failedPayload,
'n8n',
validAuthHeader
);
await service.handleWebhookCallback(failedPayload, 'n8n');
const calls = mockMigrationLogRepo.save.mock.calls as [MigrationLog][];
const savedLog = calls[0][0];
+54 -16
View File
@@ -87,7 +87,9 @@ export class AiService {
this.n8nWebhookUrl =
this.configService.get<string>('AI_N8N_WEBHOOK_URL') ?? '';
this.n8nAuthToken =
this.configService.get<string>('AI_N8N_AUTH_TOKEN') ?? '';
this.configService.get<string>('AI_N8N_SERVICE_TOKEN') ??
this.configService.get<string>('AI_N8N_AUTH_TOKEN') ??
'';
this.timeoutMs = this.configService.get<number>('AI_TIMEOUT_MS') ?? 30000;
this.callbackBaseUrl =
this.configService.get<string>('APP_BASE_URL') ?? 'http://localhost:3001';
@@ -219,22 +221,10 @@ export class AiService {
async handleWebhookCallback(
payload: AiCallbackDto,
aiSource: string,
authHeader: string
aiSource: string
): Promise<void> {
// 1. ตรวจสอบ Service Account Authentication (ADR-018 Rule 2)
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw new ValidationException(
'Missing or invalid Authorization header for AI callback'
);
}
const token = authHeader.substring(7);
if (token !== this.n8nAuthToken) {
throw new ValidationException('Invalid AI service account token');
}
// 2. ค้นหา MigrationLog ด้วย publicId (ADR-019: ใช้ UUID เท่านั้น)
// ServiceAccountGuard ผ่านการ validate Bearer token แล้วที่ controller layer (🟢 LOW-1)
// 1. ค้นหา MigrationLog ด้วย publicId (ADR-019: ใช้ UUID เท่านั้น)
const migrationLog = await this.migrationLogRepo.findOne({
where: { publicId: payload.migrationLogPublicId },
});
@@ -369,6 +359,54 @@ export class AiService {
return updated;
}
// T026: Hard-delete AuditLogs (SYSTEM_ADMIN only — ADR-023)
/**
* ลบ AiAuditLog แบบ hard delete ตามเกณฑ์ที่กำหนด
* @returns จำนวน record ที่ถูกลบ
*/
async deleteAuditLogs(criteria: {
documentPublicId?: string;
olderThanDays?: number;
}): Promise<{ deleted: number }> {
if (!criteria.documentPublicId && !criteria.olderThanDays) {
throw new ValidationException(
'At least one deletion criterion (documentPublicId or olderThanDays) is required'
);
}
const qb = this.aiAuditLogRepo.createQueryBuilder('log');
if (criteria.documentPublicId) {
qb.andWhere('log.documentPublicId = :docId', {
docId: criteria.documentPublicId,
});
}
if (criteria.olderThanDays) {
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - criteria.olderThanDays);
qb.andWhere('log.createdAt < :cutoff', { cutoff });
}
const count = await qb.getCount();
if (count === 0) return { deleted: 0 };
// ใช้ delete().execute() เพื่อออก SQL เดียว แทน N individual DELETEs
const deleteQb = this.aiAuditLogRepo.createQueryBuilder('log').delete();
if (criteria.olderThanDays) {
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - criteria.olderThanDays);
deleteQb.andWhere('log.createdAt < :cutoff', { cutoff });
}
await deleteQb.execute();
this.logger.log(
`Deleted ${count} AI audit log(s) — criteria=${JSON.stringify(criteria)}`
);
return { deleted: count };
}
// --- Helper: บันทึก AuditLog ---
private async saveAuditLog(data: {
@@ -0,0 +1,21 @@
// File: src/modules/ai/dto/ai-rag-query.dto.ts
// Change Log
// - 2026-05-14: เพิ่ม DTO สำหรับ BullMQ RAG Query ตาม ADR-023 Phase 4.
import { IsNotEmpty, IsString, IsUUID, MaxLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
/** DTO สำหรับส่ง RAG query เข้า BullMQ queue (FR-009, FR-010) */
export class AiRagQueryDto {
@ApiProperty({
description: 'คำถามสำหรับ RAG ไม่เกิน 500 ตัวอักษร',
maxLength: 500,
})
@IsString()
@IsNotEmpty()
@MaxLength(500)
question!: string;
@ApiProperty({ description: 'publicId ของโครงการ (ADR-019) เพื่อ isolation' })
@IsUUID()
projectPublicId!: string;
}
@@ -0,0 +1,26 @@
// File: src/modules/ai/dto/delete-audit-logs.dto.ts
// Change Log
// - 2026-05-14: ย้าย DeleteAuditLogsQueryDto จาก ai.controller.ts เข้า dto/ folder (🟢 LOW-2).
import { IsInt, IsOptional, IsUUID, Max, Min } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiPropertyOptional } from '@nestjs/swagger';
/** Query params สำหรับ DELETE /ai/audit-logs (T026) */
export class DeleteAuditLogsQueryDto {
@ApiPropertyOptional({ description: 'UUID ของเอกสารที่ต้องการลบ log' })
@IsOptional()
@IsUUID()
documentPublicId?: string;
@ApiPropertyOptional({
description: 'ลบ log ที่เก่ากว่า N วัน (1-365)',
minimum: 1,
maximum: 365,
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(365)
olderThanDays?: number;
}
@@ -0,0 +1,122 @@
// File: src/modules/ai/dto/legacy-migration.dto.ts
// Change Log
// - 2026-05-14: เพิ่ม DTO สำหรับ ADR-023 legacy migration staging endpoints.
import {
IsEnum,
IsNotEmpty,
IsNumber,
IsObject,
IsOptional,
IsString,
IsUUID,
Max,
Min,
} from 'class-validator';
import { Transform } from 'class-transformer';
import { MigrationReviewRecordStatus } from '../entities/migration-review.entity';
export class LegacyMigrationRecordDto {
@IsString()
@IsOptional()
originalFileName?: string;
@IsObject()
@IsOptional()
extractedMetadata?: Record<string, unknown>;
@Transform(({ value }: { value: unknown }) =>
value === undefined || value === null || value === ''
? undefined
: Number(value)
)
@IsNumber()
@Min(0)
@Max(1)
@IsOptional()
confidenceScore?: number;
@IsEnum(MigrationReviewRecordStatus)
@IsOptional()
status?: MigrationReviewRecordStatus;
@IsString()
@IsOptional()
errorReason?: string;
}
export class LegacyMigrationIngestDto {
@IsString()
@IsNotEmpty()
batchId!: string;
@IsString()
@IsOptional()
source?: 'api' | 'folder-watcher';
@IsOptional()
records?: LegacyMigrationRecordDto[] | string;
}
export class LegacyMigrationQueueQueryDto {
@Transform(({ value }: { value: unknown }) =>
value === undefined ? 1 : Number(value)
)
@IsNumber()
@Min(1)
@IsOptional()
page?: number;
@Transform(({ value }: { value: unknown }) =>
value === undefined ? 20 : Number(value)
)
@IsNumber()
@Min(1)
@Max(100)
@IsOptional()
limit?: number;
@IsEnum(MigrationReviewRecordStatus)
@IsOptional()
status?: MigrationReviewRecordStatus;
}
export class ApproveLegacyMigrationDto {
@IsString()
@IsNotEmpty()
documentNumber!: string;
@IsString()
@IsNotEmpty()
subject!: string;
@IsString()
@IsNotEmpty()
categoryCode!: string;
@IsUUID()
projectPublicId!: string;
@IsUUID()
@IsOptional()
senderOrganizationPublicId?: string;
@IsUUID()
@IsOptional()
receiverOrganizationPublicId?: string;
@IsString()
@IsOptional()
issuedDate?: string;
@IsString()
@IsOptional()
receivedDate?: string;
@IsString()
@IsOptional()
body?: string;
@IsObject()
@IsOptional()
finalMetadata?: Record<string, unknown>;
}
@@ -1,5 +1,7 @@
// File: src/modules/ai/entities/ai-audit-log.entity.ts
// Entity สำหรับตาราง ai_audit_logs — บันทึก AI Interaction ทุกครั้งตาม ADR-018 Rule 5
// Change Log
// - 2026-05-14: เพิ่ม ADR-023 feedback fields โดยคง legacy audit fields ไว้ช่วงเปลี่ยนผ่าน.
// Entity สำหรับตาราง ai_audit_logs — บันทึก AI Interaction และ feedback ตาม ADR-023
import {
Entity,
@@ -32,6 +34,24 @@ export class AiAuditLog extends UuidBaseEntity {
@Column({ name: 'ai_model', type: 'varchar', length: 50 })
aiModel!: string;
// ชื่อ Local Model ตาม ADR-023 development feedback log
@Index('idx_ai_audit_model_name')
@Column({ name: 'model_name', type: 'varchar', length: 100, nullable: true })
modelName?: string;
// JSON ที่ AI แนะนำก่อนมนุษย์ตรวจสอบ
@Column({ name: 'ai_suggestion_json', type: 'json', nullable: true })
aiSuggestionJson?: Record<string, unknown>;
// JSON ที่มนุษย์ยืนยันหรือแก้ไขจริง
@Column({ name: 'human_override_json', type: 'json', nullable: true })
humanOverrideJson?: Record<string, unknown>;
// User ID ภายในของผู้ยืนยันผล AI
@Index('idx_ai_audit_confirmed_by')
@Column({ name: 'confirmed_by_user_id', type: 'int', nullable: true })
confirmedByUserId?: number;
// เวลาประมวลผลเป็น milliseconds
@Column({ name: 'processing_time_ms', type: 'int', nullable: true })
processingTimeMs?: number;
@@ -0,0 +1,71 @@
// File: src/modules/ai/entities/migration-review.entity.ts
// Change Log
// - 2026-05-14: เพิ่ม entity staging queue สำหรับ Unified AI Architecture.
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
UpdateDateColumn,
VersionColumn,
} from 'typeorm';
import { UuidBaseEntity } from '../../../common/entities/uuid-base.entity';
export enum MigrationReviewRecordStatus {
PENDING = 'PENDING',
IMPORTED = 'IMPORTED',
REJECTED = 'REJECTED',
}
/** รายการเอกสารเก่าที่รอ human-in-the-loop validation ก่อน commit */
@Entity('migration_review_queue')
export class MigrationReviewRecord extends UuidBaseEntity {
@PrimaryGeneratedColumn()
id!: number;
@Index('idx_migration_review_batch')
@Column({ name: 'batch_id', type: 'varchar', length: 100 })
batchId!: string;
@Column({ name: 'original_file_name', type: 'varchar', length: 255 })
originalFileName!: string;
@Column({ name: 'source_attachment_public_id', type: 'uuid', nullable: true })
sourceAttachmentPublicId?: string;
@Column({ name: 'temp_attachment_id', type: 'int', nullable: true })
tempAttachmentId?: number;
@Column({ name: 'extracted_metadata', type: 'json', nullable: true })
extractedMetadata?: Record<string, unknown>;
@Column({
name: 'confidence_score',
type: 'decimal',
precision: 4,
scale: 3,
nullable: true,
})
confidenceScore?: number;
@Index('idx_migration_review_status')
@Column({
type: 'enum',
enum: MigrationReviewRecordStatus,
default: MigrationReviewRecordStatus.PENDING,
})
status!: MigrationReviewRecordStatus;
@Column({ name: 'error_reason', type: 'text', nullable: true })
errorReason?: string;
@VersionColumn({ name: 'version' })
version!: number;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt!: Date;
}
@@ -0,0 +1,53 @@
// File: src/modules/ai/guards/service-account.guard.ts
// Change Log
// - 2026-05-14: เพิ่ม Guard ตรวจสอบ n8n Service Account Token ตาม ADR-023.
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { timingSafeEqual } from 'crypto';
import { Request } from 'express';
interface ServiceAccountRequest extends Request {
headers: Request['headers'] & {
authorization?: string;
};
}
/** ตรวจสอบ Bearer token ของ n8n service account โดยไม่ใช้ user JWT */
@Injectable()
export class ServiceAccountGuard implements CanActivate {
constructor(private readonly configService: ConfigService) {}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<ServiceAccountRequest>();
const authorization = request.headers.authorization;
const expectedToken =
this.configService.get<string>('AI_N8N_SERVICE_TOKEN') ??
this.configService.get<string>('AI_N8N_AUTH_TOKEN') ??
'';
if (!expectedToken || !authorization?.startsWith('Bearer ')) {
throw new UnauthorizedException('Invalid service account token');
}
const actualToken = authorization.slice('Bearer '.length);
if (!this.isEqual(actualToken, expectedToken)) {
throw new UnauthorizedException('Invalid service account token');
}
return true;
}
private isEqual(actual: string, expected: string): boolean {
const actualBuffer = Buffer.from(actual);
const expectedBuffer = Buffer.from(expected);
return (
actualBuffer.length === expectedBuffer.length &&
timingSafeEqual(actualBuffer, expectedBuffer)
);
}
}
@@ -0,0 +1,206 @@
// File: src/modules/ai/processors/rag.processor.spec.ts
// Change Log
// - 2026-05-14: เพิ่ม Unit Test สำหรับ AiRagProcessor — ตรวจสอบ concurrency=1 และ AbortController (T030).
import { Test, TestingModule } from '@nestjs/testing';
import { Job } from 'bullmq';
import { AiRagProcessor } from './rag.processor';
import { AiRagService } from '../ai-rag.service';
import { AiRagJobPayload } from '../ai-queue.service';
import { QUEUE_AI_RAG } from '../../common/constants/queue.constants';
// ─── Helpers ─────────────────────────────────────────────────────────────────
/** สร้าง mock BullMQ Job สำหรับ RAG query */
function makeJob(
overrides: Partial<{
id: string;
requestPublicId: string;
userPublicId: string;
projectPublicId: string;
query: string;
}> = {}
): Job<AiRagJobPayload> {
return {
id: overrides.id ?? 'job-001',
data: {
requestPublicId: overrides.requestPublicId ?? 'req-uuid-001',
userPublicId: overrides.userPublicId ?? 'user-uuid-001',
projectPublicId: overrides.projectPublicId ?? 'proj-uuid-001',
query: overrides.query ?? 'What is the project scope?',
},
} as unknown as Job<AiRagJobPayload>;
}
// ─── Tests ───────────────────────────────────────────────────────────────────
describe('AiRagProcessor', () => {
let processor: AiRagProcessor;
let ragService: jest.Mocked<AiRagService>;
const mockRagService: Partial<jest.Mocked<AiRagService>> = {
processQuery: jest.fn().mockResolvedValue(undefined),
getActiveJob: jest.fn().mockResolvedValue(null),
registerActiveJob: jest.fn().mockResolvedValue(undefined),
clearActiveJob: jest.fn().mockResolvedValue(undefined),
cancelJob: jest.fn().mockResolvedValue(undefined),
getJobResult: jest.fn().mockResolvedValue(null),
saveJobResult: jest.fn().mockResolvedValue(undefined),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AiRagProcessor,
{ provide: AiRagService, useValue: mockRagService },
],
}).compile();
processor = module.get<AiRagProcessor>(AiRagProcessor);
ragService = module.get(AiRagService);
jest.clearAllMocks();
});
// ─── T030 Core: ตรวจสอบ concurrency=1 metadata ──────────────────────────
it('ควรมี @Processor decorator พร้อม queue name ที่ถูกต้อง', () => {
// ตรวจสอบ QUEUE_AI_RAG constant ตรงกับที่ใช้ใน processor
expect(QUEUE_AI_RAG).toBe('ai-rag-query');
});
it('ควร process job และเรียก ragService.processQuery ด้วย AbortSignal', async () => {
const job = makeJob({
requestPublicId: 'req-abc',
userPublicId: 'user-abc',
query: 'test question',
});
await processor.process(job);
expect(ragService.processQuery).toHaveBeenCalledTimes(1);
expect(ragService.processQuery).toHaveBeenCalledWith(
'req-abc',
'test question',
'proj-uuid-001',
'user-abc',
expect.any(AbortSignal) // T022: AbortSignal ต้องถูกส่งเข้าไปด้วย
);
});
it('ควร cleanup AbortController หลัง process เสร็จ (no memory leak)', async () => {
const job = makeJob({ requestPublicId: 'req-cleanup' });
await processor.process(job);
// หลัง process เสร็จ ไม่ควรมี controller ค้างอยู่
const aborted = processor.abortJob('req-cleanup');
expect(aborted).toBe(false); // ถูก cleanup แล้ว
});
it('ควร cleanup AbortController แม้ว่า processQuery จะ throw error', async () => {
const job = makeJob({ requestPublicId: 'req-error' });
ragService.processQuery.mockRejectedValueOnce(new Error('Ollama timeout'));
// ไม่ควร throw ออกมา (processor จัดการ error ภายใน)
await expect(processor.process(job)).rejects.toThrow('Ollama timeout');
// ยังต้อง cleanup controller
const aborted = processor.abortJob('req-error');
expect(aborted).toBe(false);
});
it('abortJob ควรคืน true เมื่อ job กำลัง processing', async () => {
const requestPublicId = 'req-inprogress';
// จำลอง processQuery ที่ใช้เวลานาน
ragService.processQuery.mockImplementationOnce(
(_reqId, _q, _proj, _user, signal) =>
new Promise<void>((_resolve, reject) => {
if (signal) {
signal.addEventListener('abort', () =>
reject(new Error('aborted'))
);
}
// ไม่ resolve เพื่อจำลอง long-running job
})
);
const job = makeJob({ requestPublicId });
const processingPromise = processor.process(job).catch(() => {
/* expected */
});
// รอให้ controller ถูก register ก่อน abort
await new Promise((r) => setTimeout(r, 10));
const result = processor.abortJob(requestPublicId);
expect(result).toBe(true);
await processingPromise;
});
it('abortJob ควรคืน false เมื่อไม่มี job ที่ requestPublicId นั้น', () => {
const result = processor.abortJob('non-existent-job');
expect(result).toBe(false);
});
// ─── T030 Stress: ตรวจสอบ 1-active-job-per-user enforcement ─────────────
describe('1-Active-Job-Per-User Enforcement (FR-009 concurrency=1)', () => {
it('ควรส่งคืน requestPublicId เดิมเมื่อ user มี active job อยู่แล้ว', async () => {
const existingJobId = 'existing-request-uuid';
ragService.getActiveJob.mockResolvedValueOnce(existingJobId);
const activeJob = await ragService.getActiveJob('user-uuid-999');
expect(activeJob).toBe(existingJobId);
});
it('ควรสามารถ registerActiveJob และ getActiveJob ได้สำหรับ user คนเดียว', async () => {
const userPublicId = 'user-stress-test';
const requestPublicId = 'new-req-uuid';
ragService.getActiveJob.mockResolvedValueOnce(null);
ragService.registerActiveJob.mockResolvedValueOnce(undefined);
ragService.getActiveJob.mockResolvedValueOnce(requestPublicId);
// ไม่มี active job เริ่มต้น
const beforeJob = await ragService.getActiveJob(userPublicId);
expect(beforeJob).toBeNull();
// ลงทะเบียน job
await ragService.registerActiveJob(userPublicId, requestPublicId);
// ตรวจสอบว่า active job ถูกเก็บแล้ว
const afterJob = await ragService.getActiveJob(userPublicId);
expect(afterJob).toBe(requestPublicId);
});
it('stress test: 10 requests ต่อเนื่อง — ควรพบ active job ตั้งแต่ request ที่ 2 เป็นต้นไป', async () => {
const userPublicId = 'user-concurrent';
const firstRequestId = 'first-req-uuid';
// ครั้งแรกไม่มี active job, หลังจากนั้นมี
ragService.getActiveJob
.mockResolvedValueOnce(null) // request 1: ไม่มี active job
.mockResolvedValue(firstRequestId); // request 2-10: พบ active job
ragService.registerActiveJob.mockResolvedValue(undefined);
// Request 1: ไม่มี active job — ควรสร้างใหม่
const req1Active = await ragService.getActiveJob(userPublicId);
expect(req1Active).toBeNull();
await ragService.registerActiveJob(userPublicId, firstRequestId);
// Requests 2-10: ทุกคำขอควรพบ active job เดิม
const concurrentChecks = await Promise.all(
Array.from({ length: 9 }, () => ragService.getActiveJob(userPublicId))
);
concurrentChecks.forEach((activeId) => {
expect(activeId).toBe(firstRequestId);
});
// ยืนยันว่า registerActiveJob ถูกเรียกแค่ครั้งเดียว (job เดียว)
expect(ragService.registerActiveJob).toHaveBeenCalledTimes(1);
});
});
});
@@ -0,0 +1,86 @@
// File: src/modules/ai/processors/rag.processor.ts
// Change Log
// - 2026-05-14: เพิ่ม BullMQ Processor สำหรับ RAG query ตาม ADR-023 Phase 4 (T018, T022).
// Processor นี้ใช้ concurrency = 1 เพื่อป้องกัน OOM บน Desk-5439 (FR-009)
import { Processor, WorkerHost, OnWorkerEvent } from '@nestjs/bullmq';
import { Logger } from '@nestjs/common';
import { Job } from 'bullmq';
import { AiRagService } from '../ai-rag.service';
import { AiRagJobPayload } from '../ai-queue.service';
import { QUEUE_AI_RAG } from '../../common/constants/queue.constants';
/**
* Processor สำหรับ RAG query queue
* - concurrency: 1 เพื่อป้องกัน VRAM overflow บน Desk-5439 (FR-009, Research Unknown 3)
* - รองรับ AbortController เพื่อยกเลิก LLM generation เมื่อ client disconnect (T022, FR-011)
*/
@Processor(QUEUE_AI_RAG, { concurrency: 1 })
export class AiRagProcessor extends WorkerHost {
private readonly logger = new Logger(AiRagProcessor.name);
/** Map สำหรับเก็บ AbortController ของแต่ละ job (T022) */
private readonly abortControllers = new Map<string, AbortController>();
constructor(private readonly ragService: AiRagService) {
super();
}
/** ประมวลผล RAG query job */
async process(job: Job<AiRagJobPayload>): Promise<void> {
const { requestPublicId, userPublicId, projectPublicId, query } = job.data;
this.logger.log(
`Processing RAG job — requestPublicId=${requestPublicId}, user=${userPublicId}`
);
// สร้าง AbortController สำหรับ job นี้ (T022)
const controller = new AbortController();
this.abortControllers.set(requestPublicId, controller);
try {
await this.ragService.processQuery(
requestPublicId,
query,
projectPublicId,
userPublicId,
controller.signal
);
} finally {
this.abortControllers.delete(requestPublicId);
}
}
/**
* Abort การประมวลผล LLM สำหรับ job ที่ระบุ (T022 — FR-011)
* ถูกเรียกจาก AiRagService.cancelJob() ผ่าน Redis cancel flag
*/
abortJob(requestPublicId: string): boolean {
const controller = this.abortControllers.get(requestPublicId);
if (controller) {
controller.abort();
this.abortControllers.delete(requestPublicId);
this.logger.log(`Aborted RAG job — requestPublicId=${requestPublicId}`);
return true;
}
return false;
}
/** Log เมื่อ job เสร็จสมบูรณ์ */
@OnWorkerEvent('completed')
onCompleted(job: Job<AiRagJobPayload>): void {
this.logger.log(
`RAG job completed — jobId=${String(job.id)}, requestPublicId=${job.data.requestPublicId}`
);
}
/** Log และ cleanup เมื่อ job ล้มเหลว */
@OnWorkerEvent('failed')
onFailed(job: Job<AiRagJobPayload> | undefined, err: Error): void {
const id = job?.data?.requestPublicId ?? 'unknown';
// ยกเลิก abort controller ที่ค้างไว้
if (job?.data?.requestPublicId) {
this.abortControllers.delete(job.data.requestPublicId);
}
this.logger.error(`RAG job failed — requestPublicId=${id}: ${err.message}`);
}
}
@@ -0,0 +1,36 @@
// File: src/modules/ai/processors/vector-deletion.processor.ts
// Change Log
// - 2026-05-14: เพิ่ม BullMQ Processor สำหรับลบ vector ใน Qdrant แบบ async ตาม ADR-023 FR-008 (T027).
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Logger } from '@nestjs/common';
import { Job } from 'bullmq';
import { QUEUE_AI_VECTOR_DELETION } from '../../common/constants/queue.constants';
import { AiQdrantService } from '../qdrant.service';
import { AiVectorDeletionJobPayload } from '../ai-queue.service';
/**
* Processor สำหรับลบ vector ของเอกสารที่ถูกลบออกจาก Qdrant แบบ asynchronous
* รองรับ retry 3 ครั้ง (ADR-008 + FR-008) เพื่อ eventual consistency เมื่อ Qdrant ไม่พร้อม
*/
@Processor(QUEUE_AI_VECTOR_DELETION)
export class AiVectorDeletionProcessor extends WorkerHost {
private readonly logger = new Logger(AiVectorDeletionProcessor.name);
constructor(private readonly qdrantService: AiQdrantService) {
super();
}
async process(job: Job<AiVectorDeletionJobPayload>): Promise<void> {
const { documentPublicId, requestedByUserPublicId } = job.data;
this.logger.log(
`Vector deletion started — documentPublicId=${documentPublicId}, jobId=${String(job.id)}, requestedBy=${requestedByUserPublicId}`
);
await this.qdrantService.deleteByDocumentPublicId(documentPublicId);
this.logger.log(
`Vector deletion completed — documentPublicId=${documentPublicId}, jobId=${String(job.id)}`
);
}
}
+104
View File
@@ -0,0 +1,104 @@
// File: src/modules/ai/qdrant.service.ts
// Change Log
// - 2026-05-14: เพิ่ม Qdrant gateway สำหรับ AI Module พร้อม project payload filter.
// - 2026-05-14: เพิ่ม OnModuleInit เพื่อ auto-call ensureCollection() (💡 S2).
import {
Injectable,
Logger,
OnModuleInit,
ServiceUnavailableException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { QdrantClient } from '@qdrant/js-client-rest';
const AI_COLLECTION_NAME = 'lcbp3_vectors';
const AI_VECTOR_SIZE = 768;
export interface AiVectorSearchResult {
pointId: string | number;
score: number;
payload: Record<string, unknown>;
}
/** Gateway กลางสำหรับ Qdrant ที่บังคับ project_public_id ทุก search */
@Injectable()
export class AiQdrantService implements OnModuleInit {
private readonly logger = new Logger(AiQdrantService.name);
private readonly client: QdrantClient;
constructor(private readonly configService: ConfigService) {
const url =
this.configService.get<string>('AI_QDRANT_URL') ??
this.configService.get<string>('QDRANT_URL') ??
'http://localhost:6333';
this.client = new QdrantClient({ url });
}
/** เรียก ensureCollection() อัตโนมัติเมื่อโมดูลถูก bootstrap */
async onModuleInit(): Promise<void> {
try {
await this.ensureCollection();
} catch (err) {
this.logger.error(
`AiQdrantService: collection init failed — ${err instanceof Error ? err.message : String(err)}`
);
}
}
/** เตรียม collection และ tenant payload index สำหรับ project isolation */
async ensureCollection(): Promise<void> {
const collections = await this.client.getCollections();
const exists = collections.collections.some(
(collection) => collection.name === AI_COLLECTION_NAME
);
if (!exists) {
await this.client.createCollection(AI_COLLECTION_NAME, {
vectors: { size: AI_VECTOR_SIZE, distance: 'Cosine' },
});
await this.client.createPayloadIndex(AI_COLLECTION_NAME, {
field_name: 'project_public_id',
field_schema: { type: 'keyword', is_tenant: true } as Parameters<
QdrantClient['createPayloadIndex']
>[1]['field_schema'],
});
this.logger.log(`Created Qdrant collection ${AI_COLLECTION_NAME}`);
}
}
/** ค้นหา vector โดยบังคับ projectPublicId เพื่อป้องกันข้อมูลข้ามโครงการ */
async searchByProject(
vector: number[],
projectPublicId: string,
limit: number
): Promise<AiVectorSearchResult[]> {
if (!projectPublicId) {
throw new ServiceUnavailableException('AI_QDRANT_PROJECT_SCOPE_REQUIRED');
}
const results = await this.client.search(AI_COLLECTION_NAME, {
vector,
limit,
filter: {
must: [{ key: 'project_public_id', match: { value: projectPublicId } }],
},
with_payload: true,
});
return results.map((result) => ({
pointId: result.id,
score: result.score,
payload: result.payload ?? {},
}));
}
/** ลบ vector ของเอกสารด้วย publicId ผ่าน queue processor ในขั้นถัดไป */
async deleteByDocumentPublicId(documentPublicId: string): Promise<void> {
await this.client.delete(AI_COLLECTION_NAME, {
wait: true,
filter: {
must: [{ key: 'public_id', match: { value: documentPublicId } }],
},
});
}
}
@@ -0,0 +1,70 @@
{
"name": "LCBP3 ADR-023 Folder Watcher",
"nodes": [
{
"parameters": {
"path": "lcbp3-ai-folder-watcher",
"httpMethod": "POST",
"responseMode": "responseNode"
},
"id": "folder-watcher-webhook",
"name": "Watched Folder Trigger",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [240, 300]
},
{
"parameters": {
"method": "POST",
"url": "={{$env.DMS_API_URL}}/api/ai/legacy-migration/ingest",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "=Bearer {{$env.AI_N8N_SERVICE_TOKEN}}"
}
]
},
"sendBody": true,
"bodyParameters": {
"parameters": [
{
"name": "batchId",
"value": "={{$json.batchId}}"
},
{
"name": "source",
"value": "folder-watcher"
},
{
"name": "records",
"value": "={{JSON.stringify($json.records || [])}}"
}
]
}
},
"id": "post-to-dms",
"name": "POST to DMS AI Ingest",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4,
"position": [520, 300]
}
],
"connections": {
"Watched Folder Trigger": {
"main": [
[
{
"node": "POST to DMS AI Ingest",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
@@ -16,3 +16,12 @@ export const QUEUE_DISTRIBUTION = 'distribution';
/** Queue สำหรับ Veto Override Notifications (T068.5) */
export const QUEUE_VETO_NOTIFICATIONS = 'veto-notifications';
/** Queue สำหรับ Legacy Document Migration ผ่าน AI Pipeline (ADR-023) */
export const QUEUE_AI_INGEST = 'ai-ingest';
/** Queue สำหรับ RAG Query ที่ต้องจำกัด concurrency บน Desk-5439 (ADR-023) */
export const QUEUE_AI_RAG = 'ai-rag-query';
/** Queue สำหรับลบ vector ใน Qdrant แบบ asynchronous (ADR-023 FR-008) */
export const QUEUE_AI_VECTOR_DELETION = 'ai-vector-deletion';
@@ -2,12 +2,14 @@ import { Test, TestingModule } from '@nestjs/testing';
import { ServiceUnavailableException } from '@nestjs/common';
import { getRepositoryToken } from '@nestjs/typeorm';
import { getQueueToken } from '@nestjs/bullmq';
import { RagService } from '../rag.service';
import { QdrantService } from '../qdrant.service';
import { EmbeddingService } from '../embedding.service';
import { TyphoonService } from '../typhoon.service';
import { IngestionService } from '../ingestion.service';
import { DocumentChunk } from '../entities/document-chunk.entity';
import { QUEUE_AI_VECTOR_DELETION } from '../../common/constants/queue.constants';
const DEFAULT_REDIS_TOKEN = 'default_IORedisModuleConnectionToken';
@@ -41,6 +43,10 @@ const mockRedis = {
setex: jest.fn(),
};
const mockVectorDeletionQueue = {
add: jest.fn().mockResolvedValue({ id: 'mock-job-id' }),
};
describe('RagService', () => {
let service: RagService;
@@ -54,6 +60,10 @@ describe('RagService', () => {
{ provide: IngestionService, useValue: mockIngestion },
{ provide: getRepositoryToken(DocumentChunk), useValue: mockChunkRepo },
{ provide: DEFAULT_REDIS_TOKEN, useValue: mockRedis },
{
provide: getQueueToken(QUEUE_AI_VECTOR_DELETION),
useValue: mockVectorDeletionQueue,
},
],
}).compile();
+4 -1
View File
@@ -4,6 +4,7 @@ import { BullModule } from '@nestjs/bullmq';
import { ConfigModule } from '@nestjs/config';
import { DocumentChunk } from './entities/document-chunk.entity';
import { QUEUE_AI_VECTOR_DELETION } from '../common/constants/queue.constants';
import { EmbeddingService } from './embedding.service';
import { QdrantService } from './qdrant.service';
import { TyphoonService } from './typhoon.service';
@@ -30,7 +31,9 @@ const DLQ_DEFAULTS = {
BullModule.registerQueue(
{ name: 'rag-ocr', defaultJobOptions: DLQ_DEFAULTS },
{ name: 'rag-thai-preprocess', defaultJobOptions: DLQ_DEFAULTS },
{ name: 'rag-embedding', defaultJobOptions: DLQ_DEFAULTS }
{ name: 'rag-embedding', defaultJobOptions: DLQ_DEFAULTS },
// T028: Producer สำหรับ dispatch vector deletion jobs (ADR-023 FR-008)
{ name: QUEUE_AI_VECTOR_DELETION }
),
],
controllers: [RagController],
+24 -13
View File
@@ -6,6 +6,10 @@ import {
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { QUEUE_AI_VECTOR_DELETION } from '../common/constants/queue.constants';
import { AiVectorDeletionJobPayload } from '../ai/ai-queue.service';
import { InjectRedis } from '@nestjs-modules/ioredis';
import Redis from 'ioredis';
import { createHash } from 'crypto';
@@ -32,7 +36,9 @@ export class RagService {
private readonly ingestionService: IngestionService,
@InjectRepository(DocumentChunk)
private readonly chunkRepo: Repository<DocumentChunk>,
@InjectRedis() private readonly redis: Redis
@InjectRedis() private readonly redis: Redis,
@InjectQueue(QUEUE_AI_VECTOR_DELETION)
private readonly vectorDeletionQueue: Queue<AiVectorDeletionJobPayload>
) {}
async query(
@@ -184,19 +190,24 @@ export class RagService {
await this.qdrant.onModuleInit();
}
async deleteVectors(attachmentPublicId: string): Promise<void> {
async deleteVectors(
attachmentPublicId: string,
requestedByUserPublicId = 'system'
): Promise<void> {
// ลบ DocumentChunk ออกจาก DB แบบ synchronous (รวดเร็ว ไม่มี external dependency)
await this.chunkRepo.delete({ documentId: attachmentPublicId });
try {
await this.qdrant.deleteByDocumentId(attachmentPublicId);
} catch (err) {
this.logger.error(
`Qdrant delete failed for ${attachmentPublicId}`,
err instanceof Error ? err.stack : String(err)
);
}
await this.chunkRepo.manager.query(
`UPDATE attachments SET rag_status = 'PENDING', rag_last_error = NULL WHERE public_id = ?`,
[attachmentPublicId]
// T028: เปลี่ยน Qdrant deletion เป็น async ผ่าน BullMQ เพื่อ eventual consistency (FR-008)
await this.vectorDeletionQueue.add(
'delete-document-vectors',
{ documentPublicId: attachmentPublicId, requestedByUserPublicId },
{
jobId: attachmentPublicId,
attempts: 3,
backoff: { type: 'exponential', delay: 5000 },
}
);
this.logger.log(
`Vector deletion queued for attachment=${attachmentPublicId}`
);
}
@@ -35,6 +35,7 @@ import { NotificationModule } from '../notification/notification.module';
NotificationTriggerService,
MatrixManagementService,
InheritanceService,
TypeOrmModule,
],
})
export class ResponseCodeModule {}
@@ -56,39 +56,40 @@ export class ReviewTaskController {
// Evaluate consensus after completion (FR-010)
try {
const fullTask = (await this.reviewTaskService.findFullTaskContext(
publicId
)) as unknown as Record<string, unknown>;
const fullTask =
await this.reviewTaskService.findFullTaskContext(publicId);
const rfaRevision = fullTask.rfaRevision as
| Record<string, unknown>
| undefined;
// Cast to access dynamic properties from innerJoinAndMapOne safely without 'any'
const context = fullTask as unknown as {
rfaRevisionId: number;
rfaRevision?: {
correspondenceRevision?: {
publicId: string;
correspondence?: {
publicId: string;
projectId: number;
type?: {
id: number;
typeCode: string;
};
};
};
};
};
const corrRevision = rfaRevision?.correspondenceRevision as
| Record<string, unknown>
| undefined;
const rfaRevision = context.rfaRevision;
const corrRevision = rfaRevision?.correspondenceRevision;
const correspondence = corrRevision?.correspondence;
const correspondence = corrRevision?.correspondence as
| Record<string, unknown>
| undefined;
if (rfaRevision && correspondence) {
if (rfaRevision && corrRevision && correspondence) {
await this.consensusService.evaluateAfterTaskComplete(
fullTask.rfaRevisionId,
context.rfaRevisionId,
{
rfaPublicId: correspondence.publicId as string,
rfaRevisionPublicId: corrRevision.publicId as string,
projectId: correspondence.projectId as number,
documentTypeId: (
correspondence.type as Record<string, unknown> | undefined
)?.id as number | undefined,
documentTypeCode:
((correspondence.type as Record<string, unknown> | undefined)
?.typeCode as string | undefined) ?? 'RFA',
rfaPublicId: correspondence.publicId,
rfaRevisionPublicId: corrRevision.publicId,
projectId: correspondence.projectId,
documentTypeId: correspondence.type?.id,
documentTypeCode: correspondence.type?.typeCode ?? 'RFA',
}
);
}